From c00107d921315d30cebd7614745c242646b24c74 Mon Sep 17 00:00:00 2001 From: acazares Date: Tue, 13 Jan 2026 12:37:11 -0600 Subject: [PATCH] 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. --- .gitignore | 3 +- .../api/v1/modules/core/user_tenant/models.py | 23 +- backend/api/v1/modules/core/users/dto.py | 16 + backend/api/v1/modules/core/users/routes.py | 129 +++++++- backend/api/v1/modules/core/users/service.py | 136 +++++++- backend/core/middleware.py | 8 +- backend/main.py | 7 + .../dashboard/invoices/edit/save-invoice.ts | 3 +- .../lib/components/sidebar/app-sidebar.svelte | 2 +- .../lib/components/sidebar/nav-user.svelte | 27 +- frontend/src/lib/server/api.ts | 43 ++- frontend/src/lib/utils.ts | 23 ++ .../routes/dashboard/account/+page.server.ts | 130 ++++++++ .../src/routes/dashboard/account/+page.svelte | 306 ++++++++++++++++++ 14 files changed, 824 insertions(+), 32 deletions(-) create mode 100644 frontend/src/routes/dashboard/account/+page.server.ts create mode 100644 frontend/src/routes/dashboard/account/+page.svelte diff --git a/.gitignore b/.gitignore index 17d85cdc..911dde10 100644 --- a/.gitignore +++ b/.gitignore @@ -59,4 +59,5 @@ node_modules/ # Docker *.dockerignore -postgres-data/ \ No newline at end of file +postgres-data/ +backend/uploads/avatars \ No newline at end of file diff --git a/backend/api/v1/modules/core/user_tenant/models.py b/backend/api/v1/modules/core/user_tenant/models.py index 879f3dd1..9fe82c4d 100644 --- a/backend/api/v1/modules/core/user_tenant/models.py +++ b/backend/api/v1/modules/core/user_tenant/models.py @@ -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") diff --git a/backend/api/v1/modules/core/users/dto.py b/backend/api/v1/modules/core/users/dto.py index 5e707531..d472a423 100644 --- a/backend/api/v1/modules/core/users/dto.py +++ b/backend/api/v1/modules/core/users/dto.py @@ -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 diff --git a/backend/api/v1/modules/core/users/routes.py b/backend/api/v1/modules/core/users/routes.py index 4c5b83b9..773773f9 100644 --- a/backend/api/v1/modules/core/users/routes.py +++ b/backend/api/v1/modules/core/users/routes.py @@ -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} diff --git a/backend/api/v1/modules/core/users/service.py b/backend/api/v1/modules/core/users/service.py index 5325a0a3..6d2906f0 100644 --- a/backend/api/v1/modules/core/users/service.py +++ b/backend/api/v1/modules/core/users/service.py @@ -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)}" + ) diff --git a/backend/core/middleware.py b/backend/core/middleware.py index f5d0df8a..0ed55a15 100644 --- a/backend/core/middleware.py +++ b/backend/core/middleware.py @@ -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) diff --git a/backend/main.py b/backend/main.py index c567450d..e5c1b936 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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") diff --git a/frontend/src/lib/components/dashboard/invoices/edit/save-invoice.ts b/frontend/src/lib/components/dashboard/invoices/edit/save-invoice.ts index 9660f5b3..c1a4bd98 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/save-invoice.ts +++ b/frontend/src/lib/components/dashboard/invoices/edit/save-invoice.ts @@ -88,8 +88,7 @@ export async function saveInvoice(options: SaveInvoiceOptions): Promise - + AS
@@ -118,8 +126,8 @@
- - CN + + AS
{user.name} @@ -129,14 +137,7 @@ - - - Upgrade to Pro - - - - - + Account diff --git a/frontend/src/lib/server/api.ts b/frontend/src/lib/server/api.ts index d40b5594..ccbfdfdb 100644 --- a/frontend/src/lib/server/api.ts +++ b/frontend/src/lib/server/api.ts @@ -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); + // 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 || {}) } + : createAuthHeaders(accessToken, options.headers as Record); + 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); + // Si el body es FormData, no incluir Content-Type + const newHeaders = isFormData + ? { 'Authorization': `Bearer ${newToken}`, ...(options.headers as Record || {}) } + : createAuthHeaders(newToken, options.headers as Record); + 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) { diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 55b3a918..b273ec3e 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -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 diff --git a/frontend/src/routes/dashboard/account/+page.server.ts b/frontend/src/routes/dashboard/account/+page.server.ts new file mode 100644 index 00000000..fd8b8e13 --- /dev/null +++ b/frontend/src/routes/dashboard/account/+page.server.ts @@ -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 = {}; + + 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 + }); + } + } +}; diff --git a/frontend/src/routes/dashboard/account/+page.svelte b/frontend/src/routes/dashboard/account/+page.svelte new file mode 100644 index 00000000..f3eaa5bf --- /dev/null +++ b/frontend/src/routes/dashboard/account/+page.svelte @@ -0,0 +1,306 @@ + + +
+
+

Configuración de Cuenta

+

+ Gestiona tu información personal y preferencias +

+
+ + {#if !profile && serverError} + + +
+

{serverError}

+ +
+
+
+ {:else if profile} +
{ + + // Agregar archivo si existe + if (avatarFile) { + formData.append('avatar', avatarFile); + } + + saving = true; + error = ''; + success = ''; + return async ({ update }) => { + await update(); + saving = false; + }; + }} + > +
+ + + + Foto de Perfil + Actualiza tu imagen de perfil + + +
+
+ +
+ +
+
+ +

+ Haz clic en la imagen o selecciona un archivo. JPG, PNG o GIF. Máximo 2MB. +

+
+
+
+
+
+ + + + + Información Personal + Actualiza tus datos personales + + +
+
+ + +
+ +
+ + +
+
+ +
+ + +

+ Cambiar el email puede requerir verificación +

+
+ +
+ + +
+ +
+ +