mirror of
https://github.com/wassname/talk.git
synced 2026-09-14 11:36:51 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54e5a18546 | ||
|
|
a7094efda6 | ||
|
|
c207a59c18 | ||
|
|
9f4c30f466 | ||
|
|
fa53327571 | ||
|
|
70a1525058 | ||
|
|
07e24496da | ||
|
|
f07026d6dd | ||
|
|
7140723570 | ||
|
|
b4ca90a05d | ||
|
|
f9bc623764 | ||
|
|
810cc002c9 | ||
|
|
c03f85d4b6 | ||
|
|
14fd5e4451 | ||
|
|
8588cab228 | ||
|
|
af71059948 | ||
|
|
dc866437ff | ||
|
|
b5afd9cbeb | ||
|
|
3640a93a2f | ||
|
|
1b50cb06d4 | ||
|
|
6d826e9115 | ||
|
|
5fff644e7d | ||
|
|
249a8f78dc | ||
|
|
5408036eb3 | ||
|
|
04e3f12c87 | ||
|
|
d5137ba1c1 | ||
|
|
e41acffcf0 | ||
|
|
5a22e99282 | ||
|
|
77b52a2454 | ||
|
|
05180394c7 | ||
|
|
4f013c50e6 | ||
|
|
da0ca97483 | ||
|
|
53a403cddb | ||
|
|
06812edcd2 | ||
|
|
1accc54238 | ||
|
|
05c055c3d2 | ||
|
|
06ab33343f | ||
|
|
3cc120cd25 | ||
|
|
44a06aef46 | ||
|
|
f0cc82cb6d | ||
|
|
8632cd9b67 | ||
|
|
5f58ca6b5d | ||
|
|
cae101627f | ||
|
|
e69262c665 | ||
|
|
7295d78523 | ||
|
|
041241d6b0 | ||
|
|
9876e04454 | ||
|
|
712c53389b | ||
|
|
9e5dbfd9cf | ||
|
|
39a4d031c7 | ||
|
|
7c95781072 | ||
|
|
f56e30c1a1 | ||
|
|
c90230c115 | ||
|
|
ad0f530fd3 | ||
|
|
281a49dc74 | ||
|
|
1cc048d46e | ||
|
|
20f90572f5 | ||
|
|
536a2165cc | ||
|
|
1027b19a49 | ||
|
|
5136fce4a3 | ||
|
|
f3f5397459 | ||
|
|
0eefd3364b | ||
|
|
361be84562 | ||
|
|
3e0d56b942 | ||
|
|
31b7dac48a | ||
|
|
ce01389809 | ||
|
|
6e89734e12 | ||
|
|
44b41b031e | ||
|
|
87b350e3a9 | ||
|
|
c375d11e32 | ||
|
|
b66efb7f2a | ||
|
|
f1272338cd | ||
|
|
3285a5ec29 | ||
|
|
acf3ad5e28 | ||
|
|
15e7ef6368 | ||
|
|
873ec09f62 | ||
|
|
3d65231db5 | ||
|
|
4a4affad70 | ||
|
|
7101d99187 | ||
|
|
18b35e38a8 | ||
|
|
6ca7e92171 | ||
|
|
09e3889793 | ||
|
|
2a1ea01827 | ||
|
|
10aaa037dc | ||
|
|
5f215a95f9 | ||
|
|
7a21879628 | ||
|
|
2e4256bf4a | ||
|
|
11f18c23cb | ||
|
|
3a510168d3 | ||
|
|
8519d07c67 | ||
|
|
d9147da8fc | ||
|
|
e87a471eb9 | ||
|
|
6cf4011cde | ||
|
|
ca369dcb4a | ||
|
|
a4e6638217 | ||
|
|
f76177c1db | ||
|
|
7283feab6b | ||
|
|
296b941ee8 | ||
|
|
586ce941e5 | ||
|
|
ec3a114e65 | ||
|
|
b6eaf210fe | ||
|
|
be293a03de | ||
|
|
4f4ecd8dfb | ||
|
|
7bc848b801 | ||
|
|
226af72ac9 | ||
|
|
1acc92a1f2 | ||
|
|
976f0762b3 | ||
|
|
bba597f086 | ||
|
|
abd2332745 | ||
|
|
a3bb8e2f41 | ||
|
|
01db900c40 | ||
|
|
4dbc73f9ab | ||
|
|
6a61f47ddf | ||
|
|
712e436f58 | ||
|
|
72cd78da8c | ||
|
|
8a1eedb0b2 | ||
|
|
bf04034085 | ||
|
|
1e445efaec | ||
|
|
466cb289c3 | ||
|
|
4c5b85a2ae | ||
|
|
4eb7c72ae2 | ||
|
|
cabe546ecf | ||
|
|
be72ebf2a1 | ||
|
|
368b431893 | ||
|
|
d9ea5977cd | ||
|
|
7e7e8891b4 | ||
|
|
ac6fba2286 | ||
|
|
ab825512b9 | ||
|
|
ce46b4dbe0 | ||
|
|
864b08135a | ||
|
|
7d5be04be3 | ||
|
|
12f6bb1ffe | ||
|
|
57f9fd8474 | ||
|
|
298e1e8d73 | ||
|
|
5f7d48a7f9 | ||
|
|
3718efce1e | ||
|
|
ccb9fe3165 | ||
|
|
7b9dbb5afd | ||
|
|
2755cc61e4 | ||
|
|
a7680ae5d9 | ||
|
|
0fd6f4b44e | ||
|
|
e2a032d4a5 | ||
|
|
7363fcc5dc | ||
|
|
d4dc381aca | ||
|
|
6e54ba7f4c | ||
|
|
950340d5e7 | ||
|
|
43846651dd | ||
|
|
a1def6f950 | ||
|
|
b7380978f5 | ||
|
|
5e4564c7ea | ||
|
|
f5374dc914 | ||
|
|
0dde122e9b | ||
|
|
fd2b01d5ed | ||
|
|
759e74a88d | ||
|
|
5c6df6d481 | ||
|
|
a02ad69568 | ||
|
|
111e83a1e6 | ||
|
|
036ed8ac78 | ||
|
|
f786daaec9 | ||
|
|
9a608fd02e | ||
|
|
1ca6736146 | ||
|
|
349f8003a7 | ||
|
|
c86148c0f1 | ||
|
|
b0e4b79a6a | ||
|
|
da37bb32f1 | ||
|
|
0219f1916e | ||
|
|
ef40cf37fe | ||
|
|
d35875145c | ||
|
|
8e5e75ed6f | ||
|
|
6892ef85f8 | ||
|
|
8b5b9e856b | ||
|
|
ac66a7bb0e | ||
|
|
6052cac1d3 | ||
|
|
93c5e336b2 | ||
|
|
8e6aa23d32 | ||
|
|
8d992f7809 | ||
|
|
168248f975 | ||
|
|
964c1cb3d2 | ||
|
|
9766215231 | ||
|
|
3b1731e809 | ||
|
|
1538690cad | ||
|
|
ca9ef44202 | ||
|
|
3dfa4969e5 | ||
|
|
92a8fc5a93 | ||
|
|
018447f00e | ||
|
|
b36109f0a1 | ||
|
|
ec3e9d1cd4 | ||
|
|
54d0096dd1 | ||
|
|
6cca99e75e | ||
|
|
92b51ef1fe | ||
|
|
39222759f9 | ||
|
|
075fe7f6b4 | ||
|
|
54bad908b3 | ||
|
|
7f86c936ad | ||
|
|
50112d3166 | ||
|
|
7562edc168 | ||
|
|
29250f2bbc | ||
|
|
9c9597ae8c | ||
|
|
16a4daaeb3 | ||
|
|
e16bc8f7ad | ||
|
|
f49ca9f89a | ||
|
|
4172066c94 | ||
|
|
dd032521c0 | ||
|
|
d8d30e512b | ||
|
|
b75408b067 | ||
|
|
670567b8b3 | ||
|
|
eb98a41bd5 | ||
|
|
7b8131d4b0 | ||
|
|
4a76e43309 | ||
|
|
70c86bf8f5 | ||
|
|
e76d6e4694 | ||
|
|
369ed3fc29 | ||
|
|
fc042779c2 |
@@ -11,3 +11,5 @@ dump.rdb
|
||||
.env
|
||||
gaba.cfg
|
||||
.idea/
|
||||
coverage/
|
||||
yarn.lock
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"verbose": true,
|
||||
"ignore": ["tests/*", "client/*", "dist/*"]
|
||||
}
|
||||
+52
-69
@@ -1,95 +1,78 @@
|
||||
# Contribution Guide
|
||||
# Contributor's Guide
|
||||
|
||||
We're very excited that you're interested in contributing to Talk! There is much to do. Before you begin, please review this document to get a sense of the practices and philosophies that hold this project together.
|
||||
Welcome! We are very excited that you are interested in contributing to Talk.
|
||||
|
||||
This document is a companion to help you approach contributing. If it does not do so, please [let us know how we can improve it](https://github.com/coralproject/talk/issues)!
|
||||
|
||||
|
||||
## Doing the Work
|
||||
## Product Roadmap
|
||||
|
||||
We are here to make it as seamless as possible to contribute to Talk. The following lists are meant to make it straightforward to perform the mechanics of working on the project so you can focus your energy toward writing and reviewing content.
|
||||
You can view what the Coral Team is working on next here https://www.pivotaltracker.com/n/projects/1863625.
|
||||
|
||||
You can view product ideas and our longer term roadmap here https://trello.com/b/ILND751a/talk.
|
||||
|
||||
|
||||
### Code Reviews
|
||||
## Contribute to the documentation
|
||||
Clear docs are a prerequisite for a successful open source project. We value non-code and code contributions equally.
|
||||
|
||||
One of the most valuable aspects of working in software. It is something that should challenge the reviewer and author alike. It is a way of focusing knowledge, experience and opinions for the benefit of the project and the participants.
|
||||
We are looking for _documentarians_ to:
|
||||
|
||||
Code reviews are a collaboration to make _the work_ as good as it can be. Code reviews are not a good venue for providing direct instruction to _the author._ Focus on positive, incremental improvements that can be made on the work at hand.
|
||||
* make clarity, grammar and completeness updates,
|
||||
* create new / missing sections, and
|
||||
* take the lead in making sections, or the over all structure better.
|
||||
|
||||
Please take your time when writing and reviewing code. Here are some fundamental questions to open up a reviewing headspace.
|
||||
### But how?
|
||||
|
||||
**Is the code clear, efficient and a pleasure to read?**
|
||||
* Our public docs site can be updated [here](https://github.com/coralproject/docs).
|
||||
* [Let us know](https://github.com/coralproject/talk/wiki/Contact-Us) if you'd like permission to update our wiki.
|
||||
* Update any of our .md docs files by following [this guide](https://github.com/coralproject/talk/wiki/Forking,-Branching-and-Merging).
|
||||
* Our API docs need to be kept honest. [Update them here](https://github.com/coralproject/talk/blob/master/docs/swagger.yaml).
|
||||
|
||||
Somewhere at the intersection of good variable names, well laid out file structures, consistent formatting and appropriate comments lies beautiful code. Code is language spoken to at least two very distinct audiences, the computer that interprets it and the developer who encounters it. Both should be at the front of your mind when reviewing code.
|
||||
## Integrate into your environment
|
||||
|
||||
Thinking like a computer, you could ask:
|
||||
First, [set up a dev environment](https://github.com/coralproject/talk/blob/master/INSTALL.md). Please let us know how this goes!
|
||||
|
||||
* Is the code using memory efficiently?
|
||||
* Is data being moved around unnecessarily?
|
||||
* Are multiple network requests being made where fewer would do?
|
||||
* Is there excess processing happening in a synchronous flow that may disrupt user experience?
|
||||
* Are there large libraries included for small gains?
|
||||
Talk is designed to integrate into existing environments in a variety of ways:
|
||||
|
||||
Then, returning to your human roots... Is the code readable?
|
||||
* [Auth integrations](https://github.com/coralproject/talk/wiki/Security#authentication-strategies)
|
||||
* [Push assets into Talk](https://github.com/coralproject/talk/blob/master/routes/api/assets/index.js)
|
||||
* Monitoring Hooks (coming in 2017)
|
||||
|
||||
* Can I understand what is happening here (and maybe even why) by simply opening up the file, starting at the top and reading downward?
|
||||
* Do comments convey clear, full thoughts in a narrative language that provides background for the code choices?
|
||||
* Are the files separated logically such that each one contains a clear concept of code?
|
||||
If you're considering deploying Talk, [please let us know](https://github.com/coralproject/talk/wiki/Contact-Us)! We are quite literally doing this for you and want to help you succeed any way we can.
|
||||
|
||||
If you are writing custom integration code in your fork of Talk, please consider keeping it generic and filing a Pull Request to contribute it back to the project! See our [forking and merging guidelines](https://github.com/coralproject/talk/wiki/Forking,-Branching-and-Merging) for more info.
|
||||
|
||||
## Write some code
|
||||
|
||||
First, [set up a dev environment](https://github.com/coralproject/talk/blob/master/INSTALL.md). Please let us know how this goes!
|
||||
|
||||
### Build a New Feature / Plugin
|
||||
|
||||
Talk is beginning life as a Commenting Platform, but is architected to support many varieties of community engagement.
|
||||
|
||||
Please [contact us](https://github.com/coralproject/talk/wiki/Contact-Us) early and often if you'd like to help. We would love to hear your ideas for features and plugins and help you find a way to productively engage the project.
|
||||
|
||||
To get an idea of where the Coral Team is going, see:
|
||||
|
||||
* our [product/design Trello board](https://trello.com/b/ILND751a/talk),
|
||||
* our [current stories](https://www.pivotaltracker.com/n/projects/1863625), and
|
||||
* our [issues](https://github.com/coralproject/talk/issues).
|
||||
|
||||
|
||||
**Is the API documentation up to date? Are all client calls written against the docs?**
|
||||
Examples:
|
||||
|
||||
We use [swagger](https://github.com/coralproject/talk/blob/master/swagger.yaml) to track our API documentation.
|
||||
|
||||
* If APIs are created or updated, is the swagger.yml file up to date? There's nothing more frustrating than trying to develop against docs that are out of date or wrong. We need to be meticulous here as it's the little differences that can cause the most frustration and tricky bugs.
|
||||
* If client code calls APIs, are they written against the swagger.yml file? Are all return codes handled?
|
||||
|
||||
**Is there sufficient test coverage?**
|
||||
|
||||
Our tests folder is set up to mirror the code folders: [https://github.com/coralproject/talk/tree/master/tests](https://github.com/coralproject/talk/tree/master/tests)
|
||||
|
||||
* Can you a sense of the logic behind the code by reading the tests?
|
||||
* Can you see both what should happen and what should _never, ever_ be allowed to happen?
|
||||
* Are there future cases that are guarded against via the creation of unit tests (aka, making sure things are typed, specifically checking for all values that will be used, etc...)?
|
||||
* [Add An Emoji Button to Comments](https://github.com/coralproject/talk/wiki/Add-An-Emoji-Button-to-Comments)
|
||||
|
||||
|
||||
### Forking, Branching and Merging
|
||||
### Work on the Core
|
||||
|
||||
Talk follows the _master as tip_ repo structure. `master` is the bleeding edge. It should be _as stable as possible_ but may suffer instabilities, generally during times that fundamental architectural elements are added.
|
||||
There is always more work to be done to make an application more stable, scaleable and secure.
|
||||
|
||||
Releases are _tagged_ off the master branch.
|
||||
If you see issues in the code or have ideas on how we may improve Talk, please consider:
|
||||
|
||||
Contributions to Talk follow this process. There are a lot of steps, but mechanically following these steps will standardize communication, help stop errors and let you focus on your contribution.
|
||||
|
||||
* At the outset of a piece of work, a branch or fork is made from master.
|
||||
* The work is done in that fork.
|
||||
* As soon as the work has taken shape, a PR is created for discussion. (If the PR is created for review before it's ready to merge, please make that clear in the description/title.)
|
||||
* At least one other contributor to the project must review all code (see Code Reviews below.)
|
||||
* If there are merge conflicts with master, merge master into the branch.
|
||||
* Ensure that [circleci](https://circleci.com/) passes all tests for your branch. (If you have forked and do not have circleci set up, you and the reviewer should independently ensure that all the of Continuous Integration steps pass before merging.)
|
||||
* If merge conflicts exist with `master`, merge `master` into your branch and re-run CI before merging into master.
|
||||
* Merge to master, but _you're not quite done yet!_
|
||||
* Deploy master to staging (or have a core member do so.)
|
||||
* Ensure that all your changes are working on staging.
|
||||
* Have your reviewer verify the same.
|
||||
* ... aaaand the work is delivered!
|
||||
* [contributing a fix](https://github.com/coralproject/talk/wiki/Forking,-Branching-and-Merging),
|
||||
* [filing an issue](https://github.com/coralproject/talk/issues), or
|
||||
* or otherwise [letting us know](https://github.com/coralproject/talk/wiki/Contact-Us).
|
||||
|
||||
|
||||
## Continuous Integration
|
||||
|
||||
We use circleci to run our ci: [https://circleci.com/gh/coralproject/talk](https://circleci.com/gh/coralproject/talk)
|
||||
|
||||
Our pipeline will _test_, _lint_, and _build_ all pushes to the repo.
|
||||
|
||||
Any branch not passing CI will not be merged into master.
|
||||
|
||||
If you're working in a fork, please run each of the steps locally before submitting a PR.
|
||||
|
||||
|
||||
## Coding Style
|
||||
|
||||
### API Design
|
||||
|
||||
When building APIs, we follow these principles:
|
||||
|
||||
* Follow [RESTful](https://en.wikipedia.org/wiki/Representational_state_transfer) principles for basic operations.
|
||||
* Avoid routing yourself into a corner, for example, by putting a variable other than an object's id directly after an object.
|
||||
* Put non-required, flexible variables into query params, required/identity based values in request params.
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
# Installing a dev environment
|
||||
|
||||
By contributing to this project you agree to the [Code of Conduct](https://coralproject.net/code-of-conduct.html).
|
||||
|
||||
## Requirements
|
||||
|
||||
### System
|
||||
|
||||
- Any flavor of Linux, OSX or Windows
|
||||
- 1GB memory (minimum)
|
||||
- 5GB storage (minimum)
|
||||
|
||||
### Software
|
||||
|
||||
* [Node](https://nodejs.org/es/download/package-manager) v7 or later
|
||||
* Mongo v3.2 or later
|
||||
* Redis v3.2 or later
|
||||
|
||||
_Please be sure to check the versions of these requirements. Insufficient versions of these may lead to unexpected errors!_
|
||||
|
||||
## First time setup
|
||||
|
||||
### Installation
|
||||
|
||||
Navigate to a directory.
|
||||
|
||||
```
|
||||
git clone https://github.com/coralproject/talk
|
||||
cd talk
|
||||
npm install
|
||||
```
|
||||
|
||||
### Environmental Variables
|
||||
|
||||
Talk uses environmental variables for configuration. You can learn about them in the [README file](README.md).
|
||||
|
||||
|
||||
## Workflows
|
||||
|
||||
### The server
|
||||
|
||||
Starting the server:
|
||||
|
||||
```
|
||||
npm start
|
||||
```
|
||||
|
||||
Browse to `http://localhost:3000` (or your custom port.)
|
||||
|
||||
### Building the front end
|
||||
|
||||
Our build process will build all front end components registered [here](https://github.com/coralproject/talk/blob/6052cac1d3494f8060325a88bb2ce03c88c2f94c/webpack.config.dev.js#L9-L15).
|
||||
|
||||
One time build:
|
||||
|
||||
```
|
||||
npm build
|
||||
```
|
||||
|
||||
Build, then rebuild when a file is updated (development build):
|
||||
|
||||
```
|
||||
npm build-watch
|
||||
```
|
||||
|
||||
|
||||
### Testing
|
||||
|
||||
Run all tests once:
|
||||
|
||||
`
|
||||
npm test
|
||||
`
|
||||
|
||||
Run our end to end tests (will install Selenium and nightwatch):
|
||||
|
||||
`
|
||||
npm run e2e
|
||||
`
|
||||
|
||||
_Please ensure all tests are passing before submitting a PR!_
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
##### Can't ping the redis server!
|
||||
|
||||
- Check that Redis Server is running.
|
||||
- Check that TALK_REDIS_URL is set.
|
||||
|
||||
##### Authenticaiton doesn't work!
|
||||
|
||||
- Make sure Redis is the correct version.
|
||||
|
||||
|
||||
@@ -4,35 +4,42 @@ A commenting platform from [The Coral Project](https://coralproject.net).
|
||||
|
||||
## Contributing to Talk
|
||||
|
||||
### Product Roadmap
|
||||
|
||||
You can view what the Coral Team is working on next here https://www.pivotaltracker.com/n/projects/1863625.
|
||||
|
||||
You can view product ideas and our longer term roadmap here https://trello.com/b/ILND751a/talk.
|
||||
See our [Contribution Guide](https://github.com/coralproject/talk/blob/master/CONTRIBUTING.md)!
|
||||
|
||||
## Usage
|
||||
|
||||
### Installation
|
||||
|
||||
To set up a development environment or build from source, see [INSTALL.md](https://github.com/coralproject/talk/blob/master/INSTALL.md).
|
||||
|
||||
To launch a Talk server of your own from your browser without any need to muck about in a terminal or think about engineering concepts, stay tuned. We will launch [our installer](https://github.com/coralproject/talk-install) shortly!!
|
||||
|
||||
|
||||
### Configuration
|
||||
|
||||
The Talk application requires specific configuration options to be available
|
||||
inside the environment in order to run, those variables are listed here:
|
||||
The Talk application looks for the following configuration values either as environment variables:
|
||||
|
||||
- `TALK_MONGO_URL` (*required*) - the database connection string for the MongoDB database.
|
||||
- `TALK_REDIS_URL` (*required*) - the database connection string for the Redis database.
|
||||
- `TALK_SESSION_SECRET` (*required*) - a random string which will be used to
|
||||
secure cookies.
|
||||
- `TALK_FACEBOOK_APP_ID` (*required*) - the Facebook app id for your Facebook
|
||||
Login enabled app.
|
||||
- `TALK_FACEBOOK_APP_SECRET` (*required*) - the Facebook app secret for your
|
||||
Facebook Login enabled app.
|
||||
- `TALK_ROOT_URL` (*required*) - root url of the installed application externally
|
||||
available in the format: `<scheme>://<host>` without the path.
|
||||
- `TALK_SMTP_EMAIL` (*required*) - the address to send emails from using the
|
||||
|
||||
- `TALK_FACEBOOK_APP_ID` (*required for login via fb*) - the Facebook app id for your Facebook
|
||||
Login enabled app.
|
||||
- `TALK_FACEBOOK_APP_SECRET` (*required for login via fb*) - the Facebook app secret for your
|
||||
Facebook Login enabled app.
|
||||
|
||||
- `TALK_SMTP_EMAIL` (*required for email*) - the address to send emails from using the
|
||||
SMTP provider.
|
||||
- `TALK_SMTP_USERNAME` (*required*) - username of the SMTP provider you are using.
|
||||
- `TALK_SMTP_PASSWORD` (*required*) - password for the SMTP provider you are using.
|
||||
- `TALK_SMTP_HOST` (*required*) - SMTP host url with format `smtp.domain.com`.
|
||||
- `TALK_SMTP_PORT` (*required*) - SMTP port.
|
||||
- `TALK_SMTP_USERNAME` (*required for email*) - username of the SMTP provider you are using.
|
||||
- `TALK_SMTP_PASSWORD` (*required for email*) - password for the SMTP provider you are using.
|
||||
- `TALK_SMTP_HOST` (*required for email*) - SMTP host url with format `smtp.domain.com`.
|
||||
- `TALK_SMTP_PORT` (*required for email*) - SMTP port.
|
||||
|
||||
Refer to the wiki page on [Configuration Loading](https://github.com/coralproject/talk/wiki/Configuration-Loading) for
|
||||
alternative methods of loading configuration during development.
|
||||
|
||||
### License
|
||||
|
||||
|
||||
@@ -5,8 +5,11 @@ const path = require('path');
|
||||
const helmet = require('helmet');
|
||||
const passport = require('./services/passport');
|
||||
const session = require('express-session');
|
||||
const enabled = require('debug').enabled;
|
||||
const RedisStore = require('connect-redis')(session);
|
||||
const redis = require('./services/redis');
|
||||
const csrf = require('csurf');
|
||||
const errors = require('./errors');
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -42,6 +45,7 @@ const session_opts = {
|
||||
rolling: true,
|
||||
saveUninitialized: false,
|
||||
resave: false,
|
||||
unset: 'destroy',
|
||||
name: 'talk.sid',
|
||||
cookie: {
|
||||
secure: false,
|
||||
@@ -73,6 +77,29 @@ app.use(session(session_opts));
|
||||
app.use(passport.initialize());
|
||||
app.use(passport.session());
|
||||
|
||||
//==============================================================================
|
||||
// CSRF MIDDLEWARE
|
||||
//==============================================================================
|
||||
|
||||
if (process.env.TEST_MODE === 'unit') {
|
||||
|
||||
// Add this fake test token in the event we are in unit test mode, and don't
|
||||
// include the CSRF protection.
|
||||
app.locals.csrfToken = 'UNIT_TESTS';
|
||||
|
||||
} else {
|
||||
|
||||
// Setup route middlewares for CSRF protection.
|
||||
// Default ignore methods are GET, HEAD, OPTIONS
|
||||
app.use(csrf({}));
|
||||
app.use((req, res, next) => {
|
||||
res.locals.csrfToken = req.csrfToken();
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
// ROUTES
|
||||
//==============================================================================
|
||||
@@ -83,40 +110,47 @@ app.use('/', require('./routes'));
|
||||
// ERROR HANDLING
|
||||
//==============================================================================
|
||||
|
||||
const ErrNotFound = new Error('Not Found');
|
||||
ErrNotFound.status = 404;
|
||||
|
||||
// Catch 404 and forward to error handler.
|
||||
app.use((req, res, next) => {
|
||||
next(ErrNotFound);
|
||||
next(errors.ErrNotFound);
|
||||
});
|
||||
|
||||
// General error handler. Respond with the message and error if we have it while
|
||||
// returning a status code that makes sense.
|
||||
app.use('/api', (err, req, res, next) => {
|
||||
if (err !== ErrNotFound) {
|
||||
if (app.get('env') !== 'test') {
|
||||
if (err !== errors.ErrNotFound) {
|
||||
if (app.get('env') !== 'test' || enabled('talk:errors')) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
res.status(err.status || 500);
|
||||
res.json({
|
||||
message: err.message,
|
||||
error: app.get('env') === 'development' ? err : {}
|
||||
});
|
||||
if (err instanceof errors.APIError) {
|
||||
res.status(err.status).json({
|
||||
message: err.message,
|
||||
error: err
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({});
|
||||
}
|
||||
});
|
||||
|
||||
app.use('/', (err, req, res, next) => {
|
||||
if (err !== ErrNotFound) {
|
||||
if (err !== errors.ErrNotFound) {
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
res.status(err.status || 500);
|
||||
res.render('error', {
|
||||
message: err.message,
|
||||
error: app.get('env') === 'development' ? err : {}
|
||||
});
|
||||
if (err instanceof errors.APIError) {
|
||||
res.status(err.status);
|
||||
res.render('error', {
|
||||
message: err.message,
|
||||
error: app.get('env') === 'development' ? err : {}
|
||||
});
|
||||
} else {
|
||||
res.render('error', {
|
||||
message: err.message,
|
||||
error: app.get('env') === 'development' ? err : {}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Perform rewrites to the runtime environment variables based on the contents
|
||||
// of the process.env.REWRITE_ENV if it exists. This is done here as it is the
|
||||
// entrypoint for the entire application.
|
||||
require('env-rewrite').rewrite();
|
||||
|
||||
/**
|
||||
* Module dependencies.
|
||||
*/
|
||||
|
||||
const program = require('commander');
|
||||
const pkg = require('../package.json');
|
||||
const dotenv = require('dotenv');
|
||||
|
||||
//==============================================================================
|
||||
// Setting up the program command line arguments.
|
||||
@@ -18,6 +14,25 @@ const pkg = require('../package.json');
|
||||
|
||||
program
|
||||
.version(pkg.version)
|
||||
.option('-c, --config [path]', 'Specify the configuration file to load')
|
||||
.parse(process.argv);
|
||||
|
||||
if (program.config) {
|
||||
let r = dotenv.config({
|
||||
path: program.config
|
||||
});
|
||||
|
||||
if (r.error) {
|
||||
throw r.error;
|
||||
}
|
||||
}
|
||||
|
||||
// Perform rewrites to the runtime environment variables based on the contents
|
||||
// of the process.env.REWRITE_ENV if it exists. This is done here as it is the
|
||||
// entrypoint for the entire application.
|
||||
require('env-rewrite').rewrite();
|
||||
|
||||
program
|
||||
.command('serve', 'serve the application')
|
||||
.command('assets', 'interact with assets')
|
||||
.command('settings', 'work with the application settings')
|
||||
|
||||
+6
-2
@@ -6,6 +6,7 @@
|
||||
|
||||
const program = require('commander');
|
||||
const scraper = require('../services/scraper');
|
||||
const mailer = require('../services/mailer');
|
||||
const util = require('../util');
|
||||
const mongoose = require('../services/mongoose');
|
||||
const kue = require('../services/kue');
|
||||
@@ -19,13 +20,16 @@ util.onshutdown([
|
||||
*/
|
||||
function processJobs() {
|
||||
|
||||
// Start the processor.
|
||||
// Start the scraper processor.
|
||||
scraper.process();
|
||||
|
||||
// Start the mail processor.
|
||||
mailer.process();
|
||||
|
||||
// The scraper only needs to shutdown when the scraper has actually been
|
||||
// started.
|
||||
util.onshutdown([
|
||||
() => scraper.shutdown()
|
||||
() => kue.Task.shutdown()
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
+8
-3
@@ -5,6 +5,8 @@ const debug = require('debug')('talk:server');
|
||||
const http = require('http');
|
||||
const init = require('../init');
|
||||
const scraper = require('../services/scraper');
|
||||
const mailer = require('../services/mailer');
|
||||
const kue = require('../services/kue');
|
||||
const mongoose = require('../services/mongoose');
|
||||
const util = require('../util');
|
||||
|
||||
@@ -12,7 +14,7 @@ const util = require('../util');
|
||||
* Get port from environment and store in Express.
|
||||
*/
|
||||
|
||||
const port = normalizePort(process.env.TALK_PORT || (process.env.NODE_ENV === 'test' ? '3011' : '3000'));
|
||||
const port = normalizePort(process.env.TALK_PORT || '3000');
|
||||
|
||||
app.set('port', port);
|
||||
|
||||
@@ -119,15 +121,18 @@ startApp();
|
||||
// Enable job processing on the thread if enabled.
|
||||
if (program.jobs) {
|
||||
|
||||
// Start the processor.
|
||||
// Start the scraper processor.
|
||||
scraper.process();
|
||||
|
||||
// Start the mail processor.
|
||||
mailer.process();
|
||||
}
|
||||
|
||||
// Define a safe shutdown function to call in the event we need to shutdown
|
||||
// because the node hooks are below which will interrupt the shutdown process.
|
||||
// Shutdown the mongoose connection, the app server, and the scraper.
|
||||
util.onshutdown([
|
||||
() => program.jobs ? scraper.shutdown() : null,
|
||||
() => program.jobs ? kue.Task.shutdown() : null,
|
||||
() => mongoose.disconnect(),
|
||||
() => server.close()
|
||||
]);
|
||||
|
||||
+10
-6
@@ -80,12 +80,16 @@ function createUser(options) {
|
||||
.then((user) => {
|
||||
console.log(`Created user ${user.id}.`);
|
||||
|
||||
return User
|
||||
.addRoleToUser(user.id, result.role.trim())
|
||||
.then(() => {
|
||||
console.log(`Added the admin ${result.role.trim()} to User ${user.id}.`);
|
||||
util.shutdown();
|
||||
});
|
||||
if (result.role && result.role.length > 0) {
|
||||
return User
|
||||
.addRoleToUser(user.id, result.role.trim())
|
||||
.then(() => {
|
||||
console.log(`Added the admin ${result.role.trim()} to User ${user.id}.`);
|
||||
util.shutdown();
|
||||
});
|
||||
} else {
|
||||
util.shutdown();
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import {Router, Route, IndexRoute, browserHistory} from 'react-router';
|
||||
|
||||
import ModerationQueue from 'containers/ModerationQueue/ModerationQueue';
|
||||
import ModerationContainer from 'containers/ModerationQueue/ModerationContainer';
|
||||
import CommentStream from 'containers/CommentStream/CommentStream';
|
||||
import Configure from 'containers/Configure/Configure';
|
||||
import Streams from 'containers/Streams/Streams';
|
||||
@@ -10,7 +10,7 @@ import LayoutContainer from 'containers/LayoutContainer';
|
||||
|
||||
const routes = (
|
||||
<Route path='/admin' component={LayoutContainer}>
|
||||
<IndexRoute component={ModerationQueue} />
|
||||
<IndexRoute component={ModerationContainer} />
|
||||
<Route path='embed' component={CommentStream} />
|
||||
<Route path='community' component={CommunityContainer} />
|
||||
<Route path='configure' component={Configure} />
|
||||
|
||||
@@ -10,9 +10,9 @@ const checkLoginFailure = error => ({type: actions.CHECK_LOGIN_FAILURE, error});
|
||||
export const checkLogin = () => dispatch => {
|
||||
dispatch(checkLoginRequest());
|
||||
coralApi('/auth')
|
||||
.then(user => {
|
||||
const isAdmin = !!user.roles.filter(i => i === 'admin').length;
|
||||
dispatch(checkLoginSuccess(user, isAdmin));
|
||||
.then(result => {
|
||||
const isAdmin = !!result.user.roles.filter(i => i === 'admin').length;
|
||||
dispatch(checkLoginSuccess(result.user, isAdmin));
|
||||
})
|
||||
.catch(error => dispatch(checkLoginFailure(error)));
|
||||
};
|
||||
|
||||
@@ -2,14 +2,21 @@ import coralApi from '../../../coral-framework/helpers/response';
|
||||
import * as commentTypes from '../constants/comments';
|
||||
import * as actionTypes from '../constants/actions';
|
||||
|
||||
function addUsersCommentsActions (dispatch, {comments, users, actions}) {
|
||||
dispatch({type: commentTypes.USERS_MODERATION_QUEUE_FETCH_SUCCESS, users});
|
||||
dispatch({type: commentTypes.COMMENTS_MODERATION_QUEUE_FETCH_SUCCESS, comments});
|
||||
dispatch({type: actionTypes.ACTIONS_MODERATION_QUEUE_FETCH_SUCCESS, actions});
|
||||
}
|
||||
|
||||
// Get comments to fill each of the three lists on the mod queue
|
||||
export const fetchModerationQueueComments = () => {
|
||||
return dispatch => {
|
||||
dispatch({type: commentTypes.COMMENTS_MODERATION_QUEUE_FETCH_REQUEST});
|
||||
|
||||
return Promise.all([
|
||||
coralApi('/queue/comments/pending'),
|
||||
coralApi('/comments?status=rejected'),
|
||||
coralApi('/comments?action_type=flag')
|
||||
coralApi('/queue/comments/rejected'),
|
||||
coralApi('/queue/comments/flagged')
|
||||
])
|
||||
.then(([pending, rejected, flagged]) => {
|
||||
|
||||
@@ -21,22 +28,46 @@ export const fetchModerationQueueComments = () => {
|
||||
actions: [...pending.actions, ...rejected.actions, ...flagged.actions]
|
||||
};
|
||||
})
|
||||
.then(({comments, users, actions}) => {
|
||||
.then(addUsersCommentsActions.bind(this, dispatch));
|
||||
};
|
||||
};
|
||||
|
||||
/* Post comments and users to redux store. Actions will be posted when they are needed. */
|
||||
dispatch({type: commentTypes.USERS_MODERATION_QUEUE_FETCH_SUCCESS, users});
|
||||
dispatch({type: commentTypes.COMMENTS_MODERATION_QUEUE_FETCH_SUCCESS, comments});
|
||||
dispatch({type: actionTypes.ACTIONS_MODERATION_QUEUE_FETCH_SUCCESS, actions});
|
||||
export const fetchPendingQueue = () => {
|
||||
return dispatch => {
|
||||
dispatch({type: commentTypes.COMMENTS_MODERATION_QUEUE_FETCH_REQUEST});
|
||||
|
||||
});
|
||||
return coralApi('/queue/comments/pending')
|
||||
.then(addUsersCommentsActions.bind(this, dispatch));
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchRejectedQueue = () => {
|
||||
return dispatch => {
|
||||
dispatch({type: commentTypes.COMMENTS_MODERATION_QUEUE_FETCH_REQUEST});
|
||||
|
||||
return coralApi('/queue/comments/rejected')
|
||||
.then(addUsersCommentsActions.bind(this, dispatch));
|
||||
};
|
||||
};
|
||||
|
||||
export const fetchFlaggedQueue = () => {
|
||||
return dispatch => {
|
||||
dispatch({type: commentTypes.COMMENTS_MODERATION_QUEUE_FETCH_REQUEST});
|
||||
|
||||
return coralApi('/queue/comments/flagged')
|
||||
.then(results => {
|
||||
results.comments.forEach(comment => comment.flagged = true);
|
||||
return results;
|
||||
})
|
||||
.then(addUsersCommentsActions.bind(this, dispatch));
|
||||
};
|
||||
};
|
||||
|
||||
// Create a new comment
|
||||
export const createComment = (name, body) => {
|
||||
return dispatch => {
|
||||
const comment = {body, name};
|
||||
return coralApi('/comments', {method: 'POST', comment})
|
||||
return (dispatch) => {
|
||||
const formData = {body, name};
|
||||
return coralApi('/comments', {method: 'POST', body: formData})
|
||||
.then(res => dispatch({type: commentTypes.COMMENT_CREATE_SUCCESS, comment: res}))
|
||||
.catch(error => dispatch({type: commentTypes.COMMENT_CREATE_FAILED, error}));
|
||||
};
|
||||
|
||||
@@ -41,14 +41,15 @@ export const newPage = () => ({
|
||||
type: COMMENTERS_NEW_PAGE
|
||||
});
|
||||
|
||||
export const setRole = (id, role) => dispatch => {
|
||||
export const setRole = (id, role) => (dispatch) => {
|
||||
|
||||
return coralApi(`/users/${id}/role`, {method: 'POST', body: {role}})
|
||||
.then(() => {
|
||||
return dispatch({type: SET_ROLE, id, role});
|
||||
});
|
||||
};
|
||||
|
||||
export const setCommenterStatus = (id, status) => dispatch => {
|
||||
export const setCommenterStatus = (id, status) => (dispatch) => {
|
||||
return coralApi(`/users/${id}/status`, {method: 'POST', body: {status}})
|
||||
.then(() => {
|
||||
return dispatch({type: SET_COMMENTER_STATUS, id, status});
|
||||
|
||||
@@ -6,7 +6,7 @@ import * as actions from '../constants/user';
|
||||
*/
|
||||
// change status of a user
|
||||
export const userStatusUpdate = (status, userId, commentId) => {
|
||||
return dispatch => {
|
||||
return (dispatch) => {
|
||||
dispatch({type: actions.UPDATE_STATUS_REQUEST});
|
||||
return coralApi(`/users/${userId}/status`, {method: 'POST', body: {status: status, comment_id: commentId}})
|
||||
.then(res => dispatch({type: actions.UPDATE_STATUS_SUCCESS, res}))
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
.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;
|
||||
}
|
||||
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: 500px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
height: 184px;
|
||||
padding: 20px;
|
||||
|
||||
.header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
h2 {
|
||||
color: black;
|
||||
font-size: 1.76em;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header h1, .separator h1{
|
||||
text-align: center;
|
||||
font-size: 1.2em;
|
||||
h3 {
|
||||
color: black;
|
||||
font-size: 1.4em;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.formField {
|
||||
@@ -143,5 +151,14 @@ input.error{
|
||||
}
|
||||
|
||||
.cancel {
|
||||
margin: 10px 0;
|
||||
margin-right: 10px;
|
||||
width: 47%;
|
||||
}
|
||||
|
||||
.ban {
|
||||
width: 47%;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
@@ -1,45 +1,46 @@
|
||||
import React from 'react';
|
||||
|
||||
import {Dialog} from 'coral-ui';
|
||||
import Button from 'coral-ui/components/Button';
|
||||
|
||||
import styles from './BanUserDialog.css';
|
||||
|
||||
import Button from 'coral-ui/components/Button';
|
||||
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../translations';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
const BanUserDialog = ({open, handleClose, onClickBanUser, user = {}}) => {
|
||||
const {userName = '', userId = '', commentId = ''} = user;
|
||||
|
||||
return (
|
||||
<Dialog className={styles.dialog} open={open} onClose={() => handleClose()} onCancel={() => handleClose()} title={lang.t('bandialog.ban_user')}>
|
||||
<span className={styles.close} onClick={() => handleClose()}>×</span>
|
||||
<div>
|
||||
<div className={styles.header}>
|
||||
<h3>
|
||||
{lang.t('bandialog.ban_user')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className={styles.separator}>
|
||||
<h4>
|
||||
{lang.t('bandialog.are_you_sure', userName)}
|
||||
</h4>
|
||||
<i>
|
||||
{lang.t('bandialog.note')}
|
||||
</i>
|
||||
</div>
|
||||
<div className={styles.buttons}>
|
||||
<Button cStyle="cancel" className={styles.cancel} onClick={() => handleClose()} full>
|
||||
{lang.t('bandialog.cancel')}
|
||||
</Button>
|
||||
<Button cStyle="black" onClick={() => onClickBanUser(userId, commentId)} full>
|
||||
{lang.t('bandialog.yes_ban_user')}
|
||||
</Button>
|
||||
</div>
|
||||
const BanUserDialog = ({open, handleClose, onClickBanUser, user = {}}) => (
|
||||
<Dialog
|
||||
className={styles.dialog}
|
||||
id="banuserDialog"
|
||||
open={open}
|
||||
onClose={() => handleClose()}
|
||||
onCancel={() => handleClose()}
|
||||
title={lang.t('bandialog.ban_user')}>
|
||||
<span className={styles.close} onClick={handleClose}>×</span>
|
||||
<div>
|
||||
<div className={styles.header}>
|
||||
<h2>
|
||||
{lang.t('bandialog.ban_user')}
|
||||
</h2>
|
||||
</div>
|
||||
<div className={styles.separator}>
|
||||
<h3>
|
||||
{lang.t('bandialog.are_you_sure', user.userName)}
|
||||
</h3>
|
||||
<i>
|
||||
{lang.t('bandialog.note')}
|
||||
</i>
|
||||
</div>
|
||||
<div className={styles.buttons}>
|
||||
<Button cStyle="cancel" className={styles.cancel} onClick={() => handleClose()} raised>
|
||||
{lang.t('bandialog.cancel')}
|
||||
</Button>
|
||||
<Button cStyle="black" className={styles.ban} onClick={() => onClickBanUser(user.userId, user.commentId)} raised>
|
||||
{lang.t('bandialog.yes_ban_user')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
);
|
||||
|
||||
export default BanUserDialog;
|
||||
|
||||
@@ -7,9 +7,8 @@ import styles from './CommentList.css';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../translations.json';
|
||||
|
||||
import {Icon} from 'react-mdl';
|
||||
import Highlighter from 'react-highlight-words';
|
||||
import {FabButton, Button} from 'coral-ui';
|
||||
import {FabButton, Button, Icon} from 'coral-ui';
|
||||
|
||||
const linkify = new Linkify();
|
||||
|
||||
@@ -20,22 +19,19 @@ export default props => {
|
||||
const links = linkify.getMatches(comment.body);
|
||||
|
||||
return (
|
||||
<li tabIndex={props.index} className={`${styles.listItem} ${props.isActive && !props.hideActive ? styles.activeItem : ''}`}>
|
||||
<li tabIndex={props.index} className={`mdl-card mdl-shadow--2dp ${styles.listItem} ${props.isActive && !props.hideActive ? styles.activeItem : ''}`}>
|
||||
<div className={styles.itemHeader}>
|
||||
<div className={styles.author}>
|
||||
<i className={`material-icons ${styles.avatar}`}>person</i>
|
||||
<span>{author.displayName || lang.t('comment.anon')}</span>
|
||||
<span className={styles.created}>{timeago().format(comment.createdAt || (Date.now() - props.index * 60 * 1000), lang.getLocale().replace('-', '_'))}</span>
|
||||
{comment.flagged ? <p className={styles.flagged}>{lang.t('comment.flagged')}</p> : null}
|
||||
</div>
|
||||
<div>
|
||||
<div className={styles.sideActions}>
|
||||
{links ?
|
||||
<span className={styles.hasLinks}><Icon name='error_outline'/> Contains Link</span> : null}
|
||||
<div className={`actions ${styles.actions}`}>
|
||||
{props.modActions.map((action, i) => getActionButton(action, i, props))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{authorStatus === 'banned' ?
|
||||
<span className={styles.banned}><Icon name='error_outline'/> {lang.t('comment.banned_user')}</span> : null}
|
||||
</div>
|
||||
@@ -65,15 +61,19 @@ const getActionButton = (action, i, props) => {
|
||||
}
|
||||
if (action === 'ban') {
|
||||
return (
|
||||
<Button
|
||||
className='ban'
|
||||
cStyle='black'
|
||||
disabled={banned ? 'disabled' : ''}
|
||||
onClick={() => props.onClickShowBanDialog(author.id, author.displayName, comment.id)}
|
||||
key={i}
|
||||
>
|
||||
{lang.t('comment.ban_user')}
|
||||
</Button>
|
||||
<div className={styles.ban}>
|
||||
<Button
|
||||
className={`ban ${styles.banButton}`}
|
||||
cStyle='darkGrey'
|
||||
disabled={banned ? 'disabled' : ''}
|
||||
onClick={() => props.onClickShowBanDialog(author.id, author.displayName, comment.id)}
|
||||
key={i}
|
||||
raised
|
||||
>
|
||||
<Icon name='not_interested' className={styles.banIcon} />
|
||||
{lang.t('comment.ban_user')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
|
||||
@@ -36,13 +36,33 @@
|
||||
|
||||
.listItem {
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
padding: 16px;
|
||||
font-size: 16px;
|
||||
width: 100%;
|
||||
max-width: 660px;
|
||||
min-width: 400px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 14px;
|
||||
position: relative;
|
||||
transition: box-shadow 200ms;
|
||||
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.sideActions {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
padding: 40px 18px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.itemHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -71,25 +91,20 @@
|
||||
|
||||
.created {
|
||||
color: #666;
|
||||
font-size: 10px;
|
||||
margin-left: 10px;
|
||||
font-size: 13px;
|
||||
margin-left: 40px;
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
transform: scale(.7);
|
||||
transform: scale(.8);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.body {
|
||||
margin-top: 20px;
|
||||
flex: 1;
|
||||
font-size: 1em;
|
||||
color: rgba(0,0,0,.54);
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-left: 10px;
|
||||
display: flex;
|
||||
font-size: 0.88em;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.flagged {
|
||||
@@ -143,3 +158,20 @@
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.ban {
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.banButton {
|
||||
width: 114px;
|
||||
letter-spacing: 1px;
|
||||
|
||||
i {
|
||||
vertical-align: middle;
|
||||
margin-right: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,14 +21,12 @@ export default class CommentList extends React.Component {
|
||||
comments: PropTypes.object.isRequired,
|
||||
users: PropTypes.object.isRequired,
|
||||
onClickAction: PropTypes.func,
|
||||
modActions: PropTypes.arrayOf(PropTypes.string),
|
||||
|
||||
// list of actions (flags, etc) associated with the comments
|
||||
modActions: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
loading: PropTypes.bool,
|
||||
|
||||
// list of actions (flags, etc) associated with the comments
|
||||
actions: PropTypes.shape({
|
||||
ids: PropTypes.arrayOf(PropTypes.string)
|
||||
}),
|
||||
suspectWords: PropTypes.arrayOf(PropTypes.string)
|
||||
suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired
|
||||
}
|
||||
|
||||
constructor (props) {
|
||||
|
||||
@@ -1,40 +1,42 @@
|
||||
.header {
|
||||
background: #505050;
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
min-height: 58px;
|
||||
}
|
||||
|
||||
.header > div {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
width: 1170px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.active {
|
||||
background: #232323;
|
||||
background-color: #696969;
|
||||
position: relative;
|
||||
padding: 0;
|
||||
min-width: 1280px;
|
||||
margin: 0 auto;
|
||||
box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12);
|
||||
height: 58px;
|
||||
}
|
||||
|
||||
.rightPanel {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
width: 170px;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
width: 170px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.rightPanel ul {
|
||||
list-style: none;
|
||||
line-height: 38px;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.rightPanel li {
|
||||
display: inline-block;
|
||||
float: right;
|
||||
margin-left: 15px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
line-height: 33px;
|
||||
}
|
||||
|
||||
.rightPanel .settings {
|
||||
vertical-align: middle;
|
||||
border-radius: 3px;
|
||||
border: solid 1px #9e9e9e;
|
||||
line-height: 10px;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.rightPanel .settings > div {
|
||||
@@ -45,3 +47,25 @@
|
||||
background: rgba(158, 158, 158, 0.69);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.navLink {
|
||||
padding: 0 20px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
background-color: transparent;
|
||||
transition: background-color 200ms;
|
||||
|
||||
&.active {
|
||||
background-color: #232323;
|
||||
}
|
||||
}
|
||||
|
||||
.nav {
|
||||
overflow: hidden;
|
||||
height: 58px !important;
|
||||
}
|
||||
|
||||
.nav .navLink {
|
||||
padding: 0 20px;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {Logo} from './Logo';
|
||||
export default ({handleLogout}) => (
|
||||
<Header className={styles.header}>
|
||||
<Logo />
|
||||
<Navigation>
|
||||
<Navigation className={styles.nav}>
|
||||
<IndexLink className={styles.navLink} to="/admin"
|
||||
activeClassName={styles.active}>{lang.t('configure.moderate')}</IndexLink>
|
||||
<Link className={styles.navLink} to="/admin/community"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
.layout {
|
||||
max-width: 1170px;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
overflow: hidden;
|
||||
background-color: #FAFAFA;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
.logo h1 {
|
||||
color: #272727;
|
||||
font-size: 20px;
|
||||
margin: 0;
|
||||
line-height: 60px;
|
||||
padding: 0 20px;
|
||||
color: #272727;
|
||||
font-size: 20px;
|
||||
margin: 0;
|
||||
line-height: 60px;
|
||||
padding-left: 13px;
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
.logo span {
|
||||
display: inline-block;
|
||||
margin-left: 10px;
|
||||
font-size: 18px;
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
margin-left: 10px;
|
||||
font-size: 18px;
|
||||
vertical-align: middle;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.logo {
|
||||
background: #E5E5E5;
|
||||
height: 100%;
|
||||
background: #E5E5E5;
|
||||
height: 100%;
|
||||
width: 128px;
|
||||
}
|
||||
|
||||
|
||||
.base {
|
||||
stroke: #E5E5E5;
|
||||
height: 35px;
|
||||
width: 35px;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {CoralLogo} from 'coral-ui';
|
||||
export const Logo = () => (
|
||||
<div className={styles.logo}>
|
||||
<h1>
|
||||
<CoralLogo stroke="#E5E5E5" />
|
||||
<CoralLogo className={styles.base} />
|
||||
<span>Talk</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,8 @@ export const CHECK_LOGIN_REQUEST = 'CHECK_LOGIN_REQUEST';
|
||||
export const CHECK_LOGIN_SUCCESS = 'CHECK_LOGIN_SUCCESS';
|
||||
export const CHECK_LOGIN_FAILURE = 'CHECK_LOGIN_FAILURE';
|
||||
|
||||
export const CHECK_CSRF_TOKEN = 'CHECK_CSRF_TOKEN';
|
||||
|
||||
export const LOGOUT_REQUEST = 'LOGOUT_REQUEST';
|
||||
export const LOGOUT_SUCCESS = 'LOGOUT_SUCCESS';
|
||||
export const LOGOUT_FAILURE = 'LOGOUT_FAILURE';
|
||||
|
||||
@@ -43,7 +43,7 @@ class CommentStream extends React.Component {
|
||||
render ({comments, users}, {snackbar, snackbarMsg}) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<CommentBox onSubmit={this.onSubmit} />
|
||||
<CommentBox onSubmit={this.onSubmit}/>
|
||||
<CommentList isActive hideActive
|
||||
singleView={false}
|
||||
commentIds={comments.ids}
|
||||
|
||||
@@ -1,17 +1,96 @@
|
||||
.roleButton {
|
||||
display: block;
|
||||
.container {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
padding-bottom: 200px;
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
display: block;
|
||||
padding-left: 40px;
|
||||
width: auto;
|
||||
.leftColumn {
|
||||
padding: 42px 56px;
|
||||
width: 234px;
|
||||
}
|
||||
|
||||
.mainContent {
|
||||
width: calc(100% - 300px);
|
||||
padding: 34px 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.roleButton {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.searchBox {
|
||||
background: white;
|
||||
width: 100%;
|
||||
padding: 9px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 2px;
|
||||
display: flex;
|
||||
background: white;
|
||||
box-sizing: border-box;
|
||||
height: 40px;
|
||||
|
||||
i {
|
||||
color: #A1A1A1
|
||||
}
|
||||
|
||||
input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
font-size: 16px;
|
||||
padding: 0 2px 0 15px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
.email {
|
||||
display: block;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.dataTable {
|
||||
width: 100%;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
|
||||
th {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
th:nth-child(2), th:nth-child(3) {
|
||||
width: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
.selectField {
|
||||
position: relative;
|
||||
width: 150px;
|
||||
height: 36px;
|
||||
background: #2c2c2c;
|
||||
padding: 10px 15px;
|
||||
box-sizing: border-box;
|
||||
color: white;
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12);
|
||||
|
||||
> div {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
i {
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
right: 7px;
|
||||
}
|
||||
|
||||
input {
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.7px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import React from 'react';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../../translations.json';
|
||||
import {Grid, Cell} from 'react-mdl';
|
||||
|
||||
import styles from './Community.css';
|
||||
import Table from './Table';
|
||||
import Loading from './Loading';
|
||||
import NoResults from './NoResults';
|
||||
import Pager from 'coral-ui/components/Pager';
|
||||
import {Pager} from 'coral-ui';
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
@@ -33,17 +32,17 @@ const tableHeaders = [
|
||||
const Community = ({isFetching, commenters, ...props}) => {
|
||||
const hasResults = !isFetching && !!commenters.length;
|
||||
return (
|
||||
<Grid>
|
||||
<Cell col={2}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.leftColumn}>
|
||||
<form action="">
|
||||
<div className={`mdl-textfield ${styles.searchBox}`}>
|
||||
<label className="mdl-button mdl-js-button mdl-button--icon" htmlFor="commenters-search">
|
||||
<div className={`${styles.searchBox}`}>
|
||||
<label htmlFor="commenters-search">
|
||||
<i className="material-icons">search</i>
|
||||
</label>
|
||||
<div className="">
|
||||
<input
|
||||
id="commenters-search"
|
||||
className={`mdl-textfield__input ${styles.searchInput}`}
|
||||
className={`${styles.searchInput}`}
|
||||
type="text"
|
||||
value={props.searchValue}
|
||||
onKeyDown={props.onKeyDownHandler}
|
||||
@@ -52,8 +51,8 @@ const Community = ({isFetching, commenters, ...props}) => {
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Cell>
|
||||
<Cell col={6}>
|
||||
</div>
|
||||
<div className={styles.mainContent}>
|
||||
{ isFetching && <Loading /> }
|
||||
{ !hasResults && <NoResults /> }
|
||||
{ hasResults &&
|
||||
@@ -68,8 +67,8 @@ const Community = ({isFetching, commenters, ...props}) => {
|
||||
page={props.page}
|
||||
onNewPageHandler={props.onNewPageHandler}
|
||||
/>
|
||||
</Cell>
|
||||
</Grid>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ class Table extends Component {
|
||||
</td>
|
||||
<td className="mdl-data-table__cell--non-numeric">
|
||||
<SelectField label={'Select me'} value={row.status || ''}
|
||||
className={styles.selectField}
|
||||
label={lang.t('community.status')}
|
||||
onChange={status => this.onCommenterStatusChange(row.id, status)}>
|
||||
<Option value={'active'}>{lang.t('community.active')}</Option>
|
||||
@@ -60,6 +61,7 @@ class Table extends Component {
|
||||
</td>
|
||||
<td className="mdl-data-table__cell--non-numeric">
|
||||
<SelectField label={'Select me'} value={row.roles[0] || ''}
|
||||
className={styles.selectField}
|
||||
label={lang.t('community.role')}
|
||||
onChange={role => this.onRoleChange(row.id, role)}>
|
||||
<Option value={''}>.</Option>
|
||||
|
||||
@@ -3,15 +3,8 @@ import {SelectField, Option} from 'react-mdl-selectfield';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../../translations.json';
|
||||
import styles from './Configure.css';
|
||||
import {
|
||||
List,
|
||||
ListItem,
|
||||
ListItemContent,
|
||||
ListItemAction,
|
||||
Textfield,
|
||||
Checkbox,
|
||||
Icon
|
||||
} from 'react-mdl';
|
||||
import {Textfield, Checkbox} from 'react-mdl';
|
||||
import {Card, Icon, Spinner} from 'coral-ui';
|
||||
|
||||
const TIMESTAMPS = {
|
||||
weeks: 60 * 60 * 24 * 7,
|
||||
@@ -71,35 +64,32 @@ const updateClosedTimeout = (updateSettings, ts, isMeasure) => (event) => {
|
||||
|
||||
const CommentSettings = ({fetchingSettings, title, updateSettings, settingsError, settings, errors}) => {
|
||||
if (fetchingSettings) {
|
||||
|
||||
/* maybe a spinner here at some point */
|
||||
return <p>Loading settings...</p>;
|
||||
return <Card shadow="4"><Spinner/>Loading settings...</Card>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.commentSettingsSection}>
|
||||
<h3>{title}</h3>
|
||||
<List>
|
||||
<ListItem className={`${styles.configSetting} ${settings.moderation === 'pre' ? styles.enabledSetting : styles.disabledSetting}`}>
|
||||
<ListItemAction>
|
||||
<Card className={`${styles.configSetting} ${settings.moderation === 'pre' ? styles.enabledSetting : styles.disabledSetting}`}>
|
||||
<div className={styles.action}>
|
||||
<Checkbox
|
||||
onChange={updateModeration(updateSettings, settings.moderation)}
|
||||
checked={settings.moderation === 'pre'} />
|
||||
</ListItemAction>
|
||||
<ListItemContent>
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.settingsHeader}>{lang.t('configure.enable-pre-moderation')}</div>
|
||||
<p className={settings.moderation === 'pre' ? '' : styles.disabledSettingText}>
|
||||
{lang.t('configure.enable-pre-moderation-text')}
|
||||
</p>
|
||||
</ListItemContent>
|
||||
</ListItem>
|
||||
<ListItem className={`${styles.configSetting} ${settings.charCountEnable ? styles.enabledSetting : styles.disabledSetting}`}>
|
||||
<ListItemAction>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className={`${styles.configSetting} ${settings.charCountEnable ? styles.enabledSetting : styles.disabledSetting}`}>
|
||||
<div className={styles.action}>
|
||||
<Checkbox
|
||||
onChange={updateCharCountEnable(updateSettings, settings.charCountEnable)}
|
||||
checked={settings.charCountEnable} />
|
||||
</ListItemAction>
|
||||
<ListItemContent>
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.settingsHeader}>{lang.t('configure.comment-count-header')}</div>
|
||||
<p className={settings.charCountEnable ? '' : styles.disabledSettingText}>
|
||||
<span>{lang.t('configure.comment-count-text-pre')}</span>
|
||||
@@ -118,32 +108,44 @@ const CommentSettings = ({fetchingSettings, title, updateSettings, settingsError
|
||||
</span>
|
||||
}
|
||||
</p>
|
||||
</ListItemContent>
|
||||
</ListItem>
|
||||
<ListItem threeLine className={`${styles.configSettingInfoBox} ${settings.infoBoxEnable ? styles.enabledSetting : styles.disabledSetting}`}>
|
||||
<ListItemAction>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className={`${styles.configSettingInfoBox} ${settings.infoBoxEnable ? styles.enabledSetting : styles.disabledSetting}`}>
|
||||
<div className={styles.action}>
|
||||
<Checkbox
|
||||
onChange={updateInfoBoxEnable(updateSettings, settings.infoBoxEnable)}
|
||||
checked={settings.infoBoxEnable} />
|
||||
</ListItemAction>
|
||||
<ListItemContent>
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
{lang.t('configure.include-comment-stream')}
|
||||
<p>
|
||||
{lang.t('configure.include-comment-stream-desc')}
|
||||
</p>
|
||||
</ListItemContent>
|
||||
</ListItem>
|
||||
<ListItem className={`${styles.configSettingInfoBox} ${settings.infoBoxEnable ? null : styles.hidden}`} >
|
||||
<ListItemContent>
|
||||
<div className={`${styles.configSettingInfoBox} ${settings.infoBoxEnable ? null : styles.hidden}`} >
|
||||
<div className={styles.content}>
|
||||
<Textfield
|
||||
onChange={updateInfoBoxContent(updateSettings)}
|
||||
value={settings.infoBoxContent}
|
||||
label={lang.t('configure.include-text')}
|
||||
rows={3}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className={styles.configSettingInfoBox}>
|
||||
<div className={styles.content}>
|
||||
{lang.t('configure.closed-comments-desc')}
|
||||
<div>
|
||||
<Textfield
|
||||
onChange={updateInfoBoxContent(updateSettings)}
|
||||
value={settings.infoBoxContent}
|
||||
label={lang.t('configure.include-text')}
|
||||
onChange={updateClosedMessage(updateSettings)}
|
||||
value={settings.closedMessage}
|
||||
label={lang.t('configure.closed-comments-label')}
|
||||
rows={3}/>
|
||||
</ListItemContent>
|
||||
</ListItem>
|
||||
<ListItem className={styles.configSettingInfoBox}>
|
||||
<ListItemContent>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className={`${styles.configSettingInfoBox}`}>
|
||||
<div className={styles.content}>
|
||||
{lang.t('configure.close-after')}
|
||||
<br />
|
||||
<Textfield
|
||||
@@ -163,19 +165,8 @@ const CommentSettings = ({fetchingSettings, title, updateSettings, settingsError
|
||||
<Option value={'weeks'}>{lang.t('configure.weeks')}</Option>
|
||||
</SelectField>
|
||||
</div>
|
||||
</ListItemContent>
|
||||
</ListItem>
|
||||
<ListItem className={styles.configSettingInfoBox}>
|
||||
<ListItemContent>
|
||||
{lang.t('configure.closed-comments-desc')}
|
||||
<Textfield
|
||||
onChange={updateClosedMessage(updateSettings)}
|
||||
value={settings.closedMessage}
|
||||
label={lang.t('configure.closed-comments-label')}
|
||||
rows={3}/>
|
||||
</ListItemContent>
|
||||
</ListItem>
|
||||
</List>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,26 +1,29 @@
|
||||
.container {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
|
||||
h3 {
|
||||
color: black;
|
||||
font-size: 1.76em;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.leftColumn {
|
||||
width: 300px;
|
||||
padding: 42px 56px;
|
||||
width: 234px;
|
||||
}
|
||||
|
||||
.mainContent {
|
||||
width: calc(70% - 300px)
|
||||
}
|
||||
|
||||
.settingOption {
|
||||
cursor: pointer;
|
||||
width: calc(100% - 300px);
|
||||
padding: 10px 14px;
|
||||
box-sizing: border-box;
|
||||
max-width: 718px;
|
||||
}
|
||||
|
||||
.configSetting {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
height: 95px;
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 20px;
|
||||
align-items: flex-start;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.settingsError {
|
||||
@@ -42,13 +45,13 @@
|
||||
}
|
||||
|
||||
.configSettingInfoBox {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 10px;
|
||||
min-height: 100px;
|
||||
margin-bottom: 20px;
|
||||
cursor: pointer;
|
||||
width: auto;
|
||||
height: auto;
|
||||
text-align: left;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.configSettingInfoBox p {
|
||||
@@ -77,7 +80,7 @@
|
||||
}
|
||||
|
||||
.charCountTexfieldEnabled {
|
||||
border-color: #4caf50;
|
||||
border-color: #00796b;
|
||||
}
|
||||
|
||||
.charCountTexfield:focus {
|
||||
@@ -85,11 +88,12 @@
|
||||
}
|
||||
|
||||
.changedSave {
|
||||
background-color:#4caf50;
|
||||
background-color: #00796B;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.copiedText {
|
||||
color: #008000;
|
||||
color: #00796b;
|
||||
float: right;
|
||||
padding: 12px;
|
||||
font-size: 14px;
|
||||
@@ -97,6 +101,7 @@
|
||||
|
||||
.copyButton {
|
||||
float: right;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.embedInput {
|
||||
@@ -113,8 +118,12 @@
|
||||
}
|
||||
|
||||
#bannedWordlist, #suspectWordlist {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.wordlistHeader {
|
||||
@@ -124,7 +133,7 @@
|
||||
}
|
||||
|
||||
.enabledSetting {
|
||||
border-left-color: #4caf50;
|
||||
border-left-color: #00796b;
|
||||
border-left-style: solid;
|
||||
border-left-width: 7px;
|
||||
}
|
||||
@@ -136,3 +145,23 @@
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.saveBox {
|
||||
margin-top: 38px;
|
||||
}
|
||||
|
||||
.commentSettingsSection {
|
||||
padding-bottom: 200px;
|
||||
.action {
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: inline-block;
|
||||
padding-left: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, {Component} from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import {
|
||||
fetchSettings,
|
||||
@@ -6,13 +6,8 @@ import {
|
||||
saveSettingsToServer,
|
||||
updateWordlist,
|
||||
} from '../../actions/settings';
|
||||
import {
|
||||
List,
|
||||
ListItem,
|
||||
ListItemContent,
|
||||
Button,
|
||||
Icon
|
||||
} from 'react-mdl';
|
||||
|
||||
import {Button, List, Item} from 'coral-ui';
|
||||
import styles from './Configure.css';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../../translations.json';
|
||||
@@ -21,7 +16,7 @@ import CommentSettings from './CommentSettings';
|
||||
import Wordlist from './Wordlist';
|
||||
import has from 'lodash/has';
|
||||
|
||||
class Configure extends React.Component {
|
||||
class Configure extends Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
|
||||
@@ -30,6 +25,8 @@ class Configure extends React.Component {
|
||||
changed: false,
|
||||
errors: {}
|
||||
};
|
||||
|
||||
this.changeSection = this.changeSection.bind(this);
|
||||
}
|
||||
|
||||
componentWillMount = () => {
|
||||
@@ -41,7 +38,7 @@ class Configure extends React.Component {
|
||||
this.setState({changed: false});
|
||||
}
|
||||
|
||||
changeSection = (activeSection) => () => {
|
||||
changeSection(activeSection) {
|
||||
this.setState({activeSection});
|
||||
}
|
||||
|
||||
@@ -99,7 +96,8 @@ class Configure extends React.Component {
|
||||
}
|
||||
|
||||
render () {
|
||||
const section = this.getSection(this.state.activeSection);
|
||||
const {activeSection} = this.state;
|
||||
const section = this.getSection(activeSection);
|
||||
|
||||
const showSave = Object.keys(this.state.errors).reduce(
|
||||
(bool, error) => this.state.errors[error] ? false : bool, this.state.changed);
|
||||
@@ -107,37 +105,40 @@ class Configure extends React.Component {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.leftColumn}>
|
||||
<List>
|
||||
<ListItem className={styles.settingOption}>
|
||||
<ListItemContent
|
||||
onClick={this.changeSection('comments')}
|
||||
icon='settings'>{lang.t('configure.comment-settings')}</ListItemContent>
|
||||
</ListItem>
|
||||
<ListItem className={styles.settingOption}>
|
||||
<ListItemContent
|
||||
onClick={this.changeSection('embed')}
|
||||
icon='code'>{lang.t('configure.embed-comment-stream')}</ListItemContent>
|
||||
</ListItem>
|
||||
<ListItem className={styles.settingOption}>
|
||||
<ListItemContent
|
||||
onClick={this.changeSection('wordlist')}
|
||||
icon='settings'>{lang.t('configure.wordlist')}</ListItemContent>
|
||||
</ListItem>
|
||||
<List onChange={this.changeSection} activeItem={activeSection}>
|
||||
<Item itemId='comments' icon="settings">
|
||||
{lang.t('configure.comment-settings')}
|
||||
</Item>
|
||||
<Item itemId='embed' icon='code'>
|
||||
{lang.t('configure.embed-comment-stream')}
|
||||
</Item>
|
||||
<Item itemId='wordlist' icon='settings'>
|
||||
{lang.t('configure.wordlist')}
|
||||
</Item>
|
||||
</List>
|
||||
<div className={styles.saveBox}>
|
||||
{
|
||||
showSave ?
|
||||
<Button
|
||||
raised
|
||||
onClick={this.saveSettings}
|
||||
className={styles.changedSave}>
|
||||
<Icon name='check' /> {lang.t('configure.save-changes')}
|
||||
</Button>
|
||||
: <Button
|
||||
raised
|
||||
disabled>
|
||||
{lang.t('configure.save-changes')}
|
||||
</Button>
|
||||
<Button
|
||||
raised
|
||||
onClick={this.saveSettings}
|
||||
className={styles.changedSave}
|
||||
icon='check'
|
||||
full
|
||||
>
|
||||
{lang.t('configure.save-changes')}
|
||||
</Button>
|
||||
:
|
||||
<Button
|
||||
raised
|
||||
disabled
|
||||
icon='check'
|
||||
full
|
||||
>
|
||||
{lang.t('configure.save-changes')}
|
||||
</Button>
|
||||
}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div className={styles.mainContent}>
|
||||
|
||||
@@ -2,11 +2,7 @@ import React, {Component} from 'react';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../../translations.json';
|
||||
import styles from './Configure.css';
|
||||
import {
|
||||
List,
|
||||
ListItem,
|
||||
Button
|
||||
} from 'react-mdl';
|
||||
import {Button, Card} from 'coral-ui';
|
||||
|
||||
class EmbedLink extends Component {
|
||||
|
||||
@@ -34,16 +30,16 @@ class EmbedLink extends Component {
|
||||
return (
|
||||
<div>
|
||||
<h3>{this.props.title}</h3>
|
||||
<List>
|
||||
<ListItem className={styles.configSettingEmbed}>
|
||||
<div>
|
||||
<Card shadow="2">
|
||||
<p>{lang.t('configure.copy-and-paste')}</p>
|
||||
<textarea rows={5} type='text' className={styles.embedInput} value={embedText} readOnly={true}/>
|
||||
<Button raised colored className={styles.copyButton} onClick={this.copyToClipBoard}>
|
||||
<Button raised className={styles.copyButton} onClick={this.copyToClipBoard} cStyle="black">
|
||||
{lang.t('embedlink.copy')}
|
||||
</Button>
|
||||
<div className={styles.copiedText}>{this.state.copied && 'Copied!'}</div>
|
||||
</ListItem>
|
||||
</List>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,15 +2,13 @@ import React from 'react';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../../translations.json';
|
||||
import TagsInput from 'react-tagsinput';
|
||||
|
||||
import styles from './Configure.css';
|
||||
|
||||
import {Card} from 'react-mdl';
|
||||
import {Card} from 'coral-ui';
|
||||
|
||||
const Wordlist = ({suspectWords, bannedWords, onChangeWordlist}) => (
|
||||
<div>
|
||||
<h3>{lang.t('configure.banned-words-title')}</h3>
|
||||
<Card id={styles.bannedWordlist} shadow={2}>
|
||||
<Card id={styles.bannedWordlist}>
|
||||
<p className={styles.wordlistHeader}>{lang.t('configure.banned-word-header')}</p>
|
||||
<p className={styles.wordlistDesc}>{lang.t('configure.banned-word-text')}</p>
|
||||
<TagsInput
|
||||
@@ -18,10 +16,11 @@ const Wordlist = ({suspectWords, bannedWords, onChangeWordlist}) => (
|
||||
inputProps={{placeholder: 'word or phrase'}}
|
||||
addOnPaste={true}
|
||||
pasteSplit={data => data.split(',').map(d => d.trim())}
|
||||
onChange={tags => onChangeWordlist('banned', tags)} />
|
||||
onChange={tags => onChangeWordlist('banned', tags)}
|
||||
/>
|
||||
</Card>
|
||||
<h3>{lang.t('configure.suspect-words-title')}</h3>
|
||||
<Card id={styles.suspectWordlist} shadow={2}>
|
||||
<Card id={styles.suspectWordlist}>
|
||||
<p className={styles.wordlistHeader}>{lang.t('configure.suspect-word-header')}</p>
|
||||
<p className={styles.wordlistDesc}>{lang.t('configure.suspect-word-text')}</p>
|
||||
<TagsInput
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import React from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import key from 'keymaster';
|
||||
|
||||
import {
|
||||
updateStatus,
|
||||
showBanUserDialog,
|
||||
hideBanUserDialog,
|
||||
fetchPendingQueue,
|
||||
fetchRejectedQueue,
|
||||
fetchFlaggedQueue,
|
||||
fetchModerationQueueComments,
|
||||
} from 'actions/comments';
|
||||
import {userStatusUpdate} from 'actions/users';
|
||||
import {fetchSettings} from 'actions/settings';
|
||||
|
||||
import ModerationQueue from './ModerationQueue';
|
||||
|
||||
class ModerationContainer extends React.Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
activeTab: 'pending',
|
||||
singleView: false,
|
||||
modalOpen: false
|
||||
};
|
||||
|
||||
this.onClose = this.onClose.bind(this);
|
||||
this.onTabClick = this.onTabClick.bind(this);
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
this.props.fetchModerationQueueComments();
|
||||
this.props.fetchSettings();
|
||||
key('s', () => this.setState({singleView: !this.state.singleView}));
|
||||
key('shift+/', () => this.setState({modalOpen: true}));
|
||||
key('esc', () => this.setState({modalOpen: false}));
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
key.unbind('s');
|
||||
key.unbind('shift+/');
|
||||
key.unbind('esc');
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
|
||||
// Hack for dynamic mdl tabs
|
||||
if (typeof componentHandler !== 'undefined') {
|
||||
|
||||
// FIXME: fix this hack
|
||||
componentHandler.upgradeAllRegistered(); // eslint-disable-line no-undef
|
||||
}
|
||||
}
|
||||
|
||||
onTabClick(activeTab) {
|
||||
this.setState({activeTab});
|
||||
|
||||
if (activeTab === 'pending') {
|
||||
this.props.fetchPendingQueue();
|
||||
} else if (activeTab === 'rejected') {
|
||||
this.props.fetchRejectedQueue();
|
||||
} else if (activeTab === 'flagged') {
|
||||
this.props.fetchFlaggedQueue();
|
||||
} else {
|
||||
this.props.fetchModerationQueueComments();
|
||||
}
|
||||
}
|
||||
|
||||
onClose() {
|
||||
this.setState({modalOpen: false});
|
||||
}
|
||||
|
||||
render () {
|
||||
const {comments} = this.props;
|
||||
const premodIds = comments.ids.filter(id => comments.byId[id].status === 'premod');
|
||||
const rejectedIds = comments.ids.filter(id => comments.byId[id].status === 'rejected');
|
||||
const flaggedIds = comments.ids.filter(id =>
|
||||
comments.byId[id].flagged === true &&
|
||||
comments.byId[id].status !== 'rejected' &&
|
||||
comments.byId[id].status !== 'accepted'
|
||||
);
|
||||
|
||||
return (
|
||||
<ModerationQueue
|
||||
onTabClick={this.onTabClick}
|
||||
onClose={this.onClose}
|
||||
premodIds={premodIds}
|
||||
rejectedIds={rejectedIds}
|
||||
flaggedIds={flaggedIds}
|
||||
{...this.props}
|
||||
{...this.state}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
comments: state.comments.toJS(),
|
||||
settings: state.settings.toJS(),
|
||||
users: state.users.toJS()
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => {
|
||||
return {
|
||||
fetchSettings: () => dispatch(fetchSettings()),
|
||||
fetchModerationQueueComments: () => dispatch(fetchModerationQueueComments()),
|
||||
fetchPendingQueue: () => dispatch(fetchPendingQueue()),
|
||||
fetchRejectedQueue: () => dispatch(fetchRejectedQueue()),
|
||||
fetchFlaggedQueue: () => dispatch(fetchFlaggedQueue()),
|
||||
showBanUserDialog: (userId, userName, commentId) => dispatch(showBanUserDialog(userId, userName, commentId)),
|
||||
hideBanUserDialog: () => dispatch(hideBanUserDialog(false)),
|
||||
banUser: (userId, commentId) => dispatch(userStatusUpdate('banned', userId, commentId)).then(() => {
|
||||
dispatch(fetchModerationQueueComments());
|
||||
}),
|
||||
updateStatus: (action, comment) => dispatch(updateStatus(action, comment))
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(ModerationContainer);
|
||||
@@ -6,12 +6,32 @@
|
||||
}
|
||||
|
||||
.tabBar {
|
||||
background: #9E9E9E;
|
||||
background: #262626;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
color: white;
|
||||
text-transform: capitalize;
|
||||
font-weight: 500;
|
||||
font-size: 15px;
|
||||
letter-spacing: 1px;
|
||||
transition: border-bottom 200ms;
|
||||
}
|
||||
|
||||
.active {
|
||||
color: white;
|
||||
box-sizing: border-box;
|
||||
border-bottom: solid 5px #F36451;
|
||||
}
|
||||
|
||||
.active > span {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.active:after {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.showShortcuts {
|
||||
|
||||
@@ -1,168 +1,104 @@
|
||||
import React from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import key from 'keymaster';
|
||||
import styles from './ModerationQueue.css';
|
||||
|
||||
import ModerationKeysModal from 'components/ModerationKeysModal';
|
||||
import CommentList from 'components/CommentList';
|
||||
import BanUserDialog from 'components/BanUserDialog';
|
||||
|
||||
import {
|
||||
updateStatus,
|
||||
showBanUserDialog,
|
||||
hideBanUserDialog,
|
||||
fetchModerationQueueComments
|
||||
} from 'actions/comments';
|
||||
import {userStatusUpdate} from 'actions/users';
|
||||
import {fetchSettings} from 'actions/settings';
|
||||
import styles from './ModerationQueue.css';
|
||||
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../../translations.json';
|
||||
|
||||
/*
|
||||
* Renders the moderation queue as a tabbed layout with 3 moderation
|
||||
* queues :
|
||||
* * pending: filtered by status Untouched
|
||||
* * rejected: filtered by status Rejected
|
||||
* * flagged: with a flagged action on them
|
||||
*/
|
||||
|
||||
class ModerationQueue extends React.Component {
|
||||
|
||||
constructor (props) {
|
||||
super(props);
|
||||
|
||||
this.state = {activeTab: 'pending', singleView: false, modalOpen: false};
|
||||
}
|
||||
|
||||
// Fetch comments and bind singleView key before render
|
||||
componentWillMount () {
|
||||
this.props.dispatch(fetchSettings());
|
||||
this.props.dispatch(fetchModerationQueueComments());
|
||||
key('s', () => this.setState({singleView: !this.state.singleView}));
|
||||
key('shift+/', () => this.setState({modalOpen: true}));
|
||||
key('esc', () => this.setState({modalOpen: false}));
|
||||
}
|
||||
|
||||
// Unbind singleView key before unmount
|
||||
componentWillUnmount () {
|
||||
key.unbind('s');
|
||||
key.unbind('shift+/');
|
||||
key.unbind('esc');
|
||||
}
|
||||
|
||||
// Hack for dynamic mdl tabs
|
||||
componentDidMount () {
|
||||
if (typeof componentHandler !== 'undefined') {
|
||||
|
||||
// FIXME: fix this hack
|
||||
componentHandler.upgradeAllRegistered(); // eslint-disable-line no-undef
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch the update status action
|
||||
onCommentAction (action, comment) {
|
||||
|
||||
// If not banning then change the status to approved or flagged as action = status
|
||||
this.props.dispatch(updateStatus(action, comment));
|
||||
}
|
||||
|
||||
showBanUserDialog (userId, userName, commentId) {
|
||||
this.props.dispatch(showBanUserDialog(userId, userName, commentId));
|
||||
}
|
||||
|
||||
hideBanUserDialog () {
|
||||
this.props.dispatch(hideBanUserDialog(false));
|
||||
}
|
||||
|
||||
banUser (userId, commentId) {
|
||||
this.props.dispatch(userStatusUpdate('banned', userId, commentId))
|
||||
.then(() => {
|
||||
this.props.dispatch(fetchModerationQueueComments());
|
||||
});
|
||||
}
|
||||
|
||||
onTabClick (activeTab) {
|
||||
this.setState({activeTab});
|
||||
}
|
||||
|
||||
// Render the tabbed lists moderation queues
|
||||
render () {
|
||||
const {comments, users, settings} = this.props;
|
||||
const {activeTab, singleView, modalOpen} = this.state;
|
||||
|
||||
const premodIds = comments.ids.filter(id => comments.byId[id].status === 'premod');
|
||||
const rejectedIds = comments.ids.filter(id => comments.byId[id].status === 'rejected');
|
||||
const flaggedIds = comments.ids.filter(id => comments.byId[id].flagged === true);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='mdl-tabs mdl-js-tabs mdl-js-ripple-effect'>
|
||||
<div className={`mdl-tabs__tab-bar ${styles.tabBar}`}>
|
||||
<a href='#pending' onClick={() => this.onTabClick('pending')}
|
||||
className={`mdl-tabs__tab ${styles.tab}`}>{lang.t('modqueue.pending')}</a>
|
||||
<a href='#rejected' onClick={() => this.onTabClick('rejected')}
|
||||
className={`mdl-tabs__tab ${styles.tab}`}>{lang.t('modqueue.rejected')}</a>
|
||||
<a href='#flagged' onClick={() => this.onTabClick('flagged')}
|
||||
className={`mdl-tabs__tab ${styles.tab}`}>{lang.t('modqueue.flagged')}</a>
|
||||
</div>
|
||||
<div className={`mdl-tabs__panel is-active ${styles.listContainer}`} id='pending'>
|
||||
<CommentList
|
||||
suspectWords={settings.settings.wordlist.suspect}
|
||||
isActive={activeTab === 'pending'}
|
||||
singleView={singleView}
|
||||
commentIds={premodIds}
|
||||
comments={comments.byId}
|
||||
users={users.byId}
|
||||
onClickAction={(action, comment) => this.onCommentAction(action, comment)}
|
||||
onClickShowBanDialog={(userId, userName, commentId) => this.showBanUserDialog(userId, userName, commentId)}
|
||||
modActions={['reject', 'approve', 'ban']}
|
||||
loading={comments.loading} />
|
||||
<BanUserDialog
|
||||
open={comments.showBanUserDialog}
|
||||
handleClose={() => this.hideBanUserDialog()}
|
||||
onClickBanUser={(userId, commentId) => this.banUser(userId, commentId)}
|
||||
user={comments.banUser}/>
|
||||
</div>
|
||||
<div className={`mdl-tabs__panel ${styles.listContainer}`} id='rejected'>
|
||||
<CommentList
|
||||
suspectWords={settings.settings.wordlist.suspect}
|
||||
isActive={activeTab === 'rejected'}
|
||||
singleView={singleView}
|
||||
commentIds={rejectedIds}
|
||||
comments={comments.byId}
|
||||
users={users.byId}
|
||||
onClickAction={(action, comment) => this.onCommentAction(action, comment)}
|
||||
modActions={['approve']}
|
||||
loading={comments.loading} />
|
||||
</div>
|
||||
<div className={`mdl-tabs__panel ${styles.listContainer}`} id='flagged'>
|
||||
<CommentList
|
||||
isActive={activeTab === 'rejected'}
|
||||
suspectWords={settings.settings.wordlist.suspect}
|
||||
singleView={singleView}
|
||||
commentIds={flaggedIds}
|
||||
comments={comments.byId}
|
||||
users={users.byId}
|
||||
onClickAction={(action, comment) => this.onCommentAction(action, comment)}
|
||||
modActions={['reject', 'approve']}
|
||||
loading={comments.loading} />
|
||||
</div>
|
||||
<ModerationKeysModal open={modalOpen}
|
||||
onClose={() => this.setState({modalOpen: false})} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
actions: state.actions.toJS(),
|
||||
settings: state.settings.toJS(),
|
||||
comments: state.comments.toJS(),
|
||||
users: state.users.toJS()
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(ModerationQueue);
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
export default ({onTabClick, ...props}) => (
|
||||
<div>
|
||||
<div className='mdl-tabs'>
|
||||
<div className={`mdl-tabs__tab-bar ${styles.tabBar}`}>
|
||||
<a href='#pending'
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onTabClick('pending');
|
||||
}}
|
||||
className={`mdl-tabs__tab ${styles.tab} ${props.activeTab === 'pending' ? styles.active : ''}`}
|
||||
>
|
||||
{lang.t('modqueue.pending')}
|
||||
</a>
|
||||
<a href='#rejected'
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onTabClick('rejected');
|
||||
}}
|
||||
className={`mdl-tabs__tab ${styles.tab} ${props.activeTab === 'rejected' ? styles.active : ''}`}
|
||||
>
|
||||
{lang.t('modqueue.rejected')}
|
||||
</a>
|
||||
<a href='#flagged'
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onTabClick('flagged');
|
||||
}}
|
||||
className={`mdl-tabs__tab ${styles.tab} ${props.activeTab === 'flagged' ? styles.active : ''}`}
|
||||
>
|
||||
{lang.t('modqueue.flagged')}
|
||||
</a>
|
||||
</div>
|
||||
<div className={`mdl-tabs__panel is-active ${styles.listContainer}`} id='pending'>
|
||||
{
|
||||
props.activeTab === 'pending'
|
||||
? <div>
|
||||
<CommentList
|
||||
suspectWords={props.settings.settings.wordlist.suspect}
|
||||
isActive={props.activeTab === 'pending'}
|
||||
singleView={props.singleView}
|
||||
commentIds={props.premodIds}
|
||||
comments={props.comments.byId}
|
||||
users={props.users.byId}
|
||||
onClickAction={props.updateStatus}
|
||||
onClickShowBanDialog={props.showBanUserDialog}
|
||||
modActions={['reject', 'approve', 'ban']}
|
||||
loading={props.comments.loading} />
|
||||
<BanUserDialog
|
||||
open={props.comments.showBanUserDialog}
|
||||
handleClose={props.hideBanUserDialog}
|
||||
onClickBanUser={props.banUser}
|
||||
user={props.comments.banUser} />
|
||||
</div>
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
<div className={`mdl-tabs__panel ${styles.listContainer}`} id='rejected'>
|
||||
{
|
||||
props.activeTab === 'rejected'
|
||||
? <CommentList
|
||||
suspectWords={props.settings.settings.wordlist.suspect}
|
||||
isActive={props.activeTab === 'rejected'}
|
||||
singleView={props.singleView}
|
||||
commentIds={props.rejectedIds}
|
||||
comments={props.comments.byId}
|
||||
users={props.users.byId}
|
||||
onClickAction={props.updateStatus}
|
||||
modActions={['approve']}
|
||||
loading={props.comments.loading} />
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
<div className={`mdl-tabs__panel ${styles.listContainer}`} id='flagged'>
|
||||
{
|
||||
props.activeTab === 'flagged'
|
||||
? <CommentList
|
||||
suspectWords={props.settings.settings.wordlist.suspect}
|
||||
isActive={props.activeTab === 'flagged'}
|
||||
singleView={props.singleView}
|
||||
commentIds={props.flaggedIds}
|
||||
comments={props.comments.byId}
|
||||
users={props.users.byId}
|
||||
onClickAction={props.updateStatus}
|
||||
modActions={['reject', 'approve']}
|
||||
loading={props.comments.loading} />
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
<ModerationKeysModal open={props.modalOpen} onClose={props.closeModal} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,11 +4,14 @@
|
||||
}
|
||||
|
||||
.leftColumn {
|
||||
width: 200px;
|
||||
padding: 42px 56px;
|
||||
width: 234px;
|
||||
}
|
||||
|
||||
.mainContent {
|
||||
width: calc(90% - 200px);
|
||||
width: calc(100% - 300px);
|
||||
padding: 34px 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
@@ -18,11 +21,12 @@
|
||||
}
|
||||
|
||||
.searchBox {
|
||||
padding: 3px;
|
||||
width: 100%;
|
||||
padding: 9px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
width: 90%;
|
||||
border-radius: 2px;
|
||||
display: flex;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.searchBoxInput {
|
||||
@@ -48,6 +52,16 @@
|
||||
|
||||
.streamsTable {
|
||||
width: 100%;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
|
||||
th {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
th.status {
|
||||
width: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
.radio {
|
||||
@@ -55,18 +69,20 @@
|
||||
}
|
||||
|
||||
.statusMenu {
|
||||
border-radius: 3px;
|
||||
border-radius: 2px;
|
||||
width: 10em;
|
||||
text-align: center;
|
||||
float: right;
|
||||
border: 1px solid #ccc;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.7px;
|
||||
font-weight: 400;
|
||||
box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12);
|
||||
}
|
||||
|
||||
.statusMenuOpen {
|
||||
padding: 10px;
|
||||
background-color: #4caf50;
|
||||
background-color: #268D81;
|
||||
}
|
||||
|
||||
.statusMenuIcon {
|
||||
@@ -75,9 +91,17 @@
|
||||
|
||||
.statusMenuClosed {
|
||||
padding: 10px;
|
||||
background-color: #000;
|
||||
background-color: #262626;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.radioGroup {
|
||||
margin-top: 5px;
|
||||
span {
|
||||
margin-bottom: 7px;
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,61 +103,63 @@ class Streams extends Component {
|
||||
render () {
|
||||
const {search, sort, filter} = this.state;
|
||||
const {assets} = this.props;
|
||||
|
||||
return <div className={styles.container}>
|
||||
<div className={styles.leftColumn}>
|
||||
|
||||
<div className={styles.searchBox}>
|
||||
<Icon name='search' className={styles.searchIcon}/>
|
||||
<input
|
||||
type='text'
|
||||
value={search}
|
||||
className={styles.searchBoxInput}
|
||||
onChange={this.onSearchChange}
|
||||
placeholder={lang.t('streams.search')}/>
|
||||
</div>
|
||||
|
||||
<div className={styles.optionHeader}>{lang.t('streams.filter-streams')}</div>
|
||||
<div className={styles.optionDetail}>{lang.t('streams.stream-status')}</div>
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.leftColumn}>
|
||||
<div className={styles.searchBox}>
|
||||
<Icon name='search' className={styles.searchIcon}/>
|
||||
<input
|
||||
type='text'
|
||||
value={search}
|
||||
className={styles.searchBoxInput}
|
||||
onChange={this.onSearchChange}
|
||||
placeholder={lang.t('streams.search')}/>
|
||||
</div>
|
||||
<div className={styles.optionHeader}>{lang.t('streams.filter-streams')}</div>
|
||||
<div className={styles.optionDetail}>{lang.t('streams.stream-status')}</div>
|
||||
<RadioGroup
|
||||
name='status filter'
|
||||
value={filter}
|
||||
childContainer='div'
|
||||
onChange={this.onSettingChange('filter')}>
|
||||
onChange={this.onSettingChange('filter')}
|
||||
className={styles.radioGroup}
|
||||
>
|
||||
<Radio value='all'>{lang.t('streams.all')}</Radio>
|
||||
<Radio value='open'>{lang.t('streams.open')}</Radio>
|
||||
<Radio value='closed'>{lang.t('streams.closed')}</Radio>
|
||||
</RadioGroup>
|
||||
<div className={styles.optionHeader}>{lang.t('streams.sort-by')}</div>
|
||||
<RadioGroup
|
||||
name='sort by'
|
||||
value={sort}
|
||||
childContainer='div'
|
||||
onChange={this.onSettingChange('sort')}>
|
||||
<Radio value='desc'>{lang.t('streams.newest')}</Radio>
|
||||
<Radio value='asc'>{lang.t('streams.oldest')}</Radio>
|
||||
</RadioGroup>
|
||||
<RadioGroup
|
||||
name='sort by'
|
||||
value={sort}
|
||||
childContainer='div'
|
||||
onChange={this.onSettingChange('sort')}
|
||||
className={styles.radioGroup}
|
||||
>
|
||||
<Radio value='desc'>{lang.t('streams.newest')}</Radio>
|
||||
<Radio value='asc'>{lang.t('streams.oldest')}</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<div className={styles.mainContent}>
|
||||
<DataTable
|
||||
className={styles.streamsTable}
|
||||
rows={assets.ids.map((id) => assets.byId[id])}>
|
||||
<TableHeader name="title">{lang.t('streams.article')}</TableHeader>
|
||||
<TableHeader name="publication_date" cellFormatter={this.renderDate}>
|
||||
{lang.t('streams.pubdate')}
|
||||
</TableHeader>
|
||||
<TableHeader name="closedAt" cellFormatter={this.renderStatus} className={styles.status}>
|
||||
{lang.t('streams.status')}
|
||||
</TableHeader>
|
||||
</DataTable>
|
||||
<Pager
|
||||
totalPages={Math.ceil((assets.count || 0) / limit)}
|
||||
page={this.state.page}
|
||||
onNewPageHandler={this.onPageClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.mainContent}>
|
||||
<DataTable
|
||||
className={styles.streamsTable}
|
||||
rows={assets.ids.map((id) => assets.byId[id])}>
|
||||
<TableHeader name="title">{lang.t('streams.article')}</TableHeader>
|
||||
<TableHeader numeric name="publication_date" cellFormatter={this.renderDate}>
|
||||
{lang.t('streams.pubdate')}
|
||||
</TableHeader>
|
||||
<TableHeader numeric name="closedAt" cellFormatter={this.renderStatus}>
|
||||
{lang.t('streams.status')}
|
||||
</TableHeader>
|
||||
</DataTable>
|
||||
<Pager
|
||||
totalPages={Math.ceil((assets.count || 0) / limit)}
|
||||
page={this.state.page}
|
||||
onNewPageHandler={this.onPageClick}
|
||||
/>
|
||||
</div>
|
||||
</div>;
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ const updateSettings = (state, action) => {
|
||||
|
||||
// any nested settings must have a specialized setter
|
||||
const updateWordlist = (state, action) => {
|
||||
return state.setIn(['settings', 'wordlist', action.listName], action.wordlist);
|
||||
return state.setIn(['settings', 'wordlist', action.listName], action.list);
|
||||
};
|
||||
|
||||
const saveComplete = (state, action) => {
|
||||
|
||||
@@ -46,13 +46,13 @@
|
||||
"include-comment-stream": "Include Comment Stream Description for Readers.",
|
||||
"include-comment-stream-desc": "Write a message to be added to the top of your comment stream. Pose a topic, include community guidelines, etc.",
|
||||
"include-text": "Include your text here.",
|
||||
"comment-settings": "Comment Settings",
|
||||
"embed-comment-stream": "Embed Comment Stream",
|
||||
"banned-word-header": "Write the bannned words list",
|
||||
"comment-settings": "Settings",
|
||||
"embed-comment-stream": "Embed Stream",
|
||||
"banned-word-header": "Write the banned words list",
|
||||
"suspect-word-header": "Write the suspect words list",
|
||||
"banned-word-text": "Comments which contain these words or phrases (not case-sensitive) will be automatically removed from the comment stream. Type a word and press Enter or Tab to add. Optionally paste a comma-separated list.",
|
||||
"suspect-word-text": "Comments which contain these words or phrases (not case-sensitive) will be highlighted in the comment stream. Type a word and press Enter or Tab to add. Optionally paste a comma-separated list.",
|
||||
"wordlist": "Banned & Suspect Words",
|
||||
"wordlist": "Banned Words",
|
||||
"banned-words-title": "Banned words list",
|
||||
"suspect-words-title": "Suspect words list",
|
||||
"save-changes": "Save Changes",
|
||||
@@ -146,6 +146,7 @@
|
||||
"moderate": "Moderar",
|
||||
"configure": "Configurar",
|
||||
"community": "Comunidad",
|
||||
"streams": "Streams",
|
||||
"closed-comments-desc": "Escribe un mensaje para cuando los comentarios se encuentran cerrados",
|
||||
"closed-comments-label": "Escribe un mensaje...",
|
||||
"never": "Nunca",
|
||||
|
||||
@@ -138,7 +138,7 @@ class CommentStream extends Component {
|
||||
</div>
|
||||
: <p>{closedMessage}</p>
|
||||
}
|
||||
{!loggedIn && <SignInContainer offset={signInOffset} />}
|
||||
{!loggedIn && <SignInContainer offset={signInOffset}/>}
|
||||
{
|
||||
rootItem.comments && rootItem.comments.map((commentId) => {
|
||||
const comment = comments[commentId];
|
||||
|
||||
@@ -23,7 +23,7 @@ const signInRequest = () => ({type: actions.FETCH_SIGNIN_REQUEST});
|
||||
const signInSuccess = (user, isAdmin) => ({type: actions.FETCH_SIGNIN_SUCCESS, user, isAdmin});
|
||||
const signInFailure = error => ({type: actions.FETCH_SIGNIN_FAILURE, error});
|
||||
|
||||
export const fetchSignIn = (formData) => dispatch => {
|
||||
export const fetchSignIn = (formData) => (dispatch) => {
|
||||
dispatch(signInRequest());
|
||||
coralApi('/auth/local', {method: 'POST', body: formData})
|
||||
.then(({user}) => {
|
||||
@@ -72,8 +72,9 @@ 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 => {
|
||||
export const fetchSignUp = formData => (dispatch) => {
|
||||
dispatch(signUpRequest());
|
||||
|
||||
coralApi('/users', {method: 'POST', body: formData})
|
||||
.then(({user}) => {
|
||||
dispatch(signUpSuccess(user));
|
||||
@@ -81,7 +82,9 @@ export const fetchSignUp = formData => dispatch => {
|
||||
dispatch(changeView('SIGNIN'));
|
||||
}, 3000);
|
||||
})
|
||||
.catch(() => dispatch(signUpFailure(lang.t('error.emailInUse')))); // We need to inprove error handling. TODO (bc)
|
||||
.catch(error => {
|
||||
dispatch(signUpFailure(lang.t(`error.${error.message}`)));
|
||||
});
|
||||
};
|
||||
|
||||
// Forgot Password Actions
|
||||
@@ -90,9 +93,9 @@ const forgotPassowordRequest = () => ({type: actions.FETCH_FORGOT_PASSWORD_REQUE
|
||||
const forgotPassowordSuccess = () => ({type: actions.FETCH_FORGOT_PASSWORD_SUCCESS});
|
||||
const forgotPassowordFailure = () => ({type: actions.FETCH_FORGOT_PASSWORD_FAILURE});
|
||||
|
||||
export const fetchForgotPassword = email => dispatch => {
|
||||
export const fetchForgotPassword = email => (dispatch) => {
|
||||
dispatch(forgotPassowordRequest(email));
|
||||
coralApi('/users/request-password-reset', {method: 'POST', body: {email}})
|
||||
coralApi('/account/password/reset', {method: 'POST', body: {email}})
|
||||
.then(() => dispatch(forgotPassowordSuccess()))
|
||||
.catch(error => dispatch(forgotPassowordFailure(error)));
|
||||
};
|
||||
@@ -124,13 +127,13 @@ const checkLoginFailure = error => ({type: actions.CHECK_LOGIN_FAILURE, error});
|
||||
export const checkLogin = () => dispatch => {
|
||||
dispatch(checkLoginRequest());
|
||||
coralApi('/auth')
|
||||
.then(user => {
|
||||
if (!user) {
|
||||
.then((result) => {
|
||||
if (!result.user) {
|
||||
throw new Error('Not logged in');
|
||||
}
|
||||
|
||||
const isAdmin = !!user.roles.filter(i => i === 'admin').length;
|
||||
dispatch(checkLoginSuccess(user, isAdmin));
|
||||
const isAdmin = !!result.user.roles.filter(i => i === 'admin').length;
|
||||
dispatch(checkLoginSuccess(result.user, isAdmin));
|
||||
})
|
||||
.catch(error => dispatch(checkLoginFailure(error)));
|
||||
};
|
||||
|
||||
@@ -220,8 +220,12 @@ export function postItem (item, type, id) {
|
||||
*/
|
||||
|
||||
export function postAction (item_id, item_type, action) {
|
||||
return () => {
|
||||
return coralApi(`/${item_type}/${item_id}/actions`, {method: 'POST', body: action});
|
||||
return (dispatch) => {
|
||||
return coralApi(`/${item_type}/${item_id}/actions`, {method: 'POST', body: action})
|
||||
.then((json) => {
|
||||
dispatch(updateItem(action.item_id, action.action_type, action.id, item_type));
|
||||
return json;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,10 +14,10 @@ const saveBioFailure = error => ({type: actions.SAVE_BIO_FAILURE, error});
|
||||
|
||||
export const saveBio = (user_id, formData) => dispatch => {
|
||||
dispatch(saveBioRequest());
|
||||
coralApi(`/users/${user_id}/bio`, {method: 'PUT', body: formData})
|
||||
.then(({settings}) => {
|
||||
coralApi('/account/settings', {method: 'PUT', body: formData})
|
||||
.then(() => {
|
||||
dispatch(addNotification('success', lang.t('successBioUpdate')));
|
||||
dispatch(saveBioSuccess(settings));
|
||||
dispatch(saveBioSuccess(formData));
|
||||
})
|
||||
.catch(error => dispatch(saveBioFailure(error)));
|
||||
};
|
||||
@@ -30,21 +30,27 @@ export const saveBio = (user_id, formData) => dispatch => {
|
||||
* @returns Promise
|
||||
*/
|
||||
export const fetchCommentsByUserId = userId => {
|
||||
return (dispatch) => {
|
||||
return (dispatch, getState) => {
|
||||
dispatch({type: actions.COMMENTS_BY_USER_REQUEST});
|
||||
return coralApi(`/comments?user_id=${userId}`)
|
||||
.then(({comments, assets}) => {
|
||||
const state = getState();
|
||||
comments.forEach(comment => dispatch(addItem(comment, 'comments')));
|
||||
assets.forEach(asset => {
|
||||
const prevAsset = state.items.getIn(['assets', asset.id]);
|
||||
|
||||
assets.forEach(asset => dispatch(addItem(asset, 'assets')));
|
||||
if (prevAsset) {
|
||||
|
||||
// Include data such as hydrated comments from assets already in the system.
|
||||
dispatch(addItem({...prevAsset.toJS(), ...asset}, 'assets'));
|
||||
} else {
|
||||
dispatch(addItem(asset, 'assets'));
|
||||
}
|
||||
});
|
||||
|
||||
dispatch({type: actions.COMMENTS_BY_USER_SUCCESS, comments: comments.map(comment => comment.id)});
|
||||
dispatch({type: assetActions.MULTIPLE_ASSETS_SUCCESS, assets: assets.map(asset => asset.id)});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error.stack);
|
||||
console.error('FAILURE_COMMENTS_BY_USER', error);
|
||||
dispatch({type: actions.COMMENTS_BY_USER_FAILURE, error});
|
||||
});
|
||||
.catch(error => dispatch({type: actions.COMMENTS_BY_USER_FAILURE, error}));
|
||||
};
|
||||
};
|
||||
|
||||
@@ -31,3 +31,4 @@ export const CHECK_LOGIN_REQUEST = 'CHECK_LOGIN_REQUEST';
|
||||
export const CHECK_LOGIN_SUCCESS = 'CHECK_LOGIN_SUCCESS';
|
||||
export const CHECK_LOGIN_FAILURE = 'CHECK_LOGIN_FAILURE';
|
||||
|
||||
export const CHECK_CSRF_TOKEN = 'CHECK_CSRF_TOKEN';
|
||||
|
||||
@@ -4,3 +4,4 @@ export const SAVE_BIO_FAILURE = 'SAVE_BIO_FAILURE';
|
||||
export const COMMENTS_BY_USER_REQUEST = 'COMMENTS_BY_USER_REQUEST';
|
||||
export const COMMENTS_BY_USER_SUCCESS = 'COMMENTS_BY_USER_SUCCESS';
|
||||
export const COMMENTS_BY_USER_FAILURE = 'COMMENTS_BY_USER_FAILURE';
|
||||
export const LOGOUT_SUCCESS = 'LOGOUT_SUCCESS';
|
||||
|
||||
@@ -2,16 +2,30 @@ export const base = '/api/v1';
|
||||
|
||||
const buildOptions = (inputOptions = {}) => {
|
||||
|
||||
const csurfDOM = document.head.querySelector('[property=csrf]');
|
||||
|
||||
const defaultOptions = {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
credentials: 'same-origin',
|
||||
_csrf: csurfDOM ? csurfDOM.content : false
|
||||
};
|
||||
|
||||
const options = Object.assign({}, defaultOptions, inputOptions);
|
||||
|
||||
if (options._csrf) {
|
||||
switch (options.method.toLowerCase()) {
|
||||
case 'post':
|
||||
case 'put':
|
||||
case 'delete':
|
||||
options.headers['x-csrf-token'] = options._csrf;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (options.method.toLowerCase() !== 'get') {
|
||||
options.body = JSON.stringify(options.body);
|
||||
}
|
||||
@@ -23,7 +37,13 @@ const handleResp = res => {
|
||||
if (res.status === 401) {
|
||||
throw new Error('Not Authorized to make this request');
|
||||
} else if (res.status > 399) {
|
||||
throw new Error('Error! Status ', res.status);
|
||||
return res.json().then(err => {
|
||||
let message = err.message || res.status;
|
||||
if (err.error && err.error.translation_key) {
|
||||
message = err.error.translation_key;
|
||||
}
|
||||
throw new Error(message);
|
||||
});
|
||||
} else if (res.status === 204) {
|
||||
return res.text();
|
||||
} else {
|
||||
|
||||
@@ -2,5 +2,5 @@ export default {
|
||||
email: email => (/^([A-Za-z0-9_\-\.])+\@([A-Za-z0-9_\-\.])+\.([A-Za-z]{2,4})$/.test(email)),
|
||||
password: pass => (/^(?=.{8,}).*$/.test(pass)),
|
||||
confirmPassword: () => true,
|
||||
displayName: displayName => (/^(?=.{3,}).*$/.test(displayName))
|
||||
displayName: displayName => (/^[a-zA-Z0-9_]+$/.test(displayName))
|
||||
};
|
||||
|
||||
@@ -41,6 +41,9 @@ export default function auth (state = initialState, action) {
|
||||
.set('view', action.view);
|
||||
case actions.CLEAN_STATE:
|
||||
return initialState;
|
||||
case actions.CHECK_CSRF_TOKEN:
|
||||
return state
|
||||
.set('_csrf', action._csrf);
|
||||
case actions.FETCH_SIGNIN_REQUEST:
|
||||
return state
|
||||
.set('isLoading', true);
|
||||
|
||||
@@ -31,12 +31,13 @@ export default function user (state = initialState, action) {
|
||||
case authActions.FETCH_SIGNIN_FACEBOOK_FAILURE:
|
||||
return initialState;
|
||||
case actions.SAVE_BIO_SUCCESS:
|
||||
return state
|
||||
.set('settings', action.settings);
|
||||
return state.set('settings', action.settings);
|
||||
case actions.COMMENTS_BY_USER_SUCCESS:
|
||||
return state.set('myComments', action.comments);
|
||||
case assetActions.MULTIPLE_ASSETS_SUCCESS:
|
||||
return state.set('myAssets', action.assets);
|
||||
case actions.LOGOUT_SUCCESS:
|
||||
return initialState;
|
||||
default :
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -7,10 +7,18 @@
|
||||
"error": {
|
||||
"email": "Not a valid E-Mail",
|
||||
"password": "Password must be at least 8 characters",
|
||||
"displayName": "Display name is too short",
|
||||
"confirmPassword": "Passwords don`t match. Please, check again",
|
||||
"displayName": "Display names can contain letters, numbers and _ only",
|
||||
"confirmPassword": "Passwords don't match. Please, check again",
|
||||
"emailPasswordError": "Email and/or password combination incorrect.",
|
||||
"emailInUse": "Email address already in use"
|
||||
"EMAIL_REQUIRED": "An email address is required",
|
||||
"PASSWORD_REQUIRED": "Must input a password",
|
||||
"PASSWORD_LENGTH": "Password is too short",
|
||||
"EMAIL_IN_USE": "Email address already in use",
|
||||
"EMAIL_DISPLAY_NAME_IN_USE": "Email address or display name already in use",
|
||||
"DISPLAYNAME_IN_USE": "Display name already in use",
|
||||
"DISPLAY_NAME_REQUIRED": "Must input a display name",
|
||||
"NO_SPECIAL_CHARACTERS": "Display names can contain letters, numbers and _ only",
|
||||
"PROFANITY_ERROR": "Display names must not contain profanity. Please contact the administrator if you believe this to be in error."
|
||||
}
|
||||
},
|
||||
"es": {
|
||||
@@ -21,10 +29,18 @@
|
||||
"error": {
|
||||
"email": "No es un email válido",
|
||||
"password": "La contraseña debe tener por lo menos 8 caracteres",
|
||||
"displayName": "El nombre es muy corto",
|
||||
"displayName": "Los nombres pueden contener letras, números y _",
|
||||
"confirmPassword": "Las contraseñas no coinciden",
|
||||
"emailPasswordError": "Email y/o contraseña incorrecta.",
|
||||
"emailInUse": "Email address already in use"
|
||||
"EMAIL_REQUIRED": "Se requiere una dirección de correo electrónico",
|
||||
"PASSWORD_REQUIRED": "Debe ingresar una contraseña",
|
||||
"PASSWORD_LENGTH": "La contraseña es muy corta",
|
||||
"EMAIL_IN_USE": "La dirección de correo electrónico se encuentra en uso",
|
||||
"EMAIL_DISPLAY_NAME_IN_USE": "Correo o Nombre en uso.",
|
||||
"DISPLAYNAME_IN_USE": "Nombre en uso.",
|
||||
"DISPLAY_NAME_REQUIRED": "Debe ingresar un nombre",
|
||||
"NO_SPECIAL_CHARACTERS": "Los nombres pueden contener letras, números y _",
|
||||
"PROFANITY_ERROR": "Los nombres no pueden contener blasfemias. Por favor contacte al administrador si cree que esto es un error"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ class FlagButton extends Component {
|
||||
case 'comments':
|
||||
item_id = id;
|
||||
break;
|
||||
case 'user':
|
||||
case 'users':
|
||||
item_id = author_id;
|
||||
break;
|
||||
}
|
||||
@@ -72,7 +72,7 @@ class FlagButton extends Component {
|
||||
onPopupOptionClick = (sets) => (e) => {
|
||||
|
||||
// If flagging a user, indicate that this is referencing the username rather than the bio
|
||||
if(sets === 'itemType' && e.target.value === 'user') {
|
||||
if(sets === 'itemType' && e.target.value === 'users') {
|
||||
this.setState({field: 'username'});
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ class FlagButton extends Component {
|
||||
const popupMenu = getPopupMenu[this.state.step](this.state.itemType);
|
||||
|
||||
return <div className={`${name}-container`}>
|
||||
<button onClick={this.onReportClick} className={`${name}-button`}>
|
||||
<button onClick={!this.props.banned ? this.onReportClick : null} className={`${name}-button`}>
|
||||
{
|
||||
flagged
|
||||
? <span className={`${name}-button-text`}>{lang.t('reported')}</span>
|
||||
|
||||
@@ -10,7 +10,7 @@ const getPopupMenu = [
|
||||
return {
|
||||
header: lang.t('step-1-header'),
|
||||
options: [
|
||||
{val: 'user', text: lang.t('flag-username')},
|
||||
{val: 'users', text: lang.t('flag-username')},
|
||||
{val: 'comments', text: lang.t('flag-comment')}
|
||||
],
|
||||
button: lang.t('continue'),
|
||||
|
||||
@@ -10,7 +10,11 @@ import CommentHistory from 'coral-plugin-history/CommentHistory';
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
import RestrictedContent from 'coral-framework/components/RestrictedContent';
|
||||
|
||||
class SignInContainer extends Component {
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../translations';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
class SettingsContainer extends Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
@@ -59,7 +63,7 @@ class SignInContainer extends Component {
|
||||
? <CommentHistory
|
||||
comments={commentsMostRecentFirst}
|
||||
assets={user.myAssets.map(id => items.assets[id])} />
|
||||
: <p>Loading comment history...</p>
|
||||
: <p>{lang.t('user-no-comment')}</p>
|
||||
}
|
||||
</TabContent>
|
||||
<TabContent show={activeTab === 1}>
|
||||
@@ -83,4 +87,4 @@ const mapDispatchToProps = dispatch => ({
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(SignInContainer);
|
||||
)(SettingsContainer);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"en":{
|
||||
"user-no-comment": "This user has not yet left a comment."
|
||||
},
|
||||
"es":{
|
||||
"user-no-comment": "Aún no ha escrito ningún comentario."
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import {Dialog} from 'coral-ui';
|
||||
import styles from './styles.css';
|
||||
|
||||
import SignInContent from './SignInContent';
|
||||
import SingUpContent from './SignUpContent';
|
||||
import SignUpContent from './SignUpContent';
|
||||
import ForgotContent from './ForgotContent';
|
||||
|
||||
const SignDialog = ({open, view, handleClose, offset, ...props}) => (
|
||||
@@ -17,7 +17,7 @@ const SignDialog = ({open, view, handleClose, offset, ...props}) => (
|
||||
}}>
|
||||
<span className={styles.close} onClick={handleClose}>×</span>
|
||||
{view === 'SIGNIN' && <SignInContent {...props} />}
|
||||
{view === 'SIGNUP' && <SingUpContent {...props} />}
|
||||
{view === 'SIGNUP' && <SignUpContent {...props} />}
|
||||
{view === 'FORGOT' && <ForgotContent {...props} />}
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -56,7 +56,7 @@ const SignUpContent = ({handleChange, formData, ...props}) => (
|
||||
onChange={handleChange}
|
||||
minLength="8"
|
||||
/>
|
||||
{ !props.errors.password && <span className={styles.hint}> Password must be at least 8 characters. </span> }
|
||||
{ props.errors.password && <span className={styles.hint}> Password must be at least 8 characters. </span> }
|
||||
<FormField
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
|
||||
@@ -19,8 +19,10 @@ export default {
|
||||
alreadyHaveAnAccount: 'Already have an account?',
|
||||
recoverPassword: 'Recover password',
|
||||
emailInUse: 'Email address already in use',
|
||||
emailORusernameInUse: 'Email address or Username already in use',
|
||||
requiredField: 'This field is required',
|
||||
passwordsDontMatch: 'Passwords don\'t match.',
|
||||
specialCharacters: 'Display names can contain letters, numbers and _ only',
|
||||
checkTheForm: 'Invalid Form. Please, check the fields'
|
||||
}
|
||||
},
|
||||
@@ -44,8 +46,10 @@ export default {
|
||||
alreadyHaveAnAccount: 'Ya tienes una cuenta?',
|
||||
recoverPassword: 'Recuperar contraseña',
|
||||
emailInUse: 'Este email se encuentra en uso',
|
||||
emailORusernameInUse: 'Este email ó nombre se encuentran en uso',
|
||||
requiredField: 'Este campo es requerido',
|
||||
passwordsDontMatch: 'Las contraseñas no coinciden',
|
||||
specialCharacters: 'Los nombres pueden contener letras, números y _',
|
||||
checkTheForm: 'Formulario Inválido. Por favor, completa los campos'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,6 @@
|
||||
display: inline-block;
|
||||
font-family: 'Roboto','Helvetica','Arial',sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0;
|
||||
overflow: hidden;
|
||||
will-change: box-shadow,transform;
|
||||
-webkit-transition: box-shadow .2s cubic-bezier(.4,0,1,1),background-color .2s cubic-bezier(.4,0,.2,1),color .2s cubic-bezier(.4,0,.2,1);
|
||||
@@ -24,6 +22,14 @@
|
||||
line-height: 28px;
|
||||
vertical-align: middle;
|
||||
margin: 2px;
|
||||
letter-spacing: 0.7px;
|
||||
font-weight: 400;
|
||||
|
||||
i {
|
||||
margin-right: 13px;
|
||||
font-size: 18px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.type--black {
|
||||
@@ -69,7 +75,7 @@
|
||||
|
||||
.type--darkGrey {
|
||||
color: white;
|
||||
background: #696969;
|
||||
background: #616161;
|
||||
}
|
||||
|
||||
.type--darkGrey:hover {
|
||||
@@ -104,6 +110,5 @@
|
||||
}
|
||||
|
||||
.raised {
|
||||
background: rgba(158,158,158,.2);
|
||||
box-shadow: 0 2px 2px 0 rgba(0,0,0,.14),0 3px 1px -2px rgba(0,0,0,.2),0 1px 5px 0 rgba(0,0,0,.12);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import React from 'react';
|
||||
import styles from './Button.css';
|
||||
import Icon from './Icon';
|
||||
|
||||
const Button = ({cStyle = 'local', children, className, raised, full, ...props}) => (
|
||||
const Button = ({cStyle = 'local', children, className, raised = false, full = false, icon = '', ...props}) => (
|
||||
<button
|
||||
className={`
|
||||
${styles.button}
|
||||
${styles[`type--${cStyle}`]}
|
||||
${className}
|
||||
${full && styles.full}
|
||||
${raised && styles.button}
|
||||
${full ? styles.full : ''}
|
||||
${raised ? styles.raised : ''}
|
||||
`}
|
||||
{...props}
|
||||
>
|
||||
{icon && <Icon name={icon} />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
.base {
|
||||
display: -webkit-flex;
|
||||
display: -ms-flexbox;
|
||||
display: flex;
|
||||
-webkit-flex-direction: column;
|
||||
-ms-flex-direction: column;
|
||||
flex-direction: column;
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
min-height: 200px;
|
||||
overflow: hidden;
|
||||
width: 330px;
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
background: #fff;
|
||||
border-radius: 2px;
|
||||
box-sizing: border-box;
|
||||
|
||||
width: 100%;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.shadow--4 {
|
||||
box-shadow: 0 4px 5px 0 rgba(0,0,0,.14), 0 1px 10px 0 rgba(0,0,0,.12), 0 2px 4px -1px rgba(0,0,0,.2);
|
||||
}
|
||||
|
||||
.shadow--2{
|
||||
box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12);
|
||||
}
|
||||
|
||||
.shadow--3 {
|
||||
box-shadow: 0 3px 4px 0 rgba(0,0,0,.14), 0 3px 3px -2px rgba(0,0,0,.2), 0 1px 8px 0 rgba(0,0,0,.12);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
import styles from './Card.css';
|
||||
|
||||
export default ({children, className, shadow = 2, ...props}) => (
|
||||
<div className={`${styles.base} ${className} ${styles[`shadow--${shadow}`]}`} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
.base {
|
||||
height: 30px;
|
||||
width : 30px;
|
||||
}
|
||||
|
||||
.mark {
|
||||
stroke: #FFFFFF;
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import styles from './CoralLogo.css';
|
||||
|
||||
const CoralLogo = ({height = '30px', width = '30px', stroke = '#FFFFFF'}) => (
|
||||
<svg width={width} height={height} viewBox='0 0 381 391' version='1.1 xmlns=http://www.w3.org/2000/svg xmlns:xlink=http://www.w3.org/1999/xlink'>
|
||||
const CoralLogo = ({className = ''}) => (
|
||||
<svg className={`${styles.base} ${className}`} viewBox='0 0 381 391' version='1.1 xmlns=http://www.w3.org/2000/svg xmlns:xlink=http://www.w3.org/1999/xlink'>
|
||||
<g stroke='none' strokeWidth='1' fill='none' fillRule='evenodd'>
|
||||
<g id='Wordmark-Round' transform='translate(-1833.000000, -411.000000)' stroke={stroke} strokeWidth='22' strokeLinecap='round' strokeLinejoin='round'>
|
||||
<g id='Wordmark-Round' className={styles.mark} transform='translate(-1833.000000, -411.000000)' strokeWidth='22' strokeLinecap='round' strokeLinejoin='round'>
|
||||
<g id='coralProjectLogo-2-Copy-2' transform='translate(1842.000000, 421.000000)'>
|
||||
<g id='Layer_2' transform='translate(2.268750, 1.133903)'>
|
||||
<rect id='Rectangle-1' fill='#F47E6B' x='0' y='0' width='358.4625' height='368.518519' rx='40'>
|
||||
|
||||
@@ -42,7 +42,9 @@ export default class Dialog extends Component {
|
||||
|
||||
componentWillUnmount() {
|
||||
const dialog = this.dialog;
|
||||
dialog.removeEventListener('cancel', this.props.onCancel);
|
||||
if (dialog) {
|
||||
dialog.removeEventListener('cancel', this.props.onCancel);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
.base {
|
||||
background: red;
|
||||
i {
|
||||
font-size: 30px;
|
||||
transform: translate(-14px,-12px) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.type--approve {
|
||||
background: #00796b;
|
||||
color: rgba(255, 255, 255, 0.901961);
|
||||
background: #388E3C;
|
||||
color: rgba(255, 255, 255, 0.901961);
|
||||
}
|
||||
|
||||
.type--approve:hover {
|
||||
background: #40a244;
|
||||
}
|
||||
|
||||
.type--reject {
|
||||
background: #d32f2f ;
|
||||
color: rgba(255, 255, 255, 0.901961);
|
||||
background: #D32F2F ;
|
||||
color: rgba(255, 255, 255, 0.901961);
|
||||
}
|
||||
|
||||
.type--reject:hover {
|
||||
background: #e53333;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import styles from './FabButton.css';
|
||||
import {FABButton, Icon} from 'react-mdl';
|
||||
|
||||
const FabButton = ({cStyle = 'local', icon, className, ...props}) => (
|
||||
<FABButton className={`${styles[`type--${cStyle}`]} ${className ? className : ''}`} {...props}>
|
||||
<FABButton className={`${styles.base} ${styles[`type--${cStyle}`]} ${className ? className : ''}`} {...props}>
|
||||
<Icon name={icon} />
|
||||
</FABButton>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
import {Icon as IconMDL} from 'react-mdl';
|
||||
|
||||
const Icon = ({className, name}) => (
|
||||
<IconMDL className={className} name={name} />
|
||||
);
|
||||
|
||||
export default Icon;
|
||||
@@ -0,0 +1,24 @@
|
||||
.base {
|
||||
box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12);
|
||||
background-color: white;
|
||||
width: 208px;
|
||||
padding: 10px 14px;
|
||||
font-size: 14px;
|
||||
margin-bottom: 10px;
|
||||
list-style: none;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: white;
|
||||
background-color: #262626;
|
||||
}
|
||||
|
||||
i {
|
||||
margin-right: 13px;
|
||||
font-size: 18px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import styles from './Item.css';
|
||||
import Icon from './Icon';
|
||||
|
||||
export default ({children, itemId, active, onItemClick, className = '', icon}) => (
|
||||
<li
|
||||
className={`${styles.base} ${className} ${active ? styles.active : ''}`}
|
||||
onClick={() => onItemClick(itemId)}
|
||||
>
|
||||
{icon && <Icon name={icon} />}
|
||||
{children}
|
||||
</li>
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
.base {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import React, {Component} from 'react';
|
||||
import styles from './List.css';
|
||||
|
||||
export default class List extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleClickItem = this.handleClickItem.bind(this);
|
||||
}
|
||||
|
||||
handleClickItem(itemId) {
|
||||
if (this.props.onChange) {
|
||||
this.props.onChange(itemId);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {children, activeItem, className = ''} = this.props;
|
||||
return (
|
||||
<ul className={`${styles.base} ${className}`}>
|
||||
{React.Children.toArray(children)
|
||||
.filter(child => !child.props.restricted)
|
||||
.map((child, i) =>
|
||||
React.cloneElement(child, {
|
||||
i,
|
||||
active: child.props.itemId === activeItem,
|
||||
onItemClick: this.handleClickItem,
|
||||
})
|
||||
)}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,19 @@
|
||||
.li {
|
||||
.pager {
|
||||
text-align: center;
|
||||
|
||||
li {
|
||||
display: inline-block;
|
||||
margin-right: 5px;
|
||||
padding: 0;
|
||||
min-width: 30px;
|
||||
color: white;
|
||||
height: 30px;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
line-height: 30px;
|
||||
width: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.current {
|
||||
background: #e3edf3;
|
||||
}
|
||||
background: #696969;
|
||||
box-shadow: 0 2px 2px 0 rgba(0,0,0,.14), 0 3px 1px -2px rgba(0,0,0,.2), 0 1px 5px 0 rgba(0,0,0,.12);
|
||||
}
|
||||
|
||||
@@ -2,19 +2,18 @@ import React, {PropTypes} from 'react';
|
||||
import styles from './Pager.css';
|
||||
|
||||
const Rows = (curr, total, onClickHandler) => Array.from(Array(total)).map((e, i) =>
|
||||
<li className={`mdl-button mdl-js-button ${styles.li} ${curr === i ? styles.current : ''}`}
|
||||
<li className={curr === i ? styles.current : ''}
|
||||
key={i} onClick={() => onClickHandler(i + 1)}>
|
||||
{i + 1}
|
||||
</li>
|
||||
);
|
||||
|
||||
const Pager = ({totalPages, page, onNewPageHandler}) => (
|
||||
<div className="pager">
|
||||
<div className={styles.pager}>
|
||||
<ul>
|
||||
{
|
||||
(totalPages > page && totalPages > 1) ?
|
||||
<li
|
||||
className={`mdl-button mdl-js-button ${styles.li}`}
|
||||
onClick={() => onNewPageHandler(page - 1)}>
|
||||
Prev
|
||||
</li>
|
||||
@@ -25,7 +24,6 @@ const Pager = ({totalPages, page, onNewPageHandler}) => (
|
||||
{
|
||||
(page < totalPages && totalPages > 1) ?
|
||||
<li
|
||||
className={`mdl-button mdl-js-button ${styles.li}`}
|
||||
onClick={() => onNewPageHandler(page + 1)}>
|
||||
Next
|
||||
</li>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import styles from './TabBar.css';
|
||||
|
||||
export class TabBar extends React.Component {
|
||||
class TabBar extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleClickTab = this.handleClickTab.bind(this);
|
||||
|
||||
@@ -9,3 +9,8 @@ export {default as Spinner} from './components/Spinner';
|
||||
export {default as Tooltip} from './components/Tooltip';
|
||||
export {default as PopupMenu} from './components/PopupMenu';
|
||||
export {default as Checkbox} from './components/Checkbox';
|
||||
export {default as Icon} from './components/Icon';
|
||||
export {default as List} from './components/List';
|
||||
export {default as Item} from './components/Item';
|
||||
export {default as Card} from './components/Card';
|
||||
export {default as Pager} from './components/Pager';
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Debug
|
||||
How we debug errors at Coral
|
||||
|
||||
## React Debugging
|
||||
For debugging React
|
||||
|
||||
### React Developer Tools
|
||||
Another amazing tool for debugging React Applications. You can see where the props are, and much more.
|
||||
|
||||
[React Developer Tools Extension](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi?hl=en)
|
||||
|
||||
|
||||
## Redux Debugging
|
||||
For debugging Redux
|
||||
|
||||
### Redux Devtool Extension
|
||||
Redux Devtool is an amazing debug tool. You can easily see what' happening with the state, the payloads, and more.
|
||||
|
||||
[Redux Devtool Chrome Extension](https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd?hl=en)
|
||||
|
||||
[Redux Devtool Github Repo](https://github.com/zalmoxisus/redux-devtools-extension)
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
# InmutableJS
|
||||
InmutableJS is a library from Facebook that provides a series of inmutable data structures. They are always immutable. The reference to them can change but the data inside of them cannot which means you can build predictable and reliable state models.
|
||||
We use ImmutableJS in Talk and it becomes really easy to manage Talk’s application state. [Immutable.js](https://facebook.github.io/immutable-js/)
|
||||
|
||||
More about Immutable Data and React:
|
||||
[React.js Conf 2015 - Immutable Data and React - YouTube](https://www.youtube.com/watch?v=I7IdS-PbEgI&feature=youtu.be)
|
||||
|
||||
## Why ImmutableJS?
|
||||
- __Immutable Data is faster__
|
||||
* Tracking mutation and Maintaining state is difficult
|
||||
* Encourages you to think differently about how data flows through your application
|
||||
|
||||
## Getting Started
|
||||
ImmutableJS API is pretty expense. We will try to cover the basics and more to show its power.
|
||||
|
||||
ImmutableJS provides many Persistent Immutable data structures including: `List()`, `Stack()`, `Map()`, `OrderedMap()`, `Set()`, `OrderedSet()` and `Record()`.
|
||||
|
||||
We will cover the most common data structures. `Map()` , `List()` and `Record()` and also we will describe the behaviour of `Seq()` with `Range()`
|
||||
|
||||
|
||||
## Map()
|
||||
- [Map()](https://facebook.github.io/immutable-js/docs/#/Map)
|
||||
* Read values
|
||||
* [get()](https://facebook.github.io/immutable-js/docs/#/Map/get)
|
||||
* [has()](https://facebook.github.io/immutable-js/docs/#/Map/has)
|
||||
* [first()](https://facebook.github.io/immutable-js/docs/#/Map/first)
|
||||
* [last()](https://facebook.github.io/immutable-js/docs/#/Map/last)
|
||||
* Read deep values
|
||||
* [getIn()](https://facebook.github.io/immutable-js/docs/#/Map/getIn)
|
||||
* Change Values
|
||||
- [set()](https://facebook.github.io/immutable-js/docs/#/Map/set)
|
||||
* [merge()](https://facebook.github.io/immutable-js/docs/#/Map/merge)
|
||||
* [update()](https://facebook.github.io/immutable-js/docs/#/Map/update)
|
||||
* [clear()](https://facebook.github.io/immutable-js/docs/#/Map/clear)
|
||||
* [delete()](https://facebook.github.io/immutable-js/docs/#/Map/delete)
|
||||
* Change deep values
|
||||
* [setIn()](https://facebook.github.io/immutable-js/docs/#/Map/getIn)
|
||||
* Conversion to JavaScript types
|
||||
* [toJS()](https://facebook.github.io/immutable-js/docs/#/Map/toJS)
|
||||
* [toArray()](https://facebook.github.io/immutable-js/docs/#/Map/toArray)
|
||||
* [toObject](https://facebook.github.io/immutable-js/docs/#/Map/toObject)
|
||||
* Member
|
||||
* [size](https://facebook.github.io/immutable-js/docs/#/Map/size)
|
||||
|
||||
|
||||
Creates a new Immutable Map. An Object graph. [Map - Immutable.js](https://facebook.github.io/immutable-js/docs/#/Map)
|
||||
|
||||
```js
|
||||
|
||||
const data = {
|
||||
‘one’: {
|
||||
title: ‘One’,
|
||||
value: 1
|
||||
},
|
||||
‘two’: {
|
||||
title: ‘Two’,
|
||||
value: 2
|
||||
}
|
||||
}
|
||||
|
||||
let map = Inmutable.Map(data)
|
||||
```
|
||||
|
||||
### get()
|
||||
Returns the value associated with the provided key, Since inmutable data cannot be mutated they create a new reference to the new data.
|
||||
[get() - Immutable.js](https://facebook.github.io/immutable-js/docs/#/Map/get)
|
||||
|
||||
```js
|
||||
map.get(‘one’).title
|
||||
```
|
||||
|
||||
|
||||
```js
|
||||
let obj = { 1: “one” };
|
||||
Object.keys(obj); // [ “1” ]
|
||||
obj[“1”]; // “one”
|
||||
obj[1]; // “one”
|
||||
|
||||
let map = Map(obj);
|
||||
map.get(“1”); // “one”
|
||||
map.get(1); // undefined
|
||||
```
|
||||
|
||||
### getIn()
|
||||
To get data from a deeply nested structure.
|
||||
[getIn() - Immutable.js](https://facebook.github.io/immutable-js/docs/#/Map/getIn)
|
||||
|
||||
*With a Map()*
|
||||
```js
|
||||
|
||||
let map = Inmutable.Map({
|
||||
title: ‘Todo One’,
|
||||
text: ‘Do todo’
|
||||
category: {
|
||||
title: ‘Some category’,
|
||||
order: 1
|
||||
}
|
||||
})
|
||||
|
||||
map.getIn([‘category’, ‘title’]) // ‘Some Category’
|
||||
|
||||
```
|
||||
|
||||
### length - size
|
||||
To get the size of a Map() or a List()
|
||||
```js
|
||||
map.size
|
||||
```
|
||||
|
||||
### set()
|
||||
```js
|
||||
map.set(‘three’, {title: ‘three’, value: 3})
|
||||
```
|
||||
|
||||
### delete()
|
||||
```js
|
||||
map.delete(‘three’, {title: ‘three’, value: 3})
|
||||
```
|
||||
|
||||
### update()
|
||||
```js
|
||||
map.update(‘one’, item => ‘’)
|
||||
```
|
||||
|
||||
### clear()
|
||||
Returns a new Map containing no keys or values.
|
||||
|
||||
```js
|
||||
map.clear()
|
||||
```
|
||||
|
||||
### merge()
|
||||
Returns a new Map resulting from merging the provided iterables.
|
||||
```js
|
||||
|
||||
let mapX = Inmutable.Map({a: 10, b: 20, c: 30})
|
||||
let mapY = Inmutable.Map({a: 10, b: 20, c: 30})
|
||||
|
||||
mapX.merge(mapY) // { a: 50, b: 40, c: 30, d: 60 }
|
||||
```
|
||||
|
||||
### Querying Methods
|
||||
|
||||
#### has
|
||||
Returns a boolean if it finds the id key
|
||||
|
||||
```js
|
||||
map.has(item.id)
|
||||
```
|
||||
|
||||
#### first
|
||||
Returns the first element of a Map
|
||||
```js
|
||||
map.first()
|
||||
```
|
||||
|
||||
### Iteration Methods
|
||||
We can use methods like `.filter`, `.map`, `.reduce` . However it’s not recommended to use `.forEach` since it can mutate the data producing side effects.
|
||||
|
||||
#### groupBy
|
||||
|
||||
Returns the first element of a Map
|
||||
```js
|
||||
items.groupBy(item => {
|
||||
return todo.completed
|
||||
});
|
||||
```
|
||||
|
||||
### Working with Subsets of a Map()
|
||||
|
||||
#### slice()
|
||||
Returns the last two items of a Map()
|
||||
slice(<from>, <to>)
|
||||
```js
|
||||
items.slice(items.size-2, todos.size);
|
||||
```
|
||||
|
||||
#### takeLast()
|
||||
Returns the last two items of a Map()
|
||||
```js
|
||||
items.takeLast(2);
|
||||
```
|
||||
|
||||
#### butLast()
|
||||
Returns the last item
|
||||
```js
|
||||
items.butLast();
|
||||
```
|
||||
|
||||
#### rest()
|
||||
```js
|
||||
items.rest();
|
||||
```
|
||||
|
||||
#### skip()
|
||||
Returns a Map() skipping the first 5 items
|
||||
```js
|
||||
items.skip(5);
|
||||
```
|
||||
|
||||
#### skipUntil()
|
||||
Returns a Map() skipping until it finds the value
|
||||
```js
|
||||
items.skipUntil(item => item.value === 1);
|
||||
```
|
||||
|
||||
#### skipWhile()
|
||||
Returns a Map() up until it finds 1 included.
|
||||
```js
|
||||
items.skipWhile(item => item.value === 1);
|
||||
```
|
||||
|
||||
### Equality Methods
|
||||
|
||||
#### is()
|
||||
```js
|
||||
let mapX = Inmutable.Map({a: 10, b: 20, c: 30})
|
||||
let mapY = Inmutable.Map({a: 10, b: 20, c: 30})
|
||||
|
||||
Immutable.is(mapX, mapY); // true
|
||||
```
|
||||
|
||||
### FromJS
|
||||
|
||||
#### Object to Map()
|
||||
Creates deeply nested Map() from a plain Javascript Object
|
||||
|
||||
```js
|
||||
let object = {a: 10, b: 20, c: 30};
|
||||
|
||||
Immutable.fromJS(object); // Map()
|
||||
```
|
||||
|
||||
|
||||
#### Array to List()
|
||||
Creates List() from a JS Array
|
||||
|
||||
```js
|
||||
let array = [10,20,30];
|
||||
|
||||
Immutable.fromJS(object); // List()
|
||||
```
|
||||
|
||||
#### Usage of the reviver function
|
||||
The reviver function takes a key and a value. Converting JS to Map() or List()
|
||||
|
||||
```js
|
||||
let array = [10,20,30];
|
||||
|
||||
Immutable.fromJS(array, (key, value) => {
|
||||
return value.toMap();
|
||||
}); // Map()
|
||||
```
|
||||
|
||||
*Note: the getIn will be index based instead of object based if it comes from an array*
|
||||
|
||||
### List()
|
||||
|
||||
Most of the __Map()__ methods can be used with __List()__
|
||||
But there are some differences.
|
||||
|
||||
### Differences between the Immutable Map() and List()
|
||||
List() have the same methods that a JS Array has. But instead of mutating the array it returns a new one.
|
||||
|
||||
Usually we wouldn’t use the push method in immutable data structures but with Immutable.List()s push methods are safe to be used.
|
||||
|
||||
```js
|
||||
let list = Immutable.List()
|
||||
list.push(3)
|
||||
list.toArray() // [3]
|
||||
```
|
||||
|
||||
#### get() and getIn()
|
||||
The get method with Map() is _key_ based and with List() is _index_ based.
|
||||
|
||||
```js
|
||||
// get()
|
||||
let list = Immutable.List();
|
||||
list.push(3);
|
||||
list.get(0); // 3
|
||||
|
||||
let map = Immutable.Map();
|
||||
list.set('active', true);
|
||||
list.get('active'); // true
|
||||
|
||||
// getIn()
|
||||
let map = Inmutable.List([10, 20, 30, [40, 50]])
|
||||
map.getIn([3, 1]) // 50
|
||||
```
|
||||
|
||||
#### of()
|
||||
We can create a __List()__ by using the _of_ method
|
||||
|
||||
```js
|
||||
const items = [];
|
||||
const list = Immutable.List.of('red', 'green', 'blue');
|
||||
```
|
||||
|
||||
*Using the spread operator:*
|
||||
```js
|
||||
const items = ['red', 'green', 'blue'];
|
||||
const list = Immutable.List.of(...items);
|
||||
```
|
||||
|
||||
### Sequences
|
||||
Represents a sequence of values. [Seq() - Immutable.js](https://facebook.github.io/immutable-js/docs/#/Seq)
|
||||
|
||||
- Sequences are immutable — Once a sequence is created, it cannot be changed.
|
||||
- Sequences are Lazy
|
||||
|
||||
Creating sequences with _of()_
|
||||
```js
|
||||
let range = [0, 1, 2 ... 999]
|
||||
let sequence = Immutable.Seq.of(...range)
|
||||
```
|
||||
|
||||
For Example: the following performs no work, because the resulting of the sequence values are never iterated:
|
||||
|
||||
```js
|
||||
|
||||
let operations = 0;
|
||||
|
||||
let squared = sequence.map(num => {
|
||||
operations++;
|
||||
return num * num;
|
||||
})
|
||||
operations; // 0
|
||||
|
||||
// Now using the sequence
|
||||
squared.take(10).toArray();
|
||||
operations; // 10
|
||||
|
||||
```
|
||||
|
||||
Once the sequence is used, it performs only the work necessary. It will return it only when you ask for them.
|
||||
|
||||
This is really powerful because it doesn’t produce an overflow with infinite an infinite range.
|
||||
|
||||
```js
|
||||
let squaredRange = Immutable.Range(1, Infinity);
|
||||
|
||||
squaredRange.size; // Infinity
|
||||
|
||||
first1000squared = squaredRange
|
||||
.take(1000)
|
||||
.map(n => n * n);
|
||||
|
||||
first1000squared.size; // 1000
|
||||
```
|
||||
|
||||
__Seq()__ allows for the efficient chaining of operations
|
||||
|
||||
```js
|
||||
let squaredOdds = Immutable.Range(0, Infinity)
|
||||
.filter(n => n % 2 !== 0)
|
||||
.map(n => n * n)
|
||||
.take(1000);
|
||||
|
||||
console.log(
|
||||
squaredOdds.toArray()
|
||||
)
|
||||
|
||||
```
|
||||
|
||||
You can fin this example here: [Sequences - JS Bin](http://jsbin.com/nilekuj/edit?js,console)
|
||||
|
||||
[image:12FACC54-0BAF-4C93-A782-F77DB7CD04D3-813-00001ABD60F45CC4/Screen Shot 2016-12-22 at 8.23.33 AM.png]
|
||||
|
||||
## Memoization with Immutable JS
|
||||
Immutable JS provides advanced memoization.
|
||||
|
||||
```js
|
||||
const seq = Immutable.Range(1, Infinity)
|
||||
.map(n => ({
|
||||
value: n
|
||||
}))
|
||||
|
||||
console.time(‘First Run’);
|
||||
seq.take(1000);
|
||||
console.timeEnd(‘First Run’); // First Run: 0.577ms
|
||||
|
||||
console.time(‘Second Run’);
|
||||
seq.take(1000);
|
||||
console.timeEnd(‘Second Run’); // Second Run: 0.165ms
|
||||
```
|
||||
|
||||
|
||||
### Play with Immutable JS
|
||||
[JS Bin - Collaborative JavaScript Debugging](http://jsbin.com/nilekuj/edit?js,console)
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# Frontend Architecture
|
||||
## The Stack
|
||||
- [React](#react)
|
||||
- [Redux](#redux)
|
||||
- [ImmutableJS](#immutablejs)
|
||||
|
||||
|
||||
## The Architecture
|
||||
Our frontend lives within [talk/client](https://github.com/coralproject/talk/tree/153193959cb4dfa5d8feaabb49811325f836ee68/client) folder. Every folder contains a plugin. In [coral-framework](https://github.com/coralproject/talk/tree/153193959cb4dfa5d8feaabb49811325f836ee68/client/coral-framework) you will find the core architecture of Talk.
|
||||
Here is where our Redux Application, translations, components, and helpers live.
|
||||
|
||||
|
||||
## Presentational and Container Components
|
||||
We use a common simple pattern called
|
||||
__Presentational and Container Components__
|
||||
|
||||
It basically consist in having two types of components:
|
||||
- Presentational
|
||||
- Containers
|
||||
|
||||
### Presentational Components
|
||||
- __How our UI looks like__
|
||||
- Are stateless components
|
||||
- Render props
|
||||
- Allow containment of children via `this.props.children`
|
||||
- They have DOM Markup
|
||||
|
||||
### Container Components
|
||||
* __How things work__
|
||||
* They don’t have markup nor styles
|
||||
* They provide data and behaviour to Presentational or Container Components
|
||||
* They connect via `react-redux`’s `connect()` to the state.
|
||||
* They `mapStateToProps` the state to the Presentational Container.
|
||||
* They `mapDispatchToProps` to send actions to the Presentational Container.
|
||||
* Name Convention `<Name>Container.js`
|
||||
|
||||
How a container looks like:
|
||||
```js
|
||||
/*
|
||||
* mapStateToProps
|
||||
* We map the part of the state that we want to use
|
||||
*/
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
auth: state.auth.toJS()
|
||||
});
|
||||
|
||||
/*
|
||||
* mapDispatchToProps
|
||||
* We map the actions that we want to use
|
||||
*/
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
checkLogin: () => dispatch(checkLogin())
|
||||
});
|
||||
|
||||
|
||||
/*
|
||||
* connect
|
||||
* We wrap our container in a connect() function
|
||||
*/
|
||||
|
||||
export default connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(SignInContainer);
|
||||
````
|
||||
|
||||
How our SignInContainer works: [talk/SignInContainer.js · GitHub](https://github.com/coralproject/talk/blob/153193959cb4dfa5d8feaabb49811325f836ee68/client/coral-sign-in/containers/SignInContainer.js)
|
||||
|
||||
Within our plugins we create two folders `containers` and `components` so we can differentiate them:
|
||||
```
|
||||
coral-sign-in/
|
||||
├── containers/
|
||||
│ └── SignInContainer.js
|
||||
└── components/
|
||||
├── SignInContent.js
|
||||
└── SignUpContent.js
|
||||
```
|
||||
|
||||
More about this architecture:
|
||||
|
||||
[Container Components – Learn React with chantastic – Medium](https://medium.com/@learnreact/container-components-c0e67432e005#.w8mzgndcg)
|
||||
|
||||
|
||||
[Presentational and Container Components – Dan Abramov – Medium](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0#.ai4ih55v3)
|
||||
|
||||
|
||||
## React
|
||||
## Redux
|
||||
We use Redux to handle the state container of Talk.
|
||||
|
||||
[How we to use Redux, and how we use it with Talk](https://github.com/coralproject/talk/blob/frontenddocs/docs/frontend/REDUX.md)
|
||||
|
||||
|
||||
## ImmutableJS
|
||||
We use Immutable JS to maintain our state immutable.
|
||||
We found some really good tradeoffs while building Talk.
|
||||
|
||||
[How to use ImmutableJS and how we use it with Talk](https://github.com/coralproject/talk/blob/frontenddocs/docs/frontend/IMMUTABLEJS.md)
|
||||
|
||||
|
||||
## Test
|
||||
[How we do testing at Coral with Talk](https://github.com/coralproject/talk/blob/frontenddocs/docs/frontend/DEBUG.md)
|
||||
|
||||
|
||||
## Lint
|
||||
For linting in Talk we use `eslint:recommended`
|
||||
|
||||
You can find more info about the rules and best practices here:
|
||||
http://eslint.org/docs/rules/#best-practices
|
||||
|
||||
## Lint the code
|
||||
```js
|
||||
npm run lint
|
||||
```
|
||||
|
||||
|
||||
## The Future of the Frontend
|
||||
- Preact
|
||||
- Reselect
|
||||
@@ -0,0 +1,223 @@
|
||||
# Redux
|
||||
Redux is a predictable state container for JavaScript apps.
|
||||
|
||||
To understand Redux we need to dive into a few concepts.
|
||||
|
||||
- [Actions](#actions)
|
||||
- [Action Creators](#actions)
|
||||
- [Action Types](#actions)
|
||||
- [Reducers](#reducers)
|
||||
- [Stores](#store)
|
||||
|
||||
## The three principles
|
||||
These are the three principles to build Redux applications. The following are specified in the Redux Documentation [Three Principles · Redux](http://redux.js.org/docs/introduction/ThreePrinciples.html)
|
||||
|
||||
### Single source of truth
|
||||
The state of your whole application is stored in an object tree within a single store. We are going to represent the whole state of our application in a single Javascript Object.
|
||||
|
||||
### State is read-only
|
||||
The only way to change the state is to emit an action, an object describing what happened.
|
||||
|
||||
### Changes are made with pure functions
|
||||
To specify how the state tree is transformed by actions, you write pure reducers.
|
||||
|
||||
## Actions
|
||||
Actions describe that something happened in our application. They are payloads of information that send data to your store. __They are the only source of information for the store.__
|
||||
|
||||
Here is an example:
|
||||
```js
|
||||
const ADD_COMMENT = 'ADD_COMMENT';
|
||||
|
||||
{
|
||||
type: ADD_COMMENT,
|
||||
comment: 'This is my comment.'
|
||||
}
|
||||
```
|
||||
|
||||
Actions are JavaScript objects. Every action must have a `type` property that indicates the type of action being performed. Types should be defined as constants.
|
||||
|
||||
Once an app becomes big enough, you may want to move them into a separate module. We store them in a `contants.js` file. [auth.js Constants](https://github.com/coralproject/talk/blob/153193959cb4dfa5d8feaabb49811325f836ee68/client/coral-framework/constants/auth.js)
|
||||
|
||||
```js
|
||||
import { ADD_COMMENT, REMOVE_COMMENT } from './constants'
|
||||
```
|
||||
|
||||
We can dispatch an action by using `dispatch()`.
|
||||
|
||||
Our actions live within the `coral-framework/actions` folder. [talk/client/coral-framework/actions](https://github.com/coralproject/talk/tree/153193959cb4dfa5d8feaabb49811325f836ee68/client/coral-framework/actions)
|
||||
|
||||
More about Actions: [Actions · Redux](http://redux.js.org/docs/basics/Actions.html)
|
||||
|
||||
### Async Actions
|
||||
For our async operations we dispatch three actions.
|
||||
|
||||
- `<ACTION_TYPE>_REQUEST`
|
||||
|
||||
- `<ACTION_TYPE>_SUCCESS`
|
||||
|
||||
- `<ACTION_TYPE>_FAILURE`
|
||||
|
||||
#### Request
|
||||
We use the postfix `_REQUEST` to know that the resource is being requested.
|
||||
|
||||
#### Success
|
||||
We use the postfix `_SUCCESS` to know that the resource response came back successfully.
|
||||
|
||||
#### Failure
|
||||
We use the postfix `_FAILURE` to know that the resource request failed.
|
||||
|
||||
## Action Creators
|
||||
Action Creators are functions that return actions. This makes it easier to use, portable and testable.
|
||||
|
||||
```js
|
||||
function addComment(comment) {
|
||||
return {
|
||||
type: ADD_COMMENT,
|
||||
comment
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
So we can later trigger those actions by using `dispatch()`
|
||||
|
||||
```js
|
||||
dispatch(addComment(comment))
|
||||
dispatch(removeComment(comment.id))
|
||||
```
|
||||
|
||||
|
||||
## Dispatch Function
|
||||
The `dispatch()` function can be accessed directly from the store as `store.dispatch()`, but more likely you'll access it using a helper like react-redux's`connect()`.
|
||||
|
||||
We use `connect()`in our containers. More about this in Architecture.
|
||||
|
||||
## Reducers
|
||||
With Actions we describe that something happened in our application. But we don’t specify how our state will be modified with this change.
|
||||
|
||||
In a Reducer we will specify how the state of our application change when an action has been dispatched.
|
||||
|
||||
Here we also will want to specify the `initialState`
|
||||
|
||||
Before building reducers it’s important to that you:
|
||||
- Don’t mutate the state
|
||||
- Return the previous state in the default case.
|
||||
|
||||
Here is an example of an auth reducer:
|
||||
```js
|
||||
const initialState = {
|
||||
isLoading: false,
|
||||
loggedIn: false,
|
||||
user: null,
|
||||
error: ''
|
||||
};
|
||||
|
||||
function auth (state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case actions.CHECK_LOGIN_REQUEST:
|
||||
return Object.assign({}, state, {
|
||||
isLoading: true
|
||||
});
|
||||
case actions.CHECK_LOGIN_SUCCESS:
|
||||
return Object.assign({}, state, {
|
||||
isLoading: false,
|
||||
loggedIn: true,
|
||||
user: action.user,
|
||||
error: ''
|
||||
});
|
||||
case actions.CHECK_LOGIN_FAILURE:
|
||||
return Object.assign({}, state, {
|
||||
isLoading: false,
|
||||
error: action.error,
|
||||
loggedIn: false,
|
||||
user: null
|
||||
});
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notice that a reducer takes the `state` as first argument and when it’s not defined it returns the `initialState`. As a second argument it takes the `action`. We have our state and we have the action. This is the time to specify how we modify the state.
|
||||
|
||||
### Reducers using ImmutableJS
|
||||
We are using ImmutableJS to maintain our app state. Here is a guide on how to use ImmutableJS.
|
||||
|
||||
This is how a simplified version of our [auth reducer](https://github.com/coralproject/talk/blob/153193959cb4dfa5d8feaabb49811325f836ee68/client/coral-framework/reducers/auth.js) looks like:
|
||||
```js
|
||||
const initialState = Map({
|
||||
isLoading: false,
|
||||
loggedIn: false,
|
||||
user: null,
|
||||
error: ‘’
|
||||
});
|
||||
|
||||
function auth (state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case CHECK_LOGIN_REQUEST:
|
||||
return state
|
||||
.set('isLoading', true);
|
||||
case CHECK_LOGIN_SUCCESS:
|
||||
return state
|
||||
.set('isLoading', false)
|
||||
.set('loggedIn', true)
|
||||
.set('user', action.user)
|
||||
.set('error', '');
|
||||
});
|
||||
case CHECK_LOGIN_FAILURE:
|
||||
return state
|
||||
.set('isLoading', false)
|
||||
.set('error', action.error)
|
||||
.set('loggedIn', false)
|
||||
.set('user', null)
|
||||
});
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Looks cleaner, right?
|
||||
|
||||
It’s pretty easy to follow. Here it says if a `CHECK_LOGIN_REQUEST` action has been dispatched set the `isLoading` from our state to `true`. And we can show a tiny loader to let the user now we are requesting something to the server.
|
||||
|
||||
Our actions live within the `coral-framework/reducers` folder. [talk/client/coral-framework/reducers ](https://github.com/coralproject/talk/tree/153193959cb4dfa5d8feaabb49811325f836ee68/client/coral-framework/reducers)
|
||||
|
||||
More about Reducers: [Reducers · Redux](http://redux.js.org/docs/basics/Reducers.html)
|
||||
|
||||
And the last thing we need to see is the __Store__
|
||||
|
||||
### Store
|
||||
The `Store` is what holds the application state. Here we can access and update the state.
|
||||
|
||||
It’s important to note that we will only have a single store in our application called `rootReducer` and we will use reducer composition instead of many stores.
|
||||
|
||||
Here is an example of how create a store with [createStore()](http://redux.js.org/docs/api/createStore.html) using a reducer:
|
||||
```js
|
||||
import { createStore } from 'redux'
|
||||
import authReducer from './auth'
|
||||
let store = createStore(authReducer)
|
||||
```
|
||||
|
||||
We do have a lot of stores so we will need to combine all our reducers with [combineReducers()](http://redux.js.org/docs/api/combineReducers.html) within a single store
|
||||
|
||||
```js
|
||||
import {combineReducers} from 'redux';
|
||||
|
||||
import authReducer from './auth'
|
||||
import configReducer from './config'
|
||||
import userReducer from './user'
|
||||
|
||||
const rootReducer = combineReducers({
|
||||
authReducer,
|
||||
configReducer,
|
||||
userReducer
|
||||
...
|
||||
});
|
||||
```
|
||||
|
||||
More about Stores: [Store · Redux](http://redux.js.org/docs/basics/Store.html)
|
||||
|
||||
## Useful Resources
|
||||
[Redux Documentation · Redux](http://redux.js.org/)
|
||||
[Getting Started with Redux](https://egghead.io/courses/getting-started-with-redux)
|
||||
[Usage with React · Redux](http://redux.js.org/docs/basics/UsageWithReact.html)
|
||||
@@ -0,0 +1,123 @@
|
||||
# Test
|
||||
How we do testing at Coral with Talk.
|
||||
|
||||
We use Nightwatch and Selenium for our E2E tests and Enzyme for our React Components.
|
||||
|
||||
## E2E tests
|
||||
For our E2E Test we use Nightwatch and Selenium.
|
||||
|
||||
#### Selenium Server Setup
|
||||
|
||||
Selenium Server is a Java application which Nightwatch uses to connect to the various browsers.
|
||||
|
||||
You will need to have the Java Development Kit (JDK) installed.
|
||||
[Java SE Development Kit 8 - Downloads](http://www.oracle.com/technetwork/java/javase/downloads/jdk8-downloads-2133151.html)
|
||||
|
||||
The minimum required version is 7.
|
||||
You can check this by running `java -version`
|
||||
|
||||
#### Folder Structure
|
||||
|
||||
```
|
||||
e2e/
|
||||
├── pages
|
||||
| ├── adminPage.js
|
||||
| └── embedStreamPage.js
|
||||
├── reports
|
||||
├── tests
|
||||
| ├── Admin
|
||||
| ├── Commenter
|
||||
| ├── Moderator
|
||||
| └── Visitor
|
||||
|
||||
```
|
||||
|
||||
#### Pages
|
||||
Here we will have all the selectors and commands for a Page
|
||||
|
||||
#### Reports
|
||||
The folder that Nightwatch will use after running the tests
|
||||
|
||||
#### Tests
|
||||
Within `tests` folder we have 4 Folders and a couple of files.
|
||||
`Admin`, `Commenter`, `Moderator`, `Visitor` contains all the group tests based on the user role and their actions.
|
||||
|
||||
## Tests
|
||||
The `pree2e` script will create 3 users: a Commenter, a Moderator, and an Admin
|
||||
|
||||
* Commenter
|
||||
* Login
|
||||
- Post a comment
|
||||
* Likes a comment
|
||||
* Flag a comment
|
||||
* Flag a username
|
||||
* Gets Permalink
|
||||
* Visits Permalink
|
||||
|
||||
- Moderator
|
||||
* Login
|
||||
|
||||
- Admin
|
||||
* Login
|
||||
- Approve Comment
|
||||
- Reject Comment
|
||||
* Ban User
|
||||
|
||||
- Visitor
|
||||
* Tries to like a comment
|
||||
* Tries to flag a comment
|
||||
- Tries to flag a username
|
||||
* Signs up
|
||||
|
||||
## Run the tests
|
||||
Run Talk
|
||||
`dotenv npm run start`
|
||||
|
||||
Run e2e tests
|
||||
`npm run e2e`
|
||||
|
||||
|
||||
## Advanced Nightwatch and Selenium Settings
|
||||
|
||||
### Adding an Integration Environment
|
||||
```json
|
||||
{
|
||||
…
|
||||
“test_settings” : {
|
||||
“default” : {
|
||||
“launch_url” : “http://localhost”,
|
||||
“globals” : {
|
||||
“myGlobalVar” : “some value”,
|
||||
“otherGlobal” : “some other value”
|
||||
}
|
||||
},
|
||||
“integration” : {
|
||||
“launch_url” : “http://staging.host”,
|
||||
“globals” : {
|
||||
“myGlobalVar” : “other value”
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`nightwatch —env integration`
|
||||
|
||||
### Chrome Options
|
||||
[List of Chromium Command Line Switches « Peter Beverloo](http://peter.sh/experiments/chromium-command-line-switches/)
|
||||
|
||||
## Tags
|
||||
You'll notice that each test file starts with tags. This is useful to selectively target tests to run.
|
||||
|
||||
_i.e nightwatch --tag login will only run login tests tagged with login_
|
||||
```js
|
||||
module.exports {
|
||||
'@tags': ['login'],
|
||||
'Test': browser => {
|
||||
[...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Source: http://nightwatchjs.org/guide#test-tags
|
||||
|
||||
+1
-1
@@ -583,7 +583,7 @@ paths:
|
||||
description: The user that has been created.
|
||||
schema:
|
||||
$ref: '#/definitions/User'
|
||||
/users/update-password:
|
||||
/account/password/reset:
|
||||
post:
|
||||
parameters:
|
||||
- name: body
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* ExtendableError provides a base Error class to source off of that does not
|
||||
* break the inheritence chain.
|
||||
*/
|
||||
class ExtendableError {
|
||||
constructor(message = null) {
|
||||
this.message = message;
|
||||
this.stack = (new Error()).stack;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* APIError is the base error that all application issued errors originate, they
|
||||
* are composed of data used by the front end and backend to handle errors
|
||||
* consistently.
|
||||
*/
|
||||
class APIError extends ExtendableError {
|
||||
constructor(message, {status = 500, translation_key = null}, metadata = {}) {
|
||||
super(message);
|
||||
|
||||
this.status = status;
|
||||
this.translation_key = translation_key;
|
||||
this.metadata = metadata;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
message: this.message,
|
||||
status: this.status,
|
||||
translation_key: this.translation_key,
|
||||
metadata: this.metadata
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ErrPasswordTooShort is returned when the password length is too short.
|
||||
const ErrPasswordTooShort = new APIError('password must be at least 8 characters', {
|
||||
status: 400,
|
||||
translation_key: 'PASSWORD_LENGTH'
|
||||
});
|
||||
|
||||
const ErrMissingEmail = new APIError('email is required', {
|
||||
translation_key: 'EMAIL_REQUIRED',
|
||||
status: 400
|
||||
});
|
||||
|
||||
const ErrMissingPassword = new APIError('password is required', {
|
||||
translation_key: 'PASSWORD_REQUIRED',
|
||||
status: 400
|
||||
});
|
||||
|
||||
const ErrEmailTaken = new APIError('Email address already in use', {
|
||||
translation_key: 'EMAIL_IN_USE',
|
||||
status: 400
|
||||
});
|
||||
|
||||
const ErrDisplayTaken = new APIError('Display name already in use', {
|
||||
translation_key: 'DISPLAYNAME_IN_USE',
|
||||
status: 400
|
||||
});
|
||||
|
||||
const ErrSpecialChars = new APIError('No special characters are allowed in a display name', {
|
||||
translation_key: 'NO_SPECIAL_CHARACTERS',
|
||||
status: 400
|
||||
});
|
||||
|
||||
const ErrMissingDisplay = new APIError('A display name is required to create a user', {
|
||||
translation_key: 'DISPLAY_NAME_REQUIRED',
|
||||
status: 400
|
||||
});
|
||||
|
||||
// ErrMissingToken is returned in the event that the password reset is requested
|
||||
// without a token.
|
||||
const ErrMissingToken = new APIError('token is required', {
|
||||
status: 400
|
||||
});
|
||||
|
||||
// ErrAssetCommentingClosed is returned when a comment or action is attempted on
|
||||
// a stream where commenting has been closed.
|
||||
class ErrAssetCommentingClosed extends APIError {
|
||||
constructor(closedMessage = null) {
|
||||
super('asset commenting is closed', {
|
||||
status: 400
|
||||
}, {
|
||||
|
||||
// Include the closedMessage in the metadata piece of the error.
|
||||
closedMessage
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ErrContainsProfanity is returned in the event that the middleware detects
|
||||
// profanity/wordlisted words in the payload.
|
||||
const ErrContainsProfanity = new APIError('Suspected profanity. If you think this in error, please let us know!', {
|
||||
translation_key: 'PROFANITY_ERROR',
|
||||
status: 400
|
||||
});
|
||||
|
||||
const ErrNotFound = new APIError('not found', {
|
||||
status: 404
|
||||
});
|
||||
|
||||
const ErrInvalidAssetURL = new APIError('asset_url is invalid', {
|
||||
status: 400
|
||||
});
|
||||
|
||||
// ErrNotAuthorized is an error that is returned in the event an operation is
|
||||
// deemed not authorized.
|
||||
const ErrNotAuthorized = new APIError('not authorized', {
|
||||
status: 401
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
ExtendableError,
|
||||
APIError,
|
||||
ErrPasswordTooShort,
|
||||
ErrMissingEmail,
|
||||
ErrMissingPassword,
|
||||
ErrMissingToken,
|
||||
ErrEmailTaken,
|
||||
ErrSpecialChars,
|
||||
ErrMissingDisplay,
|
||||
ErrContainsProfanity,
|
||||
ErrDisplayTaken,
|
||||
ErrAssetCommentingClosed,
|
||||
ErrNotFound,
|
||||
ErrInvalidAssetURL,
|
||||
ErrNotAuthorized
|
||||
};
|
||||
@@ -3,5 +3,5 @@ const Setting = require('./models/setting');
|
||||
module.exports = () => Promise.all([
|
||||
|
||||
// Upsert the settings object.
|
||||
Setting.init({id: '1', moderation: 'pre'})
|
||||
Setting.init({id: '1', moderation: 'pre', wordlist: {banned: [], suspect: []}})
|
||||
]);
|
||||
|
||||
@@ -7,17 +7,7 @@ const authorization = module.exports = {
|
||||
};
|
||||
|
||||
const debug = require('debug')('talk:middleware:authorization');
|
||||
|
||||
/**
|
||||
* ErrNotAuthorized is an error that is returned in the event an operation is
|
||||
* deemed not authorized.
|
||||
* @type {Error}
|
||||
*/
|
||||
const ErrNotAuthorized = new Error('not authorized');
|
||||
ErrNotAuthorized.status = 401;
|
||||
|
||||
// Add the ErrNotAuthorized error to the authorization object to be exported.
|
||||
authorization.ErrNotAuthorized = ErrNotAuthorized;
|
||||
const ErrNotAuthorized = require('../errors').ErrNotAuthorized;
|
||||
|
||||
/**
|
||||
* has returns true if the user has all the roles specified, otherwise it will
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ const ActionSchema = new Schema({
|
||||
item_type: String,
|
||||
item_id: String,
|
||||
user_id: String,
|
||||
metadata: Object, //Holds arbitrary metadata about the action.
|
||||
metadata: Schema.Types.Mixed
|
||||
}, {
|
||||
timestamps: {
|
||||
createdAt: 'created_at',
|
||||
|
||||
+37
-91
@@ -47,6 +47,7 @@ const CommentSchema = new Schema({
|
||||
asset_id: String,
|
||||
author_id: String,
|
||||
status_history: [StatusSchema],
|
||||
status: {type: String, default: null},
|
||||
parent_id: String
|
||||
}, {
|
||||
timestamps: {
|
||||
@@ -84,24 +85,6 @@ CommentSchema.method('filterForUser', function(user = false) {
|
||||
return this.toJSON();
|
||||
});
|
||||
|
||||
/**
|
||||
* Sets up a virtual getter function on a comment such that when you try and
|
||||
* access the `comment.last_status` it returns the last status in the array
|
||||
* of status's on the comment, or `null` if there was no status_history.
|
||||
*/
|
||||
CommentSchema.virtual('status').get(function() {
|
||||
|
||||
// Here we are taking advantage of the fact that when documents are inserted
|
||||
// for the new status on a comment that they are always appended to the end
|
||||
// of the list in the order that they are inserted, hence, the last status
|
||||
// is always the most recent.
|
||||
if (this.status_history && this.status_history.length > 0) {
|
||||
return this.status_history[this.status_history.length - 1].type;
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a new Comment that came from a public source.
|
||||
* @param {Mixed} comment either a single comment or an array of comments.
|
||||
@@ -118,7 +101,7 @@ CommentSchema.statics.publicCreate = (comment) => {
|
||||
body,
|
||||
asset_id,
|
||||
parent_id,
|
||||
status = false,
|
||||
status = null,
|
||||
author_id
|
||||
} = comment;
|
||||
|
||||
@@ -130,6 +113,7 @@ CommentSchema.statics.publicCreate = (comment) => {
|
||||
type: status,
|
||||
created_at: new Date()
|
||||
}] : [],
|
||||
status,
|
||||
author_id
|
||||
});
|
||||
|
||||
@@ -153,33 +137,18 @@ CommentSchema.statics.findByAssetId = (asset_id) => Comment.find({
|
||||
});
|
||||
|
||||
/**
|
||||
* Finds the accepted comments by the asset_id get the comments that are
|
||||
* accepted.
|
||||
* @param {String} asset_id identifier of the asset which owns the comments (uuid)
|
||||
* @return {Promise}
|
||||
* findByAssetIdWithStatuses finds all the comments where the asset id matches
|
||||
* what's provided and the status is one of the ones listed in the statuses
|
||||
* array.
|
||||
* @param {String} asset_id the asset id to search by
|
||||
* @param {Array} [statuses=[]] the array of statuses to search by
|
||||
* @return {Promise} resolves to an array of comments
|
||||
*/
|
||||
CommentSchema.statics.findAcceptedByAssetId = (asset_id) => Comment.find({
|
||||
CommentSchema.statics.findByAssetIdWithStatuses = (asset_id, statuses = []) => Comment.find({
|
||||
asset_id,
|
||||
'status_history.type': 'accepted'
|
||||
});
|
||||
|
||||
/**
|
||||
* Finds the new and accepted comments by the asset_id.
|
||||
* @param {String} asset_id identifier of the asset which owns the comments (uuid)
|
||||
* @return {Promise}
|
||||
*/
|
||||
CommentSchema.statics.findAcceptedAndNewByAssetId = (asset_id) => Comment.find({
|
||||
asset_id,
|
||||
$or: [
|
||||
{
|
||||
'status_history.type': 'accepted'
|
||||
},
|
||||
{
|
||||
status_history: {
|
||||
$size: 0
|
||||
}
|
||||
}
|
||||
]
|
||||
status: {
|
||||
$in: statuses
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -201,62 +170,30 @@ CommentSchema.statics.findByActionType = (action_type) => Action
|
||||
* @return {Promise}
|
||||
*/
|
||||
CommentSchema.statics.findIdsByActionType = (action_type) => Action
|
||||
.findCommentsIdByActionType(action_type, 'comment')
|
||||
.findCommentsIdByActionType(action_type, 'comments')
|
||||
.then((actions) => actions.map(a => a.item_id));
|
||||
|
||||
/**
|
||||
* Find comments by their status_history.
|
||||
* @param {String} status the status of the comment to search for
|
||||
* @return {Promise}
|
||||
* Find comments by current status
|
||||
* @param {String} status status of the comment to search for
|
||||
* @return {Promise} resovles to comment array
|
||||
*/
|
||||
CommentSchema.statics.findByStatus = (status = false) => {
|
||||
let q = {};
|
||||
|
||||
if (status) {
|
||||
q['status_history.type'] = status;
|
||||
} else {
|
||||
q.status_history = {$size: 0};
|
||||
}
|
||||
|
||||
return Comment.find(q);
|
||||
CommentSchema.statics.findByStatus = (status = null) => {
|
||||
return Comment.find({status});
|
||||
};
|
||||
|
||||
/**
|
||||
* Find comments that need to be moderated (aka moderation queue).
|
||||
* @param {String} moderationValue pre or post moderation setting. If it is undefined then look at the settings.
|
||||
* @param {String} asset_id
|
||||
* @return {Promise}
|
||||
*/
|
||||
CommentSchema.statics.moderationQueue = (moderation, asset_id = false) => {
|
||||
CommentSchema.statics.moderationQueue = (status, asset_id = null) => {
|
||||
|
||||
/**
|
||||
* This adds the asset_id requirement to the query if the asset_id is defined.
|
||||
*/
|
||||
const assetIDWrap = (query) => {
|
||||
if (asset_id) {
|
||||
query = query.where('asset_id', asset_id);
|
||||
}
|
||||
// Fetch the comments with statuses.
|
||||
let comments = Comment.findByStatus(status);
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
// Decide on whether or not we need to load extended options for the
|
||||
// moderation based on the moderation options.
|
||||
let comments;
|
||||
|
||||
if (moderation === 'pre') {
|
||||
|
||||
// Pre-moderation: New comments are shown in the moderator queues immediately.
|
||||
comments = assetIDWrap(CommentSchema.statics.findByStatus('premod'));
|
||||
|
||||
} else {
|
||||
|
||||
// Post-moderation: New comments do not appear in moderation queues unless they are flagged by other users.
|
||||
comments = CommentSchema.statics.findIdsByActionType('flag')
|
||||
.then((ids) => assetIDWrap(Comment.find({
|
||||
id: {
|
||||
$in: ids
|
||||
}
|
||||
})));
|
||||
if (asset_id) {
|
||||
comments = comments.where('asset_id', asset_id);
|
||||
}
|
||||
|
||||
return comments;
|
||||
@@ -277,7 +214,8 @@ CommentSchema.statics.pushStatus = (id, status, assigned_by = null) => Comment.u
|
||||
created_at: new Date(),
|
||||
assigned_by
|
||||
}
|
||||
}
|
||||
},
|
||||
$set: {status}
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -328,8 +266,16 @@ CommentSchema.statics.all = () => Comment.find();
|
||||
* probably to be paginated at some point in the future
|
||||
* @return {Promise} array resolves to an array of comments by that user
|
||||
*/
|
||||
CommentSchema.statics.findByUserId = function (author_id) {
|
||||
return Comment.find({author_id});
|
||||
CommentSchema.statics.findByUserId = function (author_id, admin = false) {
|
||||
|
||||
// do not return un-published comments for non-admins
|
||||
let query = {author_id};
|
||||
|
||||
if (!admin) {
|
||||
query.$nor = [{status: 'premod'}, {status: 'rejected'}];
|
||||
}
|
||||
|
||||
return Comment.find(query);
|
||||
};
|
||||
|
||||
// Comment model.
|
||||
|
||||
+8
-18
@@ -1,7 +1,6 @@
|
||||
const mongoose = require('../services/mongoose');
|
||||
const Schema = mongoose.Schema;
|
||||
const _ = require('lodash');
|
||||
const cache = require('../services/cache');
|
||||
|
||||
const WordlistSchema = new Schema({
|
||||
banned: [String],
|
||||
@@ -43,7 +42,7 @@ const SettingSchema = new Schema({
|
||||
},
|
||||
closedMessage: {
|
||||
type: String,
|
||||
default: ''
|
||||
default: 'Expired'
|
||||
},
|
||||
wordlist: WordlistSchema,
|
||||
charCount: {
|
||||
@@ -53,6 +52,10 @@ const SettingSchema = new Schema({
|
||||
charCountEnable: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
requireEmailConfirmation: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
}, {
|
||||
timestamps: {
|
||||
@@ -98,7 +101,8 @@ SettingSchema.method('filterForUser', function(user = false) {
|
||||
'closeTimeout',
|
||||
'closedMessage',
|
||||
'charCountEnable',
|
||||
'charCount'
|
||||
'charCount',
|
||||
'requireEmailConfirmation'
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -121,19 +125,11 @@ const SettingService = module.exports = {};
|
||||
*/
|
||||
const selector = {id: '1'};
|
||||
|
||||
/**
|
||||
* Cache expiry time in seconds for when the cached entry of the settings object
|
||||
* expires. 2 minutes.
|
||||
*/
|
||||
const EXPIRY_TIME = 60 * 2;
|
||||
|
||||
/**
|
||||
* Gets the entire settings record and sends it back
|
||||
* @return {Promise} settings the whole settings record
|
||||
*/
|
||||
SettingService.retrieve = () => cache.wrap('settings', EXPIRY_TIME, () => {
|
||||
return Setting.findOne(selector);
|
||||
}).then((setting) => new Setting(setting));
|
||||
SettingService.retrieve = () => Setting.findOne(selector);
|
||||
|
||||
/**
|
||||
* This will update the settings object with whatever you pass in
|
||||
@@ -146,12 +142,6 @@ SettingService.update = (settings) => Setting.findOneAndUpdate(selector, {
|
||||
upsert: true,
|
||||
new: true,
|
||||
setDefaultsOnInsert: true
|
||||
}).then((settings) => {
|
||||
|
||||
// Invalidate the settings cache.
|
||||
return cache
|
||||
.set('settings', settings, EXPIRY_TIME)
|
||||
.then(() => settings);
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
+225
-95
@@ -4,8 +4,12 @@ const _ = require('lodash');
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const Action = require('./action');
|
||||
|
||||
const Comment = require('./comment');
|
||||
const Wordlist = require('../services/wordlist');
|
||||
const errors = require('../errors');
|
||||
|
||||
const EMAIL_CONFIRM_JWT_SUBJECT = 'email_confirm';
|
||||
const PASSWORD_RESET_JWT_SUBJECT = 'password_reset';
|
||||
|
||||
// SALT_ROUNDS is the number of rounds that the bcrypt algorithm will run
|
||||
// through during the salting process.
|
||||
@@ -31,6 +35,37 @@ if (process.env.NODE_ENV === 'test' && !process.env.TALK_SESSION_SECRET) {
|
||||
throw new Error('TALK_SESSION_SECRET must be defined to encode JSON Web Tokens and other auth functionality');
|
||||
}
|
||||
|
||||
// ProfileSchema is the mongoose schema defined as the representation of a
|
||||
// User's profile stored in MongoDB.
|
||||
const ProfileSchema = new mongoose.Schema({
|
||||
|
||||
// ID provides the identifier for the user profile, in the case of a local
|
||||
// provider, the id would be an email, in the case of a social provider,
|
||||
// the id would be the foreign providers identifier.
|
||||
id: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
|
||||
// Provider is simply the name attached to the authentication mode. In the
|
||||
// case of a locally provided profile, this will simply be `local`, or a
|
||||
// social provider which for Facebook would just be `facebook`.
|
||||
provider: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
|
||||
// Metadata provides a place to put provider specific details. An example of
|
||||
// something that could be stored here is the `metadata.confirmed_at` could be
|
||||
// used by the `local` provider to indicate when the email address was
|
||||
// confirmed.
|
||||
metadata: {
|
||||
type: mongoose.Schema.Types.Mixed
|
||||
}
|
||||
}, {
|
||||
_id: false
|
||||
});
|
||||
|
||||
// UserSchema is the mongoose schema defined as the representation of a User in
|
||||
// MongoDB.
|
||||
const UserSchema = new mongoose.Schema({
|
||||
@@ -46,7 +81,11 @@ const UserSchema = new mongoose.Schema({
|
||||
|
||||
// This is sourced from the social provider or set manually during user setup
|
||||
// and simply provides a name to display for the given user.
|
||||
displayName: String,
|
||||
displayName: {
|
||||
type: String,
|
||||
unique: true,
|
||||
required: true
|
||||
},
|
||||
|
||||
// This is true when the user account is disabled, no action should be
|
||||
// acknowledged when they are disabled. Logins are also prevented.
|
||||
@@ -60,26 +99,7 @@ const UserSchema = new mongoose.Schema({
|
||||
// Profiles describes the array of identities for a given user. Any one user
|
||||
// can have multiple profiles associated with them, including multiple email
|
||||
// addresses.
|
||||
profiles: [new mongoose.Schema({
|
||||
|
||||
// ID provides the identifier for the user profile, in the case of a local
|
||||
// provider, the id would be an email, in the case of a social provider,
|
||||
// the id would be the foreign providers identifier.
|
||||
id: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
|
||||
// Provider is simply the name attached to the authentication mode. In the
|
||||
// case of a locally provided profile, this will simply be `local`, or a
|
||||
// social provider which for Facebook would just be `facebook`.
|
||||
provider: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
}, {
|
||||
_id: false
|
||||
})],
|
||||
profiles: [ProfileSchema],
|
||||
|
||||
// Roles provides an array of roles (as strings) that is associated with a
|
||||
// user.
|
||||
@@ -295,6 +315,27 @@ UserService.createLocalUsers = (users) => {
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Check the requested displayname for naughty words (currently in English) and special chars
|
||||
* @param {String} displayName word to be checked for profanity
|
||||
* @return {Promise} rejected if the machine's sensibilites are offended
|
||||
*/
|
||||
const isValidDisplayName = (displayName) => {
|
||||
const onlyLettersNumbersUnderscore = /^[a-z0-9_]+$/;
|
||||
|
||||
if (!displayName) {
|
||||
return Promise.reject(errors.ErrMissingDisplay);
|
||||
}
|
||||
|
||||
if (!onlyLettersNumbersUnderscore.test(displayName)) {
|
||||
|
||||
return Promise.reject(errors.ErrSpecialChars);
|
||||
}
|
||||
|
||||
// check for profanity
|
||||
return Wordlist.displayNameCheck(displayName);
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates the local user with a given email, password, and name.
|
||||
* @param {String} email email of the new user
|
||||
@@ -303,49 +344,57 @@ UserService.createLocalUsers = (users) => {
|
||||
* @param {Function} done callback
|
||||
*/
|
||||
UserService.createLocalUser = (email, password, displayName) => {
|
||||
|
||||
if (!email) {
|
||||
return Promise.reject('email is required');
|
||||
return Promise.reject(errors.ErrMissingEmail);
|
||||
}
|
||||
|
||||
email = email.toLowerCase();
|
||||
email = email.toLowerCase().trim();
|
||||
displayName = displayName.toLowerCase().trim();
|
||||
|
||||
if (!password) {
|
||||
return Promise.reject('password is required');
|
||||
return Promise.reject(errors.ErrMissingPassword);
|
||||
}
|
||||
|
||||
if (!displayName) {
|
||||
return Promise.reject('displayName is required');
|
||||
if (password.length < 8) {
|
||||
return Promise.reject(errors.ErrPasswordTooShort);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
bcrypt.hash(password, SALT_ROUNDS, (err, hashedPassword) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
let user = new UserModel({
|
||||
displayName: displayName,
|
||||
password: hashedPassword,
|
||||
roles: [],
|
||||
profiles: [
|
||||
{
|
||||
id: email,
|
||||
provider: 'local'
|
||||
return isValidDisplayName(displayName)
|
||||
.then(() => { // displayName is valid
|
||||
return new Promise((resolve, reject) => {
|
||||
bcrypt.hash(password, SALT_ROUNDS, (err, hashedPassword) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
user.save((err) => {
|
||||
if (err) {
|
||||
if (err.code === 11000) {
|
||||
return reject('Email address already in use');
|
||||
}
|
||||
return reject(err);
|
||||
}
|
||||
return resolve(user);
|
||||
let user = new UserModel({
|
||||
displayName: displayName,
|
||||
password: hashedPassword,
|
||||
roles: [],
|
||||
profiles: [
|
||||
{
|
||||
id: email,
|
||||
provider: 'local'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
user.save((err) => {
|
||||
if (err) {
|
||||
if (err.code === 11000) {
|
||||
if (err.message.match('displayName')) {
|
||||
return reject(errors.ErrDisplayTaken);
|
||||
}
|
||||
return reject(errors.ErrEmailTaken);
|
||||
}
|
||||
return reject(err);
|
||||
}
|
||||
return resolve(user);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -499,47 +548,63 @@ UserService.createPasswordResetToken = function (email) {
|
||||
email = email.toLowerCase();
|
||||
|
||||
return UserModel.findOne({profiles: {$elemMatch: {id: email}}})
|
||||
.then(user => {
|
||||
.then((user) => {
|
||||
if (!user) {
|
||||
|
||||
if (user === null) {
|
||||
|
||||
// since we don't want to reveal that the email does/doesn't exist
|
||||
// just go ahead and resolve the Promise with null and check in the endpoint
|
||||
return Promise.resolve(null);
|
||||
// Since we don't want to reveal that the email does/doesn't exist
|
||||
// just go ahead and resolve the Promise with null and check in the
|
||||
// endpoint.
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {email, jti: uuid.v4(), userId: user.id, version: user.__v};
|
||||
const token = jwt.sign(payload, process.env.TALK_SESSION_SECRET, {expiresIn: '1d'});
|
||||
const payload = {
|
||||
jti: uuid.v4(),
|
||||
email,
|
||||
userId: user.id,
|
||||
version: user.__v
|
||||
};
|
||||
|
||||
return token;
|
||||
return jwt.sign(payload, process.env.TALK_SESSION_SECRET, {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '1d',
|
||||
subject: PASSWORD_RESET_JWT_SUBJECT
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* verifies a jwt and returns the associated user
|
||||
* @param {String} token the JSON Web Token to verify
|
||||
* Verifies that the token was indeed signed by the session secret.
|
||||
* @param {String} token JWT token from the client
|
||||
* @return {Promise}
|
||||
*/
|
||||
UserService.verifyPasswordResetToken = token => {
|
||||
UserService.verifyToken = (token, options = {}) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
jwt.verify(token, process.env.TALK_SESSION_SECRET, (error, decoded) => {
|
||||
if (error) {
|
||||
return reject(error);
|
||||
|
||||
// Set the allowed algorithms.
|
||||
options.algorithms = ['HS256'];
|
||||
|
||||
jwt.verify(token, process.env.TALK_SESSION_SECRET, options, (err, decoded) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
resolve(decoded);
|
||||
});
|
||||
})
|
||||
.then(decoded => {
|
||||
|
||||
/**
|
||||
* TODO: check the jti from this decoded token in redis
|
||||
* and make an entry if it does not exist.
|
||||
* reject if entry already exists.
|
||||
*/
|
||||
return UserService.findById(decoded.userId);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Verifies a jwt and returns the associated user.
|
||||
* @param {String} token the JSON Web Token to verify
|
||||
*/
|
||||
UserService.verifyPasswordResetToken = (token) => {
|
||||
return UserService
|
||||
.verifyToken(token, {
|
||||
subject: PASSWORD_RESET_JWT_SUBJECT
|
||||
})
|
||||
.then((decoded) => UserService.findById(decoded.userId));
|
||||
};
|
||||
|
||||
/**
|
||||
* Finds a user using a value which gets compared using a prefix match against
|
||||
* the user's email address and/or their display name.
|
||||
@@ -578,34 +643,25 @@ UserService.search = (value) => {
|
||||
* Returns a count of the current users.
|
||||
* @return {Promise}
|
||||
*/
|
||||
UserService.count = () => {
|
||||
return UserModel.count();
|
||||
};
|
||||
UserService.count = () => UserModel.count();
|
||||
|
||||
/**
|
||||
* Returns all the users.
|
||||
* @return {Promise}
|
||||
*/
|
||||
UserService.all = () => {
|
||||
return UserModel.find();
|
||||
};
|
||||
UserService.all = () => UserModel.find();
|
||||
|
||||
/**
|
||||
* Adds a new User bio
|
||||
* Updates the user's settings.
|
||||
* @return {Promise}
|
||||
*/
|
||||
|
||||
UserService.addBio = (id, bio) => (
|
||||
UserModel.findOneAndUpdate({
|
||||
id
|
||||
}, {
|
||||
$set: {
|
||||
'settings.bio': bio
|
||||
}
|
||||
}, {
|
||||
new: true
|
||||
})
|
||||
);
|
||||
UserService.updateSettings = (id, settings) => UserModel.update({
|
||||
id
|
||||
}, {
|
||||
$set: {
|
||||
settings
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Add an action to the user.
|
||||
@@ -621,3 +677,77 @@ UserService.addAction = (item_id, user_id, action_type, metadata) => Action.inse
|
||||
action_type,
|
||||
metadata
|
||||
});
|
||||
|
||||
/**
|
||||
* This creates a token based around confirming the local profile.
|
||||
* @param {String} userID The user id for the user that we are creating the
|
||||
* token for.
|
||||
* @param {String} email The email that we are needing to get confirmed.
|
||||
* @return {Promise}
|
||||
*/
|
||||
UserService.createEmailConfirmToken = (userID, email) => {
|
||||
if (!email || typeof email !== 'string') {
|
||||
return Promise.reject('email is required when creating a JWT for resetting passord');
|
||||
}
|
||||
|
||||
email = email.toLowerCase();
|
||||
|
||||
return UserService
|
||||
.findById(userID)
|
||||
.then((user) => {
|
||||
if (!user) {
|
||||
return Promise.reject(new Error('user not found'));
|
||||
}
|
||||
|
||||
// Get the profile representing the local account.
|
||||
let profile = user.profiles.find((profile) => profile.id === email && profile.provider === 'local');
|
||||
|
||||
// Ensure that the user email hasn't already been verified.
|
||||
if (profile && profile.metadata && profile.metadata.confirmed_at) {
|
||||
return Promise.reject(new Error('email address already confirmed'));
|
||||
}
|
||||
|
||||
const payload = {
|
||||
email,
|
||||
userID
|
||||
};
|
||||
|
||||
return jwt.sign(payload, process.env.TALK_SESSION_SECRET, {
|
||||
jwtid: uuid.v4(),
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '1d',
|
||||
subject: EMAIL_CONFIRM_JWT_SUBJECT
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* This verifies that a given token was for the email confirmation and updates
|
||||
* that user's profile with a 'confirmed_at' parameter with the current date.
|
||||
* @param {String} token the token containing the email confirmation details
|
||||
* signed with our secret.
|
||||
* @return {Promise}
|
||||
*/
|
||||
UserService.verifyEmailConfirmation = (token) => {
|
||||
return UserService
|
||||
.verifyToken(token, {
|
||||
subject: EMAIL_CONFIRM_JWT_SUBJECT
|
||||
})
|
||||
.then(({userID, email}) => {
|
||||
|
||||
return UserModel
|
||||
.update({
|
||||
id: userID,
|
||||
profiles: {
|
||||
$elemMatch: {
|
||||
id: email,
|
||||
provider: 'local'
|
||||
}
|
||||
}
|
||||
}, {
|
||||
$set: {
|
||||
'profiles.$.metadata.confirmed_at': new Date()
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
+6
-2
@@ -5,12 +5,13 @@
|
||||
"main": "app.js",
|
||||
"scripts": {
|
||||
"start": "./bin/cli serve --jobs",
|
||||
"dev-start": "nodemon --config .nodemon.json --exec \"./bin/cli -c .env serve --jobs\"",
|
||||
"build": "NODE_ENV=production webpack --config webpack.config.js --bail",
|
||||
"build-watch": "NODE_ENV=development webpack --config webpack.config.dev.js --watch",
|
||||
"lint": "eslint bin/* .",
|
||||
"lint-fix": "eslint bin/* . --fix",
|
||||
"test": "NODE_ENV=test mocha --compilers js:babel-core/register tests/helpers/*.js --require ignore-styles --recursive tests",
|
||||
"test-watch": "NODE_ENV=test mocha --compilers js:babel-core/register --recursive -w tests",
|
||||
"test": "TEST_MODE=unit NODE_ENV=test mocha --compilers js:babel-core/register tests/helpers/*.js --require ignore-styles --recursive tests",
|
||||
"test-watch": "TEST_MODE=unit NODE_ENV=test mocha --compilers js:babel-core/register --recursive -w tests",
|
||||
"pree2e": "NODE_ENV=test scripts/pree2e.sh",
|
||||
"e2e": "NODE_ENV=test nightwatch",
|
||||
"embed-start": "NODE_ENV=development npm run build && ./bin/cli serve --jobs",
|
||||
@@ -52,7 +53,9 @@
|
||||
"cli-table": "^0.3.1",
|
||||
"commander": "^2.9.0",
|
||||
"connect-redis": "^3.1.0",
|
||||
"csurf": "^1.9.0",
|
||||
"debug": "^2.2.0",
|
||||
"dotenv": "^4.0.0",
|
||||
"ejs": "^2.5.2",
|
||||
"env-rewrite": "^1.0.2",
|
||||
"express": "^4.14.0",
|
||||
@@ -120,6 +123,7 @@
|
||||
"mocha-junit-reporter": "^1.12.1",
|
||||
"nightwatch": "^0.9.11",
|
||||
"node-fetch": "^1.6.3",
|
||||
"nodemon": "^1.11.0",
|
||||
"postcss-loader": "^1.1.0",
|
||||
"postcss-modules": "^0.5.2",
|
||||
"postcss-smart-import": "^0.5.1",
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const User = require('../../../models/user');
|
||||
const mailer = require('../../../services/mailer');
|
||||
const authorization = require('../../../middleware/authorization');
|
||||
const errors = require('../../../errors');
|
||||
|
||||
//==============================================================================
|
||||
// ROUTES
|
||||
//==============================================================================
|
||||
|
||||
router.get('/', authorization.needed(), (req, res, next) => {
|
||||
res.json(req.user);
|
||||
});
|
||||
|
||||
// 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.
|
||||
router.post('/email/confirm', (req, res, next) => {
|
||||
|
||||
const {
|
||||
token
|
||||
} = req.body;
|
||||
|
||||
if (!token) {
|
||||
return next(errors.ErrMissingToken);
|
||||
}
|
||||
|
||||
User
|
||||
.verifyEmailConfirmation(token)
|
||||
.then(() => {
|
||||
res.status(204).end();
|
||||
})
|
||||
.catch((err) => {
|
||||
next(err);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* this endpoint takes an email (username) and checks if it belongs to a User account
|
||||
* if it does, create a JWT and send an email
|
||||
*/
|
||||
router.post('/password/reset', (req, res, next) => {
|
||||
const {email} = req.body;
|
||||
|
||||
if (!email) {
|
||||
return next('you must submit an email when requesting a password.');
|
||||
}
|
||||
|
||||
User
|
||||
.createPasswordResetToken(email)
|
||||
.then((token) => {
|
||||
|
||||
// Check to see if the token isn't defined.
|
||||
if (!token) {
|
||||
|
||||
// As it isn't, don't send any emails!
|
||||
return;
|
||||
}
|
||||
|
||||
return mailer.sendSimple({
|
||||
app: req.app, // needed to render the templates.
|
||||
template: 'email/password-reset', // needed to know which template to render!
|
||||
locals: { // specifies the template locals.
|
||||
token,
|
||||
rootURL: process.env.TALK_ROOT_URL
|
||||
},
|
||||
subject: 'Password Reset',
|
||||
to: email
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
|
||||
// we want to send a 204 regardless of the user being found in the db
|
||||
// if we fail on missing emails, it would reveal if people are registered or not.
|
||||
res.status(204).end();
|
||||
})
|
||||
.catch((err) => {
|
||||
next(err);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* expects 2 fields in the body of the request
|
||||
* 1) the token that was in the url of the email link {String}
|
||||
* 2) the new password {String}
|
||||
*/
|
||||
router.put('/password/reset', (req, res, next) => {
|
||||
|
||||
const {
|
||||
token,
|
||||
password
|
||||
} = req.body;
|
||||
|
||||
if (!token) {
|
||||
return next(errors.ErrMissingToken);
|
||||
}
|
||||
|
||||
if (!password || password.length < 8) {
|
||||
return next(errors.ErrPasswordTooShort);
|
||||
}
|
||||
|
||||
User.verifyPasswordResetToken(token)
|
||||
.then(user => {
|
||||
return User.changePassword(user.id, password);
|
||||
})
|
||||
.then(() => {
|
||||
res.status(204).end();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
|
||||
next(authorization.ErrNotAuthorized);
|
||||
});
|
||||
});
|
||||
|
||||
router.put('/settings', authorization.needed(), (req, res, next) => {
|
||||
|
||||
const {
|
||||
bio
|
||||
} = req.body;
|
||||
|
||||
User
|
||||
.updateSettings(req.user.id, {bio})
|
||||
.then(() => {
|
||||
res.status(204).end();
|
||||
})
|
||||
.catch((err) => {
|
||||
next(err);
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
+13
-10
@@ -1,6 +1,7 @@
|
||||
const express = require('express');
|
||||
const passport = require('../../../services/passport');
|
||||
const authorization = require('../../../middleware/authorization');
|
||||
const errors = require('../../../errors');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -8,29 +9,31 @@ const router = express.Router();
|
||||
* This returns the user if they are logged in.
|
||||
*/
|
||||
router.get('/', (req, res, next) => {
|
||||
|
||||
if (req.user) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// When there is no user on the request, then just send back a 204 to this
|
||||
// request. It's not really "an error" if what they asked for isn't available,
|
||||
// but it could be.
|
||||
res.status(204).end();
|
||||
}, (req, res) => {
|
||||
|
||||
// Send back the user object.
|
||||
res.json(req.user.toObject());
|
||||
res.json({user: req.user});
|
||||
});
|
||||
|
||||
/**
|
||||
* This destroys the session of a user, if they have one.
|
||||
*/
|
||||
router.delete('/', authorization.needed(), (req, res) => {
|
||||
req.session.destroy(() => {
|
||||
res.status(204).end();
|
||||
});
|
||||
delete req.session.passport;
|
||||
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
//==============================================================================
|
||||
// PASSPORT ROUTES
|
||||
//==============================================================================
|
||||
|
||||
/**
|
||||
* This sends back the user data as JSON.
|
||||
*/
|
||||
@@ -40,7 +43,7 @@ const HandleAuthCallback = (req, res, next) => (err, user) => {
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return next(authorization.ErrNotAuthorized);
|
||||
return next(errors.ErrNotAuthorized);
|
||||
}
|
||||
|
||||
// Perform the login of the user!
|
||||
@@ -49,7 +52,7 @@ const HandleAuthCallback = (req, res, next) => (err, user) => {
|
||||
return next(err);
|
||||
}
|
||||
|
||||
// We logged in the user! Let's send back the user data.
|
||||
// We logged in the user! Let's send back the user data and the CSRF token.
|
||||
res.json({user});
|
||||
});
|
||||
};
|
||||
@@ -63,7 +66,7 @@ const HandleAuthPopupCallback = (req, res, next) => (err, user) => {
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return res.render('auth-callback', {err: JSON.stringify(authorization.ErrNotAuthorized), data: null});
|
||||
return res.render('auth-callback', {err: JSON.stringify(errors.ErrNotAuthorized), data: null});
|
||||
}
|
||||
|
||||
// Perform the login of the user!
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const express = require('express');
|
||||
const errors = require('../../../errors');
|
||||
const Comment = require('../../../models/comment');
|
||||
const Asset = require('../../../models/asset');
|
||||
const User = require('../../../models/user');
|
||||
@@ -20,13 +21,13 @@ router.get('/', (req, res, next) => {
|
||||
|
||||
// everything on this route requires admin privileges besides listing comments for owner of said comments
|
||||
if (!authorization.has(req.user, 'admin') && !user_id) {
|
||||
next(authorization.ErrNotAuthorized);
|
||||
next(errors.ErrNotAuthorized);
|
||||
return;
|
||||
}
|
||||
|
||||
// if the user is not an admin, only return comment list for the owner of the comments
|
||||
if (req.user.id !== user_id && !authorization.has(req.user, 'admin')) {
|
||||
next(authorization.ErrNotAuthorized);
|
||||
next(errors.ErrNotAuthorized);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -47,7 +48,7 @@ router.get('/', (req, res, next) => {
|
||||
// otherwise this will be a vulnerability if you pass user_id and something else,
|
||||
// the app will return admin-level data without the proper checks
|
||||
if (user_id) {
|
||||
query = Comment.findByUserId(user_id);
|
||||
query = Comment.findByUserId(user_id, authorization.has(req.user, 'admin'));
|
||||
} else if (status) {
|
||||
query = assetIDWrap(Comment.findByStatus(status === 'new' ? null : status));
|
||||
} else if (action_type) {
|
||||
@@ -56,7 +57,7 @@ router.get('/', (req, res, next) => {
|
||||
.then((ids) => assetIDWrap(Comment.find({
|
||||
id: {
|
||||
$in: ids
|
||||
},
|
||||
}
|
||||
})));
|
||||
} else {
|
||||
query = assetIDWrap(Comment.all());
|
||||
@@ -102,14 +103,14 @@ router.post('/', wordlist.filter('body'), (req, res, next) => {
|
||||
status = Asset
|
||||
.rectifySettings(Asset.findById(asset_id).then((asset) => {
|
||||
if (!asset) {
|
||||
return Promise.reject(new Error('asset referenced is not found'));
|
||||
return Promise.reject(errors.ErrNotFound);
|
||||
}
|
||||
|
||||
// Check to see if the asset has closed commenting...
|
||||
if (asset.isClosed) {
|
||||
|
||||
// They have, ensure that we send back an error.
|
||||
return Promise.reject(new Error(`asset has commenting closed because: ${asset.closedMessage}`));
|
||||
return Promise.reject(new errors.ErrAssetCommentingClosed(asset.closedMessage));
|
||||
}
|
||||
|
||||
return asset;
|
||||
@@ -123,7 +124,7 @@ router.post('/', wordlist.filter('body'), (req, res, next) => {
|
||||
if (charCountEnable && body.length > charCount) {
|
||||
return 'rejected';
|
||||
}
|
||||
return moderation === 'pre' ? 'premod' : '';
|
||||
return moderation === 'pre' ? 'premod' : null;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -137,7 +138,7 @@ router.post('/', wordlist.filter('body'), (req, res, next) => {
|
||||
.then((comment) => {
|
||||
if (req.wordlist.suspect) {
|
||||
return Comment
|
||||
.addAction(comment.id, null, 'flag', 'body', 'Matched suspect word filters.')
|
||||
.addAction(comment.id, null, 'flag', {field: 'body', details: 'Matched suspect word filters.'})
|
||||
.then(() => comment);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ router.use('/actions', authorization.needed(), require('./actions'));
|
||||
router.use('/auth', require('./auth'));
|
||||
router.use('/stream', require('./stream'));
|
||||
router.use('/users', require('./users'));
|
||||
router.use('/account', require('./account'));
|
||||
|
||||
// Bind the kue handler to the /kue path.
|
||||
router.use('/kue', authorization.needed('admin'), require('../../services/kue').kue.app);
|
||||
|
||||
+54
-42
@@ -2,12 +2,22 @@ const express = require('express');
|
||||
const Comment = require('../../../models/comment');
|
||||
const User = require('../../../models/user');
|
||||
const Action = require('../../../models/action');
|
||||
const Setting = require('../../../models/setting');
|
||||
const Asset = require('../../../models/asset');
|
||||
const authorization = require('../../../middleware/authorization');
|
||||
const _ = require('lodash');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function gatherActionsAndUsers (comments) {
|
||||
return Promise.all([
|
||||
comments,
|
||||
User.findByIdArray(_.uniq(comments.map((comment) => comment.author_id))),
|
||||
Action.getActionSummaries(_.uniq([
|
||||
...comments.map((comment) => comment.id),
|
||||
...comments.map((comment) => comment.author_id)
|
||||
]))
|
||||
]);
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
// Get Routes
|
||||
//==============================================================================
|
||||
@@ -16,49 +26,51 @@ const router = express.Router();
|
||||
// depending on the settings. The :moderation overwrites this settings.
|
||||
// Pre-moderation: New comments are shown in the moderator queues immediately.
|
||||
// Post-moderation: New comments do not appear in moderation queues unless they are flagged by other users.
|
||||
router.get('/comments/pending', (req, res, next) => {
|
||||
router.get('/comments/pending', authorization.needed('admin'), (req, res, next) => {
|
||||
|
||||
const {
|
||||
asset_id
|
||||
} = req.query;
|
||||
const {asset_id} = req.query;
|
||||
|
||||
let settings = Setting.retrieve();
|
||||
|
||||
if (asset_id) {
|
||||
|
||||
// In the event that we have an asset_id, we should fetch the asset settings
|
||||
// in order to actually determine if there is additional comments to parse.
|
||||
settings = Promise.all([
|
||||
settings,
|
||||
Asset.findById(asset_id).select('settings')
|
||||
]).then(([{moderation}, asset]) => {
|
||||
if (asset.settings && asset.settings.moderation) {
|
||||
return {moderation: asset.settings.moderation};
|
||||
}
|
||||
|
||||
return {moderation};
|
||||
});
|
||||
}
|
||||
|
||||
settings
|
||||
.then(({moderation}) => {
|
||||
return Comment.moderationQueue(moderation);
|
||||
}).then((comments) => {
|
||||
return Promise.all([
|
||||
comments,
|
||||
User.findByIdArray(_.uniq(comments.map((comment) => comment.author_id))),
|
||||
Action.getActionSummaries(_.uniq([
|
||||
...comments.map((comment) => comment.id),
|
||||
...comments.map((comment) => comment.author_id)
|
||||
]))
|
||||
]);
|
||||
})
|
||||
Comment.moderationQueue('premod', asset_id)
|
||||
.then(gatherActionsAndUsers)
|
||||
.then(([comments, users, actions]) => {
|
||||
res.json({
|
||||
comments,
|
||||
users,
|
||||
actions
|
||||
});
|
||||
res.json({comments, users, actions});
|
||||
})
|
||||
.catch(error => {
|
||||
next(error);
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/comments/rejected', authorization.needed('admin'), (req, res, next) => {
|
||||
const {asset_id} = req.query;
|
||||
|
||||
Comment.moderationQueue('rejected', asset_id)
|
||||
.then(gatherActionsAndUsers)
|
||||
.then(([comments, users, actions]) => {
|
||||
res.json({comments, users, actions});
|
||||
})
|
||||
.catch(error => {
|
||||
next(error);
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/comments/flagged', authorization.needed('admin'), (req, res, next) => {
|
||||
const {asset_id} = req.query;
|
||||
|
||||
const assetIDWrap = (query) => {
|
||||
if (asset_id) {
|
||||
query = query.where('asset_id', asset_id);
|
||||
}
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
Comment.findIdsByActionType('flag')
|
||||
.then(ids => assetIDWrap(Comment.find({
|
||||
id: {$in: ids}
|
||||
})))
|
||||
.then(gatherActionsAndUsers)
|
||||
.then(([comments, users, actions]) => {
|
||||
res.json({comments, users, actions});
|
||||
})
|
||||
.catch(error => {
|
||||
next(error);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const express = require('express');
|
||||
const _ = require('lodash');
|
||||
const scraper = require('../../../services/scraper');
|
||||
const errors = require('../../../errors');
|
||||
const url = require('url');
|
||||
|
||||
const Comment = require('../../../models/comment');
|
||||
@@ -8,8 +9,6 @@ const User = require('../../../models/user');
|
||||
const Action = require('../../../models/action');
|
||||
const Asset = require('../../../models/asset');
|
||||
const Setting = require('../../../models/setting');
|
||||
const ErrInvalidAssetURL = new Error('asset_url is invalid');
|
||||
ErrInvalidAssetURL.status = 400;
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -20,7 +19,7 @@ router.get('/', (req, res, next) => {
|
||||
// Verify that the asset_url is parsable.
|
||||
let parsed_asset_url = url.parse(asset_url);
|
||||
if (!parsed_asset_url.protocol) {
|
||||
return next(ErrInvalidAssetURL);
|
||||
return next(errors.ErrInvalidAssetURL);
|
||||
}
|
||||
|
||||
// Get the asset_id for this url (or create it if it doesn't exist)
|
||||
@@ -49,20 +48,11 @@ router.get('/', (req, res, next) => {
|
||||
settings.merge(asset.settings);
|
||||
}
|
||||
|
||||
// Fetch the appropriate comments stream.
|
||||
let comments;
|
||||
|
||||
if (settings.moderation === 'pre') {
|
||||
comments = Comment.findAcceptedByAssetId(asset.id);
|
||||
} else {
|
||||
comments = Comment.findAcceptedAndNewByAssetId(asset.id);
|
||||
}
|
||||
|
||||
return Promise.all([
|
||||
|
||||
// This is the promised component... Fetch the comments based on the
|
||||
// moderation settings.
|
||||
comments,
|
||||
Comment.findByAssetIdWithStatuses(asset.id, [null, 'accepted']),
|
||||
|
||||
// Send back the reference to the asset.
|
||||
asset,
|
||||
|
||||
+90
-99
@@ -1,12 +1,8 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const User = require('../../../models/user');
|
||||
const Setting = require('../../../models/setting');
|
||||
const mailer = require('../../../services/mailer');
|
||||
const ejs = require('ejs');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const resetEmailFile = fs.readFileSync(path.resolve(__dirname, '../../../views/password-reset-email.ejs'));
|
||||
const resetEmailTemplate = ejs.compile(resetEmailFile.toString());
|
||||
const authorization = require('../../../middleware/authorization');
|
||||
|
||||
router.get('/', authorization.needed('admin'), (req, res, next) => {
|
||||
@@ -50,116 +46,78 @@ router.post('/:user_id/role', authorization.needed('admin'), (req, res, next) =>
|
||||
router.post('/:user_id/status', (req, res, next) => {
|
||||
User
|
||||
.setStatus(req.params.user_id, req.body.status, req.body.comment_id)
|
||||
.then(status => {
|
||||
res.json(status);
|
||||
.then((status) => {
|
||||
res.status(201).json(status);
|
||||
})
|
||||
.catch(next);
|
||||
});
|
||||
|
||||
// /**
|
||||
// * SendEmailConfirmation sends a confirmation email to the user.
|
||||
// * @param {Request} req express request object
|
||||
// * @param {String} email user email address
|
||||
// */
|
||||
|
||||
/**
|
||||
* SendEmailConfirmation sends a confirmation email to the user.
|
||||
* @param {ExpressApp} app the instance of the express app
|
||||
* @param {String} userID the id for the user to send the email to
|
||||
* @param {String} email the email for the user to send the email to
|
||||
*/
|
||||
const SendEmailConfirmation = (app, userID, email) => User
|
||||
.createEmailConfirmToken(userID, email)
|
||||
.then((token) => {
|
||||
return mailer.sendSimple({
|
||||
app, // needed to render the templates.
|
||||
template: 'email/email-confirm', // needed to know which template to render!
|
||||
locals: { // specifies the template locals.
|
||||
token,
|
||||
rootURL: process.env.TALK_ROOT_URL,
|
||||
email
|
||||
},
|
||||
subject: 'Email Confirmation',
|
||||
to: email
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/', (req, res, next) => {
|
||||
const {email, password, displayName} = req.body;
|
||||
const {
|
||||
email,
|
||||
password,
|
||||
displayName
|
||||
} = req.body;
|
||||
|
||||
User
|
||||
.createLocalUser(email, password, displayName)
|
||||
.then(user => {
|
||||
.then((user) => {
|
||||
|
||||
res.status(201).json(user);
|
||||
// Get the settings from the database to find out if we need to send an
|
||||
// email confirmation. The Front end will know about the
|
||||
// requireEmailConfirmation as it's included in the settings get endpoint.
|
||||
return Setting.retrieve().then(({requireEmailConfirmation = false}) => {
|
||||
|
||||
if (requireEmailConfirmation) {
|
||||
|
||||
SendEmailConfirmation(req.app, user.id, email)
|
||||
.then(() => {
|
||||
|
||||
// Then send back the user.
|
||||
res.status(201).json(user);
|
||||
});
|
||||
} else {
|
||||
|
||||
// We don't need to confirm the email, let's just send back the user!
|
||||
res.status(201).json(user);
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
next(err);
|
||||
});
|
||||
});
|
||||
|
||||
const ErrPasswordTooShort = new Error('password must be at least 8 characters');
|
||||
ErrPasswordTooShort.status = 400;
|
||||
router.post('/:user_id/actions', authorization.needed(), (req, res, next) => {
|
||||
|
||||
/**
|
||||
* expects 2 fields in the body of the request
|
||||
* 1) the token that was in the url of the email link {String}
|
||||
* 2) the new password {String}
|
||||
*/
|
||||
router.post('/update-password', (req, res, next) => {
|
||||
const {token, password} = req.body;
|
||||
|
||||
if (!password || password.length < 8) {
|
||||
return next(ErrPasswordTooShort);
|
||||
}
|
||||
|
||||
User.verifyPasswordResetToken(token)
|
||||
.then(user => {
|
||||
return User.changePassword(user.id, password);
|
||||
})
|
||||
.then(() => {
|
||||
res.status(204).end();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
|
||||
next(authorization.ErrNotAuthorized);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* this endpoint takes an email (username) and checks if it belongs to a User account
|
||||
* if it does, create a JWT and send an email
|
||||
*/
|
||||
router.post('/request-password-reset', (req, res, next) => {
|
||||
const {email} = req.body;
|
||||
|
||||
if (!email) {
|
||||
return next('you must submit an email when requesting a password.');
|
||||
}
|
||||
|
||||
User
|
||||
.createPasswordResetToken(email)
|
||||
.then(token => {
|
||||
if (token === null) {
|
||||
return Promise.resolve('the email was not found in the db.');
|
||||
}
|
||||
|
||||
const options = {
|
||||
subject: 'Password Reset Requested - Talk',
|
||||
from: process.env.TALK_SMTP_FROM_ADDRESS,
|
||||
to: email,
|
||||
html: resetEmailTemplate({
|
||||
token,
|
||||
|
||||
// probably more clear to explicitly pass this
|
||||
rootURL: process.env.TALK_ROOT_URL
|
||||
})
|
||||
};
|
||||
return mailer.sendSimple(options);
|
||||
})
|
||||
.then(() => {
|
||||
|
||||
// we want to send a 204 regardless of the user being found in the db
|
||||
// if we fail on missing emails, it would reveal if people are registered or not.
|
||||
res.status(204).end();
|
||||
})
|
||||
.catch((err) => {
|
||||
next(err);
|
||||
});
|
||||
});
|
||||
|
||||
router.put('/:user_id/bio', (req, res, next) => {
|
||||
const {user_id} = req.params;
|
||||
const {bio} = req.body;
|
||||
|
||||
if (!bio) {
|
||||
return next('You must submit a new bio');
|
||||
}
|
||||
|
||||
User
|
||||
.addBio(user_id, bio)
|
||||
.then(user => {
|
||||
res.json(user);
|
||||
})
|
||||
.catch((err) => {
|
||||
next(err);
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/:user_id/actions', authorization.needed(), (req, res, next) => {
|
||||
const {
|
||||
action_type,
|
||||
metadata
|
||||
@@ -175,4 +133,37 @@ router.post('/:user_id/actions', authorization.needed(), (req, res, next) => {
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/:user_id/email/confirm', authorization.needed('admin'), (req, res, next) => {
|
||||
const {
|
||||
user_id
|
||||
} = req.params;
|
||||
|
||||
User
|
||||
.findById(user_id)
|
||||
.then((user) => {
|
||||
if (!user) {
|
||||
res.status(404).end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the first local profile.
|
||||
let localProfile = user.profiles.find((profile) => profile.provider === 'local');
|
||||
|
||||
// If there was no local profile for the user, error out.
|
||||
if (!localProfile) {
|
||||
res.status(404).end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Send the email to the first local profile that was found.
|
||||
return SendEmailConfirmation(req.app, user.id, localProfile.id)
|
||||
.then(() => {
|
||||
res.status(204).end();
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
next(err);
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user