Merge branch 'master' into gdpr-delete

This commit is contained in:
Wyatt Johnson
2018-05-02 09:58:04 -06:00
59 changed files with 653 additions and 214 deletions
+4 -1
View File
@@ -6,6 +6,8 @@ import Login from './login/containers/Main';
import reducer from './login/reducer';
import DeleteMyAccount from './profile-settings/containers/DeleteMyAccount';
import AccountDeletionRequestedSign from './stream/containers/AccountDeletionRequestedSign';
import ChangePassword from './profile-settings/containers/ChangePassword';
import ChangeUsername from './profile-settings/containers/ChangeUsername';
export default {
reducer,
@@ -18,6 +20,7 @@ export default {
SetUsernameDialog,
],
login: [Login],
profileSettings: [DeleteMyAccount],
profileHeader: [ChangeUsername],
profileSettings: [ChangePassword, DeleteMyAccount],
},
};
@@ -103,8 +103,7 @@ class SignUp extends React.Component {
/>
{passwordError && (
<span className={styles.hint}>
{' '}
Password must be at least 8 characters.{' '}
{t('talk-plugin-auth.login.password_error')}
</span>
)}
<TextField
@@ -58,8 +58,9 @@ 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"
password_error: "Password must be at least 8 characters."
forgot_your_pass: "Forgot your password?"
need_an_account: "Need an account?"
register: "Register"
@@ -101,8 +102,9 @@ 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"
password_error: "Password must be at least 8 characters."
forgot_your_pass: "Forgot your password?"
need_an_account: "Need an account?"
register: "Register"
@@ -131,6 +133,29 @@ 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"
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 be changed every 14 days"
de:
talk-plugin-auth:
login:
@@ -192,6 +217,7 @@ es:
or: "O"
email: "Dirección de Correo"
password: "Contraseña"
password_error: "La contraseña debe tener al menos 8 caracteres."
forgot_your_pass: "¿Has olvidado tu contraseña?"
need_an_account: "¿Necesitas una cuenta?"
register: "Registrar"
@@ -222,6 +248,29 @@ 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: "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."
fr:
talk-plugin-auth:
login:
@@ -324,7 +373,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">
+2 -1
View File
@@ -20,4 +20,5 @@ 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 the users comments in a CSV format.
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.
@@ -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 %>
@@ -6,18 +6,41 @@ const {
ErrDeletionAlreadyScheduled,
ErrDeletionNotScheduled,
} = require('./errors');
const { ErrNotAuthorized } = require('errors');
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');
@@ -26,7 +49,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.
@@ -36,7 +59,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
@@ -44,21 +67,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,
},
@@ -125,3 +145,20 @@ module.exports = ctx =>
cancelDeletion: () => Promise.reject(new ErrNotAuthorized()),
},
};
// 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()),
},
});
@@ -11,6 +11,9 @@ module.exports = {
cancelAccountDeletion: async (_, args, { mutators: { User } }) => {
await User.cancelDeletion();
},
downloadUser: async (_, { id }, { mutators: { User } }) => ({
archiveURL: await User.download(id),
}),
},
User: {
lastAccountDownload: (user, args, { user: currentUser }) => {
@@ -20,14 +20,15 @@ async function verifyDownloadToken(
// loadCommentsBatch will load a batch of the comments and write them to the
// stream.
async function loadCommentsBatch(ctx, csv, variables = {}) {
async function loadCommentsBatch(ctx, csv, variables) {
let result = await ctx.graphql(
`
query GetMyComments($cursor: Cursor) {
me {
query GetMyComments($userID: ID!, $cursor: Cursor) {
user(id: $userID) {
comments(query: {
limit: 100,
cursor: $cursor
cursor: $cursor,
statuses: null
}) {
hasNextPage
endCursor
@@ -50,7 +51,7 @@ async function loadCommentsBatch(ctx, csv, variables = {}) {
throw result.errors;
}
for (const comment of get(result, 'data.me.comments.nodes', [])) {
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'),
@@ -60,12 +61,12 @@ async function loadCommentsBatch(ctx, csv, variables = {}) {
]);
}
return pick(result.data.me.comments, ['hasNextPage', 'endCursor']);
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, archive, latestContentDate) {
async function loadComments(ctx, userID, archive, latestContentDate) {
// Create all the csv writers that'll write the data to the archive.
const csv = stringify();
@@ -78,12 +79,14 @@ async function loadComments(ctx, archive, latestContentDate) {
// 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,
});
}
@@ -98,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.
@@ -120,7 +133,7 @@ module.exports = router => {
return;
}
const { connectors: { services: { Users } } } = req.context;
const { connectors: { graph: { Context }, errors } } = req.context;
try {
// Pull the userID and the date that the token was issued out of the
@@ -130,25 +143,31 @@ module.exports = router => {
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);
// Grab the user that we're generating the export from. We'll use it to
// create a new context.
const user = await Users.findById(userID);
// Base a new context off of the new user.
const ctx = req.context.masqueradeAs(user);
// Get the current user's username. We need it for the generated filenames.
const result = await ctx.graphql('{ me { username } }');
if (result.errors) {
throw result.errors;
}
const username = get(result, 'data.me.username');
// 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`;
@@ -167,7 +186,7 @@ module.exports = router => {
archive.pipe(res);
// Load the comments csv up with the user's comments.
await loadComments(ctx, archive, latestContentDate);
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
@@ -41,6 +41,18 @@ type CancelAccountDeletionResponse implements Response {
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
# 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
@@ -54,4 +66,8 @@ type RootMutation {
# 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
}
@@ -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"
+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
@@ -12,7 +12,7 @@ de:
label: Beliebteste zuerst
es:
talk-plugin-sort-most-liked:
label: Most liked first
label: Más valoradas primero
fr:
talk-plugin-sort-most-liked:
label: Most liked first
@@ -12,7 +12,7 @@ de:
label: Häufigste "Ich liebe es" zuerst
es:
talk-plugin-sort-most-loved:
label: Most loved first
label: Más amadas primero
fr:
talk-plugin-sort-most-loved:
label: Most loved first
@@ -12,7 +12,7 @@ de:
label: Häufigste Antworten zuerst
es:
talk-plugin-sort-most-replied:
label: Most replied first
label: Más respondidas primero
fr:
talk-plugin-sort-most-replied:
label: Most replied first
@@ -12,7 +12,7 @@ de:
label: Häufigste "Respektiert" zuerst
es:
talk-plugin-sort-most-respected:
label: Most respected first
label: Más respetadas primero
fr:
talk-plugin-sort-most-respected:
label: Most respected first
@@ -1,3 +1,6 @@
en:
talk-plugin-sort-most-upvoted:
label: Most upvoted first
es:
talk-plugin-sort-oldest:
label: Más votadas primero
@@ -12,7 +12,7 @@ de:
label: Neueste zuerst
es:
talk-plugin-sort-newest:
label: Newest first
label: Más nuevas primero
fr:
talk-plugin-sort-newest:
label: Newest first
@@ -12,7 +12,7 @@ de:
label: Älteste zuerst
es:
talk-plugin-sort-oldest:
label: Oldest first
label: Más viejas primero
fr:
talk-plugin-sort-oldest:
label: Oldest first
@@ -21,8 +21,8 @@ de:
es:
talk-plugin-viewing-options:
viewing_options: "Opciones de visualización"
sort: Sorting
filter: Filtering
sort: Ordenado por
filter: Filtrado por
fr:
talk-plugin-viewing-options:
viewing_options: "Viewing Options"