Compare commits

...

27 Commits

Author SHA1 Message Date
Ke Wang
0765b352c9 fix: respect application's ID signup rule in WeChat Mini Program login (#5168) 2026-02-24 21:21:18 +08:00
Yang Luo
a2a8b582d9 feat: make DingTalk syncer respect TableColumns field mapping configuration (#5073) 2026-02-24 12:55:40 +08:00
Sriram-B-Srivatsa
0973652be4 fix: reduce code duplication in Logout logic (#5163) 2026-02-24 12:53:31 +08:00
Yang Luo
fef75715bf fix(web): prevent dashboard graph overlap when y-axis values increase 2026-02-23 15:24:05 +08:00
hikarukimi
4f78d56e31 feat: add OAuth consent page 2026-02-23 15:16:04 +08:00
hikarukimi
712bc756bc fix: improve code format 2026-02-23 15:09:57 +08:00
DacongDA
1c9952e3d9 feat: support JWT Profile for OAuth 2.0 Client Grants (RFC 7523) (#5124) 2026-02-23 14:44:34 +08:00
Yang Luo
bbaa28133f feat: apply application.DefaultGroup for OAuth signups (#5157) 2026-02-22 01:06:18 +08:00
Yang Luo
baef7680ea feat: validate OAuth scopes against Application config; return invalid_scope per RFC 6749 (#5153) 2026-02-21 17:44:26 +08:00
Yang Luo
d15b66177c feat: add missing Telegram field to User struct (#5151) 2026-02-21 17:21:31 +08:00
Yang Luo
5ce6bac529 fix: improve provider table links 2026-02-21 01:36:00 +08:00
Yang Luo
0621f35665 fix: improve tabs height UI in app edit page 2026-02-21 01:16:36 +08:00
Yang Luo
1ac2490419 fix: add OIDC and SAML tabs in application edit page 2026-02-21 01:13:54 +08:00
DacongDA
8c50ada494 feat: refactor provider edit page into different JS files (#5141) 2026-02-21 00:57:38 +08:00
Yang Luo
22da90576e feat: can free input in "Tag" in Addresses table 2026-02-20 16:49:50 +08:00
Yang Luo
b00404cb3a fix: fix RegionSelect cannot save value bug in Addresses table 2026-02-20 16:45:43 +08:00
Yang Luo
2ed27f4f0a fix: improve tables UI in my account page 2026-02-20 16:35:29 +08:00
Yang Luo
bf538d5260 fix: update UpdateUser() columns for missing User fields 2026-02-20 11:02:52 +08:00
Yang Luo
13ee5fd150 feat: sync newOrganization() accountItems with getBuiltInAccountItems() (#5146) 2026-02-20 10:47:02 +08:00
Yang Luo
04cdd5a012 feat: add missing user fields to GetTranslatedUserItems, getBuiltInAccountItems, init_data template, and UserFields (#5144) 2026-02-20 10:37:51 +08:00
Yang Luo
7b4873734b feat: fix "--config" flag to actually load specified configuration file (#5139) 2026-02-19 02:13:29 +08:00
Yang Luo
8d2290944a fix: add back Payment.ProductName and ProductDisplayName fields for backward compatibility 2026-02-18 19:28:14 +08:00
Yang Luo
6a2bba1627 feat: fix field visibility logic for provider types in ProviderEditPage (#5134) 2026-02-18 15:22:28 +08:00
Yang Luo
07554bbbe5 feat: fix Alipay OAuth provider by loading private key from cert object (#5119) 2026-02-17 14:42:21 +08:00
karatekaneen
a050403ee5 feat: fix bug that PKCE fails when multiple custom OAuth providers are configured (#5117) 2026-02-16 23:32:07 +08:00
IsAurora6
118eb0af80 feat: Optimize the display of payment products. (#5115) 2026-02-16 16:32:02 +08:00
Yang Luo
c16aebe642 fix: update README slogan 2026-02-16 02:33:45 +08:00
61 changed files with 3367 additions and 1444 deletions

View File

@@ -1,5 +1,5 @@
<h1 align="center" style="border-bottom: none;">📦⚡️ Casdoor</h1>
<h3 align="center">An open-source UI-first Identity and Access Management (IAM) / Single-Sign-On (SSO) platform with web UI supporting OAuth 2.0, OIDC, SAML, CAS, LDAP, SCIM, WebAuthn, TOTP, MFA and RADIUS</h3>
<h3 align="center">An open-source AI-first Identity and Access Management (IAM) /AI MCP gateway and auth server with web UI supporting MCP, A2A, OAuth 2.1, OIDC, SAML, CAS, LDAP, SCIM, WebAuthn, TOTP, MFA, Face ID, Google Workspace, Azure AD</h3>
<p align="center">
<a href="#badge">
<img alt="semantic-release" src="https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg">

View File

@@ -323,6 +323,17 @@ func (c *ApiController) Signup() {
// If OAuth parameters are present, generate OAuth code and return it
if clientId != "" && responseType == ResponseTypeCode {
consentRequired, err := object.CheckConsentRequired(user, application, scope)
if err != nil {
c.ResponseError(err.Error())
return
}
if consentRequired {
c.ResponseOk(map[string]bool{"required": true})
return
}
code, err := object.GetOAuthCode(userId, clientId, "", "password", responseType, redirectUri, scope, state, nonce, codeChallenge, "", c.Ctx.Request.Host, c.GetAcceptLanguage())
if err != nil {
c.ResponseError(err.Error(), nil)
@@ -364,18 +375,11 @@ func (c *ApiController) Logout() {
c.ClearUserSession()
c.ClearTokenSession()
owner, username, err := util.GetOwnerAndNameFromIdWithError(user)
if err != nil {
c.ResponseError(err.Error())
return
}
_, err = object.DeleteSessionId(util.GetSessionId(owner, username, object.CasdoorApplication), c.Ctx.Input.CruSession.SessionID(context.Background()))
if err != nil {
c.ResponseError(err.Error())
return
}
util.LogInfo(c.Ctx, "API: [%s] logged out", user)
if err := c.deleteUserSession(user); err != nil {
c.ResponseError(err.Error())
return
}
application := c.GetSessionApplication()
if application == nil || application.Name == "app-built-in" || application.HomepageUrl == "" {
@@ -415,21 +419,13 @@ func (c *ApiController) Logout() {
c.ClearUserSession()
c.ClearTokenSession()
// TODO https://github.com/casdoor/casdoor/pull/1494#discussion_r1095675265
owner, username, err := util.GetOwnerAndNameFromIdWithError(user)
if err != nil {
if err := c.deleteUserSession(user); err != nil {
c.ResponseError(err.Error())
return
}
_, err = object.DeleteSessionId(util.GetSessionId(owner, username, object.CasdoorApplication), c.Ctx.Input.CruSession.SessionID(context.Background()))
if err != nil {
c.ResponseError(err.Error())
return
}
util.LogInfo(c.Ctx, "API: [%s] logged out", user)
if redirectUri == "" {
c.ResponseOk()
return
@@ -766,3 +762,24 @@ func (c *ApiController) GetCaptcha() {
c.ResponseOk(Captcha{Type: "none"})
}
func (c *ApiController) deleteUserSession(user string) error {
owner, username, err := util.GetOwnerAndNameFromIdWithError(user)
if err != nil {
return err
}
// Casdoor session ID derived from owner, username, and application
sessionId := util.GetSessionId(owner, username, object.CasdoorApplication)
// Explicitly get the Beego session ID from the context
beegoSessionId := c.Ctx.Input.CruSession.SessionID(context.Background())
_, err = object.DeleteSessionId(sessionId, beegoSessionId)
if err != nil {
return err
}
util.LogInfo(c.Ctx, "API: [%s] logged out", user)
return nil
}

View File

@@ -167,6 +167,19 @@ func (c *ApiController) HandleLoggedIn(application *object.Application, user *ob
c.ResponseError(c.T("auth:Challenge method should be S256"))
return
}
consentRequired, err := object.CheckConsentRequired(user, application, scope)
if err != nil {
c.ResponseError(err.Error())
return
}
if consentRequired {
resp = &Response{Status: "ok", Data: map[string]bool{"required": true}}
resp.Data3 = user.NeedUpdatePassword
return
}
code, err := object.GetOAuthCode(userId, clientId, form.Provider, form.SigninMethod, responseType, redirectUri, scope, state, nonce, codeChallenge, resource, c.Ctx.Request.Host, c.GetAcceptLanguage())
if err != nil {
c.ResponseError(err.Error(), nil)
@@ -185,10 +198,14 @@ func (c *ApiController) HandleLoggedIn(application *object.Application, user *ob
} else {
scope := c.Ctx.Input.Query("scope")
nonce := c.Ctx.Input.Query("nonce")
token, _ := object.GetTokenByUser(application, user, scope, nonce, c.Ctx.Request.Host)
resp = tokenToResponse(token)
if !object.IsScopeValid(scope, application) {
resp = &Response{Status: "error", Msg: "error: invalid_scope", Data: ""}
} else {
token, _ := object.GetTokenByUser(application, user, scope, nonce, c.Ctx.Request.Host)
resp = tokenToResponse(token)
resp.Data3 = user.NeedUpdatePassword
resp.Data3 = user.NeedUpdatePassword
}
}
} else if form.Type == ResponseTypeDevice {
authCache, ok := object.DeviceAuthMap.LoadAndDelete(form.UserCode)
@@ -739,7 +756,11 @@ func (c *ApiController) Login() {
}
} else if provider.Category == "OAuth" || provider.Category == "Web3" {
// OAuth
idpInfo := object.FromProviderToIdpInfo(c.Ctx, provider)
idpInfo, err := object.FromProviderToIdpInfo(c.Ctx, provider)
if err != nil {
c.ResponseError(err.Error())
return
}
idpInfo.CodeVerifier = authForm.CodeVerifier
var idProvider idp.IdProvider
idProvider, err = idp.GetIdProvider(idpInfo, authForm.RedirectUri)
@@ -950,11 +971,13 @@ func (c *ApiController) Login() {
RegisterSource: fmt.Sprintf("%s/%s", application.Organization, application.Name),
}
// Set group from invitation code if available, otherwise use provider's signup group
// Set group from invitation code if available, otherwise use provider's signup group or application's default group
if invitation != nil && invitation.SignupGroup != "" {
user.Groups = []string{invitation.SignupGroup}
} else if providerItem.SignupGroup != "" {
user.Groups = []string{providerItem.SignupGroup}
} else if application.DefaultGroup != "" {
user.Groups = []string{application.DefaultGroup}
}
var affected bool

226
controllers/consent.go Normal file
View File

@@ -0,0 +1,226 @@
// Copyright 2026 The Casdoor 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 controllers
import (
"encoding/json"
"github.com/casdoor/casdoor/object"
)
// RevokeConsent revokes a consent record
// @Title RevokeConsent
// @Tag Consent API
// @Description revoke a consent record
// @Param body body object.ConsentRecord true "The consent object"
// @Success 200 {object} controllers.Response The Response object
// @router /revoke-consent [post]
func (c *ApiController) RevokeConsent() {
userId := c.GetSessionUsername()
if userId == "" {
c.ResponseError(c.T("general:Please login first"))
return
}
var consent object.ConsentRecord
err := json.Unmarshal(c.Ctx.Input.RequestBody, &consent)
if err != nil {
c.ResponseError(err.Error())
return
}
// Validate that consent.Application is not empty
if consent.Application == "" {
c.ResponseError(c.T("general:Application cannot be empty"))
return
}
// Validate that GrantedScopes is not empty when scope-specific revoke is requested
if len(consent.GrantedScopes) == 0 {
c.ResponseError(c.T("general:Granted scopes cannot be empty"))
return
}
userObj, err := object.GetUser(userId)
if err != nil {
c.ResponseError(err.Error())
return
}
if userObj == nil {
c.ResponseError(c.T("general:The user doesn't exist"))
return
}
newScopes := []object.ConsentRecord{}
for _, record := range userObj.ApplicationScopes {
if record.Application != consent.Application {
// skip other applications
newScopes = append(newScopes, record)
continue
}
// revoke specified scopes
revokeSet := make(map[string]bool)
for _, s := range consent.GrantedScopes {
revokeSet[s] = true
}
remaining := []string{}
for _, s := range record.GrantedScopes {
if !revokeSet[s] {
remaining = append(remaining, s)
}
}
if len(remaining) > 0 {
// still have remaining scopes, keep the record and update
record.GrantedScopes = remaining
newScopes = append(newScopes, record)
}
// otherwise the application authorization is revoked, delete the whole record
}
userObj.ApplicationScopes = newScopes
success, err := object.UpdateUser(userObj.GetId(), userObj, nil, false)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(success)
}
// GrantConsent grants consent for an OAuth application and returns authorization code
// @Title GrantConsent
// @Tag Consent API
// @Description grant consent for an OAuth application and get authorization code
// @Param body body object.ConsentRecord true "The consent object with OAuth parameters"
// @Success 200 {object} controllers.Response The Response object
// @router /grant-consent [post]
func (c *ApiController) GrantConsent() {
userId := c.GetSessionUsername()
if userId == "" {
c.ResponseError(c.T("general:Please login first"))
return
}
var request struct {
Application string `json:"application"`
Scopes []string `json:"grantedScopes"`
ClientId string `json:"clientId"`
Provider string `json:"provider"`
SigninMethod string `json:"signinMethod"`
ResponseType string `json:"responseType"`
RedirectUri string `json:"redirectUri"`
Scope string `json:"scope"`
State string `json:"state"`
Nonce string `json:"nonce"`
Challenge string `json:"challenge"`
Resource string `json:"resource"`
}
err := json.Unmarshal(c.Ctx.Input.RequestBody, &request)
if err != nil {
c.ResponseError(err.Error())
return
}
// Validate application by clientId
application, err := object.GetApplicationByClientId(request.ClientId)
if err != nil {
c.ResponseError(err.Error())
return
}
if application == nil {
c.ResponseError(c.T("general:Invalid client_id"))
return
}
// Verify that request.Application matches the application's actual ID
if request.Application != application.GetId() {
c.ResponseError(c.T("general:Invalid application"))
return
}
// Update user's ApplicationScopes
userObj, err := object.GetUser(userId)
if err != nil {
c.ResponseError(err.Error())
return
}
if userObj == nil {
c.ResponseError(c.T("general:User not found"))
return
}
appId := application.GetId()
found := false
// Insert new scope into existing applicationScopes
for i, record := range userObj.ApplicationScopes {
if record.Application == appId {
existing := make(map[string]bool)
for _, s := range userObj.ApplicationScopes[i].GrantedScopes {
existing[s] = true
}
for _, s := range request.Scopes {
if !existing[s] {
userObj.ApplicationScopes[i].GrantedScopes = append(userObj.ApplicationScopes[i].GrantedScopes, s)
existing[s] = true
}
}
found = true
break
}
}
// create a new applicationScopes if not found
if !found {
uniqueScopes := []string{}
existing := make(map[string]bool)
for _, s := range request.Scopes {
if !existing[s] {
uniqueScopes = append(uniqueScopes, s)
existing[s] = true
}
}
userObj.ApplicationScopes = append(userObj.ApplicationScopes, object.ConsentRecord{
Application: appId,
GrantedScopes: uniqueScopes,
})
}
_, err = object.UpdateUser(userObj.GetId(), userObj, []string{"application_scopes"}, false)
if err != nil {
c.ResponseError(err.Error())
return
}
// Now get the OAuth code
code, err := object.GetOAuthCode(
userId,
request.ClientId,
request.Provider,
request.SigninMethod,
request.ResponseType,
request.RedirectUri,
request.Scope,
request.State,
request.Nonce,
request.Challenge,
request.Resource,
c.Ctx.Request.Host,
c.GetAcceptLanguage(),
)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(code.Code)
}

View File

@@ -162,6 +162,9 @@ func (c *ApiController) DeleteToken() {
func (c *ApiController) GetOAuthToken() {
clientId := c.Ctx.Input.Query("client_id")
clientSecret := c.Ctx.Input.Query("client_secret")
assertion := c.Ctx.Input.Query("assertion")
clientAssertion := c.Ctx.Input.Query("client_assertion")
clientAssertionType := c.Ctx.Input.Query("client_assertion_type")
grantType := c.Ctx.Input.Query("grant_type")
code := c.Ctx.Input.Query("code")
verifier := c.Ctx.Input.Query("code_verifier")
@@ -193,6 +196,12 @@ func (c *ApiController) GetOAuthToken() {
if clientSecret == "" {
clientSecret = tokenRequest.ClientSecret
}
if clientAssertion == "" {
clientAssertion = tokenRequest.ClientAssertion
}
if clientAssertionType == "" {
clientAssertionType = tokenRequest.ClientAssertionType
}
if grantType == "" {
grantType = tokenRequest.GrantType
}
@@ -235,9 +244,13 @@ func (c *ApiController) GetOAuthToken() {
if resource == "" {
resource = tokenRequest.Resource
}
if assertion == "" {
assertion = tokenRequest.Assertion
}
}
}
host := c.Ctx.Request.Host
if deviceCode != "" {
deviceAuthCache, ok := object.DeviceAuthMap.Load(deviceCode)
if !ok {
@@ -278,8 +291,7 @@ func (c *ApiController) GetOAuthToken() {
username = deviceAuthCacheCast.UserName
}
host := c.Ctx.Request.Host
token, err := object.GetOAuthToken(grantType, clientId, clientSecret, code, verifier, scope, nonce, username, password, host, refreshToken, tag, avatar, c.GetAcceptLanguage(), subjectToken, subjectTokenType, audience, resource)
token, err := object.GetOAuthToken(grantType, clientId, clientSecret, code, verifier, scope, nonce, username, password, host, refreshToken, tag, avatar, c.GetAcceptLanguage(), subjectToken, subjectTokenType, assertion, clientAssertion, clientAssertionType, audience, resource)
if err != nil {
c.ResponseError(err.Error())
return
@@ -323,7 +335,12 @@ func (c *ApiController) RefreshToken() {
}
}
refreshToken2, err := object.RefreshToken(grantType, refreshToken, scope, clientId, clientSecret, host)
ok, application, clientId, _, err := c.ValidateOAuth(true)
if err != nil || !ok {
return
}
refreshToken2, err := object.RefreshToken(application, grantType, refreshToken, scope, clientId, clientSecret, host)
if err != nil {
c.ResponseError(err.Error())
return
@@ -334,14 +351,79 @@ func (c *ApiController) RefreshToken() {
c.ServeJSON()
}
func (c *ApiController) ResponseTokenError(errorMsg string) {
func (c *ApiController) ResponseTokenError(errorMsg string, errorDescription string) {
c.Data["json"] = &object.TokenError{
Error: errorMsg,
Error: errorMsg,
ErrorDescription: errorDescription,
}
c.SetTokenErrorHttpStatus()
c.ServeJSON()
}
func (c *ApiController) ValidateOAuth(ignoreValidSecret bool) (ok bool, application *object.Application, clientId, clientSecret string, err error) {
reqClientId := c.Ctx.Input.Query("client_id")
reqClientSecret := c.Ctx.Input.Query("client_secret")
clientAssertion := c.Ctx.Input.Query("client_assertion")
clientAssertionType := c.Ctx.Input.Query("client_assertion_type")
if reqClientId == "" && clientAssertionType == "" {
var tokenRequest TokenRequest
if err := json.Unmarshal(c.Ctx.Input.RequestBody, &tokenRequest); err == nil {
reqClientId = tokenRequest.ClientId
reqClientSecret = tokenRequest.ClientSecret
clientAssertion = tokenRequest.ClientAssertion
clientAssertionType = tokenRequest.ClientAssertionType
}
}
if clientAssertionType == "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" {
ok, application, err = object.ValidateClientAssertion(clientAssertion, c.Ctx.Request.Host)
if err != nil {
c.ResponseTokenError(object.InvalidClient, err.Error())
return
}
if !ok || application == nil {
c.ResponseTokenError(object.InvalidClient, "client_assertion is invalid")
return
}
clientSecret = application.ClientSecret
clientId = application.ClientId
ok = true
return
}
if reqClientId == "" && reqClientSecret == "" {
clientId, clientSecret, ok = c.Ctx.Request.BasicAuth()
if !ok {
clientId = c.Ctx.Input.Query("client_id")
clientSecret = c.Ctx.Input.Query("client_secret")
if clientId == "" || clientSecret == "" {
c.ResponseTokenError(object.InvalidRequest, "")
return
}
}
} else {
clientId = reqClientId
clientSecret = reqClientSecret
}
application, err = object.GetApplicationByClientId(clientId)
if err != nil {
c.ResponseTokenError(object.InvalidClient, err.Error())
return
}
if application == nil || (application.ClientSecret != clientSecret && !ignoreValidSecret) {
c.ResponseTokenError(object.InvalidClient, c.T("token:Invalid application or wrong clientSecret"))
return
}
ok = true
return
}
// IntrospectToken
// @Title IntrospectToken
// @Tag Login API
@@ -349,7 +431,7 @@ func (c *ApiController) ResponseTokenError(errorMsg string) {
// parameter representing an OAuth 2.0 token and returns a JSON document
// representing the meta information surrounding the
// token, including whether this token is currently active.
// This endpoint only support Basic Authorization.
// This endpoint support Basic Authorization and authorization defined in RFC 7523.
//
// @Param token formData string true "access_token's value or refresh_token's value"
// @Param token_type_hint formData string true "the token type access_token or refresh_token"
@@ -359,24 +441,9 @@ func (c *ApiController) ResponseTokenError(errorMsg string) {
// @router /login/oauth/introspect [post]
func (c *ApiController) IntrospectToken() {
tokenValue := c.Ctx.Input.Query("token")
clientId, clientSecret, ok := c.Ctx.Request.BasicAuth()
if !ok {
clientId = c.Ctx.Input.Query("client_id")
clientSecret = c.Ctx.Input.Query("client_secret")
if clientId == "" || clientSecret == "" {
c.ResponseTokenError(object.InvalidRequest)
return
}
}
application, err := object.GetApplicationByClientId(clientId)
if err != nil {
c.ResponseTokenError(err.Error())
return
}
if application == nil || application.ClientSecret != clientSecret {
c.ResponseTokenError(c.T("token:Invalid application or wrong clientSecret"))
ok, application, clientId, _, err := c.ValidateOAuth(false)
if err != nil || !ok {
return
}
@@ -390,7 +457,7 @@ func (c *ApiController) IntrospectToken() {
if tokenTypeHint != "" {
token, err = object.GetTokenByTokenValue(tokenValue, tokenTypeHint)
if err != nil {
c.ResponseTokenError(err.Error())
c.ResponseTokenError(object.InvalidRequest, err.Error())
return
}
if token == nil || token.ExpiresIn <= 0 {
@@ -467,7 +534,7 @@ func (c *ApiController) IntrospectToken() {
if tokenTypeHint == "" {
token, err = object.GetTokenByTokenValue(tokenValue, introspectionResponse.TokenType)
if err != nil {
c.ResponseTokenError(err.Error())
c.ResponseTokenError(object.InvalidRequest, err.Error())
return
}
if token == nil || token.ExpiresIn <= 0 {
@@ -479,7 +546,7 @@ func (c *ApiController) IntrospectToken() {
if token != nil {
application, err = object.GetApplication(fmt.Sprintf("%s/%s", token.Owner, token.Application))
if err != nil {
c.ResponseTokenError(err.Error())
c.ResponseTokenError(object.InvalidClient, err.Error())
return
}
if application == nil {

View File

@@ -15,20 +15,23 @@
package controllers
type TokenRequest struct {
ClientId string `json:"client_id"`
ClientSecret string `json:"client_secret"`
GrantType string `json:"grant_type"`
Code string `json:"code"`
Verifier string `json:"code_verifier"`
Scope string `json:"scope"`
Nonce string `json:"nonce"`
Username string `json:"username"`
Password string `json:"password"`
Tag string `json:"tag"`
Avatar string `json:"avatar"`
RefreshToken string `json:"refresh_token"`
SubjectToken string `json:"subject_token"`
SubjectTokenType string `json:"subject_token_type"`
Audience string `json:"audience"`
Resource string `json:"resource"` // RFC 8707 Resource Indicator
Assertion string `json:"assertion"`
ClientId string `json:"client_id"`
ClientSecret string `json:"client_secret"`
ClientAssertion string `json:"client_assertion"`
ClientAssertionType string `json:"client_assertion_type"`
GrantType string `json:"grant_type"`
Code string `json:"code"`
Verifier string `json:"code_verifier"`
Scope string `json:"scope"`
Nonce string `json:"nonce"`
Username string `json:"username"`
Password string `json:"password"`
Tag string `json:"tag"`
Avatar string `json:"avatar"`
RefreshToken string `json:"refresh_token"`
SubjectToken string `json:"subject_token"`
SubjectTokenType string `json:"subject_token_type"`
Audience string `json:"audience"`
Resource string `json:"resource"` // RFC 8707 Resource Indicator
}

View File

@@ -264,27 +264,31 @@ func rsaSignWithRSA256(signContent string, privateKey string) (string, error) {
// privateKey in database is a string, format it to PEM style
func formatPrivateKey(privateKey string) string {
// each line length is 64
preFmtPrivateKey := ""
for i := 0; ; {
if i+64 <= len(privateKey) {
preFmtPrivateKey = preFmtPrivateKey + privateKey[i:i+64] + "\n"
i += 64
} else {
preFmtPrivateKey = preFmtPrivateKey + privateKey[i:]
break
// Check if the key is already in PEM format
if strings.HasPrefix(privateKey, "-----BEGIN PRIVATE KEY-----") ||
strings.HasPrefix(privateKey, "-----BEGIN RSA PRIVATE KEY-----") {
// Key is already in PEM format, return as is
return privateKey
}
// Remove any whitespace from the key
privateKey = strings.ReplaceAll(privateKey, "\n", "")
privateKey = strings.ReplaceAll(privateKey, "\r", "")
privateKey = strings.ReplaceAll(privateKey, " ", "")
// Format the key with line breaks every 64 characters using strings.Builder
var builder strings.Builder
for i := 0; i < len(privateKey); i += 64 {
end := i + 64
if end > len(privateKey) {
end = len(privateKey)
}
builder.WriteString(privateKey[i:end])
if end < len(privateKey) {
builder.WriteString("\n")
}
}
privateKey = strings.Trim(preFmtPrivateKey, "\n")
// add pkcs#8 BEGIN and END
PemBegin := "-----BEGIN PRIVATE KEY-----\n"
PemEnd := "\n-----END PRIVATE KEY-----"
if !strings.HasPrefix(privateKey, PemBegin) {
privateKey = PemBegin + privateKey
}
if !strings.HasSuffix(privateKey, PemEnd) {
privateKey = privateKey + PemEnd
}
return privateKey
return "-----BEGIN PRIVATE KEY-----\n" + builder.String() + "\n-----END PRIVATE KEY-----"
}

View File

@@ -67,6 +67,8 @@
{"name": "ID", "visible": true, "viewRule": "Public", "modifyRule": "Immutable"},
{"name": "Name", "visible": true, "viewRule": "Public", "modifyRule": "Admin"},
{"name": "Display name", "visible": true, "viewRule": "Public", "modifyRule": "Self"},
{"name": "First name", "visible": true, "viewRule": "Public", "modifyRule": "Self"},
{"name": "Last name", "visible": true, "viewRule": "Public", "modifyRule": "Self"},
{"name": "Avatar", "visible": true, "viewRule": "Public", "modifyRule": "Self"},
{"name": "User type", "visible": true, "viewRule": "Public", "modifyRule": "Admin"},
{"name": "Password", "visible": true, "viewRule": "Self", "modifyRule": "Self"},
@@ -81,6 +83,7 @@
{"name": "Title", "visible": true, "viewRule": "Public", "modifyRule": "Self"},
{"name": "ID card type", "visible": true, "viewRule": "Public", "modifyRule": "Self"},
{"name": "ID card", "visible": true, "viewRule": "Public", "modifyRule": "Self"},
{"name": "ID card info", "visible": true, "viewRule": "Self", "modifyRule": "Self"},
{"name": "Real name", "visible": true, "viewRule": "Public", "modifyRule": "Self"},
{"name": "ID verification", "visible": true, "viewRule": "Self", "modifyRule": "Self"},
{"name": "Homepage", "visible": true, "viewRule": "Public", "modifyRule": "Self"},
@@ -101,6 +104,7 @@
{"name": "Signup application", "visible": true, "viewRule": "Public", "modifyRule": "Admin"},
{"name": "Register type", "visible": true, "viewRule": "Public", "modifyRule": "Admin"},
{"name": "Register source", "visible": true, "viewRule": "Public", "modifyRule": "Admin"},
{"name": "API key", "visible": true, "viewRule": "Self", "modifyRule": "Self"},
{"name": "Roles", "visible": true, "viewRule": "Public", "modifyRule": "Immutable"},
{"name": "Permissions", "visible": true, "viewRule": "Public", "modifyRule": "Immutable"},
{"name": "Groups", "visible": true, "viewRule": "Public", "modifyRule": "Admin"},
@@ -110,9 +114,14 @@
{"name": "Is forbidden", "visible": true, "viewRule": "Admin", "modifyRule": "Admin"},
{"name": "Is deleted", "visible": true, "viewRule": "Admin", "modifyRule": "Admin"},
{"name": "Multi-factor authentication", "visible": true, "viewRule": "Self", "modifyRule": "Self"},
{"name": "MFA items", "visible": true, "viewRule": "Self", "modifyRule": "Self"},
{"name": "WebAuthn credentials", "visible": true, "viewRule": "Self", "modifyRule": "Self"},
{"name": "Last change password time", "visible": true, "viewRule": "Admin", "modifyRule": "Admin"},
{"name": "Managed accounts", "visible": true, "viewRule": "Self", "modifyRule": "Self"},
{"name": "MFA accounts", "visible": true, "viewRule": "Self", "modifyRule": "Self"}
{"name": "Face ID", "visible": true, "viewRule": "Self", "modifyRule": "Self"},
{"name": "MFA accounts", "visible": true, "viewRule": "Self", "modifyRule": "Self"},
{"name": "Need update password", "visible": true, "viewRule": "Admin", "modifyRule": "Admin"},
{"name": "IP whitelist", "visible": true, "viewRule": "Admin", "modifyRule": "Admin"}
]
}
],

View File

@@ -125,6 +125,7 @@ type Application struct {
ClientId string `xorm:"varchar(100)" json:"clientId"`
ClientSecret string `xorm:"varchar(100)" json:"clientSecret"`
ClientCert string `xorm:"varchar(100)" json:"clientCert"`
RedirectUris []string `xorm:"varchar(1000)" json:"redirectUris"`
ForcedRedirectOrigin string `xorm:"varchar(100)" json:"forcedRedirectOrigin"`
TokenFormat string `xorm:"varchar(100)" json:"tokenFormat"`
@@ -155,6 +156,8 @@ type Application struct {
FailedSigninFrozenTime int `json:"failedSigninFrozenTime"`
CodeResendTimeout int `json:"codeResendTimeout"`
CustomScopes []*ScopeDescription `xorm:"mediumtext" json:"customScopes"`
// Reverse proxy fields
Domain string `xorm:"varchar(100)" json:"domain"`
OtherDomains []string `xorm:"varchar(1000)" json:"otherDomains"`
@@ -745,6 +748,11 @@ func UpdateApplication(id string, application *Application, isGlobalAdmin bool,
return false, err
}
err = validateCustomScopes(application.CustomScopes, lang)
if err != nil {
return false, err
}
for _, providerItem := range application.Providers {
providerItem.Provider = nil
}
@@ -800,6 +808,11 @@ func AddApplication(application *Application) (bool, error) {
return false, err
}
err = validateCustomScopes(application.CustomScopes, "en")
if err != nil {
return false, err
}
for _, providerItem := range application.Providers {
providerItem.Provider = nil
}

View File

@@ -53,6 +53,8 @@ func getBuiltInAccountItems() []*AccountItem {
{Name: "ID", Visible: true, ViewRule: "Public", ModifyRule: "Immutable"},
{Name: "Name", Visible: true, ViewRule: "Public", ModifyRule: "Admin"},
{Name: "Display name", Visible: true, ViewRule: "Public", ModifyRule: "Self"},
{Name: "First name", Visible: true, ViewRule: "Public", ModifyRule: "Self"},
{Name: "Last name", Visible: true, ViewRule: "Public", ModifyRule: "Self"},
{Name: "Avatar", Visible: true, ViewRule: "Public", ModifyRule: "Self"},
{Name: "User type", Visible: true, ViewRule: "Public", ModifyRule: "Admin"},
{Name: "Password", Visible: true, ViewRule: "Self", ModifyRule: "Self"},
@@ -67,6 +69,7 @@ func getBuiltInAccountItems() []*AccountItem {
{Name: "Title", Visible: true, ViewRule: "Public", ModifyRule: "Self"},
{Name: "ID card type", Visible: true, ViewRule: "Public", ModifyRule: "Self"},
{Name: "ID card", Visible: true, ViewRule: "Public", ModifyRule: "Self"},
{Name: "ID card info", Visible: true, ViewRule: "Self", ModifyRule: "Self"},
{Name: "Real name", Visible: true, ViewRule: "Public", ModifyRule: "Self"},
{Name: "ID verification", Visible: true, ViewRule: "Self", ModifyRule: "Self"},
{Name: "Homepage", Visible: true, ViewRule: "Public", ModifyRule: "Self"},
@@ -87,18 +90,25 @@ func getBuiltInAccountItems() []*AccountItem {
{Name: "Signup application", Visible: true, ViewRule: "Public", ModifyRule: "Admin"},
{Name: "Register type", Visible: true, ViewRule: "Public", ModifyRule: "Admin"},
{Name: "Register source", Visible: true, ViewRule: "Public", ModifyRule: "Admin"},
{Name: "API key", Visible: true, ViewRule: "Self", ModifyRule: "Self"},
{Name: "Roles", Visible: true, ViewRule: "Public", ModifyRule: "Immutable"},
{Name: "Permissions", Visible: true, ViewRule: "Public", ModifyRule: "Immutable"},
{Name: "Groups", Visible: true, ViewRule: "Public", ModifyRule: "Admin"},
{Name: "Consents", Visible: true, ViewRule: "Self", ModifyRule: "Self"},
{Name: "3rd-party logins", Visible: true, ViewRule: "Self", ModifyRule: "Self"},
{Name: "Properties", Visible: true, ViewRule: "Admin", ModifyRule: "Admin"},
{Name: "Is admin", Visible: true, ViewRule: "Admin", ModifyRule: "Admin"},
{Name: "Is forbidden", Visible: true, ViewRule: "Admin", ModifyRule: "Admin"},
{Name: "Is deleted", Visible: true, ViewRule: "Admin", ModifyRule: "Admin"},
{Name: "Multi-factor authentication", Visible: true, ViewRule: "Self", ModifyRule: "Self"},
{Name: "MFA items", Visible: true, ViewRule: "Self", ModifyRule: "Self"},
{Name: "WebAuthn credentials", Visible: true, ViewRule: "Self", ModifyRule: "Self"},
{Name: "Last change password time", Visible: true, ViewRule: "Admin", ModifyRule: "Admin"},
{Name: "Managed accounts", Visible: true, ViewRule: "Self", ModifyRule: "Self"},
{Name: "Face ID", Visible: true, ViewRule: "Self", ModifyRule: "Self"},
{Name: "MFA accounts", Visible: true, ViewRule: "Self", ModifyRule: "Self"},
{Name: "Need update password", Visible: true, ViewRule: "Admin", ModifyRule: "Admin"},
{Name: "IP whitelist", Visible: true, ViewRule: "Admin", ModifyRule: "Admin"},
}
}

View File

@@ -62,6 +62,12 @@ func InitFlag() {
configPath = *configPathPtr
exportData = *exportDataPtr
exportFilePath = *exportFilePathPtr
// Load beego config from the specified config path
err := web.LoadAppConfig("ini", configPath)
if err != nil {
panic(fmt.Sprintf("failed to load config from %s: %v", configPath, err))
}
}
func ShouldExportData() bool {

View File

@@ -33,6 +33,8 @@ type Payment struct {
// Product Info
Products []string `xorm:"varchar(1000)" json:"products"`
ProductsDisplayName string `xorm:"varchar(1000)" json:"productsDisplayName"`
ProductName string `xorm:"varchar(1000)" json:"productName"`
ProductDisplayName string `xorm:"varchar(1000)" json:"productDisplayName"`
Detail string `xorm:"varchar(255)" json:"detail"`
Currency string `xorm:"varchar(100)" json:"currency"`
Price float64 `json:"price"`

View File

@@ -564,7 +564,7 @@ func providerChangeTrigger(oldName string, newName string) error {
return session.Commit()
}
func FromProviderToIdpInfo(ctx *context.Context, provider *Provider) *idp.ProviderInfo {
func FromProviderToIdpInfo(ctx *context.Context, provider *Provider) (*idp.ProviderInfo, error) {
providerInfo := &idp.ProviderInfo{
Type: provider.Type,
SubType: provider.SubType,
@@ -588,9 +588,19 @@ func FromProviderToIdpInfo(ctx *context.Context, provider *Provider) *idp.Provid
}
} else if provider.Type == "ADFS" || provider.Type == "AzureAD" || provider.Type == "AzureADB2C" || provider.Type == "Casdoor" || provider.Type == "Okta" {
providerInfo.HostUrl = provider.Domain
} else if provider.Type == "Alipay" && provider.Cert != "" {
// For Alipay with certificate mode, load private key from certificate
cert, err := GetCert(util.GetId(provider.Owner, provider.Cert))
if err != nil {
return nil, fmt.Errorf("failed to load certificate for Alipay provider %s: %w", provider.Name, err)
}
if cert == nil {
return nil, fmt.Errorf("certificate not found for Alipay provider %s", provider.Name)
}
providerInfo.ClientSecret = cert.PrivateKey
}
return providerInfo
return providerInfo, nil
}
func GetIdvProviderFromProvider(provider *Provider) idv.IdvProvider {

View File

@@ -406,27 +406,61 @@ func (p *DingtalkSyncerProvider) getDingtalkUsers() ([]*OriginalUser, error) {
return originalUsers, nil
}
// getDingtalkUserFieldValue extracts a field value from DingtalkUser by field name
func (p *DingtalkSyncerProvider) getDingtalkUserFieldValue(dingtalkUser *DingtalkUser, fieldName string) string {
switch fieldName {
case "userid":
return dingtalkUser.UserId
case "unionid":
return dingtalkUser.UnionId
case "name":
return dingtalkUser.Name
case "email":
return dingtalkUser.Email
case "mobile":
return dingtalkUser.Mobile
case "avatar":
return dingtalkUser.Avatar
case "title":
return dingtalkUser.Position
case "job_number":
return dingtalkUser.JobNumber
case "active":
// Invert the boolean because active=true means NOT forbidden
return util.BoolToString(!dingtalkUser.Active)
default:
return ""
}
}
// dingtalkUserToOriginalUser converts DingTalk user to Casdoor OriginalUser
func (p *DingtalkSyncerProvider) dingtalkUserToOriginalUser(dingtalkUser *DingtalkUser) *OriginalUser {
// Use unionid as name to be consistent with OAuth provider
// Fallback to userId if unionid is not available
userName := dingtalkUser.UserId
if dingtalkUser.UnionId != "" {
userName = dingtalkUser.UnionId
user := &OriginalUser{
Address: []string{},
Properties: map[string]string{},
Groups: []string{},
DingTalk: dingtalkUser.UserId, // Link DingTalk provider account
}
user := &OriginalUser{
Id: dingtalkUser.UserId,
Name: userName,
DisplayName: dingtalkUser.Name,
Email: dingtalkUser.Email,
Phone: dingtalkUser.Mobile,
Avatar: dingtalkUser.Avatar,
Title: dingtalkUser.Position,
Address: []string{},
Properties: map[string]string{},
Groups: []string{},
DingTalk: dingtalkUser.UserId, // Link DingTalk provider account
// Apply TableColumns mapping if configured
if len(p.Syncer.TableColumns) > 0 {
for _, tableColumn := range p.Syncer.TableColumns {
value := p.getDingtalkUserFieldValue(dingtalkUser, tableColumn.Name)
p.Syncer.setUserByKeyValue(user, tableColumn.CasdoorName, value)
}
} else {
// Fallback to default mapping for backward compatibility
user.Id = dingtalkUser.UserId
user.Name = dingtalkUser.UserId
if dingtalkUser.UnionId != "" {
user.Name = dingtalkUser.UnionId
}
user.DisplayName = dingtalkUser.Name
user.Email = dingtalkUser.Email
user.Phone = dingtalkUser.Mobile
user.Avatar = dingtalkUser.Avatar
user.Title = dingtalkUser.Position
user.IsForbidden = !dingtalkUser.Active
}
// Add department IDs to Groups field
@@ -434,9 +468,6 @@ func (p *DingtalkSyncerProvider) dingtalkUserToOriginalUser(dingtalkUser *Dingta
user.Groups = append(user.Groups, fmt.Sprintf("%d", deptId))
}
// Set IsForbidden based on active status (active=false means user is forbidden)
user.IsForbidden = !dingtalkUser.Active
// Set CreatedTime to current time if not set
if user.CreatedTime == "" {
user.CreatedTime = util.GetCurrentTime()

View File

@@ -660,6 +660,15 @@ func generateJwtToken(application *Application, user *User, provider string, sig
return tokenString, refreshTokenString, name, err
}
func ParseJwtTokenWithoutValidation(token string) (*jwt.Token, error) {
t, _, err := jwt.NewParser().ParseUnverified(token, &Claims{})
if err != nil {
return nil, err
}
return t, nil
}
func ParseJwtToken(token string, cert *Cert) (*Claims, error) {
t, err := jwt.ParseWithClaims(token, &Claims{}, func(token *jwt.Token) (interface{}, error) {
var (

View File

@@ -19,6 +19,7 @@ import (
"encoding/base64"
"fmt"
"net/url"
"slices"
"strings"
"sync"
"time"
@@ -154,6 +155,10 @@ func CheckOAuthLogin(clientId string, responseType string, redirectUri string, s
return fmt.Sprintf(i18n.Translate(lang, "token:Redirect URI: %s doesn't exist in the allowed Redirect URI list"), redirectUri), application, nil
}
if !IsScopeValid(scope, application) {
return i18n.Translate(lang, "token:Invalid scope"), application, nil
}
// Mask application for /api/get-app-login
application.ClientSecret = ""
return "", application, nil
@@ -240,10 +245,33 @@ func GetOAuthCode(userId string, clientId string, provider string, signinMethod
}, nil
}
func GetOAuthToken(grantType string, clientId string, clientSecret string, code string, verifier string, scope string, nonce string, username string, password string, host string, refreshToken string, tag string, avatar string, lang string, subjectToken string, subjectTokenType string, audience string, resource string) (interface{}, error) {
application, err := GetApplicationByClientId(clientId)
if err != nil {
return nil, err
func GetOAuthToken(grantType string, clientId string, clientSecret string, code string, verifier string, scope string, nonce string, username string, password string, host string, refreshToken string, tag string, avatar string, lang string, subjectToken string, subjectTokenType string, assertion string, clientAssertion string, clientAssertionType string, audience string, resource string) (interface{}, error) {
var (
application *Application
err error
ok bool
)
if clientAssertionType == "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" {
ok, application, err = ValidateClientAssertion(clientAssertion, host)
if err != nil {
return nil, err
}
if !ok || application == nil {
return &TokenError{
Error: InvalidClient,
ErrorDescription: "client_assertion is invalid",
}, nil
}
clientSecret = application.ClientSecret
clientId = application.ClientId
} else {
application, err = GetApplicationByClientId(clientId)
if err != nil {
return nil, err
}
}
if application == nil {
@@ -273,12 +301,14 @@ func GetOAuthToken(grantType string, clientId string, clientSecret string, code
token, tokenError, err = GetClientCredentialsToken(application, clientSecret, scope, host)
case "token", "id_token": // Implicit Grant
token, tokenError, err = GetImplicitToken(application, username, scope, nonce, host)
case "urn:ietf:params:oauth:grant-type:jwt-bearer":
token, tokenError, err = GetJwtBearerToken(application, assertion, scope, nonce, host)
case "urn:ietf:params:oauth:grant-type:device_code":
token, tokenError, err = GetImplicitToken(application, username, scope, nonce, host)
case "urn:ietf:params:oauth:grant-type:token-exchange": // Token Exchange Grant (RFC 8693)
token, tokenError, err = GetTokenExchangeToken(application, clientSecret, subjectToken, subjectTokenType, audience, scope, host)
case "refresh_token":
refreshToken2, err := RefreshToken(grantType, refreshToken, scope, clientId, clientSecret, host)
refreshToken2, err := RefreshToken(application, grantType, refreshToken, scope, clientId, clientSecret, host)
if err != nil {
return nil, err
}
@@ -320,7 +350,7 @@ func GetOAuthToken(grantType string, clientId string, clientSecret string, code
return tokenWrapper, nil
}
func RefreshToken(grantType string, refreshToken string, scope string, clientId string, clientSecret string, host string) (interface{}, error) {
func RefreshToken(application *Application, grantType string, refreshToken string, scope string, clientId string, clientSecret string, host string) (interface{}, error) {
// check parameters
if grantType != "refresh_token" {
return &TokenError{
@@ -328,16 +358,20 @@ func RefreshToken(grantType string, refreshToken string, scope string, clientId
ErrorDescription: "grant_type should be refresh_token",
}, nil
}
application, err := GetApplicationByClientId(clientId)
if err != nil {
return nil, err
}
var err error
if application == nil {
return &TokenError{
Error: InvalidClient,
ErrorDescription: "client_id is invalid",
}, nil
application, err = GetApplicationByClientId(clientId)
if err != nil {
return nil, err
}
if application == nil {
return &TokenError{
Error: InvalidClient,
ErrorDescription: "client_id is invalid",
}, nil
}
}
if clientSecret != "" && application.ClientSecret != clientSecret {
@@ -486,6 +520,28 @@ func IsGrantTypeValid(method string, grantTypes []string) bool {
return false
}
// IsScopeValid checks whether all space-separated scopes in the scope string
// are defined in the application's Scopes list.
// If the application has no defined scopes, every scope is considered valid
// (backward-compatible behaviour).
func IsScopeValid(scope string, application *Application) bool {
if len(application.Scopes) == 0 || scope == "" {
return true
}
allowed := make(map[string]bool, len(application.Scopes))
for _, s := range application.Scopes {
allowed[s.Name] = true
}
for _, s := range strings.Fields(scope) {
if !allowed[s] {
return false
}
}
return true
}
// createGuestUserToken creates a new guest user and returns a token for them
func createGuestUserToken(application *Application, clientSecret string, verifier string) (*Token, *TokenError, error) {
// Verify client secret if provided
@@ -526,12 +582,19 @@ func createGuestUserToken(application *Application, clientSecret string, verifie
}, nil
}
// Generate a unique user ID within the confines of the application
newUserId, idErr := GenerateIdForNewUser(application)
if idErr != nil {
// If we fail to generate a unique user ID, we can fallback to a random ID
newUserId = util.GenerateId()
}
// Create the guest user
guestUser := &User{
Owner: application.Organization,
Name: guestUsername,
CreatedTime: util.GetCurrentTime(),
Id: util.GenerateId(),
Id: newUserId,
Type: "normal-user",
Password: guestPassword,
Tag: "guest-user",
@@ -715,6 +778,13 @@ func GetAuthorizationCodeToken(application *Application, clientSecret string, co
// GetPasswordToken
// Resource Owner Password Credentials flow
func GetPasswordToken(application *Application, username string, password string, scope string, host string) (*Token, *TokenError, error) {
if !IsScopeValid(scope, application) {
return nil, &TokenError{
Error: InvalidScope,
ErrorDescription: "the requested scope is invalid or not defined in the application",
}, nil
}
user, err := GetUserByFields(application.Organization, username)
if err != nil {
return nil, nil, err
@@ -796,6 +866,12 @@ func GetClientCredentialsToken(application *Application, clientSecret string, sc
ErrorDescription: "client_secret is invalid",
}, nil
}
if !IsScopeValid(scope, application) {
return nil, &TokenError{
Error: InvalidScope,
ErrorDescription: "the requested scope is invalid or not defined in the application",
}, nil
}
nullUser := &User{
Owner: application.Owner,
Id: application.GetId(),
@@ -835,6 +911,13 @@ func GetClientCredentialsToken(application *Application, clientSecret string, sc
// GetImplicitToken
// Implicit flow
func GetImplicitToken(application *Application, username string, scope string, nonce string, host string) (*Token, *TokenError, error) {
if !IsScopeValid(scope, application) {
return nil, &TokenError{
Error: InvalidScope,
ErrorDescription: "the requested scope is invalid or not defined in the application",
}, nil
}
user, err := GetUserByFields(application.Organization, username)
if err != nil {
return nil, nil, err
@@ -859,6 +942,84 @@ func GetImplicitToken(application *Application, username string, scope string, n
return token, nil, nil
}
// GetJwtBearerToken
// RFC 7523
func GetJwtBearerToken(application *Application, assertion string, scope string, nonce string, host string) (*Token, *TokenError, error) {
ok, claims, err := ValidateJwtAssertion(assertion, application, host)
if err != nil || !ok {
if err != nil {
return nil, &TokenError{
Error: InvalidGrant,
ErrorDescription: err.Error(),
}, err
}
return nil, &TokenError{
Error: InvalidGrant,
ErrorDescription: fmt.Sprintf("assertion (JWT) is invalid for application: [%s]", application.GetId()),
}, nil
}
return GetImplicitToken(application, claims.Subject, scope, nonce, host)
}
func ValidateJwtAssertion(clientAssertion string, application *Application, host string) (bool, *Claims, error) {
_, originBackend := getOriginFromHost(host)
clientCert, err := getCert(application.Owner, application.ClientCert)
if err != nil {
return false, nil, err
}
if clientCert == nil {
return false, nil, fmt.Errorf("client certificate is not configured for application: [%s]", application.GetId())
}
claims, err := ParseJwtToken(clientAssertion, clientCert)
if err != nil {
return false, nil, err
}
if !slices.Contains(application.RedirectUris, claims.Issuer) {
return false, nil, nil
}
if !slices.Contains(claims.Audience, fmt.Sprintf("%s/api/login/oauth/access_token", originBackend)) {
return false, nil, nil
}
return true, claims, nil
}
func ValidateClientAssertion(clientAssertion string, host string) (bool, *Application, error) {
token, err := ParseJwtTokenWithoutValidation(clientAssertion)
if err != nil {
return false, nil, err
}
clientId, err := token.Claims.GetSubject()
if err != nil {
return false, nil, err
}
application, err := GetApplicationByClientId(clientId)
if err != nil {
return false, nil, err
}
if application == nil {
return false, nil, fmt.Errorf("application not found for client: [%s]", clientId)
}
ok, _, err := ValidateJwtAssertion(clientAssertion, application, host)
if err != nil {
return false, application, err
}
if !ok {
return false, application, nil
}
return true, application, nil
}
// GetTokenByUser
// Implicit flow
func GetTokenByUser(application *Application, user *User, scope string, nonce string, host string) (*Token, error) {
@@ -946,9 +1107,16 @@ func GetWechatMiniProgramToken(application *Application, code string, host strin
name = fmt.Sprintf("wechat-%s", openId)
}
// Generate a unique user ID within the confines of the application
newUserId, idErr := GenerateIdForNewUser(application)
if idErr != nil {
// If we fail to generate a unique user ID, we can fallback to a random ID
newUserId = util.GenerateId()
}
user = &User{
Owner: application.Organization,
Id: util.GenerateId(),
Id: newUserId,
Name: name,
Avatar: avatar,
SignupApplication: application.Name,

View File

@@ -180,6 +180,7 @@ type User struct {
Spotify string `xorm:"spotify varchar(100)" json:"spotify"`
Strava string `xorm:"strava varchar(100)" json:"strava"`
Stripe string `xorm:"stripe varchar(100)" json:"stripe"`
Telegram string `xorm:"telegram varchar(100)" json:"telegram"`
TikTok string `xorm:"tiktok varchar(100)" json:"tiktok"`
Tumblr string `xorm:"tumblr varchar(100)" json:"tumblr"`
Twitch string `xorm:"twitch varchar(100)" json:"twitch"`
@@ -241,6 +242,7 @@ type User struct {
MfaRememberDeadline string `xorm:"varchar(100)" json:"mfaRememberDeadline"`
NeedUpdatePassword bool `json:"needUpdatePassword"`
IpWhitelist string `xorm:"varchar(200)" json:"ipWhitelist"`
ApplicationScopes []ConsentRecord `xorm:"mediumtext" json:"applicationScopes"`
}
type Userinfo struct {
@@ -860,17 +862,17 @@ func UpdateUser(id string, user *User, columns []string, isAdmin bool) (bool, er
if len(columns) == 0 {
columns = []string{
"owner", "display_name", "avatar", "first_name", "last_name",
"location", "address", "country_code", "region", "language", "affiliation", "title", "id_card_type", "id_card", "homepage", "bio", "tag", "language", "gender", "birthday", "education", "score", "karma", "ranking", "signup_application",
"is_admin", "is_forbidden", "is_deleted", "hash", "is_default_avatar", "properties", "webauthnCredentials", "managedAccounts", "face_ids", "mfaAccounts",
"signin_wrong_times", "last_change_password_time", "last_signin_wrong_time", "groups", "access_key", "access_secret", "mfa_phone_enabled", "mfa_email_enabled", "email_verified",
"location", "address", "addresses", "country_code", "region", "language", "affiliation", "title", "id_card_type", "id_card", "homepage", "bio", "tag", "language", "gender", "birthday", "education", "score", "karma", "ranking", "signup_application", "register_type", "register_source",
"is_admin", "is_forbidden", "is_deleted", "hash", "is_default_avatar", "properties", "webauthnCredentials", "mfa_items", "last_change_password_time", "managedAccounts", "face_ids", "mfaAccounts",
"signin_wrong_times", "last_signin_wrong_time", "groups", "access_key", "access_secret", "mfa_phone_enabled", "mfa_email_enabled", "email_verified",
"github", "google", "qq", "wechat", "facebook", "dingtalk", "weibo", "gitee", "linkedin", "wecom", "lark", "gitlab", "adfs",
"baidu", "alipay", "casdoor", "infoflow", "apple", "azuread", "azureadb2c", "slack", "steam", "bilibili", "okta", "douyin", "kwai", "line", "amazon",
"auth0", "battlenet", "bitbucket", "box", "cloudfoundry", "dailymotion", "deezer", "digitalocean", "discord", "dropbox",
"eveonline", "fitbit", "gitea", "heroku", "influxcloud", "instagram", "intercom", "kakao", "lastfm", "mailru", "meetup",
"microsoftonline", "naver", "nextcloud", "onedrive", "oura", "patreon", "paypal", "salesforce", "shopify", "soundcloud",
"spotify", "strava", "stripe", "type", "tiktok", "tumblr", "twitch", "twitter", "typetalk", "uber", "vk", "wepay", "xero", "yahoo",
"yammer", "yandex", "zoom", "custom", "need_update_password", "ip_whitelist", "mfa_items", "mfa_remember_deadline",
"cart",
"spotify", "strava", "stripe", "type", "telegram", "tiktok", "tumblr", "twitch", "twitter", "typetalk", "uber", "vk", "wepay", "xero", "yahoo",
"yammer", "yandex", "zoom", "custom", "need_update_password", "ip_whitelist", "mfa_remember_deadline",
"cart", "application_scopes",
}
}
if isAdmin {
@@ -954,6 +956,13 @@ func UpdateUserForAllFields(id string, user *User) (bool, error) {
user.UpdatedTime = util.GetCurrentTime()
if len(user.Groups) > 0 {
_, err = userEnforcer.UpdateGroupsForUser(user.GetId(), user.Groups)
if err != nil {
return false, err
}
}
affected, err := ormer.Engine.ID(core.PK{owner, name}).AllCols().Update(user)
if err != nil {
return false, err

119
object/user_scope.go Normal file
View File

@@ -0,0 +1,119 @@
// Copyright 2026 The Casdoor 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/casdoor/casdoor/i18n"
)
// ConsentRecord represents the data for OAuth consent API requests/responses
type ConsentRecord struct {
// owner/name
Application string `json:"application"`
GrantedScopes []string `json:"grantedScopes"`
}
// ScopeDescription represents a human-readable description of an OAuth scope
type ScopeDescription struct {
Scope string `json:"scope"`
DisplayName string `json:"displayName"`
Description string `json:"description"`
}
// parseScopes converts a space-separated scope string to a slice
func parseScopes(scopeStr string) []string {
if scopeStr == "" {
return []string{}
}
scopes := strings.Split(scopeStr, " ")
var result []string
for _, scope := range scopes {
trimmed := strings.TrimSpace(scope)
if trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
// CheckConsentRequired checks if user consent is required for the OAuth flow
func CheckConsentRequired(userObj *User, application *Application, scopeStr string) (bool, error) {
// Skip consent when no custom scopes are configured
if len(application.CustomScopes) == 0 {
return false, nil
}
// Once policy: check if consent already granted
requestedScopes := parseScopes(scopeStr)
appId := application.GetId()
// Filter requestedScopes to only include scopes defined in application.CustomScopes
customScopesMap := make(map[string]bool)
for _, customScope := range application.CustomScopes {
if customScope.Scope != "" {
customScopesMap[customScope.Scope] = true
}
}
validRequestedScopes := []string{}
for _, scope := range requestedScopes {
if customScopesMap[scope] {
validRequestedScopes = append(validRequestedScopes, scope)
}
}
// If no valid requested scopes, no consent required
if len(validRequestedScopes) == 0 {
return false, nil
}
for _, record := range userObj.ApplicationScopes {
if record.Application == appId {
// Check if grantedScopes contains all validRequestedScopes
grantedMap := make(map[string]bool)
for _, scope := range record.GrantedScopes {
grantedMap[scope] = true
}
allGranted := true
for _, scope := range validRequestedScopes {
if !grantedMap[scope] {
allGranted = false
break
}
}
if allGranted {
// Consent already granted for all valid requested scopes
return false, nil
}
}
}
// Consent required
return true, nil
}
func validateCustomScopes(customScopes []*ScopeDescription, lang string) error {
for _, scope := range customScopes {
if scope == nil || strings.TrimSpace(scope.Scope) == "" {
return fmt.Errorf("%s: custom scope name", i18n.Translate(lang, "general:Missing parameter"))
}
}
return nil
}

View File

@@ -319,6 +319,9 @@ func InitAPI() {
web.Router("/api/delete-mfa", &controllers.ApiController{}, "POST:DeleteMfa")
web.Router("/api/set-preferred-mfa", &controllers.ApiController{}, "POST:SetPreferredMfa")
web.Router("/api/grant-consent", &controllers.ApiController{}, "POST:GrantConsent")
web.Router("/api/revoke-consent", &controllers.ApiController{}, "POST:RevokeConsent")
web.Router("/.well-known/openid-configuration", &controllers.RootController{}, "GET:GetOidcDiscovery")
web.Router("/.well-known/:application/openid-configuration", &controllers.RootController{}, "GET:GetOidcDiscoveryByApplication")
web.Router("/.well-known/oauth-authorization-server", &controllers.RootController{}, "GET:GetOAuthServerMetadata")

View File

@@ -89,6 +89,23 @@ func fastAutoSignin(ctx *context.Context) (string, error) {
return "", nil
}
user, err := object.GetUser(userId)
if err != nil {
return "", err
}
if user == nil {
return "", nil
}
consentRequired, err := object.CheckConsentRequired(user, application, scope)
if err != nil {
return "", err
}
if consentRequired {
return "", nil
}
code, err := object.GetOAuthCode(userId, clientId, "", "autoSignin", responseType, redirectUri, scope, state, nonce, codeChallenge, "", ctx.Request.Host, getAcceptLanguage(ctx))
if err != nil {
return "", err

View File

@@ -487,7 +487,7 @@ class App extends Component {
: (
Conf.CustomFooter !== null ? Conf.CustomFooter : (
<React.Fragment>
Powered by <a target="_blank" href="https://casdoor.org" rel="noreferrer"><img style={{paddingBottom: "3px"}} height={"20px"} alt={"Casdoor"} src={logo} /></a>
Powered by <a target="_blank" href="https://casdoor.org" rel="noreferrer"><img style={{paddingBottom: "3px"}} height={"20px"} alt={"Casdoor"} src={logo} /></a>
</React.Fragment>
)
)
@@ -539,15 +539,16 @@ class App extends Component {
isEntryPages() {
return window.location.pathname.startsWith("/signup") ||
window.location.pathname.startsWith("/login") ||
window.location.pathname.startsWith("/forget") ||
window.location.pathname.startsWith("/prompt") ||
window.location.pathname.startsWith("/result") ||
window.location.pathname.startsWith("/cas") ||
window.location.pathname.startsWith("/select-plan") ||
window.location.pathname.startsWith("/buy-plan") ||
window.location.pathname.startsWith("/qrcode") ||
window.location.pathname.startsWith("/captcha");
window.location.pathname.startsWith("/login") ||
window.location.pathname.startsWith("/forget") ||
window.location.pathname.startsWith("/prompt") ||
window.location.pathname.startsWith("/result") ||
window.location.pathname.startsWith("/cas") ||
window.location.pathname.startsWith("/select-plan") ||
window.location.pathname.startsWith("/buy-plan") ||
window.location.pathname.startsWith("/qrcode") ||
window.location.pathname.startsWith("/consent") ||
window.location.pathname.startsWith("/captcha");
}
onClick = ({key}) => {
@@ -656,7 +657,7 @@ class App extends Component {
menuVisible={this.state.menuVisible}
logo={this.state.logo}
onChangeTheme={this.setTheme}
onClick = {this.onClick}
onClick={this.onClick}
onfinish={() => {
this.setState({requiredEnableMfa: false});
}}

View File

@@ -248,6 +248,33 @@ class ApplicationEditPage extends React.Component {
return value;
}
trimCustomScopes(customScopes) {
if (!Array.isArray(customScopes)) {
return [];
}
return customScopes.map((item) => {
const scope = (item?.scope || "").trim();
const displayName = (item?.displayName || "").trim();
const description = (item?.description || "").trim();
return {
...item,
scope: scope,
displayName: displayName,
description: description,
};
});
}
validateCustomScopes(customScopes) {
const trimmed = this.trimCustomScopes(customScopes);
for (const item of trimmed) {
if (!item || !item.scope || item.scope === "") {
return {ok: false, scopes: trimmed};
}
}
return {ok: true, scopes: trimmed};
}
updateApplicationField(key, value) {
value = this.parseApplicationField(key, value);
const application = this.state.application;
@@ -506,157 +533,6 @@ class ApplicationEditPage extends React.Component {
)}
{this.state.activeMenuKey === "authentication" && (
<React.Fragment>
<Row style={{marginTop: "10px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("provider:Client ID"), i18next.t("provider:Client ID - Tooltip"))} :
</Col>
<Col span={21} >
<Input value={this.state.application.clientId} onChange={e => {
this.updateApplicationField("clientId", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "10px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("provider:Client secret"), i18next.t("provider:Client secret - Tooltip"))} :
</Col>
<Col span={21} >
<Input value={this.state.application.clientSecret} onChange={e => {
this.updateApplicationField("clientSecret", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("application:Redirect URLs"), i18next.t("application:Redirect URLs - Tooltip"))} :
</Col>
<Col span={21} >
<UrlTable
title={i18next.t("application:Redirect URLs")}
table={this.state.application.redirectUris}
onUpdateTable={(value) => {this.updateApplicationField("redirectUris", value);}}
/>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("application:Forced redirect origin"), i18next.t("general:Forced redirect origin - Tooltip"))} :
</Col>
<Col span={21} >
<Input prefix={<LinkOutlined />} value={this.state.application.forcedRedirectOrigin} onChange={e => {
this.updateApplicationField("forcedRedirectOrigin", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("application:Grant types"), i18next.t("application:Grant types - Tooltip"))} :
</Col>
<Col span={21} >
<Select virtual={false} mode="multiple" style={{width: "100%"}}
value={this.state.application.grantTypes}
onChange={(value => {
this.updateApplicationField("grantTypes", value);
})} >
{
[
{id: "authorization_code", name: "Authorization Code"},
{id: "password", name: "Password"},
{id: "client_credentials", name: "Client Credentials"},
{id: "token", name: "Token"},
{id: "id_token", name: "ID Token"},
{id: "refresh_token", name: "Refresh Token"},
{id: "urn:ietf:params:oauth:grant-type:device_code", name: "Device Code"},
].map((item, index) => <Option key={index} value={item.id}>{item.name}</Option>)
}
</Select>
</Col>
</Row>
{
(this.state.application.category === "Agent") ? (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("general:Scopes"), i18next.t("general:Scopes - Tooltip"))} :
</Col>
<Col span={21} >
<ScopeTable
title={i18next.t("general:Scopes")}
table={this.state.application.scopes}
onUpdateTable={(value) => {this.updateApplicationField("scopes", value);}}
/>
</Col>
</Row>
) : null
}
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("application:Token format"), i18next.t("application:Token format - Tooltip"))} :
</Col>
<Col span={21} >
<Select virtual={false} style={{width: "100%"}} value={this.state.application.tokenFormat} onChange={(value => {this.updateApplicationField("tokenFormat", value);})}
options={["JWT", "JWT-Empty", "JWT-Custom", "JWT-Standard"].map((item) => Setting.getOption(item, item))}
/>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("application:Token signing method"), i18next.t("application:Token signing method - Tooltip"))} :
</Col>
<Col span={21} >
<Select virtual={false} style={{width: "100%"}} value={this.state.application.tokenSigningMethod === "" ? "RS256" : this.state.application.tokenSigningMethod} onChange={(value => {this.updateApplicationField("tokenSigningMethod", value);})}
options={["RS256", "RS512", "ES256", "ES512", "ES384"].map((item) => Setting.getOption(item, item))}
/>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("application:Token fields"), i18next.t("application:Token fields - Tooltip"))} :
</Col>
<Col span={21} >
<Select virtual={false} disabled={this.state.application.tokenFormat !== "JWT-Custom"} mode="tags" showSearch style={{width: "100%"}} value={this.state.application.tokenFields} onChange={(value => {this.updateApplicationField("tokenFields", value);})}>
<Option key={"signinMethod"} value={"signinMethod"}>{"SigninMethod"}</Option>
<Option key={"provider"} value={"provider"}>{"Provider"}</Option>
{
[...Setting.getUserCommonFields(), "permissionNames"].map((item, index) => <Option key={index} value={item}>{item}</Option>)
}
</Select>
</Col>
</Row>
{
this.state.application.tokenFormat === "JWT-Custom" ? (<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Token attributes"), i18next.t("general:Token attributes - Tooltip"))} :
</Col>
<Col span={22} >
<TokenAttributeTable
title={i18next.t("general:Token attributes")}
table={this.state.application.tokenAttributes}
application={this.state.application}
onUpdateTable={(value) => {this.updateApplicationField("tokenAttributes", value);}}
/>
</Col>
</Row>) : null
}
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("application:Token expire"), i18next.t("application:Token expire - Tooltip"))} :
</Col>
<Col span={21} >
<InputNumber style={{width: "150px"}} value={this.state.application.expireInHours} min={0.01} step={1} precision={2} addonAfter="Hours" onChange={value => {
this.updateApplicationField("expireInHours", value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("application:Refresh token expire"), i18next.t("application:Refresh token expire - Tooltip"))} :
</Col>
<Col span={21} >
<InputNumber style={{width: "150px"}} value={this.state.application.refreshExpireInHours} min={0.01} step={1} precision={2} addonAfter="Hours" onChange={value => {
this.updateApplicationField("refreshExpireInHours", value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("application:Cookie expire"), i18next.t("application:Cookie expire - Tooltip"))} :
@@ -807,7 +683,167 @@ class ApplicationEditPage extends React.Component {
}} />
</Col>
</Row>
</React.Fragment>
)}
{this.state.activeMenuKey === "oidc-oauth" && (
<React.Fragment>
<Row style={{marginTop: "10px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("provider:Client ID"), i18next.t("provider:Client ID - Tooltip"))} :
</Col>
<Col span={21} >
<Input value={this.state.application.clientId} onChange={e => {
this.updateApplicationField("clientId", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "10px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("provider:Client secret"), i18next.t("provider:Client secret - Tooltip"))} :
</Col>
<Col span={21} >
<Input value={this.state.application.clientSecret} onChange={e => {
this.updateApplicationField("clientSecret", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("application:Redirect URLs"), i18next.t("application:Redirect URLs - Tooltip"))} :
</Col>
<Col span={21} >
<UrlTable
title={i18next.t("application:Redirect URLs")}
table={this.state.application.redirectUris}
onUpdateTable={(value) => {this.updateApplicationField("redirectUris", value);}}
/>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("application:Forced redirect origin"), i18next.t("general:Forced redirect origin - Tooltip"))} :
</Col>
<Col span={21} >
<Input prefix={<LinkOutlined />} value={this.state.application.forcedRedirectOrigin} onChange={e => {
this.updateApplicationField("forcedRedirectOrigin", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("application:Grant types"), i18next.t("application:Grant types - Tooltip"))} :
</Col>
<Col span={21} >
<Select virtual={false} mode="multiple" style={{width: "100%"}}
value={this.state.application.grantTypes}
onChange={(value => {
this.updateApplicationField("grantTypes", value);
})} >
{
[
{id: "authorization_code", name: "Authorization Code"},
{id: "password", name: "Password"},
{id: "client_credentials", name: "Client Credentials"},
{id: "token", name: "Token"},
{id: "id_token", name: "ID Token"},
{id: "refresh_token", name: "Refresh Token"},
{id: "urn:ietf:params:oauth:grant-type:device_code", name: "Device Code"},
{id: "urn:ietf:params:oauth:grant-type:jwt-bearer", name: "JWT Bearer"},
].map((item, index) => <Option key={index} value={item.id}>{item.name}</Option>)
}
</Select>
</Col>
</Row>
{
(this.state.application.category === "Agent") ? (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("general:Scopes"), i18next.t("general:Scopes - Tooltip"))} :
</Col>
<Col span={21} >
<ScopeTable
title={i18next.t("general:Scopes")}
table={this.state.application.scopes}
onUpdateTable={(value) => {this.updateApplicationField("scopes", value);}}
/>
</Col>
</Row>
) : null
}
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("application:Token format"), i18next.t("application:Token format - Tooltip"))} :
</Col>
<Col span={21} >
<Select virtual={false} style={{width: "100%"}} value={this.state.application.tokenFormat} onChange={(value => {this.updateApplicationField("tokenFormat", value);})}
options={["JWT", "JWT-Empty", "JWT-Custom", "JWT-Standard"].map((item) => Setting.getOption(item, item))}
/>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("application:Token signing method"), i18next.t("application:Token signing method - Tooltip"))} :
</Col>
<Col span={21} >
<Select virtual={false} style={{width: "100%"}} value={this.state.application.tokenSigningMethod === "" ? "RS256" : this.state.application.tokenSigningMethod} onChange={(value => {this.updateApplicationField("tokenSigningMethod", value);})}
options={["RS256", "RS512", "ES256", "ES512", "ES384"].map((item) => Setting.getOption(item, item))}
/>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("application:Token fields"), i18next.t("application:Token fields - Tooltip"))} :
</Col>
<Col span={21} >
<Select virtual={false} disabled={this.state.application.tokenFormat !== "JWT-Custom"} mode="tags" showSearch style={{width: "100%"}} value={this.state.application.tokenFields} onChange={(value => {this.updateApplicationField("tokenFields", value);})}>
<Option key={"signinMethod"} value={"signinMethod"}>{"SigninMethod"}</Option>
<Option key={"provider"} value={"provider"}>{"Provider"}</Option>
{
[...Setting.getUserCommonFields(), "permissionNames"].map((item, index) => <Option key={index} value={item}>{item}</Option>)
}
</Select>
</Col>
</Row>
{
this.state.application.tokenFormat === "JWT-Custom" ? (<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Token attributes"), i18next.t("general:Token attributes - Tooltip"))} :
</Col>
<Col span={22} >
<TokenAttributeTable
title={i18next.t("general:Token attributes")}
table={this.state.application.tokenAttributes}
application={this.state.application}
onUpdateTable={(value) => {this.updateApplicationField("tokenAttributes", value);}}
/>
</Col>
</Row>) : null
}
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("application:Token expire"), i18next.t("application:Token expire - Tooltip"))} :
</Col>
<Col span={21} >
<InputNumber style={{width: "150px"}} value={this.state.application.expireInHours} min={0.01} step={1} precision={2} addonAfter="Hours" onChange={value => {
this.updateApplicationField("expireInHours", value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("application:Refresh token expire"), i18next.t("application:Refresh token expire - Tooltip"))} :
</Col>
<Col span={21} >
<InputNumber style={{width: "150px"}} value={this.state.application.refreshExpireInHours} min={0.01} step={1} precision={2} addonAfter="Hours" onChange={value => {
this.updateApplicationField("refreshExpireInHours", value);
}} />
</Col>
</Row>
</React.Fragment>
)}
{this.state.activeMenuKey === "saml" && (
<React.Fragment>
<Row style={{marginTop: "10px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("application:SAML reply URL"), i18next.t("application:Redirect URL (Assertion Consumer Service POST Binding URL) - Tooltip"))} :
</Col>
@@ -1308,7 +1344,7 @@ class ApplicationEditPage extends React.Component {
<React.Fragment>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("general:Cert"), i18next.t("general:Cert - Tooltip"))} :
{Setting.getLabel(i18next.t("application:Token cert"), i18next.t("application:Token cert - Tooltip"))} :
</Col>
<Col span={21} >
<Select virtual={false} style={{width: "100%"}} value={this.state.application.cert} onChange={(value => {this.updateApplicationField("cert", value);})}>
@@ -1318,6 +1354,18 @@ class ApplicationEditPage extends React.Component {
</Select>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("application:Client cert"), i18next.t("application:Client cert - Tooltip"))} :
</Col>
<Col span={21} >
<Select virtual={false} style={{width: "100%"}} value={this.state.application.clientCert} onChange={(value => {this.updateApplicationField("clientCert", value);})}>
{
this.state.certs.map((cert, index) => <Option key={index} value={cert.name}>{cert.name}</Option>)
}
</Select>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 3}>
{Setting.getLabel(i18next.t("application:Failed signin limit"), i18next.t("application:Failed signin limit - Tooltip"))} :
@@ -1353,7 +1401,7 @@ class ApplicationEditPage extends React.Component {
{Setting.getLabel(i18next.t("general:IP whitelist"), i18next.t("general:IP whitelist - Tooltip"))} :
</Col>
<Col span={21} >
<Input placeholder = {this.state.application.organizationObj?.ipWhitelist} value={this.state.application.ipWhitelist} onChange={e => {
<Input placeholder={this.state.application.organizationObj?.ipWhitelist} value={this.state.application.ipWhitelist} onChange={e => {
this.updateApplicationField("ipWhitelist", e.target.value);
}} />
</Col>
@@ -1452,7 +1500,7 @@ class ApplicationEditPage extends React.Component {
<Layout style={{background: "inherit", height: "100%", overflow: "auto"}}>
{
this.state.menuMode === "horizontal" || !this.state.menuMode ? (
<Header style={{background: "inherit", padding: "0px", position: "sticky", top: 0}}>
<Header style={{background: "inherit", padding: "0px", position: "sticky", top: 0, height: 38, minHeight: 38}}>
<div className="demo-logo" />
<Tabs
onChange={(key) => {
@@ -1461,9 +1509,12 @@ class ApplicationEditPage extends React.Component {
}}
type="card"
activeKey={this.state.activeMenuKey}
tabBarStyle={{marginBottom: 0}}
items={[
{label: i18next.t("application:Basic"), key: "basic"},
{label: i18next.t("application:Authentication"), key: "authentication"},
{label: "OIDC/OAuth", key: "oidc-oauth"},
{label: "SAML", key: "saml"},
{label: i18next.t("application:Providers"), key: "providers"},
{label: i18next.t("application:UI Customization"), key: "ui-customization"},
{label: i18next.t("application:Security"), key: "security"},
@@ -1488,6 +1539,8 @@ class ApplicationEditPage extends React.Component {
>
<Menu.Item key="basic">{i18next.t("application:Basic")}</Menu.Item>
<Menu.Item key="authentication">{i18next.t("application:Authentication")}</Menu.Item>
<Menu.Item key="oidc-oauth">OIDC/OAuth</Menu.Item>
<Menu.Item key="saml">SAML</Menu.Item>
<Menu.Item key="providers">{i18next.t("application:Providers")}</Menu.Item>
<Menu.Item key="ui-customization">{i18next.t("application:UI Customization")}</Menu.Item>
<Menu.Item key="security">{i18next.t("application:Security")}</Menu.Item>
@@ -1547,11 +1600,11 @@ class ApplicationEditPage extends React.Component {
{
Setting.isPasswordEnabled(this.state.application) ? (
<div className="loginBackground" style={{backgroundImage: `url(${this.state.application?.formBackgroundUrl})`, overflow: "auto"}}>
<SignupPage application={this.state.application} preview = "auto" />
<SignupPage application={this.state.application} preview="auto" />
</div>
) : (
<div className="loginBackground" style={{backgroundImage: `url(${this.state.application?.formBackgroundUrl})`, overflow: "auto"}}>
<LoginPage type={"login"} mode={"signup"} application={this.state.application} preview = "auto" />
<LoginPage type={"login"} mode={"signup"} application={this.state.application} preview="auto" />
</div>
)
}
@@ -1577,7 +1630,7 @@ class ApplicationEditPage extends React.Component {
}}>
<div style={{position: "relative", width: previewWidth, border: "1px solid rgb(217,217,217)", boxShadow: "10px 10px 5px #888888", overflow: "auto"}}>
<div className="loginBackground" style={{backgroundImage: `url(${this.state.application?.formBackgroundUrl})`, overflow: "auto"}}>
<LoginPage type={"login"} mode={"signin"} application={this.state.application} preview = "auto" />
<LoginPage type={"login"} mode={"signin"} application={this.state.application} preview="auto" />
</div>
<div style={{overflow: "auto", ...maskStyle}} />
</div>
@@ -1621,6 +1674,12 @@ class ApplicationEditPage extends React.Component {
const application = Setting.deepCopy(this.state.application);
application.providers = application.providers?.filter(provider => this.state.providers.map(provider => provider.name).includes(provider.name));
application.signinMethods = application.signinMethods?.filter(signinMethod => ["Password", "Verification code", "WebAuthn", "LDAP", "Face ID", "WeChat"].includes(signinMethod.name));
const customScopeValidation = this.validateCustomScopes(application.customScopes);
application.customScopes = customScopeValidation.scopes;
if (!customScopeValidation.ok) {
Setting.showMessage("error", `${i18next.t("general:Name")}: ${i18next.t("provider:This field is required")}`);
return;
}
ApplicationBackend.updateApplication("admin", this.state.applicationName, application)
.then((res) => {

View File

@@ -26,6 +26,7 @@ import LoginPage from "./auth/LoginPage";
import SelfForgetPage from "./auth/SelfForgetPage";
import ForgetPage from "./auth/ForgetPage";
import PromptPage from "./auth/PromptPage";
import ConsentPage from "./auth/ConsentPage";
import ResultPage from "./auth/ResultPage";
import CasLogout from "./auth/CasLogout";
import {authConfig} from "./auth/Auth";
@@ -125,6 +126,7 @@ class EntryPage extends React.Component {
<Route exact path="/forget/:applicationName" render={(props) => <ForgetPage {...this.props} account={this.props.account} application={this.state.application} onUpdateApplication={onUpdateApplication} {...props} />} />
<Route exact path="/prompt" render={(props) => this.renderLoginIfNotLoggedIn(<PromptPage {...this.props} application={this.state.application} onUpdateApplication={onUpdateApplication} {...props} />)} />
<Route exact path="/prompt/:applicationName" render={(props) => this.renderLoginIfNotLoggedIn(<PromptPage {...this.props} application={this.state.application} onUpdateApplication={onUpdateApplication} {...props} />)} />
<Route exact path="/consent/:applicationName" render={(props) => this.renderLoginIfNotLoggedIn(<ConsentPage {...this.props} application={this.state.application} onUpdateApplication={onUpdateApplication} {...props} />)} />
<Route exact path="/result" render={(props) => this.renderHomeIfLoggedIn(<ResultPage {...this.props} application={this.state.application} onUpdateApplication={onUpdateApplication} {...props} />)} />
<Route exact path="/result/:applicationName" render={(props) => this.renderHomeIfLoggedIn(<ResultPage {...this.props} application={this.state.application} onUpdateApplication={onUpdateApplication} {...props} />)} />
<Route exact path="/cas/:owner/:casApplicationName/logout" render={(props) => this.renderHomeIfLoggedIn(<CasLogout {...this.props} application={this.state.application} onUpdateApplication={onUpdateApplication} {...props} />)} />

View File

@@ -193,12 +193,12 @@ function ManagementPage(props) {
{
renderAvatar()
}
&nbsp;
&nbsp;
&nbsp;
&nbsp;
{Setting.isMobile() ? null : Setting.getShortText(Setting.getNameAtLeast(props.account.displayName), 30)} &nbsp; <DownOutlined />
&nbsp;
&nbsp;
&nbsp;
&nbsp;
&nbsp;
&nbsp;
</div>
</Dropdown>
);
@@ -252,15 +252,15 @@ function ManagementPage(props) {
{renderRightDropdown()}
{renderWidgets()}
{Setting.isAdminUser(props.account) && (props.uri.indexOf("/trees") === -1) &&
<OrganizationSelect
initValue={Setting.getOrganization()}
withAll={true}
className="org-select"
style={{display: Setting.isMobile() ? "none" : "flex"}}
onChange={(value) => {
Setting.setOrganization(value);
}}
/>
<OrganizationSelect
initValue={Setting.getOrganization()}
withAll={true}
className="org-select"
style={{display: Setting.isMobile() ? "none" : "flex"}}
onChange={(value) => {
Setting.setOrganization(value);
}}
/>
}
</React.Fragment>
);
@@ -287,9 +287,9 @@ function ManagementPage(props) {
!Setting.isMobile() ? res.push({
label:
<Link to="/">
<img className="logo" src={logo ?? props.logo} alt="logo" />
</Link>,
<Link to="/">
<img className="logo" src={logo ?? props.logo} alt="logo" />
</Link>,
disabled: true, key: "logo",
style: {
padding: 0,

View File

@@ -57,6 +57,8 @@ class OrganizationListPage extends BaseListPage {
{name: "ID", visible: true, viewRule: "Public", modifyRule: "Immutable"},
{name: "Name", visible: true, viewRule: "Public", modifyRule: "Admin"},
{name: "Display name", visible: true, viewRule: "Public", modifyRule: "Self"},
{name: "First name", visible: true, viewRule: "Public", modifyRule: "Self"},
{name: "Last name", visible: true, viewRule: "Public", modifyRule: "Self"},
{name: "Avatar", visible: true, viewRule: "Public", modifyRule: "Self"},
{name: "User type", visible: true, viewRule: "Public", modifyRule: "Admin"},
{name: "Password", visible: true, viewRule: "Self", modifyRule: "Self"},
@@ -66,6 +68,7 @@ class OrganizationListPage extends BaseListPage {
{name: "Country/Region", visible: true, viewRule: "Public", modifyRule: "Self"},
{name: "Location", visible: true, viewRule: "Public", modifyRule: "Self"},
{name: "Address", visible: true, viewRule: "Public", modifyRule: "Self"},
{name: "Addresses", visible: true, viewRule: "Public", modifyRule: "Self"},
{name: "Affiliation", visible: true, viewRule: "Public", modifyRule: "Self"},
{name: "Title", visible: true, viewRule: "Public", modifyRule: "Self"},
{name: "ID card type", visible: true, viewRule: "Public", modifyRule: "Self"},
@@ -86,6 +89,8 @@ class OrganizationListPage extends BaseListPage {
{name: "Balance", visible: true, viewRule: "Public", modifyRule: "Admin"},
{name: "Balance credit", visible: true, viewRule: "Public", modifyRule: "Admin"},
{name: "Balance currency", visible: true, viewRule: "Public", modifyRule: "Admin"},
{name: "Cart", visible: true, viewRule: "Self", modifyRule: "Self"},
{name: "Transactions", visible: true, viewRule: "Self", modifyRule: "Self"},
{name: "Signup application", visible: true, viewRule: "Public", modifyRule: "Admin"},
{name: "Register type", visible: true, viewRule: "Public", modifyRule: "Admin"},
{name: "Register source", visible: true, viewRule: "Public", modifyRule: "Admin"},
@@ -93,16 +98,22 @@ class OrganizationListPage extends BaseListPage {
{name: "Groups", visible: true, viewRule: "Public", modifyRule: "Admin"},
{name: "Roles", visible: true, viewRule: "Public", modifyRule: "Immutable"},
{name: "Permissions", visible: true, viewRule: "Public", modifyRule: "Immutable"},
{name: "Consents", visible: true, viewRule: "Self", modifyRule: "Self"},
{name: "3rd-party logins", visible: true, viewRule: "Self", modifyRule: "Self"},
{name: "Properties", visible: false, viewRule: "Admin", modifyRule: "Admin"},
{name: "Is online", visible: true, viewRule: "Admin", modifyRule: "Admin"},
{name: "Is admin", visible: true, viewRule: "Admin", modifyRule: "Admin"},
{name: "Is forbidden", visible: true, viewRule: "Admin", modifyRule: "Admin"},
{name: "Is deleted", visible: true, viewRule: "Admin", modifyRule: "Admin"},
{Name: "Multi-factor authentication", Visible: true, ViewRule: "Self", ModifyRule: "Self"},
{Name: "WebAuthn credentials", Visible: true, ViewRule: "Self", ModifyRule: "Self"},
{Name: "Managed accounts", Visible: true, ViewRule: "Self", ModifyRule: "Self"},
{Name: "MFA accounts", Visible: true, ViewRule: "Self", ModifyRule: "Self"},
{name: "Multi-factor authentication", visible: true, viewRule: "Self", modifyRule: "Self"},
{name: "MFA items", visible: true, viewRule: "Self", modifyRule: "Self"},
{name: "WebAuthn credentials", visible: true, viewRule: "Self", modifyRule: "Self"},
{name: "Last change password time", visible: true, viewRule: "Admin", modifyRule: "Admin"},
{name: "Managed accounts", visible: true, viewRule: "Self", modifyRule: "Self"},
{name: "Face ID", visible: true, viewRule: "Self", modifyRule: "Self"},
{name: "MFA accounts", visible: true, viewRule: "Self", modifyRule: "Self"},
{name: "Need update password", visible: true, viewRule: "Admin", modifyRule: "Admin"},
{name: "IP whitelist", visible: true, viewRule: "Admin", modifyRule: "Admin"},
],
};
}

View File

@@ -18,7 +18,6 @@ import {InfoCircleTwoTone} from "@ant-design/icons";
import * as PaymentBackend from "./backend/PaymentBackend";
import * as Setting from "./Setting";
import i18next from "i18next";
import * as ProductBackend from "./backend/ProductBackend";
const {Option} = Select;
@@ -30,7 +29,6 @@ class PaymentEditPage extends React.Component {
organizationName: props.organizationName !== undefined ? props.organizationName : props.match.params.organizationName,
paymentName: props.match.params.paymentName,
payment: null,
products: [],
isModalVisible: false,
isInvoiceLoading: false,
mode: props.location.mode !== undefined ? props.location.mode : "edit",
@@ -39,7 +37,6 @@ class PaymentEditPage extends React.Component {
UNSAFE_componentWillMount() {
this.getPayment();
this.getProducts();
}
getPayment() {
@@ -58,19 +55,6 @@ class PaymentEditPage extends React.Component {
});
}
getProducts() {
ProductBackend.getProducts(this.state.organizationName)
.then((res) => {
if (res.status === "ok") {
this.setState({
products: res.data,
});
} else {
Setting.showMessage("error", `Failed to get products: ${res.msg}`);
}
});
}
goToViewOrder() {
const payment = this.state.payment;
if (payment && payment.order) {
@@ -240,29 +224,6 @@ class PaymentEditPage extends React.Component {
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Products"), i18next.t("payment:Products - Tooltip"))} :
</Col>
<Col span={22} >
<Select
mode="multiple"
style={{width: "100%"}}
value={this.state.payment?.products || []}
disabled={isViewMode}
allowClear
options={(this.state.products || [])
.map((p) => ({
label: Setting.getLanguageText(p?.displayName) || p?.name,
value: p?.name,
}))
.filter((o) => o.value)}
onChange={(value) => {
this.updatePaymentField("products", value);
}}
/>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("order:Price"), i18next.t("plan:Price - Tooltip"))} :

View File

@@ -14,7 +14,7 @@
import React from "react";
import {Link} from "react-router-dom";
import {Button, List, Table, Tooltip} from "antd";
import {Button, Col, List, Row, Table, Tooltip} from "antd";
import moment from "moment";
import * as Setting from "./Setting";
import * as PaymentBackend from "./backend/PaymentBackend";
@@ -195,21 +195,31 @@ class PaymentListPage extends BaseListPage {
paddingBottom: 8,
}}
renderItem={(productInfo, i) => {
const price = productInfo.price * (productInfo.quantity || 1);
const price = productInfo.price || 0;
const number = productInfo.quantity || 1;
const currency = record.currency || "USD";
const productName = productInfo.displayName || productInfo.name;
return (
<List.Item>
<div style={{display: "inline"}}>
<Tooltip placement="topLeft" title={i18next.t("general:Edit")}>
<Button style={{marginRight: "5px"}} icon={<EditOutlined />} size="small" onClick={() => Setting.goToLinkSoft(this, `/products/${record.owner}/${productInfo.name}`)} />
</Tooltip>
<Link to={`/products/${record.owner}/${productInfo.name}`}>
{productInfo.displayName || productInfo.name}
</Link>
<span style={{marginLeft: "8px", color: "#666"}}>
{Setting.getPriceDisplay(price, currency)}
</span>
</div>
<Row style={{width: "100%"}} wrap={false} gutter={[12, 0]}>
<Col flex="auto" style={{minWidth: 0}}>
<div style={{display: "flex", alignItems: "center", minWidth: 0}}>
<Tooltip placement="topLeft" title={i18next.t("general:Edit")}>
<Button style={{marginRight: "5px"}} icon={<EditOutlined />} size="small" onClick={() => Setting.goToLinkSoft(this, `/products/${record.owner}/${productInfo.name}`)} />
</Tooltip>
<Tooltip placement="topLeft" title={productName}>
<Link to={`/products/${record.owner}/${productInfo.name}`} style={{display: "inline-block", maxWidth: "100%", minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap"}}>
{productName}
</Link>
</Tooltip>
</div>
</Col>
<Col flex="none" style={{whiteSpace: "nowrap"}}>
<span style={{color: "#666"}}>
{Setting.getCurrencySymbol(currency)}{price} ({Setting.getCurrencyText(currency)}) × {number}
</span>
</Col>
</Row>
</List.Item>
);
}}

File diff suppressed because it is too large Load Diff

View File

@@ -137,7 +137,7 @@ class RecordListPage extends BaseListPage {
title: i18next.t("record:Status code"),
dataIndex: "statusCode",
key: "statusCode",
width: "120px",
width: "140px",
sorter: true,
...this.getColumnSearchProps("statusCode"),
},

View File

@@ -457,8 +457,8 @@ export const UserFields = ["owner", "name", "password", "display_name", "id", "t
"is_admin", "homepage", "birthday", "gender", "password_type", "password_salt", "external_id", "avatar", "first_name", "last_name",
"avatar_type", "permanent_avatar", "email_verified", "region", "location", "address",
"affiliation", "title", "id_card_type", "id_card", "real_name", "is_verified", "bio", "tag", "language",
"education", "score", "karma", "ranking", "balance", "currency", "is_default_avatar", "is_online",
"is_forbidden", "is_deleted", "signup_application", "hash", "pre_hash", "access_key", "access_secret", "access_token",
"education", "score", "karma", "ranking", "balance", "balance_credit", "balance_currency", "currency", "is_default_avatar", "is_online",
"is_forbidden", "is_deleted", "signup_application", "register_type", "register_source", "hash", "pre_hash", "access_key", "access_secret", "access_token",
"created_ip", "last_signin_time", "last_signin_ip", "github", "google", "qq", "wechat", "facebook", "dingtalk",
"weibo", "gitee", "linkedin", "wecom", "lark", "gitlab", "adfs", "baidu", "alipay", "casdoor", "infoflow", "apple",
"azuread", "azureadb2c", "slack", "steam", "bilibili", "okta", "douyin", "kwai", "line", "amazon", "auth0",
@@ -469,7 +469,7 @@ export const UserFields = ["owner", "name", "password", "display_name", "id", "t
"wepay", "xero", "yahoo", "yammer", "yandex", "zoom", "metamask", "web3onboard", "custom", "webauthnCredentials",
"preferred_mfa_type", "recovery_codes", "totp_secret", "mfa_phone_enabled", "mfa_email_enabled", "invitation",
"invitation_code", "face_ids", "ldap", "properties", "roles", "permissions", "groups", "last_change_password_time",
"last_signin_wrong_time", "signin_wrong_times", "managedAccounts", "mfaAccounts", "need_update_password",
"last_signin_wrong_time", "signin_wrong_times", "managedAccounts", "mfaAccounts", "mfaItems", "need_update_password",
"created_time", "updated_time", "deleted_time",
"ip_whitelist"];
@@ -500,6 +500,7 @@ export const GetTranslatedUserItems = () => {
{name: "Country/Region", label: i18next.t("user:Country/Region")},
{name: "Location", label: i18next.t("user:Location")},
{name: "Address", label: i18next.t("user:Address")},
{name: "Addresses", label: i18next.t("user:Addresses")},
{name: "Affiliation", label: i18next.t("user:Affiliation")},
{name: "Title", label: i18next.t("general:Title")},
{name: "ID card type", label: i18next.t("user:ID card type")},
@@ -523,6 +524,8 @@ export const GetTranslatedUserItems = () => {
{name: "Karma", label: i18next.t("user:Karma")},
{name: "Ranking", label: i18next.t("user:Ranking")},
{name: "Signup application", label: i18next.t("general:Signup application")},
{name: "Register type", label: i18next.t("user:Register type")},
{name: "Register source", label: i18next.t("user:Register source")},
{name: "API key", label: i18next.t("general:API key")},
{name: "Groups", label: i18next.t("general:Groups")},
{name: "Roles", label: i18next.t("general:Roles")},
@@ -537,6 +540,7 @@ export const GetTranslatedUserItems = () => {
{name: "IP whitelist", label: i18next.t("general:IP whitelist")},
{name: "Multi-factor authentication", label: i18next.t("mfa:Multi-factor authentication")},
{name: "WebAuthn credentials", label: i18next.t("user:WebAuthn credentials")},
{name: "Last change password time", label: i18next.t("user:Last change password time")},
{name: "Managed accounts", label: i18next.t("user:Managed accounts")},
{name: "Face ID", label: i18next.t("login:Face ID")},
{name: "MFA accounts", label: i18next.t("user:MFA accounts")},
@@ -554,6 +558,8 @@ export function getUserColumns() {
transField = "Country/Region";
} else if (field === "mfaAccounts") {
transField = "MFA accounts";
} else if (field === "mfaItems") {
transField = "MFA items";
} else if (field === "face_ids") {
transField = "Face ID";
} else if (field === "managedAccounts") {

View File

@@ -389,6 +389,13 @@ class SyncerEditPage extends React.Component {
"isHashed": true,
"values": [],
},
{
"name": "unionid",
"type": "string",
"casdoorName": "Name",
"isHashed": true,
"values": [],
},
{
"name": "name",
"type": "string",
@@ -424,13 +431,6 @@ class SyncerEditPage extends React.Component {
"isHashed": true,
"values": [],
},
{
"name": "job_number",
"type": "string",
"casdoorName": "Name",
"isHashed": true,
"values": [],
},
{
"name": "active",
"type": "boolean",

View File

@@ -50,6 +50,7 @@ import MfaTable from "./table/MfaTable";
import TransactionTable from "./table/TransactionTable";
import CartTable from "./table/CartTable";
import * as TransactionBackend from "./backend/TransactionBackend";
import ConsentTable from "./table/ConsentTable";
import {Content, Header} from "antd/es/layout/layout";
import Sider from "antd/es/layout/Sider";
@@ -73,6 +74,7 @@ class UserEditPage extends React.Component {
idCardInfo: ["ID card front", "ID card back", "ID card with person"],
openFaceRecognitionModal: false,
transactions: [],
consents: [],
activeMenuKey: window.location.hash?.slice(1) || "",
menuMode: "Horizontal",
};
@@ -110,6 +112,7 @@ class UserEditPage extends React.Component {
this.setState({
user: res.data,
multiFactorAuths: res.data?.multiFactorAuths ?? [],
consents: res.data?.applicationScopes ?? [],
loading: false,
});
@@ -274,7 +277,7 @@ class UserEditPage extends React.Component {
// Fallback to comparing by owner and name
return (this.state.user.owner === this.props.account.owner &&
this.state.user.name === this.props.account.name);
this.state.user.name === this.props.account.name);
}
isSelfOrAdmin() {
@@ -609,13 +612,20 @@ class UserEditPage extends React.Component {
);
} else if (accountItem.name === "Addresses") {
return (
<AddressTable
title={i18next.t("user:Addresses")}
table={this.state.user.addresses}
onUpdateTable={(value) => {
this.updateUserField("addresses", value);
}}
/>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("user:Addresses"), i18next.t("user:Addresses"))} :
</Col>
<Col span={22} >
<AddressTable
title={i18next.t("user:Addresses")}
table={this.state.user.addresses}
onUpdateTable={(value) => {
this.updateUserField("addresses", value);
}}
/>
</Col>
</Row>
);
} else if (accountItem.name === "Affiliation") {
return (
@@ -880,7 +890,7 @@ class UserEditPage extends React.Component {
{Setting.getLabel(i18next.t("general:Transactions"), i18next.t("general:Transactions"))} :
</Col>
<Col span={22}>
<TransactionTable transactions={this.state.transactions} hideTag={true} />
<TransactionTable title={i18next.t("general:Transactions")} transactions={this.state.transactions} hideTag={true} />
</Col>
</Row>
);
@@ -1122,6 +1132,21 @@ class UserEditPage extends React.Component {
/>
</Col>
</Row>);
} else if (accountItem.name === "Consents") {
return (
<Row style={{marginTop: "20px"}}>
<Col style={{marginTop: "5px"}} span={Setting.isMobile() ? 22 : 2}>
{Setting.getLabel(i18next.t("consent:Consents"), i18next.t("consent:Consents - Tooltip"))} :
</Col>
<Col span={22}>
<ConsentTable
title={i18next.t("consent:Consents")}
table={this.state.consents}
onUpdateTable={() => this.getUser()}
/>
</Col>
</Row>
);
} else if (accountItem.name === "Multi-factor authentication") {
return (
!this.isSelfOrAdmin() ? null : (
@@ -1130,15 +1155,21 @@ class UserEditPage extends React.Component {
{Setting.getLabel(i18next.t("mfa:Multi-factor authentication"), i18next.t("mfa:Multi-factor authentication - Tooltip "))} :
</Col>
<Col span={22} >
<Card size="small" title={i18next.t("mfa:Multi-factor methods")}
extra={this.state.multiFactorAuths?.some(mfaProps => mfaProps.enabled) ?
<PopconfirmModal
text={i18next.t("general:Disable")}
title={i18next.t("general:Sure to disable") + "?"}
onConfirm={() => this.deleteMfa()}
/> : null
}>
<Card size="small" title={
<div>
{i18next.t("mfa:Multi-factor methods")}&nbsp;&nbsp;&nbsp;&nbsp;
{this.state.multiFactorAuths?.some(mfaProps => mfaProps.enabled) ?
<PopconfirmModal
text={i18next.t("general:Disable")}
title={i18next.t("general:Sure to disable") + "?"}
onConfirm={() => this.deleteMfa()}
size="small"
/> : null
}
</div>
}>
<List
size="small"
rowKey="mfaType"
itemLayout="horizontal"
dataSource={this.state.multiFactorAuths}

260
web/src/auth/ConsentPage.js Normal file
View File

@@ -0,0 +1,260 @@
// Copyright 2026 The Casdoor 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 {Button, Card, List, Result, Space} from "antd";
import {CheckOutlined, LockOutlined} from "@ant-design/icons";
import * as ApplicationBackend from "../backend/ApplicationBackend";
import * as ConsentBackend from "../backend/ConsentBackend";
import * as Setting from "../Setting";
import i18next from "i18next";
import {withRouter} from "react-router-dom";
import * as Util from "./Util";
class ConsentPage extends React.Component {
constructor(props) {
super(props);
const params = new URLSearchParams(window.location.search);
this.state = {
applicationName: props.match?.params?.applicationName || params.get("application"),
scopeDescriptions: [],
granting: false,
oAuthParams: Util.getOAuthGetParameters(),
};
}
getApplicationObj() {
return this.props.application;
}
componentDidMount() {
this.getApplication();
this.loadScopeDescriptions();
}
componentDidUpdate(prevProps) {
if (this.props.application !== prevProps.application) {
this.loadScopeDescriptions();
}
}
getApplication() {
if (!this.state.applicationName) {
return;
}
ApplicationBackend.getApplication("admin", this.state.applicationName)
.then((res) => {
if (res.status === "error") {
Setting.showMessage("error", res.msg);
return;
}
this.props.onUpdateApplication(res.data);
});
}
loadScopeDescriptions() {
const {oAuthParams} = this.state;
const application = this.getApplicationObj();
if (!oAuthParams?.scope || !application) {
return;
}
// Check if urlPar scope is within application scopes
const scopes = oAuthParams.scope.split(" ").map(s => s.trim()).filter(Boolean);
const customScopes = application.customScopes || [];
const customScopesMap = {};
customScopes.forEach(s => {
if (s?.scope) {
customScopesMap[s.scope] = s;
}
});
const scopeDescriptions = scopes
.map(scope => {
const item = customScopesMap[scope];
if (item) {
return {
...item,
displayName: item.displayName || item.scope,
};
}
return {
scope: scope,
displayName: scope,
description: i18next.t("consent:This scope is not defined in the application"),
};
})
.filter(Boolean);
this.setState({
scopeDescriptions: scopeDescriptions,
});
}
handleGrant() {
const {oAuthParams, scopeDescriptions} = this.state;
const application = this.getApplicationObj();
this.setState({granting: true});
const consent = {
owner: application.owner,
application: application.owner + "/" + application.name,
grantedScopes: scopeDescriptions.map(s => s.scope),
};
ConsentBackend.grantConsent(consent, oAuthParams)
.then((res) => {
if (res.status === "ok") {
// res.data contains the authorization code
const code = res.data;
const concatChar = oAuthParams?.redirectUri?.includes("?") ? "&" : "?";
const redirectUrl = `${oAuthParams.redirectUri}${concatChar}code=${code}&state=${oAuthParams.state}`;
Setting.goToLink(redirectUrl);
} else {
Setting.showMessage("error", res.msg);
this.setState({granting: false});
}
});
}
handleDeny() {
const {oAuthParams} = this.state;
const concatChar = oAuthParams?.redirectUri?.includes("?") ? "&" : "?";
Setting.goToLink(`${oAuthParams.redirectUri}${concatChar}error=access_denied&error_description=User denied consent&state=${oAuthParams.state}`);
}
render() {
const application = this.getApplicationObj();
if (application === undefined) {
return null;
}
if (!application) {
return (
<Result
status="error"
title={i18next.t("general:Invalid application")}
/>
);
}
const {scopeDescriptions, granting} = this.state;
const isScopeEmpty = scopeDescriptions.length === 0;
return (
<div className="login-content">
<div className={Setting.isDarkTheme(this.props.themeAlgorithm) ? "login-panel-dark" : "login-panel"}>
<div className="login-form">
<Card
style={{
padding: "32px",
width: 450,
borderRadius: "12px",
boxShadow: "0 10px 25px rgba(0, 0, 0, 0.05)",
border: "1px solid #f0f0f0",
}}
>
<div style={{textAlign: "center", marginBottom: 24}}>
{application.logo && (
<div style={{marginBottom: 16}}>
<img
src={application.logo}
alt={application.displayName || application.name}
style={{height: 56, objectFit: "contain"}}
/>
</div>
)}
<h2 style={{margin: 0, fontWeight: 600, fontSize: "24px"}}>
{i18next.t("consent:Authorization Request")}
</h2>
</div>
<div style={{marginBottom: 32}}>
<p style={{fontSize: 15, color: "#666", textAlign: "center", lineHeight: "1.6"}}>
<span style={{fontWeight: 600, color: "#000"}}>{application.displayName || application.name}</span>
{" "}{i18next.t("consent:wants to access your account")}
</p>
{application.homepageUrl && (
<div style={{textAlign: "center", marginTop: 4}}>
<a href={application.homepageUrl} target="_blank" rel="noopener noreferrer" style={{fontSize: 13, color: "#1890ff"}}>
{application.homepageUrl}
</a>
</div>
)}
</div>
<div style={{marginBottom: 32}}>
<div style={{fontSize: 14, color: "#8c8c8c", marginBottom: 16}}>
<LockOutlined style={{marginRight: 8}} /> {i18next.t("consent:This application is requesting")}
</div>
<div style={{display: "flex", justifyContent: "center"}}>
<List
size="small"
dataSource={scopeDescriptions}
style={{width: "100%"}}
renderItem={item => (
<List.Item style={{borderBottom: "none", width: "100%"}}>
<div style={{display: "inline-grid", gridTemplateColumns: "16px auto", columnGap: 8, alignItems: "start"}}>
<CheckOutlined style={{color: "#52c41a", fontSize: "14px", marginTop: "4px", justifySelf: "center"}} />
<div style={{fontWeight: 500, fontSize: "14px", lineHeight: "22px"}}>{item.displayName || item.scope}</div>
</div>
<div style={{fontSize: "12px", color: "#8c8c8c", marginTop: 2}}>{item.description}</div>
</List.Item>
)}
/>
</div>
</div>
<div style={{textAlign: "center", marginBottom: 24}}>
<Space size={16}>
<Button
type="primary"
size="large"
shape="round"
onClick={() => this.handleGrant()}
loading={granting}
disabled={granting || isScopeEmpty}
style={{minWidth: 120, height: 44, fontWeight: 500}}
>
{i18next.t("consent:Allow")}
</Button>
<Button
size="large"
shape="round"
onClick={() => this.handleDeny()}
disabled={granting || isScopeEmpty}
style={{minWidth: 120, height: 44, fontWeight: 500}}
>
{i18next.t("consent:Deny")}
</Button>
</Space>
</div>
<div style={{padding: "16px", backgroundColor: "#fafafa", borderRadius: "8px", border: "1px solid #f0f0f0"}}>
<p style={{margin: 0, fontSize: 12, color: "#8c8c8c", textAlign: "center", lineHeight: "1.5"}}>
{i18next.t("consent:By clicking Allow, you allow this app to use your information")}
</p>
</div>
</Card>
</div>
</div>
</div>
);
}
}
export default withRouter(ConsentPage);

View File

@@ -216,7 +216,7 @@ class LoginPage extends React.Component {
this.setState({
msg: res.msg,
});
return ;
return;
}
this.onUpdateApplication(res.data);
});
@@ -369,6 +369,13 @@ class LoginPage extends React.Component {
return;
}
// Check if consent is required
if (resp.data?.required === true) {
// Consent required, redirect to consent page
Setting.goToLinkSoft(ths, `/consent/${application.name}?${window.location.search.substring(1)}`);
return;
}
if (Setting.hasPromptPage(application)) {
AuthBackend.getAccount()
.then((res) => {
@@ -1141,9 +1148,11 @@ class LoginPage extends React.Component {
visible={this.state.openCaptchaModal}
noModal={noModal}
onUpdateToken={(captchaType, captchaToken, clientSecret) => {
this.setState({captchaValues: {
captchaType, captchaToken, clientSecret,
}});
this.setState({
captchaValues: {
captchaType, captchaToken, clientSecret,
},
});
}}
onOk={(captchaType, captchaToken, clientSecret) => {
const values = this.state.values;

View File

@@ -44,20 +44,15 @@ function generateCodeChallenge(verifier) {
}
function storeCodeVerifier(state, verifier) {
localStorage.setItem("pkce_verifier", `${state}#${verifier}`);
localStorage.setItem(`pkce_verifier_${state}`, verifier);
}
export function getCodeVerifier(state) {
const verifierStore = localStorage.getItem("pkce_verifier");
const [storedState, verifier] = verifierStore ? verifierStore.split("#") : [null, null];
if (storedState !== state) {
return null;
}
return verifier;
return localStorage.getItem(`pkce_verifier_${state}`);
}
export function clearCodeVerifier(state) {
localStorage.removeItem("pkce_verifier");
localStorage.removeItem(`pkce_verifier_${state}`);
}
const authInfo = {
@@ -407,24 +402,27 @@ export function getProviderUrl(provider) {
}
}
export function getProviderLogoWidget(provider) {
export function getProviderLogoWidget(provider, options = {}) {
if (provider === undefined) {
return null;
}
const url = getProviderUrl(provider);
if (url !== "") {
const disableLink = options.disableLink === true;
const imgEl = <img width={36} height={36} src={Setting.getProviderLogoURL(provider)} alt={provider.displayName} />;
if (url !== "" && !disableLink) {
return (
<Tooltip title={provider.type}>
<a target="_blank" rel="noreferrer" href={getProviderUrl(provider)}>
<img width={36} height={36} src={Setting.getProviderLogoURL(provider)} alt={provider.displayName} />
{imgEl}
</a>
</Tooltip>
);
} else {
return (
<Tooltip title={provider.type}>
<img width={36} height={36} src={Setting.getProviderLogoURL(provider)} alt={provider.displayName} />
{imgEl}
</Tooltip>
);
}

View File

@@ -293,8 +293,17 @@ class SignupPage extends React.Component {
return;
}
// Check if consent is required
if (oAuthParams && res.data && typeof res.data === "object" && res.data.required === true) {
// Consent required, redirect to consent page
Setting.goToLink(`/consent/${application.name}?${window.location.search.substring(1)}`);
return;
}
// the user's id will be returned by `signup()`, if user signup by phone, the `username` in `values` is undefined.
values.username = res.data.split("/")[1];
if (typeof res.data === "string") {
values.username = res.data.split("/")[1];
}
if (Setting.hasPromptPage(application) && (!values.plan || !values.pricing)) {
AuthBackend.getAccount("")
.then((res) => {

View File

@@ -213,17 +213,19 @@ export async function WechatOfficialAccountModal(application, provider, method)
}
const t1 = setInterval(await getEvent, 1000, application, provider, res.data2, method);
{Modal.info({
title: i18next.t("provider:Please use WeChat to scan the QR code and follow the official account for sign in"),
content: (
<div style={{marginRight: "34px"}}>
<QRCode style={{padding: "20px", margin: "auto"}} bordered={false} value={res.data} size={230} />
</div>
),
onOk() {
window.clearInterval(t1);
},
});}
{
Modal.info({
title: i18next.t("provider:Please use WeChat to scan the QR code and follow the official account for sign in"),
content: (
<div style={{marginRight: "34px"}}>
<QRCode style={{padding: "20px", margin: "auto"}} bordered={false} value={res.data} size={230} />
</div>
),
onOk() {
window.clearInterval(t1);
},
});
}
}
);
}

View File

@@ -0,0 +1,50 @@
// Copyright 2026 The Casdoor 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 * as Setting from "../Setting";
export function grantConsent(consent, oAuthParams) {
const request = {
...consent,
clientId: oAuthParams.clientId,
provider: "",
signinMethod: "",
responseType: oAuthParams.responseType || "code",
redirectUri: oAuthParams.redirectUri,
scope: oAuthParams.scope,
state: oAuthParams.state,
nonce: oAuthParams.nonce || "",
challenge: oAuthParams.codeChallenge || "",
resource: "",
};
return fetch(`${Setting.ServerUrl}/api/grant-consent`, {
method: "POST",
credentials: "include",
body: JSON.stringify(request),
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function revokeConsent(consent) {
return fetch(`${Setting.ServerUrl}/api/revoke-consent`, {
method: "POST",
credentials: "include",
body: JSON.stringify(consent),
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}

View File

@@ -158,7 +158,7 @@ const Dashboard = (props) => {
i18next.t("general:Adapters"),
i18next.t("general:Enforcers"),
], top: "10%"},
grid: {left: "3%", right: "4%", bottom: "0", top: "25%", containLabel: true},
grid: {left: "3%", right: "4%", bottom: "0", top: "30%", containLabel: true},
xAxis: {type: "category", boundaryGap: false, data: dateArray},
yAxis: {type: "value"},
series: [

View File

@@ -23,24 +23,24 @@ class RegionSelect extends React.Component {
super(props);
this.state = {
classes: props,
value: "",
};
}
onChange(e) {
this.props.onChange(e);
this.setState({value: e});
}
render() {
const value = this.props.value !== undefined && this.props.value !== "" ? this.props.value : (this.props.defaultValue !== undefined && this.props.defaultValue !== "" ? this.props.defaultValue : undefined);
return (
<Select virtual={false}
size={this.props.size}
showSearch
optionFilterProp="label"
style={{width: "100%"}}
defaultValue={this.props.defaultValue || undefined}
value={value}
placeholder="Please select country/region"
onChange={(value => {this.onChange(value);})}
onChange={(val) => {this.onChange(val);}}
filterOption={(input, option) => (option?.label ?? "").toLowerCase().includes(input.toLowerCase())}
filterSort={(optionA, optionB) =>
(optionA?.label ?? "").toLowerCase().localeCompare((optionB?.label ?? "").toLowerCase())

View File

@@ -268,7 +268,7 @@
"Admin": "管理工具",
"Affiliation URL": "工作单位URL",
"Affiliation URL - Tooltip": "工作单位的官网URL",
"All": "全部允许",
"All": "全部",
"Application": "应用",
"Application - Tooltip": "可以访问的应用",
"Applications": "应用",

View File

@@ -0,0 +1,44 @@
// Copyright 2026 The Casdoor 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 {Col, Row} from "antd";
import * as Setting from "../Setting";
import i18next from "i18next";
import {CaptchaPreview} from "../common/CaptchaPreview";
export function renderCaptchaProviderFields(provider, providerName) {
return (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Preview"), i18next.t("general:Preview - Tooltip"))} :
</Col>
<Col span={22} >
<CaptchaPreview
owner={provider.owner}
name={provider.name}
provider={provider}
providerName={providerName}
captchaType={provider.type}
subType={provider.subType}
clientId={provider.clientId}
clientSecret={provider.clientSecret}
clientId2={provider.clientId2}
clientSecret2={provider.clientSecret2}
providerUrl={provider.providerUrl}
/>
</Col>
</Row>
);
}

View File

@@ -0,0 +1,255 @@
// Copyright 2026 The Casdoor 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 {Button, Col, Input, InputNumber, Row, Select, Switch} from "antd";
import {LinkOutlined} from "@ant-design/icons";
import * as Setting from "../Setting";
import i18next from "i18next";
import * as ProviderEditTestEmail from "../common/TestEmailWidget";
import Editor from "../common/Editor";
import HttpHeaderTable from "../table/HttpHeaderTable";
const {Option} = Select;
export function renderEmailProviderFields(provider, updateProviderField, renderEmailMappingInput, account) {
return (
<React.Fragment>
{
["Custom HTTP Email", "SendGrid"].includes(provider.type) ? (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{Setting.getLabel(i18next.t("provider:Endpoint"), i18next.t("provider:Region endpoint for Internet"))} :
</Col>
<Col span={22} >
<Input prefix={<LinkOutlined />} value={provider.endpoint} onChange={e => {
updateProviderField("endpoint", e.target.value);
}} />
</Col>
</Row>) : null
}
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Host"), i18next.t("provider:Host - Tooltip"))} :
</Col>
<Col span={22} >
<Input prefix={<LinkOutlined />} value={provider.host} onChange={e => {
updateProviderField("host", e.target.value);
}} />
</Col>
</Row>
{["Azure ACS", "SendGrid"].includes(provider.type) ? null : (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Port"), i18next.t("provider:Port - Tooltip"))} :
</Col>
<Col span={22} >
<InputNumber value={provider.port} onChange={value => {
updateProviderField("port", value);
}} />
</Col>
</Row>
)}
{["Azure ACS", "SendGrid"].includes(provider.type) ? null : (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:SSL mode"), i18next.t("provider:SSL mode - Tooltip"))} :
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "200px"}} value={provider.sslMode || "Auto"} onChange={value => {
updateProviderField("sslMode", value);
}}>
<Option value="Auto">{i18next.t("general:Auto")}</Option>
<Option value="Enable">{i18next.t("general:Enable")}</Option>
<Option value="Disable">{i18next.t("general:Disable")}</Option>
</Select>
</Col>
</Row>
)}
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Enable proxy"), i18next.t("provider:Enable proxy - Tooltip"))} :
</Col>
<Col span={1} >
<Switch checked={provider.enableProxy} onChange={checked => {
updateProviderField("enableProxy", checked);
}} />
</Col>
</Row>
{
provider.type === "Custom HTTP Email" ? (
<React.Fragment>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Method"), i18next.t("provider:Method - Tooltip"))} :
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={provider.method} onChange={value => {
updateProviderField("method", value);
}}>
{
[
{id: "GET", name: "GET"},
{id: "POST", name: "POST"},
{id: "PUT", name: "PUT"},
{id: "DELETE", name: "DELETE"},
].map((method, index) => <Option key={index} value={method.id}>{method.name}</Option>)
}
</Select>
</Col>
</Row>
{
provider.method !== "GET" ? (<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("webhook:Content type"), i18next.t("webhook:Content type - Tooltip"))} :
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={provider.issuerUrl === "" ? "application/x-www-form-urlencoded" : provider.issuerUrl} onChange={value => {
updateProviderField("issuerUrl", value);
}}>
{
[
{id: "application/json", name: "application/json"},
{id: "application/x-www-form-urlencoded", name: "application/x-www-form-urlencoded"},
].map((method, index) => <Option key={index} value={method.id}>{method.name}</Option>)
}
</Select>
</Col>
</Row>) : null
}
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:HTTP header"), i18next.t("provider:HTTP header - Tooltip"))} :
</Col>
<Col span={22} >
<HttpHeaderTable httpHeaders={provider.httpHeaders} onUpdateTable={(value) => {updateProviderField("httpHeaders", value);}} />
</Col>
</Row>
{provider.method !== "GET" ? <Row style={{marginTop: "20px"}}>
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:HTTP body mapping"), i18next.t("provider:HTTP body mapping - Tooltip"))} :
</Col>
<Col span={22}>
{renderEmailMappingInput()}
</Col>
</Row> : null}
</React.Fragment>
) : null
}
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Email title"), i18next.t("provider:Email title - Tooltip"))} :
</Col>
<Col span={22} >
<Input value={provider.title} onChange={e => {
updateProviderField("title", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Email content"), i18next.t("provider:Email content - Tooltip"))} :
</Col>
<Col span={22} >
<Row style={{marginTop: "20px"}} >
<Button style={{marginLeft: "10px", marginBottom: "5px"}} onClick={() => updateProviderField("content", "You have requested a verification code at Casdoor. Here is your code: %s, please enter in 5 minutes. <reset-link>Or click %link to reset</reset-link>")} >
{i18next.t("general:Reset to Default")} (Text)
</Button>
<Button style={{marginLeft: "10px", marginBottom: "5px"}} type="primary" onClick={() => updateProviderField("content", Setting.getDefaultHtmlEmailContent())} >
{i18next.t("general:Reset to Default")} (HTML)
</Button>
</Row>
<Row>
<Col span={Setting.isMobile() ? 22 : 11}>
<div style={{height: "300px", margin: "10px"}}>
<Editor
value={provider.content}
fillHeight
dark
lang="html"
onChange={value => {
updateProviderField("content", value);
}}
/>
</div>
</Col>
<Col span={1} />
<Col span={Setting.isMobile() ? 22 : 11}>
<div style={{margin: "10px"}}>
<div dangerouslySetInnerHTML={{__html: provider.content.replace("%s", "123456").replace("%{user.friendlyName}", Setting.getFriendlyUserName(account))}} />
</div>
</Col>
</Row>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(`${i18next.t("provider:Email content")}-${i18next.t("general:Invitations")}`, i18next.t("provider:Email content - Tooltip"))} :
</Col>
<Col span={22} >
<Row style={{marginTop: "20px"}} >
<Button style={{marginLeft: "10px", marginBottom: "5px"}} onClick={() => updateProviderField("metadata", "You have invited to join Casdoor. Here is your invitation code: %s, please enter in 5 minutes. Or click %link to signup")} >
{i18next.t("general:Reset to Default")} (Text)
</Button>
<Button style={{marginLeft: "10px", marginBottom: "5px"}} type="primary" onClick={() => updateProviderField("metadata", Setting.getDefaultInvitationHtmlEmailContent())} >
{i18next.t("general:Reset to Default")} (HTML)
</Button>
</Row>
<Row>
<Col span={Setting.isMobile() ? 22 : 11}>
<div style={{height: "300px", margin: "10px"}}>
<Editor
value={provider.metadata}
fillHeight
dark
lang="html"
onChange={value => {
updateProviderField("metadata", value);
}}
/>
</div>
</Col>
<Col span={1} />
<Col span={Setting.isMobile() ? 22 : 11}>
<div style={{margin: "10px"}}>
<div dangerouslySetInnerHTML={{__html: provider.metadata.replace("%code", "123456").replace("%s", "123456")}} />
</div>
</Col>
</Row>
</Col>
</Row>
<Row style={{marginTop: "20px"}}>
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Test Email"), i18next.t("provider:Test Email - Tooltip"))} :
</Col>
<Col span={4}>
<Input value={provider.receiver} placeholder={i18next.t("user:Input your email")}
onChange={e => {
updateProviderField("receiver", e.target.value);
}} />
</Col>
{["Azure ACS", "SendGrid"].includes(provider.type) ? null : (
<Button style={{marginLeft: "10px", marginBottom: "5px"}} onClick={() => ProviderEditTestEmail.connectSmtpServer(provider)} >
{i18next.t("provider:Test SMTP Connection")}
</Button>
)}
<Button style={{marginLeft: "10px", marginBottom: "5px"}} type="primary"
disabled={!Setting.isValidEmail(provider.receiver)}
onClick={() => ProviderEditTestEmail.sendTestEmail(provider, provider.receiver)} >
{i18next.t("provider:Send Testing Email")}
</Button>
</Row>
</React.Fragment>
);
}

View File

@@ -0,0 +1,48 @@
// Copyright 2026 The Casdoor 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 {Col, Input, Row} from "antd";
import {LinkOutlined} from "@ant-design/icons";
import * as Setting from "../Setting";
import i18next from "i18next";
export function renderFaceIdProviderFields(provider, updateProviderField) {
return (
<>
{["Alibaba Cloud Facebody"].includes(provider.type) ? null : (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{Setting.getLabel(i18next.t("provider:Endpoint (Intranet)"), i18next.t("provider:Region endpoint for Intranet"))} :
</Col>
<Col span={22} >
<Input prefix={<LinkOutlined />} value={provider.intranetEndpoint} onChange={e => {
updateProviderField("intranetEndpoint", e.target.value);
}} />
</Col>
</Row>
)}
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{Setting.getLabel(i18next.t("provider:Endpoint"), i18next.t("provider:Region endpoint for Internet"))} :
</Col>
<Col span={22} >
<Input prefix={<LinkOutlined />} value={provider.endpoint} onChange={e => {
updateProviderField("endpoint", e.target.value);
}} />
</Col>
</Row>
</>
);
}

View File

@@ -0,0 +1,34 @@
// Copyright 2026 The Casdoor 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 {Col, Input, Row} from "antd";
import {LinkOutlined} from "@ant-design/icons";
import * as Setting from "../Setting";
import i18next from "i18next";
export function renderIDVerificationProviderFields(provider, updateProviderField) {
return (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{Setting.getLabel(i18next.t("provider:Endpoint"), i18next.t("provider:Region endpoint for Internet"))} :
</Col>
<Col span={22} >
<Input prefix={<LinkOutlined />} value={provider.endpoint} onChange={e => {
updateProviderField("endpoint", e.target.value);
}} />
</Col>
</Row>
);
}

View File

@@ -0,0 +1,56 @@
// Copyright 2026 The Casdoor 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 {Col, Input, InputNumber, Row} from "antd";
import {LinkOutlined} from "@ant-design/icons";
import * as Setting from "../Setting";
import i18next from "i18next";
export function renderMfaProviderFields(provider, updateProviderField) {
return (
<React.Fragment>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Host"), i18next.t("provider:Host - Tooltip"))} :
</Col>
<Col span={22} >
<Input prefix={<LinkOutlined />} value={provider.host} placeholder="10.10.10.10" onChange={e => {
updateProviderField("host", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Port"), i18next.t("provider:Port - Tooltip"))} :
</Col>
<Col span={22} >
<InputNumber value={provider.port} onChange={value => {
updateProviderField("port", value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Client secret"), i18next.t("provider:RADIUS Shared Secret - Tooltip"))} :
</Col>
<Col span={22} >
<Input value={provider.clientSecret} placeholder="Shared secret" onChange={e => {
updateProviderField("clientSecret", e.target.value);
}} />
</Col>
</Row>
</React.Fragment>
);
}

View File

@@ -0,0 +1,103 @@
// Copyright 2026 The Casdoor 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 {Button, Col, Input, Row, Select} from "antd";
import * as Setting from "../Setting";
import i18next from "i18next";
import * as ProviderNotification from "../common/TestNotificationWidget";
const {Option} = Select;
const {TextArea} = Input;
export function renderNotificationProviderFields(provider, updateProviderField, getReceiverRow) {
return (
<React.Fragment>
{["CUCloud"].includes(provider.type) ? (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{["Casdoor"].includes(provider.type) ?
Setting.getLabel(i18next.t("general:Application"), i18next.t("general:Application - Tooltip")) :
Setting.getLabel(i18next.t("provider:Region ID"), i18next.t("provider:Region ID - Tooltip"))} :
</Col>
<Col span={22} >
<Input value={provider.regionId} onChange={e => {
updateProviderField("regionId", e.target.value);
}} />
</Col>
</Row>
) : null}
{["Custom HTTP"].includes(provider.type) ? (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Method"), i18next.t("provider:Method - Tooltip"))} :
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={provider.method} onChange={value => {
updateProviderField("method", value);
}}>
{
[
{id: "GET", name: "GET"},
{id: "POST", name: "POST"},
].map((method, index) => <Option key={index} value={method.id}>{method.name}</Option>)
}
</Select>
</Col>
</Row>
) : null}
{["Custom HTTP", "CUCloud"].includes(provider.type) ? (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Parameter"), i18next.t("provider:Parameter - Tooltip"))} :
</Col>
<Col span={22} >
<Input value={provider.title} onChange={e => {
updateProviderField("title", e.target.value);
}} />
</Col>
</Row>
) : null}
{["Google Chat", "CUCloud"].includes(provider.type) ? (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Metadata"), i18next.t("provider:Metadata - Tooltip"))} :
</Col>
<Col span={22}>
<TextArea rows={4} value={provider.metadata} onChange={e => {
updateProviderField("metadata", e.target.value);
}} />
</Col>
</Row>
) : null}
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Content"), i18next.t("provider:Content - Tooltip"))} :
</Col>
<Col span={22} >
<TextArea autoSize={{minRows: 3, maxRows: 100}} value={provider.content} onChange={e => {
updateProviderField("content", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
{getReceiverRow(provider)}
<Button style={{marginLeft: "10px", marginBottom: "5px"}} type="primary"
onClick={() => ProviderNotification.sendTestNotification(provider)} >
{i18next.t("provider:Send Testing Notification")}
</Button>
</Row>
</React.Fragment>
);
}

View File

@@ -0,0 +1,218 @@
// Copyright 2026 The Casdoor 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 {Col, Input, Radio, Row, Switch} from "antd";
import {LinkOutlined} from "@ant-design/icons";
import * as Setting from "../Setting";
import i18next from "i18next";
const {TextArea} = Input;
export function renderOAuthProviderFields(provider, updateProviderField, renderUserMappingInput) {
const getDomainLabel = provider => {
switch (provider.category) {
case "OAuth":
if (provider.type === "AzureAD" || provider.type === "AzureADB2C") {
return Setting.getLabel(i18next.t("provider:Tenant ID"), i18next.t("provider:Tenant ID - Tooltip"));
} else {
return Setting.getLabel(i18next.t("provider:Domain"), i18next.t("provider:Domain - Tooltip"));
}
default:
return Setting.getLabel(i18next.t("provider:Domain"), i18next.t("provider:Domain - Tooltip"));
}
};
return (
<React.Fragment>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Email regex"), i18next.t("provider:Email regex - Tooltip"))} :
</Col>
<Col span={22}>
<TextArea rows={4} value={provider.emailRegex} onChange={e => {
updateProviderField("emailRegex", e.target.value);
}} />
</Col>
</Row>
{
provider.type !== "WeChat" ? null : (
<React.Fragment>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Use WeChat Media Platform in PC"), i18next.t("provider:Use WeChat Media Platform in PC - Tooltip"))} :
</Col>
<Col span={1} >
<Switch disabled={!provider.clientId} checked={provider.disableSsl} onChange={checked => {
updateProviderField("disableSsl", checked);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("token:Access token"), i18next.t("token:Access token - Tooltip"))} :
</Col>
<Col span={22} >
<Input value={provider.content} disabled={!provider.disableSsl || !provider.clientId2} onChange={e => {
updateProviderField("content", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Follow-up action"), i18next.t("provider:Follow-up action - Tooltip"))} :
</Col>
<Col>
<Radio.Group value={provider.signName}
disabled={!provider.disableSsl || !provider.clientId || !provider.clientId2}
buttonStyle="solid"
onChange={e => {
updateProviderField("signName", e.target.value);
}}>
<Radio.Button value="open">{i18next.t("provider:Use WeChat Open Platform to login")}</Radio.Button>
<Radio.Button value="media">{i18next.t("provider:Use WeChat Media Platform to login")}</Radio.Button>
</Radio.Group>
</Col>
</Row>
</React.Fragment>
)
}
{
provider.type !== "ADFS" && provider.type !== "AzureAD"
&& provider.type !== "AzureADB2C" && (provider.type !== "Casdoor" && provider.category !== "Storage")
&& provider.type !== "Okta" && provider.type !== "Nextcloud" ? null : (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{getDomainLabel(provider)} :
</Col>
<Col span={22} >
<Input prefix={<LinkOutlined />} value={provider.domain} onChange={e => {
updateProviderField("domain", e.target.value);
}} />
</Col>
</Row>
)
}
{
provider.type !== "Google" && provider.type !== "Lark" ? null : (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{provider.type === "Google" ?
Setting.getLabel(i18next.t("provider:Get phone number"), i18next.t("provider:Get phone number - Tooltip"))
: Setting.getLabel(i18next.t("provider:Use global endpoint"), i18next.t("provider:Use global endpoint - Tooltip"))} :
</Col>
<Col span={1} >
<Switch disabled={!provider.clientId} checked={provider.disableSsl} onChange={checked => {
updateProviderField("disableSsl", checked);
}} />
</Col>
</Row>
)
}
{
provider.type.startsWith("Custom") ? (
<React.Fragment>
<Col>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Auth URL"), i18next.t("provider:Auth URL - Tooltip"))}
</Col>
<Col span={22} >
<Input value={provider.customAuthUrl} onChange={e => {
updateProviderField("customAuthUrl", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Token URL"), i18next.t("provider:Token URL - Tooltip"))}
</Col>
<Col span={22} >
<Input value={provider.customTokenUrl} onChange={e => {
updateProviderField("customTokenUrl", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Scope"), i18next.t("provider:Scope - Tooltip"))}
</Col>
<Col span={22} >
<Input value={provider.scopes} onChange={e => {
updateProviderField("scopes", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:UserInfo URL"), i18next.t("provider:UserInfo URL - Tooltip"))}
</Col>
<Col span={22} >
<Input value={provider.customUserInfoUrl} onChange={e => {
updateProviderField("customUserInfoUrl", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Enable PKCE"), i18next.t("provider:Enable PKCE - Tooltip"))} :
</Col>
<Col span={22} >
<Switch checked={provider.enablePkce} onChange={checked => {
updateProviderField("enablePkce", checked);
}} />
</Col>
</Row>
</Col>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:User mapping"), i18next.t("provider:User mapping - Tooltip"))} :
</Col>
<Col span={22} >
{renderUserMappingInput()}
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Favicon"), i18next.t("general:Favicon - Tooltip"))} :
</Col>
<Col span={22} >
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 1}>
{Setting.getLabel(i18next.t("general:URL"), i18next.t("general:URL - Tooltip"))} :
</Col>
<Col span={23} >
<Input prefix={<LinkOutlined />} value={provider.customLogo} onChange={e => {
updateProviderField("customLogo", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 1}>
{i18next.t("general:Preview")}:
</Col>
<Col span={23} >
<a target="_blank" rel="noreferrer" href={provider.customLogo}>
<img src={provider.customLogo} alt={provider.customLogo} height={90} style={{marginBottom: "20px"}} />
</a>
</Col>
</Row>
</Col>
</Row>
</React.Fragment>
) : null
}
</React.Fragment>
);
}

View File

@@ -0,0 +1,72 @@
// Copyright 2026 The Casdoor 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 {Col, Input, Row, Select} from "antd";
import * as Setting from "../Setting";
import i18next from "i18next";
import {LinkOutlined} from "@ant-design/icons";
const {Option} = Select;
export function renderPaymentProviderFields(provider, updateProviderField, certs) {
return (
<React.Fragment>
{
(provider.type === "Alipay" || provider.type === "WeChat Pay" || provider.type === "Casdoor") ? (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Cert"), i18next.t("general:Cert - Tooltip"))} :
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={provider.cert} onChange={(value => {updateProviderField("cert", value);})}>
{
certs.map((cert, index) => <Option key={index} value={cert.name}>{cert.name}</Option>)
}
</Select>
</Col>
</Row>
) : null
}
{
(provider.type === "Alipay") ? (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Root cert"), i18next.t("general:Root cert - Tooltip"))} :
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={provider.metadata} onChange={(value => {updateProviderField("metadata", value);})}>
{
certs.map((cert, index) => <Option key={index} value={cert.name}>{cert.name}</Option>)
}
</Select>
</Col>
</Row>
) : null
}
{(provider.type === "GC" || provider.type === "FastSpring") ? (
<Row style={{marginTop: "20px"}}>
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Host"), i18next.t("provider:Host - Tooltip"))} :
</Col>
<Col span={22}>
<Input prefix={<LinkOutlined />} value={provider.host} onChange={e => {
updateProviderField("host", e.target.value);
}} />
</Col>
</Row>
) : null}
</React.Fragment>
);
}

View File

@@ -0,0 +1,133 @@
// Copyright 2026 The Casdoor 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 {Button, Col, Input, Row, Switch} from "antd";
import * as Setting from "../Setting";
import i18next from "i18next";
import {authConfig} from "../auth/Auth";
import copy from "copy-to-clipboard";
const {TextArea} = Input;
export function renderSamlProviderFields(provider, updateProviderField, metadataConfig) {
const {requestUrl, setRequestUrl, metadataLoading, fetchSamlMetadata, parseSamlMetadata} = metadataConfig;
return (
<React.Fragment>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Sign request"), i18next.t("provider:Sign request - Tooltip"))} :
</Col>
<Col span={22} >
<Switch checked={provider.enableSignAuthnRequest} onChange={checked => {
updateProviderField("enableSignAuthnRequest", checked);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Metadata url"), i18next.t("provider:Metadata url - Tooltip"))} :
</Col>
<Col span={6} >
<Input value={requestUrl} onChange={e => {
setRequestUrl(e.target.value);
}} />
</Col>
<Col span={16} >
<Button style={{marginLeft: "10px"}} type="primary" loading={metadataLoading} onClick={() => {fetchSamlMetadata();}}>{i18next.t("general:Request")}</Button>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Metadata"), i18next.t("provider:Metadata - Tooltip"))} :
</Col>
<Col span={22}>
<TextArea rows={4} value={provider.metadata} onChange={e => {
updateProviderField("metadata", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}}>
<Col style={{marginTop: "5px"}} span={2} />
<Col span={2}>
<Button type="primary" onClick={() => {parseSamlMetadata();}}>
{i18next.t("provider:Parse")}
</Button>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Endpoint"), i18next.t("provider:SAML 2.0 Endpoint (HTTP)"))} :
</Col>
<Col span={22} >
<Input value={provider.endpoint} onChange={e => {
updateProviderField("endpoint", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:IdP"), i18next.t("provider:IdP certificate"))} :
</Col>
<Col span={22} >
<Input value={provider.idP} onChange={e => {
updateProviderField("idP", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Issuer URL"), i18next.t("provider:Issuer URL - Tooltip"))} :
</Col>
<Col span={22} >
<Input value={provider.issuerUrl} onChange={e => {
updateProviderField("issuerUrl", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:SP ACS URL"), i18next.t("provider:SP ACS URL - Tooltip"))} :
</Col>
<Col span={21} >
<Input value={`${authConfig.serverUrl}/api/acs`} readOnly="readonly" />
</Col>
<Col span={1}>
<Button type="primary" onClick={() => {
copy(`${authConfig.serverUrl}/api/acs`);
Setting.showMessage("success", i18next.t("general:Copied to clipboard successfully"));
}}>
{i18next.t("general:Copy")}
</Button>
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:SP Entity ID"), i18next.t("provider:SP Entity ID - Tooltip"))} :
</Col>
<Col span={21} >
<Input value={`${authConfig.serverUrl}/api/acs`} readOnly="readonly" />
</Col>
<Col span={1}>
<Button type="primary" onClick={() => {
copy(`${authConfig.serverUrl}/api/acs`);
Setting.showMessage("success", i18next.t("general:Copied to clipboard successfully"));
}}>
{i18next.t("general:Copy")}
</Button>
</Col>
</Row>
</React.Fragment>
);
}

View File

@@ -0,0 +1,182 @@
// Copyright 2026 The Casdoor 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 {Button, Col, Input, Row, Select, Switch} from "antd";
import * as Setting from "../Setting";
import i18next from "i18next";
import * as ProviderEditTestSms from "../common/TestSmsWidget";
import {CountryCodeSelect} from "../common/select/CountryCodeSelect";
import HttpHeaderTable from "../table/HttpHeaderTable";
import {LinkOutlined} from "@ant-design/icons";
const {Option} = Select;
const SMS_PROVIDERS_WITHOUT_SIGN_NAME = ["Custom HTTP SMS", "Twilio SMS", "Amazon SNS", "Msg91 SMS", "Infobip SMS"];
const SMS_PROVIDERS_WITHOUT_TEMPLATE_CODE = ["Infobip SMS", "Custom HTTP SMS"];
export function renderSmsProviderFields(provider, updateProviderField, renderSmsMappingInput, account) {
return (
<React.Fragment>
{SMS_PROVIDERS_WITHOUT_SIGN_NAME.includes(provider.type) ?
null :
(<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Sign Name"), i18next.t("provider:Sign Name - Tooltip"))} :
</Col>
<Col span={22} >
<Input value={provider.signName} onChange={e => {
updateProviderField("signName", e.target.value);
}} />
</Col>
</Row>
)
}
{SMS_PROVIDERS_WITHOUT_TEMPLATE_CODE.includes(provider.type) ?
null :
(<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Template code"), i18next.t("provider:Template code - Tooltip"))} :
</Col>
<Col span={22} >
<Input value={provider.templateCode} onChange={e => {
updateProviderField("templateCode", e.target.value);
}} />
</Col>
</Row>
)
}
{
provider.type === "Custom HTTP SMS" ? (
<React.Fragment>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{Setting.getLabel(i18next.t("provider:Endpoint"), i18next.t("provider:Region endpoint for Internet"))} :
</Col>
<Col span={22} >
<Input prefix={<LinkOutlined />} value={provider.endpoint} onChange={e => {
updateProviderField("endpoint", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("general:Method"), i18next.t("provider:Method - Tooltip"))} :
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={provider.method} onChange={value => {
updateProviderField("method", value);
}}>
{
[
{id: "GET", name: "GET"},
{id: "POST", name: "POST"},
{id: "PUT", name: "PUT"},
{id: "DELETE", name: "DELETE"},
].map((method, index) => <Option key={index} value={method.id}>{method.name}</Option>)
}
</Select>
</Col>
</Row>
{
provider.method !== "GET" ? (<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("webhook:Content type"), i18next.t("webhook:Content type - Tooltip"))} :
</Col>
<Col span={22} >
<Select virtual={false} style={{width: "100%"}} value={provider.issuerUrl === "" ? "application/x-www-form-urlencoded" : provider.issuerUrl} onChange={value => {
updateProviderField("issuerUrl", value);
}}>
{
[
{id: "application/json", name: "application/json"},
{id: "application/x-www-form-urlencoded", name: "application/x-www-form-urlencoded"},
].map((method, index) => <Option key={index} value={method.id}>{method.name}</Option>)
}
</Select>
</Col>
</Row>) : null
}
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:HTTP header"), i18next.t("provider:HTTP header - Tooltip"))} :
</Col>
<Col span={22} >
<HttpHeaderTable httpHeaders={provider.httpHeaders} onUpdateTable={(value) => {updateProviderField("httpHeaders", value);}} />
</Col>
</Row>
{provider.method !== "GET" ? <Row style={{marginTop: "20px"}}>
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:HTTP body mapping"), i18next.t("provider:HTTP body mapping - Tooltip"))} :
</Col>
<Col span={22}>
{renderSmsMappingInput()}
</Col>
</Row> : null}
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Parameter"), i18next.t("provider:Parameter - Tooltip"))} :
</Col>
<Col span={22} >
<Input value={provider.title} onChange={e => {
updateProviderField("title", e.target.value);
}} />
</Col>
</Row>
</React.Fragment>
) : null
}
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Enable proxy"), i18next.t("provider:Enable proxy - Tooltip"))} :
</Col>
<Col span={1} >
<Switch checked={provider.enableProxy} onChange={checked => {
updateProviderField("enableProxy", checked);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:SMS Test"), i18next.t("provider:SMS Test - Tooltip"))} :
</Col>
<Col span={4} >
<Input.Group compact>
<CountryCodeSelect
style={{width: "90px"}}
initValue={provider.content}
onChange={(value) => {
updateProviderField("content", value);
}}
countryCodes={account.organization.countryCodes}
/>
<Input value={provider.receiver}
style={{width: "150px"}}
placeholder = {i18next.t("user:Input your phone number")}
onChange={e => {
updateProviderField("receiver", e.target.value);
}} />
</Input.Group>
</Col>
<Col span={2} >
<Button style={{marginLeft: "10px", marginBottom: "5px"}} type="primary"
disabled={!Setting.isValidPhone(provider.receiver) || (provider.type === "Custom HTTP SMS" && provider.endpoint === "")}
onClick={() => ProviderEditTestSms.sendTestSms(provider, "+" + Setting.getCountryCode(provider.content) + provider.receiver)} >
{i18next.t("provider:Send Testing SMS")}
</Button>
</Col>
</Row>
</React.Fragment>
);
}

View File

@@ -0,0 +1,112 @@
// Copyright 2026 The Casdoor 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 {Col, Input, Row} from "antd";
import {LinkOutlined} from "@ant-design/icons";
import * as Setting from "../Setting";
import i18next from "i18next";
export function renderStorageProviderFields(provider, updateProviderField) {
return (
<React.Fragment>
{["Local File System", "MinIO", "Tencent Cloud COS", "Google Cloud Storage", "Qiniu Cloud Kodo", "Synology", "Casdoor"].includes(provider.type) ? null : (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{Setting.getLabel(i18next.t("provider:Endpoint (Intranet)"), i18next.t("provider:Region endpoint for Intranet"))} :
</Col>
<Col span={22} >
<Input prefix={<LinkOutlined />} value={provider.intranetEndpoint} onChange={e => {
updateProviderField("intranetEndpoint", e.target.value);
}} />
</Col>
</Row>
)}
{["Local File System"].includes(provider.type) ? null : (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{Setting.getLabel(i18next.t("provider:Endpoint"), i18next.t("provider:Region endpoint for Internet"))} :
</Col>
<Col span={22} >
<Input prefix={<LinkOutlined />} value={provider.endpoint} onChange={e => {
updateProviderField("endpoint", e.target.value);
}} />
</Col>
</Row>
)}
{["Local File System"].includes(provider.type) ? null : (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{["Casdoor"].includes(provider.type) ?
Setting.getLabel(i18next.t("general:Provider"), i18next.t("general:Provider - Tooltip"))
: Setting.getLabel(i18next.t("provider:Bucket"), i18next.t("provider:Bucket - Tooltip"))} :
</Col>
<Col span={22} >
<Input value={provider.bucket} onChange={e => {
updateProviderField("bucket", e.target.value);
}} />
</Col>
</Row>
)}
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{Setting.getLabel(i18next.t("provider:Path prefix"), i18next.t("provider:Path prefix - Tooltip"))} :
</Col>
<Col span={22} >
<Input value={provider.pathPrefix} onChange={e => {
updateProviderField("pathPrefix", e.target.value);
}} />
</Col>
</Row>
{["Synology", "Casdoor"].includes(provider.type) ? null : (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{Setting.getLabel(i18next.t("provider:Domain"), i18next.t("provider:Domain - Tooltip"))} :
</Col>
<Col span={22} >
<Input prefix={<LinkOutlined />} value={provider.domain} disabled={provider.type === "Local File System"} onChange={e => {
updateProviderField("domain", e.target.value);
}} />
</Col>
</Row>
)}
{["Casdoor"].includes(provider.type) ? (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{Setting.getLabel(i18next.t("general:Organization"), i18next.t("general:Organization - Tooltip"))} :
</Col>
<Col span={22} >
<Input value={provider.content} onChange={e => {
updateProviderField("content", e.target.value);
}} />
</Col>
</Row>
) : null}
{["AWS S3", "Tencent Cloud COS", "Qiniu Cloud Kodo", "Casdoor", "CUCloud OSS", "MinIO"].includes(provider.type) ? (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={2}>
{["Casdoor"].includes(provider.type) ?
Setting.getLabel(i18next.t("general:Application"), i18next.t("general:Application - Tooltip")) :
Setting.getLabel(i18next.t("provider:Region ID"), i18next.t("provider:Region ID - Tooltip"))} :
</Col>
<Col span={22} >
<Input value={provider.regionId} onChange={e => {
updateProviderField("regionId", e.target.value);
}} />
</Col>
</Row>
) : null}
</React.Fragment>
);
}

View File

@@ -0,0 +1,48 @@
// Copyright 2026 The Casdoor 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 {Checkbox, Col, Row} from "antd";
import * as Setting from "../Setting";
import i18next from "i18next";
import * as Web3Auth from "../auth/Web3Auth";
export function renderWeb3ProviderFields(provider, updateProviderField) {
const getWalletValue = () => {
try {
return JSON.parse(provider.metadata);
} catch {
return ["injected"];
}
};
return (
provider.type === "Web3Onboard" ? (
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{Setting.getLabel(i18next.t("provider:Wallets"), i18next.t("provider:Wallets - Tooltip"))} :
</Col>
<Col span={22}>
<Checkbox.Group
options={Web3Auth.getWeb3OnboardWalletsOptions()}
value={getWalletValue()}
onChange={options => {
updateProviderField("metadata", JSON.stringify(options));
}}
/>
</Col>
</Row>
) : null
);
}

View File

@@ -14,12 +14,16 @@
import React from "react";
import {DeleteOutlined, DownOutlined, UpOutlined} from "@ant-design/icons";
import {Button, Col, Input, Row, Select, Table, Tooltip} from "antd";
import {AutoComplete, Button, Col, Input, Row, Table, Tooltip} from "antd";
import * as Setting from "../Setting";
import i18next from "i18next";
import RegionSelect from "../common/select/RegionSelect";
const {Option} = Select;
const TAG_OPTIONS = [
{value: "Home", label: "Home"},
{value: "Work", label: "Work"},
{value: "Other", label: "Other"},
];
class AddressTable extends React.Component {
constructor(props) {
@@ -86,16 +90,20 @@ class AddressTable extends React.Component {
key: "tag",
width: "100px",
render: (text, record, index) => {
const tagOptions = TAG_OPTIONS.map(opt => ({...opt, label: opt.value === "Home" ? i18next.t("general:Home") : opt.value === "Work" ? i18next.t("user:Work") : i18next.t("user:Other")}));
return (
<Select virtual={false} style={{width: "100%"}}
value={text}
<AutoComplete
size="small"
style={{width: "100%"}}
value={text || ""}
options={tagOptions}
onChange={value => {
this.updateField(table, index, "tag", value);
}} >
<Option value="Home">{i18next.t("general:Home")}</Option>
<Option value="Work">{i18next.t("user:Work")}</Option>
<Option value="Other">{i18next.t("user:Other")}</Option>
</Select>
}}
onSelect={value => {
this.updateField(table, index, "tag", value);
}}
/>
);
},
},
@@ -106,7 +114,7 @@ class AddressTable extends React.Component {
width: "150px",
render: (text, record, index) => {
return (
<Input value={text} onChange={e => {
<Input size="small" value={text} onChange={e => {
this.updateField(table, index, "line1", e.target.value);
}} />
);
@@ -119,7 +127,7 @@ class AddressTable extends React.Component {
width: "150px",
render: (text, record, index) => {
return (
<Input value={text} onChange={e => {
<Input size="small" value={text} onChange={e => {
this.updateField(table, index, "line2", e.target.value);
}} />
);
@@ -132,7 +140,7 @@ class AddressTable extends React.Component {
width: "120px",
render: (text, record, index) => {
return (
<Input value={text} onChange={e => {
<Input size="small" value={text} onChange={e => {
this.updateField(table, index, "city", e.target.value);
}} />
);
@@ -145,7 +153,7 @@ class AddressTable extends React.Component {
width: "100px",
render: (text, record, index) => {
return (
<Input value={text} onChange={e => {
<Input size="small" value={text} onChange={e => {
this.updateField(table, index, "state", e.target.value);
}} />
);
@@ -158,7 +166,7 @@ class AddressTable extends React.Component {
width: "100px",
render: (text, record, index) => {
return (
<Input value={text} onChange={e => {
<Input size="small" value={text} onChange={e => {
this.updateField(table, index, "zipCode", e.target.value);
}} />
);
@@ -172,6 +180,7 @@ class AddressTable extends React.Component {
render: (text, record, index) => {
return (
<RegionSelect
size="small"
value={text}
onChange={value => {
this.updateField(table, index, "region", value);

View File

@@ -0,0 +1,132 @@
// Copyright 2026 The Casdoor 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 {Button, Popconfirm, Table, Tag} from "antd";
import * as Setting from "../Setting";
import i18next from "i18next";
import * as ConsentBackend from "../backend/ConsentBackend";
class ConsentTable extends React.Component {
constructor(props) {
super(props);
this.state = {
classes: props,
};
}
deleteScope(record, scopeToDelete) {
ConsentBackend.revokeConsent({
application: record.application,
grantedScopes: scopeToDelete ? [scopeToDelete] : record.grantedScopes,
})
.then((res) => {
if (res.status === "ok") {
Setting.showMessage("success", i18next.t("general:Successfully revoked"));
this.props.onUpdateTable();
} else {
Setting.showMessage("error", res.msg);
}
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
});
}
renderTable(table) {
const columns = [
{
title: i18next.t("general:Application"),
dataIndex: "application",
key: "application",
width: "200px",
render: (text) => {
return text;
},
},
{
title: i18next.t("consent:Granted scopes"),
dataIndex: "grantedScopes",
key: "grantedScopes",
render: (text, record) => {
return (
<div style={{display: "flex", flexWrap: "wrap", gap: "4px"}}>
{
(Array.isArray(text) ? text : []).map((scope, index) => {
return (
<Popconfirm
key={index}
title={`${i18next.t("consent:Are you sure you want to revoke scope")}: ${scope}?`}
onConfirm={() => this.deleteScope(record, scope)}
okText={i18next.t("general:OK")}
cancelText={i18next.t("general:Cancel")}
>
<Tag
color="blue"
style={{cursor: "pointer"}}
>
{scope}
</Tag>
</Popconfirm>
);
})
}
</div>
);
},
},
{
title: i18next.t("general:Action"),
key: "action",
width: "100px",
render: (_, record, __) => {
return (
<Popconfirm
title={i18next.t("consent:Are you sure you want to revoke this consent?")}
onConfirm={() => this.deleteScope(record)}
okText={i18next.t("general:OK")}
cancelText={i18next.t("general:Cancel")}
>
<Button type="primary" danger size="small">
{i18next.t("consent:Delete")}
</Button>
</Popconfirm>
);
},
},
];
return (
<Table scroll={{x: "max-content"}} rowKey="application" columns={columns} dataSource={table} size="middle" bordered pagination={false}
title={() => (
<div>
{this.props.title}
</div>
)}
/>
);
}
render() {
return (
<div>
{
this.renderTable(this.props.table)
}
</div>
);
}
}
export default ConsentTable;

View File

@@ -0,0 +1,208 @@
// Copyright 2026 The Casdoor 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 {DeleteOutlined, DownOutlined, UpOutlined} from "@ant-design/icons";
import {AutoComplete, Button, Col, Input, Row, Table, Tooltip} from "antd";
import * as Setting from "../Setting";
import i18next from "i18next";
const DefaultScopes = [
{scope: "openid", displayName: "OpenID", description: "Authenticate the user and obtain an ID token"},
{scope: "profile", displayName: "Profile", description: "Read all user profile data"},
{scope: "email", displayName: "Email", description: "Access user email addresses (read-only)"},
{scope: "address", displayName: "Address", description: "Access the user's address information"},
{scope: "phone", displayName: "Phone", description: "Access the user's phone number information"},
{scope: "offline_access", displayName: "Offline Access", description: "Obtain refresh tokens for offline access"},
];
class CustomScopeTable extends React.Component {
constructor(props) {
super(props);
this.state = {
classes: props,
};
}
normalizeScope(scope) {
return (scope || "").trim().toLowerCase();
}
getAvailableDefaultScopes(table) {
const existingScopes = new Set((table || []).map(item => this.normalizeScope(item?.scope)).filter(Boolean));
return DefaultScopes.filter(item => !existingScopes.has(this.normalizeScope(item.scope)));
}
updateTable(table) {
this.props.onUpdateTable(table);
}
updateField(table, index, key, value) {
table[index][key] = value;
this.updateTable(table);
}
isScopeMissing(row) {
if (!row) {
return true;
}
const scope = (row.scope || "").trim();
return scope === "";
}
addRow(table) {
const row = {scope: "", displayName: "", description: ""};
if (table === undefined || table === null) {
table = [];
}
table = Setting.addRow(table, row);
this.updateTable(table);
}
deleteRow(table, i) {
table = Setting.deleteRow(table, i);
this.updateTable(table);
}
upRow(table, i) {
table = Setting.swapRow(table, i - 1, i);
this.updateTable(table);
}
downRow(table, i) {
table = Setting.swapRow(table, i, i + 1);
this.updateTable(table);
}
renderTable(table) {
table = table || [];
const columns = [
{
title: (
<div style={{display: "flex", alignItems: "center", gap: "8px"}}>
<span className="ant-form-item-required">{i18next.t("general:Name")}</span>
<div style={{color: "red"}}>*</div>
</div>
),
dataIndex: "scope",
key: "scope",
width: "260px",
render: (text, record, index) => {
const availableDefaultScopes = this.getAvailableDefaultScopes(table);
const autoCompleteOptions = availableDefaultScopes.map(item => ({
label: `${item.scope}`,
value: item.scope,
}));
return (
<AutoComplete
status={this.isScopeMissing(record) ? "error" : ""}
value={text}
options={autoCompleteOptions}
placeholder="Select or input scope"
onSelect={(value) => {
this.updateField(table, index, "scope", value);
const selectedScope = availableDefaultScopes.find(item => item.scope === value);
if (selectedScope) {
this.updateField(table, index, "displayName", selectedScope.displayName);
this.updateField(table, index, "description", selectedScope.description);
}
}}
onChange={(value) => {
this.updateField(table, index, "scope", value);
}}
>
<Input />
</AutoComplete>
);
},
},
{
title: i18next.t("general:Display name"),
dataIndex: "displayName",
key: "displayName",
width: "200px",
render: (text, _, index) => {
return (
<Input value={text} onChange={e => {
this.updateField(table, index, "displayName", e.target.value);
}} />
);
},
},
{
title: i18next.t("general:Description"),
dataIndex: "description",
key: "description",
render: (text, record, index) => {
return (
<Input value={text} onChange={e => {
this.updateField(table, index, "description", e.target.value);
}} />
);
},
},
{
title: i18next.t("general:Action"),
dataIndex: "action",
key: "action",
width: "110px",
// eslint-disable-next-line
render: (_, __, index) => {
return (
<div>
<Tooltip placement="bottomLeft" title={i18next.t("general:Up")}>
<Button style={{marginRight: "5px"}} disabled={index === 0} icon={<UpOutlined />} size="small" onClick={() => this.upRow(table, index)} />
</Tooltip>
<Tooltip placement="topLeft" title={i18next.t("general:Down")}>
<Button style={{marginRight: "5px"}} disabled={index === table.length - 1} icon={<DownOutlined />} size="small" onClick={() => this.downRow(table, index)} />
</Tooltip>
<Tooltip placement="topLeft" title={i18next.t("general:Delete")}>
<Button icon={<DeleteOutlined />} size="small" onClick={() => this.deleteRow(table, index)} />
</Tooltip>
</div>
);
},
},
];
return (
<Table title={() => (
<div style={{display: "flex", justifyContent: "space-between"}}>
<div style={{marginTop: "5px"}}>{this.props.title}</div>
<Button type="primary" size="small" onClick={() => this.addRow(table)}>{i18next.t("general:Add")}</Button>
</div>
)}
columns={columns} dataSource={table} rowKey={(record, index) => record.scope?.trim() || `temp_${index}`} size="middle" bordered pagination={false}
/>
);
}
render() {
return (
<div>
<Row style={{marginTop: "20px"}} >
<Col span={24}>
{
this.renderTable(this.props.table)
}
</Col>
</Row>
</div>
);
}
}
export default CustomScopeTable;

View File

@@ -86,7 +86,7 @@ class ManagedAccountTable extends React.Component {
render: (text, record, index) => {
const items = this.props.applications;
return (
<Select virtual={false} style={{width: "100%"}}
<Select virtual={false} size="small" style={{width: "100%"}}
value={text}
onChange={value => {
this.updateField(table, index, "application", value);
@@ -105,7 +105,7 @@ class ManagedAccountTable extends React.Component {
// width: "420px",
render: (text, record, index) => {
return (
<Input prefix={<LinkOutlined />} value={text} onChange={e => {
<Input size="small" prefix={<LinkOutlined />} value={text} onChange={e => {
this.updateField(table, index, "signinUrl", e.target.value);
}} />
);
@@ -118,7 +118,7 @@ class ManagedAccountTable extends React.Component {
width: "200px",
render: (text, record, index) => {
return (
<Input value={text} onChange={e => {
<Input size="small" value={text} onChange={e => {
this.updateField(table, index, "username", e.target.value);
}} />
);
@@ -131,7 +131,7 @@ class ManagedAccountTable extends React.Component {
width: "200px",
render: (text, record, index) => {
return (
<Input.Password value={text} onChange={e => {
<Input.Password size="small" value={text} onChange={e => {
this.updateField(table, index, "password", e.target.value);
}} />
);

View File

@@ -86,7 +86,7 @@ class MfaAccountTable extends React.Component {
width: "400px",
render: (text, record, index) => {
return (
<Input value={text} onChange={e => {
<Input size="small" value={text} onChange={e => {
this.updateField(table, index, "accountName", e.target.value);
}} />
);
@@ -99,7 +99,7 @@ class MfaAccountTable extends React.Component {
width: "300px",
render: (text, record, index) => {
return (
<Input value={text} onChange={e => {
<Input size="small" value={text} onChange={e => {
this.updateField(table, index, "issuer", e.target.value);
}} />
);
@@ -111,7 +111,7 @@ class MfaAccountTable extends React.Component {
key: "origin",
render: (text, record, index) => {
return (
<Input value={text} onChange={e => {
<Input size="small" value={text} onChange={e => {
this.updateField(table, index, "origin", e.target.value);
}} />
);
@@ -123,7 +123,7 @@ class MfaAccountTable extends React.Component {
key: "secretKey",
render: (text, record, index) => {
return (
<Input.Password value={text} onChange={e => {
<Input.Password size="small" value={text} onChange={e => {
this.updateField(table, index, "secretKey", e.target.value);
}} />
);

View File

@@ -84,7 +84,7 @@ class MfaTable extends React.Component {
key: "name",
render: (text, record, index) => {
return (
<Select virtual={false} style={{width: "100%"}}
<Select virtual={false} size="small" style={{width: "100%"}}
value={text}
onChange={value => {
this.updateField(table, index, "name", value);
@@ -103,7 +103,7 @@ class MfaTable extends React.Component {
width: "100px",
render: (text, record, index) => {
return (
<Select virtual={false} style={{width: "100%"}}
<Select virtual={false} size="small" style={{width: "100%"}}
value={text}
defaultValue="Optional"
options={RuleItems.map((item) =>

View File

@@ -110,7 +110,17 @@ class ProviderTable extends React.Component {
width: "100px",
render: (text, record, index) => {
const provider = Setting.getArrayItem(this.props.providers, "name", record.name);
return provider?.category;
const owner = provider?.owner || this.getUserOrganization()?.name;
const editUrl = provider && owner && provider.name ? `/providers/${owner}/${provider.name}` : null;
const categoryText = provider?.category;
if (editUrl && categoryText) {
return (
<a href={editUrl} target="_blank" rel="noopener noreferrer">
{categoryText}
</a>
);
}
return categoryText;
},
},
{
@@ -120,7 +130,17 @@ class ProviderTable extends React.Component {
width: "80px",
render: (text, record, index) => {
const provider = Setting.getArrayItem(this.props.providers, "name", record.name);
return Provider.getProviderLogoWidget(provider);
const owner = provider?.owner || this.getUserOrganization()?.name;
const editUrl = provider && owner && provider.name ? `/providers/${owner}/${provider.name}` : null;
const typeWidget = Provider.getProviderLogoWidget(provider, {disableLink: !!editUrl});
if (editUrl && typeWidget) {
return (
<a href={editUrl} target="_blank" rel="noopener noreferrer">
{typeWidget}
</a>
);
}
return typeWidget;
},
},
{

View File

@@ -131,13 +131,18 @@ class TransactionTable extends React.Component {
columns={columns}
dataSource={this.props.transactions}
rowKey={(record) => `${record.owner}/${record.name}`}
size="small"
size="middle"
bordered
pagination={{
pageSize: 10,
showSizeChanger: true,
pageSizeOptions: ["10", "20", "50", "100"],
}}
title={this.props.title ? () => (
<div>
{this.props.title}&nbsp;&nbsp;&nbsp;&nbsp;
</div>
) : undefined}
/>
);
}