├── .babelrc ├── .buildpacks ├── .codeclimate.yml ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .travis.yml ├── LICENSE.md ├── Procfile ├── README.md ├── app.json ├── bin └── server.js ├── config ├── default.json └── production.json ├── package.json ├── server.babel.js ├── src ├── actions │ ├── demo-ui.js │ └── request-test-buttons.js ├── app.js ├── client.js ├── components │ ├── CodeSnippet │ │ ├── CodeSnippet.scss │ │ └── index.js │ ├── Container.js │ ├── ExampleWell.js │ ├── GlobalComponents.js │ ├── IndexPanel.js │ ├── RequestTestButton.js │ ├── RequestTestErrorModal.js │ └── RequestTestSuccessModal.js ├── config.js ├── containers │ ├── Account.js │ ├── Main.js │ └── SignIn.js ├── helpers │ └── Html.js ├── reducers │ ├── demo-ui.js │ └── request-test-buttons.js ├── server.js ├── styles │ └── main.scss └── theme │ ├── bootstrap.config.js │ ├── bootstrap.config.prod.js │ ├── bootstrap.overrides.scss │ ├── font-awesome.config.js │ ├── font-awesome.config.less │ ├── font-awesome.config.prod.js │ └── variables.scss ├── static └── favicon.ico ├── test ├── components │ └── RequestTestButton-test.js ├── helper.js ├── redux │ └── demo-ui-test.js └── setup.js └── webpack ├── dev.config.js ├── prod.config.js ├── webpack-dev-server.js └── webpack-isomorphic-tools.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["react", "es2015", "stage-0"], 3 | 4 | "plugins": [ 5 | "transform-runtime", 6 | "add-module-exports", 7 | "transform-decorators-legacy", 8 | "transform-react-display-name" 9 | ], 10 | 11 | "env": { 12 | "development": { 13 | "plugins": [ 14 | "typecheck", 15 | ["react-transform", { 16 | "transforms": [{ 17 | "transform": "react-transform-catch-errors", 18 | "imports": ["react", "redbox-react"] 19 | }] 20 | }] 21 | ] 22 | } 23 | } 24 | } 25 | 26 | -------------------------------------------------------------------------------- /.buildpacks: -------------------------------------------------------------------------------- 1 | https://github.com/heroku/heroku-buildpack-nodejs 2 | https://github.com/jerrysu/heroku-buildpack-webpack 3 | -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | engines: 2 | eslint: 3 | enabled: true 4 | channel: "eslint-2" 5 | 6 | ratings: 7 | paths: 8 | - "src/**.js" 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_style = space 3 | end_of_line = lf 4 | indent_size = 2 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | 8 | [*.md] 9 | max_line_length = 0 10 | trim_trailing_whitespace = false 11 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | webpack/* 2 | karma.conf.js 3 | tests.webpack.js 4 | server.babel.js 5 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint-config-airbnb", 3 | "parser": "babel-eslint", 4 | "ecmaFeatures": { 5 | "jsx": true, 6 | "classes": true 7 | }, 8 | "env": { 9 | "browser": true, 10 | "node": true, 11 | "mocha": true 12 | }, 13 | "rules": { 14 | "react/no-multi-comp": 0, 15 | "react/prefer-stateless-function": 0, 16 | "react/jsx-no-bind": 0, 17 | "import/default": 0, 18 | "import/no-duplicates": 0, 19 | "import/named": 0, 20 | "import/namespace": 0, 21 | "import/no-unresolved": 0, 22 | "import/no-named-as-default": 2, 23 | "comma-dangle": 0, // not sure why airbnb turned this on. gross! 24 | "indent": [2, 2, {"SwitchCase": 1}], 25 | "no-console": 0, 26 | "no-alert": 0, 27 | "no-underscore-dangle": 0, 28 | "global-require": 0, 29 | "max-statements": [2, 30], 30 | "complexity": [2, 6] 31 | }, 32 | "plugins": [ 33 | "react", "import" 34 | ], 35 | "settings": { 36 | "import/resolve": { 37 | "moduleDirectory": ["node_modules", "src"] 38 | } 39 | }, 40 | "globals": { 41 | "__DEVELOPMENT__": true, 42 | "__CLIENT__": true, 43 | "__SERVER__": true, 44 | "__DISABLE_SSR__": true, 45 | "__DEVTOOLS__": true, 46 | "socket": true, 47 | "webpackIsomorphicTools": true 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | node_modules/ 3 | coverage/ 4 | static/dist/ 5 | build/ 6 | *.log 7 | *.map 8 | *.DS_Store 9 | npm-debug.log 10 | .tern-port 11 | dist/ 12 | *.iml 13 | webpack-assets.json 14 | webpack-stats.json 15 | npm-debug.log 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "6.1" 4 | script: npm run cover 5 | before_install: if [[ `npm -v` != 3* ]]; then npm i -g npm@3; fi 6 | addons: 7 | code_climate: 8 | repo_token: 012d70ff60e9470e96f6427a97a48c53f467bbf7b383bcaab293317d039b13de 9 | after_script: 10 | - codeclimate-test-reporter < coverage/lcov.info 11 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 2 | Version 2, December 2004 3 | 4 | Copyright (C) 2004 Sam Hocevar 5 | 6 | Everyone is permitted to copy and distribute verbatim or modified 7 | copies of this license document, and changing it is allowed as long 8 | as the name is changed. 9 | 10 | DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE 11 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 12 | 13 | 0. You just DO WHAT THE FUCK YOU WANT TO. 14 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | npm run start-prod 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Redux Auth Demo 2 | [![Build Status](https://travis-ci.org/lynndylanhurley/redux-auth-demo.svg?branch=master)](https://travis-ci.org/lynndylanhurley/redux-auth-demo) 3 | [![Code Climate](https://codeclimate.com/repos/578c07c7d63f0461d900609d/badges/d25442d79c2bc9985b6a/gpa.svg)](https://codeclimate.com/repos/578c07c7d63f0461d900609d/feed) 4 | [![Test Coverage](https://codeclimate.com/repos/578c07c7d63f0461d900609d/badges/d25442d79c2bc9985b6a/coverage.svg)](https://codeclimate.com/repos/578c07c7d63f0461d900609d/coverage) 5 | [![Issue Count](https://codeclimate.com/repos/578c07c7d63f0461d900609d/badges/d25442d79c2bc9985b6a/issue_count.svg)](https://codeclimate.com/repos/578c07c7d63f0461d900609d/feed) 6 | 7 | ## [See It In Action][demo] 8 | 9 | View a live demo of this project [here][demo]. 10 | 11 | Deploy this project to your own [Heroku][heroku] instance by clicking this button: 12 | 13 | [![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/lynndylanhurley/redux-auth-demo) 14 | 15 | ## What is this? 16 | 17 | This is a fully configured [redux-auth][redux-auth] application. It includes the following: 18 | 19 | * [React.js][react] 20 | * [Babel 6][babel] 21 | * [Redux][redux] 22 | * Both [Material UI][material-ui] and [React Bootstrap][react-bootstrap] themes. (Just remove the one(s) that you don't want.) 23 | * [Heroku][heroku] deployment. Click the "deploy" button at the top of this page to create your own instance. 24 | * [Webpack][webpack] with live code reloading. 25 | * [mocha][mocha], [jsdom][jsdom], and [enzyme][enzyme] for testing. 26 | * Isomorphic / universal rendering. (The server renders the initial content.) 27 | 28 | ## Installation 29 | 30 | ~~~sh 31 | git clone git@github.com:lynndylanhurley/redux-auth-demo.git 32 | cd redux-auth-demo 33 | npm install 34 | ~~~ 35 | 36 | ## Usage 37 | 38 | ### Run The Dev Server 39 | 40 | ~~~sh 41 | npm run dev 42 | ~~~ 43 | 44 | ### Run The Tests 45 | ~~~sh 46 | npm run test 47 | ~~~ 48 | 49 | ### Deploy to Heroku 50 | 51 | Assuming that your production git remote is named `heroku`: 52 | 53 | ~~~sh 54 | git push heroku master 55 | ~~~ 56 | 57 | ### Project map 58 | 59 | The following files are used in the configuration of this app: 60 | 61 | ~~~ 62 | config/ 63 | default.json 64 | production.json 65 | 66 | src/ 67 | components/ 68 | containers/ 69 | app.js 70 | client.js 71 | server.js 72 | 73 | test/ 74 | components/ 75 | redux/ 76 | helper.js 77 | ~~~ 78 | 79 | ##### `config/default.json` 80 | Set the url for your development API. 81 | 82 | ##### `config/production.json` 83 | Set the url for your production API. 84 | 85 | ##### `src/components/` 86 | Re-usable React components. 87 | 88 | ##### `src/containers/` 89 | Page components that correlate to react-router Routes. 90 | 91 | ##### `src/app.js` 92 | Configure your redux store, routes, and redux-auth settings. 93 | 94 | ##### `src/client.js` 95 | Render your app client-side. 96 | 97 | ##### `src/server.js` 98 | Initialize your app server-side. 99 | 100 | ##### `test/components/` 101 | Tests for the corresponding React components. 102 | 103 | ##### `test/redux/` 104 | Tests for redux actions + reducers. 105 | 106 | ##### `test/helper.js` 107 | Shared methods to be used by tests. 108 | 109 | ## License 110 | 111 | WTFPL © Lynn Dylan Hurley 112 | 113 | [demo]: http://redux-auth.herokuapp.com 114 | [heroku]: http://heroku.com/ 115 | [redux-auth]: https://github.com/lynndylanhurley/redux-auth 116 | [react]: https://facebook.github.io/react 117 | [material-ui]: http://www.material-ui.com 118 | [react-bootstrap]: https://react-bootstrap.github.io 119 | [webpack]: https://webpack.github.io 120 | [redux]: https://github.com/rackt/redux 121 | [babel]: https://babeljs.io 122 | [mocha]: https://mochajs.org/ 123 | [enzyme]: https://github.com/airbnb/enzyme 124 | [jsdom]: https://github.com/tmpvar/jsdom 125 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Redux Auth Kitchen Sink", 3 | "description": "Full authentication demo with all themes, heroku deployment, isomorphic rendering + routing, & HMR dev server.", 4 | "repository": "https://github.com/lynndylanhurley/redux-auth-demo", 5 | "logo": "https://github.com/lynndylanhurley/redux-auth/raw/master/docs/images/redux-auth-logo.gif", 6 | "keywords": ["node", "redux", "auth", "react", "token", "authentication", "user"], 7 | "env": { 8 | "NODE_ENV": { 9 | "description": "Disable debug + hot reload for production.", 10 | "value": "production" 11 | }, 12 | "NODE_PATH": { 13 | "description": "The root for absolute import statements.", 14 | "value": "./src" 15 | }, 16 | "NPM_CONFIG_PRODUCTION": { 17 | "description": "Allow webpack to run the build on deploy.", 18 | "value": "false" 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /bin/server.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require('../server.babel'); // babel registration (runtime transpilation for node) 3 | const WebpackIsomorphicTools = require('webpack-isomorphic-tools'); 4 | const path = require('path'); 5 | const rootDir = path.resolve(__dirname, '..'); 6 | /** 7 | * Define isomorphic constants. 8 | */ 9 | global.__CLIENT__ = false; 10 | global.__SERVER__ = true; 11 | global.__DISABLE_SSR__ = false; // <----- DISABLES SERVER SIDE RENDERING FOR ERROR DEBUGGING 12 | global.__DEVELOPMENT__ = process.env.NODE_ENV !== 'production'; 13 | 14 | if (__DEVELOPMENT__) { 15 | if (!require('piping')({ 16 | hook: true, 17 | ignore: /(\/\.|~$|\.json|\.scss$)/i 18 | })) { 19 | return; 20 | } 21 | } 22 | 23 | global.webpackIsomorphicTools = new WebpackIsomorphicTools( 24 | require('../webpack/webpack-isomorphic-tools') 25 | ) 26 | .development(__DEVELOPMENT__) 27 | .server(rootDir, () => { 28 | require('../src/server'); 29 | }); 30 | -------------------------------------------------------------------------------- /config/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "apiUrl": "http://devise-token-auth.dev" 3 | } 4 | -------------------------------------------------------------------------------- /config/production.json: -------------------------------------------------------------------------------- 1 | { 2 | "apiUrl": "https://devise-token-auth-demo.herokuapp.com" 3 | } 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-auth-demo", 3 | "description": "Token authentication for redux with isomorphic support.", 4 | "version": "0.0.3", 5 | "license": "BSD-3-Clause", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/lynndylanhurley/redux-auth-demo.git" 9 | }, 10 | "homepage": "https://github.com/lynndylanhurley/redux-auth-demo", 11 | "keywords": [ 12 | "react", 13 | "isomorphic", 14 | "universal", 15 | "starter", 16 | "boilerplate", 17 | "template", 18 | "webpack", 19 | "hapi", 20 | "transmit" 21 | ], 22 | "main": "babel.server.js", 23 | "scripts": { 24 | "start": "concurrent --kill-others \"npm run start-prod\"", 25 | "start-prod": "better-npm-run start-prod", 26 | "build": "better-npm-run build", 27 | "postinstall": "npm run build", 28 | "lint": "eslint -c .eslintrc src api", 29 | "start-dev": "better-npm-run start-dev", 30 | "watch-client": "better-npm-run watch-client", 31 | "dev": "concurrent --kill-others \"npm run watch-client\" \"npm run start-dev\"", 32 | "test": "node_modules/.bin/_mocha --require test/setup.js --compilers js:babel-register test/**/*-test.js", 33 | "cover": "node_modules/.bin/istanbul cover node_modules/.bin/_mocha -- --require test/setup.js --compilers js:babel-register test/**/*-test.js" 34 | }, 35 | "betterScripts": { 36 | "start-prod": { 37 | "command": "node ./bin/server.js", 38 | "env": { 39 | "NODE_PATH": "./src", 40 | "NODE_ENV": "production" 41 | } 42 | }, 43 | "start-dev": { 44 | "command": "node ./bin/server.js", 45 | "env": { 46 | "NODE_PATH": "./src", 47 | "NODE_ENV": "development", 48 | "PORT": 3000 49 | } 50 | }, 51 | "watch-client": { 52 | "command": "node webpack/webpack-dev-server.js", 53 | "env": { 54 | "UV_THREADPOOL_SIZE": 100, 55 | "NODE_PATH": "./src", 56 | "PORT": 3000 57 | } 58 | }, 59 | "build": { 60 | "command": "webpack --verbose --colors --display-error-details --config webpack/prod.config.js", 61 | "env": { 62 | "NODE_ENV": "production" 63 | } 64 | } 65 | }, 66 | "dependencies": { 67 | "babel-core": "6.10.4", 68 | "babel-loader": "6.2.4", 69 | "babel-plugin-add-module-exports": "0.2.1", 70 | "babel-plugin-transform-decorators-legacy": "1.3.4", 71 | "babel-plugin-transform-react-display-name": "6.8.0", 72 | "babel-plugin-transform-runtime": "6.9.0", 73 | "babel-plugin-typecheck": "3.9.0", 74 | "babel-polyfill": "6.9.1", 75 | "babel-preset-es2015": "6.9.0", 76 | "babel-preset-react": "6.11.1", 77 | "babel-preset-stage-0": "6.5.0", 78 | "babel-register": "6.9.0", 79 | "babel-runtime": "6.9.2", 80 | "bootstrap": "3.3.6", 81 | "bootstrap-sass": "3.3.6", 82 | "classnames": "2.2.5", 83 | "concurrently": "0.1.1", 84 | "config": "1.17.1", 85 | "cookie": "0.2.2", 86 | "css-loader": "0.23.1", 87 | "exports-loader": "0.6.2", 88 | "express": "4.14.0", 89 | "extend": "3.0.0", 90 | "extract-text-webpack-plugin": "0.9.1", 91 | "file-loader": "0.8.5", 92 | "font-awesome": "4.6.3", 93 | "font-awesome-webpack": "0.0.4", 94 | "h2o2": "4.0.1", 95 | "hapi": "9.3.1", 96 | "highlight.js": "8.8.0", 97 | "immutable": "3.7.5", 98 | "imports-loader": "0.6.4", 99 | "inert": "3.0.1", 100 | "isomorphic-fetch": "2.1.1", 101 | "jquery": "2.1.4", 102 | "jquery-deparam": "0.4.2", 103 | "json-loader": "0.5.4", 104 | "less": "2.5.3", 105 | "less-loader": "2.2.1", 106 | "material-ui": "0.15.2", 107 | "node-fetch": "1.3.3", 108 | "node-sass": "3.8.0", 109 | "piping": "0.3.0", 110 | "pretty-error": "2.0.0", 111 | "query-string": "2.4.2", 112 | "react": "15.2.1", 113 | "react-bootstrap": "0.29.5", 114 | "react-dom": "15.2.1", 115 | "react-helmet": "3.1.0", 116 | "react-hot-loader": "1.3.0", 117 | "react-inline-css": "2.0.0", 118 | "react-loader": "2.4.0", 119 | "react-redux": "4.4.5", 120 | "react-router": "2.5.2", 121 | "react-router-bootstrap": "0.23.0", 122 | "react-router-redux": "4.0.5", 123 | "react-select": "1.0.0-beta13", 124 | "react-tap-event-plugin": "1.0.0", 125 | "react-transmit": "3.0.8", 126 | "redux": "3.2.1", 127 | "redux-auth": "0.0.5-beta2", 128 | "redux-connect": "2.4.0", 129 | "redux-devtools": "2.1.5", 130 | "redux-immutablejs": "0.0.6", 131 | "redux-thunk": "2.1.0", 132 | "sass-loader": "4.0.0", 133 | "serialize-javascript": "1.1.2", 134 | "serve-favicon": "2.3.0", 135 | "strip-loader": "0.1.2", 136 | "style-loader": "0.12.4", 137 | "url-loader": "0.5.7", 138 | "webpack": "1.13.1", 139 | "webpack-dev-server": "1.14.1", 140 | "webpack-isomorphic-tools": "2.3.2", 141 | "whatwg-fetch": "0.9.0" 142 | }, 143 | "devDependencies": { 144 | "autoprefixer-loader": "3.2.0", 145 | "babel-eslint": "6.1.2", 146 | "babel-plugin-react-transform": "2.0.2", 147 | "better-npm-run": "0.0.9", 148 | "bootstrap-sass-loader": "1.0.10", 149 | "chai": "3.5.0", 150 | "clean-webpack-plugin": "0.1.9", 151 | "codeclimate-test-reporter": "0.3.3", 152 | "compression": "^1.6.2", 153 | "enzyme": "2.4.1", 154 | "eslint": "2.9.0", 155 | "eslint-config-airbnb": "9.0.1", 156 | "eslint-loader": "1.4.1", 157 | "eslint-plugin-import": "1.10.3", 158 | "eslint-plugin-jsx-a11y": "1.2.0", 159 | "eslint-plugin-react": "5.2.2", 160 | "fetch-mock": "5.0.0", 161 | "istanbul": "1.0.0-alpha.2", 162 | "jsdom": "9.4.1", 163 | "mocha": "2.5.3", 164 | "mockery": "1.7.0", 165 | "react-addons-test-utils": "15.2.1", 166 | "react-transform-catch-errors": "1.0.2", 167 | "react-transform-hmr": "1.0.4", 168 | "redbox-react": "1.2.10", 169 | "webpack-dev-middleware": "^1.6.1", 170 | "webpack-hot-middleware": "2.12.1" 171 | }, 172 | "engines": { 173 | "node": ">=0.10.32" 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /server.babel.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Define isomorphic constants. 3 | */ 4 | global.__CLIENT__ = false; 5 | global.__SERVER__ = true; 6 | 7 | // enable runtime transpilation to use ES6/7 in node 8 | 9 | var fs = require('fs'); 10 | 11 | var babelrc = fs.readFileSync('./.babelrc'); 12 | var config; 13 | 14 | try { 15 | config = JSON.parse(babelrc); 16 | } catch (err) { 17 | console.error('==> ERROR: Error parsing your .babelrc.'); 18 | console.error(err); 19 | } 20 | 21 | require('babel-register')(config); 22 | -------------------------------------------------------------------------------- /src/actions/demo-ui.js: -------------------------------------------------------------------------------- 1 | export const UPDATE_DEMO_THEME = 'UPDATE_DEMO_THEME'; 2 | export const UPDATE_DEMO_ENDPOINT = 'UPDATE_DEMO_ENDPOINT'; 3 | 4 | export function updateDemoTheme(theme) { 5 | return { type: UPDATE_DEMO_THEME, theme }; 6 | } 7 | 8 | export function updateDemoEndpoint(endpoint) { 9 | return { type: UPDATE_DEMO_ENDPOINT, endpoint }; 10 | } 11 | -------------------------------------------------------------------------------- /src/actions/request-test-buttons.js: -------------------------------------------------------------------------------- 1 | import { fetch } from 'redux-auth'; 2 | 3 | export const REQUEST_TEST_START = 'REQUEST_TEST_START'; 4 | export const REQUEST_TEST_COMPLETE = 'REQUEST_TEST_COMPLETE'; 5 | export const REQUEST_TEST_ERROR = 'REQUEST_TEST_ERROR'; 6 | export const DISMISS_REQUEST_TEST_SUCCESS_MODAL = 'DISMISS_REQUEST_TEST_SUCCESS_MODAL'; 7 | export const DISMISS_REQUEST_TEST_ERROR_MODAL = 'DISMISS_REQUEST_TEST_ERROR_MODAL'; 8 | 9 | export function dismissRequestTestSuccessModal() { 10 | return { type: DISMISS_REQUEST_TEST_SUCCESS_MODAL }; 11 | } 12 | export function dismissRequestTestErrorModal() { 13 | return { type: DISMISS_REQUEST_TEST_ERROR_MODAL }; 14 | } 15 | export function requestTestStart(key) { 16 | return { type: REQUEST_TEST_START, key }; 17 | } 18 | export function requestTestComplete(key) { 19 | return { type: REQUEST_TEST_COMPLETE, key }; 20 | } 21 | export function requestTestError(key) { 22 | return { type: REQUEST_TEST_ERROR, key }; 23 | } 24 | export function requestTest(url, key) { 25 | return dispatch => { 26 | dispatch(requestTestStart(key)); 27 | 28 | return fetch(url, { 29 | credentials: 'include' 30 | }) 31 | .then(resp => { 32 | console.log('got resp'); 33 | if (resp && resp.statusText === 'OK') { 34 | dispatch(requestTestComplete(key)); 35 | } else { 36 | dispatch(requestTestError(key)); 37 | } 38 | 39 | console.log('retrieving json'); 40 | 41 | return resp.json(); 42 | }) 43 | .then(json => { 44 | console.log('@-->resp json', json); 45 | return json; 46 | }) 47 | .catch(resp => { 48 | console.log('fail', resp); 49 | dispatch(requestTestError(key)); 50 | }); 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { Provider } from 'react-redux'; 3 | import { 4 | Router, 5 | Route, 6 | IndexRoute, 7 | createMemoryHistory, 8 | browserHistory 9 | } from 'react-router'; 10 | import { configure, authStateReducer } from 'redux-auth'; 11 | import { createStore, applyMiddleware, combineReducers, compose } from 'redux'; 12 | import { routerReducer, syncHistoryWithStore } from 'react-router-redux'; 13 | import demoButtons from './reducers/request-test-buttons'; 14 | import demoUi from './reducers/demo-ui'; 15 | import thunk from 'redux-thunk'; 16 | import Main from './containers/Main'; 17 | import Account from './containers/Account'; 18 | import SignIn from './containers/SignIn'; 19 | import Container from './components/Container'; 20 | import GlobalComponents from './components/GlobalComponents'; 21 | 22 | class App extends React.Component { 23 | static propTypes = { 24 | children: PropTypes.node, 25 | }; 26 | 27 | render() { 28 | return ( 29 | 30 | 31 | {this.props.children} 32 | 33 | ); 34 | } 35 | } 36 | 37 | function requireAuth(store, nextState, replace, next) { 38 | if (!store.getState().auth.getIn(['user', 'isSignedIn'])) { 39 | replace('/login'); 40 | } 41 | next(); 42 | } 43 | 44 | export function initialize({ apiUrl, cookies, isServer, currentLocation, userAgent } = {}) { 45 | const reducer = combineReducers({ 46 | auth: authStateReducer, 47 | routing: routerReducer, 48 | demoButtons, 49 | demoUi 50 | }); 51 | 52 | let history = (isServer) 53 | ? createMemoryHistory(currentLocation) 54 | : browserHistory; 55 | 56 | // create the redux store 57 | const store = createStore( 58 | reducer, 59 | compose( 60 | applyMiddleware(thunk) 61 | ) 62 | ); 63 | 64 | history = syncHistoryWithStore(history, store); 65 | 66 | // define app routes 67 | const routes = ( 68 | 69 | 70 | 71 | 72 | 77 | 78 | 79 | ); 80 | 81 | /** 82 | * The React Router 1.0 routes for both the server and the client. 83 | */ 84 | return store.dispatch(configure([ 85 | { 86 | default: { apiUrl } 87 | }, { 88 | evilUser: { 89 | apiUrl, 90 | signOutPath: '/mangs/sign_out', 91 | emailSignInPath: '/mangs/sign_in', 92 | emailRegistrationPath: '/mangs', 93 | accountUpdatePath: '/mangs', 94 | accountDeletePath: '/mangs', 95 | passwordResetPath: '/mangs/password', 96 | passwordUpdatePath: '/mangs/password', 97 | tokenValidationPath: '/mangs/validate_token', 98 | authProviderPaths: { 99 | github: '/mangs/github', 100 | facebook: '/mangs/facebook', 101 | google: '/mangs/google_oauth2' 102 | } 103 | } 104 | } 105 | ], { 106 | cookies, 107 | isServer, 108 | currentLocation 109 | })).then(({ redirectPath, blank } = {}) => { 110 | // hack for material-ui server-side rendering. 111 | // see https://github.com/callemall/material-ui/pull/2007 112 | if (userAgent) { 113 | global.navigator = { userAgent }; 114 | } 115 | 116 | return ({ 117 | blank, 118 | store, 119 | redirectPath, 120 | routes, 121 | history, 122 | provider: ( 123 | 124 | ) 125 | }); 126 | }); 127 | } 128 | -------------------------------------------------------------------------------- /src/client.js: -------------------------------------------------------------------------------- 1 | /** 2 | * THIS IS THE ENTRY POINT FOR THE CLIENT, JUST LIKE server.js IS THE ENTRY POINT FOR THE SERVER. 3 | */ 4 | import 'babel-polyfill'; 5 | import React from 'react'; 6 | import ReactDOM from 'react-dom'; 7 | import { initialize } from './app'; 8 | 9 | const dest = document.getElementById('content'); 10 | 11 | initialize({ apiUrl: window.__API_URL__ }).then(({ provider }) => { 12 | ReactDOM.render( 13 | provider, 14 | dest 15 | ); 16 | }); 17 | 18 | if (process.env.NODE_ENV !== 'production') { 19 | window.React = React; // enable debugger 20 | 21 | if ( 22 | !dest || !dest.firstChild || !dest.firstChild.attributes || 23 | !dest.firstChild.attributes['data-react-checksum'] 24 | ) { 25 | console.error( 26 | 'Server-side React render was discarded. Make sure that your initial ' + 27 | 'render does not contain any client-side code.' 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/components/CodeSnippet/CodeSnippet.scss: -------------------------------------------------------------------------------- 1 | .codeSnippet { 2 | margin-top: 10px; 3 | } 4 | -------------------------------------------------------------------------------- /src/components/CodeSnippet/index.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import $ from 'jquery'; 3 | import hljs from 'highlight.js'; 4 | 5 | class CodeSnippet extends React.Component { 6 | static propTypes = { 7 | language: PropTypes.string, 8 | children: PropTypes.node.isRequired 9 | } 10 | 11 | static defaultProps = { 12 | language: 'javascript' 13 | } 14 | 15 | state = { 16 | code: 17 | } 18 | 19 | 20 | componentDidMount() { 21 | const $target = $(this.refs.target); 22 | this.highlight($target, this.props.children); 23 | } 24 | 25 | componentDidUpdate() { 26 | const $target = $(this.refs.target); 27 | $target.html(''); 28 | this.highlight($target, this.props.children); 29 | } 30 | 31 | highlight($target, rawCode) { 32 | const code = rawCode 33 | .replace(//g, '>') 35 | .replace(/ {2,}/g, ' ') 36 | .replace(/±/g, ' '); 37 | const el = $(`${code}`)[0]; 38 | hljs.highlightBlock(el); 39 | $target.append(el); 40 | } 41 | 42 | render() { 43 | const styles = require('./CodeSnippet.scss'); 44 | return ( 45 |
46 | 47 |
48 |       
49 | ); 50 | } 51 | } 52 | 53 | export default CodeSnippet; 54 | -------------------------------------------------------------------------------- /src/components/Container.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { Grid, Navbar, NavItem, Nav } from 'react-bootstrap'; 3 | import { LinkContainer } from 'react-router-bootstrap'; 4 | 5 | class Container extends React.Component { 6 | static propTypes = { 7 | children: PropTypes.node 8 | } 9 | 10 | render() { 11 | return ( 12 |
13 | 14 | 15 | Redux Auth 16 | 17 | 25 | 26 | 27 | 28 | {this.props.children} 29 | 30 | 31 | 32 | Fork me on GitHub 38 | 39 |
40 | ); 41 | } 42 | } 43 | 44 | export default Container; 45 | -------------------------------------------------------------------------------- /src/components/ExampleWell.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { Panel } from 'react-bootstrap'; 3 | 4 | class ExampleWell extends React.Component { 5 | static propTypes = { 6 | children: PropTypes.node.isRequired 7 | } 8 | 9 | render() { 10 | return ( 11 |
12 | 13 | 14 | {this.props.children} 15 | 16 |
17 | ); 18 | } 19 | } 20 | 21 | export default ExampleWell; 22 | 23 | -------------------------------------------------------------------------------- /src/components/GlobalComponents.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import RequestTestSuccessModal from './RequestTestSuccessModal'; 3 | import RequestTestErrorModal from './RequestTestErrorModal'; 4 | import * as BSTheme from 'redux-auth/bootstrap-theme'; 5 | import * as DefaultTheme from 'redux-auth'; 6 | import * as MUITheme from 'redux-auth/material-ui-theme'; 7 | import { connect } from 'react-redux'; 8 | 9 | class GlobalComponents extends React.Component { 10 | static propTypes = { 11 | theme: PropTypes.string, 12 | }; 13 | 14 | render() { 15 | let Theme; 16 | 17 | switch (this.props.theme) { 18 | case 'default': 19 | Theme = DefaultTheme; 20 | break; 21 | case 'bootstrap': 22 | Theme = BSTheme; 23 | break; 24 | default: 25 | Theme = MUITheme; 26 | break; 27 | } 28 | 29 | return ( 30 |
31 | 32 | 33 | 34 |
35 | ); 36 | } 37 | } 38 | 39 | export default connect(({ demoUi }) => ({ 40 | theme: demoUi.get('theme'), 41 | }))(GlobalComponents); 42 | -------------------------------------------------------------------------------- /src/components/IndexPanel.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { Panel, Col } from 'react-bootstrap'; 3 | 4 | class IndexPanel extends React.Component { 5 | static propTypes = { 6 | bsStyle: PropTypes.string, 7 | header: PropTypes.string, 8 | children: PropTypes.node 9 | } 10 | 11 | static defaultProps = { 12 | bsStyle: 'info', 13 | children: 14 | } 15 | 16 | render() { 17 | return ( 18 | 19 | 20 | 21 | ); 22 | } 23 | } 24 | 25 | export default IndexPanel; 26 | -------------------------------------------------------------------------------- /src/components/RequestTestButton.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { Glyphicon } from 'react-bootstrap'; 3 | import { ButtonLoader } from 'redux-auth/bootstrap-theme'; 4 | import { connect } from 'react-redux'; 5 | import { requestTest } from '../actions/request-test-buttons'; 6 | 7 | class RequestTestButton extends React.Component { 8 | static propTypes = { 9 | path: PropTypes.string.isRequired, 10 | currentEndpointKey: PropTypes.string, 11 | endpointKey: PropTypes.string.isRequired, 12 | dispatch: PropTypes.func, 13 | demoButtons: PropTypes.any, 14 | signedIn: PropTypes.bool, 15 | }; 16 | 17 | static defaultProps = { 18 | endpointKey: 'default' 19 | }; 20 | 21 | handleClick() { 22 | const url = `${window.__API_URL__}${this.props.path}`; 23 | this.props.dispatch(requestTest(url, this.props.path)); 24 | } 25 | 26 | render() { 27 | let text = 'Will Fail'; 28 | let bsStyle = 'danger'; 29 | let glyph = ; 30 | const loading = this.props.demoButtons.getIn(['buttons', this.props.path, 'loading']); 31 | 32 | if ( 33 | this.props.signedIn && ( 34 | this.props.currentEndpointKey === this.props.endpointKey || 35 | this.props.endpointKey === 'any' 36 | ) 37 | ) { 38 | text = 'Should Succeed'; 39 | bsStyle = 'success'; 40 | glyph = ; 41 | } 42 | 43 | return ( 44 | 52 | {text} 53 | 54 | ); 55 | } 56 | } 57 | 58 | export default connect(({ auth, demoButtons }) => ({ 59 | signedIn: auth.getIn(['user', 'isSignedIn']), 60 | currentEndpointKey: auth.getIn(['configure', 'currentEndpointKey']), 61 | demoButtons 62 | }))(RequestTestButton); 63 | -------------------------------------------------------------------------------- /src/components/RequestTestErrorModal.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { Modal, Button } from 'react-bootstrap'; 3 | import { connect } from 'react-redux'; 4 | import { dismissRequestTestErrorModal } from '../actions/request-test-buttons'; 5 | 6 | class RequestTestErrorModal extends React.Component { 7 | static propTypes = { 8 | show: PropTypes.bool, 9 | url: PropTypes.string, 10 | dispatch: PropTypes.func, 11 | } 12 | 13 | static defaultProps = { 14 | show: false 15 | } 16 | 17 | close() { 18 | this.props.dispatch(dismissRequestTestErrorModal()); 19 | } 20 | 21 | render() { 22 | return ( 23 | 27 | 28 | Ajax Request Error 29 | 30 | 31 | 32 |

33 | Request to {this.props.url} failed. 34 |

35 |
36 | 37 | 38 | 41 | 42 |
43 | ); 44 | } 45 | } 46 | 47 | export default connect(({ demoButtons }) => ({ 48 | show: demoButtons.get('showErrorModal'), 49 | url: demoButtons.get('lastRequestUrl') 50 | }))(RequestTestErrorModal); 51 | -------------------------------------------------------------------------------- /src/components/RequestTestSuccessModal.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import { Modal, Button } from 'react-bootstrap'; 3 | import { connect } from 'react-redux'; 4 | import { dismissRequestTestSuccessModal } from '../actions/request-test-buttons'; 5 | 6 | class RequestTestSuccessModal extends React.Component { 7 | static propTypes = { 8 | show: PropTypes.bool, 9 | url: PropTypes.string, 10 | dispatch: PropTypes.func, 11 | }; 12 | 13 | static defaultProps = { 14 | show: false 15 | }; 16 | 17 | close() { 18 | this.props.dispatch(dismissRequestTestSuccessModal()); 19 | } 20 | 21 | render() { 22 | return ( 23 | 27 | 28 | Ajax Request Success 29 | 30 | 31 | 32 |

33 | Request to {this.props.url} was successful. 34 |

35 |
36 | 37 | 38 | 41 | 42 |
43 | ); 44 | } 45 | } 46 | 47 | export default connect(({ demoButtons }) => ({ 48 | show: demoButtons.get('showSuccessModal'), 49 | url: demoButtons.get('lastRequestUrl') 50 | }))(RequestTestSuccessModal); 51 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | require('babel-polyfill'); 2 | const config = require('config'); 3 | 4 | const environment = { 5 | development: { 6 | isProduction: false 7 | }, 8 | production: { 9 | isProduction: true 10 | } 11 | }[process.env.NODE_ENV || 'development']; 12 | 13 | module.exports = Object.assign({ 14 | host: process.env.HOST || 'localhost', 15 | port: process.env.PORT, 16 | apiUrl: config.get('apiUrl'), 17 | apiHost: process.env.APIHOST || 'localhost', 18 | apiPort: process.env.APIPORT, 19 | app: { 20 | title: 'Redux Auth', 21 | description: 'Simple, secure authentication for React + Redux.', 22 | head: { 23 | titleTemplate: 'Redux Auth: %s', 24 | meta: [ 25 | { name: 'description', content: 'Simple, secure authentication for React + Redux.' }, 26 | { charset: 'utf-8' }, 27 | { property: 'og:site_name', content: 'Redux Auth' }, 28 | { property: 'og:image', content: 'https://react-redux.herokuapp.com/logo.jpg' }, 29 | { property: 'og:locale', content: 'en_US' }, 30 | { property: 'og:title', content: 'Redux Auth' }, 31 | { property: 'og:description', content: 'Simple, secure authentication for React + Redux.' }, 32 | { property: 'og:card', content: 'summary' }, 33 | { property: 'og:site', content: '@lynndylanhurley' }, 34 | { property: 'og:creator', content: '@lynndylanhurley' }, 35 | { property: 'og:image:width', content: '200' }, 36 | { property: 'og:image:height', content: '200' } 37 | ] 38 | } 39 | }, 40 | 41 | }, environment); 42 | -------------------------------------------------------------------------------- /src/containers/Account.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { PageHeader } from 'react-bootstrap'; 3 | import { connect } from 'react-redux'; 4 | import { SignOutButton } from 'redux-auth/bootstrap-theme'; 5 | import { browserHistory } from 'react-router'; 6 | 7 | class Account extends React.Component { 8 | render() { 9 | return ( 10 |
11 | Account page 12 |

This page should only visible to authenticated users.

13 | browserHistory.push('/')} /> 14 |
15 | ); 16 | } 17 | } 18 | 19 | export default connect(({ auth }) => ({ auth }))(Account); 20 | -------------------------------------------------------------------------------- /src/containers/Main.js: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import IndexPanel from '../components/IndexPanel'; 3 | import CodeSnippet from '../components/CodeSnippet'; 4 | import ExampleWell from '../components/ExampleWell'; 5 | import RequestTestButton from '../components/RequestTestButton'; 6 | import { updateDemoTheme, updateDemoEndpoint } from '../actions/demo-ui'; 7 | import { PageHeader, OverlayTrigger, Tooltip, Row, ButtonGroup, Table } from 'react-bootstrap'; 8 | import { connect } from 'react-redux'; 9 | import { browserHistory } from 'react-router'; 10 | import * as BSTheme from 'redux-auth/bootstrap-theme'; 11 | import * as DefaultTheme from 'redux-auth/default-theme'; 12 | import * as MUITheme from 'redux-auth/material-ui-theme'; 13 | import Select from 'react-select'; 14 | 15 | if (!global.__SERVER__ && !global.__TEST__) { 16 | require('../styles/main.scss'); 17 | } 18 | 19 | class Main extends React.Component { 20 | static propTypes = { 21 | dispatch: PropTypes.func, 22 | pageEndpoint: PropTypes.string, 23 | theme: PropTypes.string, 24 | currentUserUid: PropTypes.string, 25 | currentUserProvider: PropTypes.string, 26 | currentUserEndpoint: PropTypes.string, 27 | }; 28 | 29 | updateTheme({ value }) { 30 | this.props.dispatch(updateDemoTheme(value)); 31 | } 32 | 33 | updateEndpoint({ value }) { 34 | this.props.dispatch(updateDemoEndpoint(value)); 35 | } 36 | 37 | render() { 38 | let Theme; 39 | let themePath; 40 | const endpointAttr = (this.props.pageEndpoint === 'default') 41 | ? '' 42 | : 'endpoint="evilUser"'; 43 | 44 | switch (this.props.theme) { 45 | case 'bootstrap': 46 | Theme = BSTheme; 47 | themePath = '/bootstrap-theme'; 48 | break; 49 | case 'default-theme': 50 | Theme = DefaultTheme; 51 | themePath = '/default-theme'; 52 | break; 53 | default: 54 | Theme = MUITheme; 55 | themePath = '/material-ui-theme'; 56 | break; 57 | } 58 | 59 | const deployTooltip = ( 60 | 61 | Create a new instance of this demo on your own Heroku server. 62 | 63 | ); 64 | 65 | return ( 66 |
67 | 68 | Redux Auth 69 | 70 | 71 | 75 | deploy to heroku 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 |

{this.props.currentUserUid}

84 | 85 | 86 |

{this.props.currentUserProvider}

87 | 88 | 89 | 110 | 111 | 112 | 113 | 114 | 117 | 118 | 119 | 120 | 121 | 122 | 128 | 129 | 130 | 131 | 137 | 138 | 139 | 140 | 146 | 147 | 148 |
115 | ajax test 116 |
Default user: 123 | 127 |
Alternate user class: 132 | 136 |
Group that includes both user classes: 141 | 145 |
149 |
150 | 151 | 152 | 153 | browserHistory.push('/account')} 155 | endpoint={this.props.pageEndpoint} 156 | /> 157 | 158 | 159 | 160 | {` 161 | // import 162 | import {EmailSignInForm} from "redux-auth${themePath}"; 163 | 164 | // render 165 | 166 | `} 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | {` 177 | // import 178 | import {SignOutButton} from "redux-auth${themePath}"; 179 | 180 | // render 181 | 182 | `} 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | {` 193 | // import 194 | import {EmailSignUpForm} from "redux-auth${themePath}"; 195 | 196 | // render 197 | 198 | `} 199 | 200 | 201 | 202 | 203 | 204 | 205 | browserHistory.push('/account')} 208 | endpoint={this.props.pageEndpoint} 209 | > 210 | Github 211 | 212 | browserHistory.push('/account')} 216 | secondary 217 | bsStyle="primary" 218 | > 219 | Facebook 220 | 221 | browserHistory.push('/account')} 225 | primary 226 | bsStyle="warning" 227 | > 228 | Google 229 | 230 | 231 | 232 | 233 | 234 | {` 235 | // import 236 | import {OAuthSignInButton} from "redux-auth${themePath}"; 237 | 238 | // render 239 | 240 | `} 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | {` 251 | // import 252 | import {DestroyAccountButton} from "redux-auth${themePath}"; 253 | 254 | // render 255 | 256 | `} 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | {` 267 | // import 268 | import {RequestPasswordResetForm} from "redux-auth${themePath}"; 269 | 270 | // render 271 | 272 | `} 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | {` 283 | // import 284 | import {UpdatePasswordForm} from "redux-auth${themePath}"; 285 | 286 | // render 287 | 288 | `} 289 | 290 | 291 |
292 |
293 | ); 294 | } 295 | } 296 | 297 | export default connect(({ auth, demoUi }) => ({ 298 | currentUserUid: auth.getIn(['user', 'attributes', 'provider']) || 'none', 299 | currentUserProvider: auth.getIn(['user', 'attributes', 'uid']) || 'none', 300 | currentUserEndpoint: auth.getIn(['user', 'endpointKey']) || 'none', 301 | theme: demoUi.get('theme'), 302 | pageEndpoint: demoUi.get('endpoint'), 303 | }))(Main); 304 | -------------------------------------------------------------------------------- /src/containers/SignIn.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { PageHeader } from 'react-bootstrap'; 3 | import { connect } from 'react-redux'; 4 | import { EmailSignInForm } from 'redux-auth/bootstrap-theme'; 5 | import { browserHistory } from 'react-router'; 6 | 7 | class SignIn extends React.Component { 8 | render() { 9 | return ( 10 |
11 | Sign In First 12 |

Unauthenticated users can't access the account page.

13 | browserHistory.push('/account')} /> 14 |
15 | ); 16 | } 17 | } 18 | 19 | export default connect(({ routes }) => ({ routes }))(SignIn); 20 | -------------------------------------------------------------------------------- /src/helpers/Html.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import ReactDOM from 'react-dom/server'; 3 | import serialize from 'serialize-javascript'; 4 | import Helmet from 'react-helmet'; 5 | 6 | /** 7 | * Wrapper component containing HTML metadata and boilerplate tags. 8 | * Used in server-side code only to wrap the string output of the 9 | * rendered route component. 10 | * 11 | * The only thing this component doesn't (and can't) include is the 12 | * HTML doctype declaration, which is added to the rendered output 13 | * by the server.js file. 14 | */ 15 | export default class Html extends Component { 16 | static propTypes = { 17 | assets: PropTypes.object, 18 | component: PropTypes.node, 19 | store: PropTypes.object, 20 | apiUrl: PropTypes.string 21 | }; 22 | 23 | render() { 24 | const { apiUrl, assets, component, store } = this.props; 25 | const content = component ? ReactDOM.renderToString(component) : ''; 26 | const head = Helmet.rewind(); 27 | 28 | return ( 29 | 30 | 31 | {head.base.toComponent()} 32 | {head.title.toComponent()} 33 | {head.meta.toComponent()} 34 | {head.link.toComponent()} 35 | {head.script.toComponent()} 36 | 37 | 38 | 39 | {/* styles (will be present only in production with webpack extract text plugin) */} 40 | {Object.keys(assets.styles).map((style, key) => 41 | 49 | )} 50 | 51 | {/* (will be present only in development mode) */} 52 | {/* outputs a