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]

Reply via email to