Merge pull request #17 from jedmund/auth-cleanup

Cleanup sign up and login modals
This commit is contained in:
Justin Edmund 2022-02-28 16:42:14 -08:00 committed by GitHub
commit 210b30ac2b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 340 additions and 264 deletions

View file

@ -132,5 +132,7 @@
.text { .text {
color: inherit; color: inherit;
display: block;
width: 100%;
} }
} }

View file

@ -19,10 +19,6 @@ interface Props {
} }
const HeaderMenu = (props: Props) => { const HeaderMenu = (props: Props) => {
const { open: signupOpen, openModal: openSignupModal, closeModal: closeSignupModal } = useSignupModal()
const { open: loginOpen, openModal: openLoginModal, closeModal: closeLoginModal } = useLoginModal()
const { open: aboutOpen, openModal: openAboutModal, closeModal: closeAboutModal } = useAboutModal()
function authItems() { function authItems() {
return ( return (
<nav> <nav>
@ -74,25 +70,9 @@ const HeaderMenu = (props: Props) => {
</li> </li>
</div> </div>
<div className="MenuGroup"> <div className="MenuGroup">
<li className="MenuItem" onClick={openLoginModal}> <LoginModal />
<span>Log in</span> <SignupModal />
</li>
{loginOpen ? (
<LoginModal
close={closeLoginModal}
/>
) : null}
<li className="MenuItem" onClick={openSignupModal}>
<span>Sign up</span>
</li>
{signupOpen ? (
<SignupModal
close={closeSignupModal}
/>
) : null}
</div> </div>
</ul> </ul>
) )
} }

View file

@ -1,14 +1,31 @@
.LoginForm { .Login.Dialog form {
#fields { display: flex;
display: flex; flex-direction: column;
flex-direction: column; gap: $unit / 2;
gap: $unit; margin-bottom: $unit;
.Button {
font-size: $font-regular;
padding: ($unit * 1.5) ($unit * 2);
width: 100%;
&.btn-disabled {
background: $grey-90;
color: $grey-70;
cursor: not-allowed;
}
&:not(.btn-disabled) {
background: $grey-90;
color: $grey-40;
&:hover {
background: $grey-80;
}
}
} }
.fieldset {
display: flex; input {
flex-direction: column; background: $grey-90;
gap: 4px;
margin-bottom: 8px;
} }
} }

View file

@ -1,21 +1,18 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { withCookies, Cookies } from 'react-cookie' import { useCookies } from 'react-cookie'
import { createPortal } from 'react-dom'
import * as Dialog from '@radix-ui/react-dialog'
import api from '~utils/api' import api from '~utils/api'
import { accountState } from '~utils/accountState' import { accountState } from '~utils/accountState'
import Button from '~components/Button' import Button from '~components/Button'
import Fieldset from '~components/Fieldset' import Fieldset from '~components/Fieldset'
import Modal from '~components/Modal'
import Overlay from '~components/Overlay'
import CrossIcon from '~public/icons/Cross.svg'
import './index.scss' import './index.scss'
interface Props { interface Props {}
cookies: Cookies
close: () => void
}
interface ErrorMap { interface ErrorMap {
[index: string]: string [index: string]: string
@ -26,31 +23,40 @@ interface ErrorMap {
const emailRegex = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ const emailRegex = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
const LoginModal = (props: Props) => { const LoginModal = (props: Props) => {
const emailInput: React.RefObject<HTMLInputElement> = React.createRef() // Set up form states and error handling
const passwordInput: React.RefObject<HTMLInputElement> = React.createRef()
const form: React.RefObject<HTMLInputElement>[] = [emailInput, passwordInput]
const [formValid, setFormValid] = useState(false) const [formValid, setFormValid] = useState(false)
const [errors, setErrors] = useState<ErrorMap>({ const [errors, setErrors] = useState<ErrorMap>({
email: '', email: '',
password: '' password: ''
}) })
function handleChange(event: React.ChangeEvent<HTMLInputElement>) { // Cookies
event.preventDefault() const [cookies, setCookies] = useCookies()
// States
const [open, setOpen] = useState(false)
// Set up form refs
const emailInput: React.RefObject<HTMLInputElement> = React.createRef()
const passwordInput: React.RefObject<HTMLInputElement> = React.createRef()
const form: React.RefObject<HTMLInputElement>[] = [emailInput, passwordInput]
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
const { name, value } = event.target const { name, value } = event.target
let newErrors = errors let newErrors = {...errors}
switch(name) { switch(name) {
case 'email': case 'email':
errors.email = emailRegex.test(value) if (value.length == 0)
? '' newErrors.email = 'Please enter your email'
: 'That email address is not valid' else if (!emailRegex.test(value))
newErrors.email = 'That email address is not valid'
else
newErrors.email = ''
break break
case 'password': case 'password':
errors.password = value.length == 0 newErrors.password = value.length == 0
? 'Please enter your password' ? 'Please enter your password'
: '' : ''
break break
@ -60,10 +66,10 @@ const LoginModal = (props: Props) => {
} }
setErrors(newErrors) setErrors(newErrors)
setFormValid(validateForm()) setFormValid(validateForm(newErrors))
} }
function validateForm() { function validateForm(errors: ErrorMap) {
let valid = true let valid = true
Object.values(form).forEach( Object.values(form).forEach(
@ -77,7 +83,7 @@ const LoginModal = (props: Props) => {
return valid return valid
} }
function submit(event: React.FormEvent) { function login(event: React.FormEvent) {
event.preventDefault() event.preventDefault()
const body = { const body = {
@ -89,65 +95,76 @@ const LoginModal = (props: Props) => {
if (formValid) { if (formValid) {
api.login(body) api.login(body)
.then((response) => { .then((response) => {
const cookies = props.cookies
const cookieObj = { const cookieObj = {
user_id: response.data.user.id, user_id: response.data.user.id,
username: response.data.user.username, username: response.data.user.username,
access_token: response.data.access_token access_token: response.data.access_token
} }
cookies.set('user', cookieObj, { path: '/'}) setCookies('user', cookieObj, { path: '/'})
accountState.account.authorized = true accountState.account.authorized = true
accountState.account.user = { accountState.account.user = {
id: cookieObj.user_id, id: cookieObj.user_id,
username: cookieObj.username username: cookieObj.username
} }
props.close() setOpen(false)
}, (error) => { }, (error) => {
console.error(error) console.error(error)
}) })
} }
} }
return ( function openChange(open: boolean) {
createPortal( setOpen(open)
<div> setErrors({
<Modal email: '',
title="Log in" password: ''
styleName="LoginForm" })
close={ () => {} } }
>
<form className="form" onSubmit={submit}>
<div id="fields">
<Fieldset
fieldName="email"
placeholder="Email address"
onChange={handleChange}
error={errors.email}
ref={emailInput}
/>
<Fieldset return (
fieldName="password" <Dialog.Root open={open} onOpenChange={openChange}>
placeholder="Password" <Dialog.Trigger asChild>
onChange={handleChange} <li className="MenuItem">
error={errors.password} <span>Log in</span>
ref={passwordInput} </li>
/> </Dialog.Trigger>
</div> <Dialog.Portal>
<div id="ModalBottom"> <Dialog.Content className="Login Dialog" onOpenAutoFocus={ (event) => event.preventDefault() }>
<a>Forgot your password?</a> <div className="DialogHeader">
<Button disabled={!formValid}>Log in</Button> <Dialog.Title className="DialogTitle">Log in</Dialog.Title>
</div> <Dialog.Close className="DialogClose" asChild>
<span>
<CrossIcon />
</span>
</Dialog.Close>
</div>
<form className="form" onSubmit={login}>
<Fieldset
fieldName="email"
placeholder="Email address"
onChange={handleChange}
error={errors.email}
ref={emailInput}
/>
<Fieldset
fieldName="password"
placeholder="Password"
onChange={handleChange}
error={errors.password}
ref={passwordInput}
/>
<Button disabled={!formValid}>Log in</Button>
</form> </form>
</Modal> </Dialog.Content>
<Overlay onClick={props.close} /> <Dialog.Overlay className="Overlay" />
</div>, </Dialog.Portal>
document.body </Dialog.Root>
)
) )
} }
export default withCookies(LoginModal) export default LoginModal

View file

@ -1,67 +1,47 @@
.SignupForm { .Signup.Dialog form {
padding: 16px;
}
.SignupForm form {
margin: 0;
}
.SignupForm #fields {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: $unit / 2;
margin-bottom: 8px; margin-bottom: $unit;
}
#ModalTop { .Button {
display: flex; font-size: $font-regular;
flex-direction: row; padding: ($unit * 1.5) ($unit * 2);
align-items: center; width: 100%;
margin-bottom: 16px;
margin-right: -8px;
}
#ModalTop h1 { &.btn-disabled {
margin: 0; background: $grey-90;
font-size: $font-xlarge; color: $grey-70;
text-align: left; cursor: not-allowed;
flex-grow: 1; }
}
#ModalTop i { &:not(.btn-disabled) {
padding: 8px; background: $grey-90;
} color: $grey-40;
#ModalTop i:hover { &:hover {
cursor: pointer; background: $grey-80;
} }
}
}
#ModalTop i:hover svg { .terms {
color: #444; color: $grey-40;
} font-size: $font-small;
line-height: 1.2;
margin-top: $unit;
text-align: center;
#ModalTop svg { a {
color: #888; color: $blue;
height: 18px;
width: 18px;
transform: rotate(45deg);
}
#ModalBottom { &:hover {
display: flex; color: darken($blue, 30);
flex-direction: row; }
justify-content: flex-end; }
} }
#ModalBottom a { input {
color: #666; background: $grey-90;
font-size: $font-regular; }
font-weight: 500;
flex-grow: 1;
display: flex;
align-items: center;
}
#ModalBottom .Button {
min-width: 88px;
} }

View file

@ -1,21 +1,18 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { withCookies, Cookies } from 'react-cookie' import { useCookies } from 'react-cookie'
import { createPortal } from 'react-dom'
import * as Dialog from '@radix-ui/react-dialog'
import api from '~utils/api' import api from '~utils/api'
import { accountState } from '~utils/accountState' import { accountState } from '~utils/accountState'
import Button from '~components/Button' import Button from '~components/Button'
import Fieldset from '~components/Fieldset' import Fieldset from '~components/Fieldset'
import Modal from '~components/Modal'
import Overlay from '~components/Overlay'
import CrossIcon from '~public/icons/Cross.svg'
import './index.scss' import './index.scss'
interface Props { interface Props {}
cookies: Cookies
close: () => void
}
interface ErrorMap { interface ErrorMap {
[index: string]: string [index: string]: string
@ -28,6 +25,7 @@ interface ErrorMap {
const emailRegex = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ const emailRegex = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
const SignupModal = (props: Props) => { const SignupModal = (props: Props) => {
// Set up form states and error handling
const [formValid, setFormValid] = useState(false) const [formValid, setFormValid] = useState(false)
const [errors, setErrors] = useState<ErrorMap>({ const [errors, setErrors] = useState<ErrorMap>({
username: '', username: '',
@ -36,33 +34,20 @@ const SignupModal = (props: Props) => {
passwordConfirmation: '' passwordConfirmation: ''
}) })
// Cookies
const [cookies, setCookies] = useCookies()
// States
const [open, setOpen] = useState(false)
// Set up form refs
const usernameInput = React.createRef<HTMLInputElement>() const usernameInput = React.createRef<HTMLInputElement>()
const emailInput = React.createRef<HTMLInputElement>() const emailInput = React.createRef<HTMLInputElement>()
const passwordInput = React.createRef<HTMLInputElement>() const passwordInput = React.createRef<HTMLInputElement>()
const passwordConfirmationInput = React.createRef<HTMLInputElement>() const passwordConfirmationInput = React.createRef<HTMLInputElement>()
const form = [usernameInput, emailInput, passwordInput, passwordConfirmationInput] const form = [usernameInput, emailInput, passwordInput, passwordConfirmationInput]
function check(event: React.ChangeEvent<HTMLInputElement>) { function register(event: React.FormEvent) {
const name = event.target.name
const value = event.target.value
if (value.length > 0 && errors[name].length == 0) {
const newErrors = {...errors}
api.check(name, value)
.then((response) => {
if (!response.data.available) {
newErrors[name] = `This ${name} is already in use`
}
setErrors(newErrors)
}, (error) => {
console.error(error)
})
}
}
function process(event: React.FormEvent) {
event.preventDefault() event.preventDefault()
const body = { const body = {
@ -74,50 +59,76 @@ const SignupModal = (props: Props) => {
} }
} }
if (formValid) { if (formValid)
api.endpoints.users.create(body) api.endpoints.users.create(body)
.then((response) => { .then((response) => {
const cookies = props.cookies // Set cookies
cookies.set('user', response.data.user, { path: '/'}) setCookies('user', response.data.user, { path: '/'})
// Set states
accountState.account.authorized = true accountState.account.authorized = true
accountState.account.user = { accountState.account.user = {
id: response.data.user.id, id: response.data.user.id,
username: response.data.user.username username: response.data.user.username
} }
props.close() // Close the modal
setOpen(false)
}, (error) => { }, (error) => {
console.error(error) console.error(error)
}) })
.catch(error => {
console.error(error)
})
}
function handleNameChange(event: React.ChangeEvent<HTMLInputElement>) {
event.preventDefault()
const fieldName = event.target.name
const value = event.target.value
if (value.length >= 3) {
api.check(fieldName, value)
.then((response) => {
processNameCheck(fieldName, value, response.data.available)
}, (error) => {
console.error(error)
})
} else {
validateName(fieldName, value)
} }
} }
function validateForm() { function processNameCheck(fieldName: string, value: string, available: boolean) {
let valid = true const newErrors = {...errors}
Object.values(form).forEach( if (available) {
(input) => input.current?.value.length == 0 && (valid = false) // Continue checking for errors
) newErrors[fieldName] = ''
setErrors(newErrors)
setFormValid(true)
Object.values(errors).forEach( validateName(fieldName, value)
(error) => error.length > 0 && (valid = false) } else {
) newErrors[fieldName] = `This ${fieldName} is already in use`
setErrors(newErrors)
return valid setFormValid(false)
}
} }
function handleChange(event: React.ChangeEvent<HTMLInputElement>) { function validateName(fieldName: string, value: string) {
event.preventDefault()
const { name, value } = event.target
let newErrors = {...errors} let newErrors = {...errors}
switch(name) { switch(fieldName) {
case 'username': case 'username':
newErrors.username = value.length < 3 if (value.length < 3)
? 'Username must be at least 3 characters' newErrors.username = 'Username must be at least 3 characters'
: '' else if (value.length > 20)
newErrors.username = 'Username must be less than 20 characters'
else
newErrors.username = ''
break break
case 'email': case 'email':
@ -126,6 +137,21 @@ const SignupModal = (props: Props) => {
: 'That email address is not valid' : 'That email address is not valid'
break break
default:
break
}
setErrors(newErrors)
setFormValid(validateForm(newErrors))
}
function handlePasswordChange(event: React.ChangeEvent<HTMLInputElement>) {
event.preventDefault()
const { name, value } = event.target
let newErrors = {...errors}
switch(name) {
case 'password': case 'password':
newErrors.password = passwordInput.current?.value.includes(usernameInput.current?.value!) newErrors.password = passwordInput.current?.value.includes(usernameInput.current?.value!)
? 'Your password should not contain your username' ? 'Your password should not contain your username'
@ -149,64 +175,96 @@ const SignupModal = (props: Props) => {
} }
setErrors(newErrors) setErrors(newErrors)
setFormValid(validateForm()) setFormValid(validateForm(newErrors))
}
function validateForm(errors: ErrorMap) {
let valid = true
Object.values(form).forEach(
(input) => input.current?.value.length == 0 && (valid = false)
)
Object.values(errors).forEach(
(error) => error.length > 0 && (valid = false)
)
return valid
}
function openChange(open: boolean) {
setOpen(open)
setErrors({
username: '',
email: '',
password: '',
passwordConfirmation: ''
})
} }
return ( return (
createPortal( <Dialog.Root open={open} onOpenChange={openChange}>
<div> <Dialog.Trigger asChild>
<Modal <li className="MenuItem">
title="Sign up" <span>Sign up</span>
styleName="SignupForm" </li>
close={ () => {} } </Dialog.Trigger>
> <Dialog.Portal>
<form className="form" onSubmit={process}> <Dialog.Content className="Signup Dialog" onOpenAutoFocus={ (event) => event.preventDefault() }>
<div id="fields"> <div className="DialogHeader">
<Fieldset <Dialog.Title className="DialogTitle">Sign up</Dialog.Title>
fieldName="username" <Dialog.Close className="DialogClose" asChild>
placeholder="Username" <span>
onBlur={check} <CrossIcon />
onChange={handleChange} </span>
error={errors.username} </Dialog.Close>
ref={usernameInput} </div>
/>
<Fieldset <form className="form" onSubmit={register}>
fieldName="email" <Fieldset
placeholder="Email address" fieldName="username"
onBlur={check} placeholder="Username"
onChange={handleChange} onChange={handleNameChange}
error={errors.email} error={errors.username}
ref={emailInput} ref={usernameInput}
/> />
<Fieldset <Fieldset
fieldName="password" fieldName="email"
placeholder="Password" placeholder="Email address"
onChange={handleChange} onChange={handleNameChange}
error={errors.password} error={errors.email}
ref={passwordInput} ref={emailInput}
/> />
<Fieldset <Fieldset
fieldName="confirm_password" fieldName="password"
placeholder="Password (again)" placeholder="Password"
onChange={handleChange} onChange={handlePasswordChange}
error={errors.passwordConfirmation} error={errors.password}
ref={passwordConfirmationInput} ref={passwordInput}
/> />
</div>
<div id="ModalBottom"> <Fieldset
<Button disabled={!formValid}>Sign up</Button> fieldName="confirm_password"
</div> placeholder="Password (again)"
onChange={handlePasswordChange}
error={errors.passwordConfirmation}
ref={passwordConfirmationInput}
/>
<Button disabled={!formValid}>Sign up</Button>
<Dialog.Description className="terms">
By signing up, I agree to the<br /><a href="#">Terms and Conditions</a> and <a href="#">Usage Guidelines</a>.
</Dialog.Description>
</form> </form>
</Modal> </Dialog.Content>
<Overlay onClick={props.close} /> <Dialog.Overlay className="Overlay" />
</div>, </Dialog.Portal>
document.body </Dialog.Root>
)
) )
} }
export default withCookies(SignupModal) export default SignupModal

View file

@ -5,7 +5,7 @@ import clonedeep from 'lodash.clonedeep'
import { useSnapshot } from 'valtio' import { useSnapshot } from 'valtio'
import api from '~utils/api' import api from '~utils/api'
import { accountState } from '~utils/accountState' import { accountState, initialAccountState } from '~utils/accountState'
import { appState, initialAppState } from '~utils/appState' import { appState, initialAppState } from '~utils/appState'
import Header from '~components/Header' import Header from '~components/Header'
@ -51,10 +51,15 @@ const TopHeader = () => {
function logout() { function logout() {
removeCookie('user') removeCookie('user')
accountState.authorized = false // Clean state
const resetState = clonedeep(initialAccountState)
Object.keys(resetState).forEach((key) => {
if (key !== 'language')
accountState[key] = resetState[key]
})
appState.party.editable = false appState.party.editable = false
// TODO: How can we log out without navigating to root
router.push('/') router.push('/')
return false return false
} }

View file

@ -90,16 +90,18 @@ select {
} }
.Dialog { .Dialog {
$multiplier: 4;
animation: 0.5s cubic-bezier(0.16, 1, 0.3, 1) 0s 1 normal none running openModal; animation: 0.5s cubic-bezier(0.16, 1, 0.3, 1) 0s 1 normal none running openModal;
background: white; background: white;
border-radius: $unit; border-radius: $unit;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: $unit * 3; gap: $unit * $multiplier;
height: auto; height: auto;
min-width: $unit * 48; min-width: $unit * 48;
min-height: $unit * 12; min-height: $unit * 12;
padding: $unit * 3; padding: $unit * $multiplier;
position: absolute; position: absolute;
top: 50%; top: 50%;
left: 50%; left: 50%;
@ -108,28 +110,43 @@ select {
.DialogHeader { .DialogHeader {
display: flex; display: flex;
gap: $unit;
.left {
display: flex;
flex-direction: column;
flex-grow: 1;
gap: $unit;
p {
color: $grey-40;
font-size: $font-small;
line-height: 1.25;
}
}
} }
.DialogClose { .DialogClose {
background: transparent; background: transparent;
height: 21px;
width: 21px;
&:hover { &:hover {
cursor: pointer; cursor: pointer;
svg { svg {
fill: $grey-00; fill: $error;
} }
} }
svg { svg {
fill: $grey-40; fill: $grey-40;
float: right;
height: 24px;
width: 24px;
} }
} }
.DialogTitle { .DialogTitle {
font-size: $font-large; font-size: $font-xlarge;
flex-grow: 1; flex-grow: 1;
} }