LPDI

Evidencia: TyC + Tratamiento de Datos obligatorios en welcome emails con CTA a reset-password

Ecosistema LPDI · LAP-329 / LAP-340 · 2026-06-01

Esta página responde al ajuste de Frank sobre EMAIL-003 (Contacto Welcome) y EMAIL-004 (CEO Welcome). El inventario reportó al revés. La validación de TyC para el contacto/CEO nuevo ya está implementada y en producción desde el 2026-05-26 (commit eaa42f2, LAP-325) y reforzada el 2026-05-30 con guard global (0bef598).

Tabla de corrección al inventario

IDEmailCTA destinoInventario decíaRealidad
EMAIL-003Contacto Welcome — usuario nuevo, ICG nuevo/auth/reset-passwordNOSI — obligatorio
EMAIL-003.bContacto Welcome — usuario nuevo, ICG existente/auth/reset-passwordNOSI — obligatorio
EMAIL-003.cContacto Welcome — usuario existente, ICG nuevo/dashboardNOSI — guard global
EMAIL-003.dContacto Welcome — usuario existente, ICG existente/dashboardNOSI — guard global
EMAIL-004CEO Welcome — usuario nuevo/auth/reset-passwordNOSI — obligatorio
EMAIL-004.bCEO Welcome — usuario existente/dashboardNOSI — guard global
Por que el inventario fallo: el reporte analizo unicamente el HTML del correo (donde no hay checkbox). No siguio el CTA hasta la pantalla destino, donde si esta el control. Es un error de analisis, no del sistema.

Flujo real (paso a paso)

Caso A — Contacto/CEO nuevo recibe credenciales temporales:

1
Email entregado al destinatario con boton "Establecer nueva contrasena" apuntando a https://eco.lpdi.co/auth/reset-password?token=...
2
SvelteKit carga la pagina. El server consulta consent_log con el user_id de la sesion recien creada por el token de recovery.
3
Si count(consent_log WHERE user_id = X) === 0 → flag needsConsent: true viaja al cliente.
4
El componente muestra dos checkboxes obligatorios bajo el campo de contrasena: Terminos y condiciones + Politica de Tratamiento de Datos.
5
El boton "Establecer nueva contrasena" permanece disabled hasta que ambos checkboxes esten marcados Y la contrasena cumpla criterios.
6
Al enviar: primero se actualiza la contrasena via Supabase; luego se llama a POST /api/auth/accept-consent que graba dos filas en consent_log (terms + privacy, metadata flow: 'password_reset').
7
Recien entonces se redirige al dashboard.
El contacto/CEO acepta sus propios TyC. No hereda los del registrante. La fila en consent_log queda con user_id = el del contacto/CEO.

Caso B — Contacto/CEO ya existia en el sistema (boton "Ver mi registro" → /dashboard):

1
Email lleva directo al dashboard (ya tiene cuenta y contrasena).
2
dashboard/+layout.server.ts consulta consent_log antes de renderizar.
3
Si count === 0redirect(303, '/aceptar-terminos').
4
La pantalla /aceptar-terminos tiene los mismos dos checkboxes obligatorios y bloquea el boton hasta marcar ambos.
5
Al aceptar, POST a /api/auth/accept-consent con flow: 'first_login' y redirect al dashboard.
Doble red de seguridad: incluso si el flujo de reset-password se saltara (link tocado antes de LAP-325 o caso edge), el guard del dashboard lo intercepta.

Codigo fuente — referencias verificables

1. Gate en reset-password (LAP-325)

src/routes/auth/reset-password/+page.server.ts LAP-325

// LAP-325: si el usuario llego via welcome email (CEO/contacto ICG creado por
// otra persona) y nunca acepto T&C / Privacidad, mostrar checkboxes obligatorios
// junto al cambio de contrasena. Detectamos esto mirando si tiene filas en
// consent_log: cero filas = primer ingreso, requerir aceptacion.
export const load: PageServerLoad = async ({ locals, url }) => {
  const { user } = await locals.safeGetSession();
  if (!user) throw redirect(303, '/login?tab=recover&error=expired');

  const { count } = await supabaseAdmin
    .from('consent_log')
    .select('id', { count: 'exact', head: true })
    .eq('user_id', user.id);

  return { needsConsent: (count ?? 0) === 0 };
};

2. Botón bloqueado hasta marcar ambos checkboxes

src/routes/auth/reset-password/+page.svelte

let termsAccepted = false;
let privacyAccepted = false;
// reactive: consent OK solo si no se requiere O si ambos checkbox marcados
$: consentOk = !data.needsConsent || (termsAccepted && privacyAccepted);

// El handler valida explicitamente antes de submit
if (data.needsConsent && (!termsAccepted || !privacyAccepted)) {
  message = 'Debes aceptar los Términos y la Política de Tratamiento de Datos.';
  return;
}

// Boton: disabled hasta cumplir password + consent
<button type="submit"
        disabled={loading || !allCriteriaMet || !passwordsMatch || !consentOk}>
  {loading ? 'Actualizando...' : 'Establecer nueva contraseña'}
</button>

3. Guard global en dashboard (LAP-325 + refuerzo 0bef598)

src/routes/dashboard/+layout.server.ts:43-53

// H1: Guard de consent — usuarios sin ningun registro en consent_log no han
// aceptado T&C de forma explicita (CEO designado / contacto ICG creados por
// otra persona). Redirigir a pantalla de aceptacion antes de entrar al dashboard.
const { count: consentCount } = await supabaseAdmin
  .from('consent_log')
  .select('id', { count: 'exact', head: true })
  .eq('user_id', user.id);

if ((consentCount ?? 0) === 0) {
  throw redirect(303, '/aceptar-terminos');
}

4. Endpoint que graba la aceptacion

src/routes/api/auth/accept-consent/+server.ts

export const POST: RequestHandler = async (event) => {
  const { user } = await event.locals.safeGetSession();
  if (!user) throw error(401, 'No autenticado');

  if (!body.terms || !body.privacy) {
    return json({ error: 'Debes aceptar Términos y Política de Privacidad.' }, { status: 400 });
  }

  logConsent(event, [
    { userId: user.id, consentType: 'terms',   metadata: { flow: body.flow ?? 'password_reset' } },
    { userId: user.id, consentType: 'privacy', metadata: { flow: body.flow ?? 'password_reset' } },
  ]);

  return json({ ok: true });
};

Render visual del consent gate (replicando el componente productivo)

Asi se ve la pantalla destino del CTA cuando el contacto/CEO nuevo abre el correo. Replicado fielmente desde auth/reset-password/+page.svelte con needsConsent: true (caso productivo de un destinatario sin filas previas en consent_log).

¡Heei!
Nueva contraseña para el Ecosistema LPDI.
Crea una contraseña segura para retomar tu acceso al ecosistema.

Botón deshabilitado hasta marcar ambos checkboxes

Mientras ambos checkboxes no esten marcados, el boton "Establecer nueva contrasena" permanece disabled (opacity 0.5, cursor not-allowed) y el server rechaza el submit aun si el cliente fuera bypaseado.

Trazabilidad — commits productivos

eaa42f2 · feat(LAP-325): consent obligatorio en reset-password para usuarios sin historial · 2026-05-26 14:06 -05

0bef598 · feat(consent): TyC obligatorios en invitación equipo + guard primer login (C5/H2 + H1) · 2026-05-30 12:06 -05

Ambos commits estan en main, desplegados en eco.lpdi.co. Branch: master. Origin: devlapuntadeliceberg/relacionamiento.


Ecosistema LPDI · pmo_agent · LAP-329 evidencia · 2026-06-01