diff options
Diffstat (limited to 'animism-align')
14 files changed, 534 insertions, 3 deletions
diff --git a/animism-align/cli/app/controllers/user_controller.py b/animism-align/cli/app/controllers/user_controller.py index 8d14b98..54b39ab 100644 --- a/animism-align/cli/app/controllers/user_controller.py +++ b/animism-align/cli/app/controllers/user_controller.py @@ -5,6 +5,7 @@ from werkzeug.datastructures import MultiDict from app.sql.common import db, Session from app.sql.models.user import User, UserForm from app.controllers.crud_controller import CrudView +from app.utils.auth_utils import encrypt_password from flask_jwt import current_identity @@ -17,6 +18,8 @@ class UserView(CrudView): raise ValueError("Unauthorized") if 'password' in form: item.password = encrypt_password(form['password']) + else: + raise ValueError("No password specified") if 'settings' in form: item.settings = form['settings'] @@ -31,7 +34,7 @@ class UserView(CrudView): if 'settings' in form: item.settings = form['settings'] - def on_destroy(self, session, form, item): + def on_destroy(self, session, item): if not current_identity.is_admin: raise ValueError("Unauthorized") if item.id == current_identity.id: diff --git a/animism-align/cli/app/server/web.py b/animism-align/cli/app/server/web.py index f9714cb..3f2136a 100644 --- a/animism-align/cli/app/server/web.py +++ b/animism-align/cli/app/server/web.py @@ -1,6 +1,7 @@ import os import logging import logging.handlers +from datetime import timedelta logger = logging.getLogger("") logger.setLevel(logging.DEBUG) @@ -37,6 +38,8 @@ def create_app(script_info=None): app.config['SERVER_NAME'] = app_cfg.SERVER_NAME app.config['SECRET_KEY'] = app_cfg.TOKEN_SECRET app.config['JWT_AUTH_URL_RULE'] = '/api/v1/auth/login' + # app.config['JWT_VERIFY_EXPIRATION'] = False + app.config['EXPIRATION_DELTA'] = timedelta(days=365 * 10) app.url_map.strict_slashes = False db.init_app(app) diff --git a/animism-align/cli/app/sql/models/user.py b/animism-align/cli/app/sql/models/user.py index 85549da..41ac917 100644 --- a/animism-align/cli/app/sql/models/user.py +++ b/animism-align/cli/app/sql/models/user.py @@ -28,6 +28,6 @@ class User(Base): class UserForm(ModelForm): class Meta: model = User - exclude = ['settings'] + exclude = ['password','settings'] def get_session(): return Session() diff --git a/animism-align/frontend/app/store.js b/animism-align/frontend/app/store.js index df8b835..3f30abd 100644 --- a/animism-align/frontend/app/store.js +++ b/animism-align/frontend/app/store.js @@ -12,6 +12,7 @@ import mediaReducer from 'app/views/media/media.reducer' import episodeReducer from 'app/views/episode/episode.reducer' import venueReducer from 'app/views/venue/venue.reducer' import authReducer from 'app/views/auth/auth.reducer' +import userReducer from 'app/views/user/user.reducer' // editor import alignReducer from 'app/views/align/align.reducer' @@ -31,6 +32,7 @@ const createRootReducer = history => ( media: mediaReducer, episode: episodeReducer, venue: venueReducer, + user: userReducer, auth: authReducer, align: alignReducer, diff --git a/animism-align/frontend/app/views/index.js b/animism-align/frontend/app/views/index.js index d958c74..ba16ae1 100644 --- a/animism-align/frontend/app/views/index.js +++ b/animism-align/frontend/app/views/index.js @@ -5,3 +5,4 @@ export { default as media } from './media/media.container' export { default as viewer } from './viewer/viewer.container' export { default as episode } from './episode/episode.container' export { default as venue } from './venue/venue.container' +export { default as users } from './user/user.container' diff --git a/animism-align/frontend/app/views/nav/header.component.js b/animism-align/frontend/app/views/nav/header.component.js index fadd680..b264f4d 100644 --- a/animism-align/frontend/app/views/nav/header.component.js +++ b/animism-align/frontend/app/views/nav/header.component.js @@ -22,6 +22,7 @@ function Header(props) { <Link to="/episode">Episodes</Link> <Link to="/venue">Venues</Link> <Link to="/viewer">Viewer</Link> + {props.currentUser.is_admin && <Link to="/user">Users</Link>} <a href="#" onClick={actions.auth.logout}> Logout </a> @@ -39,7 +40,7 @@ function Header(props) { // } const mapStateToProps = (state) => ({ - // auth: state.auth, + currentUser: state.auth.user, site: state.site, router: state.router, playing: state.audio.playing, diff --git a/animism-align/frontend/app/views/user/components/user.form.js b/animism-align/frontend/app/views/user/components/user.form.js new file mode 100644 index 0000000..4bb9e9b --- /dev/null +++ b/animism-align/frontend/app/views/user/components/user.form.js @@ -0,0 +1,179 @@ +import React, { Component } from 'react' +import { Link } from 'react-router-dom' + +import { capitalize } from 'app/utils' + +import { TextInput, NumberInput, LabelDescription, Select, TextArea, Checkbox, SubmitButton, Loader } from 'app/common' + +const newUser = () => ({ + username: '', + password: '', + is_admin: false, + settings: { + }, +}) + +export default class UserForm extends Component { + state = { + title: "", + submitTitle: "", + data: { ...newUser() }, + errorFields: new Set([]), + } + + constructor(props) { + super(props) + this.handleKeyDown = this.handleKeyDown.bind(this) + this.handleSelect = this.handleSelect.bind(this) + this.handleChange = this.handleChange.bind(this) + this.handleSettingsChange = this.handleSettingsChange.bind(this) + this.handleSettingsChangeEvent = this.handleSettingsChangeEvent.bind(this) + this.handleSubmit = this.handleSubmit.bind(this) + } + + componentDidMount() { + const { data, isNew } = this.props + const title = isNew ? 'New user' : 'Editing ' + data.username + const submitTitle = isNew ? "Add User" : "Save Changes" + this.setState({ + title, + submitTitle, + errorFields: new Set([]), + data: { + ...newUser(), + ...data + }, + }) + window.addEventListener('keydown', this.handleKeyDown) + } + + componentWillUnmount() { + window.removeEventListener('keydown', this.handleKeyDown) + } + + handleKeyDown(e) { + // console.log(e, e.keyCode) + if ((e.ctrlKey || e.metaKey) && e.keyCode === 83) { + if (e) { + e.preventDefault() + } + this.handleSubmit() + } + } + + handleChange(e) { + const { name, value } = e.target + this.handleSelect(name, value) + } + + handleSelect(name, value) { + const { errorFields } = this.state + if (errorFields.has(name)) { + errorFields.delete(name) + } + this.setState({ + errorFields, + data: { + ...this.state.data, + [name]: value, + } + }) + } + + handleSettingsChangeEvent(e) { + const { name, value } = e.target + this.handleSettingsChange(name, value) + } + + handleSettingsChange(name, value) { + // console.log(name, value) + if (name !== 'multiple') { + value = { [name]: value } + } + this.setState({ + data: { + ...this.state.data, + settings: { + ...this.state.data.settings, + ...value, + } + } + }) + } + + handleSubmit(e) { + if (e) { + e.preventDefault() + } + const { isNew, onSubmit } = this.props + const { data } = this.state + const requiredKeys = (isNew ? "username password" : "username").split(" ") + const validKeys = "username password is_admin settings".split(" ") + const validData = validKeys.reduce((a,b) => { a[b] = data[b]; return a }, {}) + if (!validData.password) { + delete validData.password + } + const errorFields = requiredKeys.filter(key => !validData[key]) + if (errorFields.length) { + console.log('error', errorFields, validData) + this.setState({ errorFields: new Set(errorFields) }) + } else { + if (isNew) { + // + } else { + validData.id = data.id + } + console.log('submit', validData) + onSubmit(validData) + } + } + + render() { + const { currentUser, isNew } = this.props + const { title, submitTitle, errorFields, data } = this.state + // console.log(data) + return ( + <div className='form'> + <h1>{title}</h1> + <form onSubmit={this.handleSubmit}> + <TextInput + title="Username" + name="username" + required + data={data} + onChange={this.handleChange} + autoComplete="off" + /> + <TextInput + title={isNew ? "Password" : "Change password?"} + name="password" + type="password" + required + data={data} + onChange={this.handleChange} + autoComplete="off" + /> + {currentUser.is_admin && ( + <Checkbox + label="Is admin" + name="is_admin" + checked={data.is_admin} + onChange={this.handleSelect} + /> + )} + + <SubmitButton + title={submitTitle} + onClick={this.handleSubmit} + /> + {!!errorFields.size && + <label> + <span></span> + <span>Please complete the required fields</span> + </label> + } + </form> + </div> + ) + } +} diff --git a/animism-align/frontend/app/views/user/components/user.menu.js b/animism-align/frontend/app/views/user/components/user.menu.js new file mode 100644 index 0000000..cf77970 --- /dev/null +++ b/animism-align/frontend/app/views/user/components/user.menu.js @@ -0,0 +1,64 @@ +import React, { Component } from 'react' +import { Route } from 'react-router-dom' +import { connect } from 'react-redux' + +import { history } from 'app/store' +import actions from 'app/actions' +import { MenuButton } from 'app/common' + +const mapStateToProps = state => ({ + user: state.user, + currentUser: state.auth.user, +}) + +export default class UserMenu extends Component { + render() { + return ( + <div className='menuButtons'> + <Route exact path='/users/:id/show/' component={UserShowMenu} /> + <Route exact path='/users/:id/edit/' component={UserEditMenu} /> + <Route exact path='/users/new/' component={UserNewMenu} /> + <Route exact path='/users/' component={UserIndexMenu} /> + </div> + ) + } +} + +const UserIndexMenu = connect(mapStateToProps)((props) => ([ + props.currentUser.is_admin && <MenuButton key='new' name="new" href="/users/new/" />, +])) + +const UserShowMenu = connect(mapStateToProps)((props) => ([ + <MenuButton key='back' name="back" href="/users/" />, + (props.currentUser.is_admin || parseInt(props.match.params.id) === props.currentUser.id) && ( + <MenuButton key='edit' name="edit" href={"/users/" + props.match.params.id + "/edit/"} /> + ), + (parseInt(props.match.params.id) !== props.currentUser.id) && ( + <MenuButton key='delete' name="delete" onClick={() => { + const { res: user } = props.user.show + if (confirm("Really delete this user?")) { + actions.user.destroy(user).then(() => { + history.push('/users/') + }) + } + }} /> + ), +])) + +const UserNewMenu = (props) => ([ + <MenuButton key='back' name="back" href="/users/" />, +]) + +const UserEditMenu = connect(mapStateToProps)((props) => ([ + <MenuButton key='back' name="back" href="/users/" />, + (parseInt(props.match.params.id) !== props.currentUser.id) && ( + <MenuButton key='delete' name="delete" onClick={() => { + const { res: user } = props.user.show + if (confirm("Really delete this user?")) { + actions.user.destroy(user).then(() => { + history.push('/users/') + }) + } + }} /> + ), +])) diff --git a/animism-align/frontend/app/views/user/containers/user.edit.js b/animism-align/frontend/app/views/user/containers/user.edit.js new file mode 100644 index 0000000..5d51792 --- /dev/null +++ b/animism-align/frontend/app/views/user/containers/user.edit.js @@ -0,0 +1,70 @@ +import React, { Component } from 'react' +import { connect } from 'react-redux' + +import { history } from 'app/store' +import actions from 'app/actions' + +import { Loader } from 'app/common' + +import UserForm from '../components/user.form' +import UserMenu from '../components/user.menu' + +class UserEdit extends Component { + componentDidMount() { + console.log(this.props.match.params.id) + actions.user.show(this.props.match.params.id) + } + + handleSubmit(data) { + actions.user.update(data) + .then(response => { + // response + console.log(response) + history.push('/users/') + }) + .catch(err => { + console.log(err) + if (!err.errors) { + if (err.error) { + alert(err.error) + return + } + alert("There was an error saving this user.") + return + } + const errorStr = Object.keys(err.errors) + .map(key => ( + err.errors[key].map(error => + key + ": " + error + ).join("\n") + )).join("\n") + alert("There was an error updating this user.\n" + errorStr) + }) + } + + render() { + const { show } = this.props.user + if (show.loading || !show.res) { + return ( + <div className='form'> + <Loader /> + </div> + ) + } + return ( + <div className='row formContainer'> + <UserMenu userActions={this.props.userActions} /> + <UserForm + data={show.res} + onSubmit={this.handleSubmit.bind(this)} + /> + </div> + ) + } +} + +const mapStateToProps = state => ({ + user: state.user, +}) + +export default connect(mapStateToProps)(UserEdit) diff --git a/animism-align/frontend/app/views/user/containers/user.index.js b/animism-align/frontend/app/views/user/containers/user.index.js new file mode 100644 index 0000000..8b207e0 --- /dev/null +++ b/animism-align/frontend/app/views/user/containers/user.index.js @@ -0,0 +1,79 @@ +import React, { Component } from 'react' +import { Link } from 'react-router-dom' +import { connect } from 'react-redux' + +import { Loader } from 'app/common' +import actions from 'app/actions' + +import UserMenu from '../components/user.menu' + +// const { result, collectionLookup } = this.props + +class UserIndex extends Component { + componentDidMount() { + this.fetch() + } + + fetch() { + actions.user.index() + } + + render() { + const { currentUser } = this.props + const { loading, lookup, order } = this.props.user.index + if (loading) { + return ( + <section> + <Loader /> + </section> + ) + } + if (!lookup || !order.length) { + return ( + <section> + <div className="row user-index"> + <UserMenu /> + <div> + <h1>Users</h1> + <p className='gray'> + {"No users"} + </p> + </div> + </div> + </section> + ) + } + return ( + <section> + <div className="row user-index"> + <UserMenu /> + <div className="user-list"> + <h1>Users</h1> + {order.map(id => { + const user = lookup[id] + return ( + <div key={id}> + {(currentUser.is_admin || currentUser.id === user.id) + ? <Link to={"/users/" + id + "/edit/"}> + {user.username} + </Link> + : user.username + } + {user.is_admin && " (admin)"} + </div> + ) + })} + </div> + </div> + {order.length >= 50 && <button className='loadMore' onClick={() => this.fetch(true)}>Load More</button>} + </section> + ) + } +} + +const mapStateToProps = state => ({ + user: state.user, + currentUser: state.auth.user, +}) + +export default connect(mapStateToProps)(UserIndex) diff --git a/animism-align/frontend/app/views/user/containers/user.new.js b/animism-align/frontend/app/views/user/containers/user.new.js new file mode 100644 index 0000000..9d80e0a --- /dev/null +++ b/animism-align/frontend/app/views/user/containers/user.new.js @@ -0,0 +1,76 @@ +import React, { Component } from 'react' +import { Link } from 'react-router-dom' +import { connect } from 'react-redux' + +import { history } from 'app/store' +import actions from 'app/actions' + +import UserForm from '../components/user.form' +import UserMenu from '../components/user.menu' + +class UserNew extends Component { + state = { + loading: true, + initialData: {}, + } + + componentDidMount() { + this.setState({ loading: false }) + } + + handleSubmit(data) { + console.log(data) + actions.user.create(data) + .then(res => { + console.log(res) + if (res.res && res.res.id) { + history.push('/users/') + } + }) + .catch(err => { + if (!err.errors) { + if (err.error) { + alert(err.error) + return + } + alert("There was an error saving this user.") + return + } + const errorStr = Object.keys(err.errors) + .map(key => ( + err.errors[key].map(error => + key + ": " + error + ).join("\n") + )).join("\n") + alert("There was an error creating this user.\n" + errorStr) + }) + } + + render() { + if (this.state.loading) { + return ( + <div className='row formContainer' /> + ) + } + return ( + <div className='row formContainer'> + <UserMenu /> + <UserForm + isNew + data={this.state.initialData} + onSubmit={this.handleSubmit.bind(this)} + /> + </div> + ) + } +} + +const mapStateToProps = state => ({ + user: state.user, +}) + +const mapDispatchToProps = dispatch => ({ + // uploadActions: bindActionCreators({ ...uploadActions }, dispatch), +}) + +export default connect(mapStateToProps, mapDispatchToProps)(UserNew) diff --git a/animism-align/frontend/app/views/user/user.container.js b/animism-align/frontend/app/views/user/user.container.js new file mode 100644 index 0000000..1b3e25c --- /dev/null +++ b/animism-align/frontend/app/views/user/user.container.js @@ -0,0 +1,26 @@ +import React, { Component } from 'react' +import { Route } from 'react-router-dom' +import { connect } from 'react-redux' + +import './user.css' + +import UserIndex from './containers/user.index' +import UserNew from './containers/user.new' +import UserEdit from './containers/user.edit' + +class Container extends Component { + render() { + return ( + <div className='userContainer'> + <Route exact path='/users/:id/edit/' component={UserEdit} /> + <Route exact path='/users/new/' component={UserNew} /> + <Route exact path='/users/' component={UserIndex} /> + </div> + ) + } +} +const mapStateToProps = state => ({ + user: state.user, +}) + +export default connect(mapStateToProps)(Container) diff --git a/animism-align/frontend/app/views/user/user.css b/animism-align/frontend/app/views/user/user.css new file mode 100644 index 0000000..aca58c7 --- /dev/null +++ b/animism-align/frontend/app/views/user/user.css @@ -0,0 +1,9 @@ +.app > .userContainer { + width: 100%; + height: calc(100% - 3.125rem); + overflow: scroll; +} + +.user-index { + margin-top: 1rem; +} diff --git a/animism-align/frontend/app/views/user/user.reducer.js b/animism-align/frontend/app/views/user/user.reducer.js new file mode 100644 index 0000000..cd82b02 --- /dev/null +++ b/animism-align/frontend/app/views/user/user.reducer.js @@ -0,0 +1,18 @@ +// import * as types from 'app/types' + +import { crudState, crudReducer } from 'app/api/crud.reducer' + +const initialState = crudState('user', { + options: {}, +}) + +const reducer = crudReducer('user') + +export default function userReducer(state = initialState, action) { + // console.log(action.type, action) + state = reducer(state, action) + switch (action.type) { + default: + return state + } +} |
