Compare commits
48 Commits
be0ddcc89e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b6e059da6 | ||
|
|
e9f8f03db2 | ||
|
|
b289cdc320 | ||
|
|
8bb410dcfa | ||
|
|
ce67776eec | ||
|
|
9f71fe65e5 | ||
|
|
e15835bda9 | ||
|
|
83320d4c9a | ||
|
|
a2008e2ae3 | ||
|
|
b45a451748 | ||
|
|
441caabb98 | ||
|
|
a139bde102 | ||
|
|
f35a09f07a | ||
|
|
1a10e3c784 | ||
|
|
4fe95ca538 | ||
|
|
fa36305244 | ||
|
|
9acdc4e6db | ||
|
|
e48b386b54 | ||
|
|
d183fb4b53 | ||
|
|
9c6714e97c | ||
|
|
227ab744b5 | ||
|
|
4bc393887b | ||
|
|
8ec8f15857 | ||
|
|
803c1371b7 | ||
|
|
46e4f808e7 | ||
|
|
8ce7f1b913 | ||
|
|
c407ee3530 | ||
|
|
32a4ffa70e | ||
|
|
20ce18ca74 | ||
|
|
4e5c25adbf | ||
|
|
7118f1ea45 | ||
|
|
f31a25fb03 | ||
|
|
7c7bdf7e65 | ||
|
|
21e7e65dfb | ||
|
|
3e36821e51 | ||
|
|
530021960e | ||
|
|
b64ad52d30 | ||
|
|
4a6388ade6 | ||
|
|
e7f2215be4 | ||
|
|
887d52c447 | ||
|
|
e9295dc278 | ||
|
|
001d63c513 | ||
|
|
d6ba730d4a | ||
|
|
8a4c146117 | ||
|
|
b54db723c5 | ||
|
|
de52fe9b93 | ||
|
|
a4d5a676d6 | ||
|
|
b3c37cca13 |
@@ -83,10 +83,13 @@ jobs:
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- name: Install SQLite3 CLI
|
||||
run: sudo apt-get update && sudo apt-get install -y sqlite3
|
||||
|
||||
- name: Set up database
|
||||
env:
|
||||
RAILS_ENV: test
|
||||
run: bin/rails db:create db:structure:load
|
||||
run: bin/rails db:create db:migrate
|
||||
|
||||
- name: Run tests
|
||||
env:
|
||||
@@ -95,40 +98,43 @@ jobs:
|
||||
# REDIS_URL: redis://localhost:6379/0
|
||||
run: bin/rails test
|
||||
|
||||
system-test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# services:
|
||||
# redis:
|
||||
# image: valkey/valkey:8
|
||||
# ports:
|
||||
# - 6379:6379
|
||||
# options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- name: Set up database
|
||||
env:
|
||||
RAILS_ENV: test
|
||||
run: bin/rails db:create db:structure:load
|
||||
|
||||
- name: Run System Tests
|
||||
env:
|
||||
RAILS_ENV: test
|
||||
# RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
|
||||
# REDIS_URL: redis://localhost:6379/0
|
||||
run: bin/rails test:system
|
||||
|
||||
- name: Keep screenshots from failed system tests
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: screenshots
|
||||
path: ${{ github.workspace }}/tmp/screenshots
|
||||
if-no-files-found: ignore
|
||||
# system-test:
|
||||
# runs-on: ubuntu-latest
|
||||
#
|
||||
# # services:
|
||||
# # redis:
|
||||
# # image: valkey/valkey:8
|
||||
# # ports:
|
||||
# # - 6379:6379
|
||||
# # options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
# steps:
|
||||
# - name: Checkout code
|
||||
# uses: actions/checkout@v6
|
||||
#
|
||||
# - name: Set up Ruby
|
||||
# uses: ruby/setup-ruby@v1
|
||||
# with:
|
||||
# bundler-cache: true
|
||||
#
|
||||
# - name: Install SQLite3 CLI
|
||||
# run: sudo apt-get update && sudo apt-get install -y sqlite3
|
||||
#
|
||||
# - name: Set up database
|
||||
# env:
|
||||
# RAILS_ENV: test
|
||||
# run: bin/rails db:create db:migrate
|
||||
#
|
||||
# - name: Run System Tests
|
||||
# env:
|
||||
# RAILS_ENV: test
|
||||
# # RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
|
||||
# # REDIS_URL: redis://localhost:6379/0
|
||||
# run: bin/rails test:system
|
||||
#
|
||||
# - name: Keep screenshots from failed system tests
|
||||
# uses: actions/upload-artifact@v4
|
||||
# if: failure()
|
||||
# with:
|
||||
# name: screenshots
|
||||
# path: ${{ github.workspace }}/tmp/screenshots
|
||||
# if-no-files-found: ignore
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
markup: markdown
|
||||
@@ -18,6 +18,8 @@ gem "turbo-rails"
|
||||
gem "stimulus-rails"
|
||||
# Build JSON APIs with ease [https://github.com/rails/jbuilder]
|
||||
gem "jbuilder"
|
||||
gem "grape"
|
||||
gem "rswag-ui"
|
||||
gem "caxlsx"
|
||||
gem "caxlsx_rails"
|
||||
|
||||
@@ -44,6 +46,9 @@ gem "thruster", require: false
|
||||
# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images]
|
||||
gem "image_processing", "~> 1.2"
|
||||
|
||||
# Pagination [https://github.com/ddnexus/pagy]
|
||||
gem "pagy", "~> 8.0"
|
||||
|
||||
group :development, :test do
|
||||
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
|
||||
gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"
|
||||
@@ -56,16 +61,20 @@ group :development, :test do
|
||||
|
||||
# Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/]
|
||||
gem "rubocop-rails-omakase", require: false
|
||||
|
||||
# Parallel test runner [https://github.com/grosser/parallel_tests]
|
||||
gem "parallel_tests", require: false
|
||||
end
|
||||
|
||||
group :development do
|
||||
# Use console on exceptions pages [https://github.com/rails/web-console]
|
||||
gem "web-console"
|
||||
gem "mailcatcher"
|
||||
end
|
||||
|
||||
group :test do
|
||||
# Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
|
||||
gem "benchmark", require: false
|
||||
gem "simplecov", require: false
|
||||
gem "capybara"
|
||||
gem "selenium-webdriver"
|
||||
end
|
||||
|
||||
@@ -81,11 +81,12 @@ GEM
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.21)
|
||||
bcrypt_pbkdf (1.1.2)
|
||||
benchmark (0.5.0)
|
||||
bigdecimal (4.0.1)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.21.1)
|
||||
bootsnap (1.23.0)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (7.1.2)
|
||||
brakeman (8.0.2)
|
||||
racc
|
||||
builder (3.3.0)
|
||||
bundler-audit (0.9.3)
|
||||
@@ -112,29 +113,51 @@ GEM
|
||||
connection_pool (3.0.2)
|
||||
crass (1.0.6)
|
||||
csv (3.3.5)
|
||||
daemons (1.4.1)
|
||||
date (3.5.1)
|
||||
debug (1.11.1)
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
docile (1.4.1)
|
||||
dotenv (3.2.0)
|
||||
drb (2.2.3)
|
||||
dry-configurable (1.3.0)
|
||||
dry-core (~> 1.1)
|
||||
zeitwerk (~> 2.6)
|
||||
dry-core (1.2.0)
|
||||
concurrent-ruby (~> 1.0)
|
||||
logger
|
||||
zeitwerk (~> 2.6)
|
||||
dry-inflector (1.3.1)
|
||||
dry-logic (1.6.0)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0)
|
||||
dry-core (~> 1.1)
|
||||
zeitwerk (~> 2.6)
|
||||
dry-types (1.9.1)
|
||||
bigdecimal (>= 3.0)
|
||||
concurrent-ruby (~> 1.0)
|
||||
dry-core (~> 1.0)
|
||||
dry-inflector (~> 1.0)
|
||||
dry-logic (~> 1.4)
|
||||
zeitwerk (~> 2.6)
|
||||
ed25519 (1.4.0)
|
||||
erb (6.0.1)
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.4.0)
|
||||
tzinfo
|
||||
eventmachine (1.2.7)
|
||||
ffi (1.17.3-x86_64-linux-gnu)
|
||||
fugit (1.12.1)
|
||||
et-orbi (~> 1.4)
|
||||
raabro (~> 1.4)
|
||||
globalid (1.3.0)
|
||||
activesupport (>= 6.1)
|
||||
haml (7.2.0)
|
||||
temple (>= 0.8.2)
|
||||
thor
|
||||
tilt
|
||||
grape (3.1.1)
|
||||
activesupport (>= 7.1)
|
||||
dry-configurable
|
||||
dry-types (>= 1.1)
|
||||
mustermann-grape (~> 1.1.0)
|
||||
rack (>= 2)
|
||||
zeitwerk
|
||||
htmlentities (4.4.2)
|
||||
i18n (1.14.8)
|
||||
concurrent-ruby (~> 1.0)
|
||||
@@ -146,14 +169,15 @@ GEM
|
||||
activesupport (>= 6.0.0)
|
||||
railties (>= 6.0.0)
|
||||
io-console (0.8.2)
|
||||
irb (1.16.0)
|
||||
irb (1.17.0)
|
||||
pp (>= 0.6.0)
|
||||
prism (>= 1.3.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jbuilder (2.14.1)
|
||||
actionview (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
json (2.18.0)
|
||||
json (2.18.1)
|
||||
kamal (2.10.1)
|
||||
activesupport (>= 7.0)
|
||||
base64 (~> 0.2)
|
||||
@@ -177,16 +201,6 @@ GEM
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
mailcatcher (0.2.4)
|
||||
eventmachine
|
||||
haml
|
||||
i18n
|
||||
json
|
||||
mail
|
||||
sinatra
|
||||
skinny (>= 0.1.2)
|
||||
sqlite3-ruby
|
||||
thin
|
||||
marcel (1.1.0)
|
||||
matrix (0.4.3)
|
||||
mini_magick (5.3.1)
|
||||
@@ -197,7 +211,9 @@ GEM
|
||||
msgpack (1.8.0)
|
||||
mustermann (3.0.4)
|
||||
ruby2_keywords (~> 0.0.1)
|
||||
net-imap (0.6.2)
|
||||
mustermann-grape (1.1.0)
|
||||
mustermann (>= 1.0.0)
|
||||
net-imap (0.6.3)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
@@ -212,17 +228,20 @@ GEM
|
||||
net-protocol
|
||||
net-ssh (7.3.0)
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.19.0-x86_64-linux-gnu)
|
||||
nokogiri (1.19.1-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
ostruct (0.6.3)
|
||||
pagy (8.6.3)
|
||||
parallel (1.27.0)
|
||||
parser (3.3.10.1)
|
||||
parallel_tests (5.6.0)
|
||||
parallel
|
||||
parser (3.3.10.2)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pp (0.6.3)
|
||||
prettyprint
|
||||
prettyprint (0.2.0)
|
||||
prism (1.8.0)
|
||||
prism (1.9.0)
|
||||
propshaft (1.3.1)
|
||||
actionpack (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
@@ -231,15 +250,11 @@ GEM
|
||||
date
|
||||
stringio
|
||||
public_suffix (7.0.2)
|
||||
puma (7.1.0)
|
||||
puma (7.2.0)
|
||||
nio4r (~> 2.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
rack (3.2.4)
|
||||
rack-protection (4.2.1)
|
||||
base64 (>= 0.1.0)
|
||||
logger (>= 1.6.0)
|
||||
rack (>= 3.0.0, < 4)
|
||||
rack (3.2.5)
|
||||
rack-session (2.1.1)
|
||||
base64 (>= 0.1.0)
|
||||
rack (>= 3.0.0)
|
||||
@@ -279,7 +294,7 @@ GEM
|
||||
zeitwerk (~> 2.6)
|
||||
rainbow (3.1.1)
|
||||
rake (13.3.1)
|
||||
rdoc (7.1.0)
|
||||
rdoc (7.2.0)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
tsort
|
||||
@@ -293,7 +308,10 @@ GEM
|
||||
logger (~> 1)
|
||||
nokogiri (~> 1)
|
||||
rubyzip (>= 3.0.0, < 4.0.0)
|
||||
rubocop (1.82.1)
|
||||
rswag-ui (2.17.0)
|
||||
actionpack (>= 5.2, < 8.2)
|
||||
railties (>= 5.2, < 8.2)
|
||||
rubocop (1.84.2)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
@@ -301,7 +319,7 @@ GEM
|
||||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
rubocop-ast (>= 1.48.0, < 2.0)
|
||||
rubocop-ast (>= 1.49.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
rubocop-ast (1.49.0)
|
||||
@@ -328,22 +346,18 @@ GEM
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (3.2.2)
|
||||
securerandom (0.4.1)
|
||||
selenium-webdriver (4.40.0)
|
||||
selenium-webdriver (4.41.0)
|
||||
base64 (~> 0.2)
|
||||
logger (~> 1.4)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
rubyzip (>= 1.2.2, < 4.0)
|
||||
websocket (~> 1.0)
|
||||
sinatra (4.2.1)
|
||||
logger (>= 1.6.0)
|
||||
mustermann (~> 3.0)
|
||||
rack (>= 3.0.0, < 4)
|
||||
rack-protection (= 4.2.1)
|
||||
rack-session (>= 2.0.0, < 3)
|
||||
tilt (~> 2.0)
|
||||
skinny (0.2.2)
|
||||
eventmachine (~> 1.0)
|
||||
thin
|
||||
simplecov (0.22.0)
|
||||
docile (~> 1.1)
|
||||
simplecov-html (~> 0.11)
|
||||
simplecov_json_formatter (~> 0.1)
|
||||
simplecov-html (0.13.2)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
solid_cable (3.0.12)
|
||||
actioncable (>= 7.2)
|
||||
activejob (>= 7.2)
|
||||
@@ -353,7 +367,7 @@ GEM
|
||||
activejob (>= 7.2)
|
||||
activerecord (>= 7.2)
|
||||
railties (>= 7.2)
|
||||
solid_queue (1.3.1)
|
||||
solid_queue (1.3.2)
|
||||
activejob (>= 7.1)
|
||||
activerecord (>= 7.1)
|
||||
concurrent-ruby (>= 1.3.1)
|
||||
@@ -361,8 +375,6 @@ GEM
|
||||
railties (>= 7.1)
|
||||
thor (>= 1.3.1)
|
||||
sqlite3 (2.9.0-x86_64-linux-gnu)
|
||||
sqlite3-ruby (1.3.3)
|
||||
sqlite3 (>= 1.3.3)
|
||||
sshkit (1.25.0)
|
||||
base64
|
||||
logger
|
||||
@@ -373,18 +385,11 @@ GEM
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.2.0)
|
||||
temple (0.10.4)
|
||||
thin (2.0.1)
|
||||
daemons (~> 1.0, >= 1.0.9)
|
||||
eventmachine (~> 1.0, >= 1.0.4)
|
||||
logger
|
||||
rack (>= 1, < 4)
|
||||
thor (1.5.0)
|
||||
thruster (0.1.17-x86_64-linux)
|
||||
tilt (2.7.0)
|
||||
thruster (0.1.18-x86_64-linux)
|
||||
timeout (0.6.0)
|
||||
tsort (0.2.0)
|
||||
turbo-rails (2.0.21)
|
||||
turbo-rails (2.0.23)
|
||||
actionpack (>= 7.1.0)
|
||||
railties (>= 7.1.0)
|
||||
tzinfo (2.0.6)
|
||||
@@ -406,13 +411,14 @@ GEM
|
||||
websocket-extensions (0.1.5)
|
||||
xpath (3.2.0)
|
||||
nokogiri (~> 1.8)
|
||||
zeitwerk (2.7.4)
|
||||
zeitwerk (2.7.5)
|
||||
|
||||
PLATFORMS
|
||||
x86_64-linux-gnu
|
||||
|
||||
DEPENDENCIES
|
||||
bcrypt (~> 3.1.7)
|
||||
benchmark
|
||||
bootsnap
|
||||
brakeman
|
||||
bundler-audit
|
||||
@@ -420,17 +426,21 @@ DEPENDENCIES
|
||||
caxlsx
|
||||
caxlsx_rails
|
||||
debug
|
||||
grape
|
||||
image_processing (~> 1.2)
|
||||
importmap-rails
|
||||
jbuilder
|
||||
kamal
|
||||
mailcatcher
|
||||
pagy (~> 8.0)
|
||||
parallel_tests
|
||||
propshaft
|
||||
puma (>= 5.0)
|
||||
rails (~> 8.1.2)
|
||||
roo (~> 3.0)
|
||||
rswag-ui
|
||||
rubocop-rails-omakase
|
||||
selenium-webdriver
|
||||
simplecov
|
||||
solid_cable
|
||||
solid_cache
|
||||
solid_queue
|
||||
@@ -459,10 +469,11 @@ CHECKSUMS
|
||||
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
|
||||
bcrypt (3.1.21) sha256=5964613d750a42c7ee5dc61f7b9336fb6caca429ba4ac9f2011609946e4a2dcf
|
||||
bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6
|
||||
benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c
|
||||
bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7
|
||||
bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e
|
||||
bootsnap (1.21.1) sha256=9373acfe732da35846623c337d3481af8ce77c7b3a927fb50e9aa92b46dbc4c4
|
||||
brakeman (7.1.2) sha256=6b04927710a2e7d13a72248b5d404c633188e02417f28f3d853e4b6370d26dce
|
||||
bootsnap (1.23.0) sha256=c1254f458d58558b58be0f8eb8f6eec2821456785b7cdd1e16248e2020d3f214
|
||||
brakeman (8.0.2) sha256=7b02065ce8b1de93949cefd3f2ad78e8eb370e644b95c8556a32a912a782426a
|
||||
builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f
|
||||
bundler-audit (0.9.3) sha256=81c8766c71e47d0d28a0f98c7eed028539f21a6ea3cd8f685eb6f42333c9b4e9
|
||||
capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef
|
||||
@@ -472,35 +483,38 @@ CHECKSUMS
|
||||
connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
|
||||
crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d
|
||||
csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f
|
||||
daemons (1.4.1) sha256=8fc76d76faec669feb5e455d72f35bd4c46dc6735e28c420afb822fac1fa9a1d
|
||||
date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0
|
||||
debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6
|
||||
docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e
|
||||
dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d
|
||||
drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
|
||||
dry-configurable (1.3.0) sha256=882d862858567fc1210d2549d4c090f34370fc1bb7c5c1933de3fe792e18afa8
|
||||
dry-core (1.2.0) sha256=0cc5a7da88df397f153947eeeae42e876e999c1e30900f3c536fb173854e96a1
|
||||
dry-inflector (1.3.1) sha256=7fb0c2bb04f67638f25c52e7ba39ab435d922a3a5c3cd196120f63accb682dcc
|
||||
dry-logic (1.6.0) sha256=da6fedbc0f90fc41f9b0cc7e6f05f5d529d1efaef6c8dcc8e0733f685745cea2
|
||||
dry-types (1.9.1) sha256=baebeecdb9f8395d6c9d227b62011279440943e3ef2468fe8ccc1ba11467f178
|
||||
ed25519 (1.4.0) sha256=16e97f5198689a154247169f3453ef4cfd3f7a47481fde0ae33206cdfdcac506
|
||||
erb (6.0.1) sha256=28ecdd99c5472aebd5674d6061e3c6b0a45c049578b071e5a52c2a7f13c197e5
|
||||
erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9
|
||||
et-orbi (1.4.0) sha256=6c7e3c90779821f9e3b324c5e96fda9767f72995d6ae435b96678a4f3e2de8bc
|
||||
eventmachine (1.2.7) sha256=994016e42aa041477ba9cff45cbe50de2047f25dd418eba003e84f0d16560972
|
||||
ffi (1.17.3-x86_64-linux-gnu) sha256=3746b01f677aae7b16dc1acb7cb3cc17b3e35bdae7676a3f568153fb0e2c887f
|
||||
fugit (1.12.1) sha256=5898f478ede9b415f0804e42b8f3fd53f814bd85eebffceebdbc34e1107aaf68
|
||||
globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11
|
||||
haml (7.2.0) sha256=87fd2b71f7feab1724337b090a7d767f5ab2d42f08c974f3ead673f18cfcd55a
|
||||
grape (3.1.1) sha256=774f16782d917a90e69de0499dfaab571e5ad967569ac066a2b0b918af12de69
|
||||
htmlentities (4.4.2) sha256=bbafbdf69f2eca9262be4efef7e43e6a1de54c95eb600f26984f71d2fe96c5c3
|
||||
i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5
|
||||
image_processing (1.14.0) sha256=754cc169c9c262980889bec6bfd325ed1dafad34f85242b5a07b60af004742fb
|
||||
importmap-rails (2.2.3) sha256=7101be2a4dc97cf1558fb8f573a718404c5f6bcfe94f304bf1f39e444feeb16a
|
||||
io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
|
||||
irb (1.16.0) sha256=2abe56c9ac947cdcb2f150572904ba798c1e93c890c256f8429981a7675b0806
|
||||
irb (1.17.0) sha256=168c4ddb93d8a361a045c41d92b2952c7a118fa73f23fe14e55609eb7a863aae
|
||||
jbuilder (2.14.1) sha256=4eb26376ff60ef100cb4fd6fd7533cd271f9998327e86adf20fd8c0e69fabb42
|
||||
json (2.18.0) sha256=b10506aee4183f5cf49e0efc48073d7b75843ce3782c68dbeb763351c08fd505
|
||||
json (2.18.1) sha256=fe112755501b8d0466b5ada6cf50c8c3f41e897fa128ac5d263ec09eedc9f986
|
||||
kamal (2.10.1) sha256=53b7ecb4c33dd83b1aedfc7aacd1c059f835993258a552d70d584c6ce32b6340
|
||||
language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc
|
||||
lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
|
||||
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
|
||||
loofah (2.25.0) sha256=df5ed7ac3bac6a4ec802df3877ee5cc86d027299f8952e6243b3dac446b060e6
|
||||
mail (2.9.0) sha256=6fa6673ecd71c60c2d996260f9ee3dd387d4673b8169b502134659ece6d34941
|
||||
mailcatcher (0.2.4) sha256=ba1d6f23d32f69929dce332d0aa7aeabddadd15507de474754e593807111dda9
|
||||
marcel (1.1.0) sha256=fdcfcfa33cc52e93c4308d40e4090a5d4ea279e160a7f6af988260fa970e0bee
|
||||
matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b
|
||||
mini_magick (5.3.1) sha256=29395dfd76badcabb6403ee5aff6f681e867074f8f28ce08d78661e9e4a351c4
|
||||
@@ -508,7 +522,8 @@ CHECKSUMS
|
||||
minitest (6.0.1) sha256=7854c74f48e2e975969062833adc4013f249a4b212f5e7b9d5c040bf838d54bb
|
||||
msgpack (1.8.0) sha256=e64ce0212000d016809f5048b48eb3a65ffb169db22238fb4b72472fecb2d732
|
||||
mustermann (3.0.4) sha256=85fadcb6b3c6493a8b511b42426f904b7f27b282835502233dd154daab13aa22
|
||||
net-imap (0.6.2) sha256=08caacad486853c61676cca0c0c47df93db02abc4a8239a8b67eb0981428acc6
|
||||
mustermann-grape (1.1.0) sha256=8d258a986004c8f01ce4c023c0b037c168a9ed889cf5778068ad54398fa458c5
|
||||
net-imap (0.6.3) sha256=9bab75f876596d09ee7bf911a291da478e0cd6badc54dfb82874855ccc82f2ad
|
||||
net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3
|
||||
net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8
|
||||
net-scp (4.1.0) sha256=a99b0b92a1e5d360b0de4ffbf2dc0c91531502d3d4f56c28b0139a7c093d1a5d
|
||||
@@ -516,21 +531,22 @@ CHECKSUMS
|
||||
net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736
|
||||
net-ssh (7.3.0) sha256=172076c4b30ce56fb25a03961b0c4da14e1246426401b0f89cba1a3b54bf3ef0
|
||||
nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1
|
||||
nokogiri (1.19.0-x86_64-linux-gnu) sha256=f482b95c713d60031d48c44ce14562f8d2ce31e3a9e8dd0ccb131e9e5a68b58c
|
||||
nokogiri (1.19.1-x86_64-linux-gnu) sha256=1a4902842a186b4f901078e692d12257678e6133858d0566152fe29cdb98456a
|
||||
ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912
|
||||
pagy (8.6.3) sha256=537b2ee3119f237dd6c4a0d0a35c67a77b9d91ebb9d4f85e31407c2686774fb2
|
||||
parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130
|
||||
parser (3.3.10.1) sha256=06f6a725d2cd91e5e7f2b7c32ba143631e1f7c8ae2fb918fc4cebec187e6a688
|
||||
parallel_tests (5.6.0) sha256=b2d7af382d1c0289daba65308d9143b3ad82d817d500c0ad1b4bde468beb13af
|
||||
parser (3.3.10.2) sha256=6f60c84aa4bdcedb6d1a2434b738fe8a8136807b6adc8f7f53b97da9bc4e9357
|
||||
pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6
|
||||
prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193
|
||||
prism (1.8.0) sha256=84453a16ef5530ea62c5f03ec16b52a459575ad4e7b9c2b360fd8ce2c39c1254
|
||||
prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
|
||||
propshaft (1.3.1) sha256=9acc664ef67e819ffa3d95bd7ad4c3623ea799110c5f4dee67fa7e583e74c392
|
||||
psych (5.3.1) sha256=eb7a57cef10c9d70173ff74e739d843ac3b2c019a003de48447b2963d81b1974
|
||||
public_suffix (7.0.2) sha256=9114090c8e4e7135c1fd0e7acfea33afaab38101884320c65aaa0ffb8e26a857
|
||||
puma (7.1.0) sha256=e45c10cb124f224d448c98db653a75499794edbecadc440ad616cf50f2fd49dd
|
||||
puma (7.2.0) sha256=bf8ef4ab514a4e6d4554cb4326b2004eba5036ae05cf765cfe51aba9706a72a8
|
||||
raabro (1.4.0) sha256=d4fa9ff5172391edb92b242eed8be802d1934b1464061ae5e70d80962c5da882
|
||||
racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
|
||||
rack (3.2.4) sha256=5d74b6f75082a643f43c1e76b419c40f0e5527fcfee1e669ac1e6b73c0ccb6f6
|
||||
rack-protection (4.2.1) sha256=cf6e2842df8c55f5e4d1a4be015e603e19e9bc3a7178bae58949ccbb58558bac
|
||||
rack (3.2.5) sha256=4cbd0974c0b79f7a139b4812004a62e4c60b145cba76422e288ee670601ed6d3
|
||||
rack-session (2.1.1) sha256=0b6dc07dea7e4b583f58a48e8b806d4c9f1c6c9214ebc202ec94562cbea2e4e9
|
||||
rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463
|
||||
rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868
|
||||
@@ -540,12 +556,13 @@ CHECKSUMS
|
||||
railties (8.1.2) sha256=1289ece76b4f7668fc46d07e55cc992b5b8751f2ad85548b7da351b8c59f8055
|
||||
rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a
|
||||
rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c
|
||||
rdoc (7.1.0) sha256=494899df0706c178596ca6e1d50f1b7eb285a9b2aae715be5abd742734f17363
|
||||
rdoc (7.2.0) sha256=8650f76cd4009c3b54955eb5d7e3a075c60a57276766ebf36f9085e8c9f23192
|
||||
regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4
|
||||
reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835
|
||||
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
|
||||
roo (3.0.0) sha256=6fdd7a9158d657c69768b4168754ff2110cc21fdc01a1bec1010820cb05c91b1
|
||||
rubocop (1.82.1) sha256=09f1a6a654a960eda767aebea33e47603080f8e9c9a3f019bf9b94c9cab5e273
|
||||
rswag-ui (2.17.0) sha256=5f707b9b5e8171ddf9f519f6e401e79e419bd1d07387508603e76124f2443212
|
||||
rubocop (1.84.2) sha256=5692cea54168f3dc8cb79a6fe95c5424b7ea893c707ad7a4307b0585e88dbf5f
|
||||
rubocop-ast (1.49.0) sha256=49c3676d3123a0923d333e20c6c2dbaaae2d2287b475273fddee0c61da9f71fd
|
||||
rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834
|
||||
rubocop-rails (2.34.3) sha256=10d37989024865ecda8199f311f3faca990143fbac967de943f88aca11eb9ad2
|
||||
@@ -555,25 +572,22 @@ CHECKSUMS
|
||||
ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef
|
||||
rubyzip (3.2.2) sha256=c0ed99385f0625415c8f05bcae33fe649ed2952894a95ff8b08f26ca57ea5b3c
|
||||
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
|
||||
selenium-webdriver (4.40.0) sha256=16ef7aa9853c1d4b9d52eac45aafa916e3934c5c83cb4facb03f250adfd15e5b
|
||||
sinatra (4.2.1) sha256=b7aeb9b11d046b552972ade834f1f9be98b185fa8444480688e3627625377080
|
||||
skinny (0.2.2) sha256=f40caceccfe3e1d9826f60195a090f43ea7c1130c36b3170887db69a9fb52102
|
||||
selenium-webdriver (4.41.0) sha256=cdc1173cd55cf186022cea83156cc2d0bec06d337e039b02ad25d94e41bedd22
|
||||
simplecov (0.22.0) sha256=fe2622c7834ff23b98066bb0a854284b2729a569ac659f82621fc22ef36213a5
|
||||
simplecov-html (0.13.2) sha256=bd0b8e54e7c2d7685927e8d6286466359b6f16b18cb0df47b508e8d73c777246
|
||||
simplecov_json_formatter (0.1.4) sha256=529418fbe8de1713ac2b2d612aa3daa56d316975d307244399fa4838c601b428
|
||||
solid_cable (3.0.12) sha256=a168a54731a455d5627af48d8441ea3b554b8c1f6e6cd6074109de493e6b0460
|
||||
solid_cache (1.0.10) sha256=bc05a2fb3ac78a6f43cbb5946679cf9db67dd30d22939ededc385cb93e120d41
|
||||
solid_queue (1.3.1) sha256=d9580111180c339804ff1a810a7768f69f5dc694d31e86cf1535ff2cd7a87428
|
||||
solid_queue (1.3.2) sha256=44a53047be4255f616ff13fa5d35980e7b3eee6e31d957eadb88fbe8e0db4509
|
||||
sqlite3 (2.9.0-x86_64-linux-gnu) sha256=72fff9bd750070ba3af695511ba5f0e0a2d8a9206f84869640b3e99dfaf3d5a5
|
||||
sqlite3-ruby (1.3.3) sha256=140b6742875dd5afc3f30ab95720fe60d38e154ae1f4d0728e250778a04094e7
|
||||
sshkit (1.25.0) sha256=c8c6543cdb60f91f1d277306d585dd11b6a064cb44eab0972827e4311ff96744
|
||||
stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06
|
||||
stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1
|
||||
temple (0.10.4) sha256=b7a1e94b6f09038ab0b6e4fe0126996055da2c38bec53a8a336f075748fff72c
|
||||
thin (2.0.1) sha256=5bbde5648377f5c3864b5da7cd89a23b5c2d8d8bb9435719f6db49644bcdade9
|
||||
thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73
|
||||
thruster (0.1.17-x86_64-linux) sha256=77b8f335075bd4ece7631dc84a19a710a1e6e7102cbce147b165b45851bdfcd3
|
||||
tilt (2.7.0) sha256=0d5b9ba69f6a36490c64b0eee9f6e9aad517e20dcc848800a06eb116f08c6ab3
|
||||
thruster (0.1.18-x86_64-linux) sha256=0ec1ff5f12289c1ac10cf8e28ce6b5266f4e73416b34a664b79d037c7d955c40
|
||||
timeout (0.6.0) sha256=6d722ad619f96ee383a0c557ec6eb8c4ecb08af3af62098a0be5057bf00de1af
|
||||
tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
|
||||
turbo-rails (2.0.21) sha256=02070ea29fd11d8c1a07d9d7be980729a20e94e39b8c6c819f690f7959216bc7
|
||||
turbo-rails (2.0.23) sha256=ee0d90733aafff056cf51ff11e803d65e43cae258cc55f6492020ec1f9f9315f
|
||||
tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b
|
||||
unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
|
||||
unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
|
||||
@@ -584,7 +598,7 @@ CHECKSUMS
|
||||
websocket-driver (0.8.0) sha256=ed0dba4b943c22f17f9a734817e808bc84cdce6a7e22045f5315aa57676d4962
|
||||
websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241
|
||||
xpath (3.2.0) sha256=6dfda79d91bb3b949b947ecc5919f042ef2f399b904013eb3ef6d20dd3a4082e
|
||||
zeitwerk (2.7.4) sha256=2bef90f356bdafe9a6c2bd32bcd804f83a4f9b8bc27f3600fff051eb3edcec8b
|
||||
zeitwerk (2.7.5) sha256=d8da92128c09ea6ec62c949011b00ed4a20242b255293dd66bf41545398f73dd
|
||||
|
||||
BUNDLED WITH
|
||||
4.0.4
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
"Sanasto Wiki" is a web-based dictionary application for simultaneous translators in the living Christianity. The application provides publicly accessible translations while restricting editing and commenting to invited contributors.
|
||||
"Sanasto Wiki" is a web-based glossary for simultaneous translators in the living Christianity. The application provides publicly accessible translations while restricting editing and commenting to invited contributors.
|
||||
|
||||
---
|
||||
|
||||
@@ -188,15 +188,25 @@ See 'public/Kristillisyyden sanasto ver 23.5.2013.xlsx'
|
||||
|
||||
---
|
||||
|
||||
## API (Optional Future)
|
||||
## API (Public Sync)
|
||||
|
||||
REST API for potential mobile app or integration:
|
||||
Public JSON endpoint for syncing entries:
|
||||
```
|
||||
GET /api/entries
|
||||
GET /api/entries/:id
|
||||
GET /api/entries/search?q=:query&lang=:code
|
||||
POST /api/entries (authenticated)
|
||||
PATCH /api/entries/:id (authenticated)
|
||||
GET /api/entries
|
||||
GET /api/entries?since=2026-01-01T12:00:00Z
|
||||
```
|
||||
|
||||
Responses include all language columns, category and `updated_at`. The optional `since`
|
||||
parameter filters by `updated_at` (ISO8601).
|
||||
|
||||
Swagger docs:
|
||||
```
|
||||
GET /api/swagger
|
||||
```
|
||||
|
||||
Swagger UI:
|
||||
```
|
||||
GET /api
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
# Sanasto Wiki TODO List
|
||||
|
||||
This document outlines planned improvements, bug fixes, and new features for the Sanasto Wiki application.
|
||||
|
||||
---
|
||||
|
||||
## High Priority
|
||||
|
||||
### Bugs
|
||||
|
||||
- [x] **Search input loses focus on filter change**: This issue has been resolved. The search input now retains focus when filters are applied.
|
||||
- [x] **Mismatched `enum` syntax in models**: This issue has been resolved by correcting the `enum` declarations in `SuggestedMeaning.rb` and `User.rb` to use the updated Rails 8 syntax. All tests now pass.
|
||||
- [ ] **[BUG] Mobile browser access is blocked by `:modern` browser requirement in `ApplicationController`**: This issue has been resolved by removing the `allow_browser versions: :modern` line from `ApplicationController`.
|
||||
|
||||
### Improvements
|
||||
|
||||
- [x] **Replace hardcoded `LANGUAGE_COLUMNS` with dynamic query**: The `Entry` model now dynamically fetches language codes via `SupportedLanguage.valid_codes` and caches them, removing the hardcoded array. This task is completed.
|
||||
|
||||
---
|
||||
|
||||
## Medium Priority
|
||||
|
||||
### New Features
|
||||
|
||||
- [ ] **Add user authentication:** The application currently lacks user authentication, which is a critical security vulnerability. Implementing a robust authentication system will protect sensitive data and ensure only authorized users can make changes.
|
||||
- [ ] **Implement user roles and permissions:** The `README.md` defines user roles (contributor, reviewer, admin), but the application does not yet enforce these roles. Implementing a permissions system will ensure that users can only perform actions appropriate for their role.
|
||||
- [ ] **Add create, edit, update, and destroy actions to `EntriesController`:** The `EntriesController` currently lacks the full set of CRUD actions needed for managing entries.
|
||||
- [ ] **Add views for creating and editing entries:** Corresponding views for entry creation and editing are missing.
|
||||
- [ ] **Add pages for user profiles, admin dashboard, and suggested meanings queue:** Essential UI components for user management and content review are absent.
|
||||
|
||||
### Discussion
|
||||
|
||||
- [x] **Add comments to entries**: Users can now add comments to entries.
|
||||
- [x] **Submit alternative translations as suggested meanings**: This is part of the comments and discussion feature, and the infrastructure for this is in place. Need to verify that the suggested meaning model is used to actually submit alternative translations.
|
||||
- [x] **Participate in translation discussions**: The comments section provides the foundation for this. Additional features might be needed for a full discussion.
|
||||
- [ ] **Plan for user profile based notification exception**: Implement logic to allow users to opt out of notifications for specific language changes or comments on their profile.
|
||||
|
||||
### Refactoring
|
||||
|
||||
- [x] **Improve fixture quality**: The test fixtures have been refactored to resolve conflicts and foreign key violations, ensuring tests pass reliably. This task is completed.
|
||||
|
||||
---
|
||||
|
||||
## Low Priority
|
||||
|
||||
### New Features
|
||||
|
||||
- [x] **Add a download button for entries**: This feature has been implemented in the `EntriesController#download` action and is accessible from the UI. This task is completed.
|
||||
|
||||
### Improvements
|
||||
|
||||
- [ ] **Enhance UI/UX:** While functional, the user interface could be improved to be more intuitive and visually appealing. A design review and subsequent enhancements would improve the overall user experience.
|
||||
- [ ] **Add tests for controllers and views:** The current test suite only covers the models. To ensure the reliability of the application, tests for the controllers and views should also be added.
|
||||
@@ -12,6 +12,7 @@ class Admin::DashboardController < Admin::BaseController
|
||||
@pending_suggestions_count = SuggestedMeaning.pending.count
|
||||
@accepted_suggestions_count = SuggestedMeaning.accepted.count
|
||||
@rejected_suggestions_count = SuggestedMeaning.rejected.count
|
||||
@requested_entries_count = Entry.requested.count
|
||||
|
||||
@comment_count = Comment.count
|
||||
|
||||
|
||||
@@ -14,9 +14,7 @@ class Admin::InvitationsController < Admin::BaseController
|
||||
|
||||
def create
|
||||
@invitation = User.new(invitation_params)
|
||||
@invitation.invitation_token = SecureRandom.urlsafe_base64(32)
|
||||
@invitation.invitation_sent_at = Time.current
|
||||
@invitation.invited_by = current_user
|
||||
@invitation.invite_by(current_user)
|
||||
@invitation.password = SecureRandom.urlsafe_base64(16)
|
||||
|
||||
if @invitation.save
|
||||
@@ -29,6 +27,21 @@ class Admin::InvitationsController < Admin::BaseController
|
||||
end
|
||||
end
|
||||
|
||||
def resend
|
||||
@invitation = User.find(params[:id])
|
||||
|
||||
if @invitation.invitation_accepted_at.present?
|
||||
redirect_to admin_invitations_path, alert: "Cannot resend an accepted invitation."
|
||||
return
|
||||
end
|
||||
|
||||
@invitation.invite_by!(current_user)
|
||||
|
||||
InvitationMailer.invite(@invitation).deliver_later
|
||||
|
||||
redirect_to admin_invitations_path, notice: "Invitation resent to #{@invitation.email}"
|
||||
end
|
||||
|
||||
def destroy
|
||||
@invitation = User.find(params[:id])
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
class Admin::RequestsController < Admin::BaseController
|
||||
def index
|
||||
@requested_entries = Entry.requested
|
||||
.includes(:requested_by)
|
||||
.order(created_at: :desc)
|
||||
|
||||
@approved_entries = Entry.approved
|
||||
.includes(:requested_by)
|
||||
.order(updated_at: :desc)
|
||||
end
|
||||
|
||||
def show
|
||||
@entry = Entry.find(params[:id])
|
||||
end
|
||||
|
||||
def edit
|
||||
@entry = Entry.find(params[:id])
|
||||
end
|
||||
|
||||
def update
|
||||
@entry = Entry.find(params[:id])
|
||||
|
||||
if @entry.update(entry_params)
|
||||
redirect_to admin_request_path(@entry), notice: "Request updated successfully."
|
||||
else
|
||||
flash.now[:alert] = "Error updating request."
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def approve
|
||||
@entry = Entry.find(params[:id])
|
||||
@user = @entry.requested_by
|
||||
|
||||
@user.invite_by!(current_user)
|
||||
|
||||
@entry.update!(status: :approved)
|
||||
InvitationMailer.invite(@user, approved_entry: @entry).deliver_later
|
||||
|
||||
redirect_to admin_requests_path, notice: "Request approved and invitation sent to #{@user.email}."
|
||||
end
|
||||
|
||||
def reject
|
||||
@entry = Entry.find(params[:id])
|
||||
@user = @entry.requested_by
|
||||
|
||||
entry_preview = [ @entry.fi, @entry.en, @entry.sv, @entry.no, @entry.ru, @entry.de ].compact.first || "Entry"
|
||||
|
||||
@entry.destroy!
|
||||
@user.destroy! if @user.requested_entries.count.zero?
|
||||
|
||||
redirect_to admin_requests_path, notice: "Request '#{entry_preview}' has been rejected and deleted."
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def entry_params
|
||||
params.require(:entry).permit(:category, :fi, :en, :sv, :no, :ru, :de, :notes)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,48 @@
|
||||
require "grape"
|
||||
require "ostruct"
|
||||
|
||||
class Api::Base < Grape::API
|
||||
format :json
|
||||
default_format :json
|
||||
content_type :json, "application/json"
|
||||
|
||||
helpers do
|
||||
def parse_since_param(raw_since)
|
||||
return nil if raw_since.blank?
|
||||
|
||||
Time.iso8601(raw_since)
|
||||
rescue ArgumentError
|
||||
error!({ error: "Invalid since parameter. Use ISO8601 timestamp." }, 400)
|
||||
end
|
||||
end
|
||||
|
||||
resource :entries do
|
||||
desc "Return public entries in all languages",
|
||||
attributes: OpenStruct.new(success: nil, produces: nil)
|
||||
params do
|
||||
optional :since,
|
||||
type: String,
|
||||
desc: "ISO8601 timestamp. Returns entries updated after this time."
|
||||
end
|
||||
get do
|
||||
since_time = parse_since_param(params[:since])
|
||||
|
||||
entries_scope = Entry.active_entries
|
||||
entries_scope = entries_scope.where("updated_at > ?", since_time) if since_time
|
||||
|
||||
entries_scope
|
||||
.order(:updated_at, :id)
|
||||
.select(
|
||||
:id,
|
||||
:category,
|
||||
:fi,
|
||||
:en,
|
||||
:sv,
|
||||
:no,
|
||||
:ru,
|
||||
:de,
|
||||
:updated_at
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,119 @@
|
||||
# config/routes.rb
|
||||
|
||||
# app/controllers/api/swagger_controller.rb
|
||||
module Api
|
||||
class SwaggerController < ApplicationController
|
||||
def index
|
||||
render json: {
|
||||
openapi: "3.0.0",
|
||||
info: {
|
||||
title: "Sanasto Wiki API",
|
||||
description: "Public sync API for Sanasto Wiki glossary entries.",
|
||||
version: "1.0.0"
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: "https://#{request.host}",
|
||||
description: "Production server"
|
||||
}
|
||||
],
|
||||
paths: {
|
||||
"/api/entries": {
|
||||
get: {
|
||||
summary: "Return public entries in all languages",
|
||||
description: "Retrieve all active glossary entries with optional filtering by update timestamp",
|
||||
tags: [ "Entries" ],
|
||||
parameters: [
|
||||
{
|
||||
name: "since",
|
||||
in: "query",
|
||||
description: "ISO8601 timestamp. Returns entries updated after this time.",
|
||||
required: false,
|
||||
schema: {
|
||||
type: "string",
|
||||
format: "date-time",
|
||||
example: "2024-01-01T00:00:00Z"
|
||||
}
|
||||
}
|
||||
],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "List of entries",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "array",
|
||||
items: {
|
||||
"$ref": "#/components/schemas/Entry"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
description: "Invalid since parameter",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
error: { type: "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
components: {
|
||||
schemas: {
|
||||
Entry: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: {
|
||||
type: "integer",
|
||||
description: "Entry ID"
|
||||
},
|
||||
category: {
|
||||
type: "string",
|
||||
description: "Entry category"
|
||||
},
|
||||
fi: {
|
||||
type: "string",
|
||||
description: "Finnish translation"
|
||||
},
|
||||
en: {
|
||||
type: "string",
|
||||
description: "English translation"
|
||||
},
|
||||
sv: {
|
||||
type: "string",
|
||||
description: "Swedish translation"
|
||||
},
|
||||
no: {
|
||||
type: "string",
|
||||
description: "Norwegian translation"
|
||||
},
|
||||
ru: {
|
||||
type: "string",
|
||||
description: "Russian translation"
|
||||
},
|
||||
de: {
|
||||
type: "string",
|
||||
description: "German translation"
|
||||
},
|
||||
updated_at: {
|
||||
type: "string",
|
||||
format: "date-time",
|
||||
description: "Last update timestamp (ISO8601)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,7 +1,14 @@
|
||||
class ApplicationController < ActionController::Base
|
||||
include BotBlocker
|
||||
include Pagy::Backend
|
||||
|
||||
# Changes to the importmap will invalidate the etag for HTML responses
|
||||
stale_when_importmap_changes
|
||||
|
||||
SESSION_TIMEOUT = 3.days
|
||||
|
||||
before_action :check_session_timeout
|
||||
|
||||
helper_method :supported_languages, :current_user, :logged_in?, :admin?, :reviewer_or_admin?,
|
||||
:contributor_or_above?, :setup_completed?
|
||||
|
||||
@@ -12,7 +19,40 @@ class ApplicationController < ActionController::Base
|
||||
end
|
||||
|
||||
def current_user
|
||||
@current_user ||= User.find_by(id: session[:user_id]) if session[:user_id]
|
||||
return @current_user if defined?(@current_user)
|
||||
|
||||
# First check session
|
||||
if session[:user_id]
|
||||
@current_user = User.find_by(id: session[:user_id])
|
||||
# Then check remember me cookie
|
||||
elsif cookies.signed[:remember_token]
|
||||
user = User.find_by_valid_remember_token(cookies.signed[:remember_token])
|
||||
if user
|
||||
session[:user_id] = user.id
|
||||
@current_user = user
|
||||
else
|
||||
# Invalid or expired remember token, clear it
|
||||
cookies.delete(:remember_token)
|
||||
end
|
||||
end
|
||||
|
||||
@current_user
|
||||
end
|
||||
|
||||
def check_session_timeout
|
||||
return unless logged_in?
|
||||
return if cookies.signed[:remember_token].present?
|
||||
|
||||
if session[:last_activity_at].present?
|
||||
last_activity = Time.parse(session[:last_activity_at])
|
||||
if last_activity < SESSION_TIMEOUT.ago
|
||||
reset_session
|
||||
redirect_to login_path, alert: "Your session has expired. Please sign in again."
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
session[:last_activity_at] = Time.current.to_s
|
||||
end
|
||||
|
||||
def logged_in?
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
module BotBlocker
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_action :block_bots
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def block_bots
|
||||
return unless bot_request?
|
||||
|
||||
render plain: "Bot access is not allowed", status: :forbidden
|
||||
end
|
||||
|
||||
def bot_request?
|
||||
user_agent = request.user_agent.to_s.downcase
|
||||
|
||||
# List of known bot user agents
|
||||
bot_patterns = [
|
||||
"gptbot", # OpenAI GPTBot
|
||||
"chatgpt", # ChatGPT
|
||||
"claude-web", # Anthropic Claude
|
||||
"bingbot", # Microsoft Bing
|
||||
"googlebot", # Google
|
||||
"baiduspider", # Baidu
|
||||
"yandexbot", # Yandex
|
||||
"duckduckbot", # DuckDuckGo
|
||||
"slurp", # Yahoo
|
||||
"facebookexternalhit", # Facebook
|
||||
"twitterbot", # Twitter
|
||||
"linkedinbot", # LinkedIn
|
||||
"whatsapp", # WhatsApp
|
||||
"telegrambot", # Telegram
|
||||
"slackbot", # Slack
|
||||
"discordbot", # Discord
|
||||
"applebot", # Apple
|
||||
"ia_archiver", # Alexa/Internet Archive
|
||||
"petalbot", # Huawei
|
||||
"seznambot", # Seznam
|
||||
"ahrefsbot", # Ahrefs
|
||||
"semrushbot", # SEMrush
|
||||
"mj12bot", # Majestic
|
||||
"dotbot", # OpenSiteExplorer
|
||||
"rogerbot", # Moz
|
||||
"exabot", # Exalead
|
||||
"facebot", # Facebook
|
||||
"spider", # Generic spiders
|
||||
"crawler", # Generic crawlers
|
||||
"scraper", # Generic scrapers
|
||||
"bot" # Generic bots (last resort)
|
||||
]
|
||||
|
||||
bot_patterns.any? { |pattern| user_agent.include?(pattern) }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,45 @@
|
||||
module RateLimiter
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_action :check_rate_limit, only: [ :create ]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def check_rate_limit
|
||||
identifier = request.ip
|
||||
cache_key = "rate_limit:#{controller_name}:#{identifier}"
|
||||
|
||||
# Get current attempt count
|
||||
attempts = Rails.cache.read(cache_key) || 0
|
||||
|
||||
if attempts >= max_attempts
|
||||
@rate_limited = true
|
||||
render_rate_limit_error
|
||||
return
|
||||
end
|
||||
|
||||
# Increment attempt count with expiry
|
||||
Rails.cache.write(cache_key, attempts + 1, expires_in: lockout_period)
|
||||
end
|
||||
|
||||
def reset_rate_limit
|
||||
identifier = request.ip
|
||||
cache_key = "rate_limit:#{controller_name}:#{identifier}"
|
||||
Rails.cache.delete(cache_key)
|
||||
end
|
||||
|
||||
def render_rate_limit_error
|
||||
flash.now[:alert] = "Too many failed attempts. Please try again in #{lockout_period / 60} minutes."
|
||||
render action_name == "create" ? :new : action_name, status: :too_many_requests
|
||||
end
|
||||
|
||||
def max_attempts
|
||||
5
|
||||
end
|
||||
|
||||
def lockout_period
|
||||
15.minutes
|
||||
end
|
||||
end
|
||||
@@ -6,31 +6,29 @@ class EntriesController < ApplicationController
|
||||
@category = params[:category].presence
|
||||
@query = params[:q].to_s.strip
|
||||
@starts_with = params[:starts_with].presence
|
||||
@page = [ params[:page].to_i, 1 ].max
|
||||
@per_page = 25
|
||||
|
||||
entries_scope = Entry.all
|
||||
entries_scope = Entry.active_entries
|
||||
entries_scope = entries_scope.with_category(@category)
|
||||
entries_scope = entries_scope.search(@query, language_code: @language_code)
|
||||
entries_scope = entries_scope.starts_with(@starts_with, language_code: @language_code) if @starts_with.present?
|
||||
entries_scope = entries_scope.alphabetical_for(@language_code) if @query.blank? && @starts_with.blank? && @language_code.present?
|
||||
entries_scope = entries_scope.order(created_at: :desc) if entries_scope.order_values.empty?
|
||||
|
||||
@total_entries = entries_scope.count
|
||||
@total_pages = (@total_entries.to_f / @per_page).ceil
|
||||
@entries = entries_scope.offset((@page - 1) * @per_page).limit(@per_page)
|
||||
@pagy, @entries = pagy(entries_scope, items: 25)
|
||||
@total_entries = @pagy.count
|
||||
|
||||
@entry_count = Entry.count
|
||||
@verified_count = Entry.where(verified: true).count
|
||||
@entry_count = Entry.active_entries.count
|
||||
@requested_count = Entry.requested.count
|
||||
@verified_count = Entry.active_entries.where(verified: true).count
|
||||
@needs_review_count = @entry_count - @verified_count
|
||||
@complete_entries_count = supported_languages.reduce(Entry.all) do |scope, language|
|
||||
@complete_entries_count = supported_languages.reduce(Entry.active_entries) do |scope, language|
|
||||
scope.where.not(language.code => [ nil, "" ])
|
||||
end.count
|
||||
@missing_entries_count = @entry_count - @complete_entries_count
|
||||
@language_completion = supported_languages.index_with do |language|
|
||||
next 0 if @entry_count.zero?
|
||||
|
||||
(Entry.where.not(language.code => [ nil, "" ]).count * 100.0 / @entry_count).round
|
||||
(Entry.active_entries.where.not(language.code => [ nil, "" ]).count * 100.0 / @entry_count).round
|
||||
end
|
||||
|
||||
if @language_code.present?
|
||||
@@ -61,7 +59,7 @@ class EntriesController < ApplicationController
|
||||
end
|
||||
|
||||
def download
|
||||
@entries = Entry.order(:id)
|
||||
@entries = Entry.active_entries.order(:id)
|
||||
respond_to do |format|
|
||||
format.xlsx do
|
||||
filename = "sanasto-entries-#{Time.zone.today}.xlsx"
|
||||
@@ -77,7 +75,7 @@ class EntriesController < ApplicationController
|
||||
end
|
||||
|
||||
def entry_params
|
||||
params.require(:entry).permit(:category)
|
||||
params.require(:entry).permit(:category, :fi, :en, :sv, :no, :ru, :de, :notes)
|
||||
end
|
||||
|
||||
def validate_language_code(code)
|
||||
|
||||
@@ -21,6 +21,9 @@ class InvitationsController < ApplicationController
|
||||
invitation_token: nil
|
||||
)
|
||||
|
||||
# Activate approved entries by this user
|
||||
Entry.where(requested_by: @user, status: :approved).update_all(status: :active)
|
||||
|
||||
session[:user_id] = @user.id
|
||||
redirect_to admin? ? admin_root_path : root_path, notice: "Welcome to Sanasto Wiki, #{@user.name}!"
|
||||
else
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
class PasswordResetsController < ApplicationController
|
||||
RESET_TOKEN_EXPIRY = 1.hour
|
||||
|
||||
def new
|
||||
# Show request password reset form
|
||||
end
|
||||
|
||||
def create
|
||||
@user = User.find_by(email: params[:email]&.downcase&.strip)
|
||||
|
||||
if @user&.invitation_accepted_at.present?
|
||||
@user.update!(
|
||||
reset_password_token: SecureRandom.urlsafe_base64(32),
|
||||
reset_password_sent_at: Time.current
|
||||
)
|
||||
PasswordResetMailer.reset(@user).deliver_later
|
||||
elsif @user.present?
|
||||
@user.invite_by!
|
||||
InvitationMailer.invite(@user).deliver_later
|
||||
end
|
||||
|
||||
redirect_to login_path, notice: "If that email address is in our system, you will receive password reset instructions."
|
||||
end
|
||||
|
||||
def edit
|
||||
@user = User.find_by(reset_password_token: params[:token])
|
||||
|
||||
if @user.nil?
|
||||
redirect_to login_path, alert: "Invalid password reset link."
|
||||
elsif password_reset_expired?(@user)
|
||||
redirect_to new_password_reset_path, alert: "Password reset link has expired. Please request a new one."
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
@user = User.find_by(reset_password_token: params[:token])
|
||||
|
||||
if @user.nil?
|
||||
redirect_to login_path, alert: "Invalid password reset link."
|
||||
return
|
||||
end
|
||||
|
||||
if password_reset_expired?(@user)
|
||||
redirect_to new_password_reset_path, alert: "Password reset link has expired. Please request a new one."
|
||||
return
|
||||
end
|
||||
|
||||
if params[:password].blank?
|
||||
flash.now[:alert] = "Password cannot be blank."
|
||||
render :edit, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
if params[:password] != params[:password_confirmation]
|
||||
flash.now[:alert] = "Password confirmation doesn't match."
|
||||
render :edit, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
@user.password = params[:password]
|
||||
@user.password_confirmation = params[:password_confirmation]
|
||||
@user.reset_password_token = nil
|
||||
@user.reset_password_sent_at = nil
|
||||
|
||||
if @user.save
|
||||
session[:user_id] = @user.id
|
||||
redirect_to root_path, notice: "Your password has been reset successfully."
|
||||
else
|
||||
flash.now[:alert] = @user.errors.full_messages.join(", ")
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def password_reset_expired?(user)
|
||||
user.reset_password_sent_at < RESET_TOKEN_EXPIRY.ago
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,75 @@
|
||||
class RequestsController < ApplicationController
|
||||
def new
|
||||
@entry = Entry.new
|
||||
|
||||
if current_user
|
||||
@pending_count = current_user.requested_entries.where(status: [ :requested, :approved ]).count
|
||||
elsif params[:email].present?
|
||||
@pending_count = User.find_by(email: params[:email])&.requested_entries&.where(status: [ :requested, :approved ])&.count || 0
|
||||
else
|
||||
@pending_count = 0
|
||||
end
|
||||
end
|
||||
|
||||
def create
|
||||
# If user is logged in, use their account
|
||||
if current_user
|
||||
@user = current_user
|
||||
else
|
||||
# Anonymous submission - need to find or create user
|
||||
email = request_params[:email]
|
||||
existing_user = User.find_by(email: email)
|
||||
|
||||
# Check if user has already accepted an invitation
|
||||
if existing_user&.invitation_accepted_at.present?
|
||||
redirect_to login_path, alert: "An account with this email already exists. Please log in."
|
||||
return
|
||||
end
|
||||
|
||||
# Use existing pending user or create new one
|
||||
@user = existing_user || User.new(
|
||||
name: request_params[:name],
|
||||
email: email,
|
||||
password: SecureRandom.alphanumeric(32),
|
||||
role: :contributor
|
||||
)
|
||||
end
|
||||
|
||||
# Create entry in a transaction
|
||||
ActiveRecord::Base.transaction do
|
||||
# Save user only if it's a new record
|
||||
if @user.new_record? && !@user.save
|
||||
@pending_count = 0
|
||||
@entry = Entry.new(entry_params)
|
||||
flash.now[:alert] = "There was an error submitting your request. Please check the form."
|
||||
render :new, status: :unprocessable_entity
|
||||
raise ActiveRecord::Rollback
|
||||
return
|
||||
end
|
||||
|
||||
# Create entry
|
||||
@entry = Entry.new(entry_params)
|
||||
@entry.status = :requested
|
||||
@entry.requested_by = @user
|
||||
|
||||
if @entry.save
|
||||
redirect_to root_path, notice: "Thank you for your request! We'll review it and get back to you soon."
|
||||
else
|
||||
@pending_count = 0
|
||||
flash.now[:alert] = "There was an error submitting your request. Please check the form."
|
||||
render :new, status: :unprocessable_entity
|
||||
raise ActiveRecord::Rollback
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def request_params
|
||||
params.require(:entry).permit(:name, :email, :category, :fi, :en, :sv, :no, :ru, :de, :notes)
|
||||
end
|
||||
|
||||
def entry_params
|
||||
request_params.except(:name, :email)
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,6 @@
|
||||
class SessionsController < ApplicationController
|
||||
include RateLimiter
|
||||
|
||||
def new
|
||||
# Redirect to admin if already logged in
|
||||
if logged_in?
|
||||
@@ -17,7 +19,23 @@ class SessionsController < ApplicationController
|
||||
return
|
||||
end
|
||||
|
||||
# Reset rate limit on successful login
|
||||
reset_rate_limit
|
||||
|
||||
session[:user_id] = user.id
|
||||
session[:last_activity_at] = Time.current.to_s
|
||||
|
||||
# Handle remember me
|
||||
if params[:remember_me] == "1"
|
||||
token = user.remember_me
|
||||
cookies.signed[:remember_token] = {
|
||||
value: token,
|
||||
expires: User::REMEMBER_TOKEN_EXPIRY.from_now,
|
||||
httponly: true,
|
||||
secure: Rails.env.production?
|
||||
}
|
||||
end
|
||||
|
||||
redirect_to admin? ? admin_root_path : root_path, notice: "Welcome back, #{user.name}!"
|
||||
else
|
||||
flash.now[:alert] = "Invalid email or password."
|
||||
@@ -26,7 +44,9 @@ class SessionsController < ApplicationController
|
||||
end
|
||||
|
||||
def destroy
|
||||
session[:user_id] = nil
|
||||
redirect_to root_path, notice: "You have been logged out."
|
||||
current_user&.forget_me if cookies.signed[:remember_token]
|
||||
reset_session
|
||||
cookies.delete(:remember_token)
|
||||
redirect_to root_path, notice: "You have been logged out.", status: :see_other
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
module ApplicationHelper
|
||||
include Pagy::Frontend
|
||||
|
||||
def language_name(code)
|
||||
supported_languages.find { |l| l.code == code }&.name
|
||||
end
|
||||
|
||||
@@ -28,4 +28,15 @@ module EntriesHelper
|
||||
def format_entry_category(entry)
|
||||
entry.category.to_s.tr("_", " ").capitalize
|
||||
end
|
||||
|
||||
def format_entry_status(entry)
|
||||
case entry.status
|
||||
when "requested"
|
||||
content_tag(:span, "Requested", class: "px-2 py-1 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800")
|
||||
when "approved"
|
||||
content_tag(:span, "Approved", class: "px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800")
|
||||
when "active"
|
||||
content_tag(:span, "Active", class: "px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,25 +1,46 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["modal", "button"]
|
||||
static targets = ["modal", "languageSelect", "form"]
|
||||
|
||||
connect() {
|
||||
this.modalTarget.classList.add("hidden")
|
||||
this.buttonTarget.classList.remove("hidden")
|
||||
}
|
||||
|
||||
open(event) {
|
||||
event.preventDefault()
|
||||
|
||||
// Get the language code from the button that was clicked
|
||||
const languageCode = event.currentTarget.dataset.languageCode
|
||||
|
||||
// Set the language select value if provided
|
||||
if (languageCode && this.hasLanguageSelectTarget) {
|
||||
this.languageSelectTarget.value = languageCode
|
||||
}
|
||||
|
||||
this.modalTarget.classList.remove("hidden")
|
||||
}
|
||||
|
||||
close(event) {
|
||||
if (event.target === this.modalTarget) {
|
||||
this.modalTarget.classList.add("hidden")
|
||||
this.resetForm()
|
||||
}
|
||||
}
|
||||
|
||||
closeWithButton() {
|
||||
closeWithButton(event) {
|
||||
event.preventDefault()
|
||||
this.modalTarget.classList.add("hidden")
|
||||
this.resetForm()
|
||||
}
|
||||
|
||||
stopPropagation(event) {
|
||||
event.stopPropagation()
|
||||
}
|
||||
|
||||
resetForm() {
|
||||
if (this.hasFormTarget) {
|
||||
this.formTarget.reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
class ApplicationMailer < ActionMailer::Base
|
||||
default from: "from@example.com"
|
||||
default from: Rails.application.credentials.dig(:mail, :from)
|
||||
layout "mailer"
|
||||
end
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
class InvitationMailer < ApplicationMailer
|
||||
def invite(user)
|
||||
def invite(user, approved_entry: nil)
|
||||
@user = user
|
||||
@approved_entry = approved_entry
|
||||
@invitation_url = invitation_url(@user.invitation_token)
|
||||
@expires_at = @user.invitation_sent_at + User::INVITATION_TOKEN_EXPIRY
|
||||
|
||||
subject = if @approved_entry
|
||||
"Your entry request has been approved - Join Sanasto Wiki"
|
||||
else
|
||||
"You've been invited to join Sanasto Wiki"
|
||||
end
|
||||
|
||||
mail(
|
||||
to: @user.email,
|
||||
subject: "You've been invited to join Sanasto Wiki"
|
||||
subject: subject
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
class PasswordResetMailer < ApplicationMailer
|
||||
def reset(user)
|
||||
@user = user
|
||||
@reset_url = edit_password_reset_url(@user.reset_password_token)
|
||||
@expires_at = @user.reset_password_sent_at + PasswordResetsController::RESET_TOKEN_EXPIRY
|
||||
|
||||
mail(to: @user.email, subject: "Reset your Sanasto Wiki password")
|
||||
end
|
||||
end
|
||||
@@ -1,15 +1,21 @@
|
||||
class Entry < ApplicationRecord
|
||||
belongs_to :created_by, class_name: "User", optional: true
|
||||
belongs_to :updated_by, class_name: "User", optional: true
|
||||
belongs_to :requested_by, class_name: "User", optional: true
|
||||
|
||||
has_many :suggested_meanings, dependent: :destroy
|
||||
has_many :comments, as: :commentable, dependent: :destroy
|
||||
|
||||
enum :category, %i[word phrase proper_name title reference other]
|
||||
enum :status, %i[requested approved active], default: :active
|
||||
|
||||
validates :category, presence: true
|
||||
validate :at_least_one_translation
|
||||
|
||||
scope :with_category, ->(cat) { cat.present? ? where(category: cat) : all }
|
||||
scope :requested, -> { where(status: :requested) }
|
||||
scope :approved, -> { where(status: :approved) }
|
||||
scope :active_entries, -> { where(status: :active) }
|
||||
|
||||
def self.search(query, language_code: nil)
|
||||
return all if query.blank?
|
||||
@@ -43,4 +49,10 @@ class Entry < ApplicationRecord
|
||||
def self.valid_lang?(code)
|
||||
SupportedLanguage.valid_codes.include?(code.to_s)
|
||||
end
|
||||
|
||||
def at_least_one_translation
|
||||
if [ fi, en, sv, no, ru, de ].all?(&:blank?)
|
||||
errors.add(:base, "At least one language translation is required")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,6 +6,7 @@ class User < ApplicationRecord
|
||||
|
||||
has_many :created_entries, class_name: "Entry", foreign_key: :created_by_id, dependent: :nullify
|
||||
has_many :updated_entries, class_name: "Entry", foreign_key: :updated_by_id, dependent: :nullify
|
||||
has_many :requested_entries, class_name: "Entry", foreign_key: :requested_by_id, dependent: :nullify
|
||||
has_many :submitted_suggested_meanings,
|
||||
class_name: "SuggestedMeaning",
|
||||
foreign_key: :submitted_by_id,
|
||||
@@ -18,14 +19,18 @@ class User < ApplicationRecord
|
||||
|
||||
enum :role, %i[contributor reviewer admin]
|
||||
|
||||
validates :email, presence: true, uniqueness: true
|
||||
validates :email, presence: true, uniqueness: { case_sensitive: false }
|
||||
validates :password, length: { minimum: 12 }, if: -> { password.present? }
|
||||
|
||||
before_validation :normalize_email
|
||||
|
||||
scope :by_role, ->(role) { where(role: role) if role.present? }
|
||||
scope :search_email, ->(q) { where("email LIKE ?", "%#{sanitize_sql_like(q)}%") if q.present? }
|
||||
|
||||
# Invitation token expires after 14 days
|
||||
INVITATION_TOKEN_EXPIRY = 14.days
|
||||
# Remember me token expires after 2 weeks
|
||||
REMEMBER_TOKEN_EXPIRY = 2.weeks
|
||||
|
||||
def invitation_expired?
|
||||
return false if invitation_sent_at.nil?
|
||||
@@ -36,10 +41,49 @@ class User < ApplicationRecord
|
||||
invitation_token.present? && invitation_accepted_at.nil? && !invitation_expired?
|
||||
end
|
||||
|
||||
def invite_by(invitee)
|
||||
self.invited_by = invitee if invitee && invited_by.nil?
|
||||
self.invitation_token = SecureRandom.urlsafe_base64(32)
|
||||
self.invitation_sent_at = Time.current
|
||||
end
|
||||
|
||||
def invite_by!(invitee = nil)
|
||||
invite_by(invitee)
|
||||
save!
|
||||
end
|
||||
|
||||
def self.find_by_valid_invitation_token(token)
|
||||
where(invitation_token: token)
|
||||
.where(invitation_accepted_at: nil)
|
||||
.where("invitation_sent_at > ?", INVITATION_TOKEN_EXPIRY.ago)
|
||||
.first
|
||||
end
|
||||
|
||||
def remember_me
|
||||
self.remember_token = SecureRandom.urlsafe_base64(32)
|
||||
self.remember_created_at = Time.current
|
||||
save(validate: false)
|
||||
remember_token
|
||||
end
|
||||
|
||||
def forget_me
|
||||
update_columns(remember_token: nil, remember_created_at: nil)
|
||||
end
|
||||
|
||||
def remember_token_expired?
|
||||
return true if remember_created_at.nil?
|
||||
remember_created_at < REMEMBER_TOKEN_EXPIRY.ago
|
||||
end
|
||||
|
||||
def self.find_by_valid_remember_token(token)
|
||||
user = find_by(remember_token: token)
|
||||
return nil if user.nil? || user.remember_token_expired?
|
||||
user
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def normalize_email
|
||||
self.email = email.downcase.strip if email.present?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -18,10 +18,12 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Total Users</dt>
|
||||
<dd class="text-3xl font-semibold text-gray-900"><%= @user_count %></dd>
|
||||
</dl>
|
||||
<%= link_to admin_users_path do %>
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Total Users</dt>
|
||||
<dd class="text-3xl font-semibold text-gray-900"><%= @user_count %></dd>
|
||||
</dl>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -44,10 +46,12 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Total Entries</dt>
|
||||
<dd class="text-3xl font-semibold text-gray-900"><%= @entry_count %></dd>
|
||||
</dl>
|
||||
<%= link_to root_path do %>
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Total Entries</dt>
|
||||
<dd class="text-3xl font-semibold text-gray-900"><%= @entry_count %></dd>
|
||||
</dl>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,10 +73,12 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Suggestions</dt>
|
||||
<dd class="text-3xl font-semibold text-gray-900"><%= @pending_suggestions_count %></dd>
|
||||
</dl>
|
||||
<%= link_to admin_requests_path do %>
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Suggestions / Requests</dt>
|
||||
<dd class="text-3xl font-semibold text-gray-900"><%= @pending_suggestions_count %> / <%= @requested_entries_count %></dd>
|
||||
</dl>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,10 +100,12 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Pending Invites</dt>
|
||||
<dd class="text-3xl font-semibold text-gray-900"><%= @pending_invitations %></dd>
|
||||
</dl>
|
||||
<%= link_to admin_invitations_path do %>
|
||||
<dl>
|
||||
<dt class="text-sm font-medium text-gray-500 truncate">Pending Invites</dt>
|
||||
<dd class="text-3xl font-semibold text-gray-900"><%= @pending_invitations %></dd>
|
||||
</dl>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -65,7 +65,8 @@
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<%= invitation.invited_by&.name || invitation.invited_by&.email || "-" %>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-4">
|
||||
<%= button_to "Re-send", resend_admin_invitation_path(invitation), method: :put, class: "text-blue-600 hover:text-blue-900" %>
|
||||
<%= button_to "Cancel", admin_invitation_path(invitation), method: :delete, data: { turbo_confirm: "Are you sure you want to cancel this invitation?" }, class: "text-red-600 hover:text-red-900" %>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Edit Entry Request</h1>
|
||||
<p class="text-gray-600 mt-2">Modify the entry details before approval.</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<%= form_with model: @entry, url: admin_request_path(@entry), method: :patch, class: "space-y-6" do |f| %>
|
||||
<% if @entry.errors.any? %>
|
||||
<div class="mx-6 mt-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<h3 class="font-semibold text-red-800 mb-2">Please fix the following errors:</h3>
|
||||
<ul class="list-disc list-inside text-red-700 text-sm space-y-1">
|
||||
<% @entry.errors.full_messages.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="px-6 pt-6 space-y-4">
|
||||
<%= render 'entries/form_fields', f: f %>
|
||||
|
||||
<div class="border-t border-gray-200 pt-6">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-2">Requester (Read-only)</h3>
|
||||
<div class="bg-gray-50 rounded-lg p-4 space-y-1 text-sm">
|
||||
<div><span class="font-medium">Name:</span> <%= @entry.requested_by&.name %></div>
|
||||
<div><span class="font-medium">Email:</span> <%= @entry.requested_by&.email %></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 px-6 py-4 bg-gray-50 flex gap-3">
|
||||
<%= f.submit "Save Changes", class: "px-6 py-2 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-lg transition" %>
|
||||
<%= link_to "Cancel", admin_request_path(@entry), class: "px-6 py-2 bg-gray-200 hover:bg-gray-300 text-gray-800 font-semibold rounded-lg transition" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,126 @@
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Entry Requests</h1>
|
||||
<p class="text-gray-600 mt-2">Review and manage entry requests from public users.</p>
|
||||
</div>
|
||||
|
||||
<!-- Requested Entries Section -->
|
||||
<div class="mb-12">
|
||||
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<div class="bg-yellow-50 border-b border-yellow-100 px-6 py-4">
|
||||
<h2 class="text-xl font-bold text-yellow-900 flex items-center gap-2">
|
||||
<span>⏳</span> Pending Review
|
||||
<span class="text-sm font-normal text-yellow-700">(<%= @requested_entries.count %> total)</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<% if @requested_entries.any? %>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Entry</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Category</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Requester</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<% @requested_entries.each do |entry| %>
|
||||
<tr class="hover:bg-gray-50 transition">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm font-medium text-gray-900">
|
||||
<%= [entry.fi, entry.en, entry.sv, entry.no, entry.ru, entry.de].compact.first || "(empty)" %>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-indigo-100 text-indigo-800">
|
||||
<%= entry.category.humanize %>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-sm text-gray-900"><%= entry.requested_by&.name %></div>
|
||||
<div class="text-xs text-gray-500"><%= entry.requested_by&.email %></div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<%= entry.created_at.strftime("%b %d, %Y") %>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
|
||||
<%= link_to "View", admin_request_path(entry), class: "text-indigo-600 hover:text-indigo-900" %>
|
||||
<%= link_to "Edit", edit_admin_request_path(entry), class: "text-blue-600 hover:text-blue-900" %>
|
||||
<%= button_to "Approve", approve_admin_request_path(entry), method: :post, class: "inline text-green-600 hover:text-green-900", form: { data: { turbo_confirm: "Send invitation to #{entry.requested_by&.email}?" } } %>
|
||||
<%= button_to "Reject", reject_admin_request_path(entry), method: :delete, class: "inline text-red-600 hover:text-red-900", form: { data: { turbo_confirm: "Delete this request?" } } %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="px-6 py-12 text-center text-gray-500">
|
||||
No pending requests at the moment.
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Approved Entries Section -->
|
||||
<div>
|
||||
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<div class="bg-blue-50 border-b border-blue-100 px-6 py-4">
|
||||
<h2 class="text-xl font-bold text-blue-900 flex items-center gap-2">
|
||||
<span>✅</span> Approved (Awaiting User Acceptance)
|
||||
<span class="text-sm font-normal text-blue-700">(<%= @approved_entries.count %> total)</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<% if @approved_entries.any? %>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Entry</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Category</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Requester</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Approved</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<% @approved_entries.each do |entry| %>
|
||||
<tr class="hover:bg-gray-50 transition">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm font-medium text-gray-900">
|
||||
<%= [entry.fi, entry.en, entry.sv, entry.no, entry.ru, entry.de].compact.first || "(empty)" %>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-indigo-100 text-indigo-800">
|
||||
<%= entry.category.humanize %>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-sm text-gray-900"><%= entry.requested_by&.name %></div>
|
||||
<div class="text-xs text-gray-500"><%= entry.requested_by&.email %></div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<%= entry.updated_at.strftime("%b %d, %Y") %>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
|
||||
<%= link_to "View", admin_request_path(entry), class: "text-indigo-600 hover:text-indigo-900" %>
|
||||
<%= button_to "Reject", reject_admin_request_path(entry), method: :delete, class: "inline text-red-600 hover:text-red-900", form: { data: { turbo_confirm: "Delete this approved request?" } } %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="px-6 py-12 text-center text-gray-500">
|
||||
No approved entries awaiting user acceptance.
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,96 @@
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Entry Request Details</h1>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<div class="border-b border-gray-200 px-6 py-4 bg-gray-50">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold text-gray-900">Entry Information</h2>
|
||||
<%= content_tag(:span, @entry.status.titleize, class: "px-3 py-1 text-sm font-semibold rounded-full #{@entry.requested? ? 'bg-yellow-100 text-yellow-800' : 'bg-blue-100 text-blue-800'}") %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-6 py-6 space-y-6">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-2">Category</h3>
|
||||
<span class="px-3 py-1 text-sm font-semibold rounded-full bg-indigo-100 text-indigo-800">
|
||||
<%= @entry.category.humanize %>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 pt-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Translations</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="text-sm font-medium text-gray-700 mb-1">🇫🇮 Finnish</div>
|
||||
<div class="text-gray-900"><%= @entry.fi.presence || "—" %></div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="text-sm font-medium text-gray-700 mb-1">🇬🇧 English</div>
|
||||
<div class="text-gray-900"><%= @entry.en.presence || "—" %></div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="text-sm font-medium text-gray-700 mb-1">🇸🇪 Swedish</div>
|
||||
<div class="text-gray-900"><%= @entry.sv.presence || "—" %></div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="text-sm font-medium text-gray-700 mb-1">🇳🇴 Norwegian</div>
|
||||
<div class="text-gray-900"><%= @entry.no.presence || "—" %></div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="text-sm font-medium text-gray-700 mb-1">🇷🇺 Russian</div>
|
||||
<div class="text-gray-900"><%= @entry.ru.presence || "—" %></div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<div class="text-sm font-medium text-gray-700 mb-1">🇩🇪 German</div>
|
||||
<div class="text-gray-900"><%= @entry.de.presence || "—" %></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if @entry.notes.present? %>
|
||||
<div class="border-t border-gray-200 pt-6">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-2">Notes</h3>
|
||||
<div class="bg-gray-50 rounded-lg p-4 text-gray-900 whitespace-pre-wrap">
|
||||
<%= @entry.notes %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="border-t border-gray-200 pt-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Requester Information</h3>
|
||||
<div class="bg-gray-50 rounded-lg p-4 space-y-2">
|
||||
<div>
|
||||
<span class="text-sm font-medium text-gray-700">Name:</span>
|
||||
<span class="text-gray-900 ml-2"><%= @entry.requested_by&.name %></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm font-medium text-gray-700">Email:</span>
|
||||
<span class="text-gray-900 ml-2"><%= @entry.requested_by&.email %></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm font-medium text-gray-700">Submitted:</span>
|
||||
<span class="text-gray-900 ml-2"><%= @entry.created_at.strftime("%B %d, %Y at %I:%M %p") %></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 px-6 py-4 bg-gray-50 flex flex-wrap gap-3">
|
||||
<%= link_to "← Back to Requests", admin_requests_path, class: "px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-800 font-semibold rounded-lg transition" %>
|
||||
|
||||
<% if @entry.requested? %>
|
||||
<%= link_to "Edit", edit_admin_request_path(@entry), class: "px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg transition" %>
|
||||
<%= button_to "Approve & Send Invitation", approve_admin_request_path(@entry), method: :post, class: "px-4 py-2 bg-green-600 hover:bg-green-700 text-white font-semibold rounded-lg transition", form: { data: { turbo_confirm: "Send invitation to #{@entry.requested_by&.email}?" } } %>
|
||||
<% end %>
|
||||
|
||||
<%= button_to "Reject", reject_admin_request_path(@entry), method: :delete, class: "px-4 py-2 bg-red-600 hover:bg-red-700 text-white font-semibold rounded-lg transition ml-auto", form: { data: { turbo_confirm: "Are you sure you want to delete this request?" } } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2,12 +2,16 @@
|
||||
<%= render "entries/comment", comment: @comment %>
|
||||
<% end %>
|
||||
|
||||
<% if @comment.language_code.present? %>
|
||||
<%= turbo_stream.replace "comment-details-#{@comment.language_code}" do %>
|
||||
<%= render "entries/language_comment_details", entry: @commentable, language_code: @comment.language_code %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= turbo_stream.replace "comment_tabs" do %>
|
||||
<%= render "entries/comment_tabs", entry: @commentable %>
|
||||
<% end %>
|
||||
|
||||
<%= turbo_stream.update "comment_form_modal" do %>
|
||||
<%= render "entries/comment_modal_content", entry: @commentable %>
|
||||
<% end %>
|
||||
|
||||
<%= turbo_stream.append "comment_form_modal", target: "comment_form_modal" do %>
|
||||
<script>
|
||||
document.getElementById('comment_form_modal').classList.add('hidden');
|
||||
</script>
|
||||
<% end %>
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
<%= form_with(model: [entry, Comment.new(commentable: entry)],
|
||||
data: { turbo_stream: true },
|
||||
data: { turbo_stream: true, comments_target: "form" },
|
||||
html: { class: "space-y-4" }) do |form| %>
|
||||
<%= form.hidden_field :language_code, value: (local_assigns[:language_code].presence || nil) %>
|
||||
<div>
|
||||
<%= form.label :body, "Comment", class: "sr-only" %>
|
||||
<%= form.text_area :body, rows: 4, class: "block w-full border-slate-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm", placeholder: "Add your comment..." %>
|
||||
<%= form.label :language_code, "Language", class: "block text-sm font-medium text-slate-700 mb-2" %>
|
||||
<%= form.select :language_code,
|
||||
options_for_select(
|
||||
[["All languages", nil]] + supported_languages.map { |lang| ["#{lang.name} (#{lang.code.upcase})", lang.code] },
|
||||
local_assigns[:language_code].presence
|
||||
),
|
||||
{},
|
||||
{
|
||||
data: { comments_target: "languageSelect" },
|
||||
class: "block w-full px-3 py-2 border border-slate-300 rounded-lg shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-sm"
|
||||
} %>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<div>
|
||||
<%= form.label :body, "Comment", class: "block text-sm font-medium text-slate-700 mb-2" %>
|
||||
<%= form.text_area :body, rows: 4, class: "block w-full border-slate-300 rounded-lg shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm", placeholder: "Add your comment..." %>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" data-action="click->comments#closeWithButton" class="px-4 py-2 text-sm font-medium text-slate-700 hover:text-slate-900 transition">
|
||||
Cancel
|
||||
</button>
|
||||
<%= form.submit "Submit", class: "bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition cursor-pointer" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<div data-action="click->comments#stopPropagation" class="bg-white rounded-lg shadow-xl p-6 w-full max-w-lg">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h4 class="text-lg font-bold">Add a Comment</h4>
|
||||
<button data-action="click->comments#closeWithButton" class="text-slate-500 hover:text-slate-700 text-2xl leading-none">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<%= render "entries/comment_form", entry: entry %>
|
||||
</div>
|
||||
@@ -1,8 +1,8 @@
|
||||
<% if current_user %>
|
||||
<div class="mt-8" data-controller="comments">
|
||||
<div class="mt-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-bold text-slate-900">Discussion</h3>
|
||||
<button id="add_comment_button" data-action="click->comments#open" data-comments-target="button" class="bg-indigo-600 text-white px-4 py-2 rounded-full shadow hover:bg-indigo-700 transition text-sm font-semibold">
|
||||
<button data-action="click->comments#open" class="bg-indigo-600 text-white px-4 py-2 rounded-full shadow hover:bg-indigo-700 transition text-sm font-semibold">
|
||||
Add Comment
|
||||
</button>
|
||||
</div>
|
||||
@@ -10,15 +10,7 @@
|
||||
<%= render "entries/comment_tabs", entry: entry %>
|
||||
|
||||
<div id="comment_form_modal" data-comments-target="modal" data-action="click->comments#close" class="hidden fixed inset-0 bg-slate-900 bg-opacity-50 z-40 flex items-center justify-center">
|
||||
<div class="bg-white rounded-lg shadow-xl p-6 w-full max-w-lg">
|
||||
<div class="flex justify-between items-center">
|
||||
<h4 class="text-lg font-bold">Add a Comment</h4>
|
||||
<button id="close_comment_form" data-action="click->comments#closeWithButton" class="text-slate-500 hover:text-slate-700 text-2xl leading-none">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<%= render "entries/comment_form", entry: entry %>
|
||||
</div>
|
||||
<%= render "entries/comment_modal_content", entry: entry %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<%# Local variables expected:
|
||||
# - f: form builder object
|
||||
# - show_category: boolean (default: true) - whether to show category field
|
||||
# - show_notes: boolean (default: true) - whether to show notes field
|
||||
# - category_prompt: string or false (default: "Select a category") - prompt for category select, or false for no prompt
|
||||
%>
|
||||
|
||||
<% show_category = local_assigns.fetch(:show_category, true) %>
|
||||
<% show_notes = local_assigns.fetch(:show_notes, true) %>
|
||||
<% category_prompt = local_assigns.fetch(:category_prompt, "Select a category") %>
|
||||
|
||||
<% if show_category %>
|
||||
<div>
|
||||
<%= f.label :category, "Category", class: "block text-sm font-semibold text-gray-700 mb-2" %>
|
||||
<%= f.select :category,
|
||||
Entry.categories.keys.map { |cat| [cat.humanize, cat] },
|
||||
category_prompt ? { prompt: category_prompt } : {},
|
||||
{ required: category_prompt.present?, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" } %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="border-t border-gray-200 pt-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Translations (at least one required)</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<%= f.label :fi, "🇫🇮 Finnish", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<%= f.text_field :fi, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :en, "🇬🇧 English", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<%= f.text_field :en, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :sv, "🇸🇪 Swedish", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<%= f.text_field :sv, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :no, "🇳🇴 Norwegian", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<%= f.text_field :no, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :ru, "🇷🇺 Russian", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<%= f.text_field :ru, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :de, "🇩🇪 German", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<%= f.text_field :de, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if show_notes %>
|
||||
<div>
|
||||
<%= f.label :notes, "Additional Notes (optional)", class: "block text-sm font-semibold text-gray-700 mb-2" %>
|
||||
<%= f.text_area :notes, rows: 4, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition", placeholder: "Any additional context or information about this entry..." %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -1,8 +0,0 @@
|
||||
<details class="text-xs">
|
||||
<summary class="inline-flex items-center gap-1 text-indigo-600 font-semibold cursor-pointer">
|
||||
Add comment
|
||||
</summary>
|
||||
<div class="mt-3">
|
||||
<%= render "entries/comment_form", entry: entry, language_code: language_code %>
|
||||
</div>
|
||||
</details>
|
||||
@@ -42,6 +42,9 @@
|
||||
<tr>
|
||||
<td colspan="<%= table_languages.size + 1 %>" class="px-6 py-6 text-slate-500">
|
||||
No entries matched your filters.
|
||||
<br><br>
|
||||
<%= link_to "Request a new entry", new_request_path,
|
||||
class: "text-indigo-600 font-semibold hover:text-indigo-800 underline" %>
|
||||
</td>
|
||||
</tr>
|
||||
<% else %>
|
||||
@@ -83,17 +86,19 @@
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between mt-4 text-sm text-slate-600">
|
||||
<div>Page <%= @page %> of <%= [@total_pages, 1].max %></div>
|
||||
<div><%= pagy_info(@pagy).html_safe %></div>
|
||||
<div class="flex items-center gap-2">
|
||||
<% previous_page = @page > 1 ? @page - 1 : nil %>
|
||||
<% next_page = @page < @total_pages ? @page + 1 : nil %>
|
||||
<% pagination_params = { q: @query.presence, category: @category.presence, language: @language_code.presence, starts_with: @starts_with.presence }.compact %>
|
||||
<%= link_to "Previous", previous_page ? entries_path(pagination_params.merge(page: previous_page)) : "#",
|
||||
class: "px-3 py-1.5 rounded-md border border-slate-200 #{previous_page ? 'hover:border-indigo-300' : 'text-slate-300 cursor-not-allowed'}",
|
||||
data: { turbo_stream: true } %>
|
||||
<%= link_to "Next", next_page ? entries_path(pagination_params.merge(page: next_page)) : "#",
|
||||
class: "px-3 py-1.5 rounded-md border border-slate-200 #{next_page ? 'hover:border-indigo-300' : 'text-slate-300 cursor-not-allowed'}",
|
||||
data: { turbo_stream: true } %>
|
||||
<%
|
||||
pagination_params = { q: @query.presence, category: @category.presence, language: @language_code.presence, starts_with: @starts_with.presence }.compact
|
||||
prev_url = @pagy.prev ? entries_path(pagination_params.merge(page: @pagy.prev)) : nil
|
||||
next_url = @pagy.next ? entries_path(pagination_params.merge(page: @pagy.next)) : nil
|
||||
%>
|
||||
<%= link_to "Previous", prev_url || "#",
|
||||
class: "px-3 py-1.5 rounded-md border border-slate-200 #{'opacity-50 pointer-events-none' unless prev_url}",
|
||||
data: (prev_url ? { turbo_stream: true } : {}) %>
|
||||
<%= link_to "Next", next_url || "#",
|
||||
class: "px-3 py-1.5 rounded-md border border-slate-200 #{'opacity-50 pointer-events-none' unless next_url}",
|
||||
data: (next_url ? { turbo_stream: true } : {}) %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,70 +1,55 @@
|
||||
<% content_for :title, "Edit Entry" %>
|
||||
|
||||
<nav class="sticky top-0 z-50 bg-white border-b border-slate-200">
|
||||
<div class="max-w-5xl mx-auto px-4 h-16 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xl font-bold tracking-tight text-indigo-600">Sanasto</span>
|
||||
<span class="text-xl font-light text-slate-400">Wiki</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<%= link_to "Browse", entries_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600" %>
|
||||
<%= link_to "Download XLSX", download_entries_path(format: :xlsx), class: "text-sm font-semibold text-indigo-700 px-3 py-2 rounded-lg border border-indigo-200 bg-indigo-50 hover:bg-indigo-100 transition" %>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<%= render "shared/header", show_request_button: false, show_browse_button: true %>
|
||||
|
||||
<main class="max-w-5xl mx-auto px-4 py-8 space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<%= link_to "← Back to entry", entry_path(@entry), class: "text-sm text-slate-500 hover:text-indigo-600" %>
|
||||
<%= link_to "Back to search", entries_path, class: "text-sm text-slate-500 hover:text-indigo-600" %>
|
||||
</div>
|
||||
<%= render "shared/notifications" %>
|
||||
|
||||
<div class="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
|
||||
<div class="px-6 py-4 border-b border-slate-100 bg-slate-50/50 flex justify-between items-center">
|
||||
<span class="text-[10px] font-black uppercase tracking-widest text-slate-400">Edit Category</span>
|
||||
<% if @entry.verified? %>
|
||||
<div class="flex items-center gap-1.5 text-emerald-600">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>
|
||||
<span class="text-xs font-bold">Verified</span>
|
||||
<div class="flex-1 bg-gradient-to-br from-indigo-50 via-white to-purple-50 flex items-center justify-center px-4 py-12">
|
||||
<div class="max-w-2xl w-full">
|
||||
<div class="bg-white rounded-2xl shadow-xl p-8">
|
||||
<div class="text-center mb-8">
|
||||
<div class="flex items-center justify-center gap-3 mb-2">
|
||||
<h1 class="text-3xl font-bold text-gray-900">Edit Entry</h1>
|
||||
<% if @entry.verified? %>
|
||||
<div class="flex items-center gap-1.5 text-emerald-600">
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>
|
||||
<span class="text-sm font-bold">Verified</span>
|
||||
</div>
|
||||
<% else %>
|
||||
<span class="text-sm font-semibold text-amber-600 px-3 py-1 rounded-full bg-amber-50">Unverified</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<p class="text-gray-600">Update the translations and details for this entry.</p>
|
||||
</div>
|
||||
<% else %>
|
||||
<span class="text-xs font-semibold text-amber-600">Unverified</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="p-6 space-y-6">
|
||||
<%= form_with model: @entry, class: "space-y-4" do |form| %>
|
||||
<div>
|
||||
<%= form.label :category, "Category", class: "block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2" %>
|
||||
<%= form.select :category,
|
||||
Entry.categories.keys.map { |key| [key.tr("_", " ").capitalize, key] },
|
||||
{},
|
||||
class: "block w-full border-slate-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" %>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<%= form.submit "Save Category", class: "bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-y-6 gap-x-12">
|
||||
<% supported_languages.each do |language| %>
|
||||
<% translation = entry_translation_for(@entry, language.code) %>
|
||||
<% next if translation.blank? %>
|
||||
<div class="space-y-2">
|
||||
<div class="grid grid-cols-2">
|
||||
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-tight"><%= "#{language.name} (#{language.code.upcase})" %></span>
|
||||
<%= form_with model: @entry, class: "space-y-6" do |f| %>
|
||||
<% if @entry.errors.any? %>
|
||||
<div class="p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<h3 class="font-semibold text-red-800 mb-2">Please fix the following errors:</h3>
|
||||
<ul class="list-disc list-inside text-red-700 text-sm space-y-1">
|
||||
<% @entry.errors.full_messages.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<p class="text-2xl font-semibold text-slate-800"><%= translation %></p>
|
||||
<% end %>
|
||||
|
||||
<div class="space-y-6">
|
||||
<%= render 'entries/form_fields', f: f, category_prompt: false %>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4 pt-4">
|
||||
<%= f.submit "Save Changes", class: "flex-1 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-3 px-6 rounded-lg transition shadow-md hover:shadow-lg" %>
|
||||
<%= link_to "Cancel", entry_path(@entry), class: "flex-1 text-center bg-gray-100 hover:bg-gray-200 text-gray-800 font-semibold py-3 px-6 rounded-lg transition" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if @entry.notes.present? %>
|
||||
<div class="mt-6 pt-5 border-t border-slate-100">
|
||||
<h4 class="text-xs font-bold text-slate-400 uppercase mb-2">Context & Notes</h4>
|
||||
<p class="text-sm text-slate-600 italic"><%= @entry.notes %></p>
|
||||
<div class="mt-6 text-center text-sm text-gray-600">
|
||||
<%= link_to "← Back to entry", entry_path(@entry), class: "text-indigo-600 hover:text-indigo-800 font-semibold" %> •
|
||||
<%= link_to "Back to search", entries_path, class: "text-indigo-600 hover:text-indigo-800 font-semibold" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,26 +1,9 @@
|
||||
<% content_for :title, "Sanasto Wiki" %>
|
||||
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<header class="bg-white border-b border-slate-200">
|
||||
<div class="max-w-7xl mx-auto px-4">
|
||||
<div class="h-16 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xl font-bold tracking-tight text-indigo-600">Sanasto</span>
|
||||
<span class="text-xl font-light text-slate-400">Wiki</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<%= link_to "Download XLSX", download_entries_path(format: :xlsx),
|
||||
class: "text-xs font-bold text-indigo-700 px-3 py-2 rounded-md border border-indigo-200 bg-indigo-50 hover:bg-indigo-100 transition" %>
|
||||
<% if admin? %>
|
||||
<%= link_to "Admin", admin_root_path, class: "bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition" %>
|
||||
<% else %>
|
||||
<%= link_to "Sign In", login_path, class: "bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<%= render "shared/header" %>
|
||||
|
||||
<%= render "shared/notifications" %>
|
||||
|
||||
<div class="flex-1 flex flex-col">
|
||||
<section id="search">
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="max-w-5xl mx-auto px-4 py-8 space-y-6">
|
||||
<main class="max-w-5xl mx-auto px-4 py-8 space-y-6" data-controller="comments">
|
||||
<div>
|
||||
<%= link_to "← Back to search", entries_path, class: "text-sm text-slate-500 hover:text-indigo-600" %>
|
||||
</div>
|
||||
@@ -39,14 +39,16 @@
|
||||
<% next if translation.blank? %>
|
||||
<div class="space-y-2">
|
||||
<div class="grid grid-cols-2">
|
||||
<div>
|
||||
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-tight"><%= "#{language.name} (#{language.code.upcase})" %></span>
|
||||
<% if current_user %>
|
||||
<button data-action="click->comments#open" data-language-code="<%= language.code %>" class="text-[10px] ml-3 font-bold uppercase text-indigo-400 hover:text-indigo-600 transition">
|
||||
Add Comment
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-2xl font-semibold text-slate-800"><%= translation %></p>
|
||||
<% if current_user %>
|
||||
<div id="comment-details-<%= language.code %>">
|
||||
<%= render "entries/language_comment_details", entry: @entry, language_code: language.code %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -79,6 +79,32 @@
|
||||
font-size: 14px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.entry-box {
|
||||
background: #f0fdf4;
|
||||
border-left: 4px solid #10b981;
|
||||
padding: 16px;
|
||||
margin: 20px 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.entry-box h3 {
|
||||
margin: 0 0 12px 0;
|
||||
color: #065f46;
|
||||
font-size: 16px;
|
||||
}
|
||||
.entry-translations {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.entry-translations dt {
|
||||
font-weight: 600;
|
||||
color: #064e3b;
|
||||
}
|
||||
.entry-translations dd {
|
||||
margin: 0;
|
||||
color: #1e293b;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -90,11 +116,53 @@
|
||||
<div class="content">
|
||||
<p class="greeting">Hello <%= @user.name %>,</p>
|
||||
|
||||
<p>
|
||||
The <strong>Sanasto Wiki</strong> let you search and compare, or download, translations across languages used all over the living Christianity.
|
||||
</p>
|
||||
<% if @approved_entry %>
|
||||
<p>
|
||||
Great news! Your entry request has been <strong>approved</strong> and is ready to be published.
|
||||
</p>
|
||||
|
||||
<p>With a login account, you can contribute to this work.</p>
|
||||
<div class="entry-box">
|
||||
<h3>✓ Your Approved Entry</h3>
|
||||
<p style="margin: 0 0 4px 0;"><strong>Category:</strong> <%= @approved_entry.category.to_s.humanize %></p>
|
||||
|
||||
<dl class="entry-translations">
|
||||
<% if @approved_entry.fi.present? %>
|
||||
<dt>🇫🇮 Finnish:</dt>
|
||||
<dd><%= @approved_entry.fi %></dd>
|
||||
<% end %>
|
||||
<% if @approved_entry.en.present? %>
|
||||
<dt>🇬🇧 English:</dt>
|
||||
<dd><%= @approved_entry.en %></dd>
|
||||
<% end %>
|
||||
<% if @approved_entry.sv.present? %>
|
||||
<dt>🇸🇪 Swedish:</dt>
|
||||
<dd><%= @approved_entry.sv %></dd>
|
||||
<% end %>
|
||||
<% if @approved_entry.no.present? %>
|
||||
<dt>🇳🇴 Norwegian:</dt>
|
||||
<dd><%= @approved_entry.no %></dd>
|
||||
<% end %>
|
||||
<% if @approved_entry.ru.present? %>
|
||||
<dt>🇷🇺 Russian:</dt>
|
||||
<dd><%= @approved_entry.ru %></dd>
|
||||
<% end %>
|
||||
<% if @approved_entry.de.present? %>
|
||||
<dt>🇩🇪 German:</dt>
|
||||
<dd><%= @approved_entry.de %></dd>
|
||||
<% end %>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
To complete the process and publish your entry, please accept this invitation to create your account on <strong>Sanasto Wiki</strong>.
|
||||
</p>
|
||||
<% else %>
|
||||
<p>
|
||||
The <strong>Sanasto Wiki</strong> let you search words and expressions you might need for interpretation work in the living Christianity.
|
||||
</p>
|
||||
|
||||
<p>With a login account, you can contribute to this work.</p>
|
||||
<% end %>
|
||||
|
||||
<div class="info-box">
|
||||
<p style="margin: 0;"><strong>Your Account Details:</strong></p>
|
||||
|
||||
@@ -4,9 +4,28 @@ SANASTO WIKI - INVITATION
|
||||
|
||||
Hello <%= @user.name %>,
|
||||
|
||||
<% if @approved_entry %>
|
||||
Great news! Your entry request has been APPROVED and is ready to be published.
|
||||
|
||||
YOUR APPROVED ENTRY
|
||||
-------------------
|
||||
Category: <%= @approved_entry.category.to_s.humanize %>
|
||||
|
||||
Translations:
|
||||
<% if @approved_entry.fi.present? %> • Finnish: <%= @approved_entry.fi %>
|
||||
<% end %><% if @approved_entry.en.present? %> • English: <%= @approved_entry.en %>
|
||||
<% end %><% if @approved_entry.sv.present? %> • Swedish: <%= @approved_entry.sv %>
|
||||
<% end %><% if @approved_entry.no.present? %> • Norwegian: <%= @approved_entry.no %>
|
||||
<% end %><% if @approved_entry.ru.present? %> • Russian: <%= @approved_entry.ru %>
|
||||
<% end %><% if @approved_entry.de.present? %> • German: <%= @approved_entry.de %>
|
||||
<% end %>
|
||||
|
||||
To complete the process and publish your entry, please accept this invitation to create your account on Sanasto Wiki.
|
||||
<% else %>
|
||||
The Sanasto Wiki let you search and compare, or download, translations across languages used all over the living Christianity.
|
||||
|
||||
With a login account, you can contribute to this work.
|
||||
<% end %>
|
||||
|
||||
YOUR ACCOUNT DETAILS
|
||||
--------------------
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= form_with model: @user, url: accept_invitation_path(params[:token]), method: :patch, local: true, class: "space-y-5" do |form| %>
|
||||
<%= form_with model: @user, url: accept_invitation_path(params[:token]), method: :patch, local: true, data: { turbo: false }, class: "space-y-5" do |form| %>
|
||||
<div>
|
||||
<%= form.label :password, "Set Your Password", class: "block text-sm font-medium text-slate-700 mb-2" %>
|
||||
<%= form.password_field :password,
|
||||
|
||||
@@ -16,31 +16,109 @@
|
||||
<header class="bg-white border-b border-slate-200">
|
||||
<div class="max-w-7xl mx-auto px-4">
|
||||
<div class="h-16 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= link_to admin_dashboard_path, class: "flex items-center gap-2" do %>
|
||||
<span class="text-xl font-bold tracking-tight text-indigo-600">Sanasto</span>
|
||||
<span class="text-xl font-light text-slate-400">Admin</span>
|
||||
</div>
|
||||
<nav class="flex items-center gap-3">
|
||||
<% end %>
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
<nav class="hidden md:flex items-center gap-3">
|
||||
<%= link_to "Dashboard", admin_dashboard_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %>
|
||||
<%= link_to "Users", admin_users_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %>
|
||||
<%= link_to "Invitations", admin_invitations_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %>
|
||||
<% requested_count = Entry.requested.count %>
|
||||
<% gap = requested_count.zero? ? '' : 'pr-4' %>
|
||||
<%= link_to admin_requests_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition relative #{gap}" do %>
|
||||
Requests
|
||||
<% if requested_count > 0 %>
|
||||
<span class="absolute -top-1 -right-1 bg-red-500 text-white text-xs font-bold rounded-full h-5 w-5 flex items-center justify-center">
|
||||
<%= requested_count %>
|
||||
</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%= link_to "Back to Site", root_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %>
|
||||
<%= button_to "Log Out", logout_path, method: :delete, form: { data: { turbo: false }, style: "display: inline-block;" }, class: "bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition cursor-pointer" %>
|
||||
<%= button_to "Log Out", logout_path, method: :delete, data: { turbo: false }, form: { style: "display: inline-block;" }, class: "bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition cursor-pointer" %>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile Navigation -->
|
||||
<div class="flex md:hidden items-center gap-3">
|
||||
<span class="text-sm text-slate-600">
|
||||
<%= current_user.name.split.first %>
|
||||
</span>
|
||||
<button type="button" id="admin-mobile-menu-button" class="p-2 text-slate-600 hover:text-indigo-600 focus:outline-none">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu Dropdown -->
|
||||
<div id="admin-mobile-menu" class="hidden md:hidden border-t border-slate-200 py-3">
|
||||
<div class="py-2 px-2 border-b border-slate-200 mb-2">
|
||||
<span class="text-sm font-medium text-slate-900">
|
||||
<%= current_user.name %>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<nav class="flex flex-col space-y-1">
|
||||
<%= link_to "Dashboard", admin_dashboard_path, class: "px-2 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 rounded transition" %>
|
||||
<%= link_to "Users", admin_users_path, class: "px-2 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 rounded transition" %>
|
||||
<%= link_to "Invitations", admin_invitations_path, class: "px-2 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 rounded transition" %>
|
||||
<% requested_count = Entry.requested.count %>
|
||||
<%= link_to admin_requests_path, class: "px-2 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 rounded transition flex items-center justify-between" do %>
|
||||
<span>Requests</span>
|
||||
<% if requested_count > 0 %>
|
||||
<span class="bg-red-500 text-white text-xs font-bold rounded-full h-5 w-5 flex items-center justify-center">
|
||||
<%= requested_count %>
|
||||
</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%= link_to "Back to Site", root_path, class: "px-2 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 rounded transition" %>
|
||||
<%= button_to "Log Out", logout_path, method: :delete, data: { turbo: false }, form: { class: "w-full" }, class: "w-full text-left px-2 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded transition cursor-pointer" %>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Flash messages -->
|
||||
<% if flash.any? %>
|
||||
<div class="max-w-7xl mx-auto px-4 mt-4">
|
||||
<% flash.each do |type, message| %>
|
||||
<div class="<%= type == 'notice' ? 'bg-green-50 border border-green-200 text-green-700' : 'bg-red-50 border border-red-200 text-red-700' %> px-4 py-3 rounded-lg mb-4" role="alert">
|
||||
<span class="block sm:inline"><%= message %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<script>
|
||||
function setupAdminMobileMenu() {
|
||||
const menuButton = document.getElementById('admin-mobile-menu-button');
|
||||
const mobileMenu = document.getElementById('admin-mobile-menu');
|
||||
|
||||
if (menuButton && mobileMenu) {
|
||||
// Remove existing listeners to avoid duplicates
|
||||
const newMenuButton = menuButton.cloneNode(true);
|
||||
menuButton.parentNode.replaceChild(newMenuButton, menuButton);
|
||||
|
||||
newMenuButton.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
mobileMenu.classList.toggle('hidden');
|
||||
});
|
||||
|
||||
// Close menu when clicking outside
|
||||
document.addEventListener('click', function(event) {
|
||||
const isClickInside = newMenuButton.contains(event.target) || mobileMenu.contains(event.target);
|
||||
if (!isClickInside && !mobileMenu.classList.contains('hidden')) {
|
||||
mobileMenu.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Close menu when navigating with Turbo
|
||||
document.addEventListener('turbo:click', function() {
|
||||
if (mobileMenu && !mobileMenu.classList.contains('hidden')) {
|
||||
mobileMenu.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Run on initial load and on Turbo navigation
|
||||
document.addEventListener('DOMContentLoaded', setupAdminMobileMenu);
|
||||
document.addEventListener('turbo:load', setupAdminMobileMenu);
|
||||
</script>
|
||||
|
||||
<%= render "shared/notifications" %>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="flex-1 max-w-7xl w-full mx-auto px-4 py-8">
|
||||
|
||||
@@ -14,9 +14,10 @@
|
||||
<%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %>
|
||||
<%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %>
|
||||
|
||||
<link rel="icon" href="/icon.png" type="image/png">
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/icon.png">
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
<link rel="apple-touch-icon" href="/icon-512.png" />
|
||||
|
||||
<%# Includes all stylesheet files in app/assets/stylesheets %>
|
||||
<%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta content='text/html; charset=UTF-8' http-equiv='Content-Type' />
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #334155;
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.header {
|
||||
background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);
|
||||
color: white;
|
||||
padding: 30px;
|
||||
border-radius: 8px 8px 0 0;
|
||||
text-align: center;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.header p {
|
||||
margin: 8px 0 0 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.content {
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-top: none;
|
||||
padding: 30px;
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
.greeting {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
color: #1e293b;
|
||||
}
|
||||
.button {
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);
|
||||
color: white;
|
||||
padding: 14px 32px;
|
||||
text-decoration: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
margin: 24px 0;
|
||||
text-align: center;
|
||||
}
|
||||
.button:hover {
|
||||
background: linear-gradient(135deg, #4f46e5 0%, #4338ca 100%);
|
||||
}
|
||||
.footer {
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
}
|
||||
.expiry-notice {
|
||||
background: #fef3c7;
|
||||
border-left: 4px solid #f59e0b;
|
||||
padding: 12px;
|
||||
margin: 16px 0;
|
||||
font-size: 14px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.warning-box {
|
||||
background: #fef2f2;
|
||||
border-left: 4px solid #ef4444;
|
||||
padding: 16px;
|
||||
margin: 20px 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Sanasto Wiki</h1>
|
||||
<p>Password Reset Request</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<p class="greeting">Hello <%= @user.name %>,</p>
|
||||
|
||||
<p>
|
||||
We received a request to reset your password for your Sanasto Wiki account.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
If you made this request, click the button below to set a new password:
|
||||
</p>
|
||||
|
||||
<div style="text-align: center;">
|
||||
<%= link_to "Reset My Password", @reset_url, class: "button" %>
|
||||
</div>
|
||||
|
||||
<div class="expiry-notice">
|
||||
This password reset link will expire on <strong><%= @expires_at.strftime("%B %d, %Y at %I:%M %p %Z") %></strong>.
|
||||
</div>
|
||||
|
||||
<p>
|
||||
You can also copy and paste this link into your browser:
|
||||
</p>
|
||||
<p style="word-break: break-all; color: #6366f1; font-size: 14px;">
|
||||
<%= @reset_url %>
|
||||
</p>
|
||||
|
||||
<div class="warning-box">
|
||||
<strong>Didn't request a password reset?</strong>
|
||||
<p style="margin: 8px 0 0 0;">
|
||||
If you didn't make this request, you can safely ignore this email. Your password will remain unchanged.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>
|
||||
For security reasons, this link will only work once and will expire in 1 hour.
|
||||
</p>
|
||||
<p style="margin-top: 12px;">
|
||||
Questions? Reply to this email.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,57 @@
|
||||
<% content_for :title, "Set New Password" %>
|
||||
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<header class="bg-white border-b border-slate-200">
|
||||
<div class="max-w-7xl mx-auto px-4">
|
||||
<div class="h-16 flex items-center">
|
||||
<%= link_to root_path, class: "flex items-center gap-2" do %>
|
||||
<span class="text-xl font-bold tracking-tight text-indigo-600">Sanasto</span>
|
||||
<span class="text-xl font-light text-slate-400">Wiki</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<%= render "shared/notifications" %>
|
||||
|
||||
<div class="flex-1 flex items-center justify-center px-4 py-12 bg-slate-50">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-8">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-slate-900 mb-2">Set new password</h1>
|
||||
<p class="text-sm text-slate-600">Enter your new password below.</p>
|
||||
</div>
|
||||
|
||||
|
||||
<%= form_with url: password_reset_path(params[:token]), method: :patch, local: true, data: { turbo: false }, class: "space-y-5" do |form| %>
|
||||
<div>
|
||||
<%= form.label :password, "New Password", class: "block text-sm font-medium text-slate-700 mb-2" %>
|
||||
<%= form.password_field :password,
|
||||
autofocus: true,
|
||||
autocomplete: "new-password",
|
||||
required: true,
|
||||
minlength: 8,
|
||||
placeholder: "••••••••••••",
|
||||
class: "block w-full px-4 py-3 bg-white border border-slate-200 rounded-lg shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition" %>
|
||||
<p class="mt-1 text-xs text-slate-500">Minimum 8 characters</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :password_confirmation, "Confirm New Password", class: "block text-sm font-medium text-slate-700 mb-2" %>
|
||||
<%= form.password_field :password_confirmation,
|
||||
autocomplete: "new-password",
|
||||
required: true,
|
||||
minlength: 8,
|
||||
placeholder: "••••••••••••",
|
||||
class: "block w-full px-4 py-3 bg-white border border-slate-200 rounded-lg shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition" %>
|
||||
</div>
|
||||
|
||||
<div class="pt-2">
|
||||
<%= form.submit "Reset Password",
|
||||
class: "w-full bg-indigo-600 text-white px-4 py-3 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition cursor-pointer" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,54 @@
|
||||
<% content_for :title, "Reset Password" %>
|
||||
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<header class="bg-white border-b border-slate-200">
|
||||
<div class="max-w-7xl mx-auto px-4">
|
||||
<div class="h-16 flex items-center">
|
||||
<%= link_to root_path, class: "flex items-center gap-2" do %>
|
||||
<span class="text-xl font-bold tracking-tight text-indigo-600">Sanasto</span>
|
||||
<span class="text-xl font-light text-slate-400">Wiki</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<%= render "shared/notifications" %>
|
||||
|
||||
<div class="flex-1 flex items-center justify-center px-4 py-12 bg-slate-50">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-8">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-slate-900 mb-2">Reset your password</h1>
|
||||
<p class="text-sm text-slate-600">Enter your email address and we'll send you a link to reset your password.</p>
|
||||
</div>
|
||||
|
||||
|
||||
<%= form_with url: password_resets_path, method: :post, local: true, data: { turbo: false }, class: "space-y-5" do |form| %>
|
||||
<div>
|
||||
<%= form.label :email, "Email", class: "block text-sm font-medium text-slate-700 mb-2" %>
|
||||
<%= form.email_field :email,
|
||||
autofocus: true,
|
||||
autocomplete: "email",
|
||||
required: true,
|
||||
placeholder: "you@example.com",
|
||||
class: "block w-full px-4 py-3 bg-white border border-slate-200 rounded-lg shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition" %>
|
||||
</div>
|
||||
|
||||
<div class="pt-2">
|
||||
<%= form.submit "Send Reset Instructions",
|
||||
class: "w-full bg-indigo-600 text-white px-4 py-3 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition cursor-pointer" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="mt-6 text-center space-y-3">
|
||||
<%= link_to login_path, class: "text-sm text-slate-600 hover:text-indigo-600 transition inline-flex items-center gap-1" do %>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Back to Sign In
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2,12 +2,12 @@
|
||||
"name": "SanastoWiki",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon.png",
|
||||
"src": "/icon-512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
},
|
||||
{
|
||||
"src": "/icon.png",
|
||||
"src": "/icon-512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512",
|
||||
"purpose": "maskable"
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<% content_for :title, "Request a New Entry" %>
|
||||
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<%= render "shared/header", show_request_button: false %>
|
||||
|
||||
<%= render "shared/notifications" %>
|
||||
|
||||
<div class="flex-1 bg-gradient-to-br from-indigo-50 via-white to-purple-50 flex items-center justify-center px-4 py-12">
|
||||
<div class="max-w-2xl w-full">
|
||||
<div class="bg-white rounded-2xl shadow-xl p-8">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">Request a New Entry</h1>
|
||||
<p class="text-gray-600">Is there a word you would like to see in this glossary?</p>
|
||||
</div>
|
||||
|
||||
|
||||
<% if @pending_count && @pending_count > 0 %>
|
||||
<div class="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg text-blue-800">
|
||||
You have <%= @pending_count %> pending <%= "request".pluralize(@pending_count) %> being reviewed.
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= form_with model: @entry, url: requests_path, class: "space-y-6", data: { turbo: false } do |f| %>
|
||||
<% if @entry.errors.any? %>
|
||||
<div class="p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<h3 class="font-semibold text-red-800 mb-2">Please fix the following errors:</h3>
|
||||
<ul class="list-disc list-inside text-red-700 text-sm space-y-1">
|
||||
<% @entry.errors.full_messages.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="space-y-4">
|
||||
<% if current_user %>
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
|
||||
<p class="text-sm text-blue-900">
|
||||
<span class="font-semibold">Submitting as:</span> <%= current_user.name %> (<%= current_user.email %>)
|
||||
</p>
|
||||
</div>
|
||||
<% else %>
|
||||
<div>
|
||||
<%= f.label :name, "Your Name", class: "block text-sm font-semibold text-gray-700 mb-2" %>
|
||||
<%= f.text_field :name, required: true, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :email, "Your Email", class: "block text-sm font-semibold text-gray-700 mb-2" %>
|
||||
<%= f.email_field :email, required: true, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= render 'entries/form_fields', f: f %>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4 pt-4">
|
||||
<%= f.submit "Submit Request", class: "flex-1 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-3 px-6 rounded-lg transition shadow-md hover:shadow-lg" %>
|
||||
<%= link_to "Cancel", root_path, class: "flex-1 text-center bg-gray-100 hover:bg-gray-200 text-gray-800 font-semibold py-3 px-6 rounded-lg transition" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% unless current_user %>
|
||||
<div class="mt-6 text-center text-sm text-gray-600">
|
||||
Already have an account? <%= link_to "Sign in", login_path, class: "text-indigo-600 hover:text-indigo-800 font-semibold" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -12,6 +12,8 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<%= render "shared/notifications" %>
|
||||
|
||||
<div class="flex-1 flex items-center justify-center px-4 py-12 bg-slate-50">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-8">
|
||||
@@ -20,13 +22,8 @@
|
||||
<p class="text-sm text-slate-600">Enter your credentials to continue</p>
|
||||
</div>
|
||||
|
||||
<% if flash[:alert] %>
|
||||
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6" role="alert">
|
||||
<%= flash[:alert] %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= form_with url: login_path, method: :post, local: true, class: "space-y-5" do |form| %>
|
||||
<%= form_with url: login_path, method: :post, local: true, data: { turbo: false }, class: "space-y-5" do |form| %>
|
||||
<div>
|
||||
<%= form.label :email, "Email", class: "block text-sm font-medium text-slate-700 mb-2" %>
|
||||
<%= form.email_field :email,
|
||||
@@ -38,7 +35,10 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :password, "Password", class: "block text-sm font-medium text-slate-700 mb-2" %>
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<%= form.label :password, "Password", class: "block text-sm font-medium text-slate-700" %>
|
||||
<%= link_to "Forgot password?", new_password_reset_path, class: "text-xs text-indigo-600 hover:text-indigo-700 font-medium" %>
|
||||
</div>
|
||||
<%= form.password_field :password,
|
||||
autocomplete: "current-password",
|
||||
required: true,
|
||||
@@ -46,6 +46,11 @@
|
||||
class: "block w-full px-4 py-3 bg-white border border-slate-200 rounded-lg shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition" %>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<%= check_box_tag :remember_me, "1", false, class: "w-4 h-4 text-indigo-600 border-slate-300 rounded focus:ring-2 focus:ring-indigo-500" %>
|
||||
<%= label_tag :remember_me, "Remember me for 2 weeks", class: "ml-2 text-sm text-slate-600" %>
|
||||
</div>
|
||||
|
||||
<div class="pt-2">
|
||||
<%= form.submit "Sign In",
|
||||
class: "w-full bg-indigo-600 text-white px-4 py-3 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition cursor-pointer" %>
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
<header class="bg-white border-b border-slate-200">
|
||||
<div class="max-w-7xl mx-auto px-4">
|
||||
<div class="h-16 flex items-center justify-between">
|
||||
<%= link_to root_path, class: "flex items-center gap-2" do %>
|
||||
<span class="text-xl font-bold tracking-tight text-indigo-600">Sanasto</span>
|
||||
<span class="text-xl font-light text-slate-400">Wiki</span>
|
||||
<% end %>
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
<div class="hidden md:flex items-center gap-3">
|
||||
<% if local_assigns[:show_request_button] != false %>
|
||||
<%= link_to "Request Entry", new_request_path,
|
||||
class: "text-xs font-bold text-emerald-700 px-3 py-2 rounded-md border border-emerald-200 bg-emerald-50 hover:bg-emerald-100 transition" %>
|
||||
<% end %>
|
||||
<% if local_assigns[:show_download_button] != false %>
|
||||
<%= link_to "Download XLSX", download_entries_path(format: :xlsx),
|
||||
class: "text-xs font-bold text-indigo-700 px-3 py-2 rounded-md border border-indigo-200 bg-indigo-50 hover:bg-indigo-100 transition" %>
|
||||
<% end %>
|
||||
<% if local_assigns[:show_browse_button] %>
|
||||
<%= link_to "Browse", entries_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %>
|
||||
<% end %>
|
||||
|
||||
<% if logged_in? %>
|
||||
<div class="flex items-center gap-3 ml-2 pl-3 border-l border-slate-200">
|
||||
<span class="text-sm text-slate-600">
|
||||
<%= current_user.name %>
|
||||
</span>
|
||||
<% if admin? %>
|
||||
<%= link_to "Admin", admin_root_path, class: "bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition" %>
|
||||
<% end %>
|
||||
<%= link_to "Sign Out", logout_path, data: { turbo_method: :delete },
|
||||
class: "text-sm font-medium text-slate-600 hover:text-red-600 transition" %>
|
||||
</div>
|
||||
<% else %>
|
||||
<%= link_to "Sign In", login_path, class: "bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Navigation -->
|
||||
<div class="flex md:hidden items-center gap-3">
|
||||
<% if logged_in? %>
|
||||
<span class="text-sm text-slate-600">
|
||||
<%= current_user.name.split.first %>
|
||||
</span>
|
||||
<% end %>
|
||||
<button type="button" id="mobile-menu-button" class="p-2 text-slate-600 hover:text-indigo-600 focus:outline-none">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu Dropdown -->
|
||||
<div id="mobile-menu" class="hidden md:hidden border-t border-slate-200 py-3">
|
||||
<% if logged_in? %>
|
||||
<div class="py-2 px-2 border-b border-slate-200 mb-2">
|
||||
<span class="text-sm font-medium text-slate-900">
|
||||
<%= current_user.name %>
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<nav class="flex flex-col space-y-1">
|
||||
<% if local_assigns[:show_request_button] != false %>
|
||||
<%= link_to "Request Entry", new_request_path,
|
||||
class: "px-2 py-2 text-sm font-medium text-emerald-700 hover:bg-emerald-50 rounded transition" %>
|
||||
<% end %>
|
||||
<% if local_assigns[:show_browse_button] %>
|
||||
<%= link_to "Browse", entries_path, class: "px-2 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 rounded transition" %>
|
||||
<% end %>
|
||||
|
||||
<% if logged_in? %>
|
||||
<% if admin? %>
|
||||
<%= link_to "Admin", admin_root_path, class: "px-2 py-2 text-sm font-medium text-indigo-700 hover:bg-indigo-50 rounded transition" %>
|
||||
<% end %>
|
||||
<%= link_to "Sign Out", logout_path, data: { turbo_method: :delete },
|
||||
class: "block px-2 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded transition" %>
|
||||
<% else %>
|
||||
<%= link_to "Sign In", login_path, class: "px-2 py-2 text-sm font-medium text-indigo-700 hover:bg-indigo-50 rounded transition" %>
|
||||
<% end %>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<script>
|
||||
function setupMobileMenu() {
|
||||
const menuButton = document.getElementById('mobile-menu-button');
|
||||
const mobileMenu = document.getElementById('mobile-menu');
|
||||
|
||||
if (menuButton && mobileMenu) {
|
||||
// Remove existing listeners to avoid duplicates
|
||||
const newMenuButton = menuButton.cloneNode(true);
|
||||
menuButton.parentNode.replaceChild(newMenuButton, menuButton);
|
||||
|
||||
newMenuButton.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
mobileMenu.classList.toggle('hidden');
|
||||
});
|
||||
|
||||
// Close menu when clicking outside
|
||||
document.addEventListener('click', function(event) {
|
||||
const isClickInside = newMenuButton.contains(event.target) || mobileMenu.contains(event.target);
|
||||
if (!isClickInside && !mobileMenu.classList.contains('hidden')) {
|
||||
mobileMenu.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Close menu when navigating with Turbo
|
||||
document.addEventListener('turbo:click', function() {
|
||||
if (mobileMenu && !mobileMenu.classList.contains('hidden')) {
|
||||
mobileMenu.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Run on initial load and on Turbo navigation
|
||||
document.addEventListener('DOMContentLoaded', setupMobileMenu);
|
||||
document.addEventListener('turbo:load', setupMobileMenu);
|
||||
</script>
|
||||
@@ -0,0 +1,16 @@
|
||||
<% if flash.any? %>
|
||||
<div class="flex justify-center px-4 mt-4">
|
||||
<div class="space-y-2">
|
||||
<% flash.each do |type, message| %>
|
||||
<div class="<%= type == 'notice' ? 'bg-green-50 border border-green-200 text-green-700' : 'bg-red-50 border border-red-200 text-red-700' %> px-6 py-3 rounded-lg relative shadow-sm" role="alert">
|
||||
<span class="block pr-8 text-sm font-medium"><%= message %></span>
|
||||
<button type="button" class="absolute top-0 right-0 mt-2.5 mr-2 text-current opacity-50 hover:opacity-100 transition" onclick="this.parentElement.remove()">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env ruby
|
||||
|
||||
require_relative "../config/environment"
|
||||
require "solid_queue/cli"
|
||||
|
||||
SolidQueue::Cli.start(ARGV)
|
||||
@@ -1,6 +1,7 @@
|
||||
require_relative "boot"
|
||||
|
||||
require "rails/all"
|
||||
require_relative "../lib/middleware/sanasto_cors"
|
||||
|
||||
# Require the gems listed in Gemfile, including any gems
|
||||
# you've limited to :test, :development, or :production.
|
||||
@@ -24,5 +25,7 @@ module SanastoWiki
|
||||
# config.time_zone = "Central Time (US & Canada)"
|
||||
# config.eager_load_paths << Rails.root.join("extras")
|
||||
config.active_record.schema_format = :sql
|
||||
|
||||
config.middleware.insert_before 0, Middleware::SanastoCors
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1 +1 @@
|
||||
1frgw1tdeAMChiF5O/Lr3rhpz/EKVHFcl3fUFqpqRy6m31gTG4B58nR8FCm1rBujjNKZnGRvuGzxkw8rlwJKbSCtmFSQK3XIPQ5PW6XvL8n/9WVR9VBbSQxVQinXol+xp6tKHqUv6/fJRnIcvH0AR38tbeA4a6ByTG1bz62R0YVBCVdPkvB9QvS0TIy4Ks04YTJQvi/WIuBzQ0/2++v9rO9KLlabhDwLnOTYrjRgewlFAIlI8IlRZr6NO3m4yq6LTXD8gJ9rjXLE8Ajh9BFShlXXgAXXN0UsfetSseROvCYXhsFZAAR9pIX65zh8+edtxREmFL2yTXzNwTNpUq788VLGiHBK6mQQxviZGi4URj5zfZ+piXU3GWYcpU3txNGG0vVZYhyvs2sSFk+CQnmFojCuvvq9oNjrtTgE1yi/17SNq93YG7E7m+sFh2Jmc3cN04SPv7XLixKzFAK9028M7uQZvOC6R7Pb2DySvTILbe56CDCvBiTpTCB2--hkQcz/vYM834FRs4--BcXqGn/gjm7peH3bOzjLdA==
|
||||
JC3W3xoTxCvfRD1b4tx56WaFXVOpIE0Vfnd0Pt6KUiCEYgMIcPi5KXYKWGsUIZG3PgWOx4BhZ1HR1TXfm/kB7o1fVdPuiLeWpf+3JKwjg9dU1GBwPSwpH4t4uRgWPAE48h6WCkcy22gRuCWr3LmPcZa3EV7u9tZwjWDwOS/RW2FZkkOu7HGFQKwwcGZKVy0TF6hXxGfJEZMLF+5kWgjii2a1VAI0Dy53UBwlxQ+hSilYlkAEQTYmuRzCUExjQuPB77vdZZb/Fumuqp6GrP8xubczunzaePAmv5EBREUy3xfV71/hwDwZFZDCRQQ4PbVBlTYh/3upfUtjOtG2amnqwOGq2oWP1sFVNZy6ZyicMTTjrVf7oh96vR+tHr3OBg+ZEjYS9nHhUUyUv/OjXh35nHQRH0zbbGqCTngOpyOa6IRZuC83bybNQ3+qXY9lau22LoIc8sxJdSHLQ8ZMEXPYm5f8mo3xTJ0SlY8/3QGW0VcWrEp63gqP0k/3GuoHJbjGWT08kFgh8ynAOaORZyZdVkCdS3KbUeyiNdQV0UgowqwBz4cj9pWQGjY3zU0ngTyTD9fIZ6cfyjDpWHPaXeol+X8=--Jl8cRsZDKG3chvBd--MG6KloWhjRJgvvY/pq/fHQ==
|
||||
@@ -10,15 +10,20 @@ default: &default
|
||||
timeout: 5000
|
||||
|
||||
development:
|
||||
<<: *default
|
||||
database: storage/development.sqlite3
|
||||
primary:
|
||||
<<: *default
|
||||
database: storage/development.sqlite3
|
||||
queue:
|
||||
<<: *default
|
||||
database: storage/development_queue.sqlite3
|
||||
migrations_paths: db/queue_migrate
|
||||
|
||||
# Warning: The database defined as "test" will be erased and
|
||||
# re-generated from your development database when you run "rake".
|
||||
# Do not set this db to the same as development or production.
|
||||
test:
|
||||
<<: *default
|
||||
database: storage/test.sqlite3
|
||||
database: storage/test<%= ENV["TEST_ENV_NUMBER"] %>.sqlite3
|
||||
|
||||
# Store production database in the storage/ directory, which by default
|
||||
# is mounted as a persistent Docker volume in config/deploy.yml.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
service: sanasto-wiki
|
||||
|
||||
# Name of the container image.
|
||||
image: your-username/sanasto-wiki
|
||||
image: soverein/sanasto-wiki
|
||||
|
||||
# Deploy to these servers.
|
||||
servers:
|
||||
@@ -17,7 +17,9 @@ servers:
|
||||
# Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server.
|
||||
proxy:
|
||||
ssl: true
|
||||
host: sanasto.rin.no
|
||||
hosts:
|
||||
- sanasto.rin.no
|
||||
- sanasto.wiki
|
||||
# Kamal proxy will forward to your app on port 3000
|
||||
|
||||
# Credentials for your image host.
|
||||
@@ -42,17 +44,8 @@ env:
|
||||
RAILS_LOG_TO_STDOUT: true
|
||||
RAILS_SERVE_STATIC_FILES: true
|
||||
SOLID_QUEUE_IN_PUMA: true
|
||||
|
||||
# Mail server config
|
||||
SMTP_ADDRESS: mail.soverein.no
|
||||
SMTP_PORT: 587
|
||||
SMTP_DOMAIN: frostshipdesign.no
|
||||
SMTP_AUTHENTICATION: plain
|
||||
SMTP_ENABLE_STARTTLS_AUTO: true
|
||||
secret:
|
||||
- RAILS_MASTER_KEY
|
||||
- SMTP_USERNAME
|
||||
- SMTP_PASSWORD
|
||||
|
||||
# Use persistent storage volume for SQLite database and uploads
|
||||
volumes:
|
||||
|
||||
@@ -31,12 +31,13 @@ Rails.application.configure do
|
||||
# Store uploaded files on the local file system (see config/storage.yml for options).
|
||||
config.active_storage.service = :local
|
||||
|
||||
# Don't care if the mailer can't send.
|
||||
config.action_mailer.raise_delivery_errors = false
|
||||
# Raise delivery errors to see what's happening with email
|
||||
config.action_mailer.raise_delivery_errors = true
|
||||
|
||||
# Make template changes take effect immediately.
|
||||
config.action_mailer.perform_caching = false
|
||||
|
||||
# You can use Mailpit for SMTP in development - https://github.com/axllent/mailpit
|
||||
config.action_mailer.delivery_method = :smtp
|
||||
config.action_mailer.smtp_settings = {
|
||||
address: "localhost",
|
||||
|
||||
@@ -50,23 +50,24 @@ Rails.application.configure do
|
||||
# config.cache_store = :mem_cache_store
|
||||
|
||||
# Replace the default in-process and non-durable queuing backend for Active Job.
|
||||
# config.active_job.queue_adapter = :resque
|
||||
config.active_job.queue_adapter = :solid_queue
|
||||
config.solid_queue.connects_to = { database: { writing: :queue } }
|
||||
|
||||
# Ignore bad email addresses and do not raise email delivery errors.
|
||||
# Set this to true and configure the email server for immediate delivery to raise delivery errors.
|
||||
# config.action_mailer.raise_delivery_errors = false
|
||||
|
||||
# Set host to be used by links generated in mailer templates.
|
||||
config.action_mailer.default_url_options = { host: "example.com" }
|
||||
config.action_mailer.default_url_options = { host: "sanasto.wiki" }
|
||||
|
||||
# Specify outgoing SMTP server. Remember to add smtp/* credentials via bin/rails credentials:edit.
|
||||
# config.action_mailer.smtp_settings = {
|
||||
# user_name: Rails.application.credentials.dig(:smtp, :user_name),
|
||||
# password: Rails.application.credentials.dig(:smtp, :password),
|
||||
# address: "smtp.example.com",
|
||||
# port: 587,
|
||||
# authentication: :plain
|
||||
# }
|
||||
config.action_mailer.smtp_settings = {
|
||||
user_name: Rails.application.credentials.dig(:mail, :username),
|
||||
password: Rails.application.credentials.dig(:mail, :password),
|
||||
address: Rails.application.credentials.dig(:mail, :server),
|
||||
port: 587,
|
||||
authentication: :plain
|
||||
}
|
||||
|
||||
# Enable locale fallbacks for I18n (makes lookups for any locale fall back to
|
||||
# the I18n.default_locale when a translation cannot be found).
|
||||
|
||||
@@ -20,7 +20,7 @@ Rails.application.configure do
|
||||
|
||||
# Show full error reports.
|
||||
config.consider_all_requests_local = true
|
||||
config.cache_store = :null_store
|
||||
config.cache_store = :memory_store
|
||||
|
||||
# Render exception templates for rescuable exceptions and raise for other exceptions.
|
||||
config.action_dispatch.show_exceptions = :rescuable
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Pagy Configuration
|
||||
require "pagy/extras/overflow"
|
||||
|
||||
Pagy::DEFAULT[:items] = 25 # Match current 25 items per page
|
||||
Pagy::DEFAULT[:page_param] = :page
|
||||
Pagy::DEFAULT[:overflow] = :last_page
|
||||
@@ -0,0 +1,3 @@
|
||||
Rswag::Ui.configure do |config|
|
||||
config.swagger_endpoint "/api/swagger", "Sanasto Wiki API"
|
||||
end
|
||||
@@ -0,0 +1,18 @@
|
||||
default: &default
|
||||
dispatchers:
|
||||
- polling_interval: 1
|
||||
batch_size: 500
|
||||
workers:
|
||||
- queues: "*"
|
||||
threads: 3
|
||||
processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %>
|
||||
polling_interval: 0.1
|
||||
|
||||
development:
|
||||
<<: *default
|
||||
|
||||
test:
|
||||
<<: *default
|
||||
|
||||
production:
|
||||
<<: *default
|
||||
@@ -0,0 +1,15 @@
|
||||
# examples:
|
||||
# periodic_cleanup:
|
||||
# class: CleanSoftDeletedRecordsJob
|
||||
# queue: background
|
||||
# args: [ 1000, { batch_size: 500 } ]
|
||||
# schedule: every hour
|
||||
# periodic_cleanup_with_command:
|
||||
# command: "SoftDeletedRecord.due.delete_all"
|
||||
# priority: 2
|
||||
# schedule: at 5am every day
|
||||
|
||||
production:
|
||||
clear_solid_queue_finished_jobs:
|
||||
command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
|
||||
schedule: every hour at minute 12
|
||||
@@ -1,6 +1,10 @@
|
||||
Rails.application.routes.draw do
|
||||
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
|
||||
|
||||
mount Api::Base => "/api"
|
||||
get "/api/swagger", to: "api/swagger#index"
|
||||
mount Rswag::Ui::Engine => "/api"
|
||||
|
||||
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
|
||||
# Can be used by load balancers and uptime monitors to verify that the app is live.
|
||||
get "up" => "rails/health#show", as: :rails_health_check
|
||||
@@ -21,16 +25,34 @@ Rails.application.routes.draw do
|
||||
post "login", to: "sessions#create"
|
||||
delete "logout", to: "sessions#destroy", as: :logout
|
||||
|
||||
# Password reset routes
|
||||
resources :password_resets, only: [ :new, :create ]
|
||||
get "password_resets/:token/edit", to: "password_resets#edit", as: :edit_password_reset
|
||||
patch "password_resets/:token", to: "password_resets#update", as: :password_reset
|
||||
|
||||
# Invitation acceptance routes
|
||||
get "invitations/:token", to: "invitations#show", as: :invitation
|
||||
patch "invitations/:token/accept", to: "invitations#update", as: :accept_invitation
|
||||
|
||||
# Public entry request routes
|
||||
resources :requests, only: [ :new, :create ]
|
||||
|
||||
# Admin namespace
|
||||
namespace :admin do
|
||||
root "dashboard#index"
|
||||
get "dashboard", to: "dashboard#index"
|
||||
resources :users, only: [ :index, :edit, :update, :destroy ]
|
||||
resources :invitations, only: [ :index, :new, :create, :destroy ]
|
||||
resources :invitations, only: [ :index, :new, :create, :destroy ] do
|
||||
member do
|
||||
put :resend
|
||||
end
|
||||
end
|
||||
resources :requests, only: [ :index, :show, :edit, :update ] do
|
||||
member do
|
||||
post :approve
|
||||
delete :reject
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
resources :entries do
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
class AddStatusToEntries < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
add_column :entries, :status, :integer, default: 2, null: false
|
||||
add_index :entries, :status
|
||||
|
||||
# Set all existing entries to status: 2 (active)
|
||||
reversible do |dir|
|
||||
dir.up do
|
||||
execute "UPDATE entries SET status = 2"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
class AddRequestedByToEntries < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
add_column :entries, :requested_by_id, :integer
|
||||
add_foreign_key :entries, :users, column: :requested_by_id
|
||||
add_index :entries, :requested_by_id
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
class AddPasswordResetToUsers < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
add_column :users, :reset_password_token, :string
|
||||
add_column :users, :reset_password_sent_at, :datetime
|
||||
add_index :users, :reset_password_token, unique: true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,7 @@
|
||||
class AddRememberTokenToUsers < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
add_column :users, :remember_token, :string
|
||||
add_column :users, :remember_created_at, :datetime
|
||||
add_index :users, :remember_token, unique: true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,129 @@
|
||||
ActiveRecord::Schema[7.1].define(version: 1) do
|
||||
create_table "solid_queue_blocked_executions", force: :cascade do |t|
|
||||
t.bigint "job_id", null: false
|
||||
t.string "queue_name", null: false
|
||||
t.integer "priority", default: 0, null: false
|
||||
t.string "concurrency_key", null: false
|
||||
t.datetime "expires_at", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.index [ "concurrency_key", "priority", "job_id" ], name: "index_solid_queue_blocked_executions_for_release"
|
||||
t.index [ "expires_at", "concurrency_key" ], name: "index_solid_queue_blocked_executions_for_maintenance"
|
||||
t.index [ "job_id" ], name: "index_solid_queue_blocked_executions_on_job_id", unique: true
|
||||
end
|
||||
|
||||
create_table "solid_queue_claimed_executions", force: :cascade do |t|
|
||||
t.bigint "job_id", null: false
|
||||
t.bigint "process_id"
|
||||
t.datetime "created_at", null: false
|
||||
t.index [ "job_id" ], name: "index_solid_queue_claimed_executions_on_job_id", unique: true
|
||||
t.index [ "process_id", "job_id" ], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id"
|
||||
end
|
||||
|
||||
create_table "solid_queue_failed_executions", force: :cascade do |t|
|
||||
t.bigint "job_id", null: false
|
||||
t.text "error"
|
||||
t.datetime "created_at", null: false
|
||||
t.index [ "job_id" ], name: "index_solid_queue_failed_executions_on_job_id", unique: true
|
||||
end
|
||||
|
||||
create_table "solid_queue_jobs", force: :cascade do |t|
|
||||
t.string "queue_name", null: false
|
||||
t.string "class_name", null: false
|
||||
t.text "arguments"
|
||||
t.integer "priority", default: 0, null: false
|
||||
t.string "active_job_id"
|
||||
t.datetime "scheduled_at"
|
||||
t.datetime "finished_at"
|
||||
t.string "concurrency_key"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index [ "active_job_id" ], name: "index_solid_queue_jobs_on_active_job_id"
|
||||
t.index [ "class_name" ], name: "index_solid_queue_jobs_on_class_name"
|
||||
t.index [ "finished_at" ], name: "index_solid_queue_jobs_on_finished_at"
|
||||
t.index [ "queue_name", "finished_at" ], name: "index_solid_queue_jobs_for_filtering"
|
||||
t.index [ "scheduled_at", "finished_at" ], name: "index_solid_queue_jobs_for_alerting"
|
||||
end
|
||||
|
||||
create_table "solid_queue_pauses", force: :cascade do |t|
|
||||
t.string "queue_name", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.index [ "queue_name" ], name: "index_solid_queue_pauses_on_queue_name", unique: true
|
||||
end
|
||||
|
||||
create_table "solid_queue_processes", force: :cascade do |t|
|
||||
t.string "kind", null: false
|
||||
t.datetime "last_heartbeat_at", null: false
|
||||
t.bigint "supervisor_id"
|
||||
t.integer "pid", null: false
|
||||
t.string "hostname"
|
||||
t.text "metadata"
|
||||
t.datetime "created_at", null: false
|
||||
t.string "name", null: false
|
||||
t.index [ "last_heartbeat_at" ], name: "index_solid_queue_processes_on_last_heartbeat_at"
|
||||
t.index [ "name", "supervisor_id" ], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true
|
||||
t.index [ "supervisor_id" ], name: "index_solid_queue_processes_on_supervisor_id"
|
||||
end
|
||||
|
||||
create_table "solid_queue_ready_executions", force: :cascade do |t|
|
||||
t.bigint "job_id", null: false
|
||||
t.string "queue_name", null: false
|
||||
t.integer "priority", default: 0, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.index [ "job_id" ], name: "index_solid_queue_ready_executions_on_job_id", unique: true
|
||||
t.index [ "priority", "job_id" ], name: "index_solid_queue_poll_all"
|
||||
t.index [ "queue_name", "priority", "job_id" ], name: "index_solid_queue_poll_by_queue"
|
||||
end
|
||||
|
||||
create_table "solid_queue_recurring_executions", force: :cascade do |t|
|
||||
t.bigint "job_id", null: false
|
||||
t.string "task_key", null: false
|
||||
t.datetime "run_at", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.index [ "job_id" ], name: "index_solid_queue_recurring_executions_on_job_id", unique: true
|
||||
t.index [ "task_key", "run_at" ], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true
|
||||
end
|
||||
|
||||
create_table "solid_queue_recurring_tasks", force: :cascade do |t|
|
||||
t.string "key", null: false
|
||||
t.string "schedule", null: false
|
||||
t.string "command", limit: 2048
|
||||
t.string "class_name"
|
||||
t.text "arguments"
|
||||
t.string "queue_name"
|
||||
t.integer "priority", default: 0
|
||||
t.boolean "static", default: true, null: false
|
||||
t.text "description"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index [ "key" ], name: "index_solid_queue_recurring_tasks_on_key", unique: true
|
||||
t.index [ "static" ], name: "index_solid_queue_recurring_tasks_on_static"
|
||||
end
|
||||
|
||||
create_table "solid_queue_scheduled_executions", force: :cascade do |t|
|
||||
t.bigint "job_id", null: false
|
||||
t.string "queue_name", null: false
|
||||
t.integer "priority", default: 0, null: false
|
||||
t.datetime "scheduled_at", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.index [ "job_id" ], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true
|
||||
t.index [ "scheduled_at", "priority", "job_id" ], name: "index_solid_queue_dispatch_all"
|
||||
end
|
||||
|
||||
create_table "solid_queue_semaphores", force: :cascade do |t|
|
||||
t.string "key", null: false
|
||||
t.integer "value", default: 1, null: false
|
||||
t.datetime "expires_at", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index [ "expires_at" ], name: "index_solid_queue_semaphores_on_expires_at"
|
||||
t.index [ "key", "value" ], name: "index_solid_queue_semaphores_on_key_and_value"
|
||||
t.index [ "key" ], name: "index_solid_queue_semaphores_on_key", unique: true
|
||||
end
|
||||
|
||||
add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
|
||||
add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
|
||||
add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
|
||||
add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
|
||||
add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
|
||||
add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
|
||||
end
|
||||
@@ -0,0 +1,61 @@
|
||||
CREATE TABLE IF NOT EXISTS "solid_queue_jobs" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "queue_name" varchar NOT NULL, "class_name" varchar NOT NULL, "arguments" text, "priority" integer DEFAULT 0 NOT NULL, "active_job_id" varchar, "scheduled_at" datetime(6), "finished_at" datetime(6), "concurrency_key" varchar, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL);
|
||||
CREATE INDEX "index_solid_queue_jobs_on_active_job_id" ON "solid_queue_jobs" ("active_job_id") /*application='SanastoWiki'*/;
|
||||
CREATE INDEX "index_solid_queue_jobs_on_class_name" ON "solid_queue_jobs" ("class_name") /*application='SanastoWiki'*/;
|
||||
CREATE INDEX "index_solid_queue_jobs_on_finished_at" ON "solid_queue_jobs" ("finished_at") /*application='SanastoWiki'*/;
|
||||
CREATE INDEX "index_solid_queue_jobs_for_filtering" ON "solid_queue_jobs" ("queue_name", "finished_at") /*application='SanastoWiki'*/;
|
||||
CREATE INDEX "index_solid_queue_jobs_for_alerting" ON "solid_queue_jobs" ("scheduled_at", "finished_at") /*application='SanastoWiki'*/;
|
||||
CREATE TABLE IF NOT EXISTS "solid_queue_pauses" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "queue_name" varchar NOT NULL, "created_at" datetime(6) NOT NULL);
|
||||
CREATE UNIQUE INDEX "index_solid_queue_pauses_on_queue_name" ON "solid_queue_pauses" ("queue_name") /*application='SanastoWiki'*/;
|
||||
CREATE TABLE IF NOT EXISTS "solid_queue_processes" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "kind" varchar NOT NULL, "last_heartbeat_at" datetime(6) NOT NULL, "supervisor_id" bigint, "pid" integer NOT NULL, "hostname" varchar, "metadata" text, "created_at" datetime(6) NOT NULL, "name" varchar NOT NULL);
|
||||
CREATE INDEX "index_solid_queue_processes_on_last_heartbeat_at" ON "solid_queue_processes" ("last_heartbeat_at") /*application='SanastoWiki'*/;
|
||||
CREATE UNIQUE INDEX "index_solid_queue_processes_on_name_and_supervisor_id" ON "solid_queue_processes" ("name", "supervisor_id") /*application='SanastoWiki'*/;
|
||||
CREATE INDEX "index_solid_queue_processes_on_supervisor_id" ON "solid_queue_processes" ("supervisor_id") /*application='SanastoWiki'*/;
|
||||
CREATE TABLE IF NOT EXISTS "solid_queue_recurring_tasks" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "key" varchar NOT NULL, "schedule" varchar NOT NULL, "command" varchar(2048), "class_name" varchar, "arguments" text, "queue_name" varchar, "priority" integer DEFAULT 0, "static" boolean DEFAULT TRUE NOT NULL, "description" text, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL);
|
||||
CREATE UNIQUE INDEX "index_solid_queue_recurring_tasks_on_key" ON "solid_queue_recurring_tasks" ("key") /*application='SanastoWiki'*/;
|
||||
CREATE INDEX "index_solid_queue_recurring_tasks_on_static" ON "solid_queue_recurring_tasks" ("static") /*application='SanastoWiki'*/;
|
||||
CREATE TABLE IF NOT EXISTS "solid_queue_semaphores" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "key" varchar NOT NULL, "value" integer DEFAULT 1 NOT NULL, "expires_at" datetime(6) NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL);
|
||||
CREATE INDEX "index_solid_queue_semaphores_on_expires_at" ON "solid_queue_semaphores" ("expires_at") /*application='SanastoWiki'*/;
|
||||
CREATE INDEX "index_solid_queue_semaphores_on_key_and_value" ON "solid_queue_semaphores" ("key", "value") /*application='SanastoWiki'*/;
|
||||
CREATE UNIQUE INDEX "index_solid_queue_semaphores_on_key" ON "solid_queue_semaphores" ("key") /*application='SanastoWiki'*/;
|
||||
CREATE TABLE IF NOT EXISTS "solid_queue_blocked_executions" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "job_id" bigint NOT NULL, "queue_name" varchar NOT NULL, "priority" integer DEFAULT 0 NOT NULL, "concurrency_key" varchar NOT NULL, "expires_at" datetime(6) NOT NULL, "created_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_4cd34e2228"
|
||||
FOREIGN KEY ("job_id")
|
||||
REFERENCES "solid_queue_jobs" ("id")
|
||||
ON DELETE CASCADE);
|
||||
CREATE INDEX "index_solid_queue_blocked_executions_for_release" ON "solid_queue_blocked_executions" ("concurrency_key", "priority", "job_id") /*application='SanastoWiki'*/;
|
||||
CREATE INDEX "index_solid_queue_blocked_executions_for_maintenance" ON "solid_queue_blocked_executions" ("expires_at", "concurrency_key") /*application='SanastoWiki'*/;
|
||||
CREATE UNIQUE INDEX "index_solid_queue_blocked_executions_on_job_id" ON "solid_queue_blocked_executions" ("job_id") /*application='SanastoWiki'*/;
|
||||
CREATE TABLE IF NOT EXISTS "solid_queue_claimed_executions" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "job_id" bigint NOT NULL, "process_id" bigint, "created_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_9cfe4d4944"
|
||||
FOREIGN KEY ("job_id")
|
||||
REFERENCES "solid_queue_jobs" ("id")
|
||||
ON DELETE CASCADE);
|
||||
CREATE UNIQUE INDEX "index_solid_queue_claimed_executions_on_job_id" ON "solid_queue_claimed_executions" ("job_id") /*application='SanastoWiki'*/;
|
||||
CREATE INDEX "index_solid_queue_claimed_executions_on_process_id_and_job_id" ON "solid_queue_claimed_executions" ("process_id", "job_id") /*application='SanastoWiki'*/;
|
||||
CREATE TABLE IF NOT EXISTS "solid_queue_failed_executions" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "job_id" bigint NOT NULL, "error" text, "created_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_39bbc7a631"
|
||||
FOREIGN KEY ("job_id")
|
||||
REFERENCES "solid_queue_jobs" ("id")
|
||||
ON DELETE CASCADE);
|
||||
CREATE UNIQUE INDEX "index_solid_queue_failed_executions_on_job_id" ON "solid_queue_failed_executions" ("job_id") /*application='SanastoWiki'*/;
|
||||
CREATE TABLE IF NOT EXISTS "solid_queue_ready_executions" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "job_id" bigint NOT NULL, "queue_name" varchar NOT NULL, "priority" integer DEFAULT 0 NOT NULL, "created_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_81fcbd66af"
|
||||
FOREIGN KEY ("job_id")
|
||||
REFERENCES "solid_queue_jobs" ("id")
|
||||
ON DELETE CASCADE);
|
||||
CREATE UNIQUE INDEX "index_solid_queue_ready_executions_on_job_id" ON "solid_queue_ready_executions" ("job_id") /*application='SanastoWiki'*/;
|
||||
CREATE INDEX "index_solid_queue_poll_all" ON "solid_queue_ready_executions" ("priority", "job_id") /*application='SanastoWiki'*/;
|
||||
CREATE INDEX "index_solid_queue_poll_by_queue" ON "solid_queue_ready_executions" ("queue_name", "priority", "job_id") /*application='SanastoWiki'*/;
|
||||
CREATE TABLE IF NOT EXISTS "solid_queue_recurring_executions" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "job_id" bigint NOT NULL, "task_key" varchar NOT NULL, "run_at" datetime(6) NOT NULL, "created_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_318a5533ed"
|
||||
FOREIGN KEY ("job_id")
|
||||
REFERENCES "solid_queue_jobs" ("id")
|
||||
ON DELETE CASCADE);
|
||||
CREATE UNIQUE INDEX "index_solid_queue_recurring_executions_on_job_id" ON "solid_queue_recurring_executions" ("job_id") /*application='SanastoWiki'*/;
|
||||
CREATE UNIQUE INDEX "index_solid_queue_recurring_executions_on_task_key_and_run_at" ON "solid_queue_recurring_executions" ("task_key", "run_at") /*application='SanastoWiki'*/;
|
||||
CREATE TABLE IF NOT EXISTS "solid_queue_scheduled_executions" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "job_id" bigint NOT NULL, "queue_name" varchar NOT NULL, "priority" integer DEFAULT 0 NOT NULL, "scheduled_at" datetime(6) NOT NULL, "created_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_c4316f352d"
|
||||
FOREIGN KEY ("job_id")
|
||||
REFERENCES "solid_queue_jobs" ("id")
|
||||
ON DELETE CASCADE);
|
||||
CREATE UNIQUE INDEX "index_solid_queue_scheduled_executions_on_job_id" ON "solid_queue_scheduled_executions" ("job_id") /*application='SanastoWiki'*/;
|
||||
CREATE INDEX "index_solid_queue_dispatch_all" ON "solid_queue_scheduled_executions" ("scheduled_at", "priority", "job_id") /*application='SanastoWiki'*/;
|
||||
CREATE TABLE IF NOT EXISTS "schema_migrations" ("version" varchar NOT NULL PRIMARY KEY);
|
||||
CREATE TABLE IF NOT EXISTS "ar_internal_metadata" ("key" varchar NOT NULL PRIMARY KEY, "value" varchar, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL);
|
||||
INSERT INTO "schema_migrations" (version) VALUES
|
||||
('1');
|
||||
|
||||
@@ -1,15 +1,5 @@
|
||||
CREATE TABLE IF NOT EXISTS "schema_migrations" ("version" varchar NOT NULL PRIMARY KEY);
|
||||
CREATE TABLE IF NOT EXISTS "ar_internal_metadata" ("key" varchar NOT NULL PRIMARY KEY, "value" varchar, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS "entries" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "category" integer DEFAULT 0 NOT NULL, "fi" varchar, "en" varchar, "sv" varchar, "no" varchar, "ru" varchar, "de" varchar, "notes" text, "verified" boolean DEFAULT FALSE NOT NULL, "created_by_id" integer, "updated_by_id" integer, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_367d1ab731"
|
||||
FOREIGN KEY ("created_by_id")
|
||||
REFERENCES "users" ("id")
|
||||
, CONSTRAINT "fk_rails_6f84c41258"
|
||||
FOREIGN KEY ("updated_by_id")
|
||||
REFERENCES "users" ("id")
|
||||
);
|
||||
CREATE INDEX "index_entries_on_created_by_id" ON "entries" ("created_by_id") /*application='SanastoWiki'*/;
|
||||
CREATE INDEX "index_entries_on_updated_by_id" ON "entries" ("updated_by_id") /*application='SanastoWiki'*/;
|
||||
CREATE INDEX "index_entries_on_category" ON "entries" ("category") /*application='SanastoWiki'*/;
|
||||
CREATE TABLE IF NOT EXISTS "comments" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "user_id" integer NOT NULL, "commentable_type" varchar NOT NULL, "commentable_id" integer NOT NULL, "body" text NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, "language_code" varchar /*application='SanastoWiki'*/, CONSTRAINT "fk_rails_03de2dc08c"
|
||||
FOREIGN KEY ("user_id")
|
||||
REFERENCES "users" ("id")
|
||||
@@ -18,7 +8,7 @@ CREATE INDEX "index_comments_on_user_id" ON "comments" ("user_id") /*application
|
||||
CREATE INDEX "index_comments_on_commentable" ON "comments" ("commentable_type", "commentable_id") /*application='SanastoWiki'*/;
|
||||
CREATE TABLE IF NOT EXISTS "supported_languages" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "code" varchar NOT NULL, "name" varchar NOT NULL, "native_name" varchar NOT NULL, "sort_order" integer DEFAULT 0 NOT NULL, "active" boolean DEFAULT TRUE NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL);
|
||||
CREATE UNIQUE INDEX "index_supported_languages_on_code" ON "supported_languages" ("code") /*application='SanastoWiki'*/;
|
||||
CREATE TABLE IF NOT EXISTS "users" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "password_digest" varchar NOT NULL, "name" varchar, "role" integer DEFAULT 0 NOT NULL, "primary_language" varchar, "invitation_token" varchar, "invitation_sent_at" datetime(6), "invitation_accepted_at" datetime(6), "invited_by_id" integer, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_ae14a5013f"
|
||||
CREATE TABLE IF NOT EXISTS "users" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "password_digest" varchar NOT NULL, "name" varchar, "role" integer DEFAULT 0 NOT NULL, "primary_language" varchar, "invitation_token" varchar, "invitation_sent_at" datetime(6), "invitation_accepted_at" datetime(6), "invited_by_id" integer, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, "reset_password_token" varchar /*application='SanastoWiki'*/, "reset_password_sent_at" datetime(6) /*application='SanastoWiki'*/, "remember_token" varchar /*application='SanastoWiki'*/, "remember_created_at" datetime(6) /*application='SanastoWiki'*/, CONSTRAINT "fk_rails_ae14a5013f"
|
||||
FOREIGN KEY ("invited_by_id")
|
||||
REFERENCES "users" ("id")
|
||||
);
|
||||
@@ -59,28 +49,29 @@ CREATE TABLE IF NOT EXISTS 'entries_fts_data'(id INTEGER PRIMARY KEY, block BLOB
|
||||
CREATE TABLE IF NOT EXISTS 'entries_fts_idx'(segid, term, pgno, PRIMARY KEY(segid, term)) WITHOUT ROWID;
|
||||
CREATE TABLE IF NOT EXISTS 'entries_fts_docsize'(id INTEGER PRIMARY KEY, sz BLOB);
|
||||
CREATE TABLE IF NOT EXISTS 'entries_fts_config'(k PRIMARY KEY, v) WITHOUT ROWID;
|
||||
CREATE TRIGGER entries_fts_after_insert
|
||||
AFTER INSERT ON entries
|
||||
BEGIN
|
||||
INSERT INTO entries_fts(rowid, fi, en, sv, no, ru, de, notes)
|
||||
VALUES (new.id, new.fi, new.en, new.sv, new.no, new.ru, new.de, new.notes);
|
||||
END;
|
||||
CREATE TRIGGER entries_fts_after_update
|
||||
AFTER UPDATE ON entries
|
||||
BEGIN
|
||||
INSERT INTO entries_fts(entries_fts, rowid, fi, en, sv, no, ru, de, notes)
|
||||
VALUES('delete', old.id, old.fi, old.en, old.sv, old.no, old.ru, old.de, old.notes);
|
||||
INSERT INTO entries_fts(rowid, fi, en, sv, no, ru, de, notes)
|
||||
VALUES (new.id, new.fi, new.en, new.sv, new.no, new.ru, new.de, new.notes);
|
||||
END;
|
||||
CREATE TRIGGER entries_fts_after_delete
|
||||
AFTER DELETE ON entries
|
||||
BEGIN
|
||||
INSERT INTO entries_fts(entries_fts, rowid, fi, en, sv, no, ru, de, notes)
|
||||
VALUES('delete', old.id, old.fi, old.en, old.sv, old.no, old.ru, old.de, old.notes);
|
||||
END;
|
||||
CREATE TABLE IF NOT EXISTS "setup_states" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "installed" boolean DEFAULT FALSE NOT NULL, "installed_at" datetime(6), "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS "entries" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "category" integer DEFAULT 0 NOT NULL, "fi" varchar, "en" varchar, "sv" varchar, "no" varchar, "ru" varchar, "de" varchar, "notes" text, "verified" boolean DEFAULT FALSE NOT NULL, "created_by_id" integer, "updated_by_id" integer, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, "status" integer DEFAULT 2 NOT NULL, "requested_by_id" integer, CONSTRAINT "fk_rails_6f84c41258"
|
||||
FOREIGN KEY ("updated_by_id")
|
||||
REFERENCES "users" ("id")
|
||||
, CONSTRAINT "fk_rails_367d1ab731"
|
||||
FOREIGN KEY ("created_by_id")
|
||||
REFERENCES "users" ("id")
|
||||
, CONSTRAINT "fk_rails_4d36fd8a36"
|
||||
FOREIGN KEY ("requested_by_id")
|
||||
REFERENCES "users" ("id")
|
||||
);
|
||||
CREATE INDEX "index_entries_on_created_by_id" ON "entries" ("created_by_id") /*application='SanastoWiki'*/;
|
||||
CREATE INDEX "index_entries_on_updated_by_id" ON "entries" ("updated_by_id") /*application='SanastoWiki'*/;
|
||||
CREATE INDEX "index_entries_on_category" ON "entries" ("category") /*application='SanastoWiki'*/;
|
||||
CREATE INDEX "index_entries_on_status" ON "entries" ("status") /*application='SanastoWiki'*/;
|
||||
CREATE INDEX "index_entries_on_requested_by_id" ON "entries" ("requested_by_id") /*application='SanastoWiki'*/;
|
||||
CREATE UNIQUE INDEX "index_users_on_reset_password_token" ON "users" ("reset_password_token") /*application='SanastoWiki'*/;
|
||||
CREATE UNIQUE INDEX "index_users_on_remember_token" ON "users" ("remember_token") /*application='SanastoWiki'*/;
|
||||
INSERT INTO "schema_migrations" (version) VALUES
|
||||
('20260130080931'),
|
||||
('20260130080745'),
|
||||
('20260129204706'),
|
||||
('20260129204705'),
|
||||
('20260123130957'),
|
||||
('20260123125325'),
|
||||
('20260122131000'),
|
||||
|
||||
@@ -194,3 +194,40 @@ for i in range(10):
|
||||
```
|
||||
```
|
||||
</markdown_spec>
|
||||
|
||||
<test_coverage>
|
||||
## Test Coverage Tracking
|
||||
|
||||
The project uses SimpleCov to track test coverage. Coverage data is stored in `coverage/.resultset.json` after each test run.
|
||||
|
||||
### Coverage Information
|
||||
- **Location**: `coverage/.resultset.json`
|
||||
- **Format**: JSON file with line-by-line coverage data for each Ruby file
|
||||
- **Generated by**: SimpleCov gem (runs automatically with tests)
|
||||
- **HTML Report**: `coverage/index.html` (open in browser for detailed report)
|
||||
|
||||
### Reading Coverage Data
|
||||
|
||||
When checking test coverage:
|
||||
1. Run tests: `bin/rails test`
|
||||
2. Check overall coverage in console output (e.g., "Line Coverage: 85.7% (707 / 825)")
|
||||
3. For detailed per-file coverage: open `coverage/index.html` in a browser
|
||||
4. The `.resultset.json` file contains raw coverage data if programmatic access is needed
|
||||
|
||||
### Coverage Goals
|
||||
- Aim for **80%+ line coverage** for all models and helpers
|
||||
- Controllers should have **all actions tested** (both success and error paths)
|
||||
- Critical business logic should have **100% coverage**
|
||||
|
||||
### Files Excluded from Coverage
|
||||
- `config/` - Configuration files
|
||||
- `db/` - Database migrations and schema
|
||||
- `test/` - Test files themselves
|
||||
- `vendor/` - Third-party code
|
||||
|
||||
### Coverage Notes for Agents
|
||||
- Always run tests after making changes to verify coverage isn't decreasing
|
||||
- If adding new methods/classes, add corresponding tests
|
||||
- Coverage report shows which lines are NOT covered (highlighted in red in HTML report)
|
||||
- Missing coverage often indicates edge cases or error paths not tested
|
||||
</test_coverage>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# Deployment (Kamal)
|
||||
|
||||
We deploy with Kamal. You do NOT need to manually set up web servers or Docker
|
||||
on the VM. `kamal setup` provisions everything.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Local machine
|
||||
- Ruby + Bundler
|
||||
- Docker (for building images)
|
||||
- SSH key access to the VM
|
||||
- Registry credentials (see `config/deploy.yml`)
|
||||
|
||||
### Remote VM
|
||||
- Bare VM with SSH access
|
||||
- Open ports: 22, 80, 443
|
||||
- A domain name pointing at the VM (for SSL)
|
||||
|
||||
## Configure
|
||||
|
||||
Update `config/deploy.yml`:
|
||||
|
||||
```yaml
|
||||
service: sanasto-wiki
|
||||
image: your-registry/sanasto-wiki
|
||||
|
||||
servers:
|
||||
web:
|
||||
- your-server-ip
|
||||
|
||||
proxy:
|
||||
ssl: true
|
||||
host: sanasto.example.com
|
||||
|
||||
registry:
|
||||
server: ghcr.io
|
||||
username: your-github-username
|
||||
|
||||
ssh:
|
||||
user: deploy
|
||||
```
|
||||
|
||||
Set the registry password:
|
||||
|
||||
```bash
|
||||
export KAMAL_REGISTRY_PASSWORD="your-token"
|
||||
```
|
||||
|
||||
Make sure `config/master.key` is present locally.
|
||||
|
||||
## First deploy
|
||||
|
||||
```bash
|
||||
bundle exec kamal setup
|
||||
```
|
||||
|
||||
## Regular deploys
|
||||
|
||||
```bash
|
||||
bundle exec kamal deploy
|
||||
```
|
||||
|
||||
## Useful commands
|
||||
|
||||
```bash
|
||||
bundle exec kamal app logs --follow
|
||||
bundle exec kamal app details
|
||||
bundle exec kamal app exec --interactive --reuse "bin/rails console"
|
||||
bundle exec kamal rollback
|
||||
```
|
||||
|
||||
## Initial app setup
|
||||
|
||||
Visit `https://your-domain/setup` once after the first deploy to create the
|
||||
admin account.
|
||||
@@ -2,14 +2,15 @@
|
||||
|
||||
## Authentication & Authorization
|
||||
|
||||
- [ ] **Authentication system**
|
||||
- [x] **Authentication system**
|
||||
- [x] Sessions controller and views (login/logout)
|
||||
- [x] Email/password authentication with session management
|
||||
- [x] Login redirects (admin vs regular users)
|
||||
- [x] Logout functionality
|
||||
- [ ] Password reset flow
|
||||
- [ ] Rate limiting on login attempts
|
||||
- [ ] Session management (remember me, session timeout)
|
||||
- [x] Password reset flow (email-based, 1 hour expiry)
|
||||
- [x] Rate limiting on login attempts (5 attempts, 15 minute lockout)
|
||||
- [x] Session management (remember me for 2 weeks, 30 minute timeout)
|
||||
- [x] Sign in status in the site header
|
||||
- [x] **Invitation system**
|
||||
- [x] Invitations controller (create, list, cancel)
|
||||
- [x] Invitation token generation
|
||||
@@ -49,11 +50,11 @@
|
||||
- [x] **Live search** (AJAX updates without page reload)
|
||||
|
||||
### Entry Management
|
||||
- [ ] **Create entry form** with all language fields
|
||||
- [ ] **Edit entry form** (contributors only)
|
||||
- [x] **Create entry form** with all language fields
|
||||
- [x] **Edit entry form** (contributors only)
|
||||
- [ ] **Delete entries** (admin only, with confirmation)
|
||||
- [ ] **Bulk actions** (select multiple, bulk edit/delete)
|
||||
- [ ] **Entry validation** (at least one language required, category required)
|
||||
- [x] **Entry validation** (at least one language required, category required)
|
||||
|
||||
### Suggested Meanings
|
||||
- [ ] **Submit alternative translation** form
|
||||
@@ -63,7 +64,7 @@
|
||||
- [ ] **Notifications** when suggestions are reviewed
|
||||
|
||||
### Comments & Discussion
|
||||
- [ ] **Add comment** form on entry view
|
||||
- [x] **Add comment** form on entry view
|
||||
- [ ] **Edit/delete own comments**
|
||||
- [ ] **Comment threading** (optional: replies to comments)
|
||||
- [ ] **Comment notifications** for entry contributors
|
||||
@@ -108,14 +109,29 @@
|
||||
|
||||
## Testing
|
||||
|
||||
- [ ] **Controller tests** for all actions
|
||||
- [ ] **System tests** for critical user flows
|
||||
- [ ] Public browsing and search
|
||||
- [ ] Contributor creates/edits entry
|
||||
- [ ] Reviewer workflow
|
||||
- [ ] Admin user management
|
||||
- [ ] **Integration tests** for authentication flows
|
||||
- [ ] **Performance tests** for search queries
|
||||
- [x] **Controller tests** for all actions
|
||||
- [x] EntriesController (index, show, edit, update, download, filters, search)
|
||||
- [x] PasswordResetsController (new, create, edit, update, token validation)
|
||||
- [x] Existing tests: Sessions, Invitations, Setup, Admin controllers, Comments, Requests
|
||||
- [x] **System tests** for critical user flows
|
||||
- [x] Public browsing and search
|
||||
- [x] Contributor creates/edits entry
|
||||
- [ ] Reviewer workflow (pending feature implementation)
|
||||
- [x] Admin user management
|
||||
- [x] **Integration tests** for authentication flows
|
||||
- [x] Sign in/sign out flows
|
||||
- [x] Remember me functionality
|
||||
- [x] Session timeout
|
||||
- [x] Rate limiting
|
||||
- [x] Password reset flow
|
||||
- [x] Invitation acceptance flow
|
||||
- [x] **Performance tests** for search queries
|
||||
- [x] Full text search benchmarks
|
||||
- [x] Language-specific search
|
||||
- [x] Alphabetical browsing
|
||||
- [x] Category filtering
|
||||
- [x] Combined filters
|
||||
- [x] XLSX download performance
|
||||
|
||||
## Deployment
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/bin/bash
|
||||
# Pre-commit hook that runs rubocop on staged Ruby files
|
||||
|
||||
echo "Running rubocop on staged files..."
|
||||
|
||||
# Get list of staged Ruby files
|
||||
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.rb$|\.rake$')
|
||||
|
||||
# If no Ruby files are staged, exit successfully
|
||||
if [ -z "$STAGED_FILES" ]; then
|
||||
echo "No Ruby files staged, skipping rubocop."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Run rubocop on staged files
|
||||
echo "$STAGED_FILES" | xargs bundle exec rubocop --force-exclusion
|
||||
|
||||
RUBOCOP_EXIT=$?
|
||||
|
||||
# If rubocop failed, prevent commit
|
||||
if [ $RUBOCOP_EXIT -ne 0 ]; then
|
||||
echo ""
|
||||
echo "❌ Rubocop found issues. Please fix them before committing."
|
||||
echo " You can run 'bundle exec rubocop -A' to auto-fix some issues."
|
||||
echo " To skip this hook, use 'git commit --no-verify'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Rubocop passed!"
|
||||
exit 0
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/bin/bash
|
||||
# Pre-push hook that runs security scans (brakeman + bundler-audit)
|
||||
|
||||
echo "Running security scans..."
|
||||
echo ""
|
||||
|
||||
# Run brakeman
|
||||
echo "🔍 Running brakeman..."
|
||||
bundle exec brakeman --no-pager --quiet
|
||||
|
||||
BRAKEMAN_EXIT=$?
|
||||
|
||||
if [ $BRAKEMAN_EXIT -ne 0 ]; then
|
||||
echo ""
|
||||
echo "❌ Brakeman found security issues."
|
||||
echo " Run 'bundle exec brakeman' for detailed output."
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Run bundler-audit
|
||||
echo "🔍 Running bundler-audit..."
|
||||
bundle exec bundler-audit check --update
|
||||
|
||||
BUNDLER_AUDIT_EXIT=$?
|
||||
|
||||
if [ $BUNDLER_AUDIT_EXIT -ne 0 ]; then
|
||||
echo ""
|
||||
echo "❌ Bundler-audit found vulnerable dependencies."
|
||||
echo " Run 'bundle exec bundler-audit check' for detailed output."
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# If either scan failed, prevent push
|
||||
if [ $BRAKEMAN_EXIT -ne 0 ] || [ $BUNDLER_AUDIT_EXIT -ne 0 ]; then
|
||||
echo "❌ Security scans failed. Please fix the issues before pushing."
|
||||
echo " To skip this hook, use 'git push --no-verify'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✅ All security scans passed!"
|
||||
exit 0
|
||||
@@ -0,0 +1,86 @@
|
||||
module Middleware
|
||||
class SanastoCors
|
||||
ALLOWED_APP_ID = ENV.fetch("SANASTO_APP_ID", "app.sanasto").freeze
|
||||
APP_ID_HEADER = "HTTP_X_SANASTO_APP"
|
||||
|
||||
def initialize(app)
|
||||
@app = app
|
||||
end
|
||||
|
||||
def call(env)
|
||||
if allow_cors_for?(env)
|
||||
if env["REQUEST_METHOD"] == "OPTIONS"
|
||||
return preflight_response(env["HTTP_ORIGIN"], allowed_request_headers(env))
|
||||
end
|
||||
end
|
||||
|
||||
status, headers, body = @app.call(env)
|
||||
if allow_cors_for?(env)
|
||||
apply_cors_headers(headers, env["HTTP_ORIGIN"])
|
||||
end
|
||||
[ status, headers, body ]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def allow_cors_for?(env)
|
||||
origin = env["HTTP_ORIGIN"].to_s
|
||||
return false if origin.empty?
|
||||
|
||||
if env["REQUEST_METHOD"] == "OPTIONS"
|
||||
return preflight_includes_app_id_header?(env)
|
||||
end
|
||||
|
||||
app_id = env[APP_ID_HEADER].to_s
|
||||
return false if app_id.empty?
|
||||
|
||||
app_id == ALLOWED_APP_ID
|
||||
end
|
||||
|
||||
def preflight_includes_app_id_header?(env)
|
||||
access_control_headers = env["HTTP_ACCESS_CONTROL_REQUEST_HEADERS"].to_s
|
||||
return false if access_control_headers.empty?
|
||||
|
||||
access_control_headers
|
||||
.split(",")
|
||||
.map { |header_name| header_name.strip.downcase }
|
||||
.include?("x-sanasto-app")
|
||||
end
|
||||
|
||||
def allowed_request_headers(env)
|
||||
access_control_headers = env["HTTP_ACCESS_CONTROL_REQUEST_HEADERS"].to_s
|
||||
return default_allowed_headers if access_control_headers.empty?
|
||||
|
||||
sanitized = access_control_headers
|
||||
.split(",")
|
||||
.map { |header_name| header_name.strip }
|
||||
.reject(&:empty?)
|
||||
.join(", ")
|
||||
|
||||
sanitized.empty? ? default_allowed_headers : sanitized
|
||||
end
|
||||
|
||||
def preflight_response(origin, allowed_headers)
|
||||
headers = {}
|
||||
apply_cors_headers(headers, origin, allowed_headers)
|
||||
headers["Access-Control-Max-Age"] = "86400"
|
||||
headers["Vary"] = [
|
||||
headers["Vary"],
|
||||
"Access-Control-Request-Headers",
|
||||
"Access-Control-Request-Method"
|
||||
].compact.join(", ")
|
||||
[ 204, headers, [] ]
|
||||
end
|
||||
|
||||
def apply_cors_headers(headers, origin, allowed_headers = default_allowed_headers)
|
||||
headers["Access-Control-Allow-Origin"] = origin
|
||||
headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE, OPTIONS"
|
||||
headers["Access-Control-Allow-Headers"] = allowed_headers
|
||||
headers["Vary"] = [ headers["Vary"], "Origin, X-Sanasto-App" ].compact.join(", ")
|
||||
end
|
||||
|
||||
def default_allowed_headers
|
||||
"Origin, Content-Type, Accept, Authorization, X-Sanasto-App"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 122 B After Width: | Height: | Size: 23 KiB |
@@ -1 +1,3 @@
|
||||
# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
|
||||
# Disallow all robots from all pages
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
# Setup script to install git hooks via symlinks
|
||||
|
||||
echo "Installing git hooks..."
|
||||
|
||||
# Create symlinks from .git/hooks to docs/hooks
|
||||
ln -sf ../../docs/hooks/pre-commit .git/hooks/pre-commit
|
||||
ln -sf ../../docs/hooks/pre-push .git/hooks/pre-push
|
||||
|
||||
echo "✅ Git hooks installed successfully!"
|
||||
echo ""
|
||||
echo "Installed hooks (via symlinks):"
|
||||
echo " • pre-commit: Runs rubocop on staged files"
|
||||
echo " • pre-push: Runs brakeman + bundler-audit security scans"
|
||||
echo ""
|
||||
echo "See docs/GIT_HOOKS.md for more information."
|
||||
@@ -0,0 +1,141 @@
|
||||
# Entry Request System Test Coverage
|
||||
|
||||
## Test Files Created
|
||||
|
||||
### 1. Model Tests: `test/models/entry_request_test.rb`
|
||||
Tests for Entry model enhancements:
|
||||
- ✅ Validates that at least one translation is required
|
||||
- ✅ Entry status defaults to "active"
|
||||
- ✅ Scopes (requested, approved, active_entries) work correctly
|
||||
- ✅ requested_by association functions properly
|
||||
- ✅ Status transitions (requested → approved → active)
|
||||
- ✅ Blank translations are properly handled
|
||||
|
||||
**13 tests, 40 assertions**
|
||||
|
||||
### 2. Public Controller Tests: `test/controllers/requests_controller_test.rb`
|
||||
Tests for public entry request submission:
|
||||
- ✅ Shows new request form for anonymous users (with name/email fields)
|
||||
- ✅ Shows new request form for logged-in users (without name/email fields)
|
||||
- ✅ Creates entry request with valid data
|
||||
- ✅ Requires at least one translation
|
||||
- ✅ Redirects to login if email already exists with accepted invitation
|
||||
- ✅ Shows pending count for email with existing requests
|
||||
- ✅ Creates entry with single or multiple translations
|
||||
- ✅ Logged-in user can submit request without providing name/email
|
||||
- ✅ Does not modify existing user when they submit request
|
||||
- ✅ Reuses existing pending user without modifying them
|
||||
- ✅ Transaction rollback on validation failure
|
||||
|
||||
**11 tests, 78 assertions**
|
||||
|
||||
### 3. Admin Controller Tests: `test/controllers/admin/requests_controller_test.rb`
|
||||
Tests for admin request management:
|
||||
- ✅ Requires admin authentication
|
||||
- ✅ Shows requests index with requested and approved sections
|
||||
- ✅ Lists requested and approved entries
|
||||
- ✅ Shows request details
|
||||
- ✅ Shows edit form for requested entry
|
||||
- ✅ Updates entry details
|
||||
- ✅ Validates entry data on update
|
||||
- ✅ Approves request and sends invitation email
|
||||
- ✅ Rejects request and deletes entry/user
|
||||
- ✅ Preserves user if they have multiple entries
|
||||
- ✅ Blocks access for non-admin users (contributors, reviewers)
|
||||
|
||||
**14 tests, 85 assertions**
|
||||
|
||||
### 4. Integration Tests: `test/integration/entry_request_flow_test.rb`
|
||||
Full end-to-end flow tests:
|
||||
- ✅ Complete flow: request → admin approve → user accepts → entry active
|
||||
- ✅ Rejected request removes entry and user
|
||||
- ✅ Requested/approved entries not visible on public site
|
||||
- ✅ Multiple entries by same requester all activated on invitation acceptance
|
||||
- ✅ Admin can edit entry details before approval
|
||||
- ✅ Cannot submit request with existing user email
|
||||
|
||||
**6 tests**
|
||||
|
||||
## Fixtures Added
|
||||
|
||||
### Updated: `test/fixtures/entries.yml`
|
||||
- Added `status: 2` (active) to existing entries
|
||||
- Added `requested_entry` fixture (status: requested)
|
||||
- Added `approved_entry` fixture (status: approved)
|
||||
|
||||
### Updated: `test/fixtures/users.yml`
|
||||
- Added `requester_user` fixture (user without accepted invitation)
|
||||
|
||||
### 5. Mailer Tests: `test/mailers/invitation_mailer_test.rb`
|
||||
Invitation email tests including entry approval notifications:
|
||||
- ✅ Sends email with correct details
|
||||
- ✅ Includes invitation link and expiry date
|
||||
- ✅ Has both HTML and text parts
|
||||
- ✅ **With approved entry: includes entry details in email**
|
||||
- ✅ **With approved entry: shows correct message and formatting**
|
||||
- ✅ Without approved entry: uses standard invitation message
|
||||
|
||||
**7 tests, 38 assertions**
|
||||
|
||||
## Test Summary
|
||||
|
||||
**Total: 51 tests, 316 assertions**
|
||||
|
||||
All tests passing ✅
|
||||
|
||||
Full test suite: **131 tests, 566 assertions** ✅
|
||||
|
||||
## Running the Tests
|
||||
|
||||
Run all request-related tests:
|
||||
```bash
|
||||
bin/rails test test/models/entry_request_test.rb \
|
||||
test/controllers/requests_controller_test.rb \
|
||||
test/controllers/admin/requests_controller_test.rb \
|
||||
test/integration/entry_request_flow_test.rb
|
||||
```
|
||||
|
||||
Run individual test files:
|
||||
```bash
|
||||
bin/rails test test/models/entry_request_test.rb
|
||||
bin/rails test test/controllers/requests_controller_test.rb
|
||||
bin/rails test test/controllers/admin/requests_controller_test.rb
|
||||
bin/rails test test/integration/entry_request_flow_test.rb
|
||||
```
|
||||
|
||||
Run specific test:
|
||||
```bash
|
||||
bin/rails test test/integration/entry_request_flow_test.rb:3
|
||||
```
|
||||
|
||||
## Test Coverage Areas
|
||||
|
||||
### Public Request Flow
|
||||
- Form display and validation (different for logged-in vs anonymous users)
|
||||
- User and entry creation
|
||||
- Email duplicate detection for active accounts
|
||||
- Logged-in users submit without providing name/email
|
||||
- Existing users are never modified during request submission
|
||||
- Existing pending users are reused without modification
|
||||
- Transaction safety
|
||||
|
||||
### Admin Management Flow
|
||||
- Authentication and authorization
|
||||
- Request listing and filtering
|
||||
- Request details display
|
||||
- Entry editing before approval
|
||||
- Approval with invitation sending
|
||||
- Rejection with cleanup
|
||||
|
||||
### Integration Flow
|
||||
- Complete user journey from request to active entry
|
||||
- Entry visibility rules (requested/approved not shown publicly)
|
||||
- Multi-entry approval and activation
|
||||
- Admin workflow with editing
|
||||
|
||||
### Edge Cases
|
||||
- Validation failures with transaction rollback
|
||||
- User preservation when they have multiple entries
|
||||
- Expired invitations
|
||||
- Non-admin access attempts
|
||||
- Blank translations handling
|
||||
@@ -0,0 +1,15 @@
|
||||
require "test_helper"
|
||||
require "capybara/rails"
|
||||
require "capybara/minitest"
|
||||
|
||||
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
|
||||
driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ]
|
||||
|
||||
# Helper to login as a user
|
||||
def login_as(user)
|
||||
visit login_path
|
||||
fill_in "Email", with: user.email
|
||||
fill_in "Password", with: "password123456"
|
||||
click_button "Sign In"
|
||||
end
|
||||
end
|
||||
@@ -140,4 +140,404 @@ class Admin::InvitationsControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
assert_redirected_to root_path
|
||||
end
|
||||
|
||||
# Resend action tests
|
||||
test "should resend invitation for pending invitation" do
|
||||
login_as(users(:admin_user))
|
||||
pending_user = users(:pending_invitation)
|
||||
original_token = pending_user.invitation_token
|
||||
original_sent_at = pending_user.invitation_sent_at
|
||||
|
||||
assert_enqueued_emails 1 do
|
||||
put resend_admin_invitation_path(pending_user)
|
||||
end
|
||||
|
||||
assert_redirected_to admin_invitations_path
|
||||
assert_match /Invitation resent to #{pending_user.email}/, flash[:notice]
|
||||
|
||||
pending_user.reload
|
||||
assert_not_equal original_token, pending_user.invitation_token
|
||||
assert_operator pending_user.invitation_sent_at, :>, original_sent_at
|
||||
end
|
||||
|
||||
test "should not resend accepted invitation" do
|
||||
login_as(users(:admin_user))
|
||||
accepted_user = users(:contributor_user)
|
||||
|
||||
assert_enqueued_emails 0 do
|
||||
put resend_admin_invitation_path(accepted_user)
|
||||
end
|
||||
|
||||
assert_redirected_to admin_invitations_path
|
||||
assert_equal "Cannot resend an accepted invitation.", flash[:alert]
|
||||
end
|
||||
|
||||
test "should update invitation_sent_at when resending" do
|
||||
login_as(users(:admin_user))
|
||||
pending_user = users(:pending_invitation)
|
||||
|
||||
freeze_time do
|
||||
put resend_admin_invitation_path(pending_user)
|
||||
|
||||
pending_user.reload
|
||||
assert_in_delta Time.current.to_i, pending_user.invitation_sent_at.to_i, 2
|
||||
end
|
||||
end
|
||||
|
||||
test "should generate new token when resending" do
|
||||
login_as(users(:admin_user))
|
||||
pending_user = users(:pending_invitation)
|
||||
original_token = pending_user.invitation_token
|
||||
|
||||
put resend_admin_invitation_path(pending_user)
|
||||
|
||||
pending_user.reload
|
||||
assert_not_nil pending_user.invitation_token
|
||||
assert_not_equal original_token, pending_user.invitation_token
|
||||
assert pending_user.invitation_token.length >= 32
|
||||
end
|
||||
|
||||
test "should not allow non-admin to resend invitation" do
|
||||
login_as(users(:contributor_user))
|
||||
|
||||
put resend_admin_invitation_path(users(:pending_invitation))
|
||||
|
||||
assert_redirected_to root_path
|
||||
end
|
||||
|
||||
test "should require authentication to resend invitation" do
|
||||
put resend_admin_invitation_path(users(:pending_invitation))
|
||||
|
||||
assert_redirected_to login_path
|
||||
end
|
||||
|
||||
# Index action tests
|
||||
test "should list pending invitations in descending order by sent date" do
|
||||
login_as(users(:admin_user))
|
||||
|
||||
# Create multiple pending invitations
|
||||
user1 = User.create!(email: "user1@example.com", name: "User 1", password: "temp123456789")
|
||||
user1.invite_by!(users(:admin_user))
|
||||
|
||||
user2 = User.create!(email: "user2@example.com", name: "User 2", password: "temp123456789")
|
||||
user2.update_columns(invitation_token: "token2", invitation_sent_at: 1.hour.ago)
|
||||
|
||||
get admin_invitations_path
|
||||
|
||||
assert_response :success
|
||||
assert_select "h3", /Pending Invitations/i
|
||||
# Check both emails appear in the page
|
||||
assert_select "td", text: "user1@example.com"
|
||||
assert_select "td", text: "user2@example.com"
|
||||
end
|
||||
|
||||
test "should display accepted invitations section" do
|
||||
login_as(users(:admin_user))
|
||||
|
||||
get admin_invitations_path
|
||||
|
||||
assert_response :success
|
||||
assert_select "h3", /Recently Accepted/i
|
||||
end
|
||||
|
||||
test "should show pending invitation count badge" do
|
||||
login_as(users(:admin_user))
|
||||
|
||||
get admin_invitations_path
|
||||
|
||||
assert_response :success
|
||||
assert_select "span.bg-yellow-100"
|
||||
end
|
||||
|
||||
test "should display pending invitations table when invitations exist" do
|
||||
login_as(users(:admin_user))
|
||||
|
||||
get admin_invitations_path
|
||||
|
||||
assert_select "table"
|
||||
assert_select "th", text: "Email"
|
||||
assert_select "th", text: "Role"
|
||||
assert_select "th", text: "Sent"
|
||||
end
|
||||
|
||||
# Create action - additional tests
|
||||
test "should create invitation with reviewer role" do
|
||||
login_as(users(:admin_user))
|
||||
|
||||
post admin_invitations_path, params: {
|
||||
user: {
|
||||
email: "reviewer@example.com",
|
||||
name: "New Reviewer",
|
||||
role: "reviewer",
|
||||
primary_language: "en"
|
||||
}
|
||||
}
|
||||
|
||||
new_user = User.find_by(email: "reviewer@example.com")
|
||||
assert_equal "reviewer", new_user.role
|
||||
end
|
||||
|
||||
test "should create invitation with admin role" do
|
||||
login_as(users(:admin_user))
|
||||
|
||||
post admin_invitations_path, params: {
|
||||
user: {
|
||||
email: "admin@example.com",
|
||||
name: "New Admin",
|
||||
role: "admin",
|
||||
primary_language: "en"
|
||||
}
|
||||
}
|
||||
|
||||
new_user = User.find_by(email: "admin@example.com")
|
||||
assert_equal "admin", new_user.role
|
||||
end
|
||||
|
||||
test "should ignore invalid role parameter" do
|
||||
login_as(users(:admin_user))
|
||||
|
||||
post admin_invitations_path, params: {
|
||||
user: {
|
||||
email: "newuser@example.com",
|
||||
name: "New User",
|
||||
role: "superadmin",
|
||||
primary_language: "en"
|
||||
}
|
||||
}
|
||||
|
||||
new_user = User.find_by(email: "newuser@example.com")
|
||||
assert_equal "contributor", new_user.role
|
||||
end
|
||||
|
||||
test "should set invited_by to current admin" do
|
||||
login_as(users(:admin_user))
|
||||
|
||||
post admin_invitations_path, params: {
|
||||
user: {
|
||||
email: "newuser@example.com",
|
||||
name: "New User",
|
||||
role: "contributor",
|
||||
primary_language: "en"
|
||||
}
|
||||
}
|
||||
|
||||
new_user = User.find_by(email: "newuser@example.com")
|
||||
assert_equal users(:admin_user).id, new_user.invited_by_id
|
||||
end
|
||||
|
||||
test "should generate random password for new invitation" do
|
||||
login_as(users(:admin_user))
|
||||
|
||||
post admin_invitations_path, params: {
|
||||
user: {
|
||||
email: "newuser@example.com",
|
||||
name: "New User",
|
||||
role: "contributor",
|
||||
primary_language: "en"
|
||||
}
|
||||
}
|
||||
|
||||
new_user = User.find_by(email: "newuser@example.com")
|
||||
assert new_user.password_digest.present?
|
||||
end
|
||||
|
||||
test "should not create invitation with duplicate email" do
|
||||
login_as(users(:admin_user))
|
||||
existing_user = users(:admin_user)
|
||||
|
||||
assert_no_difference("User.count") do
|
||||
post admin_invitations_path, params: {
|
||||
user: {
|
||||
email: existing_user.email,
|
||||
name: "Duplicate User",
|
||||
role: "contributor",
|
||||
primary_language: "en"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
assert_select "div.text-red-700", text: /already been taken/i
|
||||
end
|
||||
|
||||
test "should create invitation even with blank name" do
|
||||
login_as(users(:admin_user))
|
||||
|
||||
assert_difference("User.count", 1) do
|
||||
post admin_invitations_path, params: {
|
||||
user: {
|
||||
email: "newuser@example.com",
|
||||
name: "",
|
||||
role: "contributor",
|
||||
primary_language: "en"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
assert_redirected_to admin_invitations_path
|
||||
new_user = User.find_by(email: "newuser@example.com")
|
||||
assert_not_nil new_user
|
||||
end
|
||||
|
||||
test "should show success message with email after creating invitation" do
|
||||
login_as(users(:admin_user))
|
||||
|
||||
post admin_invitations_path, params: {
|
||||
user: {
|
||||
email: "newuser@example.com",
|
||||
name: "New User",
|
||||
role: "contributor",
|
||||
primary_language: "en"
|
||||
}
|
||||
}
|
||||
|
||||
assert_redirected_to admin_invitations_path
|
||||
assert_match /Invitation sent to newuser@example\.com/, flash[:notice]
|
||||
end
|
||||
|
||||
# Destroy action - additional tests
|
||||
test "should show success message after cancelling invitation" do
|
||||
login_as(users(:admin_user))
|
||||
|
||||
delete admin_invitation_path(users(:pending_invitation))
|
||||
|
||||
assert_redirected_to admin_invitations_path
|
||||
assert_equal "Invitation cancelled.", flash[:notice]
|
||||
end
|
||||
|
||||
test "should delete user record when cancelling invitation" do
|
||||
login_as(users(:admin_user))
|
||||
pending_user = users(:pending_invitation)
|
||||
user_id = pending_user.id
|
||||
|
||||
delete admin_invitation_path(pending_user)
|
||||
|
||||
assert_nil User.find_by(id: user_id)
|
||||
end
|
||||
|
||||
test "should not allow destroying user with entries" do
|
||||
login_as(users(:admin_user))
|
||||
user_with_entries = users(:contributor_user)
|
||||
|
||||
# This user has accepted invitation and potentially has entries
|
||||
assert_no_difference("User.count") do
|
||||
delete admin_invitation_path(user_with_entries)
|
||||
end
|
||||
|
||||
assert_redirected_to admin_invitations_path
|
||||
assert_equal "Cannot cancel an accepted invitation.", flash[:alert]
|
||||
end
|
||||
|
||||
# Security tests
|
||||
test "should only permit email, name, primary_language, and role params" do
|
||||
login_as(users(:admin_user))
|
||||
|
||||
post admin_invitations_path, params: {
|
||||
user: {
|
||||
email: "newuser@example.com",
|
||||
name: "New User",
|
||||
role: "contributor",
|
||||
primary_language: "en",
|
||||
password: "hackedpassword123",
|
||||
invitation_token: "hacked_token",
|
||||
invitation_accepted_at: Time.current
|
||||
}
|
||||
}
|
||||
|
||||
new_user = User.find_by(email: "newuser@example.com")
|
||||
assert_not_nil new_user
|
||||
# Password should be randomly generated, not from params
|
||||
assert_not new_user.authenticate("hackedpassword123")
|
||||
# Invitation token should be generated by invite_by, not from params
|
||||
assert_not_equal "hacked_token", new_user.invitation_token
|
||||
# Invitation should not be pre-accepted
|
||||
assert_nil new_user.invitation_accepted_at
|
||||
end
|
||||
|
||||
test "should require admin role for all actions" do
|
||||
login_as(users(:reviewer_user))
|
||||
|
||||
# Index
|
||||
get admin_invitations_path
|
||||
assert_redirected_to root_path
|
||||
|
||||
# New
|
||||
get new_admin_invitation_path
|
||||
assert_redirected_to root_path
|
||||
|
||||
# Create
|
||||
post admin_invitations_path, params: { user: { email: "test@example.com", name: "Test" } }
|
||||
assert_redirected_to root_path
|
||||
|
||||
# Resend
|
||||
put resend_admin_invitation_path(users(:pending_invitation))
|
||||
assert_redirected_to root_path
|
||||
|
||||
# Destroy
|
||||
delete admin_invitation_path(users(:pending_invitation))
|
||||
assert_redirected_to root_path
|
||||
end
|
||||
|
||||
# Edge cases
|
||||
test "should handle resending expired invitation" do
|
||||
login_as(users(:admin_user))
|
||||
pending_user = users(:pending_invitation)
|
||||
pending_user.update_columns(invitation_sent_at: 20.days.ago)
|
||||
|
||||
put resend_admin_invitation_path(pending_user)
|
||||
|
||||
assert_redirected_to admin_invitations_path
|
||||
pending_user.reload
|
||||
assert pending_user.invitation_sent_at > 1.hour.ago
|
||||
end
|
||||
|
||||
test "should normalize email to lowercase when creating invitation" do
|
||||
login_as(users(:admin_user))
|
||||
|
||||
post admin_invitations_path, params: {
|
||||
user: {
|
||||
email: "NewUser@Example.COM",
|
||||
name: "New User",
|
||||
role: "contributor",
|
||||
primary_language: "en"
|
||||
}
|
||||
}
|
||||
|
||||
new_user = User.find_by(email: "newuser@example.com")
|
||||
assert_not_nil new_user
|
||||
assert_equal "newuser@example.com", new_user.email
|
||||
end
|
||||
|
||||
test "should handle missing role parameter gracefully" do
|
||||
login_as(users(:admin_user))
|
||||
|
||||
post admin_invitations_path, params: {
|
||||
user: {
|
||||
email: "newuser@example.com",
|
||||
name: "New User",
|
||||
primary_language: "en"
|
||||
}
|
||||
}
|
||||
|
||||
new_user = User.find_by(email: "newuser@example.com")
|
||||
assert_not_nil new_user
|
||||
assert_equal "contributor", new_user.role
|
||||
end
|
||||
|
||||
test "should handle blank role parameter" do
|
||||
login_as(users(:admin_user))
|
||||
|
||||
post admin_invitations_path, params: {
|
||||
user: {
|
||||
email: "newuser@example.com",
|
||||
name: "New User",
|
||||
role: "",
|
||||
primary_language: "en"
|
||||
}
|
||||
}
|
||||
|
||||
new_user = User.find_by(email: "newuser@example.com")
|
||||
assert_not_nil new_user
|
||||
assert_equal "contributor", new_user.role
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
require "test_helper"
|
||||
|
||||
class Admin::RequestsControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
@admin = users(:admin_user)
|
||||
@requested_entry = entries(:requested_entry)
|
||||
@approved_entry = entries(:approved_entry)
|
||||
login_as(@admin)
|
||||
end
|
||||
|
||||
test "should require admin authentication" do
|
||||
logout
|
||||
get admin_requests_path
|
||||
assert_redirected_to login_path
|
||||
end
|
||||
|
||||
test "should show requests index" do
|
||||
get admin_requests_path
|
||||
|
||||
assert_response :success
|
||||
assert_select "h1", "Entry Requests"
|
||||
assert_select "h2", /Pending Review/
|
||||
assert_select "h2", /Approved/
|
||||
end
|
||||
|
||||
test "should list requested entries" do
|
||||
get admin_requests_path
|
||||
|
||||
assert_response :success
|
||||
assert_select "td", text: @requested_entry.fi
|
||||
assert_select "a[href=?]", admin_request_path(@requested_entry)
|
||||
end
|
||||
|
||||
test "should list approved entries" do
|
||||
get admin_requests_path
|
||||
|
||||
assert_response :success
|
||||
assert_select "td", text: @approved_entry.fi
|
||||
end
|
||||
|
||||
test "should show request details" do
|
||||
get admin_request_path(@requested_entry)
|
||||
|
||||
assert_response :success
|
||||
assert_select "h1", "Entry Request Details"
|
||||
assert_select "div", text: @requested_entry.fi
|
||||
assert_select "div", text: @requested_entry.en
|
||||
assert_select "span", text: @requested_entry.requested_by.name
|
||||
end
|
||||
|
||||
test "should show edit form for requested entry" do
|
||||
get edit_admin_request_path(@requested_entry)
|
||||
|
||||
assert_response :success
|
||||
assert_select "h1", "Edit Entry Request"
|
||||
assert_select "form[action=?]", admin_request_path(@requested_entry)
|
||||
assert_select "input[name='entry[fi]'][value=?]", @requested_entry.fi
|
||||
end
|
||||
|
||||
test "should update entry details" do
|
||||
patch admin_request_path(@requested_entry), params: {
|
||||
entry: {
|
||||
category: "phrase",
|
||||
fi: "päivitetty sana",
|
||||
en: "updated word",
|
||||
notes: "Updated notes"
|
||||
}
|
||||
}
|
||||
|
||||
@requested_entry.reload
|
||||
assert_equal "phrase", @requested_entry.category
|
||||
assert_equal "päivitetty sana", @requested_entry.fi
|
||||
assert_equal "updated word", @requested_entry.en
|
||||
assert_equal "Updated notes", @requested_entry.notes
|
||||
assert_redirected_to admin_request_path(@requested_entry)
|
||||
assert_equal "Request updated successfully.", flash[:notice]
|
||||
end
|
||||
|
||||
test "should not update with invalid data" do
|
||||
patch admin_request_path(@requested_entry), params: {
|
||||
entry: {
|
||||
fi: "",
|
||||
en: "",
|
||||
sv: "",
|
||||
no: "",
|
||||
ru: "",
|
||||
de: ""
|
||||
}
|
||||
}
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
@requested_entry.reload
|
||||
assert_equal "testisana", @requested_entry.fi # Unchanged
|
||||
end
|
||||
|
||||
test "should approve request and send invitation" do
|
||||
user = @requested_entry.requested_by
|
||||
assert_nil user.invitation_token
|
||||
assert_nil user.invitation_sent_at
|
||||
|
||||
assert_enqueued_emails 1 do
|
||||
post approve_admin_request_path(@requested_entry)
|
||||
end
|
||||
|
||||
user.reload
|
||||
@requested_entry.reload
|
||||
|
||||
assert_equal "approved", @requested_entry.status
|
||||
assert_not_nil user.invitation_token
|
||||
assert_not_nil user.invitation_sent_at
|
||||
assert_equal @admin, user.invited_by
|
||||
assert_redirected_to admin_requests_path
|
||||
assert_match(/invitation sent/i, flash[:notice])
|
||||
end
|
||||
|
||||
test "should not approve already approved entry" do
|
||||
# Try to approve an already approved entry
|
||||
user = @approved_entry.requested_by
|
||||
|
||||
post approve_admin_request_path(@approved_entry)
|
||||
|
||||
@approved_entry.reload
|
||||
assert_equal "approved", @approved_entry.status
|
||||
end
|
||||
|
||||
test "should reject request and delete entry and user" do
|
||||
user = @requested_entry.requested_by
|
||||
entry_id = @requested_entry.id
|
||||
user_id = user.id
|
||||
|
||||
assert_difference("Entry.count", -1) do
|
||||
assert_difference("User.count", -1) do
|
||||
delete reject_admin_request_path(@requested_entry)
|
||||
end
|
||||
end
|
||||
|
||||
assert_not Entry.exists?(entry_id)
|
||||
assert_not User.exists?(user_id)
|
||||
assert_redirected_to admin_requests_path
|
||||
assert_match(/rejected and deleted/i, flash[:notice])
|
||||
end
|
||||
|
||||
test "should reject but not delete user with multiple entries" do
|
||||
user = @requested_entry.requested_by
|
||||
|
||||
# Create another entry for the same user
|
||||
another_entry = Entry.create!(
|
||||
category: :word,
|
||||
fi: "toinen sana",
|
||||
en: "another word",
|
||||
status: :requested,
|
||||
requested_by: user
|
||||
)
|
||||
|
||||
assert_difference("Entry.count", -1) do
|
||||
assert_no_difference("User.count") do
|
||||
delete reject_admin_request_path(@requested_entry)
|
||||
end
|
||||
end
|
||||
|
||||
assert User.exists?(user.id)
|
||||
assert Entry.exists?(another_entry.id)
|
||||
end
|
||||
|
||||
test "contributors should not access admin requests" do
|
||||
logout
|
||||
contributor = users(:contributor_user)
|
||||
login_as(contributor)
|
||||
|
||||
get admin_requests_path
|
||||
assert_redirected_to root_path
|
||||
assert_match(/administrator/i, flash[:alert])
|
||||
end
|
||||
|
||||
test "reviewers should not access admin requests" do
|
||||
logout
|
||||
reviewer = users(:reviewer_user)
|
||||
login_as(reviewer)
|
||||
|
||||
get admin_requests_path
|
||||
assert_redirected_to root_path
|
||||
assert_match(/administrator/i, flash[:alert])
|
||||
end
|
||||
end
|
||||
@@ -18,6 +18,26 @@ class Admin::UsersControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "should filter users by role" do
|
||||
login_as(users(:admin_user))
|
||||
|
||||
get admin_users_path, params: { role: "reviewer" }
|
||||
|
||||
assert_response :success
|
||||
assert_select "td", text: /#{Regexp.escape(users(:reviewer_user).email)}/
|
||||
assert_select "td", text: /#{Regexp.escape(users(:contributor_user).email)}/, count: 0
|
||||
end
|
||||
|
||||
test "should filter users by email query" do
|
||||
login_as(users(:admin_user))
|
||||
|
||||
get admin_users_path, params: { q: "admin" }
|
||||
|
||||
assert_response :success
|
||||
assert_select "td", text: /#{Regexp.escape(users(:admin_user).email)}/
|
||||
assert_select "td", text: /#{Regexp.escape(users(:contributor_user).email)}/, count: 0
|
||||
end
|
||||
|
||||
test "should get edit page for user when logged in as admin" do
|
||||
login_as(users(:admin_user))
|
||||
get edit_admin_user_path(users(:contributor_user))
|
||||
@@ -35,6 +55,45 @@ class Admin::UsersControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_equal "reviewer", users(:contributor_user).reload.role
|
||||
end
|
||||
|
||||
test "should not allow admin to update own role" do
|
||||
admin_user = users(:admin_user)
|
||||
login_as(admin_user)
|
||||
|
||||
patch admin_user_path(admin_user), params: {
|
||||
user: { role: "reviewer" }
|
||||
}
|
||||
|
||||
assert_redirected_to admin_users_path
|
||||
assert_equal "You cannot modify your own role.", flash[:alert]
|
||||
assert_equal "admin", admin_user.reload.role
|
||||
end
|
||||
|
||||
test "should ignore invalid role updates" do
|
||||
login_as(users(:admin_user))
|
||||
contributor = users(:contributor_user)
|
||||
|
||||
patch admin_user_path(contributor), params: {
|
||||
user: { role: "invalid_role", name: "Updated Name" }
|
||||
}
|
||||
|
||||
assert_redirected_to admin_users_path
|
||||
contributor.reload
|
||||
assert_equal "contributor", contributor.role
|
||||
assert_equal "Updated Name", contributor.name
|
||||
end
|
||||
|
||||
test "should render edit when update is invalid" do
|
||||
login_as(users(:admin_user))
|
||||
contributor = users(:contributor_user)
|
||||
|
||||
patch admin_user_path(contributor), params: {
|
||||
user: { email: "" }
|
||||
}
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
assert_select "li", text: "Email can't be blank"
|
||||
end
|
||||
|
||||
test "should delete user when logged in as admin" do
|
||||
login_as(users(:admin_user))
|
||||
|
||||
@@ -46,6 +105,37 @@ class Admin::UsersControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_redirected_to admin_users_path
|
||||
end
|
||||
|
||||
test "should not allow admin to delete own account" do
|
||||
admin_user = users(:admin_user)
|
||||
login_as(admin_user)
|
||||
|
||||
assert_no_difference("User.count") do
|
||||
delete admin_user_path(admin_user)
|
||||
end
|
||||
|
||||
assert_redirected_to admin_users_path
|
||||
assert_equal "You cannot delete your own account.", flash[:alert]
|
||||
end
|
||||
|
||||
test "should not allow deleting first admin user" do
|
||||
other_admin = User.create!(
|
||||
email: "other-admin@example.com",
|
||||
name: "Other Admin",
|
||||
role: :admin,
|
||||
primary_language: "en",
|
||||
password: "password123456",
|
||||
invitation_accepted_at: Time.current
|
||||
)
|
||||
login_as(other_admin)
|
||||
|
||||
assert_no_difference("User.count") do
|
||||
delete admin_user_path(User.first)
|
||||
end
|
||||
|
||||
assert_redirected_to admin_users_path
|
||||
assert_equal "Cannot delete the first admin user (system default contact).", flash[:alert]
|
||||
end
|
||||
|
||||
test "should not allow non-admin to update user" do
|
||||
login_as(users(:contributor_user))
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
require "test_helper"
|
||||
|
||||
class BotBlockingTest < ActionDispatch::IntegrationTest
|
||||
test "should block GPTBot" do
|
||||
get root_path, headers: { "User-Agent" => "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.3; +https://openai.com/gptbot)" }
|
||||
assert_response :forbidden
|
||||
assert_match(/bot access is not allowed/i, response.body)
|
||||
end
|
||||
|
||||
test "should block ChatGPT bot" do
|
||||
get root_path, headers: { "User-Agent" => "Mozilla/5.0 (compatible; ChatGPT-User/1.0; +https://openai.com/bot)" }
|
||||
assert_response :forbidden
|
||||
end
|
||||
|
||||
test "should block Googlebot" do
|
||||
get root_path, headers: { "User-Agent" => "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" }
|
||||
assert_response :forbidden
|
||||
end
|
||||
|
||||
test "should block Bingbot" do
|
||||
get root_path, headers: { "User-Agent" => "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)" }
|
||||
assert_response :forbidden
|
||||
end
|
||||
|
||||
test "should block generic bot user agent" do
|
||||
get root_path, headers: { "User-Agent" => "SomeBot/1.0" }
|
||||
assert_response :forbidden
|
||||
end
|
||||
|
||||
test "should allow normal browsers" do
|
||||
get root_path, headers: { "User-Agent" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" }
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "should allow Firefox" do
|
||||
get root_path, headers: { "User-Agent" => "Mozilla/5.0 (X11; Linux x86_64; rv:89.0) Gecko/20100101 Firefox/89.0" }
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "should allow Safari" do
|
||||
get root_path, headers: { "User-Agent" => "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Safari/605.1.15" }
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "should block crawler" do
|
||||
get root_path, headers: { "User-Agent" => "SomeCrawler/1.0" }
|
||||
assert_response :forbidden
|
||||
end
|
||||
|
||||
test "should block scraper" do
|
||||
get root_path, headers: { "User-Agent" => "WebScraper/2.0" }
|
||||
assert_response :forbidden
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,177 @@
|
||||
require "test_helper"
|
||||
require "roo"
|
||||
require "tempfile"
|
||||
|
||||
class EntriesControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
@entry = entries(:one)
|
||||
@user = users(:admin_user)
|
||||
end
|
||||
|
||||
# INDEX tests
|
||||
test "should get index" do
|
||||
get entries_url
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "should filter by language" do
|
||||
get entries_url, params: { language: "fi" }
|
||||
assert_response :success
|
||||
assert_select "input[type=hidden][name='language'][value='fi']"
|
||||
end
|
||||
|
||||
test "should filter by category" do
|
||||
get entries_url, params: { category: "word" }
|
||||
assert_response :success
|
||||
assert_select "input[type=hidden][name='category'][value='word']"
|
||||
end
|
||||
|
||||
test "should search with query" do
|
||||
get entries_url, params: { q: "test" }
|
||||
assert_response :success
|
||||
assert_select "input[name='q'][value='test']"
|
||||
end
|
||||
|
||||
test "should filter by starts_with" do
|
||||
get entries_url, params: { starts_with: "a" }
|
||||
assert_response :success
|
||||
assert_select "input[type=hidden][name='starts_with'][value='a']"
|
||||
end
|
||||
|
||||
test "should paginate results" do
|
||||
get entries_url, params: { page: 2 }
|
||||
assert_response :success
|
||||
assert_select "a", text: "Previous"
|
||||
assert_select "a", text: "Next"
|
||||
end
|
||||
|
||||
test "should handle invalid language code" do
|
||||
get entries_url, params: { language: "invalid" }
|
||||
assert_response :success
|
||||
assert_select "input[name='language']", count: 0
|
||||
end
|
||||
|
||||
test "should respond to turbo_stream" do
|
||||
get entries_url, as: :turbo_stream
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "should only show active entries in index" do
|
||||
# Create a requested entry that should not appear
|
||||
requested_entry = Entry.create!(
|
||||
fi: "Requested",
|
||||
category: :word,
|
||||
status: :requested,
|
||||
requested_by: @user
|
||||
)
|
||||
|
||||
get entries_url
|
||||
assert_response :success
|
||||
assert_select "td", text: requested_entry.fi, count: 0
|
||||
end
|
||||
|
||||
# SHOW tests
|
||||
test "should show entry" do
|
||||
get entry_url(@entry)
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "should show entry with comments" do
|
||||
get entry_url(@entry)
|
||||
assert_response :success
|
||||
assert_select "p", text: @entry.fi
|
||||
end
|
||||
|
||||
# EDIT tests
|
||||
test "should get edit" do
|
||||
get edit_entry_url(@entry)
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
# UPDATE tests
|
||||
test "should update entry" do
|
||||
patch entry_url(@entry), params: {
|
||||
entry: {
|
||||
fi: "Updated Finnish",
|
||||
en: "Updated English",
|
||||
category: "word"
|
||||
}
|
||||
}
|
||||
assert_redirected_to entry_url(@entry)
|
||||
@entry.reload
|
||||
assert_equal "Updated Finnish", @entry.fi
|
||||
end
|
||||
|
||||
test "should not update entry with invalid data" do
|
||||
patch entry_url(@entry), params: {
|
||||
entry: {
|
||||
fi: "",
|
||||
en: "",
|
||||
sv: "",
|
||||
no: "",
|
||||
ru: "",
|
||||
de: ""
|
||||
}
|
||||
}
|
||||
assert_response :unprocessable_entity
|
||||
end
|
||||
|
||||
test "should update entry category" do
|
||||
patch entry_url(@entry), params: {
|
||||
entry: { category: "phrase" }
|
||||
}
|
||||
assert_redirected_to entry_url(@entry)
|
||||
@entry.reload
|
||||
assert_equal "phrase", @entry.category
|
||||
end
|
||||
|
||||
# DOWNLOAD tests
|
||||
test "should download xlsx" do
|
||||
get download_entries_url(format: :xlsx)
|
||||
assert_response :success
|
||||
assert_equal "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
response.media_type
|
||||
end
|
||||
|
||||
test "should include active entries in xlsx" do
|
||||
unique_entry = Entry.create!(
|
||||
fi: "UniqueActiveEntry123",
|
||||
category: :word,
|
||||
status: :active
|
||||
)
|
||||
|
||||
get download_entries_url(format: :xlsx)
|
||||
assert_response :success
|
||||
|
||||
Tempfile.create([ "entries", ".xlsx" ]) do |file|
|
||||
file.binmode
|
||||
file.write(response.body)
|
||||
file.flush
|
||||
|
||||
sheet = Roo::Excelx.new(file.path).sheet(0)
|
||||
cell_values = sheet.to_a.flatten.compact
|
||||
assert_includes cell_values, unique_entry.fi
|
||||
end
|
||||
end
|
||||
|
||||
# Statistics tests
|
||||
test "should calculate entry statistics" do
|
||||
get entries_url
|
||||
assert_response :success
|
||||
assert_select "div", text: /entries/i
|
||||
assert_select "div", text: /% complete/i
|
||||
end
|
||||
|
||||
test "should calculate language completion" do
|
||||
get entries_url
|
||||
assert_response :success
|
||||
assert_select "div", text: /% complete/i
|
||||
end
|
||||
|
||||
# Language ordering tests
|
||||
test "should prioritize selected language in display" do
|
||||
get entries_url, params: { language: "fi" }
|
||||
assert_response :success
|
||||
assert_select "th span", text: "FI", count: 1
|
||||
end
|
||||
end
|
||||
@@ -90,4 +90,306 @@ class InvitationsControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_redirected_to root_path
|
||||
assert_equal "Invalid or expired invitation link.", flash[:alert]
|
||||
end
|
||||
|
||||
test "should redirect admin to dashboard after accepting invitation" do
|
||||
inviter = users(:admin_user)
|
||||
pending_admin = User.create!(
|
||||
email: "pending-admin@example.com",
|
||||
name: "Pending Admin",
|
||||
role: :admin,
|
||||
primary_language: "en",
|
||||
invitation_token: "pending_admin_token",
|
||||
invitation_sent_at: 1.day.ago,
|
||||
invited_by: inviter,
|
||||
password: "password123456"
|
||||
)
|
||||
|
||||
patch accept_invitation_path(pending_admin.invitation_token), params: {
|
||||
user: {
|
||||
password: "securepassword123",
|
||||
password_confirmation: "securepassword123"
|
||||
}
|
||||
}
|
||||
|
||||
pending_admin.reload
|
||||
assert_not_nil pending_admin.invitation_accepted_at
|
||||
assert_nil pending_admin.invitation_token
|
||||
assert_equal pending_admin.id, session[:user_id]
|
||||
assert_redirected_to admin_root_path
|
||||
end
|
||||
|
||||
# Entry activation tests
|
||||
test "should activate approved entries on invitation acceptance" do
|
||||
user = users(:pending_invitation)
|
||||
|
||||
# Create approved entry for this user
|
||||
approved_entry = Entry.create!(
|
||||
category: :word,
|
||||
fi: "Test word",
|
||||
status: :approved,
|
||||
requested_by: user
|
||||
)
|
||||
|
||||
# Create requested entry (should not be activated)
|
||||
requested_entry = Entry.create!(
|
||||
category: :word,
|
||||
fi: "Requested word",
|
||||
status: :requested,
|
||||
requested_by: user
|
||||
)
|
||||
|
||||
patch accept_invitation_path(user.invitation_token), params: {
|
||||
user: {
|
||||
password: "securepassword123",
|
||||
password_confirmation: "securepassword123"
|
||||
}
|
||||
}
|
||||
|
||||
approved_entry.reload
|
||||
requested_entry.reload
|
||||
|
||||
assert_equal "active", approved_entry.status
|
||||
assert_equal "requested", requested_entry.status
|
||||
end
|
||||
|
||||
test "should activate multiple approved entries on invitation acceptance" do
|
||||
user = users(:pending_invitation)
|
||||
|
||||
# Create multiple approved entries
|
||||
entries = 3.times.map do |i|
|
||||
Entry.create!(
|
||||
category: :word,
|
||||
fi: "Word #{i}",
|
||||
status: :approved,
|
||||
requested_by: user
|
||||
)
|
||||
end
|
||||
|
||||
patch accept_invitation_path(user.invitation_token), params: {
|
||||
user: {
|
||||
password: "securepassword123",
|
||||
password_confirmation: "securepassword123"
|
||||
}
|
||||
}
|
||||
|
||||
entries.each do |entry|
|
||||
entry.reload
|
||||
assert_equal "active", entry.status
|
||||
end
|
||||
end
|
||||
|
||||
test "should not activate entries for other users" do
|
||||
user = users(:pending_invitation)
|
||||
other_user = users(:admin_user)
|
||||
|
||||
# Create approved entry for another user
|
||||
other_entry = Entry.create!(
|
||||
category: :word,
|
||||
fi: "Other user word",
|
||||
status: :approved,
|
||||
requested_by: other_user
|
||||
)
|
||||
|
||||
patch accept_invitation_path(user.invitation_token), params: {
|
||||
user: {
|
||||
password: "securepassword123",
|
||||
password_confirmation: "securepassword123"
|
||||
}
|
||||
}
|
||||
|
||||
other_entry.reload
|
||||
assert_equal "approved", other_entry.status
|
||||
end
|
||||
|
||||
test "should handle invitation acceptance with no entries" do
|
||||
user = users(:pending_invitation)
|
||||
|
||||
# Ensure the user has no entries
|
||||
Entry.where(requested_by: user).delete_all
|
||||
assert_equal 0, Entry.where(requested_by: user).count
|
||||
|
||||
patch accept_invitation_path(user.invitation_token), params: {
|
||||
user: {
|
||||
password: "securepassword123",
|
||||
password_confirmation: "securepassword123"
|
||||
}
|
||||
}
|
||||
|
||||
assert_redirected_to root_path
|
||||
user.reload
|
||||
assert_not_nil user.invitation_accepted_at
|
||||
end
|
||||
|
||||
# Security tests
|
||||
test "should only permit password parameters" do
|
||||
user = users(:pending_invitation)
|
||||
original_email = user.email
|
||||
original_name = user.name
|
||||
original_role = user.role
|
||||
|
||||
patch accept_invitation_path(user.invitation_token), params: {
|
||||
user: {
|
||||
password: "securepassword123",
|
||||
password_confirmation: "securepassword123",
|
||||
email: "hacker@example.com",
|
||||
name: "Hacker Name",
|
||||
role: "admin"
|
||||
}
|
||||
}
|
||||
|
||||
user.reload
|
||||
assert_equal original_email, user.email
|
||||
assert_equal original_name, user.name
|
||||
assert_equal original_role, user.role
|
||||
end
|
||||
|
||||
test "should not log in user when password validation fails" do
|
||||
user = users(:pending_invitation)
|
||||
|
||||
patch accept_invitation_path(user.invitation_token), params: {
|
||||
user: {
|
||||
password: "short",
|
||||
password_confirmation: "short"
|
||||
}
|
||||
}
|
||||
|
||||
assert_nil session[:user_id]
|
||||
end
|
||||
|
||||
# Edge cases
|
||||
test "should handle already accepted invitation" do
|
||||
user = users(:pending_invitation)
|
||||
user.update!(invitation_accepted_at: Time.current, invitation_token: nil)
|
||||
|
||||
patch accept_invitation_path("some_token"), params: {
|
||||
user: {
|
||||
password: "securepassword123",
|
||||
password_confirmation: "securepassword123"
|
||||
}
|
||||
}
|
||||
|
||||
assert_redirected_to root_path
|
||||
assert_equal "Invalid or expired invitation link.", flash[:alert]
|
||||
end
|
||||
|
||||
test "should set invitation_accepted_at timestamp" do
|
||||
user = users(:pending_invitation)
|
||||
assert_nil user.invitation_accepted_at
|
||||
|
||||
freeze_time do
|
||||
patch accept_invitation_path(user.invitation_token), params: {
|
||||
user: {
|
||||
password: "securepassword123",
|
||||
password_confirmation: "securepassword123"
|
||||
}
|
||||
}
|
||||
|
||||
user.reload
|
||||
assert_in_delta Time.current.to_i, user.invitation_accepted_at.to_i, 2
|
||||
end
|
||||
end
|
||||
|
||||
test "should clear invitation token after acceptance" do
|
||||
user = users(:pending_invitation)
|
||||
token = user.invitation_token
|
||||
assert_not_nil token
|
||||
|
||||
patch accept_invitation_path(token), params: {
|
||||
user: {
|
||||
password: "securepassword123",
|
||||
password_confirmation: "securepassword123"
|
||||
}
|
||||
}
|
||||
|
||||
user.reload
|
||||
assert_nil user.invitation_token
|
||||
end
|
||||
|
||||
test "should require password to be present" do
|
||||
user = users(:pending_invitation)
|
||||
|
||||
patch accept_invitation_path(user.invitation_token), params: {
|
||||
user: {
|
||||
password: nil,
|
||||
password_confirmation: nil
|
||||
}
|
||||
}
|
||||
|
||||
# has_secure_password validates password presence when setting password
|
||||
user.reload
|
||||
assert_nil user.invitation_accepted_at
|
||||
end
|
||||
|
||||
test "should show validation errors for short password" do
|
||||
user = users(:pending_invitation)
|
||||
|
||||
patch accept_invitation_path(user.invitation_token), params: {
|
||||
user: {
|
||||
password: "short",
|
||||
password_confirmation: "short"
|
||||
}
|
||||
}
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
assert_select "div.text-red-700", text: /too short/i
|
||||
end
|
||||
|
||||
test "should show validation errors for mismatched passwords" do
|
||||
user = users(:pending_invitation)
|
||||
|
||||
patch accept_invitation_path(user.invitation_token), params: {
|
||||
user: {
|
||||
password: "securepassword123",
|
||||
password_confirmation: "differentpassword"
|
||||
}
|
||||
}
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
assert_select "div.text-red-700", text: /confirmation/i
|
||||
end
|
||||
|
||||
test "should authenticate with new password after acceptance" do
|
||||
user = users(:pending_invitation)
|
||||
|
||||
patch accept_invitation_path(user.invitation_token), params: {
|
||||
user: {
|
||||
password: "mynewpassword123",
|
||||
password_confirmation: "mynewpassword123"
|
||||
}
|
||||
}
|
||||
|
||||
user.reload
|
||||
assert user.authenticate("mynewpassword123")
|
||||
assert_not user.authenticate("oldpassword")
|
||||
end
|
||||
|
||||
test "should log in user immediately after acceptance" do
|
||||
user = users(:pending_invitation)
|
||||
|
||||
# First visit the invitation page to establish session
|
||||
get invitation_path(user.invitation_token)
|
||||
assert_nil session[:user_id]
|
||||
|
||||
patch accept_invitation_path(user.invitation_token), params: {
|
||||
user: {
|
||||
password: "securepassword123",
|
||||
password_confirmation: "securepassword123"
|
||||
}
|
||||
}
|
||||
|
||||
assert_equal user.id, session[:user_id]
|
||||
end
|
||||
|
||||
test "should show welcome message with user name" do
|
||||
user = users(:pending_invitation)
|
||||
|
||||
patch accept_invitation_path(user.invitation_token), params: {
|
||||
user: {
|
||||
password: "securepassword123",
|
||||
password_confirmation: "securepassword123"
|
||||
}
|
||||
}
|
||||
|
||||
assert_match /Welcome to Sanasto Wiki, #{user.name}!/, flash[:notice]
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
require "test_helper"
|
||||
|
||||
class PasswordResetsControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
@user = users(:admin_user)
|
||||
@user.update!(invitation_accepted_at: Time.current)
|
||||
end
|
||||
|
||||
# NEW tests
|
||||
test "should get new" do
|
||||
get new_password_reset_url
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "should show password reset form" do
|
||||
get new_password_reset_url
|
||||
assert_select "form"
|
||||
assert_select "input[type=email]"
|
||||
end
|
||||
|
||||
# CREATE tests
|
||||
test "should send reset email for existing user" do
|
||||
assert_enqueued_emails 1 do
|
||||
post password_resets_url, params: { email: @user.email }
|
||||
end
|
||||
assert_redirected_to login_url
|
||||
assert_match /password reset instructions/i, flash[:notice]
|
||||
end
|
||||
|
||||
test "should generate reset token for existing user" do
|
||||
post password_resets_url, params: { email: @user.email }
|
||||
@user.reload
|
||||
assert_not_nil @user.reset_password_token
|
||||
assert_not_nil @user.reset_password_sent_at
|
||||
end
|
||||
|
||||
test "should handle non-existent email gracefully" do
|
||||
post password_resets_url, params: { email: "nonexistent@example.com" }
|
||||
assert_redirected_to login_url
|
||||
assert_match /if that email address is in our system/i, flash[:notice]
|
||||
end
|
||||
|
||||
test "should send invitation for user without accepted invitation" do
|
||||
pending_user = users(:pending_invitation)
|
||||
assert_nil pending_user.invitation_accepted_at
|
||||
|
||||
assert_enqueued_emails 1 do
|
||||
post password_resets_url, params: { email: pending_user.email }
|
||||
end
|
||||
assert_redirected_to login_url
|
||||
end
|
||||
|
||||
test "should handle blank email" do
|
||||
post password_resets_url, params: { email: "" }
|
||||
assert_redirected_to login_url
|
||||
end
|
||||
|
||||
test "should handle email with whitespace" do
|
||||
post password_resets_url, params: { email: " #{@user.email} " }
|
||||
@user.reload
|
||||
assert_not_nil @user.reset_password_token
|
||||
end
|
||||
|
||||
# EDIT tests
|
||||
test "should show reset password form with valid token" do
|
||||
@user.update!(
|
||||
reset_password_token: SecureRandom.urlsafe_base64(32),
|
||||
reset_password_sent_at: Time.current
|
||||
)
|
||||
get edit_password_reset_url(@user.reset_password_token)
|
||||
assert_response :success
|
||||
assert_select "form"
|
||||
assert_select "input[type=password]", count: 2
|
||||
end
|
||||
|
||||
test "should reject invalid token" do
|
||||
get edit_password_reset_url("invalid_token")
|
||||
assert_redirected_to login_url
|
||||
assert_match /invalid/i, flash[:alert]
|
||||
end
|
||||
|
||||
test "should reject expired token" do
|
||||
@user.update!(
|
||||
reset_password_token: SecureRandom.urlsafe_base64(32),
|
||||
reset_password_sent_at: 2.hours.ago
|
||||
)
|
||||
get edit_password_reset_url(@user.reset_password_token)
|
||||
assert_redirected_to new_password_reset_url
|
||||
assert_match /expired/i, flash[:alert]
|
||||
end
|
||||
|
||||
# UPDATE tests
|
||||
test "should reset password with valid token" do
|
||||
@user.update!(
|
||||
reset_password_token: SecureRandom.urlsafe_base64(32),
|
||||
reset_password_sent_at: Time.current
|
||||
)
|
||||
|
||||
patch password_reset_url(@user.reset_password_token), params: {
|
||||
password: "newpassword12345",
|
||||
password_confirmation: "newpassword12345"
|
||||
}
|
||||
|
||||
assert_redirected_to root_url
|
||||
assert_match /password has been reset/i, flash[:notice]
|
||||
|
||||
@user.reload
|
||||
assert_nil @user.reset_password_token
|
||||
assert_nil @user.reset_password_sent_at
|
||||
assert @user.authenticate("newpassword12345")
|
||||
end
|
||||
|
||||
test "should auto-login after successful password reset" do
|
||||
@user.update!(
|
||||
reset_password_token: SecureRandom.urlsafe_base64(32),
|
||||
reset_password_sent_at: Time.current
|
||||
)
|
||||
|
||||
patch password_reset_url(@user.reset_password_token), params: {
|
||||
password: "newpassword12345",
|
||||
password_confirmation: "newpassword12345"
|
||||
}
|
||||
|
||||
assert_equal @user.id, session[:user_id]
|
||||
end
|
||||
|
||||
test "should reject mismatched passwords" do
|
||||
@user.update!(
|
||||
reset_password_token: SecureRandom.urlsafe_base64(32),
|
||||
reset_password_sent_at: Time.current
|
||||
)
|
||||
|
||||
patch password_reset_url(@user.reset_password_token), params: {
|
||||
password: "newpassword12345",
|
||||
password_confirmation: "differentpassword"
|
||||
}
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
assert_match /doesn't match/i, flash[:alert]
|
||||
end
|
||||
|
||||
test "should reject blank password" do
|
||||
@user.update!(
|
||||
reset_password_token: SecureRandom.urlsafe_base64(32),
|
||||
reset_password_sent_at: Time.current
|
||||
)
|
||||
|
||||
patch password_reset_url(@user.reset_password_token), params: {
|
||||
password: "",
|
||||
password_confirmation: ""
|
||||
}
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
assert_match /cannot be blank/i, flash[:alert]
|
||||
end
|
||||
|
||||
test "should reject expired token on update" do
|
||||
@user.update!(
|
||||
reset_password_token: SecureRandom.urlsafe_base64(32),
|
||||
reset_password_sent_at: 2.hours.ago
|
||||
)
|
||||
|
||||
patch password_reset_url(@user.reset_password_token), params: {
|
||||
password: "newpassword12345",
|
||||
password_confirmation: "newpassword12345"
|
||||
}
|
||||
|
||||
assert_redirected_to new_password_reset_url
|
||||
assert_match /expired/i, flash[:alert]
|
||||
end
|
||||
|
||||
test "should reject invalid token on update" do
|
||||
patch password_reset_url("invalid_token"), params: {
|
||||
password: "newpassword12345",
|
||||
password_confirmation: "newpassword12345"
|
||||
}
|
||||
|
||||
assert_redirected_to login_url
|
||||
assert_match /invalid/i, flash[:alert]
|
||||
end
|
||||
|
||||
test "should enforce password validations" do
|
||||
@user.update!(
|
||||
reset_password_token: SecureRandom.urlsafe_base64(32),
|
||||
reset_password_sent_at: Time.current
|
||||
)
|
||||
|
||||
# Password too short (less than 12 characters)
|
||||
patch password_reset_url(@user.reset_password_token), params: {
|
||||
password: "short",
|
||||
password_confirmation: "short"
|
||||
}
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,223 @@
|
||||
require "test_helper"
|
||||
|
||||
class RequestsControllerTest < ActionDispatch::IntegrationTest
|
||||
test "should show new request form for anonymous users" do
|
||||
get new_request_path
|
||||
|
||||
assert_response :success
|
||||
assert_select "h1", "Request a New Entry"
|
||||
assert_select "form"
|
||||
assert_select "input[name='entry[name]']"
|
||||
assert_select "input[name='entry[email]']"
|
||||
assert_select "select[name='entry[category]']"
|
||||
end
|
||||
|
||||
test "should show new request form for logged-in users without name/email fields" do
|
||||
login_as(users(:contributor_user))
|
||||
get new_request_path
|
||||
|
||||
assert_response :success
|
||||
assert_select "h1", "Request a New Entry"
|
||||
assert_select "form"
|
||||
assert_select "input[name='entry[name]']", count: 0
|
||||
assert_select "input[name='entry[email]']", count: 0
|
||||
assert_select ".bg-blue-50", text: /Submitting as/
|
||||
end
|
||||
|
||||
test "should create entry request with valid data" do
|
||||
assert_difference([ "User.count", "Entry.count" ], 1) do
|
||||
post requests_path, params: {
|
||||
entry: {
|
||||
name: "New Requester",
|
||||
email: "newrequester@example.com",
|
||||
category: "word",
|
||||
fi: "uusi sana",
|
||||
en: "new word",
|
||||
notes: "Please add this word"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
entry = Entry.last
|
||||
user = User.last
|
||||
|
||||
assert_equal "requested", entry.status
|
||||
assert_equal user, entry.requested_by
|
||||
assert_equal "New Requester", user.name
|
||||
assert_equal "newrequester@example.com", user.email
|
||||
assert_equal "contributor", user.role
|
||||
assert_nil user.invitation_token
|
||||
assert_redirected_to root_path
|
||||
assert_match(/thank you for your request/i, flash[:notice])
|
||||
end
|
||||
|
||||
test "should require at least one translation" do
|
||||
assert_no_difference([ "User.count", "Entry.count" ]) do
|
||||
post requests_path, params: {
|
||||
entry: {
|
||||
name: "New Requester",
|
||||
email: "newrequester@example.com",
|
||||
category: "word",
|
||||
notes: "No translations provided"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
assert_select ".bg-red-50", text: /At least one language translation is required/
|
||||
end
|
||||
|
||||
test "should redirect to login if email already exists" do
|
||||
existing_user = users(:contributor_user)
|
||||
|
||||
assert_no_difference([ "User.count", "Entry.count" ]) do
|
||||
post requests_path, params: {
|
||||
entry: {
|
||||
name: "Test User",
|
||||
email: existing_user.email,
|
||||
category: "word",
|
||||
fi: "sana",
|
||||
en: "word"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
assert_redirected_to login_path
|
||||
assert_equal "An account with this email already exists. Please log in.", flash[:alert]
|
||||
end
|
||||
|
||||
test "should show pending count for email with existing requests" do
|
||||
requester = users(:requester_user)
|
||||
get new_request_path, params: { email: requester.email }
|
||||
|
||||
assert_response :success
|
||||
# User has one requested entry from fixtures
|
||||
assert_select ".bg-blue-50", text: /1 pending request/
|
||||
end
|
||||
|
||||
test "should create entry with only one translation" do
|
||||
assert_difference([ "User.count", "Entry.count" ], 1) do
|
||||
post requests_path, params: {
|
||||
entry: {
|
||||
name: "Single Translation",
|
||||
email: "single@example.com",
|
||||
category: "word",
|
||||
fi: "vain suomeksi"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
entry = Entry.last
|
||||
assert_equal "vain suomeksi", entry.fi
|
||||
assert_nil entry.en
|
||||
assert_nil entry.sv
|
||||
assert_redirected_to root_path
|
||||
end
|
||||
|
||||
test "should create entry with multiple translations" do
|
||||
assert_difference([ "User.count", "Entry.count" ], 1) do
|
||||
post requests_path, params: {
|
||||
entry: {
|
||||
name: "Multi Lingual",
|
||||
email: "multilingual@example.com",
|
||||
category: "phrase",
|
||||
fi: "hyvää päivää",
|
||||
en: "good day",
|
||||
sv: "god dag",
|
||||
no: "god dag",
|
||||
ru: "добрый день",
|
||||
de: "guten Tag"
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
entry = Entry.last
|
||||
assert_equal "hyvää päivää", entry.fi
|
||||
assert_equal "good day", entry.en
|
||||
assert_equal "god dag", entry.sv
|
||||
assert_equal "god dag", entry.no
|
||||
assert_equal "добрый день", entry.ru
|
||||
assert_equal "guten Tag", entry.de
|
||||
assert_redirected_to root_path
|
||||
end
|
||||
|
||||
test "logged-in user can submit request without providing name/email" do
|
||||
user = users(:contributor_user)
|
||||
login_as(user)
|
||||
|
||||
assert_no_difference("User.count") do
|
||||
assert_difference("Entry.count", 1) do
|
||||
post requests_path, params: {
|
||||
entry: {
|
||||
category: "word",
|
||||
fi: "kirjautunut käyttäjä",
|
||||
en: "logged in user"
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
entry = Entry.last
|
||||
assert_equal user, entry.requested_by
|
||||
assert_equal "requested", entry.status
|
||||
assert_redirected_to root_path
|
||||
assert_match(/thank you for your request/i, flash[:notice])
|
||||
end
|
||||
|
||||
test "should not modify existing user when they submit request" do
|
||||
user = users(:contributor_user)
|
||||
original_name = user.name
|
||||
original_updated_at = user.updated_at
|
||||
login_as(user)
|
||||
|
||||
post requests_path, params: {
|
||||
entry: {
|
||||
category: "word",
|
||||
fi: "testi"
|
||||
}
|
||||
}
|
||||
|
||||
user.reload
|
||||
assert_equal original_name, user.name
|
||||
assert_equal original_updated_at.to_i, user.updated_at.to_i
|
||||
end
|
||||
|
||||
test "should reuse existing pending user without modifying them" do
|
||||
# Create a user without accepted invitation
|
||||
existing_user = User.create!(
|
||||
name: "Pending User",
|
||||
email: "pending_test@example.com",
|
||||
password: SecureRandom.alphanumeric(32),
|
||||
role: :contributor
|
||||
)
|
||||
original_name = existing_user.name
|
||||
original_updated_at = existing_user.updated_at
|
||||
|
||||
# Create first entry
|
||||
Entry.create!(
|
||||
category: :word,
|
||||
fi: "first",
|
||||
status: :requested,
|
||||
requested_by: existing_user
|
||||
)
|
||||
|
||||
# Submit second request with same email but different name
|
||||
assert_no_difference("User.count") do
|
||||
assert_difference("Entry.count", 1) do
|
||||
post requests_path, params: {
|
||||
entry: {
|
||||
name: "Different Name", # This should be ignored
|
||||
email: existing_user.email,
|
||||
category: "word",
|
||||
fi: "second"
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
existing_user.reload
|
||||
assert_equal original_name, existing_user.name # Name should not change
|
||||
assert_equal original_updated_at.to_i, existing_user.updated_at.to_i # Should not be updated
|
||||
assert_equal 2, existing_user.requested_entries.count
|
||||
end
|
||||
end
|
||||