Compare commits

...
213 Commits
Author SHA1 Message Date
Riley Davis 54e5a18546 Merge pull request #250 from coralproject/registration-fix
found the bang
2017-01-19 14:31:34 -07:00
Riley Davis a7094efda6 Merge branch 'master' into registration-fix 2017-01-19 14:27:49 -07:00
David Erwin c207a59c18 Merge pull request #248 from coralproject/registration-fix
Registration fix
2017-01-19 16:24:49 -05:00
riley 9f4c30f466 found the bang 2017-01-19 14:23:57 -07:00
David Erwin fa53327571 Merge branch 'master' into registration-fix 2017-01-19 15:20:52 -05:00
Kim Gardner 70a1525058 Merge pull request #242 from coralproject/design-pass-adm
Design Pass Admin
2017-01-19 12:31:48 -05:00
Riley Davis 07e24496da allow uppercase displaynames at creation 2017-01-18 13:13:02 -07:00
Riley Davis f07026d6dd hide uppercase login 2017-01-18 12:57:07 -07:00
Belén Curcio 7140723570 Merge branch 'master' into design-pass-adm 2017-01-18 14:40:09 -03:00
Riley Davis b4ca90a05d Merge pull request #247 from coralproject/prevent-server-restart
add nodemon config to prevent server restart while developing front end
2017-01-18 10:14:34 -07:00
Riley Davis f9bc623764 make a hidden file 2017-01-18 10:10:47 -07:00
Riley Davis 810cc002c9 add nodemon config to prevent server restart while developing front end 2017-01-18 10:02:15 -07:00
David Erwin c03f85d4b6 Merge pull request #246 from coralproject/my-comment-fix-part-deux
fix my comments
2017-01-18 10:49:33 -05:00
Riley Davis 14fd5e4451 does a null check 2017-01-17 15:12:19 -07:00
Riley Davis 8588cab228 Merge pull request #245 from coralproject/revert-244-my-comments-fix
Revert "My Comment bug fix"
2017-01-17 14:59:37 -07:00
Riley Davis af71059948 Revert "My Comment bug fix" 2017-01-17 14:53:28 -07:00
Riley Davis dc866437ff Merge pull request #244 from coralproject/my-comments-fix
My Comment bug fix
2017-01-17 14:26:49 -07:00
Belen Curcio b5afd9cbeb çhanges 2017-01-17 16:39:05 -03:00
Belen Curcio 3640a93a2f Changes 2017-01-17 16:25:47 -03:00
Riley Davis 1b50cb06d4 gotta commit something to see changes 2017-01-17 11:38:15 -07:00
Belen Curcio 6d826e9115 Working Stream 2017-01-17 12:47:06 -03:00
David Erwin 5fff644e7d Merge pull request #230 from coralproject/bug-comment-number
Fixes comment number change when clicking the "Settings" tab.
2017-01-17 10:37:33 -05:00
David Erwin 249a8f78dc Merge branch 'master' into bug-comment-number 2017-01-17 10:03:34 -05:00
David Erwin 5408036eb3 Merge pull request #231 from coralproject/bugfix-flagged-actions
Removed 'rejected' and 'approved' comments from flagged queue.
2017-01-17 10:01:19 -05:00
David Erwin 04e3f12c87 Merge branch 'master' into bugfix-flagged-actions 2017-01-17 09:52:26 -05:00
David Erwin d5137ba1c1 Merge pull request #236 from coralproject/password-length-test
show password error when input length < 8
2017-01-17 09:51:34 -05:00
David Erwin e41acffcf0 Merge branch 'master' into password-length-test 2017-01-17 09:47:42 -05:00
Gabriela Rodríguez Berón 5a22e99282 Merge branch 'master' into bug-comment-number 2017-01-13 15:25:27 -08:00
Gabriela Rodríguez Berón 77b52a2454 Merge branch 'master' into bugfix-flagged-actions 2017-01-13 15:10:42 -08:00
Gabriela Rodríguez Berón 05180394c7 Merge pull request #234 from coralproject/preserve-comment-view-state
Adjusted stream to preserve comment view state
2017-01-13 15:10:01 -08:00
Gabriela Rodríguez Berón 4f013c50e6 Merge branch 'master' into bugfix-flagged-actions 2017-01-13 15:05:54 -08:00
Wyatt Johnson da0ca97483 Added comment for SIGUSR2 2017-01-13 16:02:49 -07:00
Gabriela Rodríguez Berón 53a403cddb Merge branch 'master' into preserve-comment-view-state 2017-01-13 14:29:50 -08:00
Gabriela Rodríguez Berón 06812edcd2 Merge pull request #218 from coralproject/unique-username
Unique username
2017-01-13 14:29:36 -08:00
Gabriela Rodríguez Berón 1accc54238 Merge branch 'master' into unique-username 2017-01-13 14:21:42 -08:00
Wyatt Johnson 05c055c3d2 Merge branch 'master' into preserve-comment-view-state 2017-01-13 15:20:49 -07:00
gaba 06ab33343f Errors needs to use APIError. 2017-01-13 14:15:31 -08:00
David Jay 3cc120cd25 Merge pull request #237 from coralproject/bizarro-world
do not render tab content unless they're active
2017-01-13 17:05:36 -05:00
Riley Davis 44a06aef46 do not render tab content unless they're active 2017-01-13 14:33:01 -07:00
Belen Curcio f0cc82cb6d Changes 2017-01-13 18:16:46 -03:00
David Jay 8632cd9b67 Merge pull request #233 from coralproject/my-comments-part-deux-the-commenting
don't show unpublished comments in My Comments
2017-01-13 16:15:50 -05:00
David Jay 5f58ca6b5d Merge branch 'master' into my-comments-part-deux-the-commenting 2017-01-13 16:08:39 -05:00
Belen Curcio cae101627f Merge branch 'master' of github.com:coralproject/talk into design-pass-adm 2017-01-13 17:14:51 -03:00
Belen Curcio e69262c665 Community Section Ready 2017-01-13 17:11:46 -03:00
Riley Davis 7295d78523 typos 2017-01-13 11:13:28 -07:00
Wyatt Johnson 041241d6b0 Merge branch 'master' into preserve-comment-view-state 2017-01-13 10:33:17 -07:00
Belen Curcio 9876e04454 Ban User box 2017-01-13 13:54:45 -03:00
Belen Curcio 712c53389b Banned Words and Suspect 2017-01-13 13:07:45 -03:00
Wyatt Johnson 9e5dbfd9cf Merge pull request #235 from coralproject/dev-env-docs
Updating docs for .env functionality
2017-01-13 08:29:50 -07:00
Wyatt Johnson 39a4d031c7 Adjusted for new wiki page 2017-01-13 08:24:12 -07:00
David Erwin 7c95781072 Update README.md 2017-01-13 10:21:12 -05:00
David Erwin f56e30c1a1 Updating docs for .env functionality 2017-01-13 10:08:35 -05:00
Wyatt Johnson c90230c115 Adjusted stream to preserve comment view state 2017-01-12 17:55:08 -07:00
Riley Davis ad0f530fd3 don't return unpublished comments in My Comments section. add tests 2017-01-12 17:07:35 -07:00
Wyatt Johnson 281a49dc74 Merge branch 'master' into bugfix-flagged-actions 2017-01-12 16:29:32 -07:00
Gabriela Rodríguez Berón 1cc048d46e Merge branch 'master' into unique-username 2017-01-12 14:53:13 -08:00
Riley Davis 20f90572f5 Merge pull request #228 from coralproject/pending-queue-routes
moderation queue routes
2017-01-12 15:49:44 -07:00
gaba 536a2165cc Merge branch 'master' into unique-username 2017-01-12 14:47:45 -08:00
gaba 1027b19a49 Added regex to look for the field that is duplicated. Not sure if there is a better way to do it. 2017-01-12 14:43:36 -08:00
Riley Davis 5136fce4a3 Merge branch 'master' into pending-queue-routes 2017-01-12 15:34:13 -07:00
David Jay f3f5397459 Removing rejected and approved comments from flagged feed. 2017-01-12 17:32:48 -05:00
Riley Davis 0eefd3364b update tests 2017-01-12 15:25:36 -07:00
David Jay 361be84562 Merge branch 'master' into bug-comment-number 2017-01-12 16:49:16 -05:00
David Jay 3e0d56b942 Keeping hydrated comments in assets in COMMENTS_BY_USER_REQUEST. 2017-01-12 16:41:50 -05:00
Wyatt Johnson 31b7dac48a Merge pull request #229 from coralproject/topic-220
Standardizes errors for API error returns
2017-01-12 14:33:58 -07:00
Belen Curcio ce01389809 Ádding Icon to Coral UI Button 2017-01-12 18:20:42 -03:00
Wyatt Johnson 6e89734e12 Moved errors to errors.js, std error msgs/types 2017-01-12 14:08:52 -07:00
Riley Davis 44b41b031e lint 2017-01-12 14:03:58 -07:00
Belen Curcio 87b350e3a9 Missing tab wrapper 2017-01-12 17:56:00 -03:00
Belen Curcio c375d11e32 Coral UI - List and Item 2017-01-12 17:47:45 -03:00
Riley Davis b66efb7f2a return flagged comments 2017-01-12 13:44:10 -07:00
Riley Davis f1272338cd re-use dispatch function 2017-01-12 13:30:53 -07:00
Riley Davis 3285a5ec29 add status to Comment. /comments/pending and /comments/rejected ONLY return the respective comments 2017-01-12 12:16:13 -07:00
gaba acf3ad5e28 Some conflicts were added. 2017-01-12 11:10:07 -08:00
gaba 15e7ef6368 Adds an error const. 2017-01-12 10:46:18 -08:00
gaba 873ec09f62 Merge branch 'master' into unique-username 2017-01-12 08:46:51 -08:00
Belen Curcio 3d65231db5 Progress 2017-01-12 13:27:37 -03:00
Belen Curcio 4a4affad70 More Design Tweaks 2017-01-12 09:16:23 -03:00
Belen Curcio 7101d99187 Merge branch 'master' of github.com:coralproject/talk into design-pass-adm 2017-01-12 08:09:55 -03:00
Riley Davis 18b35e38a8 Merge pull request #227 from coralproject/flagged-queue-bugfix
Fix flagged comment queue bug
2017-01-11 14:20:03 -07:00
Wyatt Johnson 6ca7e92171 Merge branch 'master' into flagged-queue-bugfix 2017-01-11 13:48:09 -07:00
David Jay 09e3889793 Updating action type from to for consistency. 2017-01-11 15:42:56 -05:00
David Jay 2a1ea01827 Merge pull request #226 from coralproject/dev-start
Added dev-start and --config flag
2017-01-11 15:14:59 -05:00
Wyatt Johnson 10aaa037dc Added dev-start and --config flag 2017-01-11 11:37:06 -07:00
Wyatt Johnson 5f215a95f9 Merge pull request #222 from coralproject/hide-passwords
Cleaned user auth response
2017-01-11 10:22:28 -07:00
Wyatt Johnson 7a21879628 Merge branch 'master' into hide-passwords 2017-01-10 14:53:26 -07:00
Kim Gardner 2e4256bf4a Merge pull request #223 from coralproject/users-bug-fix
Making user action item_type 'users' for consistency.
2017-01-10 16:11:22 -05:00
David Jay 11f18c23cb Merge branch 'master' into users-bug-fix 2017-01-10 14:13:31 -05:00
Belen Curcio 3a510168d3 design progress 2017-01-10 16:12:15 -03:00
Wyatt Johnson 8519d07c67 Cleaned user auth response 2017-01-10 12:00:06 -07:00
David Jay d9147da8fc Making user action item_type 'users' for consistency. 2017-01-10 13:59:36 -05:00
Kim Gardner e87a471eb9 Merge pull request #201 from coralproject/frontenddocs
Frontend Docs
2017-01-10 11:44:03 -05:00
Kim Gardner 6cf4011cde Merge branch 'master' into frontenddocs 2017-01-10 11:40:03 -05:00
Belen Curcio ca369dcb4a Header version 2017-01-10 12:01:48 -03:00
Belen Curcio a4e6638217 Tab bar, removing extra mdl js. It adds more events 2017-01-10 10:55:03 -03:00
Riley Davis f76177c1db Merge pull request #214 from coralproject/bad-words
Bad usernames
2017-01-09 18:13:18 -07:00
Wyatt Johnson 7283feab6b Merge branch 'master' into bad-words 2017-01-09 16:44:19 -07:00
David Jay 296b941ee8 Merge pull request #221 from coralproject/email-confirm
Email Confirmation Resend Endpoint + Fixes to mailer
2017-01-09 18:19:53 -05:00
Wyatt Johnson 586ce941e5 Merge branch 'master' into email-confirm 2017-01-09 16:10:46 -07:00
Wyatt Johnson ec3a114e65 Added missing endpoint + fix for mailer 2017-01-09 15:50:46 -07:00
Riley Davis b6eaf210fe don't use regex on existing lists 2017-01-09 15:41:47 -07:00
Belen Curcio be293a03de Header 2017-01-09 19:17:21 -03:00
Belen Curcio 4f4ecd8dfb header 2017-01-09 18:07:02 -03:00
riley 7bc848b801 make tests pass 2017-01-09 10:47:28 -07:00
Belen Curcio 226af72ac9 Adding Roboto 2017-01-09 12:54:28 -03:00
Belen Curcio 1acc92a1f2 Header Changes 2017-01-09 12:45:41 -03:00
gaba 976f0762b3 Password lenght of 8. 2017-01-06 17:42:42 -03:00
Riley Davis bba597f086 even more lint 2017-01-06 13:28:48 -07:00
Riley Davis abd2332745 lint 2017-01-06 13:23:51 -07:00
Riley Davis a3bb8e2f41 remove logs. missed some errors 2017-01-06 13:11:37 -07:00
Riley Davis 01db900c40 move defined errors into top-level errors.js file 2017-01-06 13:00:22 -07:00
gaba 4dbc73f9ab Add a test on already used display name. 2017-01-06 16:57:25 -03:00
Riley Davis 6a61f47ddf Merged branch master into bad-words 2017-01-06 12:45:41 -07:00
gaba 712e436f58 Cleans error on email address or name. We need to go through all the errors and manage them better with translations. 2017-01-06 16:38:44 -03:00
Riley Davis 72cd78da8c use wordlist service 2017-01-06 12:35:08 -07:00
gaba 8a1eedb0b2 Merge branch 'master' into unique-username 2017-01-06 14:12:46 -03:00
Wyatt Johnson bf04034085 Merge pull request #213 from coralproject/issue-191
Banned users should not be able to report/flag anything from the Front End
2017-01-06 10:03:14 -07:00
Gabriela Rodríguez Berón 1e445efaec Merge branch 'master' into issue-191 2017-01-06 13:33:25 -03:00
Gabriela Rodríguez Berón 466cb289c3 Merge pull request #215 from coralproject/email-confirm
Email Confirmation and Fixes
2017-01-06 13:31:40 -03:00
Wyatt Johnson 4c5b85a2ae Fixed session bug 2017-01-05 19:02:06 -07:00
Wyatt Johnson 4eb7c72ae2 Merge branch 'master' into email-confirm 2017-01-05 18:45:06 -07:00
Wyatt Johnson cabe546ecf Added email send on new user if enabled for confirming 2017-01-05 17:19:38 -07:00
Wyatt Johnson be72ebf2a1 Added tests + model implementation. 2017-01-05 17:01:41 -07:00
Riley Davis 368b431893 pre-validate usernames on the fe 2017-01-05 15:26:42 -07:00
gaba d9ea5977cd Unique displayName for the User. 2017-01-05 18:24:05 -03:00
Riley Davis 7e7e8891b4 display errors when trying to sign up with a bad username 2017-01-05 12:47:39 -07:00
gaba ac6fba2286 Clean user state on logout. Allow flagging only if not banned. 2017-01-05 15:34:27 -03:00
gaba ab825512b9 Adding missing translation. Not sure how to translate streams. 2017-01-04 16:46:19 -03:00
Riley Davis ce46b4dbe0 Merge pull request #212 from coralproject/add-csurf-and-cookie
Moves the packages to dependencies.
2017-01-04 13:32:37 -06:00
gaba 864b08135a Moves the packages to dependencies. 2017-01-04 16:24:17 -03:00
Gabriela Rodríguez Berón 7d5be04be3 Merge pull request #210 from coralproject/init-settings
fetch settings on mod queue load
2017-01-04 15:52:32 -03:00
Riley Davis 12f6bb1ffe fetch settings on mod queue load 2017-01-04 11:14:23 -07:00
Gabriela Rodríguez Berón 57f9fd8474 Merge pull request #204 from coralproject/csrf
CSRF
2017-01-04 14:32:12 -03:00
Wyatt Johnson 298e1e8d73 Updates to user cli + e2e + tests
- Updates to before + beforeEach for mongooose
- Removed reference to dotenv from cli in e2e, should use
  NODE_ENV=test instead.
- Changed test port from 30?? to 3000 to be consistent with what
  nightwatch was expecting
2017-01-04 10:16:51 -07:00
gaba 5f7d48a7f9 This was not needed. 2017-01-04 13:17:16 -03:00
gaba 3718efce1e Adds cookie parser 2017-01-04 12:47:19 -03:00
gaba ccb9fe3165 Adds DELETE and PUT. 2017-01-04 12:40:25 -03:00
gaba 7b9dbb5afd Review changes. 2017-01-04 12:24:01 -03:00
Gabriela Rodríguez Berón 2755cc61e4 Merge branch 'master' into csrf 2017-01-04 10:56:37 -03:00
Wyatt Johnson a7680ae5d9 Initial pass at email confirmation 2017-01-03 17:35:58 -07:00
gaba 0fd6f4b44e Working on the tests & sending CSRF 2017-01-03 18:01:21 -03:00
gaba e2a032d4a5 Merge branch 'master' into csrf 2017-01-03 16:25:05 -03:00
Riley Davis 7363fcc5dc Merge pull request #205 from coralproject/bug_203
Fixes Bug #203
2017-01-03 12:47:49 -06:00
Riley Davis d4dc381aca Merge branch 'master' into bug_203 2017-01-03 12:29:48 -06:00
Gabriela Rodríguez Berón 6e54ba7f4c Merge pull request #209 from coralproject/admin-not-loading
make the admin screen load
2017-01-03 15:29:17 -03:00
gaba 950340d5e7 Fix lint. 2017-01-03 15:18:39 -03:00
Gabriela Rodríguez Berón 43846651dd Merge branch 'master' into bug_203 2017-01-03 14:33:57 -03:00
Riley Davis a1def6f950 missing state for the wordlist. 2017-01-03 10:33:25 -07:00
Gabriela Rodríguez Berón b7380978f5 Merge pull request #206 from coralproject/open-source-docs
Open source docs
2017-01-03 12:47:06 -03:00
David Erwin 5e4564c7ea Merge branch 'master' into open-source-docs 2017-01-03 10:29:41 -05:00
Gabriela Rodríguez Berón f5374dc914 Merge pull request #207 from coralproject/fix-test
Fix test on Suspected words
2017-01-03 11:45:30 -03:00
Wyatt Johnson 0dde122e9b Merge branch 'master' into fix-test 2017-01-03 07:41:44 -07:00
Gabriela Rodríguez Berón fd2b01d5ed Merge pull request #208 from coralproject/fix/dialog-unmount
Check if dialog is present before removing listeners
2017-01-03 11:40:31 -03:00
Dan Zajdband 759e74a88d coral-admin: Fixed check for component unmount removing a listener when there is no object 2017-01-03 10:46:55 -03:00
gaba 5c6df6d481 Fix tests when flagging because suspect word. 2017-01-03 09:37:02 -03:00
gaba a02ad69568 Adds specific fields to the metadata in the action when flagging because suspect word. 2017-01-03 09:36:20 -03:00
gaba 111e83a1e6 Removes plugin csrf 2017-01-03 08:41:28 -03:00
gaba 036ed8ac78 Removes wrong csrf. 2017-01-03 08:41:28 -03:00
David Erwin f786daaec9 Merged branch open-source-docs into open-source-docs 2016-12-27 11:50:42 -05:00
David Erwin 9a608fd02e Spell stuff right and clean up 2016-12-27 11:50:34 -05:00
David Erwin 1ca6736146 Merge branch 'master' into open-source-docs 2016-12-27 11:46:54 -05:00
David Erwin 349f8003a7 Merge branch 'master' into csrf 2016-12-27 11:46:42 -05:00
David Erwin c86148c0f1 Add links to INSTALL.md 2016-12-27 11:39:54 -05:00
David Erwin b0e4b79a6a Merge pull request #197 from coralproject/addINSTALL
Adds a very simple INSTALL file for developers.
2016-12-27 11:33:58 -05:00
David Erwin da37bb32f1 Updating for style and completeness 2016-12-27 11:28:01 -05:00
David Erwin 0219f1916e Link contributing.md from readme.md 2016-12-27 10:59:08 -05:00
David Erwin ef40cf37fe Update contribution guide 2016-12-27 10:55:16 -05:00
gaba d35875145c Changes field for metadata. Needs to look at this test better. 2016-12-23 15:38:45 -08:00
gaba 8e5e75ed6f It works for user signin/signup and posting comments. 2016-12-23 14:57:39 -08:00
gaba 6892ef85f8 Sending the CSRF to the POST request. 2016-12-23 13:56:42 -08:00
Kim Gardner 8b5b9e856b Merge branch 'master' into addINSTALL 2016-12-23 16:13:36 -05:00
Kim Gardner ac66a7bb0e Merge branch 'master' into csrf 2016-12-23 16:12:53 -05:00
Belén Curcio 6052cac1d3 Merge pull request #196 from coralproject/map-dispatch-mod-queue
create mapStateToProps in ModerationQueue
2016-12-23 18:12:32 -03:00
Kim Gardner 93c5e336b2 Merge branch 'master' into addINSTALL 2016-12-23 16:12:18 -05:00
gaba 8e6aa23d32 More CSRF in front-end. 2016-12-23 13:07:16 -08:00
Belen Curcio 8d992f7809 merg 2016-12-23 16:13:36 -03:00
Belen Curcio 168248f975 Presentational and Container Components added 2016-12-23 16:09:46 -03:00
Belén Curcio 964c1cb3d2 Linting 2016-12-23 12:06:40 -03:00
Belén Curcio 9766215231 Rename FRONTEND.md to README.md 2016-12-23 11:51:46 -03:00
Belén Curcio 3b1731e809 Update FRONTEND.md 2016-12-23 10:56:15 -03:00
Belén Curcio 1538690cad Update FRONTEND.md 2016-12-23 10:55:30 -03:00
Belén Curcio ca9ef44202 links 2016-12-23 10:52:32 -03:00
Belén Curcio 3dfa4969e5 Update IMMUTABLEJS.md 2016-12-23 10:50:16 -03:00
Belén Curcio 92a8fc5a93 links 2016-12-23 10:49:45 -03:00
Belén Curcio 018447f00e Update DEBUG.md 2016-12-23 10:43:59 -03:00
Belen Curcio b36109f0a1 Áddind debug 2016-12-23 10:39:03 -03:00
gaba ec3e9d1cd4 Merge branch 'master' into csrf 2016-12-22 14:23:01 -08:00
gaba 54d0096dd1 More CSRF in forms. 2016-12-22 14:17:09 -08:00
Riley Davis 6cca99e75e Merge branch 'master' into map-dispatch-mod-queue 2016-12-22 12:25:51 -07:00
Riley Davis 92b51ef1fe Merge branch 'master' into addINSTALL 2016-12-22 12:25:08 -07:00
gaba 39222759f9 Adds links between README and INSTALL. 2016-12-22 08:31:00 -08:00
gaba 075fe7f6b4 Merge branch 'master' into csrf 2016-12-21 16:03:59 -08:00
gaba 54bad908b3 Apply changes from review. 2016-12-21 16:01:16 -08:00
gaba 7f86c936ad Adds a very simple INSTALL file for devs. 2016-12-21 13:17:04 -08:00
Riley Davis 50112d3166 create mapStateToProps 2016-12-21 12:22:54 -07:00
gaba 7562edc168 Removes console log. 2016-12-21 08:52:39 -08:00
gaba 29250f2bbc Fixed unpromised rejection in test. 2016-12-20 18:01:28 -08:00
gaba 9c9597ae8c Persist the session in tests through chai agent. 2016-12-20 17:52:55 -08:00
gaba 16a4daaeb3 Merge branch 'master' into csrf 2016-12-20 13:46:08 -08:00
gaba e16bc8f7ad Adds agent to persist session in tests. 2016-12-20 12:18:30 -08:00
gaba f49ca9f89a PAss the token around. 2016-12-19 18:11:11 -08:00
gaba 4172066c94 Silly commit. 2016-12-16 19:44:01 -08:00
gaba dd032521c0 Merge branch 'master' into csrf 2016-12-16 17:55:49 -08:00
gaba d8d30e512b Only on POST check for CSRF. 2016-12-16 16:02:45 -08:00
gaba b75408b067 Merge branch 'master' into csrf 2016-12-15 15:11:30 -08:00
gaba 670567b8b3 One more expect in the test. 2016-12-15 15:08:01 -08:00
gaba eb98a41bd5 Merge branch 'master' into csrf 2016-12-15 15:04:36 -08:00
gaba 7b8131d4b0 Use session instead of cookies. 2016-12-15 15:01:05 -08:00
gaba 4a76e43309 Merge branch 'master' into csrf 2016-12-15 14:25:08 -08:00
gaba 70c86bf8f5 Merge branch 'master' into csrf 2016-12-15 13:54:22 -08:00
gaba e76d6e4694 Merge branch 'master' into csrf 2016-12-15 13:23:18 -08:00
gaba 369ed3fc29 Adds csrf protection to some routes. 2016-12-15 13:12:03 -08:00
gaba fc042779c2 Adds csurf to and cookie-parser to manage CRSF protection. 2016-12-15 12:56:34 -08:00
133 changed files with 3937 additions and 1463 deletions
+2
View File
@@ -11,3 +11,5 @@ dump.rdb
.env
gaba.cfg
.idea/
coverage/
yarn.lock
+4
View File
@@ -0,0 +1,4 @@
{
"verbose": true,
"ignore": ["tests/*", "client/*", "dist/*"]
}
+52 -69
View File
@@ -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
View File
@@ -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.
+23 -16
View File
@@ -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
+51 -17
View File
@@ -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;
+20 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+2 -2
View File
@@ -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} />
+3 -3
View File
@@ -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)));
};
+42 -11
View File
@@ -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}));
};
+3 -2
View File
@@ -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});
+1 -1
View File
@@ -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;
+16 -16
View File
@@ -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) {
+41 -17
View File
@@ -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;
}
+19 -12
View File
@@ -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;
}
+1 -1
View File
@@ -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
View File
@@ -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>;
);
}
}
+1 -1
View File
@@ -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) => {
+5 -4
View File
@@ -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];
+12 -9
View File
@@ -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)));
};
+6 -2
View File
@@ -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;
});
};
}
+16 -10
View File
@@ -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}));
};
};
+1
View File
@@ -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';
+1
View File
@@ -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';
+22 -2
View File
@@ -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 {
+1 -1
View File
@@ -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))
};
+3
View File
@@ -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);
+3 -2
View File
@@ -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;
}
+21 -5
View File
@@ -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"
}
}
}
+3 -3
View File
@@ -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>
+1 -1
View File
@@ -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);
+8
View File
@@ -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"
+4
View File
@@ -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'
}
}
+9 -4
View File
@@ -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);
}
+5 -3
View File
@@ -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>
);
+35
View File
@@ -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);
}
+8
View File
@@ -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>
);
+8
View File
@@ -0,0 +1,8 @@
.base {
height: 30px;
width : 30px;
}
.mark {
stroke: #FFFFFF;
}
+4 -3
View File
@@ -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'>
+3 -1
View File
@@ -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() {
+20 -4
View File
@@ -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;
}
+1 -1
View File
@@ -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>
);
+8
View File
@@ -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;
+24
View File
@@ -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;
}
}
+13
View File
@@ -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>
);
+4
View File
@@ -0,0 +1,4 @@
.base {
padding: 0;
margin: 0;
}
+32
View File
@@ -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>
);
}
}
+14 -5
View File
@@ -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 -4
View File
@@ -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 -1
View File
@@ -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);
+5
View File
@@ -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';
+22
View File
@@ -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)
+390
View File
@@ -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 Talks 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 its 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 wouldnt 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 doesnt 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)
+121
View File
@@ -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 dont 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
+223
View File
@@ -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 dont 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 its important to that you:
- Dont 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 its 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?
Its 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.
Its 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)
+123
View File
@@ -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
View File
@@ -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
+129
View File
@@ -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
};
+1 -1
View File
@@ -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: []}})
]);
+1 -11
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+133
View File
@@ -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
View File
@@ -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!
+9 -8
View File
@@ -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);
}
+1
View File
@@ -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
View File
@@ -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);
+3 -13
View File
@@ -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
View File
@@ -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