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 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.parameter.Builder.parameterBuilder;
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.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
@ -28,6 +32,7 @@ import java.util.regex.Pattern;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.data.domain.Sort;
import org.springframework.http.MediaType;
import org.springframework.lang.NonNull;
@ -43,12 +48,14 @@ import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.ServerWebInputException;
import reactor.core.publisher.Flux;
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.service.RoleService;
import run.halo.app.core.extension.service.UserService;
import run.halo.app.extension.Comparators;
import run.halo.app.extension.ListResult;
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;
@ -99,6 +106,14 @@ public class UserEndpoint implements CustomEndpoint {
.required(true)
.implementation(GrantRequest.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,
builder -> builder.operationId("GetPermissions")
.description("Get permissions of user")
@ -133,13 +148,77 @@ public class UserEndpoint implements CustomEndpoint {
.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) {
final var name = request.pathVariable("name");
return userService.getUser(name)
.flatMap(this::toDetailedUser)
.flatMap(user -> ServerResponse.ok()
.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) {
@ -165,7 +244,8 @@ public class UserEndpoint implements CustomEndpoint {
spec.setEmail(newSpec.getEmail());
spec.setPhone(newSpec.getPhone());
return currentUser;
}))
})
)
.flatMap(client::update)
.flatMap(updatedUser -> ServerResponse.ok().bodyValue(updatedUser));
}
@ -240,12 +320,6 @@ public class UserEndpoint implements CustomEndpoint {
.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) {
}

View File

@ -21,4 +21,6 @@ public interface UserService {
Mono<User> grantRoles(String username, Set<String> roles);
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.CollectionUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ServerWebInputException;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Role;
@ -145,12 +146,14 @@ public class UserServiceImpl implements UserService {
}
String encodedPassword = passwordEncoder.encode(password);
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(roleNames, "Roles must not be null");
return client.fetch(User.class, user.getMetadata().getName())
.hasElement()
.flatMap(hasUser -> {
@ -160,10 +163,17 @@ public class UserServiceImpl implements UserService {
"problemDetail.user.duplicateName",
new Object[] {user.getMetadata().getName()}));
}
return client.create(user)
.flatMap(newUser -> grantRoles(user.getMetadata().getName(),
Set.of(defaultRole))
);
});
// Check if all roles exist
return Flux.fromIterable(roleNames)
.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: [ "" ]
resources: [ "users" ]
verbs: [ "create", "patch", "update", "delete", "deletecollection" ]
- apiGroups: [ "api.console.halo.run" ]
resources: [ "users", "users/permissions", "users/password" ]
verbs: [ "*" ]
---
apiVersion: v1alpha1
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.anyInt;
import static org.mockito.ArgumentMatchers.anySet;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
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");
when(client.fetch(eq(Role.class), anyString())).thenReturn(Mono.just(new Role()));
when(client.create(any(User.class))).thenReturn(Mono.just(fakeUser));
UserServiceImpl spyUserService = spy(userService);
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}'",
"typecheck": "tsc --noEmit",
"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": [],
"author": "@halo-dev",

View File

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

View File

@ -40,14 +40,16 @@ import {
// @ts-ignore
import { ChangePasswordRequest } from "../models";
// @ts-ignore
import { CreateUserRequest } from "../models";
// @ts-ignore
import { DetailedUser } from "../models";
// @ts-ignore
import { GrantRequest } from "../models";
// @ts-ignore
import { ListedUserList } from "../models";
// @ts-ignore
import { User } from "../models";
// @ts-ignore
import { UserEndpointListedUserList } from "../models";
// @ts-ignore
import { UserPermission } from "../models";
/**
* ApiConsoleHaloRunV1alpha1UserApi - axios parameter creator
@ -126,6 +128,63 @@ export const ApiConsoleHaloRunV1alpha1UserApiAxiosParamCreator = function (
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
* @param {*} [options] Override http request option.
@ -348,23 +407,23 @@ export const ApiConsoleHaloRunV1alpha1UserApiAxiosParamCreator = function (
/**
* List users
* @param {Array<string>} [sort] Sort property and direction of the list result. Supported fields: creationTimestamp
* @param {string} [keyword]
* @param {string} [role]
* @param {string} [keyword]
* @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>} [fieldSelector] Field selector for filtering.
* @param {number} [page] The page number. Zero indicates no page.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listUsers: async (
sort?: Array<string>,
keyword?: string,
role?: string,
keyword?: string,
size?: number,
page?: number,
labelSelector?: Array<string>,
fieldSelector?: Array<string>,
page?: number,
options: AxiosRequestConfig = {}
): Promise<RequestArgs> => {
const localVarPath = `/apis/api.console.halo.run/v1alpha1/users`;
@ -395,18 +454,22 @@ export const ApiConsoleHaloRunV1alpha1UserApiAxiosParamCreator = function (
localVarQueryParameter["sort"] = Array.from(sort);
}
if (keyword !== undefined) {
localVarQueryParameter["keyword"] = keyword;
}
if (role !== undefined) {
localVarQueryParameter["role"] = role;
}
if (keyword !== undefined) {
localVarQueryParameter["keyword"] = keyword;
}
if (size !== undefined) {
localVarQueryParameter["size"] = size;
}
if (page !== undefined) {
localVarQueryParameter["page"] = page;
}
if (labelSelector) {
localVarQueryParameter["labelSelector"] = labelSelector;
}
@ -415,10 +478,6 @@ export const ApiConsoleHaloRunV1alpha1UserApiAxiosParamCreator = function (
localVarQueryParameter["fieldSelector"] = fieldSelector;
}
if (page !== undefined) {
localVarQueryParameter["page"] = page;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions =
baseOptions && baseOptions.headers ? baseOptions.headers : {};
@ -529,6 +588,29 @@ export const ApiConsoleHaloRunV1alpha1UserApiFp = function (
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
* @param {*} [options] Override http request option.
@ -623,35 +705,38 @@ export const ApiConsoleHaloRunV1alpha1UserApiFp = function (
/**
* List users
* @param {Array<string>} [sort] Sort property and direction of the list result. Supported fields: creationTimestamp
* @param {string} [keyword]
* @param {string} [role]
* @param {string} [keyword]
* @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>} [fieldSelector] Field selector for filtering.
* @param {number} [page] The page number. Zero indicates no page.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async listUsers(
sort?: Array<string>,
keyword?: string,
role?: string,
keyword?: string,
size?: number,
page?: number,
labelSelector?: Array<string>,
fieldSelector?: Array<string>,
page?: number,
options?: AxiosRequestConfig
): Promise<
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<ListedUserList>
(
axios?: AxiosInstance,
basePath?: string
) => AxiosPromise<UserEndpointListedUserList>
> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listUsers(
sort,
keyword,
role,
keyword,
size,
page,
labelSelector,
fieldSelector,
page,
options
);
return createRequestFunction(
@ -714,6 +799,20 @@ export const ApiConsoleHaloRunV1alpha1UserApiFactory = function (
)
.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
* @param {*} [options] Override http request option.
@ -781,16 +880,16 @@ export const ApiConsoleHaloRunV1alpha1UserApiFactory = function (
listUsers(
requestParameters: ApiConsoleHaloRunV1alpha1UserApiListUsersRequest = {},
options?: AxiosRequestConfig
): AxiosPromise<ListedUserList> {
): AxiosPromise<UserEndpointListedUserList> {
return localVarFp
.listUsers(
requestParameters.sort,
requestParameters.keyword,
requestParameters.role,
requestParameters.keyword,
requestParameters.size,
requestParameters.page,
requestParameters.labelSelector,
requestParameters.fieldSelector,
requestParameters.page,
options
)
.then((request) => request(axios, basePath));
@ -833,6 +932,20 @@ export interface ApiConsoleHaloRunV1alpha1UserApiChangePasswordRequest {
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.
* @export
@ -900,14 +1013,14 @@ export interface ApiConsoleHaloRunV1alpha1UserApiListUsersRequest {
* @type {string}
* @memberof ApiConsoleHaloRunV1alpha1UserApiListUsers
*/
readonly keyword?: string;
readonly role?: string;
/**
*
* @type {string}
* @memberof ApiConsoleHaloRunV1alpha1UserApiListUsers
*/
readonly role?: string;
readonly keyword?: string;
/**
* Size of one page. Zero indicates no limit.
@ -916,6 +1029,13 @@ export interface ApiConsoleHaloRunV1alpha1UserApiListUsersRequest {
*/
readonly size?: number;
/**
* The page number. Zero indicates no page.
* @type {number}
* @memberof ApiConsoleHaloRunV1alpha1UserApiListUsers
*/
readonly page?: number;
/**
* Label selector for filtering.
* @type {Array<string>}
@ -929,13 +1049,6 @@ export interface ApiConsoleHaloRunV1alpha1UserApiListUsersRequest {
* @memberof ApiConsoleHaloRunV1alpha1UserApiListUsers
*/
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));
}
/**
* 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
* @param {*} [options] Override http request option.
@ -1057,12 +1186,12 @@ export class ApiConsoleHaloRunV1alpha1UserApi extends BaseAPI {
return ApiConsoleHaloRunV1alpha1UserApiFp(this.configuration)
.listUsers(
requestParameters.sort,
requestParameters.keyword,
requestParameters.role,
requestParameters.keyword,
requestParameters.size,
requestParameters.page,
requestParameters.labelSelector,
requestParameters.fieldSelector,
requestParameters.page,
options
)
.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-list";
export * from "./counter-request";
export * from "./create-user-request";
export * from "./custom-templates";
export * from "./dashboard-stats";
export * from "./detailed-user";
@ -64,7 +65,6 @@ export * from "./listed-reply-list";
export * from "./listed-single-page";
export * from "./listed-single-page-list";
export * from "./listed-user";
export * from "./listed-user-list";
export * from "./login-history";
export * from "./menu";
export * from "./menu-item";
@ -147,6 +147,7 @@ export * from "./user";
export * from "./user-connection";
export * from "./user-connection-list";
export * from "./user-connection-spec";
export * from "./user-endpoint-listed-user-list";
export * from "./user-list";
export * from "./user-permission";
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 { useQuery } from "@tanstack/vue-query";
import { useI18n } from "vue-i18n";
import UserCreationModal from "./components/UserCreationModal.vue";
const { currentUserHasPermission } = usePermission();
const { t } = useI18n();
const checkedAll = ref(false);
const editingModal = ref<boolean>(false);
const creationModal = ref<boolean>(false);
const passwordChangeModal = ref<boolean>(false);
const grantPermissionModal = ref<boolean>(false);
@ -233,11 +235,6 @@ const handleOpenCreateModal = (user: User) => {
editingModal.value = true;
};
const onEditingModalClose = () => {
routeQueryAction.value = undefined;
refetch();
};
const handleOpenPasswordChangeModal = (user: User) => {
selectedUser.value = user;
passwordChangeModal.value = true;
@ -252,19 +249,17 @@ const handleOpenGrantPermissionModal = (user: User) => {
const routeQueryAction = useRouteQuery<string | undefined>("action");
onMounted(() => {
if (!routeQueryAction.value) {
return;
}
if (routeQueryAction.value === "create") {
editingModal.value = true;
creationModal.value = true;
}
});
</script>
<template>
<UserEditingModal
v-model:visible="editingModal"
:user="selectedUser"
@close="onEditingModalClose"
<UserEditingModal v-model:visible="editingModal" :user="selectedUser" />
<UserCreationModal
v-model:visible="creationModal"
@close="routeQueryAction = undefined"
/>
<UserPasswordChangeModal
@ -305,7 +300,7 @@ onMounted(() => {
<VButton
v-permission="['system:users:manage']"
type="secondary"
@click="editingModal = true"
@click="creationModal = true"
>
<template #icon>
<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>
// core libs
import { computed, nextTick, ref, watch } from "vue";
import { nextTick, ref, watch } from "vue";
import { apiClient } from "@/utils/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 { useUserStore } from "@/stores/user";
import { useI18n } from "vue-i18n";
import { useQueryClient } from "@tanstack/vue-query";
const userStore = useUserStore();
const { t } = useI18n();
const queryClient = useQueryClient();
const props = withDefaults(
defineProps<{
@ -58,16 +60,6 @@ const initialFormState: User = {
const formState = ref<User>(cloneDeep(initialFormState));
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 = () => {
formState.value = cloneDeep(initialFormState);
reset("user-form");
@ -78,7 +70,7 @@ watch(
(visible) => {
if (visible) {
if (props.user) formState.value = cloneDeep(props.user);
setFocus(isUpdateMode.value ? "displayNameInput" : "userNameInput");
setFocus("displayNameInput");
} else {
handleResetForm();
}
@ -94,7 +86,7 @@ const onVisibleChange = (visible: boolean) => {
const annotationsFormRef = ref<InstanceType<typeof AnnotationsForm>>();
const handleCreateUser = async () => {
const handleUpdateUser = async () => {
annotationsFormRef.value?.handleSubmit();
await nextTick();
@ -111,25 +103,23 @@ const handleCreateUser = async () => {
try {
saving.value = true;
if (isUpdateMode.value) {
if (props.user?.metadata.name === userStore.currentUser?.metadata.name) {
await apiClient.user.updateCurrentUser({
user: formState.value,
});
} else {
await apiClient.extension.user.updatev1alpha1User({
name: formState.value.metadata.name,
user: formState.value,
});
}
if (props.user?.metadata.name === userStore.currentUser?.metadata.name) {
await apiClient.user.updateCurrentUser({
user: formState.value,
});
} else {
await apiClient.extension.user.createv1alpha1User({
await apiClient.extension.user.updatev1alpha1User({
name: formState.value.metadata.name,
user: formState.value,
});
}
onVisibleChange(false);
queryClient.invalidateQueries({ queryKey: ["users"] });
queryClient.invalidateQueries({ queryKey: ["user-detail"] });
Toast.success(t("core.common.toast.save_success"));
} catch (e) {
console.error("Failed to create or update user", e);
@ -140,7 +130,7 @@ const handleCreateUser = async () => {
</script>
<template>
<VModal
:title="creationModalTitle"
:title="$t('core.user.editing_modal.titles.update')"
:visible="visible"
:width="700"
@update:visible="onVisibleChange"
@ -150,7 +140,7 @@ const handleCreateUser = async () => {
name="user-form"
:config="{ validationVisibility: 'submit' }"
type="form"
@submit="handleCreateUser"
@submit="handleUpdateUser"
>
<div>
<div class="md:grid md:grid-cols-4 md:gap-6">
@ -165,7 +155,7 @@ const handleCreateUser = async () => {
<FormKit
id="userNameInput"
v-model="formState.metadata.name"
:disabled="isUpdateMode"
:disabled="true"
:label="$t('core.user.editing_modal.fields.username.label')"
type="text"
name="name"

View File

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

View File

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