mirror of
https://github.com/filecoin-project/slate.git
synced 2024-12-24 01:23:08 +03:00
Merge pull request #798 from filecoin-project/@aminejv/auth
Auth: link existing accounts with twitter
This commit is contained in:
commit
d609b99cf0
@ -244,6 +244,20 @@ export const createUserViaTwitterWithVerification = async (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 updateViewer = async (data) => {
|
||||
await Websockets.checkWebsocket();
|
||||
return await returnJSON(`/api/users/update`, {
|
||||
|
@ -61,7 +61,7 @@ export const system = {
|
||||
active: "#00BB00",
|
||||
blurBlack: "#262626",
|
||||
bgBlurGray: "#403F42",
|
||||
bgBlurWhiteTRN: "rgba(255,255,255,0.3)",
|
||||
bgBlurWhiteTRN: "rgba(255,255,255,0.7)",
|
||||
grayLight2: "#AEAEB2",
|
||||
grayLight5: "#E5E5EA",
|
||||
};
|
||||
|
@ -27,6 +27,8 @@ export const useForm = ({
|
||||
onSubmit,
|
||||
validate,
|
||||
initialValues,
|
||||
// NOTE(amine): you can format the value of each input before onChange. ex format:{username: formatUsername}
|
||||
format = {},
|
||||
validateOnBlur = true,
|
||||
validateOnSubmit = true,
|
||||
}) => {
|
||||
@ -39,16 +41,25 @@ export const useForm = ({
|
||||
});
|
||||
|
||||
const _hasError = (obj) => Object.keys(obj).some((name) => obj[name]);
|
||||
const _mergeEventHandlers = (events = []) => (e) =>
|
||||
events.forEach((event) => {
|
||||
if (event) event(e);
|
||||
});
|
||||
|
||||
const formatInputValue = (e) => {
|
||||
if (typeof format !== "object" || !(e.target.name in format)) return e.target.value;
|
||||
const formatInput = format[e.target.name];
|
||||
return formatInput ? formatInput(e.target.value) : e.target.value;
|
||||
};
|
||||
|
||||
const _mergeEventHandlers =
|
||||
(events = []) =>
|
||||
(e) =>
|
||||
events.forEach((event) => {
|
||||
if (event) event(e);
|
||||
});
|
||||
|
||||
/** ---------- NOTE(amine): Input Handlers ---------- */
|
||||
const handleFieldChange = (e) =>
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
values: { ...prev.values, [e.target.name]: e.target.value },
|
||||
values: { ...prev.values, [e.target.name]: formatInputValue(e) },
|
||||
errors: { ...prev.errors, [e.target.name]: undefined },
|
||||
touched: { ...prev.touched, [e.target.name]: false },
|
||||
}));
|
||||
@ -162,10 +173,12 @@ export const useField = ({
|
||||
touched: undefined,
|
||||
});
|
||||
|
||||
const _mergeEventHandlers = (events = []) => (e) =>
|
||||
events.forEach((event) => {
|
||||
if (event) event(e);
|
||||
});
|
||||
const _mergeEventHandlers =
|
||||
(events = []) =>
|
||||
(e) =>
|
||||
events.forEach((event) => {
|
||||
if (event) event(e);
|
||||
});
|
||||
|
||||
/** ---------- NOTE(amine): Input Handlers ---------- */
|
||||
const handleFieldChange = (e) =>
|
||||
@ -176,14 +189,14 @@ export const useField = ({
|
||||
touched: false,
|
||||
}));
|
||||
|
||||
const handleOnBlur = (e) => {
|
||||
const handleOnBlur = () => {
|
||||
// NOTE(amine): validate the inputs onBlur and touch the current input
|
||||
let error = {};
|
||||
if (validateOnBlur && validate) error = validate(state.value);
|
||||
setState((prev) => ({ ...prev, touched: validateOnBlur, error }));
|
||||
};
|
||||
|
||||
const handleFormOnSubmit = (e) => {
|
||||
const handleFormOnSubmit = () => {
|
||||
//NOTE(amine): touch all inputs
|
||||
setState((prev) => ({ ...prev, touched: true }));
|
||||
|
||||
@ -219,3 +232,9 @@ export const useField = ({
|
||||
|
||||
return { getFieldProps, value: state.value, isSubmitting: state.isSubmitting };
|
||||
};
|
||||
|
||||
export const useToggle = (initialState = false) => {
|
||||
const [state, setState] = React.useState(initialState);
|
||||
const toggleState = () => setState((prev) => !prev);
|
||||
return [state, toggleState];
|
||||
};
|
||||
|
@ -143,7 +143,10 @@ export const error = {
|
||||
SERVER_TWITTER_OAUTH_NOT_ALLOWED: "You can only authenticate via twitter while on slate.host",
|
||||
SERVER_TWITTER_LOGIN_ONLY:
|
||||
"This login is associated with a Twitter account. Please continue with Twitter instead",
|
||||
|
||||
SERVER_TWITTER_LINKING_INVALID_USERNAME: "Please choose a valid username/email",
|
||||
SERVER_TWITTER_LINKING_INVALID_PASSWORD: "Please choose a valid password",
|
||||
SERVER_TWITTER_LINKING_WRONG_CREDENTIALS: "You have entered an invalid username or password",
|
||||
SERVER_TWITTER_LINKING_FAILED: "SERVER_CREATE_USER_FAILED",
|
||||
// Email Verifications
|
||||
SERVER_EMAIL_VERIFICATION_INVALID_PIN: "Please enter a valid pin",
|
||||
SERVER_EMAIL_VERIFICATION_FAILED:
|
||||
|
@ -5,7 +5,7 @@ import { css } from "@emotion/react";
|
||||
/* TYPOGRAPHY */
|
||||
|
||||
export const HEADING_01 = css`
|
||||
font-family: ${Constants.font.text};
|
||||
font-family: ${Constants.font.medium};
|
||||
font-size: 1.953rem;
|
||||
font-weight: medium;
|
||||
line-height: 1.5;
|
||||
@ -21,25 +21,22 @@ export const HEADING_02 = css`
|
||||
`;
|
||||
|
||||
export const HEADING_03 = css`
|
||||
font-family: ${Constants.font.text};
|
||||
font-family: ${Constants.font.medium};
|
||||
font-size: 1.25rem;
|
||||
font-weight: medium;
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.02px;
|
||||
`;
|
||||
|
||||
export const HEADING_04 = css`
|
||||
font-family: ${Constants.font.text};
|
||||
font-family: ${Constants.font.medium};
|
||||
font-size: 1rem;
|
||||
font-weight: medium;
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.01px;
|
||||
`;
|
||||
|
||||
export const HEADING_05 = css`
|
||||
font-family: ${Constants.font.text};
|
||||
font-family: ${Constants.font.medium};
|
||||
font-size: 0.875rem;
|
||||
font-weight: medium;
|
||||
line-height: 1.5;
|
||||
letter-spacing: -0.01px;
|
||||
`;
|
||||
@ -146,3 +143,14 @@ export const MOBILE_ONLY = css`
|
||||
pointer-events: none;
|
||||
}
|
||||
`;
|
||||
|
||||
export const LINK = (theme) => css`
|
||||
${HOVERABLE};
|
||||
${HEADING_05};
|
||||
&,
|
||||
&:link,
|
||||
&:visited {
|
||||
color: ${theme.system.blue};
|
||||
text-decoration: none;
|
||||
}
|
||||
`;
|
||||
|
@ -770,6 +770,7 @@ export default class ApplicationPage extends React.Component {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<ApplicationLayout
|
||||
withPaddings={page.id !== "NAV_SIGN_IN"}
|
||||
page={page}
|
||||
onAction={this._handleAction}
|
||||
header={headerElement}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import * as React from "react";
|
||||
import * as Constants from "~/common/constants";
|
||||
import * as SVG from "~/common/svg";
|
||||
import * as Utilities from "~/common/utilities";
|
||||
|
||||
import { css } from "@emotion/react";
|
||||
import { GlobalTooltip } from "~/components/system/components/fragments/GlobalTooltip";
|
||||
@ -118,6 +119,10 @@ export default class ApplicationLayout extends React.Component {
|
||||
headerTop: 0,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
withPaddings: true,
|
||||
};
|
||||
|
||||
componentDidMount = () => {
|
||||
this.prevScrollPos = window.pageYOffset;
|
||||
if (this.props.isMobile) {
|
||||
@ -169,7 +174,7 @@ export default class ApplicationLayout extends React.Component {
|
||||
}
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div css={STYLES_CONTENT}>
|
||||
<div css={this.props.withPaddings && STYLES_CONTENT}>
|
||||
<GlobalTooltip />
|
||||
{this.props.header && (
|
||||
<div
|
||||
|
@ -8,10 +8,12 @@ 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 Field from "~/components/core/Field";
|
||||
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";
|
||||
|
||||
const STYLES_INITIAL_CONTAINER = css`
|
||||
display: flex;
|
||||
@ -71,18 +73,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({
|
||||
@ -183,30 +181,33 @@ export default function Initial({
|
||||
</>
|
||||
) : (
|
||||
<AnimateSharedLayout>
|
||||
<form {...getFormProps()}>
|
||||
<Field
|
||||
autoFocus
|
||||
label="Sign up with email"
|
||||
placeholder="Email"
|
||||
type="text"
|
||||
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 initial={{ opacity: 0 }} animate={{ opacity: 1 }} layout>
|
||||
<System.ButtonPrimary
|
||||
full
|
||||
type="submit"
|
||||
style={{ marginTop: "16px" }}
|
||||
loading={isCheckingEmail}
|
||||
>
|
||||
Send verification link
|
||||
</System.ButtonPrimary>
|
||||
</motion.div>
|
||||
</form>
|
||||
<Field
|
||||
autoFocus
|
||||
label="Sign up with email"
|
||||
placeholder="Email"
|
||||
type="text"
|
||||
name="email"
|
||||
full
|
||||
icon={
|
||||
isCheckingEmail
|
||||
? () => (
|
||||
<LoaderSpinner
|
||||
style={{
|
||||
height: 16,
|
||||
width: 16,
|
||||
marginLeft: 16,
|
||||
position: "absolute",
|
||||
right: 12,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: ArrowButton
|
||||
}
|
||||
// NOTE(amine): the input component internally is using 16px margin top
|
||||
containerStyle={{ marginTop: "4px" }}
|
||||
{...getSignupFielProps("email")}
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: "auto" }}>
|
||||
<a css={STYLES_LINK_ITEM} href="/terms" target="_blank">
|
||||
<div css={Styles.HORIZONTAL_CONTAINER_CENTERED}>
|
||||
|
@ -123,7 +123,6 @@ export default function ResetPassword({
|
||||
setPasswordValidations(validations);
|
||||
},
|
||||
})}
|
||||
style={{ backgroundColor: "rgba(242,242,247,0.5)" }}
|
||||
onClickIcon={() => toggleShowPassword(!showPassword)}
|
||||
icon={showPassword ? SVG.EyeOff : SVG.Eye}
|
||||
/>
|
||||
@ -161,7 +160,6 @@ export default function ResetPassword({
|
||||
type="email"
|
||||
full
|
||||
{...getFieldProps("email")}
|
||||
style={{ backgroundColor: "rgba(242,242,247,0.5)" }}
|
||||
/>
|
||||
<AnimateSharedLayout>
|
||||
<motion.div layout>
|
||||
|
@ -163,7 +163,6 @@ export default function Signin({
|
||||
type="email"
|
||||
full
|
||||
{...getEmailFieldProps("email")}
|
||||
style={{ backgroundColor: "rgba(242,242,247,0.5)" }}
|
||||
/>
|
||||
<AnimateSharedLayout>
|
||||
<motion.div layout>
|
||||
@ -202,7 +201,6 @@ export default function Signin({
|
||||
onClickIcon={() => toggleShowPassword(!showPassword)}
|
||||
icon={showPassword ? SVG.EyeOff : SVG.Eye}
|
||||
{...getFieldProps("password")}
|
||||
style={{ backgroundColor: "rgba(242,242,247,0.5)" }}
|
||||
/>
|
||||
<AnimateSharedLayout>
|
||||
<motion.div layout>
|
||||
|
@ -3,6 +3,7 @@ import * as System from "~/components/system";
|
||||
import * as Validations from "~/common/validations";
|
||||
import * as SVG from "~/common/svg";
|
||||
import * as Actions from "~/common/actions";
|
||||
import * as Strings from "~/common/strings";
|
||||
|
||||
import Field from "~/components/core/Field";
|
||||
|
||||
@ -23,12 +24,17 @@ const useSignup = () => {
|
||||
};
|
||||
|
||||
const useCheckUser = () => {
|
||||
const MESSAGE = "The username is taken.";
|
||||
const MESSAGE = "That username is taken";
|
||||
|
||||
const usernamesAllowed = React.useRef([]);
|
||||
const usernamesTaken = React.useRef([]);
|
||||
|
||||
return async ({ username }, errors) => {
|
||||
if (!Validations.username(username)) {
|
||||
errors.username = "Invalid username";
|
||||
return;
|
||||
}
|
||||
|
||||
if (usernamesAllowed.current.some((value) => value === username)) {
|
||||
return;
|
||||
}
|
||||
@ -42,7 +48,7 @@ const useCheckUser = () => {
|
||||
username,
|
||||
});
|
||||
if (response.data) {
|
||||
errors.username = "The username is taken.";
|
||||
errors.username = "That username is taken";
|
||||
usernamesTaken.current.push(username);
|
||||
return;
|
||||
}
|
||||
@ -56,10 +62,6 @@ const createValidations = (validateUsername) => async (
|
||||
) => {
|
||||
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.password(password)) errors.password = "Incorrect password";
|
||||
|
||||
if (!acceptTerms) errors.acceptTerms = "Must accept terms and conditions";
|
||||
@ -77,6 +79,7 @@ export default function Signup({ verifyEmail, createUser, resendEmailVerificatio
|
||||
|
||||
const { getFieldProps, getFormProps, isSubmitting, isValidating } = useForm({
|
||||
initialValues: { username: "", password: "", acceptTerms: false },
|
||||
format: { username: Strings.createUsername },
|
||||
validate: createValidations(validateUsername),
|
||||
onSubmit: async ({ username, password }) => await createUser({ username, password }),
|
||||
});
|
||||
@ -101,7 +104,7 @@ export default function Signup({ verifyEmail, createUser, resendEmailVerificatio
|
||||
placeholder="Username"
|
||||
name="username"
|
||||
type="text"
|
||||
success="The username is available."
|
||||
success="That username is available"
|
||||
icon={
|
||||
isValidating
|
||||
? () => (
|
||||
@ -119,7 +122,6 @@ export default function Signup({ verifyEmail, createUser, resendEmailVerificatio
|
||||
}
|
||||
full
|
||||
{...getFieldProps("username")}
|
||||
style={{ backgroundColor: "rgba(242,242,247,0.5)" }}
|
||||
/>
|
||||
|
||||
<motion.div layout>
|
||||
@ -136,7 +138,6 @@ export default function Signup({ verifyEmail, createUser, resendEmailVerificatio
|
||||
setPasswordValidations(validations);
|
||||
},
|
||||
})}
|
||||
style={{ backgroundColor: "rgba(242,242,247,0.5)" }}
|
||||
onClickIcon={() => toggleShowPassword(!showPassword)}
|
||||
icon={showPassword ? SVG.EyeOff : SVG.Eye}
|
||||
/>
|
||||
|
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,14 @@ 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 = css`
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
max-width: 224px;
|
||||
text-align: center;
|
||||
color: ${theme.system.textGrayDark};
|
||||
max-width: 228px;
|
||||
margin: 0 auto;
|
||||
background-color: unset;
|
||||
border: none;
|
||||
`;
|
||||
|
||||
const useTwitterSignup = () => {
|
||||
@ -27,12 +32,17 @@ const useTwitterSignup = () => {
|
||||
};
|
||||
|
||||
const useCheckUser = () => {
|
||||
const MESSAGE = "The username is taken.";
|
||||
const MESSAGE = "That username is taken";
|
||||
|
||||
const usernamesAllowed = React.useRef([]);
|
||||
const usernamesTaken = React.useRef([]);
|
||||
|
||||
return async ({ username }, errors) => {
|
||||
if (!Validations.username(username)) {
|
||||
errors.username = "Invalid username";
|
||||
return;
|
||||
}
|
||||
|
||||
if (usernamesAllowed.current.some((value) => value === username)) {
|
||||
return;
|
||||
}
|
||||
@ -46,7 +56,7 @@ const useCheckUser = () => {
|
||||
username,
|
||||
});
|
||||
if (response.data) {
|
||||
errors.username = "The username is taken.";
|
||||
errors.username = "That username is taken";
|
||||
usernamesTaken.current.push(username);
|
||||
return;
|
||||
}
|
||||
@ -60,10 +70,6 @@ const createValidations = (validateUsername) => async (
|
||||
) => {
|
||||
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.email(email)) errors.email = "Invalid email";
|
||||
|
||||
if (!acceptTerms) errors.acceptTerms = "Must accept terms and conditions";
|
||||
@ -80,6 +86,8 @@ const MotionLayout = ({ children, ...props }) => (
|
||||
export default function TwitterSignup({
|
||||
initialEmail,
|
||||
onSignup,
|
||||
goToTwitterLinkingScene,
|
||||
resendEmailVerification,
|
||||
createVerification,
|
||||
onSignupWithVerification,
|
||||
}) {
|
||||
@ -90,11 +98,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 +120,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 +132,7 @@ export default function TwitterSignup({
|
||||
placeholder="Username"
|
||||
name="username"
|
||||
type="text"
|
||||
success="The username is available."
|
||||
success="That username is available"
|
||||
icon={
|
||||
isValidating
|
||||
? () => (
|
||||
@ -140,7 +149,7 @@ export default function TwitterSignup({
|
||||
: null
|
||||
}
|
||||
{...getFieldProps("username")}
|
||||
style={{ backgroundColor: "rgba(242,242,247,0.5)" }}
|
||||
full
|
||||
/>
|
||||
<AnimateSharedLayout>
|
||||
<Field
|
||||
@ -150,8 +159,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 +174,18 @@ export default function TwitterSignup({
|
||||
Create account
|
||||
</System.ButtonPrimary>
|
||||
</motion.div>
|
||||
{(!initialEmail || initialEmail !== email) && (
|
||||
<motion.div layout>
|
||||
<System.P css={STYLES_SMALL} style={{ marginTop: 16 }}>
|
||||
You will receive a code to verify your email at this address
|
||||
</System.P>
|
||||
</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>
|
||||
|
@ -1,5 +1,7 @@
|
||||
import * as React from "react";
|
||||
import * as System from "~/components/system";
|
||||
import * as Constants from "~/common/constants";
|
||||
|
||||
import { css } from "@emotion/react";
|
||||
|
||||
const STYLES_CHECKBOX_LABEL = (theme) => css`
|
||||
@ -28,20 +30,12 @@ const STYLES_CHECKBOX_ERROR = (theme) => css`
|
||||
width: 16px;
|
||||
`;
|
||||
|
||||
const STYLES_CHECKBOX_SUCCESS = (theme) => css`
|
||||
background-color: rgba(242, 242, 247, 0.5);
|
||||
border: 1px solid ${theme.system.green};
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
`;
|
||||
|
||||
const STYLES_CHECKBOX_WRAPPER = css`
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
export default function AuthCheckBox({ touched, error, ...props }) {
|
||||
const showError = touched && error;
|
||||
const showSuccess = touched && !error;
|
||||
|
||||
const STYLES_CHECKBOX = React.useMemo(() => {
|
||||
if (showError) return STYLES_CHECKBOX_ERROR;
|
||||
@ -53,6 +47,14 @@ export default function AuthCheckBox({ touched, error, ...props }) {
|
||||
containerStyles={STYLES_CHECKBOX_WRAPPER}
|
||||
labelStyles={STYLES_CHECKBOX_LABEL}
|
||||
inputStyles={STYLES_CHECKBOX}
|
||||
boxStyle={
|
||||
props.value
|
||||
? {
|
||||
backgroundColor: Constants.system.brand,
|
||||
boxShadow: `0 0 0 1px ${Constants.system.brand}`,
|
||||
}
|
||||
: { backgroundColor: Constants.system.bgBlurWhiteTRN }
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
I agree to the Slate{" "}
|
||||
|
@ -113,7 +113,6 @@ export default function Verification({ onVerify, title = DEFAULT_TITLE, onResend
|
||||
}
|
||||
textStyle={{ width: "100% !important" }}
|
||||
containerStyle={{ marginTop: "28px" }}
|
||||
style={{ backgroundColor: "rgba(242,242,247,0.5)" }}
|
||||
name="pin"
|
||||
type="pin"
|
||||
{...getFieldProps()}
|
||||
|
@ -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";
|
||||
|
@ -39,7 +39,8 @@ const STYLES_HEADER = css`
|
||||
color: ${Constants.system.black};
|
||||
font-size: ${Constants.typescale.lvl1};
|
||||
font-family: ${Constants.font.semiBold};
|
||||
word-break: break-all;
|
||||
word-break: break-word;
|
||||
white-space: pre-line;
|
||||
`;
|
||||
|
||||
const STYLES_SUB_HEADER = css`
|
||||
|
@ -44,20 +44,25 @@ const STYLES_INPUT = (theme) => css`
|
||||
background-color: rgba(242, 242, 247, 0.7);
|
||||
box-shadow: ${theme.shadow.large};
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 0 1px ${theme.system.white};
|
||||
&::placeholder {
|
||||
color: ${theme.system.textGrayDark};
|
||||
}
|
||||
`;
|
||||
const STYLES_INPUT_ERROR = (theme) => css`
|
||||
border: 1px solid ${theme.system.red};
|
||||
`;
|
||||
const STYLES_INPUT_SUCCESS = (theme) => css`
|
||||
border: 1px solid ${theme.system.green};
|
||||
box-shadow: 0 0 0 1px ${theme.system.red};
|
||||
`;
|
||||
|
||||
const PasswordValidations = ({ validations }) => {
|
||||
const STYLES_INPUT_SUCCESS = (theme) => css`
|
||||
box-shadow: 0 0 0 1px ${theme.system.green};
|
||||
`;
|
||||
|
||||
const PasswordValidations = ({ validations, full, color }) => {
|
||||
return (
|
||||
<div css={STYLES_PASSWORD_VALIDATIONS}>
|
||||
<div
|
||||
css={STYLES_PASSWORD_VALIDATIONS}
|
||||
style={{ backgroundColor: color === "white" && "white", maxWidth: !full && "480px" }}
|
||||
>
|
||||
<P css={STYLES_SMALL_TEXT}>Passwords should</P>
|
||||
<div css={STYLES_PASSWORD_VALIDATION}>
|
||||
<div css={[STYLES_CIRCLE, validations.validLength && STYLES_CIRCLE_SUCCESS]} />
|
||||
@ -91,6 +96,8 @@ export default function Field({
|
||||
validations,
|
||||
errorAs,
|
||||
containerAs,
|
||||
full,
|
||||
color = "transparent",
|
||||
...props
|
||||
}) {
|
||||
const showError = touched && error;
|
||||
@ -106,10 +113,14 @@ export default function Field({
|
||||
return (
|
||||
<div>
|
||||
<ContainerComponent>
|
||||
<Input inputCss={[STYLES_INPUT, STYLES_STATUS]} {...props} />
|
||||
<Input
|
||||
inputCss={[color === "transparent" && STYLES_INPUT, STYLES_STATUS]}
|
||||
full={full}
|
||||
{...props}
|
||||
/>
|
||||
</ContainerComponent>
|
||||
{props.name === "password" && validations && (
|
||||
<PasswordValidations validations={validations} />
|
||||
<PasswordValidations color={color} full={full} validations={validations} />
|
||||
)}
|
||||
{props.name !== "password" && (showError || showSuccess) && (
|
||||
<ErrorWrapper>
|
||||
|
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" });
|
||||
};
|
@ -31,6 +31,8 @@ export default async (req, res) => {
|
||||
return res.status(500).send({ decorator: "SERVER_CREATE_USER_INVALID_USERNAME", error: true });
|
||||
}
|
||||
|
||||
const formattedUsername = Strings.createUsername(username);
|
||||
|
||||
const verification = await Data.getVerificationBySid({
|
||||
sid: req.body.data.token,
|
||||
});
|
||||
@ -70,7 +72,6 @@ export default async (req, res) => {
|
||||
return res.status(201).send({ decorator: "SERVER_CREATE_USER_TWITTER_EXISTS" });
|
||||
}
|
||||
|
||||
const newUsername = username.toLowerCase();
|
||||
const newEmail = verification.email.toLowerCase();
|
||||
|
||||
// NOTE(Amine): If there is an account with the user's twitter email
|
||||
@ -78,7 +79,7 @@ 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: formattedUsername });
|
||||
if (userByUsername) {
|
||||
return res.status(201).send({ decorator: "SERVER_CREATE_USER_USERNAME_TAKEN" });
|
||||
}
|
||||
@ -92,7 +93,7 @@ export default async (req, res) => {
|
||||
// Don't do this once you refactor.
|
||||
const { buckets, bucketKey, bucketName } = await Utilities.getBucketAPIFromUserToken({
|
||||
user: {
|
||||
username: newUsername,
|
||||
username: formattedUsername,
|
||||
data: { tokens: { api } },
|
||||
},
|
||||
});
|
||||
@ -110,7 +111,7 @@ export default async (req, res) => {
|
||||
});
|
||||
|
||||
const user = await Data.createUser({
|
||||
username: newUsername,
|
||||
username: formattedUsername,
|
||||
email: newEmail,
|
||||
twitterId: twitterUser.id_str,
|
||||
data: {
|
||||
|
@ -30,6 +30,8 @@ export default async (req, res) => {
|
||||
return res.status(500).send({ decorator: "SERVER_CREATE_USER_INVALID_USERNAME", error: true });
|
||||
}
|
||||
|
||||
const formattedUsername = Strings.createUsername(username);
|
||||
|
||||
const storedAuthToken = req.cookies[COOKIE_NAME];
|
||||
|
||||
// NOTE(amine): additional security check
|
||||
@ -59,7 +61,7 @@ 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: formattedUsername });
|
||||
if (userByUsername) {
|
||||
return res.status(201).send({ decorator: "SERVER_CREATE_USER_USERNAME_TAKEN" });
|
||||
}
|
||||
@ -69,12 +71,11 @@ export default async (req, res) => {
|
||||
const identity = await PrivateKey.fromRandom();
|
||||
const api = identity.toString();
|
||||
|
||||
const newUsername = username.toLowerCase();
|
||||
const newEmail = email.toLowerCase();
|
||||
|
||||
const { buckets, bucketKey, bucketName } = await Utilities.getBucketAPIFromUserToken({
|
||||
user: {
|
||||
username: newUsername,
|
||||
username: formattedUsername,
|
||||
data: { tokens: { api } },
|
||||
},
|
||||
});
|
||||
@ -92,7 +93,7 @@ export default async (req, res) => {
|
||||
});
|
||||
|
||||
const user = await Data.createUser({
|
||||
username: newUsername,
|
||||
username: formattedUsername,
|
||||
email: newEmail,
|
||||
twitterId: twitterUser.id_str,
|
||||
data: {
|
||||
|
@ -29,6 +29,8 @@ export default async (req, res) => {
|
||||
.send({ decorator: "SERVER_EMAIL_VERIFICATION_INVALID_TOKEN", error: true });
|
||||
}
|
||||
|
||||
const formattedUsername = Strings.createUsername(req.body.data.username);
|
||||
|
||||
const verification = await Data.getVerificationBySid({
|
||||
sid: req.body.data.token,
|
||||
});
|
||||
@ -38,7 +40,7 @@ export default async (req, res) => {
|
||||
}
|
||||
|
||||
const existing = await Data.getUserByUsername({
|
||||
username: req.body.data.username.toLowerCase(),
|
||||
username: formattedUsername,
|
||||
});
|
||||
if (existing) {
|
||||
return res.status(403).send({ decorator: "SERVER_CREATE_USER_USERNAME_TAKEN", error: true });
|
||||
@ -60,12 +62,11 @@ export default async (req, res) => {
|
||||
|
||||
// TODO(jim):
|
||||
// Don't do this once you refactor.
|
||||
const newUsername = req.body.data.username.toLowerCase();
|
||||
const newEmail = verification.email;
|
||||
|
||||
const { buckets, bucketKey, bucketName } = await Utilities.getBucketAPIFromUserToken({
|
||||
user: {
|
||||
username: newUsername,
|
||||
username: formattedUsername,
|
||||
data: { tokens: { api } },
|
||||
},
|
||||
});
|
||||
@ -85,7 +86,7 @@ export default async (req, res) => {
|
||||
const user = await Data.createUser({
|
||||
password: hash,
|
||||
salt,
|
||||
username: newUsername,
|
||||
username: formattedUsername,
|
||||
email: newEmail,
|
||||
data: {
|
||||
photo,
|
||||
@ -122,6 +123,4 @@ export default async (req, res) => {
|
||||
from: slateEmail,
|
||||
templateId: welcomeTemplateId,
|
||||
});
|
||||
|
||||
Monitor.createUser({ user });
|
||||
};
|
||||
|
@ -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: "" } }),
|
||||
}),
|
||||
@ -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);
|
||||
@ -296,7 +301,7 @@ export const useTwitter = ({ onAuthenticate, goToTwitterSignupScene }) => {
|
||||
}
|
||||
|
||||
if (response.token) {
|
||||
await onAuthenticate(response);
|
||||
await onTwitterAuthenticate(response);
|
||||
setIsLoggingIn(false);
|
||||
return;
|
||||
}
|
||||
@ -310,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({
|
||||
@ -319,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;
|
||||
@ -334,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;
|
||||
@ -365,6 +436,8 @@ export const useTwitter = ({ onAuthenticate, goToTwitterSignupScene }) => {
|
||||
return {
|
||||
isLoggingIn,
|
||||
signin,
|
||||
linkAccount,
|
||||
linkAccountWithVerification,
|
||||
signup,
|
||||
signupWithVerification,
|
||||
createVerification,
|
||||
|
@ -3,7 +3,14 @@ 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,
|
||||
@ -21,7 +28,7 @@ const STYLES_ROOT = css`
|
||||
text-align: center;
|
||||
font-size: 1rem;
|
||||
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@ -57,6 +64,7 @@ const SigninScene = ({ onAuthenticate, onTwitterAuthenticate, page, ...props })
|
||||
goToSigninScene,
|
||||
goToSignupScene,
|
||||
goToTwitterSignupScene,
|
||||
goToTwitterLinkingScene,
|
||||
goToResetPassword,
|
||||
clearMessages,
|
||||
goBack,
|
||||
@ -69,7 +77,8 @@ const SigninScene = ({ onAuthenticate, onTwitterAuthenticate, page, ...props })
|
||||
onAuthenticate,
|
||||
});
|
||||
const twitterProvider = useTwitter({
|
||||
onAuthenticate: onTwitterAuthenticate,
|
||||
onTwitterAuthenticate: onTwitterAuthenticate,
|
||||
onAuthenticate,
|
||||
goToTwitterSignupScene,
|
||||
});
|
||||
|
||||
@ -121,12 +130,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 : "";
|
||||
@ -142,18 +162,36 @@ const SigninScene = ({ onAuthenticate, onTwitterAuthenticate, page, ...props })
|
||||
/>
|
||||
);
|
||||
};
|
||||
const BackgroundGenerator = ({ children, ...props }) => {
|
||||
const background = React.useMemo(() => {
|
||||
const backgroundIdx = Utilities.getRandomNumberBetween(0, AUTH_BACKGROUNDS.length - 1);
|
||||
return AUTH_BACKGROUNDS[backgroundIdx];
|
||||
}, []);
|
||||
|
||||
// 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 windowInnerHeight = window.innerHeight;
|
||||
setHeight(windowInnerHeight);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ backgroundImage: `url(${background})`, height }} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const WithCustomWrapper = (Component) => (props) => {
|
||||
const backgroundIdx = Utilities.getRandomNumberBetween(0, AUTH_BACKGROUNDS.length);
|
||||
console.log(backgroundIdx);
|
||||
const background = AUTH_BACKGROUNDS[backgroundIdx];
|
||||
return (
|
||||
<WebsitePrototypeWrapper>
|
||||
<div style={{ backgroundImage: `url(${background})` }} css={STYLES_ROOT}>
|
||||
<BackgroundGenerator css={STYLES_ROOT}>
|
||||
<div css={STYLES_MIDDLE}>
|
||||
<Component {...props} />
|
||||
</div>
|
||||
</div>
|
||||
</BackgroundGenerator>
|
||||
</WebsitePrototypeWrapper>
|
||||
);
|
||||
};
|
||||
|
@ -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,11 +13,13 @@ 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 Avatar from "~/components/core/Avatar";
|
||||
import Field from "~/components/core/Field";
|
||||
|
||||
const STYLES_FILE_HIDDEN = css`
|
||||
height: 1px;
|
||||
@ -41,25 +43,89 @@ 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.data.body,
|
||||
photo: this.props.viewer.data.photo,
|
||||
name: this.props.viewer.data.name,
|
||||
deleting: false,
|
||||
allow_filecoin_directory_listing: this.props.viewer.data.settings
|
||||
?.allow_filecoin_directory_listing,
|
||||
allow_filecoin_directory_listing:
|
||||
this.props.viewer.data.settings?.allow_filecoin_directory_listing,
|
||||
allow_automatic_data_storage: this.props.viewer.data.settings?.allow_automatic_data_storage,
|
||||
allow_encrypted_data_storage: this.props.viewer.data.settings?.allow_encrypted_data_storage,
|
||||
changingPassword: false,
|
||||
changingAvatar: false,
|
||||
savingNameBio: false,
|
||||
changingFilecoin: false,
|
||||
modalShow: false,
|
||||
showPassword: false,
|
||||
};
|
||||
|
||||
_handleUpload = async (e) => {
|
||||
@ -70,7 +136,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({
|
||||
data: {
|
||||
@ -82,7 +148,7 @@ export default class SceneEditAccount extends React.Component {
|
||||
this.setState({ changingAvatar: false, photo: url });
|
||||
};
|
||||
|
||||
_handleSaveFilecoin = async (e) => {
|
||||
_handleSaveFilecoin = async () => {
|
||||
this.setState({ changingFilecoin: true });
|
||||
|
||||
let response = await Actions.updateViewer({
|
||||
@ -100,7 +166,7 @@ export default class SceneEditAccount extends React.Component {
|
||||
this.setState({ changingFilecoin: false });
|
||||
};
|
||||
|
||||
_handleSave = async (e) => {
|
||||
_handleSave = async () => {
|
||||
if (!Validations.username(this.state.username)) {
|
||||
Events.dispatchMessage({
|
||||
message: "Please include only letters and numbers in your username",
|
||||
@ -126,33 +192,7 @@ export default class SceneEditAccount extends React.Component {
|
||||
};
|
||||
|
||||
_handleUsernameChange = (e) => {
|
||||
this.setState({ [e.target.name]: e.target.value.toLowerCase() });
|
||||
};
|
||||
|
||||
_handleChangePassword = async (e) => {
|
||||
if (this.state.password !== this.state.confirm) {
|
||||
Events.dispatchMessage({ message: "Passwords did not match" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Validations.password(this.state.password)) {
|
||||
Events.dispatchMessage({ message: "Password length must be more than 8 characters" });
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ changingPassword: true });
|
||||
|
||||
let response = await Actions.updateViewer({
|
||||
type: "CHANGE_PASSWORD",
|
||||
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) => {
|
||||
@ -296,31 +336,10 @@ export default class SceneEditAccount extends React.Component {
|
||||
</div>
|
||||
) : null}
|
||||
{tab === "security" ? (
|
||||
<div>
|
||||
<div css={STYLES_HEADER}>Change password</div>
|
||||
<div>Passwords must be a minimum of eight characters.</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