diff --git a/README.md b/README.md deleted file mode 100644 index 5c5bf64..0000000 --- a/README.md +++ /dev/null @@ -1,49 +0,0 @@ -![cf](https://i.imgur.com/7v5ASc8.png) 32: Authentication and Authorization -====== - -## Submission Instructions -* fork this repository & create a new branch for your work -* write all of your code in a directory named `lab-` + `` **e.g.** `lab-susan` -* push to your repository -* submit a pull request to this repository -* submit a link to your PR in canvas -* write a question and observation on canvas - -## Learning Objectives -* students will be able to manage basic and bearer auth on the client side -* students will learn to manage cookies on the client side - -## Requirements -#### Configuration -#### backend -* clone [sluggram-backend](http://github.com/slugbyte/sluggram) - -##### frontend -* `README.md` -* `.babelrc` -* `.gitignore` -* `package.json` -* `webpack.config.js` -* `src/**` -* `src/main.js` -* `src/style` -* `src/style/main.scss` -* `src/style/lib` - * `_vars.scss` -* `src/style/base` - * `_base.scss` - * `_reset.scss` -* `src/style/layout` - * `_header.scss` - * `_footer.scss` - * `_content.scss` - -#### Feature Tasks -* create a simple front end for your cf-gram (or comparable) API -* create a landing page that enables a user to signup and signin - * redirect the user to the dashboard page on signup or signin - * store the users token in `localStorage` on signin - -#### Test -* test your redux reducers -* **optional:** test your `util` methods diff --git a/front/.babelrc b/front/.babelrc new file mode 100644 index 0000000..47c9ace --- /dev/null +++ b/front/.babelrc @@ -0,0 +1,4 @@ +{ + "presets": ["es2015", "react"], + "plugins": ["transform-object-rest-spread"] +} diff --git a/front/.eslintrc b/front/.eslintrc new file mode 100644 index 0000000..8dc6807 --- /dev/null +++ b/front/.eslintrc @@ -0,0 +1,21 @@ +{ + "rules": { + "no-console": "off", + "indent": [ "error", 2 ], + "quotes": [ "error", "single" ], + "semi": ["error", "always"], + "linebreak-style": [ "error", "unix" ] + }, + "env": { + "es6": true, + "node": true, + "mocha": true, + "jasmine": true + }, + "ecmaFeatures": { + "modules": true, + "experimentalObjectRestSpread": true, + "impliedStrict": true + }, + "extends": "eslint:recommended" +} diff --git a/front/.gitignore b/front/.gitignore new file mode 100644 index 0000000..dc150eb --- /dev/null +++ b/front/.gitignore @@ -0,0 +1,2 @@ +/node_modules +.env diff --git a/front/package.json b/front/package.json new file mode 100644 index 0000000..dc51e76 --- /dev/null +++ b/front/package.json @@ -0,0 +1,39 @@ +{ + "name": "frontend", + "version": "1.0.0", + "description": "", + "main": "webpack.config.js", + "scripts": { + "build": "webpack", + "watch": "webpack-dev-server --inline --hot" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "babel-core": "^6.26.0", + "babel-loader": "^7.1.2", + "babel-plugin-transform-object-rest-spread": "^6.26.0", + "babel-preset-es2015": "^6.24.1", + "babel-preset-react": "^6.24.1", + "clean-webpack-plugin": "^0.1.16", + "css-loader": "^0.28.7", + "dotenv": "^4.0.0", + "extract-text-webpack-plugin": "^3.0.0", + "file-loader": "^0.11.2", + "html-webpack-plugin": "^2.30.1", + "node-sass": "^4.5.3", + "react": "^15.6.1", + "react-dom": "^15.6.1", + "react-redux": "^5.0.6", + "react-router": "^4.2.0", + "react-router-dom": "^4.2.2", + "redux": "^3.7.2", + "sass-loader": "^6.0.6", + "superagent": "^3.6.0", + "uglifyjs-webpack-plugin": "^0.4.6", + "url-loader": "^0.5.9", + "webpack": "^3.5.6", + "webpack-dev-server": "^2.7.1" + } +} diff --git a/front/src/action/auth-action.js b/front/src/action/auth-action.js new file mode 100644 index 0000000..750e426 --- /dev/null +++ b/front/src/action/auth-action.js @@ -0,0 +1,41 @@ +import superagent from 'superagent'; + +export const tokenSet = token => ({ + type: 'TOKEN_SET', + payload: token +}); + +export const tokenDelete = () => ({ + type: 'TOKEN_DELETE', +}); + +export const signupRequest = user => dispatch => { + console.log('Bullshit') + return superagent.post(`${__API_URL__}/signup`) + .withCredentials() + .send(user) + .then(res => { + dispatch(tokenSet(res.text)) + try { + localStorage.auth = res.text; + } catch(err) { + console.error(err); + } + return res; + }) +} + +export const loginRequest = user => dispatch => { + return superagent.get(`${__API_URL__}/login`) + .withCredentials() + .auth(user.username, user.password) + .then(res => { + dispatch(tokenSet(res.text)); + try { + localStorage.auth = res.text; + } catch(err) { + console.error(err); + } + return res; + }) +} diff --git a/front/src/action/profile-action.js b/front/src/action/profile-action.js new file mode 100644 index 0000000..cd2d31d --- /dev/null +++ b/front/src/action/profile-action.js @@ -0,0 +1,23 @@ +import superagent from 'superagent'; + +export const profileCreate = profile => ({ + type: 'PROFILE_CREATE', + payload: profile +}); + +export const profileUpdate = profile => ({ + type: 'PROFILE_UPDATE', + payload: profile +}); + +export const profileCreateRequest = profile => (dispatch, getState) => { + let {auth} = getState(); + return superagent.post(`${__API_URL__}/profiles`) + .set('Authorization', `Bearer ${auth}`) + .field('bio', profile.bio) + .attach('avatar', profile.avatar) + .then(res => { + dispatch(profileCreate(res.body)); + return res; + }); +}; diff --git a/front/src/component/app/index.js b/front/src/component/app/index.js new file mode 100644 index 0000000..71bd61b --- /dev/null +++ b/front/src/component/app/index.js @@ -0,0 +1,36 @@ +import React from 'react'; +import {Provider} from 'react-redux'; +import {BrowserRouter, Route, Link} from 'react-router-dom'; + +import Landing from '../landing'; +import SettingsContainer from '../settings-container'; +import createAppStore from '../../lib/createAppStore.js'; + +let store = createAppStore() + +class App extends React.Component { + render() { + + return( + + + +
+ + + +
+
+
+
+ ) + } +} + +export default App; diff --git a/front/src/component/auth-form/index.js b/front/src/component/auth-form/index.js new file mode 100644 index 0000000..5b41cfa --- /dev/null +++ b/front/src/component/auth-form/index.js @@ -0,0 +1,94 @@ +import React from 'react'; +import * as util from '../../lib/util.js'; + +class AuthForm extends React.Component { + constructor(props) { + super(props); + this.state = { + username: '', + password: '', + email: '', + usernameError: null, + passwordError: null, + emailError: null, + error: null + } + + this.onChange = this.onChange.bind(this); + this.onSubmit = this.onSubmit.bind(this); + } + + onChange(e) { + let {name, value} = e.target; + + function errorCheck(errorName) { + return name === errorName && !value ? `${errorName} required`: null; + } + + this.setState({ + [name]: value, + usernameError: errorCheck('username'), + emailError: errorCheck('email'), + passwordError: errorCheck('password') + }) + } + + onSubmit(e) { + e.preventDefault(); + + this.props.onComplete(this.state) + .then(() => { + this.setState({ username: '', email: '', password: ''}) + }) + .catch(error => { + console.error(error); + this.setState({error}); + }) + } + + render() { + + let emailInput = ( + + ) + + return( +
+ + {util.renderIf(this.state.usernameError, + + {this.state.usernameError} + + )} + + {util.renderIf(this.state.passwordError, + + {this.state.passwordError} + + )} + {util.renderIf(this.props.auth === 'signup', emailInput)} + +
+ ) + } +} + +export default AuthForm; diff --git a/front/src/component/avatar/index.js b/front/src/component/avatar/index.js new file mode 100644 index 0000000..e6684b2 --- /dev/null +++ b/front/src/component/avatar/index.js @@ -0,0 +1,16 @@ +import React from 'react'; +import {connect} from 'react-redux'; + +class Avatar extends React.Component { + render() { + return ( + + ); + } +} + +let mapSateToProps = store => ({ + profile: store.profile +}); + +export default connect(mapSateToProps, undefined)(Avatar); diff --git a/front/src/component/landing/index.js b/front/src/component/landing/index.js new file mode 100644 index 0000000..61ce5d4 --- /dev/null +++ b/front/src/component/landing/index.js @@ -0,0 +1,33 @@ +import React from 'react'; +import {connect} from 'react-redux'; + +import AuthFrom from '../auth-form'; +import * as authReqs from '../../action/auth-action.js' + +class Landing extends React.Component { + render() { + + let {params} = this.props.match; + + let onComplete = params.auth === 'login'? + this.props.login: + this.props.signup; + + return( + + ) + } +}; + +let mapDispatchToProps = dispatch => { + return { + signup: user => dispatch(authReqs.signupRequest(user)), + login: user => dispatch(authReqs.loginRequest(user)) + } +} + +export default connect(undefined, mapDispatchToProps)(Landing); diff --git a/front/src/component/photo-form/index.js b/front/src/component/photo-form/index.js new file mode 100644 index 0000000..e69de29 diff --git a/front/src/component/photo-item/index.js b/front/src/component/photo-item/index.js new file mode 100644 index 0000000..e69de29 diff --git a/front/src/component/profile-form/index.js b/front/src/component/profile-form/index.js new file mode 100644 index 0000000..7707173 --- /dev/null +++ b/front/src/component/profile-form/index.js @@ -0,0 +1,64 @@ +import React from 'react'; +import * as util from '../../lib/util.js'; + +class ProfileForm extends React.Component { + constructor(props) { + super(props); + this.state = props.profile ? + {...this.props.profile, bio: ''} : + {bio: '', avatar: null, preview: ''} + + this.onChange = this.onChange.bind(this); + this.onSubmit = this.onSubmit.bind(this); + } + + componentWillReceiveProps(props) { + if(this.props.profile) { + this.setState(this.props.profile); + } + } + + onChange(e) { + let {type, name} = e.target; + if(name === 'bio') this.setState({bio: e.target.value}); + + if(name === 'avatar') { + let {files} = e.target; + let avatar = files[0]; + this.setState({avatar}); + util.photoToDataURL(avatar) + .then(preview => this.setState({preview})) + .catch(err => console.error(err)); + } + } + + onSubmit(e) { + e.preventDefault(); + this.props.onComplete(this.state); + } + + render() { + return( +
+ + + + +
+ ) + } +} + +export default ProfileForm; diff --git a/front/src/component/settings-container/index.js b/front/src/component/settings-container/index.js new file mode 100644 index 0000000..8665e14 --- /dev/null +++ b/front/src/component/settings-container/index.js @@ -0,0 +1,47 @@ +import React from 'react'; +import {connect} from 'react-redux'; +import ProfileForm from '../profile-form'; +import {profileCreateRequest} from '../../action/profile-action.js'; + +class SettingsContainer extends React.Component { + constructor(props) { + super(props); + this.handleProfileCreate = this.handleProfileCreate.bind(this); + this.handleProfileUpdate = this.handleProfileUpdate.bind(this); + } + + handleProfileCreate(profile) { + return this.props.profileCreate(profile) + .then(res => console.log('__RESPONSE__: ', res)) + .catch(err => console.error(err)); + } + + handleProfileUpdate(profile) { + + } + + render() { + let onComplete = this.props.profile ? + this.handleProfileCreate : + this.handleProfileUpdate; + + return ( +
+ +
+ ) + } +} + +let mapStateToProps = store => ({ + profile: store.profile +}) + +let mapDispatchToProps = dispatch => ({ + profileCreate: profile => dispatch(profileCreateRequest(profile)) +}) + +export default connect(mapStateToProps, mapDispatchToProps)(SettingsContainer); diff --git a/front/src/index.html b/front/src/index.html new file mode 100644 index 0000000..fb1a284 --- /dev/null +++ b/front/src/index.html @@ -0,0 +1,10 @@ + + + + + Every day that passes brings us one step closer to the void + + +
+ + diff --git a/front/src/lib/createAppStore.js b/front/src/lib/createAppStore.js new file mode 100644 index 0000000..97aa1b2 --- /dev/null +++ b/front/src/lib/createAppStore.js @@ -0,0 +1,10 @@ +import {createStore, applyMiddleware} from 'redux'; +import reducer from '../reducer'; +import thunk from './thunk.js'; +import reporter from './reporter.js'; + +let createAppStore = () => { + return createStore(reducer, applyMiddleware(thunk, reporter)); +}; + +module.exports = createAppStore; diff --git a/front/src/lib/reporter.js b/front/src/lib/reporter.js new file mode 100644 index 0000000..0a347af --- /dev/null +++ b/front/src/lib/reporter.js @@ -0,0 +1,15 @@ +let reporter = store => next => action => { + console.log('__ACTION__', action); + + try { + let result = next(action); + console.log('__STATE__', store.getState()); + return result; + } catch (error) { + error.action = action; + console.error('__ERROR__', error); + return error; + } +} + +export default reporter; diff --git a/front/src/lib/thunk.js b/front/src/lib/thunk.js new file mode 100644 index 0000000..c9d6452 --- /dev/null +++ b/front/src/lib/thunk.js @@ -0,0 +1,4 @@ +export default store => next => action => + typeof action === 'function' + ? action(store.dispatch, store.getState) + : next(action); diff --git a/front/src/lib/util.js b/front/src/lib/util.js new file mode 100644 index 0000000..f012a7a --- /dev/null +++ b/front/src/lib/util.js @@ -0,0 +1,38 @@ +export const photoToDataURL = (file) => { + return new Promise((resolve, reject) => { + let reader = new FileReader(); + reader.addEventListener('load', () => { + resolve(reader.result); + }) + reader.addEventListener('error', () => { + reject(reader.error); + }); + if (file) { + return reader.readAsDataURL(file) + } + return reject(new Error('ERROR: No file provided')); + }) +} + +// got from: +// https://stackoverflow.com/questions/14573223/set-cookie-and-get-cookie-with-javascript +export const readCookie = (name) => { + var nameEQ = name + '='; + var ca = document.cookie.split(';'); + for(var i=0; i < ca.length; i++) { + var c = ca[i]; + while(c.charAt(0) == ' ') c = c.substring(1, c.length); + if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length, c.length); + } + return null; +} +export const log = (...args) => + __DEBUG__ ? console.log(...args) : undefined; + +export const logError = (...args) => + __DEBUG__ ? console.error(...args) : undefined; + +export const renderIf = (test, component) => test ? component : undefined; + +export const classToggler = (options) => + Object.keys(options).filter(key => !!options[key]).join(' '); diff --git a/front/src/lib/validation.js b/front/src/lib/validation.js new file mode 100644 index 0000000..14f5cad --- /dev/null +++ b/front/src/lib/validation.js @@ -0,0 +1,12 @@ +module.exports = (profile, preReqs) => { + let incomplete = false; + + for (let i = 0; i < preReqs.length; i++) { + if(!profile[preReqs[i]]) { + console.error(`VALIDATION ERROR: Missing ${preReqs[i]} property.`); + incomplete = true; + } + } + + if(incomplete) throw new Error('VALIDATION ERROR: process terminated.'); +}; diff --git a/front/src/main.js b/front/src/main.js new file mode 100644 index 0000000..faafd74 --- /dev/null +++ b/front/src/main.js @@ -0,0 +1,5 @@ +import React from 'react'; +import ReactDom from 'react-dom'; +import App from './component/app'; + +ReactDom.render(, document.getElementById('root')); diff --git a/front/src/reducer/auth.js b/front/src/reducer/auth.js new file mode 100644 index 0000000..4bd3bcf --- /dev/null +++ b/front/src/reducer/auth.js @@ -0,0 +1,12 @@ +module.exports = (state=null, action) => { + let {type, payload} = action; + + switch(type) { + case 'TOKEN_SET': + return payload; + case 'TOKEN_DELETE': + return null; + default: + return state; + } +}; diff --git a/front/src/reducer/index.js b/front/src/reducer/index.js new file mode 100644 index 0000000..e781a72 --- /dev/null +++ b/front/src/reducer/index.js @@ -0,0 +1,8 @@ +import {combineReducers} from 'redux'; +import auth from './auth.js'; +import profile from './profile.js' + +module.exports = combineReducers({ + auth, + profile +}); diff --git a/front/src/reducer/profile.js b/front/src/reducer/profile.js new file mode 100644 index 0000000..c651d65 --- /dev/null +++ b/front/src/reducer/profile.js @@ -0,0 +1,20 @@ +import validate from '../lib/validation.js'; + +let profileReqs = ['avatar', 'email', 'bio', '_id', 'owner', 'username']; + +module.exports = (state=null, action) => { + let {type, payload} = action; + + switch(type) { + case 'PROFILE_CREATE': + validate(payload, profileReqs); + return payload; + case 'PROFILE_UPDATE': + validate(payload, profileReqs); + return {...state, ...payload}; + case 'LOGOUT': + return null; + default: + return state; + } +} diff --git a/front/webpack.config.js b/front/webpack.config.js new file mode 100644 index 0000000..4592355 --- /dev/null +++ b/front/webpack.config.js @@ -0,0 +1,86 @@ +'use strict'; + +const HtmlPlugin = require('html-webpack-plugin'); +const ExtractPlugin = require('extract-text-webpack-plugin'); +const UglifyPlugin = require('uglifyjs-webpack-plugin'); +const CleanPlugin = require('clean-webpack-plugin'); +const {DefinePlugin, EnvironmentPlugin} = require('webpack'); + +require('dotenv').config(); +let production = process.env.NODE_ENV === 'production'; + +let plugins = [ + new HtmlPlugin({template: `${__dirname}/src/index.html`}), + new ExtractPlugin('bundle-[hash].css'), + new EnvironmentPlugin(['NODE_ENV']), + new DefinePlugin({ + __DEBUG__: JSON.stringify(!production), + __API_URL__: JSON.stringify(process.env.API_URL) + }) +]; + +if(production) { + plugins = [...plugins, new CleanPlugin, new UglifyPlugin]; +} + +module.exports = { + devtool: production ? undefined : 'eval', + entry: `${__dirname}/src/main.js`, + devServer: { + historyApiFallback: true + }, + plugins, + output: { + path: `${__dirname}/build`, + filename: 'bundle-[hash].js', + publicPath: process.env.CDN_URL + }, + module: { + rules: [ + { + test: /\.js$/, + exclude: /node_modules/, + loader: 'babel-loader' + }, + { + test: /\.scss$/, + loader: ExtractPlugin.extract(['css-loader', 'sass-loader']) + }, + { + test: /\.(woff|woff2|ttf|eot|glyph|\.svg)$/, + use: [ + { + loader: 'url-loader', + options: { + limit: 10000, + name: 'font/[name].[ext]' + } + } + ] + }, + { + test: /\.(jpg|jpeg|gif|png|tiff|svg)$/, + exclude: /\.glyph.svg/, + use: [ + { + loader: 'url-loader', + options: { + limit: 6000, + name: 'image/[name].[ext]' + } + } + ] + }, + { + test: /\.(mp3|aac|aiff|wav|flac|m4a|mp4|ogg)$/, + exclude: /\.glyph.svg/, + use: [ + { + loader: 'file-loader', + options: { name: 'audio/[name].[ext]' } + } + ] + } + ] + } +}; diff --git a/sluggram b/sluggram new file mode 160000 index 0000000..1fc6e99 --- /dev/null +++ b/sluggram @@ -0,0 +1 @@ +Subproject commit 1fc6e992ae12d84d6e3f8dfc67a18c9f2eca29ad