diff --git a/Gemfile b/Gemfile
index f662b2f..9ad90a8 100644
--- a/Gemfile
+++ b/Gemfile
@@ -63,6 +63,9 @@ gem "countries"
# Authorization library
gem "pundit"
+# User agent parsing
+gem "device_detector"
+
group :development, :test do
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
gem "debug", platforms: %i[ mri windows ], require: "debug/prelude"
@@ -87,3 +90,7 @@ group :test do
gem "capybara"
gem "selenium-webdriver"
end
+
+gem "sentry-rails", "~> 6.1"
+
+gem "postgresql_cursor", "~> 0.6.9"
diff --git a/Gemfile.lock b/Gemfile.lock
index f6dc474..21eaa1a 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -105,12 +105,15 @@ GEM
xpath (~> 3.2)
concurrent-ruby (1.3.5)
connection_pool (2.5.4)
+ countries (8.0.4)
+ unaccent (~> 0.3)
crass (1.0.6)
csv (3.3.5)
date (3.5.0)
debug (1.11.0)
irb (~> 1.10)
reline (>= 0.3.8)
+ device_detector (1.1.3)
dotenv (3.1.8)
drb (2.2.3)
ed25519 (1.4.0)
@@ -258,6 +261,8 @@ GEM
pg (1.6.2-arm64-darwin)
pg (1.6.2-x86_64-linux)
pg (1.6.2-x86_64-linux-musl)
+ postgresql_cursor (0.6.9)
+ activerecord (>= 6.0)
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
@@ -371,6 +376,12 @@ GEM
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 4.0)
websocket (~> 1.0)
+ sentry-rails (6.1.0)
+ railties (>= 5.2.0)
+ sentry-ruby (~> 6.1.0)
+ sentry-ruby (6.1.0)
+ bigdecimal
+ concurrent-ruby (~> 1.0, >= 1.0.2)
solid_cable (3.0.12)
actioncable (>= 7.2)
activejob (>= 7.2)
@@ -430,6 +441,7 @@ GEM
railties (>= 7.1.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
+ unaccent (0.4.0)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.1.0)
@@ -473,7 +485,9 @@ DEPENDENCIES
brakeman
bundler-audit
capybara
+ countries
debug
+ device_detector
httparty
image_processing (~> 1.2)
importmap-rails
@@ -483,12 +497,14 @@ DEPENDENCIES
openid_connect (~> 2.2)
pagy
pg (>= 1.1)
+ postgresql_cursor (~> 0.6.9)
propshaft
puma (>= 5.0)
pundit
rails (~> 8.1.1)
rubocop-rails-omakase
selenium-webdriver
+ sentry-rails (~> 6.1)
solid_cable
solid_cache
solid_queue
diff --git a/app/javascript/controllers/quick_create_rule_controller.js b/app/javascript/controllers/quick_create_rule_controller.js
index 0d1bd29..0502d9d 100644
--- a/app/javascript/controllers/quick_create_rule_controller.js
+++ b/app/javascript/controllers/quick_create_rule_controller.js
@@ -2,18 +2,29 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
- static targets = ["form", "toggle", "ruleTypeSelect", "actionSelect", "patternFields", "rateLimitFields", "redirectFields", "helpText", "conditionsField"]
+ static targets = ["form", "toggle", "ruleTypeSelect", "actionSelect", "patternFields", "rateLimitFields", "redirectFields", "helpText", "conditionsField", "expiresAtField"]
connect() {
- this.setupEventListeners()
+ console.log("QuickCreateRuleController connected")
this.initializeFieldVisibility()
}
toggle() {
- this.formTarget.classList.toggle("hidden")
+ console.log("Toggle method called")
+ console.log("Form target:", this.formTarget)
- if (this.formTarget.classList.contains("hidden")) {
- this.resetForm()
+ if (this.formTarget) {
+ this.formTarget.classList.toggle("hidden")
+ console.log("Toggled hidden class, now:", this.formTarget.classList.contains("hidden"))
+
+ if (this.formTarget.classList.contains("hidden")) {
+ this.resetForm()
+ } else {
+ // Form is being shown, clear the expires_at field for Safari
+ this.clearExpiresAtField()
+ }
+ } else {
+ console.error("Form target not found!")
}
}
@@ -81,13 +92,28 @@ export default class extends Controller {
if (this.hasRedirectFieldsTarget) this.redirectFieldsTarget.classList.add("hidden")
}
+ clearExpiresAtField() {
+ // Clear the expires_at field - much simpler with text field
+ if (this.hasExpiresAtFieldTarget) {
+ this.expiresAtFieldTarget.value = ''
+ }
+ }
+
resetForm() {
if (this.formTarget) {
- this.formTarget.reset()
- // Reset rule type to default
- if (this.hasRuleTypeSelectTarget) {
- this.ruleTypeSelectTarget.value = "network"
- this.updateRuleTypeFields()
+ // Find the actual form element within the form target div
+ const formElement = this.formTarget.querySelector('form')
+ if (formElement) {
+ formElement.reset()
+
+ // Explicitly clear the expires_at field since browser reset might not clear datetime-local fields properly
+ this.clearExpiresAtField()
+
+ // Reset rule type to default
+ if (this.hasRuleTypeSelectTarget) {
+ this.ruleTypeSelectTarget.value = "network"
+ this.updateRuleTypeFields()
+ }
}
}
}
@@ -95,19 +121,8 @@ export default class extends Controller {
// Private methods
setupEventListeners() {
- // Set up action change listener to show/hide redirect fields
- if (this.hasActionSelectTarget) {
- this.actionSelectTarget.addEventListener("change", () => {
- this.updateRuleTypeFields()
- })
- }
-
- // Set up toggle button listener
- if (this.hasToggleTarget) {
- this.toggleTarget.addEventListener("click", () => {
- this.toggle()
- })
- }
+ // Event listeners are handled via data-action attributes in the HTML
+ // No manual event listeners needed
}
initializeFieldVisibility() {
diff --git a/app/javascript/controllers/waf_policy_form_controller.js b/app/javascript/controllers/waf_policy_form_controller.js
new file mode 100644
index 0000000..4bcbe61
--- /dev/null
+++ b/app/javascript/controllers/waf_policy_form_controller.js
@@ -0,0 +1,55 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class WafPolicyFormController extends Controller {
+ static targets = ["policyTypeSelect", "policyActionSelect", "countryTargets", "asnTargets",
+ "companyTargets", "networkTypeTargets", "redirectConfig", "challengeConfig"]
+
+ connect() {
+ this.updateTargetsVisibility()
+ this.updateActionConfig()
+ }
+
+ updateTargetsVisibility() {
+ const selectedType = this.policyTypeSelectTarget.value
+
+ // Hide all target sections
+ this.countryTargetsTarget.classList.add('hidden')
+ this.asnTargetsTarget.classList.add('hidden')
+ this.companyTargetsTarget.classList.add('hidden')
+ this.networkTypeTargetsTarget.classList.add('hidden')
+
+ // Show relevant target section
+ switch(selectedType) {
+ case 'country':
+ this.countryTargetsTarget.classList.remove('hidden')
+ break
+ case 'asn':
+ this.asnTargetsTarget.classList.remove('hidden')
+ break
+ case 'company':
+ this.companyTargetsTarget.classList.remove('hidden')
+ break
+ case 'network_type':
+ this.networkTypeTargetsTarget.classList.remove('hidden')
+ break
+ }
+ }
+
+ updateActionConfig() {
+ const selectedAction = this.policyActionSelectTarget.value
+
+ // Hide all config sections
+ this.redirectConfigTarget.classList.add('hidden')
+ this.challengeConfigTarget.classList.add('hidden')
+
+ // Show relevant config section
+ switch(selectedAction) {
+ case 'redirect':
+ this.redirectConfigTarget.classList.remove('hidden')
+ break
+ case 'challenge':
+ this.challengeConfigTarget.classList.remove('hidden')
+ break
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/views/network_ranges/_geolite_data.html.erb b/app/views/network_ranges/_geolite_data.html.erb
new file mode 100644
index 0000000..3d47cfb
--- /dev/null
+++ b/app/views/network_ranges/_geolite_data.html.erb
@@ -0,0 +1,87 @@
+<% geolite_data = network_range.network_data_for(:geolite) %>
+
+<% if geolite_data.present? %>
+
+
+
MaxMind GeoLite2 Data
+
+
+
+
+
+ <% if geolite_data['asn'].present? %>
+
+
ASN (MaxMind)
+
+ AS<%= geolite_data['asn']['autonomous_system_number'] %>
+ <% if geolite_data['asn']['autonomous_system_organization'].present? %>
+ <%= geolite_data['asn']['autonomous_system_organization'] %>
+ <% end %>
+
+
+ <% end %>
+
+
+ <% if geolite_data['country'].present? %>
+
+
Country (MaxMind)
+
+ <%= geolite_data['country']['country_name'] || geolite_data['country']['country_iso_code'] %>
+ <% if geolite_data['country']['country_iso_code'].present? %>
+ <%= country_flag(geolite_data['country']['country_iso_code']) %>
+ <% end %>
+
+
+
+ <% if geolite_data['country']['continent_name'].present? %>
+
+
Continent
+
+ <%= geolite_data['country']['continent_name'] %>
+ (<%= geolite_data['country']['continent_code'] %>)
+
+
+ <% end %>
+
+ <% if geolite_data['country']['geoname_id'].present? %>
+
+
GeoName ID
+
+ <%= geolite_data['country']['geoname_id'] %>
+
+
+ <% end %>
+
+
+
+
MaxMind Flags
+
+ <% if geolite_data['country']['is_anonymous_proxy'] %>
+ Anonymous Proxy
+ <% end %>
+ <% if geolite_data['country']['is_satellite_provider'] %>
+ Satellite Provider
+ <% end %>
+ <% if geolite_data['country']['is_anycast'] %>
+ Anycast
+ <% end %>
+ <% if geolite_data['country']['is_in_european_union'] == "1" %>
+ 🇪🇺 EU Member
+ <% end %>
+
+
+ <% end %>
+
+
+
+
+
+ Show Raw MaxMind Data
+
+
+
<%= JSON.pretty_generate(geolite_data) %>
+
+
+
+
+<% end %>
diff --git a/app/views/network_ranges/_ipapi_data.html.erb b/app/views/network_ranges/_ipapi_data.html.erb
new file mode 100644
index 0000000..b174a41
--- /dev/null
+++ b/app/views/network_ranges/_ipapi_data.html.erb
@@ -0,0 +1,112 @@
+
+
+
IPAPI Enrichment Data
+
+
+ <% if ipapi_loading %>
+
+
+
Fetching enrichment data...
+
+ <% elsif ipapi_data.present? %>
+
+ <% if parent_with_ipapi %>
+
+
+
+
+
+
+ Data inherited from parent network <%= link_to parent_with_ipapi.cidr, network_range_path(parent_with_ipapi), class: "font-mono font-medium hover:underline" %>
+
+
+
+ <% end %>
+
+
+ <% if ipapi_data['asn'].present? %>
+
+
ASN (IPAPI)
+
+ AS<%= ipapi_data['asn']['asn'] %>
+ <% if ipapi_data['asn']['org'].present? %>
+ <%= ipapi_data['asn']['org'] %>
+ <% end %>
+ <% if ipapi_data['asn']['route'].present? %>
+ <%= ipapi_data['asn']['route'] %>
+ <% end %>
+
+
+ <% end %>
+
+ <% if ipapi_data['location'].present? %>
+
+
Location
+
+ <%= [ipapi_data['location']['city'], ipapi_data['location']['state'], ipapi_data['location']['country']].compact.join(', ') %>
+ <% if ipapi_data['location']['country_code'].present? %>
+ <%= country_flag(ipapi_data['location']['country_code']) %>
+ <% end %>
+
+
+ <% end %>
+
+ <% if ipapi_data['company'].present? %>
+
+
Company (IPAPI)
+
+ <%= ipapi_data['company']['name'] %>
+ <% if ipapi_data['company']['type'].present? %>
+ <%= ipapi_data['company']['type'].humanize %>
+ <% end %>
+
+
+ <% end %>
+
+ <% if ipapi_data['is_datacenter'] || ipapi_data['is_vpn'] || ipapi_data['is_proxy'] || ipapi_data['is_tor'] %>
+
+
IPAPI Flags
+
+ <% if ipapi_data['is_datacenter'] %>
+ Datacenter
+ <% end %>
+ <% if ipapi_data['is_vpn'] %>
+ VPN
+ <% end %>
+ <% if ipapi_data['is_proxy'] %>
+ Proxy
+ <% end %>
+ <% if ipapi_data['is_tor'] %>
+ Tor
+ <% end %>
+ <% if ipapi_data['is_abuser'] %>
+ Abuser
+ <% end %>
+ <% if ipapi_data['is_bogon'] %>
+ Bogon
+ <% end %>
+
+
+ <% end %>
+
+
+
+
+
+ Show Raw IPAPI Data
+
+
+
<%= JSON.pretty_generate(ipapi_data) %>
+
+
+
+ <% else %>
+
+
+
+
+
No IPAPI data available
+
Enrichment data will be fetched automatically.
+
+ <% end %>
+
diff --git a/app/views/network_ranges/show.html.erb b/app/views/network_ranges/show.html.erb
index 29851b1..7345f94 100644
--- a/app/views/network_ranges/show.html.erb
+++ b/app/views/network_ranges/show.html.erb
@@ -1,5 +1,9 @@
<% content_for :title, "#{@network_range.cidr} - Network Range Details" %>
+<% if @network_range.persisted? %>
+ <%= turbo_stream_from "network_range_#{@network_range.id}" %>
+<% end %>
+
+
+ <% if @network_range.persisted? %>
+ <%= render partial: "network_ranges/ipapi_data", locals: {
+ ipapi_data: @ipapi_data,
+ network_range: @network_range,
+ parent_with_ipapi: @parent_with_ipapi,
+ ipapi_loading: @ipapi_loading || false
+ } %>
+ <% end %>
+
+
+ <% if @network_range.persisted? %>
+ <%= render partial: "network_ranges/geolite_data", locals: {
+ network_range: @network_range
+ } %>
+ <% end %>
+
@@ -335,9 +356,12 @@
<%= form.label :expires_at, "Expires At (Optional)", class: "block text-sm font-medium text-gray-700" %>
- <%= form.datetime_local_field :expires_at,
- class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
-
Leave blank for permanent rule
+ <%= form.text_field :expires_at,
+ placeholder: "YYYY-MM-DD HH:MM (24-hour format, optional)",
+ class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm",
+ data: { quick_create_rule_target: "expiresAtField" },
+ autocomplete: "off" %>
+
Leave blank for permanent rule. Format: YYYY-MM-DD HH:MM (e.g., 2024-12-31 23:59)
@@ -461,9 +485,9 @@
Created <%= time_ago_in_words(rule.created_at) %> ago by <%= rule.user&.email_address || 'System' %>
- <% if rule.metadata&.dig('reason').present? %>
+ <% if rule.metadata_hash['reason'].present? %>
- Reason: <%= rule.metadata['reason'] %>
+ Reason: <%= rule.metadata_hash['reason'] %>
<% end %>
diff --git a/app/views/waf_policies/edit.html.erb b/app/views/waf_policies/edit.html.erb
index 229fda3..e651ba3 100644
--- a/app/views/waf_policies/edit.html.erb
+++ b/app/views/waf_policies/edit.html.erb
@@ -13,7 +13,7 @@
- <%= form_with(model: @waf_policy, local: true, class: "space-y-6") do |form| %>
+ <%= form_with(model: @waf_policy, local: true, class: "space-y-6", data: { controller: "waf-policy-form" }) do |form| %>
@@ -35,14 +35,14 @@
placeholder: "Explain why this policy is needed..." %>
-
+
- <%= form.label :action, "Action", class: "block text-sm font-medium text-gray-700" %>
- <%= form.select :action,
- options_for_select(@actions.map { |action| [action.humanize, action] }, @waf_policy.action),
+ <%= form.label :policy_action, "Policy Action", class: "block text-sm font-medium text-gray-700" %>
+ <%= form.select :policy_action,
+ options_for_select(@actions.map { |action| [action.humanize, action] }, @waf_policy.policy_action),
{ prompt: "Select action" },
{ class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm",
- id: "action-select" } %>
+ data: { "waf-policy-form-target": "policyActionSelect", "action": "change->waf-policy-form#updateActionConfig" } } %>
@@ -164,7 +164,7 @@
⚙️ Additional Configuration
-
+
<%= label_tag "additional_data[redirect_url]", "Redirect URL", class: "block text-sm font-medium text-gray-700" %>
<%= text_field_tag "additional_data[redirect_url]", @waf_policy.additional_data&.dig('redirect_url'),
@@ -180,7 +180,7 @@
-
+
<%= label_tag "additional_data[challenge_type]", "Challenge Type", class: "block text-sm font-medium text-gray-700" %>
<%= select_tag "additional_data[challenge_type]",
@@ -205,36 +205,4 @@
class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
<% end %>
-
-
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/app/views/waf_policies/index.html.erb b/app/views/waf_policies/index.html.erb
index 5cce23f..ef7208c 100644
--- a/app/views/waf_policies/index.html.erb
+++ b/app/views/waf_policies/index.html.erb
@@ -85,7 +85,7 @@
Deny Policies
- <%= number_with_delimiter(@waf_policies.where(action: 'deny').count) %>
+ <%= number_with_delimiter(@waf_policies.where(policy_action: 'deny').count) %>
@@ -137,15 +137,15 @@
<% end %>
-
+
- <%= policy.action.upcase %>
+ <%= policy.policy_action.upcase %>
diff --git a/app/views/waf_policies/new.html.erb b/app/views/waf_policies/new.html.erb
index 4266abc..c67cb92 100644
--- a/app/views/waf_policies/new.html.erb
+++ b/app/views/waf_policies/new.html.erb
@@ -13,7 +13,7 @@
- <%= form_with(model: @waf_policy, local: true, class: "space-y-6") do |form| %>
+ <%= form_with(model: @waf_policy, local: true, class: "space-y-6", data: { controller: "waf-policy-form" }) do |form| %>
@@ -42,17 +42,17 @@
options_for_select(@policy_types.map { |type| [type.humanize, type] }, @waf_policy.policy_type),
{ prompt: "Select policy type" },
{ class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm",
- id: "policy-type-select" } %>
+ data: { "waf-policy-form-target": "policyTypeSelect", "action": "change->waf-policy-form#updateTargetsVisibility" } } %>
-
+
- <%= form.label :action, "Action", class: "block text-sm font-medium text-gray-700" %>
- <%= form.select :action,
- options_for_select(@actions.map { |action| [action.humanize, action] }, @waf_policy.action),
+ <%= form.label :policy_action, "Policy Action", class: "block text-sm font-medium text-gray-700" %>
+ <%= form.select :policy_action,
+ options_for_select(@actions.map { |action| [action.humanize, action] }, @waf_policy.policy_action),
{ prompt: "Select action" },
{ class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm",
- id: "action-select" } %>
+ data: { "waf-policy-form-target": "policyActionSelect", "action": "change->waf-policy-form#updateActionConfig" } } %>
@@ -63,7 +63,7 @@
🎯 Targets Configuration
-
+
<%= form.label :targets, "Countries", class: "block text-sm font-medium text-gray-700 mb-2" %>
-
+
<%= form.label :targets, "ASN Numbers", class: "block text-sm font-medium text-gray-700" %>
<%= text_field_tag "waf_policy[targets][]", nil,
class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm",
@@ -88,7 +88,7 @@
-
+
<%= form.label :targets, "Companies", class: "block text-sm font-medium text-gray-700" %>
<%= text_field_tag "waf_policy[targets][]", nil,
class: "block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm",
@@ -97,7 +97,7 @@
-
+
<%= form.label :targets, "Network Types", class: "block text-sm font-medium text-gray-700" %>
@@ -123,7 +123,7 @@
⚙️ Additional Configuration
-
+
<%= label_tag "additional_data[redirect_url]", "Redirect URL", class: "block text-sm font-medium text-gray-700" %>
<%= text_field_tag "additional_data[redirect_url]", nil,
@@ -139,7 +139,7 @@
-
+
<%= label_tag "additional_data[challenge_type]", "Challenge Type", class: "block text-sm font-medium text-gray-700" %>
<%= select_tag "additional_data[challenge_type]",
@@ -178,63 +178,4 @@
class: "inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %>
<% end %>
-
-
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/app/views/waf_policies/new_country.html.erb b/app/views/waf_policies/new_country.html.erb
index 952dca7..3831de0 100644
--- a/app/views/waf_policies/new_country.html.erb
+++ b/app/views/waf_policies/new_country.html.erb
@@ -14,6 +14,31 @@
<%= form_with(url: create_country_waf_policies_path, method: :post, local: true, class: "space-y-6") do |form| %>
+
+ <% if defined?(@waf_policy) && @waf_policy&.errors&.any? %>
+
+
+
+
+
+ <%= pluralize(@waf_policy.errors.count, "error") %> prohibited this policy from being saved:
+
+
+
+ <% @waf_policy.errors.full_messages.each do |message| %>
+ <%= message %>
+ <% end %>
+
+
+
+
+
+ <% end %>
+
@@ -67,28 +92,28 @@
- <%= form.label :action, "What should happen to traffic from selected countries?", class: "block text-sm font-medium text-gray-700" %>
+ <%= form.label :policy_action, "What should happen to traffic from selected countries?", class: "block text-sm font-medium text-gray-700" %>
- <%= radio_button_tag "action", "deny", true, class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300" %>
+ <%= radio_button_tag "policy_action", "deny", true, class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300" %>
🚫 Block (Deny) - Show 403 Forbidden error
- <%= radio_button_tag "action", "challenge", false, class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300" %>
+ <%= radio_button_tag "policy_action", "challenge", false, class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300" %>
🛡️ Challenge - Present CAPTCHA challenge
- <%= radio_button_tag "action", "redirect", false, class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300" %>
+ <%= radio_button_tag "policy_action", "redirect", false, class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300" %>
🔄 Redirect - Redirect to compliance page
- <%= radio_button_tag "action", "allow", false, class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300" %>
+ <%= radio_button_tag "policy_action", "allow", false, class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300" %>
✅ Allow - Explicitly allow traffic
@@ -138,14 +163,14 @@