feat: support user registration (#3631)

#### What type of PR is this?
/kind feature
/area core
/area console
/milestone 2.4.0

#### What this PR does / why we need it:
新增用户注册功能

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

#### Special notes for your reviewer:

#### Does this PR introduce a user-facing change?
```release-note
新增用户注册功能
```
pull/3640/head^2
guqing 2023-03-30 17:44:15 +08:00 committed by GitHub
parent 520074bd9c
commit ddca7731dd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 890 additions and 7 deletions

View File

@ -83,6 +83,8 @@ public class GlobalInfoEndpoint {
private String website;
private String authenticationUrl;
private String bindingUrl;
}
private void handleCommentSetting(GlobalInfo info, ConfigMap configMap) {
@ -127,6 +129,7 @@ public class GlobalInfoEndpoint {
socialAuthProvider.setLogo(provider.getLogo());
socialAuthProvider.setWebsite(provider.getWebsite());
socialAuthProvider.setAuthenticationUrl(provider.getAuthenticationUrl());
socialAuthProvider.setBindingUrl(provider.getBindingUrl());
return socialAuthProvider;
})
.toList()

View File

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

View File

@ -5,9 +5,12 @@ import static run.halo.app.core.extension.RoleBinding.containsUser;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.BooleanUtils;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import reactor.core.publisher.Flux;
@ -17,9 +20,14 @@ import run.halo.app.core.extension.RoleBinding;
import run.halo.app.core.extension.User;
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.exception.AccessDeniedException;
import run.halo.app.infra.exception.DuplicateNameException;
import run.halo.app.infra.exception.UserNotFoundException;
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
public static final String GHOST_USER_NAME = "ghost";
@ -27,10 +35,8 @@ public class UserServiceImpl implements UserService {
private final PasswordEncoder passwordEncoder;
public UserServiceImpl(ReactiveExtensionClient client, PasswordEncoder passwordEncoder) {
this.client = client;
this.passwordEncoder = passwordEncoder;
}
private final SystemConfigurableEnvironmentFetcher environmentFetcher;
@Override
public Mono<User> getUser(String username) {
@ -115,4 +121,49 @@ public class UserServiceImpl implements UserService {
});
}
@Override
@Transactional
public Mono<User> signUp(User user, String password) {
if (!StringUtils.hasText(password)) {
throw new IllegalArgumentException("Password must not be blank");
}
return environmentFetcher.fetch(SystemSetting.User.GROUP, SystemSetting.User.class)
.switchIfEmpty(Mono.error(new IllegalStateException("User setting is not configured")))
.flatMap(userSetting -> {
Boolean allowRegistration = userSetting.getAllowRegistration();
if (BooleanUtils.isFalse(allowRegistration)) {
return Mono.error(new AccessDeniedException("Registration is not allowed",
"problemDetail.user.signUpFailed.disallowed",
null));
}
String defaultRole = userSetting.getDefaultRole();
if (!StringUtils.hasText(defaultRole)) {
return Mono.error(new AccessDeniedException(
"Default registration role is not configured by admin",
"problemDetail.user.signUpFailed.disallowed",
null));
}
String encodedPassword = passwordEncoder.encode(password);
user.getSpec().setPassword(encodedPassword);
return createNewUser(user, defaultRole);
});
}
private Mono<User> createNewUser(User user, String defaultRole) {
Assert.notNull(user, "User must not be null");
return client.fetch(User.class, user.getMetadata().getName())
.hasElement()
.flatMap(hasUser -> {
if (hasUser) {
return Mono.error(
new DuplicateNameException("User name is already in use", null,
"problemDetail.user.duplicateName",
new Object[] {user.getMetadata().getName()}));
}
return client.create(user)
.flatMap(newUser -> grantRoles(user.getMetadata().getName(),
Set.of(defaultRole))
);
});
}
}

View File

@ -0,0 +1,89 @@
package run.halo.app.theme.endpoint;
import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;
import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder;
import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.RequiredArgsConstructor;
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextImpl;
import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
import org.springframework.security.web.server.context.ServerSecurityContextRepository;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.User;
import run.halo.app.core.extension.endpoint.CustomEndpoint;
import run.halo.app.core.extension.service.UserService;
import run.halo.app.extension.GroupVersion;
/**
* User endpoint for unauthenticated user.
*
* @author guqing
* @since 2.4.0
*/
@Component
@RequiredArgsConstructor
public class PublicUserEndpoint implements CustomEndpoint {
private final UserService userService;
private final ServerSecurityContextRepository securityContextRepository;
private final ReactiveUserDetailsService reactiveUserDetailsService;
@Override
public RouterFunction<ServerResponse> endpoint() {
var tag = "api.halo.run/v1alpha1/User";
return SpringdocRouteBuilder.route()
.POST("/users/-/signup", this::signUp,
builder -> builder.operationId("SignUp")
.description("Sign up a new user")
.tag(tag)
.requestBody(requestBodyBuilder().required(true)
.implementation(SignUpRequest.class)
)
.response(responseBuilder().implementation(User.class))
)
.build();
}
@Override
public GroupVersion groupVersion() {
return GroupVersion.parseAPIVersion("api.halo.run/v1alpha1");
}
private Mono<ServerResponse> signUp(ServerRequest request) {
return request.bodyToMono(SignUpRequest.class)
.flatMap(signUpRequest ->
userService.signUp(signUpRequest.user(), signUpRequest.password())
)
.flatMap(user -> authenticate(user.getMetadata().getName(), request.exchange())
.thenReturn(user)
)
.flatMap(user -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(user)
);
}
private Mono<Void> authenticate(String username, ServerWebExchange exchange) {
return reactiveUserDetailsService.findByUsername(username)
.flatMap(userDetails -> {
SecurityContextImpl securityContext = new SecurityContextImpl();
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails.getUsername(),
userDetails.getPassword(), userDetails.getAuthorities());
securityContext.setAuthentication(authentication);
return securityContextRepository.save(exchange, securityContext);
});
}
record SignUpRequest(@Schema(requiredMode = REQUIRED) User user,
@Schema(requiredMode = REQUIRED, minLength = 6) String password) {
}
}

View File

@ -33,6 +33,8 @@ problemDetail.run.halo.app.infra.exception.AttachmentAlreadyExistsException=File
problemDetail.run.halo.app.infra.exception.DuplicateNameException=Duplicate name detected, please rename it and retry.
problemDetail.run.halo.app.infra.exception.PluginAlreadyExistsException=Plugin {0} already exists.
problemDetail.user.signUpFailed.disallowed=System does not allow new users to register.
problemDetail.user.duplicateName=The username {0} already exists, please rename it and retry.
problemDetail.comment.turnedOff=The comment function has been turned off.
problemDetail.comment.systemUsersOnly=Allow only system users to comment
problemDetail.theme.upgrade.missingManifest=Missing theme manifest file "theme.yaml" or "theme.yml".

View File

@ -9,6 +9,9 @@ problemDetail.run.halo.app.infra.exception.AttachmentAlreadyExistsException=文
problemDetail.run.halo.app.infra.exception.DuplicateNameException=检测到有重复的名称,请重命名后重试。
problemDetail.run.halo.app.infra.exception.PluginAlreadyExistsException=插件 {0} 已经存。
problemDetail.user.signUpFailed.disallowed=系统不允许注册新用户。
problemDetail.user.duplicateName=用户名 {0} 已存在,请更换用户名后重试。
problemDetail.plugin.version.unsatisfied.requires=插件要求一个最小的系统版本为 {0}, 但当前版本为 {1}。
problemDetail.theme.version.unsatisfied.requires=主题要求一个最小的系统版本为 {0}, 但当前版本为 {1}。

View File

@ -3,6 +3,11 @@ kind: "ConfigMap"
metadata:
name: system-default
data:
user: |
{
"allowRegistration": false,
"defaultRole": ""
}
theme: |
{
"active": "theme-earth"

View File

@ -64,6 +64,16 @@ spec:
- $formkit: textarea
name: description
label: "站点描述"
- group: user
label: 用户设置
formSchema:
- $formkit: checkbox
name: allowRegistration
label: "开放注册"
value: false
- $formkit: text
name: defaultRole
label: "默认角色"
- group: comment
label: 评论设置
formSchema:

View File

@ -1,12 +1,15 @@
package run.halo.app.core.extension.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
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.isA;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
@ -35,6 +38,10 @@ import run.halo.app.core.extension.User;
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.exception.AccessDeniedException;
import run.halo.app.infra.exception.DuplicateNameException;
import run.halo.app.infra.exception.UserNotFoundException;
import run.halo.app.infra.utils.JsonUtils;
@ -44,6 +51,9 @@ class UserServiceImplTest {
@Mock
ReactiveExtensionClient client;
@Mock
SystemConfigurableEnvironmentFetcher environmentFetcher;
@Mock
PasswordEncoder passwordEncoder;
@ -378,4 +388,100 @@ class UserServiceImplTest {
verify(client).update(notProvidedRoleBinding);
}
}
@Nested
class SignUpTest {
@Test
void signUpWhenRegistrationNotAllowed() {
SystemSetting.User userSetting = new SystemSetting.User();
userSetting.setAllowRegistration(false);
when(environmentFetcher.fetch(eq(SystemSetting.User.GROUP),
eq(SystemSetting.User.class)))
.thenReturn(Mono.just(userSetting));
User fakeUser = fakeSignUpUser("fake-user", "fake-password");
userService.signUp(fakeUser, "fake-password")
.as(StepVerifier::create)
.expectError(AccessDeniedException.class)
.verify();
}
@Test
void signUpWhenRegistrationDefaultRoleNotConfigured() {
SystemSetting.User userSetting = new SystemSetting.User();
userSetting.setAllowRegistration(true);
when(environmentFetcher.fetch(eq(SystemSetting.User.GROUP),
eq(SystemSetting.User.class)))
.thenReturn(Mono.just(userSetting));
User fakeUser = fakeSignUpUser("fake-user", "fake-password");
userService.signUp(fakeUser, "fake-password")
.as(StepVerifier::create)
.expectError(AccessDeniedException.class)
.verify();
}
@Test
void signUpWhenRegistrationUsernameExists() {
SystemSetting.User userSetting = new SystemSetting.User();
userSetting.setAllowRegistration(true);
userSetting.setDefaultRole("fake-role");
when(environmentFetcher.fetch(eq(SystemSetting.User.GROUP),
eq(SystemSetting.User.class)))
.thenReturn(Mono.just(userSetting));
when(passwordEncoder.encode(eq("fake-password"))).thenReturn("fake-password");
when(client.fetch(eq(User.class), eq("fake-user")))
.thenReturn(Mono.just(fakeSignUpUser("test", "test")));
User fakeUser = fakeSignUpUser("fake-user", "fake-password");
userService.signUp(fakeUser, "fake-password")
.as(StepVerifier::create)
.expectError(DuplicateNameException.class)
.verify();
}
@Test
void signUpWhenRegistrationSuccessfully() {
SystemSetting.User userSetting = new SystemSetting.User();
userSetting.setAllowRegistration(true);
userSetting.setDefaultRole("fake-role");
when(environmentFetcher.fetch(eq(SystemSetting.User.GROUP),
eq(SystemSetting.User.class)))
.thenReturn(Mono.just(userSetting));
when(passwordEncoder.encode(eq("fake-password"))).thenReturn("fake-password");
when(client.fetch(eq(User.class), eq("fake-user")))
.thenReturn(Mono.empty());
User fakeUser = fakeSignUpUser("fake-user", "fake-password");
when(client.create(any(User.class))).thenReturn(Mono.just(fakeUser));
UserServiceImpl spyUserService = spy(userService);
doReturn(Mono.just(fakeUser)).when(spyUserService).grantRoles(eq("fake-user"),
anySet());
spyUserService.signUp(fakeUser, "fake-password")
.as(StepVerifier::create)
.consumeNextWith(user -> {
assertThat(user.getMetadata().getName()).isEqualTo("fake-user");
assertThat(user.getSpec().getPassword()).isEqualTo("fake-password");
})
.verifyComplete();
verify(client).create(any(User.class));
verify(spyUserService).grantRoles(eq("fake-user"), anySet());
}
User fakeSignUpUser(String name, String password) {
User user = new User();
user.setMetadata(new Metadata());
user.getMetadata().setName(name);
user.setSpec(new User.UserSpec());
user.getSpec().setPassword(password);
return user;
}
}
}

View File

@ -0,0 +1,76 @@
package run.halo.app.theme.endpoint;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
import org.springframework.security.web.server.context.ServerSecurityContextRepository;
import org.springframework.test.web.reactive.server.WebTestClient;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.User;
import run.halo.app.core.extension.service.UserService;
import run.halo.app.extension.Metadata;
/**
* Tests for {@link PublicUserEndpoint}.
*
* @author guqing
* @since 2.4.0
*/
@ExtendWith(MockitoExtension.class)
class PublicUserEndpointTest {
@Mock
private UserService userService;
@Mock
private ServerSecurityContextRepository securityContextRepository;
@Mock
private ReactiveUserDetailsService reactiveUserDetailsService;
@InjectMocks
private PublicUserEndpoint publicUserEndpoint;
private WebTestClient webClient;
@BeforeEach
void setUp() {
webClient = WebTestClient.bindToRouterFunction(publicUserEndpoint.endpoint())
.build();
}
@Test
void signUp() {
User user = new User();
user.setMetadata(new Metadata());
user.getMetadata().setName("fake-user");
user.setSpec(new User.UserSpec());
user.getSpec().setDisplayName("hello");
user.getSpec().setBio("bio");
when(userService.signUp(any(User.class), anyString())).thenReturn(Mono.just(user));
when(securityContextRepository.save(any(), any())).thenReturn(Mono.empty());
when(reactiveUserDetailsService.findByUsername(anyString())).thenReturn(Mono.just(
org.springframework.security.core.userdetails.User.withUsername("fake-user")
.password("123456")
.authorities("test-role")
.build()));
webClient.post()
.uri("/users/-/signup")
.bodyValue(new PublicUserEndpoint.SignUpRequest(user, "fake-password"))
.exchange()
.expectStatus().isOk();
verify(userService).signUp(any(User.class), anyString());
verify(securityContextRepository).save(any(), any());
verify(reactiveUserDetailsService).findByUsername(eq("fake-user"));
}
}

View File

@ -15,6 +15,7 @@ api/api-console-halo-run-v1alpha1-user-api.ts
api/api-halo-run-v1alpha1-comment-api.ts
api/api-halo-run-v1alpha1-post-api.ts
api/api-halo-run-v1alpha1-tracker-api.ts
api/api-halo-run-v1alpha1-user-api.ts
api/auth-halo-run-v1alpha1-auth-provider-api.ts
api/auth-halo-run-v1alpha1-user-connection-api.ts
api/content-halo-run-v1alpha1-category-api.ts
@ -43,6 +44,7 @@ api/v1alpha1-menu-item-api.ts
api/v1alpha1-personal-access-token-api.ts
api/v1alpha1-role-api.ts
api/v1alpha1-role-binding-api.ts
api/v1alpha1-secret-api.ts
api/v1alpha1-setting-api.ts
api/v1alpha1-user-api.ts
base.ts
@ -169,11 +171,14 @@ models/role.ts
models/search-engine-list.ts
models/search-engine-spec.ts
models/search-engine.ts
models/secret-list.ts
models/secret.ts
models/setting-form.ts
models/setting-list.ts
models/setting-ref.ts
models/setting-spec.ts
models/setting.ts
models/sign-up-request.ts
models/single-page-list.ts
models/single-page-request.ts
models/single-page-spec.ts

View File

@ -26,6 +26,7 @@ export * from "./api/api-console-halo-run-v1alpha1-user-api";
export * from "./api/api-halo-run-v1alpha1-comment-api";
export * from "./api/api-halo-run-v1alpha1-post-api";
export * from "./api/api-halo-run-v1alpha1-tracker-api";
export * from "./api/api-halo-run-v1alpha1-user-api";
export * from "./api/auth-halo-run-v1alpha1-auth-provider-api";
export * from "./api/auth-halo-run-v1alpha1-user-connection-api";
export * from "./api/content-halo-run-v1alpha1-category-api";

View File

@ -0,0 +1,212 @@
/* 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.
*/
import type { Configuration } from "../configuration";
import type { AxiosPromise, AxiosInstance, AxiosRequestConfig } from "axios";
import globalAxios from "axios";
// Some imports not used depending on template conditions
// @ts-ignore
import {
DUMMY_BASE_URL,
assertParamExists,
setApiKeyToObject,
setBasicAuthToObject,
setBearerAuthToObject,
setOAuthToObject,
setSearchParams,
serializeDataIfNeeded,
toPathString,
createRequestFunction,
} from "../common";
// @ts-ignore
import {
BASE_PATH,
COLLECTION_FORMATS,
RequestArgs,
BaseAPI,
RequiredError,
} from "../base";
// @ts-ignore
import { SignUpRequest } from "../models";
// @ts-ignore
import { User } from "../models";
/**
* ApiHaloRunV1alpha1UserApi - axios parameter creator
* @export
*/
export const ApiHaloRunV1alpha1UserApiAxiosParamCreator = function (
configuration?: Configuration
) {
return {
/**
* Sign up a new user
* @param {SignUpRequest} signUpRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
signUp: async (
signUpRequest: SignUpRequest,
options: AxiosRequestConfig = {}
): Promise<RequestArgs> => {
// verify required parameter 'signUpRequest' is not null or undefined
assertParamExists("signUp", "signUpRequest", signUpRequest);
const localVarPath = `/apis/api.halo.run/v1alpha1/users/-/signup`;
// 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(
signUpRequest,
localVarRequestOptions,
configuration
);
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
};
};
/**
* ApiHaloRunV1alpha1UserApi - functional programming interface
* @export
*/
export const ApiHaloRunV1alpha1UserApiFp = function (
configuration?: Configuration
) {
const localVarAxiosParamCreator =
ApiHaloRunV1alpha1UserApiAxiosParamCreator(configuration);
return {
/**
* Sign up a new user
* @param {SignUpRequest} signUpRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async signUp(
signUpRequest: SignUpRequest,
options?: AxiosRequestConfig
): Promise<
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<User>
> {
const localVarAxiosArgs = await localVarAxiosParamCreator.signUp(
signUpRequest,
options
);
return createRequestFunction(
localVarAxiosArgs,
globalAxios,
BASE_PATH,
configuration
);
},
};
};
/**
* ApiHaloRunV1alpha1UserApi - factory interface
* @export
*/
export const ApiHaloRunV1alpha1UserApiFactory = function (
configuration?: Configuration,
basePath?: string,
axios?: AxiosInstance
) {
const localVarFp = ApiHaloRunV1alpha1UserApiFp(configuration);
return {
/**
* Sign up a new user
* @param {ApiHaloRunV1alpha1UserApiSignUpRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
signUp(
requestParameters: ApiHaloRunV1alpha1UserApiSignUpRequest,
options?: AxiosRequestConfig
): AxiosPromise<User> {
return localVarFp
.signUp(requestParameters.signUpRequest, options)
.then((request) => request(axios, basePath));
},
};
};
/**
* Request parameters for signUp operation in ApiHaloRunV1alpha1UserApi.
* @export
* @interface ApiHaloRunV1alpha1UserApiSignUpRequest
*/
export interface ApiHaloRunV1alpha1UserApiSignUpRequest {
/**
*
* @type {SignUpRequest}
* @memberof ApiHaloRunV1alpha1UserApiSignUp
*/
readonly signUpRequest: SignUpRequest;
}
/**
* ApiHaloRunV1alpha1UserApi - object-oriented interface
* @export
* @class ApiHaloRunV1alpha1UserApi
* @extends {BaseAPI}
*/
export class ApiHaloRunV1alpha1UserApi extends BaseAPI {
/**
* Sign up a new user
* @param {ApiHaloRunV1alpha1UserApiSignUpRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ApiHaloRunV1alpha1UserApi
*/
public signUp(
requestParameters: ApiHaloRunV1alpha1UserApiSignUpRequest,
options?: AxiosRequestConfig
) {
return ApiHaloRunV1alpha1UserApiFp(this.configuration)
.signUp(requestParameters.signUpRequest, options)
.then((request) => request(this.axios, this.basePath));
}
}

View File

@ -149,3 +149,4 @@ export * from "./user-permission";
export * from "./user-spec";
export * from "./user-status";
export * from "./vote-request";
export * from "./sign-up-request";

View File

@ -0,0 +1,37 @@
/* 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 { User } from "./user";
/**
*
* @export
* @interface SignUpRequest
*/
export interface SignUpRequest {
/**
*
* @type {string}
* @memberof SignUpRequest
*/
password: string;
/**
*
* @type {User}
* @memberof SignUpRequest
*/
user: User;
}

View File

@ -0,0 +1,127 @@
<script lang="ts" setup>
import { ref, onMounted } from "vue";
import { submitForm, reset } from "@formkit/core";
import { Toast, VButton } from "@halo-dev/components";
import { apiClient } from "@/utils/api-client";
import { useRouteQuery } from "@vueuse/router";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const formState = ref({
password: "",
user: {
apiVersion: "v1alpha1",
kind: "User",
metadata: {
name: "",
},
spec: {
avatar: "",
displayName: "",
email: "",
},
},
});
const emit = defineEmits<{
(event: "succeed"): void;
}>();
const login = useRouteQuery<string>("login");
const name = useRouteQuery<string>("name");
onMounted(() => {
if (login.value) {
formState.value.user.metadata.name = login.value;
}
if (name.value) {
formState.value.user.spec.displayName = name.value;
}
});
const handleSignup = async () => {
try {
await apiClient.common.user.signUp({
signUpRequest: formState.value,
});
Toast.success(t("core.signup.operations.submit.toast_success"));
emit("succeed");
} catch (error) {
console.error("Failed to sign up", error);
}
};
</script>
<template>
<FormKit
id="signup-form"
name="signup-form"
:actions="false"
:classes="{
form: '!divide-none',
}"
type="form"
:config="{ validationVisibility: 'submit' }"
@submit="handleSignup"
@keyup.enter="submitForm('signup-form')"
>
<FormKit
v-model="formState.user.metadata.name"
name="username"
:placeholder="$t('core.signup.fields.username.placeholder')"
:validation-label="$t('core.signup.fields.username.placeholder')"
:autofocus="true"
type="text"
: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.user.spec.displayName"
name="displayName"
:placeholder="$t('core.signup.fields.display_name.placeholder')"
:validation-label="$t('core.signup.fields.display_name.placeholder')"
:autofocus="true"
type="text"
validation="required"
>
</FormKit>
<FormKit
v-model="formState.password"
name="password"
:placeholder="$t('core.signup.fields.password.placeholder')"
:validation-label="$t('core.signup.fields.password.placeholder')"
type="password"
validation="required|length:0,100"
>
</FormKit>
<FormKit
name="password_confirm"
:placeholder="$t('core.signup.fields.password_confirm.placeholder')"
:validation-label="$t('core.signup.fields.password_confirm.placeholder')"
type="password"
validation="required|confirm|length:0,100"
>
</FormKit>
</FormKit>
<VButton
class="mt-6"
block
type="secondary"
@click="submitForm('signup-form')"
>
{{ $t("core.signup.operations.submit.button") }}
</VButton>
</template>

View File

@ -1,5 +1,6 @@
core:
login:
title: Login
fields:
username:
placeholder: Username
@ -16,6 +17,23 @@ core:
modal:
title: Re-login
other_login: "Other login:"
signup:
title: Sign up
fields:
username:
placeholder: Username
display_name:
placeholder: Display name
password:
placeholder: Password
password_confirm:
placeholder: Confirm password
operations:
submit:
button: Sign up
toast_success: Sign up successfully
binding:
title: Account binding
sidebar:
search:
placeholder: Search

View File

@ -1,5 +1,6 @@
core:
login:
title: 登录
fields:
username:
placeholder: 用户名
@ -16,6 +17,23 @@ core:
modal:
title: 重新登录
other_login: 其他登录:
signup:
title: 注册
fields:
username:
placeholder: 用户名
display_name:
placeholder: 名称
password:
placeholder: 密码
password_confirm:
placeholder: 确认密码
operations:
submit:
button: 注册
toast_success: 注册成功
binding:
title: 账号绑定
sidebar:
search:
placeholder: 搜索

View File

@ -104,4 +104,5 @@ export interface SocialAuthProvider {
logo: string;
website: string;
authenticationUrl: string;
bindingUrl?: string;
}

View File

@ -0,0 +1,80 @@
<script lang="ts" setup>
import { onBeforeMount } from "vue";
import router from "@/router";
import IconLogo from "~icons/core/logo?width=5rem&height=2rem";
import { useUserStore } from "@/stores/user";
import LoginForm from "@/components/login/LoginForm.vue";
import { useRoute } from "vue-router";
import type { GlobalInfo, SocialAuthProvider } from "../actuator/types";
import { useQuery } from "@tanstack/vue-query";
import axios from "axios";
import { Toast } from "@halo-dev/components";
import { useRouteQuery } from "@vueuse/router";
import SignupForm from "@/components/signup/SignupForm.vue";
const userStore = useUserStore();
const route = useRoute();
onBeforeMount(() => {
if (!userStore.isAnonymous) {
router.push({ name: "Dashboard" });
}
});
const { data: socialAuthProviders } = useQuery<SocialAuthProvider[]>({
queryKey: ["social-auth-providers"],
queryFn: async () => {
const { data } = await axios.get<GlobalInfo>(
`${import.meta.env.VITE_API_URL}/actuator/globalinfo`,
{
withCredentials: true,
}
);
return data.socialAuthProviders;
},
refetchOnWindowFocus: false,
});
function handleBinding() {
const authProvider = socialAuthProviders.value?.find(
(p) => p.name === route.params.provider
);
if (!authProvider?.bindingUrl) {
Toast.error("绑定失败,没有找到已启用的登录方式");
return;
}
window.location.href = authProvider?.bindingUrl;
Toast.success("绑定成功");
}
const type = useRouteQuery<string>("type", "");
function handleChangeType() {
type.value = type.value === "signup" ? "" : "signup";
}
</script>
<template>
<div class="flex h-screen flex-col items-center justify-center">
<div class="mb-4">
<IconLogo />
</div>
<div class="login-form flex w-72 flex-col">
<div class="mb-4 flex">
<h1 class="text-sm">{{ $t("core.binding.title") }}</h1>
</div>
<SignupForm v-if="type === 'signup'" @succeed="handleBinding" />
<LoginForm v-else @succeed="handleBinding" />
<div class="flex">
<span class="mt-4 text-sm text-indigo-600" @click="handleChangeType">
{{
type === "signup" ? $t("core.login.title") : $t("core.signup.title")
}}
</span>
</div>
</div>
</div>
</template>

View File

@ -4,6 +4,8 @@ import router from "@/router";
import IconLogo from "~icons/core/logo?width=5rem&height=2rem";
import { useUserStore } from "@/stores/user";
import LoginForm from "@/components/login/LoginForm.vue";
import { useRouteQuery } from "@vueuse/router";
import SignupForm from "@/components/signup/SignupForm.vue";
const userStore = useUserStore();
@ -16,12 +18,26 @@ onBeforeMount(() => {
function onLoginSucceed() {
window.location.reload();
}
const type = useRouteQuery<string>("type", "");
function handleChangeType() {
type.value = type.value === "signup" ? "" : "signup";
}
</script>
<template>
<div class="flex h-screen flex-col items-center justify-center">
<IconLogo class="mb-8" />
<div class="login-form flex w-72 flex-col">
<LoginForm @succeed="onLoginSucceed" />
<SignupForm v-if="type === 'signup'" @succeed="onLoginSucceed" />
<LoginForm v-else @succeed="onLoginSucceed" />
<div class="flex">
<span class="mt-4 text-sm text-indigo-600" @click="handleChangeType">
{{
type === "signup" ? $t("core.login.title") : $t("core.signup.title")
}}
</span>
</div>
</div>
</div>
</template>

View File

@ -9,6 +9,7 @@ import PersonalAccessTokens from "./PersonalAccessTokens.vue";
import Login from "./Login.vue";
import { IconUserSettings } from "@halo-dev/components";
import { markRaw } from "vue";
import Binding from "./Binding.vue";
export default definePlugin({
components: {
@ -19,6 +20,17 @@ export default definePlugin({
path: "/login",
name: "Login",
component: Login,
meta: {
title: "core.login.title",
},
},
{
path: "/binding/:provider",
name: "Binding",
component: Binding,
meta: {
title: "core.binding.title",
},
},
{
path: "/users",

View File

@ -1,9 +1,11 @@
import { useUserStore } from "@/stores/user";
import type { Router } from "vue-router";
const whiteList = ["Setup", "Login", "Binding"];
export function setupAuthCheckGuard(router: Router) {
router.beforeEach((to, from, next) => {
if (to.name === "Setup" || to.name === "Login") {
if (whiteList.includes(to.name as string)) {
next();
return;
}

View File

@ -1,9 +1,11 @@
import { useSystemStatesStore } from "@/stores/system-states";
import type { Router } from "vue-router";
const whiteList = ["Setup", "Login", "Binding"];
export function setupCheckStatesGuard(router: Router) {
router.beforeEach(async (to, from, next) => {
if (to.name === "Setup" || to.name === "Login") {
if (whiteList.includes(to.name as string)) {
next();
return;
}

View File

@ -36,6 +36,7 @@ import {
LoginApi,
AuthHaloRunV1alpha1AuthProviderApi,
AuthHaloRunV1alpha1UserConnectionApi,
ApiHaloRunV1alpha1UserApi,
} from "@halo-dev/api-client";
import type { AxiosError, AxiosInstance } from "axios";
import axios from "axios";
@ -214,6 +215,9 @@ function setupApiClient(axios: AxiosInstance) {
baseURL,
axios
),
common: {
user: new ApiHaloRunV1alpha1UserApi(undefined, baseURL, axios),
},
};
}