mirror of https://github.com/halo-dev/halo
feat: support reset passwords based on email address (#4941)
#### What type of PR is this? /kind feature /area core /area console /milestone 2.11.x #### What this PR does / why we need it: 新增使用邮箱地址找回密码功能 #### Which issue(s) this PR fixes: Fixes #4940 #### Does this PR introduce a user-facing change? ```release-note 新增使用邮箱地址找回密码功能 ```pull/4968/head
parent
a5639a8733
commit
abd049719d
|
@ -0,0 +1,38 @@
|
||||||
|
package run.halo.app.core.extension.service;
|
||||||
|
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import run.halo.app.infra.exception.AccessDeniedException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An interface for email password recovery.
|
||||||
|
*
|
||||||
|
* @author guqing
|
||||||
|
* @since 2.11.0
|
||||||
|
*/
|
||||||
|
public interface EmailPasswordRecoveryService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <p>Send password reset email.</p>
|
||||||
|
* if the user does not exist, it will return {@link Mono#empty()}
|
||||||
|
* if the user exists, but the email is not the same, it will return {@link Mono#empty()}
|
||||||
|
*
|
||||||
|
* @param username username to request password reset
|
||||||
|
* @param email email to match the user with the username
|
||||||
|
* @return {@link Mono#empty()} if the user does not exist, or the email is not the same.
|
||||||
|
*/
|
||||||
|
Mono<Void> sendPasswordResetEmail(String username, String email);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <p>Reset password by token.</p>
|
||||||
|
* if the token is invalid, it will return {@link Mono#error(Throwable)}}
|
||||||
|
* if the token is valid, but the username is not the same, it will return
|
||||||
|
* {@link Mono#error(Throwable)}
|
||||||
|
*
|
||||||
|
* @param username username to reset password
|
||||||
|
* @param newPassword new password
|
||||||
|
* @param token token to validate the user
|
||||||
|
* @return {@link Mono#empty()} if the token is invalid or the username is not the same.
|
||||||
|
* @throws AccessDeniedException if the token is invalid
|
||||||
|
*/
|
||||||
|
Mono<Void> changePassword(String username, String newPassword, String token);
|
||||||
|
}
|
|
@ -0,0 +1,208 @@
|
||||||
|
package run.halo.app.core.extension.service.impl;
|
||||||
|
|
||||||
|
import com.google.common.cache.Cache;
|
||||||
|
import com.google.common.cache.CacheBuilder;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.experimental.Accessors;
|
||||||
|
import org.apache.commons.lang3.RandomStringUtils;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.springframework.dao.OptimisticLockingFailureException;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.Assert;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.util.retry.Retry;
|
||||||
|
import run.halo.app.core.extension.User;
|
||||||
|
import run.halo.app.core.extension.notification.Reason;
|
||||||
|
import run.halo.app.core.extension.notification.Subscription;
|
||||||
|
import run.halo.app.core.extension.service.EmailPasswordRecoveryService;
|
||||||
|
import run.halo.app.core.extension.service.UserService;
|
||||||
|
import run.halo.app.extension.GroupVersion;
|
||||||
|
import run.halo.app.extension.ReactiveExtensionClient;
|
||||||
|
import run.halo.app.infra.ExternalLinkProcessor;
|
||||||
|
import run.halo.app.infra.exception.AccessDeniedException;
|
||||||
|
import run.halo.app.infra.exception.RateLimitExceededException;
|
||||||
|
import run.halo.app.notification.NotificationCenter;
|
||||||
|
import run.halo.app.notification.NotificationReasonEmitter;
|
||||||
|
import run.halo.app.notification.UserIdentity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A default implementation for {@link EmailPasswordRecoveryService}.
|
||||||
|
*
|
||||||
|
* @author guqing
|
||||||
|
* @since 2.11.0
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class EmailPasswordRecoveryServiceImpl implements EmailPasswordRecoveryService {
|
||||||
|
public static final int MAX_ATTEMPTS = 5;
|
||||||
|
public static final long LINK_EXPIRATION_MINUTES = 30;
|
||||||
|
static final String RESET_PASSWORD_BY_EMAIL_REASON_TYPE = "reset-password-by-email";
|
||||||
|
|
||||||
|
private final ResetPasswordVerificationManager resetPasswordVerificationManager =
|
||||||
|
new ResetPasswordVerificationManager();
|
||||||
|
private final ExternalLinkProcessor externalLinkProcessor;
|
||||||
|
private final ReactiveExtensionClient client;
|
||||||
|
private final NotificationReasonEmitter reasonEmitter;
|
||||||
|
private final NotificationCenter notificationCenter;
|
||||||
|
private final UserService userService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Void> sendPasswordResetEmail(String username, String email) {
|
||||||
|
return client.fetch(User.class, username)
|
||||||
|
.flatMap(user -> {
|
||||||
|
var userEmail = user.getSpec().getEmail();
|
||||||
|
if (!StringUtils.equals(userEmail, email)) {
|
||||||
|
return Mono.empty();
|
||||||
|
}
|
||||||
|
if (!user.getSpec().isEmailVerified()) {
|
||||||
|
return Mono.empty();
|
||||||
|
}
|
||||||
|
return sendResetPasswordNotification(username, email);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<Void> changePassword(String username, String newPassword, String token) {
|
||||||
|
Assert.state(StringUtils.isNotBlank(username), "Username must not be blank");
|
||||||
|
Assert.state(StringUtils.isNotBlank(newPassword), "NewPassword must not be blank");
|
||||||
|
Assert.state(StringUtils.isNotBlank(token), "Token for reset password must not be blank");
|
||||||
|
var verified = resetPasswordVerificationManager.verifyToken(username, token);
|
||||||
|
if (!verified) {
|
||||||
|
return Mono.error(AccessDeniedException::new);
|
||||||
|
}
|
||||||
|
return userService.updateWithRawPassword(username, newPassword)
|
||||||
|
.retryWhen(Retry.backoff(8, Duration.ofMillis(100))
|
||||||
|
.filter(OptimisticLockingFailureException.class::isInstance))
|
||||||
|
.flatMap(user -> {
|
||||||
|
resetPasswordVerificationManager.removeToken(username);
|
||||||
|
return unSubscribeResetPasswordEmailNotification(user.getSpec().getEmail());
|
||||||
|
})
|
||||||
|
.then();
|
||||||
|
}
|
||||||
|
|
||||||
|
Mono<Void> unSubscribeResetPasswordEmailNotification(String email) {
|
||||||
|
if (StringUtils.isBlank(email)) {
|
||||||
|
return Mono.empty();
|
||||||
|
}
|
||||||
|
var subscriber = new Subscription.Subscriber();
|
||||||
|
subscriber.setName(UserIdentity.anonymousWithEmail(email).name());
|
||||||
|
return notificationCenter.unsubscribe(subscriber, createInterestReason(email))
|
||||||
|
.retryWhen(Retry.backoff(8, Duration.ofMillis(100))
|
||||||
|
.filter(OptimisticLockingFailureException.class::isInstance));
|
||||||
|
}
|
||||||
|
|
||||||
|
Mono<Void> sendResetPasswordNotification(String username, String email) {
|
||||||
|
var token = resetPasswordVerificationManager.generateToken(username);
|
||||||
|
var link = getResetPasswordLink(username, token);
|
||||||
|
|
||||||
|
var subscribeNotification = autoSubscribeResetPasswordEmailNotification(email);
|
||||||
|
var interestReasonSubject = createInterestReason(email).getSubject();
|
||||||
|
var emitReasonMono = reasonEmitter.emit(RESET_PASSWORD_BY_EMAIL_REASON_TYPE,
|
||||||
|
builder -> builder.attribute("expirationAtMinutes", LINK_EXPIRATION_MINUTES)
|
||||||
|
.attribute("username", username)
|
||||||
|
.attribute("link", link)
|
||||||
|
.author(UserIdentity.of(username))
|
||||||
|
.subject(Reason.Subject.builder()
|
||||||
|
.apiVersion(interestReasonSubject.getApiVersion())
|
||||||
|
.kind(interestReasonSubject.getKind())
|
||||||
|
.name(interestReasonSubject.getName())
|
||||||
|
.title("使用邮箱地址重置密码:" + email)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return Mono.when(subscribeNotification).then(emitReasonMono);
|
||||||
|
}
|
||||||
|
|
||||||
|
Mono<Void> autoSubscribeResetPasswordEmailNotification(String email) {
|
||||||
|
var subscriber = new Subscription.Subscriber();
|
||||||
|
subscriber.setName(UserIdentity.anonymousWithEmail(email).name());
|
||||||
|
var interestReason = createInterestReason(email);
|
||||||
|
return notificationCenter.subscribe(subscriber, interestReason)
|
||||||
|
.then();
|
||||||
|
}
|
||||||
|
|
||||||
|
Subscription.InterestReason createInterestReason(String email) {
|
||||||
|
var interestReason = new Subscription.InterestReason();
|
||||||
|
interestReason.setReasonType(RESET_PASSWORD_BY_EMAIL_REASON_TYPE);
|
||||||
|
interestReason.setSubject(Subscription.ReasonSubject.builder()
|
||||||
|
.apiVersion(new GroupVersion(User.GROUP, User.KIND).toString())
|
||||||
|
.kind(User.KIND)
|
||||||
|
.name(UserIdentity.anonymousWithEmail(email).name())
|
||||||
|
.build());
|
||||||
|
return interestReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getResetPasswordLink(String username, String token) {
|
||||||
|
return externalLinkProcessor.processLink(
|
||||||
|
"/uc/reset-password/" + username + "?reset_password_token=" + token);
|
||||||
|
}
|
||||||
|
|
||||||
|
static class ResetPasswordVerificationManager {
|
||||||
|
private final Cache<String, Verification> userTokenCache =
|
||||||
|
CacheBuilder.newBuilder()
|
||||||
|
.expireAfterWrite(LINK_EXPIRATION_MINUTES, TimeUnit.MINUTES)
|
||||||
|
.maximumSize(10000)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
private final Cache<String, Boolean>
|
||||||
|
blackListCache = CacheBuilder.newBuilder()
|
||||||
|
.expireAfterWrite(Duration.ofHours(2))
|
||||||
|
.maximumSize(1000)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
public boolean verifyToken(String username, String token) {
|
||||||
|
var verification = userTokenCache.getIfPresent(username);
|
||||||
|
if (verification == null) {
|
||||||
|
// expired or not generated
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (blackListCache.getIfPresent(username) != null) {
|
||||||
|
// in blacklist
|
||||||
|
throw new RateLimitExceededException(null);
|
||||||
|
}
|
||||||
|
synchronized (verification) {
|
||||||
|
if (verification.getAttempts().get() >= MAX_ATTEMPTS) {
|
||||||
|
// add to blacklist to prevent brute force attack
|
||||||
|
blackListCache.put(username, true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!verification.getToken().equals(token)) {
|
||||||
|
verification.getAttempts().incrementAndGet();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void removeToken(String username) {
|
||||||
|
userTokenCache.invalidate(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String generateToken(String username) {
|
||||||
|
Assert.state(StringUtils.isNotBlank(username), "Username must not be blank");
|
||||||
|
var verification = new Verification();
|
||||||
|
verification.setToken(RandomStringUtils.randomAlphanumeric(20));
|
||||||
|
verification.setAttempts(new AtomicInteger(0));
|
||||||
|
userTokenCache.put(username, verification);
|
||||||
|
return verification.getToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only for test.
|
||||||
|
*/
|
||||||
|
boolean contains(String username) {
|
||||||
|
return userTokenCache.getIfPresent(username) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Accessors(chain = true)
|
||||||
|
static class Verification {
|
||||||
|
private String token;
|
||||||
|
private AtomicInteger attempts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,14 +2,18 @@ package run.halo.app.theme.endpoint;
|
||||||
|
|
||||||
import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;
|
import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;
|
||||||
import static 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.requestbody.Builder.requestBodyBuilder;
|
import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder;
|
||||||
|
|
||||||
import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
|
import io.github.resilience4j.ratelimiter.RateLimiterRegistry;
|
||||||
import io.github.resilience4j.ratelimiter.RequestNotPermitted;
|
import io.github.resilience4j.ratelimiter.RequestNotPermitted;
|
||||||
import io.github.resilience4j.reactor.ratelimiter.operator.RateLimiterOperator;
|
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 io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
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.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
import org.springframework.security.core.context.SecurityContextImpl;
|
import org.springframework.security.core.context.SecurityContextImpl;
|
||||||
|
@ -20,9 +24,11 @@ import org.springframework.web.reactive.function.server.RouterFunction;
|
||||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||||
import org.springframework.web.server.ServerWebExchange;
|
import org.springframework.web.server.ServerWebExchange;
|
||||||
|
import org.springframework.web.server.ServerWebInputException;
|
||||||
import reactor.core.publisher.Mono;
|
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.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.exception.RateLimitExceededException;
|
import run.halo.app.infra.exception.RateLimitExceededException;
|
||||||
|
@ -40,6 +46,7 @@ public class PublicUserEndpoint implements CustomEndpoint {
|
||||||
private final UserService userService;
|
private final UserService userService;
|
||||||
private final ServerSecurityContextRepository securityContextRepository;
|
private final ServerSecurityContextRepository securityContextRepository;
|
||||||
private final ReactiveUserDetailsService reactiveUserDetailsService;
|
private final ReactiveUserDetailsService reactiveUserDetailsService;
|
||||||
|
private final EmailPasswordRecoveryService emailPasswordRecoveryService;
|
||||||
private final RateLimiterRegistry rateLimiterRegistry;
|
private final RateLimiterRegistry rateLimiterRegistry;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -55,9 +62,91 @@ public class PublicUserEndpoint implements CustomEndpoint {
|
||||||
)
|
)
|
||||||
.response(responseBuilder().implementation(User.class))
|
.response(responseBuilder().implementation(User.class))
|
||||||
)
|
)
|
||||||
|
.POST("/users/-/send-password-reset-email", this::sendPasswordResetEmail,
|
||||||
|
builder -> builder.operationId("SendPasswordResetEmail")
|
||||||
|
.description("Send password reset email when forgot password")
|
||||||
|
.tag(tag)
|
||||||
|
.requestBody(requestBodyBuilder()
|
||||||
|
.required(true)
|
||||||
|
.implementation(PasswordResetEmailRequest.class)
|
||||||
|
)
|
||||||
|
.response(responseBuilder()
|
||||||
|
.responseCode(HttpStatus.NO_CONTENT.toString())
|
||||||
|
.implementation(Void.class))
|
||||||
|
)
|
||||||
|
.PUT("/users/{name}/reset-password", this::resetPasswordByToken,
|
||||||
|
builder -> builder.operationId("ResetPasswordByToken")
|
||||||
|
.description("Reset password by token")
|
||||||
|
.tag(tag)
|
||||||
|
.parameter(parameterBuilder()
|
||||||
|
.name("name")
|
||||||
|
.description("The name of the user")
|
||||||
|
.required(true)
|
||||||
|
.in(ParameterIn.PATH)
|
||||||
|
)
|
||||||
|
.requestBody(requestBodyBuilder()
|
||||||
|
.required(true)
|
||||||
|
.implementation(ResetPasswordRequest.class)
|
||||||
|
)
|
||||||
|
.response(responseBuilder()
|
||||||
|
.responseCode(HttpStatus.NO_CONTENT.toString())
|
||||||
|
.implementation(Void.class)
|
||||||
|
)
|
||||||
|
)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Mono<ServerResponse> resetPasswordByToken(ServerRequest request) {
|
||||||
|
var username = request.pathVariable("name");
|
||||||
|
return request.bodyToMono(ResetPasswordRequest.class)
|
||||||
|
.doOnNext(resetReq -> {
|
||||||
|
if (StringUtils.isBlank(resetReq.token())) {
|
||||||
|
throw new ServerWebInputException("Token must not be blank");
|
||||||
|
}
|
||||||
|
if (StringUtils.isBlank(resetReq.newPassword())) {
|
||||||
|
throw new ServerWebInputException("New password must not be blank");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.switchIfEmpty(
|
||||||
|
Mono.error(() -> new ServerWebInputException("Request body must not be empty"))
|
||||||
|
)
|
||||||
|
.flatMap(resetReq -> {
|
||||||
|
var token = resetReq.token();
|
||||||
|
var newPassword = resetReq.newPassword();
|
||||||
|
return emailPasswordRecoveryService.changePassword(username, newPassword, token);
|
||||||
|
})
|
||||||
|
.then(ServerResponse.noContent().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
record PasswordResetEmailRequest(@Schema(requiredMode = REQUIRED) String username,
|
||||||
|
@Schema(requiredMode = REQUIRED) String email) {
|
||||||
|
}
|
||||||
|
|
||||||
|
record ResetPasswordRequest(@Schema(requiredMode = REQUIRED, minLength = 6) String newPassword,
|
||||||
|
@Schema(requiredMode = REQUIRED) String token) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private Mono<ServerResponse> sendPasswordResetEmail(ServerRequest request) {
|
||||||
|
return request.bodyToMono(PasswordResetEmailRequest.class)
|
||||||
|
.flatMap(passwordResetRequest -> {
|
||||||
|
var username = passwordResetRequest.username();
|
||||||
|
var email = passwordResetRequest.email();
|
||||||
|
return Mono.just(passwordResetRequest)
|
||||||
|
.transformDeferred(sendResetPasswordEmailRateLimiter(username, email))
|
||||||
|
.flatMap(
|
||||||
|
r -> emailPasswordRecoveryService.sendPasswordResetEmail(username, email))
|
||||||
|
.onErrorMap(RequestNotPermitted.class, RateLimitExceededException::new);
|
||||||
|
})
|
||||||
|
.then(ServerResponse.noContent().build());
|
||||||
|
}
|
||||||
|
|
||||||
|
<T> RateLimiterOperator<T> sendResetPasswordEmailRateLimiter(String username, String email) {
|
||||||
|
String rateLimiterKey = "send-reset-password-email-" + username + ":" + email;
|
||||||
|
var rateLimiter =
|
||||||
|
rateLimiterRegistry.rateLimiter(rateLimiterKey, "send-reset-password-email");
|
||||||
|
return RateLimiterOperator.of(rateLimiter);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public GroupVersion groupVersion() {
|
public GroupVersion groupVersion() {
|
||||||
return GroupVersion.parseAPIVersion("api.halo.run/v1alpha1");
|
return GroupVersion.parseAPIVersion("api.halo.run/v1alpha1");
|
||||||
|
|
|
@ -99,3 +99,7 @@ resilience4j.ratelimiter:
|
||||||
limitForPeriod: 3
|
limitForPeriod: 3
|
||||||
limitRefreshPeriod: 1h
|
limitRefreshPeriod: 1h
|
||||||
timeoutDuration: 0s
|
timeoutDuration: 0s
|
||||||
|
send-reset-password-email:
|
||||||
|
limitForPeriod: 2
|
||||||
|
limitRefreshPeriod: 1m
|
||||||
|
timeoutDuration: 0s
|
||||||
|
|
|
@ -126,3 +126,30 @@ spec:
|
||||||
<p>如果您没有尝试验证您的电子邮件地址,请忽略此电子邮件。</p>
|
<p>如果您没有尝试验证您的电子邮件地址,请忽略此电子邮件。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
---
|
||||||
|
apiVersion: notification.halo.run/v1alpha1
|
||||||
|
kind: NotificationTemplate
|
||||||
|
metadata:
|
||||||
|
name: template-reset-password-by-email
|
||||||
|
spec:
|
||||||
|
reasonSelector:
|
||||||
|
reasonType: reset-password-by-email
|
||||||
|
language: default
|
||||||
|
template:
|
||||||
|
title: "重置密码-[(${site.title})]"
|
||||||
|
rawBody: |
|
||||||
|
【[(${site.title})]】你已经请求了重置密码,可以链接来重置密码:[(${link})],请在 [(${expirationAtMinutes})] 分钟内完成重置。
|
||||||
|
htmlBody: |
|
||||||
|
<div class="notification-content">
|
||||||
|
<div class="head">
|
||||||
|
<p class="honorific" th:text="|${username} 你好:|"></p>
|
||||||
|
</div>
|
||||||
|
<div class="body">
|
||||||
|
<p>你已经请求了重置密码,可以点击下面的链接来重置密码:</p>
|
||||||
|
<div class="reset-link" style="line-height:24px;">
|
||||||
|
<span th:text="${link}"></span>
|
||||||
|
</div>
|
||||||
|
<p th:text="|链接有效期为 ${expirationAtMinutes} 分钟,请尽快完成重置。|"></p>
|
||||||
|
<p>如果您没有请求重置密码,请忽略此电子邮件。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
|
@ -163,3 +163,23 @@ spec:
|
||||||
- name: expirationAtMinutes
|
- name: expirationAtMinutes
|
||||||
type: string
|
type: string
|
||||||
description: "The expiration minutes of the verification code, such as 5 minutes."
|
description: "The expiration minutes of the verification code, such as 5 minutes."
|
||||||
|
---
|
||||||
|
apiVersion: notification.halo.run/v1alpha1
|
||||||
|
kind: ReasonType
|
||||||
|
metadata:
|
||||||
|
name: reset-password-by-email
|
||||||
|
labels:
|
||||||
|
halo.run/hide: "true"
|
||||||
|
spec:
|
||||||
|
displayName: "根据邮件地址重置密码"
|
||||||
|
description: "当你通过邮件地址找回密码时,会收到一条带密码重置链接的邮件,你需要点击邮件中的链接来重置密码。"
|
||||||
|
properties:
|
||||||
|
- name: username
|
||||||
|
type: string
|
||||||
|
description: "The username of the user."
|
||||||
|
- name: link
|
||||||
|
type: string
|
||||||
|
description: "The reset link."
|
||||||
|
- name: expirationAtMinutes
|
||||||
|
type: string
|
||||||
|
description: "The expiration minutes of the reset link, such as 30 minutes."
|
||||||
|
|
|
@ -0,0 +1,83 @@
|
||||||
|
package run.halo.app.core.extension.service.impl;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Nested;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import run.halo.app.infra.exception.RateLimitExceededException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for {@link EmailPasswordRecoveryServiceImpl}.
|
||||||
|
*
|
||||||
|
* @author guqing
|
||||||
|
* @since 2.11.0
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class EmailPasswordRecoveryServiceImplTest {
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
class ResetPasswordVerificationManagerTest {
|
||||||
|
@Test
|
||||||
|
public void generateTokenTest() {
|
||||||
|
var verificationManager =
|
||||||
|
new EmailPasswordRecoveryServiceImpl.ResetPasswordVerificationManager();
|
||||||
|
verificationManager.generateToken("fake-user");
|
||||||
|
var result = verificationManager.contains("fake-user");
|
||||||
|
assertThat(result).isTrue();
|
||||||
|
|
||||||
|
verificationManager.generateToken("guqing");
|
||||||
|
result = verificationManager.contains("guqing");
|
||||||
|
assertThat(result).isTrue();
|
||||||
|
|
||||||
|
result = verificationManager.contains("123");
|
||||||
|
assertThat(result).isFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void removeTest() {
|
||||||
|
var verificationManager =
|
||||||
|
new EmailPasswordRecoveryServiceImpl.ResetPasswordVerificationManager();
|
||||||
|
verificationManager.generateToken("fake-user");
|
||||||
|
var result = verificationManager.contains("fake-user");
|
||||||
|
|
||||||
|
verificationManager.removeToken("fake-user");
|
||||||
|
result = verificationManager.contains("fake-user");
|
||||||
|
assertThat(result).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void verifyTokenTestNormal() {
|
||||||
|
String username = "guqing";
|
||||||
|
var verificationManager =
|
||||||
|
new EmailPasswordRecoveryServiceImpl.ResetPasswordVerificationManager();
|
||||||
|
var result = verificationManager.verifyToken(username, "fake-code");
|
||||||
|
assertThat(result).isFalse();
|
||||||
|
|
||||||
|
var token = verificationManager.generateToken(username);
|
||||||
|
result = verificationManager.verifyToken(username, "fake-code");
|
||||||
|
assertThat(result).isFalse();
|
||||||
|
|
||||||
|
result = verificationManager.verifyToken(username, token);
|
||||||
|
assertThat(result).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void verifyTokenFailedAfterMaxAttempts() {
|
||||||
|
String username = "guqing";
|
||||||
|
var verificationManager =
|
||||||
|
new EmailPasswordRecoveryServiceImpl.ResetPasswordVerificationManager();
|
||||||
|
var token = verificationManager.generateToken(username);
|
||||||
|
for (int i = 0; i <= EmailPasswordRecoveryServiceImpl.MAX_ATTEMPTS; i++) {
|
||||||
|
var result = verificationManager.verifyToken(username, "fake-code");
|
||||||
|
assertThat(result).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> verificationManager.verifyToken(username, token))
|
||||||
|
.isInstanceOf(RateLimitExceededException.class)
|
||||||
|
.hasMessage("429 TOO_MANY_REQUESTS \"You have exceeded your quota\"");
|
||||||
|
}
|
||||||
|
}
|
|
@ -52,27 +52,32 @@ watch(
|
||||||
<SignupForm v-if="type === 'signup'" @succeed="onSignupSucceed" />
|
<SignupForm v-if="type === 'signup'" @succeed="onSignupSucceed" />
|
||||||
<LoginForm v-else @succeed="onLoginSucceed" />
|
<LoginForm v-else @succeed="onLoginSucceed" />
|
||||||
<SocialAuthProviders />
|
<SocialAuthProviders />
|
||||||
<div
|
<div class="flex justify-center gap-2 pt-3.5 text-xs">
|
||||||
v-if="globalInfo?.allowRegistration"
|
<div v-if="globalInfo?.allowRegistration" class="space-x-0.5">
|
||||||
class="flex justify-center gap-1 pt-3.5 text-xs"
|
<span class="text-slate-500">
|
||||||
>
|
{{
|
||||||
<span class="text-slate-500">
|
isLoginType
|
||||||
{{
|
? $t("core.login.operations.signup.label")
|
||||||
isLoginType
|
: $t("core.login.operations.return_login.label")
|
||||||
? $t("core.login.operations.signup.label")
|
}},
|
||||||
: $t("core.login.operations.return_login.label")
|
</span>
|
||||||
}}
|
<span
|
||||||
</span>
|
class="cursor-pointer text-secondary hover:text-gray-600"
|
||||||
<span
|
@click="handleChangeType"
|
||||||
class="cursor-pointer text-secondary hover:text-gray-600"
|
>
|
||||||
@click="handleChangeType"
|
{{
|
||||||
|
isLoginType
|
||||||
|
? $t("core.login.operations.signup.button")
|
||||||
|
: $t("core.login.operations.return_login.button")
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<RouterLink
|
||||||
|
:to="{ name: 'ResetPassword' }"
|
||||||
|
class="text-secondary hover:text-gray-600"
|
||||||
>
|
>
|
||||||
{{
|
{{ $t("core.login.operations.reset_password.button") }}
|
||||||
isLoginType
|
</RouterLink>
|
||||||
? $t("core.login.operations.signup.button")
|
|
||||||
: $t("core.login.operations.return_login.button")
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-center pt-3.5">
|
<div class="flex justify-center pt-3.5">
|
||||||
<a
|
<a
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { rbacAnnotations } from "@/constants/annotations";
|
||||||
import { useUserStore } from "@/stores/user";
|
import { useUserStore } from "@/stores/user";
|
||||||
import type { Router } from "vue-router";
|
import type { Router } from "vue-router";
|
||||||
|
|
||||||
const whiteList = ["Setup", "Login", "Binding"];
|
const whiteList = ["Setup", "Login", "Binding", "ResetPassword"];
|
||||||
|
|
||||||
export function setupAuthCheckGuard(router: Router) {
|
export function setupAuthCheckGuard(router: Router) {
|
||||||
router.beforeEach((to, from, next) => {
|
router.beforeEach((to, from, next) => {
|
||||||
|
|
|
@ -5,6 +5,7 @@ import BasicLayout from "@console/layouts/BasicLayout.vue";
|
||||||
import Setup from "@console/views/system/Setup.vue";
|
import Setup from "@console/views/system/Setup.vue";
|
||||||
import Redirect from "@console/views/system/Redirect.vue";
|
import Redirect from "@console/views/system/Redirect.vue";
|
||||||
import SetupInitialData from "@console/views/system/SetupInitialData.vue";
|
import SetupInitialData from "@console/views/system/SetupInitialData.vue";
|
||||||
|
import ResetPassword from "@console/views/system/ResetPassword.vue";
|
||||||
|
|
||||||
export const routes: Array<RouteRecordRaw> = [
|
export const routes: Array<RouteRecordRaw> = [
|
||||||
{
|
{
|
||||||
|
@ -44,6 +45,14 @@ export const routes: Array<RouteRecordRaw> = [
|
||||||
name: "Redirect",
|
name: "Redirect",
|
||||||
component: Redirect,
|
component: Redirect,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/reset-password",
|
||||||
|
name: "ResetPassword",
|
||||||
|
component: ResetPassword,
|
||||||
|
meta: {
|
||||||
|
title: "core.reset_password.title",
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default routes;
|
export default routes;
|
||||||
|
|
|
@ -0,0 +1,85 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { apiClient } from "@/utils/api-client";
|
||||||
|
import { Toast, VButton } from "@halo-dev/components";
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import IconLogo from "~icons/core/logo?width=5rem&height=2rem";
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
interface ResetPasswordForm {
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
async function onSubmit(data: ResetPasswordForm) {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
await apiClient.common.user.sendPasswordResetEmail({
|
||||||
|
passwordResetEmailRequest: {
|
||||||
|
email: data.email,
|
||||||
|
username: data.username,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Toast.success(t("core.reset_password.operations.send.toast_success"));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to send password reset email", error);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputClasses = {
|
||||||
|
outer: "!py-3 first:!pt-0 last:!pb-0",
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex h-screen flex-col items-center bg-white/90 pt-[30vh]">
|
||||||
|
<IconLogo class="mb-8" />
|
||||||
|
<div class="flex w-72 flex-col">
|
||||||
|
<FormKit
|
||||||
|
id="reset-password-form"
|
||||||
|
name="reset-password-form"
|
||||||
|
type="form"
|
||||||
|
:classes="{
|
||||||
|
form: '!divide-none',
|
||||||
|
}"
|
||||||
|
:config="{ validationVisibility: 'submit' }"
|
||||||
|
@submit="onSubmit"
|
||||||
|
@keyup.enter="$formkit.submit('reset-password-form')"
|
||||||
|
>
|
||||||
|
<FormKit
|
||||||
|
:classes="inputClasses"
|
||||||
|
name="username"
|
||||||
|
:placeholder="$t('core.reset_password.fields.username.label')"
|
||||||
|
:validation-label="$t('core.reset_password.fields.username.label')"
|
||||||
|
:autofocus="true"
|
||||||
|
type="text"
|
||||||
|
validation="required"
|
||||||
|
></FormKit>
|
||||||
|
<FormKit
|
||||||
|
:classes="inputClasses"
|
||||||
|
name="email"
|
||||||
|
:placeholder="$t('core.reset_password.fields.email.label')"
|
||||||
|
:validation-label="$t('core.reset_password.fields.email.label')"
|
||||||
|
:autofocus="true"
|
||||||
|
type="text"
|
||||||
|
validation="required"
|
||||||
|
></FormKit>
|
||||||
|
</FormKit>
|
||||||
|
<VButton
|
||||||
|
class="mt-8"
|
||||||
|
block
|
||||||
|
:loading="loading"
|
||||||
|
type="secondary"
|
||||||
|
@click="$formkit.submit('reset-password-form')"
|
||||||
|
>
|
||||||
|
{{ $t("core.reset_password.operations.send.label") }}
|
||||||
|
</VButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -186,6 +186,7 @@ models/notifier-descriptor.ts
|
||||||
models/notifier-info.ts
|
models/notifier-info.ts
|
||||||
models/notifier-setting-ref.ts
|
models/notifier-setting-ref.ts
|
||||||
models/owner-info.ts
|
models/owner-info.ts
|
||||||
|
models/password-reset-email-request.ts
|
||||||
models/pat-spec.ts
|
models/pat-spec.ts
|
||||||
models/personal-access-token-list.ts
|
models/personal-access-token-list.ts
|
||||||
models/personal-access-token.ts
|
models/personal-access-token.ts
|
||||||
|
@ -232,6 +233,7 @@ models/reply-spec.ts
|
||||||
models/reply-vo-list.ts
|
models/reply-vo-list.ts
|
||||||
models/reply-vo.ts
|
models/reply-vo.ts
|
||||||
models/reply.ts
|
models/reply.ts
|
||||||
|
models/reset-password-request.ts
|
||||||
models/reverse-proxy-list.ts
|
models/reverse-proxy-list.ts
|
||||||
models/reverse-proxy-rule.ts
|
models/reverse-proxy-rule.ts
|
||||||
models/reverse-proxy.ts
|
models/reverse-proxy.ts
|
||||||
|
|
|
@ -38,6 +38,10 @@ import {
|
||||||
RequiredError,
|
RequiredError,
|
||||||
} from "../base";
|
} from "../base";
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
import { PasswordResetEmailRequest } from "../models";
|
||||||
|
// @ts-ignore
|
||||||
|
import { ResetPasswordRequest } from "../models";
|
||||||
|
// @ts-ignore
|
||||||
import { SignUpRequest } from "../models";
|
import { SignUpRequest } from "../models";
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { User } from "../models";
|
import { User } from "../models";
|
||||||
|
@ -49,6 +53,136 @@ export const ApiHaloRunV1alpha1UserApiAxiosParamCreator = function (
|
||||||
configuration?: Configuration
|
configuration?: Configuration
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
|
/**
|
||||||
|
* Reset password by token
|
||||||
|
* @param {string} name The name of the user
|
||||||
|
* @param {ResetPasswordRequest} resetPasswordRequest
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
resetPasswordByToken: async (
|
||||||
|
name: string,
|
||||||
|
resetPasswordRequest: ResetPasswordRequest,
|
||||||
|
options: AxiosRequestConfig = {}
|
||||||
|
): Promise<RequestArgs> => {
|
||||||
|
// verify required parameter 'name' is not null or undefined
|
||||||
|
assertParamExists("resetPasswordByToken", "name", name);
|
||||||
|
// verify required parameter 'resetPasswordRequest' is not null or undefined
|
||||||
|
assertParamExists(
|
||||||
|
"resetPasswordByToken",
|
||||||
|
"resetPasswordRequest",
|
||||||
|
resetPasswordRequest
|
||||||
|
);
|
||||||
|
const localVarPath =
|
||||||
|
`/apis/api.halo.run/v1alpha1/users/{name}/reset-password`.replace(
|
||||||
|
`{${"name"}}`,
|
||||||
|
encodeURIComponent(String(name))
|
||||||
|
);
|
||||||
|
// 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: "PUT",
|
||||||
|
...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(
|
||||||
|
resetPasswordRequest,
|
||||||
|
localVarRequestOptions,
|
||||||
|
configuration
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: toPathString(localVarUrlObj),
|
||||||
|
options: localVarRequestOptions,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Send password reset email when forgot password
|
||||||
|
* @param {PasswordResetEmailRequest} passwordResetEmailRequest
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
sendPasswordResetEmail: async (
|
||||||
|
passwordResetEmailRequest: PasswordResetEmailRequest,
|
||||||
|
options: AxiosRequestConfig = {}
|
||||||
|
): Promise<RequestArgs> => {
|
||||||
|
// verify required parameter 'passwordResetEmailRequest' is not null or undefined
|
||||||
|
assertParamExists(
|
||||||
|
"sendPasswordResetEmail",
|
||||||
|
"passwordResetEmailRequest",
|
||||||
|
passwordResetEmailRequest
|
||||||
|
);
|
||||||
|
const localVarPath = `/apis/api.halo.run/v1alpha1/users/-/send-password-reset-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(
|
||||||
|
passwordResetEmailRequest,
|
||||||
|
localVarRequestOptions,
|
||||||
|
configuration
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: toPathString(localVarUrlObj),
|
||||||
|
options: localVarRequestOptions,
|
||||||
|
};
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* Sign up a new user
|
* Sign up a new user
|
||||||
* @param {SignUpRequest} signUpRequest
|
* @param {SignUpRequest} signUpRequest
|
||||||
|
@ -119,6 +253,57 @@ export const ApiHaloRunV1alpha1UserApiFp = function (
|
||||||
const localVarAxiosParamCreator =
|
const localVarAxiosParamCreator =
|
||||||
ApiHaloRunV1alpha1UserApiAxiosParamCreator(configuration);
|
ApiHaloRunV1alpha1UserApiAxiosParamCreator(configuration);
|
||||||
return {
|
return {
|
||||||
|
/**
|
||||||
|
* Reset password by token
|
||||||
|
* @param {string} name The name of the user
|
||||||
|
* @param {ResetPasswordRequest} resetPasswordRequest
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
async resetPasswordByToken(
|
||||||
|
name: string,
|
||||||
|
resetPasswordRequest: ResetPasswordRequest,
|
||||||
|
options?: AxiosRequestConfig
|
||||||
|
): Promise<
|
||||||
|
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>
|
||||||
|
> {
|
||||||
|
const localVarAxiosArgs =
|
||||||
|
await localVarAxiosParamCreator.resetPasswordByToken(
|
||||||
|
name,
|
||||||
|
resetPasswordRequest,
|
||||||
|
options
|
||||||
|
);
|
||||||
|
return createRequestFunction(
|
||||||
|
localVarAxiosArgs,
|
||||||
|
globalAxios,
|
||||||
|
BASE_PATH,
|
||||||
|
configuration
|
||||||
|
);
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Send password reset email when forgot password
|
||||||
|
* @param {PasswordResetEmailRequest} passwordResetEmailRequest
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
async sendPasswordResetEmail(
|
||||||
|
passwordResetEmailRequest: PasswordResetEmailRequest,
|
||||||
|
options?: AxiosRequestConfig
|
||||||
|
): Promise<
|
||||||
|
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>
|
||||||
|
> {
|
||||||
|
const localVarAxiosArgs =
|
||||||
|
await localVarAxiosParamCreator.sendPasswordResetEmail(
|
||||||
|
passwordResetEmailRequest,
|
||||||
|
options
|
||||||
|
);
|
||||||
|
return createRequestFunction(
|
||||||
|
localVarAxiosArgs,
|
||||||
|
globalAxios,
|
||||||
|
BASE_PATH,
|
||||||
|
configuration
|
||||||
|
);
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* Sign up a new user
|
* Sign up a new user
|
||||||
* @param {SignUpRequest} signUpRequest
|
* @param {SignUpRequest} signUpRequest
|
||||||
|
@ -156,6 +341,41 @@ export const ApiHaloRunV1alpha1UserApiFactory = function (
|
||||||
) {
|
) {
|
||||||
const localVarFp = ApiHaloRunV1alpha1UserApiFp(configuration);
|
const localVarFp = ApiHaloRunV1alpha1UserApiFp(configuration);
|
||||||
return {
|
return {
|
||||||
|
/**
|
||||||
|
* Reset password by token
|
||||||
|
* @param {ApiHaloRunV1alpha1UserApiResetPasswordByTokenRequest} requestParameters Request parameters.
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
resetPasswordByToken(
|
||||||
|
requestParameters: ApiHaloRunV1alpha1UserApiResetPasswordByTokenRequest,
|
||||||
|
options?: AxiosRequestConfig
|
||||||
|
): AxiosPromise<void> {
|
||||||
|
return localVarFp
|
||||||
|
.resetPasswordByToken(
|
||||||
|
requestParameters.name,
|
||||||
|
requestParameters.resetPasswordRequest,
|
||||||
|
options
|
||||||
|
)
|
||||||
|
.then((request) => request(axios, basePath));
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Send password reset email when forgot password
|
||||||
|
* @param {ApiHaloRunV1alpha1UserApiSendPasswordResetEmailRequest} requestParameters Request parameters.
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
*/
|
||||||
|
sendPasswordResetEmail(
|
||||||
|
requestParameters: ApiHaloRunV1alpha1UserApiSendPasswordResetEmailRequest,
|
||||||
|
options?: AxiosRequestConfig
|
||||||
|
): AxiosPromise<void> {
|
||||||
|
return localVarFp
|
||||||
|
.sendPasswordResetEmail(
|
||||||
|
requestParameters.passwordResetEmailRequest,
|
||||||
|
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.
|
||||||
|
@ -173,6 +393,41 @@ export const ApiHaloRunV1alpha1UserApiFactory = function (
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request parameters for resetPasswordByToken operation in ApiHaloRunV1alpha1UserApi.
|
||||||
|
* @export
|
||||||
|
* @interface ApiHaloRunV1alpha1UserApiResetPasswordByTokenRequest
|
||||||
|
*/
|
||||||
|
export interface ApiHaloRunV1alpha1UserApiResetPasswordByTokenRequest {
|
||||||
|
/**
|
||||||
|
* The name of the user
|
||||||
|
* @type {string}
|
||||||
|
* @memberof ApiHaloRunV1alpha1UserApiResetPasswordByToken
|
||||||
|
*/
|
||||||
|
readonly name: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {ResetPasswordRequest}
|
||||||
|
* @memberof ApiHaloRunV1alpha1UserApiResetPasswordByToken
|
||||||
|
*/
|
||||||
|
readonly resetPasswordRequest: ResetPasswordRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request parameters for sendPasswordResetEmail operation in ApiHaloRunV1alpha1UserApi.
|
||||||
|
* @export
|
||||||
|
* @interface ApiHaloRunV1alpha1UserApiSendPasswordResetEmailRequest
|
||||||
|
*/
|
||||||
|
export interface ApiHaloRunV1alpha1UserApiSendPasswordResetEmailRequest {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {PasswordResetEmailRequest}
|
||||||
|
* @memberof ApiHaloRunV1alpha1UserApiSendPasswordResetEmail
|
||||||
|
*/
|
||||||
|
readonly passwordResetEmailRequest: PasswordResetEmailRequest;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request parameters for signUp operation in ApiHaloRunV1alpha1UserApi.
|
* Request parameters for signUp operation in ApiHaloRunV1alpha1UserApi.
|
||||||
* @export
|
* @export
|
||||||
|
@ -194,6 +449,45 @@ export interface ApiHaloRunV1alpha1UserApiSignUpRequest {
|
||||||
* @extends {BaseAPI}
|
* @extends {BaseAPI}
|
||||||
*/
|
*/
|
||||||
export class ApiHaloRunV1alpha1UserApi extends BaseAPI {
|
export class ApiHaloRunV1alpha1UserApi extends BaseAPI {
|
||||||
|
/**
|
||||||
|
* Reset password by token
|
||||||
|
* @param {ApiHaloRunV1alpha1UserApiResetPasswordByTokenRequest} requestParameters Request parameters.
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
* @memberof ApiHaloRunV1alpha1UserApi
|
||||||
|
*/
|
||||||
|
public resetPasswordByToken(
|
||||||
|
requestParameters: ApiHaloRunV1alpha1UserApiResetPasswordByTokenRequest,
|
||||||
|
options?: AxiosRequestConfig
|
||||||
|
) {
|
||||||
|
return ApiHaloRunV1alpha1UserApiFp(this.configuration)
|
||||||
|
.resetPasswordByToken(
|
||||||
|
requestParameters.name,
|
||||||
|
requestParameters.resetPasswordRequest,
|
||||||
|
options
|
||||||
|
)
|
||||||
|
.then((request) => request(this.axios, this.basePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send password reset email when forgot password
|
||||||
|
* @param {ApiHaloRunV1alpha1UserApiSendPasswordResetEmailRequest} requestParameters Request parameters.
|
||||||
|
* @param {*} [options] Override http request option.
|
||||||
|
* @throws {RequiredError}
|
||||||
|
* @memberof ApiHaloRunV1alpha1UserApi
|
||||||
|
*/
|
||||||
|
public sendPasswordResetEmail(
|
||||||
|
requestParameters: ApiHaloRunV1alpha1UserApiSendPasswordResetEmailRequest,
|
||||||
|
options?: AxiosRequestConfig
|
||||||
|
) {
|
||||||
|
return ApiHaloRunV1alpha1UserApiFp(this.configuration)
|
||||||
|
.sendPasswordResetEmail(
|
||||||
|
requestParameters.passwordResetEmailRequest,
|
||||||
|
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.
|
||||||
|
|
|
@ -106,6 +106,7 @@ export * from "./notifier-descriptor-spec";
|
||||||
export * from "./notifier-info";
|
export * from "./notifier-info";
|
||||||
export * from "./notifier-setting-ref";
|
export * from "./notifier-setting-ref";
|
||||||
export * from "./owner-info";
|
export * from "./owner-info";
|
||||||
|
export * from "./password-reset-email-request";
|
||||||
export * from "./pat-spec";
|
export * from "./pat-spec";
|
||||||
export * from "./personal-access-token";
|
export * from "./personal-access-token";
|
||||||
export * from "./personal-access-token-list";
|
export * from "./personal-access-token-list";
|
||||||
|
@ -152,6 +153,7 @@ export * from "./reply-request";
|
||||||
export * from "./reply-spec";
|
export * from "./reply-spec";
|
||||||
export * from "./reply-vo";
|
export * from "./reply-vo";
|
||||||
export * from "./reply-vo-list";
|
export * from "./reply-vo-list";
|
||||||
|
export * from "./reset-password-request";
|
||||||
export * from "./reverse-proxy";
|
export * from "./reverse-proxy";
|
||||||
export * from "./reverse-proxy-list";
|
export * from "./reverse-proxy-list";
|
||||||
export * from "./reverse-proxy-rule";
|
export * from "./reverse-proxy-rule";
|
||||||
|
|
|
@ -0,0 +1,33 @@
|
||||||
|
/* 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 PasswordResetEmailRequest
|
||||||
|
*/
|
||||||
|
export interface PasswordResetEmailRequest {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
* @memberof PasswordResetEmailRequest
|
||||||
|
*/
|
||||||
|
email: string;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
* @memberof PasswordResetEmailRequest
|
||||||
|
*/
|
||||||
|
username: string;
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
/* 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 ResetPasswordRequest
|
||||||
|
*/
|
||||||
|
export interface ResetPasswordRequest {
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
* @memberof ResetPasswordRequest
|
||||||
|
*/
|
||||||
|
newPassword: string;
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @type {string}
|
||||||
|
* @memberof ResetPasswordRequest
|
||||||
|
*/
|
||||||
|
token: string;
|
||||||
|
}
|
|
@ -18,6 +18,8 @@ core:
|
||||||
label: Already have an account
|
label: Already have an account
|
||||||
button: Sign in
|
button: Sign in
|
||||||
return_site: Return to site
|
return_site: Return to site
|
||||||
|
reset_password:
|
||||||
|
button: Retrieve password
|
||||||
button: Login
|
button: Login
|
||||||
modal:
|
modal:
|
||||||
title: Re-login
|
title: Re-login
|
||||||
|
@ -1550,3 +1552,27 @@ core:
|
||||||
setting_modal:
|
setting_modal:
|
||||||
title: Post settings
|
title: Post settings
|
||||||
title: My posts
|
title: My posts
|
||||||
|
uc_reset_password:
|
||||||
|
fields:
|
||||||
|
username:
|
||||||
|
label: username
|
||||||
|
password:
|
||||||
|
label: New Password
|
||||||
|
password_confirm:
|
||||||
|
label: Confirm Password
|
||||||
|
operations:
|
||||||
|
reset:
|
||||||
|
button: Reset Password
|
||||||
|
toast_success: Reset successful
|
||||||
|
reset_password:
|
||||||
|
fields:
|
||||||
|
username:
|
||||||
|
label: Username
|
||||||
|
email:
|
||||||
|
label: email address
|
||||||
|
operations:
|
||||||
|
send:
|
||||||
|
label: Send verification email
|
||||||
|
toast_success: >-
|
||||||
|
If your username and email address match, we will send an email to
|
||||||
|
your email address.
|
||||||
|
|
|
@ -18,6 +18,8 @@ core:
|
||||||
label: 已有账号
|
label: 已有账号
|
||||||
button: 立即登录
|
button: 立即登录
|
||||||
return_site: 返回到首页
|
return_site: 返回到首页
|
||||||
|
reset_password:
|
||||||
|
button: 找回密码
|
||||||
button: 登录
|
button: 登录
|
||||||
modal:
|
modal:
|
||||||
title: 重新登录
|
title: 重新登录
|
||||||
|
@ -1207,6 +1209,30 @@ core:
|
||||||
label: 密码
|
label: 密码
|
||||||
confirm_password:
|
confirm_password:
|
||||||
label: 确认密码
|
label: 确认密码
|
||||||
|
reset_password:
|
||||||
|
title: 重置密码
|
||||||
|
fields:
|
||||||
|
username:
|
||||||
|
label: 用户名
|
||||||
|
email:
|
||||||
|
label: 邮箱地址
|
||||||
|
operations:
|
||||||
|
send:
|
||||||
|
label: 发送验证邮件
|
||||||
|
toast_success: 如果你的用户名和邮箱地址匹配,我们将会发送一封邮件到你的邮箱。
|
||||||
|
uc_reset_password:
|
||||||
|
title: 重置密码
|
||||||
|
fields:
|
||||||
|
username:
|
||||||
|
label: 用户名
|
||||||
|
password:
|
||||||
|
label: 新密码
|
||||||
|
password_confirm:
|
||||||
|
label: 确认密码
|
||||||
|
operations:
|
||||||
|
reset:
|
||||||
|
button: 重置密码
|
||||||
|
toast_success: 重置成功
|
||||||
rbac:
|
rbac:
|
||||||
Attachments Management: 附件
|
Attachments Management: 附件
|
||||||
Attachment Manage: 附件管理
|
Attachment Manage: 附件管理
|
||||||
|
|
|
@ -18,6 +18,8 @@ core:
|
||||||
label: 已有帳號
|
label: 已有帳號
|
||||||
button: 立即登入
|
button: 立即登入
|
||||||
return_site: 返回到首頁
|
return_site: 返回到首頁
|
||||||
|
reset_password:
|
||||||
|
button: 找回密碼
|
||||||
button: 登入
|
button: 登入
|
||||||
modal:
|
modal:
|
||||||
title: 重新登入
|
title: 重新登入
|
||||||
|
@ -1462,3 +1464,25 @@ core:
|
||||||
setting_modal:
|
setting_modal:
|
||||||
title: 文章設定
|
title: 文章設定
|
||||||
title: 我的文章
|
title: 我的文章
|
||||||
|
uc_reset_password:
|
||||||
|
fields:
|
||||||
|
username:
|
||||||
|
label: 用户名
|
||||||
|
password:
|
||||||
|
label: 新密碼
|
||||||
|
password_confirm:
|
||||||
|
label: 確認密碼
|
||||||
|
operations:
|
||||||
|
reset:
|
||||||
|
button: 重設密碼
|
||||||
|
toast_success: 重置成功
|
||||||
|
reset_password:
|
||||||
|
fields:
|
||||||
|
username:
|
||||||
|
label: 使用者名稱
|
||||||
|
email:
|
||||||
|
label: 郵件地址
|
||||||
|
operations:
|
||||||
|
send:
|
||||||
|
label: 發送驗證郵件
|
||||||
|
toast_success: 如果你的用戶名和郵箱地址匹配,我們將會發送一封郵件到你的郵箱。
|
||||||
|
|
|
@ -1,8 +1,15 @@
|
||||||
import { useUserStore } from "@/stores/user";
|
import { useUserStore } from "@/stores/user";
|
||||||
import type { Router } from "vue-router";
|
import type { Router } from "vue-router";
|
||||||
|
|
||||||
|
const whiteList = ["ResetPassword"];
|
||||||
|
|
||||||
export function setupAuthCheckGuard(router: Router) {
|
export function setupAuthCheckGuard(router: Router) {
|
||||||
router.beforeEach((to, from, next) => {
|
router.beforeEach((to, from, next) => {
|
||||||
|
if (whiteList.includes(to.name as string)) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
|
|
||||||
if (userStore.isAnonymous) {
|
if (userStore.isAnonymous) {
|
||||||
|
|
|
@ -2,6 +2,7 @@ import type { RouteRecordRaw } from "vue-router";
|
||||||
import NotFound from "@/views/exceptions/NotFound.vue";
|
import NotFound from "@/views/exceptions/NotFound.vue";
|
||||||
import Forbidden from "@/views/exceptions/Forbidden.vue";
|
import Forbidden from "@/views/exceptions/Forbidden.vue";
|
||||||
import BasicLayout from "@uc/layouts/BasicLayout.vue";
|
import BasicLayout from "@uc/layouts/BasicLayout.vue";
|
||||||
|
import ResetPassword from "@uc/views/ResetPassword.vue";
|
||||||
|
|
||||||
export const routes: Array<RouteRecordRaw> = [
|
export const routes: Array<RouteRecordRaw> = [
|
||||||
{
|
{
|
||||||
|
@ -20,6 +21,14 @@ export const routes: Array<RouteRecordRaw> = [
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/reset-password/:username",
|
||||||
|
component: ResetPassword,
|
||||||
|
name: "ResetPassword",
|
||||||
|
meta: {
|
||||||
|
title: "core.uc_reset_password.title",
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default routes;
|
export default routes;
|
||||||
|
|
|
@ -0,0 +1,112 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { apiClient } from "@/utils/api-client";
|
||||||
|
import { Toast, VButton } from "@halo-dev/components";
|
||||||
|
import { useRouteParams, useRouteQuery } from "@vueuse/router";
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import IconLogo from "~icons/core/logo?width=5rem&height=2rem";
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const username = useRouteParams<string>("username");
|
||||||
|
const token = useRouteQuery<string>("reset_password_token");
|
||||||
|
|
||||||
|
interface ResetPasswordForm {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
async function onSubmit(data: ResetPasswordForm) {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
await apiClient.common.user.resetPasswordByToken({
|
||||||
|
name: data.username,
|
||||||
|
resetPasswordRequest: {
|
||||||
|
newPassword: data.password,
|
||||||
|
token: token.value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Toast.success(t("core.uc_reset_password.operations.reset.toast_success"));
|
||||||
|
|
||||||
|
window.location.href = "/console/login";
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to reset password", error);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputClasses = {
|
||||||
|
outer: "!py-3 first:!pt-0 last:!pb-0",
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex h-screen flex-col items-center bg-white/90 pt-[30vh]">
|
||||||
|
<IconLogo class="mb-8" />
|
||||||
|
<div class="flex w-72 flex-col">
|
||||||
|
<FormKit
|
||||||
|
id="reset-password-form"
|
||||||
|
name="reset-password-form"
|
||||||
|
type="form"
|
||||||
|
:classes="{
|
||||||
|
form: '!divide-none',
|
||||||
|
}"
|
||||||
|
:config="{ validationVisibility: 'submit' }"
|
||||||
|
@submit="onSubmit"
|
||||||
|
@keyup.enter="$formkit.submit('reset-password-form')"
|
||||||
|
>
|
||||||
|
<FormKit
|
||||||
|
:classes="inputClasses"
|
||||||
|
name="username"
|
||||||
|
:model-value="username"
|
||||||
|
:placeholder="$t('core.uc_reset_password.fields.username.label')"
|
||||||
|
:validation-label="$t('core.uc_reset_password.fields.username.label')"
|
||||||
|
:autofocus="true"
|
||||||
|
type="text"
|
||||||
|
disabled
|
||||||
|
validation="required"
|
||||||
|
></FormKit>
|
||||||
|
<FormKit
|
||||||
|
:classes="inputClasses"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
:placeholder="$t('core.uc_reset_password.fields.password.label')"
|
||||||
|
:validation-label="$t('core.uc_reset_password.fields.password.label')"
|
||||||
|
validation="required:trim|length:5,100|matches:/^\S.*\S$/"
|
||||||
|
:validation-messages="{
|
||||||
|
matches: $t('core.formkit.validation.trim'),
|
||||||
|
}"
|
||||||
|
></FormKit>
|
||||||
|
<FormKit
|
||||||
|
:classes="inputClasses"
|
||||||
|
name="password_confirm"
|
||||||
|
type="password"
|
||||||
|
:placeholder="
|
||||||
|
$t('core.uc_reset_password.fields.password_confirm.label')
|
||||||
|
"
|
||||||
|
:validation-label="
|
||||||
|
$t('core.uc_reset_password.fields.password_confirm.label')
|
||||||
|
"
|
||||||
|
validation="confirm|required:trim|length:5,100|matches:/^\S.*\S$/"
|
||||||
|
:validation-messages="{
|
||||||
|
matches: $t('core.formkit.validation.trim'),
|
||||||
|
}"
|
||||||
|
></FormKit>
|
||||||
|
</FormKit>
|
||||||
|
<VButton
|
||||||
|
class="mt-8"
|
||||||
|
block
|
||||||
|
:loading="loading"
|
||||||
|
type="secondary"
|
||||||
|
@click="$formkit.submit('reset-password-form')"
|
||||||
|
>
|
||||||
|
{{ $t("core.uc_reset_password.operations.reset.button") }}
|
||||||
|
</VButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -139,7 +139,34 @@ items:
|
||||||
method: DELETE
|
method: DELETE
|
||||||
header:
|
header:
|
||||||
Authorization: "{{.param.auth}}"
|
Authorization: "{{.param.auth}}"
|
||||||
|
- name: sendPasswordResetEmail
|
||||||
|
request:
|
||||||
|
api: |
|
||||||
|
/api.halo.run/v1alpha1/users/-/send-password-reset-email
|
||||||
|
method: POST
|
||||||
|
header:
|
||||||
|
Content-Type: application/json
|
||||||
|
body: |
|
||||||
|
{
|
||||||
|
"username": "{{.param.userName}}",
|
||||||
|
"email": "{{.param.email}}"
|
||||||
|
}
|
||||||
|
expect:
|
||||||
|
statusCode: 204
|
||||||
|
- name: resetPasswordByToken
|
||||||
|
request:
|
||||||
|
api: |
|
||||||
|
/api.halo.run/v1alpha1/users/{{.param.userName}}/reset-password
|
||||||
|
method: PUT
|
||||||
|
header:
|
||||||
|
Content-Type: application/json
|
||||||
|
body: |
|
||||||
|
{
|
||||||
|
"newPassword": "{{randAlpha 6}}",
|
||||||
|
"token": "{{randAlpha 6}}"
|
||||||
|
}
|
||||||
|
expect:
|
||||||
|
statusCode: 403
|
||||||
## Roles
|
## Roles
|
||||||
- name: createRole
|
- name: createRole
|
||||||
request:
|
request:
|
||||||
|
|
Loading…
Reference in New Issue