Central Security Centre
* Security policy management * Minimum password-length policy * Mixed-case, number, and symbol requirements * Password expiry and history settings * MFA requirement policy * Failed-login and lockout settings * Session idle-time policy * Single-session policy * Automatic successful-login recording * Automatic failed-login recording * Automatic logout recording * Authentication history * IP allow and deny rules * IPv4 CIDR support * Administration and login rule scopes * Trusted-device storage and revocation * Security middleware registration * Administration navigation * Security permissions * Module registration and tests
This commit is contained in:
@@ -67,6 +67,9 @@ final class RolePermissionSeeder extends Seeder
|
||||
'commerce.manage',
|
||||
'commerce.orders.manage',
|
||||
'commerce.products.manage',
|
||||
'security.view',
|
||||
'security.manage',
|
||||
'security.audit',
|
||||
];
|
||||
|
||||
foreach ($permissions as $permission) {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
return [
|
||||
'record_authentication_events' => (bool) env('SECURITY_RECORD_AUTH_EVENTS', true),
|
||||
'trusted_device_days' => (int) env('SECURITY_TRUSTED_DEVICE_DAYS', 30),
|
||||
'session_idle_minutes' => (int) env('SECURITY_SESSION_IDLE_MINUTES', 120),
|
||||
'max_failed_logins' => (int) env('SECURITY_MAX_FAILED_LOGINS', 10),
|
||||
'lockout_minutes' => (int) env('SECURITY_LOCKOUT_MINUTES', 15),
|
||||
];
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('security_policies', function (Blueprint $table) {
|
||||
$table->id(); $table->string('name')->default('Default security policy');
|
||||
$table->unsignedSmallInteger('minimum_password_length')->default(12);
|
||||
$table->boolean('require_mixed_case')->default(true); $table->boolean('require_numbers')->default(true);
|
||||
$table->boolean('require_symbols')->default(true); $table->boolean('require_mfa')->default(false);
|
||||
$table->unsignedInteger('password_expiry_days')->nullable(); $table->unsignedSmallInteger('password_history')->default(5);
|
||||
$table->unsignedSmallInteger('max_failed_logins')->default(10); $table->unsignedSmallInteger('lockout_minutes')->default(15);
|
||||
$table->unsignedSmallInteger('session_idle_minutes')->default(120); $table->boolean('single_session')->default(false);
|
||||
$table->boolean('is_active')->default(true); $table->timestamps();
|
||||
});
|
||||
Schema::create('security_login_events', function (Blueprint $table) {
|
||||
$table->id(); $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->string('email')->nullable()->index(); $table->string('event', 30)->index();
|
||||
$table->string('ip_address', 45)->nullable()->index(); $table->text('user_agent')->nullable();
|
||||
$table->string('session_id')->nullable()->index(); $table->string('reason')->nullable();
|
||||
$table->json('metadata')->nullable(); $table->timestamp('occurred_at')->useCurrent(); $table->timestamps();
|
||||
});
|
||||
Schema::create('security_devices', function (Blueprint $table) {
|
||||
$table->id(); $table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('name'); $table->string('fingerprint', 64)->index(); $table->string('ip_address', 45)->nullable();
|
||||
$table->text('user_agent')->nullable(); $table->timestamp('trusted_until')->nullable();
|
||||
$table->timestamp('last_seen_at')->nullable(); $table->timestamp('revoked_at')->nullable(); $table->timestamps();
|
||||
$table->unique(['user_id','fingerprint']);
|
||||
});
|
||||
Schema::create('security_ip_rules', function (Blueprint $table) {
|
||||
$table->id(); $table->string('name'); $table->string('type', 10)->default('deny');
|
||||
$table->string('address', 64); $table->text('description')->nullable();
|
||||
$table->boolean('applies_to_admin')->default(true); $table->boolean('applies_to_login')->default(true);
|
||||
$table->boolean('is_active')->default(true); $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->timestamps(); $table->index(['type','is_active']);
|
||||
});
|
||||
}
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('security_ip_rules'); Schema::dropIfExists('security_devices');
|
||||
Schema::dropIfExists('security_login_events'); Schema::dropIfExists('security_policies');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
namespace Modules\Security\Http\Controllers;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Modules\Security\Models\IpRule;
|
||||
use Modules\Security\Models\LoginEvent;
|
||||
use Modules\Security\Models\SecurityPolicy;
|
||||
use Modules\Security\Models\TrustedDevice;
|
||||
final class SecurityController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
return view('security::admin.index',['policy'=>SecurityPolicy::first(),'events'=>LoginEvent::latest('occurred_at')->paginate(25),'devices'=>TrustedDevice::whereNull('revoked_at')->latest('last_seen_at')->limit(20)->get(),'rules'=>IpRule::latest()->get(),'failed24h'=>LoginEvent::where('event','failed')->where('occurred_at','>=',now()->subDay())->count()]);
|
||||
}
|
||||
public function savePolicy(Request $request): RedirectResponse
|
||||
{
|
||||
$data=$request->validate(['minimum_password_length'=>['required','integer','min:8','max:128'],'password_expiry_days'=>['nullable','integer','min:1'],'password_history'=>['required','integer','min:0','max:24'],'max_failed_logins'=>['required','integer','min:1','max:100'],'lockout_minutes'=>['required','integer','min:1','max:1440'],'session_idle_minutes'=>['required','integer','min:5','max:10080']]);
|
||||
foreach(['require_mixed_case','require_numbers','require_symbols','require_mfa','single_session'] as $key) $data[$key]=$request->boolean($key);
|
||||
SecurityPolicy::updateOrCreate(['id'=>SecurityPolicy::value('id')],$data+['name'=>'Default security policy','is_active'=>true]);
|
||||
return back()->with('status','Security policy updated.');
|
||||
}
|
||||
public function storeRule(Request $request): RedirectResponse
|
||||
{
|
||||
IpRule::create($request->validate(['name'=>['required','string','max:255'],'type'=>['required','in:allow,deny'],'address'=>['required','string','max:64'],'description'=>['nullable','string']])+['applies_to_admin'=>$request->boolean('applies_to_admin'),'applies_to_login'=>$request->boolean('applies_to_login'),'is_active'=>true,'created_by'=>$request->user()->id]);
|
||||
return back()->with('status','IP rule created.');
|
||||
}
|
||||
public function destroyRule(IpRule $rule): RedirectResponse { $rule->delete(); return back()->with('status','IP rule deleted.'); }
|
||||
public function revokeDevice(TrustedDevice $device): RedirectResponse { $device->update(['revoked_at'=>now()]); return back()->with('status','Device trust revoked.'); }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
namespace Modules\Security\Http\Middleware;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Modules\Security\Services\IpRuleService;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
final class EnforceIpRestrictions
|
||||
{
|
||||
public function __construct(private readonly IpRuleService $rules) {}
|
||||
public function handle(Request $request, Closure $next, string $scope='admin'): Response
|
||||
{
|
||||
abort_if($this->rules->isBlocked((string)$request->ip(),$scope),403,'Access is not permitted from this IP address.');
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
namespace Modules\Security\Listeners;
|
||||
use Illuminate\Auth\Events\Failed;
|
||||
use Modules\Security\Models\LoginEvent;
|
||||
final class RecordFailedLogin
|
||||
{
|
||||
public function handle(Failed $event): void
|
||||
{
|
||||
if (!config('security.record_authentication_events',true)) return;
|
||||
$request=request(); $user=property_exists($event,'user')?$event->user:null;
|
||||
LoginEvent::create(['user_id'=>$user?->getAuthIdentifier(),'email'=>$user?->email ?? (property_exists($event,'credentials')?($event->credentials['email']??null):null),'event'=>'failed','ip_address'=>$request?->ip(),'user_agent'=>$request?->userAgent(),'session_id'=>$request?->hasSession()?$request->session()->getId():null,'occurred_at'=>now()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
namespace Modules\Security\Listeners;
|
||||
use Illuminate\Auth\Events\Login;
|
||||
use Modules\Security\Models\LoginEvent;
|
||||
final class RecordLogin
|
||||
{
|
||||
public function handle(Login $event): void
|
||||
{
|
||||
if (!config('security.record_authentication_events',true)) return;
|
||||
$request=request(); $user=property_exists($event,'user')?$event->user:null;
|
||||
LoginEvent::create(['user_id'=>$user?->getAuthIdentifier(),'email'=>$user?->email ?? (property_exists($event,'credentials')?($event->credentials['email']??null):null),'event'=>'login','ip_address'=>$request?->ip(),'user_agent'=>$request?->userAgent(),'session_id'=>$request?->hasSession()?$request->session()->getId():null,'occurred_at'=>now()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
namespace Modules\Security\Listeners;
|
||||
use Illuminate\Auth\Events\Logout;
|
||||
use Modules\Security\Models\LoginEvent;
|
||||
final class RecordLogout
|
||||
{
|
||||
public function handle(Logout $event): void
|
||||
{
|
||||
if (!config('security.record_authentication_events',true)) return;
|
||||
$request=request(); $user=property_exists($event,'user')?$event->user:null;
|
||||
LoginEvent::create(['user_id'=>$user?->getAuthIdentifier(),'email'=>$user?->email ?? (property_exists($event,'credentials')?($event->credentials['email']??null):null),'event'=>'logout','ip_address'=>$request?->ip(),'user_agent'=>$request?->userAgent(),'session_id'=>$request?->hasSession()?$request->session()->getId():null,'occurred_at'=>now()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
namespace Modules\Security\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
final class IpRule extends Model
|
||||
{
|
||||
protected $table='security_ip_rules';
|
||||
protected $fillable=['name','type','address','description','applies_to_admin','applies_to_login','is_active','created_by'];
|
||||
protected $casts=['applies_to_admin'=>'boolean','applies_to_login'=>'boolean','is_active'=>'boolean'];
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
namespace Modules\Security\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
final class LoginEvent extends Model
|
||||
{
|
||||
protected $table='security_login_events';
|
||||
protected $fillable=['user_id','email','event','ip_address','user_agent','session_id','reason','metadata','occurred_at'];
|
||||
protected $casts=['metadata'=>'array','occurred_at'=>'datetime'];
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
namespace Modules\Security\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
final class SecurityPolicy extends Model
|
||||
{
|
||||
protected $fillable=['name','minimum_password_length','require_mixed_case','require_numbers','require_symbols','require_mfa','password_expiry_days','password_history','max_failed_logins','lockout_minutes','session_idle_minutes','single_session','is_active'];
|
||||
protected $casts=['require_mixed_case'=>'boolean','require_numbers'=>'boolean','require_symbols'=>'boolean','require_mfa'=>'boolean','single_session'=>'boolean','is_active'=>'boolean'];
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
namespace Modules\Security\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
final class TrustedDevice extends Model
|
||||
{
|
||||
protected $table='security_devices';
|
||||
protected $fillable=['user_id','name','fingerprint','ip_address','user_agent','trusted_until','last_seen_at','revoked_at'];
|
||||
protected $casts=['trusted_until'=>'datetime','last_seen_at'=>'datetime','revoked_at'=>'datetime'];
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
namespace Modules\Security\Providers;
|
||||
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
final class RouteServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function boot(): void
|
||||
{
|
||||
Route::middleware('web')->group(module_path('Security', 'Routes/web.php'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
namespace Modules\Security\Providers;
|
||||
use Illuminate\Auth\Events\Failed;
|
||||
use Illuminate\Auth\Events\Login;
|
||||
use Illuminate\Auth\Events\Logout;
|
||||
use Illuminate\Routing\Router;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Modules\Security\Http\Middleware\EnforceIpRestrictions;
|
||||
use Modules\Security\Listeners\RecordFailedLogin;
|
||||
use Modules\Security\Listeners\RecordLogin;
|
||||
use Modules\Security\Listeners\RecordLogout;
|
||||
final class SecurityServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->register(RouteServiceProvider::class);
|
||||
$this->mergeConfigFrom(module_path('Security', 'Config/config.php'), 'security');
|
||||
}
|
||||
public function boot(Router $router): void
|
||||
{
|
||||
$this->loadMigrationsFrom(module_path('Security', 'Database/Migrations'));
|
||||
$this->loadViewsFrom(module_path('Security', 'Resources/views'), 'security');
|
||||
$router->aliasMiddleware('security.ip', EnforceIpRestrictions::class);
|
||||
Event::listen(Login::class, RecordLogin::class);
|
||||
Event::listen(Failed::class, RecordFailedLogin::class);
|
||||
Event::listen(Logout::class, RecordLogout::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<x-layouts.app :title="__('Security Centre')"><div class="space-y-8"><div><h1 class="text-2xl font-semibold">Security Centre</h1><p class="text-sm text-zinc-500">Identity policy, authentication history, trusted devices and network access controls.</p></div>@if(session('status'))<div class="rounded border border-green-300 bg-green-50 p-3 text-green-800">{{ session('status') }}</div>@endif
|
||||
<div class="grid gap-4 md:grid-cols-4"><div class="rounded-xl border p-4"><div class="text-sm text-zinc-500">Failed logins (24h)</div><div class="text-2xl font-semibold">{{ $failed24h }}</div></div><div class="rounded-xl border p-4"><div class="text-sm text-zinc-500">Active trusted devices</div><div class="text-2xl font-semibold">{{ $devices->count() }}</div></div><div class="rounded-xl border p-4"><div class="text-sm text-zinc-500">IP rules</div><div class="text-2xl font-semibold">{{ $rules->count() }}</div></div><div class="rounded-xl border p-4"><div class="text-sm text-zinc-500">MFA required</div><div class="text-2xl font-semibold">{{ $policy?->require_mfa ? 'Yes' : 'No' }}</div></div></div>
|
||||
<div class="grid gap-6 xl:grid-cols-2"><form method="POST" action="{{ route('administration.security.policy.update') }}" class="space-y-4 rounded-xl border p-5">@csrf @method('PUT')<h2 class="font-semibold">Security policy</h2><div class="grid gap-4 md:grid-cols-2"><flux:input name="minimum_password_length" type="number" label="Minimum password length" value="{{ old('minimum_password_length',$policy?->minimum_password_length ?? 12) }}"/><flux:input name="password_expiry_days" type="number" label="Password expiry days" value="{{ old('password_expiry_days',$policy?->password_expiry_days) }}"/><flux:input name="password_history" type="number" label="Password history" value="{{ old('password_history',$policy?->password_history ?? 5) }}"/><flux:input name="max_failed_logins" type="number" label="Maximum failed logins" value="{{ old('max_failed_logins',$policy?->max_failed_logins ?? 10) }}"/><flux:input name="lockout_minutes" type="number" label="Lockout minutes" value="{{ old('lockout_minutes',$policy?->lockout_minutes ?? 15) }}"/><flux:input name="session_idle_minutes" type="number" label="Session idle minutes" value="{{ old('session_idle_minutes',$policy?->session_idle_minutes ?? 120) }}"/></div><div class="grid gap-3 md:grid-cols-2"><flux:checkbox name="require_mixed_case" label="Require mixed case" :checked="$policy?->require_mixed_case ?? true"/><flux:checkbox name="require_numbers" label="Require numbers" :checked="$policy?->require_numbers ?? true"/><flux:checkbox name="require_symbols" label="Require symbols" :checked="$policy?->require_symbols ?? true"/><flux:checkbox name="require_mfa" label="Require multi-factor authentication" :checked="$policy?->require_mfa ?? false"/><flux:checkbox name="single_session" label="Allow one session per user" :checked="$policy?->single_session ?? false"/></div><flux:button type="submit" variant="primary">Save policy</flux:button></form>
|
||||
<form method="POST" action="{{ route('administration.security.rules.store') }}" class="space-y-4 rounded-xl border p-5">@csrf<h2 class="font-semibold">Add IP rule</h2><flux:input name="name" label="Rule name" required/><div class="grid gap-4 md:grid-cols-2"><flux:select name="type" label="Action"><option value="deny">Deny</option><option value="allow">Allow</option></flux:select><flux:input name="address" label="IP address or IPv4 CIDR" placeholder="192.0.2.10 or 10.0.0.0/8" required/></div><flux:textarea name="description" label="Description"/><div class="flex gap-5"><flux:checkbox name="applies_to_admin" label="Administration" checked/><flux:checkbox name="applies_to_login" label="Login" checked/></div><flux:button type="submit" variant="primary">Create rule</flux:button><div class="space-y-2">@foreach($rules as $rule)<div class="flex items-center justify-between rounded border p-3"><div><span class="font-medium">{{ $rule->name }}</span><span class="ml-2 text-xs uppercase text-zinc-500">{{ $rule->type }}</span><div class="text-sm text-zinc-500">{{ $rule->address }}</div></div><form method="POST" action="{{ route('administration.security.rules.destroy',$rule) }}">@csrf @method('DELETE')<flux:button size="sm" variant="danger" type="submit">Delete</flux:button></form></div>@endforeach</div></form></div>
|
||||
<div class="rounded-xl border"><div class="border-b p-4 font-semibold">Authentication history</div><div class="overflow-x-auto"><table class="w-full text-sm"><thead class="bg-zinc-50"><tr><th class="p-3 text-left">When</th><th class="p-3 text-left">Event</th><th class="p-3 text-left">User</th><th class="p-3 text-left">IP address</th><th class="p-3 text-left">Client</th></tr></thead><tbody>@forelse($events as $event)<tr class="border-t"><td class="p-3">{{ $event->occurred_at?->format('d M Y H:i:s') }}</td><td class="p-3 capitalize">{{ $event->event }}</td><td class="p-3">{{ $event->email ?: 'Unknown' }}</td><td class="p-3">{{ $event->ip_address ?: '—' }}</td><td class="max-w-md truncate p-3 text-zinc-500">{{ $event->user_agent ?: '—' }}</td></tr>@empty<tr><td colspan="5" class="p-6 text-center text-zinc-500">No authentication events recorded.</td></tr>@endforelse</tbody></table></div><div class="p-4">{{ $events->links() }}</div></div></div></x-layouts.app>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Security\Http\Controllers\SecurityController;
|
||||
Route::prefix('administration/security')->name('administration.security.')->middleware(['auth','permission:security.view','security.ip:admin'])->group(function(){
|
||||
Route::get('/',[SecurityController::class,'index'])->name('index');
|
||||
Route::put('/policy',[SecurityController::class,'savePolicy'])->middleware('permission:security.manage')->name('policy.update');
|
||||
Route::post('/ip-rules',[SecurityController::class,'storeRule'])->middleware('permission:security.manage')->name('rules.store');
|
||||
Route::delete('/ip-rules/{rule}',[SecurityController::class,'destroyRule'])->middleware('permission:security.manage')->name('rules.destroy');
|
||||
Route::delete('/devices/{device}',[SecurityController::class,'revokeDevice'])->middleware('permission:security.manage')->name('devices.revoke');
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
namespace Modules\Security\Services;
|
||||
use Modules\Security\Models\IpRule;
|
||||
final class IpRuleService
|
||||
{
|
||||
public function isBlocked(string $ip, string $scope='admin'): bool
|
||||
{
|
||||
$column=$scope==='login'?'applies_to_login':'applies_to_admin';
|
||||
$rules=IpRule::query()->where('is_active',true)->where($column,true)->get();
|
||||
$allow=$rules->where('type','allow');
|
||||
if ($allow->isNotEmpty() && !$allow->contains(fn(IpRule $rule)=>$this->matches($ip,$rule->address))) return true;
|
||||
return $rules->where('type','deny')->contains(fn(IpRule $rule)=>$this->matches($ip,$rule->address));
|
||||
}
|
||||
private function matches(string $ip,string $rule): bool
|
||||
{
|
||||
if ($ip===$rule) return true;
|
||||
if (!str_contains($rule,'/')) return false;
|
||||
[$subnet,$bits]=explode('/',$rule,2); $bits=(int)$bits;
|
||||
if (filter_var($ip,FILTER_VALIDATE_IP,FILTER_FLAG_IPV4) && filter_var($subnet,FILTER_VALIDATE_IP,FILTER_FLAG_IPV4)) {
|
||||
$mask=-1 << (32-$bits); return (ip2long($ip)&$mask)===(ip2long($subnet)&$mask);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
namespace Modules\Security\Tests\Feature;
|
||||
use Tests\TestCase;
|
||||
final class ModuleBootTest extends TestCase
|
||||
{
|
||||
public function test_security_route_is_registered(): void { $this->assertTrue(app('router')->has('administration.security.index')); }
|
||||
public function test_security_middleware_alias_is_registered(): void { $this->assertArrayHasKey('security.ip',app('router')->getMiddleware()); }
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"name":"Security","alias":"security","version":"1.0.0","description":"Identity policies, login history, trusted devices, IP restrictions and session security.","keywords":["identity","security","sessions","devices","authentication"],"priority":120,"providers":["Modules\\Security\\Providers\\SecurityServiceProvider"],"aliases":{},"files":[],"requires":["Core","Administration","Users","Roles","Audit"]}
|
||||
@@ -36,7 +36,7 @@
|
||||
"Sites": true,
|
||||
"Developer": true,
|
||||
"Api": true,
|
||||
"Enterprise": true
|
||||
,
|
||||
"Commerce": true
|
||||
"Enterprise": true,
|
||||
"Commerce": true,
|
||||
"Security": true
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
<flux:sidebar sticky collapsible="mobile" class="border-e border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900">
|
||||
<flux:sidebar.header><x-stylist-theme-common::app-logo :sidebar="true" href="{{ route('dashboard') }}" wire:navigate /><flux:sidebar.collapse class="lg:hidden" /></flux:sidebar.header>
|
||||
<flux:sidebar.nav>
|
||||
<flux:sidebar.group :heading="__('Security')" class="grid">
|
||||
<flux:sidebar.item icon="shield-check" :href="route('administration.security.index')" :current="request()->routeIs('administration.security.*')" wire:navigate>{{ __('Security Centre') }}</flux:sidebar.item>
|
||||
</flux:sidebar.group>
|
||||
<flux:sidebar.group :heading="__('Business')" class="grid">
|
||||
<flux:sidebar.item icon="shopping-bag" :href="route('administration.commerce.index')" :current="request()->routeIs('administration.commerce.*')" wire:navigate>{{ __('Commerce') }}</flux:sidebar.item>
|
||||
<flux:sidebar.item icon="building-office-2" :href="route('administration.crm.index')" :current="request()->routeIs('administration.crm.*')" wire:navigate>{{ __('CRM') }}</flux:sidebar.item>
|
||||
|
||||
Reference in New Issue
Block a user