feat: enhance user avatar handling and synchronization with Workspace

- Updated sidebar component to merge user data from Keycloak and Workspace, including avatar URLs.
- Refactored nav-user component to utilize new avatar resolution logic and display user information more effectively.
- Introduced a new utility function to resolve user avatar URLs, prioritizing Workspace avatars.
- Implemented backend changes to support synchronization of user profile data, including avatar URLs from Workspace.
- Added database migration to include new fields for Workspace profile synchronization in user_tenants.
- Created a new client for fetching user profiles from Workspace.
- Updated dashboard components to reflect changes in user data structure and avatar handling.
- Removed avatar upload functionality from the profile update process, relying on Workspace for avatar management.
- Added tests for avatar resolution logic to ensure correct prioritization of avatar sources.
This commit is contained in:
2026-05-08 17:03:29 -05:00
parent 9fe307bd74
commit 1dbc75107f
21 changed files with 1170 additions and 150 deletions

View File

@@ -0,0 +1,56 @@
"""add workspace profile fields to user_tenants
Revision ID: c3d4e5f6a7b
Revises: b2c3d4e5f6a7
Create Date: 2026-05-08 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "c3d4e5f6a7b"
down_revision: Union[str, None] = "b2c3d4e5f6a7"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"user_tenants",
sa.Column(
"workspace_user_id",
sa.String(length=255),
nullable=True,
comment="User ID (sub) proveniente de Workspace",
),
schema="core",
)
op.add_column(
"user_tenants",
sa.Column(
"workspace_avatar_url",
sa.String(length=500),
nullable=True,
comment="Avatar URL sincronizado desde Workspace",
),
schema="core",
)
op.add_column(
"user_tenants",
sa.Column(
"workspace_profile_synced_at",
sa.DateTime(timezone=True),
nullable=True,
comment="Última sincronización de perfil con Workspace",
),
schema="core",
)
def downgrade() -> None:
op.drop_column("user_tenants", "workspace_profile_synced_at", schema="core")
op.drop_column("user_tenants", "workspace_avatar_url", schema="core")
op.drop_column("user_tenants", "workspace_user_id", schema="core")

View File

@@ -63,6 +63,7 @@ class UserInfoResponseDTO(BaseModel):
preferred_username: Optional[str] = None
tenant_id: Optional[int] = None
tenant_slug: Optional[str] = None
avatar_url: Optional[str] = None
roles: list[str] = []
permissions: list[str] = []

View File

@@ -1,6 +1,7 @@
import logging
import httpx
from typing import Any, Dict
from typing import Any, Dict, Optional
from jose import JWTError, jwt
from core.config import settings
from fastapi import HTTPException
@@ -23,6 +24,96 @@ class AuthService:
def __init__(self, db: Session):
self.db = db
@staticmethod
def _clean_text(value: Any) -> Optional[str]:
if isinstance(value, str):
cleaned = value.strip()
if cleaned:
return cleaned
return None
def _pick_text(self, *candidates: Any) -> Optional[str]:
for candidate in candidates:
value = self._clean_text(candidate)
if value:
return value
return None
def _decode_kc_user_from_token(self, access_token: str) -> Dict[str, Any]:
try:
claims = jwt.get_unverified_claims(access_token)
return claims if isinstance(claims, dict) else {}
except JWTError:
return {}
except Exception:
return {}
async def _get_kc_admin_user(self, keycloak_user_id: Optional[str]) -> Optional[Dict[str, Any]]:
"""
Fallback de datos de usuario consultando el Hub admin API.
Es opcional y no debe romper /me si falla.
"""
if not keycloak_user_id:
return None
if not settings.HUB_ADMIN_EMAIL or not settings.HUB_ADMIN_PASSWORD:
return None
try:
async with httpx.AsyncClient(timeout=10.0) as client:
login_resp = await client.post(
f"{settings.HUB_URL}api/v1/auth/login",
json={
"username": settings.HUB_ADMIN_EMAIL,
"password": settings.HUB_ADMIN_PASSWORD,
},
)
if login_resp.status_code != 200:
return None
admin_token = login_resp.json().get("access_token")
if not admin_token:
return None
user_resp = await client.get(
f"{settings.HUB_URL}api/v1/hub/admins/{keycloak_user_id}",
headers={"Authorization": f"Bearer {admin_token}"},
)
if user_resp.status_code == 200:
payload = user_resp.json()
return payload if isinstance(payload, dict) else None
except Exception as exc:
logger.debug("kc_admin_user_lookup_failed: %s", exc)
return None
def _extract_avatar_url(self, *sources: Any) -> Optional[str]:
for source in sources:
if not isinstance(source, dict):
continue
direct = self._pick_text(
source.get("avatar_url"),
source.get("avatarUrl"),
source.get("picture"),
source.get("photo"),
)
if direct:
return direct
attrs = source.get("attributes")
if isinstance(attrs, dict):
attr_candidate = attrs.get("avatar_url")
if isinstance(attr_candidate, list) and attr_candidate:
value = self._clean_text(attr_candidate[0])
if value:
return value
if isinstance(attr_candidate, str):
value = self._clean_text(attr_candidate)
if value:
return value
return None
async def login(
self,
login_data: LoginRequestDTO,
@@ -55,6 +146,38 @@ class AuthService:
except Exception as exc:
logger.warning("Lazy-link invite check failed (non-blocking): %s", exc)
# Sync de perfil/avatar desde Workspace usando el mismo bearer.
# No bloquea login si Workspace no responde.
access_token = data.get("access_token")
if access_token:
from core.workspace_profile_sync import sync_workspace_profile_for_user
from core.workspace_profile_client import WorkspaceProfileClient
workspace_profile = None
try:
workspace_profile = await WorkspaceProfileClient().get_me(access_token)
except Exception as exc:
logger.warning(
"workspace_profile_sync_failed",
extra={
"event": "workspace_profile_sync_failed",
"phase": "login",
"error": str(exc),
},
)
workspace_profile = None
await sync_workspace_profile_for_user(
self.db,
access_token=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,
)
# AUDIT LOG: Login Success
try:
from api.v1.modules.a76.audit_log.services.service import AuditService
@@ -102,7 +225,37 @@ class AuthService:
)
if response.status_code == 200:
return TokenResponseDTO(**response.json())
data = response.json()
from core.workspace_profile_sync import sync_workspace_profile_for_user
from core.workspace_profile_client import WorkspaceProfileClient
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
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,
)
return TokenResponseDTO(**data)
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
@@ -115,9 +268,78 @@ class AuthService:
Obtiene información del usuario desde el Hub
"""
from core.security import verify_token
from core.workspace_profile_sync import sync_workspace_profile_for_user
# Aprovechamos la verificación (y cache) de security.py
user_info = await verify_token(access_token)
return UserInfoResponseDTO(**user_info)
kc_user = self._decode_kc_user_from_token(access_token)
keycloak_user_id = self._pick_text(user_info.get("sub"), kc_user.get("sub"))
needs_admin_fallback = any(
not self._clean_text(user_info.get(field))
for field in ("email", "preferred_username")
) or self._extract_avatar_url(user_info) is None
kc_admin_user = None
if needs_admin_fallback:
kc_admin_user = await self._get_kc_admin_user(keycloak_user_id)
first_name = self._pick_text(
user_info.get("first_name"),
user_info.get("given_name"),
kc_user.get("given_name"),
kc_user.get("first_name"),
(kc_admin_user or {}).get("firstName"),
(kc_admin_user or {}).get("first_name"),
)
last_name = self._pick_text(
user_info.get("last_name"),
user_info.get("family_name"),
kc_user.get("family_name"),
kc_user.get("last_name"),
(kc_admin_user or {}).get("lastName"),
(kc_admin_user or {}).get("last_name"),
)
full_name = self._pick_text(
f"{first_name} {last_name}" if first_name and last_name else None,
first_name,
last_name,
)
enriched_user_info = dict(user_info)
enriched_user_info["sub"] = keycloak_user_id or user_info.get("sub")
enriched_user_info["email"] = self._pick_text(
user_info.get("email"),
(kc_admin_user or {}).get("email"),
kc_user.get("email"),
)
enriched_user_info["preferred_username"] = self._pick_text(
user_info.get("preferred_username"),
user_info.get("username"),
kc_user.get("preferred_username"),
kc_user.get("username"),
(kc_admin_user or {}).get("username"),
)
enriched_user_info["avatar_url"] = self._extract_avatar_url(
user_info,
kc_user,
kc_admin_user or {},
)
enriched_user_info["name"] = self._pick_text(
user_info.get("name"),
full_name,
kc_user.get("name"),
enriched_user_info.get("preferred_username"),
)
await sync_workspace_profile_for_user(
self.db,
access_token=access_token,
keycloak_user_id=enriched_user_info.get("sub"),
tenant_id=enriched_user_info.get("tenant_id"),
workspace_profile=enriched_user_info,
)
return UserInfoResponseDTO(**enriched_user_info)
async def logout(self, logout_data: LogoutRequestDTO) -> dict:
"""

View File

@@ -2,6 +2,7 @@
Modelo de relación entre usuarios (Keycloak) y tenants
"""
from datetime import datetime
from typing import TYPE_CHECKING, Optional
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
@@ -13,6 +14,7 @@ from sqlalchemy import (
String,
Text,
UniqueConstraint,
DateTime,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -56,6 +58,17 @@ class UserTenant(Base, TenantScopedMixin, TimestampMixin):
avatar_url: Mapped[Optional[str]] = mapped_column(
String(500), nullable=True, comment="URL de la imagen de perfil"
)
workspace_user_id: Mapped[Optional[str]] = mapped_column(
String(255), nullable=True, comment="User ID (sub) proveniente de Workspace"
)
workspace_avatar_url: Mapped[Optional[str]] = mapped_column(
String(500), nullable=True, comment="Avatar URL sincronizado desde Workspace"
)
workspace_profile_synced_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),
nullable=True,
comment="Última sincronización de perfil con Workspace",
)
# Caché local de nombre/apellido (fuente de verdad = Keycloak vía Hub;
# se sincroniza al editar perfil desde Anexo76)
first_name: Mapped[Optional[str]] = mapped_column(

View File

@@ -154,6 +154,7 @@ def get_user_avatar_image(
@router.get("/me/profile", response_model=UserResponseDTO)
async def get_my_profile(
request: Request,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
@@ -180,7 +181,17 @@ async def get_my_profile(
)
service = UserService(db, user_tenant.tenant_id, user_tenant.company_id)
return await service.get_current_user_profile(keycloak_user_id, current_user=current_user)
auth_header = request.headers.get("Authorization") or ""
access_token = (
auth_header[7:].strip()
if auth_header.lower().startswith("bearer ")
else auth_header.strip() or None
)
return await service.get_current_user_profile(
keycloak_user_id,
current_user=current_user,
access_token=access_token,
)
@router.put("/me/profile", response_model=UserResponseDTO)
@@ -214,6 +225,14 @@ async def update_my_profile(
status_code=400, detail="User does not belong to any tenant"
)
# Para sesiones autenticadas vía Workspace/Hub, la foto de perfil viene del Hub
# y no debe mutarse localmente en Anexo76.
if current_user.get("sub"):
raise HTTPException(
status_code=409,
detail="Avatar is managed by Workspace for this user",
)
auth_header = request.headers.get("Authorization") or ""
access_token = (
auth_header[7:].strip()

View File

@@ -2,6 +2,7 @@ import logging
import httpx
from datetime import datetime
from typing import Any, Dict, List, Optional
from urllib.parse import urlparse
from fastapi import HTTPException
from sqlalchemy import and_, func
@@ -15,6 +16,30 @@ from ..user_tenant.models import UserTenant
logger = logging.getLogger(__name__)
def _is_valid_http_url(url: Optional[str]) -> bool:
if not url or not isinstance(url, str):
return False
parsed = urlparse(url.strip())
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
def _legacy_avatar_public_url(user_tenant: Optional[Any]) -> Optional[str]:
if not user_tenant or not user_tenant.avatar_url:
return None
avatar_out = str(user_tenant.avatar_url)
if avatar_out.startswith("http://") or avatar_out.startswith("https://"):
return avatar_out if _is_valid_http_url(avatar_out) else None
from core.s3_keys import public_user_avatar_api_path
# Entregamos siempre el endpoint público del backend para assets locales/S3.
return public_user_avatar_api_path(
user_tenant.tenant_id,
user_tenant.keycloak_user_id,
)
def _normalize_user(
user_data: Dict[str, Any],
role: Optional[str] = None,
@@ -43,18 +68,20 @@ def _normalize_user(
# Agregar campos de perfil si user_tenant está disponible
if user_tenant:
avatar_out = user_tenant.avatar_url
if avatar_out:
from core.s3_keys import public_user_avatar_api_path
workspace_avatar = (
user_tenant.workspace_avatar_url
if _is_valid_http_url(user_tenant.workspace_avatar_url)
else None
)
legacy_avatar = _legacy_avatar_public_url(user_tenant)
avatar_out = workspace_avatar or legacy_avatar
# Siempre devolver la URL pública del endpoint de servicio de imágenes,
# independientemente de si es clave S3 (tenants/...) o ruta local (/uploads/...).
avatar_out = public_user_avatar_api_path(
user_tenant.tenant_id, user_tenant.keycloak_user_id
)
normalized.update(
{
"avatar_url": avatar_out,
"workspace_avatar_url": workspace_avatar,
"legacy_avatar_url": legacy_avatar,
"workspace_user_id": user_tenant.workspace_user_id,
"phone": user_tenant.phone,
"bio": user_tenant.bio,
"preferences": user_tenant.preferences or {},
@@ -629,11 +656,27 @@ class UserService:
"usage_percentage": round(usage_percentage, 2),
}
async def get_current_user_profile(self, keycloak_user_id: str, current_user: Dict[str, Any] = None) -> Dict[str, Any]:
async def get_current_user_profile(
self,
keycloak_user_id: str,
current_user: Dict[str, Any] = None,
access_token: Optional[str] = None,
) -> Dict[str, Any]:
"""Obtiene el perfil completo del usuario actual"""
# Use the already-verified JWT claims dict — do NOT call verify_token(uuid)
user_info = current_user or {"id": keycloak_user_id}
# Perfil "me": sincronización con cache corto (5 min).
if access_token:
from core.workspace_profile_sync import sync_workspace_profile_for_user
await sync_workspace_profile_for_user(
self.db,
access_token=access_token,
keycloak_user_id=keycloak_user_id,
tenant_id=self.tenant_id,
)
user_tenant = self.db.query(UserTenant).filter(
and_(UserTenant.keycloak_user_id == keycloak_user_id, UserTenant.is_active == True)
).first()

View File

@@ -41,6 +41,9 @@ class Settings(BaseSettings):
# Hub de Aduanasoft — requerido siempre (SaaS y self-hosted)
HUB_URL: str = "http://localhost:8001"
# Base API del Hub/Workspace para endpoint /v1/auth/me (fuente de verdad de perfil)
HUB_API_BASE_URL: str = ""
HUB_PROFILE_SYNC_TIMEOUT_MS: int = 3000
# Cuenta de servicio Hub — usada para operaciones admin (ej. sync de nombre a Keycloak)
HUB_ADMIN_EMAIL: str = ""
HUB_ADMIN_PASSWORD: str = ""
@@ -48,7 +51,7 @@ class Settings(BaseSettings):
# URL pública del frontend — usada en links de email (invitaciones, etc.)
APP_PUBLIC_URL: str = "http://localhost:3000"
@field_validator("CENTRAL_SERVER_URL", "SPOKE_URLS", "HUB_URL", mode="before")
@field_validator("CENTRAL_SERVER_URL", "SPOKE_URLS", "HUB_URL", "HUB_API_BASE_URL", mode="before")
@classmethod
def strip_quotes(cls, v: str) -> str:
if v and isinstance(v, str):
@@ -116,6 +119,17 @@ class Settings(BaseSettings):
"""
return self.CSV_IMPORT_STORAGE == "minio" or self.S3_FILE_STORAGE
@property
def hub_api_base_url(self) -> str:
"""
Base URL para endpoints /v1 del Workspace/Hub.
Si HUB_API_BASE_URL no está definido, deriva de HUB_URL + /api.
"""
custom = (self.HUB_API_BASE_URL or "").strip().rstrip("/")
if custom:
return custom
return f"{self.HUB_URL.rstrip('/')}/api"
# Instancia global de configuración
settings = Settings()

View File

@@ -344,6 +344,21 @@ async def get_current_user(
)
user_info["tenant_id"] = effective_tenant_id
# Rehidratación de sesión: sincronización no bloqueante de avatar/perfil
# con cache corto para evitar llamadas excesivas al Hub.
try:
from core.workspace_profile_sync import sync_workspace_profile_for_user
await sync_workspace_profile_for_user(
db,
access_token=token,
keycloak_user_id=user_info.get("sub"),
tenant_id=user_info.get("tenant_id"),
workspace_profile=user_info,
)
except Exception as exc:
logger.warning("workspace_profile_sync_failed_on_get_current_user: %s", exc)
return user_info

View File

@@ -0,0 +1,80 @@
import asyncio
import logging
from typing import Any, Optional
import httpx
from core.config import settings
logger = logging.getLogger(__name__)
class WorkspaceProfileClient:
"""Cliente para consultar perfil del usuario en Workspace Hub (/v1/auth/me)."""
def __init__(
self,
base_url: Optional[str] = None,
timeout_ms: Optional[int] = None,
retries: int = 2,
transport: Optional[httpx.BaseTransport] = None,
):
self.base_url = (base_url or settings.hub_api_base_url).rstrip("/")
self.timeout_s = max(0.1, float(timeout_ms or settings.HUB_PROFILE_SYNC_TIMEOUT_MS) / 1000.0)
self.retries = max(0, int(retries))
self.transport = transport
async def get_me(self, access_token: str) -> dict[str, Any]:
if not access_token:
raise ValueError("access_token is required")
headers = {"Authorization": f"Bearer {access_token}"}
url = f"{self.base_url}/v1/auth/me"
last_error: Optional[Exception] = None
for attempt in range(self.retries + 1):
try:
async with httpx.AsyncClient(
timeout=self.timeout_s,
transport=self.transport,
) as client:
response = await client.get(url, headers=headers)
if response.status_code == 200:
payload = response.json()
if not isinstance(payload, dict):
raise ValueError("Invalid workspace profile payload")
return payload
if response.status_code in (401, 403, 404):
# Errores de autenticación/autorización o endpoint no disponible:
# no vale la pena reintentar.
raise httpx.HTTPStatusError(
f"Workspace profile request failed with status {response.status_code}",
request=response.request,
response=response,
)
# Reintentar solo para errores transitorios 5xx.
if response.status_code >= 500 and attempt < self.retries:
await asyncio.sleep(0.15 * (attempt + 1))
continue
raise httpx.HTTPStatusError(
f"Workspace profile request failed with status {response.status_code}",
request=response.request,
response=response,
)
except (httpx.TimeoutException, httpx.NetworkError) as exc:
last_error = exc
if attempt >= self.retries:
break
await asyncio.sleep(0.15 * (attempt + 1))
except Exception as exc:
last_error = exc
break
if last_error:
raise last_error
raise RuntimeError("Workspace profile request failed")

View File

@@ -0,0 +1,114 @@
import logging
from datetime import datetime, timedelta, timezone
from typing import Any, Optional
from urllib.parse import urlparse
from sqlalchemy.orm import Session
from api.v1.modules.core.user_tenant.models import UserTenant
from core.workspace_profile_client import WorkspaceProfileClient
logger = logging.getLogger(__name__)
SYNC_TTL_SECONDS = 300
def _is_valid_http_url(url: Optional[str]) -> bool:
if not url or not isinstance(url, str):
return False
parsed = urlparse(url.strip())
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
def _is_fresh(ts: Optional[datetime], ttl_seconds: int = SYNC_TTL_SECONDS) -> bool:
if not ts:
return False
now = datetime.now(timezone.utc)
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
return ts >= (now - timedelta(seconds=ttl_seconds))
async def sync_workspace_profile_for_user(
db: Session,
*,
access_token: Optional[str],
keycloak_user_id: Optional[str],
tenant_id: Optional[int] = None,
company_id: Optional[int] = None,
workspace_profile: Optional[dict[str, Any]] = None,
force: bool = False,
) -> None:
"""
Sincroniza sub/avatar_url desde Workspace hacia core.user_tenants.
Nunca lanza excepción para no bloquear login ni requests autenticados.
"""
if not access_token or not keycloak_user_id:
return
try:
query = db.query(UserTenant).filter(
UserTenant.keycloak_user_id == keycloak_user_id,
UserTenant.is_active == True,
)
if tenant_id is not None:
query = query.filter(UserTenant.tenant_id == int(tenant_id))
if company_id is not None:
query = query.filter(UserTenant.company_id == int(company_id))
target = query.first()
if not target:
return
if not force and _is_fresh(target.workspace_profile_synced_at):
return
payload = workspace_profile
if payload is None:
client = WorkspaceProfileClient()
payload = await client.get_me(access_token)
workspace_sub = payload.get("sub")
if not workspace_sub:
logger.warning(
"workspace_profile_sync_warning",
extra={
"event": "workspace_profile_sync_warning",
"reason": "missing_sub",
"keycloak_user_id": keycloak_user_id,
"tenant_id": target.tenant_id,
},
)
return
avatar_url = payload.get("avatar_url")
sanitized_avatar = avatar_url.strip() if isinstance(avatar_url, str) else None
if sanitized_avatar and not _is_valid_http_url(sanitized_avatar):
logger.warning(
"workspace_profile_sync_warning",
extra={
"event": "workspace_profile_sync_warning",
"reason": "invalid_avatar_url",
"keycloak_user_id": keycloak_user_id,
"tenant_id": target.tenant_id,
},
)
sanitized_avatar = None
target.workspace_user_id = str(workspace_sub)
target.workspace_avatar_url = sanitized_avatar
target.workspace_profile_synced_at = datetime.now(timezone.utc)
db.add(target)
db.commit()
except Exception as exc:
db.rollback()
logger.warning(
"workspace_profile_sync_failed",
extra={
"event": "workspace_profile_sync_failed",
"error": str(exc),
"keycloak_user_id": keycloak_user_id,
"tenant_id": tenant_id,
"company_id": company_id,
},
)

View File

@@ -9,7 +9,7 @@
*/
import Keycloak from 'keycloak-js';
import { writable, derived } from 'svelte/store';
import { writable, derived, get } from 'svelte/store';
import { browser } from '$app/environment';
import {
clearAccessTokenOnDocument,
@@ -26,9 +26,17 @@ export interface User {
username: string;
email?: string;
name?: string;
firstName?: string | null;
lastName?: string | null;
displayName?: string | null;
avatarUrl?: string | null;
workspaceAvatarUrl?: string | null;
legacyAvatarUrl?: string | null;
tenantId?: number;
roles: string[];
permissions: string[];
// Cache management
profileSyncedAt?: number; // timestamp en ms para cache TTL
}
export interface AuthState {
@@ -49,6 +57,51 @@ const keycloakConfig = {
};
let keycloakInstance: Keycloak | null = null;
const AUTH_USER_SESSION_KEY = 'anexo76_auth_user_v1';
function pickAvatar(...candidates: Array<unknown>): string | null {
for (const candidate of candidates) {
if (typeof candidate === 'string' && candidate.trim().length > 0) {
return candidate.trim();
}
}
return null;
}
function pickText(...candidates: Array<unknown>): string | null {
for (const candidate of candidates) {
if (typeof candidate === 'string') {
const value = candidate.trim();
if (value.length > 0) {
return value;
}
}
}
return null;
}
function readUserFromSession(): User | null {
if (!browser) return null;
try {
const raw = sessionStorage.getItem(AUTH_USER_SESSION_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as User;
if (!parsed || typeof parsed !== 'object') return null;
if (!parsed.id || !parsed.username) return null;
return parsed;
} catch {
return null;
}
}
function persistUserInSession(user: User | null): void {
if (!browser) return;
if (!user) {
sessionStorage.removeItem(AUTH_USER_SESSION_KEY);
return;
}
sessionStorage.setItem(AUTH_USER_SESSION_KEY, JSON.stringify(user));
}
// ─────────────────────────────────────────────────────────
// Auth store (tokens solo en memoria)
@@ -68,7 +121,10 @@ const createAuthStore = () => {
update((s) => ({ ...s, isAuthenticated: authenticated })),
setLoading: (loading: boolean) =>
update((s) => ({ ...s, isLoading: loading })),
setUser: (user: User | null) => update((s) => ({ ...s, user })),
setUser: (user: User | null) => {
persistUserInSession(user);
update((s) => ({ ...s, user }));
},
setToken: (token: string | null) => update((s) => ({ ...s, token })),
/** ⚠️ Los tokens ya NO se guardan en localStorage; solo en memoria. */
setTokens: (accessToken: string, _refreshToken?: string) => {
@@ -77,12 +133,15 @@ const createAuthStore = () => {
// el cliente no lo almacena ni lo lee en ningún momento.
},
reset: () =>
set({
isAuthenticated: false,
isLoading: false,
user: null,
token: null
})
{
persistUserInSession(null);
set({
isAuthenticated: false,
isLoading: false,
user: null,
token: null
});
}
};
};
@@ -114,6 +173,11 @@ export const initAuth = async (): Promise<boolean> => {
try {
authStore.setLoading(true);
const sessionUser = readUserFromSession();
if (sessionUser) {
authStore.setUser(sessionUser);
}
// Restaurar token desde cookie no-HttpOnly (password login flow)
const cookieToken = getAccessTokenFromDocument();
if (cookieToken) {
@@ -166,6 +230,7 @@ let previousTenantId: number | undefined = undefined;
const updateAuthState = async () => {
if (!keycloakInstance?.authenticated) {
authStore.reset();
persistUserInSession(null);
return;
}
@@ -189,14 +254,43 @@ const updateAuthState = async () => {
currentPerms = currentState.user?.permissions || [];
} catch { }
const previousUser = get(authStore).user;
const firstName = pickText(profile.firstName, parsed?.given_name, previousUser?.firstName);
const lastName = pickText(profile.lastName, parsed?.family_name, previousUser?.lastName);
const fullNameFromParts = pickText(
firstName && lastName ? `${firstName} ${lastName}` : null,
firstName,
lastName
);
const username = pickText(
profile.username,
parsed?.preferred_username,
parsed?.username,
previousUser?.username
) ?? '';
const name = pickText(
profile.firstName || profile.lastName ? `${profile.firstName ?? ''} ${profile.lastName ?? ''}` : null,
fullNameFromParts,
parsed?.name,
previousUser?.name,
username
) ?? username;
const user: User = {
id: profile.id ?? '',
username: profile.username ?? '',
email: profile.email,
name: `${profile.firstName ?? ''} ${profile.lastName ?? ''}`.trim(),
id: pickText(profile.id, parsed?.sub, previousUser?.id) ?? '',
username,
email: pickText(profile.email, parsed?.email, previousUser?.email) ?? undefined,
name,
firstName,
lastName,
displayName: pickText(name, previousUser?.displayName, username),
avatarUrl: pickAvatar(previousUser?.avatarUrl),
workspaceAvatarUrl: pickAvatar(previousUser?.workspaceAvatarUrl),
legacyAvatarUrl: pickAvatar(previousUser?.legacyAvatarUrl),
tenantId,
roles,
permissions: parsed?.permissions?.length ? parsed.permissions : currentPerms
permissions: parsed?.permissions?.length ? parsed.permissions : currentPerms,
profileSyncedAt: previousUser?.profileSyncedAt
};
authStore.setAuthenticated(true);
@@ -338,15 +432,74 @@ const loadUserInfo = async (token: string) => {
const { api } = await import('./api');
const response = await api.auth.me();
if (response.data) {
const previousUser = get(authStore).user;
const d = response.data;
const workspaceAvatarUrl = pickAvatar(
d.workspaceAvatarUrl,
d.workspace_avatar_url,
d.avatar_url,
d.avatarUrl,
d.picture,
d.photo,
previousUser?.workspaceAvatarUrl
);
const legacyAvatarUrl = pickAvatar(
d.legacyAvatarUrl,
d.legacy_avatar_url,
d.avatar,
d.photo,
d.picture,
previousUser?.legacyAvatarUrl
);
const avatarUrl = pickAvatar(workspaceAvatarUrl, legacyAvatarUrl, previousUser?.avatarUrl);
// Merge no destructivo: nunca pisar datos válidos con campos vacíos
const firstName = pickText(d.first_name, d.firstName, previousUser?.firstName);
const lastName = pickText(d.last_name, d.lastName, previousUser?.lastName);
const username = pickText(
d.preferred_username,
d.username,
previousUser?.username
) ?? '';
const nameFromParts = pickText(
firstName && lastName ? `${firstName} ${lastName}` : null,
firstName,
lastName
);
const name = pickText(
d.name,
nameFromParts,
previousUser?.name,
username
) ?? username;
const displayName = pickText(
d.displayName,
d.display_name,
name,
username
) ?? username;
const email = pickText(d.email, previousUser?.email) ?? undefined;
const userId = pickText(d.sub, d.id, previousUser?.id) ?? '';
console.debug('[avatar][auth.loadUserInfo] /v1/auth/me avatar_url recibido:', workspaceAvatarUrl ?? '(null)');
console.debug('[avatar][auth.loadUserInfo] avatar final para authStore:', avatarUrl ?? '(null)');
console.debug('[profile][auth.loadUserInfo] first_name:', firstName ?? '(null)', 'last_name:', lastName ?? '(null)', 'avatar_url:', workspaceAvatarUrl ?? '(null)');
authStore.setUser({
id: d.sub ?? '',
username: d.preferred_username ?? d.username ?? '',
email: d.email,
name: d.name,
tenantId: d.tenant_id,
roles: d.roles ?? [],
permissions: d.permissions ?? []
id: userId,
username,
email,
name,
firstName,
lastName,
displayName,
avatarUrl,
workspaceAvatarUrl,
legacyAvatarUrl,
tenantId: d.tenant_id ?? previousUser?.tenantId,
roles: d.roles ?? previousUser?.roles ?? [],
permissions: d.permissions ?? previousUser?.permissions ?? [],
profileSyncedAt: Date.now()
});
}
} catch (err) {
@@ -354,6 +507,85 @@ const loadUserInfo = async (token: string) => {
}
};
/**
* Sincroniza el perfil del usuario desde /v1/auth/me
* - Valida cache TTL (5 minutos) antes de hacer fetch
* - Extrae first_name, last_name, avatar_url
* - Retorna objeto con campos de perfil para UI o update de store
*
* Uso:
* ```
* const profile = await syncUserProfile(accessToken);
* if (profile) {
* // profile.firstName, profile.lastName, profile.displayName, profile.avatarUrl
* }
* ```
*/
export const syncUserProfile = async (accessToken?: string): Promise<{
firstName: string | null;
lastName: string | null;
displayName: string | null;
avatarUrl: string | null;
workspaceAvatarUrl: string | null;
legacyAvatarUrl: string | null;
rawProfileSyncedAt: number;
} | null> => {
if (!browser) return null;
try {
// Validar cache TTL: 5 minutos (300000ms)
const PROFILE_CACHE_TTL = 5 * 60 * 1000;
const { get } = await import('svelte/store');
const currentState = get(authStore);
const now = Date.now();
if (
currentState.user?.profileSyncedAt &&
(now - currentState.user.profileSyncedAt) < PROFILE_CACHE_TTL
) {
console.debug('[profile][sync] Cache válido, no re-fetching /v1/auth/me');
return {
firstName: currentState.user.firstName ?? null,
lastName: currentState.user.lastName ?? null,
displayName: currentState.user.displayName ?? null,
avatarUrl: currentState.user.avatarUrl ?? null,
workspaceAvatarUrl: currentState.user.workspaceAvatarUrl ?? null,
legacyAvatarUrl: currentState.user.legacyAvatarUrl ?? null,
rawProfileSyncedAt: currentState.user.profileSyncedAt
};
}
const token = accessToken || getToken();
if (!token) {
console.warn('[profile][sync] No token disponible para sincronizar');
return null;
}
// Llamar a loadUserInfo que hace fetch a /v1/auth/me
await loadUserInfo(token);
// Retornar los nuevos valores desde el store
const updatedState = get(authStore);
if (updatedState.user) {
console.debug('[profile][sync] Perfil sincronizado exitosamente');
return {
firstName: updatedState.user.firstName ?? null,
lastName: updatedState.user.lastName ?? null,
displayName: updatedState.user.displayName ?? null,
avatarUrl: updatedState.user.avatarUrl ?? null,
workspaceAvatarUrl: updatedState.user.workspaceAvatarUrl ?? null,
legacyAvatarUrl: updatedState.user.legacyAvatarUrl ?? null,
rawProfileSyncedAt: updatedState.user.profileSyncedAt ?? 0
};
}
return null;
} catch (err) {
console.error('[profile][sync] Error sincronizando perfil:', err);
return null;
}
};
// ─────────────────────────────────────────────────────────
// Logout
// ─────────────────────────────────────────────────────────
@@ -376,6 +608,7 @@ export const logout = async () => {
// Limpiar estado en memoria
authStore.reset();
persistUserInSession(null);
clearAccessTokenOnDocument();
// La cookie HttpOnly del refresh_token la limpia el servidor

View File

@@ -3,6 +3,7 @@
import { page } from "$app/state";
import { useSidebar } from "$lib/components/ui/sidebar/context.svelte.js";
import { getSidebarData } from "$lib/components/sidebar/modules";
import { currentUser } from "$lib/auth";
import NavMain from "./nav-main.svelte";
import NavProjects from "./nav-projects.svelte";
import NavUser from "./nav-user.svelte";
@@ -21,19 +22,51 @@
// Obtener datos del sidebar con traducciones
const sidebarData = getSidebarData();
const mergedUser = $derived((page.data.user as any) || $currentUser || null);
// Combinar los datos estáticos del sidebar con los datos del usuario de Keycloak
const data = $derived({
...sidebarData,
user: page.data.user
user: mergedUser
? {
name: _displayName(page.data.user),
email: page.data.user.email || "",
avatar: page.data.user.avatar_url || "/avatars/default.jpg",
name: _displayName(mergedUser),
email: mergedUser.email || "",
username: mergedUser.preferred_username || mergedUser.username || "",
firstName: mergedUser.first_name || mergedUser.firstName || mergedUser.given_name || null,
lastName: mergedUser.last_name || mergedUser.lastName || mergedUser.family_name || null,
displayName:
mergedUser.displayName ||
_displayName(mergedUser) ||
mergedUser.preferred_username ||
mergedUser.username ||
"",
avatarUrl:
mergedUser.workspaceAvatarUrl ||
mergedUser.workspace_avatar_url ||
mergedUser.avatarUrl ||
mergedUser.avatar_url ||
mergedUser.legacyAvatarUrl ||
mergedUser.legacy_avatar_url ||
null,
workspaceAvatarUrl:
mergedUser.workspaceAvatarUrl ||
mergedUser.workspace_avatar_url ||
null,
legacyAvatarUrl:
mergedUser.legacyAvatarUrl ||
mergedUser.legacy_avatar_url ||
mergedUser.avatar_url ||
null,
}
: sidebarData.user,
});
$effect(() => {
if (mergedUser) {
console.debug('[avatar][sidebar] avatar final en page.data.user:', data.user?.avatarUrl ?? '(null)');
}
});
function _displayName(u: any): string {
const first = u.first_name || u.given_name || "";
const last = u.last_name || u.family_name || "";

View File

@@ -19,11 +19,21 @@
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { getBackendAssetUrl } from '$lib/utils';
import { resolveUserAvatarUrl } from '$lib/utils';
import AppVersion from '$lib/components/app-version.svelte';
let { user, tenants = [] }: {
user: { name: string; email: string; avatar: string };
user: {
name: string;
email: string;
username?: string;
firstName?: string | null;
lastName?: string | null;
displayName?: string | null;
avatarUrl?: string | null;
workspaceAvatarUrl?: string | null;
legacyAvatarUrl?: string | null;
};
tenants: { id: number; name: string; slug: string }[];
} = $props();
const sidebar = useSidebar();
@@ -31,14 +41,40 @@
// 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
// State for tenant switching
let switchingTenant = $state(false);
// URL completa del avatar
let avatarUrl = $derived(getBackendAssetUrl(user.avatar) || '/avatars/default.jpg');
let avatarLoadFailed = $state(false);
// Iniciales del usuario (2 primeras letras)
let initials = $derived(user.name.slice(0, 2).toUpperCase());
// URL de avatar con prioridad: Workspace -> legado
let avatarUrl = $derived(
avatarLoadFailed
? ''
: resolveUserAvatarUrl(
user.workspaceAvatarUrl ?? null,
user.legacyAvatarUrl ?? user.avatarUrl ?? null
)
);
$effect(() => {
console.debug('[avatar][nav-user] URL final usada por Avatar.Image:', avatarUrl || '(fallback)');
});
// Nombre a mostrar: displayName > firstName + lastName > name > username
let displayName = $derived(
user.displayName ||
(user.firstName && user.lastName ? `${user.firstName} ${user.lastName}`.trim() : null) ||
user.name ||
user.username ||
'User'
);
// Iniciales del usuario (2 primeras letras de displayName)
let initials = $derived(displayName.slice(0, 2).toUpperCase());
function handleAvatarError() {
avatarLoadFailed = true;
}
// Estado reactivo del idioma actual
let currentLocale = $derived(page.data.locale || 'en');
@@ -142,11 +178,11 @@
{...props}
>
<Avatar.Root class="size-8 rounded-lg">
<Avatar.Image src={avatarUrl} alt={user.name} />
<Avatar.Image src={avatarUrl} alt={displayName} onerror={handleAvatarError} />
<Avatar.Fallback class="rounded-lg">{initials}</Avatar.Fallback>
</Avatar.Root>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{user.name}</span>
<span class="truncate font-medium">{displayName}</span>
<span class="truncate text-xs">{user.email}</span>
</div>
<ChevronsUpDownIcon class="ml-auto size-4" />
@@ -162,11 +198,11 @@
<DropdownMenu.Label class="p-0 font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar.Root class="size-8 rounded-lg">
<Avatar.Image src={avatarUrl} alt={user.name} />
<Avatar.Fallback class="rounded-lg">{initials}</Avatar.Fallback>
</Avatar.Root>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{user.name}</span>
<Avatar.Image src={avatarUrl} alt={displayName} onerror={handleAvatarError} />
<Avatar.Fallback class="rounded-lg">{initials}</Avatar.Fallback>
</Avatar.Root>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{displayName}</span>
<span class="truncate text-xs">{user.email}</span>
</div>
</div>

View File

@@ -255,6 +255,15 @@ export async function validateAuth(
fetch: typeof globalThis.fetch,
redirectOnFail?: string
): Promise<any> {
const pickAvatar = (...candidates: Array<unknown>): string | null => {
for (const candidate of candidates) {
if (typeof candidate === 'string' && candidate.trim().length > 0) {
return candidate.trim();
}
}
return null;
};
try {
const response = await authenticatedFetch(
'v1/auth/me',
@@ -273,6 +282,13 @@ export async function validateAuth(
}
const keycloakData = await response.json();
const workspaceAvatarFromAuthMe = pickAvatar(
keycloakData.avatar_url,
keycloakData.avatarUrl,
keycloakData.picture,
keycloakData.photo
);
console.debug('[avatar][validateAuth] /v1/auth/me avatar_url recibido:', workspaceAvatarFromAuthMe ?? '(null)');
// Obtener perfil adicional del usuario (avatar, bio, etc.)
try {
@@ -285,6 +301,23 @@ export async function validateAuth(
if (profileResponse.ok) {
const profileData = await profileResponse.json();
const workspaceAvatarFromProfile = pickAvatar(
profileData.workspaceAvatarUrl,
profileData.workspace_avatar_url
);
const legacyAvatar = pickAvatar(
profileData.legacyAvatarUrl,
profileData.legacy_avatar_url,
profileData.avatarUrl,
profileData.avatar_url,
profileData.avatar,
profileData.photo,
profileData.picture
);
const finalWorkspaceAvatar = pickAvatar(workspaceAvatarFromAuthMe, workspaceAvatarFromProfile);
const finalAvatar = pickAvatar(finalWorkspaceAvatar, legacyAvatar);
console.debug('[avatar][validateAuth] avatar final resuelto:', finalAvatar ?? '(null)');
// Combinar datos de Keycloak con datos del perfil.
// Prioridad para nombre: caché local del perfil > JWT claims.
return {
@@ -294,7 +327,12 @@ export async function validateAuth(
email: profileData.email || keycloakData.email || '',
first_name: profileData.first_name || keycloakData.first_name || keycloakData.given_name || '',
last_name: profileData.last_name || keycloakData.last_name || keycloakData.family_name || '',
avatar_url: profileData.avatar_url || null,
avatar_url: finalAvatar,
avatarUrl: finalAvatar,
workspace_avatar_url: finalWorkspaceAvatar,
workspaceAvatarUrl: finalWorkspaceAvatar,
legacy_avatar_url: legacyAvatar,
legacyAvatarUrl: legacyAvatar,
phone: profileData.phone || null,
bio: profileData.bio || null,
preferences: profileData.preferences || {}
@@ -306,12 +344,18 @@ export async function validateAuth(
// Fallback: map raw JWT claim names to the expected field names
const nameParts = (keycloakData.name || '').split(' ');
const finalAvatar = workspaceAvatarFromAuthMe;
console.debug('[avatar][validateAuth] fallback auth/me avatar final:', finalAvatar ?? '(null)');
return {
...keycloakData,
id: keycloakData.id || keycloakData.sub,
username: keycloakData.username || keycloakData.preferred_username || '',
first_name: keycloakData.first_name || keycloakData.given_name || nameParts[0] || '',
last_name: keycloakData.last_name || keycloakData.family_name || nameParts.slice(1).join(' ') || '',
avatar_url: finalAvatar,
avatarUrl: finalAvatar,
workspace_avatar_url: finalAvatar,
workspaceAvatarUrl: finalAvatar,
};
} catch (error) {
// Si es un redirect, re-lanzarlo

View File

@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { resolveUserAvatarUrl } from './utils';
describe('resolveUserAvatarUrl', () => {
it('prioriza avatar de Workspace cuando es URL absoluta', () => {
const value = resolveUserAvatarUrl('https://hub.example.com/media/avatar.png', '/uploads/legacy.png');
expect(value).toBe('https://hub.example.com/media/avatar.png');
});
it('acepta avatar de Workspace relativo cuando no hay VITE_HUB_URL', () => {
const value = resolveUserAvatarUrl('/media/avatar.png', '/uploads/legacy.png');
expect(value).toBe('/media/avatar.png');
});
it('usa avatar legado cuando Workspace no existe', () => {
const value = resolveUserAvatarUrl(null, '/uploads/legacy.png');
expect(value).toBe('http://localhost:8000/uploads/legacy.png');
});
it('retorna vacio para fallback visual cuando no hay ninguna imagen', () => {
const value = resolveUserAvatarUrl(null, null);
expect(value).toBe('');
});
});

View File

@@ -15,8 +15,16 @@ describe('getBackendAssetUrl', () => {
expect(getBackendAssetUrl('http://ejemplo.com/archivo.png')).toBe('http://ejemplo.com/archivo.png')
})
it('reescribe host interno de Docker a host publico', () => {
expect(getBackendAssetUrl('http://hub-backend:8000/api/static/avatars/file.png')).toBe('http://localhost:8000/api/static/avatars/file.png')
})
it('reescribe URL sin protocolo con host interno', () => {
expect(getBackendAssetUrl('hub-backend:8000/api/static/avatars/file.png')).toBe('http://localhost:8000/api/static/avatars/file.png')
})
it('evita duplicar /api en la URL', () => {
expect(getBackendAssetUrl('/api/v1/items', 'http://localhost:8000/api')).toBe('http://localhost:8000/api/v1/items')
expect(getBackendAssetUrl('/api/v1/items')).toBe('http://localhost:8000/api/v1/items')
})
it('construye URL completa para ruta relativa (VITE_API_URL por defecto host:8000)', () => {

View File

@@ -5,6 +5,70 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
};
function isInternalDockerHost(hostname: string): boolean {
const host = hostname.toLowerCase();
if (!host) return false;
if (host === 'localhost' || host === '127.0.0.1') return false;
if (host === 'backend' || host === 'hub-backend' || host === 'host.docker.internal') return true;
// Nombres de servicio Docker suelen no contener punto.
return !host.includes('.');
}
function getApiPublicBaseOrigin(): string {
const raw = (import.meta.env.VITE_API_URL || '').trim();
if (raw) {
try {
const parsed = new URL(raw);
if (isInternalDockerHost(parsed.hostname)) {
if (typeof window !== 'undefined' && window.location?.origin) {
return window.location.origin;
}
return 'http://localhost:8000';
}
return `${parsed.protocol}//${parsed.host}`;
} catch {
return raw.replace(/\/+$/, '');
}
}
if (typeof window !== 'undefined' && window.location?.origin) {
return window.location.origin;
}
return 'http://localhost:8000';
}
function parseAbsoluteLikeUrl(value: string): URL | null {
const trimmed = value.trim();
if (!trimmed) return null;
try {
return new URL(trimmed);
} catch {
// Soporta formato host:puerto/ruta sin protocolo.
if (/^[a-z0-9.-]+:\d+\//i.test(trimmed)) {
try {
return new URL(`http://${trimmed}`);
} catch {
return null;
}
}
return null;
}
}
function rewriteInternalHostToPublicUrl(value: string): string {
const parsed = parseAbsoluteLikeUrl(value);
if (!parsed) return value;
if (isInternalDockerHost(parsed.hostname)) {
const publicOrigin = getApiPublicBaseOrigin();
return `${publicOrigin}${parsed.pathname}${parsed.search}${parsed.hash}`;
}
return value;
}
/**
* Convierte una ruta relativa del backend en una URL completa
* @param path Ruta relativa (ej: "/uploads/avatars/file.png") o absoluta API (ej: "/api/v1/...")
@@ -15,7 +79,11 @@ export function getBackendAssetUrl(path: string | null | undefined): string {
// Si ya es una URL completa, retornarla tal cual
if (path.startsWith('http://') || path.startsWith('https://')) {
return path;
return rewriteInternalHostToPublicUrl(path);
}
if (/^[a-z0-9.-]+:\d+\//i.test(path)) {
return rewriteInternalHostToPublicUrl(path);
}
const normalized = path.startsWith('/') ? path : `/${path}`;
@@ -35,6 +103,65 @@ export function getBackendAssetUrl(path: string | null | undefined): string {
return `${baseUrl}/${cleanPath}`;
}
export function isSafeHttpUrl(value: string | null | undefined): boolean {
if (!value) return false;
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}
function getHubAssetBaseUrl(): string {
const hubBase = (import.meta.env.VITE_HUB_URL || '').trim();
if (!hubBase) return '';
return hubBase.replace(/\/+$/, '');
}
function normalizeWorkspaceAvatarUrl(value: string | null | undefined): string {
if (!value || typeof value !== 'string') return '';
const trimmed = value.trim();
if (!trimmed) return '';
if (isSafeHttpUrl(trimmed)) {
return rewriteInternalHostToPublicUrl(trimmed);
}
if (/^[a-z0-9.-]+:\d+\//i.test(trimmed)) {
return rewriteInternalHostToPublicUrl(trimmed);
}
// Workspace puede devolver rutas relativas (ej: /media/avatar.png).
if (trimmed.startsWith('/')) {
const hubBase = getHubAssetBaseUrl();
if (hubBase) {
return `${hubBase}${trimmed}`;
}
// Si no hay HUB_URL pública, intentar resolver en el mismo origen.
return trimmed;
}
return '';
}
/**
* Prioridad de avatar de usuario:
* 1) workspaceAvatarUrl (http/https válido)
* 2) avatar local legado
* 3) fallback visual del componente Avatar
*/
export function resolveUserAvatarUrl(
workspaceAvatarUrl: string | null | undefined,
legacyAvatarUrl: string | null | undefined
): string {
const normalizedWorkspaceAvatar = normalizeWorkspaceAvatarUrl(workspaceAvatarUrl);
if (normalizedWorkspaceAvatar) {
return normalizedWorkspaceAvatar;
}
return getBackendAssetUrl(legacyAvatarUrl);
}
/**
* Obtiene únicamente el nombre del archivo a partir de una ruta/URL.
*/

View File

@@ -32,10 +32,11 @@ export const load: PageServerLoad = async ({ url, cookies }) => {
}
// SSO exchange must call the Hub that GENERATED the relay token.
// HUB_URL is the canonical public Hub (workspace.aduanasoft.com) — where the
// App Launcher runs and where relay tokens are stored.
// INTERNAL_HUB_URL is a local mirror only used for token validation in the backend.
// This fetch runs server-side (inside the Docker container), so we must use
// INTERNAL_HUB_URL (host.docker.internal) when available — "localhost" inside
// a container never reaches the host where the workspace Hub is running.
const hubUrl = (
process.env.INTERNAL_HUB_URL ||
process.env.HUB_URL ||
process.env.VITE_HUB_URL ||
'http://localhost:8001'

View File

@@ -78,11 +78,22 @@
}
if (data.user) {
const resolvedAvatarUrl =
data.user.workspaceAvatarUrl ||
data.user.workspace_avatar_url ||
data.user.avatarUrl ||
data.user.avatar_url ||
data.user.legacyAvatarUrl ||
data.user.legacy_avatar_url ||
null;
authStore.setUser({
id: data.user.sub ?? data.user.id ?? '',
username: data.user.preferred_username ?? data.user.username ?? '',
email: data.user.email,
name: data.user.name,
avatarUrl: resolvedAvatarUrl,
workspaceAvatarUrl: data.user.workspaceAvatarUrl ?? data.user.workspace_avatar_url ?? null,
legacyAvatarUrl: data.user.legacyAvatarUrl ?? data.user.legacy_avatar_url ?? data.user.avatar_url ?? null,
tenantId: data.user.tenant_id,
roles: data.user.roles ?? [],
permissions: data.user.permissions ?? []

View File

@@ -17,46 +17,13 @@ export const load: PageServerLoad = async ({ parent }) => {
export const actions: Actions = {
updateProfile: async ({ request, cookies, fetch }) => {
const formData = await request.formData();
// Manejar subida de avatar si existe
const avatarFile = formData.get('avatar') as File | null;
let avatarUrl: string | null = null;
if (avatarFile && avatarFile instanceof File && avatarFile.size > 0) {
try {
const uploadFormData = new FormData();
uploadFormData.append('file', avatarFile);
const uploadResponse = await authenticatedFetch(
'v1/core/users/me/avatar',
{
method: 'POST',
body: uploadFormData
},
cookies,
fetch,
'/login'
);
if (uploadResponse.ok) {
const result = await uploadResponse.json();
avatarUrl = result.avatar_url;
} else {
const errorText = await uploadResponse.text();
console.error('Upload failed:', errorText);
}
} catch (err) {
console.error('Error uploading avatar:', err);
}
}
const formData = await request.formData();
// Construir objeto de actualización desde FormData
const updateData: Record<string, any> = {};
for (const [key, value] of formData.entries()) {
if (key === 'avatar') continue; // Skip avatar file
if (key === 'avatar') continue;
if (value && value !== '') {
updateData[key] = value;
}

View File

@@ -5,7 +5,7 @@
import { Label } from '$lib/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card';
import { Avatar, AvatarFallback, AvatarImage } from '$lib/components/ui/avatar';
import { getBackendAssetUrl } from '$lib/utils';
import { resolveUserAvatarUrl } from '$lib/utils';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
@@ -18,8 +18,7 @@
let saving = $state(false);
let success = $state('');
let error = $state('');
let avatarFile = $state<File | null>(null);
let avatarPreview = $state('');
let avatarLoadFailed = $state(false);
// Effect para manejar errores del servidor
$effect(() => {
@@ -32,8 +31,6 @@
$effect(() => {
if (form?.success) {
success = 'Perfil actualizado exitosamente';
avatarPreview = '';
avatarFile = null;
setTimeout(() => {
success = '';
}, 3000);
@@ -44,34 +41,6 @@
}
});
async function handleAvatarChange(event: Event) {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (file) {
// Validate file type
if (!file.type.startsWith('image/')) {
error = 'Por favor selecciona una imagen válida';
return;
}
// Validate file size (max 2MB)
if (file.size > 2 * 1024 * 1024) {
error = 'La imagen debe ser menor a 2MB';
return;
}
avatarFile = file;
// Create preview
const reader = new FileReader();
reader.onload = (e) => {
avatarPreview = e.target?.result as string;
};
reader.readAsDataURL(file);
}
}
function getInitials(profile: typeof data.profile): string {
if (!profile) return '??';
const first = profile.first_name?.[0] || '';
@@ -79,8 +48,12 @@
return (first + last).toUpperCase() || profile.username?.[0]?.toUpperCase() || '?';
}
function handleAvatarError() {
avatarLoadFailed = true;
}
let currentAvatarUrl = $derived(
avatarPreview || getBackendAssetUrl(profile?.avatar_url) || ''
avatarLoadFailed ? '' : resolveUserAvatarUrl(profile?.workspace_avatar_url, profile?.legacy_avatar_url || profile?.avatar_url)
);
</script>
@@ -109,11 +82,6 @@
action="?/updateProfile"
enctype="multipart/form-data"
use:enhance={({ formData }) => {
// Agregar archivo si existe
if (avatarFile) {
formData.append('avatar', avatarFile);
}
saving = true;
error = '';
@@ -131,38 +99,29 @@
<Card class="transition-shadow hover:shadow-md">
<CardHeader>
<CardTitle class="text-xl">Foto de Perfil</CardTitle>
<CardDescription>Actualiza tu imagen de perfil</CardDescription>
<CardDescription>Esta imagen se administra desde Workspace</CardDescription>
</CardHeader>
<CardContent class="space-y-6">
<div class="flex flex-col sm:flex-row items-center gap-8">
<div class="relative group">
<label for="avatar" class="cursor-pointer">
<Avatar class="h-28 w-28 ring-4 ring-background shadow-lg transition-all group-hover:scale-105 group-hover:ring-primary/50">
<AvatarImage src={currentAvatarUrl} alt={profile.username} />
<AvatarImage src={currentAvatarUrl} alt={profile.username} onerror={handleAvatarError} />
<AvatarFallback class="text-3xl font-semibold">{getInitials(profile)}</AvatarFallback>
</Avatar>
<div class="absolute inset-0 rounded-full bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
</label>
</div>
<div class="flex-1 w-full">
<div class="flex flex-col gap-3">
<Input
id="avatar"
type="file"
accept="image/*"
onchange={handleAvatarChange}
class="cursor-pointer transition-colors"
/>
<p class="text-sm text-muted-foreground">
Haz clic en la imagen o selecciona un archivo. JPG, PNG o GIF. Máximo 2MB.
</p>
</div>
<p class="text-sm text-muted-foreground">
La foto de perfil se sincroniza desde Workspace en login, refresh de sesión y carga de perfil.
</p>
<p class="text-sm text-muted-foreground">
Para cambiarla, actualízala en Workspace y vuelve a iniciar sesión.
</p>
</div>
</div>
</CardContent>