This commit is contained in:
Mendel Konikov
2018-05-10 20:53:41 -04:00
171 changed files with 4602 additions and 965 deletions
+6
View File
@@ -14,3 +14,9 @@ utilize our internal authentication system.
To sync Talk auth with your own auth systems, you can use this plugin as a
template.
## GDPR Compliance
In order to facilitate compliance with the
[EU General Data Protection Regulation (GDPR)](https://www.eugdpr.org/), you
should review our [GDPR Compliance](/talk/integrating/gdpr/) guidelines.
-4
View File
@@ -4,8 +4,6 @@ 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';
import ChangeUsername from './profile-settings/containers/ChangeUsername';
export default {
reducer,
@@ -13,7 +11,5 @@ export default {
slots: {
stream: [UserBox, SignInButton, SetUsernameDialog],
login: [Login],
profileHeader: [ChangeUsername],
profileSettings: [ChangePassword],
},
};
@@ -1,188 +0,0 @@
import React from 'react';
import cn from 'classnames';
import PropTypes from 'prop-types';
import styles from './ChangeUsername.css';
import { Button } from 'plugin-api/beta/client/components/ui';
import ChangeUsernameDialog from './ChangeUsernameDialog';
import { t } from 'plugin-api/beta/client/services';
import InputField from './InputField';
import { getErrorMessages } from 'coral-framework/utils';
import { canUsernameBeUpdated } from 'coral-framework/utils/user';
const initialState = {
editing: false,
showDialog: false,
formData: {},
};
class ChangeUsername extends React.Component {
state = initialState;
clearForm = () => {
this.setState(initialState);
};
enableEditing = () => {
this.setState({
editing: true,
});
};
disableEditing = () => {
this.setState({
editing: false,
});
};
cancel = () => {
this.clearForm();
this.disableEditing();
};
showDialog = () => {
this.setState({
showDialog: true,
});
};
onSave = async () => {
this.showDialog();
};
saveChanges = async () => {
const { newUsername } = this.state.formData;
const { changeUsername } = this.props;
try {
await changeUsername(newUsername);
this.props.notify(
'success',
t('talk-plugin-auth.change_username.changed_username_success_msg')
);
} catch (err) {
this.props.notify('error', getErrorMessages(err));
}
this.clearForm();
this.disableEditing();
};
onChange = e => {
const { name, value } = e.target;
this.setState(state => ({
formData: {
...state.formData,
[name]: value,
},
}));
};
closeDialog = () => {
this.setState({
showDialog: false,
});
};
render() {
const {
username,
emailAddress,
root: { me: { state: { status } } },
notify,
} = this.props;
const { editing, formData, showDialog } = this.state;
return (
<section
className={cn('talk-plugin-auth--edit-profile', styles.container, {
[styles.editing]: editing,
})}
>
<ChangeUsernameDialog
canUsernameBeUpdated={canUsernameBeUpdated(status)}
showDialog={showDialog}
onChange={this.onChange}
formData={formData}
username={username}
closeDialog={this.closeDialog}
saveChanges={this.saveChanges}
notify={notify}
/>
{editing ? (
<div className={styles.content}>
<div className={styles.detailList}>
<InputField
icon="person"
id="newUsername"
name="newUsername"
onChange={this.onChange}
defaultValue={username}
columnDisplay
validationType="username"
>
<span className={styles.bottomText}>
{t('talk-plugin-auth.change_username.change_username_note')}
</span>
</InputField>
<InputField
icon="email"
id="email"
name="email"
value={emailAddress}
validationType="username"
disabled
/>
</div>
</div>
) : (
<div className={styles.content}>
<h2 className={styles.username}>{username}</h2>
{emailAddress ? (
<p className={styles.email}>{emailAddress}</p>
) : null}
</div>
)}
{editing ? (
<div className={styles.actions}>
<Button
className={cn(styles.button, styles.saveButton)}
icon="save"
onClick={this.onSave}
disabled={
!this.state.formData.newUsername ||
this.state.formData.newUsername === username
}
>
{t('talk-plugin-auth.change_username.save')}
</Button>
<a className={styles.cancelButton} onClick={this.cancel}>
{t('talk-plugin-auth.change_username.cancel')}
</a>
</div>
) : (
<div className={styles.actions}>
<Button
className={styles.button}
icon="settings"
onClick={this.enableEditing}
>
{t('talk-plugin-auth.change_username.edit_profile')}
</Button>
</div>
)}
</section>
);
}
}
ChangeUsername.propTypes = {
root: PropTypes.object.isRequired,
changeUsername: PropTypes.func.isRequired,
notify: PropTypes.func.isRequired,
username: PropTypes.string,
emailAddress: PropTypes.string,
};
export default ChangeUsername;
@@ -1,12 +0,0 @@
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
);
@@ -1,12 +0,0 @@
import { compose } from 'react-apollo';
import { bindActionCreators } from 'redux';
import { connect } from 'plugin-api/beta/client/hocs';
import ChangeUsername from '../components/ChangeUsername';
import { notify } from 'coral-framework/actions/notification';
import { withChangeUsername } from 'plugin-api/beta/client/hocs';
const mapDispatchToProps = dispatch => bindActionCreators({ notify }, dispatch);
export default compose(connect(null, mapDispatchToProps), withChangeUsername)(
ChangeUsername
);
@@ -154,8 +154,20 @@ en:
bottom_note: "Note: You will not be able to change your username again for 14 days"
confirm_changes: "Confirm Changes"
username_does_not_match: "Username does not match"
changed_username_success_msg: "Username Changed - Your username has been successfully changed. You will not be able to change your user name for 14 days."
cant_be_equal: "Your new {0} must be different to your current one"
change_username_attempt: "Username can't be updated. Usernames can be changed every 14 days"
change_email:
confirm_email_change: "Confirm Email Address Change"
description: "You are attempting to change your email address. Your new email address will be used for your login and to receive account notifications."
old_email: "Old Email Address"
new_email: "New Email Address"
enter_password: "Enter Password"
incorrect_password: "Incorrect Password"
confirm_change: "Confirm Change"
cancel: "Cancel"
change_email_msg: "Email Address Changed - Your email address has been successfully changed. This email address will now be used for signing in and email notifications."
changed_username_success_msg: "Username Changed - Your username has been successfully changed. You will not be able to change your user name for 14 days."
change_username_attempt: "Username can't be updated. Usernames can only be changed every 14 days."
de:
talk-plugin-auth:
login:
+7 -1
View File
@@ -27,4 +27,10 @@ Configuration:
or by visiting the
[Creating an App ID](https://developers.facebook.com/docs/apps/register)
guide. This is only required while the `talk-plugin-facebook-auth` plugin is
enabled.
enabled.
## GDPR Compliance
In order to facilitate compliance with the
[EU General Data Protection Regulation (GDPR)](https://www.eugdpr.org/), you
should review our [GDPR Compliance](/talk/integrating/gdpr/) guidelines.
@@ -0,0 +1,8 @@
.errorMsg {
color: #FA4643;
font-size: 0.9em;
}
.warningIcon {
color: #FA4643;
}
@@ -43,7 +43,7 @@ const InputField = ({
<div
className={cn(
styles.detailItemContent,
{ [styles.error]: hasError },
{ [styles.error]: hasError && showError },
{ [styles.disabled]: disabled }
)}
>
@@ -27,3 +27,9 @@ Configuration:
- `TALK_GOOGLE_CLIENT_SECRET` (**required**) - The Google OAuth2 client ID for
your Google login web app. You can learn more about getting a Google Client
ID at the [Google API Console](https://console.developers.google.com/apis/).
## GDPR Compliance
In order to facilitate compliance with the
[EU General Data Protection Regulation (GDPR)](https://www.eugdpr.org/), you
should review our [GDPR Compliance](/talk/integrating/gdpr/) guidelines.
@@ -10,16 +10,22 @@ import { bindActionCreators } from 'redux';
import { closeMenu } from 'plugins/talk-plugin-author-menu/client/actions';
import { notify } from 'plugin-api/beta/client/actions/notification';
import { t } from 'plugin-api/beta/client/services';
import { getErrorMessages } from 'coral-framework/utils';
class IgnoreUserConfirmationContainer extends React.Component {
ignoreUser = () => {
ignoreUser = async () => {
const { ignoreUser, notify, comment, closeMenu } = this.props;
ignoreUser(comment.user.id).then(() => {
try {
await ignoreUser(comment.user.id);
notify(
'success',
t('talk-plugin-ignore-user.notify_success', comment.user.username)
);
});
} catch (err) {
notify('error', getErrorMessages(err));
}
closeMenu();
};
+26
View File
@@ -0,0 +1,26 @@
---
title: talk-plugin-local-auth
permalink: /plugin/talk-plugin-local-auth/
layout: plugin
plugin:
name: talk-plugin-local-auth
default: true
provides:
- Client
- Server
---
This plugin will eventually contain all the local authentication code that is
responsible for creating, resetting, and managing accounts provided locally
through an email and password based login.
## Features
- *Email Change*: Allows users to change their existing email address on their account.
- *Local Account Association*: Allows users that have signed up with an external auth strategy (such as Google) the ability to associate a email address and password for login. **Note: Existing users with external authentication will be prompted to setup a local account when they sign in and when new users create an account.**
## GDPR Compliance
In order to facilitate compliance with the
[EU General Data Protection Regulation (GDPR)](https://www.eugdpr.org/), you
should review our [GDPR Compliance](/talk/integrating/gdpr/) guidelines.
@@ -0,0 +1,3 @@
{
"extends": "@coralproject/eslint-config-talk/client"
}
@@ -0,0 +1,82 @@
.dialog {
border: none;
box-shadow: 0 9px 46px 8px rgba(0, 0, 0, 0.14), 0 11px 15px -7px rgba(0, 0, 0, 0.12), 0 24px 38px 3px rgba(0, 0, 0, 0.2);
width: 320px;
top: 50%;
transform: translateY(-50%);
padding: 20px;
border-radius: 4px;
font-family: Helvetica,Helvetica Neue,Verdana,sans-serif;
color:#3B4A53;
}
.title {
font-size: 1.3em;
margin: 15px 0;
text-align: center;
}
.description {
font-size: 1em;
line-height: 20px;
margin: 0;
margin-bottom: 15px;
}
.list {
padding: 0;
margin: 20px 0;
list-style: none;
}
.item {
display: flex;
margin-bottom: 20px;
.itemIcon {
flex-grow: 0;
}
.text {
flex-grow: 1;
padding-left: 10px;
}
> i.itemIcon {
font-size: 1.3em;
}
}
.button {
color: #787D80;
border-radius: 2px;
background-color: transparent;
height: 30px;
font-size: 0.9em;
line-height: normal;
width: 100%;
display: inline-block;
text-align: center;
line-height: 30px;
font-size: 1em;
&:hover {
background-color: #eaeaea;
cursor: pointer;
}
&.cancel {
background-color: transparent;
color: #787D80;
}
&.proceed {
background-color: #3498DB;
color: white;
}
&.danger {
background-color: #FA4643;
color: white;
}
}
@@ -0,0 +1,166 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Dialog } from 'plugin-api/beta/client/components/ui';
import validate from 'coral-framework/helpers/validate';
import { getErrorMessages } from 'coral-framework/utils';
import styles from './AddEmailAddressDialog.css';
import { t } from 'plugin-api/beta/client/services';
import AddEmailContent from './AddEmailContent';
import VerifyEmailAddress from './VerifyEmailAddress';
import EmailAddressAdded from './EmailAddressAdded';
const initialState = {
step: 0,
showErrors: false,
errors: {},
formData: {
emailAddress: '',
confirmPassword: '',
confirmEmailAddress: '',
},
};
const validateRequired = v =>
v ? '' : t('talk-plugin-local-auth.add_email.required_field');
const validateRepeat = (key, msg) => (v, data) => (v !== data[key] ? msg : '');
const validateEmail = v =>
validateRequired(v) || !validate.email(v)
? t('talk-plugin-local-auth.add_email.invalid_email_address')
: '';
const validatePassword = v => validateRequired(v);
class AddEmailAddressDialog extends React.Component {
state = initialState;
fields = {
emailAddress: validateEmail,
confirmPassword: validatePassword,
confirmEmailAddress: validateRepeat(
'emailAddress',
t('talk-plugin-local-auth.add_email.confirm_email_address')
),
};
onChange = e => {
const { name, value } = e.target;
this.setState(
state => ({
formData: {
...state.formData,
[name]: value,
},
}),
() => {
this.validate();
}
);
};
validateField = (value, name) => {
const error = this.fields[name](value, this.state.formData);
if (error) {
this.addError({ [name]: error });
return false;
}
this.removeError(name);
return true;
};
addError = err => {
this.setState(({ errors }) => ({
errors: { ...errors, ...err },
}));
};
validate() {
let hasErrors = false;
Object.keys(this.state.formData).forEach(k => {
hasErrors = !this.validateField(this.state.formData[k], k) || hasErrors;
});
return !hasErrors;
}
removeError = errKey => {
this.setState(state => {
const { [errKey]: _, ...errors } = state.errors;
return {
errors,
};
});
};
showErrors = () => {
this.setState({
showErrors: true,
});
};
confirmChanges = async () => {
if (!this.validate()) {
this.showErrors();
return;
}
const { emailAddress, confirmPassword } = this.state.formData;
const { attachLocalAuth } = this.props;
try {
await attachLocalAuth({
email: emailAddress,
password: confirmPassword,
});
this.props.notify('success', 'Email Added!');
this.goToNextStep();
} catch (err) {
this.props.notify('error', getErrorMessages(err));
}
};
goToNextStep = () => {
this.setState(({ step }) => ({
step: step + 1,
}));
};
render() {
const { errors, formData, showErrors, step } = this.state;
const { root: { settings } } = this.props;
return (
<Dialog className={styles.dialog} open={true}>
{step === 0 && (
<AddEmailContent
formData={formData}
errors={errors}
showErrors={showErrors}
confirmChanges={this.confirmChanges}
onChange={this.onChange}
/>
)}
{step === 1 &&
!settings.requireEmailConfirmation && (
<EmailAddressAdded done={() => {}} />
)}
{step === 1 &&
settings.requireEmailConfirmation && (
<VerifyEmailAddress
emailAddress={formData.emailAddress}
done={() => {}}
/>
)}
</Dialog>
);
}
}
AddEmailAddressDialog.propTypes = {
attachLocalAuth: PropTypes.func.isRequired,
notify: PropTypes.func.isRequired,
root: PropTypes.object.isRequired,
};
export default AddEmailAddressDialog;
@@ -0,0 +1,104 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './AddEmailAddressDialog.css';
import { Icon } from 'plugin-api/beta/client/components/ui';
import cn from 'classnames';
import InputField from './InputField';
import { t } from 'plugin-api/beta/client/services';
const AddEmailContent = ({
formData,
errors,
showErrors,
confirmChanges,
onChange,
}) => (
<div>
<h4 className={styles.title}>
{t('talk-plugin-local-auth.add_email.content.title')}
</h4>
<p className={styles.description}>
{t('talk-plugin-local-auth.add_email.content.description')}
</p>
<ul className={styles.list}>
<li className={styles.item}>
<Icon name="done" className={styles.itemIcon} />
<span className={styles.text}>
{t('talk-plugin-local-auth.add_email.content.item_1')}
</span>
</li>
<li className={styles.item}>
<Icon name="done" className={styles.itemIcon} />
<span className={styles.text}>
{t('talk-plugin-local-auth.add_email.content.item_2')}
</span>
</li>
<li className={styles.item}>
<Icon name="done" className={styles.itemIcon} />
<span className={styles.text}>
{t('talk-plugin-local-auth.add_email.content.item_3')}
</span>
</li>
</ul>
<form autoComplete="off">
<InputField
id="emailAddress"
label={t('talk-plugin-local-auth.add_email.enter_email_address')}
name="emailAddress"
type="email"
onChange={onChange}
value={formData.emailAddress}
hasError={!!errors.emailAddress}
errorMsg={errors.emailAddress}
showError={showErrors}
columnDisplay
showSuccess={false}
/>
<InputField
id="confirmEmailAddress"
label={t('talk-plugin-local-auth.add_email.confirm_email_address')}
name="confirmEmailAddress"
type="email"
onChange={onChange}
value={formData.confirmEmailAddress}
hasError={!!errors.confirmEmailAddress}
errorMsg={errors.confirmEmailAddress}
showError={showErrors}
columnDisplay
showSuccess={false}
/>
<InputField
id="confirmPassword"
label={t('talk-plugin-local-auth.add_email.insert_password')}
name="confirmPassword"
type="password"
onChange={onChange}
value={formData.confirmPassword}
hasError={!!errors.confirmPassword}
errorMsg={errors.confirmPassword}
showError={showErrors}
columnDisplay
showSuccess={false}
/>
<div className={styles.actions}>
<a
className={cn(styles.button, styles.proceed)}
onClick={confirmChanges}
>
{t('talk-plugin-local-auth.add_email.add_email_address')}
</a>
</div>
</form>
</div>
);
AddEmailContent.propTypes = {
formData: PropTypes.object.isRequired,
errors: PropTypes.object.isRequired,
showErrors: PropTypes.bool.isRequired,
confirmChanges: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
};
export default AddEmailContent;
@@ -43,18 +43,11 @@
margin-bottom: 2px;
}
.bottomNote {
font-size: 0.9em;
line-height: 20px;
padding-top: 10px;
display: block;
}
.bottomActions {
text-align: right;
}
.usernamesChange {
.emailChange {
margin: 18px 0;
}
@@ -0,0 +1,99 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './ChangeEmailContentDialog.css';
import InputField from './InputField';
import { Button } from 'plugin-api/beta/client/components/ui';
import { t } from 'plugin-api/beta/client/services';
class ChangeEmailContentDialog extends React.Component {
state = {
showError: false,
};
showError = () => {
this.setState({
showError: true,
});
};
confirmChanges = async e => {
e.preventDefault();
if (this.formHasError()) {
this.showError();
return;
}
await this.props.save();
this.props.next();
};
formHasError = () => this.props.hasError('confirmPassword');
render() {
return (
<div>
<span className={styles.close} onClick={this.props.cancel}>
×
</span>
<h1 className={styles.title}>
{t('talk-plugin-local-auth.change_email.confirm_email_change')}
</h1>
<div className={styles.content}>
<p className={styles.description}>
{t('talk-plugin-local-auth.change_email.description')}
</p>
<div className={styles.emailChange}>
<span className={styles.item}>
{t('talk-plugin-local-auth.change_email.old_email')}:{' '}
{this.props.email}
</span>
<span className={styles.item}>
{t('talk-plugin-local-auth.change_email.new_email')}:{' '}
{this.props.formData.newEmail}
</span>
</div>
<form onSubmit={this.confirmChanges}>
<InputField
id="confirmPassword"
label={t('talk-plugin-local-auth.change_email.enter_password')}
name="confirmPassword"
type="password"
onChange={this.props.onChange}
defaultValue=""
hasError={this.props.hasError('confirmPassword')}
errorMsg={this.props.getError('confirmPassword')}
showError={this.state.showError}
columnDisplay
/>
<div className={styles.bottomActions}>
<Button
className={styles.cancel}
onClick={this.props.cancel}
type="button"
>
{t('talk-plugin-local-auth.change_email.cancel')}
</Button>
<Button className={styles.confirmChanges} type="submit">
{t('talk-plugin-local-auth.change_email.confirm_change')}
</Button>
</div>
</form>
</div>
</div>
);
}
}
ChangeEmailContentDialog.propTypes = {
save: PropTypes.func,
next: PropTypes.func,
cancel: PropTypes.func,
onChange: PropTypes.func,
formData: PropTypes.object,
email: PropTypes.string,
hasError: PropTypes.func,
getError: PropTypes.func,
};
export default ChangeEmailContentDialog;
@@ -1,22 +1,30 @@
.container {
position: relative;
color: #202020;
padding: 10px;
border-radius: 2px;
border: solid 1px transparent;
box-sizing: border-box;
justify-content: space-between;
margin: 16px 0;
&.editing {
border-color: #979797;
padding: 10px;
background-color: #EDEDED;
.actions {
top: 10px;
right: 10px;
}
.title {
margin-bottom: 1em;
}
}
}
.actions {
position: absolute;
top: 10px;
right: 10px;
top: -6px;
right: 0px;
display: flex;
flex-direction: column;
align-items: center;
@@ -24,18 +32,18 @@
.title {
color: #202020;
margin: 0 0 20px;
margin: 0;
}
.detailBottomBox {
display: block;
padding-top: 4px;
text-align: right;
width: 280px;
width: 230px;
}
.detailLink {
color: #00538A;
color: #00538A;
text-decoration: none;
font-size: 0.9em;
&:hover {
@@ -59,7 +67,7 @@
> i {
font-size: 17px;
}
&:hover {
background-color: #399ee2;
color: white;
@@ -2,7 +2,7 @@ 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 { Button, BareButton } 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';
@@ -14,7 +14,11 @@ const initialState = {
editing: false,
showErrors: true,
errors: {},
formData: {},
formData: {
oldPassword: '',
newPassword: '',
confirmNewPassword: '',
},
};
class ChangePassword extends React.Component {
@@ -45,7 +49,9 @@ class ChangePassword extends React.Component {
const cond = this.state.formData[field] === this.state.formData[field2];
if (!cond) {
this.addError({
[field2]: t('talk-plugin-auth.change_password.passwords_dont_match'),
[field2]: t(
'talk-plugin-local-auth.change_password.passwords_dont_match'
),
});
} else {
this.removeError(field2);
@@ -56,7 +62,7 @@ class ChangePassword extends React.Component {
fieldValidation = (value, type, name) => {
if (!value.length) {
this.addError({
[name]: t('talk-plugin-auth.change_password.required_field'),
[name]: t('talk-plugin-local-auth.change_password.required_field'),
});
} else if (!validate[type](value)) {
this.addError({ [name]: errorMsj[type] });
@@ -103,7 +109,13 @@ class ChangePassword extends React.Component {
this.setState(initialState);
};
onSave = async () => {
onSave = async e => {
e.preventDefault();
if (this.isSubmitBlocked()) {
return;
}
const { oldPassword, newPassword } = this.state.formData;
try {
@@ -113,7 +125,23 @@ class ChangePassword extends React.Component {
});
this.props.notify(
'success',
t('talk-plugin-auth.change_password.changed_password_msg')
t('talk-plugin-local-auth.change_password.changed_password_msg')
);
this.clearForm();
this.disableEditing();
} catch (err) {
this.props.notify('error', getErrorMessages(err));
}
};
onForgotPassword = async () => {
const { root: { me: { email } } } = this.props;
try {
await this.props.forgotPassword(email);
this.props.notify(
'success',
t('talk-plugin-local-auth.change_password.forgot_password_sent')
);
} catch (err) {
this.props.notify('error', getErrorMessages(err));
@@ -135,19 +163,26 @@ class ChangePassword extends React.Component {
};
render() {
const { editing, errors } = this.state;
const { editing, errors, showErrors } = this.state;
return (
<section
className={cn('talk-plugin-auth--change-password', styles.container, {
[styles.editing]: editing,
})}
className={cn(
'talk-plugin-local-auth--change-password',
styles.container,
{
[styles.editing]: editing,
}
)}
>
<h3 className={styles.title}>
{t('talk-plugin-auth.change_password.change_password')}
{t('talk-plugin-local-auth.change_password.change_password')}
</h3>
{editing && (
<form className="talk-plugin-auth--change-password-form">
{editing ? (
<form
className="talk-plugin-local-auth--change-password-form"
onSubmit={this.onSave}
>
<InputField
id="oldPassword"
label="Old Password"
@@ -157,11 +192,14 @@ class ChangePassword extends React.Component {
value={this.state.formData.oldPassword}
hasError={this.hasError('oldPassword')}
errorMsg={errors['oldPassword']}
showErrors
showError={showErrors}
>
<span className={styles.detailBottomBox}>
<a className={styles.detailLink}>
{t('talk-plugin-auth.change_password.forgot_password')}
<a
className={styles.detailLink}
onClick={this.onForgotPassword}
>
{t('talk-plugin-local-auth.change_password.forgot_password')}
</a>
</span>
</InputField>
@@ -174,7 +212,7 @@ class ChangePassword extends React.Component {
value={this.state.formData.newPassword}
hasError={this.hasError('newPassword')}
errorMsg={errors['newPassword']}
showErrors
showError={showErrors}
/>
<InputField
id="confirmNewPassword"
@@ -185,28 +223,30 @@ class ChangePassword extends React.Component {
value={this.state.formData.confirmNewPassword}
hasError={this.hasError('confirmNewPassword')}
errorMsg={errors['confirmNewPassword']}
showErrors
showError={showErrors}
/>
<div className={styles.actions}>
<Button
className={cn(styles.button, styles.saveButton)}
icon="save"
type="submit"
disabled={this.isSubmitBlocked()}
>
{t('talk-plugin-local-auth.change_password.save')}
</Button>
<BareButton
type="button"
className={styles.cancelButton}
onClick={this.cancel}
>
{t('talk-plugin-local-auth.change_password.cancel')}
</BareButton>
</div>
</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')}
{t('talk-plugin-local-auth.change_password.edit')}
</Button>
</div>
)}
@@ -217,6 +257,7 @@ class ChangePassword extends React.Component {
ChangePassword.propTypes = {
changePassword: PropTypes.func.isRequired,
forgotPassword: PropTypes.func.isRequired,
notify: PropTypes.func.isRequired,
};
@@ -0,0 +1,73 @@
.close {
font-size: 20px;
line-height: 14px;
top: 10px;
right: 10px;
position: absolute;
display: block;
font-weight: bold;
color: #363636;
cursor: pointer;
&:hover {
color: #6b6b6b;
}
}
.title {
font-size: 1.3em;
margin-bottom: 8px;
}
.description {
font-size: 1em;
line-height: 20px;
margin: 0;
}
.item {
display: block;
color: #4C4C4D;
font-size: 1em;
margin-bottom: 2px;
}
.bottomNote {
font-size: 0.9em;
line-height: 20px;
padding-top: 10px;
display: block;
}
.bottomActions {
text-align: right;
}
.usernamesChange {
margin: 18px 0;
}
.cancel {
border: 1px solid #787d80;
background-color: transparent;
height: 30px;
font-size: 0.9em;
line-height: normal;
&:hover {
background-color: #eaeaea;
}
}
.confirmChanges {
background-color: #3498DB;
border-color: #3498DB;
color: white;
height: 30px;
font-size: 0.9em;
&:hover {
background-color: #3ba3ec;
color: white;
}
}
@@ -1,12 +1,11 @@
import React from 'react';
import PropTypes from 'prop-types';
import cn from 'classnames';
import styles from './ChangeUsernameDialog.css';
import styles from './ChangeUsernameContentDialog.css';
import InputField from './InputField';
import { Button, Dialog } from 'plugin-api/beta/client/components/ui';
import { Button } from 'plugin-api/beta/client/components/ui';
import { t } from 'plugin-api/beta/client/services';
class ChangeUsernameDialog extends React.Component {
class ChangeUsernameContentDialog extends React.Component {
state = {
showError: false,
};
@@ -17,7 +16,9 @@ class ChangeUsernameDialog extends React.Component {
});
};
confirmChanges = async () => {
confirmChanges = async e => {
e.preventDefault();
if (this.formHasError()) {
this.showError();
return;
@@ -26,13 +27,13 @@ class ChangeUsernameDialog extends React.Component {
if (!this.props.canUsernameBeUpdated) {
this.props.notify(
'error',
t('talk-plugin-auth.change_username.change_username_attempt')
t('talk-plugin-local-auth.change_username.change_username_attempt')
);
return;
}
await this.props.saveChanges();
this.props.closeDialog();
await this.props.save();
this.props.next();
};
formHasError = () =>
@@ -40,31 +41,28 @@ class ChangeUsernameDialog extends React.Component {
render() {
return (
<Dialog
open={this.props.showDialog}
className={cn(styles.dialog, 'talk-plugin-auth--edit-profile-dialog')}
>
<span className={styles.close} onClick={this.props.closeDialog}>
<div>
<span className={styles.close} onClick={this.props.cancel}>
×
</span>
<h1 className={styles.title}>
{t('talk-plugin-auth.change_username.confirm_username_change')}
{t('talk-plugin-local-auth.change_username.confirm_username_change')}
</h1>
<div className={styles.content}>
<p className={styles.description}>
{t('talk-plugin-auth.change_username.description')}
{t('talk-plugin-local-auth.change_username.description')}
</p>
<div className={styles.usernamesChange}>
<span className={styles.item}>
{t('talk-plugin-auth.change_username.old_username')}:{' '}
{t('talk-plugin-local-auth.change_username.old_username')}:{' '}
{this.props.username}
</span>
<span className={styles.item}>
{t('talk-plugin-auth.change_username.new_username')}:{' '}
{t('talk-plugin-local-auth.change_username.new_username')}:{' '}
{this.props.formData.newUsername}
</span>
</div>
<form>
<form onSubmit={this.confirmChanges}>
<InputField
id="confirmNewUsername"
label="Re-enter new username"
@@ -74,7 +72,7 @@ class ChangeUsernameDialog extends React.Component {
defaultValue=""
hasError={this.formHasError() && this.state.showError}
errorMsg={t(
'talk-plugin-auth.change_username.username_does_not_match'
'talk-plugin-local-auth.change_username.username_does_not_match'
)}
showError={this.state.showError}
columnDisplay
@@ -82,36 +80,37 @@ class ChangeUsernameDialog extends React.Component {
validationType="username"
>
<span className={styles.bottomNote}>
{t('talk-plugin-auth.change_username.bottom_note')}
{t('talk-plugin-local-auth.change_username.bottom_note')}
</span>
</InputField>
<div className={styles.bottomActions}>
<Button
className={styles.cancel}
onClick={this.props.cancel}
type="button"
>
{t('talk-plugin-local-auth.change_username.cancel')}
</Button>
<Button className={styles.confirmChanges} type="submit">
{t('talk-plugin-local-auth.change_username.confirm_changes')}
</Button>
</div>
</form>
<div className={styles.bottomActions}>
<Button className={styles.cancel}>
{t('talk-plugin-auth.change_username.cancel')}
</Button>
<Button
className={styles.confirmChanges}
onClick={this.confirmChanges}
>
{t('talk-plugin-auth.change_username.confirm_changes')}
</Button>
</div>
</div>
</Dialog>
</div>
);
}
}
ChangeUsernameDialog.propTypes = {
saveChanges: PropTypes.func,
closeDialog: PropTypes.func,
showDialog: PropTypes.bool,
ChangeUsernameContentDialog.propTypes = {
save: PropTypes.func,
next: PropTypes.func,
cancel: PropTypes.func,
onChange: PropTypes.func,
username: PropTypes.string,
formData: PropTypes.object,
username: PropTypes.string,
canUsernameBeUpdated: PropTypes.bool.isRequired,
notify: PropTypes.func.isRequired,
};
export default ChangeUsernameDialog;
export default ChangeUsernameContentDialog;
@@ -0,0 +1,10 @@
.dialog {
border: none;
box-shadow: 0 9px 46px 8px rgba(0, 0, 0, 0.14), 0 11px 15px -7px rgba(0, 0, 0, 0.12), 0 24px 38px 3px rgba(0, 0, 0, 0.2);
width: 320px;
top: 10px;
font-family: Helvetica, 'Helvetica Neue', Verdana, sans-serif;
font-size: 14px;
border-radius: 4px;
padding: 12px 20px;
}
@@ -0,0 +1,75 @@
import React from 'react';
import cn from 'classnames';
import PropTypes from 'prop-types';
import styles from './ConfirmChangesDialog.css';
import { Dialog } from 'plugin-api/beta/client/components/ui';
const initialState = { step: 0 };
class ConfirmChangesDialog extends React.Component {
state = initialState;
goToNextStep = () => {
this.setState(({ step }) => ({
step: step + 1,
}));
};
clear = () => {
this.setState(initialState);
};
cancel = () => {
this.clear();
this.props.closeDialog();
};
continue = () => {
this.goToNextStep();
};
finish = () => {
this.clear();
this.props.closeDialog();
this.props.finish();
};
renderSteps = () => {
const steps = React.Children.toArray(this.props.children)
.filter(child => child.props.enable)
.filter((_, i) => i === this.state.step);
return steps.map(child => {
return React.cloneElement(child, {
goToNextStep: this.goToNextStep,
clear: this.clear,
cancel: this.cancel,
next:
this.state.step === steps.length - 1 ? this.finish : this.continue,
});
});
};
render() {
return (
<Dialog
open={this.props.showDialog}
className={cn(
styles.dialog,
'talk-plugin-local-auth--edit-profile-dialog'
)}
>
{this.renderSteps()}
</Dialog>
);
}
}
ConfirmChangesDialog.propTypes = {
children: PropTypes.node,
closeDialog: PropTypes.func,
showDialog: PropTypes.bool,
finish: PropTypes.func,
};
export default ConfirmChangesDialog;
@@ -0,0 +1,32 @@
import React from 'react';
import cn from 'classnames';
import PropTypes from 'prop-types';
import styles from './AddEmailAddressDialog.css';
import { t } from 'plugin-api/beta/client/services';
const EmailAddressAdded = ({ done }) => (
<div>
<h4 className={styles.title}>
{t('talk-plugin-local-auth.add_email.added.title')}
</h4>
<p className={styles.description}>
{t('talk-plugin-local-auth.add_email.added.description')}
</p>
<strong>{t('talk-plugin-local-auth.add_email.added.subtitle')}</strong>
<p className={styles.description}>
{t('talk-plugin-local-auth.add_email.added.description_2')}{' '}
<strong>{t('talk-plugin-local-auth.add_email.added.path')}</strong>.
</p>
<div className={styles.actions}>
<a className={cn(styles.button, styles.proceed)} onClick={done}>
{t('talk-plugin-local-auth.add_email.done')}
</a>
</div>
</div>
);
EmailAddressAdded.propTypes = {
done: PropTypes.func.isRequired,
};
export default EmailAddressAdded;
@@ -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,85 @@
.detailItem {
margin-bottom: 12px;
}
.detailItemContainer {
display: flex;
flex-direction: column;
}
.columnDisplay {
flex-direction: column;
.detailItemMessage {
padding: 4px 0 0;
}
}
.detailItemContent {
display: flex;
}
.detailInput {
border: solid 1px #787D80;
border-radius: 2px;
background-color: white;
height: 30px;
display: inline-block;
width: 230px;
display: flex;
box-sizing: border-box;
> .detailIcon {
font-size: 1.2em;
padding: 0 5px;
color: #787D80;
line-height: 30px;
}
&.error {
border: solid 2px #FA4643;
}
&.disabled {
background-color: #E0E0E0;
}
}
.detailLabel {
color: #4C4C4D;
font-size: 1em;
display: block;
margin-bottom: 4px;
}
.detailValue {
background: transparent;
border: none;
font-size: 1em;
color: #000;
outline: none;
flex: 1;
height: 100%;
box-sizing: border-box;
padding: 0 6px;
}
.detailItemMessage {
flex-grow: 1;
display: flex;
align-items: center;
padding-left: 6px;
.warningIcon, .checkIcon {
font-size: 17px;
}
}
.checkIcon {
color: #00CD73;
}
.warningIcon {
color: #FA4643;
}
@@ -0,0 +1,98 @@
import React from 'react';
import PropTypes from 'prop-types';
import cn from 'classnames';
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 = () => {},
showError = true,
hasError = false,
errorMsg = '',
children,
columnDisplay = false,
showSuccess = false,
validationType = '',
icon = '',
value,
defaultValue,
disabled = false,
}) => {
const inputValue = {
...(value !== undefined ? { value } : {}),
...(defaultValue !== undefined ? { defaultValue } : {}),
};
return (
<div className={styles.detailItem}>
<div className={cn(styles.detailItemContainer)}>
{label && (
<label className={styles.detailLabel} id={id}>
{label}
</label>
)}
<div
className={cn(styles.detailItemContent, {
[styles.columnDisplay]: columnDisplay,
})}
>
<div
className={cn(
styles.detailInput,
{ [styles.error]: hasError && showError },
{ [styles.disabled]: disabled }
)}
>
{icon && <Icon name={icon} className={styles.detailIcon} />}
<input
id={id}
type={type}
name={name}
className={styles.detailValue}
onChange={onChange}
autoComplete="off"
data-validation-type={validationType}
disabled={disabled}
{...inputValue}
/>
</div>
<div className={styles.detailItemMessage}>
{!hasError &&
showSuccess &&
value && (
<Icon className={styles.checkIcon} name="check_circle" />
)}
{hasError && showError && <ErrorMessage>{errorMsg}</ErrorMessage>}
</div>
</div>
</div>
{children}
</div>
);
};
InputField.propTypes = {
id: PropTypes.string,
disabled: PropTypes.bool,
label: PropTypes.string,
type: PropTypes.string,
name: PropTypes.string.isRequired,
onChange: PropTypes.func,
value: PropTypes.string,
defaultValue: PropTypes.string,
icon: PropTypes.string,
showError: PropTypes.bool,
hasError: PropTypes.bool,
errorMsg: PropTypes.string,
children: PropTypes.node,
columnDisplay: PropTypes.bool,
showSuccess: PropTypes.bool,
validationType: PropTypes.string,
};
export default InputField;
@@ -1,36 +1,48 @@
.container {
margin-bottom: 20px;
margin-top: 6px;
margin-bottom: 12px;
display: flex;
position: relative;
color: #202020;
padding: 10px;
padding: 5px;
border-radius: 2px;
box-sizing: border-box;
justify-content: space-between;
&.editing {
&.editing {
padding: 10px;
background-color: #EDEDED;
}
}
.wrapper {
display: flex;
position: relative;
box-sizing: inherit;
justify-content: inherit;
flex-grow: 1;
}
.content {
flex-grow: 1;
}
}
.actions {
flex-grow: 0;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
}
}
.email {
margin: 0;
}
.username {
.username {
margin-top: 0;
margin-bottom: 4px;
}
}
.button {
border: 1px solid #787d80;
@@ -48,7 +60,7 @@
> i {
font-size: 17px;
}
&:hover {
background-color: #399ee2;
color: white;
@@ -82,13 +94,13 @@
height: 30px;
display: inline-block;
width: 230px;
display: flex;
display: flex;
> .detailLabelIcon {
font-size: 1.2em;
padding: 0 5px;
color: #787D80;
line-height: 30px;
line-height: 30px;
}
&.disabled {
@@ -115,7 +127,7 @@
list-style: none;
margin: 0;
padding: 0;
}
}
.detailItem {
margin-bottom: 12px;
@@ -0,0 +1,294 @@
import React from 'react';
import cn from 'classnames';
import PropTypes from 'prop-types';
import styles from './Profile.css';
import { Button, BareButton } from 'plugin-api/beta/client/components/ui';
import { t } from 'plugin-api/beta/client/services';
import InputField from './InputField';
import { getErrorMessages } from 'coral-framework/utils';
import validate from 'coral-framework/helpers/validate';
import errorMsj from 'coral-framework/helpers/error';
import ConfirmChangesDialog from './ConfirmChangesDialog';
import ChangeUsernameContentDialog from './ChangeUsernameContentDialog';
import ChangeEmailContentDialog from './ChangeEmailContentDialog';
import { canUsernameBeUpdated } from 'coral-framework/utils/user';
const initialState = {
editing: false,
showDialog: false,
formData: {},
errors: {},
};
class Profile extends React.Component {
state = initialState;
clearForm = () => {
this.setState(initialState);
};
enableEditing = () => {
this.setState({
editing: true,
});
};
disableEditing = () => {
this.setState({
editing: false,
});
};
cancel = () => {
this.clearForm();
this.disableEditing();
};
showDialog = () => {
this.setState({
showDialog: true,
});
};
onSave = async e => {
e.preventDefault();
if (this.isSaveEnabled()) {
this.showDialog();
}
};
addError = err => {
this.setState(({ errors }) => ({
errors: { ...errors, ...err },
}));
};
removeError = errKey => {
this.setState(state => {
const { [errKey]: _, ...errors } = state.errors;
return {
errors,
};
});
};
fieldValidation = (value, type, name) => {
if (!value.length) {
this.addError({
[name]: t('talk-plugin-local-auth.change_password.required_field'),
});
} else if (!validate[type](value)) {
this.addError({ [name]: errorMsj[type] });
} else {
this.removeError(name);
}
};
onChange = e => {
const { name, value, type, dataset } = e.target;
const validationType = dataset.validationType || type;
this.setState(
state => ({
formData: {
...state.formData,
[name]: value,
},
}),
() => {
this.fieldValidation(value, validationType, name);
}
);
};
closeDialog = () => {
this.setState({
showDialog: false,
});
};
hasError = err => {
return Object.keys(this.state.errors).indexOf(err) !== -1;
};
isSaveEnabled = () => {
const { formData } = this.state;
const { root: { me: { username, email } } } = this.props;
const formHasErrors = !!Object.keys(this.state.errors).length;
const validUsername =
formData.newUsername && formData.newUsername !== username;
const validEmail = formData.newEmail && formData.newEmail !== email;
return !formHasErrors && (validUsername || validEmail);
};
saveUsername = async () => {
const { newUsername } = this.state.formData;
const { setUsername } = this.props;
try {
await setUsername(newUsername);
this.props.notify(
'success',
t('talk-plugin-local-auth.change_username.changed_username_success_msg')
);
} catch (err) {
this.props.notify('error', getErrorMessages(err));
}
};
saveEmail = async () => {
const { newEmail, confirmPassword } = this.state.formData;
try {
await this.props.updateEmailAddress({
email: newEmail,
confirmPassword,
});
this.props.notify(
'success',
t('talk-plugin-local-auth.change_email.change_email_msg')
);
} catch (err) {
this.props.notify('error', getErrorMessages(err));
}
};
getError = errorKey => {
return this.state.errors[errorKey];
};
finish = () => {
this.clearForm();
this.disableEditing();
};
render() {
const {
root: { me: { username, email, state: { status } } },
notify,
} = this.props;
const { editing, formData, showDialog } = this.state;
const usernameCanBeUpdated = canUsernameBeUpdated(status);
return (
<section
className={cn(
'talk-plugin-local-auth--edit-profile',
styles.container,
{
[styles.editing]: editing,
}
)}
>
<ConfirmChangesDialog
showDialog={showDialog}
closeDialog={this.closeDialog}
finish={this.finish}
>
{usernameCanBeUpdated && (
<ChangeUsernameContentDialog
notify={notify}
canUsernameBeUpdated={usernameCanBeUpdated}
save={this.saveUsername}
onChange={this.onChange}
formData={this.state.formData}
username={username}
enable={formData.newUsername && username !== formData.newUsername}
hasError={this.hasError}
/>
)}
<ChangeEmailContentDialog
save={this.saveEmail}
onChange={this.onChange}
formData={this.state.formData}
email={email}
enable={formData.newEmail && email !== formData.newEmail}
hasError={this.hasError}
getError={this.getError}
/>
</ConfirmChangesDialog>
{editing ? (
<form className={styles.wrapper} onSubmit={this.onSave}>
<div className={styles.content}>
<div className={styles.detailList}>
<InputField
icon="person"
id="newUsername"
name="newUsername"
onChange={this.onChange}
defaultValue={username}
validationType="username"
disabled={!usernameCanBeUpdated}
columnDisplay
>
<span className={styles.bottomText}>
{t(
'talk-plugin-local-auth.change_username.change_username_note'
)}
</span>
</InputField>
<InputField
icon="email"
id="newEmail"
name="newEmail"
onChange={this.onChange}
defaultValue={email}
validationType="email"
columnDisplay
/>
</div>
</div>
<div className={styles.actions}>
<Button
className={cn(styles.button, styles.saveButton)}
icon="save"
type="submit"
disabled={!this.isSaveEnabled()}
>
{t('talk-plugin-local-auth.change_username.save')}
</Button>
<BareButton
className={styles.cancelButton}
onClick={this.cancel}
type="button"
>
{t('talk-plugin-local-auth.change_username.cancel')}
</BareButton>
</div>
</form>
) : (
<div className={styles.wrapper}>
<div className={styles.content}>
<h2 className={styles.username}>{username}</h2>
{email ? <p className={styles.email}>{email}</p> : null}
</div>
<div className={styles.actions}>
<Button
className={styles.button}
icon="settings"
onClick={this.enableEditing}
>
{t('talk-plugin-local-auth.change_username.edit_profile')}
</Button>
</div>
</div>
)}
</section>
);
}
}
Profile.propTypes = {
updateEmailAddress: PropTypes.func.isRequired,
setUsername: PropTypes.func.isRequired,
root: PropTypes.object.isRequired,
notify: PropTypes.func.isRequired,
username: PropTypes.string,
emailAddress: PropTypes.string,
};
export default Profile;
@@ -0,0 +1,28 @@
import React from 'react';
import cn from 'classnames';
import PropTypes from 'prop-types';
import styles from './AddEmailAddressDialog.css';
import { t } from 'plugin-api/beta/client/services';
const VerifyEmailAddress = ({ emailAddress, done }) => (
<div>
<h4 className={styles.title}>
{t('talk-plugin-local-auth.add_email.verify.title')}
</h4>
<p className={styles.description}>
{t('talk-plugin-local-auth.add_email.verify.description', emailAddress)}
</p>
<div className={styles.actions}>
<a className={cn(styles.button, styles.proceed)} onClick={done}>
{t('talk-plugin-local-auth.add_email.done')}
</a>
</div>
</div>
);
VerifyEmailAddress.propTypes = {
emailAddress: PropTypes.string.isRequired,
done: PropTypes.func.isRequired,
};
export default VerifyEmailAddress;
@@ -0,0 +1,30 @@
import { compose, gql } from 'react-apollo';
import { bindActionCreators } from 'redux';
import { connect, withFragments, excludeIf } from 'plugin-api/beta/client/hocs';
import AddEmailAddressDialog from '../components/AddEmailAddressDialog';
import { notify } from 'coral-framework/actions/notification';
import { withAttachLocalAuth } from '../hocs';
const mapDispatchToProps = dispatch => bindActionCreators({ notify }, dispatch);
const withData = withFragments({
root: gql`
fragment TalkPluginLocalAuth_AddEmailAddressDialog_root on RootQuery {
me {
id
email
}
settings {
requireEmailConfirmation
}
}
`,
});
export default compose(
connect(null, mapDispatchToProps),
withAttachLocalAuth,
withData,
excludeIf(({ root: { me } }) => !me || me.email)
)(AddEmailAddressDialog);
@@ -0,0 +1,28 @@
import { compose, gql } from 'react-apollo';
import { bindActionCreators } from 'redux';
import { connect, withFragments } from 'plugin-api/beta/client/hocs';
import ChangePassword from '../components/ChangePassword';
import { notify } from 'coral-framework/actions/notification';
import {
withChangePassword,
withForgotPassword,
} from 'plugin-api/beta/client/hocs';
const mapDispatchToProps = dispatch => bindActionCreators({ notify }, dispatch);
const withData = withFragments({
root: gql`
fragment TalkPluginLocalAuth_ChangePassword_root on RootQuery {
me {
email
}
}
`,
});
export default compose(
connect(null, mapDispatchToProps),
withChangePassword,
withForgotPassword,
withData
)(ChangePassword);
@@ -0,0 +1,39 @@
import { compose, gql } from 'react-apollo';
import { bindActionCreators } from 'redux';
import { connect, withFragments } from 'plugin-api/beta/client/hocs';
import Profile from '../components/Profile';
import { notify } from 'coral-framework/actions/notification';
import { withSetUsername } from 'plugin-api/beta/client/hocs';
import { withUpdateEmailAddress } from '../hocs';
const mapDispatchToProps = dispatch => bindActionCreators({ notify }, dispatch);
const withData = withFragments({
root: gql`
fragment TalkPluginLocalAuth_Profile_root on RootQuery {
me {
id
email
username
state {
status {
username {
status
history {
status
created_at
}
}
}
}
}
}
`,
});
export default compose(
connect(null, mapDispatchToProps),
withSetUsername,
withUpdateEmailAddress,
withData
)(Profile);
@@ -0,0 +1,35 @@
import update from 'immutability-helper';
import get from 'lodash/get';
import findIndex from 'lodash/findIndex';
export default {
mutations: {
UpdateEmailAddress: () => ({
updateQueries: {
CoralEmbedStream_Profile: previousData => {
// Find the local profile (if they have one).
const localIndex = findIndex(get(previousData, 'me.profiles', []), {
provider: 'local',
});
if (localIndex < 0) {
return previousData;
}
// Mutate the confirmedAt, because we changed the email address, they
// can't possibly be confirmed now as well.
return update(previousData, {
me: {
profiles: {
[localIndex]: {
confirmedAt: {
$set: null,
},
},
},
},
});
},
},
}),
},
};
@@ -0,0 +1,91 @@
import { withMutation } from 'plugin-api/beta/client/hocs';
import { gql } from 'react-apollo';
import update from 'immutability-helper';
export const withAttachLocalAuth = withMutation(
gql`
mutation AttachLocalAuth($input: AttachLocalAuthInput!) {
attachLocalAuth(input: $input) {
...AttachLocalAuthResponse
}
}
`,
{
props: ({ mutate }) => ({
attachLocalAuth: input => {
return mutate({
variables: {
input,
},
update: proxy => {
const AttachLocalAuthQuery = gql`
query Talk_AttachLocalAuth {
me {
id
email
}
}
`;
const prev = proxy.readQuery({ query: AttachLocalAuthQuery });
const data = update(prev, {
me: {
email: { $set: input.email },
},
});
proxy.writeQuery({
query: AttachLocalAuthQuery,
data,
});
},
});
},
}),
}
);
export const withUpdateEmailAddress = withMutation(
gql`
mutation UpdateEmailAddress($input: UpdateEmailAddressInput!) {
updateEmailAddress(input: $input) {
...UpdateEmailAddressResponse
}
}
`,
{
props: ({ mutate }) => ({
updateEmailAddress: input => {
return mutate({
variables: {
input,
},
update: proxy => {
const UpdateEmailAddressQuery = gql`
query Talk_UpdateEmailAddress {
me {
id
email
}
}
`;
const prev = proxy.readQuery({ query: UpdateEmailAddressQuery });
const data = update(prev, {
me: {
email: { $set: input.email },
},
});
proxy.writeQuery({
query: UpdateEmailAddressQuery,
data,
});
},
});
},
}),
}
);
@@ -0,0 +1,15 @@
import ChangePassword from './containers/ChangePassword';
import AddEmailAddressDialog from './containers/AddEmailAddressDialog';
import Profile from './containers/Profile';
import translations from './translations.yml';
import graphql from './graphql';
export default {
translations,
slots: {
profileHeader: [Profile],
profileSettings: [ChangePassword],
stream: [AddEmailAddressDialog],
},
...graphql,
};
@@ -0,0 +1,87 @@
en:
talk-plugin-local-auth:
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"
forgot_password_sent: "Forgot Password - We sent you an email to recover your password"
change_username:
change_username_note: "Usernames can only be changed once every 14 days. Your username is not currently eligible to be updated."
save: "Save"
edit_profile: "Edit Profile"
cancel: "Cancel"
confirm_username_change: "Confirm Username Change"
description: "You are attempting to change your username. Your new username will appear on all of your past and future comments."
old_username: "Old Username"
new_username: "New Username"
bottom_note: "Note: You will not be able to change your username again for 14 days"
confirm_changes: "Confirm Changes"
username_does_not_match: "Username does not match"
cant_be_equal: "Your new {0} must be different to your current one"
changed_username_success_msg: "Username Changed - Your username has been successfully changed. You will not be able to change your user name for 14 days."
change_username_attempt: "Username can't be updated. Usernames can only be changed every 14 days."
change_email:
confirm_email_change: "Confirm Email Address Change"
description: "You are attempting to change your email address. Your new email address will be used for your login and to receive account notifications."
old_email: "Old Email Address"
new_email: "New Email Address"
enter_password: "Enter Password"
incorrect_password: "Incorrect Password"
confirm_change: "Confirm Change"
cancel: "Cancel"
change_email_msg: "Email Address Changed - Your email address has been successfully changed. This email address will now be used for signing in and email notifications."
add_email:
add_email_address: "Add Email Address"
enter_email_address: "Enter Email Address:"
invalid_email_address: "Invalid Email address"
confirm_email_address: "Confirm Email Address:"
email_does_not_match: "Email Address does not match"
insert_password: "Insert Password:"
required_field: "This field is required"
done: "done"
content:
title: "Add an Email Address"
description: "For your added security, we require users to add an email address to their accounts. Your email address will be used to:"
item_1: "Receive updates regarding any changes to your account (email address, username, password, etc.)"
item_2: "Allow you to download your comments."
item_3: "Send comment notifications that you have chosen to receive."
verify:
title: "Verify Your Email Address"
description: "Weve sent an email to {0} to verify your account. You must verify your email address so that it can be used for account change confirmations and notifications."
added:
title: "Email Address Added"
description: "Your email address has been added to your account."
subtitle: "Need to change your email address?"
description_2: "You can change your account settings by visiting"
path: "My Profile > Settings"
es:
talk-plugin-local-auth:
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"
forgot_password_sent: "Contraseña Olvidada - Te enviamos un email para recuperar tu contraseña"
change_username:
change_username_note: "El usuario puede ser cambiado cada 14 días"
save: "Guardar"
edit_profile: "Editar Perfil"
cancel: "Cancelar"
confirm_username_change: "Confirmar Cambio de Usuario"
description: "Estás intentando cambiar tu usuario. Tu nuevo usuario aparecerá en todos tus pasados y futuros comentarios."
old_username: "Usuario viejo"
new_username: "Usuario nuevo"
bottom_note: "Nota: No podrás cambiar tu usuario por 14 días"
confirm_changes: "Confirmar Cambios"
username_does_not_match: "El usuario no coincide"
changed_username_success_msg: "Usuario Actualizado - Tu usuario ha sido exitosamente actualizado. No podrás cambiar el usuario por 14 días."
change_username_attempt: "El usuario no puede ser actualizado. Los usuarios pueden ser cambiados cada 14 días."
+11
View File
@@ -0,0 +1,11 @@
const typeDefs = require('./server/typeDefs');
const resolvers = require('./server/resolvers');
const mutators = require('./server/mutators');
const path = require('path');
module.exports = {
translations: path.join(__dirname, 'server', 'translations.yml'),
typeDefs,
mutators,
resolvers,
};
@@ -0,0 +1,35 @@
const { TalkError } = require('errors');
// ErrNoLocalProfile is returned when there is no existing local profile
// attached to a user.
class ErrNoLocalProfile extends TalkError {
constructor() {
super('No local profile associated with account', {
translation_key: 'NO_LOCAL_PROFILE',
status: 400,
});
}
}
// ErrLocalProfile is returned when a profile is already attached to a user and
// the user is trying to attach a new profile to it.
class ErrLocalProfile extends TalkError {
constructor() {
super('Local profile already associated with account', {
translation_key: 'LOCAL_PROFILE',
status: 400,
});
}
}
// ErrIncorrectPassword is returned when the password passed was incorrect.
class ErrIncorrectPassword extends TalkError {
constructor() {
super('Password was incorrect', {
translation_key: 'INCORRECT_PASSWORD',
status: 400,
});
}
}
module.exports = { ErrLocalProfile, ErrNoLocalProfile, ErrIncorrectPassword };
@@ -0,0 +1,163 @@
const { ErrNotAuthorized, ErrNotFound, ErrEmailTaken } = require('errors');
const {
ErrNoLocalProfile,
ErrLocalProfile,
ErrIncorrectPassword,
} = require('./errors');
const { get } = require('lodash');
// hasLocalProfile checks a user's profiles to see if they already have a local
// profile associated with their account.
const hasLocalProfile = user =>
get(user, 'profiles', []).some(({ provider }) => provider === 'local');
// updateUserEmailAddress will verify that the user has sent the correct
// password followed by executing the email change and notifying the emails
// about that change.
async function updateUserEmailAddress(ctx, email, confirmPassword) {
const {
user,
loaders: { Settings },
connectors: { models: { User }, services: { Mailer, I18n, Users } },
} = ctx;
// Ensure that the user has a local profile associated with their account.
if (!hasLocalProfile(user)) {
throw new ErrNoLocalProfile();
}
// Ensure that the password provided matches what we have on file.
if (!await user.verifyPassword(confirmPassword)) {
throw new ErrIncorrectPassword();
}
// Cleanup the email address.
email = email.toLowerCase().trim();
// Update the Users email address.
try {
await User.update(
{
id: user.id,
profiles: { $elemMatch: { provider: 'local' } },
},
{
$set: { 'profiles.$.id': email },
$unset: { 'profiles.$.metadata.confirmed_at': 1 },
}
);
} catch (err) {
if (err.code === 11000) {
throw new ErrEmailTaken();
}
throw err;
}
// Get some context for the email to be sent.
const { organizationContactEmail } = await Settings.select(
'organizationContactEmail'
);
// Send off the email to the old email address that we have changed it.
await Mailer.send({
email: user.firstEmail,
template: 'plain',
locals: {
body: I18n.t(
'email.email_change_original.body',
user.firstEmail,
email,
organizationContactEmail
),
},
subject: I18n.t('email.email_change_original.subject'),
});
// Send off the email to the new email address that we need to verify the new
// address.
await Users.sendEmailConfirmation(user, email);
}
// attachUserLocalAuth will attach a new local profile to an existing user.
async function attachUserLocalAuth(ctx, email, password) {
const { user, connectors: { models: { User }, services: { Users } } } = ctx;
// Ensure that the current user doesn't already have a local account
// associated with them.
if (hasLocalProfile(user)) {
throw new ErrLocalProfile();
}
// Cleanup the email address.
email = email.toLowerCase().trim();
// Validate the password.
await Users.isValidPassword(password);
// Hash the new password.
const hashedPassword = await Users.hashPassword(password);
try {
// Associate the account with the user.
const updatedUser = await User.findOneAndUpdate(
{
id: user.id,
'profiles.provider': { $ne: 'local' },
},
{
$push: {
profiles: {
provider: 'local',
id: email,
},
},
$set: {
password: hashedPassword,
},
},
{ new: true }
);
if (!updatedUser) {
const foundUser = await User.findOne({ id: user.id });
if (!foundUser) {
throw new ErrNotFound();
}
// Check to see if this was the result of a race.
if (hasLocalProfile(foundUser)) {
throw new ErrLocalProfile();
}
throw new Error('local auth attachment failed due to unexpected reason');
}
// Send off the email to the new email address that we need to verify the
// new address.
await Users.sendEmailConfirmation(updatedUser, email);
} catch (err) {
if (err.code === 11000) {
throw new ErrEmailTaken();
}
throw err;
}
}
module.exports = ctx => {
const mutators = {
User: {
updateEmailAddress: () => Promise.reject(new ErrNotAuthorized()),
attachLocalAuth: () => Promise.reject(new ErrNotAuthorized()),
},
};
if (ctx.user) {
mutators.User.updateEmailAddress = ({ email, confirmPassword }) =>
updateUserEmailAddress(ctx, email, confirmPassword);
mutators.User.attachLocalAuth = ({ email, password }) =>
attachUserLocalAuth(ctx, email, password);
}
return mutators;
};
@@ -0,0 +1,10 @@
module.exports = {
RootMutation: {
updateEmailAddress: async (root, { input }, { mutators: { User } }) => {
await User.updateEmailAddress(input);
},
attachLocalAuth: async (root, { input }, { mutators: { User } }) => {
await User.attachLocalAuth(input);
},
},
};
@@ -0,0 +1,9 @@
en:
email:
email_change_original:
subject: Email change
body: Your email address has been changed from {0} to {1}. If you did not initiate this change, please contact {2}. # TODO: update translation
error:
NO_LOCAL_PROFILE: No existing email address is associated with this account.
LOCAL_PROFILE: An email address is already associated with this account.
INCORRECT_PASSWORD: Provided password was incorrect.
@@ -0,0 +1,47 @@
# UpdateEmailAddressInput provides input for changing a users email address
# associated with their account.
input UpdateEmailAddressInput {
# email is the Users email address that they want to update to.
email: String!
# confirmPassword is the Users current password.
confirmPassword: String!
}
# UpdateEmailAddressResponse is returned when you try to update a users email
# address.
type UpdateEmailAddressResponse implements Response {
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
}
# AttachLocalAuthInput provides the input for attaching a new local
# authentication profile.
input AttachLocalAuthInput {
# email is the Users email address that they want to add.
email: String!
# password is the Users password that they want to add.
password: String!
}
# AttachLocalAuthResponse returns any errors for when the user attempts to
# attach a new local authentication profile.
type AttachLocalAuthResponse implements Response {
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
}
type RootMutation {
# updateEmailAddress changes the email address of the current logged in user.
updateEmailAddress(input: UpdateEmailAddressInput!): UpdateEmailAddressResponse
# attachLocalAuth will attach a new local authentication profile to an
# account.
attachLocalAuth(input: AttachLocalAuthInput!): AttachLocalAuthResponse
}
@@ -0,0 +1,7 @@
const fs = require('fs');
const path = require('path');
module.exports = fs.readFileSync(
path.join(__dirname, 'typeDefs.graphql'),
'utf8'
);
@@ -1,17 +1,23 @@
const { get, map } = require('lodash');
const path = require('path');
const handle = async (ctx, comment) => {
const commentAddedHandler = async (ctx, comment) => {
// Check to see if this reply is visible.
if (!comment.visible) {
ctx.log.info('comment was not visible, not sending notification');
ctx.log.info(
{ commentID: comment.id },
'comment was not visible, not sending notification'
);
return;
}
// Check to see if this is a reply to an existing comment.
const parentID = get(comment, 'parent_id', null);
if (parentID === null) {
ctx.log.info('could not get parent comment id');
if (!parentID) {
ctx.log.info(
{ commentID: comment.id },
'could not get parent comment id, comment must be a top level comment'
);
return;
}
@@ -40,42 +46,60 @@ const handle = async (ctx, comment) => {
return;
}
const parentComment = get(reply, 'data.comment');
if (!parentComment) {
ctx.log.info({ parentID }, 'could not get parent comment');
return;
}
// Check if the user has notifications enabled.
const enabled = get(
reply,
'data.comment.user.notificationSettings.onReply',
parentComment,
'user.notificationSettings.onReply',
false
);
if (!enabled) {
ctx.log.error(
'parent comment author does not have notification category enabled'
);
return;
}
const userID = get(reply, 'data.comment.user.id', null);
if (!userID) {
ctx.log.info('could not get parent comment user id');
const parentAuthor = get(parentComment, 'user', null);
if (!parentAuthor) {
ctx.log.info('could not get parent author');
return;
}
// Pull out the author of the new comment.
// Pull out the author of the new comment. This was outputted from Mongo, so
// we have to pull it out of the `author_id` field.
const authorID = get(comment, 'author_id');
// Check to see if this is yourself replying to yourself, if that's the case
// don't send a notification.
if (userID === authorID) {
if (parentAuthor.id === authorID) {
ctx.log.info('user id of parent comment is the same as the new comment');
return;
}
// Check to see if this user is ignoring the user who replied to their
// comment.
if (map(get(comment, 'user.ignoredUsers', []), 'id').indexOf(authorID)) {
ctx.log.info('parent user has ignored the author of the new comment');
const ignoredUsers = map(get(parentAuthor, 'ignoredUsers', []), 'id');
if (ignoredUsers.includes(authorID)) {
ctx.log.info(
{ parentAuthorID: parentAuthor.id, authorID },
'parent user has ignored the author of the new comment'
);
return;
}
// The user does have notifications for replied comments enabled, queue the
// notification to be sent.
return { userID, date: comment.created_at, context: comment.id };
return {
userID: parentAuthor.id,
date: comment.created_at,
context: comment.id,
};
};
const hydrate = async (ctx, category, context) => {
@@ -133,7 +157,7 @@ const commentAcceptedHandleAdapter = (ctx, comment) => {
}
// Delegate to the handle function.
return handle(ctx, comment);
return commentAddedHandler(ctx, comment);
};
module.exports = {
@@ -155,7 +179,7 @@ module.exports = {
translations: path.join(__dirname, 'translations.yml'),
notifications: [
{
handle,
handle: commentAddedHandler,
category: 'reply',
event: 'commentAdded',
hydrate,
@@ -32,7 +32,10 @@ const handleHandlers = (ctx, handlers, ...args) =>
// Attempt to create a notification out of it.
const notification = await handle(ctx, ...args);
if (!notification) {
ctx.log.info('no notification deemed by event handler');
ctx.log.info(
{ category, event },
'no notification deemed by event handler'
);
return;
}
@@ -3,7 +3,7 @@ const getOrganizationName = async ctx => {
const { loaders: { Settings } } = ctx;
// Get the settings.
const { organizationName = null } = await Settings.load('organizationName');
const { organizationName = null } = await Settings.select('organizationName');
return organizationName;
};
+10 -1
View File
@@ -21,4 +21,13 @@ that contains a download link. Only one link can be generated every 7 days, and
the link will be valid for 24 hours.
The downloaded zip file will contain all the users comments in a CSV format
including those that have been rejected, withheld, or still in premod.
including those that have been rejected, withheld, or still in pre-moderation.
## GDPR Compliance
In order to facilitate compliance with the
[EU General Data Protection Regulation (GDPR)](https://www.eugdpr.org/), you
should review our [GDPR Compliance](/talk/integrating/gdpr/) guidelines. This
plugin can work with its client plugin disabled and then directly integrated
with existing workflows for an organization of any size through use of the API
that this plugin provides.
@@ -0,0 +1,44 @@
.container {
border: 1px solid #f26563;
border-radius: 2px;
color: #3b4a53;
padding: 20px 10px;
background-color: rgba(242, 101, 99, 0.1);
margin: 20px 0;
}
.button {
color: #787D80;
border-radius: 2px;
background-color: transparent;
height: 30px;
font-size: 0.9em;
line-height: normal;
&:hover {
background-color: #eaeaea;
}
&.secondary {
background-color: #787D80;
color: white;
}
}
.title {
margin: 0;
i.icon {
font-size: 1em;
padding: 4px;
}
}
.description {
margin: 0;
padding-left: 22px;
padding-bottom: 15px;
}
.actions {
text-align: center;
}
@@ -0,0 +1,68 @@
import React from 'react';
import PropTypes from 'prop-types';
import cn from 'classnames';
import { t } from 'plugin-api/beta/client/services';
import moment from 'moment';
import { Button, Icon } from 'plugin-api/beta/client/components/ui';
import styles from './AccountDeletionRequestedSign.css';
import { getErrorMessages } from 'coral-framework/utils';
import { scheduledDeletionDelayHours } from '../../config';
class AccountDeletionRequestedSign extends React.Component {
cancelAccountDeletion = async () => {
const { cancelAccountDeletion, notify } = this.props;
try {
await cancelAccountDeletion();
notify('success', t('delete_request.account_deletion_cancelled'));
} catch (err) {
notify('error', getErrorMessages(err));
}
};
render() {
const { me: { scheduledDeletionDate } } = this.props.root;
const deletionScheduledFor = moment(scheduledDeletionDate).format(
'MMM Do YYYY, h:mm a'
);
const deletionScheduledOn = moment(scheduledDeletionDate)
.subtract(scheduledDeletionDelayHours, 'hours')
.format('MMM Do YYYY, h:mm a');
return (
<div className={styles.container}>
<h4 className={styles.title}>
<Icon name="warning" className={styles.icon} />{' '}
{t('delete_request.account_deletion_requested')}
</h4>
<p className={styles.description}>
{t('delete_request.received_on')}
{deletionScheduledOn}.
</p>
<p className={styles.description}>
{t('delete_request.cancel_request_description')}
<b>
{' '}
{t('delete_request.before')} {deletionScheduledFor}
</b>.
</p>
<div className={styles.actions}>
<Button
className={cn(styles.button, styles.secondary)}
onClick={this.cancelAccountDeletion}
>
{t('delete_request.cancel_account_deletion_request')}
</Button>
</div>
</div>
);
}
}
AccountDeletionRequestedSign.propTypes = {
cancelAccountDeletion: PropTypes.func.isRequired,
notify: PropTypes.func.isRequired,
root: PropTypes.object.isRequired,
};
export default AccountDeletionRequestedSign;
@@ -0,0 +1,94 @@
import React from 'react';
import PropTypes from 'prop-types';
import moment from 'moment';
import { Button } from 'plugin-api/beta/client/components/ui';
import DeleteMyAccountDialog from './DeleteMyAccountDialog';
import { getErrorMessages } from 'coral-framework/utils';
import { t } from 'plugin-api/beta/client/services';
const initialState = { showDialog: false };
class DeleteMyAccount extends React.Component {
state = initialState;
showDialog = () => {
this.setState({
showDialog: true,
});
};
closeDialog = () => {
this.setState({
showDialog: false,
});
};
cancelAccountDeletion = async () => {
const { cancelAccountDeletion, notify } = this.props;
try {
await cancelAccountDeletion();
notify('success', t('delete_request.account_deletion_cancelled'));
} catch (err) {
notify('error', getErrorMessages(err));
}
};
requestAccountDeletion = async () => {
const { requestAccountDeletion, notify } = this.props;
try {
await requestAccountDeletion();
notify('success', t('delete_request.account_deletion_requested'));
} catch (err) {
notify('error', getErrorMessages(err));
}
};
render() {
const {
me: { scheduledDeletionDate },
settings: { organizationContactEmail },
} = this.props.root;
return (
<div className="talk-plugin-auth--delete-my-account">
<DeleteMyAccountDialog
requestAccountDeletion={this.requestAccountDeletion}
showDialog={this.state.showDialog}
closeDialog={this.closeDialog}
scheduledDeletionDate={scheduledDeletionDate}
organizationContactEmail={organizationContactEmail}
/>
<h3 className="talk-plugin-auth--delete-my-account-description">
{t('delete_request.delete_my_account')}
</h3>
<p className="talk-plugin-auth--delete-my-account-description">
{t('delete_request.delete_my_account_description')}
</p>
<p className="talk-plugin-auth--delete-my-account-description">
{scheduledDeletionDate &&
t(
'delete_request.already_submitted_request_description',
moment(scheduledDeletionDate).format('MMM Do YYYY, h:mm:ss a')
)}
</p>
{scheduledDeletionDate ? (
<Button onClick={this.cancelAccountDeletion}>
{t('delete_request.cancel_account_deletion_request')}
</Button>
) : (
<Button icon="delete" onClick={this.showDialog}>
{t('delete_request.delete_my_account')}
</Button>
)}
</div>
);
}
}
DeleteMyAccount.propTypes = {
requestAccountDeletion: PropTypes.func.isRequired,
cancelAccountDeletion: PropTypes.func.isRequired,
notify: PropTypes.func.isRequired,
root: PropTypes.object.isRequired,
};
export default DeleteMyAccount;
@@ -0,0 +1,45 @@
@custom-media --small-viewport (min-width: 425px);
.dialog {
width: calc(100% - 50px);
border: none;
box-shadow: 0 9px 46px 8px rgba(0, 0, 0, 0.14), 0 11px 15px -7px rgba(0, 0, 0, 0.12), 0 24px 38px 3px rgba(0, 0, 0, 0.2);
top: 10px;
font-family: Helvetica, 'Helvetica Neue', Verdana, sans-serif;
font-size: 14px;
border-radius: 4px;
padding: 20px;
@media (--small-viewport) {
width: 380px;
}
}
.close {
font-size: 20px;
line-height: 14px;
top: 10px;
right: 10px;
position: absolute;
display: block;
font-weight: bold;
color: #363636;
cursor: pointer;
&:hover {
color: #6b6b6b;
}
}
.title {
font-size: 1.2em;
margin-top: 0;
margin-bottom: 8px;
}
.description {
font-size: 1em;
line-height: 20px;
margin: 0;
}
@@ -0,0 +1,104 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './DeleteMyAccountDialog.css';
import { Dialog } from 'plugin-api/beta/client/components/ui';
import StepProgress from './StepProgress';
import DeleteMyAccountStep0 from './DeleteMyAccountStep0';
import DeleteMyAccountStep1 from './DeleteMyAccountStep1';
import DeleteMyAccountStep2 from './DeleteMyAccountStep2';
import DeleteMyAccountStep3 from './DeleteMyAccountStep3';
import DeleteMyAccountFinalStep from './DeleteMyAccountFinalStep';
import { t } from 'plugin-api/beta/client/services';
const initialState = { step: 0, formData: {} };
class DeleteMyAccountDialog extends React.Component {
state = initialState;
goToNextStep = () => {
this.setState(state => ({
step: state.step + 1,
}));
};
clear = () => {
this.setState(initialState);
};
cancel = () => {
this.clear();
this.props.closeDialog();
};
onChange = e => {
const { name, value } = e.target;
this.setState(state => ({
formData: {
...state.formData,
[name]: value,
},
}));
};
render() {
const { step } = this.state;
const { scheduledDeletionDate, organizationContactEmail } = this.props;
return (
<Dialog open={this.props.showDialog} className={styles.dialog}>
<span className={styles.close} onClick={this.cancel}>
×
</span>
<h3 className={styles.title}>
{t('delete_request.delete_my_account')}
</h3>
<StepProgress currentStep={this.state.step} totalSteps={4} />
{step === 0 && (
<DeleteMyAccountStep0
goToNextStep={this.goToNextStep}
cancel={this.cancel}
/>
)}
{step === 1 && (
<DeleteMyAccountStep1
goToNextStep={this.goToNextStep}
cancel={this.cancel}
/>
)}
{step === 2 && (
<DeleteMyAccountStep2
goToNextStep={this.goToNextStep}
cancel={this.cancel}
/>
)}
{step === 3 && (
<DeleteMyAccountStep3
formData={this.state.formData}
goToNextStep={this.goToNextStep}
cancel={this.cancel}
requestAccountDeletion={this.props.requestAccountDeletion}
onChange={this.onChange}
/>
)}
{step === 4 && (
<DeleteMyAccountFinalStep
scheduledDeletionDate={scheduledDeletionDate}
organizationContactEmail={organizationContactEmail}
finish={this.cancel}
/>
)}
</Dialog>
);
}
}
DeleteMyAccountDialog.propTypes = {
showDialog: PropTypes.bool.isRequired,
closeDialog: PropTypes.func.isRequired,
requestAccountDeletion: PropTypes.func.isRequired,
scheduledDeletionDate: PropTypes.any,
organizationContactEmail: PropTypes.string.isRequired,
};
export default DeleteMyAccountDialog;
@@ -0,0 +1,60 @@
import React from 'react';
import cn from 'classnames';
import PropTypes from 'prop-types';
import { Button, Icon } from 'plugin-api/beta/client/components/ui';
import styles from './DeleteMyAccountStep.css';
import { t } from 'plugin-api/beta/client/services';
import moment from 'moment';
const DeleteMyAccountFinalStep = props => (
<div className={styles.step}>
<p className={styles.description}>
{t('delete_request.your_request_submitted_description')}
</p>
<div className={cn(styles.box, styles.scheduledDeletion)}>
<strong className={styles.block}>
{t('delete_request.your_account_deletion_scheduled')}
</strong>
<strong className={styles.block}>
<Icon name="access_time" className={styles.timeIcon} />
<span>
{moment(props.scheduledDeletionDate).format('MMM Do YYYY, h:mm a')}
</span>
</strong>
</div>
<p className={styles.description}>
<strong> {t('delete_request.changed_your_mind')}</strong>{' '}
{t('delete_request.simply_go_to')} <strong>
{t('delete_request.cancel_account_deletion_request')}.
</strong>
</p>
<p className={styles.description}>
<strong>{t('delete_request.tell_us_why')}.</strong>{' '}
{t('delete_request.feedback_copy')}{' '}
<a href={`mailto:${props.organizationContactEmail}`}>
{props.organizationContactEmail}
</a>.
</p>
<div className={cn(styles.actions, styles.columnView)}>
<Button
className={cn(styles.button, styles.proceed)}
onClick={props.finish}
full
>
{t('delete_request.done')}
</Button>
</div>
</div>
);
DeleteMyAccountFinalStep.propTypes = {
finish: PropTypes.func.isRequired,
scheduledDeletionDate: PropTypes.any.isRequired,
organizationContactEmail: PropTypes.string.isRequired,
};
export default DeleteMyAccountFinalStep;
@@ -0,0 +1,116 @@
.list {
padding: 0;
margin: 20px 0;
list-style: none;
}
.item {
display: flex;
margin-bottom: 20px;
.itemIcon {
flex-grow: 0;
}
.text {
flex-grow: 1;
padding-left: 10px;
}
> i.itemIcon {
font-size: 1.3em;
}
}
.button {
color: #787D80;
border-radius: 2px;
background-color: transparent;
height: 30px;
font-size: 0.9em;
line-height: normal;
&:hover {
background-color: #eaeaea;
}
&.cancel {
background-color: transparent;
color: #787D80;
}
&.proceed {
background-color: #3498DB;
color: white;
}
&.danger {
background-color: #FA4643;
color: white;
}
}
.actions {
text-align: right;
padding-top: 20px;
&.columnView {
display: flex;
flex-direction: column;
align-items: center;
}
}
.title {
font-size: 1.2em;
margin-bottom: 8px;
}
.description {
font-size: 1em;
line-height: 20px;
margin: 0;
margin-bottom: 15px;
}
.box {
display: flex;
flex-direction: column;
margin-bottom: 15px;
}
.note {
margin: 10px 0;
}
.subTitle {
font-size: 1em;
margin-bottom: 8px;
}
.textBox {
background-color: #F1F2F2;
border: none;
width: 100%;
padding: 15px;
box-sizing: border-box;
color: #3B4A53;
font-size: 1em;
margin-bottom: 15px;
}
.block {
display: block;
margin-top: 2px;
}
.step {
padding-top: 20px;
}
.scheduledDeletion {
i.timeIcon {
font-size: 1.2em;
padding: 4px;
}
}
@@ -0,0 +1,49 @@
import React from 'react';
import PropTypes from 'prop-types';
import cn from 'classnames';
import { Button, Icon } from 'plugin-api/beta/client/components/ui';
import styles from './DeleteMyAccountStep.css';
import { t } from 'plugin-api/beta/client/services';
const DeleteMyAccountStep0 = props => (
<div className={styles.step}>
<p className={styles.description}>
{t('delete_request.step_0.you_are_attempting')}
</p>
<ul className={styles.list}>
<li className={styles.item}>
<Icon name="done" className={styles.itemIcon} />
<span className={styles.text}>{t('delete_request.step_0.item_1')}</span>
</li>
<li className={styles.item}>
<Icon name="done" className={styles.itemIcon} />
<span className={styles.text}>{t('delete_request.step_0.item_2')}</span>
</li>
<li className={styles.item}>
<Icon name="done" className={styles.itemIcon} />
<span className={styles.text}>{t('delete_request.step_0.item_3')}</span>
</li>
</ul>
<div className={cn(styles.actions)}>
<Button
className={cn(styles.button, styles.cancel)}
onClick={props.cancel}
>
{t('delete_request.cancel')}
</Button>
<Button
className={cn(styles.button, styles.proceed)}
onClick={props.goToNextStep}
>
{t('delete_request.proceed')}
</Button>
</div>
</div>
);
DeleteMyAccountStep0.propTypes = {
goToNextStep: PropTypes.func.isRequired,
cancel: PropTypes.func.isRequired,
};
export default DeleteMyAccountStep0;
@@ -0,0 +1,41 @@
import React from 'react';
import PropTypes from 'prop-types';
import cn from 'classnames';
import { Button } from 'plugin-api/beta/client/components/ui';
import styles from './DeleteMyAccountStep.css';
import { t } from 'plugin-api/beta/client/services';
import { scheduledDeletionDelayHours } from '../../config';
const DeleteMyAccountStep1 = props => (
<div className={styles.step}>
<h4 className={styles.subTitle}>{t('delete_request.step_1.subtitle')}</h4>
<p className={styles.description}>
{t('delete_request.step_1.description', scheduledDeletionDelayHours)}
</p>
<h4 className={styles.subTitle}>{t('delete_request.step_1.subtitle_2')}</h4>
<p className={styles.description}>
{t('delete_request.step_1.description_2', scheduledDeletionDelayHours)}
</p>
<div className={cn(styles.actions)}>
<Button
className={cn(styles.button, styles.cancel)}
onClick={props.cancel}
>
{t('delete_request.cancel')}
</Button>
<Button
className={cn(styles.button, styles.proceed)}
onClick={props.goToNextStep}
>
{t('delete_request.proceed')}
</Button>
</div>
</div>
);
DeleteMyAccountStep1.propTypes = {
goToNextStep: PropTypes.func.isRequired,
cancel: PropTypes.func.isRequired,
};
export default DeleteMyAccountStep1;
@@ -0,0 +1,41 @@
import React from 'react';
import PropTypes from 'prop-types';
import cn from 'classnames';
import { Button } from 'plugin-api/beta/client/components/ui';
import styles from './DeleteMyAccountStep.css';
import { t } from 'plugin-api/beta/client/services';
const DeleteMyAccountStep2 = props => (
<div className={styles.step}>
<p className={styles.description}>
{t('delete_request.step_2.description')}
</p>
<p className={styles.description}>
{t('delete_request.step_2.to_download')}
<strong className={styles.block}>
{t('delete_request.step_2.path')}
</strong>
</p>
<div className={cn(styles.actions)}>
<Button
className={cn(styles.button, styles.cancel)}
onClick={props.cancel}
>
{t('delete_request.cancel')}
</Button>
<Button
className={cn(styles.button, styles.proceed)}
onClick={props.goToNextStep}
>
{t('delete_request.proceed')}
</Button>
</div>
</div>
);
DeleteMyAccountStep2.propTypes = {
goToNextStep: PropTypes.func.isRequired,
cancel: PropTypes.func.isRequired,
};
export default DeleteMyAccountStep2;
@@ -0,0 +1,96 @@
import React from 'react';
import PropTypes from 'prop-types';
import cn from 'classnames';
import { Button } from 'plugin-api/beta/client/components/ui';
import styles from './DeleteMyAccountStep.css';
import InputField from './InputField';
import { t } from 'plugin-api/beta/client/services';
const initialState = {
showError: false,
};
class DeleteMyAccountStep3 extends React.Component {
state = initialState;
phrase = 'delete';
showError = () => {
this.setState({
showError: true,
});
};
clear = () => {
this.setState(initialState);
};
deleteAndContinue = async () => {
if (this.formHasError()) {
this.showError();
return;
}
await this.props.requestAccountDeletion();
this.clear();
this.props.goToNextStep();
};
formHasError = () =>
!this.props.formData.confirmPhrase ||
this.props.formData.confirmPhrase !== this.phrase;
render() {
return (
<div className={styles.step}>
<h4 className={styles.subTitle}>
{t('delete_request.step_3.subtitle')}
</h4>
<p className={styles.description}>
{t('delete_request.step_3.description')}
</p>
<input
className={styles.textBox}
disabled={true}
readOnly={true}
value={this.phrase}
/>
<InputField
id="confirmPhrase"
label={t('delete_request.step_3.type_to_confirm')}
name="confirmPhrase"
type="text"
onChange={this.props.onChange}
defaultValue=""
hasError={this.formHasError()}
errorMsg={t('delete_request.input_is_not_correct')}
showError={this.state.showError}
columnDisplay
/>
<div className={cn(styles.actions)}>
<Button
className={cn(styles.button, styles.cancel)}
onClick={this.props.cancel}
>
{t('delete_request.cancel')}
</Button>
<Button
className={cn(styles.button, styles.danger)}
onClick={this.deleteAndContinue}
>
{t('delete_request.delete_my_account')}
</Button>
</div>
</div>
);
}
}
DeleteMyAccountStep3.propTypes = {
goToNextStep: PropTypes.func.isRequired,
requestAccountDeletion: PropTypes.func.isRequired,
cancel: PropTypes.func.isRequired,
onChange: PropTypes.func.isRequired,
formData: PropTypes.object.isRequired,
};
export default DeleteMyAccountStep3;
@@ -3,6 +3,8 @@ import PropTypes from 'prop-types';
import { t } from 'plugin-api/beta/client/services';
import { Button } from 'plugin-api/beta/client/components/ui';
import styles from './DownloadCommentHistory.css';
import { getErrorMessages } from 'coral-framework/utils';
import { downloadRateLimitDays } from '../../config';
export const readableDuration = durAsHours => {
const durAsDays = Math.ceil(durAsHours / 24);
@@ -19,21 +21,30 @@ export const readableDuration = durAsHours => {
class DownloadCommentHistory extends Component {
static propTypes = {
requestDownloadLink: PropTypes.func.isRequired,
notify: PropTypes.func.isRequired,
root: PropTypes.object.isRequired,
};
requestDownloadLink = async () => {
const { requestDownloadLink, notify } = this.props;
try {
await requestDownloadLink();
notify('success', t('download_request.download_preparing'));
} catch (err) {
notify('error', getErrorMessages(err));
}
};
render() {
const {
root: { me: { lastAccountDownload } },
requestDownloadLink,
} = this.props;
const { root: { me: { lastAccountDownload } } } = this.props;
const now = new Date();
const lastAccountDownloadDate =
lastAccountDownload && new Date(lastAccountDownload);
const hoursLeft = lastAccountDownloadDate
? Math.ceil(
7 * 24 - (now.getTime() - lastAccountDownloadDate.getTime()) / 3.6e6
downloadRateLimitDays * 24 -
(now.getTime() - lastAccountDownloadDate.getTime()) / 3.6e6
)
: 0;
const canRequestDownload = !lastAccountDownloadDate || hoursLeft <= 0;
@@ -43,7 +54,7 @@ class DownloadCommentHistory extends Component {
<h3>{t('download_request.section_title')}</h3>
<p>
{t('download_request.you_will_get_a_copy')}{' '}
<b>{t('download_request.download_rate')}</b>.
<b>{t('download_request.download_rate', downloadRateLimitDays)}</b>.
</p>
{lastAccountDownloadDate && (
<p className={styles.most_recent}>
@@ -52,7 +63,7 @@ class DownloadCommentHistory extends Component {
</p>
)}
{canRequestDownload ? (
<Button className={styles.button} onClick={requestDownloadLink}>
<Button className={styles.button} onClick={this.requestDownloadLink}>
<i className="material-icons" aria-hidden={true}>
file_download
</i>{' '}
@@ -0,0 +1,12 @@
.errorMsg {
color: #FA4643;
font-size: 0.9em;
i.warningIcon {
font-size: 17px;
}
}
.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,85 @@
.detailItem {
margin-bottom: 12px;
}
.detailItemContainer {
display: flex;
flex-direction: column;
}
.columnDisplay {
flex-direction: column;
.detailItemMessage {
padding: 4px 0 0;
}
}
.detailItemContent {
display: flex;
}
.detailInput {
border: solid 1px #787D80;
border-radius: 2px;
background-color: white;
height: 30px;
display: inline-block;
width: 230px;
display: flex;
box-sizing: border-box;
> .detailIcon {
font-size: 1.2em;
padding: 0 5px;
color: #787D80;
line-height: 30px;
}
&.error {
border: solid 2px #FA4643;
}
&.disabled {
background-color: #E0E0E0;
}
}
.detailLabel {
color: #4C4C4D;
font-size: 1em;
display: block;
margin-bottom: 4px;
}
.detailValue {
background: transparent;
border: none;
font-size: 1em;
color: #000;
outline: none;
flex: 1;
height: 100%;
box-sizing: border-box;
padding: 0 6px;
}
.detailItemMessage {
flex-grow: 1;
display: flex;
align-items: center;
padding-left: 6px;
.warningIcon, .checkIcon {
font-size: 17px;
}
}
.checkIcon {
color: #00CD73;
}
.warningIcon {
color: #FA4643;
}
@@ -0,0 +1,98 @@
import React from 'react';
import PropTypes from 'prop-types';
import cn from 'classnames';
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 = () => {},
showError = true,
hasError = false,
errorMsg = '',
children,
columnDisplay = false,
showSuccess = false,
validationType = '',
icon = '',
value = '',
defaultValue = '',
disabled = false,
}) => {
const inputValue = {
...(value ? { value } : {}),
...(defaultValue ? { defaultValue } : {}),
};
return (
<div className={styles.detailItem}>
<div className={cn(styles.detailItemContainer)}>
{label && (
<label className={styles.detailLabel} id={id}>
{label}
</label>
)}
<div
className={cn(styles.detailItemContent, {
[styles.columnDisplay]: columnDisplay,
})}
>
<div
className={cn(
styles.detailInput,
{ [styles.error]: hasError && showError },
{ [styles.disabled]: disabled }
)}
>
{icon && <Icon name={icon} className={styles.detailIcon} />}
<input
id={id}
type={type}
name={name}
className={styles.detailValue}
onChange={onChange}
autoComplete="off"
data-validation-type={validationType}
disabled={disabled}
{...inputValue}
/>
</div>
<div className={styles.detailItemMessage}>
{!hasError &&
showSuccess &&
value && (
<Icon className={styles.checkIcon} name="check_circle" />
)}
{hasError && showError && <ErrorMessage>{errorMsg}</ErrorMessage>}
</div>
</div>
</div>
{children}
</div>
);
};
InputField.propTypes = {
id: PropTypes.string,
disabled: PropTypes.bool,
label: PropTypes.string,
type: PropTypes.string,
name: PropTypes.string.isRequired,
onChange: PropTypes.func,
value: PropTypes.string,
defaultValue: PropTypes.string,
icon: PropTypes.string,
showError: PropTypes.bool,
hasError: PropTypes.bool,
errorMsg: PropTypes.string,
children: PropTypes.node,
columnDisplay: PropTypes.bool,
showSuccess: PropTypes.bool,
validationType: PropTypes.string,
};
export default InputField;
@@ -0,0 +1,36 @@
.step {
color: #BBBEBF;
background-color: white;
padding: 6px;
z-index: 10;
> .icon {
font-size: 25px;
}
&.current {
color: rgba(0, 205, 115, 0.3);
}
&.completed {
color: #00CD73;
}
}
.line {
position: absolute;
border: solid 2px #BBBEBF;
width: 100%;
box-sizing: border-box;
margin: 0;
padding: 0;
}
.container {
display: flex;
position: relative;
justify-content: space-between;
align-items: center;
height: 50px;
margin: 0 20px;
}
@@ -0,0 +1,48 @@
import React from 'react';
import cn from 'classnames';
import PropTypes from 'prop-types';
import styles from './StepProgress.css';
import { Icon } from 'plugin-api/beta/client/components/ui';
const CheckItem = ({ current = false, completed = false }) => (
<span
className={cn(styles.step, {
[styles.current]: current,
[styles.completed]: completed,
})}
>
<Icon name="check_circle" className={styles.icon} />
</span>
);
CheckItem.propTypes = {
current: PropTypes.bool.isRequired,
completed: PropTypes.bool.isRequired,
};
const Line = () => <hr className={styles.line} />;
class StepProgress extends React.Component {
render() {
const { currentStep, totalSteps } = this.props;
return (
<div className={styles.container}>
{Array.from({ length: totalSteps }).map((_, i) => (
<CheckItem
key={`step_${i}`}
completed={i < currentStep}
current={currentStep === i}
/>
))}
<Line />
</div>
);
}
}
StepProgress.propTypes = {
currentStep: PropTypes.number.isRequired,
totalSteps: PropTypes.number.isRequired,
};
export default StepProgress;
@@ -0,0 +1,25 @@
import { compose, gql } from 'react-apollo';
import { bindActionCreators } from 'redux';
import { connect, withFragments, excludeIf } from 'plugin-api/beta/client/hocs';
import AccountDeletionRequestedSign from '../components/AccountDeletionRequestedSign';
import { notify } from 'coral-framework/actions/notification';
import { withCancelAccountDeletion } from '../hocs';
const mapDispatchToProps = dispatch => bindActionCreators({ notify }, dispatch);
const withData = withFragments({
root: gql`
fragment Talk_AccountDeletionRequestedSignIn_root on RootQuery {
me {
scheduledDeletionDate
}
}
`,
});
export default compose(
connect(null, mapDispatchToProps),
withCancelAccountDeletion,
withData,
excludeIf(({ root: { me } }) => !me || !me.scheduledDeletionDate)
)(AccountDeletionRequestedSign);
@@ -0,0 +1,28 @@
import { compose, gql } from 'react-apollo';
import { bindActionCreators } from 'redux';
import { connect, withFragments } from 'plugin-api/beta/client/hocs';
import DeleteMyAccount from '../components/DeleteMyAccount';
import { notify } from 'coral-framework/actions/notification';
import { withRequestAccountDeletion, withCancelAccountDeletion } from '../hocs';
const mapDispatchToProps = dispatch => bindActionCreators({ notify }, dispatch);
const withData = withFragments({
root: gql`
fragment Talk_DeleteMyAccount_root on RootQuery {
me {
scheduledDeletionDate
}
settings {
organizationContactEmail
}
}
`,
});
export default compose(
connect(null, mapDispatchToProps),
withRequestAccountDeletion,
withCancelAccountDeletion,
withData
)(DeleteMyAccount);
@@ -1,27 +1,34 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { bindActionCreators } from 'redux';
import { compose, gql } from 'react-apollo';
import DownloadCommentHistory from '../components/DownloadCommentHistory';
import { withFragments } from 'plugin-api/beta/client/hocs';
import { withRequestDownloadLink } from '../mutations';
import { withRequestDownloadLink } from '../hocs';
import { connect, withFragments } from 'plugin-api/beta/client/hocs';
import { notify } from 'coral-framework/actions/notification';
class DownloadCommentHistoryContainer extends Component {
static propTypes = {
requestDownloadLink: PropTypes.func.isRequired,
root: PropTypes.object.isRequired,
notify: PropTypes.func.isRequired,
};
render() {
return (
<DownloadCommentHistory
root={this.props.root}
notify={this.props.notify}
requestDownloadLink={this.props.requestDownloadLink}
/>
);
}
}
const mapDispatchToProps = dispatch => bindActionCreators({ notify }, dispatch);
const enhance = compose(
connect(null, mapDispatchToProps),
withFragments({
root: gql`
fragment TalkDownloadCommentHistory_DownloadCommentHistorySection_root on RootQuery {
@@ -1,6 +1,14 @@
import update from 'immutability-helper';
import { createDefaultResponseFragments } from 'coral-framework/utils';
export default {
fragments: {
...createDefaultResponseFragments(
'RequestAccountDeletionResponse',
'RequestDownloadLinkResponse',
'CancelAccountDeletionResponse'
),
},
mutations: {
DownloadCommentHistory: () => ({
updateQueries: {
@@ -0,0 +1,111 @@
import { withMutation } from 'plugin-api/beta/client/hocs';
import { gql } from 'react-apollo';
import moment from 'moment';
import update from 'immutability-helper';
export const withRequestDownloadLink = withMutation(
gql`
mutation DownloadCommentHistory {
requestDownloadLink {
errors {
translation_key
}
}
}
`,
{
props: ({ mutate }) => ({
requestDownloadLink: () => mutate({ variables: {} }),
}),
}
);
export const withRequestAccountDeletion = withMutation(
gql`
mutation RequestAccountDeletion {
requestAccountDeletion {
...RequestAccountDeletionResponse
}
}
`,
{
props: ({ mutate }) => ({
requestAccountDeletion: () => {
return mutate({
variables: {},
update: proxy => {
const RequestAccountDeletionQuery = gql`
query Talk_CancelAccountDeletion {
me {
id
scheduledDeletionDate
}
}
`;
const prev = proxy.readQuery({
query: RequestAccountDeletionQuery,
});
const scheduledDeletionDate = moment()
.add(24, 'hours')
.toDate();
const data = update(prev, {
me: {
scheduledDeletionDate: { $set: scheduledDeletionDate },
},
});
proxy.writeQuery({
query: RequestAccountDeletionQuery,
data,
});
},
});
},
}),
}
);
export const withCancelAccountDeletion = withMutation(
gql`
mutation RequestDownloadLink {
cancelAccountDeletion {
...CancelAccountDeletionResponse
}
}
`,
{
props: ({ mutate }) => ({
cancelAccountDeletion: () => {
return mutate({
variables: {},
update: proxy => {
const CancelAccountDeletionQuery = gql`
query Talk_CancelAccountDeletion {
me {
id
scheduledDeletionDate
}
}
`;
const prev = proxy.readQuery({ query: CancelAccountDeletionQuery });
const data = update(prev, {
me: {
scheduledDeletionDate: { $set: null },
},
});
proxy.writeQuery({
query: CancelAccountDeletionQuery,
data,
});
},
});
},
}),
}
);
@@ -1,10 +1,13 @@
import DownloadCommentHistory from './containers/DownloadCommentHistory';
import DeleteMyAccount from './containers/DeleteMyAccount';
import AccountDeletionRequestedSign from './containers/AccountDeletionRequestedSign';
import translations from './translations.yml';
import graphql from './graphql';
export default {
slots: {
profileSettings: [DownloadCommentHistory],
stream: [AccountDeletionRequestedSign],
profileSettings: [DownloadCommentHistory, DeleteMyAccount],
},
translations,
...graphql,
@@ -1,19 +0,0 @@
import { withMutation } from 'plugin-api/beta/client/hocs';
import { gql } from 'react-apollo';
export const withRequestDownloadLink = withMutation(
gql`
mutation DownloadCommentHistory {
requestDownloadLink {
errors {
translation_key
}
}
}
`,
{
props: ({ mutate }) => ({
requestDownloadLink: () => mutate({ variables: {} }),
}),
}
);
@@ -1,8 +1,8 @@
en:
download_request:
section_title: "Download My Comment History"
you_will_get_a_copy: "You will recieve an email with a link to download your comment history. You can make"
download_rate: "one download request every 7 days"
you_will_get_a_copy: "You will receive an email with a link to download your comment history. You can make"
download_rate: "one download request every {0} days"
most_recent_request: "Your most recent request"
request: "Request Comment History"
rate_limit: "You can submit another Comment History request in {0}"
@@ -10,3 +10,42 @@ en:
days: "{0} days"
hour: "{0} hour"
day: "{0} day"
download_preparing: "Account Download Preparing - Check your email shortly for a download link"
delete_request:
account_deletion_cancelled: 'Account Deletion Request Cancelled - Your request to delete your account has been cancelled.'
account_deletion_requested: 'Account Deletion Requested'
received_on: "A request to delete your account was received on "
cancel_request_description: "If you would like to reactivate your account, you may cancel your request to delete your account below"
before: "before"
cancel_account_deletion_request: "Cancel Account Deletion Request"
delete_my_account: "Delete My Account"
delete_my_account_description: "Deleting your account will permanently erase your profile and remove all your comments from this site."
already_submitted_request_description: "You have already submitted a request to delete your account. Your account will be deleted after {0}. You may cancel the request until that time"
your_request_submitted_description: "Your request has been submitted and confirmation has been sent to the email address associated with your account."
your_account_deletion_scheduled: "Your account is scheduled to be deleted after:"
changed_your_mind: "Changed your mind?"
simply_go_to: "Simply go to your account again before this time and click"
tell_us_why: "Tell us why"
feedback_copy: "We would like to know why you chose to delete your account. Send us feedback on our comment system by emailing"
done: "Done"
cancel: "Cancel"
proceed: "Proceed"
input_is_not_correct: "The input is not correct"
step_0:
you_are_attempting: "You are attempting to delete your account. This means:"
item_1: "All of your comments are removed from this site"
item_2: "All of your comments are deleted from our database"
item_3: "Your username and email address are removed from our system"
step_1:
subtitle: "When will my account be deleted?"
description: "Your account will be deleted {0} hours after your request has been submitted."
subtitle_2: "Can I still write comments until my account is deleted?"
description_2: "Yes, you can still comment, reply, and react to comments until the {0} hours expires."
step_2:
description: "Before your account is deleted, we recommend you download your comment history for your records. After your account is deleted, you will be unable to request your comment history."
to_download: "To download your comment history go to:"
path: "My Profile > Download My Comment History"
step_3:
subtitle: "Are you sure you want to delete your account?"
description: "To confirm you would like to delete your account please type in the following phrase into the text box below:"
type_to_confirm: "Type phrase below to confirm"
@@ -0,0 +1,4 @@
module.exports = {
scheduledDeletionDelayHours: 24,
downloadRateLimitDays: 7,
};
@@ -7,6 +7,7 @@
"private": false,
"dependencies": {
"archiver": "^2.1.1",
"cron": "^1.3.0",
"csv-stringify": "^3.0.0"
}
}
@@ -1,7 +1,15 @@
const path = require('path');
const moment = require('moment');
const { CronJob } = require('cron');
const { get } = require('lodash');
const { ErrMissingEmail } = require('errors');
module.exports = connectors => {
const { services: { Mailer } } = connectors;
const {
services: { Mailer, I18n },
models: { User },
graph: { Context },
} = connectors;
// Setup the mail templates.
['txt', 'html'].forEach(format => {
@@ -11,4 +19,128 @@ module.exports = connectors => {
format
);
});
// Setup the cron job that will scan for accounts to delete every 30 minutes.
new CronJob({
cronTime: '0,30 * * * *',
timeZone: 'America/New_York',
start: true,
runOnInit: true,
onTick: async () => {
// Create the context we'll use to perform user deletions.
const ctx = Context.forSystem();
try {
// Grab some settings.
const { loaders: { Settings } } = ctx;
const {
organizationName,
organizationContactEmail,
} = await Settings.select(
'organizationName',
'organizationContactEmail'
);
// rescheduledDeletionDate is the date in the future that we'll set the
// user's account to be deleted on if this delete fails.
const rescheduledDeletionDate = moment()
.add(1, 'hours')
.toDate();
// Keep running for each user we can pull.
while (true) {
// We'll find any user that has an account deletion date before now
// and update the user such that their deletion time is 1 hour from
// now. This will ensure that only one instance can pull the same
// user at a time, and if the delete fails, it will be retried an
// hour from now. If the deletion was successful, well, it can't be
// retried because the reference to the scheduledDeletionDate will
// get deleted along with the user.
const user = await User.findOneAndUpdate(
{
'metadata.scheduledDeletionDate': { $lte: new Date() },
},
{
$set: {
'metadata.scheduledDeletionDate': rescheduledDeletionDate,
},
}
);
if (!user) {
// There are no more users that meet the search criteria! We're
// done!
ctx.log.info('no more users are scheduled for deletion');
break;
}
// Get the user's email address.
const reply = await ctx.graphql(
`
query GetUserEmailAddress($user_id: ID!) {
user(id: $user_id) {
email
}
}
`,
{ user_id: user.id }
);
if (reply.errors) {
throw reply.errors;
}
const email = get(reply, 'data.user.email');
if (!email) {
throw new ErrMissingEmail();
}
ctx.log.info(
{
userID: user.id,
scheduledDeletionDate: user.metadata.scheduledDeletionDate,
},
'starting user delete'
);
// Delete the user using the existing graph call.
const { data, errors } = await ctx.graphql(
`
mutation DeleteUser($user_id: ID!) {
delUser(id: $user_id) {
errors {
translation_key
}
}
}
`,
{ user_id: user.id }
);
if (errors) {
throw errors;
}
if (data.errors) {
throw data.errors;
}
ctx.log.info({ userID: user.id }, 'user was deleted successfully');
// Send the download link via the user's attached email account.
await Mailer.send({
template: 'plain',
locals: {
body: I18n.t(
'email.deleted.body',
organizationName,
organizationContactEmail
),
},
subject: I18n.t('email.deleted.subject', organizationName),
email,
});
}
} catch (err) {
ctx.log.error({ err }, 'could not handle user deletions');
}
},
});
};
@@ -15,4 +15,29 @@ class ErrDownloadToken extends TalkError {
}
}
module.exports = { ErrDownloadToken };
// ErrDeletionAlreadyScheduled is returned when a user requests that their
// account get deleted when their account is already scheduled for deletion.
class ErrDeletionAlreadyScheduled extends TalkError {
constructor() {
super('Deletion is already scheduled', {
translation_key: 'DELETION_ALREADY_SCHEDULED',
status: 400,
});
}
}
// ErrDeletionNotScheduled is returned when a user requests that their
// account deletion to be canceled when it was not scheduled for deletion.
class ErrDeletionNotScheduled extends TalkError {
constructor() {
super('Deletion was not scheduled', {
translation_key: 'DELETION_NOT_SCHEDULED',
status: 400,
});
}
}
module.exports = {
ErrDownloadToken,
ErrDeletionAlreadyScheduled,
ErrDeletionNotScheduled,
};
@@ -1,8 +1,17 @@
const { get } = require('lodash');
const moment = require('moment');
const uuid = require('uuid/v4');
const { DOWNLOAD_LINK_SUBJECT } = require('./constants');
const {
ErrDeletionAlreadyScheduled,
ErrDeletionNotScheduled,
} = require('./errors');
const { ErrNotAuthorized, ErrMaxRateLimit } = require('errors');
const { URL } = require('url');
const {
scheduledDeletionDelayHours,
downloadRateLimitDays,
} = require('../config');
// generateDownloadLinks will generate a signed set of links for a given user to
// download an archive of their data.
@@ -37,21 +46,26 @@ async function sendDownloadLink(ctx) {
} = ctx;
// downloadLinkLimiter can be used to limit downloads for the user's data to
// once every 7 days.
const downloadLinkLimiter = new Limit('profileDataDownloadLimiter', 1, '7d');
// once every ${downloadRateLimitDays} days.
const downloadLinkLimiter = new Limit(
'profileDataDownloadLimiter',
1,
`${downloadRateLimitDays}d`
);
// Check that the user has not already requested a download within the last
// 7 days.
// ${downloadRateLimitDays} days.
const attempts = await downloadLinkLimiter.get(user.id);
if (attempts && attempts >= 1) {
throw new ErrMaxRateLimit();
}
// Check if the lastAccountDownload time is within 7 days.
// Check if the lastAccountDownload time is within ${downloadRateLimitDays}
// days.
if (
user.lastAccountDownload &&
moment(user.lastAccountDownload)
.add(7, 'days')
.add(downloadRateLimitDays, 'days')
.isAfter(moment())
) {
throw new ErrMaxRateLimit();
@@ -67,7 +81,7 @@ async function sendDownloadLink(ctx) {
// Generate the download links.
const { downloadLandingURL } = await generateDownloadLinks(ctx, user.id);
const { organizationName } = await Settings.load('organizationName');
const { organizationName } = await Settings.select('organizationName');
// Send the download link via the user's attached email account.
await Users.sendEmail(user, {
@@ -87,20 +101,118 @@ async function sendDownloadLink(ctx) {
);
}
// requestDeletion will schedule the current user to have their account deleted
// by setting the `scheduledDeletionDate` on the user
// ${scheduledDeletionDelayHours} hours from now.
async function requestDeletion({
user,
loaders: { Settings },
connectors: { models: { User }, services: { Users, I18n } },
}) {
// Ensure the user doesn't already have a deletion scheduled.
if (get(user, 'metadata.scheduledDeletionDate')) {
throw new ErrDeletionAlreadyScheduled();
}
// Get the date in the future ${scheduledDeletionDelayHours} hours from now.
const scheduledDeletionDate = moment().add(
scheduledDeletionDelayHours,
'hours'
);
// Amend the scheduledDeletionDate on the user.
await User.update(
{ id: user.id },
{
$set: {
'metadata.scheduledDeletionDate': scheduledDeletionDate.toDate(),
},
}
);
const { organizationName } = await Settings.select('organizationName');
// Send the download link via the user's attached email account.
await Users.sendEmail(user, {
template: 'plain',
locals: {
body: I18n.t(
'email.delete.body',
organizationName,
scheduledDeletionDate.format('MMM Do YYYY, h:mm:ss a')
),
},
subject: I18n.t('email.delete.subject', organizationName),
});
return scheduledDeletionDate.toDate();
}
// cancelDeletion will unset the scheduled deletion date on the user account
// that is used to indicate that the user was scheduled for deletion.
async function cancelDeletion({
user,
loaders: { Settings },
connectors: { models: { User }, services: { I18n, Users } },
}) {
// Ensure the user has a deletion scheduled.
const scheduledDeletionDate = get(
user,
'metadata.scheduledDeletionDate',
null
);
if (!scheduledDeletionDate) {
throw new ErrDeletionNotScheduled();
}
// Amend the scheduledDeletionDate on the user.
await User.update(
{ id: user.id },
{ $unset: { 'metadata.scheduledDeletionDate': 1 } }
);
const { organizationName } = await Settings.select('organizationName');
// Send the download link via the user's attached email account.
await Users.sendEmail(user, {
template: 'plain',
locals: {
body: I18n.t(
'email.cancelDelete.body',
organizationName,
moment(scheduledDeletionDate).format('MMM Do YYYY, h:mm:ss a')
),
},
subject: I18n.t('email.cancelDelete.subject', organizationName),
});
}
// downloadUser will return the download file url that can be used to directly
// download the archive.
async function downloadUser(ctx, userID) {
if (ctx.user.role !== 'ADMIN') {
throw new ErrNotAuthorized();
}
const { downloadFileURL } = await generateDownloadLinks(ctx, userID);
return downloadFileURL;
}
module.exports = ctx => ({
User: {
requestDownloadLink: () => sendDownloadLink(ctx),
download:
// Only ADMIN users can execute an account download.
ctx.user && ctx.user.role === 'ADMIN'
? userID => downloadUser(ctx, userID)
: () => Promise.reject(new ErrNotAuthorized()),
},
});
module.exports = ctx =>
ctx.user
? {
User: {
requestDownloadLink: () => sendDownloadLink(ctx),
requestDeletion: () => requestDeletion(ctx),
cancelDeletion: () => cancelDeletion(ctx),
download: userID => downloadUser(ctx, userID),
},
}
: {
User: {
requestDownloadLink: () => Promise.reject(new ErrNotAuthorized()),
requestDeletion: () => Promise.reject(new ErrNotAuthorized()),
cancelDeletion: () => Promise.reject(new ErrNotAuthorized()),
download: () => Promise.reject(new ErrNotAuthorized()),
},
};
@@ -5,6 +5,12 @@ module.exports = {
requestDownloadLink: async (_, args, { mutators: { User } }) => {
await User.requestDownloadLink();
},
requestAccountDeletion: async (_, args, { mutators: { User } }) => ({
scheduledDeletionDate: await User.requestDeletion(),
}),
cancelAccountDeletion: async (_, args, { mutators: { User } }) => {
await User.cancelDeletion();
},
downloadUser: async (_, { id }, { mutators: { User } }) => ({
archiveURL: await User.download(id),
}),
@@ -19,5 +25,17 @@ module.exports = {
return get(user, 'metadata.lastAccountDownload', null);
},
scheduledDeletionDate: (user, args, { user: currentUser }) => {
// If the current user is not the requesting user, and the user is not
// an admin or a moderator, return nothing.
if (
user.id !== currentUser.id &&
!['ADMIN', 'MODERATOR'].includes(user.role)
) {
return null;
}
return get(user, 'metadata.scheduledDeletionDate', null);
},
},
};
@@ -71,9 +71,15 @@ async function loadComments(ctx, userID, archive, latestContentDate) {
const csv = stringify();
// Add all the streams as files to the archive.
archive.append(csv, { name: 'talk-export/my_comments.csv' });
archive.append(csv, { name: 'comments-export/my_comments.csv' });
csv.write(['ID', 'Timestamp', 'Article', 'Link', 'Body']);
csv.write([
'Comment ID',
'Published Timestamp',
'Article URL',
'Comment Link',
'Comment Text',
]);
// Load the first batch's comments from the latest date that we were provided
// from the token.
@@ -3,14 +3,46 @@ type User {
# lastAccountDownload is the date that the user last requested a comment
# download.
lastAccountDownload: Date
# scheduledDeletionDate is the data for which the user account will be deleted
# after. The account may be deleted up to half an hour after this date because
# the job responsible for deleting the scheduled account will only run once
# every half hour.
scheduledDeletionDate: Date
}
# RequestDownloadLinkResponse contains the account download errors relating to
# the request for an account download.
type RequestDownloadLinkResponse implements Response {
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
}
# RequestAccountDeletionResponse contains the account deletion schedule errors
# relating to schedulding an account for deletion.
type RequestAccountDeletionResponse implements Response {
# scheduledDeletionDate is the data for which the user account will be deleted
# after. The account may be deleted up to half an hour after this date because
# the job responsible for deleting the scheduled account will only run once
# every half hour.
scheduledDeletionDate: Date
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
}
# CancelAccountDeletionResponse contains the account deletion errors relating to
# canceling an account deletion that was scheduled.
type CancelAccountDeletionResponse implements Response {
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
}
# DownloadUserResponse contaisn the account download archiveURL that can be used
# to directly download a zip file containing the user data.
type DownloadUserResponse implements Response {
# archiveURL is the link that can be used within the next 1 hour to download a
@@ -27,6 +59,14 @@ type RootMutation {
# users email address.
requestDownloadLink: RequestDownloadLinkResponse
# requestAccountDeletion requests that the current account get deleted. The
# mutation will return the date that the account is scheduled to be deleted.
requestAccountDeletion: RequestAccountDeletionResponse
# cancelAccountDeletion will cancel a pending account deletion that was
# previously scheduled.
cancelAccountDeletion: CancelAccountDeletionResponse
# downloadUser will provide an account download for the indicated User. This
# mutation requires the ADMIN role.
downloadUser(id: ID!): DownloadUserResponse
@@ -14,5 +14,24 @@ en:
subject: "Your comments are ready for download from {0}"
download_link_ready: "Click here to download your comments from {0} as of {1}:"
download_archive: "Download Archive"
delete:
subject: "Your account for {0} is scheduled to be deleted"
body: |
A request to delete your account was received. Your account is scheduled for deletion on {1}.
After that time all of your comments will be removed from the site, all of your comments will be removed from our database, and your username and email address will be removed from our system.
If you change your mind you can sign into your account and cancel the request before your scheduled account deletion time.
deleted:
subject: "Your account for {0} has been deleted"
body: |
Your commenter account for {0} is now deleted. We're sorry to see you go!
If you'd like to re-join the discussion in the future, you can sign up for a new account.
If you'd like to give us feedback on why you left and what we can do to make the commenting experience better, please email us at {1}.
cancelDelete:
subject: "Your account deletion request for {0} has been cancelled"
body: "You have cancelled your account deletion request for {0}. Your account is now reactivated."
error:
DOWNLOAD_TOKEN_INVALID: "Your download link is not valid."
+7 -4
View File
@@ -16,6 +16,9 @@ Enables secure rich text support server-side.
Add `"talk-plugin-rich-text"` to the `plugins.json` in your Talk installation.
This plugin provides a server and a client side implementation.
###### Note: Possible plugin conflict
The plugin `talk-plugin-comment-content` will prevent this plugin from rendering comments with rich text styling and is not needed if this plugin is enabled.
## Server implementation
### How does this work?
@@ -43,11 +46,11 @@ Settings for highlighting links. These will only apply if `higlightLinks` is set
#### `dompurify`
Rules to sanitize html input. We use [DOMPurify] (https://github.com/cure53/DOMPurify) to prevent web attacks and XSS. Here is the complete list of [settings] (https://github.com/cure53/DOMPurify)
Rules to sanitize html input. We use [DOMPurify](https://github.com/cure53/DOMPurify) to prevent web attacks and XSS. Here is the complete list of [settings](https://github.com/cure53/DOMPurify)
#### `jsdom`
In order to run html in the server we need [jsdom](https://github.com/jsdom/jsdom). Usually you wouldnt need to modify this settings.
In order to run html in the server we need [jsdom](https://github.com/jsdom/jsdom). Usually you wouldnt need to modify this settings.
## Client implementation
@@ -58,10 +61,10 @@ This plugin contains 2 important components:
- The Editor (`./components/Editor.js`)
- The Comment Content Renderer (`./components/CommentContent.js`)
The editor component utilizes the [contentEditable](https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Editable_content) and execCommand API.
The editor component utilizes the [contentEditable](https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Editable_content) and execCommand API.
If you check our `index.js` you will notice that we inject this editor in the
`commentBox` slot. We do this to replace the core comment box with this one.
`commentBox` slot. We do this to replace the core comment box with this one.
Now, in order to render the new styled comments we need a comment renderer. For
this task we will have to replace our core comment renderer by using the