Compare commits

..

50 Commits

Author SHA1 Message Date
Runar Ingebrigtsen
7b6e059da6 logo update
CI / scan_ruby (push) Successful in 16s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 21s
CI / test (push) Successful in 53s
2026-02-20 22:02:26 +01:00
Runar Ingebrigtsen
e9f8f03db2 URL update
CI / scan_ruby (push) Successful in 39s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 21s
CI / test (push) Successful in 48s
2026-02-20 21:43:40 +01:00
Runar Ingebrigtsen
b289cdc320 update + parallel tests 2026-02-20 21:43:25 +01:00
Runar Ingebrigtsen
8bb410dcfa yaml syntax
CI / scan_ruby (push) Successful in 18s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 21s
CI / test (push) Successful in 1m1s
2026-02-06 02:35:56 +01:00
Runar Ingebrigtsen
ce67776eec sanasto.wiki
CI / scan_ruby (push) Successful in 18s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 22s
CI / test (push) Successful in 50s
2026-02-06 02:34:44 +01:00
Runar Ingebrigtsen
9f71fe65e5 test corst, fix footer
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 21s
CI / test (push) Successful in 48s
2026-02-06 02:01:57 +01:00
Runar Ingebrigtsen
e15835bda9 support browser cors request
CI / scan_ruby (push) Successful in 18s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 21s
CI / test (push) Successful in 47s
2026-02-05 23:59:11 +01:00
Runar Ingebrigtsen
83320d4c9a add CORS access for sanasto.app
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 12s
CI / lint (push) Failing after 20s
CI / test (push) Successful in 48s
2026-02-05 23:52:21 +01:00
Runar Ingebrigtsen
a2008e2ae3 update vulnerability scan
CI / scan_ruby (push) Successful in 24s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 22s
CI / test (push) Successful in 52s
2026-02-04 09:02:38 +01:00
Runar Ingebrigtsen
b45a451748 fix pagination test
CI / scan_ruby (push) Failing after 15s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 22s
CI / test (push) Successful in 51s
2026-02-04 08:59:04 +01:00
Runar Ingebrigtsen
441caabb98 complete API swagger documentation
CI / scan_ruby (push) Failing after 29s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 21s
CI / test (push) Failing after 48s
2026-02-04 01:33:02 +01:00
Runar Ingebrigtsen
a139bde102 switch to pagy for pagination 2026-02-03 21:21:53 +01:00
Runar Ingebrigtsen
f35a09f07a lint 2026-02-01 05:32:30 +01:00
Runar Ingebrigtsen
1a10e3c784 api completion 2026-02-01 05:29:16 +01:00
Runar Ingebrigtsen
4fe95ca538 add sync API with swagger documentation at /api
CI / scan_ruby (push) Successful in 23s
CI / scan_js (push) Successful in 15s
CI / lint (push) Successful in 22s
CI / test (push) Successful in 47s
2026-01-31 22:39:12 +01:00
Runar Ingebrigtsen
fa36305244 document deployment 2026-01-31 16:05:44 +01:00
Runar Ingebrigtsen
9acdc4e6db actually, include notifications
CI / scan_ruby (push) Successful in 23s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 22s
CI / test (push) Successful in 50s
2026-01-31 15:55:24 +01:00
Runar Ingebrigtsen
e48b386b54 update todos 2026-01-31 15:55:02 +01:00
Runar Ingebrigtsen
d183fb4b53 normalize emails 2026-01-31 15:51:01 +01:00
Runar Ingebrigtsen
9c6714e97c shared flash notifications 2026-01-31 15:50:31 +01:00
Runar Ingebrigtsen
227ab744b5 fix logout 2026-01-31 15:49:49 +01:00
Runar Ingebrigtsen
4bc393887b 96.99% test coverage 2026-01-31 15:48:32 +01:00
Runar Ingebrigtsen
8ec8f15857 run some lint and security check before pushing code 2026-01-31 11:49:25 +01:00
Runar Ingebrigtsen
803c1371b7 lint
CI / scan_ruby (push) Successful in 18s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 21s
CI / test (push) Failing after 36s
2026-01-30 10:39:53 +01:00
Runar Ingebrigtsen
46e4f808e7 refactor comments, select language
CI / scan_ruby (push) Successful in 18s
CI / scan_js (push) Successful in 12s
CI / lint (push) Failing after 19s
CI / test (push) Failing after 36s
2026-01-30 10:37:56 +01:00
Runar Ingebrigtsen
8ce7f1b913 rate limiter
CI / scan_ruby (push) Successful in 18s
CI / scan_js (push) Successful in 14s
CI / lint (push) Failing after 21s
CI / test (push) Failing after 35s
2026-01-30 10:09:49 +01:00
Runar Ingebrigtsen
c407ee3530 shared header, responsive 2026-01-30 10:09:38 +01:00
Runar Ingebrigtsen
32a4ffa70e rate limiting sesisons 2026-01-30 10:08:57 +01:00
Runar Ingebrigtsen
20ce18ca74 remember me, password reset 2026-01-30 10:08:41 +01:00
Runar Ingebrigtsen
4e5c25adbf fix mail server and lint removal
CI / scan_ruby (push) Successful in 16s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 22s
CI / test (push) Successful in 34s
2026-01-30 08:56:12 +01:00
Runar Ingebrigtsen
7118f1ea45 block bots
CI / scan_ruby (push) Successful in 18s
CI / scan_js (push) Successful in 14s
CI / lint (push) Failing after 21s
CI / test (push) Successful in 36s
2026-01-30 08:52:17 +01:00
Runar Ingebrigtsen
f31a25fb03 stop bots crawling
CI / scan_ruby (push) Successful in 18s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 20s
CI / test (push) Successful in 39s
2026-01-30 08:43:59 +01:00
Runar Ingebrigtsen
7c7bdf7e65 lint this, check todo
CI / scan_ruby (push) Successful in 18s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 20s
CI / test (push) Successful in 33s
2026-01-30 01:47:41 +01:00
Runar Ingebrigtsen
21e7e65dfb update gems
CI / scan_ruby (push) Successful in 33s
CI / scan_js (push) Successful in 14s
CI / lint (push) Failing after 21s
CI / test (push) Successful in 36s
2026-01-30 01:45:01 +01:00
Runar Ingebrigtsen
3e36821e51 edit entries 2026-01-30 01:43:58 +01:00
Runar Ingebrigtsen
530021960e add entry requests, invite new users
CI / scan_ruby (push) Failing after 12s
CI / scan_js (push) Successful in 11s
CI / lint (push) Failing after 19s
CI / test (push) Successful in 34s
2026-01-30 01:28:53 +01:00
Runar Ingebrigtsen
b64ad52d30 use docs/TODO.md 2026-01-30 01:20:45 +01:00
Runar Ingebrigtsen
4a6388ade6 favion 2026-01-29 16:03:11 +01:00
Runar Ingebrigtsen
e7f2215be4 resend invitations 2026-01-29 15:47:03 +01:00
Runar Ingebrigtsen
887d52c447 fix mail from address
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 19s
CI / test (push) Successful in 29s
2026-01-29 14:04:42 +01:00
Runar Ingebrigtsen
e9295dc278 add solid_queue schema
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 20s
CI / test (push) Successful in 28s
2026-01-29 11:37:07 +01:00
Runar Ingebrigtsen
001d63c513 install solid queue
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 13s
CI / lint (push) Successful in 18s
CI / test (push) Successful in 29s
2026-01-27 10:03:40 +01:00
Runar Ingebrigtsen
d6ba730d4a update credentials
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 20s
CI / test (push) Successful in 29s
2026-01-27 09:47:12 +01:00
Runar Ingebrigtsen
8a4c146117 use rails credentials for smtp creds
CI / scan_ruby (push) Successful in 19s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 21s
CI / test (push) Successful in 30s
2026-01-27 09:23:11 +01:00
Runar Ingebrigtsen
b54db723c5 remove system tests, nothing to see
CI / scan_ruby (push) Successful in 19s
CI / scan_js (push) Successful in 14s
CI / lint (push) Successful in 21s
CI / test (push) Successful in 29s
2026-01-26 23:50:19 +01:00
Runar Ingebrigtsen
de52fe9b93 install sqlite in CI
CI / scan_ruby (push) Successful in 17s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 19s
CI / test (push) Successful in 30s
CI / system-test (push) Failing after 21s
2026-01-26 23:45:27 +01:00
Runar Ingebrigtsen
a4d5a676d6 db:setup db:migrate
CI / scan_ruby (push) Successful in 15s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 19s
CI / test (push) Failing after 13s
CI / system-test (push) Failing after 14s
2026-01-26 23:38:17 +01:00
Runar Ingebrigtsen
b3c37cca13 db:setup
CI / scan_ruby (push) Successful in 16s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 19s
CI / test (push) Failing after 13s
CI / system-test (push) Failing after 14s
2026-01-26 23:36:02 +01:00
Runar Ingebrigtsen
be0ddcc89e db before test
CI / scan_ruby (push) Successful in 16s
CI / scan_js (push) Successful in 12s
CI / lint (push) Successful in 19s
CI / test (push) Failing after 12s
CI / system-test (push) Failing after 13s
2026-01-26 22:54:23 +01:00
Runar Ingebrigtsen
654ec39f36 remove lint 2026-01-26 21:56:55 +01:00
120 changed files with 6874 additions and 462 deletions
+49 -33
View File
@@ -83,42 +83,58 @@ jobs:
with:
bundler-cache: true
- name: Install SQLite3 CLI
run: sudo apt-get update && sudo apt-get install -y sqlite3
- name: Set up database
env:
RAILS_ENV: test
run: bin/rails db:create db:migrate
- name: Run tests
env:
RAILS_ENV: test
# RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
# REDIS_URL: redis://localhost:6379/0
run: bin/rails db:test:prepare test
run: bin/rails test
system-test:
runs-on: ubuntu-latest
# services:
# redis:
# image: valkey/valkey:8
# ports:
# - 6379:6379
# options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
bundler-cache: true
- name: Run System Tests
env:
RAILS_ENV: test
# RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
# REDIS_URL: redis://localhost:6379/0
run: bin/rails db:test:prepare test:system
- name: Keep screenshots from failed system tests
uses: actions/upload-artifact@v4
if: failure()
with:
name: screenshots
path: ${{ github.workspace }}/tmp/screenshots
if-no-files-found: ignore
# system-test:
# runs-on: ubuntu-latest
#
# # services:
# # redis:
# # image: valkey/valkey:8
# # ports:
# # - 6379:6379
# # options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
# steps:
# - name: Checkout code
# uses: actions/checkout@v6
#
# - name: Set up Ruby
# uses: ruby/setup-ruby@v1
# with:
# bundler-cache: true
#
# - name: Install SQLite3 CLI
# run: sudo apt-get update && sudo apt-get install -y sqlite3
#
# - name: Set up database
# env:
# RAILS_ENV: test
# run: bin/rails db:create db:migrate
#
# - name: Run System Tests
# env:
# RAILS_ENV: test
# # RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
# # REDIS_URL: redis://localhost:6379/0
# run: bin/rails test:system
#
# - name: Keep screenshots from failed system tests
# uses: actions/upload-artifact@v4
# if: failure()
# with:
# name: screenshots
# path: ${{ github.workspace }}/tmp/screenshots
# if-no-files-found: ignore
+1
View File
@@ -0,0 +1 @@
markup: markdown
+10 -1
View File
@@ -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
+100 -86
View File
@@ -81,11 +81,12 @@ GEM
base64 (0.3.0)
bcrypt (3.1.21)
bcrypt_pbkdf (1.1.2)
benchmark (0.5.0)
bigdecimal (4.0.1)
bindex (0.8.1)
bootsnap (1.21.1)
bootsnap (1.23.0)
msgpack (~> 1.2)
brakeman (7.1.2)
brakeman (8.0.2)
racc
builder (3.3.0)
bundler-audit (0.9.3)
@@ -112,29 +113,51 @@ GEM
connection_pool (3.0.2)
crass (1.0.6)
csv (3.3.5)
daemons (1.4.1)
date (3.5.1)
debug (1.11.1)
irb (~> 1.10)
reline (>= 0.3.8)
docile (1.4.1)
dotenv (3.2.0)
drb (2.2.3)
dry-configurable (1.3.0)
dry-core (~> 1.1)
zeitwerk (~> 2.6)
dry-core (1.2.0)
concurrent-ruby (~> 1.0)
logger
zeitwerk (~> 2.6)
dry-inflector (1.3.1)
dry-logic (1.6.0)
bigdecimal
concurrent-ruby (~> 1.0)
dry-core (~> 1.1)
zeitwerk (~> 2.6)
dry-types (1.9.1)
bigdecimal (>= 3.0)
concurrent-ruby (~> 1.0)
dry-core (~> 1.0)
dry-inflector (~> 1.0)
dry-logic (~> 1.4)
zeitwerk (~> 2.6)
ed25519 (1.4.0)
erb (6.0.1)
erubi (1.13.1)
et-orbi (1.4.0)
tzinfo
eventmachine (1.2.7)
ffi (1.17.3-x86_64-linux-gnu)
fugit (1.12.1)
et-orbi (~> 1.4)
raabro (~> 1.4)
globalid (1.3.0)
activesupport (>= 6.1)
haml (7.2.0)
temple (>= 0.8.2)
thor
tilt
grape (3.1.1)
activesupport (>= 7.1)
dry-configurable
dry-types (>= 1.1)
mustermann-grape (~> 1.1.0)
rack (>= 2)
zeitwerk
htmlentities (4.4.2)
i18n (1.14.8)
concurrent-ruby (~> 1.0)
@@ -146,14 +169,15 @@ GEM
activesupport (>= 6.0.0)
railties (>= 6.0.0)
io-console (0.8.2)
irb (1.16.0)
irb (1.17.0)
pp (>= 0.6.0)
prism (>= 1.3.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jbuilder (2.14.1)
actionview (>= 7.0.0)
activesupport (>= 7.0.0)
json (2.18.0)
json (2.18.1)
kamal (2.10.1)
activesupport (>= 7.0)
base64 (~> 0.2)
@@ -177,16 +201,6 @@ GEM
net-imap
net-pop
net-smtp
mailcatcher (0.2.4)
eventmachine
haml
i18n
json
mail
sinatra
skinny (>= 0.1.2)
sqlite3-ruby
thin
marcel (1.1.0)
matrix (0.4.3)
mini_magick (5.3.1)
@@ -197,7 +211,9 @@ GEM
msgpack (1.8.0)
mustermann (3.0.4)
ruby2_keywords (~> 0.0.1)
net-imap (0.6.2)
mustermann-grape (1.1.0)
mustermann (>= 1.0.0)
net-imap (0.6.3)
date
net-protocol
net-pop (0.1.2)
@@ -212,17 +228,20 @@ GEM
net-protocol
net-ssh (7.3.0)
nio4r (2.7.5)
nokogiri (1.19.0-x86_64-linux-gnu)
nokogiri (1.19.1-x86_64-linux-gnu)
racc (~> 1.4)
ostruct (0.6.3)
pagy (8.6.3)
parallel (1.27.0)
parser (3.3.10.1)
parallel_tests (5.6.0)
parallel
parser (3.3.10.2)
ast (~> 2.4.1)
racc
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
prism (1.8.0)
prism (1.9.0)
propshaft (1.3.1)
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
@@ -231,15 +250,11 @@ GEM
date
stringio
public_suffix (7.0.2)
puma (7.1.0)
puma (7.2.0)
nio4r (~> 2.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.2.4)
rack-protection (4.2.1)
base64 (>= 0.1.0)
logger (>= 1.6.0)
rack (>= 3.0.0, < 4)
rack (3.2.5)
rack-session (2.1.1)
base64 (>= 0.1.0)
rack (>= 3.0.0)
@@ -279,7 +294,7 @@ GEM
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.3.1)
rdoc (7.1.0)
rdoc (7.2.0)
erb
psych (>= 4.0.0)
tsort
@@ -293,7 +308,10 @@ GEM
logger (~> 1)
nokogiri (~> 1)
rubyzip (>= 3.0.0, < 4.0.0)
rubocop (1.82.1)
rswag-ui (2.17.0)
actionpack (>= 5.2, < 8.2)
railties (>= 5.2, < 8.2)
rubocop (1.84.2)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@@ -301,7 +319,7 @@ GEM
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.48.0, < 2.0)
rubocop-ast (>= 1.49.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.49.0)
@@ -328,22 +346,18 @@ GEM
ruby2_keywords (0.0.5)
rubyzip (3.2.2)
securerandom (0.4.1)
selenium-webdriver (4.40.0)
selenium-webdriver (4.41.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 4.0)
websocket (~> 1.0)
sinatra (4.2.1)
logger (>= 1.6.0)
mustermann (~> 3.0)
rack (>= 3.0.0, < 4)
rack-protection (= 4.2.1)
rack-session (>= 2.0.0, < 3)
tilt (~> 2.0)
skinny (0.2.2)
eventmachine (~> 1.0)
thin
simplecov (0.22.0)
docile (~> 1.1)
simplecov-html (~> 0.11)
simplecov_json_formatter (~> 0.1)
simplecov-html (0.13.2)
simplecov_json_formatter (0.1.4)
solid_cable (3.0.12)
actioncable (>= 7.2)
activejob (>= 7.2)
@@ -353,7 +367,7 @@ GEM
activejob (>= 7.2)
activerecord (>= 7.2)
railties (>= 7.2)
solid_queue (1.3.1)
solid_queue (1.3.2)
activejob (>= 7.1)
activerecord (>= 7.1)
concurrent-ruby (>= 1.3.1)
@@ -361,8 +375,6 @@ GEM
railties (>= 7.1)
thor (>= 1.3.1)
sqlite3 (2.9.0-x86_64-linux-gnu)
sqlite3-ruby (1.3.3)
sqlite3 (>= 1.3.3)
sshkit (1.25.0)
base64
logger
@@ -373,18 +385,11 @@ GEM
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.2.0)
temple (0.10.4)
thin (2.0.1)
daemons (~> 1.0, >= 1.0.9)
eventmachine (~> 1.0, >= 1.0.4)
logger
rack (>= 1, < 4)
thor (1.5.0)
thruster (0.1.17-x86_64-linux)
tilt (2.7.0)
thruster (0.1.18-x86_64-linux)
timeout (0.6.0)
tsort (0.2.0)
turbo-rails (2.0.21)
turbo-rails (2.0.23)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
tzinfo (2.0.6)
@@ -406,13 +411,14 @@ GEM
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.7.4)
zeitwerk (2.7.5)
PLATFORMS
x86_64-linux-gnu
DEPENDENCIES
bcrypt (~> 3.1.7)
benchmark
bootsnap
brakeman
bundler-audit
@@ -420,17 +426,21 @@ DEPENDENCIES
caxlsx
caxlsx_rails
debug
grape
image_processing (~> 1.2)
importmap-rails
jbuilder
kamal
mailcatcher
pagy (~> 8.0)
parallel_tests
propshaft
puma (>= 5.0)
rails (~> 8.1.2)
roo (~> 3.0)
rswag-ui
rubocop-rails-omakase
selenium-webdriver
simplecov
solid_cable
solid_cache
solid_queue
@@ -459,10 +469,11 @@ CHECKSUMS
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
bcrypt (3.1.21) sha256=5964613d750a42c7ee5dc61f7b9336fb6caca429ba4ac9f2011609946e4a2dcf
bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6
benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c
bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7
bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e
bootsnap (1.21.1) sha256=9373acfe732da35846623c337d3481af8ce77c7b3a927fb50e9aa92b46dbc4c4
brakeman (7.1.2) sha256=6b04927710a2e7d13a72248b5d404c633188e02417f28f3d853e4b6370d26dce
bootsnap (1.23.0) sha256=c1254f458d58558b58be0f8eb8f6eec2821456785b7cdd1e16248e2020d3f214
brakeman (8.0.2) sha256=7b02065ce8b1de93949cefd3f2ad78e8eb370e644b95c8556a32a912a782426a
builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f
bundler-audit (0.9.3) sha256=81c8766c71e47d0d28a0f98c7eed028539f21a6ea3cd8f685eb6f42333c9b4e9
capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef
@@ -472,35 +483,38 @@ CHECKSUMS
connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d
csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f
daemons (1.4.1) sha256=8fc76d76faec669feb5e455d72f35bd4c46dc6735e28c420afb822fac1fa9a1d
date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0
debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6
docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e
dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d
drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
dry-configurable (1.3.0) sha256=882d862858567fc1210d2549d4c090f34370fc1bb7c5c1933de3fe792e18afa8
dry-core (1.2.0) sha256=0cc5a7da88df397f153947eeeae42e876e999c1e30900f3c536fb173854e96a1
dry-inflector (1.3.1) sha256=7fb0c2bb04f67638f25c52e7ba39ab435d922a3a5c3cd196120f63accb682dcc
dry-logic (1.6.0) sha256=da6fedbc0f90fc41f9b0cc7e6f05f5d529d1efaef6c8dcc8e0733f685745cea2
dry-types (1.9.1) sha256=baebeecdb9f8395d6c9d227b62011279440943e3ef2468fe8ccc1ba11467f178
ed25519 (1.4.0) sha256=16e97f5198689a154247169f3453ef4cfd3f7a47481fde0ae33206cdfdcac506
erb (6.0.1) sha256=28ecdd99c5472aebd5674d6061e3c6b0a45c049578b071e5a52c2a7f13c197e5
erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9
et-orbi (1.4.0) sha256=6c7e3c90779821f9e3b324c5e96fda9767f72995d6ae435b96678a4f3e2de8bc
eventmachine (1.2.7) sha256=994016e42aa041477ba9cff45cbe50de2047f25dd418eba003e84f0d16560972
ffi (1.17.3-x86_64-linux-gnu) sha256=3746b01f677aae7b16dc1acb7cb3cc17b3e35bdae7676a3f568153fb0e2c887f
fugit (1.12.1) sha256=5898f478ede9b415f0804e42b8f3fd53f814bd85eebffceebdbc34e1107aaf68
globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11
haml (7.2.0) sha256=87fd2b71f7feab1724337b090a7d767f5ab2d42f08c974f3ead673f18cfcd55a
grape (3.1.1) sha256=774f16782d917a90e69de0499dfaab571e5ad967569ac066a2b0b918af12de69
htmlentities (4.4.2) sha256=bbafbdf69f2eca9262be4efef7e43e6a1de54c95eb600f26984f71d2fe96c5c3
i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5
image_processing (1.14.0) sha256=754cc169c9c262980889bec6bfd325ed1dafad34f85242b5a07b60af004742fb
importmap-rails (2.2.3) sha256=7101be2a4dc97cf1558fb8f573a718404c5f6bcfe94f304bf1f39e444feeb16a
io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
irb (1.16.0) sha256=2abe56c9ac947cdcb2f150572904ba798c1e93c890c256f8429981a7675b0806
irb (1.17.0) sha256=168c4ddb93d8a361a045c41d92b2952c7a118fa73f23fe14e55609eb7a863aae
jbuilder (2.14.1) sha256=4eb26376ff60ef100cb4fd6fd7533cd271f9998327e86adf20fd8c0e69fabb42
json (2.18.0) sha256=b10506aee4183f5cf49e0efc48073d7b75843ce3782c68dbeb763351c08fd505
json (2.18.1) sha256=fe112755501b8d0466b5ada6cf50c8c3f41e897fa128ac5d263ec09eedc9f986
kamal (2.10.1) sha256=53b7ecb4c33dd83b1aedfc7aacd1c059f835993258a552d70d584c6ce32b6340
language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc
lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
loofah (2.25.0) sha256=df5ed7ac3bac6a4ec802df3877ee5cc86d027299f8952e6243b3dac446b060e6
mail (2.9.0) sha256=6fa6673ecd71c60c2d996260f9ee3dd387d4673b8169b502134659ece6d34941
mailcatcher (0.2.4) sha256=ba1d6f23d32f69929dce332d0aa7aeabddadd15507de474754e593807111dda9
marcel (1.1.0) sha256=fdcfcfa33cc52e93c4308d40e4090a5d4ea279e160a7f6af988260fa970e0bee
matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b
mini_magick (5.3.1) sha256=29395dfd76badcabb6403ee5aff6f681e867074f8f28ce08d78661e9e4a351c4
@@ -508,7 +522,8 @@ CHECKSUMS
minitest (6.0.1) sha256=7854c74f48e2e975969062833adc4013f249a4b212f5e7b9d5c040bf838d54bb
msgpack (1.8.0) sha256=e64ce0212000d016809f5048b48eb3a65ffb169db22238fb4b72472fecb2d732
mustermann (3.0.4) sha256=85fadcb6b3c6493a8b511b42426f904b7f27b282835502233dd154daab13aa22
net-imap (0.6.2) sha256=08caacad486853c61676cca0c0c47df93db02abc4a8239a8b67eb0981428acc6
mustermann-grape (1.1.0) sha256=8d258a986004c8f01ce4c023c0b037c168a9ed889cf5778068ad54398fa458c5
net-imap (0.6.3) sha256=9bab75f876596d09ee7bf911a291da478e0cd6badc54dfb82874855ccc82f2ad
net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3
net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8
net-scp (4.1.0) sha256=a99b0b92a1e5d360b0de4ffbf2dc0c91531502d3d4f56c28b0139a7c093d1a5d
@@ -516,21 +531,22 @@ CHECKSUMS
net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736
net-ssh (7.3.0) sha256=172076c4b30ce56fb25a03961b0c4da14e1246426401b0f89cba1a3b54bf3ef0
nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1
nokogiri (1.19.0-x86_64-linux-gnu) sha256=f482b95c713d60031d48c44ce14562f8d2ce31e3a9e8dd0ccb131e9e5a68b58c
nokogiri (1.19.1-x86_64-linux-gnu) sha256=1a4902842a186b4f901078e692d12257678e6133858d0566152fe29cdb98456a
ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912
pagy (8.6.3) sha256=537b2ee3119f237dd6c4a0d0a35c67a77b9d91ebb9d4f85e31407c2686774fb2
parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130
parser (3.3.10.1) sha256=06f6a725d2cd91e5e7f2b7c32ba143631e1f7c8ae2fb918fc4cebec187e6a688
parallel_tests (5.6.0) sha256=b2d7af382d1c0289daba65308d9143b3ad82d817d500c0ad1b4bde468beb13af
parser (3.3.10.2) sha256=6f60c84aa4bdcedb6d1a2434b738fe8a8136807b6adc8f7f53b97da9bc4e9357
pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6
prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193
prism (1.8.0) sha256=84453a16ef5530ea62c5f03ec16b52a459575ad4e7b9c2b360fd8ce2c39c1254
prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
propshaft (1.3.1) sha256=9acc664ef67e819ffa3d95bd7ad4c3623ea799110c5f4dee67fa7e583e74c392
psych (5.3.1) sha256=eb7a57cef10c9d70173ff74e739d843ac3b2c019a003de48447b2963d81b1974
public_suffix (7.0.2) sha256=9114090c8e4e7135c1fd0e7acfea33afaab38101884320c65aaa0ffb8e26a857
puma (7.1.0) sha256=e45c10cb124f224d448c98db653a75499794edbecadc440ad616cf50f2fd49dd
puma (7.2.0) sha256=bf8ef4ab514a4e6d4554cb4326b2004eba5036ae05cf765cfe51aba9706a72a8
raabro (1.4.0) sha256=d4fa9ff5172391edb92b242eed8be802d1934b1464061ae5e70d80962c5da882
racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f
rack (3.2.4) sha256=5d74b6f75082a643f43c1e76b419c40f0e5527fcfee1e669ac1e6b73c0ccb6f6
rack-protection (4.2.1) sha256=cf6e2842df8c55f5e4d1a4be015e603e19e9bc3a7178bae58949ccbb58558bac
rack (3.2.5) sha256=4cbd0974c0b79f7a139b4812004a62e4c60b145cba76422e288ee670601ed6d3
rack-session (2.1.1) sha256=0b6dc07dea7e4b583f58a48e8b806d4c9f1c6c9214ebc202ec94562cbea2e4e9
rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463
rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868
@@ -540,12 +556,13 @@ CHECKSUMS
railties (8.1.2) sha256=1289ece76b4f7668fc46d07e55cc992b5b8751f2ad85548b7da351b8c59f8055
rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a
rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c
rdoc (7.1.0) sha256=494899df0706c178596ca6e1d50f1b7eb285a9b2aae715be5abd742734f17363
rdoc (7.2.0) sha256=8650f76cd4009c3b54955eb5d7e3a075c60a57276766ebf36f9085e8c9f23192
regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4
reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835
rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
roo (3.0.0) sha256=6fdd7a9158d657c69768b4168754ff2110cc21fdc01a1bec1010820cb05c91b1
rubocop (1.82.1) sha256=09f1a6a654a960eda767aebea33e47603080f8e9c9a3f019bf9b94c9cab5e273
rswag-ui (2.17.0) sha256=5f707b9b5e8171ddf9f519f6e401e79e419bd1d07387508603e76124f2443212
rubocop (1.84.2) sha256=5692cea54168f3dc8cb79a6fe95c5424b7ea893c707ad7a4307b0585e88dbf5f
rubocop-ast (1.49.0) sha256=49c3676d3123a0923d333e20c6c2dbaaae2d2287b475273fddee0c61da9f71fd
rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834
rubocop-rails (2.34.3) sha256=10d37989024865ecda8199f311f3faca990143fbac967de943f88aca11eb9ad2
@@ -555,25 +572,22 @@ CHECKSUMS
ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef
rubyzip (3.2.2) sha256=c0ed99385f0625415c8f05bcae33fe649ed2952894a95ff8b08f26ca57ea5b3c
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
selenium-webdriver (4.40.0) sha256=16ef7aa9853c1d4b9d52eac45aafa916e3934c5c83cb4facb03f250adfd15e5b
sinatra (4.2.1) sha256=b7aeb9b11d046b552972ade834f1f9be98b185fa8444480688e3627625377080
skinny (0.2.2) sha256=f40caceccfe3e1d9826f60195a090f43ea7c1130c36b3170887db69a9fb52102
selenium-webdriver (4.41.0) sha256=cdc1173cd55cf186022cea83156cc2d0bec06d337e039b02ad25d94e41bedd22
simplecov (0.22.0) sha256=fe2622c7834ff23b98066bb0a854284b2729a569ac659f82621fc22ef36213a5
simplecov-html (0.13.2) sha256=bd0b8e54e7c2d7685927e8d6286466359b6f16b18cb0df47b508e8d73c777246
simplecov_json_formatter (0.1.4) sha256=529418fbe8de1713ac2b2d612aa3daa56d316975d307244399fa4838c601b428
solid_cable (3.0.12) sha256=a168a54731a455d5627af48d8441ea3b554b8c1f6e6cd6074109de493e6b0460
solid_cache (1.0.10) sha256=bc05a2fb3ac78a6f43cbb5946679cf9db67dd30d22939ededc385cb93e120d41
solid_queue (1.3.1) sha256=d9580111180c339804ff1a810a7768f69f5dc694d31e86cf1535ff2cd7a87428
solid_queue (1.3.2) sha256=44a53047be4255f616ff13fa5d35980e7b3eee6e31d957eadb88fbe8e0db4509
sqlite3 (2.9.0-x86_64-linux-gnu) sha256=72fff9bd750070ba3af695511ba5f0e0a2d8a9206f84869640b3e99dfaf3d5a5
sqlite3-ruby (1.3.3) sha256=140b6742875dd5afc3f30ab95720fe60d38e154ae1f4d0728e250778a04094e7
sshkit (1.25.0) sha256=c8c6543cdb60f91f1d277306d585dd11b6a064cb44eab0972827e4311ff96744
stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06
stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1
temple (0.10.4) sha256=b7a1e94b6f09038ab0b6e4fe0126996055da2c38bec53a8a336f075748fff72c
thin (2.0.1) sha256=5bbde5648377f5c3864b5da7cd89a23b5c2d8d8bb9435719f6db49644bcdade9
thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73
thruster (0.1.17-x86_64-linux) sha256=77b8f335075bd4ece7631dc84a19a710a1e6e7102cbce147b165b45851bdfcd3
tilt (2.7.0) sha256=0d5b9ba69f6a36490c64b0eee9f6e9aad517e20dcc848800a06eb116f08c6ab3
thruster (0.1.18-x86_64-linux) sha256=0ec1ff5f12289c1ac10cf8e28ce6b5266f4e73416b34a664b79d037c7d955c40
timeout (0.6.0) sha256=6d722ad619f96ee383a0c557ec6eb8c4ecb08af3af62098a0be5057bf00de1af
tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
turbo-rails (2.0.21) sha256=02070ea29fd11d8c1a07d9d7be980729a20e94e39b8c6c819f690f7959216bc7
turbo-rails (2.0.23) sha256=ee0d90733aafff056cf51ff11e803d65e43cae258cc55f6492020ec1f9f9315f
tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b
unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
@@ -584,7 +598,7 @@ CHECKSUMS
websocket-driver (0.8.0) sha256=ed0dba4b943c22f17f9a734817e808bc84cdce6a7e22045f5315aa57676d4962
websocket-extensions (0.1.5) sha256=1c6ba63092cda343eb53fc657110c71c754c56484aad42578495227d717a8241
xpath (3.2.0) sha256=6dfda79d91bb3b949b947ecc5919f042ef2f399b904013eb3ef6d20dd3a4082e
zeitwerk (2.7.4) sha256=2bef90f356bdafe9a6c2bd32bcd804f83a4f9b8bc27f3600fff051eb3edcec8b
zeitwerk (2.7.5) sha256=d8da92128c09ea6ec62c949011b00ed4a20242b255293dd66bf41545398f73dd
BUNDLED WITH
4.0.4
+17 -7
View File
@@ -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?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
-53
View File
@@ -1,53 +0,0 @@
# Sanasto Wiki TODO List
This document outlines planned improvements, bug fixes, and new features for the Sanasto Wiki application.
---
## High Priority
### Bugs
- [x] **Search input loses focus on filter change**: This issue has been resolved. The search input now retains focus when filters are applied.
- [x] **Mismatched `enum` syntax in models**: This issue has been resolved by correcting the `enum` declarations in `SuggestedMeaning.rb` and `User.rb` to use the updated Rails 8 syntax. All tests now pass.
- [ ] **[BUG] Mobile browser access is blocked by `:modern` browser requirement in `ApplicationController`**: This issue has been resolved by removing the `allow_browser versions: :modern` line from `ApplicationController`.
### Improvements
- [x] **Replace hardcoded `LANGUAGE_COLUMNS` with dynamic query**: The `Entry` model now dynamically fetches language codes via `SupportedLanguage.valid_codes` and caches them, removing the hardcoded array. This task is completed.
---
## Medium Priority
### New Features
- [ ] **Add user authentication:** The application currently lacks user authentication, which is a critical security vulnerability. Implementing a robust authentication system will protect sensitive data and ensure only authorized users can make changes.
- [ ] **Implement user roles and permissions:** The `README.md` defines user roles (contributor, reviewer, admin), but the application does not yet enforce these roles. Implementing a permissions system will ensure that users can only perform actions appropriate for their role.
- [ ] **Add create, edit, update, and destroy actions to `EntriesController`:** The `EntriesController` currently lacks the full set of CRUD actions needed for managing entries.
- [ ] **Add views for creating and editing entries:** Corresponding views for entry creation and editing are missing.
- [ ] **Add pages for user profiles, admin dashboard, and suggested meanings queue:** Essential UI components for user management and content review are absent.
### Discussion
- [x] **Add comments to entries**: Users can now add comments to entries.
- [x] **Submit alternative translations as suggested meanings**: This is part of the comments and discussion feature, and the infrastructure for this is in place. Need to verify that the suggested meaning model is used to actually submit alternative translations.
- [x] **Participate in translation discussions**: The comments section provides the foundation for this. Additional features might be needed for a full discussion.
- [ ] **Plan for user profile based notification exception**: Implement logic to allow users to opt out of notifications for specific language changes or comments on their profile.
### Refactoring
- [x] **Improve fixture quality**: The test fixtures have been refactored to resolve conflicts and foreign key violations, ensuring tests pass reliably. This task is completed.
---
## Low Priority
### New Features
- [x] **Add a download button for entries**: This feature has been implemented in the `EntriesController#download` action and is accessible from the UI. This task is completed.
### Improvements
- [ ] **Enhance UI/UX:** While functional, the user interface could be improved to be more intuitive and visually appealing. A design review and subsequent enhancements would improve the overall user experience.
- [ ] **Add tests for controllers and views:** The current test suite only covers the models. To ensure the reliability of the application, tests for the controllers and views should also be added.
@@ -12,6 +12,7 @@ class Admin::DashboardController < Admin::BaseController
@pending_suggestions_count = SuggestedMeaning.pending.count
@accepted_suggestions_count = SuggestedMeaning.accepted.count
@rejected_suggestions_count = SuggestedMeaning.rejected.count
@requested_entries_count = Entry.requested.count
@comment_count = Comment.count
@@ -14,9 +14,7 @@ class Admin::InvitationsController < Admin::BaseController
def create
@invitation = User.new(invitation_params)
@invitation.invitation_token = SecureRandom.urlsafe_base64(32)
@invitation.invitation_sent_at = Time.current
@invitation.invited_by = current_user
@invitation.invite_by(current_user)
@invitation.password = SecureRandom.urlsafe_base64(16)
if @invitation.save
@@ -29,6 +27,21 @@ class Admin::InvitationsController < Admin::BaseController
end
end
def resend
@invitation = User.find(params[:id])
if @invitation.invitation_accepted_at.present?
redirect_to admin_invitations_path, alert: "Cannot resend an accepted invitation."
return
end
@invitation.invite_by!(current_user)
InvitationMailer.invite(@invitation).deliver_later
redirect_to admin_invitations_path, notice: "Invitation resent to #{@invitation.email}"
end
def destroy
@invitation = User.find(params[:id])
@@ -0,0 +1,60 @@
class Admin::RequestsController < Admin::BaseController
def index
@requested_entries = Entry.requested
.includes(:requested_by)
.order(created_at: :desc)
@approved_entries = Entry.approved
.includes(:requested_by)
.order(updated_at: :desc)
end
def show
@entry = Entry.find(params[:id])
end
def edit
@entry = Entry.find(params[:id])
end
def update
@entry = Entry.find(params[:id])
if @entry.update(entry_params)
redirect_to admin_request_path(@entry), notice: "Request updated successfully."
else
flash.now[:alert] = "Error updating request."
render :edit, status: :unprocessable_entity
end
end
def approve
@entry = Entry.find(params[:id])
@user = @entry.requested_by
@user.invite_by!(current_user)
@entry.update!(status: :approved)
InvitationMailer.invite(@user, approved_entry: @entry).deliver_later
redirect_to admin_requests_path, notice: "Request approved and invitation sent to #{@user.email}."
end
def reject
@entry = Entry.find(params[:id])
@user = @entry.requested_by
entry_preview = [ @entry.fi, @entry.en, @entry.sv, @entry.no, @entry.ru, @entry.de ].compact.first || "Entry"
@entry.destroy!
@user.destroy! if @user.requested_entries.count.zero?
redirect_to admin_requests_path, notice: "Request '#{entry_preview}' has been rejected and deleted."
end
private
def entry_params
params.require(:entry).permit(:category, :fi, :en, :sv, :no, :ru, :de, :notes)
end
end
+48
View File
@@ -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
+119
View File
@@ -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
+41 -1
View File
@@ -1,7 +1,14 @@
class ApplicationController < ActionController::Base
include BotBlocker
include Pagy::Backend
# Changes to the importmap will invalidate the etag for HTML responses
stale_when_importmap_changes
SESSION_TIMEOUT = 3.days
before_action :check_session_timeout
helper_method :supported_languages, :current_user, :logged_in?, :admin?, :reviewer_or_admin?,
:contributor_or_above?, :setup_completed?
@@ -12,7 +19,40 @@ class ApplicationController < ActionController::Base
end
def current_user
@current_user ||= User.find_by(id: session[:user_id]) if session[:user_id]
return @current_user if defined?(@current_user)
# First check session
if session[:user_id]
@current_user = User.find_by(id: session[:user_id])
# Then check remember me cookie
elsif cookies.signed[:remember_token]
user = User.find_by_valid_remember_token(cookies.signed[:remember_token])
if user
session[:user_id] = user.id
@current_user = user
else
# Invalid or expired remember token, clear it
cookies.delete(:remember_token)
end
end
@current_user
end
def check_session_timeout
return unless logged_in?
return if cookies.signed[:remember_token].present?
if session[:last_activity_at].present?
last_activity = Time.parse(session[:last_activity_at])
if last_activity < SESSION_TIMEOUT.ago
reset_session
redirect_to login_path, alert: "Your session has expired. Please sign in again."
return
end
end
session[:last_activity_at] = Time.current.to_s
end
def logged_in?
+56
View File
@@ -0,0 +1,56 @@
module BotBlocker
extend ActiveSupport::Concern
included do
before_action :block_bots
end
private
def block_bots
return unless bot_request?
render plain: "Bot access is not allowed", status: :forbidden
end
def bot_request?
user_agent = request.user_agent.to_s.downcase
# List of known bot user agents
bot_patterns = [
"gptbot", # OpenAI GPTBot
"chatgpt", # ChatGPT
"claude-web", # Anthropic Claude
"bingbot", # Microsoft Bing
"googlebot", # Google
"baiduspider", # Baidu
"yandexbot", # Yandex
"duckduckbot", # DuckDuckGo
"slurp", # Yahoo
"facebookexternalhit", # Facebook
"twitterbot", # Twitter
"linkedinbot", # LinkedIn
"whatsapp", # WhatsApp
"telegrambot", # Telegram
"slackbot", # Slack
"discordbot", # Discord
"applebot", # Apple
"ia_archiver", # Alexa/Internet Archive
"petalbot", # Huawei
"seznambot", # Seznam
"ahrefsbot", # Ahrefs
"semrushbot", # SEMrush
"mj12bot", # Majestic
"dotbot", # OpenSiteExplorer
"rogerbot", # Moz
"exabot", # Exalead
"facebot", # Facebook
"spider", # Generic spiders
"crawler", # Generic crawlers
"scraper", # Generic scrapers
"bot" # Generic bots (last resort)
]
bot_patterns.any? { |pattern| user_agent.include?(pattern) }
end
end
+45
View File
@@ -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
+10 -12
View File
@@ -6,31 +6,29 @@ class EntriesController < ApplicationController
@category = params[:category].presence
@query = params[:q].to_s.strip
@starts_with = params[:starts_with].presence
@page = [ params[:page].to_i, 1 ].max
@per_page = 25
entries_scope = Entry.all
entries_scope = Entry.active_entries
entries_scope = entries_scope.with_category(@category)
entries_scope = entries_scope.search(@query, language_code: @language_code)
entries_scope = entries_scope.starts_with(@starts_with, language_code: @language_code) if @starts_with.present?
entries_scope = entries_scope.alphabetical_for(@language_code) if @query.blank? && @starts_with.blank? && @language_code.present?
entries_scope = entries_scope.order(created_at: :desc) if entries_scope.order_values.empty?
@total_entries = entries_scope.count
@total_pages = (@total_entries.to_f / @per_page).ceil
@entries = entries_scope.offset((@page - 1) * @per_page).limit(@per_page)
@pagy, @entries = pagy(entries_scope, items: 25)
@total_entries = @pagy.count
@entry_count = Entry.count
@verified_count = Entry.where(verified: true).count
@entry_count = Entry.active_entries.count
@requested_count = Entry.requested.count
@verified_count = Entry.active_entries.where(verified: true).count
@needs_review_count = @entry_count - @verified_count
@complete_entries_count = supported_languages.reduce(Entry.all) do |scope, language|
@complete_entries_count = supported_languages.reduce(Entry.active_entries) do |scope, language|
scope.where.not(language.code => [ nil, "" ])
end.count
@missing_entries_count = @entry_count - @complete_entries_count
@language_completion = supported_languages.index_with do |language|
next 0 if @entry_count.zero?
(Entry.where.not(language.code => [ nil, "" ]).count * 100.0 / @entry_count).round
(Entry.active_entries.where.not(language.code => [ nil, "" ]).count * 100.0 / @entry_count).round
end
if @language_code.present?
@@ -61,7 +59,7 @@ class EntriesController < ApplicationController
end
def download
@entries = Entry.order(:id)
@entries = Entry.active_entries.order(:id)
respond_to do |format|
format.xlsx do
filename = "sanasto-entries-#{Time.zone.today}.xlsx"
@@ -77,7 +75,7 @@ class EntriesController < ApplicationController
end
def entry_params
params.require(:entry).permit(:category)
params.require(:entry).permit(:category, :fi, :en, :sv, :no, :ru, :de, :notes)
end
def validate_language_code(code)
@@ -21,6 +21,9 @@ class InvitationsController < ApplicationController
invitation_token: nil
)
# Activate approved entries by this user
Entry.where(requested_by: @user, status: :approved).update_all(status: :active)
session[:user_id] = @user.id
redirect_to admin? ? admin_root_path : root_path, notice: "Welcome to Sanasto Wiki, #{@user.name}!"
else
@@ -0,0 +1,79 @@
class PasswordResetsController < ApplicationController
RESET_TOKEN_EXPIRY = 1.hour
def new
# Show request password reset form
end
def create
@user = User.find_by(email: params[:email]&.downcase&.strip)
if @user&.invitation_accepted_at.present?
@user.update!(
reset_password_token: SecureRandom.urlsafe_base64(32),
reset_password_sent_at: Time.current
)
PasswordResetMailer.reset(@user).deliver_later
elsif @user.present?
@user.invite_by!
InvitationMailer.invite(@user).deliver_later
end
redirect_to login_path, notice: "If that email address is in our system, you will receive password reset instructions."
end
def edit
@user = User.find_by(reset_password_token: params[:token])
if @user.nil?
redirect_to login_path, alert: "Invalid password reset link."
elsif password_reset_expired?(@user)
redirect_to new_password_reset_path, alert: "Password reset link has expired. Please request a new one."
end
end
def update
@user = User.find_by(reset_password_token: params[:token])
if @user.nil?
redirect_to login_path, alert: "Invalid password reset link."
return
end
if password_reset_expired?(@user)
redirect_to new_password_reset_path, alert: "Password reset link has expired. Please request a new one."
return
end
if params[:password].blank?
flash.now[:alert] = "Password cannot be blank."
render :edit, status: :unprocessable_entity
return
end
if params[:password] != params[:password_confirmation]
flash.now[:alert] = "Password confirmation doesn't match."
render :edit, status: :unprocessable_entity
return
end
@user.password = params[:password]
@user.password_confirmation = params[:password_confirmation]
@user.reset_password_token = nil
@user.reset_password_sent_at = nil
if @user.save
session[:user_id] = @user.id
redirect_to root_path, notice: "Your password has been reset successfully."
else
flash.now[:alert] = @user.errors.full_messages.join(", ")
render :edit, status: :unprocessable_entity
end
end
private
def password_reset_expired?(user)
user.reset_password_sent_at < RESET_TOKEN_EXPIRY.ago
end
end
+75
View File
@@ -0,0 +1,75 @@
class RequestsController < ApplicationController
def new
@entry = Entry.new
if current_user
@pending_count = current_user.requested_entries.where(status: [ :requested, :approved ]).count
elsif params[:email].present?
@pending_count = User.find_by(email: params[:email])&.requested_entries&.where(status: [ :requested, :approved ])&.count || 0
else
@pending_count = 0
end
end
def create
# If user is logged in, use their account
if current_user
@user = current_user
else
# Anonymous submission - need to find or create user
email = request_params[:email]
existing_user = User.find_by(email: email)
# Check if user has already accepted an invitation
if existing_user&.invitation_accepted_at.present?
redirect_to login_path, alert: "An account with this email already exists. Please log in."
return
end
# Use existing pending user or create new one
@user = existing_user || User.new(
name: request_params[:name],
email: email,
password: SecureRandom.alphanumeric(32),
role: :contributor
)
end
# Create entry in a transaction
ActiveRecord::Base.transaction do
# Save user only if it's a new record
if @user.new_record? && !@user.save
@pending_count = 0
@entry = Entry.new(entry_params)
flash.now[:alert] = "There was an error submitting your request. Please check the form."
render :new, status: :unprocessable_entity
raise ActiveRecord::Rollback
return
end
# Create entry
@entry = Entry.new(entry_params)
@entry.status = :requested
@entry.requested_by = @user
if @entry.save
redirect_to root_path, notice: "Thank you for your request! We'll review it and get back to you soon."
else
@pending_count = 0
flash.now[:alert] = "There was an error submitting your request. Please check the form."
render :new, status: :unprocessable_entity
raise ActiveRecord::Rollback
end
end
end
private
def request_params
params.require(:entry).permit(:name, :email, :category, :fi, :en, :sv, :no, :ru, :de, :notes)
end
def entry_params
request_params.except(:name, :email)
end
end
+22 -2
View File
@@ -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
+2
View File
@@ -1,4 +1,6 @@
module ApplicationHelper
include Pagy::Frontend
def language_name(code)
supported_languages.find { |l| l.code == code }&.name
end
+11
View File
@@ -28,4 +28,15 @@ module EntriesHelper
def format_entry_category(entry)
entry.category.to_s.tr("_", " ").capitalize
end
def format_entry_status(entry)
case entry.status
when "requested"
content_tag(:span, "Requested", class: "px-2 py-1 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800")
when "approved"
content_tag(:span, "Approved", class: "px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800")
when "active"
content_tag(:span, "Active", class: "px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800")
end
end
end
@@ -1,25 +1,46 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["modal", "button"]
static targets = ["modal", "languageSelect", "form"]
connect() {
this.modalTarget.classList.add("hidden")
this.buttonTarget.classList.remove("hidden")
}
open(event) {
event.preventDefault()
// Get the language code from the button that was clicked
const languageCode = event.currentTarget.dataset.languageCode
// Set the language select value if provided
if (languageCode && this.hasLanguageSelectTarget) {
this.languageSelectTarget.value = languageCode
}
this.modalTarget.classList.remove("hidden")
}
close(event) {
if (event.target === this.modalTarget) {
this.modalTarget.classList.add("hidden")
this.resetForm()
}
}
closeWithButton() {
closeWithButton(event) {
event.preventDefault()
this.modalTarget.classList.add("hidden")
this.resetForm()
}
stopPropagation(event) {
event.stopPropagation()
}
resetForm() {
if (this.hasFormTarget) {
this.formTarget.reset()
}
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
class ApplicationMailer < ActionMailer::Base
default from: "from@example.com"
default from: Rails.application.credentials.dig(:mail, :from)
layout "mailer"
end
+9 -2
View File
@@ -1,12 +1,19 @@
class InvitationMailer < ApplicationMailer
def invite(user)
def invite(user, approved_entry: nil)
@user = user
@approved_entry = approved_entry
@invitation_url = invitation_url(@user.invitation_token)
@expires_at = @user.invitation_sent_at + User::INVITATION_TOKEN_EXPIRY
subject = if @approved_entry
"Your entry request has been approved - Join Sanasto Wiki"
else
"You've been invited to join Sanasto Wiki"
end
mail(
to: @user.email,
subject: "You've been invited to join Sanasto Wiki"
subject: subject
)
end
end
+9
View File
@@ -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
+12
View File
@@ -1,15 +1,21 @@
class Entry < ApplicationRecord
belongs_to :created_by, class_name: "User", optional: true
belongs_to :updated_by, class_name: "User", optional: true
belongs_to :requested_by, class_name: "User", optional: true
has_many :suggested_meanings, dependent: :destroy
has_many :comments, as: :commentable, dependent: :destroy
enum :category, %i[word phrase proper_name title reference other]
enum :status, %i[requested approved active], default: :active
validates :category, presence: true
validate :at_least_one_translation
scope :with_category, ->(cat) { cat.present? ? where(category: cat) : all }
scope :requested, -> { where(status: :requested) }
scope :approved, -> { where(status: :approved) }
scope :active_entries, -> { where(status: :active) }
def self.search(query, language_code: nil)
return all if query.blank?
@@ -43,4 +49,10 @@ class Entry < ApplicationRecord
def self.valid_lang?(code)
SupportedLanguage.valid_codes.include?(code.to_s)
end
def at_least_one_translation
if [ fi, en, sv, no, ru, de ].all?(&:blank?)
errors.add(:base, "At least one language translation is required")
end
end
end
+45 -1
View File
@@ -6,6 +6,7 @@ class User < ApplicationRecord
has_many :created_entries, class_name: "Entry", foreign_key: :created_by_id, dependent: :nullify
has_many :updated_entries, class_name: "Entry", foreign_key: :updated_by_id, dependent: :nullify
has_many :requested_entries, class_name: "Entry", foreign_key: :requested_by_id, dependent: :nullify
has_many :submitted_suggested_meanings,
class_name: "SuggestedMeaning",
foreign_key: :submitted_by_id,
@@ -18,14 +19,18 @@ class User < ApplicationRecord
enum :role, %i[contributor reviewer admin]
validates :email, presence: true, uniqueness: true
validates :email, presence: true, uniqueness: { case_sensitive: false }
validates :password, length: { minimum: 12 }, if: -> { password.present? }
before_validation :normalize_email
scope :by_role, ->(role) { where(role: role) if role.present? }
scope :search_email, ->(q) { where("email LIKE ?", "%#{sanitize_sql_like(q)}%") if q.present? }
# Invitation token expires after 14 days
INVITATION_TOKEN_EXPIRY = 14.days
# Remember me token expires after 2 weeks
REMEMBER_TOKEN_EXPIRY = 2.weeks
def invitation_expired?
return false if invitation_sent_at.nil?
@@ -36,10 +41,49 @@ class User < ApplicationRecord
invitation_token.present? && invitation_accepted_at.nil? && !invitation_expired?
end
def invite_by(invitee)
self.invited_by = invitee if invitee && invited_by.nil?
self.invitation_token = SecureRandom.urlsafe_base64(32)
self.invitation_sent_at = Time.current
end
def invite_by!(invitee = nil)
invite_by(invitee)
save!
end
def self.find_by_valid_invitation_token(token)
where(invitation_token: token)
.where(invitation_accepted_at: nil)
.where("invitation_sent_at > ?", INVITATION_TOKEN_EXPIRY.ago)
.first
end
def remember_me
self.remember_token = SecureRandom.urlsafe_base64(32)
self.remember_created_at = Time.current
save(validate: false)
remember_token
end
def forget_me
update_columns(remember_token: nil, remember_created_at: nil)
end
def remember_token_expired?
return true if remember_created_at.nil?
remember_created_at < REMEMBER_TOKEN_EXPIRY.ago
end
def self.find_by_valid_remember_token(token)
user = find_by(remember_token: token)
return nil if user.nil? || user.remember_token_expired?
user
end
private
def normalize_email
self.email = email.downcase.strip if email.present?
end
end
+10 -2
View File
@@ -18,10 +18,12 @@
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<%= link_to admin_users_path do %>
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Total Users</dt>
<dd class="text-3xl font-semibold text-gray-900"><%= @user_count %></dd>
</dl>
<% end %>
</div>
</div>
</div>
@@ -44,10 +46,12 @@
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<%= link_to root_path do %>
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Total Entries</dt>
<dd class="text-3xl font-semibold text-gray-900"><%= @entry_count %></dd>
</dl>
<% end %>
</div>
</div>
</div>
@@ -69,10 +73,12 @@
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<%= link_to admin_requests_path do %>
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Suggestions</dt>
<dd class="text-3xl font-semibold text-gray-900"><%= @pending_suggestions_count %></dd>
<dt class="text-sm font-medium text-gray-500 truncate">Suggestions / Requests</dt>
<dd class="text-3xl font-semibold text-gray-900"><%= @pending_suggestions_count %> / <%= @requested_entries_count %></dd>
</dl>
<% end %>
</div>
</div>
</div>
@@ -94,10 +100,12 @@
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<%= link_to admin_invitations_path do %>
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">Pending Invites</dt>
<dd class="text-3xl font-semibold text-gray-900"><%= @pending_invitations %></dd>
</dl>
<% end %>
</div>
</div>
</div>
+2 -1
View File
@@ -65,7 +65,8 @@
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<%= invitation.invited_by&.name || invitation.invited_by&.email || "-" %>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-4">
<%= button_to "Re-send", resend_admin_invitation_path(invitation), method: :put, class: "text-blue-600 hover:text-blue-900" %>
<%= button_to "Cancel", admin_invitation_path(invitation), method: :delete, data: { turbo_confirm: "Are you sure you want to cancel this invitation?" }, class: "text-red-600 hover:text-red-900" %>
</td>
</tr>
+38
View File
@@ -0,0 +1,38 @@
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Edit Entry Request</h1>
<p class="text-gray-600 mt-2">Modify the entry details before approval.</p>
</div>
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<%= form_with model: @entry, url: admin_request_path(@entry), method: :patch, class: "space-y-6" do |f| %>
<% if @entry.errors.any? %>
<div class="mx-6 mt-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<h3 class="font-semibold text-red-800 mb-2">Please fix the following errors:</h3>
<ul class="list-disc list-inside text-red-700 text-sm space-y-1">
<% @entry.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="px-6 pt-6 space-y-4">
<%= render 'entries/form_fields', f: f %>
<div class="border-t border-gray-200 pt-6">
<h3 class="text-sm font-semibold text-gray-700 mb-2">Requester (Read-only)</h3>
<div class="bg-gray-50 rounded-lg p-4 space-y-1 text-sm">
<div><span class="font-medium">Name:</span> <%= @entry.requested_by&.name %></div>
<div><span class="font-medium">Email:</span> <%= @entry.requested_by&.email %></div>
</div>
</div>
</div>
<div class="border-t border-gray-200 px-6 py-4 bg-gray-50 flex gap-3">
<%= f.submit "Save Changes", class: "px-6 py-2 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold rounded-lg transition" %>
<%= link_to "Cancel", admin_request_path(@entry), class: "px-6 py-2 bg-gray-200 hover:bg-gray-300 text-gray-800 font-semibold rounded-lg transition" %>
</div>
<% end %>
</div>
</div>
+126
View File
@@ -0,0 +1,126 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Entry Requests</h1>
<p class="text-gray-600 mt-2">Review and manage entry requests from public users.</p>
</div>
<!-- Requested Entries Section -->
<div class="mb-12">
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<div class="bg-yellow-50 border-b border-yellow-100 px-6 py-4">
<h2 class="text-xl font-bold text-yellow-900 flex items-center gap-2">
<span>⏳</span> Pending Review
<span class="text-sm font-normal text-yellow-700">(<%= @requested_entries.count %> total)</span>
</h2>
</div>
<% if @requested_entries.any? %>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Entry</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Category</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Requester</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<% @requested_entries.each do |entry| %>
<tr class="hover:bg-gray-50 transition">
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900">
<%= [entry.fi, entry.en, entry.sv, entry.no, entry.ru, entry.de].compact.first || "(empty)" %>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-indigo-100 text-indigo-800">
<%= entry.category.humanize %>
</span>
</td>
<td class="px-6 py-4">
<div class="text-sm text-gray-900"><%= entry.requested_by&.name %></div>
<div class="text-xs text-gray-500"><%= entry.requested_by&.email %></div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<%= entry.created_at.strftime("%b %d, %Y") %>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
<%= link_to "View", admin_request_path(entry), class: "text-indigo-600 hover:text-indigo-900" %>
<%= link_to "Edit", edit_admin_request_path(entry), class: "text-blue-600 hover:text-blue-900" %>
<%= button_to "Approve", approve_admin_request_path(entry), method: :post, class: "inline text-green-600 hover:text-green-900", form: { data: { turbo_confirm: "Send invitation to #{entry.requested_by&.email}?" } } %>
<%= button_to "Reject", reject_admin_request_path(entry), method: :delete, class: "inline text-red-600 hover:text-red-900", form: { data: { turbo_confirm: "Delete this request?" } } %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% else %>
<div class="px-6 py-12 text-center text-gray-500">
No pending requests at the moment.
</div>
<% end %>
</div>
</div>
<!-- Approved Entries Section -->
<div>
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<div class="bg-blue-50 border-b border-blue-100 px-6 py-4">
<h2 class="text-xl font-bold text-blue-900 flex items-center gap-2">
<span>✅</span> Approved (Awaiting User Acceptance)
<span class="text-sm font-normal text-blue-700">(<%= @approved_entries.count %> total)</span>
</h2>
</div>
<% if @approved_entries.any? %>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Entry</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Category</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Requester</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Approved</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<% @approved_entries.each do |entry| %>
<tr class="hover:bg-gray-50 transition">
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900">
<%= [entry.fi, entry.en, entry.sv, entry.no, entry.ru, entry.de].compact.first || "(empty)" %>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-indigo-100 text-indigo-800">
<%= entry.category.humanize %>
</span>
</td>
<td class="px-6 py-4">
<div class="text-sm text-gray-900"><%= entry.requested_by&.name %></div>
<div class="text-xs text-gray-500"><%= entry.requested_by&.email %></div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<%= entry.updated_at.strftime("%b %d, %Y") %>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2">
<%= link_to "View", admin_request_path(entry), class: "text-indigo-600 hover:text-indigo-900" %>
<%= button_to "Reject", reject_admin_request_path(entry), method: :delete, class: "inline text-red-600 hover:text-red-900", form: { data: { turbo_confirm: "Delete this approved request?" } } %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% else %>
<div class="px-6 py-12 text-center text-gray-500">
No approved entries awaiting user acceptance.
</div>
<% end %>
</div>
</div>
</div>
+96
View File
@@ -0,0 +1,96 @@
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900">Entry Request Details</h1>
</div>
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<div class="border-b border-gray-200 px-6 py-4 bg-gray-50">
<div class="flex items-center justify-between">
<h2 class="text-xl font-bold text-gray-900">Entry Information</h2>
<%= content_tag(:span, @entry.status.titleize, class: "px-3 py-1 text-sm font-semibold rounded-full #{@entry.requested? ? 'bg-yellow-100 text-yellow-800' : 'bg-blue-100 text-blue-800'}") %>
</div>
</div>
<div class="px-6 py-6 space-y-6">
<div>
<h3 class="text-sm font-semibold text-gray-700 mb-2">Category</h3>
<span class="px-3 py-1 text-sm font-semibold rounded-full bg-indigo-100 text-indigo-800">
<%= @entry.category.humanize %>
</span>
</div>
<div class="border-t border-gray-200 pt-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Translations</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="bg-gray-50 rounded-lg p-4">
<div class="text-sm font-medium text-gray-700 mb-1">🇫🇮 Finnish</div>
<div class="text-gray-900"><%= @entry.fi.presence || "—" %></div>
</div>
<div class="bg-gray-50 rounded-lg p-4">
<div class="text-sm font-medium text-gray-700 mb-1">🇬🇧 English</div>
<div class="text-gray-900"><%= @entry.en.presence || "—" %></div>
</div>
<div class="bg-gray-50 rounded-lg p-4">
<div class="text-sm font-medium text-gray-700 mb-1">🇸🇪 Swedish</div>
<div class="text-gray-900"><%= @entry.sv.presence || "—" %></div>
</div>
<div class="bg-gray-50 rounded-lg p-4">
<div class="text-sm font-medium text-gray-700 mb-1">🇳🇴 Norwegian</div>
<div class="text-gray-900"><%= @entry.no.presence || "—" %></div>
</div>
<div class="bg-gray-50 rounded-lg p-4">
<div class="text-sm font-medium text-gray-700 mb-1">🇷🇺 Russian</div>
<div class="text-gray-900"><%= @entry.ru.presence || "—" %></div>
</div>
<div class="bg-gray-50 rounded-lg p-4">
<div class="text-sm font-medium text-gray-700 mb-1">🇩🇪 German</div>
<div class="text-gray-900"><%= @entry.de.presence || "—" %></div>
</div>
</div>
</div>
<% if @entry.notes.present? %>
<div class="border-t border-gray-200 pt-6">
<h3 class="text-sm font-semibold text-gray-700 mb-2">Notes</h3>
<div class="bg-gray-50 rounded-lg p-4 text-gray-900 whitespace-pre-wrap">
<%= @entry.notes %>
</div>
</div>
<% end %>
<div class="border-t border-gray-200 pt-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Requester Information</h3>
<div class="bg-gray-50 rounded-lg p-4 space-y-2">
<div>
<span class="text-sm font-medium text-gray-700">Name:</span>
<span class="text-gray-900 ml-2"><%= @entry.requested_by&.name %></span>
</div>
<div>
<span class="text-sm font-medium text-gray-700">Email:</span>
<span class="text-gray-900 ml-2"><%= @entry.requested_by&.email %></span>
</div>
<div>
<span class="text-sm font-medium text-gray-700">Submitted:</span>
<span class="text-gray-900 ml-2"><%= @entry.created_at.strftime("%B %d, %Y at %I:%M %p") %></span>
</div>
</div>
</div>
</div>
<div class="border-t border-gray-200 px-6 py-4 bg-gray-50 flex flex-wrap gap-3">
<%= link_to "← Back to Requests", admin_requests_path, class: "px-4 py-2 bg-gray-200 hover:bg-gray-300 text-gray-800 font-semibold rounded-lg transition" %>
<% if @entry.requested? %>
<%= link_to "Edit", edit_admin_request_path(@entry), class: "px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg transition" %>
<%= button_to "Approve & Send Invitation", approve_admin_request_path(@entry), method: :post, class: "px-4 py-2 bg-green-600 hover:bg-green-700 text-white font-semibold rounded-lg transition", form: { data: { turbo_confirm: "Send invitation to #{@entry.requested_by&.email}?" } } %>
<% end %>
<%= button_to "Reject", reject_admin_request_path(@entry), method: :delete, class: "px-4 py-2 bg-red-600 hover:bg-red-700 text-white font-semibold rounded-lg transition ml-auto", form: { data: { turbo_confirm: "Are you sure you want to delete this request?" } } %>
</div>
</div>
</div>
+10 -6
View File
@@ -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 %>
+20 -5
View File
@@ -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">
&times;
</button>
</div>
<%= render "entries/comment_form", entry: entry %>
</div>
+3 -11
View File
@@ -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">
&times;
</button>
</div>
<%= render "entries/comment_form", entry: entry %>
</div>
<%= render "entries/comment_modal_content", entry: entry %>
</div>
</div>
+62
View File
@@ -0,0 +1,62 @@
<%# Local variables expected:
# - f: form builder object
# - show_category: boolean (default: true) - whether to show category field
# - show_notes: boolean (default: true) - whether to show notes field
# - category_prompt: string or false (default: "Select a category") - prompt for category select, or false for no prompt
%>
<% show_category = local_assigns.fetch(:show_category, true) %>
<% show_notes = local_assigns.fetch(:show_notes, true) %>
<% category_prompt = local_assigns.fetch(:category_prompt, "Select a category") %>
<% if show_category %>
<div>
<%= f.label :category, "Category", class: "block text-sm font-semibold text-gray-700 mb-2" %>
<%= f.select :category,
Entry.categories.keys.map { |cat| [cat.humanize, cat] },
category_prompt ? { prompt: category_prompt } : {},
{ required: category_prompt.present?, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" } %>
</div>
<% end %>
<div class="border-t border-gray-200 pt-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Translations (at least one required)</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<%= f.label :fi, "🇫🇮 Finnish", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= f.text_field :fi, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
</div>
<div>
<%= f.label :en, "🇬🇧 English", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= f.text_field :en, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
</div>
<div>
<%= f.label :sv, "🇸🇪 Swedish", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= f.text_field :sv, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
</div>
<div>
<%= f.label :no, "🇳🇴 Norwegian", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= f.text_field :no, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
</div>
<div>
<%= f.label :ru, "🇷🇺 Russian", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= f.text_field :ru, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
</div>
<div>
<%= f.label :de, "🇩🇪 German", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= f.text_field :de, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
</div>
</div>
</div>
<% if show_notes %>
<div>
<%= f.label :notes, "Additional Notes (optional)", class: "block text-sm font-semibold text-gray-700 mb-2" %>
<%= f.text_area :notes, rows: 4, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition", placeholder: "Any additional context or information about this entry..." %>
</div>
<% end %>
@@ -1,8 +0,0 @@
<details class="text-xs">
<summary class="inline-flex items-center gap-1 text-indigo-600 font-semibold cursor-pointer">
Add comment
</summary>
<div class="mt-3">
<%= render "entries/comment_form", entry: entry, language_code: language_code %>
</div>
</details>
+15 -10
View File
@@ -42,6 +42,9 @@
<tr>
<td colspan="<%= table_languages.size + 1 %>" class="px-6 py-6 text-slate-500">
No entries matched your filters.
<br><br>
<%= link_to "Request a new entry", new_request_path,
class: "text-indigo-600 font-semibold hover:text-indigo-800 underline" %>
</td>
</tr>
<% else %>
@@ -83,17 +86,19 @@
</div>
<div class="flex items-center justify-between mt-4 text-sm text-slate-600">
<div>Page <%= @page %> of <%= [@total_pages, 1].max %></div>
<div><%= pagy_info(@pagy).html_safe %></div>
<div class="flex items-center gap-2">
<% previous_page = @page > 1 ? @page - 1 : nil %>
<% next_page = @page < @total_pages ? @page + 1 : nil %>
<% pagination_params = { q: @query.presence, category: @category.presence, language: @language_code.presence, starts_with: @starts_with.presence }.compact %>
<%= link_to "Previous", previous_page ? entries_path(pagination_params.merge(page: previous_page)) : "#",
class: "px-3 py-1.5 rounded-md border border-slate-200 #{previous_page ? 'hover:border-indigo-300' : 'text-slate-300 cursor-not-allowed'}",
data: { turbo_stream: true } %>
<%= link_to "Next", next_page ? entries_path(pagination_params.merge(page: next_page)) : "#",
class: "px-3 py-1.5 rounded-md border border-slate-200 #{next_page ? 'hover:border-indigo-300' : 'text-slate-300 cursor-not-allowed'}",
data: { turbo_stream: true } %>
<%
pagination_params = { q: @query.presence, category: @category.presence, language: @language_code.presence, starts_with: @starts_with.presence }.compact
prev_url = @pagy.prev ? entries_path(pagination_params.merge(page: @pagy.prev)) : nil
next_url = @pagy.next ? entries_path(pagination_params.merge(page: @pagy.next)) : nil
%>
<%= link_to "Previous", prev_url || "#",
class: "px-3 py-1.5 rounded-md border border-slate-200 #{'opacity-50 pointer-events-none' unless prev_url}",
data: (prev_url ? { turbo_stream: true } : {}) %>
<%= link_to "Next", next_url || "#",
class: "px-3 py-1.5 rounded-md border border-slate-200 #{'opacity-50 pointer-events-none' unless next_url}",
data: (next_url ? { turbo_stream: true } : {}) %>
</div>
</div>
</div>
+35 -50
View File
@@ -1,70 +1,55 @@
<% content_for :title, "Edit Entry" %>
<nav class="sticky top-0 z-50 bg-white border-b border-slate-200">
<div class="max-w-5xl mx-auto px-4 h-16 flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-xl font-bold tracking-tight text-indigo-600">Sanasto</span>
<span class="text-xl font-light text-slate-400">Wiki</span>
</div>
<div class="flex items-center gap-4">
<%= link_to "Browse", entries_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600" %>
<%= link_to "Download XLSX", download_entries_path(format: :xlsx), class: "text-sm font-semibold text-indigo-700 px-3 py-2 rounded-lg border border-indigo-200 bg-indigo-50 hover:bg-indigo-100 transition" %>
</div>
</div>
</nav>
<div class="min-h-screen flex flex-col">
<%= render "shared/header", show_request_button: false, show_browse_button: true %>
<main class="max-w-5xl mx-auto px-4 py-8 space-y-6">
<div class="flex items-center justify-between">
<%= link_to "← Back to entry", entry_path(@entry), class: "text-sm text-slate-500 hover:text-indigo-600" %>
<%= link_to "Back to search", entries_path, class: "text-sm text-slate-500 hover:text-indigo-600" %>
</div>
<%= render "shared/notifications" %>
<div class="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
<div class="px-6 py-4 border-b border-slate-100 bg-slate-50/50 flex justify-between items-center">
<span class="text-[10px] font-black uppercase tracking-widest text-slate-400">Edit Category</span>
<div class="flex-1 bg-gradient-to-br from-indigo-50 via-white to-purple-50 flex items-center justify-center px-4 py-12">
<div class="max-w-2xl w-full">
<div class="bg-white rounded-2xl shadow-xl p-8">
<div class="text-center mb-8">
<div class="flex items-center justify-center gap-3 mb-2">
<h1 class="text-3xl font-bold text-gray-900">Edit Entry</h1>
<% if @entry.verified? %>
<div class="flex items-center gap-1.5 text-emerald-600">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>
<span class="text-xs font-bold">Verified</span>
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>
<span class="text-sm font-bold">Verified</span>
</div>
<% else %>
<span class="text-xs font-semibold text-amber-600">Unverified</span>
<span class="text-sm font-semibold text-amber-600 px-3 py-1 rounded-full bg-amber-50">Unverified</span>
<% end %>
</div>
<div class="p-6 space-y-6">
<%= form_with model: @entry, class: "space-y-4" do |form| %>
<div>
<%= form.label :category, "Category", class: "block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2" %>
<%= form.select :category,
Entry.categories.keys.map { |key| [key.tr("_", " ").capitalize, key] },
{},
class: "block w-full border-slate-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" %>
<p class="text-gray-600">Update the translations and details for this entry.</p>
</div>
<div class="flex justify-end">
<%= form.submit "Save Category", class: "bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition" %>
<%= form_with model: @entry, class: "space-y-6" do |f| %>
<% if @entry.errors.any? %>
<div class="p-4 bg-red-50 border border-red-200 rounded-lg">
<h3 class="font-semibold text-red-800 mb-2">Please fix the following errors:</h3>
<ul class="list-disc list-inside text-red-700 text-sm space-y-1">
<% @entry.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="grid grid-cols-1 md:grid-cols-2 gap-y-6 gap-x-12">
<% supported_languages.each do |language| %>
<% translation = entry_translation_for(@entry, language.code) %>
<% next if translation.blank? %>
<div class="space-y-2">
<div class="grid grid-cols-2">
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-tight"><%= "#{language.name} (#{language.code.upcase})" %></span>
</div>
<p class="text-2xl font-semibold text-slate-800"><%= translation %></p>
</div>
<% end %>
<div class="space-y-6">
<%= render 'entries/form_fields', f: f, category_prompt: false %>
</div>
<% if @entry.notes.present? %>
<div class="mt-6 pt-5 border-t border-slate-100">
<h4 class="text-xs font-bold text-slate-400 uppercase mb-2">Context & Notes</h4>
<p class="text-sm text-slate-600 italic"><%= @entry.notes %></p>
<div class="flex flex-col sm:flex-row gap-4 pt-4">
<%= f.submit "Save Changes", class: "flex-1 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-3 px-6 rounded-lg transition shadow-md hover:shadow-lg" %>
<%= link_to "Cancel", entry_path(@entry), class: "flex-1 text-center bg-gray-100 hover:bg-gray-200 text-gray-800 font-semibold py-3 px-6 rounded-lg transition" %>
</div>
<% end %>
<div class="mt-6 text-center text-sm text-gray-600">
<%= link_to "← Back to entry", entry_path(@entry), class: "text-indigo-600 hover:text-indigo-800 font-semibold" %> •
<%= link_to "Back to search", entries_path, class: "text-indigo-600 hover:text-indigo-800 font-semibold" %>
</div>
</div>
</div>
</div>
</div>
</main>
+2 -19
View File
@@ -1,26 +1,9 @@
<% content_for :title, "Sanasto Wiki" %>
<div class="min-h-screen flex flex-col">
<header class="bg-white border-b border-slate-200">
<div class="max-w-7xl mx-auto px-4">
<div class="h-16 flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-xl font-bold tracking-tight text-indigo-600">Sanasto</span>
<span class="text-xl font-light text-slate-400">Wiki</span>
</div>
<div class="flex items-center gap-3">
<%= link_to "Download XLSX", download_entries_path(format: :xlsx),
class: "text-xs font-bold text-indigo-700 px-3 py-2 rounded-md border border-indigo-200 bg-indigo-50 hover:bg-indigo-100 transition" %>
<% if admin? %>
<%= link_to "Admin", admin_root_path, class: "bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition" %>
<% else %>
<%= link_to "Sign In", login_path, class: "bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition" %>
<% end %>
</div>
</div>
</div>
</header>
<%= render "shared/header" %>
<%= render "shared/notifications" %>
<div class="flex-1 flex flex-col">
<section id="search">
+8 -6
View File
@@ -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>
+69 -1
View File
@@ -79,6 +79,32 @@
font-size: 14px;
border-radius: 4px;
}
.entry-box {
background: #f0fdf4;
border-left: 4px solid #10b981;
padding: 16px;
margin: 20px 0;
border-radius: 4px;
}
.entry-box h3 {
margin: 0 0 12px 0;
color: #065f46;
font-size: 16px;
}
.entry-translations {
display: grid;
grid-template-columns: auto 1fr;
gap: 8px;
margin-top: 12px;
}
.entry-translations dt {
font-weight: 600;
color: #064e3b;
}
.entry-translations dd {
margin: 0;
color: #1e293b;
}
</style>
</head>
<body>
@@ -90,11 +116,53 @@
<div class="content">
<p class="greeting">Hello <%= @user.name %>,</p>
<% if @approved_entry %>
<p>
The <strong>Sanasto Wiki</strong> let you search and compare, or download, translations across languages used all over the living Christianity.
Great news! Your entry request has been <strong>approved</strong> and is ready to be published.
</p>
<div class="entry-box">
<h3>✓ Your Approved Entry</h3>
<p style="margin: 0 0 4px 0;"><strong>Category:</strong> <%= @approved_entry.category.to_s.humanize %></p>
<dl class="entry-translations">
<% if @approved_entry.fi.present? %>
<dt>🇫🇮 Finnish:</dt>
<dd><%= @approved_entry.fi %></dd>
<% end %>
<% if @approved_entry.en.present? %>
<dt>🇬🇧 English:</dt>
<dd><%= @approved_entry.en %></dd>
<% end %>
<% if @approved_entry.sv.present? %>
<dt>🇸🇪 Swedish:</dt>
<dd><%= @approved_entry.sv %></dd>
<% end %>
<% if @approved_entry.no.present? %>
<dt>🇳🇴 Norwegian:</dt>
<dd><%= @approved_entry.no %></dd>
<% end %>
<% if @approved_entry.ru.present? %>
<dt>🇷🇺 Russian:</dt>
<dd><%= @approved_entry.ru %></dd>
<% end %>
<% if @approved_entry.de.present? %>
<dt>🇩🇪 German:</dt>
<dd><%= @approved_entry.de %></dd>
<% end %>
</dl>
</div>
<p>
To complete the process and publish your entry, please accept this invitation to create your account on <strong>Sanasto Wiki</strong>.
</p>
<% else %>
<p>
The <strong>Sanasto Wiki</strong> let you search words and expressions you might need for interpretation work in the living Christianity.
</p>
<p>With a login account, you can contribute to this work.</p>
<% end %>
<div class="info-box">
<p style="margin: 0;"><strong>Your Account Details:</strong></p>
@@ -4,9 +4,28 @@ SANASTO WIKI - INVITATION
Hello <%= @user.name %>,
<% if @approved_entry %>
Great news! Your entry request has been APPROVED and is ready to be published.
YOUR APPROVED ENTRY
-------------------
Category: <%= @approved_entry.category.to_s.humanize %>
Translations:
<% if @approved_entry.fi.present? %> • Finnish: <%= @approved_entry.fi %>
<% end %><% if @approved_entry.en.present? %> • English: <%= @approved_entry.en %>
<% end %><% if @approved_entry.sv.present? %> • Swedish: <%= @approved_entry.sv %>
<% end %><% if @approved_entry.no.present? %> • Norwegian: <%= @approved_entry.no %>
<% end %><% if @approved_entry.ru.present? %> • Russian: <%= @approved_entry.ru %>
<% end %><% if @approved_entry.de.present? %> • German: <%= @approved_entry.de %>
<% end %>
To complete the process and publish your entry, please accept this invitation to create your account on Sanasto Wiki.
<% else %>
The Sanasto Wiki let you search and compare, or download, translations across languages used all over the living Christianity.
With a login account, you can contribute to this work.
<% end %>
YOUR ACCOUNT DETAILS
--------------------
+1 -1
View File
@@ -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,
+92 -14
View File
@@ -16,31 +16,109 @@
<header class="bg-white border-b border-slate-200">
<div class="max-w-7xl mx-auto px-4">
<div class="h-16 flex items-center justify-between">
<div class="flex items-center gap-2">
<%= link_to admin_dashboard_path, class: "flex items-center gap-2" do %>
<span class="text-xl font-bold tracking-tight text-indigo-600">Sanasto</span>
<span class="text-xl font-light text-slate-400">Admin</span>
</div>
<nav class="flex items-center gap-3">
<% end %>
<!-- Desktop Navigation -->
<nav class="hidden md:flex items-center gap-3">
<%= link_to "Dashboard", admin_dashboard_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %>
<%= link_to "Users", admin_users_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %>
<%= link_to "Invitations", admin_invitations_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %>
<% requested_count = Entry.requested.count %>
<% gap = requested_count.zero? ? '' : 'pr-4' %>
<%= link_to admin_requests_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition relative #{gap}" do %>
Requests
<% if requested_count > 0 %>
<span class="absolute -top-1 -right-1 bg-red-500 text-white text-xs font-bold rounded-full h-5 w-5 flex items-center justify-center">
<%= requested_count %>
</span>
<% end %>
<% end %>
<%= link_to "Back to Site", root_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %>
<%= button_to "Log Out", logout_path, method: :delete, form: { data: { turbo: false }, style: "display: inline-block;" }, class: "bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition cursor-pointer" %>
<%= button_to "Log Out", logout_path, method: :delete, data: { turbo: false }, form: { style: "display: inline-block;" }, class: "bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition cursor-pointer" %>
</nav>
<!-- Mobile Navigation -->
<div class="flex md:hidden items-center gap-3">
<span class="text-sm text-slate-600">
<%= current_user.name.split.first %>
</span>
<button type="button" id="admin-mobile-menu-button" class="p-2 text-slate-600 hover:text-indigo-600 focus:outline-none">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
</button>
</div>
</div>
<!-- Mobile Menu Dropdown -->
<div id="admin-mobile-menu" class="hidden md:hidden border-t border-slate-200 py-3">
<div class="py-2 px-2 border-b border-slate-200 mb-2">
<span class="text-sm font-medium text-slate-900">
<%= current_user.name %>
</span>
</div>
<nav class="flex flex-col space-y-1">
<%= link_to "Dashboard", admin_dashboard_path, class: "px-2 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 rounded transition" %>
<%= link_to "Users", admin_users_path, class: "px-2 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 rounded transition" %>
<%= link_to "Invitations", admin_invitations_path, class: "px-2 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 rounded transition" %>
<% requested_count = Entry.requested.count %>
<%= link_to admin_requests_path, class: "px-2 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 rounded transition flex items-center justify-between" do %>
<span>Requests</span>
<% if requested_count > 0 %>
<span class="bg-red-500 text-white text-xs font-bold rounded-full h-5 w-5 flex items-center justify-center">
<%= requested_count %>
</span>
<% end %>
<% end %>
<%= link_to "Back to Site", root_path, class: "px-2 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 rounded transition" %>
<%= button_to "Log Out", logout_path, method: :delete, data: { turbo: false }, form: { class: "w-full" }, class: "w-full text-left px-2 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded transition cursor-pointer" %>
</nav>
</div>
</div>
</header>
<!-- Flash messages -->
<% if flash.any? %>
<div class="max-w-7xl mx-auto px-4 mt-4">
<% flash.each do |type, message| %>
<div class="<%= type == 'notice' ? 'bg-green-50 border border-green-200 text-green-700' : 'bg-red-50 border border-red-200 text-red-700' %> px-4 py-3 rounded-lg mb-4" role="alert">
<span class="block sm:inline"><%= message %></span>
</div>
<% end %>
</div>
<% end %>
<script>
function setupAdminMobileMenu() {
const menuButton = document.getElementById('admin-mobile-menu-button');
const mobileMenu = document.getElementById('admin-mobile-menu');
if (menuButton && mobileMenu) {
// Remove existing listeners to avoid duplicates
const newMenuButton = menuButton.cloneNode(true);
menuButton.parentNode.replaceChild(newMenuButton, menuButton);
newMenuButton.addEventListener('click', function(e) {
e.stopPropagation();
mobileMenu.classList.toggle('hidden');
});
// Close menu when clicking outside
document.addEventListener('click', function(event) {
const isClickInside = newMenuButton.contains(event.target) || mobileMenu.contains(event.target);
if (!isClickInside && !mobileMenu.classList.contains('hidden')) {
mobileMenu.classList.add('hidden');
}
});
// Close menu when navigating with Turbo
document.addEventListener('turbo:click', function() {
if (mobileMenu && !mobileMenu.classList.contains('hidden')) {
mobileMenu.classList.add('hidden');
}
});
}
}
// Run on initial load and on Turbo navigation
document.addEventListener('DOMContentLoaded', setupAdminMobileMenu);
document.addEventListener('turbo:load', setupAdminMobileMenu);
</script>
<%= render "shared/notifications" %>
<!-- Main content -->
<main class="flex-1 max-w-7xl w-full mx-auto px-4 py-8">
+4 -3
View File
@@ -14,9 +14,10 @@
<%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %>
<%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %>
<link rel="icon" href="/icon.png" type="image/png">
<link rel="icon" href="/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/icon.png">
<link rel="icon" href="/favicon.ico" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<link rel="apple-touch-icon" href="/icon-512.png" />
<%# Includes all stylesheet files in app/assets/stylesheets %>
<%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
@@ -0,0 +1,130 @@
<!DOCTYPE html>
<html>
<head>
<meta content='text/html; charset=UTF-8' http-equiv='Content-Type' />
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: 1.6;
color: #334155;
max-width: 640px;
margin: 0 auto;
padding: 20px;
}
.header {
background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);
color: white;
padding: 30px;
border-radius: 8px 8px 0 0;
text-align: center;
}
.header h1 {
margin: 0;
font-size: 28px;
font-weight: 700;
}
.header p {
margin: 8px 0 0 0;
opacity: 0.9;
}
.content {
background: white;
border: 1px solid #e2e8f0;
border-top: none;
padding: 30px;
border-radius: 0 0 8px 8px;
}
.greeting {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
color: #1e293b;
}
.button {
display: inline-block;
background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);
color: white;
padding: 14px 32px;
text-decoration: none;
border-radius: 8px;
font-weight: 600;
margin: 24px 0;
text-align: center;
}
.button:hover {
background: linear-gradient(135deg, #4f46e5 0%, #4338ca 100%);
}
.footer {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e2e8f0;
font-size: 14px;
color: #64748b;
}
.expiry-notice {
background: #fef3c7;
border-left: 4px solid #f59e0b;
padding: 12px;
margin: 16px 0;
font-size: 14px;
border-radius: 4px;
}
.warning-box {
background: #fef2f2;
border-left: 4px solid #ef4444;
padding: 16px;
margin: 20px 0;
border-radius: 4px;
}
</style>
</head>
<body>
<div class="header">
<h1>Sanasto Wiki</h1>
<p>Password Reset Request</p>
</div>
<div class="content">
<p class="greeting">Hello <%= @user.name %>,</p>
<p>
We received a request to reset your password for your Sanasto Wiki account.
</p>
<p>
If you made this request, click the button below to set a new password:
</p>
<div style="text-align: center;">
<%= link_to "Reset My Password", @reset_url, class: "button" %>
</div>
<div class="expiry-notice">
This password reset link will expire on <strong><%= @expires_at.strftime("%B %d, %Y at %I:%M %p %Z") %></strong>.
</div>
<p>
You can also copy and paste this link into your browser:
</p>
<p style="word-break: break-all; color: #6366f1; font-size: 14px;">
<%= @reset_url %>
</p>
<div class="warning-box">
<strong>Didn't request a password reset?</strong>
<p style="margin: 8px 0 0 0;">
If you didn't make this request, you can safely ignore this email. Your password will remain unchanged.
</p>
</div>
<div class="footer">
<p>
For security reasons, this link will only work once and will expire in 1 hour.
</p>
<p style="margin-top: 12px;">
Questions? Reply to this email.
</p>
</div>
</div>
</body>
</html>
+57
View File
@@ -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>
+54
View File
@@ -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 -2
View File
@@ -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"
+71
View File
@@ -0,0 +1,71 @@
<% content_for :title, "Request a New Entry" %>
<div class="min-h-screen flex flex-col">
<%= render "shared/header", show_request_button: false %>
<%= render "shared/notifications" %>
<div class="flex-1 bg-gradient-to-br from-indigo-50 via-white to-purple-50 flex items-center justify-center px-4 py-12">
<div class="max-w-2xl w-full">
<div class="bg-white rounded-2xl shadow-xl p-8">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-gray-900 mb-2">Request a New Entry</h1>
<p class="text-gray-600">Is there a word you would like to see in this glossary?</p>
</div>
<% if @pending_count && @pending_count > 0 %>
<div class="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg text-blue-800">
You have <%= @pending_count %> pending <%= "request".pluralize(@pending_count) %> being reviewed.
</div>
<% end %>
<%= form_with model: @entry, url: requests_path, class: "space-y-6", data: { turbo: false } do |f| %>
<% if @entry.errors.any? %>
<div class="p-4 bg-red-50 border border-red-200 rounded-lg">
<h3 class="font-semibold text-red-800 mb-2">Please fix the following errors:</h3>
<ul class="list-disc list-inside text-red-700 text-sm space-y-1">
<% @entry.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="space-y-4">
<% if current_user %>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
<p class="text-sm text-blue-900">
<span class="font-semibold">Submitting as:</span> <%= current_user.name %> (<%= current_user.email %>)
</p>
</div>
<% else %>
<div>
<%= f.label :name, "Your Name", class: "block text-sm font-semibold text-gray-700 mb-2" %>
<%= f.text_field :name, required: true, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
</div>
<div>
<%= f.label :email, "Your Email", class: "block text-sm font-semibold text-gray-700 mb-2" %>
<%= f.email_field :email, required: true, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
</div>
<% end %>
</div>
<%= render 'entries/form_fields', f: f %>
<div class="flex flex-col sm:flex-row gap-4 pt-4">
<%= f.submit "Submit Request", class: "flex-1 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-3 px-6 rounded-lg transition shadow-md hover:shadow-lg" %>
<%= link_to "Cancel", root_path, class: "flex-1 text-center bg-gray-100 hover:bg-gray-200 text-gray-800 font-semibold py-3 px-6 rounded-lg transition" %>
</div>
<% end %>
<% unless current_user %>
<div class="mt-6 text-center text-sm text-gray-600">
Already have an account? <%= link_to "Sign in", login_path, class: "text-indigo-600 hover:text-indigo-800 font-semibold" %>
</div>
<% end %>
</div>
</div>
</div>
</div>
+12 -7
View File
@@ -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" %>
+122
View File
@@ -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>
+16
View File
@@ -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 %>
Executable
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env ruby
require_relative "../config/environment"
require "solid_queue/cli"
SolidQueue::Cli.start(ARGV)
+3
View File
@@ -1,6 +1,7 @@
require_relative "boot"
require "rails/all"
require_relative "../lib/middleware/sanasto_cors"
# Require the gems listed in Gemfile, including any gems
# you've limited to :test, :development, or :production.
@@ -24,5 +25,7 @@ module SanastoWiki
# config.time_zone = "Central Time (US & Canada)"
# config.eager_load_paths << Rails.root.join("extras")
config.active_record.schema_format = :sql
config.middleware.insert_before 0, Middleware::SanastoCors
end
end
+1 -1
View File
@@ -1 +1 @@
1frgw1tdeAMChiF5O/Lr3rhpz/EKVHFcl3fUFqpqRy6m31gTG4B58nR8FCm1rBujjNKZnGRvuGzxkw8rlwJKbSCtmFSQK3XIPQ5PW6XvL8n/9WVR9VBbSQxVQinXol+xp6tKHqUv6/fJRnIcvH0AR38tbeA4a6ByTG1bz62R0YVBCVdPkvB9QvS0TIy4Ks04YTJQvi/WIuBzQ0/2++v9rO9KLlabhDwLnOTYrjRgewlFAIlI8IlRZr6NO3m4yq6LTXD8gJ9rjXLE8Ajh9BFShlXXgAXXN0UsfetSseROvCYXhsFZAAR9pIX65zh8+edtxREmFL2yTXzNwTNpUq788VLGiHBK6mQQxviZGi4URj5zfZ+piXU3GWYcpU3txNGG0vVZYhyvs2sSFk+CQnmFojCuvvq9oNjrtTgE1yi/17SNq93YG7E7m+sFh2Jmc3cN04SPv7XLixKzFAK9028M7uQZvOC6R7Pb2DySvTILbe56CDCvBiTpTCB2--hkQcz/vYM834FRs4--BcXqGn/gjm7peH3bOzjLdA==
JC3W3xoTxCvfRD1b4tx56WaFXVOpIE0Vfnd0Pt6KUiCEYgMIcPi5KXYKWGsUIZG3PgWOx4BhZ1HR1TXfm/kB7o1fVdPuiLeWpf+3JKwjg9dU1GBwPSwpH4t4uRgWPAE48h6WCkcy22gRuCWr3LmPcZa3EV7u9tZwjWDwOS/RW2FZkkOu7HGFQKwwcGZKVy0TF6hXxGfJEZMLF+5kWgjii2a1VAI0Dy53UBwlxQ+hSilYlkAEQTYmuRzCUExjQuPB77vdZZb/Fumuqp6GrP8xubczunzaePAmv5EBREUy3xfV71/hwDwZFZDCRQQ4PbVBlTYh/3upfUtjOtG2amnqwOGq2oWP1sFVNZy6ZyicMTTjrVf7oh96vR+tHr3OBg+ZEjYS9nHhUUyUv/OjXh35nHQRH0zbbGqCTngOpyOa6IRZuC83bybNQ3+qXY9lau22LoIc8sxJdSHLQ8ZMEXPYm5f8mo3xTJ0SlY8/3QGW0VcWrEp63gqP0k/3GuoHJbjGWT08kFgh8ynAOaORZyZdVkCdS3KbUeyiNdQV0UgowqwBz4cj9pWQGjY3zU0ngTyTD9fIZ6cfyjDpWHPaXeol+X8=--Jl8cRsZDKG3chvBd--MG6KloWhjRJgvvY/pq/fHQ==
+6 -1
View File
@@ -10,15 +10,20 @@ default: &default
timeout: 5000
development:
primary:
<<: *default
database: storage/development.sqlite3
queue:
<<: *default
database: storage/development_queue.sqlite3
migrations_paths: db/queue_migrate
# Warning: The database defined as "test" will be erased and
# re-generated from your development database when you run "rake".
# Do not set this db to the same as development or production.
test:
<<: *default
database: storage/test.sqlite3
database: storage/test<%= ENV["TEST_ENV_NUMBER"] %>.sqlite3
# Store production database in the storage/ directory, which by default
# is mounted as a persistent Docker volume in config/deploy.yml.
+4 -11
View File
@@ -2,7 +2,7 @@
service: sanasto-wiki
# Name of the container image.
image: your-username/sanasto-wiki
image: soverein/sanasto-wiki
# Deploy to these servers.
servers:
@@ -17,7 +17,9 @@ servers:
# Enable SSL auto certification via Let's Encrypt and allow for multiple apps on a single web server.
proxy:
ssl: true
host: sanasto.rin.no
hosts:
- sanasto.rin.no
- sanasto.wiki
# Kamal proxy will forward to your app on port 3000
# Credentials for your image host.
@@ -42,17 +44,8 @@ env:
RAILS_LOG_TO_STDOUT: true
RAILS_SERVE_STATIC_FILES: true
SOLID_QUEUE_IN_PUMA: true
# Mail server config
SMTP_ADDRESS: mail.soverein.no
SMTP_PORT: 587
SMTP_DOMAIN: frostshipdesign.no
SMTP_AUTHENTICATION: plain
SMTP_ENABLE_STARTTLS_AUTO: true
secret:
- RAILS_MASTER_KEY
- SMTP_USERNAME
- SMTP_PASSWORD
# Use persistent storage volume for SQLite database and uploads
volumes:
+3 -2
View File
@@ -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",
+10 -9
View File
@@ -50,23 +50,24 @@ Rails.application.configure do
# config.cache_store = :mem_cache_store
# Replace the default in-process and non-durable queuing backend for Active Job.
# config.active_job.queue_adapter = :resque
config.active_job.queue_adapter = :solid_queue
config.solid_queue.connects_to = { database: { writing: :queue } }
# Ignore bad email addresses and do not raise email delivery errors.
# Set this to true and configure the email server for immediate delivery to raise delivery errors.
# config.action_mailer.raise_delivery_errors = false
# Set host to be used by links generated in mailer templates.
config.action_mailer.default_url_options = { host: "example.com" }
config.action_mailer.default_url_options = { host: "sanasto.wiki" }
# Specify outgoing SMTP server. Remember to add smtp/* credentials via bin/rails credentials:edit.
# config.action_mailer.smtp_settings = {
# user_name: Rails.application.credentials.dig(:smtp, :user_name),
# password: Rails.application.credentials.dig(:smtp, :password),
# address: "smtp.example.com",
# port: 587,
# authentication: :plain
# }
config.action_mailer.smtp_settings = {
user_name: Rails.application.credentials.dig(:mail, :username),
password: Rails.application.credentials.dig(:mail, :password),
address: Rails.application.credentials.dig(:mail, :server),
port: 587,
authentication: :plain
}
# Enable locale fallbacks for I18n (makes lookups for any locale fall back to
# the I18n.default_locale when a translation cannot be found).
+1 -1
View File
@@ -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
+6
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
Rswag::Ui.configure do |config|
config.swagger_endpoint "/api/swagger", "Sanasto Wiki API"
end
+18
View File
@@ -0,0 +1,18 @@
default: &default
dispatchers:
- polling_interval: 1
batch_size: 500
workers:
- queues: "*"
threads: 3
processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %>
polling_interval: 0.1
development:
<<: *default
test:
<<: *default
production:
<<: *default
+15
View File
@@ -0,0 +1,15 @@
# examples:
# periodic_cleanup:
# class: CleanSoftDeletedRecordsJob
# queue: background
# args: [ 1000, { batch_size: 500 } ]
# schedule: every hour
# periodic_cleanup_with_command:
# command: "SoftDeletedRecord.due.delete_all"
# priority: 2
# schedule: at 5am every day
production:
clear_solid_queue_finished_jobs:
command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
schedule: every hour at minute 12
+23 -1
View File
@@ -1,6 +1,10 @@
Rails.application.routes.draw do
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
mount Api::Base => "/api"
get "/api/swagger", to: "api/swagger#index"
mount Rswag::Ui::Engine => "/api"
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
# Can be used by load balancers and uptime monitors to verify that the app is live.
get "up" => "rails/health#show", as: :rails_health_check
@@ -21,16 +25,34 @@ Rails.application.routes.draw do
post "login", to: "sessions#create"
delete "logout", to: "sessions#destroy", as: :logout
# Password reset routes
resources :password_resets, only: [ :new, :create ]
get "password_resets/:token/edit", to: "password_resets#edit", as: :edit_password_reset
patch "password_resets/:token", to: "password_resets#update", as: :password_reset
# Invitation acceptance routes
get "invitations/:token", to: "invitations#show", as: :invitation
patch "invitations/:token/accept", to: "invitations#update", as: :accept_invitation
# Public entry request routes
resources :requests, only: [ :new, :create ]
# Admin namespace
namespace :admin do
root "dashboard#index"
get "dashboard", to: "dashboard#index"
resources :users, only: [ :index, :edit, :update, :destroy ]
resources :invitations, only: [ :index, :new, :create, :destroy ]
resources :invitations, only: [ :index, :new, :create, :destroy ] do
member do
put :resend
end
end
resources :requests, only: [ :index, :show, :edit, :update ] do
member do
post :approve
delete :reject
end
end
end
resources :entries do
@@ -18,6 +18,5 @@ class CreateSuggestedMeanings < ActiveRecord::Migration[8.1]
t.timestamps
end
end
end
@@ -0,0 +1,13 @@
class AddStatusToEntries < ActiveRecord::Migration[8.1]
def change
add_column :entries, :status, :integer, default: 2, null: false
add_index :entries, :status
# Set all existing entries to status: 2 (active)
reversible do |dir|
dir.up do
execute "UPDATE entries SET status = 2"
end
end
end
end
@@ -0,0 +1,7 @@
class AddRequestedByToEntries < ActiveRecord::Migration[8.1]
def change
add_column :entries, :requested_by_id, :integer
add_foreign_key :entries, :users, column: :requested_by_id
add_index :entries, :requested_by_id
end
end
@@ -0,0 +1,7 @@
class AddPasswordResetToUsers < ActiveRecord::Migration[8.1]
def change
add_column :users, :reset_password_token, :string
add_column :users, :reset_password_sent_at, :datetime
add_index :users, :reset_password_token, unique: true
end
end
@@ -0,0 +1,7 @@
class AddRememberTokenToUsers < ActiveRecord::Migration[8.1]
def change
add_column :users, :remember_token, :string
add_column :users, :remember_created_at, :datetime
add_index :users, :remember_token, unique: true
end
end
+129
View File
@@ -0,0 +1,129 @@
ActiveRecord::Schema[7.1].define(version: 1) do
create_table "solid_queue_blocked_executions", force: :cascade do |t|
t.bigint "job_id", null: false
t.string "queue_name", null: false
t.integer "priority", default: 0, null: false
t.string "concurrency_key", null: false
t.datetime "expires_at", null: false
t.datetime "created_at", null: false
t.index [ "concurrency_key", "priority", "job_id" ], name: "index_solid_queue_blocked_executions_for_release"
t.index [ "expires_at", "concurrency_key" ], name: "index_solid_queue_blocked_executions_for_maintenance"
t.index [ "job_id" ], name: "index_solid_queue_blocked_executions_on_job_id", unique: true
end
create_table "solid_queue_claimed_executions", force: :cascade do |t|
t.bigint "job_id", null: false
t.bigint "process_id"
t.datetime "created_at", null: false
t.index [ "job_id" ], name: "index_solid_queue_claimed_executions_on_job_id", unique: true
t.index [ "process_id", "job_id" ], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id"
end
create_table "solid_queue_failed_executions", force: :cascade do |t|
t.bigint "job_id", null: false
t.text "error"
t.datetime "created_at", null: false
t.index [ "job_id" ], name: "index_solid_queue_failed_executions_on_job_id", unique: true
end
create_table "solid_queue_jobs", force: :cascade do |t|
t.string "queue_name", null: false
t.string "class_name", null: false
t.text "arguments"
t.integer "priority", default: 0, null: false
t.string "active_job_id"
t.datetime "scheduled_at"
t.datetime "finished_at"
t.string "concurrency_key"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index [ "active_job_id" ], name: "index_solid_queue_jobs_on_active_job_id"
t.index [ "class_name" ], name: "index_solid_queue_jobs_on_class_name"
t.index [ "finished_at" ], name: "index_solid_queue_jobs_on_finished_at"
t.index [ "queue_name", "finished_at" ], name: "index_solid_queue_jobs_for_filtering"
t.index [ "scheduled_at", "finished_at" ], name: "index_solid_queue_jobs_for_alerting"
end
create_table "solid_queue_pauses", force: :cascade do |t|
t.string "queue_name", null: false
t.datetime "created_at", null: false
t.index [ "queue_name" ], name: "index_solid_queue_pauses_on_queue_name", unique: true
end
create_table "solid_queue_processes", force: :cascade do |t|
t.string "kind", null: false
t.datetime "last_heartbeat_at", null: false
t.bigint "supervisor_id"
t.integer "pid", null: false
t.string "hostname"
t.text "metadata"
t.datetime "created_at", null: false
t.string "name", null: false
t.index [ "last_heartbeat_at" ], name: "index_solid_queue_processes_on_last_heartbeat_at"
t.index [ "name", "supervisor_id" ], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true
t.index [ "supervisor_id" ], name: "index_solid_queue_processes_on_supervisor_id"
end
create_table "solid_queue_ready_executions", force: :cascade do |t|
t.bigint "job_id", null: false
t.string "queue_name", null: false
t.integer "priority", default: 0, null: false
t.datetime "created_at", null: false
t.index [ "job_id" ], name: "index_solid_queue_ready_executions_on_job_id", unique: true
t.index [ "priority", "job_id" ], name: "index_solid_queue_poll_all"
t.index [ "queue_name", "priority", "job_id" ], name: "index_solid_queue_poll_by_queue"
end
create_table "solid_queue_recurring_executions", force: :cascade do |t|
t.bigint "job_id", null: false
t.string "task_key", null: false
t.datetime "run_at", null: false
t.datetime "created_at", null: false
t.index [ "job_id" ], name: "index_solid_queue_recurring_executions_on_job_id", unique: true
t.index [ "task_key", "run_at" ], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true
end
create_table "solid_queue_recurring_tasks", force: :cascade do |t|
t.string "key", null: false
t.string "schedule", null: false
t.string "command", limit: 2048
t.string "class_name"
t.text "arguments"
t.string "queue_name"
t.integer "priority", default: 0
t.boolean "static", default: true, null: false
t.text "description"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index [ "key" ], name: "index_solid_queue_recurring_tasks_on_key", unique: true
t.index [ "static" ], name: "index_solid_queue_recurring_tasks_on_static"
end
create_table "solid_queue_scheduled_executions", force: :cascade do |t|
t.bigint "job_id", null: false
t.string "queue_name", null: false
t.integer "priority", default: 0, null: false
t.datetime "scheduled_at", null: false
t.datetime "created_at", null: false
t.index [ "job_id" ], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true
t.index [ "scheduled_at", "priority", "job_id" ], name: "index_solid_queue_dispatch_all"
end
create_table "solid_queue_semaphores", force: :cascade do |t|
t.string "key", null: false
t.integer "value", default: 1, null: false
t.datetime "expires_at", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index [ "expires_at" ], name: "index_solid_queue_semaphores_on_expires_at"
t.index [ "key", "value" ], name: "index_solid_queue_semaphores_on_key_and_value"
t.index [ "key" ], name: "index_solid_queue_semaphores_on_key", unique: true
end
add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
end
+61
View File
@@ -0,0 +1,61 @@
CREATE TABLE IF NOT EXISTS "solid_queue_jobs" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "queue_name" varchar NOT NULL, "class_name" varchar NOT NULL, "arguments" text, "priority" integer DEFAULT 0 NOT NULL, "active_job_id" varchar, "scheduled_at" datetime(6), "finished_at" datetime(6), "concurrency_key" varchar, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL);
CREATE INDEX "index_solid_queue_jobs_on_active_job_id" ON "solid_queue_jobs" ("active_job_id") /*application='SanastoWiki'*/;
CREATE INDEX "index_solid_queue_jobs_on_class_name" ON "solid_queue_jobs" ("class_name") /*application='SanastoWiki'*/;
CREATE INDEX "index_solid_queue_jobs_on_finished_at" ON "solid_queue_jobs" ("finished_at") /*application='SanastoWiki'*/;
CREATE INDEX "index_solid_queue_jobs_for_filtering" ON "solid_queue_jobs" ("queue_name", "finished_at") /*application='SanastoWiki'*/;
CREATE INDEX "index_solid_queue_jobs_for_alerting" ON "solid_queue_jobs" ("scheduled_at", "finished_at") /*application='SanastoWiki'*/;
CREATE TABLE IF NOT EXISTS "solid_queue_pauses" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "queue_name" varchar NOT NULL, "created_at" datetime(6) NOT NULL);
CREATE UNIQUE INDEX "index_solid_queue_pauses_on_queue_name" ON "solid_queue_pauses" ("queue_name") /*application='SanastoWiki'*/;
CREATE TABLE IF NOT EXISTS "solid_queue_processes" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "kind" varchar NOT NULL, "last_heartbeat_at" datetime(6) NOT NULL, "supervisor_id" bigint, "pid" integer NOT NULL, "hostname" varchar, "metadata" text, "created_at" datetime(6) NOT NULL, "name" varchar NOT NULL);
CREATE INDEX "index_solid_queue_processes_on_last_heartbeat_at" ON "solid_queue_processes" ("last_heartbeat_at") /*application='SanastoWiki'*/;
CREATE UNIQUE INDEX "index_solid_queue_processes_on_name_and_supervisor_id" ON "solid_queue_processes" ("name", "supervisor_id") /*application='SanastoWiki'*/;
CREATE INDEX "index_solid_queue_processes_on_supervisor_id" ON "solid_queue_processes" ("supervisor_id") /*application='SanastoWiki'*/;
CREATE TABLE IF NOT EXISTS "solid_queue_recurring_tasks" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "key" varchar NOT NULL, "schedule" varchar NOT NULL, "command" varchar(2048), "class_name" varchar, "arguments" text, "queue_name" varchar, "priority" integer DEFAULT 0, "static" boolean DEFAULT TRUE NOT NULL, "description" text, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL);
CREATE UNIQUE INDEX "index_solid_queue_recurring_tasks_on_key" ON "solid_queue_recurring_tasks" ("key") /*application='SanastoWiki'*/;
CREATE INDEX "index_solid_queue_recurring_tasks_on_static" ON "solid_queue_recurring_tasks" ("static") /*application='SanastoWiki'*/;
CREATE TABLE IF NOT EXISTS "solid_queue_semaphores" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "key" varchar NOT NULL, "value" integer DEFAULT 1 NOT NULL, "expires_at" datetime(6) NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL);
CREATE INDEX "index_solid_queue_semaphores_on_expires_at" ON "solid_queue_semaphores" ("expires_at") /*application='SanastoWiki'*/;
CREATE INDEX "index_solid_queue_semaphores_on_key_and_value" ON "solid_queue_semaphores" ("key", "value") /*application='SanastoWiki'*/;
CREATE UNIQUE INDEX "index_solid_queue_semaphores_on_key" ON "solid_queue_semaphores" ("key") /*application='SanastoWiki'*/;
CREATE TABLE IF NOT EXISTS "solid_queue_blocked_executions" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "job_id" bigint NOT NULL, "queue_name" varchar NOT NULL, "priority" integer DEFAULT 0 NOT NULL, "concurrency_key" varchar NOT NULL, "expires_at" datetime(6) NOT NULL, "created_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_4cd34e2228"
FOREIGN KEY ("job_id")
REFERENCES "solid_queue_jobs" ("id")
ON DELETE CASCADE);
CREATE INDEX "index_solid_queue_blocked_executions_for_release" ON "solid_queue_blocked_executions" ("concurrency_key", "priority", "job_id") /*application='SanastoWiki'*/;
CREATE INDEX "index_solid_queue_blocked_executions_for_maintenance" ON "solid_queue_blocked_executions" ("expires_at", "concurrency_key") /*application='SanastoWiki'*/;
CREATE UNIQUE INDEX "index_solid_queue_blocked_executions_on_job_id" ON "solid_queue_blocked_executions" ("job_id") /*application='SanastoWiki'*/;
CREATE TABLE IF NOT EXISTS "solid_queue_claimed_executions" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "job_id" bigint NOT NULL, "process_id" bigint, "created_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_9cfe4d4944"
FOREIGN KEY ("job_id")
REFERENCES "solid_queue_jobs" ("id")
ON DELETE CASCADE);
CREATE UNIQUE INDEX "index_solid_queue_claimed_executions_on_job_id" ON "solid_queue_claimed_executions" ("job_id") /*application='SanastoWiki'*/;
CREATE INDEX "index_solid_queue_claimed_executions_on_process_id_and_job_id" ON "solid_queue_claimed_executions" ("process_id", "job_id") /*application='SanastoWiki'*/;
CREATE TABLE IF NOT EXISTS "solid_queue_failed_executions" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "job_id" bigint NOT NULL, "error" text, "created_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_39bbc7a631"
FOREIGN KEY ("job_id")
REFERENCES "solid_queue_jobs" ("id")
ON DELETE CASCADE);
CREATE UNIQUE INDEX "index_solid_queue_failed_executions_on_job_id" ON "solid_queue_failed_executions" ("job_id") /*application='SanastoWiki'*/;
CREATE TABLE IF NOT EXISTS "solid_queue_ready_executions" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "job_id" bigint NOT NULL, "queue_name" varchar NOT NULL, "priority" integer DEFAULT 0 NOT NULL, "created_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_81fcbd66af"
FOREIGN KEY ("job_id")
REFERENCES "solid_queue_jobs" ("id")
ON DELETE CASCADE);
CREATE UNIQUE INDEX "index_solid_queue_ready_executions_on_job_id" ON "solid_queue_ready_executions" ("job_id") /*application='SanastoWiki'*/;
CREATE INDEX "index_solid_queue_poll_all" ON "solid_queue_ready_executions" ("priority", "job_id") /*application='SanastoWiki'*/;
CREATE INDEX "index_solid_queue_poll_by_queue" ON "solid_queue_ready_executions" ("queue_name", "priority", "job_id") /*application='SanastoWiki'*/;
CREATE TABLE IF NOT EXISTS "solid_queue_recurring_executions" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "job_id" bigint NOT NULL, "task_key" varchar NOT NULL, "run_at" datetime(6) NOT NULL, "created_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_318a5533ed"
FOREIGN KEY ("job_id")
REFERENCES "solid_queue_jobs" ("id")
ON DELETE CASCADE);
CREATE UNIQUE INDEX "index_solid_queue_recurring_executions_on_job_id" ON "solid_queue_recurring_executions" ("job_id") /*application='SanastoWiki'*/;
CREATE UNIQUE INDEX "index_solid_queue_recurring_executions_on_task_key_and_run_at" ON "solid_queue_recurring_executions" ("task_key", "run_at") /*application='SanastoWiki'*/;
CREATE TABLE IF NOT EXISTS "solid_queue_scheduled_executions" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "job_id" bigint NOT NULL, "queue_name" varchar NOT NULL, "priority" integer DEFAULT 0 NOT NULL, "scheduled_at" datetime(6) NOT NULL, "created_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_c4316f352d"
FOREIGN KEY ("job_id")
REFERENCES "solid_queue_jobs" ("id")
ON DELETE CASCADE);
CREATE UNIQUE INDEX "index_solid_queue_scheduled_executions_on_job_id" ON "solid_queue_scheduled_executions" ("job_id") /*application='SanastoWiki'*/;
CREATE INDEX "index_solid_queue_dispatch_all" ON "solid_queue_scheduled_executions" ("scheduled_at", "priority", "job_id") /*application='SanastoWiki'*/;
CREATE TABLE IF NOT EXISTS "schema_migrations" ("version" varchar NOT NULL PRIMARY KEY);
CREATE TABLE IF NOT EXISTS "ar_internal_metadata" ("key" varchar NOT NULL PRIMARY KEY, "value" varchar, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL);
INSERT INTO "schema_migrations" (version) VALUES
('1');
+22 -31
View File
@@ -1,15 +1,5 @@
CREATE TABLE IF NOT EXISTS "schema_migrations" ("version" varchar NOT NULL PRIMARY KEY);
CREATE TABLE IF NOT EXISTS "ar_internal_metadata" ("key" varchar NOT NULL PRIMARY KEY, "value" varchar, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL);
CREATE TABLE IF NOT EXISTS "entries" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "category" integer DEFAULT 0 NOT NULL, "fi" varchar, "en" varchar, "sv" varchar, "no" varchar, "ru" varchar, "de" varchar, "notes" text, "verified" boolean DEFAULT FALSE NOT NULL, "created_by_id" integer, "updated_by_id" integer, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_367d1ab731"
FOREIGN KEY ("created_by_id")
REFERENCES "users" ("id")
, CONSTRAINT "fk_rails_6f84c41258"
FOREIGN KEY ("updated_by_id")
REFERENCES "users" ("id")
);
CREATE INDEX "index_entries_on_created_by_id" ON "entries" ("created_by_id") /*application='SanastoWiki'*/;
CREATE INDEX "index_entries_on_updated_by_id" ON "entries" ("updated_by_id") /*application='SanastoWiki'*/;
CREATE INDEX "index_entries_on_category" ON "entries" ("category") /*application='SanastoWiki'*/;
CREATE TABLE IF NOT EXISTS "comments" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "user_id" integer NOT NULL, "commentable_type" varchar NOT NULL, "commentable_id" integer NOT NULL, "body" text NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, "language_code" varchar /*application='SanastoWiki'*/, CONSTRAINT "fk_rails_03de2dc08c"
FOREIGN KEY ("user_id")
REFERENCES "users" ("id")
@@ -18,7 +8,7 @@ CREATE INDEX "index_comments_on_user_id" ON "comments" ("user_id") /*application
CREATE INDEX "index_comments_on_commentable" ON "comments" ("commentable_type", "commentable_id") /*application='SanastoWiki'*/;
CREATE TABLE IF NOT EXISTS "supported_languages" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "code" varchar NOT NULL, "name" varchar NOT NULL, "native_name" varchar NOT NULL, "sort_order" integer DEFAULT 0 NOT NULL, "active" boolean DEFAULT TRUE NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL);
CREATE UNIQUE INDEX "index_supported_languages_on_code" ON "supported_languages" ("code") /*application='SanastoWiki'*/;
CREATE TABLE IF NOT EXISTS "users" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "password_digest" varchar NOT NULL, "name" varchar, "role" integer DEFAULT 0 NOT NULL, "primary_language" varchar, "invitation_token" varchar, "invitation_sent_at" datetime(6), "invitation_accepted_at" datetime(6), "invited_by_id" integer, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_ae14a5013f"
CREATE TABLE IF NOT EXISTS "users" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "password_digest" varchar NOT NULL, "name" varchar, "role" integer DEFAULT 0 NOT NULL, "primary_language" varchar, "invitation_token" varchar, "invitation_sent_at" datetime(6), "invitation_accepted_at" datetime(6), "invited_by_id" integer, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, "reset_password_token" varchar /*application='SanastoWiki'*/, "reset_password_sent_at" datetime(6) /*application='SanastoWiki'*/, "remember_token" varchar /*application='SanastoWiki'*/, "remember_created_at" datetime(6) /*application='SanastoWiki'*/, CONSTRAINT "fk_rails_ae14a5013f"
FOREIGN KEY ("invited_by_id")
REFERENCES "users" ("id")
);
@@ -59,28 +49,29 @@ CREATE TABLE IF NOT EXISTS 'entries_fts_data'(id INTEGER PRIMARY KEY, block BLOB
CREATE TABLE IF NOT EXISTS 'entries_fts_idx'(segid, term, pgno, PRIMARY KEY(segid, term)) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS 'entries_fts_docsize'(id INTEGER PRIMARY KEY, sz BLOB);
CREATE TABLE IF NOT EXISTS 'entries_fts_config'(k PRIMARY KEY, v) WITHOUT ROWID;
CREATE TRIGGER entries_fts_after_insert
AFTER INSERT ON entries
BEGIN
INSERT INTO entries_fts(rowid, fi, en, sv, no, ru, de, notes)
VALUES (new.id, new.fi, new.en, new.sv, new.no, new.ru, new.de, new.notes);
END;
CREATE TRIGGER entries_fts_after_update
AFTER UPDATE ON entries
BEGIN
INSERT INTO entries_fts(entries_fts, rowid, fi, en, sv, no, ru, de, notes)
VALUES('delete', old.id, old.fi, old.en, old.sv, old.no, old.ru, old.de, old.notes);
INSERT INTO entries_fts(rowid, fi, en, sv, no, ru, de, notes)
VALUES (new.id, new.fi, new.en, new.sv, new.no, new.ru, new.de, new.notes);
END;
CREATE TRIGGER entries_fts_after_delete
AFTER DELETE ON entries
BEGIN
INSERT INTO entries_fts(entries_fts, rowid, fi, en, sv, no, ru, de, notes)
VALUES('delete', old.id, old.fi, old.en, old.sv, old.no, old.ru, old.de, old.notes);
END;
CREATE TABLE IF NOT EXISTS "setup_states" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "installed" boolean DEFAULT FALSE NOT NULL, "installed_at" datetime(6), "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL);
CREATE TABLE IF NOT EXISTS "entries" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "category" integer DEFAULT 0 NOT NULL, "fi" varchar, "en" varchar, "sv" varchar, "no" varchar, "ru" varchar, "de" varchar, "notes" text, "verified" boolean DEFAULT FALSE NOT NULL, "created_by_id" integer, "updated_by_id" integer, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, "status" integer DEFAULT 2 NOT NULL, "requested_by_id" integer, CONSTRAINT "fk_rails_6f84c41258"
FOREIGN KEY ("updated_by_id")
REFERENCES "users" ("id")
, CONSTRAINT "fk_rails_367d1ab731"
FOREIGN KEY ("created_by_id")
REFERENCES "users" ("id")
, CONSTRAINT "fk_rails_4d36fd8a36"
FOREIGN KEY ("requested_by_id")
REFERENCES "users" ("id")
);
CREATE INDEX "index_entries_on_created_by_id" ON "entries" ("created_by_id") /*application='SanastoWiki'*/;
CREATE INDEX "index_entries_on_updated_by_id" ON "entries" ("updated_by_id") /*application='SanastoWiki'*/;
CREATE INDEX "index_entries_on_category" ON "entries" ("category") /*application='SanastoWiki'*/;
CREATE INDEX "index_entries_on_status" ON "entries" ("status") /*application='SanastoWiki'*/;
CREATE INDEX "index_entries_on_requested_by_id" ON "entries" ("requested_by_id") /*application='SanastoWiki'*/;
CREATE UNIQUE INDEX "index_users_on_reset_password_token" ON "users" ("reset_password_token") /*application='SanastoWiki'*/;
CREATE UNIQUE INDEX "index_users_on_remember_token" ON "users" ("remember_token") /*application='SanastoWiki'*/;
INSERT INTO "schema_migrations" (version) VALUES
('20260130080931'),
('20260130080745'),
('20260129204706'),
('20260129204705'),
('20260123130957'),
('20260123125325'),
('20260122131000'),
+37
View File
@@ -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>
+75
View File
@@ -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.
+32 -16
View File
@@ -2,14 +2,15 @@
## Authentication & Authorization
- [ ] **Authentication system**
- [x] **Authentication system**
- [x] Sessions controller and views (login/logout)
- [x] Email/password authentication with session management
- [x] Login redirects (admin vs regular users)
- [x] Logout functionality
- [ ] Password reset flow
- [ ] Rate limiting on login attempts
- [ ] Session management (remember me, session timeout)
- [x] Password reset flow (email-based, 1 hour expiry)
- [x] Rate limiting on login attempts (5 attempts, 15 minute lockout)
- [x] Session management (remember me for 2 weeks, 30 minute timeout)
- [x] Sign in status in the site header
- [x] **Invitation system**
- [x] Invitations controller (create, list, cancel)
- [x] Invitation token generation
@@ -49,11 +50,11 @@
- [x] **Live search** (AJAX updates without page reload)
### Entry Management
- [ ] **Create entry form** with all language fields
- [ ] **Edit entry form** (contributors only)
- [x] **Create entry form** with all language fields
- [x] **Edit entry form** (contributors only)
- [ ] **Delete entries** (admin only, with confirmation)
- [ ] **Bulk actions** (select multiple, bulk edit/delete)
- [ ] **Entry validation** (at least one language required, category required)
- [x] **Entry validation** (at least one language required, category required)
### Suggested Meanings
- [ ] **Submit alternative translation** form
@@ -63,7 +64,7 @@
- [ ] **Notifications** when suggestions are reviewed
### Comments & Discussion
- [ ] **Add comment** form on entry view
- [x] **Add comment** form on entry view
- [ ] **Edit/delete own comments**
- [ ] **Comment threading** (optional: replies to comments)
- [ ] **Comment notifications** for entry contributors
@@ -108,14 +109,29 @@
## Testing
- [ ] **Controller tests** for all actions
- [ ] **System tests** for critical user flows
- [ ] Public browsing and search
- [ ] Contributor creates/edits entry
- [ ] Reviewer workflow
- [ ] Admin user management
- [ ] **Integration tests** for authentication flows
- [ ] **Performance tests** for search queries
- [x] **Controller tests** for all actions
- [x] EntriesController (index, show, edit, update, download, filters, search)
- [x] PasswordResetsController (new, create, edit, update, token validation)
- [x] Existing tests: Sessions, Invitations, Setup, Admin controllers, Comments, Requests
- [x] **System tests** for critical user flows
- [x] Public browsing and search
- [x] Contributor creates/edits entry
- [ ] Reviewer workflow (pending feature implementation)
- [x] Admin user management
- [x] **Integration tests** for authentication flows
- [x] Sign in/sign out flows
- [x] Remember me functionality
- [x] Session timeout
- [x] Rate limiting
- [x] Password reset flow
- [x] Invitation acceptance flow
- [x] **Performance tests** for search queries
- [x] Full text search benchmarks
- [x] Language-specific search
- [x] Alphabetical browsing
- [x] Category filtering
- [x] Combined filters
- [x] XLSX download performance
## Deployment
+30
View File
@@ -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
+42
View File
@@ -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
+86
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 23 KiB

+141 -3
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 122 B

After

Width:  |  Height:  |  Size: 23 KiB

+3 -1
View File
@@ -1 +1,3 @@
# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
# Disallow all robots from all pages
User-agent: *
Disallow: /
+16
View File
@@ -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."
+141
View File
@@ -0,0 +1,141 @@
# Entry Request System Test Coverage
## Test Files Created
### 1. Model Tests: `test/models/entry_request_test.rb`
Tests for Entry model enhancements:
- ✅ Validates that at least one translation is required
- ✅ Entry status defaults to "active"
- ✅ Scopes (requested, approved, active_entries) work correctly
- ✅ requested_by association functions properly
- ✅ Status transitions (requested → approved → active)
- ✅ Blank translations are properly handled
**13 tests, 40 assertions**
### 2. Public Controller Tests: `test/controllers/requests_controller_test.rb`
Tests for public entry request submission:
- ✅ Shows new request form for anonymous users (with name/email fields)
- ✅ Shows new request form for logged-in users (without name/email fields)
- ✅ Creates entry request with valid data
- ✅ Requires at least one translation
- ✅ Redirects to login if email already exists with accepted invitation
- ✅ Shows pending count for email with existing requests
- ✅ Creates entry with single or multiple translations
- ✅ Logged-in user can submit request without providing name/email
- ✅ Does not modify existing user when they submit request
- ✅ Reuses existing pending user without modifying them
- ✅ Transaction rollback on validation failure
**11 tests, 78 assertions**
### 3. Admin Controller Tests: `test/controllers/admin/requests_controller_test.rb`
Tests for admin request management:
- ✅ Requires admin authentication
- ✅ Shows requests index with requested and approved sections
- ✅ Lists requested and approved entries
- ✅ Shows request details
- ✅ Shows edit form for requested entry
- ✅ Updates entry details
- ✅ Validates entry data on update
- ✅ Approves request and sends invitation email
- ✅ Rejects request and deletes entry/user
- ✅ Preserves user if they have multiple entries
- ✅ Blocks access for non-admin users (contributors, reviewers)
**14 tests, 85 assertions**
### 4. Integration Tests: `test/integration/entry_request_flow_test.rb`
Full end-to-end flow tests:
- ✅ Complete flow: request → admin approve → user accepts → entry active
- ✅ Rejected request removes entry and user
- ✅ Requested/approved entries not visible on public site
- ✅ Multiple entries by same requester all activated on invitation acceptance
- ✅ Admin can edit entry details before approval
- ✅ Cannot submit request with existing user email
**6 tests**
## Fixtures Added
### Updated: `test/fixtures/entries.yml`
- Added `status: 2` (active) to existing entries
- Added `requested_entry` fixture (status: requested)
- Added `approved_entry` fixture (status: approved)
### Updated: `test/fixtures/users.yml`
- Added `requester_user` fixture (user without accepted invitation)
### 5. Mailer Tests: `test/mailers/invitation_mailer_test.rb`
Invitation email tests including entry approval notifications:
- ✅ Sends email with correct details
- ✅ Includes invitation link and expiry date
- ✅ Has both HTML and text parts
-**With approved entry: includes entry details in email**
-**With approved entry: shows correct message and formatting**
- ✅ Without approved entry: uses standard invitation message
**7 tests, 38 assertions**
## Test Summary
**Total: 51 tests, 316 assertions**
All tests passing ✅
Full test suite: **131 tests, 566 assertions**
## Running the Tests
Run all request-related tests:
```bash
bin/rails test test/models/entry_request_test.rb \
test/controllers/requests_controller_test.rb \
test/controllers/admin/requests_controller_test.rb \
test/integration/entry_request_flow_test.rb
```
Run individual test files:
```bash
bin/rails test test/models/entry_request_test.rb
bin/rails test test/controllers/requests_controller_test.rb
bin/rails test test/controllers/admin/requests_controller_test.rb
bin/rails test test/integration/entry_request_flow_test.rb
```
Run specific test:
```bash
bin/rails test test/integration/entry_request_flow_test.rb:3
```
## Test Coverage Areas
### Public Request Flow
- Form display and validation (different for logged-in vs anonymous users)
- User and entry creation
- Email duplicate detection for active accounts
- Logged-in users submit without providing name/email
- Existing users are never modified during request submission
- Existing pending users are reused without modification
- Transaction safety
### Admin Management Flow
- Authentication and authorization
- Request listing and filtering
- Request details display
- Entry editing before approval
- Approval with invitation sending
- Rejection with cleanup
### Integration Flow
- Complete user journey from request to active entry
- Entry visibility rules (requested/approved not shown publicly)
- Multi-entry approval and activation
- Admin workflow with editing
### Edge Cases
- Validation failures with transaction rollback
- User preservation when they have multiple entries
- Expired invitations
- Non-admin access attempts
- Blank translations handling
+15
View File
@@ -0,0 +1,15 @@
require "test_helper"
require "capybara/rails"
require "capybara/minitest"
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ]
# Helper to login as a user
def login_as(user)
visit login_path
fill_in "Email", with: user.email
fill_in "Password", with: "password123456"
click_button "Sign In"
end
end
@@ -140,4 +140,404 @@ class Admin::InvitationsControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to root_path
end
# Resend action tests
test "should resend invitation for pending invitation" do
login_as(users(:admin_user))
pending_user = users(:pending_invitation)
original_token = pending_user.invitation_token
original_sent_at = pending_user.invitation_sent_at
assert_enqueued_emails 1 do
put resend_admin_invitation_path(pending_user)
end
assert_redirected_to admin_invitations_path
assert_match /Invitation resent to #{pending_user.email}/, flash[:notice]
pending_user.reload
assert_not_equal original_token, pending_user.invitation_token
assert_operator pending_user.invitation_sent_at, :>, original_sent_at
end
test "should not resend accepted invitation" do
login_as(users(:admin_user))
accepted_user = users(:contributor_user)
assert_enqueued_emails 0 do
put resend_admin_invitation_path(accepted_user)
end
assert_redirected_to admin_invitations_path
assert_equal "Cannot resend an accepted invitation.", flash[:alert]
end
test "should update invitation_sent_at when resending" do
login_as(users(:admin_user))
pending_user = users(:pending_invitation)
freeze_time do
put resend_admin_invitation_path(pending_user)
pending_user.reload
assert_in_delta Time.current.to_i, pending_user.invitation_sent_at.to_i, 2
end
end
test "should generate new token when resending" do
login_as(users(:admin_user))
pending_user = users(:pending_invitation)
original_token = pending_user.invitation_token
put resend_admin_invitation_path(pending_user)
pending_user.reload
assert_not_nil pending_user.invitation_token
assert_not_equal original_token, pending_user.invitation_token
assert pending_user.invitation_token.length >= 32
end
test "should not allow non-admin to resend invitation" do
login_as(users(:contributor_user))
put resend_admin_invitation_path(users(:pending_invitation))
assert_redirected_to root_path
end
test "should require authentication to resend invitation" do
put resend_admin_invitation_path(users(:pending_invitation))
assert_redirected_to login_path
end
# Index action tests
test "should list pending invitations in descending order by sent date" do
login_as(users(:admin_user))
# Create multiple pending invitations
user1 = User.create!(email: "user1@example.com", name: "User 1", password: "temp123456789")
user1.invite_by!(users(:admin_user))
user2 = User.create!(email: "user2@example.com", name: "User 2", password: "temp123456789")
user2.update_columns(invitation_token: "token2", invitation_sent_at: 1.hour.ago)
get admin_invitations_path
assert_response :success
assert_select "h3", /Pending Invitations/i
# Check both emails appear in the page
assert_select "td", text: "user1@example.com"
assert_select "td", text: "user2@example.com"
end
test "should display accepted invitations section" do
login_as(users(:admin_user))
get admin_invitations_path
assert_response :success
assert_select "h3", /Recently Accepted/i
end
test "should show pending invitation count badge" do
login_as(users(:admin_user))
get admin_invitations_path
assert_response :success
assert_select "span.bg-yellow-100"
end
test "should display pending invitations table when invitations exist" do
login_as(users(:admin_user))
get admin_invitations_path
assert_select "table"
assert_select "th", text: "Email"
assert_select "th", text: "Role"
assert_select "th", text: "Sent"
end
# Create action - additional tests
test "should create invitation with reviewer role" do
login_as(users(:admin_user))
post admin_invitations_path, params: {
user: {
email: "reviewer@example.com",
name: "New Reviewer",
role: "reviewer",
primary_language: "en"
}
}
new_user = User.find_by(email: "reviewer@example.com")
assert_equal "reviewer", new_user.role
end
test "should create invitation with admin role" do
login_as(users(:admin_user))
post admin_invitations_path, params: {
user: {
email: "admin@example.com",
name: "New Admin",
role: "admin",
primary_language: "en"
}
}
new_user = User.find_by(email: "admin@example.com")
assert_equal "admin", new_user.role
end
test "should ignore invalid role parameter" do
login_as(users(:admin_user))
post admin_invitations_path, params: {
user: {
email: "newuser@example.com",
name: "New User",
role: "superadmin",
primary_language: "en"
}
}
new_user = User.find_by(email: "newuser@example.com")
assert_equal "contributor", new_user.role
end
test "should set invited_by to current admin" do
login_as(users(:admin_user))
post admin_invitations_path, params: {
user: {
email: "newuser@example.com",
name: "New User",
role: "contributor",
primary_language: "en"
}
}
new_user = User.find_by(email: "newuser@example.com")
assert_equal users(:admin_user).id, new_user.invited_by_id
end
test "should generate random password for new invitation" do
login_as(users(:admin_user))
post admin_invitations_path, params: {
user: {
email: "newuser@example.com",
name: "New User",
role: "contributor",
primary_language: "en"
}
}
new_user = User.find_by(email: "newuser@example.com")
assert new_user.password_digest.present?
end
test "should not create invitation with duplicate email" do
login_as(users(:admin_user))
existing_user = users(:admin_user)
assert_no_difference("User.count") do
post admin_invitations_path, params: {
user: {
email: existing_user.email,
name: "Duplicate User",
role: "contributor",
primary_language: "en"
}
}
end
assert_response :unprocessable_entity
assert_select "div.text-red-700", text: /already been taken/i
end
test "should create invitation even with blank name" do
login_as(users(:admin_user))
assert_difference("User.count", 1) do
post admin_invitations_path, params: {
user: {
email: "newuser@example.com",
name: "",
role: "contributor",
primary_language: "en"
}
}
end
assert_redirected_to admin_invitations_path
new_user = User.find_by(email: "newuser@example.com")
assert_not_nil new_user
end
test "should show success message with email after creating invitation" do
login_as(users(:admin_user))
post admin_invitations_path, params: {
user: {
email: "newuser@example.com",
name: "New User",
role: "contributor",
primary_language: "en"
}
}
assert_redirected_to admin_invitations_path
assert_match /Invitation sent to newuser@example\.com/, flash[:notice]
end
# Destroy action - additional tests
test "should show success message after cancelling invitation" do
login_as(users(:admin_user))
delete admin_invitation_path(users(:pending_invitation))
assert_redirected_to admin_invitations_path
assert_equal "Invitation cancelled.", flash[:notice]
end
test "should delete user record when cancelling invitation" do
login_as(users(:admin_user))
pending_user = users(:pending_invitation)
user_id = pending_user.id
delete admin_invitation_path(pending_user)
assert_nil User.find_by(id: user_id)
end
test "should not allow destroying user with entries" do
login_as(users(:admin_user))
user_with_entries = users(:contributor_user)
# This user has accepted invitation and potentially has entries
assert_no_difference("User.count") do
delete admin_invitation_path(user_with_entries)
end
assert_redirected_to admin_invitations_path
assert_equal "Cannot cancel an accepted invitation.", flash[:alert]
end
# Security tests
test "should only permit email, name, primary_language, and role params" do
login_as(users(:admin_user))
post admin_invitations_path, params: {
user: {
email: "newuser@example.com",
name: "New User",
role: "contributor",
primary_language: "en",
password: "hackedpassword123",
invitation_token: "hacked_token",
invitation_accepted_at: Time.current
}
}
new_user = User.find_by(email: "newuser@example.com")
assert_not_nil new_user
# Password should be randomly generated, not from params
assert_not new_user.authenticate("hackedpassword123")
# Invitation token should be generated by invite_by, not from params
assert_not_equal "hacked_token", new_user.invitation_token
# Invitation should not be pre-accepted
assert_nil new_user.invitation_accepted_at
end
test "should require admin role for all actions" do
login_as(users(:reviewer_user))
# Index
get admin_invitations_path
assert_redirected_to root_path
# New
get new_admin_invitation_path
assert_redirected_to root_path
# Create
post admin_invitations_path, params: { user: { email: "test@example.com", name: "Test" } }
assert_redirected_to root_path
# Resend
put resend_admin_invitation_path(users(:pending_invitation))
assert_redirected_to root_path
# Destroy
delete admin_invitation_path(users(:pending_invitation))
assert_redirected_to root_path
end
# Edge cases
test "should handle resending expired invitation" do
login_as(users(:admin_user))
pending_user = users(:pending_invitation)
pending_user.update_columns(invitation_sent_at: 20.days.ago)
put resend_admin_invitation_path(pending_user)
assert_redirected_to admin_invitations_path
pending_user.reload
assert pending_user.invitation_sent_at > 1.hour.ago
end
test "should normalize email to lowercase when creating invitation" do
login_as(users(:admin_user))
post admin_invitations_path, params: {
user: {
email: "NewUser@Example.COM",
name: "New User",
role: "contributor",
primary_language: "en"
}
}
new_user = User.find_by(email: "newuser@example.com")
assert_not_nil new_user
assert_equal "newuser@example.com", new_user.email
end
test "should handle missing role parameter gracefully" do
login_as(users(:admin_user))
post admin_invitations_path, params: {
user: {
email: "newuser@example.com",
name: "New User",
primary_language: "en"
}
}
new_user = User.find_by(email: "newuser@example.com")
assert_not_nil new_user
assert_equal "contributor", new_user.role
end
test "should handle blank role parameter" do
login_as(users(:admin_user))
post admin_invitations_path, params: {
user: {
email: "newuser@example.com",
name: "New User",
role: "",
primary_language: "en"
}
}
new_user = User.find_by(email: "newuser@example.com")
assert_not_nil new_user
assert_equal "contributor", new_user.role
end
end
@@ -0,0 +1,184 @@
require "test_helper"
class Admin::RequestsControllerTest < ActionDispatch::IntegrationTest
setup do
@admin = users(:admin_user)
@requested_entry = entries(:requested_entry)
@approved_entry = entries(:approved_entry)
login_as(@admin)
end
test "should require admin authentication" do
logout
get admin_requests_path
assert_redirected_to login_path
end
test "should show requests index" do
get admin_requests_path
assert_response :success
assert_select "h1", "Entry Requests"
assert_select "h2", /Pending Review/
assert_select "h2", /Approved/
end
test "should list requested entries" do
get admin_requests_path
assert_response :success
assert_select "td", text: @requested_entry.fi
assert_select "a[href=?]", admin_request_path(@requested_entry)
end
test "should list approved entries" do
get admin_requests_path
assert_response :success
assert_select "td", text: @approved_entry.fi
end
test "should show request details" do
get admin_request_path(@requested_entry)
assert_response :success
assert_select "h1", "Entry Request Details"
assert_select "div", text: @requested_entry.fi
assert_select "div", text: @requested_entry.en
assert_select "span", text: @requested_entry.requested_by.name
end
test "should show edit form for requested entry" do
get edit_admin_request_path(@requested_entry)
assert_response :success
assert_select "h1", "Edit Entry Request"
assert_select "form[action=?]", admin_request_path(@requested_entry)
assert_select "input[name='entry[fi]'][value=?]", @requested_entry.fi
end
test "should update entry details" do
patch admin_request_path(@requested_entry), params: {
entry: {
category: "phrase",
fi: "päivitetty sana",
en: "updated word",
notes: "Updated notes"
}
}
@requested_entry.reload
assert_equal "phrase", @requested_entry.category
assert_equal "päivitetty sana", @requested_entry.fi
assert_equal "updated word", @requested_entry.en
assert_equal "Updated notes", @requested_entry.notes
assert_redirected_to admin_request_path(@requested_entry)
assert_equal "Request updated successfully.", flash[:notice]
end
test "should not update with invalid data" do
patch admin_request_path(@requested_entry), params: {
entry: {
fi: "",
en: "",
sv: "",
no: "",
ru: "",
de: ""
}
}
assert_response :unprocessable_entity
@requested_entry.reload
assert_equal "testisana", @requested_entry.fi # Unchanged
end
test "should approve request and send invitation" do
user = @requested_entry.requested_by
assert_nil user.invitation_token
assert_nil user.invitation_sent_at
assert_enqueued_emails 1 do
post approve_admin_request_path(@requested_entry)
end
user.reload
@requested_entry.reload
assert_equal "approved", @requested_entry.status
assert_not_nil user.invitation_token
assert_not_nil user.invitation_sent_at
assert_equal @admin, user.invited_by
assert_redirected_to admin_requests_path
assert_match(/invitation sent/i, flash[:notice])
end
test "should not approve already approved entry" do
# Try to approve an already approved entry
user = @approved_entry.requested_by
post approve_admin_request_path(@approved_entry)
@approved_entry.reload
assert_equal "approved", @approved_entry.status
end
test "should reject request and delete entry and user" do
user = @requested_entry.requested_by
entry_id = @requested_entry.id
user_id = user.id
assert_difference("Entry.count", -1) do
assert_difference("User.count", -1) do
delete reject_admin_request_path(@requested_entry)
end
end
assert_not Entry.exists?(entry_id)
assert_not User.exists?(user_id)
assert_redirected_to admin_requests_path
assert_match(/rejected and deleted/i, flash[:notice])
end
test "should reject but not delete user with multiple entries" do
user = @requested_entry.requested_by
# Create another entry for the same user
another_entry = Entry.create!(
category: :word,
fi: "toinen sana",
en: "another word",
status: :requested,
requested_by: user
)
assert_difference("Entry.count", -1) do
assert_no_difference("User.count") do
delete reject_admin_request_path(@requested_entry)
end
end
assert User.exists?(user.id)
assert Entry.exists?(another_entry.id)
end
test "contributors should not access admin requests" do
logout
contributor = users(:contributor_user)
login_as(contributor)
get admin_requests_path
assert_redirected_to root_path
assert_match(/administrator/i, flash[:alert])
end
test "reviewers should not access admin requests" do
logout
reviewer = users(:reviewer_user)
login_as(reviewer)
get admin_requests_path
assert_redirected_to root_path
assert_match(/administrator/i, flash[:alert])
end
end
@@ -18,6 +18,26 @@ class Admin::UsersControllerTest < ActionDispatch::IntegrationTest
assert_response :success
end
test "should filter users by role" do
login_as(users(:admin_user))
get admin_users_path, params: { role: "reviewer" }
assert_response :success
assert_select "td", text: /#{Regexp.escape(users(:reviewer_user).email)}/
assert_select "td", text: /#{Regexp.escape(users(:contributor_user).email)}/, count: 0
end
test "should filter users by email query" do
login_as(users(:admin_user))
get admin_users_path, params: { q: "admin" }
assert_response :success
assert_select "td", text: /#{Regexp.escape(users(:admin_user).email)}/
assert_select "td", text: /#{Regexp.escape(users(:contributor_user).email)}/, count: 0
end
test "should get edit page for user when logged in as admin" do
login_as(users(:admin_user))
get edit_admin_user_path(users(:contributor_user))
@@ -35,6 +55,45 @@ class Admin::UsersControllerTest < ActionDispatch::IntegrationTest
assert_equal "reviewer", users(:contributor_user).reload.role
end
test "should not allow admin to update own role" do
admin_user = users(:admin_user)
login_as(admin_user)
patch admin_user_path(admin_user), params: {
user: { role: "reviewer" }
}
assert_redirected_to admin_users_path
assert_equal "You cannot modify your own role.", flash[:alert]
assert_equal "admin", admin_user.reload.role
end
test "should ignore invalid role updates" do
login_as(users(:admin_user))
contributor = users(:contributor_user)
patch admin_user_path(contributor), params: {
user: { role: "invalid_role", name: "Updated Name" }
}
assert_redirected_to admin_users_path
contributor.reload
assert_equal "contributor", contributor.role
assert_equal "Updated Name", contributor.name
end
test "should render edit when update is invalid" do
login_as(users(:admin_user))
contributor = users(:contributor_user)
patch admin_user_path(contributor), params: {
user: { email: "" }
}
assert_response :unprocessable_entity
assert_select "li", text: "Email can't be blank"
end
test "should delete user when logged in as admin" do
login_as(users(:admin_user))
@@ -46,6 +105,37 @@ class Admin::UsersControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to admin_users_path
end
test "should not allow admin to delete own account" do
admin_user = users(:admin_user)
login_as(admin_user)
assert_no_difference("User.count") do
delete admin_user_path(admin_user)
end
assert_redirected_to admin_users_path
assert_equal "You cannot delete your own account.", flash[:alert]
end
test "should not allow deleting first admin user" do
other_admin = User.create!(
email: "other-admin@example.com",
name: "Other Admin",
role: :admin,
primary_language: "en",
password: "password123456",
invitation_accepted_at: Time.current
)
login_as(other_admin)
assert_no_difference("User.count") do
delete admin_user_path(User.first)
end
assert_redirected_to admin_users_path
assert_equal "Cannot delete the first admin user (system default contact).", flash[:alert]
end
test "should not allow non-admin to update user" do
login_as(users(:contributor_user))
+54
View File
@@ -0,0 +1,54 @@
require "test_helper"
class BotBlockingTest < ActionDispatch::IntegrationTest
test "should block GPTBot" do
get root_path, headers: { "User-Agent" => "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.3; +https://openai.com/gptbot)" }
assert_response :forbidden
assert_match(/bot access is not allowed/i, response.body)
end
test "should block ChatGPT bot" do
get root_path, headers: { "User-Agent" => "Mozilla/5.0 (compatible; ChatGPT-User/1.0; +https://openai.com/bot)" }
assert_response :forbidden
end
test "should block Googlebot" do
get root_path, headers: { "User-Agent" => "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" }
assert_response :forbidden
end
test "should block Bingbot" do
get root_path, headers: { "User-Agent" => "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)" }
assert_response :forbidden
end
test "should block generic bot user agent" do
get root_path, headers: { "User-Agent" => "SomeBot/1.0" }
assert_response :forbidden
end
test "should allow normal browsers" do
get root_path, headers: { "User-Agent" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" }
assert_response :success
end
test "should allow Firefox" do
get root_path, headers: { "User-Agent" => "Mozilla/5.0 (X11; Linux x86_64; rv:89.0) Gecko/20100101 Firefox/89.0" }
assert_response :success
end
test "should allow Safari" do
get root_path, headers: { "User-Agent" => "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Safari/605.1.15" }
assert_response :success
end
test "should block crawler" do
get root_path, headers: { "User-Agent" => "SomeCrawler/1.0" }
assert_response :forbidden
end
test "should block scraper" do
get root_path, headers: { "User-Agent" => "WebScraper/2.0" }
assert_response :forbidden
end
end
+177
View File
@@ -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

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