$ git clone https://github.com/yemiwebby/ci-secure-api.git
// move into the project $ cd ci-secure-api //install the application dependencies $ composer install // run the application $ php spark serve
CTRL + C
keys on your keyboard and proceed to make a copy of the env file named .env using the command below:$ cp env .env
CI_ENVIRONMENT = development
database.default.hostname = localhost database.default.database = YOUR_DATABASE database.default.username = YOUR_DATABASE_USERNAME database.default.password = YOUR_DATABASE_PASSWORD database.default.DBDriver = MySQLi # this is the driver for a MySQL connection. There are also drivers available for postgres & sqlite3.
YOUR_DATABASE
, YOUR_DATABASE_USERNAME
and YOUR_DATABASE_PASSWORD
placeholders with your values.// Run migrations to create tables $ php spark migrate // seed tables with default values $ php spark db:seed ClientSeeder
set404Override
function.$routes->set404Override(function () { echo view('welcome_message'); });
error_404
view, our API will render the index page and allow React to determine what should be displayed - be it a corresponding view or a 404
page.welcome_message
. You can find this file in the app/Views directory. Open up the file and replace it’s content as follows:<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>Welcome to CodeIgniter 4!</title> <meta name="description" content="The small framework with powerful features"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <link rel="shortcut icon" type="image/png" href="/favicon.ico"/> <base href="/"/> <style> #app { margin: 50px; } </style> </head> <body> <div id="app"> </div> <!-- SCRIPTS --> <script src="dist/main.js" charset="utf-8"></script> <!-- --> </body> </html>
body
element is a div
with an id app
. This div is targeted by our React app to render our components accordingly. We also added a <script>
tag to load our bundled JavaScript file. <base>
tag. This is important because it allows our application to properly handle nested URLs (for example http://localhost:8080/client/edit/3) when the page is refreshed. You can read more on this here. If you tried to serve your application at this point, nothing will be displayed as we haven’t started building our React components.$ yarn init
Enter
or provide whichever value suits you.$ yarn add react react-dom react-router-dom
$ yarn add reactstrap bootstrap jquery popper
$ yarn add formik yup
$ yarn add axios luxon
$ yarn add --dev @babel/core @babel/preset-react babel-loader
yarn add --dev @babel/plugin-proposal-optional-chaining @babel/plugin-proposal-nullish-coalescing-operator
$ yarn add --dev webpack webpack-cli path
css-loader
and style-loader
.$ yarn add --dev css-loader style-loader
$ touch webpack.config.js
const path = require('path'); module.exports = { entry: './react/src/App.js', output: { path: path.resolve(__dirname, 'public/dist'), filename: 'main.js' }, module: { rules: [ { exclude: /node_modules/, use: { loader: 'babel-loader', options: { presets: ['@babel/preset-react'], plugins: [ '@babel/plugin-proposal-optional-chaining', '@babel/plugin-proposal-nullish-coalescing-operator' ] } } }, { test: /\.css$/i, use: ['style-loader', 'css-loader'], } ] }, mode: 'development' }
//create react/src directory $ mkdir -p react/src //create App.js file in react/src directory $ touch react/src/App.js
import React from 'react'; import ReactDOM from 'react-dom'; const Root = () => <h1>Hello World from React</h1>; let container = document.getElementById('app'); let component = <Root/>; ReactDOM.render(component, container);
js
file run the following command in your terminal:$ npx webpack
$ npx webpack --watch
$ php spark serve
components
: This is a directory that will contain all the components that will be used in this application.utility
: This is a directory that will contain all the utility functions needed by our components.utility
directory and work our way up to the components
before we round off with the App.*
files.utility
. This directory will have four files as follows:import axios from 'axios'; const API = axios.create({ baseURL: 'http://localhost:8080', responseType: 'json', }); const getRequestConfiguration = (authorization) => { const headers = { 'Content-Type': 'application/json', }; if (authorization) headers.Authorization = `Bearer ${authorization}`; return { headers }; }; export const makeRequest = ({ url, values, successCallback, failureCallback, requestType = 'POST', authorization = null, }) => { const requestConfiguration = getRequestConfiguration(authorization); let promise; switch (requestType) { case 'GET': promise = API.get(url, requestConfiguration); break; case 'POST': promise = API.post(url, values, requestConfiguration); break; case 'DELETE': promise = API.delete(url, requestConfiguration); break; default: return; } promise .then((response) => { const { data } = response; successCallback(data); }) .catch((error) => { if (error.response) { failureCallback(error.response.data); } }); };
baseURL variable accordingly.import {saveJWT, saveUser} from './LocalStorage'; const successfulAuthenticationCallback = (data) => { const {user, access_token} = data; saveUser(user); saveJWT(access_token); }; export default successfulAuthenticationCallback;
import {DateTime} from 'luxon'; export const formatDate = (dateString) => { const dateISOString = new Date(dateString).toISOString(); const formattedDateTime = DateTime.fromISO(dateISOString); return formattedDateTime.setLocale('en').toLocaleString(DateTime.DATE_FULL); } export const formatCurrency = (amount) => `₦${new Intl.NumberFormat('en-GB').format(amount)}`;
formatDate
uses Luxon to present a more readable date string to the user. The formatCurrency
returns a properly formatted currency value (with the Naira symbol included).const getItem = (itemName) => localStorage.getItem(itemName); const saveItem = (itemName, itemValue) => { localStorage.setItem(itemName, itemValue); }; const getFilteredClients = clientId => { return loadClients().filter(({id}) => clientId !== id); }; export const loadUser = () => JSON.parse(getItem('user')); export const saveUser = (user) => { saveItem('user', JSON.stringify(user)); }; export const loadJWT = () => getItem('token'); export const saveJWT = (token) => { saveItem('token', token); }; export const loadClients = () => JSON.parse(getItem('clients')); export const findClient = (clientId) => loadClients().find(({id}) => id === clientId); export const saveClients = (clients) => { saveItem('clients', JSON.stringify(clients)); }; export const addClient = (client) => { saveClients([...loadClients(), client]); }; export const updateClient = (client) => { saveClients([...getFilteredClients(client.id), client]); }; export const deleteClient = ({id}) => { saveClients(getFilteredClients(id)); }; export const clearState = () => { localStorage.clear(); };
user
: This is an object containing the details of the authenticated user. Two helper functions (saveUser
and loadUser
) are exported to aid in saving and loading the authenticated user.token
: This is a string corresponding to the JWT created for the authenticated user. Two helper functions (loadJWT
and saveJWT
) are exported to aid in loading and saving the JWT.clients
: This is an array corresponding to the array of clients retrieved from the API. Two helper functions (loadClients
and saveClients
) are made exported to aid in loading and saving the clients on the database. Additionally three helper functions (addClient
, updateClient
and deleteClient
) are exported to help with modifying the saved array of clients.clearState
) is used to simulate the effect of logging out.alert
: This directory will contain components related to passing information to the user (for example notifications or ‘loading’ components).authentication
: This directory will contain components related to the authentication process.form
: This directory will hold custom form components.restricted
: This directory will contain components that are only available to authenticated users.routing
: This directory will contain components that are used to handle the Client-side-routing.// cd into components directory $ cd react/src/components // create directories $ mkdir alert authentication form restricted routing
import React from 'react'; import {UncontrolledAlert} from 'reactstrap'; const FailureAlert = ({errors}) => { return ( <UncontrolledAlert color='danger'> <h4 className='alert-heading'>Request Failed</h4> <hr/> <ul className='plainList'> {Object.values(errors).map((error, index) => { return <li key={index}>{error}</li> })} </ul> </UncontrolledAlert> ) } export default FailureAlert;
import React from 'react'; import {Spinner} from 'reactstrap'; const LoadingAlert = () => { return ( <div className='centredDiv'> <Spinner size='lg' type='grow' color='dark'/> </div> ); }; export default LoadingAlert;
import React from 'react'; import {Link} from 'react-router-dom'; const PageNotFound = () => { return ( <div className='wrap'> <h1>404 - Page Not Found</h1> <p>Could not find the page you were looking for.</p> <Link to={'/'}>Return to Dashboard</Link> </div> ); }; export default PageNotFound;
import React from 'react'; import {UncontrolledAlert} from 'reactstrap'; const SuccessAlert = ({ message, onTimeout }) => { setTimeout(onTimeout, 4000); return ( <div style={{margin: '20px'}}> <UncontrolledAlert color='success'> {message} </UncontrolledAlert> </div> ) } export default SuccessAlert;
setTimeout
function is used to close the success alert after 4 seconds.import React from 'react'; import {Input, Label} from 'reactstrap'; import {ErrorMessage, Field} from 'formik'; const getErrorDiv = message => { return ( <div style={{color: '#dc3545'}}> {message} </div> ); }; const CustomInput = ({name, label, type = 'text'}) => { return (<> <Label for={name}>{label}</Label> <Field name={name}> {({field}) => { return (<> <Input type={type} {...field} placeholder={label}/> <ErrorMessage name={name} render={getErrorDiv} /> </>) }} </Field> </>); }; export default CustomInput;
import React from 'react'; import {Route, Switch} from 'react-router-dom'; import UserProfile from '../restricted/user/Profile'; import ClientsTable from '../restricted/client/ViewAll'; import PageNotFound from '../alert/PageNotFound'; import ClientView from '../restricted/client/ViewOne'; import AddClientForm from '../restricted/client/form/AddClient'; import EditClientForm from '../restricted/client/form/EditClient'; const Routes = () => ( <Switch> <Route path='/' exact component={ClientsTable}/> <Route path={'/profile'} component={UserProfile}/> <Route path={'/client/view/:id'} component={ClientView}/> <Route path={'/client/add'} component={AddClientForm}/> <Route path={'/client/edit/:id'} component={EditClientForm}/> <Route path={'*'} component={PageNotFound}/> </Switch> ); export default Routes;
*
wildcard and the PageNotFound
component to let the user know that the requested page could not be found.import React, {useState} from 'react'; import Login from './Login'; import Register from './Register'; import {Button} from 'reactstrap'; import FailureAlert from '../alert/Failure'; import {makeRequest} from '../../utility/Api'; import successfulAuthenticationCallback from '../../utility/Authentication'; const Authentication = ({setIsAuthenticated}) => { const [isLogin, setIsLogin] = useState(false); const [errors, setErrors] = useState(null); const submitCallback = (values) => { makeRequest({ url: `auth/${isLogin ? 'login' : 'register'}`, values, successCallback: (data) => { successfulAuthenticationCallback(data); setIsAuthenticated(); }, failureCallback: (errorResponse) => { setErrors(errorResponse) } }); }; return ( <div className='centredDiv' style={{width: '60%'}}> {errors && <FailureAlert errors={errors}/>} {isLogin ? <Login submitCallback={submitCallback}/> : <Register submitCallback={submitCallback}/> } <Button style={{marginTop: '10px'}} block outline color={'primary'} onClick={() => { setIsLogin(!isLogin) }} > {isLogin ? 'Register' : 'Login'} </Button> </div> ); }; export default Authentication;
Authentication
component is used to coordinate the activities of the Login
and Register
components. Because the isLogin
state variable, the user can toggle between the registration and login form.submitCallback
is called to make a request to our API (using the makeRequest
function declared in react/src/utility/Api.js. If the response is an error response, then the FailureAlert
component is rendered to show the user what went wrong. If the request was successfully handled, the user details and token are saved using the successfulAuthenticationCallback
component declared in react/src/utility/Authentication.js.import React from 'react'; import {Form, Formik} from 'formik'; import * as Yup from 'yup'; import CustomInput from '../form/CustomInput'; import {Button, FormGroup} from 'reactstrap'; const Login = ({submitCallback}) => { const initialValues = { email: '', password: '', }; const validationSchema = Yup.object({ email: Yup.string() .email('Invalid email address') .min(6, 'Email must be at least 6 characters') .max(50, 'Email cannot exceed 50 characters') .required('Email address is required'), password: Yup.string() .min(8, 'Password must be at least 8 characters') .required('Please select a password') }); return ( <Formik initialValues={initialValues} validationSchema={validationSchema} onSubmit={submitCallback}> <Form> <FormGroup row> <CustomInput name={'email'} label={'Email Address'} type={'email'} /> </FormGroup> <FormGroup row> <CustomInput name={'password'} label={'Password'} type={'password'} /> </FormGroup> <Button block type='submit' color={'danger'}> Submit </Button> </Form> </Formik> ); }; export default Login;
import React from 'react'; import {Form, Formik} from 'formik'; import * as Yup from 'yup'; import CustomInput from '../form/CustomInput'; import {Button, FormGroup} from 'reactstrap'; const Register = ({submitCallback}) => { const initialValues = { name: '', email: '', password: '', passwordConfirmation: '' }; const validationSchema = Yup.object({ name: Yup.string() .required('Name is required'), email: Yup.string() .email('Invalid email address') .min(6, 'Email must be at least 6 characters') .max(50, 'Email cannot exceed 50 characters') .required('Email address is required'), password: Yup.string() .min(8, 'Password must be at least 8 characters') .required('Please select a password'), passwordConfirmation: Yup.string() .required('Please confirm your password') .when('password', { is: password => (!!(password && password.length > 0)), then: Yup.string().oneOf( [Yup.ref('password')], 'Invalid Password Confirmation' ) }) }); return ( <Formik initialValues={initialValues} validationSchema={validationSchema} onSubmit={submitCallback}> <Form> <FormGroup row> <CustomInput name={'name'} label={'Name'} /> </FormGroup> <FormGroup row> <CustomInput name={'email'} label={'Email Address'} type={'email'} /> </FormGroup> <FormGroup row> <CustomInput name={'password'} label={'Password'} type={'password'} /> </FormGroup> <FormGroup row> <CustomInput name={'passwordConfirmation'} label={'Confirm Password'} type={'password'} /> </FormGroup> <Button block type='submit' color={'danger'}> Submit </Button> </Form> </Formik> ); }; export default Register;
Login
and Register
components are quite similar, we start off by declaring the initial values to be used in our form. All the values are empty because we want a clean slate every time a user is registering or logging in. We also declare a validation rule to be applied for each field in our forms. If the form is successfully validated, then the provided details are passed on to the API using submitCallback
which is passed as a prop from the Authentication
component.client
: This directory will contain components relevant for managing clients.dashboard
: This directory will contain components relevant for the rendering of the dashboard.user
: This directory will contain components relevant to the user.formatDate
function we declared in react/src/utility/Formatter.js). Because we didn’t store profile images for users on our database, placeholder images are used for the profile picture.import React, {useEffect, useState} from 'react'; import {DropdownItem, DropdownMenu, DropdownToggle, NavLink, Table, UncontrolledButtonDropdown,} from 'reactstrap'; import {formatCurrency, formatDate} from '../../../utility/Formatter'; import {makeRequest} from '../../../utility/Api'; import {loadJWT, saveClients} from '../../../utility/LocalStorage'; import SuccessAlert from '../../alert/Success'; import LoadingAlert from '../../alert/Loading'; import {Link} from 'react-router-dom'; const ClientsTable = () => { const [isLoading, setIsLoading] = useState(true); const [clients, setClients] = useState([]); const [responseMessage, setResponseMessage] = useState(''); const [showSuccessAlert, setShowSuccessAlert] = useState(false); const onTimeout = () => { setShowSuccessAlert(false); }; const updateClients = (clients) => { setClients(clients); saveClients(clients); }; const deleteClient = (clientId) => { makeRequest({ url: `client/${clientId}`, successCallback: (data) => { setResponseMessage(data.message); updateClients(clients.filter(({id}) => id !== clientId)); setShowSuccessAlert(true); }, failureCallback: (error) => { console.log(error); }, requestType: 'DELETE', authorization: loadJWT(), }); }; useEffect(() => { makeRequest({ url: 'client', successCallback: (data) => { const {message, clients} = data; updateClients(clients); setIsLoading(false); setShowSuccessAlert(true); setResponseMessage(message); }, failureCallback: (error) => { console.log(error); }, requestType: 'GET', authorization: loadJWT(), }); }, []); return isLoading ? ( <LoadingAlert/> ) : ( <> {showSuccessAlert && ( <SuccessAlert {...{message: responseMessage, onTimeout}} /> )} <div style={{textAlign: 'center', margin: '20px'}}> <h1> All Clients</h1> </div> <Table responsive hover> <thead> <tr> <th>#</th> <th>Name</th> <th>Email Address</th> <th>Retainer Start</th> <th>Retainer Fee</th> <th>Actions</th> </tr> </thead> <tbody> {clients.map((client, index) => ( <tr key={client.id}> <th scope='row'>{index + 1}</th> <td>{client.name}</td> <td>{client.email}</td> <td>{formatDate(client.created_at)}</td> <td>{formatCurrency(client.retainer_fee)}</td> <td> <UncontrolledButtonDropdown> <DropdownToggle caret>Actions</DropdownToggle> <DropdownMenu> <DropdownItem> <NavLink tag={Link} to={`/client/view/${client.id}`}> View Client </NavLink> </DropdownItem> <DropdownItem divider/> <DropdownItem> <NavLink tag={Link} to={`/client/edit/${client.id}`}> Edit Client </NavLink> </DropdownItem> <DropdownItem divider/> <DropdownItem onClick={() => { deleteClient(client.id); }} > Delete Client </DropdownItem> </DropdownMenu> </UncontrolledButtonDropdown> </td> </tr> ))} </tbody> </Table> </> ); }; export default ClientsTable;
useEffect
hook is used to request for the list of clients from the API. When a successful response is received, the local state and localStorage
are updated with the clients and the table is rendered. While the application is waiting for a response, the Loading
component is used to let the user know that the clients are being retrieved from the API.deleteClient
function is used to DELETE
requests to the API. On a successful response, the list of clients is updated and the table of clients is re-rendered accordingly.import React from 'react'; import { useParams } from 'react-router-dom'; import { findClient } from '../../../utility/LocalStorage'; import { Button, Card, CardBody, CardFooter, CardHeader, CardText, CardTitle, } from 'reactstrap'; import { formatCurrency, formatDate } from '../../../utility/Formatter'; const ClientView = () => { const { id } = useParams(); const client = findClient(id); return ( <div className='centredDiv' style={{ marginTop: '50px' }}> <Card> <CardHeader>{client.name}</CardHeader> <CardBody> <CardTitle> Services retained at the price of{' '} {formatCurrency(client.retainer_fee)} </CardTitle> <CardText>{client.email}</CardText> <Button>Go somewhere</Button> </CardBody> <CardFooter> <small className='text-muted'> Services retained on {formatDate(client.created_at)} </small> </CardFooter> </Card> </div> ); }; export default ClientView;
useParams
hook provided by React Router is used to determine the id of the client to be displayed. The id is then passed to the findClient
function declared in react/src/utility/LocalStorage.js. The details of the retrieved client are then rendered accordingly.form
. This directory will have the following three (3) files:import React, { useState } from 'react'; import * as Yup from 'yup'; import { Form, Formik } from 'formik'; import { Button, FormGroup } from 'reactstrap'; import CustomInput from '../../../form/CustomInput'; import { addClient, loadJWT, updateClient, } from '../../../../utility/LocalStorage'; import FailureAlert from '../../../alert/Failure'; import { makeRequest } from '../../../../utility/Api'; import SuccessAlert from '../../../alert/Success'; import { Redirect } from 'react-router-dom'; const BaseClientForm = ({ client }) => { const [errors, setErrors] = useState(null); const [shouldRedirect, setShouldRedirect] = useState(false); const [responseMessage, setResponseMessage] = useState(''); const [showSuccessAlert, setShowSuccessAlert] = useState(false); const onTimeout = () => { setShouldRedirect(true); }; const initialValues = { name: client?.name || '', email: client?.email || '', retainer_fee: client?.retainer_fee || '', }; const validationSchema = Yup.object({ name: Yup.string().required('Name is required'), email: Yup.string() .email('Invalid email address') .min(6, 'Email must be at least 6 characters') .max(50, 'Email cannot exceed 50 characters') .required('Email address is required'), retainer_fee: Yup.string() .required('Please specify retainer fee') .test('Digits Only', 'Retainer fee should only contain number', (value) => /^\d+$/.test(value) ), }); const successCallback = (data) => { const { message, client: clientDetails } = data; setResponseMessage(message); setShowSuccessAlert(true); if (client) { updateClient(clientDetails); } else { addClient(clientDetails); } }; const submitCallback = (values) => { makeRequest({ url: `client${client ? `/${client.id}` : ''}`, values, successCallback, failureCallback: (error) => { setErrors(error); }, authorization: loadJWT(), }); }; return shouldRedirect ? ( <Redirect to='/' /> ) : ( <> {errors && <FailureAlert errors={errors} />} {showSuccessAlert && ( <SuccessAlert {...{ message: responseMessage, onTimeout, shouldShow: showSuccessAlert, }} /> )} <div className='centredDiv' style={{ marginTop: '60px' }}> <Formik initialValues={initialValues} validationSchema={validationSchema} onSubmit={submitCallback} > <Form> <FormGroup row> <CustomInput name={'name'} label={'Name'} /> </FormGroup> <FormGroup row> <CustomInput name={'email'} label={'Email Address'} type={'email'} /> </FormGroup> <FormGroup row> <CustomInput name={'retainer_fee'} label={'Retainer Fee'} type={'number'} /> </FormGroup> <Button block type='submit' color={'danger'}> Submit </Button> </Form> </Formik> </div> </> ); }; export default BaseClientForm;
Login
and Register
components we declare the initial values of the form and our validation schema. The initial values in this component aren’t as straightforward however. This is because when we are editing a client, we want the initial values to correspond with the client’s saved details.successCallback
which is triggered if the API handled the request successfully. If triggered, the details from the API are saved and a success message is displayed. Additionally, after 4 seconds, the user is returned to the dashboard. This is done using the onTimeout
callback prop passed to the SuccessAlert
component.import React from 'react'; import BaseClientForm from './BaseClientForm'; const AddClientForm = () => <BaseClientForm client={null} />; export default AddClientForm;
BaseClientForm
component. But since we are creating a new client, we pass null to the component. By doing this the form is rendered with empty initial values.import React from 'react'; import BaseClientForm from './BaseClientForm'; import { useParams } from 'react-router-dom'; import { findClient } from '../../../../utility/LocalStorage'; const EditClientForm = () => { const { id } = useParams(); return <BaseClientForm client={findClient(id)} />; }; export default EditClientForm;
useParams
hook to get the requested id and passes the appropriate client to the BaseClientForm
.import React, {useState} from 'react'; import { Collapse, DropdownItem, DropdownMenu, DropdownToggle, Nav, Navbar, NavbarBrand, NavbarToggler, NavLink, UncontrolledDropdown } from 'reactstrap'; import {Link} from 'react-router-dom'; const DashboardMenu = ({logout}) => { const [isOpen, setIsOpen] = useState(false); const toggle = () => setIsOpen(!isOpen); return ( <Navbar color='light' light expand='md'> <NavbarBrand href="/" className="mr-auto">Home</NavbarBrand> <NavbarToggler onClick={toggle}/> <Collapse isOpen={isOpen} navbar> <Nav className='ml-auto' navbar> <UncontrolledDropdown nav inNavbar> <DropdownToggle nav caret> More Actions </DropdownToggle> <DropdownMenu right> <DropdownItem> <NavLink tag={Link} to={'/client/add'}> Add Client </NavLink> </DropdownItem> <DropdownItem> <NavLink tag={Link} to={'/profile'}> View Profile </NavLink> </DropdownItem> <DropdownItem divider/> <DropdownItem onClick={logout}> Logout </DropdownItem> </DropdownMenu> </UncontrolledDropdown> </Nav> </Collapse> </Navbar> ) }; export default DashboardMenu;
import React from 'react'; import DashboardMenu from './Menu'; import {BrowserRouter as Router} from 'react-router-dom'; import Routes from '../../routing/Routes'; const Dashboard = ({logout}) => { return ( <Router> <> <DashboardMenu logout={logout}/> <Routes/> </> </Router> ) }; export default Dashboard;
.centredDiv { position: absolute; top: 40%; left: 50%; transform: translate(-50%, -50%); text-align: center; } .plainList { list-style-type: none; margin: 0; padding: 0; } .wrap { max-width: 1024px; margin: 5rem auto; padding: 2rem; background: #fff; text-align: center; border: 1px solid #efefef; border-radius: 0.5rem; position: relative; }
import React, {useState} from 'react'; import ReactDOM from 'react-dom'; import {clearState, loadJWT} from './utility/LocalStorage'; import Authentication from './components/authentication/Authentication'; import Dashboard from './components/restricted/dashboard'; import 'bootstrap/dist/css/bootstrap.min.css'; import './App.css'; const Root = () => { const [isAuthenticated, setIsAuthenticated] = useState(!!loadJWT()); const onLogin = () => { setIsAuthenticated(true); }; const onLogout = () => { clearState(); setIsAuthenticated(false); }; return !isAuthenticated ? <Authentication setIsAuthenticated={onLogin} /> : <Dashboard logout={onLogout} />; }; let container = document.getElementById('app'); let component = <Root/>; ReactDOM.render(component, container);
$ webpack
$ php spark serve