diff --git a/native-example/App.js b/native-example/App.js index 3bffb49..091a52f 100644 --- a/native-example/App.js +++ b/native-example/App.js @@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'; import { StyleSheet, Text, View, TextInput, Button } from 'react-native'; import { useEasybase, EasybaseProvider } from 'easybase-react'; +import { NativeAuth } from 'easybase-react/native'; import ebconfig from './ebconfig'; function Account() { @@ -57,7 +58,10 @@ function Router() { export default function app() { return ( <EasybaseProvider ebconfig={ebconfig}> - <Router /> + {/* <Router /> */} + <View style={styles.container}> + <NativeAuth /> + </View> </EasybaseProvider> ) } diff --git a/native-example/package.json b/native-example/package.json index c41bdf5..a89a389 100644 --- a/native-example/package.json +++ b/native-example/package.json @@ -5,6 +5,7 @@ "ios": "npm run reinit && react-native run-ios", "web": "npm run reinit && expo start --web", "start": "npm run reinit && react-native start", + "resetCache": "react-native start --reset-cache", "reinit": "npm i github:easybase/easybase-react#dev" }, "dependencies": { diff --git a/native-example/yarn.lock b/native-example/yarn.lock index 3e5cd27..a0ef3a1 100644 --- a/native-example/yarn.lock +++ b/native-example/yarn.lock @@ -2897,7 +2897,7 @@ "version" "0.1.2" "easybase-react@github:easybase/easybase-react#dev": - "resolved" "git+ssh://git@github.com/easybase/easybase-react.git#bc356375b205adbde4872c3d3b3d2ba2452c73e2" + "resolved" "git+ssh://git@github.com/easybase/easybase-react.git#f43f356fb0873aa3a3a1a8c1f4c05541aa157be3" "version" "2.1.15" dependencies: "easybasejs" "4.2.13" diff --git a/src/ui/NativeAuth/NativeAuth.tsx b/src/ui/NativeAuth/NativeAuth.tsx new file mode 100644 index 0000000..570286e --- /dev/null +++ b/src/ui/NativeAuth/NativeAuth.tsx @@ -0,0 +1,108 @@ +import React, { useEffect, useState, lazy, Suspense, Fragment } from 'react'; +import Container from './components/Container'; +import { ThemeProvider } from 'styled-components/native'; +import { Toaster } from 'react-hot-toast'; +import { mergeDeep, defaultDictionary } from '../utils'; +import { IStyles, IAuth } from '../uiTypes'; +import useEasybase from '../../useEasybase'; + +const DefaultSignIn = lazy(() => import('./pages/SignIn')); +const DefaultSignUp = lazy(() => import('./pages/SignUp')); +const DefaultForgotPassword = lazy(() => import('./pages/ForgotPassword')); + +export default function ({ theme, customStyles, children, dictionary, signUpFields }: IAuth): JSX.Element { + const [themeVal, setThemeVal] = useState<any>({}); + + const [currentPage, setCurrentPage] = useState<"SignIn" | "SignUp" | "ForgotPassword" | "ForgotPasswordConfirm">("SignIn"); + const { isUserSignedIn } = useEasybase(); + + useEffect(() => { + try { + document.body.style.margin = "0px"; + } catch (_) { } + async function mounted() { + let loadedTheme: IStyles = {}; + if (theme === "minimal-dark") { + const _theme = (await import('../themes/minimal-dark')).default; + if (_theme.init) { + _theme.init() + } + loadedTheme = _theme; + } else if (theme === "material") { + const _theme = (await import('../themes/material')).default; + if (_theme.init) { + _theme.init() + } + loadedTheme = _theme; + } else { + // catch all + const _theme = (await import('../themes/minimal')).default; + if (_theme.init) { + _theme.init() + } + loadedTheme = _theme; + } + + if (customStyles) { + loadedTheme = mergeDeep(loadedTheme, customStyles) + } + + setThemeVal(loadedTheme) + } + mounted(); + }, [theme]) + + if (isUserSignedIn()) { + return <Fragment>{children}</Fragment> + } + + const getCurrentPage = () => { + switch (currentPage) { + case "SignIn": + return ( + <Suspense fallback={<Fragment />}> + <DefaultSignIn + setCurrentPage={setCurrentPage} + dictionary={typeof dictionary === "object" ? { ...defaultDictionary, ...dictionary } : defaultDictionary} + /> + </Suspense> + ) + case "SignUp": + return ( + <Suspense fallback={<Fragment />}> + <DefaultSignUp + setCurrentPage={setCurrentPage} + dictionary={typeof dictionary === "object" ? { ...defaultDictionary, ...dictionary } : defaultDictionary} + signUpFields={typeof signUpFields === "object" ? signUpFields : {}} + /> + </Suspense> + ) + case "ForgotPassword": + return ( + <Suspense fallback={<Fragment />}> + <DefaultForgotPassword + setCurrentPage={setCurrentPage} + dictionary={typeof dictionary === "object" ? { ...defaultDictionary, ...dictionary } : defaultDictionary} + /> + </Suspense> + ) + default: + return <React.Fragment />; + } + } + + return ( + <ThemeProvider theme={themeVal}> + <Container> + <Toaster toastOptions={{ style: { fontFamily: 'inherit', ...(themeVal.toast ? { ...themeVal.toast } : {}) } }} /> + {/* {getCurrentPage()} */} + <Suspense fallback={<Fragment />}> + <DefaultSignIn + setCurrentPage={setCurrentPage} + dictionary={typeof dictionary === "object" ? { ...defaultDictionary, ...dictionary } : defaultDictionary} + /> + </Suspense> + </Container> + </ThemeProvider> + ) +} diff --git a/src/ui/NativeAuth/components/Container.tsx b/src/ui/NativeAuth/components/Container.tsx new file mode 100644 index 0000000..9e5795c --- /dev/null +++ b/src/ui/NativeAuth/components/Container.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import styled from 'styled-components/native'; + +const Container = styled.View((props: any) => ({ + flex: 1, + backgroundColor: '#fff', + alignItems: 'center', + justifyContent: 'center', + ...(props.theme.container ? { ...props.theme.container } : {}) +})) + +export default function (props: any) { + return ( + <Container>{props.children}</Container> + ) +} diff --git a/src/ui/NativeAuth/components/EmailInput.tsx b/src/ui/NativeAuth/components/EmailInput.tsx new file mode 100644 index 0000000..915f178 --- /dev/null +++ b/src/ui/NativeAuth/components/EmailInput.tsx @@ -0,0 +1,8 @@ +import React from 'react'; +import Input from './internal/Input'; + +export default function(props: any) { + return ( + <Input label="Email" autoComplete="email" {...props} type="email" required /> + ) +} diff --git a/src/ui/NativeAuth/components/ErrorText.tsx b/src/ui/NativeAuth/components/ErrorText.tsx new file mode 100644 index 0000000..fbd2c61 --- /dev/null +++ b/src/ui/NativeAuth/components/ErrorText.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import styled from 'styled-components'; + +const ErrorText = styled.p(props => ({ + marginTop: 5, + marginBottom: -5, + fontSize: 12, + fontWeight: 500, + color: 'red', + height: 0, + overflow: 'visible', + ...(props.theme.errorText ? { ...props.theme.errorText } : {}) +})) + +interface IErrorText extends React.HTMLAttributes<HTMLParagraphElement> { + value?: string | undefined; +} + +export default function (props: IErrorText) { + if (props.value && props.value.length) { + return <ErrorText {...props}>{props.value}</ErrorText> + } + return <React.Fragment /> +} diff --git a/src/ui/NativeAuth/components/ForgotPassword.tsx b/src/ui/NativeAuth/components/ForgotPassword.tsx new file mode 100644 index 0000000..8aa3a6f --- /dev/null +++ b/src/ui/NativeAuth/components/ForgotPassword.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import TextButton from './internal/TextButton'; +import styled from 'styled-components/native'; + +const ForgotPassword = styled(TextButton)(props => ({ + marginTop: -53, + marginBottom: 53, + display: 'flex', + ...(props.theme.forgotPassword ? { ...props.theme.forgotPassword } : {}) +})) + +export default function (props: any) { + return ( + <ForgotPassword {...props} /> + ) +} diff --git a/src/ui/NativeAuth/components/Form.tsx b/src/ui/NativeAuth/components/Form.tsx new file mode 100644 index 0000000..eaca678 --- /dev/null +++ b/src/ui/NativeAuth/components/Form.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import styled from 'styled-components/native'; + +const Form = styled.View((props: any) => ({ + display: "flex", + justifyContent: "center", + minWidth: 300, + width: 380, + padding: '33px 55px', + boxShadow: '0 5px 10px 0 rgb(0 0 0 / 10%)', + borderRadius: 10, + flexDirection: 'column', + fontFamily: "inherit", + margin: '6% auto 50px', + '@media (max-width: 520px)': { + margin: '0px !important', + position: 'fixed !important', + top: 0, + left: 0, + right: 0, + bottom: 0, + width: 'initial !important' + }, + ...(props.theme.form ? { ...props.theme.form } : {}) +})) + +export default function (props: any) { + return ( + <Form {...props}>{props.children}</Form> + ) +} diff --git a/src/ui/NativeAuth/components/GenderSelect.tsx b/src/ui/NativeAuth/components/GenderSelect.tsx new file mode 100644 index 0000000..9653a82 --- /dev/null +++ b/src/ui/NativeAuth/components/GenderSelect.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { UseFormRegisterReturn } from 'react-hook-form'; +import styled from 'styled-components'; +import Label from './internal/Label'; +import Select from './internal/Select'; + +const GenderSelect = styled(Select)(props => ({ + boxSizing: "border-box", + ...(props.theme.genderSelect ? { ...props.theme.genderSelect } : {}) +})) + +const Root = styled.div({ + position: "relative" +}) + +interface ISelect extends React.SelectHTMLAttributes<HTMLSelectElement> { + register(): UseFormRegisterReturn; +} + +export default function (props: ISelect) { + return ( + <Root> + <Label htmlFor="select-gender">Gender *</Label> + <GenderSelect id="select-gender" {...props} options={["Male", "Female", "Prefer not to say"]} /> + </Root> + ) +} diff --git a/src/ui/NativeAuth/components/HeaderText.tsx b/src/ui/NativeAuth/components/HeaderText.tsx new file mode 100644 index 0000000..6ca22a4 --- /dev/null +++ b/src/ui/NativeAuth/components/HeaderText.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import styled from 'styled-components/native'; + +const HeaderText = styled.Text((props: any) => ({ + fontFamily: "inherit", + fontSize: 24, + fontWeight: 500, + letterSpacing: -.2, + marginBlockStart: '0.67em', + marginBlockEnd: '0.67em', + marginInlineStart: 0, + marginInlineEnd: 0, + marginTop: '16px !important', + ...(props.theme.headerText ? { ...props.theme.headerText } : {}) +})) + +export default function (props: any) { + return ( + <HeaderText {...props} /> + ) +} diff --git a/src/ui/NativeAuth/components/PasswordInput.tsx b/src/ui/NativeAuth/components/PasswordInput.tsx new file mode 100644 index 0000000..4123d31 --- /dev/null +++ b/src/ui/NativeAuth/components/PasswordInput.tsx @@ -0,0 +1,8 @@ +import React from 'react'; +import Input from './internal/Input'; + +export default function(props: any) { + return ( + <Input label="Password" {...props} type="password" required /> + ) +} diff --git a/src/ui/NativeAuth/components/SecondaryButton.tsx b/src/ui/NativeAuth/components/SecondaryButton.tsx new file mode 100644 index 0000000..83cccea --- /dev/null +++ b/src/ui/NativeAuth/components/SecondaryButton.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import TextButton from './internal/TextButton'; +import styled from 'styled-components/native'; + +const SecondaryButton = styled(TextButton)(props => ({ + margin: '15px', + ...(props.theme.secondaryButton ? { ...props.theme.secondaryButton } : {}) +})) + +export default function (props: any) { + return ( + <SecondaryButton {...props} /> + ) +} diff --git a/src/ui/NativeAuth/components/SecondaryText.tsx b/src/ui/NativeAuth/components/SecondaryText.tsx new file mode 100644 index 0000000..ba7b971 --- /dev/null +++ b/src/ui/NativeAuth/components/SecondaryText.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import styled from 'styled-components'; + +const SecondaryText = styled.h2(props => ({ + fontFamily: "inherit", + fontSize: 15, + fontWeight: 300, + letterSpacing: -.2, + lineHeight: '20px', + whiteSpace: 'normal', + ...(props.theme.secondaryText ? { ...props.theme.secondaryText } : {}) +})) + +export default function (props: React.HTMLAttributes<HTMLHeadingElement>) { + return ( + <SecondaryText {...props} /> + ) +} diff --git a/src/ui/NativeAuth/components/Spacer.tsx b/src/ui/NativeAuth/components/Spacer.tsx new file mode 100644 index 0000000..75456a2 --- /dev/null +++ b/src/ui/NativeAuth/components/Spacer.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import styled from 'styled-components/native'; + +const Spacer = styled.View({}); + +interface ISpacer { + size?: "xlarge" | "large" | "medium" | "small" +} + +export default function (props: ISpacer) { + switch (props.size) { + case "xlarge": + return <Spacer style={{ height: 64 }} /> + case "large": + return <Spacer style={{ height: 58 }} /> + case "small": + return <Spacer style={{ height: 16 }} /> + default: + return <Spacer style={{ height: 37 }} /> + } +} diff --git a/src/ui/NativeAuth/components/SubmitButton.tsx b/src/ui/NativeAuth/components/SubmitButton.tsx new file mode 100644 index 0000000..4df3d09 --- /dev/null +++ b/src/ui/NativeAuth/components/SubmitButton.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import styled from 'styled-components/native'; + +const SubmitButtonRoot = styled.View((props: any) => props.theme.submitButtonRoot ? props.theme.submitButtonRoot : {}); + +const SubmitButton = styled.Button((props: any) => ({ + position: 'relative', + border: "none", + verticalAlign: "middle", + textAlign: "center", + textOverflow: "ellipsis", + overflow: "hidden", + outline: "none", + cursor: "pointer", + boxSizing: 'border-box', + ...(props.theme.submitButton ? { ...props.theme.submitButton } : {}) +})) + +export default function (props: any) { + return ( + <SubmitButtonRoot> + <SubmitButton type="submit" {...props} /> + </SubmitButtonRoot> + ) +} diff --git a/src/ui/NativeAuth/components/internal/Input.tsx b/src/ui/NativeAuth/components/internal/Input.tsx new file mode 100644 index 0000000..99a31c9 --- /dev/null +++ b/src/ui/NativeAuth/components/internal/Input.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import styled from 'styled-components/native'; +import Label from './Label'; + +const TextFieldRoot = styled.View((props: any) => ({ + position: 'relative', + width: '100%', + maxWidth: '100%', + padding: 0, + height: 46, + fontFamily: "inherit", + ...(props.theme.textFieldRoot ? { ...props.theme.textFieldRoot } : {}) +})) + +const TextField = styled.TextInput((props: any) => ({ + display: "block", + width: '100%', + background: '0 0', + border: 'none', + fontFamily: "inherit", + ...(props.theme.textField ? { ...props.theme.textField } : {}) +})) + +const Bar = styled.View((props: any) => props.theme.textFieldBar ? { ...props.theme.textFieldBar } : {}) + +export default function (props: any) { + return ( + <TextFieldRoot> + <TextField placeholder=" " {...props}/> + <Bar /> + <Label>{props.label}</Label> + </TextFieldRoot> + ) +} diff --git a/src/ui/NativeAuth/components/internal/Label.tsx b/src/ui/NativeAuth/components/internal/Label.tsx new file mode 100644 index 0000000..9c35e37 --- /dev/null +++ b/src/ui/NativeAuth/components/internal/Label.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import styled from 'styled-components'; + +const Label = styled.label(props => ({ + display: "none", + fontFamily: "inherit", + ...(props.theme.textFieldLabel ? { ...props.theme.textFieldLabel } : {}) +})) + +export default function (props: React.LabelHTMLAttributes<HTMLLabelElement>) { + return (<Label {...props} />) +} diff --git a/src/ui/NativeAuth/components/internal/Select.tsx b/src/ui/NativeAuth/components/internal/Select.tsx new file mode 100644 index 0000000..c87dde5 --- /dev/null +++ b/src/ui/NativeAuth/components/internal/Select.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { UseFormRegisterReturn } from 'react-hook-form'; +import styled from 'styled-components'; + +const SelectContainer = styled.div({ + position: 'relative', + display: 'inline', + width: '100%', + maxWidth: '100%', + cursor: 'pointer', + '&:after': { + content: "''", + width: 0, + height: 0, + position: 'absolute', + pointerEvents: 'none', + top: '.3em', + right: '.75em', + borderTop: '8px solid black', + opacity: 0.5, + borderLeft: '5px solid transparent', + borderRight: '5px solid transparent' + } +}) + +const Select = styled.select({ + WebkitAppearance: 'none', + MozAppearance: 'none', + appearance: 'none', + padding: '1em 2em 1em 1em', + border: 'none', + width: '100%', + fontFamily: 'inherit', + fontSize: 'inherit', + cursor: 'pointer', + outline: 'none', + '&::-ms-expand': { + display: 'none' + } +}) + +const SelectOption = styled.option(props => ({ + width: '100%', + ...(props.theme.selectOption ? { ...props.theme.selectOption } : {}) +})) + +interface ISelect extends React.SelectHTMLAttributes<HTMLSelectElement> { + options: string[]; + id: string; + register(): UseFormRegisterReturn; +} + +export default function (props: ISelect) { + return ( + <SelectContainer> + <Select {...props} {...props.register()} defaultValue=""> + <SelectOption key="empty-option" value="" disabled hidden style={{ display: 'none' }} /> + {props.options.map(e => <SelectOption key={"option" + e}>{e}</SelectOption>)} + </Select> + </SelectContainer> + ) +} diff --git a/src/ui/NativeAuth/components/internal/TextButton.tsx b/src/ui/NativeAuth/components/internal/TextButton.tsx new file mode 100644 index 0000000..2353369 --- /dev/null +++ b/src/ui/NativeAuth/components/internal/TextButton.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import styled from 'styled-components/native'; + +const TextButton = styled.Button((props: any) => ({ + cursor: "pointer", + color: '#635bff', + whiteSpace: 'nowrap', + fontWeight: 500, + fontSize: 14, + margin: 0, + background: 'none', + border: 'none', + ...(props.theme.textButton ? { ...props.theme.textButton } : {}) +})) + +export default function (props: any) { + return ( + <TextButton {...props} /> + ) +} diff --git a/src/ui/NativeAuth/pages/ForgotPassword.tsx b/src/ui/NativeAuth/pages/ForgotPassword.tsx new file mode 100644 index 0000000..6630741 --- /dev/null +++ b/src/ui/NativeAuth/pages/ForgotPassword.tsx @@ -0,0 +1,138 @@ +import React, { useState } from 'react'; +import Form from '../components/Form'; +import EmailInput from '../components/EmailInput'; +import HeaderText from '../components/HeaderText'; +import SecondaryText from '../components/SecondaryText'; +import SecondaryButton from '../components/SecondaryButton'; +import SubmitButton from '../components/SubmitButton'; +import Spacer from '../components/Spacer'; +import { useForm } from 'react-hook-form'; +import { IPage } from '../../uiTypes'; +import toast from 'react-hot-toast'; +import ErrorText from '../components/ErrorText'; +import Input from '../components/internal/Input'; +import PasswordInput from '../components/PasswordInput'; +import useEasybase from '../../../useEasybase'; + +export default function ({ setCurrentPage, dictionary }: IPage) { + const [onConfirm, setOnConfirm] = useState<boolean>(false); + const [forgottenUsername, setForgottenUsername] = useState<string | undefined>(); + const { register, handleSubmit, reset, formState: { errors, isSubmitting } } = useForm(); + const { forgotPassword, forgotPasswordConfirm } = useEasybase(); + + const onSubmit = async (formData: Record<string, string>) => { + if (!formData.email) { + return; + } + + const forgotRes = await forgotPassword(formData.email, { greeting: "Hello user," }); + if (forgotRes.success) { + setForgottenUsername(formData.email); + setOnConfirm(true); + toast.success('Check your email for a verification code') + } else { + if (forgotRes.errorCode === "RequestLimitExceeded") { + toast.error(dictionary.errorRequestLimitExceeded!); + } else if (forgotRes.errorCode === "BadFormat") { + reset(); + toast.error(dictionary.errorBadInputFormat!); + } else if (forgotRes.errorCode === "NoUserExists") { + reset(); + toast.error(dictionary.errorNoAccountFound!); + } else { + reset(); + toast.error('Bad request'); + } + } + } + + const onConfirmSubmit = async (formData: Record<string, string>) => { + if (!formData.code || !formData.newPassword || !forgottenUsername) { + return; + } + const forgotConfirmRes = await forgotPasswordConfirm(formData.code, forgottenUsername, formData.newPassword) + if (forgotConfirmRes.success) { + setOnConfirm(false); + setForgottenUsername(""); + setCurrentPage('SignIn'); + toast.success('Password successfully changed') + } else { + if (forgotConfirmRes.errorCode === "BadPasswordLength") { + toast.error(dictionary.errorPasswordTooShort!); + } else if (forgotConfirmRes.errorCode === "BadFormat") { + reset(); + toast.error(dictionary.errorBadInputFormat!); + } else if (forgotConfirmRes.errorCode === "NoUserExists") { + reset(); + toast.error(dictionary.errorNoAccountFound!); + } else if (forgotConfirmRes.errorCode === "WrongVerificationCode") { + toast.error(dictionary.errorWrongVerificationCode!); + } else { + toast.error('Bad request'); + } + } + } + + const passwordReqs = { + minLength: { + value: 8, + message: "Password must be at least 8 characters long" + }, + maxLength: { + value: 100, + message: "Password too long" + }, + pattern: { + value: /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).{7,}$/gm, + message: "Must contain a digit and uppercase and lowercase letters" + } + } + + const codeReqs = { + minLength: { + value: 8, + message: "Incorrect code length" + } + } + + if (!onConfirm) { + return ( + <Form onSubmit={handleSubmit(onSubmit)}> + <HeaderText>{dictionary.forgotPasswordHeader}</HeaderText> + <SecondaryText>{dictionary.forgotPasswordSecondaryHeader}</SecondaryText> + <Spacer size="medium" /> + <EmailInput + register={() => register("email")} + label={dictionary.newEmailLabel} + disabled={isSubmitting} + /> + <Spacer size="medium" /> + <SubmitButton disabled={isSubmitting}>{dictionary.forgotPasswordSubmitButton}</SubmitButton> + <SecondaryButton onClick={(_: any) => setCurrentPage("SignIn")} disabled={isSubmitting}>{dictionary.backToSignIn}</SecondaryButton> + </Form> + ) + } else { + return ( + <Form onSubmit={handleSubmit(onConfirmSubmit)}> + <HeaderText>{dictionary.forgotPasswordConfirmHeader}</HeaderText> + <Spacer size="medium" /> + <Input + register={() => register("code", codeReqs)} + label={dictionary.codeLabel!} + disabled={isSubmitting} + /> + <ErrorText value={errors.code?.message} /> + <Spacer size="xlarge" /> + <PasswordInput + register={() => register("newPassword", passwordReqs)} + label={dictionary.forgotPasswordConfirmLabel} + autoComplete="new-password" + disabled={isSubmitting} + /> + <ErrorText value={errors.newPassword?.message} /> + <Spacer size="xlarge" /> + <SubmitButton disabled={isSubmitting}>{dictionary.forgotPasswordConfirmSubmitButton}</SubmitButton> + </Form> + ) + } +} diff --git a/src/ui/NativeAuth/pages/SignIn.tsx b/src/ui/NativeAuth/pages/SignIn.tsx new file mode 100644 index 0000000..e674d5b --- /dev/null +++ b/src/ui/NativeAuth/pages/SignIn.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import Form from '../components/Form'; +import EmailInput from '../components/EmailInput'; +import PasswordInput from '../components/PasswordInput'; +import HeaderText from '../components/HeaderText'; +import ForgotPassword from '../components/ForgotPassword'; +import SecondaryButton from '../components/SecondaryButton'; +import SubmitButton from '../components/SubmitButton'; +import Spacer from '../components/Spacer'; +import { useForm, Controller } from 'react-hook-form'; +import toast from 'react-hot-toast'; +import { IPage } from '../../uiTypes'; +import useEasybase from '../../../useEasybase'; + +export default function ({ setCurrentPage, dictionary }: IPage) { + const { control, handleSubmit, reset, formState: { isSubmitting } } = useForm(); + const { signIn } = useEasybase(); + const onSubmit = async (formData: Record<string, string>) => { + await new Promise(resolve => setTimeout(resolve, 1000)); + const signInRes = await signIn(formData.email, formData.password); + if (!signInRes.success) { + if (signInRes.errorCode === "NoUserExists") { + toast.error(dictionary.errorUserDoesNotExist!) + } else if (signInRes.errorCode === "BadFormat") { + reset(); + toast.error(dictionary.errorBadInputFormat!) + } + } + // Will automatically change views + } + + return ( + <Form> + <HeaderText>{dictionary.signInHeader}</HeaderText> + <Spacer size="medium" /> + <Controller + control={control} + render={({ field: { onChange, onBlur, value } }) => ( + <EmailInput + onBlur={onBlur} + onChangeText={(value: any) => onChange(value)} + value={value} + label={dictionary.emailLabel} + disabled={isSubmitting} + /> + )} + name="email" + defaultValue="" + /> + + <Spacer size="xlarge" /> + <Controller + control={control} + render={({ field: { onChange, onBlur, value } }) => ( + <PasswordInput + onBlur={onBlur} + onChangeText={(value: any) => onChange(value)} + value={value} + autoComplete="current-password" + disabled={isSubmitting} + label={dictionary.passwordLabel} + /> + )} + name="password" + defaultValue="" + /> + + <Spacer size="xlarge" /> + <ForgotPassword onPress={(_: any) => setCurrentPage("ForgotPassword")} disabled={isSubmitting}>{dictionary.forgotPasswordButton}</ForgotPassword> + <SubmitButton onPress={handleSubmit(onSubmit)} disabled={isSubmitting}>{dictionary.signInSubmitButton}</SubmitButton> + <SecondaryButton onPress={(_: any) => setCurrentPage("SignUp")} disabled={isSubmitting}>{dictionary.noAccountButton}</SecondaryButton> + </Form> + ) +} diff --git a/src/ui/NativeAuth/pages/SignUp.tsx b/src/ui/NativeAuth/pages/SignUp.tsx new file mode 100644 index 0000000..63cdfe1 --- /dev/null +++ b/src/ui/NativeAuth/pages/SignUp.tsx @@ -0,0 +1,186 @@ +import React, { Fragment } from 'react'; +import Form from '../components/Form'; +import EmailInput from '../components/EmailInput'; +import PasswordInput from '../components/PasswordInput'; +import HeaderText from '../components/HeaderText'; +import SecondaryButton from '../components/SecondaryButton'; +import SubmitButton from '../components/SubmitButton'; +import Spacer from '../components/Spacer'; +import ErrorText from '../components/ErrorText'; +import GenderSelect from '../components/GenderSelect'; +import Input from '../components/internal/Input'; +import { useForm } from 'react-hook-form'; +import { IPage, ISignUpFields } from '../../uiTypes'; +import toast from 'react-hot-toast'; +import useEasybase from '../../../useEasybase'; + +interface ISignUpPage extends IPage { + signUpFields: ISignUpFields +} + +export default function ({ setCurrentPage, dictionary, signUpFields }: ISignUpPage) { + const { register, handleSubmit, formState: { errors, isSubmitting }, reset } = useForm(); + const { signUp, signIn } = useEasybase(); + + const onSubmit = async (formData: Record<string, string>) => { + if (!formData.email || !formData.password || !formData.passwordConfirm) { + return; + } + if (formData.password !== formData.passwordConfirm) { + toast.error(dictionary.errorPasswordsDoNotMatch!); + reset(); + return; + } + + const signUpAttrs = { createdAt: new Date().toISOString() }; + for (const currField of ["firstName", "lastName", "fullName", "dateOfBirth", "gender", "phoneNumber"]) { + if (signUpFields[currField]) { + if (formData[currField]) { + signUpAttrs[currField] = "" + formData[currField]; + } else { + toast.error("Missing sign up field value"); + return; + } + } + } + + const signUpRes = await signUp(formData.email, formData.password, signUpAttrs); + if (signUpRes.success) { + setCurrentPage("SignIn") + await signIn(formData.email, formData.password) + } else { + if (signUpRes.errorCode === "BadFormat") { + reset(); + toast.error(dictionary.errorBadInputFormat!); + } else if (signUpRes.errorCode === "BadPasswordLength") { + toast.error(dictionary.errorPasswordTooShort!); + } else if (signUpRes.errorCode === "UserExists") { + reset(); + toast.error(dictionary.errorUserAlreadyExists!); + } + } + } + + const passwordReqs = { + minLength: { + value: 8, + message: "Password must be at least 8 characters long" + }, + maxLength: { + value: 100, + message: "Password too long" + }, + pattern: { + value: /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).{7,}$/gm, + message: "Must contain a digit and uppercase and lowercase letters" + } + } + + return ( + <Form onSubmit={handleSubmit(onSubmit)}> + <HeaderText>{dictionary.signUpHeader}</HeaderText> + <Spacer size="medium" /> + + <EmailInput + register={() => register("email")} + label={dictionary.newEmailLabel} + disabled={isSubmitting} + /> + + { signUpFields.firstName && + <Fragment> + <Spacer size="xlarge" /> + <Input + register={() => register("firstName", typeof signUpFields.firstName === "boolean" ? {} : signUpFields.firstName)} + label={dictionary.newFirstNameLabel || ""} + disabled={isSubmitting} + /> + <ErrorText value={errors.firstName?.message} /> + </Fragment> + } + + { signUpFields.lastName && + <Fragment> + <Spacer size="xlarge" /> + <Input + register={() => register("lastName", typeof signUpFields.lastName === "boolean" ? {} : signUpFields.lastName)} + label={dictionary.newLastNameLabel || ""} + disabled={isSubmitting} + /> + <ErrorText value={errors.lastName?.message} /> + </Fragment> + } + + { signUpFields.fullName && + <Fragment> + <Spacer size="xlarge" /> + <Input + register={() => register("fullName", typeof signUpFields.fullName === "boolean" ? {} : signUpFields.fullName)} + label={dictionary.newFullNameLabel || ""} + disabled={isSubmitting} + /> + <ErrorText value={errors.fullName?.message} /> + </Fragment> + } + + { signUpFields.dateOfBirth && + <Fragment> + <Spacer size="xlarge" /> + <Input + type="date" + register={() => register("dateOfBirth", typeof signUpFields.dateOfBirth === "boolean" ? {} : signUpFields.dateOfBirth)} + label={dictionary.newDateOfBirthLabel || ""} + disabled={isSubmitting} + style={{ overflow: "hidden" }} + /> + <ErrorText value={errors.dateOfBirth?.message} /> + </Fragment> + } + + { signUpFields.gender && + <Fragment> + <Spacer size="xlarge" /> + <GenderSelect + register={() => register("gender", typeof signUpFields.gender === "boolean" ? {} : signUpFields.gender)} + disabled={isSubmitting} + /> + <ErrorText value={errors.gender?.message} /> + </Fragment> + } + + { signUpFields.phoneNumber && + <Fragment> + <Spacer size="xlarge" /> + <Input + type="tel" + label={dictionary.newPhoneNumberLabel || ""} + register={() => register("phoneNumber", typeof signUpFields.phoneNumber === "boolean" ? {} : signUpFields.phoneNumber)} + disabled={isSubmitting} + /> + <ErrorText value={errors.phoneNumber?.message} /> + </Fragment> + } + + <Spacer size="xlarge" /> + <PasswordInput + register={() => register("password", passwordReqs)} + label={dictionary.newPasswordLabel} + autoComplete="new-password" + disabled={isSubmitting} + /> + <ErrorText value={errors.password?.message} /> + <Spacer size="xlarge" /> + <PasswordInput + register={() => register("passwordConfirm", passwordReqs)} + label={dictionary.confirmNewPasswordLabel} + autoComplete="new-password" + disabled={isSubmitting} + /> + <ErrorText value={errors.passwordConfirm?.message} /> + + <Spacer size="xlarge" /> + <SubmitButton disabled={isSubmitting}>{dictionary.signUpSubmitButton}</SubmitButton> + <SecondaryButton onClick={(_: any) => setCurrentPage("SignIn")} disabled={isSubmitting}>{dictionary.backToSignIn}</SecondaryButton> + </Form> + ) +} diff --git a/src/ui/ReactNative.tsx b/src/ui/ReactNative.tsx index fc37838..58a8469 100644 --- a/src/ui/ReactNative.tsx +++ b/src/ui/ReactNative.tsx @@ -1,16 +1,18 @@ -import React from 'react'; +import React, { Suspense, Fragment, lazy } from 'react'; import { IAuth } from './uiTypes'; -import styled from 'styled-components/native'; -const Button = styled.Button({ - color: "red", - fontSize: 20 -}) +const NativeAuthComp = lazy(() => import('./NativeAuth/NativeAuth')); -export function NativeAuth (props: IAuth): JSX.Element { +export function NativeAuth(props: IAuth): JSX.Element { return ( - <Button title="Cloc" onPress={console.log} /> + <Suspense fallback={<Fragment />}> + <NativeAuthComp {...props} /> + </Suspense> ) } export default NativeAuth; + +/** + * Note that this wrapper component exists to force code-splitting + */