mirror of
https://github.com/strapi/strapi.git
synced 2025-09-20 05:52:08 +00:00
Merge pull request #12722 from strapi/chore/events
Add events to monitor first administrator account creation
This commit is contained in:
commit
a79cafc670
@ -15,7 +15,13 @@ import { Grid, GridItem } from '@strapi/design-system/Grid';
|
|||||||
import { Typography } from '@strapi/design-system/Typography';
|
import { Typography } from '@strapi/design-system/Typography';
|
||||||
import EyeStriked from '@strapi/icons/EyeStriked';
|
import EyeStriked from '@strapi/icons/EyeStriked';
|
||||||
import Eye from '@strapi/icons/Eye';
|
import Eye from '@strapi/icons/Eye';
|
||||||
import { Form, useQuery, useNotification } from '@strapi/helper-plugin';
|
import {
|
||||||
|
Form,
|
||||||
|
useQuery,
|
||||||
|
useNotification,
|
||||||
|
useTracking,
|
||||||
|
getYupInnerErrors,
|
||||||
|
} from '@strapi/helper-plugin';
|
||||||
import { useHistory } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { Formik } from 'formik';
|
import { Formik } from 'formik';
|
||||||
@ -41,12 +47,14 @@ const PasswordInput = styled(TextInput)`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Register = ({ fieldsToDisable, noSignin, onSubmit, schema }) => {
|
const Register = ({ authType, fieldsToDisable, noSignin, onSubmit, schema }) => {
|
||||||
const toggleNotification = useNotification();
|
const toggleNotification = useNotification();
|
||||||
const { push } = useHistory();
|
const { push } = useHistory();
|
||||||
const [passwordShown, setPasswordShown] = useState(false);
|
const [passwordShown, setPasswordShown] = useState(false);
|
||||||
const [confirmPasswordShown, setConfirmPasswordShown] = useState(false);
|
const [confirmPasswordShown, setConfirmPasswordShown] = useState(false);
|
||||||
|
const [submitCount, setSubmitCount] = useState(0);
|
||||||
const [userInfo, setUserInfo] = useState({});
|
const [userInfo, setUserInfo] = useState({});
|
||||||
|
const { trackUsage } = useTracking();
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
const query = useQuery();
|
const query = useQuery();
|
||||||
const registrationToken = query.get('registrationToken');
|
const registrationToken = query.get('registrationToken');
|
||||||
@ -97,225 +105,218 @@ const Register = ({ fieldsToDisable, noSignin, onSubmit, schema }) => {
|
|||||||
registrationToken: registrationToken || undefined,
|
registrationToken: registrationToken || undefined,
|
||||||
news: false,
|
news: false,
|
||||||
}}
|
}}
|
||||||
onSubmit={(data, formik) => {
|
onSubmit={async (data, formik) => {
|
||||||
if (registrationToken) {
|
try {
|
||||||
// We need to pass the registration token in the url param to the api in order to submit another admin user
|
await schema.validate(data, { abortEarly: false });
|
||||||
onSubmit({ userInfo: omit(data, ['registrationToken']), registrationToken }, formik);
|
|
||||||
} else {
|
if (submitCount > 0 && authType === 'register-admin') {
|
||||||
onSubmit(data, formik);
|
trackUsage('didSubmitWithErrorsFirstAdmin', { count: submitCount.toString() });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registrationToken) {
|
||||||
|
// We need to pass the registration token in the url param to the api in order to submit another admin user
|
||||||
|
onSubmit(
|
||||||
|
{ userInfo: omit(data, ['registrationToken']), registrationToken },
|
||||||
|
formik
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
onSubmit(data, formik);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const errors = getYupInnerErrors(err);
|
||||||
|
setSubmitCount(submitCount + 1);
|
||||||
|
|
||||||
|
formik.setErrors(errors);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
validationSchema={schema}
|
// Leaving this part commented when we remove the tracking for the submitCount
|
||||||
|
// validationSchema={schema}
|
||||||
validateOnChange={false}
|
validateOnChange={false}
|
||||||
>
|
>
|
||||||
{({ values, errors, handleChange }) => (
|
{({ values, errors, handleChange }) => {
|
||||||
<Form noValidate>
|
return (
|
||||||
<Main>
|
<Form noValidate>
|
||||||
<Column>
|
<Main>
|
||||||
<Logo />
|
<Column>
|
||||||
<Box paddingTop={6} paddingBottom={1}>
|
<Logo />
|
||||||
<Typography as="h1" variant="alpha">
|
<Box paddingTop={6} paddingBottom={1}>
|
||||||
{formatMessage({
|
<Typography as="h1" variant="alpha">
|
||||||
id: 'Auth.form.welcome.title',
|
{formatMessage({
|
||||||
defaultMessage: 'Welcome!',
|
id: 'Auth.form.welcome.title',
|
||||||
})}
|
defaultMessage: 'Welcome!',
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
<CenteredBox paddingBottom={7}>
|
|
||||||
<Typography variant="epsilon" textColor="neutral600">
|
|
||||||
{formatMessage({
|
|
||||||
id: 'Auth.form.register.subtitle',
|
|
||||||
defaultMessage:
|
|
||||||
'Your credentials are only used to authenticate yourself on the admin panel. All saved data will be stored in your own database.',
|
|
||||||
})}
|
|
||||||
</Typography>
|
|
||||||
</CenteredBox>
|
|
||||||
</Column>
|
|
||||||
<Stack size={7}>
|
|
||||||
<Grid gap={4}>
|
|
||||||
<GridItem col={6}>
|
|
||||||
<TextInput
|
|
||||||
name="firstname"
|
|
||||||
required
|
|
||||||
value={values.firstname}
|
|
||||||
error={
|
|
||||||
errors.firstname
|
|
||||||
? formatMessage({
|
|
||||||
id: errors.firstname,
|
|
||||||
defaultMessage: 'This value is required.',
|
|
||||||
})
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onChange={handleChange}
|
|
||||||
label={formatMessage({
|
|
||||||
id: 'Auth.form.firstname.label',
|
|
||||||
defaultMessage: 'Firstname',
|
|
||||||
})}
|
})}
|
||||||
/>
|
</Typography>
|
||||||
</GridItem>
|
</Box>
|
||||||
<GridItem col={6}>
|
<CenteredBox paddingBottom={7}>
|
||||||
<TextInput
|
<Typography variant="epsilon" textColor="neutral600">
|
||||||
name="lastname"
|
{formatMessage({
|
||||||
value={values.lastname}
|
id: 'Auth.form.register.subtitle',
|
||||||
onChange={handleChange}
|
defaultMessage:
|
||||||
label={formatMessage({
|
'Your credentials are only used to authenticate yourself on the admin panel. All saved data will be stored in your own database.',
|
||||||
id: 'Auth.form.lastname.label',
|
|
||||||
defaultMessage: 'Lastname',
|
|
||||||
})}
|
})}
|
||||||
/>
|
</Typography>
|
||||||
</GridItem>
|
</CenteredBox>
|
||||||
</Grid>
|
</Column>
|
||||||
<TextInput
|
<Stack size={7}>
|
||||||
name="email"
|
<Grid gap={4}>
|
||||||
disabled={fieldsToDisable.includes('email')}
|
<GridItem col={6}>
|
||||||
value={values.email}
|
<TextInput
|
||||||
onChange={handleChange}
|
name="firstname"
|
||||||
error={
|
required
|
||||||
errors.email
|
value={values.firstname}
|
||||||
? formatMessage({
|
error={errors.firstname ? formatMessage(errors.firstname) : undefined}
|
||||||
id: errors.email,
|
onChange={handleChange}
|
||||||
defaultMessage: 'This value is required.',
|
label={formatMessage({
|
||||||
})
|
id: 'Auth.form.firstname.label',
|
||||||
: undefined
|
defaultMessage: 'Firstname',
|
||||||
}
|
})}
|
||||||
required
|
/>
|
||||||
label={formatMessage({
|
</GridItem>
|
||||||
id: 'Auth.form.email.label',
|
<GridItem col={6}>
|
||||||
defaultMessage: 'Email',
|
<TextInput
|
||||||
})}
|
name="lastname"
|
||||||
type="email"
|
value={values.lastname}
|
||||||
/>
|
onChange={handleChange}
|
||||||
<PasswordInput
|
label={formatMessage({
|
||||||
name="password"
|
id: 'Auth.form.lastname.label',
|
||||||
onChange={handleChange}
|
defaultMessage: 'Lastname',
|
||||||
value={values.password}
|
})}
|
||||||
error={
|
/>
|
||||||
errors.password
|
</GridItem>
|
||||||
? formatMessage({
|
</Grid>
|
||||||
id: errors.password,
|
<TextInput
|
||||||
defaultMessage: 'This value is required',
|
name="email"
|
||||||
})
|
disabled={fieldsToDisable.includes('email')}
|
||||||
: undefined
|
value={values.email}
|
||||||
}
|
onChange={handleChange}
|
||||||
endAction={
|
error={errors.email ? formatMessage(errors.email) : undefined}
|
||||||
// eslint-disable-next-line react/jsx-wrap-multilines
|
required
|
||||||
<FieldActionWrapper
|
label={formatMessage({
|
||||||
onClick={e => {
|
id: 'Auth.form.email.label',
|
||||||
e.preventDefault();
|
defaultMessage: 'Email',
|
||||||
setPasswordShown(prev => !prev);
|
})}
|
||||||
}}
|
type="email"
|
||||||
label={formatMessage(
|
/>
|
||||||
passwordShown
|
<PasswordInput
|
||||||
? {
|
name="password"
|
||||||
id: 'Auth.form.password.show-password',
|
onChange={handleChange}
|
||||||
defaultMessage: 'Show password',
|
value={values.password}
|
||||||
}
|
error={errors.password ? formatMessage(errors.password) : undefined}
|
||||||
: {
|
endAction={
|
||||||
id: 'Auth.form.password.hide-password',
|
// eslint-disable-next-line react/jsx-wrap-multilines
|
||||||
defaultMessage: 'Hide password',
|
<FieldActionWrapper
|
||||||
}
|
onClick={e => {
|
||||||
)}
|
e.preventDefault();
|
||||||
>
|
setPasswordShown(prev => !prev);
|
||||||
{passwordShown ? <Eye /> : <EyeStriked />}
|
}}
|
||||||
</FieldActionWrapper>
|
label={formatMessage(
|
||||||
}
|
passwordShown
|
||||||
hint={formatMessage({
|
? {
|
||||||
id: 'Auth.form.password.hint',
|
id: 'Auth.form.password.show-password',
|
||||||
defaultMessage:
|
defaultMessage: 'Show password',
|
||||||
'Password must contain at least 8 characters, 1 uppercase, 1 lowercase and 1 number',
|
}
|
||||||
})}
|
: {
|
||||||
required
|
id: 'Auth.form.password.hide-password',
|
||||||
label={formatMessage({
|
defaultMessage: 'Hide password',
|
||||||
id: 'Auth.form.password.label',
|
}
|
||||||
defaultMessage: 'Password',
|
)}
|
||||||
})}
|
>
|
||||||
type={passwordShown ? 'text' : 'password'}
|
{passwordShown ? <Eye /> : <EyeStriked />}
|
||||||
/>
|
</FieldActionWrapper>
|
||||||
<PasswordInput
|
|
||||||
name="confirmPassword"
|
|
||||||
onChange={handleChange}
|
|
||||||
value={values.confirmPassword}
|
|
||||||
error={
|
|
||||||
errors.confirmPassword
|
|
||||||
? formatMessage({
|
|
||||||
id: errors.confirmPassword,
|
|
||||||
defaultMessage: 'This value is required.',
|
|
||||||
})
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
endAction={
|
|
||||||
// eslint-disable-next-line react/jsx-wrap-multilines
|
|
||||||
<FieldActionWrapper
|
|
||||||
onClick={e => {
|
|
||||||
e.preventDefault();
|
|
||||||
setConfirmPasswordShown(prev => !prev);
|
|
||||||
}}
|
|
||||||
label={formatMessage(
|
|
||||||
confirmPasswordShown
|
|
||||||
? {
|
|
||||||
id: 'Auth.form.password.show-password',
|
|
||||||
defaultMessage: 'Show password',
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
id: 'Auth.form.password.hide-password',
|
|
||||||
defaultMessage: 'Hide password',
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{confirmPasswordShown ? <Eye /> : <EyeStriked />}
|
|
||||||
</FieldActionWrapper>
|
|
||||||
}
|
|
||||||
required
|
|
||||||
label={formatMessage({
|
|
||||||
id: 'Auth.form.confirmPassword.label',
|
|
||||||
defaultMessage: 'Confirmation Password',
|
|
||||||
})}
|
|
||||||
type={confirmPasswordShown ? 'text' : 'password'}
|
|
||||||
/>
|
|
||||||
<Checkbox
|
|
||||||
onValueChange={checked => {
|
|
||||||
handleChange({ target: { value: checked, name: 'news' } });
|
|
||||||
}}
|
|
||||||
value={values.news}
|
|
||||||
name="news"
|
|
||||||
aria-label="news"
|
|
||||||
>
|
|
||||||
{formatMessage(
|
|
||||||
{
|
|
||||||
id: 'Auth.form.register.news.label',
|
|
||||||
defaultMessage:
|
|
||||||
'Keep me updated about the new features and upcoming improvements (by doing this you accept the {terms} and the {policy}).',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
terms: (
|
|
||||||
<A target="_blank" href="https://strapi.io/terms" rel="noreferrer">
|
|
||||||
{formatMessage({
|
|
||||||
id: 'Auth.privacy-policy-agreement.terms',
|
|
||||||
defaultMessage: 'terms',
|
|
||||||
})}
|
|
||||||
</A>
|
|
||||||
),
|
|
||||||
policy: (
|
|
||||||
<A target="_blank" href="https://strapi.io/privacy" rel="noreferrer">
|
|
||||||
{formatMessage({
|
|
||||||
id: 'Auth.privacy-policy-agreement.policy',
|
|
||||||
defaultMessage: 'policy',
|
|
||||||
})}
|
|
||||||
</A>
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
)}
|
hint={formatMessage({
|
||||||
</Checkbox>
|
id: 'Auth.form.password.hint',
|
||||||
<Button fullWidth size="L" type="submit">
|
defaultMessage:
|
||||||
{formatMessage({
|
'Password must contain at least 8 characters, 1 uppercase, 1 lowercase and 1 number',
|
||||||
id: 'Auth.form.button.register',
|
})}
|
||||||
defaultMessage: "Let's start",
|
required
|
||||||
})}
|
label={formatMessage({
|
||||||
</Button>
|
id: 'Auth.form.password.label',
|
||||||
</Stack>
|
defaultMessage: 'Password',
|
||||||
</Main>
|
})}
|
||||||
</Form>
|
type={passwordShown ? 'text' : 'password'}
|
||||||
)}
|
/>
|
||||||
|
<PasswordInput
|
||||||
|
name="confirmPassword"
|
||||||
|
onChange={handleChange}
|
||||||
|
value={values.confirmPassword}
|
||||||
|
error={
|
||||||
|
errors.confirmPassword ? formatMessage(errors.confirmPassword) : undefined
|
||||||
|
}
|
||||||
|
endAction={
|
||||||
|
// eslint-disable-next-line react/jsx-wrap-multilines
|
||||||
|
<FieldActionWrapper
|
||||||
|
onClick={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
setConfirmPasswordShown(prev => !prev);
|
||||||
|
}}
|
||||||
|
label={formatMessage(
|
||||||
|
confirmPasswordShown
|
||||||
|
? {
|
||||||
|
id: 'Auth.form.password.show-password',
|
||||||
|
defaultMessage: 'Show password',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
id: 'Auth.form.password.hide-password',
|
||||||
|
defaultMessage: 'Hide password',
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{confirmPasswordShown ? <Eye /> : <EyeStriked />}
|
||||||
|
</FieldActionWrapper>
|
||||||
|
}
|
||||||
|
required
|
||||||
|
label={formatMessage({
|
||||||
|
id: 'Auth.form.confirmPassword.label',
|
||||||
|
defaultMessage: 'Confirmation Password',
|
||||||
|
})}
|
||||||
|
type={confirmPasswordShown ? 'text' : 'password'}
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
onValueChange={checked => {
|
||||||
|
handleChange({ target: { value: checked, name: 'news' } });
|
||||||
|
}}
|
||||||
|
value={values.news}
|
||||||
|
name="news"
|
||||||
|
aria-label="news"
|
||||||
|
>
|
||||||
|
{formatMessage(
|
||||||
|
{
|
||||||
|
id: 'Auth.form.register.news.label',
|
||||||
|
defaultMessage:
|
||||||
|
'Keep me updated about the new features and upcoming improvements (by doing this you accept the {terms} and the {policy}).',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
terms: (
|
||||||
|
<A target="_blank" href="https://strapi.io/terms" rel="noreferrer">
|
||||||
|
{formatMessage({
|
||||||
|
id: 'Auth.privacy-policy-agreement.terms',
|
||||||
|
defaultMessage: 'terms',
|
||||||
|
})}
|
||||||
|
</A>
|
||||||
|
),
|
||||||
|
policy: (
|
||||||
|
<A target="_blank" href="https://strapi.io/privacy" rel="noreferrer">
|
||||||
|
{formatMessage({
|
||||||
|
id: 'Auth.privacy-policy-agreement.policy',
|
||||||
|
defaultMessage: 'policy',
|
||||||
|
})}
|
||||||
|
</A>
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</Checkbox>
|
||||||
|
<Button fullWidth size="L" type="submit">
|
||||||
|
{formatMessage({
|
||||||
|
id: 'Auth.form.button.register',
|
||||||
|
defaultMessage: "Let's start",
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Main>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}}
|
||||||
</Formik>
|
</Formik>
|
||||||
{!noSignin && (
|
{!noSignin && (
|
||||||
<Box paddingTop={4}>
|
<Box paddingTop={4}>
|
||||||
@ -341,10 +342,14 @@ Register.defaultProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
Register.propTypes = {
|
Register.propTypes = {
|
||||||
|
authType: PropTypes.string.isRequired,
|
||||||
fieldsToDisable: PropTypes.array,
|
fieldsToDisable: PropTypes.array,
|
||||||
noSignin: PropTypes.bool,
|
noSignin: PropTypes.bool,
|
||||||
onSubmit: PropTypes.func,
|
onSubmit: PropTypes.func,
|
||||||
schema: PropTypes.shape({ type: PropTypes.string.isRequired }).isRequired,
|
schema: PropTypes.shape({
|
||||||
|
validate: PropTypes.func.isRequired,
|
||||||
|
type: PropTypes.string.isRequired,
|
||||||
|
}).isRequired,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Register;
|
export default Register;
|
||||||
|
@ -4,7 +4,7 @@ import camelCase from 'lodash/camelCase';
|
|||||||
import get from 'lodash/get';
|
import get from 'lodash/get';
|
||||||
import omit from 'lodash/omit';
|
import omit from 'lodash/omit';
|
||||||
import { Redirect, useRouteMatch, useHistory } from 'react-router-dom';
|
import { Redirect, useRouteMatch, useHistory } from 'react-router-dom';
|
||||||
import { auth, useQuery, useGuidedTour } from '@strapi/helper-plugin';
|
import { auth, useQuery, useGuidedTour, useTracking } from '@strapi/helper-plugin';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import forms from 'ee_else_ce/pages/AuthPage/utils/forms';
|
import forms from 'ee_else_ce/pages/AuthPage/utils/forms';
|
||||||
import persistStateToLocaleStorage from '../../components/GuidedTour/utils/persistStateToLocaleStorage';
|
import persistStateToLocaleStorage from '../../components/GuidedTour/utils/persistStateToLocaleStorage';
|
||||||
@ -17,6 +17,7 @@ const AuthPage = ({ hasAdmin, setHasAdmin }) => {
|
|||||||
const { push } = useHistory();
|
const { push } = useHistory();
|
||||||
const { changeLocale } = useLocalesProvider();
|
const { changeLocale } = useLocalesProvider();
|
||||||
const { setSkipped } = useGuidedTour();
|
const { setSkipped } = useGuidedTour();
|
||||||
|
const { trackUsage } = useTracking();
|
||||||
const {
|
const {
|
||||||
params: { authType },
|
params: { authType },
|
||||||
} = useRouteMatch('/auth/:authType');
|
} = useRouteMatch('/auth/:authType');
|
||||||
@ -146,6 +147,8 @@ const AuthPage = ({ hasAdmin, setHasAdmin }) => {
|
|||||||
|
|
||||||
const registerRequest = async (body, requestURL, { setSubmitting, setErrors }) => {
|
const registerRequest = async (body, requestURL, { setSubmitting, setErrors }) => {
|
||||||
try {
|
try {
|
||||||
|
trackUsage('willCreateFirstAdmin');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: {
|
data: {
|
||||||
data: { token, user },
|
data: { token, user },
|
||||||
@ -189,6 +192,8 @@ const AuthPage = ({ hasAdmin, setHasAdmin }) => {
|
|||||||
// Redirect to the homePage
|
// Redirect to the homePage
|
||||||
push('/');
|
push('/');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
trackUsage('didNotCreateFirstAdmin');
|
||||||
|
|
||||||
if (err.response) {
|
if (err.response) {
|
||||||
const { data } = err.response;
|
const { data } = err.response;
|
||||||
const apiErrors = formatAPIErrors(data);
|
const apiErrors = formatAPIErrors(data);
|
||||||
@ -249,6 +254,7 @@ const AuthPage = ({ hasAdmin, setHasAdmin }) => {
|
|||||||
return (
|
return (
|
||||||
<Component
|
<Component
|
||||||
{...rest}
|
{...rest}
|
||||||
|
authType={authType}
|
||||||
fieldsToDisable={fieldsToDisable}
|
fieldsToDisable={fieldsToDisable}
|
||||||
formErrors={formErrors}
|
formErrors={formErrors}
|
||||||
inputsPrefix={inputsPrefix}
|
inputsPrefix={inputsPrefix}
|
||||||
|
@ -205,7 +205,13 @@ class Strapi {
|
|||||||
this.config.get('admin.autoOpen', true) !== false;
|
this.config.get('admin.autoOpen', true) !== false;
|
||||||
|
|
||||||
if (shouldOpenAdmin && !isInitialized) {
|
if (shouldOpenAdmin && !isInitialized) {
|
||||||
await utils.openBrowser(this.config);
|
try {
|
||||||
|
await utils.openBrowser(this.config);
|
||||||
|
this.telemetry.send('didOpenTab');
|
||||||
|
} catch (e) {
|
||||||
|
this.telemetry.send('didNotOpenTab');
|
||||||
|
}
|
||||||
|
;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user