feat: add first_name and last_name fields to UserTenant model and sync with Keycloak
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -0,0 +1,35 @@
|
|||||||
|
"""add first_name and last_name to user_tenants
|
||||||
|
|
||||||
|
Revision ID: a1b2c3d4e5f6
|
||||||
|
Revises: 8c9bad3da37f
|
||||||
|
Create Date: 2026-05-06 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "a1b2c3d4e5f6"
|
||||||
|
down_revision: Union[str, None] = "8c9bad3da37f"
|
||||||
|
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("first_name", sa.String(100), nullable=True,
|
||||||
|
comment="Nombre (caché local de Keycloak)"),
|
||||||
|
schema="core",
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"user_tenants",
|
||||||
|
sa.Column("last_name", sa.String(100), nullable=True,
|
||||||
|
comment="Apellido (caché local de Keycloak)"),
|
||||||
|
schema="core",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("user_tenants", "last_name", schema="core")
|
||||||
|
op.drop_column("user_tenants", "first_name", schema="core")
|
||||||
@@ -56,6 +56,14 @@ class UserTenant(Base, TenantScopedMixin, TimestampMixin):
|
|||||||
avatar_url: Mapped[Optional[str]] = mapped_column(
|
avatar_url: Mapped[Optional[str]] = mapped_column(
|
||||||
String(500), nullable=True, comment="URL de la imagen de perfil"
|
String(500), nullable=True, comment="URL de la imagen de perfil"
|
||||||
)
|
)
|
||||||
|
# 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(
|
||||||
|
String(100), nullable=True, comment="Nombre (caché local de Keycloak)"
|
||||||
|
)
|
||||||
|
last_name: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(100), nullable=True, comment="Apellido (caché local de Keycloak)"
|
||||||
|
)
|
||||||
phone: Mapped[Optional[str]] = mapped_column(
|
phone: Mapped[Optional[str]] = mapped_column(
|
||||||
String(20), nullable=True, comment="Teléfono del usuario"
|
String(20), nullable=True, comment="Teléfono del usuario"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -149,6 +149,169 @@ def get_user_avatar_image(
|
|||||||
return Response(content=data, media_type=media)
|
return Response(content=data, media_type=media)
|
||||||
|
|
||||||
|
|
||||||
|
# === Endpoints de Perfil del Usuario Actual ===
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me/profile", response_model=UserResponseDTO)
|
||||||
|
async def get_my_profile(
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Obtiene el perfil completo del usuario actual
|
||||||
|
"""
|
||||||
|
keycloak_user_id = current_user.get("sub")
|
||||||
|
if not keycloak_user_id:
|
||||||
|
raise HTTPException(status_code=400, detail="User ID not found in token")
|
||||||
|
|
||||||
|
# Obtener user_tenant para crear servicio
|
||||||
|
user_tenant = (
|
||||||
|
db.query(UserTenant)
|
||||||
|
.filter(
|
||||||
|
UserTenant.keycloak_user_id == keycloak_user_id,
|
||||||
|
UserTenant.is_active == True,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not user_tenant:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="User does not belong to any tenant"
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/me/profile", response_model=UserResponseDTO)
|
||||||
|
async def update_my_profile(
|
||||||
|
request: Request,
|
||||||
|
data: UpdateUserRequestDTO,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Actualiza el perfil del usuario actual.
|
||||||
|
Campos editables: first_name, last_name, phone.
|
||||||
|
Email, username y otros campos de identidad solo se cambian desde el Hub.
|
||||||
|
"""
|
||||||
|
keycloak_user_id = current_user.get("sub")
|
||||||
|
if not keycloak_user_id:
|
||||||
|
raise HTTPException(status_code=400, detail="User ID not found in token")
|
||||||
|
|
||||||
|
# Obtener user_tenant
|
||||||
|
user_tenant = (
|
||||||
|
db.query(UserTenant)
|
||||||
|
.filter(
|
||||||
|
UserTenant.keycloak_user_id == keycloak_user_id,
|
||||||
|
UserTenant.is_active == True,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not user_tenant:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="User does not belong to any tenant"
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
service = UserService(db, user_tenant.tenant_id, user_tenant.company_id)
|
||||||
|
return await service.update_current_user_profile(
|
||||||
|
keycloak_user_id=keycloak_user_id,
|
||||||
|
current_user=current_user,
|
||||||
|
access_token=access_token,
|
||||||
|
first_name=data.first_name,
|
||||||
|
last_name=data.last_name,
|
||||||
|
phone=data.phone,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/me/avatar")
|
||||||
|
async def upload_my_avatar(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Sube un avatar para el usuario actual.
|
||||||
|
Con MinIO guarda en tenants/{tid}/users/{sub}/avatar.{ext} y persiste la clave en UserTenant.
|
||||||
|
Retorna URL pública para <img src> (GET /users/avatar/...).
|
||||||
|
"""
|
||||||
|
if not file.content_type or not file.content_type.startswith("image/"):
|
||||||
|
raise HTTPException(status_code=400, detail="El archivo debe ser una imagen")
|
||||||
|
|
||||||
|
keycloak_user_id = current_user.get("sub")
|
||||||
|
if not keycloak_user_id:
|
||||||
|
raise HTTPException(status_code=400, detail="User ID not found in token")
|
||||||
|
|
||||||
|
user_tenant = (
|
||||||
|
db.query(UserTenant)
|
||||||
|
.filter(
|
||||||
|
UserTenant.keycloak_user_id == keycloak_user_id,
|
||||||
|
UserTenant.is_active == True,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not user_tenant:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400, detail="User does not belong to any tenant"
|
||||||
|
)
|
||||||
|
|
||||||
|
ext = Path(file.filename or "image.jpg").suffix.lower() or ".jpg"
|
||||||
|
if ext not in _AVATAR_EXT:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Extensión no permitida. Use: {', '.join(sorted(_AVATAR_EXT))}",
|
||||||
|
)
|
||||||
|
|
||||||
|
contents = await file.read()
|
||||||
|
if len(contents) > 2 * 1024 * 1024:
|
||||||
|
raise HTTPException(status_code=400, detail="La imagen debe ser menor a 2MB")
|
||||||
|
|
||||||
|
tenant_id = user_tenant.tenant_id
|
||||||
|
|
||||||
|
try:
|
||||||
|
if settings.use_s3_object_storage:
|
||||||
|
if user_tenant.avatar_url and str(user_tenant.avatar_url).startswith(
|
||||||
|
"tenants/"
|
||||||
|
):
|
||||||
|
delete_object_if_exists(str(user_tenant.avatar_url))
|
||||||
|
key = user_avatar_key(tenant_id, keycloak_user_id, ext)
|
||||||
|
ct = file.content_type or mimetypes.guess_type(f"x{ext}")[0] or "image/jpeg"
|
||||||
|
put_object_bytes(key, contents, content_type=ct)
|
||||||
|
user_tenant.avatar_url = key
|
||||||
|
logger.info(
|
||||||
|
"User avatar stored in S3 key=%s bytes=%s", key, len(contents)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
upload_dir = Path("uploads/avatars")
|
||||||
|
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
filename = f"{keycloak_user_id}{ext}"
|
||||||
|
file_path = upload_dir / filename
|
||||||
|
with open(file_path, "wb") as f:
|
||||||
|
f.write(contents)
|
||||||
|
user_tenant.avatar_url = f"/uploads/avatars/{filename}"
|
||||||
|
|
||||||
|
db.add(user_tenant)
|
||||||
|
db.commit()
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail=f"Error al guardar el avatar: {str(e)}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
public_url = public_user_avatar_api_path(tenant_id, keycloak_user_id)
|
||||||
|
return {"avatar_url": public_url}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{user_id}", response_model=UserResponseDTO)
|
@router.get("/{user_id}", response_model=UserResponseDTO)
|
||||||
async def get_user_detail(
|
async def get_user_detail(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
@@ -293,158 +456,3 @@ async def change_user_password(
|
|||||||
service = UserService(db, tenant_id, company_id)
|
service = UserService(db, tenant_id, company_id)
|
||||||
await service.change_password(user_id, data.password, data.temporary)
|
await service.change_password(user_id, data.password, data.temporary)
|
||||||
return {"message": "Password changed successfully"}
|
return {"message": "Password changed successfully"}
|
||||||
|
|
||||||
|
|
||||||
# === Endpoints de Perfil del Usuario Actual ===
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/me/profile", response_model=UserResponseDTO)
|
|
||||||
async def get_my_profile(
|
|
||||||
current_user: dict = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_core_db),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Obtiene el perfil completo del usuario actual
|
|
||||||
"""
|
|
||||||
keycloak_user_id = current_user.get("sub")
|
|
||||||
if not keycloak_user_id:
|
|
||||||
raise HTTPException(status_code=400, detail="User ID not found in token")
|
|
||||||
|
|
||||||
# Obtener user_tenant para crear servicio
|
|
||||||
user_tenant = (
|
|
||||||
db.query(UserTenant)
|
|
||||||
.filter(
|
|
||||||
UserTenant.keycloak_user_id == keycloak_user_id,
|
|
||||||
UserTenant.is_active == True,
|
|
||||||
)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
if not user_tenant:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400, detail="User does not belong to any tenant"
|
|
||||||
)
|
|
||||||
|
|
||||||
service = UserService(db, user_tenant.tenant_id, user_tenant.company_id)
|
|
||||||
return await service.get_current_user_profile(keycloak_user_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/me/profile", response_model=UserResponseDTO)
|
|
||||||
async def update_my_profile(
|
|
||||||
data: UpdateUserRequestDTO,
|
|
||||||
current_user: dict = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_core_db),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Actualiza el perfil del usuario actual
|
|
||||||
"""
|
|
||||||
keycloak_user_id = current_user.get("sub")
|
|
||||||
if not keycloak_user_id:
|
|
||||||
raise HTTPException(status_code=400, detail="User ID not found in token")
|
|
||||||
|
|
||||||
# Obtener user_tenant
|
|
||||||
user_tenant = (
|
|
||||||
db.query(UserTenant)
|
|
||||||
.filter(
|
|
||||||
UserTenant.keycloak_user_id == keycloak_user_id,
|
|
||||||
UserTenant.is_active == True,
|
|
||||||
)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
if not user_tenant:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400, detail="User does not belong to any tenant"
|
|
||||||
)
|
|
||||||
|
|
||||||
service = UserService(db, user_tenant.tenant_id, user_tenant.company_id)
|
|
||||||
return await service.update_current_user_profile(
|
|
||||||
keycloak_user_id=keycloak_user_id,
|
|
||||||
first_name=data.first_name,
|
|
||||||
last_name=data.last_name,
|
|
||||||
email=data.email,
|
|
||||||
avatar_url=data.avatar_url,
|
|
||||||
phone=data.phone,
|
|
||||||
bio=data.bio,
|
|
||||||
preferences=data.preferences,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/me/avatar")
|
|
||||||
async def upload_avatar(
|
|
||||||
file: UploadFile = File(...),
|
|
||||||
current_user: dict = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_core_db),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Sube un avatar para el usuario actual.
|
|
||||||
Con MinIO guarda en tenants/{tid}/users/{sub}/avatar.{ext} y persiste la clave en UserTenant.
|
|
||||||
Retorna URL pública para <img src> (GET /users/avatar/...).
|
|
||||||
"""
|
|
||||||
if not file.content_type or not file.content_type.startswith("image/"):
|
|
||||||
raise HTTPException(status_code=400, detail="El archivo debe ser una imagen")
|
|
||||||
|
|
||||||
keycloak_user_id = current_user.get("sub")
|
|
||||||
if not keycloak_user_id:
|
|
||||||
raise HTTPException(status_code=400, detail="User ID not found in token")
|
|
||||||
|
|
||||||
user_tenant = (
|
|
||||||
db.query(UserTenant)
|
|
||||||
.filter(
|
|
||||||
UserTenant.keycloak_user_id == keycloak_user_id,
|
|
||||||
UserTenant.is_active == True,
|
|
||||||
)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if not user_tenant:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400, detail="User does not belong to any tenant"
|
|
||||||
)
|
|
||||||
|
|
||||||
ext = Path(file.filename or "image.jpg").suffix.lower() or ".jpg"
|
|
||||||
if ext not in _AVATAR_EXT:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"Extensión no permitida. Use: {', '.join(sorted(_AVATAR_EXT))}",
|
|
||||||
)
|
|
||||||
|
|
||||||
contents = await file.read()
|
|
||||||
if len(contents) > 2 * 1024 * 1024:
|
|
||||||
raise HTTPException(status_code=400, detail="La imagen debe ser menor a 2MB")
|
|
||||||
|
|
||||||
tenant_id = user_tenant.tenant_id
|
|
||||||
|
|
||||||
try:
|
|
||||||
if settings.use_s3_object_storage:
|
|
||||||
if user_tenant.avatar_url and str(user_tenant.avatar_url).startswith(
|
|
||||||
"tenants/"
|
|
||||||
):
|
|
||||||
delete_object_if_exists(str(user_tenant.avatar_url))
|
|
||||||
key = user_avatar_key(tenant_id, keycloak_user_id, ext)
|
|
||||||
ct = file.content_type or mimetypes.guess_type(f"x{ext}")[0] or "image/jpeg"
|
|
||||||
put_object_bytes(key, contents, content_type=ct)
|
|
||||||
user_tenant.avatar_url = key
|
|
||||||
logger.info(
|
|
||||||
"User avatar stored in S3 key=%s bytes=%s", key, len(contents)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
upload_dir = Path("uploads/avatars")
|
|
||||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
filename = f"{keycloak_user_id}{ext}"
|
|
||||||
file_path = upload_dir / filename
|
|
||||||
with open(file_path, "wb") as f:
|
|
||||||
f.write(contents)
|
|
||||||
user_tenant.avatar_url = f"/uploads/avatars/{filename}"
|
|
||||||
|
|
||||||
db.add(user_tenant)
|
|
||||||
db.commit()
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
|
||||||
except Exception as e:
|
|
||||||
db.rollback()
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500, detail=f"Error al guardar el avatar: {str(e)}"
|
|
||||||
) from e
|
|
||||||
|
|
||||||
public_url = public_user_avatar_api_path(tenant_id, keycloak_user_id)
|
|
||||||
return {"avatar_url": public_url}
|
|
||||||
|
|||||||
@@ -21,14 +21,20 @@ def _normalize_user(
|
|||||||
user_tenant: Optional[Any] = None,
|
user_tenant: Optional[Any] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Normaliza los datos de usuario al formato esperado por el DTO
|
Normaliza los datos de usuario al formato esperado por el DTO.
|
||||||
|
Prioridad para nombre/apellido: caché local (user_tenant) > JWT claims > campo 'name'.
|
||||||
"""
|
"""
|
||||||
|
name_parts = (user_data.get("name") or "").split(" ", 1)
|
||||||
|
# Caché local tiene prioridad — se actualiza al guardar perfil desde Anexo76
|
||||||
|
local_first = getattr(user_tenant, "first_name", None) if user_tenant else None
|
||||||
|
local_last = getattr(user_tenant, "last_name", None) if user_tenant else None
|
||||||
|
|
||||||
normalized = {
|
normalized = {
|
||||||
"id": user_data.get("id") or user_data.get("sub"),
|
"id": user_data.get("id") or user_data.get("sub"),
|
||||||
"username": user_data.get("username") or user_data.get("preferred_username", ""),
|
"username": user_data.get("username") or user_data.get("preferred_username", ""),
|
||||||
"email": user_data.get("email", ""),
|
"email": user_data.get("email", ""),
|
||||||
"first_name": user_data.get("firstName") or user_data.get("name", "").split(" ")[0],
|
"first_name": local_first or user_data.get("firstName") or user_data.get("given_name") or (name_parts[0] if name_parts else ""),
|
||||||
"last_name": user_data.get("lastName") or (" ".join(user_data.get("name", "").split(" ")[1:]) if " " in user_data.get("name", "") else ""),
|
"last_name": local_last or user_data.get("lastName") or user_data.get("family_name") or (name_parts[1] if len(name_parts) > 1 else ""),
|
||||||
"enabled": user_data.get("enabled", True),
|
"enabled": user_data.get("enabled", True),
|
||||||
"email_verified": user_data.get("emailVerified") or user_data.get("email_verified", False),
|
"email_verified": user_data.get("emailVerified") or user_data.get("email_verified", False),
|
||||||
"created_timestamp": user_data.get("createdTimestamp"),
|
"created_timestamp": user_data.get("createdTimestamp"),
|
||||||
@@ -38,9 +44,11 @@ def _normalize_user(
|
|||||||
# Agregar campos de perfil si user_tenant está disponible
|
# Agregar campos de perfil si user_tenant está disponible
|
||||||
if user_tenant:
|
if user_tenant:
|
||||||
avatar_out = user_tenant.avatar_url
|
avatar_out = user_tenant.avatar_url
|
||||||
if avatar_out and str(avatar_out).startswith("tenants/"):
|
if avatar_out:
|
||||||
from core.s3_keys import public_user_avatar_api_path
|
from core.s3_keys import public_user_avatar_api_path
|
||||||
|
|
||||||
|
# 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(
|
avatar_out = public_user_avatar_api_path(
|
||||||
user_tenant.tenant_id, user_tenant.keycloak_user_id
|
user_tenant.tenant_id, user_tenant.keycloak_user_id
|
||||||
)
|
)
|
||||||
@@ -621,19 +629,127 @@ class UserService:
|
|||||||
"usage_percentage": round(usage_percentage, 2),
|
"usage_percentage": round(usage_percentage, 2),
|
||||||
}
|
}
|
||||||
|
|
||||||
async def get_current_user_profile(self, keycloak_user_id: str) -> Dict[str, Any]:
|
async def get_current_user_profile(self, keycloak_user_id: str, current_user: Dict[str, Any] = None) -> Dict[str, Any]:
|
||||||
"""Obtiene el perfil completo del usuario actual"""
|
"""Obtiene el perfil completo del usuario actual"""
|
||||||
# Reutilizamos verify_token para obtener info del Hub
|
# Use the already-verified JWT claims dict — do NOT call verify_token(uuid)
|
||||||
from core.security import verify_token
|
user_info = current_user or {"id": keycloak_user_id}
|
||||||
user_info = await verify_token(keycloak_user_id) # keycloak_user_id es el token en este contexto, o el ID
|
|
||||||
# Nota: en routes.py se pasa el ID. Si necesitamos info real, pedimos al Hub.
|
|
||||||
|
|
||||||
user_tenant = self.db.query(UserTenant).filter(
|
user_tenant = self.db.query(UserTenant).filter(
|
||||||
and_(UserTenant.keycloak_user_id == keycloak_user_id, UserTenant.is_active == True)
|
and_(UserTenant.keycloak_user_id == keycloak_user_id, UserTenant.is_active == True)
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
return _normalize_user(user_info, user_tenant.role if user_tenant else None, user_tenant)
|
return _normalize_user(user_info, user_tenant.role if user_tenant else None, user_tenant)
|
||||||
|
|
||||||
async def update_current_user_profile(self, keycloak_user_id: str, **kwargs) -> Dict[str, Any]:
|
async def update_current_user_profile(
|
||||||
"""Actualiza el perfil del usuario actual"""
|
self,
|
||||||
return await self.update_user(keycloak_user_id, **kwargs)
|
keycloak_user_id: str,
|
||||||
|
current_user: Dict[str, Any] = None,
|
||||||
|
access_token: Optional[str] = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Actualiza el perfil del usuario actual.
|
||||||
|
- first_name / last_name: persiste localmente en UserTenant Y sincroniza con Keycloak vía Hub.
|
||||||
|
- phone: persiste solo localmente.
|
||||||
|
"""
|
||||||
|
user_tenant = self.db.query(UserTenant).filter(
|
||||||
|
and_(UserTenant.keycloak_user_id == keycloak_user_id, UserTenant.tenant_id == self.tenant_id)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not user_tenant:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
# Campos locales — incluye first_name/last_name como caché
|
||||||
|
for field in ["role", "avatar_url", "phone", "bio", "preferences", "first_name", "last_name"]:
|
||||||
|
if field in kwargs and kwargs[field] is not None:
|
||||||
|
setattr(user_tenant, field, kwargs[field])
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(user_tenant)
|
||||||
|
|
||||||
|
# Sincronizar nombre/apellido con Keycloak vía Hub (best-effort)
|
||||||
|
first_name = kwargs.get("first_name")
|
||||||
|
last_name = kwargs.get("last_name")
|
||||||
|
if access_token and (first_name or last_name):
|
||||||
|
await self._hub_update_user_profile(keycloak_user_id, first_name, last_name, access_token)
|
||||||
|
|
||||||
|
user_info = current_user or {"id": keycloak_user_id}
|
||||||
|
return _normalize_user(user_info, user_tenant.role, user_tenant)
|
||||||
|
|
||||||
|
async def _hub_get_service_token(self) -> Optional[str]:
|
||||||
|
"""Obtiene un token de la cuenta de servicio Hub para operaciones admin."""
|
||||||
|
if not settings.HUB_ADMIN_EMAIL or not settings.HUB_ADMIN_PASSWORD:
|
||||||
|
return None
|
||||||
|
base = (settings.HUB_URL or "").rstrip("/")
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{base}/api/v1/auth/login",
|
||||||
|
json={"username": settings.HUB_ADMIN_EMAIL, "password": settings.HUB_ADMIN_PASSWORD},
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json()
|
||||||
|
return data.get("access_token")
|
||||||
|
logger.warning("Hub service account login failed status=%s", resp.status_code)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Hub service account login error: %s", exc)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _hub_update_user_profile(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
first_name: Optional[str],
|
||||||
|
last_name: Optional[str],
|
||||||
|
access_token: str,
|
||||||
|
) -> None:
|
||||||
|
"""Sincroniza nombre/apellido con Keycloak a través del Hub (best-effort, no bloquea).
|
||||||
|
|
||||||
|
Intenta primero con el token del usuario. Si el Hub devuelve 403 (el usuario no
|
||||||
|
tiene rol de Hub-admin), reintenta usando la cuenta de servicio configurada en
|
||||||
|
HUB_ADMIN_EMAIL / HUB_ADMIN_PASSWORD.
|
||||||
|
"""
|
||||||
|
base = (settings.HUB_URL or "").rstrip("/")
|
||||||
|
url = f"{base}/api/v1/hub/admins/{user_id}"
|
||||||
|
payload: Dict[str, Any] = {}
|
||||||
|
if first_name:
|
||||||
|
payload["first_name"] = first_name
|
||||||
|
if last_name:
|
||||||
|
payload["last_name"] = last_name
|
||||||
|
if not payload:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
resp = await client.patch(
|
||||||
|
url,
|
||||||
|
json=payload,
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.status_code == 403:
|
||||||
|
# El usuario no es Hub admin — reintentar con cuenta de servicio
|
||||||
|
logger.info("Hub profile sync: user token got 403, trying service account for user_id=%s", user_id)
|
||||||
|
service_token = await self._hub_get_service_token()
|
||||||
|
if service_token:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
resp = await client.patch(
|
||||||
|
url,
|
||||||
|
json=payload,
|
||||||
|
headers={"Authorization": f"Bearer {service_token}"},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Hub profile sync skipped: no service account configured (HUB_ADMIN_EMAIL/HUB_ADMIN_PASSWORD)"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if resp.status_code not in (200, 204):
|
||||||
|
logger.warning(
|
||||||
|
"Hub profile sync failed status=%s body=%s",
|
||||||
|
resp.status_code,
|
||||||
|
resp.text[:300],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info("Hub profile sync OK user_id=%s", user_id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Hub profile sync error user_id=%s: %s", user_id, exc)
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# Hub de Aduanasoft — requerido siempre (SaaS y self-hosted)
|
# Hub de Aduanasoft — requerido siempre (SaaS y self-hosted)
|
||||||
HUB_URL: str = "http://localhost:8001"
|
HUB_URL: str = "http://localhost:8001"
|
||||||
|
# Cuenta de servicio Hub — usada para operaciones admin (ej. sync de nombre a Keycloak)
|
||||||
|
HUB_ADMIN_EMAIL: str = ""
|
||||||
|
HUB_ADMIN_PASSWORD: str = ""
|
||||||
|
|
||||||
@field_validator("CENTRAL_SERVER_URL", "SPOKE_URLS", "HUB_URL", mode="before")
|
@field_validator("CENTRAL_SERVER_URL", "SPOKE_URLS", "HUB_URL", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ services:
|
|||||||
- SPOKE_URLS=${SPOKE_URLS:-""}
|
- SPOKE_URLS=${SPOKE_URLS:-""}
|
||||||
# Hub — URL interna para validación de licencias
|
# Hub — URL interna para validación de licencias
|
||||||
- HUB_URL=${HUB_URL:-http://host.docker.internal:8001}
|
- HUB_URL=${HUB_URL:-http://host.docker.internal:8001}
|
||||||
|
- HUB_ADMIN_EMAIL=${HUB_ADMIN_EMAIL:-}
|
||||||
|
- HUB_ADMIN_PASSWORD=${HUB_ADMIN_PASSWORD:-}
|
||||||
- CSV_IMPORT_STORAGE=${CSV_IMPORT_STORAGE:-minio}
|
- CSV_IMPORT_STORAGE=${CSV_IMPORT_STORAGE:-minio}
|
||||||
- S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000}
|
- S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000}
|
||||||
- S3_ACCESS_KEY=${S3_ACCESS_KEY:-${MINIO_ROOT_USER:-minioadmin}}
|
- S3_ACCESS_KEY=${S3_ACCESS_KEY:-${MINIO_ROOT_USER:-minioadmin}}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getContext, onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
|
import { page } from "$app/state";
|
||||||
import { useSidebar } from "$lib/components/ui/sidebar/context.svelte.js";
|
import { useSidebar } from "$lib/components/ui/sidebar/context.svelte.js";
|
||||||
import { getSidebarData } from "$lib/components/sidebar/modules";
|
import { getSidebarData } from "$lib/components/sidebar/modules";
|
||||||
import NavMain from "./nav-main.svelte";
|
import NavMain from "./nav-main.svelte";
|
||||||
@@ -15,9 +16,8 @@
|
|||||||
...restProps
|
...restProps
|
||||||
}: ComponentProps<typeof Sidebar.Root> = $props();
|
}: ComponentProps<typeof Sidebar.Root> = $props();
|
||||||
|
|
||||||
// Obtener datos del usuario desde el contexto (viene de Keycloak vía +layout.server.ts)
|
// Leer siempre de page.data para que el sidebar reaccione tras invalidateAll()
|
||||||
const userData = getContext<any>('user');
|
const userTenants = $derived((page.data.userTenants as { id: number; name: string; slug: string }[]) ?? []);
|
||||||
const userTenants = getContext<{ id: number; name: string; slug: string }[]>('userTenants') ?? [];
|
|
||||||
|
|
||||||
// Obtener datos del sidebar con traducciones
|
// Obtener datos del sidebar con traducciones
|
||||||
const sidebarData = getSidebarData();
|
const sidebarData = getSidebarData();
|
||||||
@@ -25,15 +25,21 @@
|
|||||||
// Combinar los datos estáticos del sidebar con los datos del usuario de Keycloak
|
// Combinar los datos estáticos del sidebar con los datos del usuario de Keycloak
|
||||||
const data = $derived({
|
const data = $derived({
|
||||||
...sidebarData,
|
...sidebarData,
|
||||||
user: userData
|
user: page.data.user
|
||||||
? {
|
? {
|
||||||
name: userData.name || userData.preferred_username || "",
|
name: _displayName(page.data.user),
|
||||||
email: userData.email || "",
|
email: page.data.user.email || "",
|
||||||
avatar: userData.avatar_url || "/avatars/default.jpg",
|
avatar: page.data.user.avatar_url || "/avatars/default.jpg",
|
||||||
}
|
}
|
||||||
: sidebarData.user,
|
: sidebarData.user,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function _displayName(u: any): string {
|
||||||
|
const first = u.first_name || u.given_name || "";
|
||||||
|
const last = u.last_name || u.family_name || "";
|
||||||
|
return (first + " " + last).trim() || u.name || u.preferred_username || "";
|
||||||
|
}
|
||||||
|
|
||||||
const sidebar = useSidebar();
|
const sidebar = useSidebar();
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
|||||||
@@ -285,20 +285,34 @@ export async function validateAuth(
|
|||||||
|
|
||||||
if (profileResponse.ok) {
|
if (profileResponse.ok) {
|
||||||
const profileData = await profileResponse.json();
|
const profileData = await profileResponse.json();
|
||||||
// Combinar datos de Keycloak con datos del perfil
|
// Combinar datos de Keycloak con datos del perfil.
|
||||||
|
// Prioridad para nombre: caché local del perfil > JWT claims.
|
||||||
return {
|
return {
|
||||||
...keycloakData,
|
...keycloakData,
|
||||||
avatar_url: profileData.avatar_url,
|
id: profileData.id || keycloakData.id || keycloakData.sub,
|
||||||
phone: profileData.phone,
|
username: profileData.username || keycloakData.username || keycloakData.preferred_username || '',
|
||||||
bio: profileData.bio,
|
email: profileData.email || keycloakData.email || '',
|
||||||
preferences: profileData.preferences
|
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,
|
||||||
|
phone: profileData.phone || null,
|
||||||
|
bio: profileData.bio || null,
|
||||||
|
preferences: profileData.preferences || {}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (profileError) {
|
} catch (profileError) {
|
||||||
console.warn('⚠️ [API] No se pudo cargar el perfil del usuario, usando solo datos de Keycloak');
|
console.warn('⚠️ [API] No se pudo cargar el perfil del usuario, usando solo datos de Keycloak');
|
||||||
}
|
}
|
||||||
|
|
||||||
return keycloakData;
|
// Fallback: map raw JWT claim names to the expected field names
|
||||||
|
const nameParts = (keycloakData.name || '').split(' ');
|
||||||
|
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(' ') || '',
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Si es un redirect, re-lanzarlo
|
// Si es un redirect, re-lanzarlo
|
||||||
if (error && typeof error === 'object' && 'status' in error && 'location' in error) {
|
if (error && typeof error === 'object' && 'status' in error && 'location' in error) {
|
||||||
|
|||||||
@@ -7,6 +7,11 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { setAccessTokenCookies } from '$lib/server/access-token-cookie';
|
import { setAccessTokenCookies } from '$lib/server/access-token-cookie';
|
||||||
|
import { getAccessTokenFromCookies } from '$lib/server/access-token-cookie';
|
||||||
|
|
||||||
|
// Disable client-side rendering to prevent SvelteKit from making a second
|
||||||
|
// __data.json request that would consume the one-time relay token twice.
|
||||||
|
export const csr = false;
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ url, cookies }) => {
|
export const load: PageServerLoad = async ({ url, cookies }) => {
|
||||||
const relayToken = url.searchParams.get('relay');
|
const relayToken = url.searchParams.get('relay');
|
||||||
@@ -18,21 +23,22 @@ export const load: PageServerLoad = async ({ url, cookies }) => {
|
|||||||
|
|
||||||
// If there is already a valid session, skip the exchange to avoid
|
// If there is already a valid session, skip the exchange to avoid
|
||||||
// re-using a one-time relay token (e.g. browser tab reload or prefetch).
|
// re-using a one-time relay token (e.g. browser tab reload or prefetch).
|
||||||
const existingToken = cookies.get('access_token') || cookies.get('access_token_0');
|
const existingToken = getAccessTokenFromCookies(cookies);
|
||||||
if (existingToken) {
|
if (existingToken) {
|
||||||
console.log('[SSO] sesión existente detectada, redirigiendo sin exchange');
|
console.log('[SSO] sesión existente detectada, redirigiendo sin exchange');
|
||||||
throw redirect(303, '/dashboard');
|
throw redirect(303, '/dashboard');
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSO exchange must call Hub backend, not Anexo76 backend.
|
// SSO exchange must call the Hub that GENERATED the relay token.
|
||||||
// Use INTERNAL_HUB_URL for server-to-server communication.
|
// HUB_URL is the canonical public Hub (workspace.aduanasoft.com) — where the
|
||||||
let hubUrl = process.env.INTERNAL_HUB_URL;
|
// App Launcher runs and where relay tokens are stored.
|
||||||
if (!hubUrl) {
|
// INTERNAL_HUB_URL is a local mirror only used for token validation in the backend.
|
||||||
hubUrl = process.env.VITE_HUB_URL;
|
const hubUrl = (
|
||||||
// Fallback: replace localhost with hub-backend for Docker
|
process.env.HUB_URL ||
|
||||||
hubUrl = hubUrl?.replace('localhost', 'host.docker.internal').replace('127.0.0.1', 'host.docker.internal');
|
process.env.VITE_HUB_URL ||
|
||||||
}
|
'http://localhost:8001'
|
||||||
const baseUrl = hubUrl?.endsWith('/') ? hubUrl : `${hubUrl}/`;
|
).replace(/\/+$/, '');
|
||||||
|
const baseUrl = `${hubUrl}/`;
|
||||||
|
|
||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
@@ -47,7 +53,22 @@ export const load: PageServerLoad = async ({ url, cookies }) => {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const body = await response.json().catch(() => ({}));
|
const body = await response.json().catch(() => ({}));
|
||||||
const detail = body?.detail || 'sso_exchange_failed';
|
const detail: string = body?.detail || 'sso_exchange_failed';
|
||||||
|
console.error('[SSO] exchange falló:', response.status, detail);
|
||||||
|
|
||||||
|
// If the token is "invalid/used", a concurrent request may have already
|
||||||
|
// succeeded and set cookies. Redirect to /dashboard — if the session is
|
||||||
|
// valid it will load; if not, the dashboard layout will redirect to /login.
|
||||||
|
const tokenAlreadyUsed =
|
||||||
|
detail.toLowerCase().includes('inválido') ||
|
||||||
|
detail.toLowerCase().includes('invalido') ||
|
||||||
|
detail.toLowerCase().includes('invalid') ||
|
||||||
|
detail.toLowerCase().includes('used') ||
|
||||||
|
detail.toLowerCase().includes('expired');
|
||||||
|
if (tokenAlreadyUsed) {
|
||||||
|
throw redirect(303, '/dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
throw redirect(303, `/login?error=${encodeURIComponent(detail)}`);
|
throw redirect(303, `/login?error=${encodeURIComponent(detail)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,9 +54,12 @@
|
|||||||
updateCsvImportBanner();
|
updateCsvImportBanner();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Hacer disponible el usuario en el contexto para los componentes hijos
|
// Hacer disponible el usuario en el contexto para los componentes hijos.
|
||||||
|
// El sidebar ya lee de page.data directamente; este contexto lo usan otros componentes.
|
||||||
setContext('user', data.user);
|
setContext('user', data.user);
|
||||||
setContext('userTenants', data.userTenants ?? []);
|
setContext('userTenants', data.userTenants ?? []);
|
||||||
|
// Actualizar el contexto reactive al cambiar data.user (post invalidateAll)
|
||||||
|
$effect(() => { setContext('user', data.user); });
|
||||||
|
|
||||||
// ── Manejar expiración de sesión ────────────────────────────────────────
|
// ── Manejar expiración de sesión ────────────────────────────────────────
|
||||||
function handleSessionExpired(e: Event) {
|
function handleSessionExpired(e: Event) {
|
||||||
|
|||||||
@@ -1,42 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* Server-side load y actions para gestión de perfil de usuario
|
* Server-side load y actions para gestión de perfil de usuario
|
||||||
*/
|
*/
|
||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import { authenticatedFetch } from '$lib/server/api';
|
import { authenticatedFetch } from '$lib/server/api';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ cookies, fetch }) => {
|
export const load: PageServerLoad = async ({ parent }) => {
|
||||||
try {
|
// El layout /dashboard ya cargó los datos del usuario via validateAuth.
|
||||||
const response = await authenticatedFetch(
|
// Reutilizamos esos datos en lugar de hacer una llamada duplicada.
|
||||||
'v1/core/users/me/profile',
|
const { user } = await parent();
|
||||||
{
|
return {
|
||||||
method: 'GET',
|
profile: user ?? null,
|
||||||
},
|
error: user ? null : 'Error al cargar el perfil'
|
||||||
cookies,
|
};
|
||||||
fetch,
|
|
||||||
'/login' // Redirigir a login si no está autenticado
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorData = await response.json().catch(() => ({}));
|
|
||||||
return {
|
|
||||||
profile: null,
|
|
||||||
error: errorData.detail || 'Error al cargar el perfil'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const profile = await response.json();
|
|
||||||
return {
|
|
||||||
profile,
|
|
||||||
error: null
|
|
||||||
};
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('Error loading profile:', err);
|
|
||||||
return {
|
|
||||||
profile: null,
|
|
||||||
error: 'Error al cargar el perfil'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
@@ -86,10 +62,9 @@ export const actions: Actions = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Agregar avatar_url si se subió exitosamente
|
// No incluir avatar_url en el PUT /me/profile: el POST /me/avatar ya persistió
|
||||||
if (avatarUrl) {
|
// el valor correcto (clave S3 o ruta local) en UserTenant. Enviar aquí la URL
|
||||||
updateData.avatar_url = avatarUrl;
|
// pública sobrescribiría esa clave y rompería el endpoint de servicio de imágenes.
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await authenticatedFetch(
|
const response = await authenticatedFetch(
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { Input } from '$lib/components/ui/input';
|
import { Input } from '$lib/components/ui/input';
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import { Textarea } from '$lib/components/ui/textarea';
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '$lib/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '$lib/components/ui/avatar';
|
||||||
import { getBackendAssetUrl } from '$lib/utils';
|
import { getBackendAssetUrl } from '$lib/utils';
|
||||||
@@ -120,7 +119,9 @@
|
|||||||
error = '';
|
error = '';
|
||||||
success = '';
|
success = '';
|
||||||
return async ({ update }) => {
|
return async ({ update }) => {
|
||||||
await update();
|
// reset:false evita que SvelteKit limpie el formulario nativo.
|
||||||
|
// update() ya llama a invalidateAll internamente.
|
||||||
|
await update({ reset: false });
|
||||||
saving = false;
|
saving = false;
|
||||||
};
|
};
|
||||||
}}
|
}}
|
||||||
@@ -200,22 +201,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2.5">
|
|
||||||
<Label for="email" class="text-sm font-medium">Email</Label>
|
|
||||||
<Input
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
type="email"
|
|
||||||
value={profile.email || ''}
|
|
||||||
placeholder="tu@email.com"
|
|
||||||
class="transition-colors"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
<p class="text-xs text-muted-foreground">
|
|
||||||
Cambiar el email puede requerir verificación
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-2.5">
|
<div class="space-y-2.5">
|
||||||
<Label for="phone" class="text-sm font-medium">Teléfono</Label>
|
<Label for="phone" class="text-sm font-medium">Teléfono</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -227,21 +212,6 @@
|
|||||||
class="transition-colors"
|
class="transition-colors"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2.5">
|
|
||||||
<Label for="bio" class="text-sm font-medium">Biografía</Label>
|
|
||||||
<Textarea
|
|
||||||
id="bio"
|
|
||||||
name="bio"
|
|
||||||
value={profile.bio || ''}
|
|
||||||
placeholder="Cuéntanos algo sobre ti..."
|
|
||||||
rows={4}
|
|
||||||
class="resize-none transition-colors"
|
|
||||||
/>
|
|
||||||
<p class="text-xs text-muted-foreground">
|
|
||||||
Máximo 500 caracteres
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -263,6 +233,13 @@
|
|||||||
<Input value={profile.id} disabled class="font-mono text-xs bg-muted/50" />
|
<Input value={profile.id} disabled class="font-mono text-xs bg-muted/50" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="space-y-2.5">
|
||||||
|
<Label class="text-sm font-medium">Email</Label>
|
||||||
|
<Input value={profile.email || ''} disabled class="bg-muted/50" />
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Para cambiar tu email accede a tu perfil en el portal de Aduanasoft.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user