mirror of
https://github.com/casdoor/casdoor.git
synced 2025-05-23 18:54:03 +08:00
feat: try to support custom OAuth provider (#667)
* feat: try to support private provider * fix: modify code according to code review * feat: set example values for custom params
This commit is contained in:
parent
530330bd66
commit
287f60353c
@ -283,7 +283,7 @@ func (c *ApiController) Login() {
|
|||||||
clientSecret = provider.ClientSecret2
|
clientSecret = provider.ClientSecret2
|
||||||
}
|
}
|
||||||
|
|
||||||
idProvider := idp.GetIdProvider(provider.Type, provider.SubType, clientId, clientSecret, provider.AppId, form.RedirectUri, provider.Domain)
|
idProvider := idp.GetIdProvider(provider.Type, provider.SubType, clientId, clientSecret, provider.AppId, form.RedirectUri, provider.Domain, provider.CustomAuthUrl, provider.CustomTokenUrl, provider.CustomUserInfoUrl)
|
||||||
if idProvider == nil {
|
if idProvider == nil {
|
||||||
c.ResponseError(fmt.Sprintf("The provider type: %s is not supported", provider.Type))
|
c.ResponseError(fmt.Sprintf("The provider type: %s is not supported", provider.Type))
|
||||||
return
|
return
|
||||||
|
108
idp/custom.go
Normal file
108
idp/custom.go
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
// Copyright 2022 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 idp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
_ "net/url"
|
||||||
|
_ "time"
|
||||||
|
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CustomIdProvider struct {
|
||||||
|
Client *http.Client
|
||||||
|
Config *oauth2.Config
|
||||||
|
UserInfoUrl string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCustomIdProvider(clientId string, clientSecret string, redirectUrl string, authUrl string, tokenUrl string, userInfoUrl string) *CustomIdProvider {
|
||||||
|
idp := &CustomIdProvider{}
|
||||||
|
idp.UserInfoUrl = userInfoUrl
|
||||||
|
|
||||||
|
var config = &oauth2.Config{
|
||||||
|
ClientID: clientId,
|
||||||
|
ClientSecret: clientSecret,
|
||||||
|
RedirectURL: redirectUrl,
|
||||||
|
Endpoint: oauth2.Endpoint{
|
||||||
|
AuthURL: authUrl,
|
||||||
|
TokenURL: tokenUrl,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
idp.Config = config
|
||||||
|
|
||||||
|
return idp
|
||||||
|
}
|
||||||
|
|
||||||
|
func (idp *CustomIdProvider) SetHttpClient(client *http.Client) {
|
||||||
|
idp.Client = client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (idp *CustomIdProvider) GetToken(code string) (*oauth2.Token, error) {
|
||||||
|
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, idp.Client)
|
||||||
|
return idp.Config.Exchange(ctx, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
type CustomUserInfo struct {
|
||||||
|
Id string `json:"sub"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
DisplayName string `json:"preferred_username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
AvatarUrl string `json:"picture"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (idp *CustomIdProvider) GetUserInfo(token *oauth2.Token) (*UserInfo, error) {
|
||||||
|
ctUserinfo := &CustomUserInfo{}
|
||||||
|
accessToken := token.AccessToken
|
||||||
|
request, err := http.NewRequest("GET", idp.UserInfoUrl, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
//add accessToken to request header
|
||||||
|
request.Header.Add("Authorization", fmt.Sprintf("Bearer %s", accessToken))
|
||||||
|
resp, err := idp.Client.Do(request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := ioutil.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = json.Unmarshal(data, ctUserinfo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctUserinfo.Status != "" {
|
||||||
|
return nil, fmt.Errorf("err: %s", ctUserinfo.Msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
userInfo := &UserInfo{
|
||||||
|
Id: ctUserinfo.Id,
|
||||||
|
Username: ctUserinfo.Name,
|
||||||
|
DisplayName: ctUserinfo.DisplayName,
|
||||||
|
Email: ctUserinfo.Email,
|
||||||
|
AvatarUrl: ctUserinfo.AvatarUrl,
|
||||||
|
}
|
||||||
|
return userInfo, nil
|
||||||
|
}
|
@ -35,7 +35,7 @@ type IdProvider interface {
|
|||||||
GetUserInfo(token *oauth2.Token) (*UserInfo, error)
|
GetUserInfo(token *oauth2.Token) (*UserInfo, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetIdProvider(typ string, subType string, clientId string, clientSecret string, appId string, redirectUrl string, hostUrl string) IdProvider {
|
func GetIdProvider(typ string, subType string, clientId string, clientSecret string, appId string, redirectUrl string, hostUrl string, authUrl string, tokenUrl string, userInfoUrl string) IdProvider {
|
||||||
if typ == "GitHub" {
|
if typ == "GitHub" {
|
||||||
return NewGithubIdProvider(clientId, clientSecret, redirectUrl)
|
return NewGithubIdProvider(clientId, clientSecret, redirectUrl)
|
||||||
} else if typ == "Google" {
|
} else if typ == "Google" {
|
||||||
@ -72,6 +72,8 @@ func GetIdProvider(typ string, subType string, clientId string, clientSecret str
|
|||||||
return NewBaiduIdProvider(clientId, clientSecret, redirectUrl)
|
return NewBaiduIdProvider(clientId, clientSecret, redirectUrl)
|
||||||
} else if typ == "Alipay" {
|
} else if typ == "Alipay" {
|
||||||
return NewAlipayIdProvider(clientId, clientSecret, redirectUrl)
|
return NewAlipayIdProvider(clientId, clientSecret, redirectUrl)
|
||||||
|
} else if typ == "Custom" {
|
||||||
|
return NewCustomIdProvider(clientId, clientSecret, redirectUrl, authUrl, tokenUrl, userInfoUrl)
|
||||||
} else if typ == "Infoflow" {
|
} else if typ == "Infoflow" {
|
||||||
if subType == "Internal" {
|
if subType == "Internal" {
|
||||||
return NewInfoflowInternalIdProvider(clientId, clientSecret, appId, redirectUrl)
|
return NewInfoflowInternalIdProvider(clientId, clientSecret, appId, redirectUrl)
|
||||||
|
@ -27,16 +27,21 @@ type Provider struct {
|
|||||||
Name string `xorm:"varchar(100) notnull pk" json:"name"`
|
Name string `xorm:"varchar(100) notnull pk" json:"name"`
|
||||||
CreatedTime string `xorm:"varchar(100)" json:"createdTime"`
|
CreatedTime string `xorm:"varchar(100)" json:"createdTime"`
|
||||||
|
|
||||||
DisplayName string `xorm:"varchar(100)" json:"displayName"`
|
DisplayName string `xorm:"varchar(100)" json:"displayName"`
|
||||||
Category string `xorm:"varchar(100)" json:"category"`
|
Category string `xorm:"varchar(100)" json:"category"`
|
||||||
Type string `xorm:"varchar(100)" json:"type"`
|
Type string `xorm:"varchar(100)" json:"type"`
|
||||||
SubType string `xorm:"varchar(100)" json:"subType"`
|
SubType string `xorm:"varchar(100)" json:"subType"`
|
||||||
Method string `xorm:"varchar(100)" json:"method"`
|
Method string `xorm:"varchar(100)" json:"method"`
|
||||||
ClientId string `xorm:"varchar(100)" json:"clientId"`
|
ClientId string `xorm:"varchar(100)" json:"clientId"`
|
||||||
ClientSecret string `xorm:"varchar(2000)" json:"clientSecret"`
|
ClientSecret string `xorm:"varchar(2000)" json:"clientSecret"`
|
||||||
ClientId2 string `xorm:"varchar(100)" json:"clientId2"`
|
ClientId2 string `xorm:"varchar(100)" json:"clientId2"`
|
||||||
ClientSecret2 string `xorm:"varchar(100)" json:"clientSecret2"`
|
ClientSecret2 string `xorm:"varchar(100)" json:"clientSecret2"`
|
||||||
Cert string `xorm:"varchar(100)" json:"cert"`
|
Cert string `xorm:"varchar(100)" json:"cert"`
|
||||||
|
CustomAuthUrl string `xorm:"varchar(200)" json:"customAuthUrl"`
|
||||||
|
CustomScope string `xorm:"varchar(200)" json:"customScope"`
|
||||||
|
CustomTokenUrl string `xorm:"varchar(200)" json:"customTokenUrl"`
|
||||||
|
CustomUserInfoUrl string `xorm:"varchar(200)" json:"customUserInfoUrl"`
|
||||||
|
CustomLogo string `xorm:"varchar(200)" json:"customLogo"`
|
||||||
|
|
||||||
Host string `xorm:"varchar(100)" json:"host"`
|
Host string `xorm:"varchar(100)" json:"host"`
|
||||||
Port int `json:"port"`
|
Port int `json:"port"`
|
||||||
|
@ -94,6 +94,7 @@ type User struct {
|
|||||||
AzureAD string `xorm:"azuread varchar(100)" json:"azuread"`
|
AzureAD string `xorm:"azuread varchar(100)" json:"azuread"`
|
||||||
Slack string `xorm:"slack varchar(100)" json:"slack"`
|
Slack string `xorm:"slack varchar(100)" json:"slack"`
|
||||||
Steam string `xorm:"steam varchar(100)" json:"steam"`
|
Steam string `xorm:"steam varchar(100)" json:"steam"`
|
||||||
|
Custom string `xorm:"custom varchar(100)" json:"custom"`
|
||||||
|
|
||||||
Ldap string `xorm:"ldap varchar(100)" json:"ldap"`
|
Ldap string `xorm:"ldap varchar(100)" json:"ldap"`
|
||||||
Properties map[string]string `json:"properties"`
|
Properties map[string]string `json:"properties"`
|
||||||
|
@ -212,6 +212,12 @@ class ProviderEditPage extends React.Component {
|
|||||||
if (value === "Local File System") {
|
if (value === "Local File System") {
|
||||||
this.updateProviderField('domain', Setting.getFullServerUrl());
|
this.updateProviderField('domain', Setting.getFullServerUrl());
|
||||||
}
|
}
|
||||||
|
if (value === "Custom") {
|
||||||
|
this.updateProviderField('customAuthUrl', 'https://door.casdoor.com/login/oauth/authorize');
|
||||||
|
this.updateProviderField('customScope', 'openid profile email');
|
||||||
|
this.updateProviderField('customTokenUrl', 'https://door.casdoor.com/api/login/oauth/access_token');
|
||||||
|
this.updateProviderField('customUserInfoUrl', 'https://door.casdoor.com/api/userinfo');
|
||||||
|
}
|
||||||
})}>
|
})}>
|
||||||
{
|
{
|
||||||
Setting.getProviderTypeOptions(this.state.provider.category).map((providerType, index) => <Option key={index} value={providerType.id}>{providerType.name}</Option>)
|
Setting.getProviderTypeOptions(this.state.provider.category).map((providerType, index) => <Option key={index} value={providerType.id}>{providerType.name}</Option>)
|
||||||
@ -256,6 +262,79 @@ class ProviderEditPage extends React.Component {
|
|||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
this.state.provider.type !== "Custom" ? null : (
|
||||||
|
<React.Fragment>
|
||||||
|
<Row style={{marginTop: '20px'}} >
|
||||||
|
<Col style={{marginTop: '5px'}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||||
|
{Setting.getLabel(i18next.t("provider:Auth URL"), i18next.t("provider:Auth URL - Tooltip"))}
|
||||||
|
</Col>
|
||||||
|
<Col span={22} >
|
||||||
|
<Input value={this.state.provider.customAuthUrl} onChange={e => {
|
||||||
|
this.updateProviderField('customAuthUrl', e.target.value);
|
||||||
|
}} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row style={{marginTop: '20px'}} >
|
||||||
|
<Col style={{marginTop: '5px'}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||||
|
{Setting.getLabel(i18next.t("provider:Scope"), i18next.t("provider:Scope - Tooltip"))}
|
||||||
|
</Col>
|
||||||
|
<Col span={22} >
|
||||||
|
<Input value={this.state.provider.customScope} onChange={e => {
|
||||||
|
this.updateProviderField('customScope', e.target.value);
|
||||||
|
}} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row style={{marginTop: '20px'}} >
|
||||||
|
<Col style={{marginTop: '5px'}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||||
|
{Setting.getLabel(i18next.t("provider:Token URL"), i18next.t("provider:Token URL - Tooltip"))}
|
||||||
|
</Col>
|
||||||
|
<Col span={22} >
|
||||||
|
<Input value={this.state.provider.customTokenUrl} onChange={e => {
|
||||||
|
this.updateProviderField('customTokenUrl', e.target.value);
|
||||||
|
}} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row style={{marginTop: '20px'}} >
|
||||||
|
<Col style={{marginTop: '5px'}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||||
|
{Setting.getLabel(i18next.t("provider:UserInfo URL"), i18next.t("provider:UserInfo URL - Tooltip"))}
|
||||||
|
</Col>
|
||||||
|
<Col span={22} >
|
||||||
|
<Input value={this.state.provider.customUserInfoUrl} onChange={e => {
|
||||||
|
this.updateProviderField('customUserInfoUrl', e.target.value);
|
||||||
|
}} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row style={{marginTop: '20px'}} >
|
||||||
|
<Col style={{marginTop: '5px'}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||||
|
{Setting.getLabel( i18next.t("general:Favicon"), i18next.t("general:Favicon - Tooltip"))} :
|
||||||
|
</Col>
|
||||||
|
<Col span={22} >
|
||||||
|
<Row style={{marginTop: '20px'}} >
|
||||||
|
<Col style={{marginTop: '5px'}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||||
|
{Setting.getLabel(i18next.t("general:URL"), i18next.t("general:URL - Tooltip"))} :
|
||||||
|
</Col>
|
||||||
|
<Col span={23} >
|
||||||
|
<Input prefix={<LinkOutlined/>} value={this.state.provider.customLogo} onChange={e => {
|
||||||
|
this.updateProviderField('customLogo', e.target.value);
|
||||||
|
}} />
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row style={{marginTop: '20px'}} >
|
||||||
|
<Col style={{marginTop: '5px'}} span={(Setting.isMobile()) ? 22 : 2}>
|
||||||
|
{i18next.t("general:Preview")}:
|
||||||
|
</Col>
|
||||||
|
<Col span={23} >
|
||||||
|
<a target="_blank" rel="noreferrer" href={this.state.provider.customLogo}>
|
||||||
|
<img src={this.state.provider.customLogo} alt={this.state.provider.customLogo} height={90} style={{marginBottom: '20px'}}/>
|
||||||
|
</a>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
|
}
|
||||||
<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}>
|
||||||
{this.getClientIdLabel()}
|
{this.getClientIdLabel()}
|
||||||
|
@ -414,6 +414,7 @@ export function getProviderTypeOptions(category) {
|
|||||||
{id: 'AzureAD', name: 'AzureAD'},
|
{id: 'AzureAD', name: 'AzureAD'},
|
||||||
{id: 'Slack', name: 'Slack'},
|
{id: 'Slack', name: 'Slack'},
|
||||||
{id: 'Steam', name: 'Steam'},
|
{id: 'Steam', name: 'Steam'},
|
||||||
|
{id: 'Custom', name: 'Custom'},
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
} else if (category === "Email") {
|
} else if (category === "Email") {
|
||||||
|
@ -107,6 +107,9 @@ const authInfo = {
|
|||||||
Steam: {
|
Steam: {
|
||||||
endpoint: "https://steamcommunity.com/openid/login",
|
endpoint: "https://steamcommunity.com/openid/login",
|
||||||
},
|
},
|
||||||
|
Custom: {
|
||||||
|
endpoint: "https://example.com/",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const otherProviderInfo = {
|
const otherProviderInfo = {
|
||||||
@ -184,6 +187,9 @@ const otherProviderInfo = {
|
|||||||
|
|
||||||
export function getProviderLogo(provider) {
|
export function getProviderLogo(provider) {
|
||||||
if (provider.category === "OAuth") {
|
if (provider.category === "OAuth") {
|
||||||
|
if (provider.type === "Custom") {
|
||||||
|
return provider.customLogo;
|
||||||
|
}
|
||||||
return `${Setting.StaticBaseUrl}/img/social_${provider.type.toLowerCase()}.png`;
|
return `${Setting.StaticBaseUrl}/img/social_${provider.type.toLowerCase()}.png`;
|
||||||
} else {
|
} else {
|
||||||
return otherProviderInfo[provider.category][provider.type].logo;
|
return otherProviderInfo[provider.category][provider.type].logo;
|
||||||
@ -308,5 +314,7 @@ export function getAuthUrl(application, provider, method) {
|
|||||||
return `${endpoint}?client_id=${provider.clientId}&redirect_uri=${redirectUri}&state=${state}&response_type=code&scope=${scope}`;
|
return `${endpoint}?client_id=${provider.clientId}&redirect_uri=${redirectUri}&state=${state}&response_type=code&scope=${scope}`;
|
||||||
} else if (provider.type === "Steam") {
|
} else if (provider.type === "Steam") {
|
||||||
return `${endpoint}?openid.claimed_id=http://specs.openid.net/auth/2.0/identifier_select&openid.identity=http://specs.openid.net/auth/2.0/identifier_select&openid.mode=checkid_setup&openid.ns=http://specs.openid.net/auth/2.0&openid.realm=${window.location.origin}&openid.return_to=${redirectUri}?state=${state}`;
|
return `${endpoint}?openid.claimed_id=http://specs.openid.net/auth/2.0/identifier_select&openid.identity=http://specs.openid.net/auth/2.0/identifier_select&openid.mode=checkid_setup&openid.ns=http://specs.openid.net/auth/2.0&openid.realm=${window.location.origin}&openid.return_to=${redirectUri}?state=${state}`;
|
||||||
|
} else if (provider.type === "Custom") {
|
||||||
|
return `${provider.customAuthUrl}?client_id=${provider.clientId}&redirect_uri=${redirectUri}&scope=${provider.customScope}&response_type=code&state=${state}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -333,6 +333,8 @@
|
|||||||
"Agent ID - Tooltip": "Agent ID - Tooltip",
|
"Agent ID - Tooltip": "Agent ID - Tooltip",
|
||||||
"App ID": "App ID",
|
"App ID": "App ID",
|
||||||
"App ID - Tooltip": "App ID - Tooltip",
|
"App ID - Tooltip": "App ID - Tooltip",
|
||||||
|
"Auth URL": "Auth URL",
|
||||||
|
"Auth URL - Tooltip": "Auth URL - 工具提示",
|
||||||
"Bucket": "存储桶",
|
"Bucket": "存储桶",
|
||||||
"Bucket - Tooltip": "Bucket名称",
|
"Bucket - Tooltip": "Bucket名称",
|
||||||
"Can not parse Metadata": "无法解析元数据",
|
"Can not parse Metadata": "无法解析元数据",
|
||||||
@ -380,6 +382,8 @@
|
|||||||
"Region endpoint for Internet": "地域节点 (外网)",
|
"Region endpoint for Internet": "地域节点 (外网)",
|
||||||
"Region endpoint for Intranet": "地域节点 (内网)",
|
"Region endpoint for Intranet": "地域节点 (内网)",
|
||||||
"SAML 2.0 Endpoint (HTTP)": "SAML 2.0 Endpoint (HTTP)",
|
"SAML 2.0 Endpoint (HTTP)": "SAML 2.0 Endpoint (HTTP)",
|
||||||
|
"Scope": "Scope",
|
||||||
|
"Scope - Tooltip": "Scope - 工具提示",
|
||||||
"SMS account": "SMS account",
|
"SMS account": "SMS account",
|
||||||
"SMS account - Tooltip": "SMS account - Tooltip",
|
"SMS account - Tooltip": "SMS account - Tooltip",
|
||||||
"SP ACS URL": "SP ACS URL",
|
"SP ACS URL": "SP ACS URL",
|
||||||
@ -403,8 +407,12 @@
|
|||||||
"Template Code - Tooltip": "模板CODE",
|
"Template Code - Tooltip": "模板CODE",
|
||||||
"Terms of Use": "使用条款",
|
"Terms of Use": "使用条款",
|
||||||
"Terms of Use - Tooltip": "使用条款 - 工具提示",
|
"Terms of Use - Tooltip": "使用条款 - 工具提示",
|
||||||
|
"Token URL": "Token URL",
|
||||||
|
"Token URL - Tooltip": "Token URL - 工具提示",
|
||||||
"Type": "类型",
|
"Type": "类型",
|
||||||
"Type - Tooltip": "类型",
|
"Type - Tooltip": "类型",
|
||||||
|
"UserInfo URL": "UserInfo URL",
|
||||||
|
"UserInfo URL - Tooltip": "UserInfo URL - 工具提示",
|
||||||
"alertType": "警报类型",
|
"alertType": "警报类型",
|
||||||
"canSignIn": "canSignIn",
|
"canSignIn": "canSignIn",
|
||||||
"canSignUp": "canSignUp",
|
"canSignUp": "canSignUp",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user