Phase 6.3: SettingsForms on RHF + Zod

- SettingsCurrencies: RHF+Zod (code required max 3, name required, symbol required max 5)
- SettingsTaxes: RHF+Zod (name required, rate numeric 0-100, country max 2)
- SettingsSequences: RHF+Zod (name required, padding numeric 1-10)
- SettingsUsers: RHF+Zod (name required, email valid, password min 8)
- SettingsRoles: RHF+Zod for create form (name required)
- SettingsGroups: RHF+Zod for create form (name required, description optional)
- Error display under each field
- Preserved all existing functionality: CRUD, permissions, members
- Added 2 validation tests for SettingsCurrencies
This commit is contained in:
Agent Zero
2026-07-24 00:28:11 +02:00
parent 2931a850c0
commit c334d02989
7 changed files with 306 additions and 109 deletions
+51 -36
View File
@@ -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<typeof inviteSchema>;
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<any>(null);
const [confirmDelete, setConfirmDelete] = useState<any>(null);
const {
register,
handleSubmit,
reset,
formState: { errors, isSubmitting },
} = useForm<InviteFormData>({
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 (
<div className="p-6 max-w-4xl mx-auto" data-testid="settings-users-page">
@@ -164,7 +180,7 @@ export function SettingsUsersPage() {
<div className="p-6 max-w-4xl mx-auto" data-testid="settings-users-page">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-secondary-900">{t('settings.users')}</h1>
<Button onClick={() => setInviteOpen(true)} data-testid="invite-user-btn">
<Button onClick={() => { reset({ name: '', email: '', password: '', role_id: '' }); setInviteOpen(true); }} data-testid="invite-user-btn">
{t('settings.inviteUser')}
</Button>
</div>
@@ -228,48 +244,47 @@ export function SettingsUsersPage() {
)}
<Modal open={inviteOpen} onClose={() => setInviteOpen(false)} title={t('settings.inviteUser')}>
<div className="space-y-4" data-testid="invite-user-form">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" data-testid="invite-user-form">
<Input
label={t('settings.name')}
type="text"
value={inviteName}
onChange={(e) => setInviteName(e.target.value)}
{...register('name')}
error={errorMsg(errors.name?.message)}
placeholder={t('settings.name')}
data-testid="invite-name"
/>
<Input
label={t('settings.inviteEmail')}
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
{...register('email')}
error={errorMsg(errors.email?.message)}
placeholder="neu.mitarbeiter@firma.de"
data-testid="invite-email"
/>
<Input
label={t('auth.password')}
type="password"
value={invitePassword}
onChange={(e) => setInvitePassword(e.target.value)}
{...register('password')}
error={errorMsg(errors.password?.message)}
placeholder="********"
data-testid="invite-password"
/>
<Select
label={t('settings.inviteRole')}
options={roleOptions}
value={inviteRoleId}
onChange={(e) => setInviteRoleId(e.target.value)}
{...register('role_id')}
/>
<div className="flex justify-end gap-3">
<Button variant="secondary" onClick={() => setInviteOpen(false)}>{t('common.cancel')}</Button>
<Button
onClick={handleInvite}
isLoading={createUserMutation.isPending}
type="submit"
isLoading={isSubmitting}
data-testid="send-invite-btn"
>
{t('settings.inviteUser')}
</Button>
</div>
</div>
</form>
</Modal>
<ConfirmDialog