Invitaciones y Registro — OnnixConnect
Fecha: 2026-05-04 Contexto: El registro libre está deshabilitado en el panel. La única vía de creación de cuentas para no-superadmins es vía invitación con signed URL.
1. Modelo
Tabla invitations (creada en sprint 2026-04-29):
id bigint primary key
empresa_id bigint NOT NULL → empresas.id
invited_by_user_id bigint NOT NULL → users.id
email varchar
role varchar -- admin | operador | lector
token varchar UNIQUE -- bin2hex(random_bytes(32))
expires_at timestamp -- now() + 7 días
accepted_at timestamp NULL
accepted_by_user_id bigint NULL → users.id
Modelo: app/Models/Invitation.php. Scopes pending(), expired(). Métodos isPending(), isExpired(), isAccepted().
2. Rutas
// routes/web.php
Route::get('/invitations/{token}', [InvitationAcceptController::class, 'show'])
->middleware('signed')
->name('invitations.show');
Route::post('/invitations/{token}/accept', [InvitationAcceptController::class, 'accept'])
->middleware('auth')
->name('invitations.accept');
Route::get('/invitations/{token}/register', [InvitationRegisterController::class, 'show'])
->middleware('signed')
->name('invitations.register');
Route::post('/invitations/{token}/register', [InvitationRegisterController::class, 'store'])
->middleware('throttle:5,15')
->name('invitations.register.store');
Observaciones:
- GET
invitations.showes público (signed URL como única protección). Sinauthpara que el invitado pueda abrir el link sin tener sesión activa. - POST
invitations.acceptsí requiereauth— solo un user logueado puede atar la invitación a su cuenta. - GET/POST
invitations.register*validan signed URL (la firma vence conexpires_atde la invitación). - POST register tiene rate-limit
throttle:5,15(máximo 5 intentos cada 15 min por IP).
3. Controllers
InvitationAcceptController
show($token)
- Busca la invitación por token.
firstOrFailsi no existe o ya fue aceptada. - Aborta 403 si está expirada.
- Si el invitado no tiene sesión (
!$request->user()):- Guarda
session('url.intended', $request->fullUrl())para volver acá post-login. - Guarda
session('login_email_hint', $invitation->email)para pre-rellenar el form de login.
- Guarda
- Renderiza
view('emisores.aceptar').
accept($token) (POST)
- Re-valida token + expiración.
- Si no existe el pivot
empresa_user, lo agrega con el rol de la invitación. setPermissionsTeamId($empresa->id)+$user->assignRole($invitation->role)(Spatie teams).- Marca
accepted_at = now(),accepted_by_user_id = $user->id. session(['emisor_activo_id' => $empresa->id])para que el panel muestre el contexto correcto.activity('invitation_accepted')con properties.- Redirect a
dashboardconwith('success', ...).
InvitationRegisterController
show($token)
- Re-valida token + expiración.
- Si ya hay cuenta con ese email: redirect a
invitations.show(no tiene sentido registrar otra vez). - Renderiza
view('emisores.registrar').
store($token) (POST)
- Re-valida token + expiración.
- Si ya hay cuenta con ese email: redirect a
invitations.showcon flash error. - Valida
name,username(regex + unique),password(min 8, confirmed). - Crea user con
email = $invitation->email(fijado, no se puede cambiar),active = true. Auth::login($user)+regeneratesesión.- Redirect a una signed URL fresca de
invitations.showpara que pueda completar el accept.
4. Mailable y vista de email
app/Mail/EmpresaInvitationMail.php
- Subject:
"Te invitaron a {razon_social} como {Rol} — OnnixConnect". - Genera signed URL apuntando a
invitations.show(no aacceptdirectamente — sería 405 al GET). - From:
config('mail.from.*').
resources/views/emails/empresa-invitation.blade.php
- HTML responsive con dark mode (
prefers-color-scheme+[data-ogsc]). - Logos embebidos como CID inline (
$message->embed(public_path(...))):onnix-logo-texto.pngpara light.onnix-logo-blanco.pngpara dark.
- Badge del rol con paleta tipada (admin rojo / operador azul / lector slate).
5. Vista emisores.aceptar
resources/views/emisores/aceptar.blade.php. Layout dinámico:
@extends(auth()->check() ? 'layouts.sneat-app' : 'layouts.sneat-auth')
- Sneat-auth si el visitante NO tiene sesión (no se renderiza sidebar; layout limpio).
- Sneat-app si SÍ está autenticado.
Tema dark/light propio scoped en .invite-scope con CSS vars (--bg-page, --bg-surface, --primary, etc.) — no usa clases Bootstrap card que rompen en dark.
Lógica de UI según estado
| Estado | UI |
|---|---|
| Sin sesión + cuenta NO existe | Texto "Aún no tenés cuenta". Botón primario Crear cuenta (signed URL a register). Botón secundario "Ya tengo cuenta" (a /login). |
| Sin sesión + cuenta SÍ existe | Texto "Ya existe una cuenta". Botón primario Iniciar sesión (a /login). |
| Con sesión + email coincide | Botón Aceptar y entrar (POST /accept). |
| Con sesión + email NO coincide | Warning "Estás autenticado como X pero la invitación es para Y". Botón Cerrar sesión y volver (POST /logout). |
6. Vista emisores.registrar
resources/views/emisores/registrar.blade.php. Form con:
- Email readonly (queda fijado por la invitación).
- Nombre completo (required, max 120).
- Username opcional (regex
[a-z0-9_.\-], unique). - Password (min 8) + confirmación (regla
confirmed).
Layout sneat-auth para invitados no autenticados.
7. Login con email pre-llenado y redirect inteligente
LoginController::showLoginForm recibe Request $request y pasa $emailHint = session('login_email_hint') a la vista. El form de login usa:
value="{{ old('login', $emailHint ?? '') }}"
LoginController::login (post-submit):
- Captura
url.intendedantes de regenerar la sesión. - Solo respeta URLs internas (mismo host) que apunten a
/invitations/...— descarta cualquier otra residual. - Regenera sesión, borra
url.intendedylogin_email_hint. - Redirect a la URL intended si fue válida; sino a
/home(admin) o/dashboard(resto).
Esto evita el bug "Livewire intended POST 419" sin perder el flujo de invitación.
8. Seguridad
- Signed URL en todos los endpoints públicos del flujo (show + register).
- Email queda fijado por la invitación — el invitado no puede registrar otro email.
- Email duplicado detectado en register (redirect a login).
- Throttle en register (5 cada 15 min/IP) para evitar abuso.
- Activity log en cada paso (member_added, invitation_accepted, invitation_sent, invitation_cancelled).
9. Verificación end-to-end
# Como superadmin: enviar invitación
# /usuarios → Nuevo Usuario → tab "Invitar por email" → email + empresa + rol
# El invitado abre el correo
# Click en "Aceptar invitación" → /invitations/{token}?signature=...
# Caso A: cuenta ya existe
# → botón "Iniciar sesión" → form con email pre-llenado → submit → /invitations/{token} → "Aceptar y entrar" → dashboard
# Caso B: cuenta nueva
# → botón "Crear cuenta" → /invitations/{token}/register?signature=... → form con email readonly + nombre + password → submit → user creado + Auth::login → redirect /invitations/{token} → "Aceptar y entrar" → dashboard
Referencias cruzadas
- Configuración del Sistema —
security.die_on_tab_closey heartbeat (también afecta este flujo). - Auth Testing — endpoints completos.
- Auditoría de Sesiones —
user_sessionsregistra el login post-aceptación.