From fac5b73b4bdabf5b02bb6905ed76f1ffe144f251 Mon Sep 17 00:00:00 2001 From: Yuri Sidorov <403994+newstler@users.noreply.github.com> Date: Wed, 7 Jan 2026 16:03:23 +0100 Subject: [PATCH 001/106] Upgraded Ruby to 4.0.0 --- .ruby-version | 2 +- Gemfile.lock | 471 +++++++++++++++++++++++++++++++++----------------- 2 files changed, 314 insertions(+), 159 deletions(-) diff --git a/.ruby-version b/.ruby-version index f989260..fcdb2e1 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.4.4 +4.0.0 diff --git a/Gemfile.lock b/Gemfile.lock index 3157de4..f64add4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,31 +1,31 @@ GIT remote: https://github.com/rails/rails.git - revision: 5a7a59ab4a9ac34b8cadf9d8ce7b554da50c5e43 + revision: e6da5b5d790816bd0b1f9c979621b24b6b595980 branch: main specs: - actioncable (8.1.0.alpha) - actionpack (= 8.1.0.alpha) - activesupport (= 8.1.0.alpha) + actioncable (8.2.0.alpha) + actionpack (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (8.1.0.alpha) - actionpack (= 8.1.0.alpha) - activejob (= 8.1.0.alpha) - activerecord (= 8.1.0.alpha) - activestorage (= 8.1.0.alpha) - activesupport (= 8.1.0.alpha) + actionmailbox (8.2.0.alpha) + actionpack (= 8.2.0.alpha) + activejob (= 8.2.0.alpha) + activerecord (= 8.2.0.alpha) + activestorage (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) mail (>= 2.8.0) - actionmailer (8.1.0.alpha) - actionpack (= 8.1.0.alpha) - actionview (= 8.1.0.alpha) - activejob (= 8.1.0.alpha) - activesupport (= 8.1.0.alpha) + actionmailer (8.2.0.alpha) + actionpack (= 8.2.0.alpha) + actionview (= 8.2.0.alpha) + activejob (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (8.1.0.alpha) - actionview (= 8.1.0.alpha) - activesupport (= 8.1.0.alpha) + actionpack (8.2.0.alpha) + actionview (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) nokogiri (>= 1.8.5) rack (>= 2.2.4) rack-session (>= 1.0.1) @@ -33,109 +33,109 @@ GIT rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (8.1.0.alpha) + actiontext (8.2.0.alpha) action_text-trix (~> 2.1.15) - actionpack (= 8.1.0.alpha) - activerecord (= 8.1.0.alpha) - activestorage (= 8.1.0.alpha) - activesupport (= 8.1.0.alpha) + actionpack (= 8.2.0.alpha) + activerecord (= 8.2.0.alpha) + activestorage (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (8.1.0.alpha) - activesupport (= 8.1.0.alpha) + actionview (8.2.0.alpha) + activesupport (= 8.2.0.alpha) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (8.1.0.alpha) - activesupport (= 8.1.0.alpha) + activejob (8.2.0.alpha) + activesupport (= 8.2.0.alpha) globalid (>= 0.3.6) - activemodel (8.1.0.alpha) - activesupport (= 8.1.0.alpha) - activerecord (8.1.0.alpha) - activemodel (= 8.1.0.alpha) - activesupport (= 8.1.0.alpha) + activemodel (8.2.0.alpha) + activesupport (= 8.2.0.alpha) + activerecord (8.2.0.alpha) + activemodel (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) timeout (>= 0.4.0) - activestorage (8.1.0.alpha) - actionpack (= 8.1.0.alpha) - activejob (= 8.1.0.alpha) - activerecord (= 8.1.0.alpha) - activesupport (= 8.1.0.alpha) + activestorage (8.2.0.alpha) + actionpack (= 8.2.0.alpha) + activejob (= 8.2.0.alpha) + activerecord (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) marcel (~> 1.0) - activesupport (8.1.0.alpha) + activesupport (8.2.0.alpha) base64 - benchmark (>= 0.3) bigdecimal concurrent-ruby (~> 1.0, >= 1.3.1) connection_pool (>= 2.2.5) drb i18n (>= 1.6, < 2) + json logger (>= 1.4.2) minitest (>= 5.1) + psych (>= 4) securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) uri (>= 0.13.1) - rails (8.1.0.alpha) - actioncable (= 8.1.0.alpha) - actionmailbox (= 8.1.0.alpha) - actionmailer (= 8.1.0.alpha) - actionpack (= 8.1.0.alpha) - actiontext (= 8.1.0.alpha) - actionview (= 8.1.0.alpha) - activejob (= 8.1.0.alpha) - activemodel (= 8.1.0.alpha) - activerecord (= 8.1.0.alpha) - activestorage (= 8.1.0.alpha) - activesupport (= 8.1.0.alpha) + rails (8.2.0.alpha) + actioncable (= 8.2.0.alpha) + actionmailbox (= 8.2.0.alpha) + actionmailer (= 8.2.0.alpha) + actionpack (= 8.2.0.alpha) + actiontext (= 8.2.0.alpha) + actionview (= 8.2.0.alpha) + activejob (= 8.2.0.alpha) + activemodel (= 8.2.0.alpha) + activerecord (= 8.2.0.alpha) + activestorage (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) bundler (>= 1.15.0) - railties (= 8.1.0.alpha) - railties (8.1.0.alpha) - actionpack (= 8.1.0.alpha) - activesupport (= 8.1.0.alpha) + railties (= 8.2.0.alpha) + railties (8.2.0.alpha) + actionpack (= 8.2.0.alpha) + activesupport (= 8.2.0.alpha) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) zeitwerk (~> 2.6) GEM remote: https://rubygems.org/ specs: - action_text-trix (2.1.15) + action_text-trix (2.1.16) railties active_link_to (1.0.5) actionpack addressable - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) + addressable (2.8.8) + public_suffix (>= 2.0.2, < 8.0) ast (2.4.3) - avo (3.21.1) + avo (3.28.0) actionview (>= 6.1) active_link_to activerecord (>= 6.1) activesupport (>= 6.1) addressable - avo-heroicons (>= 0.1.1) + avo-icons (>= 0.1.1) docile - inline_svg meta-tags - pagy (>= 7.0.0) + pagy (>= 7.0.0, < 43) prop_initializer (>= 0.2.0) turbo-rails (>= 2.0.0) turbo_power (>= 0.6.0) view_component (>= 3.7.0) zeitwerk (>= 2.6.12) - avo-heroicons (0.1.1) + avo-icons (0.1.1) + inline_svg base64 (0.3.0) - bcrypt (3.1.20) - bcrypt_pbkdf (1.1.1) - bcrypt_pbkdf (1.1.1-arm64-darwin) - benchmark (0.4.1) - bigdecimal (3.2.2) + bcrypt (3.1.21) + bcrypt_pbkdf (1.1.2) + bigdecimal (4.0.1) bindex (0.8.1) - bootsnap (1.18.6) + bootsnap (1.20.1) msgpack (~> 1.2) - brakeman (7.0.2) + brakeman (7.1.2) racc builder (3.3.0) capybara (3.40.0) @@ -147,11 +147,11 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) - concurrent-ruby (1.3.5) - connection_pool (2.5.3) + concurrent-ruby (1.3.6) + connection_pool (3.0.2) crass (1.0.6) - date (3.4.1) - debug (1.10.0) + date (3.5.1) + debug (1.11.1) irb (~> 1.10) reline (>= 0.3.8) devise (4.9.4) @@ -161,37 +161,37 @@ GEM responders warden (~> 1.2.3) docile (1.4.1) - dotenv (3.1.8) + dotenv (3.2.0) drb (2.2.3) ed25519 (1.4.0) - erb (5.0.1) + erb (6.0.1) erubi (1.13.1) - et-orbi (1.2.11) + et-orbi (1.4.0) tzinfo - fugit (1.11.1) - et-orbi (~> 1, >= 1.2.11) + fugit (1.12.1) + et-orbi (~> 1.4) raabro (~> 1.4) - globalid (1.2.1) + globalid (1.3.0) activesupport (>= 6.1) - i18n (1.14.7) + i18n (1.14.8) concurrent-ruby (~> 1.0) - importmap-rails (2.1.0) + importmap-rails (2.2.2) actionpack (>= 6.0.0) activesupport (>= 6.0.0) railties (>= 6.0.0) inline_svg (1.10.0) activesupport (>= 3.0) nokogiri (>= 1.6) - io-console (0.8.0) - irb (1.15.2) + io-console (0.8.2) + irb (1.16.0) pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) - jbuilder (2.13.0) - actionview (>= 5.0.0) - activesupport (>= 5.0.0) - json (2.12.2) - kamal (2.6.1) + jbuilder (2.14.1) + actionview (>= 7.0.0) + activesupport (>= 7.0.0) + json (2.18.0) + kamal (2.10.1) activesupport (>= 7.0) base64 (~> 0.2) bcrypt_pbkdf (~> 1.0) @@ -205,23 +205,24 @@ GEM language_server-protocol (3.17.0.5) lint_roller (1.1.0) logger (1.7.0) - loofah (2.24.1) + loofah (2.25.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) - mail (2.8.1) + mail (2.9.0) + logger mini_mime (>= 0.1.1) net-imap net-pop net-smtp - marcel (1.0.4) - matrix (0.4.2) - meta-tags (2.22.1) - actionpack (>= 6.0.0, < 8.1) - method_source (1.1.0) + marcel (1.1.0) + matrix (0.4.3) + meta-tags (2.22.2) + actionpack (>= 6.0.0, < 8.2) mini_mime (1.1.5) - minitest (5.25.5) + minitest (6.0.1) + prism (~> 1.5) msgpack (1.8.0) - net-imap (0.5.8) + net-imap (0.6.2) date net-protocol net-pop (0.1.2) @@ -235,44 +236,45 @@ GEM net-smtp (0.5.1) net-protocol net-ssh (7.3.0) - nio4r (2.7.4) - nokogiri (1.18.8-arm64-darwin) + nio4r (2.7.5) + nokogiri (1.19.0-arm64-darwin) + racc (~> 1.4) + nokogiri (1.19.0-x86_64-linux-gnu) racc (~> 1.4) - nokogiri (1.18.8-x86_64-linux-gnu) + nokogiri (1.19.0-x86_64-linux-musl) racc (~> 1.4) orm_adapter (0.5.0) - ostruct (0.6.1) - pagy (9.3.4) + ostruct (0.6.3) + pagy (9.4.0) parallel (1.27.0) - parser (3.3.8.0) + parser (3.3.10.0) ast (~> 2.4.1) racc - pp (0.6.2) + pp (0.6.3) prettyprint prettyprint (0.2.0) - prism (1.4.0) + prism (1.7.0) prop_initializer (0.2.0) zeitwerk (>= 2.6.18) - propshaft (1.1.0) + propshaft (1.3.1) actionpack (>= 7.0.0) activesupport (>= 7.0.0) rack - railties (>= 7.0.0) - psych (5.2.6) + psych (5.3.1) date stringio - public_suffix (6.0.2) - puma (6.6.0) + public_suffix (7.0.2) + puma (7.1.0) nio4r (~> 2.0) raabro (1.4.0) racc (1.8.1) - rack (3.1.16) + rack (3.2.4) rack-session (2.1.1) base64 (>= 0.1.0) rack (>= 3.0.0) rack-test (2.2.0) rack (>= 1.3) - rackup (2.2.1) + rackup (2.3.1) rack (>= 3) rails-dom-testing (2.3.0) activesupport (>= 5.0.0) @@ -282,18 +284,19 @@ GEM loofah (~> 2.21) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) rainbow (3.1.1) - rake (13.3.0) - rdoc (6.14.0) + rake (13.3.1) + rdoc (7.0.3) erb psych (>= 4.0.0) - regexp_parser (2.10.0) - reline (0.6.1) + tsort + regexp_parser (2.11.3) + reline (0.6.3) io-console (~> 0.5) - responders (3.1.1) - actionpack (>= 5.2) - railties (>= 5.2) - rexml (3.4.1) - rubocop (1.76.1) + responders (3.2.0) + actionpack (>= 7.0) + railties (>= 7.0) + rexml (3.4.4) + rubocop (1.82.1) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -301,17 +304,17 @@ GEM parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 2.9.3, < 3.0) - rubocop-ast (>= 1.45.0, < 2.0) + rubocop-ast (>= 1.48.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.45.1) + rubocop-ast (1.49.0) parser (>= 3.3.7.2) - prism (~> 1.4) - rubocop-performance (1.25.0) + prism (~> 1.7) + rubocop-performance (1.26.1) lint_roller (~> 1.1) rubocop (>= 1.75.0, < 2.0) - rubocop-ast (>= 1.38.0, < 2.0) - rubocop-rails (2.32.0) + rubocop-ast (>= 1.47.1, < 2.0) + rubocop-rails (2.34.3) activesupport (>= 4.2.0) lint_roller (~> 1.1) rack (>= 1.1) @@ -322,35 +325,36 @@ GEM rubocop-performance (>= 1.24) rubocop-rails (>= 2.30) ruby-progressbar (1.13.0) - rubyzip (2.4.1) + rubyzip (3.2.2) securerandom (0.4.1) - selenium-webdriver (4.33.0) + selenium-webdriver (4.39.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) - rubyzip (>= 1.2.2, < 3.0) + rubyzip (>= 1.2.2, < 4.0) websocket (~> 1.0) - solid_cable (3.0.8) + solid_cable (3.0.12) actioncable (>= 7.2) activejob (>= 7.2) activerecord (>= 7.2) railties (>= 7.2) - solid_cache (1.0.7) + solid_cache (1.0.10) activejob (>= 7.2) activerecord (>= 7.2) railties (>= 7.2) - solid_queue (1.1.5) + solid_queue (1.2.4) activejob (>= 7.1) activerecord (>= 7.1) concurrent-ruby (>= 1.3.1) - fugit (~> 1.11.0) + fugit (~> 1.11) railties (>= 7.1) - thor (~> 1.3.1) + thor (>= 1.3.1) sqlite-ulid (0.2.1-arm64-darwin) sqlite-ulid (0.2.1-x86_64-linux) - sqlite3 (2.7.0-arm64-darwin) - sqlite3 (2.7.0-x86_64-linux-gnu) - sshkit (1.24.0) + sqlite3 (2.9.0-arm64-darwin) + sqlite3 (2.9.0-x86_64-linux-gnu) + sqlite3 (2.9.0-x86_64-linux-musl) + sshkit (1.25.0) base64 logger net-scp (>= 1.1.2) @@ -359,32 +363,34 @@ GEM ostruct stimulus-rails (1.3.4) railties (>= 6.0.0) - stringio (3.1.7) - tailwindcss-rails (4.2.3) + stringio (3.2.0) + tailwindcss-rails (4.4.0) railties (>= 7.0.0) tailwindcss-ruby (~> 4.0) - tailwindcss-ruby (4.1.10-arm64-darwin) - tailwindcss-ruby (4.1.10-x86_64-linux-gnu) - thor (1.3.2) - thruster (0.1.13-arm64-darwin) - thruster (0.1.13-x86_64-linux) - timeout (0.4.3) - turbo-rails (2.0.16) + tailwindcss-ruby (4.1.18-arm64-darwin) + tailwindcss-ruby (4.1.18-x86_64-linux-gnu) + tailwindcss-ruby (4.1.18-x86_64-linux-musl) + thor (1.5.0) + thruster (0.1.17-arm64-darwin) + thruster (0.1.17-x86_64-linux) + timeout (0.6.0) + tsort (0.2.0) + turbo-rails (2.0.20) actionpack (>= 7.1.0) railties (>= 7.1.0) turbo_power (0.7.0) turbo-rails (>= 1.3.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - unicode-display_width (3.1.4) - unicode-emoji (~> 4.0, >= 4.0.4) - unicode-emoji (4.0.4) - uri (1.0.3) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + uri (1.1.1) useragent (0.16.11) - view_component (3.23.2) - activesupport (>= 5.2.0, < 8.1) + view_component (4.1.1) + actionview (>= 7.1.0, < 8.2) + activesupport (>= 7.1.0, < 8.2) concurrent-ruby (~> 1) - method_source (~> 1.0) warden (1.2.9) rack (>= 2.0.9) web-console (4.2.1) @@ -399,11 +405,13 @@ GEM websocket-extensions (0.1.5) xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.7.3) + zeitwerk (2.7.4) PLATFORMS - arm64-darwin-24 + arm64-darwin-25 x86_64-linux + x86_64-linux-gnu + x86_64-linux-musl DEPENDENCIES avo (>= 3.2) @@ -432,5 +440,152 @@ DEPENDENCIES turbo-rails web-console +CHECKSUMS + action_text-trix (2.1.16) sha256=f645a2c21821b8449fd1d6770708f4031c91a2eedf9ef476e9be93c64e703a8a + actioncable (8.2.0.alpha) + actionmailbox (8.2.0.alpha) + actionmailer (8.2.0.alpha) + actionpack (8.2.0.alpha) + actiontext (8.2.0.alpha) + actionview (8.2.0.alpha) + active_link_to (1.0.5) sha256=4830847b3d14589df1e9fc62038ceec015257fce975ec1c2a77836c461b139ba + activejob (8.2.0.alpha) + activemodel (8.2.0.alpha) + activerecord (8.2.0.alpha) + activestorage (8.2.0.alpha) + activesupport (8.2.0.alpha) + addressable (2.8.8) sha256=7c13b8f9536cf6364c03b9d417c19986019e28f7c00ac8132da4eb0fe393b057 + ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 + avo (3.28.0) sha256=9a7ab701f41ee201b87553a36f0d34d4fd03a7c7737aeba6c0da09ba5a031910 + avo-icons (0.1.1) sha256=d9a23d6d47bb7f8f04163119352a66a436dc8accf53f15cd0c3b5fcaffed082c + base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + bcrypt (3.1.21) sha256=5964613d750a42c7ee5dc61f7b9336fb6caca429ba4ac9f2011609946e4a2dcf + bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6 + bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7 + bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e + bootsnap (1.20.1) sha256=7ad62cda65c5157bcca0acfcc0ee11fcbb83d7d7a8a72d52ccd85e6ffc130b93 + brakeman (7.1.2) sha256=6b04927710a2e7d13a72248b5d404c633188e02417f28f3d853e4b6370d26dce + builder (3.3.0) sha256=497918d2f9dca528fdca4b88d84e4ef4387256d984b8154e9d5d3fe5a9c8835f + capybara (3.40.0) sha256=42dba720578ea1ca65fd7a41d163dd368502c191804558f6e0f71b391054aeef + concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab + connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a + crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d + date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 + debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6 + devise (4.9.4) sha256=920042fe5e704c548aa4eb65ebdd65980b83ffae67feb32c697206bfd975a7f8 + docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e + dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d + drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 + 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 + fugit (1.12.1) sha256=5898f478ede9b415f0804e42b8f3fd53f814bd85eebffceebdbc34e1107aaf68 + globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11 + i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5 + importmap-rails (2.2.2) sha256=729f5b1092f832780829ade1d0b46c7e53d91c556f06da7254da2977e93fe614 + inline_svg (1.10.0) sha256=5b652934236fd9f8adc61f3fd6e208b7ca3282698b19f28659971da84bf9a10f + io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc + irb (1.16.0) sha256=2abe56c9ac947cdcb2f150572904ba798c1e93c890c256f8429981a7675b0806 + jbuilder (2.14.1) sha256=4eb26376ff60ef100cb4fd6fd7533cd271f9998327e86adf20fd8c0e69fabb42 + json (2.18.0) sha256=b10506aee4183f5cf49e0efc48073d7b75843ce3782c68dbeb763351c08fd505 + 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 + marcel (1.1.0) sha256=fdcfcfa33cc52e93c4308d40e4090a5d4ea279e160a7f6af988260fa970e0bee + matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b + meta-tags (2.22.2) sha256=7fe78af4a92be12091f473cb84a21f6bddbd37f24c4413172df76cd14fff9e83 + mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef + minitest (6.0.1) sha256=7854c74f48e2e975969062833adc4013f249a4b212f5e7b9d5c040bf838d54bb + msgpack (1.8.0) sha256=e64ce0212000d016809f5048b48eb3a65ffb169db22238fb4b72472fecb2d732 + net-imap (0.6.2) sha256=08caacad486853c61676cca0c0c47df93db02abc4a8239a8b67eb0981428acc6 + net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 + net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8 + net-scp (4.1.0) sha256=a99b0b92a1e5d360b0de4ffbf2dc0c91531502d3d4f56c28b0139a7c093d1a5d + net-sftp (4.0.0) sha256=65bb91c859c2f93b09826757af11b69af931a3a9155050f50d1b06d384526364 + net-smtp (0.5.1) sha256=ed96a0af63c524fceb4b29b0d352195c30d82dd916a42f03c62a3a70e5b70736 + net-ssh (7.3.0) sha256=172076c4b30ce56fb25a03961b0c4da14e1246426401b0f89cba1a3b54bf3ef0 + nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1 + nokogiri (1.19.0-arm64-darwin) sha256=0811dfd936d5f6dd3f6d32ef790568bf29b2b7bead9ba68866847b33c9cf5810 + nokogiri (1.19.0-x86_64-linux-gnu) sha256=f482b95c713d60031d48c44ce14562f8d2ce31e3a9e8dd0ccb131e9e5a68b58c + nokogiri (1.19.0-x86_64-linux-musl) sha256=1c4ca6b381622420073ce6043443af1d321e8ed93cc18b08e2666e5bd02ffae4 + orm_adapter (0.5.0) sha256=aa5d0be5d540cbb46d3a93e88061f4ece6a25f6e97d6a47122beb84fe595e9b9 + ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 + pagy (9.4.0) sha256=db3f2e043f684155f18f78be62a81e8d033e39b9f97b1e1a8d12ad38d7bce738 + parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130 + parser (3.3.10.0) sha256=ce3587fa5cc55a88c4ba5b2b37621b3329aadf5728f9eafa36bbd121462aabd6 + pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6 + prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 + prism (1.7.0) sha256=10062f734bf7985c8424c44fac382ac04a58124ea3d220ec3ba9fe4f2da65103 + prop_initializer (0.2.0) sha256=bd27704d0df8c59c3baf0df5cf448eba2b140fb9934fb31b2e379b5c842d8820 + 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 + raabro (1.4.0) sha256=d4fa9ff5172391edb92b242eed8be802d1934b1464061ae5e70d80962c5da882 + racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f + rack (3.2.4) sha256=5d74b6f75082a643f43c1e76b419c40f0e5527fcfee1e669ac1e6b73c0ccb6f6 + rack-session (2.1.1) sha256=0b6dc07dea7e4b583f58a48e8b806d4c9f1c6c9214ebc202ec94562cbea2e4e9 + rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463 + rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868 + rails (8.2.0.alpha) + rails-dom-testing (2.3.0) sha256=8acc7953a7b911ca44588bf08737bc16719f431a1cc3091a292bca7317925c1d + rails-html-sanitizer (1.6.2) sha256=35fce2ca8242da8775c83b6ba9c1bcaad6751d9eb73c1abaa8403475ab89a560 + railties (8.2.0.alpha) + rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a + rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c + rdoc (7.0.3) sha256=dfe3d0981d19b7bba71d9dbaeb57c9f4e3a7a4103162148a559c4fc687ea81f9 + regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4 + reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835 + responders (3.2.0) sha256=89c2d6ac0ae16f6458a11524cae4a8efdceba1a3baea164d28ee9046bd3df55a + rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 + rubocop (1.82.1) sha256=09f1a6a654a960eda767aebea33e47603080f8e9c9a3f019bf9b94c9cab5e273 + rubocop-ast (1.49.0) sha256=49c3676d3123a0923d333e20c6c2dbaaae2d2287b475273fddee0c61da9f71fd + rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834 + rubocop-rails (2.34.3) sha256=10d37989024865ecda8199f311f3faca990143fbac967de943f88aca11eb9ad2 + rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d + ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 + rubyzip (3.2.2) sha256=c0ed99385f0625415c8f05bcae33fe649ed2952894a95ff8b08f26ca57ea5b3c + securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 + selenium-webdriver (4.39.0) sha256=984a1e63d39472eaf286bac3c6f1822fa7eea6eed9c07a66ce7b3bc5417ba826 + solid_cable (3.0.12) sha256=a168a54731a455d5627af48d8441ea3b554b8c1f6e6cd6074109de493e6b0460 + solid_cache (1.0.10) sha256=bc05a2fb3ac78a6f43cbb5946679cf9db67dd30d22939ededc385cb93e120d41 + solid_queue (1.2.4) sha256=bb60f9552a969ac377d87601b0ff6a088f5e6f20b0cbbe3844a59d022cac0e4b + sqlite-ulid (0.2.1-arm64-darwin) sha256=683aa064c0bb8a6e156e8ac875c25b7eb22cfeb7a9cebe4f1ed2e7f9841a4bd7 + sqlite-ulid (0.2.1-x86_64-linux) sha256=1d3df3a6db65927062192c8c21b7821fd1a1041570794bae519b2d82add8637d + sqlite3 (2.9.0-arm64-darwin) sha256=a917bd9b84285766ff3300b7d79cd583f5a067594c8c1263e6441618c04a6ed3 + sqlite3 (2.9.0-x86_64-linux-gnu) sha256=72fff9bd750070ba3af695511ba5f0e0a2d8a9206f84869640b3e99dfaf3d5a5 + sqlite3 (2.9.0-x86_64-linux-musl) sha256=ef716ba7a66d7deb1ccc402ac3a6d7343da17fac862793b7f0be3d2917253c90 + sshkit (1.25.0) sha256=c8c6543cdb60f91f1d277306d585dd11b6a064cb44eab0972827e4311ff96744 + stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06 + stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1 + tailwindcss-rails (4.4.0) sha256=efa2961351a52acebe616e645a81a30bb4f27fde46cc06ce7688d1cd1131e916 + tailwindcss-ruby (4.1.18-arm64-darwin) sha256=f940531d5a030c566d3d616004235bcd4c361abdd328f7d6c7e3a953a32e0155 + tailwindcss-ruby (4.1.18-x86_64-linux-gnu) sha256=e0a2220163246fe0126c5c5bafb95bc6206e7d21fce2a2878fd9c9a359137534 + tailwindcss-ruby (4.1.18-x86_64-linux-musl) sha256=d957cf545b09d2db7eb6267450cc1fc589e126524066537a0c4d5b99d701f4b2 + thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 + thruster (0.1.17-arm64-darwin) sha256=75da66fc4a0f012f9a317f6362f786a3fa953879a3fa6bed8deeaebf1c1d66ec + thruster (0.1.17-x86_64-linux) sha256=77b8f335075bd4ece7631dc84a19a710a1e6e7102cbce147b165b45851bdfcd3 + timeout (0.6.0) sha256=6d722ad619f96ee383a0c557ec6eb8c4ecb08af3af62098a0be5057bf00de1af + tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f + turbo-rails (2.0.20) sha256=cbcbb4dd3ce59f6471c9f911b1655b2c721998cc8303959d982da347f374ea95 + turbo_power (0.7.0) sha256=ad95d147e0fa761d0023ad9ca00528c7b7ddf6bba8ca2e23755d5b21b290d967 + tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b + unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 + unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f + uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 + useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844 + view_component (4.1.1) sha256=179f63b0db1d1a8f6af635dd684456b2bcdf6b6f4da2ef276bbe0579c17b377e + warden (1.2.9) sha256=46684f885d35a69dbb883deabf85a222c8e427a957804719e143005df7a1efd0 + web-console (4.2.1) sha256=e7bcf37a10ea2b4ec4281649d1cee461b32232d0a447e82c786e6841fd22fe20 + websocket (1.2.11) sha256=b7e7a74e2410b5e85c25858b26b3322f29161e300935f70a0e0d3c35e0462737 + 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 + BUNDLED WITH - 2.6.9 + 4.0.3 From e3d02defc021344e51a299d36c4f5f41cdc16c9f Mon Sep 17 00:00:00 2001 From: Yuri Sidorov <403994+newstler@users.noreply.github.com> Date: Fri, 9 Jan 2026 19:26:02 +0100 Subject: [PATCH 002/106] Remove devise, add magick links, configure script, and CLAUDE.md --- .gitignore | 3 + .kamal/secrets | 2 +- CLAUDE.md | 311 +++++++++++++++++ Gemfile | 1 - Gemfile.lock | 19 -- README.md | 198 ++++++++++- app/avo/resources/admin.rb | 15 + app/controllers/admins/admins_controller.rb | 35 ++ app/controllers/admins/sessions_controller.rb | 34 ++ app/controllers/application_controller.rb | 20 ++ app/controllers/avo/admins_controller.rb | 4 + app/controllers/home_controller.rb | 7 + app/controllers/sessions_controller.rb | 39 +++ app/helpers/admins/admins_helper.rb | 2 + app/helpers/admins/sessions_helper.rb | 2 + app/helpers/home_helper.rb | 2 + app/helpers/sessions_helper.rb | 2 + app/mailers/admin_mailer.rb | 12 + app/mailers/user_mailer.rb | 12 + app/models/admin.rb | 9 +- app/models/user.rb | 10 +- app/views/admin_mailer/magic_link.html.erb | 18 + app/views/admin_mailer/magic_link.text.erb | 9 + app/views/admins/admins/index.html.erb | 107 ++++++ app/views/admins/admins/new.html.erb | 82 +++++ app/views/admins/sessions/new.html.erb | 53 +++ app/views/home/index.html.erb | 80 +++++ app/views/layouts/application.html.erb | 35 +- app/views/sessions/new.html.erb | 36 ++ app/views/user_mailer/magic_link.html.erb | 18 + app/views/user_mailer/magic_link.text.erb | 9 + bin/configure | 163 +++++++++ config/initializers/avo.rb | 7 +- config/initializers/devise.rb | 313 ------------------ config/locales/devise.en.yml | 65 ---- config/routes.rb | 21 +- .../20241207165010_devise_create_users.rb | 47 --- .../20241207205829_devise_create_admins.rb | 46 --- db/migrate/20260109173041_create_users.rb | 12 + db/migrate/20260109173042_create_admins.rb | 11 + db/seeds.rb | 11 +- .../admins/admins_controller_test.rb | 23 ++ .../admins/sessions_controller_test.rb | 13 + test/controllers/home_controller_test.rb | 8 + test/controllers/sessions_controller_test.rb | 13 + test/fixtures/admins.yml | 14 +- test/fixtures/users.yml | 16 +- test/mailers/admin_mailer_test.rb | 11 + test/mailers/previews/admin_mailer_preview.rb | 7 + test/mailers/previews/user_mailer_preview.rb | 7 + test/mailers/user_mailer_test.rb | 11 + 51 files changed, 1457 insertions(+), 548 deletions(-) create mode 100644 CLAUDE.md create mode 100644 app/avo/resources/admin.rb create mode 100644 app/controllers/admins/admins_controller.rb create mode 100644 app/controllers/admins/sessions_controller.rb create mode 100644 app/controllers/avo/admins_controller.rb create mode 100644 app/controllers/home_controller.rb create mode 100644 app/controllers/sessions_controller.rb create mode 100644 app/helpers/admins/admins_helper.rb create mode 100644 app/helpers/admins/sessions_helper.rb create mode 100644 app/helpers/home_helper.rb create mode 100644 app/helpers/sessions_helper.rb create mode 100644 app/mailers/admin_mailer.rb create mode 100644 app/mailers/user_mailer.rb create mode 100644 app/views/admin_mailer/magic_link.html.erb create mode 100644 app/views/admin_mailer/magic_link.text.erb create mode 100644 app/views/admins/admins/index.html.erb create mode 100644 app/views/admins/admins/new.html.erb create mode 100644 app/views/admins/sessions/new.html.erb create mode 100644 app/views/home/index.html.erb create mode 100644 app/views/sessions/new.html.erb create mode 100644 app/views/user_mailer/magic_link.html.erb create mode 100644 app/views/user_mailer/magic_link.text.erb create mode 100755 bin/configure delete mode 100644 config/initializers/devise.rb delete mode 100644 config/locales/devise.en.yml delete mode 100644 db/migrate/20241207165010_devise_create_users.rb delete mode 100644 db/migrate/20241207205829_devise_create_admins.rb create mode 100644 db/migrate/20260109173041_create_users.rb create mode 100644 db/migrate/20260109173042_create_admins.rb create mode 100644 test/controllers/admins/admins_controller_test.rb create mode 100644 test/controllers/admins/sessions_controller_test.rb create mode 100644 test/controllers/home_controller_test.rb create mode 100644 test/controllers/sessions_controller_test.rb create mode 100644 test/mailers/admin_mailer_test.rb create mode 100644 test/mailers/previews/admin_mailer_preview.rb create mode 100644 test/mailers/previews/user_mailer_preview.rb create mode 100644 test/mailers/user_mailer_test.rb diff --git a/.gitignore b/.gitignore index 5922d24..e9aeb5e 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,9 @@ # Ignore master key for decrypting credentials and more. /config/master.key +# Ignore configuration marker +/.configured + /app/assets/builds/* !/app/assets/builds/.keep diff --git a/.kamal/secrets b/.kamal/secrets index 9a771a3..47be563 100644 --- a/.kamal/secrets +++ b/.kamal/secrets @@ -11,7 +11,7 @@ # GITHUB_TOKEN=$(gh config get -h github.com oauth_token) # Grab the registry password from ENV -KAMAL_REGISTRY_PASSWORD=$KAMAL_REGISTRY_PASSWORD +KAMAL_REGISTRY_PASSWORD=$(rails credentials:fetch kamal.registry_password) # Improve security by using a password manager. Never check config/master.key into git! RAILS_MASTER_KEY=$(cat config/master.key) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5286f08 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,311 @@ +# CLAUDE.md + +This file provides guidance to Claude Code when working with code in this repository. + +## Project Overview + +This is a Rails 8 template with magic link authentication for users and admins. The project is designed to be cloned and configured for new projects using `bin/configure`. + +### First-Time Setup + +When starting a new project from this template: + +1. Clone the repo: `git clone git@github.com:newstler/template.git my_project` +2. Run configuration: `bin/configure` +3. The script will: + - Rename project from "Template" to your project name + - Ask for admin email + - Create `.env` file + - Run `bin/setup` to install dependencies and setup database + +### Tech Stack + +- **Ruby**: 4.0.x +- **Rails**: 8.x +- **Database**: SQLite with Solid Stack +- **Background Jobs**: Solid Queue +- **Caching**: Solid Cache +- **Frontend**: Hotwire (Turbo + Stimulus), Tailwind CSS 4 +- **Asset Pipeline**: Propshaft +- **Deployment**: Kamal 2 + +## Development Commands + +### Server Management +```bash +bin/dev # Start dev server (REQUIRED - not rails server) +tail -f log/development.log # Monitor logs +``` + +### Database +```bash +rails db:create db:migrate db:seed +rails db # Database console +``` + +### Testing & Code Quality +```bash +rails test # Run all tests +rails test test/models/foo_test.rb:42 # Single test +bundle exec rubocop -A # Auto-fix style issues +``` + +### Rails Console & Generators +```bash +rails console +rails generate model ModelName +``` + +## Architecture Principles + +### 37signals "Vanilla Rails" Philosophy +Following patterns from Basecamp, HEY, and Campfire: + +1. **Rich domain models** over service objects +2. **CRUD controllers** over custom actions (everything is a resource) +3. **Concerns** for horizontal code sharing +4. **Records as state** over boolean columns +5. **Database-backed queues and cache** (Solid Stack) +6. **Build it yourself** before reaching for gems +7. **Ship to learn** — prototype quality is valid + +### Vladimir Dementyev's Layered Design +From "Layered Design for Ruby on Rails Applications": + +1. **Grow into abstractions** — let them emerge from code, don't create empty directories +2. **Service layer as waiting room** — for abstractions not yet revealed +3. **Form Objects** when UI forms diverge from models +4. **Scopes and concerns** for query reuse (not separate Query Objects) + +### Abstraction Decision Tree +``` +Need new abstraction? +├── Is it a form that differs from model? → Form Object (app/forms/) +├── Is it shared model behavior? → Concern (app/models/concerns/) +├── Is it shared controller behavior? → Concern (app/controllers/concerns/) +├── Is it external service integration? → Client (app/clients/) +├── Is it a complex query? → Scope or concern on the model +└── None of the above? → Keep in model/controller until pattern emerges +``` + +## Data Model + +### Primary Keys + +**Option A: ULIDs** (sortable, distributed-friendly): +```ruby +create_table :accounts, force: true, id: false do |t| + t.primary_key :id, :string, default: -> { "ULID()" } + # ... +end +``` + +**Option B: Standard integer IDs** (Rails default) + +### Core Entities + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Entity1 │ +│ ├── attribute1, attribute2 │ +│ └── associations │ +│ │ +│ Entity2 │ +│ ├── attribute1, attribute2 │ +│ └── associations │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Testing + +### Framework +- **Minitest** (Rails default, per 37signals style) +- **Fixtures** over factories +- **Integration tests** for critical paths + +### Fixture Strategy +```yaml +# test/fixtures/[models].yml +example_record: + name: "Example" + # ... +``` + +### Test Helper +```ruby +# test/test_helper.rb +class ActiveSupport::TestCase + # shared setup +end + +class ActionDispatch::IntegrationTest + # integration test setup +end +``` + +## File Structure + +``` +app/ +├── channels/ +├── clients/ # External service wrappers (create when needed) +├── controllers/ +│ ├── concerns/ +│ └── webhooks/ # Third-party webhooks +├── jobs/ +├── mailers/ +├── models/ +│ └── concerns/ +└── views/ + └── layouts/ + +config/ +├── deploy.yml # Kamal configuration +└── ... +``` + +**Note:** Create `app/forms/`, `app/clients/` etc. only when needed. Don't create empty directories. + +## Authentication System + +This template uses **magic link authentication** (passwordless): + +### User Authentication +- Users create accounts automatically on first magic link request +- Path: `/session/new` +- Model: `User` (email, name) +- Controller: `SessionsController` +- Mailer: `UserMailer.magic_link` + +### Admin Authentication +- Admins must be created by other admins (or via seeds) +- Path: `/admins/session/new` +- Model: `Admin` (email only) +- Controller: `Admins::SessionsController` +- Mailer: `AdminMailer.magic_link` +- Admin panel: `/avo` (requires admin authentication) + +### Magic Link Implementation +```ruby +# In models (User/Admin) +def generate_magic_link_token + signed_id(purpose: :magic_link, expires_in: 15.minutes) +end + +# In controllers +user = User.find_signed!(params[:token], purpose: :magic_link) +session[:user_id] = user.id +``` + +### Helper Methods (ApplicationController) +- `current_user` / `current_admin` +- `authenticate_user!` / `authenticate_admin!` + +## Important Development Practices + +### Always +- Use `bin/dev` to start the server +- Check logs after every significant change +- Write tests with features (same commit) +- Use magic links for authentication (no passwords) + +### Never +- Use Devise (we use magic links) +- Use Sidekiq (Solid Queue instead) +- Create empty directories "for later" +- Add gems before trying vanilla Rails +- Create boolean columns for state (use records or enums) + +### Code Style +- RuboCop with auto-fix via lefthook +- `params.expect()` for parameter handling (Rails 8.1+) +- Thin controllers, rich models +- Concerns for shared behavior +- CRUD resources for everything + +## Credentials + +```bash +rails credentials:edit --environment development +``` + +```yaml +# Example structure +stripe: + secret_key: sk_test_... + webhook_secret: whsec_... + +openai: + api_key: sk-... + +anthropic: + api_key: sk-ant-... +``` + +## Deployment + +Kamal 2 deployment: + +```yaml +# config/deploy.yml +service: myapp +image: myorg/myapp + +servers: + web: + - 1.2.3.4 + +registry: + username: myorg + password: + - KAMAL_REGISTRY_PASSWORD + +env: + clear: + RAILS_ENV: production + SOLID_QUEUE_IN_PUMA: true + secret: + - RAILS_MASTER_KEY +``` + +--- + +## Optional Sections + + + +### Multi-Tenancy Architecture (if applicable) +Following 37signals pattern: subdomain-based tenancy with middleware. + +```ruby +# app/models/current.rb +class Current < ActiveSupport::CurrentAttributes + attribute :account, :user +end +``` + +### AI Integration Patterns (if applicable) + +**Graceful Degradation:** +```ruby +module AiResilient + extend ActiveSupport::Concern + + def process_with_resilience + yield + rescue SomeAIError => e + handle_gracefully(e) + end +end +``` + +**Separating Instructions from Data:** +```ruby +def messages_for_ai + [ + { role: :system, content: system_prompt }, # Pure instructions + *messages.map { |m| { role: m.role, content: m.content } } + ] +end +# User content is ALWAYS in user/assistant messages, never injected into system +``` diff --git a/Gemfile b/Gemfile index f627a44..d73dd32 100644 --- a/Gemfile +++ b/Gemfile @@ -64,5 +64,4 @@ group :test do gem "selenium-webdriver" end -gem "devise", "~> 4.9" gem "avo", ">= 3.2" diff --git a/Gemfile.lock b/Gemfile.lock index f64add4..748aca0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -129,7 +129,6 @@ GEM avo-icons (0.1.1) inline_svg base64 (0.3.0) - bcrypt (3.1.21) bcrypt_pbkdf (1.1.2) bigdecimal (4.0.1) bindex (0.8.1) @@ -154,12 +153,6 @@ GEM debug (1.11.1) irb (~> 1.10) reline (>= 0.3.8) - devise (4.9.4) - bcrypt (~> 3.0) - orm_adapter (~> 0.1) - railties (>= 4.1.0) - responders - warden (~> 1.2.3) docile (1.4.1) dotenv (3.2.0) drb (2.2.3) @@ -243,7 +236,6 @@ GEM racc (~> 1.4) nokogiri (1.19.0-x86_64-linux-musl) racc (~> 1.4) - orm_adapter (0.5.0) ostruct (0.6.3) pagy (9.4.0) parallel (1.27.0) @@ -292,9 +284,6 @@ GEM regexp_parser (2.11.3) reline (0.6.3) io-console (~> 0.5) - responders (3.2.0) - actionpack (>= 7.0) - railties (>= 7.0) rexml (3.4.4) rubocop (1.82.1) json (~> 2.3) @@ -391,8 +380,6 @@ GEM actionview (>= 7.1.0, < 8.2) activesupport (>= 7.1.0, < 8.2) concurrent-ruby (~> 1) - warden (1.2.9) - rack (>= 2.0.9) web-console (4.2.1) actionview (>= 6.0.0) activemodel (>= 6.0.0) @@ -419,7 +406,6 @@ DEPENDENCIES brakeman capybara debug - devise (~> 4.9) dotenv importmap-rails jbuilder @@ -459,7 +445,6 @@ CHECKSUMS avo (3.28.0) sha256=9a7ab701f41ee201b87553a36f0d34d4fd03a7c7737aeba6c0da09ba5a031910 avo-icons (0.1.1) sha256=d9a23d6d47bb7f8f04163119352a66a436dc8accf53f15cd0c3b5fcaffed082c base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b - bcrypt (3.1.21) sha256=5964613d750a42c7ee5dc61f7b9336fb6caca429ba4ac9f2011609946e4a2dcf bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6 bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7 bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e @@ -472,7 +457,6 @@ CHECKSUMS crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6 - devise (4.9.4) sha256=920042fe5e704c548aa4eb65ebdd65980b83ffae67feb32c697206bfd975a7f8 docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 @@ -512,7 +496,6 @@ CHECKSUMS nokogiri (1.19.0-arm64-darwin) sha256=0811dfd936d5f6dd3f6d32ef790568bf29b2b7bead9ba68866847b33c9cf5810 nokogiri (1.19.0-x86_64-linux-gnu) sha256=f482b95c713d60031d48c44ce14562f8d2ce31e3a9e8dd0ccb131e9e5a68b58c nokogiri (1.19.0-x86_64-linux-musl) sha256=1c4ca6b381622420073ce6043443af1d321e8ed93cc18b08e2666e5bd02ffae4 - orm_adapter (0.5.0) sha256=aa5d0be5d540cbb46d3a93e88061f4ece6a25f6e97d6a47122beb84fe595e9b9 ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 pagy (9.4.0) sha256=db3f2e043f684155f18f78be62a81e8d033e39b9f97b1e1a8d12ad38d7bce738 parallel (1.27.0) sha256=4ac151e1806b755fb4e2dc2332cbf0e54f2e24ba821ff2d3dcf86bf6dc4ae130 @@ -540,7 +523,6 @@ CHECKSUMS rdoc (7.0.3) sha256=dfe3d0981d19b7bba71d9dbaeb57c9f4e3a7a4103162148a559c4fc687ea81f9 regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4 reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835 - responders (3.2.0) sha256=89c2d6ac0ae16f6458a11524cae4a8efdceba1a3baea164d28ee9046bd3df55a rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 rubocop (1.82.1) sha256=09f1a6a654a960eda767aebea33e47603080f8e9c9a3f019bf9b94c9cab5e273 rubocop-ast (1.49.0) sha256=49c3676d3123a0923d333e20c6c2dbaaae2d2287b475273fddee0c61da9f71fd @@ -579,7 +561,6 @@ CHECKSUMS uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844 view_component (4.1.1) sha256=179f63b0db1d1a8f6af635dd684456b2bcdf6b6f4da2ef276bbe0579c17b377e - warden (1.2.9) sha256=46684f885d35a69dbb883deabf85a222c8e427a957804719e143005df7a1efd0 web-console (4.2.1) sha256=e7bcf37a10ea2b4ec4281649d1cee461b32232d0a447e82c786e6841fd22fe20 websocket (1.2.11) sha256=b7e7a74e2410b5e85c25858b26b3322f29161e300935f70a0e0d3c35e0462737 websocket-driver (0.8.0) sha256=ed0dba4b943c22f17f9a734817e808bc84cdce6a7e22045f5315aa57676d4962 diff --git a/README.md b/README.md index 7db80e4..e0861d5 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,196 @@ -# README +# Rails 8 Template -This README would normally document whatever steps are necessary to get the -application up and running. +A modern Rails 8 template following 37signals' vanilla Rails philosophy with built-in magic link authentication. -Things you may want to cover: +## Tech Stack -* Ruby version +- **Ruby** 4.0.x +- **Rails** 8.2.x +- **Database**: SQLite with Solid Stack (Cache, Queue, Cable) +- **Frontend**: Hotwire (Turbo + Stimulus), Tailwind CSS 4 +- **Asset Pipeline**: Propshaft +- **Deployment**: Kamal 2 +- **Authentication**: Magic Links (passwordless) +- **Admin Panel**: Avo 3.x +- **Primary Keys**: ULIDs (sortable, distributed-friendly) -* System dependencies +## Features -* Configuration +- **Magic Link Authentication** for users and admins + - Users: First magic link creates account, subsequent ones sign in + - Admins: Only existing admins can create new admins +- **ULID Primary Keys** for better distributed system support +- **Solid Stack** for production-ready background jobs, caching, and cable +- **Vanilla Rails** approach - no unnecessary abstractions -* Database creation +## Getting Started -* Database initialization +### Clone for a New Project -* How to run the test suite +```bash +# Clone this template +git clone git@github.com:newstler/template.git my_new_project +cd my_new_project -* Services (job queues, cache servers, search engines, etc.) +# Keep template as remote for updates +git remote rename origin template +git remote add origin git@github.com:yourname/my_new_project.git -* Deployment instructions +# Configure project (renames from Template, sets admin email) +bin/configure -* ... +# This will prompt you for: +# - Project name (defaults to folder name) +# - Admin email address +# Then it will automatically run bin/setup +``` + +The `bin/configure` script will: +1. Rename the project from "Template" to your project name +2. Ask for your admin email +3. Create `.env` file with admin email +4. Update `db/seeds.rb` to use the email +5. Run `bin/setup` to install dependencies and setup database + +### Pull Template Updates + +```bash +# Pull latest changes from template +git fetch template +git merge template/main + +# Or cherry-pick specific commits +git cherry-pick +``` + +## Authentication System + +### User Authentication + +Users can sign up and sign in using magic links sent to their email: + +1. Visit `/session/new` +2. Enter email address +3. Receive magic link via email (creates account on first use) +4. Click link to sign in + +### Admin Authentication + +Admins must be created by other admins: + +1. First admin is created via `rails db:seed` (update email in `db/seeds.rb`) +2. Existing admins can create new admins at `/admins/admins` +3. New admin receives magic link via email +4. Admin signs in at `/admins/session/new` + +### Admin Access + +- Admin panel: `/avo` +- Admin management: `/admins/admins` +- Admin login: `/admins/session/new` + +Only authenticated admins can access Avo. + +## Development + +```bash +# Start dev server (Procfile.dev) +bin/dev + +# Console +rails console + +# Database +rails db +rails db:migrate +rails db:seed + +# Tests +rails test +rails test test/models/user_test.rb:42 + +# Code quality +bundle exec rubocop -A + +# Reconfigure project (if needed) +bin/configure +``` + +### Environment Variables + +The project uses `.env` for development configuration: + +```bash +FIRST_ADMIN_EMAIL=admin@example.com +``` + +This is automatically created by `bin/configure`. + +## Architecture Principles + +This template follows [37signals vanilla Rails philosophy](https://dev.37signals.com/) and patterns from [Layered Design for Ruby on Rails Applications](https://www.packtpub.com/product/layered-design-for-ruby-on-rails-applications/9781801813785): + +- **Rich domain models** over service objects +- **CRUD controllers** - everything is a resource +- **Concerns** for horizontal code sharing +- **Grow into abstractions** - don't create empty directories +- **Ship to learn** - prototype quality is valid + +See [CLAUDE.md](./CLAUDE.md) for complete guidelines. + +## Project Structure + +``` +app/ +├── controllers/ +│ ├── sessions_controller.rb # User auth +│ └── admins/ +│ ├── sessions_controller.rb # Admin auth +│ └── admins_controller.rb # Admin management +├── models/ +│ ├── user.rb # User model +│ └── admin.rb # Admin model +├── mailers/ +│ ├── user_mailer.rb # User magic links +│ └── admin_mailer.rb # Admin magic links +└── views/ +``` + +## Deployment + +Using Kamal 2: + +```bash +# Update config/deploy.yml with your settings +kamal setup +kamal deploy +``` + +See `config/deploy.yml` for configuration. + +## Credentials + +```bash +# Edit credentials +rails credentials:edit --environment development +rails credentials:edit --environment production +``` + +Example structure: + +```yaml +# Stripe, OpenAI, Anthropic, etc. +stripe: + secret_key: sk_test_... + webhook_secret: whsec_... +``` + +## License + +MIT + +## Credits + +Built by [Yuri Sidorov](https://yurisidorov.com) following best practices from: +- [37signals](https://37signals.com/) +- [Layered Design for Ruby on Rails Applications](https://www.packtpub.com/product/layered-design-for-ruby-on-rails-applications/9781801813785) diff --git a/app/avo/resources/admin.rb b/app/avo/resources/admin.rb new file mode 100644 index 0000000..6f9bb94 --- /dev/null +++ b/app/avo/resources/admin.rb @@ -0,0 +1,15 @@ +class Avo::Resources::Admin < Avo::BaseResource + # self.includes = [] + # self.attachments = [] + # self.search = { + # query: -> { query.ransack(id_eq: q, m: "or").result(distinct: false) } + # } + + def fields + field :email, as: :gravatar + field :email, as: :text + field :created_at, as: :date, readonly: true + field :updated_at, as: :date, readonly: true + field :id, as: :text, readonly: true + end +end diff --git a/app/controllers/admins/admins_controller.rb b/app/controllers/admins/admins_controller.rb new file mode 100644 index 0000000..93197ac --- /dev/null +++ b/app/controllers/admins/admins_controller.rb @@ -0,0 +1,35 @@ +class Admins::AdminsController < ApplicationController + before_action :authenticate_admin! + + def index + @admins = Admin.all.order(created_at: :desc) + end + + def new + @admin = Admin.new + end + + def create + @admin = Admin.new(admin_params) + + if @admin.save + # Send magic link to new admin + AdminMailer.magic_link(@admin).deliver_later + redirect_to admins_admins_path, notice: "Admin created and magic link sent!" + else + render :new, status: :unprocessable_entity + end + end + + def destroy + @admin = Admin.find(params[:id]) + @admin.destroy + redirect_to admins_admins_path, notice: "Admin deleted successfully" + end + + private + + def admin_params + params.expect(admin: [ :email ]) + end +end diff --git a/app/controllers/admins/sessions_controller.rb b/app/controllers/admins/sessions_controller.rb new file mode 100644 index 0000000..31438b7 --- /dev/null +++ b/app/controllers/admins/sessions_controller.rb @@ -0,0 +1,34 @@ +class Admins::SessionsController < ApplicationController + skip_before_action :authenticate_user!, only: [ :new, :create, :verify ] + + def new + # Show admin login form + end + + def create + email = params.expect(session: :email)[:email] + admin = Admin.find_by(email: email) + + if admin + # Send magic link to existing admin + AdminMailer.magic_link(admin).deliver_later + redirect_to new_admins_session_path, notice: "Check your email for a magic link!" + else + redirect_to new_admins_session_path, alert: "Admin not found. Only existing admins can log in." + end + end + + def verify + admin = Admin.find_signed!(params[:token], purpose: :magic_link) + session[:admin_id] = admin.id + + redirect_to "/avo", notice: "Welcome back, admin!" + rescue ActiveSupport::MessageVerifier::InvalidSignature + redirect_to new_admins_session_path, alert: "Invalid or expired magic link" + end + + def destroy + session[:admin_id] = nil + redirect_to new_admins_session_path, notice: "Signed out successfully" + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 0d95db2..ac44531 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,4 +1,24 @@ class ApplicationController < ActionController::Base # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. allow_browser versions: :modern + + private + + def current_user + @current_user ||= User.find_by(id: session[:user_id]) if session[:user_id] + end + helper_method :current_user + + def current_admin + @current_admin ||= Admin.find_by(id: session[:admin_id]) if session[:admin_id] + end + helper_method :current_admin + + def authenticate_user! + redirect_to new_session_path, alert: "Please log in" unless current_user + end + + def authenticate_admin! + redirect_to new_admins_session_path, alert: "Please log in as admin" unless current_admin + end end diff --git a/app/controllers/avo/admins_controller.rb b/app/controllers/avo/admins_controller.rb new file mode 100644 index 0000000..d7209ef --- /dev/null +++ b/app/controllers/avo/admins_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/3.0/controllers.html +class Avo::AdminsController < Avo::ResourcesController +end diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb new file mode 100644 index 0000000..da57e52 --- /dev/null +++ b/app/controllers/home_controller.rb @@ -0,0 +1,7 @@ +class HomeController < ApplicationController + before_action :authenticate_user! + + def index + @user = current_user + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb new file mode 100644 index 0000000..19f3ebf --- /dev/null +++ b/app/controllers/sessions_controller.rb @@ -0,0 +1,39 @@ +class SessionsController < ApplicationController + skip_before_action :authenticate_user!, only: [ :new, :create, :verify ] + + def new + # Show login form + end + + def create + email = params.expect(session: :email)[:email] + user = User.find_by(email: email) + + # Create user if doesn't exist (first magic link creates the account) + unless user + user = User.create!( + email: email, + name: email.split("@").first.titleize # Default name from email + ) + end + + # Send magic link + UserMailer.magic_link(user).deliver_later + + redirect_to new_session_path, notice: "Check your email for a magic link!" + end + + def verify + user = User.find_signed!(params[:token], purpose: :magic_link) + session[:user_id] = user.id + + redirect_to home_path, notice: "Welcome back, #{user.name}!" + rescue ActiveSupport::MessageVerifier::InvalidSignature + redirect_to new_session_path, alert: "Invalid or expired magic link" + end + + def destroy + session[:user_id] = nil + redirect_to new_session_path, notice: "Signed out successfully" + end +end diff --git a/app/helpers/admins/admins_helper.rb b/app/helpers/admins/admins_helper.rb new file mode 100644 index 0000000..fbe772f --- /dev/null +++ b/app/helpers/admins/admins_helper.rb @@ -0,0 +1,2 @@ +module Admins::AdminsHelper +end diff --git a/app/helpers/admins/sessions_helper.rb b/app/helpers/admins/sessions_helper.rb new file mode 100644 index 0000000..64b1d80 --- /dev/null +++ b/app/helpers/admins/sessions_helper.rb @@ -0,0 +1,2 @@ +module Admins::SessionsHelper +end diff --git a/app/helpers/home_helper.rb b/app/helpers/home_helper.rb new file mode 100644 index 0000000..23de56a --- /dev/null +++ b/app/helpers/home_helper.rb @@ -0,0 +1,2 @@ +module HomeHelper +end diff --git a/app/helpers/sessions_helper.rb b/app/helpers/sessions_helper.rb new file mode 100644 index 0000000..309f8b2 --- /dev/null +++ b/app/helpers/sessions_helper.rb @@ -0,0 +1,2 @@ +module SessionsHelper +end diff --git a/app/mailers/admin_mailer.rb b/app/mailers/admin_mailer.rb new file mode 100644 index 0000000..01b6891 --- /dev/null +++ b/app/mailers/admin_mailer.rb @@ -0,0 +1,12 @@ +class AdminMailer < ApplicationMailer + def magic_link(admin) + @admin = admin + @token = admin.generate_magic_link_token + @magic_link_url = admins_verify_magic_link_url(token: @token) + + mail( + to: @admin.email, + subject: "Your admin magic link to sign in" + ) + end +end diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb new file mode 100644 index 0000000..4e8d14d --- /dev/null +++ b/app/mailers/user_mailer.rb @@ -0,0 +1,12 @@ +class UserMailer < ApplicationMailer + def magic_link(user) + @user = user + @token = user.generate_magic_link_token + @magic_link_url = verify_magic_link_url(token: @token) + + mail( + to: @user.email, + subject: "Your magic link to sign in" + ) + end +end diff --git a/app/models/admin.rb b/app/models/admin.rb index 7f0c40f..08ce5a6 100644 --- a/app/models/admin.rb +++ b/app/models/admin.rb @@ -1,6 +1,7 @@ class Admin < ApplicationRecord - # Include default devise modules. Others available are: - # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable - devise :database_authenticatable, # :registerable, - :recoverable, :rememberable, :validatable + validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP } + + def generate_magic_link_token + signed_id(purpose: :magic_link, expires_in: 15.minutes) + end end diff --git a/app/models/user.rb b/app/models/user.rb index dcb5f12..802397a 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,8 +1,8 @@ class User < ApplicationRecord - # Include default devise modules. Others available are: - # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable - devise :database_authenticatable, :registerable, - :recoverable, :rememberable, :validatable - + validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP } validates :name, presence: true + + def generate_magic_link_token + signed_id(purpose: :magic_link, expires_in: 15.minutes) + end end diff --git a/app/views/admin_mailer/magic_link.html.erb b/app/views/admin_mailer/magic_link.html.erb new file mode 100644 index 0000000..d1579c3 --- /dev/null +++ b/app/views/admin_mailer/magic_link.html.erb @@ -0,0 +1,18 @@ +

Admin Sign In

+ +

Hi Admin,

+ +

Click the button below to sign in to the admin panel:

+ +

+ + Admin Sign In + +

+ +

Or copy and paste this link into your browser:

+

<%= @magic_link_url %>

+ +

This link will expire in 15 minutes.

+ +

If you didn't request this, please contact another administrator immediately.

diff --git a/app/views/admin_mailer/magic_link.text.erb b/app/views/admin_mailer/magic_link.text.erb new file mode 100644 index 0000000..e053034 --- /dev/null +++ b/app/views/admin_mailer/magic_link.text.erb @@ -0,0 +1,9 @@ +Hi Admin, + +Click the link below to sign in to the admin panel: + +<%= @magic_link_url %> + +This link will expire in 15 minutes. + +If you didn't request this, please contact another administrator immediately. diff --git a/app/views/admins/admins/index.html.erb b/app/views/admins/admins/index.html.erb new file mode 100644 index 0000000..98d0aec --- /dev/null +++ b/app/views/admins/admins/index.html.erb @@ -0,0 +1,107 @@ +
+
+
+
+

Admin Management

+

Manage admin accounts and access

+
+ <%= link_to new_admins_admin_path, class: "inline-flex items-center px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors font-medium" do %> + + + + Add New Admin + <% end %> +
+
+ +
+ + + + + + + + + + + <% @admins.each do |admin| %> + + + + + + + <% end %> + +
+ Admin + + ID + + Created + + Actions +
+
+
+
+ + <%= admin.email[0].upcase %> + +
+
+
+
+ <%= admin.email %> +
+ <% if admin == current_admin %> + + You + + <% end %> +
+
+
+ <%= admin.id %> + + <%= time_tag admin.created_at, admin.created_at.strftime("%b %d, %Y") %> + + <% if admin != current_admin %> + <%= button_to admins_admin_path(admin), + method: :delete, + class: "text-red-600 hover:text-red-900 inline-flex items-center", + data: { turbo_confirm: "Are you sure you want to remove #{admin.email}? They will lose admin access immediately." } do %> + + + + Remove + <% end %> + <% else %> + Current admin + <% end %> +
+ + <% if @admins.empty? %> +
+ + + +

No admins

+

Get started by creating a new admin.

+
+ <% end %> +
+ +
+
+ + + +
+ About Admin Accounts +

When you create a new admin, they'll receive a magic link via email to sign in. Admins can access the Avo admin panel and manage other admins.

+
+
+
+
diff --git a/app/views/admins/admins/new.html.erb b/app/views/admins/admins/new.html.erb new file mode 100644 index 0000000..6249e32 --- /dev/null +++ b/app/views/admins/admins/new.html.erb @@ -0,0 +1,82 @@ +
+
+ <%= link_to admins_admins_path, class: "inline-flex items-center text-sm text-gray-600 hover:text-gray-900 mb-4" do %> + + + + Back to Admins + <% end %> +

Add New Admin

+

Create a new admin account with access to the admin panel

+
+ +
+ <%= form_with model: @admin, url: admins_admins_path, class: "space-y-6" do |form| %> + <% if @admin.errors.any? %> +
+
+ + + +
+ There <%= @admin.errors.count == 1 ? 'was' : 'were' %> <%= pluralize(@admin.errors.count, "error") %>: +
    + <% @admin.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+
+
+ <% end %> + +
+ <%= form.label :email, "Email Address", class: "block text-sm font-medium text-gray-700 mb-2" %> + <%= form.email_field :email, + required: true, + autofocus: true, + autocomplete: "email", + placeholder: "newadmin@example.com", + class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent #{@admin.errors[:email].any? ? 'border-red-300' : ''}" %> +

+ The new admin will receive a magic link at this email address to sign in. +

+
+ +
+
+ + + +
+ What happens next? +
    +
  • The admin account will be created immediately
  • +
  • A magic link email will be sent to the provided address
  • +
  • The new admin can click the link to sign in
  • +
  • They'll have full admin access including Avo and admin management
  • +
+
+
+
+ +
+ <%= link_to "Cancel", admins_admins_path, class: "px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900" %> + <%= form.submit "Create Admin & Send Magic Link", + class: "px-6 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 font-medium transition-colors" %> +
+ <% end %> +
+ +
+
+ + + +
+ Security Notice +

Only create admin accounts for trusted individuals. Admins have full access to all data and can manage other admins.

+
+
+
+
diff --git a/app/views/admins/sessions/new.html.erb b/app/views/admins/sessions/new.html.erb new file mode 100644 index 0000000..04763ef --- /dev/null +++ b/app/views/admins/sessions/new.html.erb @@ -0,0 +1,53 @@ +
+
+
+
+ + + +
+

Admin Access

+

Sign in with your admin magic link

+
+ +
+ <%= form_with scope: :session, url: admins_session_path, method: :post, class: "space-y-6" do |form| %> +
+ <%= form.label :email, "Admin email address", class: "block text-sm font-medium text-gray-700 mb-2" %> + <%= form.email_field :email, + required: true, + autofocus: true, + autocomplete: "email", + placeholder: "admin@example.com", + class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent" %> +

+ Only existing admins can sign in. Contact another admin if you need access. +

+
+ +
+ <%= form.submit "Send Admin Magic Link", + class: "w-full bg-red-600 text-white py-2 px-4 rounded-lg hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 font-medium transition-colors" %> +
+ <% end %> + +
+

+ Regular user? <%= link_to "Sign in here", new_session_path, class: "text-red-600 hover:text-red-800 font-medium" %> +

+
+
+ +
+
+ + + +
+ Admin Area +

This is a restricted area. All login attempts are logged.

+
+
+
+
+
diff --git a/app/views/home/index.html.erb b/app/views/home/index.html.erb new file mode 100644 index 0000000..9c3b4fd --- /dev/null +++ b/app/views/home/index.html.erb @@ -0,0 +1,80 @@ +
+
+

Welcome, <%= @user.name %>!

+

You're successfully signed in

+
+ +
+
+
+
+ + + +
+

Your Profile

+
+
+
+
Email
+
<%= @user.email %>
+
+
+
Name
+
<%= @user.name %>
+
+
+
Member Since
+
<%= @user.created_at.strftime("%B %d, %Y") %>
+
+
+
+ +
+
+
+ + + +
+

Authentication

+
+
+

+ You're signed in using magic link authentication. No passwords needed! +

+
+
+ + + +
+ Secure & Passwordless +

Every time you sign in, you'll receive a fresh magic link via email.

+
+
+
+
+
+
+ +
+
+ + + +
+

Getting Started

+

+ This is a template application. You can customize this dashboard or add your own features. +

+
    +
  • Check out the code in app/controllers/home_controller.rb
  • +
  • Modify this view in app/views/home/index.html.erb
  • +
  • Add your own models, controllers, and views
  • +
  • See CLAUDE.md for architecture guidelines
  • +
+
+
+
+
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 43d943c..1ccb3cb 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -23,8 +23,39 @@ <%= javascript_importmap_tags %> - -
+ + <% if current_user || current_admin %> + + <% end %> + + <% if flash.any? %> +
+ <% flash.each do |type, message| %> +
+ <%= message %> +
+ <% end %> +
+ <% end %> + +
<%= yield %>
diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb new file mode 100644 index 0000000..c0d2e55 --- /dev/null +++ b/app/views/sessions/new.html.erb @@ -0,0 +1,36 @@ +
+
+
+

Welcome Back

+

Sign in with a magic link sent to your email

+
+ +
+ <%= form_with scope: :session, url: session_path, method: :post, class: "space-y-6" do |form| %> +
+ <%= form.label :email, "Email address", class: "block text-sm font-medium text-gray-700 mb-2" %> + <%= form.email_field :email, + required: true, + autofocus: true, + autocomplete: "email", + placeholder: "you@example.com", + class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent" %> +

+ We'll send you a magic link to sign in. If you don't have an account, one will be created automatically. +

+
+ +
+ <%= form.submit "Send Magic Link", + class: "w-full bg-indigo-600 text-white py-2 px-4 rounded-lg hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 font-medium transition-colors" %> +
+ <% end %> + +
+

+ Admin? <%= link_to "Sign in here", new_admins_session_path, class: "text-indigo-600 hover:text-indigo-800 font-medium" %> +

+
+
+
+
diff --git a/app/views/user_mailer/magic_link.html.erb b/app/views/user_mailer/magic_link.html.erb new file mode 100644 index 0000000..d70043e --- /dev/null +++ b/app/views/user_mailer/magic_link.html.erb @@ -0,0 +1,18 @@ +

Sign in to your account

+ +

Hi <%= @user.name %>,

+ +

Click the button below to sign in to your account:

+ +

+ + Sign In + +

+ +

Or copy and paste this link into your browser:

+

<%= @magic_link_url %>

+ +

This link will expire in 15 minutes.

+ +

If you didn't request this, you can safely ignore this email.

diff --git a/app/views/user_mailer/magic_link.text.erb b/app/views/user_mailer/magic_link.text.erb new file mode 100644 index 0000000..90c6d64 --- /dev/null +++ b/app/views/user_mailer/magic_link.text.erb @@ -0,0 +1,9 @@ +Hi <%= @user.name %>, + +Click the link below to sign in to your account: + +<%= @magic_link_url %> + +This link will expire in 15 minutes. + +If you didn't request this, you can safely ignore this email. diff --git a/bin/configure b/bin/configure new file mode 100755 index 0000000..ee39f6b --- /dev/null +++ b/bin/configure @@ -0,0 +1,163 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "fileutils" +require "io/console" + +APP_ROOT = File.expand_path("..", __dir__) + +def system!(*args) + system(*args, exception: true) +end + +def prompt(question, default = nil) + print question + print " [#{default}]" if default + print ": " + answer = $stdin.gets.chomp + answer.empty? && default ? default : answer +end + +def project_name_from_directory + File.basename(APP_ROOT) +end + +def to_class_name(string) + string.split(/[-_]/).map(&:capitalize).join +end + +def replace_in_file(file_path, old_content, new_content) + return unless File.exist?(file_path) + + content = File.read(file_path) + if content.include?(old_content) + File.write(file_path, content.gsub(old_content, new_content)) + puts " ✓ Updated #{file_path}" + end +end + +FileUtils.chdir APP_ROOT do + puts "═" * 60 + puts " Rails Project Configuration" + puts "═" * 60 + puts + + # Check if already configured + if File.exist?(".configured") + puts "⚠️ This project appears to be already configured." + print "Do you want to reconfigure? (y/N): " + answer = $stdin.gets.chomp + unless answer.downcase == "y" + puts "Exiting..." + exit 0 + end + puts + end + + # Get project name + directory_name = project_name_from_directory + suggested_name = to_class_name(directory_name) + + puts "Current directory: #{directory_name}" + project_name = prompt("Project name (CamelCase)", suggested_name) + project_name = to_class_name(project_name) # Ensure proper format + + puts "\n── Renaming project from Template to #{project_name}... ──\n" + + # Update config/application.rb + replace_in_file( + "config/application.rb", + "module Template", + "module #{project_name}" + ) + + # Update Rakefile + replace_in_file( + "Rakefile", + "Template::Application", + "#{project_name}::Application" + ) + + # Update config/environments/*.rb + %w[development test production].each do |env| + replace_in_file( + "config/environments/#{env}.rb", + "Template::Application", + "#{project_name}::Application" + ) + end + + # Update config.ru + replace_in_file( + "config.ru", + "Template::Application", + "#{project_name}::Application" + ) + + # Update bin/rails + replace_in_file( + "bin/rails", + 'APP_PATH = File.expand_path("../config/application", __dir__)', + "APP_PATH = File.expand_path(\"../config/application\", __dir__)" + ) + + # Update package.json if exists + if File.exist?("package.json") + replace_in_file( + "package.json", + '"name": "template"', + "\"name\": \"#{directory_name}\"" + ) + end + + puts "\n── Configuring first admin... ──\n" + + admin_email = prompt("Admin email address", "admin@example.com") + + # Store in .env for development + env_content = "FIRST_ADMIN_EMAIL=#{admin_email}\n" + File.write(".env", env_content) + puts " ✓ Created .env with admin email" + + # Update seeds file + seeds_content = <<~RUBY + # This file should ensure the existence of records required to run the application in every environment (production, + # development, test). The code here should be idempotent so that it can be executed at any point in every environment. + # The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup). + + # Create first admin + admin_email = ENV["FIRST_ADMIN_EMAIL"] || "admin@example.com" + admin = Admin.find_or_create_by!(email: admin_email) + puts "✓ Admin created: \#{admin.email}" + RUBY + + File.write("db/seeds.rb", seeds_content) + puts " ✓ Updated db/seeds.rb" + + # Mark as configured + File.write(".configured", "Configured on #{Time.now}\nProject: #{project_name}\nAdmin: #{admin_email}\n") + + puts "\n" + "═" * 60 + puts " ✓ Configuration Complete!" + puts "═" * 60 + puts + puts "Project: #{project_name}" + puts "Admin: #{admin_email}" + puts + puts "Running bin/setup to install dependencies and setup database..." + puts + + # Run bin/setup + system!("bin/setup") + + puts "\n" + "═" * 60 + puts " ✓ All Done!" + puts "═" * 60 + puts + puts "Next steps:" + puts " 1. Visit: /admins/session/new" + puts " 2. Request magic link for: #{admin_email}" + puts " 3. Check your email and click the magic link" + puts " 4. Access Avo admin panel at: /avo" + puts +end diff --git a/config/initializers/avo.rb b/config/initializers/avo.rb index c0c74c4..5f6cfe4 100644 --- a/config/initializers/avo.rb +++ b/config/initializers/avo.rb @@ -22,9 +22,10 @@ end ## == Authentication == - # config.current_user_method = :current_user - # config.authenticate_with do - # end + config.current_user_method = :current_admin + config.authenticate_with do + redirect_to new_admins_session_path, alert: "Please log in as admin" unless current_admin + end ## == Authorization == # config.is_admin_method = :is_admin diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb deleted file mode 100644 index 43a862f..0000000 --- a/config/initializers/devise.rb +++ /dev/null @@ -1,313 +0,0 @@ -# frozen_string_literal: true - -# Assuming you have not yet modified this file, each configuration option below -# is set to its default value. Note that some are commented out while others -# are not: uncommented lines are intended to protect your configuration from -# breaking changes in upgrades (i.e., in the event that future versions of -# Devise change the default values for those options). -# -# Use this hook to configure devise mailer, warden hooks and so forth. -# Many of these configuration options can be set straight in your model. -Devise.setup do |config| - # The secret key used by Devise. Devise uses this key to generate - # random tokens. Changing this key will render invalid all existing - # confirmation, reset password and unlock tokens in the database. - # Devise will use the `secret_key_base` as its `secret_key` - # by default. You can change it below and use your own secret key. - # config.secret_key = '7390f07dbfef53536269d11f5c4682810493afeffb8aa5f5c5a3590d602479686ff39a6a67f8420513ad093af87ede0acabe76a4ee30c9945a2a54d96b2f8f4a' - - # ==> Controller configuration - # Configure the parent class to the devise controllers. - # config.parent_controller = 'DeviseController' - - # ==> Mailer Configuration - # Configure the e-mail address which will be shown in Devise::Mailer, - # note that it will be overwritten if you use your own mailer class - # with default "from" parameter. - config.mailer_sender = "please-change-me-at-config-initializers-devise@example.com" - - # Configure the class responsible to send e-mails. - # config.mailer = 'Devise::Mailer' - - # Configure the parent class responsible to send e-mails. - # config.parent_mailer = 'ActionMailer::Base' - - # ==> ORM configuration - # Load and configure the ORM. Supports :active_record (default) and - # :mongoid (bson_ext recommended) by default. Other ORMs may be - # available as additional gems. - require "devise/orm/active_record" - - # ==> Configuration for any authentication mechanism - # Configure which keys are used when authenticating a user. The default is - # just :email. You can configure it to use [:username, :subdomain], so for - # authenticating a user, both parameters are required. Remember that those - # parameters are used only when authenticating and not when retrieving from - # session. If you need permissions, you should implement that in a before filter. - # You can also supply a hash where the value is a boolean determining whether - # or not authentication should be aborted when the value is not present. - # config.authentication_keys = [:email] - - # Configure parameters from the request object used for authentication. Each entry - # given should be a request method and it will automatically be passed to the - # find_for_authentication method and considered in your model lookup. For instance, - # if you set :request_keys to [:subdomain], :subdomain will be used on authentication. - # The same considerations mentioned for authentication_keys also apply to request_keys. - # config.request_keys = [] - - # Configure which authentication keys should be case-insensitive. - # These keys will be downcased upon creating or modifying a user and when used - # to authenticate or find a user. Default is :email. - config.case_insensitive_keys = [ :email ] - - # Configure which authentication keys should have whitespace stripped. - # These keys will have whitespace before and after removed upon creating or - # modifying a user and when used to authenticate or find a user. Default is :email. - config.strip_whitespace_keys = [ :email ] - - # Tell if authentication through request.params is enabled. True by default. - # It can be set to an array that will enable params authentication only for the - # given strategies, for example, `config.params_authenticatable = [:database]` will - # enable it only for database (email + password) authentication. - # config.params_authenticatable = true - - # Tell if authentication through HTTP Auth is enabled. False by default. - # It can be set to an array that will enable http authentication only for the - # given strategies, for example, `config.http_authenticatable = [:database]` will - # enable it only for database authentication. - # For API-only applications to support authentication "out-of-the-box", you will likely want to - # enable this with :database unless you are using a custom strategy. - # The supported strategies are: - # :database = Support basic authentication with authentication key + password - # config.http_authenticatable = false - - # If 401 status code should be returned for AJAX requests. True by default. - # config.http_authenticatable_on_xhr = true - - # The realm used in Http Basic Authentication. 'Application' by default. - # config.http_authentication_realm = 'Application' - - # It will change confirmation, password recovery and other workflows - # to behave the same regardless if the e-mail provided was right or wrong. - # Does not affect registerable. - # config.paranoid = true - - # By default Devise will store the user in session. You can skip storage for - # particular strategies by setting this option. - # Notice that if you are skipping storage for all authentication paths, you - # may want to disable generating routes to Devise's sessions controller by - # passing skip: :sessions to `devise_for` in your config/routes.rb - config.skip_session_storage = [ :http_auth ] - - # By default, Devise cleans up the CSRF token on authentication to - # avoid CSRF token fixation attacks. This means that, when using AJAX - # requests for sign in and sign up, you need to get a new CSRF token - # from the server. You can disable this option at your own risk. - # config.clean_up_csrf_token_on_authentication = true - - # When false, Devise will not attempt to reload routes on eager load. - # This can reduce the time taken to boot the app but if your application - # requires the Devise mappings to be loaded during boot time the application - # won't boot properly. - # config.reload_routes = true - - # ==> Configuration for :database_authenticatable - # For bcrypt, this is the cost for hashing the password and defaults to 12. If - # using other algorithms, it sets how many times you want the password to be hashed. - # The number of stretches used for generating the hashed password are stored - # with the hashed password. This allows you to change the stretches without - # invalidating existing passwords. - # - # Limiting the stretches to just one in testing will increase the performance of - # your test suite dramatically. However, it is STRONGLY RECOMMENDED to not use - # a value less than 10 in other environments. Note that, for bcrypt (the default - # algorithm), the cost increases exponentially with the number of stretches (e.g. - # a value of 20 is already extremely slow: approx. 60 seconds for 1 calculation). - config.stretches = Rails.env.test? ? 1 : 12 - - # Set up a pepper to generate the hashed password. - # config.pepper = '4eeb91854caf43cd8155553e0c017b7ad1fdcf6cd862273a103f02f54f568868ca036bef3897d9933ea1983816056e31e086be202373423cdaa5c44aa8216dd4' - - # Send a notification to the original email when the user's email is changed. - # config.send_email_changed_notification = false - - # Send a notification email when the user's password is changed. - # config.send_password_change_notification = false - - # ==> Configuration for :confirmable - # A period that the user is allowed to access the website even without - # confirming their account. For instance, if set to 2.days, the user will be - # able to access the website for two days without confirming their account, - # access will be blocked just in the third day. - # You can also set it to nil, which will allow the user to access the website - # without confirming their account. - # Default is 0.days, meaning the user cannot access the website without - # confirming their account. - # config.allow_unconfirmed_access_for = 2.days - - # A period that the user is allowed to confirm their account before their - # token becomes invalid. For example, if set to 3.days, the user can confirm - # their account within 3 days after the mail was sent, but on the fourth day - # their account can't be confirmed with the token any more. - # Default is nil, meaning there is no restriction on how long a user can take - # before confirming their account. - # config.confirm_within = 3.days - - # If true, requires any email changes to be confirmed (exactly the same way as - # initial account confirmation) to be applied. Requires additional unconfirmed_email - # db field (see migrations). Until confirmed, new email is stored in - # unconfirmed_email column, and copied to email column on successful confirmation. - config.reconfirmable = true - - # Defines which key will be used when confirming an account - # config.confirmation_keys = [:email] - - # ==> Configuration for :rememberable - # The time the user will be remembered without asking for credentials again. - # config.remember_for = 2.weeks - - # Invalidates all the remember me tokens when the user signs out. - config.expire_all_remember_me_on_sign_out = true - - # If true, extends the user's remember period when remembered via cookie. - # config.extend_remember_period = false - - # Options to be passed to the created cookie. For instance, you can set - # secure: true in order to force SSL only cookies. - # config.rememberable_options = {} - - # ==> Configuration for :validatable - # Range for password length. - config.password_length = 6..128 - - # Email regex used to validate email formats. It simply asserts that - # one (and only one) @ exists in the given string. This is mainly - # to give user feedback and not to assert the e-mail validity. - config.email_regexp = /\A[^@\s]+@[^@\s]+\z/ - - # ==> Configuration for :timeoutable - # The time you want to timeout the user session without activity. After this - # time the user will be asked for credentials again. Default is 30 minutes. - # config.timeout_in = 30.minutes - - # ==> Configuration for :lockable - # Defines which strategy will be used to lock an account. - # :failed_attempts = Locks an account after a number of failed attempts to sign in. - # :none = No lock strategy. You should handle locking by yourself. - # config.lock_strategy = :failed_attempts - - # Defines which key will be used when locking and unlocking an account - # config.unlock_keys = [:email] - - # Defines which strategy will be used to unlock an account. - # :email = Sends an unlock link to the user email - # :time = Re-enables login after a certain amount of time (see :unlock_in below) - # :both = Enables both strategies - # :none = No unlock strategy. You should handle unlocking by yourself. - # config.unlock_strategy = :both - - # Number of authentication tries before locking an account if lock_strategy - # is failed attempts. - # config.maximum_attempts = 20 - - # Time interval to unlock the account if :time is enabled as unlock_strategy. - # config.unlock_in = 1.hour - - # Warn on the last attempt before the account is locked. - # config.last_attempt_warning = true - - # ==> Configuration for :recoverable - # - # Defines which key will be used when recovering the password for an account - # config.reset_password_keys = [:email] - - # Time interval you can reset your password with a reset password key. - # Don't put a too small interval or your users won't have the time to - # change their passwords. - config.reset_password_within = 6.hours - - # When set to false, does not sign a user in automatically after their password is - # reset. Defaults to true, so a user is signed in automatically after a reset. - # config.sign_in_after_reset_password = true - - # ==> Configuration for :encryptable - # Allow you to use another hashing or encryption algorithm besides bcrypt (default). - # You can use :sha1, :sha512 or algorithms from others authentication tools as - # :clearance_sha1, :authlogic_sha512 (then you should set stretches above to 20 - # for default behavior) and :restful_authentication_sha1 (then you should set - # stretches to 10, and copy REST_AUTH_SITE_KEY to pepper). - # - # Require the `devise-encryptable` gem when using anything other than bcrypt - # config.encryptor = :sha512 - - # ==> Scopes configuration - # Turn scoped views on. Before rendering "sessions/new", it will first check for - # "users/sessions/new". It's turned off by default because it's slower if you - # are using only default views. - # config.scoped_views = false - - # Configure the default scope given to Warden. By default it's the first - # devise role declared in your routes (usually :user). - # config.default_scope = :user - - # Set this configuration to false if you want /users/sign_out to sign out - # only the current scope. By default, Devise signs out all scopes. - # config.sign_out_all_scopes = true - - # ==> Navigation configuration - # Lists the formats that should be treated as navigational. Formats like - # :html should redirect to the sign in page when the user does not have - # access, but formats like :xml or :json, should return 401. - # - # If you have any extra navigational formats, like :iphone or :mobile, you - # should add them to the navigational formats lists. - # - # The "*/*" below is required to match Internet Explorer requests. - config.navigational_formats = [ "*/*", :html, :turbo_stream ] - - # The default HTTP method used to sign out a resource. Default is :delete. - config.sign_out_via = :delete - - # ==> OmniAuth - # Add a new OmniAuth provider. Check the wiki for more information on setting - # up on your models and hooks. - # config.omniauth :github, 'APP_ID', 'APP_SECRET', scope: 'user,public_repo' - - # ==> Warden configuration - # If you want to use other strategies, that are not supported by Devise, or - # change the failure app, you can configure them inside the config.warden block. - # - # config.warden do |manager| - # manager.intercept_401 = false - # manager.default_strategies(scope: :user).unshift :some_external_strategy - # end - - # ==> Mountable engine configurations - # When using Devise inside an engine, let's call it `MyEngine`, and this engine - # is mountable, there are some extra configurations to be taken into account. - # The following options are available, assuming the engine is mounted as: - # - # mount MyEngine, at: '/my_engine' - # - # The router that invoked `devise_for`, in the example above, would be: - # config.router_name = :my_engine - # - # When using OmniAuth, Devise cannot automatically set OmniAuth path, - # so you need to do it manually. For the users scope, it would be: - # config.omniauth_path_prefix = '/my_engine/users/auth' - - # ==> Hotwire/Turbo configuration - # When using Devise with Hotwire/Turbo, the http status for error responses - # and some redirects must match the following. The default in Devise for existing - # apps is `200 OK` and `302 Found` respectively, but new apps are generated with - # these new defaults that match Hotwire/Turbo behavior. - # Note: These might become the new default in future versions of Devise. - config.responder.error_status = :unprocessable_entity - config.responder.redirect_status = :see_other - - # ==> Configuration for :registerable - - # When set to false, does not sign a user in automatically after their password is - # changed. Defaults to true, so a user is signed in automatically after changing a password. - # config.sign_in_after_change_password = true -end diff --git a/config/locales/devise.en.yml b/config/locales/devise.en.yml deleted file mode 100644 index 260e1c4..0000000 --- a/config/locales/devise.en.yml +++ /dev/null @@ -1,65 +0,0 @@ -# Additional translations at https://github.com/heartcombo/devise/wiki/I18n - -en: - devise: - confirmations: - confirmed: "Your email address has been successfully confirmed." - send_instructions: "You will receive an email with instructions for how to confirm your email address in a few minutes." - send_paranoid_instructions: "If your email address exists in our database, you will receive an email with instructions for how to confirm your email address in a few minutes." - failure: - already_authenticated: "You are already signed in." - inactive: "Your account is not activated yet." - invalid: "Invalid %{authentication_keys} or password." - locked: "Your account is locked." - last_attempt: "You have one more attempt before your account is locked." - not_found_in_database: "Invalid %{authentication_keys} or password." - timeout: "Your session expired. Please sign in again to continue." - unauthenticated: "You need to sign in or sign up before continuing." - unconfirmed: "You have to confirm your email address before continuing." - mailer: - confirmation_instructions: - subject: "Confirmation instructions" - reset_password_instructions: - subject: "Reset password instructions" - unlock_instructions: - subject: "Unlock instructions" - email_changed: - subject: "Email Changed" - password_change: - subject: "Password Changed" - omniauth_callbacks: - failure: "Could not authenticate you from %{kind} because \"%{reason}\"." - success: "Successfully authenticated from %{kind} account." - passwords: - no_token: "You can't access this page without coming from a password reset email. If you do come from a password reset email, please make sure you used the full URL provided." - send_instructions: "You will receive an email with instructions on how to reset your password in a few minutes." - send_paranoid_instructions: "If your email address exists in our database, you will receive a password recovery link at your email address in a few minutes." - updated: "Your password has been changed successfully. You are now signed in." - updated_not_active: "Your password has been changed successfully." - registrations: - destroyed: "Bye! Your account has been successfully cancelled. We hope to see you again soon." - signed_up: "Welcome! You have signed up successfully." - signed_up_but_inactive: "You have signed up successfully. However, we could not sign you in because your account is not yet activated." - signed_up_but_locked: "You have signed up successfully. However, we could not sign you in because your account is locked." - signed_up_but_unconfirmed: "A message with a confirmation link has been sent to your email address. Please follow the link to activate your account." - update_needs_confirmation: "You updated your account successfully, but we need to verify your new email address. Please check your email and follow the confirmation link to confirm your new email address." - updated: "Your account has been updated successfully." - updated_but_not_signed_in: "Your account has been updated successfully, but since your password was changed, you need to sign in again." - sessions: - signed_in: "Signed in successfully." - signed_out: "Signed out successfully." - already_signed_out: "Signed out successfully." - unlocks: - send_instructions: "You will receive an email with instructions for how to unlock your account in a few minutes." - send_paranoid_instructions: "If your account exists, you will receive an email with instructions for how to unlock it in a few minutes." - unlocked: "Your account has been unlocked successfully. Please sign in to continue." - errors: - messages: - already_confirmed: "was already confirmed, please try signing in" - confirmation_period_expired: "needs to be confirmed within %{period}, please request a new one" - expired: "has expired, please request a new one" - not_found: "not found" - not_locked: "was not locked" - not_saved: - one: "1 error prohibited this %{resource} from being saved:" - other: "%{count} errors prohibited this %{resource} from being saved:" diff --git a/config/routes.rb b/config/routes.rb index 57b227e..f528386 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,10 +1,21 @@ Rails.application.routes.draw do - devise_for :users - devise_for :admins - authenticate :admin do - mount Avo:: Engine, at: Avo.configuration.root_path + # User dashboard + get "home", to: "home#index", as: :home + + # User authentication (magic link - creates user on first use) + resource :session, only: [ :new, :create, :destroy ] + get "auth/:token", to: "sessions#verify", as: :verify_magic_link + + # Admin authentication (magic link) + namespace :admins do + resource :session, only: [ :new, :create, :destroy ] + get "auth/:token", to: "sessions#verify", as: :verify_magic_link + resources :admins, only: [ :index, :new, :create, :destroy ] end + # Avo admin panel (requires admin authentication) + mount Avo::Engine, at: Avo.configuration.root_path + # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. @@ -16,5 +27,5 @@ # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker # Defines the root path route ("/") - # root "posts#index" + root "sessions#new" end diff --git a/db/migrate/20241207165010_devise_create_users.rb b/db/migrate/20241207165010_devise_create_users.rb deleted file mode 100644 index e29ca19..0000000 --- a/db/migrate/20241207165010_devise_create_users.rb +++ /dev/null @@ -1,47 +0,0 @@ -# frozen_string_literal: true - -class DeviseCreateUsers < ActiveRecord::Migration[8.1] - def change - create_table :users, force: true, id: false do |t| - t.primary_key :id, :string, default: -> { "ULID()" } - - ## Database authenticatable - t.string :email, null: false, default: "" - t.string :encrypted_password, null: false, default: "" - - ## Recoverable - t.string :reset_password_token - t.datetime :reset_password_sent_at - - ## Rememberable - t.datetime :remember_created_at - - ## Trackable - # t.integer :sign_in_count, default: 0, null: false - # t.datetime :current_sign_in_at - # t.datetime :last_sign_in_at - # t.string :current_sign_in_ip - # t.string :last_sign_in_ip - - ## Confirmable - # t.string :confirmation_token - # t.datetime :confirmed_at - # t.datetime :confirmation_sent_at - # t.string :unconfirmed_email # Only if using reconfirmable - - ## Lockable - # t.integer :failed_attempts, default: 0, null: false # Only if lock strategy is :failed_attempts - # t.string :unlock_token # Only if unlock strategy is :email or :both - # t.datetime :locked_at - - t.string :name - - t.timestamps null: false - end - - add_index :users, :email, unique: true - add_index :users, :reset_password_token, unique: true - # add_index :users, :confirmation_token, unique: true - # add_index :users, :unlock_token, unique: true - end -end diff --git a/db/migrate/20241207205829_devise_create_admins.rb b/db/migrate/20241207205829_devise_create_admins.rb deleted file mode 100644 index 0d2ed82..0000000 --- a/db/migrate/20241207205829_devise_create_admins.rb +++ /dev/null @@ -1,46 +0,0 @@ -# frozen_string_literal: true - -class DeviseCreateAdmins < ActiveRecord::Migration[8.1] - def change - create_table :admins, force: true, id: false do |t| - t.primary_key :id, :string, default: -> { "ULID()" } - - ## Database authenticatable - t.string :email, null: false, default: "" - t.string :encrypted_password, null: false, default: "" - - ## Recoverable - t.string :reset_password_token - t.datetime :reset_password_sent_at - - ## Rememberable - t.datetime :remember_created_at - - ## Trackable - # t.integer :sign_in_count, default: 0, null: false - # t.datetime :current_sign_in_at - # t.datetime :last_sign_in_at - # t.string :current_sign_in_ip - # t.string :last_sign_in_ip - - ## Confirmable - # t.string :confirmation_token - # t.datetime :confirmed_at - # t.datetime :confirmation_sent_at - # t.string :unconfirmed_email # Only if using reconfirmable - - ## Lockable - # t.integer :failed_attempts, default: 0, null: false # Only if lock strategy is :failed_attempts - # t.string :unlock_token # Only if unlock strategy is :email or :both - # t.datetime :locked_at - - - t.timestamps null: false - end - - add_index :admins, :email, unique: true - add_index :admins, :reset_password_token, unique: true - # add_index :admins, :confirmation_token, unique: true - # add_index :admins, :unlock_token, unique: true - end -end diff --git a/db/migrate/20260109173041_create_users.rb b/db/migrate/20260109173041_create_users.rb new file mode 100644 index 0000000..7075353 --- /dev/null +++ b/db/migrate/20260109173041_create_users.rb @@ -0,0 +1,12 @@ +class CreateUsers < ActiveRecord::Migration[8.2] + def change + create_table :users, force: true, id: false do |t| + t.primary_key :id, :string, default: -> { "ULID()" } + t.string :email + t.string :name + + t.timestamps + end + add_index :users, :email, unique: true + end +end diff --git a/db/migrate/20260109173042_create_admins.rb b/db/migrate/20260109173042_create_admins.rb new file mode 100644 index 0000000..b5000d1 --- /dev/null +++ b/db/migrate/20260109173042_create_admins.rb @@ -0,0 +1,11 @@ +class CreateAdmins < ActiveRecord::Migration[8.2] + def change + create_table :admins, force: true, id: false do |t| + t.primary_key :id, :string, default: -> { "ULID()" } + t.string :email + + t.timestamps + end + add_index :admins, :email, unique: true + end +end diff --git a/db/seeds.rb b/db/seeds.rb index 4fbd6ed..3574b6f 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,9 +1,8 @@ # This file should ensure the existence of records required to run the application in every environment (production, # development, test). The code here should be idempotent so that it can be executed at any point in every environment. # The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup). -# -# Example: -# -# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name| -# MovieGenre.find_or_create_by!(name: genre_name) -# end + +# Create first admin +admin_email = ENV["FIRST_ADMIN_EMAIL"] || "admin@example.com" +admin = Admin.find_or_create_by!(email: admin_email) +puts "✓ Admin created: #{admin.email}" diff --git a/test/controllers/admins/admins_controller_test.rb b/test/controllers/admins/admins_controller_test.rb new file mode 100644 index 0000000..693d0d0 --- /dev/null +++ b/test/controllers/admins/admins_controller_test.rb @@ -0,0 +1,23 @@ +require "test_helper" + +class Admins::AdminsControllerTest < ActionDispatch::IntegrationTest + test "should get index" do + get admins_admins_index_url + assert_response :success + end + + test "should get new" do + get admins_admins_new_url + assert_response :success + end + + test "should get create" do + get admins_admins_create_url + assert_response :success + end + + test "should get destroy" do + get admins_admins_destroy_url + assert_response :success + end +end diff --git a/test/controllers/admins/sessions_controller_test.rb b/test/controllers/admins/sessions_controller_test.rb new file mode 100644 index 0000000..7c53a23 --- /dev/null +++ b/test/controllers/admins/sessions_controller_test.rb @@ -0,0 +1,13 @@ +require "test_helper" + +class Admins::SessionsControllerTest < ActionDispatch::IntegrationTest + test "should get new" do + get admins_sessions_new_url + assert_response :success + end + + test "should get create" do + get admins_sessions_create_url + assert_response :success + end +end diff --git a/test/controllers/home_controller_test.rb b/test/controllers/home_controller_test.rb new file mode 100644 index 0000000..f6f3785 --- /dev/null +++ b/test/controllers/home_controller_test.rb @@ -0,0 +1,8 @@ +require "test_helper" + +class HomeControllerTest < ActionDispatch::IntegrationTest + test "should get index" do + get home_index_url + assert_response :success + end +end diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb new file mode 100644 index 0000000..a78e068 --- /dev/null +++ b/test/controllers/sessions_controller_test.rb @@ -0,0 +1,13 @@ +require "test_helper" + +class SessionsControllerTest < ActionDispatch::IntegrationTest + test "should get new" do + get sessions_new_url + assert_response :success + end + + test "should get create" do + get sessions_create_url + assert_response :success + end +end diff --git a/test/fixtures/admins.yml b/test/fixtures/admins.yml index d7a3329..899b74d 100644 --- a/test/fixtures/admins.yml +++ b/test/fixtures/admins.yml @@ -1,11 +1,7 @@ # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html -# This model initially had no columns defined. If you add columns to the -# model remove the "{}" from the fixture names and add the columns immediately -# below each fixture, per the syntax in the comments below -# -one: {} -# column: value -# -two: {} -# column: value +one: + email: admin1@example.com + +two: + email: admin2@example.com diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index d7a3329..d0bec1c 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -1,11 +1,9 @@ # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html -# This model initially had no columns defined. If you add columns to the -# model remove the "{}" from the fixture names and add the columns immediately -# below each fixture, per the syntax in the comments below -# -one: {} -# column: value -# -two: {} -# column: value +one: + email: user1@example.com + name: User One + +two: + email: user2@example.com + name: User Two diff --git a/test/mailers/admin_mailer_test.rb b/test/mailers/admin_mailer_test.rb new file mode 100644 index 0000000..baf0a76 --- /dev/null +++ b/test/mailers/admin_mailer_test.rb @@ -0,0 +1,11 @@ +require "test_helper" + +class AdminMailerTest < ActionMailer::TestCase + test "magic_link" do + mail = AdminMailer.magic_link + assert_equal "Magic link", mail.subject + assert_equal [ "to@example.org" ], mail.to + assert_equal [ "from@example.com" ], mail.from + assert_match "Hi", mail.body.encoded + end +end diff --git a/test/mailers/previews/admin_mailer_preview.rb b/test/mailers/previews/admin_mailer_preview.rb new file mode 100644 index 0000000..ab52930 --- /dev/null +++ b/test/mailers/previews/admin_mailer_preview.rb @@ -0,0 +1,7 @@ +# Preview all emails at http://localhost:3000/rails/mailers/admin_mailer +class AdminMailerPreview < ActionMailer::Preview + # Preview this email at http://localhost:3000/rails/mailers/admin_mailer/magic_link + def magic_link + AdminMailer.magic_link + end +end diff --git a/test/mailers/previews/user_mailer_preview.rb b/test/mailers/previews/user_mailer_preview.rb new file mode 100644 index 0000000..4521d89 --- /dev/null +++ b/test/mailers/previews/user_mailer_preview.rb @@ -0,0 +1,7 @@ +# Preview all emails at http://localhost:3000/rails/mailers/user_mailer +class UserMailerPreview < ActionMailer::Preview + # Preview this email at http://localhost:3000/rails/mailers/user_mailer/magic_link + def magic_link + UserMailer.magic_link + end +end diff --git a/test/mailers/user_mailer_test.rb b/test/mailers/user_mailer_test.rb new file mode 100644 index 0000000..43904a2 --- /dev/null +++ b/test/mailers/user_mailer_test.rb @@ -0,0 +1,11 @@ +require "test_helper" + +class UserMailerTest < ActionMailer::TestCase + test "magic_link" do + mail = UserMailer.magic_link + assert_equal "Magic link", mail.subject + assert_equal [ "to@example.org" ], mail.to + assert_equal [ "from@example.com" ], mail.from + assert_match "Hi", mail.body.encoded + end +end From 84a0a1add9060050bfae6b7b047e1dfb53ba51e7 Mon Sep 17 00:00:00 2001 From: Yuri Sidorov <403994+newstler@users.noreply.github.com> Date: Fri, 9 Jan 2026 19:55:22 +0100 Subject: [PATCH 003/106] Removed dotenv and added litestream --- .gitignore | 3 -- Gemfile | 5 ++- Gemfile.lock | 18 +++++++- bin/configure | 71 ++++++++++++++++++++++++++----- config/initializers/litestream.rb | 55 ++++++++++++++++++++++++ config/litestream.yml | 44 +++++++++++++++++++ db/seeds.rb | 2 +- 7 files changed, 180 insertions(+), 18 deletions(-) create mode 100644 config/initializers/litestream.rb create mode 100644 config/litestream.yml diff --git a/.gitignore b/.gitignore index e9aeb5e..796ac93 100644 --- a/.gitignore +++ b/.gitignore @@ -7,9 +7,6 @@ # Ignore bundler config. /.bundle -# Ignore all environment files. -/.env* - # Ignore all logfiles and tempfiles. /log/* /tmp/* diff --git a/Gemfile b/Gemfile index d73dd32..ba75f72 100644 --- a/Gemfile +++ b/Gemfile @@ -37,6 +37,9 @@ gem "kamal", require: false # Add HTTP asset caching/compression and X-Sendfile acceleration to Puma [https://github.com/basecamp/thruster/] gem "thruster", require: false +# Litestream for SQLite replication [https://github.com/fractaledmind/litestream-ruby] +gem "litestream" + # Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] # gem "image_processing", "~> 1.2" @@ -49,8 +52,6 @@ group :development, :test do # Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/] gem "rubocop-rails-omakase", require: false - - gem "dotenv" end group :development do diff --git a/Gemfile.lock b/Gemfile.lock index 748aca0..75cab18 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -197,6 +197,20 @@ GEM zeitwerk (>= 2.6.18, < 3.0) language_server-protocol (3.17.0.5) lint_roller (1.1.0) + litestream (0.14.0-arm64-darwin) + actionpack (>= 7.0) + actionview (>= 7.0) + activejob (>= 7.0) + activesupport (>= 7.0) + railties (>= 7.0) + sqlite3 + litestream (0.14.0-x86_64-linux) + actionpack (>= 7.0) + actionview (>= 7.0) + activejob (>= 7.0) + activesupport (>= 7.0) + railties (>= 7.0) + sqlite3 logger (1.7.0) loofah (2.25.0) crass (~> 1.0.2) @@ -406,10 +420,10 @@ DEPENDENCIES brakeman capybara debug - dotenv importmap-rails jbuilder kamal + litestream propshaft puma (>= 5.0) rails! @@ -476,6 +490,8 @@ CHECKSUMS kamal (2.10.1) sha256=53b7ecb4c33dd83b1aedfc7aacd1c059f835993258a552d70d584c6ce32b6340 language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 + litestream (0.14.0-arm64-darwin) sha256=507bbb7ee99b3398304c5ef4a9bae835761359ffc72850f25708477805313d07 + litestream (0.14.0-x86_64-linux) sha256=2844734b6d8e5c6009baf8d138d6f18367f770e9e4390fb70763433db587bed6 logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 loofah (2.25.0) sha256=df5ed7ac3bac6a4ec802df3877ee5cc86d027299f8952e6243b3dac446b060e6 mail (2.9.0) sha256=6fa6673ecd71c60c2d996260f9ee3dd387d4673b8169b502134659ece6d34941 diff --git a/bin/configure b/bin/configure index ee39f6b..9905d40 100755 --- a/bin/configure +++ b/bin/configure @@ -3,6 +3,8 @@ require "fileutils" require "io/console" +require "yaml" +require "securerandom" APP_ROOT = File.expand_path("..", __dir__) @@ -114,20 +116,31 @@ FileUtils.chdir APP_ROOT do admin_email = prompt("Admin email address", "admin@example.com") - # Store in .env for development - env_content = "FIRST_ADMIN_EMAIL=#{admin_email}\n" - File.write(".env", env_content) - puts " ✓ Created .env with admin email" + puts "\n── Configuring Litestream (optional)... ──\n" + puts "Litestream provides SQLite replication to S3-compatible storage." + puts "Press Enter to skip if you don't need it yet.\n" - # Update seeds file + replica_bucket = prompt("S3 bucket name (e.g., my-app-backups)") + replica_key_id = prompt("S3 Access Key ID") unless replica_bucket.empty? + replica_access_key = prompt("S3 Secret Access Key") unless replica_bucket.empty? + + # Store config temporarily + config_data = { + "admin_email" => admin_email, + "replica_bucket" => replica_bucket, + "replica_key_id" => replica_key_id, + "replica_access_key" => replica_access_key + } + File.write(".configure_temp.yml", YAML.dump(config_data)) + + # Update seeds file with admin email seeds_content = <<~RUBY # This file should ensure the existence of records required to run the application in every environment (production, # development, test). The code here should be idempotent so that it can be executed at any point in every environment. # The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup). # Create first admin - admin_email = ENV["FIRST_ADMIN_EMAIL"] || "admin@example.com" - admin = Admin.find_or_create_by!(email: admin_email) + admin = Admin.find_or_create_by!(email: "#{admin_email}") puts "✓ Admin created: \#{admin.email}" RUBY @@ -150,14 +163,50 @@ FileUtils.chdir APP_ROOT do # Run bin/setup system!("bin/setup") + puts "\n── Writing credentials... ──\n" + + # Load config data + config_data = YAML.load_file(".configure_temp.yml") + + # Build credentials YAML content (only if Litestream is configured) + if !config_data["replica_bucket"].to_s.empty? + credentials_yaml = { + "secret_key_base" => SecureRandom.hex(64), + "litestream" => { + "replica_bucket" => config_data["replica_bucket"], + "replica_key_id" => config_data["replica_key_id"], + "replica_access_key" => config_data["replica_access_key"] + } + } + + # Write to development credentials using Rails runner + credentials_content = YAML.dump(credentials_yaml) + File.write(".credentials_temp.yml", credentials_content) + + system!('bin/rails runner " + content = File.read(\".credentials_temp.yml\") + Rails.application.credentials.config.write(content) + puts \" ✓ Development credentials updated\" + "') + + # Clean up temp files + File.delete(".credentials_temp.yml") if File.exist?(".credentials_temp.yml") + else + puts " ⊝ No credentials to write (Litestream not configured)" + end + + # Clean up temp files + File.delete(".configure_temp.yml") if File.exist?(".configure_temp.yml") + puts "\n" + "═" * 60 puts " ✓ All Done!" puts "═" * 60 puts puts "Next steps:" - puts " 1. Visit: /admins/session/new" - puts " 2. Request magic link for: #{admin_email}" - puts " 3. Check your email and click the magic link" - puts " 4. Access Avo admin panel at: /avo" + puts " 1. Run: bin/dev" + puts " 2. Visit: /admins/session/new" + puts " 3. Request magic link for: #{admin_email}" + puts " 4. Check your email and click the magic link" + puts " 5. Access Avo admin panel at: /avo" puts end diff --git a/config/initializers/litestream.rb b/config/initializers/litestream.rb new file mode 100644 index 0000000..ddba8c2 --- /dev/null +++ b/config/initializers/litestream.rb @@ -0,0 +1,55 @@ +# Use this hook to configure the litestream-ruby gem. +# Litestream provides SQLite replication to S3-compatible storage. +# +# Configuration is stored in Rails encrypted credentials. +# To view/edit: rails credentials:edit --environment development +# +# Required structure: +# litestream: +# replica_bucket: your-bucket-name +# replica_key_id: YOUR_AWS_KEY_ID +# replica_access_key: YOUR_AWS_SECRET_KEY + +Rails.application.configure do + # Configure Litestream through Rails encrypted credentials + litestream_credentials = Rails.application.credentials.litestream + + config.litestream.replica_bucket = litestream_credentials&.replica_bucket + config.litestream.replica_key_id = litestream_credentials&.replica_key_id + config.litestream.replica_access_key = litestream_credentials&.replica_access_key + + # Replica-specific bucket location. This will be your bucket's URL without the `https://` prefix. + # For example, if you used DigitalOcean Spaces, your bucket URL could look like: + # + # https://myapp.fra1.digitaloceanspaces.com + # + # And so you should set your `replica_bucket` to: + # + # myapp.fra1.digitaloceanspaces.com + # + # config.litestream.replica_bucket = litestream_credentials&.replica_bucket + # + # Replica-specific authentication key. Litestream needs authentication credentials to access your storage provider bucket. + # config.litestream.replica_key_id = litestream_credentials&.replica_key_id + # + # Replica-specific secret key. Litestream needs authentication credentials to access your storage provider bucket. + # config.litestream.replica_access_key = litestream_credentials&.replica_access_key + # + # Replica-specific region. Set the bucket’s region. Only used for AWS S3 & Backblaze B2. + # config.litestream.replica_region = "us-east-1" + # + # Replica-specific endpoint. Set the endpoint URL of the S3-compatible service. Only required for non-AWS services. + # config.litestream.replica_endpoint = "endpoint.your-objectstorage.com" + + # Configure the default Litestream config path + # config.config_path = Rails.root.join("config", "litestream.yml") + + # Configure the Litestream dashboard + # + # Set the default base controller class + # config.litestream.base_controller_class = "MyApplicationController" + # + # Set authentication credentials for Litestream dashboard + # config.litestream.username = litestream_credentials&.username + # config.litestream.password = litestream_credentials&.password +end diff --git a/config/litestream.yml b/config/litestream.yml new file mode 100644 index 0000000..29048fd --- /dev/null +++ b/config/litestream.yml @@ -0,0 +1,44 @@ +# This is the actual configuration file for litestream. +# +# You can either use the generated `config/initializers/litestream.rb` +# file to configure the litestream-ruby gem, which will populate these +# ENV variables when using the `rails litestream:replicate` command. +# +# Or, if you prefer, manually manage ENV variables and this configuration file. +# In that case, simply ensure that the ENV variables are set before running the +# `replicate` command. +# +# For more details, see: https://litestream.io/reference/config/ +dbs: + - path: storage/production.sqlite3 + replicas: + - type: s3 + bucket: $LITESTREAM_REPLICA_BUCKET + path: storage/production.sqlite3 + access-key-id: $LITESTREAM_ACCESS_KEY_ID + secret-access-key: $LITESTREAM_SECRET_ACCESS_KEY + sync-interval: 1m + - path: storage/production_cache.sqlite3 + replicas: + - type: s3 + bucket: $LITESTREAM_REPLICA_BUCKET + path: storage/production_cache.sqlite3 + access-key-id: $LITESTREAM_ACCESS_KEY_ID + secret-access-key: $LITESTREAM_SECRET_ACCESS_KEY + sync-interval: 1m + - path: storage/production_queue.sqlite3 + replicas: + - type: s3 + bucket: $LITESTREAM_REPLICA_BUCKET + path: storage/production_queue.sqlite3 + access-key-id: $LITESTREAM_ACCESS_KEY_ID + secret-access-key: $LITESTREAM_SECRET_ACCESS_KEY + sync-interval: 1m + - path: storage/production_cable.sqlite3 + replicas: + - type: s3 + bucket: $LITESTREAM_REPLICA_BUCKET + path: storage/production_cable.sqlite3 + access-key-id: $LITESTREAM_ACCESS_KEY_ID + secret-access-key: $LITESTREAM_SECRET_ACCESS_KEY + sync-interval: 1m diff --git a/db/seeds.rb b/db/seeds.rb index 3574b6f..3196d4d 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -3,6 +3,6 @@ # The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup). # Create first admin -admin_email = ENV["FIRST_ADMIN_EMAIL"] || "admin@example.com" +admin_email = "admin@example.com" admin = Admin.find_or_create_by!(email: admin_email) puts "✓ Admin created: #{admin.email}" From b5ffb9d0747ddb6b5880e4023f5ad2a12bedf179 Mon Sep 17 00:00:00 2001 From: Yuri Sidorov <403994+newstler@users.noreply.github.com> Date: Fri, 9 Jan 2026 23:33:42 +0100 Subject: [PATCH 004/106] Update documentation --- CLAUDE.md | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- README.md | 58 ++++++++++++++++++++++++++++++++++------------- 2 files changed, 107 insertions(+), 18 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5286f08..72e36dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,8 +14,8 @@ When starting a new project from this template: 2. Run configuration: `bin/configure` 3. The script will: - Rename project from "Template" to your project name - - Ask for admin email - - Create `.env` file + - Ask for admin email (written directly to db/seeds.rb) + - Optionally configure Litestream for SQLite replication - Run `bin/setup` to install dependencies and setup database ### Tech Stack @@ -25,6 +25,7 @@ When starting a new project from this template: - **Database**: SQLite with Solid Stack - **Background Jobs**: Solid Queue - **Caching**: Solid Cache +- **Replication**: Litestream (SQLite → S3-compatible storage) - **Frontend**: Hotwire (Turbo + Stimulus), Tailwind CSS 4 - **Asset Pipeline**: Propshaft - **Deployment**: Kamal 2 @@ -225,12 +226,25 @@ session[:user_id] = user.id ## Credentials +This project uses Rails encrypted credentials exclusively. No environment variables are used. + ```bash +# Edit credentials rails credentials:edit --environment development +rails credentials:edit --environment production ``` ```yaml # Example structure +secret_key_base: + +# Litestream (optional, configured via bin/configure) +litestream: + replica_bucket: my-app-backups + replica_key_id: AKIAIOSFODNN7EXAMPLE + replica_access_key: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + +# Other services stripe: secret_key: sk_test_... webhook_secret: whsec_... @@ -242,6 +256,55 @@ anthropic: api_key: sk-ant-... ``` +## Litestream - SQLite Replication + +Litestream provides continuous replication of SQLite databases to S3-compatible storage (AWS S3, DigitalOcean Spaces, Backblaze B2, etc.). + +### Configuration + +**During initial setup:** +Run `bin/configure` and provide S3 credentials when prompted. This will: +- Write Litestream config to Rails encrypted credentials +- Configure `config/initializers/litestream.rb` to read from credentials + +**Manual configuration:** +```bash +rails credentials:edit --environment production +``` + +Add: +```yaml +litestream: + replica_bucket: your-bucket-name # Without https:// prefix + replica_key_id: YOUR_AWS_KEY_ID + replica_access_key: YOUR_AWS_SECRET_KEY +``` + +### What Gets Replicated + +Litestream replicates all Solid Stack databases (see `config/litestream.yml`): +- `storage/production.sqlite3` (main database) +- `storage/production_cache.sqlite3` (Solid Cache) +- `storage/production_queue.sqlite3` (Solid Queue) +- `storage/production_cable.sqlite3` (Solid Cable) + +### Commands + +```bash +# Start replication (in production) +rails litestream:replicate + +# Restore from backup +rails litestream:restore + +# View configuration +cat config/litestream.yml +``` + +### Production Usage + +In production, Litestream typically runs alongside your Rails app. With Kamal 2, you can run it as a sidecar container or separate process. + ## Deployment Kamal 2 deployment: diff --git a/README.md b/README.md index e0861d5..84f5c3f 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ A modern Rails 8 template following 37signals' vanilla Rails philosophy with bui - **Ruby** 4.0.x - **Rails** 8.2.x - **Database**: SQLite with Solid Stack (Cache, Queue, Cable) +- **Replication**: Litestream (SQLite → S3-compatible storage) - **Frontend**: Hotwire (Turbo + Stimulus), Tailwind CSS 4 - **Asset Pipeline**: Propshaft - **Deployment**: Kamal 2 @@ -19,6 +20,9 @@ A modern Rails 8 template following 37signals' vanilla Rails philosophy with bui - **Magic Link Authentication** for users and admins - Users: First magic link creates account, subsequent ones sign in - Admins: Only existing admins can create new admins +- **Litestream SQLite Replication** to S3-compatible storage (optional) + - Continuous backup of all databases (main, cache, queue, cable) + - Configured via `bin/configure` or Rails credentials - **ULID Primary Keys** for better distributed system support - **Solid Stack** for production-ready background jobs, caching, and cable - **Vanilla Rails** approach - no unnecessary abstractions @@ -42,15 +46,15 @@ bin/configure # This will prompt you for: # - Project name (defaults to folder name) # - Admin email address +# - Litestream S3 credentials (optional) # Then it will automatically run bin/setup ``` The `bin/configure` script will: 1. Rename the project from "Template" to your project name -2. Ask for your admin email -3. Create `.env` file with admin email -4. Update `db/seeds.rb` to use the email -5. Run `bin/setup` to install dependencies and setup database +2. Ask for your admin email and write it directly to `db/seeds.rb` +3. Optionally configure Litestream for SQLite replication to S3 +4. Run `bin/setup` to install dependencies and setup database ### Pull Template Updates @@ -116,16 +120,6 @@ bundle exec rubocop -A bin/configure ``` -### Environment Variables - -The project uses `.env` for development configuration: - -```bash -FIRST_ADMIN_EMAIL=admin@example.com -``` - -This is automatically created by `bin/configure`. - ## Architecture Principles This template follows [37signals vanilla Rails philosophy](https://dev.37signals.com/) and patterns from [Layered Design for Ruby on Rails Applications](https://www.packtpub.com/product/layered-design-for-ruby-on-rails-applications/9781801813785): @@ -168,7 +162,9 @@ kamal deploy See `config/deploy.yml` for configuration. -## Credentials +## Configuration & Credentials + +This project uses Rails encrypted credentials exclusively. No environment variables are used. ```bash # Edit credentials @@ -179,12 +175,42 @@ rails credentials:edit --environment production Example structure: ```yaml -# Stripe, OpenAI, Anthropic, etc. +secret_key_base: + +# Litestream (optional, configured via bin/configure) +litestream: + replica_bucket: my-app-backups + replica_key_id: AKIAIOSFODNN7EXAMPLE + replica_access_key: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + +# Other services (Stripe, OpenAI, Anthropic, etc.) stripe: secret_key: sk_test_... webhook_secret: whsec_... ``` +### Litestream - SQLite Replication + +Litestream provides continuous replication of all SQLite databases to S3-compatible storage: + +**Setup options:** +1. During `bin/configure` (easiest) +2. Manually edit credentials: `rails credentials:edit --environment production` + +**What gets replicated:** +- Main database +- Solid Cache +- Solid Queue +- Solid Cable + +**Commands:** +```bash +rails litestream:replicate # Start replication +rails litestream:restore # Restore from backup +``` + +See `config/litestream.yml` for full configuration and [CLAUDE.md](./CLAUDE.md) for details. + ## License MIT From 574cfe97d4ba51e273934379d18d833afb8670fc Mon Sep 17 00:00:00 2001 From: Yuri Sidorov <403994+newstler@users.noreply.github.com> Date: Sat, 10 Jan 2026 17:57:16 +0100 Subject: [PATCH 005/106] Add RubyLLM, fix Avo --- .gitignore | 8 + CLAUDE.md | 194 ++++++++++++++---- Gemfile | 4 + Gemfile.lock | 86 ++++++++ app/avo/actions/refresh_models.rb | 12 ++ app/avo/actions/send_admin_magic_link.rb | 13 ++ app/avo/filters/created_at_filter.rb | 15 ++ app/avo/filters/provider_filter.rb | 16 ++ app/avo/filters/role_filter.rb | 17 ++ app/avo/resources/admin.rb | 25 ++- app/avo/resources/chat.rb | 24 +++ app/avo/resources/message.rb | 36 ++++ app/avo/resources/model.rb | 39 ++++ app/avo/resources/tool_call.rb | 25 +++ app/avo/resources/user.rb | 30 ++- app/controllers/admins/admins_controller.rb | 35 ---- app/controllers/admins/sessions_controller.rb | 5 +- app/controllers/avo/chats_controller.rb | 4 + app/controllers/avo/messages_controller.rb | 4 + app/controllers/avo/models_controller.rb | 4 + app/controllers/avo/tool_calls_controller.rb | 4 + app/controllers/chats_controller.rb | 40 ++++ app/controllers/messages_controller.rb | 25 +++ app/controllers/models_controller.rb | 16 ++ app/controllers/sessions_controller.rb | 4 +- app/jobs/chat_response_job.rb | 12 ++ app/models/chat.rb | 4 + app/models/message.rb | 12 ++ app/models/model.rb | 3 + app/models/tool_call.rb | 3 + app/models/user.rb | 2 + app/views/admins/admins/index.html.erb | 107 ---------- app/views/admins/admins/new.html.erb | 82 -------- app/views/admins/sessions/new.html.erb | 16 +- app/views/chats/_chat.html.erb | 16 ++ app/views/chats/_form.html.erb | 29 +++ app/views/chats/index.html.erb | 16 ++ app/views/chats/new.html.erb | 11 + app/views/chats/show.html.erb | 23 +++ app/views/layouts/application.html.erb | 15 +- app/views/messages/_content.html.erb | 1 + app/views/messages/_form.html.erb | 21 ++ app/views/messages/_message.html.erb | 13 ++ app/views/messages/_tool_calls.html.erb | 7 + app/views/messages/create.turbo_stream.erb | 9 + app/views/models/_model.html.erb | 16 ++ app/views/models/index.html.erb | 28 +++ app/views/models/show.html.erb | 18 ++ app/views/sessions/new.html.erb | 7 +- bin/configure | 105 +++++++--- config/credentials/development.yml.enc | 1 + config/credentials/production.yml.enc | 1 + config/environments/development.rb | 3 +- config/initializers/avo.rb | 17 +- .../initializers/browser_preview_delivery.rb | 27 +++ config/initializers/litestream.rb | 14 +- config/initializers/ruby_llm.rb | 15 ++ config/puma.rb | 3 + config/routes.rb | 14 +- ...te_active_storage_tables.active_storage.rb | 48 +++++ db/migrate/20260109224551_create_chats.rb | 8 + db/migrate/20260109224552_create_messages.rb | 17 ++ .../20260109224553_create_tool_calls.rb | 16 ++ db/migrate/20260109224555_create_models.rb | 33 +++ ...rences_to_chats_tool_calls_and_messages.rb | 9 + .../20260109230202_add_user_to_chats.rb | 5 + db/schema.rb | 115 +++++++++-- db/seeds.rb | 3 +- 68 files changed, 1238 insertions(+), 372 deletions(-) create mode 100644 app/avo/actions/refresh_models.rb create mode 100644 app/avo/actions/send_admin_magic_link.rb create mode 100644 app/avo/filters/created_at_filter.rb create mode 100644 app/avo/filters/provider_filter.rb create mode 100644 app/avo/filters/role_filter.rb create mode 100644 app/avo/resources/chat.rb create mode 100644 app/avo/resources/message.rb create mode 100644 app/avo/resources/model.rb create mode 100644 app/avo/resources/tool_call.rb delete mode 100644 app/controllers/admins/admins_controller.rb create mode 100644 app/controllers/avo/chats_controller.rb create mode 100644 app/controllers/avo/messages_controller.rb create mode 100644 app/controllers/avo/models_controller.rb create mode 100644 app/controllers/avo/tool_calls_controller.rb create mode 100644 app/controllers/chats_controller.rb create mode 100644 app/controllers/messages_controller.rb create mode 100644 app/controllers/models_controller.rb create mode 100644 app/jobs/chat_response_job.rb create mode 100644 app/models/chat.rb create mode 100644 app/models/message.rb create mode 100644 app/models/model.rb create mode 100644 app/models/tool_call.rb delete mode 100644 app/views/admins/admins/index.html.erb delete mode 100644 app/views/admins/admins/new.html.erb create mode 100644 app/views/chats/_chat.html.erb create mode 100644 app/views/chats/_form.html.erb create mode 100644 app/views/chats/index.html.erb create mode 100644 app/views/chats/new.html.erb create mode 100644 app/views/chats/show.html.erb create mode 100644 app/views/messages/_content.html.erb create mode 100644 app/views/messages/_form.html.erb create mode 100644 app/views/messages/_message.html.erb create mode 100644 app/views/messages/_tool_calls.html.erb create mode 100644 app/views/messages/create.turbo_stream.erb create mode 100644 app/views/models/_model.html.erb create mode 100644 app/views/models/index.html.erb create mode 100644 app/views/models/show.html.erb create mode 100644 config/credentials/development.yml.enc create mode 100644 config/credentials/production.yml.enc create mode 100644 config/initializers/browser_preview_delivery.rb create mode 100644 config/initializers/ruby_llm.rb create mode 100644 db/migrate/20260109184557_create_active_storage_tables.active_storage.rb create mode 100644 db/migrate/20260109224551_create_chats.rb create mode 100644 db/migrate/20260109224552_create_messages.rb create mode 100644 db/migrate/20260109224553_create_tool_calls.rb create mode 100644 db/migrate/20260109224555_create_models.rb create mode 100644 db/migrate/20260109224556_add_references_to_chats_tool_calls_and_messages.rb create mode 100644 db/migrate/20260109230202_add_user_to_chats.rb diff --git a/.gitignore b/.gitignore index 796ac93..98253e1 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,11 @@ # Ignore Rubymine files .idea/ + +# Ignore key files for decrypting credentials and more. +/config/*.key + + +# Ignore key files for decrypting credentials and more. +/config/credentials/*.key + diff --git a/CLAUDE.md b/CLAUDE.md index 72e36dd..f8c1f17 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,6 +15,7 @@ When starting a new project from this template: 3. The script will: - Rename project from "Template" to your project name - Ask for admin email (written directly to db/seeds.rb) + - Optionally configure OpenAI and Anthropic (Claude) API keys - Optionally configure Litestream for SQLite replication - Run `bin/setup` to install dependencies and setup database @@ -26,6 +27,7 @@ When starting a new project from this template: - **Background Jobs**: Solid Queue - **Caching**: Solid Cache - **Replication**: Litestream (SQLite → S3-compatible storage) +- **AI**: RubyLLM (OpenAI & Anthropic support) - **Frontend**: Hotwire (Turbo + Stimulus), Tailwind CSS 4 - **Asset Pipeline**: Propshaft - **Deployment**: Kamal 2 @@ -169,22 +171,24 @@ config/ ## Authentication System -This template uses **magic link authentication** (passwordless): +This template uses **magic link authentication** (passwordless) with complete separation of user and admin interfaces: -### User Authentication +### User Authentication (Public Interface) - Users create accounts automatically on first magic link request - Path: `/session/new` - Model: `User` (email, name) - Controller: `SessionsController` - Mailer: `UserMailer.magic_link` +- After login: redirects to `/home` -### Admin Authentication -- Admins must be created by other admins (or via seeds) -- Path: `/admins/session/new` +### Admin Authentication (Avo Interface) +- **All admin management happens through Avo at `/avo`** +- Path: `/admins/session/new` (admin login form) - Model: `Admin` (email only) - Controller: `Admins::SessionsController` - Mailer: `AdminMailer.magic_link` -- Admin panel: `/avo` (requires admin authentication) +- After magic link click: redirects to `/avo` +- Admins must exist in database (created via seeds or Avo) ### Magic Link Implementation ```ruby @@ -199,8 +203,78 @@ session[:user_id] = user.id ``` ### Helper Methods (ApplicationController) -- `current_user` / `current_admin` -- `authenticate_user!` / `authenticate_admin!` +- `current_user` - for public user interface +- `current_admin` - for Avo admin interface +- `authenticate_user!` - for user-facing controllers +- `authenticate_admin!` - for admin-specific controllers (not Avo) + +### Interface Separation +**IMPORTANT:** Keep interfaces completely separate: +- User interface: `/session/new`, `/home`, `/chats`, etc. +- Admin interface: `/admins/session/new` (login), `/avo` (admin panel) +- **No links between user and admin interfaces** +- Admin login is separate from user login (different URLs, different styling) +- All admin CRUD operations happen through Avo resources + +## Avo Admin Panel + +All administrative tasks are managed through **Avo** at `/avo`. Admin authentication uses `Admins::SessionsController`, but all CRUD operations (managing admins, users, chats, etc.) happen through Avo resources. + +### Available Resources +- **Admins** - Manage admin users, send magic links +- **Users** - View/edit users, see their chats +- **Chats** - View all AI chat sessions +- **Messages** - Inspect individual messages, tokens, tool calls +- **Models** - View available AI models, refresh from RubyLLM +- **Tool Calls** - Debug function/tool calls + +### Admin Actions +- **Send Magic Link** (Admins) - Send login link to admin(s) +- **Refresh Models** (Models) - Update AI model registry from RubyLLM + +### Avo Configuration +```ruby +# config/initializers/avo.rb +config.current_user_method do + Admin.find_by(id: session[:admin_id]) if session[:admin_id] +end + +config.authenticate_with do + admin = Admin.find_by(id: session[:admin_id]) if session[:admin_id] + redirect_to main_app.new_admins_session_path unless admin # Redirect to admin login +end +``` + +### Creating Avo Resources +Follow this pattern for new resources: + +```ruby +class Avo::Resources::ModelName < Avo::BaseResource + self.title = :name + self.includes = [:associations] + + self.search = { + query: -> { query.where("column LIKE ?", "%#{params[:q]}%") } + } + + def fields + field :id, as: :id, readonly: true + field :name, as: :text, required: true + field :association, as: :belongs_to + field :created_at, as: :date_time, readonly: true + end + + def filters + filter Avo::Filters::CustomFilter + end + + def actions + action Avo::Actions::CustomAction + end +end +``` + +**Location:** `app/avo/resources/`, `app/avo/actions/`, `app/avo/filters/` ## Important Development Practices @@ -216,6 +290,9 @@ session[:user_id] = user.id - Create empty directories "for later" - Add gems before trying vanilla Rails - Create boolean columns for state (use records or enums) +- Create separate admin controllers/views (use Avo instead) +- Mix user and admin interfaces (keep completely separate) +- Link to admin interface from user interface ### Code Style - RuboCop with auto-fix via lefthook @@ -226,17 +303,32 @@ session[:user_id] = user.id ## Credentials -This project uses Rails encrypted credentials exclusively. No environment variables are used. +This project uses **environment-specific** Rails encrypted credentials. No environment variables are used. +### Configuration +Credentials are automatically configured when running `bin/configure`: +- Development: `config/credentials/development.yml.enc` +- Development key: `config/credentials/development.key` (gitignored) +- Production: `config/credentials/production.yml.enc` +- Production key: `config/credentials/production.key` (gitignored) + +### Editing Credentials ```bash -# Edit credentials +# Edit development credentials rails credentials:edit --environment development + +# Edit production credentials rails credentials:edit --environment production ``` +### Structure ```yaml -# Example structure -secret_key_base: +# AI APIs (configured via bin/configure) +open_ai: + api_key: sk-... + +anthropic: + api_key: sk-ant-... # Litestream (optional, configured via bin/configure) litestream: @@ -248,12 +340,6 @@ litestream: stripe: secret_key: sk_test_... webhook_secret: whsec_... - -openai: - api_key: sk-... - -anthropic: - api_key: sk-ant-... ``` ## Litestream - SQLite Replication @@ -347,28 +433,66 @@ class Current < ActiveSupport::CurrentAttributes end ``` -### AI Integration Patterns (if applicable) +### RubyLLM - AI Chat Integration -**Graceful Degradation:** -```ruby -module AiResilient - extend ActiveSupport::Concern +This template includes **RubyLLM** for AI chat functionality with OpenAI and Anthropic (Claude) APIs. - def process_with_resilience - yield - rescue SomeAIError => e - handle_gracefully(e) - end +**Data Model:** +``` +┌─────────────────────────────────────────────────────────────────┐ +│ User │ +│ └── has_many :chats │ +│ │ +│ Chat │ +│ ├── belongs_to :user │ +│ ├── belongs_to :model (AI model) │ +│ └── has_many :messages │ +│ │ +│ Message │ +│ ├── belongs_to :chat │ +│ ├── role (system/user/assistant) │ +│ ├── content (text) │ +│ ├── content_raw (JSON with full API response) │ +│ └── token counts (input/output/cached) │ +│ │ +│ Model (AI model registry) │ +│ ├── model_id (e.g., "gpt-4", "claude-3-5-sonnet") │ +│ ├── provider (openai/anthropic) │ +│ └── capabilities, pricing, metadata │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Controllers & Routes:** +- `/chats` - ChatsController (index, new, create, show) +- `/chats/:chat_id/messages` - MessagesController (create) +- `/models` - ModelsController (index, show, refresh) + +All RubyLLM controllers require user authentication (`before_action :authenticate_user!`). + +**Background Processing:** +Chat responses are processed asynchronously via `ChatResponseJob` using Solid Queue. + +**Configuration:** +```ruby +# config/initializers/ruby_llm.rb +RubyLLM.configure do |config| + config.openai_api_key = Rails.application.credentials.dig(:open_ai, :api_key) + config.anthropic_api_key = Rails.application.credentials.dig(:anthropic, :api_key) + config.default_model = "gpt-4.1-nano" + config.use_new_acts_as = true # Use association-based API end ``` -**Separating Instructions from Data:** +**Usage in Models:** ```ruby -def messages_for_ai - [ - { role: :system, content: system_prompt }, # Pure instructions - *messages.map { |m| { role: m.role, content: m.content } } - ] +class Chat < ApplicationRecord + belongs_to :user + acts_as_chat messages_foreign_key: :chat_id # RubyLLM integration end -# User content is ALWAYS in user/assistant messages, never injected into system ``` + +**Best Practices:** +1. **User scoping** - Always scope chats to `current_user` +2. **Async processing** - Use background jobs for AI responses +3. **Token tracking** - Monitor token usage via Message model +4. **Model management** - Use `/models/refresh` to update available models diff --git a/Gemfile b/Gemfile index ba75f72..ae13d0f 100644 --- a/Gemfile +++ b/Gemfile @@ -66,3 +66,7 @@ group :test do end gem "avo", ">= 3.2" + +gem "ruby_llm", "~> 1.9" + +gem "fast-mcp", "~> 1.6" diff --git a/Gemfile.lock b/Gemfile.lock index 75cab18..09b20a7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -156,11 +156,58 @@ GEM 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.0) + dry-initializer (3.2.0) + dry-logic (1.6.0) + bigdecimal + concurrent-ruby (~> 1.0) + dry-core (~> 1.1) + zeitwerk (~> 2.6) + dry-schema (1.15.0) + concurrent-ruby (~> 1.0) + dry-configurable (~> 1.0, >= 1.0.1) + dry-core (~> 1.1) + dry-initializer (~> 3.2) + dry-logic (~> 1.6) + dry-types (~> 1.8) + zeitwerk (~> 2.6) + dry-types (1.9.0) + 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 + event_stream_parser (1.0.0) + faraday (2.14.0) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-multipart (1.2.0) + multipart-post (~> 2.0) + faraday-net_http (3.4.2) + net-http (~> 0.5) + faraday-retry (2.4.0) + faraday (~> 2.0) + fast-mcp (1.6.0) + addressable (~> 2.8) + base64 + dry-schema (~> 1.14) + json (~> 2.0) + mime-types (~> 3.4) + rack (>= 2.0, < 4.0) fugit (1.12.1) et-orbi (~> 1.4) raabro (~> 1.4) @@ -225,10 +272,17 @@ GEM matrix (0.4.3) meta-tags (2.22.2) actionpack (>= 6.0.0, < 8.2) + mime-types (3.7.0) + logger + mime-types-data (~> 3.2025, >= 3.2025.0507) + mime-types-data (3.2025.0924) mini_mime (1.1.5) minitest (6.0.1) prism (~> 1.5) msgpack (1.8.0) + multipart-post (2.4.1) + net-http (0.9.1) + uri (>= 0.11.1) net-imap (0.6.2) date net-protocol @@ -328,6 +382,17 @@ GEM rubocop-performance (>= 1.24) rubocop-rails (>= 2.30) ruby-progressbar (1.13.0) + ruby_llm (1.9.2) + base64 + event_stream_parser (~> 1) + faraday (>= 1.10.0) + faraday-multipart (>= 1) + faraday-net_http (>= 1) + faraday-retry (>= 1) + marcel (~> 1.0) + ruby_llm-schema (~> 0.2.1) + zeitwerk (~> 2) + ruby_llm-schema (0.2.5) rubyzip (3.2.2) securerandom (0.4.1) selenium-webdriver (4.39.0) @@ -420,6 +485,7 @@ DEPENDENCIES brakeman capybara debug + fast-mcp (~> 1.6) importmap-rails jbuilder kamal @@ -428,6 +494,7 @@ DEPENDENCIES puma (>= 5.0) rails! rubocop-rails-omakase + ruby_llm (~> 1.9) selenium-webdriver solid_cable solid_cache @@ -474,10 +541,23 @@ CHECKSUMS 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.0) sha256=441082dde958db39df7353c71e520c05732e8da9ace28c78da2d0f1d6c669fa3 + dry-initializer (3.2.0) sha256=37d59798f912dc0a1efe14a4db4a9306989007b302dcd5f25d0a2a20c166c4e3 + dry-logic (1.6.0) sha256=da6fedbc0f90fc41f9b0cc7e6f05f5d529d1efaef6c8dcc8e0733f685745cea2 + dry-schema (1.15.0) sha256=0f2a34adba4206bd6d46ec1b6b7691b402e198eecaff1d8349a7d48a77d82cd2 + dry-types (1.9.0) sha256=7b656fe0a78d2432500ae1f29fefd6762f5a032ca7000e4f36bc111453d45d4d 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 + event_stream_parser (1.0.0) sha256=a2683bab70126286f8184dc88f7968ffc4028f813161fb073ec90d171f7de3c8 + faraday (2.14.0) sha256=8699cfe5d97e55268f2596f9a9d5a43736808a943714e3d9a53e6110593941cd + faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757 + faraday-net_http (3.4.2) sha256=f147758260d3526939bf57ecf911682f94926a3666502e24c69992765875906c + faraday-retry (2.4.0) sha256=7b79c48fb7e56526faf247b12d94a680071ff40c9fda7cf1ec1549439ad11ebe + fast-mcp (1.6.0) sha256=d68abb45d2daab9e7ae2934417460e4bf9ac87493c585dc5bb626f1afb7d12c4 fugit (1.12.1) sha256=5898f478ede9b415f0804e42b8f3fd53f814bd85eebffceebdbc34e1107aaf68 globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11 i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5 @@ -498,9 +578,13 @@ CHECKSUMS marcel (1.1.0) sha256=fdcfcfa33cc52e93c4308d40e4090a5d4ea279e160a7f6af988260fa970e0bee matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b meta-tags (2.22.2) sha256=7fe78af4a92be12091f473cb84a21f6bddbd37f24c4413172df76cd14fff9e83 + mime-types (3.7.0) sha256=dcebf61c246f08e15a4de34e386ebe8233791e868564a470c3fe77c00eed5e56 + mime-types-data (3.2025.0924) sha256=f276bca15e59f35767cbcf2bc10e023e9200b30bd6a572c1daf7f4cc24994728 mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef minitest (6.0.1) sha256=7854c74f48e2e975969062833adc4013f249a4b212f5e7b9d5c040bf838d54bb msgpack (1.8.0) sha256=e64ce0212000d016809f5048b48eb3a65ffb169db22238fb4b72472fecb2d732 + multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8 + net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996 net-imap (0.6.2) sha256=08caacad486853c61676cca0c0c47df93db02abc4a8239a8b67eb0981428acc6 net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8 @@ -546,6 +630,8 @@ CHECKSUMS rubocop-rails (2.34.3) sha256=10d37989024865ecda8199f311f3faca990143fbac967de943f88aca11eb9ad2 rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 + ruby_llm (1.9.2) sha256=75485118414bf0e4160b657ff3dda0273ca1119a9573be76f07588673c054eee + ruby_llm-schema (0.2.5) sha256=b08cd42e8de7100325e2e868672a56f1915eb23692bb808f51f214e41392104f rubyzip (3.2.2) sha256=c0ed99385f0625415c8f05bcae33fe649ed2952894a95ff8b08f26ca57ea5b3c securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 selenium-webdriver (4.39.0) sha256=984a1e63d39472eaf286bac3c6f1822fa7eea6eed9c07a66ce7b3bc5417ba826 diff --git a/app/avo/actions/refresh_models.rb b/app/avo/actions/refresh_models.rb new file mode 100644 index 0000000..80826de --- /dev/null +++ b/app/avo/actions/refresh_models.rb @@ -0,0 +1,12 @@ +class Avo::Actions::RefreshModels < Avo::BaseAction + self.name = "Refresh Models" + self.message = "Refresh AI models from RubyLLM registry" + self.confirm_button_label = "Refresh" + self.standalone = true + + def handle(query:, fields:, current_user:, resource:, **args) + Model.refresh! + + succeed "Models refreshed successfully! Total models: #{Model.count}" + end +end diff --git a/app/avo/actions/send_admin_magic_link.rb b/app/avo/actions/send_admin_magic_link.rb new file mode 100644 index 0000000..29a11ce --- /dev/null +++ b/app/avo/actions/send_admin_magic_link.rb @@ -0,0 +1,13 @@ +class Avo::Actions::SendAdminMagicLink < Avo::BaseAction + self.name = "Send Magic Link" + self.message = "Send magic link email to selected admin(s)" + self.confirm_button_label = "Send" + + def handle(query:, fields:, current_user:, resource:, **args) + query.each do |admin| + AdminMailer.magic_link(admin).deliver_later + end + + succeed "Magic link(s) sent successfully!" + end +end diff --git a/app/avo/filters/created_at_filter.rb b/app/avo/filters/created_at_filter.rb new file mode 100644 index 0000000..ef1ae90 --- /dev/null +++ b/app/avo/filters/created_at_filter.rb @@ -0,0 +1,15 @@ +class Avo::Filters::CreatedAtFilter < Avo::Filters::DateTimeFilter + self.name = "Created at" + self.button_label = "Filter by date" + + def apply(request, query, value) + case value[:mode] + when "range" + query.where(created_at: value[:from]..value[:to]) + when "single" + query.where("DATE(created_at) = ?", value[:at].to_date) + else + query + end + end +end diff --git a/app/avo/filters/provider_filter.rb b/app/avo/filters/provider_filter.rb new file mode 100644 index 0000000..0958934 --- /dev/null +++ b/app/avo/filters/provider_filter.rb @@ -0,0 +1,16 @@ +class Avo::Filters::ProviderFilter < Avo::Filters::SelectFilter + self.name = "Provider" + + def apply(request, query, value) + return query if value.blank? + + query.where(provider: value) + end + + def options + { + "openai" => "OpenAI", + "anthropic" => "Anthropic" + } + end +end diff --git a/app/avo/filters/role_filter.rb b/app/avo/filters/role_filter.rb new file mode 100644 index 0000000..435df0d --- /dev/null +++ b/app/avo/filters/role_filter.rb @@ -0,0 +1,17 @@ +class Avo::Filters::RoleFilter < Avo::Filters::SelectFilter + self.name = "Role" + + def apply(request, query, value) + return query if value.blank? + + query.where(role: value) + end + + def options + { + "system" => "System", + "user" => "User", + "assistant" => "Assistant" + } + end +end diff --git a/app/avo/resources/admin.rb b/app/avo/resources/admin.rb index 6f9bb94..2b988d5 100644 --- a/app/avo/resources/admin.rb +++ b/app/avo/resources/admin.rb @@ -1,15 +1,20 @@ class Avo::Resources::Admin < Avo::BaseResource - # self.includes = [] - # self.attachments = [] - # self.search = { - # query: -> { query.ransack(id_eq: q, m: "or").result(distinct: false) } - # } + self.title = :email + self.includes = [] + + self.search = { + query: -> { query.where("email LIKE ?", "%#{params[:q]}%") } + } def fields - field :email, as: :gravatar - field :email, as: :text - field :created_at, as: :date, readonly: true - field :updated_at, as: :date, readonly: true - field :id, as: :text, readonly: true + field :id, as: :id, readonly: true + field :email, as: :gravatar, link_to_record: true + field :email, as: :text, required: true, help: "Admin email address" + field :created_at, as: :date_time, readonly: true + field :updated_at, as: :date_time, readonly: true + end + + def actions + action Avo::Actions::SendAdminMagicLink end end diff --git a/app/avo/resources/chat.rb b/app/avo/resources/chat.rb new file mode 100644 index 0000000..95895bf --- /dev/null +++ b/app/avo/resources/chat.rb @@ -0,0 +1,24 @@ +class Avo::Resources::Chat < Avo::BaseResource + self.title = :id + self.includes = [:user, :model, :messages] + + self.search = { + query: -> { query.joins(:user).where("users.email LIKE ?", "%#{params[:q]}%") } + } + + def fields + field :id, as: :id, readonly: true + + field :user, as: :belongs_to, searchable: true + field :model, as: :belongs_to, searchable: true, help: "AI model used for this chat" + + field :messages, as: :has_many + + field :created_at, as: :date_time, readonly: true + field :updated_at, as: :date_time, readonly: true + end + + def filters + filter Avo::Filters::CreatedAtFilter + end +end diff --git a/app/avo/resources/message.rb b/app/avo/resources/message.rb new file mode 100644 index 0000000..d632a9a --- /dev/null +++ b/app/avo/resources/message.rb @@ -0,0 +1,36 @@ +class Avo::Resources::Message < Avo::BaseResource + self.title = :id + self.includes = [:chat, :model, :tool_calls] + + self.search = { + query: -> { query.where("content LIKE ?", "%#{params[:q]}%") } + } + + def fields + field :id, as: :id, readonly: true + + field :chat, as: :belongs_to, searchable: true + field :model, as: :belongs_to, searchable: true, help: "AI model used for this message" + + field :role, as: :select, options: { "system" => "system", "user" => "user", "assistant" => "assistant" }, required: true + field :content, as: :textarea, rows: 5 + + # Token tracking + field :input_tokens, as: :number, readonly: true + field :output_tokens, as: :number, readonly: true + field :cached_tokens, as: :number, readonly: true + field :cache_creation_tokens, as: :number, readonly: true + + field :content_raw, as: :code, language: "json", readonly: true, help: "Full API response" + + field :tool_calls, as: :has_many + + field :created_at, as: :date_time, readonly: true + field :updated_at, as: :date_time, readonly: true + end + + def filters + filter Avo::Filters::RoleFilter + filter Avo::Filters::CreatedAtFilter + end +end diff --git a/app/avo/resources/model.rb b/app/avo/resources/model.rb new file mode 100644 index 0000000..2583715 --- /dev/null +++ b/app/avo/resources/model.rb @@ -0,0 +1,39 @@ +class Avo::Resources::Model < Avo::BaseResource + self.title = :name + self.includes = [] + + self.search = { + query: -> { query.where("name LIKE ? OR model_id LIKE ? OR provider LIKE ?", "%#{params[:q]}%", "%#{params[:q]}%", "%#{params[:q]}%") } + } + + def fields + field :id, as: :id, readonly: true + + field :name, as: :text, required: true, sortable: true + field :model_id, as: :text, required: true, help: "Model identifier (e.g., gpt-4, claude-3-5-sonnet)" + field :provider, as: :select, options: { "openai" => "OpenAI", "anthropic" => "Anthropic" }, required: true + + field :family, as: :text, help: "Model family (e.g., GPT-4, Claude 3.5)" + + field :context_window, as: :number, help: "Maximum context window size" + field :max_output_tokens, as: :number, help: "Maximum output tokens" + field :knowledge_cutoff, as: :date, help: "Knowledge cutoff date" + + field :modalities, as: :code, language: "json", help: "Supported modalities (text, image, etc.)" + field :capabilities, as: :code, language: "json", help: "Model capabilities" + field :pricing, as: :code, language: "json", help: "Pricing information" + field :metadata, as: :code, language: "json", help: "Additional metadata" + + field :model_created_at, as: :date_time, readonly: true, help: "When model was released" + field :created_at, as: :date_time, readonly: true + field :updated_at, as: :date_time, readonly: true + end + + def filters + filter Avo::Filters::ProviderFilter + end + + def actions + action Avo::Actions::RefreshModels + end +end diff --git a/app/avo/resources/tool_call.rb b/app/avo/resources/tool_call.rb new file mode 100644 index 0000000..56004a9 --- /dev/null +++ b/app/avo/resources/tool_call.rb @@ -0,0 +1,25 @@ +class Avo::Resources::ToolCall < Avo::BaseResource + self.title = :name + self.includes = [:message] + + self.search = { + query: -> { query.where("name LIKE ? OR tool_call_id LIKE ?", "%#{params[:q]}%", "%#{params[:q]}%") } + } + + def fields + field :id, as: :id, readonly: true + + field :message, as: :belongs_to, searchable: true + + field :tool_call_id, as: :text, required: true, help: "Unique tool call identifier" + field :name, as: :text, required: true, help: "Tool/function name" + field :arguments, as: :code, language: "json", help: "Tool arguments" + + field :created_at, as: :date_time, readonly: true + field :updated_at, as: :date_time, readonly: true + end + + def filters + filter Avo::Filters::CreatedAtFilter + end +end diff --git a/app/avo/resources/user.rb b/app/avo/resources/user.rb index 8b0a161..c1cee52 100644 --- a/app/avo/resources/user.rb +++ b/app/avo/resources/user.rb @@ -1,16 +1,24 @@ class Avo::Resources::User < Avo::BaseResource - # self.includes = [] - # self.attachments = [] - # self.search = { - # query: -> { query.ransack(id_eq: params[:q], m: "or").result(distinct: false) } - # } + self.title = :email + self.includes = [:chats] + + self.search = { + query: -> { query.where("email LIKE ? OR name LIKE ?", "%#{params[:q]}%", "%#{params[:q]}%") } + } def fields - field :email, as: :gravatar - field :name, as: :text - field :email, as: :text - field :created_at, as: :date, readonly: true - field :updated_at, as: :date, readonly: true - field :id, as: :text, readonly: true + field :id, as: :id, readonly: true + field :email, as: :gravatar, link_to_record: true + field :name, as: :text, required: true + field :email, as: :text, required: true, help: "User email address" + + field :chats, as: :has_many + + field :created_at, as: :date_time, readonly: true + field :updated_at, as: :date_time, readonly: true + end + + def filters + filter Avo::Filters::CreatedAtFilter end end diff --git a/app/controllers/admins/admins_controller.rb b/app/controllers/admins/admins_controller.rb deleted file mode 100644 index 93197ac..0000000 --- a/app/controllers/admins/admins_controller.rb +++ /dev/null @@ -1,35 +0,0 @@ -class Admins::AdminsController < ApplicationController - before_action :authenticate_admin! - - def index - @admins = Admin.all.order(created_at: :desc) - end - - def new - @admin = Admin.new - end - - def create - @admin = Admin.new(admin_params) - - if @admin.save - # Send magic link to new admin - AdminMailer.magic_link(@admin).deliver_later - redirect_to admins_admins_path, notice: "Admin created and magic link sent!" - else - render :new, status: :unprocessable_entity - end - end - - def destroy - @admin = Admin.find(params[:id]) - @admin.destroy - redirect_to admins_admins_path, notice: "Admin deleted successfully" - end - - private - - def admin_params - params.expect(admin: [ :email ]) - end -end diff --git a/app/controllers/admins/sessions_controller.rb b/app/controllers/admins/sessions_controller.rb index 31438b7..d5ac8b7 100644 --- a/app/controllers/admins/sessions_controller.rb +++ b/app/controllers/admins/sessions_controller.rb @@ -1,5 +1,6 @@ class Admins::SessionsController < ApplicationController - skip_before_action :authenticate_user!, only: [ :new, :create, :verify ] + # Admin login and magic link verification + # All admin management happens through Avo at /avo def new # Show admin login form @@ -29,6 +30,6 @@ def verify def destroy session[:admin_id] = nil - redirect_to new_admins_session_path, notice: "Signed out successfully" + redirect_to root_path, notice: "Signed out successfully" end end diff --git a/app/controllers/avo/chats_controller.rb b/app/controllers/avo/chats_controller.rb new file mode 100644 index 0000000..909de4c --- /dev/null +++ b/app/controllers/avo/chats_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/3.0/controllers.html +class Avo::ChatsController < Avo::ResourcesController +end diff --git a/app/controllers/avo/messages_controller.rb b/app/controllers/avo/messages_controller.rb new file mode 100644 index 0000000..7ac0625 --- /dev/null +++ b/app/controllers/avo/messages_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/3.0/controllers.html +class Avo::MessagesController < Avo::ResourcesController +end diff --git a/app/controllers/avo/models_controller.rb b/app/controllers/avo/models_controller.rb new file mode 100644 index 0000000..868c804 --- /dev/null +++ b/app/controllers/avo/models_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/3.0/controllers.html +class Avo::ModelsController < Avo::ResourcesController +end diff --git a/app/controllers/avo/tool_calls_controller.rb b/app/controllers/avo/tool_calls_controller.rb new file mode 100644 index 0000000..3494622 --- /dev/null +++ b/app/controllers/avo/tool_calls_controller.rb @@ -0,0 +1,4 @@ +# This controller has been generated to enable Rails' resource routes. +# More information on https://docs.avohq.io/3.0/controllers.html +class Avo::ToolCallsController < Avo::ResourcesController +end diff --git a/app/controllers/chats_controller.rb b/app/controllers/chats_controller.rb new file mode 100644 index 0000000..51e063f --- /dev/null +++ b/app/controllers/chats_controller.rb @@ -0,0 +1,40 @@ +class ChatsController < ApplicationController + before_action :authenticate_user! + before_action :set_chat, only: [:show] + + def index + @chats = current_user.chats.order(created_at: :desc) + end + + def new + @chat = current_user.chats.build + @selected_model = params[:model] + end + + def create + return unless prompt.present? + + @chat = current_user.chats.create!(model: model) + ChatResponseJob.perform_later(@chat.id, prompt) + + redirect_to @chat, notice: 'Chat was successfully created.' + end + + def show + @message = @chat.messages.build + end + + private + + def set_chat + @chat = current_user.chats.find(params[:id]) + end + + def model + params[:chat][:model].presence + end + + def prompt + params[:chat][:prompt] + end +end \ No newline at end of file diff --git a/app/controllers/messages_controller.rb b/app/controllers/messages_controller.rb new file mode 100644 index 0000000..9e3cadc --- /dev/null +++ b/app/controllers/messages_controller.rb @@ -0,0 +1,25 @@ +class MessagesController < ApplicationController + before_action :authenticate_user! + before_action :set_chat + + def create + return unless content.present? + + ChatResponseJob.perform_later(@chat.id, content) + + respond_to do |format| + format.turbo_stream + format.html { redirect_to @chat } + end + end + + private + + def set_chat + @chat = current_user.chats.find(params[:chat_id]) + end + + def content + params[:message][:content] + end +end \ No newline at end of file diff --git a/app/controllers/models_controller.rb b/app/controllers/models_controller.rb new file mode 100644 index 0000000..4c158a7 --- /dev/null +++ b/app/controllers/models_controller.rb @@ -0,0 +1,16 @@ +class ModelsController < ApplicationController + before_action :authenticate_user! + + def index + @models = Model.all + end + + def show + @model = Model.find(params[:id]) + end + + def refresh + Model.refresh! + redirect_to models_path, notice: "Models refreshed successfully" + end +end \ No newline at end of file diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 19f3ebf..7389f3b 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,6 +1,4 @@ class SessionsController < ApplicationController - skip_before_action :authenticate_user!, only: [ :new, :create, :verify ] - def new # Show login form end @@ -34,6 +32,6 @@ def verify def destroy session[:user_id] = nil - redirect_to new_session_path, notice: "Signed out successfully" + redirect_to root_path, notice: "Signed out successfully" end end diff --git a/app/jobs/chat_response_job.rb b/app/jobs/chat_response_job.rb new file mode 100644 index 0000000..26c393b --- /dev/null +++ b/app/jobs/chat_response_job.rb @@ -0,0 +1,12 @@ +class ChatResponseJob < ApplicationJob + def perform(chat_id, content) + chat = Chat.find(chat_id) + + chat.ask(content) do |chunk| + if chunk.content && !chunk.content.blank? + message = chat.messages.last + message.broadcast_append_chunk(chunk.content) + end + end + end +end \ No newline at end of file diff --git a/app/models/chat.rb b/app/models/chat.rb new file mode 100644 index 0000000..df59c59 --- /dev/null +++ b/app/models/chat.rb @@ -0,0 +1,4 @@ +class Chat < ApplicationRecord + belongs_to :user + acts_as_chat messages_foreign_key: :chat_id +end diff --git a/app/models/message.rb b/app/models/message.rb new file mode 100644 index 0000000..ef429e4 --- /dev/null +++ b/app/models/message.rb @@ -0,0 +1,12 @@ +class Message < ApplicationRecord + acts_as_message tool_calls_foreign_key: :message_id + has_many_attached :attachments + broadcasts_to ->(message) { "chat_#{message.chat_id}" } + + def broadcast_append_chunk(content) + broadcast_append_to "chat_#{chat_id}", + target: "message_#{id}_content", + partial: "messages/content", + locals: { content: content } + end +end diff --git a/app/models/model.rb b/app/models/model.rb new file mode 100644 index 0000000..f7341a1 --- /dev/null +++ b/app/models/model.rb @@ -0,0 +1,3 @@ +class Model < ApplicationRecord + acts_as_model chats_foreign_key: :model_id +end diff --git a/app/models/tool_call.rb b/app/models/tool_call.rb new file mode 100644 index 0000000..1ebd927 --- /dev/null +++ b/app/models/tool_call.rb @@ -0,0 +1,3 @@ +class ToolCall < ApplicationRecord + acts_as_tool_call +end diff --git a/app/models/user.rb b/app/models/user.rb index 802397a..c18d0f1 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,4 +1,6 @@ class User < ApplicationRecord + has_many :chats, dependent: :destroy + validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP } validates :name, presence: true diff --git a/app/views/admins/admins/index.html.erb b/app/views/admins/admins/index.html.erb deleted file mode 100644 index 98d0aec..0000000 --- a/app/views/admins/admins/index.html.erb +++ /dev/null @@ -1,107 +0,0 @@ -
-
-
-
-

Admin Management

-

Manage admin accounts and access

-
- <%= link_to new_admins_admin_path, class: "inline-flex items-center px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors font-medium" do %> - - - - Add New Admin - <% end %> -
-
- -
- - - - - - - - - - - <% @admins.each do |admin| %> - - - - - - - <% end %> - -
- Admin - - ID - - Created - - Actions -
-
-
-
- - <%= admin.email[0].upcase %> - -
-
-
-
- <%= admin.email %> -
- <% if admin == current_admin %> - - You - - <% end %> -
-
-
- <%= admin.id %> - - <%= time_tag admin.created_at, admin.created_at.strftime("%b %d, %Y") %> - - <% if admin != current_admin %> - <%= button_to admins_admin_path(admin), - method: :delete, - class: "text-red-600 hover:text-red-900 inline-flex items-center", - data: { turbo_confirm: "Are you sure you want to remove #{admin.email}? They will lose admin access immediately." } do %> - - - - Remove - <% end %> - <% else %> - Current admin - <% end %> -
- - <% if @admins.empty? %> -
- - - -

No admins

-

Get started by creating a new admin.

-
- <% end %> -
- -
-
- - - -
- About Admin Accounts -

When you create a new admin, they'll receive a magic link via email to sign in. Admins can access the Avo admin panel and manage other admins.

-
-
-
-
diff --git a/app/views/admins/admins/new.html.erb b/app/views/admins/admins/new.html.erb deleted file mode 100644 index 6249e32..0000000 --- a/app/views/admins/admins/new.html.erb +++ /dev/null @@ -1,82 +0,0 @@ -
-
- <%= link_to admins_admins_path, class: "inline-flex items-center text-sm text-gray-600 hover:text-gray-900 mb-4" do %> - - - - Back to Admins - <% end %> -

Add New Admin

-

Create a new admin account with access to the admin panel

-
- -
- <%= form_with model: @admin, url: admins_admins_path, class: "space-y-6" do |form| %> - <% if @admin.errors.any? %> -
-
- - - -
- There <%= @admin.errors.count == 1 ? 'was' : 'were' %> <%= pluralize(@admin.errors.count, "error") %>: -
    - <% @admin.errors.full_messages.each do |message| %> -
  • <%= message %>
  • - <% end %> -
-
-
-
- <% end %> - -
- <%= form.label :email, "Email Address", class: "block text-sm font-medium text-gray-700 mb-2" %> - <%= form.email_field :email, - required: true, - autofocus: true, - autocomplete: "email", - placeholder: "newadmin@example.com", - class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent #{@admin.errors[:email].any? ? 'border-red-300' : ''}" %> -

- The new admin will receive a magic link at this email address to sign in. -

-
- -
-
- - - -
- What happens next? -
    -
  • The admin account will be created immediately
  • -
  • A magic link email will be sent to the provided address
  • -
  • The new admin can click the link to sign in
  • -
  • They'll have full admin access including Avo and admin management
  • -
-
-
-
- -
- <%= link_to "Cancel", admins_admins_path, class: "px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900" %> - <%= form.submit "Create Admin & Send Magic Link", - class: "px-6 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 font-medium transition-colors" %> -
- <% end %> -
- -
-
- - - -
- Security Notice -

Only create admin accounts for trusted individuals. Admins have full access to all data and can manage other admins.

-
-
-
-
diff --git a/app/views/admins/sessions/new.html.erb b/app/views/admins/sessions/new.html.erb index 04763ef..55696a1 100644 --- a/app/views/admins/sessions/new.html.erb +++ b/app/views/admins/sessions/new.html.erb @@ -1,4 +1,4 @@ -
+
@@ -21,7 +21,7 @@ placeholder: "admin@example.com", class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent" %>

- Only existing admins can sign in. Contact another admin if you need access. + Only existing admins can sign in. You'll receive a magic link via email.

@@ -30,22 +30,16 @@ class: "w-full bg-red-600 text-white py-2 px-4 rounded-lg hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 font-medium transition-colors" %>
<% end %> - -
-

- Regular user? <%= link_to "Sign in here", new_session_path, class: "text-red-600 hover:text-red-800 font-medium" %> -

-
- +
- Admin Area -

This is a restricted area. All login attempts are logged.

+ Restricted Area +

This is the admin login. If you need admin access, contact an existing administrator.

diff --git a/app/views/chats/_chat.html.erb b/app/views/chats/_chat.html.erb new file mode 100644 index 0000000..715e5e7 --- /dev/null +++ b/app/views/chats/_chat.html.erb @@ -0,0 +1,16 @@ +
+
+ Model: + <%= chat.model&.name || 'Default' %> +
+ +
+ Messages: + <%= chat.messages.count %> +
+ +
+ Created: + <%= chat.created_at.strftime("%B %d, %Y at %I:%M %p") %> +
+
\ No newline at end of file diff --git a/app/views/chats/_form.html.erb b/app/views/chats/_form.html.erb new file mode 100644 index 0000000..8c7c6a3 --- /dev/null +++ b/app/views/chats/_form.html.erb @@ -0,0 +1,29 @@ +<%= form_with(model: chat, url: chats_path) do |form| %> + <% if chat.errors.any? %> +
+

<%= pluralize(chat.errors.count, "error") %> prohibited this chat from being saved:

+ +
    + <% chat.errors.each do |error| %> +
  • <%= error.full_message %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= form.label :model, "Select AI model:", style: "display: block" %> + <%= form.select :model, + options_for_select(Model.pluck(:name, :model_id).unshift(["Default (#{RubyLLM.config.default_model})", nil]), @selected_model), + {}, + style: "width: 100%; max-width: 600px; padding: 5px;" %> +
+ +
+ <%= form.text_field :prompt, style: "width: 100%; max-width: 600px;", placeholder: "What would you like to discuss?", autofocus: true %> +
+ +
+ <%= form.submit "Start new chat" %> +
+<% end %> \ No newline at end of file diff --git a/app/views/chats/index.html.erb b/app/views/chats/index.html.erb new file mode 100644 index 0000000..684e660 --- /dev/null +++ b/app/views/chats/index.html.erb @@ -0,0 +1,16 @@ +

<%= notice %>

+ +<% content_for :title, "Chats" %> + +

Chats

+ +
+ <% @chats.each do |chat| %> + <%= render chat %> +

+ <%= link_to "Show this chat", chat %> +

+ <% end %> +
+ +<%= link_to "New chat", new_chat_path %> \ No newline at end of file diff --git a/app/views/chats/new.html.erb b/app/views/chats/new.html.erb new file mode 100644 index 0000000..5715e97 --- /dev/null +++ b/app/views/chats/new.html.erb @@ -0,0 +1,11 @@ +<% content_for :title, "New chat" %> + +

New chat

+ +<%= render "form", chat: @chat %> + +
+ +
+ <%= link_to "Back to chats", chats_path %> +
\ No newline at end of file diff --git a/app/views/chats/show.html.erb b/app/views/chats/show.html.erb new file mode 100644 index 0000000..f05b462 --- /dev/null +++ b/app/views/chats/show.html.erb @@ -0,0 +1,23 @@ +

<%= notice %>

+ +<%= turbo_stream_from "chat_#{@chat.id}" %> + +<% content_for :title, "Chat" %> + +

Chat <%= @chat.id %>

+ +

Using <%= @chat.model.name %>

+ +
+ <% @chat.messages.where.not(id: nil).each do |message| %> + <%= render message %> + <% end %> +
+ +
+ <%= render "messages/form", chat: @chat, message: @message %> +
+ +
+ <%= link_to "Back to chats", chats_path %> +
\ No newline at end of file diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 1ccb3cb..55d752f 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -24,22 +24,15 @@ - <% if current_user || current_admin %> + <% if current_user %> diff --git a/app/views/messages/_content.html.erb b/app/views/messages/_content.html.erb new file mode 100644 index 0000000..70d1174 --- /dev/null +++ b/app/views/messages/_content.html.erb @@ -0,0 +1 @@ +<%= content %> \ No newline at end of file diff --git a/app/views/messages/_form.html.erb b/app/views/messages/_form.html.erb new file mode 100644 index 0000000..460e55f --- /dev/null +++ b/app/views/messages/_form.html.erb @@ -0,0 +1,21 @@ +<%= form_with(model: message, url: [@chat, message], id: "new_message") do |form| %> + <% if message.errors.any? %> +
+

<%= pluralize(message.errors.count, "error") %> prohibited this message from being saved:

+ +
    + <% message.errors.each do |error| %> +
  • <%= error.full_message %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= form.text_field :content, style: "width: 100%; max-width: 600px;", placeholder: "Message...", autofocus: true %> +
+ +
+ <%= form.submit "Send message" %> +
+<% end %> \ No newline at end of file diff --git a/app/views/messages/_message.html.erb b/app/views/messages/_message.html.erb new file mode 100644 index 0000000..77c833e --- /dev/null +++ b/app/views/messages/_message.html.erb @@ -0,0 +1,13 @@ +
+
+ <%= message.role&.capitalize %> +
+
<%= message.content %>
+ <% if message.tool_call? %> + <%= render "messages/tool_calls", message: message %> + <% end %> +
+ <%= message.created_at&.strftime("%I:%M %p") %> +
+
\ No newline at end of file diff --git a/app/views/messages/_tool_calls.html.erb b/app/views/messages/_tool_calls.html.erb new file mode 100644 index 0000000..c6de382 --- /dev/null +++ b/app/views/messages/_tool_calls.html.erb @@ -0,0 +1,7 @@ +
+ <% message.tool_calls.each do |tool_call| %> +
+ <%= tool_call.name %>(<%= tool_call.arguments.map { |k, v| "#{k}: #{v.inspect}" }.join(", ") %>) +
+ <% end %> +
\ No newline at end of file diff --git a/app/views/messages/create.turbo_stream.erb b/app/views/messages/create.turbo_stream.erb new file mode 100644 index 0000000..18177b3 --- /dev/null +++ b/app/views/messages/create.turbo_stream.erb @@ -0,0 +1,9 @@ +<%= turbo_stream.append "messages" do %> + <% @chat.messages.last(2).each do |message| %> + <%= render message %> + <% end %> +<% end %> + +<%= turbo_stream.replace "new_message" do %> + <%= render "messages/form", chat: @chat, message: @chat.messages.build %> +<% end %> \ No newline at end of file diff --git a/app/views/models/_model.html.erb b/app/views/models/_model.html.erb new file mode 100644 index 0000000..bdc5b2c --- /dev/null +++ b/app/views/models/_model.html.erb @@ -0,0 +1,16 @@ + + <%= model.provider %> + <%= model.name %> + <%= number_with_delimiter(model.context_window) if model.context_window %> + + <% if model.pricing && model.pricing['text_tokens'] && model.pricing['text_tokens']['standard'] %> + <% input = model.pricing['text_tokens']['standard']['input_per_million'] %> + <% output = model.pricing['text_tokens']['standard']['output_per_million'] %> + <% if input && output %> + $<%= "%.2f" % input %> / $<%= "%.2f" % output %> + <% end %> + <% end %> + + <%= link_to "Show", model %> + <%= link_to "Start chat", new_chat_path(model: model.model_id) %> + \ No newline at end of file diff --git a/app/views/models/index.html.erb b/app/views/models/index.html.erb new file mode 100644 index 0000000..2c135eb --- /dev/null +++ b/app/views/models/index.html.erb @@ -0,0 +1,28 @@ +

<%= notice %>

+ +<% content_for :title, "Models" %> + +

Models

+ +

+ <%= button_to "Refresh Models", refresh_models_path, method: :post %> +

+ +
+ + + + + + + + + + + + <%= render @models %> + +
ProviderModelContext Window$/1M tokens (In/Out)
+
+ +<%= link_to "Back to chats", chats_path %> \ No newline at end of file diff --git a/app/views/models/show.html.erb b/app/views/models/show.html.erb new file mode 100644 index 0000000..a35c126 --- /dev/null +++ b/app/views/models/show.html.erb @@ -0,0 +1,18 @@ +<% content_for :title, @model.name %> + +

<%= @model.name %>

+ +

ID: <%= @model.model_id %>

+

Provider: <%= @model.provider %>

+

Context Window: <%= number_with_delimiter(@model.context_window) %> tokens

+

Max Output: <%= number_with_delimiter(@model.max_output_tokens) %> tokens

+ +<% if @model.capabilities.any? %> +

Capabilities: <%= @model.capabilities.join(", ") %>

+<% end %> + +

+ <%= link_to "Start chat with this model", new_chat_path(model: @model.model_id) %> | + <%= link_to "All models", models_path %> | + <%= link_to "Back to chats", chats_path %> +

\ No newline at end of file diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index c0d2e55..c21a158 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -1,7 +1,7 @@
-

Welcome Back

+

Welcome

Sign in with a magic link sent to your email

@@ -26,11 +26,6 @@
<% end %> -
-

- Admin? <%= link_to "Sign in here", new_admins_session_path, class: "text-indigo-600 hover:text-indigo-800 font-medium" %> -

-
diff --git a/bin/configure b/bin/configure index 9905d40..117905c 100755 --- a/bin/configure +++ b/bin/configure @@ -116,6 +116,13 @@ FileUtils.chdir APP_ROOT do admin_email = prompt("Admin email address", "admin@example.com") + puts "\n── Configuring AI API Keys (optional)... ──\n" + puts "RubyLLM supports OpenAI and Anthropic (Claude) APIs." + puts "Press Enter to skip if you want to configure them later.\n" + + openai_api_key = prompt("OpenAI API Key") + anthropic_api_key = prompt("Anthropic (Claude) API Key") + puts "\n── Configuring Litestream (optional)... ──\n" puts "Litestream provides SQLite replication to S3-compatible storage." puts "Press Enter to skip if you don't need it yet.\n" @@ -127,6 +134,8 @@ FileUtils.chdir APP_ROOT do # Store config temporarily config_data = { "admin_email" => admin_email, + "openai_api_key" => openai_api_key, + "anthropic_api_key" => anthropic_api_key, "replica_bucket" => replica_bucket, "replica_key_id" => replica_key_id, "replica_access_key" => replica_access_key @@ -147,57 +156,87 @@ FileUtils.chdir APP_ROOT do File.write("db/seeds.rb", seeds_content) puts " ✓ Updated db/seeds.rb" - # Mark as configured - File.write(".configured", "Configured on #{Time.now}\nProject: #{project_name}\nAdmin: #{admin_email}\n") - - puts "\n" + "═" * 60 - puts " ✓ Configuration Complete!" - puts "═" * 60 - puts - puts "Project: #{project_name}" - puts "Admin: #{admin_email}" - puts - puts "Running bin/setup to install dependencies and setup database..." - puts - - # Run bin/setup - system!("bin/setup") - puts "\n── Writing credentials... ──\n" # Load config data config_data = YAML.load_file(".configure_temp.yml") - # Build credentials YAML content (only if Litestream is configured) - if !config_data["replica_bucket"].to_s.empty? - credentials_yaml = { - "secret_key_base" => SecureRandom.hex(64), - "litestream" => { + # Build credentials YAML content + has_ai_keys = !config_data["openai_api_key"].to_s.empty? || !config_data["anthropic_api_key"].to_s.empty? + has_litestream = !config_data["replica_bucket"].to_s.empty? + + if has_ai_keys || has_litestream + # Build credentials hash + credentials_hash = {} + + # Add AI credentials if provided + if has_ai_keys + credentials_hash["open_ai"] = { "api_key" => config_data["openai_api_key"] } unless config_data["openai_api_key"].to_s.empty? + credentials_hash["anthropic"] = { "api_key" => config_data["anthropic_api_key"] } unless config_data["anthropic_api_key"].to_s.empty? + end + + # Add Litestream credentials if provided + if has_litestream + credentials_hash["litestream"] = { "replica_bucket" => config_data["replica_bucket"], "replica_key_id" => config_data["replica_key_id"], "replica_access_key" => config_data["replica_access_key"] } - } + end - # Write to development credentials using Rails runner - credentials_content = YAML.dump(credentials_yaml) - File.write(".credentials_temp.yml", credentials_content) + # Write directly to development credentials + require "active_support/encrypted_configuration" - system!('bin/rails runner " - content = File.read(\".credentials_temp.yml\") - Rails.application.credentials.config.write(content) - puts \" ✓ Development credentials updated\" - "') + # Create credentials directory if it doesn't exist + FileUtils.mkdir_p("config/credentials") - # Clean up temp files - File.delete(".credentials_temp.yml") if File.exist?(".credentials_temp.yml") + # Initialize development credentials + key_path = "config/credentials/development.key" + credentials_path = "config/credentials/development.yml.enc" + + # Generate key if it doesn't exist + unless File.exist?(key_path) + key = ActiveSupport::EncryptedConfiguration.generate_key + File.write(key_path, key) + puts " ✓ Generated development key: #{key_path}" + end + + # Write the credentials + config = ActiveSupport::EncryptedConfiguration.new( + config_path: credentials_path, + key_path: key_path, + env_key: "RAILS_MASTER_KEY", + raise_if_missing_key: false + ) + + config.write(YAML.dump(credentials_hash)) + + puts " ✓ Development credentials updated" + puts " ✓ Saved to: #{credentials_path}" + puts " ✓ Key saved to: #{key_path}" else - puts " ⊝ No credentials to write (Litestream not configured)" + puts " ⊝ No credentials to write (no API keys or Litestream configured)" end # Clean up temp files File.delete(".configure_temp.yml") if File.exist?(".configure_temp.yml") + # Mark as configured + File.write(".configured", "Configured on #{Time.now}\nProject: #{project_name}\nAdmin: #{admin_email}\n") + + puts "\n" + "═" * 60 + puts " ✓ Configuration Complete!" + puts "═" * 60 + puts + puts "Project: #{project_name}" + puts "Admin: #{admin_email}" + puts + puts "Running bin/setup to install dependencies and setup database..." + puts + + # Run bin/setup (after credentials are written) + system!("bin/setup") + puts "\n" + "═" * 60 puts " ✓ All Done!" puts "═" * 60 diff --git a/config/credentials/development.yml.enc b/config/credentials/development.yml.enc new file mode 100644 index 0000000..8bb4947 --- /dev/null +++ b/config/credentials/development.yml.enc @@ -0,0 +1 @@ +/UY81w==--Mh85aZyRBx3UHDP+--0aGJmarDZKsf1251y8Zw5w== \ No newline at end of file diff --git a/config/credentials/production.yml.enc b/config/credentials/production.yml.enc new file mode 100644 index 0000000..344a219 --- /dev/null +++ b/config/credentials/production.yml.enc @@ -0,0 +1 @@ +CfMoJ3s1AZerfqiZ3xeeLiZUUhkFv4T3MqXM2OAGaR6CxBTZTKPx+1ntPdW6tSCiBYXW7afA5K4cOsZitUBwpLZXz9GQ0Cxdyg5H8bmaiEo55eM+xMHdCwMEllkhDS9mIfqb9j5sWmjghcMcOrbwVp5iXHvovxb2XMlsuPCQIUAOxpGEduaIp7kyeWNJXUa5gPXFIy0lSgoC3Y6fGK4CXNHgpJ7OUT4dFvKSH1gA5fQQBkuXAIzkUTjsMu8EdLqpYM/IKGRx1WOEU7bmZBvEjjFhSUWbKb2NxXkgPcCI3PaBfRonGGaKoAlF/EZlwe5zEbRh5teYdvuwO10RAoQvxr21KR3FUFJIlzuNmh6x63pJnW1P1EbKY1z4YtVW83kSdFnfa1C0VQL+TX93iTVEP1QoB3We0DeqGvnuag5kk4Luk5Ge+2DhdTDSCjDDTPVTSOQ9Lw0hRSCv4ke9ip70po1utSk/2Ll3a+Kqi7GjRKogLohnaw7xZXJ8--46uKtHwz6DnUULOB--B8QRGoD7uweOhJomWrPioQ== \ No newline at end of file diff --git a/config/environments/development.rb b/config/environments/development.rb index 4cc21c4..09aa56e 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -31,7 +31,8 @@ # 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. + # Open emails in browser automatically + config.action_mailer.delivery_method = :browser_preview config.action_mailer.raise_delivery_errors = false # Make template changes take effect immediately. diff --git a/config/initializers/avo.rb b/config/initializers/avo.rb index 5f6cfe4..4223910 100644 --- a/config/initializers/avo.rb +++ b/config/initializers/avo.rb @@ -22,11 +22,24 @@ end ## == Authentication == - config.current_user_method = :current_admin + config.current_user_method do + # Access current_admin from the main application controller's session + Admin.find_by(id: session[:admin_id]) if session[:admin_id] + end + config.authenticate_with do - redirect_to new_admins_session_path, alert: "Please log in as admin" unless current_admin + # Check if admin is authenticated + admin = Admin.find_by(id: session[:admin_id]) if session[:admin_id] + + unless admin + # Redirect to admin login page + redirect_to main_app.new_admins_session_path + end end + ## == Sign out == + config.sign_out_path_name = :admins_session_path + ## == Authorization == # config.is_admin_method = :is_admin # config.is_developer_method = :is_developer diff --git a/config/initializers/browser_preview_delivery.rb b/config/initializers/browser_preview_delivery.rb new file mode 100644 index 0000000..03e98e7 --- /dev/null +++ b/config/initializers/browser_preview_delivery.rb @@ -0,0 +1,27 @@ +# Custom delivery method that opens emails in the browser automatically +class BrowserPreviewDelivery + def initialize(settings) + @settings = settings + end + + def deliver!(mail) + # Create tmp directory if it doesn't exist + tmp_dir = Rails.root.join("tmp", "mails") + FileUtils.mkdir_p(tmp_dir) + + # Generate a unique filename + filename = "#{Time.now.strftime('%Y%m%d_%H%M%S')}_#{mail.subject&.parameterize || 'mail'}.html" + filepath = tmp_dir.join(filename) + + # Write the email HTML to file + File.write(filepath, mail.html_part&.body || mail.body) + + # Open in browser + system("open", filepath.to_s) + + Rails.logger.info "Email opened in browser: #{filepath}" + end +end + +# Register the delivery method +ActionMailer::Base.add_delivery_method :browser_preview, BrowserPreviewDelivery diff --git a/config/initializers/litestream.rb b/config/initializers/litestream.rb index ddba8c2..785cfab 100644 --- a/config/initializers/litestream.rb +++ b/config/initializers/litestream.rb @@ -12,11 +12,17 @@ Rails.application.configure do # Configure Litestream through Rails encrypted credentials - litestream_credentials = Rails.application.credentials.litestream + begin + litestream_credentials = Rails.application.credentials.litestream - config.litestream.replica_bucket = litestream_credentials&.replica_bucket - config.litestream.replica_key_id = litestream_credentials&.replica_key_id - config.litestream.replica_access_key = litestream_credentials&.replica_access_key + config.litestream.replica_bucket = litestream_credentials&.replica_bucket + config.litestream.replica_key_id = litestream_credentials&.replica_key_id + config.litestream.replica_access_key = litestream_credentials&.replica_access_key + rescue ActiveSupport::MessageEncryptor::InvalidMessage + # Credentials not yet configured - skip Litestream configuration + # This is expected during initial setup (bin/configure) + Rails.logger.debug "Litestream credentials not configured yet" if Rails.logger + end # Replica-specific bucket location. This will be your bucket's URL without the `https://` prefix. # For example, if you used DigitalOcean Spaces, your bucket URL could look like: diff --git a/config/initializers/ruby_llm.rb b/config/initializers/ruby_llm.rb new file mode 100644 index 0000000..6b7fdbb --- /dev/null +++ b/config/initializers/ruby_llm.rb @@ -0,0 +1,15 @@ +begin + RubyLLM.configure do |config| + config.openai_api_key = Rails.application.credentials.dig(:open_ai, :api_key) + config.default_model = "gpt-4.1-nano" + + config.anthropic_api_key = Rails.application.credentials.dig(:anthropic, :api_key) + + # Use the new association-based acts_as API (recommended) + config.use_new_acts_as = true + end +rescue ActiveSupport::MessageEncryptor::InvalidMessage + # Credentials not yet configured - skip RubyLLM configuration + # This is expected during initial setup (bin/configure) + Rails.logger.debug "RubyLLM credentials not configured yet" if Rails.logger +end diff --git a/config/puma.rb b/config/puma.rb index a248513..66a58ff 100644 --- a/config/puma.rb +++ b/config/puma.rb @@ -39,3 +39,6 @@ # Specify the PID file. Defaults to tmp/pids/server.pid in development. # In other environments, only set the PID file if requested. pidfile ENV["PIDFILE"] if ENV["PIDFILE"] + +# Run litestream only in production. +plugin :litestream if ENV.fetch("RAILS_ENV", "production") == "production" diff --git a/config/routes.rb b/config/routes.rb index f528386..efb6688 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,12 @@ Rails.application.routes.draw do + resources :chats do + resources :messages, only: [:create] + end + resources :models, only: [:index, :show] do + collection do + post :refresh + end + end # User dashboard get "home", to: "home#index", as: :home @@ -6,14 +14,14 @@ resource :session, only: [ :new, :create, :destroy ] get "auth/:token", to: "sessions#verify", as: :verify_magic_link - # Admin authentication (magic link) + # Admin authentication (admin management happens in Avo) namespace :admins do resource :session, only: [ :new, :create, :destroy ] get "auth/:token", to: "sessions#verify", as: :verify_magic_link - resources :admins, only: [ :index, :new, :create, :destroy ] end # Avo admin panel (requires admin authentication) + # Access at /avo - all admin management happens here mount Avo::Engine, at: Avo.configuration.root_path # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html @@ -27,5 +35,5 @@ # get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker # Defines the root path route ("/") - root "sessions#new" + root "home#index" end diff --git a/db/migrate/20260109184557_create_active_storage_tables.active_storage.rb b/db/migrate/20260109184557_create_active_storage_tables.active_storage.rb new file mode 100644 index 0000000..5dac7f6 --- /dev/null +++ b/db/migrate/20260109184557_create_active_storage_tables.active_storage.rb @@ -0,0 +1,48 @@ +# This migration comes from active_storage (originally 20170806125915) +class CreateActiveStorageTables < ActiveRecord::Migration[7.0] + def change + create_table :active_storage_blobs, force: true, id: false do |t| + t.primary_key :id, :string, default: -> { "ULID()" } + t.string :key, null: false + t.string :filename, null: false + t.string :content_type + t.text :metadata + t.string :service_name, null: false + t.bigint :byte_size, null: false + t.string :checksum + + if connection.supports_datetime_with_precision? + t.datetime :created_at, precision: 6, null: false + else + t.datetime :created_at, null: false + end + + t.index [ :key ], unique: true + end + + create_table :active_storage_attachments, force: true, id: false do |t| + t.primary_key :id, :string, default: -> { "ULID()" } + t.string :name, null: false + t.references :record, null: false, polymorphic: true, index: false, type: :string + t.references :blob, null: false, type: :string + + if connection.supports_datetime_with_precision? + t.datetime :created_at, precision: 6, null: false + else + t.datetime :created_at, null: false + end + + t.index [ :record_type, :record_id, :name, :blob_id ], name: :index_active_storage_attachments_uniqueness, unique: true + t.foreign_key :active_storage_blobs, column: :blob_id + end + + create_table :active_storage_variant_records, force: true, id: false do |t| + t.primary_key :id, :string, default: -> { "ULID()" } + t.belongs_to :blob, null: false, index: false, type: :string + t.string :variation_digest, null: false + + t.index [ :blob_id, :variation_digest ], name: :index_active_storage_variant_records_uniqueness, unique: true + t.foreign_key :active_storage_blobs, column: :blob_id + end + end +end diff --git a/db/migrate/20260109224551_create_chats.rb b/db/migrate/20260109224551_create_chats.rb new file mode 100644 index 0000000..8d5ee07 --- /dev/null +++ b/db/migrate/20260109224551_create_chats.rb @@ -0,0 +1,8 @@ +class CreateChats < ActiveRecord::Migration[8.2] + def change + create_table :chats, force: true, id: false do |t| + t.primary_key :id, :string, default: -> { "ULID()" } + t.timestamps + end + end +end diff --git a/db/migrate/20260109224552_create_messages.rb b/db/migrate/20260109224552_create_messages.rb new file mode 100644 index 0000000..d0f8b18 --- /dev/null +++ b/db/migrate/20260109224552_create_messages.rb @@ -0,0 +1,17 @@ +class CreateMessages < ActiveRecord::Migration[8.2] + def change + create_table :messages, force: true, id: false do |t| + t.primary_key :id, :string, default: -> { "ULID()" } + t.string :role, null: false + t.text :content + t.json :content_raw + t.integer :input_tokens + t.integer :output_tokens + t.integer :cached_tokens + t.integer :cache_creation_tokens + t.timestamps + end + + add_index :messages, :role + end +end diff --git a/db/migrate/20260109224553_create_tool_calls.rb b/db/migrate/20260109224553_create_tool_calls.rb new file mode 100644 index 0000000..7053f28 --- /dev/null +++ b/db/migrate/20260109224553_create_tool_calls.rb @@ -0,0 +1,16 @@ +class CreateToolCalls < ActiveRecord::Migration[8.2] + def change + create_table :tool_calls, force: true, id: false do |t| + t.primary_key :id, :string, default: -> { "ULID()" } + t.string :tool_call_id, null: false + t.string :name, null: false + + t.json :arguments, default: {} + + t.timestamps + end + + add_index :tool_calls, :tool_call_id, unique: true + add_index :tool_calls, :name + end +end diff --git a/db/migrate/20260109224555_create_models.rb b/db/migrate/20260109224555_create_models.rb new file mode 100644 index 0000000..7a069e5 --- /dev/null +++ b/db/migrate/20260109224555_create_models.rb @@ -0,0 +1,33 @@ +class CreateModels < ActiveRecord::Migration[8.2] + def change + create_table :models, force: true, id: false do |t| + t.primary_key :id, :string, default: -> { "ULID()" } + t.string :model_id, null: false + t.string :name, null: false + t.string :provider, null: false + t.string :family + t.datetime :model_created_at + t.integer :context_window + t.integer :max_output_tokens + t.date :knowledge_cutoff + + t.json :modalities, default: {} + t.json :capabilities, default: [] + t.json :pricing, default: {} + t.json :metadata, default: {} + + t.timestamps + + t.index [:provider, :model_id], unique: true + t.index :provider + t.index :family + + end + + # Load models from JSON + say_with_time "Loading models from models.json" do + RubyLLM.models.load_from_json! + Model.save_to_database + end + end +end diff --git a/db/migrate/20260109224556_add_references_to_chats_tool_calls_and_messages.rb b/db/migrate/20260109224556_add_references_to_chats_tool_calls_and_messages.rb new file mode 100644 index 0000000..8be06b9 --- /dev/null +++ b/db/migrate/20260109224556_add_references_to_chats_tool_calls_and_messages.rb @@ -0,0 +1,9 @@ +class AddReferencesToChatsToolCallsAndMessages < ActiveRecord::Migration[8.2] + def change + add_reference :chats, :model, type: :string, foreign_key: true + add_reference :tool_calls, :message, type: :string, null: false, foreign_key: true + add_reference :messages, :chat, type: :string, null: false, foreign_key: true + add_reference :messages, :model, type: :string, foreign_key: true + add_reference :messages, :tool_call, type: :string, foreign_key: true + end +end diff --git a/db/migrate/20260109230202_add_user_to_chats.rb b/db/migrate/20260109230202_add_user_to_chats.rb new file mode 100644 index 0000000..46aa785 --- /dev/null +++ b/db/migrate/20260109230202_add_user_to_chats.rb @@ -0,0 +1,5 @@ +class AddUserToChats < ActiveRecord::Migration[8.2] + def change + add_reference :chats, :user, null: false, foreign_key: true, type: :string + end +end diff --git a/db/schema.rb b/db/schema.rb index ec5e4f9..9b71bae 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,29 +10,116 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2024_12_07_205829) do +ActiveRecord::Schema[8.2].define(version: 2026_01_09_230202) do + create_table "active_storage_attachments", id: :string, default: -> { "ULID()" }, force: :cascade do |t| + t.string "blob_id", null: false + t.datetime "created_at", null: false + t.string "name", null: false + t.string "record_id", null: false + t.string "record_type", null: false + t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" + t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true + end + + create_table "active_storage_blobs", id: :string, default: -> { "ULID()" }, force: :cascade do |t| + t.bigint "byte_size", null: false + t.string "checksum" + t.string "content_type" + t.datetime "created_at", null: false + t.string "filename", null: false + t.string "key", null: false + t.text "metadata" + t.string "service_name", null: false + t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true + end + + create_table "active_storage_variant_records", id: :string, default: -> { "ULID()" }, force: :cascade do |t| + t.string "blob_id", null: false + t.string "variation_digest", null: false + t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true + end + create_table "admins", id: :string, default: -> { "ULID()" }, force: :cascade do |t| - t.string "email", default: "", null: false - t.string "encrypted_password", default: "", null: false - t.string "reset_password_token" - t.datetime "reset_password_sent_at" - t.datetime "remember_created_at" t.datetime "created_at", null: false + t.string "email" t.datetime "updated_at", null: false t.index ["email"], name: "index_admins_on_email", unique: true - t.index ["reset_password_token"], name: "index_admins_on_reset_password_token", unique: true + end + + create_table "chats", id: :string, default: -> { "ULID()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.string "model_id" + t.datetime "updated_at", null: false + t.string "user_id", null: false + t.index ["model_id"], name: "index_chats_on_model_id" + t.index ["user_id"], name: "index_chats_on_user_id" + end + + create_table "messages", id: :string, default: -> { "ULID()" }, force: :cascade do |t| + t.integer "cache_creation_tokens" + t.integer "cached_tokens" + t.string "chat_id", null: false + t.text "content" + t.json "content_raw" + t.datetime "created_at", null: false + t.integer "input_tokens" + t.string "model_id" + t.integer "output_tokens" + t.string "role", null: false + t.string "tool_call_id" + t.datetime "updated_at", null: false + t.index ["chat_id"], name: "index_messages_on_chat_id" + t.index ["model_id"], name: "index_messages_on_model_id" + t.index ["role"], name: "index_messages_on_role" + t.index ["tool_call_id"], name: "index_messages_on_tool_call_id" + end + + create_table "models", id: :string, default: -> { "ULID()" }, force: :cascade do |t| + t.json "capabilities", default: [] + t.integer "context_window" + t.datetime "created_at", null: false + t.string "family" + t.date "knowledge_cutoff" + t.integer "max_output_tokens" + t.json "metadata", default: {} + t.json "modalities", default: {} + t.datetime "model_created_at" + t.string "model_id", null: false + t.string "name", null: false + t.json "pricing", default: {} + t.string "provider", null: false + t.datetime "updated_at", null: false + t.index ["family"], name: "index_models_on_family" + t.index ["provider", "model_id"], name: "index_models_on_provider_and_model_id", unique: true + t.index ["provider"], name: "index_models_on_provider" + end + + create_table "tool_calls", id: :string, default: -> { "ULID()" }, force: :cascade do |t| + t.json "arguments", default: {} + t.datetime "created_at", null: false + t.string "message_id", null: false + t.string "name", null: false + t.string "tool_call_id", null: false + t.datetime "updated_at", null: false + t.index ["message_id"], name: "index_tool_calls_on_message_id" + t.index ["name"], name: "index_tool_calls_on_name" + t.index ["tool_call_id"], name: "index_tool_calls_on_tool_call_id", unique: true end create_table "users", id: :string, default: -> { "ULID()" }, force: :cascade do |t| - t.string "email", default: "", null: false - t.string "encrypted_password", default: "", null: false - t.string "reset_password_token" - t.datetime "reset_password_sent_at" - t.datetime "remember_created_at" - t.string "name" t.datetime "created_at", null: false + t.string "email" + t.string "name" t.datetime "updated_at", null: false t.index ["email"], name: "index_users_on_email", unique: true - t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true end + + add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" + add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" + add_foreign_key "chats", "models" + add_foreign_key "chats", "users" + add_foreign_key "messages", "chats" + add_foreign_key "messages", "models" + add_foreign_key "messages", "tool_calls" + add_foreign_key "tool_calls", "messages" end diff --git a/db/seeds.rb b/db/seeds.rb index 3196d4d..b3d6970 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -3,6 +3,5 @@ # The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup). # Create first admin -admin_email = "admin@example.com" -admin = Admin.find_or_create_by!(email: admin_email) +admin = Admin.find_or_create_by!(email: "admin@example.com") puts "✓ Admin created: #{admin.email}" From 6d13c0bb3937fff5678f64f6225c3e10d640da94 Mon Sep 17 00:00:00 2001 From: Yuri Sidorov <403994+newstler@users.noreply.github.com> Date: Tue, 13 Jan 2026 01:23:34 +0100 Subject: [PATCH 006/106] Replaced Avo with Madmin --- CLAUDE.md | 108 ++++++----- Gemfile | 2 +- Gemfile.lock | 53 +---- README.md | 7 +- app/avo/actions/refresh_models.rb | 12 -- app/avo/actions/send_admin_magic_link.rb | 13 -- app/avo/filters/created_at_filter.rb | 15 -- app/avo/filters/provider_filter.rb | 16 -- app/avo/filters/role_filter.rb | 17 -- app/avo/resources/admin.rb | 20 -- app/avo/resources/chat.rb | 24 --- app/avo/resources/message.rb | 36 ---- app/avo/resources/model.rb | 39 ---- app/avo/resources/tool_call.rb | 25 --- app/avo/resources/user.rb | 24 --- .../admins/dashboard_controller.rb | 34 ++++ app/controllers/admins/sessions_controller.rb | 2 +- app/controllers/avo/admins_controller.rb | 4 - app/controllers/avo/chats_controller.rb | 4 - app/controllers/avo/messages_controller.rb | 4 - app/controllers/avo/models_controller.rb | 4 - app/controllers/avo/tool_calls_controller.rb | 4 - app/controllers/avo/users_controller.rb | 4 - .../active_storage/attachments_controller.rb | 4 + .../madmin/active_storage/blobs_controller.rb | 8 + .../variant_records_controller.rb | 4 + app/controllers/madmin/admins_controller.rb | 9 + .../madmin/application_controller.rb | 18 ++ app/controllers/madmin/chats_controller.rb | 21 ++ app/controllers/madmin/messages_controller.rb | 20 ++ app/controllers/madmin/models_controller.rb | 14 ++ .../madmin/tool_calls_controller.rb | 16 ++ app/controllers/madmin/users_controller.rb | 16 ++ app/madmin/fields/gravatar_field.rb | 8 + app/madmin/fields/json_field.rb | 12 ++ .../active_storage/attachment_resource.rb | 26 +++ .../resources/active_storage/blob_resource.rb | 35 ++++ .../active_storage/variant_record_resource.rb | 26 +++ app/madmin/resources/admin_resource.rb | 27 +++ app/madmin/resources/chat_resource.rb | 19 ++ app/madmin/resources/message_resource.rb | 27 +++ app/madmin/resources/model_resource.rb | 28 +++ app/madmin/resources/tool_call_resource.rb | 20 ++ app/madmin/resources/user_resource.rb | 20 ++ app/views/admins/dashboard/index.html.erb | 181 ++++++++++++++++++ app/views/layouts/madmin/application.html.erb | 41 ++++ app/views/madmin/admins/show.html.erb | 38 ++++ .../madmin/application/_javascript.html.erb | 8 + .../madmin/application/_navigation.html.erb | 30 +++ app/views/madmin/chats/index.html.erb | 101 ++++++++++ .../fields/gravatar_field/_form.html.erb | 1 + .../fields/gravatar_field/_index.html.erb | 8 + .../fields/gravatar_field/_show.html.erb | 8 + .../madmin/fields/json_field/_form.html.erb | 7 + .../madmin/fields/json_field/_index.html.erb | 5 + .../madmin/fields/json_field/_show.html.erb | 1 + app/views/madmin/messages/index.html.erb | 109 +++++++++++ app/views/madmin/models/index.html.erb | 105 ++++++++++ app/views/madmin/tool_calls/index.html.erb | 101 ++++++++++ app/views/madmin/users/index.html.erb | 101 ++++++++++ config/initializers/avo.rb | 177 ----------------- config/routes.rb | 9 +- config/routes/madmin.rb | 27 +++ 63 files changed, 1360 insertions(+), 547 deletions(-) delete mode 100644 app/avo/actions/refresh_models.rb delete mode 100644 app/avo/actions/send_admin_magic_link.rb delete mode 100644 app/avo/filters/created_at_filter.rb delete mode 100644 app/avo/filters/provider_filter.rb delete mode 100644 app/avo/filters/role_filter.rb delete mode 100644 app/avo/resources/admin.rb delete mode 100644 app/avo/resources/chat.rb delete mode 100644 app/avo/resources/message.rb delete mode 100644 app/avo/resources/model.rb delete mode 100644 app/avo/resources/tool_call.rb delete mode 100644 app/avo/resources/user.rb create mode 100644 app/controllers/admins/dashboard_controller.rb delete mode 100644 app/controllers/avo/admins_controller.rb delete mode 100644 app/controllers/avo/chats_controller.rb delete mode 100644 app/controllers/avo/messages_controller.rb delete mode 100644 app/controllers/avo/models_controller.rb delete mode 100644 app/controllers/avo/tool_calls_controller.rb delete mode 100644 app/controllers/avo/users_controller.rb create mode 100644 app/controllers/madmin/active_storage/attachments_controller.rb create mode 100644 app/controllers/madmin/active_storage/blobs_controller.rb create mode 100644 app/controllers/madmin/active_storage/variant_records_controller.rb create mode 100644 app/controllers/madmin/admins_controller.rb create mode 100644 app/controllers/madmin/application_controller.rb create mode 100644 app/controllers/madmin/chats_controller.rb create mode 100644 app/controllers/madmin/messages_controller.rb create mode 100644 app/controllers/madmin/models_controller.rb create mode 100644 app/controllers/madmin/tool_calls_controller.rb create mode 100644 app/controllers/madmin/users_controller.rb create mode 100644 app/madmin/fields/gravatar_field.rb create mode 100644 app/madmin/fields/json_field.rb create mode 100644 app/madmin/resources/active_storage/attachment_resource.rb create mode 100644 app/madmin/resources/active_storage/blob_resource.rb create mode 100644 app/madmin/resources/active_storage/variant_record_resource.rb create mode 100644 app/madmin/resources/admin_resource.rb create mode 100644 app/madmin/resources/chat_resource.rb create mode 100644 app/madmin/resources/message_resource.rb create mode 100644 app/madmin/resources/model_resource.rb create mode 100644 app/madmin/resources/tool_call_resource.rb create mode 100644 app/madmin/resources/user_resource.rb create mode 100644 app/views/admins/dashboard/index.html.erb create mode 100644 app/views/layouts/madmin/application.html.erb create mode 100644 app/views/madmin/admins/show.html.erb create mode 100644 app/views/madmin/application/_javascript.html.erb create mode 100644 app/views/madmin/application/_navigation.html.erb create mode 100644 app/views/madmin/chats/index.html.erb create mode 100644 app/views/madmin/fields/gravatar_field/_form.html.erb create mode 100644 app/views/madmin/fields/gravatar_field/_index.html.erb create mode 100644 app/views/madmin/fields/gravatar_field/_show.html.erb create mode 100644 app/views/madmin/fields/json_field/_form.html.erb create mode 100644 app/views/madmin/fields/json_field/_index.html.erb create mode 100644 app/views/madmin/fields/json_field/_show.html.erb create mode 100644 app/views/madmin/messages/index.html.erb create mode 100644 app/views/madmin/models/index.html.erb create mode 100644 app/views/madmin/tool_calls/index.html.erb create mode 100644 app/views/madmin/users/index.html.erb delete mode 100644 config/initializers/avo.rb create mode 100644 config/routes/madmin.rb diff --git a/CLAUDE.md b/CLAUDE.md index f8c1f17..705f32a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -154,13 +154,18 @@ app/ ├── clients/ # External service wrappers (create when needed) ├── controllers/ │ ├── concerns/ +│ ├── madmin/ # Madmin resource controllers │ └── webhooks/ # Third-party webhooks ├── jobs/ ├── mailers/ +├── madmin/ +│ ├── fields/ # Custom Madmin fields (Json, Gravatar) +│ └── resources/ # Madmin resource definitions ├── models/ │ └── concerns/ └── views/ - └── layouts/ + ├── layouts/ + └── madmin/ # Customized Madmin views (generated as needed) config/ ├── deploy.yml # Kamal configuration @@ -181,14 +186,14 @@ This template uses **magic link authentication** (passwordless) with complete se - Mailer: `UserMailer.magic_link` - After login: redirects to `/home` -### Admin Authentication (Avo Interface) -- **All admin management happens through Avo at `/avo`** +### Admin Authentication (Madmin Interface) +- **All admin management happens through Madmin at `/madmin`** - Path: `/admins/session/new` (admin login form) - Model: `Admin` (email only) - Controller: `Admins::SessionsController` - Mailer: `AdminMailer.magic_link` -- After magic link click: redirects to `/avo` -- Admins must exist in database (created via seeds or Avo) +- After magic link click: redirects to `/madmin` +- Admins must exist in database (created via seeds or Madmin) ### Magic Link Implementation ```ruby @@ -204,77 +209,78 @@ session[:user_id] = user.id ### Helper Methods (ApplicationController) - `current_user` - for public user interface -- `current_admin` - for Avo admin interface +- `current_admin` - for Madmin admin interface - `authenticate_user!` - for user-facing controllers -- `authenticate_admin!` - for admin-specific controllers (not Avo) +- `authenticate_admin!` - for admin-specific controllers (not Madmin) ### Interface Separation **IMPORTANT:** Keep interfaces completely separate: - User interface: `/session/new`, `/home`, `/chats`, etc. -- Admin interface: `/admins/session/new` (login), `/avo` (admin panel) +- Admin interface: `/admins/session/new` (login), `/madmin` (admin panel) - **No links between user and admin interfaces** - Admin login is separate from user login (different URLs, different styling) -- All admin CRUD operations happen through Avo resources +- All admin CRUD operations happen through Madmin resources -## Avo Admin Panel +## Madmin Admin Panel -All administrative tasks are managed through **Avo** at `/avo`. Admin authentication uses `Admins::SessionsController`, but all CRUD operations (managing admins, users, chats, etc.) happen through Avo resources. +All administrative tasks are managed through **Madmin** at `/madmin`. Admin authentication uses `Admins::SessionsController`, and all CRUD operations are performed through Madmin's generated controllers and views. ### Available Resources - **Admins** - Manage admin users, send magic links -- **Users** - View/edit users, see their chats -- **Chats** - View all AI chat sessions -- **Messages** - Inspect individual messages, tokens, tool calls -- **Models** - View available AI models, refresh from RubyLLM -- **Tool Calls** - Debug function/tool calls +- **Users** - View/edit users, filter by created date, see their chats +- **Chats** - View all AI chat sessions, filter by created date +- **Messages** - Inspect individual messages, tokens, tool calls; filter by role and created date +- **Models** - View available AI models, refresh from RubyLLM, filter by provider +- **Tool Calls** - Debug function/tool calls, filter by created date ### Admin Actions -- **Send Magic Link** (Admins) - Send login link to admin(s) -- **Refresh Models** (Models) - Update AI model registry from RubyLLM +- **Send Magic Link** (Admin detail page) - Send login link to specific admin +- **Refresh Models** (Models index) - Update AI model registry from RubyLLM -### Avo Configuration -```ruby -# config/initializers/avo.rb -config.current_user_method do - Admin.find_by(id: session[:admin_id]) if session[:admin_id] -end +### Madmin Configuration -config.authenticate_with do - admin = Admin.find_by(id: session[:admin_id]) if session[:admin_id] - redirect_to main_app.new_admins_session_path unless admin # Redirect to admin login -end -``` +```ruby +# app/controllers/madmin/application_controller.rb +class Madmin::ApplicationController < Madmin::BaseController + before_action :authenticate_admin! -### Creating Avo Resources -Follow this pattern for new resources: + private -```ruby -class Avo::Resources::ModelName < Avo::BaseResource - self.title = :name - self.includes = [:associations] - - self.search = { - query: -> { query.where("column LIKE ?", "%#{params[:q]}%") } - } - - def fields - field :id, as: :id, readonly: true - field :name, as: :text, required: true - field :association, as: :belongs_to - field :created_at, as: :date_time, readonly: true + def authenticate_admin! + admin = Admin.find_by(id: session[:admin_id]) if session[:admin_id] + redirect_to main_app.new_admins_session_path unless admin end - def filters - filter Avo::Filters::CustomFilter + helper_method :current_admin + + def current_admin + @current_admin ||= Admin.find_by(id: session[:admin_id]) if session[:admin_id] end +end +``` - def actions - action Avo::Actions::CustomAction +### Creating Madmin Resources + +```ruby +class ModelResource < Madmin::Resource + attribute :id, form: false + attribute :name + attribute :email + attribute :association # belongs_to or has_many + attribute :json_field, field: JsonField + attribute :email_with_gravatar, field: GravatarField, form: false + attribute :created_at, form: false + + def self.searchable_attributes + [:name, :email] end end ``` -**Location:** `app/avo/resources/`, `app/avo/actions/`, `app/avo/filters/` +**Controllers:** `app/controllers/madmin/[models]_controller.rb` +**Resources:** `app/madmin/resources/[model]_resource.rb` +**Custom Fields:** `app/madmin/fields/[field]_field.rb` +**Views:** `app/views/madmin/[models]/` (generated as needed) ## Important Development Practices @@ -290,7 +296,7 @@ end - Create empty directories "for later" - Add gems before trying vanilla Rails - Create boolean columns for state (use records or enums) -- Create separate admin controllers/views (use Avo instead) +- Create separate admin controllers/views outside Madmin (use Madmin resources/controllers) - Mix user and admin interfaces (keep completely separate) - Link to admin interface from user interface diff --git a/Gemfile b/Gemfile index ae13d0f..a01326b 100644 --- a/Gemfile +++ b/Gemfile @@ -65,7 +65,7 @@ group :test do gem "selenium-webdriver" end -gem "avo", ">= 3.2" +gem "madmin", "~> 2.1" gem "ruby_llm", "~> 1.9" diff --git a/Gemfile.lock b/Gemfile.lock index 09b20a7..daa9da2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -105,29 +105,9 @@ GEM specs: action_text-trix (2.1.16) railties - active_link_to (1.0.5) - actionpack - addressable addressable (2.8.8) public_suffix (>= 2.0.2, < 8.0) ast (2.4.3) - avo (3.28.0) - actionview (>= 6.1) - active_link_to - activerecord (>= 6.1) - activesupport (>= 6.1) - addressable - avo-icons (>= 0.1.1) - docile - meta-tags - pagy (>= 7.0.0, < 43) - prop_initializer (>= 0.2.0) - turbo-rails (>= 2.0.0) - turbo_power (>= 0.6.0) - view_component (>= 3.7.0) - zeitwerk (>= 2.6.12) - avo-icons (0.1.1) - inline_svg base64 (0.3.0) bcrypt_pbkdf (1.1.2) bigdecimal (4.0.1) @@ -153,7 +133,6 @@ GEM 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) @@ -219,9 +198,6 @@ GEM actionpack (>= 6.0.0) activesupport (>= 6.0.0) railties (>= 6.0.0) - inline_svg (1.10.0) - activesupport (>= 3.0) - nokogiri (>= 1.6) io-console (0.8.2) irb (1.16.0) pp (>= 0.6.0) @@ -262,6 +238,13 @@ GEM loofah (2.25.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) + madmin (2.3.2) + importmap-rails + pagy (>= 3.5) + propshaft + rails (>= 7.0.0) + stimulus-rails + turbo-rails mail (2.9.0) logger mini_mime (>= 0.1.1) @@ -270,8 +253,6 @@ GEM net-smtp marcel (1.1.0) matrix (0.4.3) - meta-tags (2.22.2) - actionpack (>= 6.0.0, < 8.2) mime-types (3.7.0) logger mime-types-data (~> 3.2025, >= 3.2025.0507) @@ -314,8 +295,6 @@ GEM prettyprint prettyprint (0.2.0) prism (1.7.0) - prop_initializer (0.2.0) - zeitwerk (>= 2.6.18) propshaft (1.3.1) actionpack (>= 7.0.0) activesupport (>= 7.0.0) @@ -446,8 +425,6 @@ GEM turbo-rails (2.0.20) actionpack (>= 7.1.0) railties (>= 7.1.0) - turbo_power (0.7.0) - turbo-rails (>= 1.3.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (3.2.0) @@ -455,10 +432,6 @@ GEM unicode-emoji (4.2.0) uri (1.1.1) useragent (0.16.11) - view_component (4.1.1) - actionview (>= 7.1.0, < 8.2) - activesupport (>= 7.1.0, < 8.2) - concurrent-ruby (~> 1) web-console (4.2.1) actionview (>= 6.0.0) activemodel (>= 6.0.0) @@ -480,7 +453,6 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES - avo (>= 3.2) bootsnap brakeman capybara @@ -490,6 +462,7 @@ DEPENDENCIES jbuilder kamal litestream + madmin (~> 2.1) propshaft puma (>= 5.0) rails! @@ -515,7 +488,6 @@ CHECKSUMS actionpack (8.2.0.alpha) actiontext (8.2.0.alpha) actionview (8.2.0.alpha) - active_link_to (1.0.5) sha256=4830847b3d14589df1e9fc62038ceec015257fce975ec1c2a77836c461b139ba activejob (8.2.0.alpha) activemodel (8.2.0.alpha) activerecord (8.2.0.alpha) @@ -523,8 +495,6 @@ CHECKSUMS activesupport (8.2.0.alpha) addressable (2.8.8) sha256=7c13b8f9536cf6364c03b9d417c19986019e28f7c00ac8132da4eb0fe393b057 ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 - avo (3.28.0) sha256=9a7ab701f41ee201b87553a36f0d34d4fd03a7c7737aeba6c0da09ba5a031910 - avo-icons (0.1.1) sha256=d9a23d6d47bb7f8f04163119352a66a436dc8accf53f15cd0c3b5fcaffed082c base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6 bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7 @@ -538,7 +508,6 @@ CHECKSUMS crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d 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 @@ -562,7 +531,6 @@ CHECKSUMS globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11 i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5 importmap-rails (2.2.2) sha256=729f5b1092f832780829ade1d0b46c7e53d91c556f06da7254da2977e93fe614 - inline_svg (1.10.0) sha256=5b652934236fd9f8adc61f3fd6e208b7ca3282698b19f28659971da84bf9a10f io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc irb (1.16.0) sha256=2abe56c9ac947cdcb2f150572904ba798c1e93c890c256f8429981a7675b0806 jbuilder (2.14.1) sha256=4eb26376ff60ef100cb4fd6fd7533cd271f9998327e86adf20fd8c0e69fabb42 @@ -574,10 +542,10 @@ CHECKSUMS litestream (0.14.0-x86_64-linux) sha256=2844734b6d8e5c6009baf8d138d6f18367f770e9e4390fb70763433db587bed6 logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 loofah (2.25.0) sha256=df5ed7ac3bac6a4ec802df3877ee5cc86d027299f8952e6243b3dac446b060e6 + madmin (2.3.2) sha256=6961cbaeed82634240c7c9888a49b181834bf9b85a9282caebf0ee7f368df73c mail (2.9.0) sha256=6fa6673ecd71c60c2d996260f9ee3dd387d4673b8169b502134659ece6d34941 marcel (1.1.0) sha256=fdcfcfa33cc52e93c4308d40e4090a5d4ea279e160a7f6af988260fa970e0bee matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b - meta-tags (2.22.2) sha256=7fe78af4a92be12091f473cb84a21f6bddbd37f24c4413172df76cd14fff9e83 mime-types (3.7.0) sha256=dcebf61c246f08e15a4de34e386ebe8233791e868564a470c3fe77c00eed5e56 mime-types-data (3.2025.0924) sha256=f276bca15e59f35767cbcf2bc10e023e9200b30bd6a572c1daf7f4cc24994728 mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef @@ -603,7 +571,6 @@ CHECKSUMS pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6 prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 prism (1.7.0) sha256=10062f734bf7985c8424c44fac382ac04a58124ea3d220ec3ba9fe4f2da65103 - prop_initializer (0.2.0) sha256=bd27704d0df8c59c3baf0df5cf448eba2b140fb9934fb31b2e379b5c842d8820 propshaft (1.3.1) sha256=9acc664ef67e819ffa3d95bd7ad4c3623ea799110c5f4dee67fa7e583e74c392 psych (5.3.1) sha256=eb7a57cef10c9d70173ff74e739d843ac3b2c019a003de48447b2963d81b1974 public_suffix (7.0.2) sha256=9114090c8e4e7135c1fd0e7acfea33afaab38101884320c65aaa0ffb8e26a857 @@ -656,13 +623,11 @@ CHECKSUMS timeout (0.6.0) sha256=6d722ad619f96ee383a0c557ec6eb8c4ecb08af3af62098a0be5057bf00de1af tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f turbo-rails (2.0.20) sha256=cbcbb4dd3ce59f6471c9f911b1655b2c721998cc8303959d982da347f374ea95 - turbo_power (0.7.0) sha256=ad95d147e0fa761d0023ad9ca00528c7b7ddf6bba8ca2e23755d5b21b290d967 tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 useragent (0.16.11) sha256=700e6413ad4bb954bb63547fa098dddf7b0ebe75b40cc6f93b8d54255b173844 - view_component (4.1.1) sha256=179f63b0db1d1a8f6af635dd684456b2bcdf6b6f4da2ef276bbe0579c17b377e web-console (4.2.1) sha256=e7bcf37a10ea2b4ec4281649d1cee461b32232d0a447e82c786e6841fd22fe20 websocket (1.2.11) sha256=b7e7a74e2410b5e85c25858b26b3322f29161e300935f70a0e0d3c35e0462737 websocket-driver (0.8.0) sha256=ed0dba4b943c22f17f9a734817e808bc84cdce6a7e22045f5315aa57676d4962 diff --git a/README.md b/README.md index 84f5c3f..3b83ad7 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A modern Rails 8 template following 37signals' vanilla Rails philosophy with bui - **Asset Pipeline**: Propshaft - **Deployment**: Kamal 2 - **Authentication**: Magic Links (passwordless) -- **Admin Panel**: Avo 3.x +- **Admin Panel**: Madmin - **Primary Keys**: ULIDs (sortable, distributed-friendly) ## Features @@ -89,11 +89,10 @@ Admins must be created by other admins: ### Admin Access -- Admin panel: `/avo` -- Admin management: `/admins/admins` +- Admin panel: `/madmin` - Admin login: `/admins/session/new` -Only authenticated admins can access Avo. +Only authenticated admins can access Madmin. ## Development diff --git a/app/avo/actions/refresh_models.rb b/app/avo/actions/refresh_models.rb deleted file mode 100644 index 80826de..0000000 --- a/app/avo/actions/refresh_models.rb +++ /dev/null @@ -1,12 +0,0 @@ -class Avo::Actions::RefreshModels < Avo::BaseAction - self.name = "Refresh Models" - self.message = "Refresh AI models from RubyLLM registry" - self.confirm_button_label = "Refresh" - self.standalone = true - - def handle(query:, fields:, current_user:, resource:, **args) - Model.refresh! - - succeed "Models refreshed successfully! Total models: #{Model.count}" - end -end diff --git a/app/avo/actions/send_admin_magic_link.rb b/app/avo/actions/send_admin_magic_link.rb deleted file mode 100644 index 29a11ce..0000000 --- a/app/avo/actions/send_admin_magic_link.rb +++ /dev/null @@ -1,13 +0,0 @@ -class Avo::Actions::SendAdminMagicLink < Avo::BaseAction - self.name = "Send Magic Link" - self.message = "Send magic link email to selected admin(s)" - self.confirm_button_label = "Send" - - def handle(query:, fields:, current_user:, resource:, **args) - query.each do |admin| - AdminMailer.magic_link(admin).deliver_later - end - - succeed "Magic link(s) sent successfully!" - end -end diff --git a/app/avo/filters/created_at_filter.rb b/app/avo/filters/created_at_filter.rb deleted file mode 100644 index ef1ae90..0000000 --- a/app/avo/filters/created_at_filter.rb +++ /dev/null @@ -1,15 +0,0 @@ -class Avo::Filters::CreatedAtFilter < Avo::Filters::DateTimeFilter - self.name = "Created at" - self.button_label = "Filter by date" - - def apply(request, query, value) - case value[:mode] - when "range" - query.where(created_at: value[:from]..value[:to]) - when "single" - query.where("DATE(created_at) = ?", value[:at].to_date) - else - query - end - end -end diff --git a/app/avo/filters/provider_filter.rb b/app/avo/filters/provider_filter.rb deleted file mode 100644 index 0958934..0000000 --- a/app/avo/filters/provider_filter.rb +++ /dev/null @@ -1,16 +0,0 @@ -class Avo::Filters::ProviderFilter < Avo::Filters::SelectFilter - self.name = "Provider" - - def apply(request, query, value) - return query if value.blank? - - query.where(provider: value) - end - - def options - { - "openai" => "OpenAI", - "anthropic" => "Anthropic" - } - end -end diff --git a/app/avo/filters/role_filter.rb b/app/avo/filters/role_filter.rb deleted file mode 100644 index 435df0d..0000000 --- a/app/avo/filters/role_filter.rb +++ /dev/null @@ -1,17 +0,0 @@ -class Avo::Filters::RoleFilter < Avo::Filters::SelectFilter - self.name = "Role" - - def apply(request, query, value) - return query if value.blank? - - query.where(role: value) - end - - def options - { - "system" => "System", - "user" => "User", - "assistant" => "Assistant" - } - end -end diff --git a/app/avo/resources/admin.rb b/app/avo/resources/admin.rb deleted file mode 100644 index 2b988d5..0000000 --- a/app/avo/resources/admin.rb +++ /dev/null @@ -1,20 +0,0 @@ -class Avo::Resources::Admin < Avo::BaseResource - self.title = :email - self.includes = [] - - self.search = { - query: -> { query.where("email LIKE ?", "%#{params[:q]}%") } - } - - def fields - field :id, as: :id, readonly: true - field :email, as: :gravatar, link_to_record: true - field :email, as: :text, required: true, help: "Admin email address" - field :created_at, as: :date_time, readonly: true - field :updated_at, as: :date_time, readonly: true - end - - def actions - action Avo::Actions::SendAdminMagicLink - end -end diff --git a/app/avo/resources/chat.rb b/app/avo/resources/chat.rb deleted file mode 100644 index 95895bf..0000000 --- a/app/avo/resources/chat.rb +++ /dev/null @@ -1,24 +0,0 @@ -class Avo::Resources::Chat < Avo::BaseResource - self.title = :id - self.includes = [:user, :model, :messages] - - self.search = { - query: -> { query.joins(:user).where("users.email LIKE ?", "%#{params[:q]}%") } - } - - def fields - field :id, as: :id, readonly: true - - field :user, as: :belongs_to, searchable: true - field :model, as: :belongs_to, searchable: true, help: "AI model used for this chat" - - field :messages, as: :has_many - - field :created_at, as: :date_time, readonly: true - field :updated_at, as: :date_time, readonly: true - end - - def filters - filter Avo::Filters::CreatedAtFilter - end -end diff --git a/app/avo/resources/message.rb b/app/avo/resources/message.rb deleted file mode 100644 index d632a9a..0000000 --- a/app/avo/resources/message.rb +++ /dev/null @@ -1,36 +0,0 @@ -class Avo::Resources::Message < Avo::BaseResource - self.title = :id - self.includes = [:chat, :model, :tool_calls] - - self.search = { - query: -> { query.where("content LIKE ?", "%#{params[:q]}%") } - } - - def fields - field :id, as: :id, readonly: true - - field :chat, as: :belongs_to, searchable: true - field :model, as: :belongs_to, searchable: true, help: "AI model used for this message" - - field :role, as: :select, options: { "system" => "system", "user" => "user", "assistant" => "assistant" }, required: true - field :content, as: :textarea, rows: 5 - - # Token tracking - field :input_tokens, as: :number, readonly: true - field :output_tokens, as: :number, readonly: true - field :cached_tokens, as: :number, readonly: true - field :cache_creation_tokens, as: :number, readonly: true - - field :content_raw, as: :code, language: "json", readonly: true, help: "Full API response" - - field :tool_calls, as: :has_many - - field :created_at, as: :date_time, readonly: true - field :updated_at, as: :date_time, readonly: true - end - - def filters - filter Avo::Filters::RoleFilter - filter Avo::Filters::CreatedAtFilter - end -end diff --git a/app/avo/resources/model.rb b/app/avo/resources/model.rb deleted file mode 100644 index 2583715..0000000 --- a/app/avo/resources/model.rb +++ /dev/null @@ -1,39 +0,0 @@ -class Avo::Resources::Model < Avo::BaseResource - self.title = :name - self.includes = [] - - self.search = { - query: -> { query.where("name LIKE ? OR model_id LIKE ? OR provider LIKE ?", "%#{params[:q]}%", "%#{params[:q]}%", "%#{params[:q]}%") } - } - - def fields - field :id, as: :id, readonly: true - - field :name, as: :text, required: true, sortable: true - field :model_id, as: :text, required: true, help: "Model identifier (e.g., gpt-4, claude-3-5-sonnet)" - field :provider, as: :select, options: { "openai" => "OpenAI", "anthropic" => "Anthropic" }, required: true - - field :family, as: :text, help: "Model family (e.g., GPT-4, Claude 3.5)" - - field :context_window, as: :number, help: "Maximum context window size" - field :max_output_tokens, as: :number, help: "Maximum output tokens" - field :knowledge_cutoff, as: :date, help: "Knowledge cutoff date" - - field :modalities, as: :code, language: "json", help: "Supported modalities (text, image, etc.)" - field :capabilities, as: :code, language: "json", help: "Model capabilities" - field :pricing, as: :code, language: "json", help: "Pricing information" - field :metadata, as: :code, language: "json", help: "Additional metadata" - - field :model_created_at, as: :date_time, readonly: true, help: "When model was released" - field :created_at, as: :date_time, readonly: true - field :updated_at, as: :date_time, readonly: true - end - - def filters - filter Avo::Filters::ProviderFilter - end - - def actions - action Avo::Actions::RefreshModels - end -end diff --git a/app/avo/resources/tool_call.rb b/app/avo/resources/tool_call.rb deleted file mode 100644 index 56004a9..0000000 --- a/app/avo/resources/tool_call.rb +++ /dev/null @@ -1,25 +0,0 @@ -class Avo::Resources::ToolCall < Avo::BaseResource - self.title = :name - self.includes = [:message] - - self.search = { - query: -> { query.where("name LIKE ? OR tool_call_id LIKE ?", "%#{params[:q]}%", "%#{params[:q]}%") } - } - - def fields - field :id, as: :id, readonly: true - - field :message, as: :belongs_to, searchable: true - - field :tool_call_id, as: :text, required: true, help: "Unique tool call identifier" - field :name, as: :text, required: true, help: "Tool/function name" - field :arguments, as: :code, language: "json", help: "Tool arguments" - - field :created_at, as: :date_time, readonly: true - field :updated_at, as: :date_time, readonly: true - end - - def filters - filter Avo::Filters::CreatedAtFilter - end -end diff --git a/app/avo/resources/user.rb b/app/avo/resources/user.rb deleted file mode 100644 index c1cee52..0000000 --- a/app/avo/resources/user.rb +++ /dev/null @@ -1,24 +0,0 @@ -class Avo::Resources::User < Avo::BaseResource - self.title = :email - self.includes = [:chats] - - self.search = { - query: -> { query.where("email LIKE ? OR name LIKE ?", "%#{params[:q]}%", "%#{params[:q]}%") } - } - - def fields - field :id, as: :id, readonly: true - field :email, as: :gravatar, link_to_record: true - field :name, as: :text, required: true - field :email, as: :text, required: true, help: "User email address" - - field :chats, as: :has_many - - field :created_at, as: :date_time, readonly: true - field :updated_at, as: :date_time, readonly: true - end - - def filters - filter Avo::Filters::CreatedAtFilter - end -end diff --git a/app/controllers/admins/dashboard_controller.rb b/app/controllers/admins/dashboard_controller.rb new file mode 100644 index 0000000..0466001 --- /dev/null +++ b/app/controllers/admins/dashboard_controller.rb @@ -0,0 +1,34 @@ +module Admins + class DashboardController < ApplicationController + before_action :authenticate_admin! + + def index + @metrics = { + total_users: User.count, + total_admins: Admin.count, + total_chats: Chat.count, + total_messages: Message.count, + total_tokens: calculate_total_tokens, + total_tool_calls: ToolCall.count, + recent_chats: Chat.where("created_at >= ?", 7.days.ago).count, + recent_messages: Message.where("created_at >= ?", 7.days.ago).count, + recent_users: User.where("created_at >= ?", 7.days.ago).count, + total_models: Model.count + } + + @recent_chats = Chat.includes(:user).order(created_at: :desc).limit(5) + @recent_users = User.order(created_at: :desc).limit(5) + end + + private + + def calculate_total_tokens + Message.sum("COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0) + COALESCE(cached_tokens, 0) + COALESCE(cache_creation_tokens, 0)") + end + + def authenticate_admin! + admin = Admin.find_by(id: session[:admin_id]) if session[:admin_id] + redirect_to new_admins_session_path unless admin + end + end +end diff --git a/app/controllers/admins/sessions_controller.rb b/app/controllers/admins/sessions_controller.rb index d5ac8b7..0c11a92 100644 --- a/app/controllers/admins/sessions_controller.rb +++ b/app/controllers/admins/sessions_controller.rb @@ -23,7 +23,7 @@ def verify admin = Admin.find_signed!(params[:token], purpose: :magic_link) session[:admin_id] = admin.id - redirect_to "/avo", notice: "Welcome back, admin!" + redirect_to "/madmin", notice: "Welcome back, admin!" rescue ActiveSupport::MessageVerifier::InvalidSignature redirect_to new_admins_session_path, alert: "Invalid or expired magic link" end diff --git a/app/controllers/avo/admins_controller.rb b/app/controllers/avo/admins_controller.rb deleted file mode 100644 index d7209ef..0000000 --- a/app/controllers/avo/admins_controller.rb +++ /dev/null @@ -1,4 +0,0 @@ -# This controller has been generated to enable Rails' resource routes. -# More information on https://docs.avohq.io/3.0/controllers.html -class Avo::AdminsController < Avo::ResourcesController -end diff --git a/app/controllers/avo/chats_controller.rb b/app/controllers/avo/chats_controller.rb deleted file mode 100644 index 909de4c..0000000 --- a/app/controllers/avo/chats_controller.rb +++ /dev/null @@ -1,4 +0,0 @@ -# This controller has been generated to enable Rails' resource routes. -# More information on https://docs.avohq.io/3.0/controllers.html -class Avo::ChatsController < Avo::ResourcesController -end diff --git a/app/controllers/avo/messages_controller.rb b/app/controllers/avo/messages_controller.rb deleted file mode 100644 index 7ac0625..0000000 --- a/app/controllers/avo/messages_controller.rb +++ /dev/null @@ -1,4 +0,0 @@ -# This controller has been generated to enable Rails' resource routes. -# More information on https://docs.avohq.io/3.0/controllers.html -class Avo::MessagesController < Avo::ResourcesController -end diff --git a/app/controllers/avo/models_controller.rb b/app/controllers/avo/models_controller.rb deleted file mode 100644 index 868c804..0000000 --- a/app/controllers/avo/models_controller.rb +++ /dev/null @@ -1,4 +0,0 @@ -# This controller has been generated to enable Rails' resource routes. -# More information on https://docs.avohq.io/3.0/controllers.html -class Avo::ModelsController < Avo::ResourcesController -end diff --git a/app/controllers/avo/tool_calls_controller.rb b/app/controllers/avo/tool_calls_controller.rb deleted file mode 100644 index 3494622..0000000 --- a/app/controllers/avo/tool_calls_controller.rb +++ /dev/null @@ -1,4 +0,0 @@ -# This controller has been generated to enable Rails' resource routes. -# More information on https://docs.avohq.io/3.0/controllers.html -class Avo::ToolCallsController < Avo::ResourcesController -end diff --git a/app/controllers/avo/users_controller.rb b/app/controllers/avo/users_controller.rb deleted file mode 100644 index a9987c6..0000000 --- a/app/controllers/avo/users_controller.rb +++ /dev/null @@ -1,4 +0,0 @@ -# This controller has been generated to enable Rails' resource routes. -# More information on https://docs.avohq.io/3.0/controllers.html -class Avo::UsersController < Avo::ResourcesController -end diff --git a/app/controllers/madmin/active_storage/attachments_controller.rb b/app/controllers/madmin/active_storage/attachments_controller.rb new file mode 100644 index 0000000..d950ae6 --- /dev/null +++ b/app/controllers/madmin/active_storage/attachments_controller.rb @@ -0,0 +1,4 @@ +module Madmin + class ActiveStorage::AttachmentsController < Madmin::ResourceController + end +end diff --git a/app/controllers/madmin/active_storage/blobs_controller.rb b/app/controllers/madmin/active_storage/blobs_controller.rb new file mode 100644 index 0000000..fb59018 --- /dev/null +++ b/app/controllers/madmin/active_storage/blobs_controller.rb @@ -0,0 +1,8 @@ +module Madmin + class ActiveStorage::BlobsController < Madmin::ResourceController + def new + super + @record.assign_attributes(filename: "") + end + end +end diff --git a/app/controllers/madmin/active_storage/variant_records_controller.rb b/app/controllers/madmin/active_storage/variant_records_controller.rb new file mode 100644 index 0000000..8798628 --- /dev/null +++ b/app/controllers/madmin/active_storage/variant_records_controller.rb @@ -0,0 +1,4 @@ +module Madmin + class ActiveStorage::VariantRecordsController < Madmin::ResourceController + end +end diff --git a/app/controllers/madmin/admins_controller.rb b/app/controllers/madmin/admins_controller.rb new file mode 100644 index 0000000..34acf90 --- /dev/null +++ b/app/controllers/madmin/admins_controller.rb @@ -0,0 +1,9 @@ +module Madmin + class AdminsController < Madmin::ResourceController + def send_magic_link + @record = Admin.find(params[:id]) + AdminMailer.magic_link(@record).deliver_later + redirect_to madmin_admin_path(@record), notice: "Magic link sent to #{@record.email}!" + end + end +end diff --git a/app/controllers/madmin/application_controller.rb b/app/controllers/madmin/application_controller.rb new file mode 100644 index 0000000..09bf3bd --- /dev/null +++ b/app/controllers/madmin/application_controller.rb @@ -0,0 +1,18 @@ +module Madmin + class ApplicationController < Madmin::BaseController + before_action :authenticate_admin! + + private + + def authenticate_admin! + admin = Admin.find_by(id: session[:admin_id]) if session[:admin_id] + redirect_to main_app.new_admins_session_path, alert: "Please log in as admin" unless admin + end + + helper_method :current_admin + + def current_admin + @current_admin ||= Admin.find_by(id: session[:admin_id]) if session[:admin_id] + end + end +end diff --git a/app/controllers/madmin/chats_controller.rb b/app/controllers/madmin/chats_controller.rb new file mode 100644 index 0000000..9741982 --- /dev/null +++ b/app/controllers/madmin/chats_controller.rb @@ -0,0 +1,21 @@ +module Madmin + class ChatsController < Madmin::ResourceController + def scoped_resources + resources = super.includes(:user, :model, :messages) + + if params[:created_at_from].present? && params[:created_at_to].present? + resources = resources.where(created_at: params[:created_at_from]..params[:created_at_to]) + elsif params[:created_at].present? + date = Date.parse(params[:created_at]) + resources = resources.where("DATE(created_at) = ?", date) + end + + # Custom search by user email + if params[:q].present? + resources = resources.joins(:user).where("users.email LIKE ?", "%#{params[:q]}%") + end + + resources + end + end +end diff --git a/app/controllers/madmin/messages_controller.rb b/app/controllers/madmin/messages_controller.rb new file mode 100644 index 0000000..eb5eb92 --- /dev/null +++ b/app/controllers/madmin/messages_controller.rb @@ -0,0 +1,20 @@ +module Madmin + class MessagesController < Madmin::ResourceController + def scoped_resources + resources = super.includes(:chat, :model, :tool_calls) + + # Role filter + resources = resources.where(role: params[:role]) if params[:role].present? + + # Date filter + if params[:created_at_from].present? && params[:created_at_to].present? + resources = resources.where(created_at: params[:created_at_from]..params[:created_at_to]) + elsif params[:created_at].present? + date = Date.parse(params[:created_at]) + resources = resources.where("DATE(created_at) = ?", date) + end + + resources + end + end +end diff --git a/app/controllers/madmin/models_controller.rb b/app/controllers/madmin/models_controller.rb new file mode 100644 index 0000000..23ced79 --- /dev/null +++ b/app/controllers/madmin/models_controller.rb @@ -0,0 +1,14 @@ +module Madmin + class ModelsController < Madmin::ResourceController + def scoped_resources + resources = super + resources = resources.where(provider: params[:provider]) if params[:provider].present? + resources + end + + def refresh_all + Model.refresh! + redirect_to madmin_models_path, notice: "Models refreshed! Total: #{Model.count}" + end + end +end diff --git a/app/controllers/madmin/tool_calls_controller.rb b/app/controllers/madmin/tool_calls_controller.rb new file mode 100644 index 0000000..3aa2e01 --- /dev/null +++ b/app/controllers/madmin/tool_calls_controller.rb @@ -0,0 +1,16 @@ +module Madmin + class ToolCallsController < Madmin::ResourceController + def scoped_resources + resources = super.includes(:message) + + if params[:created_at_from].present? && params[:created_at_to].present? + resources = resources.where(created_at: params[:created_at_from]..params[:created_at_to]) + elsif params[:created_at].present? + date = Date.parse(params[:created_at]) + resources = resources.where("DATE(created_at) = ?", date) + end + + resources + end + end +end diff --git a/app/controllers/madmin/users_controller.rb b/app/controllers/madmin/users_controller.rb new file mode 100644 index 0000000..794452b --- /dev/null +++ b/app/controllers/madmin/users_controller.rb @@ -0,0 +1,16 @@ +module Madmin + class UsersController < Madmin::ResourceController + def scoped_resources + resources = super + + if params[:created_at_from].present? && params[:created_at_to].present? + resources = resources.where(created_at: params[:created_at_from]..params[:created_at_to]) + elsif params[:created_at].present? + date = Date.parse(params[:created_at]) + resources = resources.where("DATE(created_at) = ?", date) + end + + resources + end + end +end diff --git a/app/madmin/fields/gravatar_field.rb b/app/madmin/fields/gravatar_field.rb new file mode 100644 index 0000000..5614a26 --- /dev/null +++ b/app/madmin/fields/gravatar_field.rb @@ -0,0 +1,8 @@ +class GravatarField < Madmin::Field + def gravatar_url(size: 40) + return nil unless value.present? + + hash = Digest::MD5.hexdigest(value.downcase.strip) + "https://www.gravatar.com/avatar/#{hash}?s=#{size}&d=mp" + end +end diff --git a/app/madmin/fields/json_field.rb b/app/madmin/fields/json_field.rb new file mode 100644 index 0000000..b6bb34a --- /dev/null +++ b/app/madmin/fields/json_field.rb @@ -0,0 +1,12 @@ +class JsonField < Madmin::Field + def formatted_json(record) + val = value(record) + if val.present? + JSON.pretty_generate(val) + else + "{}" + end + rescue JSON::GeneratorError + val.to_s + end +end diff --git a/app/madmin/resources/active_storage/attachment_resource.rb b/app/madmin/resources/active_storage/attachment_resource.rb new file mode 100644 index 0000000..5b4a2b1 --- /dev/null +++ b/app/madmin/resources/active_storage/attachment_resource.rb @@ -0,0 +1,26 @@ +class ActiveStorage::AttachmentResource < Madmin::Resource + # Attributes + attribute :id, form: false + attribute :name + attribute :created_at, form: false + + # Associations + attribute :record + attribute :blob + + # Add scopes to easily filter records + # scope :published + + # Add actions to the resource's show page + # member_action do |record| + # link_to "Do Something", some_path + # end + + # Customize the display name of records in the admin area. + # def self.display_name(record) = record.name + + # Customize the default sort column and direction. + # def self.default_sort_column = "created_at" + # + # def self.default_sort_direction = "desc" +end diff --git a/app/madmin/resources/active_storage/blob_resource.rb b/app/madmin/resources/active_storage/blob_resource.rb new file mode 100644 index 0000000..10d21de --- /dev/null +++ b/app/madmin/resources/active_storage/blob_resource.rb @@ -0,0 +1,35 @@ +class ActiveStorage::BlobResource < Madmin::Resource + # Attributes + attribute :id, form: false + attribute :key + attribute :filename + attribute :content_type + attribute :service_name + attribute :byte_size + attribute :checksum + attribute :created_at, form: false + attribute :analyzed + attribute :identified + attribute :composed + attribute :preview_image, index: false + + # Associations + attribute :attachments + attribute :variant_records + + # Add scopes to easily filter records + # scope :published + + # Add actions to the resource's show page + # member_action do |record| + # link_to "Do Something", some_path + # end + + # Customize the display name of records in the admin area. + # def self.display_name(record) = record.name + + # Customize the default sort column and direction. + # def self.default_sort_column = "created_at" + # + # def self.default_sort_direction = "desc" +end diff --git a/app/madmin/resources/active_storage/variant_record_resource.rb b/app/madmin/resources/active_storage/variant_record_resource.rb new file mode 100644 index 0000000..845985a --- /dev/null +++ b/app/madmin/resources/active_storage/variant_record_resource.rb @@ -0,0 +1,26 @@ +class ActiveStorage::VariantRecordResource < Madmin::Resource + # Attributes + attribute :id, form: false + attribute :variation, index: false, show: false + attribute :variation_confirmation, index: false, show: false + attribute :image, index: false + + # Associations + attribute :blob + + # Add scopes to easily filter records + # scope :published + + # Add actions to the resource's show page + # member_action do |record| + # link_to "Do Something", some_path + # end + + # Customize the display name of records in the admin area. + # def self.display_name(record) = record.name + + # Customize the default sort column and direction. + # def self.default_sort_column = "created_at" + # + # def self.default_sort_direction = "desc" +end diff --git a/app/madmin/resources/admin_resource.rb b/app/madmin/resources/admin_resource.rb new file mode 100644 index 0000000..9ed4c22 --- /dev/null +++ b/app/madmin/resources/admin_resource.rb @@ -0,0 +1,27 @@ +class AdminResource < Madmin::Resource + # Attributes + attribute :id, form: false, index: false + attribute :email, field: GravatarField, index: true, show: true, form: false + attribute :email # Regular field for editing + attribute :created_at, form: false + attribute :updated_at, form: false + + # Associations + + # Member action for sending magic link + member_action do |record| + button_to "Send Magic Link", + send_magic_link_madmin_admin_path(record), + method: :post, + data: { turbo_confirm: "Send magic link to #{record.email}?" }, + class: "btn btn-primary" + end + + def self.searchable_attributes + [:email] + end + + def self.display_name(record) + record.email + end +end diff --git a/app/madmin/resources/chat_resource.rb b/app/madmin/resources/chat_resource.rb new file mode 100644 index 0000000..f7a9d24 --- /dev/null +++ b/app/madmin/resources/chat_resource.rb @@ -0,0 +1,19 @@ +class ChatResource < Madmin::Resource + # Attributes + attribute :id, form: false, index: false + attribute :created_at, form: false + attribute :updated_at, form: false + + # Associations + attribute :user + attribute :model + attribute :messages + + def self.searchable_attributes + [] # Custom search in controller + end + + def self.display_name(record) + "Chat with #{record.user.email}" if record.user + end +end diff --git a/app/madmin/resources/message_resource.rb b/app/madmin/resources/message_resource.rb new file mode 100644 index 0000000..b3fcc54 --- /dev/null +++ b/app/madmin/resources/message_resource.rb @@ -0,0 +1,27 @@ +class MessageResource < Madmin::Resource + # Attributes + attribute :id, form: false, index: false + attribute :role, :select, collection: ["system", "user", "assistant"] + attribute :content, :text + attribute :chat + attribute :model + attribute :input_tokens, form: false + attribute :output_tokens, form: false + attribute :cached_tokens, form: false + attribute :cache_creation_tokens, form: false + attribute :content_raw, field: JsonField, form: false + attribute :tool_calls + attribute :created_at, form: false + attribute :updated_at, form: false + + # Associations + + def self.searchable_attributes + [:content] + end + + def self.display_name(record) + truncated = record.content.to_s.truncate(50) + "#{record.role}: #{truncated}" + end +end diff --git a/app/madmin/resources/model_resource.rb b/app/madmin/resources/model_resource.rb new file mode 100644 index 0000000..6e5e812 --- /dev/null +++ b/app/madmin/resources/model_resource.rb @@ -0,0 +1,28 @@ +class ModelResource < Madmin::Resource + # Attributes + attribute :id, form: false, index: false + attribute :name + attribute :model_id + attribute :provider, :select, collection: ["openai", "anthropic"] + attribute :family + attribute :context_window + attribute :max_output_tokens + attribute :knowledge_cutoff + attribute :modalities, field: JsonField, form: false + attribute :capabilities, field: JsonField, form: false + attribute :pricing, field: JsonField, form: false + attribute :metadata, field: JsonField, form: false + attribute :model_created_at, form: false + attribute :created_at, form: false + attribute :updated_at, form: false + + # Associations + + def self.searchable_attributes + [:name, :model_id, :provider] + end + + def self.display_name(record) + record.name + end +end diff --git a/app/madmin/resources/tool_call_resource.rb b/app/madmin/resources/tool_call_resource.rb new file mode 100644 index 0000000..246915a --- /dev/null +++ b/app/madmin/resources/tool_call_resource.rb @@ -0,0 +1,20 @@ +class ToolCallResource < Madmin::Resource + # Attributes + attribute :id, form: false, index: false + attribute :name + attribute :tool_call_id + attribute :message + attribute :arguments, field: JsonField, form: false + attribute :created_at, form: false + attribute :updated_at, form: false + + # Associations + + def self.searchable_attributes + [:name, :tool_call_id] + end + + def self.display_name(record) + record.name + end +end diff --git a/app/madmin/resources/user_resource.rb b/app/madmin/resources/user_resource.rb new file mode 100644 index 0000000..c5daa6e --- /dev/null +++ b/app/madmin/resources/user_resource.rb @@ -0,0 +1,20 @@ +class UserResource < Madmin::Resource + # Attributes + attribute :id, form: false, index: false + attribute :email, field: GravatarField, index: true, show: true, form: false + attribute :email # Regular field for editing + attribute :name + attribute :created_at, form: false + attribute :updated_at, form: false + + # Associations + attribute :chats + + def self.searchable_attributes + [:email, :name] + end + + def self.display_name(record) + record.name.present? ? record.name : record.email + end +end diff --git a/app/views/admins/dashboard/index.html.erb b/app/views/admins/dashboard/index.html.erb new file mode 100644 index 0000000..6a96913 --- /dev/null +++ b/app/views/admins/dashboard/index.html.erb @@ -0,0 +1,181 @@ +
+
+ +
+

Dashboard

+

Overview of your application data and activity

+
+ <%= link_to "← Back to Avo", avo.root_path, class: "text-blue-600 hover:text-blue-800 font-medium" %> +
+
+ + +
+ +
+
+
+ + + +
+
+

<%= number_with_delimiter(@metrics[:total_users]) %>

+

Total Users

+

+<%= @metrics[:recent_users] %> this week

+
+ + +
+
+
+ + + +
+
+

<%= number_with_delimiter(@metrics[:total_chats]) %>

+

Total Chats

+

+<%= @metrics[:recent_chats] %> this week

+
+ + +
+
+
+ + + +
+
+

<%= number_with_delimiter(@metrics[:total_messages]) %>

+

Total Messages

+

+<%= @metrics[:recent_messages] %> this week

+
+ + +
+
+
+ + + +
+
+

<%= number_with_delimiter(@metrics[:total_tokens]) %>

+

Total AI Tokens

+

All time usage

+
+ + +
+
+
+ + + +
+
+

<%= number_with_delimiter(@metrics[:total_admins]) %>

+

Total Admins

+
+ + +
+
+
+ + + +
+
+

<%= number_with_delimiter(@metrics[:total_models]) %>

+

AI Models

+
+ + +
+
+
+ + + + +
+
+

<%= number_with_delimiter(@metrics[:total_tool_calls]) %>

+

Tool Calls

+
+ + +
+
+ + + +
+

Last 7 Days

+
+

<%= @metrics[:recent_chats] %> chats

+

<%= @metrics[:recent_messages] %> messages

+

<%= @metrics[:recent_users] %> new users

+
+
+
+ + +
+ +
+
+

Recent Chats

+ <%= link_to "View all", avo.resources_chats_path, class: "text-sm text-blue-600 hover:text-blue-800 font-medium" %> +
+
+ <% if @recent_chats.any? %> + <% @recent_chats.each do |chat| %> +
+
+

<%= chat.user.name %>

+

<%= chat.user.email %>

+
+
+

<%= time_ago_in_words(chat.created_at) %> ago

+ <%= link_to "View", avo.resources_chat_path(chat), class: "text-xs text-blue-600 hover:text-blue-800 font-medium" %> +
+
+ <% end %> + <% else %> +

No chats yet

+ <% end %> +
+
+ + +
+
+

Recent Users

+ <%= link_to "View all", avo.resources_users_path, class: "text-sm text-blue-600 hover:text-blue-800 font-medium" %> +
+
+ <% if @recent_users.any? %> + <% @recent_users.each do |user| %> +
+
+

<%= user.name %>

+

<%= user.email %>

+
+
+

<%= time_ago_in_words(user.created_at) %> ago

+ <%= link_to "View", avo.resources_user_path(user), class: "text-xs text-blue-600 hover:text-blue-800 font-medium" %> +
+
+ <% end %> + <% else %> +

No users yet

+ <% end %> +
+
+
+
+
diff --git a/app/views/layouts/madmin/application.html.erb b/app/views/layouts/madmin/application.html.erb new file mode 100644 index 0000000..3e8811c --- /dev/null +++ b/app/views/layouts/madmin/application.html.erb @@ -0,0 +1,41 @@ + + + + + + + + <% if content_for? :title %> + <%= yield(:title) %> - + <% end %> + <%= Madmin.site_name %> Admin + + <%= csrf_meta_tags %> + <%= render "javascript" %> + + +
+ <%= render "flash" %> +
+ + +
+ + +
+ <%= yield %> +
+
+ +
+ + diff --git a/app/views/madmin/admins/show.html.erb b/app/views/madmin/admins/show.html.erb new file mode 100644 index 0000000..857aeeb --- /dev/null +++ b/app/views/madmin/admins/show.html.erb @@ -0,0 +1,38 @@ +<%= content_for :title, resource.display_name(@record) %> + +
+

+ <%= link_to resource.friendly_name.pluralize, resource.index_path %> + / + <%= resource.display_name(@record) %> +

+ +
+ <% resource.member_actions.each do |action| %> + <%= instance_exec(@record, &action) %> + <% end %> + <%= link_to "Edit", resource.edit_path(@record), class: "btn btn-secondary" %> + <%= button_to "Delete", resource.show_path(@record), method: :delete, data: { turbo_confirm: "Are you sure?" }, class: "btn btn-danger" %> +
+
+ +
+ + + <% resource.attributes.values.each do |attribute| %> + <% next if attribute.field.nil? %> + <% next unless attribute.field.visible?(action_name) %> + + + + + + + <% end %> + +
+ <%= attribute.field.options.label || attribute.name.to_s.titleize %> + + <%= render partial: attribute.field.to_partial_path("show"), locals: { field: attribute.field, record: @record, resource: resource } %> +
+
diff --git a/app/views/madmin/application/_javascript.html.erb b/app/views/madmin/application/_javascript.html.erb new file mode 100644 index 0000000..f507e16 --- /dev/null +++ b/app/views/madmin/application/_javascript.html.erb @@ -0,0 +1,8 @@ +<%= javascript_importmap_tags "application", importmap: Madmin.importmap %> + +<%= tag.script 'import "trix"'.html_safe, type: "module" if defined?(::Trix) || Rails.gem_version < Gem::Version.new("8.1.0.beta1") %> +<%= tag.script 'import "lexxy"'.html_safe, type: "module" if defined?(::Lexxy) %> + +<%= stylesheet_link_tag *Madmin.stylesheets, "data-turbo-track": "reload" %> +<%= stylesheet_link_tag "https://unpkg.com/flatpickr/dist/flatpickr.min.css", "data-turbo-track": "reload" %> +<%= stylesheet_link_tag "https://unpkg.com/tom-select/dist/css/tom-select.min.css", "data-turbo-track": "reload" %> diff --git a/app/views/madmin/application/_navigation.html.erb b/app/views/madmin/application/_navigation.html.erb new file mode 100644 index 0000000..59b1d40 --- /dev/null +++ b/app/views/madmin/application/_navigation.html.erb @@ -0,0 +1,30 @@ + + +
+
+ <%= button_to "Sign Out", + main_app.admins_session_path, + method: :delete, + class: "btn btn-sm", + data: { turbo_confirm: "Sign out from admin panel?" } %> +
+ + <%= link_to "https://github.com/excid3/madmin", target: :_blank do %> + + Madmin on GitHub + <% end %> +
diff --git a/app/views/madmin/chats/index.html.erb b/app/views/madmin/chats/index.html.erb new file mode 100644 index 0000000..f70786f --- /dev/null +++ b/app/views/madmin/chats/index.html.erb @@ -0,0 +1,101 @@ +<%= content_for :title, resource.friendly_name.pluralize %> + +
+

<%= resource.friendly_name.pluralize %>

+ +
+ + + <%= link_to clear_search_params, class: "btn btn-secondary" do %> + + + + <% end if params[:q].present? %> + + <%= link_to resource.new_path, class: "btn btn-secondary" do %> + New <%= tag.span resource.friendly_name, class: "hidden md:inline-block" %> + <% end %> +
+
+ +
+ <%= form_with url: madmin_chats_path, method: :get, class: "flex gap-2 items-end flex-wrap" do |f| %> +
+ <%= f.label :created_at_from, "From:", class: "block text-sm font-medium text-gray-700" %> + <%= f.date_field :created_at_from, value: params[:created_at_from], class: "form-input" %> +
+ +
+ <%= f.label :created_at_to, "To:", class: "block text-sm font-medium text-gray-700" %> + <%= f.date_field :created_at_to, value: params[:created_at_to], class: "form-input" %> +
+ + <%= f.submit "Filter", class: "btn btn-primary" %> + <%= link_to "Clear", madmin_chats_path, class: "btn" %> + <% end %> +
+ + + +
+ + + + <% resource.attributes.values.each do |attribute| %> + <% next if attribute.field.nil? %> + <% next unless attribute.field.visible?(action_name) %> + + + <% end %> + + + + + + <% @records.each do |record| %> + + <% first_field = true %> + <% resource.attributes.values.each do |attribute| %> + <% next if attribute.field.nil? %> + <% next unless attribute.field.visible?(action_name) %> + + <% end %> + + + + <% end %> + +
<%= sortable attribute.name, attribute.name.to_s.titleize %>
+ <% if first_field %> + <%= link_to resource.show_path(record) do %> + <%= render partial: attribute.field.to_partial_path("index"), locals: { field: attribute.field, record: record, resource: resource } %> + <% end %> + <% first_field = false %> + <% else %> + <%= render partial: attribute.field.to_partial_path("index"), locals: { field: attribute.field, record: record, resource: resource } %> + <% end %> + + <%= link_to "Edit", resource.edit_path(record) %> +
+
+ + diff --git a/app/views/madmin/fields/gravatar_field/_form.html.erb b/app/views/madmin/fields/gravatar_field/_form.html.erb new file mode 100644 index 0000000..8a0f0af --- /dev/null +++ b/app/views/madmin/fields/gravatar_field/_form.html.erb @@ -0,0 +1 @@ +<%= form.hidden_field field.attribute_name %> diff --git a/app/views/madmin/fields/gravatar_field/_index.html.erb b/app/views/madmin/fields/gravatar_field/_index.html.erb new file mode 100644 index 0000000..133db4a --- /dev/null +++ b/app/views/madmin/fields/gravatar_field/_index.html.erb @@ -0,0 +1,8 @@ +
+ <%= field.value %> + <%= field.value %> +
diff --git a/app/views/madmin/fields/gravatar_field/_show.html.erb b/app/views/madmin/fields/gravatar_field/_show.html.erb new file mode 100644 index 0000000..f7ace2a --- /dev/null +++ b/app/views/madmin/fields/gravatar_field/_show.html.erb @@ -0,0 +1,8 @@ +
+ <%= field.value %> + <%= field.value %> +
diff --git a/app/views/madmin/fields/json_field/_form.html.erb b/app/views/madmin/fields/json_field/_form.html.erb new file mode 100644 index 0000000..6a5c581 --- /dev/null +++ b/app/views/madmin/fields/json_field/_form.html.erb @@ -0,0 +1,7 @@ +<% val = field.value(record) %> +<%= form.text_area field.attribute_name, + value: val ? JSON.pretty_generate(val) : "{}", + rows: 10, + class: "form-control font-mono text-sm", + placeholder: "Enter valid JSON" %> +

Enter valid JSON format

diff --git a/app/views/madmin/fields/json_field/_index.html.erb b/app/views/madmin/fields/json_field/_index.html.erb new file mode 100644 index 0000000..39fa97a --- /dev/null +++ b/app/views/madmin/fields/json_field/_index.html.erb @@ -0,0 +1,5 @@ +<% if field.value(record).present? %> + <%= truncate(field.formatted_json(record), length: 50) %> +<% else %> + +<% end %> diff --git a/app/views/madmin/fields/json_field/_show.html.erb b/app/views/madmin/fields/json_field/_show.html.erb new file mode 100644 index 0000000..0bd25da --- /dev/null +++ b/app/views/madmin/fields/json_field/_show.html.erb @@ -0,0 +1 @@ +
<%= field.formatted_json(record) %>
diff --git a/app/views/madmin/messages/index.html.erb b/app/views/madmin/messages/index.html.erb new file mode 100644 index 0000000..51b2672 --- /dev/null +++ b/app/views/madmin/messages/index.html.erb @@ -0,0 +1,109 @@ +<%= content_for :title, resource.friendly_name.pluralize %> + +
+

<%= resource.friendly_name.pluralize %>

+ +
+ + + <%= link_to clear_search_params, class: "btn btn-secondary" do %> + + + + <% end if params[:q].present? %> + + <%= link_to resource.new_path, class: "btn btn-secondary" do %> + New <%= tag.span resource.friendly_name, class: "hidden md:inline-block" %> + <% end %> +
+
+ +
+ <%= form_with url: madmin_messages_path, method: :get, class: "flex gap-2 items-end flex-wrap" do |f| %> +
+ <%= f.label :role, class: "block text-sm font-medium text-gray-700" %> + <%= f.select :role, + options_for_select([["All", ""], ["System", "system"], ["User", "user"], ["Assistant", "assistant"]], params[:role]), + {}, + class: "form-select" %> +
+ +
+ <%= f.label :created_at_from, "From:", class: "block text-sm font-medium text-gray-700" %> + <%= f.date_field :created_at_from, value: params[:created_at_from], class: "form-input" %> +
+ +
+ <%= f.label :created_at_to, "To:", class: "block text-sm font-medium text-gray-700" %> + <%= f.date_field :created_at_to, value: params[:created_at_to], class: "form-input" %> +
+ + <%= f.submit "Filter", class: "btn btn-primary" %> + <%= link_to "Clear", madmin_messages_path, class: "btn" %> + <% end %> +
+ + + +
+ + + + <% resource.attributes.values.each do |attribute| %> + <% next if attribute.field.nil? %> + <% next unless attribute.field.visible?(action_name) %> + + + <% end %> + + + + + + <% @records.each do |record| %> + + <% first_field = true %> + <% resource.attributes.values.each do |attribute| %> + <% next if attribute.field.nil? %> + <% next unless attribute.field.visible?(action_name) %> + + <% end %> + + + + <% end %> + +
<%= sortable attribute.name, attribute.name.to_s.titleize %>
+ <% if first_field %> + <%= link_to resource.show_path(record) do %> + <%= render partial: attribute.field.to_partial_path("index"), locals: { field: attribute.field, record: record, resource: resource } %> + <% end %> + <% first_field = false %> + <% else %> + <%= render partial: attribute.field.to_partial_path("index"), locals: { field: attribute.field, record: record, resource: resource } %> + <% end %> + + <%= link_to "Edit", resource.edit_path(record) %> +
+
+ + diff --git a/app/views/madmin/models/index.html.erb b/app/views/madmin/models/index.html.erb new file mode 100644 index 0000000..483f071 --- /dev/null +++ b/app/views/madmin/models/index.html.erb @@ -0,0 +1,105 @@ +<%= content_for :title, resource.friendly_name.pluralize %> + +
+

<%= resource.friendly_name.pluralize %>

+ +
+ <%= button_to "Refresh Models from RubyLLM", + refresh_all_madmin_models_path, + method: :post, + class: "btn btn-primary", + data: { turbo_confirm: "Refresh all models from RubyLLM?" } %> + + + + <%= link_to clear_search_params, class: "btn btn-secondary" do %> + + + + <% end if params[:q].present? %> + + <%= link_to resource.new_path, class: "btn btn-secondary" do %> + New <%= tag.span resource.friendly_name, class: "hidden md:inline-block" %> + <% end %> +
+
+ +
+ <%= form_with url: madmin_models_path, method: :get, class: "flex gap-2 items-end flex-wrap" do |f| %> +
+ <%= f.label :provider, class: "block text-sm font-medium text-gray-700" %> + <%= f.select :provider, + options_for_select([["All", ""], ["OpenAI", "openai"], ["Anthropic", "anthropic"]], params[:provider]), + {}, + class: "form-select" %> +
+ + <%= f.submit "Filter", class: "btn btn-primary" %> + <%= link_to "Clear", madmin_models_path, class: "btn" %> + <% end %> +
+ + + +
+ + + + <% resource.attributes.values.each do |attribute| %> + <% next if attribute.field.nil? %> + <% next unless attribute.field.visible?(action_name) %> + + + <% end %> + + + + + + <% @records.each do |record| %> + + <% first_field = true %> + <% resource.attributes.values.each do |attribute| %> + <% next if attribute.field.nil? %> + <% next unless attribute.field.visible?(action_name) %> + + <% end %> + + + + <% end %> + +
<%= sortable attribute.name, attribute.name.to_s.titleize %>
+ <% if first_field %> + <%= link_to resource.show_path(record) do %> + <%= render partial: attribute.field.to_partial_path("index"), locals: { field: attribute.field, record: record, resource: resource } %> + <% end %> + <% first_field = false %> + <% else %> + <%= render partial: attribute.field.to_partial_path("index"), locals: { field: attribute.field, record: record, resource: resource } %> + <% end %> + + <%= link_to "Edit", resource.edit_path(record) %> +
+
+ + diff --git a/app/views/madmin/tool_calls/index.html.erb b/app/views/madmin/tool_calls/index.html.erb new file mode 100644 index 0000000..64ae54c --- /dev/null +++ b/app/views/madmin/tool_calls/index.html.erb @@ -0,0 +1,101 @@ +<%= content_for :title, resource.friendly_name.pluralize %> + +
+

<%= resource.friendly_name.pluralize %>

+ +
+ + + <%= link_to clear_search_params, class: "btn btn-secondary" do %> + + + + <% end if params[:q].present? %> + + <%= link_to resource.new_path, class: "btn btn-secondary" do %> + New <%= tag.span resource.friendly_name, class: "hidden md:inline-block" %> + <% end %> +
+
+ +
+ <%= form_with url: madmin_tool_calls_path, method: :get, class: "flex gap-2 items-end flex-wrap" do |f| %> +
+ <%= f.label :created_at_from, "From:", class: "block text-sm font-medium text-gray-700" %> + <%= f.date_field :created_at_from, value: params[:created_at_from], class: "form-input" %> +
+ +
+ <%= f.label :created_at_to, "To:", class: "block text-sm font-medium text-gray-700" %> + <%= f.date_field :created_at_to, value: params[:created_at_to], class: "form-input" %> +
+ + <%= f.submit "Filter", class: "btn btn-primary" %> + <%= link_to "Clear", madmin_tool_calls_path, class: "btn" %> + <% end %> +
+ + + +
+ + + + <% resource.attributes.values.each do |attribute| %> + <% next if attribute.field.nil? %> + <% next unless attribute.field.visible?(action_name) %> + + + <% end %> + + + + + + <% @records.each do |record| %> + + <% first_field = true %> + <% resource.attributes.values.each do |attribute| %> + <% next if attribute.field.nil? %> + <% next unless attribute.field.visible?(action_name) %> + + <% end %> + + + + <% end %> + +
<%= sortable attribute.name, attribute.name.to_s.titleize %>
+ <% if first_field %> + <%= link_to resource.show_path(record) do %> + <%= render partial: attribute.field.to_partial_path("index"), locals: { field: attribute.field, record: record, resource: resource } %> + <% end %> + <% first_field = false %> + <% else %> + <%= render partial: attribute.field.to_partial_path("index"), locals: { field: attribute.field, record: record, resource: resource } %> + <% end %> + + <%= link_to "Edit", resource.edit_path(record) %> +
+
+ + diff --git a/app/views/madmin/users/index.html.erb b/app/views/madmin/users/index.html.erb new file mode 100644 index 0000000..10c51fa --- /dev/null +++ b/app/views/madmin/users/index.html.erb @@ -0,0 +1,101 @@ +<%= content_for :title, resource.friendly_name.pluralize %> + +
+

<%= resource.friendly_name.pluralize %>

+ +
+ + + <%= link_to clear_search_params, class: "btn btn-secondary" do %> + + + + <% end if params[:q].present? %> + + <%= link_to resource.new_path, class: "btn btn-secondary" do %> + New <%= tag.span resource.friendly_name, class: "hidden md:inline-block" %> + <% end %> +
+
+ +
+ <%= form_with url: madmin_users_path, method: :get, class: "flex gap-2 items-end flex-wrap" do |f| %> +
+ <%= f.label :created_at_from, "From:", class: "block text-sm font-medium text-gray-700" %> + <%= f.date_field :created_at_from, value: params[:created_at_from], class: "form-input" %> +
+ +
+ <%= f.label :created_at_to, "To:", class: "block text-sm font-medium text-gray-700" %> + <%= f.date_field :created_at_to, value: params[:created_at_to], class: "form-input" %> +
+ + <%= f.submit "Filter", class: "btn btn-primary" %> + <%= link_to "Clear", madmin_users_path, class: "btn" %> + <% end %> +
+ + + +
+ + + + <% resource.attributes.values.each do |attribute| %> + <% next if attribute.field.nil? %> + <% next unless attribute.field.visible?(action_name) %> + + + <% end %> + + + + + + <% @records.each do |record| %> + + <% first_field = true %> + <% resource.attributes.values.each do |attribute| %> + <% next if attribute.field.nil? %> + <% next unless attribute.field.visible?(action_name) %> + + <% end %> + + + + <% end %> + +
<%= sortable attribute.name, attribute.name.to_s.titleize %>
+ <% if first_field %> + <%= link_to resource.show_path(record) do %> + <%= render partial: attribute.field.to_partial_path("index"), locals: { field: attribute.field, record: record, resource: resource } %> + <% end %> + <% first_field = false %> + <% else %> + <%= render partial: attribute.field.to_partial_path("index"), locals: { field: attribute.field, record: record, resource: resource } %> + <% end %> + + <%= link_to "Edit", resource.edit_path(record) %> +
+
+ + diff --git a/config/initializers/avo.rb b/config/initializers/avo.rb deleted file mode 100644 index 4223910..0000000 --- a/config/initializers/avo.rb +++ /dev/null @@ -1,177 +0,0 @@ -# For more information regarding these settings check out our docs https://docs.avohq.io -# The values disaplayed here are the default ones. Uncomment and change them to fit your needs. -Avo.configure do |config| - ## == Routing == - config.root_path = "/avo" - # used only when you have custom `map` configuration in your config.ru - # config.prefix_path = "/internal" - - # Sometimes you might want to mount Avo's engines yourself. - # https://docs.avohq.io/3.0/routing.html - # config.mount_avo_engines = true - - # Where should the user be redirected when visiting the `/avo` url - # config.home_path = nil - - ## == Licensing == - # config.license_key = ENV['AVO_LICENSE_KEY'] - - ## == Set the context == - config.set_context do - # Return a context object that gets evaluated within Avo::ApplicationController - end - - ## == Authentication == - config.current_user_method do - # Access current_admin from the main application controller's session - Admin.find_by(id: session[:admin_id]) if session[:admin_id] - end - - config.authenticate_with do - # Check if admin is authenticated - admin = Admin.find_by(id: session[:admin_id]) if session[:admin_id] - - unless admin - # Redirect to admin login page - redirect_to main_app.new_admins_session_path - end - end - - ## == Sign out == - config.sign_out_path_name = :admins_session_path - - ## == Authorization == - # config.is_admin_method = :is_admin - # config.is_developer_method = :is_developer - # config.authorization_methods = { - # index: 'index?', - # show: 'show?', - # edit: 'edit?', - # new: 'new?', - # update: 'update?', - # create: 'create?', - # destroy: 'destroy?', - # search: 'search?', - # } - # config.raise_error_on_missing_policy = false - config.authorization_client = nil - config.explicit_authorization = true - - ## == Localization == - # config.locale = 'en-US' - - ## == Resource options == - # config.resource_controls_placement = :right - # config.model_resource_mapping = {} - # config.default_view_type = :table - # config.per_page = 24 - # config.per_page_steps = [12, 24, 48, 72] - # config.via_per_page = 8 - # config.id_links_to_resource = false - # config.pagination = -> do - # { - # type: :default, - # size: 9, # `[1, 2, 2, 1]` for pagy < 9.0 - # } - # end - - ## == Response messages dismiss time == - # config.alert_dismiss_time = 5000 - - - ## == Number of search results to display == - # config.search_results_count = 8 - - ## == Associations lookup list limit == - # config.associations_lookup_list_limit = 1000 - - ## == Cache options == - ## Provide a lambda to customize the cache store used by Avo. - ## We compute the cache store by default, this is NOT the default, just an example. - # config.cache_store = -> { - # ActiveSupport::Cache.lookup_store(:solid_cache_store) - # } - # config.cache_resources_on_index_view = true - ## permanent enable or disable cache_resource_filters, default value is false - # config.cache_resource_filters = false - ## provide a lambda to enable or disable cache_resource_filters per user/resource. - # config.cache_resource_filters = -> { current_user.cache_resource_filters? } - - ## == Turbo options == - # config.turbo = -> do - # { - # instant_click: true - # } - # end - - ## == Logger == - # config.logger = -> { - # file_logger = ActiveSupport::Logger.new(Rails.root.join("log", "avo.log")) - # - # file_logger.datetime_format = "%Y-%m-%d %H:%M:%S" - # file_logger.formatter = proc do |severity, time, progname, msg| - # "[Avo] #{time}: #{msg}\n".tap do |i| - # puts i - # end - # end - # - # file_logger - # } - - ## == Customization == - config.click_row_to_view_record = true - # config.app_name = 'Avocadelicious' - # config.timezone = 'UTC' - # config.currency = 'USD' - # config.hide_layout_when_printing = false - # config.full_width_container = false - # config.full_width_index_view = false - # config.search_debounce = 300 - # config.view_component_path = "app/components" - # config.display_license_request_timeout_error = true - # config.disabled_features = [] - # config.buttons_on_form_footers = true - # config.field_wrapper_layout = true - # config.resource_parent_controller = "Avo::ResourcesController" - # config.first_sorting_option = :desc # :desc or :asc - - ## == Branding == - # config.branding = { - # colors: { - # background: "248 246 242", - # 100 => "#CEE7F8", - # 400 => "#399EE5", - # 500 => "#0886DE", - # 600 => "#066BB2", - # }, - # chart_colors: ["#0B8AE2", "#34C683", "#2AB1EE", "#34C6A8"], - # logo: "/avo-assets/logo.png", - # logomark: "/avo-assets/logomark.png", - # placeholder: "/avo-assets/placeholder.svg", - # favicon: "/avo-assets/favicon.ico" - # } - - ## == Breadcrumbs == - # config.display_breadcrumbs = true - # config.set_initial_breadcrumbs do - # add_breadcrumb "Home", '/avo' - # end - - ## == Menus == - # config.main_menu = -> { - # section "Dashboards", icon: "avo/dashboards" do - # all_dashboards - # end - - # section "Resources", icon: "avo/resources" do - # all_resources - # end - - # section "Tools", icon: "avo/tools" do - # all_tools - # end - # } - # config.profile_menu = -> { - # link "Profile", path: "/avo/profile", icon: "heroicons/outline/user-circle" - # } -end diff --git a/config/routes.rb b/config/routes.rb index efb6688..b5f6063 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,5 @@ Rails.application.routes.draw do + draw :madmin resources :chats do resources :messages, only: [:create] end @@ -14,15 +15,15 @@ resource :session, only: [ :new, :create, :destroy ] get "auth/:token", to: "sessions#verify", as: :verify_magic_link - # Admin authentication (admin management happens in Avo) + # Admin authentication (admin management happens in Madmin) namespace :admins do resource :session, only: [ :new, :create, :destroy ] get "auth/:token", to: "sessions#verify", as: :verify_magic_link end - # Avo admin panel (requires admin authentication) - # Access at /avo - all admin management happens here - mount Avo::Engine, at: Avo.configuration.root_path + # Madmin admin panel (requires admin authentication) + # Access at /madmin - all admin management happens here + # Routes defined in config/routes/madmin.rb # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html diff --git a/config/routes/madmin.rb b/config/routes/madmin.rb new file mode 100644 index 0000000..f7b3e57 --- /dev/null +++ b/config/routes/madmin.rb @@ -0,0 +1,27 @@ +# Below are the routes for madmin +namespace :madmin do + namespace :active_storage do + resources :variant_records + end + namespace :active_storage do + resources :attachments + end + namespace :active_storage do + resources :blobs + end + resources :admins do + member do + post :send_magic_link + end + end + resources :chats + resources :messages + resources :models do + collection do + post :refresh_all + end + end + resources :tool_calls + resources :users + root to: "dashboard#show" +end From 6f9caa1fcfc941300f9fe50b4a4334037a7bf699 Mon Sep 17 00:00:00 2001 From: Yuri Sidorov <403994+newstler@users.noreply.github.com> Date: Tue, 13 Jan 2026 17:43:10 +0100 Subject: [PATCH 007/106] Style chats --- CLAUDE.md | 28 ++ Gemfile | 7 + Gemfile.lock | 17 + app/assets/images/icons/arrow-up.svg | 3 + app/assets/images/icons/chart.svg | 3 + app/assets/images/icons/chat.svg | 3 + app/assets/images/icons/chevron-left.svg | 3 + app/assets/images/icons/cog.svg | 4 + app/assets/images/icons/computer.svg | 3 + app/assets/images/icons/lightning.svg | 3 + app/assets/images/icons/messages.svg | 3 + app/assets/images/icons/plus.svg | 3 + app/assets/images/icons/shield.svg | 3 + app/assets/images/icons/users.svg | 3 + app/assets/stylesheets/application.css | 134 +++++++- app/controllers/admins/sessions_controller.rb | 2 +- .../madmin/application_controller.rb | 1 + .../dashboard_controller.rb | 31 +- app/helpers/application_helper.rb | 40 +++ app/helpers/madmin/application_helper.rb | 46 +++ .../controllers/chat_input_controller.js | 24 ++ .../controllers/scroll_bottom_controller.js | 29 ++ .../active_storage/attachment_resource.rb | 3 + .../resources/active_storage/blob_resource.rb | 3 + .../active_storage/variant_record_resource.rb | 3 + app/models/message.rb | 17 +- app/views/admins/dashboard/index.html.erb | 181 ----------- app/views/chats/_form.html.erb | 27 +- app/views/chats/index.html.erb | 73 ++++- app/views/chats/new.html.erb | 29 +- app/views/chats/show.html.erb | 35 +- app/views/layouts/application.html.erb | 28 +- app/views/layouts/madmin/application.html.erb | 5 +- .../madmin/application/_navigation.html.erb | 2 +- app/views/madmin/chats/index.html.erb | 4 +- app/views/madmin/chats/show.html.erb | 76 +++++ app/views/madmin/dashboard/show.html.erb | 300 ++++++++++++++++++ app/views/madmin/messages/index.html.erb | 4 +- app/views/madmin/messages/show.html.erb | 84 +++++ app/views/messages/_form.html.erb | 41 ++- app/views/messages/_message.html.erb | 27 +- app/views/messages/_tool_calls.html.erb | 8 +- app/views/messages/create.turbo_stream.erb | 6 - bin/configure | 2 +- config/credentials/development.yml.enc | 2 +- config/environments/development.rb | 8 + config/initializers/markdown.rb | 1 + 47 files changed, 1063 insertions(+), 299 deletions(-) create mode 100644 app/assets/images/icons/arrow-up.svg create mode 100644 app/assets/images/icons/chart.svg create mode 100644 app/assets/images/icons/chat.svg create mode 100644 app/assets/images/icons/chevron-left.svg create mode 100644 app/assets/images/icons/cog.svg create mode 100644 app/assets/images/icons/computer.svg create mode 100644 app/assets/images/icons/lightning.svg create mode 100644 app/assets/images/icons/messages.svg create mode 100644 app/assets/images/icons/plus.svg create mode 100644 app/assets/images/icons/shield.svg create mode 100644 app/assets/images/icons/users.svg rename app/controllers/{admins => madmin}/dashboard_controller.rb (53%) create mode 100644 app/helpers/madmin/application_helper.rb create mode 100644 app/javascript/controllers/chat_input_controller.js create mode 100644 app/javascript/controllers/scroll_bottom_controller.js delete mode 100644 app/views/admins/dashboard/index.html.erb create mode 100644 app/views/madmin/chats/show.html.erb create mode 100644 app/views/madmin/dashboard/show.html.erb create mode 100644 app/views/madmin/messages/show.html.erb create mode 100644 config/initializers/markdown.rb diff --git a/CLAUDE.md b/CLAUDE.md index 705f32a..b97a441 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -299,6 +299,7 @@ end - Create separate admin controllers/views outside Madmin (use Madmin resources/controllers) - Mix user and admin interfaces (keep completely separate) - Link to admin interface from user interface +- Write inline SVG in ERB files (use inline_svg gem with .svg files) ### Code Style - RuboCop with auto-fix via lefthook @@ -307,6 +308,33 @@ end - Concerns for shared behavior - CRUD resources for everything +### Icons and SVG +**STRICT RULE:** Never write inline SVG code directly in ERB files. + +Always use the `inline_svg` gem with separate `.svg` files: + +```ruby +# Good ✓ +<%= inline_svg "icons/users.svg", class: "w-6 h-6 text-blue-600" %> + +# Bad ✗ + + + +``` + +**Icon organization:** +- Store icons in `app/assets/images/icons/` (Rails asset pipeline convention) +- Use semantic names: `users.svg`, `chat.svg`, `settings.svg` +- Keep SVG files clean and minimal (viewBox, paths only) +- Icons inherit color via `currentColor` and `stroke="currentColor"` + +**Benefits:** +- Easy to update icons across the app +- Icons can be reused everywhere +- Cleaner ERB templates +- Better maintainability + ## Credentials This project uses **environment-specific** Rails encrypted credentials. No environment variables are used. diff --git a/Gemfile b/Gemfile index a01326b..b0f2d35 100644 --- a/Gemfile +++ b/Gemfile @@ -69,4 +69,11 @@ gem "madmin", "~> 2.1" gem "ruby_llm", "~> 1.9" +gem "inline_svg" + +gem "redcarpet" +gem "rouge" + gem "fast-mcp", "~> 1.6" + +gem "tidewave", "~> 0.4.1", group: :development diff --git a/Gemfile.lock b/Gemfile.lock index daa9da2..7ff4346 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -198,6 +198,9 @@ GEM actionpack (>= 6.0.0) activesupport (>= 6.0.0) railties (>= 6.0.0) + inline_svg (1.10.0) + activesupport (>= 3.0) + nokogiri (>= 1.6) io-console (0.8.2) irb (1.16.0) pp (>= 0.6.0) @@ -328,10 +331,12 @@ GEM erb psych (>= 4.0.0) tsort + redcarpet (3.6.1) regexp_parser (2.11.3) reline (0.6.3) io-console (~> 0.5) rexml (3.4.4) + rouge (4.7.0) rubocop (1.82.1) json (~> 2.3) language_server-protocol (~> 3.17.0.2) @@ -420,6 +425,10 @@ GEM thor (1.5.0) thruster (0.1.17-arm64-darwin) thruster (0.1.17-x86_64-linux) + tidewave (0.4.1) + fast-mcp (~> 1.6.0) + rack (>= 2.0) + rails (>= 7.1.0) timeout (0.6.0) tsort (0.2.0) turbo-rails (2.0.20) @@ -459,6 +468,7 @@ DEPENDENCIES debug fast-mcp (~> 1.6) importmap-rails + inline_svg jbuilder kamal litestream @@ -466,6 +476,8 @@ DEPENDENCIES propshaft puma (>= 5.0) rails! + redcarpet + rouge rubocop-rails-omakase ruby_llm (~> 1.9) selenium-webdriver @@ -477,6 +489,7 @@ DEPENDENCIES stimulus-rails tailwindcss-rails (~> 4.0) thruster + tidewave (~> 0.4.1) turbo-rails web-console @@ -531,6 +544,7 @@ CHECKSUMS globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11 i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5 importmap-rails (2.2.2) sha256=729f5b1092f832780829ade1d0b46c7e53d91c556f06da7254da2977e93fe614 + inline_svg (1.10.0) sha256=5b652934236fd9f8adc61f3fd6e208b7ca3282698b19f28659971da84bf9a10f io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc irb (1.16.0) sha256=2abe56c9ac947cdcb2f150572904ba798c1e93c890c256f8429981a7675b0806 jbuilder (2.14.1) sha256=4eb26376ff60ef100cb4fd6fd7533cd271f9998327e86adf20fd8c0e69fabb42 @@ -588,9 +602,11 @@ CHECKSUMS rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c rdoc (7.0.3) sha256=dfe3d0981d19b7bba71d9dbaeb57c9f4e3a7a4103162148a559c4fc687ea81f9 + redcarpet (3.6.1) sha256=d444910e6aa55480c6bcdc0cdb057626e8a32c054c29e793fa642ba2f155f445 regexp_parser (2.11.3) sha256=ca13f381a173b7a93450e53459075c9b76a10433caadcb2f1180f2c741fc55a4 reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835 rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 + rouge (4.7.0) sha256=dba5896715c0325c362e895460a6d350803dbf6427454f49a47500f3193ea739 rubocop (1.82.1) sha256=09f1a6a654a960eda767aebea33e47603080f8e9c9a3f019bf9b94c9cab5e273 rubocop-ast (1.49.0) sha256=49c3676d3123a0923d333e20c6c2dbaaae2d2287b475273fddee0c61da9f71fd rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834 @@ -620,6 +636,7 @@ CHECKSUMS thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 thruster (0.1.17-arm64-darwin) sha256=75da66fc4a0f012f9a317f6362f786a3fa953879a3fa6bed8deeaebf1c1d66ec thruster (0.1.17-x86_64-linux) sha256=77b8f335075bd4ece7631dc84a19a710a1e6e7102cbce147b165b45851bdfcd3 + tidewave (0.4.1) sha256=e33e0b5bd8678825fa00f2703ca64754d910996682f78b3420499068bc123258 timeout (0.6.0) sha256=6d722ad619f96ee383a0c557ec6eb8c4ecb08af3af62098a0be5057bf00de1af tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f turbo-rails (2.0.20) sha256=cbcbb4dd3ce59f6471c9f911b1655b2c721998cc8303959d982da347f374ea95 diff --git a/app/assets/images/icons/arrow-up.svg b/app/assets/images/icons/arrow-up.svg new file mode 100644 index 0000000..00eed24 --- /dev/null +++ b/app/assets/images/icons/arrow-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/icons/chart.svg b/app/assets/images/icons/chart.svg new file mode 100644 index 0000000..156d57e --- /dev/null +++ b/app/assets/images/icons/chart.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/icons/chat.svg b/app/assets/images/icons/chat.svg new file mode 100644 index 0000000..74f7a43 --- /dev/null +++ b/app/assets/images/icons/chat.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/icons/chevron-left.svg b/app/assets/images/icons/chevron-left.svg new file mode 100644 index 0000000..98100ed --- /dev/null +++ b/app/assets/images/icons/chevron-left.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/icons/cog.svg b/app/assets/images/icons/cog.svg new file mode 100644 index 0000000..0fb9a8a --- /dev/null +++ b/app/assets/images/icons/cog.svg @@ -0,0 +1,4 @@ + + + + diff --git a/app/assets/images/icons/computer.svg b/app/assets/images/icons/computer.svg new file mode 100644 index 0000000..1ae3485 --- /dev/null +++ b/app/assets/images/icons/computer.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/icons/lightning.svg b/app/assets/images/icons/lightning.svg new file mode 100644 index 0000000..f8aa050 --- /dev/null +++ b/app/assets/images/icons/lightning.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/icons/messages.svg b/app/assets/images/icons/messages.svg new file mode 100644 index 0000000..29cfeb7 --- /dev/null +++ b/app/assets/images/icons/messages.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/icons/plus.svg b/app/assets/images/icons/plus.svg new file mode 100644 index 0000000..91822b8 --- /dev/null +++ b/app/assets/images/icons/plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/icons/shield.svg b/app/assets/images/icons/shield.svg new file mode 100644 index 0000000..60b7935 --- /dev/null +++ b/app/assets/images/icons/shield.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/images/icons/users.svg b/app/assets/images/icons/users.svg new file mode 100644 index 0000000..91a336b --- /dev/null +++ b/app/assets/images/icons/users.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index fe93333..7207338 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -1,10 +1,130 @@ /* * This is a manifest file that'll be compiled into application.css. - * - * With Propshaft, assets are served efficiently without preprocessing steps. You can still include - * application-wide styles in this file, but keep in mind that CSS precedence will follow the standard - * cascading order, meaning styles declared later in the document or manifest will override earlier ones, - * depending on specificity. - * - * Consider organizing styles into separate files for maintainability. */ + +/* Prose styles for markdown content */ +.prose { + line-height: 1.6; +} + +.prose p { + margin-top: 0.75rem; + margin-bottom: 0.75rem; +} + +.prose p:first-child { + margin-top: 0; +} + +.prose p:last-child { + margin-bottom: 0; +} + +.prose h1, .prose h2, .prose h3, .prose h4 { + font-weight: 600; + margin-top: 1.5rem; + margin-bottom: 0.75rem; +} + +.prose h1:first-child, .prose h2:first-child, .prose h3:first-child { + margin-top: 0; +} + +.prose ul, .prose ol { + padding-left: 1.5rem; + margin-top: 0.5rem; + margin-bottom: 0.5rem; +} + +.prose ul { + list-style-type: disc; +} + +.prose ol { + list-style-type: decimal; +} + +.prose li { + margin-top: 0.25rem; + margin-bottom: 0.25rem; +} + +.prose blockquote { + border-left: 3px solid #d1d5db; + padding-left: 1rem; + color: #6b7280; + font-style: italic; + margin: 1rem 0; +} + +.prose a { + color: #2563eb; + text-decoration: underline; +} + +.prose table { + width: 100%; + border-collapse: collapse; + margin: 1rem 0; +} + +.prose th, .prose td { + border: 1px solid #e5e7eb; + padding: 0.5rem; + text-align: left; +} + +.prose th { + background-color: #f9fafb; + font-weight: 600; +} + +.prose code { + background-color: #f3f4f6; + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; + font-size: 0.875em; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +.prose pre { + background-color: #1e1e1e; + border-radius: 0.5rem; + padding: 1rem; + overflow-x: auto; + margin: 1rem 0; +} + +.prose pre code { + background-color: transparent; + padding: 0; + color: #e5e7eb; +} + +/* Rouge syntax highlighting - GitHub-like dark theme */ +.highlight { + background-color: #1e1e1e; + border-radius: 0.5rem; + padding: 1rem; + overflow-x: auto; + margin: 0.75rem 0; +} + +.highlight pre { + margin: 0; + padding: 0; + background: transparent; +} + +.highlight .c, .highlight .ch, .highlight .cd, .highlight .cm, .highlight .cpf, .highlight .c1, .highlight .cs { color: #6a9955; } /* Comment */ +.highlight .k, .highlight .kc, .highlight .kd, .highlight .kn, .highlight .kp, .highlight .kr, .highlight .kt { color: #569cd6; } /* Keyword */ +.highlight .s, .highlight .sb, .highlight .sc, .highlight .dl, .highlight .sd, .highlight .s2, .highlight .sh, .highlight .sx, .highlight .s1, .highlight .ss { color: #ce9178; } /* String */ +.highlight .na, .highlight .nb, .highlight .nc, .highlight .no, .highlight .nd, .highlight .ni, .highlight .ne, .highlight .nf, .highlight .nl, .highlight .nn, .highlight .nt, .highlight .nv { color: #9cdcfe; } /* Name */ +.highlight .m, .highlight .mb, .highlight .mf, .highlight .mh, .highlight .mi, .highlight .mo, .highlight .mx { color: #b5cea8; } /* Number */ +.highlight .o, .highlight .ow { color: #d4d4d4; } /* Operator */ +.highlight .p { color: #d4d4d4; } /* Punctuation */ +.highlight .gi { color: #4ec9b0; } /* Generic.Inserted */ +.highlight .gd { color: #f44747; } /* Generic.Deleted */ +.highlight .gh { color: #569cd6; font-weight: bold; } /* Generic.Heading */ +.highlight .gu { color: #569cd6; } /* Generic.Subheading */ +.highlight .err { color: #f44747; } /* Error */ diff --git a/app/controllers/admins/sessions_controller.rb b/app/controllers/admins/sessions_controller.rb index 0c11a92..66d879f 100644 --- a/app/controllers/admins/sessions_controller.rb +++ b/app/controllers/admins/sessions_controller.rb @@ -1,6 +1,6 @@ class Admins::SessionsController < ApplicationController # Admin login and magic link verification - # All admin management happens through Avo at /avo + # All admin management happens through Madmin at /madmin def new # Show admin login form diff --git a/app/controllers/madmin/application_controller.rb b/app/controllers/madmin/application_controller.rb index 09bf3bd..7e20183 100644 --- a/app/controllers/madmin/application_controller.rb +++ b/app/controllers/madmin/application_controller.rb @@ -1,6 +1,7 @@ module Madmin class ApplicationController < Madmin::BaseController before_action :authenticate_admin! + helper Madmin::ApplicationHelper private diff --git a/app/controllers/admins/dashboard_controller.rb b/app/controllers/madmin/dashboard_controller.rb similarity index 53% rename from app/controllers/admins/dashboard_controller.rb rename to app/controllers/madmin/dashboard_controller.rb index 0466001..4a70820 100644 --- a/app/controllers/admins/dashboard_controller.rb +++ b/app/controllers/madmin/dashboard_controller.rb @@ -1,8 +1,6 @@ -module Admins - class DashboardController < ApplicationController - before_action :authenticate_admin! - - def index +module Madmin + class DashboardController < Madmin::ApplicationController + def show @metrics = { total_users: User.count, total_admins: Admin.count, @@ -18,6 +16,9 @@ def index @recent_chats = Chat.includes(:user).order(created_at: :desc).limit(5) @recent_users = User.order(created_at: :desc).limit(5) + + @activity_chart_data = build_activity_chart_data + @message_role_data = build_message_role_data end private @@ -26,9 +27,23 @@ def calculate_total_tokens Message.sum("COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0) + COALESCE(cached_tokens, 0) + COALESCE(cache_creation_tokens, 0)") end - def authenticate_admin! - admin = Admin.find_by(id: session[:admin_id]) if session[:admin_id] - redirect_to new_admins_session_path unless admin + def build_activity_chart_data + dates = (6.days.ago.to_date..Date.current).to_a + + { + labels: dates.map { |d| d.strftime("%b %d") }, + users: dates.map { |d| User.where(created_at: d.all_day).count }, + chats: dates.map { |d| Chat.where(created_at: d.all_day).count }, + messages: dates.map { |d| Message.where(created_at: d.all_day).count } + } + end + + def build_message_role_data + roles = Message.group(:role).count + { + labels: roles.keys.map(&:titleize), + values: roles.values + } end end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index de6be79..ca3b486 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,2 +1,42 @@ module ApplicationHelper + class MarkdownRenderer < Redcarpet::Render::HTML + include Rouge::Plugins::Redcarpet + + def block_code(code, language) + language ||= "text" + formatter = Rouge::Formatters::HTMLLegacy.new(css_class: "highlight") + lexer = Rouge::Lexer.find_fancy(language, code) || Rouge::Lexers::PlainText.new + formatter.format(lexer.lex(code)) + end + end + + def markdown(text) + return "" if text.blank? + + options = { + filter_html: true, + hard_wrap: true, + link_attributes: { rel: "nofollow", target: "_blank" }, + fenced_code_blocks: true, + prettify: true, + tables: true, + with_toc_data: true, + no_intra_emphasis: true + } + + extensions = { + autolink: true, + superscript: true, + disable_indented_code_blocks: true, + fenced_code_blocks: true, + tables: true, + strikethrough: true, + highlight: true + } + + renderer = MarkdownRenderer.new(options) + markdown_parser = Redcarpet::Markdown.new(renderer, extensions) + + markdown_parser.render(text).html_safe + end end diff --git a/app/helpers/madmin/application_helper.rb b/app/helpers/madmin/application_helper.rb new file mode 100644 index 0000000..d17e353 --- /dev/null +++ b/app/helpers/madmin/application_helper.rb @@ -0,0 +1,46 @@ +module Madmin + module ApplicationHelper + include Pagy::Frontend if defined?(Pagy::Frontend) + + class MarkdownRenderer < Redcarpet::Render::HTML + include Rouge::Plugins::Redcarpet + + def block_code(code, language) + language ||= "text" + formatter = Rouge::Formatters::HTMLLegacy.new(css_class: "highlight") + lexer = Rouge::Lexer.find_fancy(language, code) || Rouge::Lexers::PlainText.new + formatter.format(lexer.lex(code)) + end + end + + def markdown(text) + return "" if text.blank? + + options = { + filter_html: true, + hard_wrap: true, + link_attributes: { rel: "nofollow", target: "_blank" }, + fenced_code_blocks: true, + prettify: true, + tables: true, + with_toc_data: true, + no_intra_emphasis: true + } + + extensions = { + autolink: true, + superscript: true, + disable_indented_code_blocks: true, + fenced_code_blocks: true, + tables: true, + strikethrough: true, + highlight: true + } + + renderer = MarkdownRenderer.new(options) + markdown_parser = Redcarpet::Markdown.new(renderer, extensions) + + markdown_parser.render(text).html_safe + end + end +end diff --git a/app/javascript/controllers/chat_input_controller.js b/app/javascript/controllers/chat_input_controller.js new file mode 100644 index 0000000..30bac0f --- /dev/null +++ b/app/javascript/controllers/chat_input_controller.js @@ -0,0 +1,24 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["textarea", "form"] + + connect() { + this.resize() + } + + resize() { + const textarea = this.textareaTarget + textarea.style.height = "auto" + textarea.style.height = Math.min(textarea.scrollHeight, 200) + "px" + } + + submit(event) { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault() + if (this.textareaTarget.value.trim()) { + this.formTarget.requestSubmit() + } + } + } +} diff --git a/app/javascript/controllers/scroll_bottom_controller.js b/app/javascript/controllers/scroll_bottom_controller.js new file mode 100644 index 0000000..686784f --- /dev/null +++ b/app/javascript/controllers/scroll_bottom_controller.js @@ -0,0 +1,29 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + connect() { + this.scrollToBottom() + this.observeNewMessages() + } + + disconnect() { + if (this.observer) { + this.observer.disconnect() + } + } + + observeNewMessages() { + this.observer = new MutationObserver(() => { + this.scrollToBottom() + }) + + this.observer.observe(this.element, { + childList: true, + subtree: true + }) + } + + scrollToBottom() { + this.element.scrollTop = this.element.scrollHeight + } +} diff --git a/app/madmin/resources/active_storage/attachment_resource.rb b/app/madmin/resources/active_storage/attachment_resource.rb index 5b4a2b1..a683031 100644 --- a/app/madmin/resources/active_storage/attachment_resource.rb +++ b/app/madmin/resources/active_storage/attachment_resource.rb @@ -1,4 +1,7 @@ class ActiveStorage::AttachmentResource < Madmin::Resource + # Menu configuration - nest under "Active Storage" + menu parent: "Active Storage", position: 1 + # Attributes attribute :id, form: false attribute :name diff --git a/app/madmin/resources/active_storage/blob_resource.rb b/app/madmin/resources/active_storage/blob_resource.rb index 10d21de..fa6dbeb 100644 --- a/app/madmin/resources/active_storage/blob_resource.rb +++ b/app/madmin/resources/active_storage/blob_resource.rb @@ -1,4 +1,7 @@ class ActiveStorage::BlobResource < Madmin::Resource + # Menu configuration - nest under "Active Storage" + menu parent: "Active Storage", position: 2 + # Attributes attribute :id, form: false attribute :key diff --git a/app/madmin/resources/active_storage/variant_record_resource.rb b/app/madmin/resources/active_storage/variant_record_resource.rb index 845985a..b100fdd 100644 --- a/app/madmin/resources/active_storage/variant_record_resource.rb +++ b/app/madmin/resources/active_storage/variant_record_resource.rb @@ -1,4 +1,7 @@ class ActiveStorage::VariantRecordResource < Madmin::Resource + # Menu configuration - nest under "Active Storage" + menu parent: "Active Storage", position: 3 + # Attributes attribute :id, form: false attribute :variation, index: false, show: false diff --git a/app/models/message.rb b/app/models/message.rb index ef429e4..36f4419 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -1,7 +1,9 @@ class Message < ApplicationRecord acts_as_message tool_calls_foreign_key: :message_id has_many_attached :attachments - broadcasts_to ->(message) { "chat_#{message.chat_id}" } + broadcasts_to ->(message) { "chat_#{message.chat_id}" }, inserts_by: :append, target: "messages" + + after_update_commit :broadcast_message_replacement, if: :assistant? def broadcast_append_chunk(content) broadcast_append_to "chat_#{chat_id}", @@ -9,4 +11,17 @@ def broadcast_append_chunk(content) partial: "messages/content", locals: { content: content } end + + def assistant? + role == "assistant" + end + + private + + def broadcast_message_replacement + broadcast_replace_to "chat_#{chat_id}", + target: "message_#{id}", + partial: "messages/message", + locals: { message: self } + end end diff --git a/app/views/admins/dashboard/index.html.erb b/app/views/admins/dashboard/index.html.erb deleted file mode 100644 index 6a96913..0000000 --- a/app/views/admins/dashboard/index.html.erb +++ /dev/null @@ -1,181 +0,0 @@ -
-
- -
-

Dashboard

-

Overview of your application data and activity

-
- <%= link_to "← Back to Avo", avo.root_path, class: "text-blue-600 hover:text-blue-800 font-medium" %> -
-
- - -
- -
-
-
- - - -
-
-

<%= number_with_delimiter(@metrics[:total_users]) %>

-

Total Users

-

+<%= @metrics[:recent_users] %> this week

-
- - -
-
-
- - - -
-
-

<%= number_with_delimiter(@metrics[:total_chats]) %>

-

Total Chats

-

+<%= @metrics[:recent_chats] %> this week

-
- - -
-
-
- - - -
-
-

<%= number_with_delimiter(@metrics[:total_messages]) %>

-

Total Messages

-

+<%= @metrics[:recent_messages] %> this week

-
- - -
-
-
- - - -
-
-

<%= number_with_delimiter(@metrics[:total_tokens]) %>

-

Total AI Tokens

-

All time usage

-
- - -
-
-
- - - -
-
-

<%= number_with_delimiter(@metrics[:total_admins]) %>

-

Total Admins

-
- - -
-
-
- - - -
-
-

<%= number_with_delimiter(@metrics[:total_models]) %>

-

AI Models

-
- - -
-
-
- - - - -
-
-

<%= number_with_delimiter(@metrics[:total_tool_calls]) %>

-

Tool Calls

-
- - -
-
- - - -
-

Last 7 Days

-
-

<%= @metrics[:recent_chats] %> chats

-

<%= @metrics[:recent_messages] %> messages

-

<%= @metrics[:recent_users] %> new users

-
-
-
- - -
- -
-
-

Recent Chats

- <%= link_to "View all", avo.resources_chats_path, class: "text-sm text-blue-600 hover:text-blue-800 font-medium" %> -
-
- <% if @recent_chats.any? %> - <% @recent_chats.each do |chat| %> -
-
-

<%= chat.user.name %>

-

<%= chat.user.email %>

-
-
-

<%= time_ago_in_words(chat.created_at) %> ago

- <%= link_to "View", avo.resources_chat_path(chat), class: "text-xs text-blue-600 hover:text-blue-800 font-medium" %> -
-
- <% end %> - <% else %> -

No chats yet

- <% end %> -
-
- - -
-
-

Recent Users

- <%= link_to "View all", avo.resources_users_path, class: "text-sm text-blue-600 hover:text-blue-800 font-medium" %> -
-
- <% if @recent_users.any? %> - <% @recent_users.each do |user| %> -
-
-

<%= user.name %>

-

<%= user.email %>

-
-
-

<%= time_ago_in_words(user.created_at) %> ago

- <%= link_to "View", avo.resources_user_path(user), class: "text-xs text-blue-600 hover:text-blue-800 font-medium" %> -
-
- <% end %> - <% else %> -

No users yet

- <% end %> -
-
-
-
-
diff --git a/app/views/chats/_form.html.erb b/app/views/chats/_form.html.erb index 8c7c6a3..3ea097c 100644 --- a/app/views/chats/_form.html.erb +++ b/app/views/chats/_form.html.erb @@ -1,9 +1,8 @@ -<%= form_with(model: chat, url: chats_path) do |form| %> +<%= form_with(model: chat, url: chats_path, class: "space-y-6") do |form| %> <% if chat.errors.any? %> -
-

<%= pluralize(chat.errors.count, "error") %> prohibited this chat from being saved:

- -
    +
    +

    <%= pluralize(chat.errors.count, "error") %> prohibited this chat from being saved:

    +
      <% chat.errors.each do |error| %>
    • <%= error.full_message %>
    • <% end %> @@ -12,18 +11,24 @@ <% end %>
      - <%= form.label :model, "Select AI model:", style: "display: block" %> + <%= form.label :model, "AI Model", class: "block text-sm font-medium text-gray-700 mb-2" %> <%= form.select :model, options_for_select(Model.pluck(:name, :model_id).unshift(["Default (#{RubyLLM.config.default_model})", nil]), @selected_model), {}, - style: "width: 100%; max-width: 600px; padding: 5px;" %> + class: "w-full rounded-lg border border-gray-300 px-4 py-3 focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:ring-opacity-20 focus:outline-none transition-all" %> +

      Select the AI model for this conversation

      -
      - <%= form.text_field :prompt, style: "width: 100%; max-width: 600px;", placeholder: "What would you like to discuss?", autofocus: true %> +
      + <%= form.label :prompt, "Your Message", class: "block text-sm font-medium text-gray-700 mb-2" %> + <%= form.text_area :prompt, + rows: 3, + class: "w-full rounded-lg border border-gray-300 px-4 py-3 focus:border-blue-500 focus:ring-2 focus:ring-blue-500 focus:ring-opacity-20 focus:outline-none transition-all resize-none placeholder-gray-400", + placeholder: "What would you like to discuss?", + autofocus: true %>
      - <%= form.submit "Start new chat" %> + <%= form.submit "Start Chat", class: "w-full bg-blue-600 hover:bg-blue-700 text-white font-medium py-3 px-4 rounded-lg transition-colors cursor-pointer" %>
      -<% end %> \ No newline at end of file +<% end %> diff --git a/app/views/chats/index.html.erb b/app/views/chats/index.html.erb index 684e660..733783b 100644 --- a/app/views/chats/index.html.erb +++ b/app/views/chats/index.html.erb @@ -1,16 +1,67 @@ -

      <%= notice %>

      - <% content_for :title, "Chats" %> -

      Chats

      +
      +
      +
      +

      Your Chats

      +

      Continue a conversation or start a new one

      +
      + <%= link_to new_chat_path, class: "inline-flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors font-medium" do %> + <%= inline_svg "icons/plus.svg", class: "w-5 h-5" %> + New Chat + <% end %> +
      -
      - <% @chats.each do |chat| %> - <%= render chat %> -

      - <%= link_to "Show this chat", chat %> -

      + <% if notice.present? %> +
      + <%= notice %> +
      <% end %> -
      -<%= link_to "New chat", new_chat_path %> \ No newline at end of file +
      + <% if @chats.any? %> + <% @chats.each do |chat| %> + <%= link_to chat, class: "block rounded-xl p-4 hover:bg-gray-100 transition-all group" do %> +
      +
      +
      +
      + <%= inline_svg "icons/chat.svg", class: "w-4 h-4 text-white" %> +
      +
      +

      + <%= chat.model&.name || 'Default Model' %> +

      +
      +
      + <% if chat.messages.any? %> +

      + <% first_user_message = chat.messages.find { |m| m.role == 'user' } %> + <%= first_user_message&.content&.truncate(100) || "No messages yet" %> +

      + <% end %> +
      +
      + + <%= time_ago_in_words(chat.created_at) %> ago + + <%= inline_svg "icons/chevron-left.svg", class: "w-4 h-4 text-gray-400 group-hover:text-blue-600 transition-colors rotate-180" %> +
      +
      + <% end %> + <% end %> + <% else %> +
      +
      + <%= inline_svg "icons/chat.svg", class: "w-8 h-8 text-gray-400" %> +
      +

      No chats yet

      +

      Start a conversation with an AI assistant

      + <%= link_to new_chat_path, class: "inline-flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors font-medium" do %> + <%= inline_svg "icons/plus.svg", class: "w-5 h-5" %> + Start Your First Chat + <% end %> +
      + <% end %> +
      +
      diff --git a/app/views/chats/new.html.erb b/app/views/chats/new.html.erb index 5715e97..2fbbd68 100644 --- a/app/views/chats/new.html.erb +++ b/app/views/chats/new.html.erb @@ -1,11 +1,26 @@ <% content_for :title, "New chat" %> -

      New chat

      +
      +
      +
      + + + +
      +

      Start a New Chat

      +

      Choose a model and begin your conversation

      +
      -<%= render "form", chat: @chat %> +
      + <%= render "form", chat: @chat %> +
      -
      - -
      - <%= link_to "Back to chats", chats_path %> -
      \ No newline at end of file +
      + <%= link_to chats_path, class: "inline-flex items-center gap-2 text-gray-500 hover:text-gray-700 transition-colors" do %> + + + + Back to chats + <% end %> +
      +
      diff --git a/app/views/chats/show.html.erb b/app/views/chats/show.html.erb index f05b462..0ed21b7 100644 --- a/app/views/chats/show.html.erb +++ b/app/views/chats/show.html.erb @@ -1,23 +1,26 @@ -

      <%= notice %>

      - <%= turbo_stream_from "chat_#{@chat.id}" %> <% content_for :title, "Chat" %> +<% content_for :full_width, true %> -

      Chat <%= @chat.id %>

      - -

      Using <%= @chat.model.name %>

      +
      +
      + <%= link_to chats_path, class: "p-2 hover:bg-gray-100 rounded-lg transition-colors" do %> + <%= inline_svg "icons/chevron-left.svg", class: "w-5 h-5 text-gray-600" %> + <% end %> +
      -
      - <% @chat.messages.where.not(id: nil).each do |message| %> - <%= render message %> - <% end %> -
      +
      +
      + <% @chat.messages.where.not(id: nil).each do |message| %> + <%= render message %> + <% end %> +
      +
      -
      - <%= render "messages/form", chat: @chat, message: @message %> +
      +
      + <%= render "messages/form", chat: @chat, message: @message %> +
      +
      - -
      - <%= link_to "Back to chats", chats_path %> -
      \ No newline at end of file diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 55d752f..aa437c4 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -24,22 +24,24 @@ - <% if current_user %> - + + <% end %> <% end %> <% if flash.any? %> -
      +
      <% flash.each do |type, message| %>
      <%= message %> @@ -48,7 +50,7 @@
      <% end %> -
      +
      <%= yield %>
      diff --git a/app/views/layouts/madmin/application.html.erb b/app/views/layouts/madmin/application.html.erb index 3e8811c..d457f7b 100644 --- a/app/views/layouts/madmin/application.html.erb +++ b/app/views/layouts/madmin/application.html.erb @@ -11,9 +11,10 @@ <%= Madmin.site_name %> Admin <%= csrf_meta_tags %> + <%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %> <%= render "javascript" %> - +
      <%= render "flash" %>
      @@ -22,7 +23,7 @@ - <%= link_to Madmin.site_name, try(:root_url) || madmin_root_url, data: {turbo: false} %> + <%= link_to Madmin.site_name, try(:root_url) || "/madmin", data: {turbo: false} %>
      diff --git a/app/views/madmin/application/_navigation.html.erb b/app/views/madmin/application/_navigation.html.erb index 59b1d40..058b1ec 100644 --- a/app/views/madmin/application/_navigation.html.erb +++ b/app/views/madmin/application/_navigation.html.erb @@ -1,5 +1,5 @@
+ -
+ <%# Mobile overlay %> + diff --git a/app/views/madmin/application/_flash.html.erb b/app/views/madmin/application/_flash.html.erb new file mode 100644 index 0000000..66415d7 --- /dev/null +++ b/app/views/madmin/application/_flash.html.erb @@ -0,0 +1,25 @@ +<% if flash.any? %> +
+ <% flash.each do |type, message| %> + <% + base_classes = "border px-4 py-3 rounded-lg mb-4 flex items-center gap-3" + type_classes = case type.to_s + when "notice", "success" + "bg-green-900/50 border-green-700 text-green-300" + when "alert", "error" + "bg-red-900/50 border-red-700 text-red-300" + else + "bg-blue-900/50 border-blue-700 text-blue-300" + end + %> +
+ <% if type.to_s.in?(["notice", "success"]) %> + <%= inline_svg "icons/check-circle.svg", class: "w-5 h-5 flex-shrink-0" rescue nil %> + <% elsif type.to_s.in?(["alert", "error"]) %> + <%= inline_svg "icons/x-circle.svg", class: "w-5 h-5 flex-shrink-0" rescue nil %> + <% end %> + <%= message %> +
+ <% end %> +
+<% end %> diff --git a/app/views/madmin/application/_javascript.html.erb b/app/views/madmin/application/_javascript.html.erb index f507e16..597556f 100644 --- a/app/views/madmin/application/_javascript.html.erb +++ b/app/views/madmin/application/_javascript.html.erb @@ -1,8 +1,6 @@ -<%= javascript_importmap_tags "application", importmap: Madmin.importmap %> - <%= tag.script 'import "trix"'.html_safe, type: "module" if defined?(::Trix) || Rails.gem_version < Gem::Version.new("8.1.0.beta1") %> <%= tag.script 'import "lexxy"'.html_safe, type: "module" if defined?(::Lexxy) %> -<%= stylesheet_link_tag *Madmin.stylesheets, "data-turbo-track": "reload" %> +<%# Note: Madmin.stylesheets removed - using custom dark theme layout instead %> <%= stylesheet_link_tag "https://unpkg.com/flatpickr/dist/flatpickr.min.css", "data-turbo-track": "reload" %> <%= stylesheet_link_tag "https://unpkg.com/tom-select/dist/css/tom-select.min.css", "data-turbo-track": "reload" %> diff --git a/app/views/madmin/application/_sidebar.html.erb b/app/views/madmin/application/_sidebar.html.erb new file mode 100644 index 0000000..363908c --- /dev/null +++ b/app/views/madmin/application/_sidebar.html.erb @@ -0,0 +1,91 @@ +<%# Modern dark sidebar navigation for Madmin - uses shared partials for consistent design %> + +<%# Sidebar %> + diff --git a/app/views/madmin/chats/index.html.erb b/app/views/madmin/chats/index.html.erb index 4db96ba..f5733db 100644 --- a/app/views/madmin/chats/index.html.erb +++ b/app/views/madmin/chats/index.html.erb @@ -22,7 +22,7 @@
- <%= form_with url: "/madmin/chats", method: :get, class: "flex gap-2 items-end flex-wrap" do |f| %> + <%= form_with url: resource.index_path, method: :get, class: "flex gap-2 items-end flex-wrap" do |f| %>
<%= f.label :created_at_from, "From:", class: "block text-sm font-medium text-gray-700" %> <%= f.date_field :created_at_from, value: params[:created_at_from], class: "form-input" %> @@ -34,7 +34,7 @@
<%= f.submit "Filter", class: "btn btn-primary" %> - <%= link_to "Clear", "/madmin/chats", class: "btn" %> + <%= link_to "Clear", resource.index_path, class: "btn" %> <% end %>
diff --git a/app/views/madmin/dashboard/show.html.erb b/app/views/madmin/dashboard/show.html.erb index c2363b9..2117dcc 100644 --- a/app/views/madmin/dashboard/show.html.erb +++ b/app/views/madmin/dashboard/show.html.erb @@ -1,189 +1,187 @@ -
-
-
-

Dashboard

-

Overview of your application data and activity

-
+
+
+

Dashboard

+

Overview of your application data and activity

+
-
-
-
-
- <%= inline_svg "icons/users.svg", class: "w-6 h-6 text-blue-600" %> -
- +<%= @metrics[:recent_users] %> +
+
+
+
+ <%= inline_svg "icons/users.svg", class: "w-6 h-6 text-blue-400" %>
-

<%= number_with_delimiter(@metrics[:total_users]) %>

-

Total Users

+ +<%= @metrics[:recent_users] %>
+

<%= number_with_delimiter(@metrics[:total_users]) %>

+

Total Users

+
-
-
-
- <%= inline_svg "icons/chat.svg", class: "w-6 h-6 text-purple-600" %> -
- +<%= @metrics[:recent_chats] %> +
+
+
+ <%= inline_svg "icons/chat.svg", class: "w-6 h-6 text-purple-400" %>
-

<%= number_with_delimiter(@metrics[:total_chats]) %>

-

Total Chats

+ +<%= @metrics[:recent_chats] %>
+

<%= number_with_delimiter(@metrics[:total_chats]) %>

+

Total Chats

+
-
-
-
- <%= inline_svg "icons/messages.svg", class: "w-6 h-6 text-green-600" %> -
- +<%= @metrics[:recent_messages] %> +
+
+
+ <%= inline_svg "icons/messages.svg", class: "w-6 h-6 text-green-400" %>
-

<%= number_with_delimiter(@metrics[:total_messages]) %>

-

Total Messages

+ +<%= @metrics[:recent_messages] %>
+

<%= number_with_delimiter(@metrics[:total_messages]) %>

+

Total Messages

+
-
-
-
- <%= inline_svg "icons/lightning.svg", class: "w-6 h-6 text-amber-600" %> -
+
+
+
+ <%= inline_svg "icons/lightning.svg", class: "w-6 h-6 text-amber-400" %>
-

<%= number_with_delimiter(@metrics[:total_tokens]) %>

-

AI Tokens Used

+

<%= number_with_delimiter(@metrics[:total_tokens]) %>

+

AI Tokens Used

+
-
-
-
-

Activity Overview

- Last 7 days -
-
- -
+
+
+
+

Activity Overview

+ Last 7 days
+
+ +
+
-
-
-

Messages by Role

-
-
- -
+
+
+

Messages by Role

+
+
+
+
-
-
-
-
- <%= inline_svg "icons/shield.svg", class: "w-5 h-5 text-indigo-600" %> -
-
-

<%= @metrics[:total_admins] %>

-

Admins

-
+
+
+
+
+ <%= inline_svg "icons/shield.svg", class: "w-5 h-5 text-indigo-400" %> +
+
+

<%= @metrics[:total_admins] %>

+

Admins

+
-
-
-
- <%= inline_svg "icons/computer.svg", class: "w-5 h-5 text-pink-600" %> -
-
-

<%= number_with_delimiter(@metrics[:total_models]) %>

-

AI Models

-
+
+
+
+ <%= inline_svg "icons/computer.svg", class: "w-5 h-5 text-pink-400" %> +
+
+

<%= number_with_delimiter(@metrics[:total_models]) %>

+

AI Models

+
-
-
-
- <%= inline_svg "icons/cog.svg", class: "w-5 h-5 text-teal-600" %> -
-
-

<%= number_with_delimiter(@metrics[:total_tool_calls]) %>

-

Tool Calls

-
+
+
+
+ <%= inline_svg "icons/cog.svg", class: "w-5 h-5 text-teal-400" %> +
+
+

<%= number_with_delimiter(@metrics[:total_tool_calls]) %>

+

Tool Calls

+
-
-
-
- <%= inline_svg "icons/chart.svg", class: "w-5 h-5 text-white" %> -
-
-

<%= @metrics[:recent_users] + @metrics[:recent_chats] + @metrics[:recent_messages] %>

-

Weekly Activity

-
+
+
+
+ <%= inline_svg "icons/chart.svg", class: "w-5 h-5 text-white" %> +
+
+

<%= @metrics[:recent_users] + @metrics[:recent_chats] + @metrics[:recent_messages] %>

+

Weekly Activity

+
-
-
-
-

Recent Chats

- <%= link_to "View all", "/madmin/chats", class: "text-sm text-blue-600 hover:text-blue-800 font-medium" %> -
-
- <% if @recent_chats.any? %> - <% @recent_chats.each do |chat| %> -
-
-
- <%= chat.user.name.first.upcase %> -
-
-

<%= chat.user.name %>

-

<%= chat.user.email %>

-
+
+
+
+

Recent Chats

+ <%= link_to "View all", "/madmin/chats", class: "text-sm text-blue-400 hover:text-blue-300 font-medium" %> +
+
+ <% if @recent_chats.any? %> + <% @recent_chats.each do |chat| %> +
+
+
+ <%= chat.user.name.first.upcase %>
-
-

<%= time_ago_in_words(chat.created_at) %> ago

- <%= link_to "View", "/madmin/chats/#{chat.id}", class: "text-sm text-blue-600 hover:text-blue-800 font-medium" %> +
+

<%= chat.user.name %>

+

<%= chat.user.email %>

- <% end %> - <% else %> -
-

No chats yet

+
+

<%= time_ago_in_words(chat.created_at) %> ago

+ <%= link_to "View", "/madmin/chats/#{chat.id}", class: "text-sm text-blue-400 hover:text-blue-300 font-medium" %> +
<% end %> -
+ <% else %> +
+

No chats yet

+
+ <% end %>
+
-
-
-

Recent Users

- <%= link_to "View all", "/madmin/users", class: "text-sm text-blue-600 hover:text-blue-800 font-medium" %> -
-
- <% if @recent_users.any? %> - <% @recent_users.each do |user| %> -
-
-
- <%= user.name.first.upcase %> -
-
-

<%= user.name %>

-

<%= user.email %>

-
+
+
+

Recent Users

+ <%= link_to "View all", "/madmin/users", class: "text-sm text-blue-400 hover:text-blue-300 font-medium" %> +
+
+ <% if @recent_users.any? %> + <% @recent_users.each do |user| %> +
+
+
+ <%= user.name.first.upcase %>
-
-

<%= time_ago_in_words(user.created_at) %> ago

- <%= link_to "View", "/madmin/users/#{user.id}", class: "text-sm text-blue-600 hover:text-blue-800 font-medium" %> +
+

<%= user.name %>

+

<%= user.email %>

- <% end %> - <% else %> -
-

No users yet

+
+

<%= time_ago_in_words(user.created_at) %> ago

+ <%= link_to "View", "/madmin/users/#{user.id}", class: "text-sm text-blue-400 hover:text-blue-300 font-medium" %> +
<% end %> -
+ <% else %> +
+

No users yet

+
+ <% end %>
@@ -192,6 +190,9 @@ diff --git a/app/views/madmin/messages/index.html.erb b/app/views/madmin/messages/index.html.erb index ba07d4e..8233114 100644 --- a/app/views/madmin/messages/index.html.erb +++ b/app/views/madmin/messages/index.html.erb @@ -22,7 +22,7 @@
- <%= form_with url: "/madmin/messages", method: :get, class: "flex gap-2 items-end flex-wrap" do |f| %> + <%= form_with url: resource.index_path, method: :get, class: "flex gap-2 items-end flex-wrap" do |f| %>
<%= f.label :role, class: "block text-sm font-medium text-gray-700" %> <%= f.select :role, @@ -42,7 +42,7 @@
<%= f.submit "Filter", class: "btn btn-primary" %> - <%= link_to "Clear", "/madmin/messages", class: "btn" %> + <%= link_to "Clear", resource.index_path, class: "btn" %> <% end %>
diff --git a/app/views/madmin/models/index.html.erb b/app/views/madmin/models/index.html.erb index 483f071..9c8131b 100644 --- a/app/views/madmin/models/index.html.erb +++ b/app/views/madmin/models/index.html.erb @@ -5,7 +5,7 @@
<%= button_to "Refresh Models from RubyLLM", - refresh_all_madmin_models_path, + "/madmin/models/refresh_all", method: :post, class: "btn btn-primary", data: { turbo_confirm: "Refresh all models from RubyLLM?" } %> @@ -28,7 +28,7 @@
- <%= form_with url: madmin_models_path, method: :get, class: "flex gap-2 items-end flex-wrap" do |f| %> + <%= form_with url: resource.index_path, method: :get, class: "flex gap-2 items-end flex-wrap" do |f| %>
<%= f.label :provider, class: "block text-sm font-medium text-gray-700" %> <%= f.select :provider, @@ -38,7 +38,7 @@
<%= f.submit "Filter", class: "btn btn-primary" %> - <%= link_to "Clear", madmin_models_path, class: "btn" %> + <%= link_to "Clear", resource.index_path, class: "btn" %> <% end %>
diff --git a/app/views/madmin/tool_calls/index.html.erb b/app/views/madmin/tool_calls/index.html.erb index 64ae54c..f5733db 100644 --- a/app/views/madmin/tool_calls/index.html.erb +++ b/app/views/madmin/tool_calls/index.html.erb @@ -22,7 +22,7 @@
- <%= form_with url: madmin_tool_calls_path, method: :get, class: "flex gap-2 items-end flex-wrap" do |f| %> + <%= form_with url: resource.index_path, method: :get, class: "flex gap-2 items-end flex-wrap" do |f| %>
<%= f.label :created_at_from, "From:", class: "block text-sm font-medium text-gray-700" %> <%= f.date_field :created_at_from, value: params[:created_at_from], class: "form-input" %> @@ -34,7 +34,7 @@
<%= f.submit "Filter", class: "btn btn-primary" %> - <%= link_to "Clear", madmin_tool_calls_path, class: "btn" %> + <%= link_to "Clear", resource.index_path, class: "btn" %> <% end %>
diff --git a/app/views/madmin/users/index.html.erb b/app/views/madmin/users/index.html.erb index 10c51fa..f5733db 100644 --- a/app/views/madmin/users/index.html.erb +++ b/app/views/madmin/users/index.html.erb @@ -22,7 +22,7 @@
- <%= form_with url: madmin_users_path, method: :get, class: "flex gap-2 items-end flex-wrap" do |f| %> + <%= form_with url: resource.index_path, method: :get, class: "flex gap-2 items-end flex-wrap" do |f| %>
<%= f.label :created_at_from, "From:", class: "block text-sm font-medium text-gray-700" %> <%= f.date_field :created_at_from, value: params[:created_at_from], class: "form-input" %> @@ -34,7 +34,7 @@
<%= f.submit "Filter", class: "btn btn-primary" %> - <%= link_to "Clear", madmin_users_path, class: "btn" %> + <%= link_to "Clear", resource.index_path, class: "btn" %> <% end %>
diff --git a/app/views/messages/_form.html.erb b/app/views/messages/_form.html.erb index 0f5b67c..0b6d455 100644 --- a/app/views/messages/_form.html.erb +++ b/app/views/messages/_form.html.erb @@ -1,6 +1,6 @@ <%= form_with(model: message, url: [@chat, message], id: "new_message", data: { controller: "chat-input", chat_input_target: "form" }) do |form| %> <% if message.errors.any? %> -
+
    <% message.errors.each do |error| %>
  • <%= error.full_message %>
  • @@ -9,11 +9,11 @@
<% end %> -
+
<%= form.text_area :content, rows: 1, - class: "w-full resize-none border-0 bg-transparent focus:ring-0 focus:outline-none placeholder-gray-400 text-gray-900", + class: "w-full resize-none border-0 bg-transparent focus:ring-0 focus:outline-none placeholder-dark-500 text-dark-100", placeholder: "Reply...", autofocus: true, data: { @@ -21,15 +21,15 @@ action: "keydown->chat-input#submit input->chat-input#resize" } %>
-
-
-
- <%= @chat.model&.name || 'Default' %> - <%= form.button type: "submit", class: "bg-orange-600 hover:bg-orange-700 text-white rounded-lg p-2 transition-colors cursor-pointer" do %> + <%= @chat.model&.name || 'Default' %> + <%= form.button type: "submit", class: "bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white rounded-lg p-2 transition-all cursor-pointer shadow-lg shadow-blue-500/25" do %> <%= inline_svg "icons/arrow-up.svg", class: "w-4 h-4" %> <% end %>
diff --git a/app/views/messages/_message.html.erb b/app/views/messages/_message.html.erb index 2462c2a..3ae1786 100644 --- a/app/views/messages/_message.html.erb +++ b/app/views/messages/_message.html.erb @@ -1,13 +1,13 @@
<% if message.role == 'user' %>
-
+
<%= message.content %>
<% else %>
-
<%= markdown(message.content) %>
+
<%= markdown(message.content) %>
<% if message.tool_call? %> <%= render "messages/tool_calls", message: message %> <% end %> diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb index c21a158..d7e8b1a 100644 --- a/app/views/sessions/new.html.erb +++ b/app/views/sessions/new.html.erb @@ -1,31 +1,31 @@ -
-
-
-

Welcome

-

Sign in with a magic link sent to your email

+
+
+
+ <%= inline_svg "icons/lightning.svg", class: "w-8 h-8 text-white" %>
+

Welcome

+

Sign in with a magic link sent to your email

+
-
- <%= form_with scope: :session, url: session_path, method: :post, class: "space-y-6" do |form| %> -
- <%= form.label :email, "Email address", class: "block text-sm font-medium text-gray-700 mb-2" %> - <%= form.email_field :email, - required: true, - autofocus: true, - autocomplete: "email", - placeholder: "you@example.com", - class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent" %> -

- We'll send you a magic link to sign in. If you don't have an account, one will be created automatically. -

-
- -
- <%= form.submit "Send Magic Link", - class: "w-full bg-indigo-600 text-white py-2 px-4 rounded-lg hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 font-medium transition-colors" %> -
- <% end %> +
+ <%= form_with scope: :session, url: session_path, method: :post, class: "space-y-6" do |form| %> +
+ <%= form.label :email, "Email address", class: "block text-sm font-medium text-dark-200 mb-2" %> + <%= form.email_field :email, + required: true, + autofocus: true, + autocomplete: "email", + placeholder: "you@example.com", + class: "w-full px-4 py-3 border border-dark-600 bg-dark-800 text-dark-100 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 focus:outline-none transition-all placeholder-dark-500" %> +

+ We'll send you a magic link to sign in. If you don't have an account, one will be created automatically. +

+
-
+
+ <%= form.submit "Send Magic Link", + class: "w-full bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white py-3 px-4 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/50 font-medium transition-all cursor-pointer shadow-lg shadow-blue-500/25" %> +
+ <% end %>
diff --git a/app/views/shared/_sidebar.html.erb b/app/views/shared/_sidebar.html.erb new file mode 100644 index 0000000..92ebf40 --- /dev/null +++ b/app/views/shared/_sidebar.html.erb @@ -0,0 +1,54 @@ +<%# Modern dark sidebar navigation - inspired by Anthropic/OpenAI/Lovable %> + +<%# Sidebar %> + + +<%# Mobile overlay %> + diff --git a/app/views/shared/_sidebar_group.html.erb b/app/views/shared/_sidebar_group.html.erb new file mode 100644 index 0000000..26e818a --- /dev/null +++ b/app/views/shared/_sidebar_group.html.erb @@ -0,0 +1,27 @@ +<%# + Collapsible sidebar navigation group + Params: + id: Unique group identifier for collapse state + icon: Icon name (without .svg extension) + label: Group header text + Block: Content (nested links) to show when expanded +%> + +
+ <%# Group header (clickable to toggle) %> + + + <%# Collapsible content %> + +
diff --git a/app/views/shared/_sidebar_link.html.erb b/app/views/shared/_sidebar_link.html.erb new file mode 100644 index 0000000..fae2b8d --- /dev/null +++ b/app/views/shared/_sidebar_link.html.erb @@ -0,0 +1,26 @@ +<%# + Sidebar navigation link + Params: + path: Route path (string or path helper) + icon: Icon name (without .svg extension) + label: Link text + nested: Boolean, if true applies nested styling (optional) +%> +<% + path_str = path.to_s.split('?').first + # Check if this is a root path (exact match for "/" or "/madmin") + is_root = path_str == "/" || path_str == "/madmin" + # Active if current page or if path matches start of request path (for non-root paths) + is_active = current_page?(path) || (!is_root && request.path.start_with?(path_str) && path_str.length > 1) + nested = local_assigns[:nested] || false + + base_classes = "flex items-center gap-3 px-3 py-2 text-sm rounded-lg transition-colors" + active_classes = "bg-dark-800 text-white" + inactive_classes = "text-dark-400 hover:text-dark-100 hover:bg-dark-800" + nested_classes = nested ? "pl-10" : "" +%> + +<%= link_to path, class: "#{base_classes} #{is_active ? active_classes : inactive_classes} #{nested_classes}" do %> + <%= inline_svg "icons/#{icon}.svg", class: "w-5 h-5 flex-shrink-0" %> + <%= label %> +<% end %> diff --git a/config/tailwind.config.js b/config/tailwind.config.js index a7c19a7..e4b705e 100644 --- a/config/tailwind.config.js +++ b/config/tailwind.config.js @@ -5,11 +5,39 @@ module.exports = { './app/javascript/**/*.js', './app/views/**/*.{erb,haml,html,slim}' ], + darkMode: 'class', theme: { extend: { fontFamily: { sans: ['Inter var', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Helvetica Neue', 'Arial', 'sans-serif'], }, + colors: { + // Dark theme palette inspired by Anthropic/OpenAI/Lovable + dark: { + 50: '#f8fafc', + 100: '#f1f5f9', + 200: '#e2e8f0', + 300: '#cbd5e1', + 400: '#94a3b8', + 500: '#64748b', + 600: '#475569', + 700: '#334155', + 800: '#1e293b', + 850: '#172033', + 900: '#0f172a', + 950: '#0a1120', + }, + accent: { + 50: '#eff6ff', + 100: '#dbeafe', + 200: '#bfdbfe', + 300: '#93c5fd', + 400: '#60a5fa', + 500: '#3b82f6', + 600: '#2563eb', + 700: '#1d4ed8', + }, + }, }, }, plugins: [] From f25d7e7ecebdeee949820aa9a1181c01aaafc130 Mon Sep 17 00:00:00 2001 From: Yuri Sidorov <403994+newstler@users.noreply.github.com> Date: Tue, 13 Jan 2026 21:11:55 +0100 Subject: [PATCH 009/106] WIP: Fixing the design --- app/assets/images/icons/dollar.svg | 3 + app/assets/stylesheets/application.css | 32 +++++--- .../madmin/dashboard_controller.rb | 38 ++++++++- app/controllers/sessions_controller.rb | 2 +- app/views/chats/show.html.erb | 9 +-- app/views/home/index.html.erb | 8 +- .../madmin/application/_sidebar.html.erb | 14 ++-- app/views/madmin/chats/index.html.erb | 6 +- app/views/madmin/dashboard/show.html.erb | 81 ++++++++++++++----- app/views/madmin/messages/index.html.erb | 8 +- app/views/madmin/messages/show.html.erb | 48 +++++------ app/views/madmin/models/index.html.erb | 4 +- app/views/madmin/tool_calls/index.html.erb | 6 +- app/views/madmin/users/index.html.erb | 6 +- app/views/messages/_form.html.erb | 12 +-- app/views/messages/_message.html.erb | 2 +- app/views/shared/_sidebar.html.erb | 16 ++-- app/views/shared/_sidebar_link.html.erb | 6 +- config/routes.rb | 4 +- 19 files changed, 194 insertions(+), 111 deletions(-) create mode 100644 app/assets/images/icons/dollar.svg diff --git a/app/assets/images/icons/dollar.svg b/app/assets/images/icons/dollar.svg new file mode 100644 index 0000000..9448265 --- /dev/null +++ b/app/assets/images/icons/dollar.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 726f4fe..1e7e630 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -18,6 +18,20 @@ --dark-850: #172033; --dark-900: #0f172a; --dark-950: #0a1120; + + /* Sidebar (darker than main content) */ + --sidebar-bg: #080a10; + --sidebar-hover: rgba(255,255,255,0.05); + --sidebar-active: rgba(59,130,246,0.15); + + /* Glow effects */ + --glow-blue: rgba(59,130,246,0.4); + --glow-purple: rgba(147,51,234,0.4); + + /* Card styling */ + --card-bg: rgba(30,41,59,0.4); + --card-shadow: 0 4px 24px rgba(0,0,0,0.4); + --card-border: rgba(255,255,255,0.05); } /* ================================ @@ -80,12 +94,12 @@ } .prose a { - color: #60a5fa; + color: #94a3b8; text-decoration: underline; } .prose a:hover { - color: #93c5fd; + color: #e2e8f0; } .prose table { @@ -121,8 +135,7 @@ .prose pre { background-color: var(--dark-900); - border: 1px solid var(--dark-700); - border-radius: 0.5rem; + border-radius: 0.75rem; padding: 1rem; overflow-x: auto; margin: 1rem 0; @@ -139,8 +152,7 @@ ================================ */ .highlight { background-color: var(--dark-900); - border: 1px solid var(--dark-700); - border-radius: 0.5rem; + border-radius: 0.75rem; padding: 1rem; overflow-x: auto; margin: 0.75rem 0; @@ -332,11 +344,11 @@ } .dark a { - color: #60a5fa; + color: #94a3b8; } .dark a:hover { - color: #93c5fd; + color: #e2e8f0; } /* Madmin Header */ @@ -497,12 +509,12 @@ } .dark table td a { - color: #60a5fa; + color: #94a3b8; text-decoration: none; } .dark table td a:hover { - color: #93c5fd; + color: #e2e8f0; text-decoration: underline; } diff --git a/app/controllers/madmin/dashboard_controller.rb b/app/controllers/madmin/dashboard_controller.rb index 4a70820..2838e9b 100644 --- a/app/controllers/madmin/dashboard_controller.rb +++ b/app/controllers/madmin/dashboard_controller.rb @@ -7,6 +7,7 @@ def show total_chats: Chat.count, total_messages: Message.count, total_tokens: calculate_total_tokens, + total_cost: calculate_total_cost, total_tool_calls: ToolCall.count, recent_chats: Chat.where("created_at >= ?", 7.days.ago).count, recent_messages: Message.where("created_at >= ?", 7.days.ago).count, @@ -27,6 +28,23 @@ def calculate_total_tokens Message.sum("COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0) + COALESCE(cached_tokens, 0) + COALESCE(cache_creation_tokens, 0)") end + def calculate_total_cost + Message.includes(:model).where.not(model_id: nil).sum do |msg| + next 0 unless msg.model&.pricing.present? + + pricing = msg.model.pricing.dig("text_tokens", "standard") || {} + input_rate = pricing["input_per_million"] || 0 + output_rate = pricing["output_per_million"] || 0 + cached_rate = pricing["cached_input_per_million"] || 0 + + input_cost = (msg.input_tokens || 0) * input_rate / 1_000_000.0 + output_cost = (msg.output_tokens || 0) * output_rate / 1_000_000.0 + cached_cost = (msg.cached_tokens || 0) * cached_rate / 1_000_000.0 + + input_cost + output_cost + cached_cost + end + end + def build_activity_chart_data dates = (6.days.ago.to_date..Date.current).to_a @@ -34,10 +52,28 @@ def build_activity_chart_data labels: dates.map { |d| d.strftime("%b %d") }, users: dates.map { |d| User.where(created_at: d.all_day).count }, chats: dates.map { |d| Chat.where(created_at: d.all_day).count }, - messages: dates.map { |d| Message.where(created_at: d.all_day).count } + messages: dates.map { |d| Message.where(created_at: d.all_day).count }, + cost: dates.map { |d| calculate_daily_cost(d) } } end + def calculate_daily_cost(date) + Message.includes(:model).where(created_at: date.all_day).where.not(model_id: nil).sum do |msg| + next 0 unless msg.model&.pricing.present? + + pricing = msg.model.pricing.dig("text_tokens", "standard") || {} + input_rate = pricing["input_per_million"] || 0 + output_rate = pricing["output_per_million"] || 0 + cached_rate = pricing["cached_input_per_million"] || 0 + + input_cost = (msg.input_tokens || 0) * input_rate / 1_000_000.0 + output_cost = (msg.output_tokens || 0) * output_rate / 1_000_000.0 + cached_cost = (msg.cached_tokens || 0) * cached_rate / 1_000_000.0 + + input_cost + output_cost + cached_cost + end.round(4) + end + def build_message_role_data roles = Message.group(:role).count { diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 7389f3b..f7df7b9 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -25,7 +25,7 @@ def verify user = User.find_signed!(params[:token], purpose: :magic_link) session[:user_id] = user.id - redirect_to home_path, notice: "Welcome back, #{user.name}!" + redirect_to root_path, notice: "Welcome back, #{user.name}!" rescue ActiveSupport::MessageVerifier::InvalidSignature redirect_to new_session_path, alert: "Invalid or expired magic link" end diff --git a/app/views/chats/show.html.erb b/app/views/chats/show.html.erb index 54e2276..81638cd 100644 --- a/app/views/chats/show.html.erb +++ b/app/views/chats/show.html.erb @@ -4,13 +4,6 @@ <% content_for :full_width, true %>
-
- <%= link_to chats_path, class: "p-2 hover:bg-dark-800 rounded-lg transition-colors" do %> - <%= inline_svg "icons/chevron-left.svg", class: "w-5 h-5 text-dark-400" %> - <% end %> - <%= @chat.model&.name || 'Chat' %> -
-
<% @chat.messages.where.not(id: nil).each do |message| %> @@ -19,7 +12,7 @@
-
+
<%= render "messages/form", chat: @chat, message: @message %>
diff --git a/app/views/home/index.html.erb b/app/views/home/index.html.erb index ce47e6c..4be4565 100644 --- a/app/views/home/index.html.erb +++ b/app/views/home/index.html.erb @@ -7,7 +7,7 @@
-
+
<%= inline_svg "icons/users.svg", class: "w-6 h-6 text-indigo-400" %> @@ -30,7 +30,7 @@
-
+
<%= inline_svg "icons/shield.svg", class: "w-6 h-6 text-green-400" %> @@ -41,7 +41,7 @@

You're signed in using magic link authentication. No passwords needed!

-
+
<%= inline_svg "icons/shield.svg", class: "w-4 h-4 text-green-400 mt-0.5 mr-2 flex-shrink-0" %>
@@ -54,7 +54,7 @@
-
+
<%= inline_svg "icons/lightning.svg", class: "w-6 h-6 text-blue-400 mt-0.5 mr-3 flex-shrink-0" %>
diff --git a/app/views/madmin/application/_sidebar.html.erb b/app/views/madmin/application/_sidebar.html.erb index 363908c..3155d6a 100644 --- a/app/views/madmin/application/_sidebar.html.erb +++ b/app/views/madmin/application/_sidebar.html.erb @@ -2,10 +2,10 @@ <%# Sidebar %>