feat: support MetaMask provider (#2084)

* feat: add metamask provider

* feat: add eth login

* feat: check eth sign

* feat: finish metamask signin/signup

* feat: support MetaMask provider link/unlink

* feat: update web/craco.config.js to handle polyfill

* feat: gofumpt idp/metamask.go

* feat: update MetaMask logo path

* feat: support MetaMask avatar
This commit is contained in:
haiwu
2023-07-20 17:51:36 +08:00
committed by GitHub
parent f923a8f0d7
commit d7110ff8bf
32 changed files with 548 additions and 38 deletions

View File

@ -89,6 +89,8 @@ import {withTranslation} from "react-i18next";
import LanguageSelect from "./common/select/LanguageSelect";
import ThemeSelect from "./common/select/ThemeSelect";
import OrganizationSelect from "./common/select/OrganizationSelect";
import {clearWeb3AuthToken} from "./auth/Web3Auth";
import AccountAvatar from "./account/AccountAvatar";
const {Header, Footer, Content} = Layout;
@ -312,12 +314,11 @@ class App extends Component {
.then((res) => {
if (res.status === "ok") {
const owner = this.state.account.owner;
this.setState({
account: null,
themeAlgorithm: ["default"],
});
clearWeb3AuthToken();
Setting.showMessage("success", i18next.t("application:Logged out successfully"));
const redirectUri = res.data2;
if (redirectUri !== null && redirectUri !== undefined && redirectUri !== "") {
@ -348,7 +349,9 @@ class App extends Component {
);
} else {
return (
<Avatar src={this.state.account.avatar} style={{verticalAlign: "middle"}} size="large">
<Avatar src={this.state.account.avatar} style={{verticalAlign: "middle"}} size="large"
icon={<AccountAvatar src={this.state.account.avatar} style={{verticalAlign: "middle"}} size={40} />}
>
{Setting.getShortName(this.state.account.name)}
</Avatar>
);

View File

@ -358,6 +358,8 @@ class ProviderEditPage extends React.Component {
this.updateProviderField("type", "Default");
} else if (value === "AI") {
this.updateProviderField("type", "OpenAI API - GPT");
} else if (value === "Web3") {
this.updateProviderField("type", "MetaMask");
}
})}>
{
@ -370,6 +372,7 @@ class ProviderEditPage extends React.Component {
{id: "SAML", name: "SAML"},
{id: "SMS", name: "SMS"},
{id: "Storage", name: "Storage"},
{id: "Web3", name: "Web3"},
]
.sort((a, b) => a.name.localeCompare(b.name))
.map((providerCategory, index) => <Option key={index} value={providerCategory.id}>{providerCategory.name}</Option>)
@ -524,7 +527,7 @@ class ProviderEditPage extends React.Component {
)
}
{
this.state.provider.category === "Captcha" && this.state.provider.type === "Default" ? null : (
(this.state.provider.category === "Captcha" && this.state.provider.type === "Default") || this.state.provider.category === "Web3" ? null : (
<React.Fragment>
{
this.state.provider.category === "AI" ? null : (

View File

@ -216,6 +216,12 @@ export const OtherProviderInfo = {
url: "https://platform.openai.com",
},
},
Web3: {
"MetaMask": {
logo: `${StaticBaseUrl}/img/social_metamask.svg`,
url: "https://metamask.io/",
},
},
};
export function initCountries() {
@ -288,7 +294,7 @@ export function isProviderVisible(providerItem) {
return false;
}
if (providerItem.provider.category !== "OAuth" && providerItem.provider.category !== "SAML") {
if (!["OAuth", "SAML", "Web3"].includes(providerItem.provider.category)) {
return false;
}
@ -891,6 +897,10 @@ export function getProviderTypeOptions(category) {
return ([
{id: "OpenAI API - GPT", name: "OpenAI API - GPT"},
]);
} else if (category === "Web3") {
return ([
{id: "MetaMask", name: "MetaMask"},
]);
} else {
return [];
}

View File

@ -36,6 +36,7 @@ import PopconfirmModal from "./common/modal/PopconfirmModal";
import {DeleteMfa} from "./backend/MfaBackend";
import {CheckCircleOutlined, HolderOutlined, UsergroupAddOutlined} from "@ant-design/icons";
import * as MfaBackend from "./backend/MfaBackend";
import AccountAvatar from "./account/AccountAvatar";
const {Option} = Select;
@ -791,10 +792,23 @@ class UserEditPage extends React.Component {
{
(this.state.application === null || this.state.user === null) ? null : (
this.state.application?.providers.filter(providerItem => Setting.isProviderVisible(providerItem)).map((providerItem) =>
(providerItem.provider.category === "OAuth") ? (
<OAuthWidget key={providerItem.name} labelSpan={(Setting.isMobile()) ? 10 : 3} user={this.state.user} application={this.state.application} providerItem={providerItem} account={this.props.account} onUnlinked={() => {return this.unlinked();}} />
(providerItem.provider.category === "OAuth" || providerItem.provider.category === "Web3") ? (
<OAuthWidget
key={providerItem.name}
labelSpan={(Setting.isMobile()) ? 10 : 3}
user={this.state.user}
application={this.state.application}
providerItem={providerItem}
account={this.props.account}
onUnlinked={() => {return this.unlinked();}} />
) : (
<SamlWidget key={providerItem.name} labelSpan={(Setting.isMobile()) ? 10 : 3} user={this.state.user} application={this.state.application} providerItem={providerItem} onUnlinked={() => {return this.unlinked();}} />
<SamlWidget
key={providerItem.name}
labelSpan={(Setting.isMobile()) ? 10 : 3}
user={this.state.user}
application={this.state.application}
providerItem={providerItem}
onUnlinked={() => {return this.unlinked();}} />
)
)
)
@ -971,7 +985,7 @@ class UserEditPage extends React.Component {
{
imgUrl ?
<a target="_blank" rel="noreferrer" href={imgUrl} style={{marginBottom: "10px"}}>
<img src={imgUrl} alt={imgUrl} height={90} style={{marginBottom: "20px"}} />
<AccountAvatar src={imgUrl} alt={imgUrl} size={90} style={{marginBottom: "20px"}} />
</a>
:
<Col style={{height: "78%", border: "1px dotted grey", borderRadius: 3, marginBottom: 5}}>

View File

@ -23,6 +23,7 @@ import * as UserBackend from "./backend/UserBackend";
import i18next from "i18next";
import BaseListPage from "./BaseListPage";
import PopconfirmModal from "./common/modal/PopconfirmModal";
import AccountAvatar from "./account/AccountAvatar";
class UserListPage extends BaseListPage {
constructor(props) {
@ -270,7 +271,7 @@ class UserListPage extends BaseListPage {
render: (text, record, index) => {
return (
<a target="_blank" rel="noreferrer" href={text}>
<img referrerPolicy="no-referrer" src={text} alt={text} width={50} />
<AccountAvatar referrerPolicy="no-referrer" src={text} alt={text} size={50} />
</a>
);
},

View File

@ -0,0 +1,36 @@
// Copyright 2023 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 {MetaMaskAvatar} from "react-metamask-avatar";
class AccountAvatar extends React.Component {
render() {
const {src, size} = this.props;
// The avatar for Metamask account is directly generated by an algorithm based on the address
// src = "metamask:0xC304b2cC0Be8E9ce10fF3Afd34820Ed306A23600";
const matchMetaMask = src.match(/^metamask:(\w+)$/);
if (matchMetaMask) {
const address = matchMetaMask[1];
return (
<MetaMaskAvatar address={address} size={size} />
);
}
return (
<img width={size} height={size} src={src} />
);
}
}
export default AccountAvatar;

View File

@ -95,6 +95,12 @@ class AuthCallback extends React.Component {
if (code === null) {
code = params.get("authCode");
}
// The code for Metamask is the JSON-serialized string of Web3AuthToken
// Due to the limited length of URLs, we only pass the web3AuthTokenKey
if (code === null) {
code = params.get("web3AuthTokenKey");
code = localStorage.getItem(code);
}
// Steam don't use code, so we should use all params as code.
if (isSteam !== null && code === null) {
code = this.props.location.search;

View File

@ -317,6 +317,10 @@ const authInfo = {
scope: "user:read",
endpoint: "https://zoom.us/oauth/authorize",
},
MetaMask: {
scope: "",
endpoint: "",
},
};
export function getProviderUrl(provider) {
@ -459,5 +463,7 @@ export function getAuthUrl(application, provider, method) {
return `${endpoint}?client_id=${provider.clientId}&redirect_uri=${redirectUri}&scope=${scope}&state=${state}&grant_options[]=per-user`;
} else if (provider.type === "Twitter" || provider.type === "Fitbit") {
return `${endpoint}?client_id=${provider.clientId}&redirect_uri=${redirectUri}&state=${state}&response_type=code&scope=${scope}&code_challenge=${codeChallenge}&code_challenge_method=S256`;
} else if (provider.type === "MetaMask") {
return `${redirectUri}?state=${state}`;
}
}

View File

@ -17,6 +17,7 @@ import i18next from "i18next";
import * as Provider from "./Provider";
import {getProviderLogoURL} from "../Setting";
import {GithubLoginButton, GoogleLoginButton} from "react-social-login-buttons";
import {authViaMetaMask} from "./Web3Auth";
import QqLoginButton from "./QqLoginButton";
import FacebookLoginButton from "./FacebookLoginButton";
import WeiboLoginButton from "./WeiboLoginButton";
@ -117,6 +118,12 @@ function goToSamlUrl(provider, location) {
});
}
export function goToWeb3Url(application, provider, method) {
if (provider.type === "MetaMask") {
authViaMetaMask(application, provider, method);
}
}
export function renderProviderLogo(provider, application, width, margin, size, location) {
if (size === "small") {
if (provider.category === "OAuth") {
@ -153,6 +160,12 @@ export function renderProviderLogo(provider, application, width, margin, size, l
<img width={width} height={width} src={getProviderLogoURL(provider)} alt={provider.displayName} style={{margin: margin}} />
</a>
);
} else if (provider.category === "Web3") {
return (
<a key={provider.displayName} onClick={() => goToWeb3Url(application, provider, "signup")}>
<img width={width} height={width} src={getProviderLogoURL(provider)} alt={provider.displayName} style={{margin: margin}} />
</a>
);
}
} else if (provider.type === "Custom") {
// style definition
@ -192,6 +205,16 @@ export function renderProviderLogo(provider, application, width, margin, size, l
</a>
</div>
);
} else if (provider.category === "Web3") {
return (
<div key={provider.displayName} style={{marginBottom: "10px"}}>
<a onClick={() => goToWeb3Url(application, provider, "signup")}>
{
getSigninButton(provider)
}
</a>
</div>
);
} else {
return (
<div key={provider.displayName} style={{marginBottom: "10px"}}>

149
web/src/auth/Web3Auth.js Normal file
View File

@ -0,0 +1,149 @@
// // Copyright 2023 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 {goToLink, showMessage} from "../Setting";
import i18next from "i18next";
import {v4 as uuidv4} from "uuid";
import {SignTypedDataVersion, recoverTypedSignature} from "@metamask/eth-sig-util";
import {getAuthUrl} from "./Provider";
import {Buffer} from "buffer";
// import {toChecksumAddress} from "ethereumjs-util";
global.Buffer = Buffer;
export function generateNonce() {
const nonce = uuidv4();
return nonce;
}
export function getWeb3AuthTokenKey(address) {
return `Web3AuthToken_${address}`;
}
export function setWeb3AuthToken(token) {
const key = getWeb3AuthTokenKey(token.address);
localStorage.setItem(key, JSON.stringify(token));
}
export function getWeb3AuthToken(address) {
const key = getWeb3AuthTokenKey(address);
return JSON.parse(localStorage.getItem(key));
}
export function delWeb3AuthToken(address) {
const key = getWeb3AuthTokenKey(address);
localStorage.removeItem(key);
}
export function clearWeb3AuthToken() {
const keys = Object.keys(localStorage);
keys.forEach(key => {
if (key.startsWith("Web3AuthToken_")) {
localStorage.removeItem(key);
}
});
}
export function detectMetaMaskPlugin() {
// check if ethereum extension MetaMask is installed
return window.ethereum && window.ethereum.isMetaMask;
}
export function requestEthereumAccount() {
const method = "eth_requestAccounts";
const selectedAccount = window.ethereum.request({method})
.then((accounts) => {
return accounts[0];
});
return selectedAccount;
}
export function signEthereumTypedData(from, nonce) {
// https://docs.metamask.io/wallet/how-to/sign-data/
const date = new Date();
const typedData = JSON.stringify({
domain: {
chainId: window.ethereum.chainId,
name: "Casdoor",
version: "1",
},
message: {
prompt: "In order to authenticate to this website, sign this request and your public address will be sent to the server in a verifiable way.",
nonce: nonce,
createAt: `${date.toLocaleString()}`,
},
primaryType: "AuthRequest",
types: {
EIP712Domain: [
{name: "name", type: "string"},
{name: "version", type: "string"},
{name: "chainId", type: "uint256"},
],
AuthRequest: [
{name: "prompt", type: "string"},
{name: "nonce", type: "string"},
{name: "createAt", type: "string"},
],
},
});
const method = "eth_signTypedData_v4";
const params = [from, typedData];
return window.ethereum.request({method, params})
.then((sign) => {
return {
address: from,
createAt: Math.floor(date.getTime() / 1000),
typedData: typedData,
signature: sign,
};
});
}
export function checkEthereumSignedTypedData(token) {
if (token === undefined || token === null) {
return false;
}
if (token.address && token.typedData && token.signature) {
const recoveredAddr = recoverTypedSignature({
data: JSON.parse(token.typedData),
signature: token.signature,
version: SignTypedDataVersion.V4,
});
// const recoveredAddr = token.address;
return recoveredAddr === token.address;
// return toChecksumAddress(recoveredAddr) === toChecksumAddress(token.address);
}
return false;
}
export async function authViaMetaMask(application, provider, method) {
if (!detectMetaMaskPlugin()) {
showMessage("error", `${i18next.t("login:MetaMask plugin not detected")}`);
return;
}
try {
const account = await requestEthereumAccount();
let token = getWeb3AuthToken(account);
if (!checkEthereumSignedTypedData(token)) {
const nonce = generateNonce();
token = await signEthereumTypedData(account, nonce);
setWeb3AuthToken(token);
}
const redirectUri = `${getAuthUrl(application, provider, method)}&web3AuthTokenKey=${getWeb3AuthTokenKey(account)}`;
goToLink(redirectUri);
} catch (err) {
showMessage("error", `${i18next.t("login:Failed to obtain MetaMask authorization")}: ${err.message}`);
}
}

View File

@ -19,6 +19,9 @@ import * as UserBackend from "../backend/UserBackend";
import * as Setting from "../Setting";
import * as Provider from "../auth/Provider";
import * as AuthBackend from "../auth/AuthBackend";
import {goToWeb3Url} from "../auth/ProviderButton";
import {delWeb3AuthToken} from "../auth/Web3Auth";
import AccountAvatar from "../account/AccountAvatar";
class OAuthWidget extends React.Component {
constructor(props) {
@ -88,12 +91,15 @@ class OAuthWidget extends React.Component {
return user.properties[key];
}
unlinkUser(providerType) {
unlinkUser(providerType, linkedValue) {
const body = {
providerType: providerType,
// should add the unlink user's info, cause the user may not be logged in, but a admin want to unlink the user.
user: this.props.user,
};
if (providerType === "MetaMask") {
delWeb3AuthToken(linkedValue);
}
AuthBackend.unlink(body)
.then((res) => {
if (res.status === "ok") {
@ -151,7 +157,7 @@ class OAuthWidget extends React.Component {
</span>
</Col>
<Col span={24 - this.props.labelSpan} >
<img style={{marginRight: "10px"}} width={30} height={30} src={avatarUrl} alt={name} referrerPolicy="no-referrer" />
<AccountAvatar style={{marginRight: "10px"}} size={30} src={avatarUrl} alt={name} referrerPolicy="no-referrer" />
<span style={{width: this.props.labelSpan === 3 ? "300px" : "200px", display: (Setting.isMobile()) ? "inline" : "inline-block"}}>
{
linkedValue === "" ? (
@ -169,11 +175,15 @@ class OAuthWidget extends React.Component {
</span>
{
linkedValue === "" ? (
<a key={provider.displayName} href={user.id !== account.id ? null : Provider.getAuthUrl(application, provider, "link")}>
<Button style={{marginLeft: "20px", width: linkButtonWidth}} type="primary" disabled={user.id !== account.id}>{i18next.t("user:Link")}</Button>
</a>
provider.category === "Web3" ? (
<Button style={{marginLeft: "20px", width: linkButtonWidth}} type="primary" disabled={user.id !== account.id} onClick={() => goToWeb3Url(application, provider, "link")}>{i18next.t("user:Link")}</Button>
) : (
<a key={provider.displayName} href={user.id !== account.id ? null : Provider.getAuthUrl(application, provider, "link")}>
<Button style={{marginLeft: "20px", width: linkButtonWidth}} type="primary" disabled={user.id !== account.id}>{i18next.t("user:Link")}</Button>
</a>
)
) : (
<Button disabled={!providerItem.canUnlink && !account.isGlobalAdmin} style={{marginLeft: "20px", width: linkButtonWidth}} onClick={() => this.unlinkUser(provider.type)}>{i18next.t("user:Unlink")}</Button>
<Button disabled={!providerItem.canUnlink && !account.isGlobalAdmin} style={{marginLeft: "20px", width: linkButtonWidth}} onClick={() => this.unlinkUser(provider.type, linkedValue)}>{i18next.t("user:Unlink")}</Button>
)
}
</Col>

View File

@ -392,9 +392,11 @@
"Auto sign in": "Automatische Anmeldung",
"Continue with": "Weitermachen mit",
"Email or phone": "E-Mail oder Telefon",
"Failed to obtain MetaMask authorization": "Failed to obtain MetaMask authorization",
"Forgot password?": "Passwort vergessen?",
"Loading": "Laden",
"Logging out...": "Ausloggen...",
"MetaMask plugin not detected": "MetaMask plugin not detected",
"No account?": "Kein Konto?",
"Or sign in with another account": "Oder mit einem anderen Konto anmelden",
"Please input your Email or Phone!": "Bitte geben Sie Ihre E-Mail oder Telefonnummer ein!",

View File

@ -392,9 +392,11 @@
"Auto sign in": "Auto sign in",
"Continue with": "Continue with",
"Email or phone": "Email or phone",
"Failed to obtain MetaMask authorization": "Failed to obtain MetaMask authorization",
"Forgot password?": "Forgot password?",
"Loading": "Loading",
"Logging out...": "Logging out...",
"MetaMask plugin not detected": "MetaMask plugin not detected",
"No account?": "No account?",
"Or sign in with another account": "Or sign in with another account",
"Please input your Email or Phone!": "Please input your Email or Phone!",

View File

@ -392,9 +392,11 @@
"Auto sign in": "Inicio de sesión automático",
"Continue with": "Continúe con",
"Email or phone": "Correo electrónico o teléfono",
"Failed to obtain MetaMask authorization": "Failed to obtain MetaMask authorization",
"Forgot password?": "¿Olvidaste tu contraseña?",
"Loading": "Cargando",
"Logging out...": "Cerrando sesión...",
"MetaMask plugin not detected": "MetaMask plugin not detected",
"No account?": "¿No tienes cuenta?",
"Or sign in with another account": "O inicia sesión con otra cuenta",
"Please input your Email or Phone!": "¡Por favor introduzca su correo electrónico o teléfono!",

View File

@ -392,9 +392,11 @@
"Auto sign in": "Connexion automatique",
"Continue with": "Continuer avec",
"Email or phone": "Email ou téléphone",
"Failed to obtain MetaMask authorization": "Failed to obtain MetaMask authorization",
"Forgot password?": "Mot de passe oublié ?",
"Loading": "Chargement",
"Logging out...": "Déconnexion...",
"MetaMask plugin not detected": "MetaMask plugin not detected",
"No account?": "Aucun compte ?",
"Or sign in with another account": "Ou connectez-vous avec un autre compte",
"Please input your Email or Phone!": "S'il vous plaît, entrez votre adresse e-mail ou votre numéro de téléphone !",

View File

@ -392,9 +392,11 @@
"Auto sign in": "Masuk otomatis",
"Continue with": "Lanjutkan dengan",
"Email or phone": "Email atau telepon",
"Failed to obtain MetaMask authorization": "Failed to obtain MetaMask authorization",
"Forgot password?": "Lupa kata sandi?",
"Loading": "Memuat",
"Logging out...": "Keluar...",
"MetaMask plugin not detected": "MetaMask plugin not detected",
"No account?": "Tidak memiliki akun?",
"Or sign in with another account": "Atau masuk dengan akun lain",
"Please input your Email or Phone!": "Silahkan masukkan email atau nomor telepon Anda!",

View File

@ -392,9 +392,11 @@
"Auto sign in": "自動サインイン",
"Continue with": "続ける",
"Email or phone": "メールまたは電話",
"Failed to obtain MetaMask authorization": "Failed to obtain MetaMask authorization",
"Forgot password?": "パスワードを忘れましたか?",
"Loading": "ローディング",
"Logging out...": "ログアウト中...",
"MetaMask plugin not detected": "MetaMask plugin not detected",
"No account?": "アカウントがありませんか?",
"Or sign in with another account": "別のアカウントでサインインする",
"Please input your Email or Phone!": "あなたのメールアドレスまたは電話番号を入力してください!",

View File

@ -392,9 +392,11 @@
"Auto sign in": "자동 로그인",
"Continue with": "계속하다",
"Email or phone": "이메일 또는 전화",
"Failed to obtain MetaMask authorization": "Failed to obtain MetaMask authorization",
"Forgot password?": "비밀번호를 잊으셨나요?",
"Loading": "로딩 중입니다",
"Logging out...": "로그아웃 중...",
"MetaMask plugin not detected": "MetaMask plugin not detected",
"No account?": "계정이 없나요?",
"Or sign in with another account": "다른 계정으로 로그인하세요",
"Please input your Email or Phone!": "이메일 또는 전화번호를 입력해주세요!",

View File

@ -392,9 +392,11 @@
"Auto sign in": "Entrar automaticamente",
"Continue with": "Continuar com",
"Email or phone": "Email ou telefone",
"Failed to obtain MetaMask authorization": "Failed to obtain MetaMask authorization",
"Forgot password?": "Esqueceu a senha?",
"Loading": "Carregando",
"Logging out...": "Saindo...",
"MetaMask plugin not detected": "MetaMask plugin not detected",
"No account?": "Não possui uma conta?",
"Or sign in with another account": "Ou entre com outra conta",
"Please input your Email or Phone!": "Por favor, informe seu email ou telefone!",

View File

@ -392,9 +392,11 @@
"Auto sign in": "Автоматическая авторизация",
"Continue with": "Продолжайте с",
"Email or phone": "Электронная почта или телефон",
"Failed to obtain MetaMask authorization": "Failed to obtain MetaMask authorization",
"Forgot password?": "Забыли пароль?",
"Loading": "Загрузка",
"Logging out...": "Выход...",
"MetaMask plugin not detected": "MetaMask plugin not detected",
"No account?": "Нет аккаунта?",
"Or sign in with another account": "Или войти с другой учетной записью",
"Please input your Email or Phone!": "Пожалуйста, введите свой адрес электронной почты или номер телефона!",

View File

@ -392,9 +392,11 @@
"Auto sign in": "Tự động đăng nhập",
"Continue with": "Tiếp tục với",
"Email or phone": "Email hoặc điện thoại",
"Failed to obtain MetaMask authorization": "Failed to obtain MetaMask authorization",
"Forgot password?": "Quên mật khẩu?",
"Loading": "Đang tải",
"Logging out...": "Đăng xuất ...",
"MetaMask plugin not detected": "MetaMask plugin not detected",
"No account?": "Không có tài khoản?",
"Or sign in with another account": "Hoặc đăng nhập bằng tài khoản khác",
"Please input your Email or Phone!": "Vui lòng nhập địa chỉ Email hoặc số điện thoại của bạn!",

View File

@ -392,9 +392,11 @@
"Auto sign in": "下次自动登录",
"Continue with": "使用以下账号继续",
"Email or phone": "Email或手机号",
"Failed to obtain MetaMask authorization": "获取MetaMask授权失败",
"Forgot password?": "忘记密码?",
"Loading": "加载中",
"Logging out...": "正在退出登录...",
"MetaMask plugin not detected": "未检测到MetaMask插件",
"No account?": "没有账号?",
"Or sign in with another account": "或者,登录其他账号",
"Please input your Email or Phone!": "请输入您的Email或手机号!",
@ -746,6 +748,8 @@
"Token URL - Tooltip": "自定义OAuth的Token URL",
"Type": "类型",
"Type - Tooltip": "类型",
"User mapping": "User mapping",
"User mapping - Tooltip": "User mapping - Tooltip",
"UserInfo URL": "UserInfo URL",
"UserInfo URL - Tooltip": "自定义OAuth的UserInfo URL",
"admin (Shared)": "admin共享"

View File

@ -110,7 +110,7 @@ class ProviderTable extends React.Component {
key: "canSignUp",
width: "120px",
render: (text, record, index) => {
if (record.provider?.category !== "OAuth") {
if (!["OAuth", "Web3"].includes(record.provider?.category)) {
return null;
}
@ -127,7 +127,7 @@ class ProviderTable extends React.Component {
key: "canSignIn",
width: "120px",
render: (text, record, index) => {
if (record.provider?.category !== "OAuth") {
if (!["OAuth", "Web3"].includes(record.provider?.category)) {
return null;
}
@ -144,7 +144,7 @@ class ProviderTable extends React.Component {
key: "canUnlink",
width: "120px",
render: (text, record, index) => {
if (record.provider?.category !== "OAuth") {
if (!["OAuth", "Web3"].includes(record.provider?.category)) {
return null;
}
@ -161,7 +161,7 @@ class ProviderTable extends React.Component {
key: "prompted",
width: "120px",
render: (text, record, index) => {
if (record.provider?.category !== "OAuth") {
if (!["OAuth", "Web3"].includes(record.provider?.category)) {
return null;
}