Mejora de fromts

This commit is contained in:
2026-03-03 11:25:37 -07:00
parent 49dfb3ef24
commit f10b15d91b
19 changed files with 180 additions and 2115 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -21,6 +21,7 @@ from app.services.audit_service import AuditService
from app.services.token_service import TokenService from app.services.token_service import TokenService
from app.api.deps import oauth2_scheme, get_current_user from app.api.deps import oauth2_scheme, get_current_user
from app.core.cache import cache, cache_key from app.core.cache import cache, cache_key
from app.core.limiter import limiter
# Nombres de cookie por tipo de usuario # Nombres de cookie por tipo de usuario
CLIENT_ROLES = {"CLIENT_ADMIN", "CLIENT_USER"} CLIENT_ROLES = {"CLIENT_ADMIN", "CLIENT_USER"}
@@ -46,6 +47,7 @@ settings = get_settings()
# =================================== # ===================================
@router.post("/login", response_model=LoginResponse) @router.post("/login", response_model=LoginResponse)
@limiter.limit("10/minute")
async def login( async def login(
login_data: LoginRequest, login_data: LoginRequest,
request: Request, request: Request,
@@ -707,7 +709,9 @@ _RESET_KEY_PREFIX = "pwd_reset:"
@router.post("/forgot-password", status_code=status.HTTP_200_OK) @router.post("/forgot-password", status_code=status.HTTP_200_OK)
@limiter.limit("5/minute")
async def forgot_password( async def forgot_password(
request: Request,
data: ForgotPasswordRequest, data: ForgotPasswordRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
@@ -774,7 +778,9 @@ async def forgot_password(
@router.post("/reset-password", status_code=status.HTTP_200_OK) @router.post("/reset-password", status_code=status.HTTP_200_OK)
@limiter.limit("5/minute")
async def reset_password( async def reset_password(
request: Request,
data: ResetPasswordRequest, data: ResetPasswordRequest,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,6 @@ Configuración centralizada usando Pydantic Settings v2
""" """
from functools import lru_cache from functools import lru_cache
from typing import List, Optional
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
from pydantic import field_validator, Field from pydantic import field_validator, Field
import os import os
@@ -60,8 +59,8 @@ class Settings(BaseSettings):
# =================================== # ===================================
SMTP_HOST: str = Field(default="localhost") SMTP_HOST: str = Field(default="localhost")
SMTP_PORT: int = Field(default=587) SMTP_PORT: int = Field(default=587)
SMTP_USER: Optional[str] = Field(default=None) SMTP_USER: str | None = Field(default=None)
SMTP_PASSWORD: Optional[str] = Field(default=None) SMTP_PASSWORD: str | None = Field(default=None)
SMTP_USE_TLS: bool = Field(default=True) SMTP_USE_TLS: bool = Field(default=True)
SMTP_USE_SSL: bool = Field(default=False) SMTP_USE_SSL: bool = Field(default=False)
@@ -79,7 +78,7 @@ class Settings(BaseSettings):
UPLOAD_PATH: str = Field(default="/app/uploads") UPLOAD_PATH: str = Field(default="/app/uploads")
@property @property
def ALLOWED_FILE_EXTENSIONS(self) -> List[str]: def ALLOWED_FILE_EXTENSIONS(self) -> list[str]:
"""Parse the comma-separated file extensions.""" """Parse the comma-separated file extensions."""
return [ext.strip().lower() for ext in self.ALLOWED_FILE_EXTENSIONS_STR.split(",")] return [ext.strip().lower() for ext in self.ALLOWED_FILE_EXTENSIONS_STR.split(",")]
@@ -102,7 +101,7 @@ class Settings(BaseSettings):
# =================================== # ===================================
LOG_LEVEL: str = Field(default="INFO") LOG_LEVEL: str = Field(default="INFO")
LOG_FORMAT: str = Field(default="json") LOG_FORMAT: str = Field(default="json")
LOG_FILE: Optional[str] = Field(default=None) LOG_FILE: str | None = Field(default=None)
# =================================== # ===================================
# FRONTEND URLS # FRONTEND URLS

View File

@@ -0,0 +1,20 @@
"""
Rate Limiter - ServiceManagerWeb
Configura slowapi con Redis como storage backend.
Respeta settings.RATE_LIMIT_ENABLED: si está desactivado usa memoria
y el limiter queda en modo noop (enabled=False).
"""
from slowapi import Limiter
from slowapi.util import get_remote_address
from app.core.config import get_settings
settings = get_settings()
limiter = Limiter(
key_func=get_remote_address,
storage_uri=settings.REDIS_URL if settings.RATE_LIMIT_ENABLED else "memory://",
enabled=settings.RATE_LIMIT_ENABLED,
)

View File

@@ -9,6 +9,9 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
import structlog import structlog
import time import time
import uuid import uuid
@@ -31,6 +34,7 @@ from app.api.v1.router import api_router
from app.middleware.tenant import TenantMiddleware from app.middleware.tenant import TenantMiddleware
from app.middleware.correlation_id import CorrelationIDMiddleware from app.middleware.correlation_id import CorrelationIDMiddleware
from app.core.cache import cache from app.core.cache import cache
from app.core.limiter import limiter
settings = get_settings() settings = get_settings()
setup_logging() setup_logging()
@@ -70,6 +74,11 @@ app = FastAPI(
openapi_url=f"/{settings.API_VERSION}/openapi.json" openapi_url=f"/{settings.API_VERSION}/openapi.json"
) )
# SlowAPI rate limiting
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware(SlowAPIMiddleware)
# =================================== # ===================================
# MIDDLEWARE # MIDDLEWARE
# =================================== # ===================================

View File

@@ -0,0 +1,43 @@
"""add_ticket_indexes
Revision ID: b7c8d9e0f1a2
Revises: fix_client_timestamps
Create Date: 2026-03-03 00:00:00.000000
Agrega índices a la tabla tickets para optimizar queries frecuentes:
- idx_tickets_status → filtros por estado
- idx_tickets_priority → filtros por prioridad
- idx_tickets_assigned_to → tickets por agente asignado
- idx_tickets_tenant_status → compuesto multi-tenant (tenant_id, status)
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = 'b7c8d9e0f1a2'
down_revision = 'fix_client_timestamps'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets (status)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS idx_tickets_priority ON tickets (priority)"
)
op.execute(
"CREATE INDEX IF NOT EXISTS idx_tickets_assigned_to "
"ON tickets (assigned_to) WHERE assigned_to IS NOT NULL"
)
op.execute(
"CREATE INDEX IF NOT EXISTS idx_tickets_tenant_status "
"ON tickets (tenant_id, status)"
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS idx_tickets_tenant_status")
op.execute("DROP INDEX IF EXISTS idx_tickets_assigned_to")
op.execute("DROP INDEX IF EXISTS idx_tickets_priority")
op.execute("DROP INDEX IF EXISTS idx_tickets_status")

View File

@@ -31,6 +31,7 @@ pyotp==2.9.0 # TOTP/2FA support
# =================================== # ===================================
celery==5.3.4 celery==5.3.4
redis==5.0.1 redis==5.0.1
slowapi==0.1.9 # Rate limiting middleware
# =================================== # ===================================
# EMAIL # EMAIL

View File

@@ -32,7 +32,7 @@ services:
container_name: servicemanager-redis container_name: servicemanager-redis
restart: unless-stopped restart: unless-stopped
command: redis-server --appendonly yes command: redis-server --appendonly yes
volumes: volumes:
- redis_data:/data - redis_data:/data
ports: ports:
- "6379:6379" - "6379:6379"

11
frontend-client/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
import type { User } from '$lib/stores/auth';
declare global {
namespace App {
interface Locals {
user: User | null;
}
}
}
export {};

View File

@@ -0,0 +1,19 @@
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
const cookie = event.request.headers.get('cookie') ?? '';
if (cookie) {
try {
const apiUrl = process.env.PUBLIC_API_URL ?? 'http://backend:8000';
const response = await fetch(`${apiUrl}/v1/auth/me`, {
headers: { cookie, 'X-App': 'client' }
});
event.locals.user = response.ok ? await response.json() : null;
} catch {
event.locals.user = null;
}
} else {
event.locals.user = null;
}
return resolve(event);
};

View File

@@ -128,6 +128,11 @@ function createAuthStore() {
update(state => ({ ...state, user })); update(state => ({ ...state, user }));
}, },
// Set user from SSR pre-load (no fetch required)
setUser: (user: User) => {
set({ user, token: null, isAuthenticated: true, isLoading: false });
},
// Set loading state // Set loading state
setLoading: (isLoading: boolean) => { setLoading: (isLoading: boolean) => {
update(state => ({ ...state, isLoading })); update(state => ({ ...state, isLoading }));

View File

@@ -0,0 +1,7 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = ({ locals }) => {
return {
user: locals.user ?? null
};
};

View File

@@ -9,10 +9,14 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import '../app.css'; import '../app.css';
export let data;
let mounted = false; let mounted = false;
onMount(async () => { onMount(() => {
await auth.init(); if (data.user && !$auth.isAuthenticated) {
auth.setUser(data.user);
}
mounted = true; mounted = true;
}); });

11
frontend-internal/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
import type { InternalUser } from '$lib/stores/auth';
declare global {
namespace App {
interface Locals {
user: InternalUser | null;
}
}
}
export {};

View File

@@ -0,0 +1,19 @@
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
const cookie = event.request.headers.get('cookie') ?? '';
if (cookie) {
try {
const apiUrl = process.env.PUBLIC_API_URL ?? 'http://backend:8000';
const response = await fetch(`${apiUrl}/v1/auth/me`, {
headers: { cookie, 'X-App': 'internal' }
});
event.locals.user = response.ok ? await response.json() : null;
} catch {
event.locals.user = null;
}
} else {
event.locals.user = null;
}
return resolve(event);
};

View File

@@ -190,6 +190,11 @@ function createAuthStore() {
update(state => ({ ...state, user })); update(state => ({ ...state, user }));
}, },
// Set user from SSR pre-load (no fetch required)
setUser: (user: InternalUser) => {
set({ user, token: null, refreshToken: null, isAuthenticated: true, isLoading: false });
},
// Set loading state // Set loading state
setLoading: (isLoading: boolean) => { setLoading: (isLoading: boolean) => {
update(state => ({ ...state, isLoading })); update(state => ({ ...state, isLoading }));

View File

@@ -0,0 +1,7 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = ({ locals }) => {
return {
user: locals.user ?? null
};
};

View File

@@ -10,11 +10,15 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import '../app.css'; import '../app.css';
export let data;
let sidebarOpen = false; let sidebarOpen = false;
let mounted = false; let mounted = false;
onMount(async () => { onMount(() => {
await auth.init(); if (data.user && !$auth.isAuthenticated) {
auth.setUser(data.user);
}
mounted = true; mounted = true;
}); });