forked from casdoor/casdoor
feat: add OAuth consent page
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
226
controllers/consent.go
Normal file
226
controllers/consent.go
Normal 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)
|
||||
}
|
||||
@@ -156,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"`
|
||||
@@ -746,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
|
||||
}
|
||||
@@ -801,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
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@ func getBuiltInAccountItems() []*AccountItem {
|
||||
{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"},
|
||||
|
||||
@@ -242,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 {
|
||||
@@ -871,7 +872,7 @@ func UpdateUser(id string, user *User, columns []string, isAdmin bool) (bool, er
|
||||
"microsoftonline", "naver", "nextcloud", "onedrive", "oura", "patreon", "paypal", "salesforce", "shopify", "soundcloud",
|
||||
"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",
|
||||
"cart", "application_scopes",
|
||||
}
|
||||
}
|
||||
if isAdmin {
|
||||
|
||||
119
object/user_scope.go
Normal file
119
object/user_scope.go
Normal 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
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}) => {
|
||||
|
||||
@@ -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;
|
||||
@@ -1647,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) => {
|
||||
|
||||
@@ -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} />)} />
|
||||
|
||||
@@ -98,6 +98,7 @@ 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"},
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -1129,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 : (
|
||||
|
||||
260
web/src/auth/ConsentPage.js
Normal file
260
web/src/auth/ConsentPage.js
Normal 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);
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
50
web/src/backend/ConsentBackend.js
Normal file
50
web/src/backend/ConsentBackend.js
Normal 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());
|
||||
}
|
||||
132
web/src/table/ConsentTable.js
Normal file
132
web/src/table/ConsentTable.js
Normal 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;
|
||||
208
web/src/table/CustomScopeTable.js
Normal file
208
web/src/table/CustomScopeTable.js
Normal 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;
|
||||
Reference in New Issue
Block a user