17 Commits

Author SHA1 Message Date
Riccardo Graziosi
a12a95eccc Add webhooks (#447) 2024-12-20 14:06:48 +01:00
Riccardo Graziosi
2290cff507 Fix frontend regex for email address (#449) 2024-12-10 11:51:28 +01:00
Riccardo Graziosi
17c3e621b9 Fix Sidekiq Cron initializer (#443) 2024-11-27 15:46:33 +01:00
Riccardo Graziosi
87b267998b Fix sidekiq cron SendRecapEmails job scheduling creation (#442) 2024-11-25 18:56:24 +01:00
Riccardo Graziosi
9b57df60a4 Fix get_url_for helper method (#441) 2024-11-25 17:31:46 +01:00
Riccardo Graziosi
c0d70186f6 Send recap emails for new feedback (#440) 2024-11-19 17:17:05 +01:00
dependabot[bot]
ace50e1089 Bump cross-spawn from 7.0.3 to 7.0.5 (#439)
Bumps [cross-spawn](https://github.com/moxystudio/node-cross-spawn) from 7.0.3 to 7.0.5.
- [Changelog](https://github.com/moxystudio/node-cross-spawn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/moxystudio/node-cross-spawn/compare/v7.0.3...v7.0.5)

---
updated-dependencies:
- dependency-name: cross-spawn
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-11-16 14:58:11 +01:00
Riccardo Graziosi
fb441564b8 Add sidekiq as a possible ActiveJob backend (#436) 2024-11-16 14:25:27 +01:00
Riccardo Graziosi
8dd5ca4e2a Add possibility to enter promo code in Stripe checkout (#437) 2024-11-15 17:46:05 +01:00
Riccardo Graziosi
b180886ce0 Create FUNDING.yml (#435) 2024-11-12 20:30:16 +01:00
Aditya Pandey
721e6a3a43 Make invitations expire after 3 months (#426)
Co-authored-by: riggraz <riccardo.graziosi97@gmail.com>
2024-11-11 19:16:43 +01:00
Riccardo Graziosi
054633404c Bump rails to 6.1.7.9 (#433) 2024-11-08 17:19:14 +01:00
dependabot[bot]
30dc40e58d Bump rexml from 3.3.6 to 3.3.9 (#430)
Bumps [rexml](https://github.com/ruby/rexml) from 3.3.6 to 3.3.9.
- [Release notes](https://github.com/ruby/rexml/releases)
- [Changelog](https://github.com/ruby/rexml/blob/master/NEWS.md)
- [Commits](https://github.com/ruby/rexml/compare/v3.3.6...v3.3.9)

---
updated-dependencies:
- dependency-name: rexml
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Riccardo Graziosi <31478034+riggraz@users.noreply.github.com>
2024-11-08 17:07:29 +01:00
Riccardo Graziosi
697f1ac6c4 Fix Stripe subscription update webhook and tenant notification logic (#432)
* Do not send emails for stripe custom.subscription.updated webhook event
* Do not send "mid" trial period email if tenant trial period has been extended
2024-11-08 17:02:40 +01:00
Riccardo Graziosi
31999a2af6 Add API (#427) 2024-11-08 16:40:53 +01:00
Aditya Pandey
5ad04adb10 Redirect to previous page after logging in (#423)
Co-authored-by: riggraz <riccardo.graziosi97@gmail.com>
2024-10-04 17:36:39 +02:00
Riccardo Graziosi
20f93736f5 Improve UI/UX of Post page (#416)
* Show post content and likes before fetching from backend API
* Autofocus reply and edit forms for comments
* Autofocus title field in post edit form
* More UI/UX improvements
2024-09-26 19:45:48 +02:00
181 changed files with 8122 additions and 2609 deletions

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
github: riggraz

View File

@@ -15,7 +15,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Build Docker production image
run: docker compose -f docker-compose.yml -f docker-compose-prod.yml build --build-arg ENVIRONMENT=production

3
.gitignore vendored
View File

@@ -41,3 +41,6 @@ yarn-debug.log*
/app/assets/builds/*
!/app/assets/builds/.keep
# Ignore Swagger spec file
/swagger/*

View File

@@ -37,9 +37,12 @@ RUN yarn install --check-files
# Copy all files
COPY . ${APP_ROOT}/
# Build Swagger API documentation
RUN RSWAG_SWAGGERIZE=true RAILS_ENV=test bundle exec rake rswag:specs:swaggerize
# Compile assets if production
# SECRET_KEY_BASE=1 is a workaround (see https://github.com/rails/rails/issues/32947)
RUN if [ "$ENVIRONMENT" = "production" ]; then RAILS_ENV=development ./bin/rails assets:precompile; fi
RUN if [ "$ENVIRONMENT" = "production" ]; then SECRET_KEY_BASE=1 ./bin/rails assets:precompile; fi
###
### Dev stage ###
@@ -91,6 +94,9 @@ COPY --from=builder ${APP_ROOT}/Rakefile ${APP_ROOT}/
COPY --from=builder ${APP_ROOT}/lib/tasks/ ${APP_ROOT}/lib/tasks/
COPY --from=builder /usr/local/bundle/config /usr/local/bundle/config
# Copy Swagger API documentation
COPY --from=builder ${APP_ROOT}/swagger/ ${APP_ROOT}/swagger/
ENTRYPOINT ["./docker-entrypoint-prod.sh"]
EXPOSE 3000

36
Gemfile
View File

@@ -3,9 +3,9 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" }
ruby '3.0.6'
gem 'rake', '12.3.3'
gem 'rake', '13.2.1'
gem 'rails', '6.1.7.8'
gem 'rails', '6.1.7.9'
gem 'pg', '1.3.5'
@@ -50,10 +50,29 @@ gem 'friendly_id', '5.5.1'
# Billing
gem 'stripe', '11.2.0'
# Serve swagger docs
gem 'rswag-api', '2.15.0'
# We need those gems here, so we can Swaggerize in production
gem 'rswag-specs', '2.15.0'
gem 'rspec-rails', '4.0.2'
gem 'capybara', '3.40.0'
# CORS policy
gem 'rack-cors', '2.0.2'
# ActiveJob backend
gem 'sidekiq', '7.3.5'
# Cron jobs with sidekiq
gem 'sidekiq-cron', '2.0.1'
# Template language
gem 'liquid', '5.5.1'
group :development, :test do
gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]
gem 'rspec-rails', '4.0.2'
gem 'factory_bot_rails', '5.0.2'
end
@@ -64,11 +83,10 @@ group :development do
end
group :test do
# Adds support for Capybara system testing and selenium driver
gem 'capybara', '3.36.0'
gem 'selenium-webdriver', '4.1.0'
# Easy installation and use of web drivers to run system tests with browsers
gem 'webdrivers', '5.3.1'
gem 'selenium-webdriver', '4.17.0'
# Retry flaky Capybara tests
gem 'rspec-retry', '0.6.2'
end
# If not bundled, webpack compilation in production fails

View File

@@ -1,116 +1,124 @@
GEM
remote: https://rubygems.org/
specs:
actioncable (6.1.7.8)
actionpack (= 6.1.7.8)
activesupport (= 6.1.7.8)
actioncable (6.1.7.9)
actionpack (= 6.1.7.9)
activesupport (= 6.1.7.9)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailbox (6.1.7.8)
actionpack (= 6.1.7.8)
activejob (= 6.1.7.8)
activerecord (= 6.1.7.8)
activestorage (= 6.1.7.8)
activesupport (= 6.1.7.8)
actionmailbox (6.1.7.9)
actionpack (= 6.1.7.9)
activejob (= 6.1.7.9)
activerecord (= 6.1.7.9)
activestorage (= 6.1.7.9)
activesupport (= 6.1.7.9)
mail (>= 2.7.1)
actionmailer (6.1.7.8)
actionpack (= 6.1.7.8)
actionview (= 6.1.7.8)
activejob (= 6.1.7.8)
activesupport (= 6.1.7.8)
actionmailer (6.1.7.9)
actionpack (= 6.1.7.9)
actionview (= 6.1.7.9)
activejob (= 6.1.7.9)
activesupport (= 6.1.7.9)
mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0)
actionpack (6.1.7.8)
actionview (= 6.1.7.8)
activesupport (= 6.1.7.8)
actionpack (6.1.7.9)
actionview (= 6.1.7.9)
activesupport (= 6.1.7.9)
rack (~> 2.0, >= 2.0.9)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (6.1.7.8)
actionpack (= 6.1.7.8)
activerecord (= 6.1.7.8)
activestorage (= 6.1.7.8)
activesupport (= 6.1.7.8)
actiontext (6.1.7.9)
actionpack (= 6.1.7.9)
activerecord (= 6.1.7.9)
activestorage (= 6.1.7.9)
activesupport (= 6.1.7.9)
nokogiri (>= 1.8.5)
actionview (6.1.7.8)
activesupport (= 6.1.7.8)
actionview (6.1.7.9)
activesupport (= 6.1.7.9)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
activejob (6.1.7.8)
activesupport (= 6.1.7.8)
activejob (6.1.7.9)
activesupport (= 6.1.7.9)
globalid (>= 0.3.6)
activemodel (6.1.7.8)
activesupport (= 6.1.7.8)
activerecord (6.1.7.8)
activemodel (= 6.1.7.8)
activesupport (= 6.1.7.8)
activestorage (6.1.7.8)
actionpack (= 6.1.7.8)
activejob (= 6.1.7.8)
activerecord (= 6.1.7.8)
activesupport (= 6.1.7.8)
activemodel (6.1.7.9)
activesupport (= 6.1.7.9)
activerecord (6.1.7.9)
activemodel (= 6.1.7.9)
activesupport (= 6.1.7.9)
activestorage (6.1.7.9)
actionpack (= 6.1.7.9)
activejob (= 6.1.7.9)
activerecord (= 6.1.7.9)
activesupport (= 6.1.7.9)
marcel (~> 1.0)
mini_mime (>= 1.1.0)
activesupport (6.1.7.8)
activesupport (6.1.7.9)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
tzinfo (~> 2.0)
zeitwerk (~> 2.3)
addressable (2.8.0)
public_suffix (>= 2.0.2, < 5.0)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
babel-source (5.8.35)
babel-transpiler (0.7.0)
babel-source (>= 4.0, < 6)
execjs (~> 2.0)
bcrypt (3.1.18)
base64 (0.2.0)
bcrypt (3.1.20)
bindex (0.8.1)
bootsnap (1.12.0)
msgpack (~> 1.2)
builder (3.3.0)
byebug (11.1.3)
capybara (3.36.0)
capybara (3.40.0)
addressable
matrix
mini_mime (>= 0.1.3)
nokogiri (~> 1.8)
nokogiri (~> 1.11)
rack (>= 1.6.0)
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
childprocess (4.1.0)
concurrent-ruby (1.3.3)
connection_pool (2.2.5)
concurrent-ruby (1.3.4)
connection_pool (2.4.1)
crass (1.0.6)
cronex (0.15.0)
tzinfo
unicode (>= 0.4.4.5)
cssbundling-rails (1.1.2)
railties (>= 6.0.0)
date (3.3.4)
date (3.4.0)
devise (4.7.3)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
railties (>= 4.1.0)
responders
warden (~> 1.2.3)
diff-lcs (1.5.0)
diff-lcs (1.5.1)
erubi (1.13.0)
execjs (2.8.1)
et-orbi (1.2.11)
tzinfo
execjs (2.10.0)
factory_bot (5.0.2)
activesupport (>= 4.2.0)
factory_bot_rails (5.0.2)
factory_bot (~> 5.0.2)
railties (>= 4.2.0)
ffi (1.15.5)
ffi (1.17.0)
friendly_id (5.5.1)
activerecord (>= 4.0.0)
fugit (1.11.1)
et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4)
globalid (1.2.1)
activesupport (>= 6.1)
httparty (0.21.0)
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
i18n (1.14.5)
i18n (1.14.6)
concurrent-ruby (~> 1.0)
i18n-js (3.9.2)
i18n (>= 0.6.6)
@@ -119,6 +127,8 @@ GEM
activesupport (>= 5.0.0)
jsbundling-rails (1.1.1)
railties (>= 6.0.0)
json-schema (5.0.1)
addressable (~> 2.8)
kaminari (1.2.2)
activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.2)
@@ -131,10 +141,12 @@ GEM
activerecord
kaminari-core (= 1.2.2)
kaminari-core (1.2.2)
liquid (5.5.1)
listen (3.5.1)
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
loofah (2.22.0)
logger (1.6.1)
loofah (2.23.1)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
@@ -147,10 +159,10 @@ GEM
method_source (1.1.0)
mini_mime (1.1.5)
mini_portile2 (2.8.7)
minitest (5.24.1)
msgpack (1.5.2)
minitest (5.25.1)
msgpack (1.7.5)
multi_xml (0.6.0)
net-imap (0.4.14)
net-imap (0.4.18)
date
net-protocol
net-pop (0.1.2)
@@ -159,37 +171,40 @@ GEM
timeout
net-smtp (0.5.0)
net-protocol
nio4r (2.7.3)
nokogiri (1.16.6)
nio4r (2.7.4)
nokogiri (1.16.7)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
orm_adapter (0.5.0)
pg (1.3.5)
public_suffix (4.0.7)
public_suffix (6.0.1)
puma (5.6.9)
nio4r (~> 2.0)
pundit (2.2.0)
activesupport (>= 3.0.0)
racc (1.8.0)
rack (2.2.9)
raabro (1.4.0)
racc (1.8.1)
rack (2.2.10)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-cors (2.0.2)
rack (>= 2.0.0)
rack-test (2.1.0)
rack (>= 1.3)
rails (6.1.7.8)
actioncable (= 6.1.7.8)
actionmailbox (= 6.1.7.8)
actionmailer (= 6.1.7.8)
actionpack (= 6.1.7.8)
actiontext (= 6.1.7.8)
actionview (= 6.1.7.8)
activejob (= 6.1.7.8)
activemodel (= 6.1.7.8)
activerecord (= 6.1.7.8)
activestorage (= 6.1.7.8)
activesupport (= 6.1.7.8)
rails (6.1.7.9)
actioncable (= 6.1.7.9)
actionmailbox (= 6.1.7.9)
actionmailer (= 6.1.7.9)
actionpack (= 6.1.7.9)
actiontext (= 6.1.7.9)
actionview (= 6.1.7.9)
activejob (= 6.1.7.9)
activemodel (= 6.1.7.9)
activerecord (= 6.1.7.9)
activestorage (= 6.1.7.9)
activesupport (= 6.1.7.9)
bundler (>= 1.15.0)
railties (= 6.1.7.8)
railties (= 6.1.7.9)
sprockets-rails (>= 2.0.0)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
@@ -198,15 +213,15 @@ GEM
rails-html-sanitizer (1.6.0)
loofah (~> 2.21)
nokogiri (~> 1.14)
railties (6.1.7.8)
actionpack (= 6.1.7.8)
activesupport (= 6.1.7.8)
railties (6.1.7.9)
actionpack (= 6.1.7.9)
activesupport (= 6.1.7.9)
method_source
rake (>= 12.2)
thor (~> 1.0)
rake (12.3.3)
rake (13.2.1)
rb-fsevent (0.11.2)
rb-inotify (0.10.1)
rb-inotify (0.11.1)
ffi (~> 1.0)
react-rails (2.6.2)
babel-transpiler (>= 0.7.0)
@@ -214,20 +229,21 @@ GEM
execjs
railties (>= 3.2)
tilt
regexp_parser (2.5.0)
responders (3.0.1)
actionpack (>= 5.0)
railties (>= 5.0)
rexml (3.3.6)
strscan
rspec-core (3.12.2)
rspec-support (~> 3.12.0)
rspec-expectations (3.12.3)
redis-client (0.22.2)
connection_pool
regexp_parser (2.9.2)
responders (3.1.1)
actionpack (>= 5.2)
railties (>= 5.2)
rexml (3.3.9)
rspec-core (3.13.2)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.3)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0)
rspec-mocks (3.12.5)
rspec-support (~> 3.13.0)
rspec-mocks (3.13.2)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0)
rspec-support (~> 3.13.0)
rspec-rails (4.0.2)
actionpack (>= 4.2)
activesupport (>= 4.2)
@@ -236,12 +252,33 @@ GEM
rspec-expectations (~> 3.10)
rspec-mocks (~> 3.10)
rspec-support (~> 3.10)
rspec-support (3.12.0)
rspec-retry (0.6.2)
rspec-core (> 3.3)
rspec-support (3.13.1)
rswag-api (2.15.0)
activesupport (>= 5.2, < 8.0)
railties (>= 5.2, < 8.0)
rswag-specs (2.15.0)
activesupport (>= 5.2, < 8.0)
json-schema (>= 2.2, < 6.0)
railties (>= 5.2, < 8.0)
rspec-core (>= 2.14)
rubyzip (2.3.2)
selenium-webdriver (4.1.0)
childprocess (>= 0.5, < 5.0)
selenium-webdriver (4.17.0)
base64 (~> 0.2)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2)
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)
sidekiq (7.3.5)
connection_pool (>= 2.3.0)
logger
rack (>= 2.2.4)
redis-client (>= 0.22.2)
sidekiq-cron (2.0.1)
cronex (>= 0.13.0)
fugit (~> 1.8, >= 1.11.1)
globalid (>= 1.0.1)
sidekiq (>= 6.5.0)
spring (2.1.1)
spring-watcher-listen (2.0.1)
listen (>= 2.7, < 4.0)
@@ -249,37 +286,34 @@ GEM
sprockets (4.2.1)
concurrent-ruby (~> 1.0)
rack (>= 2.2.4, < 4)
sprockets-rails (3.5.1)
sprockets-rails (3.5.2)
actionpack (>= 6.1)
activesupport (>= 6.1)
sprockets (>= 3.0.0)
stripe (11.2.0)
strscan (3.1.0)
thor (1.3.1)
tilt (2.0.10)
timeout (0.4.1)
thor (1.3.2)
tilt (2.4.0)
timeout (0.4.2)
turbolinks (5.2.1)
turbolinks-source (~> 5.2)
turbolinks-source (5.2.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode (0.4.4.5)
warden (1.2.9)
rack (>= 2.0.9)
web-console (4.2.0)
web-console (4.2.1)
actionview (>= 6.0.0)
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
webdrivers (5.3.1)
nokogiri (~> 1.6)
rubyzip (>= 1.3.0)
selenium-webdriver (~> 4.0, < 4.11)
websocket (1.2.11)
websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.6.16)
zeitwerk (2.6.18)
PLATFORMS
ruby
@@ -287,7 +321,7 @@ PLATFORMS
DEPENDENCIES
bootsnap (= 1.12.0)
byebug
capybara (= 3.36.0)
capybara (= 3.40.0)
cssbundling-rails (= 1.1.2)
devise (= 4.7.3)
factory_bot_rails (= 5.0.2)
@@ -297,23 +331,29 @@ DEPENDENCIES
jbuilder (= 2.11.5)
jsbundling-rails (= 1.1.1)
kaminari (= 1.2.2)
liquid (= 5.5.1)
listen (= 3.5.1)
pg (= 1.3.5)
puma (= 5.6.9)
pundit (= 2.2.0)
rack-attack (= 6.7.0)
rails (= 6.1.7.8)
rake (= 12.3.3)
rack-cors (= 2.0.2)
rails (= 6.1.7.9)
rake (= 13.2.1)
react-rails (= 2.6.2)
rspec-rails (= 4.0.2)
selenium-webdriver (= 4.1.0)
rspec-retry (= 0.6.2)
rswag-api (= 2.15.0)
rswag-specs (= 2.15.0)
selenium-webdriver (= 4.17.0)
sidekiq (= 7.3.5)
sidekiq-cron (= 2.0.1)
spring (= 2.1.1)
spring-watcher-listen (= 2.0.1)
stripe (= 11.2.0)
turbolinks (= 5.2.1)
tzinfo-data
web-console (>= 3.3.0)
webdrivers (= 5.3.1)
RUBY VERSION
ruby 3.0.6p216

View File

@@ -31,6 +31,7 @@
@import 'components/SiteSettings/Authentication';
@import 'components/SiteSettings/Appearance/';
@import 'components/SiteSettings/Invitations';
@import 'components/SiteSettings/Webhooks';
/* Moderation Components */
@import 'components/Moderation/Feedback';

View File

@@ -23,8 +23,14 @@
padding: 15px;
margin: 0 auto;
label[for=user_notifications_enabled] {
@extend .mb-0;
}
}
.apiKeyGenerateButton { width: 100%; }
.deviseLinks {
@extend .new_user;

View File

@@ -173,6 +173,7 @@ body {
}
.badgeWarning { @extend .badge-warning; }
.badgeDanger { @extend .badge-danger; }
.badgeSuccess { @extend .badge-success; }
.container {
max-width: 960px;
@@ -299,8 +300,8 @@ body {
}
.staffIcon {
font-size: 24px;
margin: 0 4px;
font-size: 22px;
margin: 0 0.5rem;
}
.poweredBy {

View File

@@ -1,5 +1,5 @@
.commentsContainer {
@extend .my-3;
@extend .mt-2;
.commentForm {
@extend
@@ -20,7 +20,7 @@
@extend
.d-flex,
.flex-column,
.my-3;
.mt-4;
.commentBodyForm {
@extend .d-flex;
@@ -53,6 +53,8 @@
}
.editCommentForm {
.commentFormContainer { @extend .d-block; }
textarea {
@extend .my-2;
}
@@ -71,19 +73,27 @@
.text-secondary,
.text-uppercase,
.font-weight-lighter,
.my-2;
.mt-5,
.mb-2;
}
.commentList { @extend .mb-4; }
.commentList > .commentList {
padding-left: 32px;
}
.comment {
@extend
.my-4;
.mb-2;
.commentHeader {
@extend .titleText;
@extend
.d-flex,
.align-items-end,
.titleText;
height: 36px;
.commentAuthor {
@extend .ml-2;

View File

@@ -59,7 +59,7 @@
.pl-0;
list-style: none;
height: 500px;
max-height: 500px;
overflow-y: scroll;
li.invitationListItem {
@@ -84,9 +84,13 @@
div.invitationInfo {
@extend .d-flex;
span.invitationAcceptedAt, span.invitationSentAt {
span.invitationAcceptedAt, span.invitationSentAt, span.invitationExpired {
@extend .align-self-center, .mutedText;
}
span.invitationExpired {
@extend .text-danger;
}
}
}
}

View File

@@ -0,0 +1,126 @@
.previewStyling {
@extend .mt-4, .p-3;
max-height: 400px;
max-width: 600px;
background-color: #f4f4f4;
border-radius: 8px;
}
a.backButton {
@extend .mb-2, .align-self-start;
font-size: 18px;
}
.webhooksIndexPage {
.webhooksTitle {
@extend
.d-flex,
.mb-2;
button {
@extend .ml-2;
height: min-content;
}
}
.webhooksList {
@extend .pl-1;
list-style: none;
h4 { @extend .mb-0, .mt-2; }
ul { @extend .pl-0; }
.webhookListItem {
@extend
.d-flex,
.justify-content-between,
.mb-2,
.p-2;
.webhookInfo {
@extend .d-flex;
column-gap: 32px;
.webhookName { font-size: 18px; }
.webhookDescription {
@extend .mutedText, .mb-1;
font-size: smaller;
}
}
.webhookActions {
@extend .d-flex;
align-self: center;
}
}
}
}
.webhookFormPage {
#httpBody {
min-height: 200px;
}
.httpBodyActions {
@extend
.d-flex,
.justify-content-between,
.mt-1;
div:first-child {
min-width: 250px;
}
}
.previewHttpBody {
display: block;
text-align: right;
}
.urlAndHttpBodyPreview {
@extend .mt-3;
#preview {
@extend .previewStyling;
margin-top: 0 !important;
}
}
.formGroupHttpHeaders {
@extend .mb-0;
div.formRow { @extend .mb-0; }
div.formRow:last-child {
div.formGroup { @extend .mb-1; }
}
}
.deleteHeaderActionLinkContainer {
@extend
.d-flex,
.align-self-end;
}
.submitWebhookFormButton { @extend .mt-4; }
}
.webhookTestPage {
.webhookActions {
@extend .d-flex;
}
.webhookTestResponse {
@extend .mt-4;
#testHttpResponse { @extend .previewStyling; }
}
}

View File

@@ -0,0 +1,95 @@
module Api
class BaseController < ApplicationController
include ApplicationHelper
include Pundit::Authorization
rescue_from StandardError, with: :unexpected_error # Must be at the top, catches exceptions not caught by other rescue_from
rescue_from ActiveRecord::InvalidForeignKey, with: :parameter_wrong
rescue_from ActiveRecord::RecordNotFound, with: :not_found
rescue_from ActionController::ParameterMissing, with: :parameter_missing
rescue_from Pundit::NotAuthorizedError, with: :not_authorized
rescue_from Api::V1::Helpers::ImpersonationError, with: :impersonation_error
skip_before_action :verify_authenticity_token
skip_before_action :check_tenant_is_private
skip_before_action :load_tenant_data
before_action :authenticate_with_api_key
prepend_before_action :set_current_tenant
attr_reader :current_user, :current_api_key
def pundit_user
current_api_key
end
protected
def set_current_tenant
Current.tenant = get_tenant_from_request(request)
# If current tenant is nil, return generic error message
request_http_token_authentication if Current.tenant.nil?
I18n.locale = I18n.default_locale
end
def not_authorized
render status: :unauthorized, json: {
errors: ['You are not authorized to perform this action.']
}
end
def parameter_missing
render status: :bad_request, json: {
errors: ['Some parameters are missing from the request. Please check the documentation.']
}
end
def parameter_wrong
render status: :bad_request, json: {
errors: ['Some parameters are wrong in the request. Please check the documentation.']
}
end
def not_found(exception)
render status: :not_found, json: {
errors: [exception.message]
}
end
def impersonation_error(exception)
render status: :unauthorized, json: {
errors: ["Impersonation error: #{exception.message}"]
}
end
def unexpected_error(exception)
if Rails.env.development?
error = '[DEV-ONLY MESSAGE] ' + exception.message
else
error = 'An unexpected error occurred.'
end
render status: :internal_server_error, json: {
errors: [error]
}
end
def authenticate_with_api_key
authenticate_or_request_with_http_token do |token, options|
@current_api_key = ApiKey.find_by_token(token)
@current_user = current_api_key&.user
end
end
# Override rails default 401 response to return JSON content-type
# with request for Bearer token
# https://api.rubyonrails.org/classes/ActionController/HttpAuthentication/Token/ControllerMethods.html
def request_http_token_authentication(realm = "Application", message = nil)
json_response = { errors: [message || "Access denied."] }
headers["WWW-Authenticate"] = %(Bearer realm="#{realm.tr('"', "")}")
render json: json_response, status: :unauthorized
end
end
end

View File

@@ -0,0 +1,49 @@
module Api
module V1
class BoardsController < BaseController
include Api::V1::Serializers
# List all boards
def index
boards = Board.all
authorize([:api, Board])
render json: boards.map { |board| board.slice(*BOARD_JSON_ATTRIBUTES) }
end
# Get the board by id or slug
def show
board = Board.find_by(id: params[:id]) || Board.find_by(slug: params[:id])
unless board
raise ActiveRecord::RecordNotFound, "Board with id #{params[:id]} not found"
end
authorize([:api, board])
render json: board.slice(*BOARD_JSON_ATTRIBUTES)
end
# Create a new board
def create
board = Board.new(board_params)
authorize([:api, board])
if board.save
render json: { id: board.id }, status: :created
else
render json: { errors: board.errors.full_messages }, status: :unprocessable_entity
end
end
private
def board_params
params.require(:name)
params.permit(:name, :slug, :description)
end
end
end
end

View File

@@ -0,0 +1,131 @@
module Api
module V1
class CommentsController < BaseController
include Api::V1::Serializers
include Api::V1::Helpers
# List comments
def index
comments = Comment
.includes(:user)
.order(created_at: :desc)
.limit(params[:limit] || 100)
.offset(params[:offset] || 0)
comments = comments.where(post_id: params[:post_id]) if params[:post_id].present?
authorize([:api, Comment])
render json: comments.as_json(only: COMMENT_JSON_ATTRIBUTES, include: {
user: { only: USER_JSON_ATTRIBUTES }
})
end
# Show a comment
def show
comment = Comment
.includes(:user)
.find_by(id: params[:id])
unless comment
raise ActiveRecord::RecordNotFound, "Comment with id #{params[:id]} not found"
end
authorize([:api, comment])
render json: comment.as_json(only: COMMENT_JSON_ATTRIBUTES, include: {
user: { only: USER_JSON_ATTRIBUTES }
})
end
# Create a new comment
def create
comment = Comment.new(comment_params)
authorize([:api, comment])
comment.user_id = impersonate_user_if_requested(params[:impersonated_user_id], current_api_key.user_id)
if comment.save
SendNotificationForCommentWorkflow.new(comment: comment).run
render json: { id: comment.id }, status: :created
else
render json: { errors: comment.errors.full_messages }, status: :unprocessable_entity
end
end
# Update a comment
def update
comment = Comment.find_by(id: params[:id])
unless comment
raise ActiveRecord::RecordNotFound, "Comment with id #{params[:id]} not found"
end
authorize([:api, comment])
if comment.update(comment_update_params)
render json: { id: comment.id }, status: :ok
else
render json: { errors: comment.errors.full_messages }, status: :unprocessable_entity
end
end
# Delete a comment
def destroy
comment = Comment.find_by(id: params[:id])
unless comment
raise ActiveRecord::RecordNotFound, "Comment with id #{params[:id]} not found"
end
authorize([:api, comment])
comment.destroy!
render json: { id: comment.id }, status: :ok
end
# Mark comment as post update
def mark_as_post_update
comment = Comment.find_by(id: params[:id])
unless comment
raise ActiveRecord::RecordNotFound, "Comment with id #{params[:id]} not found"
end
authorize([:api, comment])
comment.update!(is_post_update: true)
render json: { id: comment.id }, status: :ok
end
# Unmark comment as post update
def unmark_as_post_update
comment = Comment.find_by(id: params[:id])
unless comment
raise ActiveRecord::RecordNotFound, "Comment with id #{params[:id]} not found"
end
authorize([:api, comment])
comment.update!(is_post_update: false)
render json: { id: comment.id }, status: :ok
end
private
def comment_params
params.permit(:body, :is_post_update, :post_id, :parent_id)
end
def comment_update_params
params.permit(:body)
end
end
end
end

View File

@@ -0,0 +1,19 @@
module Api
module V1
module Helpers
class ImpersonationError < StandardError; end
# Impersonate a user if requested
# Note: only administrators can impersonate other users
# @param impersonated_user_id [Integer] the user id to impersonate
# @param current_user_id [Integer] the current user id (the one making the request with the API key)
def impersonate_user_if_requested(impersonated_user_id, current_user_id)
return current_user_id unless impersonated_user_id.present?
raise ImpersonationError, "You are not allowed to impersonate other users." unless User.find_by(id: current_user_id).admin?
raise ImpersonationError, "Could not find the user to impersonate." unless User.find_by(id: impersonated_user_id).present?
impersonated_user_id
end
end
end
end

View File

@@ -0,0 +1,79 @@
module Api
module V1
class LikesController < BaseController
include Api::V1::Serializers
include Api::V1::Helpers
# List likes
def index
likes = Like
.includes(:user)
.order(created_at: :desc)
.limit(params[:limit] || 100)
.offset(params[:offset] || 0)
likes = likes.where(post_id: params[:post_id]) if params[:post_id].present?
authorize([:api, Like])
render json: likes.as_json(only: LIKE_JSON_ATTRIBUTES, include: {
user: { only: USER_JSON_ATTRIBUTES }
})
end
# Show a like
def show
like = Like
.includes(:user)
.find_by(id: params[:id])
unless like
raise ActiveRecord::RecordNotFound, "Like with id #{params[:id]} not found"
end
authorize([:api, like])
render json: like.as_json(only: LIKE_JSON_ATTRIBUTES, include: {
user: { only: USER_JSON_ATTRIBUTES }
})
end
# Create like
def create
like = Like.new(like_params)
authorize([:api, like])
like.user_id = impersonate_user_if_requested(params[:impersonated_user_id], current_api_key.user_id)
if like.save
render json: { id: like.id }, status: :created
else
render json: { errors: like.errors.full_messages }, status: :unprocessable_entity
end
end
# Delete like
def destroy
like = Like.find_by(id: params[:id])
unless like
raise ActiveRecord::RecordNotFound, "Like with id #{params[:id]} not found"
end
authorize([:api, like])
like.destroy!
render json: { id: like.id }, status: :ok
end
private
def like_params
params.permit(:post_id)
end
end
end
end

View File

@@ -0,0 +1,16 @@
module Api
module V1
class PostStatusesController < BaseController
include Api::V1::Serializers
# List all post statuses
def index
post_statuses = PostStatus.all
authorize([:api, PostStatus])
render json: post_statuses.map { |post_status| post_status.slice(*POST_STATUS_JSON_ATTRIBUTES) }
end
end
end
end

View File

@@ -0,0 +1,192 @@
module Api
module V1
class PostsController < BaseController
include Api::V1::Serializers
include Api::V1::Helpers
# List posts
def index
posts = Post
.includes(:board, :post_status, :user)
.order(created_at: :desc)
.limit(params[:limit] || 20)
.offset(params[:offset] || 0)
posts = posts.where(board_id: params[:board_id]) if params[:board_id].present?
posts = posts.where(user_id: params[:user_id]) if params[:user_id].present?
authorize([:api, Post])
render json: posts.as_json(only: POST_JSON_ATTRIBUTES, include: {
board: { only: BOARD_JSON_ATTRIBUTES },
post_status: { only: POST_STATUS_JSON_ATTRIBUTES },
user: { only: USER_JSON_ATTRIBUTES }
})
end
# Get a post by id
def show
post = Post
.includes(:board, :post_status, :user)
.find_by(id: params[:id])
unless post
raise ActiveRecord::RecordNotFound, "Post with id #{params[:id]} not found"
end
authorize([:api, post])
render json: post.as_json(only: POST_JSON_ATTRIBUTES, include: {
board: { only: BOARD_JSON_ATTRIBUTES },
post_status: { only: POST_STATUS_JSON_ATTRIBUTES },
user: { only: USER_JSON_ATTRIBUTES }
})
end
# Create a new post
def create
post = Post.new(post_params)
authorize([:api, post])
post.user_id = impersonate_user_if_requested(params[:impersonated_user_id], current_api_key.user_id)
if post.save
render json: { id: post.id }, status: :created
else
render json: { errors: post.errors.full_messages }, status: :unprocessable_entity
end
end
# Update a post
def update
post = Post.find_by(id: params[:id])
unless post
raise ActiveRecord::RecordNotFound, "Post with id #{params[:id]} not found"
end
authorize([:api, post])
if post.update(post_update_params)
render json: { id: post.id }, status: :ok
else
render json: { errors: post.errors.full_messages }, status: :unprocessable_entity
end
end
# Delete a post
def destroy
post = Post.find_by(id: params[:id])
unless post
raise ActiveRecord::RecordNotFound, "Post with id #{params[:id]} not found"
end
authorize([:api, post])
post.destroy!
render json: { id: post.id }, status: :ok
end
# Update post board
def update_board
post = Post.find_by(id: params[:id])
unless post
raise ActiveRecord::RecordNotFound, "Post with id #{params[:id]} not found"
end
authorize([:api, post])
post.update!(post_update_board_params)
render json: { id: post.id }, status: :ok
end
# Update post status
def update_status
post = Post.find_by(id: params[:id])
unless post
raise ActiveRecord::RecordNotFound, "Post with id #{params[:id]} not found"
end
authorize([:api, post])
user_id = impersonate_user_if_requested(params[:impersonated_user_id], current_api_key.user_id)
post.update!(post_update_status_params)
if post.post_status_id_previously_changed?
ExecutePostStatusChangeLogicWorkflow.new(
user_id: user_id,
post: post,
post_status_id: post.post_status_id
).run
end
render json: { id: post.id }, status: :ok
end
# Approve post
def approve
post = Post.find_by(id: params[:id])
unless post
raise ActiveRecord::RecordNotFound, "Post with id #{params[:id]} not found"
end
unless post.approval_status == 'pending'
raise StandardError, "Post with id #{params[:id]} is not pending approval"
end
authorize([:api, post])
post.update!(approval_status: 'approved')
render json: { id: post.id }, status: :ok
end
# Reject post
def reject
post = Post.find_by(id: params[:id])
unless post
raise ActiveRecord::RecordNotFound, "Post with id #{params[:id]} not found"
end
unless post.approval_status == 'pending'
raise StandardError, "Post with id #{params[:id]} is not pending approval"
end
authorize([:api, post])
post.update!(approval_status: 'rejected')
render json: { id: post.id }, status: :ok
end
private
def post_params
params.require(:title)
params.permit(:title, :description, :board_id)
end
def post_update_params
params.permit(:title, :description)
end
def post_update_board_params
params.require(:board_id)
params.permit(:board_id)
end
def post_update_status_params
params.permit(:post_status_id)
end
end
end
end

View File

@@ -0,0 +1,62 @@
module Api
module V1
module Serializers
BOARD_JSON_ATTRIBUTES = [
:id,
:name,
:slug,
:description,
:created_at,
:updated_at
].freeze
COMMENT_JSON_ATTRIBUTES = [
:id,
:body,
:is_post_update,
:post_id,
:user,
:created_at,
:updated_at
].freeze
POST_STATUS_JSON_ATTRIBUTES = [
:id,
:name,
:color,
:show_in_roadmap,
:created_at,
:updated_at
].freeze
POST_JSON_ATTRIBUTES = [
:id,
:title,
:description,
:board,
:post_status,
:user,
:approval_status,
:slug,
:created_at,
:updated_at
].freeze
USER_JSON_ATTRIBUTES = [
:id,
:email,
:full_name,
:created_at,
:updated_at
].freeze
LIKE_JSON_ATTRIBUTES = [
:id,
:user,
:post_id,
:created_at,
:updated_at
].freeze
end
end
end

View File

@@ -0,0 +1,91 @@
module Api
module V1
class UsersController < BaseController
include Api::V1::Serializers
include Api::V1::Helpers
# List users
def index
users = User
.order(created_at: :desc)
.limit(params[:limit] || 100)
.offset(params[:offset] || 0)
authorize([:api, User])
render json: users.as_json(only: USER_JSON_ATTRIBUTES)
end
# Get user by id
def show
user = User.find_by(id: params[:id])
unless user
raise ActiveRecord::RecordNotFound, "User with id #{params[:id]} not found"
end
authorize([:api, user])
render json: user.slice(*USER_JSON_ATTRIBUTES)
end
# Get user by email
def show_by_email
user = User.find_by(email: params[:email])
unless user
raise ActiveRecord::RecordNotFound, "User with email #{params[:email]} not found"
end
authorize([:api, user])
render json: user.slice(*USER_JSON_ATTRIBUTES)
end
# Create user
def create
# Check whether user already exists and return its id
user = User.find_by(email: params[:email])
if user
render json: { id: user.id }, status: :ok
return
end
# ... otherwise, create a new user
user = User.new(
email: params[:email],
full_name: params[:full_name] || params[:email],
password: Devise.friendly_token,
has_set_password: false,
status: 'active'
)
user.skip_confirmation
authorize([:api, user])
if user.save
render json: { id: user.id }, status: :created
else
render json: { errors: user.errors.full_messages }, status: :unprocessable_entity
end
end
# Block user
def block
user = User.find_by(id: params[:id])
unless user
raise ActiveRecord::RecordNotFound, "User with id #{params[:id]} not found"
end
authorize([:api, user])
user.update!(status: 'blocked')
render json: { id: user.id }, status: :ok
end
end
end
end

View File

@@ -0,0 +1,19 @@
class ApiKeysController < ApplicationController
before_action :authenticate_user!
def create
current_user.api_key&.destroy # Destroy existing API key
@api_key = ApiKey.new(user: current_user)
authorize @api_key
if @api_key.save
render json: { api_key: @api_key.token }, status: :created
else
render json: {
errors: @api_key.errors.full_messages
}, status: :unprocessable_entity
end
end
end

View File

@@ -17,7 +17,9 @@ class ApplicationController < ActionController::Base
if resource.admin? && resource.sign_in_count == 1
root_path(tour: true)
else
super
safe_return_to_redirect(session[:return_to]) do
super
end
end
end
@@ -33,7 +35,12 @@ class ApplicationController < ActionController::Base
protected
def configure_devise_permitted_parameters
additional_permitted_parameters = [:full_name, :notifications_enabled, :invitation_token]
additional_permitted_parameters = [
:full_name,
:notifications_enabled,
:recap_notification_frequency,
:invitation_token
]
devise_parameter_sanitizer.permit(:sign_up, keys: additional_permitted_parameters)
devise_parameter_sanitizer.permit(:account_update, keys: additional_permitted_parameters)

View File

@@ -77,6 +77,7 @@ class BillingController < ApplicationController
mode: 'subscription',
return_url: "#{return_url}?session_id={CHECKOUT_SESSION_ID}&tenant_id=#{params[:tenant_id]}",
customer: Current.tenant.tenant_billing.customer_id,
allow_promotion_codes: true,
})
render json: { clientSecret: session.client_secret }
@@ -123,17 +124,18 @@ class BillingController < ApplicationController
TenantMailer.subscription_confirmation(tenant: Current.tenant).deliver_later
end
elsif event['type'] == 'customer.subscription.updated'
# This event is triggered when:
# (1) A subscription is canceled OR a subscription is reactivated after being canceled
# (2) A subscription is updated (e.g. switching from monthly to yearly plan or vice versa)
# (3) A subscription is automatically renewed at the end of the billing period (e.g. every month for a monthly subscription)
# Since it is difficult to distinguish between these cases, we only update the status if the subscription is active or canceled
# and we do not send any emails notifications.
Current.tenant = get_tenant_from_customer_id(event.data.object.customer)
if Current.tenant.tenant_billing.status == 'active' || Current.tenant.tenant_billing.status == 'canceled'
has_canceled = event.data.object.cancel_at_period_end
Current.tenant.tenant_billing.update!(status: has_canceled ? 'canceled' : 'active')
if has_canceled
TenantMailer.cancellation_confirmation(tenant: Current.tenant).deliver_later
else
TenantMailer.renewal_confirmation(tenant: Current.tenant).deliver_later
end
end
end

View File

@@ -32,7 +32,7 @@ class InvitationsController < ApplicationController
)
)
InvitationMailer.invite(invitation: invitation, subject: subject, body: body_with_link).deliver_now
InvitationMailer.invite(invitation: invitation, subject: subject, body: body_with_link).deliver_later
num_invitations_sent += 1
end
@@ -56,7 +56,6 @@ class InvitationsController < ApplicationController
render json: {}, status: :ok
end
private
def invitation_params
@@ -66,4 +65,4 @@ class InvitationsController < ApplicationController
invitation.require(:body)
end
end
end
end

View File

@@ -16,7 +16,7 @@ class OAuthsController < ApplicationController
else
@o_auth = OAuth.include_defaults.friendly.find(params[:id])
end
return if params[:reason] != 'test' and not @o_auth.is_enabled?
# Generate random state + other query params
@@ -53,9 +53,9 @@ class OAuthsController < ApplicationController
authorization_code: params[:code],
o_auth: @o_auth
).run
if reason == 'login'
user = OAuthSignInUserWorkflow.new(
user_profile: user_profile,
o_auth: @o_auth
@@ -70,7 +70,7 @@ class OAuthsController < ApplicationController
end
elsif reason == 'test'
unless user_signed_in? and current_user.admin?
flash[:alert] = I18n.t('errors.unauthorized')
redirect_to get_url_for(method(:root_url))
@@ -132,6 +132,7 @@ class OAuthsController < ApplicationController
remember_me user
user.invalidate_oauth_token
flash[:notice] = I18n.t('devise.sessions.signed_in')
redirect_to after_sign_in_path_for(user)
else
flash[:alert] = I18n.t('errors.o_auth_login_error', name: @o_auth.name)
@@ -207,4 +208,4 @@ class OAuthsController < ApplicationController
.require(:o_auth)
.permit(policy(@o_auth).permitted_attributes)
end
end
end

View File

@@ -108,15 +108,11 @@ class PostsController < ApplicationController
if @post.save
if @post.post_status_id_previously_changed?
PostStatusChange.create(
ExecutePostStatusChangeLogicWorkflow.new(
user_id: current_user.id,
post_id: @post.id,
post: @post,
post_status_id: @post.post_status_id
)
@post.followers.each do |follower|
UserMailer.notify_follower_of_post_status_change(post: @post, follower: follower).deliver_later
end
).run
end
render json: @post

View File

@@ -1,4 +1,6 @@
class RegistrationsController < Devise::RegistrationsController
include ApplicationHelper
# Needed to have Current.tenant available in Devise's controllers
prepend_before_action :load_tenant_data
before_action :load_oauths, only: [:new]
@@ -12,12 +14,11 @@ class RegistrationsController < Devise::RegistrationsController
# Handle invitations
is_invitation = sign_up_params[:invitation_token].present?
is_invitation_valid = true
invitation = nil
if is_invitation
invitation = Invitation.find_by(email: email)
if invitation.nil? || invitation.token_digest != Digest::SHA256.hexdigest(sign_up_params[:invitation_token]) || invitation.accepted_at.present?
if invitation.nil? || invitation.expired? || invitation.token_digest != Digest::SHA256.hexdigest(sign_up_params[:invitation_token]) || invitation.accepted_at.present?
flash[:alert] = t('errors.unauthorized')
redirect_to new_user_registration_path and return
end
@@ -88,12 +89,15 @@ class RegistrationsController < Devise::RegistrationsController
protected
# Override Devise after inactive sign up path
def after_inactive_sign_up_path_for(resource)
if Current.tenant.tenant_setting.is_private
# Redirect to log in page, since root page only visible to logged in users
new_user_session_path
else
super
safe_return_to_redirect(session[:return_to]) do
super
end
end
end

View File

@@ -4,9 +4,18 @@ class SessionsController < Devise::SessionsController
before_action :load_oauths, only: [:new]
before_action :set_page_title, only: [:new]
def new
# Update return_to path if not coming from Devise user pages
if request.referer.present? && !request.referer.include?('/users')
session[:return_to] = request.referer
end
super
end
private
def set_page_title
@page_title = t('common.forms.auth.log_in')
end
end
end

View File

@@ -19,6 +19,9 @@ class SiteSettingsController < ApplicationController
def roadmap
end
def webhooks
end
def invitations
@invitations = Invitation.all.order(updated_at: :desc)
end

View File

@@ -43,7 +43,8 @@ class TenantsController < ApplicationController
email: params[:user][:email],
password: is_o_auth_login ? Devise.friendly_token : params[:user][:password],
has_set_password: !is_o_auth_login,
role: "owner"
role: "owner",
recap_notification_frequency: "daily"
)
if is_o_auth_login

View File

@@ -20,9 +20,15 @@ class UsersController < ApplicationController
# Handle special case: trying to set user role to 'owner'
raise Pundit::NotAuthorizedError if @user.owner?
if @user.save
render json: @user
else
ActiveRecord::Base.transaction do
DestroyApiKeyIfNeededWorkflow.new(user: @user).run
if @user.save
render json: @user
else
raise ActiveRecord::Rollback
end
rescue ActiveRecord::Rollback
render json: {
error: @user.errors.full_messages
}, status: :unprocessable_entity

View File

@@ -0,0 +1,97 @@
class WebhooksController < ApplicationController
def index
authorize Webhook
@webhooks = Webhook.order(trigger: :asc, created_at: :desc)
render json: @webhooks
end
def create
@webhook = Webhook.new
@webhook.assign_attributes(webhook_params)
authorize @webhook
if @webhook.save
render json: @webhook, status: :created
else
render json: {
error: @webhook.errors.full_messages
}, status: :unprocessable_entity
end
end
def update
@webhook = Webhook.find(params[:id])
authorize @webhook
if @webhook.update(webhook_params)
render json: @webhook
else
render json: {
error: @webhook.errors.full_messages
}, status: :unprocessable_entity
end
end
def destroy
@webhook = Webhook.find(params[:id])
authorize @webhook
if @webhook.destroy
render json: {
id: params[:id]
}, status: :accepted
else
render json: {
error: @webhook.errors.full_messages
}, status: :unprocessable_entity
end
end
def preview
context = CreateLiquidTemplateContextWorkflow.new(
webhook_trigger: params[:webhook][:trigger],
is_test: true,
).run
url_template = Liquid::Template.parse(params[:webhook][:url])
url_preview = url_template.render(context)
http_body_template = Liquid::Template.parse(params[:webhook][:http_body])
http_body_preview = http_body_template.render(context)
render json: {
url_preview: url_preview,
http_body_preview: http_body_preview,
}, status: :ok
rescue => e
render json: {
error: e.message
}, status: :unprocessable_entity
end
def test
response = RunWebhook.perform_now(
webhook_id: params[:id],
current_tenant_id: Current.tenant.id,
is_test: true
)
render json: {
response: response,
}, status: :ok
rescue => e
render json: {
error: e.message
}, status: :unprocessable_entity
end
private
def webhook_params
params
.require(:webhook)
.permit(policy(@webhook).permitted_attributes)
end
end

View File

@@ -0,0 +1,5 @@
module ApiKeysHelper
def token_mask(prefix, length = 30)
"#{prefix}#{""*length}"
end
end

View File

@@ -39,24 +39,37 @@ module ApplicationHelper
end
def get_url_for(url_helper, resource: nil, disallow_custom_domain: false, options: {})
custom_domain = Current.tenant.custom_domain if not disallow_custom_domain and Current.tenant
logger.info { "(start) Call to get_url_for with tenant: #{Current.tenant&.inspect}, url_helper = #{url_helper}, resource = #{resource}, disallow_custom_domain = #{disallow_custom_domain}, options = #{options}" }
custom_domain = Current.tenant.custom_domain if not disallow_custom_domain and Current.tenant
subdomain = ''
host = ''
if not options[:subdomain].blank?
subdomain = options[:subdomain]
end
if options[:subdomain].blank? && Rails.application.multi_tenancy? && (custom_domain.blank? || disallow_custom_domain)
options[:subdomain] = Current.tenant.subdomain
subdomain = Current.tenant.subdomain
end
if custom_domain.blank? || disallow_custom_domain
options[:host] = Rails.application.base_url
host = Rails.application.base_url
host = host.gsub(%r{\Ahttps?://|/$}, '')
host = "#{subdomain}.#{host}" if subdomain.present?
else
options[:host] = custom_domain
host = custom_domain
end
options[:host] = host
if Rails.application.base_url.include?('https')
options[:protocol] = 'https'
else
options[:protocol] = 'http'
end
logger.info { "(end) Call to get_url_for with options = #{options}, resulting url = #{resource ? url_helper.call(resource, options) : url_helper.call(options)}" }
resource ? url_helper.call(resource, options) : url_helper.call(options)
end
@@ -79,4 +92,12 @@ module ApplicationHelper
tenant
end
# Redirect to previous page if present; otherwise redirect to root
def safe_return_to_redirect(url)
uri = URI.parse(url)
uri.host.present? && uri.host != request.host ? yield : url
rescue URI::InvalidURIError
yield
end
end

View File

@@ -1,5 +1,6 @@
import { Action } from "redux";
import { ThunkAction } from "redux-thunk";
import { ISiteSettingsOAuthForm } from "../../components/SiteSettings/Authentication/OAuthForm";
import HttpStatus from "../../constants/http_status";
import buildRequestHeaders from "../../helpers/buildRequestHeaders";

View File

@@ -0,0 +1,69 @@
import { Action } from "redux";
import { ThunkAction } from "redux-thunk";
import HttpStatus from "../../constants/http_status";
import buildRequestHeaders from "../../helpers/buildRequestHeaders";
import { State } from "../../reducers/rootReducer";
export const WEBHOOK_DELETE_START = 'WEBHOOK_DELETE_START';
interface WebhookDeleteStartAction {
type: typeof WEBHOOK_DELETE_START;
}
export const WEBHOOK_DELETE_SUCCESS = 'WEBHOOK_DELETE_SUCCESS';
interface WebhookDeleteSuccessAction {
type: typeof WEBHOOK_DELETE_SUCCESS;
id: number;
}
export const WEBHOOK_DELETE_FAILURE = 'WEBHOOK_DELETE_FAILURE';
interface WebhookDeleteFailureAction {
type: typeof WEBHOOK_DELETE_FAILURE;
error: string;
}
export type WebhookDeleteActionTypes =
WebhookDeleteStartAction |
WebhookDeleteSuccessAction |
WebhookDeleteFailureAction;
const webhookDeleteStart = (): WebhookDeleteStartAction => ({
type: WEBHOOK_DELETE_START,
});
const webhookDeleteSuccess = (
id: number,
): WebhookDeleteSuccessAction => ({
type: WEBHOOK_DELETE_SUCCESS,
id,
});
const webhookDeleteFailure = (error: string): WebhookDeleteFailureAction => ({
type: WEBHOOK_DELETE_FAILURE,
error,
});
export const deleteWebhook = (
id: number,
authenticityToken: string,
): ThunkAction<void, State, null, Action<string>> => (
async (dispatch) => {
dispatch(webhookDeleteStart());
try {
const res = await fetch(`/webhooks/${id}`, {
method: 'DELETE',
headers: buildRequestHeaders(authenticityToken),
});
const json = await res.json();
if (res.status === HttpStatus.Accepted) {
dispatch(webhookDeleteSuccess(id));
} else {
dispatch(webhookDeleteFailure(json.error));
}
} catch (error) {
dispatch(webhookDeleteFailure(error.message));
}
}
);

View File

@@ -0,0 +1,59 @@
import { Action } from 'redux';
import { ThunkAction } from 'redux-thunk';
import { IWebhookJSON } from '../../interfaces/IWebhook';
import { State } from '../../reducers/rootReducer';
export const WEBHOOKS_REQUEST_START = 'WEBHOOKS_REQUEST_START';
interface WebhooksRequestStartAction {
type: typeof WEBHOOKS_REQUEST_START;
}
export const WEBHOOKS_REQUEST_SUCCESS = 'WEBHOOKS_REQUEST_SUCCESS';
interface WebhooksRequestSuccessAction {
type: typeof WEBHOOKS_REQUEST_SUCCESS;
webhooks: Array<IWebhookJSON>;
}
export const WEBHOOKS_REQUEST_FAILURE = 'WEBHOOKS_REQUEST_FAILURE';
interface WebhooksRequestFailureAction {
type: typeof WEBHOOKS_REQUEST_FAILURE;
error: string;
}
export type WebhooksRequestActionTypes =
WebhooksRequestStartAction |
WebhooksRequestSuccessAction |
WebhooksRequestFailureAction;
const webhooksRequestStart = (): WebhooksRequestActionTypes => ({
type: WEBHOOKS_REQUEST_START,
});
const webhooksRequestSuccess = (
webhooks: Array<IWebhookJSON>
): WebhooksRequestActionTypes => ({
type: WEBHOOKS_REQUEST_SUCCESS,
webhooks,
});
const webhooksRequestFailure = (error: string): WebhooksRequestActionTypes => ({
type: WEBHOOKS_REQUEST_FAILURE,
error,
});
export const requestWebhooks = (): ThunkAction<void, State, null, Action<string>> => (
async (dispatch) => {
dispatch(webhooksRequestStart());
try {
const response = await fetch('/webhooks');
const json = await response.json();
dispatch(webhooksRequestSuccess(json));
} catch (e) {
dispatch(webhooksRequestFailure(e));
}
}
)

View File

@@ -0,0 +1,78 @@
import { Action } from "redux";
import { ThunkAction } from "redux-thunk";
import { IWebhook, IWebhookJSON, webhookJS2JSON } from "../../interfaces/IWebhook";
import { State } from "react-joyride";
import buildRequestHeaders from "../../helpers/buildRequestHeaders";
import HttpStatus from "../../constants/http_status";
export const WEBHOOK_SUBMIT_START = 'WEBHOOK_SUBMIT_START';
interface WebhookSubmitStartAction {
type: typeof WEBHOOK_SUBMIT_START;
}
export const WEBHOOK_SUBMIT_SUCCESS = 'WEBHOOK_SUBMIT_SUCCESS';
interface WebhookSubmitSuccessAction {
type: typeof WEBHOOK_SUBMIT_SUCCESS;
webhook: IWebhookJSON;
}
export const WEBHOOK_SUBMIT_FAILURE = 'WEBHOOK_SUBMIT_FAILURE';
interface WebhookSubmitFailureAction {
type: typeof WEBHOOK_SUBMIT_FAILURE;
error: string;
}
export type WebhookSubmitActionTypes =
WebhookSubmitStartAction |
WebhookSubmitSuccessAction |
WebhookSubmitFailureAction;
const webhookSubmitStart = (): WebhookSubmitStartAction => ({
type: WEBHOOK_SUBMIT_START,
});
const webhookSubmitSuccess = (
webhookJSON: IWebhookJSON,
): WebhookSubmitSuccessAction => ({
type: WEBHOOK_SUBMIT_SUCCESS,
webhook: webhookJSON,
});
const webhookSubmitFailure = (error: string): WebhookSubmitFailureAction => ({
type: WEBHOOK_SUBMIT_FAILURE,
error,
});
export const submitWebhook = (
webhook: IWebhook,
authenticityToken: string,
): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
dispatch(webhookSubmitStart());
try {
const res = await fetch(`/webhooks`, {
method: 'POST',
headers: buildRequestHeaders(authenticityToken),
body: JSON.stringify({
webhook: {
...webhookJS2JSON(webhook),
is_enabled: false,
},
}),
});
const json = await res.json();
if (res.status === HttpStatus.Created) {
dispatch(webhookSubmitSuccess(json));
} else {
dispatch(webhookSubmitFailure(json.error));
}
return Promise.resolve(res);
} catch (e) {
dispatch(webhookSubmitFailure(e));
return Promise.resolve(null);
}
};

View File

@@ -0,0 +1,96 @@
import { Action } from "redux";
import { ThunkAction } from "redux-thunk";
import HttpStatus from "../../constants/http_status";
import buildRequestHeaders from "../../helpers/buildRequestHeaders";
import { State } from "../../reducers/rootReducer";
import { IWebhookJSON } from "../../interfaces/IWebhook";
import { ISiteSettingsWebhookFormUpdate } from "../../components/SiteSettings/Webhooks/WebhookForm";
export const WEBHOOK_UPDATE_START = 'WEBHOOK_UPDATE_START';
interface WebhookUpdateStartAction {
type: typeof WEBHOOK_UPDATE_START;
}
export const WEBHOOK_UPDATE_SUCCESS = 'WEBHOOK_UPDATE_SUCCESS';
interface WebhookUpdateSuccessAction {
type: typeof WEBHOOK_UPDATE_SUCCESS;
webhook: IWebhookJSON;
}
export const WEBHOOK_UPDATE_FAILURE = 'WEBHOOK_UPDATE_FAILURE';
interface WebhookUpdateFailureAction {
type: typeof WEBHOOK_UPDATE_FAILURE;
error: string;
}
export type WebhookUpdateActionTypes =
WebhookUpdateStartAction |
WebhookUpdateSuccessAction |
WebhookUpdateFailureAction;
const webhookUpdateStart = (): WebhookUpdateStartAction => ({
type: WEBHOOK_UPDATE_START,
});
const webhookUpdateSuccess = (
webhookJSON: IWebhookJSON,
): WebhookUpdateSuccessAction => ({
type: WEBHOOK_UPDATE_SUCCESS,
webhook: webhookJSON,
});
const webhookUpdateFailure = (error: string): WebhookUpdateFailureAction => ({
type: WEBHOOK_UPDATE_FAILURE,
error,
});
interface UpdateWebhookParams {
id: number;
form?: ISiteSettingsWebhookFormUpdate;
isEnabled?: boolean;
authenticityToken: string;
}
export const updateWebhook = ({
id,
form = null,
isEnabled = null,
authenticityToken,
}: UpdateWebhookParams): ThunkAction<void, State, null, Action<string>> => async (dispatch) => {
dispatch(webhookUpdateStart());
const webhook = Object.assign({},
form !== null ? {
name: form.name,
description: form.description,
trigger: form.trigger,
http_method: form.httpMethod,
url: form.url,
http_body: form.httpBody,
http_headers: form.httpHeaders,
} : null,
isEnabled !== null ? { is_enabled: isEnabled } : null,
);
try {
const res = await fetch(`/webhooks/${id}`, {
method: 'PATCH',
headers: buildRequestHeaders(authenticityToken),
body: JSON.stringify({ webhook }),
});
const json = await res.json();
if (res.status === HttpStatus.OK) {
dispatch(webhookUpdateSuccess(json));
} else {
dispatch(webhookUpdateFailure(json.error));
}
return Promise.resolve(res);
} catch (e) {
dispatch(webhookUpdateFailure(e));
return Promise.resolve(null);
}
};

View File

@@ -3,6 +3,7 @@ import I18n from 'i18n-js';
import Button from '../common/Button';
import { SmallMutedText } from '../common/CustomTexts';
import { MarkdownIcon } from '../common/Icons';
interface Props {
title: string;
@@ -93,6 +94,9 @@ const NewPostForm = ({
className="form-control"
id="postDescription"
></textarea>
<div style={{position: 'relative', width: 0, height: 0}}>
<MarkdownIcon style={{position: 'absolute', left: '6px', top: '-28px'}} />
</div>
</div>
<Button onClick={e => handleSubmit(e)} className="submitBtn d-block mx-auto">

View File

@@ -3,7 +3,7 @@ import I18n from 'i18n-js';
import Button from '../common/Button';
import Switch from '../common/Switch';
import ActionLink from '../common/ActionLink';
import { CancelIcon } from '../common/Icons';
import { CancelIcon, MarkdownIcon } from '../common/Icons';
interface Props {
id: number;
@@ -55,12 +55,19 @@ class CommentEditForm extends React.Component<Props, State> {
return (
<div className="editCommentForm">
<textarea
value={body}
onChange={e => this.handleCommentBodyChange(e.target.value)}
rows={3}
className="commentForm"
/>
<div className="commentFormContainer">
<textarea
value={body}
onChange={e => this.handleCommentBodyChange(e.target.value)}
rows={3}
autoFocus
className="commentForm"
/>
<div style={{position: 'relative', width: 0, height: 0}}>
<MarkdownIcon style={{position: 'absolute', left: '6px', top: '-36px'}} />
</div>
</div>
<div>
<div>

View File

@@ -4,7 +4,7 @@ import I18n from 'i18n-js';
import NewComment from './NewComment';
import CommentList from './CommentList';
import Spinner from '../common/Spinner';
import { DangerText } from '../common/CustomTexts';
import { DangerText, MutedText } from '../common/CustomTexts';
import IComment from '../../interfaces/IComment';
import { ReplyFormState } from '../../reducers/replyFormReducer';
@@ -122,15 +122,16 @@ class CommentsP extends React.Component<Props> {
userEmail={userEmail}
/>
{ areLoading ? <Spinner /> : null }
{ error ? <DangerText>{error}</DangerText> : null }
<div className="commentsTitle">
{I18n.t('post.comments.title')}
<Separator />
{I18n.t('common.comments_number', { count: comments.length })}
</div>
{ areLoading ? <Spinner /> : null }
{ error ? <DangerText>{error}</DangerText> : null }
{ comments.length === 0 && !areLoading && !error && <MutedText>{I18n.t('post.comments.empty')}</MutedText> }
<CommentList
comments={comments}
replyForms={replyForms}

View File

@@ -7,6 +7,7 @@ import NewCommentUpdateSection from './NewCommentUpdateSection';
import Button from '../common/Button';
import Spinner from '../common/Spinner';
import { DangerText } from '../common/CustomTexts';
import { MarkdownIcon } from '../common/Icons';
interface Props {
body: string;
@@ -48,12 +49,20 @@ const NewComment = ({
<>
<div className="commentBodyForm">
<Gravatar email={userEmail} size={48} className="currentUserAvatar" />
<textarea
value={body}
onChange={handleChange}
placeholder={I18n.t('post.new_comment.body_placeholder')}
className="commentForm"
/>
<div style={{width: '100%', marginRight: '8px'}}>
<textarea
value={body}
onChange={handleChange}
autoFocus={parentId != null}
placeholder={I18n.t('post.new_comment.body_placeholder')}
className="commentForm"
/>
<div style={{position: 'relative', width: 0, height: 0}}>
<MarkdownIcon style={{position: 'absolute', left: '6px', top: '-28px'}} />
</div>
</div>
<Button
onClick={() => handleSubmit(body, parentId, postUpdateFlagValue)}
className="submitCommentButton">

View File

@@ -65,6 +65,7 @@ const PostEditForm = ({
type="text"
value={title}
onChange={e => handleChangeTitle(e.target.value)}
autoFocus
className="postTitle form-control"
/>
</div>

View File

@@ -2,7 +2,7 @@ import * as React from 'react';
import ReactMarkdown from 'react-markdown';
import I18n from 'i18n-js';
import IPost, { POST_APPROVAL_STATUS_APPROVED, POST_APPROVAL_STATUS_PENDING } from '../../interfaces/IPost';
import IPost, { POST_APPROVAL_STATUS_APPROVED, POST_APPROVAL_STATUS_PENDING, postJSON2JS } from '../../interfaces/IPost';
import IPostStatus from '../../interfaces/IPostStatus';
import IBoard from '../../interfaces/IBoard';
import ITenantSetting from '../../interfaces/ITenantSetting';
@@ -29,6 +29,7 @@ import HttpStatus from '../../constants/http_status';
import ActionLink from '../common/ActionLink';
import { EditIcon } from '../common/Icons';
import Badge, { BADGE_TYPE_DANGER, BADGE_TYPE_WARNING } from '../common/Badge';
import { likeJSON2JS } from '../../interfaces/ILike';
interface Props {
postId: number;
@@ -41,6 +42,7 @@ interface Props {
postStatusChanges: PostStatusChangesState;
boards: Array<IBoard>;
postStatuses: Array<IPostStatus>;
originPost: any;
isLoggedIn: boolean;
isPowerUser: boolean;
currentUserFullName: string;
@@ -48,7 +50,7 @@ interface Props {
tenantSetting: ITenantSetting;
authenticityToken: string;
requestPost(postId: number): void;
requestPost(postId: number): Promise<any>;
updatePost(
postId: number,
title: string,
@@ -58,7 +60,7 @@ interface Props {
authenticityToken: string,
): Promise<any>;
requestLikes(postId: number): void;
requestLikes(postId: number): Promise<any>;
requestFollow(postId: number): void;
requestPostStatusChanges(postId: number): void;
@@ -83,10 +85,20 @@ interface Props {
): void;
}
class PostP extends React.Component<Props> {
interface State {
postLoaded: boolean;
likesLoaded: boolean;
}
class PostP extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
postLoaded: false,
likesLoaded: false,
}
this._handleUpdatePost = this._handleUpdatePost.bind(this);
this._handleDeletePost = this._handleDeletePost.bind(this);
}
@@ -94,8 +106,8 @@ class PostP extends React.Component<Props> {
componentDidMount() {
const { postId } = this.props;
this.props.requestPost(postId);
this.props.requestLikes(postId);
this.props.requestPost(postId).then(() => this.setState({ postLoaded: true }));
this.props.requestLikes(postId).then(() => this.setState({ likesLoaded: true }));
this.props.requestFollow(postId);
this.props.requestPostStatusChanges(postId);
}
@@ -137,7 +149,10 @@ class PostP extends React.Component<Props> {
this.props.deletePost(
this.props.postId,
this.props.authenticityToken
).then(() => window.location.href = `/boards/${this.props.post.boardId}`);
).then(() => {
const board = this.props.boards.find(board => board.id === this.props.post.boardId);
window.location.href = `/boards/${board.slug || board.id}`;
});
}
render() {
@@ -151,6 +166,7 @@ class PostP extends React.Component<Props> {
postStatusChanges,
boards,
postStatuses,
originPost,
isLoggedIn,
isPowerUser,
@@ -166,6 +182,14 @@ class PostP extends React.Component<Props> {
handleChangeEditFormPostStatus,
} = this.props;
const {
postLoaded,
likesLoaded,
} = this.state;
const postToShow = postLoaded ? post : postJSON2JS(originPost.post);
const likesToShow = likesLoaded ? likes : { items: originPost.likes.map(l => likeJSON2JS(l)), areLoading: false, error: null };
const postUpdates = [
...comments.items.filter(comment => comment.isPostUpdate === true),
...postStatusChanges.items,
@@ -187,15 +211,15 @@ class PostP extends React.Component<Props> {
{
isPowerUser &&
<LikeList
likes={likes.items}
areLoading={likes.areLoading}
error={likes.error}
likes={likesToShow.items}
areLoading={likesToShow.areLoading}
error={likesToShow.error}
/>
}
<ActionBox
followed={followed}
submitFollow={() => submitFollow(post.id, !followed, authenticityToken)}
submitFollow={() => submitFollow(postToShow.id, !followed, authenticityToken)}
isLoggedIn={isLoggedIn}
/>
@@ -225,24 +249,24 @@ class PostP extends React.Component<Props> {
<>
<div className="postHeader">
<LikeButton
postId={post.id}
likeCount={likes.items.length}
postId={postToShow.id}
likeCount={likesToShow.items.length}
showLikeCount={isPowerUser || tenantSetting.show_vote_count}
liked={likes.items.find(like => like.email === currentUserEmail) ? 1 : 0}
liked={likesToShow.items.find(like => like.email === currentUserEmail) ? 1 : 0}
size="large"
isLoggedIn={isLoggedIn}
authenticityToken={authenticityToken}
/>
<h3>{post.title}</h3>
<h3>{postToShow.title}</h3>
</div>
<div className="postInfo">
<PostBoardLabel
{...boards.find(board => board.id === post.boardId)}
{...boards.find(board => board.id === postToShow.boardId)}
/>
<PostStatusLabel
{...postStatuses.find(postStatus => postStatus.id === post.postStatusId)}
{...postStatuses.find(postStatus => postStatus.id === postToShow.postStatusId)}
/>
{ isPowerUser &&
<ActionLink onClick={toggleEditMode} icon={<EditIcon />} customClass='editAction'>
@@ -252,10 +276,10 @@ class PostP extends React.Component<Props> {
</div>
{
(isPowerUser && post.approvalStatus !== POST_APPROVAL_STATUS_APPROVED) &&
(isPowerUser && postToShow.approvalStatus !== POST_APPROVAL_STATUS_APPROVED) &&
<div className="postInfo">
<Badge type={post.approvalStatus === POST_APPROVAL_STATUS_PENDING ? BADGE_TYPE_WARNING : BADGE_TYPE_DANGER}>
{ I18n.t(`activerecord.attributes.post.approval_status_${post.approvalStatus.toLowerCase()}`) }
<Badge type={postToShow.approvalStatus === POST_APPROVAL_STATUS_PENDING ? BADGE_TYPE_WARNING : BADGE_TYPE_DANGER}>
{ I18n.t(`activerecord.attributes.post.approval_status_${postToShow.approvalStatus.toLowerCase()}`) }
</Badge>
</div>
}
@@ -265,17 +289,17 @@ class PostP extends React.Component<Props> {
disallowedTypes={['heading', 'image', 'html']}
unwrapDisallowed
>
{post.description}
{postToShow.description}
</ReactMarkdown>
<PostFooter
createdAt={post.createdAt}
createdAt={postToShow.createdAt}
handleDeletePost={this._handleDeletePost}
toggleEditMode={toggleEditMode}
isPowerUser={isPowerUser}
authorEmail={post.userEmail}
authorFullName={post.userFullName}
authorEmail={postToShow.userEmail}
authorFullName={postToShow.userFullName}
currentUserEmail={currentUserEmail}
/>
</>

View File

@@ -33,7 +33,7 @@ const PostUpdateList = ({
<div className="postUpdateList">
{
postUpdates.length === 0 ?
postUpdates.length === 0 && !areLoading && !error ?
<CenteredMutedText>{I18n.t('post.updates_box.empty')}</CenteredMutedText>
:
null

View File

@@ -16,6 +16,7 @@ interface Props {
postId: number;
boards: Array<IBoard>;
postStatuses: Array<IPostStatus>;
originPost: any;
isLoggedIn: boolean;
isPowerUser: boolean;
currentUserFullName: string;
@@ -38,6 +39,7 @@ class PostRoot extends React.Component<Props> {
postId,
boards,
postStatuses,
originPost,
isLoggedIn,
isPowerUser,
currentUserFullName,
@@ -52,6 +54,7 @@ class PostRoot extends React.Component<Props> {
postId={postId}
boards={boards}
postStatuses={postStatuses}
originPost={originPost}
isLoggedIn={isLoggedIn}
isPowerUser={isPowerUser}

View File

@@ -1,5 +1,4 @@
import * as React from 'react';
import I18n from 'i18n-js';
import Box from '../../common/Box';
import { AuthenticationPages } from './AuthenticationSiteSettingsP';

View File

@@ -79,7 +79,7 @@ const AuthenticationIndexPage = ({
<h2>{ I18n.t('site_settings.authentication.title') }</h2>
<div className="emailRegistrationPolicy">
<h3>{ I18n.t('site_settings.authentication.email_registration_subtitle') }</h3>
<h4>{ I18n.t('site_settings.authentication.email_registration_subtitle') }</h4>
<form onSubmit={handleSubmit(onSubmit)} onChange={handleSubmit(onSubmit)}>
<div className="formGroup">

View File

@@ -1,6 +1,7 @@
import * as React from 'react';
import I18n from 'i18n-js';
import { SubmitHandler, useForm } from 'react-hook-form';
import { DangerText } from '../../common/CustomTexts';
import { getLabel, getValidationMessage } from '../../../helpers/formUtils';
import Button from '../../common/Button';
@@ -102,6 +103,7 @@ const OAuthForm = ({
>
{I18n.t('common.buttons.back')}
</ActionLink>
<h2>{ I18n.t(`site_settings.authentication.form.title_${page}`) }</h2>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="formRow">

View File

@@ -27,7 +27,7 @@ const OAuthProvidersList = ({
}: Props) => (
<>
<div className="oauthProvidersTitle">
<h3>{ I18n.t('site_settings.authentication.oauth_subtitle') }</h3>
<h4>{ I18n.t('site_settings.authentication.oauth_subtitle') }</h4>
<Button onClick={() => setPage('new')}>
{ I18n.t('common.buttons.new') }
</Button>

View File

@@ -4,6 +4,7 @@ import I18n from 'i18n-js';
import Button from '../../common/Button';
import { DangerText } from '../../common/CustomTexts';
import { MarkdownIcon } from '../../common/Icons';
interface Props {
mode: 'create' | 'update';
@@ -95,11 +96,17 @@ const BoardForm = ({
</Button>
</div>
<textarea
{...register('description')}
placeholder={I18n.t('site_settings.boards.form.description')}
className="boardDescriptionTextArea formControl"
/>
<div>
<textarea
{...register('description')}
placeholder={I18n.t('site_settings.boards.form.description')}
className="boardDescriptionTextArea formControl"
/>
<div style={{position: 'relative', width: 0, height: 0}}>
<MarkdownIcon style={{position: 'absolute', left: '6px', top: '-28px'}} />
</div>
</div>
{mode === 'update' && (
<>

View File

@@ -10,7 +10,7 @@ import buildRequestHeaders from '../../../helpers/buildRequestHeaders';
import HttpStatus from '../../../constants/http_status';
import { isValidEmail } from '../../../helpers/regex';
import IInvitation from '../../../interfaces/IInvitation';
import friendlyDate from '../../../helpers/datetime';
import friendlyDate, { fromRailsStringToJavascriptDate, nMonthsAgo } from '../../../helpers/datetime';
import ActionLink from '../../common/ActionLink';
import { TestIcon } from '../../common/Icons';
@@ -229,9 +229,14 @@ const Invitations = ({ siteName, invitations, currentUserEmail, authenticityToke
{ I18n.t('site_settings.invitations.accepted_at', { when: friendlyDate(invitation.accepted_at) }) }
</span>
:
<span className="invitationSentAt" title={invitation.updated_at}>
{ I18n.t('site_settings.invitations.sent_at', { when: friendlyDate(invitation.updated_at) }) }
</span>
fromRailsStringToJavascriptDate(invitation.updated_at) > nMonthsAgo(3) ?
<span className="invitationSentAt" title={invitation.updated_at}>
{ I18n.t('site_settings.invitations.sent_at', { when: friendlyDate(invitation.updated_at) }) }
</span>
:
<span className="invitationExpired">
{ I18n.t('site_settings.invitations.expired') }
</span>
}
</div>
</li>
@@ -243,4 +248,4 @@ const Invitations = ({ siteName, invitations, currentUserEmail, authenticityToke
);
};
export default Invitations;
export default Invitations;

View File

@@ -50,6 +50,7 @@ const RoadmapEmbedding: React.FC<Props> = ({ embeddedRoadmapUrl }) => {
onChange={event => setEmbedCode(event.target.value)}
rows={5}
id="roadmapEmbedCode"
className="formControl"
>
</textarea>

View File

@@ -0,0 +1,241 @@
import React, { useState } from 'react';
import I18n from 'i18n-js';
import Select from 'react-select';
// To keep in sync with app/workflows/create_liquid_template_context_workflow.rb
const tenantOptions = [
{ value: '{{ tenant.site_name }}', label: 'Feedback space name' },
{ value: '{{ tenant.subdomain }}', label: 'Feedback space subdomain' },
{ value: '{{ tenant.custom_domain }}', label: 'Feedback space custom domain' },
];
const boardOptions = [
{ value: '{{ board.id }}', label: 'Board ID' },
{ value: '{{ board.name }}', label: 'Board name' },
{ value: '{{ board.description }}', label: 'Board description' },
{ value: '{{ board.slug }}', label: 'Board slug' },
{ value: '{{ board.created_at }}', label: 'Board created at datetime' },
{ value: '{{ board.updated_at }}', label: 'Board updated at datetime' },
];
const postStatusOptions = [
{ value: '{{ post_status.id }}', label: 'Post status ID' },
{ value: '{{ post_status.name }}', label: 'Post status name' },
{ value: '{{ post_status.color }}', label: 'Post status color' },
{ value: '{{ post_status.show_in_roadmap }}', label: 'Post status show in roadmap flag' },
{ value: '{{ post_status.created_at }}', label: 'Post status created at datetime' },
{ value: '{{ post_status.updated_at }}', label: 'Post status updated at datetime' },
];
const userOptions = (userKeyValue: string, userKeyLabel: string) => [
{ value: `{{ ${userKeyValue}.id }}`, label: `${userKeyLabel} ID` },
{ value: `{{ ${userKeyValue}.email }}`, label: `${userKeyLabel} email` },
{ value: `{{ ${userKeyValue}.full_name }}`, label: `${userKeyLabel} full name` },
{ value: `{{ ${userKeyValue}.role }}`, label: `${userKeyLabel} role` },
{ value: `{{ ${userKeyValue}.status }}`, label: `${userKeyLabel} status` },
{ value: `{{ ${userKeyValue}.created_at }}`, label: `${userKeyLabel} created at datetime` },
{ value: `{{ ${userKeyValue}.updated_at }}`, label: `${userKeyLabel} updated at datetime` },
];
const postOptions = [
{ value: '{{ post.id }}', label: 'Post ID' },
{ value: '{{ post.title }}', label: 'Post title' },
{ value: '{{ post.description }}', label: 'Post description' },
{ value: '{{ post.slug }}', label: 'Post slug' },
{ value: '{{ post.created_at }}', label: 'Post created at datetime' },
{ value: '{{ post.updated_at }}', label: 'Post updated at datetime' },
{ value: '{{ post.url }}', label: 'Post URL' },
];
const commentOptions = [
{ value: '{{ comment.id }}', label: 'Comment ID' },
{ value: '{{ comment.body }}', label: 'Comment body' },
{ value: '{{ comment.created_at }}', label: 'Comment created at datetime' },
{ value: '{{ comment.updated_at }}', label: 'Comment updated at datetime' },
];
const optionsByWebhookTrigger = {
'new_post': [
...postOptions,
...userOptions('post_author', 'Post author'),
...boardOptions,
...tenantOptions,
],
'new_post_pending_approval': [
...postOptions,
...userOptions('post_author', 'Post author'),
...boardOptions,
...tenantOptions,
],
'delete_post': [
// only post.id is available on delete_post
postOptions.find(option => option.value === '{{ post.id }}'),
...tenantOptions,
],
'post_status_change': [
...postOptions,
...userOptions('post_author', 'Post author'),
...boardOptions,
...postStatusOptions,
...tenantOptions,
],
'new_comment': [
...commentOptions,
...userOptions('comment_author', 'Comment author'),
...postOptions,
...userOptions('post_author', 'Post author'),
...boardOptions,
...tenantOptions,
],
'new_vote': [
...userOptions('vote_author', 'Vote author'),
...postOptions,
...userOptions('post_author', 'Post author'),
...boardOptions,
...tenantOptions,
],
'new_user': [
...userOptions('user', 'User'),
...tenantOptions,
],
};
// Non-exhaustive list of Liquid tags
const liquidTagsOptions = {
label: 'Liquid tags',
options: [
{ value: '{% if <condition> %}\n\n{% endif %}', label: 'If condition' },
{ value: '{% for <item> in <collection> %}\n\n{% endfor %}', label: 'For loop' },
{ value: '{% tablerow <item> in <collection> %}\n\n{% endtablerow %}', label: 'Tablerow loop' },
{ value: '{% assign <variable> = <value> %}', label: 'Assign variable' },
]
};
// Non-exhaustive list of Liquid filters
const liquidFiltersOptions = {
label: 'Liquid filters',
options: [
{ value: ' | abs', label: 'Absolute value' },
{ value: ' | append: <value>', label: 'Append' },
{ value: ' | capitalize', label: 'Capitalize' },
{ value: ' | ceil', label: 'Ceil' },
{ value: ' | compact', label: 'Compact' },
{ value: ' | concat: <array>', label: 'Concat' },
{ value: ' | date: <format>', label: 'Date' },
{ value: ' | default: <value>', label: 'Default' },
{ value: ' | divided_by: <value>', label: 'Divided by' },
{ value: ' | downcase', label: 'Downcase' },
{ value: ' | escape', label: 'Escape' },
{ value: ' | escape_once', label: 'Escape once' },
{ value: ' | first', label: 'First' },
{ value: ' | floor', label: 'Floor' },
{ value: ' | join: <value>', label: 'Join' },
{ value: ' | last', label: 'Last' },
{ value: ' | lstrip', label: 'Lstrip' },
{ value: ' | map: <value>', label: 'Map' },
{ value: ' | minus: <value>', label: 'Minus' },
{ value: ' | modulo: <value>', label: 'Modulo' },
{ value: ' | newline_to_br', label: 'Newline to br' },
{ value: ' | plus: <value>', label: 'Plus' },
{ value: ' | prepend: <value>', label: 'Prepend' },
{ value: ' | remove: <value>', label: 'Remove' },
{ value: ' | remove_first: <value>', label: 'Remove first' },
{ value: ' | replace: <value>, <new_value>', label: 'Replace' },
{ value: ' | replace_first: <value>, <new_value>', label: 'Replace first' },
{ value: ' | replace_last: <value>, <new_value>', label: 'Replace last' },
{ value: ' | reverse', label: 'Reverse' },
{ value: ' | round', label: 'Round' },
{ value: ' | rstrip', label: 'Rstrip' },
{ value: ' | size', label: 'Size' },
{ value: ' | slice: <value>', label: 'Slice' },
{ value: ' | sort', label: 'Sort' },
{ value: ' | sort_natural', label: 'Sort natural' },
{ value: ' | split: <value>', label: 'Split' },
{ value: ' | strip', label: 'Strip' },
{ value: ' | strip_html', label: 'Strip html' },
{ value: ' | strip_newlines', label: 'Strip newlines' },
{ value: ' | times: <value>', label: 'Times' },
{ value: ' | truncate: <value>', label: 'Truncate' },
{ value: ' | truncatewords: <value>', label: 'Truncate words' },
{ value: ' | uniq', label: 'Uniq' },
{ value: ' | upcase', label: 'Upcase' },
{ value: ' | url_decode', label: 'Url decode' },
{ value: ' | url_encode', label: 'Url encode' },
{ value: ' | where: <value>', label: 'Where' },
]
};
// Custom Liquid filters
const customLiquidFiltersOptions = {
label: 'Custom Liquid filters',
options: [
{ value: ' | escape_json', label: 'Escape JSON' },
]
};
interface Props {
webhookTrigger: string;
onChange: (option: any) => void;
}
const TemplateVariablesSelector = ({
webhookTrigger,
onChange,
}: Props) => {
const options = [
{
label: 'Astuto variables',
options: optionsByWebhookTrigger[webhookTrigger] || [],
},
{
label: 'Liquid tags',
options: liquidTagsOptions.options,
},
{
label: 'Liquid filters',
options: liquidFiltersOptions.options,
},
{
label: 'Custom Liquid filters',
options: customLiquidFiltersOptions.options,
},
];
const [selectedOption, setSelectedOption] = useState(null);
const handleChange = (option) => {
onChange(option.value);
// Reset the selection
setSelectedOption(null);
};
return (
<Select
options={options}
value={selectedOption}
onChange={handleChange}
isClearable={false}
isSearchable
placeholder={I18n.t('site_settings.webhooks.form.template_variables_selector_placeholder')}
styles={{
control: (provided, state) => ({
...provided,
boxShadow: 'none',
borderColor: state.isFocused ? '#333333' : '#cdcdcd',
'&:hover': {
boxShadow: 'none',
borderColor: '#333333',
},
}),
option: (provided, state) => ({
...provided,
color: 'inherit',
backgroundColor: state.isFocused ? '#f2f2f2' : 'white',
}),
}}
/>
);
};
export default TemplateVariablesSelector;

View File

@@ -0,0 +1,415 @@
import * as React from 'react';
import I18n from 'i18n-js';
import { SubmitHandler, useFieldArray, useForm } from 'react-hook-form';
import { IWebhook, WEBHOOK_HTTP_METHOD_DELETE, WEBHOOK_HTTP_METHOD_PATCH, WEBHOOK_HTTP_METHOD_POST, WEBHOOK_HTTP_METHOD_PUT, WEBHOOK_TRIGGER_DELETED_POST, WEBHOOK_TRIGGER_NEW_COMMENT, WEBHOOK_TRIGGER_NEW_POST, WEBHOOK_TRIGGER_NEW_POST_PENDING_APPROVAL, WEBHOOK_TRIGGER_NEW_USER, WEBHOOK_TRIGGER_NEW_VOTE, WEBHOOK_TRIGGER_POST_STATUS_CHANGE, WebhookHttpMethod, WebhookTrigger } from '../../../interfaces/IWebhook';
import { WebhookPages } from './WebhooksSiteSettingsP';
import ActionLink from '../../common/ActionLink';
import { AddIcon, BackIcon, DeleteIcon, LiquidIcon, PreviewIcon } from '../../common/Icons';
import { getLabel, getValidationMessage } from '../../../helpers/formUtils';
import { DangerText } from '../../common/CustomTexts';
import Button from '../../common/Button';
import { URL_REGEX_WHITESPACE_ALLOWED } from '../../../constants/regex';
import Spinner from '../../common/Spinner';
import buildRequestHeaders from '../../../helpers/buildRequestHeaders';
import HttpStatus from '../../../constants/http_status';
import { useRef, useState } from 'react';
import TemplateVariablesSelector from './TemplateVariablesSelector';
interface Props {
isSubmitting: boolean;
submitError: string;
selectedWebhook: IWebhook;
page: WebhookPages;
setPage: React.Dispatch<React.SetStateAction<WebhookPages>>;
handleSubmitWebhook(webhook: IWebhook): void;
handleUpdateWebhook(id: number, form: ISiteSettingsWebhookFormUpdate): void;
authenticityToken: string;
}
interface ISiteSettingsWebhookFormBase {
name: string;
description: string;
trigger: string;
url: string;
httpBody: string;
httpMethod: string;
}
interface ISiteSettingsWebhookForm extends ISiteSettingsWebhookFormBase {
httpHeaders: Array<{ key: string, value: string }>;
}
export interface ISiteSettingsWebhookFormUpdate extends ISiteSettingsWebhookFormBase {
httpHeaders: string;
}
// This method tries to parse httpHeaders JSON, otherwise returns [{ key: '', value: '' }]
const parseHttpHeaders = (httpHeaders: string) => {
try {
return JSON.parse(httpHeaders);
} catch (e) {
return [{ key: '', value: '' }];
}
}
const WebhookFormPage = ({
isSubmitting,
submitError,
selectedWebhook,
page,
setPage,
handleSubmitWebhook,
handleUpdateWebhook,
authenticityToken,
}: Props) => {
const {
register,
handleSubmit,
control,
formState: { errors, isDirty },
watch,
getValues,
setValue,
} = useForm<ISiteSettingsWebhookForm>({
defaultValues: page === 'new' ? {
name: '',
description: '',
trigger: WEBHOOK_TRIGGER_NEW_POST,
url: '',
httpBody: '',
httpMethod: WEBHOOK_HTTP_METHOD_POST,
httpHeaders: [{ key: '', value: '' }],
} : {
name: selectedWebhook.name,
description: selectedWebhook.description,
trigger: selectedWebhook.trigger,
url: selectedWebhook.url,
httpBody: selectedWebhook.httpBody,
httpMethod: selectedWebhook.httpMethod,
httpHeaders: parseHttpHeaders(selectedWebhook.httpHeaders),
}
});
const { fields, append, remove } = useFieldArray({
control,
name: 'httpHeaders', // The name of the httpHeaders field
});
const onSubmit: SubmitHandler<ISiteSettingsWebhookForm> = data => {
// Remove empty headers
let httpHeaders = data.httpHeaders.filter(header => header.key !== '' && header.value !== '');
const webhook = {
isEnabled: false,
name: data.name,
description: data.description,
trigger: data.trigger as WebhookTrigger,
url: data.url.replace(/\s/g, ''),
httpBody: data.httpBody,
httpMethod: data.httpMethod as WebhookHttpMethod,
httpHeaders: JSON.stringify(httpHeaders),
};
if (page === 'new') {
handleSubmitWebhook(webhook);
} else if (page === 'edit') {
handleUpdateWebhook(selectedWebhook.id, webhook);
}
};
const trigger = watch('trigger');
const url = watch('url');
const httpBody = watch('httpBody');
const httpBodyTextAreaRef = useRef(null);
const [cursorPosition, setCursorPosition] = React.useState(0);
const handleCursorPosition = e => {
setCursorPosition(e.target.selectionStart);
};
// Insert custom string at the last cursor position
const insertString = (stringToInsert: string) => {
const currentValue = getValues('httpBody'); // Get the current textarea value
const start = currentValue.slice(0, cursorPosition);
const end = currentValue.slice(cursorPosition);
const newValue = start + stringToInsert + end;
// Update textarea value with react-hook-form
setValue('httpBody', newValue, { shouldDirty: true });
setIsPreviewOutdated(true);
// Update cursor position after the custom string
const newCursorPosition = cursorPosition + stringToInsert.length;
setCursorPosition(newCursorPosition);
// Update the DOM to reflect the cursor position
if (httpBodyTextAreaRef.current) {
setTimeout(() => {
httpBodyTextAreaRef.current.setSelectionRange(newCursorPosition, newCursorPosition);
httpBodyTextAreaRef.current.focus();
}, 0);
}
};
// State for URL and body preview
const [isPreviewVisible, setIsPreviewVisible] = useState(false);
const [previewContent, setPreviewContent] = useState('');
const [isPreviewOutdated, setIsPreviewOutdated] = useState(true);
return (
<>
<ActionLink
onClick={() => {
let confirmation = true;
if (isDirty)
confirmation = confirm(I18n.t('common.unsaved_changes') + ' ' + I18n.t('common.confirmation'));
if (confirmation) setPage('index');
}}
icon={<BackIcon />}
customClass="backButton"
>
{I18n.t('common.buttons.back')}
</ActionLink>
<h2>{ I18n.t(`site_settings.webhooks.form.title_${page}`) }</h2>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="formRow">
<div className="formGroup col-6">
<label htmlFor="name">{ getLabel('webhook', 'name') }</label>
<input
{...register('name', { required: true, maxLength: 255 })}
id="name"
className="formControl"
/>
<DangerText>{errors.name?.type === 'required' && getValidationMessage(errors.name.type, 'webhook', 'name')}</DangerText>
<DangerText>{errors.name?.type === 'maxLength' && (getLabel('webhook', 'name') + ' ' + I18n.t('activerecord.errors.messages.too_long', { count: 255 }))}</DangerText>
</div>
<div className="formGroup col-6">
<label htmlFor="description">{ getLabel('webhook', 'description') }</label>
<input
{...register('description', { maxLength: 255 })}
id="description"
className="formControl"
/>
<DangerText>{errors.description?.type === 'maxLength' && (getLabel('webhook', 'description') + ' ' + I18n.t('activerecord.errors.messages.too_long', { count: 255 }))}</DangerText>
</div>
</div>
<div className="formGroup">
<label htmlFor="trigger">{ getLabel('webhook', 'trigger') }</label>
<select
{...register('trigger')}
id="trigger"
className="selectPicker"
>
<option value={WEBHOOK_TRIGGER_NEW_POST}>
{I18n.t('site_settings.webhooks.triggers.new_post')}
</option>
<option value={WEBHOOK_TRIGGER_NEW_POST_PENDING_APPROVAL}>
{I18n.t('site_settings.webhooks.triggers.new_post_pending_approval')}
</option>
<option value={WEBHOOK_TRIGGER_DELETED_POST}>
{I18n.t('site_settings.webhooks.triggers.delete_post')}
</option>
<option value={WEBHOOK_TRIGGER_POST_STATUS_CHANGE}>
{I18n.t('site_settings.webhooks.triggers.post_status_change')}
</option>
<option value={WEBHOOK_TRIGGER_NEW_COMMENT}>
{I18n.t('site_settings.webhooks.triggers.new_comment')}
</option>
<option value={WEBHOOK_TRIGGER_NEW_VOTE}>
{I18n.t('site_settings.webhooks.triggers.new_vote')}
</option>
<option value={WEBHOOK_TRIGGER_NEW_USER}>
{I18n.t('site_settings.webhooks.triggers.new_user')}
</option>
</select>
<DangerText>{errors.trigger && getValidationMessage(errors.trigger.type, 'webhook', 'trigger')}</DangerText>
</div>
<div className="formRow">
<div className="formGroup col-3">
<label htmlFor="httpMethod">{ getLabel('webhook', 'http_method') }</label>
<select
{...register('httpMethod')}
id="httpMethod"
className="selectPicker"
>
<option value={WEBHOOK_HTTP_METHOD_POST}>
{I18n.t('site_settings.webhooks.http_methods.post')}
</option>
<option value={WEBHOOK_HTTP_METHOD_PUT}>
{I18n.t('site_settings.webhooks.http_methods.put')}
</option>
<option value={WEBHOOK_HTTP_METHOD_PATCH}>
{I18n.t('site_settings.webhooks.http_methods.patch')}
</option>
<option value={WEBHOOK_HTTP_METHOD_DELETE}>
{I18n.t('site_settings.webhooks.http_methods.delete')}
</option>
</select>
</div>
<div className="formGroup col-9">
<label htmlFor="url">
{ getLabel('webhook', 'url') }
&nbsp;
{ <LiquidIcon /> }
</label>
<input
{...register('url', {
required: true,
pattern: URL_REGEX_WHITESPACE_ALLOWED,
onChange: () => setIsPreviewOutdated(true),
})}
autoComplete="off"
id="url"
className="formControl"
/>
<DangerText>{errors.url?.type === 'required' && getValidationMessage(errors.url.type, 'webhook', 'url')}</DangerText>
<DangerText>{errors.url?.type === 'pattern' && I18n.t('common.validations.url')}</DangerText>
</div>
</div>
<div className="formGroup">
<label htmlFor="httpBody">
{ getLabel('webhook', 'http_body') }
&nbsp;
{ <LiquidIcon /> }
</label>
<textarea
{...register('httpBody', {
onChange: () => setIsPreviewOutdated(true)
})}
ref={(e) => {
register('httpBody').ref(e); // Combine react-hook-form's ref with custom ref
httpBodyTextAreaRef.current = e; // Store a local reference
}}
onClick={handleCursorPosition}
onKeyUp={handleCursorPosition}
id="httpBody"
className="formControl"
/>
<div className="httpBodyActions">
<TemplateVariablesSelector webhookTrigger={trigger} onChange={insertString} />
<ActionLink
icon={<PreviewIcon />}
onClick={async () => {
if ((url === '' && httpBody === '') || !isPreviewOutdated) return;
const res = await fetch(`/webhooks_preview`, {
method: 'PUT',
headers: buildRequestHeaders(authenticityToken),
body: JSON.stringify({
webhook: {
trigger: trigger,
url: url,
http_body: httpBody,
}
}),
});
const json = await res.json();
if (res.status === HttpStatus.OK) {
setPreviewContent(
getLabel('webhook', 'url') + ":\n" +
json.url_preview + "\n\n" +
getLabel('webhook', 'http_body') + ":\n" +
json.http_body_preview
);
} else {
setPreviewContent(
I18n.t('site_settings.webhooks.form.preview_error') + "\n" +
json.error
)
}
setIsPreviewOutdated(false);
setIsPreviewVisible(true);
}}
disabled={(url === '' && httpBody === '') || !isPreviewOutdated}
customClass="previewHttpBody"
>
{I18n.t('common.buttons.preview')}
</ActionLink>
</div>
{
isPreviewVisible &&
<div className="urlAndHttpBodyPreview">
<label>{ I18n.t('common.buttons.preview') }</label>
<pre id="preview">{previewContent}</pre>
</div>
}
</div>
<div className="formGroup formGroupHttpHeaders">
{
fields.map((field, index) => (
<div className="formRow" key={field.id}>
<div className="formGroup col-5">
<label htmlFor={`httpHeaders${index+1}Key`}>{ I18n.t('site_settings.webhooks.form.header_n_key', { n: index+1 }) }</label>
<input
{...register(`httpHeaders.${index}.key`, { required: (index!==0) })}
id={`httpHeaders${index+1}Key`}
className="formControl"
/>
<DangerText>
{errors.httpHeaders && errors.httpHeaders[index]?.key?.type === 'required' && getValidationMessage(errors.httpHeaders[index]?.key?.type, 'webhook', 'http_headers')}
</DangerText>
</div>
<div className="formGroup col-5">
<label htmlFor={`httpHeaders${index+1}Value`}>{ I18n.t('site_settings.webhooks.form.header_n_value', { n: index+1 }) }</label>
<input
{...register(`httpHeaders.${index}.value`, { required: (index!==0) })}
autoComplete="off"
id={`httpHeaders${index+1}Value`}
className="formControl"
/>
<DangerText>
{errors.httpHeaders && errors.httpHeaders[index]?.value?.type === 'required' && getValidationMessage(errors.httpHeaders[index]?.value?.type, 'webhook', 'http_headers')}
</DangerText>
</div>
<div className="formGroup col-2 deleteHeaderActionLinkContainer">
<ActionLink icon={<DeleteIcon />} onClick={() => remove(index)}>
{I18n.t('common.buttons.delete')}
</ActionLink>
</div>
</div>
))
}
</div>
<ActionLink icon={<AddIcon />} onClick={() => append({ key: "", value: "" })}>
{I18n.t('site_settings.webhooks.form.add_header')}
</ActionLink>
<Button onClick={() => null} type="submit" className="submitWebhookFormButton">
{
isSubmitting ?
<Spinner color="light" />
:
page === 'new' ?
I18n.t('common.buttons.create')
:
I18n.t('common.buttons.update')
}
</Button>
</form>
{ submitError && <p style={{marginTop: '1rem', marginBottom: '0'}}><DangerText>{submitError}</DangerText></p> }
</>
);
}
export default WebhookFormPage;

View File

@@ -0,0 +1,48 @@
import * as React from 'react';
import { WebhookPages } from './WebhooksSiteSettingsP';
import Box from '../../common/Box';
import { IWebhook } from '../../../interfaces/IWebhook';
import WebhookForm, { ISiteSettingsWebhookFormUpdate } from './WebhookForm';
interface Props {
isSubmitting: boolean;
submitError: string;
handleSubmitWebhook(webhook: IWebhook): void;
handleUpdateWebhook(id: number, form: ISiteSettingsWebhookFormUpdate): void;
selectedWebhook: IWebhook;
page: WebhookPages;
setPage: React.Dispatch<React.SetStateAction<WebhookPages>>;
authenticityToken: string;
}
const WebhookFormPage = ({
isSubmitting,
submitError,
handleSubmitWebhook,
handleUpdateWebhook,
selectedWebhook,
page,
setPage,
authenticityToken,
}: Props) => (
<>
<Box customClass="webhookFormPage">
<WebhookForm
isSubmitting={isSubmitting}
submitError={submitError}
handleSubmitWebhook={handleSubmitWebhook}
handleUpdateWebhook={handleUpdateWebhook}
selectedWebhook={selectedWebhook}
page={page}
setPage={setPage}
authenticityToken={authenticityToken}
/>
</Box>
</>
);
export default WebhookFormPage;

View File

@@ -0,0 +1,88 @@
import * as React from 'react';
import I18n from 'i18n-js';
import { IWebhook } from '../../../interfaces/IWebhook';
import { WebhookPages } from './WebhooksSiteSettingsP';
import Switch from '../../common/Switch';
import ActionLink from '../../common/ActionLink';
import { DeleteIcon, EditIcon, TestIcon } from '../../common/Icons';
import buildRequestHeaders from '../../../helpers/buildRequestHeaders';
const WEBHOOK_DESCRIPTION_MAX_LENGTH = 100;
interface Props {
webhook: IWebhook;
handleToggleEnabledWebhook: (id: number, enabled: boolean) => void;
handleDeleteWebhook: (id: number) => void;
handleTestWebhook: (id: number) => void;
setSelectedWebhook: React.Dispatch<React.SetStateAction<number>>;
setPage: React.Dispatch<React.SetStateAction<WebhookPages>>;
}
const WebhookListItem = ({
webhook,
handleToggleEnabledWebhook,
handleDeleteWebhook,
handleTestWebhook,
setSelectedWebhook,
setPage,
}: Props) => (
<li className="webhookListItem">
<div className="webhookInfo">
<div className="webhookNameAndEnabled">
<div className="webhookName">{webhook.name}</div>
{ webhook.description &&
<p className="webhookDescription">
{
webhook.description.length > WEBHOOK_DESCRIPTION_MAX_LENGTH ?
`${webhook.description.slice(0, WEBHOOK_DESCRIPTION_MAX_LENGTH)}...`
:
webhook.description
}
</p>
}
<Switch
label={I18n.t(`common.${webhook.isEnabled ? 'enabled' : 'disabled'}`)}
onClick={() => handleToggleEnabledWebhook(webhook.id, !webhook.isEnabled)}
checked={webhook.isEnabled}
htmlId={`webhook${webhook.name}EnabledSwitch`}
/>
</div>
</div>
<div className="webhookActions">
<ActionLink
onClick={() => handleTestWebhook(webhook.id)}
icon={<TestIcon />}
customClass='testAction'
>
{I18n.t('common.buttons.test')}
</ActionLink>
<ActionLink
onClick={() => {
setSelectedWebhook(webhook.id);
setPage('edit');
}}
icon={<EditIcon />}
customClass="editAction"
>
{I18n.t('common.buttons.edit')}
</ActionLink>
<ActionLink
onClick={() => confirm(I18n.t('common.confirmation')) && handleDeleteWebhook(webhook.id)}
icon={<DeleteIcon />}
customClass="deleteAction"
>
{I18n.t('common.buttons.delete')}
</ActionLink>
</div>
</li>
);
export default WebhookListItem;

View File

@@ -0,0 +1,84 @@
import * as React from 'react';
import I18n from 'i18n-js';
import Box from '../../common/Box';
import { IWebhook } from '../../../interfaces/IWebhook';
import { WebhookPages } from './WebhooksSiteSettingsP';
import ActionLink from '../../common/ActionLink';
import { BackIcon, EditIcon, TestIcon } from '../../common/Icons';
import buildRequestHeaders from '../../../helpers/buildRequestHeaders';
import Badge, { BADGE_TYPE_DANGER, BADGE_TYPE_SUCCESS } from '../../common/Badge';
interface Props {
selectedWebhook: IWebhook;
testHttpCode: number;
testHttpResponse: string;
setSelectedWebhook: React.Dispatch<React.SetStateAction<number>>;
setPage: React.Dispatch<React.SetStateAction<WebhookPages>>;
handleTestWebhook: (id: number) => void;
}
const WebhookTestPage = ({
selectedWebhook,
testHttpCode,
testHttpResponse,
setSelectedWebhook,
setPage,
handleTestWebhook,
}: Props) => (
<Box customClass="webhookTestPage">
<ActionLink
onClick={() => setPage('index') }
icon={<BackIcon />}
customClass="backButton"
>
{I18n.t('common.buttons.back')}
</ActionLink>
<div className="webhookTestTitle">
<h2>{I18n.t('site_settings.webhooks.test_page.title')}</h2>
</div>
<div className="webhookTestContent">
<div className="webhookTestInfo">
<p>
<b>{I18n.t('activerecord.models.webhook', { count: 1 })}</b>:&nbsp;
<span>{selectedWebhook.name}</span>
</p>
<div className="webhookActions">
<ActionLink
onClick={() => handleTestWebhook(selectedWebhook.id)}
icon={<TestIcon />}
customClass='testAction'
>
{I18n.t('common.buttons.test')}
</ActionLink>
<ActionLink
onClick={() => {
setSelectedWebhook(selectedWebhook.id);
setPage('edit');
}}
icon={<EditIcon />}
customClass="editAction"
>
{I18n.t('common.buttons.edit') + ' ' + I18n.t('activerecord.models.webhook', { count: 1 })}
</ActionLink>
</div>
</div>
<div className="webhookTestResponse">
<Badge type={Array.from({length: 100}, (_, i) => i + 200).includes(testHttpCode) ? BADGE_TYPE_SUCCESS : BADGE_TYPE_DANGER}>
{testHttpCode.toString()}
</Badge>
<pre id="testHttpResponse">{testHttpResponse}</pre>
</div>
</div>
</Box>
);
export default WebhookTestPage;

View File

@@ -0,0 +1,77 @@
import * as React from 'react';
import I18n from 'i18n-js';
import Box from '../../common/Box';
import { WebhooksState } from '../../../reducers/webhooksReducer';
import { WebhookPages } from './WebhooksSiteSettingsP';
import Button from '../../common/Button';
import WebhooksList from './WebhooksList';
import SiteSettingsInfoBox from '../../common/SiteSettingsInfoBox';
import ActionLink from '../../common/ActionLink';
import { LearnMoreIcon } from '../../common/Icons';
interface Props {
webhooks: WebhooksState;
isSubmitting: boolean;
isTesting: boolean;
submitError: string;
handleToggleEnabledWebhook: (id: number, enabled: boolean) => void;
handleDeleteWebhook: (id: number) => void;
handleTestWebhook: (id: number) => void;
setSelectedWebhook: React.Dispatch<React.SetStateAction<number>>;
setPage: React.Dispatch<React.SetStateAction<WebhookPages>>;
}
const WebhooksIndexPage = ({
webhooks,
isSubmitting,
isTesting,
submitError,
handleToggleEnabledWebhook,
handleDeleteWebhook,
handleTestWebhook,
setSelectedWebhook,
setPage,
}: Props) => {
return (
<>
<Box customClass="webhooksIndexPage">
<div className="webhooksTitle">
<h2>{I18n.t('site_settings.webhooks.title')}</h2>
<Button onClick={() => setPage('new')}>
{ I18n.t('common.buttons.new') }
</Button>
</div>
<p style={{textAlign: 'left'}}>
<ActionLink
onClick={() => window.open('https://docs.astuto.io/webhooks/webhooks-introduction/', '_blank')}
icon={<LearnMoreIcon />}
>
{I18n.t('site_settings.webhooks.learn_more')}
</ActionLink>
</p>
<WebhooksList
webhooks={webhooks.items}
webhooksAreLoading={webhooks.areLoading}
handleToggleEnabledWebhook={handleToggleEnabledWebhook}
handleDeleteWebhook={handleDeleteWebhook}
handleTestWebhook={handleTestWebhook}
setSelectedWebhook={setSelectedWebhook}
setPage={setPage}
/>
</Box>
<SiteSettingsInfoBox
areUpdating={webhooks.areLoading || isSubmitting || isTesting}
error={webhooks.error || submitError}
/>
</>
);
};
export default WebhooksIndexPage;

View File

@@ -0,0 +1,67 @@
import * as React from 'react';
import I18n from 'i18n-js';
import { WebhookPages } from './WebhooksSiteSettingsP';
import { IWebhook } from '../../../interfaces/IWebhook';
import WebhookListItem from './WebhookListItem';
import { CenteredMutedText } from '../../common/CustomTexts';
import Spinner from '../../common/Spinner';
interface Props {
webhooks: Array<IWebhook>;
webhooksAreLoading: boolean;
handleToggleEnabledWebhook: (id: number, enabled: boolean) => void;
handleDeleteWebhook: (id: number) => void;
handleTestWebhook: (id: number) => void;
setSelectedWebhook: React.Dispatch<React.SetStateAction<number>>;
setPage: React.Dispatch<React.SetStateAction<WebhookPages>>;
}
const WebhooksList = ({
webhooks,
webhooksAreLoading,
handleToggleEnabledWebhook,
handleDeleteWebhook,
handleTestWebhook,
setSelectedWebhook,
setPage,
}: Props) => {
// from webhooks, get a unique list of triggers
const triggers = Array.from(new Set(webhooks.map(webhook => webhook.trigger)));
if (webhooksAreLoading) return <Spinner />;
return (
<div className="webhooksList">
{
(webhooks && webhooks.length > 0) ?
triggers.map((trigger, i) => (
<div key={i}>
<h4>{I18n.t(`site_settings.webhooks.triggers.${trigger}`)}</h4>
<ul>
{
webhooks.filter(webhook => webhook.trigger === trigger).map((webhook, j) => (
<WebhookListItem
webhook={webhook}
handleToggleEnabledWebhook={handleToggleEnabledWebhook}
handleDeleteWebhook={handleDeleteWebhook}
handleTestWebhook={handleTestWebhook}
setSelectedWebhook={setSelectedWebhook}
setPage={setPage}
key={j}
/>
))
}
</ul>
</div>
))
:
<CenteredMutedText>{I18n.t('site_settings.webhooks.empty')}</CenteredMutedText>
}
</div>
);
};
export default WebhooksList;

View File

@@ -0,0 +1,124 @@
import * as React from 'react';
import { useState, useEffect } from 'react';
import { WebhooksState } from '../../../reducers/webhooksReducer';
import WebhooksIndexPage from './WebhooksIndexPage';
import WebhookFormPage from './WebhookFormPage';
import { IWebhook } from '../../../interfaces/IWebhook';
import HttpStatus from '../../../constants/http_status';
import { ISiteSettingsWebhookFormUpdate } from './WebhookForm';
import WebhookTestPage from './WebhookTestPage';
import buildRequestHeaders from '../../../helpers/buildRequestHeaders';
interface Props {
webhooks: WebhooksState;
isSubmitting: boolean;
submitError: string;
requestWebhooks(): void;
onSubmitWebhook(webhook: IWebhook, authenticityToken: string): Promise<any>;
onUpdateWebhook(id: number, form: ISiteSettingsWebhookFormUpdate, authenticityToken: string): Promise<any>;
onToggleEnabledWebhook(id: number, isEnabled: boolean, authenticityToken: string): Promise<any>;
onDeleteWebhook(id: number, authenticityToken: string): void;
authenticityToken: string;
}
export type WebhookPages = 'index' | 'new' | 'edit' | 'test';
const WebhooksSiteSettingsP = ({
webhooks,
isSubmitting,
submitError,
requestWebhooks,
onSubmitWebhook,
onUpdateWebhook,
onToggleEnabledWebhook,
onDeleteWebhook,
authenticityToken,
}: Props) => {
const [page, setPage] = useState<WebhookPages>('index');
const [selectedWebhook, setSelectedWebhook] = useState<number>(null);
const [isTesting, setIsTesting] = useState<boolean>(false);
const [testHttpCode, setTestHttpCode] = useState<number>(null);
const [testHttpResponse, setTestHttpResponse] = useState<string>(null);
useEffect(requestWebhooks, []);
const handleSubmitWebhook = (webhook: IWebhook) => {
onSubmitWebhook(webhook, authenticityToken).then(res => {
if (res?.status === HttpStatus.Created) window.location.reload();
});
};
const handleUpdateWebhook = (id: number, form: ISiteSettingsWebhookFormUpdate) => {
onUpdateWebhook(id, form, authenticityToken).then(res => {
if (res?.status === HttpStatus.OK) window.location.reload();
});
};
const handleToggleEnabledWebhook = (id: number, enabled: boolean) => {
onToggleEnabledWebhook(id, enabled, authenticityToken).then(res => {
if (res?.status === HttpStatus.OK) window.location.reload();
});
};
const handleDeleteWebhook = (id: number) => {
onDeleteWebhook(id, authenticityToken);
};
const handleTestWebhook = async (id: number) => {
setIsTesting(true);
const res = await fetch(`/webhooks/${id}/test`, {
method: 'PUT',
headers: buildRequestHeaders(authenticityToken),
});
const json = await res.json();
setTestHttpCode(res.status);
setTestHttpResponse(JSON.stringify(json, null, 2));
setSelectedWebhook(id);
setPage('test');
setIsTesting(false);
};
return (
page === 'index' ?
<WebhooksIndexPage
webhooks={webhooks}
isSubmitting={isSubmitting}
isTesting={isTesting}
submitError={submitError}
handleToggleEnabledWebhook={handleToggleEnabledWebhook}
handleDeleteWebhook={handleDeleteWebhook}
handleTestWebhook={handleTestWebhook}
setSelectedWebhook={setSelectedWebhook}
setPage={setPage}
/>
:
(page === 'new' || page === 'edit') ?
<WebhookFormPage
isSubmitting={isSubmitting}
submitError={submitError}
handleSubmitWebhook={handleSubmitWebhook}
handleUpdateWebhook={handleUpdateWebhook}
selectedWebhook={webhooks.items.find(webhook => webhook.id === selectedWebhook)}
page={page}
setPage={setPage}
authenticityToken={authenticityToken}
/>
:
<WebhookTestPage
selectedWebhook={webhooks.items.find(webhook => webhook.id === selectedWebhook)}
testHttpCode={testHttpCode}
testHttpResponse={testHttpResponse}
setSelectedWebhook={setSelectedWebhook}
setPage={setPage}
handleTestWebhook={handleTestWebhook}
/>
);
};
export default WebhooksSiteSettingsP;

View File

@@ -0,0 +1,33 @@
import * as React from 'react';
import { Provider } from 'react-redux';
import { Store } from 'redux';
import createStoreHelper from '../../../helpers/createStore';
import { State } from '../../../reducers/rootReducer';
import WebhooksSiteSettings from '../../../containers/WebhooksSiteSettings';
interface Props {
authenticityToken: string;
}
class WebhooksSiteSettingsRoot extends React.Component<Props> {
store: Store<State, any>;
constructor(props: Props) {
super(props);
this.store = createStoreHelper();
}
render() {
return (
<Provider store={this.store}>
<WebhooksSiteSettings
authenticityToken={this.props.authenticityToken}
/>
</Provider>
);
}
}
export default WebhooksSiteSettingsRoot;

View File

@@ -0,0 +1,97 @@
import * as React from 'react';
import I18n from 'i18n-js';
import { DangerText, SmallMutedText, SuccessText } from '../common/CustomTexts';
import Button from '../common/Button';
import CopyToClipboardButton from '../common/CopyToClipboardButton';
import buildRequestHeaders from '../../helpers/buildRequestHeaders';
import HttpStatus from '../../constants/http_status';
import ActionLink from '../common/ActionLink';
import { LearnMoreIcon } from '../common/Icons';
interface Props {
currentApiKey?: string;
generateApiKeyEndpoint: string;
authenticityToken: string;
}
const GenerateApiKeyDialog = ({
currentApiKey,
generateApiKeyEndpoint,
authenticityToken,
}: Props) => {
const [hasBeenGenerated, setHasBeenGenerated] = React.useState(false);
const [apiKey, setApiKey] = React.useState('');
const [error, setError] = React.useState('');
return (
<>
<h3>{I18n.t('common.forms.api_key.title')}</h3>
{
(currentApiKey && !hasBeenGenerated) &&
<>
<input type="disabled" readOnly value={currentApiKey} className="form-control" />
<SmallMutedText>{I18n.t('common.forms.api_key.current_api_key_help')}</SmallMutedText>
</>
}
{
hasBeenGenerated ?
<>
<input type="disabled" readOnly value={apiKey} className="form-control" />
<CopyToClipboardButton
label={I18n.t('common.buttons.copy_to_clipboard')}
textToCopy={apiKey}
copiedLabel={I18n.t('common.copied')}
/>
<SmallMutedText>{I18n.t('common.forms.api_key.generated_api_key_help')}</SmallMutedText>
<br />
<SuccessText>{I18n.t('common.forms.api_key.generated_api_key_successfully')}</SuccessText>
</>
:
<>
<br />
<Button
onClick={async () => {
// If there is already an API key, ask for confirmation before generating a new one
if (currentApiKey) {
const confirmation = confirm(I18n.t('common.forms.api_key.confirm_generate_new_api_key'));
if (!confirmation) return;
}
// Generate a new API key
const res = await fetch(generateApiKeyEndpoint, {
method: 'POST',
headers: buildRequestHeaders(authenticityToken),
});
if (res.status === HttpStatus.Created) {
const json = await res.json();
setApiKey(json.api_key);
setHasBeenGenerated(true);
} else {
setError(I18n.t('errors.unknown'));
}
}}
className="btnPrimary apiKeyGenerateButton"
>
{I18n.t('common.forms.api_key.generate_api_key')}
</Button>
</>
}
<br /><br />
<ActionLink
icon={<LearnMoreIcon />}
onClick={() => window.open('https://docs.astuto.io/api', '_blank')}
>
{I18n.t('common.forms.api_key.api_key_learn_more')}
</ActionLink>
{ error && <DangerText>{error}</DangerText> }
</>
);
};
export default GenerateApiKeyDialog;

View File

@@ -3,11 +3,13 @@ import * as React from 'react';
export const BADGE_TYPE_LIGHT = 'badgeLight';
export const BADGE_TYPE_WARNING = 'badgeWarning';
export const BADGE_TYPE_DANGER = 'badgeDanger';
export const BADGE_TYPE_SUCCESS = 'badgeSuccess';
export type BadgeTypes =
typeof BADGE_TYPE_LIGHT |
typeof BADGE_TYPE_WARNING |
typeof BADGE_TYPE_DANGER;
typeof BADGE_TYPE_DANGER |
typeof BADGE_TYPE_SUCCESS;
interface Props {
type: BadgeTypes;

View File

@@ -1,11 +1,12 @@
import * as React from 'react';
import I18n from 'i18n-js';
import { Tooltip } from 'react-tooltip'
import { BsReply } from 'react-icons/bs';
import { FiEdit, FiDelete, FiSettings } from 'react-icons/fi';
import { ImCancelCircle } from 'react-icons/im';
import { TbLock, TbLockOpen } from 'react-icons/tb';
import { GrTest, GrClearOption } from 'react-icons/gr';
import { GrTest, GrClearOption, GrOverview } from 'react-icons/gr';
import { BiLike, BiSolidLike } from "react-icons/bi";
import {
MdContentCopy,
@@ -15,8 +16,10 @@ import {
MdVerified,
MdCheck,
MdClear,
MdAdd,
} from 'react-icons/md';
import { FaUserNinja } from "react-icons/fa";
import { FaUserNinja, FaMarkdown } from "react-icons/fa";
import { FaDroplet } from "react-icons/fa6";
export const EditIcon = () => <FiEdit />;
@@ -41,9 +44,12 @@ export const ReplyIcon = () => <BsReply />;
export const LearnMoreIcon = () => <MdOutlineLibraryBooks />;
export const StaffIcon = () => (
<span title={I18n.t('common.user_staff')} className="staffIcon">
<>
<span data-tooltip-id="staff-tooltip" data-tooltip-content={I18n.t('common.user_staff')} className="staffIcon">
<MdVerified />
</span>
<Tooltip id="staff-tooltip" />
</>
);
export const ClearIcon = () => <GrClearOption />;
@@ -54,8 +60,50 @@ export const SolidLikeIcon = ({size = 32}) => <BiSolidLike size={size} />;
export const SettingsIcon = () => <FiSettings />;
export const AnonymousIcon = ({size = 32, title=I18n.t('defaults.user_full_name')}) => <FaUserNinja size={size} title={title} />;
export const AnonymousIcon = ({size = 32}) => (
<>
<span data-tooltip-id="anonymous-tooltip" data-tooltip-content={I18n.t('defaults.user_full_name')} className="anonymousIcon">
<FaUserNinja size={size} />
</span>
<Tooltip id="anonymous-tooltip" />
</>
);
export const ApproveIcon = () => <MdCheck />;
export const RejectIcon = () => <MdClear />;
export const RejectIcon = () => <MdClear />;
export const AddIcon = () => <MdAdd />;
export const PreviewIcon = ({size = 24}) => <GrOverview size={size} />;
export const LiquidIcon = ({size = 18}) => (
<>
<a href="https://shopify.github.io/liquid/" target="_blank" rel="noreferrer" className="link">
<span
data-tooltip-id="liquid-tooltip"
data-tooltip-content={I18n.t('common.language_supported', { language: 'Liquid' })}
className="liquidIcon"
>
<FaDroplet size={size} />
</span>
</a>
<Tooltip id="liquid-tooltip" />
</>
);
export const MarkdownIcon = ({size = 24, style = {}}) => (
<>
<a href="https://www.markdownguide.org/basic-syntax/" target="_blank" rel="noreferrer" className="link">
<span
data-tooltip-id="markdown-tooltip"
data-tooltip-content={I18n.t('common.language_supported', { language: 'Markdown' })}
style={{...style, ...{opacity: 0.75}}}
className="markdownIcon"
>
<FaMarkdown size={size} />
</span>
</a>
<Tooltip id="markdown-tooltip" />
</>
);

View File

@@ -1,2 +1,3 @@
export const EMAIL_REGEX = /(.+)@(.+){2,}\.(.+){2,}/;
export const URL_REGEX = /^(ftp|http|https):\/\/[^ "]+$/;
export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export const URL_REGEX = /^(ftp|http|https):\/\/[^ "]+$/;
export const URL_REGEX_WHITESPACE_ALLOWED = /^(ftp|http|https):\/\/[^"]+$/;

View File

@@ -34,8 +34,8 @@ const mapStateToProps = (state: State) => ({
});
const mapDispatchToProps = (dispatch) => ({
requestPost(postId: number) {
dispatch(requestPost(postId));
requestPost(postId: number): Promise<any> {
return dispatch(requestPost(postId));
},
updatePost(
@@ -69,8 +69,8 @@ const mapDispatchToProps = (dispatch) => ({
dispatch(changePostEditFormPostStatus(postStatusId));
},
requestLikes(postId: number) {
dispatch(requestLikes(postId));
requestLikes(postId: number): Promise<any> {
return dispatch(requestLikes(postId));
},
requestFollow(postId: number) {

View File

@@ -0,0 +1,44 @@
import { connect } from "react-redux";
import WebhooksSiteSettingsP from "../components/SiteSettings/Webhooks/WebhooksSiteSettingsP";
import { State } from "../reducers/rootReducer";
import { requestWebhooks } from "../actions/Webhook/requestWebhooks";
import { IWebhook } from "../interfaces/IWebhook";
import { submitWebhook } from "../actions/Webhook/submitWebhook";
import { deleteWebhook } from "../actions/Webhook/deleteWebhook";
import { ISiteSettingsWebhookFormUpdate } from "../components/SiteSettings/Webhooks/WebhookForm";
import { updateWebhook } from "../actions/Webhook/updateWebhook";
const mapStateToProps = (state: State) => ({
webhooks: state.webhooks,
isSubmitting: state.siteSettings.webhooks.isSubmitting,
submitError: state.siteSettings.webhooks.error,
});
const mapDispatchToProps = (dispatch: any) => ({
requestWebhooks() {
dispatch(requestWebhooks());
},
onSubmitWebhook(webhook: IWebhook, authenticityToken: string): Promise<any> {
return dispatch(submitWebhook(webhook, authenticityToken));
},
onUpdateWebhook(id: number, form: ISiteSettingsWebhookFormUpdate, authenticityToken: string): Promise<any> {
return dispatch(updateWebhook({id, form, authenticityToken}));
},
onToggleEnabledWebhook(id: number, isEnabled: boolean, authenticityToken: string): Promise<any> {
return dispatch(updateWebhook({id, isEnabled, authenticityToken}));
},
onDeleteWebhook(id: number, authenticityToken: string) {
dispatch(deleteWebhook(id, authenticityToken));
}
});
export default connect(
mapStateToProps,
mapDispatchToProps,
)(WebhooksSiteSettingsP);

View File

@@ -47,4 +47,19 @@ export const fromRailsStringToJavascriptDate = date => {
export const fromJavascriptDateToRailsString = (date: Date) => {
return date.toJSON();
}
export const nMonthsAgo = (n: number) => {
const currentDate = new Date();
return new Date(
Date.UTC(
currentDate.getFullYear(),
currentDate.getMonth() - n,
currentDate.getDate(),
currentDate.getHours(),
currentDate.getMinutes(),
currentDate.getSeconds()
)
);
}

View File

@@ -1,7 +1,15 @@
import ILikeJSON from "./json/ILike";
interface ILike {
id: number;
fullName: string;
email: string;
}
export default ILike;
export default ILike;
export const likeJSON2JS = (likeJSON: ILikeJSON): ILike => ({
id: likeJSON.id,
fullName: likeJSON.full_name,
email: likeJSON.email,
});

View File

@@ -1,3 +1,5 @@
import IPostJSON from "./json/IPost";
// Approval status
export const POST_APPROVAL_STATUS_APPROVED = 'approved';
export const POST_APPROVAL_STATUS_PENDING = 'pending';
@@ -26,4 +28,22 @@ interface IPost {
createdAt: string;
}
export default IPost;
export default IPost;
export const postJSON2JS = (postJSON: IPostJSON): IPost => ({
id: postJSON.id,
title: postJSON.title,
slug: postJSON.slug,
description: postJSON.description,
approvalStatus: postJSON.approval_status,
boardId: postJSON.board_id,
postStatusId: postJSON.post_status_id,
likeCount: postJSON.likes_count,
liked: postJSON.liked,
commentsCount: postJSON.comments_count,
hotness: postJSON.hotness,
userId: postJSON.user_id,
userEmail: postJSON.user_email,
userFullName: postJSON.user_full_name,
createdAt: postJSON.created_at,
});

View File

@@ -0,0 +1,77 @@
// Trigger
export const WEBHOOK_TRIGGER_NEW_POST = 'new_post';
export const WEBHOOK_TRIGGER_NEW_POST_PENDING_APPROVAL = 'new_post_pending_approval';
export const WEBHOOK_TRIGGER_DELETED_POST = 'delete_post';
export const WEBHOOK_TRIGGER_POST_STATUS_CHANGE = 'post_status_change';
export const WEBHOOK_TRIGGER_NEW_COMMENT = 'new_comment';
export const WEBHOOK_TRIGGER_NEW_VOTE = 'new_vote';
export const WEBHOOK_TRIGGER_NEW_USER = 'new_user';
export type WebhookTrigger =
typeof WEBHOOK_TRIGGER_NEW_POST |
typeof WEBHOOK_TRIGGER_NEW_POST_PENDING_APPROVAL |
typeof WEBHOOK_TRIGGER_DELETED_POST |
typeof WEBHOOK_TRIGGER_POST_STATUS_CHANGE |
typeof WEBHOOK_TRIGGER_NEW_COMMENT |
typeof WEBHOOK_TRIGGER_NEW_VOTE |
typeof WEBHOOK_TRIGGER_NEW_USER;
// HTTP method
export const WEBHOOK_HTTP_METHOD_POST = 'http_post';
export const WEBHOOK_HTTP_METHOD_PUT = 'http_put';
export const WEBHOOK_HTTP_METHOD_PATCH = 'http_patch';
export const WEBHOOK_HTTP_METHOD_DELETE = 'http_delete';
export type WebhookHttpMethod =
typeof WEBHOOK_HTTP_METHOD_POST |
typeof WEBHOOK_HTTP_METHOD_PUT |
typeof WEBHOOK_HTTP_METHOD_PATCH |
typeof WEBHOOK_HTTP_METHOD_DELETE;
export interface IWebhook {
id?: number;
name: string;
description?: string;
isEnabled: boolean;
trigger: WebhookTrigger;
url: string;
httpBody: string;
httpMethod: WebhookHttpMethod;
httpHeaders: string;
}
export interface IWebhookJSON {
id: string;
name: string;
description?: string;
is_enabled: boolean;
trigger: WebhookTrigger;
url: string;
http_body: string;
http_method: WebhookHttpMethod;
http_headers: string;
}
export const webhookJSON2JS = (webhookJSON: IWebhookJSON): IWebhook => ({
id: parseInt(webhookJSON.id),
name: webhookJSON.name,
description: webhookJSON.description,
isEnabled: webhookJSON.is_enabled,
trigger: webhookJSON.trigger,
url: webhookJSON.url,
httpBody: webhookJSON.http_body,
httpMethod: webhookJSON.http_method,
httpHeaders: webhookJSON.http_headers,
});
export const webhookJS2JSON = (webhook: IWebhook) => ({
id: webhook.id?.toString(),
name: webhook.name,
description: webhook.description,
is_enabled: webhook.isEnabled,
trigger: webhook.trigger,
url: webhook.url,
http_body: webhook.httpBody,
http_method: webhook.httpMethod,
http_headers: webhook.httpHeaders,
});

View File

@@ -0,0 +1,71 @@
import {
WebhookSubmitActionTypes,
WEBHOOK_SUBMIT_START,
WEBHOOK_SUBMIT_SUCCESS,
WEBHOOK_SUBMIT_FAILURE,
} from '../../actions/Webhook/submitWebhook';
import {
WebhookUpdateActionTypes,
WEBHOOK_UPDATE_START,
WEBHOOK_UPDATE_SUCCESS,
WEBHOOK_UPDATE_FAILURE,
} from '../../actions/Webhook/updateWebhook';
import {
WebhookDeleteActionTypes,
WEBHOOK_DELETE_FAILURE,
WEBHOOK_DELETE_START,
WEBHOOK_DELETE_SUCCESS,
} from '../../actions/Webhook/deleteWebhook';
export interface SiteSettingsWebhooksState {
isSubmitting: boolean;
error: string;
}
const initialState: SiteSettingsWebhooksState = {
isSubmitting: false,
error: '',
};
const siteSettingsWebhooksReducer = (
state = initialState,
action:
WebhookSubmitActionTypes |
WebhookUpdateActionTypes |
WebhookDeleteActionTypes
) => {
switch (action.type) {
case WEBHOOK_SUBMIT_START:
case WEBHOOK_UPDATE_START:
case WEBHOOK_DELETE_START:
return {
...state,
isSubmitting: true,
};
case WEBHOOK_SUBMIT_SUCCESS:
case WEBHOOK_UPDATE_SUCCESS:
case WEBHOOK_DELETE_SUCCESS:
return {
...state,
isSubmitting: false,
error: '',
};
case WEBHOOK_SUBMIT_FAILURE:
case WEBHOOK_UPDATE_FAILURE:
case WEBHOOK_DELETE_FAILURE:
return {
...state,
isSubmitting: false,
error: action.error,
};
default:
return state;
}
};
export default siteSettingsWebhooksReducer;

View File

@@ -8,6 +8,7 @@ import postStatusesReducer from './postStatusesReducer';
import usersReducer from './usersReducer';
import currentPostReducer from './currentPostReducer';
import oAuthsReducer from './oAuthsReducer';
import webhooksReducer from './webhooksReducer';
import siteSettingsReducer from './siteSettingsReducer';
import moderationReducer from './moderationReducer';
@@ -20,6 +21,7 @@ const rootReducer = combineReducers({
users: usersReducer,
currentPost: currentPostReducer,
oAuths: oAuthsReducer,
webhooks: webhooksReducer,
siteSettings: siteSettingsReducer,
moderation: moderationReducer,

View File

@@ -82,12 +82,34 @@ import {
OAUTH_DELETE_FAILURE,
} from '../actions/OAuth/deleteOAuth';
import {
WebhookSubmitActionTypes,
WEBHOOK_SUBMIT_FAILURE,
WEBHOOK_SUBMIT_START,
WEBHOOK_SUBMIT_SUCCESS,
} from '../actions/Webhook/submitWebhook';
import {
WebhookUpdateActionTypes,
WEBHOOK_UPDATE_START,
WEBHOOK_UPDATE_SUCCESS,
WEBHOOK_UPDATE_FAILURE,
} from '../actions/Webhook/updateWebhook';
import {
WebhookDeleteActionTypes,
WEBHOOK_DELETE_FAILURE,
WEBHOOK_DELETE_START,
WEBHOOK_DELETE_SUCCESS,
} from '../actions/Webhook/deleteWebhook';
import siteSettingsGeneralReducer, { SiteSettingsGeneralState } from './SiteSettings/generalReducer';
import siteSettingsBoardsReducer, { SiteSettingsBoardsState } from './SiteSettings/boardsReducer';
import siteSettingsPostStatusesReducer, { SiteSettingsPostStatusesState } from './SiteSettings/postStatusesReducer';
import siteSettingsRoadmapReducer, { SiteSettingsRoadmapState } from './SiteSettings/roadmapReducer';
import siteSettingsAuthenticationReducer, { SiteSettingsAuthenticationState } from './SiteSettings/authenticationReducer';
import siteSettingsAppearanceReducer, { SiteSettingsAppearanceState } from './SiteSettings/appearanceReducer';
import siteSettingsWebhooksReducer, { SiteSettingsWebhooksState } from './SiteSettings/webhooksReducer';
interface SiteSettingsState {
general: SiteSettingsGeneralState;
@@ -95,6 +117,7 @@ interface SiteSettingsState {
boards: SiteSettingsBoardsState;
postStatuses: SiteSettingsPostStatusesState;
roadmap: SiteSettingsRoadmapState;
webhooks: SiteSettingsWebhooksState;
appearance: SiteSettingsAppearanceState;
}
@@ -104,6 +127,7 @@ const initialState: SiteSettingsState = {
boards: siteSettingsBoardsReducer(undefined, {} as any),
postStatuses: siteSettingsPostStatusesReducer(undefined, {} as any),
roadmap: siteSettingsRoadmapReducer(undefined, {} as any),
webhooks: siteSettingsWebhooksReducer(undefined, {} as any),
appearance: siteSettingsAppearanceReducer(undefined, {} as any),
};
@@ -121,7 +145,10 @@ const siteSettingsReducer = (
PostStatusOrderUpdateActionTypes |
PostStatusDeleteActionTypes |
PostStatusSubmitActionTypes |
PostStatusUpdateActionTypes
PostStatusUpdateActionTypes |
WebhookSubmitActionTypes |
WebhookUpdateActionTypes |
WebhookDeleteActionTypes
): SiteSettingsState => {
switch (action.type) {
case TENANT_UPDATE_START:
@@ -187,6 +214,20 @@ const siteSettingsReducer = (
roadmap: siteSettingsRoadmapReducer(state.roadmap, action),
};
case WEBHOOK_SUBMIT_START:
case WEBHOOK_SUBMIT_SUCCESS:
case WEBHOOK_SUBMIT_FAILURE:
case WEBHOOK_UPDATE_START:
case WEBHOOK_UPDATE_SUCCESS:
case WEBHOOK_UPDATE_FAILURE:
case WEBHOOK_DELETE_START:
case WEBHOOK_DELETE_SUCCESS:
case WEBHOOK_DELETE_FAILURE:
return {
...state,
webhooks: siteSettingsWebhooksReducer(state.webhooks, action),
};
default:
return state;
}

View File

@@ -0,0 +1,93 @@
import {
WebhooksRequestActionTypes,
WEBHOOKS_REQUEST_START,
WEBHOOKS_REQUEST_SUCCESS,
WEBHOOKS_REQUEST_FAILURE,
} from '../actions/Webhook/requestWebhooks';
import {
WebhookSubmitActionTypes,
WEBHOOK_SUBMIT_SUCCESS,
} from '../actions/Webhook/submitWebhook';
import {
WebhookUpdateActionTypes,
WEBHOOK_UPDATE_SUCCESS,
} from '../actions/Webhook/updateWebhook';
import {
WebhookDeleteActionTypes,
WEBHOOK_DELETE_SUCCESS,
} from '../actions/Webhook/deleteWebhook';
import { IWebhook, webhookJSON2JS } from '../interfaces/IWebhook';
export interface WebhooksState {
items: Array<IWebhook>;
areLoading: boolean;
error: string;
}
const initialState: WebhooksState = {
items: [],
areLoading: true,
error: '',
};
const webhooksReducer = (
state = initialState,
action:
WebhooksRequestActionTypes |
WebhookSubmitActionTypes |
WebhookUpdateActionTypes |
WebhookDeleteActionTypes
) => {
switch (action.type) {
case WEBHOOKS_REQUEST_START:
return {
...state,
areLoading: true,
};
case WEBHOOKS_REQUEST_SUCCESS:
return {
...state,
areLoading: false,
error: '',
items: action.webhooks.map<IWebhook>(webhookJson => webhookJSON2JS(webhookJson)),
};
case WEBHOOKS_REQUEST_FAILURE:
return {
...state,
areLoading: false,
error: action.error,
};
case WEBHOOK_SUBMIT_SUCCESS:
return {
...state,
items: [...state.items, webhookJSON2JS(action.webhook)],
};
case WEBHOOK_UPDATE_SUCCESS:
return {
...state,
items: state.items.map(webhook => {
if (webhook.id !== parseInt(action.webhook.id)) return webhook;
return webhookJSON2JS(action.webhook);
}),
}
case WEBHOOK_DELETE_SUCCESS:
return {
...state,
items: state.items.filter(webhook => webhook.id !== action.id),
};
default:
return state;
}
};
export default webhooksReducer;

105
app/jobs/run_webhook.rb Normal file
View File

@@ -0,0 +1,105 @@
class RunWebhook < ActiveJob::Base
queue_as :webhooks
# entities is a hash with entity_name as key and entity_id as value (entity_name will be mapped to an ActiveRecord class)
def perform(webhook_id:, current_tenant_id:, is_test: false, entities: {})
Current.tenant = Tenant.find(current_tenant_id)
logger.info { "[#{Current.tenant.subdomain}] Performing RunWebhook ActiveJob for webhook ID #{webhook_id}" }
# Find webhook from DB
webhook = Webhook.find(webhook_id)
# Skip if webhook is disabled and is not a test
return if !is_test && !webhook.is_enabled
# Load entities from DB
loaded_entities = {}
entities.each do |entity_name, entity_id|
entity_class = map_entity_name_to_class(entity_name)
# If there is an ActiveRecord class for that entity_name, load it from DB
# Otherwise, just pass the ID (this is the special case of trigger 'delete_post')
if entity_class
loaded_entities[entity_name] = entity_class.find(entity_id)
else
loaded_entities[entity_name] = entity_id
end
end
# Build context based on webhook's trigger
context = CreateLiquidTemplateContextWorkflow.new(
webhook_trigger: webhook.trigger,
is_test: is_test,
entities: loaded_entities,
).run
# Parse and render template for webhook's URL
url_template = Liquid::Template.parse(webhook.url)
url = url_template.render(context)
# Parse and render template for webhook's HTTP body
http_body_template = Liquid::Template.parse(webhook.http_body)
http_body = http_body_template.render(context)
# Prepare HTTP body
if webhook.http_body.present?
http_body = JSON.parse(http_body).to_json
else
http_body = nil
end
# Prepare HTTP headers
if webhook.http_headers.present?
http_headers = JSON.parse(webhook.http_headers).each_with_object({}) do |header, memo|
memo[header['key']] = header['value']
end
else
http_headers = {}
end
# Make HTTP request
HTTParty.send(
map_webhook_http_method(webhook.http_method).downcase,
url,
{
body: http_body,
headers: http_headers,
}
)
end
private
def map_webhook_http_method(http_method)
case http_method
when :http_post
'POST'
when :http_put
'PUT'
when :http_patch
'PATCH'
when :http_delete
'DELETE'
else
'POST'
end
end
def map_entity_name_to_class(entity_name)
case entity_name
when :post
Post
when :user, :post_author, :comment_author, :vote_author
User
when :board
Board
when :post_status
PostStatus
when :comment
Comment
else
nil
end
end
end

View File

@@ -0,0 +1,92 @@
class SendRecapEmails < ActiveJob::Base
queue_as :default
def perform(*args)
logger.info { "Performing SendRecapEmails ActiveJob" }
# Fix times to 15:00 UTC
hour = 15
time_now = Time.now.utc.change(hour: hour, min: 0, sec: 0)
one_day_ago = 1.day.ago.utc.change(hour: hour, min: 0, sec: 0)
one_week_ago = 1.week.ago.utc.change(hour: hour, min: 0, sec: 0)
one_month_ago = 1.month.ago.utc.change(hour: hour, min: 0, sec: 0)
# Get tenants with active subscriptions
tbs = TenantBilling.unscoped.all
tbs = tbs.select { |tb| tb.has_active_subscription? }
tenants = Tenant.where(id: tbs.map(&:tenant_id))
# Based on the current date, determine which recap notifications to send
frequencies_to_notify = ['daily']
frequencies_to_notify.push('weekly') if Date.today.monday? # Send weekly recap on Mondays
frequencies_to_notify.push('monthly') if Date.today.day == 1 # Send monthly recap on the 1st of the month
tenants.each do |tenant|
Current.tenant = tenant
I18n.locale = tenant.locale
# Get users with recap notifications enabled
users = tenant.users.where(
role: ['owner', 'admin', 'moderator'],
notifications_enabled: true,
recap_notification_frequency: frequencies_to_notify,
)
# Get the different recap notification frequencies for users
users_recap_notification_frequencies = users.map(&:recap_notification_frequency).flatten.uniq
# Get only needed posts
if users_recap_notification_frequencies.include?('daily')
published_posts_daily = Post.where(approval_status: 'approved', created_at: one_day_ago..time_now).to_a
pending_posts_daily = Post.where(approval_status: 'pending', created_at: one_day_ago..time_now).to_a
end
if frequencies_to_notify.include?('weekly') && users_recap_notification_frequencies.include?('weekly')
published_posts_weekly = Post.where(approval_status: 'approved', created_at: one_week_ago..time_now).to_a
pending_posts_weekly = Post.where(approval_status: 'pending', created_at: one_week_ago..time_now).to_a
end
if frequencies_to_notify.include?('monthly') && users_recap_notification_frequencies.include?('monthly')
published_posts_monthly = Post.where(approval_status: 'approved', created_at: one_month_ago..time_now).to_a
pending_posts_monthly = Post.where(approval_status: 'pending', created_at: one_month_ago..time_now).to_a
end
# Notify each user based on their recap notification frequency
users.each do |user|
logger.info { "[#{tenant.subdomain}] Sending recap email to #{user.inspect}" }
# Remove from published_posts the posts published by the user
published_posts_daily_user = published_posts_daily&.select { |post| post.user_id != user.id }
should_send_daily_recap = published_posts_daily_user&.any? || pending_posts_daily&.any?
published_posts_weekly_user = published_posts_weekly&.select { |post| post.user_id != user.id }
should_send_weekly_recap = published_posts_weekly_user&.any? || pending_posts_weekly&.any?
published_posts_monthly_user = published_posts_monthly&.select { |post| post.user_id != user.id }
should_send_monthly_recap = published_posts_monthly_user&.any? || pending_posts_monthly&.any?
# Send recap email
if user.recap_notification_frequency == 'daily' && should_send_daily_recap
UserMailer.recap(
frequency: I18n.t('common.forms.auth.recap_notification_frequency_daily'),
user: user,
published_posts_count: published_posts_daily_user&.count,
pending_posts_count: pending_posts_daily&.count,
).deliver_later
elsif user.recap_notification_frequency == 'weekly' && should_send_weekly_recap
UserMailer.recap(
frequency: I18n.t('common.forms.auth.recap_notification_frequency_weekly'),
user: user,
published_posts_count: published_posts_weekly_user&.count,
pending_posts_count: pending_posts_weekly&.count,
).deliver_later
elsif user.recap_notification_frequency == 'monthly' && should_send_monthly_recap
UserMailer.recap(
frequency: I18n.t('common.forms.auth.recap_notification_frequency_monthly'),
user: user,
published_posts_count: published_posts_monthly_user&.count,
pending_posts_count: pending_posts_monthly&.count,
).deliver_later
end
end
end
end
end

View File

@@ -0,0 +1,7 @@
module CustomLiquidFilters
require 'json'
def escape_json(input)
input.to_json[1...-1] # Converts to JSON string and removes surrounding quotes
end
end

View File

@@ -43,6 +43,21 @@ class UserMailer < ApplicationMailer
)
end
def recap(frequency:, user:, published_posts_count:, pending_posts_count:)
Current.tenant = user.tenant
@frequency = frequency
@user = user
@published_posts_count = published_posts_count
@pending_posts_count = pending_posts_count
mail(
to: user.email,
subject: t('mailers.user.recap.subject', site_name: site_name, frequency: frequency)
)
end
private
def site_name

56
app/models/api_key.rb Normal file
View File

@@ -0,0 +1,56 @@
class ApiKey < ApplicationRecord
include TenantOwnable
HMAC_SECRET_KEY = Rails.application.secrets.secret_key_base
TOKEN_NAMESPACE = 'tkn'
belongs_to :user
before_validation :set_common_token_prefix, on: :create
before_validation :generate_random_token_prefix, on: :create
before_validation :generate_token, on: :create
before_validation :generate_token_digest, on: :create
# The non-hashed token
attr_accessor :token
def self.find_by_token!(token)
find_by!(token_digest: digest(token))
end
def self.find_by_token(token)
find_by(token_digest: digest(token))
end
def self.digest(token)
OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), HMAC_SECRET_KEY, token)
end
def token_prefix
[common_token_prefix, random_token_prefix].join("")
end
private
def set_common_token_prefix
if user.role == 'owner' || user.role == 'admin'
user_role = 'admin'
elsif user.role == 'moderator'
user_role = 'mod'
end
self.common_token_prefix = "#{TOKEN_NAMESPACE}_#{user_role}_"
end
def generate_random_token_prefix
self.random_token_prefix = SecureRandom.base58(6)
end
def generate_token
self.token = [common_token_prefix, random_token_prefix, SecureRandom.base58(24)].join("")
end
def generate_token_digest
self.token_digest = self.class.digest(token)
end
end

View File

@@ -6,5 +6,27 @@ class Comment < ApplicationRecord
belongs_to :parent, class_name: 'Comment', optional: true
has_many :children, class_name: 'Comment', foreign_key: 'parent_id', dependent: :destroy
after_create :run_webhooks
validates :body, presence: true
private
def run_webhooks
entities = {
comment: self.id,
comment_author: self.user.id,
post: self.post.id,
board: self.post.board.id
}
entities[:post_author] = self.post.user.id if self.post.user_id
Webhook.where(trigger: :new_comment, is_enabled: true).each do |webhook|
RunWebhook.perform_later(
webhook_id: webhook.id,
current_tenant_id: Current.tenant.id,
entities: entities
)
end
end
end

View File

@@ -1,3 +1,9 @@
class Invitation < ApplicationRecord
include TenantOwnable
belongs_to :tenant
def expired?
updated_at <= 3.months.ago
end
end

View File

@@ -4,5 +4,26 @@ class Like < ApplicationRecord
belongs_to :user
belongs_to :post
after_create :run_webhooks
validates :user_id, uniqueness: { scope: :post_id }
private
def run_webhooks
entities = {
vote_author: self.user.id,
post: self.post.id,
board: self.post.board.id
}
entities[:post_author] = self.post.user.id if self.post.user_id
Webhook.where(trigger: :new_vote, is_enabled: true).each do |webhook|
RunWebhook.perform_later(
webhook_id: webhook.id,
current_tenant_id: Current.tenant.id,
entities: entities
)
end
end
end

View File

@@ -1,5 +1,7 @@
class Post < ApplicationRecord
include TenantOwnable
include ApplicationHelper
include Rails.application.routes.url_helpers
extend FriendlyId
belongs_to :board
@@ -12,6 +14,9 @@ class Post < ApplicationRecord
has_many :comments, dependent: :destroy
has_many :post_status_changes, dependent: :destroy
after_create :run_new_post_webhooks
after_destroy :run_delete_post_webhooks
enum approval_status: [
:approved,
:pending,
@@ -24,6 +29,10 @@ class Post < ApplicationRecord
friendly_id :title, use: :scoped, scope: :tenant_id
def url
get_url_for(method(:post_url), resource: self)
end
class << self
def find_with_post_status_in(post_statuses)
where(post_status_id: post_statuses.pluck(:id))
@@ -54,4 +63,50 @@ class Post < ApplicationRecord
where(approval_status: "pending")
end
end
private
def run_new_post_webhooks
entities = {
post: self.id,
board: self.board.id
}
entities[:post_author] = self.user.id if self.user_id
# New post (approved)
if self.approval_status == 'approved'
Webhook.where(trigger: :new_post, is_enabled: true).each do |webhook|
RunWebhook.perform_later(
webhook_id: webhook.id,
current_tenant_id: Current.tenant.id,
entities: entities
)
end
end
# New post (pending approval)
if self.approval_status == 'pending'
Webhook.where(trigger: :new_post_pending_approval, is_enabled: true).each do |webhook|
RunWebhook.perform_later(
webhook_id: webhook.id,
current_tenant_id: Current.tenant.id,
entities: entities
)
end
end
end
def run_delete_post_webhooks
# Since the post has already been deleted from DB
# we only provide its ID
entities = { post_id: self.id }
Webhook.where(trigger: :delete_post, is_enabled: true).each do |webhook|
RunWebhook.perform_later(
webhook_id: webhook.id,
current_tenant_id: Current.tenant.id,
entities: entities
)
end
end
end

View File

@@ -4,4 +4,25 @@ class PostStatusChange < ApplicationRecord
belongs_to :user
belongs_to :post
belongs_to :post_status, optional: true
after_create :run_webhooks
private
def run_webhooks
entities = {
post: self.post.id,
board: self.post.board.id
}
entities[:post_author] = self.post.user.id if self.post.user_id
entities[:post_status] = self.post_status.id if self.post_status_id
Webhook.where(trigger: :post_status_change, is_enabled: true).each do |webhook|
RunWebhook.perform_later(
webhook_id: webhook.id,
current_tenant_id: Current.tenant.id,
entities: entities
)
end
end
end

View File

@@ -10,12 +10,16 @@ class User < ApplicationRecord
has_many :posts, dependent: :destroy
has_many :likes, dependent: :destroy
has_many :comments, dependent: :destroy
has_one :api_key, dependent: :destroy
enum role: [:user, :moderator, :admin, :owner]
enum status: [:active, :blocked, :deleted]
enum recap_notification_frequency: [:never, :daily, :weekly, :monthly]
after_initialize :set_default_role, if: :new_record?
after_initialize :set_default_status, if: :new_record?
after_create :run_webhooks
validates :full_name, presence: true, length: { in: 2..64 }
validates :email,
@@ -104,4 +108,20 @@ class User < ApplicationRecord
self.oauth_token = nil
self.save!
end
private
def run_webhooks
entities = {
user: self.id
}
Webhook.where(trigger: :new_user, is_enabled: true).each do |webhook|
RunWebhook.perform_later(
webhook_id: webhook.id,
current_tenant_id: Current.tenant.id,
entities: entities
)
end
end
end

76
app/models/webhook.rb Normal file
View File

@@ -0,0 +1,76 @@
class Webhook < ApplicationRecord
include TenantOwnable
before_save :encrypt_url
after_find :decrypt_url
before_save :encrypt_http_headers
after_find :decrypt_http_headers
validates :name, presence: true, uniqueness: { scope: :tenant_id }, length: { maximum: 255 }
validates :url, presence: true, format: { with: URI::regexp(%w(http https)), message: I18n.t('common.validations.url') }
validates :trigger, presence: true
validates :http_method, presence: true
enum trigger: [
:new_post,
:new_post_pending_approval,
:delete_post,
:post_status_change,
:new_comment,
:new_vote,
:new_user
]
enum http_method: [
:http_post,
:http_put,
:http_patch,
:http_delete
]
private
def encrypt_url
return if url.nil?
# Derive a 32-byte key from the secret_key_base
key = Digest::SHA256.digest(Rails.application.secrets.secret_key_base)
encryptor = ActiveSupport::MessageEncryptor.new(key)
self.url = encryptor.encrypt_and_sign(url)
end
def decrypt_url
return if url.nil?
# Derive a 32-byte key from the secret_key_base
key = Digest::SHA256.digest(Rails.application.secrets.secret_key_base)
encryptor = ActiveSupport::MessageEncryptor.new(key)
self.url = encryptor.decrypt_and_verify(url)
rescue ActiveSupport::MessageVerifier::InvalidSignature
errors.add(:url, 'could not be decrypted')
end
def encrypt_http_headers
return if http_headers.nil?
# Derive a 32-byte key from the secret_key_base
key = Digest::SHA256.digest(Rails.application.secrets.secret_key_base)
encryptor = ActiveSupport::MessageEncryptor.new(key)
self.http_headers = encryptor.encrypt_and_sign(http_headers.to_json)
end
def decrypt_http_headers
return if http_headers.nil?
# Derive a 32-byte key from the secret_key_base
key = Digest::SHA256.digest(Rails.application.secrets.secret_key_base)
encryptor = ActiveSupport::MessageEncryptor.new(key)
self.http_headers = JSON.parse(encryptor.decrypt_and_verify(http_headers)) if http_headers.present?
rescue ActiveSupport::MessageVerifier::InvalidSignature
errors.add(:http_headers, 'could not be decrypted')
end
end

View File

@@ -0,0 +1,10 @@
module Api
class BasePolicy
attr_reader :api_key, :record
def initialize(api_key, record)
@api_key = api_key
@record = record
end
end
end

View File

@@ -0,0 +1,15 @@
module Api
class BoardPolicy < BasePolicy
def index?
api_key.user.moderator?
end
def show?
api_key.user.moderator?
end
def create?
api_key.user.admin?
end
end
end

View File

@@ -0,0 +1,31 @@
module Api
class CommentPolicy < BasePolicy
def index?
api_key.user.moderator?
end
def show?
api_key.user.moderator?
end
def create?
api_key.user.moderator?
end
def update?
api_key.user.moderator?
end
def destroy?
api_key.user.moderator?
end
def mark_as_post_update?
api_key.user.moderator?
end
def unmark_as_post_update?
api_key.user.moderator?
end
end
end

View File

@@ -0,0 +1,19 @@
module Api
class LikePolicy < BasePolicy
def index?
api_key.user.moderator?
end
def show?
api_key.user.moderator?
end
def create?
api_key.user.moderator?
end
def destroy?
api_key.user.moderator?
end
end
end

View File

@@ -0,0 +1,39 @@
module Api
class PostPolicy < BasePolicy
def index?
api_key.user.moderator?
end
def show?
api_key.user.moderator?
end
def create?
api_key.user.moderator?
end
def update?
api_key.user.moderator?
end
def destroy?
api_key.user.moderator?
end
def update_board?
api_key.user.moderator?
end
def update_status?
api_key.user.moderator?
end
def approve?
api_key.user.moderator?
end
def reject?
api_key.user.moderator?
end
end
end

View File

@@ -0,0 +1,7 @@
module Api
class PostStatusPolicy < BasePolicy
def index?
api_key.user.moderator?
end
end
end

View File

@@ -0,0 +1,27 @@
module Api
class UserPolicy < BasePolicy
def index?
api_key.user.moderator?
end
def show?
api_key.user.moderator?
end
def show_by_email?
api_key.user.moderator?
end
def create?
api_key.user.moderator?
end
# Moderators can block users
# Admins can block everyone except the owner
# Owner can block everyone
# Users can't block themselves
def block?
(api_key.user.id != record.id) && ((api_key.user.moderator? && !record.moderator?) || (api_key.user.admin? && !record.owner?) || api_key.user.owner?)
end
end
end

View File

@@ -0,0 +1,5 @@
class ApiKeyPolicy < ApplicationPolicy
def create?
user.moderator? && user == record.user
end
end

View File

@@ -0,0 +1,34 @@
class WebhookPolicy < ApplicationPolicy
def permitted_attributes
if user.admin?
[
:name,
:description,
:is_enabled,
:trigger,
:url,
:http_body,
:http_method,
:http_headers
]
else
[]
end
end
def index?
user.admin?
end
def create?
user.admin?
end
def update?
user.admin?
end
def destroy?
user.admin?
end
end

View File

@@ -34,15 +34,6 @@
</div>
<% end %>
<div class="form-group">
<%= f.label :notifications_enabled, t('common.forms.auth.notifications_enabled') %>
&nbsp;
<%= f.check_box :notifications_enabled, style: "transform: scale(1.5)" %>
<small id="notificationsHelp" class="form-text text-muted">
<%= t('common.forms.auth.notifications_enabled_help') %>
</small>
</div>
<div class="form-group">
<%= f.label :password, t('common.forms.auth.password') %>
<%= f.password_field :password, autocomplete: "new-password", class: "form-control" %>
@@ -58,6 +49,41 @@
<hr />
<h3><%= t('common.forms.auth.notifications') %></h3>
<br />
<div class="form-group">
<%= f.label :notifications_enabled, t('activerecord.attributes.user.notifications_enabled') %>
&nbsp;
<%= f.check_box :notifications_enabled, style: "transform: scale(1.5)" %>
<small id="notificationsHelp" class="form-text text-muted">
<%= t('common.forms.auth.notifications_enabled_help') %>
</small>
</div>
<% if Rails.application.sidekiq_enabled? %>
<div class="form-group">
<%= f.label :recap_notification_frequency, t('activerecord.attributes.user.recap_notification_frequency') %>
<%= f.select :recap_notification_frequency,
[
[t('common.forms.auth.recap_notification_frequency_never'), "never"],
[t('common.forms.auth.recap_notification_frequency_daily'), "daily"],
[t('common.forms.auth.recap_notification_frequency_weekly'), "weekly"],
[t('common.forms.auth.recap_notification_frequency_monthly'), "monthly"]
],
{ include_blank: false },
class: "form-control" %>
<small id="recapNotificationFrequencyHelp" class="form-text text-muted">
<%= t('common.forms.auth.recap_notification_frequency_help') %>
</small>
</div>
<% else %>
<% if not Rails.application.multi_tenancy? %>
<p>You have to <a href="https://docs.astuto.io/deploy-with-sidekiq">enable Sidekiq</a> to receive recap notifications.</p>
<% end %>
<% end %>
<hr />
<div class="form-group">
<%= f.label :current_password, t('common.forms.auth.current_password') %>
<%= f.password_field :current_password, autocomplete: "current-password", required: true, class: "form-control" %>
@@ -75,6 +101,22 @@
<% end %>
<% end %>
<% if current_user.moderator? %>
<br />
<div class="edit_user">
<%=
react_component(
'UserProfile/GenerateApiKeyDialog',
{
currentApiKey: current_user.api_key.present? ? token_mask(current_user.api_key.token_prefix) : nil,
generateApiKeyEndpoint: api_keys_path,
authenticityToken: form_authenticity_token
},
)
%>
</div>
<% end %>
<br />
<div class="edit_user">

Some files were not shown because too many files have changed in this diff Show More