super_admin 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +216 -0
  3. data/Rakefile +30 -0
  4. data/app/assets/stylesheets/super_admin/application.css +15 -0
  5. data/app/assets/stylesheets/super_admin/tailwind.css +1 -0
  6. data/app/assets/stylesheets/super_admin/tailwind.source.css +25 -0
  7. data/app/controllers/super_admin/application_controller.rb +89 -0
  8. data/app/controllers/super_admin/associations_controller.rb +136 -0
  9. data/app/controllers/super_admin/audit_logs_controller.rb +39 -0
  10. data/app/controllers/super_admin/base_controller.rb +133 -0
  11. data/app/controllers/super_admin/dashboard_controller.rb +29 -0
  12. data/app/controllers/super_admin/exports_controller.rb +109 -0
  13. data/app/controllers/super_admin/resources_controller.rb +201 -0
  14. data/app/dashboards/super_admin/base_dashboard.rb +200 -0
  15. data/app/errors/super_admin/configuration_error.rb +6 -0
  16. data/app/helpers/super_admin/application_helper.rb +84 -0
  17. data/app/helpers/super_admin/exports_helper.rb +16 -0
  18. data/app/helpers/super_admin/resources_helper.rb +204 -0
  19. data/app/helpers/super_admin/route_helper.rb +7 -0
  20. data/app/javascript/super_admin/application.js +263 -0
  21. data/app/jobs/super_admin/application_job.rb +4 -0
  22. data/app/jobs/super_admin/generate_super_admin_csv_export_job.rb +100 -0
  23. data/app/mailers/super_admin/application_mailer.rb +6 -0
  24. data/app/models/super_admin/application_record.rb +5 -0
  25. data/app/models/super_admin/audit_log.rb +35 -0
  26. data/app/models/super_admin/csv_export.rb +67 -0
  27. data/app/services/super_admin/auditing.rb +74 -0
  28. data/app/services/super_admin/authorization.rb +113 -0
  29. data/app/services/super_admin/authorization_adapters/base_adapter.rb +100 -0
  30. data/app/services/super_admin/authorization_adapters/default_adapter.rb +77 -0
  31. data/app/services/super_admin/authorization_adapters/proc_adapter.rb +65 -0
  32. data/app/services/super_admin/authorization_adapters/pundit_adapter.rb +81 -0
  33. data/app/services/super_admin/csv_export_creator.rb +45 -0
  34. data/app/services/super_admin/dashboard_registry.rb +90 -0
  35. data/app/services/super_admin/dashboard_resolver.rb +100 -0
  36. data/app/services/super_admin/filter_builder.rb +185 -0
  37. data/app/services/super_admin/form_builder.rb +59 -0
  38. data/app/services/super_admin/form_fields/array_field.rb +35 -0
  39. data/app/services/super_admin/form_fields/association_field.rb +146 -0
  40. data/app/services/super_admin/form_fields/base_field.rb +53 -0
  41. data/app/services/super_admin/form_fields/boolean_field.rb +29 -0
  42. data/app/services/super_admin/form_fields/date_field.rb +15 -0
  43. data/app/services/super_admin/form_fields/date_time_field.rb +15 -0
  44. data/app/services/super_admin/form_fields/enum_field.rb +27 -0
  45. data/app/services/super_admin/form_fields/factory.rb +102 -0
  46. data/app/services/super_admin/form_fields/nested_field.rb +120 -0
  47. data/app/services/super_admin/form_fields/number_field.rb +29 -0
  48. data/app/services/super_admin/form_fields/text_area_field.rb +19 -0
  49. data/app/services/super_admin/model_inspector.rb +182 -0
  50. data/app/services/super_admin/queries/base_query.rb +45 -0
  51. data/app/services/super_admin/queries/filter_query.rb +188 -0
  52. data/app/services/super_admin/queries/resource_scope_query.rb +74 -0
  53. data/app/services/super_admin/queries/search_query.rb +146 -0
  54. data/app/services/super_admin/queries/sort_query.rb +41 -0
  55. data/app/services/super_admin/resource_configuration.rb +63 -0
  56. data/app/services/super_admin/resource_exporter.rb +78 -0
  57. data/app/services/super_admin/resource_query.rb +40 -0
  58. data/app/services/super_admin/resources/association_inspector.rb +112 -0
  59. data/app/services/super_admin/resources/collection_presenter.rb +63 -0
  60. data/app/services/super_admin/resources/context.rb +63 -0
  61. data/app/services/super_admin/resources/filter_params.rb +29 -0
  62. data/app/services/super_admin/resources/permitted_attributes.rb +104 -0
  63. data/app/services/super_admin/resources/value_normalizer.rb +121 -0
  64. data/app/services/super_admin/sensitive_attributes.rb +166 -0
  65. data/app/views/layouts/super_admin.html.erb +74 -0
  66. data/app/views/super_admin/audit_logs/index.html.erb +143 -0
  67. data/app/views/super_admin/dashboard/index.html.erb +79 -0
  68. data/app/views/super_admin/exports/index.html.erb +84 -0
  69. data/app/views/super_admin/exports/show.html.erb +57 -0
  70. data/app/views/super_admin/resources/_form.html.erb +42 -0
  71. data/app/views/super_admin/resources/destroy.turbo_stream.erb +17 -0
  72. data/app/views/super_admin/resources/edit.html.erb +37 -0
  73. data/app/views/super_admin/resources/index.html.erb +189 -0
  74. data/app/views/super_admin/resources/new.html.erb +31 -0
  75. data/app/views/super_admin/resources/show.html.erb +106 -0
  76. data/app/views/super_admin/shared/_breadcrumbs.html.erb +12 -0
  77. data/app/views/super_admin/shared/_custom_styles.html.erb +132 -0
  78. data/app/views/super_admin/shared/_flash.html.erb +55 -0
  79. data/app/views/super_admin/shared/_form_field.html.erb +35 -0
  80. data/app/views/super_admin/shared/_navigation.html.erb +92 -0
  81. data/app/views/super_admin/shared/_nested_fields.html.erb +59 -0
  82. data/app/views/super_admin/shared/_nested_record_fields.html.erb +45 -0
  83. data/config/importmap.rb +4 -0
  84. data/config/initializers/rack_attack.rb +134 -0
  85. data/config/initializers/super_admin.rb +117 -0
  86. data/config/locales/super_admin.en.yml +197 -0
  87. data/config/locales/super_admin.fr.yml +197 -0
  88. data/config/routes.rb +22 -0
  89. data/lib/generators/super_admin/dashboard_generator.rb +50 -0
  90. data/lib/generators/super_admin/install_generator.rb +58 -0
  91. data/lib/generators/super_admin/templates/20240101000001_create_super_admin_audit_logs.rb +24 -0
  92. data/lib/generators/super_admin/templates/20240101000002_create_super_admin_csv_exports.rb +33 -0
  93. data/lib/generators/super_admin/templates/super_admin.rb +58 -0
  94. data/lib/super_admin/dashboard_creator.rb +256 -0
  95. data/lib/super_admin/engine.rb +53 -0
  96. data/lib/super_admin/install_task.rb +96 -0
  97. data/lib/super_admin/version.rb +3 -0
  98. data/lib/super_admin.rb +7 -0
  99. data/lib/tasks/super_admin_tasks.rake +38 -0
  100. metadata +239 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ec0af8666f1869249cfb770f05f2ef9891bd321359fe4f03f1af34409c9b24b6
4
+ data.tar.gz: f6e7dcb811c07530fabcf6517a0c0109ff54c4ffe3fa3a09ab0a2ed40ab3f1f9
5
+ SHA512:
6
+ metadata.gz: d76e98768f788a4cc2dcfcdea9bf98c3bf0ccb7e0dd71c80c5d92eba4a35d134ec85b6d7ed4c28918acb109792c2e6c3d18ac300e640faa5f75428d54fafa424
7
+ data.tar.gz: dbdf1d1f0e045e750667eddf83477b2e799eaf047a2113c42f20457d56b32c6a45f8c480bd7b47539b7610cfa95bfdbc71e6a37face6c655c681d14e3fbd0d85
data/README.md ADDED
@@ -0,0 +1,216 @@
1
+ # SuperAdmin 🚀
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/super_admin.svg)](https://badge.fury.io/rb/super_admin)
4
+ [![CI Status](https://github.com/ThibautBaissac/super_admin/actions/workflows/ci.yml/badge.svg)](https://github.com/ThibautBaissac/super_admin/actions/workflows/ci.yml)
5
+ [![Ruby Style Guide](https://img.shields.io/badge/code_style-rubocop-brightgreen.svg)](https://github.com/rubocop/rubocop)
6
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7
+
8
+ **A modern, flexible administration engine for Rails applications**
9
+
10
+ SuperAdmin is a mountable Rails engine that provides a full-featured administration interface inspired by Administrate and ActiveAdmin, built for modern Rails 7+ applications with Hotwire, Turbo, and Tailwind CSS.
11
+
12
+ ![SuperAdmin Dashboard](docs/images/dashboard-interface.png)
13
+
14
+ **Quick Links:** [📖 Documentation](docs/) • [⚡ Features](docs/FEATURES.md) • [🚀 Quick Start](#quick-start) • [🤝 Contributing](#contributing)
15
+
16
+ ---
17
+
18
+ ## Why SuperAdmin?
19
+
20
+ - **Built for Rails 7+** - Takes full advantage of Hotwire, Turbo, and modern Rails features
21
+ - **⚡ Fast by Default** - Turbo-powered interactions without writing JavaScript
22
+ - **Beautiful UI** - Modern, responsive design with Tailwind CSS
23
+ - **Easy to Customize** - Simple DSL that doesn't get in your way
24
+ - **Batteries Included** - Audit logging, CSV exports, and authorization built-in
25
+ - **Quick Setup** - Get a full admin panel running in under 5 minutes
26
+
27
+ ## Status
28
+
29
+ > **In Active Development** - This gem is currently in active development. APIs may change between versions. Production use is not recommended until v1.0 release.
30
+
31
+ ## Requirements
32
+
33
+ - **Ruby** >= 3.2
34
+ - **Rails** >= 7.1
35
+ - **ImportMap Rails** (default in Rails 8+)
36
+
37
+ ## Installation
38
+
39
+ ### 1. Add the Gem
40
+
41
+ ```ruby
42
+ gem "super_admin", git: "https://github.com/ThibautBaissac/super_admin.git"
43
+ ```
44
+
45
+ Then run:
46
+ ```bash
47
+ bundle install
48
+ ```
49
+
50
+ ### 2. Install SuperAdmin
51
+
52
+ ```bash
53
+ rails generate super_admin:install
54
+ rails db:migrate
55
+ ```
56
+
57
+ ### 3. Mount the Engine
58
+
59
+ Add to your `config/routes.rb`:
60
+
61
+ ```ruby
62
+ mount SuperAdmin::Engine => '/super_admin'
63
+ ```
64
+
65
+ ### 4. Generate Dashboards
66
+
67
+ ```bash
68
+ # Generate dashboards for all models
69
+ rails generate super_admin:dashboard
70
+
71
+ # Or for a specific model
72
+ rails generate super_admin:dashboard User
73
+ ```
74
+
75
+ ### 5. Start Your Server
76
+
77
+ ```bash
78
+ bin/dev
79
+ ```
80
+
81
+ Visit `/super_admin` and you're done! 🎉
82
+
83
+ ## Quick Start
84
+
85
+ ### Customize a Dashboard
86
+
87
+ Edit the generated dashboard to control which attributes are displayed:
88
+
89
+ ```ruby
90
+ # app/dashboards/super_admin/user_dashboard.rb
91
+ module SuperAdmin
92
+ class UserDashboard < BaseDashboard
93
+ # Attributes shown in the table view
94
+ def collection_attributes
95
+ %i[id email name created_at]
96
+ end
97
+
98
+ # Attributes shown in the detail view
99
+ def show_attributes
100
+ %i[id email name role created_at updated_at posts]
101
+ end
102
+
103
+ # Attributes shown in the form
104
+ def form_attributes
105
+ %i[email name role bio]
106
+ end
107
+ end
108
+ end
109
+ ```
110
+
111
+ ### Configure Authorization
112
+
113
+ In `config/initializers/super_admin.rb`:
114
+
115
+ ```ruby
116
+ SuperAdmin.configure do |config|
117
+ # Use Pundit
118
+ config.authorization_adapter = :pundit
119
+
120
+ # Or custom authorization
121
+ config.authorize_with do
122
+ redirect_to root_path unless current_user&.admin?
123
+ end
124
+
125
+ # Number of records per page
126
+ config.records_per_page = 20
127
+
128
+ # Maximum nested depth for associations
129
+ config.max_nested_depth = 3
130
+ end
131
+ ```
132
+
133
+ ## Key Features
134
+
135
+ - **Full CRUD Operations** - Create, read, update, delete with minimal configuration
136
+ - **Advanced Filtering** - Search, filter, and sort with dynamic UI
137
+ - **CSV Exports** - Background job processing with status tracking
138
+ - **Audit Logging** - Track all admin activities automatically
139
+ - **Nested Associations** - Smart form builder with configurable depth
140
+ - **Authorization** - Pundit, CanCanCan, or custom authorization
141
+ - **Turbo Streams** - Real-time updates without page reloads
142
+ - **Accessibility** - WCAG 2.1 compliant with ARIA labels and keyboard navigation
143
+
144
+ [📖 See all features](docs/FEATURES.md)
145
+
146
+ ## Documentation
147
+
148
+ - **[Features](docs/FEATURES.md)** - Complete feature list
149
+ - **[Architecture](CLAUDE.md)** - Technical architecture and patterns
150
+ - **[Contributing](CONTRIBUTING.md)** - How to contribute
151
+ - **[Code of Conduct](CODE_OF_CONDUCT.md)** - Community guidelines
152
+
153
+ ## Development
154
+
155
+ ### Setup
156
+
157
+ ```bash
158
+ git clone https://github.com/ThibautBaissac/super_admin.git
159
+ cd super_admin
160
+ bundle install
161
+ ```
162
+
163
+ ### Running Tests
164
+
165
+ ```bash
166
+ bin/rails test
167
+ ```
168
+
169
+ ### Code Quality
170
+
171
+ ```bash
172
+ bin/rubocop
173
+ ```
174
+
175
+ ### Testing with a Rails App
176
+
177
+ ```bash
178
+ cd test/dummy
179
+ bin/rails db:seed
180
+ bin/dev
181
+ # Visit http://localhost:3000/super_admin/
182
+ ```
183
+
184
+ ## Contributing
185
+
186
+ We welcome contributions! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
187
+
188
+ 1. Fork the repository
189
+ 2. Create your feature branch (`git checkout -b feat/my-feature`)
190
+ 3. Write tests for your changes
191
+ 4. Run tests and linter (`bin/rails test && bin/rubocop`)
192
+ 5. Commit your changes (`git commit -am 'Add new feature'`)
193
+ 6. Push to the branch (`git push origin feat/my-feature`)
194
+ 7. Create a Pull Request
195
+
196
+ ## License
197
+
198
+ SuperAdmin is released under the [MIT License](MIT-LICENSE).
199
+
200
+ ## Support
201
+
202
+ - 🐛 [Report a Bug](https://github.com/ThibautBaissac/super_admin/issues/new?labels=bug)
203
+ - 💡 [Request a Feature](https://github.com/ThibautBaissac/super_admin/issues/new?labels=enhancement)
204
+ - 📖 [Documentation](docs/)
205
+
206
+ ## Acknowledgments
207
+
208
+ SuperAdmin is inspired by:
209
+ - [Administrate](https://github.com/thoughtbot/administrate) - Thoughtbot's admin framework
210
+ - [ActiveAdmin](https://github.com/activeadmin/activeadmin) - The Ruby on Rails framework for creating elegant backends
211
+
212
+ ---
213
+
214
+ Made with ❤️ by [Thibaut Baissac](https://github.com/ThibautBaissac)
215
+
216
+ [⭐ Star this repo](https://github.com/ThibautBaissac/super_admin) • [🐛 Report an issue](https://github.com/ThibautBaissac/super_admin/issues)
data/Rakefile ADDED
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+
5
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
6
+ load APP_RAKEFILE if File.exist?(APP_RAKEFILE)
7
+
8
+ load "rails/tasks/engine.rake"
9
+ load "rails/tasks/statistics.rake"
10
+
11
+ require "rake/testtask"
12
+
13
+ Rake::TestTask.new(:test) do |t|
14
+ t.libs << "test"
15
+ t.pattern = "test/**/*_test.rb"
16
+ t.verbose = false
17
+ end
18
+
19
+ # Documentation with YARD
20
+ begin
21
+ require "yard"
22
+ YARD::Rake::YardocTask.new do |t|
23
+ t.files = [ "app/**/*.rb", "lib/**/*.rb" ]
24
+ t.options = [ "--markup", "markdown", "--readme", "README.md" ]
25
+ end
26
+ rescue LoadError
27
+ # YARD not available
28
+ end
29
+
30
+ task default: :test
@@ -0,0 +1,15 @@
1
+ /*
2
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
3
+ * listed below.
4
+ *
5
+ * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6
+ * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
7
+ *
8
+ * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9
+ * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10
+ * files in this directory. Styles in this file should be added after the last require_* statement.
11
+ * It is generally better to create a new file per style scope.
12
+ *
13
+ *= require_tree .
14
+ *= require_self
15
+ */
@@ -0,0 +1 @@
1
+ *,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}body{overflow-x:hidden}.container{width:100%}@media (min-width:640px){.container{max-width:640px}}@media (min-width:768px){.container{max-width:768px}}@media (min-width:1024px){.container{max-width:1024px}}@media (min-width:1280px){.container{max-width:1280px}}@media (min-width:1536px){.container{max-width:1536px}}.sr-only{height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px;clip:rect(0,0,0,0);border-width:0;white-space:nowrap}.fixed{position:fixed}.sticky{position:sticky}.inset-0{inset:0}.inset-y-0{bottom:0;top:0}.left-0{left:0}.top-0{top:0}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.col-span-2{grid-column:span 2/span 2}.-mx-3{margin-left:-.75rem;margin-right:-.75rem}.mx-2{margin-left:.5rem;margin-right:.5rem}.mx-auto{margin-left:auto;margin-right:auto}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.mr-2{margin-right:.5rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.mt-auto{margin-top:auto}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-12{height:3rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-8{height:2rem}.h-full{height:100%}.min-h-screen{min-height:100vh}.w-12{width:3rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-64{width:16rem}.w-8{width:2rem}.w-full{width:100%}.min-w-0{min-width:0}.min-w-\[180px\]{min-width:180px}.min-w-\[200px\]{min-width:200px}.min-w-full{min-width:100%}.max-w-3xl{max-width:48rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-7xl{max-width:80rem}.max-w-full{max-width:100%}.max-w-md{max-width:28rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-shrink-0{flex-shrink:0}.-translate-x-full{--tw-translate-x:-100%}.-translate-x-full,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-pointer{cursor:pointer}.list-inside{list-style-position:inside}.list-disc{list-style-type:disc}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-6{row-gap:1.5rem}.space-x-1>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.25rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.25rem*var(--tw-space-x-reverse))}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.5rem*var(--tw-space-x-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.25rem*var(--tw-space-y-reverse));margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1.5rem*var(--tw-space-y-reverse));margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-bottom-width:calc(1px*var(--tw-divide-y-reverse));border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)))}.divide-gray-200>:not([hidden])~:not([hidden]){--tw-divide-opacity:1;border-color:rgb(229 231 235/var(--tw-divide-opacity,1))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-blue-200{--tw-border-opacity:1;border-color:rgb(191 219 254/var(--tw-border-opacity,1))}.border-blue-300{--tw-border-opacity:1;border-color:rgb(147 197 253/var(--tw-border-opacity,1))}.border-blue-400{--tw-border-opacity:1;border-color:rgb(96 165 250/var(--tw-border-opacity,1))}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.border-gray-800{--tw-border-opacity:1;border-color:rgb(31 41 55/var(--tw-border-opacity,1))}.border-green-200{--tw-border-opacity:1;border-color:rgb(187 247 208/var(--tw-border-opacity,1))}.border-red-200{--tw-border-opacity:1;border-color:rgb(254 202 202/var(--tw-border-opacity,1))}.border-transparent{border-color:transparent}.border-yellow-200{--tw-border-opacity:1;border-color:rgb(254 240 138/var(--tw-border-opacity,1))}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity,1))}.bg-blue-200{--tw-bg-opacity:1;background-color:rgb(191 219 254/var(--tw-bg-opacity,1))}.bg-blue-50{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-blue-700{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity,1))}.bg-blue-900{--tw-bg-opacity:1;background-color:rgb(30 58 138/var(--tw-bg-opacity,1))}.bg-blue-opacity{background-color:rgba(59,130,246,.1)}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-gray-200{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity,1))}.bg-gray-300{--tw-bg-opacity:1;background-color:rgb(209 213 219/var(--tw-bg-opacity,1))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.bg-gray-700{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.bg-gray-900{--tw-bg-opacity:1;background-color:rgb(17 24 39/var(--tw-bg-opacity,1))}.bg-green-100{--tw-bg-opacity:1;background-color:rgb(220 252 231/var(--tw-bg-opacity,1))}.bg-green-200{--tw-bg-opacity:1;background-color:rgb(187 247 208/var(--tw-bg-opacity,1))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity,1))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-green-600{--tw-bg-opacity:1;background-color:rgb(22 163 74/var(--tw-bg-opacity,1))}.bg-green-700{--tw-bg-opacity:1;background-color:rgb(21 128 61/var(--tw-bg-opacity,1))}.bg-green-900{--tw-bg-opacity:1;background-color:rgb(20 83 45/var(--tw-bg-opacity,1))}.bg-green-opacity{background-color:rgba(34,197,94,.1)}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-200{--tw-bg-opacity:1;background-color:rgb(254 202 202/var(--tw-bg-opacity,1))}.bg-red-400{--tw-bg-opacity:1;background-color:rgb(248 113 113/var(--tw-bg-opacity,1))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.bg-red-600{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity,1))}.bg-red-700{--tw-bg-opacity:1;background-color:rgb(185 28 28/var(--tw-bg-opacity,1))}.bg-red-900{--tw-bg-opacity:1;background-color:rgb(127 29 29/var(--tw-bg-opacity,1))}.bg-red-opacity{background-color:rgba(239,68,68,.1)}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-yellow-100{--tw-bg-opacity:1;background-color:rgb(254 249 195/var(--tw-bg-opacity,1))}.bg-yellow-200{--tw-bg-opacity:1;background-color:rgb(254 240 138/var(--tw-bg-opacity,1))}.bg-yellow-50{--tw-bg-opacity:1;background-color:rgb(254 252 232/var(--tw-bg-opacity,1))}.bg-yellow-600{--tw-bg-opacity:1;background-color:rgb(202 138 4/var(--tw-bg-opacity,1))}.bg-yellow-700{--tw-bg-opacity:1;background-color:rgb(161 98 7/var(--tw-bg-opacity,1))}.bg-yellow-900{--tw-bg-opacity:1;background-color:rgb(113 63 18/var(--tw-bg-opacity,1))}.bg-yellow-opacity{background-color:rgba(234,179,8,.1)}.bg-opacity-50{--tw-bg-opacity:0.5}.bg-gradient-blue{background-image:linear-gradient(135deg,#e0f2fe,#bae6fd)}.bg-gradient-green{background-image:linear-gradient(135deg,#d1fae5,#a7f3d0)}.bg-gradient-red{background-image:linear-gradient(135deg,#fee2e2,#fecaca)}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.bg-gradient-yellow{background-image:linear-gradient(135deg,#fef3c7,#fde68a)}.from-gray-50{--tw-gradient-from:#f9fafb var(--tw-gradient-from-position);--tw-gradient-to:rgba(249,250,251,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.to-gray-100{--tw-gradient-to:#f3f4f6 var(--tw-gradient-to-position)}.p-1\.5{padding:.375rem}.p-12{padding:3rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-8{padding-bottom:2rem;padding-top:2rem}.pt-0\.5{padding-top:.125rem}.pt-2{padding-top:.5rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.capitalize{text-transform:capitalize}.italic{font-style:italic}.leading-5{line-height:1.25rem}.leading-relaxed{line-height:1.625}.tracking-wider{letter-spacing:.05em}.text-blue-400{--tw-text-opacity:1;color:rgb(96 165 250/var(--tw-text-opacity,1))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity,1))}.text-blue-800{--tw-text-opacity:1;color:rgb(30 64 175/var(--tw-text-opacity,1))}.text-blue-900{--tw-text-opacity:1;color:rgb(30 58 138/var(--tw-text-opacity,1))}.text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity,1))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity,1))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.text-green-400{--tw-text-opacity:1;color:rgb(74 222 128/var(--tw-text-opacity,1))}.text-green-600{--tw-text-opacity:1;color:rgb(22 163 74/var(--tw-text-opacity,1))}.text-green-700{--tw-text-opacity:1;color:rgb(21 128 61/var(--tw-text-opacity,1))}.text-green-800{--tw-text-opacity:1;color:rgb(22 101 52/var(--tw-text-opacity,1))}.text-green-900{--tw-text-opacity:1;color:rgb(20 83 45/var(--tw-text-opacity,1))}.text-indigo-600{--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity,1))}.text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-red-800{--tw-text-opacity:1;color:rgb(153 27 27/var(--tw-text-opacity,1))}.text-red-900{--tw-text-opacity:1;color:rgb(127 29 29/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-yellow-600{--tw-text-opacity:1;color:rgb(202 138 4/var(--tw-text-opacity,1))}.text-yellow-700{--tw-text-opacity:1;color:rgb(161 98 7/var(--tw-text-opacity,1))}.text-yellow-800{--tw-text-opacity:1;color:rgb(133 77 14/var(--tw-text-opacity,1))}.text-yellow-900{--tw-text-opacity:1;color:rgb(113 63 18/var(--tw-text-opacity,1))}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-md{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px rgba(0,0,0,.1),0 2px 4px -2px rgba(0,0,0,.1);--tw-shadow-colored:0 4px 6px -1px var(--tw-shadow-color),0 2px 4px -2px var(--tw-shadow-color)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.outline{outline-style:solid}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-all{transition-duration:.15s;transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-colors{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1)}.transition-transform{transition-duration:.15s;transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1)}.duration-300{transition-duration:.3s}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}.hover\:bg-black\/5:hover{background-color:rgba(0,0,0,.05)}.hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.hover\:bg-gray-50:hover{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.hover\:bg-gray-800:hover{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.hover\:text-blue-900:hover{--tw-text-opacity:1;color:rgb(30 58 138/var(--tw-text-opacity,1))}.hover\:text-gray-700:hover{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.hover\:text-gray-900:hover{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.hover\:text-indigo-900:hover{--tw-text-opacity:1;color:rgb(49 46 129/var(--tw-text-opacity,1))}.hover\:text-red-700:hover{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.hover\:text-white:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.focus\:border-blue-500:focus{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-inset:focus{--tw-ring-inset:inset}.focus\:ring-blue-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246/var(--tw-ring-opacity,1))}.focus\:ring-current:focus{--tw-ring-color:currentColor}.focus\:ring-gray-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(107 114 128/var(--tw-ring-opacity,1))}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px}@media (min-width:640px){.sm\:mx-0{margin-left:0;margin-right:0}.sm\:mb-6{margin-bottom:1.5rem}.sm\:mb-8{margin-bottom:2rem}.sm\:ml-4{margin-left:1rem}.sm\:mt-6{margin-top:1.5rem}.sm\:table-cell{display:table-cell}.sm\:h-10{height:2.5rem}.sm\:h-5{height:1.25rem}.sm\:w-10{width:2.5rem}.sm\:w-5{width:1.25rem}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:justify-between{justify-content:space-between}.sm\:gap-3{gap:.75rem}.sm\:gap-4{gap:1rem}.sm\:gap-6{gap:1.5rem}.sm\:space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)));margin-right:calc(.5rem*var(--tw-space-x-reverse))}.sm\:p-4{padding:1rem}.sm\:p-6{padding:1.5rem}.sm\:px-4{padding-left:1rem;padding-right:1rem}.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}.sm\:py-2\.5{padding-bottom:.625rem;padding-top:.625rem}.sm\:py-4{padding-bottom:1rem;padding-top:1rem}.sm\:py-5{padding-bottom:1.25rem;padding-top:1.25rem}.sm\:py-8{padding-bottom:2rem;padding-top:2rem}.sm\:text-3xl{font-size:1.875rem;line-height:2.25rem}.sm\:text-base{font-size:1rem;line-height:1.5rem}.sm\:text-lg{font-size:1.125rem;line-height:1.75rem}.sm\:text-sm{font-size:.875rem;line-height:1.25rem}}@media (min-width:768px){.md\:flex-row{flex-direction:row}.md\:items-center{align-items:center}.md\:justify-between{justify-content:space-between}}@media (min-width:1024px){.lg\:static{position:static}.lg\:mb-6{margin-bottom:1.5rem}.lg\:mr-3{margin-right:.75rem}.lg\:hidden{display:none}.lg\:translate-x-0{--tw-translate-x:0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:justify-start{justify-content:flex-start}.lg\:px-4{padding-left:1rem;padding-right:1rem}.lg\:px-8{padding-left:2rem;padding-right:2rem}.lg\:py-6{padding-bottom:1.5rem;padding-top:1.5rem}.lg\:text-2xl{font-size:1.5rem;line-height:2rem}}
@@ -0,0 +1,25 @@
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ /* SuperAdmin custom styles */
6
+ /* These styles extend Tailwind's utility classes for SuperAdmin-specific needs */
7
+
8
+ @layer utilities {
9
+ /* Ensure mobile menu overlay appears above content */
10
+ .z-mobile-menu {
11
+ z-index: 40;
12
+ }
13
+
14
+ /* Smooth transitions for mobile menu */
15
+ .transition-sidebar {
16
+ transition: transform 0.3s ease-in-out;
17
+ }
18
+ }
19
+
20
+ @layer base {
21
+ /* Prevent horizontal scroll on mobile */
22
+ body {
23
+ overflow-x: hidden;
24
+ }
25
+ }
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperAdmin
4
+ # Base application controller for SuperAdmin namespace.
5
+ # Delegates authentication and layout decisions to configuration to ease gem extraction.
6
+ class ApplicationController < SuperAdmin.configuration.parent_controller_constant
7
+ layout -> { SuperAdmin.configuration.layout }
8
+
9
+ helper SuperAdmin::ApplicationHelper
10
+
11
+ before_action :authenticate_super_admin_user!, if: -> { SuperAdmin.configuration.authenticate_with.present? }
12
+ around_action :with_super_admin_locale, if: -> { SuperAdmin.configuration.default_locale.present? }
13
+
14
+ private
15
+
16
+ def authenticate_super_admin_user!
17
+ strategy = SuperAdmin.configuration.authenticate_with
18
+
19
+ case strategy
20
+ when Proc
21
+ invoke_proc_strategy(strategy, self)
22
+ when Symbol, String
23
+ send(strategy)
24
+ end
25
+ end
26
+
27
+ def with_super_admin_locale(&block)
28
+ locale = SuperAdmin.configuration.default_locale || I18n.default_locale
29
+ I18n.with_locale(locale, &block)
30
+ end
31
+
32
+ def current_user
33
+ strategy = SuperAdmin.configuration.current_user_method
34
+
35
+ case strategy
36
+ when Proc
37
+ result = invoke_proc_strategy(strategy, self)
38
+
39
+ if result.nil?
40
+ original_receiver = proc_original_receiver(strategy)
41
+ if original_receiver && !original_receiver.equal?(self)
42
+ begin
43
+ alternate = invoke_proc_strategy(strategy, original_receiver)
44
+ result = alternate unless alternate.nil?
45
+ rescue NameError, NoMethodError
46
+ # Ignore fallback errors; return the best effort result
47
+ end
48
+ end
49
+ end
50
+
51
+ return result unless result.nil?
52
+
53
+ defined?(super) ? super : nil
54
+ when Symbol, String
55
+ strategy_name = strategy.to_sym
56
+
57
+ if strategy_name == __method__
58
+ defined?(super) ? super : nil
59
+ elsif respond_to?(strategy_name, true)
60
+ send(strategy_name)
61
+ elsif defined?(super)
62
+ super
63
+ end
64
+ else
65
+ defined?(super) ? super : nil
66
+ end
67
+ end
68
+
69
+ def invoke_proc_strategy(strategy, receiver)
70
+ return unless strategy
71
+
72
+ arity = strategy.arity
73
+
74
+ if arity.zero?
75
+ receiver.instance_exec(&strategy)
76
+ else
77
+ strategy.call(receiver)
78
+ end
79
+ rescue ArgumentError
80
+ strategy.call(receiver)
81
+ end
82
+
83
+ def proc_original_receiver(strategy)
84
+ strategy.binding.receiver
85
+ rescue ArgumentError
86
+ nil
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperAdmin
4
+ # API controller for association search in forms.
5
+ # Enables pagination and search in large collections.
6
+ class AssociationsController < SuperAdmin::BaseController
7
+ # GET /super_admin/associations/search
8
+ # Parameters: model, q (query), page, selected_id
9
+ def search
10
+ model_class = SuperAdmin::ModelInspector.find_model(params[:model])
11
+
12
+ unless model_class
13
+ return render json: { error: "Model not found" }, status: :not_found
14
+ end
15
+
16
+ query = params[:q].to_s.strip
17
+ page = [ params[:page].to_i, 1 ].max
18
+ selected_id = params[:selected_id].presence
19
+ per_page = SuperAdmin.association_pagination_limit
20
+
21
+ scope = build_search_scope(model_class, query)
22
+
23
+ total_count = scope.count
24
+ records = scope.offset((page - 1) * per_page).limit(per_page)
25
+
26
+ # Include currently selected record if not in results
27
+ selected_record = nil
28
+ if selected_id.present?
29
+ selected_record = model_class.find_by(id: selected_id)
30
+ records = [ selected_record ] + records.to_a if selected_record && page == 1
31
+ records.uniq!(&:id) if selected_record
32
+ end
33
+
34
+ render json: {
35
+ results: records.map { |r| { id: r.id, text: sanitize_output(display_label_for(r)) } },
36
+ pagination: {
37
+ more: (page * per_page) < total_count,
38
+ page: page,
39
+ per_page: per_page,
40
+ total: total_count
41
+ }
42
+ }
43
+ rescue StandardError => e
44
+ Rails.logger.error("SuperAdmin::AssociationsController - Search error: #{e.message}")
45
+ render json: { error: "Search failed" }, status: :internal_server_error
46
+ end
47
+
48
+ private
49
+
50
+ def display_label_for(record)
51
+ return record.to_s unless record.respond_to?(:attributes)
52
+
53
+ name = record.respond_to?(:name) ? record.name.presence : nil
54
+ email = record.respond_to?(:email) ? record.email.presence : nil
55
+ title = record.respond_to?(:title) ? record.title.presence : nil
56
+ label = record.respond_to?(:label) ? record.label.presence : nil
57
+
58
+ if name && email
59
+ "#{name} (#{email})"
60
+ elsif name
61
+ name
62
+ elsif title && email
63
+ "#{title} (#{email})"
64
+ elsif title
65
+ title
66
+ elsif email
67
+ email
68
+ elsif label
69
+ label
70
+ else
71
+ record.to_s
72
+ end
73
+ end
74
+
75
+ def build_search_scope(model_class, query)
76
+ scope = model_class.all
77
+
78
+ return scope if query.blank?
79
+
80
+ searchable_columns = detect_searchable_columns(model_class)
81
+
82
+ return scope if searchable_columns.empty?
83
+
84
+ # Build secure Arel conditions instead of string interpolation
85
+ arel_table = model_class.arel_table
86
+ sanitized_query = sanitize_sql_like(query)
87
+ pattern = "%#{sanitized_query}%"
88
+
89
+ # Use ILIKE for PostgreSQL (case-insensitive), LIKE with LOWER() for SQLite
90
+ use_ilike = ActiveRecord::Base.connection.adapter_name.downcase.include?("postgres")
91
+
92
+ arel_conditions = searchable_columns.map do |column_name|
93
+ # Ensure column exists in the table to prevent injection
94
+ next unless model_class.column_names.include?(column_name)
95
+
96
+ column = arel_table[column_name.to_sym]
97
+
98
+ if use_ilike
99
+ column.matches(pattern, nil, true) # Third argument = case_insensitive for PostgreSQL
100
+ else
101
+ # For SQLite and other databases, use LOWER() for case-insensitive search
102
+ Arel::Nodes::NamedFunction.new("LOWER", [ column ])
103
+ .matches(Arel::Nodes.build_quoted(pattern.downcase))
104
+ end
105
+ end.compact
106
+
107
+ return scope if arel_conditions.empty?
108
+
109
+ # Combine conditions with OR
110
+ combined_condition = arel_conditions.reduce { |memo, condition| memo.or(condition) }
111
+ scope.where(combined_condition)
112
+ end
113
+
114
+ def detect_searchable_columns(model_class)
115
+ string_columns = model_class.columns
116
+ .select { |col| %i[string text].include?(col.type) }
117
+ .map(&:name)
118
+
119
+ priority_columns = %w[name title label email username]
120
+ searchable = string_columns & priority_columns
121
+
122
+ searchable = string_columns.first(3) if searchable.empty?
123
+
124
+ searchable
125
+ end
126
+
127
+ def sanitize_sql_like(string)
128
+ string.gsub(/[%_]/) { |match| "\\#{match}" }
129
+ end
130
+
131
+ # Sanitize output to prevent XSS attacks
132
+ def sanitize_output(string)
133
+ ERB::Util.html_escape(string.to_s)
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperAdmin
4
+ # Read-only view over audit events recorded within SuperAdmin.
5
+ class AuditLogsController < SuperAdmin::BaseController
6
+ before_action :ensure_audit_log_table!
7
+
8
+ def index
9
+ scope = SuperAdmin::AuditLog.recent
10
+ scope = scope.where(action: params[:action_type]) if params[:action_type].present?
11
+ scope = scope.where(resource_type: params[:resource_type]) if params[:resource_type].present?
12
+ scope = apply_query(scope)
13
+
14
+ @pagy, @audit_logs = pagy(scope, limit: 25)
15
+ end
16
+
17
+ private
18
+
19
+ def apply_query(scope)
20
+ return scope unless params[:query].present?
21
+
22
+ term = "%#{params[:query].to_s.strip.downcase}%"
23
+ scope.where(
24
+ "LOWER(user_email) LIKE :term OR LOWER(resource_type) LIKE :term OR LOWER(action) LIKE :term",
25
+ term: term
26
+ )
27
+ end
28
+
29
+ def ensure_audit_log_table!
30
+ return if SuperAdmin::AuditLog.table_exists?
31
+
32
+ flash[:alert] = t("super_admin.audit_logs.missing_table")
33
+ redirect_to super_admin_root_path
34
+ rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid
35
+ flash[:alert] = t("super_admin.audit_logs.missing_table")
36
+ redirect_to super_admin_root_path
37
+ end
38
+ end
39
+ end