This is an automated email from the ASF dual-hosted git repository.
hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
The following commit(s) were added to refs/heads/master by this push:
new a292e2d feat(auth): make casdoor optional and default to built-in
sign-in
a292e2d is described below
commit a292e2d2677461271d5748d8e6c2c9ef522e55b4
Author: Yang Luo <[email protected]>
AuthorDate: Mon Aug 17 11:17:50 2026 +0800
feat(auth): make casdoor optional and default to built-in sign-in
---
README.md | 17 ++-
auth/auth.go | 93 ++++++++++++++
casdoor/init.go | 26 ++--
conf/app.conf | 17 ++-
conf/casdoor.go | 33 +++++
controllers/account.go | 172 +++++++++++++++++++++++++-
controllers/application.go | 14 ++-
controllers/base.go | 16 +--
controllers/provider.go | 14 ++-
go.mod | 4 +-
main.go | 1 +
object/ormer.go | 6 +
object/site.go | 6 +-
object/site_cache.go | 24 ++--
object/site_timer_health.go | 17 ++-
object/user.go | 246 ++++++++++++++++++++++++++++++++++++++
object/user_test.go | 149 +++++++++++++++++++++++
routers/router.go | 2 +
service/captcha.go | 11 +-
service/oauth.go | 6 +-
service/util.go | 16 +--
storage/casdoor.go | 22 ++--
web/src/AccountPage.js | 185 ++++++++++++++++++++++++++++
web/src/App.js | 47 ++++++--
web/src/ChannelEditPage.js | 27 -----
web/src/Conf.js | 25 +++-
web/src/Setting.js | 25 ++++
web/src/SigninPage.js | 129 +++++++++++++++++---
web/src/backend/AccountBackend.js | 27 ++++-
web/src/locales/de/data.json | 17 ++-
web/src/locales/en/data.json | 15 ++-
web/src/locales/es/data.json | 17 ++-
web/src/locales/fr/data.json | 17 ++-
web/src/locales/id/data.json | 17 ++-
web/src/locales/ja/data.json | 17 ++-
web/src/locales/ko/data.json | 17 ++-
web/src/locales/ru/data.json | 17 ++-
web/src/locales/zh/data.json | 15 ++-
38 files changed, 1374 insertions(+), 152 deletions(-)
diff --git a/README.md b/README.md
index e07db2b..b356bad 100644
--- a/README.md
+++ b/README.md
@@ -53,7 +53,7 @@ Casbin Gateway contains 2 parts:
## Installation
-Casbin Gateway uses Casdoor to manage members. So you need to create an
organization and an application for Casbin Gateway in a Casdoor instance.
+Casbin Gateway runs standalone out of the box: it signs users in against its
own user table, seeding an `admin` account with the password `123` on first
start. Connecting it to a [Casdoor](https://casdoor.org) instance is optional,
and enables single sign-on plus the Casdoor-backed features listed under
[Optional configuration](#optional-configuration).
### Deployment Options
@@ -66,14 +66,12 @@ Casbin Gateway uses Casdoor to manage members. So you need
to create an organiza
#### Get the code
```shell
-go get github.com/casdoor/casdoor
go get github.com/apache/casbin-gateway
```
or
```shell
-git clone https://github.com/casdoor/casdoor
git clone https://github.com/apache/casbin-gateway
```
@@ -87,24 +85,25 @@ dataSourceName = root:123@tcp(localhost:3306)/
Casbin Gateway uses XORM to connect to DB, so all DBs supported by XORM can
also be used.
-#### Configure Casdoor
-
-After creating an organization and an application for Casbin Gateway in a
Casdoor, you need to update `clientID`, `clientSecret`, `casdoorOrganization`
and `casdoorApplication` in app.conf.
-
#### Run Casbin Gateway
- Configure and run Casbin Gateway by yourself. If you want to learn more, see
the [documentation](https://caswaf.org).
- Open browser: http://localhost:16001/
+- Sign in as `admin` with the password `123`, then change it from the "My
Account" page.
### Optional configuration
+#### Connect to Casdoor
+
+Casdoor takes over member management and single sign-on. Create an
organization and an application for Casbin Gateway in a
[Casdoor](https://casdoor.org) instance, then fill in `casdoorEndpoint`,
`clientId`, `clientSecret`, `casdoorOrganization` and `casdoorApplication` in
app.conf. The built-in user table is bypassed as soon as `casdoorEndpoint` is
set, and sign-in redirects to Casdoor instead.
+
#### Setup your WAF to enable some third-party login platform
-Casbin Gateway uses Casdoor to manage members. If you want to log in with
oauth, you should see [casdoor oauth
configuration](https://casdoor.org/docs/provider/oauth/overview).
+With Casdoor connected, you can log in with oauth: see the [casdoor oauth
configuration](https://casdoor.org/docs/provider/oauth/overview).
#### OSS, Mail, and SMS services
-Casbin Gateway uses Casdoor to upload files to cloud storage, send Emails and
send SMSs. See Casdoor for more details.
+Casbin Gateway uses Casdoor to upload files to cloud storage, send Emails and
send SMSs. Health-check alerts, the `CAPTCHA` rule action, per-site Casdoor SSO
and the resource storage provider are all inactive until Casdoor is configured.
See Casdoor for more details.
## Contribute
diff --git a/auth/auth.go b/auth/auth.go
new file mode 100644
index 0000000..22ddcab
--- /dev/null
+++ b/auth/auth.go
@@ -0,0 +1,93 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package auth is a thin wrapper around the identity-provider SDK.
+// All calls to the upstream SDK go through this package, so the rest of the
+// codebase never imports casdoorsdk directly and the "no provider configured"
+// guards live in one place.
+package auth
+
+import (
+ "github.com/casdoor/casdoor-go-sdk/casdoorsdk"
+ "golang.org/x/oauth2"
+)
+
+// Type aliases — re-export all SDK types used across the codebase so that
+// other packages only need to import this package, not casdoorsdk directly.
+type (
+ Application = casdoorsdk.Application
+ Cert = casdoorsdk.Cert
+ Claims = casdoorsdk.Claims
+ Client = casdoorsdk.Client
+ Provider = casdoorsdk.Provider
+ Resource = casdoorsdk.Resource
+ User = casdoorsdk.User
+)
+
+// InitConfig initialises the identity-provider SDK client.
+func InitConfig(endpoint, clientId, clientSecret, jwtPublicKey, organization,
application string) {
+ casdoorsdk.InitConfig(endpoint, clientId, clientSecret, jwtPublicKey,
organization, application)
+}
+
+func NewClient(endpoint, clientId, clientSecret, certificate, organization,
application string) *Client {
+ return casdoorsdk.NewClient(endpoint, clientId, clientSecret,
certificate, organization, application)
+}
+
+func GetUrl(action string, params map[string]string) string {
+ return casdoorsdk.GetUrl(action, params)
+}
+
+func GetOAuthToken(code, state string) (*oauth2.Token, error) {
+ return casdoorsdk.GetOAuthToken(code, state)
+}
+
+func ParseJwtToken(token string) (*Claims, error) {
+ return casdoorsdk.ParseJwtToken(token)
+}
+
+func GetUser(name string) (*User, error) {
+ return casdoorsdk.GetUser(name)
+}
+
+func GetCerts() ([]*Cert, error) {
+ return casdoorsdk.GetCerts()
+}
+
+func GetOrganizationApplications() ([]*Application, error) {
+ return casdoorsdk.GetOrganizationApplications()
+}
+
+func GetProviders() ([]*Provider, error) {
+ return casdoorsdk.GetProviders()
+}
+
+func SendEmailByProvider(title, content, sender, provider string, receivers
...string) error {
+ return casdoorsdk.SendEmailByProvider(title, content, sender, provider,
receivers...)
+}
+
+func SendSmsByProvider(content, provider string, receivers ...string) error {
+ return casdoorsdk.SendSmsByProvider(content, provider, receivers...)
+}
+
+func GetResources(owner, application, field, value, sortField, sortOrder
string) ([]*Resource, error) {
+ return casdoorsdk.GetResources(owner, application, field, value,
sortField, sortOrder)
+}
+
+func UploadResource(user, tag, parent, fullFilePath string, fileBytes []byte)
(string, string, error) {
+ return casdoorsdk.UploadResource(user, tag, parent, fullFilePath,
fileBytes)
+}
+
+func DeleteResource(resource *Resource) (bool, error) {
+ return casdoorsdk.DeleteResource(resource)
+}
diff --git a/casdoor/init.go b/casdoor/init.go
index dcfdfb8..bb75e0b 100644
--- a/casdoor/init.go
+++ b/casdoor/init.go
@@ -17,18 +17,28 @@ package casdoor
import (
_ "embed"
- "github.com/beego/beego"
- "github.com/casdoor/casdoor-go-sdk/casdoorsdk"
+ "github.com/apache/casbin-gateway/auth"
+ "github.com/apache/casbin-gateway/conf"
)
//go:embed token_jwt_key.pem
var JwtPublicKey string
+// InitCasdoorConfig wires up the Casdoor SDK when "casdoorEndpoint" is set in
+// app.conf. Casdoor is optional: with no endpoint configured, Gateway signs
+// users in against its own local user table instead, and every Casdoor-backed
+// feature degrades via conf.IsCasdoorAvailable().
func InitCasdoorConfig() {
- casdoorEndpoint := beego.AppConfig.String("casdoorEndpoint")
- clientId := beego.AppConfig.String("clientId")
- clientSecret := beego.AppConfig.String("clientSecret")
- casdoorOrganization := beego.AppConfig.String("casdoorOrganization")
- casdoorApplication := beego.AppConfig.String("casdoorApplication")
- casdoorsdk.InitConfig(casdoorEndpoint, clientId, clientSecret,
JwtPublicKey, casdoorOrganization, casdoorApplication)
+ casdoorEndpoint := conf.GetConfigString("casdoorEndpoint")
+ if casdoorEndpoint == "" {
+ conf.SetCasdoorAvailable(false)
+ return
+ }
+
+ clientId := conf.GetConfigString("clientId")
+ clientSecret := conf.GetConfigString("clientSecret")
+ casdoorOrganization := conf.GetConfigString("casdoorOrganization")
+ casdoorApplication := conf.GetConfigString("casdoorApplication")
+ auth.InitConfig(casdoorEndpoint, clientId, clientSecret, JwtPublicKey,
casdoorOrganization, casdoorApplication)
+ conf.SetCasdoorAvailable(true)
}
diff --git a/conf/app.conf b/conf/app.conf
index 1cd0a02..96e7d2e 100644
--- a/conf/app.conf
+++ b/conf/app.conf
@@ -11,12 +11,17 @@ gatewayEnabled = true
gatewayHttpPort = 80
gatewayHttpsPort = 443
httpProxy = ""
-casdoorEndpoint = https://door.casdoor.com
-; casdoorEndpoint = http://localhost:8000
-clientId = af6b5aa958822fb9dc33
-clientSecret = 8bc3010c1c951c8d876b1f311a901ff8deeb93bc
-casdoorOrganization = "casbin"
-casdoorApplication = "app-casibase"
+; Casdoor is optional. Leave "casdoorEndpoint" empty to sign in against
Gateway's
+; own user table instead, with the built-in "admin" account (password "123").
+; Filling in all five values below switches sign-in over to Casdoor SSO and
+; enables the Casdoor-backed features: site SSO, CAPTCHA rules, alert providers
+; and the resource storage provider.
+casdoorEndpoint =
+; casdoorEndpoint = https://door.casdoor.com
+clientId =
+clientSecret =
+casdoorOrganization =
+casdoorApplication =
isDemoMode = false
appDir = "C:/github_repos"
dbRegionId = ""
diff --git a/conf/casdoor.go b/conf/casdoor.go
new file mode 100644
index 0000000..f924bed
--- /dev/null
+++ b/conf/casdoor.go
@@ -0,0 +1,33 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package conf
+
+import "sync/atomic"
+
+// casdoorAvailable records whether Casdoor is configured, i.e. whether the SDK
+// may be called at all. Set by casdoor.InitCasdoorConfig at startup.
+var casdoorAvailable atomic.Bool
+
+// SetCasdoorAvailable records whether Casdoor was initialized successfully.
+func SetCasdoorAvailable(available bool) {
+ casdoorAvailable.Store(available)
+}
+
+// IsCasdoorAvailable reports whether Casdoor is configured and may be used for
+// SDK calls. Casdoor is optional: when it is not configured, Gateway falls
back
+// to its own local user table for sign-in.
+func IsCasdoorAvailable() bool {
+ return casdoorAvailable.Load()
+}
diff --git a/controllers/account.go b/controllers/account.go
index ecec551..180baed 100644
--- a/controllers/account.go
+++ b/controllers/account.go
@@ -16,22 +16,74 @@ package controllers
import (
_ "embed"
+ "encoding/json"
+ "strings"
+ "github.com/apache/casbin-gateway/auth"
+ "github.com/apache/casbin-gateway/conf"
+ "github.com/apache/casbin-gateway/object"
"github.com/apache/casbin-gateway/util"
- "github.com/casdoor/casdoor-go-sdk/casdoorsdk"
)
+type signinForm struct {
+ Username string `json:"username"`
+ Password string `json:"password"`
+}
+
+type accountForm struct {
+ DisplayName string `json:"displayName"`
+ Avatar string `json:"avatar"`
+ CurrentPassword string `json:"currentPassword"`
+ NewPassword string `json:"newPassword"`
+}
+
+// GetSigninOptions tells the web UI how to sign in: redirect to Casdoor, or
+// show the built-in username/password form.
+func (c *ApiController) GetSigninOptions() {
+ casdoorAvailable := conf.IsCasdoorAvailable()
+ signinEnabled := object.IsSigninEnabled()
+
+ c.ResponseOk(map[string]interface{}{
+ "casdoorAvailable": casdoorAvailable,
+ "signinAvailable": signinEnabled,
+ "autoSignin": signinEnabled &&
object.IsAdminUsingDefaultPassword(),
+ "authConfig": map[string]string{
+ "serverUrl":
getUnquotedConfig("casdoorEndpoint"),
+ "clientId": getUnquotedConfig("clientId"),
+ "appName":
getUnquotedConfig("casdoorApplication"),
+ "organizationName":
getUnquotedConfig("casdoorOrganization"),
+ },
+ })
+}
+
+// getUnquotedConfig reads an app.conf value, dropping the quotes that beego
+// keeps around values such as casdoorApplication = "app-casibase".
+func getUnquotedConfig(key string) string {
+ return strings.Trim(conf.GetConfigString(key), `"' `)
+}
+
+// Signin handles both sign-in flows: with an OAuth code it completes the
+// Casdoor login, without one it falls back to the local password login.
func (c *ApiController) Signin() {
code := c.Input().Get("code")
state := c.Input().Get("state")
+ if code == "" && state == "" {
+ c.signinWithPassword()
+ return
+ }
+
+ if !conf.IsCasdoorAvailable() {
+ c.ResponseError("Casdoor is not configured")
+ return
+ }
- token, err := casdoorsdk.GetOAuthToken(code, state)
+ token, err := auth.GetOAuthToken(code, state)
if err != nil {
c.ResponseError(err.Error())
return
}
- claims, err := casdoorsdk.ParseJwtToken(token.AccessToken)
+ claims, err := auth.ParseJwtToken(token.AccessToken)
if err != nil {
c.ResponseError(err.Error())
return
@@ -43,15 +95,77 @@ func (c *ApiController) Signin() {
c.ResponseOk(claims)
}
+func (c *ApiController) signinWithPassword() {
+ if !object.IsSigninEnabled() {
+ c.ResponseError("sign in is unavailable")
+ return
+ }
+
+ form := signinForm{}
+ if len(c.Ctx.Input.RequestBody) > 0 {
+ if err := json.Unmarshal(c.Ctx.Input.RequestBody, &form); err
!= nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ }
+ if form.Username == "" {
+ form.Username = c.Input().Get("username")
+ }
+ if form.Password == "" {
+ form.Password = c.Input().Get("password")
+ }
+
+ user, ok, err := object.VerifyUser(form.Username, form.Password)
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ if !ok {
+ c.ResponseError("invalid username or password")
+ return
+ }
+
+ claims := &auth.Claims{User: user.ToCasdoorUser()}
+ c.SetSessionClaims(claims)
+
+ c.ResponseOk(claims)
+}
+
func (c *ApiController) Signout() {
c.SetSessionClaims(nil)
c.ResponseOk()
}
+// autoLoginAdmin signs the built-in admin in while the seeded password is
still
+// in use, so that a fresh deployment is usable without any configuration.
+// It returns false when the response has already been written.
+func (c *ApiController) autoLoginAdmin() bool {
+ user, ok, err := object.VerifyUser("admin", "123")
+ if err != nil {
+ c.ResponseError(err.Error())
+ return false
+ }
+ if !ok {
+ c.ResponseError("please sign in first")
+ return false
+ }
+
+ c.SetSessionClaims(&auth.Claims{User: user.ToCasdoorUser()})
+ return true
+}
+
func (c *ApiController) GetAccount() {
- if c.RequireSignedIn() {
- return
+ if c.GetSessionUser() == nil {
+ fromPath := c.Input().Get("fromPath")
+ if object.IsSigninEnabled() && fromPath != "/signin" &&
object.IsAdminUsingDefaultPassword() {
+ if !c.autoLoginAdmin() {
+ return
+ }
+ } else {
+ c.ResponseError("please sign in first")
+ return
+ }
}
claims := c.GetSessionClaims()
@@ -59,3 +173,51 @@ func (c *ApiController) GetAccount() {
c.ResponseOk(claims, hostname)
}
+
+// UpdateAccount lets a locally signed-in user edit their own profile and
+// password. Casdoor-backed accounts are managed in Casdoor itself.
+func (c *ApiController) UpdateAccount() {
+ sessionUser := c.GetSessionUser()
+ if sessionUser == nil || sessionUser.Owner != object.UserOwner {
+ c.ResponseError("unauthorized operation")
+ return
+ }
+
+ form := accountForm{}
+ if err := json.Unmarshal(c.Ctx.Input.RequestBody, &form); err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+
+ user, err := object.GetUser(sessionUser.Name)
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ if user == nil {
+ c.ResponseError("unauthorized operation")
+ return
+ }
+
+ if form.NewPassword != "" && !object.CheckUserPassword(user,
form.CurrentPassword) {
+ c.ResponseError("invalid username or password")
+ return
+ }
+
+ user.DisplayName = form.DisplayName
+ user.Avatar = form.Avatar
+ if err = object.UpdateUserProfile(user); err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ if form.NewPassword != "" {
+ if err = object.UpdateUserPassword(user, form.NewPassword); err
!= nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ }
+
+ casdoorUser := user.ToCasdoorUser()
+ c.SetSessionUser(&casdoorUser)
+ c.ResponseOk(casdoorUser)
+}
diff --git a/controllers/application.go b/controllers/application.go
index c8e18ee..928dc05 100644
--- a/controllers/application.go
+++ b/controllers/application.go
@@ -14,10 +14,20 @@
package controllers
-import "github.com/casdoor/casdoor-go-sdk/casdoorsdk"
+import (
+ "github.com/apache/casbin-gateway/auth"
+ "github.com/apache/casbin-gateway/conf"
+)
func (c *ApiController) GetApplications() {
- applications, err := casdoorsdk.GetOrganizationApplications()
+ // Casdoor applications are only used to put a site behind Casdoor SSO.
+ // With no Casdoor configured there are none to choose from.
+ if !conf.IsCasdoorAvailable() {
+ c.ResponseOk([]*auth.Application{})
+ return
+ }
+
+ applications, err := auth.GetOrganizationApplications()
if err != nil {
c.ResponseError(err.Error())
return
diff --git a/controllers/base.go b/controllers/base.go
index 038609a..660615c 100644
--- a/controllers/base.go
+++ b/controllers/base.go
@@ -17,8 +17,8 @@ package controllers
import (
"encoding/gob"
+ "github.com/apache/casbin-gateway/auth"
"github.com/beego/beego"
- "github.com/casdoor/casdoor-go-sdk/casdoorsdk"
)
type ApiController struct {
@@ -26,10 +26,10 @@ type ApiController struct {
}
func init() {
- gob.Register(casdoorsdk.Claims{})
+ gob.Register(auth.Claims{})
}
-func GetUserName(user *casdoorsdk.User) string {
+func GetUserName(user *auth.User) string {
if user == nil {
return ""
}
@@ -47,17 +47,17 @@ func wrapActionResponse(affected bool, e ...error)
*Response {
}
}
-func (c *ApiController) GetSessionClaims() *casdoorsdk.Claims {
+func (c *ApiController) GetSessionClaims() *auth.Claims {
s := c.GetSession("user")
if s == nil {
return nil
}
- claims := s.(casdoorsdk.Claims)
+ claims := s.(auth.Claims)
return &claims
}
-func (c *ApiController) SetSessionClaims(claims *casdoorsdk.Claims) {
+func (c *ApiController) SetSessionClaims(claims *auth.Claims) {
if claims == nil {
c.DelSession("user")
return
@@ -66,7 +66,7 @@ func (c *ApiController) SetSessionClaims(claims
*casdoorsdk.Claims) {
c.SetSession("user", *claims)
}
-func (c *ApiController) GetSessionUser() *casdoorsdk.User {
+func (c *ApiController) GetSessionUser() *auth.User {
claims := c.GetSessionClaims()
if claims == nil {
return nil
@@ -75,7 +75,7 @@ func (c *ApiController) GetSessionUser() *casdoorsdk.User {
return &claims.User
}
-func (c *ApiController) SetSessionUser(user *casdoorsdk.User) {
+func (c *ApiController) SetSessionUser(user *auth.User) {
if user == nil {
c.DelSession("user")
return
diff --git a/controllers/provider.go b/controllers/provider.go
index 1136fb7..31af4f6 100644
--- a/controllers/provider.go
+++ b/controllers/provider.go
@@ -14,10 +14,20 @@
package controllers
-import "github.com/casdoor/casdoor-go-sdk/casdoorsdk"
+import (
+ "github.com/apache/casbin-gateway/auth"
+ "github.com/apache/casbin-gateway/conf"
+)
func (c *ApiController) GetProviders() {
- providers, err := casdoorsdk.GetProviders()
+ // Providers live in Casdoor. Without it, the site alert provider picker
+ // simply has nothing to offer instead of failing the whole page.
+ if !conf.IsCasdoorAvailable() {
+ c.ResponseOk([]*auth.Provider{})
+ return
+ }
+
+ providers, err := auth.GetProviders()
if err != nil {
c.ResponseError(err.Error())
return
diff --git a/go.mod b/go.mod
index e2d3b02..0bc0062 100644
--- a/go.mod
+++ b/go.mod
@@ -21,7 +21,9 @@ require (
github.com/siddontang/go-log v0.0.0-20190221022429-1e957dd83bed
github.com/xorm-io/core v0.7.4
github.com/xorm-io/xorm v1.1.6
+ golang.org/x/crypto v0.19.0
golang.org/x/net v0.21.0
+ golang.org/x/oauth2 v0.13.0
golang.org/x/sys v0.17.0
golang.org/x/text v0.14.0
golang.org/x/time v0.3.0
@@ -93,9 +95,7 @@ require (
go.uber.org/atomic v1.7.0 // indirect
go.uber.org/multierr v1.6.0 // indirect
go.uber.org/zap v1.18.1 // indirect
- golang.org/x/crypto v0.19.0 // indirect
golang.org/x/mod v0.14.0 // indirect
- golang.org/x/oauth2 v0.13.0 // indirect
golang.org/x/sync v0.6.0 // indirect
golang.org/x/tools v0.15.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
diff --git a/main.go b/main.go
index 54d1379..16755a8 100644
--- a/main.go
+++ b/main.go
@@ -46,6 +46,7 @@ func main() {
object.InitAdapter()
object.CreateTables()
casdoor.InitCasdoorConfig()
+ object.InitUsers()
proxy.InitHttpClient()
ip.InitIpDb()
object.InitSiteMap()
diff --git a/object/ormer.go b/object/ormer.go
index 71f7910..31a9965 100644
--- a/object/ormer.go
+++ b/object/ormer.go
@@ -184,6 +184,12 @@ func (a *Ormer) createTable() {
panic(err)
}
+ // Local users, used for sign-in when no Casdoor is configured.
+ err = a.Engine.Sync2(new(User))
+ if err != nil {
+ panic(err)
+ }
+
err = a.Engine.Sync2(new(Site))
if err != nil {
panic(err)
diff --git a/object/site.go b/object/site.go
index 11902e8..d1bbd14 100644
--- a/object/site.go
+++ b/object/site.go
@@ -20,9 +20,9 @@ import (
"strings"
"time"
+ "github.com/apache/casbin-gateway/auth"
"github.com/apache/casbin-gateway/run"
"github.com/apache/casbin-gateway/util"
- "github.com/casdoor/casdoor-go-sdk/casdoorsdk"
"github.com/xorm-io/core"
)
@@ -65,8 +65,8 @@ type Site struct {
Status string `xorm:"varchar(100)" json:"status"`
Nodes []*NodeItem `xorm:"mediumtext" json:"nodes"`
- CasdoorApplication string `xorm:"varchar(100)"
json:"casdoorApplication"`
- ApplicationObj *casdoorsdk.Application `xorm:"-"
json:"applicationObj"`
+ CasdoorApplication string `xorm:"varchar(100)"
json:"casdoorApplication"`
+ ApplicationObj *auth.Application `xorm:"-" json:"applicationObj"`
}
func GetGlobalSites() ([]*Site, error) {
diff --git a/object/site_cache.go b/object/site_cache.go
index 6dd1aa5..e536d05 100644
--- a/object/site_cache.go
+++ b/object/site_cache.go
@@ -18,7 +18,8 @@ import (
"fmt"
"strings"
- "github.com/casdoor/casdoor-go-sdk/casdoorsdk"
+ "github.com/apache/casbin-gateway/auth"
+ "github.com/apache/casbin-gateway/conf"
)
var siteMap = map[string]*Site{}
@@ -32,31 +33,36 @@ func InitSiteMap() {
}
}
-func getCasdoorCertMap() (map[string]*casdoorsdk.Cert, error) {
- certs, err := casdoorsdk.GetCerts()
+func getCasdoorCertMap() (map[string]*auth.Cert, error) {
+ certs, err := auth.GetCerts()
if err != nil {
- return nil, fmt.Errorf("casdoorsdk.GetCerts() error: %s",
err.Error())
+ return nil, fmt.Errorf("auth.GetCerts() error: %s", err.Error())
}
- res := map[string]*casdoorsdk.Cert{}
+ res := map[string]*auth.Cert{}
for _, cert := range certs {
res[cert.Name] = cert
}
return res, nil
}
-func getCasdoorApplicationMap() (map[string]*casdoorsdk.Application, error) {
+func getCasdoorApplicationMap() (map[string]*auth.Application, error) {
+ // Sites are only bound to a Casdoor application when Casdoor is
configured.
+ if !conf.IsCasdoorAvailable() {
+ return nil, nil
+ }
+
casdoorCertMap, err := getCasdoorCertMap()
if err != nil {
return nil, err
}
- applications, err := casdoorsdk.GetOrganizationApplications()
+ applications, err := auth.GetOrganizationApplications()
if err != nil {
- return nil,
fmt.Errorf("casdoorsdk.GetOrganizationApplications() error: %s", err.Error())
+ return nil, fmt.Errorf("auth.GetOrganizationApplications()
error: %s", err.Error())
}
- res := map[string]*casdoorsdk.Application{}
+ res := map[string]*auth.Application{}
for _, application := range applications {
if application.Cert != "" {
if cert, ok := casdoorCertMap[application.Cert]; ok {
diff --git a/object/site_timer_health.go b/object/site_timer_health.go
index b81d4e6..8aec491 100644
--- a/object/site_timer_health.go
+++ b/object/site_timer_health.go
@@ -19,7 +19,8 @@ import (
"strings"
"time"
- "github.com/casdoor/casdoor-go-sdk/casdoorsdk"
+ "github.com/apache/casbin-gateway/auth"
+ "github.com/apache/casbin-gateway/conf"
)
var healthCheckTryTimesMap = map[string]int{}
@@ -52,7 +53,15 @@ func healthCheck(site *Site, domain string) error {
}
pingResponse = fmt.Sprintf("Casbin Gateway health check failed for
domain %s, %s", domain, pingResponse)
- user, err := casdoorsdk.GetUser(site.Owner)
+
+ // Alerts are delivered through Casdoor's email and SMS providers.
Without
+ // Casdoor there is nowhere to send them, so the check itself still
runs but
+ // stays silent.
+ if !conf.IsCasdoorAvailable() {
+ return nil
+ }
+
+ user, err := auth.GetUser(site.Owner)
if err != nil {
return err
}
@@ -61,13 +70,13 @@ func healthCheck(site *Site, domain string) error {
}
for _, provider := range site.AlertProviders {
if strings.HasPrefix(provider, "Email/") {
- err := casdoorsdk.SendEmailByProvider("Casbin Gateway
HealthCheck Alert", pingResponse, "Casbin Gateway", provider[6:], user.Email)
+ err := auth.SendEmailByProvider("Casbin Gateway
HealthCheck Alert", pingResponse, "Casbin Gateway", provider[6:], user.Email)
if err != nil {
fmt.Println(err)
}
}
if strings.HasPrefix(provider, "SMS/") {
- err := casdoorsdk.SendSmsByProvider(pingResponse,
provider[4:], user.Phone)
+ err := auth.SendSmsByProvider(pingResponse,
provider[4:], user.Phone)
if err != nil {
fmt.Println(err)
}
diff --git a/object/user.go b/object/user.go
new file mode 100644
index 0000000..70a356f
--- /dev/null
+++ b/object/user.go
@@ -0,0 +1,246 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package object
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/apache/casbin-gateway/auth"
+ "github.com/apache/casbin-gateway/conf"
+ "github.com/apache/casbin-gateway/util"
+ "github.com/xorm-io/core"
+ "golang.org/x/crypto/bcrypt"
+)
+
+const (
+ // UserOwner is the owner of every locally stored user. It marks a
session as
+ // "basic login" rather than a Casdoor-issued identity.
+ UserOwner = "basic"
+ maxPasswordLengthBytes = 72
+ // unknownUserPasswordHash is compared against when the user does not
exist,
+ // so that a wrong username costs the same time as a wrong password.
+ unknownUserPasswordHash =
"$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"
+)
+
+type User struct {
+ Owner string `xorm:"varchar(100) notnull pk" json:"owner"`
+ Name string `xorm:"varchar(100) notnull pk" json:"name"`
+ CreatedTime string `xorm:"varchar(100)" json:"createdTime"`
+ UpdatedTime string `xorm:"varchar(100)" json:"updatedTime"`
+
+ DisplayName string `xorm:"varchar(100)" json:"displayName"`
+ Avatar string `xorm:"text" json:"avatar"`
+
+ PasswordHash string `xorm:"varchar(150)" json:"-"`
+ IsForbidden bool `json:"isForbidden"`
+ IsDeleted bool `json:"isDeleted"`
+}
+
+// IsSigninEnabled reports whether the local (password) sign-in is in use. It
is
+// the mirror image of Casdoor being configured: exactly one of the two ways to
+// sign in is active at a time.
+func IsSigninEnabled() bool {
+ return !conf.IsCasdoorAvailable()
+}
+
+func normalizeUserName(name string) (string, error) {
+ name = strings.TrimSpace(name)
+ if name == "" {
+ return "", fmt.Errorf("username cannot be empty")
+ }
+ if strings.Contains(name, "/") {
+ return "", fmt.Errorf("username cannot contain slash")
+ }
+ if len(name) > 100 {
+ return "", fmt.Errorf("username is too long")
+ }
+ return name, nil
+}
+
+func getPasswordHash(password string) (string, error) {
+ if password == "" {
+ return "", fmt.Errorf("password cannot be empty")
+ }
+ if len([]byte(password)) > maxPasswordLengthBytes {
+ return "", fmt.Errorf("password cannot be longer than %d
bytes", maxPasswordLengthBytes)
+ }
+
+ hash, err := bcrypt.GenerateFromPassword([]byte(password),
bcrypt.DefaultCost)
+ if err != nil {
+ return "", err
+ }
+ return string(hash), nil
+}
+
+func (user *User) GetId() string {
+ return util.GetIdFromOwnerAndName(user.Owner, user.Name)
+}
+
+// ToCasdoorUser projects a local user onto the SDK user type, which is what
the
+// rest of Gateway (sessions, ownership checks, the web UI) already speaks.
+func (user *User) ToCasdoorUser() auth.User {
+ displayName := user.DisplayName
+ if displayName == "" {
+ displayName = user.Name
+ }
+
+ return auth.User{
+ Owner: UserOwner,
+ Name: user.Name,
+ CreatedTime: user.CreatedTime,
+ UpdatedTime: user.UpdatedTime,
+ Id: user.Name,
+ IsAdmin: true,
+ DisplayName: displayName,
+ Avatar: user.Avatar,
+ IsForbidden: user.IsForbidden,
+ IsDeleted: user.IsDeleted,
+ }
+}
+
+func GetUser(name string) (*User, error) {
+ name, err := normalizeUserName(name)
+ if err != nil {
+ return nil, err
+ }
+
+ user := User{Owner: UserOwner, Name: name}
+ existed, err := ormer.Engine.Get(&user)
+ if err != nil {
+ return nil, err
+ }
+ if !existed {
+ return nil, nil
+ }
+ return &user, nil
+}
+
+func GetUsers() ([]*User, error) {
+ users := []*User{}
+ err := ormer.Engine.Asc("name").Find(&users, &User{Owner: UserOwner})
+ if err != nil {
+ return users, err
+ }
+ return users, nil
+}
+
+func AddUser(user *User, password string) (bool, error) {
+ name, err := normalizeUserName(user.Name)
+ if err != nil {
+ return false, err
+ }
+
+ user.Owner = UserOwner
+ user.Name = name
+ user.CreatedTime = util.GetCurrentTime()
+ user.UpdatedTime = user.CreatedTime
+ user.PasswordHash, err = getPasswordHash(password)
+ if err != nil {
+ return false, err
+ }
+
+ affected, err := ormer.Engine.Insert(user)
+ if err != nil {
+ return false, err
+ }
+ return affected != 0, nil
+}
+
+func UpdateUserProfile(user *User) error {
+ user.UpdatedTime = util.GetCurrentTime()
+ _, err := ormer.Engine.ID(core.PK{user.Owner,
user.Name}).Cols("display_name", "avatar", "updated_time").Update(user)
+ return err
+}
+
+func UpdateUserPassword(user *User, password string) error {
+ passwordHash, err := getPasswordHash(password)
+ if err != nil {
+ return err
+ }
+
+ user.PasswordHash = passwordHash
+ user.UpdatedTime = util.GetCurrentTime()
+ _, err = ormer.Engine.ID(core.PK{user.Owner,
user.Name}).Cols("password_hash", "updated_time").Update(user)
+ return err
+}
+
+func CheckUserPassword(user *User, password string) bool {
+ if user == nil || len([]byte(password)) > maxPasswordLengthBytes {
+ return false
+ }
+ return bcrypt.CompareHashAndPassword([]byte(user.PasswordHash),
[]byte(password)) == nil
+}
+
+func compareUnknownUserPassword(password string) {
+ if len([]byte(password)) <= maxPasswordLengthBytes {
+ _ =
bcrypt.CompareHashAndPassword([]byte(unknownUserPasswordHash), []byte(password))
+ }
+}
+
+func VerifyUser(username string, password string) (*User, bool, error) {
+ user, err := GetUser(username)
+ if err != nil {
+ compareUnknownUserPassword(password)
+ return nil, false, err
+ }
+ if user == nil || user.IsDeleted || user.IsForbidden ||
user.PasswordHash == "" {
+ compareUnknownUserPassword(password)
+ return nil, false, nil
+ }
+ if !CheckUserPassword(user, password) {
+ return nil, false, nil
+ }
+
+ return user, true, nil
+}
+
+// IsAdminUsingDefaultPassword reports whether the seeded admin password is
+// still in place, which is what lets a fresh deployment be used right away.
+func IsAdminUsingDefaultPassword() bool {
+ user, err := GetUser("admin")
+ if err != nil || user == nil {
+ return false
+ }
+ return CheckUserPassword(user, defaultAdminPassword)
+}
+
+const (
+ defaultAdminName = "admin"
+ defaultAdminPassword = "123"
+)
+
+// InitUsers seeds the built-in admin account when Casdoor is not configured.
+func InitUsers() {
+ if !IsSigninEnabled() {
+ return
+ }
+
+ user, err := GetUser(defaultAdminName)
+ if err != nil {
+ panic(err)
+ }
+ if user != nil {
+ return
+ }
+
+ _, err = AddUser(&User{
+ Name: defaultAdminName,
+ DisplayName: "Admin",
+ }, defaultAdminPassword)
+ if err != nil {
+ panic(err)
+ }
+}
diff --git a/object/user_test.go b/object/user_test.go
new file mode 100644
index 0000000..7c99d7a
--- /dev/null
+++ b/object/user_test.go
@@ -0,0 +1,149 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package object
+
+import (
+ "os"
+ "path/filepath"
+ "runtime"
+ "testing"
+
+ "github.com/apache/casbin-gateway/casdoor"
+ "github.com/apache/casbin-gateway/conf"
+)
+
+// setEnv points the config at a throwaway sqlite database, and restores both
+// the environment and the package ormer afterwards so that the other tests in
+// this package still see the app.conf database.
+func setEnv(t *testing.T, values map[string]string) {
+ t.Helper()
+
+ for key, value := range values {
+ old, existed := os.LookupEnv(key)
+ t.Cleanup(func() {
+ if existed {
+ _ = os.Setenv(key, old)
+ } else {
+ _ = os.Unsetenv(key)
+ }
+ })
+ if err := os.Setenv(key, value); err != nil {
+ t.Fatal(err)
+ }
+ }
+}
+
+func initSqliteOrmer(t *testing.T) {
+ t.Helper()
+
+ dbPath := filepath.Join(t.TempDir(), "gateway_test.db")
+ setEnv(t, map[string]string{
+ "driverName": "sqlite",
+ "dataSourceName": dbPath,
+ "dbName": "",
+ })
+
+ oldOrmer := ormer
+ InitAdapter()
+ ormer.createTable()
+
+ testOrmer := ormer
+ t.Cleanup(func() {
+ // Release the file handle before t.TempDir() removes the
directory.
+ runtime.SetFinalizer(testOrmer, nil)
+ _ = testOrmer.Engine.Close()
+ ormer = oldOrmer
+ })
+}
+
+func TestInitUsersSeedsAdminWithoutCasdoor(t *testing.T) {
+ setEnv(t, map[string]string{"casdoorEndpoint": ""})
+ t.Cleanup(func() { casdoor.InitCasdoorConfig() })
+
+ casdoor.InitCasdoorConfig()
+ if conf.IsCasdoorAvailable() {
+ t.Fatal("casdoor should not be available with an empty
endpoint")
+ }
+ if !IsSigninEnabled() {
+ t.Fatal("local signin should be enabled when casdoor is not
configured")
+ }
+
+ initSqliteOrmer(t)
+ InitUsers()
+
+ user, err := GetUser("admin")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if user == nil {
+ t.Fatal("InitUsers() should seed the admin user")
+ }
+ if !IsAdminUsingDefaultPassword() {
+ t.Fatal("the seeded admin should be using the default password")
+ }
+
+ casdoorUser := user.ToCasdoorUser()
+ if casdoorUser.Owner != UserOwner || casdoorUser.Name != "admin" ||
!casdoorUser.IsAdmin {
+ t.Fatalf("ToCasdoorUser() = %+v", casdoorUser)
+ }
+
+ if err = UpdateUserPassword(user, "newpass"); err != nil {
+ t.Fatal(err)
+ }
+ if IsAdminUsingDefaultPassword() {
+ t.Fatal("admin should no longer be using the default password")
+ }
+
+ // Seeding again must not reset an admin that already exists.
+ InitUsers()
+ if _, ok, _ := VerifyUser("admin", "newpass"); !ok {
+ t.Fatal("InitUsers() should leave an existing admin alone")
+ }
+}
+
+func TestVerifyUser(t *testing.T) {
+ setEnv(t, map[string]string{"casdoorEndpoint": ""})
+ t.Cleanup(func() { casdoor.InitCasdoorConfig() })
+ casdoor.InitCasdoorConfig()
+
+ initSqliteOrmer(t)
+ InitUsers()
+
+ if _, ok, _ := VerifyUser("admin", "123"); !ok {
+ t.Fatal("the seeded admin should sign in with the default
password")
+ }
+ if _, ok, _ := VerifyUser("admin", "wrong"); ok {
+ t.Fatal("a wrong password should not sign in")
+ }
+ if _, ok, _ := VerifyUser("nobody", "123"); ok {
+ t.Fatal("an unknown user should not sign in")
+ }
+ if _, ok, err := VerifyUser("", "123"); ok || err == nil {
+ t.Fatal("an empty username should be rejected")
+ }
+}
+
+func TestSigninDisabledWhenCasdoorConfigured(t *testing.T) {
+ setEnv(t, map[string]string{"casdoorEndpoint":
"https://door.casdoor.com"})
+ t.Cleanup(func() { casdoor.InitCasdoorConfig() })
+
+ casdoor.InitCasdoorConfig()
+ if !conf.IsCasdoorAvailable() {
+ t.Fatal("casdoor should be available when an endpoint is
configured")
+ }
+ if IsSigninEnabled() {
+ t.Fatal("local signin should be disabled when casdoor is
configured")
+ }
+}
diff --git a/routers/router.go b/routers/router.go
index fc9f982..b140b74 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -34,7 +34,9 @@ func initAPI() {
beego.Router("/api/signin", &controllers.ApiController{}, "POST:Signin")
beego.Router("/api/signout", &controllers.ApiController{},
"POST:Signout")
+ beego.Router("/api/get-signin-options", &controllers.ApiController{},
"GET:GetSigninOptions")
beego.Router("/api/get-account", &controllers.ApiController{},
"GET:GetAccount")
+ beego.Router("/api/update-account", &controllers.ApiController{},
"POST:UpdateAccount")
beego.Router("/api/get-providers", &controllers.ApiController{},
"GET:GetProviders")
beego.Router("/api/get-global-nodes", &controllers.ApiController{},
"GET:GetGlobalNodes")
diff --git a/service/captcha.go b/service/captcha.go
index af7e2d5..bdaa831 100644
--- a/service/captcha.go
+++ b/service/captcha.go
@@ -23,8 +23,8 @@ import (
"net/http"
"time"
+ "github.com/apache/casbin-gateway/auth"
"github.com/apache/casbin-gateway/conf"
- "github.com/casdoor/casdoor-go-sdk/casdoorsdk"
"github.com/google/uuid"
)
@@ -40,6 +40,13 @@ type verifyResponse struct {
var verifiedSession = make(map[string]time.Time)
func redirectToCaptcha(w http.ResponseWriter, r *http.Request) {
+ // The CAPTCHA challenge page is served by Casdoor. Without an endpoint
the
+ // redirect would just loop back into the proxied site, so say why
instead.
+ if !conf.IsCasdoorAvailable() {
+ responseError(w, "Casbin Gateway error: the CAPTCHA rule action
requires a \"casdoorEndpoint\" in app.conf")
+ return
+ }
+
scheme := getScheme(r)
callbackUrl := fmt.Sprintf("%s://%s/caswaf-captcha-verify", scheme,
r.Host)
captchaUri := fmt.Sprintf(
@@ -70,7 +77,7 @@ func handleCaptchaCallback(w http.ResponseWriter, r
*http.Request) {
_ = writer.WriteField("applicationId", applicationId)
_ = writer.WriteField("captchaType", typeStr)
_ = writer.Close()
- verifyURL := casdoorsdk.GetUrl("verify-captcha", nil)
+ verifyURL := auth.GetUrl("verify-captcha", nil)
req, err := http.NewRequest("POST", verifyURL, &b)
if err != nil {
redirectToCaptcha(w, r)
diff --git a/service/oauth.go b/service/oauth.go
index 04a826c..e4a6e03 100644
--- a/service/oauth.go
+++ b/service/oauth.go
@@ -19,16 +19,16 @@ import (
"net/http"
"net/url"
- "github.com/casdoor/casdoor-go-sdk/casdoorsdk"
+ "github.com/apache/casbin-gateway/auth"
)
-func getSigninUrl(casdoorClient *casdoorsdk.Client, callbackUrl string,
originalPath string) string {
+func getSigninUrl(casdoorClient *auth.Client, callbackUrl string, originalPath
string) string {
scope := "read"
return
fmt.Sprintf("%s/login/oauth/authorize?client_id=%s&response_type=code&redirect_uri=%s&scope=%s&state=%s",
casdoorClient.Endpoint, casdoorClient.ClientId,
url.QueryEscape(callbackUrl), scope, url.QueryEscape(originalPath))
}
-func redirectToCasdoor(casdoorClient *casdoorsdk.Client, w
http.ResponseWriter, r *http.Request) {
+func redirectToCasdoor(casdoorClient *auth.Client, w http.ResponseWriter, r
*http.Request) {
scheme := getScheme(r)
callbackUrl := fmt.Sprintf("%s://%s/caswaf-handler", scheme, r.Host)
diff --git a/service/util.go b/service/util.go
index 3f458f0..0d4223f 100644
--- a/service/util.go
+++ b/service/util.go
@@ -21,10 +21,9 @@ import (
"net/http"
"strings"
+ "github.com/apache/casbin-gateway/auth"
"github.com/apache/casbin-gateway/conf"
"github.com/apache/casbin-gateway/object"
- "github.com/beego/beego"
- "github.com/casdoor/casdoor-go-sdk/casdoorsdk"
)
func joinPath(a string, b string) string {
@@ -112,15 +111,16 @@ func getX509CertByDomain(domain string)
(*tls.Certificate, error) {
return &tlsCert, certErr
}
-func getCasdoorClientFromSite(site *object.Site) (*casdoorsdk.Client, error) {
+func getCasdoorClientFromSite(site *object.Site) (*auth.Client, error) {
+ if !conf.IsCasdoorAvailable() {
+ return nil, fmt.Errorf("the site is behind Casdoor SSO but no
\"casdoorEndpoint\" is configured in app.conf")
+ }
+
if site.ApplicationObj == nil {
return nil, fmt.Errorf("site.ApplicationObj is empty")
}
- casdoorEndpoint := beego.AppConfig.String("casdoorEndpoint")
- if casdoorEndpoint == "http://localhost:8000" {
- casdoorEndpoint = "http://localhost:7001"
- }
+ casdoorEndpoint := getCasdoorEndpoint()
clientId := site.ApplicationObj.ClientId
clientSecret := site.ApplicationObj.ClientSecret
@@ -130,7 +130,7 @@ func getCasdoorClientFromSite(site *object.Site)
(*casdoorsdk.Client, error) {
certificate = site.ApplicationObj.CertObj.Certificate
}
- res := casdoorsdk.NewClient(casdoorEndpoint, clientId, clientSecret,
certificate, site.ApplicationObj.Organization, site.CasdoorApplication)
+ res := auth.NewClient(casdoorEndpoint, clientId, clientSecret,
certificate, site.ApplicationObj.Organization, site.CasdoorApplication)
return res, nil
}
diff --git a/storage/casdoor.go b/storage/casdoor.go
index 87eb18d..26bc040 100644
--- a/storage/casdoor.go
+++ b/storage/casdoor.go
@@ -18,8 +18,8 @@ import (
"bytes"
"fmt"
- "github.com/beego/beego"
- "github.com/casdoor/casdoor-go-sdk/casdoorsdk"
+ "github.com/apache/casbin-gateway/auth"
+ "github.com/apache/casbin-gateway/conf"
)
type CasdoorProvider struct {
@@ -31,13 +31,19 @@ func NewCasdoorProvider(providerName string)
(*CasdoorProvider, error) {
return nil, fmt.Errorf("storage provider name: [%s] doesn't
exist", providerName)
}
+ // This provider stores objects as Casdoor resources, so it is unusable
+ // unless Casdoor is configured.
+ if !conf.IsCasdoorAvailable() {
+ return nil, fmt.Errorf("the storage provider requires a
\"casdoorEndpoint\" in app.conf")
+ }
+
return &CasdoorProvider{providerName: providerName}, nil
}
func (p *CasdoorProvider) ListObjects(prefix string) ([]*Object, error) {
- casdoorOrganization := beego.AppConfig.String("casdoorOrganization")
- casdoorApplication := beego.AppConfig.String("casdoorApplication")
- resources, err := casdoorsdk.GetResources(casdoorOrganization,
casdoorApplication, "provider", p.providerName, "Direct", prefix)
+ casdoorOrganization := conf.GetConfigString("casdoorOrganization")
+ casdoorApplication := conf.GetConfigString("casdoorApplication")
+ resources, err := auth.GetResources(casdoorOrganization,
casdoorApplication, "provider", p.providerName, "Direct", prefix)
if err != nil {
return nil, err
}
@@ -55,7 +61,7 @@ func (p *CasdoorProvider) ListObjects(prefix string)
([]*Object, error) {
}
func (p *CasdoorProvider) PutObject(user string, parent string, key string,
fileBuffer *bytes.Buffer) (string, error) {
- fileUrl, _, err := casdoorsdk.UploadResource(user, "Casibase", parent,
fmt.Sprintf("Direct/%s/%s", p.providerName, key), fileBuffer.Bytes())
+ fileUrl, _, err := auth.UploadResource(user, "Casibase", parent,
fmt.Sprintf("Direct/%s/%s", p.providerName, key), fileBuffer.Bytes())
if err != nil {
return "", err
}
@@ -63,10 +69,10 @@ func (p *CasdoorProvider) PutObject(user string, parent
string, key string, file
}
func (p *CasdoorProvider) DeleteObject(key string) error {
- resource := &casdoorsdk.Resource{
+ resource := &auth.Resource{
Name: key,
}
- _, err := casdoorsdk.DeleteResource(resource)
+ _, err := auth.DeleteResource(resource)
if err != nil {
return err
}
diff --git a/web/src/AccountPage.js b/web/src/AccountPage.js
new file mode 100644
index 0000000..21daa03
--- /dev/null
+++ b/web/src/AccountPage.js
@@ -0,0 +1,185 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import React from "react";
+import {Avatar, Button, Card, Col, Form, Input, Modal, Row} from "antd";
+import i18next from "i18next";
+import * as AccountBackend from "./backend/AccountBackend";
+import * as Setting from "./Setting";
+
+// Profile and password editing for the built-in login. Casdoor-backed accounts
+// are managed in Casdoor itself, so this page is only reachable without
Casdoor.
+class AccountPage extends React.Component {
+ constructor(props) {
+ super(props);
+ this.formRef = React.createRef();
+ this.state = {
+ avatar: props.account?.avatar ?? "",
+ passwordModalVisible: false,
+ currentPassword: "",
+ newPassword: "",
+ };
+ }
+
+ onFinish(values) {
+ AccountBackend.updateAccount(values)
+ .then((res) => {
+ if (res.status === "ok") {
+ Setting.showMessage("success", "Successfully saved");
+ window.location.reload();
+ } else {
+ Setting.showMessage("error", res.msg);
+ }
+ })
+ .catch((error) => Setting.showMessage("error", error.message));
+ }
+
+ closePasswordModal() {
+ this.setState({
+ passwordModalVisible: false,
+ currentPassword: "",
+ newPassword: "",
+ });
+ }
+
+ setPassword() {
+ const values = this.formRef.current.getFieldsValue();
+ AccountBackend.updateAccount({...values, currentPassword:
this.state.currentPassword, newPassword: this.state.newPassword})
+ .then((res) => {
+ if (res.status === "ok") {
+ Setting.showMessage("success", "Successfully saved");
+ this.closePasswordModal();
+ } else {
+ Setting.showMessage("error", res.msg);
+ }
+ })
+ .catch((error) => Setting.showMessage("error", error.message));
+ }
+
+ renderAvatar() {
+ const account = this.props.account;
+ const avatarUrl = this.state.avatar || "";
+
+ if (!avatarUrl.startsWith("http://") && !avatarUrl.startsWith("https://")
&& !avatarUrl.startsWith("data:image/")) {
+ return (
+ <Avatar style={{backgroundColor:
Setting.getAvatarColor(account.name)}} size={64}>
+ {Setting.getShortName(account.name)}
+ </Avatar>
+ );
+ }
+
+ return (
+ <Avatar src={avatarUrl} size={64}>
+ {Setting.getShortName(account.name)}
+ </Avatar>
+ );
+ }
+
+ renderField(label, control, span = 12) {
+ return (
+ <Col style={{marginTop: "12px"}} span={Setting.isMobile() ? 24 : span}>
+ <div style={{marginBottom: "6px", fontWeight: 500}}>{label}</div>
+ {control}
+ </Col>
+ );
+ }
+
+ render() {
+ const account = this.props.account;
+
+ return (
+ <div style={{padding: "16px 20px 32px"}}>
+ <div style={{marginBottom: "16px", display: "flex", justifyContent:
"space-between", alignItems: "center"}}>
+ <span style={{fontSize: "22px", fontWeight:
600}}>{i18next.t("account:My Account")}</span>
+ <Button type="primary" onClick={() =>
this.formRef.current.submit()}>{i18next.t("general:Save")}</Button>
+ </div>
+
+ <Form
+ ref={this.formRef}
+ initialValues={{
+ username: account.name,
+ displayName: account.displayName,
+ avatar: account.avatar,
+ }}
+ onFinish={(values) => this.onFinish(values)}
+ onValuesChange={(_, values) => this.setState({avatar: values.avatar
?? ""})}
+ >
+ <Card size="small" title={i18next.t("account:Profile")}
style={{marginBottom: "16px"}}>
+ <Row gutter={[16, 8]}>
+ {this.renderField(
+ i18next.t("general:Name"),
+ <Form.Item name="username" style={{margin: 0}}>
+ <Input disabled />
+ </Form.Item>
+ )}
+ {this.renderField(
+ i18next.t("general:Display name"),
+ <Form.Item name="displayName" style={{margin: 0}}>
+ <Input />
+ </Form.Item>
+ )}
+ {this.renderField(
+ i18next.t("account:Avatar"),
+ <Row gutter={10} align="middle">
+ <Col flex="80px">
+ {this.renderAvatar()}
+ </Col>
+ <Col flex="auto">
+ <Form.Item name="avatar" style={{margin: 0}}>
+ <Input placeholder={i18next.t("account:Avatar image URL,
optional")} />
+ </Form.Item>
+ </Col>
+ </Row>,
+ 24
+ )}
+ </Row>
+ </Card>
+
+ <Card size="small" title={i18next.t("general:Password")}>
+ <Button onClick={() => this.setState({passwordModalVisible:
true})}>
+ {i18next.t("account:Modify password...")}
+ </Button>
+ </Card>
+ </Form>
+
+ <Modal
+ maskClosable={false}
+ title={i18next.t("account:Modify password")}
+ open={this.state.passwordModalVisible}
+ okText={i18next.t("account:Set Password")}
+ cancelText={i18next.t("general:Cancel")}
+ onCancel={() => this.closePasswordModal()}
+ onOk={() => this.setPassword()}
+ width={520}
+ >
+ <div style={{padding: "16px 0 8px"}}>
+ <div style={{marginBottom: "6px", fontWeight:
500}}>{i18next.t("account:Old Password")}</div>
+ <Input.Password
+ style={{marginBottom: "20px"}}
+ value={this.state.currentPassword}
+ onChange={e => this.setState({currentPassword: e.target.value})}
+ />
+ <div style={{marginBottom: "6px", fontWeight:
500}}>{i18next.t("account:New Password")}</div>
+ <Input.Password
+ value={this.state.newPassword}
+ onChange={e => this.setState({newPassword: e.target.value})}
+ />
+ </div>
+ </Modal>
+ </div>
+ );
+ }
+}
+
+export default AccountPage;
diff --git a/web/src/App.js b/web/src/App.js
index 218fd26..e97bc87 100644
--- a/web/src/App.js
+++ b/web/src/App.js
@@ -33,6 +33,7 @@ import RuleEditPage from "./RuleEditPage";
import ChannelListPage from "./ChannelListPage";
import ChannelEditPage from "./ChannelEditPage";
import SigninPage from "./SigninPage";
+import AccountPage from "./AccountPage";
import RecordListPage from "./RecordListPage";
import RecordEditPage from "./RecordEditPage";
import i18next from "i18next";
@@ -63,7 +64,23 @@ class App extends Component {
UNSAFE_componentWillMount() {
this.updateMenuKey();
- this.getAccount();
+ this.getSigninOptions();
+ }
+
+ // The auth config lives in app.conf, so ask the backend for it before doing
+ // anything that depends on whether Casdoor is configured at all.
+ getSigninOptions() {
+ AccountBackend.getSigninOptions()
+ .then((res) => {
+ if (res.status === "ok") {
+ Conf.setAuthConfig(res.data.authConfig);
+ Setting.initCasdoorSdk(Conf.AuthConfig);
+ }
+ this.getAccount();
+ })
+ .catch(() => {
+ this.getAccount();
+ });
}
componentDidUpdate() {
@@ -161,7 +178,13 @@ class App extends Component {
handleRightDropdownClick(e) {
if (e.key === "/account") {
- Setting.openLink(Setting.getMyProfileUrl(this.state.account));
+ const profileUrl = Setting.getMyProfileUrl(this.state.account);
+ if (profileUrl === "") {
+ // Built-in login: the profile is edited in Gateway itself.
+ this.props.history.push("/account");
+ } else {
+ Setting.openLink(profileUrl);
+ }
} else if (e.key === "/logout") {
this.signout();
}
@@ -220,16 +243,19 @@ class App extends Component {
if (this.state.account === undefined) {
return null;
} else if (this.state.account === null) {
- res.push(
- <Menu.Item key="/signup" style={{float: "right", marginRight: "20px"}}>
- <a href={Setting.getSignupUrl()}>
- {i18next.t("account:Sign Up")}
- </a>
- </Menu.Item>
- );
+ // Signing up is a Casdoor feature; the built-in login has no
self-service.
+ if (Setting.getSignupUrl() !== "") {
+ res.push(
+ <Menu.Item key="/signup" style={{float: "right", marginRight:
"20px"}}>
+ <a href={Setting.getSignupUrl()}>
+ {i18next.t("account:Sign Up")}
+ </a>
+ </Menu.Item>
+ );
+ }
res.push(
<Menu.Item key="/signin" style={{float: "right"}}>
- <a href={Setting.getSigninUrl()}>
+ <a href={Setting.getSigninUrl() || "/signin"}>
{i18next.t("account:Sign In")}
</a>
</Menu.Item>
@@ -416,6 +442,7 @@ class App extends Component {
<Route exact path="/home" render={(props) => <HomePage
account={this.state.account} {...props} />} />
<Route exact path="/" render={(props) => <Redirect to="/sites" />} />
<Route exact path="/signin" render={(props) =>
this.renderHomeIfSignedIn(<SigninPage {...props} />)} />
+ <Route exact path="/account" render={(props) =>
this.renderSigninIfNotSignedIn(<AccountPage account={this.state.account}
{...props} />)} />
<Route exact path="/agents" render={(props) =>
this.renderSigninIfNotSignedIn(<AgentsPage account={this.state.account}
{...props} />)} />
<Route exact path="/agent-records" render={(props) =>
this.renderSigninIfNotSignedIn(<AgentRecordsPage account={this.state.account}
{...props} />)} />
<Route exact path="/agent-sessions" render={(props) =>
this.renderSigninIfNotSignedIn(<AgentSessionsPage account={this.state.account}
{...props} />)} />
diff --git a/web/src/ChannelEditPage.js b/web/src/ChannelEditPage.js
index eb899bc..84022cc 100644
--- a/web/src/ChannelEditPage.js
+++ b/web/src/ChannelEditPage.js
@@ -29,33 +29,6 @@ const BASE_URL_PRESETS = {
custom: ["https://oneapi.example.com", "https://api.deepseek.com/v1",
"https://api.moonshot.cn/v1"],
};
-// The types the gateway forwards as OpenAI-formatted requests.
-const OPENAI_COMPATIBLE_TYPES = ["openai", "custom"];
-
-// Mirrors object.BuildOpenAiUrl on the server.
-function buildOpenAiUrl(baseUrl, endpoint) {
- let url;
- try {
- url = new URL(baseUrl);
- } catch {
- return "";
- }
- if (url.protocol !== "http:" && url.protocol !== "https:") {
- return "";
- }
-
- let path = url.pathname.replace(/\/+$/, "");
- if (path.endsWith(endpoint)) {
- path = path.slice(0, path.length - endpoint.length);
- }
- if (!path.endsWith("/v1")) {
- path += "/v1";
- }
-
- url.pathname = path + endpoint;
- return url.toString();
-}
-
const MODEL_PRESETS = {
openai: ["gpt-5.5", "gpt-5", "gpt-5-mini", "o3", "o4-mini"],
claude: ["claude-sonnet-5", "claude-opus-4-8", "claude-haiku-4-5"],
diff --git a/web/src/Conf.js b/web/src/Conf.js
index d04265b..70aaf84 100644
--- a/web/src/Conf.js
+++ b/web/src/Conf.js
@@ -12,15 +12,30 @@
// See the License for the specific language governing permissions and
// limitations under the License.
+// Casdoor is optional. These values stay empty unless the backend reports a
+// configured "casdoorEndpoint" in app.conf, in which case setAuthConfig()
fills
+// them in at startup and the app signs in through Casdoor instead of the
+// built-in username/password form.
export const AuthConfig = {
- serverUrl: "https://door.casdoor.com",
- // serverUrl: "http://localhost:7001",
- clientId: "af6b5aa958822fb9dc33",
- appName: "app-casibase",
- organizationName: "casbin",
+ serverUrl: "",
+ clientId: "",
+ appName: "",
+ organizationName: "",
redirectPath: "/callback",
};
+export function setAuthConfig(config) {
+ if (config === null || config === undefined) {
+ return;
+ }
+
+ Object.keys(AuthConfig).forEach((key) => {
+ if (config[key] !== undefined && config[key] !== null) {
+ AuthConfig[key] = config[key];
+ }
+ });
+}
+
export const IsDemoMode = false;
export const ForceLanguage = "";
diff --git a/web/src/Setting.js b/web/src/Setting.js
index 807abfe..3acb470 100644
--- a/web/src/Setting.js
+++ b/web/src/Setting.js
@@ -18,6 +18,7 @@ import {isMobile as isMobileDevice} from
"react-device-detect";
import i18next from "i18next";
import moment from "moment";
import Sdk from "casdoor-js-sdk";
+import * as Conf from "./Conf";
export let ServerUrl = "";
@@ -48,6 +49,18 @@ export function initCasdoorSdk(config) {
CasdoorSdk = new Sdk(config);
}
+// Casdoor is optional: with no "casdoorEndpoint" in app.conf the backend
reports
+// an empty server URL and the app signs in against its own user table instead.
+export function isCasdoorAvailable() {
+ return Conf.AuthConfig.serverUrl !== "";
+}
+
+// A session created by the built-in username/password login, as opposed to one
+// issued by Casdoor. Such a user has no Casdoor profile page to link to.
+export function isBasicLoginMode(account) {
+ return account?.owner === "basic";
+}
+
function getUrlWithLanguage(url) {
if (url.includes("?")) {
return `${url}&language=${getLanguage()}`;
@@ -57,18 +70,30 @@ function getUrlWithLanguage(url) {
}
export function getSignupUrl() {
+ if (!isCasdoorAvailable()) {
+ return "";
+ }
return getUrlWithLanguage(CasdoorSdk.getSignupUrl());
}
export function getSigninUrl() {
+ if (!isCasdoorAvailable()) {
+ return "";
+ }
return getUrlWithLanguage(CasdoorSdk.getSigninUrl());
}
export function getUserProfileUrl(userName, account) {
+ if (!isCasdoorAvailable() || isBasicLoginMode(account)) {
+ return "";
+ }
return getUrlWithLanguage(CasdoorSdk.getUserProfileUrl(userName, account));
}
export function getMyProfileUrl(account) {
+ if (!isCasdoorAvailable() || isBasicLoginMode(account)) {
+ return "";
+ }
return getUrlWithLanguage(CasdoorSdk.getMyProfileUrl(account));
}
diff --git a/web/src/SigninPage.js b/web/src/SigninPage.js
index 313a581..75e059f 100644
--- a/web/src/SigninPage.js
+++ b/web/src/SigninPage.js
@@ -12,17 +12,118 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-import React from "react";
-import * as Setting from "./Setting";
-
-class SigninPage extends React.Component {
- componentDidMount() {
- window.location.replace(Setting.getSigninUrl());
- }
-
- render() {
- return "";
- }
-}
-
-export default SigninPage;
+import React from "react";
+import {Button, Form, Input, Result, Spin} from "antd";
+import {LockOutlined, UserOutlined} from "@ant-design/icons";
+import i18next from "i18next";
+import * as AccountBackend from "./backend/AccountBackend";
+import * as Conf from "./Conf";
+import * as Setting from "./Setting";
+
+// Signing in goes one of two ways: redirect to Casdoor when app.conf
configures
+// it, otherwise show the built-in username/password form.
+class SigninPage extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = {
+ loading: true,
+ showSignin: false,
+ errorMessage: "",
+ autoSignin: false,
+ };
+ }
+
+ componentDidMount() {
+ AccountBackend.getSigninOptions()
+ .then((res) => {
+ if (res.status === "ok" && res.data?.casdoorAvailable) {
+ // Do not wait for App to publish the config: this page may well have
+ // mounted before that request came back.
+ Conf.setAuthConfig(res.data.authConfig);
+ Setting.initCasdoorSdk(Conf.AuthConfig);
+ window.location.replace(Setting.getSigninUrl());
+ return;
+ }
+
+ this.setState({
+ loading: false,
+ showSignin: res.status === "ok" && res.data?.signinAvailable,
+ errorMessage: res.status === "ok" ? "" : res.msg,
+ autoSignin: res.status === "ok" && res.data?.autoSignin === true,
+ });
+ })
+ .catch((error) => {
+ this.setState({
+ loading: false,
+ showSignin: false,
+ errorMessage: error.message,
+ });
+ });
+ }
+
+ onFinish(values) {
+ AccountBackend.signinWithPassword(values.username, values.password)
+ .then((res) => {
+ if (res.status === "ok") {
+ const from = sessionStorage.getItem("from") || "/";
+ sessionStorage.removeItem("from");
+ window.location.href = from;
+ } else {
+ Setting.showMessage("error", res.msg);
+ }
+ })
+ .catch((error) => Setting.showMessage("error", error.message));
+ }
+
+ render() {
+ if (this.state.loading) {
+ return (
+ <div style={{display: "flex", alignItems: "center", justifyContent:
"center", minHeight: "60vh"}}>
+ <Spin size="large" tip={"Signing in..."} />
+ </div>
+ );
+ }
+
+ if (!this.state.showSignin) {
+ return (
+ <Result
+ status="warning"
+ title={"Login Error"}
+ subTitle={this.state.errorMessage || i18next.t("account:Sign in is
unavailable")}
+ />
+ );
+ }
+
+ return (
+ <div style={{display: "flex", alignItems: "center", justifyContent:
"center", minHeight: "60vh"}}>
+ <div style={{width: "340px"}}>
+ <div style={{textAlign: "center", marginBottom: "36px", fontSize:
"22px", fontWeight: 600}}>
+ {i18next.t("account:Sign In")}
+ </div>
+ <Form initialValues={{username: "admin", password:
this.state.autoSignin ? "123" : undefined}} onFinish={(values) =>
this.onFinish(values)} requiredMark={false}>
+ <Form.Item name="username" rules={[{required: true, message:
i18next.t("account:Please input your username")}]}>
+ <Input
+ prefix={<UserOutlined />}
+ placeholder={i18next.t("account:Username")}
+ style={{height: "42px"}}
+ />
+ </Form.Item>
+ <Form.Item name="password" rules={[{required: true, message:
i18next.t("account:Please input your password")}]}>
+ <Input.Password
+ prefix={<LockOutlined />}
+ placeholder={i18next.t("general:Password")}
+ autoFocus
+ style={{height: "42px"}}
+ />
+ </Form.Item>
+ <Button type="primary" htmlType="submit" block style={{height:
"42px", marginTop: "8px"}}>
+ {i18next.t("account:Sign In")}
+ </Button>
+ </Form>
+ </div>
+ </div>
+ );
+ }
+}
+
+export default SigninPage;
diff --git a/web/src/backend/AccountBackend.js
b/web/src/backend/AccountBackend.js
index ba81c3c..7c42e44 100644
--- a/web/src/backend/AccountBackend.js
+++ b/web/src/backend/AccountBackend.js
@@ -14,8 +14,15 @@
import * as Setting from "../Setting";
+export function getSigninOptions() {
+ return fetch(`${Setting.ServerUrl}/api/get-signin-options`, {
+ method: "GET",
+ credentials: "include",
+ }).then(res => res.json());
+}
+
export function getAccount() {
- return fetch(`${Setting.ServerUrl}/api/get-account`, {
+ return
fetch(`${Setting.ServerUrl}/api/get-account?fromPath=${encodeURIComponent(window.location.pathname)}`,
{
method: "GET",
credentials: "include",
}).then(res => res.json());
@@ -28,6 +35,24 @@ export function signin(code, state) {
}).then(res => res.json());
}
+export function signinWithPassword(username, password) {
+ return fetch(`${Setting.ServerUrl}/api/signin`, {
+ method: "POST",
+ credentials: "include",
+ headers: {"Content-Type": "application/json"},
+ body: JSON.stringify({username: username, password: password}),
+ }).then(res => res.json());
+}
+
+export function updateAccount(account) {
+ return fetch(`${Setting.ServerUrl}/api/update-account`, {
+ method: "POST",
+ credentials: "include",
+ headers: {"Content-Type": "application/json"},
+ body: JSON.stringify(account),
+ }).then(res => res.json());
+}
+
export function signout() {
return fetch(`${Setting.ServerUrl}/api/signout`, {
method: "POST",
diff --git a/web/src/locales/de/data.json b/web/src/locales/de/data.json
index 15849e6..b904891 100644
--- a/web/src/locales/de/data.json
+++ b/web/src/locales/de/data.json
@@ -1,9 +1,21 @@
{
"account": {
+ "Avatar": "Avatar",
+ "Avatar image URL, optional": "Avatar image URL, optional",
+ "Modify password": "Modify password",
+ "Modify password...": "Modify password...",
"My Account": "My Account",
+ "New Password": "New Password",
+ "Old Password": "Old Password",
+ "Please input your password": "Please input your password",
+ "Please input your username": "Please input your username",
+ "Profile": "Profile",
+ "Set Password": "Set Password",
"Sign In": "Sign In",
"Sign Out": "Sign Out",
- "Sign Up": "Sign Up"
+ "Sign Up": "Sign Up",
+ "Sign in is unavailable": "Sign in is unavailable",
+ "Username": "Username"
},
"agent": {
"Agent": "Agent",
@@ -64,6 +76,7 @@
"Nodes": "Nodes",
"OK": "OK",
"Owner": "Owner",
+ "Password": "Password",
"Path": "Path",
"Records": "Records",
"Refresh": "Refresh",
@@ -150,4 +163,4 @@
"Week": "Week",
"seconds": "seconds"
}
-}
\ No newline at end of file
+}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index 50d07ec..be13f28 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -1,9 +1,21 @@
{
"account": {
+ "Avatar": "Avatar",
+ "Avatar image URL, optional": "Avatar image URL, optional",
+ "Modify password": "Modify password",
+ "Modify password...": "Modify password...",
"My Account": "My Account",
+ "New Password": "New Password",
+ "Old Password": "Old Password",
+ "Please input your password": "Please input your password",
+ "Please input your username": "Please input your username",
+ "Profile": "Profile",
+ "Set Password": "Set Password",
"Sign In": "Sign In",
"Sign Out": "Sign Out",
- "Sign Up": "Sign Up"
+ "Sign Up": "Sign Up",
+ "Sign in is unavailable": "Sign in is unavailable",
+ "Username": "Username"
},
"agent": {
"Agent": "Agent",
@@ -135,6 +147,7 @@
"Nodes": "Nodes",
"OK": "OK",
"Owner": "Owner",
+ "Password": "Password",
"Path": "Path",
"Records": "Records",
"Refresh": "Refresh",
diff --git a/web/src/locales/es/data.json b/web/src/locales/es/data.json
index 75d43b7..d7be6ce 100644
--- a/web/src/locales/es/data.json
+++ b/web/src/locales/es/data.json
@@ -1,9 +1,21 @@
{
"account": {
+ "Avatar": "Avatar",
+ "Avatar image URL, optional": "Avatar image URL, optional",
+ "Modify password": "Modify password",
+ "Modify password...": "Modify password...",
"My Account": "My Account",
+ "New Password": "New Password",
+ "Old Password": "Old Password",
+ "Please input your password": "Please input your password",
+ "Please input your username": "Please input your username",
+ "Profile": "Profile",
+ "Set Password": "Set Password",
"Sign In": "Sign In",
"Sign Out": "Sign Out",
- "Sign Up": "Sign Up"
+ "Sign Up": "Sign Up",
+ "Sign in is unavailable": "Sign in is unavailable",
+ "Username": "Username"
},
"agent": {
"Agent": "Agent",
@@ -64,6 +76,7 @@
"Nodes": "Nodes",
"OK": "OK",
"Owner": "Owner",
+ "Password": "Password",
"Path": "Path",
"Records": "Records",
"Refresh": "Refresh",
@@ -150,4 +163,4 @@
"Week": "Week",
"seconds": "seconds"
}
-}
\ No newline at end of file
+}
diff --git a/web/src/locales/fr/data.json b/web/src/locales/fr/data.json
index 542e10e..f59950c 100644
--- a/web/src/locales/fr/data.json
+++ b/web/src/locales/fr/data.json
@@ -1,9 +1,21 @@
{
"account": {
+ "Avatar": "Avatar",
+ "Avatar image URL, optional": "Avatar image URL, optional",
+ "Modify password": "Modify password",
+ "Modify password...": "Modify password...",
"My Account": "My Account",
+ "New Password": "New Password",
+ "Old Password": "Old Password",
+ "Please input your password": "Please input your password",
+ "Please input your username": "Please input your username",
+ "Profile": "Profile",
+ "Set Password": "Set Password",
"Sign In": "Sign In",
"Sign Out": "Sign Out",
- "Sign Up": "Sign Up"
+ "Sign Up": "Sign Up",
+ "Sign in is unavailable": "Sign in is unavailable",
+ "Username": "Username"
},
"agent": {
"Agent": "Agent",
@@ -64,6 +76,7 @@
"Nodes": "Nodes",
"OK": "OK",
"Owner": "Owner",
+ "Password": "Password",
"Path": "Path",
"Records": "Records",
"Refresh": "Refresh",
@@ -150,4 +163,4 @@
"Week": "Week",
"seconds": "seconds"
}
-}
\ No newline at end of file
+}
diff --git a/web/src/locales/id/data.json b/web/src/locales/id/data.json
index b2e4c8c..87eae2f 100644
--- a/web/src/locales/id/data.json
+++ b/web/src/locales/id/data.json
@@ -1,9 +1,21 @@
{
"account": {
+ "Avatar": "Avatar",
+ "Avatar image URL, optional": "Avatar image URL, optional",
+ "Modify password": "Modify password",
+ "Modify password...": "Modify password...",
"My Account": "My Account",
+ "New Password": "New Password",
+ "Old Password": "Old Password",
+ "Please input your password": "Please input your password",
+ "Please input your username": "Please input your username",
+ "Profile": "Profile",
+ "Set Password": "Set Password",
"Sign In": "Sign In",
"Sign Out": "Sign Out",
- "Sign Up": "Sign Up"
+ "Sign Up": "Sign Up",
+ "Sign in is unavailable": "Sign in is unavailable",
+ "Username": "Username"
},
"agent": {
"Agent": "Agent",
@@ -64,6 +76,7 @@
"Nodes": "Nodes",
"OK": "OK",
"Owner": "Owner",
+ "Password": "Password",
"Path": "Path",
"Records": "Records",
"Refresh": "Refresh",
@@ -150,4 +163,4 @@
"Week": "Week",
"seconds": "seconds"
}
-}
\ No newline at end of file
+}
diff --git a/web/src/locales/ja/data.json b/web/src/locales/ja/data.json
index 2db0e02..a51d4ea 100644
--- a/web/src/locales/ja/data.json
+++ b/web/src/locales/ja/data.json
@@ -1,9 +1,21 @@
{
"account": {
+ "Avatar": "Avatar",
+ "Avatar image URL, optional": "Avatar image URL, optional",
+ "Modify password": "Modify password",
+ "Modify password...": "Modify password...",
"My Account": "My Account",
+ "New Password": "New Password",
+ "Old Password": "Old Password",
+ "Please input your password": "Please input your password",
+ "Please input your username": "Please input your username",
+ "Profile": "Profile",
+ "Set Password": "Set Password",
"Sign In": "Sign In",
"Sign Out": "Sign Out",
- "Sign Up": "Sign Up"
+ "Sign Up": "Sign Up",
+ "Sign in is unavailable": "Sign in is unavailable",
+ "Username": "Username"
},
"agent": {
"Agent": "Agent",
@@ -64,6 +76,7 @@
"Nodes": "Nodes",
"OK": "OK",
"Owner": "Owner",
+ "Password": "Password",
"Path": "Path",
"Records": "Records",
"Refresh": "Refresh",
@@ -150,4 +163,4 @@
"Week": "Week",
"seconds": "seconds"
}
-}
\ No newline at end of file
+}
diff --git a/web/src/locales/ko/data.json b/web/src/locales/ko/data.json
index 64d1fe6..ac4ad22 100644
--- a/web/src/locales/ko/data.json
+++ b/web/src/locales/ko/data.json
@@ -1,9 +1,21 @@
{
"account": {
+ "Avatar": "Avatar",
+ "Avatar image URL, optional": "Avatar image URL, optional",
+ "Modify password": "Modify password",
+ "Modify password...": "Modify password...",
"My Account": "My Account",
+ "New Password": "New Password",
+ "Old Password": "Old Password",
+ "Please input your password": "Please input your password",
+ "Please input your username": "Please input your username",
+ "Profile": "Profile",
+ "Set Password": "Set Password",
"Sign In": "Sign In",
"Sign Out": "Sign Out",
- "Sign Up": "Sign Up"
+ "Sign Up": "Sign Up",
+ "Sign in is unavailable": "Sign in is unavailable",
+ "Username": "Username"
},
"agent": {
"Agent": "Agent",
@@ -64,6 +76,7 @@
"Nodes": "Nodes",
"OK": "OK",
"Owner": "Owner",
+ "Password": "Password",
"Path": "Path",
"Records": "Records",
"Refresh": "Refresh",
@@ -150,4 +163,4 @@
"Week": "Week",
"seconds": "seconds"
}
-}
\ No newline at end of file
+}
diff --git a/web/src/locales/ru/data.json b/web/src/locales/ru/data.json
index bd26863..75891a3 100644
--- a/web/src/locales/ru/data.json
+++ b/web/src/locales/ru/data.json
@@ -1,9 +1,21 @@
{
"account": {
+ "Avatar": "Avatar",
+ "Avatar image URL, optional": "Avatar image URL, optional",
+ "Modify password": "Modify password",
+ "Modify password...": "Modify password...",
"My Account": "My Account",
+ "New Password": "New Password",
+ "Old Password": "Old Password",
+ "Please input your password": "Please input your password",
+ "Please input your username": "Please input your username",
+ "Profile": "Profile",
+ "Set Password": "Set Password",
"Sign In": "Sign In",
"Sign Out": "Sign Out",
- "Sign Up": "Sign Up"
+ "Sign Up": "Sign Up",
+ "Sign in is unavailable": "Sign in is unavailable",
+ "Username": "Username"
},
"agent": {
"Agent": "Agent",
@@ -64,6 +76,7 @@
"Nodes": "Nodes",
"OK": "OK",
"Owner": "Owner",
+ "Password": "Password",
"Path": "Path",
"Records": "Records",
"Refresh": "Refresh",
@@ -150,4 +163,4 @@
"Week": "Week",
"seconds": "seconds"
}
-}
\ No newline at end of file
+}
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index a1765fa..82a4885 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -1,9 +1,21 @@
{
"account": {
+ "Avatar": "头像",
+ "Avatar image URL, optional": "头像图片链接(可选)",
+ "Modify password": "修改密码",
+ "Modify password...": "修改密码...",
"My Account": "我的账户",
+ "New Password": "新密码",
+ "Old Password": "当前密码",
+ "Please input your password": "请输入密码",
+ "Please input your username": "请输入用户名",
+ "Profile": "个人资料",
+ "Set Password": "设置密码",
"Sign In": "登录",
"Sign Out": "退出",
- "Sign Up": "注册"
+ "Sign Up": "注册",
+ "Sign in is unavailable": "登录不可用",
+ "Username": "用户名"
},
"agent": {
"Agent": "Agent",
@@ -135,6 +147,7 @@
"Nodes": "Nodes",
"OK": "确定",
"Owner": "所有者",
+ "Password": "密码",
"Path": "路径",
"Records": "日志",
"Refresh": "Refresh",
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]