mirror of
https://github.com/wassname/talk.git
synced 2026-08-04 13:23:35 +08:00
Merge branch 'master' into viewing-sorting-translations-es
This commit is contained in:
@@ -4,6 +4,7 @@ import SetUsernameDialog from './stream/containers/SetUsernameDialog';
|
||||
import translations from './translations.yml';
|
||||
import Login from './login/containers/Main';
|
||||
import reducer from './login/reducer';
|
||||
import ChangePassword from './profile-settings/containers/ChangePassword';
|
||||
|
||||
export default {
|
||||
reducer,
|
||||
@@ -11,5 +12,6 @@ export default {
|
||||
slots: {
|
||||
stream: [UserBox, SignInButton, SetUsernameDialog],
|
||||
login: [Login],
|
||||
profileSettings: [ChangePassword],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
.container {
|
||||
position: relative;
|
||||
color: #202020;
|
||||
padding: 10px;
|
||||
border-radius: 2px;
|
||||
border: solid 1px transparent;
|
||||
box-sizing: border-box;
|
||||
justify-content: space-between;
|
||||
|
||||
&.editing {
|
||||
border-color: #979797;
|
||||
background-color: #EDEDED;
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: #202020;
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
|
||||
.detailBottomBox {
|
||||
display: block;
|
||||
padding-top: 4px;
|
||||
text-align: right;
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.detailLink {
|
||||
color: #00538A;
|
||||
text-decoration: none;
|
||||
font-size: 0.9em;
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
border: 1px solid #787d80;
|
||||
background-color: transparent;
|
||||
height: 30px;
|
||||
font-size: 1em;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.saveButton {
|
||||
background-color: #3498DB;
|
||||
border-color: #3498DB;
|
||||
color: white;
|
||||
|
||||
> i {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: #399ee2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
border-color: #e0e0e0;
|
||||
|
||||
&:hover {
|
||||
background-color: #e0e0e0;
|
||||
color: #4f5c67;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cancelButton {
|
||||
color:#787D80;
|
||||
margin-top: 6px;
|
||||
font-size: 0.9em;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import cn from 'classnames';
|
||||
import styles from './ChangePassword.css';
|
||||
import { Button } from 'plugin-api/beta/client/components/ui';
|
||||
import validate from 'coral-framework/helpers/validate';
|
||||
import errorMsj from 'coral-framework/helpers/error';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import { t } from 'plugin-api/beta/client/services';
|
||||
import Form from './Form';
|
||||
import InputField from './InputField';
|
||||
import { getErrorMessages } from 'coral-framework/utils';
|
||||
|
||||
const initialState = {
|
||||
editing: false,
|
||||
showErrors: true,
|
||||
errors: {},
|
||||
formData: {},
|
||||
};
|
||||
|
||||
class ChangePassword extends React.Component {
|
||||
state = initialState;
|
||||
validKeys = ['oldPassword', 'newPassword', 'confirmNewPassword'];
|
||||
|
||||
onChange = e => {
|
||||
const { name, value, type } = e.target;
|
||||
this.setState(
|
||||
state => ({
|
||||
formData: {
|
||||
...state.formData,
|
||||
[name]: value,
|
||||
},
|
||||
}),
|
||||
() => {
|
||||
this.fieldValidation(value, type, name);
|
||||
|
||||
// Perform equality validation if password fields have changed
|
||||
if (name === 'newPassword' || name === 'confirmNewPassword') {
|
||||
this.equalityValidation('newPassword', 'confirmNewPassword');
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
equalityValidation = (field, field2) => {
|
||||
const cond = this.state.formData[field] === this.state.formData[field2];
|
||||
if (!cond) {
|
||||
this.addError({
|
||||
[field2]: t('talk-plugin-auth.change_password.passwords_dont_match'),
|
||||
});
|
||||
} else {
|
||||
this.removeError(field2);
|
||||
}
|
||||
return cond;
|
||||
};
|
||||
|
||||
fieldValidation = (value, type, name) => {
|
||||
if (!value.length) {
|
||||
this.addError({
|
||||
[name]: t('talk-plugin-auth.change_password.required_field'),
|
||||
});
|
||||
} else if (!validate[type](value)) {
|
||||
this.addError({ [name]: errorMsj[type] });
|
||||
} else {
|
||||
this.removeError(name);
|
||||
}
|
||||
};
|
||||
|
||||
hasError = err => {
|
||||
return Object.keys(this.state.errors).indexOf(err) !== -1;
|
||||
};
|
||||
|
||||
addError = err => {
|
||||
this.setState(({ errors }) => ({
|
||||
errors: { ...errors, ...err },
|
||||
}));
|
||||
};
|
||||
|
||||
removeError = errKey => {
|
||||
this.setState(state => {
|
||||
const { [errKey]: _, ...errors } = state.errors;
|
||||
return {
|
||||
errors,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
enableEditing = () => {
|
||||
this.setState({
|
||||
editing: true,
|
||||
});
|
||||
};
|
||||
|
||||
isSubmitBlocked = () => {
|
||||
const formHasErrors = !!Object.keys(this.state.errors).length;
|
||||
const formIncomplete = !isEqual(
|
||||
Object.keys(this.state.formData),
|
||||
this.validKeys
|
||||
);
|
||||
return formHasErrors || formIncomplete;
|
||||
};
|
||||
|
||||
clearForm = () => {
|
||||
this.setState(initialState);
|
||||
};
|
||||
|
||||
onSave = async () => {
|
||||
const { oldPassword, newPassword } = this.state.formData;
|
||||
|
||||
try {
|
||||
await this.props.changePassword({
|
||||
oldPassword,
|
||||
newPassword,
|
||||
});
|
||||
this.props.notify(
|
||||
'success',
|
||||
t('talk-plugin-auth.change_password.changed_password_msg')
|
||||
);
|
||||
} catch (err) {
|
||||
this.props.notify('error', getErrorMessages(err));
|
||||
}
|
||||
|
||||
this.clearForm();
|
||||
this.disableEditing();
|
||||
};
|
||||
|
||||
disableEditing = () => {
|
||||
this.setState({
|
||||
editing: false,
|
||||
});
|
||||
};
|
||||
|
||||
cancel = () => {
|
||||
this.clearForm();
|
||||
this.disableEditing();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { editing, errors } = this.state;
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn('talk-plugin-auth--change-password', styles.container, {
|
||||
[styles.editing]: editing,
|
||||
})}
|
||||
>
|
||||
<h3 className={styles.title}>
|
||||
{t('talk-plugin-auth.change_password.change_password')}
|
||||
</h3>
|
||||
{editing && (
|
||||
<Form className="talk-plugin-auth--change-password-form">
|
||||
<InputField
|
||||
id="oldPassword"
|
||||
label="Old Password"
|
||||
name="oldPassword"
|
||||
type="password"
|
||||
onChange={this.onChange}
|
||||
value={this.state.formData.oldPassword}
|
||||
hasError={this.hasError('oldPassword')}
|
||||
errorMsg={errors['oldPassword']}
|
||||
showErrors
|
||||
>
|
||||
<span className={styles.detailBottomBox}>
|
||||
<a className={styles.detailLink}>
|
||||
{t('talk-plugin-auth.change_password.forgot_password')}
|
||||
</a>
|
||||
</span>
|
||||
</InputField>
|
||||
<InputField
|
||||
id="newPassword"
|
||||
label="New Password"
|
||||
name="newPassword"
|
||||
type="password"
|
||||
onChange={this.onChange}
|
||||
value={this.state.formData.newPassword}
|
||||
hasError={this.hasError('newPassword')}
|
||||
errorMsg={errors['newPassword']}
|
||||
showErrors
|
||||
/>
|
||||
<InputField
|
||||
id="confirmNewPassword"
|
||||
label="Confirm New Password"
|
||||
name="confirmNewPassword"
|
||||
type="password"
|
||||
onChange={this.onChange}
|
||||
value={this.state.formData.confirmNewPassword}
|
||||
hasError={this.hasError('confirmNewPassword')}
|
||||
errorMsg={errors['confirmNewPassword']}
|
||||
showErrors
|
||||
/>
|
||||
</Form>
|
||||
)}
|
||||
{editing ? (
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
className={cn(styles.button, styles.saveButton)}
|
||||
icon="save"
|
||||
onClick={this.onSave}
|
||||
disabled={this.isSubmitBlocked()}
|
||||
>
|
||||
{t('talk-plugin-auth.change_password.save')}
|
||||
</Button>
|
||||
<a className={styles.cancelButton} onClick={this.cancel}>
|
||||
{t('talk-plugin-auth.change_password.cancel')}
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.actions}>
|
||||
<Button className={styles.button} onClick={this.enableEditing}>
|
||||
{t('talk-plugin-auth.change_password.edit')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ChangePassword.propTypes = {
|
||||
changePassword: PropTypes.func.isRequired,
|
||||
notify: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default ChangePassword;
|
||||
@@ -0,0 +1,9 @@
|
||||
.errorMsg {
|
||||
color: #FA4643;
|
||||
padding-left: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.warningIcon {
|
||||
color: #FA4643;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styles from './ErrorMessage.css';
|
||||
import { Icon } from 'plugin-api/beta/client/components/ui';
|
||||
|
||||
const ErrorMessage = ({ children }) => (
|
||||
<div className={styles.errorMsg}>
|
||||
<Icon className={styles.warningIcon} name="warning" />
|
||||
<span>{children}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
ErrorMessage.propTypes = {
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
export default ErrorMessage;
|
||||
@@ -0,0 +1,5 @@
|
||||
.detailList {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import styles from './Form.css';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const Form = ({ children, className = '' }) => (
|
||||
<form className={className}>
|
||||
<ul className={styles.detailList}>{children}</ul>
|
||||
</form>
|
||||
);
|
||||
|
||||
Form.propTypes = {
|
||||
className: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
export default Form;
|
||||
@@ -0,0 +1,51 @@
|
||||
|
||||
.detailItem {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.detailItemContainer {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.detailItemContent {
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.detailLabel {
|
||||
color: #4C4C4D;
|
||||
font-size: 1em;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.detailValue {
|
||||
padding: 6px 2px;
|
||||
border: solid 1px #979797;
|
||||
display: block;
|
||||
font-size: 1.1em;
|
||||
border-radius: 2px;
|
||||
background-color: #ffffff;
|
||||
color: #979797;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.detailItemMessage {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 2px;
|
||||
padding-top: 16px;
|
||||
|
||||
.warningIcon, .checkIcon {
|
||||
font-size: 17px;
|
||||
}
|
||||
}
|
||||
|
||||
.checkIcon {
|
||||
color: #00CD73;
|
||||
}
|
||||
|
||||
.warningIcon {
|
||||
color: #FA4643;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import styles from './InputField.css';
|
||||
import ErrorMessage from './ErrorMessage';
|
||||
import { Icon } from 'plugin-api/beta/client/components/ui';
|
||||
|
||||
const InputField = ({
|
||||
id = '',
|
||||
label = '',
|
||||
type = 'text',
|
||||
name = '',
|
||||
onChange = () => {},
|
||||
value = '',
|
||||
showError = true,
|
||||
hasError = false,
|
||||
errorMsg = '',
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<li className={styles.detailItem}>
|
||||
<div className={styles.detailItemContainer}>
|
||||
<div className={styles.detailItemContent}>
|
||||
<label className={styles.detailLabel} id={id}>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
type={type}
|
||||
name={name}
|
||||
className={styles.detailValue}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.detailItemMessage}>
|
||||
{!hasError &&
|
||||
value && <Icon className={styles.checkIcon} name="check_circle" />}
|
||||
{hasError && showError && <ErrorMessage>{errorMsg}</ErrorMessage>}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
InputField.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
label: PropTypes.string.isRequired,
|
||||
type: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
onChange: PropTypes.func,
|
||||
value: PropTypes.string,
|
||||
showError: PropTypes.bool,
|
||||
hasError: PropTypes.bool,
|
||||
errorMsg: PropTypes.string,
|
||||
children: PropTypes.node,
|
||||
};
|
||||
|
||||
export default InputField;
|
||||
@@ -0,0 +1,12 @@
|
||||
import { compose } from 'react-apollo';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { connect } from 'plugin-api/beta/client/hocs';
|
||||
import ChangePassword from '../components/ChangePassword';
|
||||
import { notify } from 'coral-framework/actions/notification';
|
||||
import { withChangePassword } from 'plugin-api/beta/client/hocs';
|
||||
|
||||
const mapDispatchToProps = dispatch => bindActionCreators({ notify }, dispatch);
|
||||
|
||||
export default compose(connect(null, mapDispatchToProps), withChangePassword)(
|
||||
ChangePassword
|
||||
);
|
||||
@@ -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,15 @@ en:
|
||||
username: Username
|
||||
write_your_username: "Edit your username"
|
||||
your_username: "Your username appears on every comment you post."
|
||||
change_password:
|
||||
change_password: "Change Password"
|
||||
passwords_dont_match: "Passwords don`t match"
|
||||
required_field: "This field is required"
|
||||
forgot_password: "Forgot your password?"
|
||||
save: "Save"
|
||||
cancel: "Cancel"
|
||||
edit: "Edit"
|
||||
changed_password_msg: "Changed Password - Your password has been successfully changed"
|
||||
de:
|
||||
talk-plugin-auth:
|
||||
login:
|
||||
@@ -222,6 +231,15 @@ es:
|
||||
username: Nombre
|
||||
write_your_username: "Edita tu nombre"
|
||||
your_username: "Tu nombre aparece en cada comentario que publiques."
|
||||
change_password:
|
||||
change_password: "Cambiar Contraseña"
|
||||
passwords_dont_match: "Las contraseñas no coinciden"
|
||||
required_field: "Este campo es requerido"
|
||||
forgot_password: "Olvidaste tu contraseña?"
|
||||
save: "Guardar"
|
||||
cancel: "Cancelar"
|
||||
edit: "Editar"
|
||||
changed_password_msg: "Contraseña Actualizada - Tu contraseña ha sido exitosamente actualizada"
|
||||
fr:
|
||||
talk-plugin-auth:
|
||||
login:
|
||||
@@ -324,7 +342,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">
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
title: talk-plugin-profile-data
|
||||
layout: plugin
|
||||
permalink: /plugin/talk-plugin-profile-data/
|
||||
plugin:
|
||||
name: talk-plugin-profile-data
|
||||
default: true
|
||||
provides:
|
||||
- Client
|
||||
- Server
|
||||
---
|
||||
|
||||
Provides a series of profile data management utilities to users via their
|
||||
profile tab.
|
||||
|
||||
## Download My Profile
|
||||
|
||||
Enables the ability for users to download their profile data in a zip file from
|
||||
their profile tab in the comment stream. Once clicked, an email will be sent
|
||||
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.
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "@coralproject/eslint-config-talk/client"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
.button {
|
||||
margin: 0;
|
||||
|
||||
i {
|
||||
font-size: inherit;
|
||||
vertical-align: sub;
|
||||
}
|
||||
}
|
||||
|
||||
.most_recent {
|
||||
color: #808080;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import React, { Component } from 'react';
|
||||
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';
|
||||
|
||||
export const readableDuration = durAsHours => {
|
||||
const durAsDays = Math.ceil(durAsHours / 24);
|
||||
|
||||
return durAsHours > 23
|
||||
? durAsDays > 1
|
||||
? t('download_request.days', durAsDays)
|
||||
: t('download_request.day', durAsDays)
|
||||
: durAsHours > 1
|
||||
? t('download_request.hours', durAsHours)
|
||||
: t('download_request.hour', durAsHours);
|
||||
};
|
||||
|
||||
class DownloadCommentHistory extends Component {
|
||||
static propTypes = {
|
||||
requestDownloadLink: PropTypes.func.isRequired,
|
||||
root: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
root: { me: { lastAccountDownload } },
|
||||
requestDownloadLink,
|
||||
} = 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
|
||||
)
|
||||
: 0;
|
||||
const canRequestDownload = !lastAccountDownloadDate || hoursLeft <= 0;
|
||||
|
||||
return (
|
||||
<section className={'talk-plugin-ignore-user-section'}>
|
||||
<h3>{t('download_request.section_title')}</h3>
|
||||
<p>
|
||||
{t('download_request.you_will_get_a_copy')}{' '}
|
||||
<b>{t('download_request.download_rate')}</b>.
|
||||
</p>
|
||||
{lastAccountDownloadDate && (
|
||||
<p className={styles.most_recent}>
|
||||
{t('download_request.most_recent_request')}:{' '}
|
||||
{lastAccountDownloadDate.toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
{canRequestDownload ? (
|
||||
<Button className={styles.button} onClick={requestDownloadLink}>
|
||||
<i className="material-icons" aria-hidden={true}>
|
||||
file_download
|
||||
</i>{' '}
|
||||
{t('download_request.request')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button className={styles.button} disabled>
|
||||
<i className="material-icons" aria-hidden={true}>
|
||||
access_time
|
||||
</i>{' '}
|
||||
{t('download_request.rate_limit', readableDuration(hoursLeft))}
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default DownloadCommentHistory;
|
||||
@@ -0,0 +1,39 @@
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { compose, gql } from 'react-apollo';
|
||||
import DownloadCommentHistory from '../components/DownloadCommentHistory';
|
||||
import { withFragments } from 'plugin-api/beta/client/hocs';
|
||||
import { withRequestDownloadLink } from '../mutations';
|
||||
|
||||
class DownloadCommentHistoryContainer extends Component {
|
||||
static propTypes = {
|
||||
requestDownloadLink: PropTypes.func.isRequired,
|
||||
root: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<DownloadCommentHistory
|
||||
root={this.props.root}
|
||||
requestDownloadLink={this.props.requestDownloadLink}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const enhance = compose(
|
||||
withFragments({
|
||||
root: gql`
|
||||
fragment TalkDownloadCommentHistory_DownloadCommentHistorySection_root on RootQuery {
|
||||
__typename
|
||||
me {
|
||||
id
|
||||
lastAccountDownload
|
||||
}
|
||||
}
|
||||
`,
|
||||
}),
|
||||
withRequestDownloadLink
|
||||
);
|
||||
|
||||
export default enhance(DownloadCommentHistoryContainer);
|
||||
@@ -0,0 +1,18 @@
|
||||
import update from 'immutability-helper';
|
||||
|
||||
export default {
|
||||
mutations: {
|
||||
DownloadCommentHistory: () => ({
|
||||
updateQueries: {
|
||||
CoralEmbedStream_Profile: previousData =>
|
||||
update(previousData, {
|
||||
me: {
|
||||
lastAccountDownload: {
|
||||
$set: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import DownloadCommentHistory from './containers/DownloadCommentHistory';
|
||||
import translations from './translations.yml';
|
||||
import graphql from './graphql';
|
||||
|
||||
export default {
|
||||
slots: {
|
||||
profileSettings: [DownloadCommentHistory],
|
||||
},
|
||||
translations,
|
||||
...graphql,
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
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: {} }),
|
||||
}),
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
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"
|
||||
most_recent_request: "Your most recent request"
|
||||
request: "Request Comment History"
|
||||
rate_limit: "You can submit another Comment History request in {0}"
|
||||
hours: "{0} hours"
|
||||
days: "{0} days"
|
||||
hour: "{0} hour"
|
||||
day: "{0} day"
|
||||
@@ -0,0 +1,15 @@
|
||||
const path = require('path');
|
||||
const router = require('./server/router');
|
||||
const mutators = require('./server/mutators');
|
||||
const typeDefs = require('./server/typeDefs');
|
||||
const connect = require('./server/connect');
|
||||
const resolvers = require('./server/resolvers');
|
||||
|
||||
module.exports = {
|
||||
mutators,
|
||||
router,
|
||||
connect,
|
||||
typeDefs,
|
||||
translations: path.join(__dirname, 'translations.yml'),
|
||||
resolvers,
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@coralproject/talk-plugin-profile-data",
|
||||
"version": "1.0.0",
|
||||
"description": "Adds profile data management for Talk",
|
||||
"main": "index.js",
|
||||
"license": "Apache-2.0",
|
||||
"private": false,
|
||||
"dependencies": {
|
||||
"archiver": "^2.1.1",
|
||||
"csv-stringify": "^3.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
const path = require('path');
|
||||
|
||||
module.exports = connectors => {
|
||||
const { services: { Mailer } } = connectors;
|
||||
|
||||
// Setup the mail templates.
|
||||
['txt', 'html'].forEach(format => {
|
||||
Mailer.templates.register(
|
||||
path.join(__dirname, 'emails', `download.${format}.ejs`),
|
||||
'download',
|
||||
format
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
DOWNLOAD_LINK_SUBJECT: 'download_link',
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
<p><%= t('email.download.download_link_ready', organizationName, now.toLocaleString()) %> <a href="<%= downloadLandingURL %>"><%= t('email.download.download_archive') %></a></p>
|
||||
@@ -0,0 +1,3 @@
|
||||
<%= t('email.download.download_link_ready', organizationName, now.toLocaleString()) %>
|
||||
|
||||
<%= downloadLandingURL %>
|
||||
@@ -0,0 +1,18 @@
|
||||
const { TalkError } = require('errors');
|
||||
|
||||
// ErrDownloadToken is returned in the event that the download is requested
|
||||
// without a valid token.
|
||||
class ErrDownloadToken extends TalkError {
|
||||
constructor(err) {
|
||||
super(
|
||||
'Token is invalid',
|
||||
{
|
||||
translation_key: 'DOWNLOAD_TOKEN_INVALID',
|
||||
status: 400,
|
||||
},
|
||||
{ err }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ErrDownloadToken };
|
||||
@@ -0,0 +1,106 @@
|
||||
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;
|
||||
|
||||
// downloadLinkLimiter can be used to limit downloads for the user's data to
|
||||
// once every 7 days.
|
||||
const downloadLinkLimiter = new Limit('profileDataDownloadLimiter', 1, '7d');
|
||||
|
||||
// Check that the user has not already requested a download within the last
|
||||
// 7 days.
|
||||
const attempts = await downloadLinkLimiter.get(user.id);
|
||||
if (attempts && attempts >= 1) {
|
||||
throw new ErrMaxRateLimit();
|
||||
}
|
||||
|
||||
// Check if the lastAccountDownload time is within 7 days.
|
||||
if (
|
||||
user.lastAccountDownload &&
|
||||
moment(user.lastAccountDownload)
|
||||
.add(7, 'days')
|
||||
.isAfter(moment())
|
||||
) {
|
||||
throw new ErrMaxRateLimit();
|
||||
}
|
||||
|
||||
// The account currently does not have a download link, let's record the
|
||||
// download. This will throw an error if a race ocurred and we should stop
|
||||
// now.
|
||||
await downloadLinkLimiter.test(user.id);
|
||||
|
||||
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: {
|
||||
downloadLandingURL,
|
||||
organizationName,
|
||||
now,
|
||||
},
|
||||
subject: I18n.t('email.download.subject', organizationName),
|
||||
});
|
||||
|
||||
// Amend the lastAccountDownload on the user.
|
||||
await User.update(
|
||||
{ id: user.id },
|
||||
{ $set: { 'metadata.lastAccountDownload': now } }
|
||||
);
|
||||
}
|
||||
|
||||
// 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()),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
const { get } = require('lodash');
|
||||
|
||||
module.exports = {
|
||||
RootMutation: {
|
||||
requestDownloadLink: async (_, args, { mutators: { User } }) => {
|
||||
await User.requestDownloadLink();
|
||||
},
|
||||
downloadUser: async (_, { id }, { mutators: { User } }) => ({
|
||||
archiveURL: await User.download(id),
|
||||
}),
|
||||
},
|
||||
User: {
|
||||
lastAccountDownload: (user, args, { user: currentUser }) => {
|
||||
// If the current user is not the requesting user, and the user is not
|
||||
// an admin, return nothing.
|
||||
if (user.id !== currentUser.id && user.role !== 'ADMIN') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return get(user, 'metadata.lastAccountDownload', null);
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,200 @@
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const { DOWNLOAD_LINK_SUBJECT } = require('./constants');
|
||||
const { get, pick, kebabCase } = require('lodash');
|
||||
const moment = require('moment');
|
||||
const archiver = require('archiver');
|
||||
const stringify = require('csv-stringify');
|
||||
const { ErrDownloadToken } = require('./errors');
|
||||
|
||||
async function verifyDownloadToken(
|
||||
{ connectors: { services: { Users } } },
|
||||
token
|
||||
) {
|
||||
const jwt = await Users.verifyToken(token, {
|
||||
subject: DOWNLOAD_LINK_SUBJECT,
|
||||
});
|
||||
|
||||
return jwt;
|
||||
}
|
||||
|
||||
// loadCommentsBatch will load a batch of the comments and write them to the
|
||||
// stream.
|
||||
async function loadCommentsBatch(ctx, csv, variables) {
|
||||
let result = await ctx.graphql(
|
||||
`
|
||||
query GetMyComments($userID: ID!, $cursor: Cursor) {
|
||||
user(id: $userID) {
|
||||
comments(query: {
|
||||
limit: 100,
|
||||
cursor: $cursor,
|
||||
statuses: null
|
||||
}) {
|
||||
hasNextPage
|
||||
endCursor
|
||||
nodes {
|
||||
id
|
||||
created_at
|
||||
asset {
|
||||
url
|
||||
}
|
||||
body
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables
|
||||
);
|
||||
if (result.errors) {
|
||||
throw result.errors;
|
||||
}
|
||||
|
||||
for (const comment of get(result, 'data.user.comments.nodes', [])) {
|
||||
csv.write([
|
||||
comment.id,
|
||||
moment(comment.created_at).format('YYYY-MM-DD HH:mm:ss'),
|
||||
get(comment, 'asset.url'),
|
||||
comment.url,
|
||||
comment.body,
|
||||
]);
|
||||
}
|
||||
|
||||
return pick(get(result, 'data.user.comments'), ['hasNextPage', 'endCursor']);
|
||||
}
|
||||
|
||||
// loadComments will load batches of the comments and write them to the csv
|
||||
// stream. Once the comments have finished writing, it will close the stream.
|
||||
async function loadComments(ctx, userID, archive, latestContentDate) {
|
||||
// Create all the csv writers that'll write the data to the archive.
|
||||
const csv = stringify();
|
||||
|
||||
// Add all the streams as files to the archive.
|
||||
archive.append(csv, { name: 'talk-export/my_comments.csv' });
|
||||
|
||||
csv.write(['ID', 'Timestamp', 'Article', 'Link', 'Body']);
|
||||
|
||||
// Load the first batch's comments from the latest date that we were provided
|
||||
// from the token.
|
||||
let connection = await loadCommentsBatch(ctx, csv, {
|
||||
cursor: latestContentDate,
|
||||
userID,
|
||||
});
|
||||
|
||||
// As long as there's more comments, keep paginating.
|
||||
while (connection.hasNextPage) {
|
||||
connection = await loadCommentsBatch(ctx, csv, {
|
||||
cursor: connection.endCursor,
|
||||
userID,
|
||||
});
|
||||
}
|
||||
|
||||
csv.end();
|
||||
}
|
||||
|
||||
module.exports = router => {
|
||||
// /account/download will render the download page.
|
||||
router.get('/account/download', (req, res) => {
|
||||
res.render(path.join(__dirname, 'views', 'download'));
|
||||
});
|
||||
|
||||
// /api/v1/account/download will send back a zipped archive of the users
|
||||
// account.
|
||||
router.all(
|
||||
'/api/v1/account/download',
|
||||
express.urlencoded({ extended: false }),
|
||||
async (req, res, next) => {
|
||||
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.
|
||||
try {
|
||||
// Verify the token
|
||||
await verifyDownloadToken(req.context, token);
|
||||
} catch (err) {
|
||||
return next(new ErrDownloadToken(err));
|
||||
}
|
||||
|
||||
res.status(204).end();
|
||||
|
||||
// Don't continue to pass it onto the next middleware, as we've only been
|
||||
// asked to verify the token.
|
||||
return;
|
||||
}
|
||||
|
||||
const { connectors: { graph: { Context }, errors } } = req.context;
|
||||
|
||||
try {
|
||||
// Pull the userID and the date that the token was issued out of the
|
||||
// provided token.
|
||||
const { user: userID, iat } = await verifyDownloadToken(
|
||||
req.context,
|
||||
token
|
||||
);
|
||||
|
||||
// Create a system context used to get all comments for that user.
|
||||
const ctx = Context.forSystem();
|
||||
|
||||
// Get the current user's username. We need it for the generated filenames.
|
||||
const result = await ctx.graphql(
|
||||
`query GetUser($userID: ID!) {
|
||||
user(id: $userID) { username }
|
||||
}`,
|
||||
{ userID }
|
||||
);
|
||||
if (result.errors) {
|
||||
throw result.errors;
|
||||
}
|
||||
|
||||
const user = get(result, 'data.user');
|
||||
if (!user) {
|
||||
throw new errors.ErrNotFound();
|
||||
}
|
||||
|
||||
// Unpack the date that the token was issued, and use it as a source for the
|
||||
// earliest comment we should include in the download.
|
||||
const latestContentDate = new Date(iat * 1000);
|
||||
|
||||
// Generate the filename of the file that the user will download.
|
||||
const username = get(user, 'username');
|
||||
const filename = `talk-${kebabCase(username)}-${kebabCase(
|
||||
moment(latestContentDate).format('YYYY-MM-DD HH:mm:ss')
|
||||
)}.zip`;
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Disposition': `attachment; filename=${filename}`,
|
||||
});
|
||||
|
||||
// Create the zip archive we'll use to write all the exported files to.
|
||||
const archive = archiver('zip', {
|
||||
zlib: { level: 9 },
|
||||
});
|
||||
|
||||
// Pipe this to the response writer directly.
|
||||
archive.pipe(res);
|
||||
|
||||
// Load the comments csv up with the user's comments.
|
||||
await loadComments(ctx, userID, archive, latestContentDate);
|
||||
|
||||
// Mark the end of adding files, no more files can be added after this. Once
|
||||
// all the stream readers have finished writing, and have closed, the
|
||||
// archiver will close which will finish the HTTP request.
|
||||
archive.finalize();
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
type User {
|
||||
|
||||
# lastAccountDownload is the date that the user last requested a comment
|
||||
# download.
|
||||
lastAccountDownload: Date
|
||||
}
|
||||
|
||||
type RequestDownloadLinkResponse implements Response {
|
||||
|
||||
# An array of errors relating to the mutation that occurred.
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
module.exports = fs.readFileSync(
|
||||
path.join(__dirname, 'typeDefs.graphql'),
|
||||
'utf8'
|
||||
);
|
||||
@@ -0,0 +1,56 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title><%= t('download_landing.download_your_account') %></title>
|
||||
<%- include(root + '/partials/account') %>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">
|
||||
<section class="container">
|
||||
<h1><%= t('download_landing.download_your_account') %></h1>
|
||||
<p><%= t('download_landing.download_details') %></p>
|
||||
<p><%= t('download_landing.all_information_included') %></p>
|
||||
<ul class="check_list">
|
||||
<li><%= t('download_landing.information_included.date') %></li>
|
||||
<li><%= t('download_landing.information_included.url') %></li>
|
||||
<li><%= t('download_landing.information_included.body') %></li>
|
||||
<li><%= t('download_landing.information_included.asset_url') %></li>
|
||||
</ul>
|
||||
<div class="error-console"><span></span></div>
|
||||
<form id="download-form" method="post" action="<%= BASE_PATH %>api/v1/account/download">
|
||||
<button type="submit"><%= t('download_landing.confirm') %></button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.1.1/jquery.min.js"></script>
|
||||
<script type="text/javascript">
|
||||
$(function() {
|
||||
function showError(error) {
|
||||
try {
|
||||
let err = JSON.parse(error);
|
||||
$('.error-console span').text(err.message);
|
||||
$('.error-console').fadeIn();
|
||||
} catch (err) {
|
||||
$('.error-console span').text(error);
|
||||
$('.error-console').fadeIn();
|
||||
}
|
||||
}
|
||||
|
||||
var token = location.hash.replace('#', '');
|
||||
|
||||
$.ajax({
|
||||
url: '<%= BASE_PATH %>api/v1/account/download',
|
||||
contentType: 'application/json',
|
||||
method: 'POST',
|
||||
data: JSON.stringify({token: token, check: true})
|
||||
})
|
||||
.then(function () {
|
||||
$('#download-form').append('<input name="token" type="hidden" value="' + token + '"/>').fadeIn();
|
||||
})
|
||||
.catch(function (error) {
|
||||
showError(error.responseText);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,18 @@
|
||||
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 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"
|
||||
url: "The permalink URL for the comment"
|
||||
body: "The comment text"
|
||||
asset_url: "The URL on the article or story where the comment appears"
|
||||
confirm: "Download My Comment History"
|
||||
email:
|
||||
download:
|
||||
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"
|
||||
error:
|
||||
DOWNLOAD_TOKEN_INVALID: "Your download link is not valid."
|
||||
Reference in New Issue
Block a user