Compare commits

...

26 Commits

Author SHA1 Message Date
6a1ec51978 feat: fix SSRF when download avatar (#1193) 2022-10-20 14:47:08 +08:00
dffa68cbce feat: fix SAML login error bug (#1228)
* Update LoginPage.js

* fix saml login error
2022-10-20 01:14:38 +08:00
fad209a7a3 Don't check username in UpdateUser() API 2022-10-19 22:50:19 +08:00
8b222ce2e3 Use Steam ID as username 2022-10-18 22:07:20 +08:00
c5293f428d fix: delete this accidentally added files (#1229)
* fix: delete this accidentally added files

* fix: ignore build result

* fix: remove unnecessary asterisk
2022-10-18 21:55:34 +08:00
146aec9ee8 feat: skip username restriction for new users coming from OAuth providers. (#1225) 2022-10-17 18:01:01 +08:00
50a52de856 feat: support database version control (#1221)
* feat: support Database version control

* Update adapter.go

* fix review problems

* Update adapter.go

Co-authored-by: Yang Luo <hsluoyz@qq.com>
2022-10-15 17:20:20 +08:00
8f7a8d7d4f fix: translation without reloading (#1215)
* fix: translation without reloading

* fix: language switch
2022-10-12 19:52:02 +08:00
23f3fe1e3c feat: update code format (#1214)
* feat: doc

* feat: doc

* Update model.go

Co-authored-by: Gucheng <85475922+nomeguy@users.noreply.github.com>
2022-10-12 11:42:14 +08:00
59ff5e02ab fix: Add support for including underscores for username (#1210)
* fix: Add support for including underscores for username

* Update check.go

Co-authored-by: Yang Luo <hsluoyz@qq.com>
2022-10-11 19:39:19 +08:00
8d41508d6b fix: center loading in account page (#1209)
* fix: center loading in account page

* Update UserEditPage.js

Co-authored-by: Yang Luo <hsluoyz@qq.com>
2022-10-11 00:52:08 +08:00
04f70cf012 Improve renderRightDropdown() 2022-10-10 22:53:47 +08:00
83724c73f9 feat: fix pad and mobile views (#1202)
* fix figure width

* fix: pad resolution menu

* feat: drawer style mobile menu

* fix: menu button i18n
2022-10-10 22:37:25 +08:00
33e419e133 Show more items to org admin 2022-10-10 21:58:17 +08:00
b832c304ae Can get owner in getObject() 2022-10-10 20:56:55 +08:00
4c7f6fda37 fix: Add restriction to username when signing up (#1203) 2022-10-10 19:58:02 +08:00
e4a54fe375 fix: disable roles inputbox when model doesn't support RBAC (#1201)
* feat:Support simple ldap server

* fix:fix review problems

* fix:fix review problems

* fix: fix ldapserver crash bug

* Update ldapserver.go

* fix: fix dulpicate go routines

* fix gofumpt problems

* fix: fix UserList error

* feat:disable 'sub role' when model is incorrect

* feat:disable 'sub role' when model is incorrect

* feat:disable 'sub role' when model is incorrect

* delete useless output

* update func name

* Update PermissionEditPage.js

* Update PermissionEditPage.js

Co-authored-by: Yang Luo <hsluoyz@qq.com>
2022-10-10 00:53:55 +08:00
87da3dad76 Remove useless file 2022-10-09 22:18:38 +08:00
44ad88353f Add error to GetDefaultApplication() 2022-10-09 10:39:33 +08:00
a955fb57d6 feat: fix UserList error (#1194)
* feat:Support simple ldap server

* fix:fix review problems

* fix:fix review problems

* fix: fix ldapserver crash bug

* Update ldapserver.go

* fix: fix dulpicate go routines

* fix gofumpt problems

* fix: fix UserList error
2022-10-08 20:00:45 +08:00
d2960ad66b Fix README typo 2022-10-08 16:00:08 +08:00
5243aabf43 docs: Create SECURITY.md (#1192) 2022-10-07 19:02:35 +08:00
d3a2c2a66e Improve org admin permissions 2022-10-07 16:27:21 +08:00
0a9058a585 Improve user list page 2022-10-07 15:43:50 +08:00
225719810b Update link typo in README 2022-10-06 19:37:00 +08:00
c634d4a891 feat: add some css style for the custom Provider button (#1185)
* fix: add some css style for the custom button

* fix: refactor previous code

* fix: add i18 adaptation

* fix: modifiy the saml codition
2022-10-06 19:28:02 +08:00
39 changed files with 294 additions and 76 deletions

0
$env
View File

3
.gitignore vendored
View File

@ -27,3 +27,6 @@ logs/
files/ files/
lastupdate.tmp lastupdate.tmp
commentsRouter*.go commentsRouter*.go
# ignore build result
casdoor

View File

@ -51,7 +51,7 @@
## Documentation ## Documentation
- International: https://casdoor.org - International: https://casdoor.org
- Asian mirror: https://docs.casdoor.cn - Asian mirror: https://casdoor.cn
## Install ## Install
@ -69,7 +69,7 @@ https://casdoor.org/docs/how-to-connect/overview
## Integrations ## Integrations
https://casdoor.org/docs/integration/apisix https://casdoor.org/docs/category/integrations
## How to contact? ## How to contact?

9
SECURITY.md Normal file
View File

@ -0,0 +1,9 @@
# Security Policy
## Reporting a Vulnerability
We are grateful for security researchers and users reporting a vulnerability to us first. To ensure that your request is handled in a timely manner and we can keep users safe, please follow the below guidelines.
- **Please do not report security vulnerabilities directly on GitHub.**
- To report a vulnerability, please email [admin@casdoor.org](admin@casdoor.org).

View File

@ -15,12 +15,14 @@
package authz package authz
import ( import (
"fmt"
"strings" "strings"
"github.com/casbin/casbin/v2" "github.com/casbin/casbin/v2"
"github.com/casbin/casbin/v2/model" "github.com/casbin/casbin/v2/model"
xormadapter "github.com/casbin/xorm-adapter/v3" xormadapter "github.com/casbin/xorm-adapter/v3"
"github.com/casdoor/casdoor/conf" "github.com/casdoor/casdoor/conf"
"github.com/casdoor/casdoor/object"
stringadapter "github.com/qiangmzsx/string-adapter/v2" stringadapter "github.com/qiangmzsx/string-adapter/v2"
) )
@ -138,6 +140,12 @@ func IsAllowed(subOwner string, subName string, method string, urlPath string, o
} }
} }
userId := fmt.Sprintf("%s/%s", subOwner, subName)
user := object.GetUser(userId)
if user != nil && user.IsAdmin && subOwner == objOwner {
return true
}
res, err := Enforcer.Enforce(subOwner, subName, method, urlPath, objOwner, objName) res, err := Enforcer.Enforce(subOwner, subName, method, urlPath, objOwner, objName)
if err != nil { if err != nil {
panic(err) panic(err)

BIN
casdoor

Binary file not shown.

View File

@ -133,11 +133,12 @@ func (c *ApiController) GetDefaultApplication() {
userId := c.GetSessionUsername() userId := c.GetSessionUsername()
id := c.Input().Get("id") id := c.Input().Get("id")
application := object.GetMaskedApplication(object.GetDefaultApplication(id), userId) application, err := object.GetDefaultApplication(id)
if application == nil { if err != nil {
c.ResponseError("Please set a default application for this organization") c.ResponseError(err.Error())
return return
} }
c.ResponseOk(application) maskedApplication := object.GetMaskedApplication(application, userId)
c.ResponseOk(maskedApplication)
} }

View File

@ -183,6 +183,12 @@ func (c *ApiController) AddUser() {
return return
} }
msg := object.CheckUsername(user.Name)
if msg != "" {
c.ResponseError(msg)
return
}
c.Data["json"] = wrapActionResponse(object.AddUser(&user)) c.Data["json"] = wrapActionResponse(object.AddUser(&user))
c.ServeJSON() c.ServeJSON()
} }

View File

@ -282,7 +282,7 @@ func getUser(gothUser goth.User, provider string) *UserInfo {
} }
} }
if provider == "steam" { if provider == "steam" {
user.Username = user.DisplayName user.Username = user.Id
user.Email = "" user.Email = ""
} }
return &user return &user

View File

@ -19,11 +19,13 @@ import (
"runtime" "runtime"
"github.com/beego/beego" "github.com/beego/beego"
xormadapter "github.com/casbin/xorm-adapter/v3"
"github.com/casdoor/casdoor/conf" "github.com/casdoor/casdoor/conf"
"github.com/casdoor/casdoor/util" "github.com/casdoor/casdoor/util"
_ "github.com/denisenkom/go-mssqldb" // db = mssql _ "github.com/denisenkom/go-mssqldb" // db = mssql
_ "github.com/go-sql-driver/mysql" // db = mysql _ "github.com/go-sql-driver/mysql" // db = mysql
_ "github.com/lib/pq" // db = postgres _ "github.com/lib/pq" // db = postgres
"xorm.io/xorm/migrate"
//_ "github.com/mattn/go-sqlite3" // db = sqlite3 //_ "github.com/mattn/go-sqlite3" // db = sqlite3
"xorm.io/core" "xorm.io/core"
"xorm.io/xorm" "xorm.io/xorm"
@ -40,6 +42,7 @@ func InitConfig() {
beego.BConfig.WebConfig.Session.SessionOn = true beego.BConfig.WebConfig.Session.SessionOn = true
InitAdapter(true) InitAdapter(true)
initMigrations()
} }
func InitAdapter(createDatabase bool) { func InitAdapter(createDatabase bool) {
@ -214,6 +217,11 @@ func (a *Adapter) createTable() {
if err != nil { if err != nil {
panic(err) panic(err)
} }
err = a.Engine.Sync2(new(xormadapter.CasbinRule))
if err != nil {
panic(err)
}
} }
func GetSession(owner string, offset, limit int, field, value, sortField, sortOrder string) *xorm.Session { func GetSession(owner string, offset, limit int, field, value, sortField, sortOrder string) *xorm.Session {
@ -239,3 +247,22 @@ func GetSession(owner string, offset, limit int, field, value, sortField, sortOr
} }
return session return session
} }
func initMigrations() {
migrations := []*migrate.Migration{
{
ID: "20221015CasbinRule--fill ptype field with p",
Migrate: func(tx *xorm.Engine) error {
_, err := tx.Cols("ptype").Update(&xormadapter.CasbinRule{
Ptype: "p",
})
return err
},
Rollback: func(tx *xorm.Engine) error {
return tx.DropTables(&xormadapter.CasbinRule{})
},
},
}
m := migrate.New(adapter.Engine, migrate.DefaultOptions, migrations)
m.Migrate()
}

View File

@ -50,7 +50,7 @@ func downloadFile(url string) (*bytes.Buffer, error) {
return fileBuffer, nil return fileBuffer, nil
} }
func getPermanentAvatarUrl(organization string, username string, url string) string { func getPermanentAvatarUrl(organization string, username string, url string, upload bool) string {
if url == "" { if url == "" {
return "" return ""
} }
@ -62,6 +62,14 @@ func getPermanentAvatarUrl(organization string, username string, url string) str
fullFilePath := fmt.Sprintf("/avatar/%s/%s.png", organization, username) fullFilePath := fmt.Sprintf("/avatar/%s/%s.png", organization, username)
uploadedFileUrl, _ := getUploadFileUrl(defaultStorageProvider, fullFilePath, false) uploadedFileUrl, _ := getUploadFileUrl(defaultStorageProvider, fullFilePath, false)
if upload {
DownloadAndUpload(url, fullFilePath)
}
return uploadedFileUrl
}
func DownloadAndUpload(url string, fullFilePath string) {
fileBuffer, err := downloadFile(url) fileBuffer, err := downloadFile(url)
if err != nil { if err != nil {
panic(err) panic(err)
@ -71,6 +79,4 @@ func getPermanentAvatarUrl(organization string, username string, url string) str
if err != nil { if err != nil {
panic(err) panic(err)
} }
return uploadedFileUrl
} }

View File

@ -32,7 +32,7 @@ func TestSyncPermanentAvatars(t *testing.T) {
continue continue
} }
user.PermanentAvatar = getPermanentAvatarUrl(user.Owner, user.Name, user.Avatar) user.PermanentAvatar = getPermanentAvatarUrl(user.Owner, user.Name, user.Avatar, true)
updateUserColumn("permanent_avatar", user) updateUserColumn("permanent_avatar", user)
fmt.Printf("[%d/%d]: Update user: [%s]'s permanent avatar: %s\n", i, len(users), user.GetId(), user.PermanentAvatar) fmt.Printf("[%d/%d]: Update user: [%s]'s permanent avatar: %s\n", i, len(users), user.GetId(), user.PermanentAvatar)
} }

View File

@ -59,6 +59,11 @@ func CheckUserSignup(application *Application, organization *Organization, usern
if reWhiteSpace.MatchString(username) { if reWhiteSpace.MatchString(username) {
return "username cannot contain white spaces" return "username cannot contain white spaces"
} }
msg := CheckUsername(username)
if msg != "" {
return msg
}
if HasUserByField(organization.Name, "name", username) { if HasUserByField(organization.Name, "name", username) {
return "username already exists" return "username already exists"
} }
@ -313,3 +318,19 @@ func CheckAccessPermission(userId string, application *Application) (bool, error
} }
return allowed, err return allowed, err
} }
func CheckUsername(username string) string {
if username == "" {
return "Empty username."
} else if len(username) > 39 {
return "Username is too long (maximum is 39 characters)."
}
// https://stackoverflow.com/questions/58726546/github-username-convention-using-regex
re, _ := regexp.Compile("^[a-zA-Z0-9]+((?:-[a-zA-Z0-9]+)|(?:_[a-zA-Z0-9]+))*$")
if !re.MatchString(username) {
return "The username may only contain alphanumeric characters, underlines or hyphens, cannot have consecutive hyphens or underlines, and cannot begin or end with a hyphen or underline."
}
return ""
}

View File

@ -409,6 +409,7 @@ func SyncLdapUsers(owner string, users []LdapRespUser, ldapId string) (*[]LdapRe
} }
} }
} }
if !found && !AddUser(&User{ if !found && !AddUser(&User{
Owner: owner, Owner: owner,
Name: buildLdapUserName(user.Uid, user.UidNumber), Name: buildLdapUserName(user.Uid, user.UidNumber),

View File

@ -218,14 +218,14 @@ func CheckAccountItemModifyRule(accountItem *AccountItem, user *User) (bool, str
return true, "" return true, ""
} }
func GetDefaultApplication(id string) *Application { func GetDefaultApplication(id string) (*Application, error) {
organization := GetOrganization(id) organization := GetOrganization(id)
if organization == nil { if organization == nil {
return nil return nil, fmt.Errorf("The organization: %s does not exist", id)
} }
if organization.DefaultApplication != "" { if organization.DefaultApplication != "" {
return getApplication("admin", organization.DefaultApplication) return getApplication("admin", organization.DefaultApplication), fmt.Errorf("The default application: %s does not exist", organization.DefaultApplication)
} }
applications := []*Application{} applications := []*Application{}
@ -235,7 +235,7 @@ func GetDefaultApplication(id string) *Application {
} }
if len(applications) == 0 { if len(applications) == 0 {
return nil return nil, fmt.Errorf("The application does not exist")
} }
defaultApplication := applications[0] defaultApplication := applications[0]
@ -249,5 +249,5 @@ func GetDefaultApplication(id string) *Application {
extendApplicationWithProviders(defaultApplication) extendApplicationWithProviders(defaultApplication)
extendApplicationWithOrg(defaultApplication) extendApplicationWithOrg(defaultApplication)
return defaultApplication return defaultApplication, nil
} }

View File

@ -120,7 +120,7 @@ func (syncer *Syncer) updateUserForOriginalFields(user *User) (bool, error) {
} }
if user.Avatar != oldUser.Avatar && user.Avatar != "" { if user.Avatar != oldUser.Avatar && user.Avatar != "" {
user.PermanentAvatar = getPermanentAvatarUrl(user.Owner, user.Name, user.Avatar) user.PermanentAvatar = getPermanentAvatarUrl(user.Owner, user.Name, user.Avatar, true)
} }
columns := syncer.getCasdoorColumns() columns := syncer.getCasdoorColumns()

View File

@ -703,7 +703,7 @@ func GetWechatMiniProgramToken(application *Application, code string, host strin
} }
// Add new user // Add new user
var name string var name string
if username != "" { if CheckUsername(username) == "" {
name = username name = username
} else { } else {
name = fmt.Sprintf("wechat-%s", openId) name = fmt.Sprintf("wechat-%s", openId)

View File

@ -386,7 +386,7 @@ func UpdateUser(id string, user *User, columns []string, isGlobalAdmin bool) boo
user.UpdateUserHash() user.UpdateUserHash()
if user.Avatar != oldUser.Avatar && user.Avatar != "" && user.PermanentAvatar != "*" { if user.Avatar != oldUser.Avatar && user.Avatar != "" && user.PermanentAvatar != "*" {
user.PermanentAvatar = getPermanentAvatarUrl(user.Owner, user.Name, user.Avatar) user.PermanentAvatar = getPermanentAvatarUrl(user.Owner, user.Name, user.Avatar, false)
} }
if len(columns) == 0 { if len(columns) == 0 {
@ -419,7 +419,7 @@ func UpdateUserForAllFields(id string, user *User) bool {
user.UpdateUserHash() user.UpdateUserHash()
if user.Avatar != oldUser.Avatar && user.Avatar != "" { if user.Avatar != oldUser.Avatar && user.Avatar != "" {
user.PermanentAvatar = getPermanentAvatarUrl(user.Owner, user.Name, user.Avatar) user.PermanentAvatar = getPermanentAvatarUrl(user.Owner, user.Name, user.Avatar, false)
} }
affected, err := adapter.Engine.ID(core.PK{owner, name}).AllCols().Update(user) affected, err := adapter.Engine.ID(core.PK{owner, name}).AllCols().Update(user)
@ -449,7 +449,7 @@ func AddUser(user *User) bool {
user.UpdateUserHash() user.UpdateUserHash()
user.PreHash = user.Hash user.PreHash = user.Hash
user.PermanentAvatar = getPermanentAvatarUrl(user.Owner, user.Name, user.Avatar) user.PermanentAvatar = getPermanentAvatarUrl(user.Owner, user.Name, user.Avatar, false)
user.Ranking = GetUserCount(user.Owner, "", "") + 1 user.Ranking = GetUserCount(user.Owner, "", "") + 1
@ -474,7 +474,7 @@ func AddUsers(users []*User) bool {
user.UpdateUserHash() user.UpdateUserHash()
user.PreHash = user.Hash user.PreHash = user.Hash
user.PermanentAvatar = getPermanentAvatarUrl(user.Owner, user.Name, user.Avatar) user.PermanentAvatar = getPermanentAvatarUrl(user.Owner, user.Name, user.Avatar, true)
} }
affected, err := adapter.Engine.Insert(users) affected, err := adapter.Engine.Insert(users)

View File

@ -159,7 +159,7 @@ func DisableVerificationCode(dest string) {
} }
} }
// from Casnode/object/validateCode.go line 116 // From Casnode/object/validateCode.go line 116
var stdNums = []byte("0123456789") var stdNums = []byte("0123456789")
func getRandomCode(length int) string { func getRandomCode(length int) string {

View File

@ -63,11 +63,16 @@ func getObject(ctx *context.Context) (string, string) {
if method == http.MethodGet { if method == http.MethodGet {
// query == "?id=built-in/admin" // query == "?id=built-in/admin"
id := ctx.Input.Query("id") id := ctx.Input.Query("id")
if id == "" { if id != "" {
return "", "" return util.GetOwnerAndNameFromId(id)
} }
return util.GetOwnerAndNameFromId(id) owner := ctx.Input.Query("owner")
if owner != "" {
return owner, ""
}
return "", ""
} else { } else {
body := ctx.Input.RequestBody body := ctx.Input.RequestBody

View File

@ -16,8 +16,8 @@ import React, {Component} from "react";
import "./App.less"; import "./App.less";
import {Helmet} from "react-helmet"; import {Helmet} from "react-helmet";
import * as Setting from "./Setting"; import * as Setting from "./Setting";
import {DownOutlined, LogoutOutlined, SettingOutlined} from "@ant-design/icons"; import {BarsOutlined, DownOutlined, LogoutOutlined, SettingOutlined} from "@ant-design/icons";
import {Avatar, BackTop, Button, Card, Dropdown, Layout, Menu, Result} from "antd"; import {Avatar, BackTop, Button, Card, Drawer, Dropdown, Layout, Menu, Result} from "antd";
import {Link, Redirect, Route, Switch, withRouter} from "react-router-dom"; import {Link, Redirect, Route, Switch, withRouter} from "react-router-dom";
import OrganizationListPage from "./OrganizationListPage"; import OrganizationListPage from "./OrganizationListPage";
import OrganizationEditPage from "./OrganizationEditPage"; import OrganizationEditPage from "./OrganizationEditPage";
@ -74,6 +74,7 @@ import ModelEditPage from "./ModelEditPage";
import SystemInfo from "./SystemInfo"; import SystemInfo from "./SystemInfo";
import AdapterListPage from "./AdapterListPage"; import AdapterListPage from "./AdapterListPage";
import AdapterEditPage from "./AdapterEditPage"; import AdapterEditPage from "./AdapterEditPage";
import {withTranslation} from "react-i18next";
const {Header, Footer} = Layout; const {Header, Footer} = Layout;
@ -85,6 +86,7 @@ class App extends Component {
selectedMenuKey: 0, selectedMenuKey: 0,
account: undefined, account: undefined,
uri: null, uri: null,
menuVisible: false,
}; };
Setting.initServerUrl(); Setting.initServerUrl();
@ -298,12 +300,12 @@ class App extends Component {
<Menu onClick={this.handleRightDropdownClick.bind(this)}> <Menu onClick={this.handleRightDropdownClick.bind(this)}>
<Menu.Item key="/account"> <Menu.Item key="/account">
<SettingOutlined /> <SettingOutlined />
&nbsp; &nbsp;&nbsp;
{i18next.t("account:My Account")} {i18next.t("account:My Account")}
</Menu.Item> </Menu.Item>
<Menu.Item key="/logout"> <Menu.Item key="/logout">
<LogoutOutlined /> <LogoutOutlined />
&nbsp; &nbsp;&nbsp;
{i18next.t("account:Logout")} {i18next.t("account:Logout")}
</Menu.Item> </Menu.Item>
</Menu> </Menu>
@ -378,6 +380,9 @@ class App extends Component {
</Link> </Link>
</Menu.Item> </Menu.Item>
); );
}
if (Setting.isLocalAdminUser(this.state.account)) {
res.push( res.push(
<Menu.Item key="/users"> <Menu.Item key="/users">
<Link to="/users"> <Link to="/users">
@ -592,6 +597,18 @@ class App extends Component {
); );
} }
onClose = () => {
this.setState({
menuVisible: false,
});
};
showMenu = () => {
this.setState({
menuVisible: true,
});
};
renderContent() { renderContent() {
if (!Setting.isMobile()) { if (!Setting.isMobile()) {
return ( return (
@ -610,7 +627,7 @@ class App extends Component {
// theme="dark" // theme="dark"
mode={(Setting.isMobile() && this.isStartPages()) ? "inline" : "horizontal"} mode={(Setting.isMobile() && this.isStartPages()) ? "inline" : "horizontal"}
selectedKeys={[`${this.state.selectedMenuKey}`]} selectedKeys={[`${this.state.selectedMenuKey}`]}
style={{lineHeight: "64px", width: "78%", position: "absolute", left: "145px"}} style={{lineHeight: "64px", position: "absolute", left: "145px", right: "200px"}}
> >
{ {
this.renderMenu() this.renderMenu()
@ -643,22 +660,28 @@ class App extends Component {
</Link> </Link>
) )
} }
<Menu <Drawer title={i18next.t("general:Close")} placement="left" visible={this.state.menuVisible} onClose={this.onClose}>
// theme="dark" <Menu
mode={(Setting.isMobile() && this.isStartPages()) ? "inline" : "horizontal"} // theme="dark"
selectedKeys={[`${this.state.selectedMenuKey}`]} mode={(Setting.isMobile()) ? "inline" : "horizontal"}
style={{lineHeight: "64px"}} selectedKeys={[`${this.state.selectedMenuKey}`]}
> style={{lineHeight: "64px"}}
{ onClick={this.onClose}
this.renderMenu() >
}
<div style = {{float: "right"}}>
{ {
this.renderAccount() this.renderMenu()
} }
<SelectLanguageBox /> </Menu>
</div> </Drawer>
</Menu> <Button icon={<BarsOutlined />} onClick={this.showMenu} type="text">
{i18next.t("general:Menu")}
</Button>
<div style = {{float: "right"}}>
{
this.renderAccount()
}
<SelectLanguageBox />
</div>
</Header> </Header>
{ {
this.renderRouter() this.renderRouter()
@ -776,4 +799,4 @@ class App extends Component {
} }
} }
export default withRouter(App); export default withRouter(withTranslation()(App));

View File

@ -12,19 +12,21 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
import React, {useState} from "react"; import React, {useEffect, useState} from "react";
import Cropper from "react-cropper"; import Cropper from "react-cropper";
import "cropperjs/dist/cropper.css"; import "cropperjs/dist/cropper.css";
import * as Setting from "./Setting"; import * as Setting from "./Setting";
import {Button, Col, Modal, Row} from "antd"; import {Button, Col, Modal, Row, Select} from "antd";
import i18next from "i18next"; import i18next from "i18next";
import * as ResourceBackend from "./backend/ResourceBackend"; import * as ResourceBackend from "./backend/ResourceBackend";
export const CropperDiv = (props) => { export const CropperDiv = (props) => {
const [loading, setLoading] = useState(true);
const [options, setOptions] = useState([]);
const [image, setImage] = useState(""); const [image, setImage] = useState("");
const [cropper, setCropper] = useState(); const [cropper, setCropper] = useState();
const [visible, setVisible] = React.useState(false); const [visible, setVisible] = useState(false);
const [confirmLoading, setConfirmLoading] = React.useState(false); const [confirmLoading, setConfirmLoading] = useState(false);
const {title} = props; const {title} = props;
const {user} = props; const {user} = props;
const {buttonText} = props; const {buttonText} = props;
@ -60,7 +62,7 @@ export const CropperDiv = (props) => {
ResourceBackend.uploadResource(user.owner, user.name, "avatar", "CropperDiv", fullFilePath, blob) ResourceBackend.uploadResource(user.owner, user.name, "avatar", "CropperDiv", fullFilePath, blob)
.then((res) => { .then((res) => {
if (res.status === "ok") { if (res.status === "ok") {
window.location.href = "/account"; window.location.href = window.location.pathname;
} else { } else {
Setting.showMessage("error", res.msg); Setting.showMessage("error", res.msg);
} }
@ -88,6 +90,48 @@ export const CropperDiv = (props) => {
uploadButton.click(); uploadButton.click();
}; };
const getOptions = (data) => {
const options = [];
if (props.account.organization.defaultAvatar !== null) {
options.push({value: props.account.organization.defaultAvatar});
}
for (let i = 0; i < data.length; i++) {
if (data[i].fileType === "image") {
const url = `${data[i].url}`;
options.push({
value: url,
});
}
}
return options;
};
const getBase64Image = (src) => {
return new Promise((resolve) => {
const image = new Image();
image.src = src;
image.setAttribute("crossOrigin", "anonymous");
image.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = image.width;
canvas.height = image.height;
const ctx = canvas.getContext("2d");
ctx.drawImage(image, 0, 0, image.width, image.height);
const dataURL = canvas.toDataURL("image/png");
resolve(dataURL);
};
});
};
useEffect(() => {
setLoading(true);
ResourceBackend.getResources(props.account.owner, props.account.name, "", "", "", "", "", "")
.then((res) => {
setLoading(false);
setOptions(getOptions(res));
});
}, []);
return ( return (
<div> <div>
<Button type="default" onClick={showModal}> <Button type="default" onClick={showModal}>
@ -105,10 +149,20 @@ export const CropperDiv = (props) => {
[<Button block key="submit" type="primary" onClick={handleOk}>{i18next.t("user:Set new profile picture")}</Button>] [<Button block key="submit" type="primary" onClick={handleOk}>{i18next.t("user:Set new profile picture")}</Button>]
} }
> >
<Col style={{margin: "0px auto 40px auto", width: 1000, height: 300}}> <Col style={{margin: "0px auto 60px auto", width: 1000, height: 350}}>
<Row style={{width: "100%", marginBottom: "20px"}}> <Row style={{width: "100%", marginBottom: "20px"}}>
<input style={{display: "none"}} ref={input => uploadButton = input} type="file" accept="image/*" onChange={onChange} /> <input style={{display: "none"}} ref={input => uploadButton = input} type="file" accept="image/*" onChange={onChange} />
<Button block onClick={selectFile}>{i18next.t("user:Select a photo...")}</Button> <Button block onClick={selectFile}>{i18next.t("user:Select a photo...")}</Button>
<Select
style={{width: "100%"}}
loading={loading}
placeholder={i18next.t("user:Please select avatar from resources")}
onChange={(async value => {
setImage(await getBase64Image(value));
})}
options={options}
allowClear={true}
/>
</Row> </Row>
<Cropper <Cropper
style={{height: "100%"}} style={{height: "100%"}}

View File

@ -35,6 +35,7 @@ class PermissionEditPage extends React.Component {
permissionName: props.match.params.permissionName, permissionName: props.match.params.permissionName,
permission: null, permission: null,
organizations: [], organizations: [],
model: null,
users: [], users: [],
roles: [], roles: [],
models: [], models: [],
@ -59,6 +60,7 @@ class PermissionEditPage extends React.Component {
this.getRoles(permission.owner); this.getRoles(permission.owner);
this.getModels(permission.owner); this.getModels(permission.owner);
this.getResources(permission.owner); this.getResources(permission.owner);
this.getModel(permission.owner, permission.model);
}); });
} }
@ -98,6 +100,15 @@ class PermissionEditPage extends React.Component {
}); });
} }
getModel(organizationName, modelName) {
ModelBackend.getModel(organizationName, modelName)
.then((res) => {
this.setState({
model: res,
});
});
}
getResources(organizationName) { getResources(organizationName) {
ApplicationBackend.getApplicationsByOrganization("admin", organizationName) ApplicationBackend.getApplicationsByOrganization("admin", organizationName)
.then((res) => { .then((res) => {
@ -115,6 +126,10 @@ class PermissionEditPage extends React.Component {
} }
updatePermissionField(key, value) { updatePermissionField(key, value) {
if (key === "model") {
this.getModel(this.state.permission.owner, value);
}
value = this.parsePermissionField(key, value); value = this.parsePermissionField(key, value);
const permission = this.state.permission; const permission = this.state.permission;
@ -124,6 +139,13 @@ class PermissionEditPage extends React.Component {
}); });
} }
hasRoleDefinition(model) {
if (model !== null) {
return model.modelText.includes("role_definition");
}
return false;
}
renderPermission() { renderPermission() {
return ( return (
<Card size="small" title={ <Card size="small" title={
@ -214,7 +236,7 @@ class PermissionEditPage extends React.Component {
{Setting.getLabel(i18next.t("role:Sub roles"), i18next.t("role:Sub roles - Tooltip"))} : {Setting.getLabel(i18next.t("role:Sub roles"), i18next.t("role:Sub roles - Tooltip"))} :
</Col> </Col>
<Col span={22} > <Col span={22} >
<Select virtual={false} mode="tags" style={{width: "100%"}} value={this.state.permission.roles} onChange={(value => {this.updatePermissionField("roles", value);})}> <Select virtual={false} disabled={!this.hasRoleDefinition(this.state.model)} mode="tags" style={{width: "100%"}} value={this.state.permission.roles} onChange={(value => {this.updatePermissionField("roles", value);})}>
{ {
this.state.roles.filter(roles => (roles.owner !== this.state.roles.owner || roles.name !== this.state.roles.name)).map((permission, index) => <Option key={index} value={`${permission.owner}/${permission.name}`}>{`${permission.owner}/${permission.name}`}</Option>) this.state.roles.filter(roles => (roles.owner !== this.state.roles.owner || roles.name !== this.state.roles.name)).map((permission, index) => <Option key={index} value={`${permission.owner}/${permission.name}`}>{`${permission.owner}/${permission.name}`}</Option>)
} }

View File

@ -341,7 +341,7 @@ class PermissionListPage extends BaseListPage {
this.setState({loading: true}); this.setState({loading: true});
const getPermissions = Setting.isLocalAdminUser(this.props.account) ? PermissionBackend.getPermissions : PermissionBackend.getPermissionsBySubmitter; const getPermissions = Setting.isLocalAdminUser(this.props.account) ? PermissionBackend.getPermissions : PermissionBackend.getPermissionsBySubmitter;
getPermissions("", params.pagination.current, params.pagination.pageSize, field, value, sortField, sortOrder) getPermissions(Setting.isAdminUser(this.props.account) ? "" : this.props.account.owner, params.pagination.current, params.pagination.pageSize, field, value, sortField, sortOrder)
.then((res) => { .then((res) => {
if (res.status === "ok") { if (res.status === "ok") {
this.setState({ this.setState({

View File

@ -25,7 +25,7 @@ class RoleListPage extends BaseListPage {
newRole() { newRole() {
const randomName = Setting.getRandomName(); const randomName = Setting.getRandomName();
return { return {
owner: "built-in", owner: this.props.account.owner,
name: `role_${randomName}`, name: `role_${randomName}`,
createdTime: moment().format(), createdTime: moment().format(),
displayName: `New Role - ${randomName}`, displayName: `New Role - ${randomName}`,
@ -211,7 +211,7 @@ class RoleListPage extends BaseListPage {
value = params.type; value = params.type;
} }
this.setState({loading: true}); this.setState({loading: true});
RoleBackend.getRoles("", params.pagination.current, params.pagination.pageSize, field, value, sortField, sortOrder) RoleBackend.getRoles(Setting.isAdminUser(this.props.account) ? "" : this.props.account.owner, params.pagination.current, params.pagination.pageSize, field, value, sortField, sortOrder)
.then((res) => { .then((res) => {
if (res.status === "ok") { if (res.status === "ok") {
this.setState({ this.setState({

View File

@ -554,7 +554,7 @@ export function changeLanguage(language) {
localStorage.setItem("language", language); localStorage.setItem("language", language);
changeMomentLanguage(language); changeMomentLanguage(language);
i18next.changeLanguage(language); i18next.changeLanguage(language);
window.location.reload(true); // window.location.reload(true);
} }
export function changeMomentLanguage(language) { export function changeMomentLanguage(language) {

View File

@ -17,7 +17,6 @@ import {Button, Card, Col, Input, Result, Row, Select, Spin, Switch} from "antd"
import * as UserBackend from "./backend/UserBackend"; import * as UserBackend from "./backend/UserBackend";
import * as OrganizationBackend from "./backend/OrganizationBackend"; import * as OrganizationBackend from "./backend/OrganizationBackend";
import * as Setting from "./Setting"; import * as Setting from "./Setting";
import {LinkOutlined} from "@ant-design/icons";
import i18next from "i18next"; import i18next from "i18next";
import CropperDiv from "./CropperDiv.js"; import CropperDiv from "./CropperDiv.js";
import * as ApplicationBackend from "./backend/ApplicationBackend"; import * as ApplicationBackend from "./backend/ApplicationBackend";
@ -232,16 +231,6 @@ class UserEditPage extends React.Component {
{Setting.getLabel(i18next.t("general:Avatar"), i18next.t("general:Avatar - Tooltip"))} : {Setting.getLabel(i18next.t("general:Avatar"), i18next.t("general:Avatar - Tooltip"))} :
</Col> </Col>
<Col span={22} > <Col span={22} >
<Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{i18next.t("general:URL")}:
</Col>
<Col span={22} >
<Input prefix={<LinkOutlined />} value={this.state.user.avatar} onChange={e => {
this.updateUserField("avatar", e.target.value);
}} />
</Col>
</Row>
<Row style={{marginTop: "20px"}} > <Row style={{marginTop: "20px"}} >
<Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}> <Col style={{marginTop: "5px"}} span={(Setting.isMobile()) ? 22 : 2}>
{i18next.t("general:Preview")}: {i18next.t("general:Preview")}:
@ -661,7 +650,7 @@ class UserEditPage extends React.Component {
return ( return (
<div> <div>
{ {
this.state.loading ? <Spin size="large" /> : ( this.state.loading ? <Spin size="large" style={{marginLeft: "50%", marginTop: "10%"}} /> : (
this.state.user !== null ? this.renderUser() : this.state.user !== null ? this.renderUser() :
<Result <Result
status="404" status="404"

View File

@ -374,7 +374,7 @@ class UserListPage extends BaseListPage {
const sortField = params.sortField, sortOrder = params.sortOrder; const sortField = params.sortField, sortOrder = params.sortOrder;
this.setState({loading: true}); this.setState({loading: true});
if (this.state.organizationName === undefined) { if (this.state.organizationName === undefined) {
UserBackend.getGlobalUsers(params.pagination.current, params.pagination.pageSize, field, value, sortField, sortOrder) (Setting.isAdminUser(this.props.account) ? UserBackend.getGlobalUsers(params.pagination.current, params.pagination.pageSize, field, value, sortField, sortOrder) : UserBackend.getUsers(this.props.account.owner, params.pagination.current, params.pagination.pageSize, field, value, sortField, sortOrder))
.then((res) => { .then((res) => {
if (res.status === "ok") { if (res.status === "ok") {
this.setState({ this.setState({

View File

@ -29,6 +29,7 @@ import i18next from "i18next";
import CustomGithubCorner from "../CustomGithubCorner"; import CustomGithubCorner from "../CustomGithubCorner";
import {CountDownInput} from "../common/CountDownInput"; import {CountDownInput} from "../common/CountDownInput";
import SelectLanguageBox from "../SelectLanguageBox"; import SelectLanguageBox from "../SelectLanguageBox";
import {withTranslation} from "react-i18next";
const {TabPane} = Tabs; const {TabPane} = Tabs;
@ -155,8 +156,8 @@ class LoginPage extends React.Component {
values["type"] = "saml"; values["type"] = "saml";
} }
if (this.state.owner !== null && this.state.owner !== undefined) { if (this.state.application.organization !== null && this.state.application.organization !== undefined) {
values["organization"] = this.state.owner; values["organization"] = this.state.application.organization;
} }
} }
postCodeLoginAction(res) { postCodeLoginAction(res) {
@ -730,4 +731,4 @@ class LoginPage extends React.Component {
} }
} }
export default LoginPage; export default withTranslation()(LoginPage);

View File

@ -129,6 +129,32 @@ export function renderProviderLogo(provider, application, width, margin, size, l
); );
} }
} else if (provider.type === "Custom") {
// style definition
const text = i18next.t("login:Sign in with {type}").replace("{type}", provider.displayName);
const customAStyle = {display: "block", height: "55px", color: "#000"};
const customButtonStyle = {display: "flex", alignItems: "center", width: "calc(100% - 10px)", height: "50px", margin: "5px", padding: "0 10px", backgroundColor: "transparent", boxShadow: "0px 1px 3px rgba(0,0,0,0.5)", border: "0px", borderRadius: "3px", cursor: "pointer"};
const customImgStyle = {justfyContent: "space-between"};
const customSpanStyle = {textAlign: "center", lineHeight: "50px", width: "100%", fontSize: "19px"};
if (provider.category === "OAuth") {
return (
<a key={provider.displayName} href={Provider.getAuthUrl(application, provider, "signup")} style={customAStyle}>
<button style={customButtonStyle}>
<img width={26} src={getProviderLogoURL(provider)} alt={provider.displayName} style={customImgStyle} />
<span style={customSpanStyle}>{text}</span>
</button>
</a>
);
} else if (provider.category === "SAML") {
return (
<a key={provider.displayName} onClick={() => getSamlUrl(provider, location)} style={customAStyle}>
<button style={customButtonStyle}>
<img width={26} src={getProviderLogoURL(provider)} alt={provider.displayName} style={customImgStyle} />
<span style={customSpanStyle}>{text}</span>
</button>
</a>
);
}
} else { } else {
return ( return (
<div key={provider.displayName} style={{marginBottom: "10px"}}> <div key={provider.displayName} style={{marginBottom: "10px"}}>

View File

@ -44,10 +44,11 @@ class SingleCard extends React.Component {
return ( return (
<Card.Grid style={gridStyle} onClick={() => Setting.goToLinkSoft(this, silentSigninLink)}> <Card.Grid style={gridStyle} onClick={() => Setting.goToLinkSoft(this, silentSigninLink)}>
<img src={logo} alt="logo" height={60} style={{marginBottom: "20px"}} /> <img src={logo} alt="logo" width={"100%"} style={{marginBottom: "20px"}} />
<Meta <Meta
title={title} title={title}
description={desc} description={desc}
style={{justifyContent: "center"}}
/> />
</Card.Grid> </Card.Grid>
); );
@ -61,7 +62,7 @@ class SingleCard extends React.Component {
<Card <Card
hoverable hoverable
cover={ cover={
<img alt="logo" src={logo} style={{width: "100%", height: "210px", objectFit: "scale-down"}} /> <img alt="logo" src={logo} style={{width: "100%", objectFit: "scale-down"}} />
} }
onClick={() => Setting.goToLinkSoft(this, silentSigninLink)} onClick={() => Setting.goToLinkSoft(this, silentSigninLink)}
style={isSingle ? {width: "320px"} : {width: "100%"}} style={isSingle ? {width: "320px"} : {width: "100%"}}

View File

@ -23,6 +23,7 @@ import ja from "./locales/ja/data.json";
import es from "./locales/es/data.json"; import es from "./locales/es/data.json";
import * as Conf from "./Conf"; import * as Conf from "./Conf";
import * as Setting from "./Setting"; import * as Setting from "./Setting";
import {initReactI18next} from "react-i18next";
const resources = { const resources = {
en: en, en: en,
@ -80,7 +81,7 @@ function initLanguage() {
return language; return language;
} }
i18n.init({ i18n.use(initReactI18next).init({
lng: initLanguage(), lng: initLanguage(),
resources: resources, resources: resources,

View File

@ -133,6 +133,7 @@
"Certs": "Certs", "Certs": "Certs",
"Click to Upload": "Click to Upload", "Click to Upload": "Click to Upload",
"Client IP": "Client-IP", "Client IP": "Client-IP",
"Close": "Close",
"Created time": "Erstellte Zeit", "Created time": "Erstellte Zeit",
"Default application": "Default application", "Default application": "Default application",
"Default application - Tooltip": "Default application - Tooltip", "Default application - Tooltip": "Default application - Tooltip",
@ -165,6 +166,7 @@
"Logo - Tooltip": "App's image tag", "Logo - Tooltip": "App's image tag",
"Master password": "Master-Passwort", "Master password": "Master-Passwort",
"Master password - Tooltip": "Masterpasswort - Tooltip", "Master password - Tooltip": "Masterpasswort - Tooltip",
"Menu": "Menu",
"Method": "Methode", "Method": "Methode",
"Model": "Model", "Model": "Model",
"Model - Tooltip": "Model - Tooltip", "Model - Tooltip": "Model - Tooltip",

View File

@ -133,6 +133,7 @@
"Certs": "Certs", "Certs": "Certs",
"Click to Upload": "Click to Upload", "Click to Upload": "Click to Upload",
"Client IP": "Client IP", "Client IP": "Client IP",
"Close": "Close",
"Created time": "Created time", "Created time": "Created time",
"Default application": "Default application", "Default application": "Default application",
"Default application - Tooltip": "Default application - Tooltip", "Default application - Tooltip": "Default application - Tooltip",
@ -165,6 +166,7 @@
"Logo - Tooltip": "Logo - Tooltip", "Logo - Tooltip": "Logo - Tooltip",
"Master password": "Master password", "Master password": "Master password",
"Master password - Tooltip": "Master password - Tooltip", "Master password - Tooltip": "Master password - Tooltip",
"Menu": "Menu",
"Method": "Method", "Method": "Method",
"Model": "Model", "Model": "Model",
"Model - Tooltip": "Model - Tooltip", "Model - Tooltip": "Model - Tooltip",

View File

@ -133,6 +133,7 @@
"Certs": "Certes", "Certs": "Certes",
"Click to Upload": "Click to Upload", "Click to Upload": "Click to Upload",
"Client IP": "IP du client", "Client IP": "IP du client",
"Close": "Close",
"Created time": "Date de création", "Created time": "Date de création",
"Default application": "Default application", "Default application": "Default application",
"Default application - Tooltip": "Default application - Tooltip", "Default application - Tooltip": "Default application - Tooltip",
@ -165,6 +166,7 @@
"Logo - Tooltip": "App's image tag", "Logo - Tooltip": "App's image tag",
"Master password": "Mot de passe maître", "Master password": "Mot de passe maître",
"Master password - Tooltip": "Mot de passe maître - Infobulle", "Master password - Tooltip": "Mot de passe maître - Infobulle",
"Menu": "Menu",
"Method": "Méthode", "Method": "Méthode",
"Model": "Model", "Model": "Model",
"Model - Tooltip": "Model - Tooltip", "Model - Tooltip": "Model - Tooltip",

View File

@ -133,6 +133,7 @@
"Certs": "Certs", "Certs": "Certs",
"Click to Upload": "Click to Upload", "Click to Upload": "Click to Upload",
"Client IP": "クライアント IP", "Client IP": "クライアント IP",
"Close": "Close",
"Created time": "作成日時", "Created time": "作成日時",
"Default application": "Default application", "Default application": "Default application",
"Default application - Tooltip": "Default application - Tooltip", "Default application - Tooltip": "Default application - Tooltip",
@ -165,6 +166,7 @@
"Logo - Tooltip": "App's image tag", "Logo - Tooltip": "App's image tag",
"Master password": "マスターパスワード", "Master password": "マスターパスワード",
"Master password - Tooltip": "マスターパスワード - ツールチップ", "Master password - Tooltip": "マスターパスワード - ツールチップ",
"Menu": "Menu",
"Method": "方法", "Method": "方法",
"Model": "Model", "Model": "Model",
"Model - Tooltip": "Model - Tooltip", "Model - Tooltip": "Model - Tooltip",

View File

@ -133,6 +133,7 @@
"Certs": "Certs", "Certs": "Certs",
"Click to Upload": "Click to Upload", "Click to Upload": "Click to Upload",
"Client IP": "Client IP", "Client IP": "Client IP",
"Close": "Close",
"Created time": "Created time", "Created time": "Created time",
"Default application": "Default application", "Default application": "Default application",
"Default application - Tooltip": "Default application - Tooltip", "Default application - Tooltip": "Default application - Tooltip",
@ -165,6 +166,7 @@
"Logo - Tooltip": "App's image tag", "Logo - Tooltip": "App's image tag",
"Master password": "Master password", "Master password": "Master password",
"Master password - Tooltip": "Master password - Tooltip", "Master password - Tooltip": "Master password - Tooltip",
"Menu": "Menu",
"Method": "Method", "Method": "Method",
"Model": "Model", "Model": "Model",
"Model - Tooltip": "Model - Tooltip", "Model - Tooltip": "Model - Tooltip",

View File

@ -133,6 +133,7 @@
"Certs": "Сертификаты", "Certs": "Сертификаты",
"Click to Upload": "Нажмите здесь, чтобы загрузить", "Click to Upload": "Нажмите здесь, чтобы загрузить",
"Client IP": "IP клиента", "Client IP": "IP клиента",
"Close": "Close",
"Created time": "Время создания", "Created time": "Время создания",
"Default application": "Default application", "Default application": "Default application",
"Default application - Tooltip": "Default application - Tooltip", "Default application - Tooltip": "Default application - Tooltip",
@ -165,6 +166,7 @@
"Logo - Tooltip": "App's image tag", "Logo - Tooltip": "App's image tag",
"Master password": "Мастер-пароль", "Master password": "Мастер-пароль",
"Master password - Tooltip": "Мастер-пароль - Tooltip", "Master password - Tooltip": "Мастер-пароль - Tooltip",
"Menu": "Menu",
"Method": "Метод", "Method": "Метод",
"Model": "Модель", "Model": "Модель",
"Model - Tooltip": "Модель - Подсказка", "Model - Tooltip": "Модель - Подсказка",

View File

@ -133,6 +133,7 @@
"Certs": "证书", "Certs": "证书",
"Click to Upload": "点击上传", "Click to Upload": "点击上传",
"Client IP": "客户端IP", "Client IP": "客户端IP",
"Close": "关闭",
"Created time": "创建时间", "Created time": "创建时间",
"Default application": "默认应用", "Default application": "默认应用",
"Default application - Tooltip": "默认应用", "Default application - Tooltip": "默认应用",
@ -165,6 +166,7 @@
"Logo - Tooltip": "应用程序向外展示的图标", "Logo - Tooltip": "应用程序向外展示的图标",
"Master password": "万能密码", "Master password": "万能密码",
"Master password - Tooltip": "可用来登录该组织下的所有用户,方便管理员以该用户身份登录,以解决技术问题", "Master password - Tooltip": "可用来登录该组织下的所有用户,方便管理员以该用户身份登录,以解决技术问题",
"Menu": "目录",
"Method": "方法", "Method": "方法",
"Model": "模型", "Model": "模型",
"Model - Tooltip": "Casbin模型", "Model - Tooltip": "Casbin模型",