tenant arreglado

This commit is contained in:
2026-03-05 14:04:04 -07:00
parent 3b46f48655
commit d8232fda7c
8 changed files with 299 additions and 118 deletions

View File

@@ -1,7 +1,7 @@
"""
Auth Schemas - ServiceManagerWeb
Pydantic schemas para autenticación y autorización.
Pydantic schemas para autenticación y autorización.
"""
from pydantic import BaseModel, EmailStr
@@ -12,7 +12,7 @@ class LoginRequest(BaseModel):
"""Schema para solicitud de login."""
email: EmailStr
password: str
tenant_slug: str
tenant_slug: Optional[str] = None
totp_code: Optional[str] = None
@@ -53,28 +53,28 @@ class TwoFactorSetupResponse(BaseModel):
class TwoFactorEnableRequest(BaseModel):
"""Código TOTP para confirmar y activar 2FA."""
"""Código TOTP para confirmar y activar 2FA."""
totp_code: str
class TwoFactorEnableResponse(BaseModel):
"""Resultado al habilitar 2FA: incluye los códigos de respaldo."""
"""Resultado al habilitar 2FA: incluye los códigos de respaldo."""
enabled: bool
backup_codes: List[str]
class TwoFactorDisableRequest(BaseModel):
"""Deshabilitar 2FA verificando con TOTP o código de respaldo."""
"""Deshabilitar 2FA verificando con TOTP o código de respaldo."""
totp_code: Optional[str] = None
backup_code: Optional[str] = None
# ============================================================
# Cambio de contraseña
# Cambio de contraseña
# ============================================================
class ChangePasswordRequest(BaseModel):
"""Schema para cambio de contraseña del usuario autenticado."""
"""Schema para cambio de contraseña del usuario autenticado."""
current_password: str
new_password: str
@@ -82,15 +82,15 @@ class ChangePasswordRequest(BaseModel):
# ============================================================
# Recuperación de contraseña
# Recuperación de contraseña
# ============================================================
class ForgotPasswordRequest(BaseModel):
"""Solicitar enlace de reseteo de contraseña por email."""
"""Solicitar enlace de reseteo de contraseña por email."""
email: EmailStr
class ResetPasswordRequest(BaseModel):
"""Aplicar nueva contraseña usando token de reseteo."""
"""Aplicar nueva contraseña usando token de reseteo."""
token: str
new_password: str

View File

@@ -1,7 +1,7 @@
"""
Authentication Endpoints - ServiceManagerWeb
Endpoints para autenticación y autorización
Endpoints para autenticación y autorización
"""
from fastapi import APIRouter, HTTPException, status, Depends, Request, Response
@@ -28,7 +28,7 @@ CLIENT_ROLES = {"CLIENT_ADMIN", "CLIENT_USER"}
def _cookie_name_for_role(role: str) -> str:
"""Devuelve el nombre de cookie según el rol del usuario."""
"""Devuelve el nombre de cookie según el rol del usuario."""
return "client_access_token" if role in CLIENT_ROLES else "internal_access_token"
from app.api.schemas.auth import (
LoginRequest, LoginResponse, RefreshTokenRequest, TokenResponse,
@@ -88,25 +88,23 @@ async def login(
headers={"Retry-After": str(settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)},
)
# 1. Validar tenant
tenant_result = await db.execute(
select(Tenant).where(Tenant.slug == login_data.tenant_slug)
)
tenant = tenant_result.scalar_one_or_none()
if tenant is None:
logger.warning(
"Login failed - tenant not found",
email=login_data.email,
tenant_slug=login_data.tenant_slug,
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found",
# 1. Validar tenant - por slug si viene, sino buscar por email
if login_data.tenant_slug:
tenant_result = await db.execute(
select(Tenant).where(Tenant.slug == login_data.tenant_slug)
)
tenant = tenant_result.scalar_one_or_none()
if tenant is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found",
)
else:
tenant = None
# Rate limiting (best-effort): by (tenant,email) to slow brute force.
ident_key = None
if settings.RATE_LIMIT_ENABLED and not settings.TESTING:
if settings.RATE_LIMIT_ENABLED and not settings.TESTING and tenant:
email_norm = login_data.email.strip().lower()
ident_key = cache_key("rl", "login", "id", str(tenant.id), email_norm)
ident_count = await cache.incr(ident_key, 1)
@@ -142,22 +140,25 @@ async def login(
headers={"Retry-After": str(settings.LOGIN_RATE_LIMIT_WINDOW_SECONDS)},
)
# 2. Buscar usuario en base de datos (aislado por tenant)
query = select(User).where(
User.email == login_data.email,
User.tenant_id == tenant.id,
)
# 2. Buscar usuario - filtrar por tenant si se detecto, sino buscar por email
if tenant:
query = select(User).where(
User.email == login_data.email,
User.tenant_id == tenant.id,
)
else:
query = select(User).where(User.email == login_data.email)
result = await db.execute(query)
user = result.scalar_one_or_none()
# 3. Verificar usuario y contraseña
# 3. Verificar usuario y contraseña
if not user or not security.verify_password(login_data.password, user.password_hash):
logger.warning(
"Login failed - invalid credentials",
email=login_data.email
)
# Registrar intento fallido en auditoría (si el usuario existe)
# Registrar intento fallido en auditoría (si el usuario existe)
if user:
try:
await AuditService.log(
@@ -178,7 +179,7 @@ async def login(
detail="Invalid credentials",
)
# 4. Verificar si está activo
# 4. Verificar si está activo
if not user.is_active:
logger.warning(
"Login failed - user inactive",
@@ -189,19 +190,19 @@ async def login(
detail="User inactive",
)
# 5. Verificar 2FA si está habilitado
# 5. Verificar 2FA si está habilitado
if user.totp_enabled:
if not login_data.totp_code:
# Indicar al frontend que debe pedir el código TOTP
# Indicar al frontend que debe pedir el código TOTP
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Se requiere autenticación de dos factores (2FA). Ingresa tu código."
detail="Se requiere autenticación de dos factores (2FA). Ingresa tu código."
)
if not security.verify_totp(user.totp_secret, login_data.totp_code):
logger.warning("Login failed - invalid 2FA code", email=login_data.email)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Código 2FA inválido o expirado"
detail="Código 2FA inválido o expirado"
)
# Create tokens
@@ -233,7 +234,7 @@ async def login(
detail="Service temporarily unavailable",
)
# Registrar login exitoso en auditoría
# Registrar login exitoso en auditoría
try:
await AuditService.log(
db=db,
@@ -392,7 +393,7 @@ async def logout(
except Exception as e:
logger.warning("Failed to revoke refresh tokens on logout", error=str(e))
# Registrar logout en auditoría
# Registrar logout en auditoría
try:
import uuid
user_id = uuid.UUID(payload["sub"])
@@ -413,7 +414,7 @@ async def logout(
logger.info("Logout successful", user_id=payload["sub"])
# Borrar la cookie correcta según el rol del usuario
# Borrar la cookie correcta según el rol del usuario
cookie_name = _cookie_name_for_role(payload.get("role", ""))
response.delete_cookie(key=cookie_name)
return {"message": "Successfully logged out"}
@@ -502,7 +503,7 @@ async def get_2fa_status(
current_user: User = Depends(get_current_user),
):
"""
Consultar si el 2FA está habilitado para el usuario actual.
Consultar si el 2FA está habilitado para el usuario actual.
Returns:
Estado de 2FA del usuario autenticado.
@@ -516,10 +517,10 @@ async def setup_2fa(
db: AsyncSession = Depends(get_db),
):
"""
Iniciar configuración de 2FA: genera un nuevo TOTP secret y QR URI.
Iniciar configuración de 2FA: genera un nuevo TOTP secret y QR URI.
El secret se guarda en BD pero 2FA NO se activa todavía.
Se necesita llamar a /2fa/enable con un código válido para activarlo.
El secret se guarda en BD pero 2FA NO se activa todavía.
Se necesita llamar a /2fa/enable con un código válido para activarlo.
Returns:
Secret y QR URI para escanear con la app autenticadora.
@@ -527,7 +528,7 @@ async def setup_2fa(
new_secret = security.generate_totp_secret()
qr_uri = security.generate_totp_uri(new_secret, current_user.email)
# Guardar el secret (sin habilitar aún)
# Guardar el secret (sin habilitar aún)
current_user.totp_secret = new_secret
await db.commit()
@@ -543,29 +544,29 @@ async def enable_2fa(
db: AsyncSession = Depends(get_db),
):
"""
Activar 2FA verificando que el usuario escaneó correctamente el QR.
Activar 2FA verificando que el usuario escaneó correctamente el QR.
Requiere que /2fa/setup haya sido llamado previamente.
Args:
data: Código TOTP generado por la app autenticadora.
data: Código TOTP generado por la app autenticadora.
Returns:
Confirmación y lista de códigos de respaldo.
Confirmación y lista de códigos de respaldo.
"""
if not current_user.totp_secret:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Primero inicia el proceso de configuración con /2fa/setup"
detail="Primero inicia el proceso de configuración con /2fa/setup"
)
if not security.verify_totp(current_user.totp_secret, data.totp_code):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Código TOTP inválido. Verifica la hora de tu dispositivo e intenta de nuevo."
detail="Código TOTP inválido. Verifica la hora de tu dispositivo e intenta de nuevo."
)
# Activar 2FA y generar códigos de respaldo
# Activar 2FA y generar códigos de respaldo
backup_codes = security.generate_backup_codes()
current_user.totp_enabled = True
current_user.backup_codes = backup_codes
@@ -593,21 +594,21 @@ async def disable_2fa(
db: AsyncSession = Depends(get_db),
):
"""
Deshabilitar 2FA verificando con código TOTP o código de respaldo.
Deshabilitar 2FA verificando con código TOTP o código de respaldo.
Args:
data: totp_code o backup_code para verificar identidad.
Returns:
Mensaje de confirmación.
Mensaje de confirmación.
"""
if not current_user.totp_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="El 2FA no está habilitado en esta cuenta"
detail="El 2FA no está habilitado en esta cuenta"
)
# Verificar con TOTP o código de respaldo
# Verificar con TOTP o código de respaldo
verified = False
if data.totp_code:
@@ -615,7 +616,7 @@ async def disable_2fa(
elif data.backup_code and current_user.backup_codes:
if data.backup_code in current_user.backup_codes:
verified = True
# Invalidar el código de respaldo usado
# Invalidar el código de respaldo usado
current_user.backup_codes = [
c for c in current_user.backup_codes if c != data.backup_code
]
@@ -623,7 +624,7 @@ async def disable_2fa(
if not verified:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Verificación fallida. Proporciona un código TOTP o un código de respaldo válido."
detail="Verificación fallida. Proporciona un código TOTP o un código de respaldo válido."
)
# Deshabilitar 2FA
@@ -644,7 +645,7 @@ async def disable_2fa(
logger.info("2FA disabled", user_id=str(current_user.id))
return {"message": "Autenticación de dos factores deshabilitada correctamente"}
return {"message": "Autenticación de dos factores deshabilitada correctamente"}
@router.post("/change-password", status_code=status.HTTP_200_OK)
@@ -654,32 +655,32 @@ async def change_password(
db: AsyncSession = Depends(get_db),
):
"""
Cambiar la contraseña del usuario autenticado.
Cambiar la contraseña del usuario autenticado.
Verifica la contraseña actual antes de actualizar.
Requiere autenticación activa.
Verifica la contraseña actual antes de actualizar.
Requiere autenticación activa.
"""
from datetime import datetime
# Validar longitud mínima
# Validar longitud mínima
if len(data.new_password) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="La nueva contraseña debe tener al menos 8 caracteres"
detail="La nueva contraseña debe tener al menos 8 caracteres"
)
# Verificar que la contraseña actual sea correcta
# Verificar que la contraseña actual sea correcta
if not security.verify_password(data.current_password, current_user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="La contraseña actual es incorrecta"
detail="La contraseña actual es incorrecta"
)
# No permitir que la nueva sea igual a la actual
if security.verify_password(data.new_password, current_user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="La nueva contraseña no puede ser igual a la actual"
detail="La nueva contraseña no puede ser igual a la actual"
)
current_user.password_hash = security.hash_password(data.new_password)
@@ -697,11 +698,11 @@ async def change_password(
await db.commit()
logger.info("Password changed", user_id=str(current_user.id))
return {"message": "Contraseña actualizada correctamente"}
return {"message": "Contraseña actualizada correctamente"}
# ============================================================
# Recuperación de contraseña (forgot / reset)
# Recuperación de contraseña (forgot / reset)
# ============================================================
_RESET_TOKEN_TTL = 1800 # 30 minutos en segundos
@@ -716,10 +717,10 @@ async def forgot_password(
db: AsyncSession = Depends(get_db),
):
"""
Solicitar reseteo de contraseña.
Solicitar reseteo de contraseña.
Siempre retorna 200 aunque el email no exista, para no revelar
si una dirección está registrada en el sistema.
si una dirección está registrada en el sistema.
"""
import secrets
from redis.asyncio import from_url as redis_from_url
@@ -735,9 +736,9 @@ async def forgot_password(
user = result.scalar_one_or_none()
if not user:
# Respuesta idéntica no revelar existencia
# Respuesta idéntica — no revelar existencia
logger.info("Forgot password: email not found", email=data.email)
return {"message": "Si el correo está registrado recibirás un enlace en breve."}
return {"message": "Si el correo está registrado recibirás un enlace en breve."}
# Generar token seguro
token = secrets.token_urlsafe(32)
@@ -757,7 +758,7 @@ async def forgot_password(
await send_email(
to_email=user.email,
subject="Restablece tu contraseña — ServiceManager",
subject="Restablece tu contraseña — ServiceManager",
html_content=html,
text_content=text,
)
@@ -774,7 +775,7 @@ async def forgot_password(
await db.commit()
logger.info("Password reset email sent", user_id=str(user.id))
return {"message": "Si el correo está registrado recibirás un enlace en breve."}
return {"message": "Si el correo está registrado recibirás un enlace en breve."}
@router.post("/reset-password", status_code=status.HTTP_200_OK)
@@ -785,7 +786,7 @@ async def reset_password(
db: AsyncSession = Depends(get_db),
):
"""
Aplicar nueva contraseña usando el token recibido por email.
Aplicar nueva contraseña usando el token recibido por email.
El token es de un solo uso: se elimina de Redis al usarse.
"""
@@ -796,7 +797,7 @@ async def reset_password(
if len(data.new_password) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="La contraseña debe tener al menos 8 caracteres"
detail="La contraseña debe tener al menos 8 caracteres"
)
redis_key = f"{_RESET_KEY_PREFIX}{data.token}"
@@ -807,7 +808,7 @@ async def reset_password(
if not user_id_str:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="El enlace de reseteo es inválido o ya expiró. Solicita uno nuevo."
detail="El enlace de reseteo es inválido o ya expiró. Solicita uno nuevo."
)
# Eliminar token inmediatamente (un solo uso)
@@ -838,4 +839,4 @@ async def reset_password(
await db.commit()
logger.info("Password reset completed", user_id=str(user.id))
return {"message": "Contraseña actualizada correctamente. Ya puedes iniciar sesión."}
return {"message": "Contraseña actualizada correctamente. Ya puedes iniciar sesión."}

67
backend/fix_login.py Normal file
View File

@@ -0,0 +1,67 @@
import re
with open("/app/app/api/v1/endpoints/auth.py", "r") as f:
content = f.read()
old = ''' # 1. Validar tenant
tenant_result = await db.execute(
select(Tenant).where(Tenant.slug == login_data.tenant_slug)
)
tenant = tenant_result.scalar_one_or_none()
if tenant is None:
logger.warning(
"Login failed - tenant not found",
email=login_data.email,
tenant_slug=login_data.tenant_slug,
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found",
)'''
new = ''' # 1. Validar tenant - por slug si viene, sino detectar por email
if login_data.tenant_slug:
tenant_result = await db.execute(
select(Tenant).where(Tenant.slug == login_data.tenant_slug)
)
tenant = tenant_result.scalar_one_or_none()
if tenant is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found",
)
else:
tenant = None'''
if old in content:
content = content.replace(old, new)
print("OK: bloque tenant reemplazado")
else:
print("ERROR: bloque no encontrado")
# Tambien actualizar la query de usuario para usar tenant o no
old2 = ''' # 2. Buscar usuario en base de datos (aislado por tenant)
query = select(User).where(
User.email == login_data.email,
User.tenant_id == tenant.id,
)'''
new2 = ''' # 2. Buscar usuario - filtrar por tenant si se detecto, sino buscar por email
if tenant:
query = select(User).where(
User.email == login_data.email,
User.tenant_id == tenant.id,
)
else:
query = select(User).where(User.email == login_data.email)'''
if old2 in content:
content = content.replace(old2, new2)
print("OK: bloque query reemplazado")
else:
print("ERROR: bloque query no encontrado")
with open("/app/app/api/v1/endpoints/auth.py", "w") as f:
f.write(content)
print("Listo")

23
backend/fix_ratelimit.py Normal file
View File

@@ -0,0 +1,23 @@
with open("/app/app/api/v1/endpoints/auth.py", "r") as f:
content = f.read()
old = ''' # Rate limiting (best-effort): by (tenant,email) to slow brute force.
ident_key = None
if settings.RATE_LIMIT_ENABLED and not settings.TESTING:
email_norm = login_data.email.strip().lower()
ident_key = cache_key("rl", "login", "id", str(tenant.id), email_norm)'''
new = ''' # Rate limiting (best-effort): by (tenant,email) to slow brute force.
ident_key = None
if settings.RATE_LIMIT_ENABLED and not settings.TESTING and tenant:
email_norm = login_data.email.strip().lower()
ident_key = cache_key("rl", "login", "id", str(tenant.id), email_norm)'''
if old in content:
content = content.replace(old, new)
print("OK: rate limiting fix aplicado")
else:
print("ERROR: bloque no encontrado")
with open("/app/app/api/v1/endpoints/auth.py", "w") as f:
f.write(content)

67
fix_login.py Normal file
View File

@@ -0,0 +1,67 @@
import re
with open("/app/app/api/v1/endpoints/auth.py", "r") as f:
content = f.read()
old = ''' # 1. Validar tenant
tenant_result = await db.execute(
select(Tenant).where(Tenant.slug == login_data.tenant_slug)
)
tenant = tenant_result.scalar_one_or_none()
if tenant is None:
logger.warning(
"Login failed - tenant not found",
email=login_data.email,
tenant_slug=login_data.tenant_slug,
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found",
)'''
new = ''' # 1. Validar tenant - por slug si viene, sino detectar por email
if login_data.tenant_slug:
tenant_result = await db.execute(
select(Tenant).where(Tenant.slug == login_data.tenant_slug)
)
tenant = tenant_result.scalar_one_or_none()
if tenant is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found",
)
else:
tenant = None'''
if old in content:
content = content.replace(old, new)
print("OK: bloque tenant reemplazado")
else:
print("ERROR: bloque no encontrado")
# Tambien actualizar la query de usuario para usar tenant o no
old2 = ''' # 2. Buscar usuario en base de datos (aislado por tenant)
query = select(User).where(
User.email == login_data.email,
User.tenant_id == tenant.id,
)'''
new2 = ''' # 2. Buscar usuario - filtrar por tenant si se detecto, sino buscar por email
if tenant:
query = select(User).where(
User.email == login_data.email,
User.tenant_id == tenant.id,
)
else:
query = select(User).where(User.email == login_data.email)'''
if old2 in content:
content = content.replace(old2, new2)
print("OK: bloque query reemplazado")
else:
print("ERROR: bloque query no encontrado")
with open("/app/app/api/v1/endpoints/auth.py", "w") as f:
f.write(content)
print("Listo")

23
fix_ratelimit.py Normal file
View File

@@ -0,0 +1,23 @@
with open("/app/app/api/v1/endpoints/auth.py", "r") as f:
content = f.read()
old = ''' # Rate limiting (best-effort): by (tenant,email) to slow brute force.
ident_key = None
if settings.RATE_LIMIT_ENABLED and not settings.TESTING:
email_norm = login_data.email.strip().lower()
ident_key = cache_key("rl", "login", "id", str(tenant.id), email_norm)'''
new = ''' # Rate limiting (best-effort): by (tenant,email) to slow brute force.
ident_key = None
if settings.RATE_LIMIT_ENABLED and not settings.TESTING and tenant:
email_norm = login_data.email.strip().lower()
ident_key = cache_key("rl", "login", "id", str(tenant.id), email_norm)'''
if old in content:
content = content.replace(old, new)
print("OK: rate limiting fix aplicado")
else:
print("ERROR: bloque no encontrado")
with open("/app/app/api/v1/endpoints/auth.py", "w") as f:
f.write(content)

View File

@@ -7,7 +7,7 @@
let email = '';
let password = '';
let tenantSlug = 'aduanasoft-demo';
let tenantSlug = 'ventas';
let totpCode = '';
let isLoading = false;
let showTwoFactor = false;
@@ -34,11 +34,11 @@
await auth.login({
email,
password,
tenant_slug: tenantSlug.trim() || 'aduanasoft-demo',
tenant_slug: tenantSlug.trim() || 'ventas',
totp_code: totpCode || undefined
});
toast.success('¡Bienvenido! Has iniciado sesión correctamente');
toast.success('¡Bienvenido! Has iniciado sesión correctamente');
goto('/');
} catch (error: any) {
console.error('Login error:', error);
@@ -46,9 +46,9 @@
// Check if 2FA is required
if (error.message.includes('two-factor') || error.message.includes('2FA')) {
showTwoFactor = true;
errorMessage = 'Introduce el código de tu aplicación de autenticación';
errorMessage = 'Introduce el código de tu aplicación de autenticación';
} else {
errorMessage = error.message || 'Error al iniciar sesión';
errorMessage = error.message || 'Error al iniciar sesión';
toast.error(errorMessage);
}
} finally {
@@ -96,8 +96,8 @@
de Servicios de TI
</h2>
<p class="text-lg text-blue-100/90 font-light max-w-lg leading-relaxed drop-shadow-md">
Portal de atención a clientes. Genere tickets de soporte técnico para nuestros sistemas y
reciba asistencia especializada para garantizar la continuidad de su operación.
Portal de atención a clientes. Genere tickets de soporte técnico para nuestros sistemas y
reciba asistencia especializada para garantizar la continuidad de su operación.
</p>
</div>
@@ -125,7 +125,7 @@
<div
class="p-3 rounded-md bg-red-50 border border-red-100 flex items-center gap-3 animate-fade-in text-sm text-red-600"
>
<Icon name="alert-circle" class="w-4 h-4 flex-shrink-0" />
<Icon name="alert-circle" className="w-4 h-4 flex-shrink-0" />
{errorMessage}
</div>
{/if}
@@ -135,13 +135,13 @@
<!-- Email Input -->
<div class="space-y-1.5">
<label for="email" class="block text-sm font-semibold text-gray-700"
>Correo Electrónico</label
>Correo Electrónico</label
>
<div class="relative group">
<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 group-focus-within:text-blue-600 transition-colors"
className="w-5 h-5 text-gray-400 group-focus-within:text-blue-600 transition-colors"
/>
</div>
<input
@@ -160,13 +160,13 @@
<!-- Password Input -->
<div class="space-y-1.5">
<label for="password" class="block text-sm font-semibold text-gray-700"
>Contraseña</label
>Contraseña</label
>
<div class="relative group">
<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 group-focus-within:text-blue-600 transition-colors"
className="w-5 h-5 text-gray-400 group-focus-within:text-blue-600 transition-colors"
/>
</div>
{#if showPassword}
@@ -176,7 +176,7 @@
bind:value={password}
on:keydown={handleKeyDown}
class="block w-full pl-10 pr-10 py-3 bg-[#fff9c4]/0 hover:bg-gray-50 focus:bg-white border text-gray-900 border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-transparent transition-all duration-200 sm:text-sm"
placeholder="••••••••"
placeholder="••••••••"
required
disabled={isLoading}
/>
@@ -187,7 +187,7 @@
bind:value={password}
on:keydown={handleKeyDown}
class="block w-full pl-10 pr-10 py-3 bg-[#fff9c4]/0 hover:bg-gray-50 focus:bg-white border text-gray-900 border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-600 focus:border-transparent transition-all duration-200 sm:text-sm"
placeholder="••••••••"
placeholder="••••••••"
required
disabled={isLoading}
/>
@@ -197,7 +197,7 @@
class="absolute inset-y-0 right-0 pr-3 flex items-center cursor-pointer text-gray-400 hover:text-gray-600 focus:outline-none"
on:click={() => (showPassword = !showPassword)}
>
<Icon name={showPassword ? 'eye-off' : 'eye'} class="w-5 h-5" />
<Icon name={showPassword ? 'eye-off' : 'eye'} className="w-5 h-5" />
</button>
</div>
</div>
@@ -221,7 +221,7 @@
class="text-sm font-medium text-blue-600 hover:text-blue-500 bg-transparent border-none p-0 cursor-pointer"
on:click={() => goto('/forgot-password')}
>
Olvide mi clave
Olvidé mi clave
</button>
</div>
</div>
@@ -229,13 +229,13 @@
<!-- 2FA Input -->
<div class="space-y-4 animate-slide-up">
<label for="code" class="block text-sm font-medium text-gray-700 text-center"
>Código de Verificación (2FA)</label
>Código de Verificación (2FA)</label
>
<p class="text-xs text-center text-gray-500 mb-4">Ingrese el código de 6 dígitos</p>
<p class="text-xs text-center text-gray-500 mb-4">Ingrese el código de 6 dígitos</p>
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Icon name="shield-check" class="w-5 h-5 text-blue-500" />
<Icon name="shield-check" className="w-5 h-5 text-blue-500" />
</div>
<input
id="code"
@@ -259,7 +259,7 @@
disabled={isLoading}
>
{#if isLoading}
<Icon name="loader-2" class="w-5 h-5 animate-spin mr-2" />
<Icon name="loader-2" className="w-5 h-5 animate-spin mr-2" />
Procesando...
{:else}
{showTwoFactor ? 'Verificar Acceso' : 'Acceder al Portal'}
@@ -268,7 +268,7 @@
</div>
<div class="mt-8 text-center text-xs text-gray-400">
© 2026 Aduanasoft. Acceso exclusivo autorizado.
© 2026 Aduanasoft. Acceso exclusivo autorizado.
</div>
</form>
</div>

View File

@@ -32,11 +32,11 @@
await auth.login({
email,
password,
tenant_slug: 'aduanasoft-demo',
tenant_slug: 'aduanasoft',
totp_code: totpCode || undefined
});
toast.success('¡Bienvenido! Has iniciado sesión correctamente');
toast.success('¡Bienvenido! Has iniciado sesión correctamente');
goto('/');
} catch (error: any) {
console.error('Login error:', error);
@@ -44,9 +44,9 @@
// Check if 2FA is required
if (error.message.includes('two-factor') || error.message.includes('2FA')) {
showTwoFactor = true;
errorMessage = 'Introduce el código de tu aplicación de autenticación';
errorMessage = 'Introduce el código de tu aplicación de autenticación';
} else {
errorMessage = error.message || 'Error al iniciar sesión';
errorMessage = error.message || 'Error al iniciar sesión';
toast.error(errorMessage);
}
} finally {
@@ -77,27 +77,27 @@
<div class="relative z-10">
<div class="flex items-center space-x-3 mb-6">
<div class="p-2 bg-blue-500/20 rounded border border-blue-500/30">
<Icon name="server" class="w-6 h-6 text-blue-400" />
<Icon name="server" className="w-6 h-6 text-blue-400" />
</div>
<span class="text-sm font-mono tracking-wider text-blue-400">INTERNAL_ACCESS_V2</span>
</div>
<h1 class="text-3xl font-bold tracking-tight mb-4">
Panel de Administración
Panel de Administración
</h1>
<p class="text-gray-400 text-sm leading-relaxed max-w-sm">
Plataforma de gestión de servicios, monitoreo de tickets y administración de usuarios. Acceso restringido únicamente a personal autorizado.
Plataforma de gestión de servicios, monitoreo de tickets y administración de usuarios. Acceso restringido únicamente a personal autorizado.
</p>
</div>
<div class="relative z-10 mt-12">
<div class="space-y-3">
<div class="flex items-center space-x-3 text-xs text-gray-400 font-mono">
<Icon name="check-circle" class="w-4 h-4 text-green-500" />
<Icon name="check-circle" className="w-4 h-4 text-green-500" />
<span>System Status: Operational</span>
</div>
<div class="flex items-center space-x-3 text-xs text-gray-400 font-mono">
<Icon name="shield" class="w-4 h-4 text-blue-500" />
<Icon name="shield" className="w-4 h-4 text-blue-500" />
<span>256-bit Encryption Enabled</span>
</div>
</div>
@@ -109,14 +109,14 @@
<div class="max-w-sm mx-auto w-full">
<div class="mb-8">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-1">Iniciar Sesión</h2>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-1">Iniciar Sesión</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">Acceso al sistema central</p>
</div>
<form on:submit|preventDefault={handleLogin} class="space-y-5">
{#if errorMessage}
<div class="p-3 rounded-md bg-red-50 dark:bg-red-900/10 border border-red-200 dark:border-red-900 flex items-start gap-3">
<Icon name="alert-triangle" class="w-5 h-5 text-red-600 dark:text-red-500 flex-shrink-0 mt-0.5" />
<Icon name="alert-triangle" className="w-5 h-5 text-red-600 dark:text-red-500 flex-shrink-0 mt-0.5" />
<p class="text-sm text-red-600 dark:text-red-500">{errorMessage}</p>
</div>
{/if}
@@ -127,7 +127,7 @@
<label for="email" class="block text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">Usuario / Correo</label>
<div class="relative group">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-blue-500 transition-colors">
<Icon name="user" class="w-5 h-5" />
<Icon name="user" className="w-5 h-5" />
</div>
<input
id="email"
@@ -146,7 +146,7 @@
<label for="password" class="block text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-1">Clave de Acceso</label>
<div class="relative group">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-blue-500 transition-colors">
<Icon name="lock" class="w-5 h-5" />
<Icon name="lock" className="w-5 h-5" />
</div>
<input
id="password"
@@ -154,7 +154,7 @@
bind:value={password}
on:keydown={handleKeyDown}
class="form-input w-full pl-10 py-2.5 bg-gray-50 dark:bg-gray-800 border-gray-300 dark:border-gray-700 rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-all font-mono text-sm"
placeholder="••••••••••••"
placeholder="••••••••••••"
required
disabled={isLoading}
/>
@@ -165,7 +165,7 @@
{:else}
<!-- 2FA Input -->
<div class="bg-blue-50 dark:bg-blue-900/10 p-4 rounded-lg border border-blue-100 dark:border-blue-800/30">
<label for="code" class="block text-xs font-semibold uppercase tracking-wider text-blue-800 dark:text-blue-300 mb-2 text-center">Verificación de Seguridad</label>
<label for="code" class="block text-xs font-semibold uppercase tracking-wider text-blue-800 dark:text-blue-300 mb-2 text-center">Verificación de Seguridad</label>
<div class="relative">
<input
id="code"
@@ -193,7 +193,7 @@
disabled={isLoading}
>
{#if isLoading}
<Icon name="loader" class="w-4 h-4 animate-spin mr-2" />
<Icon name="loader" className="w-4 h-4 animate-spin mr-2" />
Autenticando...
{:else}
{showTwoFactor ? 'Verificar Token' : 'Entrar al Panel'}
@@ -204,7 +204,7 @@
</div>
<div class="mt-8 pt-6 border-t border-gray-100 dark:border-gray-800">
<p class="text-[10px] text-gray-400 text-center uppercase tracking-widest">Aduanasoft Internal Systems © 2024</p>
<p class="text-[10px] text-gray-400 text-center uppercase tracking-widest">Aduanasoft Internal Systems © 2024</p>
</div>
</div>
</div>