Saltar al contenido principal

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.show es público (signed URL como única protección). Sin auth para que el invitado pueda abrir el link sin tener sesión activa.
  • POST invitations.accept sí requiere auth — solo un user logueado puede atar la invitación a su cuenta.
  • GET/POST invitations.register* validan signed URL (la firma vence con expires_at de 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)

  1. Busca la invitación por token. firstOrFail si no existe o ya fue aceptada.
  2. Aborta 403 si está expirada.
  3. 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.
  4. Renderiza view('emisores.aceptar').

accept($token) (POST)

  1. Re-valida token + expiración.
  2. Si no existe el pivot empresa_user, lo agrega con el rol de la invitación.
  3. setPermissionsTeamId($empresa->id) + $user->assignRole($invitation->role) (Spatie teams).
  4. Marca accepted_at = now(), accepted_by_user_id = $user->id.
  5. session(['emisor_activo_id' => $empresa->id]) para que el panel muestre el contexto correcto.
  6. activity('invitation_accepted') con properties.
  7. Redirect a dashboard con with('success', ...).

InvitationRegisterController

show($token)

  1. Re-valida token + expiración.
  2. Si ya hay cuenta con ese email: redirect a invitations.show (no tiene sentido registrar otra vez).
  3. Renderiza view('emisores.registrar').

store($token) (POST)

  1. Re-valida token + expiración.
  2. Si ya hay cuenta con ese email: redirect a invitations.show con flash error.
  3. Valida name, username (regex + unique), password (min 8, confirmed).
  4. Crea user con email = $invitation->email (fijado, no se puede cambiar), active = true.
  5. Auth::login($user) + regenerate sesión.
  6. Redirect a una signed URL fresca de invitations.show para 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 a accept directamente — 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.png para light.
    • onnix-logo-blanco.png para 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

EstadoUI
Sin sesión + cuenta NO existeTexto "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Í existeTexto "Ya existe una cuenta". Botón primario Iniciar sesión (a /login).
Con sesión + email coincideBotón Aceptar y entrar (POST /accept).
Con sesión + email NO coincideWarning "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):

  1. Captura url.intended antes de regenerar la sesión.
  2. Solo respeta URLs internas (mismo host) que apunten a /invitations/... — descarta cualquier otra residual.
  3. Regenera sesión, borra url.intended y login_email_hint.
  4. 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