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:
2026-01-13 12:37:11 -06:00
parent dc6325a0f2
commit c00107d921
14 changed files with 824 additions and 32 deletions

3
.gitignore vendored
View File

@@ -59,4 +59,5 @@ node_modules/
# Docker
*.dockerignore
postgres-data/
postgres-data/
backend/uploads/avatars

View File

@@ -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")

View File

@@ -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

View File

@@ -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}

View File

@@ -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)}"
)

View File

@@ -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)

View File

@@ -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")

View File

@@ -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;

View File

@@ -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,
});

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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

View 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
});
}
}
};

View 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>