mirror of
https://github.com/astuto/astuto.git
synced 2025-12-24 15:39:46 +01:00
Compare commits
157 Commits
riggraz-up
...
l10n_main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5eb2acede | ||
|
|
8ab3ad980f | ||
|
|
52a21a9635 | ||
|
|
d09ecacd73 | ||
|
|
78edcb5878 | ||
|
|
3910f3794e | ||
|
|
6f729b4335 | ||
|
|
e3f2ac163e | ||
|
|
9ad4506256 | ||
|
|
f97e0ea4d3 | ||
|
|
bf63643f0a | ||
|
|
50c556eec1 | ||
|
|
3ee2f0d23a | ||
|
|
8ff33c2bba | ||
|
|
fb2bc9b9c7 | ||
|
|
590ff4410d | ||
|
|
c2e146cbf3 | ||
|
|
5c516c8ecc | ||
|
|
8272502a2a | ||
|
|
b34131d989 | ||
|
|
372f82e17c | ||
|
|
3dc683bccc | ||
|
|
a71113785c | ||
|
|
a482c5949c | ||
|
|
d8e77fa5b7 | ||
|
|
91a045ad12 | ||
|
|
c06c29dfc2 | ||
|
|
59507a0d4b | ||
|
|
d277567b3b | ||
|
|
346151ced8 | ||
|
|
9986dad55d | ||
|
|
36386faa8c | ||
|
|
1f01aa6243 | ||
|
|
95519efc03 | ||
|
|
fa871b7247 | ||
|
|
70e6d8360d | ||
|
|
26dfeadd2c | ||
|
|
a0b5bc2c56 | ||
|
|
71b22e41b7 | ||
|
|
0eb86a0da3 | ||
|
|
9e94be5f73 | ||
|
|
c3fa6f8ea7 | ||
|
|
a6ed1108bc | ||
|
|
bc4ba92cf8 | ||
|
|
23e89a47f9 | ||
|
|
46626aee94 | ||
|
|
5f02741b0a | ||
|
|
cbadd00cca | ||
|
|
8e0420a164 | ||
|
|
7637efde5c | ||
|
|
c55d88e07c | ||
|
|
787bfd3009 | ||
|
|
1073554ed4 | ||
|
|
ae2ef36c05 | ||
|
|
31c02fabca | ||
|
|
cd01e02f09 | ||
|
|
f1452a25a9 | ||
|
|
4f162f9c00 | ||
|
|
d15768495c | ||
|
|
c3aa7bca3e | ||
|
|
fc3c26df72 | ||
|
|
dfc3cd545c | ||
|
|
f6eb75b0bc | ||
|
|
34036b9b6f | ||
|
|
556c6a9c82 | ||
|
|
036f929636 | ||
|
|
f34425e4df | ||
|
|
d6847cc188 | ||
|
|
bdbc76ddfb | ||
|
|
0dc35e02f6 | ||
|
|
82eba8950e | ||
|
|
edb80ee153 | ||
|
|
9ef82a72cd | ||
|
|
38cccb2c6b | ||
|
|
3a939d4bb0 | ||
|
|
afe4766577 | ||
|
|
4fc6ced76c | ||
|
|
952277c901 | ||
|
|
3364df40f1 | ||
|
|
c7721c6c86 | ||
|
|
5fbb2505fc | ||
|
|
eb09a73f2a | ||
|
|
0fd036faf8 | ||
|
|
7557a7c72d | ||
|
|
7204095f13 | ||
|
|
d1a029f484 | ||
|
|
b9663e8bae | ||
|
|
42c5eb283a | ||
|
|
3f91d64806 | ||
|
|
2fcd36fc85 | ||
|
|
e16422d9e2 | ||
|
|
5688bd4fbf | ||
|
|
8fc962c8e3 | ||
|
|
607896b35e | ||
|
|
d8e87e9803 | ||
|
|
aa7ee143f2 | ||
|
|
38fd84e9bd | ||
|
|
e524c0fb6f | ||
|
|
f21a5fe351 | ||
|
|
fb4d8d5ed1 | ||
|
|
cba2022ba4 | ||
|
|
b4cac94170 | ||
|
|
cc5459c72b | ||
|
|
f4aad486d6 | ||
|
|
e0912d007e | ||
|
|
783944b77f | ||
|
|
912bb769f3 | ||
|
|
4a47ee43c8 | ||
|
|
23c0b990e6 | ||
|
|
47dc4d6d98 | ||
|
|
b0622a7a17 | ||
|
|
b2b4b0acc6 | ||
|
|
51c3a55a90 | ||
|
|
333d58e01a | ||
|
|
b5d3b0ff1b | ||
|
|
09b3fb0e49 | ||
|
|
43d229c787 | ||
|
|
95f040026f | ||
|
|
ff1bcfbd27 | ||
|
|
4370a218ff | ||
|
|
e18ba3209f | ||
|
|
2f1ba18834 | ||
|
|
239f8a4c38 | ||
|
|
409cba42ea | ||
|
|
00d8389c4f | ||
|
|
412822c753 | ||
|
|
c9043563b9 | ||
|
|
6cdd6aa87f | ||
|
|
8b7f4d7eef | ||
|
|
1e5cdbbc04 | ||
|
|
f66f51a002 | ||
|
|
3e311d2dab | ||
|
|
46d2350d5c | ||
|
|
50d9f7e29a | ||
|
|
978d8a6453 | ||
|
|
807aeac7a1 | ||
|
|
a5b0ef3724 | ||
|
|
c61a1a3260 | ||
|
|
c765069e5f | ||
|
|
5633fd2a88 | ||
|
|
f738b8a737 | ||
|
|
b49d1537c2 | ||
|
|
9128ef0287 | ||
|
|
f20b6b59d9 | ||
|
|
dca0e729db | ||
|
|
d622753d2f | ||
|
|
e6448dc37a | ||
|
|
cce2374759 | ||
|
|
cd2b752c0d | ||
|
|
d5d4be601e | ||
|
|
13bdf8dac9 | ||
|
|
d099605298 | ||
|
|
94b0b967f7 | ||
|
|
dc2fc47eb0 | ||
|
|
65717d7347 | ||
|
|
2e4938baf8 | ||
|
|
272371dde2 |
2
.github/workflows/run-tests.yml
vendored
2
.github/workflows/run-tests.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- 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
3
.gitignore
vendored
@@ -41,6 +41,3 @@ yarn-debug.log*
|
||||
|
||||
/app/assets/builds/*
|
||||
!/app/assets/builds/.keep
|
||||
|
||||
# Ignore Swagger spec file
|
||||
/swagger/*
|
||||
@@ -37,12 +37,9 @@ 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 SECRET_KEY_BASE=1 ./bin/rails assets:precompile; fi
|
||||
RUN if [ "$ENVIRONMENT" = "production" ]; then RAILS_ENV=development ./bin/rails assets:precompile; fi
|
||||
|
||||
###
|
||||
### Dev stage ###
|
||||
@@ -94,9 +91,6 @@ 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
36
Gemfile
@@ -3,9 +3,9 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" }
|
||||
|
||||
ruby '3.0.6'
|
||||
|
||||
gem 'rake', '13.2.1'
|
||||
gem 'rake', '12.3.3'
|
||||
|
||||
gem 'rails', '6.1.7.9'
|
||||
gem 'rails', '6.1.7.8'
|
||||
|
||||
gem 'pg', '1.3.5'
|
||||
|
||||
@@ -50,29 +50,10 @@ 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
|
||||
|
||||
@@ -83,10 +64,11 @@ group :development do
|
||||
end
|
||||
|
||||
group :test do
|
||||
gem 'selenium-webdriver', '4.17.0'
|
||||
|
||||
# Retry flaky Capybara tests
|
||||
gem 'rspec-retry', '0.6.2'
|
||||
# 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'
|
||||
end
|
||||
|
||||
# If not bundled, webpack compilation in production fails
|
||||
|
||||
264
Gemfile.lock
264
Gemfile.lock
@@ -1,124 +1,116 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (6.1.7.9)
|
||||
actionpack (= 6.1.7.9)
|
||||
activesupport (= 6.1.7.9)
|
||||
actioncable (6.1.7.8)
|
||||
actionpack (= 6.1.7.8)
|
||||
activesupport (= 6.1.7.8)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
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)
|
||||
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)
|
||||
mail (>= 2.7.1)
|
||||
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)
|
||||
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)
|
||||
mail (~> 2.5, >= 2.5.4)
|
||||
rails-dom-testing (~> 2.0)
|
||||
actionpack (6.1.7.9)
|
||||
actionview (= 6.1.7.9)
|
||||
activesupport (= 6.1.7.9)
|
||||
actionpack (6.1.7.8)
|
||||
actionview (= 6.1.7.8)
|
||||
activesupport (= 6.1.7.8)
|
||||
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.9)
|
||||
actionpack (= 6.1.7.9)
|
||||
activerecord (= 6.1.7.9)
|
||||
activestorage (= 6.1.7.9)
|
||||
activesupport (= 6.1.7.9)
|
||||
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)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (6.1.7.9)
|
||||
activesupport (= 6.1.7.9)
|
||||
actionview (6.1.7.8)
|
||||
activesupport (= 6.1.7.8)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.4)
|
||||
rails-dom-testing (~> 2.0)
|
||||
rails-html-sanitizer (~> 1.1, >= 1.2.0)
|
||||
activejob (6.1.7.9)
|
||||
activesupport (= 6.1.7.9)
|
||||
activejob (6.1.7.8)
|
||||
activesupport (= 6.1.7.8)
|
||||
globalid (>= 0.3.6)
|
||||
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)
|
||||
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)
|
||||
marcel (~> 1.0)
|
||||
mini_mime (>= 1.1.0)
|
||||
activesupport (6.1.7.9)
|
||||
activesupport (6.1.7.8)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
i18n (>= 1.6, < 2)
|
||||
minitest (>= 5.1)
|
||||
tzinfo (~> 2.0)
|
||||
zeitwerk (~> 2.3)
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
addressable (2.8.0)
|
||||
public_suffix (>= 2.0.2, < 5.0)
|
||||
babel-source (5.8.35)
|
||||
babel-transpiler (0.7.0)
|
||||
babel-source (>= 4.0, < 6)
|
||||
execjs (~> 2.0)
|
||||
base64 (0.2.0)
|
||||
bcrypt (3.1.20)
|
||||
bcrypt (3.1.18)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.12.0)
|
||||
msgpack (~> 1.2)
|
||||
builder (3.3.0)
|
||||
byebug (11.1.3)
|
||||
capybara (3.40.0)
|
||||
capybara (3.36.0)
|
||||
addressable
|
||||
matrix
|
||||
mini_mime (>= 0.1.3)
|
||||
nokogiri (~> 1.11)
|
||||
nokogiri (~> 1.8)
|
||||
rack (>= 1.6.0)
|
||||
rack-test (>= 0.6.3)
|
||||
regexp_parser (>= 1.5, < 3.0)
|
||||
xpath (~> 3.2)
|
||||
concurrent-ruby (1.3.4)
|
||||
connection_pool (2.4.1)
|
||||
childprocess (4.1.0)
|
||||
concurrent-ruby (1.3.3)
|
||||
connection_pool (2.2.5)
|
||||
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.4.0)
|
||||
date (3.3.4)
|
||||
devise (4.7.3)
|
||||
bcrypt (~> 3.0)
|
||||
orm_adapter (~> 0.1)
|
||||
railties (>= 4.1.0)
|
||||
responders
|
||||
warden (~> 1.2.3)
|
||||
diff-lcs (1.5.1)
|
||||
diff-lcs (1.5.0)
|
||||
erubi (1.13.0)
|
||||
et-orbi (1.2.11)
|
||||
tzinfo
|
||||
execjs (2.10.0)
|
||||
execjs (2.8.1)
|
||||
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.17.0)
|
||||
ffi (1.15.5)
|
||||
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.6)
|
||||
i18n (1.14.5)
|
||||
concurrent-ruby (~> 1.0)
|
||||
i18n-js (3.9.2)
|
||||
i18n (>= 0.6.6)
|
||||
@@ -127,8 +119,6 @@ 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)
|
||||
@@ -141,12 +131,10 @@ 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)
|
||||
logger (1.6.1)
|
||||
loofah (2.23.1)
|
||||
loofah (2.22.0)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
mail (2.8.1)
|
||||
@@ -158,11 +146,11 @@ GEM
|
||||
matrix (0.4.2)
|
||||
method_source (1.1.0)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.8)
|
||||
minitest (5.25.1)
|
||||
msgpack (1.7.5)
|
||||
mini_portile2 (2.8.7)
|
||||
minitest (5.24.1)
|
||||
msgpack (1.5.2)
|
||||
multi_xml (0.6.0)
|
||||
net-imap (0.4.18)
|
||||
net-imap (0.4.14)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
@@ -171,57 +159,54 @@ GEM
|
||||
timeout
|
||||
net-smtp (0.5.0)
|
||||
net-protocol
|
||||
nio4r (2.7.4)
|
||||
nokogiri (1.16.8)
|
||||
nio4r (2.7.3)
|
||||
nokogiri (1.16.6)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
orm_adapter (0.5.0)
|
||||
pg (1.3.5)
|
||||
public_suffix (6.0.1)
|
||||
public_suffix (4.0.7)
|
||||
puma (5.6.9)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.2.0)
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (2.2.10)
|
||||
racc (1.8.0)
|
||||
rack (2.2.9)
|
||||
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.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)
|
||||
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)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 6.1.7.9)
|
||||
railties (= 6.1.7.8)
|
||||
sprockets-rails (>= 2.0.0)
|
||||
rails-dom-testing (2.2.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
nokogiri (>= 1.6)
|
||||
rails-html-sanitizer (1.6.1)
|
||||
rails-html-sanitizer (1.6.0)
|
||||
loofah (~> 2.21)
|
||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||
railties (6.1.7.9)
|
||||
actionpack (= 6.1.7.9)
|
||||
activesupport (= 6.1.7.9)
|
||||
nokogiri (~> 1.14)
|
||||
railties (6.1.7.8)
|
||||
actionpack (= 6.1.7.8)
|
||||
activesupport (= 6.1.7.8)
|
||||
method_source
|
||||
rake (>= 12.2)
|
||||
thor (~> 1.0)
|
||||
rake (13.2.1)
|
||||
rake (12.3.3)
|
||||
rb-fsevent (0.11.2)
|
||||
rb-inotify (0.11.1)
|
||||
rb-inotify (0.10.1)
|
||||
ffi (~> 1.0)
|
||||
react-rails (2.6.2)
|
||||
babel-transpiler (>= 0.7.0)
|
||||
@@ -229,21 +214,20 @@ GEM
|
||||
execjs
|
||||
railties (>= 3.2)
|
||||
tilt
|
||||
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)
|
||||
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)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-mocks (3.13.2)
|
||||
rspec-support (~> 3.12.0)
|
||||
rspec-mocks (3.12.5)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-support (~> 3.12.0)
|
||||
rspec-rails (4.0.2)
|
||||
actionpack (>= 4.2)
|
||||
activesupport (>= 4.2)
|
||||
@@ -252,33 +236,12 @@ GEM
|
||||
rspec-expectations (~> 3.10)
|
||||
rspec-mocks (~> 3.10)
|
||||
rspec-support (~> 3.10)
|
||||
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)
|
||||
rspec-support (3.12.0)
|
||||
rubyzip (2.3.2)
|
||||
selenium-webdriver (4.17.0)
|
||||
base64 (~> 0.2)
|
||||
selenium-webdriver (4.1.0)
|
||||
childprocess (>= 0.5, < 5.0)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
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)
|
||||
rubyzip (>= 1.2.2)
|
||||
spring (2.1.1)
|
||||
spring-watcher-listen (2.0.1)
|
||||
listen (>= 2.7, < 4.0)
|
||||
@@ -286,34 +249,37 @@ GEM
|
||||
sprockets (4.2.1)
|
||||
concurrent-ruby (~> 1.0)
|
||||
rack (>= 2.2.4, < 4)
|
||||
sprockets-rails (3.5.2)
|
||||
sprockets-rails (3.5.1)
|
||||
actionpack (>= 6.1)
|
||||
activesupport (>= 6.1)
|
||||
sprockets (>= 3.0.0)
|
||||
stripe (11.2.0)
|
||||
thor (1.3.2)
|
||||
tilt (2.4.0)
|
||||
timeout (0.4.2)
|
||||
strscan (3.1.0)
|
||||
thor (1.3.1)
|
||||
tilt (2.0.10)
|
||||
timeout (0.4.1)
|
||||
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.1)
|
||||
web-console (4.2.0)
|
||||
actionview (>= 6.0.0)
|
||||
activemodel (>= 6.0.0)
|
||||
bindex (>= 0.4.0)
|
||||
railties (>= 6.0.0)
|
||||
websocket (1.2.11)
|
||||
webdrivers (5.3.1)
|
||||
nokogiri (~> 1.6)
|
||||
rubyzip (>= 1.3.0)
|
||||
selenium-webdriver (~> 4.0, < 4.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.18)
|
||||
zeitwerk (2.6.16)
|
||||
|
||||
PLATFORMS
|
||||
ruby
|
||||
@@ -321,7 +287,7 @@ PLATFORMS
|
||||
DEPENDENCIES
|
||||
bootsnap (= 1.12.0)
|
||||
byebug
|
||||
capybara (= 3.40.0)
|
||||
capybara (= 3.36.0)
|
||||
cssbundling-rails (= 1.1.2)
|
||||
devise (= 4.7.3)
|
||||
factory_bot_rails (= 5.0.2)
|
||||
@@ -331,29 +297,23 @@ 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)
|
||||
rack-cors (= 2.0.2)
|
||||
rails (= 6.1.7.9)
|
||||
rake (= 13.2.1)
|
||||
rails (= 6.1.7.8)
|
||||
rake (= 12.3.3)
|
||||
react-rails (= 2.6.2)
|
||||
rspec-rails (= 4.0.2)
|
||||
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)
|
||||
selenium-webdriver (= 4.1.0)
|
||||
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
|
||||
|
||||
56
README.md
56
README.md
@@ -1,30 +1,46 @@
|
||||
<p align="center">
|
||||
<img width="400" src="./images/logo-and-name.png" />
|
||||
<a href="https://astuto.io/?utm_campaign=github_logo&utm_source=github.com">
|
||||
<img width="400" src="./images/logo-and-name.png" />
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://www.producthunt.com/posts/astuto?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-astuto" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=179870&theme=neutral&period=daily" alt="Astuto - An open source customer feedback tool 🦊 | Product Hunt Embed" style="width: 250px; height: 54px;" width="250px" height="54px" /></a>
|
||||
<br>
|
||||
<h3 align="center">
|
||||
<a href="https://feedback.astuto.io/">✨ Try it out</a>
|
||||
•
|
||||
<a href="https://astuto.io/?utm_campaign=github_learnmore&utm_source=github.com">📖 Learn more</a>
|
||||
</h3>
|
||||
</p>
|
||||
|
||||
Astuto is an open source customer feedback tool. It helps you collect, manage and prioritize feedback from your customers, so you can build a better product.
|
||||
|
||||
<img src="./images/hero-image.png" />
|
||||
|
||||
## Features
|
||||
|
||||
- **Roadmap**: show users what you're working on
|
||||
- **Simple Sign In**: let users log in with email or any OAuth2 provider
|
||||
- **Webhooks**: integrate with your existing tools (e.g. Jira, Trello, Slack)
|
||||
- **API**: programmatically manage your feedback space with our REST API
|
||||
- **Moderation Queue**: decide whether to show new feedback immediately or request approval
|
||||
- **Anonymous Feedback**: enable unregistered users to publish feedback
|
||||
- **... and more**: invitation system, brand customization, recap emails for administrators, private site settings, and more!
|
||||
|
||||
## Documentation
|
||||
|
||||
Documentation website is not online anymore. You can read Astuto's documentation from the [GitHub repository](https://github.com/astuto/astuto-docs).
|
||||
<a href="https://feedback.astuto.io/">
|
||||
<img src="./images/hero-image.png" />
|
||||
</a>
|
||||
|
||||
## Get started
|
||||
|
||||
### Hosted
|
||||
|
||||
We offer a hosted solution, so you don't have to provision your own server. This is the easiest and fastest way to get started: you can sign up and start collecting feedback in a few minutes.
|
||||
|
||||
[Start your 7-day free trial](https://login.astuto.io/signup) without entering any payment method, then it's 15 €/month with annual subscription or 20 €/month with monthly subscription. [Learn more on astuto.io](https://astuto.io/?utm_campaign=github_getstarted&utm_source=github.com).
|
||||
|
||||
With the paid plan:
|
||||
|
||||
- You avoid deployment hassles like renting a server, issuing SSL certificates, configuring a mail server and managing updates
|
||||
- You get some OAuth providers out of the box: Google, Facebook and GitHub are ready to log your users in, no configuration needed
|
||||
- You get priority support
|
||||
- You support open source and get our eternal gratitude :)
|
||||
|
||||
|
||||
### Self-hosted
|
||||
|
||||
Read the [Deploy with Docker instructions](https://docs.astuto.io/deploy-docker) for the most comprehensive and up to date guide on installing and configuring Astuto.
|
||||
|
||||
What you find below are minimal instructions to get you started as quickly as possible:
|
||||
|
||||
0. Ensure you have Docker and Docker Compose installed
|
||||
1. Create an empty folder
|
||||
2. Inside that folder, create a `docker-compose.yml` file with the following content:
|
||||
@@ -51,17 +67,21 @@ services:
|
||||
volumes:
|
||||
dbdata:
|
||||
```
|
||||
3. Edit the environment variables to fit your needs
|
||||
3. Edit the environment variables to fit your needs. You can find more information about env variables in the [documentation](https://docs.astuto.io/deploy-docker/#2-edit-environment-variables).
|
||||
4. Run `docker compose pull && docker compose up`
|
||||
5. You should now have a running instance of Astuto on port 3000. A default user account has been created with credentials email: `admin@example.com`, password: `password`.
|
||||
|
||||
## Documentation
|
||||
|
||||
Check out [docs.astuto.io](https://docs.astuto.io/) to learn how to deploy Astuto, configure custom OAuth providers, customize appearance and more!
|
||||
|
||||
## Contributing
|
||||
|
||||
There are many ways to contribute to Astuto, not just coding. Proposing features, reporting issues, translating to a new language or improving documentation are a few examples! Please read our [contributing guidelines](https://github.com/riggraz/astuto/blob/main/CONTRIBUTING.md) to learn more.
|
||||
|
||||
## Credits
|
||||
|
||||
Astuto logo and all image assets are credited [here](https://github.com/astuto/astuto-io/blob/main/src/pages/Credits.jsx).
|
||||
Astuto logo and all image assets are credited [here](https://astuto.io/credits).
|
||||
|
||||
A huge thank you to code contributors
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@
|
||||
@import 'components/SiteSettings/Authentication';
|
||||
@import 'components/SiteSettings/Appearance/';
|
||||
@import 'components/SiteSettings/Invitations';
|
||||
@import 'components/SiteSettings/Webhooks';
|
||||
|
||||
/* Moderation Components */
|
||||
@import 'components/Moderation/Feedback';
|
||||
|
||||
@@ -23,14 +23,8 @@
|
||||
|
||||
padding: 15px;
|
||||
margin: 0 auto;
|
||||
|
||||
label[for=user_notifications_enabled] {
|
||||
@extend .mb-0;
|
||||
}
|
||||
}
|
||||
|
||||
.apiKeyGenerateButton { width: 100%; }
|
||||
|
||||
.deviseLinks {
|
||||
@extend .new_user;
|
||||
|
||||
|
||||
@@ -173,7 +173,6 @@ body {
|
||||
}
|
||||
.badgeWarning { @extend .badge-warning; }
|
||||
.badgeDanger { @extend .badge-danger; }
|
||||
.badgeSuccess { @extend .badge-success; }
|
||||
|
||||
.container {
|
||||
max-width: 960px;
|
||||
@@ -300,8 +299,8 @@ body {
|
||||
}
|
||||
|
||||
.staffIcon {
|
||||
font-size: 22px;
|
||||
margin: 0 0.5rem;
|
||||
font-size: 24px;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.poweredBy {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.commentsContainer {
|
||||
@extend .mt-2;
|
||||
@extend .my-3;
|
||||
|
||||
.commentForm {
|
||||
@extend
|
||||
@@ -20,7 +20,7 @@
|
||||
@extend
|
||||
.d-flex,
|
||||
.flex-column,
|
||||
.mt-4;
|
||||
.my-3;
|
||||
|
||||
.commentBodyForm {
|
||||
@extend .d-flex;
|
||||
@@ -53,8 +53,6 @@
|
||||
}
|
||||
|
||||
.editCommentForm {
|
||||
.commentFormContainer { @extend .d-block; }
|
||||
|
||||
textarea {
|
||||
@extend .my-2;
|
||||
}
|
||||
@@ -73,27 +71,19 @@
|
||||
.text-secondary,
|
||||
.text-uppercase,
|
||||
.font-weight-lighter,
|
||||
.mt-5,
|
||||
.mb-2;
|
||||
.my-2;
|
||||
}
|
||||
|
||||
.commentList { @extend .mb-4; }
|
||||
|
||||
.commentList > .commentList {
|
||||
padding-left: 32px;
|
||||
}
|
||||
|
||||
.comment {
|
||||
@extend
|
||||
.mb-2;
|
||||
.my-4;
|
||||
|
||||
.commentHeader {
|
||||
@extend
|
||||
.d-flex,
|
||||
.align-items-end,
|
||||
.titleText;
|
||||
|
||||
height: 36px;
|
||||
@extend .titleText;
|
||||
|
||||
.commentAuthor {
|
||||
@extend .ml-2;
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
.pl-0;
|
||||
|
||||
list-style: none;
|
||||
max-height: 500px;
|
||||
height: 500px;
|
||||
overflow-y: scroll;
|
||||
|
||||
li.invitationListItem {
|
||||
@@ -84,13 +84,9 @@
|
||||
div.invitationInfo {
|
||||
@extend .d-flex;
|
||||
|
||||
span.invitationAcceptedAt, span.invitationSentAt, span.invitationExpired {
|
||||
span.invitationAcceptedAt, span.invitationSentAt {
|
||||
@extend .align-self-center, .mutedText;
|
||||
}
|
||||
|
||||
span.invitationExpired {
|
||||
@extend .text-danger;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
.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; }
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
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
|
||||
@@ -1,49 +0,0 @@
|
||||
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
|
||||
@@ -1,131 +0,0 @@
|
||||
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
|
||||
@@ -1,19 +0,0 @@
|
||||
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
|
||||
@@ -1,79 +0,0 @@
|
||||
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
|
||||
@@ -1,16 +0,0 @@
|
||||
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
|
||||
@@ -1,192 +0,0 @@
|
||||
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
|
||||
@@ -1,62 +0,0 @@
|
||||
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
|
||||
@@ -1,91 +0,0 @@
|
||||
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
|
||||
@@ -1,19 +0,0 @@
|
||||
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
|
||||
@@ -17,9 +17,7 @@ class ApplicationController < ActionController::Base
|
||||
if resource.admin? && resource.sign_in_count == 1
|
||||
root_path(tour: true)
|
||||
else
|
||||
safe_return_to_redirect(session[:return_to]) do
|
||||
super
|
||||
end
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
@@ -35,12 +33,7 @@ class ApplicationController < ActionController::Base
|
||||
protected
|
||||
|
||||
def configure_devise_permitted_parameters
|
||||
additional_permitted_parameters = [
|
||||
:full_name,
|
||||
:notifications_enabled,
|
||||
:recap_notification_frequency,
|
||||
:invitation_token
|
||||
]
|
||||
additional_permitted_parameters = [:full_name, :notifications_enabled, :invitation_token]
|
||||
|
||||
devise_parameter_sanitizer.permit(:sign_up, keys: additional_permitted_parameters)
|
||||
devise_parameter_sanitizer.permit(:account_update, keys: additional_permitted_parameters)
|
||||
|
||||
@@ -77,7 +77,6 @@ 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 }
|
||||
@@ -124,18 +123,17 @@ 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
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ class InvitationsController < ApplicationController
|
||||
)
|
||||
)
|
||||
|
||||
InvitationMailer.invite(invitation: invitation, subject: subject, body: body_with_link).deliver_later
|
||||
InvitationMailer.invite(invitation: invitation, subject: subject, body: body_with_link).deliver_now
|
||||
|
||||
num_invitations_sent += 1
|
||||
end
|
||||
@@ -56,6 +56,7 @@ class InvitationsController < ApplicationController
|
||||
render json: {}, status: :ok
|
||||
end
|
||||
|
||||
|
||||
private
|
||||
|
||||
def invitation_params
|
||||
@@ -65,4 +66,4 @@ class InvitationsController < ApplicationController
|
||||
invitation.require(:body)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -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,7 +132,6 @@ 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)
|
||||
@@ -208,4 +207,4 @@ class OAuthsController < ApplicationController
|
||||
.require(:o_auth)
|
||||
.permit(policy(@o_auth).permitted_attributes)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -108,11 +108,15 @@ class PostsController < ApplicationController
|
||||
|
||||
if @post.save
|
||||
if @post.post_status_id_previously_changed?
|
||||
ExecutePostStatusChangeLogicWorkflow.new(
|
||||
PostStatusChange.create(
|
||||
user_id: current_user.id,
|
||||
post: @post,
|
||||
post_id: @post.id,
|
||||
post_status_id: @post.post_status_id
|
||||
).run
|
||||
)
|
||||
|
||||
@post.followers.each do |follower|
|
||||
UserMailer.notify_follower_of_post_status_change(post: @post, follower: follower).deliver_later
|
||||
end
|
||||
end
|
||||
|
||||
render json: @post
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
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]
|
||||
@@ -14,11 +12,12 @@ 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.expired? || invitation.token_digest != Digest::SHA256.hexdigest(sign_up_params[:invitation_token]) || invitation.accepted_at.present?
|
||||
if invitation.nil? || 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
|
||||
@@ -89,15 +88,12 @@ 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
|
||||
safe_return_to_redirect(session[:return_to]) do
|
||||
super
|
||||
end
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -4,18 +4,9 @@ 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
|
||||
@@ -19,9 +19,6 @@ class SiteSettingsController < ApplicationController
|
||||
def roadmap
|
||||
end
|
||||
|
||||
def webhooks
|
||||
end
|
||||
|
||||
def invitations
|
||||
@invitations = Invitation.all.order(updated_at: :desc)
|
||||
end
|
||||
|
||||
@@ -15,9 +15,6 @@ class TenantsController < ApplicationController
|
||||
end
|
||||
|
||||
def create
|
||||
# NOTE: new tenants registrations disabled
|
||||
raise "Tenant registration disabled"
|
||||
|
||||
@tenant = Tenant.new
|
||||
@tenant.assign_attributes(tenant_create_params)
|
||||
authorize @tenant
|
||||
@@ -46,8 +43,7 @@ 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",
|
||||
recap_notification_frequency: "daily"
|
||||
role: "owner"
|
||||
)
|
||||
|
||||
if is_o_auth_login
|
||||
|
||||
@@ -20,15 +20,9 @@ class UsersController < ApplicationController
|
||||
# Handle special case: trying to set user role to 'owner'
|
||||
raise Pundit::NotAuthorizedError if @user.owner?
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
DestroyApiKeyIfNeededWorkflow.new(user: @user).run
|
||||
|
||||
if @user.save
|
||||
render json: @user
|
||||
else
|
||||
raise ActiveRecord::Rollback
|
||||
end
|
||||
rescue ActiveRecord::Rollback
|
||||
if @user.save
|
||||
render json: @user
|
||||
else
|
||||
render json: {
|
||||
error: @user.errors.full_messages
|
||||
}, status: :unprocessable_entity
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
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
|
||||
@@ -1,5 +0,0 @@
|
||||
module ApiKeysHelper
|
||||
def token_mask(prefix, length = 30)
|
||||
"#{prefix}#{"•"*length}"
|
||||
end
|
||||
end
|
||||
@@ -39,37 +39,24 @@ module ApplicationHelper
|
||||
end
|
||||
|
||||
def get_url_for(url_helper, resource: nil, disallow_custom_domain: false, options: {})
|
||||
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)
|
||||
subdomain = Current.tenant.subdomain
|
||||
options[:subdomain] = Current.tenant.subdomain
|
||||
end
|
||||
|
||||
if custom_domain.blank? || disallow_custom_domain
|
||||
host = Rails.application.base_url
|
||||
host = host.gsub(%r{\Ahttps?://|/$}, '')
|
||||
host = "#{subdomain}.#{host}" if subdomain.present?
|
||||
options[:host] = Rails.application.base_url
|
||||
else
|
||||
host = custom_domain
|
||||
options[: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
|
||||
|
||||
@@ -92,12 +79,4 @@ 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
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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";
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -1,59 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -1,78 +0,0 @@
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -1,96 +0,0 @@
|
||||
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);
|
||||
}
|
||||
};
|
||||
@@ -125,7 +125,7 @@ const Billing = ({
|
||||
<p>Subscription {isExpired ? 'expired' : 'expires'} on {subscriptionEndsAtFormatted}</p>
|
||||
}
|
||||
|
||||
{/* {
|
||||
{
|
||||
(tenantBilling.status === TENANT_BILLING_STATUS_TRIAL) && chosenPrice === null &&
|
||||
<PricingTable
|
||||
prices={prices}
|
||||
@@ -169,20 +169,16 @@ const Billing = ({
|
||||
You will be redirected to Stripe, our billing partner.
|
||||
</SmallMutedText>
|
||||
</div>
|
||||
} */}
|
||||
}
|
||||
|
||||
<p>
|
||||
We do not accept new subscriptions right now.
|
||||
</p>
|
||||
|
||||
{/* <div className="billingUsefulLinks">
|
||||
<div className="billingUsefulLinks">
|
||||
<ActionLink onClick={() => window.open('https://astuto.io/terms-of-service', '_blank')} icon={<LearnMoreIcon />}>
|
||||
Terms of Service
|
||||
</ActionLink>
|
||||
<ActionLink onClick={() => window.open('https://astuto.io/privacy-policy', '_blank')} icon={<LearnMoreIcon />}>
|
||||
Privacy Policy
|
||||
</ActionLink>
|
||||
</div> */}
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import I18n from 'i18n-js';
|
||||
|
||||
import Button from '../common/Button';
|
||||
import { SmallMutedText } from '../common/CustomTexts';
|
||||
import { MarkdownIcon } from '../common/Icons';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
@@ -94,9 +93,6 @@ 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">
|
||||
|
||||
@@ -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, MarkdownIcon } from '../common/Icons';
|
||||
import { CancelIcon } from '../common/Icons';
|
||||
|
||||
interface Props {
|
||||
id: number;
|
||||
@@ -55,19 +55,12 @@ class CommentEditForm extends React.Component<Props, State> {
|
||||
|
||||
return (
|
||||
<div className="editCommentForm">
|
||||
|
||||
<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>
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={e => this.handleCommentBodyChange(e.target.value)}
|
||||
rows={3}
|
||||
className="commentForm"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import I18n from 'i18n-js';
|
||||
import NewComment from './NewComment';
|
||||
import CommentList from './CommentList';
|
||||
import Spinner from '../common/Spinner';
|
||||
import { DangerText, MutedText } from '../common/CustomTexts';
|
||||
import { DangerText } from '../common/CustomTexts';
|
||||
|
||||
import IComment from '../../interfaces/IComment';
|
||||
import { ReplyFormState } from '../../reducers/replyFormReducer';
|
||||
@@ -122,16 +122,15 @@ 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}
|
||||
|
||||
@@ -7,7 +7,6 @@ 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;
|
||||
@@ -49,20 +48,12 @@ const NewComment = ({
|
||||
<>
|
||||
<div className="commentBodyForm">
|
||||
<Gravatar email={userEmail} size={48} className="currentUserAvatar" />
|
||||
|
||||
<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>
|
||||
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={handleChange}
|
||||
placeholder={I18n.t('post.new_comment.body_placeholder')}
|
||||
className="commentForm"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => handleSubmit(body, parentId, postUpdateFlagValue)}
|
||||
className="submitCommentButton">
|
||||
|
||||
@@ -65,7 +65,6 @@ const PostEditForm = ({
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={e => handleChangeTitle(e.target.value)}
|
||||
autoFocus
|
||||
className="postTitle form-control"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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, postJSON2JS } from '../../interfaces/IPost';
|
||||
import IPost, { POST_APPROVAL_STATUS_APPROVED, POST_APPROVAL_STATUS_PENDING } from '../../interfaces/IPost';
|
||||
import IPostStatus from '../../interfaces/IPostStatus';
|
||||
import IBoard from '../../interfaces/IBoard';
|
||||
import ITenantSetting from '../../interfaces/ITenantSetting';
|
||||
@@ -29,7 +29,6 @@ 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;
|
||||
@@ -42,7 +41,6 @@ interface Props {
|
||||
postStatusChanges: PostStatusChangesState;
|
||||
boards: Array<IBoard>;
|
||||
postStatuses: Array<IPostStatus>;
|
||||
originPost: any;
|
||||
isLoggedIn: boolean;
|
||||
isPowerUser: boolean;
|
||||
currentUserFullName: string;
|
||||
@@ -50,7 +48,7 @@ interface Props {
|
||||
tenantSetting: ITenantSetting;
|
||||
authenticityToken: string;
|
||||
|
||||
requestPost(postId: number): Promise<any>;
|
||||
requestPost(postId: number): void;
|
||||
updatePost(
|
||||
postId: number,
|
||||
title: string,
|
||||
@@ -60,7 +58,7 @@ interface Props {
|
||||
authenticityToken: string,
|
||||
): Promise<any>;
|
||||
|
||||
requestLikes(postId: number): Promise<any>;
|
||||
requestLikes(postId: number): void;
|
||||
requestFollow(postId: number): void;
|
||||
requestPostStatusChanges(postId: number): void;
|
||||
|
||||
@@ -85,20 +83,10 @@ interface Props {
|
||||
): void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
postLoaded: boolean;
|
||||
likesLoaded: boolean;
|
||||
}
|
||||
|
||||
class PostP extends React.Component<Props, State> {
|
||||
class PostP extends React.Component<Props> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
postLoaded: false,
|
||||
likesLoaded: false,
|
||||
}
|
||||
|
||||
this._handleUpdatePost = this._handleUpdatePost.bind(this);
|
||||
this._handleDeletePost = this._handleDeletePost.bind(this);
|
||||
}
|
||||
@@ -106,8 +94,8 @@ class PostP extends React.Component<Props, State> {
|
||||
componentDidMount() {
|
||||
const { postId } = this.props;
|
||||
|
||||
this.props.requestPost(postId).then(() => this.setState({ postLoaded: true }));
|
||||
this.props.requestLikes(postId).then(() => this.setState({ likesLoaded: true }));
|
||||
this.props.requestPost(postId);
|
||||
this.props.requestLikes(postId);
|
||||
this.props.requestFollow(postId);
|
||||
this.props.requestPostStatusChanges(postId);
|
||||
}
|
||||
@@ -149,10 +137,7 @@ class PostP extends React.Component<Props, State> {
|
||||
this.props.deletePost(
|
||||
this.props.postId,
|
||||
this.props.authenticityToken
|
||||
).then(() => {
|
||||
const board = this.props.boards.find(board => board.id === this.props.post.boardId);
|
||||
window.location.href = `/boards/${board.slug || board.id}`;
|
||||
});
|
||||
).then(() => window.location.href = `/boards/${this.props.post.boardId}`);
|
||||
}
|
||||
|
||||
render() {
|
||||
@@ -166,7 +151,6 @@ class PostP extends React.Component<Props, State> {
|
||||
postStatusChanges,
|
||||
boards,
|
||||
postStatuses,
|
||||
originPost,
|
||||
|
||||
isLoggedIn,
|
||||
isPowerUser,
|
||||
@@ -182,14 +166,6 @@ class PostP extends React.Component<Props, State> {
|
||||
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,
|
||||
@@ -211,15 +187,15 @@ class PostP extends React.Component<Props, State> {
|
||||
{
|
||||
isPowerUser &&
|
||||
<LikeList
|
||||
likes={likesToShow.items}
|
||||
areLoading={likesToShow.areLoading}
|
||||
error={likesToShow.error}
|
||||
likes={likes.items}
|
||||
areLoading={likes.areLoading}
|
||||
error={likes.error}
|
||||
/>
|
||||
}
|
||||
|
||||
<ActionBox
|
||||
followed={followed}
|
||||
submitFollow={() => submitFollow(postToShow.id, !followed, authenticityToken)}
|
||||
submitFollow={() => submitFollow(post.id, !followed, authenticityToken)}
|
||||
|
||||
isLoggedIn={isLoggedIn}
|
||||
/>
|
||||
@@ -249,24 +225,24 @@ class PostP extends React.Component<Props, State> {
|
||||
<>
|
||||
<div className="postHeader">
|
||||
<LikeButton
|
||||
postId={postToShow.id}
|
||||
likeCount={likesToShow.items.length}
|
||||
postId={post.id}
|
||||
likeCount={likes.items.length}
|
||||
showLikeCount={isPowerUser || tenantSetting.show_vote_count}
|
||||
liked={likesToShow.items.find(like => like.email === currentUserEmail) ? 1 : 0}
|
||||
liked={likes.items.find(like => like.email === currentUserEmail) ? 1 : 0}
|
||||
size="large"
|
||||
isLoggedIn={isLoggedIn}
|
||||
authenticityToken={authenticityToken}
|
||||
/>
|
||||
|
||||
<h3>{postToShow.title}</h3>
|
||||
<h3>{post.title}</h3>
|
||||
</div>
|
||||
|
||||
<div className="postInfo">
|
||||
<PostBoardLabel
|
||||
{...boards.find(board => board.id === postToShow.boardId)}
|
||||
{...boards.find(board => board.id === post.boardId)}
|
||||
/>
|
||||
<PostStatusLabel
|
||||
{...postStatuses.find(postStatus => postStatus.id === postToShow.postStatusId)}
|
||||
{...postStatuses.find(postStatus => postStatus.id === post.postStatusId)}
|
||||
/>
|
||||
{ isPowerUser &&
|
||||
<ActionLink onClick={toggleEditMode} icon={<EditIcon />} customClass='editAction'>
|
||||
@@ -276,10 +252,10 @@ class PostP extends React.Component<Props, State> {
|
||||
</div>
|
||||
|
||||
{
|
||||
(isPowerUser && postToShow.approvalStatus !== POST_APPROVAL_STATUS_APPROVED) &&
|
||||
(isPowerUser && post.approvalStatus !== POST_APPROVAL_STATUS_APPROVED) &&
|
||||
<div className="postInfo">
|
||||
<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 type={post.approvalStatus === POST_APPROVAL_STATUS_PENDING ? BADGE_TYPE_WARNING : BADGE_TYPE_DANGER}>
|
||||
{ I18n.t(`activerecord.attributes.post.approval_status_${post.approvalStatus.toLowerCase()}`) }
|
||||
</Badge>
|
||||
</div>
|
||||
}
|
||||
@@ -289,17 +265,17 @@ class PostP extends React.Component<Props, State> {
|
||||
disallowedTypes={['heading', 'image', 'html']}
|
||||
unwrapDisallowed
|
||||
>
|
||||
{postToShow.description}
|
||||
{post.description}
|
||||
</ReactMarkdown>
|
||||
|
||||
<PostFooter
|
||||
createdAt={postToShow.createdAt}
|
||||
createdAt={post.createdAt}
|
||||
handleDeletePost={this._handleDeletePost}
|
||||
toggleEditMode={toggleEditMode}
|
||||
|
||||
isPowerUser={isPowerUser}
|
||||
authorEmail={postToShow.userEmail}
|
||||
authorFullName={postToShow.userFullName}
|
||||
authorEmail={post.userEmail}
|
||||
authorFullName={post.userFullName}
|
||||
currentUserEmail={currentUserEmail}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -33,7 +33,7 @@ const PostUpdateList = ({
|
||||
|
||||
<div className="postUpdateList">
|
||||
{
|
||||
postUpdates.length === 0 && !areLoading && !error ?
|
||||
postUpdates.length === 0 ?
|
||||
<CenteredMutedText>{I18n.t('post.updates_box.empty')}</CenteredMutedText>
|
||||
:
|
||||
null
|
||||
|
||||
@@ -16,7 +16,6 @@ interface Props {
|
||||
postId: number;
|
||||
boards: Array<IBoard>;
|
||||
postStatuses: Array<IPostStatus>;
|
||||
originPost: any;
|
||||
isLoggedIn: boolean;
|
||||
isPowerUser: boolean;
|
||||
currentUserFullName: string;
|
||||
@@ -39,7 +38,6 @@ class PostRoot extends React.Component<Props> {
|
||||
postId,
|
||||
boards,
|
||||
postStatuses,
|
||||
originPost,
|
||||
isLoggedIn,
|
||||
isPowerUser,
|
||||
currentUserFullName,
|
||||
@@ -54,7 +52,6 @@ class PostRoot extends React.Component<Props> {
|
||||
postId={postId}
|
||||
boards={boards}
|
||||
postStatuses={postStatuses}
|
||||
originPost={originPost}
|
||||
|
||||
isLoggedIn={isLoggedIn}
|
||||
isPowerUser={isPowerUser}
|
||||
|
||||
@@ -78,7 +78,7 @@ const AppearanceSiteSettingsP = ({
|
||||
|
||||
<p style={{textAlign: 'left'}}>
|
||||
<ActionLink
|
||||
onClick={() => window.open('https://github.com/astuto/astuto-docs/blob/main/docs/appearance.md', '_blank')}
|
||||
onClick={() => window.open('https://docs.astuto.io/appearance-customization/', '_blank')}
|
||||
icon={<LearnMoreIcon />}
|
||||
>
|
||||
{I18n.t('site_settings.appearance.learn_more')}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import I18n from 'i18n-js';
|
||||
|
||||
import Box from '../../common/Box';
|
||||
import { AuthenticationPages } from './AuthenticationSiteSettingsP';
|
||||
|
||||
@@ -79,7 +79,7 @@ const AuthenticationIndexPage = ({
|
||||
<h2>{ I18n.t('site_settings.authentication.title') }</h2>
|
||||
|
||||
<div className="emailRegistrationPolicy">
|
||||
<h4>{ I18n.t('site_settings.authentication.email_registration_subtitle') }</h4>
|
||||
<h3>{ I18n.t('site_settings.authentication.email_registration_subtitle') }</h3>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} onChange={handleSubmit(onSubmit)}>
|
||||
<div className="formGroup">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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';
|
||||
@@ -103,7 +102,6 @@ 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">
|
||||
|
||||
@@ -27,7 +27,7 @@ const OAuthProvidersList = ({
|
||||
}: Props) => (
|
||||
<>
|
||||
<div className="oauthProvidersTitle">
|
||||
<h4>{ I18n.t('site_settings.authentication.oauth_subtitle') }</h4>
|
||||
<h3>{ I18n.t('site_settings.authentication.oauth_subtitle') }</h3>
|
||||
<Button onClick={() => setPage('new')}>
|
||||
{ I18n.t('common.buttons.new') }
|
||||
</Button>
|
||||
@@ -35,7 +35,7 @@ const OAuthProvidersList = ({
|
||||
|
||||
<p style={{textAlign: 'left'}}>
|
||||
<ActionLink
|
||||
onClick={() => window.open('https://github.com/astuto/astuto-docs/blob/main/docs/oauth/oauth-configuration-basics.md', '_blank')}
|
||||
onClick={() => window.open('https://docs.astuto.io/category/oauth-configuration/', '_blank')}
|
||||
icon={<LearnMoreIcon />}
|
||||
>
|
||||
{I18n.t('site_settings.authentication.learn_more')}
|
||||
|
||||
@@ -4,7 +4,6 @@ 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';
|
||||
@@ -96,17 +95,11 @@ const BoardForm = ({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<textarea
|
||||
{...register('description')}
|
||||
placeholder={I18n.t('site_settings.boards.form.description')}
|
||||
className="boardDescriptionTextArea formControl"
|
||||
/>
|
||||
|
||||
{mode === 'update' && (
|
||||
<>
|
||||
|
||||
@@ -278,7 +278,7 @@ const GeneralSiteSettingsP = ({
|
||||
}
|
||||
<div style={{marginTop: 8}}>
|
||||
<ActionLink
|
||||
onClick={() => window.open('https://github.com/astuto/astuto-docs/blob/main/docs/custom-domain.md', '_blank')}
|
||||
onClick={() => window.open('https://docs.astuto.io/custom-domain', '_blank')}
|
||||
icon={<LearnMoreIcon />}
|
||||
>
|
||||
{I18n.t('site_settings.general.custom_domain_learn_more')}
|
||||
|
||||
@@ -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, { fromRailsStringToJavascriptDate, nMonthsAgo } from '../../../helpers/datetime';
|
||||
import friendlyDate from '../../../helpers/datetime';
|
||||
import ActionLink from '../../common/ActionLink';
|
||||
import { TestIcon } from '../../common/Icons';
|
||||
|
||||
@@ -229,14 +229,9 @@ const Invitations = ({ siteName, invitations, currentUserEmail, authenticityToke
|
||||
{ I18n.t('site_settings.invitations.accepted_at', { when: friendlyDate(invitation.accepted_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>
|
||||
<span className="invitationSentAt" title={invitation.updated_at}>
|
||||
{ I18n.t('site_settings.invitations.sent_at', { when: friendlyDate(invitation.updated_at) }) }
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</li>
|
||||
@@ -248,4 +243,4 @@ const Invitations = ({ siteName, invitations, currentUserEmail, authenticityToke
|
||||
);
|
||||
};
|
||||
|
||||
export default Invitations;
|
||||
export default Invitations;
|
||||
@@ -50,7 +50,6 @@ const RoadmapEmbedding: React.FC<Props> = ({ embeddedRoadmapUrl }) => {
|
||||
onChange={event => setEmbedCode(event.target.value)}
|
||||
rows={5}
|
||||
id="roadmapEmbedCode"
|
||||
className="formControl"
|
||||
>
|
||||
</textarea>
|
||||
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
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;
|
||||
@@ -1,415 +0,0 @@
|
||||
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') }
|
||||
|
||||
{ <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') }
|
||||
|
||||
{ <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;
|
||||
@@ -1,48 +0,0 @@
|
||||
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;
|
||||
@@ -1,88 +0,0 @@
|
||||
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;
|
||||
@@ -1,84 +0,0 @@
|
||||
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>:
|
||||
<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;
|
||||
@@ -1,77 +0,0 @@
|
||||
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://github.com/astuto/astuto-docs/blob/main/docs/webhooks/webhooks-introduction.md', '_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;
|
||||
@@ -1,67 +0,0 @@
|
||||
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;
|
||||
@@ -1,124 +0,0 @@
|
||||
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;
|
||||
@@ -1,33 +0,0 @@
|
||||
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;
|
||||
@@ -7,7 +7,6 @@ import ConfirmEmailSignUpPage from './ConfirmEmailSignUpPage';
|
||||
import ConfirmOAuthSignUpPage from './ConfirmOAuthSignUpPage';
|
||||
import { IOAuth } from '../../interfaces/IOAuth';
|
||||
import HttpStatus from '../../constants/http_status';
|
||||
import Box from '../common/Box';
|
||||
|
||||
interface Props {
|
||||
oAuthLoginCompleted: boolean;
|
||||
@@ -117,70 +116,57 @@ const TenantSignUpP = ({
|
||||
});
|
||||
}
|
||||
|
||||
// return (
|
||||
// <>
|
||||
// <img src={astutoLogoImage} width={64} height={64} className="astutoLogo" />
|
||||
|
||||
// <div className="tenantSignUpContainer">
|
||||
// {
|
||||
// (currentStep === 1 || currentStep === 2) &&
|
||||
// <UserSignUpForm
|
||||
// currentStep={currentStep}
|
||||
// setCurrentStep={setCurrentStep}
|
||||
// authMethod={authMethod}
|
||||
// setAuthMethod={setAuthMethod}
|
||||
// oAuths={oAuths}
|
||||
// userData={userData}
|
||||
// setUserData={setUserData}
|
||||
// setGoneBack={setGoneBack}
|
||||
// />
|
||||
// }
|
||||
|
||||
// {
|
||||
// (goneBack || currentStep === 2) &&
|
||||
// <TenantSignUpForm
|
||||
// isSubmitting={isSubmitting}
|
||||
// error={error}
|
||||
// handleSignUpSubmit={handleSignUpSubmit}
|
||||
// trialPeriodDays={trialPeriodDays}
|
||||
// currentStep={currentStep}
|
||||
// setCurrentStep={setCurrentStep}
|
||||
// />
|
||||
// }
|
||||
|
||||
// {
|
||||
// currentStep === 3 && authMethod === 'oauth' &&
|
||||
// <ConfirmOAuthSignUpPage
|
||||
// baseUrl={baseUrl}
|
||||
// subdomain={tenantData.subdomain}
|
||||
// feedbackSpaceCreatedImage={feedbackSpaceCreatedImage}
|
||||
// />
|
||||
// }
|
||||
|
||||
// {
|
||||
// currentStep === 3 && authMethod === 'email' &&
|
||||
// <ConfirmEmailSignUpPage
|
||||
// subdomain={tenantData.subdomain}
|
||||
// userEmail={userData.email}
|
||||
// pendingTenantImage={pendingTenantImage}
|
||||
// />
|
||||
// }
|
||||
// </div>
|
||||
// </>
|
||||
// );
|
||||
|
||||
return (
|
||||
<>
|
||||
<img src={astutoLogoImage} width={64} height={64} className="astutoLogo" />
|
||||
|
||||
<div className="tenantSignUpContainer">
|
||||
<Box>
|
||||
<p>It is not possible to sign up to Astuto.</p>
|
||||
<p>You can <a href="https://github.com/astuto/astuto">self-host your own instance</a> instead.</p>
|
||||
</Box>
|
||||
</div>
|
||||
<img src={astutoLogoImage} width={64} height={64} className="astutoLogo" />
|
||||
|
||||
<div className="tenantSignUpContainer">
|
||||
{
|
||||
(currentStep === 1 || currentStep === 2) &&
|
||||
<UserSignUpForm
|
||||
currentStep={currentStep}
|
||||
setCurrentStep={setCurrentStep}
|
||||
authMethod={authMethod}
|
||||
setAuthMethod={setAuthMethod}
|
||||
oAuths={oAuths}
|
||||
userData={userData}
|
||||
setUserData={setUserData}
|
||||
setGoneBack={setGoneBack}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
(goneBack || currentStep === 2) &&
|
||||
<TenantSignUpForm
|
||||
isSubmitting={isSubmitting}
|
||||
error={error}
|
||||
handleSignUpSubmit={handleSignUpSubmit}
|
||||
trialPeriodDays={trialPeriodDays}
|
||||
currentStep={currentStep}
|
||||
setCurrentStep={setCurrentStep}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
currentStep === 3 && authMethod === 'oauth' &&
|
||||
<ConfirmOAuthSignUpPage
|
||||
baseUrl={baseUrl}
|
||||
subdomain={tenantData.subdomain}
|
||||
feedbackSpaceCreatedImage={feedbackSpaceCreatedImage}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
currentStep === 3 && authMethod === 'email' &&
|
||||
<ConfirmEmailSignUpPage
|
||||
subdomain={tenantData.subdomain}
|
||||
userEmail={userData.email}
|
||||
pendingTenantImage={pendingTenantImage}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default TenantSignUpP;
|
||||
@@ -1,89 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
}
|
||||
|
||||
{ error && <DangerText>{error}</DangerText> }
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default GenerateApiKeyDialog;
|
||||
@@ -3,13 +3,11 @@ 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_SUCCESS;
|
||||
typeof BADGE_TYPE_DANGER;
|
||||
|
||||
interface Props {
|
||||
type: BadgeTypes;
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
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, GrOverview } from 'react-icons/gr';
|
||||
import { GrTest, GrClearOption } from 'react-icons/gr';
|
||||
import { BiLike, BiSolidLike } from "react-icons/bi";
|
||||
import {
|
||||
MdContentCopy,
|
||||
@@ -16,10 +15,8 @@ import {
|
||||
MdVerified,
|
||||
MdCheck,
|
||||
MdClear,
|
||||
MdAdd,
|
||||
} from 'react-icons/md';
|
||||
import { FaUserNinja, FaMarkdown } from "react-icons/fa";
|
||||
import { FaDroplet } from "react-icons/fa6";
|
||||
import { FaUserNinja } from "react-icons/fa";
|
||||
|
||||
export const EditIcon = () => <FiEdit />;
|
||||
|
||||
@@ -44,12 +41,9 @@ export const ReplyIcon = () => <BsReply />;
|
||||
export const LearnMoreIcon = () => <MdOutlineLibraryBooks />;
|
||||
|
||||
export const StaffIcon = () => (
|
||||
<>
|
||||
<span data-tooltip-id="staff-tooltip" data-tooltip-content={I18n.t('common.user_staff')} className="staffIcon">
|
||||
<span title={I18n.t('common.user_staff')} className="staffIcon">
|
||||
<MdVerified />
|
||||
</span>
|
||||
<Tooltip id="staff-tooltip" />
|
||||
</>
|
||||
);
|
||||
|
||||
export const ClearIcon = () => <GrClearOption />;
|
||||
@@ -60,50 +54,8 @@ export const SolidLikeIcon = ({size = 32}) => <BiSolidLike size={size} />;
|
||||
|
||||
export const SettingsIcon = () => <FiSettings />;
|
||||
|
||||
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 AnonymousIcon = ({size = 32, title=I18n.t('defaults.user_full_name')}) => <FaUserNinja size={size} title={title} />;
|
||||
|
||||
export const ApproveIcon = () => <MdCheck />;
|
||||
|
||||
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" />
|
||||
</>
|
||||
);
|
||||
export const RejectIcon = () => <MdClear />;
|
||||
@@ -3,7 +3,7 @@ import I18n from 'i18n-js';
|
||||
|
||||
const PoweredByLink = () => (
|
||||
<div className="poweredBy">
|
||||
<a href="https://github.com/astuto/astuto/" target="_blank">
|
||||
<a href={`http://astuto.io/?utm_campaign=poweredby&utm_source=${window.location.hostname}`} target="_blank">
|
||||
{ I18n.t('common.powered_by') } Astuto
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
export const URL_REGEX = /^(ftp|http|https):\/\/[^ "]+$/;
|
||||
export const URL_REGEX_WHITESPACE_ALLOWED = /^(ftp|http|https):\/\/[^"]+$/;
|
||||
export const EMAIL_REGEX = /(.+)@(.+){2,}\.(.+){2,}/;
|
||||
export const URL_REGEX = /^(ftp|http|https):\/\/[^ "]+$/;
|
||||
@@ -34,8 +34,8 @@ const mapStateToProps = (state: State) => ({
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
requestPost(postId: number): Promise<any> {
|
||||
return dispatch(requestPost(postId));
|
||||
requestPost(postId: number) {
|
||||
dispatch(requestPost(postId));
|
||||
},
|
||||
|
||||
updatePost(
|
||||
@@ -69,8 +69,8 @@ const mapDispatchToProps = (dispatch) => ({
|
||||
dispatch(changePostEditFormPostStatus(postStatusId));
|
||||
},
|
||||
|
||||
requestLikes(postId: number): Promise<any> {
|
||||
return dispatch(requestLikes(postId));
|
||||
requestLikes(postId: number) {
|
||||
dispatch(requestLikes(postId));
|
||||
},
|
||||
|
||||
requestFollow(postId: number) {
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
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);
|
||||
@@ -47,19 +47,4 @@ 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()
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,7 @@
|
||||
import ILikeJSON from "./json/ILike";
|
||||
|
||||
interface ILike {
|
||||
id: number;
|
||||
fullName: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export default ILike;
|
||||
|
||||
export const likeJSON2JS = (likeJSON: ILikeJSON): ILike => ({
|
||||
id: likeJSON.id,
|
||||
fullName: likeJSON.full_name,
|
||||
email: likeJSON.email,
|
||||
});
|
||||
export default ILike;
|
||||
@@ -1,5 +1,3 @@
|
||||
import IPostJSON from "./json/IPost";
|
||||
|
||||
// Approval status
|
||||
export const POST_APPROVAL_STATUS_APPROVED = 'approved';
|
||||
export const POST_APPROVAL_STATUS_PENDING = 'pending';
|
||||
@@ -28,22 +26,4 @@ interface IPost {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
export default IPost;
|
||||
@@ -1,77 +0,0 @@
|
||||
// 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,
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
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;
|
||||
@@ -8,7 +8,6 @@ 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';
|
||||
|
||||
@@ -21,7 +20,6 @@ const rootReducer = combineReducers({
|
||||
users: usersReducer,
|
||||
currentPost: currentPostReducer,
|
||||
oAuths: oAuthsReducer,
|
||||
webhooks: webhooksReducer,
|
||||
|
||||
siteSettings: siteSettingsReducer,
|
||||
moderation: moderationReducer,
|
||||
|
||||
@@ -82,34 +82,12 @@ 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;
|
||||
@@ -117,7 +95,6 @@ interface SiteSettingsState {
|
||||
boards: SiteSettingsBoardsState;
|
||||
postStatuses: SiteSettingsPostStatusesState;
|
||||
roadmap: SiteSettingsRoadmapState;
|
||||
webhooks: SiteSettingsWebhooksState;
|
||||
appearance: SiteSettingsAppearanceState;
|
||||
}
|
||||
|
||||
@@ -127,7 +104,6 @@ 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),
|
||||
};
|
||||
|
||||
@@ -145,10 +121,7 @@ const siteSettingsReducer = (
|
||||
PostStatusOrderUpdateActionTypes |
|
||||
PostStatusDeleteActionTypes |
|
||||
PostStatusSubmitActionTypes |
|
||||
PostStatusUpdateActionTypes |
|
||||
WebhookSubmitActionTypes |
|
||||
WebhookUpdateActionTypes |
|
||||
WebhookDeleteActionTypes
|
||||
PostStatusUpdateActionTypes
|
||||
): SiteSettingsState => {
|
||||
switch (action.type) {
|
||||
case TENANT_UPDATE_START:
|
||||
@@ -214,20 +187,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
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;
|
||||
@@ -1,105 +0,0 @@
|
||||
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
|
||||
@@ -1,92 +0,0 @@
|
||||
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
|
||||
@@ -1,7 +0,0 @@
|
||||
module CustomLiquidFilters
|
||||
require 'json'
|
||||
|
||||
def escape_json(input)
|
||||
input.to_json[1...-1] # Converts to JSON string and removes surrounding quotes
|
||||
end
|
||||
end
|
||||
@@ -43,21 +43,6 @@ 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
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
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
|
||||
@@ -6,27 +6,5 @@ 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
|
||||
|
||||
@@ -1,9 +1,3 @@
|
||||
class Invitation < ApplicationRecord
|
||||
include TenantOwnable
|
||||
|
||||
belongs_to :tenant
|
||||
|
||||
def expired?
|
||||
updated_at <= 3.months.ago
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,26 +4,5 @@ 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
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
class Post < ApplicationRecord
|
||||
include TenantOwnable
|
||||
include ApplicationHelper
|
||||
include Rails.application.routes.url_helpers
|
||||
extend FriendlyId
|
||||
|
||||
belongs_to :board
|
||||
@@ -14,9 +12,6 @@ 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,
|
||||
@@ -29,10 +24,6 @@ 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))
|
||||
@@ -63,50 +54,4 @@ 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
|
||||
|
||||
@@ -4,25 +4,4 @@ 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
|
||||
|
||||
@@ -10,16 +10,12 @@ 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,
|
||||
@@ -108,20 +104,4 @@ 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
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
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
|
||||
@@ -1,10 +0,0 @@
|
||||
module Api
|
||||
class BasePolicy
|
||||
attr_reader :api_key, :record
|
||||
|
||||
def initialize(api_key, record)
|
||||
@api_key = api_key
|
||||
@record = record
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,15 +0,0 @@
|
||||
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
|
||||
@@ -1,31 +0,0 @@
|
||||
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
|
||||
@@ -1,19 +0,0 @@
|
||||
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
|
||||
@@ -1,39 +0,0 @@
|
||||
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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user