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
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
<aside id="super-admin-sidebar" data-super-admin--mobile-menu-target="sidebar" class="fixed lg:static inset-y-0 left-0 z-40 w-64 bg-gray-900 text-white flex-shrink-0 transform -translate-x-full lg:translate-x-0 transition-transform duration-300 ease-in-out">
|
|
2
|
+
<div class="flex flex-col h-full">
|
|
3
|
+
<!-- Logo -->
|
|
4
|
+
<div class="px-6 py-4 border-b border-gray-800 flex items-center justify-between">
|
|
5
|
+
<h1 class="text-xl lg:text-2xl font-bold text-white truncate"><%= t("super_admin.navigation.brand") %></h1>
|
|
6
|
+
<!-- Close button for mobile -->
|
|
7
|
+
<button data-action="click->super-admin--mobile-menu#toggle" class="lg:hidden inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-white hover:bg-gray-800 focus:outline-none">
|
|
8
|
+
<span class="sr-only">Close menu</span>
|
|
9
|
+
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
10
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
|
11
|
+
</svg>
|
|
12
|
+
</button>
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
<!-- Navigation -->
|
|
16
|
+
<nav class="flex-1 px-3 lg:px-4 py-4 lg:py-6 overflow-y-auto">
|
|
17
|
+
<!-- Return to App -->
|
|
18
|
+
<div class="mb-4 lg:mb-6">
|
|
19
|
+
<% return_path = if defined?(main_app) && main_app.respond_to?(:root_path)
|
|
20
|
+
main_app.root_path
|
|
21
|
+
else
|
|
22
|
+
super_admin_root_path
|
|
23
|
+
end %>
|
|
24
|
+
<%= link_to return_path, class: "flex items-center px-3 lg:px-4 py-2 text-sm font-medium rounded-md text-gray-300 hover-bg-gray-800 hover-text-white transition-colors" do %>
|
|
25
|
+
<svg class="mr-2 lg:mr-3 h-5 w-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
26
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
|
|
27
|
+
</svg>
|
|
28
|
+
<span class="truncate"><%= t("super_admin.navigation.return_to_app") %></span>
|
|
29
|
+
<% end %>
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
<!-- Dashboard -->
|
|
33
|
+
<div class="mb-2">
|
|
34
|
+
<%= link_to super_admin_root_path, class: "flex items-center px-3 lg:px-4 py-2 text-sm font-medium rounded-md hover-bg-gray-800 #{current_page?(super_admin_root_path) ? 'bg-gray-800 text-white' : 'text-gray-300'}" do %>
|
|
35
|
+
<svg class="mr-2 lg:mr-3 h-5 w-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
36
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
|
|
37
|
+
</svg>
|
|
38
|
+
<span class="truncate"><%= t("super_admin.navigation.dashboard") %></span>
|
|
39
|
+
<% end %>
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<!-- Exports -->
|
|
43
|
+
<div class="mb-2">
|
|
44
|
+
<%= link_to super_admin_exports_path, class: "flex items-center px-3 lg:px-4 py-2 text-sm font-medium rounded-md hover-bg-gray-800 #{current_page?(super_admin_exports_path) ? 'bg-gray-800 text-white' : 'text-gray-300'}" do %>
|
|
45
|
+
<svg class="mr-2 lg:mr-3 h-5 w-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
46
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
|
47
|
+
</svg>
|
|
48
|
+
<span class="truncate"><%= t("super_admin.navigation.exports") %></span>
|
|
49
|
+
<% end %>
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<!-- Audit Logs -->
|
|
53
|
+
<div class="mb-4 lg:mb-6">
|
|
54
|
+
<% audit_active = controller_name == "audit_logs" %>
|
|
55
|
+
<%= link_to super_admin_audit_logs_path, class: "flex items-center px-3 lg:px-4 py-2 text-sm font-medium rounded-md hover-bg-gray-800 #{audit_active ? 'bg-gray-800 text-white' : 'text-gray-300'}" do %>
|
|
56
|
+
<svg class="mr-2 lg:mr-3 h-5 w-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
57
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V5a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"></path>
|
|
58
|
+
</svg>
|
|
59
|
+
<span class="truncate"><%= t("super_admin.navigation.audit_logs") %></span>
|
|
60
|
+
<% end %>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<!-- Models List -->
|
|
64
|
+
<div>
|
|
65
|
+
<h3 class="px-3 lg:px-4 text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">
|
|
66
|
+
<%= t("super_admin.navigation.models_heading", count: available_models.count) %>
|
|
67
|
+
</h3>
|
|
68
|
+
<div class="space-y-1">
|
|
69
|
+
<% available_models.each do |model_class| %>
|
|
70
|
+
<% resource_name = model_class.name.underscore.pluralize %>
|
|
71
|
+
<% is_active = params[:resource] == resource_name %>
|
|
72
|
+
<%
|
|
73
|
+
begin
|
|
74
|
+
record_count = model_class.count
|
|
75
|
+
rescue ActiveRecord::StatementInvalid, StandardError
|
|
76
|
+
record_count = "—"
|
|
77
|
+
end
|
|
78
|
+
%>
|
|
79
|
+
<%= link_to model_path(model_class), class: "flex items-center justify-between px-3 lg:px-4 py-2 text-sm rounded-md hover-bg-gray-800 #{is_active ? 'bg-gray-800 text-white' : 'text-gray-300'}" do %>
|
|
80
|
+
<span class="truncate flex-1 min-w-0">
|
|
81
|
+
<%= model_display_name(model_class) %>
|
|
82
|
+
</span>
|
|
83
|
+
<span class="ml-2 text-xs font-semibold flex-shrink-0 <%= is_active ? 'text-blue-400 bg-blue-900-opacity' : 'text-gray-400 bg-gray-800' %> px-2 py-0.5 rounded-full">
|
|
84
|
+
<%= record_count %>
|
|
85
|
+
</span>
|
|
86
|
+
<% end %>
|
|
87
|
+
<% end %>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
</nav>
|
|
91
|
+
</div>
|
|
92
|
+
</aside>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<%# Rendu des champs nested attributes pour une association accepts_nested_attributes_for %>
|
|
2
|
+
<%# Locals: form, parent_model_class, association, nested_model_class, nested_attributes, nested_options, label, current_depth %>
|
|
3
|
+
|
|
4
|
+
<% association_name = association.name %>
|
|
5
|
+
<% current_depth ||= 0 %>
|
|
6
|
+
<% max_depth = SuperAdmin.max_nested_depth %>
|
|
7
|
+
|
|
8
|
+
<% new_record_template = capture do %>
|
|
9
|
+
<%= form.fields_for(association_name, association.klass.new, child_index: "__NEW_RECORD__") do |nested_form| %>
|
|
10
|
+
<%= render "super_admin/shared/nested_record_fields",
|
|
11
|
+
form: nested_form,
|
|
12
|
+
association: association,
|
|
13
|
+
nested_model_class: nested_model_class,
|
|
14
|
+
nested_attributes: nested_attributes,
|
|
15
|
+
nested_options: nested_options,
|
|
16
|
+
new_record: true,
|
|
17
|
+
current_depth: current_depth + 1 %>
|
|
18
|
+
<% end %>
|
|
19
|
+
<% end %>
|
|
20
|
+
|
|
21
|
+
<div class="mb-6 space-y-4 rounded-lg border border-gray-200 bg-gray-50 p-4"
|
|
22
|
+
data-controller="super-admin--nested-form">
|
|
23
|
+
<div class="flex items-center justify-between">
|
|
24
|
+
<h3 class="text-base font-semibold text-gray-800">
|
|
25
|
+
<%= label %>
|
|
26
|
+
<% if current_depth > 0 %>
|
|
27
|
+
<span class="ml-2 text-xs text-gray-500">(niveau <%= current_depth + 1 %>/<%= max_depth %>)</span>
|
|
28
|
+
<% end %>
|
|
29
|
+
</h3>
|
|
30
|
+
<% if association.collection? %>
|
|
31
|
+
<span class="text-xs text-gray-500"><%= t("super_admin.resources.nested.collection_hint") %></span>
|
|
32
|
+
<% end %>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<div class="space-y-4" data-super-admin--nested-form-target="entries">
|
|
36
|
+
<%= form.fields_for association_name do |nested_form| %>
|
|
37
|
+
<%= render "super_admin/shared/nested_record_fields",
|
|
38
|
+
form: nested_form,
|
|
39
|
+
association: association,
|
|
40
|
+
nested_model_class: nested_model_class,
|
|
41
|
+
nested_attributes: nested_attributes,
|
|
42
|
+
nested_options: nested_options,
|
|
43
|
+
new_record: nested_form.object.new_record?,
|
|
44
|
+
current_depth: current_depth + 1 %>
|
|
45
|
+
<% end %>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<template data-super-admin--nested-form-target="template">
|
|
49
|
+
<%= new_record_template %>
|
|
50
|
+
</template>
|
|
51
|
+
|
|
52
|
+
<div class="pt-2">
|
|
53
|
+
<button type="button"
|
|
54
|
+
class="inline-flex items-center rounded-md border border-dashed border-blue-300 px-3 py-2 text-sm font-medium text-blue-600 hover-border-blue-400 hover-text-blue-700"
|
|
55
|
+
data-action="super-admin--nested-form#add">
|
|
56
|
+
<%= t("super_admin.resources.nested.add", model: nested_model_class.model_name.human(count: 1)) %>
|
|
57
|
+
</button>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
<%# Rendu d'un enregistrement nested %>
|
|
2
|
+
<%# Locals: form, association, nested_model_class, nested_attributes, nested_options, new_record, current_depth %>
|
|
3
|
+
|
|
4
|
+
<% current_depth ||= 1 %>
|
|
5
|
+
<% removable = nested_options[:allow_destroy] || new_record %>
|
|
6
|
+
<% entry_title = if form.object.respond_to?(:persisted?) && form.object.persisted?
|
|
7
|
+
t("super_admin.resources.nested.entry_existing", model: nested_model_class.model_name.human(count: 1), id: form.object.id)
|
|
8
|
+
else
|
|
9
|
+
t("super_admin.resources.nested.entry_new", model: nested_model_class.model_name.human(count: 1))
|
|
10
|
+
end %>
|
|
11
|
+
|
|
12
|
+
<div class="space-y-4 rounded-md border border-gray-200 bg-white p-4"
|
|
13
|
+
data-super-admin--nested-form-entry>
|
|
14
|
+
<div class="flex items-center justify-between">
|
|
15
|
+
<p class="text-sm font-medium text-gray-800">
|
|
16
|
+
<%= entry_title %>
|
|
17
|
+
</p>
|
|
18
|
+
<% if removable %>
|
|
19
|
+
<button type="button"
|
|
20
|
+
class="text-sm font-medium text-red-600 hover:text-red-700"
|
|
21
|
+
data-action="super-admin--nested-form#remove"
|
|
22
|
+
data-super-admin--nested-form-remove>
|
|
23
|
+
<%= t("super_admin.resources.nested.remove") %>
|
|
24
|
+
</button>
|
|
25
|
+
<% end %>
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<% if form.object.respond_to?(:persisted?) && form.object.persisted? %>
|
|
29
|
+
<%= form.hidden_field :id %>
|
|
30
|
+
<% end %>
|
|
31
|
+
|
|
32
|
+
<% if nested_options[:allow_destroy] %>
|
|
33
|
+
<%= form.hidden_field :_destroy, value: "0" %>
|
|
34
|
+
<% end %>
|
|
35
|
+
|
|
36
|
+
<% nested_attributes.each do |attribute| %>
|
|
37
|
+
<%= render "super_admin/shared/form_field", form: form, model_class: nested_model_class, attribute_name: attribute %>
|
|
38
|
+
<% end %>
|
|
39
|
+
|
|
40
|
+
<% if nested_options[:allow_destroy] && form.object.respond_to?(:persisted?) && form.object.persisted? %>
|
|
41
|
+
<p class="text-xs text-gray-500">
|
|
42
|
+
<%= t("super_admin.resources.nested.remove_hint") %>
|
|
43
|
+
</p>
|
|
44
|
+
<% end %>
|
|
45
|
+
</div>
|
data/config/importmap.rb
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Configure Rack::Attack for SuperAdmin endpoints
|
|
4
|
+
# This protects against common attacks like brute force, DoS, and abuse
|
|
5
|
+
|
|
6
|
+
module SuperAdmin
|
|
7
|
+
class RackAttackConfiguration
|
|
8
|
+
class << self
|
|
9
|
+
def configure
|
|
10
|
+
return unless defined?(Rack::Attack)
|
|
11
|
+
|
|
12
|
+
configure_throttles
|
|
13
|
+
configure_blocklists
|
|
14
|
+
configure_safelists
|
|
15
|
+
configure_tracking
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def configure_throttles
|
|
21
|
+
# Throttle searches to prevent DoS
|
|
22
|
+
Rack::Attack.throttle("super_admin/searches/ip", limit: 30, period: 60) do |req|
|
|
23
|
+
if req.path.start_with?("/super_admin") && (req.get? || req.post?) && req.path.include?("search")
|
|
24
|
+
req.ip
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Throttle association search API (more generous as it's used heavily in forms)
|
|
29
|
+
Rack::Attack.throttle("super_admin/api/associations/ip", limit: 100, period: 60) do |req|
|
|
30
|
+
if req.path == "/super_admin/associations/search" && req.get?
|
|
31
|
+
req.ip
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Throttle CSV exports to prevent abuse
|
|
36
|
+
Rack::Attack.throttle("super_admin/exports/ip", limit: 5, period: 300) do |req|
|
|
37
|
+
if req.path.start_with?("/super_admin") && req.post? && req.path.include?("export")
|
|
38
|
+
req.ip
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Throttle bulk operations (more restrictive)
|
|
43
|
+
Rack::Attack.throttle("super_admin/bulk/ip", limit: 10, period: 60) do |req|
|
|
44
|
+
if req.path.match?(%r{/super_admin/.+/bulk}) && req.post?
|
|
45
|
+
req.ip
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Throttle write operations (create/update/delete)
|
|
50
|
+
Rack::Attack.throttle("super_admin/writes/ip", limit: 60, period: 60) do |req|
|
|
51
|
+
if req.path.start_with?("/super_admin") && (req.post? || req.patch? || req.put? || req.delete?)
|
|
52
|
+
req.ip
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Global throttle for all SuperAdmin requests
|
|
57
|
+
Rack::Attack.throttle("super_admin/global/ip", limit: 300, period: 60) do |req|
|
|
58
|
+
if req.path.start_with?("/super_admin")
|
|
59
|
+
req.ip
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def configure_blocklists
|
|
65
|
+
# Block requests from known bad actors (can be configured via environment)
|
|
66
|
+
Rack::Attack.blocklist("super_admin/blocked_ips") do |req|
|
|
67
|
+
if req.path.start_with?("/super_admin")
|
|
68
|
+
blocked_ips = ENV.fetch("SUPER_ADMIN_BLOCKED_IPS", "").split(",").map(&:strip)
|
|
69
|
+
blocked_ips.include?(req.ip)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Block requests with suspicious patterns in query params
|
|
74
|
+
Rack::Attack.blocklist("super_admin/sql_injection_attempts") do |req|
|
|
75
|
+
if req.path.start_with?("/super_admin")
|
|
76
|
+
query_string = req.query_string.to_s.downcase
|
|
77
|
+
# Detect common SQL injection patterns
|
|
78
|
+
query_string.match?(/(\bunion\b|\bselect\b|\binsert\b|\bupdate\b|\bdelete\b|\bdrop\b).*(\bfrom\b|\binto\b|\btable\b)/)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def configure_safelists
|
|
84
|
+
# Safelist requests from localhost in development
|
|
85
|
+
Rack::Attack.safelist("super_admin/localhost") do |req|
|
|
86
|
+
if req.path.start_with?("/super_admin")
|
|
87
|
+
Rails.env.development? && [ "127.0.0.1", "::1" ].include?(req.ip)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Allow configurable safelist via environment
|
|
92
|
+
Rack::Attack.safelist("super_admin/safelisted_ips") do |req|
|
|
93
|
+
if req.path.start_with?("/super_admin")
|
|
94
|
+
safe_ips = ENV.fetch("SUPER_ADMIN_SAFE_IPS", "").split(",").map(&:strip)
|
|
95
|
+
safe_ips.include?(req.ip)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def configure_tracking
|
|
101
|
+
# Track requests for monitoring (optional, requires Rails cache)
|
|
102
|
+
Rack::Attack.track("super_admin/requests") do |req|
|
|
103
|
+
req.path.start_with?("/super_admin")
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Auto-configure if Rack::Attack is available
|
|
111
|
+
if defined?(Rack::Attack)
|
|
112
|
+
SuperAdmin::RackAttackConfiguration.configure
|
|
113
|
+
|
|
114
|
+
# Custom response for throttled requests
|
|
115
|
+
Rack::Attack.throttled_responder = lambda do |req|
|
|
116
|
+
match_data = req.env["rack.attack.match_data"]
|
|
117
|
+
now = match_data[:epoch_time]
|
|
118
|
+
|
|
119
|
+
headers = {
|
|
120
|
+
"Content-Type" => "application/json",
|
|
121
|
+
"X-RateLimit-Limit" => match_data[:limit].to_s,
|
|
122
|
+
"X-RateLimit-Remaining" => "0",
|
|
123
|
+
"X-RateLimit-Reset" => (now + (match_data[:period] - now % match_data[:period])).to_s
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
body = {
|
|
127
|
+
error: "Rate limit exceeded",
|
|
128
|
+
message: "Too many requests. Please try again later.",
|
|
129
|
+
retry_after: match_data[:period] - now % match_data[:period]
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
[ 429, headers, [ body.to_json ] ]
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/module/delegation"
|
|
4
|
+
|
|
5
|
+
module SuperAdmin
|
|
6
|
+
class Configuration
|
|
7
|
+
attr_accessor :max_nested_depth,
|
|
8
|
+
:association_select_limit,
|
|
9
|
+
:association_pagination_limit,
|
|
10
|
+
:enable_association_search,
|
|
11
|
+
:authorization_adapter,
|
|
12
|
+
:on_unauthorized,
|
|
13
|
+
:current_user_method,
|
|
14
|
+
:layout,
|
|
15
|
+
:default_locale,
|
|
16
|
+
:parent_controller,
|
|
17
|
+
:user_class,
|
|
18
|
+
:super_admin_check
|
|
19
|
+
|
|
20
|
+
attr_reader :authorize_with, :authenticate_with
|
|
21
|
+
|
|
22
|
+
attr_reader :additional_sensitive_attributes
|
|
23
|
+
|
|
24
|
+
def initialize
|
|
25
|
+
@max_nested_depth = 2
|
|
26
|
+
@association_select_limit = 10
|
|
27
|
+
@association_pagination_limit = 20
|
|
28
|
+
@enable_association_search = true
|
|
29
|
+
|
|
30
|
+
@authorize_with = nil
|
|
31
|
+
@authorization_adapter = :auto
|
|
32
|
+
@on_unauthorized = nil
|
|
33
|
+
|
|
34
|
+
@authenticate_with = nil
|
|
35
|
+
@current_user_method = :current_user
|
|
36
|
+
@user_class = "User"
|
|
37
|
+
|
|
38
|
+
@layout = "super_admin"
|
|
39
|
+
@default_locale = :fr
|
|
40
|
+
@parent_controller = "::ApplicationController"
|
|
41
|
+
|
|
42
|
+
@super_admin_check = nil
|
|
43
|
+
@additional_sensitive_attributes = []
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def authorize_with(value = nil, &block)
|
|
47
|
+
if block_given?
|
|
48
|
+
@authorize_with = block
|
|
49
|
+
elsif !value.nil?
|
|
50
|
+
@authorize_with = value
|
|
51
|
+
else
|
|
52
|
+
@authorize_with
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def authorize_with=(value)
|
|
57
|
+
@authorize_with = value
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def authenticate_with(value = nil, &block)
|
|
61
|
+
if block_given?
|
|
62
|
+
@authenticate_with = block
|
|
63
|
+
elsif !value.nil?
|
|
64
|
+
@authenticate_with = value
|
|
65
|
+
else
|
|
66
|
+
@authenticate_with
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def authenticate_with=(value)
|
|
71
|
+
@authenticate_with = value
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def additional_sensitive_attributes=(value)
|
|
75
|
+
@additional_sensitive_attributes = Array(value)
|
|
76
|
+
SuperAdmin::SensitiveAttributes.reset!
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def user_class_constant
|
|
80
|
+
user_class.is_a?(String) ? user_class.constantize : user_class
|
|
81
|
+
rescue NameError
|
|
82
|
+
raise ConfigurationError, "User class '#{user_class}' is not defined"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def parent_controller_constant
|
|
86
|
+
parent_controller.is_a?(String) ? parent_controller.constantize : parent_controller
|
|
87
|
+
rescue NameError
|
|
88
|
+
raise ConfigurationError, "Parent controller '#{parent_controller}' is not defined"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
class << self
|
|
93
|
+
attr_writer :configuration
|
|
94
|
+
|
|
95
|
+
def configuration
|
|
96
|
+
@configuration ||= Configuration.new
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def configure
|
|
100
|
+
yield(configuration)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def reset_configuration!
|
|
104
|
+
@configuration = Configuration.new
|
|
105
|
+
SuperAdmin::SensitiveAttributes.reset!
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
delegate :max_nested_depth,
|
|
109
|
+
:association_select_limit,
|
|
110
|
+
:association_pagination_limit,
|
|
111
|
+
to: :configuration
|
|
112
|
+
|
|
113
|
+
def enable_association_search?
|
|
114
|
+
configuration.enable_association_search
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
en:
|
|
2
|
+
time:
|
|
3
|
+
formats:
|
|
4
|
+
long: "%B %d, %Y %H:%M"
|
|
5
|
+
super_admin:
|
|
6
|
+
layout:
|
|
7
|
+
window_title: "SuperAdmin - %{page_title}"
|
|
8
|
+
default_page_title: "Administration"
|
|
9
|
+
footer: "SuperAdmin - %{year}"
|
|
10
|
+
flash:
|
|
11
|
+
access_denied: "You do not have permission to access this section."
|
|
12
|
+
navigation:
|
|
13
|
+
brand: "SuperAdmin"
|
|
14
|
+
return_to_app: "Back to app"
|
|
15
|
+
dashboard: "Dashboard"
|
|
16
|
+
exports: "CSV Exports"
|
|
17
|
+
audit_logs: "Audit logs"
|
|
18
|
+
models_heading: "Models (%{count})"
|
|
19
|
+
dashboard:
|
|
20
|
+
index:
|
|
21
|
+
page_title: "Dashboard"
|
|
22
|
+
breadcrumb: "Dashboard"
|
|
23
|
+
heading: "SuperAdmin Dashboard"
|
|
24
|
+
intro: "Full management of every model in the application"
|
|
25
|
+
stats:
|
|
26
|
+
models_label: "Available models"
|
|
27
|
+
records_label: "Total records"
|
|
28
|
+
users_label: "Users"
|
|
29
|
+
table:
|
|
30
|
+
title: "All models"
|
|
31
|
+
headers:
|
|
32
|
+
model: "Model"
|
|
33
|
+
table: "Table"
|
|
34
|
+
records: "Records"
|
|
35
|
+
actions: "Actions"
|
|
36
|
+
manage: "Manage →"
|
|
37
|
+
resources:
|
|
38
|
+
shared:
|
|
39
|
+
search_placeholder: "Search..."
|
|
40
|
+
search_button: "Search"
|
|
41
|
+
reset_filters: "Reset"
|
|
42
|
+
advanced_filters: "Advanced filters"
|
|
43
|
+
contains_placeholder: "Contains..."
|
|
44
|
+
boolean_options:
|
|
45
|
+
placeholder: "—"
|
|
46
|
+
yes: "Yes"
|
|
47
|
+
no: "No"
|
|
48
|
+
min_placeholder: "Min"
|
|
49
|
+
max_placeholder: "Max"
|
|
50
|
+
clear_filters: "Clear filters"
|
|
51
|
+
apply_filters: "Apply"
|
|
52
|
+
bulk:
|
|
53
|
+
selected_suffix: "selected"
|
|
54
|
+
action_placeholder: "Bulk action"
|
|
55
|
+
destroy_action: "Delete selected"
|
|
56
|
+
apply: "Apply"
|
|
57
|
+
confirm: "Confirm the selected action on the checked items?"
|
|
58
|
+
select_all_label: "Select all items"
|
|
59
|
+
select_item_label: "Select item %{id}"
|
|
60
|
+
nested:
|
|
61
|
+
collection_hint: "Changes are saved when you submit the form."
|
|
62
|
+
add: "Add %{model}"
|
|
63
|
+
remove: "Remove"
|
|
64
|
+
remove_hint: "Tick to remove this entry when saving."
|
|
65
|
+
entry_existing: "%{model} #%{id}"
|
|
66
|
+
entry_new: "New %{model}"
|
|
67
|
+
missing_association: "Association %{name} is not available."
|
|
68
|
+
max_depth_exceeded: "Maximum nesting depth reached (%{max} levels). Cannot nest further."
|
|
69
|
+
index:
|
|
70
|
+
export_csv: "Export to CSV"
|
|
71
|
+
view_exports: "View exports"
|
|
72
|
+
new: "Create"
|
|
73
|
+
records_count:
|
|
74
|
+
zero: "%{total} records"
|
|
75
|
+
one: "%{total} record"
|
|
76
|
+
other: "%{total} records"
|
|
77
|
+
actions_header: "Actions"
|
|
78
|
+
view: "View"
|
|
79
|
+
edit: "Edit"
|
|
80
|
+
delete: "Delete"
|
|
81
|
+
delete_confirm: "Are you sure?"
|
|
82
|
+
empty_state: "No records match your search."
|
|
83
|
+
form:
|
|
84
|
+
error_heading:
|
|
85
|
+
one: "1 error prevented this record from being saved:"
|
|
86
|
+
other: "%{count} errors prevented this record from being saved:"
|
|
87
|
+
cancel: "Cancel"
|
|
88
|
+
create: "Create"
|
|
89
|
+
update: "Update"
|
|
90
|
+
boolean_hint: "Check to enable"
|
|
91
|
+
array_placeholder: "Enter one value per line or separate with commas"
|
|
92
|
+
association_limited: "Showing first %{count} results out of %{total} total. Use search to refine."
|
|
93
|
+
new:
|
|
94
|
+
page_title: "New - %{model}"
|
|
95
|
+
breadcrumb: "New"
|
|
96
|
+
heading: "Create %{model}"
|
|
97
|
+
edit:
|
|
98
|
+
page_title: "Edit - %{model}"
|
|
99
|
+
breadcrumb: "Edit"
|
|
100
|
+
heading: "Edit %{model} #%{id}"
|
|
101
|
+
show:
|
|
102
|
+
page_title: "Details - %{model}"
|
|
103
|
+
breadcrumb_id: "#%{id}"
|
|
104
|
+
heading: "%{model} #%{id}"
|
|
105
|
+
edit: "Edit"
|
|
106
|
+
delete: "Delete"
|
|
107
|
+
delete_confirm: "Are you sure you want to delete this record?"
|
|
108
|
+
attributes: "Attributes"
|
|
109
|
+
associations: "Associations"
|
|
110
|
+
association_count_html:
|
|
111
|
+
one: "%{number} record"
|
|
112
|
+
other: "%{number} records"
|
|
113
|
+
association_none: "None"
|
|
114
|
+
association_error: "Error: %{message}"
|
|
115
|
+
created_at: "Created on %{date}"
|
|
116
|
+
updated_at: "Updated on %{date}"
|
|
117
|
+
flash:
|
|
118
|
+
create:
|
|
119
|
+
success: "%{model} successfully created."
|
|
120
|
+
update:
|
|
121
|
+
success: "%{model} successfully updated."
|
|
122
|
+
destroy:
|
|
123
|
+
success: "%{model} successfully deleted."
|
|
124
|
+
dependencies: "Cannot delete: other records depend on this resource."
|
|
125
|
+
bulk:
|
|
126
|
+
success: "%{count} %{model} successfully deleted."
|
|
127
|
+
selection_required: "Select at least one record."
|
|
128
|
+
unsupported_action: "Unsupported bulk action."
|
|
129
|
+
dependencies: "Some records could not be deleted because of existing dependencies."
|
|
130
|
+
load_model_failed: "Model not found: %{resource}"
|
|
131
|
+
not_found: "%{model} not found."
|
|
132
|
+
exports:
|
|
133
|
+
index:
|
|
134
|
+
title: "CSV exports"
|
|
135
|
+
breadcrumb: "Exports"
|
|
136
|
+
heading: "CSV exports"
|
|
137
|
+
subtitle: "Track export progress and download them once ready."
|
|
138
|
+
back_to_resources: "Back to dashboard"
|
|
139
|
+
processing_hint: "Processing… You can leave this page."
|
|
140
|
+
expiration_hint: "Available until %{date}"
|
|
141
|
+
download: "Download"
|
|
142
|
+
pending: "Pending"
|
|
143
|
+
delete: "Delete"
|
|
144
|
+
delete_confirm: "Are you sure you want to delete this export?"
|
|
145
|
+
empty: "No exports generated yet."
|
|
146
|
+
table:
|
|
147
|
+
headers:
|
|
148
|
+
created_at: "Created at"
|
|
149
|
+
resource: "Resource"
|
|
150
|
+
status: "Status"
|
|
151
|
+
records: "Records"
|
|
152
|
+
actions: "Actions"
|
|
153
|
+
show:
|
|
154
|
+
title: "Export %{token}"
|
|
155
|
+
heading: "Export %{resource}"
|
|
156
|
+
download: "Download CSV"
|
|
157
|
+
created_at: "Created at"
|
|
158
|
+
status: "Status"
|
|
159
|
+
records: "Record count"
|
|
160
|
+
expires_at: "Expires at"
|
|
161
|
+
error: "Error"
|
|
162
|
+
status:
|
|
163
|
+
pending: "Pending"
|
|
164
|
+
processing: "Processing"
|
|
165
|
+
ready: "Ready"
|
|
166
|
+
failed: "Failed"
|
|
167
|
+
flash:
|
|
168
|
+
created: "Export started. Check the exports page to follow the progress (token %{token})."
|
|
169
|
+
unavailable: "The file is not available yet."
|
|
170
|
+
expired: "This file has expired and can no longer be downloaded."
|
|
171
|
+
not_found: "Export not found."
|
|
172
|
+
destroyed: "Export successfully deleted."
|
|
173
|
+
audit_logs:
|
|
174
|
+
title: "Activity log"
|
|
175
|
+
subtitle: "Track all administrative actions performed in SuperAdmin."
|
|
176
|
+
table_title: "Recent activity"
|
|
177
|
+
unknown_user: "Unknown user"
|
|
178
|
+
view_changes: "View changes"
|
|
179
|
+
filters:
|
|
180
|
+
search: "Search logs"
|
|
181
|
+
all_actions: "All actions"
|
|
182
|
+
all_resources: "All resources"
|
|
183
|
+
apply: "Filter"
|
|
184
|
+
headers:
|
|
185
|
+
performed_at: "Performed at"
|
|
186
|
+
user: "User"
|
|
187
|
+
resource: "Resource"
|
|
188
|
+
action: "Action"
|
|
189
|
+
changes: "Changes"
|
|
190
|
+
context: "Context"
|
|
191
|
+
empty: "No activity recorded yet."
|
|
192
|
+
missing_table: "Audit logs are not available yet. Run the SuperAdmin migrations to create the audit log table."
|
|
193
|
+
helpers:
|
|
194
|
+
resources:
|
|
195
|
+
empty_string: "(empty)"
|
|
196
|
+
actions:
|
|
197
|
+
close: "Close"
|