feat: multi-tenant SaaS implementation - admin interface, tenant isolation, and UI updates

This commit is contained in:
jeremy bayse
2026-03-28 18:38:22 +01:00
parent 7d94be7a8c
commit f53d5770df
20 changed files with 757 additions and 34 deletions

View File

@@ -15,10 +15,14 @@ class CandidateController extends Controller
{
public function index()
{
$candidates = Candidate::with(['user', 'documents', 'attempts'])->latest()->get();
$candidates = Candidate::with(['user', 'documents', 'attempts', 'tenant', 'jobPosition'])->latest()->get();
$jobPositions = \App\Models\JobPosition::orderBy('title')->get();
$tenants = \App\Models\Tenant::orderBy('name')->get();
return \Inertia\Inertia::render('Admin/Candidates/Index', [
'candidates' => $candidates
'candidates' => $candidates,
'jobPositions' => $jobPositions,
'tenants' => $tenants
]);
}
@@ -42,8 +46,10 @@ class CandidateController extends Controller
'email' => 'required|string|email|max:255|unique:users',
'phone' => 'nullable|string|max:20',
'linkedin_url' => 'nullable|url|max:255',
'cv' => 'nullable|file|mimes:pdf|max:5120',
'cover_letter' => 'nullable|file|mimes:pdf|max:5120',
'cv' => 'nullable|mimes:pdf|max:5120',
'cover_letter' => 'nullable|mimes:pdf|max:5120',
'tenant_id' => 'nullable|exists:tenants,id',
'job_position_id' => 'nullable|exists:job_positions,id',
]);
$password = Str::random(10);
@@ -51,15 +57,17 @@ class CandidateController extends Controller
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($password),
'password' => Hash::make(Str::random(12)),
'role' => 'candidate',
'tenant_id' => auth()->user()->isSuperAdmin() ? $request->tenant_id : auth()->user()->tenant_id,
]);
$candidate = Candidate::create([
'user_id' => $user->id,
$candidate = $user->candidate()->create([
'phone' => $request->phone,
'linkedin_url' => $request->linkedin_url,
'status' => 'en_attente',
'tenant_id' => auth()->user()->isSuperAdmin() ? $request->tenant_id : auth()->user()->tenant_id,
'job_position_id' => $request->job_position_id,
]);
$this->storeDocument($candidate, $request->file('cv'), 'cv');

View File

@@ -13,7 +13,8 @@ class JobPositionController extends Controller
$this->authorizeAdmin();
return Inertia::render('Admin/JobPositions/Index', [
'jobPositions' => JobPosition::all()
'jobPositions' => JobPosition::with('tenant')->get(),
'tenants' => \App\Models\Tenant::orderBy('name')->get()
]);
}
@@ -26,6 +27,7 @@ class JobPositionController extends Controller
'description' => 'required|string',
'requirements' => 'nullable|array',
'ai_prompt' => 'nullable|string',
'tenant_id' => 'nullable|exists:tenants,id',
]);
JobPosition::create([
@@ -33,6 +35,7 @@ class JobPositionController extends Controller
'description' => $request->description,
'requirements' => $request->requirements,
'ai_prompt' => $request->ai_prompt,
'tenant_id' => auth()->user()->isSuperAdmin() ? $request->tenant_id : auth()->user()->tenant_id,
]);
return back()->with('success', 'Fiche de poste créée avec succès.');
@@ -47,6 +50,7 @@ class JobPositionController extends Controller
'description' => 'required|string',
'requirements' => 'nullable|array',
'ai_prompt' => 'nullable|string',
'tenant_id' => 'nullable|exists:tenants,id',
]);
$jobPosition->update([
@@ -54,6 +58,7 @@ class JobPositionController extends Controller
'description' => $request->description,
'requirements' => $request->requirements,
'ai_prompt' => $request->ai_prompt,
'tenant_id' => auth()->user()->isSuperAdmin() ? $request->tenant_id : auth()->user()->tenant_id,
]);
return back()->with('success', 'Fiche de poste mise à jour.');

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers;
use App\Models\Tenant;
use Illuminate\Http\Request;
use Inertia\Inertia;
class TenantController extends Controller
{
public function index()
{
if (!auth()->user()->isSuperAdmin()) {
abort(403, 'Unauthorized action.');
}
$tenants = Tenant::orderBy('name')->get();
return Inertia::render('Admin/Tenants/Index', [
'tenants' => $tenants
]);
}
public function store(Request $request)
{
if (!auth()->user()->isSuperAdmin()) {
abort(403, 'Unauthorized action.');
}
$request->validate([
'name' => 'required|string|max:255|unique:tenants,name',
]);
Tenant::create($request->only('name'));
return back()->with('success', 'Structure créée avec succès.');
}
public function update(Request $request, Tenant $tenant)
{
if (!auth()->user()->isSuperAdmin()) {
abort(403, 'Unauthorized action.');
}
$request->validate([
'name' => 'required|string|max:255|unique:tenants,name,' . $tenant->id,
]);
$tenant->update($request->only('name'));
return back()->with('success', 'Structure mise à jour.');
}
public function destroy(Tenant $tenant)
{
if (!auth()->user()->isSuperAdmin()) {
abort(403, 'Unauthorized action.');
}
$tenant->delete();
return back()->with('success', 'Structure supprimée.');
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use App\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Inertia\Inertia;
class UserController extends Controller
{
public function index()
{
if (!auth()->user()->isSuperAdmin()) {
abort(403, 'Unauthorized action.');
}
$users = User::whereIn('role', ['admin', 'super_admin'])
->with('tenant')
->orderBy('name')
->get();
$tenants = Tenant::orderBy('name')->get();
return Inertia::render('Admin/Users/Index', [
'users' => $users,
'tenants' => $tenants
]);
}
public function store(Request $request)
{
if (!auth()->user()->isSuperAdmin()) {
abort(403, 'Unauthorized action.');
}
$request->validate([
'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255|unique:users',
'role' => ['required', Rule::in(['admin', 'super_admin'])],
'tenant_id' => 'nullable|exists:tenants,id',
]);
$password = Str::random(10);
User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($password),
'role' => $request->role,
'tenant_id' => $request->role === 'super_admin' ? null : $request->tenant_id,
]);
return back()->with('success', 'Administrateur créé avec succès. Mot de passe généré : ' . $password);
}
public function update(Request $request, User $user)
{
if (!auth()->user()->isSuperAdmin()) {
abort(403, 'Unauthorized action.');
}
$request->validate([
'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255|unique:users,email,' . $user->id,
'role' => ['required', Rule::in(['admin', 'super_admin'])],
'tenant_id' => 'nullable|exists:tenants,id',
]);
$user->update([
'name' => $request->name,
'email' => $request->email,
'role' => $request->role,
'tenant_id' => $request->role === 'super_admin' ? null : $request->tenant_id,
]);
return back()->with('success', 'Administrateur mis à jour.');
}
public function destroy(User $user)
{
if (!auth()->user()->isSuperAdmin()) {
abort(403, 'Unauthorized action.');
}
if ($user->id === auth()->id()) {
return back()->with('error', 'Vous ne pouvez pas supprimer votre propre compte.');
}
$user->delete();
return back()->with('success', 'Administrateur supprimé.');
}
}

View File

@@ -32,7 +32,7 @@ class HandleInertiaRequests extends Middleware
return [
...parent::share($request),
'auth' => [
'user' => $request->user(),
'user' => $request->user() ? clone $request->user()->load('tenant') : null,
],
'flash' => [
'success' => $request->session()->get('success'),

View File

@@ -9,10 +9,12 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
#[Fillable(['user_id', 'job_position_id', 'phone', 'linkedin_url', 'status', 'notes', 'cv_score', 'motivation_score', 'interview_score', 'ai_analysis'])]
use App\Traits\BelongsToTenant;
#[Fillable(['user_id', 'job_position_id', 'phone', 'linkedin_url', 'status', 'notes', 'cv_score', 'motivation_score', 'interview_score', 'ai_analysis', 'tenant_id'])]
class Candidate extends Model
{
use HasFactory;
use HasFactory, BelongsToTenant;
protected $casts = [
'ai_analysis' => 'array',

View File

@@ -7,10 +7,12 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
#[Fillable(['title', 'description', 'requirements', 'ai_prompt'])]
use App\Traits\BelongsToTenant;
#[Fillable(['title', 'description', 'requirements', 'ai_prompt', 'tenant_id'])]
class JobPosition extends Model
{
use HasFactory;
use HasFactory, BelongsToTenant;
protected $casts = [
'requirements' => 'array',

View File

@@ -8,10 +8,12 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
#[Fillable(['title', 'description', 'duration_minutes'])]
use App\Traits\BelongsToTenant;
#[Fillable(['title', 'description', 'duration_minutes', 'tenant_id'])]
class Quiz extends Model
{
use HasFactory;
use HasFactory, BelongsToTenant;
public function questions(): HasMany
{

31
app/Models/Tenant.php Normal file
View File

@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Attributes\Fillable;
#[Fillable(['name'])]
class Tenant extends Model
{
public function users()
{
return $this->hasMany(User::class);
}
public function candidates()
{
return $this->hasMany(Candidate::class);
}
public function quizzes()
{
return $this->hasMany(Quiz::class);
}
public function jobPositions()
{
return $this->hasMany(JobPosition::class);
}
}

View File

@@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
#[Fillable(['name', 'email', 'password', 'role'])]
#[Fillable(['name', 'email', 'password', 'role', 'tenant_id'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
@@ -19,7 +19,12 @@ class User extends Authenticatable
public function isAdmin(): bool
{
return $this->role === 'admin';
return in_array($this->role, ['admin', 'super_admin']);
}
public function isSuperAdmin(): bool
{
return $this->role === 'super_admin';
}
public function isCandidate(): bool
@@ -32,6 +37,11 @@ class User extends Authenticatable
return $this->hasOne(Candidate::class);
}
public function tenant()
{
return $this->belongsTo(Tenant::class);
}
/**
* Get the attributes that should be cast.
*

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Traits;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
trait BelongsToTenant
{
protected static function bootBelongsToTenant()
{
static::addGlobalScope('tenant', function (Builder $builder) {
if (Auth::check()) {
$user = Auth::user();
if ($user->role === 'super_admin') {
// Super admins see everything
return;
}
if ($user->tenant_id) {
$builder->where('tenant_id', $user->tenant_id);
}
}
});
static::creating(function ($model) {
if (Auth::check() && Auth::user()->tenant_id && Auth::user()->role !== 'super_admin') {
$model->tenant_id = Auth::user()->tenant_id;
}
});
}
public function tenant()
{
return $this->belongsTo(\App\Models\Tenant::class);
}
}