From b78d4f29eacffc49518735729a25f29d95b9fb81 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Thu, 15 Jun 2017 18:12:53 -0400 Subject: [PATCH 01/33] Added PAT support --- bin/cli-token | 98 ++++++++++++++++++++++++++++++++ graph/mutators/index.js | 2 + graph/mutators/token.js | 41 +++++++++++++ graph/resolvers/root_mutation.js | 6 ++ graph/resolvers/user.js | 10 +++- graph/typeDefs.graphql | 57 +++++++++++++++++++ models/schema/token.js | 16 ++++++ models/user.js | 4 ++ perms/constants.js | 5 +- perms/mutationReducer.js | 4 ++ perms/queryReducer.js | 2 + services/passport.js | 18 ++++++ services/tokens.js | 82 ++++++++++++++++++++++++++ 13 files changed, 343 insertions(+), 2 deletions(-) create mode 100644 bin/cli-token create mode 100644 graph/mutators/token.js create mode 100644 models/schema/token.js create mode 100644 services/tokens.js diff --git a/bin/cli-token b/bin/cli-token new file mode 100644 index 000000000..f6b4570a7 --- /dev/null +++ b/bin/cli-token @@ -0,0 +1,98 @@ +#!/usr/bin/env node + +/** + * Module dependencies. + */ + +const program = require('./commander'); +const mongoose = require('../services/mongoose'); +const TokensService = require('../services/tokens'); +const util = require('./util'); +const Table = require('cli-table'); + +// Regeister the shutdown criteria. +util.onshutdown([ + () => mongoose.disconnect() +]); + +async function listTokens(userID) { + try { + let tokens = await TokensService.list(userID); + + let table = new Table({ + head: [ + 'ID', + 'Name', + 'Status' + ] + }); + + tokens.forEach((token) => { + table.push([ + token.id, + token.name, + token.active ? 'Active' : 'Revoked' + ]); + }); + + console.log(table.toString()); + + util.shutdown(); + } catch (e) { + console.error(e); + util.shutdown(1); + } +} + +async function revokeToken(tokenID) { + try { + + await TokensService.revoke(null, tokenID); + + util.shutdown(); + } catch (e) { + console.error(e); + util.shutdown(1); + } +} + +async function createToken(userID, tokenName) { + try { + + let {pat: {id}, jwt} = await TokensService.create(userID, tokenName); + + console.log(`Created Token[${id}] for User[${userID}] = ${jwt}`); + + util.shutdown(); + } catch (e) { + console.error(e); + util.shutdown(1); + } +} + +//============================================================================== +// Setting up the program command line arguments. +//============================================================================== + +program + .command('list ') + .description('list tokens for a user') + .action(listTokens); + +program + .command('revoke ') + .description('revokes a token with a given id') + .action(revokeToken); + +program + .command('create ') + .description('create a token for a user with a given name') + .action(createToken); + +program.parse(process.argv); + +// If there is no command listed, output help. +if (!process.argv.slice(2).length) { + program.outputHelp(); + util.shutdown(); +} diff --git a/graph/mutators/index.js b/graph/mutators/index.js index 0076a5e8f..e7e5df9e7 100644 --- a/graph/mutators/index.js +++ b/graph/mutators/index.js @@ -4,6 +4,7 @@ const debug = require('debug')('talk:graph:mutators'); const Comment = require('./comment'); const Action = require('./action'); const Tag = require('./tag'); +const Token = require('./token'); const User = require('./user'); const plugins = require('../../services/plugins'); @@ -14,6 +15,7 @@ let mutators = [ Comment, Action, Tag, + Token, User, // Load the plugin mutators from the manager. diff --git a/graph/mutators/token.js b/graph/mutators/token.js new file mode 100644 index 000000000..2a1028596 --- /dev/null +++ b/graph/mutators/token.js @@ -0,0 +1,41 @@ +const errors = require('../../errors'); +const TokensService = require('../../services/tokens'); +const { + CREATE_TOKEN, + REVOKE_TOKEN +} = require('../../perms/constants'); + +// Creates a new token for a user. +const createToken = async ({user}, {name}) => { + let {pat, jwt} = await TokensService.create(user.id, name); + + // Attach the token to the PAT. + pat.jwt = jwt; + + // Return that PAT! + return pat; +}; + +// Revokes the token from the user. +const revokeToken = async ({user}, {id}) => { + return TokensService.revoke(user.id, id); +}; + +module.exports = (context) => { + let mutators = { + Token: { + create: () => Promise.reject(errors.ErrNotAuthorized), + revoke: () => Promise.reject(errors.ErrNotAuthorized) + } + }; + + if (context.user && context.user.can(CREATE_TOKEN)) { + mutators.Token.create = (input) => createToken(context, input); + } + + if (context.user && context.user.can(REVOKE_TOKEN)) { + mutators.Token.revoke = (input) => revokeToken(context, input); + } + + return mutators; +}; diff --git a/graph/resolvers/root_mutation.js b/graph/resolvers/root_mutation.js index f192405cc..dec0553a4 100644 --- a/graph/resolvers/root_mutation.js +++ b/graph/resolvers/root_mutation.js @@ -39,6 +39,12 @@ const RootMutation = { }, removeTag(_, {tag}, {mutators: {Tag}}) { return wrapResponse(null)(Tag.remove(tag)); + }, + createToken(_, {input}, {mutators: {Token}}) { + return wrapResponse('token')(Token.create(input)); + }, + revokeToken(_, {input}, {mutators: {Token}}) { + return wrapResponse(null)(Token.revoke(input)); } }; diff --git a/graph/resolvers/user.js b/graph/resolvers/user.js index 5f6fc52a1..035af22cb 100644 --- a/graph/resolvers/user.js +++ b/graph/resolvers/user.js @@ -5,7 +5,8 @@ const { SEARCH_OTHER_USERS, SEARCH_OTHERS_COMMENTS, UPDATE_USER_ROLES, - SEARCH_COMMENT_METRICS + SEARCH_COMMENT_METRICS, + LIST_OWN_TOKENS } = require('../../perms/constants'); const User = { @@ -46,6 +47,13 @@ const User = { return null; }, + tokens({id, tokens}, args, {user}) { + if (!user || ((user.id !== id) && !user.can(LIST_OWN_TOKENS))) { + return null; + } + + return tokens; + }, ignoredUsers({id}, args, {user, loaders: {Users}}) { // Only allow a logged in user that is either the current user or is a staff diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index 117b7e077..b69051697 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -36,6 +36,23 @@ enum USER_ROLES { MODERATOR } +# Token is a personal access token associated with a given user. +type Token { + + # ID is the unique identifier for the token. + id: ID! + + # Name is the description for the token. + name: String! + + # Active determines if the token is available to hit the API. + active: Boolean! + + # JWT is the actual token to use for authentication, this is only available + # on token creation, otherwise it will be null. + jwt: String +} + type UserProfile { # the id is an identifier for the user profile (email, facebook id, etc) id: String! @@ -78,6 +95,9 @@ type User { # ignored users. ignoredUsers: [User!] + # Tokens are the personal access tokens for a given user. + tokens: [Token!] + # returns all comments based on a query. comments(query: CommentsQuery): CommentConnection! @@ -889,6 +909,37 @@ type EditCommentResponse implements Response { errors: [UserError!] } +# CreateTokenInput contains the input to create the token. +input CreateTokenInput { + + # Name is the description for the token. + name: String! +} + +# CreateTokenResponse contains the errors related to creating a token. +type CreateTokenResponse implements Response { + + # Token is the Token that was created, or null if it failed. + token: Token + + # An array of errors relating to the mutation that occured. + errors: [UserError!] +} + +# RevokeTokenInput contains the input to revoke the token. +input RevokeTokenInput { + + # ID is the JTI for the token. + id: ID! +} + +# RevokeTokenResponse contains the errors related to revoking a token. +type RevokeTokenResponse implements Response { + + # An array of errors relating to the mutation that occured. + errors: [UserError!] +} + # All mutations for the application are defined on this object. type RootMutation { @@ -928,6 +979,12 @@ type RootMutation { # Ignore comments by another user ignoreUser(id: ID!): IgnoreUserResponse + # CreateToken will create a token that is attached to the current user. + createToken(input: CreateTokenInput!): CreateTokenResponse! + + # RevokeToken will revoke an existing token. + revokeToken(input: RevokeTokenInput!): RevokeTokenResponse! + # Stop Ignoring comments by another user stopIgnoringUser(id: ID!): StopIgnoringUserResponse } diff --git a/models/schema/token.js b/models/schema/token.js new file mode 100644 index 000000000..83b6cc049 --- /dev/null +++ b/models/schema/token.js @@ -0,0 +1,16 @@ +const mongoose = require('../../services/mongoose'); +const Schema = mongoose.Schema; + +const TokenSchema = new Schema({ + + // ID is the JTI of a given JWT's identifier. + id: String, + + // Name is given by the user on token creation. + name: String, + + // Active is used to determine if the token is valid. + active: Boolean +}); + +module.exports = TokenSchema; diff --git a/models/user.js b/models/user.js index fb05a5e28..b19a87b0a 100644 --- a/models/user.js +++ b/models/user.js @@ -3,6 +3,7 @@ const bcrypt = require('bcrypt'); const Schema = mongoose.Schema; const uuid = require('uuid'); const TagLinkSchema = require('./schema/tag_link'); +const TokenSchema = require('./schema/token'); const intersection = require('lodash/intersection'); const can = require('../perms'); @@ -86,6 +87,9 @@ const UserSchema = new Schema({ // addresses. profiles: [ProfileSchema], + // Tokens are the individual personal access tokens for a given user. + tokens: [TokenSchema], + // Roles provides an array of roles (as strings) that is associated with a // user. roles: [{ diff --git a/perms/constants.js b/perms/constants.js index 2b5b907ac..e843a17c7 100644 --- a/perms/constants.js +++ b/perms/constants.js @@ -14,6 +14,8 @@ module.exports = { REMOVE_COMMENT_TAG: 'REMOVE_COMMENT_TAG', UPDATE_USER_ROLES: 'UPDATE_USER_ROLES', UPDATE_CONFIG: 'UPDATE_CONFIG', + CREATE_TOKEN: 'CREATE_TOKEN', + REVOKE_TOKEN: 'REVOKE_TOKEN', // queries SEARCH_ASSETS: 'SEARCH_ASSETS', @@ -21,5 +23,6 @@ module.exports = { SEARCH_ACTIONS: 'SEARCH_ACTIONS', SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS: 'SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS', SEARCH_OTHERS_COMMENTS: 'SEARCH_OTHERS_COMMENTS', - SEARCH_COMMENT_METRICS: 'SEARCH_COMMENT_METRICS' + SEARCH_COMMENT_METRICS: 'SEARCH_COMMENT_METRICS', + LIST_OWN_TOKENS: 'LIST_OWN_TOKENS' }; diff --git a/perms/mutationReducer.js b/perms/mutationReducer.js index 53cece51f..897217348 100644 --- a/perms/mutationReducer.js +++ b/perms/mutationReducer.js @@ -29,6 +29,10 @@ module.exports = (user, perm) => { return check(user, ['ADMIN', 'MODERATOR']); case types.UPDATE_CONFIG: return check(user, ['ADMIN', 'MODERATOR']); + case types.CREATE_TOKEN: + return check(user, ['ADMIN']); + case types.REVOKE_TOKEN: + return check(user, ['ADMIN']); default: break; } diff --git a/perms/queryReducer.js b/perms/queryReducer.js index 0e5054788..ba7e2b206 100644 --- a/perms/queryReducer.js +++ b/perms/queryReducer.js @@ -15,6 +15,8 @@ module.exports = (user, perm) => { return check(user, ['ADMIN', 'MODERATOR']); case types.SEARCH_COMMENT_METRICS: return check(user, ['ADMIN', 'MODERATOR']); + case types.LIST_OWN_TOKENS: + return check(user, ['ADMIN']); default: break; } diff --git a/services/passport.js b/services/passport.js index 1d031c39c..bfd6bca45 100644 --- a/services/passport.js +++ b/services/passport.js @@ -34,6 +34,23 @@ const GenerateToken = (user) => JWT.sign({}, JWT_SECRET, { audience: JWT_AUDIENCE }); +// GeneratePersonalAccessToken will sign a token to include all the +// authorization information needed for the front end for headless access. +const GeneratePersonalAccessToken = (userID) => { + const payload = { + jti: uuid.v4(), + iss: JWT_ISSUER, + aud: JWT_AUDIENCE, + sub: userID, + pat: true + }; + + // Sign the payload. + const jwt = JWT.sign(payload, JWT_SECRET, {}); + + return {payload, jwt}; +}; + // SetTokenForSafari sends the token in a cookie for Safari clients. const SetTokenForSafari = (req, res, token) => { const browser = bowser._detect(req.headers['user-agent']); @@ -474,5 +491,6 @@ module.exports = { HandleAuthPopupCallback, HandleGenerateCredentials, HandleLogout, + GeneratePersonalAccessToken, CheckBlacklisted }; diff --git a/services/tokens.js b/services/tokens.js new file mode 100644 index 000000000..eeefce175 --- /dev/null +++ b/services/tokens.js @@ -0,0 +1,82 @@ +const UserModel = require('../models/user'); +const {GeneratePersonalAccessToken} = require('./passport'); + +/** + * TokenService manages Personal Access Tokens for users. These tokens are + * persisted in the database and attached to the user. + */ +module.exports = class TokenService { + + /** + * Creates a token for a user with a given name. + * + * @param {String} userID the id of the user owning the token + * @param {String} tokenName the name of the token to be created + */ + static async create(userID, tokenName) { + + // Create the token. + let {payload, jwt} = GeneratePersonalAccessToken(userID); + + // Create the PAT. + let pat = { + id: payload.jti, + name: tokenName, + active: true + }; + + // Wait to update the user model with the new PAT. + await UserModel.update({id: userID}, { + $push: { + tokens: pat + } + }); + + return {payload, jwt, pat}; + } + + /** + * Revokes a token and prevents the token from being used. Once a token has + * been revoked, it cannot be re-enabled. + * + * @param {String} userID the id of the user owning the token + * @param {String} tokenID the jti of the token to revoke + */ + static async revoke(userID, tokenID) { + let query = { + tokens: { + $elemMatch: { + id: tokenID + } + } + }; + + if (userID) { + query.id = userID; + } + + // Revoke the token id. + await UserModel.update(query, { + $set: { + 'tokens.$.active': false + } + }); + } + + /** + * Lists the tokens owned by the user. + * + * @param {String} userID the id of the user owning the token + */ + static async list(userID) { + + // Get the user specified by the id. + let user = await UserModel.findOne({id: userID}).select('tokens'); + if (!user || !user.tokens) { + return []; + } + + return user.tokens; + } + +}; From 393ad8530aab7b31a11d9a4db0cf48b275e9c490 Mon Sep 17 00:00:00 2001 From: Wyatt Johnson Date: Mon, 19 Jun 2017 08:55:08 -0600 Subject: [PATCH 02/33] added tests, fixed some services --- bin/cli | 1 + bin/cli-token | 2 + services/passport.js | 38 ++++++------- services/tokens.js | 49 ++++++++++++++++- test/server/services/tokens.js | 99 ++++++++++++++++++++++++++++++++++ 5 files changed, 165 insertions(+), 24 deletions(-) mode change 100644 => 100755 bin/cli-token create mode 100644 test/server/services/tokens.js diff --git a/bin/cli b/bin/cli index 1f3d50ad2..33560c53b 100755 --- a/bin/cli +++ b/bin/cli @@ -12,6 +12,7 @@ program .command('assets', 'interact with assets') .command('setup', 'setup the application') .command('jobs', 'work with the job queues') + .command('token', 'work with the access tokens') .command('users', 'work with the application auth') .command('migration', 'provides utilities for migrating the database') .command('plugins', 'provides utilities for interacting with the plugin system') diff --git a/bin/cli-token b/bin/cli-token old mode 100644 new mode 100755 index f6b4570a7..50335d925 --- a/bin/cli-token +++ b/bin/cli-token @@ -49,6 +49,8 @@ async function revokeToken(tokenID) { await TokensService.revoke(null, tokenID); + console.log(`Revoked Token[${tokenID}]`); + util.shutdown(); } catch (e) { console.error(e); diff --git a/services/passport.js b/services/passport.js index bfd6bca45..92cd914b3 100644 --- a/services/passport.js +++ b/services/passport.js @@ -1,6 +1,7 @@ const passport = require('passport'); const UsersService = require('./users'); const SettingsService = require('./settings'); +const TokensService = require('./tokens'); const fetch = require('node-fetch'); const FormData = require('form-data'); const JWT = require('jsonwebtoken'); @@ -34,23 +35,6 @@ const GenerateToken = (user) => JWT.sign({}, JWT_SECRET, { audience: JWT_AUDIENCE }); -// GeneratePersonalAccessToken will sign a token to include all the -// authorization information needed for the front end for headless access. -const GeneratePersonalAccessToken = (userID) => { - const payload = { - jti: uuid.v4(), - iss: JWT_ISSUER, - aud: JWT_AUDIENCE, - sub: userID, - pat: true - }; - - // Sign the payload. - const jwt = JWT.sign(payload, JWT_SECRET, {}); - - return {payload, jwt}; -}; - // SetTokenForSafari sends the token in a cookie for Safari clients. const SetTokenForSafari = (req, res, token) => { const browser = bowser._detect(req.headers['user-agent']); @@ -174,10 +158,7 @@ const HandleLogout = (req, res, next) => { }); }; -/** - * Check if the given token is already blacklisted, throw an error if it is. - */ -const CheckBlacklisted = (jwt) => new Promise((resolve, reject) => { +const checkGeneralTokenBlacklist = (jwt) => new Promise((resolve, reject) => { client.get(`jtir[${jwt.jti}]`, (err, expiry) => { if (err) { return reject(err); @@ -191,6 +172,20 @@ const CheckBlacklisted = (jwt) => new Promise((resolve, reject) => { }); }); +/** + * Check if the given token is already blacklisted, throw an error if it is. + */ +const CheckBlacklisted = async (jwt) => { + + // Check to see if this is a PAT. + if (jwt.pat) { + return TokensService.validate(jwt.sub, jwt.jti); + } + + // It wasn't a PAT! Check to see if it is valid anyways. + return checkGeneralTokenBlacklist(jwt); +}; + const jwt = require('jsonwebtoken'); const JwtStrategy = require('passport-jwt').Strategy; const ExtractJwt = require('passport-jwt').ExtractJwt; @@ -491,6 +486,5 @@ module.exports = { HandleAuthPopupCallback, HandleGenerateCredentials, HandleLogout, - GeneratePersonalAccessToken, CheckBlacklisted }; diff --git a/services/tokens.js b/services/tokens.js index eeefce175..3c78a0a94 100644 --- a/services/tokens.js +++ b/services/tokens.js @@ -1,5 +1,13 @@ +const errors = require('../errors'); const UserModel = require('../models/user'); -const {GeneratePersonalAccessToken} = require('./passport'); +const JWT = require('jsonwebtoken'); +const uuid = require('uuid'); + +const { + JWT_SECRET, + JWT_ISSUER, + JWT_AUDIENCE +} = require('../config'); /** * TokenService manages Personal Access Tokens for users. These tokens are @@ -16,7 +24,16 @@ module.exports = class TokenService { static async create(userID, tokenName) { // Create the token. - let {payload, jwt} = GeneratePersonalAccessToken(userID); + const payload = { + jti: uuid.v4(), + iss: JWT_ISSUER, + aud: JWT_AUDIENCE, + sub: userID, + pat: true + }; + + // Sign the payload. + const jwt = JWT.sign(payload, JWT_SECRET, {}); // Create the PAT. let pat = { @@ -63,6 +80,34 @@ module.exports = class TokenService { }); } + /** + * Validate that a given Token is valid. + * + * @param {String} userID the user's id that owns the token + * @param {String} tokenID the id of the token + */ + static async validate(userID, tokenID) { + + // Find the user. + let user = await UserModel.findOne({ + id: userID + }).select('tokens'); + if (!user || !user.tokens) { + throw new errors.ErrAuthentication('user does not exist'); + } + + // Extract the token from the user. + let token = user.tokens.find(({id}) => id === tokenID); + if (!token) { + throw new errors.ErrAuthentication('token does not exist'); + } + + // Check to see if it is active. + if (!token.active) { + throw new errors.ErrAuthentication('token is not active'); + } + } + /** * Lists the tokens owned by the user. * diff --git a/test/server/services/tokens.js b/test/server/services/tokens.js new file mode 100644 index 000000000..2aa1f291d --- /dev/null +++ b/test/server/services/tokens.js @@ -0,0 +1,99 @@ +const TokensService = require('../../../services/tokens'); +const UsersService = require('../../../services/users'); +const SettingsService = require('../../../services/settings'); + +const chai = require('chai'); +const chaiAsPromised = require('chai-as-promised'); + +chai.use(chaiAsPromised); + +const expect = chai.expect; + +describe('services.TokensService', () => { + + let user; + beforeEach(async () => { + await SettingsService.init(); + user = await UsersService.createLocalUser('sockmonster@gmail.com', '2Coral!!', 'Sockmonster'); + }); + + describe('#create', () => { + + it('can create the token without error', async () => { + let token = await TokensService.create(user.id, 'Github Token'); + expect(token).to.be.an.object; + expect(token.jwt).to.be.a.string; + expect(token.pat).to.be.an.object; + + let pat = token.pat; + + let tokens = await TokensService.list(user.id); + expect(tokens).to.have.length(1); + expect(tokens[0]).to.have.property('id', pat.id); + expect(tokens[0]).to.have.property('name', pat.name); + }); + + }); + + describe('#revoke', () => { + + it('can revoke a token', async () => { + let {pat: {id}} = await TokensService.create(user.id, 'Github Token'); + + let tokens = await TokensService.list(user.id); + expect(tokens).to.have.length(1); + expect(tokens[0]).to.have.property('id', id); + expect(tokens[0]).to.have.property('active', true); + + await TokensService.revoke(user.id, id); + + tokens = await TokensService.list(user.id); + expect(tokens).to.have.length(1); + expect(tokens[0]).to.have.property('id', id); + expect(tokens[0]).to.have.property('active', false); + }); + + }); + + describe('#validate', () => { + + it('will allow a valid token', async () => { + + // Create a token. + let {pat: {id}} = await TokensService.create(user.id, 'Github Token'); + + // Validate it. + await TokensService.validate(user.id, id); + }); + + it('will not allow an invalid token', async () => { + + // Create a token. + let {pat: {id}} = await TokensService.create(user.id, 'Github Token'); + + // Revoke it. + await TokensService.revoke(user.id, id); + + // Validate it. + return TokensService.validate(user.id, id).should.eventually.be.rejected; + }); + + }); + + describe('#list', () => { + + it('lists the tokens for a user', async () => { + + let tokens = await TokensService.list(user.id); + expect(tokens).to.have.length(0); + + // Create a token. + let {pat: {id}} = await TokensService.create(user.id, 'Github Token'); + + tokens = await TokensService.list(user.id); + expect(tokens).to.have.length(1); + expect(tokens[0]).to.have.property('id', id); + }); + + }); +}); From 646b5be8c8ca7fe7b908496647b9a1a2cda98588 Mon Sep 17 00:00:00 2001 From: Kim Gardner Date: Tue, 20 Jun 2017 10:48:01 +0100 Subject: [PATCH 03/33] Show version in nav --- client/coral-admin/src/components/ui/Header.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/coral-admin/src/components/ui/Header.js b/client/coral-admin/src/components/ui/Header.js index f2a0b06e3..ca1cc1f30 100644 --- a/client/coral-admin/src/components/ui/Header.js +++ b/client/coral-admin/src/components/ui/Header.js @@ -84,12 +84,12 @@ const CoralHeader = ({ {t('configure.sign_out')} - - Talk {`v${process.env.VERSION}`} - +
  • + {`v${process.env.VERSION}`} +
  • From fe8f3ae4bb8dd329bca67be5e18f1036ef6b96af Mon Sep 17 00:00:00 2001 From: Belen Curcio Date: Tue, 20 Jun 2017 08:32:08 -0300 Subject: [PATCH 04/33] Moving Copy component --- .../Moderation/components/UserDetail.css | 3 +-- .../Moderation/components/UserDetail.js | 21 +++++++++++-------- .../components/Copy.js | 3 ++- client/coral-framework/components/index.js | 1 + client/coral-ui/index.js | 1 - plugin-api/beta/client/components/index.js | 1 + 6 files changed, 17 insertions(+), 13 deletions(-) rename client/{coral-ui => coral-framework}/components/Copy.js (87%) diff --git a/client/coral-admin/src/routes/Moderation/components/UserDetail.css b/client/coral-admin/src/routes/Moderation/components/UserDetail.css index 710f84759..f0b08bfb7 100644 --- a/client/coral-admin/src/routes/Moderation/components/UserDetail.css +++ b/client/coral-admin/src/routes/Moderation/components/UserDetail.css @@ -35,9 +35,8 @@ border: none; background-color: transparent; font-size: 16px; - position: absolute; - width: 90%; outline: none; + width: calc(100% - 90px); } .commentStatuses { diff --git a/client/coral-admin/src/routes/Moderation/components/UserDetail.js b/client/coral-admin/src/routes/Moderation/components/UserDetail.js index abc9f4440..023fb9a3f 100644 --- a/client/coral-admin/src/routes/Moderation/components/UserDetail.js +++ b/client/coral-admin/src/routes/Moderation/components/UserDetail.js @@ -1,8 +1,8 @@ import React, {PropTypes} from 'react'; -import {Button, Drawer, Copy} from 'coral-ui'; -import styles from './UserDetail.css'; -import Slot from 'coral-framework/components/Slot'; import Comment from './Comment'; +import styles from './UserDetail.css'; +import {Button, Drawer} from 'coral-ui'; +import {Slot, Copy} from 'coral-framework/components'; import {actionsMap} from '../helpers/moderationQueueActionsMap'; export default class UserDetail extends React.Component { @@ -98,13 +98,16 @@ export default class UserDetail extends React.Component { return (

    {user.username}

    - {profile && this.profile = ref} value={profile} />} - this.showCopied()} text={profile} className={styles.profileEmail}> - - +
    + {profile && this.profile = ref} value={profile} />} + + this.showCopied()} text={profile} className={styles.profileEmail}> + + +
    Date: Tue, 20 Jun 2017 08:37:53 -0300 Subject: [PATCH 05/33] Adding the translations --- .../src/routes/Moderation/components/UserDetail.js | 5 +++-- locales/en.yml | 3 +++ locales/es.yml | 3 +++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/client/coral-admin/src/routes/Moderation/components/UserDetail.js b/client/coral-admin/src/routes/Moderation/components/UserDetail.js index 023fb9a3f..bf8b80267 100644 --- a/client/coral-admin/src/routes/Moderation/components/UserDetail.js +++ b/client/coral-admin/src/routes/Moderation/components/UserDetail.js @@ -2,6 +2,7 @@ import React, {PropTypes} from 'react'; import Comment from './Comment'; import styles from './UserDetail.css'; import {Button, Drawer} from 'coral-ui'; +import t from 'coral-framework/services/i18n'; import {Slot, Copy} from 'coral-framework/components'; import {actionsMap} from '../helpers/moderationQueueActionsMap'; @@ -102,9 +103,9 @@ export default class UserDetail extends React.Component {
    {profile && this.profile = ref} value={profile} />} - this.showCopied()} text={profile} className={styles.profileEmail}> + this.showCopied()} text={profile}>
    diff --git a/locales/en.yml b/locales/en.yml index de95ddb91..0bbf594c8 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -32,6 +32,9 @@ en: comment_post_banned_word: "Your comment contains one or more words that are not permitted, so it will not be published. If you think this message is incorrect, please contact our moderation team." comment_post_notif: "Your comment has been posted." comment_post_notif_premod: "Thank you for posting. Our moderation team will review your comment shortly." + common: + copied: 'Copied' + copy: 'Copy' community: account_creation_date: "Account Creation Date" active: Active diff --git a/locales/es.yml b/locales/es.yml index 6897f6f8b..9d7e4c939 100644 --- a/locales/es.yml +++ b/locales/es.yml @@ -32,6 +32,9 @@ es: comment_post_banned_word: "Tu comentario contiene una o más palabras que no están permitidas en nuestro espacio, por lo que no será publicado. Si crees que es un error, por favor contacta a nuestro equipo de moderación." comment_post_notif: "Tu comentario ha sido publicado." comment_post_notif_premod: "Gracias por el comentario. Nuestro equipo de moderación va a revisarlo muy pronto." + common: + copied: 'Copiado' + copy: 'Copiar' community: account_creation_date: "Fecha de creación de la cuenta" active: Activa From 7faa00c46387bce0052a01a3bce5e00d0f742d13 Mon Sep 17 00:00:00 2001 From: Belen Curcio Date: Tue, 20 Jun 2017 09:05:01 -0300 Subject: [PATCH 06/33] withCopyToClipboard --- .../Moderation/components/UserDetail.js | 17 ++++---- client/coral-framework/components/Copy.js | 39 ------------------- client/coral-framework/components/index.js | 1 - client/coral-framework/hocs/index.js | 2 +- .../hocs/withCopyToClipboard.js | 33 ++++++++++++++++ 5 files changed, 44 insertions(+), 48 deletions(-) delete mode 100644 client/coral-framework/components/Copy.js create mode 100644 client/coral-framework/hocs/withCopyToClipboard.js diff --git a/client/coral-admin/src/routes/Moderation/components/UserDetail.js b/client/coral-admin/src/routes/Moderation/components/UserDetail.js index bf8b80267..116e043ca 100644 --- a/client/coral-admin/src/routes/Moderation/components/UserDetail.js +++ b/client/coral-admin/src/routes/Moderation/components/UserDetail.js @@ -3,7 +3,8 @@ import Comment from './Comment'; import styles from './UserDetail.css'; import {Button, Drawer} from 'coral-ui'; import t from 'coral-framework/services/i18n'; -import {Slot, Copy} from 'coral-framework/components'; +import {Slot} from 'coral-framework/components'; +import {withCopyToClipboard} from 'coral-framework/hocs'; import {actionsMap} from '../helpers/moderationQueueActionsMap'; export default class UserDetail extends React.Component { @@ -96,18 +97,20 @@ export default class UserDetail extends React.Component { rejectedPercent = 0; } + const CopyToClipboard = withCopyToClipboard({ text: profile })( + + ); + + console.log(CopyToClipboard) + return (

    {user.username}

    {profile && this.profile = ref} value={profile} />} - - this.showCopied()} text={profile}> - -
    { - this.props.onCopy(); - e.clearSelection(); - }); - } - - refCopyButton(button) { - this.copyButtonEl = button; - } - - render() { - const {children, target = '', text = '', className = ''} = this.props; - - return ( - - {children} - - ); - } -} diff --git a/client/coral-framework/components/index.js b/client/coral-framework/components/index.js index 6c9a3f876..b7aaef665 100644 --- a/client/coral-framework/components/index.js +++ b/client/coral-framework/components/index.js @@ -1,2 +1 @@ export {default as Slot} from './Slot'; -export {default as Copy} from './Copy'; diff --git a/client/coral-framework/hocs/index.js b/client/coral-framework/hocs/index.js index 01b3daff4..782f840bd 100644 --- a/client/coral-framework/hocs/index.js +++ b/client/coral-framework/hocs/index.js @@ -1,4 +1,4 @@ export {default as withFragments} from './withFragments'; export {default as withMutation} from './withMutation'; export {default as withQuery} from './withQuery'; - +export {default as withCopyToClipboard} from './withCopyToClipboard'; diff --git a/client/coral-framework/hocs/withCopyToClipboard.js b/client/coral-framework/hocs/withCopyToClipboard.js new file mode 100644 index 000000000..2ca9e1b13 --- /dev/null +++ b/client/coral-framework/hocs/withCopyToClipboard.js @@ -0,0 +1,33 @@ +import React from 'react'; +import Clipboard from 'clipboard'; + +export default (config) => (WrappedComponent) => { + console.log(config, WrappedComponent) + + class withCopyToClipboard extends React.Component { + + componentDidMount() { + const node = ReactDOM.findDOMNode(WrappedComponent); + const clipboard = new Clipboard(node); + + clipboard.on('success', (e) => { + this.props.onCopy(); + e.clearSelection(); + }); + } + + render() { + const {target = '', text = ''} = config; + + return ; + } + } + + return withCopyToClipboard; +}; From d97fb0a83fa472da7efebe569eaf5cb70a8e4e51 Mon Sep 17 00:00:00 2001 From: Belen Curcio Date: Tue, 20 Jun 2017 09:47:37 -0300 Subject: [PATCH 07/33] Refs by ReactDOMNode --- .../components/ButtonCopyToClipboard.js | 35 ++++++++++++++++++ .../Moderation/components/Moderation.js | 2 + .../Moderation/components/UserDetail.js | 19 +--------- .../hocs/withCopyToClipboard.js | 16 ++++---- client/coral-ui/components/Button.js | 37 ++++++++++--------- 5 files changed, 67 insertions(+), 42 deletions(-) create mode 100644 client/coral-admin/src/routes/Moderation/components/ButtonCopyToClipboard.js diff --git a/client/coral-admin/src/routes/Moderation/components/ButtonCopyToClipboard.js b/client/coral-admin/src/routes/Moderation/components/ButtonCopyToClipboard.js new file mode 100644 index 000000000..ba25d533c --- /dev/null +++ b/client/coral-admin/src/routes/Moderation/components/ButtonCopyToClipboard.js @@ -0,0 +1,35 @@ +import React from 'react'; +import {Button} from 'coral-ui'; +import t from 'coral-framework/services/i18n'; +import {withCopyToClipboard} from 'coral-framework/hocs'; + +class ButtonCopyToClipboard extends React.Component { + + constructor() { + super(); + + this.state = { + emailCopied: false + }; + } + + showCopied() { + this.setState({ + emailCopied: true + }, () => { + setTimeout(() => this.setState({ + emailCopied: false + }), 3000); + }); + } + + render () { + return ( + + ); + } +} + +export default withCopyToClipboard(ButtonCopyToClipboard); diff --git a/client/coral-admin/src/routes/Moderation/components/Moderation.js b/client/coral-admin/src/routes/Moderation/components/Moderation.js index 63c9e13ab..6d3b975db 100644 --- a/client/coral-admin/src/routes/Moderation/components/Moderation.js +++ b/client/coral-admin/src/routes/Moderation/components/Moderation.js @@ -207,6 +207,7 @@ export default class Moderation extends Component { shortcutsNoteVisible={moderation.shortcutsNoteVisible} open={moderation.modalOpen} onClose={this.onClose}/> + {moderation.userDetailId && ( )} + - {this.state.emailCopied ? t('common.copied') : t('common.copy')} - - ); - - console.log(CopyToClipboard) - return (

    {user.username}

    {profile && this.profile = ref} value={profile} />} +
    (WrappedComponent) => { - console.log(config, WrappedComponent) +export default (WrappedComponent) => { class withCopyToClipboard extends React.Component { - componentDidMount() { - const node = ReactDOM.findDOMNode(WrappedComponent); - const clipboard = new Clipboard(node); + const clipboard = new Clipboard(ReactDOM.findDOMNode(this)); clipboard.on('success', (e) => { - this.props.onCopy(); + if (this.props.onCopy) { + this.props.onCopy(); + } e.clearSelection(); }); } render() { - const {target = '', text = ''} = config; + const {target = '', text = '', className = '', ...rest} = this.props; return ; } } diff --git a/client/coral-ui/components/Button.js b/client/coral-ui/components/Button.js index 6d3dc94d2..cbfb6ad61 100644 --- a/client/coral-ui/components/Button.js +++ b/client/coral-ui/components/Button.js @@ -2,20 +2,23 @@ import React from 'react'; import styles from './Button.css'; import Icon from './Icon'; -const Button = ({cStyle = 'local', children, className, raised = false, full = false, icon = '', ...props}) => ( - -); - -export default Button; +export default class Button extends React.Component { + render() { + const {cStyle = 'local', children, className, raised = false, full = false, icon = '', ...props} = this.props; + return ( + + ); + } +} From 23fcba4ccce569eea1d68faff49b4a5dc3dd443e Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Tue, 20 Jun 2017 19:55:53 +0700 Subject: [PATCH 08/33] Don't show caret when logged out --- .../src/components/Comment.js | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/client/coral-embed-stream/src/components/Comment.js b/client/coral-embed-stream/src/components/Comment.js index a66b8a224..f14e4c991 100644 --- a/client/coral-embed-stream/src/components/Comment.js +++ b/client/coral-embed-stream/src/components/Comment.js @@ -431,28 +431,28 @@ export default class Comment extends React.Component { inline /> - { (currentUser && - (comment.user.id === currentUser.id)) + { (currentUser && (comment.user.id === currentUser.id)) && - /* User can edit/delete their own comment for a short window after posting */ - ? - { - commentIsStillEditable(comment) && - Edit - } - + /* User can edit/delete their own comment for a short window after posting */ + + { + commentIsStillEditable(comment) && + Edit + } + + } + { (currentUser && (comment.user.id !== currentUser.id)) && /* TopRightMenu allows currentUser to ignore other users' comments */ - : - - + + + } - { this.state.isEditing ? Date: Tue, 20 Jun 2017 20:00:00 +0700 Subject: [PATCH 09/33] Always default to `all` queue --- client/coral-admin/src/AppRouter.js | 2 +- .../coral-admin/src/routes/Moderation/containers/StorySearch.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/client/coral-admin/src/AppRouter.js b/client/coral-admin/src/AppRouter.js index 3bde5f931..385076b21 100644 --- a/client/coral-admin/src/AppRouter.js +++ b/client/coral-admin/src/AppRouter.js @@ -50,7 +50,7 @@ const routes = ( - + diff --git a/client/coral-admin/src/routes/Moderation/containers/StorySearch.js b/client/coral-admin/src/routes/Moderation/containers/StorySearch.js index cf47ca662..2196e8994 100644 --- a/client/coral-admin/src/routes/Moderation/containers/StorySearch.js +++ b/client/coral-admin/src/routes/Moderation/containers/StorySearch.js @@ -41,7 +41,7 @@ class StorySearchContainer extends React.Component { goToStory = (id) => { const {router, closeSearch} = this.props; - router.push(`/admin/moderate/${id}`); + router.push(`/admin/moderate/all/${id}`); closeSearch(); } From 942ea7b9167a012fb9d4c8b606bf8d7e90d8f470 Mon Sep 17 00:00:00 2001 From: Belen Curcio Date: Tue, 20 Jun 2017 10:37:10 -0300 Subject: [PATCH 10/33] ButtonCopyToClipboard --- .../components/ButtonCopyToClipboard.js | 23 ++----------------- .../Moderation/components/UserDetail.js | 12 +--------- .../hocs/withCopyToClipboard.js | 14 ++++------- client/coral-ui/components/Button.css | 10 ++++++++ locales/en.yml | 1 - locales/es.yml | 1 - 6 files changed, 18 insertions(+), 43 deletions(-) diff --git a/client/coral-admin/src/routes/Moderation/components/ButtonCopyToClipboard.js b/client/coral-admin/src/routes/Moderation/components/ButtonCopyToClipboard.js index ba25d533c..5bc5767ba 100644 --- a/client/coral-admin/src/routes/Moderation/components/ButtonCopyToClipboard.js +++ b/client/coral-admin/src/routes/Moderation/components/ButtonCopyToClipboard.js @@ -4,29 +4,10 @@ import t from 'coral-framework/services/i18n'; import {withCopyToClipboard} from 'coral-framework/hocs'; class ButtonCopyToClipboard extends React.Component { - - constructor() { - super(); - - this.state = { - emailCopied: false - }; - } - - showCopied() { - this.setState({ - emailCopied: true - }, () => { - setTimeout(() => this.setState({ - emailCopied: false - }), 3000); - }); - } - render () { return ( - ); } diff --git a/client/coral-admin/src/routes/Moderation/components/UserDetail.js b/client/coral-admin/src/routes/Moderation/components/UserDetail.js index a591d1393..791f47729 100644 --- a/client/coral-admin/src/routes/Moderation/components/UserDetail.js +++ b/client/coral-admin/src/routes/Moderation/components/UserDetail.js @@ -44,16 +44,6 @@ export default class UserDetail extends React.Component { this.props.changeStatus('rejected'); } - showCopied() { - this.setState({ - emailCopied: true - }, () => { - setTimeout(() => this.setState({ - emailCopied: false - }), 3000); - }); - } - render () { const { root: { @@ -95,7 +85,7 @@ export default class UserDetail extends React.Component {
    {profile && this.profile = ref} value={profile} />} - +
    { - - class withCopyToClipboard extends React.Component { + class WithCopyToClipboard extends React.Component { componentDidMount() { const clipboard = new Clipboard(ReactDOM.findDOMNode(this)); clipboard.on('success', (e) => { - if (this.props.onCopy) { - this.props.onCopy(); - } e.clearSelection(); }); } render() { - const {target = '', text = '', className = '', ...rest} = this.props; + const {copyTarget = '', copyText = '', className = '', ...rest} = this.props; return ; } } - return withCopyToClipboard; + return WithCopyToClipboard; }; diff --git a/client/coral-ui/components/Button.css b/client/coral-ui/components/Button.css index 671c55bad..409fc1bc6 100644 --- a/client/coral-ui/components/Button.css +++ b/client/coral-ui/components/Button.css @@ -53,6 +53,16 @@ color: #212121; } +.type--local:hover { + background: #d6d5d5; + color: #212121; +} + +.type--local:active { + background: #cccccc; + color: #212121; +} + .type--facebook { background-color: #4267b2; border-color: #4267b2; diff --git a/locales/en.yml b/locales/en.yml index 0bbf594c8..73a7e82d2 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -33,7 +33,6 @@ en: comment_post_notif: "Your comment has been posted." comment_post_notif_premod: "Thank you for posting. Our moderation team will review your comment shortly." common: - copied: 'Copied' copy: 'Copy' community: account_creation_date: "Account Creation Date" diff --git a/locales/es.yml b/locales/es.yml index 9d7e4c939..4eb1ca756 100644 --- a/locales/es.yml +++ b/locales/es.yml @@ -33,7 +33,6 @@ es: comment_post_notif: "Tu comentario ha sido publicado." comment_post_notif_premod: "Gracias por el comentario. Nuestro equipo de moderación va a revisarlo muy pronto." common: - copied: 'Copiado' copy: 'Copiar' community: account_creation_date: "Fecha de creación de la cuenta" From 644c291d7f3c42a5d2a5f0b6dc0bd81c3fe75b85 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Tue, 20 Jun 2017 21:25:20 +0700 Subject: [PATCH 11/33] Don't cut off long comment --- client/coral-admin/src/routes/Moderation/components/styles.css | 2 -- 1 file changed, 2 deletions(-) diff --git a/client/coral-admin/src/routes/Moderation/components/styles.css b/client/coral-admin/src/routes/Moderation/components/styles.css index 9fa343762..1d449c75f 100644 --- a/client/coral-admin/src/routes/Moderation/components/styles.css +++ b/client/coral-admin/src/routes/Moderation/components/styles.css @@ -190,8 +190,6 @@ span { } &.selected { - max-width: 720px; - max-height: 410px; } .context { From 49420e152e7e2991dee0a796ee4898f2747e0bca Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Tue, 20 Jun 2017 22:30:26 +0700 Subject: [PATCH 12/33] Click outside closes TopRightMenu, detect click outside iframe --- .eslintrc.json | 3 +- .../Moderation/components/UserDetail.js | 165 +++++++++--------- .../{TopRightMenu.css => Toggleable.css} | 0 .../src/components/Toggleable.js | 39 +++++ .../src/components/TopRightMenu.js | 36 +--- client/coral-embed/src/index.js | 5 + .../components/ClickOutside.js | 35 ++++ client/coral-plugin-flags/FlagButton.js | 132 +++++++------- .../PermalinkButton.js | 58 +++--- client/coral-ui/components/Drawer.js | 9 +- package.json | 1 - yarn.lock | 8 +- 12 files changed, 267 insertions(+), 224 deletions(-) rename client/coral-embed-stream/src/components/{TopRightMenu.css => Toggleable.css} (100%) create mode 100644 client/coral-embed-stream/src/components/Toggleable.js create mode 100644 client/coral-framework/components/ClickOutside.js diff --git a/.eslintrc.json b/.eslintrc.json index 2186efd8b..12355ac34 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -53,7 +53,8 @@ "no-lonely-if": [2], "curly": [2], "no-unused-vars": ["error", { - "argsIgnorePattern": "next" + "argsIgnorePattern": "^_|next", + "varsIgnorePattern": "^_" }], "no-multiple-empty-lines": ["error", { "max": 1 diff --git a/client/coral-admin/src/routes/Moderation/components/UserDetail.js b/client/coral-admin/src/routes/Moderation/components/UserDetail.js index abc9f4440..b45f20b78 100644 --- a/client/coral-admin/src/routes/Moderation/components/UserDetail.js +++ b/client/coral-admin/src/routes/Moderation/components/UserDetail.js @@ -4,6 +4,7 @@ import styles from './UserDetail.css'; import Slot from 'coral-framework/components/Slot'; import Comment from './Comment'; import {actionsMap} from '../helpers/moderationQueueActionsMap'; +import ClickOutside from 'coral-framework/components/ClickOutside'; export default class UserDetail extends React.Component { @@ -96,92 +97,94 @@ export default class UserDetail extends React.Component { } return ( - -

    {user.username}

    - {profile && this.profile = ref} value={profile} />} + + +

    {user.username}

    + {profile && this.profile = ref} value={profile} />} - this.showCopied()} text={profile} className={styles.profileEmail}> - - + this.showCopied()} text={profile} className={styles.profileEmail}> + + - -

    Member since {new Date(user.created_at).toLocaleString()}

    -
    -

    - Account summary -
    Data represents the last six months of activity -

    -
    -
    -

    Total Comments

    -

    {totalComments}

    -
    -
    -

    Reject Rate

    -

    {`${(rejectedPercent).toFixed(1)}%`}

    -
    -
    - { - selectedIds.length === 0 - ? ( -
      -
    • All
    • -
    • Rejected
    • -
    - ) - : ( -
    - - - {`${selectedIds.length} comments selected`} + +

    Member since {new Date(user.created_at).toLocaleString()}

    +
    +

    + Account summary +
    Data represents the last six months of activity +

    +
    +
    +

    Total Comments

    +

    {totalComments}

    - ) - } - -
    +
    +

    Reject Rate

    +

    {`${(rejectedPercent).toFixed(1)}%`}

    +
    +
    { - nodes.map((comment, i) => { - const status = comment.action_summaries ? 'FLAGGED' : comment.status; - const selected = selectedIds.indexOf(comment.id) !== -1; - return {}} - actions={actionsMap[status]} - showBanUserDialog={showBanUserDialog} - showSuspendUserDialog={showSuspendUserDialog} - acceptComment={this.acceptThenReload} - rejectComment={this.rejectThenReload} - selected={selected} - toggleSelect={toggleSelect} - currentAsset={null} - currentUserId={this.props.id} - minimal={true} />; - }) + selectedIds.length === 0 + ? ( +
      +
    • All
    • +
    • Rejected
    • +
    + ) + : ( +
    + + + {`${selectedIds.length} comments selected`} +
    + ) } -
    - + +
    + { + nodes.map((comment, i) => { + const status = comment.action_summaries ? 'FLAGGED' : comment.status; + const selected = selectedIds.indexOf(comment.id) !== -1; + return {}} + actions={actionsMap[status]} + showBanUserDialog={showBanUserDialog} + showSuspendUserDialog={showSuspendUserDialog} + acceptComment={this.acceptThenReload} + rejectComment={this.rejectThenReload} + selected={selected} + toggleSelect={toggleSelect} + currentAsset={null} + currentUserId={this.props.id} + minimal={true} />; + }) + } +
    + + ); } } diff --git a/client/coral-embed-stream/src/components/TopRightMenu.css b/client/coral-embed-stream/src/components/Toggleable.css similarity index 100% rename from client/coral-embed-stream/src/components/TopRightMenu.css rename to client/coral-embed-stream/src/components/Toggleable.css diff --git a/client/coral-embed-stream/src/components/Toggleable.js b/client/coral-embed-stream/src/components/Toggleable.js new file mode 100644 index 000000000..7dc6af5e9 --- /dev/null +++ b/client/coral-embed-stream/src/components/Toggleable.js @@ -0,0 +1,39 @@ +import React from 'react'; +import ClickOutside from 'coral-framework/components/ClickOutside'; +import styles from './Toggleable.css'; +import classnames from 'classnames'; + +const upArrow = ; +const downArrow = ; +export default class Toggleable extends React.Component { + constructor(props) { + super(props); + this.toggle = this.toggle.bind(this); + this.close = this.close.bind(this); + this.state = { + isOpen: false + }; + } + toggle() { + this.setState({isOpen: !this.state.isOpen}); + } + + close = () => { + this.setState({isOpen: false}); + } + + render() { + const {children} = this.props; + const {isOpen} = this.state; + return ( + + + {isOpen ? upArrow : downArrow} + {isOpen ? children : null} + + + ); + } +} + diff --git a/client/coral-embed-stream/src/components/TopRightMenu.js b/client/coral-embed-stream/src/components/TopRightMenu.js index 84d64f061..d65722b6d 100644 --- a/client/coral-embed-stream/src/components/TopRightMenu.js +++ b/client/coral-embed-stream/src/components/TopRightMenu.js @@ -1,8 +1,6 @@ import React, {PropTypes} from 'react'; -import classnames from 'classnames'; - import {IgnoreUserWizard} from './IgnoreUserWizard'; -import styles from './TopRightMenu.css'; +import Toggleable from './Toggleable'; // TopRightMenu appears as a dropdown in the top right of the comment. // when you click the down cehvron, it expands and shows IgnoreUserWizard @@ -56,38 +54,6 @@ export class TopRightMenu extends React.Component { />
    - ); - } -} - -const upArrow = ; -const downArrow = ; -class Toggleable extends React.Component { - constructor(props) { - super(props); - this.toggle = this.toggle.bind(this); - this.close = this.close.bind(this); - this.state = { - isOpen: false - }; - } - toggle() { - this.setState({isOpen: !this.state.isOpen}); - } - close() { - this.setState({isOpen: false}); - } - render() { - const {children} = this.props; - const {isOpen} = this.state; - return ( - - // /*onBlur={ this.close } */ - - {isOpen ? upArrow : downArrow} - {isOpen ? children : null} - ); } } diff --git a/client/coral-embed/src/index.js b/client/coral-embed/src/index.js index 14de2756e..5b42fa541 100644 --- a/client/coral-embed/src/index.js +++ b/client/coral-embed/src/index.js @@ -73,6 +73,11 @@ function configurePymParent(pymParent, opts) { window.document.body.appendChild(snackbar); + // Notify embed that there was a click outside. + document.addEventListener('click', () => { + pymParent.sendMessage('click'); + }, true); + // Workaround: IOS Safari ignores `width` but respects `min-width` value. pymParent.el.firstChild.style.width = '1px'; pymParent.el.firstChild.style.minWidth = '100%'; diff --git a/client/coral-framework/components/ClickOutside.js b/client/coral-framework/components/ClickOutside.js new file mode 100644 index 000000000..e13ef4472 --- /dev/null +++ b/client/coral-framework/components/ClickOutside.js @@ -0,0 +1,35 @@ +import {Component, cloneElement, Children} from 'react'; +import PropTypes from 'prop-types'; +import {findDOMNode} from 'react-dom'; +import {pym} from 'coral-framework'; + +export default class ClickOutside extends Component { + static propTypes = { + onClickOutside: PropTypes.func.isRequired + }; + + domNode = null; + + handleClick = (e) => { + const {onClickOutside} = this.props; + if (!e || !this.domNode.contains(e.target)) { + onClickOutside(e); + } + }; + + componentDidMount() { + this.domNode = findDOMNode(this); + document.addEventListener('click', this.handleClick, true); + pym.onMessage('click', this.handleClick); + } + + componentWillUnmount() { + document.removeEventListener('click', this.handleClick, true); + pym.messageHandlers.click = pym.messageHandlers.click.filter((h) => h !== this.handleClick); + } + + render() { + const {children, onClickOutside: _, ...rest} = this.props; + return cloneElement(Children.only(children), rest); + } +} diff --git a/client/coral-plugin-flags/FlagButton.js b/client/coral-plugin-flags/FlagButton.js index 73ed50e9c..e8a17f201 100644 --- a/client/coral-plugin-flags/FlagButton.js +++ b/client/coral-plugin-flags/FlagButton.js @@ -4,11 +4,11 @@ import t from 'coral-framework/services/i18n'; import {can} from 'coral-framework/services/perms'; import {PopupMenu, Button} from 'coral-ui'; -import onClickOutside from 'react-onclickoutside'; +import ClickOutside from 'coral-framework/components/ClickOutside'; const name = 'coral-plugin-flags'; -class FlagButton extends Component { +export default class FlagButton extends Component { state = { showMenu: false, @@ -128,7 +128,7 @@ class FlagButton extends Component { this.setState({message: e.target.value}); } - handleClickOutside () { + handleClickOutside = () => { this.closeMenu(); } @@ -138,79 +138,81 @@ class FlagButton extends Component { const flagged = flaggedByCurrentUser || localPost; const popupMenu = getPopupMenu[this.state.step](this.state.itemType); - return
    - - { - this.state.showMenu && -
    this.popup = ref}> - -
    {popupMenu.header}
    + return ( + +
    + + { + this.state.showMenu && +
    this.popup = ref}> + +
    {popupMenu.header}
    { - popupMenu.options.map((option) => -
    - -
    -
    - ) + popupMenu.text && +
    {popupMenu.text}
    } { - this.state.reason &&
    -
    -