Compare commits
29 Commits
4e5c25adbf
...
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 |
@@ -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 (8.0.1)
|
||||
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,11 +228,14 @@ 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)
|
||||
@@ -235,11 +254,7 @@ GEM
|
||||
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.84.0)
|
||||
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)
|
||||
@@ -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,15 +385,8 @@ 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.23)
|
||||
@@ -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 (8.0.1) sha256=c68ce0ac35a6295027c4eab8b4ac597d2a0bfc82f0d62dcd334bbf944d352f70
|
||||
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,10 +531,12 @@ 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.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
|
||||
@@ -529,8 +546,7 @@ CHECKSUMS
|
||||
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.84.0) sha256=88dec310153bb685a879f5a7cdb601f6287b8f0ee675d9dc63a17c7204c4190a
|
||||
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,22 +572,19 @@ 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.23) sha256=ee0d90733aafff056cf51ff11e803d65e43cae258cc55f6492020ec1f9f9315f
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -37,10 +35,7 @@ class Admin::InvitationsController < Admin::BaseController
|
||||
return
|
||||
end
|
||||
|
||||
@invitation.update!(
|
||||
invitation_token: SecureRandom.urlsafe_base64(32),
|
||||
invitation_sent_at: Time.current
|
||||
)
|
||||
@invitation.invite_by!(current_user)
|
||||
|
||||
InvitationMailer.invite(@invitation).deliver_later
|
||||
|
||||
|
||||
@@ -32,11 +32,7 @@ class Admin::RequestsController < Admin::BaseController
|
||||
@entry = Entry.find(params[:id])
|
||||
@user = @entry.requested_by
|
||||
|
||||
@user.update!(
|
||||
invitation_token: SecureRandom.urlsafe_base64(32),
|
||||
invitation_sent_at: Time.current,
|
||||
invited_by: current_user
|
||||
)
|
||||
@user.invite_by!(current_user)
|
||||
|
||||
@entry.update!(status: :approved)
|
||||
InvitationMailer.invite(@user, approved_entry: @entry).deliver_later
|
||||
|
||||
@@ -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,9 +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?
|
||||
|
||||
@@ -14,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,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,8 +6,6 @@ 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.active_entries
|
||||
entries_scope = entries_scope.with_category(@category)
|
||||
@@ -16,9 +14,8 @@ class EntriesController < ApplicationController
|
||||
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.active_entries.count
|
||||
@requested_count = Entry.requested.count
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -19,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?
|
||||
@@ -37,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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -2,15 +2,20 @@
|
||||
# - 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] }, { prompt: "Select a category" }, { 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" } %>
|
||||
<%= 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 %>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -86,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,20 +1,9 @@
|
||||
<% content_for :title, "Edit Entry" %>
|
||||
|
||||
<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">
|
||||
<%= 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 class="flex items-center gap-3">
|
||||
<%= link_to "Browse", entries_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %>
|
||||
<%= 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" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<%= render "shared/header", show_request_button: false, show_browse_button: true %>
|
||||
|
||||
<%= 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">
|
||||
@@ -46,41 +35,8 @@
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<%= f.label :category, "Category", class: "block text-sm font-semibold text-gray-700 mb-2" %>
|
||||
<%= f.select :category,
|
||||
Entry.categories.keys.map { |key| [key.tr("_", " ").capitalize, key] },
|
||||
{},
|
||||
{ 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 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">
|
||||
<% %w[fi en sv no ru de].each do |lang_code| %>
|
||||
<div>
|
||||
<%= f.label lang_code.to_sym,
|
||||
case lang_code
|
||||
when 'fi' then '🇫🇮 Finnish'
|
||||
when 'en' then '🇬🇧 English'
|
||||
when 'sv' then '🇸🇪 Swedish'
|
||||
when 'no' then '🇳🇴 Norwegian'
|
||||
when 'ru' then '🇷🇺 Russian'
|
||||
when 'de' then '🇩🇪 German'
|
||||
end,
|
||||
class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<%= f.text_field lang_code.to_sym,
|
||||
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>
|
||||
</div>
|
||||
|
||||
<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 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">
|
||||
|
||||
@@ -1,43 +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 "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" %>
|
||||
<%= 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" %>
|
||||
|
||||
<!-- Flash messages -->
|
||||
<% if flash.any? %>
|
||||
<div class="max-w-7xl mx-auto px-4 mt-4 w-full">
|
||||
<% 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 relative" role="alert">
|
||||
<span class="block sm:inline pr-8"><%= message %></span>
|
||||
<button type="button" class="absolute top-0 right-0 mt-3 mr-3 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>
|
||||
<% end %>
|
||||
<%= 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>
|
||||
|
||||
@@ -158,7 +158,7 @@
|
||||
</p>
|
||||
<% else %>
|
||||
<p>
|
||||
The <strong>Sanasto Wiki</strong> let you search and compare, or download, translations across languages used all over the living Christianity.
|
||||
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>
|
||||
|
||||
@@ -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,11 +16,13 @@
|
||||
<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" %>
|
||||
@@ -35,27 +37,88 @@
|
||||
<% 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 relative" role="alert">
|
||||
<span class="block sm:inline pr-8"><%= message %></span>
|
||||
<button type="button" class="absolute top-0 right-0 mt-3 mr-3 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>
|
||||
<% 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,10 +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="/favicon.ico" type="image/x-icon">
|
||||
<link rel="icon" href="/icon.svg" type="image/svg+xml">
|
||||
<link rel="icon" href="/icon.png" type="image/png">
|
||||
<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"
|
||||
|
||||
@@ -1,19 +1,9 @@
|
||||
<% content_for :title, "Request a New Entry" %>
|
||||
|
||||
<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">
|
||||
<%= 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 class="flex items-center gap-3">
|
||||
<%= link_to "Sign In", login_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<%= 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">
|
||||
@@ -23,11 +13,6 @@
|
||||
<p class="text-gray-600">Is there a word you would like to see in this glossary?</p>
|
||||
</div>
|
||||
|
||||
<% if flash[:alert] %>
|
||||
<div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-800">
|
||||
<%= flash[:alert] %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if @pending_count && @pending_count > 0 %>
|
||||
<div class="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg text-blue-800">
|
||||
|
||||
@@ -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 %>
|
||||
@@ -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
|
||||
|
||||
@@ -23,7 +23,7 @@ development:
|
||||
# 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -58,7 +58,7 @@ Rails.application.configure do
|
||||
# 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: "sanasto.rin.no" }
|
||||
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 = {
|
||||
|
||||
@@ -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
|
||||
@@ -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,6 +25,11 @@ 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
|
||||
|
||||
@@ -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
|
||||
@@ -8,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")
|
||||
);
|
||||
@@ -65,7 +65,11 @@ CREATE INDEX "index_entries_on_updated_by_id" ON "entries" ("updated_by_id") /*a
|
||||
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'),
|
||||
|
||||
@@ -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
|
||||
@@ -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 |
|
Before Width: | Height: | Size: 143 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.0 KiB After Width: | Height: | Size: 23 KiB |
@@ -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,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
|
||||
|
||||
@@ -43,9 +43,9 @@ class Admin::RequestsControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
assert_response :success
|
||||
assert_select "h1", "Entry Request Details"
|
||||
assert_match @requested_entry.fi, response.body
|
||||
assert_match @requested_entry.en, response.body
|
||||
assert_match @requested_entry.requested_by.name, response.body
|
||||
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
|
||||
|
||||
@@ -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,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,210 @@
|
||||
require "test_helper"
|
||||
|
||||
class EntriesHelperTest < ActionView::TestCase
|
||||
setup do
|
||||
@entry = entries(:one)
|
||||
end
|
||||
|
||||
# alphabet_letters tests
|
||||
test "alphabet_letters returns A-Z for blank language code" do
|
||||
result = alphabet_letters(nil)
|
||||
assert_equal ("A".."Z").to_a, result
|
||||
assert_equal 26, result.length
|
||||
end
|
||||
|
||||
test "alphabet_letters returns A-Z for empty string" do
|
||||
result = alphabet_letters("")
|
||||
assert_equal ("A".."Z").to_a, result
|
||||
end
|
||||
|
||||
test "alphabet_letters returns A-Z for unknown language" do
|
||||
result = alphabet_letters("unknown")
|
||||
assert_equal ("A".."Z").to_a, result
|
||||
end
|
||||
|
||||
test "alphabet_letters returns A-Z for English" do
|
||||
result = alphabet_letters("en")
|
||||
assert_equal ("A".."Z").to_a, result
|
||||
assert_equal 26, result.length
|
||||
end
|
||||
|
||||
test "alphabet_letters returns A-Z for German" do
|
||||
result = alphabet_letters("de")
|
||||
assert_equal ("A".."Z").to_a, result
|
||||
end
|
||||
|
||||
test "alphabet_letters returns Cyrillic alphabet for Russian" do
|
||||
result = alphabet_letters("ru")
|
||||
assert_equal 33, result.length
|
||||
assert_includes result, "А"
|
||||
assert_includes result, "Я"
|
||||
assert_includes result, "Ё"
|
||||
assert_not_includes result, "A"
|
||||
assert_not_includes result, "Z"
|
||||
end
|
||||
|
||||
test "alphabet_letters returns A-Z plus Æ Ø Å for Norwegian" do
|
||||
result = alphabet_letters("no")
|
||||
assert_equal 29, result.length
|
||||
assert_includes result, "A"
|
||||
assert_includes result, "Z"
|
||||
assert_includes result, "Æ"
|
||||
assert_includes result, "Ø"
|
||||
assert_includes result, "Å"
|
||||
assert_equal [ "Æ", "Ø", "Å" ], result.last(3)
|
||||
end
|
||||
|
||||
test "alphabet_letters returns A-Z plus Å Ä Ö for Swedish" do
|
||||
result = alphabet_letters("sv")
|
||||
assert_equal 29, result.length
|
||||
assert_includes result, "A"
|
||||
assert_includes result, "Z"
|
||||
assert_includes result, "Å"
|
||||
assert_includes result, "Ä"
|
||||
assert_includes result, "Ö"
|
||||
assert_equal [ "Å", "Ä", "Ö" ], result.last(3)
|
||||
end
|
||||
|
||||
test "alphabet_letters returns A-Z plus Å Ä Ö for Finnish" do
|
||||
result = alphabet_letters("fi")
|
||||
assert_equal 29, result.length
|
||||
assert_includes result, "A"
|
||||
assert_includes result, "Z"
|
||||
assert_includes result, "Å"
|
||||
assert_includes result, "Ä"
|
||||
assert_includes result, "Ö"
|
||||
assert_equal [ "Å", "Ä", "Ö" ], result.last(3)
|
||||
end
|
||||
|
||||
# entry_translation_for tests
|
||||
test "entry_translation_for returns Finnish translation" do
|
||||
@entry.fi = "Suomalainen sana"
|
||||
result = entry_translation_for(@entry, "fi")
|
||||
assert_equal "Suomalainen sana", result
|
||||
end
|
||||
|
||||
test "entry_translation_for returns English translation" do
|
||||
@entry.en = "English word"
|
||||
result = entry_translation_for(@entry, "en")
|
||||
assert_equal "English word", result
|
||||
end
|
||||
|
||||
test "entry_translation_for returns Swedish translation" do
|
||||
@entry.sv = "Svenskt ord"
|
||||
result = entry_translation_for(@entry, "sv")
|
||||
assert_equal "Svenskt ord", result
|
||||
end
|
||||
|
||||
test "entry_translation_for returns Norwegian translation" do
|
||||
@entry.no = "Norsk ord"
|
||||
result = entry_translation_for(@entry, "no")
|
||||
assert_equal "Norsk ord", result
|
||||
end
|
||||
|
||||
test "entry_translation_for returns Russian translation" do
|
||||
@entry.ru = "Русское слово"
|
||||
result = entry_translation_for(@entry, "ru")
|
||||
assert_equal "Русское слово", result
|
||||
end
|
||||
|
||||
test "entry_translation_for returns German translation" do
|
||||
@entry.de = "Deutsches Wort"
|
||||
result = entry_translation_for(@entry, "de")
|
||||
assert_equal "Deutsches Wort", result
|
||||
end
|
||||
|
||||
test "entry_translation_for returns nil for invalid language code" do
|
||||
result = entry_translation_for(@entry, "invalid")
|
||||
assert_nil result
|
||||
end
|
||||
|
||||
test "entry_translation_for returns nil for non-existent attribute" do
|
||||
result = entry_translation_for(@entry, "zz")
|
||||
assert_nil result
|
||||
end
|
||||
|
||||
test "entry_translation_for returns nil for blank translation" do
|
||||
@entry.fi = nil
|
||||
result = entry_translation_for(@entry, "fi")
|
||||
assert_nil result
|
||||
end
|
||||
|
||||
test "entry_translation_for handles symbol language code" do
|
||||
@entry.fi = "Test"
|
||||
result = entry_translation_for(@entry, :fi)
|
||||
assert_equal "Test", result
|
||||
end
|
||||
|
||||
# format_entry_category tests
|
||||
test "format_entry_category formats word category" do
|
||||
@entry.category = "word"
|
||||
result = format_entry_category(@entry)
|
||||
assert_equal "Word", result
|
||||
end
|
||||
|
||||
test "format_entry_category formats phrase category" do
|
||||
@entry.category = "phrase"
|
||||
result = format_entry_category(@entry)
|
||||
assert_equal "Phrase", result
|
||||
end
|
||||
|
||||
test "format_entry_category formats proper_name category" do
|
||||
@entry.category = "proper_name"
|
||||
result = format_entry_category(@entry)
|
||||
assert_equal "Proper name", result
|
||||
end
|
||||
|
||||
test "format_entry_category formats title category" do
|
||||
@entry.category = "title"
|
||||
result = format_entry_category(@entry)
|
||||
assert_equal "Title", result
|
||||
end
|
||||
|
||||
test "format_entry_category formats reference category" do
|
||||
@entry.category = "reference"
|
||||
result = format_entry_category(@entry)
|
||||
assert_equal "Reference", result
|
||||
end
|
||||
|
||||
test "format_entry_category formats other category" do
|
||||
@entry.category = "other"
|
||||
result = format_entry_category(@entry)
|
||||
assert_equal "Other", result
|
||||
end
|
||||
|
||||
# format_entry_status tests
|
||||
test "format_entry_status returns requested badge" do
|
||||
@entry.status = :requested
|
||||
result = format_entry_status(@entry)
|
||||
assert_match /Requested/, result
|
||||
assert_match /bg-yellow-100/, result
|
||||
assert_match /text-yellow-800/, result
|
||||
end
|
||||
|
||||
test "format_entry_status returns approved badge" do
|
||||
@entry.status = :approved
|
||||
result = format_entry_status(@entry)
|
||||
assert_match /Approved/, result
|
||||
assert_match /bg-blue-100/, result
|
||||
assert_match /text-blue-800/, result
|
||||
end
|
||||
|
||||
test "format_entry_status returns active badge" do
|
||||
@entry.status = :active
|
||||
result = format_entry_status(@entry)
|
||||
assert_match /Active/, result
|
||||
assert_match /bg-green-100/, result
|
||||
assert_match /text-green-800/, result
|
||||
end
|
||||
|
||||
test "format_entry_status badge contains proper HTML structure" do
|
||||
@entry.status = :active
|
||||
result = format_entry_status(@entry)
|
||||
assert_match /<span/, result
|
||||
assert_match /class=/, result
|
||||
assert_match /px-2 py-1/, result
|
||||
assert_match /text-xs/, result
|
||||
assert_match /font-semibold/, result
|
||||
assert_match /rounded-full/, result
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,63 @@
|
||||
require "test_helper"
|
||||
|
||||
class ApiEntriesTest < ActionDispatch::IntegrationTest
|
||||
test "returns active entries with language fields" do
|
||||
entry = entries(:one)
|
||||
|
||||
get "/api/entries"
|
||||
|
||||
assert_response :success
|
||||
payload = JSON.parse(response.body)
|
||||
entry_payload = payload.find { |item| item["id"] == entry.id }
|
||||
|
||||
assert_not_nil entry_payload
|
||||
assert_equal entry.fi, entry_payload["fi"]
|
||||
assert_equal entry.en, entry_payload["en"]
|
||||
assert_equal entry.sv, entry_payload["sv"]
|
||||
assert_equal entry.no, entry_payload["no"]
|
||||
assert_equal entry.ru, entry_payload["ru"]
|
||||
assert_equal entry.de, entry_payload["de"]
|
||||
end
|
||||
|
||||
test "filters by updated_at when since param is provided" do
|
||||
older_entry = Entry.create!(
|
||||
fi: "Older Entry",
|
||||
category: :word,
|
||||
status: :active
|
||||
)
|
||||
older_entry.update_column(:updated_at, 2.days.ago)
|
||||
|
||||
newer_entry = Entry.create!(
|
||||
fi: "Newer Entry",
|
||||
category: :word,
|
||||
status: :active
|
||||
)
|
||||
newer_entry.update_column(:updated_at, 1.hour.ago)
|
||||
|
||||
get "/api/entries", params: { since: 1.day.ago.iso8601 }
|
||||
|
||||
assert_response :success
|
||||
payload = JSON.parse(response.body)
|
||||
returned_ids = payload.map { |item| item["id"] }
|
||||
|
||||
assert_includes returned_ids, newer_entry.id
|
||||
assert_not_includes returned_ids, older_entry.id
|
||||
end
|
||||
|
||||
test "returns bad request for invalid since param" do
|
||||
get "/api/entries", params: { since: "not-a-time" }
|
||||
|
||||
assert_response :bad_request
|
||||
payload = JSON.parse(response.body)
|
||||
assert_equal "Invalid since parameter. Use ISO8601 timestamp.", payload["error"]
|
||||
end
|
||||
|
||||
test "exposes swagger docs" do
|
||||
get "/api/swagger"
|
||||
|
||||
assert_response :success
|
||||
payload = JSON.parse(response.body)
|
||||
assert_equal "Sanasto Wiki API", payload.dig("info", "title")
|
||||
assert_includes payload.keys, "paths"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,317 @@
|
||||
require "test_helper"
|
||||
|
||||
class AuthenticationFlowTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
@user = users(:admin_user)
|
||||
@user.update!(invitation_accepted_at: Time.current)
|
||||
Rails.cache.clear
|
||||
end
|
||||
|
||||
test "user can sign in with valid credentials" do
|
||||
get login_path
|
||||
assert_response :success
|
||||
|
||||
post login_path, params: {
|
||||
email: @user.email,
|
||||
password: "password123456"
|
||||
}
|
||||
|
||||
assert_redirected_to admin_root_path
|
||||
follow_redirect!
|
||||
assert_response :success
|
||||
assert_equal @user.id, session[:user_id]
|
||||
end
|
||||
|
||||
test "user cannot sign in with invalid password" do
|
||||
post login_path, params: {
|
||||
email: @user.email,
|
||||
password: "wrongpassword"
|
||||
}
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
assert_nil session[:user_id]
|
||||
assert_select "div[role='alert']", text: /invalid email or password/i
|
||||
end
|
||||
|
||||
test "user cannot sign in with non-existent email" do
|
||||
post login_path, params: {
|
||||
email: "nonexistent@example.com",
|
||||
password: "password123456"
|
||||
}
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
assert_nil session[:user_id]
|
||||
end
|
||||
|
||||
test "pending user cannot sign in" do
|
||||
pending_user = users(:pending_invitation)
|
||||
|
||||
post login_path, params: {
|
||||
email: pending_user.email,
|
||||
password: "password123456"
|
||||
}
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
assert_nil session[:user_id]
|
||||
assert_select "div[role='alert']", text: /pending/i
|
||||
end
|
||||
|
||||
test "user can sign out" do
|
||||
# Sign in first
|
||||
post login_path, params: {
|
||||
email: @user.email,
|
||||
password: "password123456"
|
||||
}
|
||||
assert_equal @user.id, session[:user_id]
|
||||
|
||||
# Sign out
|
||||
delete logout_path
|
||||
|
||||
assert_redirected_to root_path
|
||||
assert_nil session[:user_id]
|
||||
end
|
||||
|
||||
test "session persists across requests" do
|
||||
post login_path, params: {
|
||||
email: @user.email,
|
||||
password: "password123456"
|
||||
}
|
||||
|
||||
assert_equal @user.id, session[:user_id]
|
||||
|
||||
get root_path
|
||||
assert_equal @user.id, session[:user_id]
|
||||
|
||||
get entries_path
|
||||
assert_equal @user.id, session[:user_id]
|
||||
end
|
||||
|
||||
test "remember me creates cookie" do
|
||||
post login_path, params: {
|
||||
email: @user.email,
|
||||
password: "password123456",
|
||||
remember_me: "1"
|
||||
}
|
||||
|
||||
assert_not_nil cookies[:remember_token]
|
||||
@user.reload
|
||||
assert_not_nil @user.remember_token
|
||||
assert_not_nil @user.remember_created_at
|
||||
end
|
||||
|
||||
test "remember me cookie logs user in automatically" do
|
||||
post login_path, params: {
|
||||
email: @user.email,
|
||||
password: "password123456",
|
||||
remember_me: "1"
|
||||
}
|
||||
remember_cookie = cookies[:remember_token]
|
||||
assert_not_nil remember_cookie
|
||||
|
||||
# Clear session
|
||||
reset!
|
||||
|
||||
# Make request with remember cookie
|
||||
cookies[:remember_token] = remember_cookie
|
||||
get root_path
|
||||
|
||||
assert_equal @user.id, session[:user_id]
|
||||
end
|
||||
|
||||
test "logout clears remember me cookie" do
|
||||
# Sign in with remember me
|
||||
post login_path, params: {
|
||||
email: @user.email,
|
||||
password: "password123456",
|
||||
remember_me: "1"
|
||||
}
|
||||
|
||||
assert_not_nil cookies[:remember_token]
|
||||
|
||||
# Sign out
|
||||
delete logout_path
|
||||
|
||||
remember_cookie = cookies[:remember_token]
|
||||
assert remember_cookie.nil? || remember_cookie.empty?
|
||||
@user.reload
|
||||
assert_nil @user.remember_token
|
||||
end
|
||||
|
||||
test "rate limiting prevents brute force" do
|
||||
max_attempts = 5
|
||||
responses = []
|
||||
|
||||
(max_attempts + 1).times do
|
||||
post login_path, params: {
|
||||
email: @user.email,
|
||||
password: "wrongpassword"
|
||||
}
|
||||
responses << response.status
|
||||
end
|
||||
|
||||
assert responses.first(max_attempts).all? { |status| status == 422 },
|
||||
"Expected first #{max_attempts} responses to be 422, got #{responses.first(max_attempts)}"
|
||||
|
||||
if Rails.cache.is_a?(ActiveSupport::Cache::NullStore)
|
||||
assert responses.last == 422,
|
||||
"Expected last response to be 422 with null cache store, got #{responses.last}"
|
||||
else
|
||||
assert responses.last == 429, "Expected last response to be 429, got #{responses.last}"
|
||||
end
|
||||
assert_select "div[role='alert']", text: /too many/i
|
||||
end
|
||||
|
||||
test "successful login resets rate limit" do
|
||||
# Fail a few times
|
||||
3.times do
|
||||
post login_path, params: {
|
||||
email: @user.email,
|
||||
password: "wrongpassword"
|
||||
}
|
||||
end
|
||||
|
||||
# Then succeed
|
||||
post login_path, params: {
|
||||
email: @user.email,
|
||||
password: "password123456"
|
||||
}
|
||||
|
||||
assert_redirected_to admin_root_path
|
||||
|
||||
# Should be able to login again immediately
|
||||
delete logout_path
|
||||
post login_path, params: {
|
||||
email: @user.email,
|
||||
password: "password123456"
|
||||
}
|
||||
|
||||
assert_redirected_to admin_root_path
|
||||
end
|
||||
|
||||
test "session timeout logs user out after inactivity" do
|
||||
# Sign in
|
||||
post login_path, params: {
|
||||
email: @user.email,
|
||||
password: "password123456"
|
||||
}
|
||||
assert_equal @user.id, session[:user_id]
|
||||
|
||||
# Simulate time passing
|
||||
travel 4.days do
|
||||
get root_path
|
||||
assert_redirected_to login_path
|
||||
assert_match /expired/i, flash[:alert]
|
||||
assert_nil session[:user_id]
|
||||
end
|
||||
end
|
||||
|
||||
test "remember me prevents session timeout" do
|
||||
# Sign in with remember me
|
||||
post login_path, params: {
|
||||
email: @user.email,
|
||||
password: "password123456",
|
||||
remember_me: "1"
|
||||
}
|
||||
|
||||
remember_cookie = cookies[:remember_token]
|
||||
|
||||
# Simulate time passing (but within remember me period)
|
||||
travel 5.days do
|
||||
cookies[:remember_token] = remember_cookie
|
||||
get root_path
|
||||
assert_response :success
|
||||
assert_equal @user.id, session[:user_id]
|
||||
end
|
||||
end
|
||||
|
||||
test "password reset flow completes successfully" do
|
||||
# Request reset
|
||||
post password_resets_path, params: { email: @user.email }
|
||||
assert_redirected_to login_path
|
||||
|
||||
@user.reload
|
||||
assert_not_nil @user.reset_password_token
|
||||
|
||||
# Visit reset form
|
||||
get edit_password_reset_path(@user.reset_password_token)
|
||||
assert_response :success
|
||||
|
||||
# Submit new password
|
||||
patch password_reset_path(@user.reset_password_token), params: {
|
||||
password: "newpassword12345",
|
||||
password_confirmation: "newpassword12345"
|
||||
}
|
||||
|
||||
assert_redirected_to root_path
|
||||
assert_equal @user.id, session[:user_id]
|
||||
|
||||
@user.reload
|
||||
assert @user.authenticate("newpassword12345")
|
||||
assert_nil @user.reset_password_token
|
||||
end
|
||||
|
||||
test "invitation acceptance flow" do
|
||||
pending_user = users(:pending_invitation)
|
||||
|
||||
# Visit invitation
|
||||
get invitation_path(pending_user.invitation_token)
|
||||
assert_response :success
|
||||
|
||||
# Accept invitation
|
||||
patch accept_invitation_path(pending_user.invitation_token), params: {
|
||||
user: {
|
||||
password: "newpassword12345",
|
||||
password_confirmation: "newpassword12345"
|
||||
}
|
||||
}
|
||||
|
||||
assert_redirected_to root_path
|
||||
assert_equal pending_user.id, session[:user_id]
|
||||
|
||||
pending_user.reload
|
||||
assert_not_nil pending_user.invitation_accepted_at
|
||||
assert pending_user.authenticate("newpassword12345")
|
||||
end
|
||||
|
||||
test "expired invitation cannot be accepted" do
|
||||
pending_user = users(:pending_invitation)
|
||||
pending_user.update!(invitation_sent_at: 15.days.ago)
|
||||
|
||||
get invitation_path(pending_user.invitation_token)
|
||||
assert_redirected_to root_path
|
||||
assert_match /expired/i, flash[:alert]
|
||||
end
|
||||
|
||||
test "admin user redirects to admin dashboard after login" do
|
||||
post login_path, params: {
|
||||
email: @user.email,
|
||||
password: "password123456"
|
||||
}
|
||||
|
||||
assert_redirected_to admin_root_path
|
||||
end
|
||||
|
||||
test "contributor redirects to root after login" do
|
||||
contributor = users(:contributor_user)
|
||||
contributor.update!(invitation_accepted_at: Time.current)
|
||||
|
||||
post login_path, params: {
|
||||
email: contributor.email,
|
||||
password: "password123456"
|
||||
}
|
||||
|
||||
assert_redirected_to root_path
|
||||
end
|
||||
|
||||
test "already logged in user redirects from login page" do
|
||||
# Sign in first
|
||||
post login_path, params: {
|
||||
email: @user.email,
|
||||
password: "password123456"
|
||||
}
|
||||
|
||||
# Try to visit login page again
|
||||
get login_path
|
||||
assert_redirected_to admin_root_path
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,30 @@
|
||||
require "test_helper"
|
||||
|
||||
class CorsPreflightTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
@origin = "http://localhost:5173"
|
||||
@app_id = "app.sanasto"
|
||||
end
|
||||
|
||||
test "options preflight returns cors headers when app header is requested" do
|
||||
options "/api/entries", headers: {
|
||||
"Origin" => @origin,
|
||||
"Access-Control-Request-Method" => "GET",
|
||||
"Access-Control-Request-Headers" => "x-sanasto-app"
|
||||
}
|
||||
|
||||
assert_response :no_content
|
||||
assert_equal @origin, response.headers["Access-Control-Allow-Origin"]
|
||||
assert_includes response.headers["Access-Control-Allow-Headers"], "x-sanasto-app"
|
||||
end
|
||||
|
||||
test "get includes cors headers when app id is provided" do
|
||||
get "/api/entries", headers: {
|
||||
"Origin" => @origin,
|
||||
"X-Sanasto-App" => @app_id
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
assert_equal @origin, response.headers["Access-Control-Allow-Origin"]
|
||||
end
|
||||
end
|
||||
@@ -127,7 +127,7 @@ class EntryRequestFlowTest < ActionDispatch::IntegrationTest
|
||||
assert_response :success
|
||||
|
||||
# Active entry should be counted
|
||||
assert_match /#{Entry.active_entries.count}/, response.body
|
||||
assert_select "div", text: "#{Entry.active_entries.count} entries"
|
||||
|
||||
# Verify counts exclude requested/approved entries
|
||||
total_entries = Entry.count
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
require "test_helper"
|
||||
require "benchmark"
|
||||
|
||||
class SearchPerformanceTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
# Create a substantial number of test entries for performance testing
|
||||
@test_entries = []
|
||||
50.times do |i|
|
||||
@test_entries << Entry.create!(
|
||||
fi: "Testi sana #{i}",
|
||||
en: "Test word #{i}",
|
||||
sv: "Test ord #{i}",
|
||||
category: :word,
|
||||
status: :active
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
teardown do
|
||||
# Clean up test entries
|
||||
Entry.where(id: @test_entries.map(&:id)).delete_all
|
||||
end
|
||||
|
||||
test "full text search completes in reasonable time" do
|
||||
measure_time("Full text search") do
|
||||
get entries_path, params: { q: "test" }
|
||||
assert_response :success
|
||||
end
|
||||
end
|
||||
|
||||
test "language-specific search is performant" do
|
||||
measure_time("Language-specific search") do
|
||||
get entries_path, params: { q: "test", language: "en" }
|
||||
assert_response :success
|
||||
end
|
||||
end
|
||||
|
||||
test "alphabetical browsing is performant" do
|
||||
measure_time("Alphabetical browsing") do
|
||||
get entries_path, params: { language: "fi", starts_with: "t" }
|
||||
assert_response :success
|
||||
end
|
||||
end
|
||||
|
||||
test "category filtering is performant" do
|
||||
measure_time("Category filtering") do
|
||||
get entries_path, params: { category: "word" }
|
||||
assert_response :success
|
||||
end
|
||||
end
|
||||
|
||||
test "combined filters are performant" do
|
||||
measure_time("Combined filters") do
|
||||
get entries_path, params: { q: "test", language: "fi", category: "word" }
|
||||
assert_response :success
|
||||
end
|
||||
end
|
||||
|
||||
test "pagination does not degrade performance" do
|
||||
measure_time("Pagination") do
|
||||
get entries_path, params: { page: 2 }
|
||||
assert_response :success
|
||||
end
|
||||
end
|
||||
|
||||
test "entry show page loads quickly" do
|
||||
entry = @test_entries.first
|
||||
|
||||
measure_time("Entry show page") do
|
||||
get entry_path(entry)
|
||||
assert_response :success
|
||||
end
|
||||
end
|
||||
|
||||
test "XLSX download handles large datasets" do
|
||||
measure_time("XLSX download") do
|
||||
get download_entries_path(format: :xlsx)
|
||||
assert_response :success
|
||||
end
|
||||
end
|
||||
|
||||
test "statistics calculation is performant" do
|
||||
measure_time("Statistics calculation") do
|
||||
get entries_path
|
||||
assert_response :success
|
||||
assert_select "div", text: /entries/i
|
||||
assert_select "div", text: /% complete/i
|
||||
end
|
||||
end
|
||||
|
||||
test "search with no results is fast" do
|
||||
measure_time("No results search") do
|
||||
get entries_path, params: { q: "nonexistentword12345xyz" }
|
||||
assert_response :success
|
||||
end
|
||||
end
|
||||
|
||||
test "multiple sequential searches maintain performance" do
|
||||
searches = [ "test", "sana", "word", "ord" ]
|
||||
|
||||
total_time = Benchmark.measure do
|
||||
searches.each do |query|
|
||||
get entries_path, params: { q: query }
|
||||
assert_response :success
|
||||
end
|
||||
end
|
||||
|
||||
assert total_time.real < 2.0, "Multiple searches took too long: #{total_time.real}s"
|
||||
end
|
||||
|
||||
test "turbo stream responses are performant" do
|
||||
measure_time("Turbo stream response") do
|
||||
get entries_path, as: :turbo_stream, params: { q: "test" }
|
||||
assert_response :success
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def measure_time(description, max_time_ms: 500)
|
||||
time = Benchmark.measure do
|
||||
yield
|
||||
end
|
||||
|
||||
time_ms = (time.real * 1000).round(2)
|
||||
assert time_ms < max_time_ms,
|
||||
"#{description} took too long: #{time_ms}ms (max: #{max_time_ms}ms)"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,23 @@
|
||||
require "test_helper"
|
||||
|
||||
class PasswordResetMailerTest < ActionMailer::TestCase
|
||||
test "reset email contains token and expiry" do
|
||||
user = users(:admin_user)
|
||||
user.update!(
|
||||
reset_password_token: "reset_token_123",
|
||||
reset_password_sent_at: Time.current
|
||||
)
|
||||
|
||||
email = PasswordResetMailer.reset(user)
|
||||
assert_emails 1 do
|
||||
email.deliver_now
|
||||
end
|
||||
|
||||
assert_equal [ user.email ], email.to
|
||||
assert_equal "Reset your Sanasto Wiki password", email.subject
|
||||
assert_includes email.content_type, "text/html"
|
||||
assert_includes email.body.encoded, "reset_token_123"
|
||||
assert_includes email.body.encoded, "Password Reset Request"
|
||||
assert_includes email.body.encoded, "will expire on"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,55 @@
|
||||
require "test_helper"
|
||||
|
||||
class SanastoCorsTest < ActiveSupport::TestCase
|
||||
def setup
|
||||
@app = ->(_env) { [ 200, {}, [ "ok" ] ] }
|
||||
@middleware = Middleware::SanastoCors.new(@app)
|
||||
@origin = "http://localhost:5173"
|
||||
@app_id = "app.sanasto"
|
||||
end
|
||||
|
||||
test "adds cors headers for allowed get requests" do
|
||||
env = Rack::MockRequest.env_for(
|
||||
"/api/entries",
|
||||
method: "GET",
|
||||
"HTTP_ORIGIN" => @origin,
|
||||
"HTTP_X_SANASTO_APP" => @app_id
|
||||
)
|
||||
|
||||
status, headers, _body = @middleware.call(env)
|
||||
|
||||
assert_equal 200, status
|
||||
assert_equal @origin, headers["Access-Control-Allow-Origin"]
|
||||
assert_includes headers["Access-Control-Allow-Headers"], "X-Sanasto-App"
|
||||
end
|
||||
|
||||
test "returns preflight response with cors headers when app header is requested" do
|
||||
env = Rack::MockRequest.env_for(
|
||||
"/api/entries",
|
||||
method: "OPTIONS",
|
||||
"HTTP_ORIGIN" => @origin,
|
||||
"HTTP_ACCESS_CONTROL_REQUEST_METHOD" => "GET",
|
||||
"HTTP_ACCESS_CONTROL_REQUEST_HEADERS" => "x-sanasto-app"
|
||||
)
|
||||
|
||||
status, headers, _body = @middleware.call(env)
|
||||
|
||||
assert_equal 204, status
|
||||
assert_equal @origin, headers["Access-Control-Allow-Origin"]
|
||||
assert_includes headers["Access-Control-Allow-Headers"], "x-sanasto-app"
|
||||
assert_includes headers["Vary"], "Access-Control-Request-Headers"
|
||||
end
|
||||
|
||||
test "does not add cors headers when app id is missing" do
|
||||
env = Rack::MockRequest.env_for(
|
||||
"/api/entries",
|
||||
method: "GET",
|
||||
"HTTP_ORIGIN" => @origin
|
||||
)
|
||||
|
||||
status, headers, _body = @middleware.call(env)
|
||||
|
||||
assert_equal 200, status
|
||||
assert_nil headers["Access-Control-Allow-Origin"]
|
||||
end
|
||||
end
|
||||
@@ -37,4 +37,422 @@ class UserTest < ActiveSupport::TestCase
|
||||
assert_not user.valid?
|
||||
assert_includes user.errors[:password], "is too short (minimum is 12 characters)"
|
||||
end
|
||||
|
||||
# Association tests
|
||||
test "can have invited_by association" do
|
||||
inviter = users(:admin_user)
|
||||
user = User.create!(email: "invited@example.com", password: "password123456", invited_by: inviter)
|
||||
assert_equal inviter, user.invited_by
|
||||
end
|
||||
|
||||
test "can have invited_users" do
|
||||
inviter = users(:admin_user)
|
||||
user = User.create!(email: "invited@example.com", password: "password123456", invited_by: inviter)
|
||||
assert_includes inviter.invited_users, user
|
||||
end
|
||||
|
||||
test "has many created_entries" do
|
||||
user = users(:admin_user)
|
||||
assert_respond_to user, :created_entries
|
||||
end
|
||||
|
||||
test "has many updated_entries" do
|
||||
user = users(:admin_user)
|
||||
assert_respond_to user, :updated_entries
|
||||
end
|
||||
|
||||
test "has many requested_entries" do
|
||||
user = users(:admin_user)
|
||||
assert_respond_to user, :requested_entries
|
||||
end
|
||||
|
||||
test "has many submitted_suggested_meanings" do
|
||||
user = users(:admin_user)
|
||||
assert_respond_to user, :submitted_suggested_meanings
|
||||
end
|
||||
|
||||
test "has many reviewed_suggested_meanings" do
|
||||
user = users(:admin_user)
|
||||
assert_respond_to user, :reviewed_suggested_meanings
|
||||
end
|
||||
|
||||
test "has many comments" do
|
||||
user = users(:admin_user)
|
||||
assert_respond_to user, :comments
|
||||
end
|
||||
|
||||
# Scope tests
|
||||
test "by_role scope filters contributors" do
|
||||
contributor_user = users(:contributor_user)
|
||||
results = User.by_role(:contributor)
|
||||
assert_includes results, contributor_user
|
||||
end
|
||||
|
||||
test "by_role scope filters reviewers" do
|
||||
reviewer_user = users(:reviewer_user)
|
||||
results = User.by_role(:reviewer)
|
||||
assert_includes results, reviewer_user
|
||||
end
|
||||
|
||||
test "by_role scope filters admins" do
|
||||
admin_user = users(:admin_user)
|
||||
results = User.by_role(:admin)
|
||||
assert_includes results, admin_user
|
||||
end
|
||||
|
||||
test "by_role scope returns all when role is blank" do
|
||||
results = User.by_role(nil)
|
||||
assert_equal User.all, results
|
||||
end
|
||||
|
||||
test "search_email scope finds users by email" do
|
||||
admin_user = users(:admin_user)
|
||||
results = User.search_email("admin")
|
||||
assert_includes results, admin_user
|
||||
end
|
||||
|
||||
test "search_email scope returns all when query is blank" do
|
||||
results = User.search_email(nil)
|
||||
assert_equal User.all, results
|
||||
end
|
||||
|
||||
test "search_email scope finds partial matches" do
|
||||
admin_user = users(:admin_user)
|
||||
results = User.search_email("exam")
|
||||
assert_includes results, admin_user
|
||||
end
|
||||
|
||||
# invitation_expired? tests
|
||||
test "invitation_expired? returns false when invitation_sent_at is nil" do
|
||||
user = User.new(email: "test@example.com", password: "password123456")
|
||||
assert_not user.invitation_expired?
|
||||
end
|
||||
|
||||
test "invitation_expired? returns false for recent invitation" do
|
||||
user = User.new(
|
||||
email: "test@example.com",
|
||||
password: "password123456",
|
||||
invitation_sent_at: 1.day.ago
|
||||
)
|
||||
assert_not user.invitation_expired?
|
||||
end
|
||||
|
||||
test "invitation_expired? returns true for expired invitation" do
|
||||
user = User.new(
|
||||
email: "test@example.com",
|
||||
password: "password123456",
|
||||
invitation_sent_at: 15.days.ago
|
||||
)
|
||||
assert user.invitation_expired?
|
||||
end
|
||||
|
||||
test "invitation_expired? returns false exactly at expiry boundary" do
|
||||
user = User.new(
|
||||
email: "test@example.com",
|
||||
password: "password123456",
|
||||
invitation_sent_at: 13.days.ago
|
||||
)
|
||||
assert_not user.invitation_expired?
|
||||
end
|
||||
|
||||
# invitation_pending? tests
|
||||
test "invitation_pending? returns true for valid pending invitation" do
|
||||
user = User.new(
|
||||
email: "test@example.com",
|
||||
password: "password123456",
|
||||
invitation_token: "valid_token",
|
||||
invitation_sent_at: 1.day.ago,
|
||||
invitation_accepted_at: nil
|
||||
)
|
||||
assert user.invitation_pending?
|
||||
end
|
||||
|
||||
test "invitation_pending? returns false when invitation is accepted" do
|
||||
user = User.new(
|
||||
email: "test@example.com",
|
||||
password: "password123456",
|
||||
invitation_token: "valid_token",
|
||||
invitation_sent_at: 1.day.ago,
|
||||
invitation_accepted_at: Time.current
|
||||
)
|
||||
assert_not user.invitation_pending?
|
||||
end
|
||||
|
||||
test "invitation_pending? returns false when invitation is expired" do
|
||||
user = User.new(
|
||||
email: "test@example.com",
|
||||
password: "password123456",
|
||||
invitation_token: "valid_token",
|
||||
invitation_sent_at: 15.days.ago,
|
||||
invitation_accepted_at: nil
|
||||
)
|
||||
assert_not user.invitation_pending?
|
||||
end
|
||||
|
||||
test "invitation_pending? returns false when no invitation token" do
|
||||
user = User.new(
|
||||
email: "test@example.com",
|
||||
password: "password123456",
|
||||
invitation_token: nil,
|
||||
invitation_sent_at: 1.day.ago,
|
||||
invitation_accepted_at: nil
|
||||
)
|
||||
assert_not user.invitation_pending?
|
||||
end
|
||||
|
||||
# invite_by tests
|
||||
test "invite_by sets invited_by and generates token" do
|
||||
inviter = users(:admin_user)
|
||||
user = User.new(email: "test@example.com", password: "password123456")
|
||||
user.invite_by(inviter)
|
||||
|
||||
assert_equal inviter, user.invited_by
|
||||
assert_not_nil user.invitation_token
|
||||
assert_not_nil user.invitation_sent_at
|
||||
end
|
||||
|
||||
test "invite_by does not override existing invited_by" do
|
||||
original_inviter = users(:admin_user)
|
||||
new_inviter = users(:reviewer_user)
|
||||
user = User.new(email: "test@example.com", password: "password123456", invited_by: original_inviter)
|
||||
user.invite_by(new_inviter)
|
||||
|
||||
assert_equal original_inviter, user.invited_by
|
||||
end
|
||||
|
||||
test "invite_by handles nil invitee" do
|
||||
user = User.new(email: "test@example.com", password: "password123456")
|
||||
user.invite_by(nil)
|
||||
|
||||
assert_nil user.invited_by
|
||||
assert_not_nil user.invitation_token
|
||||
assert_not_nil user.invitation_sent_at
|
||||
end
|
||||
|
||||
test "invite_by generates 32-character token" do
|
||||
inviter = users(:admin_user)
|
||||
user = User.new(email: "test@example.com", password: "password123456")
|
||||
user.invite_by(inviter)
|
||||
|
||||
assert user.invitation_token.length >= 32
|
||||
end
|
||||
|
||||
# invite_by! tests
|
||||
test "invite_by! saves the user" do
|
||||
inviter = users(:admin_user)
|
||||
user = User.new(email: "test@example.com", password: "password123456")
|
||||
user.invite_by!(inviter)
|
||||
|
||||
assert_not_nil user.id
|
||||
assert user.persisted?
|
||||
end
|
||||
|
||||
test "invite_by! works without invitee parameter" do
|
||||
user = User.new(email: "test@example.com", password: "password123456")
|
||||
user.invite_by!
|
||||
|
||||
assert_not_nil user.id
|
||||
assert_not_nil user.invitation_token
|
||||
end
|
||||
|
||||
# find_by_valid_invitation_token tests
|
||||
test "find_by_valid_invitation_token finds user with valid token" do
|
||||
user = User.create!(
|
||||
email: "test@example.com",
|
||||
password: "password123456",
|
||||
invitation_token: "valid_token_12345",
|
||||
invitation_sent_at: 1.day.ago,
|
||||
invitation_accepted_at: nil
|
||||
)
|
||||
|
||||
found_user = User.find_by_valid_invitation_token("valid_token_12345")
|
||||
assert_equal user, found_user
|
||||
end
|
||||
|
||||
test "find_by_valid_invitation_token returns nil for expired token" do
|
||||
User.create!(
|
||||
email: "test@example.com",
|
||||
password: "password123456",
|
||||
invitation_token: "expired_token",
|
||||
invitation_sent_at: 15.days.ago,
|
||||
invitation_accepted_at: nil
|
||||
)
|
||||
|
||||
found_user = User.find_by_valid_invitation_token("expired_token")
|
||||
assert_nil found_user
|
||||
end
|
||||
|
||||
test "find_by_valid_invitation_token returns nil for accepted invitation" do
|
||||
User.create!(
|
||||
email: "test@example.com",
|
||||
password: "password123456",
|
||||
invitation_token: "accepted_token",
|
||||
invitation_sent_at: 1.day.ago,
|
||||
invitation_accepted_at: Time.current
|
||||
)
|
||||
|
||||
found_user = User.find_by_valid_invitation_token("accepted_token")
|
||||
assert_nil found_user
|
||||
end
|
||||
|
||||
test "find_by_valid_invitation_token returns nil for non-existent token" do
|
||||
found_user = User.find_by_valid_invitation_token("nonexistent_token")
|
||||
assert_nil found_user
|
||||
end
|
||||
|
||||
# remember_me tests
|
||||
test "remember_me generates token and saves" do
|
||||
user = users(:admin_user)
|
||||
token = user.remember_me
|
||||
|
||||
assert_not_nil token
|
||||
assert_not_nil user.remember_token
|
||||
assert_not_nil user.remember_created_at
|
||||
assert_equal token, user.remember_token
|
||||
end
|
||||
|
||||
test "remember_me returns the generated token" do
|
||||
user = users(:admin_user)
|
||||
token = user.remember_me
|
||||
|
||||
assert_kind_of String, token
|
||||
assert token.length >= 32
|
||||
end
|
||||
|
||||
test "remember_me updates database immediately" do
|
||||
user = users(:admin_user)
|
||||
user.remember_me
|
||||
|
||||
user.reload
|
||||
assert_not_nil user.remember_token
|
||||
assert_not_nil user.remember_created_at
|
||||
end
|
||||
|
||||
test "remember_me saves without validation" do
|
||||
user = users(:admin_user)
|
||||
# Make user invalid (email already taken by changing to duplicate)
|
||||
user.email = ""
|
||||
token = user.remember_me
|
||||
|
||||
assert_not_nil token
|
||||
user.reload
|
||||
assert_not_nil user.remember_token
|
||||
end
|
||||
|
||||
# forget_me tests
|
||||
test "forget_me clears remember token and timestamp" do
|
||||
user = users(:admin_user)
|
||||
user.remember_me
|
||||
assert_not_nil user.remember_token
|
||||
|
||||
user.forget_me
|
||||
user.reload
|
||||
|
||||
assert_nil user.remember_token
|
||||
assert_nil user.remember_created_at
|
||||
end
|
||||
|
||||
test "forget_me works when no remember token exists" do
|
||||
user = users(:admin_user)
|
||||
user.forget_me
|
||||
|
||||
assert_nil user.remember_token
|
||||
assert_nil user.remember_created_at
|
||||
end
|
||||
|
||||
# remember_token_expired? tests
|
||||
test "remember_token_expired? returns true when remember_created_at is nil" do
|
||||
user = users(:admin_user)
|
||||
assert user.remember_token_expired?
|
||||
end
|
||||
|
||||
test "remember_token_expired? returns false for recent token" do
|
||||
user = users(:admin_user)
|
||||
user.remember_token = "token"
|
||||
user.remember_created_at = 1.day.ago
|
||||
user.save(validate: false)
|
||||
|
||||
assert_not user.remember_token_expired?
|
||||
end
|
||||
|
||||
test "remember_token_expired? returns true for expired token" do
|
||||
user = users(:admin_user)
|
||||
user.remember_token = "token"
|
||||
user.remember_created_at = 3.weeks.ago
|
||||
user.save(validate: false)
|
||||
|
||||
assert user.remember_token_expired?
|
||||
end
|
||||
|
||||
test "remember_token_expired? boundary at 2 weeks" do
|
||||
user = users(:admin_user)
|
||||
user.remember_token = "token"
|
||||
user.remember_created_at = 13.days.ago
|
||||
user.save(validate: false)
|
||||
|
||||
assert_not user.remember_token_expired?
|
||||
end
|
||||
|
||||
# find_by_valid_remember_token tests
|
||||
test "find_by_valid_remember_token finds user with valid token" do
|
||||
user = users(:admin_user)
|
||||
token = user.remember_me
|
||||
|
||||
found_user = User.find_by_valid_remember_token(token)
|
||||
assert_equal user, found_user
|
||||
end
|
||||
|
||||
test "find_by_valid_remember_token returns nil for expired token" do
|
||||
user = users(:admin_user)
|
||||
user.remember_token = "expired_token"
|
||||
user.remember_created_at = 3.weeks.ago
|
||||
user.save(validate: false)
|
||||
|
||||
found_user = User.find_by_valid_remember_token("expired_token")
|
||||
assert_nil found_user
|
||||
end
|
||||
|
||||
test "find_by_valid_remember_token returns nil for non-existent token" do
|
||||
found_user = User.find_by_valid_remember_token("nonexistent_token")
|
||||
assert_nil found_user
|
||||
end
|
||||
|
||||
test "find_by_valid_remember_token returns nil when user has no remember_created_at" do
|
||||
user = users(:admin_user)
|
||||
user.remember_token = "token_without_timestamp"
|
||||
user.remember_created_at = nil
|
||||
user.save(validate: false)
|
||||
|
||||
found_user = User.find_by_valid_remember_token("token_without_timestamp")
|
||||
assert_nil found_user
|
||||
end
|
||||
|
||||
# Password authentication tests
|
||||
test "authenticate returns user for correct password" do
|
||||
user = User.create!(email: "test@example.com", password: "password123456")
|
||||
assert_equal user, user.authenticate("password123456")
|
||||
end
|
||||
|
||||
test "authenticate returns false for incorrect password" do
|
||||
user = User.create!(email: "test@example.com", password: "password123456")
|
||||
assert_not user.authenticate("wrongpassword")
|
||||
end
|
||||
|
||||
# Email normalization tests
|
||||
test "email uniqueness is case-insensitive" do
|
||||
User.create!(email: "Test@Example.com", password: "password123456")
|
||||
duplicate_user = User.new(email: "test@example.com", password: "password123456")
|
||||
|
||||
# Note: This depends on database collation, but should be tested
|
||||
assert_not duplicate_user.valid?
|
||||
end
|
||||
|
||||
# Constants tests
|
||||
test "INVITATION_TOKEN_EXPIRY is 14 days" do
|
||||
assert_equal 14.days, User::INVITATION_TOKEN_EXPIRY
|
||||
end
|
||||
|
||||
test "REMEMBER_TOKEN_EXPIRY is 2 weeks" do
|
||||
assert_equal 2.weeks, User::REMEMBER_TOKEN_EXPIRY
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
require "application_system_test_case"
|
||||
|
||||
class AdminWorkflowTest < ApplicationSystemTestCase
|
||||
setup do
|
||||
@admin = users(:admin_user)
|
||||
@admin.update!(invitation_accepted_at: Time.current)
|
||||
end
|
||||
|
||||
test "admin can access dashboard" do
|
||||
login_as(@admin)
|
||||
|
||||
visit admin_root_path
|
||||
|
||||
assert_text "Dashboard"
|
||||
assert_text "Total Users"
|
||||
assert_text "Total Entries"
|
||||
end
|
||||
|
||||
test "admin sees admin button in header" do
|
||||
login_as(@admin)
|
||||
visit root_path
|
||||
|
||||
within "header" do
|
||||
assert_link "Admin"
|
||||
end
|
||||
end
|
||||
|
||||
test "admin can send invitation" do
|
||||
login_as(@admin)
|
||||
|
||||
visit admin_invitations_path
|
||||
click_link "Send New Invitation"
|
||||
|
||||
assert_current_path new_admin_invitation_path
|
||||
|
||||
fill_in "Name", with: "New User"
|
||||
fill_in "Email", with: "newuser@example.com"
|
||||
select "Contributor", from: "Role"
|
||||
|
||||
click_button "Send Invitation"
|
||||
|
||||
assert_current_path admin_invitations_path
|
||||
assert_text "Invitation sent"
|
||||
assert_text "newuser@example.com"
|
||||
end
|
||||
|
||||
test "admin can view users list" do
|
||||
login_as(@admin)
|
||||
|
||||
visit admin_users_path
|
||||
|
||||
assert_text "Users"
|
||||
assert_selector "table"
|
||||
assert_text @admin.email
|
||||
end
|
||||
|
||||
test "admin can edit user role" do
|
||||
user = users(:contributor_user)
|
||||
login_as(@admin)
|
||||
|
||||
visit admin_users_path
|
||||
within "#user_#{user.id}" do
|
||||
click_link "Edit"
|
||||
end
|
||||
|
||||
assert_current_path edit_admin_user_path(user)
|
||||
|
||||
select "Reviewer", from: "Role"
|
||||
click_button "Update User"
|
||||
|
||||
assert_current_path admin_users_path
|
||||
assert_text "User updated"
|
||||
|
||||
user.reload
|
||||
assert_equal "reviewer", user.role
|
||||
end
|
||||
|
||||
test "admin can delete user" do
|
||||
user = users(:contributor_user)
|
||||
login_as(@admin)
|
||||
|
||||
visit admin_users_path
|
||||
|
||||
within "#user_#{user.id}" do
|
||||
click_button "Delete"
|
||||
end
|
||||
|
||||
assert_current_path admin_users_path
|
||||
assert_text "User deleted"
|
||||
assert_no_text user.email
|
||||
end
|
||||
|
||||
test "admin can view entry requests" do
|
||||
requested_entry = Entry.create!(
|
||||
fi: "Requested Entry",
|
||||
category: :word,
|
||||
status: :requested,
|
||||
requested_by: users(:contributor_user)
|
||||
)
|
||||
|
||||
login_as(@admin)
|
||||
|
||||
visit admin_requests_path
|
||||
|
||||
assert_text "Requested Entry"
|
||||
assert_link "View"
|
||||
assert_link "Edit"
|
||||
assert_button "Approve"
|
||||
end
|
||||
|
||||
test "admin can approve entry request" do
|
||||
requester = users(:contributor_user)
|
||||
requested_entry = Entry.create!(
|
||||
fi: "Requested Entry",
|
||||
category: :word,
|
||||
status: :requested,
|
||||
requested_by: requester
|
||||
)
|
||||
|
||||
login_as(@admin)
|
||||
|
||||
visit admin_requests_path
|
||||
|
||||
within "#entry_#{requested_entry.id}" do
|
||||
click_button "Approve"
|
||||
end
|
||||
|
||||
assert_current_path admin_requests_path
|
||||
assert_text "approved"
|
||||
|
||||
requested_entry.reload
|
||||
assert_equal "approved", requested_entry.status
|
||||
end
|
||||
|
||||
test "admin can edit entry request before approval" do
|
||||
requested_entry = Entry.create!(
|
||||
fi: "Requested Entry",
|
||||
category: :word,
|
||||
status: :requested,
|
||||
requested_by: users(:contributor_user)
|
||||
)
|
||||
|
||||
login_as(@admin)
|
||||
|
||||
visit edit_admin_request_path(requested_entry)
|
||||
|
||||
fill_in "Finnish", with: "Edited Finnish"
|
||||
fill_in "English", with: "Added English"
|
||||
click_button "Save Changes"
|
||||
|
||||
assert_current_path admin_requests_path
|
||||
requested_entry.reload
|
||||
assert_equal "Edited Finnish", requested_entry.fi
|
||||
assert_equal "Added English", requested_entry.en
|
||||
end
|
||||
|
||||
test "admin can reject entry request" do
|
||||
requested_entry = Entry.create!(
|
||||
fi: "Requested Entry",
|
||||
category: :word,
|
||||
status: :requested,
|
||||
requested_by: users(:contributor_user)
|
||||
)
|
||||
|
||||
login_as(@admin)
|
||||
|
||||
visit admin_requests_path
|
||||
|
||||
within "#entry_#{requested_entry.id}" do
|
||||
click_button "Reject"
|
||||
end
|
||||
|
||||
assert_current_path admin_requests_path
|
||||
assert_text "rejected"
|
||||
assert_nil Entry.find_by(id: requested_entry.id)
|
||||
end
|
||||
|
||||
test "admin can resend invitation" do
|
||||
pending_user = users(:pending_invitation)
|
||||
login_as(@admin)
|
||||
|
||||
visit admin_invitations_path
|
||||
|
||||
within "#invitation_#{pending_user.id}" do
|
||||
click_button "Resend"
|
||||
end
|
||||
|
||||
assert_current_path admin_invitations_path
|
||||
assert_text "Invitation resent"
|
||||
end
|
||||
|
||||
test "admin can cancel invitation" do
|
||||
pending_user = users(:pending_invitation)
|
||||
login_as(@admin)
|
||||
|
||||
visit admin_invitations_path
|
||||
|
||||
within "#invitation_#{pending_user.id}" do
|
||||
click_button "Cancel"
|
||||
end
|
||||
|
||||
assert_current_path admin_invitations_path
|
||||
assert_text "deleted"
|
||||
assert_nil User.find_by(id: pending_user.id)
|
||||
end
|
||||
|
||||
test "admin sees request count badge" do
|
||||
Entry.create!(
|
||||
fi: "Requested Entry 1",
|
||||
category: :word,
|
||||
status: :requested,
|
||||
requested_by: users(:contributor_user)
|
||||
)
|
||||
Entry.create!(
|
||||
fi: "Requested Entry 2",
|
||||
category: :word,
|
||||
status: :requested,
|
||||
requested_by: users(:contributor_user)
|
||||
)
|
||||
|
||||
login_as(@admin)
|
||||
visit admin_root_path
|
||||
|
||||
within "header" do
|
||||
assert_text "2"
|
||||
end
|
||||
end
|
||||
|
||||
test "admin navigation is responsive" do
|
||||
login_as(@admin)
|
||||
visit admin_root_path
|
||||
|
||||
# Test mobile menu if visible
|
||||
if page.has_selector?("#admin-mobile-menu-button", visible: true)
|
||||
click_button id: "admin-mobile-menu-button"
|
||||
assert_selector "#admin-mobile-menu", visible: true
|
||||
assert_link "Dashboard"
|
||||
assert_link "Users"
|
||||
assert_link "Invitations"
|
||||
end
|
||||
end
|
||||
|
||||
test "admin cannot delete themselves" do
|
||||
login_as(@admin)
|
||||
|
||||
visit admin_users_path
|
||||
|
||||
within "#user_#{@admin.id}" do
|
||||
click_button "Delete"
|
||||
end
|
||||
|
||||
assert_text "cannot delete yourself"
|
||||
assert User.exists?(@admin.id)
|
||||
end
|
||||
|
||||
test "admin can view dashboard statistics" do
|
||||
login_as(@admin)
|
||||
|
||||
visit admin_dashboard_path
|
||||
|
||||
assert_text "Total Users"
|
||||
assert_text "Total Entries"
|
||||
assert_text "Pending Invitations"
|
||||
assert_text "Entry Requests"
|
||||
end
|
||||
|
||||
test "admin can navigate between admin sections" do
|
||||
login_as(@admin)
|
||||
|
||||
visit admin_root_path
|
||||
|
||||
click_link "Users"
|
||||
assert_current_path admin_users_path
|
||||
|
||||
click_link "Invitations"
|
||||
assert_current_path admin_invitations_path
|
||||
|
||||
click_link "Requests"
|
||||
assert_current_path admin_requests_path
|
||||
|
||||
click_link "Dashboard"
|
||||
assert_current_path admin_dashboard_path
|
||||
end
|
||||
|
||||
test "admin can return to main site from admin" do
|
||||
login_as(@admin)
|
||||
|
||||
visit admin_root_path
|
||||
|
||||
# On mobile, click hamburger menu first
|
||||
if page.has_selector?("#admin-mobile-menu-button", visible: true)
|
||||
click_button id: "admin-mobile-menu-button"
|
||||
end
|
||||
|
||||
click_link "Back to Site"
|
||||
|
||||
assert_current_path root_path
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,185 @@
|
||||
require "application_system_test_case"
|
||||
|
||||
class ContributorWorkflowTest < ApplicationSystemTestCase
|
||||
setup do
|
||||
@contributor = users(:contributor_user)
|
||||
@contributor.update!(invitation_accepted_at: Time.current)
|
||||
end
|
||||
|
||||
test "contributor can sign in" do
|
||||
visit login_path
|
||||
|
||||
fill_in "Email", with: @contributor.email
|
||||
fill_in "Password", with: "password123456"
|
||||
click_button "Sign In"
|
||||
|
||||
assert_text "Welcome back"
|
||||
within "header" do
|
||||
assert_text @contributor.name.split.first
|
||||
end
|
||||
end
|
||||
|
||||
test "contributor can edit entry" do
|
||||
entry = entries(:one)
|
||||
login_as(@contributor)
|
||||
|
||||
visit entry_path(entry)
|
||||
click_link "Edit"
|
||||
|
||||
assert_current_path edit_entry_path(entry)
|
||||
|
||||
fill_in "Finnish", with: "Updated Finnish Text"
|
||||
fill_in "English", with: "Updated English Text"
|
||||
select "Phrase", from: "Category"
|
||||
fill_in "Additional Notes", with: "Updated notes"
|
||||
|
||||
click_button "Save Changes"
|
||||
|
||||
assert_current_path entry_path(entry)
|
||||
assert_text "Entry updated"
|
||||
assert_text "Updated Finnish Text"
|
||||
assert_text "Updated English Text"
|
||||
end
|
||||
|
||||
test "contributor can add comment to entry" do
|
||||
entry = entries(:one)
|
||||
login_as(@contributor)
|
||||
|
||||
visit entry_path(entry)
|
||||
|
||||
click_button "Add Comment"
|
||||
|
||||
within "#comment_form_modal" do
|
||||
select "Finnish (FI)", from: "Language"
|
||||
fill_in "Comment", with: "This is my comment on the Finnish translation"
|
||||
click_button "Submit"
|
||||
end
|
||||
|
||||
assert_text "This is my comment on the Finnish translation"
|
||||
assert_text @contributor.name
|
||||
end
|
||||
|
||||
test "contributor sees signed in status in header" do
|
||||
login_as(@contributor)
|
||||
visit root_path
|
||||
|
||||
within "header" do
|
||||
assert_text @contributor.name.split.first
|
||||
assert_button "Sign Out", visible: :all
|
||||
end
|
||||
end
|
||||
|
||||
test "contributor can sign out" do
|
||||
login_as(@contributor)
|
||||
visit root_path
|
||||
|
||||
# On mobile, click hamburger menu first
|
||||
if page.has_selector?("#mobile-menu-button", visible: true)
|
||||
click_button id: "mobile-menu-button"
|
||||
end
|
||||
|
||||
click_link "Sign Out"
|
||||
|
||||
assert_current_path root_path
|
||||
assert_text "You have been logged out"
|
||||
within "header" do
|
||||
assert_link "Sign In"
|
||||
end
|
||||
end
|
||||
|
||||
test "contributor cannot access admin pages" do
|
||||
login_as(@contributor)
|
||||
|
||||
visit admin_root_path
|
||||
|
||||
assert_current_path root_path
|
||||
assert_text "administrator"
|
||||
end
|
||||
|
||||
test "contributor can request password reset" do
|
||||
visit login_path
|
||||
|
||||
click_link "Forgot password?"
|
||||
|
||||
assert_current_path new_password_reset_path
|
||||
|
||||
fill_in "Email", with: @contributor.email
|
||||
click_button "Send Reset Instructions"
|
||||
|
||||
assert_current_path login_path
|
||||
assert_text "password reset instructions"
|
||||
end
|
||||
|
||||
test "contributor session persists across page loads" do
|
||||
login_as(@contributor)
|
||||
|
||||
visit root_path
|
||||
assert_text @contributor.name.split.first
|
||||
|
||||
visit entries_path
|
||||
within "header" do
|
||||
assert_text @contributor.name.split.first
|
||||
end
|
||||
end
|
||||
|
||||
test "contributor can use remember me" do
|
||||
visit login_path
|
||||
|
||||
fill_in "Email", with: @contributor.email
|
||||
fill_in "Password", with: "password123456"
|
||||
check "Remember me for 2 weeks"
|
||||
click_button "Sign In"
|
||||
|
||||
assert_text "Welcome back"
|
||||
end
|
||||
|
||||
test "contributor sees validation errors when editing entry incorrectly" do
|
||||
entry = entries(:one)
|
||||
login_as(@contributor)
|
||||
|
||||
visit edit_entry_path(entry)
|
||||
|
||||
# Clear all translations (should fail validation)
|
||||
fill_in "Finnish", with: ""
|
||||
fill_in "English", with: ""
|
||||
fill_in "Swedish", with: ""
|
||||
fill_in "Norwegian", with: ""
|
||||
fill_in "Russian", with: ""
|
||||
fill_in "German", with: ""
|
||||
|
||||
click_button "Save Changes"
|
||||
|
||||
assert_text "At least one language translation is required"
|
||||
end
|
||||
|
||||
test "contributor can filter comments by language tab" do
|
||||
entry = entries(:one)
|
||||
login_as(@contributor)
|
||||
|
||||
# Create comments in different languages
|
||||
Comment.create!(
|
||||
commentable: entry,
|
||||
user: @contributor,
|
||||
body: "Finnish comment",
|
||||
language_code: "fi"
|
||||
)
|
||||
Comment.create!(
|
||||
commentable: entry,
|
||||
user: @contributor,
|
||||
body: "English comment",
|
||||
language_code: "en"
|
||||
)
|
||||
|
||||
visit entry_path(entry)
|
||||
|
||||
click_link "Finnish"
|
||||
assert_text "Finnish comment"
|
||||
|
||||
click_link "English"
|
||||
assert_text "English comment"
|
||||
|
||||
click_link "All languages"
|
||||
assert_text "Finnish comment"
|
||||
assert_text "English comment"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,114 @@
|
||||
require "application_system_test_case"
|
||||
|
||||
class PublicBrowsingTest < ApplicationSystemTestCase
|
||||
test "visitor can browse entries without logging in" do
|
||||
visit root_path
|
||||
|
||||
assert_selector "h1", text: "Sanasto Wiki"
|
||||
assert_selector ".entry-row", minimum: 1
|
||||
end
|
||||
|
||||
test "visitor can search entries" do
|
||||
entry = entries(:one)
|
||||
visit root_path
|
||||
|
||||
fill_in "q", with: entry.fi
|
||||
click_button "Search"
|
||||
|
||||
assert_text entry.fi
|
||||
end
|
||||
|
||||
test "visitor can filter by language" do
|
||||
visit root_path
|
||||
|
||||
click_button "Finnish"
|
||||
|
||||
assert_current_path entries_path(language: "fi")
|
||||
end
|
||||
|
||||
test "visitor can filter by category" do
|
||||
visit root_path
|
||||
|
||||
select "Word", from: "category"
|
||||
|
||||
assert_current_path entries_path(category: "word")
|
||||
end
|
||||
|
||||
test "visitor can view entry details" do
|
||||
entry = entries(:one)
|
||||
visit root_path
|
||||
|
||||
click_link entry.fi
|
||||
|
||||
assert_text entry.fi
|
||||
assert_text entry.en if entry.en.present?
|
||||
end
|
||||
|
||||
test "visitor can browse alphabetically" do
|
||||
visit root_path
|
||||
|
||||
click_button "Finnish"
|
||||
click_link "A"
|
||||
|
||||
assert_current_path entries_path(language: "fi", starts_with: "a")
|
||||
end
|
||||
|
||||
test "visitor can download XLSX" do
|
||||
visit root_path
|
||||
|
||||
click_link "Download XLSX"
|
||||
|
||||
assert_equal "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
page.response_headers["Content-Type"]
|
||||
end
|
||||
|
||||
test "visitor sees sign in link in header" do
|
||||
visit root_path
|
||||
|
||||
within "header" do
|
||||
assert_link "Sign In"
|
||||
end
|
||||
end
|
||||
|
||||
test "visitor can request new entry" do
|
||||
visit root_path
|
||||
|
||||
click_link "Request Entry"
|
||||
|
||||
assert_current_path new_request_path
|
||||
assert_field "Name"
|
||||
assert_field "Email"
|
||||
end
|
||||
|
||||
test "search results show no results message when nothing matches" do
|
||||
visit root_path
|
||||
|
||||
fill_in "q", with: "nonexistentword12345xyz"
|
||||
click_button "Search"
|
||||
|
||||
assert_text "No entries matched your filters"
|
||||
end
|
||||
|
||||
test "visitor can paginate through results" do
|
||||
# Create enough entries to require pagination
|
||||
26.times do |i|
|
||||
Entry.create!(
|
||||
fi: "Test Entry #{i}",
|
||||
category: :word,
|
||||
status: :active
|
||||
)
|
||||
end
|
||||
|
||||
visit root_path
|
||||
|
||||
assert_selector ".entry-row", count: 25
|
||||
assert_link "Next"
|
||||
end
|
||||
|
||||
test "visitor sees entry statistics" do
|
||||
visit root_path
|
||||
|
||||
assert_text "Total Entries"
|
||||
assert_text "Verified"
|
||||
end
|
||||
end
|
||||
@@ -1,3 +1,6 @@
|
||||
require "simplecov"
|
||||
SimpleCov.start "rails"
|
||||
|
||||
ENV["RAILS_ENV"] ||= "test"
|
||||
require_relative "../config/environment"
|
||||
require "rails/test_help"
|
||||
|
||||