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 class User {
public static final String GROUP = "user"; public static final String GROUP = "user";
Boolean allowRegistration; Boolean allowRegistration;
Boolean mustVerifyEmailOnRegistration;
String defaultRole; String defaultRole;
String avatarPolicy; String avatarPolicy;
} }

View File

@ -55,13 +55,11 @@ public class GlobalInfoEndpoint {
handleBasicSetting(info, configMap); handleBasicSetting(info, configMap);
handlePostSlugGenerationStrategy(info, configMap); handlePostSlugGenerationStrategy(info, configMap);
})); }));
return info; return info;
} }
@Data @Data
public static class GlobalInfo { public static class GlobalInfo {
private URL externalUrl; private URL externalUrl;
private boolean useAbsolutePermalink; private boolean useAbsolutePermalink;
@ -85,6 +83,8 @@ public class GlobalInfoEndpoint {
private String postSlugGenerationStrategy; private String postSlugGenerationStrategy;
private List<SocialAuthProvider> socialAuthProviders; private List<SocialAuthProvider> socialAuthProviders;
private Boolean mustVerifyEmailOnRegistration;
} }
@Data @Data
@ -117,12 +117,14 @@ public class GlobalInfoEndpoint {
} }
private void handleUserSetting(GlobalInfo info, ConfigMap configMap) { private void handleUserSetting(GlobalInfo info, ConfigMap configMap) {
var user = SystemSetting.get(configMap, User.GROUP, User.class); var userSetting = SystemSetting.get(configMap, User.GROUP, User.class);
if (user == null) { if (userSetting == null) {
info.setAllowRegistration(false); info.setAllowRegistration(false);
info.setMustVerifyEmailOnRegistration(false);
} else { } else {
info.setAllowRegistration( 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 * @throws EmailVerificationFailed if send failed
*/ */
Mono<Void> verify(String username, String code); 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(); .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) { Mono<Void> sendVerificationNotification(String username, String email) {
var code = emailVerificationManager.generateCode(username, email); var code = emailVerificationManager.generateCode(username, email);
var subscribeNotification = autoSubscribeVerificationEmailNotification(email); var subscribeNotification = autoSubscribeVerificationEmailNotification(email);

View File

@ -1,5 +1,6 @@
package run.halo.app.theme.endpoint; 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 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.apiresponse.Builder.responseBuilder;
import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; 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.enums.ParameterIn;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.BooleanUtils;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
import org.springframework.http.HttpStatus; 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.User;
import run.halo.app.core.extension.endpoint.CustomEndpoint; import run.halo.app.core.extension.endpoint.CustomEndpoint;
import run.halo.app.core.extension.service.EmailPasswordRecoveryService; 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.core.extension.service.UserService;
import run.halo.app.extension.GroupVersion; 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.exception.RateLimitExceededException;
import run.halo.app.infra.utils.IpAddressUtils; import run.halo.app.infra.utils.IpAddressUtils;
@ -48,6 +56,8 @@ public class PublicUserEndpoint implements CustomEndpoint {
private final ReactiveUserDetailsService reactiveUserDetailsService; private final ReactiveUserDetailsService reactiveUserDetailsService;
private final EmailPasswordRecoveryService emailPasswordRecoveryService; private final EmailPasswordRecoveryService emailPasswordRecoveryService;
private final RateLimiterRegistry rateLimiterRegistry; private final RateLimiterRegistry rateLimiterRegistry;
private final SystemConfigurableEnvironmentFetcher environmentFetcher;
private final EmailVerificationService emailVerificationService;
@Override @Override
public RouterFunction<ServerResponse> endpoint() { public RouterFunction<ServerResponse> endpoint() {
@ -62,6 +72,22 @@ public class PublicUserEndpoint implements CustomEndpoint {
) )
.response(responseBuilder().implementation(User.class)) .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, .POST("/users/-/send-password-reset-email", this::sendPasswordResetEmail,
builder -> builder.operationId("SendPasswordResetEmail") builder -> builder.operationId("SendPasswordResetEmail")
.description("Send password reset email when forgot password") .description("Send password reset email when forgot password")
@ -126,6 +152,9 @@ public class PublicUserEndpoint implements CustomEndpoint {
@Schema(requiredMode = REQUIRED) String token) { @Schema(requiredMode = REQUIRED) String token) {
} }
record RegisterVerifyEmailRequest(@Schema(requiredMode = REQUIRED) String email) {
}
private Mono<ServerResponse> sendPasswordResetEmail(ServerRequest request) { private Mono<ServerResponse> sendPasswordResetEmail(ServerRequest request) {
return request.bodyToMono(PasswordResetEmailRequest.class) return request.bodyToMono(PasswordResetEmailRequest.class)
.flatMap(passwordResetRequest -> { .flatMap(passwordResetRequest -> {
@ -154,6 +183,30 @@ public class PublicUserEndpoint implements CustomEndpoint {
private Mono<ServerResponse> signUp(ServerRequest request) { private Mono<ServerResponse> signUp(ServerRequest request) {
return request.bodyToMono(SignUpRequest.class) 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 -> .flatMap(signUpRequest ->
userService.signUp(signUpRequest.user(), signUpRequest.password()) userService.signUp(signUpRequest.user(), signUpRequest.password())
) )
@ -168,6 +221,35 @@ public class PublicUserEndpoint implements CustomEndpoint {
.onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new); .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) { private <T> RateLimiterOperator<T> getRateLimiterForSignUp(ServerWebExchange exchange) {
var clientIp = IpAddressUtils.getClientIp(exchange.getRequest()); var clientIp = IpAddressUtils.getClientIp(exchange.getRequest());
var rateLimiter = rateLimiterRegistry.rateLimiter("signup-from-ip-" + clientIp, 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, 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 name: allowRegistration
label: "开放注册" label: "开放注册"
value: false value: false
- $formkit: checkbox
name: mustVerifyEmailOnRegistration
label: "注册需验证邮箱(请确保启用邮件通知)"
value: false
- $formkit: roleSelect - $formkit: roleSelect
name: defaultRole name: defaultRole
label: "默认角色" label: "默认角色"

View File

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

View File

@ -36,15 +36,19 @@ import {
RequestArgs, RequestArgs,
BaseAPI, BaseAPI,
RequiredError, RequiredError,
operationServerMap,
} from "../base"; } from "../base";
// @ts-ignore // @ts-ignore
import { PasswordResetEmailRequest } from "../models"; import { PasswordResetEmailRequest } from "../models";
// @ts-ignore // @ts-ignore
import { RegisterVerifyEmailRequest } from "../models";
// @ts-ignore
import { ResetPasswordRequest } from "../models"; import { ResetPasswordRequest } from "../models";
// @ts-ignore // @ts-ignore
import { SignUpRequest } from "../models"; import { SignUpRequest } from "../models";
// @ts-ignore // @ts-ignore
import { User } from "../models"; import { User } from "../models";
/** /**
* ApiHaloRunV1alpha1UserApi - axios parameter creator * ApiHaloRunV1alpha1UserApi - axios parameter creator
* @export * @export
@ -183,6 +187,67 @@ export const ApiHaloRunV1alpha1UserApiAxiosParamCreator = function (
options: localVarRequestOptions, 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 * Sign up a new user
* @param {SignUpRequest} signUpRequest * @param {SignUpRequest} signUpRequest
@ -273,12 +338,18 @@ export const ApiHaloRunV1alpha1UserApiFp = function (
resetPasswordRequest, resetPasswordRequest,
options options
); );
return createRequestFunction( const index = configuration?.serverIndex ?? 0;
localVarAxiosArgs, const operationBasePath =
globalAxios, operationServerMap["ApiHaloRunV1alpha1UserApi.resetPasswordByToken"]?.[
BASE_PATH, index
configuration ]?.url;
); return (axios, basePath) =>
createRequestFunction(
localVarAxiosArgs,
globalAxios,
BASE_PATH,
configuration
)(axios, operationBasePath || basePath);
}, },
/** /**
* Send password reset email when forgot password * Send password reset email when forgot password
@ -297,12 +368,48 @@ export const ApiHaloRunV1alpha1UserApiFp = function (
passwordResetEmailRequest, passwordResetEmailRequest,
options options
); );
return createRequestFunction( const index = configuration?.serverIndex ?? 0;
localVarAxiosArgs, const operationBasePath =
globalAxios, operationServerMap[
BASE_PATH, "ApiHaloRunV1alpha1UserApi.sendPasswordResetEmail"
configuration ]?.[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 * Sign up a new user
@ -320,12 +427,16 @@ export const ApiHaloRunV1alpha1UserApiFp = function (
signUpRequest, signUpRequest,
options options
); );
return createRequestFunction( const index = configuration?.serverIndex ?? 0;
localVarAxiosArgs, const operationBasePath =
globalAxios, operationServerMap["ApiHaloRunV1alpha1UserApi.signUp"]?.[index]?.url;
BASE_PATH, return (axios, basePath) =>
configuration createRequestFunction(
); localVarAxiosArgs,
globalAxios,
BASE_PATH,
configuration
)(axios, operationBasePath || basePath);
}, },
}; };
}; };
@ -376,6 +487,23 @@ export const ApiHaloRunV1alpha1UserApiFactory = function (
) )
.then((request) => request(axios, basePath)); .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 * Sign up a new user
* @param {ApiHaloRunV1alpha1UserApiSignUpRequest} requestParameters Request parameters. * @param {ApiHaloRunV1alpha1UserApiSignUpRequest} requestParameters Request parameters.
@ -428,6 +556,20 @@ export interface ApiHaloRunV1alpha1UserApiSendPasswordResetEmailRequest {
readonly passwordResetEmailRequest: PasswordResetEmailRequest; 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. * Request parameters for signUp operation in ApiHaloRunV1alpha1UserApi.
* @export * @export
@ -488,6 +630,25 @@ export class ApiHaloRunV1alpha1UserApi extends BaseAPI {
.then((request) => request(this.axios, this.basePath)); .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 * Sign up a new user
* @param {ApiHaloRunV1alpha1UserApiSignUpRequest} requestParameters Request parameters. * @param {ApiHaloRunV1alpha1UserApiSignUpRequest} requestParameters Request parameters.

View File

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

View File

@ -1,10 +1,13 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref, onMounted } from "vue"; import { ref, onMounted, reactive, computed, type ComputedRef } from "vue";
import { submitForm } from "@formkit/core"; import { submitForm } from "@formkit/core";
import { Toast, VButton } from "@halo-dev/components"; import { Toast, VButton } from "@halo-dev/components";
import { apiClient } from "@/utils/api-client"; import { apiClient } from "@/utils/api-client";
import { useRouteQuery } from "@vueuse/router"; import { useRouteQuery } from "@vueuse/router";
import { useI18n } from "vue-i18n"; 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(); const { t } = useI18n();
@ -31,6 +34,7 @@ const formState = ref({
email: "", email: "",
}, },
}, },
verifyCode: "",
}); });
const loading = ref(false); const loading = ref(false);
@ -40,8 +44,14 @@ const emit = defineEmits<{
const login = useRouteQuery<string>("login"); const login = useRouteQuery<string>("login");
const name = useRouteQuery<string>("name"); const name = useRouteQuery<string>("name");
const globalInfoStore = useGlobalInfoStore();
const signUpCond = reactive({
mustVerifyEmailOnRegistration: false,
});
onMounted(() => { onMounted(() => {
signUpCond.mustVerifyEmailOnRegistration =
globalInfoStore.globalInfo?.mustVerifyEmailOnRegistration || false;
if (login.value) { if (login.value) {
formState.value.user.metadata.name = login.value; formState.value.user.metadata.name = login.value;
} }
@ -49,6 +59,16 @@ onMounted(() => {
formState.value.user.spec.displayName = name.value; 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 () => { const handleSignup = async () => {
try { try {
@ -71,6 +91,60 @@ const handleSignup = async () => {
const inputClasses = { const inputClasses = {
outer: "!py-3 first:!pt-0 last:!pb-0", 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> </script>
<template> <template>
@ -123,8 +197,30 @@ const inputClasses = {
:validation-label="$t('core.signup.fields.email.placeholder')" :validation-label="$t('core.signup.fields.email.placeholder')"
type="email" type="email"
name="email" name="email"
validation="required|email|length:0,100" :validation="emailValidation"
:validation-messages="{
matches: $t('core.signup.fields.email.matchFailed'),
}"
></FormKit> ></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 <FormKit
v-model="formState.password" v-model="formState.password"
name="password" name="password"

View File

@ -32,6 +32,17 @@ core:
placeholder: Display name placeholder: Display name
email: email:
placeholder: 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: password:
placeholder: Password placeholder: Password
password_confirm: password_confirm:

View File

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

View File

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

View File

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