feat: add MinIO image server and profile photo upload
Infrastructure: MinIO service added to docker-compose (prod + dev) with minio-init container that creates the avatars bucket with public read policy. Traefik routes minio.task.beetech.dev → port 9000. Backend: native MinIO SDK integration via MinioStorageService; UploadUserPhotoUseCase validates 5 MB limit and image content-type; new POST /auth/profile/photo endpoint (multipart). All hardcoded ui-avatars.com fallback URLs removed from 6 use cases — backend now returns null when user has no photo. Frontend: ProfilePage gains clickable avatar with camera overlay, file picker, preview, and photo upload on save. UserAvatar gains onError handler for broken URLs and xs size. OrganizationAdminPage replaces hardcoded ui-avatars.com with UserAvatar component. CI/CD: deploy-main.yml passes 5 new MinIO secrets to the server and writes them to .env.production. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
15118177a2
commit
59a5c61585
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 }}
|
MP_JWT_VERIFY_PUBLICKEY: ${{ secrets.MP_JWT_VERIFY_PUBLICKEY }}
|
||||||
GHCR_USERNAME: ${{ secrets.GHCR_USERNAME }}
|
GHCR_USERNAME: ${{ secrets.GHCR_USERNAME }}
|
||||||
GHCR_TOKEN: ${{ secrets.GHCR_READ_TOKEN }}
|
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:
|
with:
|
||||||
host: ${{ secrets.HOSTINGER_HOST }}
|
host: ${{ secrets.HOSTINGER_HOST }}
|
||||||
username: ${{ secrets.HOSTINGER_USER }}
|
username: ${{ secrets.HOSTINGER_USER }}
|
||||||
key: ${{ secrets.HOSTINGER_SSH_KEY }}
|
key: ${{ secrets.HOSTINGER_SSH_KEY }}
|
||||||
port: ${{ secrets.HOSTINGER_PORT || 22 }}
|
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: |
|
script: |
|
||||||
set -eu
|
set -eu
|
||||||
APP_DIR="${{ secrets.HOSTINGER_APP_PATH }}"
|
APP_DIR="${{ secrets.HOSTINGER_APP_PATH }}"
|
||||||
|
|
@ -137,6 +142,11 @@ jobs:
|
||||||
echo "FRONTEND_IMAGE=${FRONTEND_IMAGE}"
|
echo "FRONTEND_IMAGE=${FRONTEND_IMAGE}"
|
||||||
echo "BACKEND_IMAGE=${BACKEND_IMAGE}"
|
echo "BACKEND_IMAGE=${BACKEND_IMAGE}"
|
||||||
echo "MP_JWT_VERIFY_ISSUER=${MP_JWT_VERIFY_ISSUER}"
|
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 'SMALLRYE_JWT_SIGN_KEY="%s"\n' "$SMALLRYE_JWT_SIGN_KEY"
|
||||||
printf 'MP_JWT_VERIFY_PUBLICKEY="%s"\n' "$MP_JWT_VERIFY_PUBLICKEY"
|
printf 'MP_JWT_VERIFY_PUBLICKEY="%s"\n' "$MP_JWT_VERIFY_PUBLICKEY"
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,11 @@
|
||||||
<groupId>io.quarkus</groupId>
|
<groupId>io.quarkus</groupId>
|
||||||
<artifactId>quarkus-hibernate-orm</artifactId>
|
<artifactId>quarkus-hibernate-orm</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.minio</groupId>
|
||||||
|
<artifactId>minio</artifactId>
|
||||||
|
<version>8.5.17</version>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.quarkus</groupId>
|
<groupId>io.quarkus</groupId>
|
||||||
<artifactId>quarkus-junit</artifactId>
|
<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.create.CreateUserUseCase;
|
||||||
import net.beetechgroup.beetask.usecase.user.profile.GetUserProfileUseCase;
|
import net.beetechgroup.beetask.usecase.user.profile.GetUserProfileUseCase;
|
||||||
import net.beetechgroup.beetask.usecase.user.update.UpdateUserProfileUseCase;
|
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 net.beetechgroup.beetask.usecase.repository.UserRepository;
|
||||||
import io.smallrye.jwt.auth.principal.JWTParser;
|
import io.smallrye.jwt.auth.principal.JWTParser;
|
||||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||||
|
|
@ -46,4 +48,9 @@ public class UserUseCaseConfig {
|
||||||
public UpdateUserProfileUseCase updateUserProfileUseCase(UserRepository userRepository) {
|
public UpdateUserProfileUseCase updateUserProfileUseCase(UserRepository userRepository) {
|
||||||
return new UpdateUserProfileUseCase(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.UpdateUserProfileInput;
|
||||||
import net.beetechgroup.beetask.usecase.user.update.UpdateUserProfileOutput;
|
import net.beetechgroup.beetask.usecase.user.update.UpdateUserProfileOutput;
|
||||||
import net.beetechgroup.beetask.usecase.user.update.UpdateUserProfileUseCase;
|
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.Authenticated;
|
||||||
import io.quarkus.security.identity.SecurityIdentity;
|
import io.quarkus.security.identity.SecurityIdentity;
|
||||||
|
|
@ -52,6 +56,9 @@ public class AuthController {
|
||||||
@Inject
|
@Inject
|
||||||
UpdateUserProfileUseCase updateUserProfileUseCase;
|
UpdateUserProfileUseCase updateUserProfileUseCase;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
UploadUserPhotoUseCase uploadUserPhotoUseCase;
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
SecurityIdentity securityIdentity;
|
SecurityIdentity securityIdentity;
|
||||||
|
|
||||||
|
|
@ -65,6 +72,20 @@ public class AuthController {
|
||||||
return getUserProfileUseCase.execute(email);
|
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")
|
@Path("/profile")
|
||||||
@PUT
|
@PUT
|
||||||
@Authenticated
|
@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.create.CreateUserOutput;
|
||||||
import net.beetechgroup.beetask.usecase.user.update.UpdateUserProfileInput;
|
import net.beetechgroup.beetask.usecase.user.update.UpdateUserProfileInput;
|
||||||
import net.beetechgroup.beetask.usecase.user.update.UpdateUserProfileOutput;
|
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;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
public class AuthControllerMapper {
|
public class AuthControllerMapper {
|
||||||
|
|
@ -49,4 +54,17 @@ public class AuthControllerMapper {
|
||||||
public static UpdateProfileResponse toUpdateProfileResponse(UpdateUserProfileOutput output) {
|
public static UpdateProfileResponse toUpdateProfileResponse(UpdateUserProfileOutput output) {
|
||||||
return new UpdateProfileResponse(output.name(), output.email(), output.photo());
|
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(
|
return new LoginOutput(
|
||||||
user.getName(),
|
user.getName(),
|
||||||
user.getEmail(),
|
user.getEmail(),
|
||||||
Objects.nonNull(user.getPhoto()) ? user.getPhoto() : "https://ui-avatars.com/api/?name=" + user.getName().replace(" ", "+") + "&background=random",
|
user.getPhoto(),
|
||||||
token,
|
token,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
TokenConstants.ACCESS_TOKEN_EXPIRY_SECONDS,
|
TokenConstants.ACCESS_TOKEN_EXPIRY_SECONDS,
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ public class RefreshTokenUseCase {
|
||||||
return new LoginOutput(
|
return new LoginOutput(
|
||||||
user.getName(),
|
user.getName(),
|
||||||
user.getEmail(),
|
user.getEmail(),
|
||||||
Objects.nonNull(user.getPhoto()) ? user.getPhoto() : "https://ui-avatars.com/api/?name=" + user.getName().replace(" ", "+") + "&background=random",
|
user.getPhoto(),
|
||||||
newToken,
|
newToken,
|
||||||
newRefreshToken,
|
newRefreshToken,
|
||||||
TokenConstants.ACCESS_TOKEN_EXPIRY_SECONDS,
|
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) {
|
private static String getPhotoUrl(String name, String photo) {
|
||||||
if (Objects.nonNull(photo) && !photo.isBlank()) {
|
return (Objects.nonNull(photo) && !photo.isBlank()) ? photo : null;
|
||||||
return photo;
|
|
||||||
}
|
|
||||||
return "https://ui-avatars.com/api/?name=" + name.replace(" ", "+") + "&background=random";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static TaskHistoryItemOutput toTaskHistoryItemOutput(TaskHistoryItem taskHistoryItem) {
|
private static TaskHistoryItemOutput toTaskHistoryItemOutput(TaskHistoryItem taskHistoryItem) {
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ public class GetUserProfileUseCase {
|
||||||
return new UserProfileOutput(
|
return new UserProfileOutput(
|
||||||
user.getName(),
|
user.getName(),
|
||||||
user.getEmail(),
|
user.getEmail(),
|
||||||
Objects.nonNull(user.getPhoto()) ? user.getPhoto() : "https://ui-avatars.com/api/?name=" + user.getName().replace(" ", "+") + "&background=random",
|
user.getPhoto(),
|
||||||
orgs
|
orgs
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,9 @@ package net.beetechgroup.beetask.usecase.user.update;
|
||||||
|
|
||||||
import net.beetechgroup.beetask.entities.User;
|
import net.beetechgroup.beetask.entities.User;
|
||||||
|
|
||||||
import java.util.Objects;
|
|
||||||
|
|
||||||
public class UpdateUserProfileMapper {
|
public class UpdateUserProfileMapper {
|
||||||
|
|
||||||
public static UpdateUserProfileOutput toOutput(User user) {
|
public static UpdateUserProfileOutput toOutput(User user) {
|
||||||
String photo = Objects.nonNull(user.getPhoto())
|
return new UpdateUserProfileOutput(user.getName(), user.getEmail(), user.getPhoto());
|
||||||
? user.getPhoto()
|
|
||||||
: "https://ui-avatars.com/api/?name=" + user.getName().replace(" ", "+") + "&background=random";
|
|
||||||
return new UpdateUserProfileOutput(user.getName(), user.getEmail(), photo);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@ import net.beetechgroup.beetask.entities.User;
|
||||||
import net.beetechgroup.beetask.usecase.repository.UserRepository;
|
import net.beetechgroup.beetask.usecase.repository.UserRepository;
|
||||||
import org.jboss.logging.Logger;
|
import org.jboss.logging.Logger;
|
||||||
|
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
public class UpdateUserProfileUseCase {
|
public class UpdateUserProfileUseCase {
|
||||||
private static final Logger LOGGER = Logger.getLogger(UpdateUserProfileUseCase.class);
|
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());
|
LOGGER.infof("Profile updated for user %d: name=%s, email=%s", saved.getId(), saved.getName(), saved.getEmail());
|
||||||
|
|
||||||
String photo = Objects.nonNull(saved.getPhoto())
|
return new UpdateUserProfileOutput(saved.getName(), saved.getEmail(), saved.getPhoto());
|
||||||
? saved.getPhoto()
|
|
||||||
: "https://ui-avatars.com/api/?name=" + saved.getName().replace(" ", "+") + "&background=random";
|
|
||||||
|
|
||||||
return new UpdateUserProfileOutput(saved.getName(), saved.getEmail(), photo);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
smallrye.jwt.sign.key=${SMALLRYE_JWT_SIGN_KEY}
|
||||||
mp.jwt.verify.publickey=${MP_JWT_VERIFY_PUBLICKEY}
|
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
|
MP_JWT_VERIFY_ISSUER=https://beetech.net
|
||||||
SMALLRYE_JWT_SIGN_KEY_LOCATION=privateKey.pem
|
SMALLRYE_JWT_SIGN_KEY_LOCATION=privateKey.pem
|
||||||
MP_JWT_VERIFY_PUBLICKEY_LOCATION=publicKey.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}
|
MP_JWT_VERIFY_ISSUER: ${MP_JWT_VERIFY_ISSUER}
|
||||||
SMALLRYE_JWT_SIGN_KEY: ${SMALLRYE_JWT_SIGN_KEY}
|
SMALLRYE_JWT_SIGN_KEY: ${SMALLRYE_JWT_SIGN_KEY}
|
||||||
MP_JWT_VERIFY_PUBLICKEY: ${MP_JWT_VERIFY_PUBLICKEY}
|
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:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
|
|
@ -52,5 +57,38 @@ services:
|
||||||
- "traefik.http.routers.frontend.tls.certresolver=letsencrypt"
|
- "traefik.http.routers.frontend.tls.certresolver=letsencrypt"
|
||||||
- "traefik.http.services.frontend.loadbalancer.server.port=80"
|
- "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:
|
volumes:
|
||||||
postgres_data:
|
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'
|
import { cn } from '../lib/utils'
|
||||||
|
|
||||||
interface UserAvatarProps {
|
interface UserAvatarProps {
|
||||||
name: string
|
name: string
|
||||||
photo?: string | null
|
photo?: string | null
|
||||||
size?: 'sm' | 'md' | 'lg'
|
size?: 'xs' | 'sm' | 'md' | 'lg'
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const sizeClasses = {
|
const sizeClasses = {
|
||||||
|
xs: 'h-6 w-6 rounded-lg text-[10px]',
|
||||||
sm: 'h-8 w-8 rounded-xl text-xs',
|
sm: 'h-8 w-8 rounded-xl text-xs',
|
||||||
md: 'h-10 w-10 rounded-2xl text-sm',
|
md: 'h-10 w-10 rounded-2xl text-sm',
|
||||||
lg: 'h-14 w-14 rounded-2xl text-xl',
|
lg: 'h-14 w-14 rounded-2xl text-xl',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserAvatar({ name, photo, size = 'md', className }: UserAvatarProps) {
|
export function UserAvatar({ name, photo, size = 'md', className }: UserAvatarProps) {
|
||||||
|
const [imgError, setImgError] = useState(false)
|
||||||
|
|
||||||
const initials = name
|
const initials = name
|
||||||
.split(' ')
|
.split(' ')
|
||||||
.slice(0, 2)
|
.slice(0, 2)
|
||||||
|
|
@ -21,13 +25,14 @@ export function UserAvatar({ name, photo, size = 'md', className }: UserAvatarPr
|
||||||
.join('')
|
.join('')
|
||||||
.toUpperCase()
|
.toUpperCase()
|
||||||
|
|
||||||
if (photo) {
|
if (photo && !imgError) {
|
||||||
return (
|
return (
|
||||||
<img
|
<img
|
||||||
src={photo}
|
src={photo}
|
||||||
alt={name}
|
alt={name}
|
||||||
title={name}
|
title={name}
|
||||||
className={cn('shrink-0 object-cover', sizeClasses[size], className)}
|
className={cn('shrink-0 object-cover', sizeClasses[size], className)}
|
||||||
|
onError={() => setImgError(true)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
type JoinRequest,
|
type JoinRequest,
|
||||||
} from "../services/organizationService";
|
} from "../services/organizationService";
|
||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
|
import { UserAvatar } from "../components/UserAvatar";
|
||||||
|
|
||||||
export const OrganizationAdminPage: React.FC = () => {
|
export const OrganizationAdminPage: React.FC = () => {
|
||||||
const { activeOrg } = useAuth();
|
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"
|
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">
|
<div className="flex items-center gap-4">
|
||||||
<img
|
<UserAvatar
|
||||||
src={
|
name={request.name}
|
||||||
request.photo ||
|
photo={request.photo}
|
||||||
`https://ui-avatars.com/api/?name=${request.name}&background=random`
|
size="md"
|
||||||
}
|
className="border border-border-soft"
|
||||||
alt={request.name}
|
|
||||||
className="w-12 h-12 rounded-full border border-border-soft object-cover"
|
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-bold text-text-main text-lg">
|
<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 { 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 { useAuth } from "../contexts/AuthContext";
|
||||||
import { userService } from "../services/userService";
|
import { userService } from "../services/userService";
|
||||||
import { cn } from "../lib/utils";
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
|
const MAX_PHOTO_SIZE = 5 * 1024 * 1024;
|
||||||
|
|
||||||
export const ProfilePage: React.FC = () => {
|
export const ProfilePage: React.FC = () => {
|
||||||
const { user, refreshUser } = useAuth();
|
const { user, refreshUser } = useAuth();
|
||||||
|
|
||||||
const [name, setName] = useState(user?.name ?? "");
|
const [name, setName] = useState(user?.name ?? "");
|
||||||
const [email, setEmail] = useState(user?.email ?? "");
|
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 [isSaving, setIsSaving] = useState(false);
|
||||||
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setName(user?.name ?? "");
|
setName(user?.name ?? "");
|
||||||
setEmail(user?.email ?? "");
|
setEmail(user?.email ?? "");
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
const initials = (name || user?.name || "?")
|
const currentPhoto = photoPreview ?? user?.photo ?? null;
|
||||||
|
const displayInitials = (name || user?.name || "?")
|
||||||
.split(" ")
|
.split(" ")
|
||||||
.slice(0, 2)
|
.slice(0, 2)
|
||||||
.map((w) => w[0])
|
.map((w) => w[0])
|
||||||
.join("")
|
.join("")
|
||||||
.toUpperCase();
|
.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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -37,8 +64,13 @@ export const ProfilePage: React.FC = () => {
|
||||||
setErrorMsg(null);
|
setErrorMsg(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (pendingPhoto) {
|
||||||
|
await userService.uploadPhoto(pendingPhoto);
|
||||||
|
}
|
||||||
await userService.updateProfile({ name: name.trim(), email: email.trim() });
|
await userService.updateProfile({ name: name.trim(), email: email.trim() });
|
||||||
await refreshUser();
|
await refreshUser();
|
||||||
|
setPendingPhoto(null);
|
||||||
|
setPhotoPreview(null);
|
||||||
setSuccessMsg("Perfil atualizado com sucesso!");
|
setSuccessMsg("Perfil atualizado com sucesso!");
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : "Erro ao atualizar perfil.";
|
const message = err instanceof Error ? err.message : "Erro ao atualizar perfil.";
|
||||||
|
|
@ -63,14 +95,44 @@ export const ProfilePage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-2xl border border-border-soft bg-surface p-8 shadow-sm">
|
<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">
|
<div className="mb-8 flex flex-col items-center gap-3">
|
||||||
<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">
|
<button
|
||||||
{initials}
|
type="button"
|
||||||
</div>
|
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">
|
||||||
|
{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">
|
<div className="text-center">
|
||||||
<p className="font-bold text-text-main">{user?.name}</p>
|
<p className="font-bold text-text-main">{user?.name}</p>
|
||||||
<p className="text-sm text-text-muted">{user?.email}</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,10 @@ export interface UpdateProfileResult {
|
||||||
photo: string
|
photo: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UploadPhotoResult {
|
||||||
|
photoUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
export const userService = {
|
export const userService = {
|
||||||
updateProfile: async (payload: UpdateProfilePayload): Promise<UpdateProfileResult> => {
|
updateProfile: async (payload: UpdateProfilePayload): Promise<UpdateProfileResult> => {
|
||||||
return apiFetch<UpdateProfileResult>('/auth/profile', {
|
return apiFetch<UpdateProfileResult>('/auth/profile', {
|
||||||
|
|
@ -18,4 +22,13 @@ export const userService = {
|
||||||
body: payload,
|
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