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
Takagi 2023-07-24 16:08:04 +08:00 committed by GitHub
parent fdfaa53614
commit 84093d8db0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 1084 additions and 33 deletions

View File

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

View File

@ -68,6 +68,7 @@ public class SystemSetting {
public static final String GROUP = "user";
Boolean allowRegistration;
String defaultRole;
String avatarPolicy;
}
@Data

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -78,6 +78,10 @@ spec:
- $formkit: roleSelect
name: defaultRole
label: "默认角色"
- $formkit: attachmentPolicySelect
name: avatarPolicy
label: "头像存储位置"
value: "default-policy"
- group: comment
label: 评论设置
formSchema:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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