refactor: add custom API for create user (#3803)

#### What type of PR is this?
/kind improvement
/area core
/milestone 2.5.x
/kind api-change

#### What this PR does / why we need it:
提供自定义 API 用于创建用户账号
简化了创建用户账号需要先创建账号,再分配角色再重置密码的复杂流程。
需要 Console 端适配此 PR

#### Which issue(s) this PR fixes:
Fixes #2852

#### Does this PR introduce a user-facing change?
```release-note
优化用户账号创建流程
```
pull/3838/head
guqing 2023-04-24 16:19:42 +08:00 committed by GitHub
parent 6ca2cabffb
commit fc77d51c48
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 677 additions and 102 deletions

View File

@ -2,6 +2,8 @@ package run.halo.app.core.extension.endpoint;
import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;
import static java.util.Comparator.comparing; 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.apiresponse.Builder.responseBuilder;
import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; 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.requestbody.Builder.requestBodyBuilder;
@ -13,8 +15,10 @@ import com.fasterxml.jackson.core.type.TypeReference;
import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Duration;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.LinkedHashSet; import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
@ -28,6 +32,7 @@ import java.util.regex.Pattern;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.lang.NonNull; import org.springframework.lang.NonNull;
@ -43,12 +48,14 @@ import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.ServerWebInputException; import org.springframework.web.server.ServerWebInputException;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import reactor.util.retry.Retry;
import run.halo.app.core.extension.Role; import run.halo.app.core.extension.Role;
import run.halo.app.core.extension.User; import run.halo.app.core.extension.User;
import run.halo.app.core.extension.service.RoleService; import run.halo.app.core.extension.service.RoleService;
import run.halo.app.core.extension.service.UserService; import run.halo.app.core.extension.service.UserService;
import run.halo.app.extension.Comparators; import run.halo.app.extension.Comparators;
import run.halo.app.extension.ListResult; import run.halo.app.extension.ListResult;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.MetadataUtil; import run.halo.app.extension.MetadataUtil;
import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.router.IListRequest; import run.halo.app.extension.router.IListRequest;
@ -99,6 +106,14 @@ public class UserEndpoint implements CustomEndpoint {
.required(true) .required(true)
.implementation(GrantRequest.class)) .implementation(GrantRequest.class))
.response(responseBuilder().implementation(User.class))) .response(responseBuilder().implementation(User.class)))
.POST("/users", this::createUser,
builder -> builder.operationId("CreateUser")
.description("Creates a new user.")
.tag(tag)
.requestBody(requestBodyBuilder()
.required(true)
.implementation(CreateUserRequest.class))
.response(responseBuilder().implementation(User.class)))
.GET("/users/{name}/permissions", this::getUserPermission, .GET("/users/{name}/permissions", this::getUserPermission,
builder -> builder.operationId("GetPermissions") builder -> builder.operationId("GetPermissions")
.description("Get permissions of user") .description("Get permissions of user")
@ -133,13 +148,77 @@ public class UserEndpoint implements CustomEndpoint {
.build(); .build();
} }
private Mono<ServerResponse> createUser(ServerRequest request) {
return request.bodyToMono(CreateUserRequest.class)
.doOnNext(createUserRequest -> {
if (StringUtils.isBlank(createUserRequest.name())) {
throw new ServerWebInputException("Name is required");
}
if (StringUtils.isBlank(createUserRequest.email())) {
throw new ServerWebInputException("Email is required");
}
})
.flatMap(userRequest -> {
User newUser = CreateUserRequest.from(userRequest);
return userService.createUser(newUser, userRequest.roles())
.then(Mono.defer(() -> userService.updateWithRawPassword(userRequest.name(),
userRequest.password()))
.retryWhen(Retry.backoff(5, Duration.ofMillis(100))
.filter(OptimisticLockingFailureException.class::isInstance)
)
);
})
.flatMap(user -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(user)
);
}
private Mono<ServerResponse> getUserByName(ServerRequest request) { private Mono<ServerResponse> getUserByName(ServerRequest request) {
final var name = request.pathVariable("name"); final var name = request.pathVariable("name");
return userService.getUser(name) return userService.getUser(name)
.flatMap(this::toDetailedUser) .flatMap(this::toDetailedUser)
.flatMap(user -> ServerResponse.ok() .flatMap(user -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.bodyValue(user)); .bodyValue(user)
);
}
record CreateUserRequest(@Schema(requiredMode = REQUIRED) String name,
@Schema(requiredMode = REQUIRED) String email,
String displayName,
String avatar,
String phone,
String password,
String bio,
Map<String, String> annotations,
Set<String> roles) {
/**
* <p>Creates a new user from {@link CreateUserRequest}.</p>
* Note: this method will not set password.
*
* @param userRequest user request
* @return user from request
*/
public static User from(CreateUserRequest userRequest) {
var user = new User();
user.setMetadata(new Metadata());
user.getMetadata().setName(userRequest.name());
user.getMetadata().setAnnotations(new HashMap<>());
Map<String, String> annotations =
defaultIfNull(userRequest.annotations(), Map.of());
user.getMetadata().getAnnotations().putAll(annotations);
var spec = new User.UserSpec();
user.setSpec(spec);
spec.setEmail(userRequest.email());
spec.setDisplayName(defaultIfBlank(userRequest.displayName(), userRequest.name()));
spec.setAvatar(userRequest.avatar());
spec.setPhone(userRequest.phone());
spec.setBio(userRequest.bio());
return user;
}
} }
private Mono<ServerResponse> updateProfile(ServerRequest request) { private Mono<ServerResponse> updateProfile(ServerRequest request) {
@ -165,7 +244,8 @@ public class UserEndpoint implements CustomEndpoint {
spec.setEmail(newSpec.getEmail()); spec.setEmail(newSpec.getEmail());
spec.setPhone(newSpec.getPhone()); spec.setPhone(newSpec.getPhone());
return currentUser; return currentUser;
})) })
)
.flatMap(client::update) .flatMap(client::update)
.flatMap(updatedUser -> ServerResponse.ok().bodyValue(updatedUser)); .flatMap(updatedUser -> ServerResponse.ok().bodyValue(updatedUser));
} }
@ -240,12 +320,6 @@ public class UserEndpoint implements CustomEndpoint {
.then(ServerResponse.ok().build())); .then(ServerResponse.ok().build()));
} }
private Mono<GrantRequest> checkRoles(GrantRequest request) {
return Flux.fromIterable(request.roles)
.flatMap(role -> client.get(Role.class, role))
.then(Mono.just(request));
}
record GrantRequest(Set<String> roles) { record GrantRequest(Set<String> roles) {
} }

View File

@ -21,4 +21,6 @@ public interface UserService {
Mono<User> grantRoles(String username, Set<String> roles); Mono<User> grantRoles(String username, Set<String> roles);
Mono<User> signUp(User user, String password); Mono<User> signUp(User user, String password);
Mono<User> createUser(User user, Set<String> roles);
} }

View File

@ -13,6 +13,7 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.Assert; import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils; import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import org.springframework.web.server.ServerWebInputException;
import reactor.core.publisher.Flux; import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Role; import run.halo.app.core.extension.Role;
@ -145,12 +146,14 @@ public class UserServiceImpl implements UserService {
} }
String encodedPassword = passwordEncoder.encode(password); String encodedPassword = passwordEncoder.encode(password);
user.getSpec().setPassword(encodedPassword); user.getSpec().setPassword(encodedPassword);
return createNewUser(user, defaultRole); return createUser(user, Set.of(defaultRole));
}); });
} }
private Mono<User> createNewUser(User user, String defaultRole) { @Override
public Mono<User> createUser(User user, Set<String> roleNames) {
Assert.notNull(user, "User must not be null"); Assert.notNull(user, "User must not be null");
Assert.notNull(roleNames, "Roles must not be null");
return client.fetch(User.class, user.getMetadata().getName()) return client.fetch(User.class, user.getMetadata().getName())
.hasElement() .hasElement()
.flatMap(hasUser -> { .flatMap(hasUser -> {
@ -160,10 +163,17 @@ public class UserServiceImpl implements UserService {
"problemDetail.user.duplicateName", "problemDetail.user.duplicateName",
new Object[] {user.getMetadata().getName()})); new Object[] {user.getMetadata().getName()}));
} }
return client.create(user) // Check if all roles exist
.flatMap(newUser -> grantRoles(user.getMetadata().getName(), return Flux.fromIterable(roleNames)
Set.of(defaultRole)) .flatMap(roleName -> client.fetch(Role.class, roleName)
); .switchIfEmpty(Mono.error(() -> new ServerWebInputException(
}); "Role [" + roleName + "] is not found."))
)
)
.then();
})
.then(Mono.defer(() -> client.create(user)
.flatMap(newUser -> grantRoles(user.getMetadata().getName(), roleNames)))
);
} }
} }

View File

@ -15,6 +15,9 @@ rules:
- apiGroups: [ "" ] - apiGroups: [ "" ]
resources: [ "users" ] resources: [ "users" ]
verbs: [ "create", "patch", "update", "delete", "deletecollection" ] verbs: [ "create", "patch", "update", "delete", "deletecollection" ]
- apiGroups: [ "api.console.halo.run" ]
resources: [ "users", "users/permissions", "users/password" ]
verbs: [ "*" ]
--- ---
apiVersion: v1alpha1 apiVersion: v1alpha1
kind: "Role" kind: "Role"

View File

@ -3,6 +3,7 @@ package run.halo.app.core.extension.endpoint;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anySet; import static org.mockito.ArgumentMatchers.anySet;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.same; import static org.mockito.ArgumentMatchers.same;
@ -491,4 +492,24 @@ class UserEndpointTest {
} }
} }
} @Test
void createWhenNameDuplicate() {
when(userService.createUser(any(User.class), anySet()))
.thenReturn(Mono.just(new User()));
when(userService.updateWithRawPassword(anyString(), anyString()))
.thenReturn(Mono.just(new User()));
var userRequest = new UserEndpoint.CreateUserRequest("fake-user",
"fake-email",
"",
"",
"",
"",
"",
Map.of(),
Set.of());
webClient.post().uri("/users")
.bodyValue(userRequest)
.exchange()
.expectStatus().isOk();
}
}

View File

@ -458,6 +458,7 @@ class UserServiceImplTest {
User fakeUser = fakeSignUpUser("fake-user", "fake-password"); User fakeUser = fakeSignUpUser("fake-user", "fake-password");
when(client.fetch(eq(Role.class), anyString())).thenReturn(Mono.just(new Role()));
when(client.create(any(User.class))).thenReturn(Mono.just(fakeUser)); when(client.create(any(User.class))).thenReturn(Mono.just(fakeUser));
UserServiceImpl spyUserService = spy(userService); UserServiceImpl spyUserService = spy(userService);
doReturn(Mono.just(fakeUser)).when(spyUserService).grantRoles(eq("fake-user"), doReturn(Mono.just(fakeUser)).when(spyUserService).grantRoles(eq("fake-user"),

View File

@ -9,7 +9,7 @@
"prettier": "prettier --write './src/**/*.{js,jsx,ts,tsx,json,yml,yaml}'", "prettier": "prettier --write './src/**/*.{js,jsx,ts,tsx,json,yml,yaml}'",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"release": "bumpp", "release": "bumpp",
"gen": "openapi-generator-cli generate -i http://localhost:8090/v3/api-docs/all-api -g typescript-axios -c ./src/.openapi_config.yaml -o ./src --type-mappings='set=Array' && pnpm lint && pnpm prettier" "gen": "openapi-generator-cli generate -i http://localhost:8090/v3/api-docs/all-api -g typescript-axios -c ./src/.openapi_config.yaml -o ./src --type-mappings='set=Array' --skip-validate-spec && pnpm lint && pnpm prettier"
}, },
"keywords": [], "keywords": [],
"author": "@halo-dev", "author": "@halo-dev",

View File

@ -89,6 +89,7 @@ models/contributor.ts
models/counter-list.ts models/counter-list.ts
models/counter-request.ts models/counter-request.ts
models/counter.ts models/counter.ts
models/create-user-request.ts
models/custom-templates.ts models/custom-templates.ts
models/dashboard-stats.ts models/dashboard-stats.ts
models/detailed-user.ts models/detailed-user.ts
@ -118,7 +119,6 @@ models/listed-reply-list.ts
models/listed-reply.ts models/listed-reply.ts
models/listed-single-page-list.ts models/listed-single-page-list.ts
models/listed-single-page.ts models/listed-single-page.ts
models/listed-user-list.ts
models/listed-user.ts models/listed-user.ts
models/login-history.ts models/login-history.ts
models/menu-item-list.ts models/menu-item-list.ts
@ -201,6 +201,7 @@ models/theme.ts
models/user-connection-list.ts models/user-connection-list.ts
models/user-connection-spec.ts models/user-connection-spec.ts
models/user-connection.ts models/user-connection.ts
models/user-endpoint-listed-user-list.ts
models/user-list.ts models/user-list.ts
models/user-permission.ts models/user-permission.ts
models/user-spec.ts models/user-spec.ts

View File

@ -40,14 +40,16 @@ import {
// @ts-ignore // @ts-ignore
import { ChangePasswordRequest } from "../models"; import { ChangePasswordRequest } from "../models";
// @ts-ignore // @ts-ignore
import { CreateUserRequest } from "../models";
// @ts-ignore
import { DetailedUser } from "../models"; import { DetailedUser } from "../models";
// @ts-ignore // @ts-ignore
import { GrantRequest } from "../models"; import { GrantRequest } from "../models";
// @ts-ignore // @ts-ignore
import { ListedUserList } from "../models";
// @ts-ignore
import { User } from "../models"; import { User } from "../models";
// @ts-ignore // @ts-ignore
import { UserEndpointListedUserList } from "../models";
// @ts-ignore
import { UserPermission } from "../models"; import { UserPermission } from "../models";
/** /**
* ApiConsoleHaloRunV1alpha1UserApi - axios parameter creator * ApiConsoleHaloRunV1alpha1UserApi - axios parameter creator
@ -126,6 +128,63 @@ export const ApiConsoleHaloRunV1alpha1UserApiAxiosParamCreator = function (
options: localVarRequestOptions, options: localVarRequestOptions,
}; };
}, },
/**
* Creates a new user.
* @param {CreateUserRequest} createUserRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
createUser: async (
createUserRequest: CreateUserRequest,
options: AxiosRequestConfig = {}
): Promise<RequestArgs> => {
// verify required parameter 'createUserRequest' is not null or undefined
assertParamExists("createUser", "createUserRequest", createUserRequest);
const localVarPath = `/apis/api.console.halo.run/v1alpha1/users`;
// 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;
// authentication BasicAuth required
// http basic authentication required
setBasicAuthToObject(localVarRequestOptions, configuration);
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration);
localVarHeaderParameter["Content-Type"] = "application/json";
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions =
baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {
...localVarHeaderParameter,
...headersFromBaseOptions,
...options.headers,
};
localVarRequestOptions.data = serializeDataIfNeeded(
createUserRequest,
localVarRequestOptions,
configuration
);
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/** /**
* Get current user detail * Get current user detail
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
@ -348,23 +407,23 @@ export const ApiConsoleHaloRunV1alpha1UserApiAxiosParamCreator = function (
/** /**
* List users * List users
* @param {Array<string>} [sort] Sort property and direction of the list result. Supported fields: creationTimestamp * @param {Array<string>} [sort] Sort property and direction of the list result. Supported fields: creationTimestamp
* @param {string} [keyword]
* @param {string} [role] * @param {string} [role]
* @param {string} [keyword]
* @param {number} [size] Size of one page. Zero indicates no limit. * @param {number} [size] Size of one page. Zero indicates no limit.
* @param {number} [page] The page number. Zero indicates no page.
* @param {Array<string>} [labelSelector] Label selector for filtering. * @param {Array<string>} [labelSelector] Label selector for filtering.
* @param {Array<string>} [fieldSelector] Field selector for filtering. * @param {Array<string>} [fieldSelector] Field selector for filtering.
* @param {number} [page] The page number. Zero indicates no page.
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
listUsers: async ( listUsers: async (
sort?: Array<string>, sort?: Array<string>,
keyword?: string,
role?: string, role?: string,
keyword?: string,
size?: number, size?: number,
page?: number,
labelSelector?: Array<string>, labelSelector?: Array<string>,
fieldSelector?: Array<string>, fieldSelector?: Array<string>,
page?: number,
options: AxiosRequestConfig = {} options: AxiosRequestConfig = {}
): Promise<RequestArgs> => { ): Promise<RequestArgs> => {
const localVarPath = `/apis/api.console.halo.run/v1alpha1/users`; const localVarPath = `/apis/api.console.halo.run/v1alpha1/users`;
@ -395,18 +454,22 @@ export const ApiConsoleHaloRunV1alpha1UserApiAxiosParamCreator = function (
localVarQueryParameter["sort"] = Array.from(sort); localVarQueryParameter["sort"] = Array.from(sort);
} }
if (keyword !== undefined) {
localVarQueryParameter["keyword"] = keyword;
}
if (role !== undefined) { if (role !== undefined) {
localVarQueryParameter["role"] = role; localVarQueryParameter["role"] = role;
} }
if (keyword !== undefined) {
localVarQueryParameter["keyword"] = keyword;
}
if (size !== undefined) { if (size !== undefined) {
localVarQueryParameter["size"] = size; localVarQueryParameter["size"] = size;
} }
if (page !== undefined) {
localVarQueryParameter["page"] = page;
}
if (labelSelector) { if (labelSelector) {
localVarQueryParameter["labelSelector"] = labelSelector; localVarQueryParameter["labelSelector"] = labelSelector;
} }
@ -415,10 +478,6 @@ export const ApiConsoleHaloRunV1alpha1UserApiAxiosParamCreator = function (
localVarQueryParameter["fieldSelector"] = fieldSelector; localVarQueryParameter["fieldSelector"] = fieldSelector;
} }
if (page !== undefined) {
localVarQueryParameter["page"] = page;
}
setSearchParams(localVarUrlObj, localVarQueryParameter); setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = let headersFromBaseOptions =
baseOptions && baseOptions.headers ? baseOptions.headers : {}; baseOptions && baseOptions.headers ? baseOptions.headers : {};
@ -529,6 +588,29 @@ export const ApiConsoleHaloRunV1alpha1UserApiFp = function (
configuration configuration
); );
}, },
/**
* Creates a new user.
* @param {CreateUserRequest} createUserRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async createUser(
createUserRequest: CreateUserRequest,
options?: AxiosRequestConfig
): Promise<
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<User>
> {
const localVarAxiosArgs = await localVarAxiosParamCreator.createUser(
createUserRequest,
options
);
return createRequestFunction(
localVarAxiosArgs,
globalAxios,
BASE_PATH,
configuration
);
},
/** /**
* Get current user detail * Get current user detail
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
@ -623,35 +705,38 @@ export const ApiConsoleHaloRunV1alpha1UserApiFp = function (
/** /**
* List users * List users
* @param {Array<string>} [sort] Sort property and direction of the list result. Supported fields: creationTimestamp * @param {Array<string>} [sort] Sort property and direction of the list result. Supported fields: creationTimestamp
* @param {string} [keyword]
* @param {string} [role] * @param {string} [role]
* @param {string} [keyword]
* @param {number} [size] Size of one page. Zero indicates no limit. * @param {number} [size] Size of one page. Zero indicates no limit.
* @param {number} [page] The page number. Zero indicates no page.
* @param {Array<string>} [labelSelector] Label selector for filtering. * @param {Array<string>} [labelSelector] Label selector for filtering.
* @param {Array<string>} [fieldSelector] Field selector for filtering. * @param {Array<string>} [fieldSelector] Field selector for filtering.
* @param {number} [page] The page number. Zero indicates no page.
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
async listUsers( async listUsers(
sort?: Array<string>, sort?: Array<string>,
keyword?: string,
role?: string, role?: string,
keyword?: string,
size?: number, size?: number,
page?: number,
labelSelector?: Array<string>, labelSelector?: Array<string>,
fieldSelector?: Array<string>, fieldSelector?: Array<string>,
page?: number,
options?: AxiosRequestConfig options?: AxiosRequestConfig
): Promise< ): Promise<
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<ListedUserList> (
axios?: AxiosInstance,
basePath?: string
) => AxiosPromise<UserEndpointListedUserList>
> { > {
const localVarAxiosArgs = await localVarAxiosParamCreator.listUsers( const localVarAxiosArgs = await localVarAxiosParamCreator.listUsers(
sort, sort,
keyword,
role, role,
keyword,
size, size,
page,
labelSelector, labelSelector,
fieldSelector, fieldSelector,
page,
options options
); );
return createRequestFunction( return createRequestFunction(
@ -714,6 +799,20 @@ export const ApiConsoleHaloRunV1alpha1UserApiFactory = function (
) )
.then((request) => request(axios, basePath)); .then((request) => request(axios, basePath));
}, },
/**
* Creates a new user.
* @param {ApiConsoleHaloRunV1alpha1UserApiCreateUserRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
createUser(
requestParameters: ApiConsoleHaloRunV1alpha1UserApiCreateUserRequest,
options?: AxiosRequestConfig
): AxiosPromise<User> {
return localVarFp
.createUser(requestParameters.createUserRequest, options)
.then((request) => request(axios, basePath));
},
/** /**
* Get current user detail * Get current user detail
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
@ -781,16 +880,16 @@ export const ApiConsoleHaloRunV1alpha1UserApiFactory = function (
listUsers( listUsers(
requestParameters: ApiConsoleHaloRunV1alpha1UserApiListUsersRequest = {}, requestParameters: ApiConsoleHaloRunV1alpha1UserApiListUsersRequest = {},
options?: AxiosRequestConfig options?: AxiosRequestConfig
): AxiosPromise<ListedUserList> { ): AxiosPromise<UserEndpointListedUserList> {
return localVarFp return localVarFp
.listUsers( .listUsers(
requestParameters.sort, requestParameters.sort,
requestParameters.keyword,
requestParameters.role, requestParameters.role,
requestParameters.keyword,
requestParameters.size, requestParameters.size,
requestParameters.page,
requestParameters.labelSelector, requestParameters.labelSelector,
requestParameters.fieldSelector, requestParameters.fieldSelector,
requestParameters.page,
options options
) )
.then((request) => request(axios, basePath)); .then((request) => request(axios, basePath));
@ -833,6 +932,20 @@ export interface ApiConsoleHaloRunV1alpha1UserApiChangePasswordRequest {
readonly changePasswordRequest: ChangePasswordRequest; readonly changePasswordRequest: ChangePasswordRequest;
} }
/**
* Request parameters for createUser operation in ApiConsoleHaloRunV1alpha1UserApi.
* @export
* @interface ApiConsoleHaloRunV1alpha1UserApiCreateUserRequest
*/
export interface ApiConsoleHaloRunV1alpha1UserApiCreateUserRequest {
/**
*
* @type {CreateUserRequest}
* @memberof ApiConsoleHaloRunV1alpha1UserApiCreateUser
*/
readonly createUserRequest: CreateUserRequest;
}
/** /**
* Request parameters for getPermissions operation in ApiConsoleHaloRunV1alpha1UserApi. * Request parameters for getPermissions operation in ApiConsoleHaloRunV1alpha1UserApi.
* @export * @export
@ -900,14 +1013,14 @@ export interface ApiConsoleHaloRunV1alpha1UserApiListUsersRequest {
* @type {string} * @type {string}
* @memberof ApiConsoleHaloRunV1alpha1UserApiListUsers * @memberof ApiConsoleHaloRunV1alpha1UserApiListUsers
*/ */
readonly keyword?: string; readonly role?: string;
/** /**
* *
* @type {string} * @type {string}
* @memberof ApiConsoleHaloRunV1alpha1UserApiListUsers * @memberof ApiConsoleHaloRunV1alpha1UserApiListUsers
*/ */
readonly role?: string; readonly keyword?: string;
/** /**
* Size of one page. Zero indicates no limit. * Size of one page. Zero indicates no limit.
@ -916,6 +1029,13 @@ export interface ApiConsoleHaloRunV1alpha1UserApiListUsersRequest {
*/ */
readonly size?: number; readonly size?: number;
/**
* The page number. Zero indicates no page.
* @type {number}
* @memberof ApiConsoleHaloRunV1alpha1UserApiListUsers
*/
readonly page?: number;
/** /**
* Label selector for filtering. * Label selector for filtering.
* @type {Array<string>} * @type {Array<string>}
@ -929,13 +1049,6 @@ export interface ApiConsoleHaloRunV1alpha1UserApiListUsersRequest {
* @memberof ApiConsoleHaloRunV1alpha1UserApiListUsers * @memberof ApiConsoleHaloRunV1alpha1UserApiListUsers
*/ */
readonly fieldSelector?: Array<string>; readonly fieldSelector?: Array<string>;
/**
* The page number. Zero indicates no page.
* @type {number}
* @memberof ApiConsoleHaloRunV1alpha1UserApiListUsers
*/
readonly page?: number;
} }
/** /**
@ -979,6 +1092,22 @@ export class ApiConsoleHaloRunV1alpha1UserApi extends BaseAPI {
.then((request) => request(this.axios, this.basePath)); .then((request) => request(this.axios, this.basePath));
} }
/**
* Creates a new user.
* @param {ApiConsoleHaloRunV1alpha1UserApiCreateUserRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ApiConsoleHaloRunV1alpha1UserApi
*/
public createUser(
requestParameters: ApiConsoleHaloRunV1alpha1UserApiCreateUserRequest,
options?: AxiosRequestConfig
) {
return ApiConsoleHaloRunV1alpha1UserApiFp(this.configuration)
.createUser(requestParameters.createUserRequest, options)
.then((request) => request(this.axios, this.basePath));
}
/** /**
* Get current user detail * Get current user detail
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
@ -1057,12 +1186,12 @@ export class ApiConsoleHaloRunV1alpha1UserApi extends BaseAPI {
return ApiConsoleHaloRunV1alpha1UserApiFp(this.configuration) return ApiConsoleHaloRunV1alpha1UserApiFp(this.configuration)
.listUsers( .listUsers(
requestParameters.sort, requestParameters.sort,
requestParameters.keyword,
requestParameters.role, requestParameters.role,
requestParameters.keyword,
requestParameters.size, requestParameters.size,
requestParameters.page,
requestParameters.labelSelector, requestParameters.labelSelector,
requestParameters.fieldSelector, requestParameters.fieldSelector,
requestParameters.page,
options options
) )
.then((request) => request(this.axios, this.basePath)); .then((request) => request(this.axios, this.basePath));

View File

@ -0,0 +1,75 @@
/* tslint:disable */
/* eslint-disable */
/**
* Halo Next API
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 2.0.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
*
* @export
* @interface CreateUserRequest
*/
export interface CreateUserRequest {
/**
*
* @type {{ [key: string]: string; }}
* @memberof CreateUserRequest
*/
annotations?: { [key: string]: string };
/**
*
* @type {string}
* @memberof CreateUserRequest
*/
avatar?: string;
/**
*
* @type {string}
* @memberof CreateUserRequest
*/
bio?: string;
/**
*
* @type {string}
* @memberof CreateUserRequest
*/
displayName?: string;
/**
*
* @type {string}
* @memberof CreateUserRequest
*/
email: string;
/**
*
* @type {string}
* @memberof CreateUserRequest
*/
name: string;
/**
*
* @type {string}
* @memberof CreateUserRequest
*/
password?: string;
/**
*
* @type {string}
* @memberof CreateUserRequest
*/
phone?: string;
/**
*
* @type {Array<string>}
* @memberof CreateUserRequest
*/
roles?: Array<string>;
}

View File

@ -35,6 +35,7 @@ export * from "./contributor";
export * from "./counter"; export * from "./counter";
export * from "./counter-list"; export * from "./counter-list";
export * from "./counter-request"; export * from "./counter-request";
export * from "./create-user-request";
export * from "./custom-templates"; export * from "./custom-templates";
export * from "./dashboard-stats"; export * from "./dashboard-stats";
export * from "./detailed-user"; export * from "./detailed-user";
@ -64,7 +65,6 @@ export * from "./listed-reply-list";
export * from "./listed-single-page"; export * from "./listed-single-page";
export * from "./listed-single-page-list"; export * from "./listed-single-page-list";
export * from "./listed-user"; export * from "./listed-user";
export * from "./listed-user-list";
export * from "./login-history"; export * from "./login-history";
export * from "./menu"; export * from "./menu";
export * from "./menu-item"; export * from "./menu-item";
@ -147,6 +147,7 @@ export * from "./user";
export * from "./user-connection"; export * from "./user-connection";
export * from "./user-connection-list"; export * from "./user-connection-list";
export * from "./user-connection-spec"; export * from "./user-connection-spec";
export * from "./user-endpoint-listed-user-list";
export * from "./user-list"; export * from "./user-list";
export * from "./user-permission"; export * from "./user-permission";
export * from "./user-spec"; export * from "./user-spec";

View File

@ -0,0 +1,79 @@
/* tslint:disable */
/* eslint-disable */
/**
* Halo Next API
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 2.0.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import { ListedUser } from "./listed-user";
/**
*
* @export
* @interface UserEndpointListedUserList
*/
export interface UserEndpointListedUserList {
/**
* Indicates whether current page is the first page.
* @type {boolean}
* @memberof UserEndpointListedUserList
*/
first: boolean;
/**
* Indicates whether current page has previous page.
* @type {boolean}
* @memberof UserEndpointListedUserList
*/
hasNext: boolean;
/**
* Indicates whether current page has previous page.
* @type {boolean}
* @memberof UserEndpointListedUserList
*/
hasPrevious: boolean;
/**
* A chunk of items.
* @type {Array<ListedUser>}
* @memberof UserEndpointListedUserList
*/
items: Array<ListedUser>;
/**
* Indicates whether current page is the last page.
* @type {boolean}
* @memberof UserEndpointListedUserList
*/
last: boolean;
/**
* Page number, starts from 1. If not set or equal to 0, it means no pagination.
* @type {number}
* @memberof UserEndpointListedUserList
*/
page: number;
/**
* Size of each page. If not set or equal to 0, it means no pagination.
* @type {number}
* @memberof UserEndpointListedUserList
*/
size: number;
/**
* Total elements.
* @type {number}
* @memberof UserEndpointListedUserList
*/
total: number;
/**
* Indicates total pages.
* @type {number}
* @memberof UserEndpointListedUserList
*/
totalPages: number;
}

View File

@ -40,12 +40,14 @@ import { useFetchRole } from "../roles/composables/use-role";
import FilterCleanButton from "@/components/filter/FilterCleanButton.vue"; import FilterCleanButton from "@/components/filter/FilterCleanButton.vue";
import { useQuery } from "@tanstack/vue-query"; import { useQuery } from "@tanstack/vue-query";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import UserCreationModal from "./components/UserCreationModal.vue";
const { currentUserHasPermission } = usePermission(); const { currentUserHasPermission } = usePermission();
const { t } = useI18n(); const { t } = useI18n();
const checkedAll = ref(false); const checkedAll = ref(false);
const editingModal = ref<boolean>(false); const editingModal = ref<boolean>(false);
const creationModal = ref<boolean>(false);
const passwordChangeModal = ref<boolean>(false); const passwordChangeModal = ref<boolean>(false);
const grantPermissionModal = ref<boolean>(false); const grantPermissionModal = ref<boolean>(false);
@ -233,11 +235,6 @@ const handleOpenCreateModal = (user: User) => {
editingModal.value = true; editingModal.value = true;
}; };
const onEditingModalClose = () => {
routeQueryAction.value = undefined;
refetch();
};
const handleOpenPasswordChangeModal = (user: User) => { const handleOpenPasswordChangeModal = (user: User) => {
selectedUser.value = user; selectedUser.value = user;
passwordChangeModal.value = true; passwordChangeModal.value = true;
@ -252,19 +249,17 @@ const handleOpenGrantPermissionModal = (user: User) => {
const routeQueryAction = useRouteQuery<string | undefined>("action"); const routeQueryAction = useRouteQuery<string | undefined>("action");
onMounted(() => { onMounted(() => {
if (!routeQueryAction.value) {
return;
}
if (routeQueryAction.value === "create") { if (routeQueryAction.value === "create") {
editingModal.value = true; creationModal.value = true;
} }
}); });
</script> </script>
<template> <template>
<UserEditingModal <UserEditingModal v-model:visible="editingModal" :user="selectedUser" />
v-model:visible="editingModal"
:user="selectedUser" <UserCreationModal
@close="onEditingModalClose" v-model:visible="creationModal"
@close="routeQueryAction = undefined"
/> />
<UserPasswordChangeModal <UserPasswordChangeModal
@ -305,7 +300,7 @@ onMounted(() => {
<VButton <VButton
v-permission="['system:users:manage']" v-permission="['system:users:manage']"
type="secondary" type="secondary"
@click="editingModal = true" @click="creationModal = true"
> >
<template #icon> <template #icon>
<IconAddCircle class="h-full w-full" /> <IconAddCircle class="h-full w-full" />

View File

@ -0,0 +1,196 @@
<script lang="ts" setup>
// core libs
import { ref, watch } from "vue";
import { apiClient } from "@/utils/api-client";
import type { CreateUserRequest } from "@halo-dev/api-client";
// components
import { Toast, VButton, VModal, VSpace } from "@halo-dev/components";
import SubmitButton from "@/components/button/SubmitButton.vue";
// libs
import cloneDeep from "lodash.clonedeep";
import { reset } from "@formkit/core";
// hooks
import { setFocus } from "@/formkit/utils/focus";
import { useI18n } from "vue-i18n";
import { useQueryClient } from "@tanstack/vue-query";
const { t } = useI18n();
const queryClient = useQueryClient();
const props = withDefaults(
defineProps<{
visible: boolean;
}>(),
{
visible: false,
}
);
const emit = defineEmits<{
(event: "update:visible", visible: boolean): void;
(event: "close"): void;
}>();
const initialFormState: CreateUserRequest = {
avatar: "",
bio: "",
displayName: "",
email: "",
name: "",
password: "",
phone: "",
roles: [],
};
const formState = ref<CreateUserRequest>(cloneDeep(initialFormState));
const selectedRole = ref("");
const saving = ref(false);
const handleResetForm = () => {
formState.value = cloneDeep(initialFormState);
reset("user-creation-form");
};
watch(
() => props.visible,
(visible) => {
if (visible) {
setFocus("creationUserNameInput");
} else {
handleResetForm();
}
}
);
const onVisibleChange = (visible: boolean) => {
emit("update:visible", visible);
if (!visible) {
emit("close");
}
};
const handleCreateUser = async () => {
try {
saving.value = true;
if (selectedRole.value) {
formState.value.roles = [selectedRole.value];
}
await apiClient.user.createUser({
createUserRequest: formState.value,
});
onVisibleChange(false);
Toast.success(t("core.common.toast.save_success"));
queryClient.invalidateQueries({ queryKey: ["users"] });
} catch (e) {
console.error("Failed to create or update user", e);
} finally {
saving.value = false;
}
};
</script>
<template>
<VModal
:title="$t('core.user.editing_modal.titles.create')"
:visible="visible"
:width="650"
@update:visible="onVisibleChange"
>
<FormKit
id="user-creation-form"
name="user-creation-form"
:config="{ validationVisibility: 'submit' }"
type="form"
@submit="handleCreateUser"
>
<FormKit
id="creationUserNameInput"
v-model="formState.name"
:label="$t('core.user.editing_modal.fields.username.label')"
type="text"
name="name"
:validation="[
['required'],
['length:0,63'],
[
'matches',
/^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$/,
],
]"
:validation-messages="{
matches: $t('core.user.editing_modal.fields.username.validation'),
}"
></FormKit>
<FormKit
v-model="formState.displayName"
:label="$t('core.user.editing_modal.fields.display_name.label')"
type="text"
name="displayName"
validation="required|length:0,50"
></FormKit>
<FormKit
v-model="formState.email"
:label="$t('core.user.editing_modal.fields.email.label')"
type="email"
name="email"
validation="required|email|length:0,100"
></FormKit>
<FormKit
v-model="formState.phone"
:label="$t('core.user.editing_modal.fields.phone.label')"
type="text"
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="url|length:0,1024"
></FormKit>
<FormKit
v-model="formState.password"
:label="$t('core.user.change_password_modal.fields.new_password.label')"
type="password"
name="password"
validation="required|length:0,100"
></FormKit>
<FormKit
v-model="selectedRole"
:label="$t('core.user.grant_permission_modal.fields.role.label')"
type="roleSelect"
></FormKit>
<FormKit
v-model="formState.bio"
:label="$t('core.user.editing_modal.fields.bio.label')"
type="textarea"
name="bio"
validation="length:0,2048"
></FormKit>
</FormKit>
<template #footer>
<VSpace>
<SubmitButton
v-if="visible"
:loading="saving"
type="secondary"
:text="$t('core.common.buttons.submit')"
@submit="$formkit.submit('user-creation-form')"
>
</SubmitButton>
<VButton @click="onVisibleChange(false)">
{{ $t("core.common.buttons.cancel_and_shortcut") }}
</VButton>
</VSpace>
</template>
</VModal>
</template>

View File

@ -1,6 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
// core libs // core libs
import { computed, nextTick, ref, watch } from "vue"; import { nextTick, ref, watch } from "vue";
import { apiClient } from "@/utils/api-client"; import { apiClient } from "@/utils/api-client";
import type { User } from "@halo-dev/api-client"; import type { User } from "@halo-dev/api-client";
@ -17,9 +17,11 @@ import { setFocus } from "@/formkit/utils/focus";
import AnnotationsForm from "@/components/form/AnnotationsForm.vue"; import AnnotationsForm from "@/components/form/AnnotationsForm.vue";
import { useUserStore } from "@/stores/user"; import { useUserStore } from "@/stores/user";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useQueryClient } from "@tanstack/vue-query";
const userStore = useUserStore(); const userStore = useUserStore();
const { t } = useI18n(); const { t } = useI18n();
const queryClient = useQueryClient();
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@ -58,16 +60,6 @@ const initialFormState: User = {
const formState = ref<User>(cloneDeep(initialFormState)); const formState = ref<User>(cloneDeep(initialFormState));
const saving = ref(false); const saving = ref(false);
const isUpdateMode = computed(() => {
return !!formState.value.metadata.creationTimestamp;
});
const creationModalTitle = computed(() => {
return isUpdateMode.value
? t("core.user.editing_modal.titles.update")
: t("core.user.editing_modal.titles.create");
});
const handleResetForm = () => { const handleResetForm = () => {
formState.value = cloneDeep(initialFormState); formState.value = cloneDeep(initialFormState);
reset("user-form"); reset("user-form");
@ -78,7 +70,7 @@ watch(
(visible) => { (visible) => {
if (visible) { if (visible) {
if (props.user) formState.value = cloneDeep(props.user); if (props.user) formState.value = cloneDeep(props.user);
setFocus(isUpdateMode.value ? "displayNameInput" : "userNameInput"); setFocus("displayNameInput");
} else { } else {
handleResetForm(); handleResetForm();
} }
@ -94,7 +86,7 @@ const onVisibleChange = (visible: boolean) => {
const annotationsFormRef = ref<InstanceType<typeof AnnotationsForm>>(); const annotationsFormRef = ref<InstanceType<typeof AnnotationsForm>>();
const handleCreateUser = async () => { const handleUpdateUser = async () => {
annotationsFormRef.value?.handleSubmit(); annotationsFormRef.value?.handleSubmit();
await nextTick(); await nextTick();
@ -111,25 +103,23 @@ const handleCreateUser = async () => {
try { try {
saving.value = true; saving.value = true;
if (isUpdateMode.value) {
if (props.user?.metadata.name === userStore.currentUser?.metadata.name) { if (props.user?.metadata.name === userStore.currentUser?.metadata.name) {
await apiClient.user.updateCurrentUser({ await apiClient.user.updateCurrentUser({
user: formState.value, user: formState.value,
}); });
} else {
await apiClient.extension.user.updatev1alpha1User({
name: formState.value.metadata.name,
user: formState.value,
});
}
} else { } else {
await apiClient.extension.user.createv1alpha1User({ await apiClient.extension.user.updatev1alpha1User({
name: formState.value.metadata.name,
user: formState.value, user: formState.value,
}); });
} }
onVisibleChange(false); onVisibleChange(false);
queryClient.invalidateQueries({ queryKey: ["users"] });
queryClient.invalidateQueries({ queryKey: ["user-detail"] });
Toast.success(t("core.common.toast.save_success")); Toast.success(t("core.common.toast.save_success"));
} catch (e) { } catch (e) {
console.error("Failed to create or update user", e); console.error("Failed to create or update user", e);
@ -140,7 +130,7 @@ const handleCreateUser = async () => {
</script> </script>
<template> <template>
<VModal <VModal
:title="creationModalTitle" :title="$t('core.user.editing_modal.titles.update')"
:visible="visible" :visible="visible"
:width="700" :width="700"
@update:visible="onVisibleChange" @update:visible="onVisibleChange"
@ -150,7 +140,7 @@ const handleCreateUser = async () => {
name="user-form" name="user-form"
:config="{ validationVisibility: 'submit' }" :config="{ validationVisibility: 'submit' }"
type="form" type="form"
@submit="handleCreateUser" @submit="handleUpdateUser"
> >
<div> <div>
<div class="md:grid md:grid-cols-4 md:gap-6"> <div class="md:grid md:grid-cols-4 md:gap-6">
@ -165,7 +155,7 @@ const handleCreateUser = async () => {
<FormKit <FormKit
id="userNameInput" id="userNameInput"
v-model="formState.metadata.name" v-model="formState.metadata.name"
:disabled="isUpdateMode" :disabled="true"
:label="$t('core.user.editing_modal.fields.username.label')" :label="$t('core.user.editing_modal.fields.username.label')"
type="text" type="text"
name="name" name="name"

View File

@ -120,7 +120,7 @@ const handleChangePassword = async () => {
" "
name="password_confirm" name="password_confirm"
type="password" type="password"
validation="required|confirm|length:0,50" validation="required|confirm|length:0,100"
></FormKit> ></FormKit>
</FormKit> </FormKit>
<template #footer> <template #footer>

View File

@ -107,16 +107,14 @@ const handleTabChange = (id: string) => {
</script> </script>
<template> <template>
<BasicLayout> <BasicLayout>
<UserEditingModal <UserEditingModal v-model:visible="editingModal" :user="user?.user" />
v-model:visible="editingModal"
:user="user?.user"
@close="refetch"
/>
<UserPasswordChangeModal <UserPasswordChangeModal
v-model:visible="passwordChangeModal" v-model:visible="passwordChangeModal"
:user="user?.user" :user="user?.user"
@close="refetch" @close="refetch"
/> />
<header class="bg-white"> <header class="bg-white">
<div class="p-4"> <div class="p-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">