Files
HoruxDespachosNuevo/apps/web/app/invitacion/registro/[token]/page.tsx
Horux Dev 745bc8385b 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
2026-05-11 22:03:03 +00:00

190 lines
6.4 KiB
TypeScript

'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>
);
}