feat(invitations): flujo de invitacion de clientes por email
Backend:
- Nuevo modelo Prisma ClientInvitation con token unico, expiracion
y estados (pending/accepted/expired).
- Migracion: 20260511213955_add_client_invitations
- Service client-invitations.service.ts: crear invitacion,
validar token, registrar desde invitacion (reutiliza logica
de creacion de tenant + usuario de despacho.service).
- Controller + routes: POST /invitations/client (admin),
GET /invitations/client/validate/:token (publico),
POST /invitations/client/register/:token (publico),
GET /invitations/client (admin).
- Email template client-invitation.ts con link a
/invitacion/registro/{token}.
- Agregado sendClientInvitation a email.service.
Frontend:
- Pagina /invitacion/registro/[token] para que el invitado
complete registro (nombre, password, despacho, RFC, perfil).
- Pagina /admin/invitar-cliente para que admin global envie
invitaciones y vea el historial.
- Hooks useCreateInvitation, useValidateInvitationToken,
useRegisterFromInvitation, useClientInvitations.
- API client lib/api/client-invitations.ts.
Infra:
- PM2 ecosystem.config.js: usa node --import tsx con
kill_timeout aumentado a 15s para evitar EADDRINUSE.
- React Query retry=2 con delay exponencial para resiliencia.
Refs: docs/CAMBIOS-2026-05-09.md
This commit is contained in:
142
apps/web/app/(dashboard)/admin/invitar-cliente/page.tsx
Normal file
142
apps/web/app/(dashboard)/admin/invitar-cliente/page.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Header } from '@/components/layouts/header';
|
||||
import { useCreateInvitation, useClientInvitations } from '@/lib/hooks/use-client-invitations';
|
||||
import { Button, Input, Card, CardContent, CardHeader, CardTitle } from '@horux/shared-ui';
|
||||
import { format } from 'date-fns';
|
||||
import { es } from 'date-fns/locale';
|
||||
|
||||
export default function InvitarClientePage() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [nombreDespacho, setNombreDespacho] = useState('');
|
||||
const [rfc, setRfc] = useState('');
|
||||
const [message, setMessage] = useState<{ kind: 'ok' | 'error'; text: string } | null>(null);
|
||||
|
||||
const createMut = useCreateInvitation();
|
||||
const { data: invitations, isLoading } = useClientInvitations();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setMessage(null);
|
||||
if (!email.trim()) {
|
||||
setMessage({ kind: 'error', text: 'El email es requerido' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await createMut.mutateAsync({ email, nombreDespacho, rfc });
|
||||
setMessage({ kind: 'ok', text: 'Invitación enviada exitosamente' });
|
||||
setEmail('');
|
||||
setNombreDespacho('');
|
||||
setRfc('');
|
||||
} catch (err: any) {
|
||||
setMessage({ kind: 'error', text: err.response?.data?.message || 'Error al enviar invitación' });
|
||||
}
|
||||
};
|
||||
|
||||
const statusBadge = (status: string) => {
|
||||
const map: Record<string, string> = {
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
accepted: 'bg-green-100 text-green-800',
|
||||
expired: 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
return (
|
||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${map[status] || 'bg-gray-100'}`}>
|
||||
{status === 'pending' ? 'Pendiente' : status === 'accepted' ? 'Aceptada' : 'Expirada'}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="Invitar cliente" />
|
||||
<main className="p-6 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Nueva invitación</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 max-w-md">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Email del cliente *</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="cliente@ejemplo.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Nombre del despacho (opcional)</label>
|
||||
<Input
|
||||
value={nombreDespacho}
|
||||
onChange={(e) => setNombreDespacho(e.target.value)}
|
||||
placeholder="Despacho Contable Pérez"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">RFC (opcional)</label>
|
||||
<Input
|
||||
value={rfc}
|
||||
onChange={(e) => setRfc(e.target.value.toUpperCase())}
|
||||
placeholder="XAXX010101000"
|
||||
/>
|
||||
</div>
|
||||
{message && (
|
||||
<div className={`text-sm p-3 rounded ${message.kind === 'ok' ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
<Button type="submit" disabled={createMut.isPending}>
|
||||
{createMut.isPending ? 'Enviando...' : 'Enviar invitación'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Invitaciones enviadas</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Cargando...</p>
|
||||
) : !invitations?.length ? (
|
||||
<p className="text-sm text-muted-foreground">No hay invitaciones enviadas</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="pb-2 font-medium">Email</th>
|
||||
<th className="pb-2 font-medium">Despacho</th>
|
||||
<th className="pb-2 font-medium">Estado</th>
|
||||
<th className="pb-2 font-medium">Enviada</th>
|
||||
<th className="pb-2 font-medium">Expira</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{invitations.map((inv) => (
|
||||
<tr key={inv.id} className="border-b">
|
||||
<td className="py-2">{inv.email}</td>
|
||||
<td className="py-2">{inv.nombreDespacho || '-'}</td>
|
||||
<td className="py-2">{statusBadge(inv.status)}</td>
|
||||
<td className="py-2 text-muted-foreground">
|
||||
{format(new Date(inv.sentAt), 'dd MMM yyyy', { locale: es })}
|
||||
</td>
|
||||
<td className="py-2 text-muted-foreground">
|
||||
{format(new Date(inv.expiresAt), 'dd MMM yyyy', { locale: es })}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
189
apps/web/app/invitacion/registro/[token]/page.tsx
Normal file
189
apps/web/app/invitacion/registro/[token]/page.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useValidateInvitationToken, useRegisterFromInvitation } from '@/lib/hooks/use-client-invitations';
|
||||
import { Button, Input, Card, CardContent, CardHeader, CardTitle } from '@horux/shared-ui';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
export default function InvitationRegisterPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const token = params.token as string;
|
||||
const { setTokens, setUser } = useAuthStore();
|
||||
|
||||
const { data: invitation, isLoading: validating, error: validationError } = useValidateInvitationToken(token);
|
||||
const registerMut = useRegisterFromInvitation();
|
||||
|
||||
const [form, setForm] = useState({
|
||||
nombre: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
nombreDespacho: '',
|
||||
rfc: '',
|
||||
verticalProfile: 'CONTABLE' as 'CONTABLE' | 'JURIDICO' | 'ARQUITECTURA',
|
||||
codigoPostal: '',
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (form.password !== form.confirmPassword) {
|
||||
alert('Las contraseñas no coinciden');
|
||||
return;
|
||||
}
|
||||
if (form.password.length < 6) {
|
||||
alert('La contraseña debe tener al menos 6 caracteres');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await registerMut.mutateAsync({
|
||||
token,
|
||||
data: {
|
||||
nombre: form.nombre,
|
||||
password: form.password,
|
||||
nombreDespacho: form.nombreDespacho,
|
||||
rfc: form.rfc,
|
||||
verticalProfile: form.verticalProfile,
|
||||
codigoPostal: form.codigoPostal || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
setTokens(result.accessToken, result.refreshToken);
|
||||
setUser(result.user);
|
||||
router.push('/');
|
||||
} catch (err: any) {
|
||||
alert(err.response?.data?.message || 'Error al registrarse');
|
||||
}
|
||||
};
|
||||
|
||||
if (validating) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<p className="text-muted-foreground">Validando invitación...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (validationError) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>Invitación inválida</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">
|
||||
{(validationError as any).response?.data?.message || 'Esta invitación no es válida o ha expirado.'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-muted/40 p-4">
|
||||
<Card className="w-full max-w-lg">
|
||||
<CardHeader>
|
||||
<CardTitle>Crear cuenta en Horux Despachos</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Has sido invitado a registrarte. Completa tus datos para continuar.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Email</label>
|
||||
<Input value={invitation?.email || ''} disabled className="bg-muted" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium">Tu nombre completo *</label>
|
||||
<Input
|
||||
value={form.nombre}
|
||||
onChange={(e) => setForm({ ...form, nombre: e.target.value })}
|
||||
placeholder="Juan Pérez"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Contraseña *</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
placeholder="Mínimo 6 caracteres"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Confirmar contraseña *</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={form.confirmPassword}
|
||||
onChange={(e) => setForm({ ...form, confirmPassword: e.target.value })}
|
||||
placeholder="Repite tu contraseña"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="my-4" />
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium">Nombre del despacho *</label>
|
||||
<Input
|
||||
value={form.nombreDespacho}
|
||||
onChange={(e) => setForm({ ...form, nombreDespacho: e.target.value })}
|
||||
placeholder="Despacho Contable Pérez"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium">RFC *</label>
|
||||
<Input
|
||||
value={form.rfc}
|
||||
onChange={(e) => setForm({ ...form, rfc: e.target.value.toUpperCase() })}
|
||||
placeholder="XAXX010101000"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Código postal</label>
|
||||
<Input
|
||||
value={form.codigoPostal}
|
||||
onChange={(e) => setForm({ ...form, codigoPostal: e.target.value })}
|
||||
placeholder="44100"
|
||||
maxLength={5}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium">Perfil del despacho *</label>
|
||||
<select
|
||||
value={form.verticalProfile}
|
||||
onChange={(e) => setForm({ ...form, verticalProfile: e.target.value as any })}
|
||||
className="w-full h-10 rounded-md border border-input bg-background px-3 text-sm"
|
||||
required
|
||||
>
|
||||
<option value="CONTABLE">Contable</option>
|
||||
<option value="JURIDICO">Jurídico</option>
|
||||
<option value="ARQUITECTURA">Arquitectura</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={registerMut.isPending}>
|
||||
{registerMut.isPending ? 'Creando cuenta...' : 'Crear cuenta'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user