mirror of
https://github.com/casdoor/casdoor.git
synced 2025-08-21 11:30:34 +08:00
Compare commits
11 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
fd5ccd8d41 | ||
![]() |
a439c5195d | ||
![]() |
ba2e997d54 | ||
![]() |
0818de85d1 | ||
![]() |
457c6098a4 | ||
![]() |
60f979fbb5 | ||
![]() |
ff53e44fa6 | ||
![]() |
1832de47db | ||
![]() |
535eb0c465 | ||
![]() |
c190634cf3 | ||
![]() |
f7559aa040 |
@@ -854,6 +854,7 @@ func (c *ApiController) Login() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if authForm.Passcode != "" {
|
if authForm.Passcode != "" {
|
||||||
|
user.CountryCode = user.GetCountryCode(user.CountryCode)
|
||||||
mfaUtil := object.GetMfaUtil(authForm.MfaType, user.GetPreferredMfaProps(false))
|
mfaUtil := object.GetMfaUtil(authForm.MfaType, user.GetPreferredMfaProps(false))
|
||||||
if mfaUtil == nil {
|
if mfaUtil == nil {
|
||||||
c.ResponseError("Invalid multi-factor authentication type")
|
c.ResponseError("Invalid multi-factor authentication type")
|
||||||
|
@@ -294,6 +294,7 @@ func (c *ApiController) SendVerificationCode() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
vform.CountryCode = mfaProps.CountryCode
|
vform.CountryCode = mfaProps.CountryCode
|
||||||
|
vform.CountryCode = user.GetCountryCode(vform.CountryCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
provider, err = application.GetSmsProvider(vform.Method, vform.CountryCode)
|
provider, err = application.GetSmsProvider(vform.Method, vform.CountryCode)
|
||||||
|
@@ -520,11 +520,46 @@ func CheckUsername(username string, lang string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CheckUsernameWithEmail(username string, lang string) string {
|
||||||
|
if username == "" {
|
||||||
|
return i18n.Translate(lang, "check:Empty username.")
|
||||||
|
} else if len(username) > 39 {
|
||||||
|
return i18n.Translate(lang, "check:Username is too long (maximum is 39 characters).")
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://stackoverflow.com/questions/58726546/github-username-convention-using-regex
|
||||||
|
|
||||||
|
if !util.ReUserNameWithEmail.MatchString(username) {
|
||||||
|
return i18n.Translate(lang, "check:Username supports email format. Also 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. Also pay attention to the email format.")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func CheckUpdateUser(oldUser, user *User, lang string) string {
|
func CheckUpdateUser(oldUser, user *User, lang string) string {
|
||||||
if oldUser.Name != user.Name {
|
if oldUser.Name != user.Name {
|
||||||
if msg := CheckUsername(user.Name, lang); msg != "" {
|
organizationName := oldUser.Owner
|
||||||
return msg
|
if organizationName == "" {
|
||||||
|
organizationName = user.Owner
|
||||||
}
|
}
|
||||||
|
|
||||||
|
organization, err := getOrganization("admin", organizationName)
|
||||||
|
if err != nil {
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
if organization == nil {
|
||||||
|
return fmt.Sprintf(i18n.Translate(lang, "auth:The organization: %s does not exist"), organizationName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if organization.UseEmailAsUsername {
|
||||||
|
if msg := CheckUsernameWithEmail(user.Name, lang); msg != "" {
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if msg := CheckUsername(user.Name, lang); msg != "" {
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if HasUserByField(user.Owner, "name", user.Name) {
|
if HasUserByField(user.Owner, "name", user.Name) {
|
||||||
return i18n.Translate(lang, "check:Username already exists")
|
return i18n.Translate(lang, "check:Username already exists")
|
||||||
}
|
}
|
||||||
|
@@ -43,6 +43,8 @@ func CheckEntryIp(clientIp string, user *User, application *Application, organiz
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
application.IpRestriction = err.Error() + application.Name
|
application.IpRestriction = err.Error() + application.Name
|
||||||
return fmt.Errorf(err.Error() + application.Name)
|
return fmt.Errorf(err.Error() + application.Name)
|
||||||
|
} else {
|
||||||
|
application.IpRestriction = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
if organization == nil && application.OrganizationObj != nil {
|
if organization == nil && application.OrganizationObj != nil {
|
||||||
@@ -55,6 +57,8 @@ func CheckEntryIp(clientIp string, user *User, application *Application, organiz
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
organization.IpRestriction = err.Error() + organization.Name
|
organization.IpRestriction = err.Error() + organization.Name
|
||||||
return fmt.Errorf(err.Error() + organization.Name)
|
return fmt.Errorf(err.Error() + organization.Name)
|
||||||
|
} else {
|
||||||
|
organization.IpRestriction = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -102,14 +102,6 @@ func GetTokenByAccessToken(accessToken string) (*Token, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !existed {
|
|
||||||
token = Token{AccessToken: accessToken}
|
|
||||||
existed, err = ormer.Engine.Get(&token)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !existed {
|
if !existed {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -123,14 +115,6 @@ func GetTokenByRefreshToken(refreshToken string) (*Token, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if !existed {
|
|
||||||
token = Token{RefreshToken: refreshToken}
|
|
||||||
existed, err = ormer.Engine.Get(&token)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !existed {
|
if !existed {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
@@ -816,6 +816,10 @@ func AddUser(user *User) (bool, error) {
|
|||||||
user.UpdateUserPassword(organization)
|
user.UpdateUserPassword(organization)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if user.CreatedTime == "" {
|
||||||
|
user.CreatedTime = util.GetCurrentTime()
|
||||||
|
}
|
||||||
|
|
||||||
err = user.UpdateUserHash()
|
err = user.UpdateUserHash()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
|
@@ -25,10 +25,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
rePhone *regexp.Regexp
|
rePhone *regexp.Regexp
|
||||||
ReWhiteSpace *regexp.Regexp
|
ReWhiteSpace *regexp.Regexp
|
||||||
ReFieldWhiteList *regexp.Regexp
|
ReFieldWhiteList *regexp.Regexp
|
||||||
ReUserName *regexp.Regexp
|
ReUserName *regexp.Regexp
|
||||||
|
ReUserNameWithEmail *regexp.Regexp
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -36,6 +37,7 @@ func init() {
|
|||||||
ReWhiteSpace, _ = regexp.Compile(`\s`)
|
ReWhiteSpace, _ = regexp.Compile(`\s`)
|
||||||
ReFieldWhiteList, _ = regexp.Compile(`^[A-Za-z0-9]+$`)
|
ReFieldWhiteList, _ = regexp.Compile(`^[A-Za-z0-9]+$`)
|
||||||
ReUserName, _ = regexp.Compile("^[a-zA-Z0-9]+([-._][a-zA-Z0-9]+)*$")
|
ReUserName, _ = regexp.Compile("^[a-zA-Z0-9]+([-._][a-zA-Z0-9]+)*$")
|
||||||
|
ReUserNameWithEmail, _ = regexp.Compile(`^([a-zA-Z0-9]+([-._][a-zA-Z0-9]+)*)|([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})$`) // Add support for email formats
|
||||||
}
|
}
|
||||||
|
|
||||||
func IsEmailValid(email string) bool {
|
func IsEmailValid(email string) bool {
|
||||||
|
@@ -603,7 +603,7 @@ class ApplicationEditPage extends React.Component {
|
|||||||
{Setting.getLabel(i18next.t("general:IP whitelist"), i18next.t("general:IP whitelist - Tooltip"))} :
|
{Setting.getLabel(i18next.t("general:IP whitelist"), i18next.t("general:IP whitelist - Tooltip"))} :
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={22} >
|
<Col span={22} >
|
||||||
<Input placeholder = {this.state.application.organizationObj?.ipWhitelist} value={this.state.application.ipWhiteList} onChange={e => {
|
<Input placeholder = {this.state.application.organizationObj?.ipWhitelist} value={this.state.application.ipWhitelist} onChange={e => {
|
||||||
this.updateApplicationField("ipWhitelist", e.target.value);
|
this.updateApplicationField("ipWhitelist", e.target.value);
|
||||||
}} />
|
}} />
|
||||||
</Col>
|
</Col>
|
||||||
|
@@ -908,7 +908,7 @@ class ProviderEditPage extends React.Component {
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
)}
|
)}
|
||||||
{["Custom HTTP SMS", "Qiniu Cloud Kodo", "Synology", "Casdoor"].includes(this.state.provider.type) ? null : (
|
{["Custom HTTP SMS", "Synology", "Casdoor"].includes(this.state.provider.type) ? null : (
|
||||||
<Row style={{marginTop: "20px"}} >
|
<Row style={{marginTop: "20px"}} >
|
||||||
<Col style={{marginTop: "5px"}} span={2}>
|
<Col style={{marginTop: "5px"}} span={2}>
|
||||||
{Setting.getLabel(i18next.t("provider:Domain"), i18next.t("provider:Domain - Tooltip"))} :
|
{Setting.getLabel(i18next.t("provider:Domain"), i18next.t("provider:Domain - Tooltip"))} :
|
||||||
|
@@ -187,7 +187,7 @@ class RoleEditPage extends React.Component {
|
|||||||
{Setting.getLabel(i18next.t("role:Sub users"), i18next.t("role:Sub users - Tooltip"))} :
|
{Setting.getLabel(i18next.t("role:Sub users"), i18next.t("role:Sub users - Tooltip"))} :
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={22} >
|
<Col span={22} >
|
||||||
<Select virtual={false} mode="multiple" style={{width: "100%"}} value={this.state.role.users}
|
<Select virtual={true} mode="multiple" style={{width: "100%"}} value={this.state.role.users}
|
||||||
onChange={(value => {this.updateRoleField("users", value);})}
|
onChange={(value => {this.updateRoleField("users", value);})}
|
||||||
options={this.state.users.map((user) => Setting.getOption(`${user.owner}/${user.name}`, `${user.owner}/${user.name}`))}
|
options={this.state.users.map((user) => Setting.getOption(`${user.owner}/${user.name}`, `${user.owner}/${user.name}`))}
|
||||||
/>
|
/>
|
||||||
|
@@ -444,8 +444,8 @@ class LoginPage extends React.Component {
|
|||||||
formValues={values}
|
formValues={values}
|
||||||
authParams={casParams}
|
authParams={casParams}
|
||||||
application={this.getApplicationObj()}
|
application={this.getApplicationObj()}
|
||||||
onFail={() => {
|
onFail={(errorMessage) => {
|
||||||
Setting.showMessage("error", i18next.t("mfa:Verification failed"));
|
Setting.showMessage("error", errorMessage);
|
||||||
}}
|
}}
|
||||||
onSuccess={(res) => loginHandler(res)}
|
onSuccess={(res) => loginHandler(res)}
|
||||||
/>);
|
/>);
|
||||||
@@ -513,8 +513,8 @@ class LoginPage extends React.Component {
|
|||||||
formValues={values}
|
formValues={values}
|
||||||
authParams={oAuthParams}
|
authParams={oAuthParams}
|
||||||
application={this.getApplicationObj()}
|
application={this.getApplicationObj()}
|
||||||
onFail={() => {
|
onFail={(errorMessage) => {
|
||||||
Setting.showMessage("error", i18next.t("mfa:Verification failed"));
|
Setting.showMessage("error", errorMessage);
|
||||||
}}
|
}}
|
||||||
onSuccess={(res) => loginHandler(res)}
|
onSuccess={(res) => loginHandler(res)}
|
||||||
/>);
|
/>);
|
||||||
|
@@ -37,7 +37,7 @@ class MfaSetupPage extends React.Component {
|
|||||||
this.state = {
|
this.state = {
|
||||||
account: props.account,
|
account: props.account,
|
||||||
application: null,
|
application: null,
|
||||||
applicationName: props.account.signupApplication ?? "",
|
applicationName: props.account.signupApplication ?? localStorage.getItem("applicationName") ?? "",
|
||||||
current: location.state?.from !== undefined ? 1 : 0,
|
current: location.state?.from !== undefined ? 1 : 0,
|
||||||
mfaProps: null,
|
mfaProps: null,
|
||||||
mfaType: params.get("mfaType") ?? SmsMfaType,
|
mfaType: params.get("mfaType") ?? SmsMfaType,
|
||||||
|
121
web/src/common/CasdoorAppConnector.js
Normal file
121
web/src/common/CasdoorAppConnector.js
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
// Copyright 2024 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 {Alert, Button, QRCode} from "antd";
|
||||||
|
import * as Setting from "../Setting";
|
||||||
|
import i18next from "i18next";
|
||||||
|
|
||||||
|
export const generateCasdoorAppUrl = (accessToken, forQrCode = true) => {
|
||||||
|
let qrUrl = "";
|
||||||
|
let error = null;
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
error = i18next.t("general:Access token is empty");
|
||||||
|
return {qrUrl, error};
|
||||||
|
}
|
||||||
|
|
||||||
|
qrUrl = `casdoor-app://login?serverUrl=${window.location.origin}&accessToken=${accessToken}`;
|
||||||
|
|
||||||
|
if (forQrCode && qrUrl.length >= 2000) {
|
||||||
|
qrUrl = "";
|
||||||
|
error = i18next.t("general:QR code is too large");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {qrUrl, error};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CasdoorAppQrCode = ({accessToken, icon}) => {
|
||||||
|
const {qrUrl, error} = generateCasdoorAppUrl(accessToken, true);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <Alert message={error} type="error" showIcon />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QRCode
|
||||||
|
value={qrUrl}
|
||||||
|
icon={icon}
|
||||||
|
errorLevel="M"
|
||||||
|
size={230}
|
||||||
|
bordered={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CasdoorAppUrl = ({accessToken}) => {
|
||||||
|
const {qrUrl, error} = generateCasdoorAppUrl(accessToken, false);
|
||||||
|
|
||||||
|
const handleCopyUrl = async() => {
|
||||||
|
if (!window.isSecureContext) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(qrUrl);
|
||||||
|
Setting.showMessage("success", i18next.t("general:Copied to clipboard"));
|
||||||
|
} catch (err) {
|
||||||
|
Setting.showMessage("error", i18next.t("general:Failed to copy"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <Alert message={error} type="error" showIcon />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: "10px",
|
||||||
|
}}>
|
||||||
|
<span>{i18next.t("general:URL String")}</span>
|
||||||
|
{window.isSecureContext && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={handleCopyUrl}
|
||||||
|
style={{marginLeft: "10px"}}
|
||||||
|
>
|
||||||
|
{i18next.t("general:Copy URL")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "10px",
|
||||||
|
maxWidth: "400px",
|
||||||
|
maxHeight: "100px",
|
||||||
|
overflow: "auto",
|
||||||
|
wordBreak: "break-all",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
cursor: "pointer",
|
||||||
|
userSelect: "all",
|
||||||
|
backgroundColor: "#f5f5f5",
|
||||||
|
borderRadius: "4px",
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(e.target);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{qrUrl}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@@ -14,9 +14,10 @@
|
|||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import {DeleteOutlined, DownOutlined, UpOutlined} from "@ant-design/icons";
|
import {DeleteOutlined, DownOutlined, UpOutlined} from "@ant-design/icons";
|
||||||
import {Alert, Button, Col, Image, Input, Popover, QRCode, Row, Table, Tooltip} from "antd";
|
import {Button, Col, Image, Input, Popover, Row, Table, Tooltip} from "antd";
|
||||||
import * as Setting from "../Setting";
|
import * as Setting from "../Setting";
|
||||||
import i18next from "i18next";
|
import i18next from "i18next";
|
||||||
|
import {CasdoorAppQrCode, CasdoorAppUrl} from "../common/CasdoorAppConnector";
|
||||||
|
|
||||||
class MfaAccountTable extends React.Component {
|
class MfaAccountTable extends React.Component {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
@@ -76,42 +77,6 @@ class MfaAccountTable extends React.Component {
|
|||||||
this.updateTable(table);
|
this.updateTable(table);
|
||||||
}
|
}
|
||||||
|
|
||||||
getQrUrl() {
|
|
||||||
const {accessToken} = this.props;
|
|
||||||
let qrUrl = `casdoor-app://login?serverUrl=${window.location.origin}&accessToken=${accessToken}`;
|
|
||||||
let error = null;
|
|
||||||
|
|
||||||
if (!accessToken) {
|
|
||||||
qrUrl = "";
|
|
||||||
error = i18next.t("general:Access token is empty");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (qrUrl.length >= 2000) {
|
|
||||||
qrUrl = "";
|
|
||||||
error = i18next.t("general:QR code is too large");
|
|
||||||
}
|
|
||||||
|
|
||||||
return {qrUrl, error};
|
|
||||||
}
|
|
||||||
|
|
||||||
renderQrCode() {
|
|
||||||
const {qrUrl, error} = this.getQrUrl();
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return <Alert message={error} type="error" showIcon />;
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<QRCode
|
|
||||||
value={qrUrl}
|
|
||||||
icon={this.state.icon}
|
|
||||||
errorLevel="M"
|
|
||||||
size={230}
|
|
||||||
bordered={false}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
renderTable(table) {
|
renderTable(table) {
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
@@ -194,10 +159,25 @@ class MfaAccountTable extends React.Component {
|
|||||||
title={() => (
|
title={() => (
|
||||||
<div>
|
<div>
|
||||||
{this.props.title}
|
{this.props.title}
|
||||||
<Button style={{marginRight: "10px"}} type="primary" size="small" onClick={() => this.addRow(table)}>{i18next.t("general:Add")}</Button>
|
<Button style={{marginRight: "10px"}} type="primary" size="small" onClick={() => this.addRow(table)}>
|
||||||
<Popover trigger="focus" overlayInnerStyle={{padding: 0}}
|
{i18next.t("general:Add")}
|
||||||
content={this.renderQrCode()}>
|
</Button>
|
||||||
<Button style={{marginLeft: "5px"}} type="primary" size="small">{i18next.t("general:QR Code")}</Button>
|
<Popover
|
||||||
|
trigger="focus"
|
||||||
|
overlayInnerStyle={{padding: 0}}
|
||||||
|
content={<CasdoorAppQrCode accessToken={this.props.accessToken} icon={this.state.icon} />}
|
||||||
|
>
|
||||||
|
<Button style={{marginRight: "10px"}} type="primary" size="small">
|
||||||
|
{i18next.t("general:QR Code")}
|
||||||
|
</Button>
|
||||||
|
</Popover>
|
||||||
|
<Popover
|
||||||
|
trigger="click"
|
||||||
|
content={<CasdoorAppUrl accessToken={this.props.accessToken} />}
|
||||||
|
>
|
||||||
|
<Button type="primary" size="small">
|
||||||
|
{i18next.t("general:Show URL")}
|
||||||
|
</Button>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
Reference in New Issue
Block a user