Skip to content

Commit

Permalink
Feat: Session Authentication (#165)
Browse files Browse the repository at this point in the history
  • Loading branch information
rickimoore committed Apr 11, 2023
1 parent 47db8e4 commit 26bc797
Show file tree
Hide file tree
Showing 25 changed files with 503 additions and 169 deletions.
16 changes: 9 additions & 7 deletions custom.d.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
declare module '*.svg' {
import React = require('react');
export const ReactComponent: React.FC<React.SVGProps<SVGSVGElement>>;
const src: string;
export default src;
import React = require('react')
export const ReactComponent: React.FC<React.SVGProps<SVGSVGElement>>
const src: string
export default src
}

declare module '*.png';
declare module '*.png'

declare module 'rodal';
declare module 'rodal'

declare module 'svg-identicon'
declare module 'svg-identicon'

declare module 'crypto-js'
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@
"axios": "^0.27.2",
"bootstrap-icons": "^1.9.1",
"chart.js": "^4.2.0",
"dotenv": "^16.0.3",
"concurrently": "5.2.0",
"crypto-js": "^4.1.1",
"dotenv": "^16.0.3",
"electron-is-dev": "^2.0.0",
"electron-squirrel-startup": "^1.0.0",
"eslint-import-resolver-typescript": "^3.5.1",
Expand Down
2 changes: 1 addition & 1 deletion src/components/Button/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ const Button: FC<ButtonProps> = ({
case ButtonFace.TERTIARY:
return 'border border-primary text-primary'
case ButtonFace.SECONDARY:
return 'bg-primary text-white'
return 'bg-primary text-white disabled:cursor-default disabled:opacity-30'
case ButtonFace.WHITE:
return 'bg-transparent disabled:border-dark300 disabled:text-dark300 disabled:cursor-default border border-white text-white'
default:
Expand Down
7 changes: 0 additions & 7 deletions src/components/CurrencySelect/CurrencySelect.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,6 @@ import CurrencySelect from './CurrencySelect'
import { mockedRecoilState, mockedRecoilValue } from '../../../test.helpers'
import { mockCurrencies } from '../../mocks/currencyResults'

jest.mock('recoil', () => ({
useRecoilValue: jest.fn(),
useRecoilState: jest.fn(() => ['mock-value', jest.fn()]),
atom: jest.fn(),
selector: jest.fn(),
}))

const mockSetState = jest.fn()

describe('Currency select component', () => {
Expand Down
41 changes: 36 additions & 5 deletions src/components/Input/Input.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { FC, HTMLInputTypeAttribute, InputHTMLAttributes, useState } from 'react'
import {
FC,
HTMLInputTypeAttribute,
InputHTMLAttributes,
useState,
ClipboardEvent,
ChangeEvent,
} from 'react'
import Typography from '../Typography/Typography'
import { UiMode } from '../../constants/enums'
import Tooltip from '../ToolTip/Tooltip'
Expand All @@ -12,6 +19,8 @@ export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
toolTipMaxWidth?: number
className?: string
uiMode?: UiMode
isDisableToggle?: boolean
isDisablePaste?: boolean
inputStyle?: 'primary' | 'secondary'
icon?: string
}
Expand All @@ -27,11 +36,25 @@ const Input: FC<InputProps> = ({
toolTipId,
toolTipMode,
toolTipMaxWidth = 250,
isDisableToggle,
isDisablePaste,
icon,
onChange,
...props
}) => {
const [inputType, setType] = useState<HTMLInputTypeAttribute>(type || 'text')
const isPasswordType = type === 'password'
const sanitizeInput = (e: ChangeEvent<HTMLInputElement>) => {
return {
...e,
target: {
...e.target,
value: e.target.value
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/onerror\s*=\s*["'][^"']*["']/gi, ''),
},
}
}

const generateInputStyle = () => {
switch (inputStyle) {
Expand All @@ -40,9 +63,15 @@ const Input: FC<InputProps> = ({
default:
return `${
uiMode === UiMode.LIGHT
? 'text-dark500 bg-dark10 border-dark500'
: 'bg-transparent border-white placeholder:text-dark500'
} font-light text-white border-b text-body md:text-subtitle1`
? 'text-dark500 bg-dark10 border-dark500 px-2'
: 'bg-transparent text-white border-white placeholder:text-dark500'
} font-light border-b text-body md:text-subtitle1`
}
}

const handlePaste = (e: ClipboardEvent<HTMLInputElement>) => {
if (isDisablePaste) {
e.preventDefault()
}
}

Expand Down Expand Up @@ -75,12 +104,14 @@ const Input: FC<InputProps> = ({
<div className='relative w-full'>
<input
{...props}
onChange={(value) => onChange?.(sanitizeInput(value))}
onPaste={handlePaste}
type={inputType}
className={`${isPasswordType || icon ? 'pr-5' : ''} ${
className ? className : ''
} pb-2 w-full font-openSauce outline-none ${generateInputStyle()}`}
/>
{isPasswordType ? (
{isPasswordType && !isDisableToggle ? (
<i
onClick={togglePassword}
className={`cursor-pointer text-caption1 md:text-body ${
Expand Down
33 changes: 29 additions & 4 deletions src/components/ProtocolInput/ProtocolInput.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,40 @@
import ProtocolInput, { ProtocolInputProps } from './ProtocolInput'
import { Story } from '@storybook/react'
import ConfigConnectionForm from '../../forms/ConfigConnectionForm'
import { Control, useForm, UseFormGetValues } from 'react-hook-form'
import React, { FC } from 'react'
import { UseFormSetValue } from 'react-hook-form/dist/types/form'

export interface RenderProps {
control: Control<any>
setValue: UseFormSetValue<any>
getValues: UseFormGetValues<any>
}

export default {
key: 'Protocol Input',
component: ProtocolInput,
}

const MockConnectionForm: FC<{ children: (props: RenderProps) => React.ReactElement }> = ({
children,
}) => {
const { control, setValue, getValues } = useForm()

return (
<form>
{children &&
children({
control,
setValue,
getValues,
})}
</form>
)
}

const Template: Story<ProtocolInputProps> = ({ type, id, isValid }) => (
<ConfigConnectionForm>
{({ control, setValue, getValues }) => (
<MockConnectionForm>
{({ control, getValues, setValue }) => (
<div className='h-screen w-screen bg-black flex flex-col items-center justify-center'>
<div className='w-full max-w-xl'>
<ProtocolInput
Expand All @@ -23,7 +48,7 @@ const Template: Story<ProtocolInputProps> = ({ type, id, isValid }) => (
</div>
</div>
)}
</ConfigConnectionForm>
</MockConnectionForm>
)

export const Basic = Template.bind({})
Expand Down
6 changes: 5 additions & 1 deletion src/components/SelectDropDown/SelectDropDown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import useClickOutside from '../../hooks/useClickOutside'
import Typography, { TypographyColor } from '../Typography/Typography'
import DropDown from '../DropDown/DropDown'
import { useTranslation } from 'react-i18next'
import addClassString from '../../utilities/addClassString'

export type OptionType = string | number

Expand All @@ -13,6 +14,7 @@ export type SelectOption = {

export interface SelectDropDownProps {
options: SelectOption[]
className?: string
label?: string
placeholder?: string
value?: OptionType
Expand All @@ -26,13 +28,15 @@ const SelectDropDown: FC<SelectDropDownProps> = ({
onSelect,
value,
color,
className,
label,
isFilter,
placeholder,
}) => {
const { t } = useTranslation()
const [query, setQuery] = useState('')
const [isOpen, toggle] = useState(false)
const classes = addClassString('w-36', [className])

const toggleDropdown = () => toggle((prevState) => !prevState)
const makeSelection = (selection: OptionType) => {
Expand Down Expand Up @@ -60,7 +64,7 @@ const SelectDropDown: FC<SelectDropDownProps> = ({
}, [query, options])

return (
<div className='w-36'>
<div className={classes}>
{label && (
<Typography type='text-caption1' color={color} className='xl:text-body'>
{label}
Expand Down
158 changes: 158 additions & 0 deletions src/components/SessionAuthModal/SessionAuthModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import RodalModal from '../RodalModal/RodalModal'
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'
import { apiToken, appView, onBoardView, sessionAuthErrorCount } from '../../recoil/atoms'
import Typography from '../Typography/Typography'
import useLocalStorage from '../../hooks/useLocalStorage'
import { ChangeEvent, FC, ReactElement, useEffect, useState } from 'react'
import addClassString from '../../utilities/addClassString'
import { AppView, OnboardView, UiMode } from '../../constants/enums'
import CryptoJS from 'crypto-js'
import Button, { ButtonFace } from '../Button/Button'
import Input from '../Input/Input'
import { MAX_SESSION_UNLOCK_ATTEMPTS } from '../../constants/constants'
import { useTranslation } from 'react-i18next'

export interface SessionAuthModalProps {
onSuccess: (token?: string) => void
onFail?: () => void
isOpen: boolean
onClose?: () => void
children?: ReactElement | ReactElement[]
mode?: UiMode
}

const SessionAuthModal: FC<SessionAuthModalProps> = ({
onSuccess,
children,
isOpen,
onFail,
onClose,
mode,
}) => {
const { t } = useTranslation()
const [localStorageApiToken, storeApiToken] = useLocalStorage<string>('api-token', '')
const memoryApiToken = useRecoilValue(apiToken)
const [password, setPassword] = useState('')
const [errorCount, setCount] = useRecoilState(sessionAuthErrorCount)
const [isError, setIsError] = useState(false)
const setView = useSetRecoilState(onBoardView)
const setAppView = useSetRecoilState(appView)

useEffect(() => {
if (errorCount >= MAX_SESSION_UNLOCK_ATTEMPTS) {
onFail?.()
}
}, [errorCount])

const classes = addClassString('', [isError && 'animate-shake'])

const setInput = (event: ChangeEvent<HTMLInputElement>) => setPassword(event.target.value)

const viewConfig = () => {
storeApiToken('')
setView(OnboardView.CONFIGURE)
setAppView(AppView.ONBOARD)
}

const playErrorAnim = () => {
setIsError(true)

setTimeout(() => {
setIsError(false)
}, 1000)
}

const handleError = () => {
playErrorAnim()
setCount((prevState) => prevState + 1)
}

const confirmApiToken = () => {
if (password !== memoryApiToken) {
handleError()
return
}

setCount(0)
onSuccess(memoryApiToken)
}

const confirmPassword = () => {
const pattern = /^api-token-0x\w*$/
try {
const token = CryptoJS.AES.decrypt(localStorageApiToken, password).toString(CryptoJS.enc.Utf8)
if (!token.length || !pattern.test(token)) {
handleError()
return
}
setCount(0)
onSuccess(token)
} catch (e) {
handleError()
}
}

const authenticateAction = () => {
if (localStorageApiToken) {
confirmPassword()
return
}

confirmApiToken()
}

const renderNoPasswordRedirect = () => (
<>
<Typography type='text-caption1'>{t('sessionAuthModal.failedResponse')}</Typography>
<div className='w-full flex justify-center p-4'>
<Button className={classes} onClick={viewConfig} type={ButtonFace.SECONDARY}>
<i className='bi bi-box-arrow-right text-white mr-2' />
{t('sessionAuthModal.configSettings')}
</Button>
</div>
</>
)
const renderPasswordInput = () => (
<>
<Typography type='text-caption1'>{t('sessionAuthModal.passwordPrompt')}</Typography>
<Input uiMode={mode} type='password' label='Password' value={password} onChange={setInput} />
<div className='w-full flex justify-center p-4'>
<Button
isDisabled={!password}
className={classes}
onClick={authenticateAction}
type={ButtonFace.SECONDARY}
>
{t('confirmPassword')}
</Button>
</div>
</>
)

return (
<>
<RodalModal onClose={onClose} isVisible={isOpen}>
<div className='p-4'>
<div className='border-b-style500 pb-4 mb-4'>
<Typography
type='text-subtitle2'
fontWeight='font-light'
color='text-transparent'
className='primary-gradient-text'
>
{t('sessionAuthModal.title')}
</Typography>
</div>
<div className='w-full space-y-4'>
{errorCount < MAX_SESSION_UNLOCK_ATTEMPTS
? renderPasswordInput()
: renderNoPasswordRedirect()}
</div>
</div>
</RodalModal>
{children}
</>
)
}

export default SessionAuthModal
Loading

0 comments on commit 26bc797

Please sign in to comment.