feat: add MinIO image server and profile photo upload
feat: add MinIO image server and profile photo upload
This commit is contained in:
commit
5a2073cba9
26 changed files with 420 additions and 38 deletions
12
.github/workflows/deploy-main.yml
vendored
12
.github/workflows/deploy-main.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package net.beetechgroup.beetask.interfaceadapters.controllers.auth;
|
||||
|
||||
public record PhotoUploadResponse(String photoUrl) {}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package net.beetechgroup.beetask.usecase.user.uploadphoto;
|
||||
|
||||
public record UploadUserPhotoOutput(String photoUrl) {}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
55
docker-compose.yml
Normal 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:
|
||||
|
|
@ -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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue