From 82dc052ef5397d4205e93f065ee419c5dd080ca1 Mon Sep 17 00:00:00 2001
From: Crivion
Date: Wed, 22 Oct 2025 10:01:54 +0300
Subject: [PATCH] firewall: move add rule to modal for ux consistency
---
app/Http/Controllers/FirewallController.php | 35 +++-
resources/js/Pages/Firewall/Index.jsx | 32 +---
.../Partials/CreateFirewallRuleForm.jsx | 161 ++++++++++++++++++
3 files changed, 198 insertions(+), 30 deletions(-)
create mode 100644 resources/js/Pages/Firewall/Partials/CreateFirewallRuleForm.jsx
diff --git a/app/Http/Controllers/FirewallController.php b/app/Http/Controllers/FirewallController.php
index 7d48370..586486f 100644
--- a/app/Http/Controllers/FirewallController.php
+++ b/app/Http/Controllers/FirewallController.php
@@ -38,14 +38,43 @@ class FirewallController extends Controller
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
- 'rule' => 'required|string|min:2',
'type' => 'required|string|in:allow,deny',
+ 'protocol' => 'required|string|in:tcp,udp',
+ 'port' => 'required|integer|min:1|max:65535',
+ 'ip' => 'required|string', // validated below for any|ip|cidr
+ 'to' => 'required|string',
+ 'comment' => 'nullable|string|max:150',
]);
+ $from = trim($validated['ip']);
+ $to = trim($validated['to']);
+
+ $isAny = fn(string $v) => strtolower($v) === 'any';
+ $isIp = fn(string $v) => filter_var($v, FILTER_VALIDATE_IP) !== false;
+ $isCidr = fn(string $v) => (bool) preg_match('/^((25[0-5]|2[0-4]\\d|1?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|1?\\d?\\d)){3})\\/(3[0-2]|[12]?\\d)$/', $v);
+
+ if (!($isAny($from) || $isIp($from) || $isCidr($from))) {
+ return back()->withErrors(['ip' => 'IP must be "any", a valid IP address, or CIDR range.'])->withInput();
+ }
+ if (!($isAny($to) || $isIp($to))) {
+ return back()->withErrors(['to' => 'To must be "any" or a valid IP address.'])->withInput();
+ }
+
+ $proto = strtolower($validated['protocol']);
+ $port = (int) $validated['port'];
+ $comment = $validated['comment'] ?? '';
+ $comment = trim($comment);
+ $commentEscaped = str_replace("'", "\\'", $comment);
+
+ $spec = "proto {$proto} from {$from} to {$to} port {$port}";
+ if ($commentEscaped !== '') {
+ $spec .= " comment '" . $commentEscaped . "'";
+ }
+
if ($validated['type'] === 'allow') {
- (new AddUfwRuleAction())->execute($validated['rule']);
+ (new AddUfwRuleAction())->execute($spec);
} else {
- (new \App\Actions\Firewall\AddUfwDenyRuleAction())->execute($validated['rule']);
+ (new \App\Actions\Firewall\AddUfwDenyRuleAction())->execute($spec);
}
session()->flash('success', 'Rule ' . $validated['type'] . 'ed successfully.');
diff --git a/resources/js/Pages/Firewall/Index.jsx b/resources/js/Pages/Firewall/Index.jsx
index 8d7b343..9799d15 100644
--- a/resources/js/Pages/Firewall/Index.jsx
+++ b/resources/js/Pages/Firewall/Index.jsx
@@ -4,6 +4,7 @@ import { router } from '@inertiajs/react';
import { useState } from 'react';
import { toast } from 'react-toastify';
import ConfirmationButton from '@/Components/ConfirmationButton';
+import CreateFirewallRuleForm from './Partials/CreateFirewallRuleForm';
import { MdSecurity } from 'react-icons/md';
import { FaToggleOn, FaToggleOff } from 'react-icons/fa';
import { TiDelete } from 'react-icons/ti';
@@ -23,15 +24,7 @@ export default function FirewallIndex({ status, rules }) {
});
};
- const addRule = (e) => {
- e.preventDefault();
- if (!newRule.trim()) return;
- router.post(route('firewall.store'), { rule: newRule.trim(), type: ruleType }, {
- onBefore: () => toast('Adding rule...'),
- onSuccess: () => { setNewRule(''); setRuleType('allow'); router.reload({ only: ['rules'] }); },
- onError: () => toast('Failed to add rule')
- });
- };
+ // rule creation moved to modal form component
const deleteRule = (idOrSpec) => {
router.delete(route('firewall.destroy', { id: idOrSpec }), {
@@ -65,24 +58,9 @@ export default function FirewallIndex({ status, rules }) {
-
+
+
+
diff --git a/resources/js/Pages/Firewall/Partials/CreateFirewallRuleForm.jsx b/resources/js/Pages/Firewall/Partials/CreateFirewallRuleForm.jsx
new file mode 100644
index 0000000..3591f7a
--- /dev/null
+++ b/resources/js/Pages/Firewall/Partials/CreateFirewallRuleForm.jsx
@@ -0,0 +1,161 @@
+import InputError from '@/Components/InputError';
+import InputLabel from '@/Components/InputLabel';
+import Modal from '@/Components/Modal';
+import PrimaryButton from '@/Components/PrimaryButton';
+import SecondaryButton from '@/Components/SecondaryButton';
+import TextInput from '@/Components/TextInput';
+import { useForm } from '@inertiajs/react';
+import { useState } from 'react';
+import { MdSecurity } from 'react-icons/md';
+
+export default function CreateFirewallRuleForm() {
+ const [showModal, setShowModal] = useState(false);
+
+ const {
+ data,
+ setData,
+ post,
+ processing,
+ reset,
+ errors,
+ clearErrors,
+ } = useForm({
+ type: 'allow',
+ protocol: 'tcp',
+ port: '',
+ ip: 'any',
+ to: 'any',
+ comment: '',
+ });
+
+ const openModal = () => setShowModal(true);
+
+ const closeModal = () => {
+ setShowModal(false);
+ clearErrors();
+ reset({ type: 'allow', protocol: 'tcp', port: '', ip: 'any', to: 'any', comment: '' });
+ };
+
+ const submit = (e) => {
+ e.preventDefault();
+ post(route('firewall.store'), {
+ preserveScroll: true,
+ onSuccess: () => closeModal(),
+ });
+ };
+
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+}