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:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@servicemanager/internal-frontend",
|
||||
"version": "1.6.0",
|
||||
"version": "1.9.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -160,14 +160,17 @@
|
||||
|
||||
<!-- User info -->
|
||||
<div class="px-4 py-4 border-t border-gray-200">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center">
|
||||
<a
|
||||
href="/profile"
|
||||
class="flex items-center space-x-3 rounded-lg p-1 -m-1 hover:bg-gray-100 transition-colors group"
|
||||
>
|
||||
<div class="w-8 h-8 bg-primary-100 rounded-full flex items-center justify-center shrink-0">
|
||||
<span class="text-primary-600 text-sm font-medium">
|
||||
{$auth.user?.first_name?.[0]}{$auth.user?.last_name?.[0]}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 truncate">
|
||||
<p class="text-sm font-medium text-gray-900 truncate group-hover:text-primary-600">
|
||||
{$auth.user?.first_name}
|
||||
{$auth.user?.last_name}
|
||||
</p>
|
||||
@@ -181,12 +184,20 @@
|
||||
: 'Auditor'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
class="w-4 h-4 text-gray-400 group-hover:text-primary-500 shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Version info -->
|
||||
<div class="px-4 py-2 border-t border-gray-100">
|
||||
<p class="text-xs text-gray-400 text-center">v1.6.0</p>
|
||||
<p class="text-xs text-gray-400 text-center">v1.9.0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
73
frontend-internal/src/lib/utils/colorUtils.ts
Normal file
73
frontend-internal/src/lib/utils/colorUtils.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
// Utilidades de colores para diferentes estados y severidades
|
||||
type ColorType = 'severity' | 'status' | 'action' | 'priority';
|
||||
|
||||
const COLOR_MAPS = {
|
||||
severity: {
|
||||
'critical': 'bg-red-600 text-white',
|
||||
'high': 'bg-orange-600 text-white',
|
||||
'medium': 'bg-yellow-500 text-white',
|
||||
'low': 'bg-blue-600 text-white'
|
||||
},
|
||||
status: {
|
||||
'active': 'bg-blue-600 text-white',
|
||||
'open': 'bg-blue-600 text-white',
|
||||
'resolved': 'bg-green-600 text-white',
|
||||
'closed': 'bg-green-600 text-white',
|
||||
'investigating': 'bg-yellow-500 text-white',
|
||||
'new': 'bg-blue-500 text-white',
|
||||
'in_progress': 'bg-purple-600 text-white',
|
||||
'waiting_customer': 'bg-orange-500 text-white',
|
||||
'reopened': 'bg-red-500 text-white'
|
||||
},
|
||||
action: {
|
||||
'delete': 'bg-red-600 text-white',
|
||||
'update': 'bg-blue-600 text-white',
|
||||
'login': 'bg-indigo-600 text-white',
|
||||
'logout': 'bg-indigo-600 text-white',
|
||||
'create': 'bg-green-600 text-white',
|
||||
'failed': 'bg-red-500 text-white'
|
||||
},
|
||||
priority: {
|
||||
'urgent': 'bg-red-600 text-white',
|
||||
'high': 'bg-orange-500 text-white',
|
||||
'medium': 'bg-yellow-500 text-white',
|
||||
'low': 'bg-blue-500 text-white'
|
||||
}
|
||||
};
|
||||
|
||||
export function getColorClass(value: string, type: ColorType = 'status'): string {
|
||||
const map = COLOR_MAPS[type];
|
||||
const key = value?.toLowerCase();
|
||||
|
||||
if (type === 'action') {
|
||||
const matchKey = Object.keys(map).find(k => key?.includes(k));
|
||||
return map[matchKey as keyof typeof map] || 'bg-gray-600 text-white';
|
||||
}
|
||||
|
||||
return map[key as keyof typeof map] || 'bg-gray-600 text-white';
|
||||
}
|
||||
|
||||
export function getStatusIcon(status: string): string {
|
||||
const icons = {
|
||||
'active': '🔴',
|
||||
'open': '📂',
|
||||
'resolved': '✅',
|
||||
'closed': '🔒',
|
||||
'investigating': '🔍',
|
||||
'new': '🆕',
|
||||
'in_progress': '⚙️',
|
||||
'waiting_customer': '⏳',
|
||||
'reopened': '🔄'
|
||||
};
|
||||
return icons[status?.toLowerCase() as keyof typeof icons] || '📋';
|
||||
}
|
||||
|
||||
export function getSeverityIcon(severity: string): string {
|
||||
const icons = {
|
||||
'critical': '🚨',
|
||||
'high': '⚠️',
|
||||
'medium': '⚡',
|
||||
'low': 'ℹ️'
|
||||
};
|
||||
return icons[severity?.toLowerCase() as keyof typeof icons] || '📊';
|
||||
}
|
||||
77
frontend-internal/src/lib/utils/dateFormats.ts
Normal file
77
frontend-internal/src/lib/utils/dateFormats.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
// Utilidades de formato de fecha
|
||||
export type DateFormat = 'full' | 'short' | 'simple' | 'time';
|
||||
|
||||
export function formatDate(dateString: string, format: DateFormat = 'full'): string {
|
||||
const date = new Date(dateString);
|
||||
const today = new Date();
|
||||
const isToday = date.toDateString() === today.toDateString();
|
||||
|
||||
const formats = {
|
||||
full: () => date.toLocaleString('es-MX', {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
}),
|
||||
short: () => isToday
|
||||
? date.toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' })
|
||||
: date.toLocaleDateString('es-MX', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }),
|
||||
simple: () => date.toLocaleDateString('es-MX', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
}),
|
||||
time: () => date.toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' })
|
||||
};
|
||||
|
||||
return formats[format]();
|
||||
}
|
||||
|
||||
export function getRelativeTime(dateString: string): string {
|
||||
const now = new Date().getTime();
|
||||
const then = new Date(dateString).getTime();
|
||||
const diffMs = now - then;
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return 'Hace un momento';
|
||||
if (diffMins < 60) return `Hace ${diffMins} minuto${diffMins > 1 ? 's' : ''}`;
|
||||
if (diffHours < 24) return `Hace ${diffHours} hora${diffHours > 1 ? 's' : ''}`;
|
||||
if (diffDays < 7) return `Hace ${diffDays} día${diffDays > 1 ? 's' : ''}`;
|
||||
return formatDate(dateString, 'short');
|
||||
}
|
||||
|
||||
export function getDateRangeForPeriod(periodFilter: string, customDateFrom?: string, customDateTo?: string): { from: string; to: string } {
|
||||
const now = new Date();
|
||||
let from: Date;
|
||||
let to: Date = new Date();
|
||||
|
||||
switch (periodFilter) {
|
||||
case 'today':
|
||||
from = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0));
|
||||
break;
|
||||
case 'yesterday':
|
||||
from = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - 1, 0, 0, 0));
|
||||
to = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0));
|
||||
break;
|
||||
case 'last7days':
|
||||
from = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - 7, 0, 0, 0));
|
||||
break;
|
||||
case 'last30days':
|
||||
from = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - 30, 0, 0, 0));
|
||||
break;
|
||||
case 'custom':
|
||||
if (!customDateFrom || !customDateTo) {
|
||||
from = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0));
|
||||
} else {
|
||||
from = new Date(customDateFrom + 'T00:00:00Z');
|
||||
to = new Date(customDateTo + 'T23:59:59Z');
|
||||
}
|
||||
break;
|
||||
default:
|
||||
from = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 0, 0, 0));
|
||||
}
|
||||
|
||||
return {
|
||||
from: from.toISOString(),
|
||||
to: to.toISOString()
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
417
frontend-internal/src/routes/profile/+page.svelte
Normal file
417
frontend-internal/src/routes/profile/+page.svelte
Normal file
@@ -0,0 +1,417 @@
|
||||
<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';
|
||||
|
||||
// --- Seguridad general ---
|
||||
let currentPassword = '';
|
||||
let newPassword = '';
|
||||
let confirmPassword = '';
|
||||
let isChangingPassword = false;
|
||||
|
||||
// --- 2FA ---
|
||||
let is2faLoading = false;
|
||||
let show2faSetup = false;
|
||||
let qrUri = '';
|
||||
let totpSetupCode = '';
|
||||
let backupCodes: string[] = [];
|
||||
let showBackupCodes = false;
|
||||
let show2faDisable = false;
|
||||
let disableTotpCode = '';
|
||||
|
||||
onMount(() => {
|
||||
if (!$auth.isAuthenticated) goto('/login');
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Cambio de contraseña
|
||||
// ============================================================
|
||||
|
||||
async function handlePasswordChange() {
|
||||
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||
toast.error('Completa todos los campos de contraseña');
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
toast.error('Las contraseñas nuevas no coinciden');
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
toast.error('La nueva contraseña debe tener al menos 8 caracteres');
|
||||
return;
|
||||
}
|
||||
|
||||
isChangingPassword = true;
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/change-password', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${$auth.token}`
|
||||
},
|
||||
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.detail || 'Error al cambiar contraseña');
|
||||
}
|
||||
|
||||
currentPassword = '';
|
||||
newPassword = '';
|
||||
confirmPassword = '';
|
||||
toast.success('Contraseña actualizada correctamente');
|
||||
} catch (e: any) {
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
isChangingPassword = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 2FA
|
||||
// ============================================================
|
||||
|
||||
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;
|
||||
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 la hora de tu dispositivo.');
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
function roleLabel(role: string | undefined) {
|
||||
const labels: Record<string, string> = {
|
||||
ADMIN: 'Administrador',
|
||||
SUPPORT_MANAGER: 'Gerente de Soporte',
|
||||
AGENT: 'Agente',
|
||||
AUDITOR: 'Auditor'
|
||||
};
|
||||
return role ? (labels[role] ?? role) : '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Mi Perfil - ServiceManager</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-3xl mx-auto px-4 py-8 space-y-8">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Mi Perfil</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">Configuración de tu cuenta y seguridad</p>
|
||||
</div>
|
||||
|
||||
<!-- Información de la cuenta -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||
<h2 class="text-base font-semibold text-gray-900">Información de la cuenta</h2>
|
||||
</div>
|
||||
<div class="px-6 py-5">
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<div class="w-14 h-14 bg-primary-100 rounded-full flex items-center justify-center text-primary-700 text-xl font-bold select-none">
|
||||
{$auth.user?.first_name?.[0]}{$auth.user?.last_name?.[0]}
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-base font-semibold text-gray-900">
|
||||
{$auth.user?.first_name} {$auth.user?.last_name}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500">{$auth.user?.email}</p>
|
||||
<span class="inline-block mt-1 px-2 py-0.5 text-xs font-medium bg-blue-100 text-blue-800 rounded-full">
|
||||
{roleLabel($auth.user?.role)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<dt class="text-gray-500 font-medium">ID de usuario</dt>
|
||||
<dd class="text-gray-900 font-mono mt-0.5">{$auth.user?.id?.substring(0, 8)}...</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-gray-500 font-medium">Tenant ID</dt>
|
||||
<dd class="text-gray-900 font-mono mt-0.5">{$auth.user?.tenant_id?.substring(0, 8)}...</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-gray-500 font-medium">Estado</dt>
|
||||
<dd class="mt-0.5">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium {$auth.user?.is_active ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}">
|
||||
{$auth.user?.is_active ? 'Activo' : 'Inactivo'}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Seguridad: 2FA -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||
<h2 class="text-base font-semibold text-gray-900">Autenticación de dos factores (2FA)</h2>
|
||||
<p class="text-sm text-gray-500 mt-0.5">
|
||||
Protege tu cuenta con una capa adicional de verificación al iniciar sesión.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
{#if $auth.user?.is_two_factor_enabled}
|
||||
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">2FA habilitado</p>
|
||||
<p class="text-xs text-gray-500">Tu cuenta está protegida con TOTP</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
|
||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">2FA no habilitado</p>
|
||||
<p class="text-xs text-gray-500">Recomendado para cuentas de staff interno</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{#if $auth.user?.is_two_factor_enabled}
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm text-red-600 hover:text-red-800 font-medium border border-red-200 px-3 py-1.5 rounded hover:bg-red-50 transition-colors disabled:opacity-50"
|
||||
on:click={() => { show2faDisable = !show2faDisable; disableTotpCode = ''; }}
|
||||
disabled={is2faLoading}
|
||||
>Deshabilitar</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm bg-gray-900 text-white px-3 py-1.5 rounded font-medium hover:bg-gray-700 transition-colors disabled:opacity-50"
|
||||
on:click={setup2fa}
|
||||
disabled={is2faLoading}
|
||||
>
|
||||
{is2faLoading ? 'Cargando...' : 'Configurar 2FA'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 1: QR Code -->
|
||||
{#if show2faSetup && qrUri}
|
||||
<div class="mt-5 pt-5 border-t border-gray-200 space-y-4">
|
||||
<p class="text-sm font-medium text-gray-800">
|
||||
1. Escanea el código QR con Google Authenticator, Authy u otra app TOTP:
|
||||
</p>
|
||||
<div class="flex justify-center bg-gray-50 border border-gray-200 rounded p-4">
|
||||
<img
|
||||
src="https://api.qrserver.com/v1/create-qr-code/?size=180x180&data={encodeURIComponent(qrUri)}"
|
||||
alt="QR 2FA"
|
||||
class="w-44 h-44"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-sm font-medium text-gray-800">
|
||||
2. Ingresa el código generado por la app para confirmar:
|
||||
</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
class="w-36 border border-gray-300 rounded px-3 py-2 text-center tracking-widest font-mono text-lg focus:ring-2 focus:ring-gray-900 focus:border-transparent outline-none"
|
||||
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 transition-colors"
|
||||
on:click={enable2fa}
|
||||
disabled={is2faLoading}
|
||||
>
|
||||
{is2faLoading ? 'Verificando...' : 'Confirmar y activar'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm text-gray-500 hover:text-gray-700 font-medium"
|
||||
on:click={() => { show2faSetup = false; }}
|
||||
>Cancelar</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Backup codes -->
|
||||
{#if showBackupCodes && backupCodes.length > 0}
|
||||
<div class="mt-5 pt-5 border-t border-green-200 bg-green-50 rounded-b-lg -mx-6 -mb-5 px-6 pb-5">
|
||||
<h4 class="font-semibold text-green-900 mb-1">✅ 2FA activado — Guarda tus códigos de respaldo</h4>
|
||||
<p class="text-sm text-green-700 mb-3">
|
||||
Estos códigos son de <strong>un solo uso</strong>. Guárdalos en un lugar seguro para acceder sin tu dispositivo.
|
||||
</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 hover:text-green-900"
|
||||
on:click={() => { showBackupCodes = false; backupCodes = []; }}
|
||||
>He guardado mis códigos de respaldo</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Disable confirmation -->
|
||||
{#if show2faDisable}
|
||||
<div class="mt-5 pt-5 border-t border-red-200 bg-red-50 rounded-b-lg -mx-6 -mb-5 px-6 pb-5">
|
||||
<p class="text-sm font-medium text-red-800 mb-3">
|
||||
Ingresa el código de tu app autenticadora para confirmar:
|
||||
</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
class="w-36 border border-red-300 rounded px-3 py-2 text-center tracking-widest font-mono text-lg focus:ring-2 focus:ring-red-500 outline-none"
|
||||
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 transition-colors"
|
||||
on:click={disable2fa}
|
||||
disabled={is2faLoading}
|
||||
>
|
||||
{is2faLoading ? 'Verificando...' : 'Confirmar y deshabilitar'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm text-gray-500 hover:text-gray-700"
|
||||
on:click={() => { show2faDisable = false; }}
|
||||
>Cancelar</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cambio de contraseña -->
|
||||
<div class="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||
<h2 class="text-base font-semibold text-gray-900">Cambiar contraseña</h2>
|
||||
<p class="text-sm text-gray-500 mt-0.5">Actualiza tu contraseña de acceso al sistema.</p>
|
||||
</div>
|
||||
|
||||
<form on:submit|preventDefault={handlePasswordChange} class="px-6 py-5 space-y-4">
|
||||
<div>
|
||||
<label for="current-password" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Contraseña actual <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="current-password"
|
||||
type="password"
|
||||
class="w-full border border-gray-300 rounded px-3 py-2 focus:ring-2 focus:ring-gray-900 focus:border-transparent outline-none text-sm"
|
||||
bind:value={currentPassword}
|
||||
disabled={isChangingPassword}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="new-password" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Nueva contraseña <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="new-password"
|
||||
type="password"
|
||||
class="w-full border border-gray-300 rounded px-3 py-2 focus:ring-2 focus:ring-gray-900 focus:border-transparent outline-none text-sm"
|
||||
bind:value={newPassword}
|
||||
disabled={isChangingPassword}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="confirm-password" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
Confirmar nueva contraseña <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
class="w-full border border-gray-300 rounded px-3 py-2 focus:ring-2 focus:ring-gray-900 focus:border-transparent outline-none text-sm"
|
||||
bind:value={confirmPassword}
|
||||
disabled={isChangingPassword}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
class="bg-gray-900 text-white px-5 py-2 rounded font-medium hover:bg-gray-700 disabled:opacity-50 transition-colors text-sm"
|
||||
disabled={isChangingPassword}
|
||||
>
|
||||
{isChangingPassword ? 'Guardando...' : 'Cambiar contraseña'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,22 +2,26 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()],
|
||||
server: {
|
||||
port: 3000,
|
||||
host: '0.0.0.0',
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.PUBLIC_API_URL || 'http://backend:8000',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, '')
|
||||
}
|
||||
}
|
||||
},
|
||||
preview: {
|
||||
port: 3000,
|
||||
host: '0.0.0.0'
|
||||
},
|
||||
plugins: [sveltekit()],
|
||||
server: {
|
||||
port: 3000,
|
||||
host: '0.0.0.0',
|
||||
watch: {
|
||||
usePolling: true,
|
||||
interval: 500
|
||||
},
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.PUBLIC_API_URL || 'http://backend:8000',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, '')
|
||||
}
|
||||
}
|
||||
},
|
||||
preview: {
|
||||
port: 3000,
|
||||
host: '0.0.0.0'
|
||||
},
|
||||
build: {
|
||||
target: 'esnext'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user