mirror of
https://github.com/wassname/talk.git
synced 2026-08-03 13:20:59 +08:00
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:
+8
-1
@@ -287,8 +287,15 @@ async function createUser() {
|
||||
|
||||
const { email, username, password, role } = answers;
|
||||
|
||||
const ctx = Context.forSystem();
|
||||
|
||||
// Create the user.
|
||||
const user = await UsersService.createLocalUser(email, password, username);
|
||||
const user = await UsersService.createLocalUser(
|
||||
ctx,
|
||||
email,
|
||||
password,
|
||||
username
|
||||
);
|
||||
|
||||
// Set the role.
|
||||
await UsersService.setRole(user.id, role);
|
||||
|
||||
@@ -71,7 +71,6 @@ class OrganizationSettings extends React.Component {
|
||||
await this.props.savePending();
|
||||
this.disableEditing();
|
||||
};
|
||||
|
||||
displayErrors = (errors = []) => (
|
||||
<ul className={styles.errorList}>
|
||||
{errors.map((errKey, i) => (
|
||||
|
||||
@@ -4,13 +4,32 @@ import Slot from 'coral-framework/components/Slot';
|
||||
import styles from './Profile.css';
|
||||
import TabPanel from '../containers/TabPanel';
|
||||
|
||||
const Profile = ({ username, emailAddress, root, slotPassthrough }) => {
|
||||
const DefaultProfileHeader = ({ username, emailAddress }) => (
|
||||
<div className={styles.userInfo}>
|
||||
<h2 className={styles.username}>{username}</h2>
|
||||
{emailAddress ? <p className={styles.email}>{emailAddress}</p> : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
DefaultProfileHeader.propTypes = {
|
||||
username: PropTypes.string,
|
||||
emailAddress: PropTypes.string,
|
||||
};
|
||||
|
||||
const Profile = ({ id, username, emailAddress, root, slotPassthrough }) => {
|
||||
return (
|
||||
<div className="talk-my-profile talk-profile-container">
|
||||
<div className={styles.userInfo}>
|
||||
<h2 className={styles.username}>{username}</h2>
|
||||
{emailAddress ? <p className={styles.email}>{emailAddress}</p> : null}
|
||||
</div>
|
||||
<Slot
|
||||
fill="profileHeader"
|
||||
size={1}
|
||||
defaultComponent={DefaultProfileHeader}
|
||||
passthrough={{
|
||||
...slotPassthrough,
|
||||
id,
|
||||
username,
|
||||
emailAddress,
|
||||
}}
|
||||
/>
|
||||
<Slot fill="profileSections" passthrough={slotPassthrough} />
|
||||
<TabPanel root={root} slotPassthrough={slotPassthrough} />
|
||||
</div>
|
||||
@@ -18,6 +37,7 @@ const Profile = ({ username, emailAddress, root, slotPassthrough }) => {
|
||||
};
|
||||
|
||||
Profile.propTypes = {
|
||||
id: PropTypes.string,
|
||||
username: PropTypes.string,
|
||||
emailAddress: PropTypes.string,
|
||||
root: PropTypes.object,
|
||||
|
||||
@@ -30,6 +30,7 @@ class ProfileContainer extends Component {
|
||||
|
||||
return (
|
||||
<Profile
|
||||
id={me.id}
|
||||
username={me.username}
|
||||
emailAddress={emailAddress}
|
||||
root={root}
|
||||
|
||||
@@ -177,8 +177,8 @@ button.comment__action-button[disabled],
|
||||
}
|
||||
|
||||
.talk-plugin-flags-popup-header {
|
||||
font-weight: bolder;
|
||||
font-size: 1.33rem;
|
||||
font-weight: bold;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ export default {
|
||||
'UnsuspendUserResponse',
|
||||
'UpdateAssetSettingsResponse',
|
||||
'UpdateAssetStatusResponse',
|
||||
'UpdateSettingsResponse'
|
||||
'UpdateSettingsResponse',
|
||||
'ChangePasswordResponse'
|
||||
),
|
||||
};
|
||||
|
||||
@@ -623,6 +623,27 @@ export const withUpdateSettings = withMutation(
|
||||
}
|
||||
);
|
||||
|
||||
export const withChangePassword = withMutation(
|
||||
gql`
|
||||
mutation ChangePassword($input: ChangePasswordInput!) {
|
||||
changePassword(input: $input) {
|
||||
...ChangePasswordResponse
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
props: ({ mutate }) => ({
|
||||
changePassword: input => {
|
||||
return mutate({
|
||||
variables: {
|
||||
input,
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
export const withUpdateAssetSettings = withMutation(
|
||||
gql`
|
||||
mutation UpdateAssetSettings($id: ID!, $input: AssetSettingsInput!) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'moment/locale/da';
|
||||
import 'moment/locale/de';
|
||||
import 'moment/locale/es';
|
||||
import 'moment/locale/fr';
|
||||
import 'moment/locale/nl';
|
||||
import 'moment/locale/pt-br';
|
||||
|
||||
import { createStorage } from 'coral-framework/services/storage';
|
||||
@@ -18,10 +19,10 @@ import daTA from 'timeago.js/locales/da';
|
||||
import deTA from 'timeago.js/locales/de';
|
||||
import esTA from 'timeago.js/locales/es';
|
||||
import frTA from 'timeago.js/locales/fr';
|
||||
import nlTA from 'timeago.js/locales/nl';
|
||||
import pt_BRTA from 'timeago.js/locales/pt_BR';
|
||||
import zh_CNTA from 'timeago.js/locales/zh_CN';
|
||||
import zh_TWTA from 'timeago.js/locales/zh_TW';
|
||||
import nl from 'timeago.js/locales/nl';
|
||||
|
||||
import ar from '../../../locales/ar.yml';
|
||||
import en from '../../../locales/en.yml';
|
||||
@@ -29,10 +30,10 @@ import da from '../../../locales/da.yml';
|
||||
import de from '../../../locales/de.yml';
|
||||
import es from '../../../locales/es.yml';
|
||||
import fr from '../../../locales/fr.yml';
|
||||
import nl_NL from '../../../locales/nl_NL.yml';
|
||||
import pt_BR from '../../../locales/pt_BR.yml';
|
||||
import zh_CN from '../../../locales/zh_CN.yml';
|
||||
import zh_TW from '../../../locales/zh_TW.yml';
|
||||
import nl_NL from '../../../locales/nl_NL.yml';
|
||||
|
||||
const defaultLanguage = process.env.TALK_DEFAULT_LANG;
|
||||
const translations = {
|
||||
@@ -112,10 +113,10 @@ export function setupTranslations() {
|
||||
ta.register('da', daTA);
|
||||
ta.register('de', deTA);
|
||||
ta.register('fr', frTA);
|
||||
ta.register('nl_NL', nlTA);
|
||||
ta.register('pt_BR', pt_BRTA);
|
||||
ta.register('zh_CN', zh_CNTA);
|
||||
ta.register('zh_TW', zh_TWTA);
|
||||
ta.register('nl_NL', nl);
|
||||
|
||||
timeagoInstance = ta();
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
box-sizing: border-box;
|
||||
background: white;
|
||||
border-radius: 3px;
|
||||
padding: 20px 10px;
|
||||
padding: 10px 10px;
|
||||
z-index: 300;
|
||||
right: 1%;
|
||||
}
|
||||
|
||||
@@ -170,8 +170,9 @@ moderators.
|
||||
|
||||
All your team and commenters show in the People sub-tab. From here, you can
|
||||
manage your team members’ roles (Admins, Moderators, Staff), as well as search
|
||||
for commenters and take action on them (e.g. Ban/Un-ban, Suspend, etc.). ###
|
||||
Configure
|
||||
for commenters and take action on them (e.g. Ban/Un-ban, Suspend, etc.).
|
||||
|
||||
### Configure
|
||||
|
||||
See [Configuring Talk](/talk/configuring-talk/).
|
||||
|
||||
|
||||
@@ -10,6 +10,9 @@ const secrets = require('../secrets');
|
||||
// Errors.
|
||||
const errors = require('../errors');
|
||||
|
||||
// URLs.
|
||||
const url = require('../url');
|
||||
|
||||
// Graph.
|
||||
const { getBroker } = require('./subscriptions/broker');
|
||||
const { getPubsub } = require('./subscriptions/pubsub');
|
||||
@@ -58,6 +61,7 @@ const defaultConnectors = {
|
||||
errors,
|
||||
config,
|
||||
secrets,
|
||||
url,
|
||||
models: {
|
||||
Action,
|
||||
Asset,
|
||||
|
||||
@@ -9,6 +9,7 @@ const {
|
||||
SET_USER_SUSPENSION_STATUS,
|
||||
UPDATE_USER_ROLES,
|
||||
DELETE_USER,
|
||||
CHANGE_PASSWORD,
|
||||
} = require('../../perms/constants');
|
||||
|
||||
const setUserUsernameStatus = async (ctx, id, status) => {
|
||||
@@ -143,6 +144,38 @@ const delUser = async (ctx, id) => {
|
||||
await user.remove();
|
||||
};
|
||||
|
||||
const changeUserPassword = async (ctx, oldPassword, newPassword) => {
|
||||
const {
|
||||
user,
|
||||
loaders: { Settings },
|
||||
connectors: { services: { I18n } },
|
||||
} = ctx;
|
||||
|
||||
// Verify the old password.
|
||||
const validPassword = await user.verifyPassword(oldPassword);
|
||||
if (!validPassword) {
|
||||
throw new ErrNotAuthorized();
|
||||
}
|
||||
|
||||
// Change the users password now.
|
||||
await Users.changePassword(user.id, newPassword);
|
||||
|
||||
// Get some context for the email to be sent.
|
||||
const { organizationName, organizationContactEmail } = await Settings.load([
|
||||
'organizationName',
|
||||
'organizationContactEmail',
|
||||
]);
|
||||
|
||||
// Send the password change email.
|
||||
await Users.sendEmail(user, {
|
||||
template: 'plain',
|
||||
locals: {
|
||||
body: I18n.t('email.password_change.body', organizationContactEmail),
|
||||
},
|
||||
subject: I18n.t('email.password_change.subject', organizationName),
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = ctx => {
|
||||
let mutators = {
|
||||
User: {
|
||||
@@ -155,6 +188,7 @@ module.exports = ctx => {
|
||||
setUsername: () => Promise.reject(new ErrNotAuthorized()),
|
||||
stopIgnoringUser: () => Promise.reject(new ErrNotAuthorized()),
|
||||
del: () => Promise.reject(new ErrNotAuthorized()),
|
||||
changePassword: () => Promise.reject(new ErrNotAuthorized()),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -194,6 +228,11 @@ module.exports = ctx => {
|
||||
if (ctx.user.can(DELETE_USER)) {
|
||||
mutators.User.del = id => delUser(ctx, id);
|
||||
}
|
||||
|
||||
if (ctx.user.can(CHANGE_PASSWORD)) {
|
||||
mutators.User.changePassword = ({ oldPassword, newPassword }) =>
|
||||
changeUserPassword(ctx, oldPassword, newPassword);
|
||||
}
|
||||
}
|
||||
|
||||
return mutators;
|
||||
|
||||
@@ -139,6 +139,9 @@ const RootMutation = {
|
||||
delUser: async (_, { id }, { mutators: { User } }) => {
|
||||
await User.del(id);
|
||||
},
|
||||
changePassword: async (_, { input }, { mutators: { User } }) => {
|
||||
await User.changePassword(input);
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = RootMutation;
|
||||
|
||||
@@ -1442,6 +1442,21 @@ type DelUserResponse implements Response {
|
||||
errors: [UserError!]
|
||||
}
|
||||
|
||||
input ChangePasswordInput {
|
||||
# oldPassword is the previous password set on the account. An incorrect
|
||||
# password here will result in an unauthorized error being thrown.
|
||||
oldPassword: String!
|
||||
|
||||
# newPassword is the password we're changing it to.
|
||||
newPassword: String!
|
||||
}
|
||||
|
||||
type ChangePasswordResponse implements Response {
|
||||
|
||||
# An array of errors relating to the mutation that occurred.
|
||||
errors: [UserError!]
|
||||
}
|
||||
|
||||
# All mutations for the application are defined on this object.
|
||||
type RootMutation {
|
||||
|
||||
@@ -1542,6 +1557,10 @@ type RootMutation {
|
||||
|
||||
# delUser will delete the user with the specified id.
|
||||
delUser(id: ID!): DelUserResponse
|
||||
|
||||
# changePassword allows the current user to change their password that have an
|
||||
# associated local user account.
|
||||
changePassword(input: ChangePasswordInput!): ChangePasswordResponse
|
||||
}
|
||||
|
||||
type UsernameChangedPayload {
|
||||
|
||||
+7
-4
@@ -218,6 +218,9 @@ en:
|
||||
we_received_a_request: "We received a request to reset your password. If you did not request this change, you can ignore this email."
|
||||
if_you_did: "If you did,"
|
||||
please_click: "please click here to reset password"
|
||||
password_change:
|
||||
subject: "{0} password change"
|
||||
body: "The password on your account has been changed.\n\nIf you did not request this change, please contact us at {0}."
|
||||
embedlink:
|
||||
copy: "Copy to Clipboard"
|
||||
error:
|
||||
@@ -233,7 +236,7 @@ en:
|
||||
RATE_LIMIT_EXCEEDED: "Rate limit exceeded"
|
||||
USERNAME_IN_USE: "Username already in use"
|
||||
USERNAME_REQUIRED: "Must input a username"
|
||||
EMAIL_NOT_VERIFIED: "E-mail address not verified"
|
||||
EMAIL_NOT_VERIFIED: "Email address not verified"
|
||||
EDIT_WINDOW_ENDED: "You can no longer edit this comment. The time window to do so has expired."
|
||||
EDIT_USERNAME_NOT_AUTHORIZED: "You do not have permission to update your username."
|
||||
SAME_USERNAME_PROVIDED: "You must submit a different username."
|
||||
@@ -249,8 +252,8 @@ en:
|
||||
email: "Not a valid E-Mail"
|
||||
confirm_password: "Passwords don't match. Please check again"
|
||||
network_error: "Failed to connect to server. Check your internet connection and try again."
|
||||
email_not_verified: "E-mail address {0} not verified."
|
||||
email_password: "E-mail and/or password combination incorrect."
|
||||
email_not_verified: "Email address {0} not verified."
|
||||
email_password: "Email and/or password combination incorrect."
|
||||
organization_name: "Organization name must only contain letters or numbers."
|
||||
organization_contact_email: "Organization email is not valid."
|
||||
password: "Password must be at least 8 characters"
|
||||
@@ -438,7 +441,7 @@ en:
|
||||
title_reject: "We noticed you rejected a username"
|
||||
suspend_user: "Suspend User"
|
||||
yes_suspend: "Yes suspend"
|
||||
email_message_reject: "Another member of the community recently flagged your username for review. Because of its content your user was rejected. This means you can no longer comment, like, or flag content until you rewrite your username. Please e-mail us if you have any questions or concerns."
|
||||
email_message_reject: "Another member of the community recently flagged your username for review. Because of its content your user was rejected. This means you can no longer comment, like, or flag content until you rewrite your username. Please email us if you have any questions or concerns."
|
||||
write_message: "Write a message"
|
||||
send: Send
|
||||
thank_you: "We value your safety and feedback. A moderator will review your report."
|
||||
|
||||
@@ -19,4 +19,5 @@ module.exports = {
|
||||
UPDATE_ASSET_STATUS: 'UPDATE_ASSET_STATUS',
|
||||
UPDATE_SETTINGS: 'UPDATE_SETTINGS',
|
||||
DELETE_USER: 'DELETE_USER',
|
||||
CHANGE_PASSWORD: 'CHANGE_PASSWORD',
|
||||
};
|
||||
|
||||
@@ -1,10 +1,34 @@
|
||||
const { get, isString } = require('lodash');
|
||||
const moment = require('moment');
|
||||
const { check } = require('../utils');
|
||||
const types = require('../constants');
|
||||
|
||||
module.exports = (user, perm) => {
|
||||
switch (perm) {
|
||||
case types.CHANGE_USERNAME:
|
||||
return user.status.username.status === 'REJECTED';
|
||||
case types.CHANGE_PASSWORD:
|
||||
// Only users with a local account where they have a password set can
|
||||
// actually change their password.
|
||||
return (
|
||||
user.profiles.some(({ provider }) => provider === 'local') &&
|
||||
isString(user.password) &&
|
||||
user.password.length > 0
|
||||
);
|
||||
|
||||
case types.CHANGE_USERNAME: {
|
||||
// Only users who have their usernames rejected or those users who
|
||||
// not changed their usernames within 14 days can change their usernames.
|
||||
const now = moment();
|
||||
return (
|
||||
user.status.username.status === 'REJECTED' ||
|
||||
get(user, 'status.username.history', [])
|
||||
.filter(({ status }) => status === 'CHANGED')
|
||||
.every(({ created_at }) =>
|
||||
moment(created_at)
|
||||
.add(14, 'days')
|
||||
.isAfter(now)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
case types.SET_USERNAME:
|
||||
return user.status.username.status === 'UNSET';
|
||||
|
||||
@@ -25,5 +25,7 @@ export {
|
||||
withUnbanUser,
|
||||
withStopIgnoringUser,
|
||||
withSetCommentStatus,
|
||||
withChangePassword,
|
||||
withChangeUsername,
|
||||
} from 'coral-framework/graphql/mutations';
|
||||
export { compose } from 'recompose';
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -109,20 +109,11 @@ router.put(
|
||||
async (req, res, next) => {
|
||||
const { token, password } = req.body;
|
||||
|
||||
if (!password || password.length < 8) {
|
||||
return next(errors.ErrPasswordTooShort);
|
||||
}
|
||||
|
||||
try {
|
||||
let [user, redirect] = await UsersService.verifyPasswordResetToken(token);
|
||||
|
||||
// Change the users' password.
|
||||
await UsersService.changePassword(user.id, password);
|
||||
|
||||
const { redirect } = await UsersService.resetPassword(token, password);
|
||||
res.json({ redirect });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return next(errors.ErrNotAuthorized);
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
+127
-49
@@ -1,4 +1,5 @@
|
||||
const uuid = require('uuid');
|
||||
const moment = require('moment');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const {
|
||||
ErrMaxRateLimit,
|
||||
@@ -132,7 +133,7 @@ class Users {
|
||||
locals: {
|
||||
body: message,
|
||||
},
|
||||
subject: 'Your account has been suspended',
|
||||
subject: 'Your account has been suspended', // TODO: replace with translation
|
||||
});
|
||||
}
|
||||
|
||||
@@ -234,57 +235,64 @@ class Users {
|
||||
return user;
|
||||
}
|
||||
|
||||
static async _setUsername(
|
||||
id,
|
||||
username,
|
||||
fromStatus,
|
||||
toStatus,
|
||||
assignedBy,
|
||||
resetAllowed = false
|
||||
) {
|
||||
static async setUsername(id, username, assignedBy) {
|
||||
try {
|
||||
const oldestEditTime = moment()
|
||||
.subtract(14, 'days')
|
||||
.toDate();
|
||||
|
||||
// A username can be set if:
|
||||
//
|
||||
// - The previous status was 'UNSET'
|
||||
// - The username has not been changed within the last 14 days.
|
||||
const query = {
|
||||
id,
|
||||
'status.username.status': fromStatus,
|
||||
};
|
||||
if (!resetAllowed) {
|
||||
query.username = { $ne: username };
|
||||
}
|
||||
|
||||
let user = await User.findOneAndUpdate(
|
||||
query,
|
||||
{
|
||||
$set: {
|
||||
username,
|
||||
lowercaseUsername: username.toLowerCase(),
|
||||
'status.username.status': toStatus,
|
||||
$or: [
|
||||
{
|
||||
'status.username.status': 'UNSET',
|
||||
},
|
||||
$push: {
|
||||
'status.username.history': {
|
||||
status: toStatus,
|
||||
assigned_by: assignedBy,
|
||||
created_at: Date.now(),
|
||||
{
|
||||
'status.username.status': { $in: ['APPROVED', 'SET'] },
|
||||
'status.username.history.created_at': {
|
||||
$lte: oldestEditTime,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const update = {
|
||||
$set: {
|
||||
username,
|
||||
lowercaseUsername: username.toLowerCase(),
|
||||
'status.username.status': 'SET',
|
||||
},
|
||||
{
|
||||
new: true,
|
||||
}
|
||||
);
|
||||
$push: {
|
||||
'status.username.history': {
|
||||
status: 'SET',
|
||||
assigned_by: assignedBy,
|
||||
created_at: Date.now(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let user = await User.findOneAndUpdate(query, update, {
|
||||
new: true,
|
||||
});
|
||||
if (!user) {
|
||||
user = await Users.findById(id);
|
||||
if (user === null) {
|
||||
throw new ErrNotFound();
|
||||
}
|
||||
|
||||
if (user.status.username.status !== fromStatus) {
|
||||
if (
|
||||
!['UNSET', 'APPROVED', 'SET'].includes(user.status.username.status) ||
|
||||
!user.status.username.history.every(({ created_at }) =>
|
||||
oldestEditTime.isAfter(created_at)
|
||||
)
|
||||
) {
|
||||
throw new ErrPermissionUpdateUsername();
|
||||
}
|
||||
|
||||
if (!resetAllowed && user.username === username) {
|
||||
throw new ErrSameUsernameProvided();
|
||||
}
|
||||
|
||||
throw new Error('edit username failed for an unexpected reason');
|
||||
}
|
||||
|
||||
@@ -298,12 +306,57 @@ class Users {
|
||||
}
|
||||
}
|
||||
|
||||
static async setUsername(id, username, assignedBy) {
|
||||
return Users._setUsername(id, username, 'UNSET', 'SET', assignedBy, true);
|
||||
}
|
||||
|
||||
static async changeUsername(id, username, assignedBy) {
|
||||
return Users._setUsername(id, username, 'REJECTED', 'CHANGED', assignedBy);
|
||||
try {
|
||||
const query = {
|
||||
id,
|
||||
username: { $ne: username },
|
||||
'status.username.status': 'REJECTED',
|
||||
};
|
||||
|
||||
const update = {
|
||||
$set: {
|
||||
username,
|
||||
lowercaseUsername: username.toLowerCase(),
|
||||
'status.username.status': 'CHANGED',
|
||||
},
|
||||
$push: {
|
||||
'status.username.history': {
|
||||
status: 'CHANGED',
|
||||
assigned_by: assignedBy,
|
||||
created_at: Date.now(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let user = await User.findOneAndUpdate(query, update, {
|
||||
new: true,
|
||||
});
|
||||
if (!user) {
|
||||
user = await Users.findById(id);
|
||||
if (user === null) {
|
||||
throw new ErrNotFound();
|
||||
}
|
||||
|
||||
if (user.status.username.status !== 'REJECTED') {
|
||||
throw new ErrPermissionUpdateUsername();
|
||||
}
|
||||
|
||||
if (user.username === username) {
|
||||
throw new ErrSameUsernameProvided();
|
||||
}
|
||||
|
||||
throw new Error('edit username failed for an unexpected reason');
|
||||
}
|
||||
|
||||
return user;
|
||||
} catch (err) {
|
||||
if (err.code === 11000) {
|
||||
throw new ErrUsernameTaken();
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -490,6 +543,10 @@ class Users {
|
||||
}
|
||||
|
||||
static async changePassword(id, password) {
|
||||
if (!password || password.length < 8) {
|
||||
throw new ErrPasswordTooShort();
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS);
|
||||
|
||||
return User.update(
|
||||
@@ -725,18 +782,13 @@ class Users {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies a jwt and returns the associated user. Throws an error when the
|
||||
* token isn't valid.
|
||||
*
|
||||
* @param {String} token the JSON Web Token to verify
|
||||
*/
|
||||
// TODO: update doc
|
||||
static async verifyPasswordResetToken(token) {
|
||||
if (!token) {
|
||||
throw new Error('cannot verify an empty token');
|
||||
}
|
||||
|
||||
const { userId, loc, version } = await Users.verifyToken(token, {
|
||||
const { userId, loc: redirect, version } = await Users.verifyToken(token, {
|
||||
subject: PASSWORD_RESET_JWT_SUBJECT,
|
||||
});
|
||||
|
||||
@@ -746,7 +798,33 @@ class Users {
|
||||
throw new Error('password reset token has expired');
|
||||
}
|
||||
|
||||
return [user, loc];
|
||||
return { user, redirect, version };
|
||||
}
|
||||
|
||||
// TODO: update doc
|
||||
static async resetPassword(token, password) {
|
||||
const { user, redirect, version } = await this.verifyPasswordResetToken(
|
||||
token
|
||||
);
|
||||
|
||||
if (!password || password.length < 8) {
|
||||
throw new ErrPasswordTooShort();
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS);
|
||||
|
||||
// Update the user's password.
|
||||
await User.update(
|
||||
{ id: user.id, __v: version },
|
||||
{
|
||||
$inc: { __v: 1 },
|
||||
$set: {
|
||||
password: hashedPassword,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return { user, redirect };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -89,7 +89,7 @@ describe('graph.mutations.changeUsername', () => {
|
||||
expect(res.data.changeUsername.errors).to.have.length(1);
|
||||
expect(res.data.changeUsername.errors[0]).to.have.property(
|
||||
'translation_key',
|
||||
'NOT_AUTHORIZED'
|
||||
'EDIT_USERNAME_NOT_AUTHORIZED'
|
||||
);
|
||||
|
||||
// Set the user to the desired status.
|
||||
|
||||
@@ -2,6 +2,8 @@ const UsersService = require('../../../services/users');
|
||||
const SettingsService = require('../../../services/settings');
|
||||
const mailer = require('../../../services/mailer');
|
||||
const Context = require('../../../graph/context');
|
||||
const timekeeper = require('timekeeper');
|
||||
const moment = require('moment');
|
||||
|
||||
const chai = require('chai');
|
||||
chai.use(require('chai-as-promised'));
|
||||
@@ -303,6 +305,62 @@ describe('services.UsersService', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (func === 'setUsername') {
|
||||
it('should let a user set their username from UNSET', async () => {
|
||||
const user = mockUsers[0];
|
||||
|
||||
// Set the user to the desired status.
|
||||
await UsersService.setUsernameStatus(user.id, 'UNSET');
|
||||
await UsersService.setUsername(user.id, 'new_username', null);
|
||||
});
|
||||
|
||||
describe('time based', () => {
|
||||
afterEach(() => {
|
||||
timekeeper.reset();
|
||||
});
|
||||
|
||||
['SET', 'APPROVED'].forEach(status => {
|
||||
it(`should not allow users to change their username if it was changed within 14 of today from ${status}`, async () => {
|
||||
const user = mockUsers[0];
|
||||
|
||||
// Set the user to the desired status.
|
||||
await UsersService.setUsernameStatus(user.id, status);
|
||||
|
||||
timekeeper.travel(
|
||||
moment()
|
||||
.add(5, 'days')
|
||||
.toDate()
|
||||
);
|
||||
|
||||
try {
|
||||
await UsersService.setUsername(user.id, 'new_username', null);
|
||||
throw new Error('edit was processed successfully');
|
||||
} catch (err) {
|
||||
expect(err).have.property(
|
||||
'translation_key',
|
||||
'EDIT_USERNAME_NOT_AUTHORIZED'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it(`allows users to change their username if it was changed 14 days before today from ${status}`, async () => {
|
||||
const user = mockUsers[0];
|
||||
|
||||
// Set the user to the desired status.
|
||||
await UsersService.setUsernameStatus(user.id, status);
|
||||
|
||||
timekeeper.travel(
|
||||
moment()
|
||||
.add(15, 'days')
|
||||
.toDate()
|
||||
);
|
||||
|
||||
await UsersService.setUsername(user.id, 'new_username', null);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('#isValidUsername', () => {
|
||||
|
||||
+1
-1
@@ -11,8 +11,8 @@
|
||||
<link rel="stylesheet" href="https://code.getmdl.io/1.2.1/material.min.css">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||
<link href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" type="text/css" href="<%= resolve('coral-admin/bundle.css') %>">
|
||||
<%- include partials/head %>
|
||||
<link rel="stylesheet" type="text/css" href="<%= resolve('coral-admin/bundle.css') %>">
|
||||
</head>
|
||||
<body class="admin-page">
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, user-scalable=no">
|
||||
<%- include ../partials/head %>
|
||||
<link rel="stylesheet" type="text/css" href="<%= resolve('embed/stream/default.css') %>">
|
||||
<link rel="stylesheet" type="text/css" href="<%= resolve('embed/stream/bundle.css') %>">
|
||||
<%- include ../partials/head %>
|
||||
</head>
|
||||
<body class="embed-stream-page">
|
||||
<div id="talk-embed-stream-container"></div>
|
||||
|
||||
+1
-1
@@ -4,8 +4,8 @@
|
||||
<meta name="viewport" content="width=device-width, user-scalable=no">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||
<link href="https://maxcdn.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" type="text/css" href="<%= resolve('coral-login/bundle.css') %>">
|
||||
<%- include partials/head %>
|
||||
<link rel="stylesheet" type="text/css" href="<%= resolve('coral-login/bundle.css') %>">
|
||||
</head>
|
||||
<body>
|
||||
<div id="talk-login-container"></div>
|
||||
|
||||
Reference in New Issue
Block a user