Merge branch 'master' into admin

This commit is contained in:
Wyatt Johnson
2016-11-22 08:31:21 -07:00
committed by GitHub
29 changed files with 1037 additions and 4070 deletions
+8 -1
View File
@@ -32,7 +32,7 @@ This is a guide to have a common language to talk about "Talk".
* Protected Profile: information about users that only moderators and admins can see
* Queue - Group of items based on a query, aka - moderation queue
* Target - The item/s on which an action is performed..
* Target - The item/s on which an action is performed
## Actions
@@ -64,3 +64,10 @@ Postmoderation means that comments appear on the site _before_ any moderation ac
* New comments appear in comment streams immediately.
* New comments do not appear in moderation queues unless they are flagged by other users.
### Word lists
* Banned words - words that the site never allows in a comment
* Suspect words - words whose usage needs to be approved by a moderator before being shown in the stream
* Approved words - words that are usually Banned or Suspect sitewide, but approved for use in a specific article stream
+17 -20
View File
@@ -13,33 +13,30 @@ process.env.DEBUG = process.env.TALK_DEBUG;
const app = require('../app');
const debug = require('debug')('talk:server');
const http = require('http');
const initPromise = require('../init');
const init = require('../init');
const port = normalizePort(process.env.TALK_PORT || '3000');
let server;
initPromise
.then(() => {
/**
* Get port from environment and store in Express.
*/
init().then(() => {
app.set('port', port);
/**
* Get port from environment and store in Express.
*/
app.set('port', port);
/**
* Create HTTP server.
*/
/**
* Create HTTP server.
*/
server = http.createServer(app);
server = http.createServer(app);
/**
* Listen on provided port, on all network interfaces.
*/
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);
});
/**
* Listen on provided port, on all network interfaces.
*/
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);
});
/**
* Normalize a port into a number, string, or false.
@@ -34,7 +34,11 @@ export default store => next => action => {
// Get comments to fill each of the three lists on the mod queue
const fetchModerationQueueComments = store =>
Promise.all([fetch('/api/v1/queue/comments/pending'), fetch('/api/v1/comments/status/rejected'), fetch('/api/v1/comments/action/flag')])
Promise.all([
fetch('/api/v1/queue/comments/pending'),
fetch('/api/v1/comments?status=rejected'),
fetch('/api/v1/comments?action=flag')
])
.then(res => Promise.all(res.map(r => r.json())))
.then(res => {
res[2] = res[2].map(comment => { comment.flagged = true; return comment; });
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -87,9 +87,9 @@ const forgotPassowordRequest = () => ({type: actions.FETCH_FORGOT_PASSWORD_REQUE
const forgotPassowordSuccess = () => ({type: actions.FETCH_FORGOT_PASSWORD_SUCCESS});
const forgotPassowordFailure = () => ({type: actions.FETCH_FORGOT_PASSWORD_FAILURE});
export const fetchForgotPassword = () => dispatch => {
dispatch(forgotPassowordRequest());
fetch(`${base}/user/request-password-reset`, getInit('POST'))
export const fetchForgotPassword = email => dispatch => {
dispatch(forgotPassowordRequest(email));
fetch(`${base}/user/request-password-reset`, getInit('POST', {email}))
.then(handleResp)
.then(() => dispatch(forgotPassowordSuccess()))
.catch(error => dispatch(forgotPassowordFailure(error)));
+6 -10
View File
@@ -15,7 +15,7 @@ const getInit = (method, body) => {
};
const init = {method, headers};
if (method.toLowerCase() !== 'get') {
if (body) {
init.body = JSON.stringify(body);
}
@@ -23,6 +23,9 @@ const getInit = (method, body) => {
};
const responseHandler = response => {
if (response.status === 204) {
return;
}
return response.ok ? response.json() : Promise.reject(`${response.status} ${response.statusText}`);
};
/**
@@ -199,8 +202,6 @@ export function postItem (item, type, id) {
};
}
//http://localhost:16180/v1/action/flag/user/user_89654/on/item/87e418c5-aafb-4eb7-9ce4-78f28793782a
/*
* PostAction
* Posts an action to an item
@@ -243,14 +244,9 @@ export function postAction (item_id, action_type, user_id, item_type) {
*
*/
export function deleteAction (item_id, action_type, user_id, item_type) {
export function deleteAction (action_id) {
return () => {
const action = {
action_type,
user_id
};
return fetch(`/api/v1/${item_type}/${item_id}/actions`, getInit('DELETE', action))
return fetch(`/api/v1/actions/${action_id}`, {method: 'DELETE'})
.then(responseHandler);
};
}
+12
View File
@@ -8,6 +8,8 @@ const initialState = Map({
showSignInDialog: false,
view: 'SIGNIN',
error: '',
passwordRequestSuccess: null,
passwordRequestFailure: null,
successSignUp: false
});
@@ -22,6 +24,8 @@ export default function auth (state = initialState, action) {
showSignInDialog: false,
view: 'SIGNIN',
error: '',
passwordRequestFailure: null,
passwordRequestSuccess: null,
successSignUp: false
}));
case actions.CHANGE_VIEW :
@@ -79,6 +83,14 @@ export default function auth (state = initialState, action) {
case actions.VALID_FORM:
return state
.set('error', '');
case actions.FETCH_FORGOT_PASSWORD_SUCCESS:
return state
.set('passwordRequestFailure', null)
.set('passwordRequestSuccess', 'If you have a registered account, a password reset link was sent to that email');
case actions.FETCH_FORGOT_PASSWORD_FAILURE:
return state
.set('passwordRequestFailure', 'There was an error sending your password reset email. Please try again soon!')
.set('passwordRequestSuccess', null);
default :
return state;
}
+4 -3
View File
@@ -10,12 +10,13 @@ const FlagButton = ({flag, id, postAction, deleteAction, addItem, updateItem, ad
if (!flagged) {
postAction(id, 'flag', '123', 'comments')
.then((action) => {
addItem({...action, current_user:true}, 'actions');
updateItem(action.item_id, action.action_type, action.id, 'comments');
let id = `${action.action_type}_${action.item_id}`;
addItem({id, current_user: action, count: flag ? flag.count + 1 : 1}, 'actions');
updateItem(action.item_id, action.action_type, id, 'comments');
});
addNotification('success', lang.t('flag-notif'));
} else {
deleteAction(id, 'flag', '123', 'comments')
deleteAction(flagged.id)
.then(() => {
updateItem(id, 'flag', '', 'comments');
});
+4 -3
View File
@@ -10,11 +10,12 @@ const LikeButton = ({like, id, postAction, deleteAction, addItem, updateItem}) =
if (!liked) {
postAction(id, 'like', '123', 'comments')
.then((action) => {
addItem({id: action.id, current_user:true, count: like ? like.count + 1 : 1}, 'actions');
updateItem(action.item_id, action.action_type, action.id, 'comments');
let id = `${action.action_type}_${action.item_id}`;
addItem({id, current_user: action, count: like ? like.count + 1 : 1}, 'actions');
updateItem(action.item_id, action.action_type, id, 'comments');
});
} else {
deleteAction(id, 'like', '123', 'comments')
deleteAction(liked.id)
.then(() => {
updateItem(like.id, 'count', like.count - 1, 'actions');
updateItem(like.id, 'current_user', false, 'actions');
@@ -5,25 +5,56 @@ import I18n from 'coral-framework/modules/i18n/i18n';
import translations from '../translations';
const lang = new I18n(translations);
const ForgotContent = ({changeView, ...props}) => (
<div>
<div className={styles.header}>
<h1>{lang.t('signIn.recoverPassword')}</h1>
</div>
<form onSubmit={(e) => {e.preventDefault(); props.fetchForgotPassword();}}>
<div className={styles.formField}>
<label htmlFor="email">{lang.t('signIn.email')}</label>
<input type="text" id="email" />
class ForgotContent extends React.Component {
constructor (props) {
super(props);
this.handleSubmit = this.handleSubmit.bind(this);
}
handleSubmit (e) {
e.preventDefault();
this.props.fetchForgotPassword(this.emailInput.value);
}
render () {
const {changeView, auth} = this.props;
const {passwordRequestSuccess, passwordRequestFailure} = auth;
return (
<div>
<div className={styles.header}>
<h1>{lang.t('signIn.recoverPassword')}</h1>
</div>
<form onSubmit={this.handleSubmit}>
<div className={styles.formField}>
<label htmlFor="email">{lang.t('signIn.email')}</label>
<input
ref={input => this.emailInput = input}
type="text"
id="email"
name="email" />
</div>
<Button type="submit" cStyle="black" className={styles.signInButton}>
{lang.t('signIn.recoverPassword')}
</Button>
{
passwordRequestSuccess
? <p className={styles.passwordRequestSuccess}>{passwordRequestSuccess}</p>
: null
}
{
passwordRequestFailure
? <p className={styles.attention}>{passwordRequestFailure}</p>
: null
}
</form>
<div className={styles.footer}>
<span>{lang.t('signIn.needAnAccount')} <a onClick={() => changeView('SIGNUP')}>{lang.t('signIn.register')}</a></span>
<span>{lang.t('signIn.alreadyHaveAnAccount')} <a onClick={() => changeView('SIGNIN')}>{lang.t('signIn.signIn')}</a></span>
</div>
</div>
<Button type="submit" cStyle="black" className={styles.signInButton}>
{lang.t('signIn.recoverPassword')}
</Button>
</form>
<div className={styles.footer}>
<span>{lang.t('signIn.needAnAccount')} <a onClick={() => changeView('SIGNUP')}>{lang.t('signIn.register')}</a></span>
<span>{lang.t('signIn.alreadyHaveAnAccount')} <a onClick={() => changeView('SIGNIN')}>{lang.t('signIn.signIn')}</a></span>
</div>
</div>
);
);
}
}
export default ForgotContent;
+13 -1
View File
@@ -128,4 +128,16 @@ input.error{
.action {
margin-top: 15px;
}
}
.passwordRequestSuccess {
border: 1px solid green;
background-color: lightgreen;
padding: 10px;
}
.passwordRequestFailure {
border: 1px solid orange;
background-color: 1px solid coral
}
+12 -3
View File
@@ -1,6 +1,15 @@
const Setting = require('./models/setting');
const wordlist = require('./services/wordlist');
const defaults = {id: '1', moderation: 'pre'};
module.exports = Setting.init(defaults);
module.exports = () => Promise.all([
// presumably this file will grow, which is why I've broken it out.
// Upsert the settings object.
Setting
.init({id: '1', moderation: 'pre'})
.then(() => {
// Load in the wordlist now that settings have been init'd.
return wordlist.init();
})
]);
+1 -3
View File
@@ -90,9 +90,7 @@ ActionSchema.statics.findCommentsIdByActionType = function(action_type, item_typ
return Action.find({
'action_type': action_type,
'item_type': item_type
},
'item_id'
);
}, 'item_id');
};
const Action = mongoose.model('Action', ActionSchema);
+44 -36
View File
@@ -17,7 +17,6 @@ const CommentSchema = new Schema({
},
asset_id: String,
author_id: String,
username: String,
status: {
type: String,
enum: ['accepted', 'rejected', ''],
@@ -31,19 +30,6 @@ const CommentSchema = new Schema({
}
});
//==============================================================================
// New Statics
//==============================================================================
/**
* Create a comment.
* @param {String} body content of comment
*/
CommentSchema.statics.new = function(body, author_id, asset_id, parent_id, status, username) {
let comment = new Comment({body, author_id, asset_id, parent_id, status, username});
return comment.save();
};
//==============================================================================
// Find Statics
//==============================================================================
@@ -51,7 +37,8 @@ CommentSchema.statics.new = function(body, author_id, asset_id, parent_id, statu
/**
* Finds a comment by the id.
* @param {String} id identifier of comment (uuid)
*/
* @return {Promise}
*/
CommentSchema.statics.findById = function(id) {
return Comment.findOne({'id': id});
};
@@ -59,7 +46,8 @@ CommentSchema.statics.findById = function(id) {
/**
* Finds ALL the comments by the asset_id.
* @param {String} asset_id identifier of the asset which owns this comment (uuid)
*/
* @return {Promise}
*/
CommentSchema.statics.findByAssetId = function(asset_id) {
return Comment.find({asset_id});
};
@@ -68,7 +56,8 @@ CommentSchema.statics.findByAssetId = function(asset_id) {
* Finds the accepted comments by the asset_id.
* get the comments that are accepted.
* @param {String} asset_id identifier of the asset which owns the comments (uuid)
*/
* @return {Promise}
*/
CommentSchema.statics.findAcceptedByAssetId = function(asset_id) {
return Comment.find({asset_id: asset_id, status:'accepted'});
};
@@ -76,7 +65,8 @@ CommentSchema.statics.findAcceptedByAssetId = function(asset_id) {
/**
* Finds the new and accepted comments by the asset_id.
* @param {String} asset_id identifier of the asset which owns the comments (uuid)
*/
* @return {Promise}
*/
CommentSchema.statics.findAcceptedAndNewByAssetId = function(asset_id) {
return Comment.find({asset_id: asset_id, status: {'$in': ['accepted', '']}});
};
@@ -84,7 +74,8 @@ CommentSchema.statics.findAcceptedAndNewByAssetId = function(asset_id) {
/**
* Find comments by an action that was performed on them.
* @param {String} action_type the type of action that was performed on the comment
*/
* @return {Promise}
*/
CommentSchema.statics.findByActionType = function(action_type) {
return Action
.findCommentsIdByActionType(action_type, 'comment')
@@ -99,50 +90,54 @@ CommentSchema.statics.findByActionType = function(action_type) {
* Find not moderated comments by an action that was performed on them.
* @param {String} action_type the type of action that was performed on the comment
* @param {String} status the status of the comment to search for
*/
* @return {Promise}
*/
CommentSchema.statics.findByStatusByActionType = function(status, action_type) {
return Action
.findCommentsIdByActionType(action_type, 'comment')
.then((actions) => {
return Comment.find({
'status': status,
'id': {
'$in': actions.map(a => {
return a.item_id;
})
status: status,
id: {
$in: actions.map(a => a.item_id)
}
});
});
};
/**
* Find comments by their status.
* @param {String} status the status of the comment to search for
*/
* @return {Promise}
*/
CommentSchema.statics.findByStatus = function(status) {
return Comment.find({'status': status});
return Comment.find({
status: status === 'new' ? '' : status
});
};
/**
* Find comments that need to be moderated (aka moderation queue).
* @param {String} moderationValue pre or post moderation setting. If it is undefined then look at the settings.
*/
* @return {Promise}
*/
CommentSchema.statics.moderationQueue = function(moderation) {
switch(moderation){
// Pre-moderation: New comments are shown in the moderator queues immediately.
case 'pre':
return Comment.findByStatus('').then((comments) => {
return comments;
});
// Post-moderation: New comments do not appear in moderation queues unless they are flagged by other users.
case 'post':
return Comment.findByStatusByActionType('', 'flag').then((comments) => {
return comments;
});
default:
throw new Error('Moderation setting not found.');
return Promise.reject(Error('Moderation setting not found.'));
}
};
@@ -154,16 +149,18 @@ CommentSchema.statics.moderationQueue = function(moderation) {
* Change the status of a comment.
* @param {String} id identifier of the comment (uuid)
* @param {String} status the new status of the comment
*/
* @return {Promise}
*/
CommentSchema.statics.changeStatus = function(id, status) {
return Comment.findOneAndUpdate({'id': id}, {$set: {'status': status}}, {upsert: false, new: true});
return Comment.findOneAndUpdate({'id': id}, {$set: {'status': status}});
};
/**
* Add an action to the comment.
* @param {String} id identifier of the comment (uuid)
* @param {String} action the new action to the comment
*/
* @return {Promise}
*/
CommentSchema.statics.addAction = function(id, user_id, action_type) {
// check that the comment exist
let action = new Action({
@@ -183,7 +180,8 @@ CommentSchema.statics.addAction = function(id, user_id, action_type) {
* Change the status of a comment.
* @param {String} id identifier of the comment (uuid)
* @param {String} status the new status of the comment
*/
* @return {Promise}
*/
CommentSchema.statics.removeById = function(id) {
return Comment.remove({'id': id});
};
@@ -193,7 +191,8 @@ CommentSchema.statics.removeById = function(id) {
* @param {String} id identifier of the comment (uuid)
* @param {String} action_type the type of the action to be removed
* @param {String} user_id the id of the user performing the action
*/
* @return {Promise}
*/
CommentSchema.statics.removeAction = function(item_id, user_id, action_type) {
return Action.remove({
action_type,
@@ -203,6 +202,15 @@ CommentSchema.statics.removeAction = function(item_id, user_id, action_type) {
});
};
/**
* Returns all the comments in the collection.
* @return {Promise}
*/
CommentSchema.statics.all = () => {
return Comment.find();
};
// Comment model.
const Comment = mongoose.model('Comment', CommentSchema);
module.exports = Comment;
+3 -2
View File
@@ -2,7 +2,7 @@ const mongoose = require('../mongoose');
const Schema = mongoose.Schema;
/**
* this Schema manages application settings that get used on front- and backend
* this Schema manages application settings that get used on front and backend
* NOTE: when you set a setting here, it will not automatically be exposed to
* the front end. You must add it to the whitelist in the settings route
* in /routes/api/settings/index.js
@@ -12,7 +12,8 @@ const SettingSchema = new Schema({
id: {type: String, default: '1'},
moderation: {type: String, enum: ['pre', 'post'], default: 'pre'},
infoBoxEnable: {type: Boolean, default: false},
infoBoxContent: {type: String, default: ''}
infoBoxContent: {type: String, default: ''},
wordlist: [String]
}, {
timestamps: {
createdAt: 'created_at',
+4 -14
View File
@@ -16,13 +16,8 @@
"config": {
"pre-git": {
"commit-msg": [],
"pre-commit": [
"npm run lint",
"npm test"
],
"pre-push": [
"npm test"
],
"pre-commit": ["npm run lint", "npm test"],
"pre-push": ["npm test"],
"post-commit": [],
"post-merge": []
}
@@ -31,12 +26,7 @@
"type": "git",
"url": "git+https://github.com/coralproject/talk.git"
},
"keywords": [
"talk",
"coral",
"coralproject",
"ask"
],
"keywords": ["talk", "coral", "coralproject", "ask"],
"author": "",
"license": "Apache-2.0",
"bugs": {
@@ -56,13 +46,13 @@
"helmet": "^3.1.0",
"jsonwebtoken": "^7.1.9",
"lodash": "^4.16.6",
"lodash.debounce": "^4.0.8",
"mongoose": "^4.6.5",
"morgan": "^1.7.0",
"nodemailer": "^2.6.4",
"passport": "^0.3.2",
"passport-facebook": "^2.1.1",
"passport-local": "^1.0.0",
"natural": "^0.4.0",
"prompt": "^1.0.0",
"react-linkify": "^0.1.3",
"redis": "^2.6.3",
+6 -3
View File
@@ -5,9 +5,12 @@ router.get('/embed/stream/preview', (req, res) => {
res.render('embed-stream', {basePath: '/client/embed/stream'});
});
router.get('/password-reset/:token', (req, res, next) => {
// render a page or something?
res.send('ok');
// this route is expecting there to be a token in the hash
// see /views/password-reset-email.ejs
router.get('/password-reset', (req, res, next) => {
// TODO: store the redirect uri in the token or something fancy
// admins and regular users should probably be redirected to different places.
res.render('password-reset', {redirectUri: process.env.TALK_ROOT_URL});
});
router.get('*', (req, res) => {
+19
View File
@@ -0,0 +1,19 @@
const express = require('express');
const Action = require('../../../models/action');
const router = express.Router();
router.delete('/:action_id', (req, res, next) => {
Action
.findOneAndRemove({
id: req.params.action_id
})
.then(() => {
res.status(204).end();
})
.catch(error => {
next(error);
});
});
module.exports = router;
+79 -115
View File
@@ -1,147 +1,111 @@
const express = require('express');
const Comment = require('../../../models/comment');
const wordlist = require('../../../services/wordlist');
const router = express.Router();
//==============================================================================
// Get Routes
//==============================================================================
router.get('/', (req, res, next) => {
Comment.find({}).then((comments) => {
res.status(200).json(comments);
let query;
if (req.query.status) {
query = Comment.findByStatus(req.query.status);
} else if (req.query.action_type) {
query = Comment.findByActionType(req.query.action_type);
} else {
query = Comment.all();
}
query.then(comments => {
res.json(comments);
})
.catch(next);
.catch((err) => {
next(err);
});
});
router.post('/', wordlist.filter('body'), (req, res, next) => {
const {
body,
asset_id,
parent_id,
author_id
} = req.body;
Comment
.create({
body,
asset_id,
parent_id,
status: req.wordlist.matched ? 'rejected' : '',
author_id
})
.then((comment) => {
res.status(201).send(comment);
})
.catch((err) => {
next(err);
});
});
router.get('/:comment_id', (req, res, next) => {
Comment
.findById(req.params.comment_id)
.then(comment => {
if (!comment) {
res.status(404).end();
return;
}
res.status(200).json(comment);
})
.catch(next);
});
// Get all the comments that have an action_type over them.
router.get('/action/:action_type', (req, res, next) => {
Comment
.findByActionType(req.params.action_type)
.then((comments) => {
res.status(200).json(comments);
})
.catch(next);
});
// Get all the comments that were rejected.
router.get('/status/rejected', (req, res, next) => {
Comment.findByStatus('rejected').then(comments => {
res.status(200).json(comments);
})
.catch(next);
});
// Get all the comments that were accepted.
router.get('/status/accepted', (req, res, next) => {
Comment.findByStatus('accepted').then((comments) => {
res.status(200).json(comments);
})
.catch(error => {
next(error);
});
});
// Get all the not moderated comments.
router.get('/status/new', (req, res, next) => {
Comment.findByStatus('').then((comments) => {
res.status(200).json(comments);
})
.catch(error => {
next(error);
});
});
//==============================================================================
// Post Routes
//==============================================================================
router.post('/', (req, res, next) => {
const {body, author_id, asset_id, parent_id, status, username} = req.body;
Comment
.new(body, author_id, asset_id, parent_id, status, username)
.then((comment) => {
res.status(200).send({'id': comment.id});
})
.catch(error => {
next(error);
.catch((err) => {
next(err);
});
});
router.post('/:comment_id', (req, res, next) => {
Comment
.findById(req.params.comment_id)
.then((comment) => {
comment.body = req.body.body;
comment.author_id = req.body.author_id;
comment.asset_id = req.body.asset_id;
comment.parent_id = req.body.parent_id;
comment.status = req.body.status;
return comment.save();
})
.then((comment) => {
res.status(200).send(comment);
})
.catch(error => {
next(error);
});
});
router.post('/:comment_id/status', (req, res, next) => {
Comment
.changeStatus(req.params.comment_id, req.body.status)
.then(comment => res.status(200).send(comment))
.catch(error => next(error));
});
router.post('/:comment_id/actions', (req, res, next) => {
Comment
.addAction(req.params.comment_id, req.body.user_id, req.body.action_type)
.then((action) => {
res.status(200).send(action);
})
.catch(error => {
next(error);
});
});
//==============================================================================
// Delete Routes
//==============================================================================
router.delete('/:comment_id', (req, res, next) => {
Comment
.removeById(req.params.comment_id)
.then(() => {
res.status(201).send({});
res.status(204).end();
})
.catch(error => {
next(error);
.catch((err) => {
next(err);
});
});
router.delete('/:comment_id/actions', (req, res, next) => {
console.log(req.params);
router.put('/:comment_id/status', (req, res, next) => {
const {
status
} = req.body;
Comment
.removeAction(req.params.comment_id, req.body.user_id, req.body.action_type)
.changeStatus(req.params.comment_id, status)
.then(() => {
res.status(201).send({});
res.status(204).end();
})
.catch(error => {
next(error);
.catch((err) => {
next(err);
});
});
router.post('/:comment_id/actions', (req, res, next) => {
const {
user_id,
action_type
} = req.body;
Comment
.addAction(req.params.comment_id, user_id, action_type)
.then((action) => {
res.status(201).json(action);
})
.catch((err) => {
next(err);
});
});
+1
View File
@@ -9,5 +9,6 @@ router.use('/queue', require('./queue'));
router.use('/settings', require('./settings'));
router.use('/stream', require('./stream'));
router.use('/user', require('./user'));
router.use('/actions', require('./actions'));
module.exports = router;
+9 -4
View File
@@ -1,4 +1,5 @@
const express = require('express');
const _ = require('lodash');
const Comment = require('../../../models/comment');
const User = require('../../../models/user');
@@ -25,9 +26,9 @@ router.get('/', (req, res, next) => {
case 'pre':
return Promise.all([Comment.findAcceptedByAssetId(asset.id), asset]);
case 'post':
return Promise.all([Comment.findAcceptedAndNewByAssetId(asset.id), asset]);
return Promise.all([Comment.findAcceptedAndNewByAssetId(asset.id), asset]);
default:
throw new Error('Moderation setting not found.');
return Promise.reject(new Error('Moderation setting not found.'));
}
})
// Get all the users and actions for those comments.
@@ -35,8 +36,12 @@ router.get('/', (req, res, next) => {
return Promise.all([
[asset],
comments,
User.findByIdArray(comments.map((comment) => comment.author_id)),
Action.getActionSummaries(comments.map((comment) => comment.id))
User.findByIdArray(_.uniq(comments.map((comment) => comment.author_id))),
Action.getActionSummaries(_.uniq([
asset.id,
...comments.map((comment) => comment.id),
...comments.map((comment) => comment.author_id)
]))
]);
})
.then(([assets, comments, users, actions]) => {
+5 -1
View File
@@ -79,6 +79,10 @@ router.post('/', (req, res, next) => {
router.post('/update-password', (req, res, next) => {
const {token, password} = req.body;
if (!password || password.length < 8) {
return res.status(400).send('Password must be at least 8 characters');
}
User.verifyPasswordResetToken(token)
.then(user => {
return User.changePassword(user.id, password);
@@ -100,7 +104,7 @@ router.post('/request-password-reset', (req, res, next) => {
const {email} = req.body;
if (!email) {
return next();
return next('you must submit an email when requesting a password.');
}
User
+164
View File
@@ -0,0 +1,164 @@
const debug = require('debug')('talk:services:wordlist');
const _ = require('lodash');
const natural = require('natural');
const tokenizer = new natural.WordTokenizer();
const Setting = require('../models/setting');
/**
* The root wordlist object.
* @type {Object}
*/
const wordlist = {
list: [],
enabled: false
};
/**
* Loads wordlists in from the naughty-words package based on languages
* selected.
* @param {Array} languages language codes to add to the wordlist
*/
wordlist.init = () => {
return Setting
.getSettings()
.then((settings) => {
// Insert the settings wordlist.
wordlist.insert(settings.wordlist);
});
};
/**
* Inserts the wordlist data and enables the wordlist.
* @param {Array} list list of words to be added to the wordlist
*/
wordlist.insert = (list) => {
// Add the words to this array, but also lowercase the words so that an
// easy comparison can take place.
wordlist.list = _.uniq(wordlist.list.concat(list.map((word) => {
return tokenizer.tokenize(word.toLowerCase());
})));
debug(`Added ${list.length} words to the wordlist, now the wordlist is ${wordlist.list.length} entries long.`);
// Enable the wordlist.
wordlist.enabled = true;
return Promise.resolve(wordlist);
};
/**
* Tests the phrase to see if it contains any of the defined blockwords.
* @param {String} phrase value to check for blockwords.
* @return {Boolean} true if a blockword is found, false otherwise.
*/
wordlist.match = (phrase) => {
// Lowercase the word to ensure that we don't miss a match due to
// capitalization.
let lowerPhraseWords = tokenizer.tokenize(phrase.toLowerCase());
// This will return true in the event that at least one blockword is found
// in the phrase.
return wordlist.list.some((blockphrase) => {
// First, let's see if we can find the first word in the blockphrase in the
// source phrase.
let idx = lowerPhraseWords.indexOf(blockphrase[0]);
if (idx === -1) {
// The first blockword in the blockphrase did not match the source phrase
// anywhere.
return false;
}
// Here we'll quick respond with true in the event that the blockphrase was
// just a single word.
if (blockphrase.length === 1) {
return true;
}
// We found the first word in the source phrase! Lets ensure it matches the
// rest of the blockphrase...
// Check to see if it even has the length to support this word!
if (lowerPhraseWords.length < idx + blockphrase.length - 1) {
// We couldn't possibly have the entire phrase here because we don't have
// enough entries!
return false;
}
for (let i = 1; i < blockphrase.length; i++) {
// Check to see if the next word also matches!
if (lowerPhraseWords[idx + i] !== blockphrase[i]) {
return false;
}
}
// We've walked over all the words of the blockphrase, and haven't had a
// mismatch... It does contain the whole word!
return true;
});
};
// ErrContainsProfanity is returned in the event that the middleware detects
// profanity/wordlisted words in the payload.
const ErrContainsProfanity = new Error('contains profanity');
ErrContainsProfanity.status = 400;
/**
* Connect middleware for scanning request bodies for wordlisted words and
* attaching a ErrContainsProfanity to the req.wordlisted parameter, otherwise
* it will just set that parameter to false.
* @param {Array} fields selectors for the body to extract the fields to be
* tested
* @return {Function} the Connect middleware
*/
wordlist.filter = (...fields) => (req, res, next) => {
// Start with the sensible default that the content does not contain
// profanity.
req.wordlist = {
matched: false
};
// If the wordlist isn't enabled, then don't actually perform checking and
// forward the request!
if (!wordlist.enabled) {
return next();
}
// Loop over all the fields from the body that we want to check.
const containsProfanity = fields.some((field) => {
let phrase = _.get(req.body, field, false);
// If the field doesn't exist in the body, then it can't be profane!
if (!phrase) {
// Return that there wasn't a profane word here.
return false;
}
// Check if the field contains a profane word.
if (wordlist.match(phrase)) {
debug(`the field "${field}" contained a phrase "${phrase}" which contained a wordlisted word/phrase`);
return true;
}
return false;
});
// The body could contain some profanity, address that here.
if (containsProfanity) {
req.wordlist.matched = ErrContainsProfanity;
}
next();
};
module.exports = wordlist;
module.exports.ErrContainsProfanity = ErrContainsProfanity;
+260 -156
View File
@@ -7,221 +7,320 @@ host: talk-stg.coralproject.net
schemes:
- https
basePath: /api/v1
consumes:
- application/json
produces:
- application/json
paths:
/comments:
# get:
# tags:
# - Comments
# produces:
# - application/json
# summary: Comment Types
# description: |
# This endpoint retrieves comments
# parameters:
# - name: id
# in: query
# description: Comment by id
# required: false
# type: string
# responses:
# 200:
# description: An array of comments
# schema:
# type: array
# items:
# $ref: '#/definitions/Comment'
get:
tags:
- Comments
parameters:
- name: status
in: query
description: Performs a search based on the comment's status.
type: string
enum:
- flag
- name: action_type
in: query
description: Performs a search based on the actions that have been added to it.
type: string
enum:
- rejected
- accepted
- new
responses:
200:
description: Comments matching the query.
schema:
type: array
items:
- $ref: '#/definitions/Comment'
500:
description: An error occured.
schema:
$ref: '#/definitions/Error'
post:
description: Add a new comment
tags:
- Comments
parameters:
- name: body
in: body
description: Body
required: true
description: The comment to create.
schema:
$ref: '#/definitions/Comment'
responses:
201:
description: "OK: Comment Added"
description: The comment that was created.
schema:
$ref: '#/definitions/Comment'
$ref: '#/definitions/Comment'
500:
description: "Error"
description: An error occured.
schema:
$ref: '#/definitions/Error'
/comments/{comment_id}:
get:
tags:
- Comments
parameters:
- name: comment_id
in: path
description: The id of the comment to retrieve.
type: string
required: true
responses:
200:
description: The comment was found.
schema:
$ref: '#/definitions/Comment'
404:
description: The comment was not found.
500:
description: An error occured.
schema:
$ref: '#/definitions/Error'
delete:
tags:
- Comments
parameters:
- name: comment_id
in: path
description: The id of the comment to delete.
type: string
required: true
responses:
204:
description: The comment was deleted.
500:
description: An error occured.
schema:
$ref: '#/definitions/Error'
/comments/{comment_id}/status:
put:
tags:
- Comments
- Moderation
parameters:
- name: comment_id
in: path
description: The id of the comment to retrieve.
type: string
required: true
- name: body
in: body
description: The status to update to.
required: true
schema:
type: object
properties:
status:
type: string
description: The status to update to.
responses:
204:
description: The comment status was updated.
500:
description: An error occured.
schema:
$ref: '#/definitions/Error'
/comments/{comment_id}/actions:
post:
tags:
- Comments
description: Add a action
- Actions
parameters:
- name: comment_id
in: path
description: Comment ID
required: true
description: The id of the comment to retrieve.
type: string
required: true
- name: body
in: body
description: comment
description: The action to add.
required: true
schema:
$ref: '#/definitions/Action'
type: object
properties:
action_type:
type: string
description: The action to add
responses:
201:
description: Action Added
description: The action created.
schema:
type: array
items:
$ref: '#/definitions/Comment'
/comments/{comment_id}/status:
post:
$ref: '#/definitions/Action'
500:
description: An error occured.
schema:
$ref: '#/definitions/Error'
/actions/{action_id}:
delete:
tags:
- Comments
description: Add a new status
- Actions
parameters:
- name: comment_id
- name: action_id
in: path
description: Comment ID
required: true
description: The id of the action to delete.
type: string
- name: body
in: body
description: comment
required: true
schema:
$ref: '#/definitions/ModerationAction'
responses:
204:
description: ModerationAction Added
/queue:
description: The action was deleted.
500:
description: An error occured.
schema:
$ref: '#/definitions/Error'
/auth:
get:
tags:
- Queue
description: Queue
parameters:
- name: type
in: query
description:
"pending: no status | flagged: flagged action + no status | rejected: rejected status"
required: true
type: string
enum:
- pending
- flagged
- rejected
- name: limit
in: query
description: Queue limit
required: false
type: integer
- name: skip
in: query
description: Skip
required: false
type: integer
- Auth
description: Retrieves the current authentication credentials.
responses:
200:
description: ModerationAction Added
description: The current user.
schema:
$ref: '#/definitions/User'
500:
description: An error occured.
schema:
$ref: '#/definitions/Error'
delete:
tags:
- Auth
description: Logs out the current authenticated user.
responses:
204:
description: The current user has been logged out.
500:
description: An error occured.
schema:
$ref: '#/definitions/Error'
/auth/local:
post:
tags:
- Auth
parameters:
- name: body
in: body
required: true
description: The login credentials.
schema:
type: object
properties:
email:
type: string
description: The email address of the current user.
password:
type: string
description: The password of the current user.
responses:
200:
description: The user has authenticated sucesfully.
schema:
$ref: '#/definitions/User'
401:
description: The authentication error.
schema:
$ref: '#/definitions/Error'
500:
description: An error occured.
schema:
$ref: '#/definitions/Error'
/auth/facebook:
get:
tags:
- Auth
responses:
302:
description: Redirects the user to perform external facebook authentication.
500:
description: An error occured.
schema:
$ref: '#/definitions/Error'
/queue/comments/pending:
get:
tags:
- Comments
- Moderation
responses:
200:
description: The comments that are not moderated.
schema:
type: array
items:
$ref: '#/definitions/ModerationAction'
- $ref: '#/definitions/Comment'
500:
description: An error occured.
schema:
$ref: '#/definitions/Error'
/stream:
get:
tags:
- Stream
description: Stream
- Actions
- Assets
- Comments
- Users
parameters:
- name: asset_id
- name: asset_url
in: query
description: Description
required: true
description: The asset url to get the comment stream from.
type: string
format: url
responses:
200:
description: OK
description: The comment stream.
schema:
type: array
items:
$ref: '#/definitions/Item'
type: object
properties:
assets:
type: array
items:
- $ref: '#/definitions/Asset'
comments:
type: array
items:
- $ref: '#/definitions/Comment'
users:
type: array
items:
- $ref: '#/definitions/User'
actions:
type: array
items:
- $ref: '#/definitions/Actions'
500:
description: An error occured.
schema:
$ref: '#/definitions/Error'
/settings:
get:
tags:
- Settings
description: Settings
responses:
200:
description: Get Setting
description: The settings.
schema:
type: array
items:
$ref: '#/definitions/Setting'
$ref: '#/definitions/Settings'
500:
description: An error occured.
schema:
$ref: '#/definitions/Error'
put:
tags:
- Settings
description: Settings
responses:
204:
description: OK
/user/request-password-reset:
post:
tags:
- Users
description: trigger a reset password email. sends a success code whether email was found or no.
responses:
204:
description: OK
/user/update-password:
post:
tags:
- Users
description: Update existing user password
parameters:
- name: token
type: string
in: body
description: JSON Web token taken taken from emailed link
required: true
- name: password
type: string
in: body
description: new password to be settings
required: true
responses:
204:
description: OK
/asset:
get:
tags:
- Asset
description: Get an asset by id.
responses:
200:
description: OK
put:
tags:
- Asset
description: Upsert an asset.
responses:
204:
description: OK
/asset?url={url}:
get:
tags:
- Asset
parameters:
- name: url
in: query
description: The url of the asset.
required: true
description: Get an asset by its url.
responses:
200:
description: OK
description: The settings were updated.
500:
description: An error occured.
schema:
$ref: '#/definitions/Error'
definitions:
Error:
type: object
properties:
message:
type: string
description: The error that occured.
Item:
type: object
ModerationAction:
@@ -314,5 +413,10 @@ definitions:
type: string
description: An array of the authors for this asset.
publication_date:
type: date
desctipion: When this asset was published.
type: string
format: datetime
description: When this asset was published.
User:
type: object
Settings:
type: object
@@ -173,7 +173,7 @@ describe('itemActions', () => {
fetchMock.delete('*', {});
return actions.deleteAction('abc', 'flag', '123', 'comments')(store.dispatch)
.then(response => {
expect(fetchMock.calls().matched[0][0]).to.equal('/api/v1/comments/abc/actions');
expect(fetchMock.calls().matched[0][0]).to.equal('/api/v1/actions/abc');
expect(response).to.deep.equal({});
});
});
+52 -103
View File
@@ -10,6 +10,7 @@ const expect = chai.expect;
chai.should();
chai.use(require('chai-http'));
const wordlist = require('../../../../services/wordlist');
const Comment = require('../../../../models/comment');
const Action = require('../../../../models/action');
const User = require('../../../../models/user');
@@ -64,13 +65,13 @@ describe('Get /comments', () => {
]);
});
it('should return all the comments', function(done){
chai.request(app)
it('should return all the comments', () => {
return chai.request(app)
.get('/api/v1/comments')
.end(function(err, res){
expect(err).to.be.null;
.then((res) => {
expect(res).to.have.status(200);
done();
});
});
});
@@ -122,48 +123,42 @@ describe('Get comments by status and action', () => {
]);
});
it('should return all the rejected comments', function(done){
chai.request(app)
.get('/api/v1/comments/status/rejected')
.end(function(err, res){
expect(err).to.be.null;
it('should return all the rejected comments', () => {
return chai.request(app)
.get('/api/v1/comments?status=rejected')
.then((res) => {
expect(res).to.have.status(200);
expect(res.body[0]).to.have.property('id', 'abc');
done();
});
});
it('should return all the approved comments', function(done){
chai.request(app)
.get('/api/v1/comments/status/accepted')
.end(function(err, res){
expect(err).to.be.null;
it('should return all the approved comments', () => {
return chai.request(app)
.get('/api/v1/comments?status=accepted')
.then((res) => {
expect(res).to.have.status(200);
expect(res.body[0]).to.have.property('id', 'hij');
done();
});
});
it('should return all the new comments', function(done){
chai.request(app)
.get('/api/v1/comments/status/new')
.end(function(err, res){
expect(err).to.be.null;
it('should return all the new comments', () => {
return chai.request(app)
.get('/api/v1/comments?status=new')
.then((res) => {
expect(res).to.have.status(200);
expect(res.body[0]).to.have.property('id', 'def');
done();
});
});
it('should return all the flagged comments', function(done){
chai.request(app)
.get('/api/v1/comments/action/flag')
.end(function(err, res){
it('should return all the flagged comments', () => {
return chai.request(app)
.get('/api/v1/comments?action_type=flag')
.then((res) => {
expect(res).to.have.status(200);
expect(err).to.be.null;
expect(res.body.length).to.equal(1);
expect(res.body[0]).to.have.property('id', 'abc');
done();
});
});
});
@@ -190,18 +185,31 @@ describe('Post /comments', () => {
beforeEach(() => {
return Promise.all([
User.createLocalUsers(users),
Action.create(actions)
Action.create(actions),
wordlist.insert([
'bad words'
])
]);
});
it('it should create a comment', function(done) {
chai.request(app)
it('should create a comment', () => {
return chai.request(app)
.post('/api/v1/comments')
.send({'body': 'Something body.', 'author_id': '123', 'asset_id': '1', 'parent_id': ''})
.end(function(err, res){
expect(res).to.have.status(200);
.then((res) => {
expect(res).to.have.status(201);
expect(res.body).to.have.property('id');
done();
});
});
it('should create a comment with a rejected status if it contains a bad word', () => {
return chai.request(app)
.post('/api/v1/comments')
.send({'body': 'bad words are the baddest', 'author_id': '123', 'asset_id': '1', 'parent_id': ''})
.then((res) => {
expect(res).to.have.status(201);
expect(res.body).to.have.property('id');
expect(res.body).to.have.property('status', 'rejected');
});
});
});
@@ -251,72 +259,14 @@ describe('Get /:comment_id', () => {
]);
});
it('should return the right comment for the comment_id', function(done){
chai.request(app)
it('should return the right comment for the comment_id', () => {
return chai.request(app)
.get('/api/v1/comments/abc')
.end(function(err, res){
expect(err).to.be.null;
.then((res) => {
expect(res).to.have.status(200);
expect(res).to.have.property('body');
expect(res.body).to.have.property('body', 'comment 10');
done();
});
});
});
describe('Put /:comment_id', () => {
const comments = [{
id: 'abc',
body: 'comment 10',
asset_id: 'asset',
author_id: '123'
}, {
id: 'def',
body: 'comment 20',
asset_id: 'asset',
author_id: '456'
}, {
id: 'hij',
body: 'comment 30',
asset_id: '456'
}];
const users = [{
displayName: 'Ana',
email: 'ana@gmail.com',
password: '123'
}, {
displayName: 'Maria',
email: 'maria@gmail.com',
password: '123'
}];
const actions = [{
action_type: 'flag',
item_id: 'abc'
}, {
action_type: 'like',
item_id: 'hij'
}];
beforeEach(() => {
return Promise.all([
Comment.create(comments),
User.createLocalUsers(users),
Action.create(actions)
]);
});
it('it should update comment', function(done) {
chai.request(app)
.post('/api/v1/comments/abc')
.send({'body': 'Something body.', 'author_id': '123', 'asset_id': '1', 'parent_id': ''})
.end(function(err, res){
expect(err).to.be.null;
expect(res).to.have.status(200);
expect(res.body).to.have.property('body', 'Something body.');
done();
});
});
});
@@ -369,7 +319,7 @@ describe('Remove /:comment_id', () => {
return chai.request(app)
.delete('/api/v1/comments/abc')
.then((res) => {
expect(res).to.have.status(201);
expect(res).to.have.status(204);
return Comment.findById('abc');
})
@@ -384,7 +334,7 @@ process.on('unhandledRejection', (reason) => {
console.error(reason);
});
describe('Post /:comment_id/status', () => {
describe('Put /:comment_id/status', () => {
const comments = [{
id: 'abc',
@@ -433,12 +383,11 @@ describe('Post /:comment_id/status', () => {
it('it should update status', function() {
return chai.request(app)
.post('/api/v1/comments/abc/status')
.put('/api/v1/comments/abc/status')
.send({status: 'accepted'})
.then((res) => {
expect(res).to.have.status(200);
expect(res).to.have.body;
expect(res.body).to.have.property('status', 'accepted');
expect(res).to.have.status(204);
expect(res.body).to.be.empty;
});
});
});
@@ -495,7 +444,7 @@ describe('Post /:comment_id/actions', () => {
.post('/api/v1/comments/abc/actions')
.send({'user_id': '456', 'action_type': 'flag'})
.then((res) => {
expect(res).to.have.status(200);
expect(res).to.have.status(201);
expect(res).to.have.body;
expect(res.body).to.have.property('item_type', 'comment');
expect(res.body).to.have.property('action_type', 'flag');
+119
View File
@@ -0,0 +1,119 @@
const expect = require('chai').expect;
const wordlist = require('../../services/wordlist');
describe('wordlist: services', () => {
before(() => wordlist.insert([
'BAD',
'bad',
'how to murder',
'how to kill'
]));
beforeEach(() => {
expect(wordlist.list).to.not.be.empty;
expect(wordlist.enabled).to.be.true;
});
describe('#init', () => {
it('has entries', () => {
expect(wordlist.list).to.not.be.empty;
expect(wordlist.enabled).to.be.true;
});
});
describe('#match', () => {
it('does match on a bad word', () => {
[
'how to kill',
'what is bad',
'bad',
'BAD.',
'how to murder',
'How To mUrDer'
].forEach((word) => {
expect(wordlist.match(word)).to.be.true;
});
});
it('does not match on a good word', () => {
[
'how to',
'kill',
'bads',
'how to be a great person?',
'how to not kill?'
].forEach((word) => {
expect(wordlist.match(word)).to.be.false;
});
});
});
describe('#filter', () => {
it('matches on bodies containing bad words', (done) => {
let req = {
body: {
content: 'how to kill?'
}
};
wordlist.filter('content')(req, {}, (err) => {
expect(err).to.be.undefined;
expect(req).to.have.property('wordlist');
expect(req.wordlist).to.have.property('matched');
expect(req.wordlist.matched).to.be.equal(wordlist.ErrContainsProfanity);
done();
});
});
it('does not match on bodies not containing bad words', (done) => {
let req = {
body: {
content: 'how to be a great person?'
}
};
wordlist.filter('content')(req, {}, (err) => {
expect(err).to.be.undefined;
expect(req).to.have.property('wordlist');
expect(req.wordlist).to.have.property('matched');
expect(req.wordlist.matched).to.be.false;
done();
});
});
it('does not match on bodies not containing the bad word field', (done) => {
let req = {
body: {
author: 'how to kill?',
content: 'how to be a great person?'
}
};
wordlist.filter('content')(req, {}, (err) => {
expect(err).to.be.undefined;
expect(req).to.have.property('wordlist');
expect(req.wordlist).to.have.property('matched');
expect(req.wordlist.matched).to.be.false;
done();
});
});
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
<!-- extremely naive implementation of a password reset email -->
<p>We received a request to reset your password. If you did not request this change, you can ignore this email.<br />
If you did, <a href="<%= rootURL %>/admin/password-reset/<%= token %>">please click here to reset password</a>.</p>
If you did, <a href="<%= rootURL %>/admin/password-reset#<%= token %>">please click here to reset password</a>.</p>
<% if (process.env.NODE_ENV !== 'production') { %>
<p style="color: red"><%= token %></p>
<% } %>
+135
View File
@@ -0,0 +1,135 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="initial-scale=1, maximum-scale=1">
<title>Password Reset</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<link rel="stylesheet" href="https://code.getmdl.io/1.2.1/material.indigo-pink.min.css">
<style media="screen">
body, #root {
width: 100%;
height: 100%;
margin: 0;
background: #fff;
}
#root form {
max-width: 300px;
border: 1px solid lightgrey;
box-shadow: 0px 10px 24px 2px rgba(0,0,0,0.2);
margin: 50px auto;
padding: 15px;
}
.legend {
text-align: center;
width: 100%;
font-weight: bold;
}
label {
display: block;
margin-top: 10px;
margin-bottom: 3px;
padding-right: 30px;
}
small {
color: #888;
}
input {
border-radius: 4px;
margin-top: 3px;
border: 1px solid lightgrey;
font-size: 16px;
width: 100%;
padding: 14px;
height: 100%;
display: inline-block;
}
.submit-password-reset {
border-radius: 4px;
border: none;
display: block;
background-color: #333;
color: white;
text-align: center;
width: 100%;
padding: 10px;
margin-top: 10px;
cursor: pointer;
}
.error-console {
display: none;
margin-top: 10px;
border-radius: 4px;
background-color: pink;
color: red;
border: 1px solid red;
padding: 10px;
}
.error-console.active {
display: block;
}
</style>
</head>
<body>
<div id="root">
<form id="reset-password-form">
<legend class="legend">Set new password</legend>
<label for="password">
New password
<input type="password" name="password" placeholder="new password" />
<p><small>Password must be at least 8 characters</small></p>
</label>
<label for="confirm-password">
Confirm password
<input type="password" name="confirm-password" placeholder="confirm password" />
</label>
<button class="submit-password-reset" type="submit">Apply</button>
<div class="error-console">foo</div>
</form>
</div>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.1.1/jquery.min.js"></script>
<script>
$(function () {
function showError(message) {
$('.error-console').text(message).addClass('active');
}
function handleSubmit (e) {
e.preventDefault();
$('.error-console').removeClass('active');
var password = $('[name="password"]').val();
var confirm = $('[name="confirm-password"]').val();
if (password !== confirm || password === '' || password.length < 8) {
showError('passwords must match and be 8 characters.');
return false;
}
$.ajax({
url: '/api/v1/user/update-password',
contentType: 'application/json',
method: 'POST',
data: JSON.stringify({password: password, token: location.hash.replace('#', '')})
}).then(function (success) {
location.href = '<%= redirectUri %>';
}).catch(function (error) {
showError(error.responseText);
});
}
$('#reset-password-form').on('submit', handleSubmit);
});
</script>
</body>
</html>