Merge branch 'master' into fix-cli-user-create

This commit is contained in:
Kim Gardner
2018-04-24 13:07:38 -04:00
committed by GitHub
27 changed files with 640 additions and 27 deletions
@@ -71,7 +71,6 @@ class OrganizationSettings extends React.Component {
await this.props.savePending();
this.disableEditing();
};
displayErrors = (errors = []) => (
<ul className={styles.errorList}>
{errors.map((errKey, i) => (
+2 -1
View File
@@ -25,6 +25,7 @@ export default {
'UnsuspendUserResponse',
'UpdateAssetSettingsResponse',
'UpdateAssetStatusResponse',
'UpdateSettingsResponse'
'UpdateSettingsResponse',
'ChangePasswordResponse'
),
};
@@ -623,6 +623,27 @@ export const withUpdateSettings = withMutation(
}
);
export const withChangePassword = withMutation(
gql`
mutation ChangePassword($input: ChangePasswordInput!) {
changePassword(input: $input) {
...ChangePasswordResponse
}
}
`,
{
props: ({ mutate }) => ({
changePassword: input => {
return mutate({
variables: {
input,
},
});
},
}),
}
);
export const withUpdateAssetSettings = withMutation(
gql`
mutation UpdateAssetSettings($id: ID!, $input: AssetSettingsInput!) {
+39
View File
@@ -9,6 +9,7 @@ const {
SET_USER_SUSPENSION_STATUS,
UPDATE_USER_ROLES,
DELETE_USER,
CHANGE_PASSWORD,
} = require('../../perms/constants');
const setUserUsernameStatus = async (ctx, id, status) => {
@@ -143,6 +144,38 @@ const delUser = async (ctx, id) => {
await user.remove();
};
const changeUserPassword = async (ctx, oldPassword, newPassword) => {
const {
user,
loaders: { Settings },
connectors: { services: { I18n } },
} = ctx;
// Verify the old password.
const validPassword = await user.verifyPassword(oldPassword);
if (!validPassword) {
throw new ErrNotAuthorized();
}
// Change the users password now.
await Users.changePassword(user.id, newPassword);
// Get some context for the email to be sent.
const { organizationName, organizationContactEmail } = await Settings.load([
'organizationName',
'organizationContactEmail',
]);
// Send the password change email.
await Users.sendEmail(user, {
template: 'plain',
locals: {
body: I18n.t('email.password_change.body', organizationContactEmail),
},
subject: I18n.t('email.password_change.subject', organizationName),
});
};
module.exports = ctx => {
let mutators = {
User: {
@@ -155,6 +188,7 @@ module.exports = ctx => {
setUsername: () => Promise.reject(new ErrNotAuthorized()),
stopIgnoringUser: () => Promise.reject(new ErrNotAuthorized()),
del: () => Promise.reject(new ErrNotAuthorized()),
changePassword: () => Promise.reject(new ErrNotAuthorized()),
},
};
@@ -194,6 +228,11 @@ module.exports = ctx => {
if (ctx.user.can(DELETE_USER)) {
mutators.User.del = id => delUser(ctx, id);
}
if (ctx.user.can(CHANGE_PASSWORD)) {
mutators.User.changePassword = ({ oldPassword, newPassword }) =>
changeUserPassword(ctx, oldPassword, newPassword);
}
}
return mutators;
+3
View File
@@ -139,6 +139,9 @@ const RootMutation = {
delUser: async (_, { id }, { mutators: { User } }) => {
await User.del(id);
},
changePassword: async (_, { input }, { mutators: { User } }) => {
await User.changePassword(input);
},
};
module.exports = RootMutation;
+19
View File
@@ -1442,6 +1442,21 @@ type DelUserResponse implements Response {
errors: [UserError!]
}
input ChangePasswordInput {
# oldPassword is the previous password set on the account. An incorrect
# password here will result in an unauthorized error being thrown.
oldPassword: String!
# newPassword is the password we're changing it to.
newPassword: String!
}
type ChangePasswordResponse implements Response {
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
}
# All mutations for the application are defined on this object.
type RootMutation {
@@ -1542,6 +1557,10 @@ type RootMutation {
# delUser will delete the user with the specified id.
delUser(id: ID!): DelUserResponse
# changePassword allows the current user to change their password that have an
# associated local user account.
changePassword(input: ChangePasswordInput!): ChangePasswordResponse
}
type UsernameChangedPayload {
+3
View File
@@ -218,6 +218,9 @@ en:
we_received_a_request: "We received a request to reset your password. If you did not request this change, you can ignore this email."
if_you_did: "If you did,"
please_click: "please click here to reset password"
password_change:
subject: "{0} password change"
body: "The password on your account has been changed.\n\nIf you did not request this change, please contact us at {0}."
embedlink:
copy: "Copy to Clipboard"
error:
+1
View File
@@ -19,4 +19,5 @@ module.exports = {
UPDATE_ASSET_STATUS: 'UPDATE_ASSET_STATUS',
UPDATE_SETTINGS: 'UPDATE_SETTINGS',
DELETE_USER: 'DELETE_USER',
CHANGE_PASSWORD: 'CHANGE_PASSWORD',
};
+9
View File
@@ -1,8 +1,17 @@
const { isString } = require('lodash');
const { check } = require('../utils');
const types = require('../constants');
module.exports = (user, perm) => {
switch (perm) {
case types.CHANGE_PASSWORD:
// Only users with a local account where they have a password set can
// actually change their password.
return (
user.profiles.some(({ provider }) => provider === 'local') &&
isString(user.password) &&
user.password.length > 0
);
case types.CHANGE_USERNAME:
return user.status.username.status === 'REJECTED';
+1
View File
@@ -25,5 +25,6 @@ export {
withUnbanUser,
withStopIgnoringUser,
withSetCommentStatus,
withChangePassword,
} from 'coral-framework/graphql/mutations';
export { compose } from 'recompose';
+2
View File
@@ -4,6 +4,7 @@ import SetUsernameDialog from './stream/containers/SetUsernameDialog';
import translations from './translations.yml';
import Login from './login/containers/Main';
import reducer from './login/reducer';
import ChangePassword from './profile-settings/containers/ChangePassword';
export default {
reducer,
@@ -11,5 +12,6 @@ export default {
slots: {
stream: [UserBox, SignInButton, SetUsernameDialog],
login: [Login],
profileSettings: [ChangePassword],
},
};
@@ -0,0 +1,87 @@
.container {
position: relative;
color: #202020;
padding: 10px;
border-radius: 2px;
border: solid 1px transparent;
box-sizing: border-box;
justify-content: space-between;
&.editing {
border-color: #979797;
background-color: #EDEDED;
}
}
.actions {
position: absolute;
top: 10px;
right: 10px;
display: flex;
flex-direction: column;
align-items: center;
}
.title {
color: #202020;
margin: 0 0 20px;
}
.detailBottomBox {
display: block;
padding-top: 4px;
text-align: right;
width: 280px;
}
.detailLink {
color: #00538A;
text-decoration: none;
font-size: 0.9em;
&:hover {
cursor: pointer;
}
}
.button {
border: 1px solid #787d80;
background-color: transparent;
height: 30px;
font-size: 1em;
line-height: normal;
}
.saveButton {
background-color: #3498DB;
border-color: #3498DB;
color: white;
> i {
font-size: 17px;
}
&:hover {
background-color: #399ee2;
color: white;
}
&:disabled {
border-color: #e0e0e0;
&:hover {
background-color: #e0e0e0;
color: #4f5c67;
cursor: default;
}
}
}
.cancelButton {
color:#787D80;
margin-top: 6px;
font-size: 0.9em;
&:hover {
cursor: pointer;
}
}
@@ -0,0 +1,224 @@
import React from 'react';
import PropTypes from 'prop-types';
import cn from 'classnames';
import styles from './ChangePassword.css';
import { Button } from 'plugin-api/beta/client/components/ui';
import validate from 'coral-framework/helpers/validate';
import errorMsj from 'coral-framework/helpers/error';
import isEqual from 'lodash/isEqual';
import { t } from 'plugin-api/beta/client/services';
import Form from './Form';
import InputField from './InputField';
import { getErrorMessages } from 'coral-framework/utils';
const initialState = {
editing: false,
showErrors: true,
errors: {},
formData: {},
};
class ChangePassword extends React.Component {
state = initialState;
validKeys = ['oldPassword', 'newPassword', 'confirmNewPassword'];
onChange = e => {
const { name, value, type } = e.target;
this.setState(
state => ({
formData: {
...state.formData,
[name]: value,
},
}),
() => {
this.fieldValidation(value, type, name);
// Perform equality validation if password fields have changed
if (name === 'newPassword' || name === 'confirmNewPassword') {
this.equalityValidation('newPassword', 'confirmNewPassword');
}
}
);
};
equalityValidation = (field, field2) => {
const cond = this.state.formData[field] === this.state.formData[field2];
if (!cond) {
this.addError({
[field2]: t('talk-plugin-auth.change_password.passwords_dont_match'),
});
} else {
this.removeError(field2);
}
return cond;
};
fieldValidation = (value, type, name) => {
if (!value.length) {
this.addError({
[name]: t('talk-plugin-auth.change_password.required_field'),
});
} else if (!validate[type](value)) {
this.addError({ [name]: errorMsj[type] });
} else {
this.removeError(name);
}
};
hasError = err => {
return Object.keys(this.state.errors).indexOf(err) !== -1;
};
addError = err => {
this.setState(({ errors }) => ({
errors: { ...errors, ...err },
}));
};
removeError = errKey => {
this.setState(state => {
const { [errKey]: _, ...errors } = state.errors;
return {
errors,
};
});
};
enableEditing = () => {
this.setState({
editing: true,
});
};
isSubmitBlocked = () => {
const formHasErrors = !!Object.keys(this.state.errors).length;
const formIncomplete = !isEqual(
Object.keys(this.state.formData),
this.validKeys
);
return formHasErrors || formIncomplete;
};
clearForm = () => {
this.setState(initialState);
};
onSave = async () => {
const { oldPassword, newPassword } = this.state.formData;
try {
await this.props.changePassword({
oldPassword,
newPassword,
});
this.props.notify(
'success',
t('talk-plugin-auth.change_password.changed_password_msg')
);
} catch (err) {
this.props.notify('error', getErrorMessages(err));
}
this.clearForm();
this.disableEditing();
};
disableEditing = () => {
this.setState({
editing: false,
});
};
cancel = () => {
this.clearForm();
this.disableEditing();
};
render() {
const { editing, errors } = this.state;
return (
<section
className={cn('talk-plugin-auth--change-password', styles.container, {
[styles.editing]: editing,
})}
>
<h3 className={styles.title}>
{t('talk-plugin-auth.change_password.change_password')}
</h3>
{editing && (
<Form className="talk-plugin-auth--change-password-form">
<InputField
id="oldPassword"
label="Old Password"
name="oldPassword"
type="password"
onChange={this.onChange}
value={this.state.formData.oldPassword}
hasError={this.hasError('oldPassword')}
errorMsg={errors['oldPassword']}
showErrors
>
<span className={styles.detailBottomBox}>
<a className={styles.detailLink}>
{t('talk-plugin-auth.change_password.forgot_password')}
</a>
</span>
</InputField>
<InputField
id="newPassword"
label="New Password"
name="newPassword"
type="password"
onChange={this.onChange}
value={this.state.formData.newPassword}
hasError={this.hasError('newPassword')}
errorMsg={errors['newPassword']}
showErrors
/>
<InputField
id="confirmNewPassword"
label="Confirm New Password"
name="confirmNewPassword"
type="password"
onChange={this.onChange}
value={this.state.formData.confirmNewPassword}
hasError={this.hasError('confirmNewPassword')}
errorMsg={errors['confirmNewPassword']}
showErrors
/>
</Form>
)}
{editing ? (
<div className={styles.actions}>
<Button
className={cn(styles.button, styles.saveButton)}
icon="save"
onClick={this.onSave}
disabled={this.isSubmitBlocked()}
>
{t('talk-plugin-auth.change_password.save')}
</Button>
<a className={styles.cancelButton} onClick={this.cancel}>
{t('talk-plugin-auth.change_password.cancel')}
</a>
</div>
) : (
<div className={styles.actions}>
<Button className={styles.button} onClick={this.enableEditing}>
{t('talk-plugin-auth.change_password.edit')}
</Button>
</div>
)}
</section>
);
}
}
ChangePassword.propTypes = {
changePassword: PropTypes.func.isRequired,
notify: PropTypes.func.isRequired,
};
export default ChangePassword;
@@ -0,0 +1,9 @@
.errorMsg {
color: #FA4643;
padding-left: 4px;
font-size: 0.9em;
}
.warningIcon {
color: #FA4643;
}
@@ -0,0 +1,17 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './ErrorMessage.css';
import { Icon } from 'plugin-api/beta/client/components/ui';
const ErrorMessage = ({ children }) => (
<div className={styles.errorMsg}>
<Icon className={styles.warningIcon} name="warning" />
<span>{children}</span>
</div>
);
ErrorMessage.propTypes = {
children: PropTypes.node,
};
export default ErrorMessage;
@@ -0,0 +1,5 @@
.detailList {
padding: 0;
margin: 0;
list-style: none;
}
@@ -0,0 +1,16 @@
import React from 'react';
import styles from './Form.css';
import PropTypes from 'prop-types';
const Form = ({ children, className = '' }) => (
<form className={className}>
<ul className={styles.detailList}>{children}</ul>
</form>
);
Form.propTypes = {
className: PropTypes.string,
children: PropTypes.node,
};
export default Form;
@@ -0,0 +1,51 @@
.detailItem {
margin-bottom: 12px;
}
.detailItemContainer {
display: flex;
}
.detailItemContent {
min-width: 280px;
}
.detailLabel {
color: #4C4C4D;
font-size: 1em;
display: block;
margin-bottom: 4px;
}
.detailValue {
padding: 6px 2px;
border: solid 1px #979797;
display: block;
font-size: 1.1em;
border-radius: 2px;
background-color: #ffffff;
color: #979797;
box-sizing: border-box;
width: 100%;
}
.detailItemMessage {
flex-grow: 1;
display: flex;
align-items: center;
padding-left: 2px;
padding-top: 16px;
.warningIcon, .checkIcon {
font-size: 17px;
}
}
.checkIcon {
color: #00CD73;
}
.warningIcon {
color: #FA4643;
}
@@ -0,0 +1,60 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './InputField.css';
import ErrorMessage from './ErrorMessage';
import { Icon } from 'plugin-api/beta/client/components/ui';
const InputField = ({
id = '',
label = '',
type = 'text',
name = '',
onChange = () => {},
value = '',
showError = true,
hasError = false,
errorMsg = '',
children,
}) => {
return (
<li className={styles.detailItem}>
<div className={styles.detailItemContainer}>
<div className={styles.detailItemContent}>
<label className={styles.detailLabel} id={id}>
{label}
</label>
<input
id={id}
type={type}
name={name}
className={styles.detailValue}
onChange={onChange}
value={value}
autoComplete="off"
/>
</div>
<div className={styles.detailItemMessage}>
{!hasError &&
value && <Icon className={styles.checkIcon} name="check_circle" />}
{hasError && showError && <ErrorMessage>{errorMsg}</ErrorMessage>}
</div>
</div>
{children}
</li>
);
};
InputField.propTypes = {
id: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
type: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
onChange: PropTypes.func,
value: PropTypes.string,
showError: PropTypes.bool,
hasError: PropTypes.bool,
errorMsg: PropTypes.string,
children: PropTypes.node,
};
export default InputField;
@@ -0,0 +1,12 @@
import { compose } from 'react-apollo';
import { bindActionCreators } from 'redux';
import { connect } from 'plugin-api/beta/client/hocs';
import ChangePassword from '../components/ChangePassword';
import { notify } from 'coral-framework/actions/notification';
import { withChangePassword } from 'plugin-api/beta/client/hocs';
const mapDispatchToProps = dispatch => bindActionCreators({ notify }, dispatch);
export default compose(connect(null, mapDispatchToProps), withChangePassword)(
ChangePassword
);
@@ -131,6 +131,15 @@ en:
username: Username
write_your_username: "Edit your username"
your_username: "Your username appears on every comment you post."
change_password:
change_password: "Change Password"
passwords_dont_match: "Passwords don`t match"
required_field: "This field is required"
forgot_password: "Forgot your password?"
save: "Save"
cancel: "Cancel"
edit: "Edit"
changed_password_msg: "Changed Password - Your password has been successfully changed"
de:
talk-plugin-auth:
login:
@@ -222,6 +231,15 @@ es:
username: Nombre
write_your_username: "Edita tu nombre"
your_username: "Tu nombre aparece en cada comentario que publiques."
change_password:
change_password: "Cambiar Contraseña"
passwords_dont_match: "Las contraseñas no coinciden"
required_field: "Este campo es requerido"
forgot_password: "Olvidaste tu contraseña?"
save: "Guardar"
cancel: "Cancelar"
edit: "Editar"
changed_password_msg: "Contraseña Actualizada - Tu contraseña ha sido exitosamente actualizada"
fr:
talk-plugin-auth:
login:
@@ -3,9 +3,9 @@
<head>
<meta name="viewport" content="initial-scale=1, maximum-scale=1">
<title><%= t('talk-plugin-notifications.unsubscribe_page.unsubscribe') %></title>
<%- include(root + '/partials/head') %>
<link rel="stylesheet" href="https://code.getmdl.io/1.2.1/material.indigo-pink.min.css">
<link rel="stylesheet" href="<%= BASE_PATH %>public/css/admin.css">
<%- include(root + '/partials/head') %>
</head>
<body class="confirm-email-page">
<div id="root">
+3 -12
View File
@@ -109,20 +109,11 @@ router.put(
async (req, res, next) => {
const { token, password } = req.body;
if (!password || password.length < 8) {
return next(errors.ErrPasswordTooShort);
}
try {
let [user, redirect] = await UsersService.verifyPasswordResetToken(token);
// Change the users' password.
await UsersService.changePassword(user.id, password);
const { redirect } = await UsersService.resetPassword(token, password);
res.json({ redirect });
} catch (e) {
console.error(e);
return next(errors.ErrNotAuthorized);
} catch (err) {
return next(err);
}
}
);
+34 -9
View File
@@ -132,7 +132,7 @@ class Users {
locals: {
body: message,
},
subject: 'Your account has been suspended',
subject: 'Your account has been suspended', // TODO: replace with translation
});
}
@@ -490,6 +490,10 @@ class Users {
}
static async changePassword(id, password) {
if (!password || password.length < 8) {
throw new ErrPasswordTooShort();
}
const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS);
return User.update(
@@ -725,18 +729,13 @@ class Users {
});
}
/**
* Verifies a jwt and returns the associated user. Throws an error when the
* token isn't valid.
*
* @param {String} token the JSON Web Token to verify
*/
// TODO: update doc
static async verifyPasswordResetToken(token) {
if (!token) {
throw new Error('cannot verify an empty token');
}
const { userId, loc, version } = await Users.verifyToken(token, {
const { userId, loc: redirect, version } = await Users.verifyToken(token, {
subject: PASSWORD_RESET_JWT_SUBJECT,
});
@@ -746,7 +745,33 @@ class Users {
throw new Error('password reset token has expired');
}
return [user, loc];
return { user, redirect, version };
}
// TODO: update doc
static async resetPassword(token, password) {
const { user, redirect, version } = await this.verifyPasswordResetToken(
token
);
if (!password || password.length < 8) {
throw new ErrPasswordTooShort();
}
const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS);
// Update the user's password.
await User.update(
{ id: user.id, __v: version },
{
$inc: { __v: 1 },
$set: {
password: hashedPassword,
},
}
);
return { user, redirect };
}
/**
+1 -1
View File
@@ -11,8 +11,8 @@
<link rel="stylesheet" href="https://code.getmdl.io/1.2.1/material.min.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<link href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet">
<link rel="stylesheet" type="text/css" href="<%= resolve('coral-admin/bundle.css') %>">
<%- include partials/head %>
<link rel="stylesheet" type="text/css" href="<%= resolve('coral-admin/bundle.css') %>">
</head>
<body class="admin-page">
<div id="root"></div>
+1 -1
View File
@@ -2,9 +2,9 @@
<html>
<head>
<meta name="viewport" content="width=device-width, user-scalable=no">
<%- include ../partials/head %>
<link rel="stylesheet" type="text/css" href="<%= resolve('embed/stream/default.css') %>">
<link rel="stylesheet" type="text/css" href="<%= resolve('embed/stream/bundle.css') %>">
<%- include ../partials/head %>
</head>
<body class="embed-stream-page">
<div id="talk-embed-stream-container"></div>
+1 -1
View File
@@ -4,8 +4,8 @@
<meta name="viewport" content="width=device-width, user-scalable=no">
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
<link href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet">
<link rel="stylesheet" type="text/css" href="<%= resolve('coral-login/bundle.css') %>">
<%- include partials/head %>
<link rel="stylesheet" type="text/css" href="<%= resolve('coral-login/bundle.css') %>">
</head>
<body>
<div id="talk-login-container"></div>