diff --git a/app/Console/Commands/MakeSuperAdmin.php b/app/Console/Commands/MakeSuperAdmin.php new file mode 100644 index 0000000..539cadf --- /dev/null +++ b/app/Console/Commands/MakeSuperAdmin.php @@ -0,0 +1,40 @@ +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."); + } +} diff --git a/app/Http/Controllers/Auth/RegisteredUserController.php b/app/Http/Controllers/Auth/RegisteredUserController.php index 53a546b..c15cc2d 100644 --- a/app/Http/Controllers/Auth/RegisteredUserController.php +++ b/app/Http/Controllers/Auth/RegisteredUserController.php @@ -31,17 +31,41 @@ class RegisteredUserController extends Controller public function store(Request $request): RedirectResponse { $request->validate([ + 'structure_name' => 'required|string|max:255|unique:structures,name', 'name' => 'required|string|max:255', 'email' => 'required|string|lowercase|email|max:255|unique:'.User::class, '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, 'email' => $request->email, 'password' => Hash::make($request->password), + 'structure_id' => $structure->id, ]); + // Affectation du rôle + $user->assignRole($adminRole); + event(new Registered($user)); Auth::login($user); diff --git a/app/Http/Controllers/RoleController.php b/app/Http/Controllers/RoleController.php index 9c63482..6baefdb 100644 --- a/app/Http/Controllers/RoleController.php +++ b/app/Http/Controllers/RoleController.php @@ -4,7 +4,7 @@ namespace App\Http\Controllers; use Illuminate\Http\Request; use Inertia\Inertia; -use Spatie\Permission\Models\Role; +use App\Models\Role; use Spatie\Permission\Models\Permission; class RoleController extends Controller diff --git a/app/Http/Controllers/ServiceController.php b/app/Http/Controllers/ServiceController.php index ac5a4df..ff1706d 100644 --- a/app/Http/Controllers/ServiceController.php +++ b/app/Http/Controllers/ServiceController.php @@ -5,7 +5,7 @@ namespace App\Http\Controllers; use App\Models\Service; use Illuminate\Http\Request; use Inertia\Inertia; -use Spatie\Permission\Models\Role; +use App\Models\Role; use Spatie\Permission\Models\Permission; class ServiceController extends Controller diff --git a/app/Http/Controllers/SuperAdminController.php b/app/Http/Controllers/SuperAdminController.php new file mode 100644 index 0000000..692c0e3 --- /dev/null +++ b/app/Http/Controllers/SuperAdminController.php @@ -0,0 +1,112 @@ +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é."); + } +} diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 80436cb..e883db9 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -30,7 +30,7 @@ class UserController extends Controller implements \Illuminate\Routing\Controlle public function create() { 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', [ 'user' => $user->load('roles'), - 'roles' => \Spatie\Permission\Models\Role::all(), + 'roles' => \App\Models\Role::all(), ]); } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 6635f32..2de234e 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -32,7 +32,22 @@ class HandleInertiaRequests extends Middleware return [ ...parent::share($request), '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'), ], ]; } diff --git a/app/Http/Middleware/TenantContext.php b/app/Http/Middleware/TenantContext.php new file mode 100644 index 0000000..a8a0a88 --- /dev/null +++ b/app/Http/Middleware/TenantContext.php @@ -0,0 +1,50 @@ +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); + } +} diff --git a/app/Models/Agent.php b/app/Models/Agent.php index fa32d9e..d7de8f6 100644 --- a/app/Models/Agent.php +++ b/app/Models/Agent.php @@ -3,9 +3,11 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use App\Traits\BelongsToStructure; class Agent extends Model { + use BelongsToStructure; protected $fillable = [ 'first_name', 'last_name', diff --git a/app/Models/Attachment.php b/app/Models/Attachment.php index 4f036b9..e421e75 100644 --- a/app/Models/Attachment.php +++ b/app/Models/Attachment.php @@ -3,9 +3,11 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use App\Traits\BelongsToStructure; class Attachment extends Model { + use BelongsToStructure; protected $fillable = [ 'service_task_id', 'filename', diff --git a/app/Models/Comment.php b/app/Models/Comment.php index 89e5d3e..7659edb 100644 --- a/app/Models/Comment.php +++ b/app/Models/Comment.php @@ -3,9 +3,11 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use App\Traits\BelongsToStructure; class Comment extends Model { + use BelongsToStructure; protected $fillable = ['user_id', 'content', 'commentable_id', 'commentable_type']; public function user() diff --git a/app/Models/IntegrationRequest.php b/app/Models/IntegrationRequest.php index be2abed..6b77252 100644 --- a/app/Models/IntegrationRequest.php +++ b/app/Models/IntegrationRequest.php @@ -5,10 +5,11 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use Spatie\Activitylog\Traits\LogsActivity; use Spatie\Activitylog\LogOptions; +use App\Traits\BelongsToStructure; class IntegrationRequest extends Model { - use LogsActivity; + use LogsActivity, BelongsToStructure; public function getActivitylogOptions(): LogOptions { diff --git a/app/Models/IntegrationTemplate.php b/app/Models/IntegrationTemplate.php index b37b4dc..8f394aa 100644 --- a/app/Models/IntegrationTemplate.php +++ b/app/Models/IntegrationTemplate.php @@ -3,9 +3,12 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use App\Traits\BelongsToStructure; class IntegrationTemplate extends Model { + use BelongsToStructure; + protected $fillable = [ 'name', 'description', diff --git a/app/Models/Service.php b/app/Models/Service.php index 5a20587..e6c673b 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -3,9 +3,12 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use App\Traits\BelongsToStructure; class Service extends Model { + use BelongsToStructure; + protected $fillable = [ 'name', 'code', diff --git a/app/Models/ServiceTask.php b/app/Models/ServiceTask.php index 2757b1f..e255552 100644 --- a/app/Models/ServiceTask.php +++ b/app/Models/ServiceTask.php @@ -5,10 +5,11 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; use Spatie\Activitylog\Traits\LogsActivity; use Spatie\Activitylog\LogOptions; +use App\Traits\BelongsToStructure; class ServiceTask extends Model { - use LogsActivity; + use LogsActivity, BelongsToStructure; public function getActivitylogOptions(): LogOptions { diff --git a/app/Models/TaskItem.php b/app/Models/TaskItem.php index dfad1a2..9d396d0 100644 --- a/app/Models/TaskItem.php +++ b/app/Models/TaskItem.php @@ -3,9 +3,11 @@ namespace App\Models; use Illuminate\Database\Eloquent\Model; +use App\Traits\BelongsToStructure; class TaskItem extends Model { + use BelongsToStructure; protected $fillable = [ 'service_task_id', 'label', diff --git a/app/Models/User.php b/app/Models/User.php index b1ff2fe..d2b4f79 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -8,11 +8,14 @@ use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Spatie\Permission\Traits\HasRoles; +use App\Traits\BelongsToStructure; class User extends Authenticatable { - /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasFactory, Notifiable, HasRoles; + use HasFactory, Notifiable, BelongsToStructure; + use HasRoles { + hasRole as traitHasRole; + } /** * The attributes that are mass assignable. @@ -23,6 +26,7 @@ class User extends Authenticatable 'name', 'email', 'password', + 'structure_id', ]; /** @@ -47,4 +51,29 @@ class User extends Authenticatable '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); + } } diff --git a/app/Traits/BelongsToStructure.php b/app/Traits/BelongsToStructure.php new file mode 100644 index 0000000..5012b7e --- /dev/null +++ b/app/Traits/BelongsToStructure.php @@ -0,0 +1,45 @@ +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); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 113955b..b28bc47 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -12,6 +12,7 @@ return Application::configure(basePath: dirname(__DIR__)) ) ->withMiddleware(function (Middleware $middleware) { $middleware->web(append: [ + \App\Http\Middleware\TenantContext::class, \App\Http\Middleware\HandleInertiaRequests::class, \Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class, ]); diff --git a/config/permission.php b/config/permission.php index f39f6b5..9dc028a 100644 --- a/config/permission.php +++ b/config/permission.php @@ -24,7 +24,7 @@ return [ * `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`. */ - '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) */ - 'teams' => false, + 'teams' => true, /* * The class to use to resolve the permissions team id diff --git a/database/migrations/2026_02_21_190311_add_structure_id_to_agents_table.php b/database/migrations/2026_02_21_190311_add_structure_id_to_agents_table.php new file mode 100644 index 0000000..d59cc7b --- /dev/null +++ b/database/migrations/2026_02_21_190311_add_structure_id_to_agents_table.php @@ -0,0 +1,32 @@ +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'); + }); + } +}; diff --git a/database/migrations/2026_02_21_191412_create_structures_table.php b/database/migrations/2026_02_21_191412_create_structures_table.php new file mode 100644 index 0000000..ecd0432 --- /dev/null +++ b/database/migrations/2026_02_21_191412_create_structures_table.php @@ -0,0 +1,31 @@ +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'); + } +}; diff --git a/database/seeders/SaaSTenantSeeder.php b/database/seeders/SaaSTenantSeeder.php new file mode 100644 index 0000000..452377b --- /dev/null +++ b/database/seeders/SaaSTenantSeeder.php @@ -0,0 +1,46 @@ + '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.'); + } +} diff --git a/resources/js/Components/App/ServiceTaskCard.vue b/resources/js/Components/App/ServiceTaskCard.vue index b4f3046..dfad6fe 100644 --- a/resources/js/Components/App/ServiceTaskCard.vue +++ b/resources/js/Components/App/ServiceTaskCard.vue @@ -16,7 +16,7 @@ const props = defineProps({ const user = computed(() => usePage().props.auth.user); 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(() => { diff --git a/resources/js/Layouts/AuthenticatedLayout.vue b/resources/js/Layouts/AuthenticatedLayout.vue index 441dd52..389cf83 100644 --- a/resources/js/Layouts/AuthenticatedLayout.vue +++ b/resources/js/Layouts/AuthenticatedLayout.vue @@ -13,6 +13,21 @@ const showingNavigationDropdown = ref(false);