feat: Implement user profile management with avatar upload and additional fields
- Added avatar_url, phone, bio, and preferences fields to UserTenant model. - Updated UserService to handle user profile updates and retrieval. - Created endpoints for getting and updating the current user's profile. - Implemented avatar upload functionality with validation in routes. - Enhanced frontend to manage user profile, including avatar preview and form handling. - Added server-side logic for loading and updating user profile data. - Updated .gitignore to include uploads directory.
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -59,4 +59,5 @@ node_modules/
|
||||
|
||||
# Docker
|
||||
*.dockerignore
|
||||
postgres-data/
|
||||
postgres-data/
|
||||
backend/uploads/avatars
|
||||
@@ -6,7 +6,14 @@ from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from core.database import Base
|
||||
from sqlalchemy import Boolean, ForeignKeyConstraint, String, UniqueConstraint
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
ForeignKeyConstraint,
|
||||
JSON,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -43,5 +50,19 @@ class UserTenant(Base, TenantScopedMixin, TimestampMixin):
|
||||
# Información adicional - Rol del usuario en este tenant (opcional)
|
||||
role: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||
|
||||
# Campos de perfil de usuario
|
||||
avatar_url: Mapped[Optional[str]] = mapped_column(
|
||||
String(500), nullable=True, comment="URL de la imagen de perfil"
|
||||
)
|
||||
phone: Mapped[Optional[str]] = mapped_column(
|
||||
String(20), nullable=True, comment="Teléfono del usuario"
|
||||
)
|
||||
bio: Mapped[Optional[str]] = mapped_column(
|
||||
Text, nullable=True, comment="Biografía del usuario"
|
||||
)
|
||||
preferences: Mapped[Optional[dict]] = mapped_column(
|
||||
JSON, nullable=True, comment="Preferencias del usuario (tema, idioma, etc.)"
|
||||
)
|
||||
|
||||
# Relación con Tenant
|
||||
tenant: Mapped["Tenant"] = relationship("Tenant", back_populates="user_relations")
|
||||
|
||||
@@ -33,6 +33,14 @@ class UpdateUserRequestDTO(BaseModel):
|
||||
email_verified: Optional[bool] = None
|
||||
role: Optional[str] = Field(None, description="Rol del usuario en el tenant actual")
|
||||
|
||||
# Campos de perfil
|
||||
avatar_url: Optional[str] = Field(
|
||||
None, max_length=500, description="URL del avatar"
|
||||
)
|
||||
phone: Optional[str] = Field(None, max_length=20, description="Teléfono")
|
||||
bio: Optional[str] = Field(None, description="Biografía")
|
||||
preferences: Optional[dict] = Field(None, description="Preferencias del usuario")
|
||||
|
||||
@field_validator("first_name", "last_name", "email")
|
||||
@classmethod
|
||||
def validate_non_empty_string(cls, v: Optional[str]) -> Optional[str]:
|
||||
@@ -55,6 +63,14 @@ class UserResponseDTO(BaseModel):
|
||||
created_timestamp: Optional[int] = None
|
||||
role: Optional[str] = Field(None, description="Rol del usuario en el tenant actual")
|
||||
|
||||
# Campos de perfil
|
||||
avatar_url: Optional[str] = Field(None, description="URL del avatar")
|
||||
phone: Optional[str] = Field(None, description="Teléfono")
|
||||
bio: Optional[str] = Field(None, description="Biografía")
|
||||
preferences: Optional[dict] = Field(
|
||||
default_factory=dict, description="Preferencias"
|
||||
)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@@ -3,10 +3,13 @@ Rutas para gestión de usuarios de Keycloak
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user, get_tenant_from_token
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..user_tenant.models import UserTenant
|
||||
@@ -139,6 +142,7 @@ def update_user(
|
||||
- Estado (habilitado/deshabilitado)
|
||||
- Verificación de email
|
||||
- Rol en el tenant
|
||||
- Perfil (avatar, teléfono, bio, preferencias)
|
||||
"""
|
||||
user = service.update_user(
|
||||
user_id=user_id,
|
||||
@@ -148,6 +152,10 @@ def update_user(
|
||||
enabled=data.enabled,
|
||||
email_verified=data.email_verified,
|
||||
role=data.role,
|
||||
avatar_url=data.avatar_url,
|
||||
phone=data.phone,
|
||||
bio=data.bio,
|
||||
preferences=data.preferences,
|
||||
)
|
||||
return user
|
||||
|
||||
@@ -185,3 +193,122 @@ def change_user_password(
|
||||
"""
|
||||
service.change_password(user_id, data.password, data.temporary)
|
||||
return {"message": "Password changed successfully"}
|
||||
|
||||
|
||||
# === Endpoints de Perfil del Usuario Actual ===
|
||||
|
||||
|
||||
@router.get("/me/profile", response_model=UserResponseDTO)
|
||||
def get_my_profile(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""
|
||||
Obtiene el perfil completo del usuario actual
|
||||
Incluye datos de Keycloak y datos de perfil (avatar, bio, etc.)
|
||||
"""
|
||||
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 service.get_current_user_profile(keycloak_user_id)
|
||||
|
||||
|
||||
@router.put("/me/profile", response_model=UserResponseDTO)
|
||||
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
|
||||
|
||||
Puede actualizar:
|
||||
- Datos de Keycloak: nombre, apellido, email
|
||||
- Datos de perfil: avatar, teléfono, biografía, preferencias
|
||||
"""
|
||||
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 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
|
||||
Retorna la URL del avatar subido
|
||||
"""
|
||||
# Validar tipo de archivo
|
||||
if not file.content_type or not file.content_type.startswith("image/"):
|
||||
raise HTTPException(status_code=400, detail="El archivo debe ser una imagen")
|
||||
|
||||
# Validar tamaño (max 2MB)
|
||||
contents = await file.read()
|
||||
if len(contents) > 2 * 1024 * 1024:
|
||||
raise HTTPException(status_code=400, detail="La imagen debe ser menor a 2MB")
|
||||
|
||||
# Crear directorio si no existe
|
||||
upload_dir = Path("/app/uploads/avatars")
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Generar nombre con keycloak_user_id (sobrescribe si existe)
|
||||
keycloak_user_id = current_user.get("sub")
|
||||
ext = Path(file.filename or "image.jpg").suffix
|
||||
filename = f"{keycloak_user_id}{ext}"
|
||||
file_path = upload_dir / filename
|
||||
|
||||
# Guardar archivo
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(contents)
|
||||
|
||||
# Retornar URL relativa
|
||||
avatar_url = f"/uploads/avatars/{filename}"
|
||||
|
||||
return {"avatar_url": avatar_url}
|
||||
|
||||
@@ -20,14 +20,16 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_keycloak_user(
|
||||
user_data: Dict[str, Any], role: Optional[str] = None
|
||||
user_data: Dict[str, Any],
|
||||
role: Optional[str] = None,
|
||||
user_tenant: Optional[Any] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Normaliza los datos de usuario de Keycloak al formato esperado por el DTO
|
||||
|
||||
Keycloak usa camelCase, nuestro DTO usa snake_case
|
||||
"""
|
||||
return {
|
||||
normalized = {
|
||||
"id": user_data.get("id"),
|
||||
"username": user_data.get("username", ""),
|
||||
"email": user_data.get("email", ""),
|
||||
@@ -39,6 +41,19 @@ def _normalize_keycloak_user(
|
||||
"role": role,
|
||||
}
|
||||
|
||||
# Agregar campos de perfil si user_tenant está disponible
|
||||
if user_tenant:
|
||||
normalized.update(
|
||||
{
|
||||
"avatar_url": user_tenant.avatar_url,
|
||||
"phone": user_tenant.phone,
|
||||
"bio": user_tenant.bio,
|
||||
"preferences": user_tenant.preferences or {},
|
||||
}
|
||||
)
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
class UserService:
|
||||
"""Servicio para gestionar usuarios en Keycloak"""
|
||||
@@ -197,7 +212,7 @@ class UserService:
|
||||
# Obtener información completa del usuario
|
||||
user_info = self.keycloak_admin.get_user(user_id)
|
||||
|
||||
return _normalize_keycloak_user(user_info, role)
|
||||
return _normalize_keycloak_user(user_info, role, user_tenant)
|
||||
|
||||
except KeycloakError as e:
|
||||
logger.error(f"Keycloak error creating user: {str(e)}")
|
||||
@@ -257,7 +272,7 @@ class UserService:
|
||||
for ut in user_tenants:
|
||||
try:
|
||||
user_info = self.keycloak_admin.get_user(ut.keycloak_user_id)
|
||||
normalized_user = _normalize_keycloak_user(user_info, ut.role)
|
||||
normalized_user = _normalize_keycloak_user(user_info, ut.role, ut)
|
||||
|
||||
# Filtrar por búsqueda si se proporciona
|
||||
if search:
|
||||
@@ -316,7 +331,7 @@ class UserService:
|
||||
|
||||
try:
|
||||
user_info = self.keycloak_admin.get_user(user_id)
|
||||
return _normalize_keycloak_user(user_info, user_tenant.role)
|
||||
return _normalize_keycloak_user(user_info, user_tenant.role, user_tenant)
|
||||
except KeycloakError as e:
|
||||
logger.error(f"Error getting user from Keycloak: {str(e)}")
|
||||
raise HTTPException(status_code=404, detail="User not found in Keycloak")
|
||||
@@ -330,6 +345,10 @@ class UserService:
|
||||
enabled: Optional[bool] = None,
|
||||
email_verified: Optional[bool] = None,
|
||||
role: Optional[str] = None,
|
||||
avatar_url: Optional[str] = None,
|
||||
phone: Optional[str] = None,
|
||||
bio: Optional[str] = None,
|
||||
preferences: Optional[dict] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Actualiza información de un usuario"""
|
||||
# Verificar que el usuario pertenece al tenant
|
||||
@@ -365,15 +384,25 @@ class UserService:
|
||||
if update_data:
|
||||
self.keycloak_admin.update_user(user_id, update_data)
|
||||
|
||||
# Actualizar rol en UserTenant si se proporciona
|
||||
# Actualizar campos en UserTenant
|
||||
if role is not None:
|
||||
user_tenant.role = role
|
||||
self.db.commit()
|
||||
if avatar_url is not None:
|
||||
user_tenant.avatar_url = avatar_url
|
||||
if phone is not None:
|
||||
user_tenant.phone = phone
|
||||
if bio is not None:
|
||||
user_tenant.bio = bio
|
||||
if preferences is not None:
|
||||
user_tenant.preferences = preferences
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(user_tenant)
|
||||
|
||||
# Obtener información actualizada
|
||||
user_info = self.keycloak_admin.get_user(user_id)
|
||||
|
||||
return _normalize_keycloak_user(user_info, user_tenant.role)
|
||||
return _normalize_keycloak_user(user_info, user_tenant.role, user_tenant)
|
||||
|
||||
except KeycloakError as e:
|
||||
logger.error(f"Error updating user in Keycloak: {str(e)}")
|
||||
@@ -495,3 +524,94 @@ class UserService:
|
||||
"users_available": users_available,
|
||||
"usage_percentage": round(usage_percentage, 2),
|
||||
}
|
||||
|
||||
def get_current_user_profile(self, keycloak_user_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Obtiene el perfil completo del usuario actual
|
||||
Combina datos de Keycloak con datos de UserTenant
|
||||
"""
|
||||
user_tenant = (
|
||||
self.db.query(UserTenant)
|
||||
.filter(
|
||||
and_(
|
||||
UserTenant.keycloak_user_id == keycloak_user_id,
|
||||
UserTenant.is_active == True,
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not user_tenant:
|
||||
raise HTTPException(status_code=404, detail="User profile not found")
|
||||
|
||||
try:
|
||||
user_info = self.keycloak_admin.get_user(keycloak_user_id)
|
||||
return _normalize_keycloak_user(user_info, user_tenant.role, user_tenant)
|
||||
except KeycloakError as e:
|
||||
logger.error(f"Error getting user from Keycloak: {str(e)}")
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
def update_current_user_profile(
|
||||
self,
|
||||
keycloak_user_id: str,
|
||||
first_name: Optional[str] = None,
|
||||
last_name: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
avatar_url: Optional[str] = None,
|
||||
phone: Optional[str] = None,
|
||||
bio: Optional[str] = None,
|
||||
preferences: Optional[dict] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Actualiza el perfil del usuario actual
|
||||
"""
|
||||
user_tenant = (
|
||||
self.db.query(UserTenant)
|
||||
.filter(
|
||||
and_(
|
||||
UserTenant.keycloak_user_id == keycloak_user_id,
|
||||
UserTenant.is_active == True,
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not user_tenant:
|
||||
raise HTTPException(status_code=404, detail="User profile not found")
|
||||
|
||||
try:
|
||||
# Actualizar Keycloak
|
||||
update_data = {}
|
||||
if first_name is not None:
|
||||
update_data["firstName"] = first_name
|
||||
if last_name is not None:
|
||||
update_data["lastName"] = last_name
|
||||
if email is not None:
|
||||
update_data["email"] = email
|
||||
|
||||
if update_data:
|
||||
self.keycloak_admin.update_user(keycloak_user_id, update_data)
|
||||
|
||||
# Actualizar campos de perfil en UserTenant
|
||||
if avatar_url is not None:
|
||||
user_tenant.avatar_url = avatar_url
|
||||
if phone is not None:
|
||||
user_tenant.phone = phone
|
||||
if bio is not None:
|
||||
user_tenant.bio = bio
|
||||
if preferences is not None:
|
||||
user_tenant.preferences = preferences
|
||||
|
||||
self.db.commit()
|
||||
self.db.refresh(user_tenant)
|
||||
|
||||
# Retornar perfil actualizado
|
||||
user_info = self.keycloak_admin.get_user(keycloak_user_id)
|
||||
return _normalize_keycloak_user(user_info, user_tenant.role, user_tenant)
|
||||
|
||||
except KeycloakError as e:
|
||||
logger.error(f"Error updating user profile: {str(e)}")
|
||||
self.db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Error updating profile: {str(e)}"
|
||||
)
|
||||
|
||||
@@ -28,7 +28,13 @@ class TenantMiddleware(BaseHTTPMiddleware):
|
||||
# Rutas públicas que no requieren tenant
|
||||
# Permitir acceso sin autenticación a rutas de documentación y salud
|
||||
doc_prefixes = ["/api/redoc", "/api/openapi.json"]
|
||||
public_prefixes = ["/api/v1/auth", "/api/v1/status", "/api/health", "/api/"]
|
||||
public_prefixes = [
|
||||
"/api/v1/auth",
|
||||
"/api/v1/status",
|
||||
"/api/health",
|
||||
"/api/",
|
||||
"/uploads",
|
||||
]
|
||||
|
||||
path = request.url.path
|
||||
# Permitir cualquier subruta de docs/redoc/openapi.json (por ejemplo, /api/docs, /api/docs/, /api/docs/oauth2-redirect)
|
||||
|
||||
@@ -18,6 +18,8 @@ from fastapi import FastAPI, Request, status, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pathlib import Path
|
||||
|
||||
# Importar modelos para registrar con SQLAlchemy
|
||||
from api.v1.modules.a76.items.models import Item
|
||||
@@ -98,6 +100,11 @@ if settings.DEBUG:
|
||||
app.add_middleware(LicenseValidationMiddleware)
|
||||
app.add_middleware(TenantMiddleware)
|
||||
|
||||
# Crear directorio de uploads si no existe y montar archivos estáticos
|
||||
uploads_dir = Path("/app/uploads")
|
||||
uploads_dir.mkdir(parents=True, exist_ok=True)
|
||||
app.mount("/uploads", StaticFiles(directory=str(uploads_dir)), name="uploads")
|
||||
|
||||
# Registrar routers
|
||||
app.include_router(api_v1_router, prefix="/api/v1")
|
||||
|
||||
|
||||
@@ -88,8 +88,7 @@ export async function saveInvoice(options: SaveInvoiceOptions): Promise<SaveInvo
|
||||
} else {
|
||||
// Actualizar factura existente con todos sus sub-recursos
|
||||
const updatePayload = { ...payload, id: invoiceId } as UpdateInvoiceData;
|
||||
const response = await invoicesApi.update(invoiceId!, companyId, updatePayload);
|
||||
console.log('Update response:', response);
|
||||
const response = await invoicesApi.update(invoiceId!, companyId, updatePayload);
|
||||
if (response.error) {
|
||||
const error: any = new Error(response.error);
|
||||
error.validationErrors = response.validationErrors;
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
? {
|
||||
name: userData.name || userData.preferred_username || "",
|
||||
email: userData.email || "",
|
||||
// avatar: "/avatars/default.jpg", // Puedes agregar avatar desde Keycloak si está disponible
|
||||
avatar: userData.avatar_url || "/avatars/default.jpg",
|
||||
}
|
||||
: sidebarData.user,
|
||||
});
|
||||
|
||||
@@ -7,18 +7,22 @@
|
||||
import BellIcon from "@lucide/svelte/icons/bell";
|
||||
import ChevronsUpDownIcon from "@lucide/svelte/icons/chevrons-up-down";
|
||||
import CreditCardIcon from "@lucide/svelte/icons/credit-card";
|
||||
import LogOutIcon from "@lucide/svelte/icons/log-out";
|
||||
import SparklesIcon from "@lucide/svelte/icons/sparkles";
|
||||
import LogOutIcon from "@lucide/svelte/icons/log-out";
|
||||
import LanguagesIcon from "@lucide/svelte/icons/languages";
|
||||
import MoonIcon from "@lucide/svelte/icons/moon";
|
||||
import SunIcon from "@lucide/svelte/icons/sun";
|
||||
import { logout } from "$lib/auth";
|
||||
import { cookieName } from "$lib/paraglide/runtime";
|
||||
import { page } from "$app/state";
|
||||
import { goto } from "$app/navigation";
|
||||
import { browser } from "$app/environment";
|
||||
import { getBackendAssetUrl } from "$lib/utils";
|
||||
|
||||
let { user }: { user: { name: string; email: string; avatar: string } } = $props();
|
||||
const sidebar = useSidebar();
|
||||
|
||||
// URL completa del avatar
|
||||
let avatarUrl = $derived(getBackendAssetUrl(user.avatar) || '/avatars/default.jpg');
|
||||
|
||||
// Estado reactivo del idioma actual
|
||||
let currentLocale = $derived(page.data.locale || 'en');
|
||||
@@ -51,6 +55,10 @@
|
||||
await logout();
|
||||
}
|
||||
|
||||
function navigateToAccount() {
|
||||
goto('/dashboard/account');
|
||||
}
|
||||
|
||||
function toggleLanguage() {
|
||||
if (!browser) return;
|
||||
|
||||
@@ -98,7 +106,7 @@
|
||||
{...props}
|
||||
>
|
||||
<Avatar.Root class="size-8 rounded-lg">
|
||||
<Avatar.Image src={user.avatar} alt={user.name} />
|
||||
<Avatar.Image src={avatarUrl} alt={user.name} />
|
||||
<Avatar.Fallback class="rounded-lg">AS</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
@@ -118,8 +126,8 @@
|
||||
<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={user.avatar} alt={user.name} />
|
||||
<Avatar.Fallback class="rounded-lg">CN</Avatar.Fallback>
|
||||
<Avatar.Image src={avatarUrl} alt={user.name} />
|
||||
<Avatar.Fallback class="rounded-lg">AS</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-medium">{user.name}</span>
|
||||
@@ -129,14 +137,7 @@
|
||||
</DropdownMenu.Label>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.Item>
|
||||
<SparklesIcon />
|
||||
Upgrade to Pro
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Group>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={navigateToAccount}>
|
||||
<BadgeCheckIcon />
|
||||
Account
|
||||
</DropdownMenu.Item>
|
||||
|
||||
@@ -156,7 +156,7 @@ export async function authenticatedFetch(
|
||||
}
|
||||
|
||||
// Construir URL completa
|
||||
const url = endpoint.startsWith('http') ? endpoint : `${baseUrl}${endpoint}`;
|
||||
const url = endpoint.startsWith('http') ? endpoint : `${baseUrl}${endpoint}`;
|
||||
|
||||
// Crear AbortController para timeout
|
||||
const controller = new AbortController();
|
||||
@@ -166,7 +166,12 @@ export async function authenticatedFetch(
|
||||
}, timeout);
|
||||
|
||||
// Realizar la petición inicial
|
||||
const headers = createAuthHeaders(accessToken, options.headers as Record<string, string>);
|
||||
// Si el body es FormData, no incluir Content-Type (el navegador lo establece con el boundary)
|
||||
const isFormData = options.body instanceof FormData;
|
||||
const headers = isFormData
|
||||
? { 'Authorization': `Bearer ${accessToken}`, ...(options.headers as Record<string, string> || {}) }
|
||||
: createAuthHeaders(accessToken, options.headers as Record<string, string>);
|
||||
|
||||
let response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
@@ -187,7 +192,11 @@ export async function authenticatedFetch(
|
||||
newController.abort();
|
||||
}, timeout);
|
||||
|
||||
const newHeaders = createAuthHeaders(newToken, options.headers as Record<string, string>);
|
||||
// Si el body es FormData, no incluir Content-Type
|
||||
const newHeaders = isFormData
|
||||
? { 'Authorization': `Bearer ${newToken}`, ...(options.headers as Record<string, string> || {}) }
|
||||
: createAuthHeaders(newToken, options.headers as Record<string, string>);
|
||||
|
||||
response = await fetch(url, {
|
||||
...options,
|
||||
headers: newHeaders,
|
||||
@@ -247,7 +256,33 @@ export async function validateAuth(
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
const keycloakData = await response.json();
|
||||
|
||||
// Obtener perfil adicional del usuario (avatar, bio, etc.)
|
||||
try {
|
||||
const profileResponse = await authenticatedFetch(
|
||||
'v1/core/users/me/profile',
|
||||
{},
|
||||
cookies,
|
||||
fetch
|
||||
);
|
||||
|
||||
if (profileResponse.ok) {
|
||||
const profileData = await profileResponse.json();
|
||||
// Combinar datos de Keycloak con datos del perfil
|
||||
return {
|
||||
...keycloakData,
|
||||
avatar_url: profileData.avatar_url,
|
||||
phone: profileData.phone,
|
||||
bio: profileData.bio,
|
||||
preferences: profileData.preferences
|
||||
};
|
||||
}
|
||||
} catch (profileError) {
|
||||
console.warn('⚠️ [API] No se pudo cargar el perfil del usuario, usando solo datos de Keycloak');
|
||||
}
|
||||
|
||||
return keycloakData;
|
||||
} catch (error) {
|
||||
// Si es un redirect, re-lanzarlo
|
||||
if (error && typeof error === 'object' && 'status' in error && 'location' in error) {
|
||||
|
||||
@@ -3,6 +3,29 @@ import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
};
|
||||
|
||||
/**
|
||||
* Convierte una ruta relativa del backend en una URL completa
|
||||
* @param path Ruta relativa (ej: "/uploads/avatars/file.png")
|
||||
* @returns URL completa del backend (ej: "http://localhost:8000/uploads/avatars/file.png")
|
||||
*/
|
||||
export function getBackendAssetUrl(path: string | null | undefined): string {
|
||||
if (!path) return '';
|
||||
|
||||
// Si ya es una URL completa, retornarla tal cual
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||
return path;
|
||||
}
|
||||
|
||||
// Eliminar la / inicial si existe para evitar //
|
||||
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
|
||||
|
||||
// Obtener la base URL del API sin el sufijo /api
|
||||
let baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000';
|
||||
baseUrl = baseUrl.replace(/\/api\/?$/, ''); // Eliminar /api o /api/ del final
|
||||
|
||||
return `${baseUrl}/${cleanPath}`;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
130
frontend/src/routes/dashboard/account/+page.server.ts
Normal file
130
frontend/src/routes/dashboard/account/+page.server.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Server-side load y actions para gestión de perfil de usuario
|
||||
*/
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { authenticatedFetch } from '$lib/server/api';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, fetch }) => {
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
'v1/core/users/me/profile',
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
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 = {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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 (value && value !== '') {
|
||||
updateData[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Agregar avatar_url si se subió exitosamente
|
||||
if (avatarUrl) {
|
||||
updateData.avatar_url = avatarUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await authenticatedFetch(
|
||||
'v1/core/users/me/profile',
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(updateData)
|
||||
},
|
||||
cookies,
|
||||
fetch,
|
||||
'/login'
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
return fail(response.status, {
|
||||
error: errorData.detail || 'Error al actualizar el perfil',
|
||||
values: updateData
|
||||
});
|
||||
}
|
||||
|
||||
const updatedProfile = await response.json();
|
||||
return {
|
||||
success: true,
|
||||
profile: updatedProfile
|
||||
};
|
||||
} catch (err: any) {
|
||||
console.error('Error updating profile:', err);
|
||||
return fail(500, {
|
||||
error: 'Error al actualizar el perfil',
|
||||
values: updateData
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
306
frontend/src/routes/dashboard/account/+page.svelte
Normal file
306
frontend/src/routes/dashboard/account/+page.svelte
Normal file
@@ -0,0 +1,306 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
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 { Avatar, AvatarFallback, AvatarImage } from '$lib/components/ui/avatar';
|
||||
import { getBackendAssetUrl } from '$lib/utils';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
// State derivado de los datos del servidor
|
||||
let profile = $derived(data.profile);
|
||||
let serverError = $derived(data.error);
|
||||
|
||||
// State local
|
||||
let saving = $state(false);
|
||||
let success = $state('');
|
||||
let error = $state('');
|
||||
let avatarFile = $state<File | null>(null);
|
||||
let avatarPreview = $state('');
|
||||
|
||||
// Effect para manejar errores del servidor
|
||||
$effect(() => {
|
||||
if (serverError) {
|
||||
error = serverError;
|
||||
}
|
||||
});
|
||||
|
||||
// Effect para manejar respuesta del form action
|
||||
$effect(() => {
|
||||
if (form?.success) {
|
||||
success = 'Perfil actualizado exitosamente';
|
||||
avatarPreview = '';
|
||||
avatarFile = null;
|
||||
setTimeout(() => {
|
||||
success = '';
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
if (form?.error) {
|
||||
error = form.error;
|
||||
}
|
||||
});
|
||||
|
||||
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] || '';
|
||||
const last = profile.last_name?.[0] || '';
|
||||
return (first + last).toUpperCase() || profile.username?.[0]?.toUpperCase() || '?';
|
||||
}
|
||||
|
||||
let currentAvatarUrl = $derived(
|
||||
avatarPreview || getBackendAssetUrl(profile?.avatar_url) || ''
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto py-8 px-4 max-w-5xl">
|
||||
<div class="mb-8 space-y-1">
|
||||
<h1 class="text-4xl font-bold tracking-tight">Configuración de Cuenta</h1>
|
||||
<p class="text-muted-foreground text-lg">
|
||||
Gestiona tu información personal y preferencias
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if !profile && serverError}
|
||||
<Card>
|
||||
<CardContent class="py-12">
|
||||
<div class="text-center text-destructive">
|
||||
<p class="font-medium">{serverError}</p>
|
||||
<Button class="mt-4" onclick={() => window.location.reload()}>
|
||||
Reintentar
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{:else if profile}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/updateProfile"
|
||||
enctype="multipart/form-data"
|
||||
use:enhance={({ formData }) => {
|
||||
|
||||
// Agregar archivo si existe
|
||||
if (avatarFile) {
|
||||
formData.append('avatar', avatarFile);
|
||||
}
|
||||
|
||||
saving = true;
|
||||
error = '';
|
||||
success = '';
|
||||
return async ({ update }) => {
|
||||
await update();
|
||||
saving = false;
|
||||
};
|
||||
}}
|
||||
>
|
||||
<div class="space-y-6 pb-24">
|
||||
<!-- Profile Picture -->
|
||||
<Card class="transition-shadow hover:shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle class="text-xl">Foto de Perfil</CardTitle>
|
||||
<CardDescription>Actualiza tu imagen de perfil</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} />
|
||||
<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" />
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Personal Information -->
|
||||
<Card class="transition-shadow hover:shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle class="text-xl">Información Personal</CardTitle>
|
||||
<CardDescription>Actualiza tus datos personales</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-2.5">
|
||||
<Label for="first_name" class="text-sm font-medium">Nombre</Label>
|
||||
<Input
|
||||
id="first_name"
|
||||
name="first_name"
|
||||
value={profile.first_name || ''}
|
||||
placeholder="Tu nombre"
|
||||
class="transition-colors"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2.5">
|
||||
<Label for="last_name" class="text-sm font-medium">Apellido</Label>
|
||||
<Input
|
||||
id="last_name"
|
||||
name="last_name"
|
||||
value={profile.last_name || ''}
|
||||
placeholder="Tu apellido"
|
||||
class="transition-colors"
|
||||
required
|
||||
/>
|
||||
</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">
|
||||
<Label for="phone" class="text-sm font-medium">Teléfono</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
value={profile.phone || ''}
|
||||
placeholder="+52 123 456 7890"
|
||||
class="transition-colors"
|
||||
/>
|
||||
</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>
|
||||
</Card>
|
||||
|
||||
<!-- Account Information (Read-only) -->
|
||||
<Card class="transition-shadow hover:shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle class="text-xl">Información de Cuenta</CardTitle>
|
||||
<CardDescription>Datos de tu cuenta en el sistema</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-2.5">
|
||||
<Label class="text-sm font-medium">Nombre de Usuario</Label>
|
||||
<Input value={profile.username} disabled class="bg-muted/50" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2.5">
|
||||
<Label class="text-sm font-medium">ID de Usuario</Label>
|
||||
<Input value={profile.id} disabled class="font-mono text-xs bg-muted/50" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Messages -->
|
||||
{#if error}
|
||||
<div class="bg-destructive/15 text-destructive px-5 py-4 rounded-lg border border-destructive/20 shadow-sm">
|
||||
<p class="text-sm font-medium">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if success}
|
||||
<div class="bg-green-50 text-green-800 px-5 py-4 rounded-lg border border-green-200 shadow-sm">
|
||||
<p class="text-sm font-medium">{success}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Actions - Sticky Footer -->
|
||||
<div class="fixed bottom-0 left-0 right-0 md:left-64 bg-background/95 backdrop-blur-sm border-t p-4 z-50">
|
||||
<div class="max-w-5xl mx-auto flex flex-col sm:flex-row justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onclick={() => window.location.reload()}
|
||||
disabled={saving}
|
||||
class="w-full sm:w-auto transition-all"
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
class="w-full sm:w-auto transition-all shadow-lg shadow-primary/20"
|
||||
>
|
||||
{saving ? 'Guardando...' : 'Guardar Cambios'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user