Mejora de fromts
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||||
|
|||||||
20
backend/app/core/limiter.py
Normal file
20
backend/app/core/limiter.py
Normal 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,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
# ===================================
|
# ===================================
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
11
frontend-client/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { User } from '$lib/stores/auth';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
interface Locals {
|
||||||
|
user: User | null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
19
frontend-client/src/hooks.server.ts
Normal file
19
frontend-client/src/hooks.server.ts
Normal 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);
|
||||||
|
};
|
||||||
@@ -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 }));
|
||||||
|
|||||||
7
frontend-client/src/routes/+layout.server.ts
Normal file
7
frontend-client/src/routes/+layout.server.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: LayoutServerLoad = ({ locals }) => {
|
||||||
|
return {
|
||||||
|
user: locals.user ?? null
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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
11
frontend-internal/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { InternalUser } from '$lib/stores/auth';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
interface Locals {
|
||||||
|
user: InternalUser | null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
19
frontend-internal/src/hooks.server.ts
Normal file
19
frontend-internal/src/hooks.server.ts
Normal 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);
|
||||||
|
};
|
||||||
@@ -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 }));
|
||||||
|
|||||||
7
frontend-internal/src/routes/+layout.server.ts
Normal file
7
frontend-internal/src/routes/+layout.server.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: LayoutServerLoad = ({ locals }) => {
|
||||||
|
return {
|
||||||
|
user: locals.user ?? null
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user