feat(ui) - signup password enforce (#1272)
* feat(ui) - enforce pwd during signup (#1271) * change(ui) - antd dependency
This commit is contained in:
parent
7847753b55
commit
6bb9181787
9 changed files with 1220 additions and 313 deletions
|
|
@ -13,7 +13,7 @@ import { withStore } from 'App/mstore';
|
|||
import APIClient from './api_client';
|
||||
import * as routes from './routes';
|
||||
import { OB_DEFAULT_TAB, isRoute } from 'App/routes';
|
||||
import Signup from './components/Signup/Signup';
|
||||
import Signup from 'Components/Signup';
|
||||
import { fetchTenants } from 'Duck/user';
|
||||
import { setSessionPath } from 'Duck/sessions';
|
||||
import { ModalProvider } from './components/Modal';
|
||||
|
|
|
|||
|
|
@ -1,101 +0,0 @@
|
|||
import React from 'react';
|
||||
import withPageTitle from 'HOCs/withPageTitle';
|
||||
import { Icon } from 'UI';
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
import cn from 'classnames';
|
||||
import SignupForm from './SignupForm';
|
||||
import RegisterBg from '../../svg/register.svg';
|
||||
import HealthModal from 'Components/Header/HealthStatus/HealthModal/HealthModal';
|
||||
import { getHealthRequest } from 'Components/Header/HealthStatus/getHealth';
|
||||
import { login } from 'App/routes';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { fetchTenants } from 'Duck/user';
|
||||
import Copyright from 'Shared/Copyright';
|
||||
|
||||
const LOGIN_ROUTE = login();
|
||||
const BulletItem = ({ text }) => (
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="mr-3 h-8 w-8 rounded-full bg-white shadow flex items-center justify-center">
|
||||
<Icon name="check" size="26" />
|
||||
</div>
|
||||
<div>{text}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const healthStatusCheck_key = '__or__healthStatusCheck_key'
|
||||
|
||||
@connect(
|
||||
(state, props) => ({
|
||||
loading: state.getIn(['user', 'loginRequest', 'loading']),
|
||||
authDetails: state.getIn(['user', 'authDetails']),
|
||||
}), { fetchTenants }
|
||||
)
|
||||
@withPageTitle('Signup - OpenReplay')
|
||||
@withRouter
|
||||
export default class Signup extends React.Component {
|
||||
state = {
|
||||
healthModalPassed: localStorage.getItem(healthStatusCheck_key === 'true'),
|
||||
healthStatusLoading: true,
|
||||
healthStatus: null,
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(nextProps, prevState) {
|
||||
const { authDetails } = nextProps;
|
||||
if (Object.keys(authDetails).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (authDetails.tenants) {
|
||||
nextProps.history.push(LOGIN_ROUTE);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
getHealth = async () => {
|
||||
this.setState({ healthStatusLoading: true });
|
||||
const { healthMap } = await getHealthRequest(true);
|
||||
this.setState({ healthStatus: healthMap, healthStatusLoading: false });
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (!this.state.healthModalPassed) void this.getHealth();
|
||||
|
||||
const { authDetails } = this.props;
|
||||
if (Object.keys(authDetails).length === 0) {
|
||||
this.props.fetchTenants();
|
||||
}
|
||||
}
|
||||
|
||||
setHealthModalPassed = () => {
|
||||
localStorage.setItem(healthStatusCheck_key, 'true');
|
||||
this.setState({ healthModalPassed: true });
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.state.healthModalPassed) {
|
||||
return (
|
||||
<HealthModal
|
||||
setShowModal={() => null}
|
||||
healthResponse={this.state.healthStatus}
|
||||
getHealth={this.getHealth}
|
||||
isLoading={this.state.healthStatusLoading}
|
||||
setPassed={this.setHealthModalPassed}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center gap-6" style={{ height: '100vh' }}>
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="">
|
||||
<SignupForm />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Copyright />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
91
frontend/app/components/Signup/Signup.tsx
Normal file
91
frontend/app/components/Signup/Signup.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { withRouter, RouteComponentProps } from 'react-router-dom';
|
||||
import { connect, ConnectedProps } from 'react-redux';
|
||||
import { Icon } from 'UI';
|
||||
import SignupForm from './SignupForm';
|
||||
import HealthModal from 'Components/Header/HealthStatus/HealthModal/HealthModal';
|
||||
import { getHealthRequest } from 'Components/Header/HealthStatus/getHealth';
|
||||
import { fetchTenants } from 'Duck/user';
|
||||
import withPageTitle from 'HOCs/withPageTitle';
|
||||
import { login } from 'App/routes';
|
||||
import Copyright from 'Shared/Copyright';
|
||||
|
||||
const LOGIN_ROUTE = login();
|
||||
const BulletItem: React.FC<{ text: string }> = ({ text }) => (
|
||||
<div className='flex items-center mb-4'>
|
||||
<div className='mr-3 h-8 w-8 rounded-full bg-white shadow flex items-center justify-center'>
|
||||
<Icon name='check' size='26' />
|
||||
</div>
|
||||
<div>{text}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const healthStatusCheck_key = '__or__healthStatusCheck_key';
|
||||
|
||||
const mapStateToProps = (state: any) => ({
|
||||
loading: state.getIn(['user', 'loginRequest', 'loading']),
|
||||
authDetails: state.getIn(['user', 'authDetails'])
|
||||
});
|
||||
|
||||
const mapDispatchToProps = {
|
||||
fetchTenants
|
||||
};
|
||||
|
||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||
|
||||
type PropsFromRedux = ConnectedProps<typeof connector>;
|
||||
|
||||
type SignupProps = PropsFromRedux & RouteComponentProps;
|
||||
|
||||
const Signup: React.FC<SignupProps> = ({ loading, authDetails, fetchTenants, history }) => {
|
||||
const [healthModalPassed, setHealthModalPassed] = useState<boolean>(localStorage.getItem(healthStatusCheck_key) === 'true');
|
||||
const [healthStatusLoading, setHealthStatusLoading] = useState<boolean>(true);
|
||||
const [healthStatus, setHealthStatus] = useState<any>(null);
|
||||
|
||||
const getHealth = async () => {
|
||||
setHealthStatusLoading(true);
|
||||
const { healthMap } = await getHealthRequest(true);
|
||||
setHealthStatus(healthMap);
|
||||
setHealthStatusLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!healthModalPassed) void getHealth();
|
||||
|
||||
if (Object.keys(authDetails).length === 0) {
|
||||
fetchTenants();
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (Object.keys(authDetails).length === 0) {
|
||||
history.push(LOGIN_ROUTE);
|
||||
}
|
||||
}, [authDetails]);
|
||||
|
||||
if (!healthModalPassed) {
|
||||
return (
|
||||
<HealthModal
|
||||
setShowModal={() => null}
|
||||
healthResponse={healthStatus}
|
||||
getHealth={getHealth}
|
||||
isLoading={healthStatusLoading}
|
||||
setPassed={() => setHealthModalPassed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex justify-center items-center gap-6' style={{ height: '100vh' }}>
|
||||
<div className='flex items-center justify-center'>
|
||||
<div className=''>
|
||||
<SignupForm />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Copyright />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default connector(withRouter(withPageTitle('Signup - OpenReplay')(Signup)));
|
||||
|
|
@ -1,201 +0,0 @@
|
|||
import React from 'react';
|
||||
import { Form, Input, Icon, Button, Link } from 'UI';
|
||||
import { login } from 'App/routes';
|
||||
import ReCAPTCHA from 'react-google-recaptcha';
|
||||
import stl from './signup.module.css';
|
||||
import { signup } from 'Duck/user';
|
||||
import { connect } from 'react-redux';
|
||||
import Select from 'Shared/Select';
|
||||
import { SITE_ID_STORAGE_KEY } from 'App/constants/storageKeys';
|
||||
|
||||
const LOGIN_ROUTE = login();
|
||||
const recaptchaRef = React.createRef();
|
||||
|
||||
@connect(
|
||||
(state) => ({
|
||||
tenants: state.getIn(['user', 'tenants']),
|
||||
errors: state.getIn(['user', 'signupRequest', 'errors']),
|
||||
loading: state.getIn(['user', 'signupRequest', 'loading']),
|
||||
}),
|
||||
{ signup }
|
||||
)
|
||||
export default class SignupForm extends React.Component {
|
||||
state = {
|
||||
tenantId: '',
|
||||
fullname: '',
|
||||
password: '',
|
||||
email: '',
|
||||
projectName: '',
|
||||
organizationName: '',
|
||||
reload: false,
|
||||
CAPTCHA_ENABLED: window.env.CAPTCHA_ENABLED === 'true',
|
||||
};
|
||||
|
||||
static getDerivedStateFromProps(props, state) {
|
||||
if (props.errors && props.errors.size > 0 && state.reload) {
|
||||
recaptchaRef.current.reset();
|
||||
return {
|
||||
reload: false,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
handleSubmit = (token) => {
|
||||
const { tenantId, fullname, password, email, projectName, organizationName, auth } = this.state;
|
||||
localStorage.removeItem(SITE_ID_STORAGE_KEY);
|
||||
this.props.signup({
|
||||
tenantId,
|
||||
fullname,
|
||||
password,
|
||||
email,
|
||||
projectName,
|
||||
organizationName,
|
||||
auth,
|
||||
'g-recaptcha-response': token,
|
||||
});
|
||||
this.setState({ reload: true });
|
||||
};
|
||||
|
||||
write = ({ target: { value, name } }) => this.setState({ [name]: value });
|
||||
writeOption = ({ name, value }) => this.setState({ [name]: value.value });
|
||||
|
||||
onSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
const { CAPTCHA_ENABLED } = this.state;
|
||||
if (CAPTCHA_ENABLED && recaptchaRef.current) {
|
||||
recaptchaRef.current.execute();
|
||||
} else if (!CAPTCHA_ENABLED) {
|
||||
this.handleSubmit();
|
||||
}
|
||||
};
|
||||
render() {
|
||||
const { loading, errors, tenants } = this.props;
|
||||
const { CAPTCHA_ENABLED } = this.state;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="m-10 ">
|
||||
<img src="/assets/logo.svg" width={200} />
|
||||
</div>
|
||||
<Form onSubmit={this.onSubmit} className="bg-white border rounded">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-center text-2xl font-medium mb-6 border-b p-5 w-full">
|
||||
Create Account
|
||||
</h2>
|
||||
</div>
|
||||
<>
|
||||
{CAPTCHA_ENABLED && (
|
||||
<ReCAPTCHA
|
||||
ref={recaptchaRef}
|
||||
size="invisible"
|
||||
sitekey={window.env.CAPTCHA_SITE_KEY}
|
||||
onChange={(token) => this.handleSubmit(token)}
|
||||
/>
|
||||
)}
|
||||
<div className="px-8">
|
||||
{tenants.length > 0 && (
|
||||
<Form.Field>
|
||||
<label>Existing Accounts</label>
|
||||
<Select
|
||||
className="w-full"
|
||||
placeholder="Select account"
|
||||
selection
|
||||
options={tenants}
|
||||
name="tenantId"
|
||||
// value={ instance.currentPeriod }
|
||||
onChange={this.writeOption}
|
||||
/>
|
||||
</Form.Field>
|
||||
)}
|
||||
<Form.Field>
|
||||
<label>Email Address</label>
|
||||
<Input
|
||||
autoFocus={true}
|
||||
autoComplete="username"
|
||||
type="email"
|
||||
placeholder="E.g. email@yourcompany.com"
|
||||
name="email"
|
||||
onChange={this.write}
|
||||
required="true"
|
||||
icon="envelope"
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<label className="mb-2">Password</label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Min 8 Characters"
|
||||
minLength="8"
|
||||
name="password"
|
||||
onChange={this.write}
|
||||
required="true"
|
||||
icon="key"
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<label>Name</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="E.g John Doe"
|
||||
name="fullname"
|
||||
onChange={this.write}
|
||||
required="true"
|
||||
icon="user-alt"
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<label>Organization</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="E.g Uber"
|
||||
name="organizationName"
|
||||
onChange={this.write}
|
||||
required="true"
|
||||
icon="buildings"
|
||||
/>
|
||||
</Form.Field>
|
||||
|
||||
<Button type="submit" variant="primary" loading={loading} className="w-full">
|
||||
Create Account
|
||||
</Button>
|
||||
<div className="my-6">
|
||||
<div className="text-sm">
|
||||
By signing up, you agree to our{' '}
|
||||
<a href="https://openreplay.com/terms.html" className="link">
|
||||
terms of service
|
||||
</a>{' '}
|
||||
and{' '}
|
||||
<a href="https://openreplay.com/privacy.html" className="link">
|
||||
privacy policy
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
{errors && (
|
||||
<div className={stl.errors}>
|
||||
{errors.map((error) => (
|
||||
<div className={stl.errorItem}>
|
||||
<Icon name="info" color="red" size="20" />
|
||||
<span className="color-red ml-2">
|
||||
{error}
|
||||
<br />
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
|
||||
<div className="text-center py-6">
|
||||
Already having an account?{' '}
|
||||
<span className="link">
|
||||
<Link to={LOGIN_ROUTE}>Login</Link>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
217
frontend/app/components/Signup/SignupForm/SignupForm.tsx
Normal file
217
frontend/app/components/Signup/SignupForm/SignupForm.tsx
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import React, { useState, useRef, ChangeEvent, FormEvent, useEffect } from 'react';
|
||||
import { Form, Input, Button, Link } from 'UI';
|
||||
import { login } from 'App/routes';
|
||||
import ReCAPTCHA from 'react-google-recaptcha';
|
||||
import { signup } from 'Duck/user';
|
||||
import { connect, ConnectedProps } from 'react-redux';
|
||||
import Select from 'Shared/Select';
|
||||
import { SITE_ID_STORAGE_KEY } from 'App/constants/storageKeys';
|
||||
import { validatePassword } from 'App/validate';
|
||||
import { PASSWORD_POLICY } from 'App/constants';
|
||||
import { Alert, Space } from 'antd';
|
||||
|
||||
const LOGIN_ROUTE = login();
|
||||
|
||||
const mapState = (state: any) => ({
|
||||
tenants: state.getIn(['user', 'tenants']),
|
||||
errors: state.getIn(['user', 'signupRequest', 'errors']),
|
||||
loading: state.getIn(['user', 'signupRequest', 'loading'])
|
||||
});
|
||||
|
||||
const mapDispatch = {
|
||||
signup
|
||||
};
|
||||
|
||||
const connector = connect(mapState, mapDispatch);
|
||||
|
||||
type PropsFromRedux = ConnectedProps<typeof connector>;
|
||||
|
||||
type SignupFormProps = PropsFromRedux;
|
||||
|
||||
const SignupForm: React.FC<SignupFormProps> = ({ tenants, errors, loading, signup }) => {
|
||||
const [state, setState] = useState({
|
||||
tenantId: '',
|
||||
fullname: '',
|
||||
password: '',
|
||||
email: '',
|
||||
projectName: '',
|
||||
organizationName: '',
|
||||
reload: false,
|
||||
CAPTCHA_ENABLED: window.env.CAPTCHA_ENABLED === 'true'
|
||||
});
|
||||
const recaptchaRef = useRef<ReCAPTCHA>(null);
|
||||
const [passwordError, setPasswordError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = (token: string) => {
|
||||
const { tenantId, fullname, password, email, projectName, organizationName, auth } = state;
|
||||
if (!validatePassword(password)) return;
|
||||
localStorage.removeItem(SITE_ID_STORAGE_KEY);
|
||||
signup({
|
||||
tenantId,
|
||||
fullname,
|
||||
password,
|
||||
email,
|
||||
projectName,
|
||||
organizationName,
|
||||
auth,
|
||||
'g-recaptcha-response': token
|
||||
});
|
||||
setState({ ...state, reload: true });
|
||||
};
|
||||
|
||||
const write = ({ target: { value, name } }: ChangeEvent<HTMLInputElement>) =>
|
||||
setState({ ...state, [name]: value });
|
||||
|
||||
const writeOption = ({ name, value }: { name: string; value: { value: string } }) =>
|
||||
setState({ ...state, [name]: value.value });
|
||||
|
||||
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const { CAPTCHA_ENABLED } = state;
|
||||
if (CAPTCHA_ENABLED && recaptchaRef.current) {
|
||||
recaptchaRef.current.execute();
|
||||
} else if (!CAPTCHA_ENABLED) {
|
||||
handleSubmit('');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (state.password && !validatePassword(state.password)) {
|
||||
setPasswordError('Password must be at least 8 characters long');
|
||||
} else {
|
||||
setPasswordError(null);
|
||||
}
|
||||
}, [state.password]);
|
||||
|
||||
return (
|
||||
<div className='flex flex-col items-center'>
|
||||
<div className='m-10 '>
|
||||
<img src='/assets/logo.svg' width={200} alt='Logo' />
|
||||
</div>
|
||||
<Form onSubmit={onSubmit} className='bg-white border rounded' style={{ maxWidth: '420px' }}>
|
||||
<div className='mb-8'>
|
||||
<h2 className='text-center text-2xl font-medium mb-6 border-b p-5 w-full'>
|
||||
Create Account
|
||||
</h2>
|
||||
</div>
|
||||
<>
|
||||
{state.CAPTCHA_ENABLED && (
|
||||
<ReCAPTCHA
|
||||
ref={recaptchaRef}
|
||||
size='invisible'
|
||||
sitekey={window.env.CAPTCHA_SITE_KEY}
|
||||
onChange={(token) => handleSubmit(token || '')}
|
||||
/>
|
||||
)}
|
||||
<div className='px-8'>
|
||||
{tenants.length > 0 && (
|
||||
<Form.Field>
|
||||
<label>Existing Accounts</label>
|
||||
<Select
|
||||
className='w-full'
|
||||
placeholder='Select account'
|
||||
selection
|
||||
options={tenants}
|
||||
name='tenantId'
|
||||
// value={ instance.currentPeriod }
|
||||
onChange={writeOption}
|
||||
/>
|
||||
</Form.Field>
|
||||
)}
|
||||
<Form.Field>
|
||||
<label>Email Address</label>
|
||||
<Input
|
||||
autoFocus={true}
|
||||
autoComplete='username'
|
||||
type='email'
|
||||
placeholder='E.g. email@yourcompany.com'
|
||||
name='email'
|
||||
onChange={write}
|
||||
required={true}
|
||||
icon='envelope'
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<label className='mb-2'>Password</label>
|
||||
<Input
|
||||
type='password'
|
||||
placeholder='Min 8 Characters'
|
||||
minLength={8}
|
||||
name='password'
|
||||
onChange={write}
|
||||
required={true}
|
||||
icon='key'
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<label>Name</label>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='E.g John Doe'
|
||||
name='fullname'
|
||||
onChange={write}
|
||||
required={true}
|
||||
icon='user-alt'
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field>
|
||||
<label>Organization</label>
|
||||
<Input
|
||||
type='text'
|
||||
placeholder='E.g Uber'
|
||||
name='organizationName'
|
||||
onChange={write}
|
||||
required={true}
|
||||
icon='buildings'
|
||||
/>
|
||||
</Form.Field>
|
||||
|
||||
{passwordError && (
|
||||
// <Alert type='error' message={PASSWORD_POLICY} banner icon={null} />
|
||||
<Alert
|
||||
className='my-3'
|
||||
// message="Error Text"
|
||||
description={PASSWORD_POLICY}
|
||||
type='error'
|
||||
/>
|
||||
)}
|
||||
{errors && errors.length && (
|
||||
<Alert
|
||||
className='my-3'
|
||||
// message="Error Text"
|
||||
description={errors[0]}
|
||||
type='error'
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button type='submit' variant='primary' loading={loading} className='w-full'>
|
||||
Create Account
|
||||
</Button>
|
||||
<div className='my-6'>
|
||||
<div className='text-sm'>
|
||||
By signing up, you agree to our{' '}
|
||||
<a href='https://openreplay.com/terms.html' className='link'>
|
||||
terms of service
|
||||
</a>{' '}
|
||||
and{' '}
|
||||
<a href='https://openreplay.com/privacy.html' className='link'>
|
||||
privacy policy
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</Form>
|
||||
|
||||
<div className='text-center py-6'>
|
||||
Already having an account?{' '}
|
||||
<span className='link'>
|
||||
<Link to={LOGIN_ROUTE}>Login</Link>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default connector(SignupForm);
|
||||
1
frontend/app/components/Signup/index.ts
Normal file
1
frontend/app/components/Signup/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from './Signup'
|
||||
|
|
@ -27,6 +27,7 @@
|
|||
"@sentry/browser": "^5.21.1",
|
||||
"@svg-maps/world": "^1.0.1",
|
||||
"@svgr/webpack": "^6.2.1",
|
||||
"antd": "^5.5.0",
|
||||
"chroma-js": "^2.4.2",
|
||||
"classnames": "^2.3.1",
|
||||
"copy-to-clipboard": "^3.3.1",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue