feat: add MinIO image server and profile photo upload

feat: add MinIO image server and profile photo upload
This commit is contained in:
Gabriel Menezes 2026-05-21 18:24:37 -04:00 committed by GitHub
commit 5a2073cba9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 420 additions and 38 deletions

View file

@ -118,12 +118,17 @@ jobs:
MP_JWT_VERIFY_PUBLICKEY: ${{ secrets.MP_JWT_VERIFY_PUBLICKEY }}
GHCR_USERNAME: ${{ secrets.GHCR_USERNAME }}
GHCR_TOKEN: ${{ secrets.GHCR_READ_TOKEN }}
MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }}
MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }}
MINIO_ENDPOINT: ${{ secrets.MINIO_ENDPOINT }}
MINIO_PUBLIC_URL: ${{ secrets.MINIO_PUBLIC_URL }}
MINIO_BUCKET: ${{ secrets.MINIO_BUCKET }}
with:
host: ${{ secrets.HOSTINGER_HOST }}
username: ${{ secrets.HOSTINGER_USER }}
key: ${{ secrets.HOSTINGER_SSH_KEY }}
port: ${{ secrets.HOSTINGER_PORT || 22 }}
envs: FRONTEND_IMAGE,BACKEND_IMAGE,POSTGRES_USER,POSTGRES_PASSWORD,DB_URL,MP_JWT_VERIFY_ISSUER,SMALLRYE_JWT_SIGN_KEY,MP_JWT_VERIFY_PUBLICKEY,GHCR_USERNAME,GHCR_TOKEN
envs: FRONTEND_IMAGE,BACKEND_IMAGE,POSTGRES_USER,POSTGRES_PASSWORD,DB_URL,MP_JWT_VERIFY_ISSUER,SMALLRYE_JWT_SIGN_KEY,MP_JWT_VERIFY_PUBLICKEY,GHCR_USERNAME,GHCR_TOKEN,MINIO_ROOT_USER,MINIO_ROOT_PASSWORD,MINIO_ENDPOINT,MINIO_PUBLIC_URL,MINIO_BUCKET
script: |
set -eu
APP_DIR="${{ secrets.HOSTINGER_APP_PATH }}"
@ -137,6 +142,11 @@ jobs:
echo "FRONTEND_IMAGE=${FRONTEND_IMAGE}"
echo "BACKEND_IMAGE=${BACKEND_IMAGE}"
echo "MP_JWT_VERIFY_ISSUER=${MP_JWT_VERIFY_ISSUER}"
echo "MINIO_ROOT_USER=${MINIO_ROOT_USER}"
echo "MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}"
echo "MINIO_ENDPOINT=${MINIO_ENDPOINT}"
echo "MINIO_PUBLIC_URL=${MINIO_PUBLIC_URL}"
echo "MINIO_BUCKET=${MINIO_BUCKET}"
printf 'SMALLRYE_JWT_SIGN_KEY="%s"\n' "$SMALLRYE_JWT_SIGN_KEY"
printf 'MP_JWT_VERIFY_PUBLICKEY="%s"\n' "$MP_JWT_VERIFY_PUBLICKEY"

View file

@ -79,6 +79,11 @@
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-orm</artifactId>
</dependency>
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.5.17</version>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-junit</artifactId>

View file

@ -8,6 +8,8 @@ import net.beetechgroup.beetask.usecase.auth.refresh.RefreshTokenUseCase;
import net.beetechgroup.beetask.usecase.user.create.CreateUserUseCase;
import net.beetechgroup.beetask.usecase.user.profile.GetUserProfileUseCase;
import net.beetechgroup.beetask.usecase.user.update.UpdateUserProfileUseCase;
import net.beetechgroup.beetask.usecase.user.uploadphoto.UploadUserPhotoUseCase;
import net.beetechgroup.beetask.usecase.repository.StorageRepository;
import net.beetechgroup.beetask.usecase.repository.UserRepository;
import io.smallrye.jwt.auth.principal.JWTParser;
import org.eclipse.microprofile.config.inject.ConfigProperty;
@ -46,4 +48,9 @@ public class UserUseCaseConfig {
public UpdateUserProfileUseCase updateUserProfileUseCase(UserRepository userRepository) {
return new UpdateUserProfileUseCase(userRepository);
}
@Produces
public UploadUserPhotoUseCase uploadUserPhotoUseCase(UserRepository userRepository, StorageRepository storageRepository) {
return new UploadUserPhotoUseCase(userRepository, storageRepository);
}
}

View file

@ -0,0 +1,76 @@
package net.beetechgroup.beetask.frameworks.storage;
import io.minio.BucketExistsArgs;
import io.minio.MakeBucketArgs;
import io.minio.MinioClient;
import io.minio.PutObjectArgs;
import io.minio.SetBucketPolicyArgs;
import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;
import net.beetechgroup.beetask.usecase.repository.StorageRepository;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
import java.io.InputStream;
@ApplicationScoped
public class MinioStorageService implements StorageRepository {
private static final Logger LOGGER = Logger.getLogger(MinioStorageService.class);
@ConfigProperty(name = "app.storage.endpoint")
String endpoint;
@ConfigProperty(name = "app.storage.access-key")
String accessKey;
@ConfigProperty(name = "app.storage.secret-key")
String secretKey;
@ConfigProperty(name = "app.storage.bucket")
String bucket;
@ConfigProperty(name = "app.storage.public-url")
String publicUrl;
private MinioClient minioClient;
@PostConstruct
void init() {
minioClient = MinioClient.builder()
.endpoint(endpoint)
.credentials(accessKey, secretKey)
.build();
try {
boolean exists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucket).build());
if (!exists) {
minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucket).build());
String publicPolicy = """
{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":["*"]},"Action":["s3:GetObject"],"Resource":["arn:aws:s3:::%s/*"]}]}
""".formatted(bucket);
minioClient.setBucketPolicy(SetBucketPolicyArgs.builder().bucket(bucket).config(publicPolicy).build());
LOGGER.infof("Bucket '%s' created with public read policy", bucket);
}
} catch (Exception e) {
LOGGER.warnf("Could not initialize MinIO bucket '%s': %s", bucket, e.getMessage());
}
}
@Override
public String upload(String key, InputStream data, String contentType, long size) {
try {
minioClient.putObject(PutObjectArgs.builder()
.bucket(bucket)
.object(key)
.stream(data, size, -1)
.contentType(contentType)
.build());
String url = publicUrl + "/" + bucket + "/" + key;
LOGGER.infof("Uploaded object '%s' to bucket '%s'", key, bucket);
return url;
} catch (Exception e) {
LOGGER.errorf("Failed to upload object '%s': %s", key, e.getMessage());
throw new RuntimeException("Falha ao fazer upload da imagem", e);
}
}
}

View file

@ -22,6 +22,10 @@ import net.beetechgroup.beetask.usecase.user.create.CreateUserUseCase;
import net.beetechgroup.beetask.usecase.user.update.UpdateUserProfileInput;
import net.beetechgroup.beetask.usecase.user.update.UpdateUserProfileOutput;
import net.beetechgroup.beetask.usecase.user.update.UpdateUserProfileUseCase;
import net.beetechgroup.beetask.usecase.user.uploadphoto.UploadUserPhotoInput;
import net.beetechgroup.beetask.usecase.user.uploadphoto.UploadUserPhotoOutput;
import net.beetechgroup.beetask.usecase.user.uploadphoto.UploadUserPhotoUseCase;
import org.jboss.resteasy.reactive.MultipartForm;
import io.quarkus.security.Authenticated;
import io.quarkus.security.identity.SecurityIdentity;
@ -52,6 +56,9 @@ public class AuthController {
@Inject
UpdateUserProfileUseCase updateUserProfileUseCase;
@Inject
UploadUserPhotoUseCase uploadUserPhotoUseCase;
@Inject
SecurityIdentity securityIdentity;
@ -65,6 +72,20 @@ public class AuthController {
return getUserProfileUseCase.execute(email);
}
@Path("/profile/photo")
@POST
@Authenticated
@Consumes(MediaType.MULTIPART_FORM_DATA)
@Operation(summary = "Upload profile photo", description = "Uploads a profile photo (max 5 MB) to MinIO")
public PhotoUploadResponse uploadPhoto(@MultipartForm PhotoUploadRequest request) {
String email = securityIdentity.getPrincipal().getName();
LOGGER.infof("Photo upload requested for user %s", email);
UploadUserPhotoInput input = AuthControllerMapper.toUploadUserPhotoInput(request, email);
UploadUserPhotoOutput output = uploadUserPhotoUseCase.execute(input);
LOGGER.infof("Photo uploaded successfully for user %s", email);
return AuthControllerMapper.toPhotoUploadResponse(output);
}
@Path("/profile")
@PUT
@Authenticated

View file

@ -7,6 +7,11 @@ import net.beetechgroup.beetask.usecase.user.create.CreateUserInput;
import net.beetechgroup.beetask.usecase.user.create.CreateUserOutput;
import net.beetechgroup.beetask.usecase.user.update.UpdateUserProfileInput;
import net.beetechgroup.beetask.usecase.user.update.UpdateUserProfileOutput;
import net.beetechgroup.beetask.usecase.user.uploadphoto.UploadUserPhotoInput;
import net.beetechgroup.beetask.usecase.user.uploadphoto.UploadUserPhotoOutput;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.util.stream.Collectors;
public class AuthControllerMapper {
@ -49,4 +54,17 @@ public class AuthControllerMapper {
public static UpdateProfileResponse toUpdateProfileResponse(UpdateUserProfileOutput output) {
return new UpdateProfileResponse(output.name(), output.email(), output.photo());
}
public static UploadUserPhotoInput toUploadUserPhotoInput(PhotoUploadRequest request, String email) {
try {
InputStream data = Files.newInputStream(request.photo.uploadedFile());
return new UploadUserPhotoInput(email, data, request.photo.contentType(), request.photo.size());
} catch (IOException e) {
throw new RuntimeException("Falha ao ler o arquivo de foto", e);
}
}
public static PhotoUploadResponse toPhotoUploadResponse(UploadUserPhotoOutput output) {
return new PhotoUploadResponse(output.photoUrl());
}
}

View file

@ -0,0 +1,10 @@
package net.beetechgroup.beetask.interfaceadapters.controllers.auth;
import org.jboss.resteasy.reactive.RestForm;
import org.jboss.resteasy.reactive.multipart.FileUpload;
public class PhotoUploadRequest {
@RestForm("photo")
public FileUpload photo;
}

View file

@ -0,0 +1,3 @@
package net.beetechgroup.beetask.interfaceadapters.controllers.auth;
public record PhotoUploadResponse(String photoUrl) {}

View file

@ -77,7 +77,7 @@ public class LoginUseCase {
return new LoginOutput(
user.getName(),
user.getEmail(),
Objects.nonNull(user.getPhoto()) ? user.getPhoto() : "https://ui-avatars.com/api/?name=" + user.getName().replace(" ", "+") + "&background=random",
user.getPhoto(),
token,
refreshToken,
TokenConstants.ACCESS_TOKEN_EXPIRY_SECONDS,

View file

@ -84,7 +84,7 @@ public class RefreshTokenUseCase {
return new LoginOutput(
user.getName(),
user.getEmail(),
Objects.nonNull(user.getPhoto()) ? user.getPhoto() : "https://ui-avatars.com/api/?name=" + user.getName().replace(" ", "+") + "&background=random",
user.getPhoto(),
newToken,
newRefreshToken,
TokenConstants.ACCESS_TOKEN_EXPIRY_SECONDS,

View file

@ -0,0 +1,7 @@
package net.beetechgroup.beetask.usecase.repository;
import java.io.InputStream;
public interface StorageRepository {
String upload(String key, InputStream data, String contentType, long size);
}

View file

@ -33,10 +33,7 @@ public class CreateTaskMapper {
}
private static String getPhotoUrl(String name, String photo) {
if (Objects.nonNull(photo) && !photo.isBlank()) {
return photo;
}
return "https://ui-avatars.com/api/?name=" + name.replace(" ", "+") + "&background=random";
return (Objects.nonNull(photo) && !photo.isBlank()) ? photo : null;
}
private static TaskHistoryItemOutput toTaskHistoryItemOutput(TaskHistoryItem taskHistoryItem) {

View file

@ -47,7 +47,7 @@ public class GetUserProfileUseCase {
return new UserProfileOutput(
user.getName(),
user.getEmail(),
Objects.nonNull(user.getPhoto()) ? user.getPhoto() : "https://ui-avatars.com/api/?name=" + user.getName().replace(" ", "+") + "&background=random",
user.getPhoto(),
orgs
);
}

View file

@ -2,14 +2,9 @@ package net.beetechgroup.beetask.usecase.user.update;
import net.beetechgroup.beetask.entities.User;
import java.util.Objects;
public class UpdateUserProfileMapper {
public static UpdateUserProfileOutput toOutput(User user) {
String photo = Objects.nonNull(user.getPhoto())
? user.getPhoto()
: "https://ui-avatars.com/api/?name=" + user.getName().replace(" ", "+") + "&background=random";
return new UpdateUserProfileOutput(user.getName(), user.getEmail(), photo);
return new UpdateUserProfileOutput(user.getName(), user.getEmail(), user.getPhoto());
}
}

View file

@ -4,8 +4,6 @@ import net.beetechgroup.beetask.entities.User;
import net.beetechgroup.beetask.usecase.repository.UserRepository;
import org.jboss.logging.Logger;
import java.util.Objects;
import java.util.Optional;
public class UpdateUserProfileUseCase {
private static final Logger LOGGER = Logger.getLogger(UpdateUserProfileUseCase.class);
@ -37,10 +35,6 @@ public class UpdateUserProfileUseCase {
LOGGER.infof("Profile updated for user %d: name=%s, email=%s", saved.getId(), saved.getName(), saved.getEmail());
String photo = Objects.nonNull(saved.getPhoto())
? saved.getPhoto()
: "https://ui-avatars.com/api/?name=" + saved.getName().replace(" ", "+") + "&background=random";
return new UpdateUserProfileOutput(saved.getName(), saved.getEmail(), photo);
return new UpdateUserProfileOutput(saved.getName(), saved.getEmail(), saved.getPhoto());
}
}

View file

@ -0,0 +1,5 @@
package net.beetechgroup.beetask.usecase.user.uploadphoto;
import java.io.InputStream;
public record UploadUserPhotoInput(String email, InputStream data, String contentType, long size) {}

View file

@ -0,0 +1,3 @@
package net.beetechgroup.beetask.usecase.user.uploadphoto;
public record UploadUserPhotoOutput(String photoUrl) {}

View file

@ -0,0 +1,43 @@
package net.beetechgroup.beetask.usecase.user.uploadphoto;
import net.beetechgroup.beetask.entities.User;
import net.beetechgroup.beetask.usecase.repository.StorageRepository;
import net.beetechgroup.beetask.usecase.repository.UserRepository;
import org.jboss.logging.Logger;
public class UploadUserPhotoUseCase {
private static final Logger LOGGER = Logger.getLogger(UploadUserPhotoUseCase.class);
private static final long MAX_SIZE_BYTES = 5L * 1024 * 1024;
private final UserRepository userRepository;
private final StorageRepository storageRepository;
public UploadUserPhotoUseCase(UserRepository userRepository, StorageRepository storageRepository) {
this.userRepository = userRepository;
this.storageRepository = storageRepository;
}
public UploadUserPhotoOutput execute(UploadUserPhotoInput input) {
if (input.size() > MAX_SIZE_BYTES) {
throw new RuntimeException("Foto deve ter no máximo 5 MB");
}
if (!input.contentType().startsWith("image/")) {
throw new RuntimeException("O arquivo deve ser uma imagem");
}
User user = userRepository.findByEmail(input.email())
.orElseThrow(() -> {
LOGGER.warnf("Photo upload failed: user %s not found", input.email());
return new RuntimeException("Usuário não encontrado");
});
String key = String.valueOf(user.getId());
String photoUrl = storageRepository.upload(key, input.data(), input.contentType(), input.size());
user.setPhoto(photoUrl);
userRepository.save(user);
LOGGER.infof("Photo updated for user %d", user.getId());
return new UploadUserPhotoOutput(photoUrl);
}
}

View file

@ -26,3 +26,10 @@ mp.jwt.verify.issuer=${MP_JWT_VERIFY_ISSUER}
smallrye.jwt.sign.key=${SMALLRYE_JWT_SIGN_KEY}
mp.jwt.verify.publickey=${MP_JWT_VERIFY_PUBLICKEY}
# MinIO / Object Storage
app.storage.endpoint=${MINIO_ENDPOINT:http://localhost:9000}
app.storage.access-key=${MINIO_ACCESS_KEY:minioadmin}
app.storage.secret-key=${MINIO_SECRET_KEY:minioadmin}
app.storage.bucket=${MINIO_BUCKET:avatars}
app.storage.public-url=${MINIO_PUBLIC_URL:http://localhost:9000}

View file

@ -9,3 +9,12 @@ QUARKUS_HTTP_CORS_ORIGINS=https://app.example.com
MP_JWT_VERIFY_ISSUER=https://beetech.net
SMALLRYE_JWT_SIGN_KEY_LOCATION=privateKey.pem
MP_JWT_VERIFY_PUBLICKEY_LOCATION=publicKey.pem
# MinIO (object storage for user avatars)
# MINIO_ROOT_USER and MINIO_ROOT_PASSWORD are used both by the MinIO service
# and by the backend as access/secret keys.
MINIO_ROOT_USER=change-me
MINIO_ROOT_PASSWORD=change-me
MINIO_ENDPOINT=http://minio:9000
MINIO_PUBLIC_URL=https://minio.task.beetech.dev
MINIO_BUCKET=avatars

View file

@ -29,6 +29,11 @@ services:
MP_JWT_VERIFY_ISSUER: ${MP_JWT_VERIFY_ISSUER}
SMALLRYE_JWT_SIGN_KEY: ${SMALLRYE_JWT_SIGN_KEY}
MP_JWT_VERIFY_PUBLICKEY: ${MP_JWT_VERIFY_PUBLICKEY}
MINIO_ENDPOINT: ${MINIO_ENDPOINT}
MINIO_ACCESS_KEY: ${MINIO_ROOT_USER}
MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD}
MINIO_BUCKET: ${MINIO_BUCKET}
MINIO_PUBLIC_URL: ${MINIO_PUBLIC_URL}
depends_on:
postgres:
@ -52,5 +57,38 @@ services:
- "traefik.http.routers.frontend.tls.certresolver=letsencrypt"
- "traefik.http.services.frontend.loadbalancer.server.port=80"
minio:
image: minio/minio:latest
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
volumes:
- minio_data:/data
labels:
- "traefik.enable=true"
- "traefik.http.routers.minio.rule=Host(`minio.task.beetech.dev`)"
- "traefik.http.routers.minio.entrypoints=websecure"
- "traefik.http.routers.minio.tls.certresolver=letsencrypt"
- "traefik.http.services.minio.loadbalancer.server.port=9000"
minio-init:
image: minio/mc:latest
depends_on:
- minio
restart: on-failure
entrypoint: >
/bin/sh -c "
sleep 3 &&
mc alias set local http://minio:9000 $$MINIO_ROOT_USER $$MINIO_ROOT_PASSWORD &&
mc mb --ignore-existing local/avatars &&
mc anonymous set download local/avatars
"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
volumes:
postgres_data:
minio_data:

55
docker-compose.yml Normal file
View file

@ -0,0 +1,55 @@
services:
postgres:
image: postgres:16
container_name: bee-task-postgres
restart: unless-stopped
environment:
POSTGRES_USER: beetask
POSTGRES_PASSWORD: beetask
POSTGRES_DB: beetask
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U beetask"]
interval: 10s
timeout: 5s
retries: 5
minio:
image: minio/minio:latest
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
ports:
- "9000:9000"
- "9001:9001"
volumes:
- minio_data:/data
minio-init:
image: minio/mc:latest
depends_on:
- minio
restart: on-failure
entrypoint: >
/bin/sh -c "
sleep 3 &&
mc alias set local http://minio:9000 minioadmin minioadmin &&
mc mb --ignore-existing local/avatars &&
mc anonymous set download local/avatars
"
volumes:
postgres_data:
minio_data:

View file

@ -1,19 +1,23 @@
import { useState } from 'react'
import { cn } from '../lib/utils'
interface UserAvatarProps {
name: string
photo?: string | null
size?: 'sm' | 'md' | 'lg'
size?: 'xs' | 'sm' | 'md' | 'lg'
className?: string
}
const sizeClasses = {
xs: 'h-6 w-6 rounded-lg text-[10px]',
sm: 'h-8 w-8 rounded-xl text-xs',
md: 'h-10 w-10 rounded-2xl text-sm',
lg: 'h-14 w-14 rounded-2xl text-xl',
}
export function UserAvatar({ name, photo, size = 'md', className }: UserAvatarProps) {
const [imgError, setImgError] = useState(false)
const initials = name
.split(' ')
.slice(0, 2)
@ -21,13 +25,14 @@ export function UserAvatar({ name, photo, size = 'md', className }: UserAvatarPr
.join('')
.toUpperCase()
if (photo) {
if (photo && !imgError) {
return (
<img
src={photo}
alt={name}
title={name}
className={cn('shrink-0 object-cover', sizeClasses[size], className)}
onError={() => setImgError(true)}
/>
)
}

View file

@ -6,6 +6,7 @@ import {
type JoinRequest,
} from "../services/organizationService";
import { useAuth } from "../contexts/AuthContext";
import { UserAvatar } from "../components/UserAvatar";
export const OrganizationAdminPage: React.FC = () => {
const { activeOrg } = useAuth();
@ -95,13 +96,11 @@ export const OrganizationAdminPage: React.FC = () => {
className="p-6 flex flex-col sm:flex-row items-center justify-between gap-6 hover:bg-surface-muted/20 transition-colors"
>
<div className="flex items-center gap-4">
<img
src={
request.photo ||
`https://ui-avatars.com/api/?name=${request.name}&background=random`
}
alt={request.name}
className="w-12 h-12 rounded-full border border-border-soft object-cover"
<UserAvatar
name={request.name}
photo={request.photo}
size="md"
className="border border-border-soft"
/>
<div>
<h3 className="font-bold text-text-main text-lg">

View file

@ -1,32 +1,59 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { motion } from "framer-motion";
import { User, Mail, Save, CheckCircle, AlertCircle, Loader2 } from "lucide-react";
import { Mail, Save, CheckCircle, AlertCircle, Loader2, Camera, User } from "lucide-react";
import { useAuth } from "../contexts/AuthContext";
import { userService } from "../services/userService";
import { cn } from "../lib/utils";
const MAX_PHOTO_SIZE = 5 * 1024 * 1024;
export const ProfilePage: React.FC = () => {
const { user, refreshUser } = useAuth();
const [name, setName] = useState(user?.name ?? "");
const [email, setEmail] = useState(user?.email ?? "");
const [pendingPhoto, setPendingPhoto] = useState<File | null>(null);
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [successMsg, setSuccessMsg] = useState<string | null>(null);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setName(user?.name ?? "");
setEmail(user?.email ?? "");
}, [user]);
const initials = (name || user?.name || "?")
const currentPhoto = photoPreview ?? user?.photo ?? null;
const displayInitials = (name || user?.name || "?")
.split(" ")
.slice(0, 2)
.map((w) => w[0])
.join("")
.toUpperCase();
const hasChanges = name !== user?.name || email !== user?.email;
const hasChanges =
name !== user?.name || email !== user?.email || pendingPhoto !== null;
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > MAX_PHOTO_SIZE) {
setErrorMsg("A foto deve ter no máximo 5 MB.");
e.target.value = "";
return;
}
if (!file.type.startsWith("image/")) {
setErrorMsg("Selecione um arquivo de imagem.");
e.target.value = "";
return;
}
setErrorMsg(null);
setPendingPhoto(file);
setPhotoPreview(URL.createObjectURL(file));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@ -37,8 +64,13 @@ export const ProfilePage: React.FC = () => {
setErrorMsg(null);
try {
if (pendingPhoto) {
await userService.uploadPhoto(pendingPhoto);
}
await userService.updateProfile({ name: name.trim(), email: email.trim() });
await refreshUser();
setPendingPhoto(null);
setPhotoPreview(null);
setSuccessMsg("Perfil atualizado com sucesso!");
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Erro ao atualizar perfil.";
@ -63,14 +95,44 @@ export const ProfilePage: React.FC = () => {
</div>
<div className="rounded-2xl border border-border-soft bg-surface p-8 shadow-sm">
{/* Avatar */}
{/* Avatar com upload */}
<div className="mb-8 flex flex-col items-center gap-3">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="group relative h-20 w-20 cursor-pointer"
title="Alterar foto"
>
{currentPhoto ? (
<img
src={currentPhoto}
alt={name || user?.name}
className="h-20 w-20 rounded-full object-cover ring-4 ring-brand/10"
/>
) : (
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-brand/10 text-2xl font-black text-brand ring-4 ring-brand/10">
{initials}
{displayInitials}
</div>
)}
<div className="absolute inset-0 flex items-center justify-center rounded-full bg-black/40 opacity-0 transition-opacity group-hover:opacity-100">
<Camera size={20} className="text-white" />
</div>
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleFileChange}
/>
<div className="text-center">
<p className="font-bold text-text-main">{user?.name}</p>
<p className="text-sm text-text-muted">{user?.email}</p>
<p className="mt-1 text-xs text-text-muted">
Clique na foto para alterar · máx. 5 MB
</p>
</div>
</div>

View file

@ -11,6 +11,10 @@ export interface UpdateProfileResult {
photo: string
}
export interface UploadPhotoResult {
photoUrl: string
}
export const userService = {
updateProfile: async (payload: UpdateProfilePayload): Promise<UpdateProfileResult> => {
return apiFetch<UpdateProfileResult>('/auth/profile', {
@ -18,4 +22,13 @@ export const userService = {
body: payload,
})
},
uploadPhoto: async (file: File): Promise<UploadPhotoResult> => {
const form = new FormData()
form.append('photo', file)
return apiFetch<UploadPhotoResult>('/auth/profile/photo', {
method: 'POST',
body: form,
})
},
}