5 Commits

Author SHA1 Message Date
Ernesto Herrera
223395b430 feat(core): compañía por tenant (1:1) en my-companies para desbloquear el dashboard
get_my_companies devolvía [] (STUB) → sin compañía activa el CRM bloqueaba todo.
Modelo: una empresa por tenant (company_id = tenant_id, como el agente de carga).
Asegura el vínculo user↔tenant↔company; los permisos se resuelven en
/permissions/me (bootstrap de super_admin al primer usuario).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 18:05:44 -06:00
Ernesto Herrera
579c6e1f19 chore(deploy): activar sesión local (patrón SIWEB) en testing
Pasa SESSION_STORE_ENABLED/IDLE/MAX al backend vía environment del override
(el contenedor no lee .env). SESSION_STORE_ENABLED=false revierte el patrón.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 17:03:01 -06:00
Ernesto Herrera
5d1c65f236 feat(auth): sesión local del CRM (patrón SIWEB) para eliminar el bucle de login
Desacopla la sesión de la app del access token de Keycloak (60s). Tras el SSO/
refresh, el backend emite un JWT de sesión local (HS256) con vida por inactividad
(SESSION_IDLE_MINUTES, cap SESSION_MAX_HOURS) y guarda los tokens KC en valkey.
La app usa esa sesión local como bearer (cookie access_token); el token KC vive en
kc_access_token solo para llamadas directas al Hub. Así el refresh KC contra el Hub
solo se intenta al expirar la sesión local, no cada ~60s → se elimina el bucle
causado por el bug "Token is not active" del relay.

Se RESPETA la revocación central de Keycloak: si el Hub rechaza el refresh, la
sesión termina (401). Sin re-emisión de fallback → sin bypass de revocación.

Todo detrás de SESSION_STORE_ENABLED (default False = comportamiento idéntico).

Backend: core/local_session.py, core/session_store.py (valkey), verify_token acepta
la sesión local, refresh la emite/actualiza; DTOs con session_token/session_id;
test unitario de local_session.
Frontend: access_token=sesión local, kc_access_token para el Hub; sso/refresh/
silent-refresh/switch-tenant y pantallas de workspace cableadas.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 16:50:35 -06:00
Ernesto Herrera
a613a7a6aa feat(crm,workspace): alta de organizaciones y usuarios vía Hub
Nueva sección "Workspace" (Organizaciones + Usuarios) que orquesta el Hub
reenviando el token del usuario autenticado. Sin secretos en el CRM ni
bypass: la autorización la impone el Hub (hub_admin para tenants;
hub_admin o admin del tenant para invitaciones).

- Organizaciones: listar (GET /api/v1/hub/tenants) y crear
  (POST /api/v1/hub/tenants → tenant + realm Keycloak).
- Usuarios: invitación de un solo uso (POST /api/v1/hub/invites) con
  enlace copiable si el correo no llega.
- Se descarta /auth/provision-user (PROVISION_SECRET, machine-to-machine)
  en favor del flujo de invitación con token de admin.
- Helpers puros (slug, validación, extracción de errores del Hub) con
  tests unitarios; estado "requiere permisos" ante 403 y fallback a slug
  manual para admin de tenant.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 13:19:21 -06:00
Aduanasoft
45f128a551 fix(deploy): apk add wget best-effort en Dockerfile.prod (redes sin CDN de Alpine)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 09:54:57 -06:00
23 changed files with 1395 additions and 65 deletions

View File

@@ -36,6 +36,12 @@ class TokenResponseDTO(BaseModel):
tenant: Optional["TenantInfoDTO"] = None
tenant_id: Optional[int] = None
tenant_slug: Optional[str] = None
# Sesión local del CRM (patrón SIWEB) — presente solo con SESSION_STORE_ENABLED.
# Es un JWT propio (HS256) que la app usa como bearer para el backend del CRM y
# que sobrevive aunque el refresh del token KC contra el Hub falle. El access_token
# de arriba sigue siendo el de Keycloak (para llamadas al Hub).
session_token: Optional[str] = None
session_id: Optional[str] = None
class Config:
json_schema_extra = {
@@ -52,6 +58,12 @@ class RefreshTokenRequestDTO(BaseModel):
"""DTO para solicitud de refresh token"""
refresh_token: str = Field(..., description="Refresh token")
# Sesión local actual del CRM (patrón SIWEB). Si se envía, el backend preserva el
# inicio de sesión (cap absoluto) y puede re-emitirla como fallback cuando el
# refresh del token KC contra el Hub falla ("Token is not active" del relay).
session_token: Optional[str] = Field(None, description="Sesión local actual del CRM (opcional)")
# session_id opaco de la sesión en valkey (guarda los tokens KC fuera del browser).
session_id: Optional[str] = Field(None, description="ID de sesión en valkey (opcional)")
class UserInfoResponseDTO(BaseModel):

View File

@@ -402,10 +402,16 @@ async def dev_login():
@router.get("/my-companies")
async def get_my_companies(
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""
Retorna las compañías accesibles para el usuario actual.
STUB: implementa con tu modelo de compañías.
Modelo del CRM: una compañía por tenant (1:1) — el ``company_id`` coincide con
el ``tenant_id``. Cada agente de carga (tenant) opera como una empresa. Se
garantiza el vínculo usuario↔tenant↔company; los permisos de la empresa se
resuelven en ``/permissions/me`` (bootstrap de super_admin al primer usuario).
En dev-local retorna una compañía ficticia para que el dashboard funcione.
"""
from core.config import settings
@@ -415,8 +421,42 @@ async def get_my_companies(
"id": settings.DEV_LOCAL_AUTH_COMPANY_ID,
"name": "Empresa Dev Local",
"tenant_id": settings.DEV_LOCAL_AUTH_TENANT_ID,
"rfc": None,
"logo": None,
"is_active": True,
}]
# Implementa aquí la consulta real a tu tabla de compañías.
return []
from core.security import (
resolve_effective_tenant_id_from_user,
_ensure_user_tenant_for_company,
)
from api.v1.modules.core.tenants.models import Tenant
tenant_id = resolve_effective_tenant_id_from_user(current_user)
if not tenant_id:
return []
tenant = db.query(Tenant).filter(Tenant.id == int(tenant_id)).first()
name = (
(tenant.name if tenant else None)
or current_user.get("tenant_slug")
or "Mi empresa"
)
# Garantizar el vínculo usuario↔tenant↔company (company_id = tenant_id).
user_id = current_user.get("sub") or current_user.get("id")
if user_id:
try:
_ensure_user_tenant_for_company(db, str(user_id), int(tenant_id), int(tenant_id))
except Exception as exc:
logger = __import__("logging").getLogger(__name__)
logger.warning("no se pudo asegurar user_tenant (no bloquea): %s", exc)
return [{
"id": int(tenant_id),
"name": name,
"tenant_id": int(tenant_id),
"rfc": None,
"logo": None,
"is_active": True,
}]

View File

@@ -213,55 +213,133 @@ class AuthService:
logger.error(f"Unexpected login error: {str(e)}")
raise HTTPException(status_code=500, detail="Authentication error")
def _decode_local_session(self, session_token: Optional[str]) -> Optional[Dict[str, Any]]:
"""
Decodifica una sesión local del CRM (HS256) verificando la firma pero
SIN exigir exp — para poder re-emitirla en el refresh. Retorna los claims
o None si la firma no valida o no es una sesión local del CRM.
"""
if not session_token:
return None
try:
claims = jwt.decode(
session_token,
settings.SECRET_KEY,
algorithms=["HS256"],
options={"verify_exp": False},
)
except JWTError:
return None
if not claims.get("crm_session") or claims.get("source") != "local":
return None
return claims
def _session_claims_from_kc(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""Construye los claims de la sesión local a partir de la respuesta KC del Hub."""
kc_claims = self._decode_kc_user_from_token(data.get("access_token", ""))
claims: Dict[str, Any] = dict(kc_claims)
# tenant_id/tenant_slug explícitos del Hub tienen precedencia sobre el token
if data.get("tenant_id") is not None:
claims["tenant_id"] = data.get("tenant_id")
if data.get("tenant_slug") is not None:
claims["tenant_slug"] = data.get("tenant_slug")
return claims
async def refresh_token(self, refresh_data: RefreshTokenRequestDTO) -> TokenResponseDTO:
"""
Refresca el access token usando el Hub
Refresca la sesión.
- Intenta el refresh del token KC contra el Hub (comportamiento histórico).
- Con SESSION_STORE_ENABLED, además emite/actualiza la sesión local del CRM
(patrón SIWEB) que la app usa como bearer y que dura por inactividad, de
modo que el refresh KC solo se intenta al expirar esa sesión (no cada ~60s).
- Si el Hub RECHAZA el refresh se devuelve 401 y la sesión termina: se
RESPETA la revocación central de Keycloak (sin re-emisión de fallback).
"""
from datetime import datetime, timezone
session_enabled = bool(getattr(settings, "SESSION_STORE_ENABLED", False))
prev_claims = self._decode_local_session(refresh_data.session_token) if session_enabled else None
prev_sst = prev_claims.get("sst") if prev_claims else None
prev_session_id = refresh_data.session_id if session_enabled else None
# Fuente del refresh KC: valkey (sesión) tiene precedencia sobre lo que
# mande el cliente (puede estar desactualizado). Fail-silent.
kc_refresh = refresh_data.refresh_token
if session_enabled and prev_session_id:
from core import session_store
sess = session_store.get_session(prev_session_id)
if sess and sess.get("refresh_token"):
kc_refresh = sess["refresh_token"]
# ── Intento de refresh del token KC contra el Hub ────────────────────────
kc_ok = False
data: Optional[Dict[str, Any]] = None
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(
f"{settings.HUB_URL}api/v1/auth/refresh",
json=refresh_data.model_dump()
json={"refresh_token": kc_refresh},
)
if response.status_code == 200:
kc_ok = response.status_code == 200
if kc_ok:
data = response.json()
from core.workspace_profile_sync import sync_workspace_profile_for_user
from core.workspace_profile_client import WorkspaceProfileClient
else:
logger.warning("Hub rechazó el refresh (status %s)", response.status_code)
except Exception as exc:
logger.warning("Hub inalcanzable en refresh: %s", exc)
kc_ok = False
workspace_profile = None
try:
workspace_profile = await WorkspaceProfileClient().get_me(
data.get("access_token", "")
)
except Exception as exc:
logger.warning(
"workspace_profile_sync_failed",
extra={
"event": "workspace_profile_sync_failed",
"phase": "refresh",
"error": str(exc),
},
)
workspace_profile = None
# ── Camino feliz: el Hub renovó el token KC ──────────────────────────────
if kc_ok and data is not None:
from core.workspace_profile_sync import sync_workspace_profile_for_user
from core.workspace_profile_client import WorkspaceProfileClient
await sync_workspace_profile_for_user(
self.db,
access_token=data.get("access_token"),
keycloak_user_id=(workspace_profile or {}).get("sub")
or data.get("sub")
or data.get("user_id"),
tenant_id=data.get("tenant_id"),
workspace_profile=workspace_profile,
force=True,
workspace_profile = None
try:
workspace_profile = await WorkspaceProfileClient().get_me(data.get("access_token", ""))
except Exception as exc:
logger.warning(
"workspace_profile_sync_failed",
extra={"event": "workspace_profile_sync_failed", "phase": "refresh", "error": str(exc)},
)
return TokenResponseDTO(**data)
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
workspace_profile = None
except Exception as e:
logger.error(f"Token refresh error: {str(e)}")
raise HTTPException(status_code=500, detail="Token refresh error")
await sync_workspace_profile_for_user(
self.db,
access_token=data.get("access_token"),
keycloak_user_id=(workspace_profile or {}).get("sub") or data.get("sub") or data.get("user_id"),
tenant_id=data.get("tenant_id"),
workspace_profile=workspace_profile,
force=True,
)
resp = TokenResponseDTO(**data)
if session_enabled:
from core import local_session, session_store
start = int(prev_sst) if prev_sst else int(datetime.now(timezone.utc).timestamp())
claims = self._session_claims_from_kc(data)
new_access = data.get("access_token", "")
new_refresh = data.get("refresh_token", "")
# Reutiliza la sesión de valkey si ya existía; si no, la crea.
if prev_session_id and session_store.get_session(prev_session_id):
session_store.update_session_tokens(prev_session_id, new_access, new_refresh)
resp.session_id = prev_session_id
else:
resp.session_id = session_store.create_session(new_access, new_refresh, start)
resp.session_token = local_session.mint_session_token(claims, session_start=start)
return resp
# El Hub rechazó el refresh: la sesión termina y se RESPETA la revocación
# central de Keycloak (no hay re-emisión local de fallback). El usuario
# re-entra por el App Launcher. La sesión local de larga duración evita el
# bucle: el refresh solo se intenta al expirar la sesión local por
# inactividad (idle), no cada ~60s como con el token KC crudo.
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
async def get_user_info(self, access_token: str) -> UserInfoResponseDTO:
"""

View File

@@ -42,6 +42,19 @@ class Settings(BaseSettings):
PERMISSION_CACHE_ENABLED: bool = True
PERMISSION_CACHE_TTL_SECONDS: int = 300
# Sesión local del CRM (patrón SIWEB) — desacopla la sesión de la app del
# token KC de 60s. Tras SSO/login se guardan los tokens KC en valkey y se emite
# una sesión local firmada (HS256) con vida por inactividad (idle) y cap
# absoluto. Así el refresh del token KC contra el Hub solo se intenta al expirar
# la sesión local (no cada ~60s), lo que elimina el bucle de login.
#
# SE RESPETA la revocación central de Keycloak: si el Hub rechaza el refresh, la
# sesión termina (no hay re-emisión local de fallback). Flag-gated para rollback:
# con SESSION_STORE_ENABLED=False el comportamiento no cambia.
SESSION_STORE_ENABLED: bool = False
SESSION_IDLE_MINUTES: int = 30
SESSION_MAX_HOURS: int = 10
# Synchronization
SYNC_SECRET_TOKEN: str = "change-this-sync-token-in-production"
CENTRAL_SERVER_URL: str = "http://localhost:8000/api/v1/core/help-center/sync/"

View File

@@ -0,0 +1,94 @@
"""
Sesión local del CRM (patrón SIWEB).
Emite y valida un JWT de sesión propio (HS256, firmado con SECRET_KEY) que
transporta la identidad YA verificada por Keycloak/Hub. Desacopla la sesión de la
app del token KC de 60s: la app valida esta sesión local (sin ir al Hub) durante
su ventana de inactividad, de modo que el refresh del token KC solo se intenta al
expirar la sesión local — no cada ~60s. Esto elimina el bucle de login.
Se RESPETA la revocación central: si el Hub rechaza el refresh, la sesión termina
(no hay re-emisión de fallback).
Marcadores del token:
- source: "local" + crm_session: True → distingue de tokens KC (RS256) y del
token dev-local (dev_local: True).
- sst (session start time, epoch seg) → fija la vida ABSOLUTA máxima (cap).
- exp → sliding por inactividad (idle); se
re-emite en cada refresh mientras no se supere el cap.
Seguridad: es un desacople CONSCIENTE de la revocación central de KC (OWASP A07).
Se acota con idle corto (= ssoSessionIdleTimeout) y cap absoluto
(= ssoSessionMaxLifespan); el logout elimina la sesión de valkey.
"""
from datetime import datetime, timezone
from typing import Any, Dict, Optional
from jose import JWTError, jwt
from core.config import settings
# Claims de identidad que se propagan del token KC a la sesión local.
_IDENTITY_CLAIMS = (
"sub", "email", "preferred_username", "username", "name",
"given_name", "family_name", "first_name", "last_name",
"tenant_id", "tenant_slug", "roles", "permissions",
"is_hub_admin", "avatar_url",
)
def _now_epoch() -> int:
return int(datetime.now(timezone.utc).timestamp())
def mint_session_token(claims: Dict[str, Any], session_start: Optional[int] = None) -> str:
"""
Emite un JWT de sesión local a partir de los claims (verificados) del usuario.
`session_start` (epoch seg) fija el inicio de sesión para el cap absoluto; si
no se provee, se usa el momento actual (sesión nueva).
"""
now = _now_epoch()
sst = int(session_start) if session_start else now
payload: Dict[str, Any] = {
k: claims[k] for k in _IDENTITY_CLAIMS if claims.get(k) is not None
}
payload.update({
"source": "local",
"crm_session": True,
"sst": sst,
"iat": now,
"exp": now + settings.SESSION_IDLE_MINUTES * 60,
})
return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
def verify_session_token(token: str) -> Optional[Dict[str, Any]]:
"""
Valida un JWT de sesión local. Retorna los claims si es válido, no expiró por
inactividad y no superó el cap absoluto de vida; None en cualquier otro caso.
Nunca lanza (para poder encadenar con la validación contra el Hub).
"""
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
except JWTError:
return None
# Solo aceptamos tokens de sesión local del CRM (no KC, no dev-local).
if not payload.get("crm_session") or payload.get("source") != "local":
return None
# Cap absoluto de vida de sesión (independiente del sliding por idle).
sst = payload.get("sst")
if isinstance(sst, (int, float)):
if _now_epoch() - int(sst) > settings.SESSION_MAX_HOURS * 3600:
return None
return payload
def session_start_of(payload: Dict[str, Any]) -> Optional[int]:
"""Extrae el epoch de inicio de sesión (sst) de un payload de sesión local."""
sst = payload.get("sst")
return int(sst) if isinstance(sst, (int, float)) else None

View File

@@ -47,6 +47,19 @@ async def verify_token(token: str, tenant_id_override: str = None) -> Dict[str,
if cache_key in token_cache:
return token_cache[cache_key]
# Sesión local del CRM (patrón SIWEB): si el token es una sesión local firmada
# (HS256, crm_session), validarla sin ir al Hub en cada request. Así el refresh
# del token KC solo se intenta al expirar la sesión local (no cada ~60s), lo que
# elimina el bucle de login. verify_session_token retorna None para tokens KC
# (RS256), así que no interfiere con el flujo normal.
if settings.SESSION_STORE_ENABLED:
from core.local_session import verify_session_token
local_claims = verify_session_token(token)
if local_claims is not None:
token_cache[cache_key] = local_claims
return local_claims
# Shortcut para tokens de desarrollo local
if settings.DEV_LOCAL_AUTH:
try:

View File

@@ -0,0 +1,111 @@
"""
Store de sesión en Valkey/Redis (patrón SIWEB).
Guarda los tokens de Keycloak (access + refresh) FUERA del browser, indexados por
un session_id opaco. La app usa la sesión local firmada (ver core.local_session)
para su propia auth; los tokens KC de aquí solo se usan para llamadas al Hub
(provisioning, my-apps, my-tenants), refrescándolos best-effort.
Fail-silent: si Valkey no está disponible, las operaciones degradan a None/no-op
y la sesión local firmada sigue sosteniendo la app.
"""
import json
import logging
import uuid
from typing import Optional
from core.config import settings
try:
import redis # type: ignore
except Exception: # pragma: no cover - redis es opcional en algunos entornos
redis = None # type: ignore
logger = logging.getLogger(__name__)
_KEY_PREFIX = "crm:session:"
_client = None
def _get_client():
"""Cliente Redis/Valkey compartido (perezoso). None si no está disponible."""
global _client
if redis is None:
return None
if _client is None:
try:
_client = redis.Redis.from_url(settings.VALKEY_URL, decode_responses=True)
except Exception as exc:
logger.warning("session_store_init_failed: %s", exc)
return None
return _client
def _ttl_seconds() -> int:
# La sesión en valkey vive como máximo lo que la vida absoluta de la sesión.
return settings.SESSION_MAX_HOURS * 3600
def create_session(access_token: str, refresh_token: str, session_start: int) -> Optional[str]:
"""Crea una sesión con los tokens KC y devuelve el session_id (o None si Valkey no está)."""
client = _get_client()
if client is None:
return None
session_id = str(uuid.uuid4())
data = json.dumps({
"access_token": access_token,
"refresh_token": refresh_token or "",
"sst": int(session_start),
})
try:
client.setex(f"{_KEY_PREFIX}{session_id}", _ttl_seconds(), data)
return session_id
except Exception as exc:
logger.warning("session_store_create_failed: %s", exc)
return None
def get_session(session_id: str) -> Optional[dict]:
"""Devuelve {access_token, refresh_token, sst} de la sesión, o None."""
client = _get_client()
if client is None or not session_id:
return None
try:
raw = client.get(f"{_KEY_PREFIX}{session_id}")
return json.loads(raw) if raw else None
except Exception as exc:
logger.warning("session_store_get_failed: %s", exc)
return None
def update_session_tokens(session_id: str, access_token: str, refresh_token: str) -> None:
"""Actualiza los tokens KC de una sesión existente conservando su TTL y su sst."""
client = _get_client()
if client is None or not session_id:
return
try:
key = f"{_KEY_PREFIX}{session_id}"
ttl = client.ttl(key)
if ttl and ttl > 0:
existing = client.get(key)
sst = json.loads(existing).get("sst") if existing else None
data = json.dumps({
"access_token": access_token,
"refresh_token": refresh_token or "",
"sst": sst,
})
client.setex(key, ttl, data)
except Exception as exc:
logger.warning("session_store_update_failed: %s", exc)
def delete_session(session_id: str) -> None:
"""Elimina la sesión (logout). Fail-silent."""
client = _get_client()
if client is None or not session_id:
return
try:
client.delete(f"{_KEY_PREFIX}{session_id}")
except Exception as exc:
logger.warning("session_store_delete_failed: %s", exc)

View File

@@ -0,0 +1,74 @@
"""
Pruebas de la sesión local del CRM (patrón SIWEB) — core.local_session.
Lógica pura (firma HS256 + claims); no requiere BD ni valkey.
"""
from datetime import datetime, timezone
from jose import jwt
from core.config import settings
from core.local_session import mint_session_token, verify_session_token, session_start_of
def _now() -> int:
return int(datetime.now(timezone.utc).timestamp())
def test_round_trip_conserva_identidad():
claims = {
"sub": "kc-user-123",
"email": "user@example.com",
"tenant_id": 11,
"tenant_slug": "aduanasoft",
"is_hub_admin": True,
}
token = mint_session_token(claims)
out = verify_session_token(token)
assert out is not None
assert out["sub"] == "kc-user-123"
assert out["tenant_id"] == 11
assert out["tenant_slug"] == "aduanasoft"
assert out["is_hub_admin"] is True
assert out["source"] == "local"
assert out["crm_session"] is True
assert isinstance(out["sst"], int)
def test_cap_absoluto_rechaza_sesion_vieja():
# session_start más allá del cap absoluto → verify debe rechazar aunque no expiró por idle.
old_start = _now() - (settings.SESSION_MAX_HOURS * 3600 + 120)
token = mint_session_token({"sub": "x"}, session_start=old_start)
assert verify_session_token(token) is None
def test_preserva_session_start():
start = _now() - 60
token = mint_session_token({"sub": "x"}, session_start=start)
out = verify_session_token(token)
assert out is not None
assert session_start_of(out) == start
def test_firma_alterada_se_rechaza():
token = mint_session_token({"sub": "x"})
# Alterar el último carácter de la firma invalida el token.
tampered = token[:-1] + ("A" if token[-1] != "A" else "B")
assert verify_session_token(tampered) is None
def test_token_no_crm_se_rechaza():
# Un HS256 válido pero SIN los marcadores de sesión local no debe aceptarse.
other = jwt.encode(
{"sub": "x", "exp": _now() + 600},
settings.SECRET_KEY,
algorithm="HS256",
)
assert verify_session_token(other) is None
def test_token_basura_se_rechaza():
assert verify_session_token("no-es-un-jwt") is None
assert verify_session_token("") is None

View File

@@ -5,11 +5,26 @@
# docker compose -f docker-compose.yml -f deploy/docker-compose.testing.yml up -d --build
services:
backend:
# DNS por-contenedor: el resolver del host no es alcanzable desde los contenedores;
# el backend debe resolver workspace.aduanasoft.com (Hub) en runtime.
dns:
- "8.8.8.8"
- "1.1.1.1"
ports: !override
- "127.0.0.1:8000:8000"
# Sesión local del CRM (patrón SIWEB). El backend toma su config de esta lista
# (no lee el .env dentro del contenedor), así que los flags van aquí. Valores
# desde el .env por sustitución. SESSION_STORE_ENABLED=false revierte al comportamiento previo.
environment:
- SESSION_STORE_ENABLED=${SESSION_STORE_ENABLED:-true}
- SESSION_IDLE_MINUTES=${SESSION_IDLE_MINUTES:-30}
- SESSION_MAX_HOURS=${SESSION_MAX_HOURS:-10}
command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--log-level", "info"]
frontend:
dns:
- "8.8.8.8"
- "1.1.1.1"
build:
context: ./frontend
dockerfile: Dockerfile.prod
@@ -21,8 +36,11 @@ services:
VITE_KEYCLOAK_CLIENT_ID: ${KEYCLOAK_CLIENT_ID:-aduanasoft}
environment:
- NODE_ENV=production
# El callback OIDC (/auth/callback) intercambia el código por tokens con el
# secret del client confidencial 'aduanasoft'. El compose base no lo pasa al frontend.
- KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-}
volumes: !override []
command: !override ["pnpm", "start"]
command: !override ["node", "build/index.js"]
ports: !override
- "127.0.0.1:5173:5173"
@@ -31,3 +49,10 @@ services:
minio:
ports: !override []
# En el server no existe el Hub local: app-hub deja de ser red externa y se crea local.
# El CRM alcanza el Hub por su URL pública (workspace.aduanasoft.com), no por esta red.
networks:
app-hub:
external: false
driver: bridge

View File

@@ -49,7 +49,8 @@ FROM node:22-alpine AS runtime
WORKDIR /app
RUN apk add --no-cache wget
# wget de busybox ya viene en alpine; el apk es best-effort (redes restringidas sin CDN de Alpine)
RUN apk add --no-cache wget || true
RUN npm config set strict-ssl false && \
npm install -g pnpm

View File

@@ -6,6 +6,7 @@ import {
Briefcase,
Ship,
Receipt,
Building2,
} from '@lucide/svelte';
export type SystemContext = 'fixed_asset' | 'inventory';
@@ -69,6 +70,15 @@ export function getNavMain(): NavMainItem[] {
{ title: 'Facturas y cobranza', url: '/dashboard/fin/facturas' },
],
},
{
title: 'Workspace',
url: '/dashboard/workspace/organizaciones',
icon: Building2,
items: [
{ title: 'Organizaciones', url: '/dashboard/workspace/organizaciones' },
{ title: 'Usuarios (invitaciones)', url: '/dashboard/workspace/usuarios' },
],
},
{
title: 'Usuarios',
url: '/dashboard/users',

View File

@@ -82,6 +82,60 @@ export function clearAuthTokens(cookies: Cookies) {
cookies.delete('id_token', { path: '/' });
cookies.delete('active_company_id', { path: '/' });
cookies.delete('active_system', { path: '/' });
// Sesión local del CRM (patrón SIWEB)
cookies.delete('kc_access_token', { path: '/' });
cookies.delete('crm_sid', { path: '/' });
}
/**
* Token de Keycloak para llamadas DIRECTAS al Hub (my-apps, my-tenants,
* provisioning). Con el patrón de sesión local (SIWEB) el `access_token` guarda
* la sesión local del CRM, así que el token KC vive en su propia cookie
* `kc_access_token`. Fallback a `access_token` cuando el patrón está apagado
* (kc_access_token ausente) → comportamiento histórico intacto.
*/
export function getKcAccessToken(cookies: Cookies): string | null {
return cookies.get('kc_access_token') ?? getAccessTokenFromCookies(cookies);
}
/**
* Aplica a las cookies la respuesta de /v1/auth/refresh considerando la sesión
* local del CRM (patrón SIWEB):
* - Con `session_token`: `access_token` = sesión local (bearer de la app),
* `kc_access_token` = token KC (para el Hub), `crm_sid` = id de sesión valkey.
* - Sin él: comportamiento histórico (`access_token` = token KC).
* Devuelve el token que la app debe usar para reintentar (la sesión local si aplica).
*/
export function applyRefreshedTokens(
cookies: Cookies,
data: { access_token?: string; refresh_token?: string; session_token?: string; session_id?: string }
): string | null {
const secure = isSecureContext();
if (data.session_token) {
setAccessTokenCookies(cookies, data.session_token, { secure, maxAge: 60 * 60 * 24 * 7 });
// access_token KC vacío = fallback sin token fresco → conservar el actual.
if (data.access_token) {
cookies.set('kc_access_token', data.access_token, {
path: '/', httpOnly: true, sameSite: 'lax', secure, maxAge: 60 * 60 * 24 * 7
});
}
if (data.session_id) {
cookies.set('crm_sid', data.session_id, {
path: '/', httpOnly: true, sameSite: 'lax', secure, maxAge: 60 * 60 * 24 * 30
});
}
if (data.refresh_token) {
cookies.set('refresh_token', data.refresh_token, {
path: '/', httpOnly: true, sameSite: 'lax', secure, maxAge: 60 * 60 * 24 * 30
});
}
return data.session_token;
}
if (data.access_token) {
setAuthTokens(cookies, data.access_token, data.refresh_token);
return data.access_token;
}
return null;
}
/**
@@ -113,12 +167,21 @@ export async function refreshAccessToken(
try {
const baseUrl = getServerApiUrl();
// Sesión local actual del CRM (patrón SIWEB): se envía para preservar el
// inicio de sesión (cap absoluto) y permitir el re-emitido de fallback
// cuando el refresh del token KC contra el Hub falla.
const currentSession = getAccessTokenFromCookies(cookies);
const sessionId = cookies.get('crm_sid');
const response = await fetch(`${baseUrl}v1/auth/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ refresh_token: refreshToken })
body: JSON.stringify({
refresh_token: refreshToken,
...(currentSession ? { session_token: currentSession } : {}),
...(sessionId ? { session_id: sessionId } : {})
})
});
if (!response.ok) {
@@ -127,10 +190,8 @@ export async function refreshAccessToken(
const data = await response.json();
// Actualizar las cookies con los nuevos tokens
setAuthTokens(cookies, data.access_token, data.refresh_token);
return data.access_token;
// Actualiza las cookies teniendo en cuenta la sesión local (o histórico si no aplica).
return applyRefreshedTokens(cookies, data);
} catch (error) {
console.error('🔄 [API] Error al refrescar token:', error);
return null;

View File

@@ -0,0 +1,80 @@
import { describe, it, expect } from 'vitest';
import {
slugifyTenantName,
validateTenantForm,
hubErrorMessage,
isForbiddenStatus,
TENANT_SLUG_RE
} from './workspace-provision.shared';
describe('slugifyTenantName', () => {
it('convierte nombre con acentos y espacios a slug válido', () => {
const slug = slugifyTenantName('Logística Peña & Cía S.A. de C.V.');
expect(slug).toBe('logistica-pena-cia-s-a-de-c-v');
expect(TENANT_SLUG_RE.test(slug)).toBe(true);
});
it('quita guiones al inicio y al final', () => {
expect(slugifyTenantName(' --Hola-- ')).toBe('hola');
});
it('cadena sin caracteres válidos da string vacío', () => {
expect(slugifyTenantName('!!!')).toBe('');
});
});
describe('validateTenantForm', () => {
it('acepta datos válidos', () => {
expect(
validateTenantForm({ name: 'Empresa ABC', slug: 'empresa-abc', contact_email: 'a@b.com' })
).toBeNull();
});
it('rechaza nombre demasiado corto', () => {
expect(
validateTenantForm({ name: 'A', slug: 'a-b', contact_email: 'a@b.com' })
).toMatch(/al menos 2/);
});
it('rechaza slug con mayúsculas o espacios', () => {
expect(
validateTenantForm({ name: 'Empresa ABC', slug: 'Empresa ABC', contact_email: 'a@b.com' })
).toMatch(/slug/i);
});
it('rechaza email inválido', () => {
expect(
validateTenantForm({ name: 'Empresa ABC', slug: 'empresa-abc', contact_email: 'no-email' })
).toMatch(/email/i);
});
});
describe('hubErrorMessage', () => {
it('devuelve el detail string tal cual', () => {
expect(hubErrorMessage({ detail: 'Slug ya existe' }, 409)).toBe('Slug ya existe');
});
it('formatea el primer error de validación de Pydantic', () => {
const body = { detail: [{ loc: ['body', 'contact_email'], msg: 'value is not a valid email' }] };
expect(hubErrorMessage(body, 422)).toBe('contact_email: value is not a valid email');
});
it('mensaje de permisos ante 403 sin detail', () => {
expect(hubErrorMessage(null, 403)).toMatch(/permisos/i);
});
it('mensaje genérico con status ante cuerpo desconocido', () => {
expect(hubErrorMessage(null, 500)).toMatch(/500/);
});
});
describe('isForbiddenStatus', () => {
it('true para 401 y 403', () => {
expect(isForbiddenStatus(401)).toBe(true);
expect(isForbiddenStatus(403)).toBe(true);
});
it('false para otros', () => {
expect(isForbiddenStatus(422)).toBe(false);
expect(isForbiddenStatus(200)).toBe(false);
});
});

View File

@@ -0,0 +1,85 @@
/**
* Helpers puros para el alta de organizaciones/usuarios en el Workspace (Hub).
* Sin dependencias de entorno para poder testearse en aislamiento (Vitest).
*/
// Slug del tenant: mismas reglas que el Hub (TenantCreateDTO.slug → ^[a-z0-9-]+$).
export const TENANT_SLUG_RE = /^[a-z0-9-]+$/;
// Roles válidos para invitar un usuario. Son los que el Hub reconoce en su
// flujo de alta (ver ProvisionUserRequestDTO / create_invite). No inventar otros.
export const WORKSPACE_INVITE_ROLES = ['user', 'admin', 'supervisor', 'operador', 'visor'] as const;
export type WorkspaceInviteRole = (typeof WORKSPACE_INVITE_ROLES)[number];
/**
* Deriva un slug candidato a partir del nombre de la organización:
* minúsculas, sin acentos, espacios y símbolos → guiones.
*/
export function slugifyTenantName(name: string): string {
return name
.normalize('NFD')
.replace(/[̀-ͯ]/g, '') // quita acentos (marcas combinantes Unicode)
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 100);
}
export type TenantFormValues = {
name: string;
slug: string;
contact_email: string;
};
/**
* Valida los campos obligatorios del alta de organización antes de llamar al Hub,
* para dar feedback inmediato sin gastar un round-trip.
* Devuelve un mensaje de error o null si es válido.
*/
export function validateTenantForm(values: TenantFormValues): string | null {
if (!values.name || values.name.trim().length < 2) {
return 'El nombre de la organización debe tener al menos 2 caracteres.';
}
if (!TENANT_SLUG_RE.test(values.slug)) {
return 'El slug solo admite minúsculas, dígitos y guiones (sin espacios ni acentos).';
}
if (!values.contact_email || !values.contact_email.includes('@')) {
return 'El email de contacto es obligatorio y debe ser válido.';
}
return null;
}
/**
* Extrae un mensaje legible del cuerpo de error de FastAPI (Hub).
* - 401/403 → mensaje de permisos.
* - detail string → tal cual.
* - detail array (422 Pydantic) → "campo: msg" del primer error.
* - cualquier otro → mensaje genérico con el status.
*/
export function hubErrorMessage(body: unknown, status: number): string {
const detail =
body && typeof body === 'object' ? (body as { detail?: unknown }).detail : null;
if (typeof detail === 'string' && detail.trim()) {
return detail;
}
if (Array.isArray(detail) && detail.length > 0) {
const first = detail[0] as { loc?: unknown[]; msg?: string };
const loc = Array.isArray(first.loc) ? first.loc : [];
const field = loc.length ? String(loc[loc.length - 1]) : '';
const msg = first.msg ?? 'dato inválido';
return field ? `${field}: ${msg}` : msg;
}
if (status === 401 || status === 403) {
return 'No tienes permisos de administrador en el workspace para esta acción.';
}
return `El workspace respondió con un error (${status}).`;
}
/** True si el status del Hub indica falta de permisos/sesión. */
export function isForbiddenStatus(status: number): boolean {
return status === 401 || status === 403;
}

View File

@@ -0,0 +1,126 @@
/**
* Orquestación del Hub (Workspace) para dar de alta ORGANIZACIONES (tenants) y
* USUARIOS (invitaciones) desde el CRM.
*
* Principio de seguridad: SIEMPRE se llama server-side reenviando el token del
* usuario autenticado (Bearer). Es el Hub quien valida el permiso —
* `hub_admin`/superadmin para tenants, `hub_admin` o `admin` del tenant para
* invitaciones. El CRM NO guarda secretos ni hace bypass de autorización.
*
* Endpoints del Hub (base = getHubBackendUrl()):
* GET /api/v1/hub/tenants → lista de organizaciones (solo hub_admin)
* POST /api/v1/hub/tenants → crea tenant + realm Keycloak (solo hub_admin)
* POST /api/v1/hub/invites → invitación de un solo uso + email (hub_admin | admin del tenant)
*/
import { getHubBackendUrl } from '$lib/server/workspace-auth';
import { hubErrorMessage, isForbiddenStatus } from '$lib/server/workspace-provision.shared';
const HUB_API_PREFIX = '/api/v1/hub';
function hubUrl(path: string): string {
return `${getHubBackendUrl()}${HUB_API_PREFIX}${path}`;
}
export type WorkspaceTenant = {
id: number;
name: string;
slug: string;
display_name?: string | null;
contact_name?: string | null;
contact_email: string;
status: string;
is_self_hosted: boolean;
has_license: boolean;
created_at: string;
};
export type WorkspaceInvite = {
id: number;
email: string;
tenant_slug: string;
role: string;
invite_url: string;
expires_at: string;
created_at: string;
};
export type HubResult<T> =
| { ok: true; data: T }
| { ok: false; status: number; forbidden: boolean; error: string };
/** Construye el resultado de error a partir de una respuesta no-2xx del Hub. */
async function toErrorResult<T>(res: Response): Promise<HubResult<T>> {
const body = await res.json().catch(() => null);
return {
ok: false,
status: res.status,
forbidden: isForbiddenStatus(res.status),
error: hubErrorMessage(body, res.status)
};
}
const jsonHeaders = (accessToken: string) => ({
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json'
});
/** Lista las organizaciones del workspace. Requiere hub_admin (403 si no). */
export async function listWorkspaceTenants(
accessToken: string,
fetch: typeof globalThis.fetch
): Promise<HubResult<WorkspaceTenant[]>> {
const res = await fetch(hubUrl('/tenants'), {
headers: { Authorization: `Bearer ${accessToken}` }
});
if (!res.ok) return toErrorResult<WorkspaceTenant[]>(res);
const data = (await res.json()) as { tenants?: WorkspaceTenant[] };
return { ok: true, data: Array.isArray(data.tenants) ? data.tenants : [] };
}
export type CreateTenantInput = {
name: string;
slug: string;
contact_email: string;
contact_name?: string;
contact_phone?: string;
display_name?: string;
is_self_hosted: boolean;
app_url?: string;
};
/** Crea una organización (tenant) y provisiona su realm en Keycloak. */
export async function createWorkspaceTenant(
accessToken: string,
fetch: typeof globalThis.fetch,
input: CreateTenantInput
): Promise<HubResult<WorkspaceTenant>> {
const res = await fetch(hubUrl('/tenants'), {
method: 'POST',
headers: jsonHeaders(accessToken),
body: JSON.stringify(input)
});
if (!res.ok) return toErrorResult<WorkspaceTenant>(res);
return { ok: true, data: (await res.json()) as WorkspaceTenant };
}
export type CreateInviteInput = {
email: string;
tenant_slug: string;
role: string;
};
/** Genera una invitación de un solo uso para un usuario en un tenant. */
export async function createWorkspaceInvite(
accessToken: string,
fetch: typeof globalThis.fetch,
input: CreateInviteInput
): Promise<HubResult<WorkspaceInvite>> {
const res = await fetch(hubUrl('/invites'), {
method: 'POST',
headers: jsonHeaders(accessToken),
body: JSON.stringify(input)
});
if (!res.ok) return toErrorResult<WorkspaceInvite>(res);
return { ok: true, data: (await res.json()) as WorkspaceInvite };
}

View File

@@ -14,8 +14,8 @@
import { json } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import { getServerApiUrl, setAuthTokens } from '$lib/server/api';
import { clearAccessTokenCookies } from '$lib/server/access-token-cookie';
import { getServerApiUrl, applyRefreshedTokens, clearAuthTokens } from '$lib/server/api';
import { getAccessTokenFromCookies } from '$lib/server/access-token-cookie';
export const POST = async ({ cookies, fetch }: RequestEvent) => {
const refreshToken = cookies.get('refresh_token');
@@ -27,34 +27,43 @@ export const POST = async ({ cookies, fetch }: RequestEvent) => {
try {
const baseUrl = getServerApiUrl();
// Sesión local actual del CRM (patrón SIWEB): se reenvía para preservar el
// inicio de sesión y permitir el re-emitido de fallback cuando el refresh KC falla.
const currentSession = getAccessTokenFromCookies(cookies);
const sessionId = cookies.get('crm_sid');
const response = await fetch(`${baseUrl}v1/auth/refresh`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refresh_token: refreshToken })
body: JSON.stringify({
refresh_token: refreshToken,
...(currentSession ? { session_token: currentSession } : {}),
...(sessionId ? { session_id: sessionId } : {})
})
});
if (!response.ok) {
// El refresh token expiró o fue invalidado por Keycloak (sesión terminada).
// Limpiar las cookies para que el servidor redirigir al login en la siguiente carga.
cookies.delete('refresh_token', { path: '/' });
clearAccessTokenCookies(cookies);
cookies.delete('active_company_id', { path: '/' });
// El refresh falló y no hubo sesión local que re-emitir (cap superado o
// patrón apagado). Limpiar cookies para que el server redirija al login.
clearAuthTokens(cookies);
const status = response.status === 401 ? 401 : 400;
return json({ error: 'Refresh token expired or invalid' }, { status });
}
const data = (await response.json()) as {
access_token: string;
access_token?: string;
refresh_token?: string;
session_token?: string;
session_id?: string;
expires_in?: number;
};
// Actualizar las cookies HttpOnly con los nuevos tokens
setAuthTokens(cookies, data.access_token, data.refresh_token);
// Actualiza cookies considerando la sesión local; devuelve el bearer de la app.
const appToken = applyRefreshedTokens(cookies, data);
// Devolver solo el access_token al cliente
return json({ access_token: data.access_token });
// Devolver solo el token de app al cliente (sesión local si aplica, o KC).
return json({ access_token: appToken ?? data.access_token ?? '' });
} catch (error) {
console.error('[silent-refresh] Error inesperado:', error);
return json({ error: 'Internal server error' }, { status: 500 });

View File

@@ -9,7 +9,7 @@
import { json } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
import type { RequestEvent } from '@sveltejs/kit';
import { getServerApiUrl, getAuthTokens, setAuthTokens } from '$lib/server/api';
import { getServerApiUrl, getAuthTokens, getKcAccessToken, setAuthTokens } from '$lib/server/api';
export const POST = async ({ request, cookies, fetch }: RequestEvent) => {
const body = await request.json();
@@ -28,9 +28,11 @@ export const POST = async ({ request, cookies, fetch }: RequestEvent) => {
// Modo SSO relay: validar acceso vía Hub y actualizar cookie de override
if (tenant_id) {
try {
// Validación contra el Hub → token KC (el access_token puede ser la sesión local).
const kcToken = getKcAccessToken(cookies) ?? accessToken;
const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, '');
const tenantsRes = await fetch(`${hubUrl}/api/v1/auth/my-tenants`, {
headers: { 'Authorization': `Bearer ${accessToken}` },
headers: { 'Authorization': `Bearer ${kcToken}` },
});
if (!tenantsRes.ok) {
return json({ error: 'Could not validate tenant access' }, { status: 403 });

View File

@@ -134,12 +134,32 @@ export const load: PageServerLoad = async ({ url, cookies }) => {
const isProduction = isSecureContext();
console.log('[SSO] ORIGIN-based secure context:', isProduction);
// Sesión local del CRM (patrón SIWEB): si el refresh proactivo devolvió una
// sesión local firmada, el access_token guarda ESA sesión (bearer de la app,
// sobrevive aunque el refresh KC del Hub falle) y el token KC va a su propia
// cookie kc_access_token (solo para llamadas directas al Hub). Si no vino
// (flag apagado), comportamiento histórico: access_token = token KC.
const sessionToken = typeof tokens.session_token === 'string' ? tokens.session_token : null;
const kcAccessToken = tokens.access_token as string;
// access_token — NO HttpOnly (Bearer desde JS); fragmentado si el JWT supera ~4KB
setAccessTokenCookies(cookies, tokens.access_token as string, {
setAccessTokenCookies(cookies, sessionToken ?? kcAccessToken, {
secure: isProduction,
maxAge: 60 * 60 * 24 * 7,
});
if (sessionToken) {
// kc_access_token — HttpOnly; solo el server lo usa para llamar al Hub.
cookies.set('kc_access_token', kcAccessToken, {
path: '/', httpOnly: true, secure: isProduction, sameSite: 'lax', maxAge: 60 * 60 * 24 * 7,
});
if (typeof tokens.session_id === 'string') {
cookies.set('crm_sid', tokens.session_id, {
path: '/', httpOnly: true, secure: isProduction, sameSite: 'lax', maxAge: 60 * 60 * 24 * 30,
});
}
}
// refresh_token — HttpOnly (never exposed to JS)
if (typeof tokens.refresh_token === 'string') {
cookies.set('refresh_token', tokens.refresh_token, {

View File

@@ -3,6 +3,7 @@ import type { LayoutServerLoad } from './$types';
import {
validateAuth,
getAuthTokens,
getKcAccessToken,
getUserCompanies,
clearAuthTokens
} from '$lib/server/api';
@@ -30,12 +31,15 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
let myApps: { apps: unknown[]; routing: unknown } = { apps: [], routing: null };
if (!DEV_LOCAL_AUTH) {
// Llamadas DIRECTAS al Hub → token KC (con el patrón de sesión local, el
// access_token guarda la sesión local del CRM, no el token de Keycloak).
const kcToken = getKcAccessToken(cookies) ?? accessToken;
try {
const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, '');
const tenantOverride = cookies.get('sso_tenant_id');
const tenantsRes = await fetch(`${hubUrl}/api/v1/auth/my-tenants`, {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Authorization': `Bearer ${kcToken}`,
...(tenantOverride ? { 'X-Tenant-Override': tenantOverride } : {})
}
});
@@ -46,8 +50,7 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
// No bloquear el dashboard si falla la carga de tenants
}
const freshAccessToken = getAuthTokens(cookies).accessToken ?? accessToken;
myApps = await fetchMyApps(freshAccessToken, fetch, cookies.get('sso_tenant_id'));
myApps = await fetchMyApps(kcToken, fetch, cookies.get('sso_tenant_id'));
}
return {

View File

@@ -0,0 +1,78 @@
import { fail } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { getKcAccessToken } from '$lib/server/api';
import {
listWorkspaceTenants,
createWorkspaceTenant,
type CreateTenantInput
} from '$lib/server/workspace-provision';
import { validateTenantForm } from '$lib/server/workspace-provision.shared';
export const load: PageServerLoad = async ({ cookies, fetch }) => {
const accessToken = getKcAccessToken(cookies);
if (!accessToken) {
return { tenants: [], forbidden: true, loadError: null };
}
const res = await listWorkspaceTenants(accessToken, fetch);
if (!res.ok) {
// 401/403 → el usuario no es admin del workspace: se muestra estado informativo,
// no un error. Otros status sí se reportan como error de carga.
return { tenants: [], forbidden: res.forbidden, loadError: res.forbidden ? null : res.error };
}
return { tenants: res.data, forbidden: false, loadError: null };
};
export const actions: Actions = {
create: async ({ request, cookies, fetch }) => {
const accessToken = getKcAccessToken(cookies);
if (!accessToken) return fail(401, { error: 'Tu sesión expiró. Vuelve a entrar al CRM.' });
const data = await request.formData();
const name = String(data.get('name') ?? '').trim();
const slug = String(data.get('slug') ?? '')
.trim()
.toLowerCase();
const contact_email = String(data.get('contact_email') ?? '').trim();
const contact_name = String(data.get('contact_name') ?? '').trim();
const contact_phone = String(data.get('contact_phone') ?? '').trim();
const display_name = String(data.get('display_name') ?? '').trim();
const app_url = String(data.get('app_url') ?? '').trim();
const is_self_hosted = data.get('is_self_hosted') === 'on';
// Valores para repoblar el formulario si algo falla.
const values = {
name,
slug,
contact_email,
contact_name,
contact_phone,
display_name,
app_url,
is_self_hosted
};
const validationError = validateTenantForm({ name, slug, contact_email });
if (validationError) return fail(422, { error: validationError, values });
// Solo se envían los campos opcionales con valor, para no mandar strings vacíos.
const payload: CreateTenantInput = {
name,
slug,
contact_email,
is_self_hosted,
...(contact_name ? { contact_name } : {}),
...(contact_phone ? { contact_phone } : {}),
...(display_name ? { display_name } : {}),
...(app_url ? { app_url } : {})
};
const res = await createWorkspaceTenant(accessToken, fetch, payload);
if (!res.ok) {
return fail(res.forbidden ? 403 : 422, { error: res.error, values });
}
return { success: true, tenantName: res.data.name, tenantSlug: res.data.slug };
}
};

View File

@@ -0,0 +1,180 @@
<script lang="ts">
import { Building2, Plus, ShieldAlert, RefreshCw } from '@lucide/svelte';
import { enhance } from '$app/forms';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { toast } from 'svelte-sonner';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let submitting = $state(false);
const inputCls =
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
// Valores repoblados tras un fallo de validación del servidor.
const v = $derived((form && 'values' in form ? form.values : null) as Record<string, unknown> | null);
function prev(field: string): string {
const val = v?.[field];
return typeof val === 'string' ? val : '';
}
</script>
<div class="space-y-6">
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Building2 class="h-6 w-6" /> Organizaciones del workspace
</h1>
<p class="mt-1 text-sm text-muted-foreground">
Da de alta un cliente nuevo (tenant). Se crea su realm en Keycloak y su licencia base.
</p>
</div>
{#if data.forbidden}
<Card.Root>
<Card.Content class="flex items-start gap-3 pt-6">
<ShieldAlert class="mt-0.5 h-5 w-5 text-amber-500" />
<div class="text-sm">
<p class="font-medium">Requiere permisos de administrador del workspace</p>
<p class="mt-1 text-muted-foreground">
El alta de organizaciones solo está disponible para administradores del Hub
(<code>hub_admin</code>). Tu usuario actual no tiene ese rol.
</p>
</div>
</Card.Content>
</Card.Root>
{:else}
{#if data.loadError}
<div class="rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">
No se pudo consultar el workspace: {data.loadError}
</div>
{/if}
<!-- Alta de organización -->
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2"><Plus class="h-4 w-4" /> Nueva organización</Card.Title>
<Card.Description>El slug identifica al tenant; no se puede cambiar después.</Card.Description>
</Card.Header>
<Card.Content>
<form
method="POST"
action="?/create"
use:enhance={() => {
submitting = true;
return async ({ result, update }) => {
submitting = false;
if (result.type === 'success') {
toast.success(`Organización creada: ${result.data?.tenantName ?? ''}`);
await update({ reset: true });
} else if (result.type === 'failure') {
toast.error(String(result.data?.error ?? 'No se pudo crear la organización'));
await update({ reset: false });
} else {
await update();
}
};
}}
>
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
<span class="font-medium">Nombre / Razón social *</span>
<input class={inputCls} name="name" required minlength="2" value={prev('name')} />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Slug *</span>
<input
class="font-mono {inputCls}"
name="slug"
required
pattern="[a-z0-9-]+"
placeholder="empresa-abc"
title="Solo minúsculas, dígitos y guiones"
value={prev('slug')}
/>
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Nombre para mostrar</span>
<input class={inputCls} name="display_name" value={prev('display_name')} />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Contacto (nombre)</span>
<input class={inputCls} name="contact_name" value={prev('contact_name')} />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Email de contacto *</span>
<input type="email" class={inputCls} name="contact_email" required value={prev('contact_email')} />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Teléfono de contacto</span>
<input class={inputCls} name="contact_phone" value={prev('contact_phone')} />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">URL de la app (opcional)</span>
<input class={inputCls} name="app_url" placeholder="https://app.empresa-abc.com" value={prev('app_url')} />
</label>
<label class="flex items-center gap-2 text-sm sm:col-span-2">
<input type="checkbox" name="is_self_hosted" class="h-4 w-4 rounded border" />
<span>Cliente self-hosted (instalación propia)</span>
</label>
</div>
<div class="mt-6 flex justify-end border-t pt-4">
<Button type="submit" disabled={submitting}>
{submitting ? 'Creando…' : 'Crear organización'}
</Button>
</div>
</form>
</Card.Content>
</Card.Root>
<!-- Organizaciones existentes -->
<Card.Root>
<Card.Header>
<Card.Title>Organizaciones ({data.tenants.length})</Card.Title>
<Card.Description>Tenants activos en el workspace.</Card.Description>
</Card.Header>
<Card.Content>
{#if data.tenants.length === 0}
<p class="flex items-center gap-2 text-sm text-muted-foreground">
<RefreshCw class="h-4 w-4" /> Aún no hay organizaciones registradas.
</p>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="text-left text-muted-foreground">
<tr class="border-b">
<th class="py-2 pr-4 font-medium">Nombre</th>
<th class="py-2 pr-4 font-medium">Slug</th>
<th class="py-2 pr-4 font-medium">Contacto</th>
<th class="py-2 pr-4 font-medium">Estatus</th>
<th class="py-2 pr-4 font-medium">Licencia</th>
</tr>
</thead>
<tbody>
{#each data.tenants as t (t.id)}
<tr class="border-b last:border-0">
<td class="py-2 pr-4">{t.display_name || t.name}</td>
<td class="py-2 pr-4 font-mono text-xs">{t.slug}</td>
<td class="py-2 pr-4">{t.contact_email}</td>
<td class="py-2 pr-4">
<span
class="rounded-full px-2 py-0.5 text-xs {t.status === 'active'
? 'bg-emerald-500/15 text-emerald-600'
: 'bg-muted text-muted-foreground'}"
>
{t.status}
</span>
</td>
<td class="py-2 pr-4">{t.has_license ? 'Sí' : '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</Card.Content>
</Card.Root>
{/if}
</div>

View File

@@ -0,0 +1,63 @@
import { fail } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { getKcAccessToken } from '$lib/server/api';
import { listWorkspaceTenants, createWorkspaceInvite } from '$lib/server/workspace-provision';
import { WORKSPACE_INVITE_ROLES } from '$lib/server/workspace-provision.shared';
export const load: PageServerLoad = async ({ cookies, fetch }) => {
const accessToken = getKcAccessToken(cookies);
if (!accessToken) {
return { tenants: [], canListTenants: false, roles: WORKSPACE_INVITE_ROLES };
}
// Los hub_admin pueden listar todos los tenants (dropdown). Un admin de tenant
// no puede listarlos (403) pero sí puede invitar a SU tenant escribiendo el slug.
const res = await listWorkspaceTenants(accessToken, fetch);
if (!res.ok) {
return { tenants: [], canListTenants: false, roles: WORKSPACE_INVITE_ROLES };
}
return {
tenants: res.data.map((t) => ({ slug: t.slug, name: t.display_name || t.name })),
canListTenants: true,
roles: WORKSPACE_INVITE_ROLES
};
};
export const actions: Actions = {
invite: async ({ request, cookies, fetch }) => {
const accessToken = getKcAccessToken(cookies);
if (!accessToken) return fail(401, { error: 'Tu sesión expiró. Vuelve a entrar al CRM.' });
const data = await request.formData();
const email = String(data.get('email') ?? '').trim();
const tenant_slug = String(data.get('tenant_slug') ?? '')
.trim()
.toLowerCase();
const role = String(data.get('role') ?? 'user').trim();
const values = { email, tenant_slug, role };
if (!email || !email.includes('@')) {
return fail(422, { error: 'Ingresa un email válido para invitar.', values });
}
if (!tenant_slug) {
return fail(422, { error: 'Selecciona (o escribe) el slug de la organización destino.', values });
}
if (!WORKSPACE_INVITE_ROLES.includes(role as (typeof WORKSPACE_INVITE_ROLES)[number])) {
return fail(422, { error: 'Rol inválido.', values });
}
const res = await createWorkspaceInvite(accessToken, fetch, { email, tenant_slug, role });
if (!res.ok) {
return fail(res.forbidden ? 403 : 422, { error: res.error, values });
}
return {
success: true,
email: res.data.email,
inviteUrl: res.data.invite_url,
expiresAt: res.data.expires_at
};
}
};

View File

@@ -0,0 +1,152 @@
<script lang="ts">
import { UserPlus, Copy, Check, MailCheck } from '@lucide/svelte';
import { enhance } from '$app/forms';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { toast } from 'svelte-sonner';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let submitting = $state(false);
let copied = $state(false);
const inputCls =
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
const v = $derived((form && 'values' in form ? form.values : null) as Record<string, unknown> | null);
function prev(field: string): string {
const val = v?.[field];
return typeof val === 'string' ? val : '';
}
// Invitación recién creada (para copiar/compartir el enlace).
const created = $derived(
form && 'success' in form && form.success
? { email: form.email, inviteUrl: form.inviteUrl }
: null
);
async function copyInvite(url: string) {
try {
await navigator.clipboard.writeText(url);
copied = true;
toast.success('Enlace de invitación copiado');
setTimeout(() => (copied = false), 2000);
} catch {
toast.error('No se pudo copiar el enlace');
}
}
</script>
<div class="space-y-6">
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
<UserPlus class="h-6 w-6" /> Alta de usuarios (invitaciones)
</h1>
<p class="mt-1 text-sm text-muted-foreground">
Invita a un usuario a una organización del workspace. Recibe un enlace de un solo uso para
fijar su contraseña y activarse.
</p>
</div>
{#if created}
<Card.Root class="border-emerald-500/40">
<Card.Content class="pt-6">
<div class="flex items-start gap-3">
<MailCheck class="mt-0.5 h-5 w-5 text-emerald-600" />
<div class="min-w-0 flex-1 text-sm">
<p class="font-medium">Invitación enviada a {created.email}</p>
<p class="mt-1 text-muted-foreground">
Si el correo no llega, comparte este enlace directamente:
</p>
<div class="mt-2 flex items-center gap-2">
<input class="flex-1 font-mono text-xs {inputCls}" readonly value={created.inviteUrl} />
<Button variant="outline" size="sm" onclick={() => copyInvite(String(created.inviteUrl))}>
{#if copied}<Check class="h-4 w-4" />{:else}<Copy class="h-4 w-4" />{/if}
</Button>
</div>
</div>
</div>
</Card.Content>
</Card.Root>
{/if}
<Card.Root>
<Card.Header>
<Card.Title>Nueva invitación</Card.Title>
<Card.Description>
{#if data.canListTenants}
Elige la organización destino y el rol del usuario.
{:else}
Escribe el slug de tu organización y el rol del usuario.
{/if}
</Card.Description>
</Card.Header>
<Card.Content>
<form
method="POST"
action="?/invite"
use:enhance={() => {
submitting = true;
return async ({ result, update }) => {
submitting = false;
if (result.type === 'success') {
toast.success('Invitación creada');
await update({ reset: true });
} else if (result.type === 'failure') {
toast.error(String(result.data?.error ?? 'No se pudo crear la invitación'));
await update({ reset: false });
} else {
await update();
}
};
}}
>
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
<span class="font-medium">Email del usuario *</span>
<input type="email" class={inputCls} name="email" required value={prev('email')} />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Organización *</span>
{#if data.canListTenants}
<select class={inputCls} name="tenant_slug" required value={prev('tenant_slug')}>
<option value="" disabled>Selecciona…</option>
{#each data.tenants as t (t.slug)}
<option value={t.slug}>{t.name} ({t.slug})</option>
{/each}
</select>
{:else}
<input
class="font-mono {inputCls}"
name="tenant_slug"
required
pattern="[a-z0-9-]+"
placeholder="empresa-abc"
title="Slug del tenant (minúsculas, dígitos y guiones)"
value={prev('tenant_slug')}
/>
{/if}
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Rol *</span>
<select class={inputCls} name="role" required value={prev('role') || 'user'}>
{#each data.roles as r (r)}
<option value={r}>{r}</option>
{/each}
</select>
</label>
</div>
<div class="mt-6 flex justify-end border-t pt-4">
<Button type="submit" disabled={submitting}>
{submitting ? 'Enviando…' : 'Enviar invitación'}
</Button>
</div>
</form>
</Card.Content>
</Card.Root>
</div>