feat: Version 1.10.0 - Refactorizacion, optimizacion UI y mejoras de seguridad

- Extraccion de helpers en backend: audit_helpers.py, helpers.py
- Modularizacion de schemas en archivos individuales por dominio
- Reduccion de audit.py en 953 lineas (74% del archivo)
- Reduccion de tickets.py en 655 lineas (60% del archivo)
- Expansion de auth.py con recuperacion de contrasenia y tokens
- Nuevos modulos: core/email.py, core/cache.py
- Reorganizacion de scripts a backend/scripts/
- Frontend: refactorizacion de audit page con array-driven components
- Frontend: correccion de 11 errores ortograficos en tickets page
- Frontend: proxy Docker corregido en vite.config.js
- Frontend: nuevas rutas forgot-password, reset-password, organization, profile
- Nuevas utilidades TS: colorUtils.ts, dateFormats.ts
- 5 nuevos archivos de tests unitarios en backend/tests/unit/
- Eliminacion de 3 scripts temporales de prueba
- Documentacion tecnica: CAMBIOS_v1.10.0.md, OPTIMIZACIONES_RENDIMIENTO.md
This commit is contained in:
2026-02-19 13:48:21 -07:00
parent 16d795e8bd
commit 517297e89a
57 changed files with 8022 additions and 3660 deletions

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { auth } from '$lib/stores/auth.js';
import { onMount } from 'svelte';
import Icon from './Icon.svelte';
export let showLogo = true;
@@ -96,6 +95,13 @@
>
Mi Perfil
</a>
<a
href="/organization"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
on:click={() => (isMenuOpen = false)}
>
Mi Organización
</a>
<button
on:click={handleLogout}
class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"

View File

@@ -5,7 +5,7 @@
const statusConfig = {
NEW: { label: 'Nuevo', class: 'badge-new' },
IN_PROGRESS: { label: 'En Progreso', class: 'badge-in-progress' },
WAITING_FOR_CLIENT: { label: 'Esperando Cliente', class: 'badge-waiting' },
WAITING_CUSTOMER: { label: 'Esperando Cliente', class: 'badge-waiting' },
RESOLVED: { label: 'Resuelto', class: 'badge-resolved' },
CLOSED: { label: 'Cerrado', class: 'badge-closed' },
REOPENED: { label: 'Reabierto', class: 'badge-reopened' }

View File

@@ -13,7 +13,7 @@ export interface Ticket {
id: string;
title: string;
description: string;
status: 'NEW' | 'IN_PROGRESS' | 'WAITING_FOR_CLIENT' | 'RESOLVED' | 'CLOSED' | 'REOPENED';
status: 'NEW' | 'IN_PROGRESS' | 'WAITING_CUSTOMER' | 'RESOLVED' | 'CLOSED' | 'REOPENED';
priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
category_id: string;
category_name?: string;

View File

@@ -25,7 +25,7 @@
<!-- Footer with version -->
<footer class="py-4 text-center border-t border-gray-200 bg-white">
<p class="text-xs text-gray-400">ServiceManagerWeb v1.6.0 · © 2026 Aduanasoft</p>
<p class="text-xs text-gray-400">ServiceManagerWeb v1.9.0 · © 2026 Aduanasoft</p>
</footer>
<!-- Toast notifications -->

View File

@@ -0,0 +1,151 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { auth } from '$lib/stores/auth.js';
import Icon from '$lib/components/Icon.svelte';
let email = '';
let isLoading = false;
let submitted = false;
let errorMessage = '';
onMount(() => {
if ($auth.isAuthenticated) goto('/');
});
async function handleSubmit() {
if (!email) {
errorMessage = 'Ingresa tu correo electrónico';
return;
}
isLoading = true;
errorMessage = '';
try {
const response = await fetch('/api/v1/auth/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email })
});
// Siempre mostramos el mensaje de éxito (backend no revela si el email existe)
submitted = true;
} catch {
errorMessage = 'Error de conexión. Intenta de nuevo.';
} finally {
isLoading = false;
}
}
</script>
<svelte:head>
<title>Olvidé mi contraseña - ServiceManager</title>
</svelte:head>
<div class="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div class="sm:mx-auto sm:w-full sm:max-w-md">
<!-- Logo -->
<div class="flex justify-center mb-6">
<a href="/login" class="flex items-center space-x-2">
<div class="w-10 h-10 bg-blue-700 rounded-lg flex items-center justify-center">
<Icon name="ticket" class="w-6 h-6 text-white" />
</div>
<span class="text-xl font-bold text-gray-900">ServiceManager</span>
</a>
</div>
<div class="bg-white py-10 px-8 shadow-sm rounded-xl border border-gray-200">
{#if submitted}
<!-- Estado de éxito -->
<div class="text-center space-y-4">
<div class="w-14 h-14 bg-green-100 rounded-full flex items-center justify-center mx-auto">
<Icon name="mail" class="w-7 h-7 text-green-600" />
</div>
<h2 class="text-xl font-bold text-gray-900">Revisa tu correo</h2>
<p class="text-sm text-gray-600 leading-relaxed">
Si <strong>{email}</strong> está registrado en el sistema, recibirás un correo
con un enlace para restablecer tu contraseña en los próximos minutos.
</p>
<p class="text-xs text-gray-400">
El enlace es válido por 30 minutos y solo puede usarse una vez.
</p>
<div class="pt-4 space-y-2">
<button
type="button"
class="w-full py-2.5 px-4 text-sm font-medium text-white bg-blue-700 rounded-lg hover:bg-blue-800 transition-colors"
on:click={() => { submitted = false; email = ''; }}
>
Enviar otro correo
</button>
<a
href="/login"
class="block text-center text-sm text-gray-500 hover:text-gray-700 py-2"
>
Volver al inicio de sesión
</a>
</div>
</div>
{:else}
<!-- Formulario -->
<div class="space-y-6">
<div class="text-center space-y-1">
<h2 class="text-2xl font-bold text-gray-900">¿Olvidaste tu contraseña?</h2>
<p class="text-sm text-gray-500">
Ingresa tu correo y te enviaremos un enlace para restablecerla.
</p>
</div>
{#if errorMessage}
<div class="p-3 rounded-lg bg-red-50 border border-red-100 flex items-center gap-2 text-sm text-red-600">
<Icon name="alert-circle" class="w-4 h-4 shrink-0" />
{errorMessage}
</div>
{/if}
<form on:submit|preventDefault={handleSubmit} class="space-y-5">
<div>
<label for="email" class="block text-sm font-semibold text-gray-700 mb-1.5">
Correo electrónico
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Icon name="mail" class="w-5 h-5 text-gray-400" />
</div>
<input
id="email"
type="email"
class="block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-lg text-sm text-gray-900 focus:ring-2 focus:ring-blue-600 focus:border-transparent outline-none transition-all"
placeholder="tu@empresa.com"
bind:value={email}
disabled={isLoading}
required
/>
</div>
</div>
<button
type="submit"
class="w-full flex justify-center items-center gap-2 py-3.5 px-4 text-sm font-bold text-white bg-blue-700 rounded-lg hover:bg-blue-800 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
disabled={isLoading}
>
{#if isLoading}
<Icon name="loader-2" class="w-4 h-4 animate-spin" />
Enviando...
{:else}
Enviar enlace de restablecimiento
{/if}
</button>
</form>
<div class="text-center pt-2">
<a href="/login" class="text-sm text-blue-600 hover:text-blue-500 font-medium">
← Volver al inicio de sesión
</a>
</div>
</div>
{/if}
</div>
<p class="mt-6 text-center text-xs text-gray-400">
© 2026 Aduanasoft. Acceso exclusivo autorizado.
</p>
</div>
</div>

View File

@@ -0,0 +1,346 @@
<script lang="ts">
import { onMount } from 'svelte';
import { auth } from '$lib/stores/auth.js';
import { toast } from '$lib/stores/toast.js';
import { goto } from '$app/navigation';
let profile: any = null;
let isLoading = true;
let isSaving = false;
let isEditing = false;
let form = {
business_name: '',
commercial_name: '',
rfc: '',
client_type: '',
country: '',
state: '',
city: '',
address: '',
postal_code: '',
main_phone: '',
main_email: '',
website: '',
business_hours: '',
company_representative: '',
notes: ''
};
onMount(async () => {
if (!$auth.isAuthenticated) {
goto('/login');
return;
}
await loadProfile();
});
async function loadProfile() {
isLoading = true;
try {
const response = await fetch('/api/v1/client-profile/', {
headers: {
Authorization: `Bearer ${$auth.token}`,
'X-Tenant-ID': $auth.user?.tenant_id ?? ''
}
});
if (!response.ok) throw new Error((await response.json()).detail);
profile = await response.json();
// Poblar form con datos existentes
for (const key of Object.keys(form)) {
if (profile[key] !== undefined && profile[key] !== null) {
(form as any)[key] = profile[key];
}
}
} catch (e: any) {
toast.error(e.message || 'Error al cargar el perfil de organización');
} finally {
isLoading = false;
}
}
async function saveProfile() {
isSaving = true;
try {
const response = await fetch('/api/v1/client-profile/', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${$auth.token}`,
'X-Tenant-ID': $auth.user?.tenant_id ?? ''
},
body: JSON.stringify(form)
});
if (!response.ok) throw new Error((await response.json()).detail);
profile = await response.json();
isEditing = false;
toast.success('Perfil de organización actualizado');
} catch (e: any) {
toast.error(e.message || 'Error al guardar el perfil');
} finally {
isSaving = false;
}
}
function cancelEdit() {
for (const key of Object.keys(form)) {
(form as any)[key] = (profile?.[key] !== undefined && profile?.[key] !== null)
? profile[key]
: '';
}
isEditing = false;
}
function val(key: string): string {
return profile?.[key] ?? '';
}
</script>
<svelte:head>
<title>Mi Organización - ServiceManager</title>
</svelte:head>
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header -->
<div class="flex justify-between items-start mb-8">
<div>
<h1 class="text-3xl font-bold text-gray-900">Mi Organización</h1>
<p class="text-gray-600 mt-1">Información empresarial de tu organización</p>
</div>
{#if !isEditing && !isLoading}
<button
type="button"
class="btn-primary px-4 py-2"
on:click={() => (isEditing = true)}
>
Editar información
</button>
{/if}
</div>
{#if isLoading}
<div class="text-center py-16">
<div class="spinner w-8 h-8 mx-auto mb-4"></div>
<p class="text-gray-500">Cargando información de la organización...</p>
</div>
{:else}
<form on:submit|preventDefault={saveProfile} class="space-y-8">
<!-- Información general -->
<div class="card">
<div class="border-b border-gray-200 px-6 py-4">
<h2 class="text-base font-semibold text-gray-900">Información general</h2>
</div>
<div class="px-6 py-5">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-5">
<div>
<label class="form-label">Razón social</label>
{#if isEditing}
<input type="text" class="form-input" bind:value={form.business_name} />
{:else}
<p class="text-sm text-gray-900 mt-1">{val('business_name') || '—'}</p>
{/if}
</div>
<div>
<label class="form-label">Nombre comercial</label>
{#if isEditing}
<input type="text" class="form-input" bind:value={form.commercial_name} />
{:else}
<p class="text-sm text-gray-900 mt-1">{val('commercial_name') || '—'}</p>
{/if}
</div>
<div>
<label class="form-label">RFC</label>
{#if isEditing}
<input type="text" class="form-input" style="text-transform:uppercase" bind:value={form.rfc} maxlength="13" placeholder="XAXX010101000" />
{:else}
<p class="text-sm font-mono text-gray-900 mt-1">{val('rfc') || '—'}</p>
{/if}
</div>
<div>
<label class="form-label">Tipo de cliente</label>
{#if isEditing}
<select class="form-input" bind:value={form.client_type}>
<option value="">Seleccionar...</option>
<option value="EMPRESA">Empresa</option>
<option value="PERSONA_FISICA">Persona Física</option>
<option value="GOBIERNO">Gobierno</option>
<option value="OTRO">Otro</option>
</select>
{:else}
<p class="text-sm text-gray-900 mt-1">{val('client_type') || '—'}</p>
{/if}
</div>
<div>
<label class="form-label">Representante</label>
{#if isEditing}
<input type="text" class="form-input" bind:value={form.company_representative} />
{:else}
<p class="text-sm text-gray-900 mt-1">{val('company_representative') || '—'}</p>
{/if}
</div>
<div>
<label class="form-label">Sitio web</label>
{#if isEditing}
<input type="url" class="form-input" bind:value={form.website} placeholder="https://..." />
{:else}
{#if val('website')}
<p class="text-sm mt-1">
<a href={val('website')} target="_blank" rel="noopener noreferrer" class="text-primary-600 hover:underline">{val('website')}</a>
</p>
{:else}
<p class="text-sm text-gray-400 mt-1"></p>
{/if}
{/if}
</div>
</div>
</div>
</div>
<!-- Ubicación -->
<div class="card">
<div class="border-b border-gray-200 px-6 py-4">
<h2 class="text-base font-semibold text-gray-900">Ubicación</h2>
</div>
<div class="px-6 py-5">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-5">
<div>
<label class="form-label">País</label>
{#if isEditing}
<input type="text" class="form-input" bind:value={form.country} />
{:else}
<p class="text-sm text-gray-900 mt-1">{val('country') || '—'}</p>
{/if}
</div>
<div>
<label class="form-label">Estado / Provincia</label>
{#if isEditing}
<input type="text" class="form-input" bind:value={form.state} />
{:else}
<p class="text-sm text-gray-900 mt-1">{val('state') || '—'}</p>
{/if}
</div>
<div>
<label class="form-label">Ciudad</label>
{#if isEditing}
<input type="text" class="form-input" bind:value={form.city} />
{:else}
<p class="text-sm text-gray-900 mt-1">{val('city') || '—'}</p>
{/if}
</div>
<div>
<label class="form-label">Código postal</label>
{#if isEditing}
<input type="text" class="form-input" bind:value={form.postal_code} maxlength="10" />
{:else}
<p class="text-sm text-gray-900 mt-1">{val('postal_code') || '—'}</p>
{/if}
</div>
<div class="sm:col-span-2">
<label class="form-label">Dirección</label>
{#if isEditing}
<input type="text" class="form-input" bind:value={form.address} />
{:else}
<p class="text-sm text-gray-900 mt-1">{val('address') || '—'}</p>
{/if}
</div>
</div>
</div>
</div>
<!-- Contacto -->
<div class="card">
<div class="border-b border-gray-200 px-6 py-4">
<h2 class="text-base font-semibold text-gray-900">Contacto</h2>
</div>
<div class="px-6 py-5">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-5">
<div>
<label class="form-label">Teléfono principal</label>
{#if isEditing}
<input type="tel" class="form-input" bind:value={form.main_phone} />
{:else}
<p class="text-sm text-gray-900 mt-1">{val('main_phone') || '—'}</p>
{/if}
</div>
<div>
<label class="form-label">Email principal</label>
{#if isEditing}
<input type="email" class="form-input" bind:value={form.main_email} />
{:else}
<p class="text-sm text-gray-900 mt-1">{val('main_email') || '—'}</p>
{/if}
</div>
<div>
<label class="form-label">Horario de atención</label>
{#if isEditing}
<input type="text" class="form-input" bind:value={form.business_hours} placeholder="Lun-Vie 9:00-18:00" />
{:else}
<p class="text-sm text-gray-900 mt-1">{val('business_hours') || '—'}</p>
{/if}
</div>
</div>
</div>
</div>
<!-- Notas -->
<div class="card">
<div class="border-b border-gray-200 px-6 py-4">
<h2 class="text-base font-semibold text-gray-900">Notas internas</h2>
</div>
<div class="px-6 py-5">
{#if isEditing}
<textarea
class="form-input resize-none"
rows="4"
bind:value={form.notes}
placeholder="Información adicional sobre la organización..."
></textarea>
{:else}
{#if val('notes')}
<p class="text-sm text-gray-900 whitespace-pre-wrap">{val('notes')}</p>
{:else}
<p class="text-sm text-gray-400">Sin notas</p>
{/if}
{/if}
</div>
</div>
<!-- Acciones -->
{#if isEditing}
<div class="flex justify-end gap-3">
<button
type="button"
class="btn-secondary px-5 py-2"
on:click={cancelEdit}
disabled={isSaving}
>Cancelar</button>
<button
type="submit"
class="btn-primary px-5 py-2"
disabled={isSaving}
>
{isSaving ? 'Guardando...' : 'Guardar cambios'}
</button>
</div>
{/if}
</form>
{/if}
</div>

View File

@@ -19,6 +19,86 @@
// Tabs management
let activeTab = 'personal';
// 2FA management
let is2faLoading = false;
let show2faSetup = false;
let qrUri = '';
let totpSetupCode = '';
let backupCodes: string[] = [];
let showBackupCodes = false;
let show2faDisable = false;
let disableTotpCode = '';
async function setup2fa() {
is2faLoading = true;
try {
const response = await fetch('/api/v1/auth/2fa/setup', {
method: 'POST',
headers: { Authorization: `Bearer ${$auth.token}` }
});
if (!response.ok) throw new Error((await response.json()).detail);
const data = await response.json();
qrUri = data.qr_uri;
show2faSetup = true;
totpSetupCode = '';
} catch (e: any) {
toast.error(e.message || 'Error al iniciar configuración de 2FA');
} finally {
is2faLoading = false;
}
}
async function enable2fa() {
if (!totpSetupCode || totpSetupCode.length !== 6) {
toast.error('Ingresa el código de 6 dígitos de tu app autenticadora');
return;
}
is2faLoading = true;
try {
const response = await fetch('/api/v1/auth/2fa/enable', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${$auth.token}` },
body: JSON.stringify({ totp_code: totpSetupCode })
});
if (!response.ok) throw new Error((await response.json()).detail);
const data = await response.json();
backupCodes = data.backup_codes;
showBackupCodes = true;
show2faSetup = false;
// Actualizar estado en el store
if ($auth.user) auth.updateUser({ ...$auth.user, is_two_factor_enabled: true });
toast.success('¡2FA activado correctamente!');
} catch (e: any) {
toast.error(e.message || 'Código inválido. Verifica tu app autenticadora.');
} finally {
is2faLoading = false;
}
}
async function disable2fa() {
if (!disableTotpCode || disableTotpCode.length < 6) {
toast.error('Ingresa el código de 6 dígitos para confirmar');
return;
}
is2faLoading = true;
try {
const response = await fetch('/api/v1/auth/2fa/disable', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${$auth.token}` },
body: JSON.stringify({ totp_code: disableTotpCode })
});
if (!response.ok) throw new Error((await response.json()).detail);
show2faDisable = false;
disableTotpCode = '';
if ($auth.user) auth.updateUser({ ...$auth.user, is_two_factor_enabled: false });
toast.success('2FA deshabilitado correctamente');
} catch (e: any) {
toast.error(e.message || 'Código inválido');
} finally {
is2faLoading = false;
}
}
// Business profile data
let businessProfile = {
business_name: '',
@@ -306,13 +386,11 @@
</svelte:head>
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Header -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Mi Perfil</h1>
<p class="text-gray-600 mt-2">Gestiona tu información personal y configuración empresarial</p>
</div>
<!-- Tabs Navigation -->
<div class="border-b border-gray-200 mb-8">
<nav class="-mb-px flex space-x-8">
<button
@@ -367,9 +445,7 @@
</nav>
</div>
<!-- Tab Content -->
<div class="space-y-8">
<!-- Personal Information Tab -->
{#if activeTab === 'personal'}
<div class="card">
<div class="card-header">
@@ -450,7 +526,6 @@
</div>
{/if}
<!-- General Business Information Tab -->
{#if activeTab === 'general'}
<div class="card">
<div class="card-header">
@@ -460,7 +535,6 @@
<div class="card-content">
<form on:submit|preventDefault={handleBusinessProfileSave} class="space-y-6">
<!-- Información General -->
<div class="bg-gray-50 p-4 rounded-lg">
<h3 class="font-medium text-gray-900 mb-4">Información General</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
@@ -538,7 +612,6 @@
</div>
</div>
<!-- Ubicación -->
<div class="bg-white p-4 rounded-lg border border-gray-200">
<h3 class="font-medium text-gray-900 mb-4">Ubicación</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@@ -623,7 +696,6 @@
</div>
</div>
<!-- Representantes -->
<div class="bg-white p-4 rounded-lg border border-gray-200">
<h3 class="font-medium text-gray-900 mb-4">Representantes</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
@@ -653,7 +725,6 @@
</div>
</div>
<!-- Configuración -->
<div class="bg-white p-4 rounded-lg border border-gray-200">
<h3 class="font-medium text-gray-900 mb-4">Configuración</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
@@ -724,7 +795,6 @@
</div>
{/if}
<!-- Contact Information Tab -->
{#if activeTab === 'contact'}
<div class="card">
<div class="card-header">
@@ -734,7 +804,6 @@
<div class="card-content">
<form on:submit|preventDefault={handleBusinessProfileSave} class="space-y-6">
<!-- Teléfonos -->
<div class="bg-white p-4 rounded-lg border border-gray-200">
<h3 class="font-medium text-gray-900 mb-4">Teléfonos</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
@@ -791,7 +860,6 @@
</div>
</div>
<!-- Emails -->
<div class="bg-white p-4 rounded-lg border border-gray-200">
<h3 class="font-medium text-gray-900 mb-4">Correos Electrónicos</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
@@ -823,7 +891,6 @@
</div>
</div>
<!-- Web y Horarios -->
<div class="bg-white p-4 rounded-lg border border-gray-200">
<h3 class="font-medium text-gray-900 mb-4">Web y Horarios</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
@@ -880,7 +947,6 @@
</div>
{/if}
<!-- Security Tab -->
{#if activeTab === 'security'}
<div class="card">
<div class="card-header">
@@ -889,50 +955,123 @@
</div>
<div class="card-content space-y-6">
<!-- Two-Factor Authentication Status -->
<div class="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div>
<h3 class="font-medium text-gray-900">Autenticación de dos factores (2FA)</h3>
<p class="text-sm text-gray-600">
{$auth.user?.is_two_factor_enabled
? 'La autenticación de dos factores está habilitada'
: 'Mejora la seguridad habilitando 2FA'}
</p>
</div>
<div>
{#if $auth.user?.is_two_factor_enabled}
<span
class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800"
>
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
Habilitado
</span>
{:else}
<span
class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-red-100 text-red-800"
>
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
Deshabilitado
</span>
{/if}
<div class="border border-gray-200 rounded-lg overflow-hidden">
<div class="flex items-center justify-between p-4 bg-gray-50">
<div>
<h3 class="font-medium text-gray-900">Autenticación de dos factores (2FA)</h3>
<p class="text-sm text-gray-600 mt-0.5">
{$auth.user?.is_two_factor_enabled
? 'Tu cuenta está protegida con autenticación de dos factores'
: 'Añade una capa extra de seguridad a tu cuenta'}
</p>
</div>
<div class="flex items-center gap-3">
{#if $auth.user?.is_two_factor_enabled}
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
<svg class="w-3.5 h-3.5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
Habilitado
</span>
<button
type="button"
class="text-sm text-red-600 hover:text-red-800 font-medium"
on:click={() => { show2faDisable = !show2faDisable; disableTotpCode = ''; }}
disabled={is2faLoading}
>Deshabilitar</button>
{:else}
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-600">
<svg class="w-3.5 h-3.5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
Deshabilitado
</span>
<button
type="button"
class="text-sm bg-blue-600 text-white px-3 py-1.5 rounded font-medium hover:bg-blue-700 disabled:opacity-50"
on:click={setup2fa}
disabled={is2faLoading}
>
{is2faLoading ? 'Cargando...' : 'Habilitar 2FA'}
</button>
{/if}
</div>
</div>
{#if show2faSetup && qrUri}
<div class="p-5 border-t border-gray-200 space-y-4">
<p class="text-sm font-medium text-gray-700">1. Escanea este código QR en Google Authenticator, Authy o cualquier app TOTP:</p>
<div class="flex justify-center bg-white p-4 border border-gray-200 rounded">
<img src="https://api.qrserver.com/v1/create-qr-code/?size=180x180&data={encodeURIComponent(qrUri)}" alt="Código QR 2FA" class="w-44 h-44" />
</div>
<p class="text-sm font-medium text-gray-700 mt-3">2. Ingresa el código de 6 dígitos para confirmar:</p>
<div class="flex gap-3">
<input
type="text"
class="form-input w-40 text-center tracking-widest font-mono text-lg"
placeholder="000000"
maxlength="6"
bind:value={totpSetupCode}
/>
<button
type="button"
class="bg-green-600 text-white px-4 py-2 rounded font-medium hover:bg-green-700 disabled:opacity-50"
on:click={enable2fa}
disabled={is2faLoading}
>
{is2faLoading ? 'Verificando...' : 'Confirmar y activar'}
</button>
<button
type="button"
class="text-gray-500 hover:text-gray-700 text-sm font-medium"
on:click={() => { show2faSetup = false; }}
>Cancelar</button>
</div>
</div>
{/if}
{#if showBackupCodes && backupCodes.length > 0}
<div class="p-5 border-t border-green-200 bg-green-50">
<h4 class="font-medium text-green-900 mb-2">✅ 2FA activado — Guarda tus códigos de respaldo</h4>
<p class="text-sm text-green-700 mb-3">Estos códigos son de un solo uso. Guárdalos en un lugar seguro.</p>
<div class="grid grid-cols-2 gap-2 font-mono text-sm">
{#each backupCodes as code}
<span class="bg-white border border-green-200 px-3 py-1.5 rounded text-center">{code}</span>
{/each}
</div>
<button
type="button"
class="mt-4 text-sm text-green-700 underline"
on:click={() => { showBackupCodes = false; backupCodes = []; }}
>He guardado mis códigos</button>
</div>
{/if}
{#if show2faDisable}
<div class="p-5 border-t border-red-200 bg-red-50">
<p class="text-sm font-medium text-red-800 mb-3">Ingresa el código de tu app autenticadora para deshabilitar 2FA:</p>
<div class="flex gap-3">
<input
type="text"
class="form-input w-40 text-center tracking-widest font-mono text-lg border-red-300"
placeholder="000000"
maxlength="6"
bind:value={disableTotpCode}
/>
<button
type="button"
class="bg-red-600 text-white px-4 py-2 rounded font-medium hover:bg-red-700 disabled:opacity-50"
on:click={disable2fa}
disabled={is2faLoading}
>
{is2faLoading ? 'Verificando...' : 'Confirmar y deshabilitar'}
</button>
<button
type="button"
class="text-gray-500 hover:text-gray-700 text-sm"
on:click={() => { show2faDisable = false; }}
>Cancelar</button>
</div>
</div>
{/if}
</div>
<!-- Change Password Form -->
<form on:submit|preventDefault={handlePasswordChange} class="space-y-6">
<h3 class="text-lg font-medium text-gray-900">Cambiar Contraseña</h3>
@@ -1005,7 +1144,6 @@
</div>
{/if}
<!-- Account Information Tab -->
{#if activeTab === 'account'}
<div class="card">
<div class="card-header">

View File

@@ -0,0 +1,206 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { auth } from '$lib/stores/auth.js';
import Icon from '$lib/components/Icon.svelte';
let token = '';
let newPassword = '';
let confirmPassword = '';
let showPassword = false;
let isLoading = false;
let errorMessage = '';
let success = false;
onMount(() => {
if ($auth.isAuthenticated) { goto('/'); return; }
token = $page.url.searchParams.get('token') ?? '';
if (!token) {
errorMessage = 'El enlace es inválido. Asegúrate de usar el enlace completo del correo.';
}
});
async function handleSubmit() {
errorMessage = '';
if (!newPassword || !confirmPassword) {
errorMessage = 'Completa todos los campos';
return;
}
if (newPassword.length < 8) {
errorMessage = 'La contraseña debe tener al menos 8 caracteres';
return;
}
if (newPassword !== confirmPassword) {
errorMessage = 'Las contraseñas no coinciden';
return;
}
isLoading = true;
try {
const response = await fetch('/api/v1/auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, new_password: newPassword })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Error al restablecer la contraseña');
}
success = true;
// Redirigir al login después de 3 segundos
setTimeout(() => goto('/login'), 3000);
} catch (e: any) {
errorMessage = e.message;
} finally {
isLoading = false;
}
}
</script>
<svelte:head>
<title>Nueva contraseña - ServiceManager</title>
</svelte:head>
<div class="min-h-screen bg-gray-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
<div class="sm:mx-auto sm:w-full sm:max-w-md">
<!-- Logo -->
<div class="flex justify-center mb-6">
<a href="/login" class="flex items-center space-x-2">
<div class="w-10 h-10 bg-blue-700 rounded-lg flex items-center justify-center">
<Icon name="ticket" class="w-6 h-6 text-white" />
</div>
<span class="text-xl font-bold text-gray-900">ServiceManager</span>
</a>
</div>
<div class="bg-white py-10 px-8 shadow-sm rounded-xl border border-gray-200">
{#if success}
<!-- Estado de éxito -->
<div class="text-center space-y-4">
<div class="w-14 h-14 bg-green-100 rounded-full flex items-center justify-center mx-auto">
<Icon name="check-circle" class="w-7 h-7 text-green-600" />
</div>
<h2 class="text-xl font-bold text-gray-900">¡Contraseña actualizada!</h2>
<p class="text-sm text-gray-600">
Tu contraseña ha sido restablecida correctamente.
Serás redirigido al inicio de sesión en unos segundos.
</p>
<a
href="/login"
class="inline-block mt-4 py-2.5 px-6 text-sm font-bold text-white bg-blue-700 rounded-lg hover:bg-blue-800 transition-colors"
>
Ir al inicio de sesión
</a>
</div>
{:else}
<!-- Formulario -->
<div class="space-y-6">
<div class="text-center space-y-1">
<h2 class="text-2xl font-bold text-gray-900">Nueva contraseña</h2>
<p class="text-sm text-gray-500">
Crea una contraseña segura para tu cuenta.
</p>
</div>
{#if errorMessage}
<div class="p-3 rounded-lg bg-red-50 border border-red-100 flex items-start gap-2 text-sm text-red-600">
<Icon name="alert-circle" class="w-4 h-4 shrink-0 mt-0.5" />
<span>{errorMessage}</span>
</div>
{/if}
<form on:submit|preventDefault={handleSubmit} class="space-y-5">
<div>
<label for="new-password" class="block text-sm font-semibold text-gray-700 mb-1.5">
Nueva contraseña
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Icon name="lock" class="w-5 h-5 text-gray-400" />
</div>
<input
id="new-password"
type={showPassword ? 'text' : 'password'}
class="block w-full pl-10 pr-10 py-3 border border-gray-300 rounded-lg text-sm text-gray-900 focus:ring-2 focus:ring-blue-600 focus:border-transparent outline-none transition-all"
placeholder="Mínimo 8 caracteres"
bind:value={newPassword}
disabled={isLoading || !token}
minlength="8"
required
/>
<button
type="button"
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600"
on:click={() => (showPassword = !showPassword)}
tabindex="-1"
>
<Icon name={showPassword ? 'eye-off' : 'eye'} class="w-5 h-5" />
</button>
</div>
</div>
<div>
<label for="confirm-password" class="block text-sm font-semibold text-gray-700 mb-1.5">
Confirmar contraseña
</label>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Icon name="lock" class="w-5 h-5 text-gray-400" />
</div>
<input
id="confirm-password"
type={showPassword ? 'text' : 'password'}
class="block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-lg text-sm text-gray-900 focus:ring-2 focus:ring-blue-600 focus:border-transparent outline-none transition-all
{confirmPassword && confirmPassword !== newPassword ? 'border-red-400 focus:ring-red-400' : ''}
{confirmPassword && confirmPassword === newPassword ? 'border-green-400' : ''}"
placeholder="Repite la contraseña"
bind:value={confirmPassword}
disabled={isLoading || !token}
required
/>
</div>
{#if confirmPassword && confirmPassword !== newPassword}
<p class="mt-1 text-xs text-red-500">Las contraseñas no coinciden</p>
{/if}
</div>
<!-- Password strength hint -->
<div class="bg-gray-50 rounded-lg px-4 py-3 text-xs text-gray-500 space-y-1">
<p class="font-medium text-gray-600">Requisitos:</p>
<p class:text-green-600={newPassword.length >= 8} class:text-gray-400={newPassword.length < 8}>
✓ Mínimo 8 caracteres
</p>
</div>
<button
type="submit"
class="w-full flex justify-center items-center gap-2 py-3.5 px-4 text-sm font-bold text-white bg-blue-700 rounded-lg hover:bg-blue-800 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
disabled={isLoading || !token}
>
{#if isLoading}
<Icon name="loader-2" class="w-4 h-4 animate-spin" />
Guardando...
{:else}
Establecer nueva contraseña
{/if}
</button>
</form>
<div class="text-center pt-2">
<a href="/login" class="text-sm text-blue-600 hover:text-blue-500 font-medium">
← Volver al inicio de sesión
</a>
</div>
</div>
{/if}
</div>
<p class="mt-6 text-center text-xs text-gray-400">
© 2026 Aduanasoft. Acceso exclusivo autorizado.
</p>
</div>
</div>

View File

@@ -120,7 +120,7 @@
<div class="ml-3">
<p class="text-sm font-medium text-gray-500">Esperando</p>
<p class="text-2xl font-semibold text-gray-900">
{statusCounts['WAITING_FOR_CLIENT'] || 0}
{statusCounts['WAITING_CUSTOMER'] || 0}
</p>
</div>
</div>
@@ -171,7 +171,7 @@
<option value="">Todos los estados</option>
<option value="NEW">Nuevo</option>
<option value="IN_PROGRESS">En Progreso</option>
<option value="WAITING_FOR_CLIENT">Esperando Cliente</option>
<option value="WAITING_CUSTOMER">Esperando Cliente</option>
<option value="RESOLVED">Resuelto</option>
<option value="CLOSED">Cerrado</option>
<option value="REOPENED">Reabierto</option>

View File

@@ -6,6 +6,10 @@ export default defineConfig({
server: {
port: 3000,
host: '0.0.0.0',
watch: {
usePolling: true,
interval: 500
},
proxy: {
'/api': {
target: process.env.PUBLIC_API_URL || 'http://backend:8000',