added vpn feature to mno

This commit is contained in:
Kwesi Banson Jnr
2026-08-17 19:37:22 +00:00
parent a797d9587c
commit e4a6a40d16
7 changed files with 329 additions and 8 deletions

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\NetworkOperator;
use App\Models\NetworkOperatorConnection;
class NetworkOperatorsController extends Controller
{
@@ -34,9 +35,10 @@ class NetworkOperatorsController extends Controller
$totalActive = NetworkOperator::where('connection_status', 'Active')->count();
$totalSipLinks = NetworkOperator::where('connection_type', 'like', '%VPN%')->count();
$uniqueCountries = $uniqueCountriesCount = NetworkOperator::distinct('country')->count('country');
$totalOperators = NetworkOperator::count();
return view('network_operators.index', compact('operators', 'totalActive', 'totalSipLinks', 'totalOperators'));
return view('network_operators.index', compact('operators', 'totalActive', 'uniqueCountries', 'totalOperators'));
}
public function store(Request $request)
@@ -111,5 +113,61 @@ class NetworkOperatorsController extends Controller
return view('network_operators.show', compact('operator'));
}
public function storeConnection(Request $request, $operatorId)
{
$request->validate([
'connection_mode' => 'required|string',
'identifier' => [
'required',
'string',
function ($attribute, $value, $fail) {
// 1. Check if valid IP Address (IPv4 or IPv6)
$isIp = filter_var($value, FILTER_VALIDATE_IP);
// 2. Check if valid Domain Name (e.g., smpp.operator.com)
$isDomain = preg_match('/^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/i', $value);
// 3. Check if valid CIDR Subnet (e.g., 196.200.15.0/24)
$isSubnet = preg_match('/^([0-9]{1,3}\.){3}[0-9]{1,3}\/([0-9]|[1-2][0-9]|3[0-2])$/', $value);
if (!$isIp && !$isDomain && !$isSubnet) {
$fail('The identifier must be a valid IP address, CIDR subnet, or Domain Name.');
}
},
],
'port' => 'nullable|integer|min:1|max:65535',
'vpn_file' => 'nullable|file|mimes:pdf,doc,docx|max:5120',
]);
$filePath = null;
if ($request->hasFile('vpn_file')) {
$filePath = $request->file('vpn_file')->store('vpn_forms', 'public');
}
$connection = NetworkOperatorConnection::create([
'network_operator_id' => $operatorId,
'connection_mode' => $request->connection_mode,
'identifier' => $request->identifier,
'port' => $request->port,
'status' => $request->status ?? 'Active',
'notes' => $request->notes,
'vpn_file_path' => $filePath,
]);
return response()->json([
'success' => true,
'message' => 'Connection detail added successfully!',
'connection' => $connection
]);
}
public function destroyConnection($id)
{
$connection = NetworkOperatorConnection::findOrFail($id);
$connection->delete();
return redirect()->back()->with('success', 'Connection detail removed.');
}
}

View File

@@ -19,6 +19,10 @@ class NetworkOperator extends Model
public function account_manager_info(){
return $this->hasOne('App\Models\StaffMember', 'id', 'account_manager_id');
}
public function connectionDetails()
{
return $this->hasMany(NetworkOperatorConnection::class, 'network_operator_id');
}
/*
public function setBuyingRateAttribute($value)
{
@@ -32,4 +36,5 @@ class NetworkOperator extends Model
set: fn (string $value) => filter_var($value, FILTER_SANITIZE_NUMBER_FLOAT, FILTER_FLAG_ALLOW_FRACTION)
);
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class NetworkOperatorConnection extends Model
{
protected $table = 'network_operator_connections';
protected $guarded = [];
public function operator()
{
return $this->belongsTo(NetworkOperator::class, 'network_operator_id');
}
}

View File

@@ -34,13 +34,13 @@
<div class="col-xl-4 col-md-6">
<div class="content-card p-3 h-100 border-start border-4 border-0" style="border-left-color: #5c4df0 !important;">
<div class="text-secondary fw-bold mb-1" style="font-size: 0.75rem; letter-spacing: 0.5px;">ACTIVE CONNECTIONS</div>
<h3 class="fw-bold mb-0">{{ $totalActive }} <span class="fs-6 fw-normal text-success"><i class="bi bi-arrow-up"></i> Online</span></h3>
<h3 class="fw-bold mb-0">{{ $totalActive }} <span class="fs-6 fw-normal text-success"><i class="bi bi-arrow-up"></i> Active</span></h3>
</div>
</div>
<div class="col-xl-4 col-md-6">
<div class="content-card p-3 h-100 border-start border-4 border-0" style="border-left-color: #10b981 !important;">
<div class="text-secondary fw-bold mb-1" style="font-size: 0.75rem; letter-spacing: 0.5px;">VPN LINKS </div>
<h3 class="fw-bold mb-0">{{ $totalSipLinks }} Links</h3>
<div class="text-secondary fw-bold mb-1" style="font-size: 0.75rem; letter-spacing: 0.5px;">Countries </div>
<h3 class="fw-bold mb-0">{{ $uniqueCountries }} </h3>
</div>
</div>
<div class="col-xl-4 col-md-6">

View File

@@ -108,8 +108,8 @@
<input type="text" class="form-control" id="mnoTechSupport" name="technical_support_person" placeholder="Tech contact">
</div>
<div class="col-md-4">
<label class="form-label text-secondary fw-semibold" style="font-size: 0.85rem;">Support Skype</label>
<input type="text" class="form-control" id="mnoSupportSkype" name="support_skype" placeholder="Skype ID">
<label class="form-label text-secondary fw-semibold" style="font-size: 0.85rem;">Support MS Teams</label>
<input type="text" class="form-control" id="mnoSupportSkype" name="support_skype" placeholder="MS Teams ID">
</div>
<div class="col-md-4">
<label class="form-label text-secondary fw-semibold" style="font-size: 0.85rem;">Account Manager</label>

View File

@@ -155,7 +155,247 @@
@endif
</div>
</div>
</div>
</div> <!-- Close Support Contacts content-card -->
</div> <!-- Close Right Column (col-lg-4) -->
</div> <!-- Close the main row (row g-4) -->
<!-- Connections & Interconnect Details Section -->
<div class="content-card p-4 mb-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="fw-bold mb-0 text-secondary" style="font-size: 0.9rem; text-transform: uppercase; letter-spacing: 0.5px;">
<i class="bi bi-shield-lock me-2 text-primary"></i> Connection Endpoints (VPN / Whitelisted IPs & Domains)
</h5>
<button type="button" class="btn btn-sm text-white fw-bold" style="background-color: #5c4df0;" data-bs-toggle="modal" data-bs-target="#addConnectionModal">
<i class="bi bi-plus-lg me-1"></i> Add Connection Detail
</button>
</div>
<hr class="text-muted opacity-25">
<div class="table-responsive">
<table class="table table-custom align-middle mb-0">
<thead>
<tr>
<th>Mode</th>
<th>Identifier (IP / Domain / VPN)</th>
<th>Port</th>
<th>Status</th>
<th>Notes</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@forelse($operator->connectionDetails as $conn)
<tr>
<td>
<span class="badge bg-secondary bg-opacity-10 text-dark border">{{ $conn->connection_mode }}</span>
@if($conn->vpn_file_path)
<a href="{{ asset('storage/' . $conn->vpn_file_path) }}" target="_blank" class="d-block mt-1" style="font-size: 0.75rem;">
<i class="bi bi-paperclip"></i> View Form
</a>
@endif
</td>
<td><code>{{ $conn->identifier }}</code></td>
<td>{{ $conn->port ?? 'N/A' }}</td>
<td>
<span class="badge {{ $conn->status == 'Active' ? 'bg-success' : 'bg-warning' }} bg-opacity-10 text-success px-2 py-1">
{{ $conn->status }}
</span>
</td>
<td>{{ $conn->notes ?? '-' }}</td>
<td class="text-end">
<form action="{{ route('mnos.connections.destroy', $conn->id) }}" method="POST" class="d-inline">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-sm btn-light text-danger" onclick="return confirm('Delete this connection detail?')">
<i class="bi bi-trash"></i>
</button>
</form>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="text-center text-secondary py-4">No specific VPN or IP whitelisting rules added yet.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
@endsection
</div>
</div>
</div>
@endsection
@push('modals')
<!-- Add Connection Modal -->
<!-- Add Connection Modal -->
<div class="modal fade" id="addConnectionModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header bg-light">
<h5 class="modal-title fw-bold">Add Interconnect Detail</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<!-- Added ID to the form -->
<form id="addConnectionForm" action="{{ route('mnos.connections.store', $operator->id) }}" method="POST" enctype="multipart/form-data">
@csrf
<div class="modal-body p-4">
<!-- Alert container for AJAX errors -->
<div id="connectionModalAlert"></div>
<div class="mb-3">
<label class="form-label text-secondary fw-semibold small">Connection Mode *</label>
<select name="connection_mode" id="connectionModeSelect" class="form-select" required>
<option value="Whitelisted IP">Whitelisted IP Address</option>
<option value="Domain">Domain / Hostname</option>
<option value="VPN Tunnel">VPN Tunnel Endpoint</option>
</select>
</div>
<div class="mb-3" id="vpnFormContainer" style="display: none;">
<label class="form-label text-secondary fw-semibold small">Upload VPN Form (PDF, DOCX)</label>
<input type="file" name="vpn_file" id="vpnFileInput" class="form-control" accept=".pdf,.doc,.docx,.xlsx,.jpg,.png">
</div>
<div class="mb-3">
<label class="form-label text-secondary fw-semibold small">Identifier (IP / Domain / VPN Peer) *</label>
<input type="text" name="identifier" class="form-control" required placeholder="e.g. 196.200.15.22 or smpp.operator.com">
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label text-secondary fw-semibold small">Port</label>
<!-- Added type="number", min, and max -->
<input type="number" name="port" class="form-control" placeholder="e.g. 2775" min="1" max="65535">
</div>
<div class="col-md-6">
<label class="form-label text-secondary fw-semibold small">Status</label>
<select name="status" class="form-select">
<option value="Active">Active</option>
<option value="Testing">Testing</option>
<option value="Disabled">Disabled</option>
</select>
</div>
</div>
<div class="mb-3">
<label class="form-label text-secondary fw-semibold small">Notes / Description</label>
<textarea name="notes" class="form-control" rows="2" placeholder="Optional context or routing details..."></textarea>
</div>
</div>
<div class="modal-footer bg-light">
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
<button type="submit" id="btnSubmitConnection" class="btn text-white btn-sm fw-bold px-4" style="background-color: #5c4df0;">Save Endpoint</button>
</div>
</form>
</div>
</div>
</div>
@endpush
@push('scripts')
<script>
$(document).ready(function() {
console.log('foo bar')
$('#connectionModeSelect').on('change', function() {
if ($(this).val() === 'VPN Tunnel') {
$('#vpnFormContainer').slideDown();
} else {
$('#vpnFormContainer').slideUp();
$('#vpnFileInput').val(''); // Clear the file input if they switch away
}
});
});
$(document).ready(function() {
// Toggle VPN File input visibility
$('#connectionModeSelect').on('change', function() {
if ($(this).val() === 'VPN Tunnel') {
$('#vpnFormContainer').slideDown();
} else {
$('#vpnFormContainer').slideUp();
$('#vpnFileInput').val('');
}
});
// Handle AJAX Form Submission
$('#addConnectionForm').on('submit', function(e) {
e.preventDefault();
let $form = $(this);
let $submitBtn = $('#btnSubmitConnection');
let originalText = $submitBtn.html();
let $alertBox = $('#connectionModalAlert');
// Use FormData to handle the file upload payload
let formData = new FormData(this);
$submitBtn.html('<span class="spinner-border spinner-border-sm me-2"></span>Saving...').prop('disabled', true);
$alertBox.html('');
$form.find('.is-invalid').removeClass('is-invalid');
$form.find('.invalid-feedback').remove();
$.ajax({
url: $form.attr('action'),
type: 'POST',
data: formData,
processData: false, // Prevent jQuery from converting the data into a query string
contentType: false, // Prevent jQuery from overriding the multipart header
success: function(response) {
if (response.success) {
$('#addConnectionModal').modal('hide');
Swal.fire({
icon: 'success',
title: 'Success!',
text: response.message,
confirmButtonColor: '#5c4df0'
}).then(() => {
// Reload the page to reflect the new connection in the table
location.reload();
});
}
},
error: function(xhr) {
if (xhr.status === 422) {
let errors = xhr.responseJSON.errors;
$alertBox.html(`
<div class="alert alert-danger d-flex align-items-center py-2 px-3 small" role="alert">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
<div>Please check the highlighted fields below.</div>
</div>
`);
// Highlight specific fields
$.each(errors, function(key, value) {
let $input = $form.find('[name="' + key + '"]');
if ($input.length) {
$input.addClass('is-invalid');
$input.parent().append('<div class="invalid-feedback d-block fw-medium small">' + value[0] + '</div>');
}
});
} else {
$alertBox.html(`
<div class="alert alert-danger d-flex align-items-center py-2 px-3 small" role="alert">
<i class="bi bi-x-circle-fill me-2"></i>
<div>An error occurred. Please try again.</div>
</div>
`);
}
},
complete: function() {
$submitBtn.html(originalText).prop('disabled', false);
}
});
});
// Reset the form when the modal is closed
$('#addConnectionModal').on('hidden.bs.modal', function () {
$('#addConnectionForm')[0].reset();
$('#vpnFormContainer').hide();
$('#connectionModalAlert').html('');
$('#addConnectionForm').find('.is-invalid').removeClass('is-invalid');
$('#addConnectionForm').find('.invalid-feedback').remove();
});
});
</script>
@endpush

View File

@@ -27,6 +27,8 @@ Route::middleware(['auth'])->group(function () {
Route::resource('clients', App\Http\Controllers\ClientsController::class);
#MNOs mnos.store
Route::post('/mnos/{id}/connections', [App\Http\Controllers\NetworkOperatorsController::class, 'storeConnection'])->name('mnos.connections.store');
Route::delete('/mnos/connections/{id}', [App\Http\Controllers\NetworkOperatorsController::class, 'destroyConnection'])->name('mnos.connections.destroy');
Route::get('/mnos/{id}', [App\Http\Controllers\NetworkOperatorsController::class, 'show'])->name('mnos.show');
Route::get('/mnos', [App\Http\Controllers\NetworkOperatorsController::class, 'index'])->name('mnos.index');
Route::post('/mnos', [App\Http\Controllers\NetworkOperatorsController::class, 'store'])->name('mnos.store');