add email verification

This commit is contained in:
MaysWind
2023-09-03 23:45:12 +08:00
parent c38b277887
commit e2b81f7b57
35 changed files with 931 additions and 35 deletions

View File

@@ -112,6 +112,19 @@ var UserData = &cli.Command{
},
},
},
{
Name: "user-resend-verify-email",
Usage: "Resend user verify email",
Action: resendUserVerifyEmail,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "username",
Aliases: []string{"n"},
Required: true,
Usage: "Specific user name",
},
},
},
{
Name: "user-set-email-verified",
Usage: "Set user email address verified",
@@ -364,6 +377,26 @@ func disableUser(c *cli.Context) error {
return nil
}
func resendUserVerifyEmail(c *cli.Context) error {
_, err := initializeSystem(c)
if err != nil {
return err
}
username := c.String("username")
err = clis.UserData.ResendVerifyEmail(c, username)
if err != nil {
log.BootErrorf("[user_data.resendUserVerifyEmail] error occurs when resending user verify email")
return err
}
log.BootInfof("[user_data.resendUserVerifyEmail] verify email for user \"%s\" has been resent", username)
return nil
}
func setUserEmailVerified(c *cli.Context) error {
_, err := initializeSystem(c)

View File

@@ -201,6 +201,16 @@ func startWebServer(c *cli.Context) error {
apiRoute.POST("/register.json", bindApiWithTokenUpdate(api.Users.UserRegisterHandler, config))
}
if config.EnableUserVerifyEmail {
apiRoute.POST("/verify_email/resend.json", bindApi(api.Users.UserSendVerifyEmailByUnloginUserHandler))
emailVerifyRoute := apiRoute.Group("/verify_email")
emailVerifyRoute.Use(bindMiddleware(middlewares.JWTEmailVerifyAuthorization))
{
emailVerifyRoute.POST("/by_token.json", bindApi(api.Users.UserEmailVerifyHandler))
}
}
if config.EnableUserForgetPassword {
apiRoute.POST("/forget_password/request.json", bindApi(api.ForgetPasswords.UserForgetPasswordRequestHandler))
@@ -226,6 +236,10 @@ func startWebServer(c *cli.Context) error {
apiV1Route.GET("/users/profile/get.json", bindApi(api.Users.UserProfileHandler))
apiV1Route.POST("/users/profile/update.json", bindApiWithTokenUpdate(api.Users.UserUpdateProfileHandler, config))
if config.EnableUserVerifyEmail {
apiV1Route.POST("/users/verify_email/resend.json", bindApi(api.Users.UserSendVerifyEmailByLoginedUserHandler))
}
// Two Factor Authorization
if config.EnableTwoFactor {
apiV1Route.GET("/users/2fa/status.json", bindApi(api.TwoFactorAuthorizations.TwoFactorStatusHandler))

View File

@@ -112,6 +112,9 @@ token_expired_time = 2592000
# Temporary token expired seconds (0 - 4294967295), default is 300 (5 minutes)
temporary_token_expired_time = 300
# Email verify token expired seconds (0 - 4294967295), default is 3600 (60 minutes)
email_verify_token_expired_time = 3600
# Password reset token expired seconds (0 - 4294967295), default is 3600 (60 minutes)
password_reset_token_expired_time = 3600
@@ -122,9 +125,18 @@ request_id_header = true
# Set to true to allow users to register account by themselves
enable_register = true
# Set to true to allow users to verify email address
enable_email_verify = false
# Set to true to require email must be verified when login
enable_force_email_verify = false
# Set to true to allow users to reset password
enable_forget_password = true
# Set to true to require email must be verified when use forget password
forget_password_require_email_verify = false
# User avatar provider, supports the following types:
# "gravatar": https://gravatar.com
# Leave blank if you want to disable user avatar

View File

@@ -8,6 +8,7 @@ import (
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/services"
"github.com/mayswind/ezbookkeeping/pkg/settings"
)
// AuthorizationsApi represents authorization api
@@ -48,6 +49,13 @@ func (a *AuthorizationsApi) AuthorizeHandler(c *core.Context) (interface{}, *err
return nil, errs.ErrUserIsDisabled
}
if settings.Container.Current.EnableUserForceVerifyEmail && !user.EmailVerified {
log.WarnfWithRequestId(c, "[authorizations.AuthorizeHandler] login failed for user \"%s\", because user has not verified email", credential.LoginName)
return nil, errs.NewErrorWithContext(errs.ErrEmailIsNotVerified, map[string]string{
"email": user.Email,
})
}
err = a.users.UpdateUserLastLoginTime(c, user.Uid)
if err != nil {
@@ -121,6 +129,16 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeHandler(c *core.Context) (interfac
return nil, errs.ErrUserNotFound
}
if user.Disabled {
log.WarnfWithRequestId(c, "[authorizations.TwoFactorAuthorizeHandler] user \"uid:%d\" is disabled", user.Uid)
return nil, errs.ErrUserIsDisabled
}
if settings.Container.Current.EnableUserForceVerifyEmail && !user.EmailVerified {
log.WarnfWithRequestId(c, "[authorizations.TwoFactorAuthorizeHandler] user \"uid:%d\" has not verified email", user.Uid)
return nil, errs.ErrEmailIsNotVerified
}
oldTokenClaims := c.GetTokenClaims()
err = a.tokens.DeleteTokenByClaims(c, oldTokenClaims)
@@ -173,6 +191,16 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.Cont
return nil, errs.ErrUserNotFound
}
if user.Disabled {
log.WarnfWithRequestId(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] user \"uid:%d\" is disabled", user.Uid)
return nil, errs.ErrUserIsDisabled
}
if settings.Container.Current.EnableUserForceVerifyEmail && !user.EmailVerified {
log.WarnfWithRequestId(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] user \"uid:%d\" has not verified email", user.Uid)
return nil, errs.ErrEmailIsNotVerified
}
err = a.twoFactorAuthorizations.GetAndUseUserTwoFactorRecoveryCode(c, uid, credential.RecoveryCode, user.Salt)
if err != nil {
@@ -205,8 +233,9 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.Cont
func (a *AuthorizationsApi) getAuthResponse(token string, need2FA bool, user *models.User) *models.AuthResponse {
return &models.AuthResponse{
Token: token,
Need2FA: need2FA,
User: user.ToUserBasicInfo(),
Token: token,
Need2FA: need2FA,
NeedVerifyEmail: false,
User: user.ToUserBasicInfo(),
}
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/services"
"github.com/mayswind/ezbookkeeping/pkg/settings"
)
// ForgetPasswordsApi represents user forget password api
@@ -51,7 +52,7 @@ func (a *ForgetPasswordsApi) UserForgetPasswordRequestHandler(c *core.Context) (
return nil, errs.ErrUserIsDisabled
}
if !user.EmailVerified {
if settings.Container.Current.ForgetPasswordRequireVerifyEmail && !user.EmailVerified {
log.WarnfWithRequestId(c, "[forget_passwords.UserForgetPasswordRequestHandler] user \"uid:%d\" has not verified email", user.Uid)
return nil, errs.ErrEmailIsNotVerified
}
@@ -99,7 +100,7 @@ func (a *ForgetPasswordsApi) UserResetPasswordHandler(c *core.Context) (interfac
return nil, errs.ErrUserIsDisabled
}
if !user.EmailVerified {
if settings.Container.Current.ForgetPasswordRequireVerifyEmail && !user.EmailVerified {
log.WarnfWithRequestId(c, "[forget_passwords.UserResetPasswordHandler] user \"uid:%d\" has not verified email", user.Uid)
return nil, errs.ErrEmailIsNotVerified
}

View File

@@ -73,8 +73,13 @@ func (a *UsersApi) UserRegisterHandler(c *core.Context) (interface{}, *errs.Erro
log.InfofWithRequestId(c, "[users.UserRegisterHandler] user \"%s\" has registered successfully, uid is %d", user.Username, user.Uid)
authResp := &models.AuthResponse{
Need2FA: false,
User: user.ToUserBasicInfo(),
Need2FA: false,
NeedVerifyEmail: settings.Container.Current.EnableUserForceVerifyEmail,
User: user.ToUserBasicInfo(),
}
if authResp.NeedVerifyEmail {
return authResp, nil
}
token, claims, err := a.tokens.CreateToken(c, user)
@@ -93,6 +98,69 @@ func (a *UsersApi) UserRegisterHandler(c *core.Context) (interface{}, *errs.Erro
return authResp, nil
}
// UserEmailVerifyHandler sets user email address verified
func (a *UsersApi) UserEmailVerifyHandler(c *core.Context) (interface{}, *errs.Error) {
var userVerifyEmailReq models.UserVerifyEmailRequest
err := c.ShouldBindJSON(&userVerifyEmailReq)
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.ErrorfWithRequestId(c, "[users.UserEmailVerifyHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
if user.Disabled {
log.WarnfWithRequestId(c, "[users.UserEmailVerifyHandler] user \"uid:%d\" is disabled", user.Uid)
return nil, errs.ErrUserIsDisabled
}
if user.EmailVerified {
log.WarnfWithRequestId(c, "[users.UserEmailVerifyHandler] user \"uid:%d\" email has been verified", user.Uid)
return nil, errs.ErrEmailIsVerified
}
err = a.users.SetUserEmailVerified(c, user.Username)
if err != nil {
log.ErrorfWithRequestId(c, "[users.UserEmailVerifyHandler] failed to update user \"uid:%d\" email address verified, because %s", user.Uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
now := time.Now().Unix()
err = a.tokens.DeleteTokensByTypeBeforeTime(c, uid, core.USER_TOKEN_TYPE_EMAIL_VERIFY, now)
if err == nil {
log.InfofWithRequestId(c, "[users.UserEmailVerifyHandler] revoke old email verify tokens before unix time \"%d\" for user \"uid:%d\"", now, user.Uid)
} else {
log.WarnfWithRequestId(c, "[users.UserEmailVerifyHandler] failed to revoke old email verify tokens for user \"uid:%d\", because %s", user.Uid, err.Error())
}
resp := &models.UserVerifyEmailResponse{}
if userVerifyEmailReq.RequestNewToken {
token, claims, err := a.tokens.CreateToken(c, user)
if err != nil {
log.WarnfWithRequestId(c, "[users.UserEmailVerifyHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
return resp, nil
}
resp.NewToken = token
resp.User = user.ToUserBasicInfo()
c.SetTextualToken(token)
c.SetTokenClaims(claims)
log.InfofWithRequestId(c, "[users.UserEmailVerifyHandler] user \"uid:%d\" token created, new token will be expired at %d", user.Uid, claims.ExpiresAt)
}
return resp, nil
}
// UserProfileHandler returns user profile of current user
func (a *UsersApi) UserProfileHandler(c *core.Context) (interface{}, *errs.Error) {
uid := c.GetCurrentUid()
@@ -283,3 +351,85 @@ func (a *UsersApi) UserUpdateProfileHandler(c *core.Context) (interface{}, *errs
return resp, nil
}
// UserSendVerifyEmailByUnloginUserHandler sends unlogin user verify email
func (a *UsersApi) UserSendVerifyEmailByUnloginUserHandler(c *core.Context) (interface{}, *errs.Error) {
var userResendVerifyEmailReq models.UserResendVerifyEmailRequest
err := c.ShouldBindJSON(&userResendVerifyEmailReq)
user, err := a.users.GetUserByEmail(c, userResendVerifyEmailReq.Email)
if err != nil {
if !errs.IsCustomError(err) {
log.ErrorfWithRequestId(c, "[users.UserSendVerifyEmailByUnloginUserHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
if !a.users.IsPasswordEqualsUserPassword(userResendVerifyEmailReq.Password, user) {
log.WarnfWithRequestId(c, "[users.UserSendVerifyEmailByUnloginUserHandler] request password not equals to the user password")
return nil, errs.ErrUserPasswordWrong
}
if user.Disabled {
log.WarnfWithRequestId(c, "[users.UserSendVerifyEmailByUnloginUserHandler] user \"uid:%d\" is disabled", user.Uid)
return nil, errs.ErrUserIsDisabled
}
if user.EmailVerified {
log.WarnfWithRequestId(c, "[users.UserSendVerifyEmailByUnloginUserHandler] user \"uid:%d\" email has been verified", user.Uid)
return nil, errs.ErrEmailIsVerified
}
token, _, err := a.tokens.CreateEmailVerifyToken(c, user)
if err != nil {
log.ErrorfWithRequestId(c, "[users.UserSendVerifyEmailByUnloginUserHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.ErrTokenGenerating
}
err = a.users.SendVerifyEmail(user, token, c.GetClientLocale())
if err != nil {
log.WarnfWithRequestId(c, "[users.UserSendVerifyEmailByUnloginUserHandler] cannot send email to \"%s\", because %s", user.Email, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
return true, nil
}
// UserSendVerifyEmailByLoginedUserHandler sends logined user verify email
func (a *UsersApi) UserSendVerifyEmailByLoginedUserHandler(c *core.Context) (interface{}, *errs.Error) {
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(c, uid)
if err != nil {
if !errs.IsCustomError(err) {
log.ErrorfWithRequestId(c, "[users.UserSendVerifyEmailByLoginedUserHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
if user.EmailVerified {
log.WarnfWithRequestId(c, "[users.UserSendVerifyEmailByLoginedUserHandler] user \"uid:%d\" email has been verified", user.Uid)
return nil, errs.ErrEmailIsVerified
}
token, _, err := a.tokens.CreateEmailVerifyToken(c, user)
if err != nil {
log.ErrorfWithRequestId(c, "[users.UserSendVerifyEmailByLoginedUserHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.ErrTokenGenerating
}
err = a.users.SendVerifyEmail(user, token, c.GetClientLocale())
if err != nil {
log.WarnfWithRequestId(c, "[users.UserSendVerifyEmailByLoginedUserHandler] cannot send email to \"%s\", because %s", user.Email, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
return true, nil
}

View File

@@ -10,6 +10,7 @@ import (
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/services"
"github.com/mayswind/ezbookkeeping/pkg/settings"
"github.com/mayswind/ezbookkeeping/pkg/validators"
)
@@ -177,9 +178,9 @@ func (l *UserDataCli) SendPasswordResetMail(c *cli.Context, username string) err
return err
}
if !user.EmailVerified {
if settings.Container.Current.ForgetPasswordRequireVerifyEmail && !user.EmailVerified {
log.BootWarnf("[user_data.SendPasswordResetMail] user \"uid:%d\" has not verified email", user.Uid)
return errs.ErrEmptyIsNotVerified
return errs.ErrEmailIsNotVerified
}
token, _, err := l.tokens.CreatePasswordResetToken(nil, user)
@@ -233,6 +234,42 @@ func (l *UserDataCli) DisableUser(c *cli.Context, username string) error {
return nil
}
// ResendVerifyEmail resends an email with account activation link
func (l *UserDataCli) ResendVerifyEmail(c *cli.Context, username string) error {
if username == "" {
log.BootErrorf("[user_data.ResendVerifyEmail] user name is empty")
return errs.ErrUsernameIsEmpty
}
user, err := l.users.GetUserByUsername(nil, username)
if err != nil {
log.BootErrorf("[user_data.ResendVerifyEmail] failed to get user by user name \"%s\", because %s", username, err.Error())
return err
}
if user.EmailVerified {
log.BootWarnf("[user_data.ResendVerifyEmail] user \"uid:%d\" email has been verified", user.Uid)
return errs.ErrEmailIsVerified
}
token, _, err := l.tokens.CreateEmailVerifyToken(nil, user)
if err != nil {
log.BootErrorf("[user_data.ResendVerifyEmail] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
return errs.ErrTokenGenerating
}
err = l.users.SendVerifyEmail(user, token, "")
if err != nil {
log.BootErrorf("[user_data.ResendVerifyEmail] cannot send email to \"%s\", because %s", user.Email, err.Error())
return err
}
return nil
}
// SetUserEmailVerified sets user email address verified
func (l *UserDataCli) SetUserEmailVerified(c *cli.Context, username string) error {
if username == "" {

View File

@@ -13,6 +13,7 @@ type TokenType byte
const (
USER_TOKEN_TYPE_NORMAL TokenType = 1
USER_TOKEN_TYPE_REQUIRE_2FA TokenType = 2
USER_TOKEN_TYPE_EMAIL_VERIFY TokenType = 3
USER_TOKEN_TYPE_PASSWORD_RESET TokenType = 4
)

View File

@@ -38,6 +38,7 @@ type Error struct {
HttpStatusCode int
Message string
BaseError []error
Context interface{}
}
// Error returns the error message
@@ -81,6 +82,19 @@ func NewIncompleteOrIncorrectSubmissionError(err error) *Error {
ErrIncompleteOrIncorrectSubmission.Message, err)
}
// NewErrorWithContext returns a new error instance with specified context
func NewErrorWithContext(baseError *Error, context interface{}) *Error {
return &Error{
Category: baseError.Category,
SubCategory: baseError.SubCategory,
Index: baseError.Index,
HttpStatusCode: baseError.HttpStatusCode,
Message: baseError.Message,
BaseError: baseError.BaseError,
Context: context,
}
}
// Or would return the error from err parameter if the this error is defined in this project,
// or return the default error
func Or(err error, defaultErr *Error) *Error {

View File

@@ -19,5 +19,6 @@ var (
ErrTokenRecordNotFound = NewNormalError(NormalSubcategoryToken, 10, http.StatusBadRequest, "token is not found")
ErrTokenExpired = NewNormalError(NormalSubcategoryToken, 11, http.StatusBadRequest, "token is expired")
ErrTokenIsEmpty = NewNormalError(NormalSubcategoryToken, 12, http.StatusBadRequest, "token is empty")
ErrEmailVerifyTokenIsInvalidOrExpired = NewNormalError(NormalSubcategoryToken, 13, http.StatusBadRequest, "email verify token is invalid or expired")
ErrPasswordResetTokenIsInvalidOrExpired = NewNormalError(NormalSubcategoryToken, 14, http.StatusBadRequest, "password reset token is invalid or expired")
)

View File

@@ -27,4 +27,5 @@ var (
ErrEmailIsEmptyOrInvalid = NewNormalError(NormalSubcategoryUser, 18, http.StatusBadRequest, "email is empty or invalid")
ErrNewPasswordEqualsOldInvalid = NewNormalError(NormalSubcategoryUser, 19, http.StatusBadRequest, "new password equals old password")
ErrEmailIsNotVerified = NewNormalError(NormalSubcategoryUser, 20, http.StatusBadRequest, "email is not verified")
ErrEmailIsVerified = NewNormalError(NormalSubcategoryUser, 21, http.StatusBadRequest, "email is verified")
)

View File

@@ -2,9 +2,19 @@ package locales
// LocaleTextItems represents all text items need to be translated
type LocaleTextItems struct {
VerifyEmailTextItems *VerifyEmailTextItems
ForgetPasswordMailTextItems *ForgetPasswordMailTextItems
}
// VerifyEmailTextItems represents text items need to be translated in verify mail
type VerifyEmailTextItems struct {
Title string
SalutationFormat string
DescriptionAboveBtn string
VerifyEmail string
DescriptionBelowBtnFormat string
}
// ForgetPasswordMailTextItems represents text items need to be translated in forget password mail
type ForgetPasswordMailTextItems struct {
Title string

View File

@@ -1,6 +1,13 @@
package locales
var en = &LocaleTextItems{
VerifyEmailTextItems: &VerifyEmailTextItems{
Title: "Verify Email",
SalutationFormat: "Hi %s,",
DescriptionAboveBtn: "Please click the link below to confirm your email address.",
VerifyEmail: "Verify Email",
DescriptionBelowBtnFormat: "If you did not sign up for %s account, please simply disregard this email. If you cannot click the link above, please copy the above url and paste it into your browser. The verify email link will be expired after %v minutes.",
},
ForgetPasswordMailTextItems: &ForgetPasswordMailTextItems{
Title: "Reset Your Password",
SalutationFormat: "Hi %s,",

View File

@@ -1,9 +1,16 @@
package locales
var zhHans = &LocaleTextItems{
VerifyEmailTextItems: &VerifyEmailTextItems{
Title: "验证邮箱",
SalutationFormat: "%s 您好,",
DescriptionAboveBtn: "请点击下方的链接确认您的邮箱地址。",
VerifyEmail: "验证邮箱",
DescriptionBelowBtnFormat: "如果您没有注册 %s 账户,请直接忽略本邮件。如果您无法点击上述链接,请复制下方的地址然后在您的浏览器中粘贴。邮箱验证链接将在 %v 分钟后过期。",
},
ForgetPasswordMailTextItems: &ForgetPasswordMailTextItems{
Title: "重置密码",
SalutationFormat: "%s 好,",
SalutationFormat: "%s 好,",
DescriptionAboveBtn: "我们刚才收到重置您密码的请求。您可以点击下方链接重置您的密码。",
ResetPassword: "重置密码",
DescriptionBelowBtnFormat: "如果您没有请求重置密码,请直接忽略本邮件。如果您无法点击上述链接,请复制下方的地址然后在您的浏览器中粘贴。重置密码链接将在 %v 分钟后过期。",

View File

@@ -56,6 +56,25 @@ func JWTTwoFactorAuthorization(c *core.Context) {
c.Next()
}
// JWTEmailVerifyAuthorization verifies whether current request is email verification
func JWTEmailVerifyAuthorization(c *core.Context) {
claims, err := getTokenClaims(c, TOKEN_SOURCE_TYPE_ARGUMENT)
if err != nil {
utils.PrintJsonErrorResult(c, errs.ErrEmailVerifyTokenIsInvalidOrExpired)
return
}
if claims.Type != core.USER_TOKEN_TYPE_EMAIL_VERIFY {
log.WarnfWithRequestId(c, "[authorization.JWTEmailVerifyAuthorization] user \"uid:%d\" token is not for email verification", claims.Uid)
utils.PrintJsonErrorResult(c, errs.ErrCurrentInvalidToken)
return
}
c.SetTokenClaims(claims)
c.Next()
}
// JWTResetPasswordAuthorization verifies whether current request is password reset
func JWTResetPasswordAuthorization(c *core.Context) {
claims, err := getTokenClaims(c, TOKEN_SOURCE_TYPE_ARGUMENT)

View File

@@ -18,6 +18,7 @@ func ServerSettingsCookie(config *settings.Config) core.MiddlewareHandlerFunc {
settingsArr := []string{
buildBooleanSetting("r", config.EnableUserRegister),
buildBooleanSetting("f", config.EnableUserForgetPassword),
buildBooleanSetting("v", config.EnableUserVerifyEmail),
buildBooleanSetting("e", config.EnableDataExport),
buildStringSetting("m", strings.Replace(config.MapProvider, "_", "-", -1)),
}

View File

@@ -2,7 +2,8 @@ package models
// AuthResponse returns a view-object of user authorization
type AuthResponse struct {
Token string `json:"token"`
Need2FA bool `json:"need2FA"`
User *UserBasicInfo `json:"user"`
Token string `json:"token"`
Need2FA bool `json:"need2FA"`
NeedVerifyEmail bool `json:"needVerifyEmail"`
User *UserBasicInfo `json:"user"`
}

View File

@@ -108,6 +108,23 @@ type UserRegisterRequest struct {
FirstDayOfWeek WeekDay `json:"firstDayOfWeek" binding:"min=0,max=6"`
}
// UserVerifyEmailRequest represents all parameters of user verify email request
type UserVerifyEmailRequest struct {
RequestNewToken bool `json:"requestNewToken" binding:"omitempty"`
}
// UserVerifyEmailResponse represents all response parameters after user have verified email
type UserVerifyEmailResponse struct {
NewToken string `json:"newToken,omitempty"`
User *UserBasicInfo `json:"user"`
}
// UserResendVerifyEmailRequest represents all parameters of user resend verify email request
type UserResendVerifyEmailRequest struct {
Email string `json:"email" binding:"omitempty,max=100,validEmail"`
Password string `json:"password" binding:"omitempty,min=6,max=128"`
}
// UserProfileUpdateRequest represents all parameters of user updating profile request
type UserProfileUpdateRequest struct {
Email string `json:"email" binding:"omitempty,notBlank,max=100,validEmail"`
@@ -147,6 +164,7 @@ type UserProfileResponse struct {
ShortDateFormat ShortDateFormat `json:"shortDateFormat"`
LongTimeFormat LongTimeFormat `json:"longTimeFormat"`
ShortTimeFormat ShortTimeFormat `json:"shortTimeFormat"`
EmailVerified bool `json:"emailVerified"`
LastLoginAt int64 `json:"lastLoginAt"`
}
@@ -229,6 +247,7 @@ func (u *User) ToUserProfileResponse() *UserProfileResponse {
ShortDateFormat: u.ShortDateFormat,
LongTimeFormat: u.LongTimeFormat,
ShortTimeFormat: u.ShortTimeFormat,
EmailVerified: u.EmailVerified,
LastLoginAt: u.LastLoginUnixTime,
}
}

View File

@@ -52,7 +52,7 @@ func (s *ForgetPasswordService) SendPasswordResetEmail(c *core.Context, user *mo
expireTimeInMinutes := s.CurrentConfig().PasswordResetTokenExpiredTimeDuration.Minutes()
passwordResetUrl := fmt.Sprintf(passwordResetUrlFormat, s.CurrentConfig().RootUrl, url.QueryEscape(passwordResetToken))
tmpl, err := templates.GetTemplate("email/password_reset")
tmpl, err := templates.GetTemplate(templates.TEMPLATE_PASSWORD_RESET)
if err != nil {
return err

View File

@@ -88,6 +88,11 @@ func (s *TokenService) CreateRequire2FAToken(c *core.Context, user *models.User)
return s.createToken(c, user, core.USER_TOKEN_TYPE_REQUIRE_2FA, s.getUserAgent(c), s.CurrentConfig().TemporaryTokenExpiredTimeDuration)
}
// CreateEmailVerifyToken generates a new email verify token and saves to database
func (s *TokenService) CreateEmailVerifyToken(c *core.Context, user *models.User) (string, *core.UserTokenClaims, error) {
return s.createToken(c, user, core.USER_TOKEN_TYPE_EMAIL_VERIFY, s.getUserAgent(c), s.CurrentConfig().EmailVerifyTokenExpiredTimeDuration)
}
// CreatePasswordResetToken generates a new password reset token and saves to database
func (s *TokenService) CreatePasswordResetToken(c *core.Context, user *models.User) (string, *core.UserTokenClaims, error) {
return s.createToken(c, user, core.USER_TOKEN_TYPE_PASSWORD_RESET, s.getUserAgent(c), s.CurrentConfig().PasswordResetTokenExpiredTimeDuration)
@@ -165,6 +170,18 @@ func (s *TokenService) DeleteTokensBeforeTime(c *core.Context, uid int64, expire
})
}
// DeleteTokensByTypeBeforeTime deletes tokens that is specified type and created before specific time
func (s *TokenService) DeleteTokensByTypeBeforeTime(c *core.Context, uid int64, tokenType core.TokenType, expireTime int64) error {
if uid <= 0 {
return errs.ErrUserIdInvalid
}
return s.TokenDB(uid).DoTransaction(c, func(sess *xorm.Session) error {
_, err := sess.Where("uid=? AND token_type=? AND created_unix_time<?", uid, tokenType, expireTime).Delete(&models.TokenRecord{})
return err
})
}
// ParseFromTokenId returns token model according to token id
func (s *TokenService) ParseFromTokenId(tokenId string) (*models.TokenRecord, error) {
pairs := strings.Split(tokenId, ":")

View File

@@ -1,6 +1,9 @@
package services
import (
"bytes"
"fmt"
"net/url"
"time"
"xorm.io/xorm"
@@ -8,14 +11,22 @@ import (
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/datastore"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/locales"
"github.com/mayswind/ezbookkeeping/pkg/mail"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/settings"
"github.com/mayswind/ezbookkeeping/pkg/templates"
"github.com/mayswind/ezbookkeeping/pkg/utils"
"github.com/mayswind/ezbookkeeping/pkg/uuid"
)
const verifyEmailUrlFormat = "%sdesktop/#/verify_email?token=%s"
// UserService represents user service
type UserService struct {
ServiceUsingDB
ServiceUsingConfig
ServiceUsingMailer
ServiceUsingUuid
}
@@ -25,6 +36,12 @@ var (
ServiceUsingDB: ServiceUsingDB{
container: datastore.Container,
},
ServiceUsingConfig: ServiceUsingConfig{
container: settings.Container,
},
ServiceUsingMailer: ServiceUsingMailer{
container: mail.Container,
},
ServiceUsingUuid: ServiceUsingUuid{
container: uuid.Container,
},
@@ -390,6 +407,60 @@ func (s *UserService) ExistsEmail(c *core.Context, email string) (bool, error) {
return s.UserDB().NewSession(c).Cols("email").Where("email=? AND deleted=?", email, false).Exist(&models.User{})
}
// SendVerifyEmail sends verify email according to specified parameters
func (s *UserService) SendVerifyEmail(user *models.User, verifyEmailToken string, backupLocale string) error {
if !s.CurrentConfig().EnableSMTP {
return errs.ErrSMTPServerNotEnabled
}
locale := user.Language
if locale == "" {
locale = backupLocale
}
localeTextItems := locales.GetLocaleTextItems(locale)
verifyEmailTextItems := localeTextItems.VerifyEmailTextItems
expireTimeInMinutes := s.CurrentConfig().EmailVerifyTokenExpiredTimeDuration.Minutes()
verifyEmailUrl := fmt.Sprintf(verifyEmailUrlFormat, s.CurrentConfig().RootUrl, url.QueryEscape(verifyEmailToken))
tmpl, err := templates.GetTemplate(templates.TEMPLATE_VERIFY_EMAIL)
if err != nil {
return err
}
templateParams := map[string]interface{}{
"AppName": s.CurrentConfig().AppName,
"VerifyEmail": map[string]interface{}{
"Title": verifyEmailTextItems.Title,
"Salutation": fmt.Sprintf(verifyEmailTextItems.SalutationFormat, user.Nickname),
"DescriptionAboveBtn": verifyEmailTextItems.DescriptionAboveBtn,
"VerifyEmailUrl": verifyEmailUrl,
"VerifyEmail": verifyEmailTextItems.VerifyEmail,
"DescriptionBelowBtn": fmt.Sprintf(verifyEmailTextItems.DescriptionBelowBtnFormat, s.CurrentConfig().AppName, expireTimeInMinutes),
},
}
var bodyBuffer bytes.Buffer
err = tmpl.Execute(&bodyBuffer, templateParams)
if err != nil {
return err
}
message := &mail.MailMessage{
To: user.Email,
Subject: verifyEmailTextItems.Title,
Body: bodyBuffer.String(),
}
err = s.SendMail(message)
return err
}
// IsPasswordEqualsUserPassword returns whether the given password is correct
func (s *UserService) IsPasswordEqualsUserPassword(password string, user *models.User) bool {
return user.Password == utils.EncodePassword(password, user.Salt)

View File

@@ -116,6 +116,7 @@ const (
defaultSecretKey string = "ezbookkeeping"
defaultTokenExpiredTime uint32 = 604800 // 7 days
defaultTemporaryTokenExpiredTime uint32 = 300 // 5 minutes
defaultEmailVerifyTokenExpiredTime uint32 = 3600 // 60 minutes
defaultPasswordResetTokenExpiredTime uint32 = 3600 // 60 minutes
defaultExchangeRatesDataRequestTimeout uint32 = 10000 // 10 seconds
@@ -200,14 +201,19 @@ type Config struct {
TokenExpiredTimeDuration time.Duration
TemporaryTokenExpiredTime uint32
TemporaryTokenExpiredTimeDuration time.Duration
EmailVerifyTokenExpiredTime uint32
EmailVerifyTokenExpiredTimeDuration time.Duration
PasswordResetTokenExpiredTime uint32
PasswordResetTokenExpiredTimeDuration time.Duration
EnableRequestIdHeader bool
// User
EnableUserRegister bool
EnableUserForgetPassword bool
AvatarProvider string
EnableUserRegister bool
EnableUserVerifyEmail bool
EnableUserForceVerifyEmail bool
EnableUserForgetPassword bool
ForgetPasswordRequireVerifyEmail bool
AvatarProvider string
// Data
EnableDataExport bool
@@ -481,6 +487,9 @@ func loadSecurityConfiguration(config *Config, configFile *ini.File, sectionName
config.TemporaryTokenExpiredTime = getConfigItemUint32Value(configFile, sectionName, "temporary_token_expired_time", defaultTemporaryTokenExpiredTime)
config.TemporaryTokenExpiredTimeDuration = time.Duration(config.TemporaryTokenExpiredTime) * time.Second
config.EmailVerifyTokenExpiredTime = getConfigItemUint32Value(configFile, sectionName, "email_verify_token_expired_time", defaultEmailVerifyTokenExpiredTime)
config.EmailVerifyTokenExpiredTimeDuration = time.Duration(config.EmailVerifyTokenExpiredTime) * time.Second
config.PasswordResetTokenExpiredTime = getConfigItemUint32Value(configFile, sectionName, "password_reset_token_expired_time", defaultPasswordResetTokenExpiredTime)
config.PasswordResetTokenExpiredTimeDuration = time.Duration(config.PasswordResetTokenExpiredTime) * time.Second
@@ -491,7 +500,10 @@ func loadSecurityConfiguration(config *Config, configFile *ini.File, sectionName
func loadUserConfiguration(config *Config, configFile *ini.File, sectionName string) error {
config.EnableUserRegister = getConfigItemBoolValue(configFile, sectionName, "enable_register", false)
config.EnableUserVerifyEmail = getConfigItemBoolValue(configFile, sectionName, "enable_email_verify", false)
config.EnableUserForceVerifyEmail = getConfigItemBoolValue(configFile, sectionName, "enable_force_email_verify", false)
config.EnableUserForgetPassword = getConfigItemBoolValue(configFile, sectionName, "enable_forget_password", false)
config.ForgetPasswordRequireVerifyEmail = getConfigItemBoolValue(configFile, sectionName, "forget_password_require_email_verify", false)
if getConfigItemStringValue(configFile, sectionName, "avatar_provider") == "" {
config.AvatarProvider = ""

View File

@@ -0,0 +1,9 @@
package templates
type KnownTemplate string
// Known templates
const (
TEMPLATE_VERIFY_EMAIL KnownTemplate = "email/verify_email"
TEMPLATE_PASSWORD_RESET KnownTemplate = "email/password_reset"
)

View File

@@ -9,16 +9,16 @@ import (
const templateBasePath = "templates"
const templateFileExtension = "tmpl"
var templateCache = make(map[string]*CachedTemplate)
var templateCache = make(map[KnownTemplate]*CachedTemplate)
// CachedTemplate represents a cached template
type CachedTemplate struct {
templateName string
templateName KnownTemplate
templateContent *template.Template
}
// GetTemplate returns a cached template instance according to the template name
func GetTemplate(templateName string) (*template.Template, error) {
func GetTemplate(templateName KnownTemplate) (*template.Template, error) {
fullPath := filepath.Join(templateBasePath, fmt.Sprintf("%s.%s", templateName, templateFileExtension))
cachedTemplate, exists := templateCache[templateName]

View File

@@ -45,12 +45,18 @@ func PrintJsonErrorResult(c *core.Context, err *errs.Error) {
}
}
c.AbortWithStatusJSON(err.HttpStatusCode, gin.H{
result := gin.H{
"success": false,
"errorCode": err.Code(),
"errorMessage": errorMessage,
"path": c.Request.URL.Path,
})
}
if err.Context != nil {
result["context"] = err.Context
}
c.AbortWithStatusJSON(err.HttpStatusCode, result)
}
// PrintDataErrorResult writes error response in custom content type to current http context

View File

@@ -37,6 +37,10 @@ export function isUserForgetPasswordEnabled() {
return getServerSetting('f') === '1';
}
export function isUserVerifyEmailEnabled() {
return getServerSetting('v') === '1';
}
export function isDataExportingEnabled() {
return getServerSetting('e') === '1';
}

View File

@@ -102,6 +102,22 @@ export default {
firstDayOfWeek
});
},
verifyEmail: ({ token, requestNewToken }) => {
return axios.post('verify_email/by_token.json?token=' + token, {
requestNewToken
}, {
noAuth: true,
ignoreError: true
});
},
resendVerifyEmailByUnloginUser: ({ email, password }) => {
return axios.post('verify_email/resend.json', {
email,
password
}, {
timeout: api.requestForgetPasswordTimeout
});
},
requestResetPassword: ({ email }) => {
return axios.post('forget_password/request.json', {
email
@@ -173,6 +189,11 @@ export default {
shortTimeFormat
});
},
resendVerifyEmailByLoginedUser: () => {
return axios.post('v1/users/verify_email/resend.json', {}, {
timeout: api.requestForgetPasswordTimeout
});
},
get2FAStatus: () => {
return axios.get('v1/users/2fa/status.json');
},

View File

@@ -585,6 +585,7 @@ export default {
'email is empty or invalid': 'Email is empty or invalid',
'new password equals old password': 'New password equals old password',
'email is not verified': 'Email is not verified',
'email is verified': 'Email is verified',
'unauthorized access': 'Unauthorized access',
'current token is invalid': 'Current token is invalid',
'current token is expired': 'Current token is expired',
@@ -597,6 +598,7 @@ export default {
'token is not found': 'Token is not found',
'token is expired': 'Token is expired',
'token is empty': 'Token is empty',
'email verify token is invalid or expired': 'Email verify token is invalid or expired',
'password reset token is invalid or expired': 'Password reset token is invalid or expired',
'passcode is invalid': 'Passcode is invalid',
'two factor backup code is invalid': 'Two factor backup code is invalid',
@@ -847,6 +849,14 @@ export default {
'Use a passcode': 'Use a passcode',
'PIN code is invalid': 'PIN code is invalid',
'PIN code is wrong': 'PIN code is wrong',
'Verify your email': 'Verify your email',
'Verifying...': 'Verifying...',
'Account activation link has been sent to your email address:': 'Account activation link has been sent to your email address:',
', If you don\'t receive the mail, fill password and click the button below to resend the verify mail.': ', If you don\'t receive the mail, fill password and click the button below to resend the verify mail.',
'Resend Validation Email': 'Resend Validation Email',
'Validation email has been sent': 'Validation email has been sent',
'Unable to verify email': 'Unable to verify email',
'Unable to resend verify email': 'Unable to resend verify email',
'Send Reset Link': 'Send Reset Link',
'Please input your email address used for registration and we\'ll send you an email with reset password link': 'Please input your email address used for registration and we\'ll send you an email with reset password link',
'Password reset email has been sent': 'Password reset email has been sent',
@@ -1055,8 +1065,9 @@ export default {
'Basic Settings': 'Basic Settings',
'Security Settings': 'Security Settings',
'Two-Factor Authentication Settings': 'Two-Factor Authentication Settings',
'Email has been verified': 'Email has been verified',
'Email has not been verified': 'Email has not been verified',
'Username:': 'Username:',
'Avatar Provider:': 'Avatar Provider:',
'Current Password': 'Current Password',
'New Password': 'New Password',
'Modify Password': 'Modify Password',

View File

@@ -584,7 +584,8 @@ export default {
'email is invalid': '邮箱无效',
'email is empty or invalid': '邮箱为空或无效',
'new password equals old password': '新密码与旧密码相同',
'email is not verified': '邮箱没有验证过',
'email is not verified': '邮箱还未验证过',
'email is verified': '邮箱已经验证过',
'unauthorized access': '未授权的登录',
'current token is invalid': '当前认证令牌无效',
'current token is expired': '当前认证令牌已过期',
@@ -597,6 +598,7 @@ export default {
'token is not found': '认证令牌不存在',
'token is expired': '认证令牌已过期',
'token is empty': '认证令牌为空',
'email verify token is invalid or expired': '邮箱验证令牌无效或已过期',
'password reset token is invalid or expired': '密码重置令牌无效或已过期',
'passcode is invalid': '验证码无效',
'two factor backup code is invalid': '两步验证备用码无效',
@@ -847,6 +849,14 @@ export default {
'Use a passcode': '使用验证码',
'PIN code is invalid': 'PIN码无效',
'PIN code is wrong': 'PIN码错误',
'Verify your email': '验证您的邮箱',
'Verifying...': '正在验证...',
'Account activation link has been sent to your email address:': '账号激活链接已经发送到您的邮箱地址:',
', If you don\'t receive the mail, fill password and click the button below to resend the verify mail.': ',如果您没有收到邮件,输入密码并点击下方的按钮重新发送验证邮件。',
'Resend Validation Email': '重发验证邮件',
'Validation email has been sent': '验证邮件已发送',
'Unable to verify email': '无法验证邮箱',
'Unable to resend verify email': '无法重新发送验证邮件',
'Send Reset Link': '发送重置链接',
'Please input your email address used for registration and we\'ll send you an email with reset password link': '请输入您注册时使用的电子邮箱地址,我们将发送一封包含重置密码链接的邮件给您',
'Password reset email has been sent': '重置密码邮件已发送',
@@ -1055,8 +1065,9 @@ export default {
'Basic Settings': '基本设置',
'Security Settings': '安全设置',
'Two-Factor Authentication Settings': '两步验证设置',
'Email has been verified': '邮箱地址已验证',
'Email has not been verified': '邮箱地址未验证',
'Username:': '用户名:',
'Avatar Provider:': '头像提供方:',
'Current Password': '当前密码',
'New Password': '新密码',
'Modify Password': '修改密码',

View File

@@ -5,6 +5,7 @@ import userState from '@/lib/userstate.js';
import MainLayout from '@/views/desktop/MainLayout.vue';
import LoginPage from '@/views/desktop/LoginPage.vue';
import SignUpPage from '@/views/desktop/SignupPage.vue';
import VerifyEmailPage from '@/views/desktop/VerifyEmailPage.vue';
import ForgetPasswordPage from '@/views/desktop/ForgetPasswordPage.vue';
import ResetPasswordPage from '@/views/desktop/ResetPasswordPage.vue';
import UnlockPage from '@/views/desktop/UnlockPage.vue';
@@ -159,6 +160,14 @@ const router = createRouter({
component: SignUpPage,
beforeEnter: checkNotLogin
},
{
path: '/verify_email',
component: VerifyEmailPage,
props: route => ({
email: route.query.email,
token: route.query.token
})
},
{
path: '/forgetpassword',
component: ForgetPasswordPage,

View File

@@ -247,6 +247,69 @@ export const useRootStore = defineStore('root', {
userState.clearWebAuthnConfig();
this.resetAllStates(true);
},
verifyEmail({ token, requestNewToken }) {
return new Promise((resolve, reject) => {
services.verifyEmail({
token,
requestNewToken
}).then(response => {
const data = response.data;
if (!data || !data.success || !data.result) {
reject({ message: 'Unable to verify email' });
return;
}
if (data.result.newToken && isString(data.result.newToken)) {
userState.updateToken(data.result.newToken);
}
if (data.result.user && isObject(data.result.user)) {
const userStore = useUserStore();
userStore.storeUserInfo(data.result.user);
}
resolve(data.result);
}).catch(error => {
logger.error('failed to verify email', error);
if (error && error.processed) {
reject(error);
} else if (error.response && error.response.data && error.response.data.errorMessage) {
reject({ error: error.response.data });
} else {
reject({ message: 'Unable to verify email' });
}
});
});
},
resendVerifyEmailByUnloginUser({ email, password }) {
return new Promise((resolve, reject) => {
services.resendVerifyEmailByUnloginUser({
email,
password
}).then(response => {
const data = response.data;
if (!data || !data.success || !data.result) {
reject({ message: 'Unable to resend verify email' });
return;
}
resolve(data.result);
}).catch(error => {
logger.error('failed to resend verify email', error);
if (error && error.processed) {
reject(error);
} else if (error.response && error.response.data && error.response.data.errorMessage) {
reject({ error: error.response.data });
} else {
reject({ message: 'Unable to resend verify email' });
}
});
});
},
requestResetPassword({ email }) {
return new Promise((resolve, reject) => {
services.requestResetPassword({
@@ -363,6 +426,30 @@ export const useRootStore = defineStore('root', {
});
});
},
resendVerifyEmailByLoginedUser() {
return new Promise((resolve, reject) => {
services.resendVerifyEmailByLoginedUser().then(response => {
const data = response.data;
if (!data || !data.success || !data.result) {
reject({ message: 'Unable to resend verify email' });
return;
}
resolve(data.result);
}).catch(error => {
logger.error('failed to resend verify email', error);
if (error && error.processed) {
reject(error);
} else if (error.response && error.response.data && error.response.data.errorMessage) {
reject({ error: error.response.data });
} else {
reject({ message: 'Unable to resend verify email' });
}
});
});
},
clearUserData({ password }) {
return new Promise((resolve, reject) => {
services.clearData({

View File

@@ -312,6 +312,11 @@ export default {
}).catch(error => {
self.logining = false;
if (error.error && error.error.errorCode === 201020 && error.error.context && error.error.context.email) {
self.$router.push('/verify_email?email=' + encodeURIComponent(error.error.context.email));
return;
}
if (!error.processed) {
self.$refs.snackbar.showError(error);
}

View File

@@ -0,0 +1,241 @@
<template>
<div class="layout-wrapper">
<router-link to="/">
<div class="auth-logo d-flex align-start gap-x-3">
<img alt="logo" class="login-page-logo" :src="ezBookkeepingLogoPath" />
<h1 class="font-weight-medium leading-normal text-2xl">{{ $t('global.app.title') }}</h1>
</div>
</router-link>
<v-row no-gutters class="auth-wrapper">
<v-col cols="12" md="8" class="d-none d-md-flex align-center justify-center position-relative">
<div class="d-flex auth-img-footer" v-if="!isDarkMode">
<v-img src="img/desktop/background.svg"/>
</div>
<div class="d-flex auth-img-footer" v-if="isDarkMode">
<v-img src="img/desktop/background-dark.svg"/>
</div>
<div class="d-flex align-center justify-center w-100 pt-10">
<v-img max-width="320px" src="img/desktop/people2.svg"/>
</div>
</v-col>
<v-col cols="12" md="4" class="auth-card d-flex flex-column">
<div class="d-flex align-center justify-center h-100">
<v-card variant="flat" class="w-100 mt-0 px-4 pt-12" max-width="500">
<v-card-text>
<h5 class="text-h5 mb-3">{{ $t('Verify your email') }}</h5>
<p class="mb-0" v-if="token && loading">{{ $t('Verifying...') }}</p>
<p class="mb-0" v-if="token && verified">{{ $t('Email has been verified') }}</p>
<p class="mb-0" v-if="token && !verified && errorMessage">{{ errorMessage }}</p>
<p class="mb-0" v-if="!token && !email">{{ $t('Parameter Invalid') }}</p>
<p class="mb-0" v-if="!token && email">
<span>{{ $t('Account activation link has been sent to your email address:') }}</span>
<span class="ml-1">{{ email }}</span>
<span class="ml-1">{{ $t(', If you don\'t receive the mail, fill password and click the button below to resend the verify mail.') }}</span>
</p>
</v-card-text>
<v-card-text class="pb-0 mb-6">
<v-form>
<v-row>
<v-col cols="12" v-if="!loading && !token && email && isUserVerifyEmailEnabled">
<v-text-field
autocomplete="password"
clearable
:type="isPasswordVisible ? 'text' : 'password'"
:disabled="loading || resending"
:label="$t('Password')"
:placeholder="$t('Your password')"
:append-inner-icon="isPasswordVisible ? icons.eyeSlash : icons.eye"
v-model="password"
@click:append-inner="isPasswordVisible = !isPasswordVisible"
@keyup.enter="resendEmail"
/>
</v-col>
<v-col cols="12" v-if="!loading && !token && email && isUserVerifyEmailEnabled">
<v-btn block type="submit" :disabled="loading || resending || !password" @click="resendEmail">
{{ $t('Resend Validation Email') }}
<v-progress-circular indeterminate size="24" class="ml-2" v-if="resending"></v-progress-circular>
</v-btn>
</v-col>
<v-col cols="12">
<router-link class="d-flex align-center justify-center" to="/login"
:class="{ 'disabled': loading || resending }">
<v-icon :icon="icons.left"/>
<span>{{ $t('Back to log in') }}</span>
</router-link>
</v-col>
</v-row>
</v-form>
</v-card-text>
</v-card>
</div>
<v-spacer/>
<div class="d-flex align-center justify-center">
<v-card variant="flat" class="w-100 px-4 pb-4" max-width="500">
<v-card-text class="pt-0">
<v-row>
<v-col cols="12" class="text-center">
<v-menu location="bottom">
<template #activator="{ props }">
<v-btn variant="text"
:disabled="resending"
v-bind="props">{{ currentLanguageName }}</v-btn>
</template>
<v-list>
<v-list-item v-for="(lang, locale) in allLanguages" :key="locale">
<v-list-item-title
class="cursor-pointer"
@click="changeLanguage(locale)">
{{ lang.displayName }}
</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-col>
<v-col cols="12" class="d-flex align-center pt-0">
<v-divider />
</v-col>
<v-col cols="12" class="text-center text-sm">
<span>Powered by </span>
<a href="https://github.com/mayswind/ezbookkeeping" target="_blank">ezBookkeeping</a>&nbsp;<span>{{ version }}</span>
</v-col>
</v-row>
</v-card-text>
</v-card>
</div>
</v-col>
</v-row>
<confirm-dialog ref="confirmDialog"/>
<snack-bar ref="snackbar" @update:show="onSnackbarShowStateChanged" />
</div>
</template>
<script>
import { useTheme } from 'vuetify';
import { mapStores } from 'pinia';
import { useRootStore } from '@/stores/index.js';
import { useSettingsStore } from '@/stores/setting.js';
import assetConstants from '@/consts/asset.js';
import { isUserVerifyEmailEnabled } from '@/lib/server_settings.js';
import {
mdiChevronLeft,
mdiEyeOffOutline,
mdiEyeOutline
} from '@mdi/js';
export default {
props: [
'email',
'token'
],
data() {
return {
password: '',
isPasswordVisible: false,
loading: true,
resending: false,
verified: false,
errorMessage: '',
icons: {
left: mdiChevronLeft,
eye: mdiEyeOutline,
eyeSlash: mdiEyeOffOutline
}
};
},
computed: {
...mapStores(useRootStore, useSettingsStore),
ezBookkeepingLogoPath() {
return assetConstants.ezBookkeepingLogoPath;
},
version() {
return 'v' + this.$version;
},
allLanguages() {
return this.$locale.getAllLanguageInfos();
},
isDarkMode() {
return this.globalTheme.global.name.value === 'dark';
},
currentLanguageName() {
return this.$locale.getCurrentLanguageDisplayName();
},
isUserVerifyEmailEnabled() {
return isUserVerifyEmailEnabled();
}
},
setup() {
const theme = useTheme();
return {
globalTheme: theme
};
},
created() {
const self = this;
self.verified = false;
self.loading = true;
if (!self.token) {
self.loading = false;
return;
}
self.rootStore.verifyEmail({
token: self.token,
requestNewToken: !self.$user.isUserLogined()
}).then(() => {
self.loading = false;
self.verified = true;
self.$refs.snackbar.showMessage('Email has been verified');
}).catch(error => {
self.loading = false;
self.verified = false;
if (!error.processed) {
self.errorMessage = self.$tError(error.message || error);
self.$refs.snackbar.showError(error);
}
});
},
methods: {
resendEmail() {
const self = this;
self.resending = true;
self.rootStore.resendVerifyEmailByUnloginUser({
email: self.email,
password: self.password
}).then(() => {
self.resending = false;
self.$refs.snackbar.showMessage('Validation email has been sent');
}).catch(error => {
self.resending = false;
if (!error.processed) {
self.$refs.snackbar.showError(error);
}
});
},
onSnackbarShowStateChanged(newValue) {
if (!newValue && this.verified && this.$user.isUserLogined()) {
this.$router.replace('/');
}
},
changeLanguage(locale) {
const localeDefaultSettings = this.$locale.setLanguage(locale);
this.settingsStore.updateLocalizedDefaultSettings(localeDefaultSettings);
}
}
}
</script>

View File

@@ -25,9 +25,9 @@
<span v-if="!loading">{{ oldProfile.username }}</span>
</div>
<div class="d-flex text-body-1">
<span class="me-1">{{ $t('Avatar Provider:') }}</span>
<v-skeleton-loader class="skeleton-no-margin" type="text" style="width: 100px" :loading="true" v-if="loading"></v-skeleton-loader>
<span v-if="!loading">{{ currentUserAvatarProvider }}</span>
<span class="me-1" v-if="!loading && emailVerified">{{ $t('Email has been verified') }}</span>
<span class="me-1" v-if="!loading && !emailVerified">{{ $t('Email has not been verified') }}</span>
<v-skeleton-loader class="skeleton-no-margin mt-2 mb-1" type="text" style="width: 160px" :loading="true" v-if="loading"></v-skeleton-loader>
</div>
</div>
</v-card-text>
@@ -268,6 +268,7 @@ export default {
longTimeFormat: 0,
shortTimeFormat: 0
},
emailVerified: false,
loading: true,
saving: false,
icons: {
@@ -310,13 +311,6 @@ export default {
allTransactionEditScopeTypes() {
return this.$locale.getAllTransactionEditScopeTypes();
},
currentUserAvatarProvider() {
if (this.oldProfile.avatarProvider === 'gravatar') {
return 'Gravatar';
} else {
return this.$t('None');
}
},
inputIsNotChanged() {
return !!this.inputIsNotChangedProblemMessage;
},
@@ -383,6 +377,7 @@ export default {
Promise.all(promises).then(responses => {
const profile = responses[1];
self.setCurrentUserProfile(profile);
self.emailVerified = profile.emailVerified;
self.loading = false;
}).catch(error => {
self.oldProfile.nickname = '';

View File

@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Type" content="text/html;charset=utf-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui, viewport-fit=cover">
<title>{{.VerifyEmail.Title}}</title>
</head>
<body style="margin: 0; padding: 0 10px 0 10px">
<table width="360px" border="0" cellspacing="0" cellpadding="0" style="width: 360px; border: 0; border-collapse: collapse; margin: 10px auto 5px auto;">
<tr>
<td height="50" style="font-size: 20px; line-height: 50px"><strong>{{.AppName}}</strong></td>
</tr>
<tr>
<td style="padding: 10px 0 10px 0; border-top: solid 1px #ccc">
<p>{{.VerifyEmail.Salutation}}</p>
<p>{{.VerifyEmail.DescriptionAboveBtn}}</p>
</td>
</tr>
<tr>
<td height="50" style="line-height: 50px; text-align: center">
<a href="{{.VerifyEmail.VerifyEmailUrl}}" style="width: 100%; color: #fff; background-color:#c67e48; display:block">
<strong>{{.VerifyEmail.VerifyEmail}}</strong>
</a>
</td>
</tr>
<tr>
<td style="padding: 10px 0 10px 0">
<p>{{.VerifyEmail.DescriptionBelowBtn}}</p>
</td>
</tr>
<tr>
<td style="padding-bottom: 20px">
<small style="color: #888; word-break: break-all">{{.VerifyEmail.VerifyEmailUrl}}</small>
</td>
</tr>
</table>
</body>
</html>