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.
- checksums.yaml +7 -0
- data/README.md +216 -0
- data/Rakefile +30 -0
- data/app/assets/stylesheets/super_admin/application.css +15 -0
- data/app/assets/stylesheets/super_admin/tailwind.css +1 -0
- data/app/assets/stylesheets/super_admin/tailwind.source.css +25 -0
- data/app/controllers/super_admin/application_controller.rb +89 -0
- data/app/controllers/super_admin/associations_controller.rb +136 -0
- data/app/controllers/super_admin/audit_logs_controller.rb +39 -0
- data/app/controllers/super_admin/base_controller.rb +133 -0
- data/app/controllers/super_admin/dashboard_controller.rb +29 -0
- data/app/controllers/super_admin/exports_controller.rb +109 -0
- data/app/controllers/super_admin/resources_controller.rb +201 -0
- data/app/dashboards/super_admin/base_dashboard.rb +200 -0
- data/app/errors/super_admin/configuration_error.rb +6 -0
- data/app/helpers/super_admin/application_helper.rb +84 -0
- data/app/helpers/super_admin/exports_helper.rb +16 -0
- data/app/helpers/super_admin/resources_helper.rb +204 -0
- data/app/helpers/super_admin/route_helper.rb +7 -0
- data/app/javascript/super_admin/application.js +263 -0
- data/app/jobs/super_admin/application_job.rb +4 -0
- data/app/jobs/super_admin/generate_super_admin_csv_export_job.rb +100 -0
- data/app/mailers/super_admin/application_mailer.rb +6 -0
- data/app/models/super_admin/application_record.rb +5 -0
- data/app/models/super_admin/audit_log.rb +35 -0
- data/app/models/super_admin/csv_export.rb +67 -0
- data/app/services/super_admin/auditing.rb +74 -0
- data/app/services/super_admin/authorization.rb +113 -0
- data/app/services/super_admin/authorization_adapters/base_adapter.rb +100 -0
- data/app/services/super_admin/authorization_adapters/default_adapter.rb +77 -0
- data/app/services/super_admin/authorization_adapters/proc_adapter.rb +65 -0
- data/app/services/super_admin/authorization_adapters/pundit_adapter.rb +81 -0
- data/app/services/super_admin/csv_export_creator.rb +45 -0
- data/app/services/super_admin/dashboard_registry.rb +90 -0
- data/app/services/super_admin/dashboard_resolver.rb +100 -0
- data/app/services/super_admin/filter_builder.rb +185 -0
- data/app/services/super_admin/form_builder.rb +59 -0
- data/app/services/super_admin/form_fields/array_field.rb +35 -0
- data/app/services/super_admin/form_fields/association_field.rb +146 -0
- data/app/services/super_admin/form_fields/base_field.rb +53 -0
- data/app/services/super_admin/form_fields/boolean_field.rb +29 -0
- data/app/services/super_admin/form_fields/date_field.rb +15 -0
- data/app/services/super_admin/form_fields/date_time_field.rb +15 -0
- data/app/services/super_admin/form_fields/enum_field.rb +27 -0
- data/app/services/super_admin/form_fields/factory.rb +102 -0
- data/app/services/super_admin/form_fields/nested_field.rb +120 -0
- data/app/services/super_admin/form_fields/number_field.rb +29 -0
- data/app/services/super_admin/form_fields/text_area_field.rb +19 -0
- data/app/services/super_admin/model_inspector.rb +182 -0
- data/app/services/super_admin/queries/base_query.rb +45 -0
- data/app/services/super_admin/queries/filter_query.rb +188 -0
- data/app/services/super_admin/queries/resource_scope_query.rb +74 -0
- data/app/services/super_admin/queries/search_query.rb +146 -0
- data/app/services/super_admin/queries/sort_query.rb +41 -0
- data/app/services/super_admin/resource_configuration.rb +63 -0
- data/app/services/super_admin/resource_exporter.rb +78 -0
- data/app/services/super_admin/resource_query.rb +40 -0
- data/app/services/super_admin/resources/association_inspector.rb +112 -0
- data/app/services/super_admin/resources/collection_presenter.rb +63 -0
- data/app/services/super_admin/resources/context.rb +63 -0
- data/app/services/super_admin/resources/filter_params.rb +29 -0
- data/app/services/super_admin/resources/permitted_attributes.rb +104 -0
- data/app/services/super_admin/resources/value_normalizer.rb +121 -0
- data/app/services/super_admin/sensitive_attributes.rb +166 -0
- data/app/views/layouts/super_admin.html.erb +74 -0
- data/app/views/super_admin/audit_logs/index.html.erb +143 -0
- data/app/views/super_admin/dashboard/index.html.erb +79 -0
- data/app/views/super_admin/exports/index.html.erb +84 -0
- data/app/views/super_admin/exports/show.html.erb +57 -0
- data/app/views/super_admin/resources/_form.html.erb +42 -0
- data/app/views/super_admin/resources/destroy.turbo_stream.erb +17 -0
- data/app/views/super_admin/resources/edit.html.erb +37 -0
- data/app/views/super_admin/resources/index.html.erb +189 -0
- data/app/views/super_admin/resources/new.html.erb +31 -0
- data/app/views/super_admin/resources/show.html.erb +106 -0
- data/app/views/super_admin/shared/_breadcrumbs.html.erb +12 -0
- data/app/views/super_admin/shared/_custom_styles.html.erb +132 -0
- data/app/views/super_admin/shared/_flash.html.erb +55 -0
- data/app/views/super_admin/shared/_form_field.html.erb +35 -0
- data/app/views/super_admin/shared/_navigation.html.erb +92 -0
- data/app/views/super_admin/shared/_nested_fields.html.erb +59 -0
- data/app/views/super_admin/shared/_nested_record_fields.html.erb +45 -0
- data/config/importmap.rb +4 -0
- data/config/initializers/rack_attack.rb +134 -0
- data/config/initializers/super_admin.rb +117 -0
- data/config/locales/super_admin.en.yml +197 -0
- data/config/locales/super_admin.fr.yml +197 -0
- data/config/routes.rb +22 -0
- data/lib/generators/super_admin/dashboard_generator.rb +50 -0
- data/lib/generators/super_admin/install_generator.rb +58 -0
- data/lib/generators/super_admin/templates/20240101000001_create_super_admin_audit_logs.rb +24 -0
- data/lib/generators/super_admin/templates/20240101000002_create_super_admin_csv_exports.rb +33 -0
- data/lib/generators/super_admin/templates/super_admin.rb +58 -0
- data/lib/super_admin/dashboard_creator.rb +256 -0
- data/lib/super_admin/engine.rb +53 -0
- data/lib/super_admin/install_task.rb +96 -0
- data/lib/super_admin/version.rb +3 -0
- data/lib/super_admin.rb +7 -0
- data/lib/tasks/super_admin_tasks.rake +38 -0
- 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
|
+
[](https://badge.fury.io/rb/super_admin)
|
|
4
|
+
[](https://github.com/ThibautBaissac/super_admin/actions/workflows/ci.yml)
|
|
5
|
+
[](https://github.com/rubocop/rubocop)
|
|
6
|
+
[](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
|
+

|
|
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
|