mirror of
https://github.com/filecoin-project/slate.git
synced 2024-10-26 22:39:39 +03:00
fix(auth): radd missing code
This commit is contained in:
parent
c6482e64e3
commit
9fd63292cf
@ -527,3 +527,24 @@ export const createSurvey = async (data) => {
|
||||
body: JSON.stringify({ data }),
|
||||
});
|
||||
};
|
||||
|
||||
export const linkTwitterAccount = async (data) => {
|
||||
return await returnJSON(`/api/twitter/link`, {
|
||||
...DEFAULT_OPTIONS,
|
||||
body: JSON.stringify({ data }),
|
||||
});
|
||||
};
|
||||
|
||||
export const linkTwitterAccountWithVerification = async (data) => {
|
||||
return await returnJSON(`/api/twitter/link-with-verification`, {
|
||||
...DEFAULT_OPTIONS,
|
||||
body: JSON.stringify({ data }),
|
||||
});
|
||||
};
|
||||
|
||||
export const resendPasswordResetVerification = async (data) => {
|
||||
return await returnJSON(`/api/verifications/password-reset/resend`, {
|
||||
...DEFAULT_OPTIONS,
|
||||
body: JSON.stringify({ data }),
|
||||
});
|
||||
};
|
||||
|
@ -127,7 +127,8 @@ export const useForm = ({
|
||||
let errors = {};
|
||||
try {
|
||||
setInternal((prev) => ({ ...prev, isValidating: true }));
|
||||
errors = await validate(state.values, { ...state.errors });
|
||||
console.log("submitting errors", state);
|
||||
errors = await validate(state.values, {});
|
||||
if (_hasError(errors)) return;
|
||||
} catch (e) {
|
||||
Logging.error(e);
|
||||
|
@ -277,6 +277,21 @@ export const copyText = (str) => {
|
||||
return true;
|
||||
};
|
||||
|
||||
//NOTE(martina): createUsername is like createSlug, except allowing _ instead of -, and instead of replacing invalid characters it simply removes them
|
||||
export const createUsername = (text) => {
|
||||
if (isEmpty(text)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
text = text
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9_]/g, "");
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
// SOURCE(jim):
|
||||
// https://gist.github.com/mathewbyrne/1280286
|
||||
// modified to support chinese characters, base case, and german.
|
||||
|
@ -7,7 +7,7 @@ export const Loader = (props) => {
|
||||
strokeWidth="1.25"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
height={props.height}
|
||||
{...props}
|
||||
style={{ display: "block", ...props.style }}
|
||||
>
|
||||
<path d="M8 1.68237V4.34904" />
|
||||
|
@ -32,6 +32,10 @@ export const encryptPasswordClient = async (text) => {
|
||||
return hash;
|
||||
};
|
||||
|
||||
export const getRandomNumberBetween = (min, max) => {
|
||||
return Math.round(Math.random() * (max - min) + min);
|
||||
};
|
||||
|
||||
export const coerceToArray = (input) => {
|
||||
if (!input) {
|
||||
return [];
|
||||
|
@ -8,17 +8,18 @@ import * as Strings from "~/common/strings";
|
||||
import * as Styles from "~/common/styles";
|
||||
|
||||
import { css } from "@emotion/react";
|
||||
import { motion, AnimateSharedLayout } from "framer-motion";
|
||||
import { useForm, useField } from "~/common/hooks";
|
||||
import { AnimateSharedLayout } from "framer-motion";
|
||||
import { useField } from "~/common/hooks";
|
||||
import { Toggle, SignUpPopover, ArrowButton } from "~/components/core/Auth/components";
|
||||
import { LoaderSpinner } from "~/components/system/components/Loaders";
|
||||
|
||||
import Field from "~/components/core/Field";
|
||||
import { Toggle, SignUpPopover } from "~/components/core/Auth/components";
|
||||
|
||||
const STYLES_INITIAL_CONTAINER = css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
`;
|
||||
|
||||
const STYLES_LINK_ITEM = (theme) => css`
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
@ -31,11 +32,9 @@ const STYLES_LINK_ITEM = (theme) => css`
|
||||
color: ${theme.system.black};
|
||||
transition: 200ms ease all;
|
||||
word-wrap: break-word;
|
||||
|
||||
:visited {
|
||||
color: ${theme.system.black};
|
||||
}
|
||||
|
||||
:hover {
|
||||
color: ${theme.system.blue};
|
||||
}
|
||||
@ -72,22 +71,14 @@ export default function Initial({
|
||||
const { TOGGLE_OPTIONS, toggleValue, handleToggleChange } = useToggler(page);
|
||||
|
||||
// NOTE(amine): Signup view form
|
||||
const {
|
||||
getFieldProps,
|
||||
getFormProps,
|
||||
isSubmitting: isCheckingEmail,
|
||||
} = useForm({
|
||||
const { getFieldProps: getSignupFielProps, isSubmitting: isCheckingEmail } = useField({
|
||||
validateOnBlur: false,
|
||||
initialValues: { email: initialEmail || "" },
|
||||
validate: ({ email }, errors) => {
|
||||
if (Strings.isEmpty(email)) {
|
||||
errors.email = "Please provide an email";
|
||||
} else if (!Validations.email(email)) {
|
||||
errors.email = "Invalid email address";
|
||||
}
|
||||
return errors;
|
||||
initialValues: initialEmail || "",
|
||||
validate: (email) => {
|
||||
if (Strings.isEmpty(email)) return "Please provide an email";
|
||||
else if (!Validations.email(email)) return "Invalid email address";
|
||||
},
|
||||
onSubmit: async ({ email }) => {
|
||||
onSubmit: async (email) => {
|
||||
const response = await Actions.checkEmail({ email });
|
||||
if (response?.data?.twitter) {
|
||||
Events.dispatchMessage({
|
||||
@ -171,61 +162,36 @@ export default function Initial({
|
||||
// NOTE(amine): the input component internally is using 16px margin top
|
||||
containerStyle={{ marginTop: "4px" }}
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: "auto" }}>
|
||||
<a css={STYLES_LINK_ITEM} href="/terms" target="_blank">
|
||||
<div css={Styles.HORIZONTAL_CONTAINER_CENTERED}>
|
||||
<SVG.RightArrow height="16px" style={{ marginRight: 4 }} /> Terms of service
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a css={STYLES_LINK_ITEM} style={{ marginTop: 4 }} href="/guidelines" target="_blank">
|
||||
<div css={Styles.HORIZONTAL_CONTAINER_CENTERED}>
|
||||
<SVG.RightArrow height="16px" style={{ marginRight: 4 }} /> Community guidelines
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<AnimateSharedLayout>
|
||||
<form {...getFormProps()}>
|
||||
<Field
|
||||
autoFocus
|
||||
label="Sign up with email"
|
||||
placeholder="Email"
|
||||
type="email"
|
||||
name="email"
|
||||
full
|
||||
style={{ backgroundColor: "rgba(242,242,247,0.5)" }}
|
||||
// NOTE(amine): the input component internally is using 16px margin top
|
||||
containerStyle={{ marginTop: "4px" }}
|
||||
{...getFieldProps("email")}
|
||||
/>
|
||||
|
||||
<motion.div layout>
|
||||
<System.ButtonPrimary
|
||||
full
|
||||
type="submit"
|
||||
style={{ marginTop: "8px" }}
|
||||
loading={isCheckingEmail}
|
||||
>
|
||||
Send verification code
|
||||
</System.ButtonPrimary>
|
||||
</motion.div>
|
||||
</form>
|
||||
<div style={{ marginTop: "auto" }}>
|
||||
<a css={STYLES_LINK_ITEM} href="/terms" target="_blank">
|
||||
<div css={Styles.HORIZONTAL_CONTAINER_CENTERED}>
|
||||
<SVG.RightArrow height="16px" style={{ marginRight: 4 }} /> Terms of service
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a css={STYLES_LINK_ITEM} style={{ marginTop: 4 }} href="/guidelines" target="_blank">
|
||||
<div css={Styles.HORIZONTAL_CONTAINER_CENTERED}>
|
||||
<SVG.RightArrow height="16px" style={{ marginRight: 4 }} /> Community guidelines
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<Field
|
||||
autoFocus
|
||||
label="Sign up with email"
|
||||
placeholder="Email"
|
||||
type="text"
|
||||
name="email"
|
||||
full
|
||||
style={{ backgroundColor: "rgba(242,242,247,0.5)" }}
|
||||
icon={
|
||||
isCheckingEmail
|
||||
? () => (
|
||||
<LoaderSpinner
|
||||
style={{
|
||||
height: 16,
|
||||
width: 16,
|
||||
marginLeft: 16,
|
||||
position: "relative",
|
||||
right: 12,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: ArrowButton
|
||||
}
|
||||
// NOTE(amine): the input component internally is using 16px margin top
|
||||
containerStyle={{ marginTop: "4px" }}
|
||||
{...getSignupFielProps("email")}
|
||||
/>
|
||||
</AnimateSharedLayout>
|
||||
)}
|
||||
</div>
|
||||
|
@ -50,21 +50,20 @@ const useCheckUser = () => {
|
||||
};
|
||||
};
|
||||
|
||||
const createValidations = (validateUsername) => async (
|
||||
{ username, password, acceptTerms },
|
||||
errors
|
||||
) => {
|
||||
await validateUsername({ username }, errors);
|
||||
const createValidations =
|
||||
(validateUsername) =>
|
||||
async ({ username, password, acceptTerms }, errors) => {
|
||||
await validateUsername({ username }, errors);
|
||||
|
||||
if (!Validations.username(username)) errors.username = "Invalid username";
|
||||
// Note(amine): username should not be an email
|
||||
if (Validations.email(username)) errors.username = "Username shouldn't be an email";
|
||||
if (!Validations.username(username)) errors.username = "Invalid username";
|
||||
// Note(amine): username should not be an email
|
||||
if (Validations.email(username)) errors.username = "Username shouldn't be an email";
|
||||
|
||||
if (!Validations.password(password)) errors.password = "Incorrect password";
|
||||
if (!Validations.password(password)) errors.password = "Incorrect password";
|
||||
|
||||
if (!acceptTerms) errors.acceptTerms = "Must accept terms and conditions";
|
||||
return errors;
|
||||
};
|
||||
if (!acceptTerms) errors.acceptTerms = "Must accept terms and conditions";
|
||||
return errors;
|
||||
};
|
||||
|
||||
export default function Signup({ verifyEmail, createUser, resendEmailVerification }) {
|
||||
const [passwordValidations, setPasswordValidations] = React.useState(
|
||||
@ -110,7 +109,7 @@ export default function Signup({ verifyEmail, createUser, resendEmailVerificatio
|
||||
height: 16,
|
||||
width: 16,
|
||||
marginLeft: 16,
|
||||
position: "absolute",
|
||||
position: "relative",
|
||||
right: 12,
|
||||
}}
|
||||
/>
|
||||
|
166
components/core/Auth/TwitterLinking.js
Normal file
166
components/core/Auth/TwitterLinking.js
Normal file
@ -0,0 +1,166 @@
|
||||
/* eslint-disable jsx-a11y/no-autofocus */
|
||||
import * as React from "react";
|
||||
import * as Validations from "~/common/validations";
|
||||
import * as System from "~/components/system";
|
||||
import * as Strings from "~/common/strings";
|
||||
|
||||
import Field from "~/components/core/Field";
|
||||
|
||||
import { AnimateSharedLayout, motion } from "framer-motion";
|
||||
import { useForm } from "~/common/hooks";
|
||||
|
||||
import { SignUpPopover, Verification, AuthCheckBox } from "~/components/core/Auth/components";
|
||||
|
||||
const useTwitterLinking = () => {
|
||||
// NOTE(amine): can be either 'account' | 'email' | 'verificatiom'
|
||||
const [scene, setScene] = React.useState("account");
|
||||
const handlers = React.useMemo(
|
||||
() => ({
|
||||
goToEmailScene: () => setScene("email"),
|
||||
goToVerificationScene: () => setScene("verification"),
|
||||
}),
|
||||
[]
|
||||
);
|
||||
return { ...handlers, scene };
|
||||
};
|
||||
|
||||
const MotionLayout = ({ children, ...props }) => (
|
||||
<motion.div layout {...props}>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
const handleValidation = async ({ username, password, acceptTerms }, errors) => {
|
||||
if (!Validations.username(username) && !Validations.email(username))
|
||||
errors.username = "Invalid username";
|
||||
|
||||
if (!Validations.legacyPassword(password)) errors.password = "Incorrect password";
|
||||
|
||||
if (!acceptTerms) errors.acceptTerms = "Must accept terms and conditions";
|
||||
return errors;
|
||||
};
|
||||
|
||||
export default function TwitterLinking({
|
||||
linkAccount,
|
||||
linkAccountWithVerification,
|
||||
resendEmailVerification,
|
||||
createVerification,
|
||||
}) {
|
||||
const { scene, goToVerificationScene, goToEmailScene } = useTwitterLinking();
|
||||
|
||||
const {
|
||||
getFieldProps,
|
||||
getFormProps,
|
||||
values: { username },
|
||||
isSubmitting,
|
||||
} = useForm({
|
||||
initialValues: { username: "", password: "", acceptTerms: false },
|
||||
validate: handleValidation,
|
||||
onSubmit: async ({ username, password }) => {
|
||||
const response = await linkAccount({ username, password });
|
||||
if (response.shouldMigrate) {
|
||||
goToEmailScene();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
getFormProps: getEmailFormProps,
|
||||
getFieldProps: getEmailFieldProps,
|
||||
isSubmitting: isEmailFormSubmitting,
|
||||
} = useForm({
|
||||
initialValues: { email: "" },
|
||||
validate: ({ email }, errors) => {
|
||||
if (Strings.isEmpty(email)) {
|
||||
errors.email = "Please provide an email";
|
||||
} else if (!Validations.email(email)) {
|
||||
errors.email = "Invalid email address";
|
||||
}
|
||||
return errors;
|
||||
},
|
||||
onSubmit: async ({ email }) => {
|
||||
const response = await createVerification({ email });
|
||||
if (!response) return;
|
||||
goToVerificationScene();
|
||||
},
|
||||
});
|
||||
|
||||
if (scene === "verification") {
|
||||
const handleVerification = async ({ pin }) => {
|
||||
await linkAccountWithVerification({ pin });
|
||||
};
|
||||
return <Verification onVerify={handleVerification} onResend={resendEmailVerification} />;
|
||||
}
|
||||
|
||||
if (scene === "email") {
|
||||
return (
|
||||
<div>
|
||||
<SignUpPopover title={`Please add an email address for ${username}`}>
|
||||
<form {...getEmailFormProps()} style={{ marginTop: 72 }}>
|
||||
<Field
|
||||
autoFocus
|
||||
containerStyle={{ marginTop: 16 }}
|
||||
placeholder="Email"
|
||||
name="email"
|
||||
type="email"
|
||||
full
|
||||
{...getEmailFieldProps("email")}
|
||||
/>
|
||||
<AnimateSharedLayout>
|
||||
<motion.div layout>
|
||||
<System.ButtonPrimary
|
||||
full
|
||||
type="submit"
|
||||
loading={isEmailFormSubmitting}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
Send verification code
|
||||
</System.ButtonPrimary>
|
||||
</motion.div>
|
||||
</AnimateSharedLayout>
|
||||
</form>
|
||||
</SignUpPopover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SignUpPopover title="Create an account">
|
||||
<form {...getFormProps()}>
|
||||
<Field
|
||||
autoFocus
|
||||
containerStyle={{ marginTop: 41 }}
|
||||
placeholder="Username"
|
||||
name="username"
|
||||
type="text"
|
||||
full
|
||||
{...getFieldProps("username")}
|
||||
/>
|
||||
<AnimateSharedLayout>
|
||||
<Field
|
||||
containerStyle={{ marginTop: 16 }}
|
||||
containerAs={MotionLayout}
|
||||
errorAs={MotionLayout}
|
||||
placeholder="password"
|
||||
name="password"
|
||||
type="password"
|
||||
full
|
||||
{...getFieldProps("password")}
|
||||
/>
|
||||
|
||||
<motion.div layout>
|
||||
<AuthCheckBox style={{ marginTop: 16 }} {...getFieldProps("acceptTerms")} />
|
||||
<System.ButtonPrimary
|
||||
full
|
||||
style={{ marginTop: 36 }}
|
||||
loading={isSubmitting}
|
||||
type="submit"
|
||||
>
|
||||
Connect to Twitter
|
||||
</System.ButtonPrimary>
|
||||
</motion.div>
|
||||
</AnimateSharedLayout>
|
||||
</form>
|
||||
</SignUpPopover>
|
||||
);
|
||||
}
|
@ -1,7 +1,10 @@
|
||||
/* eslint-disable jsx-a11y/no-autofocus */
|
||||
import * as React from "react";
|
||||
import * as System from "~/components/system";
|
||||
import * as Validations from "~/common/validations";
|
||||
import * as Actions from "~/common/actions";
|
||||
import * as Styles from "~/common/styles";
|
||||
import * as Strings from "~/common/strings";
|
||||
|
||||
import Field from "~/components/core/Field";
|
||||
|
||||
@ -12,12 +15,16 @@ import { useForm } from "~/common/hooks";
|
||||
|
||||
import { SignUpPopover, Verification, AuthCheckBox } from "~/components/core/Auth/components";
|
||||
|
||||
const STYLES_SMALL = (theme) => css`
|
||||
font-size: ${theme.typescale.lvlN1};
|
||||
const STYLES_LINK = (theme) => css`
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
max-width: 224px;
|
||||
text-align: center;
|
||||
color: ${theme.semantic.textGrayDark};
|
||||
max-width: 228px;
|
||||
margin: 0 auto;
|
||||
background-color: unset;
|
||||
border: none;
|
||||
`;
|
||||
|
||||
const useTwitterSignup = () => {
|
||||
@ -27,7 +34,7 @@ const useTwitterSignup = () => {
|
||||
};
|
||||
|
||||
const useCheckUser = () => {
|
||||
const MESSAGE = "The username is taken.";
|
||||
const MESSAGE = "The username is taken";
|
||||
|
||||
const usernamesAllowed = React.useRef([]);
|
||||
const usernamesTaken = React.useRef([]);
|
||||
@ -46,7 +53,7 @@ const useCheckUser = () => {
|
||||
username,
|
||||
});
|
||||
if (response.data) {
|
||||
errors.username = "The username is taken.";
|
||||
errors.username = "The username is taken";
|
||||
usernamesTaken.current.push(username);
|
||||
return;
|
||||
}
|
||||
@ -54,22 +61,19 @@ const useCheckUser = () => {
|
||||
};
|
||||
};
|
||||
|
||||
const createValidations = (validateUsername) => async (
|
||||
{ username, email, acceptTerms },
|
||||
errors
|
||||
) => {
|
||||
await validateUsername({ username }, errors);
|
||||
const createValidations =
|
||||
(validateUsername) =>
|
||||
async ({ username, acceptTerms }, errors) => {
|
||||
await validateUsername({ username }, errors);
|
||||
|
||||
if (!Validations.username(username)) errors.username = "Invalid username";
|
||||
// Note(amine): username should not be an email
|
||||
if (Validations.email(username)) errors.username = "Username shouldn't be an email";
|
||||
if (!Validations.username(username)) errors.username = "Invalid username";
|
||||
// Note(amine): username should not be an email
|
||||
if (Validations.email(username)) errors.username = "Username shouldn't be an email";
|
||||
|
||||
if (!Validations.email(email)) errors.email = "Invalid email";
|
||||
if (!acceptTerms) errors.acceptTerms = "Must accept terms and conditions";
|
||||
|
||||
if (!acceptTerms) errors.acceptTerms = "Must accept terms and conditions";
|
||||
|
||||
return errors;
|
||||
};
|
||||
return errors;
|
||||
};
|
||||
|
||||
const MotionLayout = ({ children, ...props }) => (
|
||||
<motion.div layout {...props}>
|
||||
@ -80,6 +84,8 @@ const MotionLayout = ({ children, ...props }) => (
|
||||
export default function TwitterSignup({
|
||||
initialEmail,
|
||||
onSignup,
|
||||
goToTwitterLinkingScene,
|
||||
resendEmailVerification,
|
||||
createVerification,
|
||||
onSignupWithVerification,
|
||||
}) {
|
||||
@ -90,11 +96,12 @@ export default function TwitterSignup({
|
||||
const {
|
||||
getFieldProps,
|
||||
getFormProps,
|
||||
values: { email, username },
|
||||
values: { username },
|
||||
isSubmitting,
|
||||
isValidating,
|
||||
} = useForm({
|
||||
initialValues: { username: "", email: initialEmail, acceptTerms: false },
|
||||
format: { username: Strings.createUsername },
|
||||
validate: createValidations(validateUsername),
|
||||
onSubmit: async ({ username, email }) => {
|
||||
if (email !== initialEmail) {
|
||||
@ -111,7 +118,7 @@ export default function TwitterSignup({
|
||||
const handleVerification = async ({ pin }) => {
|
||||
await onSignupWithVerification({ username, pin });
|
||||
};
|
||||
return <Verification onVerify={handleVerification} />;
|
||||
return <Verification onVerify={handleVerification} onResend={resendEmailVerification} />;
|
||||
}
|
||||
|
||||
return (
|
||||
@ -123,7 +130,7 @@ export default function TwitterSignup({
|
||||
placeholder="Username"
|
||||
name="username"
|
||||
type="text"
|
||||
success="The username is available."
|
||||
success="The username is available"
|
||||
icon={
|
||||
isValidating
|
||||
? () => (
|
||||
@ -132,7 +139,7 @@ export default function TwitterSignup({
|
||||
height: 16,
|
||||
width: 16,
|
||||
marginLeft: 16,
|
||||
position: "absolute",
|
||||
position: "relative",
|
||||
right: 12,
|
||||
}}
|
||||
/>
|
||||
@ -140,7 +147,7 @@ export default function TwitterSignup({
|
||||
: null
|
||||
}
|
||||
{...getFieldProps("username")}
|
||||
style={{ backgroundColor: "rgba(242,242,247,0.5)" }}
|
||||
full
|
||||
/>
|
||||
<AnimateSharedLayout>
|
||||
<Field
|
||||
@ -150,8 +157,8 @@ export default function TwitterSignup({
|
||||
placeholder="Email"
|
||||
name="email"
|
||||
type="email"
|
||||
full
|
||||
{...getFieldProps("email")}
|
||||
style={{ backgroundColor: "rgba(242,242,247,0.5)" }}
|
||||
/>
|
||||
|
||||
<motion.div layout>
|
||||
@ -165,13 +172,18 @@ export default function TwitterSignup({
|
||||
Create account
|
||||
</System.ButtonPrimary>
|
||||
</motion.div>
|
||||
{(!initialEmail || initialEmail !== email) && (
|
||||
<motion.div layout>
|
||||
<System.P1 css={STYLES_SMALL} style={{ marginTop: 16 }}>
|
||||
You will receive a code to verify your email at this address
|
||||
</System.P1>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<motion.div layout>
|
||||
<div style={{ textAlign: "center", marginTop: 24 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToTwitterLinkingScene}
|
||||
css={[Styles.LINK, STYLES_LINK]}
|
||||
>
|
||||
Already have an account? Connect your account to Twitter.
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimateSharedLayout>
|
||||
</form>
|
||||
</SignUpPopover>
|
||||
|
@ -10,7 +10,7 @@ import { AnimateSharedLayout, motion } from "framer-motion";
|
||||
import { LoaderSpinner } from "~/components/system/components/Loaders";
|
||||
import { css } from "@emotion/react";
|
||||
import { useField } from "~/common/hooks";
|
||||
import { SignUpPopover } from "~/components/core/Auth/components";
|
||||
import { SignUpPopover, ArrowButton } from "~/components/core/Auth/components";
|
||||
|
||||
const STYLES_HELPER = (theme) => css`
|
||||
text-align: center;
|
||||
@ -114,7 +114,7 @@ export default function Verification({ onVerify, title = DEFAULT_TITLE, onResend
|
||||
<LoaderSpinner height="16px" />
|
||||
</div>
|
||||
)
|
||||
: SVG.RightArrow
|
||||
: ArrowButton
|
||||
}
|
||||
textStyle={{ width: "100% !important" }}
|
||||
containerStyle={{ marginTop: "28px" }}
|
||||
|
@ -2,3 +2,4 @@ export { default as Toggle } from "~/components/core/Auth/components/Toggle";
|
||||
export { default as SignUpPopover } from "~/components/core/Auth/components/SignUpPopover";
|
||||
export { default as Verification } from "~/components/core/Auth/components/Verification";
|
||||
export { default as AuthCheckBox } from "~/components/core/Auth/components/AuthCheckBox";
|
||||
export { default as ArrowButton } from "~/components/core/Auth/components/ArrowButton";
|
||||
|
@ -2,4 +2,5 @@ export { default as Signin } from "~/components/core/Auth/Signin";
|
||||
export { default as Signup } from "~/components/core/Auth/Signup";
|
||||
export { default as Initial } from "~/components/core/Auth/Initial";
|
||||
export { default as TwitterSignup } from "~/components/core/Auth/TwitterSignup";
|
||||
export { default as TwitterLinking } from "~/components/core/Auth/TwitterLinking";
|
||||
export { default as ResetPassword } from "~/components/core/Auth/ResetPassword";
|
||||
|
@ -43,7 +43,7 @@ const STYLES_CIRCLE_SUCCESS = (theme) => css`
|
||||
`;
|
||||
|
||||
const STYLES_INPUT = (theme) => css`
|
||||
background-color: rgba(242, 242, 247, 0.7);
|
||||
background-color: rgba(242, 242, 247, 0.5);
|
||||
box-shadow: ${theme.shadow.lightLarge};
|
||||
border-radius: 12px;
|
||||
&::placeholder {
|
||||
@ -53,6 +53,7 @@ const STYLES_INPUT = (theme) => css`
|
||||
const STYLES_INPUT_ERROR = (theme) => css`
|
||||
background-color: rgba(242, 242, 247, 0.5);
|
||||
border: 1px solid ${theme.system.red};
|
||||
border-radius: 8px;
|
||||
&::placeholder {
|
||||
color: ${theme.semantic.textGrayDark};
|
||||
}
|
||||
@ -60,6 +61,7 @@ const STYLES_INPUT_ERROR = (theme) => css`
|
||||
const STYLES_INPUT_SUCCESS = (theme) => css`
|
||||
background-color: rgba(242, 242, 247, 0.5);
|
||||
border: 1px solid ${theme.system.green};
|
||||
border-radius: 8px;
|
||||
&::placeholder {
|
||||
color: ${theme.semantic.textGrayDark};
|
||||
}
|
||||
|
@ -204,7 +204,6 @@ const STYLES_LOADER_PROGRESS = css`
|
||||
// export const LoaderSpinner = (props) => <div css={STYLES_LOADER_SPINNER} {...props} />;
|
||||
|
||||
const STYLES_LOADER_SPINNER = css`
|
||||
display: inline-block;
|
||||
animation: slate-client-animation-spin 1.5s cubic-bezier(0.5, 0.1, 0.4, 0.7) infinite;
|
||||
|
||||
@keyframes slate-client-animation-spin {
|
||||
@ -221,10 +220,11 @@ const STYLES_LOADER_SPINNER = css`
|
||||
|
||||
export const LoaderProgress = (props) => <div css={STYLES_LOADER_PROGRESS} {...props} />;
|
||||
|
||||
export const LoaderSpinner = (props) => (
|
||||
<span css={STYLES_LOADER_SPINNER}>
|
||||
export const LoaderSpinner = ({ css, ...props }) => (
|
||||
<span>
|
||||
<SVG.Loader
|
||||
{...props}
|
||||
css={[STYLES_LOADER_SPINNER, css]}
|
||||
style={{
|
||||
display: "block",
|
||||
color: Constants.system.blue,
|
||||
|
@ -118,7 +118,7 @@ export const parseAuthHeader = (value) => {
|
||||
};
|
||||
|
||||
export const getFilecoinAPIFromUserToken = async ({ user }) => {
|
||||
const textileKey = user.textileKey;
|
||||
const { textileKey } = user;
|
||||
const identity = await PrivateKey.fromString(textileKey);
|
||||
const filecoin = await Filecoin.withKeyInfo(TEXTILE_KEY_INFO);
|
||||
await filecoin.getToken(identity);
|
||||
|
@ -55,20 +55,37 @@ export default async (req, res) => {
|
||||
return res.status(403).send({ decorator: "SERVER_TWITTER_LOGIN_ONLY", error: true });
|
||||
}
|
||||
|
||||
const hash = await Utilities.encryptPassword(req.body.data.password, user.salt);
|
||||
let userUpdates = { id: user.id, lastActive: new Date() };
|
||||
|
||||
let hash = await Utilities.encryptPassword(req.body.data.password, user.salt);
|
||||
|
||||
let updatePassword = user.authVersion === 1; //NOTE(martina): if they are v1, we may need to update their password
|
||||
if (hash !== user.password) {
|
||||
return res.status(403).send({ decorator: "SERVER_SIGN_IN_WRONG_CREDENTIALS", error: true });
|
||||
//NOTE(martina): this was added to deal with a specific case where the passwords of some v1 users could either be the v1 hashing schema or v2 (so we try both)
|
||||
if (user.authVersion === 1) {
|
||||
const clientHash = await encryptPasswordClient(req.body.data.password);
|
||||
hash = await Utilities.encryptPassword(clientHash, user.salt);
|
||||
if (hash !== user.password) {
|
||||
return res.status(403).send({ decorator: "SERVER_SIGN_IN_WRONG_CREDENTIALS", error: true });
|
||||
}
|
||||
updatePassword = false; //NOTE(martina): means the user's password is already v2 hashed, and doesn't need to be updated
|
||||
} else {
|
||||
return res.status(403).send({ decorator: "SERVER_SIGN_IN_WRONG_CREDENTIALS", error: true });
|
||||
}
|
||||
}
|
||||
|
||||
let userUpdates = {};
|
||||
if (user.authVersion === 1) {
|
||||
userUpdates.authVersion = 2;
|
||||
userUpdates.revertedVersion = false;
|
||||
}
|
||||
|
||||
if (updatePassword) {
|
||||
const newHash = await encryptPasswordClient(req.body.data.password);
|
||||
const doubledHash = await Utilities.encryptPassword(newHash, user.salt);
|
||||
|
||||
userUpdates = { id: user.id, lastActive: new Date(), authVersion: 2, password: doubledHash };
|
||||
userUpdates.password = doubledHash;
|
||||
}
|
||||
|
||||
await Data.updateUserById({ id: user.id, lastActive: new Date(), ...userUpdates });
|
||||
await Data.updateUserById(userUpdates);
|
||||
|
||||
if (!user.email) {
|
||||
return res
|
||||
|
126
pages/api/twitter/link-with-verification.js
Normal file
126
pages/api/twitter/link-with-verification.js
Normal file
@ -0,0 +1,126 @@
|
||||
import * as Environment from "~/node_common/environment";
|
||||
import * as Data from "~/node_common/data";
|
||||
import * as Utilities from "~/node_common/utilities";
|
||||
import * as Strings from "~/common/strings";
|
||||
import * as Validations from "~/common/validations";
|
||||
import * as Constants from "~/common/constants";
|
||||
import * as Logging from "~/common/logging";
|
||||
|
||||
export default async (req, res) => {
|
||||
if (!Strings.isEmpty(Environment.ALLOWED_HOST) && req.headers.host !== Environment.ALLOWED_HOST) {
|
||||
return res.status(403).send({ decorator: "SERVER_TWITTER_OAUTH_NOT_ALLOWED", error: true });
|
||||
}
|
||||
|
||||
if (Strings.isEmpty(req.body?.data?.username)) {
|
||||
return res
|
||||
.status(500)
|
||||
.send({ decorator: "SERVER_TWITTER_LINKING_INVALID_USERNAME", error: true });
|
||||
}
|
||||
|
||||
if (!Validations.legacyPassword(req.body.data.password)) {
|
||||
return res
|
||||
.status(500)
|
||||
.send({ decorator: "SERVER_TWITTER_LINKING_INVALID_PASSWORD", error: true });
|
||||
}
|
||||
|
||||
if (Strings.isEmpty(req.body.data.token)) {
|
||||
return res.status(500).send({ decorator: "SERVER_TWITTER_OAUTH_NO_OAUTH_TOKEN", error: true });
|
||||
}
|
||||
|
||||
if (!Validations.verificationPin(req.body.data.pin)) {
|
||||
return res
|
||||
.status(500)
|
||||
.send({ decorator: "SERVER_EMAIL_VERIFICATION_INVALID_PIN", error: true });
|
||||
}
|
||||
|
||||
const { token, password, username, pin } = req.body.data;
|
||||
|
||||
let user;
|
||||
if (Validations.email(username)) {
|
||||
try {
|
||||
user = await Data.getUserByEmail({ email: username });
|
||||
} catch (e) {
|
||||
Logging.error(e);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
user = await Data.getUserByUsername({
|
||||
username: req.body.data.username.toLowerCase(),
|
||||
});
|
||||
} catch (e) {
|
||||
Logging.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!user || user.error) {
|
||||
return res
|
||||
.status(!user ? 404 : 500)
|
||||
.send({ decorator: "SERVER_SIGN_IN_USER_NOT_FOUND", error: true });
|
||||
}
|
||||
|
||||
// Note(amine): Twitter users won't have a password,
|
||||
// we should think in the future how to handle this use case
|
||||
if ((!user.salt || !user.password) && user.twitterId) {
|
||||
return res.status(403).send({ decorator: "SERVER_TWITTER_ALREADY_LINKED", error: true });
|
||||
}
|
||||
|
||||
const hash = await Utilities.encryptPassword(password, user.salt);
|
||||
if (hash !== user.password) {
|
||||
return res.status(403).send({ decorator: "SERVER_TWITTER_WRONG_CREDENTIALS", error: true });
|
||||
}
|
||||
|
||||
const verification = await Data.getVerificationBySid({
|
||||
sid: token,
|
||||
});
|
||||
|
||||
if (!verification) {
|
||||
return res.status(404).send({ decorator: "SERVER_EMAIL_VERIFICATION_FAILED", error: true });
|
||||
}
|
||||
|
||||
if (verification.error) {
|
||||
return res.status(404).send({ decorator: "SERVER_EMAIL_VERIFICATION_FAILED", error: true });
|
||||
}
|
||||
|
||||
const isTokenExpired =
|
||||
new Date() - new Date(verification.createdAt) > Constants.TOKEN_EXPIRATION_TIME;
|
||||
if (isTokenExpired) {
|
||||
return res.status(401).send({ decorator: "SERVER_EMAIL_VERIFICATION_FAILED", error: true });
|
||||
}
|
||||
|
||||
if (verification.type !== "email_twitter_verification") {
|
||||
return res.status(401).send({ decorator: "SERVER_EMAIL_VERIFICATION_FAILED", error: true });
|
||||
}
|
||||
|
||||
if (verification.pin !== pin) {
|
||||
return res
|
||||
.status(401)
|
||||
.send({ decorator: "SERVER_EMAIL_VERIFICATION_INVALID_PIN", error: true });
|
||||
}
|
||||
|
||||
const twitterUser = await Data.getTwitterToken({ token: verification.twitterToken });
|
||||
if (!twitterUser) {
|
||||
return res.status(401).send({ decorator: "SERVER_TWITTER_LINKING_FAILED", error: true });
|
||||
}
|
||||
|
||||
if (!twitterUser) {
|
||||
return res.status(401).send({ decorator: "SERVER_TWITTER_LINKING_FAILED", error: true });
|
||||
}
|
||||
|
||||
const updates = await Data.updateUserById({
|
||||
id: user.id,
|
||||
lastActive: new Date(),
|
||||
email: verification.email,
|
||||
twitterId: twitterUser.id_str,
|
||||
data: {
|
||||
twitter: {
|
||||
username: twitterUser.screen_name,
|
||||
verified: twitterUser.verified,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (updates.error) {
|
||||
return res.status(401).send({ decorator: "SERVER_TWITTER_LINKING_FAILED", error: true });
|
||||
}
|
||||
return res.status(200).send({ decorator: "SERVER_TWITTER_LINKING" });
|
||||
};
|
92
pages/api/twitter/link.js
Normal file
92
pages/api/twitter/link.js
Normal file
@ -0,0 +1,92 @@
|
||||
import * as Environment from "~/node_common/environment";
|
||||
import * as Data from "~/node_common/data";
|
||||
import * as Utilities from "~/node_common/utilities";
|
||||
import * as Strings from "~/common/strings";
|
||||
import * as Validations from "~/common/validations";
|
||||
import * as Logging from "~/common/logging";
|
||||
|
||||
export default async (req, res) => {
|
||||
if (!Strings.isEmpty(Environment.ALLOWED_HOST) && req.headers.host !== Environment.ALLOWED_HOST) {
|
||||
return res.status(403).send({ decorator: "SERVER_TWITTER_OAUTH_NOT_ALLOWED", error: true });
|
||||
}
|
||||
|
||||
if (Strings.isEmpty(req.body.data.token)) {
|
||||
return res.status(500).send({ decorator: "SERVER_TWITTER_OAUTH_NO_OAUTH_TOKEN", error: true });
|
||||
}
|
||||
|
||||
if (Strings.isEmpty(req.body?.data?.username)) {
|
||||
return res
|
||||
.status(400)
|
||||
.send({ decorator: "SERVER_TWITTER_LINKING_INVALID_USERNAME", error: true });
|
||||
}
|
||||
|
||||
if (!Validations.legacyPassword(req.body?.data?.password)) {
|
||||
return res
|
||||
.status(400)
|
||||
.send({ decorator: "SERVER_TWITTER_LINKING_INVALID_PASSWORD", error: true });
|
||||
}
|
||||
|
||||
const { username, password, token } = req.body.data;
|
||||
|
||||
let user;
|
||||
if (Validations.email(username)) {
|
||||
try {
|
||||
user = await Data.getUserByEmail({ email: username });
|
||||
} catch (e) {
|
||||
Logging.error(e);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
user = await Data.getUserByUsername({
|
||||
username: req.body.data.username.toLowerCase(),
|
||||
});
|
||||
} catch (e) {
|
||||
Logging.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
if (!user || user.error) {
|
||||
return res
|
||||
.status(!user ? 404 : 500)
|
||||
.send({ decorator: "SERVER_SIGN_IN_USER_NOT_FOUND", error: true });
|
||||
}
|
||||
|
||||
// Note(amine): Twitter users won't have a password,
|
||||
// we should think in the future how to handle this use case
|
||||
if ((!user.salt || !user.password) && user.twitterId) {
|
||||
return res.status(403).send({ decorator: "SERVER_CREATE_USER_TWITTER_EXISTS", error: true });
|
||||
}
|
||||
|
||||
const hash = await Utilities.encryptPassword(password, user.salt);
|
||||
if (hash !== user.password) {
|
||||
return res
|
||||
.status(403)
|
||||
.send({ decorator: "SERVER_TWITTER_LINKING_WRONG_CREDENTIALS", error: true });
|
||||
}
|
||||
|
||||
if (!user.email) {
|
||||
return res.status(200).send({ shouldMigrate: true });
|
||||
}
|
||||
|
||||
const twitterUser = await Data.getTwitterToken({ token: token });
|
||||
if (!twitterUser) {
|
||||
return res.status(401).send({ decorator: "SERVER_TWITTER_LINKING_FAILED", error: true });
|
||||
}
|
||||
|
||||
const updates = await Data.updateUserById({
|
||||
id: user.id,
|
||||
lastActive: new Date(),
|
||||
twitterId: twitterUser.id_str,
|
||||
data: {
|
||||
twitter: {
|
||||
username: twitterUser.screen_name,
|
||||
verified: twitterUser.verified,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (updates.error) {
|
||||
return res.status(401).send({ decorator: "SERVER_TWITTER_LINKING_FAILED", error: true });
|
||||
}
|
||||
return res.status(200).send({ decorator: "SERVER_TWITTER_LINKING" });
|
||||
};
|
@ -3,9 +3,9 @@ import * as Data from "~/node_common/data";
|
||||
import * as Utilities from "~/node_common/utilities";
|
||||
import * as Strings from "~/common/strings";
|
||||
import * as Validations from "~/common/validations";
|
||||
import SearchManager from "~/node_common/managers/search";
|
||||
import * as Constants from "~/node_common/constants";
|
||||
|
||||
import SearchManager from "~/node_common/managers/search";
|
||||
import JWT from "jsonwebtoken";
|
||||
|
||||
export default async (req, res) => {
|
||||
@ -68,7 +68,7 @@ export default async (req, res) => {
|
||||
return res.status(201).send({ decorator: "SERVER_CREATE_USER_TWITTER_EXISTS" });
|
||||
}
|
||||
|
||||
const newUsername = username.toLowerCase();
|
||||
const newUsername = Strings.createUsername(username);
|
||||
const newEmail = verification.email.toLowerCase();
|
||||
|
||||
// NOTE(Amine): If there is an account with the user's twitter email
|
||||
@ -76,17 +76,13 @@ export default async (req, res) => {
|
||||
if (userByEmail) return res.status(201).send({ decorator: "SERVER_CREATE_USER_EMAIL_TAKEN" });
|
||||
|
||||
// NOTE(Amine): If there is an account with the provided username
|
||||
const userByUsername = await Data.getUserByUsername({ username });
|
||||
const userByUsername = await Data.getUserByUsername({ username: newUsername });
|
||||
if (userByUsername) {
|
||||
return res.status(201).send({ decorator: "SERVER_CREATE_USER_USERNAME_TAKEN" });
|
||||
}
|
||||
|
||||
const {
|
||||
textileKey,
|
||||
textileToken,
|
||||
textileThreadID,
|
||||
textileBucketCID,
|
||||
} = await Utilities.createBucket({});
|
||||
const { textileKey, textileToken, textileThreadID, textileBucketCID } =
|
||||
await Utilities.createBucket({});
|
||||
|
||||
if (!textileKey || !textileToken || !textileThreadID || !textileBucketCID) {
|
||||
return res
|
||||
|
@ -3,8 +3,8 @@ import * as Data from "~/node_common/data";
|
||||
import * as Utilities from "~/node_common/utilities";
|
||||
import * as Strings from "~/common/strings";
|
||||
import * as Validations from "~/common/validations";
|
||||
import SearchManager from "~/node_common/managers/search";
|
||||
|
||||
import SearchManager from "~/node_common/managers/search";
|
||||
import JWT from "jsonwebtoken";
|
||||
|
||||
const COOKIE_NAME = "oauth_token";
|
||||
@ -28,6 +28,9 @@ export default async (req, res) => {
|
||||
return res.status(500).send({ decorator: "SERVER_CREATE_USER_INVALID_USERNAME", error: true });
|
||||
}
|
||||
|
||||
const newUsername = Strings.createUsername(username);
|
||||
const newEmail = email.toLowerCase();
|
||||
|
||||
const storedAuthToken = req.cookies[COOKIE_NAME];
|
||||
|
||||
// NOTE(amine): additional security check
|
||||
@ -57,20 +60,13 @@ export default async (req, res) => {
|
||||
}
|
||||
|
||||
// NOTE(Amine): If there is an account with the provided username
|
||||
const userByUsername = await Data.getUserByUsername({ username });
|
||||
const userByUsername = await Data.getUserByUsername({ username: newUsername });
|
||||
if (userByUsername) {
|
||||
return res.status(201).send({ decorator: "SERVER_CREATE_USER_USERNAME_TAKEN" });
|
||||
}
|
||||
|
||||
const newUsername = username.toLowerCase();
|
||||
const newEmail = email.toLowerCase();
|
||||
|
||||
const {
|
||||
textileKey,
|
||||
textileToken,
|
||||
textileThreadID,
|
||||
textileBucketCID,
|
||||
} = await Utilities.createBucket({});
|
||||
const { textileKey, textileToken, textileThreadID, textileBucketCID } =
|
||||
await Utilities.createBucket({});
|
||||
|
||||
if (!textileKey || !textileToken || !textileThreadID || !textileBucketCID) {
|
||||
return res
|
||||
|
@ -15,6 +15,6 @@ export default async (req, res) => {
|
||||
|
||||
return res.status(200).send({
|
||||
decorator: "SERVER_CHECK_EMAIL",
|
||||
data: { email: !!userByEmail.email, twitter: !!!userByEmail.password },
|
||||
data: { email: !!userByEmail.email, twitter: !userByEmail.password },
|
||||
});
|
||||
};
|
||||
|
@ -1,13 +1,13 @@
|
||||
import * as Environment from "~/node_common/environment";
|
||||
import * as Data from "~/node_common/data";
|
||||
import * as Utilities from "~/node_common/utilities";
|
||||
import SearchManager from "~/node_common/managers/search";
|
||||
import * as Validations from "~/common/validations";
|
||||
import * as Strings from "~/common/strings";
|
||||
import * as EmailManager from "~/node_common/managers/emails";
|
||||
import * as Monitor from "~/node_common/monitor";
|
||||
|
||||
import BCrypt from "bcrypt";
|
||||
import SearchManager from "~/node_common/managers/search";
|
||||
|
||||
export default async (req, res) => {
|
||||
if (!Strings.isEmpty(Environment.ALLOWED_HOST) && req.headers.host !== Environment.ALLOWED_HOST) {
|
||||
@ -36,14 +36,17 @@ export default async (req, res) => {
|
||||
return res.status(403).send({ decorator: "SERVER_CREATE_USER_EMAIL_UNVERIFIED", error: true });
|
||||
}
|
||||
|
||||
const newUsername = Strings.createUsername(req.body.data.username);
|
||||
const newEmail = verification.email;
|
||||
|
||||
const existing = await Data.getUserByUsername({
|
||||
username: req.body.data.username.toLowerCase(),
|
||||
username: newUsername,
|
||||
});
|
||||
if (existing) {
|
||||
return res.status(403).send({ decorator: "SERVER_CREATE_USER_USERNAME_TAKEN", error: true });
|
||||
}
|
||||
|
||||
const existingViaEmail = await Data.getUserByEmail({ email: verification.email });
|
||||
const existingViaEmail = await Data.getUserByEmail({ email: newEmail });
|
||||
if (existingViaEmail) {
|
||||
return res.status(403).send({ decorator: "SERVER_CREATE_USER_EMAIL_TAKEN", error: true });
|
||||
}
|
||||
@ -52,15 +55,8 @@ export default async (req, res) => {
|
||||
const salt = await BCrypt.genSalt(rounds);
|
||||
const hash = await Utilities.encryptPassword(req.body.data.password, salt);
|
||||
|
||||
const newUsername = req.body.data.username.toLowerCase();
|
||||
const newEmail = verification.email;
|
||||
|
||||
const {
|
||||
textileKey,
|
||||
textileToken,
|
||||
textileThreadID,
|
||||
textileBucketCID,
|
||||
} = await Utilities.createBucket({});
|
||||
const { textileKey, textileToken, textileThreadID, textileBucketCID } =
|
||||
await Utilities.createBucket({});
|
||||
|
||||
if (!textileKey || !textileToken || !textileThreadID || !textileBucketCID) {
|
||||
return res
|
||||
|
55
pages/api/verifications/password-reset/resend.js
Normal file
55
pages/api/verifications/password-reset/resend.js
Normal file
@ -0,0 +1,55 @@
|
||||
import * as Data from "~/node_common/data";
|
||||
import * as Strings from "~/common/strings";
|
||||
import * as Environment from "~/node_common/environment";
|
||||
import * as EmailManager from "~/node_common/managers/emails";
|
||||
import * as Constants from "~/node_common/constants";
|
||||
|
||||
// NOTE(amine): this endpoint is rate limited in ./server.js,
|
||||
export default async (req, res) => {
|
||||
if (!Strings.isEmpty(Environment.ALLOWED_HOST) && req.headers.host !== Environment.ALLOWED_HOST) {
|
||||
return res
|
||||
.status(403)
|
||||
.send({ decorator: "SERVER_EMAIL_VERIFICATION_NOT_ALLOWED", error: true });
|
||||
}
|
||||
|
||||
if (Strings.isEmpty(req.body.data.token)) {
|
||||
return res
|
||||
.status(500)
|
||||
.send({ decorator: "SERVER_EMAIL_VERIFICATION_INVALID_TOKEN", error: true });
|
||||
}
|
||||
|
||||
const verification = await Data.getVerificationBySid({
|
||||
sid: req.body.data.token,
|
||||
});
|
||||
|
||||
if (!verification) {
|
||||
return res.status(404).send({ decorator: "SERVER_EMAIL_VERIFICATION_FAILED", error: true });
|
||||
}
|
||||
if (verification.error) {
|
||||
return res.status(404).send({ decorator: "SERVER_EMAIL_VERIFICATION_FAILED", error: true });
|
||||
}
|
||||
|
||||
const isTokenExpired =
|
||||
new Date() - new Date(verification.createdAt) > Constants.TOKEN_EXPIRATION_TIME;
|
||||
if (isTokenExpired) {
|
||||
return res.status(401).send({ decorator: "SERVER_EMAIL_VERIFICATION_FAILED", error: true });
|
||||
}
|
||||
|
||||
const confTemplateId = "d-0bde6fd8eabf4ed4ae7fd409ddd532dd";
|
||||
const slateEmail = "hello@slate.host";
|
||||
|
||||
const sentEmail = await EmailManager.sendTemplate({
|
||||
to: verification.email,
|
||||
from: slateEmail,
|
||||
templateId: confTemplateId,
|
||||
templateData: { confirmation_code: verification.pin },
|
||||
});
|
||||
|
||||
if (sentEmail?.error) {
|
||||
return res.status(500).send({ decorator: sentEmail.decorator, error: true });
|
||||
}
|
||||
|
||||
return res.status(200).send({
|
||||
decorator: "SERVER_EMAIL_VERIFICATION_RESEND_SUCCESS",
|
||||
});
|
||||
};
|
@ -15,7 +15,10 @@ const AUTH_STATE_GRAPH = {
|
||||
RESET_PASSWORD: "password_reset",
|
||||
},
|
||||
signup: {},
|
||||
twitter_signup: {},
|
||||
twitter_signup: {
|
||||
LINK_EXISTING_ACCOUNT: "twitter_linking",
|
||||
},
|
||||
twitter_linking: {},
|
||||
password_reset: { BACK: "signin" },
|
||||
};
|
||||
|
||||
@ -40,6 +43,7 @@ export const useAuthFlow = () => {
|
||||
goToTwitterSignupScene: ({ twitterEmail }) =>
|
||||
send({ event: "SIGNUP_WITH_TWITTER", context: { twitterEmail } }),
|
||||
goToResetPassword: () => send({ event: "RESET_PASSWORD" }),
|
||||
goToTwitterLinkingScene: () => send({ event: "LINK_EXISTING_ACCOUNT" }),
|
||||
goBack: () => send({ event: "BACK" }),
|
||||
clearMessages: () => send({ ...state, context: { ...state.context, message: "" } }),
|
||||
}),
|
||||
@ -133,7 +137,7 @@ export const usePasswordReset = ({ onAuthenticate }) => {
|
||||
};
|
||||
|
||||
const resendVerification = async () => {
|
||||
const response = await Actions.resendVerification({
|
||||
const response = await Actions.resendPasswordResetVerification({
|
||||
token: verificationToken.current,
|
||||
});
|
||||
if (Events.hasError(response)) {
|
||||
@ -206,8 +210,9 @@ export const useSignup = ({ onAuthenticate }) => {
|
||||
return { createVerification, verifyEmail, createUser, resendVerification };
|
||||
};
|
||||
|
||||
export const useTwitter = ({ onAuthenticate, goToTwitterSignupScene }) => {
|
||||
export const useTwitter = ({ onAuthenticate, onTwitterAuthenticate, goToTwitterSignupScene }) => {
|
||||
const verificationToken = React.useRef();
|
||||
const credentialsRef = React.useRef();
|
||||
const popupRef = React.useRef();
|
||||
|
||||
const [isLoggingIn, setIsLoggingIn] = React.useState(false);
|
||||
@ -262,7 +267,9 @@ export const useTwitter = ({ onAuthenticate, goToTwitterSignupScene }) => {
|
||||
reject("getAuthTokenAndVerifier Error 2");
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
} catch (e) {
|
||||
Logging.error(e);
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
|
||||
@ -294,7 +301,7 @@ export const useTwitter = ({ onAuthenticate, goToTwitterSignupScene }) => {
|
||||
}
|
||||
|
||||
if (response.token) {
|
||||
await onAuthenticate(response);
|
||||
await onTwitterAuthenticate(response);
|
||||
setIsLoggingIn(false);
|
||||
return;
|
||||
}
|
||||
@ -308,6 +315,72 @@ export const useTwitter = ({ onAuthenticate, goToTwitterSignupScene }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const linkAccount = async ({ username, password }) => {
|
||||
const { authToken } = twitterTokens.current;
|
||||
credentialsRef.current = { username, password };
|
||||
|
||||
const userVersionResponse = await Actions.getUserVersion({ username });
|
||||
if (Events.hasError(userVersionResponse)) return;
|
||||
|
||||
let hashedPassword;
|
||||
if (userVersionResponse?.data?.version === 2) {
|
||||
hashedPassword = await Utilities.encryptPasswordClient(password);
|
||||
} else {
|
||||
hashedPassword = password;
|
||||
}
|
||||
// NOTE(amine): handling client hash if the user is v2
|
||||
|
||||
const response = await Actions.linkTwitterAccount({
|
||||
username,
|
||||
password: hashedPassword,
|
||||
token: authToken,
|
||||
});
|
||||
|
||||
if (response.shouldMigrate) {
|
||||
return response;
|
||||
}
|
||||
|
||||
if (Events.hasError(response)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const authResponse = await onAuthenticate({ username, password: hashedPassword });
|
||||
if (Events.hasError(authResponse)) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const linkAccountWithVerification = async ({ pin }) => {
|
||||
const { username, password } = credentialsRef.current;
|
||||
|
||||
const userVersionResponse = await Actions.getUserVersion({ username });
|
||||
if (Events.hasError(userVersionResponse)) return;
|
||||
|
||||
let hashedPassword;
|
||||
if (userVersionResponse?.data?.version === 2) {
|
||||
hashedPassword = await Utilities.encryptPasswordClient(password);
|
||||
} else {
|
||||
hashedPassword = password;
|
||||
}
|
||||
|
||||
// NOTE(amine): handling client hash if the user is v2
|
||||
const response = await Actions.linkTwitterAccountWithVerification({
|
||||
username,
|
||||
password: hashedPassword,
|
||||
token: verificationToken.current,
|
||||
pin,
|
||||
});
|
||||
|
||||
if (Events.hasError(response)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const authResponse = await onAuthenticate({ username, password: hashedPassword });
|
||||
if (Events.hasError(authResponse)) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const signup = async ({ email = "", username = "" }) => {
|
||||
const { authToken } = twitterTokens.current;
|
||||
const response = await Actions.createUserViaTwitter({
|
||||
@ -317,7 +390,7 @@ export const useTwitter = ({ onAuthenticate, goToTwitterSignupScene }) => {
|
||||
});
|
||||
if (Events.hasError(response)) return;
|
||||
if (response.token) {
|
||||
await onAuthenticate(response);
|
||||
await onTwitterAuthenticate(response);
|
||||
return;
|
||||
}
|
||||
return response;
|
||||
@ -332,7 +405,7 @@ export const useTwitter = ({ onAuthenticate, goToTwitterSignupScene }) => {
|
||||
|
||||
if (Events.hasError(response)) return;
|
||||
if (response.token) {
|
||||
await onAuthenticate(response);
|
||||
await onTwitterAuthenticate(response);
|
||||
return;
|
||||
}
|
||||
return response;
|
||||
@ -363,6 +436,8 @@ export const useTwitter = ({ onAuthenticate, goToTwitterSignupScene }) => {
|
||||
return {
|
||||
isLoggingIn,
|
||||
signin,
|
||||
linkAccount,
|
||||
linkAccountWithVerification,
|
||||
signup,
|
||||
signupWithVerification,
|
||||
createVerification,
|
||||
|
@ -1,9 +1,16 @@
|
||||
import * as React from "react";
|
||||
import * as UserBehaviors from "~/common/user-behaviors";
|
||||
import * as Utilities from "common/utilities";
|
||||
import WebsitePrototypeWrapper from "~/components/core/WebsitePrototypeWrapper";
|
||||
|
||||
import { css } from "@emotion/react";
|
||||
import { Initial, Signin, Signup, TwitterSignup, ResetPassword } from "~/components/core/Auth";
|
||||
import {
|
||||
Initial,
|
||||
Signin,
|
||||
Signup,
|
||||
TwitterSignup,
|
||||
TwitterLinking,
|
||||
ResetPassword,
|
||||
} from "~/components/core/Auth";
|
||||
|
||||
import {
|
||||
useAuthFlow,
|
||||
@ -13,9 +20,6 @@ import {
|
||||
usePasswordReset,
|
||||
} from "~/scenes/SceneAuth/hooks";
|
||||
|
||||
const background_image =
|
||||
"https://slate.textile.io/ipfs/bafybeiddgkvf5ta6y5b7wamrxl33mtst4detegleblw4gfduhwm3sdwdra";
|
||||
|
||||
const STYLES_ROOT = css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -23,12 +27,10 @@ const STYLES_ROOT = css`
|
||||
justify-content: space-between;
|
||||
text-align: center;
|
||||
font-size: 1rem;
|
||||
|
||||
min-height: 100vh;
|
||||
width: 100vw;
|
||||
position: absolute;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-image: url(${background_image});
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
`;
|
||||
@ -45,11 +47,23 @@ const STYLES_MIDDLE = css`
|
||||
padding: 24px;
|
||||
`;
|
||||
|
||||
const AUTH_BACKGROUNDS = [
|
||||
"https://slate.textile.io/ipfs/bafybeigostprfkuuvuqlehutki32fnvshm2dyy4abqotmlffsca4f7qs7a",
|
||||
"https://slate.textile.io/ipfs/bafybeicmokw3bl5six6u7eflbxcdblpgbx3fat24djrqg6n3hmbleidks4",
|
||||
"https://slate.textile.io/ipfs/bafybeibkttaavlkjxgtafqndyrbgvwqcng67zvd4v36w7fvpajwmdgmxcu",
|
||||
"https://slate.textile.io/ipfs/bafybeicpk7hkbeqdgbwkx3dltlz3akf3qbjpqgfphbnry4b6txnailtlpq",
|
||||
"https://slate.textile.io/ipfs/bafybeibb2xknh3iwwetrro73hw3xfzjgwbi4n4c63wqmwt5hvaloqnh33u",
|
||||
"https://slate.textile.io/ipfs/bafybeig4mij32vyda2jbh6zua3r2rkdpby6wtvninwgxvsejjdnls4wpc4",
|
||||
"https://slate.textile.io/ipfs/bafybeihmoycn4a6zafd2k3fjcadskrxwvri5cwhabatzbyzteouh3s7igi",
|
||||
"https://slate.textile.io/ipfs/bafybeigxssjsv3tmdhz4bj6vl2ca5c6rrhdkepw3mifvlllb7orpx5cfou",
|
||||
];
|
||||
|
||||
const SigninScene = ({ onAuthenticate, onTwitterAuthenticate, page, ...props }) => {
|
||||
const {
|
||||
goToSigninScene,
|
||||
goToSignupScene,
|
||||
goToTwitterSignupScene,
|
||||
goToTwitterLinkingScene,
|
||||
goToResetPassword,
|
||||
clearMessages,
|
||||
goBack,
|
||||
@ -62,7 +76,8 @@ const SigninScene = ({ onAuthenticate, onTwitterAuthenticate, page, ...props })
|
||||
onAuthenticate,
|
||||
});
|
||||
const twitterProvider = useTwitter({
|
||||
onAuthenticate: onTwitterAuthenticate,
|
||||
onTwitterAuthenticate: onTwitterAuthenticate,
|
||||
onAuthenticate,
|
||||
goToTwitterSignupScene,
|
||||
});
|
||||
|
||||
@ -114,12 +129,23 @@ const SigninScene = ({ onAuthenticate, onTwitterAuthenticate, page, ...props })
|
||||
<TwitterSignup
|
||||
initialEmail={context.twitterEmail}
|
||||
createVerification={twitterProvider.createVerification}
|
||||
resendEmailVerification={twitterProvider.resendEmailVerification}
|
||||
resendEmailVerification={twitterProvider.resendVerification}
|
||||
goToTwitterLinkingScene={goToTwitterLinkingScene}
|
||||
onSignupWithVerification={twitterProvider.signupWithVerification}
|
||||
onSignup={twitterProvider.signup}
|
||||
/>
|
||||
);
|
||||
|
||||
if (scene === "twitter_linking") {
|
||||
return (
|
||||
<TwitterLinking
|
||||
linkAccount={twitterProvider.linkAccount}
|
||||
linkAccountWithVerification={twitterProvider.linkAccountWithVerification}
|
||||
resendEmailVerification={twitterProvider.resendVerification}
|
||||
createVerification={twitterProvider.createVerification}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// NOTE(amine): if the user goes back, we should prefill the email
|
||||
const initialEmail =
|
||||
prevScene === "signin" && context.emailOrUsername ? context.emailOrUsername : "";
|
||||
@ -135,16 +161,48 @@ const SigninScene = ({ onAuthenticate, onTwitterAuthenticate, page, ...props })
|
||||
/>
|
||||
);
|
||||
};
|
||||
const BackgroundGenerator = ({ children, isMobile, ...props }) => {
|
||||
const background = React.useMemo(() => {
|
||||
const backgroundIdx = Utilities.getRandomNumberBetween(0, AUTH_BACKGROUNDS.length - 1);
|
||||
return AUTH_BACKGROUNDS[backgroundIdx];
|
||||
}, []);
|
||||
|
||||
const WithCustomWrapper = (Component) => (props) =>
|
||||
(
|
||||
// NOTE(amine): fix for 100vh overflowing in mobile
|
||||
// https://bugs.webkit.org/show_bug.cgi?id=141832
|
||||
const [height, setHeight] = React.useState();
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!window) return;
|
||||
const updateHeight = () => {
|
||||
const windowInnerHeight = window.innerHeight;
|
||||
setHeight(windowInnerHeight);
|
||||
};
|
||||
|
||||
updateHeight();
|
||||
// NOTE(amine): don't update height on mobile
|
||||
if (isMobile) return;
|
||||
|
||||
window.addEventListener("resize", updateHeight);
|
||||
return () => window.addEventListener("resize", updateHeight);
|
||||
}, [isMobile]);
|
||||
|
||||
return (
|
||||
<div style={{ backgroundImage: `url(${background})`, height }} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const WithCustomWrapper = (Component) => (props) => {
|
||||
return (
|
||||
<WebsitePrototypeWrapper>
|
||||
<div css={STYLES_ROOT}>
|
||||
<BackgroundGenerator css={STYLES_ROOT} isMobile={props.isMobile}>
|
||||
<div css={STYLES_MIDDLE}>
|
||||
<Component {...props} />
|
||||
</div>
|
||||
</div>
|
||||
</BackgroundGenerator>
|
||||
</WebsitePrototypeWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default WithCustomWrapper(SigninScene);
|
||||
|
@ -5,7 +5,7 @@ import * as Strings from "~/common/strings";
|
||||
import * as Validations from "~/common/validations";
|
||||
import * as Window from "~/common/window";
|
||||
import * as Constants from "~/common/constants";
|
||||
import * as FileUtilities from "~/common/file-utilities";
|
||||
import * as Utilities from "~/common/utilities";
|
||||
import * as UserBehaviors from "~/common/user-behaviors";
|
||||
import * as Events from "~/common/custom-events";
|
||||
import * as SVG from "~/common/svg";
|
||||
@ -13,10 +13,12 @@ import * as SVG from "~/common/svg";
|
||||
import { css } from "@emotion/react";
|
||||
import { SecondaryTabGroup } from "~/components/core/TabGroup";
|
||||
import { ConfirmationModal } from "~/components/core/ConfirmationModal";
|
||||
import { useForm, useToggle } from "~/common/hooks";
|
||||
|
||||
import WebsitePrototypeWrapper from "~/components/core/WebsitePrototypeWrapper";
|
||||
import ScenePage from "~/components/core/ScenePage";
|
||||
import ScenePageHeader from "~/components/core/ScenePageHeader";
|
||||
import Field from "~/components/core/Field";
|
||||
import ProfilePhoto from "~/components/core/ProfilePhoto";
|
||||
|
||||
const STYLES_FILE_HIDDEN = css`
|
||||
@ -41,10 +43,76 @@ const STYLES_HEADER = css`
|
||||
margin-bottom: 16px;
|
||||
`;
|
||||
|
||||
const SecuritySection = ({ onUpdateViewer, username }) => {
|
||||
const [passwordValidations, setPasswordValidations] = React.useState(
|
||||
Validations.passwordForm("")
|
||||
);
|
||||
|
||||
const { getFieldProps, getFormProps, isSubmitting } = useForm({
|
||||
initialValues: { password: "" },
|
||||
validate: ({ password }, errors) => {
|
||||
if (!Validations.password(password)) errors.password = "Incorrect password";
|
||||
return errors;
|
||||
},
|
||||
onSubmit: async ({ password }) => {
|
||||
const userVersionResponse = await Actions.getUserVersion({ username });
|
||||
if (Events.hasError(userVersionResponse)) return;
|
||||
|
||||
let hashedPassword;
|
||||
if (userVersionResponse?.data?.version === 2) {
|
||||
hashedPassword = await Utilities.encryptPasswordClient(password);
|
||||
} else {
|
||||
hashedPassword = password;
|
||||
}
|
||||
|
||||
let response = await onUpdateViewer({
|
||||
type: "CHANGE_PASSWORD",
|
||||
password: hashedPassword,
|
||||
});
|
||||
|
||||
if (Events.hasError(response)) {
|
||||
return;
|
||||
}
|
||||
|
||||
Events.dispatchMessage({ message: "Password successfully updated!", status: "INFO" });
|
||||
},
|
||||
});
|
||||
|
||||
const [showPassword, togglePasswordVisibility] = useToggle(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div css={STYLES_HEADER}>Change password</div>
|
||||
<div>Passwords must be a minimum of eight characters.</div>
|
||||
<form {...getFormProps()}>
|
||||
<Field
|
||||
containerStyle={{ marginTop: 24 }}
|
||||
placeholder="Your new password"
|
||||
validations={passwordValidations}
|
||||
color="white"
|
||||
{...getFieldProps("password", {
|
||||
onChange: (e) => {
|
||||
const validations = Validations.passwordForm(e.target.value);
|
||||
setPasswordValidations(validations);
|
||||
},
|
||||
})}
|
||||
type={showPassword ? "text" : "password"}
|
||||
onClickIcon={togglePasswordVisibility}
|
||||
icon={showPassword ? SVG.EyeOff : SVG.Eye}
|
||||
/>
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<System.ButtonPrimary loading={isSubmitting} type="submit" style={{ width: "200px" }}>
|
||||
Change password
|
||||
</System.ButtonPrimary>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default class SceneEditAccount extends React.Component {
|
||||
state = {
|
||||
username: this.props.viewer.username,
|
||||
password: "",
|
||||
confirm: "",
|
||||
body: this.props.viewer.body,
|
||||
photo: this.props.viewer.photo,
|
||||
@ -55,7 +123,6 @@ export default class SceneEditAccount extends React.Component {
|
||||
savingNameBio: false,
|
||||
changingFilecoin: false,
|
||||
modalShow: false,
|
||||
showPassword: false,
|
||||
};
|
||||
|
||||
_handleUpload = async (e) => {
|
||||
@ -66,7 +133,7 @@ export default class SceneEditAccount extends React.Component {
|
||||
return;
|
||||
}
|
||||
|
||||
const cid = file.cid;
|
||||
const { cid } = file;
|
||||
const url = Strings.getURLfromCID(cid);
|
||||
let updateResponse = await Actions.updateViewer({
|
||||
user: {
|
||||
@ -78,7 +145,7 @@ export default class SceneEditAccount extends React.Component {
|
||||
this.setState({ changingAvatar: false, photo: url });
|
||||
};
|
||||
|
||||
_handleSave = async (e) => {
|
||||
_handleSave = async () => {
|
||||
if (!Validations.username(this.state.username)) {
|
||||
Events.dispatchMessage({
|
||||
message: "Please include only letters and numbers in your username",
|
||||
@ -106,30 +173,7 @@ export default class SceneEditAccount extends React.Component {
|
||||
};
|
||||
|
||||
_handleUsernameChange = (e) => {
|
||||
this.setState({ [e.target.name]: e.target.value.toLowerCase() });
|
||||
};
|
||||
|
||||
_handleChangePassword = async (e) => {
|
||||
if (!Validations.password(this.state.password)) {
|
||||
Events.dispatchMessage({
|
||||
message: "Password does not meet requirements",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ changingPassword: true });
|
||||
|
||||
let response = await Actions.updateViewer({
|
||||
type: "CHANGE_PASSWORD",
|
||||
user: { password: this.state.password },
|
||||
});
|
||||
|
||||
if (Events.hasError(response)) {
|
||||
this.setState({ changingPassword: false });
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ changingPassword: false, password: "", confirm: "" });
|
||||
this.setState({ [e.target.name]: Strings.createUsername(e.target.value) });
|
||||
};
|
||||
|
||||
_handleDelete = async (res) => {
|
||||
@ -222,34 +266,10 @@ export default class SceneEditAccount extends React.Component {
|
||||
</div>
|
||||
) : null}
|
||||
{tab === "security" ? (
|
||||
<div>
|
||||
<div css={STYLES_HEADER}>Change password</div>
|
||||
<div>
|
||||
Passwords should be at least 8 characters long, contain a mix of upper and lowercase
|
||||
letters, and have at least 1 number and 1 symbol
|
||||
</div>
|
||||
|
||||
<System.Input
|
||||
containerStyle={{ marginTop: 24 }}
|
||||
name="password"
|
||||
type={this.state.showPassword ? "text" : "password"}
|
||||
value={this.state.password}
|
||||
placeholder="Your new password"
|
||||
onChange={this._handleChange}
|
||||
onClickIcon={() => this.setState({ showPassword: !this.state.showPassword })}
|
||||
icon={this.state.showPassword ? SVG.EyeOff : SVG.Eye}
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<System.ButtonPrimary
|
||||
onClick={this._handleChangePassword}
|
||||
loading={this.state.changingPassword}
|
||||
style={{ width: "200px" }}
|
||||
>
|
||||
Change password
|
||||
</System.ButtonPrimary>
|
||||
</div>
|
||||
</div>
|
||||
<SecuritySection
|
||||
onUpdateViewer={Actions.updateViewer}
|
||||
username={this.props.viewer.username}
|
||||
/>
|
||||
) : null}
|
||||
{tab === "account" ? (
|
||||
<div>
|
||||
|
Loading…
Reference in New Issue
Block a user