support reset password by email reset link

This commit is contained in:
MaysWind
2023-08-26 23:37:02 +08:00
parent c66bc62c41
commit f31ef1649f
42 changed files with 1298 additions and 30 deletions
+1
View File
@@ -32,6 +32,7 @@ WORKDIR /ezbookkeeping
COPY --from=be-builder --chown=1000:1000 /go/src/github.com/mayswind/ezbookkeeping/ezbookkeeping /ezbookkeeping/ezbookkeeping
COPY --from=fe-builder --chown=1000:1000 /go/src/github.com/mayswind/ezbookkeeping/dist /ezbookkeeping/public
COPY --chown=1000:1000 conf /ezbookkeeping/conf
COPY --chown=1000:1000 templates /ezbookkeeping/templates
COPY --chown=1000:1000 LICENSE /ezbookkeeping/LICENSE
USER 1000:1000
EXPOSE 8080
+11
View File
@@ -9,6 +9,7 @@ import (
"github.com/mayswind/ezbookkeeping/pkg/datastore"
"github.com/mayswind/ezbookkeeping/pkg/exchangerates"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/mail"
"github.com/mayswind/ezbookkeeping/pkg/settings"
"github.com/mayswind/ezbookkeeping/pkg/utils"
"github.com/mayswind/ezbookkeeping/pkg/uuid"
@@ -83,6 +84,15 @@ func initializeSystem(c *cli.Context) (*settings.Config, error) {
return nil, err
}
err = mail.InitializeMailer(config)
if err != nil {
if !isDisableBootLog {
log.BootErrorf("[initializer.initializeSystem] initializes mailer failed, because %s", err.Error())
}
return nil, err
}
err = exchangerates.InitializeExchangeRatesDataSource(config)
if err != nil {
@@ -110,6 +120,7 @@ func getConfigWithoutSensitiveData(config *settings.Config) *settings.Config {
}
clonedConfig.DatabaseConfig.DatabasePassword = "****"
clonedConfig.SmtpConfig.SmtpPasswd = "****"
clonedConfig.SecretKey = "****"
return clonedConfig
+10
View File
@@ -201,6 +201,16 @@ func startWebServer(c *cli.Context) error {
apiRoute.POST("/register.json", bindApiWithTokenUpdate(api.Users.UserRegisterHandler, config))
}
if config.EnableUserForgetPassword {
apiRoute.POST("/forget_password/request.json", bindApiWithTokenUpdate(api.ForgetPasswords.UserForgetPasswordRequestHandler, config))
resetPasswordRoute := apiRoute.Group("/forget_password/reset")
resetPasswordRoute.Use(bindMiddleware(middlewares.JWTResetPasswordAuthorization))
{
resetPasswordRoute.POST("/by_token.json", bindApiWithTokenUpdate(api.ForgetPasswords.UserResetPasswordHandler, config))
}
}
apiRoute.GET("/logout.json", bindApiWithTokenUpdate(api.Tokens.TokenRevokeCurrentHandler, config))
apiV1Route := apiRoute.Group("/v1")
+19
View File
@@ -68,6 +68,19 @@ log_query = false
# Set to true to automatically update database structure when starting web server
auto_update_database = true
[mail]
# Set to true to enable sending mail by smtp server
enable_smtp = false
# Smtp Server connection configuration
smtp_host = 127.0.0.1:25
smtp_user =
smtp_passwd =
smtp_skip_tls_verify = false
# Mail from address. This can be just an email address, or the "Name" <user@domain.com> format.
from_address =
[log]
# Either "console", "file", default is "console"
# Use space to separate multiple modes, e.g. "console file"
@@ -99,6 +112,9 @@ token_expired_time = 2592000
# Temporary token expired seconds (0 - 4294967295), default is 300 (5 minutes)
temporary_token_expired_time = 300
# Forget password token expired seconds (0 - 4294967295), default is 3600 (60 minutes)
forget_password_token_expired_time = 3600
# Add X-Request-Id header to response to track user request or error, default is true
request_id_header = true
@@ -106,6 +122,9 @@ 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 reset password by email verification code
enable_forget_password = true
# User avatar provider, supports the following types:
# "gravatar": https://gravatar.com
# Leave blank if you want to disable user avatar
+2
View File
@@ -19,6 +19,7 @@ require (
github.com/wk8/go-ordered-map/v2 v2.1.8
golang.org/x/crypto v0.12.0
gopkg.in/ini.v1 v1.67.0
gopkg.in/mail.v2 v2.3.1
xorm.io/xorm v1.3.2
)
@@ -58,6 +59,7 @@ require (
golang.org/x/sys v0.11.0 // indirect
golang.org/x/text v0.12.0 // indirect
google.golang.org/protobuf v1.30.0 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
xorm.io/builder v0.3.12 // indirect
)
+4
View File
@@ -626,6 +626,8 @@ google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqw
google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
@@ -638,6 +640,8 @@ gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o=
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk=
gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw=
gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
+126
View File
@@ -0,0 +1,126 @@
package api
import (
"time"
"github.com/mayswind/ezbookkeeping/pkg/core"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/log"
"github.com/mayswind/ezbookkeeping/pkg/models"
"github.com/mayswind/ezbookkeeping/pkg/services"
)
// ForgetPasswordsApi represents user forget password api
type ForgetPasswordsApi struct {
users *services.UserService
tokens *services.TokenService
forgetPasswords *services.ForgetPasswordService
}
// Initialize a user api singleton instance
var (
ForgetPasswords = &ForgetPasswordsApi{
users: services.Users,
tokens: services.Tokens,
forgetPasswords: services.ForgetPasswords,
}
)
// UserForgetPasswordRequestHandler generates password reset link and send user an email with this link
func (a *ForgetPasswordsApi) UserForgetPasswordRequestHandler(c *core.Context) (interface{}, *errs.Error) {
var request models.ForgetPasswordRequest
err := c.ShouldBindJSON(&request)
if err != nil {
log.WarnfWithRequestId(c, "[forget_passwords.UserForgetPasswordRequestHandler] parse request failed, because %s", err.Error())
return nil, errs.ErrEmailIsEmptyOrInvalid
}
user, err := a.users.GetUserByEmail(request.Email)
if err != nil {
if !errs.IsCustomError(err) {
log.ErrorfWithRequestId(c, "[forget_passwords.UserForgetPasswordRequestHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
token, _, err := a.tokens.CreatePasswordResetToken(user, c)
if err != nil {
log.ErrorfWithRequestId(c, "[forget_passwords.UserForgetPasswordRequestHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.ErrTokenGenerating
}
err = a.forgetPasswords.SendPasswordResetEmail(user, token)
if err != nil {
log.WarnfWithRequestId(c, "[forget_passwords.UserForgetPasswordRequestHandler] cannot send email to \"%s\", because %s", user.Email, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
return true, nil
}
// UserResetPasswordHandler resets user password by request parameters
func (a *ForgetPasswordsApi) UserResetPasswordHandler(c *core.Context) (interface{}, *errs.Error) {
var request models.PasswordResetRequest
err := c.ShouldBindJSON(&request)
if err != nil {
log.WarnfWithRequestId(c, "[forget_passwords.UserResetPasswordHandler] parse request failed, because %s", err.Error())
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
}
uid := c.GetCurrentUid()
user, err := a.users.GetUserById(uid)
if err != nil {
if !errs.IsCustomError(err) {
log.ErrorfWithRequestId(c, "[forget_passwords.UserResetPasswordHandler] failed to get user, because %s", err.Error())
}
return nil, errs.ErrUserNotFound
}
if user.Email != request.Email {
log.WarnfWithRequestId(c, "[forget_passwords.UserResetPasswordHandler] request email not equals the user email")
return nil, errs.ErrEmptyIsInvalid
}
if a.users.IsPasswordEqualsUserPassword(request.Password, user) {
oldTokenClaims := c.GetTokenClaims()
err = a.tokens.DeleteTokenByClaims(oldTokenClaims)
if err != nil {
log.WarnfWithRequestId(c, "[forget_passwords.UserResetPasswordHandler] failed to revoke password reset token \"utid:%s\" for user \"uid:%d\", because %s", oldTokenClaims.UserTokenId, user.Uid, err.Error())
}
return nil, errs.ErrNewPasswordEqualsOldInvalid
}
userNew := &models.User{
Uid: user.Uid,
Salt: user.Salt,
Password: request.Password,
}
_, err = a.users.UpdateUser(userNew, false)
if err != nil {
log.ErrorfWithRequestId(c, "[forget_passwords.UserResetPasswordHandler] failed to update user \"uid:%d\", because %s", user.Uid, err.Error())
return nil, errs.Or(err, errs.ErrOperationFailed)
}
now := time.Now().Unix()
err = a.tokens.DeleteTokensBeforeTime(uid, now)
if err == nil {
log.InfofWithRequestId(c, "[forget_passwords.UserResetPasswordHandler] revoke old tokens before unix time \"%d\" for user \"uid:%d\"", now, user.Uid)
} else {
log.WarnfWithRequestId(c, "[forget_passwords.UserResetPasswordHandler] failed to revoke old tokens for user \"uid:%d\", because %s", user.Uid, err.Error())
}
return true, nil
}
+1
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_RESET_PASSWORD TokenType = 3
)
// UserTokenClaims represents user token
+1
View File
@@ -14,6 +14,7 @@ const (
SystemSubcategoryDefault = 0
SystemSubcategorySetting = 1
SystemSubcategoryDatabase = 2
SystemSubcategoryMail = 3
)
// Sub categories of normal error
+9
View File
@@ -0,0 +1,9 @@
package errs
import "net/http"
// Error codes related to mail
var (
ErrSmtpServerNotEnabled = NewSystemError(SystemSubcategoryMail, 0, http.StatusInternalServerError, "smtp server is not enabled")
ErrSmtpServerHostInvalid = NewSystemError(SystemSubcategoryMail, 1, http.StatusInternalServerError, "smtp server host is invalid")
)
+1
View File
@@ -19,4 +19,5 @@ 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")
ErrPasswordResetTokenIsInvalidOrExpired = NewNormalError(NormalSubcategoryToken, 13, http.StatusBadRequest, "password reset token is invalid or expired")
)
+3
View File
@@ -23,4 +23,7 @@ var (
ErrUserRegistrationNotAllowed = NewNormalError(NormalSubcategoryUser, 14, http.StatusBadRequest, "user registration not allowed")
ErrUserDefaultAccountIsInvalid = NewNormalError(NormalSubcategoryUser, 15, http.StatusBadRequest, "user default account is invalid")
ErrUserIsDisabled = NewNormalError(NormalSubcategoryUser, 16, http.StatusBadRequest, "user is disabled")
ErrEmptyIsInvalid = NewNormalError(NormalSubcategoryUser, 17, http.StatusBadRequest, "email is invalid")
ErrEmailIsEmptyOrInvalid = NewNormalError(NormalSubcategoryUser, 18, http.StatusBadRequest, "email is empty or invalid")
ErrNewPasswordEqualsOldInvalid = NewNormalError(NormalSubcategoryUser, 19, http.StatusBadRequest, "new password equals old password")
)
+24
View File
@@ -0,0 +1,24 @@
package locales
// DefaultLanguage represents the default language
var DefaultLanguage = en
// AllLanguages represents all the supported language
var AllLanguages = map[string]*LocaleInfo{
"en": {
Content: en,
},
"zh-Hans": {
Content: zhHans,
},
}
func GetLocaleTextItems(locale string) *LocaleTextItems {
localeInfo, exists := AllLanguages[locale]
if exists {
return localeInfo.Content
}
return DefaultLanguage
}
+15
View File
@@ -0,0 +1,15 @@
package locales
// LocaleTextItems represents all text items need to be translated
type LocaleTextItems struct {
ForgetPasswordMailTextItems *ForgetPasswordMailTextItems
}
// ForgetPasswordMailTextItems represents text items need to be translated in forget password mail
type ForgetPasswordMailTextItems struct {
Title string
SalutationFormat string
DescriptionAboveBtn string
ResetPassword string
DescriptionBelowBtnFormat string
}
+11
View File
@@ -0,0 +1,11 @@
package locales
var en = &LocaleTextItems{
ForgetPasswordMailTextItems: &ForgetPasswordMailTextItems{
Title: "Reset Your Password",
SalutationFormat: "Hi %s,",
DescriptionAboveBtn: "We recently received a request to reset your password. You can click the below link to reset your password.",
ResetPassword: "Reset Password",
DescriptionBelowBtnFormat: "If you did not request to reset your password, please simply disregard this email. If you cannot click the above link, please copy the above url and paste it into your browser. The password reset link will be expired after %v minutes.",
},
}
+7
View File
@@ -0,0 +1,7 @@
package locales
// LocaleInfo represents locale info
type LocaleInfo struct {
Aliases []string
Content *LocaleTextItems
}
+11
View File
@@ -0,0 +1,11 @@
package locales
var zhHans = &LocaleTextItems{
ForgetPasswordMailTextItems: &ForgetPasswordMailTextItems{
Title: "重置密码",
SalutationFormat: "%s 你好,",
DescriptionAboveBtn: "我们刚才收到重置您密码的请求。您可以点击下方链接重置您的密码。",
ResetPassword: "重置密码",
DescriptionBelowBtnFormat: "如果您没有请求重置密码,请直接忽略本邮件。如果您无法点击上述链接,请复制下方的地址然后在您的浏览器中粘贴。重置密码链接将在 %v 分钟后过期。",
},
}
+63
View File
@@ -0,0 +1,63 @@
package mail
import (
"crypto/tls"
"net"
"gopkg.in/mail.v2"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/settings"
"github.com/mayswind/ezbookkeeping/pkg/utils"
)
// DefaultMailer represents default mailer
type DefaultMailer struct {
dialer *mail.Dialer
fromAddress string
}
// NewDefaultMailer returns a new default mailer
func NewDefaultMailer(smtpConfig *settings.SmtpConfig) (*DefaultMailer, error) {
host, portStr, err := net.SplitHostPort(smtpConfig.SmtpHost)
if err != nil {
return nil, errs.ErrSmtpServerHostInvalid
}
port, err := utils.StringToInt(portStr)
if err != nil {
return nil, errs.ErrSmtpServerHostInvalid
}
dialer := mail.NewDialer(host, port, smtpConfig.SmtpUser, smtpConfig.SmtpPasswd)
dialer.TLSConfig = &tls.Config{
ServerName: host,
InsecureSkipVerify: smtpConfig.SmtpSkipTLSVerify,
}
mailer := &DefaultMailer{
dialer: dialer,
fromAddress: smtpConfig.FromAddress,
}
return mailer, nil
}
// SendMail sends an email according to argument
func (m *DefaultMailer) SendMail(message *MailMessage) error {
if m.dialer == nil {
return errs.ErrSmtpServerNotEnabled
}
mailMessage := mail.NewMessage()
mailMessage.SetHeader("From", m.fromAddress)
mailMessage.SetHeader("To", message.To)
mailMessage.SetHeader("Subject", message.Subject)
mailMessage.SetBody("text/html", message.Body)
err := m.dialer.DialAndSend(mailMessage)
return err
}
+8
View File
@@ -0,0 +1,8 @@
package mail
// MailMessage represents an email entity
type MailMessage struct {
To string
Subject string
Body string
}
+6
View File
@@ -0,0 +1,6 @@
package mail
// Mailer is email sender interface
type Mailer interface {
SendMail(message *MailMessage) error
}
+37
View File
@@ -0,0 +1,37 @@
package mail
import (
"github.com/mayswind/ezbookkeeping/pkg/settings"
)
// MailerContainer contains the current mailer
type MailerContainer struct {
Current Mailer
}
// Initialize a mailer container singleton instance
var (
Container = &MailerContainer{}
)
// InitializeMailer initializes the current mailer according to the config
func InitializeMailer(config *settings.Config) error {
if !config.EnableSmtp {
Container.Current = nil
return nil
}
mailer, err := NewDefaultMailer(config.SmtpConfig)
if err != nil {
return err
}
Container.Current = mailer
return nil
}
// SendMail sends an email according to argument
func (u *MailerContainer) SendMail(message *MailMessage) error {
return u.Current.SendMail(message)
}
+19
View File
@@ -56,6 +56,25 @@ func JWTTwoFactorAuthorization(c *core.Context) {
c.Next()
}
// JWTResetPasswordAuthorization verifies whether current request is password reset
func JWTResetPasswordAuthorization(c *core.Context) {
claims, err := getTokenClaims(c, TOKEN_SOURCE_TYPE_ARGUMENT)
if err != nil {
utils.PrintJsonErrorResult(c, errs.ErrPasswordResetTokenIsInvalidOrExpired)
return
}
if claims.Type != core.USER_TOKEN_TYPE_RESET_PASSWORD {
log.WarnfWithRequestId(c, "[authorization.JWTResetPasswordAuthorization] user \"uid:%d\" token is not for password request", claims.Uid)
utils.PrintJsonErrorResult(c, errs.ErrCurrentInvalidToken)
return
}
c.SetTokenClaims(claims)
c.Next()
}
func jwtAuthorization(c *core.Context, source TokenSourceType) {
claims, err := getTokenClaims(c, source)
@@ -17,6 +17,7 @@ func ServerSettingsCookie(config *settings.Config) core.MiddlewareHandlerFunc {
return func(c *core.Context) {
settingsArr := []string{
buildBooleanSetting("r", config.EnableUserRegister),
buildBooleanSetting("f", config.EnableUserForgetPassword),
buildBooleanSetting("e", config.EnableDataExport),
buildStringSetting("m", strings.Replace(config.MapProvider, "_", "-", -1)),
}
+12
View File
@@ -0,0 +1,12 @@
package models
// ForgetPasswordRequest represents all parameters of forget password request
type ForgetPasswordRequest struct {
Email string `json:"email" binding:"required,notBlank,max=100,validEmail"`
}
// PasswordResetRequest represents all parameters of reset password request
type PasswordResetRequest struct {
Email string `json:"email" binding:"required,notBlank,max=100,validEmail"`
Password string `json:"password" binding:"required,min=6,max=128"`
}
+16
View File
@@ -2,6 +2,8 @@ package services
import (
"github.com/mayswind/ezbookkeeping/pkg/datastore"
"github.com/mayswind/ezbookkeeping/pkg/errs"
"github.com/mayswind/ezbookkeeping/pkg/mail"
"github.com/mayswind/ezbookkeeping/pkg/settings"
"github.com/mayswind/ezbookkeeping/pkg/uuid"
)
@@ -36,6 +38,20 @@ func (s *ServiceUsingConfig) CurrentConfig() *settings.Config {
return s.container.Current
}
// ServiceUsingMailer represents a service that need to use mailer
type ServiceUsingMailer struct {
container *mail.MailerContainer
}
// SendMail sends an email according to argument
func (s *ServiceUsingMailer) SendMail(message *mail.MailMessage) error {
if s.container.Current == nil {
return errs.ErrSmtpServerNotEnabled
}
return s.container.Current.SendMail(message)
}
// ServiceUsingUuid represents a service that need to use uuid
type ServiceUsingUuid struct {
container *uuid.UuidContainer
+81
View File
@@ -0,0 +1,81 @@
package services
import (
"bytes"
"fmt"
"net/url"
"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"
)
const passwordResetUrlFormat = "%sdesktop/#/resetpassword?token=%s"
// ForgetPasswordService represents forget password service
type ForgetPasswordService struct {
ServiceUsingConfig
ServiceUsingMailer
}
// Initialize a forget password service singleton instance
var (
ForgetPasswords = &ForgetPasswordService{
ServiceUsingConfig: ServiceUsingConfig{
container: settings.Container,
},
ServiceUsingMailer: ServiceUsingMailer{
container: mail.Container,
},
}
)
// SendPasswordResetEmail sends password reset email according to specified parameters
func (s *ForgetPasswordService) SendPasswordResetEmail(user *models.User, passwordResetToken string) error {
if !s.CurrentConfig().EnableSmtp {
return errs.ErrSmtpServerNotEnabled
}
localeTextItems := locales.GetLocaleTextItems(user.Language)
forgetPasswordTextItems := localeTextItems.ForgetPasswordMailTextItems
expireTimeInMinutes := s.CurrentConfig().ForgetPasswordTokenExpiredTimeDuration.Minutes()
passwordResetUrl := fmt.Sprintf(passwordResetUrlFormat, s.CurrentConfig().RootUrl, url.QueryEscape(passwordResetToken))
tmpl, err := templates.GetTemplate("email/password_reset")
if err != nil {
return err
}
templateParams := map[string]interface{}{
"ForgetPasswordMail": map[string]interface{}{
"Title": forgetPasswordTextItems.Title,
"Salutation": fmt.Sprintf(forgetPasswordTextItems.SalutationFormat, user.Nickname),
"DescriptionAboveBtn": forgetPasswordTextItems.DescriptionAboveBtn,
"ResetPasswordUrl": passwordResetUrl,
"ResetPassword": forgetPasswordTextItems.ResetPassword,
"DescriptionBelowBtn": fmt.Sprintf(forgetPasswordTextItems.DescriptionBelowBtnFormat, expireTimeInMinutes),
},
}
var bodyBuffer bytes.Buffer
err = tmpl.Execute(&bodyBuffer, templateParams)
if err != nil {
return err
}
message := &mail.MailMessage{
To: user.Email,
Subject: forgetPasswordTextItems.Title,
Body: bodyBuffer.String(),
}
err = s.SendMail(message)
return err
}
+5
View File
@@ -88,6 +88,11 @@ func (s *TokenService) CreateRequire2FAToken(user *models.User, ctx *core.Contex
return s.createToken(user, core.USER_TOKEN_TYPE_REQUIRE_2FA, s.getUserAgent(ctx), s.CurrentConfig().TemporaryTokenExpiredTimeDuration)
}
// CreatePasswordResetToken generates a new password reset token and saves to database
func (s *TokenService) CreatePasswordResetToken(user *models.User, ctx *core.Context) (string, *core.UserTokenClaims, error) {
return s.createToken(user, core.USER_TOKEN_TYPE_RESET_PASSWORD, s.getUserAgent(ctx), s.CurrentConfig().ForgetPasswordTokenExpiredTimeDuration)
}
// DeleteToken deletes given token from database
func (s *TokenService) DeleteToken(tokenRecord *models.TokenRecord) error {
if tokenRecord.Uid <= 0 {
+43
View File
@@ -116,6 +116,7 @@ const (
defaultSecretKey string = "ezbookkeeping"
defaultTokenExpiredTime uint32 = 604800 // 7 days
defaultTemporaryTokenExpiredTime uint32 = 300 // 5 minutes
defaultForgetPasswordTokenExpiredTime uint32 = 3600 // 60 minutes
defaultExchangeRatesDataRequestTimeout uint32 = 10000 // 10 seconds
)
@@ -137,6 +138,15 @@ type DatabaseConfig struct {
ConnectionMaxLifeTime uint32
}
// SmtpConfig represents the smtp setting config
type SmtpConfig struct {
SmtpHost string
SmtpUser string
SmtpPasswd string
SmtpSkipTLSVerify bool
FromAddress string
}
// Config represents the global setting config
type Config struct {
// Global
@@ -167,6 +177,10 @@ type Config struct {
EnableQueryLog bool
AutoUpdateDatabase bool
// Mail
EnableSmtp bool
SmtpConfig *SmtpConfig
// Log
LogModes []string
EnableConsoleLog bool
@@ -186,10 +200,13 @@ type Config struct {
TokenExpiredTimeDuration time.Duration
TemporaryTokenExpiredTime uint32
TemporaryTokenExpiredTimeDuration time.Duration
ForgetPasswordTokenExpiredTime uint32
ForgetPasswordTokenExpiredTimeDuration time.Duration
EnableRequestIdHeader bool
// User
EnableUserRegister bool
EnableUserForgetPassword bool
AvatarProvider string
// Data
@@ -246,6 +263,12 @@ func LoadConfiguration(configFilePath string) (*Config, error) {
return nil, err
}
err = loadMailConfiguration(config, cfgFile, "mail")
if err != nil {
return nil, err
}
err = loadLogConfiguration(config, cfgFile, "log")
if err != nil {
@@ -394,6 +417,22 @@ func loadDatabaseConfiguration(config *Config, configFile *ini.File, sectionName
return nil
}
func loadMailConfiguration(config *Config, configFile *ini.File, sectionName string) error {
config.EnableSmtp = getConfigItemBoolValue(configFile, sectionName, "enable_smtp", false)
smtpConfig := &SmtpConfig{}
smtpConfig.SmtpHost = getConfigItemStringValue(configFile, sectionName, "smtp_host")
smtpConfig.SmtpUser = getConfigItemStringValue(configFile, sectionName, "smtp_user")
smtpConfig.SmtpPasswd = getConfigItemStringValue(configFile, sectionName, "smtp_passwd")
smtpConfig.SmtpSkipTLSVerify = getConfigItemBoolValue(configFile, sectionName, "smtp_skip_tls_verify", false)
smtpConfig.FromAddress = getConfigItemStringValue(configFile, sectionName, "from_address")
config.SmtpConfig = smtpConfig
return nil
}
func loadLogConfiguration(config *Config, configFile *ini.File, sectionName string) error {
config.LogModes = strings.Split(getConfigItemStringValue(configFile, sectionName, "mode", defaultLogMode), " ")
@@ -442,6 +481,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.ForgetPasswordTokenExpiredTime = getConfigItemUint32Value(configFile, sectionName, "forget_password_token_expired_time", defaultForgetPasswordTokenExpiredTime)
config.ForgetPasswordTokenExpiredTimeDuration = time.Duration(config.ForgetPasswordTokenExpiredTime) * time.Second
config.EnableRequestIdHeader = getConfigItemBoolValue(configFile, sectionName, "request_id_header", true)
return nil
@@ -449,6 +491,7 @@ 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.EnableUserForgetPassword = getConfigItemBoolValue(configFile, sectionName, "enable_forget_password", false)
if getConfigItemStringValue(configFile, sectionName, "avatar_provider") == "" {
config.AvatarProvider = ""
+42
View File
@@ -0,0 +1,42 @@
package templates
import (
"fmt"
"html/template"
"path/filepath"
)
const templateBasePath = "templates"
const templateFileExtension = "tmpl"
var templateCache = make(map[string]*CachedTemplate)
// CachedTemplate represents a cached template
type CachedTemplate struct {
templateName string
templateContent *template.Template
}
// GetTemplate returns a cached template instance according to the template name
func GetTemplate(templateName string) (*template.Template, error) {
fullPath := filepath.Join(templateBasePath, fmt.Sprintf("%s.%s", templateName, templateFileExtension))
cachedTemplate, exists := templateCache[templateName]
if exists {
return cachedTemplate.templateContent, nil
}
tmpl, err := template.ParseFiles(fullPath)
if err != nil {
return nil, err
}
templateCache[templateName] = &CachedTemplate{
templateName: templateName,
templateContent: tmpl,
}
return tmpl, err
}
+4
View File
@@ -1,3 +1,5 @@
const defaultTimeout = 10000; // 10s
const requestForgetPasswordTimeout = 30000; // 30s
const baseApiUrlPath = '/api';
const baseQrcodePath = '/qrcode';
const baseProxyUrlPath = '/proxy';
@@ -7,6 +9,8 @@ const baiduMapJavascriptUrl = 'https://api.map.baidu.com/api?v=3.0';
const amapJavascriptUrl = 'https://webapi.amap.com/maps?v=2.0';
export default {
defaultTimeout: defaultTimeout,
requestForgetPasswordTimeout: requestForgetPasswordTimeout,
baseApiUrlPath: baseApiUrlPath,
baseQrcodePath: baseQrcodePath,
baseProxyUrlPath: baseProxyUrlPath,
+4
View File
@@ -33,6 +33,10 @@ export function isUserRegistrationEnabled() {
return getServerSetting('r') === '1';
}
export function isUserForgetPasswordEnabled() {
return getServerSetting('f') === '1';
}
export function isDataExportingEnabled() {
return getServerSetting('e') === '1';
}
+16 -1
View File
@@ -13,7 +13,7 @@ let needBlockRequest = false;
let blockedRequests = [];
axios.defaults.baseURL = api.baseApiUrlPath;
axios.defaults.timeout = 10000; // 10s
axios.defaults.timeout = api.defaultTimeout;
axios.interceptors.request.use(config => {
const token = userState.getToken();
@@ -102,6 +102,21 @@ export default {
firstDayOfWeek
});
},
requestResetPassword: ({ email }) => {
return axios.post('forget_password/request.json', {
email
}, {
timeout: api.requestForgetPasswordTimeout
});
},
resetPassword: ({ email, token, password }) => {
return axios.post('forget_password/reset/by_token.json?token=' + token, {
email,
password
}, {
ignoreError: true
});
},
logout: () => {
return axios.get('logout.json');
},
+16
View File
@@ -558,6 +558,7 @@ export default {
'api not found': 'Failed to request api',
'not implemented': 'Not implemented',
'database operation failed': 'Database operation failed',
'smtp server is not enabled': 'Smtp server is not enabled',
'incomplete or incorrect submission': 'Incomplete or incorrect submission',
'operation failed': 'Operation failed',
'nothing will be updated': 'Nothing will be updated',
@@ -580,6 +581,9 @@ export default {
'login name or password is invalid': 'Login name or password is invalid',
'login name or password is wrong': 'Login name or password is wrong',
'user is disabled': 'User is disabled',
'email is invalid': 'Email is invalid',
'email is empty or invalid': 'Email is empty or invalid',
'new password equals old password': 'New password equals old password',
'unauthorized access': 'Unauthorized access',
'current token is invalid': 'Current token is invalid',
'current token is expired': 'Current token is expired',
@@ -592,6 +596,7 @@ export default {
'token is not found': 'Token is not found',
'token is expired': 'Token is expired',
'token is empty': 'Token is empty',
'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',
'two factor is not enabled': 'Two factor is not enabled',
@@ -812,7 +817,9 @@ export default {
'This year or later': 'This year or later',
'Log In': 'Log In',
'Click here to log in': 'Click here to log in',
'Back to log in': 'Back to log in',
'Don\'t have an account?': 'Don\'t have an account?',
'Forget Password?': 'Forget Password?',
'Create an account': 'Create an account',
'Username cannot be empty': 'Username cannot be empty',
'Password cannot be empty': 'Password cannot be empty',
@@ -839,6 +846,15 @@ export default {
'Use a passcode': 'Use a passcode',
'PIN code is invalid': 'PIN code is invalid',
'PIN code is wrong': 'PIN code is wrong',
'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',
'Unable to send password reset email': 'Unable to send password reset email',
'Reset Password': 'Reset Password',
'Update Password': 'Update Password',
'Please input your email address again, and input the new password.': 'Please input your email address again, and input the new password.',
'Password has been updated': 'Password has been updated',
'Unable to reset password': 'Unable to reset password',
'Sign Up': 'Sign Up',
'Overview': 'Overview',
'Asset Summary': 'Asset Summary',
+16
View File
@@ -558,6 +558,7 @@ export default {
'api not found': '接口调用失败',
'not implemented': '未实现',
'database operation failed': '数据库操作失败',
'smtp server is not enabled': 'Smtp 服务器没有启用',
'incomplete or incorrect submission': '提交不完整或不正确',
'operation failed': '操作失败',
'nothing will be updated': '没有内容更新',
@@ -580,6 +581,9 @@ export default {
'login name or password is invalid': '登录名或密码无效',
'login name or password is wrong': '登录名或密码错误',
'user is disabled': '用户已禁用',
'email is invalid': '邮箱无效',
'email is empty or invalid': '邮箱为空或无效',
'new password equals old password': '新密码与旧密码相同',
'unauthorized access': '未授权的登录',
'current token is invalid': '当前认证令牌无效',
'current token is expired': '当前认证令牌已过期',
@@ -592,6 +596,7 @@ export default {
'token is not found': '认证令牌不存在',
'token is expired': '认证令牌已过期',
'token is empty': '认证令牌为空',
'password reset token is invalid or expired': '密码重置令牌无效或已过期',
'passcode is invalid': '验证码无效',
'two factor backup code is invalid': '两步验证备用码无效',
'two factor is not enabled': '两步验证没有启用',
@@ -812,7 +817,9 @@ export default {
'This year or later': '今年或更晚',
'Log In': '登录',
'Click here to log in': '点击这里登录',
'Back to log in': '返回登录页',
'Don\'t have an account?': '还没有账号?',
'Forget Password?': '忘记密码?',
'Create an account': '创建新账号',
'Username cannot be empty': '用户名不能为空',
'Password cannot be empty': '密码不能为空',
@@ -839,6 +846,15 @@ export default {
'Use a passcode': '使用验证码',
'PIN code is invalid': 'PIN码无效',
'PIN code is wrong': 'PIN码错误',
'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': '重置密码邮件已发送',
'Unable to send password reset email': '无法发送重置密码邮件',
'Reset Password': '重置密码',
'Update Password': '更新密码',
'Please input your email address again, and input the new password.': '请再次输入您的邮箱,然后输入新的密码。',
'Password has been updated': '密码已经更新',
'Unable to reset password': '无法重置密码',
'Sign Up': '注册',
'Overview': '总览',
'Asset Summary': '资产概要',
+14
View File
@@ -5,6 +5,8 @@ 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 ForgetPasswordPage from '@/views/desktop/ForgetPasswordPage.vue';
import ResetPasswordPage from '@/views/desktop/ResetPasswordPage.vue';
import UnlockPage from '@/views/desktop/UnlockPage.vue';
import HomePage from '@/views/desktop/HomePage.vue';
@@ -157,6 +159,18 @@ const router = createRouter({
component: SignUpPage,
beforeEnter: checkNotLogin
},
{
path: '/forgetpassword',
component: ForgetPasswordPage,
beforeEnter: checkNotLogin
},
{
path: '/resetpassword',
component: ResetPasswordPage,
props: route => ({
token: route.query.token
})
},
{
path: '/unlock',
component: UnlockPage,
+54
View File
@@ -247,6 +247,60 @@ export const useRootStore = defineStore('root', {
userState.clearWebAuthnConfig();
this.resetAllStates(true);
},
requestResetPassword({ email }) {
return new Promise((resolve, reject) => {
services.requestResetPassword({
email
}).then(response => {
const data = response.data;
if (!data || !data.success || !data.result) {
reject({ message: 'Unable to send password reset email' });
return;
}
resolve(data.result);
}).catch(error => {
logger.error('failed to send password reset 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 send password reset email' });
}
});
});
},
resetPassword({ email, token, password }) {
return new Promise((resolve, reject) => {
services.resetPassword({
token,
email,
password
}).then(response => {
const data = response.data;
if (!data || !data.success || !data.result) {
reject({ message: 'Unable to reset password' });
return;
}
resolve(data.result);
}).catch(error => {
logger.error('failed to reset password', 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 reset password' });
}
});
});
},
updateUserProfile({ profile, currentPassword }) {
return new Promise((resolve, reject) => {
services.updateProfile({
+187
View File
@@ -0,0 +1,187 @@
<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="600px" src="img/desktop/people4.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('Forget Password?') }}</h5>
<p class="mb-0">{{ $t('Please input your email address used for registration and we\'ll send you an email with reset password link') }}</p>
</v-card-text>
<v-card-text class="pb-0 mb-6">
<v-form>
<v-row>
<v-col cols="12">
<v-text-field
type="email"
autocomplete="email"
autofocus="autofocus"
clearable
:disabled="requesting"
:label="$t('E-mail')"
:placeholder="$t('Your email address')"
v-model="email"
@keyup.enter="requestResetPassword"
/>
</v-col>
<v-col cols="12">
<v-btn block type="submit" :disabled="!email || requesting" @click="requestResetPassword">
{{ $t('Send Reset Link') }}
<v-progress-circular indeterminate size="24" class="ml-2" v-if="requesting"></v-progress-circular>
</v-btn>
</v-col>
<v-col cols="12">
<router-link class="d-flex align-center justify-center" to="/login"
:class="{ 'disabled': requesting }">
<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="requesting"
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" />
</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 {
mdiChevronLeft,
} from '@mdi/js';
export default {
data() {
return {
email: '',
requesting: false,
icons: {
left: mdiChevronLeft
}
};
},
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();
}
},
setup() {
const theme = useTheme();
return {
globalTheme: theme
};
},
methods: {
requestResetPassword() {
const self = this;
if (!self.email) {
self.$refs.snackbar.showMessage('Email address cannot be empty');
return;
}
self.requesting = true;
self.rootStore.requestResetPassword({
email: self.email
}).then(() => {
self.requesting = false;
self.$refs.snackbar.showMessage('Password reset email has been sent');
}).catch(error => {
self.requesting = false;
if (!error.processed) {
self.$refs.snackbar.showError(error);
}
});
},
changeLanguage(locale) {
const localeDefaultSettings = this.$locale.setLanguage(locale);
this.settingsStore.updateLocalizedDefaultSettings(localeDefaultSettings);
}
}
}
</script>
+9 -1
View File
@@ -95,6 +95,11 @@
<a href="javascript:void(0);" @click="showMobileQrCode = true">
<span class="nav-item-title">{{ $t('Use on Mobile Device') }}</span>
</a>
<v-spacer/>
<router-link class="text-primary" to="/forgetpassword"
:class="{'disabled': !isUserForgetPasswordEnabled}">
{{ $t('Forget Password?') }}
</router-link>
</div>
</v-col>
@@ -176,7 +181,7 @@ import { useSettingsStore } from '@/stores/setting.js';
import { useExchangeRatesStore } from '@/stores/exchangeRates.js';
import assetConstants from '@/consts/asset.js';
import { isUserRegistrationEnabled } from '@/lib/server_settings.js';
import { isUserRegistrationEnabled, isUserForgetPasswordEnabled } from '@/lib/server_settings.js';
import {
mdiEyeOutline,
@@ -221,6 +226,9 @@ export default {
isUserRegistrationEnabled() {
return isUserRegistrationEnabled();
},
isUserForgetPasswordEnabled() {
return isUserForgetPasswordEnabled();
},
inputIsEmpty() {
return !this.username || !this.password;
},
+248
View File
@@ -0,0 +1,248 @@
<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="600px" src="img/desktop/people4.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('Reset Password') }}</h5>
<p class="mb-0">{{ $t('Please input your email address again, and input the new password.') }}</p>
</v-card-text>
<v-card-text class="pb-0 mb-6">
<v-form>
<v-row>
<v-col cols="12">
<v-text-field
type="email"
autocomplete="email"
autofocus="autofocus"
clearable
:disabled="updating"
:label="$t('E-mail')"
:placeholder="$t('Your email address')"
v-model="email"
@keyup.enter="$refs.passwordInput.focus()"
/>
</v-col>
<v-col cols="12">
<v-text-field
autocomplete="new-password"
clearable
ref="passwordInput"
:type="isNewPasswordVisible ? 'text' : 'password'"
:disabled="updating"
:label="$t('Password')"
:placeholder="$t('Your password')"
:append-inner-icon="isNewPasswordVisible ? icons.eyeSlash : icons.eye"
v-model="newPassword"
@click:append-inner="isNewPasswordVisible = !isNewPasswordVisible"
@keyup.enter="$refs.confirmPasswordInput.focus()"
/>
</v-col>
<v-col cols="12">
<v-text-field
clearable
ref="confirmPasswordInput"
:type="isConfirmPasswordVisible ? 'text' : 'password'"
:disabled="updating"
:label="$t('Confirmation Password')"
:placeholder="$t('Re-enter the password')"
:append-inner-icon="isConfirmPasswordVisible ? icons.eyeSlash : icons.eye"
v-model="confirmPassword"
@click:append-inner="isConfirmPasswordVisible = !isConfirmPasswordVisible"
@keyup.enter="resetPassword"
/>
</v-col>
<v-col cols="12">
<v-btn block :disabled="!email || !newPassword || !confirmPassword || updating" @click="resetPassword">
{{ $t('Update Password') }}
<v-progress-circular indeterminate size="24" class="ml-2" v-if="updating"></v-progress-circular>
</v-btn>
</v-col>
<v-col cols="12">
<router-link class="d-flex align-center justify-center" to="/login"
:class="{ 'disabled': updating }">
<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="updating"
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" />
</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 {
mdiChevronLeft,
mdiEyeOffOutline,
mdiEyeOutline
} from '@mdi/js';
export default {
props: [
'token'
],
data() {
return {
email: '',
newPassword: '',
confirmPassword: '',
isNewPasswordVisible: false,
isConfirmPasswordVisible: false,
updating: false,
icons: {
left: mdiChevronLeft,
eye: mdiEyeOutline,
eyeSlash: mdiEyeOffOutline
}
};
},
computed: {
...mapStores(useRootStore, useSettingsStore),
inputProblemMessage() {
if (!this.email) {
return 'Email address cannot be empty';
} else if (!this.newPassword && !this.confirmPassword) {
return 'Nothing has been modified';
} else if (!this.newPassword && this.confirmPassword) {
return 'New password cannot be empty';
} else if (this.newPassword && !this.confirmPassword) {
return 'Confirmation password cannot be empty';
} else if (this.newPassword && this.confirmPassword && this.newPassword !== this.confirmPassword) {
return 'Password and confirmation password do not match';
} else {
return null;
}
},
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();
}
},
setup() {
const theme = useTheme();
return {
globalTheme: theme
};
},
methods: {
resetPassword() {
const self = this;
const problemMessage = self.inputProblemMessage;
if (problemMessage) {
self.$refs.snackbar.showMessage(problemMessage);
return;
}
self.updating = true;
self.rootStore.resetPassword({
token: self.token,
email: self.email,
password: self.newPassword
}).then(() => {
self.updating = false;
self.$refs.snackbar.showMessage('Password has been updated');
}).catch(error => {
self.updating = false;
if (!error.processed) {
self.$refs.snackbar.showError(error);
}
});
},
changeLanguage(locale) {
const localeDefaultSettings = this.$locale.setLanguage(locale);
this.settingsStore.updateLocalizedDefaultSettings(localeDefaultSettings);
}
}
}
</script>
+68 -1
View File
@@ -35,6 +35,9 @@
</small>
</template>
<template #after>
<small>
<f7-link :class="{'disabled': !isUserForgetPasswordEnabled}" @click="showForgetPasswordSheet = true">{{ $t('Forget Password?') }}</f7-link>
</small>
</template>
</f7-list-item>
</f7-list>
@@ -127,6 +130,37 @@
</div>
</f7-page-content>
</f7-sheet>
<f7-sheet
style="height:auto"
:opened="showForgetPasswordSheet" @sheet:closed="showForgetPasswordSheet = false"
>
<f7-page-content>
<div class="display-flex padding justify-content-space-between align-items-center">
<div class="ebk-sheet-title"><b>{{ $t('Forget Password?') }}</b></div>
</div>
<div class="padding-horizontal padding-bottom">
<p class="no-margin">
<span>{{ $t('Please input your email address used for registration and we\'ll send you an email with reset password link') }}</span>
</p>
<f7-list strong class="no-margin">
<f7-list-input
type="email"
autocomplete="email"
outline
floating-label
clear-button
class="no-margin no-padding-bottom"
:label="$t('E-mail')"
:placeholder="$t('Your email address')"
v-model:value="forgetPasswordEmail"
@keyup.enter="requestResetPassword"
></f7-list-input>
</f7-list>
<f7-button large fill :class="{ 'disabled': !forgetPasswordEmail || requestingForgetPassword }" :text="$t('Send Reset Link')" @click="requestResetPassword"></f7-button>
</div>
</f7-page-content>
</f7-sheet>
</f7-page>
</template>
@@ -137,7 +171,7 @@ import { useSettingsStore } from '@/stores/setting.js';
import { useExchangeRatesStore } from '@/stores/exchangeRates.js';
import assetConstants from '@/consts/asset.js';
import { isUserRegistrationEnabled } from '@/lib/server_settings.js';
import { isUserRegistrationEnabled, isUserForgetPasswordEnabled } from '@/lib/server_settings.js';
import { getDesktopVersionPath } from '@/lib/version.js';
import { isModalShowing } from '@/lib/ui.mobile.js';
@@ -152,9 +186,12 @@ export default {
passcode: '',
backupCode: '',
tempToken: '',
forgetPasswordEmail: '',
logining: false,
verifying: false,
requestingForgetPassword: false,
show2faSheet: false,
showForgetPasswordSheet: false,
twoFAVerifyType: 'passcode'
};
},
@@ -175,6 +212,9 @@ export default {
isUserRegistrationEnabled() {
return isUserRegistrationEnabled();
},
isUserForgetPasswordEnabled() {
return isUserForgetPasswordEnabled();
},
inputIsEmpty() {
return !this.username || !this.password;
},
@@ -308,6 +348,33 @@ export default {
}
});
},
requestResetPassword() {
const self = this;
if (!self.forgetPasswordEmail) {
self.$alert('Email address cannot be empty');
return;
}
self.requestingForgetPassword = true;
self.$showLoading(() => self.requestingForgetPassword);
self.rootStore.requestResetPassword({
email: self.forgetPasswordEmail
}).then(() => {
self.requestingForgetPassword = false;
self.$hideLoading();
self.$toast('Password reset email has been sent');
}).catch(error => {
self.requestingForgetPassword = false;
self.$hideLoading();
if (!error.processed) {
self.$toast(error.message || error);
}
});
},
switch2FAVerifyType() {
if (this.twoFAVerifyType === 'passcode') {
this.twoFAVerifyType = 'backupcode';
+40
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>{{.ForgetPasswordMail.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>ezBookkeeping</strong></td>
</tr>
<tr>
<td style="padding: 10px 0 10px 0; border-top: solid 1px #ccc">
<p>{{.ForgetPasswordMail.Salutation}}</p>
<p>{{.ForgetPasswordMail.DescriptionAboveBtn}}</p>
</td>
</tr>
<tr>
<td height="50" style="line-height: 50px; text-align: center">
<a href="{{.ForgetPasswordMail.ResetPasswordUrl}}" style="width: 100%; color: #fff; background-color:#c67e48; display:block">
<strong>{{.ForgetPasswordMail.ResetPassword}}</strong>
</a>
</td>
</tr>
<tr>
<td style="padding: 10px 0 10px 0">
<p>{{.ForgetPasswordMail.DescriptionBelowBtn}}</p>
</td>
</tr>
<tr>
<td style="padding-bottom: 20px">
<small style="color: #888; word-break: break-all">{{.ForgetPasswordMail.ResetPasswordUrl}}</small>
</td>
</tr>
</table>
</body>
</html>
+6
View File
@@ -81,6 +81,12 @@
"url": "https://golang.org/x/crypto",
"licenseUrl": "https://cs.opensource.google/go/x/crypto/+/refs/tags/v0.12.0:LICENSE"
},
{
"name": "Gomail",
"copyright": "Copyright (c) 2014 Alexandre Cesaro",
"url": "https://github.com/go-mail/mail",
"licenseUrl": "https://github.com/go-mail/mail/blob/v2.3.1/LICENSE"
},
{
"name": "barcode",
"copyright": "Copyright (c) 2014 Florian Sundermann",