Dwayne Harris 5 years ago
parent
commit
100f73f4c8
  1. 105
      package-lock.json
  2. 3
      package.json
  3. 59
      src/actions/authentication.ts
  4. 15
      src/actions/directory.ts
  5. 35
      src/actions/registration.ts
  6. 10
      src/api/errors.ts
  7. 4
      src/api/fetch.ts
  8. 2
      src/components/app/app.tsx
  9. 2
      src/components/create-group-step/create-group-step.tsx
  10. 10
      src/components/create-group-step/index.ts
  11. 22
      src/components/create-user-step/index.ts
  12. 4
      src/components/forms/password-field/password-field.tsx
  13. 22
      src/components/notification-container/notification-container.tsx
  14. 2
      src/components/page-header/index.tsx
  15. 46
      src/components/pages/login/index.ts
  16. 44
      src/components/pages/login/login.tsx
  17. 4
      src/components/pages/register-group/index.ts
  18. 4
      src/components/pages/register/index.ts
  19. 31
      src/components/pages/self/index.ts
  20. 37
      src/components/pages/self/self.tsx
  21. 16
      src/components/pages/test/index.ts
  22. 26
      src/components/pages/test/test.tsx
  23. 18
      src/components/user-info/index.ts
  24. 9
      src/constants/index.ts
  25. 8
      src/hooks/index.ts
  26. 6
      src/reducers/authentication.ts
  27. 15
      src/selectors/authentication.ts
  28. 6
      src/selectors/directory.ts
  29. 19
      src/selectors/entities.ts
  30. 3
      src/selectors/requests.ts
  31. 2
      src/store/schemas.ts
  32. 5
      src/types/entities.ts
  33. 20
      src/types/store.ts
  34. 7
      src/utils/index.ts
  35. 2
      webpack.config.ts

105
package-lock.json

@ -393,6 +393,15 @@
}
}
},
"@types/webpack-bundle-analyzer": {
"version": "2.13.3",
"resolved": "https://registry.npmjs.org/@types/webpack-bundle-analyzer/-/webpack-bundle-analyzer-2.13.3.tgz",
"integrity": "sha512-p8EXyKfq311FFFfRuAR9tOHFFTQ9DqGrjRQYXbjjEMfl9pKGaTtRy1zFJtPMyZHfRoqh5rsYPVSVknkl004M7A==",
"dev": true,
"requires": {
"@types/webpack": "*"
}
},
"@types/webpack-dev-server": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/webpack-dev-server/-/webpack-dev-server-3.1.7.tgz",
@ -641,6 +650,12 @@
"integrity": "sha512-/czfa8BwS88b9gWQVhc8eknunSA2DoJpJyTQkhheIf5E48u1N0R4q/YxxsAeqRrmK9TQ/uYfgLDfZo91UlANIA==",
"dev": true
},
"acorn-walk": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-6.2.0.tgz",
"integrity": "sha512-7evsyfH1cLOCdAzZAd43Cic04yKydNx0cF+7tiA19p1XnLLPU4dpCQOqpjqwokFe//vS0QqfqqjCS2JkiIs0cA==",
"dev": true
},
"ajv": {
"version": "6.10.2",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.2.tgz",
@ -1084,6 +1099,18 @@
"tweetnacl": "^0.14.3"
}
},
"bfj": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/bfj/-/bfj-6.1.2.tgz",
"integrity": "sha512-BmBJa4Lip6BPRINSZ0BPEIfB1wUY/9rwbwvIHQA1KjX9om29B6id0wnWXq7m3bn5JrUVjeOTnVuhPT1FiHwPGw==",
"dev": true,
"requires": {
"bluebird": "^3.5.5",
"check-types": "^8.0.3",
"hoopy": "^0.1.4",
"tryer": "^1.0.1"
}
},
"big.js": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
@ -1397,6 +1424,12 @@
"supports-color": "^5.3.0"
}
},
"check-types": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/check-types/-/check-types-8.0.3.tgz",
"integrity": "sha512-YpeKZngUmG65rLudJ4taU7VLkOCTMhNl/u4ctNC56LQS/zJTyNH0Lrtwm1tfTsbLlwvlfsA2d1c8vCf/Kh2KwQ==",
"dev": true
},
"chokidar": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-2.1.8.tgz",
@ -2188,6 +2221,12 @@
"domelementtype": "1"
}
},
"duplexer": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.1.tgz",
"integrity": "sha1-rOb/gIwc5mtX0ev5eXessCM0z8E=",
"dev": true
},
"duplexify": {
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz",
@ -2216,6 +2255,12 @@
"integrity": "sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=",
"dev": true
},
"ejs": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-2.7.1.tgz",
"integrity": "sha512-kS/gEPzZs3Y1rRsbGX4UOSjtP/CeJP0CxSNZHYxGfVM/VgLcv0ZqM7C45YyTj2DI2g7+P9Dd24C+IMIg6D0nYQ==",
"dev": true
},
"elliptic": {
"version": "6.5.1",
"resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.1.tgz",
@ -2622,6 +2667,12 @@
"integrity": "sha512-vNKxJHTEKNThjfrdJwHc7brvM6eVevuO5nTj6ez8ZQ1qbXTvGthucRF7S4vf2cr71QVnT70V34v0S1DyQsti0w==",
"dev": true
},
"filesize": {
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/filesize/-/filesize-3.6.1.tgz",
"integrity": "sha512-7KjR1vv6qnicaPMi1iiTcI85CyYwRO/PSFCu6SvqL8jN2Wjt/NIYQTFtFs7fSDCYOstUkEWIQGFUg5YZQfjlcg==",
"dev": true
},
"fill-range": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
@ -3668,6 +3719,16 @@
"resolved": "https://registry.npmjs.org/gud/-/gud-1.0.0.tgz",
"integrity": "sha512-zGEOVKFM5sVPPrYs7J5/hYEw2Pof8KCyOwyhG8sAF26mCAeUFAcYPu1mwB7hhpIP29zOIBaDqwuHdLp0jvZXjw=="
},
"gzip-size": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-5.1.1.tgz",
"integrity": "sha512-FNHi6mmoHvs1mxZAds4PpdCS6QG8B4C1krxJsMutgxl5t3+GlRTzzI3NEkifXx2pVsOvJdOGSmIgDhQ55FwdPA==",
"dev": true,
"requires": {
"duplexer": "^0.1.1",
"pify": "^4.0.1"
}
},
"handle-thing": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.0.tgz",
@ -3853,6 +3914,12 @@
"parse-passwd": "^1.0.0"
}
},
"hoopy": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/hoopy/-/hoopy-0.1.4.tgz",
"integrity": "sha512-HRcs+2mr52W0K+x8RzcLzuPPmVIKMSv97RGHy0Ea9y/mpcaK+xTrjICA04KAHi4GRzxliNqNJEFYWHghy3rSfQ==",
"dev": true
},
"hosted-git-info": {
"version": "2.8.4",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.4.tgz",
@ -5029,6 +5096,11 @@
}
}
},
"moment": {
"version": "2.24.0",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.24.0.tgz",
"integrity": "sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg=="
},
"move-concurrently": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz",
@ -5500,6 +5572,12 @@
"wrappy": "1"
}
},
"opener": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/opener/-/opener-1.5.1.tgz",
"integrity": "sha512-goYSy5c2UXE4Ra1xixabeVh1guIX/ZV/YokJksb6q2lubWu6UbvPQ20p542/sFIll1nl8JnCyK9oBaOcCWXwvA==",
"dev": true
},
"opn": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/opn/-/opn-5.5.0.tgz",
@ -7855,6 +7933,12 @@
"glob": "^7.1.2"
}
},
"tryer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/tryer/-/tryer-1.0.1.tgz",
"integrity": "sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA==",
"dev": true
},
"ts-loader": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-6.1.2.tgz",
@ -8347,6 +8431,27 @@
}
}
},
"webpack-bundle-analyzer": {
"version": "3.5.1",
"resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-3.5.1.tgz",
"integrity": "sha512-CDdaT3TTu4F9X3tcDq6PNJOiNGgREOM0WdN2vVAoUUn+M6NLB5kJ543HImCWbrDwOpbpGARSwU8r+u0Pl367kA==",
"dev": true,
"requires": {
"acorn": "^6.0.7",
"acorn-walk": "^6.1.1",
"bfj": "^6.1.1",
"chalk": "^2.4.1",
"commander": "^2.18.0",
"ejs": "^2.6.1",
"express": "^4.16.3",
"filesize": "^3.6.1",
"gzip-size": "^5.0.0",
"lodash": "^4.17.15",
"mkdirp": "^0.5.1",
"opener": "^1.5.1",
"ws": "^6.0.0"
}
},
"webpack-cli": {
"version": "3.3.9",
"resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-3.3.9.tgz",

3
package.json

@ -28,6 +28,7 @@
"@types/redux-logger": "^3.0.7",
"@types/uuid": "^3.4.5",
"@types/webpack": "^4.39.1",
"@types/webpack-bundle-analyzer": "^2.13.3",
"@types/webpack-dev-server": "^3.1.7",
"@types/zxcvbn": "^4.4.0",
"bulma": "^0.7.5",
@ -42,6 +43,7 @@
"ts-node": "^8.4.1",
"typescript": "^3.6.3",
"webpack": "^4.40.2",
"webpack-bundle-analyzer": "^3.5.1",
"webpack-cli": "^3.3.9",
"webpack-dev-server": "^3.8.1"
},
@ -53,6 +55,7 @@
"classnames": "^2.2.6",
"history": "^4.10.1",
"lodash": "^4.17.15",
"moment": "^2.24.0",
"normalizr": "^3.4.1",
"react": "^16.9.0",
"react-avatar-editor": "^11.0.7",

59
src/actions/authentication.ts

@ -6,8 +6,13 @@ import { setEntities } from 'src/actions/entities'
import { startRequest, finishRequest } from 'src/actions/requests'
import { userSchema } from 'src/store/schemas'
import { REQUEST_KEYS } from 'src/constants'
import { AppThunkAction, Entity } from 'src/types'
import {
LOCAL_STORAGE_ACCESS_TOKEN_KEY,
LOCAL_STORAGE_REFRESH_TOKEN_KEY,
LOCAL_STORAGE_ACCESS_TOKEN_AT_KEY,
} from 'src/constants'
import { AppThunkAction, Entity, RequestKey } from 'src/types'
export interface SetCheckedAction extends Action {
type: 'AUTHENTICATION_SET_CHECKED'
@ -23,7 +28,11 @@ export interface SetUserAction extends Action {
payload: string
}
export type AuthenticationActions = SetCheckedAction | SetAuthenticatedAction | SetUserAction
export interface UnauthenticateAction extends Action {
type: 'AUTHENTICATION_UNAUTHENTICATE'
}
export type AuthenticationActions = SetCheckedAction | SetAuthenticatedAction | SetUserAction | UnauthenticateAction
export const setChecked = (): SetCheckedAction => ({
type: 'AUTHENTICATION_SET_CHECKED',
@ -39,8 +48,12 @@ export const setUser = (userId: string): SetUserAction => ({
payload: userId,
})
export const unauthenticate = (): UnauthenticateAction => ({
type: 'AUTHENTICATION_UNAUTHENTICATE',
})
export const fetchSelf = (): AppThunkAction => async dispatch => {
dispatch(startRequest(REQUEST_KEYS.FETCH_GROUP_AVAILABILITY))
dispatch(startRequest(RequestKey.FetchGroupAvailability))
try {
const self = await apiFetch<Entity>({
@ -53,10 +66,44 @@ export const fetchSelf = (): AppThunkAction => async dispatch => {
dispatch(setUser(self.id))
dispatch(setAuthenticated(true))
dispatch(finishRequest(REQUEST_KEYS.FETCH_GROUP_AVAILABILITY, true))
dispatch(finishRequest(RequestKey.FetchGroupAvailability, true))
} catch (err) {
dispatch(setAuthenticated(false))
dispatch(finishRequest(REQUEST_KEYS.FETCH_GROUP_AVAILABILITY, false))
dispatch(finishRequest(RequestKey.FetchGroupAvailability, false))
throw err
}
}
interface AuthenticateResponse {
id: string
access: string
refresh: string
expires: number
}
export const authenticate = (name: string, password: string): AppThunkAction<string> => async dispatch => {
dispatch(startRequest(RequestKey.Authenticate))
try {
const response = await apiFetch<AuthenticateResponse>({
path: '/api/authenticate',
method: 'post',
body: {
id: name,
password,
},
})
localStorage.setItem(LOCAL_STORAGE_ACCESS_TOKEN_KEY, response.access)
localStorage.setItem(LOCAL_STORAGE_REFRESH_TOKEN_KEY, response.refresh)
if (response.expires) localStorage.setItem(LOCAL_STORAGE_ACCESS_TOKEN_AT_KEY, response.expires.toString())
dispatch(finishRequest(RequestKey.Authenticate, true))
await dispatch(fetchSelf())
return response.id
} catch (err) {
dispatch(finishRequest(RequestKey.Authenticate, false))
throw err
}
}

15
src/actions/directory.ts

@ -6,9 +6,8 @@ import { setEntity, setEntities } from 'src/actions/entities'
import { startRequest, finishRequest } from 'src/actions/requests'
import { groupSchema } from 'src/store/schemas'
import { REQUEST_KEYS } from 'src/constants'
import { objectToQuerystring } from 'src/utils'
import { AppThunkAction, Entity } from 'src/types'
import { AppThunkAction, Entity, RequestKey } from 'src/types'
export interface SetGroupsAction extends Action {
type: 'DIRECTORY_SET_GROUPS'
@ -44,7 +43,7 @@ export const setContinuation = (continuation: string): SetContinuationAction =>
export const fetchGroup = (id: string): AppThunkAction => {
return async dispatch => {
dispatch(startRequest(REQUEST_KEYS.FETCH_GROUP))
dispatch(startRequest(RequestKey.FetchGroup))
try {
const group = await apiFetch<Entity>({
@ -52,9 +51,9 @@ export const fetchGroup = (id: string): AppThunkAction => {
})
dispatch(setEntity('group', group))
dispatch(finishRequest(REQUEST_KEYS.FETCH_GROUP, true))
dispatch(finishRequest(RequestKey.FetchGroup, true))
} catch (err) {
dispatch(finishRequest(REQUEST_KEYS.FETCH_GROUP, false))
dispatch(finishRequest(RequestKey.FetchGroup, false))
throw err
}
}
@ -66,7 +65,7 @@ interface GroupsResponse {
}
export const fetchGroups = (sort?: string, continuation?: string): AppThunkAction => async dispatch => {
dispatch(startRequest(REQUEST_KEYS.FETCH_GROUPS))
dispatch(startRequest(RequestKey.FetchGroups))
try {
const response = await apiFetch<GroupsResponse>({
@ -82,9 +81,9 @@ export const fetchGroups = (sort?: string, continuation?: string): AppThunkActio
dispatch(setContinuation(response.continuation))
}
dispatch(finishRequest(REQUEST_KEYS.FETCH_GROUPS, true))
dispatch(finishRequest(RequestKey.FetchGroups, true))
} catch (err) {
dispatch(finishRequest(REQUEST_KEYS.FETCH_GROUPS, false))
dispatch(finishRequest(RequestKey.FetchGroups, false))
throw err
}
}

35
src/actions/registration.ts

@ -7,10 +7,9 @@ import { startRequest, finishRequest } from 'src/actions/requests'
import {
LOCAL_STORAGE_ACCESS_TOKEN_KEY,
LOCAL_STORAGE_REFRESH_TOKEN_KEY,
REQUEST_KEYS,
} from 'src/constants'
import { AppThunkAction } from 'src/types'
import { AppThunkAction, NotificationType, RequestKey } from 'src/types'
export interface SetStepAction extends Action {
type: 'REGISTRATION_SET_STEP'
@ -30,7 +29,7 @@ export const setStep = (step: number): SetStepAction => ({
})
export const checkGroupAvailability = (name: string): AppThunkAction => async dispatch => {
dispatch(startRequest(REQUEST_KEYS.FETCH_GROUP_AVAILABILITY))
dispatch(startRequest(RequestKey.FetchGroupAvailability))
try {
const { id, available } = await apiFetch<AvailabilityResponse>({
@ -42,20 +41,20 @@ export const checkGroupAvailability = (name: string): AppThunkAction => async di
})
if (available) {
dispatch(setFieldNotification('group-name', 'success', `${id} is available`))
dispatch(setFieldNotification('group-name', NotificationType.Success, `${id} is available`))
} else {
dispatch(setFieldNotification('group-name', 'error', `${id} isn't available`))
dispatch(setFieldNotification('group-name', NotificationType.Error, `${id} isn't available`))
}
dispatch(finishRequest(REQUEST_KEYS.FETCH_GROUP_AVAILABILITY, true))
dispatch(finishRequest(RequestKey.FetchGroupAvailability, true))
} catch (err) {
dispatch(finishRequest(REQUEST_KEYS.FETCH_GROUP_AVAILABILITY, false))
dispatch(finishRequest(RequestKey.FetchGroupAvailability, false))
throw err
}
}
export const checkUserAvailability = (name: string): AppThunkAction => async dispatch => {
dispatch(startRequest(REQUEST_KEYS.FETCH_USER_AVAILABILITY))
dispatch(startRequest(RequestKey.FetchUserAvailability))
try {
const { id, available } = await apiFetch<AvailabilityResponse>({
@ -67,14 +66,14 @@ export const checkUserAvailability = (name: string): AppThunkAction => async dis
})
if (available) {
dispatch(setFieldNotification('user-id', 'success', `${id} is available`))
dispatch(setFieldNotification('user-id', NotificationType.Success, `${id} is available`))
} else {
dispatch(setFieldNotification('user-id', 'error', `${id} isn't available`))
dispatch(setFieldNotification('user-id', NotificationType.Error, `${id} isn't available`))
}
dispatch(finishRequest(REQUEST_KEYS.FETCH_USER_AVAILABILITY, true))
dispatch(finishRequest(RequestKey.FetchUserAvailability, true))
} catch (err) {
dispatch(finishRequest(REQUEST_KEYS.FETCH_USER_AVAILABILITY, false))
dispatch(finishRequest(RequestKey.FetchUserAvailability, false))
throw err
}
}
@ -92,7 +91,7 @@ interface CreateGroupResponse {
export const createGroup = (options: CreateGroupOptions): AppThunkAction<string> => async dispatch => {
const { name, registration, about } = options
dispatch(startRequest(REQUEST_KEYS.CREATE_GROUP))
dispatch(startRequest(RequestKey.CreateGroup))
try {
const { id } = await apiFetch<CreateGroupResponse>({
@ -105,11 +104,11 @@ export const createGroup = (options: CreateGroupOptions): AppThunkAction<string>
},
})
dispatch(finishRequest(REQUEST_KEYS.CREATE_GROUP, true))
dispatch(finishRequest(RequestKey.CreateGroup, true))
return id
} catch (err) {
dispatch(finishRequest(REQUEST_KEYS.CREATE_GROUP, false))
dispatch(finishRequest(RequestKey.CreateGroup, false))
throw err
}
}
@ -131,7 +130,7 @@ interface RegisterResponse {
export const register = (options: RegisterOptions): AppThunkAction<string> => async dispatch => {
const { id, email, password, name, group } = options
dispatch(startRequest(REQUEST_KEYS.REGISTER))
dispatch(startRequest(RequestKey.Register))
try {
const response = await apiFetch<RegisterResponse>({
@ -146,14 +145,14 @@ export const register = (options: RegisterOptions): AppThunkAction<string> => as
},
})
dispatch(finishRequest(REQUEST_KEYS.REGISTER, true))
dispatch(finishRequest(RequestKey.Register, true))
localStorage.setItem(LOCAL_STORAGE_ACCESS_TOKEN_KEY, response.access)
localStorage.setItem(LOCAL_STORAGE_REFRESH_TOKEN_KEY, response.refresh)
return response.id
} catch (err) {
dispatch(finishRequest(REQUEST_KEYS.REGISTER, false))
dispatch(finishRequest(RequestKey.Register, false))
throw err
}
}

10
src/api/errors.ts

@ -2,15 +2,15 @@ import { History } from 'history'
import { setFieldNotification } from 'src/actions/forms'
import { showNotification } from 'src/actions/notifications'
import { AppThunkDispatch, FormNotification } from 'src/types'
import { AppThunkDispatch, FormNotification, NotificationType } from 'src/types'
export function handleApiError(err: HttpError, dispatch: AppThunkDispatch, history: History) {
if (err instanceof ServerError) {
dispatch(showNotification('error', 'Server Error'))
dispatch(showNotification(NotificationType.Error, 'Server Error'))
}
if (err instanceof BadRequestError) {
dispatch(showNotification('error', `Error: ${err.message}`))
dispatch(showNotification(NotificationType.Error, `Error: ${err.message}`))
for (const error of err.errors) {
const { field, type, message } = error
@ -19,12 +19,12 @@ export function handleApiError(err: HttpError, dispatch: AppThunkDispatch, histo
}
if (err instanceof UnauthorizedError) {
dispatch(showNotification('error', 'You need to be logged in.'))
dispatch(showNotification(NotificationType.Error, 'You need to be logged in.'))
history.push('/login')
}
if (err instanceof NotFoundError) {
dispatch(showNotification('error', 'Not found.'))
dispatch(showNotification(NotificationType.Error, 'Not found.'))
}
}

4
src/api/fetch.ts

@ -11,7 +11,7 @@ import {
LOCAL_STORAGE_REFRESH_TOKEN_KEY,
} from '../constants'
import { FetchOptions, FormNotification } from '../types'
import { FetchOptions, FormNotification, NotificationType } from '../types'
import getConfig from '../config'
interface RefreshResponse {
@ -38,7 +38,7 @@ const mapErrorsToFormNotifications = (errors?: FormError[]): FormNotification[]
return errors.map(e => ({
field: e.field,
type: 'error',
type: NotificationType.Error,
message: e.message,
}))
}

2
src/components/app/app.tsx

@ -17,7 +17,6 @@ import Login from '../pages/login'
import Register from '../pages/register'
import RegisterGroup from '../pages/register-group'
import Self from '../pages/self'
import Test from '../pages/test'
import './app.scss'
@ -65,7 +64,6 @@ const App: FC<Props> = ({ collapsed, fetching, fetchSelf, setChecked }) => {
<Route path="/self" component={Self} />
<Route path="/developers" component={Developers} />
<Route path="/about" component={About} />
<Route path="/test" component={Test} />
</div>
<NotificationContainer />

2
src/components/create-group-step/create-group-step.tsx

@ -32,7 +32,7 @@ const CreateGroupStep: FC<Props> = ({
<hr />
<nav className="level">
<div className="level-left">
<div className="level-left">
<p className="level-item">
<button className="button" onClick={() => previous()}>
<span className="icon is-small">

10
src/components/create-group-step/index.ts

@ -5,7 +5,7 @@ import { showNotification } from 'src/actions/notifications'
import { setStep } from 'src/actions/registration'
import { getFieldValue } from 'src/selectors/forms'
import { MAX_ID_LENGTH } from 'src/constants'
import { AppState, AppThunkDispatch } from 'src/types'
import { AppState, AppThunkDispatch, NotificationType } from 'src/types'
import CreateGroupStep, { Props } from './create-group-step'
@ -23,18 +23,18 @@ const mapDispatchToProps = (dispatch: AppThunkDispatch, ownProps: Props) => ({
let invalid = false
if (!name) {
dispatch(setFieldNotification('group-name', 'error', 'This is required'))
dispatch(setFieldNotification('group-name', NotificationType.Error, 'This is required'))
invalid = true
}
if (name.length > MAX_ID_LENGTH) {
dispatch(setFieldNotification('group-name', 'error', `This must be less than ${MAX_ID_LENGTH} characters`))
dispatch(setFieldNotification('group-name', NotificationType.Error, `This must be less than ${MAX_ID_LENGTH} characters`))
invalid = true
}
if (!agree) {
dispatch(setFieldNotification('group-agree', 'error', 'You must agree to the terms and conditions to continue'))
dispatch(showNotification('error', 'You must agree to the terms and conditions to continue.'))
dispatch(setFieldNotification('group-agree', NotificationType.Error, 'You must agree to the terms and conditions to continue'))
dispatch(showNotification(NotificationType.Error, 'You must agree to the terms and conditions to continue.'))
invalid = true
}

22
src/components/create-user-step/index.ts

@ -6,9 +6,9 @@ import { showNotification } from 'src/actions/notifications'
import { setStep } from 'src/actions/registration'
import { getFieldValue } from 'src/selectors/forms'
import { MAX_ID_LENGTH, MAX_NAME_LENGTH } from 'src/constants'
import { AppState, AppThunkDispatch } from 'src/types'
import { AppState, AppThunkDispatch, NotificationType } from 'src/types'
import CreateUserStep, { Props } from './create-user-step'
import CreateUserStep from './create-user-step'
const mapStateToProps = (state: AppState) => ({
userId: getFieldValue<string>(state, 'user-id', ''),
@ -18,44 +18,44 @@ const mapStateToProps = (state: AppState) => ({
agree: getFieldValue<boolean>(state, 'user-agree', false),
})
const mapDispatchToProps = (dispatch: AppThunkDispatch, ownProps: Props) => ({
const mapDispatchToProps = (dispatch: AppThunkDispatch) => ({
next: (userId: string, name: string, email: string, password: string, agree: boolean) => {
let invalid = false
if (!userId) {
dispatch(setFieldNotification('user-id', 'error', 'This is required'))
dispatch(setFieldNotification('user-id', NotificationType.Error, 'This is required'))
invalid = true
}
if (userId.length > MAX_ID_LENGTH) {
dispatch(setFieldNotification('user-id', 'error', `This must be less than ${MAX_ID_LENGTH} characters`))
dispatch(setFieldNotification('user-id', NotificationType.Error, `This must be less than ${MAX_ID_LENGTH} characters`))
invalid = true
}
if (name.length > MAX_NAME_LENGTH) {
dispatch(setFieldNotification('user-name', 'error', `This must be less than ${MAX_NAME_LENGTH} characters`))
dispatch(setFieldNotification('user-name', NotificationType.Error, `This must be less than ${MAX_NAME_LENGTH} characters`))
invalid = true
}
if (email === '') {
dispatch(setFieldNotification('user-email', 'error', 'This is required'))
dispatch(setFieldNotification('user-email', NotificationType.Error, 'This is required'))
invalid = true
}
if (!agree) {
dispatch(setFieldNotification('user-agree', 'error', 'You must agree to the terms and conditions to continue'))
dispatch(showNotification('error', 'You must agree to the terms and conditions to continue.'))
dispatch(setFieldNotification('user-agree', NotificationType.Error, 'You must agree to the terms and conditions to continue'))
dispatch(showNotification(NotificationType.Error, 'You must agree to the terms and conditions to continue.'))
invalid = true
}
if (password === '') {
dispatch(setFieldNotification('password', 'error', 'This is required'))
dispatch(setFieldNotification('password', NotificationType.Error, 'This is required'))
invalid = true
} else {
const { score } = zxcvbn(password)
if (score === 0) {
dispatch(setFieldNotification('password', 'error', 'Try another password'))
dispatch(setFieldNotification('password', NotificationType.Error, 'Try another password'))
invalid = true
}
}

4
src/components/forms/password-field/password-field.tsx

@ -14,6 +14,7 @@ export interface Props {
userInputs?: string[]
value?: string
notification?: FormNotification
showStrength?: boolean
setValue?: (value: string) => void
}
@ -22,6 +23,7 @@ const PasswordField: FC<Props> = ({
userInputs = [],
value = '',
notification,
showStrength = true,
setValue = noop,
}) => {
const inputClassDictionary: ClassDictionary = { input: true }
@ -30,7 +32,7 @@ const PasswordField: FC<Props> = ({
let icon: IconDefinition | undefined
let passwordMessage: ReactNode | undefined
if (value) {
if (value && showStrength) {
const { score } = zxcvbn(value, userInputs)
switch (score) {

22
src/components/notification-container/notification-container.tsx

@ -1,6 +1,7 @@
import React, { FC } from 'react'
import { Notification as INotification } from 'src/types'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faDoorOpen } from '@fortawesome/free-solid-svg-icons'
import Notification from '../notification'
import './notification-container.scss'
@ -15,6 +16,23 @@ const NotificationContainer: FC<Props> = ({ notifications, setAuto, dismiss }) =
return (
<div id="notification-container">
{notifications.map(notification => {
const content = () => {
switch (notification.type) {
case 'welcome':
return (
<p>
<span className="icon">
<FontAwesomeIcon icon={faDoorOpen} />
</span>
&nbsp;&nbsp;
<span>{notification.content}</span>
</p>
)
default:
return <span>{notification.content}</span>
}
}
return (
<Notification
key={notification.id}
@ -23,7 +41,7 @@ const NotificationContainer: FC<Props> = ({ notifications, setAuto, dismiss }) =
auto={notification.auto}
setAuto={setAuto}
dismiss={dismiss}>
{notification.content}
{content()}
</Notification>
)
})}

2
src/components/page-header/index.tsx

@ -10,7 +10,7 @@ const PageHeader: FC<Props> = ({ title, subtitle }) => (
<div className="hero-body">
<div className="container">
<h1 className="title">{title}</h1>
{subtitle && <h2 className="subtitle">{title}</h2>}
{subtitle && <h2 className="subtitle">{subtitle}</h2>}
</div>
</div>
</section>

46
src/components/pages/login/index.ts

@ -1,21 +1,53 @@
import { connect } from 'react-redux'
import { getStep } from 'src/selectors/registration'
import { initForm, initField } from 'src/actions/forms'
import { AppState, AppThunkDispatch } from 'src/types'
import { handleApiError } from 'src/api/errors'
import { authenticate } from 'src/actions/authentication'
import { showNotification } from 'src/actions/notifications'
import { getChecked, getAuthenticated } from 'src/selectors/authentication'
import { getFieldValue } from 'src/selectors/forms'
import { getIsFetching } from 'src/selectors/requests'
import { initForm, initField, setFieldNotification } from 'src/actions/forms'
import { AppState, AppThunkDispatch, NotificationType, RequestKey } from 'src/types'
import Login from './login'
import Login, { Props } from './login'
const mapStateToProps = (state: AppState) => ({
step: getStep(state),
checked: getChecked(state),
authenticated: getAuthenticated(state),
name: getFieldValue(state, 'name', ''),
password: getFieldValue(state, 'password', ''),
authenticating: getIsFetching(state, RequestKey.Authenticate),
})
const mapDispatchToProps = (dispatch: AppThunkDispatch) => ({
const mapDispatchToProps = (dispatch: AppThunkDispatch, ownProps: Props) => ({
initForm: () => {
dispatch(initForm())
dispatch(initField('name'))
dispatch(initField('name', 'id'))
dispatch(initField('password'))
},
authenticate: async (name: string, password: string) => {
let invalid = false
if (!name) {
dispatch(setFieldNotification('name', NotificationType.Error, 'This is required'))
invalid = true
}
if (!password) {
dispatch(setFieldNotification('password', NotificationType.Error, 'This is required'))
invalid = true
}
if (invalid) return
try {
const id = await dispatch(authenticate(name, password))
dispatch(showNotification(NotificationType.Welcome, `Welcome back ${id}!`))
ownProps.history.push('/')
} catch (err) {
handleApiError(err, dispatch, ownProps.history)
}
},
})
export default connect(

44
src/components/pages/login/login.tsx

@ -1,19 +1,48 @@
import React, { FC, useEffect } from 'react'
import { RouteComponentProps } from 'react-router'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faIdCard, faKey } from '@fortawesome/free-solid-svg-icons'
import classNames from 'classnames'
import PageHeader from 'src/components/page-header'
import TextField from 'src/components/forms/text-field'
import PasswordField from 'src/components/forms/password-field'
interface Props {
import { ClassDictionary } from 'src/types'
export interface Props extends RouteComponentProps {
checked: boolean
authenticated: boolean
name?: string
password?: string
authenticating?: boolean
initForm: () => void
authenticate: (name: string, password: string) => void
}
const Login: FC<Props> = ({ initForm }) => {
const Login: FC<Props> = ({
checked,
authenticated,
name = '',
password = '',
authenticating = false,
initForm,
authenticate,
history
}) => {
useEffect(() => {
if (checked && authenticated) history.push('/self')
}, [checked, authenticated])
useEffect(() => {
initForm()
}, [])
const buttonClassDictionary: ClassDictionary = {
button: true,
'is-primary': true,
'is-loading': authenticating,
}
return (
<div>
@ -21,17 +50,18 @@ const Login: FC<Props> = ({ initForm }) => {
<div className="main-content">
<div className="centered-content">
<div className="centered-content-icon">
<span className="icon is-large has-text-primary">
<FontAwesomeIcon icon={faKey} size="lg" />
<div className="centered-content-icon has-background-primary">
<span className="icon is-large has-text-white">
<FontAwesomeIcon icon={faKey} size="2x" />
</span>
</div>
<TextField icon={faIdCard} name="name" label="Username" placeholder="Your Username/ID" />
<br />
<PasswordField placeholder="Your password" />
<PasswordField placeholder="Your password" showStrength={false} />
<br />
<button className="button is-primary">Log In</button>
<button className={classNames(buttonClassDictionary)} onClick={() => authenticate(name, password)}>Log In</button>
</div>
</div>
</div>

4
src/components/pages/register-group/index.ts

@ -3,12 +3,12 @@ import { connect } from 'react-redux'
import { handleApiError } from 'src/api/errors'
import { fetchGroup } from 'src/actions/directory'
import { getEntity } from 'src/selectors/entities'
import { AppState, AppThunkDispatch } from 'src/types'
import { AppState, AppThunkDispatch, EntityType } from 'src/types'
import RegisterGroup, { Props } from './register-group'
const mapStateToProps = (state: AppState, ownProps: Props) => ({
group: getEntity(state, 'groups', ownProps.match.params.id),
group: getEntity(state, EntityType.Group, ownProps.match.params.id),
})
const mapDispatchToProps = (dispatch: AppThunkDispatch, ownProps: Props) => ({

4
src/components/pages/register/index.ts

@ -8,7 +8,7 @@ import { showNotification } from 'src/actions/notifications'
import { createGroup, register } from 'src/actions/registration'
import { valueFromForm } from 'src/utils'
import { AppState, AppThunkDispatch, Form } from 'src/types'
import { AppState, AppThunkDispatch, Form, NotificationType } from 'src/types'
import Register, { Props } from './register'
@ -36,7 +36,7 @@ const mapDispatchToProps = (dispatch: AppThunkDispatch, ownProps: Props) => ({
const userAgree = valueFromForm<boolean>(form, 'user-agree', false)
if (!groupAgree || !userAgree) {
dispatch(showNotification('error', 'You must agree to both Community and User terms and conditions.'))
dispatch(showNotification(NotificationType.Error, 'You must agree to both Community and User terms and conditions.'))
return
}

31
src/components/pages/self/index.ts

@ -1,22 +1,27 @@
import { Dispatch } from 'redux'
import { connect } from 'react-redux'
import { getAuthenticated, getAuthenticatedUserId, getChecked } from 'src/selectors/authentication'
import { getEntity } from 'src/selectors/entities'
import { unauthenticate } from 'src/actions/authentication'
import { getAuthenticated, getChecked, getAuthenticatedUser } from 'src/selectors/authentication'
import { AppState } from 'src/types'
import Self from './self'
const mapStateToProps = (state: AppState) => {
const userId = getAuthenticatedUserId(state)
const user = userId ? getEntity(state, 'users', userId) : undefined
const mapStateToProps = (state: AppState) => ({
checked: getChecked(state),
authenticated: getAuthenticated(state),
user: getAuthenticatedUser(state),
})
return {
checked: getChecked(state),
authenticated: getAuthenticated(state),
user,
}
}
const mapDispatchToProps = (dispatch: Dispatch) => ({
logout: async () => {
localStorage.clear()
dispatch(unauthenticate())
window.location.href = '/'
},
})
export default connect(
mapStateToProps
mapStateToProps,
mapDispatchToProps
)(Self)

37
src/components/pages/self/self.tsx

@ -1,33 +1,52 @@
import React, { FC, useEffect } from 'react'
import { RouteComponentProps } from 'react-router-dom'
import { setTitle } from 'src/utils'
import moment from 'moment'
import { useAuthenticationCheck } from 'src/hooks'
import { setTitle } from 'src/utils'
import { Entity } from 'src/types'
import PageHeader from 'src/components/page-header'
interface Props extends RouteComponentProps {
export interface Props extends RouteComponentProps {
checked: boolean
authenticated: boolean
user?: Entity
logout: () => void
}
const Self: FC<Props> = ({ checked, authenticated, user, history }) => {
useEffect(() => {
if (checked && !authenticated) history.push('/login')
}, [checked, authenticated])
const Self: FC<Props> = ({ checked, authenticated, user, logout, history }) => {
useAuthenticationCheck(checked, authenticated, history)
useEffect(() => {
if (user) setTitle(`${user.name} (@${user.id})`)
}, [user])
if (!user) {
return (
<div>
<PageHeader title="Self" />
<div className="main-content"></div>
</div>
)
}
return (
<div>
<PageHeader title={user ? user.name as string : '?'} subtitle={user ? user.id : '?'} />
<PageHeader title={user.name as string || user.id as string} subtitle={`@${user.id}`} />
<div className="main-content">
<nav className="level">
<div className="level-item has-text-centered">
<p>
<div className="heading">Joined</div>
<div className="title">{moment(user.created).format('MMMM Do YYYY')}</div>
</p>
</div>
</nav>
<p>
Hello.
<button className="button is-danger" onClick={() => logout()}>Log Out</button>
</p>
</div>
</div>

16
src/components/pages/test/index.ts

@ -1,16 +0,0 @@
import { connect } from 'react-redux'
import { showNotification } from 'src/actions/notifications'
import { AppThunkDispatch, NotificationType } from 'src/types'
import Test from './test'
const mapDispatchToProps = (dispatch: AppThunkDispatch) => ({
show: async (type: NotificationType, content: string) => {
dispatch(showNotification(type, content))
},
})
export default connect(
null,
mapDispatchToProps
)(Test)

26
src/components/pages/test/test.tsx

@ -1,26 +0,0 @@
import React, { FC } from 'react'
import { NotificationType } from 'src/types'
import PageHeader from 'src/components/page-header'
interface Props {
show: (type: NotificationType, content: string) => void
}
const Test: FC<Props> = ({ show }) => (
<div>
<PageHeader title="Test Page" />
<div className="main-content">
<p>
<button className="button is-success" onClick={() => show('success', 'You did it!!!!!')}>Show Success</button>
<br /><br />
<button className="button is-info" onClick={() => show('info', 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.')}>Show Info</button>
<br /><br />
<button className="button is-danger" onClick={() => show('error', 'Fuck')}>Show Error</button>
</p>
</div>
</div>
)
export default Test

18
src/components/user-info/index.ts

@ -1,20 +1,14 @@
import { connect } from 'react-redux'
import { getAuthenticated, getAuthenticatedUserId } from 'src/selectors/authentication'
import { getEntity } from 'src/selectors/entities'
import { AppState, User } from 'src/types'
import { getAuthenticated, getAuthenticatedUser } from 'src/selectors/authentication'
import { AppState } from 'src/types'
import UserInfo from './user-info'
const mapStateToProps = (state: AppState) => {
const userId = getAuthenticatedUserId(state)
const user = userId ? getEntity<User>(state, 'users', userId) : undefined
return {
authenticated: getAuthenticated(state),
user,
}
}
const mapStateToProps = (state: AppState) => ({
authenticated: getAuthenticated(state),
user: getAuthenticatedUser(state),
})
export default connect(
mapStateToProps

9
src/constants/index.ts

@ -4,12 +4,3 @@ export const MAX_NAME_LENGTH = 80
export const LOCAL_STORAGE_ACCESS_TOKEN_KEY = 'FLEXOR_ACCESS_TOKEN'
export const LOCAL_STORAGE_ACCESS_TOKEN_AT_KEY = 'FLEXOR_ACCESS_TOKEN_AT'
export const LOCAL_STORAGE_REFRESH_TOKEN_KEY = 'FLEXOR_REFRESH_TOKEN'
export const REQUEST_KEYS = {
FETCH_GROUP: 'FETCH_GROUP',
FETCH_GROUPS: 'FETCH_GROUPS',
FETCH_GROUP_AVAILABILITY: 'FETCH_GROUP_AVAILABILITY',
FETCH_USER_AVAILABILITY: 'FETCH_USER_AVAILABILITY',
CREATE_GROUP: 'CREATE_GROUP',
REGISTER: 'REGISTER',
}

8
src/hooks/index.ts

@ -0,0 +1,8 @@
import { useEffect } from 'react'
import { History } from 'history'
export const useAuthenticationCheck = (checked: boolean, authenticated: boolean, history: History) => {
useEffect(() => {
if (checked && !authenticated) history.push('/login')
}, [checked, authenticated])
}

6
src/reducers/authentication.ts

@ -27,6 +27,12 @@ const reducer: Reducer<AuthenticationState, AuthenticationActions> = (state = in
...state,
userId: action.payload,
}
case 'AUTHENTICATION_UNAUTHENTICATE':
return {
...state,
authenticated: false,
userId: undefined,
}
default:
return state
}

15
src/selectors/authentication.ts

@ -1,5 +1,18 @@
import { AppState } from '../types'
import { denormalize } from 'normalizr'
import { createSelector } from 'reselect'
import { userSchema } from 'src/store/schemas'
import { getEntityStore } from './entities'
import { AppState, User } from 'src/types'
export const getChecked = (state: AppState) => state.authentication.checked
export const getAuthenticated = (state: AppState) => state.authentication.authenticated
export const getAuthenticatedUserId = (state: AppState) => state.authentication.userId
export const getAuthenticatedUser = createSelector(
[getEntityStore, getAuthenticatedUserId],
(entities, userId) => {
if (!userId) return
return denormalize(userId, userSchema, entities) as User
}
)

6
src/selectors/directory.ts

@ -3,13 +3,13 @@ import { createSelector } from 'reselect'
import { groupSchema } from '../store/schemas'
import { getEntityStore } from './entities'
import { AppState, Group } from '../types'
import { AppState, Group } from 'src/types'
export const getGroupIds = (state: AppState) => state.directory.groups
export const getGroups = createSelector(
[getEntityStore, getGroupIds],
(store, groups) => {
return denormalize(groups, [groupSchema], store) as Group[]
(entities, groups) => {
return denormalize(groups, [groupSchema], entities) as Group[]
}
)

19
src/selectors/entities.ts

@ -1,10 +1,19 @@
import { AppState, Entity, EntityTypes } from '../types'
import { denormalize } from 'normalizr'
import { userSchema, groupSchema } from 'src/store/schemas'
import { AppState, Entity, EntityType } from '../types'
export const getEntityStore = (state: AppState) => state.entities
export const getEntity = <T extends Entity = Entity>(state: AppState, type: EntityTypes, id: string) => {
const store = getEntityStore(state)
const collection = store[type]
export const getEntity = <T extends Entity = Entity>(state: AppState, type: EntityType, id?: string) => {
if (!id) return
const entities = getEntityStore(state)
return collection ? collection[id] as T : undefined
switch (type) {
case EntityType.User:
return denormalize(id, userSchema, entities) as T
case EntityType.Group:
return denormalize(id, groupSchema, entities) as T
default:
return
}
}

3
src/selectors/requests.ts

@ -0,0 +1,3 @@
import { AppState, RequestKey } from 'src/types'
export const getIsFetching = (state: AppState, key: RequestKey) => state.requests[key] ? state.requests[key].fetching : false

2
src/store/schemas.ts

@ -3,5 +3,5 @@ import { schema } from 'normalizr'
export const groupSchema = new schema.Entity('groups')
export const userSchema = new schema.Entity('users', {
groupSchema,
group: groupSchema,
})

5
src/types/entities.ts

@ -1,4 +1,7 @@
export type EntityTypes = 'users' | 'groups'
export enum EntityType {
User = 'users',
Group = 'groups',
}
export interface Entity {
[key: string]: string | number | boolean | object | any[]

20
src/types/store.ts

@ -1,7 +1,23 @@
import { EntityStore } from './entities'
export type NotificationType = 'info' | 'success' | 'error'
export enum NotificationType {
Info = 'info',
Success = 'success',
Error = 'error',
Welcome = 'welcome',
}
export enum RequestKey {
FetchGroup = 'fetch_group',
FetchGroups = 'fetch_groups',
FetchGroupAvailability = 'fetch_group_availability',
FetchUserAvailability = 'fetch_user_availability',
CreateGroup = 'create_group',
Register = 'register',
Authenticate = 'authenticate',
}
export type FormValue = string | number | boolean
export interface FormNotification {
@ -11,7 +27,7 @@ export interface FormNotification {
}
export interface APIRequest {
id: string
id: RequestKey
fetching: boolean
started: number
succeeded: boolean

7
src/utils/index.ts

@ -2,9 +2,10 @@ import { NotificationType, Form, FormValue } from '../types'
export function notificationTypeToClassName(type: NotificationType): string {
switch (type) {
case 'info': return 'is-info'
case 'success': return 'is-success'
case 'error': return 'is-danger'
case NotificationType.Info: return 'is-info'
case NotificationType.Success: return 'is-success'
case NotificationType.Error: return 'is-danger'
case NotificationType.Welcome: return 'is-success'
}
}

2
webpack.config.ts

@ -1,6 +1,7 @@
import { Configuration } from 'webpack'
import HtmlWebpackPlugin from 'html-webpack-plugin'
import MiniCssExtractPlugin from 'mini-css-extract-plugin'
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'
import c from './config/config.json'
@ -71,6 +72,7 @@ const config: Configuration = {
new MiniCssExtractPlugin({
filename: '[name].css',
}),
// new BundleAnalyzerPlugin(),
],
}

Loading…
Cancel
Save