feat(admin): onboarding por invitación — /admin/usuarios + /auth/confirm
Platform admin invita usuarios de empresa desde /admin/usuarios. inviteUserByEmail + insert inmediato en user_roles (org_admin). Rollback explícito si falla el insert. /auth/confirm maneja el link de invitación con PASSWORD_RECOVERY event. Middleware no requirió cambios — /auth/confirm ya queda fuera del matcher de rutas protegidas. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
67
app/admin/usuarios/invite-form.tsx
Normal file
67
app/admin/usuarios/invite-form.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
'use client'
|
||||
import { useActionState } from 'react'
|
||||
import { inviteUser, type InviteUserResult } from '@/app/actions/invite-user'
|
||||
|
||||
interface OrgOption {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const INITIAL_STATE: InviteUserResult = { success: false, error: '' }
|
||||
|
||||
const ERROR_MESSAGES: Record<string, string> = {
|
||||
no_autenticado: 'Tu sesión expiró — volvé a iniciar sesión.',
|
||||
forbidden: 'No tenés permisos para invitar usuarios.',
|
||||
email_invalido: 'El email no es válido.',
|
||||
organizacion_requerida: 'Elegí una organización.',
|
||||
organizacion_no_encontrada: 'La organización seleccionada no existe.',
|
||||
}
|
||||
|
||||
function describeError(code: string): string {
|
||||
if (code.startsWith('invite_failed'))
|
||||
return `No se pudo enviar la invitación: ${code.slice('invite_failed: '.length)}`
|
||||
if (code.startsWith('role_insert_failed'))
|
||||
return `Error interno al asignar el rol: ${code.slice('role_insert_failed: '.length)}`
|
||||
return ERROR_MESSAGES[code] ?? 'Ocurrió un error inesperado.'
|
||||
}
|
||||
|
||||
export function InviteForm({ organizations }: { organizations: OrgOption[] }) {
|
||||
const [state, formAction, isPending] = useActionState(
|
||||
async (_prev: InviteUserResult, formData: FormData) => inviteUser(formData),
|
||||
INITIAL_STATE
|
||||
)
|
||||
|
||||
return (
|
||||
<form action={formAction} className="login-form">
|
||||
<div className="login-field">
|
||||
<label htmlFor="email" className="login-label">Email</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
disabled={isPending}
|
||||
className="text-short-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="login-field">
|
||||
<label htmlFor="orgId" className="login-label">Organización</label>
|
||||
<select id="orgId" name="orgId" required disabled={isPending} className="admin-survey-select">
|
||||
<option value="">Seleccioná una organización</option>
|
||||
{organizations.map((org) => (
|
||||
<option key={org.id} value={org.id}>{org.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{!state.success && state.error && (
|
||||
<p role="alert" className="field-error login-error">{describeError(state.error)}</p>
|
||||
)}
|
||||
{state.success && (
|
||||
<p className="admin-subtitle" role="status">Invitación enviada correctamente.</p>
|
||||
)}
|
||||
<button type="submit" disabled={isPending} className="login-submit">
|
||||
{isPending ? 'Enviando…' : 'Enviar invitación'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user