support Nextcloud OAuth 2.0 authentication
This commit is contained in:
@@ -149,5 +149,13 @@ func updateAllDatabaseTablesStructure(c *core.CliContext) error {
|
||||
|
||||
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] user application cloud settings table maintained successfully")
|
||||
|
||||
err = datastore.Container.UserDataStore.SyncStructs(new(models.UserExternalAuth))
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.BootInfof(c, "[database.updateAllDatabaseTablesStructure] user external auth table maintained successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -200,5 +200,9 @@ func getConfigWithoutSensitiveData(config *settings.Config) *settings.Config {
|
||||
}
|
||||
}
|
||||
|
||||
if clonedConfig.OAuth2ClientSecret != "" {
|
||||
clonedConfig.OAuth2ClientSecret = "****"
|
||||
}
|
||||
|
||||
return clonedConfig
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/urfave/cli/v3"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/api"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/cron"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
@@ -72,6 +73,13 @@ func startWebServer(c *core.CliContext) error {
|
||||
return err
|
||||
}
|
||||
|
||||
err = oauth2.InitializeOAuth2Provider(config)
|
||||
|
||||
if err != nil {
|
||||
log.BootErrorf(c, "[webserver.startWebServer] initializes oauth 2.0 provider failed, because %s", err.Error())
|
||||
return err
|
||||
}
|
||||
|
||||
err = cron.InitializeCronJobSchedulerContainer(c, config, true)
|
||||
|
||||
if err != nil {
|
||||
@@ -242,14 +250,26 @@ func startWebServer(c *core.CliContext) error {
|
||||
}
|
||||
}
|
||||
|
||||
if config.EnableOAuth2Login {
|
||||
oauth2Route := router.Group("/oauth2")
|
||||
oauth2Route.Use(bindMiddleware(middlewares.RequestId(config)))
|
||||
oauth2Route.Use(bindMiddleware(middlewares.RequestLog))
|
||||
{
|
||||
oauth2Route.GET("/login", bindRedirect(api.OAuth2Authentications.LoginHandler))
|
||||
oauth2Route.GET("/callback", bindRedirect(api.OAuth2Authentications.CallbackHandler))
|
||||
}
|
||||
}
|
||||
|
||||
apiRoute := router.Group("/api")
|
||||
|
||||
apiRoute.Use(bindMiddleware(middlewares.RequestId(config)))
|
||||
apiRoute.Use(bindMiddleware(middlewares.RequestLog))
|
||||
{
|
||||
apiRoute.POST("/authorize.json", bindApiWithTokenUpdate(api.Authorizations.AuthorizeHandler, config))
|
||||
if config.EnableInternalAuth {
|
||||
apiRoute.POST("/authorize.json", bindApiWithTokenUpdate(api.Authorizations.AuthorizeHandler, config))
|
||||
}
|
||||
|
||||
if config.EnableTwoFactor {
|
||||
if config.EnableInternalAuth && config.EnableTwoFactor {
|
||||
twoFactorRoute := apiRoute.Group("/2fa")
|
||||
twoFactorRoute.Use(bindMiddleware(middlewares.JWTTwoFactorAuthorization))
|
||||
{
|
||||
@@ -258,7 +278,15 @@ func startWebServer(c *core.CliContext) error {
|
||||
}
|
||||
}
|
||||
|
||||
if config.EnableUserRegister {
|
||||
if config.EnableOAuth2Login {
|
||||
oauth2Route := apiRoute.Group("/oauth2")
|
||||
oauth2Route.Use(bindMiddleware(middlewares.JWTOAuth2CallbackAuthorization))
|
||||
{
|
||||
oauth2Route.POST("/authorize.json", bindApiWithTokenUpdate(api.Authorizations.OAuth2CallbackAuthorizeHandler, config))
|
||||
}
|
||||
}
|
||||
|
||||
if config.EnableInternalAuth && config.EnableUserRegister {
|
||||
apiRoute.POST("/register.json", bindApiWithTokenUpdate(api.Users.UserRegisterHandler, config))
|
||||
}
|
||||
|
||||
@@ -272,7 +300,7 @@ func startWebServer(c *core.CliContext) error {
|
||||
}
|
||||
}
|
||||
|
||||
if config.EnableUserForgetPassword {
|
||||
if config.EnableInternalAuth && config.EnableUserForgetPassword {
|
||||
apiRoute.POST("/forget_password/request.json", bindApi(api.ForgetPasswords.UserForgetPasswordRequestHandler))
|
||||
|
||||
resetPasswordRoute := apiRoute.Group("/forget_password/reset")
|
||||
@@ -444,6 +472,19 @@ func bindMiddleware(fn core.MiddlewareHandlerFunc) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func bindRedirect(fn core.RedirectHandlerFunc) gin.HandlerFunc {
|
||||
return func(ginCtx *gin.Context) {
|
||||
c := core.WrapWebContext(ginCtx)
|
||||
url, err := fn(c)
|
||||
|
||||
if err != nil {
|
||||
utils.PrintJsonErrorResult(c, err)
|
||||
} else {
|
||||
c.Redirect(http.StatusFound, url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func bindApi(fn core.ApiHandlerFunc) gin.HandlerFunc {
|
||||
return func(ginCtx *gin.Context) {
|
||||
c := core.WrapWebContext(ginCtx)
|
||||
|
||||
@@ -270,15 +270,52 @@ max_failures_per_ip_per_minute = 5
|
||||
max_failures_per_user_per_minute = 5
|
||||
|
||||
[auth]
|
||||
# Set to true to enable two-factor authorization
|
||||
# Set to true to enable internal authentication
|
||||
enable_internal_auth = true
|
||||
|
||||
# Set to true to enable OAuth 2.0 authentication
|
||||
enable_oauth2_auth = false
|
||||
|
||||
# For "internal" authentication only, set to true to enable two-factor authorization
|
||||
enable_two_factor = true
|
||||
|
||||
# Set to true to allow users to reset password
|
||||
# For "internal" authentication only, 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
|
||||
# For "internal" authentication only, set to true to require email must be verified when use forget password
|
||||
forget_password_require_email_verify = false
|
||||
|
||||
# For "oauth2" authentication only, OAuth 2.0 client ID
|
||||
oauth2_client_id =
|
||||
|
||||
# For "oauth2" authentication only, OAuth 2.0 client secret
|
||||
oauth2_client_secret =
|
||||
|
||||
# For "oauth2" authentication only, OAuth 2.0 provider user identifier claim name, supports "email" and "username", default is "email"
|
||||
oauth2_user_identifier = email
|
||||
|
||||
# For "oauth2" authentication only, if the user returned by OAuth 2.0 is not registered, automatically create a new user (requires "enable_register" to be set to true)
|
||||
oauth2_auto_register = true
|
||||
|
||||
# For "oauth2" authentication only, OAuth 2.0 provider, supports "nextcloud" currently
|
||||
oauth2_provider =
|
||||
|
||||
# For "oauth2" authentication only, OAuth 2.0 state expired seconds (60 - 4294967295), default is 300 (5 minutes)
|
||||
oauth2_state_expired_time = 300
|
||||
|
||||
# For "oauth2" authentication only, requesting OAuth 2.0 api timeout (0 - 4294967295 milliseconds)
|
||||
# Set to 0 to disable timeout for requesting OAuth 2.0 api, default is 10000 (10 seconds)
|
||||
oauth2_request_timeout = 10000
|
||||
|
||||
# For "oauth2" authentication only, proxy for ezbookkeeping server requesting OAuth 2.0 api, supports "system" (use system proxy), "none" (do not use proxy), or proxy URL which starts with "http://", "https://" or "socks5://", default is "system"
|
||||
oauth2_proxy = system
|
||||
|
||||
# For "oauth2" authentication only, set to true to skip tls verification when request OAuth 2.0 api
|
||||
oauth2_skip_tls_verify = false
|
||||
|
||||
# For "oauth2" authentication and "nextcloud" OAuth 2.0 provider only, nextcloud base url, e.g. "https://cloud.example.org/"
|
||||
nextcloud_base_url =
|
||||
|
||||
[user]
|
||||
# Set to true to allow users to register account by themselves
|
||||
enable_register = true
|
||||
|
||||
1
go.mod
1
go.mod
@@ -91,6 +91,7 @@ require (
|
||||
github.com/xuri/nfp v0.0.1 // indirect
|
||||
golang.org/x/arch v0.18.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
|
||||
golang.org/x/oauth2 v0.31.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
google.golang.org/protobuf v1.36.6 // indirect
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||
|
||||
2
go.sum
2
go.sum
@@ -186,6 +186,8 @@ golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXy
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/oauth2 v0.31.0 h1:8Fq0yVZLh4j4YA47vHKFTa9Ew5XIrCP8LC6UeNZnLxo=
|
||||
golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
|
||||
@@ -22,6 +22,7 @@ type AuthorizationsApi struct {
|
||||
userAppCloudSettings *services.UserApplicationCloudSettingsService
|
||||
tokens *services.TokenService
|
||||
twoFactorAuthorizations *services.TwoFactorAuthorizationService
|
||||
userExternalAuths *services.UserExternalAuthService
|
||||
}
|
||||
|
||||
// Initialize a authorization api singleton instance
|
||||
@@ -48,11 +49,16 @@ var (
|
||||
userAppCloudSettings: services.UserApplicationCloudSettings,
|
||||
tokens: services.Tokens,
|
||||
twoFactorAuthorizations: services.TwoFactorAuthorizations,
|
||||
userExternalAuths: services.UserExternalAuths,
|
||||
}
|
||||
)
|
||||
|
||||
// AuthorizeHandler verifies and authorizes current login request
|
||||
func (a *AuthorizationsApi) AuthorizeHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
if !a.CurrentConfig().EnableInternalAuth {
|
||||
return nil, errs.ErrCannotLoginByPassword
|
||||
}
|
||||
|
||||
var credential models.UserLoginRequest
|
||||
err := c.ShouldBindJSON(&credential)
|
||||
|
||||
@@ -151,7 +157,7 @@ func (a *AuthorizationsApi) AuthorizeHandler(c *core.WebContext) (any, *errs.Err
|
||||
applicationCloudSettingSlice = &userApplicationCloudSettings.Settings
|
||||
}
|
||||
|
||||
log.Infof(c, "[authorizations.AuthorizeHandler] user \"uid:%d\" has logined, token type is %d, token will be expired at %d", user.Uid, claims.Type, claims.ExpiresAt)
|
||||
log.Infof(c, "[authorizations.AuthorizeHandler] user \"uid:%d\" has logged in, token type is %d, token will be expired at %d", user.Uid, claims.Type, claims.ExpiresAt)
|
||||
|
||||
authResp := a.getAuthResponse(c, token, twoFactorEnable, user, applicationCloudSettingSlice)
|
||||
return authResp, nil
|
||||
@@ -159,6 +165,10 @@ func (a *AuthorizationsApi) AuthorizeHandler(c *core.WebContext) (any, *errs.Err
|
||||
|
||||
// TwoFactorAuthorizeHandler verifies and authorizes current 2fa login by passcode
|
||||
func (a *AuthorizationsApi) TwoFactorAuthorizeHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
if !a.CurrentConfig().EnableInternalAuth {
|
||||
return nil, errs.ErrCannotLoginByPassword
|
||||
}
|
||||
|
||||
var credential models.TwoFactorLoginRequest
|
||||
err := c.ShouldBindJSON(&credential)
|
||||
|
||||
@@ -198,7 +208,7 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeHandler(c *core.WebContext) (any,
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[authorizations.TwoFactorAuthorizeHandler] failed to get user \"uid:%d\" info, because %s", user.Uid, err.Error())
|
||||
log.Errorf(c, "[authorizations.TwoFactorAuthorizeHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error())
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
@@ -246,6 +256,10 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeHandler(c *core.WebContext) (any,
|
||||
|
||||
// TwoFactorAuthorizeByRecoveryCodeHandler verifies and authorizes current 2fa login by recovery code
|
||||
func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
if !a.CurrentConfig().EnableInternalAuth {
|
||||
return nil, errs.ErrCannotLoginByPassword
|
||||
}
|
||||
|
||||
var credential models.TwoFactorRecoveryCodeLoginRequest
|
||||
err := c.ShouldBindJSON(&credential)
|
||||
|
||||
@@ -276,7 +290,7 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.WebC
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to get user \"uid:%d\" info, because %s", user.Uid, err.Error())
|
||||
log.Errorf(c, "[authorizations.TwoFactorAuthorizeByRecoveryCodeHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error())
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
@@ -338,6 +352,131 @@ func (a *AuthorizationsApi) TwoFactorAuthorizeByRecoveryCodeHandler(c *core.WebC
|
||||
return authResp, nil
|
||||
}
|
||||
|
||||
// OAuth2CallbackAuthorizeHandler verifies and authorizes current OAuth 2.0 callback login
|
||||
func (a *AuthorizationsApi) OAuth2CallbackAuthorizeHandler(c *core.WebContext) (any, *errs.Error) {
|
||||
if !a.CurrentConfig().EnableOAuth2Login {
|
||||
return nil, errs.ErrOAuth2NotEnabled
|
||||
}
|
||||
|
||||
var credential models.OAuth2CallbackLoginRequest
|
||||
err := c.ShouldBindJSON(&credential)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] parse request failed, because %s", err.Error())
|
||||
return nil, errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
userExternalAuthType := core.UserExternalAuthType(credential.Provider)
|
||||
|
||||
if !userExternalAuthType.IsValid() {
|
||||
log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] provider \"%s\" is invalid", credential.Provider)
|
||||
return nil, errs.ErrInvalidOAuth2Provider
|
||||
}
|
||||
|
||||
uid := c.GetCurrentUid()
|
||||
err = a.CheckFailureCount(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] cannot auth for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrFailureCountLimitReached)
|
||||
}
|
||||
|
||||
user, err := a.users.GetUserById(c, uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] failed to get user \"uid:%d\" info, because %s", uid, err.Error())
|
||||
return nil, errs.ErrUserNotFound
|
||||
}
|
||||
|
||||
if user.Disabled {
|
||||
log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] user \"uid:%d\" is disabled", user.Uid)
|
||||
return nil, errs.ErrUserIsDisabled
|
||||
}
|
||||
|
||||
if a.CurrentConfig().EnableUserForceVerifyEmail && !user.EmailVerified {
|
||||
log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] user \"uid:%d\" has not verified email", user.Uid)
|
||||
return nil, errs.ErrEmailIsNotVerified
|
||||
}
|
||||
|
||||
oldTokenClaims := c.GetTokenClaims()
|
||||
|
||||
if oldTokenClaims.Type == core.USER_TOKEN_TYPE_OAUTH2_CALLBACK_REQUIRE_VERIFY {
|
||||
if credential.Password == "" {
|
||||
return nil, errs.ErrPasswordIsEmpty
|
||||
}
|
||||
|
||||
if !a.users.IsPasswordEqualsUserPassword(credential.Password, user) {
|
||||
failureCheckErr := a.CheckAndIncreaseFailureCount(c, uid)
|
||||
|
||||
if failureCheckErr != nil {
|
||||
log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] cannot login for user \"uid:%d\", because %s", user.Uid, failureCheckErr.Error())
|
||||
return nil, errs.Or(failureCheckErr, errs.ErrFailureCountLimitReached)
|
||||
}
|
||||
|
||||
return nil, errs.ErrUserPasswordWrong
|
||||
}
|
||||
|
||||
userExternalAuth := &models.UserExternalAuth{
|
||||
Uid: user.Uid,
|
||||
ExternalAuthType: userExternalAuthType,
|
||||
}
|
||||
|
||||
if a.CurrentConfig().OAuth2UserIdentifier == settings.OAuth2UserIdentifierEmail {
|
||||
userExternalAuth.ExternalEmail = user.Email
|
||||
} else if a.CurrentConfig().OAuth2UserIdentifier == settings.OAuth2UserIdentifierUsername {
|
||||
userExternalAuth.ExternalUsername = user.Username
|
||||
}
|
||||
|
||||
err = a.userExternalAuths.CreateUserExternalAuth(c, userExternalAuth)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] failed to create user external auth for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrOperationFailed)
|
||||
}
|
||||
|
||||
log.Infof(c, "[authorizations.OAuth2CallbackAuthorizeHandler] user external auth has been created for user \"uid:%d\"", user.Uid)
|
||||
} else if oldTokenClaims.Type == core.USER_TOKEN_TYPE_OAUTH2_CALLBACK {
|
||||
_, err = a.userExternalAuths.GetUserExternalAuthByUid(c, uid, userExternalAuthType)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] failed to get user external auth for user \"uid:%d\", because %s", uid, err.Error())
|
||||
return nil, errs.Or(err, errs.ErrUserExternalAuthNotFound)
|
||||
}
|
||||
} else {
|
||||
return nil, errs.ErrSystemError
|
||||
}
|
||||
|
||||
err = a.tokens.DeleteTokenByClaims(c, oldTokenClaims)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] failed to revoke temporary token \"utid:%s\" for user \"uid:%d\", because %s", oldTokenClaims.UserTokenId, user.Uid, err.Error())
|
||||
}
|
||||
|
||||
token, claims, err := a.tokens.CreateToken(c, user)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] failed to create token for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
return nil, errs.ErrTokenGenerating
|
||||
}
|
||||
|
||||
c.SetTextualToken(token)
|
||||
c.SetTokenClaims(claims)
|
||||
|
||||
userApplicationCloudSettings, err := a.userAppCloudSettings.GetUserApplicationCloudSettingsByUid(c, user.Uid)
|
||||
var applicationCloudSettingSlice *models.ApplicationCloudSettingSlice = nil
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[authorizations.OAuth2CallbackAuthorizeHandler] failed to get latest user application cloud settings for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
} else if userApplicationCloudSettings != nil && len(userApplicationCloudSettings.Settings) > 0 {
|
||||
applicationCloudSettingSlice = &userApplicationCloudSettings.Settings
|
||||
}
|
||||
|
||||
log.Infof(c, "[authorizations.OAuth2CallbackAuthorizeHandler] user \"uid:%d\" has logged in, token will be expired at %d", user.Uid, claims.ExpiresAt)
|
||||
|
||||
authResp := a.getAuthResponse(c, token, false, user, applicationCloudSettingSlice)
|
||||
return authResp, nil
|
||||
}
|
||||
|
||||
func (a *AuthorizationsApi) getAuthResponse(c *core.WebContext, token string, need2FA bool, user *models.User, applicationCloudSettings *models.ApplicationCloudSettingSlice) *models.AuthResponse {
|
||||
return &models.AuthResponse{
|
||||
Token: token,
|
||||
|
||||
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/avatars"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
@@ -120,6 +121,13 @@ func (a *ApiUsingDuplicateChecker) SetSubmissionRemarkIfEnable(checkerType dupli
|
||||
}
|
||||
}
|
||||
|
||||
// SetSubmissionRemarkWithCustomExpirationIfEnable saves the identification and remark by the current duplicate checker with custom expiration time if the duplicate submission check is enabled
|
||||
func (a *ApiUsingDuplicateChecker) SetSubmissionRemarkWithCustomExpirationIfEnable(checkerType duplicatechecker.DuplicateCheckerType, uid int64, identification string, remark string, expiration time.Duration) {
|
||||
if a.CurrentConfig().EnableDuplicateSubmissionsCheck {
|
||||
a.container.SetSubmissionRemarkWithCustomExpiration(checkerType, uid, identification, remark, expiration)
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveSubmissionRemarkIfEnable removes the identification and remark by the current duplicate checker if the duplicate submission check is enabled
|
||||
func (a *ApiUsingDuplicateChecker) RemoveSubmissionRemarkIfEnable(checkerType duplicatechecker.DuplicateCheckerType, uid int64, identification string) {
|
||||
if a.CurrentConfig().EnableDuplicateSubmissionsCheck {
|
||||
|
||||
313
pkg/api/oauth2_authentications.go
Normal file
313
pkg/api/oauth2_authentications.go
Normal file
@@ -0,0 +1,313 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/auth/oauth2"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/duplicatechecker"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/locales"
|
||||
"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/utils"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/validators"
|
||||
)
|
||||
|
||||
const oauth2CallbackPageUrlSuccessFormat = "%sdesktop/#/oauth2_callback?platform=%s&provider=%s&token=%s"
|
||||
const oauth2CallbackPageUrlNeedVerifyFormat = "%sdesktop/#/oauth2_callback?platform=%s&provider=%s&userName=%s&token=%s"
|
||||
const oauth2CallbackPageUrlFailedFormat = "%sdesktop/#/oauth2_callback?error=%s"
|
||||
|
||||
// OAuth2AuthenticationApi represents OAuth 2.0 authorization api
|
||||
type OAuth2AuthenticationApi struct {
|
||||
ApiUsingConfig
|
||||
ApiUsingDuplicateChecker
|
||||
users *services.UserService
|
||||
tokens *services.TokenService
|
||||
userExternalAuths *services.UserExternalAuthService
|
||||
}
|
||||
|
||||
// Initialize a OAuth 2.0 authentication api singleton instance
|
||||
var (
|
||||
OAuth2Authentications = &OAuth2AuthenticationApi{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
ApiUsingDuplicateChecker: ApiUsingDuplicateChecker{
|
||||
ApiUsingConfig: ApiUsingConfig{
|
||||
container: settings.Container,
|
||||
},
|
||||
container: duplicatechecker.Container,
|
||||
},
|
||||
users: services.Users,
|
||||
tokens: services.Tokens,
|
||||
userExternalAuths: services.UserExternalAuths,
|
||||
}
|
||||
)
|
||||
|
||||
// LoginHandler handles user login request via OAuth 2.0
|
||||
func (a *OAuth2AuthenticationApi) LoginHandler(c *core.WebContext) (string, *errs.Error) {
|
||||
var oauth2LoginReq models.OAuth2LoginRequest
|
||||
err := c.ShouldBindQuery(&oauth2LoginReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[oauth2_authentications.LoginHandler] parse request failed, because %s", err.Error())
|
||||
return "", errs.NewIncompleteOrIncorrectSubmissionError(err)
|
||||
}
|
||||
|
||||
if oauth2LoginReq.Platform != "mobile" && oauth2LoginReq.Platform != "desktop" {
|
||||
return "", errs.ErrInvalidOAuth2LoginRequest
|
||||
}
|
||||
|
||||
state := fmt.Sprintf("%s|%s", oauth2LoginReq.Platform, oauth2LoginReq.ClientSessionId)
|
||||
remark := ""
|
||||
|
||||
if a.CurrentConfig().EnableDuplicateSubmissionsCheck {
|
||||
found := false
|
||||
found, remark = a.GetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_OAUTH2_REDIRECT, 0, oauth2LoginReq.ClientSessionId)
|
||||
|
||||
if found {
|
||||
log.Errorf(c, "[oauth2_authentications.LoginHandler] another oauth 2.0 state \"%s\" has been processing for client session id \"%s\"", remark, oauth2LoginReq.ClientSessionId)
|
||||
return "", errs.ErrRepeatedRequest
|
||||
}
|
||||
|
||||
randomString, err := utils.GetRandomNumberOrLowercaseLetter(32)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oauth2_authentications.LoginHandler] failed to generate random string for oauth 2.0 state, because %s", err.Error())
|
||||
return "", errs.ErrSystemError
|
||||
}
|
||||
|
||||
remark = fmt.Sprintf("%s|%s|%s", oauth2LoginReq.Platform, oauth2LoginReq.ClientSessionId, randomString)
|
||||
state = fmt.Sprintf("%s|%s|%s", oauth2LoginReq.Platform, oauth2LoginReq.ClientSessionId, utils.MD5EncodeToString([]byte(remark)))
|
||||
}
|
||||
|
||||
redirectUrl, err := oauth2.GetOAuth2AuthUrl(c, state)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oauth2_authentications.LoginHandler] failed to get oauth 2.0 auth url, because %s", err.Error())
|
||||
return "", errs.Or(err, errs.ErrSystemError)
|
||||
}
|
||||
|
||||
a.SetSubmissionRemarkWithCustomExpirationIfEnable(duplicatechecker.DUPLICATE_CHECKER_TYPE_OAUTH2_REDIRECT, 0, oauth2LoginReq.ClientSessionId, remark, a.CurrentConfig().OAuth2StateExpiredTimeDuration)
|
||||
|
||||
return redirectUrl, nil
|
||||
}
|
||||
|
||||
// CallbackHandler handles OAuth 2.0 callback request
|
||||
func (a *OAuth2AuthenticationApi) CallbackHandler(c *core.WebContext) (string, *errs.Error) {
|
||||
var oauth2CallbackReq models.OAuth2CallbackRequest
|
||||
err := c.ShouldBindQuery(&oauth2CallbackReq)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[oauth2_authentications.CallbackHandler] parse request failed, because %s", err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.NewIncompleteOrIncorrectSubmissionError(err))
|
||||
}
|
||||
|
||||
if oauth2CallbackReq.State == "" {
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrMissingOAuth2State)
|
||||
}
|
||||
|
||||
if oauth2CallbackReq.Code == "" {
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrMissingOAuth2Code)
|
||||
}
|
||||
|
||||
platform := ""
|
||||
clientSessionId := ""
|
||||
|
||||
stateParts := strings.Split(oauth2CallbackReq.State, "|")
|
||||
|
||||
if len(stateParts) >= 2 {
|
||||
platform = stateParts[0]
|
||||
clientSessionId = stateParts[1]
|
||||
} else {
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrInvalidOAuth2State)
|
||||
}
|
||||
|
||||
if platform != "mobile" && platform != "desktop" {
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrInvalidOAuth2LoginRequest)
|
||||
}
|
||||
|
||||
if a.CurrentConfig().EnableDuplicateSubmissionsCheck {
|
||||
found, remark := a.GetSubmissionRemark(duplicatechecker.DUPLICATE_CHECKER_TYPE_OAUTH2_REDIRECT, 0, clientSessionId)
|
||||
|
||||
if !found {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] cannot find oauth 2.0 state in duplicate checker for client session id \"%s\"", clientSessionId)
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrInvalidOAuth2Callback)
|
||||
}
|
||||
|
||||
remarkParts := strings.Split(remark, "|")
|
||||
|
||||
if len(remarkParts) != 3 || remarkParts[0] != platform || remarkParts[1] != clientSessionId {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] invalid oauth 2.0 state \"%s\" in duplicate checker for client session id \"%s\"", remark, clientSessionId)
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrInvalidOAuth2State)
|
||||
}
|
||||
|
||||
expectedState := fmt.Sprintf("%s|%s|%s", platform, clientSessionId, remarkParts[2])
|
||||
expectedState = fmt.Sprintf("%s|%s|%s", platform, clientSessionId, utils.MD5EncodeToString([]byte(expectedState)))
|
||||
|
||||
if oauth2CallbackReq.State != expectedState {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] mismatched random string in oauth 2.0 state, expected \"%s\", got \"%s\"", expectedState, oauth2CallbackReq.State)
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrInvalidOAuth2State)
|
||||
}
|
||||
|
||||
a.RemoveSubmissionRemarkIfEnable(duplicatechecker.DUPLICATE_CHECKER_TYPE_OAUTH2_REDIRECT, 0, clientSessionId)
|
||||
}
|
||||
|
||||
oauth2Token, err := oauth2.GetOAuth2Token(c, oauth2CallbackReq.Code)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to retrieve oauth 2.0 token, because %s", err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.Or(err, errs.ErrCannotRetrieveOAuth2Token))
|
||||
}
|
||||
|
||||
oauth2UserInfo, err := oauth2.GetOAuth2UserInfo(c, oauth2Token)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to retrieve oauth 2.0 user info, because %s", err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.Or(err, errs.ErrInvalidOAuth2Token))
|
||||
}
|
||||
|
||||
if oauth2UserInfo == nil {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to retrieve oauth 2.0 user info, because user info is nil")
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrCannotRetrieveUserInfo)
|
||||
}
|
||||
|
||||
if oauth2UserInfo.UserName == "" || oauth2UserInfo.Email == "" {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] invalid oauth 2.0 user info, userName: %s, email: %s", oauth2UserInfo.UserName, oauth2UserInfo.Email)
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrCannotRetrieveUserInfo)
|
||||
}
|
||||
|
||||
userExternalAuthType := oauth2.GetExternalUserAuthType()
|
||||
var userExternalAuth *models.UserExternalAuth
|
||||
|
||||
if a.CurrentConfig().OAuth2UserIdentifier == settings.OAuth2UserIdentifierEmail {
|
||||
userExternalAuth, err = a.userExternalAuths.GetUserExternalAuthByExternalEmail(c, oauth2UserInfo.Email, userExternalAuthType)
|
||||
} else if a.CurrentConfig().OAuth2UserIdentifier == settings.OAuth2UserIdentifierUsername {
|
||||
userExternalAuth, err = a.userExternalAuths.GetUserExternalAuthByExternalUserName(c, oauth2UserInfo.UserName, userExternalAuthType)
|
||||
} else {
|
||||
userExternalAuth, err = a.userExternalAuths.GetUserExternalAuthByExternalEmail(c, oauth2UserInfo.Email, userExternalAuthType)
|
||||
}
|
||||
|
||||
if err != nil && !errors.Is(err, errs.ErrUserExternalAuthNotFound) {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to get user external auth, because %s", err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.Or(err, errs.ErrOperationFailed))
|
||||
}
|
||||
|
||||
var user *models.User
|
||||
|
||||
if err == nil { // user already bound to external auth, redirect to success page
|
||||
user, err = a.users.GetUserById(c, userExternalAuth.Uid)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to get user by id %d, because %s", userExternalAuth.Uid, err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.Or(err, errs.ErrOperationFailed))
|
||||
}
|
||||
} else if errors.Is(err, errs.ErrUserExternalAuthNotFound) { // user not bound to external auth, try to bind or register new user
|
||||
if a.CurrentConfig().OAuth2UserIdentifier == settings.OAuth2UserIdentifierEmail {
|
||||
user, err = a.users.GetUserByEmail(c, oauth2UserInfo.Email)
|
||||
} else if a.CurrentConfig().OAuth2UserIdentifier == settings.OAuth2UserIdentifierUsername {
|
||||
user, err = a.users.GetUserByUsername(c, oauth2UserInfo.UserName)
|
||||
} else {
|
||||
user, err = a.users.GetUserByEmail(c, oauth2UserInfo.Email)
|
||||
}
|
||||
|
||||
if err != nil && !errors.Is(err, errs.ErrUserNotFound) {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to get user, because %s", err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.Or(err, errs.ErrOperationFailed))
|
||||
}
|
||||
|
||||
if user == nil && a.CurrentConfig().EnableUserRegister && a.CurrentConfig().OAuth2AutoRegister {
|
||||
userName := strings.TrimSpace(oauth2UserInfo.UserName)
|
||||
email := strings.TrimSpace(oauth2UserInfo.Email)
|
||||
nickName := strings.TrimSpace(oauth2UserInfo.NickName)
|
||||
languageCode := ""
|
||||
currencyCode := "USD"
|
||||
|
||||
if _, exists := locales.AllLanguages[oauth2UserInfo.LanguageCode]; exists {
|
||||
languageCode = oauth2UserInfo.LanguageCode
|
||||
}
|
||||
|
||||
if _, exists := validators.AllCurrencyNames[oauth2UserInfo.CurrencyCode]; exists {
|
||||
currencyCode = oauth2UserInfo.CurrencyCode
|
||||
}
|
||||
|
||||
user = &models.User{
|
||||
Username: userName,
|
||||
Email: email,
|
||||
Nickname: nickName,
|
||||
Password: "",
|
||||
Language: languageCode,
|
||||
DefaultCurrency: currencyCode,
|
||||
FirstDayOfWeek: oauth2UserInfo.FirstDayOfWeek,
|
||||
FiscalYearStart: core.FISCAL_YEAR_START_DEFAULT,
|
||||
TransactionEditScope: models.TRANSACTION_EDIT_SCOPE_ALL,
|
||||
FeatureRestriction: a.CurrentConfig().DefaultFeatureRestrictions,
|
||||
}
|
||||
|
||||
err = a.users.CreateUser(c, user)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to create user \"%s\", because %s", user.Username, err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.Or(err, errs.ErrOperationFailed))
|
||||
}
|
||||
|
||||
log.Infof(c, "[oauth2_authentications.CallbackHandler] user \"%s\" has registered successfully, uid is %d", user.Username, user.Uid)
|
||||
|
||||
userExternalAuth := &models.UserExternalAuth{
|
||||
Uid: user.Uid,
|
||||
ExternalAuthType: userExternalAuthType,
|
||||
ExternalUsername: oauth2UserInfo.UserName,
|
||||
ExternalEmail: oauth2UserInfo.Email,
|
||||
}
|
||||
|
||||
err = a.userExternalAuths.CreateUserExternalAuth(c, userExternalAuth)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to create user external auth for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.Or(err, errs.ErrOperationFailed))
|
||||
}
|
||||
|
||||
log.Infof(c, "[oauth2_authentications.CallbackHandler] user external auth has been created for user \"uid:%d\"", user.Uid)
|
||||
} else if user == nil {
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrOAuth2AutoRegistrationNotEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
if userExternalAuth == nil {
|
||||
token, _, err := a.tokens.CreateOAuth2CallbackRequireVerifyToken(c, user)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to create oauth 2.0 callback verify token for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrTokenGenerating)
|
||||
}
|
||||
|
||||
return a.redirectToVerifyCallbackPage(c, platform, userExternalAuthType, user.Username, token)
|
||||
} else {
|
||||
token, _, err := a.tokens.CreateOAuth2CallbackToken(c, user)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[oauth2_authentications.CallbackHandler] failed to create oauth 2.0 callback token for user \"uid:%d\", because %s", user.Uid, err.Error())
|
||||
return a.redirectToFailedCallbackPage(c, errs.ErrTokenGenerating)
|
||||
}
|
||||
|
||||
return a.redirectToSuccessCallbackPage(c, platform, userExternalAuthType, token)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *OAuth2AuthenticationApi) redirectToSuccessCallbackPage(c *core.WebContext, platform string, externalAuthType core.UserExternalAuthType, token string) (string, *errs.Error) {
|
||||
return fmt.Sprintf(oauth2CallbackPageUrlSuccessFormat, a.CurrentConfig().RootUrl, platform, externalAuthType, url.QueryEscape(token)), nil
|
||||
}
|
||||
|
||||
func (a *OAuth2AuthenticationApi) redirectToVerifyCallbackPage(c *core.WebContext, platform string, externalAuthType core.UserExternalAuthType, userName string, token string) (string, *errs.Error) {
|
||||
return fmt.Sprintf(oauth2CallbackPageUrlNeedVerifyFormat, a.CurrentConfig().RootUrl, platform, externalAuthType, userName, url.QueryEscape(token)), nil
|
||||
}
|
||||
|
||||
func (a *OAuth2AuthenticationApi) redirectToFailedCallbackPage(c *core.WebContext, err *errs.Error) (string, *errs.Error) {
|
||||
return fmt.Sprintf(oauth2CallbackPageUrlFailedFormat, a.CurrentConfig().RootUrl, url.QueryEscape(utils.GetDisplayErrorMessage(err))), nil
|
||||
}
|
||||
@@ -35,14 +35,18 @@ func (a *ServerSettingsApi) ServerSettingsJavascriptHandler(c *core.WebContext)
|
||||
builder := &strings.Builder{}
|
||||
builder.WriteString(ezbookkeepingServerSettingsJavascriptFileHeader)
|
||||
|
||||
a.appendBooleanSetting(builder, "r", config.EnableUserRegister)
|
||||
a.appendBooleanSetting(builder, "f", config.EnableUserForgetPassword)
|
||||
a.appendBooleanSetting(builder, "a", config.EnableInternalAuth)
|
||||
a.appendBooleanSetting(builder, "o", config.EnableOAuth2Login)
|
||||
a.appendBooleanSetting(builder, "r", config.EnableInternalAuth && config.EnableUserRegister)
|
||||
a.appendBooleanSetting(builder, "f", config.EnableInternalAuth && config.EnableUserForgetPassword)
|
||||
a.appendBooleanSetting(builder, "v", config.EnableUserVerifyEmail)
|
||||
a.appendBooleanSetting(builder, "p", config.EnableTransactionPictures)
|
||||
a.appendBooleanSetting(builder, "s", config.EnableScheduledTransaction)
|
||||
a.appendBooleanSetting(builder, "e", config.EnableDataExport)
|
||||
a.appendBooleanSetting(builder, "i", config.EnableDataImport)
|
||||
|
||||
a.appendStringSetting(builder, "op", config.OAuth2Provider)
|
||||
|
||||
if config.EnableMCPServer {
|
||||
a.appendBooleanSetting(builder, "mcp", config.EnableMCPServer)
|
||||
}
|
||||
@@ -138,7 +142,7 @@ func (a *ServerSettingsApi) appendStringSetting(builder *strings.Builder, key st
|
||||
builder.WriteString(";\n")
|
||||
}
|
||||
|
||||
func (a *ServerSettingsApi) appendMultiLanguageTipSetting(builder *strings.Builder, key string, value settings.TipConfig) {
|
||||
func (a *ServerSettingsApi) appendMultiLanguageTipSetting(builder *strings.Builder, key string, value settings.MultiLanguageContentConfig) {
|
||||
builder.WriteString(ezbookkeepingServerSettingsGlobalVariableFullName)
|
||||
builder.WriteString("[")
|
||||
a.appendEncodedString(builder, key)
|
||||
|
||||
110
pkg/auth/oauth2/nextcloud_oauth2_provider.go
Normal file
110
pkg/auth/oauth2/nextcloud_oauth2_provider.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package oauth2
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
)
|
||||
|
||||
type nextcloudUserInfoResponse struct {
|
||||
OCS *struct {
|
||||
Meta *struct {
|
||||
Status string `json:"status"`
|
||||
StatusCode int `json:"statuscode"`
|
||||
} `json:"meta"`
|
||||
Data *struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display-name"`
|
||||
} `json:"data"`
|
||||
} `json:"ocs"`
|
||||
}
|
||||
|
||||
// NextcloudOAuth2Provider represents Nextcloud OAuth 2.0 provider
|
||||
type NextcloudOAuth2Provider struct {
|
||||
baseUrl string
|
||||
}
|
||||
|
||||
// NewNextcloudOAuth2Provider creates a new Nextcloud OAuth 2.0 provider instance
|
||||
func NewNextcloudOAuth2Provider(baseUrl string) OAuth2Provider {
|
||||
if baseUrl[len(baseUrl)-1] != '/' {
|
||||
baseUrl += "/"
|
||||
}
|
||||
|
||||
return &NextcloudOAuth2Provider{
|
||||
baseUrl: baseUrl,
|
||||
}
|
||||
}
|
||||
|
||||
// GetAuthUrl returns the authentication url of the Nextcloud provider
|
||||
func (p *NextcloudOAuth2Provider) GetAuthUrl() string {
|
||||
return p.baseUrl + "apps/oauth2/authorize"
|
||||
}
|
||||
|
||||
// GetTokenUrl returns the token url of the Nextcloud provider
|
||||
func (p *NextcloudOAuth2Provider) GetTokenUrl() string {
|
||||
return p.baseUrl + "apps/oauth2/api/v1/token"
|
||||
}
|
||||
|
||||
// GetUserInfo returns the user info by the Nextcloud provider
|
||||
func (p *NextcloudOAuth2Provider) GetUserInfo(c core.Context, oauth2Client *http.Client) (*OAuth2UserInfo, error) {
|
||||
url := p.baseUrl + "ocs/v2.php/cloud/user?format=json"
|
||||
resp, err := oauth2Client.Get(url)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[nextcloud_oauth2_provider.GetUserInfo] failed to get user info response, because %s", err.Error())
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
|
||||
log.Debugf(c, "[nextcloud_oauth2_provider.GetUserInfo] response is %s", body)
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
log.Errorf(c, "[nextcloud_oauth2_provider.GetUserInfo] failed to get user info response, because response code is %d", resp.StatusCode)
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
return p.parseUserInfo(c, body)
|
||||
}
|
||||
|
||||
// GetScopes returns the scopes required by the Nextcloud provider
|
||||
func (p *NextcloudOAuth2Provider) GetScopes() []string {
|
||||
return []string{"profile", "email"}
|
||||
}
|
||||
|
||||
func (p *NextcloudOAuth2Provider) parseUserInfo(c core.Context, body []byte) (*OAuth2UserInfo, error) {
|
||||
userInfoResp := &nextcloudUserInfoResponse{}
|
||||
err := json.Unmarshal(body, &userInfoResp)
|
||||
|
||||
if err != nil {
|
||||
log.Warnf(c, "[nextcloud_oauth2_provider.parseUserInfo] failed to parse user info response body, because %s", err.Error())
|
||||
return nil, errs.ErrCannotRetrieveUserInfo
|
||||
}
|
||||
|
||||
if userInfoResp.OCS == nil || userInfoResp.OCS.Meta == nil || userInfoResp.OCS.Data == nil {
|
||||
log.Warnf(c, "[nextcloud_oauth2_provider.parseUserInfo] invalid user info response body")
|
||||
return nil, errs.ErrCannotRetrieveUserInfo
|
||||
}
|
||||
|
||||
if userInfoResp.OCS.Meta.StatusCode != 200 {
|
||||
log.Warnf(c, "[nextcloud_oauth2_provider.parseUserInfo] user info response status code is %d", userInfoResp.OCS.Meta.StatusCode)
|
||||
return nil, errs.ErrCannotRetrieveUserInfo
|
||||
}
|
||||
|
||||
if userInfoResp.OCS.Data.ID == "" {
|
||||
log.Warnf(c, "[nextcloud_oauth2_provider.parseUserInfo] user info id is empty")
|
||||
return nil, errs.ErrCannotRetrieveUserInfo
|
||||
}
|
||||
|
||||
return &OAuth2UserInfo{
|
||||
UserName: userInfoResp.OCS.Data.ID,
|
||||
Email: userInfoResp.OCS.Data.Email,
|
||||
NickName: userInfoResp.OCS.Data.DisplayName,
|
||||
}, nil
|
||||
}
|
||||
105
pkg/auth/oauth2/oauth2_authentication.go
Normal file
105
pkg/auth/oauth2/oauth2_authentication.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package oauth2
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/utils"
|
||||
)
|
||||
|
||||
// OAuth2Container contains the current OAuth 2.0 authentication provider
|
||||
type OAuth2Container struct {
|
||||
oauth2Config *oauth2.Config
|
||||
oauth2Provider OAuth2Provider
|
||||
oauth2HttpClient *http.Client
|
||||
externalUserAuthType core.UserExternalAuthType
|
||||
}
|
||||
|
||||
// Initialize a OAuth 2.0 container singleton instance
|
||||
var (
|
||||
Container = &OAuth2Container{}
|
||||
)
|
||||
|
||||
// InitializeOAuth2Provider initializes the current OAuth 2.0 provider according to the config
|
||||
func InitializeOAuth2Provider(config *settings.Config) error {
|
||||
if !config.EnableOAuth2Login {
|
||||
return nil
|
||||
}
|
||||
|
||||
if config.OAuth2ClientID == "" || config.OAuth2ClientSecret == "" || config.OAuth2UserIdentifier == "" || config.OAuth2Provider == "" {
|
||||
return errs.ErrInvalidOAuth2Config
|
||||
}
|
||||
|
||||
var oauth2Provider OAuth2Provider
|
||||
var externalUserAuthType core.UserExternalAuthType
|
||||
|
||||
if config.OAuth2Provider == settings.OAuth2ProviderNextcloud {
|
||||
oauth2Provider = NewNextcloudOAuth2Provider(config.OAuth2NextcloudBaseUrl)
|
||||
externalUserAuthType = core.USER_EXTERNAL_AUTH_TYPE_OAUTH2_NEXTCLOUD
|
||||
} else {
|
||||
return errs.ErrInvalidOAuth2Provider
|
||||
}
|
||||
|
||||
Container.oauth2Config = buildOAuth2Config(config, oauth2Provider)
|
||||
Container.oauth2Provider = oauth2Provider
|
||||
Container.oauth2HttpClient = utils.NewHttpClient(config.OAuth2RequestTimeout, config.OAuth2Proxy, config.OAuth2SkipTLSVerify, settings.GetUserAgent())
|
||||
Container.externalUserAuthType = externalUserAuthType
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOAuth2AuthUrl returns the OAuth 2.0 authentication url
|
||||
func GetOAuth2AuthUrl(c core.Context, state string) (string, error) {
|
||||
if Container.oauth2Config == nil {
|
||||
return "", errs.ErrOAuth2NotEnabled
|
||||
}
|
||||
|
||||
return Container.oauth2Config.AuthCodeURL(state), nil
|
||||
}
|
||||
|
||||
// GetOAuth2Token exchanges the authorization code for an OAuth 2.0 token
|
||||
func GetOAuth2Token(c core.Context, code string) (*oauth2.Token, error) {
|
||||
if Container.oauth2Config == nil || Container.oauth2HttpClient == nil {
|
||||
return nil, errs.ErrOAuth2NotEnabled
|
||||
}
|
||||
|
||||
return Container.oauth2Config.Exchange(wrapOAuth2Context(c, Container.oauth2HttpClient), code)
|
||||
}
|
||||
|
||||
// GetOAuth2UserInfo retrieves the OAuth 2.0 user info using the provided OAuth 2.0 token
|
||||
func GetOAuth2UserInfo(c core.Context, token *oauth2.Token) (*OAuth2UserInfo, error) {
|
||||
if Container.oauth2Config == nil || Container.oauth2Provider == nil || Container.oauth2HttpClient == nil {
|
||||
return nil, errs.ErrOAuth2NotEnabled
|
||||
}
|
||||
|
||||
if token == nil {
|
||||
return nil, errs.ErrInvalidOAuth2Token
|
||||
}
|
||||
|
||||
oauth2Client := oauth2.NewClient(wrapOAuth2Context(c, Container.oauth2HttpClient), oauth2.StaticTokenSource(token))
|
||||
return Container.oauth2Provider.GetUserInfo(c, oauth2Client)
|
||||
}
|
||||
|
||||
// GetExternalUserAuthType returns the external user auth type of the current OAuth 2.0 provider
|
||||
func GetExternalUserAuthType() core.UserExternalAuthType {
|
||||
return Container.externalUserAuthType
|
||||
}
|
||||
|
||||
func buildOAuth2Config(config *settings.Config, oauth2Provider OAuth2Provider) *oauth2.Config {
|
||||
redirectURL := config.RootUrl + "oauth2/callback"
|
||||
|
||||
return &oauth2.Config{
|
||||
ClientID: config.OAuth2ClientID,
|
||||
ClientSecret: config.OAuth2ClientSecret,
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: oauth2Provider.GetAuthUrl(),
|
||||
TokenURL: oauth2Provider.GetTokenUrl(),
|
||||
},
|
||||
RedirectURL: redirectURL,
|
||||
Scopes: oauth2Provider.GetScopes(),
|
||||
}
|
||||
}
|
||||
31
pkg/auth/oauth2/oauth2_context.go
Normal file
31
pkg/auth/oauth2/oauth2_context.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package oauth2
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
)
|
||||
|
||||
// OAuth2Context represents the context for OAuth 2.0 operations
|
||||
type OAuth2Context struct {
|
||||
core.Context
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// Value returns the value associated with key
|
||||
func (o *OAuth2Context) Value(key any) any {
|
||||
if key == oauth2.HTTPClient {
|
||||
return o.httpClient
|
||||
}
|
||||
|
||||
return o.Context.Value(key)
|
||||
}
|
||||
|
||||
func wrapOAuth2Context(ctx core.Context, httpClient *http.Client) core.Context {
|
||||
return &OAuth2Context{
|
||||
Context: ctx,
|
||||
httpClient: httpClient,
|
||||
}
|
||||
}
|
||||
22
pkg/auth/oauth2/oauth2_provider.go
Normal file
22
pkg/auth/oauth2/oauth2_provider.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package oauth2
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
)
|
||||
|
||||
// OAuth2Provider defines the structure of OAuth 2.0 provider
|
||||
type OAuth2Provider interface {
|
||||
// GetAuthUrl returns the authentication url of the provider
|
||||
GetAuthUrl() string
|
||||
|
||||
// GetTokenUrl returns the token url of the provider
|
||||
GetTokenUrl() string
|
||||
|
||||
// GetUserInfo returns the user info
|
||||
GetUserInfo(c core.Context, oauth2Client *http.Client) (*OAuth2UserInfo, error)
|
||||
|
||||
// GetScopes returns the scopes required by the provider
|
||||
GetScopes() []string
|
||||
}
|
||||
13
pkg/auth/oauth2/oauth2_user_info.go
Normal file
13
pkg/auth/oauth2/oauth2_user_info.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package oauth2
|
||||
|
||||
import "github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
|
||||
// OAuth2UserInfo represents the user info retrieved from OAuth 2.0 provider
|
||||
type OAuth2UserInfo struct {
|
||||
UserName string
|
||||
Email string
|
||||
NickName string
|
||||
LanguageCode string
|
||||
CurrencyCode string
|
||||
FirstDayOfWeek core.WeekDay
|
||||
}
|
||||
@@ -12,6 +12,9 @@ type CliHandlerFunc func(*CliContext) error
|
||||
// MiddlewareHandlerFunc represents the middleware handler function
|
||||
type MiddlewareHandlerFunc func(*WebContext)
|
||||
|
||||
// RedirectHandlerFunc represents the redirect handler function
|
||||
type RedirectHandlerFunc func(*WebContext) (string, *errs.Error)
|
||||
|
||||
// ApiHandlerFunc represents the api handler function
|
||||
type ApiHandlerFunc func(*WebContext) (any, *errs.Error)
|
||||
|
||||
|
||||
@@ -11,11 +11,13 @@ type TokenType byte
|
||||
|
||||
// Token types
|
||||
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
|
||||
USER_TOKEN_TYPE_MCP TokenType = 5
|
||||
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
|
||||
USER_TOKEN_TYPE_MCP TokenType = 5
|
||||
USER_TOKEN_TYPE_OAUTH2_CALLBACK_REQUIRE_VERIFY TokenType = 6
|
||||
USER_TOKEN_TYPE_OAUTH2_CALLBACK TokenType = 7
|
||||
)
|
||||
|
||||
// UserTokenClaims represents user token
|
||||
|
||||
18
pkg/core/user_external_auth_type.go
Normal file
18
pkg/core/user_external_auth_type.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package core
|
||||
|
||||
// UserExternalAuthType represents the type of user external authentication
|
||||
type UserExternalAuthType string
|
||||
|
||||
// User External Auth Type
|
||||
const (
|
||||
USER_EXTERNAL_AUTH_TYPE_OAUTH2_NEXTCLOUD UserExternalAuthType = "nextcloud"
|
||||
)
|
||||
|
||||
// IsValid checks if the UserExternalAuthType is valid
|
||||
func (t UserExternalAuthType) IsValid() bool {
|
||||
switch t {
|
||||
case USER_EXTERNAL_AUTH_TYPE_OAUTH2_NEXTCLOUD:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import "time"
|
||||
type DuplicateChecker interface {
|
||||
GetSubmissionRemark(checkerType DuplicateCheckerType, uid int64, identification string) (bool, string)
|
||||
SetSubmissionRemark(checkerType DuplicateCheckerType, uid int64, identification string, remark string)
|
||||
SetSubmissionRemarkWithCustomExpiration(checkerType DuplicateCheckerType, uid int64, identification string, remark string, expiration time.Duration)
|
||||
RemoveSubmissionRemark(checkerType DuplicateCheckerType, uid int64, identification string)
|
||||
GetOrSetCronJobRunningInfo(jobName string, runningInfo string, runningInterval time.Duration) (bool, string)
|
||||
RemoveCronJobRunningInfo(jobName string)
|
||||
|
||||
@@ -57,6 +57,15 @@ func (c *DuplicateCheckerContainer) SetSubmissionRemark(checkerType DuplicateChe
|
||||
c.current.SetSubmissionRemark(checkerType, uid, identification, remark)
|
||||
}
|
||||
|
||||
// SetSubmissionRemarkWithCustomExpiration saves the identification and remark by the current duplicate checker with custom expiration time
|
||||
func (c *DuplicateCheckerContainer) SetSubmissionRemarkWithCustomExpiration(checkerType DuplicateCheckerType, uid int64, identification string, remark string, expiration time.Duration) {
|
||||
if c.current == nil {
|
||||
return
|
||||
}
|
||||
|
||||
c.current.SetSubmissionRemarkWithCustomExpiration(checkerType, uid, identification, remark, expiration)
|
||||
}
|
||||
|
||||
// RemoveSubmissionRemark removes the identification and remark by the current duplicate checker
|
||||
func (c *DuplicateCheckerContainer) RemoveSubmissionRemark(checkerType DuplicateCheckerType, uid int64, identification string) {
|
||||
if c.current == nil {
|
||||
|
||||
@@ -13,5 +13,6 @@ const (
|
||||
DUPLICATE_CHECKER_TYPE_NEW_TEMPLATE DuplicateCheckerType = 5
|
||||
DUPLICATE_CHECKER_TYPE_NEW_PICTURE DuplicateCheckerType = 6
|
||||
DUPLICATE_CHECKER_TYPE_IMPORT_TRANSACTIONS DuplicateCheckerType = 7
|
||||
DUPLICATE_CHECKER_TYPE_OAUTH2_REDIRECT DuplicateCheckerType = 8
|
||||
DUPLICATE_CHECKER_TYPE_FAILURE_CHECK DuplicateCheckerType = 255
|
||||
)
|
||||
|
||||
@@ -42,6 +42,11 @@ func (c *InMemoryDuplicateChecker) SetSubmissionRemark(checkerType DuplicateChec
|
||||
c.cache.Set(c.getCacheKey(checkerType, uid, identification), remark, cache.DefaultExpiration)
|
||||
}
|
||||
|
||||
// SetSubmissionRemarkWithCustomExpiration saves the identification and remark to in-memory cache with custom expiration time
|
||||
func (c *InMemoryDuplicateChecker) SetSubmissionRemarkWithCustomExpiration(checkerType DuplicateCheckerType, uid int64, identification string, remark string, expiration time.Duration) {
|
||||
c.cache.Set(c.getCacheKey(checkerType, uid, identification), remark, expiration)
|
||||
}
|
||||
|
||||
// RemoveSubmissionRemark removes the identification and remark in in-memory cache
|
||||
func (c *InMemoryDuplicateChecker) RemoveSubmissionRemark(checkerType DuplicateCheckerType, uid int64, identification string) {
|
||||
c.cache.Delete(c.getCacheKey(checkerType, uid, identification))
|
||||
|
||||
@@ -41,6 +41,8 @@ const (
|
||||
NormalSubcategoryUserCustomExchangeRate = 13
|
||||
NormalSubcategoryModelContextProtocol = 14
|
||||
NormalSubcategoryLargeLanguageModel = 15
|
||||
NormalSubcategoryUserExternalAuth = 16
|
||||
NormalSubcategoryOAuth2 = 17
|
||||
)
|
||||
|
||||
// Error represents the specific error returned to user
|
||||
|
||||
10
pkg/errs/external_auth.go
Normal file
10
pkg/errs/external_auth.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package errs
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Error codes related to user external authentication
|
||||
var (
|
||||
ErrUserExternalAuthNotFound = NewNormalError(NormalSubcategoryUserExternalAuth, 0, http.StatusBadRequest, "user external auth is not found")
|
||||
)
|
||||
19
pkg/errs/oauth2.go
Normal file
19
pkg/errs/oauth2.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package errs
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Error codes related to oauth 2.0
|
||||
var (
|
||||
ErrOAuth2NotEnabled = NewNormalError(NormalSubcategoryOAuth2, 0, http.StatusUnauthorized, "oauth 2.0 not enabled")
|
||||
ErrOAuth2AutoRegistrationNotEnabled = NewNormalError(NormalSubcategoryOAuth2, 1, http.StatusUnauthorized, "oauth 2.0 auto registration not enabled")
|
||||
ErrInvalidOAuth2LoginRequest = NewNormalError(NormalSubcategoryOAuth2, 2, http.StatusUnauthorized, "invalid oauth 2.0 login request")
|
||||
ErrInvalidOAuth2Callback = NewNormalError(NormalSubcategoryOAuth2, 3, http.StatusUnauthorized, "invalid oauth 2.0 callback")
|
||||
ErrMissingOAuth2State = NewNormalError(NormalSubcategoryOAuth2, 4, http.StatusUnauthorized, "missing state in oauth 2.0 callback")
|
||||
ErrMissingOAuth2Code = NewNormalError(NormalSubcategoryOAuth2, 5, http.StatusUnauthorized, "missing code in oauth 2.0 callback")
|
||||
ErrInvalidOAuth2State = NewNormalError(NormalSubcategoryOAuth2, 6, http.StatusUnauthorized, "invalid state in oauth 2.0 callback")
|
||||
ErrCannotRetrieveOAuth2Token = NewNormalError(NormalSubcategoryOAuth2, 7, http.StatusUnauthorized, "cannot retrieve oauth 2.0 token")
|
||||
ErrInvalidOAuth2Token = NewNormalError(NormalSubcategoryOAuth2, 8, http.StatusUnauthorized, "invalid oauth 2.0 token")
|
||||
ErrCannotRetrieveUserInfo = NewNormalError(NormalSubcategoryOAuth2, 9, http.StatusUnauthorized, "cannot retrieve user info from oauth 2.0 provider")
|
||||
)
|
||||
@@ -26,4 +26,8 @@ var (
|
||||
ErrInvalidIpAddressPattern = NewSystemError(SystemSubcategorySetting, 19, http.StatusInternalServerError, "invalid ip address pattern")
|
||||
ErrInvalidLLMProvider = NewSystemError(SystemSubcategorySetting, 20, http.StatusInternalServerError, "invalid llm provider")
|
||||
ErrInvalidLLMModelId = NewSystemError(SystemSubcategorySetting, 21, http.StatusInternalServerError, "invalid llm model id")
|
||||
ErrInvalidOAuth2Config = NewSystemError(SystemSubcategorySetting, 22, http.StatusInternalServerError, "invalid oauth 2.0 config")
|
||||
ErrInvalidOAuth2UserIdentifier = NewSystemError(SystemSubcategorySetting, 23, http.StatusInternalServerError, "invalid oauth 2.0 user identifier")
|
||||
ErrInvalidOAuth2Provider = NewSystemError(SystemSubcategorySetting, 24, http.StatusInternalServerError, "invalid oauth 2.0 provider")
|
||||
ErrInvalidOAuth2StateExpiredTime = NewSystemError(SystemSubcategorySetting, 25, http.StatusInternalServerError, "invalid oauth 2.0 state expired time")
|
||||
)
|
||||
|
||||
@@ -38,4 +38,5 @@ var (
|
||||
ErrUserAvatarExtensionInvalid = NewNormalError(NormalSubcategoryUser, 29, http.StatusNotFound, "user avatar file extension invalid")
|
||||
ErrExceedMaxUserAvatarFileSize = NewNormalError(NormalSubcategoryUser, 30, http.StatusBadRequest, "exceed the maximum size of user avatar file")
|
||||
ErrNotPermittedToPerformThisAction = NewNormalError(NormalSubcategoryUser, 31, http.StatusBadRequest, "not permitted to perform this action")
|
||||
ErrCannotLoginByPassword = NewNormalError(NormalSubcategoryUser, 32, http.StatusBadRequest, "cannot login by password")
|
||||
)
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
package exchangerates
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
@@ -28,23 +26,10 @@ type HttpExchangeRatesDataSource interface {
|
||||
type CommonHttpExchangeRatesDataProvider struct {
|
||||
ExchangeRatesDataProvider
|
||||
dataSource HttpExchangeRatesDataSource
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func (e *CommonHttpExchangeRatesDataProvider) GetLatestExchangeRates(c core.Context, uid int64, currentConfig *settings.Config) (*models.LatestExchangeRateResponse, error) {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
utils.SetProxyUrl(transport, currentConfig.ExchangeRatesProxy)
|
||||
|
||||
if currentConfig.ExchangeRatesSkipTLSVerify {
|
||||
transport.TLSClientConfig = &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
}
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: time.Duration(currentConfig.ExchangeRatesRequestTimeout) * time.Millisecond,
|
||||
}
|
||||
|
||||
requests, err := e.dataSource.BuildRequests()
|
||||
|
||||
if err != nil {
|
||||
@@ -56,14 +41,7 @@ func (e *CommonHttpExchangeRatesDataProvider) GetLatestExchangeRates(c core.Cont
|
||||
|
||||
for i := 0; i < len(requests); i++ {
|
||||
req := requests[i]
|
||||
|
||||
if len(req.Header.Values("User-Agent")) < 1 {
|
||||
req.Header.Set("User-Agent", settings.GetUserAgent())
|
||||
} else if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Del("User-Agent")
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
resp, err := e.httpClient.Do(req)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[common_http_exchange_rates_data_provider.GetLatestExchangeRates] failed to request latest exchange rate data for user \"uid:%d\", because %s", uid, err.Error())
|
||||
@@ -76,7 +54,7 @@ func (e *CommonHttpExchangeRatesDataProvider) GetLatestExchangeRates(c core.Cont
|
||||
log.Debugf(c, "[common_http_exchange_rates_data_provider.GetLatestExchangeRates] response#%d is %s", i, body)
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
log.Errorf(c, "[common_http_exchange_rates_data_provider.GetLatestExchangeRates] failed to get latest exchange rate data response for user \"uid:%d\", because response code is not %d", uid, resp.StatusCode)
|
||||
log.Errorf(c, "[common_http_exchange_rates_data_provider.GetLatestExchangeRates] failed to get latest exchange rate data response for user \"uid:%d\", because response code is %d", uid, resp.StatusCode)
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
@@ -125,8 +103,9 @@ func (e *CommonHttpExchangeRatesDataProvider) GetLatestExchangeRates(c core.Cont
|
||||
return finalExchangeRateResponse, nil
|
||||
}
|
||||
|
||||
func newCommonHttpExchangeRatesDataProvider(dataSource HttpExchangeRatesDataSource) *CommonHttpExchangeRatesDataProvider {
|
||||
func newCommonHttpExchangeRatesDataProvider(config *settings.Config, dataSource HttpExchangeRatesDataSource) *CommonHttpExchangeRatesDataProvider {
|
||||
return &CommonHttpExchangeRatesDataProvider{
|
||||
dataSource: dataSource,
|
||||
httpClient: utils.NewHttpClient(config.ExchangeRatesRequestTimeout, config.ExchangeRatesProxy, config.ExchangeRatesSkipTLSVerify, settings.GetUserAgent()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,55 +20,55 @@ var (
|
||||
// InitializeExchangeRatesDataSource initializes the current exchange rates data source according to the config
|
||||
func InitializeExchangeRatesDataSource(config *settings.Config) error {
|
||||
if config.ExchangeRatesDataSource == settings.ReserveBankOfAustraliaDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(&ReserveBankOfAustraliaDataSource{})
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(config, &ReserveBankOfAustraliaDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.BankOfCanadaDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(&BankOfCanadaDataSource{})
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(config, &BankOfCanadaDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.CzechNationalBankDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(&CzechNationalBankDataSource{})
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(config, &CzechNationalBankDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.DanmarksNationalbankDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(&DanmarksNationalbankDataSource{})
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(config, &DanmarksNationalbankDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.EuroCentralBankDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(&EuroCentralBankDataSource{})
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(config, &EuroCentralBankDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.NationalBankOfGeorgiaDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(&NationalBankOfGeorgiaDataSource{})
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(config, &NationalBankOfGeorgiaDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.CentralBankOfHungaryDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(&CentralBankOfHungaryDataSource{})
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(config, &CentralBankOfHungaryDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.BankOfIsraelDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(&BankOfIsraelDataSource{})
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(config, &BankOfIsraelDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.CentralBankOfMyanmarDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(&CentralBankOfMyanmarDataSource{})
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(config, &CentralBankOfMyanmarDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.NorgesBankDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(&NorgesBankDataSource{})
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(config, &NorgesBankDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.NationalBankOfPolandDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(&NationalBankOfPolandDataSource{})
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(config, &NationalBankOfPolandDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.NationalBankOfRomaniaDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(&NationalBankOfRomaniaDataSource{})
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(config, &NationalBankOfRomaniaDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.BankOfRussiaDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(&BankOfRussiaDataSource{})
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(config, &BankOfRussiaDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.SwissNationalBankDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(&SwissNationalBankDataSource{})
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(config, &SwissNationalBankDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.NationalBankOfUkraineDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(&NationalBankOfUkraineDataSource{})
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(config, &NationalBankOfUkraineDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.CentralBankOfUzbekistanDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(&CentralBankOfUzbekistanDataSource{})
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(config, &CentralBankOfUzbekistanDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.InternationalMonetaryFundDataSource {
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(&InternationalMonetaryFundDataSource{})
|
||||
Container.current = newCommonHttpExchangeRatesDataProvider(config, &InternationalMonetaryFundDataSource{})
|
||||
return nil
|
||||
} else if config.ExchangeRatesDataSource == settings.UserCustomExchangeRatesDataSource {
|
||||
Container.current = newUserCustomExchangeRatesDataProvider()
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
@@ -28,7 +26,8 @@ type HttpLargeLanguageModelAdapter interface {
|
||||
// CommonHttpLargeLanguageModelProvider defines the structure of common http large language model provider
|
||||
type CommonHttpLargeLanguageModelProvider struct {
|
||||
provider.LargeLanguageModelProvider
|
||||
adapter HttpLargeLanguageModelAdapter
|
||||
adapter HttpLargeLanguageModelAdapter
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// GetJsonResponse returns the json response from common http large language model provider
|
||||
@@ -51,20 +50,6 @@ func (p *CommonHttpLargeLanguageModelProvider) GetJsonResponse(c core.Context, u
|
||||
}
|
||||
|
||||
func (p *CommonHttpLargeLanguageModelProvider) getTextualResponse(c core.Context, uid int64, currentLLMConfig *settings.LLMConfig, request *data.LargeLanguageModelRequest, responseType data.LargeLanguageModelResponseFormat) (*data.LargeLanguageModelTextualResponse, error) {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
utils.SetProxyUrl(transport, currentLLMConfig.LargeLanguageModelAPIProxy)
|
||||
|
||||
if currentLLMConfig.LargeLanguageModelAPISkipTLSVerify {
|
||||
transport.TLSClientConfig = &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
}
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: time.Duration(currentLLMConfig.LargeLanguageModelAPIRequestTimeout) * time.Millisecond,
|
||||
}
|
||||
|
||||
httpRequest, err := p.adapter.BuildTextualRequest(c, uid, request, responseType)
|
||||
|
||||
if err != nil {
|
||||
@@ -72,9 +57,7 @@ func (p *CommonHttpLargeLanguageModelProvider) getTextualResponse(c core.Context
|
||||
return nil, errs.ErrFailedToRequestRemoteApi
|
||||
}
|
||||
|
||||
httpRequest.Header.Set("User-Agent", settings.GetUserAgent())
|
||||
|
||||
resp, err := client.Do(httpRequest)
|
||||
resp, err := p.httpClient.Do(httpRequest)
|
||||
|
||||
if err != nil {
|
||||
log.Errorf(c, "[common_http_large_language_model_provider.getTextualResponse] failed to request large language model api for user \"uid:%d\", because %s", uid, err.Error())
|
||||
@@ -95,8 +78,9 @@ func (p *CommonHttpLargeLanguageModelProvider) getTextualResponse(c core.Context
|
||||
}
|
||||
|
||||
// NewCommonHttpLargeLanguageModelProvider creates a http adapter based large language model provider instance
|
||||
func NewCommonHttpLargeLanguageModelProvider(adapter HttpLargeLanguageModelAdapter) *CommonHttpLargeLanguageModelProvider {
|
||||
func NewCommonHttpLargeLanguageModelProvider(llmConfig *settings.LLMConfig, adapter HttpLargeLanguageModelAdapter) *CommonHttpLargeLanguageModelProvider {
|
||||
return &CommonHttpLargeLanguageModelProvider{
|
||||
adapter: adapter,
|
||||
adapter: adapter,
|
||||
httpClient: utils.NewHttpClient(llmConfig.LargeLanguageModelAPIRequestTimeout, llmConfig.LargeLanguageModelAPIProxy, llmConfig.LargeLanguageModelAPISkipTLSVerify, settings.GetUserAgent()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ func (p *GoogleAILargeLanguageModelAdapter) buildJsonRequestBody(c core.Context,
|
||||
|
||||
// NewGoogleAILargeLanguageModelProvider creates a new Google AI large language model provider instance
|
||||
func NewGoogleAILargeLanguageModelProvider(llmConfig *settings.LLMConfig) provider.LargeLanguageModelProvider {
|
||||
return common.NewCommonHttpLargeLanguageModelProvider(&GoogleAILargeLanguageModelAdapter{
|
||||
return common.NewCommonHttpLargeLanguageModelProvider(llmConfig, &GoogleAILargeLanguageModelAdapter{
|
||||
GoogleAIAPIKey: llmConfig.GoogleAIAPIKey,
|
||||
GoogleAIModelID: llmConfig.GoogleAIModelID,
|
||||
})
|
||||
|
||||
@@ -159,7 +159,7 @@ func (p *OllamaLargeLanguageModelAdapter) getOllamaRequestUrl() string {
|
||||
|
||||
// NewOllamaLargeLanguageModelProvider creates a new Ollama large language model provider instance
|
||||
func NewOllamaLargeLanguageModelProvider(llmConfig *settings.LLMConfig) provider.LargeLanguageModelProvider {
|
||||
return common.NewCommonHttpLargeLanguageModelProvider(&OllamaLargeLanguageModelAdapter{
|
||||
return common.NewCommonHttpLargeLanguageModelProvider(llmConfig, &OllamaLargeLanguageModelAdapter{
|
||||
OllamaServerURL: llmConfig.OllamaServerURL,
|
||||
OllamaModelID: llmConfig.OllamaModelID,
|
||||
})
|
||||
|
||||
@@ -37,7 +37,7 @@ func (p *OpenAIOfficialChatCompletionsAPIProvider) GetModelID() string {
|
||||
|
||||
// NewOpenAILargeLanguageModelProvider creates a new OpenAI large language model provider instance
|
||||
func NewOpenAILargeLanguageModelProvider(llmConfig *settings.LLMConfig) provider.LargeLanguageModelProvider {
|
||||
return newCommonOpenAIChatCompletionsAPILargeLanguageModelAdapter(&OpenAIOfficialChatCompletionsAPIProvider{
|
||||
return newCommonOpenAIChatCompletionsAPILargeLanguageModelAdapter(llmConfig, &OpenAIOfficialChatCompletionsAPIProvider{
|
||||
OpenAIAPIKey: llmConfig.OpenAIAPIKey,
|
||||
OpenAIModelID: llmConfig.OpenAIModelID,
|
||||
})
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/provider"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/llm/provider/common"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/log"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/settings"
|
||||
)
|
||||
|
||||
// OpenAIChatCompletionsAPIProvider defines the structure of OpenAI chat completions API provider
|
||||
@@ -212,8 +213,8 @@ func (p *CommonOpenAIChatCompletionsAPILargeLanguageModelAdapter) buildJsonReque
|
||||
return requestBodyBytes, nil
|
||||
}
|
||||
|
||||
func newCommonOpenAIChatCompletionsAPILargeLanguageModelAdapter(apiProvider OpenAIChatCompletionsAPIProvider) provider.LargeLanguageModelProvider {
|
||||
return common.NewCommonHttpLargeLanguageModelProvider(&CommonOpenAIChatCompletionsAPILargeLanguageModelAdapter{
|
||||
func newCommonOpenAIChatCompletionsAPILargeLanguageModelAdapter(llmConfig *settings.LLMConfig, apiProvider OpenAIChatCompletionsAPIProvider) provider.LargeLanguageModelProvider {
|
||||
return common.NewCommonHttpLargeLanguageModelProvider(llmConfig, &CommonOpenAIChatCompletionsAPILargeLanguageModelAdapter{
|
||||
apiProvider: apiProvider,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ func (p *OpenAICompatibleChatCompletionsAPIProvider) getFinalChatCompletionsRequ
|
||||
|
||||
// NewOpenAICompatibleLargeLanguageModelProvider creates a new OpenAI compatible large language model provider instance
|
||||
func NewOpenAICompatibleLargeLanguageModelProvider(llmConfig *settings.LLMConfig) provider.LargeLanguageModelProvider {
|
||||
return newCommonOpenAIChatCompletionsAPILargeLanguageModelAdapter(&OpenAICompatibleChatCompletionsAPIProvider{
|
||||
return newCommonOpenAIChatCompletionsAPILargeLanguageModelAdapter(llmConfig, &OpenAICompatibleChatCompletionsAPIProvider{
|
||||
OpenAICompatibleBaseURL: llmConfig.OpenAICompatibleBaseURL,
|
||||
OpenAICompatibleAPIKey: llmConfig.OpenAICompatibleAPIKey,
|
||||
OpenAICompatibleModelID: llmConfig.OpenAICompatibleModelID,
|
||||
|
||||
@@ -39,7 +39,7 @@ func (p *OpenRouterChatCompletionsAPIProvider) GetModelID() string {
|
||||
|
||||
// NewOpenRouterLargeLanguageModelProvider creates a new OpenRouter large language model provider instance
|
||||
func NewOpenRouterLargeLanguageModelProvider(llmConfig *settings.LLMConfig) provider.LargeLanguageModelProvider {
|
||||
return newCommonOpenAIChatCompletionsAPILargeLanguageModelAdapter(&OpenRouterChatCompletionsAPIProvider{
|
||||
return newCommonOpenAIChatCompletionsAPILargeLanguageModelAdapter(llmConfig, &OpenRouterChatCompletionsAPIProvider{
|
||||
OpenRouterAPIKey: llmConfig.OpenRouterAPIKey,
|
||||
OpenRouterModelID: llmConfig.OpenRouterModelID,
|
||||
})
|
||||
|
||||
@@ -111,6 +111,25 @@ func JWTMCPAuthorization(c *core.WebContext) {
|
||||
c.Next()
|
||||
}
|
||||
|
||||
// JWTOAuth2CallbackAuthorization verifies whether current request is OAuth 2.0 callback
|
||||
func JWTOAuth2CallbackAuthorization(c *core.WebContext) {
|
||||
claims, err := getTokenClaims(c, TOKEN_SOURCE_TYPE_HEADER)
|
||||
|
||||
if err != nil {
|
||||
utils.PrintJsonErrorResult(c, errs.ErrTokenExpired)
|
||||
return
|
||||
}
|
||||
|
||||
if claims.Type != core.USER_TOKEN_TYPE_OAUTH2_CALLBACK && claims.Type != core.USER_TOKEN_TYPE_OAUTH2_CALLBACK_REQUIRE_VERIFY {
|
||||
log.Warnf(c, "[authorization.JWTOAuth2CallbackAuthorization] user \"uid:%d\" token is not for oauth 2.0 callback request", claims.Uid)
|
||||
utils.PrintJsonErrorResult(c, errs.ErrCurrentInvalidToken)
|
||||
return
|
||||
}
|
||||
|
||||
c.SetTokenClaims(claims)
|
||||
c.Next()
|
||||
}
|
||||
|
||||
func jwtAuthorization(c *core.WebContext, source TokenSourceType) {
|
||||
claims, err := getTokenClaims(c, source)
|
||||
|
||||
|
||||
19
pkg/models/oauth2.go
Normal file
19
pkg/models/oauth2.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package models
|
||||
|
||||
// OAuth2LoginRequest represents all parameters of OAuth 2.0 login request
|
||||
type OAuth2LoginRequest struct {
|
||||
Platform string `form:"platform" binding:"required"`
|
||||
ClientSessionId string `form:"client_session_id" binding:"required"`
|
||||
}
|
||||
|
||||
// OAuth2CallbackRequest represents all parameters of OAuth 2.0 callback request
|
||||
type OAuth2CallbackRequest struct {
|
||||
State string `form:"state"`
|
||||
Code string `form:"code"`
|
||||
}
|
||||
|
||||
// OAuth2CallbackLoginRequest represents all parameters of OAuth 2.0 callback login request
|
||||
type OAuth2CallbackLoginRequest struct {
|
||||
Provider string `json:"provider" binding:"required,notBlank"`
|
||||
Password string `json:"password" binding:"omitempty,min=6,max=128"`
|
||||
}
|
||||
17
pkg/models/user_external_auth.go
Normal file
17
pkg/models/user_external_auth.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package models
|
||||
|
||||
import "github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
|
||||
// UserExternalAuth represents user external auth data stored in database
|
||||
type UserExternalAuth struct {
|
||||
Uid int64 `xorm:"PK"`
|
||||
ExternalAuthType core.UserExternalAuthType `xorm:"VARCHAR(32) PK UNIQUE(uqe_userexternalauth_authtype_username) UNIQUE(uqe_userexternalauth_authtype_email)"`
|
||||
ExternalUsername string `xorm:"VARCHAR(32) UNIQUE(uqe_userexternalauth_authtype_username) NOT NULL"`
|
||||
ExternalEmail string `xorm:"VARCHAR(100) UNIQUE(uqe_userexternalauth_authtype_email) NOT NULL"`
|
||||
CreatedUnixTime int64
|
||||
}
|
||||
|
||||
// UserExternalAuthRevokeRequest represents all parameters of user external auth revoke request
|
||||
type UserExternalAuthRevokeRequest struct {
|
||||
ExternalAuthType core.UserExternalAuthType `json:"externalAuthType" binding:"required,notBlank"`
|
||||
}
|
||||
@@ -133,6 +133,18 @@ func (s *TokenService) CreateMCPTokenViaCli(c *core.CliContext, user *models.Use
|
||||
return token, tokenRecord, err
|
||||
}
|
||||
|
||||
// CreateOAuth2CallbackRequireVerifyToken generates a new OAuth 2.0 callback token requiring user to verify and saves to database
|
||||
func (s *TokenService) CreateOAuth2CallbackRequireVerifyToken(c *core.WebContext, user *models.User) (string, *core.UserTokenClaims, error) {
|
||||
token, claims, _, err := s.createToken(c, user, core.USER_TOKEN_TYPE_OAUTH2_CALLBACK_REQUIRE_VERIFY, s.getUserAgent(c), s.CurrentConfig().TemporaryTokenExpiredTimeDuration)
|
||||
return token, claims, err
|
||||
}
|
||||
|
||||
// CreateOAuth2CallbackToken generates a new OAuth 2.0 callback token and saves to database
|
||||
func (s *TokenService) CreateOAuth2CallbackToken(c *core.WebContext, user *models.User) (string, *core.UserTokenClaims, error) {
|
||||
token, claims, _, err := s.createToken(c, user, core.USER_TOKEN_TYPE_OAUTH2_CALLBACK, s.getUserAgent(c), s.CurrentConfig().TemporaryTokenExpiredTimeDuration)
|
||||
return token, claims, err
|
||||
}
|
||||
|
||||
// UpdateTokenLastSeen updates the last seen time of specified token
|
||||
func (s *TokenService) UpdateTokenLastSeen(c core.Context, tokenRecord *models.TokenRecord) error {
|
||||
if tokenRecord.Uid <= 0 {
|
||||
|
||||
117
pkg/services/user_external_auths.go
Normal file
117
pkg/services/user_external_auths.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"xorm.io/xorm"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/datastore"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/models"
|
||||
)
|
||||
|
||||
// UserExternalAuthService represents user external auth service
|
||||
type UserExternalAuthService struct {
|
||||
ServiceUsingDB
|
||||
}
|
||||
|
||||
// Initialize a user external auth service singleton instance
|
||||
var (
|
||||
UserExternalAuths = &UserExternalAuthService{
|
||||
ServiceUsingDB: ServiceUsingDB{
|
||||
container: datastore.Container,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// GetUserAllExternalAuthsByUid returns the user all external auth list according to user uid
|
||||
func (s *UserExternalAuthService) GetUserAllExternalAuthsByUid(c core.Context, uid int64) ([]*models.UserExternalAuth, error) {
|
||||
if uid <= 0 {
|
||||
return nil, errs.ErrUserIdInvalid
|
||||
}
|
||||
|
||||
var userExternalAuths []*models.UserExternalAuth
|
||||
err := s.UserDB().NewSession(c).Where("uid=?", uid).Find(&userExternalAuths)
|
||||
|
||||
return userExternalAuths, err
|
||||
}
|
||||
|
||||
// GetUserExternalAuthByUid returns the user external auth record by uid
|
||||
func (s *UserExternalAuthService) GetUserExternalAuthByUid(c core.Context, uid int64, externalAuthType core.UserExternalAuthType) (*models.UserExternalAuth, error) {
|
||||
if uid <= 0 {
|
||||
return nil, errs.ErrUserIdInvalid
|
||||
}
|
||||
|
||||
userExternalAuth := &models.UserExternalAuth{}
|
||||
has, err := s.UserDB().NewSession(c).Where("uid=? AND external_auth_type=?", uid, externalAuthType).Get(userExternalAuth)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, errs.ErrUserExternalAuthNotFound
|
||||
}
|
||||
|
||||
return userExternalAuth, err
|
||||
}
|
||||
|
||||
// GetUserExternalAuthByExternalUserName returns the user external auth record by external username
|
||||
func (s *UserExternalAuthService) GetUserExternalAuthByExternalUserName(c core.Context, externalUserName string, externalAuthType core.UserExternalAuthType) (*models.UserExternalAuth, error) {
|
||||
userExternalAuth := &models.UserExternalAuth{}
|
||||
has, err := s.UserDB().NewSession(c).Where("external_auth_type=? AND external_username=?", externalAuthType, externalUserName).Get(userExternalAuth)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, errs.ErrUserExternalAuthNotFound
|
||||
}
|
||||
|
||||
return userExternalAuth, err
|
||||
}
|
||||
|
||||
// GetUserExternalAuthByExternalEmail returns the user external auth record by external email
|
||||
func (s *UserExternalAuthService) GetUserExternalAuthByExternalEmail(c core.Context, externalEmail string, externalAuthType core.UserExternalAuthType) (*models.UserExternalAuth, error) {
|
||||
userExternalAuth := &models.UserExternalAuth{}
|
||||
has, err := s.UserDB().NewSession(c).Where("external_auth_type=? AND external_email=?", externalAuthType, externalEmail).Get(userExternalAuth)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if !has {
|
||||
return nil, errs.ErrUserExternalAuthNotFound
|
||||
}
|
||||
|
||||
return userExternalAuth, err
|
||||
}
|
||||
|
||||
// CreateUserExternalAuth creates a new user external auth record in database
|
||||
func (s *UserExternalAuthService) CreateUserExternalAuth(c core.Context, userExternalAuth *models.UserExternalAuth) error {
|
||||
if userExternalAuth.Uid <= 0 {
|
||||
return errs.ErrUserIdInvalid
|
||||
}
|
||||
|
||||
userExternalAuth.CreatedUnixTime = time.Now().Unix()
|
||||
|
||||
return s.UserDB().DoTransaction(c, func(sess *xorm.Session) error {
|
||||
_, err := sess.Insert(userExternalAuth)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteUserExternalAuth deletes given user external auth record from database
|
||||
func (s *UserExternalAuthService) DeleteUserExternalAuth(c core.Context, uid int64, externalAuthType core.UserExternalAuthType) error {
|
||||
if uid <= 0 {
|
||||
return errs.ErrUserIdInvalid
|
||||
}
|
||||
|
||||
return s.UserDB().DoTransaction(c, func(sess *xorm.Session) error {
|
||||
deletedRows, err := sess.Where("uid=? AND external_auth_type=?", uid, externalAuthType).Delete(&models.UserExternalAuth{})
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
} else if deletedRows < 1 {
|
||||
return errs.ErrUserExternalAuthNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -85,6 +85,17 @@ const (
|
||||
InMemoryDuplicateCheckerType string = "in_memory"
|
||||
)
|
||||
|
||||
// OAuth 2.0 user identifier types
|
||||
const (
|
||||
OAuth2UserIdentifierEmail string = "email"
|
||||
OAuth2UserIdentifierUsername string = "username"
|
||||
)
|
||||
|
||||
// OAuth 2.0 rovider types
|
||||
const (
|
||||
OAuth2ProviderNextcloud string = "nextcloud"
|
||||
)
|
||||
|
||||
// Map provider types
|
||||
const (
|
||||
OpenStreetMapProvider string = "openstreetmap"
|
||||
@@ -164,6 +175,9 @@ const (
|
||||
defaultMaxFailuresPerIpPerMinute uint32 = 5
|
||||
defaultMaxFailuresPerUserPerMinute uint32 = 5
|
||||
|
||||
defaultOAuth2StateExpiredTime uint32 = 300 // 5 minutes
|
||||
defaultOAuth2RequestTimeout uint32 = 10000 // 10 seconds
|
||||
|
||||
defaultTransactionPictureFileMaxSize uint32 = 10485760 // 10MB
|
||||
defaultUserAvatarFileMaxSize uint32 = 1048576 // 1MB
|
||||
|
||||
@@ -240,15 +254,8 @@ type LLMConfig struct {
|
||||
LargeLanguageModelAPISkipTLSVerify bool
|
||||
}
|
||||
|
||||
// TipConfig represents a tip setting config
|
||||
type TipConfig struct {
|
||||
Enabled bool
|
||||
DefaultContent string
|
||||
MultiLanguageContent map[string]string
|
||||
}
|
||||
|
||||
// NotificationConfig represents a notification setting config
|
||||
type NotificationConfig struct {
|
||||
// MultiLanguageContentConfig represents a multi-language content setting config
|
||||
type MultiLanguageContentConfig struct {
|
||||
Enabled bool
|
||||
DefaultContent string
|
||||
MultiLanguageContent map[string]string
|
||||
@@ -351,9 +358,22 @@ type Config struct {
|
||||
MaxFailuresPerUserPerMinute uint32
|
||||
|
||||
// Auth
|
||||
EnableInternalAuth bool
|
||||
EnableOAuth2Login bool
|
||||
EnableTwoFactor bool
|
||||
EnableUserForgetPassword bool
|
||||
ForgetPasswordRequireVerifyEmail bool
|
||||
OAuth2ClientID string
|
||||
OAuth2ClientSecret string
|
||||
OAuth2UserIdentifier string
|
||||
OAuth2AutoRegister bool
|
||||
OAuth2Provider string
|
||||
OAuth2StateExpiredTime uint32
|
||||
OAuth2StateExpiredTimeDuration time.Duration
|
||||
OAuth2RequestTimeout uint32
|
||||
OAuth2Proxy string
|
||||
OAuth2SkipTLSVerify bool
|
||||
OAuth2NextcloudBaseUrl string
|
||||
|
||||
// User
|
||||
EnableUserRegister bool
|
||||
@@ -372,12 +392,12 @@ type Config struct {
|
||||
MaxImportFileSize uint32
|
||||
|
||||
// Tip
|
||||
LoginPageTips TipConfig
|
||||
LoginPageTips MultiLanguageContentConfig
|
||||
|
||||
// Notification
|
||||
AfterRegisterNotification NotificationConfig
|
||||
AfterLoginNotification NotificationConfig
|
||||
AfterOpenNotification NotificationConfig
|
||||
AfterRegisterNotification MultiLanguageContentConfig
|
||||
AfterLoginNotification MultiLanguageContentConfig
|
||||
AfterOpenNotification MultiLanguageContentConfig
|
||||
|
||||
// Map
|
||||
MapProvider string
|
||||
@@ -956,9 +976,47 @@ func loadSecurityConfiguration(config *Config, configFile *ini.File, sectionName
|
||||
}
|
||||
|
||||
func loadAuthConfiguration(config *Config, configFile *ini.File, sectionName string) error {
|
||||
config.EnableInternalAuth = getConfigItemBoolValue(configFile, sectionName, "enable_internal_auth", true)
|
||||
config.EnableOAuth2Login = getConfigItemBoolValue(configFile, sectionName, "enable_oauth2_auth", false)
|
||||
config.EnableTwoFactor = getConfigItemBoolValue(configFile, sectionName, "enable_two_factor", true)
|
||||
config.EnableUserForgetPassword = getConfigItemBoolValue(configFile, sectionName, "enable_forget_password", false)
|
||||
config.ForgetPasswordRequireVerifyEmail = getConfigItemBoolValue(configFile, sectionName, "forget_password_require_email_verify", false)
|
||||
config.OAuth2ClientID = getConfigItemStringValue(configFile, sectionName, "oauth2_client_id")
|
||||
config.OAuth2ClientSecret = getConfigItemStringValue(configFile, sectionName, "oauth2_client_secret")
|
||||
|
||||
oauth2UserIdentifier := getConfigItemStringValue(configFile, sectionName, "oauth2_user_identifier")
|
||||
|
||||
if oauth2UserIdentifier == OAuth2UserIdentifierEmail {
|
||||
config.OAuth2UserIdentifier = OAuth2UserIdentifierEmail
|
||||
} else if oauth2UserIdentifier == OAuth2UserIdentifierUsername {
|
||||
config.OAuth2UserIdentifier = OAuth2UserIdentifierUsername
|
||||
} else {
|
||||
return errs.ErrInvalidOAuth2UserIdentifier
|
||||
}
|
||||
|
||||
config.OAuth2AutoRegister = getConfigItemBoolValue(configFile, sectionName, "oauth2_auto_register", true)
|
||||
|
||||
oauth2Provider := getConfigItemStringValue(configFile, sectionName, "oauth2_provider")
|
||||
|
||||
if oauth2Provider == OAuth2ProviderNextcloud {
|
||||
config.OAuth2Provider = OAuth2ProviderNextcloud
|
||||
} else {
|
||||
return errs.ErrInvalidOAuth2Provider
|
||||
}
|
||||
|
||||
config.OAuth2StateExpiredTime = getConfigItemUint32Value(configFile, sectionName, "oauth2_state_expired_time", defaultOAuth2StateExpiredTime)
|
||||
|
||||
if config.OAuth2StateExpiredTime < 60 {
|
||||
return errs.ErrInvalidOAuth2StateExpiredTime
|
||||
}
|
||||
|
||||
config.OAuth2StateExpiredTimeDuration = time.Duration(config.OAuth2StateExpiredTime) * time.Second
|
||||
|
||||
config.OAuth2Proxy = getConfigItemStringValue(configFile, sectionName, "oauth2_proxy", "system")
|
||||
config.OAuth2RequestTimeout = getConfigItemUint32Value(configFile, sectionName, "oauth2_request_timeout", defaultOAuth2RequestTimeout)
|
||||
config.OAuth2SkipTLSVerify = getConfigItemBoolValue(configFile, sectionName, "oauth2_skip_tls_verify", false)
|
||||
|
||||
config.OAuth2NextcloudBaseUrl = getConfigItemStringValue(configFile, sectionName, "nextcloud_base_url")
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -996,15 +1054,15 @@ func loadDataConfiguration(config *Config, configFile *ini.File, sectionName str
|
||||
}
|
||||
|
||||
func loadTipConfiguration(config *Config, configFile *ini.File, sectionName string) error {
|
||||
config.LoginPageTips = getTipConfiguration(configFile, sectionName, "enable_tips_in_login_page", "login_page_tips_content")
|
||||
config.LoginPageTips = getMultiLanguageContentConfig(configFile, sectionName, "enable_tips_in_login_page", "login_page_tips_content")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadNotificationConfiguration(config *Config, configFile *ini.File, sectionName string) error {
|
||||
config.AfterRegisterNotification = getNotificationConfiguration(configFile, sectionName, "enable_notification_after_register", "after_register_notification_content")
|
||||
config.AfterLoginNotification = getNotificationConfiguration(configFile, sectionName, "enable_notification_after_login", "after_login_notification_content")
|
||||
config.AfterOpenNotification = getNotificationConfiguration(configFile, sectionName, "enable_notification_after_open", "after_open_notification_content")
|
||||
config.AfterRegisterNotification = getMultiLanguageContentConfig(configFile, sectionName, "enable_notification_after_register", "after_register_notification_content")
|
||||
config.AfterLoginNotification = getMultiLanguageContentConfig(configFile, sectionName, "enable_notification_after_login", "after_login_notification_content")
|
||||
config.AfterOpenNotification = getMultiLanguageContentConfig(configFile, sectionName, "enable_notification_after_open", "after_open_notification_content")
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1141,29 +1199,8 @@ func getFinalPath(workingPath, p string) (string, error) {
|
||||
return p, err
|
||||
}
|
||||
|
||||
func getTipConfiguration(configFile *ini.File, sectionName string, enableKey string, contentKey string) TipConfig {
|
||||
config := TipConfig{
|
||||
Enabled: getConfigItemBoolValue(configFile, sectionName, enableKey, false),
|
||||
DefaultContent: getConfigItemStringValue(configFile, sectionName, contentKey, ""),
|
||||
MultiLanguageContent: make(map[string]string),
|
||||
}
|
||||
|
||||
for languageTag := range locales.AllLanguages {
|
||||
multiLanguageContentKey := strings.ToLower(languageTag)
|
||||
multiLanguageContentKey = strings.Replace(multiLanguageContentKey, "-", "_", -1)
|
||||
multiLanguageContentKey = contentKey + "_" + multiLanguageContentKey
|
||||
content := getConfigItemStringValue(configFile, sectionName, multiLanguageContentKey, "")
|
||||
|
||||
if content != "" {
|
||||
config.MultiLanguageContent[languageTag] = content
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
func getNotificationConfiguration(configFile *ini.File, sectionName string, enableKey string, contentKey string) NotificationConfig {
|
||||
config := NotificationConfig{
|
||||
func getMultiLanguageContentConfig(configFile *ini.File, sectionName string, enableKey string, contentKey string) MultiLanguageContentConfig {
|
||||
config := MultiLanguageContentConfig{
|
||||
Enabled: getConfigItemBoolValue(configFile, sectionName, enableKey, false),
|
||||
DefaultContent: getConfigItemStringValue(configFile, sectionName, contentKey, ""),
|
||||
MultiLanguageContent: make(map[string]string),
|
||||
|
||||
@@ -26,5 +26,9 @@ func (c *ConfigContainer) GetCurrentConfig() *Config {
|
||||
}
|
||||
|
||||
func GetUserAgent() string {
|
||||
if Version == "" {
|
||||
return "ezBookkeeping"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("ezBookkeeping/%s", Version)
|
||||
}
|
||||
|
||||
@@ -2,12 +2,10 @@ package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mayswind/ezbookkeeping/pkg/core"
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
@@ -26,22 +24,9 @@ type WebDAVObjectStorage struct {
|
||||
// NewWebDAVObjectStorage returns a WebDAV object storage
|
||||
func NewWebDAVObjectStorage(config *settings.Config, pathPrefix string) (*WebDAVObjectStorage, error) {
|
||||
webDavConfig := config.WebDAVConfig
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
utils.SetProxyUrl(transport, webDavConfig.Proxy)
|
||||
|
||||
if webDavConfig.SkipTLSVerify {
|
||||
transport.TLSClientConfig = &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
}
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: time.Duration(webDavConfig.RequestTimeout) * time.Millisecond,
|
||||
}
|
||||
|
||||
storage := &WebDAVObjectStorage{
|
||||
httpClient: client,
|
||||
httpClient: utils.NewHttpClient(webDavConfig.RequestTimeout, webDavConfig.Proxy, webDavConfig.SkipTLSVerify, settings.GetUserAgent()),
|
||||
webDavConfig: webDavConfig,
|
||||
rootPath: webDavConfig.RootPath,
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package utils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"reflect"
|
||||
|
||||
@@ -11,6 +12,22 @@ import (
|
||||
"github.com/mayswind/ezbookkeeping/pkg/errs"
|
||||
)
|
||||
|
||||
// GetDisplayErrorMessage returns the display error message for given error
|
||||
func GetDisplayErrorMessage(err *errs.Error) string {
|
||||
if err.Code() == errs.ErrIncompleteOrIncorrectSubmission.Code() && len(err.BaseError) > 0 {
|
||||
var validationErrors validator.ValidationErrors
|
||||
ok := errors.As(err.BaseError[0], &validationErrors)
|
||||
|
||||
if ok {
|
||||
for _, err := range validationErrors {
|
||||
return getValidationErrorText(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
// PrintJsonSuccessResult writes success response in json format to current http context
|
||||
func PrintJsonSuccessResult(c *core.WebContext, result any) {
|
||||
c.JSON(http.StatusOK, core.O{
|
||||
@@ -32,23 +49,10 @@ func PrintDataSuccessResult(c *core.WebContext, contentType string, fileName str
|
||||
func PrintJsonErrorResult(c *core.WebContext, err *errs.Error) {
|
||||
c.SetResponseError(err)
|
||||
|
||||
errorMessage := err.Error()
|
||||
|
||||
if err.Code() == errs.ErrIncompleteOrIncorrectSubmission.Code() && len(err.BaseError) > 0 {
|
||||
validationErrors, ok := err.BaseError[0].(validator.ValidationErrors)
|
||||
|
||||
if ok {
|
||||
for _, err := range validationErrors {
|
||||
errorMessage = getValidationErrorText(err)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result := core.O{
|
||||
"success": false,
|
||||
"errorCode": err.Code(),
|
||||
"errorMessage": errorMessage,
|
||||
"errorMessage": GetDisplayErrorMessage(err),
|
||||
"path": c.Request.URL.Path,
|
||||
}
|
||||
|
||||
@@ -68,19 +72,6 @@ func PrintJSONRPCSuccessResult(c *core.WebContext, jsonRPCRequest *core.JSONRPCR
|
||||
func PrintJSONRPCErrorResult(c *core.WebContext, jsonRPCRequest *core.JSONRPCRequest, err *errs.Error) {
|
||||
c.SetResponseError(err)
|
||||
|
||||
errorMessage := err.Error()
|
||||
|
||||
if err.Code() == errs.ErrIncompleteOrIncorrectSubmission.Code() && len(err.BaseError) > 0 {
|
||||
validationErrors, ok := err.BaseError[0].(validator.ValidationErrors)
|
||||
|
||||
if ok {
|
||||
for _, err := range validationErrors {
|
||||
errorMessage = getValidationErrorText(err)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var id any
|
||||
|
||||
if jsonRPCRequest != nil {
|
||||
@@ -97,27 +88,13 @@ func PrintJSONRPCErrorResult(c *core.WebContext, jsonRPCRequest *core.JSONRPCReq
|
||||
jsonRPCError = core.JSONRPCInvalidParamsError
|
||||
}
|
||||
|
||||
c.AbortWithStatusJSON(err.HttpStatusCode, core.NewJSONRPCErrorResponseWithCause(id, jsonRPCError, errorMessage))
|
||||
c.AbortWithStatusJSON(err.HttpStatusCode, core.NewJSONRPCErrorResponseWithCause(id, jsonRPCError, GetDisplayErrorMessage(err)))
|
||||
}
|
||||
|
||||
// PrintDataErrorResult writes error response in custom content type to current http context
|
||||
func PrintDataErrorResult(c *core.WebContext, contentType string, err *errs.Error) {
|
||||
c.SetResponseError(err)
|
||||
|
||||
errorMessage := err.Error()
|
||||
|
||||
if err.Code() == errs.ErrIncompleteOrIncorrectSubmission.Code() && len(err.BaseError) > 0 {
|
||||
validationErrors, ok := err.BaseError[0].(validator.ValidationErrors)
|
||||
|
||||
if ok {
|
||||
for _, err := range validationErrors {
|
||||
errorMessage = getValidationErrorText(err)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.Data(err.HttpStatusCode, contentType, []byte(errorMessage))
|
||||
c.Data(err.HttpStatusCode, contentType, []byte(GetDisplayErrorMessage(err)))
|
||||
c.Abort()
|
||||
}
|
||||
|
||||
@@ -149,23 +126,10 @@ func WriteEventStreamJsonSuccessResult(c *core.WebContext, result any) {
|
||||
func WriteEventStreamJsonErrorResult(c *core.WebContext, originalErr *errs.Error) {
|
||||
c.SetResponseError(originalErr)
|
||||
|
||||
errorMessage := originalErr.Error()
|
||||
|
||||
if originalErr.Code() == errs.ErrIncompleteOrIncorrectSubmission.Code() && len(originalErr.BaseError) > 0 {
|
||||
validationErrors, ok := originalErr.BaseError[0].(validator.ValidationErrors)
|
||||
|
||||
if ok {
|
||||
for _, err := range validationErrors {
|
||||
errorMessage = getValidationErrorText(err)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result := core.O{
|
||||
"success": false,
|
||||
"errorCode": originalErr.Code(),
|
||||
"errorMessage": errorMessage,
|
||||
"errorMessage": GetDisplayErrorMessage(originalErr),
|
||||
"path": c.Request.URL.Path,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,47 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
type defaultTransport struct {
|
||||
defaultUserAgent string
|
||||
baseTransport http.RoundTripper
|
||||
}
|
||||
|
||||
func (t *defaultTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
if len(req.Header.Values("User-Agent")) < 1 {
|
||||
req.Header.Set("User-Agent", t.defaultUserAgent)
|
||||
} else if req.Header.Get("User-Agent") == "" {
|
||||
req.Header.Del("User-Agent")
|
||||
}
|
||||
|
||||
return t.baseTransport.RoundTrip(req)
|
||||
}
|
||||
|
||||
// NewHttpClient creates and returns a new http client with specified settings
|
||||
func NewHttpClient(requestTimeout uint32, proxy string, skipTLSVerify bool, defaultUserAgent string) *http.Client {
|
||||
baseTransport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
SetProxyUrl(baseTransport, proxy)
|
||||
|
||||
if skipTLSVerify {
|
||||
baseTransport.TLSClientConfig = &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
}
|
||||
}
|
||||
|
||||
return &http.Client{
|
||||
Transport: &defaultTransport{
|
||||
defaultUserAgent: defaultUserAgent,
|
||||
baseTransport: baseTransport,
|
||||
},
|
||||
Timeout: time.Duration(requestTimeout) * time.Millisecond,
|
||||
}
|
||||
}
|
||||
|
||||
// SetProxyUrl sets proxy url to http transport according to specified proxy setting
|
||||
func SetProxyUrl(transport *http.Transport, proxy string) {
|
||||
if proxy == "none" {
|
||||
|
||||
3
src/consts/oauth2.ts
Normal file
3
src/consts/oauth2.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const OAUTH2_PROVIDER_DISPLAY_NAME: Record<string, string> = {
|
||||
'nextcloud': 'Nextcloud'
|
||||
}
|
||||
@@ -3,6 +3,14 @@ function getServerSetting(key: string): string | number | boolean | Record<strin
|
||||
return settings[key];
|
||||
}
|
||||
|
||||
export function isInternalAuthEnabled(): boolean {
|
||||
return getServerSetting('a') !== 0;
|
||||
}
|
||||
|
||||
export function isOAuth2Enabled(): boolean {
|
||||
return getServerSetting('o') === 1;
|
||||
}
|
||||
|
||||
export function isUserRegistrationEnabled(): boolean {
|
||||
return getServerSetting('r') === 1;
|
||||
}
|
||||
@@ -31,6 +39,14 @@ export function isDataImportingEnabled(): boolean {
|
||||
return getServerSetting('i') === 1;
|
||||
}
|
||||
|
||||
export function getOAuth2Provider(): string {
|
||||
return getServerSetting('op') as string;
|
||||
}
|
||||
|
||||
export function getOIDCCustomDisplayNames(): Record<string, string>{
|
||||
return getServerSetting('ocn') as Record<string, string>;
|
||||
}
|
||||
|
||||
export function isMCPServerEnabled(): boolean {
|
||||
return getServerSetting('mcp') === 1;
|
||||
}
|
||||
|
||||
@@ -135,6 +135,9 @@ import type {
|
||||
UserProfileUpdateRequest,
|
||||
UserProfileUpdateResponse
|
||||
} from '@/models/user.ts';
|
||||
import type {
|
||||
OAuth2CallbackLoginRequest
|
||||
} from '@/models/oauth2.ts';
|
||||
import type {
|
||||
UserApplicationCloudSettingsUpdateRequest
|
||||
} from '@/models/user_app_cloud_setting.ts';
|
||||
@@ -265,6 +268,13 @@ export default {
|
||||
}
|
||||
});
|
||||
},
|
||||
authorizeOAuth2: ({ req, token }: { req: OAuth2CallbackLoginRequest, token: string }): ApiResponsePromise<AuthResponse> => {
|
||||
return axios.post<ApiResponse<AuthResponse>>('oauth2/authorize.json', req, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
},
|
||||
register: (req: UserRegisterRequest): ApiResponsePromise<RegisterResponse> => {
|
||||
return axios.post<ApiResponse<RegisterResponse>>('register.json', req);
|
||||
},
|
||||
@@ -695,6 +705,9 @@ export default {
|
||||
cancelRequest: (cancelableUuid: string) => {
|
||||
cancelableRequests[cancelableUuid] = true;
|
||||
},
|
||||
generateOAuth2LoginUrl: (platform: 'mobile' | 'desktop', clientSessionId: string): string => {
|
||||
return `${getBasePath()}/oauth2/login?platform=${platform}&client_session_id=${clientSessionId}`;
|
||||
},
|
||||
generateQrCodeUrl: (qrCodeName: string): string => {
|
||||
return `${getBasePath()}${BASE_QRCODE_PATH}/${qrCodeName}.png`;
|
||||
},
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
},
|
||||
"misc": {
|
||||
"multiTextJoinSeparator": ", ",
|
||||
"loginWithCustomProvider": "Log in with {name}",
|
||||
"hoursBehindDefaultTimezone": "{hours} Stunde(n) hinter der Standardzeitzone",
|
||||
"hoursAheadOfDefaultTimezone": "{hours} Stunde(n) vor der Standardzeitzone",
|
||||
"hoursMinutesBehindDefaultTimezone": "{hours} Stunde(n) und {minutes} Minute(n) hinter der Standardzeitzone",
|
||||
@@ -129,7 +130,8 @@
|
||||
"moveTransactionsInAccountTip": "You CANNOT undo this action. This will move all transactions from {fromAccount} to {toAccount}.",
|
||||
"clearTransactionsInAccountTip": "You CANNOT undo this action. This will clear your transactions data in {account}. Please enter your current password to confirm.",
|
||||
"accountActivationAndResendValidationEmailTip": "Ein Aktivierungslink wurde an Ihre E-Mail-Adresse gesendet: {email}. Wenn Sie die E-Mail nicht erhalten haben, geben Sie bitte das Passwort erneut ein und klicken Sie auf die Schaltfläche unten, um die Bestätigungs-E-Mail erneut zu senden.",
|
||||
"resendValidationEmailTip": "Wenn Sie die E-Mail nicht erhalten haben, geben Sie bitte das Passwort erneut ein und klicken Sie auf die Schaltfläche unten, um die Bestätigungs-E-Mail an: {email} erneut zu senden."
|
||||
"resendValidationEmailTip": "Wenn Sie die E-Mail nicht erhalten haben, geben Sie bitte das Passwort erneut ein und klicken Sie auf die Schaltfläche unten, um die Bestätigungs-E-Mail an: {email} erneut zu senden.",
|
||||
"oauth2bindTip": "You're signing in to the {userName} user using {providerName}. Please enter your ezBookkeeping password to verify."
|
||||
}
|
||||
},
|
||||
"dataExport": {
|
||||
@@ -1086,6 +1088,7 @@
|
||||
"user avatar file extension invalid": "Dateierweiterung des Benutzeravatars ist ungültig",
|
||||
"exceed the maximum size of user avatar file": "Hochgeladener Benutzeravatar überschreitet die maximal zulässige Dateigröße",
|
||||
"not permitted to perform this action": "Sie sind nicht berechtigt, diese Aktion auszuführen",
|
||||
"cannot login by password": "You cannot login by password",
|
||||
"unauthorized access": "Unbefugter Zugriff",
|
||||
"current token is invalid": "Aktuelles Token ist ungültig",
|
||||
"current token is expired": "Aktuelles Token ist abgelaufen",
|
||||
@@ -1238,6 +1241,17 @@
|
||||
"image for AI recognition is empty": "Image for AI recognition file is empty",
|
||||
"exceed the maximum size of image file for AI recognition": "The uploaded image for AI recognition exceeds the maximum allowed file size",
|
||||
"no transaction information detected": "No transaction information detected",
|
||||
"user external auth is not found": "User external authentication is not found",
|
||||
"oauth 2.0 not enabled": "OAuth 2.0 is not enabled",
|
||||
"oauth 2.0 auto registration not enabled": "OAuth 2.0 auto registration is not enabled",
|
||||
"invalid oauth 2.0 login request": "Invalid OAuth 2.0 login request",
|
||||
"invalid oauth 2.0 callback": "Invalid OAuth 2.0 callback",
|
||||
"missing state in oauth 2.0 callback": "Missing state parameter in OAuth 2.0 callback",
|
||||
"missing code in oauth 2.0 callback": "Missing code parameter in OAuth 2.0 callback",
|
||||
"invalid state in oauth 2.0 callback": "Invalid state parameter in OAuth 2.0 callback",
|
||||
"cannot retrieve oauth 2.0 token": "Cannot retrieve OAuth 2.0 token",
|
||||
"invalid oauth 2.0 token": "Invalid OAuth 2.0 token",
|
||||
"cannot retrieve user info from oauth 2.0 provider": "Cannot retrieve user info from OAuth 2.0 provider",
|
||||
"query items cannot be blank": "Abfrageelemente dürfen nicht leer sein",
|
||||
"query items too much": "Zu viele Abfrageelemente",
|
||||
"query items have invalid item": "Ungültiges Element in Abfrageelementen",
|
||||
@@ -1391,6 +1405,7 @@
|
||||
"Operation": "Vorgang",
|
||||
"Open": "Open",
|
||||
"Close": "Schließen",
|
||||
"or": "or",
|
||||
"Submit": "Einreichen",
|
||||
"Add": "Hinzufügen",
|
||||
"Import": "Importieren",
|
||||
@@ -1572,7 +1587,10 @@
|
||||
"This month or later": "Dieser Monat oder später",
|
||||
"This year or later": "Dieses Jahr oder später",
|
||||
"Log In": "Anmelden",
|
||||
"Log in with OAuth 2.0": "Log in with OAuth 2.0",
|
||||
"Log in with Connect ID": "Log in with Connect ID",
|
||||
"Click here to log in": "Hier klicken, um sich anzumelden",
|
||||
"Logging in...": "Logging in...",
|
||||
"Back to login page": "Zurück zur Anmeldeseite",
|
||||
"Back to home page": "Zurück zur Startseite",
|
||||
"Don't have an account?": "Sie haben kein Konto?",
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
},
|
||||
"misc": {
|
||||
"multiTextJoinSeparator": ", ",
|
||||
"loginWithCustomProvider": "Log in with {name}",
|
||||
"hoursBehindDefaultTimezone": "{hours} hour(s) behind default timezone",
|
||||
"hoursAheadOfDefaultTimezone": "{hours} hour(s) ahead of default timezone",
|
||||
"hoursMinutesBehindDefaultTimezone": "{hours} hour(s) and {minutes} minutes behind default timezone",
|
||||
@@ -129,7 +130,8 @@
|
||||
"moveTransactionsInAccountTip": "You CANNOT undo this action. This will move all transactions from {fromAccount} to {toAccount}.",
|
||||
"clearTransactionsInAccountTip": "You CANNOT undo this action. This will clear your transactions data in {account}. Please enter your current password to confirm.",
|
||||
"accountActivationAndResendValidationEmailTip": "Account activation link has been sent to your email address: {email}, If you don't receive the mail, please fill password again and click the button below to resend the validation mail.",
|
||||
"resendValidationEmailTip": "If you don't receive the mail, please fill password again and click the button below to resend the validation mail to: {email}"
|
||||
"resendValidationEmailTip": "If you don't receive the mail, please fill password again and click the button below to resend the validation mail to: {email}",
|
||||
"oauth2bindTip": "You're signing in to the {userName} user using {providerName}. Please enter your ezBookkeeping password to verify."
|
||||
}
|
||||
},
|
||||
"dataExport": {
|
||||
@@ -1086,6 +1088,7 @@
|
||||
"user avatar file extension invalid": "User avatar file extension is invalid",
|
||||
"exceed the maximum size of user avatar file": "The uploaded user avatar exceeds the maximum allowed file size",
|
||||
"not permitted to perform this action": "You are not permitted to perform this action",
|
||||
"cannot login by password": "You cannot login by password",
|
||||
"unauthorized access": "Unauthorized access",
|
||||
"current token is invalid": "Current token is invalid",
|
||||
"current token is expired": "Current token is expired",
|
||||
@@ -1238,6 +1241,17 @@
|
||||
"image for AI recognition is empty": "Image for AI recognition file is empty",
|
||||
"exceed the maximum size of image file for AI recognition": "The uploaded image for AI recognition exceeds the maximum allowed file size",
|
||||
"no transaction information detected": "No transaction information detected",
|
||||
"user external auth is not found": "User external authentication is not found",
|
||||
"oauth 2.0 not enabled": "OAuth 2.0 is not enabled",
|
||||
"oauth 2.0 auto registration not enabled": "OAuth 2.0 auto registration is not enabled",
|
||||
"invalid oauth 2.0 login request": "Invalid OAuth 2.0 login request",
|
||||
"invalid oauth 2.0 callback": "Invalid OAuth 2.0 callback",
|
||||
"missing state in oauth 2.0 callback": "Missing state parameter in OAuth 2.0 callback",
|
||||
"missing code in oauth 2.0 callback": "Missing code parameter in OAuth 2.0 callback",
|
||||
"invalid state in oauth 2.0 callback": "Invalid state parameter in OAuth 2.0 callback",
|
||||
"cannot retrieve oauth 2.0 token": "Cannot retrieve OAuth 2.0 token",
|
||||
"invalid oauth 2.0 token": "Invalid OAuth 2.0 token",
|
||||
"cannot retrieve user info from oauth 2.0 provider": "Cannot retrieve user info from OAuth 2.0 provider",
|
||||
"query items cannot be blank": "There are no query items",
|
||||
"query items too much": "There are too many query items",
|
||||
"query items have invalid item": "There is invalid item in query items",
|
||||
@@ -1391,6 +1405,7 @@
|
||||
"Operation": "Operation",
|
||||
"Open": "Open",
|
||||
"Close": "Close",
|
||||
"or": "or",
|
||||
"Submit": "Submit",
|
||||
"Add": "Add",
|
||||
"Import": "Import",
|
||||
@@ -1572,7 +1587,10 @@
|
||||
"This month or later": "This month or later",
|
||||
"This year or later": "This year or later",
|
||||
"Log In": "Log In",
|
||||
"Log in with OAuth 2.0": "Log in with OAuth 2.0",
|
||||
"Log in with Connect ID": "Log in with Connect ID",
|
||||
"Click here to log in": "Click here to log in",
|
||||
"Logging in...": "Logging in...",
|
||||
"Back to login page": "Back to login page",
|
||||
"Back to home page": "Back to home page",
|
||||
"Don't have an account?": "Don't have an account?",
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
},
|
||||
"misc": {
|
||||
"multiTextJoinSeparator": ", ",
|
||||
"loginWithCustomProvider": "Log in with {name}",
|
||||
"hoursBehindDefaultTimezone": "{hours} hora(s) de retraso en la zona horaria predeterminada",
|
||||
"hoursAheadOfDefaultTimezone": "{hours} hora(s) por delante de la zona horaria predeterminada",
|
||||
"hoursMinutesBehindDefaultTimezone": "{hours} hora(s) y {minutes} minutos de retraso en la zona horaria predeterminada",
|
||||
@@ -129,7 +130,8 @@
|
||||
"moveTransactionsInAccountTip": "You CANNOT undo this action. This will move all transactions from {fromAccount} to {toAccount}.",
|
||||
"clearTransactionsInAccountTip": "You CANNOT undo this action. This will clear your transactions data in {account}. Please enter your current password to confirm.",
|
||||
"accountActivationAndResendValidationEmailTip": "El enlace de activación de la cuenta se envió a su dirección de correo electrónico: {email}. Si no recibe el correo, ingrese la contraseña nuevamente y haga clic en el botón a continuación para reenviar el correo de validación.",
|
||||
"resendValidationEmailTip": "Si no recibe el correo, complete nuevamente la contraseña y haga clic en el botón a continuación para reenviar el correo de validación a: {email}"
|
||||
"resendValidationEmailTip": "Si no recibe el correo, complete nuevamente la contraseña y haga clic en el botón a continuación para reenviar el correo de validación a: {email}",
|
||||
"oauth2bindTip": "You're signing in to the {userName} user using {providerName}. Please enter your ezBookkeeping password to verify."
|
||||
}
|
||||
},
|
||||
"dataExport": {
|
||||
@@ -1086,6 +1088,7 @@
|
||||
"user avatar file extension invalid": "La extensión del archivo de avatar del usuario no es válida",
|
||||
"exceed the maximum size of user avatar file": "El avatar de usuario subido supera el tamaño de archivo máximo permitido",
|
||||
"not permitted to perform this action": "No tienes permiso para realizar esta acción.",
|
||||
"cannot login by password": "You cannot login by password",
|
||||
"unauthorized access": "Acceso no autorizado",
|
||||
"current token is invalid": "El token actual no es válido",
|
||||
"current token is expired": "El token actual ha caducado",
|
||||
@@ -1238,6 +1241,17 @@
|
||||
"image for AI recognition is empty": "Image for AI recognition file is empty",
|
||||
"exceed the maximum size of image file for AI recognition": "The uploaded image for AI recognition exceeds the maximum allowed file size",
|
||||
"no transaction information detected": "No transaction information detected",
|
||||
"user external auth is not found": "User external authentication is not found",
|
||||
"oauth 2.0 not enabled": "OAuth 2.0 is not enabled",
|
||||
"oauth 2.0 auto registration not enabled": "OAuth 2.0 auto registration is not enabled",
|
||||
"invalid oauth 2.0 login request": "Invalid OAuth 2.0 login request",
|
||||
"invalid oauth 2.0 callback": "Invalid OAuth 2.0 callback",
|
||||
"missing state in oauth 2.0 callback": "Missing state parameter in OAuth 2.0 callback",
|
||||
"missing code in oauth 2.0 callback": "Missing code parameter in OAuth 2.0 callback",
|
||||
"invalid state in oauth 2.0 callback": "Invalid state parameter in OAuth 2.0 callback",
|
||||
"cannot retrieve oauth 2.0 token": "Cannot retrieve OAuth 2.0 token",
|
||||
"invalid oauth 2.0 token": "Invalid OAuth 2.0 token",
|
||||
"cannot retrieve user info from oauth 2.0 provider": "Cannot retrieve user info from OAuth 2.0 provider",
|
||||
"query items cannot be blank": "--",
|
||||
"query items too much": "--",
|
||||
"query items have invalid item": "Hay un elemento no válido en los elementos de consulta",
|
||||
@@ -1391,6 +1405,7 @@
|
||||
"Operation": "Operación",
|
||||
"Open": "Open",
|
||||
"Close": "Cerrar",
|
||||
"or": "or",
|
||||
"Submit": "Enviar",
|
||||
"Add": "Agregar",
|
||||
"Import": "Importar",
|
||||
@@ -1572,7 +1587,10 @@
|
||||
"This month or later": "Este mes o más tarde",
|
||||
"This year or later": "Este año o más tarde",
|
||||
"Log In": "Acceso",
|
||||
"Log in with OAuth 2.0": "Log in with OAuth 2.0",
|
||||
"Log in with Connect ID": "Log in with Connect ID",
|
||||
"Click here to log in": "Haga clic aquí para iniciar sesión",
|
||||
"Logging in...": "Logging in...",
|
||||
"Back to login page": "Volver a la página de inicio de sesión",
|
||||
"Back to home page": "Volver a la página de inicio",
|
||||
"Don't have an account?": "¿No tienes una cuenta?",
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
},
|
||||
"misc": {
|
||||
"multiTextJoinSeparator": ", ",
|
||||
"loginWithCustomProvider": "Log in with {name}",
|
||||
"hoursBehindDefaultTimezone": "{hours} heure(s) de retard sur le fuseau horaire par défaut",
|
||||
"hoursAheadOfDefaultTimezone": "{hours} heure(s) d'avance sur le fuseau horaire par défaut",
|
||||
"hoursMinutesBehindDefaultTimezone": "{hours} heure(s) et {minutes} minutes de retard sur le fuseau horaire par défaut",
|
||||
@@ -129,7 +130,8 @@
|
||||
"moveTransactionsInAccountTip": "You CANNOT undo this action. This will move all transactions from {fromAccount} to {toAccount}.",
|
||||
"clearTransactionsInAccountTip": "You CANNOT undo this action. This will clear your transactions data in {account}. Please enter your current password to confirm.",
|
||||
"accountActivationAndResendValidationEmailTip": "Le lien d'activation du compte a été envoyé à votre adresse e-mail : {email}, Si vous ne recevez pas le mail, veuillez remplir à nouveau le mot de passe et cliquer sur le bouton ci-dessous pour renvoyer l'e-mail de validation.",
|
||||
"resendValidationEmailTip": "Si vous ne recevez pas le mail, veuillez remplir à nouveau le mot de passe et cliquer sur le bouton ci-dessous pour renvoyer l'e-mail de validation à : {email}"
|
||||
"resendValidationEmailTip": "Si vous ne recevez pas le mail, veuillez remplir à nouveau le mot de passe et cliquer sur le bouton ci-dessous pour renvoyer l'e-mail de validation à : {email}",
|
||||
"oauth2bindTip": "You're signing in to the {userName} user using {providerName}. Please enter your ezBookkeeping password to verify."
|
||||
}
|
||||
},
|
||||
"dataExport": {
|
||||
@@ -1086,6 +1088,7 @@
|
||||
"user avatar file extension invalid": "L'extension du fichier d'avatar utilisateur est invalide",
|
||||
"exceed the maximum size of user avatar file": "L'avatar utilisateur téléchargé dépasse la taille de fichier maximale autorisée",
|
||||
"not permitted to perform this action": "Vous n'êtes pas autorisé à effectuer cette action",
|
||||
"cannot login by password": "You cannot login by password",
|
||||
"unauthorized access": "Accès non autorisé",
|
||||
"current token is invalid": "Le token actuel est invalide",
|
||||
"current token is expired": "Le token actuel a expiré",
|
||||
@@ -1238,6 +1241,17 @@
|
||||
"image for AI recognition is empty": "Le fichier d'image pour la reconnaissance IA est vide",
|
||||
"exceed the maximum size of image file for AI recognition": "L'image téléchargée pour la reconnaissance IA dépasse la taille de fichier maximale autorisée",
|
||||
"no transaction information detected": "Aucune information de transaction détectée",
|
||||
"user external auth is not found": "User external authentication is not found",
|
||||
"oauth 2.0 not enabled": "OAuth 2.0 is not enabled",
|
||||
"oauth 2.0 auto registration not enabled": "OAuth 2.0 auto registration is not enabled",
|
||||
"invalid oauth 2.0 login request": "Invalid OAuth 2.0 login request",
|
||||
"invalid oauth 2.0 callback": "Invalid OAuth 2.0 callback",
|
||||
"missing state in oauth 2.0 callback": "Missing state parameter in OAuth 2.0 callback",
|
||||
"missing code in oauth 2.0 callback": "Missing code parameter in OAuth 2.0 callback",
|
||||
"invalid state in oauth 2.0 callback": "Invalid state parameter in OAuth 2.0 callback",
|
||||
"cannot retrieve oauth 2.0 token": "Cannot retrieve OAuth 2.0 token",
|
||||
"invalid oauth 2.0 token": "Invalid OAuth 2.0 token",
|
||||
"cannot retrieve user info from oauth 2.0 provider": "Cannot retrieve user info from OAuth 2.0 provider",
|
||||
"query items cannot be blank": "Il n'y a pas d'éléments de requête",
|
||||
"query items too much": "Il y a trop d'éléments de requête",
|
||||
"query items have invalid item": "Il y a un élément invalide dans les éléments de requête",
|
||||
@@ -1391,6 +1405,7 @@
|
||||
"Operation": "Opération",
|
||||
"Open": "Ouvrir",
|
||||
"Close": "Fermer",
|
||||
"or": "or",
|
||||
"Submit": "Soumettre",
|
||||
"Add": "Ajouter",
|
||||
"Import": "Importer",
|
||||
@@ -1572,7 +1587,10 @@
|
||||
"This month or later": "Ce mois ou plus tard",
|
||||
"This year or later": "Cette année ou plus tard",
|
||||
"Log In": "Se connecter",
|
||||
"Log in with OAuth 2.0": "Log in with OAuth 2.0",
|
||||
"Log in with Connect ID": "Log in with Connect ID",
|
||||
"Click here to log in": "Cliquez ici pour vous connecter",
|
||||
"Logging in...": "Logging in...",
|
||||
"Back to login page": "Retour à la page de connexion",
|
||||
"Back to home page": "Retour à la page d'accueil",
|
||||
"Don't have an account?": "Vous n'avez pas de compte ?",
|
||||
|
||||
@@ -160,6 +160,7 @@ import { UTC_TIMEZONE, ALL_TIMEZONES } from '@/consts/timezone.ts';
|
||||
import { ALL_CURRENCIES } from '@/consts/currency.ts';
|
||||
import { DEFAULT_EXPENSE_CATEGORIES, DEFAULT_INCOME_CATEGORIES, DEFAULT_TRANSFER_CATEGORIES } from '@/consts/category.ts';
|
||||
import { KnownErrorCode, SPECIFIED_API_NOT_FOUND_ERRORS, PARAMETERIZED_ERRORS } from '@/consts/api.ts';
|
||||
import { OAUTH2_PROVIDER_DISPLAY_NAME } from '@/consts/oauth2.ts';
|
||||
import { DEFAULT_DOCUMENT_LANGUAGE_FOR_IMPORT_FILE, SUPPORTED_DOCUMENT_LANGUAGES_FOR_IMPORT_FILE, SUPPORTED_IMPORT_FILE_CATEGORY_AND_TYPES } from '@/consts/file.ts';
|
||||
|
||||
import {
|
||||
@@ -870,18 +871,18 @@ export function useI18n() {
|
||||
return textArray.join(separator);
|
||||
}
|
||||
|
||||
function getServerTipContent(tipConfig: Record<string, string>): string {
|
||||
if (!tipConfig) {
|
||||
function getServerMultiLanguageConfigContent(multiLanguageConfig: Record<string, string>): string {
|
||||
if (!multiLanguageConfig) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const currentLanguage = getCurrentLanguageTag();
|
||||
|
||||
if (isString(tipConfig[currentLanguage])) {
|
||||
return tipConfig[currentLanguage];
|
||||
if (isString(multiLanguageConfig[currentLanguage])) {
|
||||
return multiLanguageConfig[currentLanguage];
|
||||
}
|
||||
|
||||
return tipConfig['default'] || '';
|
||||
return multiLanguageConfig['default'] || '';
|
||||
}
|
||||
|
||||
function getCurrentLanguageTag(): string {
|
||||
@@ -2139,6 +2140,46 @@ export function useI18n() {
|
||||
return ret;
|
||||
}
|
||||
|
||||
function getLocalizedOAuth2ProviderName(oauth2Provider: string, oidcDisplayNames: Record<string, string>): string {
|
||||
if (oauth2Provider === 'oidc') {
|
||||
const providerDisplayName = getServerMultiLanguageConfigContent(oidcDisplayNames);
|
||||
|
||||
if (providerDisplayName) {
|
||||
return providerDisplayName;
|
||||
} else {
|
||||
return 'Connect ID';
|
||||
}
|
||||
} else {
|
||||
const providerDisplayName = OAUTH2_PROVIDER_DISPLAY_NAME[oauth2Provider];
|
||||
|
||||
if (providerDisplayName) {
|
||||
return providerDisplayName;
|
||||
} else {
|
||||
return 'OAuth 2.0';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getLocalizedOAuth2LoginText(oauth2Provider: string, oidcDisplayNames: Record<string, string>): string {
|
||||
if (oauth2Provider === 'oidc') {
|
||||
const providerDisplayName = getServerMultiLanguageConfigContent(oidcDisplayNames);
|
||||
|
||||
if (providerDisplayName) {
|
||||
return t('format.misc.loginWithCustomProvider', { name: providerDisplayName });
|
||||
} else {
|
||||
return t('Log in with Connect ID');
|
||||
}
|
||||
} else {
|
||||
const providerDisplayName = OAUTH2_PROVIDER_DISPLAY_NAME[oauth2Provider];
|
||||
|
||||
if (providerDisplayName) {
|
||||
return t('format.misc.loginWithCustomProvider', { name: providerDisplayName });
|
||||
} else {
|
||||
return t('Log in with OAuth 2.0');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setLanguage(languageKey: string | null, force?: boolean): LocaleDefaultSettings | null {
|
||||
if (!languageKey) {
|
||||
languageKey = getDefaultLanguage();
|
||||
@@ -2266,7 +2307,7 @@ export function useI18n() {
|
||||
ti: translateIf,
|
||||
te: translateError,
|
||||
joinMultiText,
|
||||
getServerTipContent,
|
||||
getServerMultiLanguageConfigContent,
|
||||
// get current language info
|
||||
getCurrentLanguageTag,
|
||||
getCurrentLanguageInfo,
|
||||
@@ -2411,6 +2452,9 @@ export function useI18n() {
|
||||
getAdaptiveAmountRate,
|
||||
getAmountPrependAndAppendText,
|
||||
getCategorizedAccountsWithDisplayBalance,
|
||||
// other format functions
|
||||
getLocalizedOAuth2ProviderName,
|
||||
getLocalizedOAuth2LoginText,
|
||||
// localization setting functions
|
||||
setLanguage,
|
||||
setTimeZone,
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
},
|
||||
"misc": {
|
||||
"multiTextJoinSeparator": ", ",
|
||||
"loginWithCustomProvider": "Log in with {name}",
|
||||
"hoursBehindDefaultTimezone": "Indietro di {hours} ore rispetto al fuso orario standard",
|
||||
"hoursAheadOfDefaultTimezone": "Avanti di {hours} ore rispetto al fuso orario standard",
|
||||
"hoursMinutesBehindDefaultTimezone": "Indietro di {hours} ore e {minutes} minuti rispetto al fuso orario standard",
|
||||
@@ -129,7 +130,8 @@
|
||||
"moveTransactionsInAccountTip": "You CANNOT undo this action. This will move all transactions from {fromAccount} to {toAccount}.",
|
||||
"clearTransactionsInAccountTip": "You CANNOT undo this action. This will clear your transactions data in {account}. Please enter your current password to confirm.",
|
||||
"accountActivationAndResendValidationEmailTip": "Abbiamo inviato un link per l'attivazione del tuo account all'indirizzo {email}. Se non hai ricevuto la mail, inserisci nuovamente la password e premi il bottone per ritentare l'invio.",
|
||||
"resendValidationEmailTip": "Se non hai ricevuto la mail, inserisci nuovamente la password e premi il bottone per ritentare l'invio all'indirizzo: {email}"
|
||||
"resendValidationEmailTip": "Se non hai ricevuto la mail, inserisci nuovamente la password e premi il bottone per ritentare l'invio all'indirizzo: {email}",
|
||||
"oauth2bindTip": "You're signing in to the {userName} user using {providerName}. Please enter your ezBookkeeping password to verify."
|
||||
}
|
||||
},
|
||||
"dataExport": {
|
||||
@@ -1086,6 +1088,7 @@
|
||||
"user avatar file extension invalid": "Estensione del file avatar utente non valida",
|
||||
"exceed the maximum size of user avatar file": "L'avatar utente caricato supera la dimensione massima consentita del file",
|
||||
"not permitted to perform this action": "Non sei autorizzato a eseguire questa azione",
|
||||
"cannot login by password": "You cannot login by password",
|
||||
"unauthorized access": "Accesso non autorizzato",
|
||||
"current token is invalid": "Token corrente non valido",
|
||||
"current token is expired": "Token corrente scaduto",
|
||||
@@ -1238,6 +1241,17 @@
|
||||
"image for AI recognition is empty": "Image for AI recognition file is empty",
|
||||
"exceed the maximum size of image file for AI recognition": "The uploaded image for AI recognition exceeds the maximum allowed file size",
|
||||
"no transaction information detected": "No transaction information detected",
|
||||
"user external auth is not found": "User external authentication is not found",
|
||||
"oauth 2.0 not enabled": "OAuth 2.0 is not enabled",
|
||||
"oauth 2.0 auto registration not enabled": "OAuth 2.0 auto registration is not enabled",
|
||||
"invalid oauth 2.0 login request": "Invalid OAuth 2.0 login request",
|
||||
"invalid oauth 2.0 callback": "Invalid OAuth 2.0 callback",
|
||||
"missing state in oauth 2.0 callback": "Missing state parameter in OAuth 2.0 callback",
|
||||
"missing code in oauth 2.0 callback": "Missing code parameter in OAuth 2.0 callback",
|
||||
"invalid state in oauth 2.0 callback": "Invalid state parameter in OAuth 2.0 callback",
|
||||
"cannot retrieve oauth 2.0 token": "Cannot retrieve OAuth 2.0 token",
|
||||
"invalid oauth 2.0 token": "Invalid OAuth 2.0 token",
|
||||
"cannot retrieve user info from oauth 2.0 provider": "Cannot retrieve user info from OAuth 2.0 provider",
|
||||
"query items cannot be blank": "Non ci sono elementi di query",
|
||||
"query items too much": "Ci sono troppi elementi di query",
|
||||
"query items have invalid item": "C'è un elemento non valido negli elementi di query",
|
||||
@@ -1391,6 +1405,7 @@
|
||||
"Operation": "Operazione",
|
||||
"Open": "Open",
|
||||
"Close": "Chiudi",
|
||||
"or": "or",
|
||||
"Submit": "Invia",
|
||||
"Add": "Aggiungi",
|
||||
"Import": "Importa",
|
||||
@@ -1572,7 +1587,10 @@
|
||||
"This month or later": "Questo mese o successivo",
|
||||
"This year or later": "Quest'anno o successivo",
|
||||
"Log In": "Accedi",
|
||||
"Log in with OAuth 2.0": "Log in with OAuth 2.0",
|
||||
"Log in with Connect ID": "Log in with Connect ID",
|
||||
"Click here to log in": "Clicca qui per accedere",
|
||||
"Logging in...": "Logging in...",
|
||||
"Back to login page": "Torna alla pagina di accesso",
|
||||
"Back to home page": "Torna alla home page",
|
||||
"Don't have an account?": "Non hai un account?",
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
},
|
||||
"misc": {
|
||||
"multiTextJoinSeparator": "、",
|
||||
"loginWithCustomProvider": "Log in with {name}",
|
||||
"hoursBehindDefaultTimezone": "デフォルトのタイムゾーンより{hours}時間遅れています",
|
||||
"hoursAheadOfDefaultTimezone": "デフォルトのタイムゾーンから{hours}時間進んでいます",
|
||||
"hoursMinutesBehindDefaultTimezone": "デフォルトのタイムゾーンより{hours}時間{minutes}分遅れています",
|
||||
@@ -129,7 +130,8 @@
|
||||
"moveTransactionsInAccountTip": "You CANNOT undo this action. This will move all transactions from {fromAccount} to {toAccount}.",
|
||||
"clearTransactionsInAccountTip": "You CANNOT undo this action. This will clear your transactions data in {account}. Please enter your current password to confirm.",
|
||||
"accountActivationAndResendValidationEmailTip": "アカウントの有効化リンクがメールアドレスに送信されました:{email}、メールが届かない場合はパスワードをもう一度入力して下のボタンをクリックして認証メールを再送信してください。",
|
||||
"resendValidationEmailTip": "メールが届かない場合は、パスワードをもう一度入力の上、以下のボタンをクリックして検証メールを再送信してください: {email}"
|
||||
"resendValidationEmailTip": "メールが届かない場合は、パスワードをもう一度入力の上、以下のボタンをクリックして検証メールを再送信してください: {email}",
|
||||
"oauth2bindTip": "You're signing in to the {userName} user using {providerName}. Please enter your ezBookkeeping password to verify."
|
||||
}
|
||||
},
|
||||
"dataExport": {
|
||||
@@ -1086,6 +1088,7 @@
|
||||
"user avatar file extension invalid": "ユーザーアバターファイルの拡張子が無効です",
|
||||
"exceed the maximum size of user avatar file": "アップロードされたユーザーアバターは最大ファイルサイズを超えています",
|
||||
"not permitted to perform this action": "このアクションを実行は許可されていません",
|
||||
"cannot login by password": "You cannot login by password",
|
||||
"unauthorized access": "不正アクセス",
|
||||
"current token is invalid": "現在のトークンは無効です",
|
||||
"current token is expired": "現在のトークンの有効期限が切れています",
|
||||
@@ -1238,6 +1241,17 @@
|
||||
"image for AI recognition is empty": "Image for AI recognition file is empty",
|
||||
"exceed the maximum size of image file for AI recognition": "The uploaded image for AI recognition exceeds the maximum allowed file size",
|
||||
"no transaction information detected": "No transaction information detected",
|
||||
"user external auth is not found": "User external authentication is not found",
|
||||
"oauth 2.0 not enabled": "OAuth 2.0 is not enabled",
|
||||
"oauth 2.0 auto registration not enabled": "OAuth 2.0 auto registration is not enabled",
|
||||
"invalid oauth 2.0 login request": "Invalid OAuth 2.0 login request",
|
||||
"invalid oauth 2.0 callback": "Invalid OAuth 2.0 callback",
|
||||
"missing state in oauth 2.0 callback": "Missing state parameter in OAuth 2.0 callback",
|
||||
"missing code in oauth 2.0 callback": "Missing code parameter in OAuth 2.0 callback",
|
||||
"invalid state in oauth 2.0 callback": "Invalid state parameter in OAuth 2.0 callback",
|
||||
"cannot retrieve oauth 2.0 token": "Cannot retrieve OAuth 2.0 token",
|
||||
"invalid oauth 2.0 token": "Invalid OAuth 2.0 token",
|
||||
"cannot retrieve user info from oauth 2.0 provider": "Cannot retrieve user info from OAuth 2.0 provider",
|
||||
"query items cannot be blank": "クエリ項目がありません",
|
||||
"query items too much": "クエリ項目が多すぎます",
|
||||
"query items have invalid item": "クエリ項目に無効な項目があります",
|
||||
@@ -1391,6 +1405,7 @@
|
||||
"Operation": "操作",
|
||||
"Open": "Open",
|
||||
"Close": "閉じる",
|
||||
"or": "or",
|
||||
"Submit": "送信",
|
||||
"Add": "追加",
|
||||
"Import": "インポート",
|
||||
@@ -1572,7 +1587,10 @@
|
||||
"This month or later": "今月以降",
|
||||
"This year or later": "今年以降",
|
||||
"Log In": "ログイン",
|
||||
"Log in with OAuth 2.0": "Log in with OAuth 2.0",
|
||||
"Log in with Connect ID": "Log in with Connect ID",
|
||||
"Click here to log in": "ここをクリックしてログインしてください",
|
||||
"Logging in...": "Logging in...",
|
||||
"Back to login page": "ログインページに戻る",
|
||||
"Back to home page": "Back to home page",
|
||||
"Don't have an account?": "Don't have an account?",
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
},
|
||||
"misc": {
|
||||
"multiTextJoinSeparator": ", ",
|
||||
"loginWithCustomProvider": "Log in with {name}",
|
||||
"hoursBehindDefaultTimezone": "기본 시간대보다 {hours}시간 느립니다",
|
||||
"hoursAheadOfDefaultTimezone": "기본 시간대보다 {hours}시간 빠릅니다",
|
||||
"hoursMinutesBehindDefaultTimezone": "기본 시간대보다 {hours}시간 {minutes}분 느립니다",
|
||||
@@ -129,7 +130,8 @@
|
||||
"moveTransactionsInAccountTip": "You CANNOT undo this action. This will move all transactions from {fromAccount} to {toAccount}.",
|
||||
"clearTransactionsInAccountTip": "이 작업은 되돌릴 수 없습니다. {account}의 거래 데이터를 지웁니다. 계속하시려면 현재 비밀번호를 입력하세요.",
|
||||
"accountActivationAndResendValidationEmailTip": "계정 활성화 링크가 귀하의 이메일 주소({email})로 전송되었습니다. 메일을 받지 못하신 경우, 비밀번호를 다시 입력하고 아래 버튼을 클릭하여 확인 메일을 재전송하십시오.",
|
||||
"resendValidationEmailTip": "메일을 받지 못하신 경우, 비밀번호를 다시 입력하고 아래 버튼을 클릭하여 확인 메일을 {email}로 재전송하십시오."
|
||||
"resendValidationEmailTip": "메일을 받지 못하신 경우, 비밀번호를 다시 입력하고 아래 버튼을 클릭하여 확인 메일을 {email}로 재전송하십시오.",
|
||||
"oauth2bindTip": "You're signing in to the {userName} user using {providerName}. Please enter your ezBookkeeping password to verify."
|
||||
}
|
||||
},
|
||||
"dataExport": {
|
||||
@@ -1086,6 +1088,7 @@
|
||||
"user avatar file extension invalid": "사용자 아바타 파일 확장자가 유효하지 않습니다",
|
||||
"exceed the maximum size of user avatar file": "업로드된 사용자 아바타가 허용된 최대 파일 크기를 초과합니다",
|
||||
"not permitted to perform this action": "이 작업을 수행할 수 있는 권한이 없습니다",
|
||||
"cannot login by password": "You cannot login by password",
|
||||
"unauthorized access": "권한이 없는 접근입니다",
|
||||
"current token is invalid": "현재 토큰이 유효하지 않습니다",
|
||||
"current token is expired": "현재 토큰이 만료되었습니다",
|
||||
@@ -1238,6 +1241,17 @@
|
||||
"image for AI recognition is empty": "AI 인식을 위한 이미지 파일이 비어 있습니다.",
|
||||
"exceed the maximum size of image file for AI recognition": "AI 인식을 위한 업로드된 이미지가 허용된 최대 파일 크기를 초과합니다.",
|
||||
"no transaction information detected": "거래 정보가 감지되지 않았습니다.",
|
||||
"user external auth is not found": "User external authentication is not found",
|
||||
"oauth 2.0 not enabled": "OAuth 2.0 is not enabled",
|
||||
"oauth 2.0 auto registration not enabled": "OAuth 2.0 auto registration is not enabled",
|
||||
"invalid oauth 2.0 login request": "Invalid OAuth 2.0 login request",
|
||||
"invalid oauth 2.0 callback": "Invalid OAuth 2.0 callback",
|
||||
"missing state in oauth 2.0 callback": "Missing state parameter in OAuth 2.0 callback",
|
||||
"missing code in oauth 2.0 callback": "Missing code parameter in OAuth 2.0 callback",
|
||||
"invalid state in oauth 2.0 callback": "Invalid state parameter in OAuth 2.0 callback",
|
||||
"cannot retrieve oauth 2.0 token": "Cannot retrieve OAuth 2.0 token",
|
||||
"invalid oauth 2.0 token": "Invalid OAuth 2.0 token",
|
||||
"cannot retrieve user info from oauth 2.0 provider": "Cannot retrieve user info from OAuth 2.0 provider",
|
||||
"query items cannot be blank": "쿼리 항목이 비어 있을 수 없습니다.",
|
||||
"query items too much": "쿼리 항목이 너무 많습니다.",
|
||||
"query items have invalid item": "쿼리 항목에 유효하지 않은 항목이 있습니다.",
|
||||
@@ -1391,6 +1405,7 @@
|
||||
"Operation": "작업",
|
||||
"Open": "열기",
|
||||
"Close": "닫기",
|
||||
"or": "or",
|
||||
"Submit": "제출",
|
||||
"Add": "추가",
|
||||
"Import": "가져오기",
|
||||
@@ -1572,7 +1587,10 @@
|
||||
"This month or later": "이번 달 이후",
|
||||
"This year or later": "올해 이후",
|
||||
"Log In": "로그인",
|
||||
"Log in with OAuth 2.0": "Log in with OAuth 2.0",
|
||||
"Log in with Connect ID": "Log in with Connect ID",
|
||||
"Click here to log in": "여기를 클릭하여 로그인",
|
||||
"Logging in...": "Logging in...",
|
||||
"Back to login page": "로그인 페이지로 돌아가기",
|
||||
"Back to home page": "홈 페이지로 돌아가기",
|
||||
"Don't have an account?": "계정이 없으신가요?",
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
},
|
||||
"misc": {
|
||||
"multiTextJoinSeparator": ", ",
|
||||
"loginWithCustomProvider": "Log in with {name}",
|
||||
"hoursBehindDefaultTimezone": "{hours} uur achter standaardtijdzone",
|
||||
"hoursAheadOfDefaultTimezone": "{hours} uur voor op standaardtijdzone",
|
||||
"hoursMinutesBehindDefaultTimezone": "{hours} uur en {minutes} minuten achter standaardtijdzone",
|
||||
@@ -129,7 +130,8 @@
|
||||
"moveTransactionsInAccountTip": "You CANNOT undo this action. This will move all transactions from {fromAccount} to {toAccount}.",
|
||||
"clearTransactionsInAccountTip": "You CANNOT undo this action. This will clear your transactions data in {account}. Please enter your current password to confirm.",
|
||||
"accountActivationAndResendValidationEmailTip": "Een activatielink is verzonden naar je e-mailadres: {email}. Als je de e-mail niet ontvangt, vul dan je wachtwoord opnieuw in en klik op de knop hieronder om de validatiemail opnieuw te verzenden.",
|
||||
"resendValidationEmailTip": "Als je de e-mail niet ontvangt, vul dan je wachtwoord opnieuw in en klik op de knop hieronder om de validatiemail opnieuw te verzenden naar: {email}"
|
||||
"resendValidationEmailTip": "Als je de e-mail niet ontvangt, vul dan je wachtwoord opnieuw in en klik op de knop hieronder om de validatiemail opnieuw te verzenden naar: {email}",
|
||||
"oauth2bindTip": "You're signing in to the {userName} user using {providerName}. Please enter your ezBookkeeping password to verify."
|
||||
}
|
||||
},
|
||||
"dataExport": {
|
||||
@@ -1086,6 +1088,7 @@
|
||||
"user avatar file extension invalid": "Bestandsextensie van gebruikersavatar is ongeldig",
|
||||
"exceed the maximum size of user avatar file": "Geüploade avatar overschrijdt de maximaal toegestane bestandsgrootte",
|
||||
"not permitted to perform this action": "Je hebt geen toestemming om deze actie uit te voeren",
|
||||
"cannot login by password": "You cannot login by password",
|
||||
"unauthorized access": "Ongeautoriseerde toegang",
|
||||
"current token is invalid": "Huidige token is ongeldig",
|
||||
"current token is expired": "Huidige token is verlopen",
|
||||
@@ -1238,6 +1241,17 @@
|
||||
"image for AI recognition is empty": "Image for AI recognition file is empty",
|
||||
"exceed the maximum size of image file for AI recognition": "The uploaded image for AI recognition exceeds the maximum allowed file size",
|
||||
"no transaction information detected": "No transaction information detected",
|
||||
"user external auth is not found": "User external authentication is not found",
|
||||
"oauth 2.0 not enabled": "OAuth 2.0 is not enabled",
|
||||
"oauth 2.0 auto registration not enabled": "OAuth 2.0 auto registration is not enabled",
|
||||
"invalid oauth 2.0 login request": "Invalid OAuth 2.0 login request",
|
||||
"invalid oauth 2.0 callback": "Invalid OAuth 2.0 callback",
|
||||
"missing state in oauth 2.0 callback": "Missing state parameter in OAuth 2.0 callback",
|
||||
"missing code in oauth 2.0 callback": "Missing code parameter in OAuth 2.0 callback",
|
||||
"invalid state in oauth 2.0 callback": "Invalid state parameter in OAuth 2.0 callback",
|
||||
"cannot retrieve oauth 2.0 token": "Cannot retrieve OAuth 2.0 token",
|
||||
"invalid oauth 2.0 token": "Invalid OAuth 2.0 token",
|
||||
"cannot retrieve user info from oauth 2.0 provider": "Cannot retrieve user info from OAuth 2.0 provider",
|
||||
"query items cannot be blank": "Geen zoekitems opgegeven",
|
||||
"query items too much": "Te veel zoekitems",
|
||||
"query items have invalid item": "Ongeldig item in zoekitems",
|
||||
@@ -1391,6 +1405,7 @@
|
||||
"Operation": "Bewerking",
|
||||
"Open": "Openen",
|
||||
"Close": "Sluiten",
|
||||
"or": "or",
|
||||
"Submit": "Verzenden",
|
||||
"Add": "Toevoegen",
|
||||
"Import": "Importeren",
|
||||
@@ -1572,7 +1587,10 @@
|
||||
"This month or later": "Deze maand of later",
|
||||
"This year or later": "Dit jaar of later",
|
||||
"Log In": "Inloggen",
|
||||
"Log in with OAuth 2.0": "Log in with OAuth 2.0",
|
||||
"Log in with Connect ID": "Log in with Connect ID",
|
||||
"Click here to log in": "Klik hier om in te loggen",
|
||||
"Logging in...": "Logging in...",
|
||||
"Back to login page": "Terug naar inlogpagina",
|
||||
"Back to home page": "Terug naar startpagina",
|
||||
"Don't have an account?": "Nog geen account?",
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
},
|
||||
"misc": {
|
||||
"multiTextJoinSeparator": ", ",
|
||||
"loginWithCustomProvider": "Log in with {name}",
|
||||
"hoursBehindDefaultTimezone": "{hours} hora(s) atrás do fuso horário padrão",
|
||||
"hoursAheadOfDefaultTimezone": "{hours} hora(s) à frente do fuso horário padrão",
|
||||
"hoursMinutesBehindDefaultTimezone": "{hours} hora(s) e {minutes} minutos atrás do fuso horário padrão",
|
||||
@@ -129,7 +130,8 @@
|
||||
"moveTransactionsInAccountTip": "You CANNOT undo this action. This will move all transactions from {fromAccount} to {toAccount}.",
|
||||
"clearTransactionsInAccountTip": "You CANNOT undo this action. This will clear your transactions data in {account}. Please enter your current password to confirm.",
|
||||
"accountActivationAndResendValidationEmailTip": "O link de ativação da conta foi enviado para seu endereço de e-mail: {email}. Se você não receber o e-mail, por favor preencha a senha novamente e clique no botão abaixo para reenviar o e-mail de validação.",
|
||||
"resendValidationEmailTip": "Se você não receber o e-mail, por favor preencha a senha novamente e clique no botão abaixo para reenviar o e-mail de validação para: {email}"
|
||||
"resendValidationEmailTip": "Se você não receber o e-mail, por favor preencha a senha novamente e clique no botão abaixo para reenviar o e-mail de validação para: {email}",
|
||||
"oauth2bindTip": "You're signing in to the {userName} user using {providerName}. Please enter your ezBookkeeping password to verify."
|
||||
}
|
||||
},
|
||||
"dataExport": {
|
||||
@@ -1086,6 +1088,7 @@
|
||||
"user avatar file extension invalid": "A extensão do arquivo de avatar do usuário é inválida",
|
||||
"exceed the maximum size of user avatar file": "O avatar de usuário enviado excede o tamanho máximo permitido de arquivo",
|
||||
"not permitted to perform this action": "Você não tem permissão para realizar esta ação",
|
||||
"cannot login by password": "You cannot login by password",
|
||||
"unauthorized access": "Acesso não autorizado",
|
||||
"current token is invalid": "Token atual é inválido",
|
||||
"current token is expired": "Token atual está expirado",
|
||||
@@ -1238,6 +1241,17 @@
|
||||
"image for AI recognition is empty": "Image for AI recognition file is empty",
|
||||
"exceed the maximum size of image file for AI recognition": "The uploaded image for AI recognition exceeds the maximum allowed file size",
|
||||
"no transaction information detected": "No transaction information detected",
|
||||
"user external auth is not found": "User external authentication is not found",
|
||||
"oauth 2.0 not enabled": "OAuth 2.0 is not enabled",
|
||||
"oauth 2.0 auto registration not enabled": "OAuth 2.0 auto registration is not enabled",
|
||||
"invalid oauth 2.0 login request": "Invalid OAuth 2.0 login request",
|
||||
"invalid oauth 2.0 callback": "Invalid OAuth 2.0 callback",
|
||||
"missing state in oauth 2.0 callback": "Missing state parameter in OAuth 2.0 callback",
|
||||
"missing code in oauth 2.0 callback": "Missing code parameter in OAuth 2.0 callback",
|
||||
"invalid state in oauth 2.0 callback": "Invalid state parameter in OAuth 2.0 callback",
|
||||
"cannot retrieve oauth 2.0 token": "Cannot retrieve OAuth 2.0 token",
|
||||
"invalid oauth 2.0 token": "Invalid OAuth 2.0 token",
|
||||
"cannot retrieve user info from oauth 2.0 provider": "Cannot retrieve user info from OAuth 2.0 provider",
|
||||
"query items cannot be blank": "Não há itens de consulta",
|
||||
"query items too much": "Há muitos itens de consulta",
|
||||
"query items have invalid item": "Há item inválido nos itens de consulta",
|
||||
@@ -1391,6 +1405,7 @@
|
||||
"Operation": "Operação",
|
||||
"Open": "Open",
|
||||
"Close": "Fechar",
|
||||
"or": "or",
|
||||
"Submit": "Enviar",
|
||||
"Add": "Adicionar",
|
||||
"Import": "Importar",
|
||||
@@ -1572,7 +1587,10 @@
|
||||
"This month or later": "Este mês ou depois",
|
||||
"This year or later": "Este ano ou depois",
|
||||
"Log In": "Fazer Login",
|
||||
"Log in with OAuth 2.0": "Log in with OAuth 2.0",
|
||||
"Log in with Connect ID": "Log in with Connect ID",
|
||||
"Click here to log in": "Clique aqui para fazer login",
|
||||
"Logging in...": "Logging in...",
|
||||
"Back to login page": "Voltar para a página de login",
|
||||
"Back to home page": "Voltar para a página inicial",
|
||||
"Don't have an account?": "Não tem uma conta?",
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
},
|
||||
"misc": {
|
||||
"multiTextJoinSeparator": ", ",
|
||||
"loginWithCustomProvider": "Log in with {name}",
|
||||
"hoursBehindDefaultTimezone": "{hours} час(ов) позади часового пояса по умолчанию",
|
||||
"hoursAheadOfDefaultTimezone": "{hours} час(ов) впереди часового пояса по умолчанию",
|
||||
"hoursMinutesBehindDefaultTimezone": "{hours} час(ов) и {minutes} минут позади часового пояса по умолчанию",
|
||||
@@ -129,7 +130,8 @@
|
||||
"moveTransactionsInAccountTip": "You CANNOT undo this action. This will move all transactions from {fromAccount} to {toAccount}.",
|
||||
"clearTransactionsInAccountTip": "You CANNOT undo this action. This will clear your transactions data in {account}. Please enter your current password to confirm.",
|
||||
"accountActivationAndResendValidationEmailTip": "Ссылка для активации учетной записи была отправлена на ваш электронный адрес: {email}. Если вы не получили письмо, заполните пароль снова и нажмите кнопку ниже, чтобы отправить письмо повторно.",
|
||||
"resendValidationEmailTip": "Если вы не получили письмо, заполните пароль снова и нажмите кнопку ниже, чтобы отправить письмо повторно на: {email}"
|
||||
"resendValidationEmailTip": "Если вы не получили письмо, заполните пароль снова и нажмите кнопку ниже, чтобы отправить письмо повторно на: {email}",
|
||||
"oauth2bindTip": "You're signing in to the {userName} user using {providerName}. Please enter your ezBookkeeping password to verify."
|
||||
}
|
||||
},
|
||||
"dataExport": {
|
||||
@@ -1086,6 +1088,7 @@
|
||||
"user avatar file extension invalid": "Недопустимое расширение файла аватара пользователя",
|
||||
"exceed the maximum size of user avatar file": "Загруженный аватар пользователя превышает максимально допустимый размер файла",
|
||||
"not permitted to perform this action": "Вам не разрешено выполнять это действие",
|
||||
"cannot login by password": "You cannot login by password",
|
||||
"unauthorized access": "Несанкционированный доступ",
|
||||
"current token is invalid": "Текущий токен недействителен",
|
||||
"current token is expired": "Текущий токен истек",
|
||||
@@ -1238,6 +1241,17 @@
|
||||
"image for AI recognition is empty": "Image for AI recognition file is empty",
|
||||
"exceed the maximum size of image file for AI recognition": "The uploaded image for AI recognition exceeds the maximum allowed file size",
|
||||
"no transaction information detected": "No transaction information detected",
|
||||
"user external auth is not found": "User external authentication is not found",
|
||||
"oauth 2.0 not enabled": "OAuth 2.0 is not enabled",
|
||||
"oauth 2.0 auto registration not enabled": "OAuth 2.0 auto registration is not enabled",
|
||||
"invalid oauth 2.0 login request": "Invalid OAuth 2.0 login request",
|
||||
"invalid oauth 2.0 callback": "Invalid OAuth 2.0 callback",
|
||||
"missing state in oauth 2.0 callback": "Missing state parameter in OAuth 2.0 callback",
|
||||
"missing code in oauth 2.0 callback": "Missing code parameter in OAuth 2.0 callback",
|
||||
"invalid state in oauth 2.0 callback": "Invalid state parameter in OAuth 2.0 callback",
|
||||
"cannot retrieve oauth 2.0 token": "Cannot retrieve OAuth 2.0 token",
|
||||
"invalid oauth 2.0 token": "Invalid OAuth 2.0 token",
|
||||
"cannot retrieve user info from oauth 2.0 provider": "Cannot retrieve user info from OAuth 2.0 provider",
|
||||
"query items cannot be blank": "Нет элементов запроса",
|
||||
"query items too much": "Слишком много элементов запроса",
|
||||
"query items have invalid item": "В элементах запроса присутствует недопустимый элемент",
|
||||
@@ -1391,6 +1405,7 @@
|
||||
"Operation": "Операция",
|
||||
"Open": "Open",
|
||||
"Close": "Закрыть",
|
||||
"or": "or",
|
||||
"Submit": "Отправить",
|
||||
"Add": "Добавить",
|
||||
"Import": "Импорт",
|
||||
@@ -1572,7 +1587,10 @@
|
||||
"This month or later": "В этом месяце или позже",
|
||||
"This year or later": "В этом году или позже",
|
||||
"Log In": "Войти",
|
||||
"Log in with OAuth 2.0": "Log in with OAuth 2.0",
|
||||
"Log in with Connect ID": "Log in with Connect ID",
|
||||
"Click here to log in": "Нажмите здесь, чтобы войти",
|
||||
"Logging in...": "Logging in...",
|
||||
"Back to login page": "Вернуться на страницу входа",
|
||||
"Back to home page": "Вернуться на главную страницу",
|
||||
"Don't have an account?": "Нет учетной записи?",
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
},
|
||||
"misc": {
|
||||
"multiTextJoinSeparator": ", ",
|
||||
"loginWithCustomProvider": "Log in with {name}",
|
||||
"hoursBehindDefaultTimezone": "ช้ากว่าเขตเวลาเริ่มต้น {hours} ชั่วโมง",
|
||||
"hoursAheadOfDefaultTimezone": "เร็วกว่าเขตเวลาเริ่มต้น {hours} ชั่วโมง",
|
||||
"hoursMinutesBehindDefaultTimezone": "ช้ากว่าเขตเวลาเริ่มต้น {hours} ชั่วโมง {minutes} นาที",
|
||||
@@ -129,7 +130,8 @@
|
||||
"moveTransactionsInAccountTip": "You CANNOT undo this action. This will move all transactions from {fromAccount} to {toAccount}.",
|
||||
"clearTransactionsInAccountTip": "คุณไม่สามารถยกเลิกการกระทำนี้ได้ การกระทำนี้จะลบข้อมูลธุรกรรมทั้งหมดใน {account} โปรดป้อนรหัสผ่านปัจจุบันเพื่อยืนยัน",
|
||||
"accountActivationAndResendValidationEmailTip": "ลิงก์สำหรับเปิดใช้งานบัญชีได้ถูกส่งไปยังอีเมลของคุณแล้ว: {email} หากคุณไม่ได้รับอีเมล โปรดกรอกรหัสผ่านอีกครั้งแล้วกดปุ่มด้านล่างเพื่อส่งอีเมลยืนยันอีกครั้ง",
|
||||
"resendValidationEmailTip": "หากคุณไม่ได้รับอีเมล โปรดกรอกรหัสผ่านอีกครั้งแล้วกดปุ่มด้านล่างเพื่อส่งอีเมลยืนยันไปยัง: {email}"
|
||||
"resendValidationEmailTip": "หากคุณไม่ได้รับอีเมล โปรดกรอกรหัสผ่านอีกครั้งแล้วกดปุ่มด้านล่างเพื่อส่งอีเมลยืนยันไปยัง: {email}",
|
||||
"oauth2bindTip": "You're signing in to the {userName} user using {providerName}. Please enter your ezBookkeeping password to verify."
|
||||
}
|
||||
},
|
||||
"dataExport": {
|
||||
@@ -1086,6 +1088,7 @@
|
||||
"user avatar file extension invalid": "นามสกุลไฟล์รูปประจำตัวผู้ใช้ไม่ถูกต้อง",
|
||||
"exceed the maximum size of user avatar file": "ขนาดไฟล์รูปประจำตัวผู้ใช้เกินขนาดสูงสุดที่อนุญาต",
|
||||
"not permitted to perform this action": "คุณไม่ได้รับอนุญาตให้ดำเนินการนี้",
|
||||
"cannot login by password": "You cannot login by password",
|
||||
"unauthorized access": "การเข้าถึงไม่ได้รับอนุญาต",
|
||||
"current token is invalid": "โทเค็นปัจจุบันไม่ถูกต้อง",
|
||||
"current token is expired": "โทเค็นปัจจุบันหมดอายุ",
|
||||
@@ -1238,6 +1241,17 @@
|
||||
"image for AI recognition is empty": "ไฟล์รูปภาพสำหรับการจดจำด้วย AI ว่างเปล่า",
|
||||
"exceed the maximum size of image file for AI recognition": "ไฟล์รูปภาพสำหรับการจดจำด้วย AI เกินขนาดสูงสุดที่อนุญาต",
|
||||
"no transaction information detected": "ไม่พบข้อมูลธุรกรรม",
|
||||
"user external auth is not found": "User external authentication is not found",
|
||||
"oauth 2.0 not enabled": "OAuth 2.0 is not enabled",
|
||||
"oauth 2.0 auto registration not enabled": "OAuth 2.0 auto registration is not enabled",
|
||||
"invalid oauth 2.0 login request": "Invalid OAuth 2.0 login request",
|
||||
"invalid oauth 2.0 callback": "Invalid OAuth 2.0 callback",
|
||||
"missing state in oauth 2.0 callback": "Missing state parameter in OAuth 2.0 callback",
|
||||
"missing code in oauth 2.0 callback": "Missing code parameter in OAuth 2.0 callback",
|
||||
"invalid state in oauth 2.0 callback": "Invalid state parameter in OAuth 2.0 callback",
|
||||
"cannot retrieve oauth 2.0 token": "Cannot retrieve OAuth 2.0 token",
|
||||
"invalid oauth 2.0 token": "Invalid OAuth 2.0 token",
|
||||
"cannot retrieve user info from oauth 2.0 provider": "Cannot retrieve user info from OAuth 2.0 provider",
|
||||
"query items cannot be blank": "ไม่มีรายการสำหรับค้นหา",
|
||||
"query items too much": "รายการค้นหามากเกินไป",
|
||||
"query items have invalid item": "มีรายการไม่ถูกต้องในรายการค้นหา",
|
||||
@@ -1391,6 +1405,7 @@
|
||||
"Operation": "การดำเนินการ",
|
||||
"Open": "เปิด",
|
||||
"Close": "ปิด",
|
||||
"or": "or",
|
||||
"Submit": "ส่ง",
|
||||
"Add": "เพิ่ม",
|
||||
"Import": "นำเข้า",
|
||||
@@ -1572,7 +1587,10 @@
|
||||
"This month or later": "เดือนนี้หรือหลังจากนี้",
|
||||
"This year or later": "ปีนี้หรือหลังจากนี้",
|
||||
"Log In": "เข้าสู่ระบบ",
|
||||
"Log in with OAuth 2.0": "Log in with OAuth 2.0",
|
||||
"Log in with Connect ID": "Log in with Connect ID",
|
||||
"Click here to log in": "คลิกที่นี่เพื่อเข้าสู่ระบบ",
|
||||
"Logging in...": "Logging in...",
|
||||
"Back to login page": "กลับไปยังหน้าล็อกอิน",
|
||||
"Back to home page": "กลับไปยังหน้าหลัก",
|
||||
"Don't have an account?": "ยังไม่มีบัญชี?",
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
},
|
||||
"misc": {
|
||||
"multiTextJoinSeparator": ", ",
|
||||
"loginWithCustomProvider": "Log in with {name}",
|
||||
"hoursBehindDefaultTimezone": "{hours} год позаду часового поясу за замовчуванням",
|
||||
"hoursAheadOfDefaultTimezone": "{hours} год попереду часового поясу за замовчуванням",
|
||||
"hoursMinutesBehindDefaultTimezone": "{hours} год і {minutes} хв позаду часового поясу за замовчуванням",
|
||||
@@ -129,7 +130,8 @@
|
||||
"moveTransactionsInAccountTip": "You CANNOT undo this action. This will move all transactions from {fromAccount} to {toAccount}.",
|
||||
"clearTransactionsInAccountTip": "You CANNOT undo this action. This will clear your transactions data in {account}. Please enter your current password to confirm.",
|
||||
"accountActivationAndResendValidationEmailTip": "Посилання для активації облікового запису було надіслано на вашу електронну адресу: {email}. Якщо ви не отримали лист, введіть пароль ще раз і натисніть кнопку нижче, щоб надіслати лист повторно.",
|
||||
"resendValidationEmailTip": "Якщо ви не отримали лист, введіть пароль ще раз і натисніть кнопку нижче, щоб надіслати лист повторно на адресу: {email}"
|
||||
"resendValidationEmailTip": "Якщо ви не отримали лист, введіть пароль ще раз і натисніть кнопку нижче, щоб надіслати лист повторно на адресу: {email}",
|
||||
"oauth2bindTip": "You're signing in to the {userName} user using {providerName}. Please enter your ezBookkeeping password to verify."
|
||||
}
|
||||
},
|
||||
"dataExport": {
|
||||
@@ -1086,6 +1088,7 @@
|
||||
"user avatar file extension invalid": "Недопустиме розширення файлу аватара користувача",
|
||||
"exceed the maximum size of user avatar file": "Завантажений аватар перевищує максимально допустимий розмір",
|
||||
"not permitted to perform this action": "Вам не дозволено виконувати цю дію",
|
||||
"cannot login by password": "You cannot login by password",
|
||||
"unauthorized access": "Несанкціонований доступ",
|
||||
"current token is invalid": "Поточний токен недійсний",
|
||||
"current token is expired": "Поточний токен прострочений",
|
||||
@@ -1238,6 +1241,17 @@
|
||||
"image for AI recognition is empty": "Image for AI recognition file is empty",
|
||||
"exceed the maximum size of image file for AI recognition": "The uploaded image for AI recognition exceeds the maximum allowed file size",
|
||||
"no transaction information detected": "No transaction information detected",
|
||||
"user external auth is not found": "User external authentication is not found",
|
||||
"oauth 2.0 not enabled": "OAuth 2.0 is not enabled",
|
||||
"oauth 2.0 auto registration not enabled": "OAuth 2.0 auto registration is not enabled",
|
||||
"invalid oauth 2.0 login request": "Invalid OAuth 2.0 login request",
|
||||
"invalid oauth 2.0 callback": "Invalid OAuth 2.0 callback",
|
||||
"missing state in oauth 2.0 callback": "Missing state parameter in OAuth 2.0 callback",
|
||||
"missing code in oauth 2.0 callback": "Missing code parameter in OAuth 2.0 callback",
|
||||
"invalid state in oauth 2.0 callback": "Invalid state parameter in OAuth 2.0 callback",
|
||||
"cannot retrieve oauth 2.0 token": "Cannot retrieve OAuth 2.0 token",
|
||||
"invalid oauth 2.0 token": "Invalid OAuth 2.0 token",
|
||||
"cannot retrieve user info from oauth 2.0 provider": "Cannot retrieve user info from OAuth 2.0 provider",
|
||||
"query items cannot be blank": "Елементи запиту не можуть бути порожніми",
|
||||
"query items too much": "Занадто багато елементів запиту",
|
||||
"query items have invalid item": "Запит містить недійсний елемент",
|
||||
@@ -1391,6 +1405,7 @@
|
||||
"Operation": "Дія",
|
||||
"Open": "Open",
|
||||
"Close": "Закрити",
|
||||
"or": "or",
|
||||
"Submit": "Підтвердити",
|
||||
"Add": "Додати",
|
||||
"Import": "Імпортувати",
|
||||
@@ -1572,7 +1587,10 @@
|
||||
"This month or later": "Цього місяця або пізніше",
|
||||
"This year or later": "Цього року або пізніше",
|
||||
"Log In": "Увійти",
|
||||
"Log in with OAuth 2.0": "Log in with OAuth 2.0",
|
||||
"Log in with Connect ID": "Log in with Connect ID",
|
||||
"Click here to log in": "Натисніть тут, щоб увійти",
|
||||
"Logging in...": "Logging in...",
|
||||
"Back to login page": "Повернутися на сторінку входу",
|
||||
"Back to home page": "Повернутися на головну сторінку",
|
||||
"Don't have an account?": "Немає облікового запису?",
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
},
|
||||
"misc": {
|
||||
"multiTextJoinSeparator": ", ",
|
||||
"loginWithCustomProvider": "Log in with {name}",
|
||||
"hoursBehindDefaultTimezone": "{hours} giờ sau múi giờ mặc định",
|
||||
"hoursAheadOfDefaultTimezone": "{hours} giờ trước múi giờ mặc định",
|
||||
"hoursMinutesBehindDefaultTimezone": "{hours} giờ và {minutes} phút sau múi giờ mặc định",
|
||||
@@ -129,7 +130,8 @@
|
||||
"moveTransactionsInAccountTip": "You CANNOT undo this action. This will move all transactions from {fromAccount} to {toAccount}.",
|
||||
"clearTransactionsInAccountTip": "You CANNOT undo this action. This will clear your transactions data in {account}. Please enter your current password to confirm.",
|
||||
"accountActivationAndResendValidationEmailTip": "Liên kết kích hoạt tài khoản đã được gửi tới email của bạn: {email}. Nếu bạn không nhận được email, vui lòng nhập lại mật khẩu và nhấp nút bên dưới để gửi lại email xác nhận.",
|
||||
"resendValidationEmailTip": "Nếu bạn không nhận được email, vui lòng nhập lại mật khẩu và nhấp nút bên dưới để gửi lại email xác nhận tới: {email}"
|
||||
"resendValidationEmailTip": "Nếu bạn không nhận được email, vui lòng nhập lại mật khẩu và nhấp nút bên dưới để gửi lại email xác nhận tới: {email}",
|
||||
"oauth2bindTip": "You're signing in to the {userName} user using {providerName}. Please enter your ezBookkeeping password to verify."
|
||||
}
|
||||
},
|
||||
"dataExport": {
|
||||
@@ -1086,6 +1088,7 @@
|
||||
"user avatar file extension invalid": "Đuôi tệp avatar người dùng không hợp lệ",
|
||||
"exceed the maximum size of user avatar file": "Avatar người dùng đã tải lên vượt quá kích thước tệp tối đa cho phép",
|
||||
"not permitted to perform this action": "Bạn không được phép thực hiện hành động này",
|
||||
"cannot login by password": "You cannot login by password",
|
||||
"unauthorized access": "Truy cập trái phép",
|
||||
"current token is invalid": "Mã thông báo hiện tại không hợp lệ",
|
||||
"current token is expired": "Mã thông báo hiện tại đã hết hạn",
|
||||
@@ -1238,6 +1241,17 @@
|
||||
"image for AI recognition is empty": "Image for AI recognition file is empty",
|
||||
"exceed the maximum size of image file for AI recognition": "The uploaded image for AI recognition exceeds the maximum allowed file size",
|
||||
"no transaction information detected": "No transaction information detected",
|
||||
"user external auth is not found": "User external authentication is not found",
|
||||
"oauth 2.0 not enabled": "OAuth 2.0 is not enabled",
|
||||
"oauth 2.0 auto registration not enabled": "OAuth 2.0 auto registration is not enabled",
|
||||
"invalid oauth 2.0 login request": "Invalid OAuth 2.0 login request",
|
||||
"invalid oauth 2.0 callback": "Invalid OAuth 2.0 callback",
|
||||
"missing state in oauth 2.0 callback": "Missing state parameter in OAuth 2.0 callback",
|
||||
"missing code in oauth 2.0 callback": "Missing code parameter in OAuth 2.0 callback",
|
||||
"invalid state in oauth 2.0 callback": "Invalid state parameter in OAuth 2.0 callback",
|
||||
"cannot retrieve oauth 2.0 token": "Cannot retrieve OAuth 2.0 token",
|
||||
"invalid oauth 2.0 token": "Invalid OAuth 2.0 token",
|
||||
"cannot retrieve user info from oauth 2.0 provider": "Cannot retrieve user info from OAuth 2.0 provider",
|
||||
"query items cannot be blank": "Không có mục truy vấn",
|
||||
"query items too much": "Có quá nhiều mục truy vấn",
|
||||
"query items have invalid item": "Có mục không hợp lệ trong các mục truy vấn",
|
||||
@@ -1391,6 +1405,7 @@
|
||||
"Operation": "Thao tác",
|
||||
"Open": "Open",
|
||||
"Close": "Đóng",
|
||||
"or": "or",
|
||||
"Submit": "Gửi",
|
||||
"Add": "Thêm",
|
||||
"Import": "Nhập",
|
||||
@@ -1572,7 +1587,10 @@
|
||||
"This month or later": "Tháng này trở đi",
|
||||
"This year or later": "Năm nay trở đi",
|
||||
"Log In": "Đăng nhập",
|
||||
"Log in with OAuth 2.0": "Log in with OAuth 2.0",
|
||||
"Log in with Connect ID": "Log in with Connect ID",
|
||||
"Click here to log in": "Nhấp vào đây để đăng nhập",
|
||||
"Logging in...": "Logging in...",
|
||||
"Back to login page": "Quay lại trang đăng nhập",
|
||||
"Back to home page": "Quay lại trang chủ",
|
||||
"Don't have an account?": "Bạn chưa có tài khoản?",
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
},
|
||||
"misc": {
|
||||
"multiTextJoinSeparator": "、",
|
||||
"loginWithCustomProvider": "使用 {name} 登录",
|
||||
"hoursBehindDefaultTimezone": "比默认时区晚{hours}小时",
|
||||
"hoursAheadOfDefaultTimezone": "比默认时区早{hours}小时",
|
||||
"hoursMinutesBehindDefaultTimezone": "比默认时区晚{hours}小时{minutes}分",
|
||||
@@ -129,7 +130,8 @@
|
||||
"moveTransactionsInAccountTip": "您不能撤销该操作。该操作将会把 {fromAccount} 账户中所有的交易数据移动到 {toAccount}。",
|
||||
"clearTransactionsInAccountTip": "您不能撤销该操作。该操作将会清除您在 {account} 账户中的交易数据。请输入您当前的密码以确认。",
|
||||
"accountActivationAndResendValidationEmailTip": "账号激活链接已经发送到您的邮箱地址:{email},如果您没有收到邮件,请再次输入密码并点击下方的按钮重新发送验证邮件。",
|
||||
"resendValidationEmailTip": "如果您没有收到邮件,请再次输入密码并点击下方的按钮重新发送验证邮件到:{email}"
|
||||
"resendValidationEmailTip": "如果您没有收到邮件,请再次输入密码并点击下方的按钮重新发送验证邮件到:{email}",
|
||||
"oauth2bindTip": "您正在使用 {providerName} 登录 \"{userName}\" 用户,请输入你的 ezBookkeeping 的密码进行验证。"
|
||||
}
|
||||
},
|
||||
"dataExport": {
|
||||
@@ -1086,6 +1088,7 @@
|
||||
"user avatar file extension invalid": "用户头像文件扩展名无效",
|
||||
"exceed the maximum size of user avatar file": "上传的用户头像超出了允许的最大文件大小",
|
||||
"not permitted to perform this action": "您不能执行该操作",
|
||||
"cannot login by password": "您不能使用密码登录",
|
||||
"unauthorized access": "未授权的登录",
|
||||
"current token is invalid": "当前认证令牌无效",
|
||||
"current token is expired": "当前认证令牌已过期",
|
||||
@@ -1238,6 +1241,17 @@
|
||||
"image for AI recognition is empty": "用于AI识别的图片为空",
|
||||
"exceed the maximum size of image file for AI recognition": "用于AI识别的图片超出了允许的最大文件大小",
|
||||
"no transaction information detected": "没有检测到交易信息",
|
||||
"user external auth is not found": "用户外部认证信息不存在",
|
||||
"oauth 2.0 not enabled": "OAuth 2.0 没有启用",
|
||||
"oauth 2.0 auto registration not enabled": "OAuth 2.0 自动注册没有启用",
|
||||
"invalid oauth 2.0 login request": "无效的 OAuth 2.0 登录请求",
|
||||
"invalid oauth 2.0 callback": "无效的 OAuth 2.0 回调请求",
|
||||
"missing state in oauth 2.0 callback": "OAuth 2.0 回调中缺少 state 参数",
|
||||
"missing code in oauth 2.0 callback": "OAuth 2.0 回调中缺少 code 参数",
|
||||
"invalid state in oauth 2.0 callback": "OAuth 2.0 回调中的 state 参数无效",
|
||||
"cannot retrieve oauth 2.0 token": "无法获取 OAuth 2.0 令牌",
|
||||
"invalid oauth 2.0 token": "无效的 OAuth 2.0 令牌",
|
||||
"cannot retrieve user info from oauth 2.0 provider": "无法从 OAuth 2.0 提供者获取用户信息",
|
||||
"query items cannot be blank": "请求项目不能为空",
|
||||
"query items too much": "请求项目过多",
|
||||
"query items have invalid item": "请求项目中有非法项目",
|
||||
@@ -1391,6 +1405,7 @@
|
||||
"Operation": "操作",
|
||||
"Open": "打开",
|
||||
"Close": "关闭",
|
||||
"or": "或",
|
||||
"Submit": "提交",
|
||||
"Add": "添加",
|
||||
"Import": "导入",
|
||||
@@ -1572,7 +1587,10 @@
|
||||
"This month or later": "本月或更晚",
|
||||
"This year or later": "今年或更晚",
|
||||
"Log In": "登录",
|
||||
"Log in with OAuth 2.0": "使用 OAuth 2.0 登录",
|
||||
"Log in with Connect ID": "使用 Connect ID 登录",
|
||||
"Click here to log in": "点击这里登录",
|
||||
"Logging in...": "正在登录...",
|
||||
"Back to login page": "返回登录页",
|
||||
"Back to home page": "返回首页",
|
||||
"Don't have an account?": "还没有账号?",
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
},
|
||||
"misc": {
|
||||
"multiTextJoinSeparator": "、",
|
||||
"loginWithCustomProvider": "使用 {name} 登入",
|
||||
"hoursBehindDefaultTimezone": "比預設時區晚{hours}小時",
|
||||
"hoursAheadOfDefaultTimezone": "比預設時區早{hours}小時",
|
||||
"hoursMinutesBehindDefaultTimezone": "比預設時區晚{hours}小時{minutes}分",
|
||||
@@ -129,7 +130,8 @@
|
||||
"moveTransactionsInAccountTip": "您不能還原此操作。此操作將會把 {fromAccount} 帳戶中的所有交易資料移動到 {toAccount}。",
|
||||
"clearTransactionsInAccountTip": "您不能還原此操作。此操作將會清除您在 {account} 帳戶中的交易資料。請輸入您目前的密碼以確認。",
|
||||
"accountActivationAndResendValidationEmailTip": "帳號啟用連結已經傳送到您的信箱地址:{email},如果您沒有收到郵件,請再次輸入密碼並點擊下方的按鈕重新發送驗證郵件。",
|
||||
"resendValidationEmailTip": "如果您沒有收到郵件,請再次輸入密碼並點擊下方的按鈕重新發送驗證郵件到:{email}"
|
||||
"resendValidationEmailTip": "如果您沒有收到郵件,請再次輸入密碼並點擊下方的按鈕重新發送驗證郵件到:{email}",
|
||||
"oauth2bindTip": "您正在使用 {providerName} 登入 \"{userName}\" 使用者,請輸入您的 ezBookkeeping 的密碼以進行驗證。"
|
||||
}
|
||||
},
|
||||
"dataExport": {
|
||||
@@ -1086,6 +1088,7 @@
|
||||
"user avatar file extension invalid": "使用者頭像檔案副檔名無效",
|
||||
"exceed the maximum size of user avatar file": "上傳的使用者頭像超過允許的最大檔案大小",
|
||||
"not permitted to perform this action": "您不能執行該操作",
|
||||
"cannot login by password": "您不能使用密碼登入",
|
||||
"unauthorized access": "未授權的登入",
|
||||
"current token is invalid": "目前認證令牌無效",
|
||||
"current token is expired": "目前認證令牌已過期",
|
||||
@@ -1238,6 +1241,17 @@
|
||||
"image for AI recognition is empty": "用於AI識別的圖片檔案為空",
|
||||
"exceed the maximum size of image file for AI recognition": "用於AI識別的圖片超出了允許的最大檔案大小",
|
||||
"no transaction information detected": "沒有檢測到交易資訊",
|
||||
"user external auth is not found": "使用者外部驗證資訊不存在",
|
||||
"oauth 2.0 not enabled": "OAuth 2.0 未啟用",
|
||||
"oauth 2.0 auto registration not enabled": "OAuth 2.0 自動註冊未啟用",
|
||||
"invalid oauth 2.0 login request": "無效的 OAuth 2.0 登入請求",
|
||||
"invalid oauth 2.0 callback": "無效的 OAuth 2.0 回調請求",
|
||||
"missing state in oauth 2.0 callback": "OAuth 2.0 回調中缺少 state 參數",
|
||||
"missing code in oauth 2.0 callback": "OAuth 2.0 回調中缺少 code 參數",
|
||||
"invalid state in oauth 2.0 callback": "OAuth 2.0 回調中的 state 參數無效",
|
||||
"cannot retrieve oauth 2.0 token": "無法獲取 OAuth 2.0 令牌",
|
||||
"invalid oauth 2.0 token": "無效的 OAuth 2.0 令牌",
|
||||
"cannot retrieve user info from oauth 2.0 provider": "無法從 OAuth 2.0 提供者獲取使用者資訊",
|
||||
"query items cannot be blank": "查詢項目不能為空",
|
||||
"query items too much": "查詢項目過多",
|
||||
"query items have invalid item": "查詢項目中有非法項目",
|
||||
@@ -1391,6 +1405,7 @@
|
||||
"Operation": "操作",
|
||||
"Open": "開啟",
|
||||
"Close": "關閉",
|
||||
"or": "或",
|
||||
"Submit": "提交",
|
||||
"Add": "新增",
|
||||
"Import": "匯入",
|
||||
@@ -1572,7 +1587,10 @@
|
||||
"This month or later": "本月或更晚",
|
||||
"This year or later": "今年或更晚",
|
||||
"Log In": "登入",
|
||||
"Log in with OAuth 2.0": "使用 OAuth 2.0 登入",
|
||||
"Log in with Connect ID": "使用 Connect ID 登入",
|
||||
"Click here to log in": "點擊這裡登入",
|
||||
"Logging in...": "正在登入...",
|
||||
"Back to login page": "返回登入頁面",
|
||||
"Back to home page": "返回首頁",
|
||||
"Don't have an account?": "還沒有帳號?",
|
||||
|
||||
4
src/models/oauth2.ts
Normal file
4
src/models/oauth2.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface OAuth2CallbackLoginRequest {
|
||||
readonly provider?: string;
|
||||
readonly password?: string;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ 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 OAuth2CallbackPage from '@/views/desktop/OAuth2CallbackPage.vue';
|
||||
import UnlockPage from '@/views/desktop/UnlockPage.vue';
|
||||
|
||||
import HomePage from '@/views/desktop/HomePage.vue';
|
||||
@@ -226,6 +227,17 @@ const router = createRouter({
|
||||
token: route.query['token']
|
||||
})
|
||||
},
|
||||
{
|
||||
path: '/oauth2_callback',
|
||||
component: OAuth2CallbackPage,
|
||||
props: route => ({
|
||||
token: route.query['token'],
|
||||
provider: route.query['provider'],
|
||||
platform: route.query['platform'],
|
||||
userName: route.query['userName'],
|
||||
error: route.query['error']
|
||||
})
|
||||
},
|
||||
{
|
||||
path: '/unlock',
|
||||
component: UnlockPage,
|
||||
|
||||
@@ -21,8 +21,8 @@ import type {
|
||||
UserProfileUpdateRequest,
|
||||
UserProfileUpdateResponse
|
||||
} from '@/models/user.ts';
|
||||
import type { LocalizedPresetCategory } from '@/core/category.ts';
|
||||
import type { ForgetPasswordRequest } from '@/models/forget_password.ts';
|
||||
import type { LocalizedPresetCategory } from '@/core/category.ts';
|
||||
|
||||
import {
|
||||
isObject,
|
||||
@@ -77,6 +77,10 @@ export const useRootStore = defineStore('root', () => {
|
||||
currentNotification.value = content;
|
||||
}
|
||||
|
||||
function generateOAuth2LoginUrl(platform: 'mobile' | 'desktop', clientSessionId: string): string {
|
||||
return services.generateOAuth2LoginUrl(platform, clientSessionId);
|
||||
}
|
||||
|
||||
function authorize(req: UserLoginRequest): Promise<AuthResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
services.authorize(req).then(response => {
|
||||
@@ -187,6 +191,56 @@ export const useRootStore = defineStore('root', () => {
|
||||
});
|
||||
}
|
||||
|
||||
function authorizeOAuth2({ provider, password, token }: { provider: string, password?: string, token: string }): Promise<AuthResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
services.authorizeOAuth2({
|
||||
req: {
|
||||
provider,
|
||||
password
|
||||
},
|
||||
token
|
||||
}).then(response => {
|
||||
const data = response.data;
|
||||
|
||||
if (!data || !data.success || !data.result || !data.result.token) {
|
||||
reject({ message: 'Unable to log in' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (settingsStore.appSettings.applicationLock || hasUserAppLockState()) {
|
||||
const appLockState = getUserAppLockState();
|
||||
|
||||
if (!appLockState || appLockState.username !== data.result.user?.username) {
|
||||
clearCurrentTokenAndUserInfo(true);
|
||||
settingsStore.setEnableApplicationLock(false);
|
||||
settingsStore.setEnableApplicationLockWebAuthn(false);
|
||||
clearWebAuthnConfig();
|
||||
}
|
||||
}
|
||||
|
||||
settingsStore.setApplicationSettingsFromCloudSettings(data.result.applicationCloudSettings);
|
||||
|
||||
updateCurrentToken(data.result.token);
|
||||
|
||||
if (data.result.user && isObject(data.result.user)) {
|
||||
userStore.storeUserBasicInfo(data.result.user);
|
||||
}
|
||||
|
||||
resolve(data.result);
|
||||
}).catch(error => {
|
||||
logger.error('failed to login', 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 log in' });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function register({ user, presetCategories }: { user: User, presetCategories?: LocalizedPresetCategory[] }): Promise<RegisterResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
services.register(user.toRegisterRequest(presetCategories)).then(response => {
|
||||
@@ -588,8 +642,10 @@ export const useRootStore = defineStore('root', () => {
|
||||
currentNotification,
|
||||
// functions
|
||||
setNotificationContent,
|
||||
generateOAuth2LoginUrl,
|
||||
authorize,
|
||||
authorize2FA,
|
||||
authorizeOAuth2,
|
||||
register,
|
||||
lock,
|
||||
logout,
|
||||
|
||||
@@ -8,12 +8,12 @@ import { useExchangeRatesStore } from '@/stores/exchangeRates.ts';
|
||||
|
||||
import type { AuthResponse } from '@/models/auth_response.ts';
|
||||
|
||||
import { getLoginPageTips } from '@/lib/server_settings.ts';
|
||||
import { getOAuth2Provider, getOIDCCustomDisplayNames, getLoginPageTips } from '@/lib/server_settings.ts';
|
||||
import { getClientDisplayVersion } from '@/lib/version.ts';
|
||||
import { setExpenseAndIncomeAmountColor } from '@/lib/ui/common.ts';
|
||||
|
||||
export function useLoginPageBase() {
|
||||
const { getServerTipContent, setLanguage } = useI18n();
|
||||
export function useLoginPageBase(platform: 'mobile' | 'desktop') {
|
||||
const { getServerMultiLanguageConfigContent, getLocalizedOAuth2LoginText, setLanguage } = useI18n();
|
||||
|
||||
const rootStore = useRootStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
@@ -27,6 +27,7 @@ export function useLoginPageBase() {
|
||||
const backupCode = ref<string>('');
|
||||
const tempToken = ref<string>('');
|
||||
const twoFAVerifyType = ref<string>('passcode');
|
||||
const oauth2ClientSessionId = ref<string>('');
|
||||
|
||||
const logining = ref<boolean>(false);
|
||||
const verifying = ref<boolean>(false);
|
||||
@@ -40,7 +41,9 @@ export function useLoginPageBase() {
|
||||
}
|
||||
});
|
||||
|
||||
const tips = computed<string>(() => getServerTipContent(getLoginPageTips()));
|
||||
const oauth2LoginUrl = computed<string>(() => rootStore.generateOAuth2LoginUrl(platform, oauth2ClientSessionId.value));
|
||||
const oauth2LoginDisplayName = computed<string>(() => getLocalizedOAuth2LoginText(getOAuth2Provider(), getOIDCCustomDisplayNames()));
|
||||
const tips = computed<string>(() => getServerMultiLanguageConfigContent(getLoginPageTips()));
|
||||
|
||||
function doAfterLogin(authResponse: AuthResponse): void {
|
||||
if (authResponse.user) {
|
||||
@@ -69,11 +72,14 @@ export function useLoginPageBase() {
|
||||
backupCode,
|
||||
tempToken,
|
||||
twoFAVerifyType,
|
||||
oauth2ClientSessionId,
|
||||
logining,
|
||||
verifying,
|
||||
// computed states
|
||||
inputIsEmpty,
|
||||
twoFAInputIsEmpty,
|
||||
oauth2LoginUrl,
|
||||
oauth2LoginDisplayName,
|
||||
tips,
|
||||
// functions
|
||||
doAfterLogin
|
||||
|
||||
@@ -24,14 +24,14 @@
|
||||
<v-card variant="flat" class="w-100 mt-0 px-4 pt-12" max-width="500">
|
||||
<v-card-text>
|
||||
<h4 class="text-h4 mb-2">{{ tt('Welcome to ezBookkeeping') }}</h4>
|
||||
<p class="mb-0">{{ tt('Please log in with your ezBookkeeping account') }}</p>
|
||||
<p class="mb-0" v-if="isInternalAuthEnabled()">{{ tt('Please log in with your ezBookkeeping account') }}</p>
|
||||
<p class="mt-1 mb-0" v-if="tips">{{ tips }}</p>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-text class="pb-0 mb-6">
|
||||
<v-form>
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-col cols="12" v-if="isInternalAuthEnabled()">
|
||||
<v-text-field
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
@@ -45,7 +45,7 @@
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12">
|
||||
<v-col cols="12" v-if="isInternalAuthEnabled()">
|
||||
<v-text-field
|
||||
autocomplete="current-password"
|
||||
ref="passwordInput"
|
||||
@@ -101,10 +101,20 @@
|
||||
|
||||
<v-col cols="12">
|
||||
<v-btn block :disabled="inputIsEmpty || logining || verifying"
|
||||
@click="login" v-if="!show2faInput">
|
||||
@click="login" v-if="isInternalAuthEnabled() && !show2faInput">
|
||||
{{ tt('Log In') }}
|
||||
<v-progress-circular indeterminate size="22" class="ms-2" v-if="logining"></v-progress-circular>
|
||||
</v-btn>
|
||||
|
||||
<v-col cols="12" class="d-flex align-center px-0" v-if="isInternalAuthEnabled() && isOAuth2Enabled()">
|
||||
<v-divider class="me-3" />
|
||||
{{ tt('or') }}
|
||||
<v-divider class="ms-3" />
|
||||
</v-col>
|
||||
|
||||
<v-btn block :disabled="logining || verifying" :href="oauth2LoginUrl" v-if="isOAuth2Enabled()">
|
||||
{{ oauth2LoginDisplayName }}
|
||||
</v-btn>
|
||||
<v-btn block :disabled="twoFAInputIsEmpty || logining || verifying"
|
||||
@click="verify" v-else-if="show2faInput">
|
||||
{{ tt('Continue') }}
|
||||
@@ -112,7 +122,7 @@
|
||||
</v-btn>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" class="text-center text-base">
|
||||
<v-col cols="12" class="text-center text-base" v-if="isInternalAuthEnabled()">
|
||||
<span class="me-1">{{ tt('Don\'t have an account?') }}</span>
|
||||
<router-link class="text-primary" to="/signup"
|
||||
:class="{'disabled': !isUserRegistrationEnabled()}">
|
||||
@@ -170,7 +180,14 @@ import { ThemeType } from '@/core/theme.ts';
|
||||
import { APPLICATION_LOGO_PATH } from '@/consts/asset.ts';
|
||||
import { KnownErrorCode } from '@/consts/api.ts';
|
||||
|
||||
import { isUserRegistrationEnabled, isUserForgetPasswordEnabled, isUserVerifyEmailEnabled } from '@/lib/server_settings.ts';
|
||||
import { generateRandomUUID } from '@/lib/misc.ts';
|
||||
import {
|
||||
isUserRegistrationEnabled,
|
||||
isUserForgetPasswordEnabled,
|
||||
isUserVerifyEmailEnabled,
|
||||
isInternalAuthEnabled,
|
||||
isOAuth2Enabled
|
||||
} from '@/lib/server_settings.ts';
|
||||
|
||||
import {
|
||||
mdiOnepassword,
|
||||
@@ -194,13 +211,16 @@ const {
|
||||
backupCode,
|
||||
tempToken,
|
||||
twoFAVerifyType,
|
||||
oauth2ClientSessionId,
|
||||
logining,
|
||||
verifying,
|
||||
inputIsEmpty,
|
||||
twoFAInputIsEmpty,
|
||||
oauth2LoginUrl,
|
||||
oauth2LoginDisplayName,
|
||||
tips,
|
||||
doAfterLogin
|
||||
} = useLoginPageBase();
|
||||
} = useLoginPageBase('desktop');
|
||||
|
||||
const passwordInput = useTemplateRef<VTextField>('passwordInput');
|
||||
const passcodeInput = useTemplateRef<VTextField>('passcodeInput');
|
||||
@@ -301,4 +321,6 @@ function verify(): void {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
oauth2ClientSessionId.value = generateRandomUUID();
|
||||
</script>
|
||||
|
||||
215
src/views/desktop/OAuth2CallbackPage.vue
Normal file
215
src/views/desktop/OAuth2CallbackPage.vue
Normal file
@@ -0,0 +1,215 @@
|
||||
<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="APPLICATION_LOGO_PATH" />
|
||||
<h1 class="font-weight-medium leading-normal text-2xl">{{ tt('global.app.title') }}</h1>
|
||||
</div>
|
||||
</router-link>
|
||||
<v-row no-gutters class="auth-wrapper">
|
||||
<v-col cols="12" md="8" class="auth-image-background d-none d-md-flex align-center justify-center position-relative">
|
||||
<div class="d-flex auth-img-footer" v-if="!isDarkMode">
|
||||
<v-img class="img-with-direction" src="img/desktop/background.svg"/>
|
||||
</div>
|
||||
<div class="d-flex auth-img-footer" v-if="isDarkMode">
|
||||
<v-img class="img-with-direction" src="img/desktop/background-dark.svg"/>
|
||||
</div>
|
||||
<div class="d-flex align-center justify-center w-100 pt-10">
|
||||
<v-img class="img-with-direction" max-width="300px" src="img/desktop/people2.svg" v-if="!isDarkMode"/>
|
||||
<v-img class="img-with-direction" max-width="300px" src="img/desktop/people2-dark.svg" v-else-if="isDarkMode"/>
|
||||
</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>
|
||||
<h4 class="text-h4 mb-2">{{ oauth2LoginDisplayName }}</h4>
|
||||
<p class="mb-0" v-if="!error && platform && token && !userName">{{ tt('Logging in...') }}</p>
|
||||
<p class="mb-0" v-else-if="!error && userName">{{ tt('format.misc.oauth2bindTip', { providerName: oauth2ProviderDisplayName, userName: userName }) }}</p>
|
||||
<p class="mb-0" v-else-if="error">{{ tt(error) }}</p>
|
||||
<p class="mb-0" v-else>{{ tt('An error occurred') }}</p>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-text class="pb-0 mb-6" v-if="!error && userName">
|
||||
<v-form>
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-text-field
|
||||
type="password"
|
||||
autocomplete="password"
|
||||
:autofocus="true"
|
||||
:disabled="logining"
|
||||
:label="tt('Password')"
|
||||
:placeholder="tt('Your password')"
|
||||
v-model="password"
|
||||
@keyup.enter="verify"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12">
|
||||
<v-btn block type="submit" :disabled="!password || logining" @click="verify">
|
||||
{{ tt('Continue') }}
|
||||
<v-progress-circular indeterminate size="22" class="ms-2" v-if="logining"></v-progress-circular>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12">
|
||||
<router-link class="d-flex align-center justify-center" to="/login"
|
||||
:class="{ 'disabled': logining }">
|
||||
<v-icon class="icon-with-direction" :icon="mdiChevronLeft"/>
|
||||
<span>{{ tt('Back to login page') }}</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">
|
||||
<language-select-button :disabled="logining" />
|
||||
</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> <span>{{ version }}</span>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<snack-bar ref="snackbar" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SnackBar from '@/components/desktop/SnackBar.vue';
|
||||
|
||||
import { computed, useTemplateRef } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useTheme } from 'vuetify';
|
||||
|
||||
import { useI18n } from '@/locales/helpers.ts';
|
||||
import { useLoginPageBase } from '@/views/base/LoginPageBase.ts';
|
||||
|
||||
import { useRootStore } from '@/stores/index.ts';
|
||||
|
||||
import { ThemeType } from '@/core/theme.ts';
|
||||
import { APPLICATION_LOGO_PATH } from '@/consts/asset.ts';
|
||||
import { KnownErrorCode } from '@/consts/api.ts';
|
||||
|
||||
import {
|
||||
isUserVerifyEmailEnabled,
|
||||
getOAuth2Provider,
|
||||
getOIDCCustomDisplayNames
|
||||
} from '@/lib/server_settings.ts';
|
||||
|
||||
import {
|
||||
mdiChevronLeft
|
||||
} from '@mdi/js';
|
||||
|
||||
type SnackBarType = InstanceType<typeof SnackBar>;
|
||||
|
||||
const props = defineProps<{
|
||||
token?: string;
|
||||
provider?: string;
|
||||
platform?: string;
|
||||
userName?: string;
|
||||
error?: string;
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
const theme = useTheme();
|
||||
|
||||
const { tt, getLocalizedOAuth2ProviderName, getLocalizedOAuth2LoginText } = useI18n();
|
||||
|
||||
const rootStore = useRootStore();
|
||||
|
||||
const {
|
||||
version,
|
||||
password,
|
||||
logining,
|
||||
doAfterLogin
|
||||
} = useLoginPageBase('desktop');
|
||||
|
||||
const snackbar = useTemplateRef<SnackBarType>('snackbar');
|
||||
|
||||
const isDarkMode = computed<boolean>(() => theme.global.name.value === ThemeType.Dark);
|
||||
const oauth2ProviderDisplayName = computed<string>(() => getLocalizedOAuth2ProviderName(getOAuth2Provider(), getOIDCCustomDisplayNames()));
|
||||
const oauth2LoginDisplayName = computed<string>(() => getLocalizedOAuth2LoginText(getOAuth2Provider(), getOIDCCustomDisplayNames()));
|
||||
|
||||
const inputProblemMessage = computed<string | null>(() => {
|
||||
if (!password.value) {
|
||||
return 'Password cannot be blank';
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
function verify(): void {
|
||||
const problemMessage = inputProblemMessage.value;
|
||||
|
||||
if (problemMessage) {
|
||||
snackbar.value?.showMessage(problemMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
logining.value = true;
|
||||
|
||||
rootStore.authorizeOAuth2({
|
||||
provider: props.provider || '',
|
||||
password: password.value,
|
||||
token: props.token || ''
|
||||
}).then(authResponse => {
|
||||
logining.value = false;
|
||||
doAfterLogin(authResponse);
|
||||
router.replace('/');
|
||||
}).catch(error => {
|
||||
logining.value = false;
|
||||
|
||||
if (isUserVerifyEmailEnabled() && error.error && error.error.errorCode === KnownErrorCode.UserEmailNotVerified && error.error.context && error.error.context.email) {
|
||||
router.push(`/verify_email?email=${encodeURIComponent(error.error.context.email)}&emailSent=${error.error.context.hasValidEmailVerifyToken || false}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!error.processed) {
|
||||
snackbar.value?.showError(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!props.error && props.platform && props.token && !props.userName) {
|
||||
logining.value = true;
|
||||
|
||||
rootStore.authorizeOAuth2({
|
||||
provider: props.provider || '',
|
||||
token: props.token || ''
|
||||
}).then(authResponse => {
|
||||
logining.value = false;
|
||||
doAfterLogin(authResponse);
|
||||
router.replace('/');
|
||||
}).catch(error => {
|
||||
logining.value = false;
|
||||
|
||||
if (isUserVerifyEmailEnabled() && error.error && error.error.errorCode === KnownErrorCode.UserEmailNotVerified && error.error.context && error.error.context.email) {
|
||||
router.push(`/verify_email?email=${encodeURIComponent(error.error.context.email)}&emailSent=${error.error.context.hasValidEmailVerifyToken || false}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!error.processed) {
|
||||
snackbar.value?.showError(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
@@ -9,7 +9,7 @@
|
||||
<f7-block-footer>{{ tips }}</f7-block-footer>
|
||||
</f7-list>
|
||||
|
||||
<f7-list form dividers class="margin-bottom-half">
|
||||
<f7-list form dividers class="margin-bottom-half" v-if="isInternalAuthEnabled()">
|
||||
<f7-list-input
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
@@ -47,8 +47,14 @@
|
||||
</f7-list>
|
||||
|
||||
<f7-list class="margin-vertical-half">
|
||||
<f7-list-button :class="{ 'disabled': inputIsEmpty || logining }" :text="tt('Log In')" @click="login"></f7-list-button>
|
||||
<f7-block-footer>
|
||||
<f7-list-button :class="{ 'disabled': inputIsEmpty || logining }" :text="tt('Log In')" @click="login" v-if="isInternalAuthEnabled()"></f7-list-button>
|
||||
<f7-list-item class="login-divider display-flex align-items-center" v-if="isInternalAuthEnabled() && isOAuth2Enabled()">
|
||||
<hr class="margin-inline-end-half" />
|
||||
<small>{{ tt('or') }}</small>
|
||||
<hr class="margin-inline-start-half" />
|
||||
</f7-list-item>
|
||||
<f7-list-button external :class="{ 'disabled': logining }" :href="oauth2LoginUrl" :text="oauth2LoginDisplayName" v-if="isOAuth2Enabled()"></f7-list-button>
|
||||
<f7-block-footer v-if="isInternalAuthEnabled()">
|
||||
<span>{{ tt('Don\'t have an account?') }}</span>
|
||||
<f7-link :class="{'disabled': !isUserRegistrationEnabled()}" href="/signup" :text="tt('Create an account')"></f7-link>
|
||||
</f7-block-footer>
|
||||
@@ -176,7 +182,15 @@ import { useRootStore } from '@/stores/index.ts';
|
||||
|
||||
import { APPLICATION_LOGO_PATH } from '@/consts/asset.ts';
|
||||
import { KnownErrorCode } from '@/consts/api.ts';
|
||||
import { isUserRegistrationEnabled, isUserForgetPasswordEnabled, isUserVerifyEmailEnabled } from '@/lib/server_settings.ts';
|
||||
|
||||
import { generateRandomUUID } from '@/lib/misc.ts';
|
||||
import {
|
||||
isUserRegistrationEnabled,
|
||||
isUserForgetPasswordEnabled,
|
||||
isUserVerifyEmailEnabled,
|
||||
isInternalAuthEnabled,
|
||||
isOAuth2Enabled
|
||||
} from '@/lib/server_settings.ts';
|
||||
import { getDesktopVersionPath } from '@/lib/version.ts';
|
||||
import { useI18nUIComponents, showLoading, hideLoading, isModalShowing } from '@/lib/ui/mobile.ts';
|
||||
|
||||
@@ -197,13 +211,16 @@ const {
|
||||
backupCode,
|
||||
tempToken,
|
||||
twoFAVerifyType,
|
||||
oauth2ClientSessionId,
|
||||
logining,
|
||||
verifying,
|
||||
inputIsEmpty,
|
||||
twoFAInputIsEmpty,
|
||||
oauth2LoginUrl,
|
||||
oauth2LoginDisplayName,
|
||||
tips,
|
||||
doAfterLogin
|
||||
} = useLoginPageBase();
|
||||
} = useLoginPageBase('mobile');
|
||||
|
||||
const forgetPasswordEmail = ref<string>('');
|
||||
const resendVerifyEmail = ref<string>('');
|
||||
@@ -389,4 +406,33 @@ function switch2FAVerifyType(): void {
|
||||
twoFAVerifyType.value = 'passcode';
|
||||
}
|
||||
}
|
||||
|
||||
oauth2ClientSessionId.value = generateRandomUUID();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.login-divider > .item-content {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
|
||||
> .item-inner {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
min-height: 0;
|
||||
|
||||
> small {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
> hr {display: block;
|
||||
flex: 1 1 100%;
|
||||
height: 0;
|
||||
max-height: 0;
|
||||
border-style: solid;
|
||||
border-width: thin 0 0 0;
|
||||
opacity: 0.12;
|
||||
transition: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -99,6 +99,12 @@
|
||||
"url": "https://golang.org/x/text",
|
||||
"licenseUrl": "https://cs.opensource.google/go/x/text/+/refs/tags/v0.28.0:LICENSE"
|
||||
},
|
||||
{
|
||||
"name": "Go OAuth2",
|
||||
"copyright": "Copyright (c) 2009 The Go Authors. All rights reserved.",
|
||||
"url": "https://golang.org/x/oauth2",
|
||||
"licenseUrl": "https://cs.opensource.google/go/x/oauth2/+/refs/tags/v0.31.0:LICENSE"
|
||||
},
|
||||
{
|
||||
"name": "Gomail",
|
||||
"copyright": "Copyright (c) 2014 Alexandre Cesaro",
|
||||
|
||||
@@ -290,6 +290,10 @@ export default defineConfig(() => {
|
||||
target: 'http://127.0.0.1:8080/',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/oauth2': {
|
||||
target: 'http://127.0.0.1:8080/',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8080/',
|
||||
changeOrigin: true
|
||||
|
||||
Reference in New Issue
Block a user