feat: add support for force verify email during user registration (#5320)

#### What type of PR is this?

/kind feature
/kind improvement
/area core
/area console
/kind api-change

#### What this PR does / why we need it:
增加对用户注册时必须验证邮箱的支持

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

Fixes #5016

#### Special notes for your reviewer:
`regRequireVerifyEmail` 为 `false` 时与现在的注册行为一致
为 `true` 时注册页显示验证码校验相关,注册成功后 `UserSpec.emailVerified` 即为 `true`
没有判断邮件通知是否开启,与现有的邮箱验证一致,如未开启则收不到邮件

#### Does this PR introduce a user-facing change?

```release-note
增加对用户注册时必须验证邮箱的支持
```
pull/5407/head
MashiroT 2024-02-22 17:44:06 +08:00 committed by GitHub
parent 9e676712e4
commit 50fbe37be8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 519 additions and 59 deletions

View File

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

View File

@ -55,13 +55,11 @@ public class GlobalInfoEndpoint {
handleBasicSetting(info, configMap);
handlePostSlugGenerationStrategy(info, configMap);
}));
return info;
}
@Data
public static class GlobalInfo {
private URL externalUrl;
private boolean useAbsolutePermalink;
@ -85,6 +83,8 @@ public class GlobalInfoEndpoint {
private String postSlugGenerationStrategy;
private List<SocialAuthProvider> socialAuthProviders;
private Boolean mustVerifyEmailOnRegistration;
}
@Data
@ -117,12 +117,14 @@ public class GlobalInfoEndpoint {
}
private void handleUserSetting(GlobalInfo info, ConfigMap configMap) {
var user = SystemSetting.get(configMap, User.GROUP, User.class);
if (user == null) {
var userSetting = SystemSetting.get(configMap, User.GROUP, User.class);
if (userSetting == null) {
info.setAllowRegistration(false);
info.setMustVerifyEmailOnRegistration(false);
} else {
info.setAllowRegistration(
user.getAllowRegistration() != null && user.getAllowRegistration());
userSetting.getAllowRegistration() != null && userSetting.getAllowRegistration());
info.setMustVerifyEmailOnRegistration(userSetting.getMustVerifyEmailOnRegistration());
}
}

View File

@ -27,4 +27,21 @@ public interface EmailVerificationService {
* @throws EmailVerificationFailed if send failed
*/
Mono<Void> verify(String username, String code);
/**
* Send verification code.
* The only difference is use email as username.
*
* @param email email to send must not be blank
*/
Mono<Void> sendRegisterVerificationCode(String email);
/**
* Verify email by given code.
*
* @param email email as username to verify email must not be blank
* @param code code to verify email must not be blank
* @throws EmailVerificationFailed if send failed
*/
Mono<Boolean> verifyRegisterVerificationCode(String email, String code);
}

View File

@ -102,6 +102,19 @@ public class EmailVerificationServiceImpl implements EmailVerificationService {
.then();
}
@Override
public Mono<Void> sendRegisterVerificationCode(String email) {
Assert.state(StringUtils.isNotBlank(email), "Email must not be blank");
return sendVerificationNotification(email, email);
}
@Override
public Mono<Boolean> verifyRegisterVerificationCode(String email, String code) {
Assert.state(StringUtils.isNotBlank(email), "Username must not be blank");
Assert.state(StringUtils.isNotBlank(code), "Code must not be blank");
return Mono.just(emailVerificationManager.verifyCode(email, email, code));
}
Mono<Void> sendVerificationNotification(String username, String email) {
var code = emailVerificationManager.generateCode(username, email);
var subscribeNotification = autoSubscribeVerificationEmailNotification(email);

View File

@ -1,5 +1,6 @@
package run.halo.app.theme.endpoint;
import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED;
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.parameter.Builder.parameterBuilder;
@ -11,6 +12,7 @@ import io.github.resilience4j.reactor.ratelimiter.operator.RateLimiterOperator;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.BooleanUtils;
import org.apache.commons.lang3.StringUtils;
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
import org.springframework.http.HttpStatus;
@ -29,8 +31,14 @@ 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.EmailPasswordRecoveryService;
import run.halo.app.core.extension.service.EmailVerificationService;
import run.halo.app.core.extension.service.UserService;
import run.halo.app.extension.GroupVersion;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting;
import run.halo.app.infra.ValidationUtils;
import run.halo.app.infra.exception.AccessDeniedException;
import run.halo.app.infra.exception.EmailVerificationFailed;
import run.halo.app.infra.exception.RateLimitExceededException;
import run.halo.app.infra.utils.IpAddressUtils;
@ -48,6 +56,8 @@ public class PublicUserEndpoint implements CustomEndpoint {
private final ReactiveUserDetailsService reactiveUserDetailsService;
private final EmailPasswordRecoveryService emailPasswordRecoveryService;
private final RateLimiterRegistry rateLimiterRegistry;
private final SystemConfigurableEnvironmentFetcher environmentFetcher;
private final EmailVerificationService emailVerificationService;
@Override
public RouterFunction<ServerResponse> endpoint() {
@ -62,6 +72,22 @@ public class PublicUserEndpoint implements CustomEndpoint {
)
.response(responseBuilder().implementation(User.class))
)
.POST("/users/-/send-register-verify-email", this::sendRegisterVerifyEmail,
builder -> builder.operationId("SendRegisterVerifyEmail")
.description(
"Send registration verification email, which can be called when "
+ "mustVerifyEmailOnRegistration in user settings is true"
)
.tag(tag)
.requestBody(requestBodyBuilder()
.required(true)
.implementation(RegisterVerifyEmailRequest.class)
)
.response(responseBuilder()
.responseCode(HttpStatus.NO_CONTENT.toString())
.implementation(Void.class)
)
)
.POST("/users/-/send-password-reset-email", this::sendPasswordResetEmail,
builder -> builder.operationId("SendPasswordResetEmail")
.description("Send password reset email when forgot password")
@ -126,6 +152,9 @@ public class PublicUserEndpoint implements CustomEndpoint {
@Schema(requiredMode = REQUIRED) String token) {
}
record RegisterVerifyEmailRequest(@Schema(requiredMode = REQUIRED) String email) {
}
private Mono<ServerResponse> sendPasswordResetEmail(ServerRequest request) {
return request.bodyToMono(PasswordResetEmailRequest.class)
.flatMap(passwordResetRequest -> {
@ -154,6 +183,30 @@ public class PublicUserEndpoint implements CustomEndpoint {
private Mono<ServerResponse> signUp(ServerRequest request) {
return request.bodyToMono(SignUpRequest.class)
.doOnNext(signUpRequest -> signUpRequest.user().getSpec().setEmailVerified(false))
.flatMap(signUpRequest -> environmentFetcher.fetch(SystemSetting.User.GROUP,
SystemSetting.User.class)
.map(user -> BooleanUtils.isTrue(user.getMustVerifyEmailOnRegistration()))
.defaultIfEmpty(false)
.flatMap(mustVerifyEmailOnRegistration -> {
if (!mustVerifyEmailOnRegistration) {
return Mono.just(signUpRequest);
}
if (!StringUtils.isNumeric(signUpRequest.verifyCode)) {
return Mono.error(new EmailVerificationFailed());
}
return emailVerificationService.verifyRegisterVerificationCode(
signUpRequest.user().getSpec().getEmail(),
signUpRequest.verifyCode)
.flatMap(verified -> {
if (BooleanUtils.isNotTrue(verified)) {
return Mono.error(new EmailVerificationFailed());
}
signUpRequest.user().getSpec().setEmailVerified(true);
return Mono.just(signUpRequest);
});
})
)
.flatMap(signUpRequest ->
userService.signUp(signUpRequest.user(), signUpRequest.password())
)
@ -168,6 +221,35 @@ public class PublicUserEndpoint implements CustomEndpoint {
.onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new);
}
private Mono<ServerResponse> sendRegisterVerifyEmail(ServerRequest request) {
return request.bodyToMono(RegisterVerifyEmailRequest.class)
.switchIfEmpty(Mono.error(
() -> new ServerWebInputException("Required request body is missing."))
)
.map(emailReq -> {
var email = emailReq.email();
if (!ValidationUtils.isValidEmail(email)) {
throw new ServerWebInputException("Invalid email address.");
}
return email;
})
.flatMap(email -> environmentFetcher.fetch(SystemSetting.User.GROUP,
SystemSetting.User.class)
.map(config -> BooleanUtils.isTrue(config.getMustVerifyEmailOnRegistration()))
.defaultIfEmpty(false)
.doOnNext(mustVerifyEmailOnRegistration -> {
if (!mustVerifyEmailOnRegistration) {
throw new AccessDeniedException("Email verification is not required.");
}
})
.transformDeferred(sendRegisterEmailVerificationCodeRateLimiter(email))
.flatMap(s -> emailVerificationService.sendRegisterVerificationCode(email)
.onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new))
.onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new)
)
.then(ServerResponse.ok().build());
}
private <T> RateLimiterOperator<T> getRateLimiterForSignUp(ServerWebExchange exchange) {
var clientIp = IpAddressUtils.getClientIp(exchange.getRequest());
var rateLimiter = rateLimiterRegistry.rateLimiter("signup-from-ip-" + clientIp,
@ -187,7 +269,17 @@ public class PublicUserEndpoint implements CustomEndpoint {
});
}
private <T> RateLimiterOperator<T> sendRegisterEmailVerificationCodeRateLimiter(String email) {
String rateLimiterKey = "send-register-verify-email:" + email;
var rateLimiter =
rateLimiterRegistry.rateLimiter(rateLimiterKey, "send-email-verification-code");
return RateLimiterOperator.of(rateLimiter);
}
record SignUpRequest(@Schema(requiredMode = REQUIRED) User user,
@Schema(requiredMode = REQUIRED, minLength = 6) String password) {
@Schema(requiredMode = REQUIRED, minLength = 6) String password,
@Schema(requiredMode = NOT_REQUIRED, minLength = 6, maxLength = 6)
String verifyCode
) {
}
}

View File

@ -96,6 +96,10 @@ spec:
name: allowRegistration
label: "开放注册"
value: false
- $formkit: checkbox
name: mustVerifyEmailOnRegistration
label: "注册需验证邮箱(请确保启用邮件通知)"
value: false
- $formkit: roleSelect
name: defaultRole
label: "默认角色"

View File

@ -3,6 +3,7 @@ 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.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ -21,6 +22,8 @@ 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;
import run.halo.app.infra.SystemConfigurableEnvironmentFetcher;
import run.halo.app.infra.SystemSetting;
/**
* Tests for {@link PublicUserEndpoint}.
@ -36,7 +39,8 @@ class PublicUserEndpointTest {
private ServerSecurityContextRepository securityContextRepository;
@Mock
private ReactiveUserDetailsService reactiveUserDetailsService;
@Mock
SystemConfigurableEnvironmentFetcher environmentFetcher;
@Mock
RateLimiterRegistry rateLimiterRegistry;
@ -67,6 +71,9 @@ class PublicUserEndpointTest {
.password("123456")
.authorities("test-role")
.build()));
SystemSetting.User userSetting = mock(SystemSetting.User.class);
when(environmentFetcher.fetch(SystemSetting.User.GROUP, SystemSetting.User.class))
.thenReturn(Mono.just(userSetting));
when(rateLimiterRegistry.rateLimiter("signup-from-ip-127.0.0.1", "signup"))
.thenReturn(RateLimiter.ofDefaults("signup"));
@ -74,7 +81,7 @@ class PublicUserEndpointTest {
webClient.post()
.uri("/users/-/signup")
.header("X-Forwarded-For", "127.0.0.1")
.bodyValue(new PublicUserEndpoint.SignUpRequest(user, "fake-password"))
.bodyValue(new PublicUserEndpoint.SignUpRequest(user, "fake-password", ""))
.exchange()
.expectStatus().isOk();

View File

@ -36,15 +36,19 @@ import {
RequestArgs,
BaseAPI,
RequiredError,
operationServerMap,
} from "../base";
// @ts-ignore
import { PasswordResetEmailRequest } from "../models";
// @ts-ignore
import { RegisterVerifyEmailRequest } from "../models";
// @ts-ignore
import { ResetPasswordRequest } from "../models";
// @ts-ignore
import { SignUpRequest } from "../models";
// @ts-ignore
import { User } from "../models";
/**
* ApiHaloRunV1alpha1UserApi - axios parameter creator
* @export
@ -183,6 +187,67 @@ export const ApiHaloRunV1alpha1UserApiAxiosParamCreator = function (
options: localVarRequestOptions,
};
},
/**
* Send registration verification email, which can be called when mustVerifyEmailOnRegistration in user settings is true
* @param {RegisterVerifyEmailRequest} registerVerifyEmailRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
sendRegisterVerifyEmail: async (
registerVerifyEmailRequest: RegisterVerifyEmailRequest,
options: AxiosRequestConfig = {}
): Promise<RequestArgs> => {
// verify required parameter 'registerVerifyEmailRequest' is not null or undefined
assertParamExists(
"sendRegisterVerifyEmail",
"registerVerifyEmailRequest",
registerVerifyEmailRequest
);
const localVarPath = `/apis/api.halo.run/v1alpha1/users/-/send-register-verify-email`;
// 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(
registerVerifyEmailRequest,
localVarRequestOptions,
configuration
);
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Sign up a new user
* @param {SignUpRequest} signUpRequest
@ -273,12 +338,18 @@ export const ApiHaloRunV1alpha1UserApiFp = function (
resetPasswordRequest,
options
);
return createRequestFunction(
localVarAxiosArgs,
globalAxios,
BASE_PATH,
configuration
);
const index = configuration?.serverIndex ?? 0;
const operationBasePath =
operationServerMap["ApiHaloRunV1alpha1UserApi.resetPasswordByToken"]?.[
index
]?.url;
return (axios, basePath) =>
createRequestFunction(
localVarAxiosArgs,
globalAxios,
BASE_PATH,
configuration
)(axios, operationBasePath || basePath);
},
/**
* Send password reset email when forgot password
@ -297,12 +368,48 @@ export const ApiHaloRunV1alpha1UserApiFp = function (
passwordResetEmailRequest,
options
);
return createRequestFunction(
localVarAxiosArgs,
globalAxios,
BASE_PATH,
configuration
);
const index = configuration?.serverIndex ?? 0;
const operationBasePath =
operationServerMap[
"ApiHaloRunV1alpha1UserApi.sendPasswordResetEmail"
]?.[index]?.url;
return (axios, basePath) =>
createRequestFunction(
localVarAxiosArgs,
globalAxios,
BASE_PATH,
configuration
)(axios, operationBasePath || basePath);
},
/**
* Send registration verification email, which can be called when mustVerifyEmailOnRegistration in user settings is true
* @param {RegisterVerifyEmailRequest} registerVerifyEmailRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async sendRegisterVerifyEmail(
registerVerifyEmailRequest: RegisterVerifyEmailRequest,
options?: AxiosRequestConfig
): Promise<
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>
> {
const localVarAxiosArgs =
await localVarAxiosParamCreator.sendRegisterVerifyEmail(
registerVerifyEmailRequest,
options
);
const index = configuration?.serverIndex ?? 0;
const operationBasePath =
operationServerMap[
"ApiHaloRunV1alpha1UserApi.sendRegisterVerifyEmail"
]?.[index]?.url;
return (axios, basePath) =>
createRequestFunction(
localVarAxiosArgs,
globalAxios,
BASE_PATH,
configuration
)(axios, operationBasePath || basePath);
},
/**
* Sign up a new user
@ -320,12 +427,16 @@ export const ApiHaloRunV1alpha1UserApiFp = function (
signUpRequest,
options
);
return createRequestFunction(
localVarAxiosArgs,
globalAxios,
BASE_PATH,
configuration
);
const index = configuration?.serverIndex ?? 0;
const operationBasePath =
operationServerMap["ApiHaloRunV1alpha1UserApi.signUp"]?.[index]?.url;
return (axios, basePath) =>
createRequestFunction(
localVarAxiosArgs,
globalAxios,
BASE_PATH,
configuration
)(axios, operationBasePath || basePath);
},
};
};
@ -376,6 +487,23 @@ export const ApiHaloRunV1alpha1UserApiFactory = function (
)
.then((request) => request(axios, basePath));
},
/**
* Send registration verification email, which can be called when mustVerifyEmailOnRegistration in user settings is true
* @param {ApiHaloRunV1alpha1UserApiSendRegisterVerifyEmailRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
sendRegisterVerifyEmail(
requestParameters: ApiHaloRunV1alpha1UserApiSendRegisterVerifyEmailRequest,
options?: AxiosRequestConfig
): AxiosPromise<void> {
return localVarFp
.sendRegisterVerifyEmail(
requestParameters.registerVerifyEmailRequest,
options
)
.then((request) => request(axios, basePath));
},
/**
* Sign up a new user
* @param {ApiHaloRunV1alpha1UserApiSignUpRequest} requestParameters Request parameters.
@ -428,6 +556,20 @@ export interface ApiHaloRunV1alpha1UserApiSendPasswordResetEmailRequest {
readonly passwordResetEmailRequest: PasswordResetEmailRequest;
}
/**
* Request parameters for sendRegisterVerifyEmail operation in ApiHaloRunV1alpha1UserApi.
* @export
* @interface ApiHaloRunV1alpha1UserApiSendRegisterVerifyEmailRequest
*/
export interface ApiHaloRunV1alpha1UserApiSendRegisterVerifyEmailRequest {
/**
*
* @type {RegisterVerifyEmailRequest}
* @memberof ApiHaloRunV1alpha1UserApiSendRegisterVerifyEmail
*/
readonly registerVerifyEmailRequest: RegisterVerifyEmailRequest;
}
/**
* Request parameters for signUp operation in ApiHaloRunV1alpha1UserApi.
* @export
@ -488,6 +630,25 @@ export class ApiHaloRunV1alpha1UserApi extends BaseAPI {
.then((request) => request(this.axios, this.basePath));
}
/**
* Send registration verification email, which can be called when mustVerifyEmailOnRegistration in user settings is true
* @param {ApiHaloRunV1alpha1UserApiSendRegisterVerifyEmailRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ApiHaloRunV1alpha1UserApi
*/
public sendRegisterVerifyEmail(
requestParameters: ApiHaloRunV1alpha1UserApiSendRegisterVerifyEmailRequest,
options?: AxiosRequestConfig
) {
return ApiHaloRunV1alpha1UserApiFp(this.configuration)
.sendRegisterVerifyEmail(
requestParameters.registerVerifyEmailRequest,
options
)
.then((request) => request(this.axios, this.basePath));
}
/**
* Sign up a new user
* @param {ApiHaloRunV1alpha1UserApiSignUpRequest} requestParameters Request parameters.

View File

@ -73,3 +73,16 @@ export class RequiredError extends Error {
this.name = "RequiredError";
}
}
interface ServerMap {
[key: string]: {
url: string,
description: string,
}[];
}
/**
*
* @export
*/
export const operationServerMap: ServerMap = {};

View File

@ -13,19 +13,12 @@
*/
export interface ConfigurationParameters {
apiKey?:
| string
| Promise<string>
| ((name: string) => string)
| ((name: string) => Promise<string>);
apiKey?: string | Promise<string> | ((name: string) => string) | ((name: string) => Promise<string>);
username?: string;
password?: string;
accessToken?:
| string
| Promise<string>
| ((name?: string, scopes?: string[]) => string)
| ((name?: string, scopes?: string[]) => Promise<string>);
accessToken?: string | Promise<string> | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise<string>);
basePath?: string;
serverIndex?: number;
baseOptions?: any;
formDataCtor?: new () => any;
}
@ -36,11 +29,7 @@ export class Configuration {
* @param name security name
* @memberof Configuration
*/
apiKey?:
| string
| Promise<string>
| ((name: string) => string)
| ((name: string) => Promise<string>);
apiKey?: string | Promise<string> | ((name: string) => string) | ((name: string) => Promise<string>);
/**
* parameter for basic security
*
@ -61,11 +50,7 @@ export class Configuration {
* @param scopes oauth2 scope
* @memberof Configuration
*/
accessToken?:
| string
| Promise<string>
| ((name?: string, scopes?: string[]) => string)
| ((name?: string, scopes?: string[]) => Promise<string>);
accessToken?: string | Promise<string> | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise<string>);
/**
* override base path
*
@ -73,6 +58,13 @@ export class Configuration {
* @memberof Configuration
*/
basePath?: string;
/**
* override server index
*
* @type {number}
* @memberof Configuration
*/
serverIndex?: number;
/**
* base options for axios calls
*
@ -95,6 +87,7 @@ export class Configuration {
this.password = param.password;
this.accessToken = param.accessToken;
this.basePath = param.basePath;
this.serverIndex = param.serverIndex;
this.baseOptions = param.baseOptions;
this.formDataCtor = param.formDataCtor;
}
@ -110,14 +103,7 @@ export class Configuration {
* @return True if the given MIME is JSON, false otherwise.
*/
public isJsonMime(mime: string): boolean {
const jsonMime: RegExp = new RegExp(
"^(application/json|[^;/ \t]+/[^;/ \t]+[+]json)[ \t]*(;.*)?$",
"i"
);
return (
mime !== null &&
(jsonMime.test(mime) ||
mime.toLowerCase() === "application/json-patch+json")
);
const jsonMime: RegExp = new RegExp("^(application\/json|[^;/ \t]+\/[^;/ \t]+[+]json)[ \t]*(;.*)?$", "i");
return mime !== null && (jsonMime.test(mime) || mime.toLowerCase() === "application/json-patch+json");
}
}

View File

@ -148,6 +148,7 @@ export * from "./reason-type-notifier-matrix";
export * from "./reason-type-notifier-request";
export * from "./reason-type-spec";
export * from "./ref";
export * from "./register-verify-email-request";
export * from "./reply";
export * from "./reply-list";
export * from "./reply-request";

View File

@ -0,0 +1,27 @@
/* 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 RegisterVerifyEmailRequest
*/
export interface RegisterVerifyEmailRequest {
/**
*
* @type {string}
* @memberof RegisterVerifyEmailRequest
*/
email: string;
}

View File

@ -24,14 +24,20 @@ import { User } from "./user";
export interface SignUpRequest {
/**
*
* @type {string}
* @type {any}
* @memberof SignUpRequest
*/
password: string;
password: any;
/**
*
* @type {User}
* @memberof SignUpRequest
*/
user: User;
/**
*
* @type {any}
* @memberof SignUpRequest
*/
verifyCode?: any;
}

View File

@ -1,10 +1,13 @@
<script lang="ts" setup>
import { ref, onMounted } from "vue";
import { ref, onMounted, reactive, computed, type ComputedRef } from "vue";
import { submitForm } 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";
import { useMutation } from "@tanstack/vue-query";
import { useIntervalFn } from "@vueuse/shared";
import { useGlobalInfoStore } from "@/stores/global-info";
const { t } = useI18n();
@ -31,6 +34,7 @@ const formState = ref({
email: "",
},
},
verifyCode: "",
});
const loading = ref(false);
@ -40,8 +44,14 @@ const emit = defineEmits<{
const login = useRouteQuery<string>("login");
const name = useRouteQuery<string>("name");
const globalInfoStore = useGlobalInfoStore();
const signUpCond = reactive({
mustVerifyEmailOnRegistration: false,
});
onMounted(() => {
signUpCond.mustVerifyEmailOnRegistration =
globalInfoStore.globalInfo?.mustVerifyEmailOnRegistration || false;
if (login.value) {
formState.value.user.metadata.name = login.value;
}
@ -49,6 +59,16 @@ onMounted(() => {
formState.value.user.spec.displayName = name.value;
}
});
const emailRegex = new RegExp("^[\\w\\-.]+@([\\w-]+\\.)+[\\w-]{2,}$");
const emailValidation: ComputedRef<
// please see https://github.com/formkit/formkit/blob/bd5cf1c378d358ed3aba7b494713af20b6c909ab/packages/inputs/src/props.ts#L660
// eslint-disable-next-line @typescript-eslint/no-explicit-any
string | Array<[rule: string, ...args: any]>
> = computed(() => {
if (signUpCond.mustVerifyEmailOnRegistration)
return [["required"], ["matches", emailRegex]];
else return "required|email|length:0,100";
});
const handleSignup = async () => {
try {
@ -71,6 +91,60 @@ const handleSignup = async () => {
const inputClasses = {
outer: "!py-3 first:!pt-0 last:!pb-0",
};
// the code below is copied from console/uc-src/modules/profile/components/EmailVerifyModal.vue
const timer = ref(0);
const { pause, resume, isActive } = useIntervalFn(
() => {
if (timer.value <= 0) {
pause();
} else {
timer.value--;
}
},
1000,
{
immediate: false,
}
);
const { mutate: sendVerifyCode, isLoading: isSending } = useMutation({
mutationKey: ["send-verify-code"],
mutationFn: async () => {
if (!formState.value.user.spec.email.match(emailRegex)) {
Toast.error(t("core.signup.fields.email.matchFailed"));
throw new Error("email is illegal");
}
return await apiClient.common.user.sendRegisterVerifyEmail({
registerVerifyEmailRequest: {
email: formState.value.user.spec.email,
},
});
},
onSuccess() {
Toast.success(
t("core.signup.fields.verify_code.operations.send_code.toast_success")
);
timer.value = 60;
resume();
},
});
const sendVerifyCodeButtonText = computed(() => {
if (isSending.value) {
return t(
"core.signup.fields.verify_code.operations.send_code.buttons.sending"
);
}
return isActive.value
? t(
"core.signup.fields.verify_code.operations.send_code.buttons.countdown",
{
timer: timer.value,
}
)
: t("core.signup.fields.verify_code.operations.send_code.buttons.send");
});
</script>
<template>
@ -123,8 +197,30 @@ const inputClasses = {
:validation-label="$t('core.signup.fields.email.placeholder')"
type="email"
name="email"
validation="required|email|length:0,100"
:validation="emailValidation"
:validation-messages="{
matches: $t('core.signup.fields.email.matchFailed'),
}"
></FormKit>
<FormKit
v-if="signUpCond.mustVerifyEmailOnRegistration"
v-model="formState.verifyCode"
type="number"
name="code"
:placeholder="$t('core.signup.fields.verify_code.placeholder')"
validation="required"
>
<template #suffix>
<VButton
:loading="isSending"
:disabled="isActive"
class="rounded-none border-y-0 border-l border-r-0 tabular-nums"
@click="sendVerifyCode"
>
{{ sendVerifyCodeButtonText }}
</VButton>
</template>
</FormKit>
<FormKit
v-model="formState.password"
name="password"

View File

@ -32,6 +32,17 @@ core:
placeholder: Display name
email:
placeholder: Email
matchFailed: The email format is wrong or the service provider is not supported.
verify_code:
placeholder: Verification code
operations:
send_code:
buttons:
sending: sending
send: Send Code
countdown: "resend after {timer} seconds"
toast_success: verification code sent
toast_email_empty: please enter your email address
password:
placeholder: Password
password_confirm:

View File

@ -32,6 +32,17 @@ core:
placeholder: 名称
email:
placeholder: 电子邮箱
matchFailed: 邮箱格式错误或服务商不受支持
verify_code:
placeholder: 验证码
operations:
send_code:
buttons:
sending: 发送中
send: 发送验证码
countdown: "{timer} 秒后重发"
toast_success: 验证码已发送
toast_email_empty: 请输入电子邮箱
password:
placeholder: 密码
password_confirm:

View File

@ -32,6 +32,17 @@ core:
placeholder: 名稱
email:
placeholder: 電子郵箱
matchFailed: 郵箱格式錯誤或服務商不受支援
verify_code:
placeholder: 驗證碼
operations:
send_code:
buttons:
countdown: "{timer} 秒後重發"
send: 發送驗證碼
sending: 發送中
toast_email_empty: 請輸入電子郵件信箱
toast_success: 驗證碼已發送
password:
placeholder: 密碼
password_confirm:

View File

@ -13,6 +13,7 @@ export interface GlobalInfo {
dataInitialized: boolean;
favicon?: string;
postSlugGenerationStrategy: ModeType;
mustVerifyEmailOnRegistration: boolean;
}
export interface Info {