mirror of https://github.com/halo-dev/halo
feat: add support for user avatar upload (#4253)
#### What type of PR is this? /kind improvement /area console /area core #### What this PR does / why we need it: 此 PR 对用户头像上传的方式进行了重构,移除了原有的头像链接及上传至附件库的方案。允许具有用户管理权限的用户对其他用户的头像进行修改和移除。 Core: 新增了 `/apis/api.console.halo.run/v1alpha1/users/-/avatar` 的 `POST` 以及 `DELETE` 接口,用来上传用户的头像及删除当前用户的头像。 Console: 新增对用户头像进行裁剪的功能,并调用上传接口保存用户头像。 需等待 #4247 合并 #### Which issue(s) this PR fixes: Fixes #2688 See #4251 See #4247 #### Special notes for your reviewer: 1. 测试上传、删除头像接口是否能够正常执行。 2. 查看当前用户的头像是否能够设置成功。 3. 查看附件库中,当前用户的头像文件是否为 0 或 1 个。 #### Does this PR introduce a user-facing change? ```release-note 支持裁剪、上传和删除用户头像。 ```pull/4206/head^2
parent
fdfaa53614
commit
84093d8db0
|
@ -34,6 +34,11 @@ public class User extends AbstractExtension {
|
|||
|
||||
public static final String ROLE_NAMES_ANNO = "rbac.authorization.halo.run/role-names";
|
||||
|
||||
public static final String LAST_AVATAR_ATTACHMENT_NAME_ANNO =
|
||||
"halo.run/last-avatar-attachment-name";
|
||||
|
||||
public static final String AVATAR_ATTACHMENT_NAME_ANNO = "halo.run/avatar-attachment-name";
|
||||
|
||||
public static final String HIDDEN_USER_LABEL = "halo.run/hidden-user";
|
||||
|
||||
@Schema(required = true)
|
||||
|
|
|
@ -68,6 +68,7 @@ public class SystemSetting {
|
|||
public static final String GROUP = "user";
|
||||
Boolean allowRegistration;
|
||||
String defaultRole;
|
||||
String avatarPolicy;
|
||||
}
|
||||
|
||||
@Data
|
||||
|
|
|
@ -5,13 +5,17 @@ import static java.util.Comparator.comparing;
|
|||
import static org.apache.commons.lang3.ObjectUtils.defaultIfNull;
|
||||
import static org.apache.commons.lang3.StringUtils.defaultIfBlank;
|
||||
import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder;
|
||||
import static org.springdoc.core.fn.builders.content.Builder.contentBuilder;
|
||||
import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder;
|
||||
import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder;
|
||||
import static org.springdoc.core.fn.builders.schema.Builder.schemaBuilder;
|
||||
import static org.springframework.web.reactive.function.server.RequestPredicates.contentType;
|
||||
import static run.halo.app.extension.ListResult.generateGenericClass;
|
||||
import static run.halo.app.extension.router.QueryParamBuildUtil.buildParametersFromType;
|
||||
import static run.halo.app.extension.router.selector.SelectorUtil.labelAndFieldSelectorToPredicate;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.google.common.io.Files;
|
||||
import io.swagger.v3.oas.annotations.enums.ParameterIn;
|
||||
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
@ -25,22 +29,32 @@ import java.util.List;
|
|||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springdoc.core.fn.builders.requestbody.Builder;
|
||||
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
|
||||
import org.springframework.core.io.buffer.DataBuffer;
|
||||
import org.springframework.dao.OptimisticLockingFailureException;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.codec.multipart.FilePart;
|
||||
import org.springframework.http.codec.multipart.Part;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
|
||||
import org.springframework.security.core.context.SecurityContext;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.util.MultiValueMap;
|
||||
import org.springframework.util.unit.DataSize;
|
||||
import org.springframework.web.reactive.function.BodyExtractors;
|
||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
|
@ -51,6 +65,8 @@ import reactor.core.publisher.Mono;
|
|||
import reactor.util.retry.Retry;
|
||||
import run.halo.app.core.extension.Role;
|
||||
import run.halo.app.core.extension.User;
|
||||
import run.halo.app.core.extension.attachment.Attachment;
|
||||
import run.halo.app.core.extension.service.AttachmentService;
|
||||
import run.halo.app.core.extension.service.RoleService;
|
||||
import run.halo.app.core.extension.service.UserService;
|
||||
import run.halo.app.extension.Comparators;
|
||||
|
@ -59,6 +75,8 @@ import run.halo.app.extension.Metadata;
|
|||
import run.halo.app.extension.MetadataUtil;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.extension.router.IListRequest;
|
||||
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
|
||||
import run.halo.app.infra.SystemSetting;
|
||||
import run.halo.app.infra.utils.JsonUtils;
|
||||
|
||||
@Component
|
||||
|
@ -66,9 +84,14 @@ import run.halo.app.infra.utils.JsonUtils;
|
|||
public class UserEndpoint implements CustomEndpoint {
|
||||
|
||||
private static final String SELF_USER = "-";
|
||||
private static final String USER_AVATAR_GROUP_NAME = "user-avatar-group";
|
||||
private static final String DEFAULT_USER_AVATAR_ATTACHMENT_POLICY_NAME = "default-policy";
|
||||
private static final DataSize MAX_AVATAR_FILE_SIZE = DataSize.ofMegabytes(2L);
|
||||
private final ReactiveExtensionClient client;
|
||||
private final UserService userService;
|
||||
private final RoleService roleService;
|
||||
private final AttachmentService attachmentService;
|
||||
private final SystemConfigurableEnvironmentFetcher environmentFetcher;
|
||||
|
||||
@Override
|
||||
public RouterFunction<ServerResponse> endpoint() {
|
||||
|
@ -145,9 +168,137 @@ public class UserEndpoint implements CustomEndpoint {
|
|||
.implementation(generateGenericClass(ListedUser.class)));
|
||||
buildParametersFromType(builder, ListRequest.class);
|
||||
})
|
||||
.POST("users/{name}/avatar", contentType(MediaType.MULTIPART_FORM_DATA),
|
||||
this::uploadUserAvatar,
|
||||
builder -> builder
|
||||
.operationId("UploadUserAvatar")
|
||||
.description("upload user avatar")
|
||||
.tag(tag)
|
||||
.parameter(parameterBuilder()
|
||||
.in(ParameterIn.PATH)
|
||||
.name("name")
|
||||
.description("User name")
|
||||
.required(true)
|
||||
)
|
||||
.requestBody(Builder.requestBodyBuilder()
|
||||
.required(true)
|
||||
.content(contentBuilder()
|
||||
.mediaType(MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
.schema(schemaBuilder().implementation(IAvatarUploadRequest.class))
|
||||
))
|
||||
.response(responseBuilder().implementation(User.class))
|
||||
)
|
||||
.DELETE("users/{name}/avatar", this::deleteUserAvatar, builder -> builder
|
||||
.tag(tag)
|
||||
.operationId("DeleteUserAvatar")
|
||||
.description("delete user avatar")
|
||||
.parameter(parameterBuilder()
|
||||
.in(ParameterIn.PATH)
|
||||
.name("name")
|
||||
.description("User name")
|
||||
.required(true)
|
||||
)
|
||||
.response(responseBuilder().implementation(User.class))
|
||||
.build())
|
||||
.build();
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> deleteUserAvatar(ServerRequest request) {
|
||||
final var nameInPath = request.pathVariable("name");
|
||||
return getUserOrSelf(nameInPath)
|
||||
.flatMap(user -> {
|
||||
MetadataUtil.nullSafeAnnotations(user)
|
||||
.remove(User.AVATAR_ATTACHMENT_NAME_ANNO);
|
||||
return client.update(user);
|
||||
})
|
||||
.flatMap(user -> ServerResponse.ok().bodyValue(user));
|
||||
}
|
||||
|
||||
private Mono<User> getUserOrSelf(String name) {
|
||||
if (!SELF_USER.equals(name)) {
|
||||
return client.get(User.class, name);
|
||||
}
|
||||
return ReactiveSecurityContextHolder.getContext()
|
||||
.map(SecurityContext::getAuthentication)
|
||||
.map(Authentication::getName)
|
||||
.flatMap(currentUserName -> client.get(User.class, currentUserName));
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> uploadUserAvatar(ServerRequest request) {
|
||||
final var username = request.pathVariable("name");
|
||||
return request.body(BodyExtractors.toMultipartData())
|
||||
.map(AvatarUploadRequest::new)
|
||||
.flatMap(this::uploadAvatar)
|
||||
.flatMap(attachment -> getUserOrSelf(username)
|
||||
.flatMap(user -> {
|
||||
MetadataUtil.nullSafeAnnotations(user)
|
||||
.put(User.AVATAR_ATTACHMENT_NAME_ANNO,
|
||||
attachment.getMetadata().getName());
|
||||
return client.update(user);
|
||||
})
|
||||
.retryWhen(Retry.backoff(5, Duration.ofMillis(100))
|
||||
.filter(OptimisticLockingFailureException.class::isInstance))
|
||||
)
|
||||
.flatMap(user -> ServerResponse.ok().bodyValue(user));
|
||||
}
|
||||
|
||||
public interface IAvatarUploadRequest {
|
||||
@Schema(requiredMode = REQUIRED, description = "Avatar file")
|
||||
FilePart getFile();
|
||||
}
|
||||
|
||||
public record AvatarUploadRequest(MultiValueMap<String, Part> formData) {
|
||||
public FilePart getFile() {
|
||||
Part file = formData.getFirst("file");
|
||||
if (file == null) {
|
||||
throw new ServerWebInputException("No file part found in the request");
|
||||
}
|
||||
|
||||
if (!(file instanceof FilePart filePart)) {
|
||||
throw new ServerWebInputException("Invalid part of file");
|
||||
}
|
||||
|
||||
if (!filePart.filename().endsWith(".png")) {
|
||||
throw new ServerWebInputException("Only support avatar in PNG format");
|
||||
}
|
||||
return filePart;
|
||||
}
|
||||
}
|
||||
|
||||
private Mono<Attachment> uploadAvatar(AvatarUploadRequest uploadRequest) {
|
||||
return environmentFetcher.fetch(SystemSetting.User.GROUP, SystemSetting.User.class)
|
||||
.switchIfEmpty(
|
||||
Mono.error(new IllegalStateException("User setting is not configured"))
|
||||
)
|
||||
.flatMap(userSetting -> Mono.defer(
|
||||
() -> {
|
||||
String avatarPolicy = userSetting.getAvatarPolicy();
|
||||
if (StringUtils.isBlank(avatarPolicy)) {
|
||||
avatarPolicy = DEFAULT_USER_AVATAR_ATTACHMENT_POLICY_NAME;
|
||||
}
|
||||
FilePart filePart = uploadRequest.getFile();
|
||||
var ext = Files.getFileExtension(filePart.filename());
|
||||
return attachmentService.upload(avatarPolicy,
|
||||
USER_AVATAR_GROUP_NAME,
|
||||
UUID.randomUUID() + "." + ext,
|
||||
maxSizeCheck(filePart.content()),
|
||||
filePart.headers().getContentType()
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private Flux<DataBuffer> maxSizeCheck(Flux<DataBuffer> content) {
|
||||
var lenRef = new AtomicInteger(0);
|
||||
return content.doOnNext(dataBuffer -> {
|
||||
int len = lenRef.accumulateAndGet(dataBuffer.readableByteCount(), Integer::sum);
|
||||
if (len > MAX_AVATAR_FILE_SIZE.toBytes()) {
|
||||
throw new ServerWebInputException("The avatar file needs to be smaller than "
|
||||
+ MAX_AVATAR_FILE_SIZE.toMegabytes() + " MB.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> createUser(ServerRequest request) {
|
||||
return request.bodyToMono(CreateUserRequest.class)
|
||||
.doOnNext(createUserRequest -> {
|
||||
|
@ -234,10 +385,18 @@ public class UserEndpoint implements CustomEndpoint {
|
|||
.switchIfEmpty(
|
||||
Mono.error(() -> new ServerWebInputException("Username didn't match.")))
|
||||
.map(user -> {
|
||||
currentUser.getMetadata().setAnnotations(user.getMetadata().getAnnotations());
|
||||
Map<String, String> oldAnnotations =
|
||||
MetadataUtil.nullSafeAnnotations(currentUser);
|
||||
Map<String, String> newAnnotations = user.getMetadata().getAnnotations();
|
||||
if (!CollectionUtils.isEmpty(newAnnotations)) {
|
||||
newAnnotations.put(User.LAST_AVATAR_ATTACHMENT_NAME_ANNO,
|
||||
oldAnnotations.get(User.LAST_AVATAR_ATTACHMENT_NAME_ANNO));
|
||||
newAnnotations.put(User.AVATAR_ATTACHMENT_NAME_ANNO,
|
||||
oldAnnotations.get(User.AVATAR_ATTACHMENT_NAME_ANNO));
|
||||
currentUser.getMetadata().setAnnotations(newAnnotations);
|
||||
}
|
||||
var spec = currentUser.getSpec();
|
||||
var newSpec = user.getSpec();
|
||||
spec.setAvatar(newSpec.getAvatar());
|
||||
spec.setBio(newSpec.getBio());
|
||||
spec.setDisplayName(newSpec.getDisplayName());
|
||||
spec.setTwoFactorAuthEnabled(newSpec.getTwoFactorAuthEnabled());
|
||||
|
|
|
@ -3,6 +3,7 @@ package run.halo.app.core.extension.reconciler;
|
|||
import static run.halo.app.core.extension.User.GROUP;
|
||||
import static run.halo.app.core.extension.User.KIND;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
@ -16,6 +17,8 @@ import run.halo.app.core.extension.Role;
|
|||
import run.halo.app.core.extension.RoleBinding;
|
||||
import run.halo.app.core.extension.User;
|
||||
import run.halo.app.core.extension.UserConnection;
|
||||
import run.halo.app.core.extension.attachment.Attachment;
|
||||
import run.halo.app.core.extension.service.AttachmentService;
|
||||
import run.halo.app.core.extension.service.RoleService;
|
||||
import run.halo.app.extension.ExtensionClient;
|
||||
import run.halo.app.extension.GroupKind;
|
||||
|
@ -37,6 +40,7 @@ public class UserReconciler implements Reconciler<Request> {
|
|||
private final ExtensionClient client;
|
||||
private final ExternalUrlSupplier externalUrlSupplier;
|
||||
private final RoleService roleService;
|
||||
private final AttachmentService attachmentService;
|
||||
private final RetryTemplate retryTemplate = RetryTemplate.builder()
|
||||
.maxAttempts(20)
|
||||
.fixedBackoff(300)
|
||||
|
@ -54,10 +58,48 @@ public class UserReconciler implements Reconciler<Request> {
|
|||
addFinalizerIfNecessary(user);
|
||||
ensureRoleNamesAnno(request.name());
|
||||
updatePermalink(request.name());
|
||||
handleAvatar(request.name());
|
||||
});
|
||||
return new Result(false, null);
|
||||
}
|
||||
|
||||
private void handleAvatar(String name) {
|
||||
client.fetch(User.class, name).ifPresent(user -> {
|
||||
Map<String, String> annotations = MetadataUtil.nullSafeAnnotations(user);
|
||||
|
||||
String avatarAttachmentName = annotations.get(User.AVATAR_ATTACHMENT_NAME_ANNO);
|
||||
String oldAvatarAttachmentName = annotations.get(User.LAST_AVATAR_ATTACHMENT_NAME_ANNO);
|
||||
|
||||
if (StringUtils.isNotBlank(oldAvatarAttachmentName)
|
||||
&& !StringUtils.equals(oldAvatarAttachmentName, avatarAttachmentName)) {
|
||||
client.fetch(Attachment.class, oldAvatarAttachmentName)
|
||||
.ifPresent(client::delete);
|
||||
annotations.remove(User.LAST_AVATAR_ATTACHMENT_NAME_ANNO);
|
||||
oldAvatarAttachmentName = null;
|
||||
}
|
||||
|
||||
if (StringUtils.isBlank(oldAvatarAttachmentName)
|
||||
&& StringUtils.isNotBlank(avatarAttachmentName)) {
|
||||
oldAvatarAttachmentName = avatarAttachmentName;
|
||||
annotations.put(User.LAST_AVATAR_ATTACHMENT_NAME_ANNO, oldAvatarAttachmentName);
|
||||
}
|
||||
|
||||
if (StringUtils.isNotBlank(avatarAttachmentName)) {
|
||||
client.fetch(Attachment.class, avatarAttachmentName)
|
||||
.ifPresent(attachment -> {
|
||||
URI avatarUri = attachmentService.getPermalink(attachment).block();
|
||||
if (avatarUri == null) {
|
||||
throw new IllegalStateException("User avatar attachment not found.");
|
||||
}
|
||||
user.getSpec().setAvatar(avatarUri.toString());
|
||||
});
|
||||
} else {
|
||||
user.getSpec().setAvatar(null);
|
||||
}
|
||||
client.update(user);
|
||||
});
|
||||
}
|
||||
|
||||
private void ensureRoleNamesAnno(String name) {
|
||||
client.fetch(User.class, name).ifPresent(user -> {
|
||||
Map<String, String> annotations = MetadataUtil.nullSafeAnnotations(user);
|
||||
|
|
|
@ -35,3 +35,14 @@ spec:
|
|||
name: location
|
||||
label: 存储位置
|
||||
help: ~/.halo2/attachments/upload 下的子目录
|
||||
---
|
||||
apiVersion: storage.halo.run/v1alpha1
|
||||
kind: Group
|
||||
metadata:
|
||||
name: user-avatar-group
|
||||
labels:
|
||||
halo.run/hidden: "true"
|
||||
finalizers:
|
||||
- system-protection
|
||||
spec:
|
||||
displayName: UserAvatar
|
|
@ -35,6 +35,10 @@ rules:
|
|||
resources: [ "users" ]
|
||||
resourceNames: [ "-" ]
|
||||
verbs: [ "get", "update" ]
|
||||
- apiGroups: [ "api.console.halo.run" ]
|
||||
resources: [ "users/avatar" ]
|
||||
resourceNames: [ "-" ]
|
||||
verbs: [ "create", "delete" ]
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
kind: "Role"
|
||||
|
|
|
@ -16,7 +16,7 @@ rules:
|
|||
resources: [ "users" ]
|
||||
verbs: [ "create", "patch", "update", "delete", "deletecollection" ]
|
||||
- apiGroups: [ "api.console.halo.run" ]
|
||||
resources: [ "users", "users/permissions", "users/password" ]
|
||||
resources: [ "users", "users/permissions", "users/password", "users/avatar" ]
|
||||
verbs: [ "*" ]
|
||||
---
|
||||
apiVersion: v1alpha1
|
||||
|
|
|
@ -78,6 +78,10 @@ spec:
|
|||
- $formkit: roleSelect
|
||||
name: defaultRole
|
||||
label: "默认角色"
|
||||
- $formkit: attachmentPolicySelect
|
||||
name: avatarPolicy
|
||||
label: "头像存储位置"
|
||||
value: "default-policy"
|
||||
- group: comment
|
||||
label: 评论设置
|
||||
formSchema:
|
||||
|
|
|
@ -7,6 +7,7 @@ import static org.mockito.ArgumentMatchers.anyString;
|
|||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.same;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
@ -17,6 +18,7 @@ import static run.halo.app.extension.GroupVersionKind.fromExtension;
|
|||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
@ -30,19 +32,25 @@ import org.mockito.Mock;
|
|||
import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.client.MultipartBodyBuilder;
|
||||
import org.springframework.security.test.context.support.WithMockUser;
|
||||
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||
import org.springframework.web.reactive.function.BodyInserters;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.Role;
|
||||
import run.halo.app.core.extension.RoleBinding;
|
||||
import run.halo.app.core.extension.User;
|
||||
import run.halo.app.core.extension.attachment.Attachment;
|
||||
import run.halo.app.core.extension.service.AttachmentService;
|
||||
import run.halo.app.core.extension.service.RoleService;
|
||||
import run.halo.app.core.extension.service.UserService;
|
||||
import run.halo.app.extension.ListResult;
|
||||
import run.halo.app.extension.Metadata;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.extension.exception.ExtensionNotFoundException;
|
||||
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
|
||||
import run.halo.app.infra.SystemSetting;
|
||||
import run.halo.app.infra.utils.JsonUtils;
|
||||
|
||||
@SpringBootTest
|
||||
|
@ -55,6 +63,12 @@ class UserEndpointTest {
|
|||
@Mock
|
||||
RoleService roleService;
|
||||
|
||||
@Mock
|
||||
AttachmentService attachmentService;
|
||||
|
||||
@Mock
|
||||
SystemConfigurableEnvironmentFetcher environmentFetcher;
|
||||
|
||||
@Mock
|
||||
ReactiveExtensionClient client;
|
||||
|
||||
|
@ -512,4 +526,104 @@ class UserEndpointTest {
|
|||
.exchange()
|
||||
.expectStatus().isOk();
|
||||
}
|
||||
|
||||
@Nested
|
||||
class AvatarUploadTest {
|
||||
@Test
|
||||
void respondWithErrorIfTypeNotPNG() {
|
||||
|
||||
var multipartBodyBuilder = new MultipartBodyBuilder();
|
||||
multipartBodyBuilder.part("file", "fake-file")
|
||||
.contentType(MediaType.IMAGE_JPEG)
|
||||
.filename("fake-filename.jpg");
|
||||
|
||||
SystemSetting.User user = mock(SystemSetting.User.class);
|
||||
when(environmentFetcher.fetch(SystemSetting.User.GROUP, SystemSetting.User.class))
|
||||
.thenReturn(Mono.just(user));
|
||||
|
||||
webClient
|
||||
.post()
|
||||
.uri("/users/-/avatar")
|
||||
.contentType(MediaType.MULTIPART_FORM_DATA)
|
||||
.body(BodyInserters.fromMultipartData(multipartBodyBuilder.build()))
|
||||
.exchange()
|
||||
.expectStatus()
|
||||
.is4xxClientError();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldUploadSuccessfully() {
|
||||
var currentUser = createUser("fake-user");
|
||||
|
||||
Attachment attachment = new Attachment();
|
||||
Metadata metadata = new Metadata();
|
||||
metadata.setName("fake-attachment");
|
||||
attachment.setMetadata(metadata);
|
||||
|
||||
var multipartBodyBuilder = new MultipartBodyBuilder();
|
||||
multipartBodyBuilder.part("file", "fake-file")
|
||||
.contentType(MediaType.IMAGE_PNG)
|
||||
.filename("fake-filename.png");
|
||||
|
||||
SystemSetting.User user = mock(SystemSetting.User.class);
|
||||
when(environmentFetcher.fetch(SystemSetting.User.GROUP, SystemSetting.User.class))
|
||||
.thenReturn(Mono.just(user));
|
||||
when(client.get(User.class, "fake-user")).thenReturn(Mono.just(currentUser));
|
||||
when(attachmentService.upload(anyString(), anyString(), anyString(),
|
||||
any(), any(MediaType.IMAGE_PNG.getClass()))).thenReturn(Mono.just(attachment));
|
||||
|
||||
when(client.update(currentUser)).thenReturn(Mono.just(currentUser));
|
||||
|
||||
webClient.post()
|
||||
.uri("/users/-/avatar")
|
||||
.contentType(MediaType.MULTIPART_FORM_DATA)
|
||||
.body(BodyInserters.fromMultipartData(multipartBodyBuilder.build()))
|
||||
.exchange()
|
||||
.expectStatus()
|
||||
.isOk()
|
||||
.expectBody()
|
||||
.json("""
|
||||
{
|
||||
"spec":{
|
||||
"displayName":"Faker",
|
||||
"avatar":"fake-avatar.png",
|
||||
"email":"hi@halo.run",
|
||||
"password":"fake-password",
|
||||
"bio":"Fake bio"
|
||||
},
|
||||
"status":null,
|
||||
"apiVersion":"v1alpha1",
|
||||
"kind":"User",
|
||||
"metadata":{
|
||||
"name":"fake-user",
|
||||
"annotations":{
|
||||
"halo.run/avatar-attachment-name":
|
||||
"fake-attachment"
|
||||
}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
verify(client).get(User.class, "fake-user");
|
||||
verify(client).update(currentUser);
|
||||
}
|
||||
|
||||
User createUser(String name) {
|
||||
var spec = new User.UserSpec();
|
||||
spec.setEmail("hi@halo.run");
|
||||
spec.setBio("Fake bio");
|
||||
spec.setDisplayName("Faker");
|
||||
spec.setAvatar("fake-avatar.png");
|
||||
spec.setPassword("fake-password");
|
||||
|
||||
var metadata = new Metadata();
|
||||
metadata.setName(name);
|
||||
metadata.setAnnotations(new HashMap<>());
|
||||
|
||||
var user = new User();
|
||||
user.setSpec(spec);
|
||||
user.setMetadata(metadata);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -64,10 +64,10 @@ class UserReconcilerTest {
|
|||
when(client.fetch(eq(User.class), eq("fake-user")))
|
||||
.thenReturn(Optional.of(user("fake-user")));
|
||||
userReconciler.reconcile(new Reconciler.Request("fake-user"));
|
||||
verify(client, times(2)).update(any(User.class));
|
||||
verify(client, times(3)).update(any(User.class));
|
||||
|
||||
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
|
||||
verify(client, times(2)).update(captor.capture());
|
||||
verify(client, times(3)).update(captor.capture());
|
||||
assertThat(captor.getValue().getStatus().getPermalink())
|
||||
.isEqualTo("http://localhost:8090/authors/fake-user");
|
||||
}
|
||||
|
@ -77,7 +77,7 @@ class UserReconcilerTest {
|
|||
when(client.fetch(eq(User.class), eq(AnonymousUserConst.PRINCIPAL)))
|
||||
.thenReturn(Optional.of(user(AnonymousUserConst.PRINCIPAL)));
|
||||
userReconciler.reconcile(new Reconciler.Request(AnonymousUserConst.PRINCIPAL));
|
||||
verify(client, times(1)).update(any(User.class));
|
||||
verify(client, times(2)).update(any(User.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -102,7 +102,7 @@ class UserReconcilerTest {
|
|||
|
||||
userReconciler.reconcile(new Reconciler.Request("fake-user"));
|
||||
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
|
||||
verify(client, times(2)).update(captor.capture());
|
||||
verify(client, times(3)).update(captor.capture());
|
||||
User user = captor.getAllValues().get(1);
|
||||
assertThat(user.getMetadata().getAnnotations().get(User.ROLE_NAMES_ANNO))
|
||||
.isEqualTo("[\"fake-role\"]");
|
||||
|
@ -113,6 +113,7 @@ class UserReconcilerTest {
|
|||
user.setMetadata(new Metadata());
|
||||
user.getMetadata().setName(name);
|
||||
user.getMetadata().setFinalizers(Set.of("user-protection"));
|
||||
user.setSpec(new User.UserSpec());
|
||||
return user;
|
||||
}
|
||||
}
|
|
@ -74,6 +74,7 @@
|
|||
"axios": "^0.27.2",
|
||||
"codemirror": "^6.0.1",
|
||||
"colorjs.io": "^0.4.3",
|
||||
"cropperjs": "^1.5.13",
|
||||
"dayjs": "^1.11.7",
|
||||
"emoji-mart": "^5.3.3",
|
||||
"fastq": "^1.15.0",
|
||||
|
|
|
@ -185,6 +185,60 @@ export const ApiConsoleHaloRunV1alpha1UserApiAxiosParamCreator = function (
|
|||
options: localVarRequestOptions,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* delete user avatar
|
||||
* @param {string} name User name
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
deleteUserAvatar: async (
|
||||
name: string,
|
||||
options: AxiosRequestConfig = {}
|
||||
): Promise<RequestArgs> => {
|
||||
// verify required parameter 'name' is not null or undefined
|
||||
assertParamExists("deleteUserAvatar", "name", name);
|
||||
const localVarPath =
|
||||
`/apis/api.console.halo.run/v1alpha1/users/{name}/avatar`.replace(
|
||||
`{${"name"}}`,
|
||||
encodeURIComponent(String(name))
|
||||
);
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
let baseOptions;
|
||||
if (configuration) {
|
||||
baseOptions = configuration.baseOptions;
|
||||
}
|
||||
|
||||
const localVarRequestOptions = {
|
||||
method: "DELETE",
|
||||
...baseOptions,
|
||||
...options,
|
||||
};
|
||||
const localVarHeaderParameter = {} as any;
|
||||
const localVarQueryParameter = {} as any;
|
||||
|
||||
// authentication BasicAuth required
|
||||
// http basic authentication required
|
||||
setBasicAuthToObject(localVarRequestOptions, configuration);
|
||||
|
||||
// authentication BearerAuth required
|
||||
// http bearer authentication required
|
||||
await setBearerAuthToObject(localVarHeaderParameter, configuration);
|
||||
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions =
|
||||
baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {
|
||||
...localVarHeaderParameter,
|
||||
...headersFromBaseOptions,
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
return {
|
||||
url: toPathString(localVarUrlObj),
|
||||
options: localVarRequestOptions,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* Get current user detail
|
||||
* @param {*} [options] Override http request option.
|
||||
|
@ -544,6 +598,74 @@ export const ApiConsoleHaloRunV1alpha1UserApiAxiosParamCreator = function (
|
|||
configuration
|
||||
);
|
||||
|
||||
return {
|
||||
url: toPathString(localVarUrlObj),
|
||||
options: localVarRequestOptions,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* upload user avatar
|
||||
* @param {string} name User name
|
||||
* @param {File} file
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
uploadUserAvatar: async (
|
||||
name: string,
|
||||
file: File,
|
||||
options: AxiosRequestConfig = {}
|
||||
): Promise<RequestArgs> => {
|
||||
// verify required parameter 'name' is not null or undefined
|
||||
assertParamExists("uploadUserAvatar", "name", name);
|
||||
// verify required parameter 'file' is not null or undefined
|
||||
assertParamExists("uploadUserAvatar", "file", file);
|
||||
const localVarPath =
|
||||
`/apis/api.console.halo.run/v1alpha1/users/{name}/avatar`.replace(
|
||||
`{${"name"}}`,
|
||||
encodeURIComponent(String(name))
|
||||
);
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
let baseOptions;
|
||||
if (configuration) {
|
||||
baseOptions = configuration.baseOptions;
|
||||
}
|
||||
|
||||
const localVarRequestOptions = {
|
||||
method: "POST",
|
||||
...baseOptions,
|
||||
...options,
|
||||
};
|
||||
const localVarHeaderParameter = {} as any;
|
||||
const localVarQueryParameter = {} as any;
|
||||
const localVarFormParams = new ((configuration &&
|
||||
configuration.formDataCtor) ||
|
||||
FormData)();
|
||||
|
||||
// authentication BasicAuth required
|
||||
// http basic authentication required
|
||||
setBasicAuthToObject(localVarRequestOptions, configuration);
|
||||
|
||||
// authentication BearerAuth required
|
||||
// http bearer authentication required
|
||||
await setBearerAuthToObject(localVarHeaderParameter, configuration);
|
||||
|
||||
if (file !== undefined) {
|
||||
localVarFormParams.append("file", file as any);
|
||||
}
|
||||
|
||||
localVarHeaderParameter["Content-Type"] = "multipart/form-data";
|
||||
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions =
|
||||
baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {
|
||||
...localVarHeaderParameter,
|
||||
...headersFromBaseOptions,
|
||||
...options.headers,
|
||||
};
|
||||
localVarRequestOptions.data = localVarFormParams;
|
||||
|
||||
return {
|
||||
url: toPathString(localVarUrlObj),
|
||||
options: localVarRequestOptions,
|
||||
|
@ -611,6 +733,27 @@ export const ApiConsoleHaloRunV1alpha1UserApiFp = function (
|
|||
configuration
|
||||
);
|
||||
},
|
||||
/**
|
||||
* delete user avatar
|
||||
* @param {string} name User name
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async deleteUserAvatar(
|
||||
name: string,
|
||||
options?: AxiosRequestConfig
|
||||
): Promise<
|
||||
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<User>
|
||||
> {
|
||||
const localVarAxiosArgs =
|
||||
await localVarAxiosParamCreator.deleteUserAvatar(name, options);
|
||||
return createRequestFunction(
|
||||
localVarAxiosArgs,
|
||||
globalAxios,
|
||||
BASE_PATH,
|
||||
configuration
|
||||
);
|
||||
},
|
||||
/**
|
||||
* Get current user detail
|
||||
* @param {*} [options] Override http request option.
|
||||
|
@ -767,6 +910,29 @@ export const ApiConsoleHaloRunV1alpha1UserApiFp = function (
|
|||
configuration
|
||||
);
|
||||
},
|
||||
/**
|
||||
* upload user avatar
|
||||
* @param {string} name User name
|
||||
* @param {File} file
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async uploadUserAvatar(
|
||||
name: string,
|
||||
file: File,
|
||||
options?: AxiosRequestConfig
|
||||
): Promise<
|
||||
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<User>
|
||||
> {
|
||||
const localVarAxiosArgs =
|
||||
await localVarAxiosParamCreator.uploadUserAvatar(name, file, options);
|
||||
return createRequestFunction(
|
||||
localVarAxiosArgs,
|
||||
globalAxios,
|
||||
BASE_PATH,
|
||||
configuration
|
||||
);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -813,6 +979,20 @@ export const ApiConsoleHaloRunV1alpha1UserApiFactory = function (
|
|||
.createUser(requestParameters.createUserRequest, options)
|
||||
.then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* delete user avatar
|
||||
* @param {ApiConsoleHaloRunV1alpha1UserApiDeleteUserAvatarRequest} requestParameters Request parameters.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
deleteUserAvatar(
|
||||
requestParameters: ApiConsoleHaloRunV1alpha1UserApiDeleteUserAvatarRequest,
|
||||
options?: AxiosRequestConfig
|
||||
): AxiosPromise<User> {
|
||||
return localVarFp
|
||||
.deleteUserAvatar(requestParameters.name, options)
|
||||
.then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Get current user detail
|
||||
* @param {*} [options] Override http request option.
|
||||
|
@ -908,6 +1088,24 @@ export const ApiConsoleHaloRunV1alpha1UserApiFactory = function (
|
|||
.updateCurrentUser(requestParameters.user, options)
|
||||
.then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* upload user avatar
|
||||
* @param {ApiConsoleHaloRunV1alpha1UserApiUploadUserAvatarRequest} requestParameters Request parameters.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
uploadUserAvatar(
|
||||
requestParameters: ApiConsoleHaloRunV1alpha1UserApiUploadUserAvatarRequest,
|
||||
options?: AxiosRequestConfig
|
||||
): AxiosPromise<User> {
|
||||
return localVarFp
|
||||
.uploadUserAvatar(
|
||||
requestParameters.name,
|
||||
requestParameters.file,
|
||||
options
|
||||
)
|
||||
.then((request) => request(axios, basePath));
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -946,6 +1144,20 @@ export interface ApiConsoleHaloRunV1alpha1UserApiCreateUserRequest {
|
|||
readonly createUserRequest: CreateUserRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request parameters for deleteUserAvatar operation in ApiConsoleHaloRunV1alpha1UserApi.
|
||||
* @export
|
||||
* @interface ApiConsoleHaloRunV1alpha1UserApiDeleteUserAvatarRequest
|
||||
*/
|
||||
export interface ApiConsoleHaloRunV1alpha1UserApiDeleteUserAvatarRequest {
|
||||
/**
|
||||
* User name
|
||||
* @type {string}
|
||||
* @memberof ApiConsoleHaloRunV1alpha1UserApiDeleteUserAvatar
|
||||
*/
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request parameters for getPermissions operation in ApiConsoleHaloRunV1alpha1UserApi.
|
||||
* @export
|
||||
|
@ -1065,6 +1277,27 @@ export interface ApiConsoleHaloRunV1alpha1UserApiUpdateCurrentUserRequest {
|
|||
readonly user: User;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request parameters for uploadUserAvatar operation in ApiConsoleHaloRunV1alpha1UserApi.
|
||||
* @export
|
||||
* @interface ApiConsoleHaloRunV1alpha1UserApiUploadUserAvatarRequest
|
||||
*/
|
||||
export interface ApiConsoleHaloRunV1alpha1UserApiUploadUserAvatarRequest {
|
||||
/**
|
||||
* User name
|
||||
* @type {string}
|
||||
* @memberof ApiConsoleHaloRunV1alpha1UserApiUploadUserAvatar
|
||||
*/
|
||||
readonly name: string;
|
||||
|
||||
/**
|
||||
*
|
||||
* @type {File}
|
||||
* @memberof ApiConsoleHaloRunV1alpha1UserApiUploadUserAvatar
|
||||
*/
|
||||
readonly file: File;
|
||||
}
|
||||
|
||||
/**
|
||||
* ApiConsoleHaloRunV1alpha1UserApi - object-oriented interface
|
||||
* @export
|
||||
|
@ -1108,6 +1341,22 @@ export class ApiConsoleHaloRunV1alpha1UserApi extends BaseAPI {
|
|||
.then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* delete user avatar
|
||||
* @param {ApiConsoleHaloRunV1alpha1UserApiDeleteUserAvatarRequest} requestParameters Request parameters.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
* @memberof ApiConsoleHaloRunV1alpha1UserApi
|
||||
*/
|
||||
public deleteUserAvatar(
|
||||
requestParameters: ApiConsoleHaloRunV1alpha1UserApiDeleteUserAvatarRequest,
|
||||
options?: AxiosRequestConfig
|
||||
) {
|
||||
return ApiConsoleHaloRunV1alpha1UserApiFp(this.configuration)
|
||||
.deleteUserAvatar(requestParameters.name, options)
|
||||
.then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user detail
|
||||
* @param {*} [options] Override http request option.
|
||||
|
@ -1212,4 +1461,20 @@ export class ApiConsoleHaloRunV1alpha1UserApi extends BaseAPI {
|
|||
.updateCurrentUser(requestParameters.user, options)
|
||||
.then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* upload user avatar
|
||||
* @param {ApiConsoleHaloRunV1alpha1UserApiUploadUserAvatarRequest} requestParameters Request parameters.
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
* @memberof ApiConsoleHaloRunV1alpha1UserApi
|
||||
*/
|
||||
public uploadUserAvatar(
|
||||
requestParameters: ApiConsoleHaloRunV1alpha1UserApiUploadUserAvatarRequest,
|
||||
options?: AxiosRequestConfig
|
||||
) {
|
||||
return ApiConsoleHaloRunV1alpha1UserApiFp(this.configuration)
|
||||
.uploadUserAvatar(requestParameters.name, requestParameters.file, options)
|
||||
.then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -59,6 +59,12 @@ import IconArrowDownCircleLine from "~icons/ri/arrow-down-circle-line";
|
|||
import IconTerminalBoxLine from "~icons/ri/terminal-box-line";
|
||||
import IconClipboardLine from "~icons/ri/clipboard-line";
|
||||
import IconLockPasswordLine from "~icons/ri/lock-password-line";
|
||||
import IconRiPencilFill from "~icons/ri/pencil-fill";
|
||||
import IconZoomInLine from "~icons/ri/zoom-in-line";
|
||||
import IconZoomOutLine from "~icons/ri/zoom-out-line";
|
||||
import IconArrowLeftRightLine from "~icons/ri/arrow-left-right-line";
|
||||
import IconArrowUpDownLine from "~icons/ri/arrow-up-down-line";
|
||||
import IconRiUpload2Fill from "~icons/ri/upload-2-fill";
|
||||
|
||||
export {
|
||||
IconDashboard,
|
||||
|
@ -122,4 +128,10 @@ export {
|
|||
IconTerminalBoxLine,
|
||||
IconClipboardLine,
|
||||
IconLockPasswordLine,
|
||||
IconRiPencilFill,
|
||||
IconZoomInLine,
|
||||
IconZoomOutLine,
|
||||
IconArrowLeftRightLine,
|
||||
IconArrowUpDownLine,
|
||||
IconRiUpload2Fill,
|
||||
};
|
||||
|
|
|
@ -128,6 +128,9 @@ importers:
|
|||
colorjs.io:
|
||||
specifier: ^0.4.3
|
||||
version: 0.4.3
|
||||
cropperjs:
|
||||
specifier: ^1.5.13
|
||||
version: 1.5.13
|
||||
dayjs:
|
||||
specifier: ^1.11.7
|
||||
version: 1.11.7
|
||||
|
@ -5550,6 +5553,10 @@ packages:
|
|||
/crelt@1.0.5:
|
||||
resolution: {integrity: sha512-+BO9wPPi+DWTDcNYhr/W90myha8ptzftZT+LwcmUbbok0rcP/fequmFYCw8NMoH7pkAZQzU78b3kYrlua5a9eA==}
|
||||
|
||||
/cropperjs@1.5.13:
|
||||
resolution: {integrity: sha512-by7jKAo73y5/Do0K6sxdTKHgndY0NMjG2bEdgeJxycbcmHuCiMXqw8sxy5C5Y5WTOTcDGmbT7Sr5CgKOXR06OA==}
|
||||
dev: false
|
||||
|
||||
/cross-spawn@5.1.0:
|
||||
resolution: {integrity: sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==}
|
||||
dependencies:
|
||||
|
|
|
@ -9,6 +9,8 @@ export enum rbacAnnotations {
|
|||
ROLE_NAMES = "rbac.authorization.halo.run/role-names",
|
||||
DISPLAY_NAME = "rbac.authorization.halo.run/display-name",
|
||||
DEPENDENCIES = "rbac.authorization.halo.run/dependencies",
|
||||
AVATAR_ATTACHMENT_NAME = "halo.run/avatar-attachment-name",
|
||||
LAST_AVATAR_ATTACHMENT_NAME = "halo.run/last-avatar-attachment-name",
|
||||
}
|
||||
|
||||
// content
|
||||
|
|
|
@ -874,6 +874,14 @@ core:
|
|||
bio: Bio
|
||||
creation_time: Creation time
|
||||
identity_authentication: Identity authentication
|
||||
avatar:
|
||||
title: Avatar
|
||||
toast_upload_failed: Failed to upload avatar
|
||||
toast_remove_failed: Failed to delete avatar
|
||||
cropper_modal:
|
||||
title: Crop Avatar
|
||||
remove:
|
||||
title: Are you sure you want to delete the avatar?
|
||||
role:
|
||||
title: Roles
|
||||
common:
|
||||
|
|
|
@ -874,6 +874,14 @@ core:
|
|||
bio: 描述
|
||||
creation_time: 注册时间
|
||||
identity_authentication: 登录方式
|
||||
avatar:
|
||||
title: 头像
|
||||
toast_upload_failed: 上传头像失败
|
||||
toast_remove_failed: 删除头像失败
|
||||
cropper_modal:
|
||||
title: 裁剪头像
|
||||
remove:
|
||||
title: 确定要删除头像吗?
|
||||
role:
|
||||
title: 角色
|
||||
common:
|
||||
|
|
|
@ -874,6 +874,14 @@ core:
|
|||
bio: 描述
|
||||
creation_time: 註冊時間
|
||||
identity_authentication: 登入方式
|
||||
avatar:
|
||||
title: 頭像
|
||||
toast_upload_failed: 上傳頭像失敗
|
||||
toast_remove_failed: 刪除頭像失敗
|
||||
cropper_modal:
|
||||
title: 裁剪頭像
|
||||
remove:
|
||||
title: 確定要刪除頭像嗎?
|
||||
role:
|
||||
title: 角色
|
||||
common:
|
||||
|
|
|
@ -0,0 +1,259 @@
|
|||
<script setup lang="ts">
|
||||
import {
|
||||
IconZoomInLine,
|
||||
IconZoomOutLine,
|
||||
IconArrowLeftRightLine,
|
||||
IconArrowUpDownLine,
|
||||
IconRefreshLine,
|
||||
IconRiUpload2Fill,
|
||||
} from "@halo-dev/components";
|
||||
import { type Ref } from "vue";
|
||||
import { ref, watch } from "vue";
|
||||
import "cropperjs/dist/cropper.css";
|
||||
import Cropper from "cropperjs";
|
||||
import { onMounted } from "vue";
|
||||
import { computed } from "vue";
|
||||
import type { Component } from "vue";
|
||||
import { markRaw } from "vue";
|
||||
|
||||
export type ToolbarName =
|
||||
| "upload"
|
||||
| "zoomIn"
|
||||
| "zoomOut"
|
||||
| "flipHorizontal"
|
||||
| "flipVertical"
|
||||
| "reset";
|
||||
|
||||
export interface ToolbarItem {
|
||||
name: ToolbarName;
|
||||
icon: Component;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
file: File;
|
||||
preview?: boolean;
|
||||
cropperWidth?: number;
|
||||
cropperHeight?: number;
|
||||
cropperImageType?: "png" | "jpeg" | "webp" | "jpg";
|
||||
toolbars?: boolean | ToolbarName[];
|
||||
}>(),
|
||||
{
|
||||
preview: true,
|
||||
cropperWidth: 200,
|
||||
cropperHeight: 200,
|
||||
cropperImageType: "png",
|
||||
toolbars: true,
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "changeFile"): void;
|
||||
}>();
|
||||
|
||||
const cropper = ref<Cropper>();
|
||||
const defaultToolbars: ToolbarItem[] = [
|
||||
{
|
||||
name: "upload",
|
||||
icon: markRaw(IconRiUpload2Fill),
|
||||
onClick: () => {
|
||||
emit("changeFile");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "zoomIn",
|
||||
icon: markRaw(IconZoomInLine),
|
||||
onClick: () => {
|
||||
cropper.value?.zoom(0.1);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "zoomOut",
|
||||
icon: markRaw(IconZoomOutLine),
|
||||
onClick: () => {
|
||||
cropper.value?.zoom(-0.1);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "flipHorizontal",
|
||||
icon: markRaw(IconArrowLeftRightLine),
|
||||
onClick: () => {
|
||||
cropper.value?.scaleX(-cropper.value?.getData().scaleX || -1);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "flipVertical",
|
||||
icon: markRaw(IconArrowUpDownLine),
|
||||
onClick: () => {
|
||||
cropper.value?.scaleY(-cropper.value?.getData().scaleY || -1);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reset",
|
||||
icon: markRaw(IconRefreshLine),
|
||||
onClick: () => {
|
||||
cropper.value?.reset();
|
||||
},
|
||||
},
|
||||
];
|
||||
const previewElement = ref<HTMLElement>();
|
||||
const imageElement = ref<HTMLImageElement>() as Ref<HTMLImageElement>;
|
||||
const toolbarItems = computed(() => {
|
||||
if (props.toolbars === true) {
|
||||
return defaultToolbars;
|
||||
}
|
||||
if (Array.isArray(props.toolbars) && props.toolbars.length > 0) {
|
||||
return defaultToolbars.filter((item) =>
|
||||
(props.toolbars as string[]).includes(item.name)
|
||||
);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
cropper.value = new Cropper(imageElement.value, {
|
||||
aspectRatio: props.cropperHeight / props.cropperWidth,
|
||||
viewMode: 1,
|
||||
dragMode: "move",
|
||||
checkCrossOrigin: false,
|
||||
cropBoxResizable: false,
|
||||
center: false,
|
||||
minCropBoxWidth: props.cropperWidth,
|
||||
minCropBoxHeight: props.cropperHeight,
|
||||
preview: previewElement.value?.querySelectorAll(".preview"),
|
||||
});
|
||||
});
|
||||
|
||||
const imageUrl = ref<string>("");
|
||||
|
||||
const renderImages = (file: File) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function () {
|
||||
imageUrl.value = reader.result as string;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const getCropperFile = (): Promise<File> => {
|
||||
return new Promise<File>((resolve, reject) => {
|
||||
if (!cropper.value) {
|
||||
reject();
|
||||
return;
|
||||
}
|
||||
cropper.value
|
||||
.getCroppedCanvas({
|
||||
width: props.cropperWidth,
|
||||
height: props.cropperHeight,
|
||||
})
|
||||
.toBlob((blob) => {
|
||||
if (blob === null) {
|
||||
reject();
|
||||
return;
|
||||
}
|
||||
const fileName = props.file.name.replace(
|
||||
/\.[^/.]+$/,
|
||||
`.${props.cropperImageType}`
|
||||
);
|
||||
const file = new File([blob], fileName, {
|
||||
type: `image/${props.cropperImageType}`,
|
||||
});
|
||||
resolve(file);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.file,
|
||||
(file) => {
|
||||
if (file) {
|
||||
if (file.type.indexOf("image") === -1) {
|
||||
return;
|
||||
}
|
||||
renderImages(file);
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => imageUrl.value,
|
||||
(imageUrl) => {
|
||||
if (imageUrl) {
|
||||
cropper.value?.replace(imageUrl);
|
||||
}
|
||||
},
|
||||
{
|
||||
immediate: true,
|
||||
}
|
||||
);
|
||||
|
||||
defineExpose({
|
||||
getCropperFile,
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex flex-col-reverse sm:flex-row">
|
||||
<div
|
||||
class="relative max-h-[500px] flex-auto overflow-hidden rounded-md sm:mr-4"
|
||||
:style="{ minHeight: `${cropperHeight}px` }"
|
||||
>
|
||||
<img
|
||||
ref="imageElement"
|
||||
alt="Uploaded Image"
|
||||
class="block h-full w-full object-cover"
|
||||
/>
|
||||
<div class="absolute bottom-3 left-1/2 -translate-x-1/2">
|
||||
<ul class="flex rounded-md bg-gray-100 p-0.5 opacity-75">
|
||||
<li
|
||||
v-for="toolbar in toolbarItems"
|
||||
:key="toolbar.name"
|
||||
:title="toolbar.name"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 hover:rounded-md hover:bg-gray-200"
|
||||
@click="toolbar.onClick"
|
||||
>
|
||||
<component :is="toolbar.icon" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref="previewElement"
|
||||
class="mb-4 flex justify-around sm:mb-0 sm:inline-block sm:justify-start"
|
||||
>
|
||||
<div
|
||||
class="preview overflow-hidden rounded-md border border-gray-300 shadow-md sm:mb-4"
|
||||
:style="{ width: `${cropperWidth}px`, height: `${cropperHeight}px` }"
|
||||
></div>
|
||||
<div class="flex flex-col justify-between sm:flex-row">
|
||||
<div
|
||||
class="preview overflow-hidden rounded-full border border-gray-300"
|
||||
:style="{
|
||||
width: `${cropperWidth * 0.5}px`,
|
||||
height: `${cropperWidth * 0.5}px`,
|
||||
}"
|
||||
></div>
|
||||
<div
|
||||
class="preview overflow-hidden rounded-full border border-gray-300"
|
||||
:style="{
|
||||
width: `${cropperWidth * 0.3}px`,
|
||||
height: `${cropperWidth * 0.3}px`,
|
||||
}"
|
||||
></div>
|
||||
<div
|
||||
class="preview overflow-hidden rounded-full border border-gray-300"
|
||||
:style="{
|
||||
width: `${cropperWidth * 0.2}px`,
|
||||
height: `${cropperWidth * 0.2}px`,
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
|
@ -149,13 +149,6 @@ const handleCreateUser = async () => {
|
|||
name="phone"
|
||||
validation="length:0,20"
|
||||
></FormKit>
|
||||
<FormKit
|
||||
v-model="formState.avatar"
|
||||
:label="$t('core.user.editing_modal.fields.avatar.label')"
|
||||
type="attachment"
|
||||
name="avatar"
|
||||
validation="length:0,1024"
|
||||
></FormKit>
|
||||
<FormKit
|
||||
v-model="formState.password"
|
||||
:label="$t('core.user.change_password_modal.fields.new_password.label')"
|
||||
|
|
|
@ -42,7 +42,6 @@ const emit = defineEmits<{
|
|||
const initialFormState: User = {
|
||||
spec: {
|
||||
displayName: "",
|
||||
avatar: "",
|
||||
email: "",
|
||||
phone: "",
|
||||
password: "",
|
||||
|
@ -182,14 +181,6 @@ const handleUpdateUser = async () => {
|
|||
name="phone"
|
||||
validation="length:0,20"
|
||||
></FormKit>
|
||||
<FormKit
|
||||
v-model="formState.spec.avatar"
|
||||
:label="$t('core.user.editing_modal.fields.avatar.label')"
|
||||
type="attachment"
|
||||
name="avatar"
|
||||
:accepts="['image/*']"
|
||||
validation="length:0,1024"
|
||||
></FormKit>
|
||||
<FormKit
|
||||
v-model="formState.spec.bio"
|
||||
:label="$t('core.user.editing_modal.fields.bio.label')"
|
||||
|
|
|
@ -2,11 +2,17 @@
|
|||
import BasicLayout from "@/layouts/BasicLayout.vue";
|
||||
import { apiClient } from "@/utils/api-client";
|
||||
import {
|
||||
IconRiPencilFill,
|
||||
VButton,
|
||||
VTabbar,
|
||||
VAvatar,
|
||||
VDropdown,
|
||||
VDropdownItem,
|
||||
VModal,
|
||||
VSpace,
|
||||
Toast,
|
||||
VLoading,
|
||||
Dialog,
|
||||
} from "@halo-dev/components";
|
||||
import {
|
||||
computed,
|
||||
|
@ -14,6 +20,7 @@ import {
|
|||
provide,
|
||||
ref,
|
||||
watch,
|
||||
defineAsyncComponent,
|
||||
type ComputedRef,
|
||||
type Ref,
|
||||
} from "vue";
|
||||
|
@ -25,7 +32,22 @@ import { usePermission } from "@/utils/permission";
|
|||
import { useUserStore } from "@/stores/user";
|
||||
import { useQuery } from "@tanstack/vue-query";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useFileDialog } from "@vueuse/core";
|
||||
import { rbacAnnotations } from "@/constants/annotations";
|
||||
|
||||
const UserAvatarCropper = defineAsyncComponent(
|
||||
() => import("../components/UserAvatarCropper.vue")
|
||||
);
|
||||
|
||||
interface IUserAvatarCropperType
|
||||
extends Ref<InstanceType<typeof UserAvatarCropper>> {
|
||||
getCropperFile(): Promise<File>;
|
||||
}
|
||||
|
||||
const { open, reset, onChange } = useFileDialog({
|
||||
accept: ".jpg, .jpeg, .png",
|
||||
multiple: false,
|
||||
});
|
||||
const { currentUserHasPermission } = usePermission();
|
||||
const userStore = useUserStore();
|
||||
const { t } = useI18n();
|
||||
|
@ -50,6 +72,7 @@ const { params } = useRoute();
|
|||
|
||||
const {
|
||||
data: user,
|
||||
isFetching,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
|
@ -65,6 +88,13 @@ const {
|
|||
return data;
|
||||
}
|
||||
},
|
||||
refetchInterval: (data) => {
|
||||
const annotations = data?.user.metadata.annotations;
|
||||
return annotations?.[rbacAnnotations.AVATAR_ATTACHMENT_NAME] !==
|
||||
annotations?.[rbacAnnotations.LAST_AVATAR_ATTACHMENT_NAME]
|
||||
? 1000
|
||||
: false;
|
||||
},
|
||||
});
|
||||
|
||||
const isCurrentUser = computed(() => {
|
||||
|
@ -104,6 +134,74 @@ const handleTabChange = (id: string) => {
|
|||
router.push({ name: tab.routeName });
|
||||
}
|
||||
};
|
||||
|
||||
const userAvatarCropper = ref<IUserAvatarCropperType>();
|
||||
const showAvatarEditor = ref(false);
|
||||
const visibleCropperModal = ref(false);
|
||||
const originalFile = ref<File>() as Ref<File>;
|
||||
onChange((files) => {
|
||||
if (!files) {
|
||||
return;
|
||||
}
|
||||
if (files.length > 0) {
|
||||
originalFile.value = files[0];
|
||||
visibleCropperModal.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
const uploadSaving = ref(false);
|
||||
const handleUploadAvatar = () => {
|
||||
userAvatarCropper.value?.getCropperFile().then((file) => {
|
||||
uploadSaving.value = true;
|
||||
apiClient.user
|
||||
.uploadUserAvatar({
|
||||
name: params.name as string,
|
||||
file: file,
|
||||
})
|
||||
.then(() => {
|
||||
refetch();
|
||||
handleCloseCropperModal();
|
||||
})
|
||||
.catch(() => {
|
||||
Toast.error(t("core.user.detail.avatar.toast_upload_failed"));
|
||||
})
|
||||
.finally(() => {
|
||||
uploadSaving.value = false;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveCurrentAvatar = () => {
|
||||
Dialog.warning({
|
||||
title: t("core.user.detail.avatar.remove.title"),
|
||||
description: t("core.common.dialog.descriptions.cannot_be_recovered"),
|
||||
confirmType: "danger",
|
||||
confirmText: t("core.common.buttons.confirm"),
|
||||
cancelText: t("core.common.buttons.cancel"),
|
||||
onConfirm: async () => {
|
||||
apiClient.user
|
||||
.deleteUserAvatar({
|
||||
name: params.name as string,
|
||||
})
|
||||
.then(() => {
|
||||
refetch();
|
||||
})
|
||||
.catch(() => {
|
||||
Toast.error(t("core.user.detail.avatar.toast_remove_failed"));
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleCloseCropperModal = () => {
|
||||
visibleCropperModal.value = false;
|
||||
reset();
|
||||
};
|
||||
|
||||
const changeUploadAvatar = () => {
|
||||
reset();
|
||||
open();
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<BasicLayout>
|
||||
|
@ -119,16 +217,47 @@ const handleTabChange = (id: string) => {
|
|||
<div class="p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-row items-center gap-5">
|
||||
<div class="h-20 w-20">
|
||||
<VAvatar
|
||||
v-if="user"
|
||||
:src="user.user.spec.avatar"
|
||||
:alt="user.user.spec.displayName"
|
||||
circle
|
||||
width="100%"
|
||||
height="100%"
|
||||
class="ring-4 ring-white drop-shadow-md"
|
||||
/>
|
||||
<div class="group relative h-20 w-20">
|
||||
<VLoading v-if="isFetching" class="h-full w-full" />
|
||||
<div
|
||||
v-else
|
||||
class="h-full w-full"
|
||||
@mouseover="showAvatarEditor = true"
|
||||
@mouseout="showAvatarEditor = false"
|
||||
>
|
||||
<VAvatar
|
||||
v-if="user"
|
||||
:src="user.user.spec.avatar"
|
||||
:alt="user.user.spec.displayName"
|
||||
circle
|
||||
width="100%"
|
||||
height="100%"
|
||||
class="ring-4 ring-white drop-shadow-md"
|
||||
/>
|
||||
<VDropdown
|
||||
v-if="
|
||||
currentUserHasPermission(['system:users:manage']) ||
|
||||
isCurrentUser
|
||||
"
|
||||
>
|
||||
<div
|
||||
v-show="showAvatarEditor"
|
||||
class="absolute left-0 right-0 top-0 h-full w-full cursor-pointer rounded-full border-0 bg-black/60 text-center leading-[5rem] transition-opacity duration-300 group-hover:opacity-100"
|
||||
>
|
||||
<IconRiPencilFill
|
||||
class="inline-block w-full self-center text-2xl text-white"
|
||||
/>
|
||||
</div>
|
||||
<template #popper>
|
||||
<VDropdownItem @click="open()">
|
||||
{{ $t("core.common.buttons.upload") }}
|
||||
</VDropdownItem>
|
||||
<VDropdownItem @click="handleRemoveCurrentAvatar">
|
||||
{{ $t("core.common.buttons.delete") }}
|
||||
</VDropdownItem>
|
||||
</template>
|
||||
</VDropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div class="block">
|
||||
<h1 class="truncate text-lg font-bold text-gray-900">
|
||||
|
@ -173,5 +302,32 @@ const handleTabChange = (id: string) => {
|
|||
<RouterView></RouterView>
|
||||
</div>
|
||||
</section>
|
||||
<VModal
|
||||
:visible="visibleCropperModal"
|
||||
:width="1200"
|
||||
:title="$t('core.user.detail.avatar.cropper_modal.title')"
|
||||
@update:visible="handleCloseCropperModal"
|
||||
>
|
||||
<UserAvatarCropper
|
||||
ref="userAvatarCropper"
|
||||
:file="originalFile"
|
||||
@change-file="changeUploadAvatar"
|
||||
/>
|
||||
<template #footer>
|
||||
<VSpace>
|
||||
<VButton
|
||||
v-if="visibleCropperModal"
|
||||
:loading="uploadSaving"
|
||||
type="secondary"
|
||||
@click="handleUploadAvatar"
|
||||
>
|
||||
{{ $t("core.common.buttons.submit") }}
|
||||
</VButton>
|
||||
<VButton @click="handleCloseCropperModal">
|
||||
{{ $t("core.common.buttons.cancel") }}
|
||||
</VButton>
|
||||
</VSpace>
|
||||
</template>
|
||||
</VModal>
|
||||
</BasicLayout>
|
||||
</template>
|
||||
|
|
Loading…
Reference in New Issue