diff --git a/Gemfile b/Gemfile index 15570b9..0a3ee79 100644 --- a/Gemfile +++ b/Gemfile @@ -21,6 +21,9 @@ gem "jbuilder" # HTML-aware ERB rendering via the Herb engine [https://reactionview.dev] gem "reactionview" +# Admin dashboard for super admins [https://avohq.io] +gem "avo", "~> 3.2" + # Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] gem "bcrypt", "~> 3.1.7" diff --git a/Gemfile.lock b/Gemfile.lock index 5ee8f43..3a298cc 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -104,9 +104,29 @@ GEM specs: action_text-trix (2.1.19) railties + active_link_to (1.0.5) + actionpack + addressable addressable (2.9.0) public_suffix (>= 2.0.2, < 8.0) ast (2.4.3) + avo (3.32.1) + actionview (>= 6.1) + active_link_to + activerecord (>= 6.1) + activesupport (>= 6.1) + addressable + avo-icons (>= 0.1.2) + docile + meta-tags + pagy (>= 7.0.0, < 43) + prop_initializer (>= 0.3.0) + turbo-rails (>= 2.0.0) + turbo_power (>= 0.6.0) + view_component (>= 3.7.0) + zeitwerk (>= 2.6.12) + avo-icons (0.1.6) + inline_svg base64 (0.3.0) bcrypt (3.1.22) bcrypt_pbkdf (1.1.2) @@ -176,6 +196,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.18.0) pp (>= 0.6.0) @@ -217,6 +240,8 @@ GEM net-smtp marcel (1.2.1) matrix (0.4.3) + meta-tags (2.23.0) + actionpack (>= 6.0.0) mini_magick (5.3.1) logger mini_mime (1.1.5) @@ -254,6 +279,7 @@ GEM nokogiri (1.19.4-x86_64-linux-musl) racc (~> 1.4) ostruct (0.6.3) + pagy (9.4.0) parallel (2.1.0) parser (3.3.11.1) ast (~> 2.4.1) @@ -267,6 +293,8 @@ GEM prettyprint prettyprint (0.2.0) prism (1.9.0) + prop_initializer (0.4.0) + zeitwerk (>= 2.6.18) propshaft (1.3.2) actionpack (>= 7.0.0) activesupport (>= 7.0.0) @@ -405,6 +433,8 @@ GEM turbo-rails (2.0.23) 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) @@ -412,6 +442,10 @@ GEM unicode-emoji (4.2.0) uri (1.1.1) useragent (0.16.11) + view_component (4.12.0) + actionview (>= 7.1.0) + activesupport (>= 7.1.0) + concurrent-ruby (~> 1) web-console (4.3.0) actionview (>= 8.0.0) bindex (>= 0.4.0) @@ -437,6 +471,7 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES + avo (~> 3.2) bcrypt (~> 3.1.7) bootsnap brakeman @@ -477,6 +512,7 @@ CHECKSUMS actionpack (8.1.3) actiontext (8.1.3) actionview (8.1.3) + active_link_to (1.0.5) sha256=4830847b3d14589df1e9fc62038ceec015257fce975ec1c2a77836c461b139ba activejob (8.1.3) activemodel (8.1.3) activerecord (8.1.3) @@ -484,6 +520,8 @@ CHECKSUMS activesupport (8.1.3) addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383 + avo (3.32.1) sha256=0cbc80204734585769fdc68c2721c749b754cd3f8139d4da0667b4c4b22fe849 + avo-icons (0.1.6) sha256=6f1c985aca238d81446e517cee4924752e7ada1604971f6bb59913472e123f4c base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b bcrypt (3.1.22) sha256=1f0072e88c2d705d94aff7f2c5cb02eb3f1ec4b8368671e19112527489f29032 bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6 @@ -528,6 +566,7 @@ CHECKSUMS i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5 image_processing (1.14.0) sha256=754cc169c9c262980889bec6bfd325ed1dafad34f85242b5a07b60af004742fb importmap-rails (2.2.3) sha256=7101be2a4dc97cf1558fb8f573a718404c5f6bcfe94f304bf1f39e444feeb16a + inline_svg (1.10.0) sha256=5b652934236fd9f8adc61f3fd6e208b7ca3282698b19f28659971da84bf9a10f io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 jbuilder (2.15.1) sha256=2430bec28fb0cebacb5875b1009cf9d8bc3c303ccb810c4c8b062a4b51457637 @@ -542,6 +581,7 @@ CHECKSUMS mail (2.9.0) sha256=6fa6673ecd71c60c2d996260f9ee3dd387d4673b8169b502134659ece6d34941 marcel (1.2.1) sha256=1678e9360e32f9eafa917c80029e2f6d10b2715c66a4b87b6d0da9b9cd1f859f matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b + meta-tags (2.23.0) sha256=ffe78b5bee398de4ff5ac3316f5a786049538a651643b8476def06c3acc762c1 mini_magick (5.3.1) sha256=29395dfd76badcabb6403ee5aff6f681e867074f8f28ce08d78661e9e4a351c4 mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef minitest (6.0.6) sha256=153ea36d1d987a62942382b61075745042a2b3123b1cd48f4c3675af9cc7d6f1 @@ -562,6 +602,7 @@ CHECKSUMS nokogiri (1.19.4-x86_64-linux-gnu) sha256=379fae440b28915e3f19d752ce2dcf8465ed2b2fbefd2a7ca0dd497bc981a06a nokogiri (1.19.4-x86_64-linux-musl) sha256=17dfb7c1fa194ae02fbf7c51a7afc8d278045ab3fdacfd86f91d02d7b274470b ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 + pagy (9.4.0) sha256=db3f2e043f684155f18f78be62a81e8d033e39b9f97b1e1a8d12ad38d7bce738 parallel (2.1.0) sha256=b35258865c2e31134c5ecb708beaaf6772adf9d5efae28e93e99260877b09356 parser (3.3.11.1) sha256=d17ace7aabe3e72c3cc94043714be27cc6f852f104d81aa284c2281aecc65d54 postmark (1.25.1) sha256=2ac110fa1b4f85a62996fdc63167922eea1c4284a9a7ceb9e007f2758d9e17be @@ -569,6 +610,7 @@ CHECKSUMS pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6 prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 + prop_initializer (0.4.0) sha256=487d31d44a58ec7654a7db9eb23cad30030a5bbf16e6a53c71e3cbb88be9e1d8 propshaft (1.3.2) sha256=1d56a3e56a92c21bfc29caf07406b5386b00d4c47ddf357cf989a5a234b1389e psych (5.4.0) sha256=14f72d69a611af663d7d70e4a7b67d9eb1f3ae9f8d916b478961d5a0075ba5b7 public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 @@ -631,11 +673,13 @@ CHECKSUMS timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f turbo-rails (2.0.23) sha256=ee0d90733aafff056cf51ff11e803d65e43cae258cc55f6492020ec1f9f9315f + 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.12.0) sha256=b9a5979d1c43eba2aa21a06a86f661aab3990104855f2909ef7c3779acdcdcb4 web-console (4.3.0) sha256=e13b71301cdfc2093f155b5aa3a622db80b4672d1f2f713119cc7ec7ac6a6da4 websocket (1.2.11) sha256=b7e7a74e2410b5e85c25858b26b3322f29161e300935f70a0e0d3c35e0462737 websocket-driver (0.8.1) sha256=5ab238238ce230e5d4b262d2be39624c867914eab99171dc4952b58b577c2d96 diff --git a/app/avo/resources/user.rb b/app/avo/resources/user.rb new file mode 100644 index 0000000..37542d3 --- /dev/null +++ b/app/avo/resources/user.rb @@ -0,0 +1,17 @@ +class Avo::Resources::User < Avo::BaseResource + self.title = :email_address + + def fields + field :id, as: :id + field :email_address, as: :text, readonly: true + field :name, as: :text, readonly: true + + field :super_admin, as: :boolean + + field :confirmed_at, as: :date_time, readonly: true + field :created_at, as: :date_time, readonly: true, only_on: :show + field :updated_at, as: :date_time, readonly: true, only_on: :show + + field :organizations, as: :has_many, through: :organization_memberships + end +end diff --git a/app/controllers/avo/users_controller.rb b/app/controllers/avo/users_controller.rb new file mode 100644 index 0000000..a9987c6 --- /dev/null +++ b/app/controllers/avo/users_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::UsersController < Avo::ResourcesController +end diff --git a/app/controllers/concerns/authentication.rb b/app/controllers/concerns/authentication.rb index a819597..0ea939f 100644 --- a/app/controllers/concerns/authentication.rb +++ b/app/controllers/concerns/authentication.rb @@ -26,7 +26,7 @@ def resume_session end def find_session_by_cookie - Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id] + Session.find_by_cookie(cookies) end def request_authentication diff --git a/app/models/session.rb b/app/models/session.rb index cf376fb..100b5d4 100644 --- a/app/models/session.rb +++ b/app/models/session.rb @@ -1,3 +1,7 @@ class Session < ApplicationRecord belongs_to :user + + def self.find_by_cookie(cookies) + find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id] + end end diff --git a/config/initializers/avo.rb b/config/initializers/avo.rb new file mode 100644 index 0000000..7b113bd --- /dev/null +++ b/config/initializers/avo.rb @@ -0,0 +1,172 @@ +# 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" + + # 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 == + # This app has no `current_user` helper — sessions are resolved into Current.user. + config.current_user_method do + Current.user + end + + # Avo's controllers don't run the app's Authentication/SetCurrentOrganization + # concerns, so Current is empty here. Resume the session from the signed cookie + # (same lookup as Authentication#resume_session), then allow only super_admins. + # Everyone else is sent back to the app's home page via main_app. + config.authenticate_with do + Current.session ||= Session.find_by_cookie(cookies) + + redirect_to main_app.root_path unless Current.user&.super_admin? + end + + ## == 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_row_controls_config = { + # placement: :right, + # float: false, + # show_on_hover: false + # }.freeze + # 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 + + ## == 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 + # config.exclude_from_status = [] + # config.model_generator_hook = true + + ## == 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 4df8432..dba0516 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,6 @@ Rails.application.routes.draw do + mount_avo + # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html resource :home resource :dashboard, only: :show diff --git a/test/integration/avo_access_test.rb b/test/integration/avo_access_test.rb new file mode 100644 index 0000000..f2634ac --- /dev/null +++ b/test/integration/avo_access_test.rb @@ -0,0 +1,32 @@ +require "test_helper" + +class AvoAccessTest < ActionDispatch::IntegrationTest + test "super_admins can reach the Avo dashboard" do + sign_in_as users(:super_admin) + get "/avo" + # /avo lands on the first resource (there is no custom dashboard yet). + follow_redirect! + assert_response :success + end + + test "non-super_admins are redirected to the home page" do + sign_in_as users(:one) + get "/avo" + assert_redirected_to "/" + end + + test "signed-out visitors are redirected to the home page" do + get "/avo" + assert_redirected_to "/" + end + + test "a super_admin can promote another user via the User resource" do + sign_in_as users(:super_admin) + user = users(:one) + assert_not user.super_admin? + + patch "/avo/resources/users/#{user.id}", params: { user: { super_admin: "1" } } + + assert user.reload.super_admin? + end +end