Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -12,7 +12,9 @@ class LoginRequestDTO(BaseModel):
|
||||
|
||||
username: str = Field(..., description="Usuario o email")
|
||||
password: str = Field(..., min_length=6, description="Contraseña")
|
||||
tenant_slug: str = Field(..., description="Slug del tenant")
|
||||
# Opcional en el primer paso: si no se provee, el backend verifica credenciales
|
||||
# y devuelve la lista de tenants disponibles en lugar de tokens.
|
||||
tenant_slug: Optional[str] = Field(None, description="Slug del tenant")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
@@ -57,6 +59,7 @@ class UserInfoResponseDTO(BaseModel):
|
||||
name: Optional[str] = None
|
||||
preferred_username: Optional[str] = None
|
||||
tenant_id: Optional[int] = None
|
||||
tenant_slug: Optional[str] = None
|
||||
roles: list[str] = []
|
||||
|
||||
class Config:
|
||||
@@ -146,10 +149,49 @@ class SetCookieRequestDTO(BaseModel):
|
||||
access_token: str = Field(..., description="Access token JWT")
|
||||
refresh_token: str = Field(..., description="Refresh token JWT")
|
||||
|
||||
|
||||
class SwitchTenantRequestDTO(BaseModel):
|
||||
"""DTO para cambiar de tenant estando autenticado"""
|
||||
|
||||
tenant_slug: str = Field(..., description="Slug del tenant destino")
|
||||
refresh_token: str = Field(..., description="Refresh token actual para emitir nuevos tokens")
|
||||
|
||||
|
||||
class DiscoverTenantsRequestDTO(BaseModel):
|
||||
"""DTO para descubrir los tenants de un usuario sin necesidad de indicarlo manualmente"""
|
||||
|
||||
username: str = Field(..., description="Nombre de usuario o email")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"username": "jperez",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TenantInfoDTO(BaseModel):
|
||||
"""Información básica de un tenant para mostrar en el selector de login"""
|
||||
|
||||
id: int
|
||||
name: str
|
||||
slug: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class DiscoverTenantsResponseDTO(BaseModel):
|
||||
"""Respuesta con los tenants disponibles para un usuario"""
|
||||
|
||||
tenants: list[TenantInfoDTO]
|
||||
|
||||
|
||||
class LoginChoiceResponseDTO(BaseModel):
|
||||
"""
|
||||
Respuesta del login cuando el usuario pertenece a varios tenants.
|
||||
Las credenciales ya fueron verificadas; el cliente debe re-enviar con tenant_slug.
|
||||
"""
|
||||
|
||||
status: str = "choose_tenant"
|
||||
tenants: list[TenantInfoDTO]
|
||||
|
||||
@@ -10,12 +10,14 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from .dto import (
|
||||
ExchangeCodeRequestDTO,
|
||||
LoginChoiceResponseDTO,
|
||||
LoginRequestDTO,
|
||||
LogoutRequestDTO,
|
||||
RefreshTokenRequestDTO,
|
||||
RegisterRequestDTO,
|
||||
RegisterResponseDTO,
|
||||
SetCookieRequestDTO,
|
||||
SwitchTenantRequestDTO,
|
||||
TokenResponseDTO,
|
||||
UserInfoResponseDTO,
|
||||
)
|
||||
@@ -49,7 +51,7 @@ async def register(
|
||||
return service.register(register_data)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponseDTO)
|
||||
@router.post("/login", response_model=None)
|
||||
async def login(
|
||||
login_data: LoginRequestDTO,
|
||||
request: Request, # Inject Request
|
||||
@@ -73,6 +75,42 @@ async def login(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/switch-tenant", response_model=TokenResponseDTO)
|
||||
async def switch_tenant(
|
||||
data: SwitchTenantRequestDTO,
|
||||
db: Session = Depends(get_core_db),
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
):
|
||||
"""
|
||||
Cambia el tenant activo de un usuario ya autenticado y retorna nuevos tokens JWT.
|
||||
|
||||
Requiere:
|
||||
- Authorization: Bearer <access_token> (para identificar al usuario)
|
||||
- Body: { tenant_slug, refresh_token }
|
||||
"""
|
||||
service = AuthService(db)
|
||||
# Obtener info del usuario desde el access token actual
|
||||
user_info = service.get_user_info(credentials.credentials)
|
||||
|
||||
keycloak_user_id = user_info.sub
|
||||
# El realm se puede inferir del token; usamos el campo tenant_id para buscar el realm actual,
|
||||
# pero lo más directo es dejar que Keycloak lo resuelva usando la config global.
|
||||
# Todos los tenants comparten el mismo realm en esta arquitectura.
|
||||
from api.v1.modules.core.tenants.models import Tenant
|
||||
from core.database import get_core_db as _gcdb
|
||||
# Obtener el realm del tenant destino (o default)
|
||||
tenant = db.query(Tenant).filter(Tenant.slug == data.tenant_slug, Tenant.is_active).first()
|
||||
if not tenant:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
return service.switch_tenant(
|
||||
keycloak_user_id=keycloak_user_id,
|
||||
keycloak_realm=tenant.keycloak_realm,
|
||||
tenant_slug=data.tenant_slug,
|
||||
refresh_token=data.refresh_token,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponseDTO)
|
||||
async def refresh_token(
|
||||
refresh_data: RefreshTokenRequestDTO, db: Session = Depends(get_core_db)
|
||||
|
||||
@@ -43,32 +43,45 @@ class AuthService:
|
||||
login_data: LoginRequestDTO,
|
||||
ip_address: str = None,
|
||||
user_agent: str = None
|
||||
) -> TokenResponseDTO:
|
||||
):
|
||||
"""
|
||||
Autentica usuario y obtiene tokens
|
||||
Autentica usuario y obtiene tokens.
|
||||
|
||||
Si se omite tenant_slug, verifica credenciales primero y devuelve
|
||||
la lista de tenants disponibles (LoginChoiceResponseDTO) en lugar de tokens.
|
||||
|
||||
Args:
|
||||
login_data: Credenciales de login
|
||||
login_data: Credenciales de login (tenant_slug es opcional)
|
||||
ip_address: Dirección IP del cliente
|
||||
user_agent: User Agent del cliente
|
||||
|
||||
Returns:
|
||||
TokenResponseDTO con access_token y refresh_token
|
||||
TokenResponseDTO si tenant_slug fue provisto,
|
||||
LoginChoiceResponseDTO si no se proveyó tenant_slug.
|
||||
|
||||
Raises:
|
||||
HTTPException: Si las credenciales son inválidas
|
||||
"""
|
||||
# PRIMER PASO: sin tenant_slug → verificar creds y devolver lista de orgs
|
||||
if not login_data.tenant_slug:
|
||||
from .dto import LoginChoiceResponseDTO, TenantInfoDTO
|
||||
tenants = self._verify_credentials_and_list_tenants(
|
||||
login_data.username, login_data.password
|
||||
)
|
||||
# Siempre devolver LoginChoiceResponseDTO; el frontend decide si
|
||||
# auto-seleccionar (1 tenant) o mostrar selector (>1 tenants).
|
||||
return LoginChoiceResponseDTO(
|
||||
tenants=[TenantInfoDTO(**t) for t in tenants]
|
||||
)
|
||||
|
||||
try:
|
||||
# Verificar que el tenant existe
|
||||
tenant_service = TenantService(self.db)
|
||||
user_tenant_service = UserTenantService(self.db)
|
||||
tenant = tenant_service.get_tenant_by_slug(login_data.tenant_slug)
|
||||
|
||||
if not tenant:
|
||||
raise HTTPException(status_code=404, detail="Tenant not found")
|
||||
|
||||
if not tenant.is_active:
|
||||
raise HTTPException(status_code=403, detail="Tenant is not active")
|
||||
if not tenant or not tenant.is_active:
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
# Crear nueva instancia de KeycloakOpenID con el realm del tenant
|
||||
keycloak_client = KeycloakOpenID(
|
||||
@@ -110,8 +123,8 @@ class AuthService:
|
||||
f"User {user_id} tried to access tenant {tenant.id} without permission"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="You don't have access to this tenant",
|
||||
status_code=401,
|
||||
detail="Invalid credentials",
|
||||
)
|
||||
|
||||
# Obtener los datos actuales del usuario
|
||||
@@ -228,17 +241,25 @@ class AuthService:
|
||||
if "realm_access" in user_info:
|
||||
roles = user_info["realm_access"].get("roles", [])
|
||||
|
||||
# Extraer tenant_id si está presente
|
||||
# Extraer tenant_id y tenant_slug si están presentes
|
||||
tenant_id = user_info.get("tenant_id")
|
||||
if not tenant_id and "attributes" in user_info:
|
||||
tenant_id = user_info["attributes"].get("tenant_id")
|
||||
|
||||
tenant_slug = user_info.get("tenant_slug")
|
||||
if not tenant_slug and "attributes" in user_info:
|
||||
tenant_slug = user_info["attributes"].get("tenant_slug")
|
||||
# Puede venir como lista de Keycloak attributes
|
||||
if isinstance(tenant_slug, list):
|
||||
tenant_slug = tenant_slug[0] if tenant_slug else None
|
||||
|
||||
return UserInfoResponseDTO(
|
||||
sub=user_info.get("sub"),
|
||||
email=user_info.get("email"),
|
||||
name=user_info.get("name"),
|
||||
preferred_username=user_info.get("preferred_username"),
|
||||
tenant_id=int(tenant_id) if tenant_id else None,
|
||||
tenant_slug=tenant_slug,
|
||||
roles=roles,
|
||||
)
|
||||
|
||||
@@ -455,3 +476,252 @@ class AuthService:
|
||||
except Exception as e:
|
||||
logger.error(f"Code exchange error: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Code exchange error")
|
||||
|
||||
def switch_tenant(
|
||||
self,
|
||||
keycloak_user_id: str,
|
||||
keycloak_realm: str,
|
||||
tenant_slug: str,
|
||||
refresh_token: str,
|
||||
) -> TokenResponseDTO:
|
||||
"""
|
||||
Cambia el tenant activo de un usuario autenticado sin requerir su contraseña.
|
||||
|
||||
Pasos:
|
||||
1. Verifica que el tenant existe y está activo.
|
||||
2. Verifica que el usuario tiene acceso a ese tenant.
|
||||
3. Actualiza los atributos tenant_id/tenant_slug del usuario en Keycloak.
|
||||
4. Usa el refresh_token para emitir nuevos tokens que ya contienen los atributos actualizados.
|
||||
"""
|
||||
from api.v1.modules.core.tenants.models import Tenant
|
||||
|
||||
tenant_service = TenantService(self.db)
|
||||
user_tenant_service = UserTenantService(self.db)
|
||||
|
||||
tenant = tenant_service.get_tenant_by_slug(tenant_slug)
|
||||
if not tenant or not tenant.is_active:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
# Verificar acceso
|
||||
has_access = user_tenant_service.user_has_access_to_tenant(keycloak_user_id, tenant.id)
|
||||
if not has_access:
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
# Actualizar atributos en Keycloak antes de emitir el nuevo token
|
||||
try:
|
||||
keycloak_admin = KeycloakAdmin(
|
||||
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
|
||||
username=settings.KEYCLOAK_ADMIN_USERNAME,
|
||||
password=settings.KEYCLOAK_ADMIN_PASSWORD,
|
||||
realm_name=keycloak_realm,
|
||||
user_realm_name="master",
|
||||
verify=True,
|
||||
)
|
||||
current_user = keycloak_admin.get_user(keycloak_user_id)
|
||||
attrs = current_user.get("attributes", {})
|
||||
attrs["tenant_id"] = [str(tenant.id)]
|
||||
attrs["tenant_slug"] = [tenant.slug]
|
||||
keycloak_admin.update_user(
|
||||
user_id=keycloak_user_id,
|
||||
payload={
|
||||
"email": current_user.get("email"),
|
||||
"firstName": current_user.get("firstName"),
|
||||
"lastName": current_user.get("lastName"),
|
||||
"enabled": current_user.get("enabled", True),
|
||||
"emailVerified": current_user.get("emailVerified", False),
|
||||
"attributes": attrs,
|
||||
},
|
||||
)
|
||||
except KeycloakError as e:
|
||||
logger.warning(f"switch_tenant: could not update user attributes: {e}")
|
||||
raise HTTPException(status_code=500, detail="Could not update tenant attributes")
|
||||
|
||||
# Emitir nuevos tokens usando el refresh_token existente
|
||||
keycloak_client = KeycloakOpenID(
|
||||
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
|
||||
client_id=settings.KEYCLOAK_CLIENT_ID,
|
||||
realm_name=keycloak_realm,
|
||||
client_secret_key=settings.KEYCLOAK_CLIENT_SECRET,
|
||||
)
|
||||
try:
|
||||
token_response = keycloak_client.refresh_token(refresh_token)
|
||||
except KeycloakError as e:
|
||||
logger.warning(f"switch_tenant: token refresh failed: {e}")
|
||||
raise HTTPException(status_code=401, detail="Token refresh failed; please log in again")
|
||||
|
||||
return TokenResponseDTO(
|
||||
access_token=token_response["access_token"],
|
||||
refresh_token=token_response["refresh_token"],
|
||||
token_type="bearer",
|
||||
expires_in=token_response["expires_in"],
|
||||
)
|
||||
|
||||
def _verify_credentials_and_list_tenants(self, username: str, password: str) -> list:
|
||||
"""
|
||||
Verifica las credenciales del usuario contra Keycloak y, solo si son válidas,
|
||||
devuelve la lista de tenants a los que tiene acceso.
|
||||
|
||||
Esto evita el oráculo de enumeración de usuarios del antiguo endpoint
|
||||
/discover-tenants que no requería contraseña.
|
||||
|
||||
Args:
|
||||
username: Nombre de usuario o email
|
||||
password: Contraseña en texto plano
|
||||
|
||||
Returns:
|
||||
Lista de dicts {id, name, slug} con los tenants del usuario
|
||||
|
||||
Raises:
|
||||
HTTPException 401: Si las credenciales son inválidas
|
||||
"""
|
||||
from api.v1.modules.core.tenants.models import Tenant
|
||||
from api.v1.modules.core.user_tenant.models import UserTenant
|
||||
from sqlalchemy import and_
|
||||
|
||||
tenants = self.db.query(Tenant).filter(Tenant.is_active).all()
|
||||
if not tenants:
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
realms: dict[str, list] = {}
|
||||
for tenant in tenants:
|
||||
realms.setdefault(tenant.keycloak_realm, []).append(tenant)
|
||||
|
||||
credentials_verified = False
|
||||
matched_tenants = []
|
||||
|
||||
for realm_name, realm_tenants in realms.items():
|
||||
try:
|
||||
keycloak_admin = KeycloakAdmin(
|
||||
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
|
||||
username=settings.KEYCLOAK_ADMIN_USERNAME,
|
||||
password=settings.KEYCLOAK_ADMIN_PASSWORD,
|
||||
realm_name=realm_name,
|
||||
user_realm_name="master",
|
||||
verify=True,
|
||||
)
|
||||
|
||||
users = keycloak_admin.get_users({"username": username, "exact": True})
|
||||
if not users:
|
||||
users = keycloak_admin.get_users({"email": username, "exact": True})
|
||||
if not users:
|
||||
continue
|
||||
|
||||
keycloak_user_id = users[0]["id"]
|
||||
|
||||
# Verificar la contraseña contra este realm (una sola vez)
|
||||
if not credentials_verified:
|
||||
keycloak_client = KeycloakOpenID(
|
||||
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
|
||||
client_id=settings.KEYCLOAK_CLIENT_ID,
|
||||
realm_name=realm_name,
|
||||
client_secret_key=settings.KEYCLOAK_CLIENT_SECRET,
|
||||
)
|
||||
try:
|
||||
keycloak_client.token(
|
||||
username=username,
|
||||
password=password,
|
||||
grant_type=["password"],
|
||||
)
|
||||
credentials_verified = True
|
||||
except KeycloakError:
|
||||
# Contraseña incorrecta — no revelar que el usuario existe
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
# Recopilar tenants con acceso confirmado
|
||||
for tenant in realm_tenants:
|
||||
has_access = (
|
||||
self.db.query(UserTenant)
|
||||
.filter(
|
||||
and_(
|
||||
UserTenant.keycloak_user_id == keycloak_user_id,
|
||||
UserTenant.tenant_id == tenant.id,
|
||||
UserTenant.is_active,
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if has_access:
|
||||
matched_tenants.append(
|
||||
{"id": tenant.id, "name": tenant.name, "slug": tenant.slug}
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not query realm '{realm_name}' during credential check: {e}")
|
||||
continue
|
||||
|
||||
if not credentials_verified:
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
return matched_tenants
|
||||
|
||||
def discover_user_tenants(self, username: str) -> list:
|
||||
"""
|
||||
[DEPRECATED] Usa _verify_credentials_and_list_tenants en su lugar.
|
||||
Descubre los tenants activos a los que pertenece un usuario dado su username.
|
||||
"""
|
||||
from api.v1.modules.core.tenants.models import Tenant
|
||||
from api.v1.modules.core.user_tenant.models import UserTenant
|
||||
from sqlalchemy import and_
|
||||
|
||||
# 1. Obtener todos los tenants activos
|
||||
tenants = self.db.query(Tenant).filter(Tenant.is_active).all()
|
||||
|
||||
if not tenants:
|
||||
return []
|
||||
|
||||
# 2. Agrupar tenants por keycloak_realm para no repetir consultas admin
|
||||
realms: dict[str, list] = {}
|
||||
for tenant in tenants:
|
||||
realms.setdefault(tenant.keycloak_realm, []).append(tenant)
|
||||
|
||||
matched_tenants = []
|
||||
|
||||
for realm_name, realm_tenants in realms.items():
|
||||
try:
|
||||
keycloak_admin = KeycloakAdmin(
|
||||
server_url=f"{settings.KEYCLOAK_SERVER_URL}/kcauth",
|
||||
username=settings.KEYCLOAK_ADMIN_USERNAME,
|
||||
password=settings.KEYCLOAK_ADMIN_PASSWORD,
|
||||
realm_name=realm_name,
|
||||
user_realm_name="master",
|
||||
verify=True,
|
||||
)
|
||||
|
||||
# Buscar por username exacto
|
||||
users = keycloak_admin.get_users({"username": username, "exact": True})
|
||||
if not users:
|
||||
# Intentar por email
|
||||
users = keycloak_admin.get_users({"email": username, "exact": True})
|
||||
|
||||
if not users:
|
||||
continue
|
||||
|
||||
keycloak_user_id = users[0]["id"]
|
||||
|
||||
# 3. Para cada tenant en este realm, verificar UserTenant
|
||||
for tenant in realm_tenants:
|
||||
has_access = (
|
||||
self.db.query(UserTenant)
|
||||
.filter(
|
||||
and_(
|
||||
UserTenant.keycloak_user_id == keycloak_user_id,
|
||||
UserTenant.tenant_id == tenant.id,
|
||||
UserTenant.is_active,
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if has_access:
|
||||
matched_tenants.append(
|
||||
{"id": tenant.id, "name": tenant.name, "slug": tenant.slug}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Could not query realm '{realm_name}' during tenant discovery: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
return matched_tenants
|
||||
|
||||
@@ -5,17 +5,16 @@
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldDescription,
|
||||
FieldSeparator,
|
||||
} from "$lib/components/ui/field/index.js";
|
||||
import { Input } from "$lib/components/ui/input/index.js";
|
||||
import { Button } from "$lib/components/ui/button/index.js";
|
||||
import { cn } from "$lib/utils.js";
|
||||
import { FileText, ShieldCheck } from 'lucide-svelte';
|
||||
import faviconUrl from '$lib/assets/favicon.svg';
|
||||
import type { HTMLAttributes } from "svelte/elements";
|
||||
import { page } from '$app/state';
|
||||
import { enhance } from '$app/forms';
|
||||
import { loginWithProvider } from '$lib/sso';
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, tick } from 'svelte';
|
||||
|
||||
let { class: className, ...restProps }: HTMLAttributes<HTMLDivElement> = $props();
|
||||
|
||||
@@ -23,14 +22,20 @@
|
||||
|
||||
let username = $state('demo');
|
||||
let password = $state('demo123');
|
||||
let tenantSlug = $state('aduanasoft');
|
||||
let tenantSlug = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
// Obtener el error del servidor si existe
|
||||
// step 1 = credenciales, step 2 = selección de organización
|
||||
let step = $state<1 | 2>(1);
|
||||
let readyToSubmit = $state(false);
|
||||
let formEl: HTMLFormElement | undefined = $state();
|
||||
|
||||
// Descubrimiento de tenants
|
||||
type TenantInfo = { id: number; name: string; slug: string };
|
||||
let tenants = $state<TenantInfo[]>([]);
|
||||
|
||||
const error = $derived(page.form?.error || '');
|
||||
|
||||
// Limpiar todo el localStorage y cookies al montar el componente de login
|
||||
// Esto asegura que no queden datos del tenant anterior
|
||||
onMount(() => {
|
||||
clearAllData();
|
||||
});
|
||||
@@ -46,59 +51,100 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Función para limpiar todo el localStorage y cookies
|
||||
function clearAllData() {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('activeCompanyId');
|
||||
}
|
||||
|
||||
clearClientCookies();
|
||||
}
|
||||
|
||||
async function fetchTenants(): Promise<TenantInfo[]> {
|
||||
try {
|
||||
const apiBase = (import.meta.env.VITE_API_URL || '').replace(/\/+$/, '');
|
||||
// Llama a /login SIN tenant_slug: el backend verifica credenciales primero,
|
||||
// luego devuelve las orgs. Sin contraseña válida no se revela nada.
|
||||
const res = await fetch(`${apiBase}/v1/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
// { status: "choose_tenant", tenants: [...] }
|
||||
if (data.tenants) return data.tenants;
|
||||
}
|
||||
} catch {
|
||||
// ignore — el server action mostrará el error de autenticación
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// Llama al backend real desde el paso 2
|
||||
function confirmTenant() {
|
||||
if (!tenantSlug) return;
|
||||
readyToSubmit = true;
|
||||
formEl?.requestSubmit();
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
step = 1;
|
||||
tenantSlug = '';
|
||||
tenants = [];
|
||||
readyToSubmit = false;
|
||||
}
|
||||
|
||||
function handleMicrosoftLogin() {
|
||||
// Limpiar datos antes de iniciar SSO
|
||||
clearAllData();
|
||||
|
||||
// Guardar el tenant_slug en localStorage para recuperarlo después del callback
|
||||
if (tenantSlug) {
|
||||
localStorage.setItem('pending_tenant_slug', tenantSlug);
|
||||
}
|
||||
if (tenantSlug) localStorage.setItem('pending_tenant_slug', tenantSlug);
|
||||
loginWithProvider('microsoft');
|
||||
}
|
||||
|
||||
function handleGoogleLogin() {
|
||||
// Limpiar datos antes de iniciar SSO
|
||||
clearAllData();
|
||||
|
||||
// Guardar el tenant_slug en localStorage para recuperarlo después del callback
|
||||
if (tenantSlug) {
|
||||
localStorage.setItem('pending_tenant_slug', tenantSlug);
|
||||
}
|
||||
if (tenantSlug) localStorage.setItem('pending_tenant_slug', tenantSlug);
|
||||
loginWithProvider('google');
|
||||
}
|
||||
|
||||
function handleAppleLogin() {
|
||||
// Apple no está configurado por defecto en Keycloak,
|
||||
// pero puedes agregarlo siguiendo el mismo patrón
|
||||
alert('Apple SSO no está configurado aún');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={cn("flex flex-col gap-6", className)} {...restProps}>
|
||||
<Card.Root class="overflow-hidden p-0">
|
||||
<Card.Root class="overflow-hidden p-0 shadow-2xl border-0">
|
||||
<Card.Content class="grid p-0 md:grid-cols-2">
|
||||
<!-- Formulario -->
|
||||
<form
|
||||
class="p-6 md:p-8"
|
||||
bind:this={formEl}
|
||||
class="p-8 md:p-10 flex flex-col justify-center"
|
||||
method="POST"
|
||||
use:enhance={() => {
|
||||
use:enhance={async ({ cancel }) => {
|
||||
// Interceptar solo en el paso 1 antes de hacer el submit real
|
||||
if (!readyToSubmit) {
|
||||
cancel();
|
||||
loading = true;
|
||||
tenants = await fetchTenants();
|
||||
loading = false;
|
||||
if (tenants.length === 1) {
|
||||
// 1 sola org: login directo
|
||||
tenantSlug = tenants[0].slug;
|
||||
readyToSubmit = true;
|
||||
await tick(); // esperar a que el DOM refleje tenantSlug antes de enviar
|
||||
formEl?.requestSubmit();
|
||||
} else if (tenants.length > 1) {
|
||||
// Varias orgs: mostrar selector
|
||||
step = 2;
|
||||
} else {
|
||||
// 0 orgs: enviar igual, el backend rechazará
|
||||
readyToSubmit = true;
|
||||
await tick();
|
||||
formEl?.requestSubmit();
|
||||
}
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
return async ({ update, result }) => {
|
||||
await update();
|
||||
loading = false;
|
||||
|
||||
// Si hay un error, limpiar cookies del cliente
|
||||
readyToSubmit = false;
|
||||
if (result.type === 'failure') {
|
||||
clearClientCookies();
|
||||
}
|
||||
@@ -106,131 +152,218 @@
|
||||
}}
|
||||
>
|
||||
<FieldGroup>
|
||||
<div class="flex flex-col items-center gap-2 text-center">
|
||||
<h1 class="text-2xl font-bold bg-gradient-to-r from-blue-600 to-blue-800 bg-clip-text text-transparent">Anexo 76</h1>
|
||||
<p class="text-muted-foreground text-balance text-sm">
|
||||
Sistema de Cumplimiento Fiscal y Aduanal
|
||||
</p>
|
||||
<!-- Logo / Branding -->
|
||||
<div class="flex flex-col items-center gap-3 text-center mb-2">
|
||||
<img src={faviconUrl} alt="Anexo 76" class="w-14 h-14 rounded-2xl shadow-lg shadow-blue-200 dark:shadow-blue-900/50" />
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold tracking-tight text-slate-900 dark:text-slate-100">Anexo 76</h1>
|
||||
<p class="text-muted-foreground text-sm mt-0.5">
|
||||
Sistema de Cumplimiento Fiscal y Aduanal
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{#if error}
|
||||
<div class="rounded-md bg-red-50 p-4 text-sm text-red-800">
|
||||
<div class="flex items-start gap-3 rounded-xl bg-red-50 dark:bg-red-950/40 border border-red-200 dark:border-red-800 p-4 text-sm text-red-700 dark:text-red-400">
|
||||
<svg class="w-4 h-4 mt-0.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||
</svg>
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="tenant-{id}">Tenant</FieldLabel>
|
||||
<Input
|
||||
id="tenant-{id}"
|
||||
name="tenant_slug"
|
||||
type="text"
|
||||
placeholder="aduanasoft"
|
||||
bind:value={tenantSlug}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel for="username-{id}">Usuario</FieldLabel>
|
||||
<Input
|
||||
id="username-{id}"
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder="demo"
|
||||
bind:value={username}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<div class="flex items-center">
|
||||
<FieldLabel for="password-{id}">Contraseña</FieldLabel>
|
||||
<a href="##" class="ml-auto text-sm underline-offset-2 hover:underline">
|
||||
¿Olvidaste tu contraseña?
|
||||
</a>
|
||||
|
||||
<!-- Campos ocultos siempre presentes para el submit en paso 2 -->
|
||||
<input type="hidden" name="tenant_slug" value={tenantSlug} />
|
||||
{#if step === 2}
|
||||
<input type="hidden" name="username" value={username} />
|
||||
<input type="hidden" name="password" value={password} />
|
||||
{/if}
|
||||
|
||||
{#if step === 1}
|
||||
<!-- PASO 1: Credenciales -->
|
||||
<Field>
|
||||
<FieldLabel for="username-{id}" class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">Usuario</FieldLabel>
|
||||
<Input
|
||||
id="username-{id}"
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder="usuario@empresa.com"
|
||||
bind:value={username}
|
||||
required
|
||||
disabled={loading}
|
||||
class="h-11"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<div class="flex items-center justify-between">
|
||||
<FieldLabel for="password-{id}" class="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">Contraseña</FieldLabel>
|
||||
<a href="##" class="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400 font-medium hover:underline underline-offset-2">
|
||||
¿Olvidaste tu contraseña?
|
||||
</a>
|
||||
</div>
|
||||
<Input
|
||||
id="password-{id}"
|
||||
name="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
required
|
||||
disabled={loading}
|
||||
class="h-11"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="h-11 w-full bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 font-semibold shadow-md shadow-blue-200 dark:shadow-blue-900/40 transition-all duration-200"
|
||||
>
|
||||
{#if loading}
|
||||
<svg class="animate-spin w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
||||
</svg>
|
||||
Verificando...
|
||||
{:else}
|
||||
Iniciar sesión
|
||||
{/if}
|
||||
</Button>
|
||||
</Field>
|
||||
|
||||
<div class="relative flex items-center gap-3 my-1">
|
||||
<div class="flex-1 h-px bg-border"></div>
|
||||
<span class="text-xs text-muted-foreground font-medium px-1">o continúa con</span>
|
||||
<div class="flex-1 h-px bg-border"></div>
|
||||
</div>
|
||||
<Input
|
||||
id="password-{id}"
|
||||
name="password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? 'Iniciando sesión...' : 'Iniciar sesión'}
|
||||
</Button>
|
||||
</Field>
|
||||
<FieldSeparator class="*:data-[slot=field-separator-content]:bg-card">
|
||||
O continua con
|
||||
</FieldSeparator>
|
||||
<Field class="grid grid-cols-3 gap-4">
|
||||
<Button variant="outline" type="button" onclick={handleAppleLogin} disabled={loading}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12.152 6.896c-.948 0-2.415-1.078-3.96-1.04-2.04.027-3.91 1.183-4.961 3.014-2.117 3.675-.546 9.103 1.519 12.09 1.013 1.454 2.208 3.09 3.792 3.039 1.52-.065 2.09-.987 3.935-.987 1.831 0 2.35.987 3.96.948 1.637-.026 2.676-1.48 3.676-2.948 1.156-1.688 1.636-3.325 1.662-3.415-.039-.013-3.182-1.221-3.22-4.857-.026-3.04 2.48-4.494 2.597-4.559-1.429-2.09-3.623-2.324-4.39-2.376-2-.156-3.675 1.09-4.61 1.09zM15.53 3.83c.843-1.012 1.4-2.427 1.245-3.83-1.207.052-2.662.805-3.532 1.818-.78.896-1.454 2.338-1.273 3.714 1.338.104 2.715-.688 3.559-1.701"
|
||||
fill="currentColor"
|
||||
/>
|
||||
|
||||
<Field class="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onclick={handleGoogleLogin}
|
||||
disabled={loading}
|
||||
class="h-11 border hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z" fill="currentColor"/>
|
||||
</svg>
|
||||
<span class="sr-only">Google</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onclick={handleMicrosoftLogin}
|
||||
disabled={loading}
|
||||
class="h-11 border hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
<svg class="w-4 h-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M11.4 24H0V12.6h11.4V24zM24 24H12.6V12.6H24V24zM11.4 11.4H0V0h11.4v11.4zm12.6 0H12.6V0H24v11.4z" fill="currentColor"/>
|
||||
</svg>
|
||||
<span class="sr-only">Microsoft</span>
|
||||
</Button>
|
||||
</Field>
|
||||
|
||||
<p class="text-center text-sm text-muted-foreground">
|
||||
¿No tienes cuenta?{' '}
|
||||
<a href="/register" class="font-semibold text-blue-600 hover:text-blue-700 dark:text-blue-400 hover:underline underline-offset-2">
|
||||
Regístrate
|
||||
</a>
|
||||
</p>
|
||||
{:else}
|
||||
<!-- PASO 2: Selección de organización -->
|
||||
<div class="flex flex-col gap-1 text-center">
|
||||
<p class="text-sm font-medium text-slate-700 dark:text-slate-300">Selecciona tu organización</p>
|
||||
<p class="text-xs text-muted-foreground">Tu cuenta tiene acceso a varias organizaciones</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
{#each tenants as t}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (tenantSlug = t.slug)}
|
||||
class="flex items-center gap-3 rounded-xl border-2 px-4 py-3 text-left transition-all duration-150 hover:border-blue-400 hover:bg-blue-50 dark:hover:bg-blue-950/40
|
||||
{tenantSlug === t.slug
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-950/40'
|
||||
: 'border-input bg-background'}"
|
||||
>
|
||||
<span class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900/50 text-blue-600 dark:text-blue-400 font-bold text-sm">
|
||||
{t.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<span class="flex-1 text-sm font-medium text-slate-800 dark:text-slate-200">{t.name}</span>
|
||||
{#if tenantSlug === t.slug}
|
||||
<svg class="w-4 h-4 text-blue-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<Field>
|
||||
<Button
|
||||
type="button"
|
||||
onclick={confirmTenant}
|
||||
disabled={!tenantSlug || loading}
|
||||
class="h-11 w-full bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 font-semibold shadow-md shadow-blue-200 dark:shadow-blue-900/40 transition-all duration-200"
|
||||
>
|
||||
{#if loading}
|
||||
<svg class="animate-spin w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
||||
</svg>
|
||||
Iniciando sesión...
|
||||
{:else}
|
||||
Continuar
|
||||
{/if}
|
||||
</Button>
|
||||
</Field>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={goBack}
|
||||
class="flex items-center justify-center gap-1.5 text-xs text-muted-foreground hover:text-slate-700 dark:hover:text-slate-300 transition-colors mx-auto"
|
||||
>
|
||||
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
<span class="sr-only">Login with Apple</span>
|
||||
</Button>
|
||||
<Button variant="outline" type="button" onclick={handleGoogleLogin} disabled={loading}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
<span class="sr-only">Login with Google</span>
|
||||
</Button>
|
||||
<Button variant="outline" type="button" onclick={handleMicrosoftLogin} disabled={loading}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<path d="M11.4 24H0V12.6h11.4V24zM24 24H12.6V12.6H24V24zM11.4 11.4H0V0h11.4v11.4zm12.6 0H12.6V0H24v11.4z" fill="currentColor"/>
|
||||
</svg>
|
||||
<span class="sr-only">Login with Microsoft</span>
|
||||
</Button>
|
||||
</Field>
|
||||
<FieldDescription class="text-center">
|
||||
¿No tienes una cuenta? <a href="/register">Regístrate</a>
|
||||
</FieldDescription>
|
||||
Volver al inicio de sesión
|
||||
</button>
|
||||
{/if}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
<div class="relative hidden md:block bg-gradient-to-br from-blue-50 to-slate-100 dark:from-slate-900 dark:to-blue-950">
|
||||
<div class="absolute inset-0 flex items-center justify-center p-12">
|
||||
<div class="relative w-full h-full flex flex-col items-center justify-center gap-8">
|
||||
<!-- Ícono principal - Escudo con documento -->
|
||||
<div class="relative">
|
||||
<FileText class="w-48 h-48 text-blue-600 dark:text-blue-400 opacity-90" strokeWidth={1.5} />
|
||||
<!-- Escudo de protección superpuesto -->
|
||||
<div class="absolute -bottom-2 -right-2 bg-white dark:bg-slate-800 rounded-full p-2 shadow-lg">
|
||||
<ShieldCheck class="w-12 h-12 text-green-600 dark:text-green-400" fill="currentColor" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Texto descriptivo -->
|
||||
<div class="text-center space-y-3 max-w-sm">
|
||||
<h2 class="text-2xl font-bold text-slate-800 dark:text-slate-100">
|
||||
Cumplimiento Simplificado
|
||||
</h2>
|
||||
<p class="text-slate-600 dark:text-slate-300 text-sm leading-relaxed">
|
||||
Gestiona tus obligaciones fiscales del Anexo 76 del SAT de manera eficiente y segura
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Elementos decorativos -->
|
||||
<div class="absolute top-8 left-8 w-20 h-20 border-2 border-blue-200 dark:border-blue-800 rounded-full opacity-50"></div>
|
||||
<div class="absolute bottom-8 right-8 w-16 h-16 border-2 border-blue-300 dark:border-blue-700 rounded-lg opacity-50 rotate-45"></div>
|
||||
|
||||
<!-- Panel imagen -->
|
||||
<div class="relative hidden md:block overflow-hidden">
|
||||
<!-- Fotografía de fondo -->
|
||||
<img
|
||||
src="/login-bg.jpg"
|
||||
alt=""
|
||||
class="absolute inset-0 w-full h-full object-cover object-center"
|
||||
/>
|
||||
<!-- Overlay degradado -->
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-slate-900/90 via-slate-900/40 to-slate-900/10"></div>
|
||||
|
||||
<!-- Contenido sobre la imagen -->
|
||||
<div class="relative z-10 h-full flex flex-col justify-end p-10">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<img src={faviconUrl} alt="" class="w-9 h-9 rounded-xl" />
|
||||
<span class="text-white font-bold text-lg tracking-tight">Anexo 76</span>
|
||||
</div>
|
||||
<blockquote class="space-y-2">
|
||||
<p class="text-white text-xl font-semibold leading-snug">
|
||||
"Cumplimiento fiscal simplificado para empresas que operan con el SAT."
|
||||
</p>
|
||||
<footer class="text-slate-300 text-sm">Sistema de Cumplimiento Fiscal y Aduanal</footer>
|
||||
</blockquote>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
<FieldDescription class="px-6 text-center text-xs">
|
||||
Al continuar, aceptas nuestros <a href="##" class="underline hover:text-blue-600">Términos de Servicio</a> y
|
||||
<a href="##" class="underline hover:text-blue-600">Política de Privacidad</a>.
|
||||
</FieldDescription>
|
||||
<p class="px-6 text-center text-xs text-muted-foreground">
|
||||
Al continuar, aceptas nuestros
|
||||
<a href="##" class="underline hover:text-blue-600 underline-offset-2">Términos de Servicio</a>
|
||||
y
|
||||
<a href="##" class="underline hover:text-blue-600 underline-offset-2">Política de Privacidad</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
// Obtener datos del usuario desde el contexto (viene de Keycloak vía +layout.server.ts)
|
||||
const userData = getContext<any>('user');
|
||||
const userTenants = getContext<{ id: number; name: string; slug: string }[]>('userTenants') ?? [];
|
||||
|
||||
// Obtener datos del sidebar con traducciones
|
||||
const sidebarData = getSidebarData();
|
||||
@@ -42,7 +43,7 @@
|
||||
<NavProjects projects={data.projects} />
|
||||
</Sidebar.Content>
|
||||
<Sidebar.Footer>
|
||||
<NavUser user={data.user} />
|
||||
<NavUser user={data.user} tenants={userTenants} />
|
||||
</Sidebar.Footer>
|
||||
<Sidebar.Rail />
|
||||
</Sidebar.Root>
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
import LanguagesIcon from '@lucide/svelte/icons/languages';
|
||||
import MoonIcon from '@lucide/svelte/icons/moon';
|
||||
import SunIcon from '@lucide/svelte/icons/sun';
|
||||
import ArrowLeftRightIcon from '@lucide/svelte/icons/arrow-left-right';
|
||||
import BuildingIcon from '@lucide/svelte/icons/building';
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
import { logout } from '$lib/auth';
|
||||
import { cookieName } from '$lib/paraglide/runtime';
|
||||
import { page } from '$app/state';
|
||||
@@ -19,9 +22,18 @@
|
||||
import { getBackendAssetUrl } from '$lib/utils';
|
||||
import AppVersion from '$lib/components/app-version.svelte';
|
||||
|
||||
let { user }: { user: { name: string; email: string; avatar: string } } = $props();
|
||||
let { user, tenants = [] }: {
|
||||
user: { name: string; email: string; avatar: string };
|
||||
tenants: { id: number; name: string; slug: string }[];
|
||||
} = $props();
|
||||
const sidebar = useSidebar();
|
||||
|
||||
// Tenant activo (viene en el JWT como atributo tenant_slug)
|
||||
let currentTenantSlug = $derived((page.data.user as any)?.tenant_slug ?? '');
|
||||
|
||||
// Estado de cambio de tenant
|
||||
let switchingTenant = $state(false);
|
||||
|
||||
// URL completa del avatar
|
||||
let avatarUrl = $derived(getBackendAssetUrl(user.avatar) || '/avatars/default.jpg');
|
||||
|
||||
@@ -59,6 +71,26 @@
|
||||
await logout();
|
||||
}
|
||||
|
||||
async function switchTenant(slug: string) {
|
||||
if (slug === currentTenantSlug || switchingTenant) return;
|
||||
switchingTenant = true;
|
||||
try {
|
||||
const res = await fetch('/api-sveltekit/auth/switch-tenant', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tenant_slug: slug })
|
||||
});
|
||||
if (res.ok) {
|
||||
// Recargar el dashboard con los nuevos tokens
|
||||
window.location.href = '/dashboard';
|
||||
} else {
|
||||
console.error('Error al cambiar de organización');
|
||||
}
|
||||
} finally {
|
||||
switchingTenant = false;
|
||||
}
|
||||
}
|
||||
|
||||
function navigateToAccount() {
|
||||
goto('/dashboard/account');
|
||||
}
|
||||
@@ -169,6 +201,40 @@
|
||||
{/if}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
{#if tenants.length > 1}
|
||||
<DropdownMenu.Sub>
|
||||
<DropdownMenu.SubTrigger class="gap-2">
|
||||
{#if switchingTenant}
|
||||
<svg class="animate-spin size-4 shrink-0" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/>
|
||||
</svg>
|
||||
{:else}
|
||||
<ArrowLeftRightIcon class="size-4" />
|
||||
{/if}
|
||||
Cambiar organización
|
||||
</DropdownMenu.SubTrigger>
|
||||
<DropdownMenu.SubContent class="min-w-44">
|
||||
<DropdownMenu.Label class="text-xs text-muted-foreground pb-1">
|
||||
Mis organizaciones
|
||||
</DropdownMenu.Label>
|
||||
{#each tenants as tenant (tenant.id)}
|
||||
<DropdownMenu.Item
|
||||
class="gap-2 cursor-pointer"
|
||||
disabled={switchingTenant}
|
||||
onclick={() => switchTenant(tenant.slug)}
|
||||
>
|
||||
<BuildingIcon class="size-4 shrink-0 text-muted-foreground" />
|
||||
<span class="flex-1 truncate">{tenant.name}</span>
|
||||
{#if tenant.slug === currentTenantSlug}
|
||||
<CheckIcon class="size-3.5 shrink-0 text-blue-600" />
|
||||
{/if}
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
</DropdownMenu.SubContent>
|
||||
</DropdownMenu.Sub>
|
||||
{/if}
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item onclick={handleLogout}>
|
||||
<LogOutIcon />
|
||||
Log out
|
||||
|
||||
@@ -4,4 +4,4 @@
|
||||
let { ...restProps }: DropdownMenuPrimitive.SubProps = $props();
|
||||
</script>
|
||||
|
||||
<DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...restProps} />
|
||||
<DropdownMenuPrimitive.Sub {...restProps} />
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Endpoint server-side para cambiar de tenant sin exponer el refresh_token al cliente.
|
||||
*
|
||||
* Flujo:
|
||||
* 1. Cliente llama POST /api-sveltekit/auth/switch-tenant con { tenant_slug }
|
||||
* 2. Este servidor lee access_token y refresh_token de las cookies (HttpOnly).
|
||||
* 3. Llama al backend /v1/auth/switch-tenant con ambos tokens.
|
||||
* 4. Si es exitoso, actualiza las cookies con los nuevos tokens.
|
||||
* 5. Retorna ok al cliente para que recargue la página.
|
||||
*/
|
||||
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import { getServerApiUrl, getAuthTokens, setAuthTokens } from '$lib/server/api';
|
||||
|
||||
export const POST = async ({ request, cookies, fetch }: RequestEvent) => {
|
||||
const { tenant_slug } = await request.json();
|
||||
|
||||
if (!tenant_slug) {
|
||||
return json({ error: 'tenant_slug is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken } = getAuthTokens(cookies);
|
||||
|
||||
if (!accessToken || !refreshToken) {
|
||||
return json({ error: 'Not authenticated' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const baseUrl = getServerApiUrl();
|
||||
|
||||
const response = await fetch(`${baseUrl}v1/auth/switch-tenant`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${accessToken}`
|
||||
},
|
||||
body: JSON.stringify({ tenant_slug, refresh_token: refreshToken })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}));
|
||||
return json({ error: err.detail || 'Switch failed' }, { status: response.status });
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Actualizar cookies con los nuevos tokens del nuevo tenant
|
||||
setAuthTokens(cookies, data.access_token, data.refresh_token);
|
||||
// Limpiar la compañía activa para que el dashboard recargue con el nuevo tenant
|
||||
cookies.delete('active_company_id', { path: '/' });
|
||||
|
||||
return json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error('[switch-tenant] Error:', error);
|
||||
return json({ error: 'Internal server error' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -4,7 +4,8 @@ import {
|
||||
validateAuth,
|
||||
getUserCompanies,
|
||||
getAuthTokens,
|
||||
clearAuthTokens
|
||||
clearAuthTokens,
|
||||
authenticatedFetch
|
||||
} from '$lib/server/api';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
|
||||
@@ -27,11 +28,29 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
|
||||
// Cargar las compañías del usuario en el servidor (SSR)
|
||||
const companies = await getUserCompanies(cookies, fetch);
|
||||
|
||||
// Cargar los tenants del usuario para el selector de organización
|
||||
let userTenants: { id: number; name: string; slug: string; is_active: boolean }[] = [];
|
||||
try {
|
||||
const tenantsRes = await authenticatedFetch(
|
||||
`v1/core/user-tenants/${userData.sub}`,
|
||||
{},
|
||||
cookies,
|
||||
fetch
|
||||
);
|
||||
if (tenantsRes.ok) {
|
||||
const tenantsData = await tenantsRes.json();
|
||||
userTenants = tenantsData.tenants ?? [];
|
||||
}
|
||||
} catch {
|
||||
// No bloquear el dashboard si falla la carga de tenants
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
user: { ...userData, token: accessToken },
|
||||
companies, // Pasar las compañías al cliente
|
||||
error: undefined // Agregar error opcional para compatibilidad con error-handler
|
||||
companies,
|
||||
userTenants,
|
||||
error: undefined
|
||||
};
|
||||
} catch (error) {
|
||||
// Si es un redirect, re-lanzarlo sin tocar las cookies
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
|
||||
// Hacer disponible el usuario en el contexto para los componentes hijos
|
||||
setContext('user', data.user);
|
||||
setContext('userTenants', data.userTenants ?? []);
|
||||
|
||||
// ── Manejar expiración de sesión ────────────────────────────────────────
|
||||
function handleSessionExpired(e: Event) {
|
||||
|
||||
@@ -5,6 +5,7 @@ export const load: LayoutLoad = async ({ data }) => {
|
||||
return {
|
||||
user: data.user,
|
||||
companies: data.companies,
|
||||
authenticated: data.authenticated
|
||||
authenticated: data.authenticated,
|
||||
userTenants: data.userTenants ?? []
|
||||
};
|
||||
};
|
||||
|
||||
@@ -26,7 +26,7 @@ export const actions = {
|
||||
const tenant_slug = data.get('tenant_slug')?.toString();
|
||||
|
||||
if (!username || !password || !tenant_slug) {
|
||||
return fail(400, { error: 'Todos los campos son requeridos' });
|
||||
return fail(400, { error: 'Credenciales incorrectas' });
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import LoginForm from "$lib/components/login-form.svelte";
|
||||
</script>
|
||||
|
||||
<div class="bg-muted flex min-h-svh flex-col items-center justify-center p-6 md:p-10">
|
||||
<div class="bg-gradient-to-br from-slate-100 via-blue-50 to-slate-200 dark:from-slate-950 dark:via-blue-950/30 dark:to-slate-900 flex min-h-svh flex-col items-center justify-center p-6 md:p-10">
|
||||
<div class="w-full max-w-sm md:max-w-3xl">
|
||||
<LoginForm />
|
||||
</div>
|
||||
|
||||
BIN
frontend/static/login-bg.jpg
Normal file
BIN
frontend/static/login-bg.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
Reference in New Issue
Block a user