diff --git a/.nodemon.json b/.nodemon.json index 44b17e9ee..101104f4a 100644 --- a/.nodemon.json +++ b/.nodemon.json @@ -1,6 +1,5 @@ { "exec": "npm-run-all --parallel generate-introspection start:development", - "verbose": true, "ignore": ["test/*", "client/*", "dist/*", "plugins/*/client"], "ext": "js,json,graphql,yml", "watch": [ diff --git a/Dockerfile.onbuild b/Dockerfile.onbuild index dab06f336..5739d6f95 100644 --- a/Dockerfile.onbuild +++ b/Dockerfile.onbuild @@ -1,6 +1,9 @@ FROM coralproject/talk:latest # Setup the build arguments +ONBUILD ARG TALK_ADDTL_COMMENTS_ON_LOAD_MORE=10 +ONBUILD ARG TALK_ASSET_COMMENTS_LOAD_DEPTH=10 +ONBUILD ARG TALK_REPLY_COMMENTS_LOAD_DEPTH=3 ONBUILD ARG TALK_THREADING_LEVEL=3 ONBUILD ARG TALK_DEFAULT_STREAM_TAB=all ONBUILD ARG TALK_DEFAULT_LANG=en diff --git a/app.js b/app.js index 85e7a01fb..c2bf9648d 100644 --- a/app.js +++ b/app.js @@ -4,7 +4,6 @@ const path = require('path'); const merge = require('lodash/merge'); const helmet = require('helmet'); const plugins = require('./services/plugins'); -const compression = require('compression'); const { HELMET_CONFIGURATION } = require('./config'); const { MOUNT_PATH } = require('./url'); const routes = require('./routes'); @@ -64,9 +63,6 @@ app.use( ) ); -// Compress the responses if appropriate. -app.use(compression()); - //============================================================================== // VIEW CONFIGURATION //============================================================================== diff --git a/client/coral-admin/src/actions/auth.js b/client/coral-admin/src/actions/auth.js deleted file mode 100644 index 193c6ac44..000000000 --- a/client/coral-admin/src/actions/auth.js +++ /dev/null @@ -1,181 +0,0 @@ -import bowser from 'bowser'; -import * as actions from '../constants/auth'; -import t from 'coral-framework/services/i18n'; -import jwtDecode from 'jwt-decode'; - -//============================================================================== -// SIGN IN -//============================================================================== - -export const handleLogin = (email, password, recaptchaResponse) => ( - dispatch, - _, - { rest, client, localStorage } -) => { - dispatch({ type: actions.LOGIN_REQUEST }); - - const params = { - method: 'POST', - body: { - email, - password, - }, - }; - - if (recaptchaResponse) { - params.headers = { - 'X-Recaptcha-Response': recaptchaResponse, - }; - } - - return rest('/auth/local', params) - .then(({ user, token }) => { - if (!user) { - if (!bowser.safari && !bowser.ios && localStorage) { - localStorage.removeItem('token'); - localStorage.removeItem('exp'); - } - return dispatch(checkLoginFailure('not logged in')); - } - - dispatch(handleAuthToken(token)); - client.resetWebsocket(); - dispatch(checkLoginSuccess(user)); - }) - .catch(error => { - console.error(error); - const errorMessage = error.translation_key - ? t(`error.${error.translation_key}`) - : error.toString(); - - if (error.translation_key === 'NOT_AUTHORIZED') { - // invalid credentials - dispatch({ - type: actions.LOGIN_FAILURE, - message: t('error.email_password'), - }); - } else if (error.translation_key === 'LOGIN_MAXIMUM_EXCEEDED') { - dispatch({ - type: actions.LOGIN_MAXIMUM_EXCEEDED, - message: t(`error.${error.translation_key}`), - }); - } else { - dispatch({ - type: actions.LOGIN_FAILURE, - message: errorMessage, - }); - } - }); -}; - -//============================================================================== -// FORGOT PASSWORD -//============================================================================== - -const forgotPasswordRequest = () => ({ - type: actions.FETCH_FORGOT_PASSWORD_REQUEST, -}); - -const forgotPasswordSuccess = () => ({ - type: actions.FETCH_FORGOT_PASSWORD_SUCCESS, -}); - -const forgotPasswordFailure = error => ({ - type: actions.FETCH_FORGOT_PASSWORD_FAILURE, - error, -}); - -export const requestPasswordReset = email => (dispatch, _, { rest }) => { - dispatch(forgotPasswordRequest(email)); - const redirectUri = location.href; - - return rest('/account/password/reset', { - method: 'POST', - body: { email, loc: redirectUri }, - }) - .then(() => dispatch(forgotPasswordSuccess())) - .catch(error => { - console.error(error); - const errorMessage = error.translation_key - ? t(`error.${error.translation_key}`) - : error.toString(); - dispatch(forgotPasswordFailure(errorMessage)); - }); -}; - -//============================================================================== -// CHECK LOGIN -//============================================================================== - -const checkLoginRequest = () => ({ - type: actions.CHECK_LOGIN_REQUEST, -}); - -const checkLoginSuccess = (user, isAdmin) => ({ - type: actions.CHECK_LOGIN_SUCCESS, - user, - isAdmin, -}); - -const checkLoginFailure = error => ({ - type: actions.CHECK_LOGIN_FAILURE, - error, -}); - -export const checkLogin = () => ( - dispatch, - _, - { rest, client, localStorage } -) => { - dispatch(checkLoginRequest()); - return rest('/auth') - .then(({ user }) => { - if (!user) { - if (!bowser.safari && !bowser.ios && localStorage) { - localStorage.removeItem('token'); - localStorage.removeItem('exp'); - } - return dispatch(checkLoginFailure('not logged in')); - } - - client.resetWebsocket(); - dispatch(checkLoginSuccess(user)); - }) - .catch(error => { - console.error(error); - const errorMessage = error.translation_key - ? t(`error.${error.translation_key}`) - : error.toString(); - dispatch(checkLoginFailure(errorMessage)); - }); -}; - -//============================================================================== -// LOGOUT -//============================================================================== - -export const logout = () => (dispatch, _, { rest, client, localStorage }) => { - return rest('/auth', { method: 'DELETE' }).then(() => { - if (localStorage) { - localStorage.removeItem('token'); - localStorage.removeItem('exp'); - } - - // Reset the websocket. - client.resetWebsocket(); - - dispatch({ type: actions.LOGOUT }); - }); -}; - -//============================================================================== -// AUTH TOKEN -//============================================================================== - -export const handleAuthToken = token => (dispatch, _, { localStorage }) => { - if (localStorage) { - localStorage.setItem('exp', jwtDecode(token).exp); - localStorage.setItem('token', token); - } - dispatch({ type: 'HANDLE_AUTH_TOKEN' }); -}; diff --git a/client/coral-admin/src/actions/config.js b/client/coral-admin/src/actions/config.js deleted file mode 100644 index 5e2995e85..000000000 --- a/client/coral-admin/src/actions/config.js +++ /dev/null @@ -1,7 +0,0 @@ -export const CONFIG_UPDATED = 'CONFIG_UPDATED'; - -export const fetchConfig = () => dispatch => { - let json = document.getElementById('data'); - let data = JSON.parse(json.textContent); - dispatch({ type: CONFIG_UPDATED, data }); -}; diff --git a/client/coral-admin/src/components/Drawer.js b/client/coral-admin/src/components/Drawer.js index 0d59d3bbe..25f21a455 100644 --- a/client/coral-admin/src/components/Drawer.js +++ b/client/coral-admin/src/components/Drawer.js @@ -7,12 +7,12 @@ import t from 'coral-framework/services/i18n'; import { can } from 'coral-framework/services/perms'; import cn from 'classnames'; -const CoralDrawer = ({ handleLogout, auth = {} }) => ( +const CoralDrawer = ({ handleLogout, currentUser }) => ( - {auth && auth.user && can(auth.user, 'ACCESS_ADMIN') ? ( + {currentUser && can(currentUser, 'ACCESS_ADMIN') ? (
- {can(auth.user, 'MODERATE_COMMENTS') && ( + {can(currentUser, 'MODERATE_COMMENTS') && ( ( > {t('configure.community')} - {can(auth.user, 'UPDATE_CONFIG') && ( + {can(currentUser, 'UPDATE_CONFIG') && ( ( CoralDrawer.propTypes = { handleLogout: PropTypes.func.isRequired, restricted: PropTypes.bool, // hide app elements from a logged out user - auth: PropTypes.object, + currentUser: PropTypes.object, }; export default CoralDrawer; diff --git a/client/coral-admin/src/components/ForgotPassword.css b/client/coral-admin/src/components/ForgotPassword.css new file mode 100644 index 000000000..beae79eea --- /dev/null +++ b/client/coral-admin/src/components/ForgotPassword.css @@ -0,0 +1,20 @@ + +.header, .cta, .success { + text-align: center; + font-size: 16px; +} + +.success { + cursor: pointer; + padding: 8px 14px; +} + +.signInLink { + color: blue; + font-weight: normal; + text-decoration: none; +} + +.signInLink:hover { + text-decoration: underline; +} diff --git a/client/coral-admin/src/components/ForgotPassword.js b/client/coral-admin/src/components/ForgotPassword.js new file mode 100644 index 000000000..fe08436ae --- /dev/null +++ b/client/coral-admin/src/components/ForgotPassword.js @@ -0,0 +1,82 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import styles from './ForgotPassword.css'; +import { Button, TextField, Alert, Success } from 'coral-ui'; +import t from 'coral-framework/services/i18n'; + +class ForgotPassword extends React.Component { + constructor(props) { + super(props); + } + + handleEmailChange = e => this.props.onEmailChange(e.target.value); + + handleSubmit = e => { + e.preventDefault(); + this.props.onSubmit(); + }; + + handleSignInLink = e => { + e.preventDefault(); + this.props.onSignInLink(); + }; + + renderSuccess() { + return ( +
+ {t('password_reset.mail_sent')}{' '} + + Sign in + + +
+ ); + } + + renderForm() { + const { email, errorMessage } = this.props; + return ( +
+ {errorMessage && {errorMessage}} + + +

+ Go back to{' '} + + Sign In + + . +

+ + ); + } + + render() { + return this.props.success ? this.renderSuccess() : this.renderForm(); + } +} + +ForgotPassword.propTypes = { + success: PropTypes.bool.isRequired, + email: PropTypes.string.isRequired, + onEmailChange: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, + errorMessage: PropTypes.string, + onSignInLink: PropTypes.func.isRequired, +}; + +export default ForgotPassword; diff --git a/client/coral-admin/src/components/Header.js b/client/coral-admin/src/components/Header.js index 6b2274d27..fc150b840 100644 --- a/client/coral-admin/src/components/Header.js +++ b/client/coral-admin/src/components/Header.js @@ -13,7 +13,7 @@ import CommunityIndicator from '../routes/Community/containers/Indicator'; const CoralHeader = ({ handleLogout, showShortcuts = () => {}, - auth, + currentUser, root, data, }) => { @@ -22,9 +22,9 @@ const CoralHeader = ({
- {auth && auth.user && can(auth.user, 'ACCESS_ADMIN') ? ( + {currentUser && can(currentUser, 'ACCESS_ADMIN') ? ( - {can(auth.user, 'MODERATE_COMMENTS') && ( + {can(currentUser, 'MODERATE_COMMENTS') && ( - {can(auth.user, 'UPDATE_CONFIG') && ( + {can(currentUser, 'UPDATE_CONFIG') && ( - - {t('configure.sign_out')} - + {currentUser && ( + + {t('configure.sign_out')} + + )}
@@ -116,7 +118,7 @@ const CoralHeader = ({ }; CoralHeader.propTypes = { - auth: PropTypes.object, + currentUser: PropTypes.object, showShortcuts: PropTypes.func, handleLogout: PropTypes.func.isRequired, root: PropTypes.object.isRequired, diff --git a/client/coral-admin/src/components/Layout.js b/client/coral-admin/src/components/Layout.js index 900858dee..5e3b9ce03 100644 --- a/client/coral-admin/src/components/Layout.js +++ b/client/coral-admin/src/components/Layout.js @@ -10,22 +10,26 @@ const Layout = ({ handleLogout = () => {}, toggleShortcutModal = () => {}, restricted = false, - auth, + currentUser, }) => (
+ -
{children}
); Layout.propTypes = { children: PropTypes.node, - auth: PropTypes.object, + currentUser: PropTypes.object, handleLogout: PropTypes.func, toggleShortcutModal: PropTypes.func, restricted: PropTypes.bool, // hide elements from a user that's logged out diff --git a/client/coral-admin/src/components/Login.css b/client/coral-admin/src/components/Login.css new file mode 100644 index 000000000..49f03a11f --- /dev/null +++ b/client/coral-admin/src/components/Login.css @@ -0,0 +1,18 @@ +.layout { + max-width: 400px; + margin: 0 auto; +} + +.header, .cta { + text-align: center; + font-size: 16px; +} + +.layout h1 { + font-size: 40px; +} + +.header { + font-size: 30px; +} + diff --git a/client/coral-admin/src/components/Login.js b/client/coral-admin/src/components/Login.js new file mode 100644 index 000000000..cc5ec9436 --- /dev/null +++ b/client/coral-admin/src/components/Login.js @@ -0,0 +1,37 @@ +import React, { Component } from 'react'; +import SignIn from '../containers/SignIn'; +import ForgotPassword from '../containers/ForgotPassword'; +import PropTypes from 'prop-types'; +import styles from './Login.css'; +import Layout from 'coral-admin/src/components/Layout'; +import cn from 'classnames'; + +class LoginContainer extends Component { + renderForm() { + return this.props.forgotPassword ? ( + + ) : ( + + ); + } + + render() { + return ( + +
+

Team sign in

+

Sign in to interact with your community.

+ {this.renderForm()} +
+
+ ); + } +} + +LoginContainer.propTypes = { + forgotPassword: PropTypes.bool.isRequired, + onForgotPasswordLink: PropTypes.func.isRequired, + onSignInLink: PropTypes.func.isRequired, +}; + +export default LoginContainer; diff --git a/client/coral-admin/src/components/NotFound.css b/client/coral-admin/src/components/NotFound.css index 9977f858a..6743a144d 100644 --- a/client/coral-admin/src/components/NotFound.css +++ b/client/coral-admin/src/components/NotFound.css @@ -3,35 +3,3 @@ margin: 0 auto; } -.loginLayout { - max-width: 400px; - margin: 0 auto; -} - -.loginHeader, .loginCTA, .forgotPasswordCTA, .passwordRequestSuccess { - text-align: center; - font-size: 16px; -} - -.forgotPasswordLink, .signInLink { - color: blue; - font-weight: normal; - text-decoration: none; -} - -.forgotPasswordLink:hover, .signInLink:hover { - text-decoration: underline; -} - -.layout h1 { - font-size: 40px; -} - -.loginHeader { - font-size: 30px; -} - -.passwordRequestSuccess { - cursor: pointer; - padding: 8px 14px; -} diff --git a/client/coral-admin/src/components/SignIn.css b/client/coral-admin/src/components/SignIn.css new file mode 100644 index 000000000..10ab54c09 --- /dev/null +++ b/client/coral-admin/src/components/SignIn.css @@ -0,0 +1,23 @@ +.forgotPasswordCTA { + text-align: center; + font-size: 16px; +} + +.forgotPasswordLink:hover { + text-decoration: underline; +} + +.forgotPasswordLink { + color: blue; + font-weight: normal; + text-decoration: none; +} + +.recaptcha { + margin-top: 16px; + margin-bottom: 6px; +} + +.signInButton { + margin-top: 10px; +} diff --git a/client/coral-admin/src/components/SignIn.js b/client/coral-admin/src/components/SignIn.js new file mode 100644 index 000000000..5e1de2033 --- /dev/null +++ b/client/coral-admin/src/components/SignIn.js @@ -0,0 +1,94 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import styles from './SignIn.css'; +import { Button, TextField, Alert } from 'coral-ui'; +import cn from 'classnames'; +import Recaptcha from 'coral-framework/components/Recaptcha'; + +class SignIn extends React.Component { + recaptcha = null; + + handleForgotPasswordLink = e => { + e.preventDefault(); + this.props.onForgotPasswordLink(); + }; + handleEmailChange = e => this.props.onEmailChange(e.target.value); + handlePasswordChange = e => this.props.onPasswordChange(e.target.value); + + handleSubmit = e => { + e.preventDefault(); + this.props.onSubmit(); + + // Reset recaptcha because each response can only + // be used once. + if (this.recaptcha) { + this.recaptcha.reset(); + } + }; + + handleRecaptchaRef = ref => { + this.recaptcha = ref; + }; + + render() { + const { email, password, errorMessage, requireRecaptcha } = this.props; + return ( +
+ {errorMessage && {errorMessage}} + + + {requireRecaptcha && ( +
+ +
+ )} + +

+ Forgot your password?{' '} + + Request a new one. + +

+ + ); + } +} + +SignIn.propTypes = { + email: PropTypes.string.isRequired, + password: PropTypes.string.isRequired, + onEmailChange: PropTypes.func.isRequired, + onPasswordChange: PropTypes.func.isRequired, + onForgotPasswordLink: PropTypes.func.isRequired, + onRecaptchaVerify: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, + errorMessage: PropTypes.string, + requireRecaptcha: PropTypes.bool.isRequired, +}; + +export default SignIn; diff --git a/client/coral-admin/src/containers/ForgotPassword.js b/client/coral-admin/src/containers/ForgotPassword.js new file mode 100644 index 000000000..62740e87c --- /dev/null +++ b/client/coral-admin/src/containers/ForgotPassword.js @@ -0,0 +1,41 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { withForgotPassword } from 'coral-framework/hocs'; +import { compose } from 'recompose'; +import ForgotPassword from '../components/ForgotPassword'; + +class ForgotPasswordContainer extends Component { + state = { + email: '', + }; + + handleSubmit = () => { + this.props.forgotPassword(this.state.email); + }; + + handleEmailChange = email => { + this.setState({ email }); + }; + + render() { + return ( + + ); + } +} + +ForgotPasswordContainer.propTypes = { + success: PropTypes.bool.isRequired, + forgotPassword: PropTypes.func.isRequired, + errorMessage: PropTypes.string, + onSignInLink: PropTypes.func.isRequired, +}; + +export default compose(withForgotPassword)(ForgotPasswordContainer); diff --git a/client/coral-admin/src/containers/Layout.js b/client/coral-admin/src/containers/Layout.js index 07e780e3e..aad9971c4 100644 --- a/client/coral-admin/src/containers/Layout.js +++ b/client/coral-admin/src/containers/Layout.js @@ -2,86 +2,58 @@ import React from 'react'; import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; import Layout from '../components/Layout'; -import { fetchConfig } from '../actions/config'; -import AdminLogin from '../components/AdminLogin'; +import Login from '../containers/Login'; import { FullLoading } from '../components/FullLoading'; import BanUserDialog from './BanUserDialog'; import SuspendUserDialog from './SuspendUserDialog'; import { toggleModal as toggleShortcutModal } from '../actions/moderation'; -import { - checkLogin, - handleLogin, - requestPasswordReset, - logout, -} from '../actions/auth'; +import { logout } from 'coral-framework/actions/auth'; import { can } from 'coral-framework/services/perms'; import UserDetail from 'coral-admin/src/containers/UserDetail'; import PropTypes from 'prop-types'; class LayoutContainer extends React.Component { - componentWillMount() { - const { checkLogin, fetchConfig } = this.props; - - checkLogin(); - fetchConfig(); - } - render() { const { - user, - loggedIn, - loadingUser, - loginError, - loginMaxExceeded, - passwordRequestSuccess, - } = this.props.auth; - - const { + currentUser, + checkedInitialLogin, children, logout, toggleShortcutModal, - TALK_RECAPTCHA_PUBLIC, } = this.props; - if (loadingUser) { + if (!checkedInitialLogin) { return ; } - if (!loggedIn) { - return ( - - ); + if (!currentUser) { + return ; } - if (can(user, 'ACCESS_ADMIN') && loggedIn) { - return ( - - - - - {children} - - ); - } else if (loggedIn) { - return ( - -

- This page is for team use only. Please contact an administrator if - you want to join this team. -

-
- ); + if (currentUser) { + if (can(currentUser, 'ACCESS_ADMIN')) { + return ( + + + + + {children} + + ); + } else { + return ( + +

+ This page is for team use only. Please contact an administrator if + you want to join this team. +

+
+ ); + } } return ; } @@ -89,29 +61,20 @@ class LayoutContainer extends React.Component { LayoutContainer.propTypes = { children: PropTypes.node, - requestPasswordReset: PropTypes.func, - handleLogin: PropTypes.func, - auth: PropTypes.object, - handleLogout: PropTypes.func, + currentUser: PropTypes.object, + checkedInitialLogin: PropTypes.bool, logout: PropTypes.func, toggleShortcutModal: PropTypes.func, - TALK_RECAPTCHA_PUBLIC: PropTypes.string, - checkLogin: PropTypes.func, - fetchConfig: PropTypes.func, }; const mapStateToProps = state => ({ - auth: state.auth, - TALK_RECAPTCHA_PUBLIC: state.config.data.TALK_RECAPTCHA_PUBLIC, + currentUser: state.auth.user, + checkedInitialLogin: state.auth.checkedInitialLogin, }); const mapDispatchToProps = dispatch => bindActionCreators( { - checkLogin, - fetchConfig, - handleLogin, - requestPasswordReset, toggleShortcutModal, logout, }, diff --git a/client/coral-admin/src/containers/Login.js b/client/coral-admin/src/containers/Login.js new file mode 100644 index 000000000..08558bc5c --- /dev/null +++ b/client/coral-admin/src/containers/Login.js @@ -0,0 +1,30 @@ +import React, { Component } from 'react'; +import Login from '../components/Login'; + +class LoginContainer extends Component { + state = { + forgotPassword: false, + }; + + switchToForgotPassword = () => { + this.setState({ forgotPassword: true }); + }; + + switchToSignIn = () => { + this.setState({ forgotPassword: false }); + }; + + render() { + return ( + + ); + } +} + +LoginContainer.propTypes = {}; + +export default LoginContainer; diff --git a/client/coral-admin/src/containers/SignIn.js b/client/coral-admin/src/containers/SignIn.js new file mode 100644 index 000000000..523d81091 --- /dev/null +++ b/client/coral-admin/src/containers/SignIn.js @@ -0,0 +1,58 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { withSignIn } from 'coral-framework/hocs'; +import { compose } from 'recompose'; +import SignIn from '../components/SignIn'; + +class SignInContainer extends Component { + state = { + email: '', + password: '', + recaptchaResponse: null, + }; + + handleSubmit = () => { + this.props.signIn( + this.state.email, + this.state.password, + this.state.recaptchaResponse + ); + }; + + handleEmailChange = email => { + this.setState({ email }); + }; + + handlePasswordChange = password => { + this.setState({ password }); + }; + + handleRecaptchaVerify = recaptchaResponse => { + this.setState({ recaptchaResponse }); + }; + + render() { + return ( + + ); + } +} + +SignInContainer.propTypes = { + signIn: PropTypes.func.isRequired, + errorMessage: PropTypes.string, + onForgotPasswordLink: PropTypes.func.isRequired, + requireRecaptcha: PropTypes.bool.isRequired, +}; + +export default compose(withSignIn)(SignInContainer); diff --git a/client/coral-admin/src/reducers/auth.js b/client/coral-admin/src/reducers/auth.js deleted file mode 100644 index 14aa2c21c..000000000 --- a/client/coral-admin/src/reducers/auth.js +++ /dev/null @@ -1,65 +0,0 @@ -import * as actions from '../constants/auth'; - -const initialState = { - loggedIn: false, - user: null, - loginError: null, - loginMaxExceeded: false, - passwordRequestSuccess: null, -}; - -export default function auth(state = initialState, action) { - switch (action.type) { - case actions.CHECK_LOGIN_REQUEST: - return { - ...state, - loadingUser: true, - }; - case actions.CHECK_LOGIN_FAILURE: - return { - ...state, - loggedIn: false, - loadingUser: false, - user: null, - }; - case actions.CHECK_LOGIN_SUCCESS: - return { - ...state, - loggedIn: true, - loadingUser: false, - user: action.user, - }; - case actions.LOGOUT: - return initialState; - case actions.LOGIN_SUCCESS: - return { - ...state, - loginMaxExceeded: false, - loginError: null, - }; - case actions.LOGIN_FAILURE: - return { - ...state, - loginError: action.message, - }; - case actions.FETCH_FORGOT_PASSWORD_REQUEST: - return { - ...state, - passwordRequestSuccess: null, - }; - case actions.FETCH_FORGOT_PASSWORD_SUCCESS: - return { - ...state, - passwordRequestSuccess: - 'If you have a registered account, a password reset link was sent to that email.', - }; - case actions.LOGIN_MAXIMUM_EXCEEDED: - return { - ...state, - loginMaxExceeded: true, - loginError: action.message, - }; - default: - return state; - } -} diff --git a/client/coral-admin/src/reducers/index.js b/client/coral-admin/src/reducers/index.js index 11801a379..be0a8a606 100644 --- a/client/coral-admin/src/reducers/index.js +++ b/client/coral-admin/src/reducers/index.js @@ -1,17 +1,14 @@ -import auth from './auth'; import stories from './stories'; import configure from './configure'; import community from './community'; import moderation from './moderation'; import install from './install'; -import config from './config'; import banUserDialog from './banUserDialog'; import suspendUserDialog from './suspendUserDialog'; import userDetail from './userDetail'; import ui from './ui'; export default { - auth, banUserDialog, configure, suspendUserDialog, @@ -20,6 +17,5 @@ export default { community, moderation, install, - config, ui, }; diff --git a/client/coral-admin/src/routes/Configure/components/Configure.js b/client/coral-admin/src/routes/Configure/components/Configure.js index f81bf7735..140222f68 100644 --- a/client/coral-admin/src/routes/Configure/components/Configure.js +++ b/client/coral-admin/src/routes/Configure/components/Configure.js @@ -24,7 +24,7 @@ export default class Configure extends Component { render() { const { - auth: { user }, + currentUser, canSave, savePending, setActiveSection, @@ -32,7 +32,7 @@ export default class Configure extends Component { } = this.props; const SectionComponent = this.getSectionComponent(activeSection); - if (!can(user, 'UPDATE_CONFIG')) { + if (!can(currentUser, 'UPDATE_CONFIG')) { return (

You must be an administrator to access config settings. Please find @@ -87,7 +87,7 @@ export default class Configure extends Component { Configure.propTypes = { savePending: PropTypes.func.isRequired, - auth: PropTypes.object.isRequired, + currentUser: PropTypes.object.isRequired, data: PropTypes.object.isRequired, root: PropTypes.object.isRequired, settings: PropTypes.object.isRequired, diff --git a/client/coral-admin/src/routes/Configure/containers/Configure.js b/client/coral-admin/src/routes/Configure/containers/Configure.js index ce33fa1f4..fe87766b6 100644 --- a/client/coral-admin/src/routes/Configure/containers/Configure.js +++ b/client/coral-admin/src/routes/Configure/containers/Configure.js @@ -30,7 +30,7 @@ class ConfigureContainer extends Component { return ( ({ - auth: state.auth, + currentUser: state.auth.user, pending: state.configure.pending, canSave: state.configure.canSave, activeSection: state.configure.activeSection, @@ -97,7 +97,7 @@ ConfigureContainer.propTypes = { updateSettings: PropTypes.func.isRequired, clearPending: PropTypes.func.isRequired, setActiveSection: PropTypes.func.isRequired, - auth: PropTypes.object.isRequired, + currentUser: PropTypes.object.isRequired, data: PropTypes.object.isRequired, root: PropTypes.object.isRequired, canSave: PropTypes.bool.isRequired, diff --git a/client/coral-admin/src/routes/Moderation/components/Comment.css b/client/coral-admin/src/routes/Moderation/components/Comment.css index 119688b73..b0d989c54 100644 --- a/client/coral-admin/src/routes/Moderation/components/Comment.css +++ b/client/coral-admin/src/routes/Moderation/components/Comment.css @@ -143,7 +143,6 @@ i { font-size: 12px; - top: 2px; position: relative; } } diff --git a/client/coral-admin/src/routes/Moderation/components/Moderation.js b/client/coral-admin/src/routes/Moderation/components/Moderation.js index c1de008fe..2d9b793ff 100644 --- a/client/coral-admin/src/routes/Moderation/components/Moderation.js +++ b/client/coral-admin/src/routes/Moderation/components/Moderation.js @@ -185,7 +185,7 @@ class Moderation extends Component { commentBelongToQueue={this.props.commentBelongToQueue} isLoadingMore={this.state.isLoadingMore} commentCount={activeTabCount} - currentUserId={this.props.auth.user.id} + currentUserId={this.props.currentUser.id} viewUserDetail={viewUserDetail} selectCommentId={props.selectCommentId} cleanUpQueue={props.cleanUpQueue} @@ -225,7 +225,7 @@ Moderation.propTypes = { cleanUpQueue: PropTypes.func.isRequired, storySearchChange: PropTypes.func.isRequired, moderation: PropTypes.object.isRequired, - auth: PropTypes.object.isRequired, + currentUser: PropTypes.object.isRequired, queueConfig: PropTypes.object.isRequired, commentBelongToQueue: PropTypes.func.isRequired, handleCommentChange: PropTypes.func.isRequired, diff --git a/client/coral-admin/src/routes/Moderation/containers/Moderation.js b/client/coral-admin/src/routes/Moderation/containers/Moderation.js index c0e2e60a6..f35255732 100644 --- a/client/coral-admin/src/routes/Moderation/containers/Moderation.js +++ b/client/coral-admin/src/routes/Moderation/containers/Moderation.js @@ -110,7 +110,7 @@ class ModerationContainer extends Component { comment.status_history[comment.status_history.length - 1] .assigned_by; const notifyText = - this.props.auth.user.id === user.id + this.props.currentUser.id === user.id ? '' : t( 'modqueue.notify_accepted', @@ -131,7 +131,7 @@ class ModerationContainer extends Component { comment.status_history[comment.status_history.length - 1] .assigned_by; const notifyText = - this.props.auth.user.id === user.id + this.props.currentUser.id === user.id ? '' : t( 'modqueue.notify_rejected', @@ -152,7 +152,7 @@ class ModerationContainer extends Component { comment.status_history[comment.status_history.length - 1] .assigned_by; const notifyText = - this.props.auth.user.id === user.id + this.props.currentUser.id === user.id ? '' : t( 'modqueue.notify_reset', @@ -515,7 +515,7 @@ const withModQueueQuery = withQuery( const mapStateToProps = state => ({ moderation: state.moderation, - auth: state.auth, + currentUser: state.auth.user, }); const mapDispatchToProps = dispatch => ({ diff --git a/client/coral-embed-stream/src/AppRouter.js b/client/coral-embed-stream/src/AppRouter.js deleted file mode 100644 index ec7b6300a..000000000 --- a/client/coral-embed-stream/src/AppRouter.js +++ /dev/null @@ -1,25 +0,0 @@ -import React from 'react'; -import { Router, Route } from 'react-router'; -import PropTypes from 'prop-types'; - -import Embed from './containers/Embed'; -import { LoginContainer } from './containers/LoginContainer'; - -const routes = ( -

- - -
-); - -class AppRouter extends React.Component { - static contextTypes = { - history: PropTypes.object, - }; - - render() { - return ; - } -} - -export default AppRouter; diff --git a/client/coral-embed-stream/src/actions/auth.js b/client/coral-embed-stream/src/actions/auth.js deleted file mode 100644 index d7e2b7826..000000000 --- a/client/coral-embed-stream/src/actions/auth.js +++ /dev/null @@ -1,415 +0,0 @@ -import jwtDecode from 'jwt-decode'; -import bowser from 'bowser'; -import * as actions from '../constants/auth'; -import { notify } from 'coral-framework/actions/notification'; -import t from 'coral-framework/services/i18n'; -import get from 'lodash/get'; - -export const updateStatus = status => ({ - type: actions.UPDATE_STATUS, - status, -}); - -export const showSignInDialog = () => ({ - type: actions.SHOW_SIGNIN_DIALOG, -}); - -export const hideSignInDialog = () => dispatch => { - if (window.opener && window.opener !== window) { - // TODO: We need to address this when we refactor the - // login popup out of the embed. - - // we are in a popup - window.close(); - } else { - dispatch(checkLogin()); - } - dispatch({ type: actions.HIDE_SIGNIN_DIALOG }); -}; - -export const resetSignInDialog = () => dispatch => { - dispatch({ type: actions.HIDE_SIGNIN_DIALOG }); -}; - -export const focusSignInDialog = () => ({ - type: actions.FOCUS_SIGNIN_DIALOG, -}); - -export const blurSignInDialog = () => ({ - type: actions.BLUR_SIGNIN_DIALOG, -}); - -export const showCreateUsernameDialog = () => ({ - type: actions.SHOW_CREATEUSERNAME_DIALOG, -}); - -export const hideCreateUsernameDialog = () => ({ - type: actions.HIDE_CREATEUSERNAME_DIALOG, -}); - -export const updateUsername = username => ({ - type: actions.UPDATE_USERNAME, - username, -}); - -export const changeView = view => dispatch => { - dispatch({ - type: actions.CHANGE_VIEW, - view, - }); - - switch (view) { - case 'SIGNUP': - window.resizeTo(500, 800); - break; - case 'FORGOT': - window.resizeTo(500, 400); - break; - default: - window.resizeTo(500, 550); - } -}; - -export const cleanState = () => ({ - type: actions.CLEAN_STATE, -}); - -// Sign In Actions - -const signInRequest = email => ({ - type: actions.FETCH_SIGNIN_REQUEST, - email, -}); - -const signInFailure = error => ({ - type: actions.FETCH_SIGNIN_FAILURE, - error, -}); - -//============================================================================== -// AUTH TOKEN -//============================================================================== - -export const handleAuthToken = token => (dispatch, _, { localStorage }) => { - if (localStorage) { - localStorage.setItem('exp', jwtDecode(token).exp); - localStorage.setItem('token', token); - } - - dispatch({ type: 'HANDLE_AUTH_TOKEN' }); -}; - -//============================================================================== -// SIGN IN -//============================================================================== - -export const fetchSignIn = formData => { - return (dispatch, _, { rest }) => { - dispatch(signInRequest(formData.email)); - - return rest('/auth/local', { method: 'POST', body: formData }) - .then(({ token }) => { - if (!bowser.safari && !bowser.ios) { - dispatch(handleAuthToken(token)); - } - dispatch(hideSignInDialog()); - }) - .catch(error => { - console.error(error); - if (error.metadata) { - // the user might not have a valid email. prompt the user user re-request the confirmation email - dispatch( - signInFailure(t('error.email_not_verified', error.metadata)) - ); - } else if (error.translation_key === 'NOT_AUTHORIZED') { - // invalid credentials - dispatch(signInFailure(t('error.email_password'), error.metadata)); - } else { - dispatch(signInFailure(error)); - } - }); - }; -}; - -//============================================================================== -// SIGN IN - FACEBOOK -//============================================================================== - -const signInFacebookRequest = () => ({ - type: actions.FETCH_SIGNIN_FACEBOOK_REQUEST, -}); - -const signInFacebookSuccess = user => ({ - type: actions.FETCH_SIGNIN_FACEBOOK_SUCCESS, - user, -}); - -const signInFacebookFailure = error => ({ - type: actions.FETCH_SIGNIN_FACEBOOK_FAILURE, - error, -}); - -export const fetchSignInFacebook = () => (dispatch, _, { rest }) => { - dispatch(signInFacebookRequest()); - window.open( - `${rest.uri}/auth/facebook`, - 'Continue with Facebook', - 'menubar=0,resizable=0,width=500,height=500,top=200,left=500' - ); -}; - -//============================================================================== -// SIGN UP - FACEBOOK -//============================================================================== - -const signUpFacebookRequest = () => ({ - type: actions.FETCH_SIGNUP_FACEBOOK_REQUEST, -}); - -export const fetchSignUpFacebook = () => (dispatch, _, { rest }) => { - dispatch(signUpFacebookRequest()); - window.open( - `${rest.uri}/auth/facebook`, - 'Continue with Facebook', - 'menubar=0,resizable=0,width=500,height=500,top=200,left=500' - ); -}; - -export const facebookCallback = (err, data) => dispatch => { - if (err) { - dispatch(signInFacebookFailure(err)); - return; - } - try { - dispatch(handleAuthToken(data.token)); - dispatch(signInFacebookSuccess(data.user)); - dispatch(hideSignInDialog()); - } catch (err) { - dispatch(signInFacebookFailure(err)); - return; - } -}; - -//============================================================================== -// SIGN UP -//============================================================================== - -const signUpRequest = () => ({ type: actions.FETCH_SIGNUP_REQUEST }); -const signUpSuccess = user => ({ type: actions.FETCH_SIGNUP_SUCCESS, user }); -const signUpFailure = error => ({ type: actions.FETCH_SIGNUP_FAILURE, error }); - -export const fetchSignUp = formData => (dispatch, getState, { rest }) => { - const redirectUri = getState().auth.redirectUri; - dispatch(signUpRequest()); - - rest('/users', { - method: 'POST', - body: formData, - headers: { 'X-Pym-Url': redirectUri }, - }) - .then(({ user }) => { - dispatch(signUpSuccess(user)); - }) - .catch(error => { - console.error(error); - const errorMessage = error.translation_key - ? t(`error.${error.translation_key}`) - : error.toString(); - dispatch(signUpFailure(errorMessage)); - }); -}; - -//============================================================================== -// FORGOT PASSWORD -//============================================================================== - -const forgotPasswordRequest = () => ({ - type: actions.FETCH_FORGOT_PASSWORD_REQUEST, -}); - -const forgotPasswordSuccess = () => ({ - type: actions.FETCH_FORGOT_PASSWORD_SUCCESS, -}); - -const forgotPasswordFailure = error => ({ - type: actions.FETCH_FORGOT_PASSWORD_FAILURE, - error, -}); - -export const fetchForgotPassword = email => (dispatch, getState, { rest }) => { - dispatch(forgotPasswordRequest(email)); - const redirectUri = getState().auth.redirectUri; - rest('/account/password/reset', { - method: 'POST', - body: { email, loc: redirectUri }, - }) - .then(() => dispatch(forgotPasswordSuccess())) - .catch(error => { - console.error(error); - const errorMessage = error.translation_key - ? t(`error.${error.translation_key}`) - : error.toString(); - dispatch(forgotPasswordFailure(errorMessage)); - }); -}; - -//============================================================================== -// LOGOUT -//============================================================================== - -export const logout = () => async ( - dispatch, - _, - { rest, client, pym, localStorage } -) => { - await rest('/auth', { method: 'DELETE' }); - - if (localStorage) { - localStorage.removeItem('token'); - localStorage.removeItem('exp'); - } - - // Reset the websocket. - client.resetWebsocket(); - - dispatch({ type: actions.LOGOUT }); - pym.sendMessage('coral-auth-changed'); -}; - -//============================================================================== -// CHECK LOGIN -//============================================================================== - -const checkLoginRequest = () => ({ type: actions.CHECK_LOGIN_REQUEST }); -const checkLoginFailure = error => ({ - type: actions.CHECK_LOGIN_FAILURE, - error, -}); - -const checkLoginSuccess = (user, isAdmin) => ({ - type: actions.CHECK_LOGIN_SUCCESS, - user, - isAdmin, -}); - -const ErrNotLoggedIn = new Error('Not logged in'); - -export const checkLogin = () => ( - dispatch, - _, - { rest, client, pym, localStorage } -) => { - dispatch(checkLoginRequest()); - rest('/auth') - .then(result => { - if (!result.user) { - if (localStorage) { - localStorage.removeItem('token'); - localStorage.removeItem('exp'); - } - throw ErrNotLoggedIn; - } - - // Reset the websocket. - client.resetWebsocket(); - - dispatch(checkLoginSuccess(result.user)); - pym.sendMessage('coral-auth-changed', JSON.stringify(result.user)); - - // This is for login via social. Usernames should be set. - if ( - get(result.user, 'status.username.status') === 'UNSET' && - !get(result.user, 'status.banned.status') - ) { - dispatch(showCreateUsernameDialog()); - } - }) - .catch(error => { - if (error !== ErrNotLoggedIn) { - console.error(error); - } - if (error.status && error.status === 401 && localStorage) { - // Unauthorized. - localStorage.removeItem('token'); - localStorage.removeItem('exp'); - } - const errorMessage = error.translation_key - ? t(`error.${error.translation_key}`) - : error.toString(); - dispatch(checkLoginFailure(errorMessage)); - }); -}; - -export const validForm = () => ({ type: actions.VALID_FORM }); -export const invalidForm = error => ({ type: actions.INVALID_FORM, error }); - -//============================================================================== -// VERIFY EMAIL -//============================================================================== - -const verifyEmailRequest = () => ({ - type: actions.VERIFY_EMAIL_REQUEST, -}); - -const verifyEmailSuccess = () => ({ - type: actions.VERIFY_EMAIL_SUCCESS, -}); - -const verifyEmailFailure = error => ({ - type: actions.VERIFY_EMAIL_FAILURE, - error, -}); - -export const requestConfirmEmail = email => (dispatch, getState, { rest }) => { - const redirectUri = getState().auth.redirectUri; - dispatch(verifyEmailRequest()); - return rest('/users/resend-verify', { - method: 'POST', - body: { email }, - headers: { 'X-Pym-Url': redirectUri }, - }) - .then(() => { - dispatch(verifyEmailSuccess()); - }) - .catch(error => { - console.error(error); - dispatch(verifyEmailFailure(error)); - throw error; - }); -}; - -// Login Popup actions. -export const setRequireEmailVerification = required => ({ - type: actions.SET_REQUIRE_EMAIL_VERIFICATION, - required, -}); - -export const setRedirectUri = uri => ({ - type: actions.SET_REDIRECT_URI, - uri, -}); - -//============================================================================== -// Edit Username -//============================================================================== - -const editUsernameFailure = error => ({ - type: actions.EDIT_USERNAME_FAILURE, - error, -}); -const editUsernameSuccess = () => ({ type: actions.EDIT_USERNAME_SUCCESS }); - -export const editName = username => (dispatch, _, { rest }) => { - return rest('/account/username', { method: 'PUT', body: { username } }) - .then(() => { - dispatch(editUsernameSuccess()); - dispatch(notify('success', t('framework.success_name_update'))); - }) - .catch(error => { - console.error(error); - const errorMessage = error.translation_key - ? t(`error.${error.translation_key}`) - : error.toString(); - dispatch(editUsernameFailure(errorMessage)); - }); -}; diff --git a/client/coral-embed-stream/src/actions/login.js b/client/coral-embed-stream/src/actions/login.js new file mode 100644 index 000000000..c602bf7b6 --- /dev/null +++ b/client/coral-embed-stream/src/actions/login.js @@ -0,0 +1,19 @@ +import * as actions from '../constants/login'; +import { checkLogin } from 'coral-framework/actions/auth'; + +export const showSignInDialog = () => ({ + type: actions.SHOW_SIGNIN_DIALOG, +}); + +export const hideSignInDialog = () => dispatch => { + dispatch(checkLogin()); + dispatch({ type: actions.HIDE_SIGNIN_DIALOG }); +}; + +export const focusSignInDialog = () => ({ + type: actions.FOCUS_SIGNIN_DIALOG, +}); + +export const blurSignInDialog = () => ({ + type: actions.BLUR_SIGNIN_DIALOG, +}); diff --git a/client/coral-embed-stream/src/components/Embed.js b/client/coral-embed-stream/src/components/Embed.js index 0fd257ef2..b7eb6d580 100644 --- a/client/coral-embed-stream/src/components/Embed.js +++ b/client/coral-embed-stream/src/components/Embed.js @@ -16,17 +16,10 @@ import cn from 'classnames'; export default class Embed extends React.Component { changeTab = tab => { - // TODO: move data fetching to appropiate containers. - switch (tab) { - case 'profile': - this.props.data.refetch(); - break; - } this.props.setActiveTab(tab); }; getTabs() { - const { user } = this.props.auth; const tabs = [ , ]; - if (can(user, 'UPDATE_ASSET_CONFIG')) { + if (can(this.props.currentUser, 'UPDATE_ASSET_CONFIG')) { tabs.push( ; } - return ; + return ( + + ); } } @@ -255,21 +266,46 @@ const EMBED_QUERY = gql` `; export const withEmbedQuery = withQuery(EMBED_QUERY, { - options: ({ auth, commentId, assetId, assetUrl, sortBy, sortOrder }) => ({ + options: ({ + currentUser, + commentId, + assetId, + assetUrl, + sortBy, + sortOrder, + }) => ({ variables: { assetId, assetUrl, commentId, hasComment: commentId !== '', - excludeIgnored: Boolean(auth && auth.user && auth.user.id), + excludeIgnored: Boolean(currentUser && currentUser.id), sortBy, sortOrder, }, }), }); +EmbedContainer.propTypes = { + setActiveTab: PropTypes.func, + currentUser: PropTypes.object, + blurSignInDialog: PropTypes.func, + focusSignInDialog: PropTypes.func, + hideSignInDialog: PropTypes.func, + router: PropTypes.object, + commentId: PropTypes.string, + root: PropTypes.object, + activeTab: PropTypes.string, + parentUrl: PropTypes.string, + data: PropTypes.object, + fetchAssetSuccess: PropTypes.func, + showSignInDialog: PropTypes.bool, + signInDialogFocus: PropTypes.bool, +}; + const mapStateToProps = state => ({ - auth: state.auth, + currentUser: state.auth.user, + checkedInitialLogin: state.auth.checkedInitialLogin, commentId: state.stream.commentId, assetId: state.stream.assetId, assetUrl: state.stream.assetUrl, @@ -277,13 +313,14 @@ const mapStateToProps = state => ({ config: state.config, sortOrder: state.stream.sortOrder, sortBy: state.stream.sortBy, + showSignInDialog: state.login.showSignInDialog, + signInDialogFocus: state.login.signInDialogFocus, + parentUrl: state.login.parentUrl, }); const mapDispatchToProps = dispatch => bindActionCreators( { - logout, - checkLogin, setActiveTab, fetchAssetSuccess, notify, @@ -297,6 +334,6 @@ const mapDispatchToProps = dispatch => export default compose( connect(mapStateToProps, mapDispatchToProps), - branch(props => !props.auth.checkedInitialLogin, renderComponent(Spinner)), + branch(props => !props.checkedInitialLogin, renderComponent(Spinner)), withEmbedQuery )(EmbedContainer); diff --git a/client/coral-embed-stream/src/containers/LoginContainer.js b/client/coral-embed-stream/src/containers/LoginContainer.js deleted file mode 100644 index 5ee0c54a1..000000000 --- a/client/coral-embed-stream/src/containers/LoginContainer.js +++ /dev/null @@ -1,4 +0,0 @@ -import React from 'react'; -import Slot from 'coral-framework/components/Slot'; - -export const LoginContainer = () => ; diff --git a/client/coral-embed-stream/src/index.js b/client/coral-embed-stream/src/index.js index 7100c0d28..a4c6bcd39 100644 --- a/client/coral-embed-stream/src/index.js +++ b/client/coral-embed-stream/src/index.js @@ -1,59 +1,22 @@ import React from 'react'; import { render } from 'react-dom'; +import Embed from './containers/Embed'; -import { - checkLogin, - handleAuthToken, - logout, -} from 'coral-embed-stream/src/actions/auth'; import graphqlExtension from './graphql'; -import { addExternalConfig } from 'coral-embed-stream/src/actions/config'; import { createContext } from 'coral-framework/services/bootstrap'; -import AppRouter from './AppRouter'; import reducers from './reducers'; import TalkProvider from 'coral-framework/components/TalkProvider'; import pluginsConfig from 'pluginsConfig'; -// TODO: move init code into `bootstrap` service after auth has been refactored. -function preInit({ store, pym, inIframe }) { - // TODO: This is popup specific code and needs to be refactored. - if (!inIframe) { - store.dispatch(addExternalConfig({})); - store.dispatch(checkLogin()); - return; - } - - pym.onMessage('login', token => { - if (token) { - store.dispatch(handleAuthToken(token)); - } - store.dispatch(checkLogin()); - }); - - pym.onMessage('logout', () => { - store.dispatch(logout()); - }); - - return new Promise(resolve => { - pym.sendMessage('getConfig'); - pym.onMessage('config', config => { - store.dispatch(addExternalConfig(JSON.parse(config))); - store.dispatch(checkLogin()); - resolve(); - }); - }); -} - async function main() { const context = await createContext({ reducers, graphqlExtension, pluginsConfig, - preInit, }); render( - + , document.querySelector('#talk-embed-stream-container') ); diff --git a/client/coral-embed-stream/src/reducers/auth.js b/client/coral-embed-stream/src/reducers/auth.js deleted file mode 100644 index faa576fff..000000000 --- a/client/coral-embed-stream/src/reducers/auth.js +++ /dev/null @@ -1,249 +0,0 @@ -import * as actions from '../constants/auth'; -import pym from 'coral-framework/services/pym'; -import merge from 'lodash/merge'; - -const initialState = { - isLoading: false, - loggedIn: false, - user: null, - showSignInDialog: false, - signInDialogFocus: false, - showCreateUsernameDialog: false, - checkedInitialLogin: false, - view: 'SIGNIN', - error: null, - passwordRequestSuccess: null, - passwordRequestFailure: null, - emailVerificationFailure: false, - emailVerificationLoading: false, - emailVerificationSuccess: false, - successSignUp: false, - fromSignUp: false, - requireEmailConfirmation: false, - redirectUri: pym.parentUrl || location.href, -}; - -const purge = user => { - const {settings, ...userData} = user; // eslint-disable-line - return userData; -}; - -export default function auth(state = initialState, action) { - switch (action.type) { - case actions.FOCUS_SIGNIN_DIALOG: - return { - ...state, - signInDialogFocus: true, - }; - case actions.BLUR_SIGNIN_DIALOG: - return { - ...state, - signInDialogFocus: false, - }; - case actions.SHOW_SIGNIN_DIALOG: - return { - ...state, - showSignInDialog: true, - signInDialogFocus: true, - }; - case actions.RESET_SIGNIN_DIALOG: - case actions.HIDE_SIGNIN_DIALOG: - return { - ...state, - isLoading: false, - showSignInDialog: false, - signInDialogFocus: false, - view: 'SIGNIN', - error: null, - passwordRequestFailure: null, - passwordRequestSuccess: null, - emailVerificationFailure: false, - emailVerificationSuccess: false, - emailVerificationLoading: false, - successSignUp: false, - }; - case actions.SHOW_CREATEUSERNAME_DIALOG: - return { - ...state, - showCreateUsernameDialog: true, - }; - case actions.HIDE_CREATEUSERNAME_DIALOG: - return { - ...state, - showCreateUsernameDialog: false, - }; - case actions.CREATE_USERNAME_SUCCESS: - return { - ...state, - showCreateUsernameDialog: false, - error: null, - }; - case actions.CREATE_USERNAME_FAILURE: - return { - ...state, - error: action.error, - }; - case actions.CHANGE_VIEW: - return { - ...state, - error: action.error, - view: action.view, - }; - case actions.CLEAN_STATE: - return initialState; - case actions.FETCH_SIGNIN_REQUEST: - return { - ...state, - email: action.email, - isLoading: true, - }; - case actions.CHECK_LOGIN_FAILURE: - return { - ...state, - checkedInitialLogin: true, - loggedIn: false, - user: null, - }; - case actions.CHECK_LOGIN_SUCCESS: - return { - ...state, - checkedInitialLogin: true, - loggedIn: true, - user: purge(action.user), - }; - case actions.FETCH_SIGNIN_SUCCESS: - return { - ...state, - loggedIn: true, - user: purge(action.user), - }; - case actions.FETCH_SIGNIN_FAILURE: - return { - ...state, - 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 { - ...state, - fromSignUp: true, - }; - case actions.FETCH_SIGNIN_FACEBOOK_REQUEST: - return { - ...state, - fromSignUp: false, - }; - case actions.FETCH_SIGNIN_FACEBOOK_SUCCESS: - return { - ...state, - loggedIn: true, - user: purge(action.user), - }; - case actions.FETCH_SIGNIN_FACEBOOK_FAILURE: - return { - ...state, - error: action.error, - user: null, - }; - case actions.FETCH_SIGNUP_REQUEST: - return { - ...state, - isLoading: true, - }; - case actions.FETCH_SIGNUP_FAILURE: - return { - ...state, - error: action.error, - isLoading: false, - }; - case actions.FETCH_SIGNUP_SUCCESS: - return { - ...state, - isLoading: false, - successSignUp: true, - }; - case actions.LOGOUT: - return { - ...state, - user: null, - isLoading: false, - loggedIn: false, - }; - case actions.INVALID_FORM: - return { - ...state, - error: action.error, - }; - case actions.VALID_FORM: - return { - ...state, - error: null, - }; - case actions.FETCH_FORGOT_PASSWORD_SUCCESS: - return { - ...state, - passwordRequestFailure: null, - passwordRequestSuccess: - 'If you have a registered account, a password reset link was sent to that email', - }; - case actions.FETCH_FORGOT_PASSWORD_FAILURE: - return { - ...state, - passwordRequestFailure: - 'There was an error sending your password reset email. Please try again soon!', - passwordRequestSuccess: null, - }; - case actions.UPDATE_USERNAME: - return { - ...state, - user: { - ...state.user, - username: action.username, - lowercaseUsername: action.username.toLowerCase(), - }, - }; - case actions.VERIFY_EMAIL_FAILURE: - return { - ...state, - emailVerificationFailure: action.error, - emailVerificationLoading: false, - }; - case actions.VERIFY_EMAIL_REQUEST: - return { - ...state, - emailVerificationLoading: true, - }; - case actions.VERIFY_EMAIL_SUCCESS: - return { - ...state, - emailVerificationSuccess: true, - emailVerificationLoading: false, - }; - case actions.SET_REQUIRE_EMAIL_VERIFICATION: - return { - ...state, - requireEmailConfirmation: action.required, - }; - case actions.SET_REDIRECT_URI: - return { - ...state, - redirectUri: action.uri, - }; - case actions.UPDATE_STATUS: { - return { - ...state, - user: { - ...state.user, - status: merge({}, state.user.status, action.status), - }, - }; - } - default: - return state; - } -} diff --git a/client/coral-embed-stream/src/reducers/index.js b/client/coral-embed-stream/src/reducers/index.js index ac581557b..039db7ec3 100644 --- a/client/coral-embed-stream/src/reducers/index.js +++ b/client/coral-embed-stream/src/reducers/index.js @@ -1,15 +1,13 @@ -import auth from './auth'; +import login from './login'; import asset from './asset'; import embed from './embed'; -import config from './config'; import configure from './configure'; import stream from './stream'; export default { - auth, + login, asset, embed, - config, configure, stream, }; diff --git a/client/coral-embed-stream/src/reducers/login.js b/client/coral-embed-stream/src/reducers/login.js new file mode 100644 index 000000000..c24d36f96 --- /dev/null +++ b/client/coral-embed-stream/src/reducers/login.js @@ -0,0 +1,38 @@ +import * as actions from '../constants/login'; +import pym from 'coral-framework/services/pym'; + +const initialState = { + parentUrl: pym.parentUrl || location.href, + showSignInDialog: false, + signInDialogFocus: false, +}; + +export default function login(state = initialState, action) { + switch (action.type) { + case actions.FOCUS_SIGNIN_DIALOG: + return { + ...state, + signInDialogFocus: true, + }; + case actions.BLUR_SIGNIN_DIALOG: + return { + ...state, + signInDialogFocus: false, + }; + case actions.SHOW_SIGNIN_DIALOG: + return { + ...state, + showSignInDialog: true, + signInDialogFocus: true, + }; + + case actions.HIDE_SIGNIN_DIALOG: + return { + ...state, + showSignInDialog: false, + signInDialogFocus: false, + }; + default: + return state; + } +} diff --git a/client/coral-embed-stream/src/reducers/stream.js b/client/coral-embed-stream/src/reducers/stream.js index 42af4453a..30b9fe22a 100644 --- a/client/coral-embed-stream/src/reducers/stream.js +++ b/client/coral-embed-stream/src/reducers/stream.js @@ -1,5 +1,5 @@ import * as actions from '../constants/stream'; -import * as authActions from '../constants/auth'; +import * as authActions from 'coral-framework/constants/auth'; function getQueryVariable(variable) { let query = window.location.search.substring(1); diff --git a/client/coral-embed-stream/src/tabs/profile/containers/ProfileContainer.js b/client/coral-embed-stream/src/tabs/profile/containers/ProfileContainer.js index a818ebec4..57aa2b0fe 100644 --- a/client/coral-embed-stream/src/tabs/profile/containers/ProfileContainer.js +++ b/client/coral-embed-stream/src/tabs/profile/containers/ProfileContainer.js @@ -10,12 +10,7 @@ import NotLoggedIn from '../components/NotLoggedIn'; import { Spinner } from 'coral-ui'; import CommentHistory from '../components/CommentHistory'; -// TODO: Auth logic needs refactoring. -import { - showSignInDialog, - checkLogin, -} from 'coral-embed-stream/src/actions/auth'; - +import { showSignInDialog } from 'coral-embed-stream/src/actions/login'; import { appendNewNodes } from 'plugin-api/beta/client/utils'; import update from 'immutability-helper'; import { getSlotFragmentSpreads } from 'coral-framework/utils'; @@ -24,7 +19,7 @@ import t from 'coral-framework/services/i18n'; class ProfileContainer extends Component { componentWillReceiveProps(nextProps) { - if (!this.props.auth.loggedIn && nextProps.auth.loggedIn) { + if (!this.props.currentUser && nextProps.currentUser) { // Refetch after login. this.props.data.refetch(); } @@ -55,13 +50,7 @@ class ProfileContainer extends Component { }; render() { - const { - auth, - auth: { user: authUser }, - showSignInDialog, - root, - data, - } = this.props; + const { currentUser, showSignInDialog, root, data } = this.props; const { me } = this.props.root; const loading = this.props.data.loading; @@ -69,7 +58,7 @@ class ProfileContainer extends Component { return
{this.props.data.error.message}
; } - if (!auth.loggedIn) { + if (!currentUser) { return ; } @@ -77,7 +66,7 @@ class ProfileContainer extends Component { return ; } - const localProfile = authUser.profiles.find(p => p.provider === 'local'); + const localProfile = currentUser.profiles.find(p => p.provider === 'local'); const emailAddress = localProfile && localProfile.id; return ( @@ -168,15 +157,20 @@ const withProfileQuery = withQuery( ${getSlotFragmentSpreads(slots, 'root')} } ${CommentFragment} -` +`, + { + options: { + fetchPolicy: 'network-only', + }, + } ); const mapStateToProps = state => ({ - auth: state.auth, + currentUser: state.auth.user, }); const mapDispatchToProps = dispatch => - bindActionCreators({ showSignInDialog, checkLogin }, dispatch); + bindActionCreators({ showSignInDialog }, dispatch); export default compose( connect(mapStateToProps, mapDispatchToProps), diff --git a/client/coral-embed-stream/src/tabs/stream/components/Stream.js b/client/coral-embed-stream/src/tabs/stream/components/Stream.js index 23f438045..a431b1986 100644 --- a/client/coral-embed-stream/src/tabs/stream/components/Stream.js +++ b/client/coral-embed-stream/src/tabs/stream/components/Stream.js @@ -59,7 +59,7 @@ class Stream extends React.Component { deleteAction, showSignInDialog, loadNewReplies, - auth: { user }, + currentUser, emit, viewAllComments, } = this.props; @@ -111,7 +111,7 @@ class Stream extends React.Component { disableReply={!open} postComment={postComment} asset={asset} - currentUser={user} + currentUser={currentUser} highlighted={comment.id} postFlag={postFlag} postDontAgree={postDontAgree} @@ -150,7 +150,7 @@ class Stream extends React.Component { setActiveStreamTab, loadNewReplies, loadMoreComments, - auth: { user }, + currentUser, emit, sortOrder, sortBy, @@ -200,7 +200,7 @@ class Stream extends React.Component { notify={notify} disableReply={asset.isClosed} postComment={postComment} - currentUser={user} + currentUser={currentUser} postFlag={postFlag} postDontAgree={postDontAgree} loadMore={loadMoreComments} @@ -230,21 +230,23 @@ class Stream extends React.Component { postComment, notify, updateItem, - auth: { loggedIn, user }, + currentUser, } = this.props; const { keepCommentBox } = this.state; const open = !asset.isClosed; - const banned = get(user, 'status.banned.status'); - const suspensionUntil = get(user, 'status.suspension.until'); - const rejectedUsername = get(user, 'status.username.status') === 'REJECTED'; - const changedUsername = get(user, 'status.username.status') === 'CHANGED'; + const banned = get(currentUser, 'status.banned.status'); + const suspensionUntil = get(currentUser, 'status.suspension.until'); + const rejectedUsername = + get(currentUser, 'status.username.status') === 'REJECTED'; + const changedUsername = + get(currentUser, 'status.username.status') === 'CHANGED'; const temporarilySuspended = - user && suspensionUntil && new Date(suspensionUntil) > new Date(); + currentUser && suspensionUntil && new Date(suspensionUntil) > new Date(); const showCommentBox = - loggedIn && + currentUser && ((!banned && !temporarilySuspended && !rejectedUsername && @@ -289,7 +291,8 @@ class Stream extends React.Component { )} {changedUsername && } - {!banned && rejectedUsername && } + {!banned && + rejectedUsername && } {banned && } {showCommentBox && ( @@ -312,10 +315,10 @@ class Stream extends React.Component { - {loggedIn && ( + {currentUser && ( )} @@ -342,12 +345,11 @@ Stream.propTypes = { deleteAction: PropTypes.func, showSignInDialog: PropTypes.func, loadNewReplies: PropTypes.func, - auth: PropTypes.object, + currentUser: PropTypes.object, emit: PropTypes.func, sortOrder: PropTypes.string, sortBy: PropTypes.string, loading: PropTypes.bool, - editName: PropTypes.func, appendItemArray: PropTypes.func, updateItem: PropTypes.func, viewAllComments: PropTypes.func, diff --git a/client/coral-embed-stream/src/tabs/stream/containers/Comment.js b/client/coral-embed-stream/src/tabs/stream/containers/Comment.js index 9ed421273..5a6c332e0 100644 --- a/client/coral-embed-stream/src/tabs/stream/containers/Comment.js +++ b/client/coral-embed-stream/src/tabs/stream/containers/Comment.js @@ -4,7 +4,10 @@ import Comment from '../components/Comment'; import { withFragments } from 'coral-framework/hocs'; import { getSlotFragmentSpreads } from 'coral-framework/utils'; import { withSetCommentStatus } from 'coral-framework/graphql/mutations'; -import { THREADING_LEVEL } from '../../../constants/stream'; +import { + THREADING_LEVEL, + REPLY_COMMENTS_LOAD_DEPTH, +} from '../../../constants/stream'; import hoistStatics from 'recompose/hoistStatics'; import { nest } from '../../../graphql/utils'; @@ -118,7 +121,7 @@ const withCommentFragments = withFragments({ ...CoralEmbedStream_Comment_SingleComment ${nest( ` - replies(query: {limit: 3, excludeIgnored: $excludeIgnored}) { + replies(query: {limit: ${REPLY_COMMENTS_LOAD_DEPTH}, excludeIgnored: $excludeIgnored}) { nodes { ...CoralEmbedStream_Comment_SingleComment ...nest diff --git a/client/coral-embed-stream/src/tabs/stream/containers/Stream.js b/client/coral-embed-stream/src/tabs/stream/containers/Stream.js index 51b4d9d9e..e80719646 100644 --- a/client/coral-embed-stream/src/tabs/stream/containers/Stream.js +++ b/client/coral-embed-stream/src/tabs/stream/containers/Stream.js @@ -1,9 +1,11 @@ import React from 'react'; +import PropTypes from 'prop-types'; import { gql, compose } from 'react-apollo'; import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; import { ADDTL_COMMENTS_ON_LOAD_MORE, + ASSET_COMMENTS_LOAD_DEPTH, THREADING_LEVEL, } from '../../../constants/stream'; import { @@ -14,8 +16,8 @@ import { withEditComment, } from 'coral-framework/graphql/mutations'; -import * as authActions from 'coral-embed-stream/src/actions/auth'; -import * as notificationActions from 'coral-framework/actions/notification'; +import { showSignInDialog } from 'coral-embed-stream/src/actions/login'; +import { notify } from 'coral-framework/actions/notification'; import { setActiveReplyBox, setActiveTab, @@ -40,9 +42,6 @@ import { } from '../../../graphql/utils'; import StreamError from '../components/StreamError'; -const { showSignInDialog, editName } = authActions; -const { notify } = notificationActions; - class StreamContainer extends React.Component { commentsAddedSubscription = null; commentsEditedSubscription = null; @@ -60,8 +59,8 @@ class StreamContainer extends React.Component { // Ignore mutations from me. // TODO: need way to detect mutations created by this client, and allow mutations from other clients. if ( - this.props.auth.user && - commentEdited.user.id === this.props.auth.user.id + this.props.currentUser && + commentEdited.user.id === this.props.currentUser.id ) { return prev; } @@ -92,8 +91,8 @@ class StreamContainer extends React.Component { // Ignore mutations from me. // TODO: need way to detect mutations created by this client, and allow mutations from other clients. if ( - this.props.auth.user && - commentAdded.user.id === this.props.auth.user.id + this.props.currentUser && + commentAdded.user.id === this.props.currentUser.id ) { return prev; } @@ -204,8 +203,8 @@ class StreamContainer extends React.Component { } } - userIsDegraged({ auth: { user } } = this.props) { - return !can(user, 'INTERACT_WITH_COMMUNITY'); + userIsDegraged({ currentUser } = this.props) { + return !can(currentUser, 'INTERACT_WITH_COMMUNITY'); } render() { @@ -225,8 +224,28 @@ class StreamContainer extends React.Component { return ( ({ - auth: state.auth, + currentUser: state.auth.user, activeReplyBox: state.stream.activeReplyBox, commentId: state.stream.commentId, assetId: state.stream.assetId, @@ -417,7 +463,6 @@ const mapDispatchToProps = dispatch => showSignInDialog, notify, setActiveReplyBox, - editName, viewAllComments, setActiveStreamTab: setActiveTab, }, diff --git a/client/coral-embed-stream/style/default.css b/client/coral-embed-stream/style/default.css index a04ee6f05..04797211e 100644 --- a/client/coral-embed-stream/style/default.css +++ b/client/coral-embed-stream/style/default.css @@ -72,17 +72,6 @@ body { font-weight: bold; } -/* Coral sign in button */ - -#coralSignInButton { - background-color: #2a2a2a; - color: #FFF; -} - -#coralSignInButton:hover { - background-color: #767676; -} - /* Info Box Styles */ diff --git a/client/coral-framework/actions/auth.js b/client/coral-framework/actions/auth.js new file mode 100644 index 000000000..0c4780548 --- /dev/null +++ b/client/coral-framework/actions/auth.js @@ -0,0 +1,110 @@ +import * as actions from '../constants/auth'; +import jwtDecode from 'jwt-decode'; + +function cleanAuthData(localStorage) { + localStorage.removeItem('token'); + localStorage.removeItem('exp'); +} + +export const checkLogin = () => ( + dispatch, + _, + { rest, client, pym, localStorage } +) => { + dispatch(checkLoginRequest()); + rest('/auth') + .then(result => { + if (!result.user) { + if (localStorage) { + cleanAuthData(localStorage); + } + dispatch(checkLoginSuccess(null)); + return; + } + + // Reset the websocket. + client.resetWebsocket(); + + dispatch(checkLoginSuccess(result.user)); + pym.sendMessage('coral-auth-changed', JSON.stringify(result.user)); + }) + .catch(error => { + if (error.status && error.status === 401 && localStorage) { + // Unauthorized. + cleanAuthData(localStorage); + } else { + console.error(error); + } + dispatch(checkLoginFailure(error)); + }); +}; + +const checkLoginRequest = () => ({ type: actions.CHECK_LOGIN_REQUEST }); + +const checkLoginFailure = error => ({ + type: actions.CHECK_LOGIN_FAILURE, + error, +}); + +const checkLoginSuccess = user => ({ + type: actions.CHECK_LOGIN_SUCCESS, + user, +}); + +export const setAuthToken = token => (dispatch, _, { localStorage }) => { + if (localStorage) { + localStorage.setItem('exp', jwtDecode(token).exp); + localStorage.setItem('token', token); + } + + dispatch(checkLogin()); +}; + +export const handleSuccessfulLogin = (user, token) => ( + dispatch, + _, + { client, localStorage } +) => { + if (localStorage) { + localStorage.setItem('exp', jwtDecode(token).exp); + localStorage.setItem('token', token); + } + + client.resetWebsocket(); + + dispatch({ + type: actions.HANDLE_SUCCESSFUL_LOGIN, + user, + }); +}; + +/** + * Logout + */ +export const logout = () => async ( + dispatch, + _, + { rest, client, pym, localStorage } +) => { + await rest('/auth', { method: 'DELETE' }); + + if (localStorage) { + cleanAuthData(localStorage); + } + + // Reset the websocket. + client.resetWebsocket(); + + dispatch({ type: actions.LOGOUT }); + pym.sendMessage('coral-auth-changed'); +}; + +export const updateStatus = status => ({ + type: actions.UPDATE_STATUS, + status, +}); + +export const updateUsername = username => ({ + type: actions.UPDATE_USERNAME, + username, +}); diff --git a/client/coral-framework/actions/config.js b/client/coral-framework/actions/config.js new file mode 100644 index 000000000..dd1522333 --- /dev/null +++ b/client/coral-framework/actions/config.js @@ -0,0 +1,6 @@ +import { MERGE_CONFIG } from '../constants/config'; + +export const mergeConfig = config => ({ + type: MERGE_CONFIG, + config, +}); diff --git a/client/coral-framework/components/Recaptcha.js b/client/coral-framework/components/Recaptcha.js new file mode 100644 index 000000000..f632514ba --- /dev/null +++ b/client/coral-framework/components/Recaptcha.js @@ -0,0 +1,56 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import ReactRecaptcha from 'react-recaptcha'; + +class Recaptcha extends React.Component { + static contextTypes = { + store: PropTypes.object, + }; + + ref = null; + + handleRef = ref => { + this.ref = ref; + }; + + reset = () => this.ref.reset(); + + getSiteKey() { + // This should be fine because it's static and will never change. + // Prefer this to connect HOC because wie expose the instance method + // `reset` + return this.context.store.getState().config.static.TALK_RECAPTCHA_PUBLIC; + } + + render() { + return ( + + ); + } +} + +Recaptcha.defaultProps = { + render: 'explicit', + theme: 'light', + size: 'normal', +}; + +Recaptcha.propTypes = { + onLoad: PropTypes.func, + onVerify: PropTypes.func.isRequired, + theme: PropTypes.string, + render: PropTypes.string, + size: PropTypes.string, + className: PropTypes.string, +}; + +export default Recaptcha; diff --git a/client/coral-framework/components/Slot.js b/client/coral-framework/components/Slot.js index af524b21d..fe63b4f30 100644 --- a/client/coral-framework/components/Slot.js +++ b/client/coral-framework/components/Slot.js @@ -5,6 +5,7 @@ import { connect } from 'react-redux'; import kebabCase from 'lodash/kebabCase'; import PropTypes from 'prop-types'; import isEqual from 'lodash/isEqual'; +import get from 'lodash/get'; import { getShallowChanges } from 'coral-framework/utils'; const emptyConfig = {}; @@ -68,7 +69,7 @@ class Slot extends React.Component { } = this.props; const { plugins } = this.context; let children = this.getChildren(); - const pluginConfig = reduxState.config.pluginConfig || emptyConfig; + const pluginConfig = get(reduxState, 'config.pluginConfig') || emptyConfig; if (children.length === 0 && DefaultComponent) { const props = plugins.getSlotComponentProps( DefaultComponent, diff --git a/client/coral-framework/constants/auth.js b/client/coral-framework/constants/auth.js new file mode 100644 index 000000000..25e2f6a78 --- /dev/null +++ b/client/coral-framework/constants/auth.js @@ -0,0 +1,11 @@ +const prefix = `TALK_FRAMEWORK`; + +export const CHECK_LOGIN_REQUEST = `${prefix}_CHECK_LOGIN_REQUEST`; +export const CHECK_LOGIN_SUCCESS = `${prefix}_CHECK_LOGIN_SUCCESS`; +export const CHECK_LOGIN_FAILURE = `${prefix}_CHECK_LOGIN_FAILURE`; + +export const LOGOUT = `${prefix}_LOGOUT`; +export const HANDLE_SUCCESSFUL_LOGIN = `${prefix}_HANDLE_SUCCESSFUL_LOGIN`; + +export const UPDATE_STATUS = '${prefix}_UPDATE_STATUS'; +export const UPDATE_USERNAME = '${prefix}_UPDATE_USERNAME'; diff --git a/client/coral-framework/constants/config.js b/client/coral-framework/constants/config.js new file mode 100644 index 000000000..bf846af1d --- /dev/null +++ b/client/coral-framework/constants/config.js @@ -0,0 +1,3 @@ +const prefix = `TALK_FRAMEWORK`; + +export const MERGE_CONFIG = `${prefix}_MERGE_CONFIG`; diff --git a/client/coral-framework/hocs/index.js b/client/coral-framework/hocs/index.js index 174f25a63..eb481de99 100644 --- a/client/coral-framework/hocs/index.js +++ b/client/coral-framework/hocs/index.js @@ -6,3 +6,10 @@ export { default as withEmit } from './withEmit'; export { default as excludeIf } from './excludeIf'; export { default as connect } from './connect'; export { default as withMergedSettings } from './withMergedSettings'; +export { default as withSignIn } from './withSignIn'; +export { default as withSignUp } from './withSignUp'; +export { default as withForgotPassword } from './withForgotPassword'; +export { default as withSetUsername } from './withSetUsername'; +export { + default as withResendEmailConfirmation, +} from './withResendEmailConfirmation'; diff --git a/client/coral-framework/hocs/withForgotPassword.js b/client/coral-framework/hocs/withForgotPassword.js new file mode 100644 index 000000000..9ef4b59e1 --- /dev/null +++ b/client/coral-framework/hocs/withForgotPassword.js @@ -0,0 +1,68 @@ +import React from 'react'; +import hoistStatics from 'recompose/hoistStatics'; +import PropTypes from 'prop-types'; +import { translateError } from '../utils'; + +/** + * WithForgotPassword provides properties + * `forgotPasssword`, + * `loading`, + * `errorMessage`, + * `success`. + */ +export default hoistStatics(WrappedComponent => { + class WithForgotPassword extends React.Component { + static contextTypes = { + store: PropTypes.object, + rest: PropTypes.func, + pym: PropTypes.object, + }; + + state = { + error: null, + loading: false, + success: false, + }; + + forgotPassword = (email, redirectUri) => { + if (!redirectUri) { + redirectUri = this.context.pym.parentUrl || location.href; + } + const { rest } = this.context; + this.setState({ loading: true, error: null, success: false }); + + rest('/account/password/reset', { + method: 'POST', + body: { email, loc: redirectUri }, + }) + .then(() => { + this.setState({ loading: false, error: null, success: true }); + }) + .catch(error => { + console.error(error); + this.setState({ loading: false, error }); + }); + }; + + getErrorMessage() { + if (!this.state.error) { + return null; + } + return translateError(this.state.error); + } + + render() { + return ( + + ); + } + } + + return WithForgotPassword; +}); diff --git a/client/coral-framework/hocs/withResendEmailConfirmation.js b/client/coral-framework/hocs/withResendEmailConfirmation.js new file mode 100644 index 000000000..64f5ed69f --- /dev/null +++ b/client/coral-framework/hocs/withResendEmailConfirmation.js @@ -0,0 +1,69 @@ +import React from 'react'; +import hoistStatics from 'recompose/hoistStatics'; +import PropTypes from 'prop-types'; +import { translateError } from '../utils'; + +/** + * WithResendEmailConfirmaton provides properties + * `resendEmailConfirmation`, + * `loading`, + * `errorMessage`, + * `success`. + */ +export default hoistStatics(WrappedComponent => { + class WithResendEmailConfirmaton extends React.Component { + static contextTypes = { + store: PropTypes.object, + rest: PropTypes.func, + pym: PropTypes.object, + }; + + state = { + error: null, + loading: false, + success: false, + }; + + resendEmailConfirmation = (email, redirectUri) => { + if (!redirectUri) { + redirectUri = this.context.pym.parentUrl || location.href; + } + const { rest } = this.context; + this.setState({ loading: true, error: null, success: false }); + + rest('/users/resend-verify', { + method: 'POST', + body: { email }, + headers: { 'X-Pym-Url': redirectUri }, + }) + .then(() => { + this.setState({ loading: false, error: null, success: true }); + }) + .catch(error => { + console.error(error); + this.setState({ loading: false, error }); + }); + }; + + getErrorMessage() { + if (!this.state.error) { + return null; + } + return translateError(this.state.error); + } + + render() { + return ( + + ); + } + } + + return WithResendEmailConfirmaton; +}); diff --git a/client/coral-framework/hocs/withSetUsername.js b/client/coral-framework/hocs/withSetUsername.js new file mode 100644 index 000000000..05323efa1 --- /dev/null +++ b/client/coral-framework/hocs/withSetUsername.js @@ -0,0 +1,100 @@ +import React from 'react'; +import hoistStatics from 'recompose/hoistStatics'; +import PropTypes from 'prop-types'; +import { getErrorMessages } from '../utils'; +import validate from '../helpers/validate'; +import errorMsg from 'coral-framework/helpers/error'; +import t from '../services/i18n'; +import { withSetUsername as withSetUsernameMutation } from 'coral-framework/graphql/mutations'; +import { updateUsername, updateStatus } from '../actions/auth'; +import { compose } from 'recompose'; +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; +import get from 'lodash/get'; + +/** + * withSetUsername provides properties + * `setUsername`, + * `loading`, + * `errorMessage`, + * `requireEmailVerification`, + * `success`, + * `validateUsername`. + */ +const withSetUsername = hoistStatics(WrappedComponent => { + class WithSetUsername extends React.Component { + static propTypes = { + setUsername: PropTypes.func.isRequired, + currentUserId: PropTypes.string, + updateUsername: PropTypes.func.isRequired, + updateStatus: PropTypes.func.isRequired, + }; + + state = { + error: null, + loading: false, + success: false, + }; + + validateUsername = value => { + if (!value) { + return t('error.required_field'); + } + return validate.username(value) ? null : errorMsg.username; + }; + + setUsername = async username => { + if (!this.props.currentUserId) { + throw new Error('User not logged in'); + } + + try { + await this.props.setUsername(this.props.currentUserId, username); + this.props.updateUsername(username); + this.props.updateStatus({ username: { status: 'SET' } }); + this.setState({ success: true, loading: false, error: null }); + } catch (error) { + if (!error.status || error.status !== 401) { + console.error(error); + } + const changeSet = { success: false, loading: false, error }; + this.setState(changeSet); + } + }; + + getErrorMessage() { + if (!this.state.error) { + return null; + } + return getErrorMessages(this.state.error).join(', '); + } + + render() { + return ( + + ); + } + } + + return WithSetUsername; +}); + +const mapStateToProps = ({ auth }) => ({ + currentUserId: get(auth, 'user.id'), +}); + +const mapDispatchToProps = dispatch => + bindActionCreators({ updateUsername, updateStatus }, dispatch); + +export default compose( + connect(mapStateToProps, mapDispatchToProps), + withSetUsernameMutation, + withSetUsername +); diff --git a/client/coral-framework/hocs/withSignIn.js b/client/coral-framework/hocs/withSignIn.js new file mode 100644 index 000000000..d377d8ada --- /dev/null +++ b/client/coral-framework/hocs/withSignIn.js @@ -0,0 +1,93 @@ +import React from 'react'; +import hoistStatics from 'recompose/hoistStatics'; +import PropTypes from 'prop-types'; +import { handleSuccessfulLogin } from '../actions/auth'; +import { translateError } from '../utils'; +import { t } from '../services/i18n'; + +/** + * WithSignIn provides properties + * `signIn` + * `loading` + * `errorMessage` + * `requireRecaptcha` + * `requireEmailConfirmation` + * 'success' + */ +export default hoistStatics(WrappedComponent => { + class WithSignIn extends React.Component { + static contextTypes = { + store: PropTypes.object, + rest: PropTypes.func, + }; + + state = { + error: null, + loading: false, + success: false, + requireRecaptcha: false, + requireEmailConfirmation: false, + }; + + signIn = (email, password, recaptchaResponse) => { + const { store, rest } = this.context; + const params = { + method: 'POST', + body: { + email, + password, + }, + }; + + if (recaptchaResponse) { + params.headers = { + 'X-Recaptcha-Response': recaptchaResponse, + }; + } + + rest('/auth/local', params) + .then(({ user, token }) => { + this.setState({ success: true, loading: false, error: null }); + store.dispatch(handleSuccessfulLogin(user, token)); + }) + .catch(error => { + if (!error.status || error.status !== 401) { + console.error(error); + } + const changeSet = { success: false, loading: false, error }; + if (error.translation_key === 'LOGIN_MAXIMUM_EXCEEDED') { + changeSet.requireRecaptcha = !!this.context.store.getState().config + .static.TALK_RECAPTCHA_PUBLIC; + } else if (error.translation_key === 'EMAIL_NOT_VERIFIED') { + changeSet.requireEmailConfirmation = true; + } + this.setState(changeSet); + }); + }; + + getErrorMessage() { + if (!this.state.error) { + return null; + } + return this.state.error.translation_key === 'NOT_AUTHORIZED' + ? t('error.email_password') + : translateError(this.state.error); + } + + render() { + return ( + + ); + } + } + + return WithSignIn; +}); diff --git a/client/coral-framework/hocs/withSignUp.js b/client/coral-framework/hocs/withSignUp.js new file mode 100644 index 000000000..8c35e1cc9 --- /dev/null +++ b/client/coral-framework/hocs/withSignUp.js @@ -0,0 +1,124 @@ +import React from 'react'; +import hoistStatics from 'recompose/hoistStatics'; +import { compose, gql } from 'react-apollo'; +import PropTypes from 'prop-types'; +import { translateError } from '../utils'; +import validate from '../helpers/validate'; +import errorMsg from 'coral-framework/helpers/error'; +import t from '../services/i18n'; +import withQuery from './withQuery'; +import get from 'lodash/get'; + +const requiredFields = ['username', 'email', 'password']; +const allFields = requiredFields; + +const QUERY = gql` + query TalkFramework_WithSignUpQuery { + settings { + requireEmailConfirmation + } + } +`; + +export const withSettingsQuery = withQuery(QUERY); + +/** + * withSignUp provides properties + * `signUp`, + * `loading`, + * `errorMessage`, + * `requireEmailVerification`, + * `success`, + * `validate`. + */ +const withSignUp = hoistStatics(WrappedComponent => { + class WithSignUp extends React.Component { + static contextTypes = { + store: PropTypes.object, + rest: PropTypes.func, + pym: PropTypes.object, + }; + + static propTypes = { + root: PropTypes.object.isRequired, + }; + + state = { + error: null, + loading: false, + success: false, + }; + + validate = (field, value) => { + if (!allFields.includes(field)) { + return null; + } + + if (requiredFields.includes(field) && !value) { + return t('error.required_field'); + } + + if (field in validate) { + return validate[field](value) ? null : errorMsg[field]; + } + + return null; + }; + + signUp = ({ username, email, password }, redirectUri) => { + if (!redirectUri) { + redirectUri = this.context.pym.parentUrl || location.href; + } + + const { rest } = this.context; + const params = { + method: 'POST', + body: { + username, + email, + password, + }, + headers: { 'X-Pym-Url': redirectUri }, + }; + + rest('/users', params) + .then(() => { + this.setState({ success: true, loading: false, error: null }); + }) + .catch(error => { + if (!error.status || error.status !== 401) { + console.error(error); + } + const changeSet = { success: false, loading: false, error }; + this.setState(changeSet); + }); + }; + + getErrorMessage() { + if (!this.state.error) { + return null; + } + return translateError(this.state.error); + } + + render() { + return ( + + ); + } + } + + return WithSignUp; +}); + +export default compose(withSettingsQuery, withSignUp); diff --git a/client/coral-framework/reducers/auth.js b/client/coral-framework/reducers/auth.js new file mode 100644 index 000000000..3af606d0b --- /dev/null +++ b/client/coral-framework/reducers/auth.js @@ -0,0 +1,61 @@ +import * as actions from '../constants/auth'; +import merge from 'lodash/merge'; + +const initialState = { + checkedInitialLogin: false, + initialLoginError: null, + user: null, +}; + +const purge = user => { + const {settings, ...userData} = user; // eslint-disable-line + return userData; +}; + +export default function auth(state = initialState, action) { + switch (action.type) { + case actions.CHECK_LOGIN_FAILURE: + return { + ...state, + initialLoginError: action.error, + checkedInitialLogin: true, + user: null, + }; + case actions.CHECK_LOGIN_SUCCESS: + return { + ...state, + checkedInitialLogin: true, + user: action.user ? purge(action.user) : null, + }; + case actions.HANDLE_SUCCESSFUL_LOGIN: + return { + ...state, + user: action.user ? purge(action.user) : null, + }; + case actions.LOGOUT: + return { + ...state, + user: null, + }; + case actions.UPDATE_STATUS: { + return { + ...state, + user: { + ...state.user, + status: merge({}, state.user.status, action.status), + }, + }; + } + case actions.UPDATE_USERNAME: + return { + ...state, + user: { + ...state.user, + username: action.username, + lowercaseUsername: action.username.toLowerCase(), + }, + }; + default: + return state; + } +} diff --git a/client/coral-admin/src/reducers/config.js b/client/coral-framework/reducers/config.js similarity index 54% rename from client/coral-admin/src/reducers/config.js rename to client/coral-framework/reducers/config.js index 0588f0a13..a3a0409eb 100644 --- a/client/coral-admin/src/reducers/config.js +++ b/client/coral-framework/reducers/config.js @@ -1,15 +1,13 @@ -import * as actions from '../actions/config'; +import { MERGE_CONFIG } from '../constants/config'; -const initialState = { - data: {}, -}; +const initialState = {}; export default function config(state = initialState, action) { switch (action.type) { - case actions.CONFIG_UPDATED: + case MERGE_CONFIG: return { ...state, - data: action.data, + ...action.config, }; default: return state; diff --git a/client/coral-framework/reducers/index.js b/client/coral-framework/reducers/index.js new file mode 100644 index 000000000..3661ebaef --- /dev/null +++ b/client/coral-framework/reducers/index.js @@ -0,0 +1,8 @@ +import auth from './auth'; +import config from './config'; + +export default { + auth, + login: auth, + config, +}; diff --git a/client/coral-framework/services/bootstrap.js b/client/coral-framework/services/bootstrap.js index 6e0f69915..a7dd6df98 100644 --- a/client/coral-framework/services/bootstrap.js +++ b/client/coral-framework/services/bootstrap.js @@ -22,6 +22,10 @@ import { import { createHistory } from 'coral-framework/services/history'; import { createIntrospection } from 'coral-framework/services/introspection'; import introspectionData from 'coral-framework/graphql/introspection.json'; +import coreReducers from '../reducers'; +import { checkLogin as checkLoginAction } from '../actions/auth'; +import { mergeConfig } from '../actions/config'; +import { setAuthToken, logout } from '../actions/auth'; /** * getAuthToken returns the active auth token or null @@ -32,7 +36,7 @@ import introspectionData from 'coral-framework/graphql/introspection.json'; const getAuthToken = (store, storage) => { let state = store.getState(); - if (state.config.auth_token) { + if (state.config && state.config.auth_token) { // if an auth_token exists in config, use it. return state.config.auth_token; } else if (!bowser.safari && !bowser.ios && storage) { @@ -51,6 +55,19 @@ function areWeInIframe() { } } +function initExternalConfig({ store, pym, inIframe }) { + if (!inIframe) { + return; + } + return new Promise(resolve => { + pym.sendMessage('getConfig'); + pym.onMessage('config', config => { + store.dispatch(mergeConfig(JSON.parse(config))); + resolve(); + }); + }); +} + /** * createContext setups and returns Talk dependencies that should be * passed to `TalkProvider`. @@ -70,6 +87,8 @@ export async function createContext({ notification, preInit, init = noop, + checkLogin = true, + addExternalConfig = true, } = {}) { const inIframe = areWeInIframe(); const eventEmitter = new EventEmitter({ wildcard: true }); @@ -98,7 +117,8 @@ export async function createContext({ token, }); - let { LIVE_URI: liveUri } = getStaticConfiguration(); + const staticConfig = getStaticConfiguration(); + let { LIVE_URI: liveUri } = staticConfig; if (liveUri == null) { // The protocol must match the origin protocol, secure/insecure. const protocol = location.protocol === 'https:' ? 'wss' : 'ws'; @@ -161,6 +181,7 @@ export async function createContext({ // Create our redux store. const finalReducers = { + ...coreReducers, ...reducers, ...plugins.getReducers(), }; @@ -180,9 +201,39 @@ export async function createContext({ [client.middleware(), apolloErrorReporter, createReduxEmitter(eventEmitter)] ); - // Run pre initialization. + if (inIframe) { + pym.onMessage('login', token => { + if (token) { + store.dispatch(setAuthToken(token)); + } + }); + + pym.onMessage('logout', () => { + store.dispatch(logout()); + }); + } + + const preInitList = []; + + store.dispatch( + mergeConfig({ + static: staticConfig, + }) + ); + if (preInit) { - await preInit(context); + preInitList.push(preInit(context)); + } + + if (addExternalConfig) { + preInitList.push(initExternalConfig(context)); + } + + // Run pre initialization. + await Promise.all(preInitList); + + if (checkLogin) { + store.dispatch(checkLoginAction()); } // Run initialization. diff --git a/client/coral-framework/services/plugins.js b/client/coral-framework/services/plugins.js index 8f9fa8080..6f99a2291 100644 --- a/client/coral-framework/services/plugins.js +++ b/client/coral-framework/services/plugins.js @@ -6,6 +6,7 @@ import flattenDeep from 'lodash/flattenDeep'; import isEmpty from 'lodash/isEmpty'; import flatten from 'lodash/flatten'; import mapValues from 'lodash/mapValues'; +import get from 'lodash/get'; import { getDisplayName } from 'coral-framework/helpers/hoc'; import camelize from '../helpers/camelize'; @@ -83,7 +84,7 @@ class PluginsService { * query datas are only passed to the component if it is defined in `component.fragments`. */ getSlotComponentProps(component, reduxState, props, queryData) { - const pluginConfig = reduxState.config.plugin_config || emptyConfig; + const pluginConfig = get(reduxState, 'config.plugin_config') || emptyConfig; return { ...props, config: pluginConfig, @@ -97,7 +98,7 @@ class PluginsService { * Returns React Elements for given slot. */ getSlotElements(slot, reduxState, props = {}, queryData = {}) { - const pluginConfig = reduxState.config.plugin_config || emptyConfig; + const pluginConfig = get(reduxState, 'config.plugin_config') || emptyConfig; const isDisabled = component => { if ( diff --git a/client/coral-framework/utils/index.js b/client/coral-framework/utils/index.js index d53b9b496..66792b9b3 100644 --- a/client/coral-framework/utils/index.js +++ b/client/coral-framework/utils/index.js @@ -252,3 +252,12 @@ export function mapLeaves(o, mapper) { return mapper(val); }); } + +export function translateError(error) { + if (error.translation_key) { + return t(`error.${error.translation_key}`); + } else if (error.networkError) { + return t('error.network_error'); + } + return error.toString(); +} diff --git a/client/coral-login/src/containers/Main.js b/client/coral-login/src/containers/Main.js new file mode 100644 index 000000000..1d38b9b8e --- /dev/null +++ b/client/coral-login/src/containers/Main.js @@ -0,0 +1,10 @@ +import React from 'react'; +import Slot from 'coral-framework/components/Slot'; + +class Main extends React.Component { + render() { + return ; + } +} + +export default Main; diff --git a/client/coral-login/src/index.js b/client/coral-login/src/index.js new file mode 100644 index 000000000..aa81bce18 --- /dev/null +++ b/client/coral-login/src/index.js @@ -0,0 +1,21 @@ +import React from 'react'; +import { render } from 'react-dom'; + +import { createContext } from 'coral-framework/services/bootstrap'; +import Main from './containers/Main'; +import TalkProvider from 'coral-framework/components/TalkProvider'; +import pluginsConfig from 'pluginsConfig'; + +async function main() { + const context = await createContext({ + pluginsConfig, + }); + render( + +
+ , + document.querySelector('#talk-login-container') + ); +} + +main(); diff --git a/client/coral-ui/components/BareButton.css b/client/coral-ui/components/BareButton.css new file mode 100644 index 000000000..0a9c81625 --- /dev/null +++ b/client/coral-ui/components/BareButton.css @@ -0,0 +1,3 @@ +.bare { + composes: buttonReset from "coral-framework/styles/reset.css"; +} diff --git a/client/coral-ui/components/BareButton.js b/client/coral-ui/components/BareButton.js new file mode 100644 index 000000000..6d3f9fa93 --- /dev/null +++ b/client/coral-ui/components/BareButton.js @@ -0,0 +1,23 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import styles from './BareButton.css'; +import cn from 'classnames'; + +/** + * BareButton is a button whose styling is stripped off to a minimum. + * Can pass anchor=true to use `a` instead of `button` + */ +const BareButton = ({ anchor, className, ...props }) => { + let Element = 'button'; + if (anchor) { + Element = 'a'; + } + return ; +}; + +BareButton.propTypes = { + className: PropTypes.string, + anchor: PropTypes.bool, +}; + +export default BareButton; diff --git a/client/coral-ui/components/Label.css b/client/coral-ui/components/Label.css index 5e7f656ee..18723fa2f 100644 --- a/client/coral-ui/components/Label.css +++ b/client/coral-ui/components/Label.css @@ -10,6 +10,7 @@ line-height: 22px; min-width: 80px; text-align: center; + vertical-align: text-top; } .icon { diff --git a/client/coral-ui/index.js b/client/coral-ui/index.js index 7ab267b0f..38c6a8650 100644 --- a/client/coral-ui/index.js +++ b/client/coral-ui/index.js @@ -28,3 +28,4 @@ export { default as Label } from './components/Label'; export { default as FlagLabel } from './components/FlagLabel'; export { default as Dropdown } from './components/Dropdown'; export { default as Option } from './components/Option'; +export { default as BareButton } from './components/BareButton'; diff --git a/config.js b/config.js index dd9aed13c..9b7c7adcc 100644 --- a/config.js +++ b/config.js @@ -44,6 +44,10 @@ const CONFIG = { // fetching again. SETTINGS_CACHE_TIME: ms(process.env.TALK_SETTINGS_CACHE_TIME || '1hr'), + // ALLOW_NO_LIMIT_QUERIES enables some queries to specify a limit of -1 to + // request all of the records. Otherwise, minimum limits of 0 are enforced. + ALLOW_NO_LIMIT_QUERIES: process.env.TALK_ALLOW_NO_LIMIT_QUERIES === 'TRUE', + //------------------------------------------------------------------------------ // JWT based configuration //------------------------------------------------------------------------------ diff --git a/docs/_docs/02-01-required-configuration.md b/docs/_docs/02-01-required-configuration.md index da235ed90..4d3cd5dbd 100644 --- a/docs/_docs/02-01-required-configuration.md +++ b/docs/_docs/02-01-required-configuration.md @@ -84,23 +84,3 @@ TALK_JWT_SECRET=jX9y8G2ApcVLwyL{$6s3 Be default, we sign our tokens with HMAC using a SHA-256 hash algorithm. If you want to change the signing algorithm, or use multiple signing/verifying keys, refer to our [Advanced Configuration]({{ "/advanced-configuration/" | relative_url }}) documentation. - -## TALK_FACEBOOK_APP_ID - -The Facebook App ID for your Facebook Login enabled app. You can learn more -about getting a Facebook App ID at the -[Facebook Developers Portal](https://developers.facebook.com){:target="_blank"} -or by visiting the -[Creating an App ID](https://developers.facebook.com/docs/apps/register){:target="_blank"} -guide. This is only required while the `talk-plugin-facebook-auth` plugin is -enabled. - -## TALK_FACEBOOK_APP_SECRET - -The Facebook App Secret for your Facebook Login enabled app. You can learn more -about getting a Facebook App Secret at the -[Facebook Developers Portal](https://developers.facebook.com){:target="_blank"} -or by visiting the -[Creating an App ID](https://developers.facebook.com/docs/apps/register){:target="_blank"} -guide. This is only required while the `talk-plugin-facebook-auth` plugin is -enabled. diff --git a/docs/_docs/02-02-advanced-configuration.md b/docs/_docs/02-02-advanced-configuration.md index e985f8f20..50c430252 100644 --- a/docs/_docs/02-02-advanced-configuration.md +++ b/docs/_docs/02-02-advanced-configuration.md @@ -57,6 +57,26 @@ When `TRUE`, it will not mount the static asset serving routes on the router. This is used primarily in conjunction with [TALK_STATIC_URI](#talk_static_uri){: .param} when the static assets are being hosted on an external domain. (Default `FALSE`) +## TALK_FACEBOOK_APP_ID + +The Facebook App ID for your Facebook Login enabled app. You can learn more +about getting a Facebook App ID at the +[Facebook Developers Portal](https://developers.facebook.com){:target="_blank"} +or by visiting the +[Creating an App ID](https://developers.facebook.com/docs/apps/register){:target="_blank"} +guide. This is only required while the `talk-plugin-facebook-auth` plugin is +enabled. + +## TALK_FACEBOOK_APP_SECRET + +The Facebook App Secret for your Facebook Login enabled app. You can learn more +about getting a Facebook App Secret at the +[Facebook Developers Portal](https://developers.facebook.com){:target="_blank"} +or by visiting the +[Creating an App ID](https://developers.facebook.com/docs/apps/register){:target="_blank"} +guide. This is only required while the `talk-plugin-facebook-auth` plugin is +enabled. + ## TALK_HELMET_CONFIGURATION A JSON string representing the configuration passed to the @@ -303,6 +323,8 @@ the websocket to keep the socket alive, parsed by ## TALK_RECAPTCHA_PUBLIC +Setting a reCAPTCHA Public and Secret key will enable and require reCAPTCHA upon multiple failed login attempts. + Client secret used for enabling reCAPTCHA powered logins. If [TALK_RECAPTCHA_SECRET](#talk_recaptcha_secret){: .param} and [TALK_RECAPTCHA_PUBLIC](#talk_recaptcha_public){: .param} are not provided it will instead @@ -486,4 +508,34 @@ Used to set the key for use with [Apollo Engine](https://www.apollographql.com/engine/){:target="_blank"} for tracing of GraphQL requests. -**Note: Apollo Engine is a premium service, charges may apply.** \ No newline at end of file +**Note: Apollo Engine is a premium service, charges may apply.** + +## ALLOW_NO_LIMIT_QUERIES + +Setting this to `TRUE` will allow queries to execute without a limit (returns +all documents). This introduces a significant performance regression, and should +be used with caution. (Default `FALSE`) + +## TALK_ADDTL_COMMENTS_ON_LOAD_MORE + +This is a **Build Variable** and must be consumed during build. If using the +[Docker-onbuild]({{ "/installation-from-docker/#onbuild" | relative_url }}) +image you can specify it with `--build-arg TALK_ADDTL_COMMENTS_ON_LOAD_MORE=10`. + +Specifies the number of additional comments to load when a user clicks `Load More`. (Default `10`) + +## TALK_ASSET_COMMENTS_LOAD_DEPTH + +This is a **Build Variable** and must be consumed during build. If using the +[Docker-onbuild]({{ "/installation-from-docker/#onbuild" | relative_url }}) +image you can specify it with `--build-arg TALK_ASSET_COMMENTS_LOAD_DEPTH=10`. + +Specifies the initial number of comments to load for an asset. (Default `10`) + +## TALK_REPLY_COMMENTS_LOAD_DEPTH + +This is a **Build Variable** and must be consumed during build. If using the +[Docker-onbuild]({{ "/installation-from-docker/#onbuild" | relative_url }}) +image you can specify it with `--build-arg TALK_REPLY_COMMENTS_LOAD_DEPTH=3`. + +Specifies the initial replies to load for a comment. (Default `3`) \ No newline at end of file diff --git a/docs/_docs/04-03-additional-plugins.md b/docs/_docs/04-03-additional-plugins.md index bbb4c5cff..4f1654a4f 100644 --- a/docs/_docs/04-03-additional-plugins.md +++ b/docs/_docs/04-03-additional-plugins.md @@ -116,8 +116,8 @@ point with the still spam-like comment, the comment will be reported as containi Configuration: -- `TALK_AKISMET_API_KEY` (**required**) - The Akismet API key located on your account page -- `TALK_AKISMET_SITE` (**required**) - The URL for your site that the comment are appearing on (not the root url, the url for the articles) +- `TALK_AKISMET_API_KEY` (**required**) - The Akismet API key located on your account page. +- `TALK_AKISMET_SITE` (**required**) - The URL where you are embedding the comment stream on to provide context to Akismet. If you're hosting talk on https://talk.mynews.org/, and your news site is https://mynews.org/, then you should set this parameter to `https://mynews.org/` ## talk-plugin-notifications @@ -148,4 +148,4 @@ anything. You need to enable one of the `talk-plugin-notifications-category-*` p Source: [plugins/talk-plugin-notifications-category-reply](https://github.com/coralproject/talk/tree/master/plugins/talk-plugin-notifications-category-reply){:target="_blank"} Replies made to each user will trigger an email to be sent with the notification -details if enabled. \ No newline at end of file +details if enabled. diff --git a/graph/loaders/actions.js b/graph/loaders/actions.js index 96e45b0e3..b5ea31a3f 100644 --- a/graph/loaders/actions.js +++ b/graph/loaders/actions.js @@ -1,54 +1,182 @@ const DataLoader = require('dataloader'); - const util = require('./util'); - -const ActionsService = require('../../services/actions'); -const ActionModel = require('../../models/action'); +const { first, get, merge, remove, groupBy, reduce } = require('lodash'); /** * Gets actions based on their item id's. */ -const genActionsByItemID = (_, item_ids) => { - return ActionsService.findByItemIdArray(item_ids).then( +const genActionsByItemID = ( + { connectors: { services: { Actions } } }, + item_ids +) => { + return Actions.findByItemIdArray(item_ids).then( util.arrayJoinBy(item_ids, 'item_id') ); }; /** - * Looks up actions based on the requested id's all bounded by the user. - * @param {Object} context the context of the request - * @param {Array} ids array of id's to get - * @return {Promise} resolves to the promises of the requested actions + * Looks up the actions for each of the items. + * + * @param {Object} ctx the graph context of the request + * @param {Array} itemIDs the items that we need to get the actions for */ -const genActionSummariessByItemID = ({ user = {} }, item_ids) => { - return ActionsService.getActionSummaries(item_ids, user.id).then( - util.arrayJoinBy(item_ids, 'item_id') +const genActionsAuthoredWithID = ( + { user = {}, connectors: { services: { Actions } } }, + itemIDs +) => + Actions.getUserActions(user.id, itemIDs).then( + util.arrayJoinBy(itemIDs, 'item_id') ); -}; /** - * Search for actions based on their action_type and item_type and ensures that - * the actions returned have unique item id's. - * @param {String} action_type the action to search by - * @param {String} item_type the item id to search by - * @return {Promise} resolves to distinct items actions + * iterateActionCounts will create an iterable object that can be used to + * compute action summaries. + * + * @param {Object} action_counts the action count object */ -const getItemIdsByActionTypeAndItemType = (_, action_type, item_type) => { - return ActionModel.distinct('item_id', { action_type, item_type }); +const iterateActionCounts = action_counts => + Object.keys(action_counts).map(action_type => ({ + count: action_counts[action_type], + action_type: action_type.toUpperCase(), + })); + +/** + * getUserActions will get the actions made by the user for this specific + * item. + * + * @param {Object} ctx the graph context of the request + * @param {Object} item the item that we're getting the actions for + */ +async function getUserActions(ctx, { action_counts, id }) { + const { loaders: { Actions } } = ctx; + + // Get the total count for all action types. + const totalActionCount = reduce( + action_counts, + (total, count) => total + count, + 0 + ); + + // Check to see if there are any user actions to get. + const hasUserActions = ctx.user && totalActionCount > 0; + if (!hasUserActions) { + return {}; + } + + // Possibly get the list of user actions completed by the user. This will be + // used later to join together with the action summaries to provide context. + const userActions = await Actions.getAuthoredByID.load(id); + if (userActions.length === 0) { + return {}; + } + + // Group the user actions in the same way that the action counts are + // grouped. This will let us extract it easy. + return reduce( + groupBy(userActions, ({ action_type, group_id }) => + (group_id ? `${action_type}_${group_id}` : action_type).toUpperCase() + ), + (allUserActions, userActions, actionType) => + merge(allUserActions, { [actionType]: first(userActions) }), + {} + ); +} + +// This will match any action count that is specific for a group id. +const nonGroupIDTest = /^([A-Z]+)_([A-Z_]+)$/; + +/** + * resolveActionSummariesForItem will resolve the action summaries for an item. + * + * @param {Object} ctx the graph context of the request + * @param {Object} item the item that we are resolving an action summary for + */ +async function resolveActionSummariesForItem(ctx, { id, action_counts }) { + // Cache all those entries for which we got the group id of, because we + // don't want to include them twice. + const groupIDCache = {}; + + // Get the user actions for this specific item. + const groupedUserActions = await getUserActions(ctx, { id, action_counts }); + + // Generate the action summaries for the item. + return iterateActionCounts(action_counts).reduce( + (actionTypeList, { count, action_type }) => { + // Get the current user's actions (if they have any). + const current_user = get(groupedUserActions, action_type, null); + + // Check to see if this is a action without a corresponding group id. + if (nonGroupIDTest.test(action_type)) { + // This action type does have a group id associated with it. + const results = nonGroupIDTest.exec(action_type); + const groupActionType = results[1]; + const groupID = results[2]; + + // Purge out the summary if it already exists, and mark that this + // group id has been found so we don't include it in the future. + remove( + actionTypeList, + ({ action_type }) => action_type === groupActionType + ); + groupIDCache[groupActionType] = true; + + // Push the new entry in. + actionTypeList.push({ + action_type: groupActionType, + group_id: groupID, + count, + current_user, + }); + } else { + // This does not have a group id. Check to see if this group id + // already has an specific (group id) entry. + if (groupIDCache[action_type]) { + // It does. Don't add anything. + return actionTypeList; + } + + // It does not, add the entry. + actionTypeList.push({ + action_type, + group_id: null, + count, + current_user, + }); + } + + return actionTypeList; + }, + [] + ); +} + +/** + * Looks up the action summaries for a set of items. + * + * @param {Object} ctx the graph context of the request + * @param {Array} items the items that should have their items looked up for + */ +const genActionSummariesByItem = async (ctx, items) => { + // This is designed to match the action_counts value that is embedded on + // documents which cache action counts. For users that are not logged in, we + // don't need to hit the actions collection at all! + + // We will literate over all the items that we're comparing. + return items.map(item => resolveActionSummariesForItem(ctx, item)); }; /** * Creates a set of loaders based on a GraphQL context. - * @param {Object} context the context of the GraphQL request + * @param {Object} ctx the context of the GraphQL request * @return {Object} object of loaders */ -module.exports = context => ({ +module.exports = ctx => ({ Actions: { - getByID: new DataLoader(ids => genActionsByItemID(context, ids)), - getSummariesByItemID: new DataLoader(ids => - genActionSummariessByItemID(context, ids) + getByID: new DataLoader(ids => genActionsByItemID(ctx, ids)), + getSummariesByItem: new DataLoader( + items => genActionSummariesByItem(ctx, items), + { cacheKeyFn: ({ id }) => id } ), - getByTypes: ({ action_type, item_type }) => - getItemIdsByActionTypeAndItemType(context, action_type, item_type), + getAuthoredByID: new DataLoader(ids => genActionsAuthoredWithID(ctx, ids)), }, }); diff --git a/graph/loaders/comments.js b/graph/loaders/comments.js index 7f0dbea00..37d40a219 100644 --- a/graph/loaders/comments.js +++ b/graph/loaders/comments.js @@ -4,7 +4,10 @@ const { SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS, SEARCH_OTHERS_COMMENTS, } = require('../../perms/constants'); -const { CACHE_EXPIRY_COMMENT_COUNT } = require('../../config'); +const { + CACHE_EXPIRY_COMMENT_COUNT, + ALLOW_NO_LIMIT_QUERIES, +} = require('../../config'); const ms = require('ms'); const sc = require('snake-case'); @@ -148,12 +151,11 @@ const getCommentCountByQuery = (ctx, options) => { * @param {Object} params the params from the client describing the query */ const getStartCursor = (ctx, nodes, { cursor, sortBy }) => { - switch (sortBy) { - case 'CREATED_AT': - return nodes.length ? nodes[0].created_at : null; - case 'REPLIES': - // The cursor is the start! This is using numeric pagination. - return cursor != null ? cursor : 0; + if (sortBy === 'CREATED_AT') { + return nodes.length ? nodes[0].created_at : null; + } else if (sortBy === 'REPLIES') { + // The cursor is the start! This is using numeric pagination. + return cursor != null ? cursor : 0; } const SORT_KEY = sortBy.toLowerCase(); @@ -181,11 +183,10 @@ const getStartCursor = (ctx, nodes, { cursor, sortBy }) => { * @param {Object} params the params from the client describing the query */ const getEndCursor = (ctx, nodes, { cursor, sortBy }) => { - switch (sortBy) { - case 'CREATED_AT': - return nodes.length ? nodes[nodes.length - 1].created_at : null; - case 'REPLIES': - return nodes.length ? (cursor != null ? cursor : 0) + nodes.length : null; + if (sortBy === 'CREATED_AT') { + return nodes.length ? nodes[nodes.length - 1].created_at : null; + } else if (sortBy === 'REPLIES') { + return nodes.length ? (cursor != null ? cursor : 0) + nodes.length : null; } const SORT_KEY = sortBy.toLowerCase(); @@ -212,36 +213,33 @@ const getEndCursor = (ctx, nodes, { cursor, sortBy }) => { * @param {Object} params the params from the client describing the query */ const applySort = (ctx, query, { cursor, sortOrder, sortBy }) => { - switch (sortBy) { - case 'CREATED_AT': { - if (cursor) { - if (sortOrder === 'DESC') { - query = query.where({ - created_at: { - $lt: cursor, - }, - }); - } else { - query = query.where({ - created_at: { - $gt: cursor, - }, - }); - } + if (sortBy === 'CREATED_AT') { + if (cursor) { + if (sortOrder === 'DESC') { + query = query.where({ + created_at: { + $lt: cursor, + }, + }); + } else { + query = query.where({ + created_at: { + $gt: cursor, + }, + }); } - - return query.sort({ created_at: sortOrder === 'DESC' ? -1 : 1 }); } - case 'REPLIES': { - if (cursor) { - query = query.skip(cursor); - } - return query.sort({ - reply_count: sortOrder === 'DESC' ? -1 : 1, - created_at: sortOrder === 'DESC' ? -1 : 1, - }); + return query.sort({ created_at: sortOrder === 'DESC' ? -1 : 1 }); + } else if (sortBy === 'REPLIES') { + if (cursor) { + query = query.skip(cursor); } + + return query.sort({ + reply_count: sortOrder === 'DESC' ? -1 : 1, + created_at: sortOrder === 'DESC' ? -1 : 1, + }); } const SORT_KEY = sortBy.toLowerCase(); @@ -280,7 +278,7 @@ const executeWithSort = async ( query = applySort(ctx, query, { cursor, sortOrder, sortBy }); // Apply the limit (if it exists, as it's applied universally). - if (limit) { + if (limit >= 0) { query = query.limit(limit + 1); } @@ -290,7 +288,7 @@ const executeWithSort = async ( // The hasNextPage is always handled the same (ask for one more than we need, // if there is one more, than there is more). let hasNextPage = false; - if (limit && nodes.length > limit) { + if (limit >= 0 && nodes.length > limit) { // There was one more than we expected! Set hasNextPage = true and remove // the last item from the array that we requested. hasNextPage = true; @@ -302,11 +300,9 @@ const executeWithSort = async ( return { startCursor: getStartCursor(ctx, nodes, { cursor, - sortOrder, sortBy, - limit, }), - endCursor: getEndCursor(ctx, nodes, { cursor, sortOrder, sortBy, limit }), + endCursor: getEndCursor(ctx, nodes, { cursor, sortBy }), hasNextPage, nodes, }; @@ -338,6 +334,11 @@ const getCommentsByQuery = async ( ) => { let comments = CommentModel.find(); + // Enforce that the limit must be gte 0 if this option is not true. + if (!ALLOW_NO_LIMIT_QUERIES && limit < 0) { + throw new Error('cannot query for limit < 0'); + } + // If user queries for statuses other than NONE and/or ACCEPTED statuses, it needs // special privileges. if ( diff --git a/graph/resolvers/comment.js b/graph/resolvers/comment.js index 1a36bab0c..7005701ec 100644 --- a/graph/resolvers/comment.js +++ b/graph/resolvers/comment.js @@ -35,12 +35,12 @@ const Comment = { actions({ id }, _, { loaders: { Actions } }) { return Actions.getByID.load(id); }, - action_summaries({ id, action_summaries }, _, { loaders: { Actions } }) { - if (action_summaries) { - return action_summaries; + action_summaries(comment, _, { loaders: { Actions } }) { + if (comment.action_summaries) { + return comment.action_summaries; } - return Actions.getSummariesByItemID.load(id); + return Actions.getSummariesByItem.load(comment); }, asset({ asset_id }, _, { loaders: { Assets } }) { return Assets.getByID.load(asset_id); diff --git a/graph/resolvers/user.js b/graph/resolvers/user.js index c18c3ce97..46fe0ac3c 100644 --- a/graph/resolvers/user.js +++ b/graph/resolvers/user.js @@ -16,8 +16,8 @@ const { const { property } = require('lodash'); const User = { - action_summaries({ id }, _, { loaders: { Actions } }) { - return Actions.getSummariesByItemID.load(id); + action_summaries(user, _, { loaders: { Actions } }) { + return Actions.getSummariesByItem.load(user); }, actions({ id }, _, { loaders: { Actions } }) { return Actions.getByID.load(id); diff --git a/locales/en.yml b/locales/en.yml index 1494ae28a..8f7a3853b 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -23,6 +23,7 @@ en: click_to_confirm: "Click below to confirm your email address" confirm: "Confirm" password_reset: + mail_sent: 'If you have a registered account, a password reset link was sent to that email' set_new_password: "Change Your Password" new_password: "New Password" new_password_help: "Password must be at least 8 characters" @@ -236,6 +237,7 @@ en: password: "Password must be at least 8 characters" username: "Usernames can contain letters numbers and _ only" unexpected: "Unexpected error occurred. Sorry!" + required_field: "This field is required" temporarily_suspended: "Your account is currently suspended. It will be reactivated {0}. Please contact us if you have any questions." flag_comment: "Report comment" flag_reason: "Reason for reporting (Optional)" diff --git a/locales/es.yml b/locales/es.yml index 76599de73..28e12ac3f 100644 --- a/locales/es.yml +++ b/locales/es.yml @@ -27,7 +27,7 @@ es: new_password: "New Password" new_password_help: "Password must be at least 8 characters" confirm_new_password: "Confirm New Password" - change_password: "Change Password" + change_password: "Change Password" characters_remaining: "carácteres restantes" comment: anon: Anónimo @@ -235,6 +235,7 @@ es: organization_name: "El nombre de la organización debe contener letras y/o números." password: "La contraseña debe tener por lo menos 8 caracteres" username: "Los nombres pueden contener letras números y _" + required_field: "Este campo es requerido" unexpected: "Lo siento. Ha habido un error no previsto." temporarily_suspended: "Your account is currently suspended. It will be reactivated {0}. Please contact us if you have any questions." flag_comment: "Reportar este comentario" diff --git a/locales/fr.yml b/locales/fr.yml index 50d9dedb7..a922d931d 100644 --- a/locales/fr.yml +++ b/locales/fr.yml @@ -2,7 +2,7 @@ fr: your_account_has_been_suspended: Your account has been temporarily suspended. your_account_has_been_banned: Your account has been banned. your_username_has_been_rejected: Your account has been suspended because your username has been deemed inappropriate. To restore your account please enter a new username. - embed_comments_tab: Comments + embed_comments_tab: Comments bandialog: are_you_sure: "Êtes-vous sûr de vouloir bannir {0}?" ban_user: "Bannir l'utilisateur ?" @@ -235,6 +235,7 @@ fr: organization_name: "Le nom de l'organisation ne peut contenir que des lettres ou des chiffres." password: "Le mot de passe doit être d'au moins 8 caractères" username: "Les noms d'utilisateur ne peuvent contenir que des chiffres, des lettres et \"_\"" + required_field: "Ce champ est obligatoire" unexpected: "Unexpected error occurred. Sorry!" temporarily_suspended: "Your account is currently suspended. It will be reactivated {0}. Please contact us if you have any questions." flag_comment: "Signaler un commentaire" diff --git a/locales/nl_NL.yml b/locales/nl_NL.yml index a17893bbc..f6be0ef6c 100644 --- a/locales/nl_NL.yml +++ b/locales/nl_NL.yml @@ -62,10 +62,10 @@ nl_NL: reactions: 'reacties' story: 'Verhaal' flagged_usernames: - notify_approved: '{0} approved username {1}' - notify_rejected: '{0} rejected username {1}' - notify_flagged: '{0} reported username {1}' - notify_changed: 'user {0} changed their username to {1}' + notify_approved: '{0} heeft gebruikersnaam {1} goedgekeurd' + notify_rejected: '{0} heeft gebruikersnaam {1} afgekeurd' + notify_flagged: '{0} heeft gebruikersnaam {1} gerapporteerd' + notify_changed: 'gebruiker {0} heeft zijn/haar gebruikersnaam veranderd naar {1}' community: account_creation_date: "Aanmaakdatum account" active: Actief @@ -256,7 +256,7 @@ nl_NL: label: "Nieuwe gebruikersnaam" msg: "Je account is momenteel geschorst omdat je gebruikersnaam ongepast is bevonden. Om je account te herstellen, moet je je gebruikersnaam herstellen. Neem contact met ons op wanneer je vragen hebt." changed_name: - msg: "Your username change is under review by our moderation team." + msg: "De verandering van je gebruikersnaam wordt door ons moderatieteam gecontroleerd." my_comments: "Mijn reacties" my_profile: "Mijn profiel" new_count: "Bekijk {0} meer {1}" @@ -344,7 +344,7 @@ nl_NL: no_agree_comment: "Ik ben het niet eens met deze reactie" no_like_bio: "Ik vind deze biografie niet leuk" no_like_username: "Ik vind deze gebruikersnaam niet leuk" - already_flagged_username: "You have already flagged this username." + already_flagged_username: "Je hebt deze gebruikersnaam al gemeld." other: Ander permalink: Deel personal_info: "Deze reactie onthult persoonlijk identificeerbare gegevens." @@ -420,18 +420,18 @@ nl_NL: user_bio: "Gebruikers biografie" username_flags: "Meldingen voor deze gebruikersnaam" user_detail: - remove_suspension: "Remove Suspension" - suspend: "Suspend User" - remove_ban: "Remove Ban" - ban: "Ban User" - member_since: "Member Since" + remove_suspension: "Schorsing opheffen" + suspend: "Gebruiker schorsen" + remove_ban: "Verbanning verwijderen" + ban: "Gebruiker verbannen" + member_since: "Lid sinds" email: "Email" - total_comments: "Total Comments" - reject_rate: "Reject Rate" - reports: "Reports" - all: "All" - rejected: "Rejected" - account_history: "Account History" + total_comments: "Totaal aantal reacties" + reject_rate: "Beoordeling verwijderen" + reports: "Rapportages" + all: "Alle" + rejected: "Afgewezen" + account_history: "Accountgeschiedenis" user_impersonating: "Deze gebruiker imiteert" user_no_comment: "Je hebt nog niet eerder gereageerd. Laat je mening horen!" username_offensive: "Dit is een aanstootgevende gebruikersnaam." @@ -459,5 +459,5 @@ nl_NL: launch: "Start Talk" close: "Installatie afsluiten" admin_sidebar: - view_options: "View Options" - sort_comments: "Sort Comments" + view_options: "Bekijk opties" + sort_comments: "Sorteer reacties" diff --git a/locales/zh_CN.yml b/locales/zh_CN.yml index 83f9cb7c2..3108addf6 100644 --- a/locales/zh_CN.yml +++ b/locales/zh_CN.yml @@ -236,6 +236,7 @@ zh_CN: password: "密码长度须至少为 8 字符" username: "用户名只能包含字母、数字跟下划线" unexpected: "发生了异常错误。对不起!" + required_field: "该字段必填" temporarily_suspended: "Your account is currently suspended. It will be reactivated {0}. Please contact us if you have any questions." flag_comment: "举报评论" flag_reason: "举报理由(可选)" diff --git a/locales/zh_TW.yml b/locales/zh_TW.yml index d515f5c56..c6698fec4 100644 --- a/locales/zh_TW.yml +++ b/locales/zh_TW.yml @@ -236,6 +236,7 @@ zh_TW: password: "密碼必須至少8個字符" username: "用戶名只能包含字母、數字和下劃線。" unexpected: "發生了意外錯誤。抱歉!" + required_field: "該字段必填" temporarily_suspended: "Your account is currently suspended. It will be reactivated {0}. Please contact us if you have any questions." flag_comment: "舉報評論" flag_reason: "舉報原因(可選)" diff --git a/package.json b/package.json index 591ccbc41..bc4f38a2d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "talk", - "version": "4.1.0", + "version": "4.2.0", "description": "A better commenting experience from Mozilla, The New York Times, and the Washington Post. https://coralproject.net", "main": "app.js", "private": true, @@ -120,8 +120,8 @@ "inquirer": "^3.2.2", "inquirer-autocomplete-prompt": "^0.12.1", "ioredis": "3.1.4", - "joi": "^10.6.0", - "jsonwebtoken": "^7.4.3", + "joi": "^13.0.0", + "jsonwebtoken": "^8.0.0", "jwt-decode": "^2.2.0", "keymaster": "^1.6.2", "kue": "0.11.6", @@ -150,7 +150,7 @@ "postcss-smart-import": "^0.5.1", "precss": "1.4.0", "prop-types": "^15.5.10", - "pym.js": "^1.1.1", + "pym.js": "^1.3.2", "query-string": "^5.0.0", "react": "^15.4.2", "react-apollo": "^1.4.12", diff --git a/plugin-api/beta/client/actions/auth.js b/plugin-api/beta/client/actions/auth.js new file mode 100644 index 000000000..85e2bd844 --- /dev/null +++ b/plugin-api/beta/client/actions/auth.js @@ -0,0 +1,5 @@ +export { + setAuthToken, + handleSuccessfulLogin, + logout, +} from 'coral-framework/actions/auth'; diff --git a/plugin-api/beta/client/actions/stream.js b/plugin-api/beta/client/actions/stream.js index 2751f4a17..bb3d465d9 100644 --- a/plugin-api/beta/client/actions/stream.js +++ b/plugin-api/beta/client/actions/stream.js @@ -1 +1,2 @@ export { setSort } from 'coral-embed-stream/src/actions/stream'; +export { showSignInDialog } from 'coral-embed-stream/src/actions/login'; diff --git a/plugin-api/beta/client/components/index.js b/plugin-api/beta/client/components/index.js index 974c4609f..fc6159544 100644 --- a/plugin-api/beta/client/components/index.js +++ b/plugin-api/beta/client/components/index.js @@ -26,3 +26,4 @@ export { export { default as StreamConfiguration, } from 'coral-framework/components/StreamConfiguration'; +export { default as Recaptcha } from 'coral-framework/components/Recaptcha'; diff --git a/plugin-api/beta/client/hocs/index.js b/plugin-api/beta/client/hocs/index.js index 0d7cac460..397d8b94d 100644 --- a/plugin-api/beta/client/hocs/index.js +++ b/plugin-api/beta/client/hocs/index.js @@ -1,10 +1,17 @@ export { default as withReaction } from './withReaction'; export { default as withTags } from './withTags'; export { default as withSortOption } from './withSortOption'; -export { default as withFragments } from 'coral-framework/hocs/withFragments'; -export { default as excludeIf } from 'coral-framework/hocs/excludeIf'; -export { default as connect } from 'coral-framework/hocs/connect'; -export { default as withEmit } from 'coral-framework/hocs/withEmit'; +export { + connect, + withEmit, + excludeIf, + withFragments, + withForgotPassword, + withSignIn, + withSignUp, + withResendEmailConfirmation, + withSetUsername, +} from 'coral-framework/hocs'; export { withIgnoreUser, withBanUser, diff --git a/plugin-api/beta/client/hocs/withReaction.js b/plugin-api/beta/client/hocs/withReaction.js index 6020d4002..95d3f5bcf 100644 --- a/plugin-api/beta/client/hocs/withReaction.js +++ b/plugin-api/beta/client/hocs/withReaction.js @@ -15,8 +15,7 @@ import * as PropTypes from 'prop-types'; import { getDefinitionName } from '../utils'; import { t, can } from 'plugin-api/beta/client/services'; -// TODO: Auth logic needs refactoring. -import { showSignInDialog } from 'coral-embed-stream/src/actions/auth'; +import { showSignInDialog } from 'coral-embed-stream/src/actions/login'; /* * Disable false-positive warning below, as it doesn't work well with how we currently diff --git a/plugin-api/beta/client/selectors/auth.js b/plugin-api/beta/client/selectors/auth.js new file mode 100644 index 000000000..a82e11aab --- /dev/null +++ b/plugin-api/beta/client/selectors/auth.js @@ -0,0 +1,8 @@ +import get from 'lodash/get'; + +export const usernameStatusSelector = state => + get(state, 'auth.user.status.username.status'); + +export const usernameSelector = state => get(state, 'auth.user.username'); + +export const isLoggedInSelector = state => !!get(state, 'auth.user'); diff --git a/plugins.default.json b/plugins.default.json index e6d1b23cb..066c94478 100644 --- a/plugins.default.json +++ b/plugins.default.json @@ -1,7 +1,6 @@ { "server": [ "talk-plugin-auth", - "talk-plugin-facebook-auth", "talk-plugin-featured-comments", "talk-plugin-offtopic", "talk-plugin-respect" diff --git a/plugins/talk-plugin-akismet/client/components/SpamDetail.js b/plugins/talk-plugin-akismet/client/components/SpamDetail.js index dafdb5a86..951622e34 100644 --- a/plugins/talk-plugin-akismet/client/components/SpamDetail.js +++ b/plugins/talk-plugin-akismet/client/components/SpamDetail.js @@ -5,7 +5,7 @@ import { t } from 'plugin-api/beta/client/services'; const SpamLabel = () => ( diff --git a/plugins/talk-plugin-akismet/client/components/SpamLabel.js b/plugins/talk-plugin-akismet/client/components/SpamLabel.js index c2033ff39..17c9b53ef 100644 --- a/plugins/talk-plugin-akismet/client/components/SpamLabel.js +++ b/plugins/talk-plugin-akismet/client/components/SpamLabel.js @@ -3,7 +3,7 @@ import { FlagLabel } from 'plugin-api/beta/client/components/ui'; import { t } from 'plugin-api/beta/client/services'; const SpamLabel = () => ( - {t('talk-plugin-akismet.spam')} + {t('talk-plugin-akismet.spam')} ); export default SpamLabel; diff --git a/plugins/talk-plugin-akismet/client/containers/SpamCommentDetail.js b/plugins/talk-plugin-akismet/client/containers/SpamCommentDetail.js index a133be062..a49fd89ca 100644 --- a/plugins/talk-plugin-akismet/client/containers/SpamCommentDetail.js +++ b/plugins/talk-plugin-akismet/client/containers/SpamCommentDetail.js @@ -5,7 +5,7 @@ import { isSpam } from '../utils'; const enhance = compose( excludeIf( - ({ comment: { spam, actions } }) => spam === null || isSpam(actions) + ({ comment: { spam, actions } }) => spam === null || !isSpam(actions) ) ); diff --git a/plugins/talk-plugin-akismet/client/containers/SpamCommentFlagDetail.js b/plugins/talk-plugin-akismet/client/containers/SpamCommentFlagDetail.js deleted file mode 100644 index a49fd89ca..000000000 --- a/plugins/talk-plugin-akismet/client/containers/SpamCommentFlagDetail.js +++ /dev/null @@ -1,12 +0,0 @@ -import { compose } from 'react-apollo'; -import { excludeIf } from 'plugin-api/beta/client/hocs'; -import SpamDetail from './SpamDetail'; -import { isSpam } from '../utils'; - -const enhance = compose( - excludeIf( - ({ comment: { spam, actions } }) => spam === null || !isSpam(actions) - ) -); - -export default enhance(SpamDetail); diff --git a/plugins/talk-plugin-akismet/client/index.js b/plugins/talk-plugin-akismet/client/index.js index 0eca7ab85..7170e14fb 100644 --- a/plugins/talk-plugin-akismet/client/index.js +++ b/plugins/talk-plugin-akismet/client/index.js @@ -2,7 +2,6 @@ import translations from './translations.yml'; import CheckSpamHook from './containers/CheckSpamHook'; import SpamLabel from './containers/SpamLabel'; import SpamCommentDetail from './containers/SpamCommentDetail'; -import SpamCommentFlagDetail from './containers/SpamCommentFlagDetail'; export default { translations, @@ -10,6 +9,5 @@ export default { commentInputDetailArea: [CheckSpamHook], adminCommentLabels: [SpamLabel], adminCommentMoreDetails: [SpamCommentDetail], - adminCommentMoreFlagDetails: [SpamCommentFlagDetail], }, }; diff --git a/plugins/talk-plugin-akismet/client/translations.yml b/plugins/talk-plugin-akismet/client/translations.yml index 0d9b30a3e..6cc19a492 100644 --- a/plugins/talk-plugin-akismet/client/translations.yml +++ b/plugins/talk-plugin-akismet/client/translations.yml @@ -61,18 +61,18 @@ fr: nl_NL: error: COMMENT_IS_SPAM: | - The language in this comment looks like spam. You can - edit the comment or submit it anyway for moderator review. + Deze reactie ziet er uit als spam. Je kan je reactie + wijzigen, of indienen zodat ons moderatieteam het kan beoordelen. talk-plugin-akismet: spam: "Spam" spam_comment: "Spam" - detected: "Detected by Akismet" + detected: "Gedetecteerd door Akismet" still_spam: | - Thank you. Our moderation team will review your comment shortly. + Dank je wel. Ons moderatieteam zal je reactie beoordelen. flags: reasons: comment: - spam_comment: "Detected Spam" + spam_comment: "Spam gedetecteerd" pt_BR: error: COMMENT_IS_SPAM: | diff --git a/plugins/talk-plugin-auth/client/components/ChangeUsername.js b/plugins/talk-plugin-auth/client/components/ChangeUsername.js deleted file mode 100644 index e814a9d05..000000000 --- a/plugins/talk-plugin-auth/client/components/ChangeUsername.js +++ /dev/null @@ -1,183 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; -import { compose } from 'react-apollo'; -import { bindActionCreators } from 'redux'; -import errorMsj from 'coral-framework/helpers/error'; -import validate from 'coral-framework/helpers/validate'; -import CreateUsernameDialog from './CreateUsernameDialog'; -import { withSetUsername } from 'coral-framework/graphql/mutations'; -import { forEachError } from 'plugin-api/beta/client/utils'; - -import t from 'coral-framework/services/i18n'; - -import { - showCreateUsernameDialog, - hideCreateUsernameDialog, - invalidForm, - validForm, - updateUsername, -} from 'coral-embed-stream/src/actions/auth'; - -class ChangeUsernameContainer extends React.Component { - constructor(props) { - super(props); - - this.state = { - formData: { - username: (props.auth.user && props.auth.user.username) || '', - }, - errors: {}, - showErrors: false, - }; - } - - componentWillReceiveProps(next) { - if ( - !this.props.auth.showCreateUsernameDialog && - next.auth.showCreateUsernameDialog - ) { - this.setState({ - formData: { - username: - (this.props.auth.user && this.props.auth.user.username) || '', - }, - }); - } - } - - handleChange = e => { - const { name, value } = e.target; - this.setState( - state => ({ - ...state, - formData: { - ...state.formData, - [name]: value, - }, - }), - () => { - this.validation(name, value); - } - ); - }; - - addError = (name, error) => { - return this.setState(state => ({ - errors: { - ...state.errors, - [name]: error, - }, - })); - }; - - validation = (name, value) => { - const { addError } = this; - - if (!value.length) { - addError(name, t('createdisplay.required_field')); - } else if (!validate[name](value)) { - addError(name, errorMsj[name]); - } else { - const {[name]: prop, ...errors} = this.state.errors; // eslint-disable-line - // Removes Error - this.setState(state => ({ ...state, errors })); - } - }; - - isCompleted = () => { - const { formData } = this.state; - return !Object.keys(formData).filter(prop => !formData[prop].length).length; - }; - - displayErrors = (show = true) => { - this.setState({ showErrors: show }); - }; - - async setUsernameAndClose(username, props = this.props) { - const { - validForm, - invalidForm, - setUsername, - hideCreateUsernameDialog, - updateUsername, - } = props; - try { - // Perform mutation - await setUsername(this.props.auth.user.id, username); - - // Also change in redux store... - updateUsername(username); - - hideCreateUsernameDialog(); - validForm(); - } catch (error) { - const msgs = []; - forEachError(error, ({ msg }) => msgs.push(msg)); - invalidForm(t(msgs.join(', '))); - } - } - - handleSubmitUsername = e => { - e.preventDefault(); - const { errors, formData: { username } } = this.state; - const { invalidForm } = this.props; - this.displayErrors(); - if (this.isCompleted() && !Object.keys(errors).length) { - this.setUsernameAndClose(username); - } else { - invalidForm(t('createdisplay.check_the_form')); - } - }; - - handleClose = () => { - this.setUsernameAndClose(this.props.auth.user.username); - }; - - render() { - const { loggedIn, auth } = this.props; - return ( -
- -
- ); - } -} - -ChangeUsernameContainer.propTypes = { - auth: PropTypes.object, - hideCreateUsernameDialog: PropTypes.func, - validForm: PropTypes.func, - invalidForm: PropTypes.func, - loggedIn: PropTypes.bool, - changeUsername: PropTypes.func, -}; - -const mapStateToProps = ({ auth }) => ({ - auth: auth, -}); - -const mapDispatchToProps = dispatch => - bindActionCreators( - { - showCreateUsernameDialog, - hideCreateUsernameDialog, - invalidForm, - validForm, - updateUsername, - }, - dispatch - ); - -export default compose( - withSetUsername, - connect(mapStateToProps, mapDispatchToProps) -)(ChangeUsernameContainer); diff --git a/plugins/talk-plugin-auth/client/components/CreateUsernameDialog.js b/plugins/talk-plugin-auth/client/components/CreateUsernameDialog.js deleted file mode 100644 index 08ddc171e..000000000 --- a/plugins/talk-plugin-auth/client/components/CreateUsernameDialog.js +++ /dev/null @@ -1,83 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import styles from './styles.css'; -import { - Dialog, - Alert, - TextField, - Button, -} from 'plugin-api/beta/client/components/ui'; -import { FakeComment } from './FakeComment'; -import t from 'coral-framework/services/i18n'; - -const CreateUsernameDialog = ({ - open, - handleClose, - formData, - handleSubmitUsername, - handleChange, - ...props -}) => ( - - - × - -
-
-

{t('createdisplay.write_your_username')}

-
-
-

- {t('createdisplay.your_username')} -

- -

- {t('createdisplay.if_you_dont_change_your_name')} -

- {props.auth.error && {props.auth.error}} -
- {props.errors.username && ( - - {' '} - {t('createdisplay.special_characters')}{' '} - - )} -
- - -
-
-
-
-
-); - -CreateUsernameDialog.propTypes = { - open: PropTypes.bool, - handleClose: PropTypes.func, - formData: PropTypes.object, - handleSubmitUsername: PropTypes.func, - handleChange: PropTypes.func, - auth: PropTypes.object, - errors: PropTypes.object, -}; - -export default CreateUsernameDialog; diff --git a/plugins/talk-plugin-auth/client/components/ForgotContent.js b/plugins/talk-plugin-auth/client/components/ForgotContent.js deleted file mode 100644 index bfadbb001..000000000 --- a/plugins/talk-plugin-auth/client/components/ForgotContent.js +++ /dev/null @@ -1,81 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import styles from './styles.css'; -import { Button, TextField } from 'plugin-api/beta/client/components/ui'; -import t from 'coral-framework/services/i18n'; - -class ForgotContent extends React.Component { - state = { value: '' }; - - handleSubmit = e => { - e.preventDefault(); - this.props.fetchForgotPassword(this.state.value); - }; - - handleChangeEmail = e => { - const { value } = e.target; - this.setState({ value }); - }; - - render() { - const { changeView, auth } = this.props; - const { passwordRequestSuccess, passwordRequestFailure } = auth; - - return ( -
-
-

{t('sign_in.recover_password')}

-
-
-
- -
- - {passwordRequestSuccess ? ( -

- {passwordRequestSuccess} -

- ) : null} - {passwordRequestFailure ? ( -

- {passwordRequestFailure} -

- ) : null} -
-
- - {t('sign_in.need_an_account')}{' '} - changeView('SIGNUP')}>{t('sign_in.register')} - - - {t('sign_in.already_have_an_account')}{' '} - changeView('SIGNIN')}>{t('sign_in.sign_in')} - -
-
- ); - } -} - -ForgotContent.propTypes = { - auth: PropTypes.object, - changeView: PropTypes.func, - fetchForgotPassword: PropTypes.func, -}; - -export default ForgotContent; diff --git a/plugins/talk-plugin-auth/client/components/ResendVerification.js b/plugins/talk-plugin-auth/client/components/ResendVerification.js deleted file mode 100644 index d2d71dd45..000000000 --- a/plugins/talk-plugin-auth/client/components/ResendVerification.js +++ /dev/null @@ -1,54 +0,0 @@ -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 ( -
-

{t('sign_in.email_verify_cta')}

- - {error && ( - - {error.translation_key - ? t(`error.${error.translation_key}`) - : error.toString()} - - )} -
- {t('error.email_not_verified', email)} -
-
- - {loading && } - {success && } -
-
- ); - } -} - -ResendVerification.propTypes = { - resendVerification: PropTypes.bool.isRequired, - error: PropTypes.object, - loading: PropTypes.bool, - success: PropTypes.bool, - email: PropTypes.string.isRequired, -}; - -export default ResendVerification; diff --git a/plugins/talk-plugin-auth/client/components/SignDialog.js b/plugins/talk-plugin-auth/client/components/SignDialog.js deleted file mode 100644 index a33baeb99..000000000 --- a/plugins/talk-plugin-auth/client/components/SignDialog.js +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react'; -import { Dialog } from 'plugin-api/beta/client/components/ui'; -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, resetSignInDialog, ...props }) => ( - - {view !== 'SIGNIN' && ( - - × - - )} - {view === 'SIGNIN' && } - {view === 'SIGNUP' && } - {view === 'FORGOT' && } - {view === 'RESEND_VERIFICATION' && ( - - )} - -); - -export default SignDialog; diff --git a/plugins/talk-plugin-auth/client/components/SignInButton.js b/plugins/talk-plugin-auth/client/components/SignInButton.js deleted file mode 100644 index 163a5e5f8..000000000 --- a/plugins/talk-plugin-auth/client/components/SignInButton.js +++ /dev/null @@ -1,25 +0,0 @@ -import React from 'react'; -import { Button } from 'plugin-api/beta/client/components/ui'; -import { connect } from 'react-redux'; -import { bindActionCreators } from 'redux'; -import { showSignInDialog } from 'coral-embed-stream/src/actions/auth'; -import t from 'coral-framework/services/i18n'; - -const SignInButton = ({ loggedIn, showSignInDialog }) => ( -
- {!loggedIn ? ( - - ) : null} -
-); - -const mapStateToProps = ({ auth }) => ({ - loggedIn: auth.loggedIn, -}); - -const mapDispatchToProps = dispatch => - bindActionCreators({ showSignInDialog }, dispatch); - -export default connect(mapStateToProps, mapDispatchToProps)(SignInButton); diff --git a/plugins/talk-plugin-auth/client/components/SignInContainer.js b/plugins/talk-plugin-auth/client/components/SignInContainer.js deleted file mode 100644 index 6f99f9ce6..000000000 --- a/plugins/talk-plugin-auth/client/components/SignInContainer.js +++ /dev/null @@ -1,202 +0,0 @@ -import React from 'react'; -import { connect } from 'react-redux'; -import SignDialog from './SignDialog'; -import { bindActionCreators } from 'redux'; -import t from 'coral-framework/services/i18n'; -import errorMsj from 'coral-framework/helpers/error'; -import validate from 'coral-framework/helpers/validate'; - -import { - changeView, - fetchSignUp, - fetchSignIn, - hideSignInDialog, - fetchSignInFacebook, - fetchSignUpFacebook, - fetchForgotPassword, - requestConfirmEmail, - resetSignInDialog, - facebookCallback, - invalidForm, - validForm, -} from 'coral-embed-stream/src/actions/auth'; - -class SignInContainer extends React.Component { - constructor(props) { - super(props); - - this.state = { - formData: { - email: '', - username: '', - password: '', - confirmPassword: '', - }, - errors: {}, - showErrors: false, - }; - } - - componentDidMount() { - this.listenToStorageChanges(); - const { formData } = this.state; - const errors = Object.keys(formData).reduce((map, prop) => { - map[prop] = t('sign_in.required_field'); - return map; - }, {}); - this.setState({ errors }); - } - - componentWillUnmount() { - this.unlisten(); - } - - listenToStorageChanges() { - window.addEventListener('storage', this.handleAuth); - } - - unlisten() { - window.removeEventListener('storage', this.handleAuth); - } - - handleAuth = e => { - // Listening to FB changes - // FB localStorage key is 'auth' - const authCallback = this.props.facebookCallback; - - if (e.key === 'auth') { - const { err, data } = JSON.parse(e.newValue); - authCallback(err, data); - this.unlisten(); - localStorage.removeItem('auth'); - } - }; - - handleChange = e => { - const { name, value } = e.target; - this.setState( - state => ({ - ...state, - formData: { - ...state.formData, - [name]: value, - }, - }), - () => { - this.validation(name, value); - } - ); - }; - - resendVerification = () => { - this.props.requestConfirmEmail(this.props.auth.email).then(() => { - setTimeout(() => { - // allow success UI to be shown for a second, and then close the modal - this.props.resetSignInDialog(); - }, 2500); - }); - }; - - addError = (name, error) => { - return this.setState(state => ({ - errors: { - ...state.errors, - [name]: error, - }, - })); - }; - - validation = (name, value) => { - const { addError } = this; - const { formData } = this.state; - - if (!value.length) { - addError(name, t('sign_in.required_field')); - } else if ( - name === 'confirmPassword' && - formData.confirmPassword !== formData.password - ) { - addError('confirmPassword', t('sign_in.passwords_dont_match')); - } else if (!validate[name](value)) { - addError(name, errorMsj[name]); - } else { - const {[name]: prop, ...errors} = this.state.errors; // eslint-disable-line - // Removes Error - this.setState(state => ({ ...state, errors })); - } - }; - - isCompleted = () => { - const { formData } = this.state; - return !Object.keys(formData).filter(prop => !formData[prop].length).length; - }; - - displayErrors = (show = true) => { - this.setState({ showErrors: show }); - }; - - handleSignUp = e => { - e.preventDefault(); - const { errors } = this.state; - const { fetchSignUp, validForm, invalidForm } = this.props; - this.displayErrors(); - if (this.isCompleted() && !Object.keys(errors).length) { - fetchSignUp(this.state.formData); - validForm(); - } else { - invalidForm(t('sign_in.check_the_form')); - } - }; - - handleSignIn = e => { - e.preventDefault(); - this.props.fetchSignIn(this.state.formData); - }; - - render() { - const { auth } = this.props; - const { - requireEmailConfirmation, - emailVerificationLoading, - emailVerificationSuccess, - } = auth; - - return ( - - ); - } -} - -const mapStateToProps = state => ({ - auth: state.auth, -}); - -const mapDispatchToProps = dispatch => - bindActionCreators( - { - facebookCallback, - fetchSignUp, - fetchSignIn, - fetchSignInFacebook, - fetchSignUpFacebook, - fetchForgotPassword, - requestConfirmEmail, - changeView, - hideSignInDialog, - resetSignInDialog, - invalidForm, - validForm, - }, - dispatch - ); - -export default connect(mapStateToProps, mapDispatchToProps)(SignInContainer); diff --git a/plugins/talk-plugin-auth/client/components/SignInContent.js b/plugins/talk-plugin-auth/client/components/SignInContent.js deleted file mode 100644 index e74de4ff2..000000000 --- a/plugins/talk-plugin-auth/client/components/SignInContent.js +++ /dev/null @@ -1,108 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -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, - formData, - changeView, - handleSignIn, - auth, - fetchSignInFacebook, -}) => { - return ( -
-
-

{t('sign_in.sign_in_to_join')}

-
- {auth.error && ( - - {auth.error.translation_key - ? t(`error.${auth.error.translation_key}`) - : auth.error.toString()} - - )} -
-
- -
-
-

{t('sign_in.or')}

-
-
- - -
- {!auth.isLoading ? ( - - ) : ( - - )} -
- -
- -
- ); -}; - -SignInContent.propTypes = { - auth: PropTypes.shape({ - isLoading: PropTypes.bool.isRequired, - error: PropTypes.string, - emailVerificationFailure: PropTypes.bool, - }).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, - resendVerification: PropTypes.func.isRequired, - formData: PropTypes.object, -}; - -export default SignInContent; diff --git a/plugins/talk-plugin-auth/client/components/SignUpContent.js b/plugins/talk-plugin-auth/client/components/SignUpContent.js deleted file mode 100644 index 845fe502c..000000000 --- a/plugins/talk-plugin-auth/client/components/SignUpContent.js +++ /dev/null @@ -1,143 +0,0 @@ -import styles from './styles.css'; -import React from 'react'; -import { - Button, - TextField, - Spinner, - Success, - Alert, -} from 'plugin-api/beta/client/components/ui'; -import t from 'coral-framework/services/i18n'; - -class SignUpContent extends React.Component { - componentWillReceiveProps(next) { - if ( - !this.props.emailVerificationEnabled && - !this.props.auth.successSignUp && - next.auth.successSignUp - ) { - setTimeout(() => { - this.props.changeView('SIGNIN'); - }, 2000); - } - } - - render() { - const { - handleChange, - formData, - emailVerificationEnabled, - auth, - errors, - showErrors, - changeView, - handleSignUp, - fetchSignUpFacebook, - } = this.props; - - return ( -
-
-

{t('sign_in.sign_up')}

-
- - {auth.error && {auth.error}} - {!auth.successSignUp && ( -
-
- -
-
-

{t('sign_in.or')}

-
-
- - - - {errors.password && ( - - {' '} - Password must be at least 8 characters.{' '} - - )} - -
- - {auth.isLoading && } -
- -
- )} - {auth.successSignUp && ( -
- - {emailVerificationEnabled && ( -

- {t('sign_in.verify_email')} -
-
- {t('sign_in.verify_email2')} -

- )} -
- )} -
- {t('sign_in.already_have_an_account')}{' '} - changeView('SIGNIN')}> - {t('sign_in.sign_in')} - -
-
- ); - } -} - -export default SignUpContent; diff --git a/plugins/talk-plugin-auth/client/components/UserBox.js b/plugins/talk-plugin-auth/client/components/UserBox.js deleted file mode 100644 index d601ace74..000000000 --- a/plugins/talk-plugin-auth/client/components/UserBox.js +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react'; -import styles from './styles.css'; -import { connect } from 'react-redux'; -import { bindActionCreators } from 'redux'; -import t from 'coral-framework/services/i18n'; -import { logout } from 'coral-embed-stream/src/actions/auth'; - -const UserBox = ({ loggedIn, user, logout, onShowProfile }) => ( -
- {loggedIn ? ( -
- - {t('sign_in.logged_in_as')} - - {user.username}. {t('sign_in.not_you')} - logout()} - > - {t('sign_in.logout')} - -
- ) : null} -
-); - -const mapStateToProps = ({ auth }) => ({ - loggedIn: auth.loggedIn, - user: auth.user, -}); - -const mapDispatchToProps = dispatch => bindActionCreators({ logout }, dispatch); - -export default connect(mapStateToProps, mapDispatchToProps)(UserBox); diff --git a/plugins/talk-plugin-auth/client/components/styles.css b/plugins/talk-plugin-auth/client/components/styles.css deleted file mode 100644 index 735f34d31..000000000 --- a/plugins/talk-plugin-auth/client/components/styles.css +++ /dev/null @@ -1,167 +0,0 @@ -.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: 280px; - top: 10px; -} - -.header { - margin-bottom: 20px; -} - -.header h1, .separator h1{ - text-align: center; - font-size: 1.2em; -} - -.footer { - margin: 20px auto 10px; - text-align: center; -} - -.footer span { - display: block; - margin-bottom: 5px; -} - -.footer a { - color: #2c69b6; - cursor: pointer; - margin: 0 5px; -} - -.socialConnections { - margin-bottom: 20px; -} - -.signInButton { - margin-top: 10px; - background-color: #2a2a2a; -} - -.close { - font-size: 20px; - line-height: 14px; - top: 10px; - right: 10px; - position: absolute; - display: block; - font-weight: bold; - color: #363636; - cursor: pointer; -} - -.close:hover { - color: #6b6b6b; -} - -input.error{ - border: solid 2px #f44336; -} - -.errorMsg, .hint { - color: grey; - font-weight: 600; - padding: 3px 0 16px; -} - -.userBox { - margin: 10px 0 20px; - letter-spacing: 0.1px; -} - -.userBoxLoggedIn { - font-weight: bold; -} - -.userBox a { - color: black; - font-weight: bold; - cursor: pointer; - margin: 0px; - margin-left: 4px; - padding-bottom: 2px; -} - -.userBox .logout { - border-bottom: solid 1px black; -} - -.attention { - display: inline-block; - width: 15px; - height: 15px; - background: #B71C1C; - color: #FFEBEE; - font-weight: bolder; - padding: 4px; - vertical-align: middle; - border-radius: 20px; - box-sizing: border-box; - font-size: 9px; - line-height: 7px; - text-align: center; - margin-right: 5px; -} - -.action { - margin-top: 0px; -} - -.passwordRequestSuccess { - border: 1px solid green; - background-color: lightgreen; - padding: 10px; -} - -.passwordRequestFailure { - border: 1px solid orange; - background-color: 1px solid coral; - padding: 10px; -} - -.emailConfirmDialog { - margin-top: 15px; -} - -.confirmLabel { - display: block; -} - -/* Change username Dialog*/ - -.dialogusername { - 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: 400px; - top: 10px; -} - -.yourusername { - display: block; -} - -.example { - display: block; -} - -.ifyoudont { - display: block; - margin-top: 15px; -} - -.saveusername { - display: block; - width: 100%; -} - -.savebutton { - display: inline; - background-color: rgb(105,105,105); - color: white; -} - -.fakeComment { - display: block; - margin-bottom: 5px; -} diff --git a/plugins/talk-plugin-auth/client/index.js b/plugins/talk-plugin-auth/client/index.js index 12aa8ccd5..13abce1d9 100644 --- a/plugins/talk-plugin-auth/client/index.js +++ b/plugins/talk-plugin-auth/client/index.js @@ -1,13 +1,15 @@ -import UserBox from './components/UserBox'; -import SignInButton from './components/SignInButton'; -import SignInContainer from './components/SignInContainer'; -import ChangeUserNameContainer from './components/ChangeUsername'; +import UserBox from './stream/containers/UserBox'; +import SignInButton from './stream/containers/SignInButton'; +import SetUsernameDialog from './stream/containers/SetUsernameDialog'; import translations from './translations.yml'; +import Login from './login/containers/Main'; +import reducer from './login/reducer'; export default { + reducer, translations, slots: { - stream: [UserBox, SignInButton, ChangeUserNameContainer], - login: [SignInContainer], + stream: [UserBox, SignInButton, SetUsernameDialog], + login: [Login], }, }; diff --git a/plugins/talk-plugin-auth/client/login/actions.js b/plugins/talk-plugin-auth/client/login/actions.js new file mode 100644 index 000000000..593556507 --- /dev/null +++ b/plugins/talk-plugin-auth/client/login/actions.js @@ -0,0 +1,16 @@ +import * as actions from './constants'; + +export const setView = view => ({ + type: actions.SET_VIEW, + view, +}); + +export const setEmail = email => ({ + type: actions.SET_EMAIL, + email, +}); + +export const setPassword = password => ({ + type: actions.SET_PASSWORD, + password, +}); diff --git a/plugins/talk-plugin-auth/client/login/components/External.css b/plugins/talk-plugin-auth/client/login/components/External.css new file mode 100644 index 000000000..8fe36394a --- /dev/null +++ b/plugins/talk-plugin-auth/client/login/components/External.css @@ -0,0 +1,16 @@ +.external { + margin-bottom: 20px; +} + +.separator h1{ + text-align: center; + font-size: 1.2em; +} + +.slot > * { + margin-bottom: 8px; + + &:last-child { + margin-bottom: 0px; + } +} diff --git a/plugins/talk-plugin-auth/client/login/components/External.js b/plugins/talk-plugin-auth/client/login/components/External.js new file mode 100644 index 000000000..8f3c16e59 --- /dev/null +++ b/plugins/talk-plugin-auth/client/login/components/External.js @@ -0,0 +1,25 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import styles from './External.css'; +import { Slot } from 'plugin-api/beta/client/components'; +import { IfSlotIsNotEmpty } from 'plugin-api/beta/client/components'; +import { t } from 'plugin-api/beta/client/services'; + +const External = ({ slot }) => ( + +
+
+ +
+
+

{t('talk-plugin-auth.login.or')}

+
+
+
+); + +External.propTypes = { + slot: PropTypes.string.isRequired, +}; + +export default External; diff --git a/plugins/talk-plugin-auth/client/login/components/ForgotPassword.css b/plugins/talk-plugin-auth/client/login/components/ForgotPassword.css new file mode 100644 index 000000000..ca00726c4 --- /dev/null +++ b/plugins/talk-plugin-auth/client/login/components/ForgotPassword.css @@ -0,0 +1,42 @@ +.header { + margin-bottom: 20px; +} + +.header h1, .separator h1{ + text-align: center; + font-size: 1.2em; +} + +.button { + margin-top: 10px; + background-color: #2a2a2a; +} +.footer { + margin: 20px auto 10px; + text-align: center; +} + +.footer span { + display: block; + margin-bottom: 5px; +} + +.footer a { + color: #2c69b6; + cursor: pointer; + margin: 0 5px; +} + +.success { + border: 1px solid green; + background-color: lightgreen; + padding: 10px; +} + +.failure { + border: 1px solid orange; + background-color: 1px solid coral; + padding: 10px; +} + + diff --git a/plugins/talk-plugin-auth/client/login/components/ForgotPassword.js b/plugins/talk-plugin-auth/client/login/components/ForgotPassword.js new file mode 100644 index 000000000..02d62a85d --- /dev/null +++ b/plugins/talk-plugin-auth/client/login/components/ForgotPassword.js @@ -0,0 +1,81 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import styles from './ForgotPassword.css'; +import { Button, TextField } from 'plugin-api/beta/client/components/ui'; +import { t } from 'plugin-api/beta/client/services'; + +class ForgotPassword extends React.Component { + handleSignUpLink = e => { + e.preventDefault(); + this.props.onSignUpLink(); + }; + handleSignInLink = e => { + e.preventDefault(); + this.props.onSignInLink(); + }; + handleEmailChange = e => this.props.onEmailChange(e.target.value); + handleSubmit = e => { + e.preventDefault(); + this.props.onSubmit(); + }; + + render() { + const { email, errorMessage, success } = this.props; + + return ( +
+
+

{t('talk-plugin-auth.login.recover_password')}

+
+
+
+ +
+ + {success ? ( +

{t('password_reset.mail_sent')}

+ ) : null} + {errorMessage ? ( +

{errorMessage}

+ ) : null} +
+
+ + {t('talk-plugin-auth.login.need_an_account')}{' '} + + {t('talk-plugin-auth.login.register')} + + + + {t('talk-plugin-auth.login.already_have_an_account')}{' '} + + {t('talk-plugin-auth.login.sign_in')} + + +
+
+ ); + } +} + +ForgotPassword.propTypes = { + success: PropTypes.bool.isRequired, + email: PropTypes.string.isRequired, + onEmailChange: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, + errorMessage: PropTypes.string, + onSignInLink: PropTypes.func.isRequired, + onSignUpLink: PropTypes.func.isRequired, +}; + +export default ForgotPassword; diff --git a/plugins/talk-plugin-auth/client/login/components/Main.css b/plugins/talk-plugin-auth/client/login/components/Main.css new file mode 100644 index 000000000..eb48d61c1 --- /dev/null +++ b/plugins/talk-plugin-auth/client/login/components/Main.css @@ -0,0 +1,24 @@ +.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: 280px; + top: 10px; + font-family: Helvetica, 'Helvetica Neue', Verdana, sans-serif; + font-size: 14px; +} + +.close { + font-size: 20px; + line-height: 14px; + top: 10px; + right: 10px; + position: absolute; + display: block; + font-weight: bold; + color: #363636; + cursor: pointer; +} + +.close:hover { + color: #6b6b6b; +} diff --git a/plugins/talk-plugin-auth/client/login/components/Main.js b/plugins/talk-plugin-auth/client/login/components/Main.js new file mode 100644 index 000000000..992d7a2fd --- /dev/null +++ b/plugins/talk-plugin-auth/client/login/components/Main.js @@ -0,0 +1,30 @@ +import React from 'react'; +import { Dialog } from 'plugin-api/beta/client/components/ui'; +import styles from './Main.css'; +import PropTypes from 'prop-types'; +import SignIn from '../containers/SignIn'; +import SignUp from '../containers/SignUp'; +import ForgotPassword from '../containers/ForgotPassword'; +import ResendEmailConfirmation from '../containers/ResendEmailConfirmation'; +import * as views from '../enums/views'; + +const Main = ({ view, onResetView }) => ( + + {view !== views.SIGN_IN && ( + + × + + )} + {view === views.SIGN_IN && } + {view === views.SIGN_UP && } + {view === views.FORGOT_PASSWORD && } + {view === views.RESEND_EMAIL_CONFIRMATION && } + +); + +Main.propTypes = { + view: PropTypes.string.isRequired, + onResetView: PropTypes.func.isRequired, +}; + +export default Main; diff --git a/plugins/talk-plugin-auth/client/components/ResendVerification.css b/plugins/talk-plugin-auth/client/login/components/ResendEmailConfirmation.css similarity index 100% rename from plugins/talk-plugin-auth/client/components/ResendVerification.css rename to plugins/talk-plugin-auth/client/login/components/ResendEmailConfirmation.css diff --git a/plugins/talk-plugin-auth/client/login/components/ResendEmailConfirmation.js b/plugins/talk-plugin-auth/client/login/components/ResendEmailConfirmation.js new file mode 100644 index 000000000..a3a746034 --- /dev/null +++ b/plugins/talk-plugin-auth/client/login/components/ResendEmailConfirmation.js @@ -0,0 +1,58 @@ +import React from 'react'; +import { + Button, + Spinner, + Success, + Alert, +} from 'plugin-api/beta/client/components/ui'; +import { t } from 'plugin-api/beta/client/services'; +import PropTypes from 'prop-types'; +import styles from './ResendEmailConfirmation.css'; + +class ResendVerification extends React.Component { + handleSubmit = e => { + e.preventDefault(); + this.props.onSubmit(); + }; + + render() { + const { email, errorMessage, loading, success } = this.props; + return ( +
+

+ {t('talk-plugin-auth.login.email_verify_cta')} +

+ + {errorMessage && {errorMessage}} +
+ {t('error.email_not_verified', email)} +
+
+ {!loading && + !success && ( + + )} + {loading && } + {success && } +
+
+ ); + } +} + +ResendVerification.propTypes = { + success: PropTypes.bool.isRequired, + loading: PropTypes.bool.isRequired, + email: PropTypes.string.isRequired, + onSubmit: PropTypes.func.isRequired, + errorMessage: PropTypes.string, +}; + +export default ResendVerification; diff --git a/plugins/talk-plugin-auth/client/login/components/SignIn.css b/plugins/talk-plugin-auth/client/login/components/SignIn.css new file mode 100644 index 000000000..6b9c9de85 --- /dev/null +++ b/plugins/talk-plugin-auth/client/login/components/SignIn.css @@ -0,0 +1,38 @@ +.header { + margin-bottom: 20px; +} + +.header h1 { + text-align: center; + font-size: 1.2em; +} + +.action { + margin-top: 0px; +} + +.signInButton { + margin-top: 10px; + background-color: #2a2a2a; +} + +.footer { + margin: 20px auto 10px; + text-align: center; +} + +.footer span { + display: block; + margin-bottom: 5px; +} + +.footer a { + color: #2c69b6; + cursor: pointer; + margin: 0 5px; +} + +.recaptcha { + margin-top: 16px; + margin-bottom: 16px; +} diff --git a/plugins/talk-plugin-auth/client/login/components/SignIn.js b/plugins/talk-plugin-auth/client/login/components/SignIn.js new file mode 100644 index 000000000..f082f7f4d --- /dev/null +++ b/plugins/talk-plugin-auth/client/login/components/SignIn.js @@ -0,0 +1,136 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + Button, + TextField, + Spinner, + Alert, +} from 'plugin-api/beta/client/components/ui'; +import styles from './SignIn.css'; +import { t } from 'plugin-api/beta/client/services'; +import cn from 'classnames'; +import { Recaptcha } from 'plugin-api/beta/client/components'; +import External from './External'; + +class SignIn extends React.Component { + recaptcha = null; + + handleForgotPasswordLink = e => { + e.preventDefault(); + this.props.onForgotPasswordLink(); + }; + handleSignUpLink = e => { + e.preventDefault(); + this.props.onSignUpLink(); + }; + handleEmailChange = e => this.props.onEmailChange(e.target.value); + handlePasswordChange = e => this.props.onPasswordChange(e.target.value); + + handleSubmit = e => { + e.preventDefault(); + this.props.onSubmit(); + + // Reset recaptcha because each response can only + // be used once. + if (this.recaptcha) { + this.recaptcha.reset(); + } + }; + + handleRecaptchaRef = ref => { + this.recaptcha = ref; + }; + + render() { + const { + email, + password, + errorMessage, + requireRecaptcha, + loading, + } = this.props; + return ( +
+
+

{t('talk-plugin-auth.login.sign_in_to_join')}

+
+ {errorMessage && {errorMessage}} +
+ +
+ + + {requireRecaptcha && ( +
+ +
+ )} +
+ {!loading ? ( + + ) : ( + + )} +
+ +
+ +
+ ); + } +} + +SignIn.propTypes = { + loading: PropTypes.bool.isRequired, + email: PropTypes.string.isRequired, + password: PropTypes.string.isRequired, + onEmailChange: PropTypes.func.isRequired, + onPasswordChange: PropTypes.func.isRequired, + onForgotPasswordLink: PropTypes.func.isRequired, + onSignUpLink: PropTypes.func.isRequired, + onRecaptchaVerify: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, + errorMessage: PropTypes.string, + requireRecaptcha: PropTypes.bool.isRequired, +}; + +export default SignIn; diff --git a/plugins/talk-plugin-auth/client/login/components/SignUp.css b/plugins/talk-plugin-auth/client/login/components/SignUp.css new file mode 100644 index 000000000..6d5474e57 --- /dev/null +++ b/plugins/talk-plugin-auth/client/login/components/SignUp.css @@ -0,0 +1,40 @@ +.header { + margin-bottom: 20px; +} + +.header h1 { + text-align: center; + font-size: 1.2em; +} + +.hint { + color: grey; + font-weight: 600; + padding: 3px 0 16px; +} + +.action { + margin-top: 0px; +} + +.button { + margin-top: 10px; + background-color: #2a2a2a; +} + +.footer { + margin: 20px auto 10px; + text-align: center; +} + +.footer span { + display: block; + margin-bottom: 5px; +} + +.footer a { + color: #2c69b6; + cursor: pointer; + margin: 0 5px; +} + diff --git a/plugins/talk-plugin-auth/client/login/components/SignUp.js b/plugins/talk-plugin-auth/client/login/components/SignUp.js new file mode 100644 index 000000000..61c4d0b1d --- /dev/null +++ b/plugins/talk-plugin-auth/client/login/components/SignUp.js @@ -0,0 +1,166 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + Button, + TextField, + Spinner, + Success, + Alert, +} from 'plugin-api/beta/client/components/ui'; +import { t } from 'plugin-api/beta/client/services'; +import styles from './SignUp.css'; +import External from './External'; + +class SignUp extends React.Component { + handleSignInLink = e => { + e.preventDefault(); + this.props.onSignInLink(); + }; + + handleUsernameChange = e => this.props.onUsernameChange(e.target.value); + handleEmailChange = e => this.props.onEmailChange(e.target.value); + handlePasswordChange = e => this.props.onPasswordChange(e.target.value); + handlePasswordRepeatChange = e => + this.props.onPasswordRepeatChange(e.target.value); + + handleSubmit = e => { + e.preventDefault(); + this.props.onSubmit(); + }; + + render() { + const { + username, + email, + password, + passwordRepeat, + usernameError, + emailError, + passwordError, + passwordRepeatError, + loading, + errorMessage, + requireEmailConfirmation, + success, + } = this.props; + + return ( +
+
+

{t('talk-plugin-auth.login.sign_up')}

+
+ + {errorMessage && {errorMessage}} + {!success && ( +
+ +
+ + + + {passwordError && ( + + {' '} + Password must be at least 8 characters.{' '} + + )} + +
+ + {loading && } +
+ +
+ )} + {success && ( +
+ + {requireEmailConfirmation && ( +

+ {t('talk-plugin-auth.login.verify_email')} +
+
+ {t('talk-plugin-auth.login.verify_email2')} +

+ )} +
+ )} +
+ {t('talk-plugin-auth.login.already_have_an_account')}{' '} + + {t('talk-plugin-auth.login.sign_in')} + +
+
+ ); + } +} + +SignUp.propTypes = { + loading: PropTypes.bool.isRequired, + username: PropTypes.string.isRequired, + usernameError: PropTypes.string, + email: PropTypes.string.isRequired, + emailError: PropTypes.string, + password: PropTypes.string.isRequired, + passwordError: PropTypes.string, + passwordRepeat: PropTypes.string.isRequired, + passwordRepeatError: PropTypes.string, + onUsernameChange: PropTypes.func.isRequired, + onEmailChange: PropTypes.func.isRequired, + onPasswordChange: PropTypes.func.isRequired, + onPasswordRepeatChange: PropTypes.func.isRequired, + onSignInLink: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, + errorMessage: PropTypes.string, + requireEmailConfirmation: PropTypes.bool.isRequired, + success: PropTypes.bool.isRequired, +}; + +export default SignUp; diff --git a/plugins/talk-plugin-auth/client/login/constants.js b/plugins/talk-plugin-auth/client/login/constants.js new file mode 100644 index 000000000..3997c11f4 --- /dev/null +++ b/plugins/talk-plugin-auth/client/login/constants.js @@ -0,0 +1,5 @@ +const prefix = 'TALK_AUTH'; + +export const SET_VIEW = `${prefix}_SET_VIEW`; +export const SET_EMAIL = `${prefix}_SET_EMAIL`; +export const SET_PASSWORD = `${prefix}_SET_PASSWORD`; diff --git a/plugins/talk-plugin-auth/client/login/containers/ForgotPassword.js b/plugins/talk-plugin-auth/client/login/containers/ForgotPassword.js new file mode 100644 index 000000000..c5e2f42da --- /dev/null +++ b/plugins/talk-plugin-auth/client/login/containers/ForgotPassword.js @@ -0,0 +1,63 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { connect, withForgotPassword } from 'plugin-api/beta/client/hocs'; +import { compose } from 'recompose'; +import ForgotPassword from '../components/ForgotPassword'; +import { bindActionCreators } from 'redux'; +import * as views from '../enums/views'; +import { setView, setEmail } from '../actions'; + +class ForgotPasswordContainer extends Component { + handleSubmit = () => { + this.props.forgotPassword(this.props.email); + }; + + handleSignUpLink = () => { + this.props.setView(views.SIGN_UP); + }; + + handleSignInLink = () => { + this.props.setView(views.SIGN_IN); + }; + + render() { + return ( + + ); + } +} + +ForgotPasswordContainer.propTypes = { + success: PropTypes.bool.isRequired, + forgotPassword: PropTypes.func.isRequired, + errorMessage: PropTypes.string, + setView: PropTypes.func.isRequired, + email: PropTypes.string.isRequired, + setEmail: PropTypes.func.isRequired, +}; + +const mapStateToProps = ({ talkPluginAuth: state }) => ({ + email: state.email, +}); + +const mapDispatchToProps = dispatch => + bindActionCreators( + { + setView, + setEmail, + }, + dispatch + ); + +export default compose( + connect(mapStateToProps, mapDispatchToProps), + withForgotPassword +)(ForgotPasswordContainer); diff --git a/plugins/talk-plugin-auth/client/login/containers/Main.js b/plugins/talk-plugin-auth/client/login/containers/Main.js new file mode 100644 index 000000000..05778133a --- /dev/null +++ b/plugins/talk-plugin-auth/client/login/containers/Main.js @@ -0,0 +1,96 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Main from '../components/Main'; +import { connect } from 'plugin-api/beta/client/hocs'; +import { bindActionCreators } from 'redux'; +import { setView } from '../actions'; +import { + setAuthToken, + handleSuccessfulLogin, +} from 'plugin-api/beta/client/actions/auth'; +import * as views from '../enums/views'; + +class MainContainer extends React.Component { + resetView = () => { + this.props.setView(views.SIGN_IN); + }; + + resizeHeight() { + setTimeout(() => { + const height = document.getElementById('signInDialog').offsetHeight + 100; + window.resizeTo(500, height); + }, 20); + } + + componentDidMount() { + this.resizeHeight(); + this.listenToStorageChanges(); + } + + componentDidUpdate(prevProps) { + if (prevProps.view !== this.props.view) { + this.resizeHeight(); + } + } + + componentWillUnmount() { + this.unlisten(); + } + + listenToStorageChanges() { + window.addEventListener('storage', this.handleAuth); + } + + unlisten() { + window.removeEventListener('storage', this.handleAuth); + } + + // External logins store auth data into `auth`, we use it to detect + // a successful sign in. + handleAuth = e => { + if (e.key === 'auth') { + const { err, data } = JSON.parse(e.newValue); + if (err) { + console.error(err); + } else if (data && data.token) { + if (data.user) { + this.props.handleSuccessfulLogin(data.user, data.token); + } else { + this.props.setAuthToken(data.token); + } + this.unlisten(); + localStorage.removeItem('auth'); + window.close(); + } else { + console.error('auth was set, but did not contain a token'); + } + } + }; + + render() { + return
; + } +} + +MainContainer.propTypes = { + view: PropTypes.string.isRequired, + setView: PropTypes.func.isRequired, + handleSuccessfulLogin: PropTypes.func.isRequired, + setAuthToken: PropTypes.func.isRequired, +}; + +const mapStateToProps = ({ talkPluginAuth: state }) => ({ + view: state.view, +}); + +const mapDispatchToProps = dispatch => + bindActionCreators( + { + setView, + handleSuccessfulLogin, + setAuthToken, + }, + dispatch + ); + +export default connect(mapStateToProps, mapDispatchToProps)(MainContainer); diff --git a/plugins/talk-plugin-auth/client/login/containers/ResendEmailConfirmation.js b/plugins/talk-plugin-auth/client/login/containers/ResendEmailConfirmation.js new file mode 100644 index 000000000..bd7c9ad8c --- /dev/null +++ b/plugins/talk-plugin-auth/client/login/containers/ResendEmailConfirmation.js @@ -0,0 +1,64 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { + connect, + withResendEmailConfirmation, +} from 'plugin-api/beta/client/hocs'; +import { compose } from 'recompose'; +import ResendEmailConfirmaton from '../components/ResendEmailConfirmation'; +import { bindActionCreators } from 'redux'; +import * as views from '../enums/views'; +import { setView } from '../actions'; + +class ResendEmailConfirmatonContainer extends Component { + handleSubmit = () => { + this.props.resendEmailConfirmation(this.props.email); + }; + + componentWillReceiveProps(nextProps) { + if (nextProps.success) { + setTimeout(() => { + // allow success UI to be shown for a second, and then close the modal + this.props.setView(views.SIGN_IN); + }, 2500); + } + } + + render() { + return ( + + ); + } +} + +ResendEmailConfirmatonContainer.propTypes = { + success: PropTypes.bool.isRequired, + loading: PropTypes.bool.isRequired, + resendEmailConfirmation: PropTypes.func.isRequired, + errorMessage: PropTypes.string.isRequired, + setView: PropTypes.func.isRequired, + email: PropTypes.string.isRequired, +}; + +const mapStateToProps = ({ talkPluginAuth: state }) => ({ + email: state.email, +}); + +const mapDispatchToProps = dispatch => + bindActionCreators( + { + setView, + }, + dispatch + ); + +export default compose( + connect(mapStateToProps, mapDispatchToProps), + withResendEmailConfirmation +)(ResendEmailConfirmatonContainer); diff --git a/plugins/talk-plugin-auth/client/login/containers/SignIn.js b/plugins/talk-plugin-auth/client/login/containers/SignIn.js new file mode 100644 index 000000000..77c6a8614 --- /dev/null +++ b/plugins/talk-plugin-auth/client/login/containers/SignIn.js @@ -0,0 +1,94 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { connect, withSignIn } from 'plugin-api/beta/client/hocs'; +import { compose } from 'recompose'; +import SignIn from '../components/SignIn'; +import { bindActionCreators } from 'redux'; +import * as views from '../enums/views'; +import { setView, setEmail, setPassword } from '../actions'; + +class SignInContainer extends Component { + state = { + recaptchaResponse: null, + }; + + handleSubmit = () => { + this.props.signIn( + this.props.email, + this.props.password, + this.state.recaptchaResponse + ); + }; + + handleRecaptchaVerify = recaptchaResponse => { + this.setState({ recaptchaResponse }); + }; + + handleForgotPasswordLink = () => { + this.props.setView(views.FORGOT_PASSWORD); + }; + + handleSignUpLink = () => { + this.props.setView(views.SIGN_UP); + }; + + componentWillReceiveProps(nextProps) { + if (nextProps.requireEmailConfirmation) { + this.props.setView(views.RESEND_EMAIL_CONFIRMATION); + } else if (nextProps.success) { + window.close(); + } + } + + render() { + return ( + + ); + } +} + +SignInContainer.propTypes = { + signIn: PropTypes.func.isRequired, + errorMessage: PropTypes.string, + requireRecaptcha: PropTypes.bool.isRequired, + requireEmailConfirmation: PropTypes.bool.isRequired, + loading: PropTypes.bool.isRequired, + success: PropTypes.bool.isRequired, + setView: PropTypes.func.isRequired, + email: PropTypes.string.isRequired, + password: PropTypes.string.isRequired, + setEmail: PropTypes.func.isRequired, + setPassword: PropTypes.func.isRequired, +}; + +const mapStateToProps = ({ talkPluginAuth: state }) => ({ + email: state.email, + password: state.password, +}); + +const mapDispatchToProps = dispatch => + bindActionCreators( + { + setView, + setEmail, + setPassword, + }, + dispatch + ); + +export default compose( + connect(mapStateToProps, mapDispatchToProps), + withSignIn +)(SignInContainer); diff --git a/plugins/talk-plugin-auth/client/login/containers/SignUp.js b/plugins/talk-plugin-auth/client/login/containers/SignUp.js new file mode 100644 index 000000000..609102ebc --- /dev/null +++ b/plugins/talk-plugin-auth/client/login/containers/SignUp.js @@ -0,0 +1,136 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { connect, withSignUp } from 'plugin-api/beta/client/hocs'; +import { compose } from 'recompose'; +import SignUp from '../components/SignUp'; +import { bindActionCreators } from 'redux'; +import * as views from '../enums/views'; +import { setView, setEmail, setPassword } from '../actions'; +import { t } from 'plugin-api/beta/client/services'; + +class SignUpContainer extends Component { + state = { + username: '', + passwordRepeat: '', + usernameError: null, + emailError: null, + passwordError: null, + passwordRepeatError: null, + }; + + validate = data => { + let valid = true; + const changes = {}; + Object.keys(data).forEach(name => { + const error = this.props.validate(name, data[name]); + if (error) { + valid = false; + } + changes[`${name}Error`] = error; + }); + + if (data.password !== data.passwordRepeat) { + changes['passwordRepeatError'] = t( + 'talk-plugin-auth.login.passwords_dont_match' + ); + valid = false; + } + + this.setState(changes); + return valid; + }; + + handleSubmit = () => { + const data = { + username: this.state.username, + email: this.props.email, + password: this.props.password, + passwordRepeat: this.state.passwordRepeat, + }; + + if (this.validate(data)) { + this.props.signUp(data); + } + }; + + setUsername = username => this.setState({ username }); + setPasswordRepeat = passwordRepeat => this.setState({ passwordRepeat }); + + handleForgotPasswordLink = () => { + this.props.setView(views.FORGOT_PASSWORD); + }; + + handleSignInLink = () => { + this.props.setView(views.SIGN_IN); + }; + + componentWillReceiveProps(nextProps) { + if (nextProps.success) { + setTimeout(() => { + // allow success UI to be shown for a second, and then close the modal + this.props.setView(views.SIGN_IN); + }, 2000); + } + } + + render() { + return ( + + ); + } +} + +SignUpContainer.propTypes = { + setView: PropTypes.func.isRequired, + email: PropTypes.string.isRequired, + password: PropTypes.string.isRequired, + setEmail: PropTypes.func.isRequired, + setPassword: PropTypes.func.isRequired, + signUp: PropTypes.func.isRequired, + loading: PropTypes.bool.isRequired, + errorMessage: PropTypes.string, + requireEmailConfirmation: PropTypes.bool.isRequired, + success: PropTypes.bool.isRequired, + validate: PropTypes.func.isRequired, +}; + +const mapStateToProps = ({ talkPluginAuth: state }) => ({ + email: state.email, + password: state.password, +}); + +const mapDispatchToProps = dispatch => + bindActionCreators( + { + setView, + setEmail, + setPassword, + }, + dispatch + ); + +export default compose( + connect(mapStateToProps, mapDispatchToProps), + withSignUp +)(SignUpContainer); diff --git a/plugins/talk-plugin-auth/client/login/enums/views.js b/plugins/talk-plugin-auth/client/login/enums/views.js new file mode 100644 index 000000000..5c6732755 --- /dev/null +++ b/plugins/talk-plugin-auth/client/login/enums/views.js @@ -0,0 +1,4 @@ +export const SIGN_IN = 'SIGN_IN'; +export const FORGOT_PASSWORD = 'FORGOT_PASSWORD'; +export const SIGN_UP = 'SIGN_UP'; +export const RESEND_EMAIL_CONFIRMATION = 'RESEND_EMAIL_CONFIRMATION'; diff --git a/plugins/talk-plugin-auth/client/login/reducer.js b/plugins/talk-plugin-auth/client/login/reducer.js new file mode 100644 index 000000000..004aff8f8 --- /dev/null +++ b/plugins/talk-plugin-auth/client/login/reducer.js @@ -0,0 +1,30 @@ +import * as actions from './constants'; +import * as views from './enums/views'; + +const initialState = { + view: views.SIGN_IN, + email: '', + password: '', +}; + +export default function reducer(state = initialState, action) { + switch (action.type) { + case actions.SET_VIEW: + return { + ...state, + view: action.view, + }; + case actions.SET_EMAIL: + return { + ...state, + email: action.email, + }; + case actions.SET_PASSWORD: + return { + ...state, + password: action.password, + }; + default: + return state; + } +} diff --git a/plugins/talk-plugin-auth/client/components/FakeComment.css b/plugins/talk-plugin-auth/client/stream/components/FakeComment.css similarity index 100% rename from plugins/talk-plugin-auth/client/components/FakeComment.css rename to plugins/talk-plugin-auth/client/stream/components/FakeComment.css diff --git a/plugins/talk-plugin-auth/client/components/FakeComment.js b/plugins/talk-plugin-auth/client/stream/components/FakeComment.js similarity index 72% rename from plugins/talk-plugin-auth/client/components/FakeComment.js rename to plugins/talk-plugin-auth/client/stream/components/FakeComment.js index 82c61182b..e353a7f58 100644 --- a/plugins/talk-plugin-auth/client/components/FakeComment.js +++ b/plugins/talk-plugin-auth/client/stream/components/FakeComment.js @@ -1,9 +1,9 @@ import React from 'react'; -import t from 'coral-framework/services/i18n'; -import ReplyButton from 'coral-embed-stream/src/tabs/stream/components/ReplyButton'; +import PropTypes from 'prop-types'; import styles from './FakeComment.css'; import { Icon } from 'plugin-api/beta/client/components/ui'; import { CommentTimestamp } from 'plugin-api/beta/client/components'; +import { t } from 'plugin-api/beta/client/services'; export const FakeComment = ({ username, created_at, body }) => (
@@ -16,11 +16,10 @@ export const FakeComment = ({ username, created_at, body }) => ( {t('like')} - {}} - parentCommentId={'commentID'} - currentUserId={{}} - /> +
); + +FakeComment.propTypes = { + username: PropTypes.string.isRequired, + created_at: PropTypes.string.isRequired, + body: PropTypes.string.isRequired, +}; diff --git a/plugins/talk-plugin-auth/client/stream/components/SetUsernameDialog.css b/plugins/talk-plugin-auth/client/stream/components/SetUsernameDialog.css new file mode 100644 index 000000000..c1947ca7d --- /dev/null +++ b/plugins/talk-plugin-auth/client/stream/components/SetUsernameDialog.css @@ -0,0 +1,41 @@ +.dialogusername { + 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: 400px; + top: 10px; +} + +.yourusername { + display: block; +} + +.header { + margin-bottom: 20px; +} + +.header h1 { + text-align: center; + font-size: 1.2em; +} + +.saveusername { + display: block; + width: 100%; +} + +.savebutton { + display: inline; + background-color: rgb(105,105,105); + color: white; +} + +.fakeComment { + display: block; + margin-bottom: 5px; +} + +.hint { + color: grey; + font-weight: 600; + padding: 3px 0 16px; +} diff --git a/plugins/talk-plugin-auth/client/stream/components/SetUsernameDialog.js b/plugins/talk-plugin-auth/client/stream/components/SetUsernameDialog.js new file mode 100644 index 000000000..0fd742879 --- /dev/null +++ b/plugins/talk-plugin-auth/client/stream/components/SetUsernameDialog.js @@ -0,0 +1,84 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import styles from './SetUsernameDialog.css'; +import { + Dialog, + Alert, + TextField, + Button, +} from 'plugin-api/beta/client/components/ui'; +import { FakeComment } from './FakeComment'; +import { t } from 'plugin-api/beta/client/services'; + +class SetUsernameDialog extends React.Component { + handleUsernameChange = e => this.props.onUsernameChange(e.target.value); + + handleSubmit = e => { + e.preventDefault(); + this.props.onSubmit(); + }; + + render() { + const { username, usernameError, errorMessage } = this.props; + + return ( + +
+
+

+ {t('talk-plugin-auth.set_username_dialog.write_your_username')} +

+
+
+

+ {t('talk-plugin-auth.set_username_dialog.your_username')} +

+ + {errorMessage && {errorMessage}} +
+ {usernameError && ( + + {' '} + {t( + 'talk-plugin-auth.set_username_dialog.special_characters' + )}{' '} + + )} +
+ + +
+
+
+
+
+ ); + } +} + +SetUsernameDialog.propTypes = { + loading: PropTypes.bool.isRequired, + username: PropTypes.string.isRequired, + usernameError: PropTypes.string, + onUsernameChange: PropTypes.func.isRequired, + onSubmit: PropTypes.func.isRequired, + errorMessage: PropTypes.string, +}; + +export default SetUsernameDialog; diff --git a/plugins/talk-plugin-auth/client/stream/components/SignInButton.css b/plugins/talk-plugin-auth/client/stream/components/SignInButton.css new file mode 100644 index 000000000..774c6f280 --- /dev/null +++ b/plugins/talk-plugin-auth/client/stream/components/SignInButton.css @@ -0,0 +1,9 @@ +.button { + background-color: #2a2a2a; + color: #FFF; +} + +.button:hover { + background-color: #767676; + color: #FFF; +} diff --git a/plugins/talk-plugin-auth/client/stream/components/SignInButton.js b/plugins/talk-plugin-auth/client/stream/components/SignInButton.js new file mode 100644 index 000000000..53e0bc23d --- /dev/null +++ b/plugins/talk-plugin-auth/client/stream/components/SignInButton.js @@ -0,0 +1,27 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Button } from 'plugin-api/beta/client/components/ui'; +import { t } from 'plugin-api/beta/client/services'; +import styles from './SignInButton.css'; + +const SignInButton = ({ isLoggedIn, showSignInDialog }) => ( +
+ {!isLoggedIn ? ( + + ) : null} +
+); + +SignInButton.propTypes = { + isLoggedIn: PropTypes.bool, + showSignInDialog: PropTypes.func, +}; + +export default SignInButton; diff --git a/plugins/talk-plugin-auth/client/stream/components/UserBox.css b/plugins/talk-plugin-auth/client/stream/components/UserBox.css new file mode 100644 index 000000000..799468177 --- /dev/null +++ b/plugins/talk-plugin-auth/client/stream/components/UserBox.css @@ -0,0 +1,21 @@ +.userBox { + margin: 10px 0 20px; + letter-spacing: 0.1px; +} + +.userBoxLoggedIn { + font-weight: bold; +} + +.userBox a { + color: black; + font-weight: bold; + cursor: pointer; + margin: 0px; + margin-left: 4px; + padding-bottom: 2px; +} + +.userBox .logout { + border-bottom: solid 1px black; +} diff --git a/plugins/talk-plugin-auth/client/stream/components/UserBox.js b/plugins/talk-plugin-auth/client/stream/components/UserBox.js new file mode 100644 index 000000000..aa9d14df6 --- /dev/null +++ b/plugins/talk-plugin-auth/client/stream/components/UserBox.js @@ -0,0 +1,33 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import styles from './UserBox.css'; +import { t } from 'plugin-api/beta/client/services'; +import cn from 'classnames'; + +const UserBox = ({ username, logout, onShowProfile }) => ( +
+ {username ? ( +
+ + {t('talk-plugin-auth.login.logged_in_as')} + + {username}.{' '} + {t('talk-plugin-auth.login.not_you')} + logout()} + > + {t('talk-plugin-auth.login.logout')} + +
+ ) : null} +
+); + +UserBox.propTypes = { + username: PropTypes.string, + logout: PropTypes.func, + onShowProfile: PropTypes.func, +}; + +export default UserBox; diff --git a/plugins/talk-plugin-auth/client/stream/containers/SetUsernameDialog.js b/plugins/talk-plugin-auth/client/stream/containers/SetUsernameDialog.js new file mode 100644 index 000000000..93c7ea74b --- /dev/null +++ b/plugins/talk-plugin-auth/client/stream/containers/SetUsernameDialog.js @@ -0,0 +1,64 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { connect, withSetUsername } from 'plugin-api/beta/client/hocs'; +import { compose, branch, renderNothing } from 'recompose'; +import { + usernameStatusSelector, + usernameSelector, +} from 'plugin-api/beta/client/selectors/auth'; +import SetUsernameDialog from '../components/SetUsernameDialog'; + +class SetUsernameDialogContainer extends Component { + state = { + username: this.props.username, + usernameError: null, + }; + + handleSubmit = () => { + const validationError = this.props.validateUsername(this.state.username); + if (validationError) { + this.setState({ usernameError: validationError }); + } else { + this.props.setUsername(this.state.username); + } + }; + + setUsername = username => this.setState({ username }); + + render() { + if (!this.props.unset) { + return null; + } + return ( + + ); + } +} + +SetUsernameDialogContainer.propTypes = { + unset: PropTypes.bool.isRequired, + username: PropTypes.string, + setUsername: PropTypes.func.isRequired, + loading: PropTypes.bool.isRequired, + errorMessage: PropTypes.string, + success: PropTypes.bool.isRequired, + validateUsername: PropTypes.func.isRequired, +}; + +const mapStateToProps = state => ({ + unset: usernameStatusSelector(state) === 'UNSET', + username: usernameSelector(state), +}); + +export default compose( + connect(mapStateToProps, null), + withSetUsername, + branch(props => !props.username, renderNothing) +)(SetUsernameDialogContainer); diff --git a/plugins/talk-plugin-auth/client/stream/containers/SignInButton.js b/plugins/talk-plugin-auth/client/stream/containers/SignInButton.js new file mode 100644 index 000000000..87b2a900f --- /dev/null +++ b/plugins/talk-plugin-auth/client/stream/containers/SignInButton.js @@ -0,0 +1,14 @@ +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; +import { showSignInDialog } from 'plugin-api/beta/client/actions/stream'; +import SignInButton from '../components/SignInButton'; +import { isLoggedInSelector } from 'plugin-api/beta/client/selectors/auth'; + +const mapStateToProps = state => ({ + isLoggedIn: isLoggedInSelector(state), +}); + +const mapDispatchToProps = dispatch => + bindActionCreators({ showSignInDialog }, dispatch); + +export default connect(mapStateToProps, mapDispatchToProps)(SignInButton); diff --git a/plugins/talk-plugin-auth/client/stream/containers/UserBox.js b/plugins/talk-plugin-auth/client/stream/containers/UserBox.js new file mode 100644 index 000000000..720a61087 --- /dev/null +++ b/plugins/talk-plugin-auth/client/stream/containers/UserBox.js @@ -0,0 +1,13 @@ +import { connect } from 'react-redux'; +import { bindActionCreators } from 'redux'; +import { logout } from 'plugin-api/beta/client/actions/auth'; +import UserBox from '../components/UserBox'; +import { usernameSelector } from 'plugin-api/beta/client/selectors/auth'; + +const mapStateToProps = state => ({ + username: usernameSelector(state), +}); + +const mapDispatchToProps = dispatch => bindActionCreators({ logout }, dispatch); + +export default connect(mapStateToProps, mapDispatchToProps)(UserBox); diff --git a/plugins/talk-plugin-auth/client/translations.yml b/plugins/talk-plugin-auth/client/translations.yml index 92f7b96ec..9731da90a 100644 --- a/plugins/talk-plugin-auth/client/translations.yml +++ b/plugins/talk-plugin-auth/client/translations.yml @@ -1,399 +1,397 @@ da: - sign_in: - email_verify_cta: "Please verify your email address." - 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?" - logged_in_as: "Signed in as" - facebook_sign_in: "Sign in with Facebook" - facebook_sign_up: "Sign up with Facebook" - logout: "Sign out" - sign_in: "Sign in" - sign_in_to_join: "Sign in to join the conversation" - or: "Or" - email: "E-mail Address" - password: "Password" - forgot_your_pass: "Forgot your password?" - need_an_account: "Need an account?" - register: "Register" - sign_up: "Sign Up" - confirm_password: "Confirm Password" - username: "Username" - already_have_an_account: "Already have an account?" - recover_password: "Recover password" - email_in_use: "Email address already in use" - email_or_username_in_use: "Email address or Username already in use" - required_field: "This field is required" - passwords_dont_match: "Passwords don't match." - special_characters: "Usernames can contain letters, numbers and _ only" - sign_in_to_comment: "Sign in to comment" - check_the_form: "Invalid Form. Please, check the fields" - createdisplay: - check_the_form: "Invalid Form. Please check the fields" - continue: "Continue with the same Facebook username" - error_create: "Error when changing username" - fake_comment_body: "This is an example comment. Readers can share their thoughts and opinions with newsrooms in the comments section." - fake_comment_date: "1 minute ago" - if_you_dont_change_your_name: "If you don't change your username at this step your Facebook display name will appear alongside of all your comments." - required_field: "Required field" - save: Save - special_characters: "Usernames can contain letters numbers and _ only" - username: Username - write_your_username: "Edit your username" - your_username: "Your username appears on every comment you post." + talk-plugin-auth: + login: + email_verify_cta: "Please verify your email address." + 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?" + logged_in_as: "Signed in as" + facebook_sign_in: "Sign in with Facebook" + facebook_sign_up: "Sign up with Facebook" + logout: "Sign out" + sign_in: "Sign in" + sign_in_to_join: "Sign in to join the conversation" + or: "Or" + email: "E-mail Address" + password: "Password" + forgot_your_pass: "Forgot your password?" + need_an_account: "Need an account?" + register: "Register" + sign_up: "Sign Up" + confirm_password: "Confirm Password" + username: "Username" + already_have_an_account: "Already have an account?" + recover_password: "Recover password" + email_in_use: "Email address already in use" + email_or_username_in_use: "Email address or Username already in use" + required_field: "This field is required" + passwords_dont_match: "Passwords don't match." + special_characters: "Usernames can contain letters, numbers and _ only" + sign_in_to_comment: "Sign in to comment" + check_the_form: "Invalid Form. Please, check the fields" + set_username_dialog: + check_the_form: "Invalid Form. Please check the fields" + continue: "Continue with the same Facebook username" + error_create: "Error when changing username" + fake_comment_body: "This is an example comment. Readers can share their thoughts and opinions with newsrooms in the comments section." + fake_comment_date: "1 minute ago" + if_you_dont_change_your_name: "If you don't change your username at this step your Facebook display name will appear alongside of all your comments." + required_field: "Required field" + save: Save + special_characters: "Usernames can contain letters numbers and _ only" + username: Username + write_your_username: "Edit your username" + your_username: "Your username appears on every comment you post." en: - sign_in: - email_verify_cta: "Please verify your email address." - 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?" - logged_in_as: "Signed in as" - facebook_sign_in: "Sign in with Facebook" - facebook_sign_up: "Sign up with Facebook" - logout: "Sign out" - sign_in: "Sign in" - sign_in_to_join: "Sign in to join the conversation" - or: "Or" - email: "E-mail Address" - password: "Password" - forgot_your_pass: "Forgot your password?" - need_an_account: "Need an account?" - register: "Register" - sign_up: "Sign Up" - confirm_password: "Confirm Password" - username: "Username" - already_have_an_account: "Already have an account?" - recover_password: "Recover password" - email_in_use: "Email address already in use" - email_or_username_in_use: "Email address or Username already in use" - required_field: "This field is required" - passwords_dont_match: "Passwords don't match." - special_characters: "Usernames can contain letters, numbers and _ only" - sign_in_to_comment: "Sign in to comment" - check_the_form: "Invalid Form. Please, check the fields" - createdisplay: - check_the_form: "Invalid Form. Please check the fields" - continue: "Continue with the same Facebook username" - error_create: "Error when changing username" - fake_comment_body: "This is an example comment. Readers can share their thoughts and opinions with newsrooms in the comments section." - fake_comment_date: "1 minute ago" - if_you_dont_change_your_name: "If you don't change your username at this step your Facebook display name will appear alongside of all your comments." - required_field: "Required field" - save: Save - special_characters: "Usernames can contain letters numbers and _ only" - username: Username - write_your_username: "Edit your username" - your_username: "Your username appears on every comment you post." + talk-plugin-auth: + login: + email_verify_cta: "Please verify your email address." + 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?" + logged_in_as: "Signed in as" + logout: "Sign out" + sign_in: "Sign in" + sign_in_to_join: "Sign in to join the conversation" + or: "Or" + email: "E-mail Address" + password: "Password" + forgot_your_pass: "Forgot your password?" + need_an_account: "Need an account?" + register: "Register" + sign_up: "Sign Up" + confirm_password: "Confirm Password" + username: "Username" + already_have_an_account: "Already have an account?" + recover_password: "Recover password" + email_in_use: "Email address already in use" + email_or_username_in_use: "Email address or Username already in use" + required_field: "This field is required" + passwords_dont_match: "Passwords don't match." + special_characters: "Usernames can contain letters, numbers and _ only" + sign_in_to_comment: "Sign in to comment" + check_the_form: "Invalid Form. Please, check the fields" + set_username_dialog: + check_the_form: "Invalid Form. Please check the fields" + continue: "Continue with the same Facebook username" + error_create: "Error when changing username" + fake_comment_body: "This is an example comment. Readers can share their thoughts and opinions with newsrooms in the comments section." + fake_comment_date: "1 minute ago" + if_you_dont_change_your_name: "If you don't change your username at this step your Facebook display name will appear alongside of all your comments." + required_field: "Required field" + save: Save + special_characters: "Usernames can contain letters numbers and _ only" + username: Username + write_your_username: "Edit your username" + your_username: "Your username appears on every comment you post." de: - sign_in: - email_verify_cta: "Bitte bestätigen Sie Ihre E-Mail-Adresse." - request_new_verify_email: "E-Mail-Bestätigung erneut anfordern" - verify_email: "Danke, dass Sie ein Konto eingerichtet haben! Wir haben eine Nachricht an Ihre E-Mail-Adresse geschickt, mit der Sie Ihr Konto bestätigen können." - verify_email2: "Sie müssen ihr Konto bestätigen, bevor Sie beginnen können." - not_you: "Nicht Ihr Name?" - logged_in_as: "Angemeldet als" - facebook_sign_in: "Mit Facebook anmelden" - facebook_sign_up: "Mit Facebook registrieren" - logout: "Abmelden" - sign_in: "Anmelden" - sign_in_to_join: "Anmelden, um zu diskutieren" - or: "Oder" - email: "E-Mail-Adresse" - password: "Passwort" - forgot_your_pass: "Passwort vergessen?" - need_an_account: "Brauchen Sie ein Konto?" - register: "Registrieren" - sign_up: "Registrieren" - confirm_password: "Passwort bestätigen" - username: "Nutzername" - already_have_an_account: "Konto bereits vorhanden?" - recover_password: "Passwort zurücksetzen" - email_in_use: "E-Mail-Adresse bereits vergeben" - email_or_username_in_use: "E-Mail-Adresse oder Nutzername bereits vergeben" - required_field: "Pflichtfeld" - passwords_dont_match: "Passwörter nicht identisch." - special_characters: "Nutzernamen dürfen nur Buchstaben, Zahlen und _ enthalten" - sign_in_to_comment: "Zum Kommentieren anmelden" - check_the_form: "Ungültige Eingabe. Bitte prüfen Sie Ihre Felder" - createdisplay: - check_the_form: "Ungültige Eingabe. Bitte prüfen Sie Ihre Felder" - continue: "Mit diesem Facebook-Profilnamen fortfahren" - error_create: "Fehler bei Änderung des Nutzernamens" - fake_comment_body: "Dies ist ein Beispiel-Kommentar. Leser können im Kommentarbereich Meinungen und Gedanken mit der Redaktion austauschen." - fake_comment_date: "Vor 1 Minute" - if_you_dont_change_your_name: "Wenn Sie Ihren Nutzernamen an diesem Punkt nicht ändern, wird Ihr Facebook-Name mit Ihren Kommentaren angezeigt." - required_field: "Pflichtfeld" - save: Speichern - special_characters: "Nutzernamen dürfen nur Buchstaben, Zahlen und _ enthalten" - username: Nutzername - write_your_username: "Nutzername bearbeiten" - your_username: "Ihr Nutzername erscheint an jedem Ihrer veröffentlichten Kommentare." + talk-plugin-auth: + login: + email_verify_cta: "Bitte bestätigen Sie Ihre E-Mail-Adresse." + request_new_verify_email: "E-Mail-Bestätigung erneut anfordern" + verify_email: "Danke, dass Sie ein Konto eingerichtet haben! Wir haben eine Nachricht an Ihre E-Mail-Adresse geschickt, mit der Sie Ihr Konto bestätigen können." + verify_email2: "Sie müssen ihr Konto bestätigen, bevor Sie beginnen können." + not_you: "Nicht Ihr Name?" + logged_in_as: "Angemeldet als" + facebook_sign_in: "Mit Facebook anmelden" + facebook_sign_up: "Mit Facebook registrieren" + logout: "Abmelden" + sign_in: "Anmelden" + sign_in_to_join: "Anmelden, um zu diskutieren" + or: "Oder" + email: "E-Mail-Adresse" + password: "Passwort" + forgot_your_pass: "Passwort vergessen?" + need_an_account: "Brauchen Sie ein Konto?" + register: "Registrieren" + sign_up: "Registrieren" + confirm_password: "Passwort bestätigen" + username: "Nutzername" + already_have_an_account: "Konto bereits vorhanden?" + recover_password: "Passwort zurücksetzen" + email_in_use: "E-Mail-Adresse bereits vergeben" + email_or_username_in_use: "E-Mail-Adresse oder Nutzername bereits vergeben" + required_field: "Pflichtfeld" + passwords_dont_match: "Passwörter nicht identisch." + special_characters: "Nutzernamen dürfen nur Buchstaben, Zahlen und _ enthalten" + sign_in_to_comment: "Zum Kommentieren anmelden" + check_the_form: "Ungültige Eingabe. Bitte prüfen Sie Ihre Felder" + set_username_dialog: + check_the_form: "Ungültige Eingabe. Bitte prüfen Sie Ihre Felder" + continue: "Mit diesem Facebook-Profilnamen fortfahren" + error_create: "Fehler bei Änderung des Nutzernamens" + fake_comment_body: "Dies ist ein Beispiel-Kommentar. Leser können im Kommentarbereich Meinungen und Gedanken mit der Redaktion austauschen." + fake_comment_date: "Vor 1 Minute" + if_you_dont_change_your_name: "Wenn Sie Ihren Nutzernamen an diesem Punkt nicht ändern, wird Ihr Facebook-Name mit Ihren Kommentaren angezeigt." + required_field: "Pflichtfeld" + save: Speichern + special_characters: "Nutzernamen dürfen nur Buchstaben, Zahlen und _ enthalten" + username: Nutzername + write_your_username: "Nutzername bearbeiten" + your_username: "Ihr Nutzername erscheint an jedem Ihrer veröffentlichten Kommentare." es: - sign_in: - email_verify_cta: "Por favor confirme su 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." - not_you: "¿No eres tu?" - logged_in_as: "Entraste como" - facebook_sign_in: "Entrar con Facebook" - facebook_sign_up: "Registrarse con Facebook" - logout: "Salir" - sign_in: "Entrar" - sign_in_to_join: "Entrar para unirte a la conversación" - or: "O" - email: "Dirección de Correo" - password: "Contraseña" - forgot_your_pass: "¿Has olvidado tu contraseña?" - need_an_account: "¿Necesitas una cuenta?" - register: "Registrar" - sign_up: "Registro" - confirm_password: "Confirmar Contraseña" - username: "Nombre" - already_have_an_account: "¿Ya tienes una cuenta?" - recover_password: "Recuperar la contraseña" - email_in_use: "Este correo se encuentra en uso" - email_or_username_in_use: "Este correo ó nombre de usuario se encuentran en uso" - required_field: "Este campo es requerido" - passwords_dont_match: "Las contraseñas no coinciden." - special_characters: "Los nombres pueden contener letras, números y _" - sign_in_to_comment: "Entrar para comentar" - check_the_form: "Formulario Inválido. Por favor, completa los campos" - createdisplay: - check_the_form: "Formulario Inválido. Por favor verifica los campos" - continue: "Continuar con el mismo nombre que aparece en Facebook" - error_create: "Hubo un error al cambiar el nombre de usuario" - fake_comment_body: "Este es un comentario de ejemplo. Las lectoras pueden compartir\ - \ sus ideas y opiniones con los periodistas en la sección de comentarios." - fake_comment_date: "hace un minuto" - if_you_dont_change_your_name: "Si no modificas tu nombre de usuario en este paso\ - \ tu nombre de Facebook aparecerá al lado de cada comentario que publiques." - required_field: "Campo requerido" - save: Guardar - special_characters: "Los nombres sólo pueden contener letras números y _" - username: Nombre - write_your_username: "Edita tu nombre" - your_username: "Tu nombre aparece en cada comentario que publiques." + talk-plugin-auth: + login: + email_verify_cta: "Por favor confirme su 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." + not_you: "¿No eres tu?" + logged_in_as: "Entraste como" + logout: "Salir" + sign_in: "Entrar" + sign_in_to_join: "Entrar para unirte a la conversación" + or: "O" + email: "Dirección de Correo" + password: "Contraseña" + forgot_your_pass: "¿Has olvidado tu contraseña?" + need_an_account: "¿Necesitas una cuenta?" + register: "Registrar" + sign_up: "Registro" + confirm_password: "Confirmar Contraseña" + username: "Nombre" + already_have_an_account: "¿Ya tienes una cuenta?" + recover_password: "Recuperar la contraseña" + email_in_use: "Este correo se encuentra en uso" + email_or_username_in_use: "Este correo ó nombre de usuario se encuentran en uso" + required_field: "Este campo es requerido" + passwords_dont_match: "Las contraseñas no coinciden." + special_characters: "Los nombres pueden contener letras, números y _" + sign_in_to_comment: "Entrar para comentar" + check_the_form: "Formulario Inválido. Por favor, completa los campos" + set_username_dialog: + check_the_form: "Formulario Inválido. Por favor verifica los campos" + continue: "Continuar con el mismo nombre que aparece en Facebook" + error_create: "Hubo un error al cambiar el nombre de usuario" + fake_comment_body: "Este es un comentario de ejemplo. Las lectoras pueden compartir\ + \ sus ideas y opiniones con los periodistas en la sección de comentarios." + fake_comment_date: "hace un minuto" + if_you_dont_change_your_name: "Si no modificas tu nombre de usuario en este paso\ + \ tu nombre de Facebook aparecerá al lado de cada comentario que publiques." + required_field: "Campo requerido" + save: Guardar + special_characters: "Los nombres sólo pueden contener letras números y _" + username: Nombre + write_your_username: "Edita tu nombre" + your_username: "Tu nombre aparece en cada comentario que publiques." 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" - 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 ?" - logged_in_as: "Connecté en tant que" - facebook_sign_in: "Connectez-vous avec Facebook" - facebook_sign_up: "Inscrivez-vous avec Facebook" - logout: "Se déconnecter" - sign_in: "Se connecter" - sign_in_to_join: "Connectez-vous pour participer à la conversation" - or: "Ou" - email: "Adresse e-mail" - password: "Mot de passe" - forgot_your_pass: "Mot de passe oublié ?" - need_an_account: "Besoin d'un compte ?" - register: "Inscription" - sign_up: "S'inscrire" - confirm_password: "Confirmez Le mot de passe" - username: "Nom d'utilisateur" - already_have_an_account: "Vous avez déjà un compte ?" - recover_password: "Récupérer le mot de passe" - email_in_use: "L'adresse e-mail est déjà utilisée" - email_or_username_in_use: "Adresse e-mail ou nom d'utilisateur déjà utilisé" - required_field: "Ce champ est obligatoire" - passwords_dont_match: "Les mots de passe ne correspondent pas." - special_characters: "Les noms d'utilisateur ne peuvent contenir que des lettres, des chiffres et \"_\" seulement" - sign_in_to_comment: "Connectez-vous pour commenter" - check_the_form: "Formulaire non valide. Veuillez vérifier les champs" - createdisplay: - check_the_form: "Formulaire non valide. Veuillez vérifier les champs" - continue: "Continuez avec le même nom d'utilisateur que sur Facebook" - error_create: "Erreur lors de la modification du nom d'utilisateur" - fake_comment_body: "Ceci est un exemple de commentaire. Les lecteurs peuvent partager leurs pensées et opinions avec les rédactions dans la section des commentaires." - fake_comment_date: "Il y a 1 minute" - if_you_dont_change_your_name: "Si vous ne modifiez pas votre nom d'utilisateur à cette étape, votre nom d'affichage Facebook apparaîtra à côté de tous vos commentaires." - required_field: "champs requis" - save: Sauvegarder - special_characters: "Les noms d'utilisateur ne peuvent contenir que des chiffres, des lettres et \"_\"" - username: "Nom d'utilisateur" - write_your_username: "Modifier votre nom d'utilisateur" - your_username: "Votre nom d'utilisateur apparaît sur chaque commentaire que vous publiez." + talk-plugin-auth: + login: + email_verify_cta: "Merci de vérifier votre adresse 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 ?" + logged_in_as: "Connecté en tant que" + logout: "Se déconnecter" + sign_in: "Se connecter" + sign_in_to_join: "Connectez-vous pour participer à la conversation" + or: "Ou" + email: "Adresse e-mail" + password: "Mot de passe" + forgot_your_pass: "Mot de passe oublié ?" + need_an_account: "Besoin d'un compte ?" + register: "Inscription" + sign_up: "S'inscrire" + confirm_password: "Confirmez Le mot de passe" + username: "Nom d'utilisateur" + already_have_an_account: "Vous avez déjà un compte ?" + recover_password: "Récupérer le mot de passe" + email_in_use: "L'adresse e-mail est déjà utilisée" + email_or_username_in_use: "Adresse e-mail ou nom d'utilisateur déjà utilisé" + required_field: "Ce champ est obligatoire" + passwords_dont_match: "Les mots de passe ne correspondent pas." + special_characters: "Les noms d'utilisateur ne peuvent contenir que des lettres, des chiffres et \"_\" seulement" + sign_in_to_comment: "Connectez-vous pour commenter" + check_the_form: "Formulaire non valide. Veuillez vérifier les champs" + set_username_dialog: + check_the_form: "Formulaire non valide. Veuillez vérifier les champs" + continue: "Continuez avec le même nom d'utilisateur que sur Facebook" + error_create: "Erreur lors de la modification du nom d'utilisateur" + fake_comment_body: "Ceci est un exemple de commentaire. Les lecteurs peuvent partager leurs pensées et opinions avec les rédactions dans la section des commentaires." + fake_comment_date: "Il y a 1 minute" + if_you_dont_change_your_name: "Si vous ne modifiez pas votre nom d'utilisateur à cette étape, votre nom d'affichage Facebook apparaîtra à côté de tous vos commentaires." + required_field: "champs requis" + save: Sauvegarder + special_characters: "Les noms d'utilisateur ne peuvent contenir que des chiffres, des lettres et \"_\"" + username: "Nom d'utilisateur" + write_your_username: "Modifier votre nom d'utilisateur" + your_username: "Votre nom d'utilisateur apparaît sur chaque commentaire que vous publiez." nl_NL: sign_in: - email_verify_cta: "Please verify your email address." - 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?" - logged_in_as: "Signed in as" - facebook_sign_in: "Sign in with Facebook" - facebook_sign_up: "Sign up with Facebook" - logout: "Sign out" - sign_in: "Sign in" - sign_in_to_join: "Sign in to join the conversation" - or: "Or" - email: "E-mail Address" - password: "Password" - forgot_your_pass: "Forgot your password?" - need_an_account: "Need an account?" - register: "Register" - sign_up: "Sign Up" - confirm_password: "Confirm Password" - username: "Username" - already_have_an_account: "Already have an account?" - recover_password: "Recover password" - email_in_use: "Email address already in use" - email_or_username_in_use: "Email address or Username already in use" - required_field: "This field is required" - passwords_dont_match: "Passwords don't match." - special_characters: "Usernames can contain letters, numbers and _ only" - sign_in_to_comment: "Sign in to comment" - check_the_form: "Invalid Form. Please, check the fields" + email_verify_cta: "Controleer je e-mailadres." + request_new_verify_email: "Vraag nieuwe bevestigingsemail aan" + verify_email: "Bedankt voor het aanmaken van een account! We hebben een email verstuurd naar het adres dat je hebt opgegeven om je account te verifiëren." + verify_email2: "Je account moet worden geverifiëerd voordat je kunt deelnemen in de community." + not_you: "Ben je dit niet?" + logged_in_as: "Ingelogd als" + facebook_sign_in: "Inloggen met Facebook" + facebook_sign_up: "Registreren met Facebook" + logout: "Uitloggen" + sign_in: "Inloggen" + sign_in_to_join: "Log in om deel te nemen" + or: "Of" + email: "E-mailadres" + password: "Wachtwoord" + forgot_your_pass: "Wachtwoord vergeten?" + need_an_account: "Heb je een account nodig?" + register: "Registreren" + sign_up: "Aanmelden" + confirm_password: "Bevestig wachtwoord" + username: "Gebruikersnaam" + already_have_an_account: "Heb je al een account?" + recover_password: "Wachtwoord herstellen" + email_in_use: "E-mailadres is al in gebruik" + email_or_username_in_use: "E-mailadres of gebruikersnaam is al in gebruik" + required_field: "Dit is een vereist veld" + passwords_dont_match: "Wachtwoorden komen niet overeen" + special_characters: "Gebruikersnamen kunnen alleen letters, cijfers en _ bevatten" + sign_in_to_comment: "Aanmelden om te reageren" + check_the_form: "Het formulier bevat ongeldige invoer, controleer je invoer" createdisplay: - check_the_form: "Invalid Form. Please check the fields" - continue: "Continue with the same Facebook username" - error_create: "Error when changing username" - fake_comment_body: "This is an example comment. Readers can share their thoughts and opinions with newsrooms in the comments section." - fake_comment_date: "1 minute ago" - if_you_dont_change_your_name: "If you don't change your username at this step your Facebook display name will appear alongside of all your comments." - required_field: "Required field" - save: Save - special_characters: "Usernames can contain letters numbers and _ only" - username: Username - write_your_username: "Edit your username" - your_username: "Your username appears on every comment you post." + check_the_form: "Het formulier bevat ongeldige invoer, controleer je invoer" + continue: "Doorgaan met dezelfde Facebook gebruikersnaam" + error_create: "Fout opgetreden bij het wijzigen van de gebruikersnaam" + fake_comment_body: "Dit is een voorbeeldreactie. Lezers kunnen hun gedachten en meningen met newsrooms delen in het reactie-gedeelte" + fake_comment_date: "1 minuut geleden" + if_you_dont_change_your_name: "Wanneer je je gebruikersnaam nu niet wijzigt, zal je Facebook naam bij al je reacties komen te staan." + required_field: "Vereist veld" + save: Opslaan + special_characters: "Gebruikersnamen kunnen alleen letters, cijfers en _ bevatten" + username: Gebruikersnaam + write_your_username: "Wijzig je gebruikersnaam" + your_username: "Je gebruikersnaam verschijnt bij al je reacties." pt_BR: - sign_in: - email_verify_cta: "Please verify your email address." - 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?" - logged_in_as: "Signed in as" - facebook_sign_in: "Sign in with Facebook" - facebook_sign_up: "Sign up with Facebook" - logout: "Sign out" - sign_in: "Sign in" - sign_in_to_join: "Sign in to join the conversation" - or: "Or" - email: "E-mail Address" - password: "Password" - forgot_your_pass: "Forgot your password?" - need_an_account: "Need an account?" - register: "Register" - sign_up: "Sign Up" - confirm_password: "Confirm Password" - username: "Username" - already_have_an_account: "Already have an account?" - recover_password: "Recover password" - email_in_use: "Email address already in use" - email_or_username_in_use: "Email address or Username already in use" - required_field: "This field is required" - passwords_dont_match: "Passwords don't match." - special_characters: "Usernames can contain letters, numbers and _ only" - sign_in_to_comment: "Sign in to comment" - check_the_form: "Invalid Form. Please, check the fields" - createdisplay: - check_the_form: "Invalid Form. Please check the fields" - continue: "Continue with the same Facebook username" - error_create: "Error when changing username" - fake_comment_body: "This is an example comment. Readers can share their thoughts and opinions with newsrooms in the comments section." - fake_comment_date: "1 minute ago" - if_you_dont_change_your_name: "If you don't change your username at this step your Facebook display name will appear alongside of all your comments." - required_field: "Required field" - save: Save - special_characters: "Usernames can contain letters numbers and _ only" - username: Username - write_your_username: "Edit your username" - your_username: "Your username appears on every comment you post." + talk-plugin-auth: + login: + email_verify_cta: "Please verify your email address." + 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?" + logged_in_as: "Signed in as" + facebook_sign_in: "Sign in with Facebook" + facebook_sign_up: "Sign up with Facebook" + logout: "Sign out" + sign_in: "Sign in" + sign_in_to_join: "Sign in to join the conversation" + or: "Or" + email: "E-mail Address" + password: "Password" + forgot_your_pass: "Forgot your password?" + need_an_account: "Need an account?" + register: "Register" + sign_up: "Sign Up" + confirm_password: "Confirm Password" + username: "Username" + already_have_an_account: "Already have an account?" + recover_password: "Recover password" + email_in_use: "Email address already in use" + email_or_username_in_use: "Email address or Username already in use" + required_field: "This field is required" + passwords_dont_match: "Passwords don't match." + special_characters: "Usernames can contain letters, numbers and _ only" + sign_in_to_comment: "Sign in to comment" + check_the_form: "Invalid Form. Please, check the fields" + set_username_dialog: + check_the_form: "Invalid Form. Please check the fields" + continue: "Continue with the same Facebook username" + error_create: "Error when changing username" + fake_comment_body: "This is an example comment. Readers can share their thoughts and opinions with newsrooms in the comments section." + fake_comment_date: "1 minute ago" + if_you_dont_change_your_name: "If you don't change your username at this step your Facebook display name will appear alongside of all your comments." + required_field: "Required field" + save: Save + special_characters: "Usernames can contain letters numbers and _ only" + username: Username + write_your_username: "Edit your username" + your_username: "Your username appears on every comment you post." zh_CN: - sign_in: - email_verify_cta: "请确认您的邮箱地址。" - request_new_verify_email: "请求再次发送邮件" - verify_email: "感谢您创建帐号。我们已向您提供的地址发送了封邮件,您可以通过邮件验证帐号。" - verify_email2: "您参与社群前须验证帐号。" - not_you: "不是你?" - logged_in_as: "登录身份" - facebook_sign_in: "使用 Facebook 帐号" - facebook_sign_up: "使用 Facebook 帐号" - logout: "登出" - sign_in: "登入" - sign_in_to_join: "登入以加入对话" - or: "或" - email: "邮箱地址" - password: "密码" - forgot_your_pass: "忘记密码?" - need_an_account: "需要帐号?" - register: "注册" - sign_up: "注册" - confirm_password: "确认密码" - username: "用户名" - already_have_an_account: "已有帐号?" - recover_password: "恢复密码" - email_in_use: "邮箱地址已被使用" - email_or_username_in_use: "邮箱地址或用户名已被使用" - required_field: "该字段必填" - passwords_dont_match: "密码不匹配。" - special_characters: "用户名只能包含字母、数字跟下划线" - sign_in_to_comment: "登入以评论" - check_the_form: "表格无效。请检查字段。" - createdisplay: - check_the_form: "表格无效。请检查字段。" - continue: "使用 Facebook 用户名" - error_create: "更改用户名时发生了错误" - fake_comment_body: "此为评论样例。读者可以在评论部分中分享他们关于新闻编辑室的想法和意见。" - fake_comment_date: "1 分钟前" - if_you_dont_change_your_name: "若您不在此改变用户名,您的 Facebook 用户名将随您的评论一同出现。" - required_field: "必填字段" - save: "保存" - special_characters: "用户名只能包含字母、数字跟下划线" - username: "用户名" - write_your_username: "编辑您的用户名" - your_username: "您的用户名将随您发表的所有评论一同出现。" + talk-plugin-auth: + login: + email_verify_cta: "请确认您的邮箱地址。" + request_new_verify_email: "请求再次发送邮件" + verify_email: "感谢您创建帐号。我们已向您提供的地址发送了封邮件,您可以通过邮件验证帐号。" + verify_email2: "您参与社群前须验证帐号。" + not_you: "不是你?" + logged_in_as: "登录身份" + logout: "登出" + sign_in: "登入" + sign_in_to_join: "登入以加入对话" + or: "或" + email: "邮箱地址" + password: "密码" + forgot_your_pass: "忘记密码?" + need_an_account: "需要帐号?" + register: "注册" + sign_up: "注册" + confirm_password: "确认密码" + username: "用户名" + already_have_an_account: "已有帐号?" + recover_password: "恢复密码" + email_in_use: "邮箱地址已被使用" + email_or_username_in_use: "邮箱地址或用户名已被使用" + required_field: "该字段必填" + passwords_dont_match: "密码不匹配。" + special_characters: "用户名只能包含字母、数字跟下划线" + sign_in_to_comment: "登入以评论" + check_the_form: "表格无效。请检查字段。" + set_username_dialog: + check_the_form: "表格无效。请检查字段。" + continue: "使用 Facebook 用户名" + error_create: "更改用户名时发生了错误" + fake_comment_body: "此为评论样例。读者可以在评论部分中分享他们关于新闻编辑室的想法和意见。" + fake_comment_date: "1 分钟前" + if_you_dont_change_your_name: "若您不在此改变用户名,您的 Facebook 用户名将随您的评论一同出现。" + required_field: "必填字段" + save: "保存" + special_characters: "用户名只能包含字母、数字跟下划线" + username: "用户名" + write_your_username: "编辑您的用户名" + your_username: "您的用户名将随您发表的所有评论一同出现。" zh_TW: - sign_in: - email_verify_cta: "請確認您的郵箱地址。" - request_new_verify_email: "請求再次發送郵件" - verify_email: "感謝您創建帳號。我們已向您提供的地址發送了封郵件,您可以通過郵件驗證帳號。" - verify_email2: "您參與社群前須驗證帳號。" - not_you: "不是你?" - logged_in_as: "登錄身份" - facebook_sign_in: "使用 Facebook 帳號" - facebook_sign_up: "使用 Facebook 帳號" - logout: "登出" - sign_in: "登入" - sign_in_to_join: "登入以加入對話" - or: "或" - email: "郵箱地址" - password: "密碼" - forgot_your_pass: "忘記密碼?" - need_an_account: "需要帳號?" - register: "註冊" - sign_up: "註冊" - confirm_password: "確認密碼" - username: "用戶名" - already_have_an_account: "已有帳號?" - recover_password: "恢覆密碼" - email_in_use: "郵箱地址已被使用" - email_or_username_in_use: "郵箱地址或用戶名已被使用" - required_field: "該字段必填" - passwords_dont_match: "密碼不匹配。" - special_characters: "用戶名只能包含字母、數字跟下劃線" - sign_in_to_comment: "登入以評論" - check_the_form: "表格無效。請檢查字段。" - createdisplay: - check_the_form: "表格無效。請檢查字段。" - continue: "使用 Facebook 用戶名" - error_create: "更改用戶名時發生了錯誤" - fake_comment_body: "此為評論樣例。讀者可以在評論部分中分享他們關於新聞編輯室的想法和意見。" - fake_comment_date: "1 分鐘前" - if_you_dont_change_your_name: "若您不在此改變用戶名,您的 Facebook 用戶名將隨您的評論一同出現。" - required_field: "必填字段" - save: "保存" - special_characters: "用戶名只能包含字母、數字跟下劃線" - username: "用戶名" - write_your_username: "編輯您的用戶名" - your_username: "您的用戶名將隨您發表的所有評論一同出現。" + talk-plugin-auth: + login: + email_verify_cta: "請確認您的郵箱地址。" + request_new_verify_email: "請求再次發送郵件" + verify_email: "感謝您創建帳號。我們已向您提供的地址發送了封郵件,您可以通過郵件驗證帳號。" + verify_email2: "您參與社群前須驗證帳號。" + not_you: "不是你?" + logged_in_as: "登錄身份" + logout: "登出" + sign_in: "登入" + sign_in_to_join: "登入以加入對話" + or: "或" + email: "郵箱地址" + password: "密碼" + forgot_your_pass: "忘記密碼?" + need_an_account: "需要帳號?" + register: "註冊" + sign_up: "註冊" + confirm_password: "確認密碼" + username: "用戶名" + already_have_an_account: "已有帳號?" + recover_password: "恢覆密碼" + email_in_use: "郵箱地址已被使用" + email_or_username_in_use: "郵箱地址或用戶名已被使用" + required_field: "該字段必填" + passwords_dont_match: "密碼不匹配。" + special_characters: "用戶名只能包含字母、數字跟下劃線" + sign_in_to_comment: "登入以評論" + check_the_form: "表格無效。請檢查字段。" + set_username_dialog: + check_the_form: "表格無效。請檢查字段。" + continue: "使用 Facebook 用戶名" + error_create: "更改用戶名時發生了錯誤" + fake_comment_body: "此為評論樣例。讀者可以在評論部分中分享他們關於新聞編輯室的想法和意見。" + fake_comment_date: "1 分鐘前" + if_you_dont_change_your_name: "若您不在此改變用戶名,您的 Facebook 用戶名將隨您的評論一同出現。" + required_field: "必填字段" + save: "保存" + special_characters: "用戶名只能包含字母、數字跟下劃線" + username: "用戶名" + write_your_username: "編輯您的用戶名" + your_username: "您的用戶名將隨您發表的所有評論一同出現。" diff --git a/plugins/talk-plugin-facebook-auth/client/.eslintrc.json b/plugins/talk-plugin-facebook-auth/client/.eslintrc.json new file mode 100644 index 000000000..c8a6db18a --- /dev/null +++ b/plugins/talk-plugin-facebook-auth/client/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "@coralproject/eslint-config-talk/client" +} diff --git a/plugins/talk-plugin-facebook-auth/client/actions.js b/plugins/talk-plugin-facebook-auth/client/actions.js new file mode 100644 index 000000000..5a9ebc66f --- /dev/null +++ b/plugins/talk-plugin-facebook-auth/client/actions.js @@ -0,0 +1,7 @@ +export const loginWithFacebook = () => (dispatch, _, { rest }) => { + window.open( + `${rest.uri}/auth/facebook`, + 'Continue with Facebook', + 'menubar=0,resizable=0,width=500,height=500,top=200,left=500' + ); +}; diff --git a/plugins/talk-plugin-facebook-auth/client/components/FacebookButton.css b/plugins/talk-plugin-facebook-auth/client/components/FacebookButton.css new file mode 100644 index 000000000..694d6a322 --- /dev/null +++ b/plugins/talk-plugin-facebook-auth/client/components/FacebookButton.css @@ -0,0 +1,13 @@ +.button { + background-color: #4267b2; + border-color: #4267b2; + color: rgb(255, 255, 255); + width: 100%; + box-sizing: border-box; + padding: 10px 20px; +} + +.button:hover { + background-color: #365899; + border-color: #365899; +} diff --git a/plugins/talk-plugin-facebook-auth/client/components/FacebookButton.js b/plugins/talk-plugin-facebook-auth/client/components/FacebookButton.js new file mode 100644 index 000000000..d9d1c0ab1 --- /dev/null +++ b/plugins/talk-plugin-facebook-auth/client/components/FacebookButton.js @@ -0,0 +1,11 @@ +import React from 'react'; +import { BareButton } from 'plugin-api/beta/client/components/ui'; +import styles from './FacebookButton.css'; + +export default ({ onClick, children }) => { + return ( + + {children} + + ); +}; diff --git a/plugins/talk-plugin-facebook-auth/client/components/SignIn.js b/plugins/talk-plugin-facebook-auth/client/components/SignIn.js new file mode 100644 index 000000000..1909c1aba --- /dev/null +++ b/plugins/talk-plugin-facebook-auth/client/components/SignIn.js @@ -0,0 +1,9 @@ +import React from 'react'; +import FacebookButton from '../containers/FacebookButton'; +import { t } from 'plugin-api/beta/client/services'; + +export default () => { + return ( + {t('talk-plugin-facebook-auth.sign_in')} + ); +}; diff --git a/plugins/talk-plugin-facebook-auth/client/components/SignUp.js b/plugins/talk-plugin-facebook-auth/client/components/SignUp.js new file mode 100644 index 000000000..b0a185e3f --- /dev/null +++ b/plugins/talk-plugin-facebook-auth/client/components/SignUp.js @@ -0,0 +1,9 @@ +import React from 'react'; +import FacebookButton from '../containers/FacebookButton'; +import { t } from 'plugin-api/beta/client/services'; + +export default () => { + return ( + {t('talk-plugin-facebook-auth.sign_up')} + ); +}; diff --git a/plugins/talk-plugin-facebook-auth/client/containers/FacebookButton.js b/plugins/talk-plugin-facebook-auth/client/containers/FacebookButton.js new file mode 100644 index 000000000..8ae3ff01e --- /dev/null +++ b/plugins/talk-plugin-facebook-auth/client/containers/FacebookButton.js @@ -0,0 +1,9 @@ +import { connect } from 'plugin-api/beta/client/hocs'; +import { bindActionCreators } from 'redux'; +import { loginWithFacebook } from '../actions'; +import FacebookButton from '../components/FacebookButton'; + +const mapDispatchToProps = dispatch => + bindActionCreators({ onClick: loginWithFacebook }, dispatch); + +export default connect(null, mapDispatchToProps)(FacebookButton); diff --git a/plugins/talk-plugin-facebook-auth/client/index.js b/plugins/talk-plugin-facebook-auth/client/index.js new file mode 100644 index 000000000..cb8a8f059 --- /dev/null +++ b/plugins/talk-plugin-facebook-auth/client/index.js @@ -0,0 +1,11 @@ +import SignIn from './components/SignIn'; +import SignUp from './components/SignUp'; +import translations from './translations.yml'; + +export default { + translations, + slots: { + authExternalSignIn: [SignIn], + authExternalSignUp: [SignUp], + }, +}; diff --git a/plugins/talk-plugin-facebook-auth/client/translations.yml b/plugins/talk-plugin-facebook-auth/client/translations.yml new file mode 100644 index 000000000..35a5c255c --- /dev/null +++ b/plugins/talk-plugin-facebook-auth/client/translations.yml @@ -0,0 +1,20 @@ +en: + talk-plugin-facebook-auth: + sign_in: "Sign in with Facebook" + sign_up: "Sign up with Facebook" +es: + talk-plugin-facebook-auth: + facebook_sign_in: "Entrar con Facebook" + facebook_sign_up: "Registrarse con Facebook" +fr: + talk-plugin-facebook-auth: + facebook_sign_in: "Connectez-vous avec Facebook" + facebook_sign_up: "Inscrivez-vous avec Facebook" +zh_CN: + talk-plugin-facebook-auth: + facebook_sign_in: "使用 Facebook 帐号" + facebook_sign_up: "使用 Facebook 帐号" +zh_TW: + talk-plugin-facebook-auth: + facebook_sign_in: "使用 Facebook 帳號" + facebook_sign_up: "使用 Facebook 帳號" diff --git a/plugins/talk-plugin-featured-comments/client/translations.yml b/plugins/talk-plugin-featured-comments/client/translations.yml index 553f218c1..003372403 100644 --- a/plugins/talk-plugin-featured-comments/client/translations.yml +++ b/plugins/talk-plugin-featured-comments/client/translations.yml @@ -72,19 +72,19 @@ fr: yes_feature_comment: Yes, feature comment nl_NL: talk-plugin-featured-comments: - un_feature: Un-Feature - feature: Feature - featured: Featured - featured_comments: Featured Comments - go_to_conversation: Go to conversation - tooltip_description: Comments selected by our team as worth reading - notify_self_featured: 'The comment from {0} is now featured and approved' - notify_featured: '{0} featured and approved comment "{1}"' - notify_unfeatured: '{0} unfeatured comment "{1}"' - feature_comment: Feature comment? - are_you_sure: Are you sure you would like to feature this comment? - cancel: Cancel - yes_feature_comment: Yes, feature comment + un_feature: Uitlichting opheffen. + feature: Uitlichten + featured: Uitgelicht + featured_comments: Uitgelichte reacties + go_to_conversation: Ga naar conversatie + tooltip_description: Reacties die ons team de moeite waard vind om te lezen. + notify_self_featured: 'De reactie van {0} is nu uitgelicht en goedgekeurd' + notify_featured: '{0} heeft de volgende reactie uitgelicht en goedgekeurd: "{1}"' + notify_unfeatured: '{0} hief de uitlichting op van reactie "{1}"' + feature_comment: Reactie uitlichten? + are_you_sure: Weet je zeker dat je deze reactie wil uitlichten? + cancel: Annuleren + yes_feature_comment: Ja, reactie uitlichten. pt_BR: talk-plugin-featured-comments: un_feature: Un-Feature diff --git a/plugins/talk-plugin-flag-details/client/translations.yml b/plugins/talk-plugin-flag-details/client/translations.yml index 7346fdf79..b0fe6ccae 100644 --- a/plugins/talk-plugin-flag-details/client/translations.yml +++ b/plugins/talk-plugin-flag-details/client/translations.yml @@ -15,7 +15,7 @@ fr: flags: Reports nl_NL: talk-plugin-flag-details: - flags: Reports + flags: Meldingen pt_BR: talk-plugin-flag-details: flags: Reports diff --git a/plugins/talk-plugin-ignore-user/client/translations.yml b/plugins/talk-plugin-ignore-user/client/translations.yml index 2d4c2ebdd..4f32c535d 100644 --- a/plugins/talk-plugin-ignore-user/client/translations.yml +++ b/plugins/talk-plugin-ignore-user/client/translations.yml @@ -62,17 +62,17 @@ fr: confirmation_title: Ignore {0}? nl_NL: talk-plugin-ignore-user: - section_title: Ignored users - section_info: Because you ignored the following commenters, their comments are hidden. - stop_ignoring: Stop ignoring - ignore_user: Ignore User - cancel: Cancel + section_title: Genegeerde gebruikers + section_info: Omdat je de volgende reageerders negeert, worden hun reacties verborgen. + stop_ignoring: Stop met negeren + ignore_user: Gebruiker negeren + cancel: Annuleren confirmation: | - When you ignore a user, all comments they wrote on the site will be hidden from you. You can - undo this later from My Profile. + Wanneer je een gebruiker negeert, zullen al zijn/haar reacties voor jou worden verborgen. Je kunt + dit later ongedaan maken vanuit Mijn profiel. notify_success: | - You are now ignoring {0}. You can undo this action from My Profile. - confirmation_title: Ignore {0}? + Je negeert nu {0}. Je kunt dit ongedaan maken vanuit Mijn profiel. + confirmation_title: Negeer {0}? pt_BR: talk-plugin-ignore-user: section_title: "Usuários ignorados" diff --git a/plugins/talk-plugin-love/client/translations.yml b/plugins/talk-plugin-love/client/translations.yml index eef4d68b1..24744c2f9 100644 --- a/plugins/talk-plugin-love/client/translations.yml +++ b/plugins/talk-plugin-love/client/translations.yml @@ -20,8 +20,8 @@ fr: loved: Loved nl_NL: talk-plugin-love: - love: Love - loved: Loved + love: Ik hou er van + loved: Geliefd pt_BR: talk-plugin-love: love: Love diff --git a/plugins/talk-plugin-member-since/client/translations.yml b/plugins/talk-plugin-member-since/client/translations.yml index 87a8dab6a..f63f5a1fd 100644 --- a/plugins/talk-plugin-member-since/client/translations.yml +++ b/plugins/talk-plugin-member-since/client/translations.yml @@ -15,7 +15,7 @@ fr: member_since: "Member Since" nl_NL: talk-plugin-member-since: - member_since: "Member Since" + member_since: "Gebruiker sinds" pt_BR: talk-plugin-member-since: member_since: "Member Since" diff --git a/plugins/talk-plugin-moderation-actions/client/translations.yml b/plugins/talk-plugin-moderation-actions/client/translations.yml index ce3b9ead3..9052d03f5 100644 --- a/plugins/talk-plugin-moderation-actions/client/translations.yml +++ b/plugins/talk-plugin-moderation-actions/client/translations.yml @@ -60,16 +60,16 @@ fr: ban_user_dialog_headline: "Ban User?" nl_NL: talk-plugin-moderation-actions: - reject_comment: "Reject" - approve_comment: "Approve" - approved_comment: "Approved" - moderation_actions: "Moderation Actions" - ban_user: "Ban User" - ban_user_dialog_sub: "Are you sure you would like to ban this user?" - ban_user_dialog_copy: "Note: Banning this user will also place this comment in the Rejected queue." - ban_user_dialog_cancel: "Cancel" - ban_user_dialog_yes: "Yes. Ban user" - ban_user_dialog_headline: "Ban User?" + reject_comment: "Afkeuren" + approve_comment: "Goedkeuren" + approved_comment: "Goedgekeurd" + moderation_actions: "Moderatie handelingen" + ban_user: "Verban gebruiker" + ban_user_dialog_sub: "Weet je zeker dat je deze gebruiker wil verbannen?" + ban_user_dialog_copy: "Let op: Wanneer je deze gebruiker verbant zal zijn/haar reactie ook in de Afgekeurde reacties worden geplaatst." + ban_user_dialog_cancel: "Annuleren" + ban_user_dialog_yes: "Ja, verban gebruiker" + ban_user_dialog_headline: "Gebruiker verbannen?" pt_BR: talk-plugin-moderation-actions: reject_comment: "Reject" diff --git a/plugins/talk-plugin-offtopic/client/translations.json b/plugins/talk-plugin-offtopic/client/translations.json index a7050c1fe..826b3e15b 100644 --- a/plugins/talk-plugin-offtopic/client/translations.json +++ b/plugins/talk-plugin-offtopic/client/translations.json @@ -20,8 +20,8 @@ "hide_off_topic": "Hide Off-Topic Comments" }, "nl_NL": { - "off_topic": "Off Topic", - "hide_off_topic": "Hide Off-Topic Comments" + "off_topic": "Off topic", + "hide_off_topic": "Off-topic reacties verbergen" }, "pt_BR": { "off_topic": "Off Topic", diff --git a/plugins/talk-plugin-respect/client/translations.yml b/plugins/talk-plugin-respect/client/translations.yml index f4416b0c7..22497ea52 100644 --- a/plugins/talk-plugin-respect/client/translations.yml +++ b/plugins/talk-plugin-respect/client/translations.yml @@ -21,7 +21,7 @@ fr: nl_NL: talk-plugin-respect: respect: Respect - respected: Respected + respected: Gerespecteerd pt_BR: talk-plugin-respect: respect: Respect diff --git a/plugins/talk-plugin-sort-most-liked/client/translations.yml b/plugins/talk-plugin-sort-most-liked/client/translations.yml index 0457d5d23..f9e48d261 100644 --- a/plugins/talk-plugin-sort-most-liked/client/translations.yml +++ b/plugins/talk-plugin-sort-most-liked/client/translations.yml @@ -15,7 +15,7 @@ fr: label: Most liked first nl_NL: talk-plugin-sort-most-liked: - label: Most liked first + label: Meest geliked eerst pt_BR: talk-plugin-sort-most-liked: label: Most liked first diff --git a/plugins/talk-plugin-sort-most-loved/client/translations.yml b/plugins/talk-plugin-sort-most-loved/client/translations.yml index 4bec16536..c290e4a53 100644 --- a/plugins/talk-plugin-sort-most-loved/client/translations.yml +++ b/plugins/talk-plugin-sort-most-loved/client/translations.yml @@ -15,7 +15,7 @@ fr: label: Most loved first nl_NL: talk-plugin-sort-most-loved: - label: Most loved first + label: Meest geliefd eerst pt_BR: talk-plugin-sort-most-loved: label: Most loved first diff --git a/plugins/talk-plugin-sort-most-replied/client/translations.yml b/plugins/talk-plugin-sort-most-replied/client/translations.yml index 9d61741f2..dacdb93fb 100644 --- a/plugins/talk-plugin-sort-most-replied/client/translations.yml +++ b/plugins/talk-plugin-sort-most-replied/client/translations.yml @@ -15,7 +15,7 @@ fr: label: Most replied first nl_NL: talk-plugin-sort-most-replied: - label: Most replied first + label: Meest beantwoord eerst pt_BR: talk-plugin-sort-most-replied: label: Most replied first diff --git a/plugins/talk-plugin-sort-most-respected/client/translations.yml b/plugins/talk-plugin-sort-most-respected/client/translations.yml index 1e238d2e0..736cc63c1 100644 --- a/plugins/talk-plugin-sort-most-respected/client/translations.yml +++ b/plugins/talk-plugin-sort-most-respected/client/translations.yml @@ -15,7 +15,7 @@ fr: label: Most respected first nl_NL: talk-plugin-sort-most-respected: - label: Most respected first + label: Meest gerespecteerd eerst pt_BR: talk-plugin-sort-most-respected: label: Most respected first diff --git a/plugins/talk-plugin-sort-newest/client/translations.yml b/plugins/talk-plugin-sort-newest/client/translations.yml index 56bf9946c..e85eec18e 100644 --- a/plugins/talk-plugin-sort-newest/client/translations.yml +++ b/plugins/talk-plugin-sort-newest/client/translations.yml @@ -15,7 +15,7 @@ fr: label: Newest first nl_NL: talk-plugin-sort-newest: - label: Newest first + label: Nieuwste eerst pt_BR: talk-plugin-sort-newest: label: Newest first diff --git a/plugins/talk-plugin-sort-oldest/client/translations.yml b/plugins/talk-plugin-sort-oldest/client/translations.yml index ab4f9e97d..f12fe8e43 100644 --- a/plugins/talk-plugin-sort-oldest/client/translations.yml +++ b/plugins/talk-plugin-sort-oldest/client/translations.yml @@ -15,7 +15,7 @@ fr: label: Oldest first nl_NL: talk-plugin-sort-oldest: - label: Oldest first + label: Oudste eerst pt_BR: talk-plugin-sort-oldest: label: Oldest first diff --git a/plugins/talk-plugin-subscriber/client/translations.yml b/plugins/talk-plugin-subscriber/client/translations.yml index 33801a22b..65b438779 100644 --- a/plugins/talk-plugin-subscriber/client/translations.yml +++ b/plugins/talk-plugin-subscriber/client/translations.yml @@ -15,7 +15,7 @@ fr: subscriber: "Subscriber" nl_NL: talk-plugin-subscriber: - subscriber: "Subscriber" + subscriber: "Abonnee" pt_BR: talk-plugin-subscriber: subscriber: "Subscriber" diff --git a/plugins/talk-plugin-toxic-comments/client/components/ToxicDetail.js b/plugins/talk-plugin-toxic-comments/client/components/ToxicDetail.js index 6a278b63b..9530188f2 100644 --- a/plugins/talk-plugin-toxic-comments/client/components/ToxicDetail.js +++ b/plugins/talk-plugin-toxic-comments/client/components/ToxicDetail.js @@ -29,7 +29,7 @@ const getInfo = (toxicity, actions) => { const ToxicLabel = ({ comment: { actions, toxicity } }) => ( diff --git a/plugins/talk-plugin-toxic-comments/client/components/ToxicLabel.js b/plugins/talk-plugin-toxic-comments/client/components/ToxicLabel.js index cb9bca87e..d0f5bbe29 100644 --- a/plugins/talk-plugin-toxic-comments/client/components/ToxicLabel.js +++ b/plugins/talk-plugin-toxic-comments/client/components/ToxicLabel.js @@ -1,6 +1,6 @@ import React from 'react'; import { FlagLabel } from 'plugin-api/beta/client/components/ui'; -const ToxicLabel = () => Toxic; +const ToxicLabel = () => Toxic; export default ToxicLabel; diff --git a/plugins/talk-plugin-toxic-comments/client/translations.yml b/plugins/talk-plugin-toxic-comments/client/translations.yml index c6ee6d0a2..10fe80ec3 100644 --- a/plugins/talk-plugin-toxic-comments/client/translations.yml +++ b/plugins/talk-plugin-toxic-comments/client/translations.yml @@ -91,21 +91,21 @@ fr: nl_NL: error: COMMENT_IS_TOXIC: | - Are you sure? The language in this comment might violate our community guidelines. - You can edit the comment or submit it for moderator review. + Weet je het zeker? Het taalgebruik in deze reactie kan de community richtlijnen schenden. + Je kan je reactie wijzigen of je reactie indienen zodat ons moderatieteam het kan beoordelen. talk-plugin-toxic-comments: - unlikely: "Unlikely" - highly_likely: "Highly Likely" - possibly: "Possibly" - likely: "Likely" + unlikely: "Onwaarschijnlijk" + highly_likely: "Heel waarschijnlijk" + possibly: "Mogelijk" + likely: "Waarschijnlijk" toxic_comment: "Toxic Comment" still_toxic: | - This edited comment might still violate our community guidelines. - Our moderation team will review your comment shortly. + Deze gewijzigde reactie kan nog steeds onze community richtlijnen schenden + Ons moderatieteam zal je reactie beoordelen. flags: reasons: comment: - toxic_comment: "Highly likely to be Toxic" + toxic_comment: "Zeer waarschijnlijk toxic" pt_BR: error: COMMENT_IS_TOXIC: | diff --git a/plugins/talk-plugin-viewing-options/client/translations.yml b/plugins/talk-plugin-viewing-options/client/translations.yml index c37090b12..1e4eada60 100644 --- a/plugins/talk-plugin-viewing-options/client/translations.yml +++ b/plugins/talk-plugin-viewing-options/client/translations.yml @@ -25,8 +25,8 @@ fr: filter: Filtering nl_NL: talk-plugin-viewing-options: - viewing_options: "Viewing Options" - sort: Sorting + viewing_options: "Weergave opties" + sort: Sortering filter: Filtering pt_BR: talk-plugin-viewing-options: diff --git a/routes/admin/index.js b/routes/admin/index.js index 66f9c123d..ce686bd85 100644 --- a/routes/admin/index.js +++ b/routes/admin/index.js @@ -1,6 +1,12 @@ const express = require('express'); const router = express.Router(); +if (process.env.NODE_ENV !== 'production') { + router.get('/docs', (req, res) => { + res.render('admin/docs'); + }); +} + router.get('/confirm-email', (req, res) => { res.render('admin/confirm-email'); }); diff --git a/routes/api/index.js b/routes/api/index.js index 5f704fcda..f6651a4f5 100644 --- a/routes/api/index.js +++ b/routes/api/index.js @@ -1,17 +1,7 @@ const express = require('express'); -const pkg = require('../../package.json'); - const router = express.Router(); -router.get('/', (req, res) => { - res.json({ version: pkg.version }); -}); - -router.use('/assets', require('./assets')); -router.use('/settings', require('./settings')); -router.use('/auth', require('./auth')); -router.use('/users', require('./users')); -router.use('/account', require('./account')); -router.use('/setup', require('./setup')); +// Return the current version. +router.use('/v1', require('./v1')); module.exports = router; diff --git a/routes/api/settings/index.js b/routes/api/settings/index.js deleted file mode 100644 index 1cad31146..000000000 --- a/routes/api/settings/index.js +++ /dev/null @@ -1,29 +0,0 @@ -const express = require('express'); -const SettingsService = require('../../../services/settings'); -const authorization = require('../../../middleware/authorization'); - -const router = express.Router(); - -router.get( - '/', - authorization.needed('ADMIN', 'MODERATOR'), - async (req, res, next) => { - try { - let settings = await SettingsService.retrieve(); - res.json(settings); - } catch (e) { - return next(e); - } - } -); - -router.put('/', authorization.needed('ADMIN'), async (req, res, next) => { - try { - await SettingsService.update(req.body); - res.status(204).end(); - } catch (e) { - return next(e); - } -}); - -module.exports = router; diff --git a/routes/api/account/index.js b/routes/api/v1/account.js similarity index 89% rename from routes/api/account/index.js rename to routes/api/v1/account.js index c666d9f2f..e1395323d 100644 --- a/routes/api/account/index.js +++ b/routes/api/v1/account.js @@ -5,10 +5,7 @@ const mailer = require('../../../services/mailer'); const authorization = require('../../../middleware/authorization'); const errors = require('../../../errors'); -//============================================================================== -// ROUTES -//============================================================================== - +// Return the current logged in user. router.get('/', authorization.needed(), (req, res, next) => { res.json(req.user); }); @@ -45,9 +42,8 @@ const tokenCheck = (verifier, error) => async (req, res, next) => { next(); }; -// POST /email/confirm takes the password confirmation token available as a -// payload parameter and if it verifies, it updates the confirmed_at date on the -// local profile. +// Takes the password confirmation token available as a payload parameter and if +// it verifies, it updates the confirmed_at date on the local profile. router.post( '/email/verify', tokenCheck( @@ -67,6 +63,7 @@ router.post( } ); +// Sends the password reset email if the user exists. router.post('/password/reset', async (req, res, next) => { const { email, loc } = req.body; @@ -93,6 +90,7 @@ router.post('/password/reset', async (req, res, next) => { } }); +// Executes the password reset. router.put( '/password/reset', tokenCheck( diff --git a/routes/api/assets/index.js b/routes/api/v1/assets.js similarity index 99% rename from routes/api/assets/index.js rename to routes/api/v1/assets.js index 47aca1cc8..e3bd21dd4 100644 --- a/routes/api/assets/index.js +++ b/routes/api/v1/assets.js @@ -1,10 +1,8 @@ const express = require('express'); const router = express.Router(); const authorization = require('../../../middleware/authorization'); - const errors = require('../../../errors'); const AssetsService = require('../../../services/assets'); - const AssetModel = require('../../../models/asset'); const FilterOpenAssets = (query, filter) => { diff --git a/routes/api/auth/index.js b/routes/api/v1/auth.js similarity index 89% rename from routes/api/auth/index.js rename to routes/api/v1/auth.js index 90912a537..369f4278a 100644 --- a/routes/api/auth/index.js +++ b/routes/api/v1/auth.js @@ -4,7 +4,6 @@ const { HandleGenerateCredentials, HandleLogout, } = require('../../../services/passport'); - const router = express.Router(); /** @@ -27,15 +26,14 @@ router.get('/', (req, res, next) => { /** * This blacklists the token used to authenticate. */ - router.delete('/', HandleLogout); -// ============================================================================= +//============================================================================== // PASSPORT ROUTES //============================================================================== /** - * Local auth endpoint, will recieve a email and password + * Local auth endpoint, will receive a email and password */ router.post('/local', (req, res, next) => { // Perform the local authentication. diff --git a/routes/api/v1/graph.js b/routes/api/v1/graph.js new file mode 100644 index 000000000..7d13d4c92 --- /dev/null +++ b/routes/api/v1/graph.js @@ -0,0 +1,19 @@ +const express = require('express'); +const apollo = require('apollo-server-express'); +const { createGraphOptions } = require('../../../graph'); +const staticTemplate = require('../../../middleware/staticTemplate'); +const router = express.Router(); + +router.use('/ql', apollo.graphqlExpress(createGraphOptions)); + +// Only include the graphiql tool if we aren't in production mode. +if (process.env.NODE_ENV !== 'production') { + // Interactive graphiql interface. + router.use('/iql', staticTemplate, (req, res) => { + res.render('graphiql', { + endpointURL: 'api/v1/graph/ql', + }); + }); +} + +module.exports = router; diff --git a/routes/api/v1/index.js b/routes/api/v1/index.js new file mode 100644 index 000000000..e331d09d8 --- /dev/null +++ b/routes/api/v1/index.js @@ -0,0 +1,17 @@ +const express = require('express'); +const pkg = require('../../../package.json'); +const router = express.Router(); + +// Return the current version. +router.get('/', (req, res) => { + res.json({ version: pkg.version }); +}); + +router.use('/account', require('./account')); +router.use('/assets', require('./assets')); +router.use('/auth', require('./auth')); +router.use('/graph', require('./graph')); +router.use('/setup', require('./setup')); +router.use('/users', require('./users')); + +module.exports = router; diff --git a/routes/api/setup/index.js b/routes/api/v1/setup.js similarity index 99% rename from routes/api/setup/index.js rename to routes/api/v1/setup.js index 93b22fda1..c3cf9518e 100644 --- a/routes/api/setup/index.js +++ b/routes/api/v1/setup.js @@ -1,7 +1,5 @@ const express = require('express'); - const SetupService = require('../../../services/setup'); - const router = express.Router(); router.get('/', async (req, res, next) => { diff --git a/routes/api/users/index.js b/routes/api/v1/users.js similarity index 100% rename from routes/api/users/index.js rename to routes/api/v1/users.js diff --git a/routes/index.js b/routes/index.js index 03ababb31..926d5e2b5 100644 --- a/routes/index.js +++ b/routes/index.js @@ -1,5 +1,4 @@ const SetupService = require('../services/setup'); -const apollo = require('apollo-server-express'); const authentication = require('../middleware/authentication'); const cookieParser = require('cookie-parser'); const enabled = require('debug').enabled; @@ -7,11 +6,11 @@ const errors = require('../errors'); const express = require('express'); const i18n = require('../middleware/i18n'); const path = require('path'); +const compression = require('compression'); const plugins = require('../services/plugins'); const staticTemplate = require('../middleware/staticTemplate'); const staticMiddleware = require('express-static-gzip'); const { DISABLE_STATIC_SERVER } = require('../config'); -const { createGraphOptions } = require('../graph'); const Context = require('../graph/context'); const { passport } = require('../services/passport'); const { MOUNT_PATH } = require('../url'); @@ -32,10 +31,9 @@ if (!DISABLE_STATIC_SERVER) { /** * Redirect old embed calls. */ + const oldEmbed = path.resolve(MOUNT_PATH, 'embed.js'); + const newEmbed = path.resolve(MOUNT_PATH, 'static/embed.js'); router.get('/embed.js', (req, res) => { - const oldEmbed = path.resolve(MOUNT_PATH, 'embed.js'); - const newEmbed = path.resolve(MOUNT_PATH, 'static/embed.js'); - console.warn( `deprecation warning: ${oldEmbed} will be phased out soon, please replace calls from ${oldEmbed} to ${newEmbed}` ); @@ -74,11 +72,15 @@ router.use((req, res, next) => { next(); }); +// Compress all API responses if appropriate. +router.use(compression()); + //============================================================================== // STATIC ROUTES //============================================================================== router.use('/admin', staticTemplate, require('./admin')); +router.use('/login', staticTemplate, require('./login')); router.use('/embed', staticTemplate, require('./embed')); //============================================================================== @@ -107,37 +109,12 @@ router.use(passport.initialize()); // Attach the authentication middleware, this will be responsible for decoding // (if present) the JWT on the request. -router.use('/api', authentication); +router.use('/api', authentication, require('./api')); //============================================================================== -// GraphQL Router +// DEVELOPMENT ROUTES //============================================================================== -// GraphQL endpoint. -router.use('/api/v1/graph/ql', apollo.graphqlExpress(createGraphOptions)); - -// Only include the graphiql tool if we aren't in production mode. -if (process.env.NODE_ENV !== 'production') { - // Interactive graphiql interface. - router.use('/api/v1/graph/iql', staticTemplate, (req, res) => { - res.render('graphiql', { - endpointURL: 'api/v1/graph/ql', - }); - }); - - // GraphQL documentation. - router.get('/admin/docs', (req, res) => { - res.render('admin/docs'); - }); -} - -router.use('/api/v1', require('./api')); - -//============================================================================== -// ROUTES -//============================================================================== - -// Development routes. if (process.env.NODE_ENV !== 'production') { router.use('/assets', staticTemplate, require('./assets')); router.get('/', staticTemplate, async (req, res) => { @@ -177,7 +154,7 @@ router.use((req, res, next) => { next(errors.ErrNotFound); }); -// General api error handler. Respond with the message and error if we have it +// General API error handler. Respond with the message and error if we have it // while returning a status code that makes sense. router.use('/api', (err, req, res, next) => { if (err !== errors.ErrNotFound) { diff --git a/routes/login/index.js b/routes/login/index.js new file mode 100644 index 000000000..b69cb7f5c --- /dev/null +++ b/routes/login/index.js @@ -0,0 +1,8 @@ +const express = require('express'); +const router = express.Router(); + +router.get('*', (req, res) => { + res.render('login'); +}); + +module.exports = router; diff --git a/services/actions.js b/services/actions.js index 856e67482..1c95e5128 100644 --- a/services/actions.js +++ b/services/actions.js @@ -127,74 +127,19 @@ module.exports = class ActionsService { } /** - * Returns summaries of actions for an array of ids. + * Get the actions for a specific user on the specific items. * - * @param {String} ids array of user identifiers (uuid) + * @param {String} userID the id of the user to find their actions for + * @param {Array} itemIDs the ids of the items to find their actions + * for */ - static getActionSummaries(item_ids, current_user_id = '') { - // only grab items that match the specified item id's - let $match = { + static getUserActions(userID, itemIDs) { + return ActionModel.find({ + user_id: userID, item_id: { - $in: item_ids, + $in: itemIDs, }, - }; - - let $group = { - // group unique documents by these properties, we are leveraging the - // fact that each uuid is completely unique. - _id: { - item_id: '$item_id', - action_type: '$action_type', - group_id: '$group_id', - }, - - // and sum up all actions matching the above grouping criteria - count: { - $sum: 1, - }, - - // we are leveraging the fact that each uuid is completely unique and - // just grabbing the last instance of the item type here. - item_type: { - $first: '$item_type', - }, - - current_user: { - $max: { - $cond: { - if: { - $eq: ['$user_id', current_user_id], - }, - then: '$$CURRENT', - else: null, - }, - }, - }, - }; - - let $project = { - // suppress the _id field - _id: false, - - // map the fields from the _id grouping down a level - item_id: '$_id.item_id', - action_type: '$_id.action_type', - group_id: '$_id.group_id', - - // map the field directly - count: '$count', - item_type: '$item_type', - - // set the current user to false here - current_user: '$current_user', - }; - - return ActionModel.aggregate([ - { $match }, - { $group }, - { $project }, - { $sort: { action_type: 1, group_id: 1 } }, - ]); + }); } /** diff --git a/services/jwt.js b/services/jwt.js index 67e45a3ab..356adbb62 100644 --- a/services/jwt.js +++ b/services/jwt.js @@ -1,5 +1,5 @@ const jwt = require('jsonwebtoken'); -const uniq = require('lodash/uniq'); +const { merge, uniq, omitBy, isUndefined } = require('lodash'); /** * MultiSecret will take many secrets and provide a unified interface for @@ -22,7 +22,10 @@ class MultiSecret { * Sign will sign with the first secret. */ sign(payload, options) { - return this.secrets[0].sign(payload, options); + return this.secrets[0].sign( + omitBy(payload, isUndefined), + omitBy(options, isUndefined) + ); } /** @@ -78,10 +81,13 @@ class Secret { return jwt.sign( payload, this.signingKey, - Object.assign({}, options, { - keyid: this.kid, - algorithm: this.algorithm, - }) + omitBy( + merge({}, options, { + keyid: this.kid, + algorithm: this.algorithm, + }), + isUndefined + ) ); } diff --git a/services/tokens.js b/services/tokens.js index 636465262..2f62af61f 100644 --- a/services/tokens.js +++ b/services/tokens.js @@ -27,7 +27,9 @@ module.exports = class TokenService { pat: true, }; - set(payload, JWT_USER_ID_CLAIM, userID); + if (userID) { + set(payload, JWT_USER_ID_CLAIM, userID); + } // Sign the payload. const jwt = JWT_SECRET.sign(payload, {}); diff --git a/test/server/graph/context.js b/test/server/graph/context.js index d2b6af76f..23d11aea3 100644 --- a/test/server/graph/context.js +++ b/test/server/graph/context.js @@ -39,7 +39,8 @@ describe('graph.Context', () => { }); it('creates a context without a user', done => { - expect(c).to.have.property('user', null); + expect(c).to.have.property('user'); + expect(c.user).to.be.undefined; done(); }); diff --git a/test/server/graph/loaders/actions.js b/test/server/graph/loaders/actions.js new file mode 100644 index 000000000..f8e96175b --- /dev/null +++ b/test/server/graph/loaders/actions.js @@ -0,0 +1,122 @@ +const chai = require('chai'); +chai.use(require('chai-as-promised')); +const { expect } = chai; +const sinon = require('sinon'); +const { find } = require('lodash'); +const loaders = require('../../../../graph/loaders/actions'); + +describe('graph.loaders.Actions', () => { + describe('#getAuthoredByID', () => { + it('loads the correct entries', async () => { + const spy = sinon.spy(async () => [ + { item_id: 'comment_1' }, + { item_id: 'comment_2' }, + ]); + const { Actions: { getAuthoredByID } } = loaders({ + user: { id: 'user_1' }, + connectors: { services: { Actions: { getUserActions: spy } } }, + }); + + const actions = await getAuthoredByID.loadMany([ + 'comment_2', + 'comment_1', + ]); + + expect(spy.calledWith('user_1', ['comment_2', 'comment_1'])); + expect(actions).to.have.length(2); + expect(actions[0]).to.have.length(1); + expect(actions[0][0]).to.have.property('item_id', 'comment_2'); + expect(actions[1]).to.have.length(1); + expect(actions[1][0]).to.have.property('item_id', 'comment_1'); + }); + }); + + describe('#getSummariesByItem', () => { + describe('logged out user', () => { + it('does not include any user data', async () => { + const { Actions: { getSummariesByItem } } = loaders({ + loaders: { + Actions: { + getAuthoredByID: { + load: () => Promise.reject(new Error('should not be called')), + }, + }, + }, + user: null, + }); + + const summaries = await getSummariesByItem.load({ + id: '1', + action_counts: { flag: 1, flag_comment_offensive: 1, respect: 2 }, + }); + + expect(summaries).to.have.length(2); + + const flag = find(summaries, { action_type: 'FLAG' }); + expect(flag).to.be.defined; + + expect(flag).to.have.property('current_user', null); + expect(flag).to.have.property('action_type', 'FLAG'); + expect(flag).to.have.property('group_id', 'COMMENT_OFFENSIVE'); + expect(flag).to.have.property('count', 1); + + const respect = find(summaries, { action_type: 'RESPECT' }); + expect(respect).to.be.defined; + + expect(respect).to.have.property('current_user', null); + expect(respect).to.have.property('action_type', 'RESPECT'); + expect(respect).to.have.property('group_id', null); + expect(respect).to.have.property('count', 2); + }); + }); + + describe('logged in user', () => { + it('does include user', async () => { + const { Actions: { getSummariesByItem } } = loaders({ + loaders: { + Actions: { + getAuthoredByID: { + load: commentID => { + expect(commentID).to.equal('comment_1'); + return [ + { + id: 'action_1', + action_type: 'FLAG', + group_id: 'COMMENT_OFFENSIVE', + }, + ]; + }, + }, + }, + }, + user: { id: 'user_1' }, + }); + + const summaries = await getSummariesByItem.load({ + id: 'comment_1', + action_counts: { flag: 1, flag_comment_offensive: 1, respect: 2 }, + }); + + expect(summaries).to.have.length(2); + + const flag = find(summaries, { action_type: 'FLAG' }); + expect(flag).to.be.defined; + + expect(flag).to.have.property('action_type', 'FLAG'); + expect(flag).to.have.property('group_id', 'COMMENT_OFFENSIVE'); + expect(flag).to.have.property('count', 1); + + expect(flag).to.have.property('current_user').not.null; + expect(flag.current_user).to.have.property('id', 'action_1'); + + const respect = find(summaries, { action_type: 'RESPECT' }); + expect(respect).to.be.defined; + + expect(respect).to.have.property('current_user', null); + expect(respect).to.have.property('action_type', 'RESPECT'); + expect(respect).to.have.property('group_id', null); + expect(respect).to.have.property('count', 2); + }); + }); + }); +}); diff --git a/test/server/routes/api/settings/index.js b/test/server/routes/api/settings/index.js deleted file mode 100644 index 52264d800..000000000 --- a/test/server/routes/api/settings/index.js +++ /dev/null @@ -1,56 +0,0 @@ -const passport = require('../../../passport'); - -const app = require('../../../../../app'); - -const chai = require('chai'); -chai.should(); -chai.use(require('chai-http')); -const expect = chai.expect; - -const SettingsService = require('../../../../../services/settings'); -const defaults = { id: '1', moderation: 'PRE' }; - -describe('/api/v1/settings', () => { - beforeEach(() => SettingsService.init(defaults)); - - describe('#get', () => { - it('should return a settings object', async () => { - for (let role of ['ADMIN', 'MODERATOR']) { - const res = await chai - .request(app) - .get('/api/v1/settings') - .set(passport.inject({ role })); - expect(res).to.have.status(200); - expect(res).to.be.json; - expect(res.body).to.have.property('moderation', 'PRE'); - } - }); - }); - - describe('#put', () => { - it('should update the settings', () => { - return chai - .request(app) - .put('/api/v1/settings') - .set(passport.inject({ role: 'ADMIN' })) - .send({ moderation: 'POST' }) - .then(res => { - expect(res).to.have.status(204); - - return SettingsService.retrieve(); - }) - .then(settings => { - expect(settings).to.have.property('moderation', 'POST'); - }); - }); - - it('should require ADMIN role', () => { - const promise = chai - .request(app) - .put('/api/v1/settings') - .set(passport.inject({ role: 'MODERATOR' })) - .send({ moderation: 'POST' }); - return expect(promise).to.eventually.be.rejected; - }); - }); -}); diff --git a/test/server/services/actions.js b/test/server/services/actions.js index 670bcf4d3..0615e55df 100644 --- a/test/server/services/actions.js +++ b/test/server/services/actions.js @@ -104,67 +104,4 @@ describe('services.ActionsService', () => { ); }); }); - - describe('#getActionSummaries()', () => { - it('should return properly formatted summaries from an array of item_ids', () => { - return ActionsService.getActionSummaries([comment.id, '789']).then( - summaries => { - expect(summaries).to.have.length(2); - - expect(summaries).to.deep.include({ - action_type: 'LIKE', - count: 1, - item_id: comment.id, - item_type: 'COMMENTS', - current_user: null, - }); - - expect(summaries).to.deep.include({ - action_type: 'FLAG', - count: 2, - item_id: comment.id, - item_type: 'COMMENTS', - current_user: null, - }); - } - ); - }); - - it('should include a current user when one is passed', () => { - return ActionsService.getActionSummaries( - [comment.id], - 'flagginguserid' - ).then(summaries => { - expect(summaries).to.have.length(2); - - let summary = summaries.find( - s => s.item_id === comment.id && s.action_type === 'FLAG' - ); - - expect(summary).to.not.be.undefined; - expect(summary.current_user).to.not.be.null; - expect(summary.current_user).to.have.property('item_id', comment.id); - expect(summary.current_user).to.have.property('item_type', 'COMMENTS'); - expect(summary.current_user).to.have.property( - 'user_id', - 'flagginguserid' - ); - expect(summary.current_user).to.have.property('action_type', 'FLAG'); - }); - }); - - it("should not include a current user when one is passed for a user that doesn't have an action", () => { - return ActionsService.getActionSummaries( - [comment.id], - 'flagginguserid2' - ).then(summaries => { - expect(summaries).to.have.length(2); - - summaries.forEach(summary => { - expect(summary).to.not.be.undefined; - expect(summary).to.have.property('current_user', null); - }); - }); - }); - }); }); diff --git a/views/admin.ejs b/views/admin.ejs index da689f07f..939da0dcf 100644 --- a/views/admin.ejs +++ b/views/admin.ejs @@ -1,25 +1,9 @@ - Talk - Coral Admin - - - - - - - - - - - - - - - - <%_ if (locals.customCssUrl) { _%> - - <%_ } _%> - <% if (data != null) { %> - - <% } %> - + <%- include partials/head %>
diff --git a/views/admin/confirm-email.ejs b/views/admin/confirm-email.ejs index 8c0d85e01..e63910734 100644 --- a/views/admin/confirm-email.ejs +++ b/views/admin/confirm-email.ejs @@ -1,15 +1,11 @@ - Email Verification - - <%_ if (locals.customCssUrl) { _%> - - <%_ } _%> + <%- include partials/head %>
diff --git a/views/admin/docs.ejs b/views/admin/docs.ejs index 7c6ad9251..bfc476ee3 100644 --- a/views/admin/docs.ejs +++ b/views/admin/docs.ejs @@ -1,7 +1,6 @@ - Talk: GraphQL Docs - <%_ if (locals.customCssUrl) { _%> - - <%_ } _%> + <%- include partials/head %>
diff --git a/views/admin/password-reset.ejs b/views/admin/password-reset.ejs index ae06b5179..f66db491a 100644 --- a/views/admin/password-reset.ejs +++ b/views/admin/password-reset.ejs @@ -1,15 +1,11 @@ - Password Reset - - <%_ if (locals.customCssUrl) { _%> - - <%_ } _%> + <%- include partials/head %>
diff --git a/views/embed/stream.ejs b/views/embed/stream.ejs index dcd84d5f7..f9af61f7c 100644 --- a/views/embed/stream.ejs +++ b/views/embed/stream.ejs @@ -1,18 +1,9 @@ - - - - <%_ if (locals.customCssUrl) { _%> - - <%_ } _%> - <%_ if (data != null) { _%> - - <%_ } _%> - + <%- include ../partials/head %>
diff --git a/views/login.ejs b/views/login.ejs new file mode 100644 index 000000000..87c5929f6 --- /dev/null +++ b/views/login.ejs @@ -0,0 +1,12 @@ + + + + + <%- include partials/head %> + + +
+ + + + diff --git a/views/partials/head.ejs b/views/partials/head.ejs new file mode 100644 index 000000000..bc8b949d3 --- /dev/null +++ b/views/partials/head.ejs @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + +<%_ if (locals.customCssUrl) { _%> + +<%_ } _%> +<%_ if (data != null) { _%> + +<%_ } _%> + \ No newline at end of file diff --git a/webpack.config.js b/webpack.config.js index d488a0a0f..d02294736 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -21,6 +21,7 @@ debug(`Using ${pluginsPath} as the plugin configuration path`); const buildTargets = [ 'coral-admin', + 'coral-login', 'coral-docs', { name: 'coral-auth-callback', disablePolyfill: true }, ]; @@ -128,6 +129,9 @@ const config = { new webpack.EnvironmentPlugin({ TALK_PLUGINS_JSON: '{}', TALK_THREADING_LEVEL: '3', + TALK_ADDTL_COMMENTS_ON_LOAD_MORE: '10', + TALK_ASSET_COMMENTS_LOAD_DEPTH: '10', + TALK_REPLY_COMMENTS_LOAD_DEPTH: '3', TALK_DEFAULT_STREAM_TAB: 'all', TALK_DEFAULT_LANG: 'en', }), diff --git a/yarn.lock b/yarn.lock index ef8ed5e42..bd1733372 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4048,6 +4048,10 @@ hoek@4.x.x: version "4.2.0" resolved "https://registry.yarnpkg.com/hoek/-/hoek-4.2.0.tgz#72d9d0754f7fe25ca2d01ad8f8f9a9449a89526d" +hoek@5.x.x: + version "5.0.3" + resolved "https://registry.yarnpkg.com/hoek/-/hoek-5.0.3.tgz#b71d40d943d0a95da01956b547f83c4a5b4a34ac" + hoist-non-react-statics@^1.0.0, hoist-non-react-statics@^1.0.3, hoist-non-react-statics@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-1.2.0.tgz#aa448cf0986d55cc40773b17174b7dd066cb7cfb" @@ -4643,9 +4647,11 @@ isemail@1.x.x: version "1.2.0" resolved "https://registry.yarnpkg.com/isemail/-/isemail-1.2.0.tgz#be03df8cc3e29de4d2c5df6501263f1fa4595e9a" -isemail@2.x.x: - version "2.2.1" - resolved "https://registry.yarnpkg.com/isemail/-/isemail-2.2.1.tgz#0353d3d9a62951080c262c2aa0a42b8ea8e9e2a6" +isemail@3.x.x: + version "3.1.1" + resolved "https://registry.yarnpkg.com/isemail/-/isemail-3.1.1.tgz#e8450fe78ff1b48347db599122adcd0668bd92b5" + dependencies: + punycode "2.x.x" isexe@^2.0.0: version "2.0.0" @@ -4731,10 +4737,6 @@ istanbul-reports@^1.1.2: dependencies: handlebars "^4.0.3" -items@2.x.x: - version "2.1.1" - resolved "https://registry.yarnpkg.com/items/-/items-2.1.1.tgz#8bd16d9c83b19529de5aea321acaada78364a198" - iterall@1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/iterall/-/iterall-1.0.2.tgz#41a2e96ce9eda5e61c767ee5dc312373bb046e91" @@ -4968,14 +4970,13 @@ jest@^21.2.1: dependencies: jest-cli "^21.2.1" -joi@^10.6.0: - version "10.6.0" - resolved "https://registry.yarnpkg.com/joi/-/joi-10.6.0.tgz#52587f02d52b8b75cdb0c74f0b164a191a0e1fc2" +joi@^13.0.0: + version "13.1.2" + resolved "https://registry.yarnpkg.com/joi/-/joi-13.1.2.tgz#b2db260323cc7f919fafa51e09e2275bd089a97e" dependencies: - hoek "4.x.x" - isemail "2.x.x" - items "2.x.x" - topo "2.x.x" + hoek "5.x.x" + isemail "3.x.x" + topo "3.x.x" joi@^6.10.1: version "6.10.1" @@ -5144,7 +5145,7 @@ jsonpointer@^4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/jsonpointer/-/jsonpointer-4.0.1.tgz#4fd92cb34e0e9db3c89c8622ecf51f9b978c6cb9" -jsonwebtoken@^7.0.0, jsonwebtoken@^7.4.3: +jsonwebtoken@^7.0.0: version "7.4.3" resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-7.4.3.tgz#77f5021de058b605a1783fa1283e99812e645638" dependencies: @@ -5154,6 +5155,21 @@ jsonwebtoken@^7.0.0, jsonwebtoken@^7.4.3: ms "^2.0.0" xtend "^4.0.1" +jsonwebtoken@^8.0.0: + version "8.1.1" + resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.1.1.tgz#b04d8bb2ad847bc93238c3c92170ffdbdd1cb2ea" + dependencies: + jws "^3.1.4" + lodash.includes "^4.3.0" + lodash.isboolean "^3.0.3" + lodash.isinteger "^4.0.4" + lodash.isnumber "^3.0.3" + lodash.isplainobject "^4.0.6" + lodash.isstring "^4.0.1" + lodash.once "^4.0.0" + ms "^2.1.1" + xtend "^4.0.1" + jsprim@^1.2.2: version "1.4.1" resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2" @@ -5498,6 +5514,10 @@ lodash.get@4.4.2, lodash.get@^4.4.2: version "4.4.2" resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" +lodash.includes@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" + lodash.isarguments@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a" @@ -5506,6 +5526,10 @@ lodash.isarray@^3.0.0: version "3.0.4" resolved "https://registry.yarnpkg.com/lodash.isarray/-/lodash.isarray-3.0.4.tgz#79e4eb88c36a8122af86f844aa9bcd851b5fbb55" +lodash.isboolean@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6" + lodash.isempty@^4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/lodash.isempty/-/lodash.isempty-4.4.0.tgz#6f86cbedd8be4ec987be9aaf33c9684db1b31e7e" @@ -5514,11 +5538,19 @@ lodash.isequal@^4.4.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" +lodash.isinteger@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343" + +lodash.isnumber@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc" + lodash.isobject@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/lodash.isobject/-/lodash.isobject-3.0.2.tgz#3c8fb8d5b5bf4bf90ae06e14f2a530a4ed935e1d" -lodash.isplainobject@^4.0.0: +lodash.isplainobject@^4.0.0, lodash.isplainobject@^4.0.6: version "4.0.6" resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" @@ -6012,7 +6044,7 @@ ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" -ms@^2.0.0: +ms@^2.0.0, ms@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a" @@ -7554,9 +7586,13 @@ punycode@1.4.1, punycode@^1.2.4, punycode@^1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" -pym.js@^1.1.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/pym.js/-/pym.js-1.3.1.tgz#140cfa9b4bda2e13613e89683e5551c4ce2ac9c4" +punycode@2.x.x: + version "2.1.0" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.0.tgz#5f863edc89b96db09074bad7947bf09056ca4e7d" + +pym.js@^1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/pym.js/-/pym.js-1.3.2.tgz#0ebd083c5a7ef7650214db872b4b29a10743305d" q@1.4.1: version "1.4.1" @@ -7744,8 +7780,8 @@ react-portal@^4.1.2: prop-types "^15.5.8" react-recaptcha@^2.2.6: - version "2.3.5" - resolved "https://registry.yarnpkg.com/react-recaptcha/-/react-recaptcha-2.3.5.tgz#a5db337125bb00fb13c2fa2e4ebfbe8b0cd06bb7" + version "2.3.6" + resolved "https://registry.yarnpkg.com/react-recaptcha/-/react-recaptcha-2.3.6.tgz#afe07b5552f3ea4d37ecd22d9881c2776719ec2b" react-redux@^4.4.5: version "4.4.8" @@ -9157,11 +9193,11 @@ topo@1.x.x: dependencies: hoek "2.x.x" -topo@2.x.x: - version "2.0.2" - resolved "https://registry.yarnpkg.com/topo/-/topo-2.0.2.tgz#cd5615752539057c0dc0491a621c3bc6fbe1d182" +topo@3.x.x: + version "3.0.0" + resolved "https://registry.yarnpkg.com/topo/-/topo-3.0.0.tgz#37e48c330efeac784538e0acd3e62ca5e231fe7a" dependencies: - hoek "4.x.x" + hoek "5.x.x" touch@^3.1.0: version "3.1.0"