Merge branch 'master' into search
This commit is contained in:
commit
41d117f04e
|
@ -5,6 +5,7 @@ import constants from 'utils/strings/constants';
|
|||
export interface MessageAttributes {
|
||||
title?: string;
|
||||
staticBackdrop?: boolean;
|
||||
nonClosable?: boolean;
|
||||
content?: any;
|
||||
close?: { text?: string; variant?: string };
|
||||
proceed?: {
|
||||
|
@ -35,10 +36,14 @@ export default function MessageDialog({
|
|||
return (
|
||||
<Modal
|
||||
{...props}
|
||||
onHide={attributes.nonClosable ? () => null : props.onHide}
|
||||
centered
|
||||
backdrop={attributes.staticBackdrop ? 'static' : 'true'}
|
||||
>
|
||||
<Modal.Header style={{ borderBottom: 'none' }} closeButton>
|
||||
<Modal.Header
|
||||
style={{ borderBottom: 'none' }}
|
||||
closeButton={!attributes.nonClosable}
|
||||
>
|
||||
{attributes.title && (
|
||||
<Modal.Title>
|
||||
<strong>{attributes.title}</strong>
|
||||
|
@ -50,53 +55,55 @@ export default function MessageDialog({
|
|||
{children ? children : <h5>{attributes.content}</h5>}
|
||||
</Modal.Body>
|
||||
)}
|
||||
<Modal.Footer style={{ borderTop: 'none' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
{attributes.close && (
|
||||
<Button
|
||||
variant={`outline-${
|
||||
attributes.close?.variant ?? 'secondary'
|
||||
}`}
|
||||
onClick={props.onHide}
|
||||
style={{
|
||||
padding: '6px 3em',
|
||||
margin: '0 20px',
|
||||
marginBottom: '20px',
|
||||
flex: 1,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{attributes.close?.text ?? constants.OK}
|
||||
</Button>
|
||||
)}
|
||||
{attributes.proceed && (
|
||||
<Button
|
||||
variant={`outline-${
|
||||
attributes.proceed?.variant ?? 'primary'
|
||||
}`}
|
||||
onClick={() => {
|
||||
attributes.proceed.action();
|
||||
props.onHide();
|
||||
}}
|
||||
style={{
|
||||
padding: '6px 3em',
|
||||
margin: '0 20px',
|
||||
marginBottom: '20px',
|
||||
flex: 1,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
disabled={attributes.proceed.disabled}
|
||||
>
|
||||
{attributes.proceed.text}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Modal.Footer>
|
||||
{(attributes.close || attributes.proceed) && (
|
||||
<Modal.Footer style={{ borderTop: 'none' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
{attributes.close && (
|
||||
<Button
|
||||
variant={`outline-${
|
||||
attributes.close?.variant ?? 'secondary'
|
||||
}`}
|
||||
onClick={props.onHide}
|
||||
style={{
|
||||
padding: '6px 3em',
|
||||
margin: '0 20px',
|
||||
marginBottom: '20px',
|
||||
flex: 1,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{attributes.close?.text ?? constants.OK}
|
||||
</Button>
|
||||
)}
|
||||
{attributes.proceed && (
|
||||
<Button
|
||||
variant={`outline-${
|
||||
attributes.proceed?.variant ?? 'primary'
|
||||
}`}
|
||||
onClick={() => {
|
||||
attributes.proceed.action();
|
||||
props.onHide();
|
||||
}}
|
||||
style={{
|
||||
padding: '6px 3em',
|
||||
margin: '0 20px',
|
||||
marginBottom: '20px',
|
||||
flex: 1,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
disabled={attributes.proceed.disabled}
|
||||
>
|
||||
{attributes.proceed.text}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Modal.Footer>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,109 +0,0 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import Container from 'components/Container';
|
||||
import Button from 'react-bootstrap/Button';
|
||||
import constants from 'utils/strings/constants';
|
||||
import { Card, Form, Spinner } from 'react-bootstrap';
|
||||
import { Formik, FormikHelpers } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import SubmitButton from './SubmitButton';
|
||||
|
||||
interface formValues {
|
||||
passphrase: string;
|
||||
}
|
||||
interface Props {
|
||||
callback: (passphrase: string, setFieldError) => void;
|
||||
fieldType: string;
|
||||
title: string;
|
||||
placeholder: string;
|
||||
buttonText: string;
|
||||
alternateOption: { text: string; click: () => void };
|
||||
back: () => void;
|
||||
}
|
||||
|
||||
export default function PassPhraseForm(props: Props) {
|
||||
const [loading, SetLoading] = useState(false);
|
||||
const submitForm = async (
|
||||
values: formValues,
|
||||
{ setFieldError }: FormikHelpers<formValues>
|
||||
) => {
|
||||
SetLoading(true);
|
||||
await props.callback(values.passphrase, setFieldError);
|
||||
SetLoading(false);
|
||||
};
|
||||
return (
|
||||
<Container>
|
||||
<Card
|
||||
style={{ minWidth: '320px', padding: '40px 30px' }}
|
||||
className="text-center"
|
||||
>
|
||||
<Card.Body>
|
||||
<Card.Title style={{ marginBottom: '24px' }}>
|
||||
{props.title}
|
||||
</Card.Title>
|
||||
<Formik<formValues>
|
||||
initialValues={{ passphrase: '' }}
|
||||
onSubmit={submitForm}
|
||||
validationSchema={Yup.object().shape({
|
||||
passphrase: Yup.string().required(
|
||||
constants.REQUIRED
|
||||
),
|
||||
})}
|
||||
validateOnChange={false}
|
||||
validateOnBlur={false}
|
||||
>
|
||||
{({
|
||||
values,
|
||||
touched,
|
||||
errors,
|
||||
handleChange,
|
||||
handleSubmit,
|
||||
}) => (
|
||||
<Form noValidate onSubmit={handleSubmit}>
|
||||
<Form.Group>
|
||||
<Form.Control
|
||||
type={props.fieldType}
|
||||
placeholder={props.placeholder}
|
||||
value={values.passphrase}
|
||||
onChange={handleChange('passphrase')}
|
||||
isInvalid={Boolean(
|
||||
touched.passphrase &&
|
||||
errors.passphrase
|
||||
)}
|
||||
disabled={loading}
|
||||
autoFocus={true}
|
||||
/>
|
||||
<Form.Control.Feedback type="invalid">
|
||||
{errors.passphrase}
|
||||
</Form.Control.Feedback>
|
||||
</Form.Group>
|
||||
<SubmitButton
|
||||
buttonText={props.buttonText}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
<br />
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
marginTop: '12px',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={props.alternateOption.click}
|
||||
>
|
||||
{props.alternateOption.text}
|
||||
</Button>
|
||||
<Button variant="link" onClick={props.back}>
|
||||
{constants.GO_BACK}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Container>
|
||||
);
|
||||
}
|
|
@ -19,7 +19,7 @@ interface formValues {
|
|||
passphrase: string;
|
||||
confirm: string;
|
||||
}
|
||||
function SetPassword(props: Props) {
|
||||
function SetPasswordForm(props: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const onSubmit = async (
|
||||
values: formValues,
|
||||
|
@ -94,7 +94,7 @@ function SetPassword(props: Props) {
|
|||
<Form.Control
|
||||
type="password"
|
||||
placeholder={
|
||||
constants.PASSPHRASE_CONFIRM
|
||||
constants.RE_ENTER_PASSPHRASE
|
||||
}
|
||||
value={values.confirm}
|
||||
onChange={handleChange('confirm')}
|
||||
|
@ -129,4 +129,4 @@ function SetPassword(props: Props) {
|
|||
</Container>
|
||||
);
|
||||
}
|
||||
export default SetPassword;
|
||||
export default SetPasswordForm;
|
|
@ -32,7 +32,7 @@ interface Props {
|
|||
files: File[];
|
||||
collections: Collection[];
|
||||
setDialogMessage: SetDialogMessage;
|
||||
setPlanModalView;
|
||||
showPlanSelectorModal: () => void;
|
||||
}
|
||||
export default function Sidebar(props: Props) {
|
||||
const [usage, SetUsage] = useState<string>(null);
|
||||
|
@ -44,6 +44,7 @@ export default function Sidebar(props: Props) {
|
|||
}, []);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [recoverModalView, setRecoveryModalView] = useState(false);
|
||||
const [accountDeleteModalView, setAccountDeleteModalView] = useState(false);
|
||||
useEffect(() => {
|
||||
const main = async () => {
|
||||
if (!isOpen) {
|
||||
|
@ -93,7 +94,7 @@ export default function Sidebar(props: Props) {
|
|||
const router = useRouter();
|
||||
function onManageClick() {
|
||||
setIsOpen(false);
|
||||
props.setPlanModalView(true);
|
||||
props.showPlanSelectorModal();
|
||||
}
|
||||
return (
|
||||
<Menu
|
||||
|
@ -234,7 +235,7 @@ export default function Sidebar(props: Props) {
|
|||
></div>
|
||||
<LinkButton
|
||||
variant="danger"
|
||||
style={{ marginTop: '30px', marginBottom: '50px' }}
|
||||
style={{ marginTop: '30px' }}
|
||||
onClick={() =>
|
||||
props.setDialogMessage({
|
||||
title: `${constants.CONFIRM} ${constants.LOGOUT}`,
|
||||
|
@ -251,6 +252,7 @@ export default function Sidebar(props: Props) {
|
|||
>
|
||||
logout
|
||||
</LinkButton>
|
||||
<div style={{ marginBottom: '50px' }} />
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
|
66
src/components/SingleInputForm.tsx
Normal file
66
src/components/SingleInputForm.tsx
Normal file
|
@ -0,0 +1,66 @@
|
|||
import React, { useState } from 'react';
|
||||
import constants from 'utils/strings/constants';
|
||||
import { Form } from 'react-bootstrap';
|
||||
import { Formik, FormikHelpers } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import SubmitButton from './SubmitButton';
|
||||
|
||||
interface formValues {
|
||||
passphrase: string;
|
||||
}
|
||||
interface Props {
|
||||
callback: (passphrase: string, setFieldError) => void;
|
||||
fieldType: string;
|
||||
placeholder: string;
|
||||
buttonText: string;
|
||||
}
|
||||
|
||||
export default function SingleInputForm(props: Props) {
|
||||
const [loading, SetLoading] = useState(false);
|
||||
const submitForm = async (
|
||||
values: formValues,
|
||||
{ setFieldError }: FormikHelpers<formValues>
|
||||
) => {
|
||||
SetLoading(true);
|
||||
await props.callback(values.passphrase, setFieldError);
|
||||
SetLoading(false);
|
||||
};
|
||||
return (
|
||||
<Formik<formValues>
|
||||
initialValues={{ passphrase: '' }}
|
||||
onSubmit={submitForm}
|
||||
validationSchema={Yup.object().shape({
|
||||
passphrase: Yup.string().required(constants.REQUIRED),
|
||||
})}
|
||||
validateOnChange={false}
|
||||
validateOnBlur={false}
|
||||
>
|
||||
{({ values, touched, errors, handleChange, handleSubmit }) => (
|
||||
<Form noValidate onSubmit={handleSubmit}>
|
||||
<Form.Group>
|
||||
<Form.Control
|
||||
type={props.fieldType}
|
||||
placeholder={props.placeholder}
|
||||
value={values.passphrase}
|
||||
onChange={handleChange('passphrase')}
|
||||
isInvalid={Boolean(
|
||||
touched.passphrase && errors.passphrase
|
||||
)}
|
||||
disabled={loading}
|
||||
autoFocus={true}
|
||||
/>
|
||||
<Form.Control.Feedback type="invalid">
|
||||
{errors.passphrase}
|
||||
</Form.Control.Feedback>
|
||||
</Form.Group>
|
||||
<SubmitButton
|
||||
buttonText={props.buttonText}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
<br />
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
);
|
||||
}
|
|
@ -10,7 +10,7 @@ import CryptoWorker, {
|
|||
} from 'utils/crypto';
|
||||
import { getActualKey } from 'utils/common/key';
|
||||
import { logoutUser, setKeys, UpdatedKey } from 'services/userService';
|
||||
import PasswordForm from 'components/PasswordForm';
|
||||
import SetPasswordForm from 'components/SetPasswordForm';
|
||||
|
||||
export interface KEK {
|
||||
key: string;
|
||||
|
@ -44,10 +44,8 @@ export default function Generate() {
|
|||
setFieldError('confirm', constants.PASSWORD_GENERATION_FAILED);
|
||||
return;
|
||||
}
|
||||
const encryptedKeyAttributes: B64EncryptionResult = await cryptoWorker.encryptToB64(
|
||||
key,
|
||||
kek.key
|
||||
);
|
||||
const encryptedKeyAttributes: B64EncryptionResult =
|
||||
await cryptoWorker.encryptToB64(key, kek.key);
|
||||
const updatedKey: UpdatedKey = {
|
||||
kekSalt,
|
||||
encryptedKey: encryptedKeyAttributes.encryptedData,
|
||||
|
@ -73,7 +71,7 @@ export default function Generate() {
|
|||
router.push('/gallery');
|
||||
};
|
||||
return (
|
||||
<PasswordForm
|
||||
<SetPasswordForm
|
||||
callback={onSubmit}
|
||||
buttonText={constants.CHANGE_PASSWORD}
|
||||
back={
|
||||
|
|
|
@ -12,7 +12,9 @@ import CryptoWorker, {
|
|||
} from 'utils/crypto';
|
||||
import { logoutUser } from 'services/userService';
|
||||
import { isFirstLogin } from 'utils/storage';
|
||||
import PassPhraseForm from 'components/PassphraseForm';
|
||||
import SingleInputForm from 'components/SingleInputForm';
|
||||
import Container from 'components/Container';
|
||||
import { Button, Card } from 'react-bootstrap';
|
||||
|
||||
export default function Credentials() {
|
||||
const router = useRouter();
|
||||
|
@ -72,17 +74,42 @@ export default function Credentials() {
|
|||
};
|
||||
|
||||
return (
|
||||
<PassPhraseForm
|
||||
callback={verifyPassphrase}
|
||||
title={constants.ENTER_PASSPHRASE}
|
||||
placeholder={constants.RETURN_PASSPHRASE_HINT}
|
||||
buttonText={constants.VERIFY_PASSPHRASE}
|
||||
fieldType="password"
|
||||
alternateOption={{
|
||||
text: constants.FORGOT_PASSWORD,
|
||||
click: () => router.push('/recover'),
|
||||
}}
|
||||
back={logoutUser}
|
||||
/>
|
||||
<>
|
||||
<Container>
|
||||
<Card
|
||||
style={{ minWidth: '320px', padding: '40px 30px' }}
|
||||
className="text-center"
|
||||
>
|
||||
<Card.Body>
|
||||
<Card.Title style={{ marginBottom: '24px' }}>
|
||||
{constants.ENTER_PASSPHRASE}
|
||||
</Card.Title>
|
||||
<SingleInputForm
|
||||
callback={verifyPassphrase}
|
||||
placeholder={constants.RETURN_PASSPHRASE_HINT}
|
||||
buttonText={constants.VERIFY_PASSPHRASE}
|
||||
fieldType="password"
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
marginTop: '12px',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => router.push('/recover')}
|
||||
>
|
||||
{constants.FORGOT_PASSWORD}
|
||||
</Button>
|
||||
<Button variant="link" onClick={logoutUser}>
|
||||
{constants.GO_BACK}
|
||||
</Button>
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -194,6 +194,7 @@ export default function Gallery() {
|
|||
action: logoutUser,
|
||||
variant: 'primary',
|
||||
},
|
||||
nonClosable: true,
|
||||
});
|
||||
break;
|
||||
case errorCodes.ERR_KEY_MISSING:
|
||||
|
@ -328,7 +329,7 @@ export default function Gallery() {
|
|||
files={files}
|
||||
collections={collections}
|
||||
setDialogMessage={setDialogMessage}
|
||||
setPlanModalView={setPlanModalView}
|
||||
showPlanSelectorModal={() => setPlanModalView(true)}
|
||||
/>
|
||||
<UploadButton openFileUploader={openFileUploader} />
|
||||
<PhotoFrame
|
||||
|
|
|
@ -9,7 +9,7 @@ import CryptoWorker, {
|
|||
setSessionKeys,
|
||||
generateAndSaveIntermediateKeyAttributes,
|
||||
} from 'utils/crypto';
|
||||
import PasswordForm from 'components/PasswordForm';
|
||||
import SetPasswordForm from 'components/SetPasswordForm';
|
||||
import { KeyAttributes } from 'types';
|
||||
import { setJustSignedUp } from 'utils/storage';
|
||||
import RecoveryKeyModal from 'components/RecoveryKeyModal';
|
||||
|
@ -52,24 +52,16 @@ export default function Generate() {
|
|||
setFieldError('confirm', constants.PASSWORD_GENERATION_FAILED);
|
||||
return;
|
||||
}
|
||||
const masterKeyEncryptedWithKek: B64EncryptionResult = await cryptoWorker.encryptToB64(
|
||||
masterKey,
|
||||
kek.key
|
||||
);
|
||||
const masterKeyEncryptedWithRecoveryKey: B64EncryptionResult = await cryptoWorker.encryptToB64(
|
||||
masterKey,
|
||||
recoveryKey
|
||||
);
|
||||
const recoveryKeyEncryptedWithMasterKey: B64EncryptionResult = await cryptoWorker.encryptToB64(
|
||||
recoveryKey,
|
||||
masterKey
|
||||
);
|
||||
const masterKeyEncryptedWithKek: B64EncryptionResult =
|
||||
await cryptoWorker.encryptToB64(masterKey, kek.key);
|
||||
const masterKeyEncryptedWithRecoveryKey: B64EncryptionResult =
|
||||
await cryptoWorker.encryptToB64(masterKey, recoveryKey);
|
||||
const recoveryKeyEncryptedWithMasterKey: B64EncryptionResult =
|
||||
await cryptoWorker.encryptToB64(recoveryKey, masterKey);
|
||||
|
||||
const keyPair = await cryptoWorker.generateKeyPair();
|
||||
const encryptedKeyPairAttributes: B64EncryptionResult = await cryptoWorker.encryptToB64(
|
||||
keyPair.privateKey,
|
||||
masterKey
|
||||
);
|
||||
const encryptedKeyPairAttributes: B64EncryptionResult =
|
||||
await cryptoWorker.encryptToB64(keyPair.privateKey, masterKey);
|
||||
|
||||
const keyAttributes: KeyAttributes = {
|
||||
kekSalt,
|
||||
|
@ -101,7 +93,7 @@ export default function Generate() {
|
|||
|
||||
return (
|
||||
<>
|
||||
<PasswordForm
|
||||
<SetPasswordForm
|
||||
callback={onSubmit}
|
||||
buttonText={constants.SET_PASSPHRASE}
|
||||
back={logoutUser}
|
||||
|
|
|
@ -5,8 +5,10 @@ import { getData, LS_KEYS } from 'utils/storage/localStorage';
|
|||
import { useRouter } from 'next/router';
|
||||
import { KeyAttributes } from 'types';
|
||||
import CryptoWorker, { setSessionKeys } from 'utils/crypto';
|
||||
import PassPhraseForm from 'components/PassphraseForm';
|
||||
import SingleInputForm from 'components/SingleInputForm';
|
||||
import MessageDialog from 'components/MessageDialog';
|
||||
import Container from 'components/Container';
|
||||
import { Card, Button } from 'react-bootstrap';
|
||||
|
||||
export default function Recover() {
|
||||
const router = useRouter();
|
||||
|
@ -43,28 +45,51 @@ export default function Recover() {
|
|||
|
||||
return (
|
||||
<>
|
||||
<PassPhraseForm
|
||||
callback={recover}
|
||||
fieldType="text"
|
||||
title={constants.RECOVER_ACCOUNT}
|
||||
placeholder={constants.RETURN_RECOVERY_KEY_HINT}
|
||||
buttonText={constants.RECOVER}
|
||||
alternateOption={{
|
||||
text: constants.NO_RECOVERY_KEY,
|
||||
click: () => SetMessageDialogView(true),
|
||||
}}
|
||||
back={router.back}
|
||||
/>
|
||||
<Container>
|
||||
<Card
|
||||
style={{ minWidth: '320px', padding: '40px 30px' }}
|
||||
className="text-center"
|
||||
>
|
||||
<Card.Body>
|
||||
<Card.Title style={{ marginBottom: '24px' }}>
|
||||
{constants.RECOVER_ACCOUNT}
|
||||
</Card.Title>
|
||||
<SingleInputForm
|
||||
callback={recover}
|
||||
fieldType="text"
|
||||
placeholder={constants.RETURN_RECOVERY_KEY_HINT}
|
||||
buttonText={constants.RECOVER}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
marginTop: '12px',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => SetMessageDialogView(true)}
|
||||
>
|
||||
{constants.NO_RECOVERY_KEY}
|
||||
</Button>
|
||||
<Button variant="link" onClick={router.back}>
|
||||
{constants.GO_BACK}
|
||||
</Button>
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Container>
|
||||
<MessageDialog
|
||||
size={'lg'}
|
||||
show={messageDialogView}
|
||||
onHide={() => SetMessageDialogView(false)}
|
||||
attributes={{
|
||||
title: constants.SORRY,
|
||||
close: {},
|
||||
content: constants.NO_RECOVERY_KEY_MESSAGE,
|
||||
}}
|
||||
>
|
||||
{constants.NO_RECOVERY_KEY_MESSAGE}
|
||||
</MessageDialog>
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -51,7 +51,8 @@ const englishConstants = {
|
|||
</p>
|
||||
),
|
||||
PASSPHRASE_HINT: 'password',
|
||||
PASSPHRASE_CONFIRM: 'password again',
|
||||
RE_ENTER_PASSPHRASE: 'password again',
|
||||
CONFIRM_PASSPHRASE: 'confirm your password',
|
||||
PASSPHRASE_MATCH_ERROR: `passwords don't match`,
|
||||
CONSOLE_WARNING_STOP: 'STOP!',
|
||||
CONSOLE_WARNING_DESC: `This is a browser feature intended for developers. Please don't copy-paste unverified code here.`,
|
||||
|
|
Loading…
Reference in a new issue