├── .eslintignore
├── src
├── components
│ ├── .gitkeep
│ ├── Layout
│ │ ├── Layout.module.scss
│ │ ├── index.jsx
│ │ └── Layout.test.js
│ ├── Navigation
│ │ ├── Aside
│ │ │ ├── Aside.module.scss
│ │ │ ├── __snapshots__
│ │ │ │ └── Aside.test.js.snap
│ │ │ ├── Aside.test.js
│ │ │ └── index.jsx
│ │ ├── Link
│ │ │ ├── __snapshots__
│ │ │ │ └── Link.test.js.snap
│ │ │ ├── index.jsx
│ │ │ └── Link.test.js
│ │ ├── Footer
│ │ │ ├── Footer.module.scss
│ │ │ ├── Footer.test.js
│ │ │ ├── index.jsx
│ │ │ └── __snapshots__
│ │ │ │ └── Footer.test.js.snap
│ │ └── NavBar
│ │ │ ├── NavBar.test.js
│ │ │ ├── __snapshots__
│ │ │ └── NavBar.test.js.snap
│ │ │ └── index.jsx
│ ├── ConfirmationModal
│ │ ├── ConfirmationModal.scss
│ │ ├── __snapshots__
│ │ │ └── ConfirmationModal.test.js.snap
│ │ ├── index.jsx
│ │ └── ConfirmationModal.test.js
│ ├── ErrorMessage
│ │ ├── __snapshots__
│ │ │ └── ErrorMessage.test.js.snap
│ │ ├── ErrorMessage.test.js
│ │ └── index.jsx
│ ├── UserForm
│ │ ├── UserForm.scss
│ │ └── UserForm.test.js
│ ├── Table
│ │ ├── Table.module.scss
│ │ └── TableMobile.css
│ ├── DatePicker
│ │ ├── __snapshots__
│ │ │ └── DatePicker.test.js.snap
│ │ ├── DatePicker.scss
│ │ ├── DatePicker.test.js
│ │ └── index.jsx
│ └── LanguageWrapper
│ │ └── index.js
├── styles
│ └── .gitkeep
├── pages
│ ├── NotFound
│ │ ├── NotFound.module.scss
│ │ ├── __snapshots__
│ │ │ └── NotFound.test.js.snap
│ │ ├── NotFound.test.js
│ │ └── index.jsx
│ ├── Login
│ │ ├── Login.module.scss
│ │ ├── Login.test.js
│ │ └── __snapshots__
│ │ │ └── Login.test.js.snap
│ ├── ResetPassword
│ │ ├── ResetPassword.module.scss
│ │ ├── __snapshots__
│ │ │ └── ResetPassword.test.js.snap
│ │ ├── ResetPassword.test.js
│ │ └── index.jsx
│ ├── Router
│ │ ├── paths.js
│ │ ├── Router.test.js
│ │ ├── PrivateRoute
│ │ │ ├── index.js
│ │ │ └── PrivateRoute.test.js
│ │ └── index.js
│ ├── Section
│ │ ├── Section.test.js
│ │ ├── index.jsx
│ │ └── __snapshots__
│ │ │ └── Section.test.js.snap
│ ├── Submenu
│ │ ├── Submenu.test.js
│ │ ├── index.jsx
│ │ └── __snapshots__
│ │ │ └── Submenu.test.js.snap
│ ├── Users
│ │ ├── Users.module.scss
│ │ ├── Users.test.js
│ │ └── index.jsx
│ ├── Home
│ │ ├── index.jsx
│ │ ├── __snapshots__
│ │ │ └── Home.test.js.snap
│ │ └── Home.test.js
│ ├── Profile
│ │ ├── Profile.test.js
│ │ ├── index.jsx
│ │ └── ChangePassword
│ │ │ ├── __snapshots__
│ │ │ └── ChangePassword.test.js.snap
│ │ │ ├── ChangePassword.test.js
│ │ │ └── index.jsx
│ └── User
│ │ ├── User.test.js
│ │ └── index.jsx
├── assets
│ ├── en.png
│ ├── es.png
│ ├── 404.gif
│ ├── default-image-establishment.jpg
│ └── user-default-log.svg
├── state
│ ├── actions
│ │ └── preferences.js
│ ├── api
│ │ ├── index.js
│ │ ├── rtdb.js
│ │ └── firestore.js
│ ├── reducers
│ │ ├── preferences
│ │ │ ├── index.js
│ │ │ └── preferences.test.js
│ │ ├── index.js
│ │ ├── users
│ │ │ ├── index.js
│ │ │ └── users.test.js
│ │ └── auth
│ │ │ └── index.js
│ └── store.js
├── firebase.js
├── hooks
│ └── index.js
├── index.scss
├── index.js
├── setupTests.js
├── utils
│ └── index.js
├── languages
│ ├── en.json
│ └── es.json
└── serviceWorker.js
├── .prettierrc
├── firestore.indexes.json
├── functions
├── src
│ ├── types
│ │ └── firebase-function-tools.d.ts
│ ├── db
│ │ └── users
│ │ │ ├── onDelete.function.ts
│ │ │ └── onUpdate.function.ts
│ ├── firestore
│ │ └── users
│ │ │ ├── onDelete.function.ts
│ │ │ └── onUpdate.function.ts
│ ├── index.ts
│ └── https
│ │ └── createUser.function.ts
├── env.example.json
├── .gitignore
├── tsconfig.json
├── test
│ ├── util
│ │ └── config.ts
│ ├── db
│ │ └── users
│ │ │ ├── onDelete.test.ts
│ │ │ └── onUpdate.test.ts
│ ├── firestore
│ │ └── users
│ │ │ ├── onDelete.test.ts
│ │ │ └── onUpdate.test.ts
│ └── https
│ │ └── createUser.test.ts
├── package.json
├── setupProject.js
└── tslint.json
├── jsconfig.json
├── .firebaserc
├── storage.rules
├── .github
├── dependabot.yml
├── ISSUE_TEMPLATE
│ ├── feature_request.md
│ └── bug_report.md
└── workflows
│ ├── production-deployment.yml
│ ├── staging-deployment.yml
│ └── pull-requests.yml
├── .env.example
├── database.rules.json
├── firebase.json
├── firestore.rules
├── LICENSE.md
├── .eslintrc
├── .gitignore
├── public
└── index.html
└── package.json
/.eslintignore:
--------------------------------------------------------------------------------
1 | build/*
--------------------------------------------------------------------------------
/src/components/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/styles/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true
3 | }
4 |
--------------------------------------------------------------------------------
/firestore.indexes.json:
--------------------------------------------------------------------------------
1 | {
2 | "indexes": [],
3 | "fieldOverrides": []
4 | }
5 |
--------------------------------------------------------------------------------
/src/pages/NotFound/NotFound.module.scss:
--------------------------------------------------------------------------------
1 | .section {
2 | margin: 0 auto;
3 | }
4 |
--------------------------------------------------------------------------------
/functions/src/types/firebase-function-tools.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'firebase-function-tools';
2 |
--------------------------------------------------------------------------------
/src/assets/en.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CreateThrive/react-firebase-admin/HEAD/src/assets/en.png
--------------------------------------------------------------------------------
/src/assets/es.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CreateThrive/react-firebase-admin/HEAD/src/assets/es.png
--------------------------------------------------------------------------------
/src/assets/404.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CreateThrive/react-firebase-admin/HEAD/src/assets/404.gif
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": "src"
4 | },
5 | "include": ["src"]
6 | }
7 |
--------------------------------------------------------------------------------
/src/components/Layout/Layout.module.scss:
--------------------------------------------------------------------------------
1 | .layout {
2 | min-height: calc(100vh - 100px);
3 | background-color: white;
4 | }
--------------------------------------------------------------------------------
/src/components/Navigation/Aside/Aside.module.scss:
--------------------------------------------------------------------------------
1 | .submenuLink:hover {
2 | background-color: #262930;
3 | color: white;
4 | }
5 |
--------------------------------------------------------------------------------
/functions/env.example.json:
--------------------------------------------------------------------------------
1 | {
2 | "databaseURL": "",
3 | "storageBucket": "",
4 | "projectId": "",
5 | "serviceAccountKey": ""
6 | }
7 |
--------------------------------------------------------------------------------
/src/assets/default-image-establishment.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CreateThrive/react-firebase-admin/HEAD/src/assets/default-image-establishment.jpg
--------------------------------------------------------------------------------
/src/components/ConfirmationModal/ConfirmationModal.scss:
--------------------------------------------------------------------------------
1 | header.modal-card-head,
2 | footer.modal-card-foot {
3 | border: 1px solid #f1f2f2;
4 | background: white;
5 | }
6 |
--------------------------------------------------------------------------------
/src/components/Navigation/Link/__snapshots__/Link.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` rendering should render without crashing 1`] = `undefined`;
4 |
--------------------------------------------------------------------------------
/src/pages/Login/Login.module.scss:
--------------------------------------------------------------------------------
1 | .errorMessage {
2 | margin: 0.7rem 0 0 0;
3 | }
4 | .socialButtons {
5 | flex-direction: column;
6 | }
7 | .icon {
8 | margin-right: 5px;
9 | }
10 |
--------------------------------------------------------------------------------
/.firebaserc:
--------------------------------------------------------------------------------
1 | {
2 | "projects": {
3 | "production": "react-firebase-admin-eeac2",
4 | "staging": "react-firebase-admin-eeac2",
5 | "default": "react-firebase-admin-eeac2"
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/storage.rules:
--------------------------------------------------------------------------------
1 | service firebase.storage {
2 | match /b/{bucket}/o {
3 | match /users/{imageId} {
4 | allow write: if request.auth!=null;
5 | allow read: if true;
6 | }
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/src/pages/ResetPassword/ResetPassword.module.scss:
--------------------------------------------------------------------------------
1 | .sub-title {
2 | margin: 0 0 1rem 0;
3 | }
4 |
5 | .errorMessage {
6 | margin: 0.7rem 0 0 0;
7 | }
8 |
9 | .return-login {
10 | display: block;
11 | margin: 1rem 0 0 0;
12 | }
13 |
--------------------------------------------------------------------------------
/functions/.gitignore:
--------------------------------------------------------------------------------
1 | ## Compiled JavaScript files
2 | **/*.js
3 | **/*.js.map
4 |
5 | # Typescript v1 declaration files
6 | typings/
7 |
8 | node_modules/
9 |
10 | lib/
11 |
12 | #Necessary config for testing
13 | env.json
14 |
15 | service-account-key.json
--------------------------------------------------------------------------------
/src/state/actions/preferences.js:
--------------------------------------------------------------------------------
1 | import { createAction } from 'redux-act';
2 |
3 | export const PREFERENCES_SET_LOCALE = createAction('PREFERENCES_SET_LOCALE');
4 |
5 | export const setUserLocale = locale => dispatch => {
6 | return dispatch(PREFERENCES_SET_LOCALE({ locale }));
7 | };
8 |
--------------------------------------------------------------------------------
/functions/src/db/users/onDelete.function.ts:
--------------------------------------------------------------------------------
1 | import { auth } from 'firebase-admin';
2 | import { database } from 'firebase-functions';
3 |
4 | export default database.ref('users/{uid}').onDelete((snapshot, context) => {
5 | const { uid } = context.params;
6 | return auth().deleteUser(uid);
7 | });
8 |
--------------------------------------------------------------------------------
/src/state/api/index.js:
--------------------------------------------------------------------------------
1 | import {
2 | createDocument,
3 | deleteDocument,
4 | fetchCollection,
5 | fetchDocument,
6 | updateDocument,
7 | } from './rtdb';
8 |
9 | export {
10 | createDocument,
11 | deleteDocument,
12 | fetchCollection,
13 | fetchDocument,
14 | updateDocument,
15 | };
16 |
--------------------------------------------------------------------------------
/src/components/Navigation/Footer/Footer.module.scss:
--------------------------------------------------------------------------------
1 | footer {
2 | border-top: 1px solid #f1f2f2;
3 | }
4 |
5 | .level {
6 | display: flex;
7 | }
8 |
9 | @media screen and (max-width: 768px) {
10 | .level span {
11 | display: none;
12 | }
13 | .levelRight {
14 | margin-top: 0 !important;
15 | }
16 | }
--------------------------------------------------------------------------------
/src/components/ErrorMessage/__snapshots__/ErrorMessage.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` rendering should render without crashing 1`] = `
4 |
5 |
8 | This field is required
9 |
10 |
11 | `;
12 |
--------------------------------------------------------------------------------
/src/pages/Router/paths.js:
--------------------------------------------------------------------------------
1 | export default {
2 | ROOT: '/',
3 | LOGIN: '/login',
4 | USERS: '/users',
5 | ADD_USER: '/users/new',
6 | MODIFY_USER: '/users/:id',
7 | PROFILE: '/profile',
8 | RESET_PASSWORD: '/recover-password',
9 | SECTION: '/section',
10 | SUBMENU_1: '/submenu1',
11 | SUBMENU_2: '/submenu2'
12 | };
13 |
--------------------------------------------------------------------------------
/functions/src/firestore/users/onDelete.function.ts:
--------------------------------------------------------------------------------
1 | import { auth } from 'firebase-admin';
2 | import { firestore } from 'firebase-functions';
3 |
4 | export default firestore
5 | .document('users/{uid}')
6 | .onDelete((snapshot, context) => {
7 | const { uid } = context.params;
8 | return auth().deleteUser(uid);
9 | });
10 |
--------------------------------------------------------------------------------
/src/components/UserForm/UserForm.scss:
--------------------------------------------------------------------------------
1 | .is-user-avatar {
2 | &.has-max-width {
3 | max-height: 7rem;
4 | }
5 |
6 | .user-avatar {
7 | height: 100%;
8 | max-height: 7rem;
9 | max-width: 7rem;
10 | }
11 | }
12 |
13 | @media screen and (max-width: 768px) {
14 | div.preview {
15 | display: none;
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/pages/Section/Section.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import Section from '.';
4 |
5 | describe(' rendering', () => {
6 | it('should render without crashing', () => {
7 | const { component } = renderWithProviders()({});
8 |
9 | expect(component.asFragment()).toMatchSnapshot();
10 | });
11 | });
12 |
--------------------------------------------------------------------------------
/src/pages/Submenu/Submenu.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import Submenu from '.';
4 |
5 | describe(' rendering', () => {
6 | it('should render without crashing', () => {
7 | const { component } = renderWithProviders()({});
8 |
9 | expect(component.asFragment()).toMatchSnapshot();
10 | });
11 | });
12 |
--------------------------------------------------------------------------------
/src/components/ErrorMessage/ErrorMessage.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import ErrorMessage from '.';
4 |
5 | describe(' rendering', () => {
6 | it('should render without crashing', () => {
7 | const { component } = renderWithProviders()({});
8 |
9 | expect(component.asFragment()).toMatchSnapshot();
10 | });
11 | });
12 |
--------------------------------------------------------------------------------
/src/components/Navigation/Footer/Footer.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render } from '@testing-library/react';
3 | import Footer from '.';
4 |
5 | describe(' rendering', () => {
6 | it('should render without crashing', () => {
7 | const component = render();
8 |
9 | expect(component.asFragment()).toMatchSnapshot();
10 | });
11 | });
12 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: 'npm'
4 | directory: '/'
5 | schedule:
6 | interval: 'weekly'
7 |
8 | - package-ecosystem: 'npm'
9 | directory: '/functions'
10 | schedule:
11 | interval: 'weekly'
12 |
13 | - package-ecosystem: 'github-actions'
14 | directory: '/'
15 | schedule:
16 | interval: 'weekly'
17 |
--------------------------------------------------------------------------------
/functions/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "commonjs",
4 | "noImplicitReturns": true,
5 | "noUnusedLocals": true,
6 | "outDir": "lib",
7 | "sourceMap": true,
8 | "strict": true,
9 | "target": "es2017",
10 | "resolveJsonModule": true,
11 | "skipLibCheck": true
12 | },
13 | "compileOnSave": true,
14 | "include": ["src"]
15 | }
16 |
--------------------------------------------------------------------------------
/functions/src/index.ts:
--------------------------------------------------------------------------------
1 | import * as loadFunctions from 'firebase-function-tools';
2 | import * as admin from 'firebase-admin';
3 | // This import is needed by admin.initializeApp() to get the project info (Database url, project id, etc)
4 | // @ts-ignore
5 | import * as functions from 'firebase-functions';
6 |
7 | admin.initializeApp();
8 |
9 | loadFunctions(__dirname, exports, '.function.js');
10 |
--------------------------------------------------------------------------------
/src/components/Table/Table.module.scss:
--------------------------------------------------------------------------------
1 | table {
2 | width: 100%;
3 | }
4 |
5 | .isSortable:hover, .isCurrentSort {
6 | border-color: #7a7a7a !important;
7 | }
8 |
9 | .tableIcon {
10 | margin-left: .5rem;
11 | }
12 |
13 | .currentPage {
14 | color: #2e323a !important;
15 | border-color: #4a4a4a !important;
16 | background-color: #fff;
17 | }
18 |
19 | .level {
20 | padding: 20px;
21 | }
--------------------------------------------------------------------------------
/src/pages/Users/Users.module.scss:
--------------------------------------------------------------------------------
1 | .establishments {
2 | display: flex;
3 | flex-wrap: wrap;
4 | }
5 |
6 | .header {
7 | display: flex;
8 | justify-content: center;
9 | flex-wrap: wrap;
10 | margin: 0 0 1rem 0;
11 |
12 | @media (min-width: 560px) {
13 | justify-content: space-between;
14 | }
15 | }
16 |
17 | .tableHeader {
18 | input {
19 | max-width: 250px;
20 | margin-left: 15px;
21 | }
22 | }
--------------------------------------------------------------------------------
/src/state/reducers/preferences/index.js:
--------------------------------------------------------------------------------
1 | import { createReducer } from 'redux-act';
2 |
3 | import { PREFERENCES_SET_LOCALE } from 'state/actions/preferences';
4 |
5 | const initialState = {
6 | locale: null
7 | };
8 |
9 | export const preferencesReducer = createReducer(
10 | {
11 | [PREFERENCES_SET_LOCALE]: (state, payload) => ({
12 | ...state,
13 | locale: payload.locale
14 | })
15 | },
16 | initialState
17 | );
18 |
--------------------------------------------------------------------------------
/src/components/ErrorMessage/index.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | import { useFormatMessage } from 'hooks';
5 |
6 | const ErrorMessage = ({ text = '' }) => {
7 | const defaultText = useFormatMessage('ErrorMessage.defaultMessage');
8 | return
{text || defaultText}
;
9 | };
10 |
11 | ErrorMessage.propTypes = {
12 | text: PropTypes.string,
13 | };
14 |
15 | export default ErrorMessage;
16 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | REACT_APP_FIRE_BASE_KEY = ''
2 | REACT_APP_FIRE_BASE_PROJECT_ID = ''
3 | REACT_APP_FIRE_BASE_AUTH_DOMAIN = ''
4 | REACT_APP_FIRE_BASE_DB_URL = ''
5 | REACT_APP_FIRE_BASE_STORAGE_BUCKET = ''
6 | REACT_APP_FIRE_BASE_MESSAGING_SENDER_ID = ''
7 | REACT_APP_FIRE_BASE_APP_ID = ''
8 | REACT_APP_FIRE_BASE_MEASURMENT_ID = ''
9 | REACT_APP_LOGIN_PAGE_URL = 'http://localhost:3000/login'
10 | REACT_APP_FIRE_BASE_STORAGE_API = 'https://firebasestorage.googleapis.com/v0/b/${REACT_APP_FIRE_BASE_STORAGE_BUCKET}'
11 |
--------------------------------------------------------------------------------
/src/pages/Home/index.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { useFormatMessage } from 'hooks';
4 |
5 | const Home = () => (
6 | <>
7 |
8 |
9 |
{useFormatMessage('Home.home')}
10 |
11 |
12 |
13 |
14 | {useFormatMessage('Home.content')}
15 |
16 | >
17 | );
18 |
19 | export default Home;
20 |
--------------------------------------------------------------------------------
/functions/test/util/config.ts:
--------------------------------------------------------------------------------
1 | import * as testConfig from 'firebase-functions-test';
2 | import * as admin from 'firebase-admin';
3 | import {
4 | databaseURL,
5 | storageBucket,
6 | projectId,
7 | serviceAccountKey
8 | } from '../../env.json';
9 |
10 | const projectConfig = {
11 | databaseURL,
12 | storageBucket,
13 | projectId,
14 | serviceAccountKey
15 | };
16 |
17 | const test = testConfig(projectConfig, serviceAccountKey);
18 |
19 | admin.initializeApp();
20 |
21 | export { admin, test };
22 |
--------------------------------------------------------------------------------
/database.rules.json:
--------------------------------------------------------------------------------
1 | {
2 | "rules": {
3 | "users": {
4 | "$user": {
5 | ".write": "auth !== null && ((auth.token.isAdmin === true && data.child('isAdmin').val() === false) || auth.uid === $user)",
6 | ".read": "auth !== null && ((auth.token.isAdmin === true && data.child('isAdmin').val() === false) || auth.uid === $user)"
7 | },
8 | ".write": "auth !== null && auth.token.isAdmin === true",
9 | ".read": "auth !== null && auth.token.isAdmin === true"
10 | }
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/src/components/DatePicker/__snapshots__/DatePicker.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` rendering should render without crashing 1`] = `
4 |
5 |
18 |
19 | `;
20 |
--------------------------------------------------------------------------------
/src/pages/Section/index.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { useFormatMessage } from 'hooks';
4 |
5 | const Section = () => (
6 | <>
7 |
8 |
9 |
{useFormatMessage('Section.section')}
10 |
11 |
12 |
13 | {useFormatMessage('Section.content')}
14 |
15 | >
16 | );
17 |
18 | export default Section;
19 |
--------------------------------------------------------------------------------
/src/pages/Submenu/index.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { useFormatMessage } from 'hooks';
4 |
5 | const Submenu = () => (
6 | <>
7 |
8 |
9 |
{useFormatMessage('Submenu.submenu')}
10 |
11 |
12 |
13 | {useFormatMessage('Submenu.content')}
14 |
15 | >
16 | );
17 |
18 | export default Submenu;
19 |
--------------------------------------------------------------------------------
/functions/src/db/users/onUpdate.function.ts:
--------------------------------------------------------------------------------
1 | import { database } from 'firebase-functions';
2 | import { auth } from 'firebase-admin';
3 |
4 | export default database.ref('/users/{uid}').onUpdate((change, context) => {
5 | const before = change.before.val();
6 | const after = change.after.val();
7 | const { isAdmin } = after;
8 |
9 | if (before.isAdmin === isAdmin) {
10 | return null;
11 | }
12 |
13 | const { uid } = context.params;
14 |
15 | return auth().setCustomUserClaims(uid, {
16 | isAdmin,
17 | });
18 | });
19 |
--------------------------------------------------------------------------------
/src/pages/Home/__snapshots__/Home.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` rendering should render without crashing 1`] = `
4 |
5 |
8 |
11 |
14 | Home
15 |
16 |
17 |
18 |
23 |
24 | `;
25 |
--------------------------------------------------------------------------------
/src/pages/Section/__snapshots__/Section.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` rendering should render without crashing 1`] = `
4 |
5 |
8 |
11 |
14 | Section
15 |
16 |
17 |
18 |
21 | Section content
22 |
23 |
24 | `;
25 |
--------------------------------------------------------------------------------
/src/pages/Submenu/__snapshots__/Submenu.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` rendering should render without crashing 1`] = `
4 |
5 |
8 |
11 |
14 | Submenu
15 |
16 |
17 |
18 |
21 | Submenu content
22 |
23 |
24 | `;
25 |
--------------------------------------------------------------------------------
/src/state/reducers/preferences/preferences.test.js:
--------------------------------------------------------------------------------
1 | import { PREFERENCES_SET_LOCALE } from 'state/actions/preferences';
2 |
3 | import { preferencesReducer } from '.';
4 |
5 | describe('Preferences reducer', () => {
6 | const initialState = {
7 | locale: null
8 | };
9 | const reducerTest = reducerTester(preferencesReducer);
10 |
11 | it('should set locale to "en" when PREFERENCES_SET_LOCALE action is fired', () => {
12 | reducerTest(initialState, PREFERENCES_SET_LOCALE({ locale: 'en' }), {
13 | ...initialState,
14 | locale: 'en'
15 | });
16 | });
17 | });
18 |
--------------------------------------------------------------------------------
/functions/src/firestore/users/onUpdate.function.ts:
--------------------------------------------------------------------------------
1 | import { firestore } from 'firebase-functions';
2 | import { auth } from 'firebase-admin';
3 |
4 | export default firestore
5 | .document('/users/{uid}')
6 | .onUpdate((change, context) => {
7 | const before = change.before.data();
8 | const after = change.after.data();
9 | const { isAdmin } = after;
10 |
11 | if (before.isAdmin === isAdmin) {
12 | return null;
13 | }
14 |
15 | const { uid } = context.params;
16 |
17 | return auth().setCustomUserClaims(uid, {
18 | isAdmin,
19 | });
20 | });
21 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/src/pages/Home/Home.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import * as reactRedux from 'react-redux';
3 |
4 | import * as actions from 'state/actions/auth';
5 | import Home from '.';
6 |
7 | describe(' rendering', () => {
8 | const dispatchMock = jest.fn();
9 |
10 | beforeEach(() => {
11 | jest
12 | .spyOn(reactRedux, 'useDispatch')
13 | .mockImplementation(() => dispatchMock);
14 | jest.spyOn(actions, 'auth').mockImplementation(jest.fn);
15 | });
16 |
17 | it('should render without crashing', () => {
18 | const { component } = renderWithProviders()({
19 | auth: {},
20 | });
21 |
22 | expect(component.asFragment()).toMatchSnapshot();
23 | });
24 | });
25 |
--------------------------------------------------------------------------------
/src/pages/Router/Router.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import * as reactRedux from 'react-redux';
3 |
4 | import * as actions from 'state/actions/auth';
5 | import Router from '.';
6 |
7 | describe(' rendering', () => {
8 | const dispatchMock = jest.fn();
9 |
10 | beforeEach(() => {
11 | jest
12 | .spyOn(reactRedux, 'useDispatch')
13 | .mockImplementation(() => dispatchMock);
14 | jest.spyOn(actions, 'auth').mockImplementation(jest.fn);
15 | });
16 |
17 | it('should render without crashing', () => {
18 | const { component } = renderWithProviders()({
19 | auth: { userData: { id: 'testId' } },
20 | });
21 |
22 | expect(component.asFragment()).toMatchSnapshot();
23 | });
24 | });
25 |
--------------------------------------------------------------------------------
/firebase.json:
--------------------------------------------------------------------------------
1 | {
2 | "database": {
3 | "rules": "database.rules.json"
4 | },
5 | "hosting": {
6 | "public": "build",
7 | "ignore": [
8 | "firebase.json",
9 | "**/.*",
10 | "**/node_modules/**"
11 | ],
12 | "rewrites": [
13 | {
14 | "source": "**",
15 | "destination": "/index.html"
16 | }
17 | ]
18 | },
19 | "storage": {
20 | "rules": "storage.rules"
21 | },
22 | "functions": {
23 | "predeploy": [
24 | "npm --prefix \"$RESOURCE_DIR\" run lint",
25 | "npm --prefix \"$RESOURCE_DIR\" run build"
26 | ],
27 | "source": "functions"
28 | },
29 | "firestore": {
30 | "rules": "firestore.rules",
31 | "indexes": "firestore.indexes.json"
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/components/Navigation/Link/index.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { NavLink } from 'react-router-dom';
4 |
5 | const Link = ({ children, to, className, noActiveStyle = false, onClick }) => {
6 | return (
7 |
14 | {children}
15 |
16 | );
17 | };
18 |
19 | Link.propTypes = {
20 | children: PropTypes.node.isRequired,
21 | to: PropTypes.string.isRequired,
22 | className: PropTypes.string,
23 | noActiveStyle: PropTypes.bool,
24 | onClick: PropTypes.func,
25 | };
26 |
27 | export default Link;
28 |
--------------------------------------------------------------------------------
/functions/src/https/createUser.function.ts:
--------------------------------------------------------------------------------
1 | import { https } from 'firebase-functions';
2 | import { auth } from 'firebase-admin';
3 |
4 | const createUserAuth = async (email: string, isAdmin: boolean) => {
5 | const { uid } = await auth().createUser({ email });
6 |
7 | await auth().setCustomUserClaims(uid, {
8 | isAdmin
9 | });
10 |
11 | return uid;
12 | };
13 |
14 | export default https.onCall(async data => {
15 | const { email, isAdmin } = data;
16 |
17 | if (!email) {
18 | throw new https.HttpsError('invalid-argument', 'auth/invalid-email');
19 | }
20 |
21 | let uid;
22 | try {
23 | uid = await createUserAuth(email, isAdmin);
24 | } catch (error) {
25 | throw new https.HttpsError('invalid-argument', error.code);
26 | }
27 |
28 | return { uid };
29 | });
30 |
--------------------------------------------------------------------------------
/firestore.rules:
--------------------------------------------------------------------------------
1 | rules_version = '2';
2 | service cloud.firestore {
3 | match /databases/{database}/documents {
4 |
5 | function isAuthenticated() {
6 | return request.auth != null && request.auth.token.email_verified == true;
7 | }
8 |
9 | function isAdmin() {
10 | return request.auth.token.isAdmin == true;
11 | }
12 |
13 |
14 | match /users/{userId} {
15 |
16 | function isIdentified() {
17 | return request.auth.uid == userId;
18 | }
19 |
20 | allow read: if isAuthenticated() && (isAdmin() || isIdentified());
21 |
22 | allow write: if isAuthenticated() && isAdmin();
23 |
24 | allow update: if isAuthenticated() && (isAdmin() || isIdentified());
25 |
26 | allow delete: if isAuthenticated() && isAdmin();
27 | }
28 | }
29 | }
--------------------------------------------------------------------------------
/src/pages/Users/Users.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import * as reactRedux from 'react-redux';
3 |
4 | import * as actions from 'state/actions/users';
5 | import Users from '.';
6 |
7 | describe(' rendering', () => {
8 | const dispatchMock = jest.fn();
9 |
10 | beforeEach(() => {
11 | jest
12 | .spyOn(reactRedux, 'useDispatch')
13 | .mockImplementation(() => dispatchMock);
14 | jest.spyOn(actions, 'usersCleanUp').mockImplementation(jest.fn);
15 | });
16 |
17 | it('should render without crashing', () => {
18 | const { component } = renderWithProviders()({
19 | users: {
20 | data: [],
21 | },
22 | auth: {
23 | userData: {},
24 | },
25 | });
26 |
27 | expect(component.asFragment()).toMatchSnapshot();
28 | });
29 | });
30 |
--------------------------------------------------------------------------------
/src/firebase.js:
--------------------------------------------------------------------------------
1 | import firebase from 'firebase/app';
2 | import 'firebase/database';
3 | import 'firebase/auth';
4 | import 'firebase/storage';
5 | import 'firebase/functions';
6 |
7 | const config = {
8 | apiKey: process.env.REACT_APP_FIRE_BASE_KEY,
9 | authDomain: process.env.REACT_APP_FIRE_BASE_AUTH_DOMAIN,
10 | databaseURL: process.env.REACT_APP_FIRE_BASE_DB_URL,
11 | projectId: process.env.REACT_APP_FIRE_BASE_PROJECT_ID,
12 | storageBucket: process.env.REACT_APP_FIRE_BASE_STORAGE_BUCKET,
13 | messagingSenderId: process.env.REACT_APP_FIRE_BASE_MESSAGING_SENDER_ID,
14 | appId: process.env.REACT_APP_FIRE_BASE_APP_ID,
15 | measurementId: process.env.REACT_APP_FIRE_BASE_MEASURMENT_ID,
16 | };
17 |
18 | firebase.initializeApp(config);
19 | firebase.database();
20 | firebase.storage();
21 |
22 | export default firebase;
23 |
--------------------------------------------------------------------------------
/src/state/store.js:
--------------------------------------------------------------------------------
1 | import { createStore, compose, applyMiddleware } from 'redux';
2 | import { persistStore } from 'redux-persist';
3 | import thunk from 'redux-thunk';
4 |
5 | import rootReducer from './reducers';
6 | import { verifyAuth } from './actions/auth';
7 |
8 | export const configureStore = initialState => {
9 | const middlewares = [];
10 |
11 | const composeEnhancers =
12 | (process.env.NODE_ENV === 'development'
13 | ? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
14 | : null) || compose;
15 |
16 | middlewares.push(applyMiddleware(thunk));
17 |
18 | const store = createStore(
19 | rootReducer,
20 | initialState,
21 | composeEnhancers(...middlewares)
22 | );
23 |
24 | store.dispatch(verifyAuth());
25 |
26 | const persistor = persistStore(store);
27 |
28 | return { store, persistor };
29 | };
30 |
--------------------------------------------------------------------------------
/src/hooks/index.js:
--------------------------------------------------------------------------------
1 | import { useIntl } from 'react-intl';
2 |
3 | const useChangeHandler = setState => {
4 | const onChangeHandler = event => {
5 | const { target } = event;
6 | const value = target.type === 'checkbox' ? target.checked : target.value;
7 | const { name } = target;
8 | setState(prevState => ({ ...prevState, [`${name}`]: value }));
9 | };
10 |
11 | return onChangeHandler;
12 | };
13 |
14 | const useFormatMessage = (
15 | id,
16 | values = {},
17 | defaultMessage = '',
18 | description = ''
19 | ) => {
20 | const intl = useIntl();
21 | return intl.formatMessage({ id, defaultMessage, description }, values);
22 | };
23 |
24 | const useFormatDate = (value, options = {}) => {
25 | const intl = useIntl();
26 | return intl.formatDate(value, options);
27 | };
28 |
29 | export { useChangeHandler, useFormatMessage, useFormatDate };
30 |
--------------------------------------------------------------------------------
/src/pages/Router/PrivateRoute/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Route, Redirect } from 'react-router-dom';
3 | import { useSelector, shallowEqual } from 'react-redux';
4 | import PropTypes from 'prop-types';
5 |
6 | import Layout from 'components/Layout';
7 | import paths from '../paths';
8 |
9 | const PrivateRoute = ({ path, component: Component }) => {
10 | const { id } = useSelector(
11 | state => ({
12 | id: state.auth.userData.id
13 | }),
14 | shallowEqual
15 | );
16 |
17 | return (
18 |
19 | (id ? : )}
23 | />
24 |
25 | );
26 | };
27 |
28 | PrivateRoute.propType = {
29 | path: PropTypes.string.isRequired,
30 | component: PropTypes.element.isRequired
31 | };
32 |
33 | export default PrivateRoute;
34 |
--------------------------------------------------------------------------------
/src/state/reducers/index.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux';
2 | import { persistReducer } from 'redux-persist';
3 | import storage from 'redux-persist/lib/storage';
4 | import { reducer as toastrReducer } from 'react-redux-toastr';
5 |
6 | import { authReducer } from './auth';
7 | import { usersReducer } from './users';
8 | import { preferencesReducer } from './preferences';
9 |
10 | export default combineReducers({
11 | auth: persistReducer(
12 | {
13 | key: 'auth',
14 | storage,
15 | blacklist: ['error', 'loading'],
16 | },
17 | authReducer
18 | ),
19 | preferences: persistReducer(
20 | { key: 'preferences', storage },
21 | preferencesReducer
22 | ),
23 | users: persistReducer(
24 | {
25 | key: 'users',
26 | storage,
27 | blacklist: ['error', 'loading'],
28 | },
29 | usersReducer
30 | ),
31 | toastr: toastrReducer,
32 | });
33 |
--------------------------------------------------------------------------------
/src/components/LanguageWrapper/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { IntlProvider } from 'react-intl';
3 | import { useSelector, shallowEqual, useDispatch } from 'react-redux';
4 |
5 | import { setUserLocale } from 'state/actions/preferences';
6 | import { availableLocales, browserLocale, messages } from 'utils/index';
7 |
8 | const LanguageWrapper = ({ children }) => {
9 | const dispatch = useDispatch();
10 |
11 | let { locale } = useSelector(
12 | (state) => ({
13 | locale: state.preferences.locale,
14 | }),
15 | shallowEqual
16 | );
17 |
18 | if (!locale) {
19 | locale = availableLocales.includes(browserLocale) ? browserLocale : 'en';
20 | dispatch(setUserLocale(locale));
21 | }
22 |
23 | return (
24 |
29 | {children}
30 |
31 | );
32 | };
33 |
34 | export default LanguageWrapper;
35 |
--------------------------------------------------------------------------------
/functions/test/db/users/onDelete.test.ts:
--------------------------------------------------------------------------------
1 | import { admin, test } from '../../util/config';
2 | import * as chai from 'chai';
3 | import * as chaiAsPromised from 'chai-as-promised';
4 | import onDelete from '../../../src/db/users/onDelete.function';
5 | import 'mocha';
6 |
7 | chai.use(chaiAsPromised);
8 |
9 | describe('onDelete Realtime Database', () => {
10 | let userRecord: any;
11 |
12 | it('should delete the user from the authentication section', async () => {
13 | userRecord = await admin.auth().createUser({ email: 'user@example.com' });
14 |
15 | const wrapped = test.wrap(onDelete);
16 |
17 | await wrapped(
18 | {},
19 | {
20 | params: {
21 | uid: userRecord.uid,
22 | },
23 | }
24 | );
25 | return await chai
26 | .expect(admin.auth().getUser(userRecord.uid))
27 | .to.be.rejectedWith(
28 | Error,
29 | 'There is no user record corresponding to the provided identifier.'
30 | );
31 | });
32 | });
33 |
--------------------------------------------------------------------------------
/functions/test/firestore/users/onDelete.test.ts:
--------------------------------------------------------------------------------
1 | import { admin, test } from '../../util/config';
2 | import * as chai from 'chai';
3 | import * as chaiAsPromised from 'chai-as-promised';
4 | import onDelete from '../../../src/firestore/users/onDelete.function';
5 | import 'mocha';
6 |
7 | chai.use(chaiAsPromised);
8 |
9 | describe('onDelete Firestore', () => {
10 | let userRecord: any;
11 |
12 | it('should delete the user from the authentication section', async () => {
13 | userRecord = await admin.auth().createUser({ email: 'user@example.com' });
14 |
15 | const wrapped = test.wrap(onDelete);
16 |
17 | await wrapped(
18 | {},
19 | {
20 | params: {
21 | uid: userRecord.uid,
22 | },
23 | }
24 | );
25 | return await chai
26 | .expect(admin.auth().getUser(userRecord.uid))
27 | .to.be.rejectedWith(
28 | Error,
29 | 'There is no user record corresponding to the provided identifier.'
30 | );
31 | });
32 | });
33 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Desktop (please complete the following information):**
27 | - OS: [e.g. iOS]
28 | - Browser [e.g. chrome, safari]
29 | - Version [e.g. 22]
30 |
31 | **Smartphone (please complete the following information):**
32 | - Device: [e.g. iPhone6]
33 | - OS: [e.g. iOS8.1]
34 | - Browser [e.g. stock browser, safari]
35 | - Version [e.g. 22]
36 |
37 | **Additional context**
38 | Add any other context about the problem here.
39 |
--------------------------------------------------------------------------------
/src/index.scss:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | padding: 0;
4 | }
5 |
6 | .title {
7 | word-break: normal;
8 | }
9 |
10 | .section,
11 | .hero-body {
12 | padding-top: 1.5rem;
13 | padding-bottom: 1.5rem;
14 | }
15 |
16 | .column {
17 | flex-basis: unset;
18 | }
19 |
20 | .card {
21 | height: 100%;
22 | display: flex;
23 | flex-direction: column;
24 | }
25 |
26 | .card-footer {
27 | margin-top: auto;
28 | }
29 |
30 | .redux-toastr .toastr .close-toastr {
31 | opacity: 1;
32 | color: #fff;
33 | }
34 |
35 | .redux-toastr .toastr .rrt-left-container {
36 | width: 75px;
37 | }
38 |
39 | .redux-toastr .toastr .rrt-left-container .toastr-icon {
40 | width: 26px !important;
41 | height: 26px !important;
42 | }
43 |
44 | .redux-toastr .toastr .rrt-middle-container {
45 | display: flex;
46 | flex-direction: column;
47 | justify-content: center;
48 | margin-left: 75px;
49 | min-height: 70px;
50 | }
51 |
52 | .error {
53 | color: red;
54 | }
55 |
56 | .error::before {
57 | display: inline;
58 | content: '⚠ ';
59 | }
60 |
--------------------------------------------------------------------------------
/src/components/Layout/index.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | import NavBar from '../Navigation/NavBar';
5 | import Aside from '../Navigation/Aside';
6 | import Footer from '../Navigation/Footer';
7 |
8 | import classes from './Layout.module.scss';
9 |
10 | const Layout = ({ children }) => {
11 | const [asideMobileActive, setAsideMobileActive] = useState(false);
12 |
13 | const handleMobileToggle = () => {
14 | document.documentElement.classList.toggle('has-aside-mobile-expanded');
15 | setAsideMobileActive(!asideMobileActive);
16 | };
17 |
18 | return (
19 | <>
20 |
24 |
25 | {children}
26 |
27 | >
28 | );
29 | };
30 |
31 | Layout.propTypes = {
32 | children: PropTypes.node.isRequired,
33 | };
34 |
35 | export default Layout;
36 |
--------------------------------------------------------------------------------
/src/components/Navigation/Link/Link.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { fireEvent } from '@testing-library/react';
3 | import '@testing-library/jest-dom';
4 |
5 | import Link from '.';
6 |
7 | describe(' rendering', () => {
8 | it('should render without crashing', () => {
9 | const { component } = renderWithProviders(Test);
10 |
11 | expect(component).toMatchSnapshot();
12 | });
13 |
14 | it('should render the component correctly', () => {
15 | const { component } = renderWithProviders(Test)({
16 | auth: { userData: { id: 'testId' } },
17 | });
18 |
19 | expect(component.getByText('Test')).toBeTruthy();
20 | });
21 |
22 | it('should set the correct url to the component', () => {
23 | const { component } = renderWithProviders(Test)({
24 | auth: { userData: { id: 'testId' } },
25 | });
26 |
27 | fireEvent.click(component.getByText('Test'));
28 |
29 | expect(window.location.pathname).toBe('/url');
30 | });
31 | });
32 |
--------------------------------------------------------------------------------
/src/pages/Profile/Profile.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import * as reactRedux from 'react-redux';
3 |
4 | import * as actions from 'state/actions/users';
5 | import Profile from '.';
6 |
7 | describe(' rendering', () => {
8 | const dispatchMock = jest.fn();
9 |
10 | beforeEach(() => {
11 | jest
12 | .spyOn(reactRedux, 'useDispatch')
13 | .mockImplementation(() => dispatchMock);
14 | jest.spyOn(actions, 'usersCleanUp').mockImplementation(jest.fn);
15 | });
16 |
17 | it('should render without crashing', () => {
18 | const { component } = renderWithProviders()({
19 | users: {
20 | data: [],
21 | },
22 | auth: {
23 | userData: {
24 | email: 'test@test.com',
25 | name: 'Test',
26 | location: 'Montevideo, Uruguay',
27 | isAdmin: false,
28 | file: null,
29 | id: 'test id',
30 | logoUrl: 'some logoUrl',
31 | createdAt: '11/12/2020',
32 | },
33 | },
34 | });
35 |
36 | expect(component.asFragment()).toMatchSnapshot();
37 | });
38 | });
39 |
--------------------------------------------------------------------------------
/src/pages/Router/PrivateRoute/PrivateRoute.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import * as reactRedux from 'react-redux';
3 |
4 | import * as actions from 'state/actions/auth';
5 | import PrivateRoute from '.';
6 | import paths from '../paths';
7 |
8 | describe(' rendering', () => {
9 | const dispatchMock = jest.fn();
10 |
11 | beforeEach(() => {
12 | jest
13 | .spyOn(reactRedux, 'useDispatch')
14 | .mockImplementation(() => dispatchMock);
15 | jest
16 | .spyOn(reactRedux, 'useSelector')
17 | .mockImplementation(() => dispatchMock);
18 | jest.spyOn(actions, 'auth').mockImplementation(jest.fn);
19 | });
20 |
21 | it('should render without crashing', () => {
22 | const { component } = renderWithProviders()({
23 | user: {},
24 | });
25 |
26 | expect(component.asFragment()).toMatchSnapshot();
27 | });
28 |
29 | it('should redirect to /login when the user is not authenticated', () => {
30 | renderWithProviders()({
31 | user: {},
32 | });
33 | expect(window.location.pathname).toBe(paths.LOGIN);
34 | });
35 | });
36 |
--------------------------------------------------------------------------------
/src/components/Navigation/Footer/index.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import classNames from 'classnames';
3 |
4 | import classes from './Footer.module.scss';
5 |
6 | const Footer = () => {
7 | return (
8 |
31 | );
32 | };
33 |
34 | export default Footer;
35 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 CreateThrive
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/components/ConfirmationModal/__snapshots__/ConfirmationModal.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` rendering should render without crashing 1`] = `
4 |
5 |
8 |
11 |
14 |
17 |
20 | test title
21 |
22 |
23 |
28 |
44 |
45 |
46 |
47 | `;
48 |
--------------------------------------------------------------------------------
/src/components/DatePicker/DatePicker.scss:
--------------------------------------------------------------------------------
1 | .react-datepicker__header {
2 | background: none;
3 | }
4 |
5 | .react-datepicker__input-container {
6 | input {
7 | background-color: white;
8 | border-radius: 4px;
9 | color: #363636;
10 | border: 1px solid #dbdbdb;
11 | font-size: 1rem;
12 | height: 2.25em;
13 | line-height: 1.5;
14 | padding-bottom: calc(0.375em - 1px);
15 | padding-left: calc(0.625em - 1px);
16 | padding-right: calc(0.625em - 1px);
17 | padding-top: calc(0.375em - 1px);
18 | }
19 |
20 | input:hover {
21 | border-color: #b5b5b5;
22 | }
23 | }
24 |
25 | .react-datepicker {
26 | font-family: inherit;
27 | background-color: white;
28 | border-radius: 4px;
29 | color: #363636;
30 | border-color: #dbdbdb;
31 | }
32 |
33 | .react-datepicker__day {
34 | color: #4a4a4a;
35 | }
36 |
37 | .react-datepicker__day-name {
38 | color: #7a7a7a;
39 | font-weight: 600;
40 | }
41 |
42 | .react-datepicker__current-month {
43 | color: #363636;
44 | font-weight: 400;
45 | }
46 |
47 | .react-datepicker__header {
48 | border-bottom: 1px solid #dbdbdb;
49 | }
50 |
51 | .react-datepicker__day--selected {
52 | color: white;
53 | }
54 |
--------------------------------------------------------------------------------
/functions/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "functions",
3 | "scripts": {
4 | "lint": "tslint --project tsconfig.json",
5 | "build": "tsc",
6 | "serve": "npm run build && firebase emulators:start --only functions",
7 | "shell": "npm run build && firebase functions:shell",
8 | "start": "npm run shell",
9 | "deploy": "firebase deploy --only functions",
10 | "logs": "firebase functions:log",
11 | "setup-firebase": "node setupProject.js",
12 | "test": "mocha -r ts-node/register --recursive 'test/**/*.test.ts' --timeout 10000 --exit"
13 | },
14 | "engines": {
15 | "node": "10"
16 | },
17 | "main": "lib/index.js",
18 | "dependencies": {
19 | "camelcase": "^6.0.0",
20 | "firebase-admin": "^9.4.1",
21 | "firebase-function-tools": "^1.1.4",
22 | "firebase-functions": "^3.12.0",
23 | "glob": "^7.1.6",
24 | "inquirer": "^7.3.3"
25 | },
26 | "devDependencies": {
27 | "@types/chai": "^4.2.12",
28 | "@types/chai-as-promised": "^7.1.3",
29 | "@types/mocha": "^8.0.4",
30 | "chai": "^4.2.0",
31 | "chai-as-promised": "^7.1.1",
32 | "firebase-functions-test": "^0.2.3",
33 | "mocha": "^8.2.1",
34 | "ts-node": "^9.1.1",
35 | "tslint": "^6.1.3",
36 | "typescript": "^4.1.2"
37 | },
38 | "private": true
39 | }
40 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { Provider } from 'react-redux';
4 | import { PersistGate } from 'redux-persist/integration/react';
5 | import ReduxToastr from 'react-redux-toastr';
6 |
7 | import LanguageWrapper from 'components/LanguageWrapper';
8 | import 'react-redux-toastr/lib/css/react-redux-toastr.min.css';
9 | import { configureStore } from './state/store';
10 | import './index.scss';
11 | import Router from './pages/Router';
12 | import * as serviceWorker from './serviceWorker';
13 |
14 | import './assets/css/main.css';
15 |
16 | const { store, persistor } = configureStore({});
17 |
18 | const app = (
19 |
20 |
21 |
22 | state.toastr}
27 | transitionIn="fadeIn"
28 | transitionOut="fadeOut"
29 | progressBar
30 | closeOnToastrClick
31 | />
32 |
33 |
34 |
35 |
36 | );
37 |
38 | ReactDOM.render(app, document.getElementById('root'));
39 |
40 | serviceWorker.unregister();
41 |
--------------------------------------------------------------------------------
/src/components/Navigation/Footer/__snapshots__/Footer.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` rendering should render without crashing 1`] = `
4 |
5 |
49 |
50 | `;
51 |
--------------------------------------------------------------------------------
/src/state/api/rtdb.js:
--------------------------------------------------------------------------------
1 | import firebase from 'firebase.js';
2 |
3 | const getRealTimeRef = (path) => firebase.database().ref(path);
4 |
5 | export const fetchDocument = async (collection, id) => {
6 | const document = (
7 | await getRealTimeRef(`${collection}/${id}`).once(`value`)
8 | ).val();
9 |
10 | return document ? { id, ...document } : null;
11 | };
12 |
13 | export const fetchCollection = async (collection, options = {}) => {
14 | let baseQuery = getRealTimeRef(collection);
15 |
16 | if (options.filterBy) {
17 | const { filterBy, value } = options;
18 | baseQuery = baseQuery.orderByChild(filterBy).equalTo(value);
19 | }
20 |
21 | const fetchedCollection = (await baseQuery.once('value')).val();
22 |
23 | const data = fetchedCollection
24 | ? Object.entries(fetchedCollection).map(([key, value]) => ({
25 | id: key,
26 | ...value,
27 | }))
28 | : [];
29 |
30 | return data;
31 | };
32 |
33 | export const deleteDocument = (collection, id) => {
34 | return getRealTimeRef(`${collection}/${id}`).remove();
35 | };
36 |
37 | export const createDocument = (collection, id, values) => {
38 | return getRealTimeRef(`${collection}/${id}`).set(values);
39 | };
40 |
41 | export const updateDocument = (collection, id, values) => {
42 | return getRealTimeRef(`${collection}/${id}`).update(values);
43 | };
44 |
--------------------------------------------------------------------------------
/functions/test/https/createUser.test.ts:
--------------------------------------------------------------------------------
1 | import { admin, test } from '../util/config';
2 | import { https } from 'firebase-functions';
3 | import * as chai from 'chai';
4 | import * as chaiAsPromised from 'chai-as-promised';
5 | import * as createUser from '../../src/https/createUser.function';
6 | import 'mocha';
7 |
8 | chai.use(chaiAsPromised);
9 |
10 | describe('createUser', () => {
11 | let userRecord: any;
12 |
13 | after(async () => {
14 | await admin.auth().deleteUser(userRecord.uid);
15 | });
16 |
17 | it('should throw an error because the email is not provided', () => {
18 | const wrapped = test.wrap(createUser.default);
19 |
20 | const data = {
21 | email: '',
22 | isAdmin: false
23 | };
24 |
25 | return chai
26 | .expect(wrapped(data))
27 | .to.be.rejectedWith(https.HttpsError, 'auth/invalid-email');
28 | });
29 |
30 | it('should create the user in auth with correct email and custom claims', () => {
31 | const wrapped = test.wrap(createUser.default);
32 |
33 | const data = {
34 | email: 'user@example.com',
35 | isAdmin: false
36 | };
37 |
38 | return wrapped(data).then(async (res: any) => {
39 | userRecord = await admin.auth().getUser(res.uid);
40 |
41 | chai.assert.equal(data.email, userRecord.email);
42 | chai.assert.equal(data.isAdmin, userRecord.customClaims!.isAdmin);
43 | });
44 | });
45 | });
46 |
--------------------------------------------------------------------------------
/src/components/DatePicker/DatePicker.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import DatePickerStyled from '.';
4 |
5 | describe(' rendering', () => {
6 | const onChange = jest.fn();
7 |
8 | it('should render without crashing', () => {
9 | const { component } = renderWithProviders(
10 |
16 | )({});
17 |
18 | expect(component.asFragment()).toMatchSnapshot();
19 | });
20 |
21 | it('should render component correctly', () => {
22 | const { component } = renderWithProviders(
23 |
29 | )({});
30 |
31 | expect(component.container.querySelector('input')).toBeTruthy();
32 | });
33 |
34 | it('should pass the date prop to correctly', () => {
35 | const { component } = renderWithProviders(
36 |
42 | )({});
43 |
44 | expect(component.container.querySelector('input').value).toBe('11-12-20');
45 | });
46 | });
47 |
--------------------------------------------------------------------------------
/src/components/DatePicker/index.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import DatePicker, { registerLocale } from 'react-datepicker';
3 | import 'react-datepicker/dist/react-datepicker.css';
4 | import PropTypes from 'prop-types';
5 | import es from 'date-fns/locale/es';
6 | import en from 'date-fns/locale/en-US';
7 | import { shallowEqual, useSelector } from 'react-redux';
8 |
9 | import './DatePicker.scss';
10 |
11 | registerLocale('en', en);
12 | registerLocale('es', es);
13 |
14 | const dateFormat = (locale) => {
15 | switch (locale) {
16 | case 'en':
17 | return 'MM-dd-yy';
18 | case 'es':
19 | return 'dd/MM/yy';
20 | default:
21 | return 'MM-dd-yy';
22 | }
23 | };
24 |
25 | const DatePickerStyled = ({ date, onChange }) => {
26 | const onDateChangedHandler = (value) =>
27 | onChange(value ? value.toDateString() : new Date().toDateString());
28 |
29 | const { locale } = useSelector(
30 | (state) => ({
31 | locale: state.preferences.locale,
32 | }),
33 | shallowEqual
34 | );
35 |
36 | return (
37 |
43 | );
44 | };
45 |
46 | DatePickerStyled.propTypes = {
47 | date: PropTypes.instanceOf(Date).isRequired,
48 | onChange: PropTypes.func.isRequired,
49 | };
50 |
51 | export default DatePickerStyled;
52 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "airbnb",
4 | "prettier",
5 | "prettier/react",
6 | "plugin:jest/recommended"
7 | ],
8 | "plugins": ["prettier", "jest"],
9 | "rules": {
10 | "react/jsx-filename-extension": [
11 | 1,
12 | {
13 | "extensions": [".js", ".jsx"]
14 | }
15 | ],
16 | "react/prop-types": 0,
17 | "no-underscore-dangle": 0,
18 | "import/imports-first": ["error", "absolute-first"],
19 | "import/newline-after-import": "error",
20 | "import/prefer-default-export": 0,
21 | "semi": "error",
22 | "jsx-a11y/click-events-have-key-events": 0,
23 | "jsx-a11y/no-static-element-interactions": 0,
24 | "jsx-a11y/anchor-is-valid": 0,
25 | "react/require-default-props": 0,
26 | "jest/expect-expect": 0,
27 | "import/no-cycle": 0,
28 | "react/button-has-type": 0
29 | },
30 | "globals": {
31 | "window": true,
32 | "document": true,
33 | "localStorage": true,
34 | "FormData": true,
35 | "FileReader": true,
36 | "Blob": true,
37 | "navigator": true,
38 | "Headers": true,
39 | "Request": true,
40 | "fetch": true,
41 | "reducerTester": true,
42 | "renderWithProviders": true
43 | },
44 | "parser": "babel-eslint",
45 | "settings": {
46 | "import/resolver": {
47 | "node": {
48 | "paths": ["src"]
49 | }
50 | }
51 | },
52 | "env": {
53 | "mocha": true
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/state/api/firestore.js:
--------------------------------------------------------------------------------
1 | import firebase from 'firebase.js';
2 |
3 | const getFirestoreRef = (path) => firebase.firestore().collection(path);
4 |
5 | export const fetchDocument = async (collection, id) => {
6 | const document = await getFirestoreRef(collection).doc(id).get();
7 | if (!document.exists) {
8 | return null;
9 | }
10 |
11 | return { id: document.id, ...document.data() };
12 | };
13 |
14 | export const fetchCollection = async (collection, options = {}) => {
15 | const data = [];
16 | let baseQuery = getFirestoreRef(collection);
17 |
18 | if (options.queries) {
19 | const { queries } = options;
20 | queries.forEach(({ attribute, operator, value }) => {
21 | baseQuery = baseQuery.where(attribute, operator, value);
22 | });
23 | }
24 |
25 | if (options.sort) {
26 | const { attribute, order } = options.sort;
27 | baseQuery = baseQuery.orderBy(attribute, order);
28 | }
29 | (await baseQuery.get()).forEach((doc) =>
30 | data.push({ id: doc.id, ...doc.data() })
31 | );
32 |
33 | return data;
34 | };
35 |
36 | export const deleteDocument = (collection, id) => {
37 | return getFirestoreRef(collection).doc(id).delete();
38 | };
39 |
40 | export const createDocument = (collection, id, values) => {
41 | return getFirestoreRef(collection).doc(id).set(values);
42 | };
43 |
44 | export const updateDocument = (collection, id, values) => {
45 | return getFirestoreRef(collection).doc(id).update(values);
46 | };
47 |
--------------------------------------------------------------------------------
/src/pages/NotFound/__snapshots__/NotFound.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` rendering should render without crashing 1`] = `
4 |
5 |
8 |
11 |
14 |
17 |
20 |
23 |
26 | Error 404: page not found
27 |
28 |
31 | The requested URL / was not found
32 |
33 |
37 | Go Back
38 |
39 |
40 |
43 |

47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 | `;
55 |
--------------------------------------------------------------------------------
/src/pages/Login/Login.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import * as reactRedux from 'react-redux';
3 |
4 | import * as actions from 'state/actions/auth';
5 | import paths from '../Router/paths';
6 | import Login from '.';
7 |
8 | jest.mock('react-firebaseui');
9 |
10 | describe(' rendering', () => {
11 | const dispatchMock = jest.fn();
12 |
13 | beforeEach(() => {
14 | jest
15 | .spyOn(reactRedux, 'useDispatch')
16 | .mockImplementation(() => dispatchMock);
17 | jest.spyOn(actions, 'authCleanUp').mockImplementation(jest.fn);
18 | });
19 |
20 | it('should render without crashing', () => {
21 | const { component } = renderWithProviders()({
22 | auth: {
23 | userData: {},
24 | },
25 | });
26 |
27 | expect(component.asFragment()).toMatchSnapshot();
28 | });
29 |
30 | it('should display an error message when there is an error', () => {
31 | const { component } = renderWithProviders()({
32 | auth: {
33 | userData: {},
34 | error: 'sample error',
35 | },
36 | });
37 |
38 | expect(component.container.querySelector('p.has-text-danger')).toBeTruthy();
39 | });
40 |
41 | it('should redirect to /home when the user is authenticated', () => {
42 | renderWithProviders()({
43 | auth: {
44 | userData: {
45 | id: 'some userId',
46 | },
47 | },
48 | });
49 |
50 | expect(window.location.pathname).toBe(paths.ROOT);
51 | });
52 | });
53 |
--------------------------------------------------------------------------------
/src/pages/Router/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Route, Switch, BrowserRouter } from 'react-router-dom';
3 |
4 | import Login from 'pages/Login';
5 | import Home from 'pages/Home';
6 | import Users from 'pages/Users';
7 | import Profile from 'pages/Profile';
8 | import ResetPassword from 'pages/ResetPassword';
9 | import NotFound from 'pages/NotFound';
10 | import User from 'pages/User';
11 | import Section from 'pages/Section';
12 | import Submenu from 'pages/Submenu';
13 | import paths from './paths';
14 | import PrivateRoute from './PrivateRoute';
15 |
16 | const RouterComponent = () => {
17 | return (
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | );
34 | };
35 |
36 | export default RouterComponent;
37 |
--------------------------------------------------------------------------------
/src/pages/User/User.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import * as reactRedux from 'react-redux';
3 |
4 | import * as actions from 'state/actions/users';
5 | import User from '.';
6 |
7 | describe(' rendering', () => {
8 | const dispatchMock = jest.fn();
9 | const mockDate = new Date(1605668400000);
10 |
11 | beforeEach(() => {
12 | jest
13 | .spyOn(reactRedux, 'useDispatch')
14 | .mockImplementation(() => dispatchMock);
15 | jest.spyOn(actions, 'usersCleanUp').mockImplementation(jest.fn);
16 | jest.spyOn(global, 'Date').mockImplementation(() => mockDate);
17 | Date.now = jest.fn(() => mockDate.getTime());
18 | });
19 |
20 | it('should render without crashing', () => {
21 | const { component } = renderWithProviders()({
22 | users: {
23 | data: [],
24 | },
25 | });
26 |
27 | expect(component.asFragment()).toMatchSnapshot();
28 | });
29 |
30 | it('should not show the spinner when creating a user', () => {
31 | const { component } = renderWithProviders()({
32 | users: {
33 | data: [],
34 | },
35 | });
36 |
37 | expect(component.container.querySelector('.spinner')).toBeFalsy();
38 | });
39 |
40 | it('should render the UserForm component when creating a user', () => {
41 | const { component } = renderWithProviders()({
42 | users: {
43 | data: [],
44 | },
45 | });
46 |
47 | expect(component.getByText('User Information')).toBeTruthy();
48 | });
49 | });
50 |
--------------------------------------------------------------------------------
/src/pages/NotFound/NotFound.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { fireEvent } from '@testing-library/react';
4 | import NotFound from '.';
5 | import paths from '../Router/paths';
6 |
7 | describe(' rendering', () => {
8 | const location = {
9 | pathname: '/',
10 | };
11 |
12 | it('should render without crashing', () => {
13 | const { component } = renderWithProviders()(
14 | {
15 | auth: {
16 | userData: {},
17 | },
18 | }
19 | );
20 |
21 | expect(component.asFragment()).toMatchSnapshot();
22 | });
23 |
24 | it('should display the button with the login path if user is not authenticated', () => {
25 | const { component } = renderWithProviders()(
26 | {
27 | auth: {
28 | userData: {
29 | id: null,
30 | },
31 | },
32 | }
33 | );
34 | fireEvent.click(component.getByRole('link'));
35 | expect(window.location.pathname).toBe(paths.LOGIN);
36 | });
37 |
38 | it('should display the button with the home path if user is authenticated', () => {
39 | const { component } = renderWithProviders()(
40 | {
41 | auth: {
42 | userData: {
43 | id: 'some userId',
44 | },
45 | },
46 | }
47 | );
48 |
49 | fireEvent.click(component.getByRole('link'));
50 | expect(window.location.pathname).toBe(paths.ROOT);
51 | });
52 | });
53 |
--------------------------------------------------------------------------------
/functions/test/db/users/onUpdate.test.ts:
--------------------------------------------------------------------------------
1 | import { admin, test } from '../../util/config';
2 | import * as chai from 'chai';
3 | import onUpdate from '../../../src/db/users/onUpdate.function';
4 | import 'mocha';
5 |
6 | describe('onUpdate Realtime Database', () => {
7 | let userRecord: any;
8 |
9 | before(async () => {
10 | const user = {
11 | email: 'user@example.com',
12 | password: 'secretPassword',
13 | };
14 | const customClaims = {
15 | isAdmin: false,
16 | };
17 |
18 | userRecord = await admin.auth().createUser(user);
19 | await admin.auth().setCustomUserClaims(userRecord.uid, customClaims);
20 | });
21 |
22 | after(async () => {
23 | await admin.auth().deleteUser(userRecord.uid);
24 | });
25 |
26 | it("should update user's custom claims in auth", async () => {
27 | const wrapped = test.wrap(onUpdate);
28 |
29 | const beforeSnap = test.database.makeDataSnapshot(
30 | { email: 'user@example.com', isAdmin: false },
31 | `users/${userRecord.uid}`
32 | );
33 | const afterSnap = test.database.makeDataSnapshot(
34 | { email: 'user@example.com', isAdmin: true },
35 | `users/${userRecord.uid}`
36 | );
37 |
38 | const change = test.makeChange(beforeSnap, afterSnap);
39 |
40 | await wrapped(change, {
41 | params: {
42 | uid: userRecord.uid,
43 | },
44 | });
45 | return admin
46 | .auth()
47 | .getUser(userRecord.uid)
48 | .then((snap) => {
49 | chai.assert.isTrue(snap.customClaims!.isAdmin);
50 | });
51 | });
52 | });
53 |
--------------------------------------------------------------------------------
/src/setupTests.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-extraneous-dependencies */
2 | import React from 'react';
3 | import deepFreeze from 'deep-freeze';
4 | import { render } from '@testing-library/react';
5 | import { BrowserRouter } from 'react-router-dom';
6 | import { Provider } from 'react-redux';
7 | import configureStore from 'redux-mock-store';
8 | import { IntlProvider } from 'react-intl';
9 | import english from 'languages/en';
10 | import 'mutationobserver-shim';
11 |
12 | global.MutationObserver = window.MutationObserver;
13 |
14 | global.reducerTester = (reducer) => (currentState, action, expectedState) => {
15 | if (currentState && typeof currentState === 'object') {
16 | deepFreeze(currentState);
17 | }
18 | const newState = reducer(currentState, action);
19 | return expect(newState).toEqual(expectedState);
20 | };
21 |
22 | const mockedStore = (initial = {}) =>
23 | configureStore([
24 | /* place middlewares here */
25 | ])(initial);
26 |
27 | const initStore = (initialState) =>
28 | mockedStore({
29 | ...initialState,
30 | preferences: { locale: 'en' },
31 | });
32 |
33 | // Use this to test mounted components w/ store connection
34 | global.renderWithProviders = (children) => (initialState) => {
35 | const store = initStore(initialState);
36 | return {
37 | component: render(
38 |
39 |
40 | {children}
41 |
42 |
43 | ),
44 | store,
45 | };
46 | };
47 |
--------------------------------------------------------------------------------
/functions/test/firestore/users/onUpdate.test.ts:
--------------------------------------------------------------------------------
1 | import { admin, test } from '../../util/config';
2 | import * as chai from 'chai';
3 | import onUpdate from '../../../src/firestore/users/onUpdate.function';
4 | import 'mocha';
5 |
6 | describe('onUpdate Firestore', () => {
7 | let userRecord: any;
8 |
9 | before(async () => {
10 | const user = {
11 | email: 'user@example.com',
12 | password: 'secretPassword',
13 | };
14 | const customClaims = {
15 | isAdmin: false,
16 | };
17 |
18 | userRecord = await admin.auth().createUser(user);
19 | await admin.auth().setCustomUserClaims(userRecord.uid, customClaims);
20 | });
21 |
22 | after(async () => {
23 | await admin.auth().deleteUser(userRecord.uid);
24 | });
25 |
26 | it("should update user's custom claims in auth", async () => {
27 | const wrapped = test.wrap(onUpdate);
28 |
29 | const beforeSnap = test.firestore.makeDocumentSnapshot(
30 | { email: 'user@example.com', isAdmin: false },
31 | `users/${userRecord.uid}`
32 | );
33 | const afterSnap = test.firestore.makeDocumentSnapshot(
34 | { email: 'user@example.com', isAdmin: true },
35 | `users/${userRecord.uid}`
36 | );
37 |
38 | const change = test.makeChange(beforeSnap, afterSnap);
39 |
40 | await wrapped(change, {
41 | params: {
42 | uid: userRecord.uid,
43 | },
44 | });
45 | return admin
46 | .auth()
47 | .getUser(userRecord.uid)
48 | .then((snap) => {
49 | chai.assert.isTrue(snap.customClaims!.isAdmin);
50 | });
51 | });
52 | });
53 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 |
8 | # Runtime data
9 | pids
10 | *.pid
11 | *.seed
12 | *.pid.lock
13 |
14 | # Directory for instrumented libs generated by jscoverage/JSCover
15 | lib-cov
16 |
17 | # Coverage directory used by tools like istanbul
18 | coverage
19 |
20 | # nyc test coverage
21 | .nyc_output
22 |
23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
24 | .grunt
25 |
26 | # Bower dependency directory (https://bower.io/)
27 | bower_components
28 |
29 | # node-waf configuration
30 | .lock-wscript
31 |
32 | # Compiled binary addons (https://nodejs.org/api/addons.html)
33 | build/Release
34 |
35 | # Dependency directories
36 | node_modules/
37 | jspm_packages/
38 |
39 | # TypeScript v1 declaration files
40 | typings/
41 |
42 | # Optional npm cache directory
43 | .npm
44 |
45 | # Optional eslint cache
46 | .eslintcache
47 |
48 | # Optional REPL history
49 | .node_repl_history
50 |
51 | # Output of 'npm pack'
52 | *.tgz
53 |
54 | # Yarn Integrity file
55 | .yarn-integrity
56 |
57 | # dotenv environment variables file
58 | .env
59 |
60 | # next.js build output
61 | .next
62 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
63 |
64 | # dependencies
65 | /node_modules
66 | /.pnp
67 | .pnp.js
68 |
69 | # testing
70 | /coverage
71 |
72 | # production
73 | /build
74 |
75 | # misc
76 | .DS_Store
77 | .env.local
78 | .env.development.local
79 | .env.test.local
80 | .env.production.local
81 |
82 | npm-debug.log*
83 | yarn-debug.log*
84 | yarn-error.log*
85 |
86 | .firebase
87 |
88 | # service account key
89 | serviceAccountKey.json
90 |
--------------------------------------------------------------------------------
/src/pages/Profile/index.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useSelector, shallowEqual, useDispatch } from 'react-redux';
3 | import * as yup from 'yup';
4 |
5 | import { useFormatMessage } from 'hooks';
6 | import UserForm from 'components/UserForm';
7 | import { modifyUser } from 'state/actions/users';
8 | import ChangePassword from './ChangePassword';
9 |
10 | const schema = yup.object().shape({
11 | name: yup.string().required(),
12 | isAdmin: yup.boolean().notRequired(),
13 | location: yup.string().notRequired(),
14 | createdAt: yup.string().required(),
15 | });
16 |
17 | const Profile = () => {
18 | const { userData } = useSelector(
19 | (state) => ({
20 | userData: state.auth.userData,
21 | }),
22 | shallowEqual
23 | );
24 |
25 | const dispatch = useDispatch();
26 |
27 | const onSubmitHandler = (value) => {
28 | const newUser = {
29 | ...value,
30 | file: value?.file[0] || null,
31 | isEditing: true,
32 | isProfile: true,
33 | id: userData.id,
34 | };
35 | dispatch(modifyUser(newUser));
36 | };
37 |
38 | return (
39 | <>
40 |
41 |
42 |
{useFormatMessage('Profile.profile')}
43 |
44 |
45 |
55 | >
56 | );
57 | };
58 |
59 | export default Profile;
60 |
--------------------------------------------------------------------------------
/src/pages/NotFound/index.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Link, useLocation } from 'react-router-dom';
3 | import { useSelector, shallowEqual } from 'react-redux';
4 |
5 | import { useFormatMessage } from 'hooks';
6 | import path from 'pages/Router/paths';
7 | import NotFoudImage from 'assets/404.gif';
8 | import classes from './NotFound.module.scss';
9 |
10 | const NotFound = () => {
11 | const location = useLocation();
12 |
13 | const { isAuth } = useSelector(
14 | state => ({
15 | isAuth: !!state.auth.userData.id
16 | }),
17 | shallowEqual
18 | );
19 |
20 | const userPath = isAuth ? path.ROOT : path.LOGIN;
21 |
22 | return (
23 |
24 |
25 |
26 |
27 |
28 |
29 |
{useFormatMessage('NotFound.404')}
30 |
31 | {useFormatMessage('NotFound.url', { url: location.pathname })}
32 |
33 |
34 | {useFormatMessage('NotFound.back')}
35 |
36 |
37 |
38 |

39 |
40 |
41 |
42 |
43 |
44 |
45 | );
46 | };
47 |
48 | export default NotFound;
49 |
--------------------------------------------------------------------------------
/.github/workflows/production-deployment.yml:
--------------------------------------------------------------------------------
1 | name: ci
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 |
8 | jobs:
9 | build-test-deploy:
10 | runs-on: ubuntu-latest
11 |
12 | steps:
13 | - uses: actions/checkout@v2.3.2
14 | - name: Setup Node.js environment
15 | uses: actions/setup-node@v2-beta
16 | with:
17 | node-version: '12.x'
18 | - name: npm install, build, and test
19 | run: |
20 | npm ci
21 | cd functions
22 | npm ci
23 | cd ..
24 | npm run build
25 | env:
26 | REACT_APP_FIRE_BASE_KEY: ${{ secrets.FIRE_BASE_KEY_STAGING }}
27 | REACT_APP_FIRE_BASE_AUTH_DOMAIN: ${{ secrets.FIRE_BASE_AUTH_DOMAIN_STAGING }}
28 | REACT_APP_FIRE_BASE_DB_URL: ${{ secrets.FIRE_BASE_DB_URL_STAGING }}
29 | REACT_APP_FIRE_BASE_PROJECT_ID: ${{ secrets.FIRE_BASE_PROJECT_ID_STAGING }}
30 | REACT_APP_FIRE_BASE_STORAGE_BUCKET: ${{ secrets.FIRE_BASE_STORAGE_BUCKET_STAGING }}
31 | REACT_APP_FIRE_BASE_MESSAGING_SENDER_ID: ${{ secrets.FIRE_BASE_MESSAGING_SENDER_ID_STAGING }}
32 | REACT_APP_FIRE_BASE_APP_ID: ${{ secrets.FIRE_BASE_APP_ID_STAGING }}
33 | REACT_APP_FIRE_BASE_MEASURMENT_ID: ${{ secrets.FIRE_BASE_MEASURMENT_ID_STAGING }}
34 | REACT_APP_CLOUD_FUNCTIONS_REST_API: ${{ secrets.CLOUD_FUNCTIONS_REST_API_STAGING }}
35 | REACT_APP_LOGIN_PAGE_URL: ${{ secrets.LOGIN_PAGE_URL_STAGING }}
36 | CI: ''
37 | - name: Firebase deployment
38 | run: |
39 | npm install -g firebase-tools
40 | firebase deploy -P staging --token $FIREBASE_TOKEN
41 | env:
42 | FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}
43 |
--------------------------------------------------------------------------------
/.github/workflows/staging-deployment.yml:
--------------------------------------------------------------------------------
1 | name: Staging CI
2 |
3 | on:
4 | push:
5 | branches:
6 | - development
7 |
8 | jobs:
9 | build-test-deploy:
10 | runs-on: ubuntu-latest
11 |
12 | steps:
13 | - uses: actions/checkout@v2.3.2
14 | - name: Setup Node.js environment
15 | uses: actions/setup-node@v2-beta
16 | with:
17 | node-version: '12.x'
18 | - name: npm install, build, and test
19 | run: |
20 | npm ci
21 | cd functions
22 | npm ci
23 | cd ..
24 | npm run build
25 | env:
26 | REACT_APP_FIRE_BASE_KEY: ${{ secrets.FIRE_BASE_KEY_STAGING }}
27 | REACT_APP_FIRE_BASE_AUTH_DOMAIN: ${{ secrets.FIRE_BASE_AUTH_DOMAIN_STAGING }}
28 | REACT_APP_FIRE_BASE_DB_URL: ${{ secrets.FIRE_BASE_DB_URL_STAGING }}
29 | REACT_APP_FIRE_BASE_PROJECT_ID: ${{ secrets.FIRE_BASE_PROJECT_ID_STAGING }}
30 | REACT_APP_FIRE_BASE_STORAGE_BUCKET: ${{ secrets.FIRE_BASE_STORAGE_BUCKET_STAGING }}
31 | REACT_APP_FIRE_BASE_MESSAGING_SENDER_ID: ${{ secrets.FIRE_BASE_MESSAGING_SENDER_ID_STAGING }}
32 | REACT_APP_FIRE_BASE_APP_ID: ${{ secrets.FIRE_BASE_APP_ID_STAGING }}
33 | REACT_APP_FIRE_BASE_MEASURMENT_ID: ${{ secrets.FIRE_BASE_MEASURMENT_ID_STAGING }}
34 | REACT_APP_CLOUD_FUNCTIONS_REST_API: ${{ secrets.CLOUD_FUNCTIONS_REST_API_STAGING }}
35 | REACT_APP_LOGIN_PAGE_URL: ${{ secrets.LOGIN_PAGE_URL_STAGING }}
36 | CI: ''
37 | - name: Firebase deployment
38 | run: |
39 | npm install -g firebase-tools
40 | firebase deploy -P staging --token $FIREBASE_TOKEN
41 | env:
42 | FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}
43 |
--------------------------------------------------------------------------------
/src/components/Layout/Layout.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import * as reactRedux from 'react-redux';
3 |
4 | import Layout from '.';
5 |
6 | describe(' rendering', () => {
7 | const dispatchMock = jest.fn();
8 |
9 | beforeEach(() => {
10 | jest
11 | .spyOn(reactRedux, 'useSelector')
12 | .mockImplementation(() => dispatchMock);
13 | });
14 |
15 | it('should render without crashing', () => {
16 | const { component } = renderWithProviders(Test)({});
17 |
18 | expect(component.asFragment()).toMatchSnapshot();
19 | });
20 |
21 | it('should render component correctly', () => {
22 | const { component } = renderWithProviders(Test)({
23 | auth: {
24 | userData: {},
25 | },
26 | });
27 |
28 | expect(component.container.querySelector('.navbar-brand')).toBeTruthy();
29 | });
30 |
31 | it('should render component correctly', () => {
32 | const { component } = renderWithProviders(Test)({
33 | auth: {
34 | userData: {},
35 | },
36 | });
37 |
38 | expect(component.container.querySelector('.aside')).toBeTruthy();
39 | });
40 |
41 | it('should render component correctly', () => {
42 | const { component } = renderWithProviders(Test)({
43 | auth: {
44 | userData: {},
45 | },
46 | });
47 |
48 | expect(component.container.querySelector('.footer')).toBeTruthy();
49 | });
50 |
51 | it('should render a div with the children', () => {
52 | const { component } = renderWithProviders(Test)({
53 | auth: {
54 | userData: {},
55 | },
56 | });
57 |
58 | expect(component.getByText('Test')).toBeTruthy();
59 | });
60 | });
61 |
--------------------------------------------------------------------------------
/src/components/ConfirmationModal/index.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | import './ConfirmationModal.scss';
5 |
6 | const ConfirmationModal = ({
7 | isActive,
8 | isLoading,
9 | title,
10 | body,
11 | confirmButtonMessage,
12 | onConfirmation,
13 | cancelButtonMessage,
14 | onCancel,
15 | }) => {
16 | const modifiers = isActive && 'is-active';
17 | const loadingModifier = isLoading && 'is-loading';
18 |
19 | return (
20 |
21 |
25 |
26 |
29 |
30 |
47 |
48 |
49 | );
50 | };
51 |
52 | ConfirmationModal.propTypes = {
53 | isActive: PropTypes.bool,
54 | isLoading: PropTypes.bool,
55 | title: PropTypes.string.isRequired,
56 | body: PropTypes.string.isRequired,
57 | confirmButtonMessage: PropTypes.string.isRequired,
58 | onConfirmation: PropTypes.func.isRequired,
59 | cancelButtonMessage: PropTypes.string.isRequired,
60 | onCancel: PropTypes.func.isRequired,
61 | };
62 |
63 | export default ConfirmationModal;
64 |
--------------------------------------------------------------------------------
/src/assets/user-default-log.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.github/workflows/pull-requests.yml:
--------------------------------------------------------------------------------
1 | name: Pull Request CI
2 |
3 | on:
4 | pull_request:
5 | branches:
6 | - master
7 | - development
8 | - feature/*
9 |
10 | jobs:
11 | build:
12 | name: Build
13 |
14 | runs-on: ubuntu-latest
15 |
16 | steps:
17 | - uses: actions/checkout@v2.3.2
18 | with:
19 | ref: ${{ github.ref }}
20 | - name: Setup Node.js environment
21 | uses: actions/setup-node@v2-beta
22 | with:
23 | node-version: '12.x'
24 | - name: Installing dependencies
25 | run: npm ci
26 | - name: Building project
27 | run: npm run build
28 | env:
29 | CI: ''
30 | tests:
31 | name: Testing
32 |
33 | runs-on: ubuntu-latest
34 |
35 | steps:
36 | - uses: actions/checkout@v2.3.2
37 | - name: Setup Node.js environment
38 | uses: actions/setup-node@v2-beta
39 | with:
40 | node-version: '12.x'
41 | - name: Installing dependencies
42 | run: npm ci
43 | - name: Running project tests
44 | run: CI=true npm test
45 | env:
46 | REACT_APP_FIRE_BASE_KEY: ${{ secrets.FIREBASE_TOKEN }}
47 | REACT_APP_FIRE_BASE_AUTH_DOMAIN: ${{ secrets.FIRE_BASE_AUTH_DOMAIN_STAGING }}
48 | REACT_APP_FIRE_BASE_DB_URL: ${{ secrets.FIRE_BASE_DB_URL_STAGING }}
49 | REACT_APP_FIRE_BASE_PROJECT_ID: ${{ secrets.FIRE_BASE_PROJECT_ID_STAGING }}
50 | REACT_APP_FIRE_BASE_STORAGE_BUCKET: ${{ secrets.FIRE_BASE_STORAGE_BUCKET_STAGING }}
51 | REACT_APP_FIRE_BASE_MESSAGING_SENDER_ID: ${{ secrets.FIRE_BASE_MESSAGING_SENDER_ID_STAGING }}
52 | REACT_APP_FIRE_BASE_APP_ID: ${{ secrets.FIRE_BASE_APP_ID_STAGING }}
53 | REACT_APP_FIRE_BASE_MEASURMENT_ID: ${{ secrets.FIRE_BASE_MEASURMENT_ID_STAGING }}
54 | REACT_APP_CLOUD_FUNCTIONS_REST_API: ${{ secrets.CLOUD_FUNCTIONS_REST_API_STAGING }}
55 | REACT_APP_LOGIN_PAGE_URL: ${{ secrets.LOGIN_PAGE_URL_STAGING }}
56 |
--------------------------------------------------------------------------------
/src/components/Table/TableMobile.css:
--------------------------------------------------------------------------------
1 | .b-table .is-image-cell .image img {
2 | max-height: 100%;
3 | }
4 |
5 | @media screen and (max-width: 768px) {
6 | .b-table .table.has-mobile-cards thead {
7 | display: none;
8 | }
9 |
10 | .b-table .table.has-mobile-cards tfoot th {
11 | border: 0;
12 | display: inherit;
13 | }
14 |
15 | .b-table .table.has-mobile-cards tr {
16 | -webkit-box-shadow: 0 2px 3px rgba(10, 10, 10, 0.1),
17 | 0 0 0 1px rgba(10, 10, 10, 0.1);
18 | box-shadow: 0 2px 3px rgba(10, 10, 10, 0.1), 0 0 0 1px rgba(10, 10, 10, 0.1);
19 | max-width: 100%;
20 | position: relative;
21 | display: block;
22 | }
23 |
24 | .b-table .table.has-mobile-cards tr td {
25 | border: 0;
26 | display: inherit;
27 | }
28 |
29 | .b-table .table.has-mobile-cards tr td:last-child {
30 | border-bottom: 0;
31 | }
32 |
33 | .b-table .table.has-mobile-cards tr:not(:last-child) {
34 | margin-bottom: 1rem;
35 | }
36 |
37 | .b-table .table.has-mobile-cards tr:not([class*='is-']) {
38 | background: inherit;
39 | }
40 |
41 | .b-table .table.has-mobile-cards tr:not([class*='is-']):hover {
42 | background-color: inherit;
43 | }
44 |
45 | .b-table .table.has-mobile-cards tr.detail {
46 | margin-top: -1rem;
47 | }
48 |
49 | .b-table
50 | .table.has-mobile-cards
51 | tr:not(.detail):not(.is-empty):not(.table-footer)
52 | td {
53 | display: -webkit-box;
54 | display: -ms-flexbox;
55 | display: flex;
56 | width: auto;
57 | -webkit-box-pack: justify;
58 | -ms-flex-pack: justify;
59 | justify-content: space-between;
60 | text-align: right;
61 | border-bottom: 1px solid #f5f5f5;
62 | }
63 |
64 | .b-table
65 | .table.has-mobile-cards
66 | tr:not(.detail):not(.is-empty):not(.table-footer)
67 | td:before {
68 | content: attr(data-label);
69 | font-weight: 600;
70 | padding-right: 0.5em;
71 | text-align: left;
72 | }
73 |
74 | .b-table
75 | .table.has-mobile-cards
76 | td.has-no-head-mobile.is-image-cell
77 | .image {
78 | height: 25vw !important;
79 | }
80 | }
81 |
82 | .pagination-link {
83 | background-color: white;
84 | }
85 |
86 | .table.is-hoverable.is-striped
87 | tbody
88 | tr:not(.is-selected):hover:nth-child(even) {
89 | background-color: #fafafa;
90 | }
91 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
8 |
9 |
10 |
11 |
15 |
16 |
20 |
21 |
30 | React Firebase
31 |
32 |
33 |
39 |
43 |
44 |
45 |
46 |
47 |
57 |
58 |
59 |
--------------------------------------------------------------------------------
/src/utils/index.js:
--------------------------------------------------------------------------------
1 | import { createIntl, createIntlCache } from 'react-intl';
2 | import firebase from 'firebase.js';
3 |
4 | import english from 'languages/en';
5 | import spanish from 'languages/es';
6 | import en from 'assets/en.png';
7 | import es from 'assets/es.png';
8 |
9 | export const FIREBASE_RESPONSE = {
10 | EMAIL_IN_USE: 'auth/email-already-exists',
11 | EMAIL_INVALID: 'auth/invalid-email',
12 | EMAIL_NOT_FOUND: 'auth/user-not-found',
13 | PASSWORD_INVALID: 'auth/wrong-password',
14 | USER_DISABLED: 'auth/user-disabled',
15 | TOO_MANY_REQUESTS: 'auth/too-many-requests',
16 | EXPIRED_ACTION_CODE: 'auth/expired-action-code',
17 | INVALID_ACTION_CODE: 'auth/invalid-action-code',
18 | QUOTA_EXCEEDED_STORAGE: 'storage/quota-exceeded',
19 | UNAUTHENTICATED_STORAGE: 'storage/unauthenticated',
20 | UNAUTHORIZED_STORAGE: 'storage/unauthorized',
21 | };
22 |
23 | export const messages = {
24 | en: english,
25 | es: spanish,
26 | };
27 |
28 | const getIntlContext = (locale) => {
29 | const cache = createIntlCache();
30 | return createIntl(
31 | {
32 | locale,
33 | messages: messages[locale],
34 | },
35 | cache
36 | );
37 | };
38 |
39 | export const firebaseError = (error, locale) => {
40 | const intl = getIntlContext(locale);
41 | return intl.formatMessage({
42 | id: error,
43 | defaultMessage: messages[locale]['utils.default'],
44 | });
45 | };
46 |
47 | export const availableLocales = Object.keys(messages);
48 |
49 | export const browserLocale = navigator.language.split(/[-_]/)[0];
50 |
51 | export const flags = {
52 | en,
53 | es,
54 | };
55 |
56 | export const uiConfig = (onSignInSuccessHandler, onSignInFailHandler) => {
57 | return {
58 | callbacks: {
59 | signInSuccessWithAuthResult: onSignInSuccessHandler,
60 | signInFailure: onSignInFailHandler,
61 | },
62 | signInFlow: 'popup',
63 | signInSuccessUrl: '/home',
64 | signInOptions: [
65 | {
66 | provider: firebase.auth.GoogleAuthProvider.PROVIDER_ID,
67 | fullLabel: 'Continue with Google',
68 | scopes: [
69 | 'https://www.googleapis.com/auth/user.addresses.read',
70 | 'https://www.googleapis.com/auth/userinfo.email',
71 | ],
72 | },
73 | {
74 | provider: firebase.auth.FacebookAuthProvider.PROVIDER_ID,
75 | fullLabel: 'Continue with Facebook',
76 | scopes: ['email'],
77 | },
78 | { provider: 'microsoft.com', fullLabel: 'Continue with Microsoft' },
79 | ],
80 | };
81 | };
82 |
--------------------------------------------------------------------------------
/src/components/Navigation/NavBar/NavBar.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import * as reactRedux from 'react-redux';
3 | import '@testing-library/jest-dom';
4 | import { fireEvent } from '@testing-library/react';
5 |
6 | import es from 'assets/es.png';
7 | import * as authActions from 'state/actions/auth';
8 | import * as preferencesActions from 'state/actions/preferences';
9 | import paths from 'pages/Router/paths';
10 | import NavBar from '.';
11 |
12 | const onHandleMobile = jest.fn();
13 |
14 | describe(' rendering', () => {
15 | it('should render without crashing', () => {
16 | const { component } = renderWithProviders(
17 |
18 | )({
19 | auth: {
20 | userData: {},
21 | },
22 | });
23 |
24 | expect(component.asFragment()).toMatchSnapshot();
25 | });
26 |
27 | it('the link should redirect to the profile page', () => {
28 | const { component } = renderWithProviders(
29 |
30 | )({
31 | auth: {
32 | userData: {},
33 | },
34 | });
35 |
36 | fireEvent.click(component.getByText('Profile'));
37 |
38 | expect(window.location.pathname).toBe(paths.PROFILE);
39 | });
40 | });
41 |
42 | describe(' actions', () => {
43 | const dispatchMock = jest.fn();
44 |
45 | beforeEach(() => {
46 | jest
47 | .spyOn(reactRedux, 'useDispatch')
48 | .mockImplementation(() => dispatchMock);
49 | jest.spyOn(authActions, 'logout').mockImplementation(jest.fn);
50 | jest.spyOn(preferencesActions, 'setUserLocale').mockImplementation(jest.fn);
51 | });
52 |
53 | it('should dispatch logout action when the user tries to logout', () => {
54 | const { component } = renderWithProviders(
55 |
56 | )({
57 | auth: {
58 | userData: {},
59 | },
60 | });
61 |
62 | fireEvent.click(component.getByText('Log Out'));
63 |
64 | expect(authActions.logout).toHaveBeenCalled();
65 | });
66 |
67 | it('should display US flag when locale is set to english', () => {
68 | const { component } = renderWithProviders(
69 |
70 | )({
71 | auth: {
72 | userData: {},
73 | },
74 | });
75 | expect(component.getByAltText('es flag')).toHaveAttribute('src', es);
76 | });
77 |
78 | it('should dispatch setUserLocale action when the user tries to change language', () => {
79 | const { component } = renderWithProviders(
80 |
81 | )({
82 | auth: {
83 | userData: {},
84 | },
85 | });
86 |
87 | fireEvent.click(component.getByAltText('es flag'));
88 |
89 | expect(preferencesActions.setUserLocale).toHaveBeenCalledWith('es');
90 | });
91 | });
92 |
--------------------------------------------------------------------------------
/src/pages/User/index.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useMemo } from 'react';
2 | import { useParams, Redirect } from 'react-router-dom';
3 | import { useSelector, shallowEqual, useDispatch } from 'react-redux';
4 | import ClipLoader from 'react-spinners/ClipLoader';
5 | import * as yup from 'yup';
6 |
7 | import UserForm from 'components/UserForm';
8 | import { createUser, modifyUser, fetchUsers } from 'state/actions/users';
9 | import paths from 'pages/Router/paths';
10 | import { useFormatMessage } from 'hooks';
11 |
12 | const schema = yup.object().shape({
13 | email: yup.string().email().required(),
14 | name: yup.string().required(),
15 | isAdmin: yup.boolean().notRequired(),
16 | location: yup.string().notRequired(),
17 | createdAt: yup.string().required(),
18 | });
19 |
20 | const User = () => {
21 | const { id } = useParams();
22 |
23 | const isEditing = useMemo(() => !!id, [id]);
24 |
25 | const { success, userData, error } = useSelector(
26 | (state) => ({
27 | success: state.users.success,
28 | userData: state.users.data.find((user) => user.id === id),
29 | error: state.users.error,
30 | }),
31 | shallowEqual
32 | );
33 |
34 | const dispatch = useDispatch();
35 |
36 | useEffect(() => {
37 | if (isEditing) {
38 | if (!userData) {
39 | dispatch(fetchUsers(id));
40 | }
41 | }
42 | }, [isEditing, id, userData, dispatch]);
43 |
44 | const redirect = ((isEditing && error) || success) && (
45 |
46 | );
47 |
48 | const editUserMessage = useFormatMessage('User.editUser');
49 |
50 | const newUserMessage = useFormatMessage('User.editUser');
51 |
52 | const onSubmitHandler = (value) => {
53 | const newUser = {
54 | ...value,
55 | file: value?.file[0] || null,
56 | isEditing,
57 | id,
58 | };
59 |
60 | if (isEditing) {
61 | dispatch(modifyUser(newUser));
62 | } else {
63 | dispatch(createUser(newUser));
64 | }
65 | };
66 |
67 | return (
68 | <>
69 | {redirect}
70 |
71 |
72 |
73 | {isEditing ? editUserMessage : newUserMessage}
74 |
75 |
76 |
77 |
78 | {isEditing && !userData ? (
79 |
80 | ) : (
81 |
97 | )}
98 |
99 | >
100 | );
101 | };
102 |
103 | export default User;
104 |
--------------------------------------------------------------------------------
/src/pages/ResetPassword/__snapshots__/ResetPassword.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` rendering should render without crashing 1`] = `
4 |
5 |
100 |
101 | `;
102 |
--------------------------------------------------------------------------------
/src/components/Navigation/Aside/__snapshots__/Aside.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` rendering should render without crashing 1`] = `
4 |
5 |
125 |
126 | `;
127 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-firebase-admin",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@hookform/resolvers": "^0.1.0",
7 | "bulma": "^0.9.1",
8 | "classnames": "^2.2.6",
9 | "date-fns": "^2.16.1",
10 | "firebase": "^8.1.2",
11 | "mutationobserver-shim": "^0.3.7",
12 | "node-sass": "^4.14.1",
13 | "prop-types": "^15.7.2",
14 | "react": "^17.0.1",
15 | "react-datepicker": "^3.3.0",
16 | "react-dom": "^17.0.1",
17 | "react-firebaseui": "^4.1.0",
18 | "react-hook-form": "^6.12.2",
19 | "react-intl": "^5.10.6",
20 | "react-redux": "^7.2.2",
21 | "react-redux-toastr": "^7.6.5",
22 | "react-router-dom": "^5.2.0",
23 | "react-scripts": "4.0.1",
24 | "react-spinners": "^0.9.0",
25 | "react-table": "^7.6.2",
26 | "redux": "^4.0.5",
27 | "redux-act": "^1.8.0",
28 | "redux-persist": "^6.0.0",
29 | "redux-thunk": "^2.3.0",
30 | "yup": "^0.32.5"
31 | },
32 | "scripts": {
33 | "start": "react-scripts start",
34 | "build": "react-scripts build",
35 | "test": "react-scripts test",
36 | "eject": "react-scripts eject",
37 | "lint": "eslint .",
38 | "setup-admin-dashboard": "npm install && npm run build && firebase deploy",
39 | "deploy": "npm run build && firebase deploy --only hosting",
40 | "precommit:react": "npm test",
41 | "precommit:functions": "cd functions/ && npm run build && npm test",
42 | "precommit": "cross-env CI=true npm run precommit:react && npm run precommit:functions"
43 | },
44 | "eslintConfig": {
45 | "extends": "react-app"
46 | },
47 | "browserslist": {
48 | "production": [
49 | ">0.2%",
50 | "not dead",
51 | "not op_mini all"
52 | ],
53 | "development": [
54 | "last 1 chrome version",
55 | "last 1 firefox version",
56 | "last 1 safari version"
57 | ]
58 | },
59 | "devDependencies": {
60 | "@testing-library/jest-dom": "^5.11.6",
61 | "@testing-library/react": "^11.2.2",
62 | "babel-eslint": "^10.1.0",
63 | "cross-env": "^7.0.3",
64 | "deep-freeze": "^0.0.1",
65 | "dotenv": "^8.2.0",
66 | "eslint": "^7.15.0",
67 | "eslint-config-airbnb": "^18.2.0",
68 | "eslint-config-prettier": "^6.11.0",
69 | "eslint-loader": "^4.0.2",
70 | "eslint-plugin-import": "^2.21.2",
71 | "eslint-plugin-jest": "^23.18.0",
72 | "eslint-plugin-jsx-a11y": "^6.3.0",
73 | "eslint-plugin-prettier": "^3.1.4",
74 | "eslint-plugin-promise": "^4.2.1",
75 | "eslint-plugin-react": "^7.20.5",
76 | "husky": "^4.3.5",
77 | "lint-staged": "^10.5.3",
78 | "prettier": "^2.2.1",
79 | "redux-mock-store": "^1.5.4",
80 | "sass-loader": "^10.1.0"
81 | },
82 | "husky": {
83 | "hooks": {
84 | "pre-commit": "npm run precommit && lint-staged"
85 | }
86 | },
87 | "jest": {
88 | "collectCoverageFrom": [
89 | "src/**/*.{js,jsx}"
90 | ],
91 | "coverageReporters": [
92 | "text",
93 | "html"
94 | ],
95 | "resetMocks": true
96 | },
97 | "lint-staged": {
98 | "*.{js,jsx}": [
99 | "eslint --fix",
100 | "prettier --write"
101 | ],
102 | "functions/**/*.ts": [
103 | "npm run lint"
104 | ]
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/src/pages/ResetPassword/ResetPassword.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import * as reactRedux from 'react-redux';
3 | import '@testing-library/jest-dom';
4 |
5 | import * as actions from 'state/actions/auth';
6 | import { fireEvent } from '@testing-library/react';
7 | import ResetPassword from '.';
8 |
9 | describe(' rendering', () => {
10 | const dispatchMock = jest.fn();
11 |
12 | beforeEach(() => {
13 | jest
14 | .spyOn(reactRedux, 'useDispatch')
15 | .mockImplementation(() => dispatchMock);
16 | jest.spyOn(actions, 'authCleanUp').mockImplementation(jest.fn);
17 | });
18 |
19 | it('should render without crashing', () => {
20 | const { component } = renderWithProviders()({
21 | auth: {
22 | userData: {},
23 | },
24 | });
25 |
26 | expect(component.asFragment()).toMatchSnapshot();
27 | });
28 |
29 | it('should display an error message correctly', () => {
30 | const { component } = renderWithProviders()({
31 | auth: {
32 | userData: {},
33 | error: 'some error',
34 | },
35 | });
36 |
37 | expect(component.container.querySelector('p.has-text-danger')).toBeTruthy();
38 | });
39 |
40 | it('should display a confirmation message correctly', () => {
41 | const { component } = renderWithProviders()({
42 | auth: {
43 | userData: {},
44 | restoredPassword: true,
45 | },
46 | });
47 |
48 | expect(component.container.querySelector('.card-content')).toBeTruthy();
49 | });
50 |
51 | it('should display the button loading correctly', () => {
52 | const { component } = renderWithProviders()({
53 | auth: {
54 | userData: {},
55 | loading: true,
56 | },
57 | });
58 |
59 | expect(component.getByRole('button')).toHaveClass('is-loading');
60 | });
61 | });
62 |
63 | describe(' actions', () => {
64 | const dispatchMock = jest.fn();
65 |
66 | beforeEach(() => {
67 | jest
68 | .spyOn(reactRedux, 'useDispatch')
69 | .mockImplementation(() => dispatchMock);
70 | jest.spyOn(actions, 'resetPassword').mockImplementation(() => jest.fn());
71 | jest.spyOn(actions, 'authCleanUp').mockImplementation(jest.fn);
72 | });
73 |
74 | it('should dispatch resetPassword action when the form is submitted', async () => {
75 | const { component } = renderWithProviders()({
76 | auth: {
77 | userData: {},
78 | },
79 | });
80 |
81 | fireEvent.input(component.container.querySelector('input[name=email]'), {
82 | target: {
83 | value: 'test@gmail.com',
84 | },
85 | });
86 |
87 | fireEvent.submit(component.container.querySelector('form'));
88 |
89 | await (() => expect(actions.resetPassword).toBeCalledTimes(1));
90 |
91 | await (() =>
92 | expect(actions.resetPassword).toBeCalledWith('test@gmail.com'));
93 | });
94 |
95 | it('should dispatch resetPasswordCleanUp action when the component is unmounted', () => {
96 | const { component } = renderWithProviders()({
97 | auth: {
98 | userData: {},
99 | },
100 | });
101 |
102 | component.unmount();
103 |
104 | expect(actions.authCleanUp).toBeCalled();
105 | });
106 | });
107 |
--------------------------------------------------------------------------------
/src/state/reducers/users/index.js:
--------------------------------------------------------------------------------
1 | import { createReducer } from 'redux-act';
2 |
3 | import {
4 | USERS_FETCH_DATA_INIT,
5 | USERS_FETCH_DATA_SUCCESS,
6 | USERS_FETCH_DATA_FAIL,
7 | USERS_DELETE_USER_INIT,
8 | USERS_DELETE_USER_SUCCESS,
9 | USERS_DELETE_USER_FAIL,
10 | USERS_CREATE_USER_INIT,
11 | USERS_CREATE_USER_SUCCESS,
12 | USERS_CREATE_USER_FAIL,
13 | USERS_MODIFY_USER_INIT,
14 | USERS_MODIFY_USER_SUCCESS,
15 | USERS_MODIFY_USER_FAIL,
16 | USERS_CLEAN_UP,
17 | USERS_CLEAR_DATA_LOGOUT,
18 | } from 'state/actions/users';
19 |
20 | const initialState = {
21 | data: [],
22 | loading: false,
23 | error: null,
24 | success: false,
25 | deleted: false,
26 | };
27 |
28 | export const usersReducer = createReducer(
29 | {
30 | [USERS_FETCH_DATA_INIT]: () => ({
31 | ...initialState,
32 | loading: true,
33 | }),
34 | [USERS_FETCH_DATA_SUCCESS]: (state, payload) => ({
35 | ...state,
36 | data: payload.data,
37 | loading: false,
38 | error: null,
39 | }),
40 | [USERS_FETCH_DATA_FAIL]: (state, payload) => ({
41 | ...state,
42 | loading: false,
43 | error: payload.error,
44 | }),
45 | [USERS_DELETE_USER_INIT]: (state) => ({
46 | ...state,
47 | loading: true,
48 | }),
49 | [USERS_DELETE_USER_SUCCESS]: (state, payload) => ({
50 | ...state,
51 | data: state.data.filter((elem) => elem.id !== payload.id),
52 | loading: false,
53 | error: null,
54 | deleted: true,
55 | }),
56 | [USERS_DELETE_USER_FAIL]: (state, payload) => ({
57 | ...state,
58 | loading: false,
59 | error: payload.error,
60 | }),
61 | [USERS_CREATE_USER_INIT]: (state) => ({
62 | ...state,
63 | loading: true,
64 | }),
65 | [USERS_CREATE_USER_SUCCESS]: (state, payload) => ({
66 | ...state,
67 | data: state.data.concat(payload.user),
68 | loading: false,
69 | error: null,
70 | success: true,
71 | }),
72 | [USERS_CREATE_USER_FAIL]: (state, payload) => ({
73 | ...state,
74 | loading: false,
75 | error: payload.error,
76 | }),
77 | [USERS_MODIFY_USER_INIT]: (state) => ({
78 | ...state,
79 | loading: true,
80 | }),
81 | [USERS_MODIFY_USER_SUCCESS]: (state, payload) => ({
82 | ...state,
83 | data: !state.data
84 | ? []
85 | : state.data.map((elem) => {
86 | if (elem.id === payload.id) {
87 | return {
88 | name: payload.user.name,
89 | location: payload.user.location,
90 | id: payload.id,
91 | logoUrl: payload.user.logoUrl,
92 | createdAt: payload.user.createdAt,
93 | email: elem.email,
94 | };
95 | }
96 | return elem;
97 | }),
98 | loading: false,
99 | error: null,
100 | success: true,
101 | }),
102 | [USERS_MODIFY_USER_FAIL]: (state, payload) => ({
103 | ...state,
104 | loading: false,
105 | error: payload.error,
106 | }),
107 | [USERS_CLEAN_UP]: (state) => ({
108 | ...state,
109 | loading: false,
110 | error: null,
111 | success: false,
112 | deleted: false,
113 | }),
114 | [USERS_CLEAR_DATA_LOGOUT]: () => ({
115 | ...initialState,
116 | }),
117 | },
118 | initialState
119 | );
120 |
--------------------------------------------------------------------------------
/src/pages/Login/__snapshots__/Login.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` rendering should render without crashing 1`] = `
4 |
5 |
117 |
118 | `;
119 |
--------------------------------------------------------------------------------
/src/components/Navigation/Aside/Aside.test.js:
--------------------------------------------------------------------------------
1 | import { fireEvent } from '@testing-library/react';
2 | import React from 'react';
3 |
4 | import Aside from '.';
5 |
6 | describe(' rendering', () => {
7 | const onHandler = jest.fn();
8 |
9 | it('should render without crashing', () => {
10 | const { component } = renderWithProviders(
11 |
12 | )({
13 | auth: {
14 | userData: {
15 | isAdmin: true,
16 | },
17 | },
18 | });
19 |
20 | expect(component.asFragment()).toMatchSnapshot();
21 | });
22 |
23 | it('should set the handleMobileToggle prop correctly when clicking Home', () => {
24 | const { component } = renderWithProviders(
25 |
26 | )({
27 | auth: {
28 | userData: {
29 | isAdmin: true,
30 | },
31 | },
32 | });
33 |
34 | fireEvent.click(component.getByText('Home'));
35 | expect(onHandler).toBeCalled();
36 | });
37 |
38 | it('should set the handleMobileToggle prop correctly when clicking Users', () => {
39 | const { component } = renderWithProviders(
40 |
41 | )({
42 | auth: {
43 | userData: {
44 | isAdmin: true,
45 | },
46 | },
47 | });
48 |
49 | fireEvent.click(component.getByText('Users'));
50 | expect(onHandler).toBeCalled();
51 | });
52 |
53 | it('should set the handleMobileToggle prop correctly when clicking Submenu 1', () => {
54 | const { component } = renderWithProviders(
55 |
56 | )({
57 | auth: {
58 | userData: {
59 | isAdmin: true,
60 | },
61 | },
62 | });
63 |
64 | fireEvent.click(component.getByText('Submenu 1'));
65 | expect(onHandler).toBeCalled();
66 | });
67 |
68 | it('should set the handleMobileToggle prop correctly when clicking Submenu 2', () => {
69 | const { component } = renderWithProviders(
70 |
71 | )({
72 | auth: {
73 | userData: {
74 | isAdmin: true,
75 | },
76 | },
77 | });
78 |
79 | fireEvent.click(component.getByText('Submenu 2'));
80 | expect(onHandler).toBeCalled();
81 | });
82 |
83 | it('should not render the /users link if it the user is not an admin', () => {
84 | const { component } = renderWithProviders(
85 |
86 | )({
87 | auth: {
88 | userData: {
89 | isAdmin: false,
90 | },
91 | },
92 | });
93 |
94 | expect(component.queryByText('Users')).toBeNull();
95 | });
96 |
97 | it('should render the /users link if it the user is an admin', () => {
98 | const { component } = renderWithProviders(
99 |
100 | )({
101 | auth: {
102 | userData: {
103 | isAdmin: true,
104 | },
105 | },
106 | });
107 |
108 | expect(component.getByText('Users')).toBeTruthy();
109 | });
110 |
111 | it('should render the component if the user is an admin', () => {
112 | const { component } = renderWithProviders(
113 |
114 | )({
115 | auth: {
116 | userData: {
117 | isAdmin: true,
118 | },
119 | },
120 | });
121 |
122 | expect(component.getByText('Dropdown Menu')).toBeTruthy();
123 | });
124 |
125 | it('should render the component if the user is not an admin', () => {
126 | const { component } = renderWithProviders(
127 |
128 | )({
129 | auth: {
130 | userData: {
131 | isAdmin: false,
132 | },
133 | },
134 | });
135 |
136 | expect(component.getByText('Dropdown Menu')).toBeTruthy();
137 | });
138 | });
139 |
--------------------------------------------------------------------------------
/src/pages/Profile/ChangePassword/__snapshots__/ChangePassword.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` rendering should render without crashing 1`] = `
4 |
5 |
150 |
151 | `;
152 |
--------------------------------------------------------------------------------
/src/components/Navigation/Aside/index.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { useSelector, shallowEqual } from 'react-redux';
3 | import classNames from 'classnames';
4 | import PropTypes from 'prop-types';
5 | import { Link } from 'react-router-dom';
6 |
7 | import { useFormatMessage } from 'hooks';
8 | import paths from 'pages/Router/paths';
9 | import NavLink from '../Link';
10 | import classes from './Aside.module.scss';
11 |
12 | export const SubMenu = ({ label, children }) => {
13 | const [active, setActive] = useState(false);
14 |
15 | return (
16 |
17 | setActive(!active)}
21 | >
22 |
23 |
24 |
25 | {label}
26 |
27 |
28 |
35 |
36 |
37 |
38 |
39 |
40 | );
41 | };
42 |
43 | SubMenu.propTypes = {
44 | children: PropTypes.node.isRequired,
45 | label: PropTypes.string.isRequired,
46 | };
47 |
48 | const Aside = ({ handleMobileToggle }) => {
49 | const { isAdmin } = useSelector(
50 | (state) => ({
51 | isAdmin: state.auth.userData.isAdmin,
52 | }),
53 | shallowEqual
54 | );
55 |
56 | const usersMessage = useFormatMessage('Aside.users');
57 |
58 | return (
59 |
120 | );
121 | };
122 |
123 | Aside.propTypes = {
124 | handleMobileToggle: PropTypes.func.isRequired,
125 | };
126 |
127 | export default Aside;
128 |
--------------------------------------------------------------------------------
/src/components/Navigation/NavBar/__snapshots__/NavBar.test.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[` rendering should render without crashing 1`] = `
4 |
5 |
160 |
161 | `;
162 |
--------------------------------------------------------------------------------
/src/pages/Profile/ChangePassword/ChangePassword.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import * as reactRedux from 'react-redux';
3 | import { fireEvent } from '@testing-library/react';
4 | import '@testing-library/jest-dom';
5 |
6 | import * as actions from 'state/actions/auth';
7 | import ChangePassword from '.';
8 |
9 | describe(' rendering', () => {
10 | const dispatchMock = jest.fn();
11 |
12 | beforeEach(() => {
13 | jest
14 | .spyOn(reactRedux, 'useDispatch')
15 | .mockImplementation(() => dispatchMock);
16 | jest.spyOn(actions, 'authCleanUp').mockImplementation(jest.fn);
17 | });
18 |
19 | it('should render without crashing', () => {
20 | const { component } = renderWithProviders()({
21 | auth: {
22 | userData: {},
23 | },
24 | });
25 |
26 | expect(component.asFragment()).toMatchSnapshot();
27 | });
28 |
29 | it('should display an error message when the current and new password are equal', async () => {
30 | const { component } = renderWithProviders()({
31 | auth: {
32 | userData: {},
33 | },
34 | });
35 |
36 | fireEvent.input(component.getByTestId('current'), {
37 | target: {
38 | value: 'oldpassword',
39 | },
40 | });
41 |
42 | fireEvent.input(component.getByTestId('new'), {
43 | target: {
44 | value: 'oldpassword',
45 | },
46 | });
47 |
48 | await (() =>
49 | expect(
50 | component.getByText(
51 | 'The new password and the current one cannot be the same'
52 | )
53 | ).toBeTruthy());
54 | });
55 |
56 | it('should display a message informing the user that the new password is secure', async () => {
57 | const { component } = renderWithProviders()({
58 | auth: {
59 | userData: {},
60 | },
61 | });
62 |
63 | fireEvent.input(component.getByTestId('new'), {
64 | target: {
65 | value: 'newSecurePassword',
66 | },
67 | });
68 |
69 | await (() => expect(component.getByText('Safe password')).toBeTruthy());
70 | });
71 |
72 | it('should display a message informing the user that the new and confirmation passwords match', async () => {
73 | const { component } = renderWithProviders()({
74 | auth: {
75 | userData: {},
76 | },
77 | });
78 |
79 | fireEvent.input(component.getByTestId('new'), {
80 | target: {
81 | value: 'newSecurePassword!',
82 | },
83 | });
84 |
85 | fireEvent.input(component.getByTestId('confirmation'), {
86 | target: {
87 | value: 'newSecurePassword!',
88 | },
89 | });
90 |
91 | await (() => expect(component.getByText('Passwords match')).toBeTruthy());
92 | });
93 |
94 | it('should display the button loading when loading', () => {
95 | const { component } = renderWithProviders()({
96 | auth: {
97 | userData: {},
98 | loading: true,
99 | },
100 | });
101 |
102 | expect(component.getByRole('button')).toHaveClass('is-loading');
103 | });
104 | });
105 |
106 | describe(' actions', () => {
107 | const dispatchMock = jest.fn();
108 | beforeEach(() => {
109 | jest
110 | .spyOn(reactRedux, 'useDispatch')
111 | .mockImplementation(() => dispatchMock);
112 | jest.spyOn(actions, 'changeUserPassword').mockImplementation(jest.fn);
113 | });
114 |
115 | it('should dispatch changeUserPassword action when the form is submited', async () => {
116 | const { component } = renderWithProviders()({
117 | auth: {
118 | userData: {},
119 | },
120 | });
121 |
122 | fireEvent.input(component.getByTestId('current'), {
123 | target: {
124 | value: 'oldpassword',
125 | },
126 | });
127 |
128 | fireEvent.input(component.getByTestId('new'), {
129 | target: {
130 | value: 'newpassword',
131 | },
132 | });
133 |
134 | fireEvent.input(component.getByTestId('confirmation'), {
135 | target: {
136 | value: 'newpassword',
137 | },
138 | });
139 |
140 | fireEvent.click(component.getByRole('button'));
141 |
142 | await (() => expect(actions.changeUserPassword).toBeCalledTimes(1));
143 |
144 | await (() =>
145 | expect(actions.changeUserPassword).toBeCalledWith(
146 | 'oldpassword',
147 | 'newpassword'
148 | ));
149 | });
150 | });
151 |
--------------------------------------------------------------------------------
/src/languages/en.json:
--------------------------------------------------------------------------------
1 | {
2 | "Home.home": "Home",
3 | "Home.content": "Home content",
4 | "Login.login": "Login",
5 | "Login.setNewPassword": "Set your new password",
6 | "Login.email": "E-mail Address",
7 | "Login.password": "Password",
8 | "Login.setPassword": "Set Password",
9 | "Login.forgotPassword": "Forgot Password?",
10 | "Login.facebook": "Continue with Facebook",
11 | "Login.google": "Continue with Google",
12 | "Login.microsoft": "Continue with Microsoft",
13 | "Login.safePassword": "Safe password",
14 | "Login.unsafePassword": "Unsafe password",
15 | "Login.invalidEmail": "Invalid email",
16 | "Login.invalidPassword": "Invalid password",
17 | "NotFound.404": "Error 404: page not found",
18 | "NotFound.url": "The requested URL {url} was not found",
19 | "NotFound.back": "Go Back",
20 | "Profile.profile": "Profile",
21 | "ChangePassword.samePassword": "The new password and the current one cannot be the same",
22 | "ChangePassword.changePassword": "Change Password",
23 | "ChangePassword.currentPassword": "Current Password",
24 | "ChangePassword.newPassword": "New Password",
25 | "ChangePassword.confirmPassword": "Confirm Password",
26 | "ChangePassword.submits": "Submit",
27 | "ChangePassword.safePassword": "Safe password",
28 | "ChangePassword.insecurePassword": "Insecure password",
29 | "ChangePassword.matchPassword": "Passwords match",
30 | "ChangePassword.notMatchPassword": "Passwords do not match",
31 | "ChangePassword.invalidPassword": "Password must be at least six characters long",
32 | "Section.section": "Section",
33 | "Section.content": "Section content",
34 | "ResetPassword.recovery": "Password Recovery",
35 | "ResetPassword.recoverEmail": "We have sent you an email to {mail} so you can recover your account.",
36 | "ResetPassword.email": "E-mail Address",
37 | "ResetPassword.emailRegistration": "E-mail used for registration",
38 | "ResetPassword.resetLink": "Send Reset Link",
39 | "ResetPassword.back": "Back",
40 | "Submenu.submenu": "Submenu",
41 | "Submenu.content": "Submenu content",
42 | "User.editUser": "Edit User",
43 | "User.newUser": "New User",
44 | "Users.name": "Name",
45 | "Users.email": "Email",
46 | "Users.location": "Location",
47 | "Users.admin": "Admin",
48 | "Users.created": "Created",
49 | "Users.delete": "Delete",
50 | "Users.confirm": "Confirm action",
51 | "Users.permDelete": "This will permanently delete the user. Action can not be undone.",
52 | "Users.cancel": "Cancel",
53 | "Users.users": "Users",
54 | "Users.newUser": "New User",
55 | "Users.search": "Search:",
56 | "Aside.home": "Home",
57 | "Aside.users": "Users",
58 | "Aside.dropdownMenu": "Dropdown Menu",
59 | "Aside.submenu1": "Submenu 1",
60 | "Aside.submenu2": "Submenu 2",
61 | "NavBar.profile": "Profile",
62 | "NavBar.logOut": "Log Out",
63 | "NavBar.Language": "English",
64 | "Table.perPage": " per page",
65 | "UserForm.invalidEmail": "Invalid E-mail",
66 | "UserForm.userInfo": "User Information",
67 | "UserForm.email": "E-mail",
68 | "UserForm.name": "Name",
69 | "UserForm.location": "Location",
70 | "UserForm.admin": "Admin",
71 | "UserForm.created": "Created",
72 | "UserForm.logo": "Logo",
73 | "UserForm.pickAnotherFile": "Pick another file",
74 | "UserForm.pickFile": "Pick a file",
75 | "UserForm.submit": "Submit",
76 | "UserForm.goBack": "Go Back",
77 | "UserForm.userPreview": "User Preview",
78 | "auth/email-already-exists": "Email already in use",
79 | "auth/invalid-email": "Email is invalid",
80 | "auth/wrong-password": "Invalid credentials",
81 | "auth/user-disabled": "User disabled",
82 | "auth/too-many-requests": "Too many attempts made, try again later",
83 | "auth/expired-action-code": "The invitation link has expired, get in touch with your administrator",
84 | "auth/invalid-action-code": "The invitation link has expired, get in touch with your administrator",
85 | "storage/quota-exceeded": "Internal server error, get in touch with your administrator",
86 | "storage/unauthenticated": "Unauthenticated, please authenticate and try again",
87 | "storage/unauthorized": "Unauthorized, you are not authorized to perform this action",
88 | "auth/account-exists-with-different-credential": "Email on another sign in method",
89 | "auth/popup-blocked": "Pop up blocked by the browser, please enable pop ups for this page",
90 | "auth/popup-closed-by-user": "Operation cancelled, pop up was closed",
91 | "auth/cancelled-popup-request": "Too many pop ups, just one pop up is allowed",
92 | "utils.default": "Unknown error, get in touch with your administrator",
93 | "ErrorMessage.defaultMessage": "This field is required",
94 | "invalidEmail": "Invalid E-mail"
95 | }
96 |
--------------------------------------------------------------------------------
/functions/setupProject.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-param-reassign */
2 | /* eslint-disable consistent-return */
3 | /* eslint-disable import/no-dynamic-require */
4 | /* eslint-disable global-require */
5 | const admin = require('firebase-admin');
6 | const inquirer = require('inquirer');
7 | const fs = require('fs');
8 |
9 | const configPath = '../src/state/api/index.js';
10 |
11 | const importPath = '../src/firebase.js';
12 |
13 | const questions = [
14 | {
15 | type: 'input',
16 | name: 'path',
17 | message: 'Enter the path to the service account key file: ',
18 | },
19 | {
20 | type: 'input',
21 | name: 'databaseURL',
22 | message: 'Enter database URL: ',
23 | },
24 | {
25 | type: 'input',
26 | name: 'email',
27 | message: 'Enter user email: ',
28 | },
29 | {
30 | type: 'password',
31 | name: 'password',
32 | message: 'Enter user password: ',
33 | mask: '*',
34 | },
35 | {
36 | type: 'list',
37 | name: 'database',
38 | message: 'Select the database of your choice:',
39 | choices: ['Realtime Database', 'Firestore'],
40 | },
41 |
42 | {
43 | type: 'confirm',
44 | name: 'deletedb',
45 | message: 'Do you want to delete unused cloud functions?',
46 | default: true,
47 | },
48 | ];
49 |
50 | const replaceDatabase = (oldDatabase, newDatabase) => {
51 | fs.readFile(configPath, 'utf8', (error, data) => {
52 | if (error) {
53 | return console.log(error);
54 | }
55 | const result = data.replace(oldDatabase, newDatabase);
56 |
57 | fs.writeFile(configPath, result, 'utf8', (err) => {
58 | if (err) return console.log(err);
59 | });
60 | });
61 |
62 | fs.readFile(importPath, 'utf8', (error, data) => {
63 | if (error) {
64 | return console.log(error);
65 | }
66 |
67 | let oldInit;
68 | let newInit;
69 | let oldImport;
70 | let newImport;
71 | if (oldDatabase === 'rtdb') {
72 | oldInit = 'firebase.database()';
73 | newInit = 'firebase.firestore()';
74 | oldImport = 'firebase/database';
75 | newImport = 'firebase/firestore';
76 | } else {
77 | oldInit = 'firebase.firestore()';
78 | newInit = 'firebase.database()';
79 | oldImport = 'firebase/firestore';
80 | newImport = 'firebase/database';
81 | }
82 |
83 | data = data.replace(oldInit, newInit);
84 |
85 | data = data.replace(oldImport, newImport);
86 |
87 | fs.writeFile(importPath, data, 'utf8', (err) => {
88 | if (err) return console.log(err);
89 | });
90 | });
91 | };
92 |
93 | const deleteDatabase = async (database) => {
94 | const dir = database !== 'Firestore' ? 'firestore' : 'db';
95 |
96 | try {
97 | fs.rmdirSync(`./src/${dir}`, { recursive: true });
98 | } catch (error) {
99 | console.error(`Error while deleting ${database}. ${error}`);
100 | }
101 |
102 | try {
103 | fs.rmdirSync(`./test/${dir}`, { recursive: true });
104 | } catch (error) {
105 | console.error(`Error while deleting ${database} tests. ${error}`);
106 | }
107 | };
108 |
109 | inquirer
110 | .prompt(questions)
111 | .then(async ({ database, path, email, password, databaseURL, deletedb }) => {
112 | const serviceAccount = require(path);
113 |
114 | admin.initializeApp({
115 | credential: admin.credential.cert(serviceAccount),
116 | databaseURL,
117 | });
118 |
119 | console.log('Setting admin account in authentication 🔨');
120 |
121 | const { uid } = await admin.auth().createUser({
122 | email,
123 | password,
124 | emailVerified: true,
125 | });
126 |
127 | await admin.auth().setCustomUserClaims(uid, {
128 | isAdmin: true,
129 | });
130 |
131 | console.log('Created admin account in authentication');
132 |
133 | console.log('Creating admin account in database');
134 |
135 | const user = {
136 | isAdmin: true,
137 | name: 'Test Name',
138 | location: 'Test Location',
139 | createdAt: new Date().toDateString(),
140 | email,
141 | };
142 |
143 | if (database === 'Firestore') {
144 | replaceDatabase('rtdb', 'firestore');
145 | await admin.firestore().collection('users').doc(uid).set(user);
146 | } else {
147 | replaceDatabase('firestore', 'rtdb');
148 | await admin.database().ref(`users/${uid}`).set(user);
149 | }
150 |
151 | if (deletedb) {
152 | deleteDatabase(database);
153 | }
154 |
155 | console.log(`Created admin account in ${database}`);
156 | process.exit(0);
157 | })
158 | .catch((error) => {
159 | console.log(error.message);
160 | process.exit(0);
161 | });
162 |
--------------------------------------------------------------------------------
/src/languages/es.json:
--------------------------------------------------------------------------------
1 | {
2 | "Home.home": "Inicio",
3 | "Home.content": "Contenido del Inicio",
4 | "Login.login": "Iniciar Sesión",
5 | "Login.setNewPassword": "Establece tu nueva contraseña",
6 | "Login.email": "Dirección de E-mail",
7 | "Login.password": "Contraseña",
8 | "Login.setPassword": "Establecer contraseña",
9 | "Login.forgotPassword": "Olvido su contraseña?",
10 | "Login.facebook": "Continuar con Facebook",
11 | "Login.google": "Continuar con Google",
12 | "Login.microsoft": "Continuar con Microsoft",
13 | "Login.safePassword": "Contraseña segura",
14 | "Login.unsafePassword": "Contraseña insegura",
15 | "Login.invalidEmail": "Email inválido",
16 | "Login.invalidPassword": "Contraseña inválida",
17 | "NotFound.404": "Error 404: página no encontrada",
18 | "NotFound.url": "El URL {url} no fue encontrado",
19 | "NotFound.back": "Regresar",
20 | "Profile.profile": "Perfil",
21 | "ChangePassword.samePassword": "La nueva contraseña y la actual no pueden ser la misma",
22 | "ChangePassword.changePassword": "Cambiar contraseña",
23 | "ChangePassword.currentPassword": "Contraseña actual",
24 | "ChangePassword.newPassword": "Nueva contraseña",
25 | "ChangePassword.confirmPassword": "Confirmar contraseña",
26 | "ChangePassword.submits": "Enviar",
27 | "ChangePassword.safePassword": "Contraseña segura",
28 | "ChangePassword.insecurePassword": "Contraseña Insegura",
29 | "ChangePassword.matchPassword": "Contraseñas coinciden",
30 | "ChangePassword.notMatchPassword": "Contraseñas no coinciden",
31 | "ChangePassword.invalidPassword": "La contraseña debe tener al menos seis caracteres de largo",
32 | "Section.section": "Sección",
33 | "Section.content": "Contenido de la Sección",
34 | "ResetPassword.recovery": "Recuperar Contraseña",
35 | "ResetPassword.recoverEmail": "Le mandamos un email a {mail} para que pueda recuperar su cuenta.",
36 | "ResetPassword.email": "Dirección E-mail",
37 | "ResetPassword.emailRegistration": "E-mail usado para registrarse",
38 | "ResetPassword.resetLink": "Mandar Link de Reinicio",
39 | "ResetPassword.back": "Atrás",
40 | "Submenu.submenu": "Submenu",
41 | "Submenu.content": "Contenido del Submenu",
42 | "User.editUser": "Editar Usuario",
43 | "User.newUser": "Nuevo Usuario",
44 | "Users.name": "Nombre",
45 | "Users.email": "Email",
46 | "Users.location": "Localización",
47 | "Users.admin": "Admin",
48 | "Users.created": "Creado",
49 | "Users.delete": "Borrar",
50 | "Users.confirm": "Confirmar acción",
51 | "Users.permDelete": "Esto borrará permanentemente el usuario. Esta acción no se puede deshacer.",
52 | "Users.cancel": "Cancelar",
53 | "Users.users": "Usuarios",
54 | "Users.newUser": "Nuevo Usuario",
55 | "Users.search": "Buscar:",
56 | "Aside.home": "Inicio",
57 | "Aside.users": "Usuarios",
58 | "Aside.dropdownMenu": "Menú",
59 | "Aside.submenu1": "Submenú 1",
60 | "Aside.submenu2": "Submenú 2",
61 | "NavBar.profile": "Perfil",
62 | "NavBar.logOut": "Cerrar Sesión",
63 | "NavBar.Language": "Español",
64 | "Table.perPage": " por página",
65 | "UserForm.invalidEmail": "E-mail inválido",
66 | "UserForm.userInfo": "Información del Usuario",
67 | "UserForm.email": "E-mail",
68 | "UserForm.name": "Nombre",
69 | "UserForm.location": "Localización",
70 | "UserForm.admin": "Admin",
71 | "UserForm.created": "Creado",
72 | "UserForm.logo": "Logo",
73 | "UserForm.pickAnotherFile": "Elige otro archivo",
74 | "UserForm.pickFile": "Elige un archivo",
75 | "UserForm.submit": "Enviar",
76 | "UserForm.goBack": "Regresar",
77 | "UserForm.userPreview": "Vista previa del usuario",
78 | "auth/email-already-exists": "Email ya esta siendo usado",
79 | "auth/invalid-email": "Email inválido",
80 | "auth/wrong-password": "Credenciales inválidas",
81 | "auth/user-disabled": "Usuario deshabilitado",
82 | "auth/too-many-requests": "Demasiados intentos, intententelo más tarde",
83 | "auth/expired-action-code": "El link de invitación expiró, contáctese con su administrador",
84 | "auth/invalid-action-code": "El link de invitación expiró, contáctese con su administrador",
85 | "storage/quota-exceeded": "Error interno del servidor, contáctese con su administrador",
86 | "storage/unauthenticated": "Sin autenticación, por favor autentíquese e intente de nuevo",
87 | "storage/unauthorized": "Sin autorización, no se encuentra autorizado para realizar esta acción",
88 | "auth/account-exists-with-different-credential": "Email en otro método de inicio de sesión",
89 | "auth/popup-blocked": "Pop up bloqueado por el navegador, por favor habilite los pop ups para esta página",
90 | "auth/popup-closed-by-user": "Operacion cancelada, el pop up fue cerrado",
91 | "auth/cancelled-popup-request": "Demasiados pop ups, solamente un pop up es permitido",
92 | "utils.default": "Error desconocido, contáctese con su administrador",
93 | "ErrorMessage.defaultMessage": "Este campo es requerido",
94 | "invalidEmail": "E-mail inválido"
95 | }
96 |
--------------------------------------------------------------------------------
/src/state/reducers/auth/index.js:
--------------------------------------------------------------------------------
1 | import { createReducer } from 'redux-act';
2 |
3 | import {
4 | AUTH_SIGN_IN_INIT,
5 | AUTH_SIGN_IN_FAIL,
6 | AUTH_FETCH_USER_DATA_INIT,
7 | AUTH_FETCH_USER_DATA_SUCCESS,
8 | AUTH_FETCH_USER_DATA_FAIL,
9 | AUTH_LOGOUT_INIT,
10 | AUTH_LOGOUT_SUCCESS,
11 | AUTH_RESTORE_SESSION_INIT,
12 | AUTH_RESTORE_SESSION_SUCCESS,
13 | AUTH_RESTORE_SESSION_FAIL,
14 | AUTH_SET_PASSWORD_INIT,
15 | AUTH_SET_PASSWORD_SUCCESS,
16 | AUTH_SET_PASSWORD_FAIL,
17 | AUTH_RESET_PASSWORD_INIT,
18 | AUTH_RESET_PASSWORD_SUCCESS,
19 | AUTH_RESET_PASSWORD_FAIL,
20 | AUTH_CLEAN_UP,
21 | AUTH_CHANGE_PASSWORD_INIT,
22 | AUTH_CHANGE_PASSWORD_SUCCESS,
23 | AUTH_CHANGE_PASSWORD_FAIL,
24 | AUTH_UPDATE_USER_DATA,
25 | AUTH_PROVIDER_FAIL,
26 | AUTH_PROVIDER_INIT,
27 | AUTH_PROVIDER_SUCCESS
28 | } from 'state/actions/auth';
29 |
30 | const initialState = {
31 | userData: {
32 | id: null,
33 | isAdmin: null
34 | },
35 | loading: false,
36 | error: null,
37 | restoring: false,
38 | restoringError: null,
39 | restoredPassword: false,
40 | changedPassword: false
41 | };
42 |
43 | export const authReducer = createReducer(
44 | {
45 | [AUTH_SIGN_IN_INIT]: () => ({
46 | ...initialState,
47 | loading: true
48 | }),
49 | [AUTH_SIGN_IN_FAIL]: (state, payload) => ({
50 | ...state,
51 | loading: false,
52 | error: payload.error
53 | }),
54 | [AUTH_FETCH_USER_DATA_INIT]: () => ({
55 | ...initialState,
56 | loading: true
57 | }),
58 | [AUTH_FETCH_USER_DATA_SUCCESS]: (state, payload) => ({
59 | ...state,
60 | userData: {
61 | id: payload.id,
62 | isAdmin: payload.isAdmin,
63 | email: payload.email,
64 | name: payload.name,
65 | location: payload.location,
66 | logoUrl: payload.logoUrl,
67 | createdAt: payload.createdAt
68 | },
69 | loading: false,
70 | error: null
71 | }),
72 | [AUTH_FETCH_USER_DATA_FAIL]: (state, payload) => ({
73 | ...state,
74 | loading: false,
75 | error: payload.error
76 | }),
77 | [AUTH_LOGOUT_INIT]: () => ({ ...initialState }),
78 | [AUTH_LOGOUT_SUCCESS]: state => ({ ...state }),
79 | [AUTH_RESTORE_SESSION_INIT]: state => ({ ...state, restoring: true }),
80 | [AUTH_RESTORE_SESSION_SUCCESS]: state => ({
81 | ...state,
82 | restoring: false,
83 | restoringError: null
84 | }),
85 | [AUTH_RESTORE_SESSION_FAIL]: state => ({
86 | ...state,
87 | restoring: false,
88 | restoringError: true
89 | }),
90 | [AUTH_SET_PASSWORD_INIT]: state => ({ ...state, loading: true }),
91 | [AUTH_SET_PASSWORD_SUCCESS]: state => ({
92 | ...state,
93 | loading: false,
94 | error: null
95 | }),
96 | [AUTH_SET_PASSWORD_FAIL]: (state, payload) => ({
97 | ...state,
98 | loading: false,
99 | error: payload.error
100 | }),
101 | [AUTH_RESET_PASSWORD_INIT]: () => ({
102 | ...initialState,
103 | loading: true
104 | }),
105 | [AUTH_RESET_PASSWORD_SUCCESS]: state => ({
106 | ...state,
107 | loading: false,
108 | error: null,
109 | restoredPassword: true
110 | }),
111 | [AUTH_RESET_PASSWORD_FAIL]: (state, payload) => ({
112 | ...state,
113 | loading: false,
114 | error: payload.error
115 | }),
116 | [AUTH_CLEAN_UP]: state => ({
117 | ...state,
118 | error: null,
119 | changedPassword: false
120 | }),
121 | [AUTH_CHANGE_PASSWORD_INIT]: state => ({
122 | ...state,
123 | loading: true
124 | }),
125 | [AUTH_CHANGE_PASSWORD_SUCCESS]: state => ({
126 | ...state,
127 | loading: false,
128 | changedPassword: true
129 | }),
130 | [AUTH_CHANGE_PASSWORD_FAIL]: (state, payload) => ({
131 | ...state,
132 | loading: false,
133 | error: payload.error
134 | }),
135 | [AUTH_UPDATE_USER_DATA]: (state, payload) => ({
136 | ...state,
137 | userData: {
138 | id: payload.id,
139 | email: state.userData.email,
140 | isAdmin: payload.isAdmin,
141 | name: payload.name,
142 | location: payload.location,
143 | logoUrl: payload.logoUrl || state.userData.logoUrl,
144 | createdAt: payload.createdAt
145 | }
146 | }),
147 | [AUTH_PROVIDER_INIT]: state => ({
148 | ...state,
149 | loading: true
150 | }),
151 | [AUTH_PROVIDER_SUCCESS]: (state, payload) => ({
152 | ...state,
153 | userData: {
154 | id: payload.id,
155 | isAdmin: payload.isAdmin,
156 | email: payload.email,
157 | name: payload.name,
158 | location: payload.location,
159 | logoUrl: payload.logoUrl,
160 | createdAt: payload.createdAt
161 | },
162 | error: null,
163 | loading: false
164 | }),
165 | [AUTH_PROVIDER_FAIL]: (state, payload) => ({
166 | ...state,
167 | loading: false,
168 | error: payload.error
169 | })
170 | },
171 | initialState
172 | );
173 |
--------------------------------------------------------------------------------
/src/components/ConfirmationModal/ConfirmationModal.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, fireEvent } from '@testing-library/react';
3 |
4 | import ConfirmationModal from '.';
5 |
6 | describe(' rendering', () => {
7 | const onConfirm = jest.fn();
8 | const onCancel = jest.fn();
9 |
10 | beforeEach(() => {
11 | onConfirm.mockClear();
12 | onCancel.mockClear();
13 | });
14 |
15 | it('should render without crashing', () => {
16 | const component = render(
17 |
26 | );
27 |
28 | expect(component.asFragment()).toMatchSnapshot();
29 | });
30 |
31 | it('should set the active modifier if the isActive prop is passed down', () => {
32 | const component = render(
33 |
42 | );
43 |
44 | expect(
45 | component.container.querySelector('div.modal.is-active')
46 | ).toBeTruthy();
47 | });
48 |
49 | it('should not set the active modifier if the isActive prop is not passed down', () => {
50 | const component = render(
51 |
59 | );
60 |
61 | expect(component.container.querySelector('div.modal.is-active')).toBeNull();
62 | });
63 |
64 | it('should call onConfirm when the confirmation button is clicked', () => {
65 | const component = render(
66 |
75 | );
76 |
77 | fireEvent.click(component.getByText('confirm test message'));
78 | expect(onConfirm).toHaveBeenCalled();
79 | });
80 |
81 | it('should call onCancel when the cancel button is clicked', () => {
82 | const component = render(
83 |
92 | );
93 |
94 | fireEvent.click(component.getByText('cancel test message'));
95 |
96 | expect(onCancel).toHaveBeenCalled();
97 | });
98 |
99 | it('should set the title of the modal', () => {
100 | const component = render(
101 |
110 | );
111 |
112 | expect(component.getByText('test title')).toBeTruthy();
113 | });
114 |
115 | it('should set the body of the modal', () => {
116 | const component = render(
117 |
126 | );
127 |
128 | expect(component.getByText('test body')).toBeTruthy();
129 | });
130 |
131 | it('should set the confirm button message', () => {
132 | const component = render(
133 |
142 | );
143 |
144 | expect(component.getByText('confirm test message')).toBeTruthy();
145 | });
146 |
147 | it('should set the cancel button message', () => {
148 | const component = render(
149 |
158 | );
159 |
160 | expect(component.getByText('cancel test message')).toBeTruthy();
161 | });
162 | });
163 |
--------------------------------------------------------------------------------
/functions/tslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "rules": {
3 | // -- Strict errors --
4 | // These lint rules are likely always a good idea.
5 |
6 | // Force function overloads to be declared together. This ensures readers understand APIs.
7 | "adjacent-overload-signatures": true,
8 |
9 | // Do not allow the subtle/obscure comma operator.
10 | "ban-comma-operator": true,
11 |
12 | // Do not allow internal modules or namespaces . These are deprecated in favor of ES6 modules.
13 | "no-namespace": true,
14 |
15 | // Do not allow parameters to be reassigned. To avoid bugs, developers should instead assign new values to new vars.
16 | "no-parameter-reassignment": true,
17 |
18 | // Force the use of ES6-style imports instead of /// imports.
19 | "no-reference": true,
20 |
21 | // Do not allow type assertions that do nothing. This is a big warning that the developer may not understand the
22 | // code currently being edited (they may be incorrectly handling a different type case that does not exist).
23 | "no-unnecessary-type-assertion": true,
24 |
25 | // Disallow nonsensical label usage.
26 | "label-position": true,
27 |
28 | // Disallows the (often typo) syntax if (var1 = var2). Replace with if (var2) { var1 = var2 }.
29 | "no-conditional-assignment": true,
30 |
31 | // Disallows constructors for primitive types (e.g. new Number('123'), though Number('123') is still allowed).
32 | "no-construct": true,
33 |
34 | // Do not allow super() to be called twice in a constructor.
35 | "no-duplicate-super": true,
36 |
37 | // Do not allow the same case to appear more than once in a switch block.
38 | "no-duplicate-switch-case": true,
39 |
40 | // Do not allow a variable to be declared more than once in the same block. Consider function parameters in this
41 | // rule.
42 | "no-duplicate-variable": [true, "check-parameters"],
43 |
44 | // Disallows a variable definition in an inner scope from shadowing a variable in an outer scope. Developers should
45 | // instead use a separate variable name.
46 | "no-shadowed-variable": true,
47 |
48 | // Empty blocks are almost never needed. Allow the one general exception: empty catch blocks.
49 | "no-empty": [true, "allow-empty-catch"],
50 |
51 | // Functions must either be handled directly (e.g. with a catch() handler) or returned to another function.
52 | // This is a major source of errors in Cloud Functions and the team strongly recommends leaving this rule on.
53 | "no-floating-promises": true,
54 |
55 | // Do not allow any imports for modules that are not in package.json. These will almost certainly fail when
56 | // deployed.
57 | "no-implicit-dependencies": true,
58 |
59 | // The 'this' keyword can only be used inside of classes.
60 | "no-invalid-this": true,
61 |
62 | // Do not allow strings to be thrown because they will not include stack traces. Throw Errors instead.
63 | "no-string-throw": true,
64 |
65 | // Disallow control flow statements, such as return, continue, break, and throw in finally blocks.
66 | "no-unsafe-finally": true,
67 |
68 | // Expressions must always return a value. Avoids common errors like const myValue = functionReturningVoid();
69 | "no-void-expression": [true, "ignore-arrow-function-shorthand"],
70 |
71 | // Disallow duplicate imports in the same file.
72 | "no-duplicate-imports": true,
73 |
74 |
75 | // -- Strong Warnings --
76 | // These rules should almost never be needed, but may be included due to legacy code.
77 | // They are left as a warning to avoid frustration with blocked deploys when the developer
78 | // understand the warning and wants to deploy anyway.
79 |
80 | // Warn when an empty interface is defined. These are generally not useful.
81 | "no-empty-interface": {"severity": "warning"},
82 |
83 | // Warn when an import will have side effects.
84 | "no-import-side-effect": {"severity": "warning"},
85 |
86 | // Warn when variables are defined with var. Var has subtle meaning that can lead to bugs. Strongly prefer const for
87 | // most values and let for values that will change.
88 | "no-var-keyword": {"severity": "warning"},
89 |
90 | // Prefer === and !== over == and !=. The latter operators support overloads that are often accidental.
91 | "triple-equals": {"severity": "warning"},
92 |
93 | // Warn when using deprecated APIs.
94 | "deprecation": {"severity": "warning"},
95 |
96 | // -- Light Warnings --
97 | // These rules are intended to help developers use better style. Simpler code has fewer bugs. These would be "info"
98 | // if TSLint supported such a level.
99 |
100 | // prefer for( ... of ... ) to an index loop when the index is only used to fetch an object from an array.
101 | // (Even better: check out utils like .map if transforming an array!)
102 | "prefer-for-of": {"severity": "warning"},
103 |
104 | // Warns if function overloads could be unified into a single function with optional or rest parameters.
105 | "unified-signatures": {"severity": "warning"},
106 |
107 | // Prefer const for values that will not change. This better documents code.
108 | "prefer-const": {"severity": "warning"},
109 |
110 | // Multi-line object literals and function calls should have a trailing comma. This helps avoid merge conflicts.
111 | "trailing-comma": {"severity": "warning"}
112 | },
113 |
114 | "defaultSeverity": "error"
115 | }
116 |
--------------------------------------------------------------------------------
/src/serviceWorker.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 | /* eslint-disable no-param-reassign */
3 | /* eslint-disable no-use-before-define */
4 | // This optional code is used to register a service worker.
5 | // register() is not called by default.
6 |
7 | // This lets the app load faster on subsequent visits in production, and gives
8 | // it offline capabilities. However, it also means that developers (and users)
9 | // will only see deployed updates on subsequent visits to a page, after all the
10 | // existing tabs open on the page have been closed, since previously cached
11 | // resources are updated in the background.
12 |
13 | // To learn more about the benefits of this model and instructions on how to
14 | // opt-in, read https://bit.ly/CRA-PWA
15 |
16 | const isLocalhost = Boolean(
17 | window.location.hostname === 'localhost' ||
18 | // [::1] is the IPv6 localhost address.
19 | window.location.hostname === '[::1]' ||
20 | // 127.0.0.1/8 is considered localhost for IPv4.
21 | window.location.hostname.match(
22 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
23 | )
24 | );
25 |
26 | export function register(config) {
27 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
28 | // The URL constructor is available in all browsers that support SW.
29 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
30 | if (publicUrl.origin !== window.location.origin) {
31 | // Our service worker won't work if PUBLIC_URL is on a different origin
32 | // from what our page is served on. This might happen if a CDN is used to
33 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374
34 | return;
35 | }
36 |
37 | window.addEventListener('load', () => {
38 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
39 |
40 | if (isLocalhost) {
41 | // This is running on localhost. Let's check if a service worker still exists or not.
42 | checkValidServiceWorker(swUrl, config);
43 |
44 | // Add some additional logging to localhost, pointing developers to the
45 | // service worker/PWA documentation.
46 | navigator.serviceWorker.ready.then(() => {
47 | console.log(
48 | 'This web app is being served cache-first by a service ' +
49 | 'worker. To learn more, visit https://bit.ly/CRA-PWA'
50 | );
51 | });
52 | } else {
53 | // Is not localhost. Just register service worker
54 | registerValidSW(swUrl, config);
55 | }
56 | });
57 | }
58 | }
59 |
60 | function registerValidSW(swUrl, config) {
61 | navigator.serviceWorker
62 | .register(swUrl)
63 | .then(registration => {
64 | registration.onupdatefound = () => {
65 | const installingWorker = registration.installing;
66 | if (installingWorker == null) {
67 | return;
68 | }
69 | installingWorker.onstatechange = () => {
70 | if (installingWorker.state === 'installed') {
71 | if (navigator.serviceWorker.controller) {
72 | // At this point, the updated precached content has been fetched,
73 | // but the previous service worker will still serve the older
74 | // content until all client tabs are closed.
75 | console.log(
76 | 'New content is available and will be used when all ' +
77 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
78 | );
79 |
80 | // Execute callback
81 | if (config && config.onUpdate) {
82 | config.onUpdate(registration);
83 | }
84 | } else {
85 | // At this point, everything has been precached.
86 | // It's the perfect time to display a
87 | // "Content is cached for offline use." message.
88 | console.log('Content is cached for offline use.');
89 |
90 | // Execute callback
91 | if (config && config.onSuccess) {
92 | config.onSuccess(registration);
93 | }
94 | }
95 | }
96 | };
97 | };
98 | })
99 | .catch(error => {
100 | console.error('Error during service worker registration:', error);
101 | });
102 | }
103 |
104 | function checkValidServiceWorker(swUrl, config) {
105 | // Check if the service worker can be found. If it can't reload the page.
106 | fetch(swUrl)
107 | .then(response => {
108 | // Ensure service worker exists, and that we really are getting a JS file.
109 | const contentType = response.headers.get('content-type');
110 | if (
111 | response.status === 404 ||
112 | (contentType != null && contentType.indexOf('javascript') === -1)
113 | ) {
114 | // No service worker found. Probably a different app. Reload the page.
115 | navigator.serviceWorker.ready.then(registration => {
116 | registration.unregister().then(() => {
117 | window.location.reload();
118 | });
119 | });
120 | } else {
121 | // Service worker found. Proceed as normal.
122 | registerValidSW(swUrl, config);
123 | }
124 | })
125 | .catch(() => {
126 | console.log(
127 | 'No internet connection found. App is running in offline mode.'
128 | );
129 | });
130 | }
131 |
132 | export function unregister() {
133 | if ('serviceWorker' in navigator) {
134 | navigator.serviceWorker.ready.then(registration => {
135 | registration.unregister();
136 | });
137 | }
138 | }
139 |
--------------------------------------------------------------------------------
/src/pages/ResetPassword/index.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import { useDispatch, useSelector, shallowEqual } from 'react-redux';
3 | import { Redirect, Link } from 'react-router-dom';
4 | import classNames from 'classnames';
5 | import { useForm } from 'react-hook-form';
6 | import { yupResolver } from '@hookform/resolvers';
7 | import * as yup from 'yup';
8 |
9 | import { useFormatMessage } from 'hooks';
10 | import { resetPassword, authCleanUp } from 'state/actions/auth';
11 | import paths from 'pages/Router/paths';
12 | import ErrorMessage from 'components/ErrorMessage';
13 |
14 | import classes from './ResetPassword.module.scss';
15 |
16 | const schema = yup.object().shape({
17 | email: yup.string().email().required(),
18 | });
19 |
20 | const ResetPassword = () => {
21 | const { loading, error, restoredPassword, isAuth } = useSelector(
22 | (state) => ({
23 | loading: state.auth.loading,
24 | error: state.auth.error,
25 | restoredPassword: state.auth.restoredPassword,
26 | isAuth: !!state.auth.userData.userId,
27 | }),
28 | shallowEqual
29 | );
30 |
31 | const dispatch = useDispatch();
32 |
33 | const { register, handleSubmit, errors, watch } = useForm({
34 | resolver: yupResolver(schema),
35 | });
36 |
37 | useEffect(() => {
38 | document.documentElement.classList.remove(
39 | 'has-aside-left',
40 | 'has-navbar-fixed-top'
41 | );
42 | return () => {
43 | document.documentElement.classList.add(
44 | 'has-aside-left',
45 | 'has-navbar-fixed-top'
46 | );
47 | dispatch(authCleanUp());
48 | };
49 | }, [dispatch]);
50 |
51 | const onSubmitHandler = ({ email }) => {
52 | dispatch(resetPassword(email));
53 | };
54 |
55 | const redirect = isAuth && ;
56 |
57 | const recoverEmailMessage = useFormatMessage('ResetPassword.recoverEmail', {
58 | mail: watch('email'),
59 | });
60 | const emailMessage = useFormatMessage('ResetPassword.email');
61 | const emailRegistrationMessage = useFormatMessage(
62 | 'ResetPassword.emailRegistration'
63 | );
64 | const resetLinkMessage = useFormatMessage('ResetPassword.resetLink');
65 | const backMessage = useFormatMessage('ResetPassword.back');
66 |
67 | const invalidEmailMessage = useFormatMessage('invalidEmail');
68 |
69 | return (
70 |
71 | {redirect}
72 |
73 |
74 |
75 |
76 |
77 |
85 |
86 | {restoredPassword ? (
87 |
88 | {recoverEmailMessage}
89 |
90 | ) : (
91 |
134 | )}
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 | );
143 | };
144 |
145 | export default ResetPassword;
146 |
--------------------------------------------------------------------------------
/src/components/UserForm/UserForm.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import * as reactRedux from 'react-redux';
3 | import { fireEvent } from '@testing-library/react';
4 | import '@testing-library/jest-dom';
5 | import * as yup from 'yup';
6 |
7 | import * as actions from 'state/actions/users';
8 | import UserForm from '.';
9 |
10 | const schema = yup.object().shape({
11 | email: yup.string().email().required(),
12 | name: yup.string().required(),
13 | isAdmin: yup.boolean().notRequired(),
14 | location: yup.string().notRequired(),
15 | createdAt: yup.string().required(),
16 | });
17 |
18 | describe(' rendering', () => {
19 | let userData;
20 | const dispatchMock = jest.fn();
21 |
22 | beforeEach(() => {
23 | userData = {
24 | email: 'mkrukuy@gmail.com',
25 | name: 'Mateo',
26 | location: 'Montevideo, Uruguay',
27 | isAdmin: false,
28 | file: null,
29 | id: 'test id',
30 | logoUrl: 'some logoUrl',
31 | createdAt: '11/12/2020',
32 | };
33 | jest
34 | .spyOn(reactRedux, 'useDispatch')
35 | .mockImplementation(() => dispatchMock);
36 | jest.spyOn(actions, 'usersCleanUp').mockImplementation(jest.fn);
37 | });
38 |
39 | it('should render without crashing', () => {
40 | const user = { ...userData, createdAt: '11/21/2020' };
41 |
42 | const { component } = renderWithProviders(
43 |
48 | )({
49 | users: {},
50 | });
51 |
52 | expect(component.asFragment()).toMatchSnapshot();
53 | });
54 |
55 | it('should display user name preview', () => {
56 | const { component } = renderWithProviders(
57 |
62 | )({
63 | users: {},
64 | });
65 |
66 | expect(component.getByTestId('name')).toHaveAttribute('value', 'Mateo');
67 | });
68 |
69 | it('should display email preview if it is creating a new user', () => {
70 | const { component } = renderWithProviders(
71 |
76 | )({
77 | users: {},
78 | });
79 |
80 | expect(component.getByTestId('email')).toHaveAttribute(
81 | 'value',
82 | 'mkrukuy@gmail.com'
83 | );
84 | });
85 |
86 | it('should display location preview', () => {
87 | const { component } = renderWithProviders(
88 |
94 | )({
95 | users: {},
96 | });
97 |
98 | expect(component.getByTestId('location')).toHaveAttribute(
99 | 'value',
100 | 'Montevideo, Uruguay'
101 | );
102 | });
103 |
104 | it('should display admin preview', () => {
105 | const { component } = renderWithProviders(
106 |
112 | )({
113 | users: {},
114 | });
115 |
116 | expect(component.getByTestId('admin')).toBeTruthy();
117 | });
118 |
119 | it('should display created preview', () => {
120 | const { component } = renderWithProviders(
121 |
127 | )({
128 | users: {},
129 | });
130 | expect(component.getByTestId('date')).toBeTruthy();
131 | });
132 | });
133 |
134 | describe(' actions', () => {
135 | const dispatchMock = jest.fn();
136 |
137 | let userData;
138 | beforeEach(() => {
139 | jest
140 | .spyOn(reactRedux, 'useDispatch')
141 | .mockImplementation(() => dispatchMock);
142 | jest.spyOn(actions, 'createUser').mockImplementation(jest.fn);
143 | jest.spyOn(actions, 'modifyUser').mockImplementation(jest.fn);
144 | userData = {
145 | name: 'Mateo',
146 | email: 'mkrukuy@gmail.com',
147 | location: 'Montevideo, Uruguay',
148 | id: 'test id',
149 | logoUrl: 'some logoUrl',
150 | isAdmin: false,
151 | file: null,
152 | createdAt: '11/12/2020',
153 | };
154 | });
155 |
156 | it('should dispatch createUser action when creating a new user', async () => {
157 | const { component } = renderWithProviders(
158 |
163 | )({
164 | users: {},
165 | });
166 |
167 | fireEvent.submit(component.container.querySelector('form'));
168 |
169 | await (() => expect(actions.createUser).toBeCalledTimes(1));
170 |
171 | await (() => expect(actions.createUser).toBeCalledWith(userData));
172 | });
173 |
174 | it('should dispatch modifyUser action when editing a user', async () => {
175 | const { component } = renderWithProviders(
176 |
182 | )({
183 | users: {},
184 | });
185 |
186 | fireEvent.submit(component.container.querySelector('form'));
187 |
188 | await (() => expect(actions.modifyUser).toBeCalledTimes(1));
189 |
190 | await (() => expect(actions.modifyUser).toBeCalledWith(userData));
191 | });
192 | });
193 |
--------------------------------------------------------------------------------
/src/components/Navigation/NavBar/index.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useCallback } from 'react';
2 | import { useDispatch, useSelector, shallowEqual } from 'react-redux';
3 | import classNames from 'classnames';
4 | import PropTypes from 'prop-types';
5 |
6 | import { availableLocales, flags } from 'utils';
7 | import { setUserLocale } from 'state/actions/preferences';
8 | import { useFormatMessage } from 'hooks';
9 | import { logout } from 'state/actions/auth';
10 | import paths from 'pages/Router/paths';
11 | import defaultLogo from 'assets/user-default-log.svg';
12 | import Link from '../Link';
13 |
14 | const NavBar = ({ handleMobileToggle, asideMobileActive }) => {
15 | const [navMobileActive, setNavMobileActive] = useState(false);
16 |
17 | const { userName, logoUrl, locale } = useSelector(
18 | (state) => ({
19 | userName: state.auth.userData.name,
20 | logoUrl: state.auth.userData.logoUrl,
21 | locale: state.preferences.locale,
22 | }),
23 | shallowEqual
24 | );
25 |
26 | const dispatch = useDispatch();
27 |
28 | const onClickLogoutHandler = () => {
29 | dispatch(logout());
30 | };
31 |
32 | const onMobileToggleHandler = useCallback(() => {
33 | setNavMobileActive(!navMobileActive);
34 | }, [setNavMobileActive, navMobileActive]);
35 |
36 | const changeLocaleHandler = (local) => {
37 | dispatch(setUserLocale(local));
38 | };
39 |
40 | const locales = availableLocales.filter((local) => local !== locale);
41 |
42 | return (
43 |
148 | );
149 | };
150 |
151 | NavBar.propTypes = {
152 | handleMobileToggle: PropTypes.func.isRequired,
153 | asideMobileActive: PropTypes.bool,
154 | };
155 |
156 | export default NavBar;
157 |
--------------------------------------------------------------------------------
/src/state/reducers/users/users.test.js:
--------------------------------------------------------------------------------
1 | import {
2 | USERS_FETCH_DATA_INIT,
3 | USERS_FETCH_DATA_SUCCESS,
4 | USERS_FETCH_DATA_FAIL,
5 | USERS_DELETE_USER_INIT,
6 | USERS_DELETE_USER_SUCCESS,
7 | USERS_DELETE_USER_FAIL,
8 | USERS_CREATE_USER_INIT,
9 | USERS_CREATE_USER_SUCCESS,
10 | USERS_CREATE_USER_FAIL,
11 | USERS_MODIFY_USER_INIT,
12 | USERS_MODIFY_USER_SUCCESS,
13 | USERS_MODIFY_USER_FAIL,
14 | USERS_CLEAN_UP,
15 | USERS_CLEAR_DATA_LOGOUT,
16 | } from 'state/actions/users';
17 |
18 | import { usersReducer } from '.';
19 |
20 | describe('Establishments reducer', () => {
21 | const initialState = {
22 | data: [],
23 | loading: false,
24 | error: null,
25 | success: false,
26 | deleted: false,
27 | };
28 |
29 | const reducerTest = reducerTester(usersReducer);
30 |
31 | it('should return the initial state', () => {
32 | reducerTest(initialState, {}, initialState);
33 | });
34 |
35 | it('should reset to the initialState and set loading to true when USERS_FETCH_DATA_INIT action is fired', () => {
36 | reducerTest(initialState, USERS_FETCH_DATA_INIT(), {
37 | ...initialState,
38 | loading: true,
39 | });
40 | });
41 |
42 | it('should set the state with the corresponding error and set loading to false when USERS_FETCH_DATA_FAIL actions is fired', () => {
43 | reducerTest(initialState, USERS_FETCH_DATA_FAIL({ error: 'some error' }), {
44 | ...initialState,
45 | error: 'some error',
46 | loading: false,
47 | });
48 | });
49 |
50 | it('should set error to null, loading to false and data with the corresponding values when USERS_FETCH_DATA_SUCCESS actions is fired', () => {
51 | const userData = [
52 | {
53 | name: 'Test name',
54 | email: 'Test email',
55 | location: 'Test location',
56 | createdAt: '11/20/2020',
57 | },
58 | ];
59 |
60 | reducerTest(initialState, USERS_FETCH_DATA_SUCCESS({ data: userData }), {
61 | ...initialState,
62 | data: userData,
63 | loading: false,
64 | error: null,
65 | });
66 | });
67 |
68 | it('should set error to null, loading to false and user with the corresponding values when USERS_FETCH_DATA_SUCCESS actions is fired', () => {
69 | const data = [
70 | {
71 | name: 'Test name',
72 | email: 'Test email',
73 | location: 'Test location',
74 | createdAt: '11/20/2020',
75 | },
76 | ];
77 | reducerTest(initialState, USERS_FETCH_DATA_SUCCESS({ data }), {
78 | ...initialState,
79 | data,
80 | loading: false,
81 | error: null,
82 | });
83 | });
84 |
85 | it('should set loading to true when USERS_DELETE_USER_INIT action is fired', () => {
86 | reducerTest(initialState, USERS_DELETE_USER_INIT(), {
87 | ...initialState,
88 | loading: true,
89 | });
90 | });
91 |
92 | it('should remove the corresponding establishment from the state, set loading to false and error to null when USERS_DELETE_USER_SUCCESS action is fired', () => {
93 | const user = { id: 'exampleId' };
94 |
95 | reducerTest(
96 | { ...initialState, data: [user] },
97 | USERS_DELETE_USER_SUCCESS({ id: 'exampleId' }),
98 | { ...initialState, error: null, loading: false, deleted: true }
99 | );
100 | });
101 |
102 | it('should set the state with the corresponding error and set loading to false when USERS_DELETE_USER_FAIL actions is fired', () => {
103 | reducerTest(initialState, USERS_DELETE_USER_FAIL({ error: 'some error' }), {
104 | ...initialState,
105 | error: 'some error',
106 | loading: false,
107 | });
108 | });
109 |
110 | it('should reset the state to the initial state when USERS_CLEAR_DATA_LOGOUT action is fired', () => {
111 | reducerTest(initialState, USERS_CLEAR_DATA_LOGOUT(), initialState);
112 | });
113 |
114 | it('should set loading to true to the current state when USERS_CREATE_USER_INIT action is fired', () => {
115 | reducerTest(initialState, USERS_CREATE_USER_INIT(), {
116 | ...initialState,
117 | loading: true,
118 | });
119 | });
120 |
121 | it('should set error to null, loading to false and add the new user to the current state when USERS_CREATE_USER_SUCCESS action is fired', () => {
122 | const user = [
123 | {
124 | name: 'some name',
125 | location: 'some location',
126 | email: 'some location',
127 | },
128 | ];
129 |
130 | reducerTest(initialState, USERS_CREATE_USER_SUCCESS({ user }), {
131 | ...initialState,
132 | data: user,
133 | success: true,
134 | });
135 | });
136 |
137 | it('should set loading to false and error with the corresponding payload to the current state USERS_CREATE_USER_FAIL action is fired', () => {
138 | reducerTest(initialState, USERS_CREATE_USER_FAIL({ error: 'some error' }), {
139 | ...initialState,
140 | error: 'some error',
141 | });
142 | });
143 |
144 | it('should set loading to true when USERS_MODIFY_USER_INIT action is fired', () => {
145 | reducerTest(initialState, USERS_MODIFY_USER_INIT(), {
146 | ...initialState,
147 | loading: true,
148 | });
149 | });
150 |
151 | it('should set loading to false, error to null and modify the corresponding user when USERS_MODIFY_USER_SUCCESS action is fired', () => {
152 | const initialUsers = [
153 | {
154 | name: 'test name',
155 | location: 'test location',
156 | email: 'test email',
157 | id: 'test id',
158 | logoUrl: 'some logo',
159 | createdAt: '11/20/2020',
160 | },
161 | ];
162 |
163 | const resultUser = {
164 | name: 'test name 2',
165 | location: 'test location',
166 | email: 'test email',
167 | id: 'test id',
168 | logoUrl: 'some logo',
169 | createdAt: '11/20/2020',
170 | };
171 |
172 | reducerTest(
173 | { ...initialState, data: initialUsers },
174 | USERS_MODIFY_USER_SUCCESS({
175 | user: resultUser,
176 | id: 'test id',
177 | }),
178 | {
179 | ...initialState,
180 | data: [resultUser],
181 | loading: false,
182 | error: null,
183 | success: true,
184 | }
185 | );
186 | });
187 |
188 | it('should set loading to false and error with the corresponding payload to the current state USERS_MODIFY_USER_FAIL action is fired', () => {
189 | reducerTest(initialState, USERS_MODIFY_USER_FAIL({ error: 'some error' }), {
190 | ...initialState,
191 | error: 'some error',
192 | });
193 | });
194 |
195 | it('should set loading, success and deleted to false and error to null when USERS_CLEAN_UP action is fired', () => {
196 | reducerTest(initialState, USERS_CLEAN_UP(), {
197 | ...initialState,
198 | loading: false,
199 | success: false,
200 | deleted: false,
201 | error: null,
202 | });
203 | });
204 | });
205 |
--------------------------------------------------------------------------------
/src/pages/Users/index.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { useDispatch, useSelector, shallowEqual } from 'react-redux';
3 | import { Redirect, Link } from 'react-router-dom';
4 | import classNames from 'classnames';
5 | import ClipLoader from 'react-spinners/ClipLoader';
6 |
7 | import { useFormatMessage, useFormatDate } from 'hooks';
8 | import Table from 'components/Table';
9 | import { fetchUsers, deleteUser, usersCleanUp } from 'state/actions/users';
10 | import paths from 'pages/Router/paths';
11 | import ConfirmationModal from 'components/ConfirmationModal';
12 | import classes from './Users.module.scss';
13 |
14 | const Users = () => {
15 | const { usersList, isAdmin, error, loading, deleted } = useSelector(
16 | (state) => ({
17 | usersList: state.users.data,
18 | isAdmin: state.auth.userData.isAdmin,
19 | error: state.users.error,
20 | loading: state.users.loading,
21 | deleted: state.users.deleted,
22 | }),
23 | shallowEqual
24 | );
25 |
26 | const [deleteModal, setDeleteModal] = useState({
27 | userId: null,
28 | isOpen: false,
29 | });
30 |
31 | const dispatch = useDispatch();
32 |
33 | const [search, setSearch] = useState('');
34 |
35 | useEffect(() => {
36 | if (isAdmin) {
37 | dispatch(fetchUsers());
38 | }
39 |
40 | return () => dispatch(usersCleanUp());
41 | }, [dispatch, isAdmin]);
42 |
43 | useEffect(() => {
44 | if (deleted && !loading) {
45 | setDeleteModal((prevState) => ({
46 | userId: null,
47 | isOpen: !prevState.isOpen,
48 | }));
49 | }
50 | }, [deleted, loading]);
51 |
52 | const redirect = !isAdmin && ;
53 |
54 | const onRemoveButtonClickHandler = (userId) => {
55 | setDeleteModal((prevState) => ({
56 | userId,
57 | isOpen: !prevState.isOpen,
58 | }));
59 | };
60 |
61 | const onCloseModalHandler = () => {
62 | setDeleteModal({ userId: null, isOpen: false });
63 | };
64 |
65 | const onDeleteUserHandler = () => {
66 | dispatch(deleteUser(deleteModal.userId));
67 | };
68 |
69 | const columns = [
70 | {
71 | Header: '',
72 | accessor: 'logoUrl',
73 | Cell: ({ row }) => (
74 |
75 |

76 |
77 | ),
78 | disableSortBy: true,
79 | },
80 | {
81 | Header: useFormatMessage('Users.name'),
82 | accessor: 'name',
83 | },
84 | {
85 | Header: useFormatMessage('Users.email'),
86 | accessor: 'email',
87 | },
88 | {
89 | Header: useFormatMessage('Users.location'),
90 | accessor: 'location',
91 | },
92 | {
93 | Header: useFormatMessage('Users.admin'),
94 | accessor: 'isAdmin',
95 | Cell: ({ row }) => (
96 |
97 | {row.original.isAdmin ? (
98 |
99 |
100 |
101 | ) : (
102 |
103 |
104 |
105 | )}
106 |
107 | ),
108 | },
109 | {
110 | Header: useFormatMessage('Users.created'),
111 | accessor: 'created',
112 | Cell: ({ row }) => (
113 |
114 | {useFormatDate(row.original.createdAt, {
115 | weekday: 'short',
116 | year: 'numeric',
117 | month: 'short',
118 | day: 'numeric',
119 | })}
120 |
121 | ),
122 | },
123 | {
124 | Header: '',
125 | id: 'actions',
126 | accessor: 'actions',
127 | Cell: ({ row }) => (
128 | <>
129 | {!row.original.isAdmin && (
130 |
131 |
135 |
136 |
137 |
138 |
139 |
140 |
149 |
150 | )}
151 | >
152 | ),
153 | disableSortBy: true,
154 | },
155 | ];
156 |
157 | const data = search
158 | ? usersList.filter((el) => {
159 | const clonedElem = { ...el };
160 | delete clonedElem.id;
161 | delete clonedElem.isAdmin;
162 | delete clonedElem.logoUrl;
163 | return Object.values(clonedElem).some((field) =>
164 | field.toLowerCase().includes(search.toLowerCase())
165 | );
166 | })
167 | : usersList;
168 |
169 | const deleteMessage = useFormatMessage('Users.delete');
170 |
171 | const confirmMessage = useFormatMessage('Users.confirm');
172 |
173 | const permDeleteMessage = useFormatMessage('Users.permDelete');
174 |
175 | const cancelMessage = useFormatMessage('Users.cancel');
176 |
177 | return (
178 | <>
179 | {redirect}
180 | {deleteModal.isOpen && (
181 |
191 | )}
192 |
193 |
194 |
195 |
196 |
197 |
{useFormatMessage('Users.users')}
198 |
199 |
200 |
201 |
202 |
203 | {useFormatMessage('Users.newUser')}
204 |
205 |
206 |
207 |
208 |
209 |
210 |
211 |
212 |
223 |
224 | {loading ?
:
}
225 | {error && 'Show error'}
226 |
227 |
228 |
229 | >
230 | );
231 | };
232 |
233 | export default Users;
234 |
--------------------------------------------------------------------------------
/src/pages/Profile/ChangePassword/index.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable jsx-a11y/label-has-associated-control */
2 | import React, { useEffect } from 'react';
3 | import { useDispatch, useSelector, shallowEqual } from 'react-redux';
4 | import { useForm } from 'react-hook-form';
5 | import { yupResolver } from '@hookform/resolvers';
6 | import * as yup from 'yup';
7 | import classNames from 'classnames';
8 |
9 | import { changeUserPassword, authCleanUp } from 'state/actions/auth';
10 | import { useFormatMessage } from 'hooks';
11 | import ErrorMessage from 'components/ErrorMessage';
12 |
13 | const schema = yup.object().shape({
14 | current: yup.string().min(6).required(),
15 | new: yup
16 | .string()
17 | .min(6)
18 | .notOneOf([yup.ref('current')])
19 | .required(),
20 | confirmation: yup
21 | .string()
22 | .equals([yup.ref('new')])
23 | .required(),
24 | });
25 |
26 | const ChangePasswordCard = () => {
27 | const { loading, changedPassword } = useSelector(
28 | (state) => ({
29 | loading: state.auth.loading,
30 | changedPassword: state.auth.changedPassword,
31 | }),
32 | shallowEqual
33 | );
34 |
35 | const dispatch = useDispatch();
36 |
37 | const { register, handleSubmit, watch, setValue, errors } = useForm({
38 | mode: 'onChange',
39 | defaultValues: {
40 | current: '',
41 | new: '',
42 | confirmation: '',
43 | },
44 | resolver: yupResolver(schema),
45 | });
46 |
47 | useEffect(() => {
48 | if (changedPassword) {
49 | setValue('current', '');
50 | setValue('new', '');
51 | setValue('confirmation', '');
52 | }
53 | return () => dispatch(authCleanUp());
54 | }, [dispatch, changedPassword, setValue]);
55 |
56 | const newPassword = watch('new');
57 | const confirmationPassword = watch('confirmation');
58 |
59 | const invalidPasswordMessage = useFormatMessage(
60 | `ChangePassword.invalidPassword`
61 | );
62 |
63 | const safePasswordMessage = useFormatMessage(`ChangePassword.safePassword`);
64 |
65 | const insecurePasswordMessage = useFormatMessage(
66 | `ChangePassword.insecurePassword`
67 | );
68 |
69 | const passwordsMatchMessagge = useFormatMessage(
70 | `ChangePassword.matchPassword`
71 | );
72 |
73 | const notMatchPasswordMessage = useFormatMessage(
74 | `ChangePassword.notMatchPassword`
75 | );
76 |
77 | const samePasswordMessage = useFormatMessage(`ChangePassword.samePassword`);
78 |
79 | const onSubmitHandler = ({ current, confirmation }) => {
80 | dispatch(changeUserPassword(current, confirmation));
81 | };
82 |
83 | return (
84 |
213 | );
214 | };
215 |
216 | export default ChangePasswordCard;
217 |
--------------------------------------------------------------------------------