Merge branch 'gdpr-username' of github.com:coralproject/talk into gdpr-email

* 'gdpr-username' of github.com:coralproject/talk: (36 commits)
  performing mutation
  Spanish translations
  More translations
  No empty username, no same username
  Decrease font-size and padding in Flag Comment popup a bit.
  Translations
  Adding translations
  Adding translations
  InputField refactor
  wip more validations
  Full functionality, needs translation
  Adding Dialog, styles and more
  Use email vs e-mail
  Small fix
  Adding withChangeUsername to plugin hocs, wip and more
  Introducing profileHeader slot
  Adding a notification after saving
  Fix lint
  Add missing ctx arg for CLI users create
  final refactor, done.
  ...
This commit is contained in:
okbel
2018-04-29 20:29:54 -03:00
47 changed files with 1522 additions and 115 deletions
+4
View File
@@ -4,6 +4,8 @@ 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,
@@ -11,5 +13,7 @@ export default {
slots: {
stream: [UserBox, SignInButton, SetUsernameDialog],
login: [Login],
profileHeader: [ChangeUsername],
profileSettings: [ChangePassword],
},
};
@@ -0,0 +1,87 @@
.container {
position: relative;
color: #202020;
padding: 10px;
border-radius: 2px;
border: solid 1px transparent;
box-sizing: border-box;
justify-content: space-between;
&.editing {
border-color: #979797;
background-color: #EDEDED;
}
}
.actions {
position: absolute;
top: 10px;
right: 10px;
display: flex;
flex-direction: column;
align-items: center;
}
.title {
color: #202020;
margin: 0 0 20px;
}
.detailBottomBox {
display: block;
padding-top: 4px;
text-align: right;
width: 280px;
}
.detailLink {
color: #00538A;
text-decoration: none;
font-size: 0.9em;
&:hover {
cursor: pointer;
}
}
.button {
border: 1px solid #787d80;
background-color: transparent;
height: 30px;
font-size: 0.9em;
line-height: normal;
}
.saveButton {
background-color: #3498DB;
border-color: #3498DB;
color: white;
> i {
font-size: 17px;
}
&:hover {
background-color: #399ee2;
color: white;
}
&:disabled {
border-color: #e0e0e0;
&:hover {
background-color: #e0e0e0;
color: #4f5c67;
cursor: default;
}
}
}
.cancelButton {
color:#787D80;
margin-top: 6px;
font-size: 0.9em;
&:hover {
cursor: pointer;
}
}
@@ -0,0 +1,223 @@
import React from 'react';
import PropTypes from 'prop-types';
import cn from 'classnames';
import styles from './ChangePassword.css';
import { Button } from 'plugin-api/beta/client/components/ui';
import validate from 'coral-framework/helpers/validate';
import errorMsj from 'coral-framework/helpers/error';
import isEqual from 'lodash/isEqual';
import { t } from 'plugin-api/beta/client/services';
import InputField from './InputField';
import { getErrorMessages } from 'coral-framework/utils';
const initialState = {
editing: false,
showErrors: true,
errors: {},
formData: {},
};
class ChangePassword extends React.Component {
state = initialState;
validKeys = ['oldPassword', 'newPassword', 'confirmNewPassword'];
onChange = e => {
const { name, value, type } = e.target;
this.setState(
state => ({
formData: {
...state.formData,
[name]: value,
},
}),
() => {
this.fieldValidation(value, type, name);
// Perform equality validation if password fields have changed
if (name === 'newPassword' || name === 'confirmNewPassword') {
this.equalityValidation('newPassword', 'confirmNewPassword');
}
}
);
};
equalityValidation = (field, field2) => {
const cond = this.state.formData[field] === this.state.formData[field2];
if (!cond) {
this.addError({
[field2]: t('talk-plugin-auth.change_password.passwords_dont_match'),
});
} else {
this.removeError(field2);
}
return cond;
};
fieldValidation = (value, type, name) => {
if (!value.length) {
this.addError({
[name]: t('talk-plugin-auth.change_password.required_field'),
});
} else if (!validate[type](value)) {
this.addError({ [name]: errorMsj[type] });
} else {
this.removeError(name);
}
};
hasError = err => {
return Object.keys(this.state.errors).indexOf(err) !== -1;
};
addError = err => {
this.setState(({ errors }) => ({
errors: { ...errors, ...err },
}));
};
removeError = errKey => {
this.setState(state => {
const { [errKey]: _, ...errors } = state.errors;
return {
errors,
};
});
};
enableEditing = () => {
this.setState({
editing: true,
});
};
isSubmitBlocked = () => {
const formHasErrors = !!Object.keys(this.state.errors).length;
const formIncomplete = !isEqual(
Object.keys(this.state.formData),
this.validKeys
);
return formHasErrors || formIncomplete;
};
clearForm = () => {
this.setState(initialState);
};
onSave = async () => {
const { oldPassword, newPassword } = this.state.formData;
try {
await this.props.changePassword({
oldPassword,
newPassword,
});
this.props.notify(
'success',
t('talk-plugin-auth.change_password.changed_password_msg')
);
} catch (err) {
this.props.notify('error', getErrorMessages(err));
}
this.clearForm();
this.disableEditing();
};
disableEditing = () => {
this.setState({
editing: false,
});
};
cancel = () => {
this.clearForm();
this.disableEditing();
};
render() {
const { editing, errors } = this.state;
return (
<section
className={cn('talk-plugin-auth--change-password', styles.container, {
[styles.editing]: editing,
})}
>
<h3 className={styles.title}>
{t('talk-plugin-auth.change_password.change_password')}
</h3>
{editing && (
<form className="talk-plugin-auth--change-password-form">
<InputField
id="oldPassword"
label="Old Password"
name="oldPassword"
type="password"
onChange={this.onChange}
value={this.state.formData.oldPassword}
hasError={this.hasError('oldPassword')}
errorMsg={errors['oldPassword']}
showErrors
>
<span className={styles.detailBottomBox}>
<a className={styles.detailLink}>
{t('talk-plugin-auth.change_password.forgot_password')}
</a>
</span>
</InputField>
<InputField
id="newPassword"
label="New Password"
name="newPassword"
type="password"
onChange={this.onChange}
value={this.state.formData.newPassword}
hasError={this.hasError('newPassword')}
errorMsg={errors['newPassword']}
showErrors
/>
<InputField
id="confirmNewPassword"
label="Confirm New Password"
name="confirmNewPassword"
type="password"
onChange={this.onChange}
value={this.state.formData.confirmNewPassword}
hasError={this.hasError('confirmNewPassword')}
errorMsg={errors['confirmNewPassword']}
showErrors
/>
</form>
)}
{editing ? (
<div className={styles.actions}>
<Button
className={cn(styles.button, styles.saveButton)}
icon="save"
onClick={this.onSave}
disabled={this.isSubmitBlocked()}
>
{t('talk-plugin-auth.change_password.save')}
</Button>
<a className={styles.cancelButton} onClick={this.cancel}>
{t('talk-plugin-auth.change_password.cancel')}
</a>
</div>
) : (
<div className={styles.actions}>
<Button className={styles.button} onClick={this.enableEditing}>
{t('talk-plugin-auth.change_password.edit')}
</Button>
</div>
)}
</section>
);
}
}
ChangePassword.propTypes = {
changePassword: PropTypes.func.isRequired,
notify: PropTypes.func.isRequired,
};
export default ChangePassword;
@@ -0,0 +1,122 @@
.container {
margin-bottom: 20px;
display: flex;
position: relative;
color: #202020;
padding: 10px;
border-radius: 2px;
box-sizing: border-box;
justify-content: space-between;
&.editing {
background-color: #EDEDED;
}
}
.content {
flex-grow: 1;
}
.actions {
flex-grow: 0;
display: flex;
flex-direction: column;
align-items: center;
}
.email {
margin: 0;
}
.username {
margin-bottom: 4px;
}
.button {
border: 1px solid #787d80;
background-color: transparent;
height: 30px;
font-size: 0.9em;
line-height: normal;
}
.saveButton {
background-color: #3498DB;
border-color: #3498DB;
color: white;
> i {
font-size: 17px;
}
&:hover {
background-color: #399ee2;
color: white;
}
&:disabled {
border-color: #e0e0e0;
&:hover {
background-color: #e0e0e0;
color: #4f5c67;
cursor: default;
}
}
}
.cancelButton {
color:#787D80;
margin-top: 6px;
font-size: 0.9em;
&:hover {
cursor: pointer;
}
}
.detailLabel {
border: solid 1px #787D80;
border-radius: 2px;
background-color: white;
height: 30px;
display: inline-block;
width: 230px;
display: flex;
> .detailLabelIcon {
font-size: 1.2em;
padding: 0 5px;
color: #787D80;
line-height: 30px;
}
&.disabled {
background-color: #E0E0E0;
}
}
.detailValue {
background: transparent;
border: none;
font-size: 1em;
color: #000;
height: 30px;
outline: none;
flex: 1;
}
.bottomText {
color: #474747;
font-size: 0.9em;
}
.detailList {
list-style: none;
margin: 0;
padding: 0;
}
.detailItem {
margin-bottom: 12px;
}
@@ -0,0 +1,182 @@
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';
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 { id } = this.props;
try {
await this.props.changeUsername({
id,
username: 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 } = this.props;
const { editing } = this.state;
return (
<section
className={cn('talk-plugin-auth--edit-profile', styles.container, {
[styles.editing]: editing,
})}
>
<ChangeUsernameDialog
showDialog={this.state.showDialog}
onChange={this.onChange}
formData={this.state.formData}
username={username}
closeDialog={this.closeDialog}
saveChanges={this.saveChanges}
/>
{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 = {
changeUsername: PropTypes.func.isRequired,
notify: PropTypes.func.isRequired,
username: PropTypes.string,
emailAddress: PropTypes.string,
};
export default ChangeUsername;
@@ -0,0 +1,84 @@
.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;
}
.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;
}
}
@@ -0,0 +1,106 @@
import React from 'react';
import PropTypes from 'prop-types';
import cn from 'classnames';
import styles from './ChangeUsernameDialog.css';
import InputField from './InputField';
import { Button, Dialog } from 'plugin-api/beta/client/components/ui';
import { t } from 'plugin-api/beta/client/services';
class ChangeUsernameDialog extends React.Component {
state = {
showError: false,
};
showError = () => {
this.setState({
showError: true,
});
};
confirmChanges = async () => {
if (this.formHasError()) {
this.showError();
} else {
await this.props.saveChanges();
this.props.closeDialog();
}
};
formHasError = () =>
this.props.formData.confirmNewUsername !== this.props.formData.newUsername;
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}>
×
</span>
<h1 className={styles.title}>
{t('talk-plugin-auth.change_username.confirm_username_change')}
</h1>
<div className={styles.content}>
<p className={styles.description}>
{t('talk-plugin-auth.change_username.description')}
</p>
<div className={styles.usernamesChange}>
<span className={styles.item}>
{t('talk-plugin-auth.change_username.old_username')}:{' '}
{this.props.username}
</span>
<span className={styles.item}>
{t('talk-plugin-auth.change_username.new_username')}:{' '}
{this.props.formData.newUsername}
</span>
</div>
<form>
<InputField
id="confirmNewUsername"
label="Re-enter new username"
name="confirmNewUsername"
type="text"
onChange={this.props.onChange}
defaultValue=""
hasError={this.formHasError() && this.state.showError}
errorMsg={t(
'talk-plugin-auth.change_username.username_does_not_match'
)}
showError={this.state.showError}
columnDisplay
showSuccess={false}
validationType="username"
>
<span className={styles.bottomNote}>
{t('talk-plugin-auth.change_username.bottom_note')}
</span>
</InputField>
</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>
);
}
}
ChangeUsernameDialog.propTypes = {
saveChanges: PropTypes.func,
closeDialog: PropTypes.func,
showDialog: PropTypes.bool,
onChange: PropTypes.func,
username: PropTypes.string,
formData: PropTypes.object,
};
export default ChangeUsernameDialog;
@@ -0,0 +1,8 @@
.errorMsg {
color: #FA4643;
font-size: 0.9em;
}
.warningIcon {
color: #FA4643;
}
@@ -0,0 +1,17 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './ErrorMessage.css';
import { Icon } from 'plugin-api/beta/client/components/ui';
const ErrorMessage = ({ children }) => (
<div className={styles.errorMsg}>
<Icon className={styles.warningIcon} name="warning" />
<span>{children}</span>
</div>
);
ErrorMessage.propTypes = {
children: PropTypes.node,
};
export default ErrorMessage;
@@ -0,0 +1,80 @@
.detailItem {
margin-bottom: 12px;
}
.detailItemContainer {
display: flex;
}
.columnDisplay {
flex-direction: column;
.detailItemMessage {
padding: 4px 0 0;
}
}
.detailItemContent {
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;
}
.detailItemMessage {
flex-grow: 1;
display: flex;
align-items: center;
padding-left: 6px;
padding-top: 16px;
.warningIcon, .checkIcon {
font-size: 17px;
}
}
.checkIcon {
color: #00CD73;
}
.warningIcon {
color: #FA4643;
}
@@ -0,0 +1,94 @@
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, {
[styles.columnDisplay]: columnDisplay,
})}
>
{label && (
<label className={styles.detailLabel} id={id}>
{label}
</label>
)}
<div
className={cn(
styles.detailItemContent,
{ [styles.error]: hasError },
{ [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>
{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,12 @@
import { compose } from 'react-apollo';
import { bindActionCreators } from 'redux';
import { connect } from 'plugin-api/beta/client/hocs';
import ChangePassword from '../components/ChangePassword';
import { notify } from 'coral-framework/actions/notification';
import { withChangePassword } from 'plugin-api/beta/client/hocs';
const mapDispatchToProps = dispatch => bindActionCreators({ notify }, dispatch);
export default compose(connect(null, mapDispatchToProps), withChangePassword)(
ChangePassword
);
@@ -0,0 +1,12 @@
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
);
@@ -58,7 +58,7 @@ da:
sign_in: "Sign in"
sign_in_to_join: "Sign in to join the conversation"
or: "Or"
email: "E-mail Address"
email: "Email Address"
password: "Password"
forgot_your_pass: "Forgot your password?"
need_an_account: "Need an account?"
@@ -101,7 +101,7 @@ en:
sign_in: "Sign in"
sign_in_to_join: "Sign in to join the conversation"
or: "Or"
email: "E-mail Address"
email: "Email Address"
password: "Password"
forgot_your_pass: "Forgot your password?"
need_an_account: "Need an account?"
@@ -131,6 +131,27 @@ en:
username: Username
write_your_username: "Edit your username"
your_username: "Your username appears on every comment you post."
change_password:
change_password: "Change Password"
passwords_dont_match: "Passwords don`t match"
required_field: "This field is required"
forgot_password: "Forgot your password?"
save: "Save"
cancel: "Cancel"
edit: "Edit"
changed_password_msg: "Changed Password - Your password has been successfully changed"
change_username:
change_username_note: "Usernames can be changed every 14 days"
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"
de:
talk-plugin-auth:
login:
@@ -222,6 +243,28 @@ es:
username: Nombre
write_your_username: "Edita tu nombre"
your_username: "Tu nombre aparece en cada comentario que publiques."
change_password:
change_password: "Cambiar Contraseña"
passwords_dont_match: "Las contraseñas no coinciden"
required_field: "Este campo es requerido"
forgot_password: "Olvidaste tu contraseña?"
save: "Guardar"
cancel: "Cancelar"
edit: "Editar"
changed_password_msg: "Contraseña Actualizada - Tu contraseña ha sido exitosamente actualizada"
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: "Username Changed - Your username has been successfully changed. You will not be able to change your user name for 14 days."
fr:
talk-plugin-auth:
login:
@@ -324,7 +367,7 @@ pt_BR:
sign_in: "Sign in"
sign_in_to_join: "Sign in to join the conversation"
or: "Or"
email: "E-mail Address"
email: "Email Address"
password: "Password"
forgot_your_pass: "Forgot your password?"
need_an_account: "Need an account?"
@@ -3,9 +3,9 @@
<head>
<meta name="viewport" content="initial-scale=1, maximum-scale=1">
<title><%= t('talk-plugin-notifications.unsubscribe_page.unsubscribe') %></title>
<%- include(root + '/partials/head') %>
<link rel="stylesheet" href="https://code.getmdl.io/1.2.1/material.indigo-pink.min.css">
<link rel="stylesheet" href="<%= BASE_PATH %>public/css/admin.css">
<%- include(root + '/partials/head') %>
</head>
<body class="confirm-email-page">
<div id="root">
@@ -1 +1 @@
<p><%= t('email.download.download_link_ready', organizationName, now.toLocaleString()) %> <a href="<%= BASE_URL %>account/download#<%= token %>"><%= t('email.download.download_archive') %></a></p>
<p><%= t('email.download.download_link_ready', organizationName, now.toLocaleString()) %> <a href="<%= downloadLandingURL %>"><%= t('email.download.download_archive') %></a></p>
@@ -1,3 +1,3 @@
<%= t('email.download.download_link_ready', organizationName, now.toLocaleString()) %>
<%= BASE_URL %>account/download#<%= token %>
<%= downloadLandingURL %>
@@ -1,17 +1,41 @@
const moment = require('moment');
const uuid = require('uuid/v4');
const { DOWNLOAD_LINK_SUBJECT } = require('./constants');
const { ErrNotAuthorized, ErrMaxRateLimit } = require('errors');
const { URL } = require('url');
// generateDownloadLinks will generate a signed set of links for a given user to
// download an archive of their data.
async function generateDownloadLinks(ctx, userID) {
const { connectors: { url: { BASE_URL }, secrets } } = ctx;
// Generate a token for the download link.
const token = await secrets.jwt.sign(
{ user: userID },
{ jwtid: uuid.v4(), expiresIn: '1d', subject: DOWNLOAD_LINK_SUBJECT }
);
// Generate the url that a user can land on.
const downloadLandingURL = new URL('account/download', BASE_URL);
downloadLandingURL.hash = token;
// Generate the url that the API calls to download the actual zip.
const downloadFileURL = new URL('api/v1/account/download', BASE_URL);
downloadFileURL.searchParams.set('token', token);
return {
downloadLandingURL: downloadLandingURL.href,
downloadFileURL: downloadFileURL.href,
};
}
async function sendDownloadLink(ctx) {
const {
user,
loaders: { Settings },
connectors: { services: { Users, I18n, Limit }, models: { User } },
} = ctx;
async function sendDownloadLink({
user,
loaders: { Settings },
connectors: {
errors,
secrets,
services: { Users, I18n, Limit },
models: { User },
},
}) {
// downloadLinkLimiter can be used to limit downloads for the user's data to
// once every 7 days.
const downloadLinkLimiter = new Limit('profileDataDownloadLimiter', 1, '7d');
@@ -20,7 +44,7 @@ async function sendDownloadLink({
// 7 days.
const attempts = await downloadLinkLimiter.get(user.id);
if (attempts && attempts >= 1) {
throw errors.ErrMaxRateLimit;
throw new ErrMaxRateLimit();
}
// Check if the lastAccountDownload time is within 7 days.
@@ -30,7 +54,7 @@ async function sendDownloadLink({
.add(7, 'days')
.isAfter(moment())
) {
throw errors.ErrMaxRateLimit;
throw new ErrMaxRateLimit();
}
// The account currently does not have a download link, let's record the
@@ -38,21 +62,18 @@ async function sendDownloadLink({
// now.
await downloadLinkLimiter.test(user.id);
// Generate a token for the download link.
const token = await secrets.jwt.sign(
{ user: user.id },
{ jwtid: uuid.v4(), expiresIn: '1d', subject: DOWNLOAD_LINK_SUBJECT }
);
const now = new Date();
// Generate the download links.
const { downloadLandingURL } = await generateDownloadLinks(ctx, user.id);
const { organizationName } = await Settings.load('organizationName');
// Send the download link via the user's attached email account.
await Users.sendEmail(user, {
template: 'download',
locals: {
token,
downloadLandingURL,
organizationName,
now,
},
@@ -66,8 +87,20 @@ async function sendDownloadLink({
);
}
// downloadUser will return the download file url that can be used to directly
// download the archive.
async function downloadUser(ctx, userID) {
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()),
},
});
@@ -5,6 +5,9 @@ module.exports = {
requestDownloadLink: async (_, args, { mutators: { User } }) => {
await User.requestDownloadLink();
},
downloadUser: async (_, { id }, { mutators: { User } }) => ({
archiveURL: await User.download(id),
}),
},
User: {
lastAccountDownload: (user, args, { user: currentUser }) => {
@@ -101,11 +101,21 @@ module.exports = router => {
// /api/v1/account/download will send back a zipped archive of the users
// account.
router.post(
router.all(
'/api/v1/account/download',
express.urlencoded({ extended: false }),
async (req, res, next) => {
const { token = null, check = false } = req.body;
let { token = null, check = false } = req.body;
if (!token) {
// If the token wasn't found in the body, then we should check the query
// to see if it was passed that way.
token = req.query.token;
}
if (!token) {
return res.status(400).end();
}
if (check) {
// This request is checking to see if the token is valid.
@@ -11,9 +11,23 @@ type RequestDownloadLinkResponse implements Response {
errors: [UserError!]
}
type DownloadUserResponse implements Response {
# archiveURL is the link that can be used within the next 1 hour to download a
# users archive.
archiveURL: String
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
}
type RootMutation {
# requestDownloadLink will request a download link be sent to the primary
# users email address.
requestDownloadLink: RequestDownloadLinkResponse
# downloadUser will provide an account download for the indicated User. This
# mutation requires the ADMIN role.
downloadUser(id: ID!): DownloadUserResponse
}
@@ -1,7 +1,7 @@
en:
download_landing:
download_your_account: "Download Your Comment History"
download_details: "Your comment history will be downloaded into a .zip file. After your comment history is unzipped you will have a comma seperated value (or .csv) file that you can easily import into your favorite spreadsheet application."
download_details: "Your comment history will be downloaded into a .zip file. After your comment history is unzipped you will have a comma separated value (or .csv) file that you can easily import into your favorite spreadsheet application."
all_information_included: "For each of your comments the following information is included:"
information_included:
date: "When you wrote the comment"