feat: implement multi-tenancy and super admin impersonation with security banner

This commit is contained in:
jeremy bayse
2026-02-21 20:15:47 +01:00
parent a0e904d69d
commit 63e448ef22
31 changed files with 819 additions and 51 deletions

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use App\Models\Role;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class MakeSuperAdmin extends Command
{
protected $signature = 'make:superadmin {email}';
protected $description = 'Promote a user to SuperAdmin';
public function handle()
{
$email = $this->argument('email');
$user = User::withoutGlobalScope('structure')->where('email', $email)->first();
if (!$user) {
$this->error("Utilisateur non trouvé.");
return;
}
// S'assurer que le rôle SuperAdmin existe (globalement)
$role = Role::withoutGlobalScope('structure')->firstOrCreate(
['name' => 'SuperAdmin', 'guard_name' => 'web'],
['structure_id' => null] // Rôle global
);
// Assigner le rôle sur le contexte de l'utilisateur (ou structure 1 par défaut pour le CABM)
setPermissionsTeamId($user->structure_id ?? 1);
if (!$user->hasRole('SuperAdmin')) {
$user->assignRole($role);
}
$this->info("Félicitations ! L'utilisateur {$email} a été promu SuperAdmin.");
}
}

View File

@@ -31,17 +31,41 @@ class RegisteredUserController extends Controller
public function store(Request $request): RedirectResponse public function store(Request $request): RedirectResponse
{ {
$request->validate([ $request->validate([
'structure_name' => 'required|string|max:255|unique:structures,name',
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'email' => 'required|string|lowercase|email|max:255|unique:'.User::class, 'email' => 'required|string|lowercase|email|max:255|unique:'.User::class,
'password' => ['required', 'confirmed', Rules\Password::defaults()], 'password' => ['required', 'confirmed', Rules\Password::defaults()],
]); ]);
$user = User::create([ // 1. Créer la structure (Tenant)
// Le slug est généré avec un uniqid() pour éviter les conflits si deux noms produisent le même slug.
$structure = \App\Models\Structure::create([
'name' => $request->structure_name,
'slug' => \Illuminate\Support\Str::slug($request->structure_name) . '-' . substr(uniqid(), -5),
'is_active' => true,
]);
// 2. Définir le contexte Tenant pour que Spatie attache les rôles à cette structure-ci
config(['tenant.structure_id' => $structure->id]);
setPermissionsTeamId($structure->id);
// 3. Création des rôles par défaut pour le nouveau locataire
$adminRole = \App\Models\Role::firstOrCreate(['name' => 'Admin']);
\App\Models\Role::firstOrCreate(['name' => 'Agent']);
\App\Models\Role::firstOrCreate(['name' => 'Manager']);
\App\Models\Role::firstOrCreate(['name' => 'RH']);
// 4. Créer le premier compte Administrateur
$user = User::withoutGlobalScope('structure')->create([
'name' => $request->name, 'name' => $request->name,
'email' => $request->email, 'email' => $request->email,
'password' => Hash::make($request->password), 'password' => Hash::make($request->password),
'structure_id' => $structure->id,
]); ]);
// Affectation du rôle
$user->assignRole($adminRole);
event(new Registered($user)); event(new Registered($user));
Auth::login($user); Auth::login($user);

View File

@@ -4,7 +4,7 @@ namespace App\Http\Controllers;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Inertia; use Inertia\Inertia;
use Spatie\Permission\Models\Role; use App\Models\Role;
use Spatie\Permission\Models\Permission; use Spatie\Permission\Models\Permission;
class RoleController extends Controller class RoleController extends Controller

View File

@@ -5,7 +5,7 @@ namespace App\Http\Controllers;
use App\Models\Service; use App\Models\Service;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Inertia\Inertia; use Inertia\Inertia;
use Spatie\Permission\Models\Role; use App\Models\Role;
use Spatie\Permission\Models\Permission; use Spatie\Permission\Models\Permission;
class ServiceController extends Controller class ServiceController extends Controller

View File

@@ -0,0 +1,112 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Inertia\Inertia;
use App\Models\Structure;
class SuperAdminController extends Controller
{
public function index(Request $request)
{
// On s'assure que seul un SuperAdmin peut accéder ici
if (!auth()->user()->hasRole('SuperAdmin')) {
abort(403, 'Accès refusé. Vous devez être SuperAdmin.');
}
$structures = Structure::withCount(['users' => function ($query) {
$query->withoutGlobalScope('structure');
}])->get();
return Inertia::render('SuperAdmin/Index', [
'structures' => $structures,
'current_structure_id' => session('target_structure_id')
]);
}
public function create()
{
if (!auth()->user()->hasRole('SuperAdmin')) { abort(403); }
return Inertia::render('SuperAdmin/Create');
}
public function store(Request $request)
{
if (!auth()->user()->hasRole('SuperAdmin')) { abort(403); }
$validated = $request->validate([
'name' => 'required|string|max:255',
'slug' => 'required|string|max:255|unique:structures',
'domain' => 'nullable|string|max:255|unique:structures',
'is_active' => 'boolean'
]);
Structure::create($validated);
return redirect()->route('superadmin.index')->with('success', 'Structure créée avec succès.');
}
public function edit(Structure $structure)
{
if (!auth()->user()->hasRole('SuperAdmin')) { abort(403); }
return Inertia::render('SuperAdmin/Edit', [
'structure' => $structure
]);
}
public function update(Request $request, Structure $structure)
{
if (!auth()->user()->hasRole('SuperAdmin')) { abort(403); }
$validated = $request->validate([
'name' => 'required|string|max:255',
'slug' => 'required|string|max:255|unique:structures,slug,' . $structure->id,
'domain' => 'nullable|string|max:255|unique:structures,domain,' . $structure->id,
'is_active' => 'boolean'
]);
$structure->update($validated);
return redirect()->route('superadmin.index')->with('success', 'Structure mise à jour.');
}
public function destroy(Structure $structure)
{
if (!auth()->user()->hasRole('SuperAdmin')) { abort(403); }
if (Structure::count() <= 1) {
return redirect()->back()->with('error', 'Impossible de supprimer la dernière structure.');
}
$structure->delete();
return redirect()->route('superadmin.index')->with('success', 'Structure supprimée avec succès.');
}
public function switchStructure(Request $request, Structure $structure)
{
if (!auth()->user()->hasRole('SuperAdmin')) {
abort(403);
}
// On enregistre dans la session qu'on veut "impersonner" cette structure
$request->session()->put('target_structure_id', $structure->id);
return redirect()->route('dashboard')->with('success', "Vous naviguez maintenant sur la structure : {$structure->name}.");
}
public function resetStructure(Request $request)
{
if (!auth()->user()->hasRole('SuperAdmin')) {
abort(403);
}
// On retire l'impersonnation, on redevient un SuperAdmin "Global"
$request->session()->forget('target_structure_id');
return redirect()->route('superadmin.index')->with('success', "Périmètre global restauré.");
}
}

View File

@@ -30,7 +30,7 @@ class UserController extends Controller implements \Illuminate\Routing\Controlle
public function create() public function create()
{ {
return Inertia::render('User/Edit', [ return Inertia::render('User/Edit', [
'roles' => \Spatie\Permission\Models\Role::all(), 'roles' => \App\Models\Role::all(),
]); ]);
} }
@@ -60,7 +60,7 @@ class UserController extends Controller implements \Illuminate\Routing\Controlle
{ {
return Inertia::render('User/Edit', [ return Inertia::render('User/Edit', [
'user' => $user->load('roles'), 'user' => $user->load('roles'),
'roles' => \Spatie\Permission\Models\Role::all(), 'roles' => \App\Models\Role::all(),
]); ]);
} }

View File

@@ -32,7 +32,22 @@ class HandleInertiaRequests extends Middleware
return [ return [
...parent::share($request), ...parent::share($request),
'auth' => [ 'auth' => [
'user' => $request->user() ? $request->user()->load('roles') : null, 'user' => $request->user()
? $request->user()->load([
'roles' => function($q) { $q->withoutGlobalScope('structure'); },
'structure'
])
: null,
],
'tenant' => [
'current' => config('tenant.structure_id')
? \App\Models\Structure::find(config('tenant.structure_id'))
: null,
'is_impersonating' => $request->session()->has('target_structure_id'),
],
'flash' => [
'success' => $request->session()->get('success'),
'error' => $request->session()->get('error'),
], ],
]; ];
} }

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use Illuminate\Support\Facades\Auth;
class TenantContext
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
if (Auth::check()) {
$user = Auth::user();
// 1. Initialiser le contexte Spatie sur la propre structure de l'utilisateur
// CELA NE DOIT JAMAIS CHANGER !
// C'est ce qui permet à Laravel de toujours charger les rôles originels (Admin, SuperAdmin) du compte,
// même s'il est en train d'ausculter les données d'un autre client.
setPermissionsTeamId($user->structure_id);
// S'il s'agit d'un SuperAdmin, il peut avoir choisi une structure spécifique en session
if ($user->hasRole('SuperAdmin')) {
if ($request->session()->has('target_structure_id')) {
$targetId = $request->session()->get('target_structure_id');
// On modifie UNIQUEMENT la configuration globale pour filtrer les données (BelongsToStructure scope)
config(['tenant.structure_id' => $targetId]);
} else {
// Par défaut, s'il n'a pas ciblé de structure, vue globale (tous les locataires)
config(['tenant.structure_id' => null]);
// Et par défaut on remet sa session sur son origine si elle était vide
$request->session()->put('target_structure_id', null);
}
} else {
// Utilisateur SaaS standard : on fixe la config globale à SA structure
config(['tenant.structure_id' => $user->structure_id]);
// On met de force cette valeur dans sa session
$request->session()->put('target_structure_id', $user->structure_id);
}
}
return $next($request);
}
}

View File

@@ -3,9 +3,11 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use App\Traits\BelongsToStructure;
class Agent extends Model class Agent extends Model
{ {
use BelongsToStructure;
protected $fillable = [ protected $fillable = [
'first_name', 'first_name',
'last_name', 'last_name',

View File

@@ -3,9 +3,11 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use App\Traits\BelongsToStructure;
class Attachment extends Model class Attachment extends Model
{ {
use BelongsToStructure;
protected $fillable = [ protected $fillable = [
'service_task_id', 'service_task_id',
'filename', 'filename',

View File

@@ -3,9 +3,11 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use App\Traits\BelongsToStructure;
class Comment extends Model class Comment extends Model
{ {
use BelongsToStructure;
protected $fillable = ['user_id', 'content', 'commentable_id', 'commentable_type']; protected $fillable = ['user_id', 'content', 'commentable_id', 'commentable_type'];
public function user() public function user()

View File

@@ -5,10 +5,11 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Spatie\Activitylog\Traits\LogsActivity; use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions; use Spatie\Activitylog\LogOptions;
use App\Traits\BelongsToStructure;
class IntegrationRequest extends Model class IntegrationRequest extends Model
{ {
use LogsActivity; use LogsActivity, BelongsToStructure;
public function getActivitylogOptions(): LogOptions public function getActivitylogOptions(): LogOptions
{ {

View File

@@ -3,9 +3,12 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use App\Traits\BelongsToStructure;
class IntegrationTemplate extends Model class IntegrationTemplate extends Model
{ {
use BelongsToStructure;
protected $fillable = [ protected $fillable = [
'name', 'name',
'description', 'description',

View File

@@ -3,9 +3,12 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use App\Traits\BelongsToStructure;
class Service extends Model class Service extends Model
{ {
use BelongsToStructure;
protected $fillable = [ protected $fillable = [
'name', 'name',
'code', 'code',

View File

@@ -5,10 +5,11 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Spatie\Activitylog\Traits\LogsActivity; use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions; use Spatie\Activitylog\LogOptions;
use App\Traits\BelongsToStructure;
class ServiceTask extends Model class ServiceTask extends Model
{ {
use LogsActivity; use LogsActivity, BelongsToStructure;
public function getActivitylogOptions(): LogOptions public function getActivitylogOptions(): LogOptions
{ {

View File

@@ -3,9 +3,11 @@
namespace App\Models; namespace App\Models;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use App\Traits\BelongsToStructure;
class TaskItem extends Model class TaskItem extends Model
{ {
use BelongsToStructure;
protected $fillable = [ protected $fillable = [
'service_task_id', 'service_task_id',
'label', 'label',

View File

@@ -8,11 +8,14 @@ use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Spatie\Permission\Traits\HasRoles; use Spatie\Permission\Traits\HasRoles;
use App\Traits\BelongsToStructure;
class User extends Authenticatable class User extends Authenticatable
{ {
/** @use HasFactory<\Database\Factories\UserFactory> */ use HasFactory, Notifiable, BelongsToStructure;
use HasFactory, Notifiable, HasRoles; use HasRoles {
hasRole as traitHasRole;
}
/** /**
* The attributes that are mass assignable. * The attributes that are mass assignable.
@@ -23,6 +26,7 @@ class User extends Authenticatable
'name', 'name',
'email', 'email',
'password', 'password',
'structure_id',
]; ];
/** /**
@@ -47,4 +51,29 @@ class User extends Authenticatable
'password' => 'hashed', 'password' => 'hashed',
]; ];
} }
/**
* Override de Spatie HasRoles pour qu'un SuperAdmin valide toutes les vérifications de rôle
* Cela permet notamment de parcourir les locataires (Tenant) sans être bloqué par les "hasRole('Admin')"
*/
public function hasRole($roles, string $guard = null): bool
{
// Si on ne demande pas explicitement le rôle SuperAdmin, on vérifie si l'utilisateur l'a globalement.
// On passe par DB::table pour éviter que le GlobalScope 'structure' ne filtre nos propres rôles
// lorsqu'on est en train de simuler une autre structure.
if ($roles !== 'SuperAdmin') {
$isSuperAdmin = \Illuminate\Support\Facades\DB::table('model_has_roles')
->join('roles', 'roles.id', '=', 'model_has_roles.role_id')
->where('model_has_roles.model_id', $this->id)
->where('model_has_roles.model_type', self::class)
->where('roles.name', 'SuperAdmin')
->exists();
if ($isSuperAdmin) {
return true;
}
}
return $this->traitHasRole($roles, $guard);
}
} }

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Traits;
use App\Models\Structure;
use App\Models\Scopes\StructureScope;
use Illuminate\Database\Eloquent\Builder;
trait BelongsToStructure
{
/**
* Boot the trait to apply the GlobalScope.
*/
protected static function bootBelongsToStructure()
{
static::addGlobalScope('structure', function (Builder $builder) {
// On utilise la configuration injectée par le middleware plutôt que d'appeler auth()->user()
// Cela évite la boucle infinie d'authentification lorsque le scope s'applique à la table `users`.
$structureId = config('tenant.structure_id');
if ($structureId !== null) {
// Dans le cas spécifique de SQLite en mode de test, il faut parfois préciser la table.
// Par sécurité on gère la jointure si besoin, mas ici on reste simple.
$builder->where($builder->getModel()->getTable() . '.structure_id', $structureId);
}
});
static::creating(function ($model) {
// Assigner automatiquement la structure_id sur les nouveaux enregistrements
if (!$model->structure_id) {
$structureId = config('tenant.structure_id');
if ($structureId) {
$model->structure_id = $structureId;
}
}
});
}
/**
* Define the relationship to Structure.
*/
public function structure()
{
return $this->belongsTo(Structure::class);
}
}

View File

@@ -12,6 +12,7 @@ return Application::configure(basePath: dirname(__DIR__))
) )
->withMiddleware(function (Middleware $middleware) { ->withMiddleware(function (Middleware $middleware) {
$middleware->web(append: [ $middleware->web(append: [
\App\Http\Middleware\TenantContext::class,
\App\Http\Middleware\HandleInertiaRequests::class, \App\Http\Middleware\HandleInertiaRequests::class,
\Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class, \Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class,
]); ]);

View File

@@ -24,7 +24,7 @@ return [
* `Spatie\Permission\Contracts\Role` contract. * `Spatie\Permission\Contracts\Role` contract.
*/ */
'role' => Spatie\Permission\Models\Role::class, 'role' => \App\Models\Role::class,
], ],
@@ -93,7 +93,7 @@ return [
* foreign key is other than `team_id`. * foreign key is other than `team_id`.
*/ */
'team_foreign_key' => 'team_id', 'team_foreign_key' => 'structure_id',
], ],
/* /*
@@ -131,7 +131,7 @@ return [
* (view the latest version of this package's migration file) * (view the latest version of this package's migration file)
*/ */
'teams' => false, 'teams' => true,
/* /*
* The class to use to resolve the permissions team id * The class to use to resolve the permissions team id

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('agents', function (Blueprint $table) {
$table->foreignId('structure_id')->nullable()->constrained()->onDelete('cascade');
});
// Rattacher les agents existants au CABM
\Illuminate\Support\Facades\DB::table('agents')->whereNull('structure_id')->update(['structure_id' => 1]);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('agents', function (Blueprint $table) {
$table->dropForeign(['structure_id']);
$table->dropColumn('structure_id');
});
}
};

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('structures', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->string('domain')->nullable()->unique();
$table->boolean('is_active')->default(true);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('structures');
}
};

View File

@@ -0,0 +1,46 @@
<?php
namespace Database\Seeders;
use App\Models\Structure;
use App\Models\User;
use App\Models\Role;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\DB;
class SaaSTenantSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
// 1. Créer la structure principale (votre agence CABM par défaut)
$cabm = Structure::firstOrCreate([
'slug' => 'cabm'
], [
'name' => 'CABM',
'is_active' => true,
]);
// 2. Mettre à jour tous les agents orphelins vers cette structure
DB::table('agents')->whereNull('structure_id')->update(['structure_id' => $cabm->id]);
// 3. Mettre à jour tous les services, templates, users, etc. vers cette structure
$tables = ['services', 'integration_templates', 'integration_requests', 'users', 'service_tasks'];
foreach ($tables as $table) {
if (Schema::hasColumn($table, 'structure_id')) {
DB::table($table)->whereNull('structure_id')->update(['structure_id' => $cabm->id]);
}
}
// 4. S'assurer que le rôle SuperAdmin existe globalement
$superAdminRole = Role::withoutGlobalScope('structure')->updateOrCreate(
['name' => 'SuperAdmin'],
['guard_name' => 'web', 'structure_id' => null]
);
$this->command->info('Migration vers le mode SaaS terminée. Structure par défaut : CABM.');
}
}

View File

@@ -16,7 +16,7 @@ const props = defineProps({
const user = computed(() => usePage().props.auth.user); const user = computed(() => usePage().props.auth.user);
const canManage = computed(() => { const canManage = computed(() => {
return user.value.roles.some(r => r.name === props.task.service.name || r.name === 'Admin'); return user.value.roles.some(r => r.name === props.task.service.name || r.name === 'Admin' || r.name === 'SuperAdmin');
}); });
const progress = computed(() => { const progress = computed(() => {

View File

@@ -13,6 +13,21 @@ const showingNavigationDropdown = ref(false);
<template> <template>
<div> <div>
<div class="min-h-screen bg-gray-100 dark:bg-gray-900"> <div class="min-h-screen bg-gray-100 dark:bg-gray-900">
<!-- Impersonation Banner -->
<div v-if="$page.props.tenant.is_impersonating" class="bg-amber-500 text-white py-2 px-4 shadow-md">
<div class="max-w-7xl mx-auto flex justify-between items-center text-sm font-bold">
<div class="flex items-center space-x-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
<span>MODE SIMULATION ACTIF : Vous modifiez actuellement le locataire "{{ $page.props.tenant.current ? $page.props.tenant.current.name : 'VUE GLOBALE' }}"</span>
</div>
<Link :href="route('superadmin.reset')" method="post" as="button" class="bg-white text-amber-600 px-3 py-1 rounded-md hover:bg-amber-50 transition-colors">
Arrêter la simulation
</Link>
</div>
</div>
<nav <nav
class="border-b border-gray-100 bg-white dark:border-gray-700 dark:bg-gray-800" class="border-b border-gray-100 bg-white dark:border-gray-700 dark:bg-gray-800"
> >
@@ -20,13 +35,16 @@ const showingNavigationDropdown = ref(false);
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"> <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex h-16 justify-between"> <div class="flex h-16 justify-between">
<div class="flex"> <div class="flex">
<!-- Logo --> <!-- Logo and Structure Name -->
<div class="flex shrink-0 items-center"> <div class="flex shrink-0 items-center space-x-3">
<Link :href="route('dashboard')"> <Link :href="route('dashboard')">
<ApplicationLogo <ApplicationLogo
class="block h-9 w-auto" class="block h-9 w-auto"
/> />
</Link> </Link>
<span class="text-lg font-bold text-gray-800 dark:text-gray-200">
{{ $page.props.tenant.current ? $page.props.tenant.current.name : ($page.props.auth.user.structure ? $page.props.auth.user.structure.name : '') }}
</span>
</div> </div>
<!-- Navigation Links --> <!-- Navigation Links -->
@@ -40,40 +58,48 @@ const showingNavigationDropdown = ref(false);
Tableau de Bord Tableau de Bord
</NavLink> </NavLink>
<NavLink <NavLink
v-if="$page.props.auth.user.roles.some(r => r.name === 'Admin')" v-if="$page.props.auth.user.roles.some(r => r.name === 'Admin' || r.name === 'SuperAdmin')"
:href="route('users.index')" :href="route('users.index')"
:active="route().current('users.*')" :active="route().current('users.*')"
> >
Utilisateurs Utilisateurs
</NavLink> </NavLink>
<NavLink <NavLink
v-if="$page.props.auth.user.roles.some(r => r.name === 'Admin')" v-if="$page.props.auth.user.roles.some(r => r.name === 'Admin' || r.name === 'SuperAdmin')"
:href="route('roles.index')" :href="route('roles.index')"
:active="route().current('roles.*')" :active="route().current('roles.*')"
> >
Rôles Rôles
</NavLink> </NavLink>
<NavLink <NavLink
v-if="$page.props.auth.user.roles.some(r => r.name === 'Admin')" v-if="$page.props.auth.user.roles.some(r => r.name === 'Admin' || r.name === 'SuperAdmin')"
:href="route('permissions.index')" :href="route('permissions.index')"
:active="route().current('permissions.*')" :active="route().current('permissions.*')"
> >
Permissions Permissions
</NavLink> </NavLink>
<NavLink <NavLink
v-if="$page.props.auth.user.roles.some(r => r.name === 'Admin')" v-if="$page.props.auth.user.roles.some(r => r.name === 'Admin' || r.name === 'SuperAdmin')"
:href="route('services.index')" :href="route('services.index')"
:active="route().current('services.*')" :active="route().current('services.*')"
> >
Services Services
</NavLink> </NavLink>
<NavLink <NavLink
v-if="$page.props.auth.user.roles.some(r => r.name === 'Admin')" v-if="$page.props.auth.user.roles.some(r => r.name === 'Admin' || r.name === 'SuperAdmin')"
:href="route('templates.index')" :href="route('templates.index')"
:active="route().current('templates.*')" :active="route().current('templates.*')"
> >
Modèles Modèles
</NavLink> </NavLink>
<NavLink
v-if="$page.props.auth.user.roles.some(r => r.name === 'SuperAdmin')"
:href="route('superadmin.index')"
:active="route().current('superadmin.*')"
class="text-indigo-600 dark:text-indigo-400 font-bold"
>
🛠 SaaS Admin
</NavLink>
</div> </div>
</div> </div>
@@ -182,40 +208,48 @@ const showingNavigationDropdown = ref(false);
Tableau de Bord Tableau de Bord
</ResponsiveNavLink> </ResponsiveNavLink>
<ResponsiveNavLink <ResponsiveNavLink
v-if="$page.props.auth.user.roles.some(r => r.name === 'Admin')" v-if="$page.props.auth.user.roles.some(r => r.name === 'Admin' || r.name === 'SuperAdmin')"
:href="route('users.index')" :href="route('users.index')"
:active="route().current('users.*')" :active="route().current('users.*')"
> >
Utilisateurs Utilisateurs
</ResponsiveNavLink> </ResponsiveNavLink>
<ResponsiveNavLink <ResponsiveNavLink
v-if="$page.props.auth.user.roles.some(r => r.name === 'Admin')" v-if="$page.props.auth.user.roles.some(r => r.name === 'Admin' || r.name === 'SuperAdmin')"
:href="route('roles.index')" :href="route('roles.index')"
:active="route().current('roles.*')" :active="route().current('roles.*')"
> >
Rôles Rôles
</ResponsiveNavLink> </ResponsiveNavLink>
<ResponsiveNavLink <ResponsiveNavLink
v-if="$page.props.auth.user.roles.some(r => r.name === 'Admin')" v-if="$page.props.auth.user.roles.some(r => r.name === 'Admin' || r.name === 'SuperAdmin')"
:href="route('permissions.index')" :href="route('permissions.index')"
:active="route().current('permissions.*')" :active="route().current('permissions.*')"
> >
Permissions Permissions
</ResponsiveNavLink> </ResponsiveNavLink>
<ResponsiveNavLink <ResponsiveNavLink
v-if="$page.props.auth.user.roles.some(r => r.name === 'Admin')" v-if="$page.props.auth.user.roles.some(r => r.name === 'Admin' || r.name === 'SuperAdmin')"
:href="route('services.index')" :href="route('services.index')"
:active="route().current('services.*')" :active="route().current('services.*')"
> >
Services Services
</ResponsiveNavLink> </ResponsiveNavLink>
<ResponsiveNavLink <ResponsiveNavLink
v-if="$page.props.auth.user.roles.some(r => r.name === 'Admin')" v-if="$page.props.auth.user.roles.some(r => r.name === 'Admin' || r.name === 'SuperAdmin')"
:href="route('templates.index')" :href="route('templates.index')"
:active="route().current('templates.*')" :active="route().current('templates.*')"
> >
Modèles Modèles
</ResponsiveNavLink> </ResponsiveNavLink>
<ResponsiveNavLink
v-if="$page.props.auth.user.roles.some(r => r.name === 'SuperAdmin')"
:href="route('superadmin.index')"
:active="route().current('superadmin.*')"
class="text-indigo-600 dark:text-indigo-400 font-bold"
>
🛠 SaaS Admin
</ResponsiveNavLink>
</div> </div>
<!-- Responsive Settings Options --> <!-- Responsive Settings Options -->
@@ -261,6 +295,18 @@ const showingNavigationDropdown = ref(false);
<!-- Page Content --> <!-- Page Content -->
<main> <main>
<!-- Flash Messages -->
<div v-if="$page.props.flash.success" class="max-w-7xl mx-auto mt-4 px-4 sm:px-6 lg:px-8">
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded relative" role="alert">
<span class="block sm:inline">{{ $page.props.flash.success }}</span>
</div>
</div>
<div v-if="$page.props.flash.error" class="max-w-7xl mx-auto mt-4 px-4 sm:px-6 lg:px-8">
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
<span class="block sm:inline">{{ $page.props.flash.error }}</span>
</div>
</div>
<slot /> <slot />
</main> </main>
</div> </div>

View File

@@ -7,6 +7,7 @@ import TextInput from '@/Components/TextInput.vue';
import { Head, Link, useForm } from '@inertiajs/vue3'; import { Head, Link, useForm } from '@inertiajs/vue3';
const form = useForm({ const form = useForm({
structure_name: '',
name: '', name: '',
email: '', email: '',
password: '', password: '',
@@ -26,7 +27,23 @@ const submit = () => {
<form @submit.prevent="submit"> <form @submit.prevent="submit">
<div> <div>
<InputLabel for="name" value="Nom" /> <InputLabel for="structure_name" value="Nom de votre Organisation (SaaS)" />
<TextInput
id="structure_name"
type="text"
class="mt-1 block w-full"
v-model="form.structure_name"
required
autofocus
autocomplete="organization"
/>
<InputError class="mt-2" :message="form.errors.structure_name" />
</div>
<div class="mt-4">
<InputLabel for="name" value="Votre Nom complet" />
<TextInput <TextInput
id="name" id="name"
@@ -34,7 +51,6 @@ const submit = () => {
class="mt-1 block w-full" class="mt-1 block w-full"
v-model="form.name" v-model="form.name"
required required
autofocus
autocomplete="name" autocomplete="name"
/> />

View File

@@ -0,0 +1,86 @@
<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
import InputError from '@/Components/InputError.vue';
import InputLabel from '@/Components/InputLabel.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import TextInput from '@/Components/TextInput.vue';
import { Head, useForm } from '@inertiajs/vue3';
const form = useForm({
name: '',
slug: '',
domain: '',
is_active: true,
});
const submit = () => {
form.post(route('superadmin.store'));
};
</script>
<template>
<Head title="Créer une Structure" />
<AuthenticatedLayout>
<template #header>
<h2 class="text-xl font-semibold leading-tight text-gray-800 dark:text-gray-200">
Créer une nouvelle Structure (Tenant)
</h2>
</template>
<div class="py-12">
<div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6">
<form @submit.prevent="submit" class="max-w-xl space-y-6">
<div>
<InputLabel for="name" value="Nom de l'organisation" />
<TextInput
id="name"
type="text"
class="mt-1 block w-full"
v-model="form.name"
required
autofocus
/>
<InputError class="mt-2" :message="form.errors.name" />
</div>
<div>
<InputLabel for="slug" value="Slug (Identifiant URL)" />
<TextInput
id="slug"
type="text"
class="mt-1 block w-full"
v-model="form.slug"
required
/>
<InputError class="mt-2" :message="form.errors.slug" />
</div>
<div>
<InputLabel for="domain" value="Domaine personnalisé (Optionnel)" />
<TextInput
id="domain"
type="text"
class="mt-1 block w-full"
v-model="form.domain"
/>
<InputError class="mt-2" :message="form.errors.domain" />
</div>
<div class="flex items-center space-x-2">
<input type="checkbox" id="is_active" v-model="form.is_active" class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500">
<InputLabel for="is_active" value="Structure active" />
</div>
<div class="flex items-center gap-4">
<PrimaryButton :disabled="form.processing">Créer</PrimaryButton>
</div>
</form>
</div>
</div>
</div>
</div>
</AuthenticatedLayout>
</template>

View File

@@ -0,0 +1,89 @@
<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
import InputError from '@/Components/InputError.vue';
import InputLabel from '@/Components/InputLabel.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import TextInput from '@/Components/TextInput.vue';
import { Head, useForm } from '@inertiajs/vue3';
const props = defineProps({
structure: Object
});
const form = useForm({
name: props.structure.name,
slug: props.structure.slug,
domain: props.structure.domain,
is_active: props.structure.is_active === 1 || props.structure.is_active === true,
});
const submit = () => {
form.put(route('superadmin.update', props.structure.id));
};
</script>
<template>
<Head title="Modifier la Structure" />
<AuthenticatedLayout>
<template #header>
<h2 class="text-xl font-semibold leading-tight text-gray-800 dark:text-gray-200">
Modifier : {{ structure.name }}
</h2>
</template>
<div class="py-12">
<div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6">
<form @submit.prevent="submit" class="max-w-xl space-y-6">
<div>
<InputLabel for="name" value="Nom de l'organisation" />
<TextInput
id="name"
type="text"
class="mt-1 block w-full"
v-model="form.name"
required
/>
<InputError class="mt-2" :message="form.errors.name" />
</div>
<div>
<InputLabel for="slug" value="Slug (Identifiant URL)" />
<TextInput
id="slug"
type="text"
class="mt-1 block w-full"
v-model="form.slug"
required
/>
<InputError class="mt-2" :message="form.errors.slug" />
</div>
<div>
<InputLabel for="domain" value="Domaine personnalisé (Optionnel)" />
<TextInput
id="domain"
type="text"
class="mt-1 block w-full"
v-model="form.domain"
/>
<InputError class="mt-2" :message="form.errors.domain" />
</div>
<div class="flex items-center space-x-2">
<input type="checkbox" id="is_active" v-model="form.is_active" class="rounded border-gray-300 text-indigo-600 shadow-sm focus:ring-indigo-500">
<InputLabel for="is_active" value="Structure active" />
</div>
<div class="flex items-center gap-4">
<PrimaryButton :disabled="form.processing">Mettre à jour</PrimaryButton>
</div>
</form>
</div>
</div>
</div>
</div>
</AuthenticatedLayout>
</template>

View File

@@ -0,0 +1,103 @@
<script setup>
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
import { Head, Link, router } from '@inertiajs/vue3';
import { ref } from 'vue';
const props = defineProps({
structures: Array,
current_structure_id: Number
});
const switchTo = (id) => {
router.post(route('superadmin.switch', id));
};
const resetSession = () => {
router.post(route('superadmin.reset'));
};
const deleteStructure = (id) => {
if (confirm('Êtes-vous sûr de vouloir supprimer cette structure (Tenant) et toutes ses données associées ?')) {
router.delete(route('superadmin.destroy', id));
}
};
</script>
<template>
<Head title="Super Administration SaaS" />
<AuthenticatedLayout>
<template #header>
<div class="flex justify-between items-center">
<h2 class="text-xl font-semibold leading-tight text-gray-800 dark:text-gray-200">
Super Administration SaaS
</h2>
<div class="flex space-x-4">
<Link :href="route('superadmin.create')" class="px-4 py-2 bg-indigo-600 text-white rounded-md text-sm hover:bg-indigo-700">
+ Créer une Structure
</Link>
<button v-if="current_structure_id" @click="resetSession" class="px-4 py-2 bg-red-600 text-white rounded-md text-sm hover:bg-red-700">
Arrêter la simulation (Vue Globale)
</button>
</div>
</div>
</template>
<div class="py-12">
<div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6 text-gray-900 dark:text-gray-100">
<h3 class="text-lg font-medium mb-4">Gestion des Structures (Tenants)</h3>
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse">
<thead>
<tr class="border-b dark:border-gray-700 bg-gray-50 dark:bg-gray-700/50">
<th class="px-6 py-3 text-xs font-bold uppercase text-gray-500">Nom</th>
<th class="px-6 py-3 text-xs font-bold uppercase text-gray-500">Slug (URL)</th>
<th class="px-6 py-3 text-xs font-bold uppercase text-gray-500">Utilisateurs</th>
<th class="px-6 py-3 text-xs font-bold uppercase text-gray-500">Statut</th>
<th class="px-6 py-3 text-xs font-bold uppercase text-gray-500">Action</th>
</tr>
</thead>
<tbody>
<tr v-for="structure in structures" :key="structure.id" class="border-b dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/30">
<td class="px-6 py-4 font-medium">{{ structure.name }}</td>
<td class="px-6 py-4 text-sm text-gray-500">{{ structure.slug }}</td>
<td class="px-6 py-4">{{ structure.users_count }}</td>
<td class="px-6 py-4">
<span v-if="structure.is_active" class="px-2 py-1 text-xs font-semibold text-green-800 bg-green-100 rounded-full">Actif</span>
<span v-else class="px-2 py-1 text-xs font-semibold text-red-800 bg-red-100 rounded-full">Inactif</span>
</td>
<td class="px-6 py-4">
<div class="flex items-center space-x-3">
<button
v-if="current_structure_id !== structure.id"
@click="switchTo(structure.id)"
title="Simuler (Impersonate)"
class="text-blue-600 hover:text-blue-900 text-sm font-medium">
Gérer ce SaaS
</button>
<span v-else class="text-green-600 text-sm font-bold w-[100px]"> En cours</span>
<span class="text-gray-300">|</span>
<Link :href="route('superadmin.edit', structure.id)" class="text-indigo-600 hover:text-indigo-900">
Modifier
</Link>
<button @click="deleteStructure(structure.id)" class="text-red-600 hover:text-red-900">
Supprimer
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</AuthenticatedLayout>
</template>

View File

@@ -43,6 +43,16 @@ Route::middleware('auth')->group(function () {
Route::resource('roles', \App\Http\Controllers\RoleController::class); Route::resource('roles', \App\Http\Controllers\RoleController::class);
Route::resource('permissions', \App\Http\Controllers\PermissionController::class); Route::resource('permissions', \App\Http\Controllers\PermissionController::class);
Route::resource('services', \App\Http\Controllers\ServiceController::class); Route::resource('services', \App\Http\Controllers\ServiceController::class);
// Routes Super Admin
Route::get('superadmin', [\App\Http\Controllers\SuperAdminController::class, 'index'])->name('superadmin.index');
Route::get('superadmin/create', [\App\Http\Controllers\SuperAdminController::class, 'create'])->name('superadmin.create');
Route::post('superadmin/store', [\App\Http\Controllers\SuperAdminController::class, 'store'])->name('superadmin.store');
Route::get('superadmin/{structure}/edit', [\App\Http\Controllers\SuperAdminController::class, 'edit'])->name('superadmin.edit');
Route::put('superadmin/{structure}', [\App\Http\Controllers\SuperAdminController::class, 'update'])->name('superadmin.update');
Route::delete('superadmin/{structure}', [\App\Http\Controllers\SuperAdminController::class, 'destroy'])->name('superadmin.destroy');
Route::post('superadmin/switch/{structure}', [\App\Http\Controllers\SuperAdminController::class, 'switchStructure'])->name('superadmin.switch');
Route::post('superadmin/reset', [\App\Http\Controllers\SuperAdminController::class, 'resetStructure'])->name('superadmin.reset');
}); });
require __DIR__.'/auth.php'; require __DIR__.'/auth.php';

View File

@@ -1,23 +0,0 @@
<?php
require __DIR__.'/vendor/autoload.php';
$app = require_once __DIR__.'/bootstrap/app.php';
$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
$kernel->bootstrap();
use Illuminate\Support\Facades\Mail;
echo "Configuration Mail:\n";
echo "Mailer: " . config('mail.default') . "\n";
echo "Host: " . config('mail.mailers.smtp.host') . "\n";
echo "Port: " . config('mail.mailers.smtp.port') . "\n";
try {
Mail::raw('Ceci est un test pour vérifier la connexion à Mailpit.', function ($message) {
$message->to('test@cabm.local')
->subject('Test Mailpit - ' . now());
});
echo "✅ Email envoyé avec succès ! Vérifiez votre interface Mailpit (http://localhost:8025).";
} catch (\Exception $e) {
echo "❌ Erreur lors de l'envoi : " . $e->getMessage();
}