├── typings ├── index.d.ts ├── rebass │ └── index.d.ts ├── react-loading │ └── index.d.ts ├── webpack-chunk-hash │ └── index.d.ts └── inline-manifest-webpack-plugin │ └── index.d.ts ├── infrastructure ├── versions.tf ├── build-artifacts.bash ├── install.bash ├── outputs.tf ├── upload-artifacts.bash ├── deploy-infrastructure.bash ├── codebuild-role-policy.tpl ├── variables.tf ├── main.tf └── README.md ├── src ├── favicon.ico ├── books │ ├── BooksPage.css │ ├── Book.ts │ ├── selectors.ts │ ├── sagas.ts │ ├── reducer.ts │ ├── BooksPage.tsx │ └── books.json ├── shared-components │ ├── Banner.jpg │ ├── AppFooter.tsx │ ├── FullscreenLoader.tsx │ ├── NotFoundPage.tsx │ ├── ScrollToTop.ts │ ├── GaTracker.ts │ ├── Navbar.tsx │ └── HomePage.tsx ├── rootSaga.ts ├── auth │ ├── selectors.ts │ ├── reducer.ts │ ├── PrivateRoute.tsx │ └── Auth0Wrapper.tsx ├── app │ ├── reducer.ts │ └── App.tsx ├── rootReducer.ts ├── index.ejs ├── index.css ├── configureStore.ts ├── index.tsx └── apiService.ts ├── .gitignore ├── Dockerfile ├── tslint.json ├── .eslintrc.js ├── LICENSE ├── server.ts ├── package.json ├── webpack.config.ts ├── tsconfig-webpack.json ├── tsconfig.json ├── webpack.prod.config.ts └── README.md /typings/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.css'; 2 | -------------------------------------------------------------------------------- /typings/rebass/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'rebass'; 2 | -------------------------------------------------------------------------------- /typings/react-loading/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'react-loading'; 2 | -------------------------------------------------------------------------------- /typings/webpack-chunk-hash/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'webpack-chunk-hash'; 2 | -------------------------------------------------------------------------------- /infrastructure/versions.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = ">= 0.12" 3 | } 4 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jch254/starter-pack/HEAD/src/favicon.ico -------------------------------------------------------------------------------- /typings/inline-manifest-webpack-plugin/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'inline-manifest-webpack-plugin'; 2 | -------------------------------------------------------------------------------- /src/books/BooksPage.css: -------------------------------------------------------------------------------- 1 | .description { 2 | font-style: italic; 3 | font-size: 12px; 4 | margin: 0; 5 | } -------------------------------------------------------------------------------- /src/shared-components/Banner.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jch254/starter-pack/HEAD/src/shared-components/Banner.jpg -------------------------------------------------------------------------------- /infrastructure/build-artifacts.bash: -------------------------------------------------------------------------------- 1 | #!/bin/bash -ex 2 | 3 | echo Building artifacts... 4 | 5 | yarn run build 6 | 7 | echo Finished building artifacts 8 | -------------------------------------------------------------------------------- /infrastructure/install.bash: -------------------------------------------------------------------------------- 1 | #!/bin/bash -ex 2 | 3 | echo Installing dependencies... 4 | 5 | yarn install 6 | 7 | echo Finished installing dependencies 8 | -------------------------------------------------------------------------------- /infrastructure/outputs.tf: -------------------------------------------------------------------------------- 1 | output "s3_bucket_id" { 2 | value = module.webapp.s3_bucket_id 3 | } 4 | 5 | output "cloudfront_distribution_id" { 6 | value = module.webapp.cloudfront_distribution_id 7 | } 8 | -------------------------------------------------------------------------------- /src/books/Book.ts: -------------------------------------------------------------------------------- 1 | interface Book { 2 | id: string; 3 | title: string; 4 | author: string; 5 | description: string; 6 | img: string; 7 | url: string; 8 | } 9 | 10 | export default Book; 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | dist/ 4 | .vscode/ 5 | typings.json 6 | npm-debug.log 7 | yarn-error.log 8 | .terraform 9 | *.tfstate 10 | *.tfstate.backup 11 | .history/ 12 | *.tfplan 13 | stats.json 14 | -------------------------------------------------------------------------------- /src/rootSaga.ts: -------------------------------------------------------------------------------- 1 | import { all } from 'redux-saga/effects'; 2 | import { watchBooksRequest } from './books/sagas'; 3 | 4 | export default function* rootSaga() { 5 | yield all([ 6 | watchBooksRequest(), 7 | ]); 8 | } 9 | -------------------------------------------------------------------------------- /src/auth/selectors.ts: -------------------------------------------------------------------------------- 1 | import { Auth0Client } from '@auth0/auth0-spa-js'; 2 | import { GlobalState } from '../rootReducer'; 3 | 4 | export const getAuth0Client = (state: GlobalState): Auth0Client | undefined => state.auth.auth0Client; 5 | -------------------------------------------------------------------------------- /src/app/reducer.ts: -------------------------------------------------------------------------------- 1 | export interface AppState {} 2 | 3 | export const initialState: AppState = {}; 4 | 5 | export default function reducer(state: AppState = initialState, action: any): AppState { 6 | switch (action.type) { 7 | default: 8 | return state; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:14-alpine 2 | WORKDIR /app 3 | 4 | COPY package.json yarn.lock ./ 5 | RUN yarn install 6 | 7 | ENV SERVER_HOSTNAME=0.0.0.0 8 | 9 | COPY server.ts tsconfig.json tsconfig-webpack.json .eslintrc.js tslint.json webpack.config.ts webpack.prod.config.ts ./ 10 | COPY src src 11 | COPY typings typings 12 | 13 | EXPOSE 3001/tcp 14 | 15 | ENTRYPOINT ["yarn", "run"] 16 | -------------------------------------------------------------------------------- /infrastructure/upload-artifacts.bash: -------------------------------------------------------------------------------- 1 | #!/bin/bash -ex 2 | 3 | S3_BUCKET_ID=$(cd infrastructure && terraform output -raw s3_bucket_id) 4 | CLOUDFRONT_DISTRIBUTION_ID=$(cd infrastructure && terraform output -raw cloudfront_distribution_id) 5 | 6 | cd dist 7 | aws s3 sync . "s3://${S3_BUCKET_ID}/" --delete --acl=public-read --exclude '.git/*' 8 | aws cloudfront create-invalidation --distribution-id "${CLOUDFRONT_DISTRIBUTION_ID}" --paths '/*' 9 | cd .. 10 | -------------------------------------------------------------------------------- /infrastructure/deploy-infrastructure.bash: -------------------------------------------------------------------------------- 1 | #!/bin/bash -ex 2 | 3 | echo Deploying infrastructure via Terraform... 4 | 5 | cd infrastructure 6 | terraform init \ 7 | -backend-config "bucket=${REMOTE_STATE_BUCKET}" \ 8 | -backend-config "key=${TF_VAR_name}" \ 9 | -backend-config "region=${TF_VAR_region}" \ 10 | -get=true \ 11 | -upgrade=true 12 | terraform plan -out main.tfplan 13 | terraform apply main.tfplan 14 | cd .. 15 | 16 | echo Finished deploying infrastructure 17 | -------------------------------------------------------------------------------- /src/shared-components/AppFooter.tsx: -------------------------------------------------------------------------------- 1 | import * as moment from 'moment'; 2 | import * as React from 'react'; 3 | import { 4 | Small, 5 | Toolbar, 6 | } from 'rebass'; 7 | 8 | const AppFooter = () => ( 9 | 10 | 11 | 12 | {`© 603.nz ${moment().year()}`} 13 | 14 | 15 | 16 | ); 17 | 18 | export default AppFooter; 19 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint-config-airbnb"], 3 | "rules": { 4 | "variable-name": [true, "ban-keywords", "check-format", "allow-pascal-case"], 5 | "import-name": false, 6 | "object-shorthand-properties-first": false, 7 | "ordered-imports": [ 8 | true, 9 | { 10 | "import-sources-order": "case-insensitive", 11 | "named-imports-order": "lowercase-first" 12 | } 13 | ], 14 | "max-line-length": [ 15 | true, 16 | 150 17 | ] 18 | } 19 | } -------------------------------------------------------------------------------- /src/shared-components/FullscreenLoader.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import Loading from 'react-loading'; 3 | import { Flex } from 'rebass'; 4 | 5 | interface FullscreenLoaderProps { 6 | delay?: number; 7 | style?: any; 8 | } 9 | 10 | const FullscreenLoader = ({ delay, style = {} }: FullscreenLoaderProps) => ( 11 | 12 | 13 | 14 | ); 15 | 16 | FullscreenLoader.defaultProps = { 17 | delay: 1000, 18 | }; 19 | 20 | export default FullscreenLoader; 21 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | plugins: [ 4 | '@typescript-eslint', 5 | '@typescript-eslint/tslint', 6 | 'css-modules' 7 | ], 8 | parserOptions: { 9 | project: './tsconfig.json', 10 | sourceType: 'module', 11 | tsconfigRootDir: __dirname, 12 | ecmaVersion: 2019, 13 | }, 14 | overrides: [ 15 | { 16 | files: ["*.ts", "*.tsx"], 17 | rules: { 18 | "@typescript-eslint/tslint/config": ["error", { lintFile: "./tslint.json" }], 19 | "css-modules/no-unused-class": ['error', {}], 20 | "css-modules/no-undef-class": ['error', {}] 21 | } 22 | } 23 | ] 24 | }; 25 | -------------------------------------------------------------------------------- /src/shared-components/NotFoundPage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { 3 | Blockquote, 4 | Container, 5 | Heading, 6 | } from 'rebass'; 7 | 8 | const NotFoundPage = () => ( 9 | 10 | 404. 11 | 12 | Sorry, that page does not exist 13 | 14 |
15 | "All that is gold does not glitter,
16 | Not all those who wander are lost;
17 | The old that is strong does not wither,
18 | Deep roots are not reached by the frost." 19 |
20 |
21 | ); 22 | 23 | export default NotFoundPage; 24 | -------------------------------------------------------------------------------- /src/rootReducer.ts: -------------------------------------------------------------------------------- 1 | import { connectRouter, RouterState } from 'connected-react-router'; 2 | import { History } from 'history'; 3 | import { combineReducers } from 'redux'; 4 | import appReducer, { AppState } from './app/reducer'; 5 | import authReducer, { AuthState } from './auth/reducer'; 6 | import booksReducer, { BooksState } from './books/reducer'; 7 | 8 | export interface GlobalState { 9 | app: AppState; 10 | auth: AuthState; 11 | books: BooksState; 12 | router: RouterState; 13 | } 14 | 15 | const rootReducer = (history: History) => combineReducers({ 16 | app: appReducer, 17 | auth: authReducer, 18 | books: booksReducer, 19 | router: connectRouter(history), 20 | }); 21 | 22 | export default rootReducer; 23 | -------------------------------------------------------------------------------- /src/books/selectors.ts: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | import { ResponseError } from '../apiService'; 3 | import { GlobalState } from '../rootReducer'; 4 | 5 | import Book from './Book'; 6 | 7 | const getBooks = (state: GlobalState): Map => state.books.books; 8 | 9 | export const getError = (state: GlobalState): ResponseError | undefined => state.books.error; 10 | 11 | export const getIsFetching = (state: GlobalState): boolean => state.books.isFetching; 12 | 13 | export const getSortedBooks = createSelector( 14 | [getBooks], 15 | (books: Map) => new Map( 16 | [...books].sort(([idA, bookA], [idB, bookB]) => bookA.title.localeCompare(bookB.title)), 17 | ), 18 | ); 19 | -------------------------------------------------------------------------------- /src/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <% var item, key %> 9 | <% htmlWebpackPlugin.options.meta = htmlWebpackPlugin.options.meta || [] %> 10 | <% if (Array.isArray(htmlWebpackPlugin.options.meta)) { %> 11 | <% for (item of htmlWebpackPlugin.options.meta) { %> 12 | <%= key %>="<%= item[key] %>"<% } %>> 13 | <% } %> 14 | <% } %> 15 | 16 | <%= htmlWebpackPlugin.options.title %> 17 | 18 | 19 | 20 |
21 |
22 | 23 | 24 | -------------------------------------------------------------------------------- /src/shared-components/ScrollToTop.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { withRouter, RouteComponentProps } from 'react-router-dom'; 3 | 4 | interface ScrollToTopProps { 5 | children?: any; 6 | } 7 | 8 | type Props = ScrollToTopProps & RouteComponentProps; 9 | 10 | class ScrollToTop extends React.PureComponent { 11 | componentDidUpdate(prevProps: Props) { 12 | // TODO: Restore scroll position on browser back button etc. 13 | if (this.props.location !== prevProps.location) { 14 | window.scrollTo(0, 0); 15 | } 16 | } 17 | 18 | render() { 19 | const { children } = this.props; 20 | 21 | return children; 22 | } 23 | } 24 | 25 | export default withRouter(ScrollToTop); 26 | -------------------------------------------------------------------------------- /src/auth/reducer.ts: -------------------------------------------------------------------------------- 1 | import { Auth0Client } from '@auth0/auth0-spa-js'; 2 | import produce from 'immer'; 3 | import actionCreatorFactory from 'typescript-fsa'; 4 | import { reducerWithInitialState } from 'typescript-fsa-reducers'; 5 | 6 | export interface AuthState { 7 | auth0Client?: Auth0Client; 8 | } 9 | 10 | export const initialState: AuthState = {}; 11 | 12 | const actionCreator = actionCreatorFactory('AUTH'); 13 | 14 | export const authActions = { 15 | setAuth0Client: actionCreator('SET_AUTH0_CLIENT'), 16 | }; 17 | 18 | export default reducerWithInitialState(initialState) 19 | .case(authActions.setAuth0Client, (state, payload) => 20 | produce(state, (draft) => { 21 | draft.auth0Client = payload; 22 | }), 23 | ); 24 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | :global(*) { 2 | box-sizing: border-box; 3 | } 4 | 5 | :global(html) { 6 | width: 100%; 7 | height: 100%; 8 | margin: 0; 9 | } 10 | 11 | :global(#root) { 12 | width: 100%; 13 | height: 100%; 14 | margin: 0; 15 | } 16 | 17 | :global(#provider) { 18 | width: 100%; 19 | height: 100%; 20 | margin: 0; 21 | } 22 | 23 | :global(a) { 24 | text-decoration: none; 25 | color: #999; 26 | } 27 | 28 | :global(body) { 29 | width: 100%; 30 | height: 100%; 31 | margin: 0; 32 | background-color: #fff; 33 | color: #111; 34 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Helvetica, sans-serif; 35 | line-height: 1.5; 36 | } 37 | 38 | :global(button:focus) { 39 | outline: none; 40 | } 41 | -------------------------------------------------------------------------------- /infrastructure/codebuild-role-policy.tpl: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2012-10-17", 3 | "Statement": [ 4 | { 5 | "Effect": "Allow", 6 | "Resource": [ 7 | "*" 8 | ], 9 | "Action": [ 10 | "logs:*", 11 | "s3:*", 12 | "codebuild:*", 13 | "codepipeline:*", 14 | "cloudwatch:*", 15 | "cloudfront:*", 16 | "route53:*", 17 | "iam:*", 18 | "ssm:DescribeParameters" 19 | ] 20 | }, 21 | { 22 | "Effect": "Allow", 23 | "Action": [ 24 | "kms:Decrypt" 25 | ], 26 | "Resource": ${kms_key_arns} 27 | }, 28 | { 29 | "Effect": "Allow", 30 | "Action": [ 31 | "ssm:GetParameters" 32 | ], 33 | "Resource": ${ssm_parameter_arns} 34 | } 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /src/configureStore.ts: -------------------------------------------------------------------------------- 1 | import { routerMiddleware } from 'connected-react-router'; 2 | import { createBrowserHistory } from 'history'; 3 | import { applyMiddleware, createStore } from 'redux'; 4 | import { composeWithDevTools } from 'redux-devtools-extension'; 5 | import createSagaMiddleware from 'redux-saga'; 6 | import rootReducer from './rootReducer'; 7 | import rootSaga from './rootSaga'; 8 | 9 | export const history = createBrowserHistory(); 10 | 11 | const dev = process.env.NODE_ENV !== 'production'; 12 | const sagaMiddleware = createSagaMiddleware(); 13 | let middleware = applyMiddleware(sagaMiddleware, routerMiddleware(history)); 14 | 15 | if (dev) { 16 | middleware = composeWithDevTools(middleware); 17 | } 18 | 19 | export default () => { 20 | const store = createStore(rootReducer(history), {}, middleware); 21 | 22 | sagaMiddleware.run(rootSaga); 23 | 24 | return store; 25 | }; 26 | -------------------------------------------------------------------------------- /src/books/sagas.ts: -------------------------------------------------------------------------------- 1 | import { SagaIterator } from 'redux-saga'; 2 | import { call, spawn, take } from 'redux-saga/effects'; 3 | import { Action } from 'typescript-fsa'; 4 | import { bindAsyncAction } from 'typescript-fsa-redux-saga'; 5 | import { fetchBooks, handleApiError } from '../apiService'; 6 | import Book from './Book'; 7 | import { booksActions } from './reducer'; 8 | 9 | const fetchBooksWorker = bindAsyncAction(booksActions.fetchBooks, { skipStartedAction: true })( 10 | function* (idToken): SagaIterator { 11 | try { 12 | const books: Map = yield call(fetchBooks, idToken); 13 | 14 | return books; 15 | } catch (error) { 16 | yield call(handleApiError, error); 17 | 18 | throw error; 19 | } 20 | }, 21 | ); 22 | 23 | export function* watchBooksRequest() { 24 | while (true) { 25 | const action: Action = yield take(booksActions.fetchBooks.started); 26 | 27 | yield spawn(fetchBooksWorker, action.payload); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/shared-components/GaTracker.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as ga from 'react-ga'; 3 | import { withRouter, RouteComponentProps } from 'react-router-dom'; 4 | 5 | interface GaTrackerProps { 6 | children?: any; 7 | } 8 | 9 | type Props = GaTrackerProps & RouteComponentProps; 10 | 11 | class GaTracker extends React.PureComponent { 12 | constructor(props: Props) { 13 | super(props); 14 | 15 | if (process.env.NODE_ENV === 'production' && process.env.GA_ID !== undefined) { 16 | ga.initialize(process.env.GA_ID as string); 17 | ga.pageview(window.location.pathname); 18 | } 19 | } 20 | 21 | componentDidUpdate(prevProps: Props) { 22 | if (process.env.NODE_ENV === 'production' && this.props.location !== prevProps.location) { 23 | ga.pageview(window.location.pathname); 24 | } 25 | } 26 | 27 | render() { 28 | const { children } = this.props; 29 | 30 | return children; 31 | } 32 | } 33 | 34 | export default withRouter(GaTracker); 35 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import 'isomorphic-fetch'; 2 | import * as React from 'react'; 3 | import * as ReactDOM from 'react-dom'; 4 | import { Provider } from 'react-redux'; 5 | import { Provider as RebassProvider } from 'rebass'; 6 | import App from './app/App'; 7 | import { Auth0Provider } from './auth/Auth0Wrapper'; 8 | import configureStore, { history } from './configureStore'; 9 | 10 | import './index.css'; 11 | 12 | // Add ES6 Map support for redux-devtools-extension 13 | // See: https://github.com/zalmoxisus/redux-devtools-extension/issues/124 14 | if (process.env.NODE_ENV !== 'production') { 15 | require('map.prototype.tojson'); 16 | } 17 | 18 | const store = configureStore(); 19 | 20 | ReactDOM.render( 21 | 22 | 23 | 28 | 29 | 30 | 31 | , 32 | document.getElementById('root'), 33 | ); 34 | -------------------------------------------------------------------------------- /src/auth/PrivateRoute.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | // tslint:disable-next-line:no-duplicate-imports 3 | import { ComponentType } from 'react'; 4 | import { Route } from 'react-router-dom'; 5 | import { useAuth0 } from './Auth0Wrapper'; 6 | 7 | interface PrivateRouteProps { 8 | component: ComponentType; 9 | path: string; 10 | [key: string]: any; 11 | } 12 | 13 | const PrivateRoute = ({ component: Component, path, ...rest }: PrivateRouteProps) => { 14 | const { isLoggingIn, isAuthenticated, loginWithRedirect } = useAuth0(); 15 | 16 | React.useEffect( 17 | () => { 18 | if (isLoggingIn || isAuthenticated) { 19 | return; 20 | } 21 | 22 | const login = async () => { 23 | await loginWithRedirect({ 24 | appState: { targetUrl: path }, 25 | }); 26 | }; 27 | 28 | login(); 29 | }, 30 | [isLoggingIn, isAuthenticated, loginWithRedirect, path], 31 | ); 32 | 33 | const render = (props: any) => isAuthenticated ? : null; 34 | 35 | return ; 36 | }; 37 | 38 | export default PrivateRoute; 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Jordan Hornblow 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 | -------------------------------------------------------------------------------- /server.ts: -------------------------------------------------------------------------------- 1 | import * as webpack from 'webpack'; 2 | import * as WebpackDevServer from 'webpack-dev-server'; 3 | import devConfig from './webpack.config'; 4 | import prodConfig from './webpack.prod.config'; 5 | 6 | const SERVER_PORT = process.env.SERVER_PORT ? parseInt(process.env.SERVER_PORT as string, 10) : 3001; 7 | const SERVER_HOSTNAME = process.env.SERVER_HOSTNAME || 'localhost'; 8 | 9 | const webpackConfig = process.env.NODE_ENV === 'production' ? prodConfig : devConfig; 10 | const output = webpackConfig.output as webpack.Output; 11 | 12 | const compiler = webpack(webpackConfig); 13 | 14 | const server = new WebpackDevServer(compiler as any, { 15 | publicPath: output.publicPath as string, 16 | hot: process.env.NODE_ENV !== 'production', 17 | historyApiFallback: true, 18 | stats: { 19 | colors: true, 20 | hash: false, 21 | timings: true, 22 | chunks: false, 23 | chunkModules: false, 24 | modules: false, 25 | }, 26 | }); 27 | 28 | server.listen(SERVER_PORT, SERVER_HOSTNAME, (err?: Error) => { 29 | if (err) { 30 | console.log(err); 31 | } 32 | 33 | console.log(`Server listening at http://${SERVER_HOSTNAME}:${SERVER_PORT}`); 34 | }); 35 | -------------------------------------------------------------------------------- /src/books/reducer.ts: -------------------------------------------------------------------------------- 1 | import produce from 'immer'; 2 | import actionCreatorFactory from 'typescript-fsa'; 3 | import { reducerWithInitialState } from 'typescript-fsa-reducers'; 4 | import { ResponseError } from '../apiService'; 5 | import Book from './Book'; 6 | 7 | export interface BooksState { 8 | isFetching: boolean; 9 | books: Map; 10 | error?: ResponseError; 11 | } 12 | 13 | export const initialState: BooksState = { 14 | isFetching: false, 15 | books: new Map(), 16 | }; 17 | 18 | const actionCreator = actionCreatorFactory('BOOKS'); 19 | 20 | export const booksActions = { 21 | fetchBooks: actionCreator.async, ResponseError>('FETCH_BOOKS'), 22 | }; 23 | 24 | export default reducerWithInitialState(initialState) 25 | .case(booksActions.fetchBooks.started, state => 26 | produce(state, (draft) => { 27 | draft.isFetching = true; 28 | }), 29 | ) 30 | .case(booksActions.fetchBooks.done, (state, payload) => 31 | produce(state, (draft) => { 32 | draft.isFetching = false; 33 | draft.books = payload.result; 34 | }), 35 | ) 36 | .case(booksActions.fetchBooks.failed, (state, payload) => 37 | produce(state, (draft) => { 38 | draft.isFetching = false; 39 | draft.error = payload.error; 40 | }), 41 | ); 42 | -------------------------------------------------------------------------------- /src/shared-components/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import { LogoutOptions, RedirectLoginOptions } from '@auth0/auth0-spa-js'; 2 | import * as React from 'react'; 3 | import { NavLink } from 'react-router-dom'; 4 | import { 5 | Button, 6 | Fixed, 7 | Label, 8 | Toolbar, 9 | } from 'rebass'; 10 | 11 | interface NavbarProps { 12 | user?: any; 13 | handleLogin: (options?: RedirectLoginOptions) => Promise; 14 | handleLogout: (options?: LogoutOptions) => void; 15 | } 16 | 17 | const activeStyle = { 18 | color: '#111', 19 | }; 20 | 21 | const Navbar = ({ 22 | user, 23 | handleLogin, 24 | handleLogout, 25 | }: NavbarProps) => ( 26 | 27 | 28 | 29 | 32 | 33 | { 34 | user && 35 | 36 | 39 | 40 | } 41 | { 42 | user === undefined ? 43 | : 46 | 49 | } 50 | 51 | 52 | ); 53 | 54 | export default Navbar; 55 | -------------------------------------------------------------------------------- /infrastructure/variables.tf: -------------------------------------------------------------------------------- 1 | variable "region" { 2 | description = "AWS region to deploy to (e.g. ap-southeast-2)" 3 | } 4 | 5 | variable "name" { 6 | description = "Name of project (used in AWS resource names)" 7 | } 8 | 9 | variable "kms_key_arns" { 10 | description = "Array of KMS Key ARNs used to decrypt secrets specified via ssm_parameter_arns variable" 11 | } 12 | 13 | variable "ssm_parameter_arns" { 14 | description = "Array of SSM Parameter ARNs used to set secret build environment variables via SSM Parameter Store" 15 | } 16 | 17 | variable "build_docker_image" { 18 | description = "Docker image to use as build environment" 19 | } 20 | 21 | variable "build_docker_tag" { 22 | description = "Docker image tag to use as build environment" 23 | } 24 | 25 | variable "source_type" { 26 | description = "Type of repository that contains the source code to be built. Valid values for this parameter are: CODECOMMIT, CODEPIPELINE, GITHUB or S3." 27 | default = "GITHUB" 28 | } 29 | 30 | variable "buildspec" { 31 | description = "The CodeBuild build spec declaration path - see https://docs.aws.amazon.com/codebuild/latest/userguide/build-spec-ref.html" 32 | } 33 | 34 | variable "source_location" { 35 | description = "HTTPS URL of CodeCommit repo or S3 bucket to use as project source" 36 | } 37 | 38 | variable "cache_bucket" { 39 | description = "S3 bucket to use as build cache, the value must be a valid S3 bucket name/prefix" 40 | default = "" 41 | } 42 | 43 | variable "bucket_name" { 44 | description = "Name of deployment S3 bucket" 45 | } 46 | 47 | variable "dns_names" { 48 | description = "List of DNS names for app" 49 | type = list(string) 50 | } 51 | 52 | variable "route53_zone_id" { 53 | description = "Route 53 Hosted Zone ID" 54 | default = "" 55 | } 56 | 57 | variable "acm_arn" { 58 | description = "ARN of ACM SSL certificate" 59 | } 60 | -------------------------------------------------------------------------------- /infrastructure/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | aws = { 4 | source = "hashicorp/aws" 5 | version = "~> 2.0" 6 | } 7 | } 8 | backend "s3" { 9 | encrypt = "true" 10 | } 11 | } 12 | 13 | provider "aws" { 14 | region = var.region 15 | } 16 | 17 | resource "aws_iam_role" "codebuild_role" { 18 | name = "${var.name}-codebuild" 19 | 20 | assume_role_policy = < import(/* webpackChunkName: "books" */'../books/BooksPage')); 17 | // tslint:disable-next-line:space-in-parens 18 | const NotFoundPage = React.lazy(() => import(/* webpackChunkName: "not-found" */'../shared-components/NotFoundPage')); 19 | 20 | interface AppProps { 21 | history: History; 22 | } 23 | 24 | const App = ({ 25 | history, 26 | }: AppProps) => { 27 | const { isLoggingIn, loginWithRedirect, logout, user } = useAuth0(); 28 | 29 | return ( 30 | 31 | 32 | 33 | { 34 | isLoggingIn ? 35 | : 36 | 37 | loginWithRedirect({ appState: { targetUrl: '/books' } })} 40 | handleLogout={() => logout({ returnTo: window.location.origin, client_id: process.env.AUTH0_CLIENT_ID })} 41 | /> 42 | }> 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | } 52 | 53 | 54 | 55 | ); 56 | }; 57 | 58 | export default App; 59 | -------------------------------------------------------------------------------- /src/books/BooksPage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import { 4 | Box, 5 | Card, 6 | Container, 7 | Flex, 8 | Heading, 9 | Image, 10 | Message, 11 | Text, 12 | } from 'rebass'; 13 | import { useAuth0 } from '../auth/Auth0Wrapper'; 14 | import { GlobalState } from '../rootReducer'; 15 | import FullscreenLoader from '../shared-components/FullscreenLoader'; 16 | import { booksActions } from './reducer'; 17 | import { getError, getIsFetching, getSortedBooks } from './selectors'; 18 | 19 | import styles from './BooksPage.css'; 20 | 21 | const BooksPage = () => { 22 | const { getTokenSilently } = useAuth0(); 23 | const dispatch = useDispatch(); 24 | const books = useSelector((state: GlobalState) => getSortedBooks(state)); 25 | const isFetching = useSelector((state: GlobalState) => getIsFetching(state)); 26 | const error = useSelector((state: GlobalState) => getError(state)); 27 | 28 | React.useEffect( 29 | () => { 30 | const requestBooks = async () => { 31 | const token = await getTokenSilently(); 32 | 33 | if (token) { 34 | dispatch(booksActions.fetchBooks.started(token)); 35 | } 36 | }; 37 | 38 | requestBooks(); 39 | }, 40 | [], 41 | ); 42 | 43 | return isFetching ? 44 | : 45 | 46 | 47 | { 48 | error && 49 | 50 | {`Error: ${JSON.stringify(error)}`} 51 | 52 | } 53 | 54 | { 55 | [...books] 56 | .map(([id, book]) => ( 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | {book.title} 65 | 66 | 67 | {book.author} 68 |

69 | {book.description} 70 |

71 |
72 |
73 | )) 74 | } 75 |
76 |
; 77 | }; 78 | 79 | export default BooksPage; 80 | -------------------------------------------------------------------------------- /src/apiService.ts: -------------------------------------------------------------------------------- 1 | import { Auth0Client } from '@auth0/auth0-spa-js'; 2 | import { AnyAction } from 'redux'; 3 | import { put, select } from 'redux-saga/effects'; 4 | import { getAuth0Client } from './auth/selectors'; 5 | import Book from './books/Book'; 6 | import { GlobalState } from './rootReducer'; 7 | 8 | const books = require('./books/books.json'); 9 | 10 | // const getFetchInit = (requestMethod: string, idToken?: string, body?: any): RequestInit => { 11 | // const requestHeaders = new Headers(); 12 | 13 | // if (idToken) { 14 | // requestHeaders.append('Authorization', `Bearer ${idToken}`); 15 | // } 16 | 17 | // requestHeaders.append('Content-Type', 'application/json'); 18 | 19 | // const fetchInit = { method: requestMethod, headers: requestHeaders } as RequestInit; 20 | 21 | // if (body) { 22 | // fetchInit.body = JSON.stringify(body); 23 | // } 24 | 25 | // return fetchInit; 26 | // }; 27 | 28 | export async function fetchBooks(idToken: string): Promise> { 29 | // This app reads data from books.json as this is just a demonstration 30 | // Normally an API call would be made (see below) 31 | // The API should check validity of idToken and return unauthorised if not valid 32 | // The app would then prompt the user to log in again 33 | // See https://github.com/jch254/serverless-node-dynamodb-api for an example 34 | 35 | // const response = await fetch(`${process.env.API_BASE_URI}/items`, getFetchInit('GET', idToken)); 36 | 37 | try { 38 | return books 39 | .reduce( 40 | (returnedBooks: Map, book: Book) => { 41 | returnedBooks.set(book.id, book); 42 | 43 | return returnedBooks; 44 | }, 45 | new Map(), 46 | ); 47 | } catch (err) { 48 | throw new Error(`Error occurred downstream: ${err}`); 49 | } 50 | } 51 | 52 | export interface ResponseError extends Error { 53 | response?: Response; 54 | } 55 | 56 | export function* handleApiError(error: any, failureAction?: (error?: any) => AnyAction) { 57 | const response = error.response; 58 | 59 | if (response !== undefined) { 60 | if (response.status === 401) { 61 | // Unauthorised - show login 62 | if (failureAction !== undefined) { 63 | yield put(failureAction()); 64 | } 65 | 66 | const auth0Client: Auth0Client = yield select(getAuth0Client); 67 | const path: string = yield select((state: GlobalState) => state.router.location.pathname); 68 | 69 | auth0Client.logout({ returnTo: window.location.origin, client_id: process.env.AUTH0_CLIENT_ID }); 70 | auth0Client.loginWithRedirect({ appState: { targetUrl: path } }); 71 | } 72 | } else { 73 | if (failureAction !== undefined) { 74 | yield put(failureAction()); 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/books/books.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": "978-1501111105", 4 | "title": "Grit", 5 | "author": "Angela Duckworth", 6 | "description": "In this instant New York Times bestseller, pioneering psychologist Angela Duckworth shows anyone striving to succeed—be it parents, students, educators, athletes...", 7 | "img": "https://img.jch254.com/Grit.jpg", 8 | "url": "http://amzn.com/1501111108" 9 | }, 10 | { 11 | "id": "978-1942761327", 12 | "title": "The Low Carb Myth", 13 | "author": "Ari Whitten, Wade Smith", 14 | "description": "Does a High Carb Diet Make You Fat, Hungry, and Lazy? The answer may surprise you, but the science says no! The truth is that people can be healthy and lean...", 15 | "img": "https://img.jch254.com/LowCarbMyth.jpg", 16 | "url": "http://amzn.com/1942761325" 17 | }, 18 | { 19 | "id": "978-1455586691", 20 | "title": "Deep Work", 21 | "author": "Cal Newport", 22 | "description": "One of the most valuable skills in our economy is becoming increasingly rare. If you master this skill, you'll achieve extraordinary results. Deep work is the ability to focus without distraction...", 23 | "img": "https://img.jch254.com/DeepWork.jpg", 24 | "url": "http://amzn.com/1455586692" 25 | }, 26 | { 27 | "id": "978-1455509126", 28 | "title": "So Good They Can't Ignore You", 29 | "author": "Cal Newport", 30 | "description": "In this eye-opening account, Cal Newport debunks the long-held belief that 'follow your passion' is good advice. Not only is the cliché flawed-preexisting passions...", 31 | "img": "https://img.jch254.com/SoGood.jpg", 32 | "url": "http://amzn.com/1455509124" 33 | }, 34 | { 35 | "id": "978-0544456235", 36 | "title": "Peak", 37 | "author": "Anders Ericsson, Robert Pool", 38 | "description": "From the world’s reigning expert on expertise comes a powerful new approach to mastering almost any skill. Have you ever wanted to learn a language or pick up an...", 39 | "img": "https://img.jch254.com/Peak.jpg", 40 | "url": "http://amzn.com/0544456238" 41 | }, 42 | { 43 | "id": "978-0803740600", 44 | "title": "Quiet Power", 45 | "author": "Susan Cain", 46 | "description": "Susan Cain sparked a worldwide conversation when she published Quiet: The Power of Introverts in a World That Can’t Stop Talking. With her inspiring book, she...", 47 | "img": "https://img.jch254.com/QuietPower.jpg", 48 | "url": "http://amzn.com/0803740603" 49 | }, 50 | { 51 | "id": "978-1101903988", 52 | "title": "Born for This", 53 | "author": "Chris Guillebeau", 54 | "description": "Have you ever met someone with the perfect job? To the outside observer, it seems like they've won the career lottery -- that by some stroke of luck or circumstance...", 55 | "img": "https://img.jch254.com/BornForThis.jpg", 56 | "url": "http://amzn.com/1101903988" 57 | }, 58 | { 59 | "id": "978-0061561795", 60 | "title": "Island", 61 | "author": "Aldous Huxley", 62 | "description": "The final novel from Aldous Huxley, Island is a provocative counterpoint to his worldwide classic Brave New World, in which a flourishing, ideal society located on a....", 63 | "img": "https://img.jch254.com/Island.jpg", 64 | "url": "http://amzn.com/0061561797" 65 | }, 66 | { 67 | "id": "978-0465023950", 68 | "title": "The Pleasure of Finding Things Out", 69 | "author": "Richard P. Feynman", 70 | "description": "The Pleasure of Finding Things Out is a magnificent treasury of the best short works of Richard P. Feynman—from interviews...", 71 | "img": "https://img.jch254.com/Feynman.jpg", 72 | "url": "http://amzn.com/0465023959" 73 | } 74 | ] 75 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "starter-pack", 3 | "version": "1.0.0", 4 | "description": "React + Redux + Auth0", 5 | "license": "MIT", 6 | "scripts": { 7 | "dev": "cross-env TS_NODE_PROJECT=tsconfig-webpack.json ts-node server.ts", 8 | "build": "rimraf dist && cross-env NODE_ENV=production TS_NODE_PROJECT=tsconfig-webpack.json webpack --config ./webpack.prod.config.ts --progress --profile --colors", 9 | "prod": "cross-env NODE_ENV=production TS_NODE_PROJECT=tsconfig-webpack.json ts-node server.ts", 10 | "lint": "eslint ./ --ext ts,tsx", 11 | "analyze-dev": "rimraf stats.json && cross-env TS_NODE_PROJECT=tsconfig-webpack.json webpack --config webpack.config.ts --profile --json > stats.json && webpack-bundle-analyzer stats.json", 12 | "analyze-prod": "rimraf stats.json && cross-env NODE_ENV=production TS_NODE_PROJECT=tsconfig-webpack.json webpack --config webpack.prod.config.ts --profile --json > stats.json && webpack-bundle-analyzer stats.json" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/jch254/starter-pack" 17 | }, 18 | "author": "Jordan Hornblow ", 19 | "dependencies": { 20 | "@auth0/auth0-spa-js": "^1.10.0", 21 | "connected-react-router": "^6.8.0", 22 | "history": "^4.10.1", 23 | "immer": "^7.0.5", 24 | "isomorphic-fetch": "^2.2.1", 25 | "moment": "^2.27.0", 26 | "query-string": "5", 27 | "react": "^16.13.1", 28 | "react-dom": "^16.13.1", 29 | "react-ga": "^3.0.0", 30 | "react-loading": "^2.0.3", 31 | "react-redux": "^7.2.0", 32 | "react-router-dom": "^5.2.0", 33 | "react-transition-group": "^4.4.1", 34 | "rebass": "2.3.4", 35 | "redux": "^4.0.5", 36 | "redux-saga": "^v1.1.3", 37 | "reselect": "^4.0.0", 38 | "styled-components": "3.4.10", 39 | "typescript-fsa": "^3.0.0", 40 | "typescript-fsa-reducers": "^1.2.1", 41 | "typescript-fsa-redux-saga": "^2.0.0" 42 | }, 43 | "devDependencies": { 44 | "@types/history": "^4.7.6", 45 | "@types/html-webpack-plugin": "3.2.3", 46 | "@types/mini-css-extract-plugin": "^0.9.1", 47 | "@types/node": "^14.0.14", 48 | "@types/optimize-css-assets-webpack-plugin": "^5.0.1", 49 | "@types/query-string": "5.1.0", 50 | "@types/react": "^16.9.41", 51 | "@types/react-dom": "^16.9.8", 52 | "@types/react-redux": "^7.1.9", 53 | "@types/react-router-dom": "^5.1.5", 54 | "@types/terser-webpack-plugin": "^3.0.0", 55 | "@types/webpack": "^4.41.18", 56 | "@types/webpack-dev-server": "^3.11.0", 57 | "@types/webpack-env": "^1.15.2", 58 | "@typescript-eslint/eslint-plugin": "^3.5.0", 59 | "@typescript-eslint/eslint-plugin-tslint": "^3.5.0", 60 | "@typescript-eslint/parser": "^3.5.0", 61 | "cross-env": "^7.0.2", 62 | "css-loader": "^3.6.0", 63 | "eslint": "^7.4.0", 64 | "eslint-plugin-css-modules": "^2.11.0", 65 | "file-loader": "^6.0.0", 66 | "fork-ts-checker-webpack-plugin": "^5.0.6", 67 | "html-webpack-plugin": "^3.2.0", 68 | "inline-manifest-webpack-plugin": "^4.0.2", 69 | "map.prototype.tojson": "^0.1.3", 70 | "mini-css-extract-plugin": "^0.9.0", 71 | "optimize-css-assets-webpack-plugin": "^5.0.3", 72 | "redux-devtools-extension": "^2.13.8", 73 | "rimraf": "^3.0.2", 74 | "source-map-loader": "^1.0.1", 75 | "style-loader": "^1.2.1", 76 | "terser-webpack-plugin": "^3.0.6", 77 | "ts-loader": "^7.0.5", 78 | "ts-node": "^8.10.2", 79 | "tslint": "^6.1.2", 80 | "tslint-config-airbnb": "^5.11.2", 81 | "tsutils": "^3.17.1", 82 | "typescript": "^3.9.6", 83 | "url-loader": "^4.1.0", 84 | "webpack": "^4.43.0", 85 | "webpack-bundle-analyzer": "^3.8.0", 86 | "webpack-chunk-hash": "^0.6.0", 87 | "webpack-cli": "^3.3.12", 88 | "webpack-dev-server": "^3.11.0" 89 | } 90 | } -------------------------------------------------------------------------------- /src/auth/Auth0Wrapper.tsx: -------------------------------------------------------------------------------- 1 | import createAuth0Client, { 2 | getIdTokenClaimsOptions, 3 | Auth0Client, 4 | Auth0ClientOptions, 5 | GetTokenSilentlyOptions, 6 | GetTokenWithPopupOptions, 7 | IdToken, 8 | LogoutOptions, 9 | PopupLoginOptions, 10 | RedirectLoginOptions, 11 | } from '@auth0/auth0-spa-js'; 12 | import { replace } from 'connected-react-router'; 13 | import * as React from 'react'; 14 | import { useDispatch, useSelector } from 'react-redux'; 15 | import { authActions } from './reducer'; 16 | import { getAuth0Client } from './selectors'; 17 | 18 | export interface Auth0Context { 19 | isAuthenticated: boolean; 20 | user?: any; 21 | isLoggingIn: boolean; 22 | isPopupOpen: boolean; 23 | loginWithPopup: (options?: PopupLoginOptions) => Promise; 24 | getIdTokenClaims: (options?: getIdTokenClaimsOptions) => Promise; 25 | loginWithRedirect: (options?: RedirectLoginOptions) => Promise; 26 | getTokenSilently: (options?: GetTokenSilentlyOptions) => Promise; 27 | getTokenWithPopup: (options?: GetTokenWithPopupOptions) => Promise; 28 | logout: (options?: LogoutOptions) => void; 29 | } 30 | 31 | export const Auth0Context = React.createContext({} as Auth0Context); 32 | 33 | export const useAuth0 = () => React.useContext(Auth0Context); 34 | 35 | interface Auth0ProviderProps extends Auth0ClientOptions { 36 | children: any; 37 | } 38 | 39 | export const Auth0Provider = ({ 40 | children, 41 | ...auth0Options 42 | }: Auth0ProviderProps) => { 43 | const [isAuthenticated, setIsAuthenticated] = React.useState(false); 44 | const [user, setUser] = React.useState(); 45 | const [isLoggingIn, setIsLoggingIn] = React.useState(true); 46 | const [isPopupOpen, setIsPopupOpen] = React.useState(false); 47 | const dispatch = useDispatch(); 48 | const auth0Client = useSelector(getAuth0Client); 49 | 50 | React.useEffect( 51 | () => { 52 | const initAuth0 = async () => { 53 | const auth0Client = await createAuth0Client(auth0Options as Auth0ClientOptions); 54 | 55 | dispatch(authActions.setAuth0Client(auth0Client)); 56 | 57 | if (window.location.search.includes('code=')) { 58 | const { appState } = await auth0Client.handleRedirectCallback(); 59 | 60 | dispatch(replace(appState && appState.targetUrl ? appState.targetUrl : '/')); 61 | } 62 | 63 | const isAuthenticated = await auth0Client.isAuthenticated(); 64 | 65 | setIsAuthenticated(isAuthenticated); 66 | 67 | if (isAuthenticated) { 68 | const user = await auth0Client.getUser(); 69 | 70 | setUser(user); 71 | } 72 | 73 | setIsLoggingIn(false); 74 | }; 75 | 76 | initAuth0(); 77 | }, 78 | [], 79 | ); 80 | 81 | const loginWithPopup = async (options?: PopupLoginOptions) => { 82 | setIsPopupOpen(true); 83 | 84 | try { 85 | await (auth0Client as Auth0Client).loginWithPopup(options); 86 | } catch (error) { 87 | console.error(error); 88 | } finally { 89 | setIsPopupOpen(false); 90 | } 91 | 92 | const user = await (auth0Client as Auth0Client).getUser(); 93 | 94 | setUser(user); 95 | setIsAuthenticated(true); 96 | }; 97 | 98 | return ( 99 | (auth0Client as Auth0Client).getIdTokenClaims(options), 107 | loginWithRedirect: (options?: RedirectLoginOptions) => (auth0Client as Auth0Client).loginWithRedirect(options), 108 | getTokenSilently: (options?: GetTokenSilentlyOptions) => (auth0Client as Auth0Client).getTokenSilently(options), 109 | getTokenWithPopup: (options?: GetTokenWithPopupOptions) => (auth0Client as Auth0Client).getTokenWithPopup(options), 110 | logout: (options?: LogoutOptions) => (auth0Client as Auth0Client).logout(options), 111 | }} 112 | > 113 | {children} 114 | 115 | ); 116 | }; 117 | -------------------------------------------------------------------------------- /webpack.config.ts: -------------------------------------------------------------------------------- 1 | import * as HtmlWebpackPlugin from 'html-webpack-plugin'; 2 | import * as InlineManifestWebpackPlugin from 'inline-manifest-webpack-plugin'; 3 | import * as path from 'path'; 4 | import * as webpack from 'webpack'; 5 | 6 | import ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin'); 7 | 8 | const SERVER_PORT = process.env.SERVER_PORT || 3001; 9 | const SERVER_HOSTNAME = process.env.SERVER_HOSTNAME || 'localhost'; 10 | 11 | const config: webpack.Configuration = { 12 | mode: 'development', 13 | devtool: 'cheap-module-source-map', 14 | entry: [ 15 | 'webpack/hot/dev-server', 16 | `webpack-dev-server/client?http://${SERVER_HOSTNAME}:${SERVER_PORT}`, 17 | path.join(__dirname, 'src', 'index.tsx'), 18 | ], 19 | output: { 20 | path: path.join(__dirname, 'dist'), 21 | filename: 'assets/[name].js', 22 | chunkFilename: 'assets/[name].js', 23 | publicPath: '/', 24 | }, 25 | plugins: [ 26 | new webpack.DefinePlugin({ 27 | 'process.env': { 28 | AUTH0_CLIENT_ID: JSON.stringify(process.env.AUTH0_CLIENT_ID), 29 | AUTH0_DOMAIN: JSON.stringify(process.env.AUTH0_DOMAIN), 30 | GA_ID: JSON.stringify(process.env.GA_ID), 31 | }, 32 | }), 33 | new webpack.ContextReplacementPlugin(/moment[/\\]locale$/, /en/), 34 | new webpack.HotModuleReplacementPlugin(), 35 | new HtmlWebpackPlugin({ 36 | title: 'Starter Pack | 603.nz', 37 | template: path.join(__dirname, 'src', 'index.ejs'), 38 | favicon: path.join(__dirname, 'src', 'favicon.ico'), 39 | meta: [ 40 | { 41 | name: 'description', 42 | content: 'React + Redux + Auth0', 43 | }, 44 | ], 45 | minify: { 46 | collapseWhitespace: true, 47 | }, 48 | }), 49 | new InlineManifestWebpackPlugin(), 50 | new ForkTsCheckerWebpackPlugin({ 51 | async: false, 52 | eslint: { 53 | files: './src/**/*.{ts,tsx,js,jsx}', // required - same as command `eslint ./src/**/*.{ts,tsx,js,jsx} --ext .ts,.tsx,.js,.jsx` 54 | }, 55 | }), 56 | ], 57 | optimization: { 58 | runtimeChunk: 'single', 59 | splitChunks: { 60 | chunks: 'all', 61 | cacheGroups: { 62 | default: false, 63 | vendor: { 64 | name: 'vendor', 65 | chunks: 'all', 66 | test: /[\\/]node_modules[\\/]/, 67 | priority: 20, 68 | }, 69 | utilities: { 70 | test: /[\\/]node_modules[\\/](immutable|moment|react|react-dom|react-loading)[\\/]/, 71 | name: 'utilities', 72 | priority: 30, 73 | }, 74 | common: { 75 | name: 'async-common', 76 | minChunks: 2, 77 | chunks: 'async', 78 | priority: 10, 79 | reuseExistingChunk: true, 80 | enforce: true, 81 | }, 82 | }, 83 | }, 84 | }, 85 | resolve: { 86 | extensions: ['.ts', '.tsx', '.js', '.jsx', '.css', '.json'], 87 | modules: [ 88 | path.join(__dirname, 'src'), 89 | path.join(__dirname, 'node_modules'), 90 | ], 91 | }, 92 | module: { 93 | rules: [ 94 | { 95 | test: /\.tsx?$/, 96 | include: path.join(__dirname, 'src'), 97 | use: [{ 98 | loader: 'ts-loader', 99 | options: { 100 | transpileOnly: true, 101 | experimentalWatchApi: true, 102 | }, 103 | }], 104 | }, 105 | { 106 | test: /\.js$/, 107 | use: ['source-map-loader'], 108 | include: path.join(__dirname, 'src'), 109 | enforce: 'pre', 110 | }, 111 | { 112 | test: /\.css?$/, 113 | include: path.join(__dirname, 'src'), 114 | use: [ 115 | { 116 | loader: 'style-loader', 117 | }, 118 | { 119 | loader: 'css-loader', 120 | options: { 121 | modules: { 122 | mode: 'local', 123 | localIdentName: '[path][name]__[local]--[hash:base64:5]', 124 | }, 125 | }, 126 | }, 127 | ], 128 | }, 129 | { 130 | test: /\.(jpe?g|png|gif|svg|ico)$/, 131 | include: path.join(__dirname, 'src'), 132 | use: [{ 133 | loader: 'url-loader', 134 | options: { 135 | limit: 10240, 136 | esModule: false, 137 | }, 138 | }], 139 | }, 140 | ], 141 | }, 142 | }; 143 | 144 | export default config; 145 | -------------------------------------------------------------------------------- /tsconfig-webpack.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "ES5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */ 5 | "module": "commonjs", /* Specify module code generation: 'commonjs', 'amd', 'system', 'umd' or 'es2015'. */ 6 | //"lib": [], /* Specify library files to be included in the compilation: */ 7 | "allowJs": false, /* Allow javascript files to be compiled. */ 8 | "checkJs": false, /* Report errors in .js files. */ 9 | // "jsx": "react", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 10 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 11 | "sourceMap": false, /* Generates corresponding '.map' file. */ 12 | // "outFile": "./", /* Concatenate and emit output to single file. */ 13 | "outDir": "./ts", /* Redirect output structure to the directory. */ 14 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 15 | // "removeComments": true, /* Do not emit comments to output. */ 16 | // "noEmit": true, /* Do not emit outputs. */ 17 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 18 | "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 19 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 20 | /* Strict Type-Checking Options */ 21 | "strict": true, /* Enable all strict type-checking options. */ 22 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 23 | // "strictNullChecks": true, /* Enable strict null checks. */ 24 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 25 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 26 | /* Additional Checks */ 27 | "noUnusedLocals": true, /* Report errors on unused locals. */ 28 | // "noUnusedParameters": false, /* Report errors on unused parameters. */ 29 | "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 30 | "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 31 | "noStrictGenericChecks": true, /* Disable strict checking of generic signatures in function types. */ 32 | /* Module Resolution Options */ 33 | // "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 34 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 35 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 36 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 37 | "typeRoots": [ 38 | "./node_modules/@types", 39 | "./typings" 40 | ], /* List of folders to include type definitions from. */ 41 | // "types": [], /* Type declaration files to be included in compilation. */ 42 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 43 | /* Source Map Options */ 44 | // "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 45 | // "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */ 46 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 47 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 48 | /* Experimental Options */ 49 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 50 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 51 | }, 52 | "exclude": [ 53 | "node_modules", 54 | "dist" 55 | ] 56 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "ES5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', or 'ESNEXT'. */ 5 | "module": "esnext", /* Specify module code generation: 'commonjs', 'amd', 'system', 'umd' or 'es2015'. */ 6 | "lib": [ 7 | "es6", 8 | "es5", 9 | "dom" 10 | ], /* Specify library files to be included in the compilation: */ 11 | "allowJs": false, /* Allow javascript files to be compiled. */ 12 | "checkJs": false, /* Report errors in .js files. */ 13 | "jsx": "react", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 14 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 15 | "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | "outDir": "./ts", /* Redirect output structure to the directory. */ 18 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "removeComments": true, /* Do not emit comments to output. */ 20 | // "noEmit": true, /* Do not emit outputs. */ 21 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 22 | "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 23 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 24 | /* Strict Type-Checking Options */ 25 | "strict": true, /* Enable all strict type-checking options. */ 26 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 27 | // "strictNullChecks": true, /* Enable strict null checks. */ 28 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 29 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 30 | "strictFunctionTypes": false, /* See: https://github.com/reduxjs/redux/issues/2709 */ 31 | /* Additional Checks */ 32 | "noUnusedLocals": true, /* Report errors on unused locals. */ 33 | // "noUnusedParameters": false, /* Report errors on unused parameters. */ 34 | "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 35 | "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 36 | "noStrictGenericChecks": true, /* Disable strict checking of generic signatures in function types. */ 37 | /* Module Resolution Options */ 38 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 39 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 40 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 41 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 42 | // "typeRoots": [], /* List of folders to include type definitions from. */ 43 | // "types": [], /* Type declaration files to be included in compilation. */ 44 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 45 | /* Source Map Options */ 46 | // "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 47 | // "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */ 48 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 49 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 50 | /* Experimental Options */ 51 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 52 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 53 | "importsNotUsedAsValues": "preserve" 54 | }, 55 | "exclude": [ 56 | "node_modules", 57 | "dist" 58 | ] 59 | } -------------------------------------------------------------------------------- /webpack.prod.config.ts: -------------------------------------------------------------------------------- 1 | import * as HtmlWebpackPlugin from 'html-webpack-plugin'; 2 | import * as InlineManifestWebpackPlugin from 'inline-manifest-webpack-plugin'; 3 | import * as MiniCssExtractPlugin from 'mini-css-extract-plugin'; 4 | import * as OptimizeCssAssetsPlugin from 'optimize-css-assets-webpack-plugin'; 5 | import * as path from 'path'; 6 | import * as TerserPlugin from 'terser-webpack-plugin'; 7 | import * as webpack from 'webpack'; 8 | import * as WebpackChunkHash from 'webpack-chunk-hash'; 9 | 10 | import ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin'); 11 | 12 | const config: webpack.Configuration = { 13 | mode: 'production', 14 | entry: [ 15 | path.join(__dirname, 'src', 'index.tsx'), 16 | ], 17 | output: { 18 | path: path.join(__dirname, 'dist'), 19 | filename: 'assets/[name].[contenthash].js', 20 | chunkFilename: 'assets/[name].[contenthash].js', 21 | publicPath: '/', 22 | }, 23 | plugins: [ 24 | new webpack.DefinePlugin({ 25 | 'process.env': { 26 | AUTH0_CLIENT_ID: JSON.stringify(process.env.AUTH0_CLIENT_ID), 27 | AUTH0_DOMAIN: JSON.stringify(process.env.AUTH0_DOMAIN), 28 | GA_ID: JSON.stringify(process.env.GA_ID), 29 | }, 30 | }), 31 | new webpack.ContextReplacementPlugin(/moment[/\\]locale$/, /en/), 32 | new WebpackChunkHash(), 33 | new MiniCssExtractPlugin({ 34 | filename: 'assets/[name].[contenthash].css', 35 | ignoreOrder: true, 36 | }), 37 | new HtmlWebpackPlugin({ 38 | title: 'Starter Pack | 603.nz', 39 | template: path.join(__dirname, 'src', 'index.ejs'), 40 | favicon: path.join(__dirname, 'src', 'favicon.ico'), 41 | meta: [ 42 | { 43 | name: 'description', 44 | content: 'React + Redux + Auth0', 45 | }, 46 | ], 47 | minify: { 48 | collapseWhitespace: true, 49 | }, 50 | }), 51 | new InlineManifestWebpackPlugin(), 52 | new ForkTsCheckerWebpackPlugin({ 53 | async: false, 54 | eslint: { 55 | files: './src/**/*.{ts,tsx,js,jsx}', // required - same as command `eslint ./src/**/*.{ts,tsx,js,jsx} --ext .ts,.tsx,.js,.jsx` 56 | }, 57 | }), 58 | ], 59 | optimization: { 60 | runtimeChunk: 'single', 61 | moduleIds: 'hashed', 62 | chunkIds: 'named', 63 | minimizer: [ 64 | new OptimizeCssAssetsPlugin({ 65 | cssProcessorOptions: { safe: true, discardComments: { removeAll: true } }, 66 | canPrint: false, 67 | }), 68 | new TerserPlugin({ 69 | parallel: true, 70 | terserOptions: { 71 | mangle: { 72 | safari10: true, 73 | }, 74 | }, 75 | }), 76 | ], 77 | splitChunks: { 78 | chunks: 'all', 79 | maxInitialRequests: Infinity, 80 | cacheGroups: { 81 | default: false, 82 | vendor: { 83 | name: 'vendor', 84 | chunks: 'all', 85 | test: /[\\/]node_modules[\\/]/, 86 | priority: 20, 87 | }, 88 | utilities: { 89 | test: /[\\/]node_modules[\\/](immutable|moment|react|react-dom|react-loading)[\\/]/, 90 | name: 'utilities', 91 | priority: 30, 92 | }, 93 | common: { 94 | name: 'async-common', 95 | minChunks: 2, 96 | chunks: 'async', 97 | priority: 10, 98 | reuseExistingChunk: true, 99 | enforce: true, 100 | }, 101 | // See: https://github.com/webpack-contrib/mini-css-extract-plugin/issues/85 102 | styles: { 103 | name: 'styles', 104 | test: module => module.nameForCondition && /\.css$/.test(module.nameForCondition()) && !/^javascript/.test(module.type), 105 | chunks: 'all', 106 | enforce: true, 107 | }, 108 | }, 109 | }, 110 | }, 111 | resolve: { 112 | extensions: ['.ts', '.tsx', '.js', '.jsx', '.css', '.json'], 113 | modules: [ 114 | path.join(__dirname, 'src'), 115 | path.join(__dirname, 'node_modules'), 116 | ], 117 | }, 118 | module: { 119 | rules: [ 120 | { 121 | test: /\.tsx?$/, 122 | include: path.join(__dirname, 'src'), 123 | use: [{ 124 | loader: 'ts-loader', 125 | options: { 126 | transpileOnly: true, 127 | experimentalWatchApi: true, 128 | }, 129 | }], 130 | }, 131 | { 132 | test: /\.css?$/, 133 | include: path.join(__dirname, 'src'), 134 | use: [ 135 | MiniCssExtractPlugin.loader, 136 | { 137 | loader: 'css-loader', 138 | options: { 139 | modules: { 140 | mode: 'local', 141 | localIdentName: '[hash:base64:5]', 142 | }, 143 | }, 144 | }, 145 | ], 146 | }, 147 | { 148 | test: /\.(jpe?g|png|gif|svg|ico)$/, 149 | include: path.join(__dirname, 'src'), 150 | use: [{ 151 | loader: 'url-loader', 152 | options: { 153 | limit: 10240, 154 | esModule: false, 155 | }, 156 | }], 157 | }, 158 | ], 159 | }, 160 | }; 161 | 162 | export default config; 163 | -------------------------------------------------------------------------------- /src/shared-components/HomePage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import { 4 | Banner, 5 | Box, 6 | Container, 7 | Flex, 8 | Heading, 9 | Subhead, 10 | Text, 11 | } from 'rebass'; 12 | 13 | const banner = require('./Banner.jpg'); 14 | 15 | const HomePage = () => ( 16 | 17 | 22 | 23 | Starter Pack 24 | 25 | 26 | React + Redux + Auth0 27 | 28 | 29 | 35 | View on Github 36 | 37 | 38 | 39 | 40 | 41 | 42 | Starter Pack combines React (ft. hooks), Redux and Redux-saga with Auth0's Universal Login as a starting point for modern web apps with solid 47 | authentication. Why reinvent the wheel? The app utilises Rebass to keep things looking decent. I built this 49 | as a way to quickly prototype new ideas. 50 | 51 | 52 | 53 | 54 | 55 | React 56 | 57 | 58 | 59 | 60 | Redux 61 | 62 | 63 | 64 | 65 | 66 | Redux Saga 67 | 68 | 69 | 70 | 71 | 72 | Auth0 Universal Login 73 | 74 | 75 | 76 | 77 | 78 | React Router 79 | 80 | 81 | 82 | 83 | 84 | Rebass 85 | 86 | 87 | 88 | 89 | Reselect 90 | 91 | 92 | 93 | 94 | TypeScript 95 | 96 | 97 | 98 | 99 | Node.js 100 | 101 | 102 | 103 | 104 | Webpack 105 | 106 | 107 | 108 | 109 | Webpack (ft. various loaders/plugins/tools) is used to run a local development server and build 110 | the production version. Code splitting (with long-term caching in the production version) has been set 113 | up via Webpack and React. Webpack's SplitChunksPlugin is used to split vendor code. 115 | React.lazy is used for async component-centric code splitting and loading - see App.tsx 116 | as an example of creating a split point. MiniCssExtractPlugin is used to split CSS. HtmlWebpackPlugin is 117 | used to generate an index.html with the appropriate output assets injected, the Webpack manifest is 118 | inlined into index.html to save requests. 119 | 120 | 121 | The app contains a 'locked down' Books page which 122 | requires a user to log in/sign up before content will be visible. The data is read from a 123 | local JSON file as this is a only demonstration/starting point. In the real world data 124 | would be fetched from an API (see apiService.ts). The API should check 125 | validity of the JWT token and return unauthorised if invalid. The app would then prompt 126 | the user to log in again. See Serverless API for 127 | a more detailed example of authentication in action. 128 | 129 | 130 | This branch utilises TypeScript for type checking and transpliation to 132 | browser-friendly ES5 JavaScript. ESLint (ft. plugins) runs on compilation and fails the build 133 | if errors are reported. Eslint-plugin-css-modules provides type checking of CSS modules - identifying 134 | CSS files with unused classes and components using undefined CSS classes. 135 | 136 | 137 | 138 | ); 139 | 140 | export default HomePage; 141 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Starter Pack (TypeScript Edition) 2 | 3 | [![Build Status](https://codebuild.ap-southeast-2.amazonaws.com/badges?uuid=eyJlbmNyeXB0ZWREYXRhIjoiK2RUODZJTEw1YStIMDBhQmoyNGZuQmJzVi9FZFRoVGIrWWxCZVRuRlRZUlVOeFRLZzl1azA0Sm1mUEVLU3d6YWxoR2c4bHlpNHZVNnBpb09aOEVUMUdFPSIsIml2UGFyYW1ldGVyU3BlYyI6IjdKSzZqbGtVVHRDY2xjemoiLCJtYXRlcmlhbFNldFNlcmlhbCI6MX0%3D&branch=typescript)](https://starter-pack-typescript.603.nz) 4 | [![TypeScript](https://img.shields.io/badge/TypeScript-Ready-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/) 5 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) 6 | [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](#contributing-) 7 | 8 | Modern React + Auth0 + Redux Saga + Webpack setup for quickly prototyping secure single‑page applications. 9 | 10 | ## Why this project? 11 | 12 | Spinning up a serious front‑end often means repeating the same plumbing: auth flows, routing, state management, async side effects, code splitting, build optimisation, and a decent component baseline. Starter Pack gives you an opinionated, production‑leaning foundation so you can focus on your idea—not on wiring boilerplate. 13 | 14 | You get: 15 | 16 | * Robust authentication via Auth0 Universal Login (JWT-based, ready to pair with any API) 17 | * A protected example route (Books) demonstrating gated content & token handling 18 | * Sensible architecture with Redux + Redux Saga for predictable async workflows 19 | * Modern React (hooks + lazy loading) with granular code splitting & long‑term caching 20 | * TypeScript everywhere for safer refactors and discoverable APIs 21 | * Fast, cache‑friendly Webpack build geared for dev velocity and production reliability 22 | 23 | ## Key Features 24 | 25 | * 🔐 Auth0 Universal Login integration (easily swap provider if needed) 26 | * 🔄 Redux + Redux Saga side‑effect model 27 | * 🧩 Code splitting with `React.lazy` + Webpack SplitChunks + CSS extraction 28 | * 🏗 Strong type safety (TypeScript) + linting (ESLint + css‑modules validation) 29 | * 🎯 Example domain (books) incl. protected route + JSON data stub 30 | * 🚀 Hot‑reload dev server 31 | * 🐳 Docker support for parity & deployment experiments 32 | * 📦 Production build with hashed assets & manifest inlining 33 | 34 | ## Live Demo 35 | 36 | Visit: 37 | 38 | Screenshots: 39 | 40 | | Main | Login | Protected Content | 41 | |------|-------|-------------------| 42 | | ![Main](https://img.jch254.com/Main.png) | ![Modal](https://img.jch254.com/Login.png) | ![Recommended](https://img.jch254.com/Books.png) | 43 | 44 | ## Quick Start (Copy & Paste) 45 | 46 | ```bash 47 | git clone https://github.com/jch254/starter-pack.git 48 | cd starter-pack 49 | git checkout typescript 50 | yarn install 51 | AUTH0_CLIENT_ID=YOUR_CLIENT_ID \ 52 | AUTH0_DOMAIN=YOUR_DOMAIN \ 53 | yarn run dev 54 | ``` 55 | 56 | Open 57 | 58 | Don't have Auth0 values yet? See Configuration below—you can still explore most of the UI without logging in. 59 | 60 | ## Installation 61 | 62 | Prerequisites: 63 | 64 | * Node.js (LTS recommended) 65 | * Yarn (or adapt commands to npm/pnpm) 66 | * Auth0 account (for full auth flow) 67 | 68 | Install dependencies: 69 | 70 | ```bash 71 | yarn install 72 | ``` 73 | 74 | ## Configuration (Environment Variables) 75 | 76 | Two environment variables are required for authentication to function: 77 | 78 | | Variable | Description | Example | 79 | |----------|-------------|---------| 80 | | `AUTH0_CLIENT_ID` | SPA application Client ID | `abc123XYZ` | 81 | | `AUTH0_DOMAIN` | Your Auth0 tenant domain | `your-tenant.eu.auth0.com` | 82 | 83 | Set them inline when running scripts: 84 | 85 | ```bash 86 | AUTH0_CLIENT_ID=abc AUTH0_DOMAIN=your-tenant.eu.auth0.com yarn run dev 87 | ``` 88 | 89 | Or export them (macOS/Linux): 90 | 91 | ```bash 92 | export AUTH0_CLIENT_ID=abc 93 | export AUTH0_DOMAIN=your-tenant.eu.auth0.com 94 | yarn run dev 95 | ``` 96 | 97 | Auth0 Setup: 98 | 99 | 1. Create a Single Page Application in the Auth0 dashboard 100 | 2. Add `http://localhost:3001` to Allowed Callback URLs & Allowed Web Origins 101 | 3. Save changes and copy the Client ID + Domain 102 | 103 | ## Available Scripts 104 | 105 | | Script | Purpose | Notes | 106 | |--------|---------|-------| 107 | | `yarn run dev` | Start dev server with hot reload | Serves at | 108 | | `yarn run build` | Production bundle | Outputs to `/dist` | 109 | | `yarn run prod` | Serve built production bundle | Requires prior build (invokes build if necessary) | 110 | 111 | ## Development Workflow 112 | 113 | Start locally (auth enabled): 114 | 115 | ```bash 116 | AUTH0_CLIENT_ID=abc AUTH0_DOMAIN=your-tenant.eu.auth0.com yarn run dev 117 | ``` 118 | 119 | Build production assets: 120 | 121 | ```bash 122 | yarn run build 123 | ``` 124 | 125 | Serve production build locally: 126 | 127 | ```bash 128 | yarn run prod 129 | ``` 130 | 131 | ## Docker Usage 132 | 133 | Build image: 134 | 135 | ```bash 136 | docker build -t starter-pack . 137 | ``` 138 | 139 | Run (choose an npm script: `dev` or `prod`): 140 | 141 | ```bash 142 | docker run \ 143 | -p 3001:3001 \ 144 | -e AUTH0_CLIENT_ID=abc \ 145 | -e AUTH0_DOMAIN=your-tenant.eu.auth0.com \ 146 | starter-pack dev 147 | ``` 148 | 149 | If you omit the script name the container will exit and list available commands. 150 | 151 | ## Architecture & Tech Stack 152 | 153 | Core stack: 154 | 155 | * React (hooks) + React Router 156 | * Redux + Redux Saga + Reselect 157 | * Auth0 SPA SDK 158 | * TypeScript (strict-ish typing) + ESLint 159 | * Rebass (primitive UI components) + CSS Modules 160 | * Webpack (dev server, SplitChunks, manifest inlining, MiniCssExtractPlugin) 161 | 162 | Notable implementation details: 163 | 164 | * Code splitting: dynamic `React.lazy` boundaries (see `src/app/App.tsx`) 165 | * Protected route pattern via Auth0 wrapper (`src/auth` directory) 166 | * Example data service abstraction (`src/apiService.ts`) 167 | * Separate reducers & sagas by domain (`src/books`, `src/app`) 168 | * Type definitions in `typings/` for external modules without bundled types 169 | 170 | Directory snapshot: 171 | 172 | ```text 173 | src/ 174 | app/ # App shell & root component 175 | auth/ # Auth0 integration + route guard 176 | books/ # Example protected feature module 177 | shared-components/ # Reusable UI pieces 178 | infrastructure/ # Terraform + scripts for infra & deployment 179 | ``` 180 | 181 | ## Extending / Customisation Ideas 182 | 183 | * Swap Auth0 for another OIDC/OAuth provider 184 | * Add API layer + real network calls (fetch/axios + token refresh) 185 | * Introduce testing (Jest + React Testing Library) 186 | * Add performance budgets / bundle analyzer 187 | * Implement dark mode theme toggle 188 | 189 | ## Contributing 🙌 190 | 191 | Contributions are very welcome—whether it's a bug report, feature idea, documentation tweak, or a pull request. 192 | 193 | 1. Fork the repo & create a branch: `git checkout -b feature/your-thing` 194 | 2. Make changes (keep commits purposeful) 195 | 3. Run lint/build locally 196 | 4. Open a Pull Request describing the change & rationale 197 | 198 | Guidelines: 199 | Guidelines: 200 | 201 | * Keep scope minimal—small PRs are easier to review 202 | * Add comments where intent isn't obvious 203 | * Prefer TypeScript strictness over `any` 204 | * Match existing code style (lint will help) 205 | 206 | Feel free to open an issue first to discuss bigger ideas. 207 | 208 | ## Reporting Issues 209 | 210 | When filing an issue, please include: 211 | 212 | * What you expected vs what happened 213 | * Steps to reproduce 214 | * Environment (OS, Node version) if relevant 215 | * Logs or screenshots (if helpful) 216 | 217 | ## FAQ 218 | 219 | **Q: Can I use npm instead of yarn?** 220 | A: Yes—adapt commands (`npm install`, `npm run dev`, etc.). 221 | 222 | **Q: Do I need Auth0 to try it?** 223 | A: You can run the app without environment variables; protected routes will simply not authenticate. 224 | 225 | **Q: Where do I plug in an API?** 226 | A: Start in `src/apiService.ts` and replace the mocked JSON flow with real fetch logic. 227 | 228 | ## License 229 | 230 | MIT © 2016–present Jordan Hornblow. See [LICENSE](./LICENSE) for full text. 231 | 232 | ## Acknowledgements 233 | 234 | * Auth0 for the SPA SDK 235 | * React, Redux, Saga & broader OSS ecosystem 236 | 237 | --- 238 | 239 | Enjoying this starter? A star ⭐ helps others discover it. 240 | 241 | --- 242 | 243 | Looking for the plain JavaScript version? See the [master branch](https://github.com/jch254/starter-pack/tree/master). 244 | --------------------------------------------------------------------------------