mirror of
https://github.com/wassname/talk.git
synced 2026-07-06 05:17:19 +08:00
Merge branch 'master' into community-pagination-
This commit is contained in:
+31
-1
@@ -241,13 +241,21 @@ function listUsers() {
|
||||
});
|
||||
|
||||
users.forEach((user) => {
|
||||
let state = user.disabled ? 'Disabled' : 'Enabled';
|
||||
const profile = user.profiles.find(({provider}) => provider === 'local');
|
||||
if (profile && profile.metadata && profile.metadata.confirmed_at) {
|
||||
state += ', Verified';
|
||||
} else {
|
||||
state += ', Unverified';
|
||||
}
|
||||
|
||||
table.push([
|
||||
user.id,
|
||||
user.username,
|
||||
user.profiles.map((p) => p.provider).join(', '),
|
||||
user.roles.join(', '),
|
||||
user.status,
|
||||
user.disabled ? 'Disabled' : 'Enabled'
|
||||
state
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -396,6 +404,23 @@ function enableUser(userID) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies an email address for a user.
|
||||
*
|
||||
* @param userID the user's id
|
||||
* @param email the user's email address to be verified
|
||||
*/
|
||||
async function verify(userID, email) {
|
||||
try {
|
||||
await UsersService.confirmEmail(userID, email);
|
||||
console.log(`User ${userID} had their email ${email} verified.`);
|
||||
util.shutdown();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
util.shutdown(1);
|
||||
}
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
// Setting up the program command line arguments.
|
||||
//==============================================================================
|
||||
@@ -467,6 +492,11 @@ program
|
||||
.description('enable a given user from logging in')
|
||||
.action(enableUser);
|
||||
|
||||
program
|
||||
.command('verify <userID> <email>')
|
||||
.description('verifies the given user\'s email address')
|
||||
.action(verify);
|
||||
|
||||
program.parse(process.argv);
|
||||
|
||||
// If there is no command listed, output help.
|
||||
|
||||
@@ -23,6 +23,10 @@ export const hideSignInDialog = () => (dispatch) => {
|
||||
dispatch({type: actions.HIDE_SIGNIN_DIALOG});
|
||||
};
|
||||
|
||||
export const resetSignInDialog = () => (dispatch) => {
|
||||
dispatch({type: actions.HIDE_SIGNIN_DIALOG});
|
||||
};
|
||||
|
||||
export const focusSignInDialog = () => ({
|
||||
type: actions.FOCUS_SIGNIN_DIALOG,
|
||||
});
|
||||
@@ -94,8 +98,9 @@ export const cleanState = () => ({
|
||||
|
||||
// Sign In Actions
|
||||
|
||||
const signInRequest = () => ({
|
||||
type: actions.FETCH_SIGNIN_REQUEST
|
||||
const signInRequest = (email) => ({
|
||||
type: actions.FETCH_SIGNIN_REQUEST,
|
||||
email,
|
||||
});
|
||||
|
||||
const signInFailure = (error) => ({
|
||||
@@ -122,7 +127,7 @@ export const handleAuthToken = (token) => (dispatch, _, {storage}) => {
|
||||
|
||||
export const fetchSignIn = (formData) => {
|
||||
return (dispatch, _, {rest}) => {
|
||||
dispatch(signInRequest());
|
||||
dispatch(signInRequest(formData.email));
|
||||
|
||||
return rest('/auth/local', {method: 'POST', body: formData})
|
||||
.then(({token}) => {
|
||||
@@ -144,8 +149,7 @@ export const fetchSignIn = (formData) => {
|
||||
// invalid credentials
|
||||
dispatch(signInFailure(t('error.email_password'), error.metadata));
|
||||
} else {
|
||||
const str = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
|
||||
dispatch(signInFailure(str));
|
||||
dispatch(signInFailure(error));
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -349,8 +353,9 @@ const verifyEmailSuccess = () => ({
|
||||
type: actions.VERIFY_EMAIL_SUCCESS
|
||||
});
|
||||
|
||||
const verifyEmailFailure = () => ({
|
||||
type: actions.VERIFY_EMAIL_FAILURE
|
||||
const verifyEmailFailure = (error) => ({
|
||||
type: actions.VERIFY_EMAIL_FAILURE,
|
||||
error,
|
||||
});
|
||||
|
||||
export const requestConfirmEmail = (email) => (dispatch, getState, {rest}) => {
|
||||
@@ -366,8 +371,8 @@ export const requestConfirmEmail = (email) => (dispatch, getState, {rest}) => {
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
const errorMessage = error.translation_key ? t(`error.${error.translation_key}`) : error.toString();
|
||||
dispatch(verifyEmailFailure(errorMessage));
|
||||
dispatch(verifyEmailFailure(error));
|
||||
throw error;
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -53,3 +53,4 @@ export const UPDATE_USERNAME = 'UPDATE_USERNAME';
|
||||
export const SET_REQUIRE_EMAIL_VERIFICATION = 'SET_REQUIRE_EMAIL_VERIFICATION';
|
||||
export const SET_REDIRECT_URI = 'SET_REDIRECT_URI';
|
||||
|
||||
export const RESET_SIGNIN_DIALOG = 'RESET_SIGNIN_DIALOG';
|
||||
|
||||
@@ -10,7 +10,7 @@ const initialState = {
|
||||
showCreateUsernameDialog: false,
|
||||
checkedInitialLogin: false,
|
||||
view: 'SIGNIN',
|
||||
error: '',
|
||||
error: null,
|
||||
passwordRequestSuccess: null,
|
||||
passwordRequestFailure: null,
|
||||
emailVerificationFailure: false,
|
||||
@@ -45,14 +45,15 @@ export default function auth (state = initialState, action) {
|
||||
showSignInDialog: true,
|
||||
signInDialogFocus: true,
|
||||
};
|
||||
case actions.HIDE_SIGNIN_DIALOG :
|
||||
case actions.RESET_SIGNIN_DIALOG:
|
||||
case actions.HIDE_SIGNIN_DIALOG:
|
||||
return {
|
||||
...state,
|
||||
isLoading: false,
|
||||
showSignInDialog: false,
|
||||
signInDialogFocus: false,
|
||||
view: 'SIGNIN',
|
||||
error: '',
|
||||
error: null,
|
||||
passwordRequestFailure: null,
|
||||
passwordRequestSuccess: null,
|
||||
emailVerificationFailure: false,
|
||||
@@ -74,7 +75,7 @@ export default function auth (state = initialState, action) {
|
||||
return {
|
||||
...state,
|
||||
showCreateUsernameDialog: false,
|
||||
error: '',
|
||||
error: null,
|
||||
};
|
||||
case actions.CREATE_USERNAME_FAILURE:
|
||||
return {
|
||||
@@ -92,6 +93,7 @@ export default function auth (state = initialState, action) {
|
||||
case actions.FETCH_SIGNIN_REQUEST:
|
||||
return {
|
||||
...state,
|
||||
email: action.email,
|
||||
isLoading: true,
|
||||
};
|
||||
case actions.CHECK_LOGIN_FAILURE:
|
||||
@@ -120,6 +122,7 @@ export default function auth (state = initialState, action) {
|
||||
isLoading: false,
|
||||
error: action.error,
|
||||
user: null,
|
||||
view: action.error.translation_key === 'EMAIL_NOT_VERIFIED' ? 'RESEND_VERIFICATION' : state.view,
|
||||
};
|
||||
case actions.FETCH_SIGNUP_FACEBOOK_REQUEST:
|
||||
return {
|
||||
@@ -175,7 +178,7 @@ export default function auth (state = initialState, action) {
|
||||
case actions.VALID_FORM:
|
||||
return {
|
||||
...state,
|
||||
error: '',
|
||||
error: null,
|
||||
};
|
||||
case actions.FETCH_FORGOT_PASSWORD_SUCCESS:
|
||||
return {
|
||||
@@ -200,7 +203,7 @@ export default function auth (state = initialState, action) {
|
||||
case actions.VERIFY_EMAIL_FAILURE:
|
||||
return {
|
||||
...state,
|
||||
emailVerificationFailure: true,
|
||||
emailVerificationFailure: action.error,
|
||||
emailVerificationLoading: false,
|
||||
};
|
||||
case actions.VERIFY_EMAIL_REQUEST:
|
||||
|
||||
@@ -97,7 +97,8 @@ class ErrAssetCommentingClosed extends APIError {
|
||||
class ErrAuthentication extends APIError {
|
||||
constructor(message = null) {
|
||||
super('authentication error occured', {
|
||||
status: 401
|
||||
status: 401,
|
||||
translation_key: 'AUTHENTICATION'
|
||||
}, {
|
||||
message
|
||||
});
|
||||
@@ -195,38 +196,52 @@ const ErrAssetURLAlreadyExists = new APIError('Asset URL already exists, cannot
|
||||
status: 409
|
||||
});
|
||||
|
||||
// ErrNotVerified is returned when a user tries to login with valid credentials
|
||||
// but their email address is not yet verified.
|
||||
const ErrNotVerified = new APIError('User does not have a verified email address', {
|
||||
translation_key: 'EMAIL_NOT_VERIFIED',
|
||||
status: 401,
|
||||
});
|
||||
|
||||
const ErrMaxRateLimit = new APIError('Rate limit exeeded', {
|
||||
translation_key: 'RATE_LIMIT_EXCEEDED',
|
||||
status: 429,
|
||||
});
|
||||
|
||||
// ErrCannotIgnoreStaff is returned when a user tries to ignore a staff member.
|
||||
const ErrCannotIgnoreStaff = new APIError('Cannot ignore staff members.', {
|
||||
translation_key: 'CANNOT_IGNORE_STAFF',
|
||||
status: 400
|
||||
status: 400,
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
ExtendableError,
|
||||
APIError,
|
||||
ErrAlreadyExists,
|
||||
ErrPasswordTooShort,
|
||||
ErrSettingsNotInit,
|
||||
ErrAssetCommentingClosed,
|
||||
ErrAssetURLAlreadyExists,
|
||||
ErrAuthentication,
|
||||
ErrCannotIgnoreStaff,
|
||||
ErrCommentTooShort,
|
||||
ErrContainsProfanity,
|
||||
ErrEditWindowHasEnded,
|
||||
ErrEmailTaken,
|
||||
ErrInstallLock,
|
||||
ErrInvalidAssetURL,
|
||||
ErrLoginAttemptMaximumExceeded,
|
||||
ErrMaxRateLimit,
|
||||
ErrMissingEmail,
|
||||
ErrMissingPassword,
|
||||
ErrMissingToken,
|
||||
ErrEmailTaken,
|
||||
ErrSpecialChars,
|
||||
ErrMissingUsername,
|
||||
ErrContainsProfanity,
|
||||
ErrUsernameTaken,
|
||||
ErrAssetCommentingClosed,
|
||||
ErrNotFound,
|
||||
ErrInvalidAssetURL,
|
||||
ErrAuthentication,
|
||||
ErrNotAuthorized,
|
||||
ErrNotFound,
|
||||
ErrNotVerified,
|
||||
ErrPasswordTooShort,
|
||||
ErrPermissionUpdateUsername,
|
||||
ErrSameUsernameProvided,
|
||||
ErrSettingsInit,
|
||||
ErrInstallLock,
|
||||
ErrLoginAttemptMaximumExceeded,
|
||||
ErrEditWindowHasEnded,
|
||||
ErrCommentTooShort,
|
||||
ErrAssetURLAlreadyExists,
|
||||
ErrCannotIgnoreStaff
|
||||
};
|
||||
ErrSettingsNotInit,
|
||||
ErrSpecialChars,
|
||||
ErrUsernameTaken,
|
||||
ExtendableError,
|
||||
};
|
||||
@@ -194,8 +194,10 @@ en:
|
||||
NO_SPECIAL_CHARACTERS: "Usernames can contain letters numbers and _ only"
|
||||
PASSWORD_LENGTH: "Password is too short"
|
||||
PROFANITY_ERROR: "Usernames must not contain profanity. Please contact the administrator if you believe this to be in error."
|
||||
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"
|
||||
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."
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
.header {
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.notVerified {
|
||||
padding: 10px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 2px;
|
||||
border: solid 1px #dddd00;
|
||||
background: #FFFF9C;
|
||||
color: #777700;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
import {Button, Spinner, Success, Alert} from 'plugin-api/beta/client/components/ui';
|
||||
import PropTypes from 'prop-types';
|
||||
import styles from './ResendVerification.css';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
class ResendVerification extends React.Component {
|
||||
render() {
|
||||
const {resendVerification, error, loading, success, email} = this.props;
|
||||
return (
|
||||
<div className="talk-resend-verification">
|
||||
<h1 className={styles.header}>
|
||||
{t('sign_in.email_verify_cta')}
|
||||
</h1>
|
||||
|
||||
{error &&
|
||||
<Alert>
|
||||
{error.translation_key ? t(`error.${error.translation_key}`) : error.toString()}
|
||||
</Alert>}
|
||||
<div className={styles.notVerified}>
|
||||
{t('error.email_not_verified', email)}
|
||||
</div>
|
||||
<div>
|
||||
<Button id="resendConfirmEmail" cStyle="black" onClick={resendVerification} full>
|
||||
{t('sign_in.request_new_verify_email')}
|
||||
</Button>
|
||||
{loading && <Spinner />}
|
||||
{success && <Success />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ResendVerification.propTypes = {
|
||||
resendVerification: PropTypes.bool.isRequired,
|
||||
error: PropTypes.object,
|
||||
loading: PropTypes.bool,
|
||||
success: PropTypes.bool,
|
||||
email: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default ResendVerification;
|
||||
@@ -5,13 +5,22 @@ import styles from './styles.css';
|
||||
import SignInContent from './SignInContent';
|
||||
import SignUpContent from './SignUpContent';
|
||||
import ForgotContent from './ForgotContent';
|
||||
import ResendVerification from './ResendVerification';
|
||||
|
||||
const SignDialog = ({open, view, hideSignInDialog, ...props}) => (
|
||||
const SignDialog = ({open, view, resetSignInDialog, ...props}) => (
|
||||
<Dialog className={styles.dialog} id="signInDialog" open={open}>
|
||||
<span className={styles.close} onClick={hideSignInDialog}>×</span>
|
||||
{view !== 'SIGNIN' && <span className={styles.close} onClick={resetSignInDialog}>×</span>}
|
||||
{view === 'SIGNIN' && <SignInContent {...props} />}
|
||||
{view === 'SIGNUP' && <SignUpContent {...props} />}
|
||||
{view === 'FORGOT' && <ForgotContent {...props} />}
|
||||
{view === 'RESEND_VERIFICATION' &&
|
||||
<ResendVerification
|
||||
resendVerification={props.resendVerification}
|
||||
error={props.auth.emailVerificationFailure}
|
||||
success={props.auth.emailVerificationSuccess}
|
||||
loading={props.auth.emailVerificationLoading}
|
||||
email={props.auth.email}
|
||||
/>}
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
fetchSignUpFacebook,
|
||||
fetchForgotPassword,
|
||||
requestConfirmEmail,
|
||||
resetSignInDialog,
|
||||
facebookCallback,
|
||||
invalidForm,
|
||||
validForm,
|
||||
@@ -31,7 +32,6 @@ class SignInContainer extends React.Component {
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
},
|
||||
emailToBeResent: '',
|
||||
errors: {},
|
||||
showErrors: false
|
||||
};
|
||||
@@ -80,26 +80,15 @@ class SignInContainer extends React.Component {
|
||||
);
|
||||
};
|
||||
|
||||
handleChangeEmail = (e) => {
|
||||
const {value} = e.target;
|
||||
this.setState({emailToBeResent: value});
|
||||
};
|
||||
|
||||
handleResendVerification = (e) => {
|
||||
e.preventDefault();
|
||||
resendVerification = () => {
|
||||
this.props
|
||||
.requestConfirmEmail(
|
||||
this.state.emailToBeResent,
|
||||
)
|
||||
.requestConfirmEmail(this.props.auth.email)
|
||||
.then(() => {
|
||||
setTimeout(() => {
|
||||
|
||||
// allow success UI to be shown for a second, and then close the modal
|
||||
this.props.hideSignInDialog();
|
||||
this.props.resetSignInDialog();
|
||||
}, 2500);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -194,6 +183,7 @@ const mapDispatchToProps = (dispatch) =>
|
||||
requestConfirmEmail,
|
||||
changeView,
|
||||
hideSignInDialog,
|
||||
resetSignInDialog,
|
||||
invalidForm,
|
||||
validForm
|
||||
},
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {Button, TextField, Spinner, Success, Alert} from 'plugin-api/beta/client/components/ui';
|
||||
import {Button, TextField, Spinner, Alert} from 'plugin-api/beta/client/components/ui';
|
||||
import styles from './styles.css';
|
||||
import t from 'coral-framework/services/i18n';
|
||||
|
||||
const SignInContent = ({
|
||||
handleChange,
|
||||
handleChangeEmail,
|
||||
emailToBeResent,
|
||||
handleResendVerification,
|
||||
emailVerificationLoading,
|
||||
emailVerificationSuccess,
|
||||
formData,
|
||||
changeView,
|
||||
handleSignIn,
|
||||
@@ -21,71 +16,56 @@ const SignInContent = ({
|
||||
<div className="coral-sign-in">
|
||||
<div className={`${styles.header} header`}>
|
||||
<h1>
|
||||
{auth.emailVerificationFailure
|
||||
? t('sign_in.email_verify_cta')
|
||||
: t('sign_in.sign_in_to_join')}
|
||||
{t('sign_in.sign_in_to_join')}
|
||||
</h1>
|
||||
</div>
|
||||
{auth.error && <Alert>{auth.error}</Alert>}
|
||||
{auth.emailVerificationFailure
|
||||
? <form onSubmit={handleResendVerification}>
|
||||
<p>{t('sign_in.request_new_verify_email')}</p>
|
||||
{auth.error &&
|
||||
<Alert>
|
||||
{auth.error.translation_key ? t(`error.${auth.error.translation_key}`) : auth.error.toString()}
|
||||
</Alert>}
|
||||
<div>
|
||||
<div className={`${styles.socialConnections} social-connections`}>
|
||||
<Button cStyle="facebook" onClick={fetchSignInFacebook} full>
|
||||
{t('sign_in.facebook_sign_in')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.separator}>
|
||||
<h1>
|
||||
{t('sign_in.or')}
|
||||
</h1>
|
||||
</div>
|
||||
<form onSubmit={handleSignIn}>
|
||||
<TextField
|
||||
id="confirm-email"
|
||||
id="email"
|
||||
type="email"
|
||||
label={t('sign_in.email')}
|
||||
value={emailToBeResent}
|
||||
onChange={handleChangeEmail}
|
||||
value={formData.email}
|
||||
style={{fontSize: 16}}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<Button id="resendConfirmEmail" type="submit" cStyle="black" full>
|
||||
Send Email
|
||||
</Button>
|
||||
{emailVerificationLoading && <Spinner />}
|
||||
{emailVerificationSuccess && <Success />}
|
||||
<TextField
|
||||
id="password"
|
||||
type="password"
|
||||
label={t('sign_in.password')}
|
||||
value={formData.password}
|
||||
style={{fontSize: 16}}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<div className={styles.action}>
|
||||
{!auth.isLoading
|
||||
? <Button
|
||||
id="coralLogInButton"
|
||||
type="submit"
|
||||
cStyle="black"
|
||||
className={styles.signInButton}
|
||||
full
|
||||
>
|
||||
{t('sign_in.sign_in')}
|
||||
</Button>
|
||||
: <Spinner />}
|
||||
</div>
|
||||
</form>
|
||||
: <div>
|
||||
<div className={`${styles.socialConnections} social-connections`}>
|
||||
<Button cStyle="facebook" onClick={fetchSignInFacebook} full>
|
||||
{t('sign_in.facebook_sign_in')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.separator}>
|
||||
<h1>
|
||||
{t('sign_in.or')}
|
||||
</h1>
|
||||
</div>
|
||||
<form onSubmit={handleSignIn}>
|
||||
<TextField
|
||||
id="email"
|
||||
type="email"
|
||||
label={t('sign_in.email')}
|
||||
value={formData.email}
|
||||
style={{fontSize: 16}}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<TextField
|
||||
id="password"
|
||||
type="password"
|
||||
label={t('sign_in.password')}
|
||||
value={formData.password}
|
||||
style={{fontSize: 16}}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<div className={styles.action}>
|
||||
{!auth.isLoading
|
||||
? <Button
|
||||
id="coralLogInButton"
|
||||
type="submit"
|
||||
cStyle="black"
|
||||
className={styles.signInButton}
|
||||
full
|
||||
>
|
||||
{t('sign_in.sign_in')}
|
||||
</Button>
|
||||
: <Spinner />}
|
||||
</div>
|
||||
</form>
|
||||
</div>}
|
||||
</div>
|
||||
<div className={`${styles.footer} footer`}>
|
||||
<span>
|
||||
<a onClick={() => changeView('FORGOT')}>
|
||||
@@ -111,12 +91,12 @@ SignInContent.propTypes = {
|
||||
}).isRequired,
|
||||
fetchSignInFacebook: PropTypes.func.isRequired,
|
||||
handleSignIn: PropTypes.func.isRequired,
|
||||
handleChange: PropTypes.func.isRequired,
|
||||
changeView: PropTypes.func.isRequired,
|
||||
emailVerificationLoading: PropTypes.bool.isRequired,
|
||||
emailVerificationSuccess: PropTypes.bool.isRequired,
|
||||
handleResendVerification: PropTypes.func.isRequired,
|
||||
handleChangeEmail: PropTypes.func.isRequired,
|
||||
emailToBeResent: PropTypes.string.isRequired
|
||||
resendVerification: PropTypes.func.isRequired,
|
||||
formData: PropTypes.object,
|
||||
};
|
||||
|
||||
export default SignInContent;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
en:
|
||||
sign_in:
|
||||
email_verify_cta: "Please verify your email address."
|
||||
request_new_verify_email: "Request another email:"
|
||||
request_new_verify_email: "Request another email"
|
||||
verify_email: "Thank you for creating an account! We sent an email to the address you provided to verify your account."
|
||||
verify_email2: "You must verify your account before engaging with the community."
|
||||
not_you: "Not you?"
|
||||
@@ -45,7 +45,7 @@ en:
|
||||
es:
|
||||
sign_in:
|
||||
email_verify_cta: "Por favor confirme su correo."
|
||||
request_new_verify_email: "Enviar otro correo:"
|
||||
request_new_verify_email: "Enviar otro correo"
|
||||
verify_email: "¡Gracias por crear una cuenta! Le enviamos un correo a la direcció\
|
||||
n que dio para confirmar su cuenta."
|
||||
verify_email2: "Debe confirmarla antes de poder involucrarse en la comunidad."
|
||||
@@ -92,7 +92,7 @@ es:
|
||||
fr:
|
||||
sign_in:
|
||||
email_verify_cta: "Merci de vérifier votre adresse e-mail."
|
||||
request_new_verify_email: "Demander un nouvel envoi d'e-mail :"
|
||||
request_new_verify_email: "Demander un nouvel envoi d'e-mail"
|
||||
verify_email: "Merci d'avoir créé un compte ! Nous avons envoyé un courrier électronique à l'adresse que vous avez fournie pour vérifier votre adresse e-mail."
|
||||
verify_email2: "Vous devez vérifier votre adresse e-mail avant de vous engager auprès de la communauté."
|
||||
not_you: "Pas vous ?"
|
||||
|
||||
+34
-14
@@ -5,6 +5,7 @@ const mailer = require('../../../services/mailer');
|
||||
const errors = require('../../../errors');
|
||||
const authorization = require('../../../middleware/authorization');
|
||||
const i18n = require('../../../services/i18n');
|
||||
const Limit = require('../../../services/limit');
|
||||
const {
|
||||
ROOT_URL
|
||||
} = require('../../../config');
|
||||
@@ -115,16 +116,15 @@ router.post('/:user_id/email', authorization.needed('ADMIN', 'MODERATOR'), async
|
||||
|
||||
/**
|
||||
* SendEmailConfirmation sends a confirmation email to the user.
|
||||
* @param {ExpressApp} app the instance of the express app
|
||||
* @param {String} userID the id for the user to send the email to
|
||||
* @param {String} email the email for the user to send the email to
|
||||
*/
|
||||
const SendEmailConfirmation = async (app, userID, email, referer) => {
|
||||
let token = await UsersService.createEmailConfirmToken(userID, email, referer);
|
||||
const SendEmailConfirmation = async (user, email, referer) => {
|
||||
let token = await UsersService.createEmailConfirmToken(user, email, referer);
|
||||
|
||||
return mailer.sendSimple({
|
||||
template: 'email-confirm', // needed to know which template to render!
|
||||
locals: { // specifies the template locals.
|
||||
template: 'email-confirm',
|
||||
locals: {
|
||||
token,
|
||||
rootURL: ROOT_URL,
|
||||
email
|
||||
@@ -144,7 +144,7 @@ router.post('/', async (req, res, next) => {
|
||||
|
||||
// Send an email confirmation. The Front end will know about the
|
||||
// requireEmailConfirmation as it's included in the settings get endpoint.
|
||||
await SendEmailConfirmation(req.app, user.id, email, redirectUri);
|
||||
await SendEmailConfirmation(user, email, redirectUri);
|
||||
|
||||
res.status(201).json(user);
|
||||
} catch (e) {
|
||||
@@ -172,25 +172,45 @@ router.post('/:user_id/actions', authorization.needed(), async (req, res, next)
|
||||
}
|
||||
});
|
||||
|
||||
// This will allow 1 try every minute.
|
||||
const resendRateLimiter = new Limit('/api/v1/users/resend-verify', 1, '1m');
|
||||
|
||||
// trigger an email confirmation re-send by a new user
|
||||
router.post('/resend-verify', async (req, res, next) => {
|
||||
const {email} = req.body;
|
||||
const redirectUri = req.header('X-Pym-Url') || req.header('Referer');
|
||||
let {email = ''} = req.body;
|
||||
|
||||
if (!email) {
|
||||
// Clean up and validate the email.
|
||||
email = email.toLowerCase().trim();
|
||||
if (email.length < 5) {
|
||||
return next(errors.ErrMissingEmail);
|
||||
}
|
||||
|
||||
// Check if we're past the rate limit, if we are, stop now. Otherwise, record
|
||||
// this as an attempt to send a verification email.
|
||||
try {
|
||||
const tries = await resendRateLimiter.get(email);
|
||||
if (tries > 0) {
|
||||
throw errors.ErrMaxRateLimit;
|
||||
}
|
||||
|
||||
// find user by email.
|
||||
// if the local profile is verified, return an error code?
|
||||
// send a 204 after the email is re-sent
|
||||
await SendEmailConfirmation(req.app, null, email, redirectUri);
|
||||
await resendRateLimiter.test(email);
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await UsersService.findLocalUser(email);
|
||||
if (!user) {
|
||||
throw errors.ErrNotFound;
|
||||
}
|
||||
|
||||
await SendEmailConfirmation(user, email, redirectUri);
|
||||
|
||||
res.status(204).end();
|
||||
} catch (e) {
|
||||
return next(e);
|
||||
console.trace(e);
|
||||
res.status(204).end();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -214,7 +234,7 @@ router.post('/:user_id/email/confirm', authorization.needed('ADMIN', 'MODERATOR'
|
||||
}
|
||||
|
||||
// Send the email to the first local profile that was found.
|
||||
await SendEmailConfirmation(req.app, user.id, localProfile.id);
|
||||
await SendEmailConfirmation(user, localProfile.id);
|
||||
|
||||
res.status(204).end();
|
||||
} catch (e) {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
const ms = require('ms');
|
||||
const errors = require('../errors');
|
||||
const {createClientFactory} = require('./redis');
|
||||
const client = createClientFactory();
|
||||
|
||||
/**
|
||||
* Limit is designed to support rate limiting a resource.
|
||||
*/
|
||||
class Limit {
|
||||
constructor(prefix, max, duration) {
|
||||
this.ttl = ms(duration) / 1000;
|
||||
this.prefix = prefix;
|
||||
this.max = max;
|
||||
}
|
||||
|
||||
/**
|
||||
* key will compose the redis key used to store the rate limit information.
|
||||
*
|
||||
* @param {String} value the string to use that is being limited
|
||||
* @returns {String} the redis key to set
|
||||
*/
|
||||
key(value) {
|
||||
return `limit[${this.prefix}][${value}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* get will fetch the current number of attempts within the given window
|
||||
* duration.
|
||||
*
|
||||
* @param {String} value the value to limit with
|
||||
* @returns {Integer} the number of tries within the current window
|
||||
*/
|
||||
async get(value) {
|
||||
const key = this.key(value);
|
||||
|
||||
return client().get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* test will increment the number of tries, reset the window length and
|
||||
* will throw an error if the number of tries exceed the maximum for the
|
||||
* window duration.
|
||||
*
|
||||
* @param {String} value the value to limit with
|
||||
* @returns {Promise} resolves to the number of tries, or throws an error
|
||||
*/
|
||||
async test(value) {
|
||||
const key = this.key(value);
|
||||
|
||||
const [[, tries], [, expiry]] = await client()
|
||||
.multi()
|
||||
.incr(key)
|
||||
.expire(key, this.ttl)
|
||||
.exec();
|
||||
|
||||
// if this is new or has no expiry
|
||||
if (tries === 1 || expiry === -1) {
|
||||
|
||||
// then expire it after the timeout
|
||||
client().expire(key, this.ttl);
|
||||
}
|
||||
|
||||
if (tries > this.max) {
|
||||
throw errors.ErrMaxRateLimit;
|
||||
}
|
||||
|
||||
return tries;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Limit;
|
||||
@@ -145,7 +145,7 @@ async function ValidateUserLogin(loginProfile, user, done) {
|
||||
// If the profile doesn't have a metadata field, or it does not have a
|
||||
// confirmed_at field, or that field is null, then send them back.
|
||||
if (!profile.metadata || !profile.metadata.confirmed_at || profile.metadata.confirmed_at === null) {
|
||||
return done(new errors.ErrAuthentication(loginProfile.id));
|
||||
return done(errors.ErrNotVerified);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+12
-38
@@ -18,7 +18,7 @@ const UserModel = require('../models/user');
|
||||
const USER_STATUS = require('../models/enum/user_status');
|
||||
const USER_ROLES = require('../models/enum/user_roles');
|
||||
|
||||
const RECAPTCHA_WINDOW_SECONDS = 60 * 10; // 10 minutes.
|
||||
const RECAPTCHA_WINDOW = '10m'; // 10 minutes.
|
||||
const RECAPTCHA_INCORRECT_TRIGGER = 5; // after 3 incorrect attempts, recaptcha will be required.
|
||||
|
||||
const ActionsService = require('./actions');
|
||||
@@ -35,9 +35,8 @@ const PASSWORD_RESET_JWT_SUBJECT = 'password_reset';
|
||||
const SALT_ROUNDS = 10;
|
||||
|
||||
// Create a redis client to use for authentication.
|
||||
const {createClientFactory} = require('./redis');
|
||||
|
||||
const client = createClientFactory();
|
||||
const Limit = require('./limit');
|
||||
const loginRateLimiter = new Limit('loginAttempts', RECAPTCHA_INCORRECT_TRIGGER, RECAPTCHA_WINDOW);
|
||||
|
||||
// UsersService is the interface for the application to interact with the
|
||||
// UserModel through.
|
||||
@@ -71,23 +70,14 @@ module.exports = class UsersService {
|
||||
* where future login attempts must be made with the recaptcha flag.
|
||||
*/
|
||||
static async recordLoginAttempt(email) {
|
||||
const rdskey = `la[${email.toLowerCase().trim()}]`;
|
||||
try {
|
||||
await loginRateLimiter.test(email.toLowerCase().trim());
|
||||
} catch (err) {
|
||||
if (err === errors.ErrMaxRateLimit) {
|
||||
throw errors.ErrLoginAttemptMaximumExceeded;
|
||||
}
|
||||
|
||||
const replies = await client()
|
||||
.multi()
|
||||
.incr(rdskey)
|
||||
.expire(rdskey, RECAPTCHA_WINDOW_SECONDS)
|
||||
.exec();
|
||||
|
||||
// if this is new or has no expiry
|
||||
if (replies[0] === 1 || replies[1] === -1) {
|
||||
|
||||
// then expire it after the timeout
|
||||
client().expire(rdskey, RECAPTCHA_WINDOW_SECONDS);
|
||||
}
|
||||
|
||||
if (replies[0] >= RECAPTCHA_INCORRECT_TRIGGER) {
|
||||
throw errors.ErrLoginAttemptMaximumExceeded;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,9 +88,7 @@ module.exports = class UsersService {
|
||||
* errors.ErrLoginAttemptMaximumExceeded
|
||||
*/
|
||||
static async checkLoginAttempts(email) {
|
||||
const rdskey = `la[${email.toLowerCase().trim()}]`;
|
||||
|
||||
const attempts = await client().get(rdskey);
|
||||
const attempts = await loginRateLimiter.get(email.toLowerCase().trim());
|
||||
if (!attempts) {
|
||||
return;
|
||||
}
|
||||
@@ -718,7 +706,7 @@ module.exports = class UsersService {
|
||||
* @param {String} email The email that we are needing to get confirmed.
|
||||
* @return {Promise}
|
||||
*/
|
||||
static async createEmailConfirmToken(userID = null, email, referer = ROOT_URL) {
|
||||
static async createEmailConfirmToken(user, email, referer = ROOT_URL) {
|
||||
if (!email || typeof email !== 'string') {
|
||||
throw new Error('email is required when creating a JWT for resetting passord');
|
||||
}
|
||||
@@ -732,20 +720,6 @@ module.exports = class UsersService {
|
||||
subject: EMAIL_CONFIRM_JWT_SUBJECT
|
||||
};
|
||||
|
||||
let user;
|
||||
if (!userID) {
|
||||
|
||||
// If there is no userID, we're coming from the endpoint where a new user
|
||||
// is re-requesting a confirmation email and we don't know the userID.
|
||||
user = await UserModel.findOne({profiles: {$elemMatch: {id: email, provider: 'local'}}});
|
||||
} else {
|
||||
user = await UsersService.findById(userID);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw errors.ErrNotFound;
|
||||
}
|
||||
|
||||
// Get the profile representing the local account.
|
||||
let profile = user.profiles.find((profile) => profile.id === email && profile.provider === 'local');
|
||||
|
||||
|
||||
@@ -74,10 +74,8 @@ describe('/api/v1/auth/local', () => {
|
||||
.catch((err) => {
|
||||
expect(err).to.have.status(401);
|
||||
err.response.body.should.have.property('error');
|
||||
err.response.body.error.should.have.property('metadata');
|
||||
err.response.body.error.metadata.should.have.property('message', 'maria@gmail.com');
|
||||
|
||||
return UsersService.createEmailConfirmToken(mockUser.id, mockUser.profiles[0].id);
|
||||
return UsersService.createEmailConfirmToken(mockUser, mockUser.profiles[0].id);
|
||||
})
|
||||
.then(UsersService.verifyEmailConfirmation)
|
||||
.then(() => {
|
||||
|
||||
@@ -88,17 +88,19 @@ describe('services.UsersService', () => {
|
||||
describe('#createEmailConfirmToken', () => {
|
||||
|
||||
it('should create a token for a valid user', async () => {
|
||||
const token = await UsersService.createEmailConfirmToken(mockUsers[0].id, mockUsers[0].profiles[0].id);
|
||||
const token = await UsersService.createEmailConfirmToken(mockUsers[0], mockUsers[0].profiles[0].id);
|
||||
expect(token).to.not.be.null;
|
||||
});
|
||||
|
||||
it('should not create a token for a user already verified', async () => {
|
||||
const token = await UsersService.createEmailConfirmToken(mockUsers[0].id, mockUsers[0].profiles[0].id);
|
||||
const token = await UsersService.createEmailConfirmToken(mockUsers[0], mockUsers[0].profiles[0].id);
|
||||
expect(token).to.not.be.null;
|
||||
|
||||
await UsersService.verifyEmailConfirmation(token);
|
||||
|
||||
return expect(UsersService.createEmailConfirmToken(mockUsers[0].id, mockUsers[0].profiles[0].id)).to.eventually.be.rejected;
|
||||
const user = await UsersService.findById(mockUsers[0].id);
|
||||
|
||||
return expect(UsersService.createEmailConfirmToken(user, mockUsers[0].profiles[0].id)).to.eventually.be.rejected;
|
||||
});
|
||||
|
||||
});
|
||||
@@ -106,7 +108,7 @@ describe('services.UsersService', () => {
|
||||
describe('#verifyEmailConfirmation', () => {
|
||||
|
||||
it('should correctly validate a valid token', async () => {
|
||||
const token = await UsersService.createEmailConfirmToken(mockUsers[0].id, mockUsers[0].profiles[0].id);
|
||||
const token = await UsersService.createEmailConfirmToken(mockUsers[0], mockUsers[0].profiles[0].id);
|
||||
expect(token).to.not.be.null;
|
||||
|
||||
return expect(UsersService.verifyEmailConfirmation(token)).to.eventually.not.be.rejected;
|
||||
@@ -122,7 +124,7 @@ describe('services.UsersService', () => {
|
||||
|
||||
it('should update the user model when verification is complete', () => {
|
||||
return UsersService
|
||||
.createEmailConfirmToken(mockUsers[0].id, mockUsers[0].profiles[0].id)
|
||||
.createEmailConfirmToken(mockUsers[0], mockUsers[0].profiles[0].id)
|
||||
.then((token) => {
|
||||
expect(token).to.not.be.null;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user