45
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Admin\Concerns\ScopedToTenant;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\DirectoryUser;
|
||||
use App\Signature\SignatureBuilder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/** « Qui reçoit quoi » : template résolu pour chaque agent, pour vérifier les priorités. */
|
||||
class AgentController extends Controller
|
||||
{
|
||||
use ScopedToTenant;
|
||||
|
||||
public function index(Request $request, SignatureBuilder $builder): View
|
||||
{
|
||||
$q = trim((string) $request->query('q'));
|
||||
$agents = $this->search($q)->with('groups:id,display_name')->paginate(25)->withQueryString();
|
||||
$resolved = $agents->getCollection()->mapWithKeys(fn ($a) => [$a->id => $builder->templateFor($a)?->name]);
|
||||
|
||||
return view('admin.agents.index', compact('agents', 'resolved', 'q'));
|
||||
}
|
||||
|
||||
public function show(DirectoryUser $agent, SignatureBuilder $builder): View
|
||||
{
|
||||
$this->ensureTenant($agent);
|
||||
|
||||
return view('admin.agents.show', [
|
||||
'agent' => $agent->load('groups:id,display_name'),
|
||||
'template' => $builder->templateFor($agent),
|
||||
'full' => $builder->build($agent, 'new'),
|
||||
'reply' => $builder->build($agent, 'reply'),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Autocomplétion pour le choix de l'agent d'aperçu. */
|
||||
public function lookup(Request $request): JsonResponse
|
||||
{
|
||||
$q = trim((string) $request->query('q'));
|
||||
abort_if(mb_strlen($q) < 2, 422);
|
||||
|
||||
return response()->json($this->search($q)->limit(15)->get()->map(fn ($a) => [
|
||||
'id' => $a->id,
|
||||
'label' => trim($a->display_name.' — '.$a->mail),
|
||||
]));
|
||||
}
|
||||
|
||||
private function search(string $q)
|
||||
{
|
||||
return DirectoryUser::where('tenant_id', $this->tenantId())
|
||||
->where('account_enabled', true)
|
||||
->when($q !== '', fn ($query) => $query->where(fn ($w) => $w
|
||||
->where('display_name', 'like', "%{$q}%")
|
||||
->orWhere('mail', 'like', "%{$q}%")
|
||||
->orWhere('department', 'like', "%{$q}%")))
|
||||
->orderBy('display_name');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Admin\Concerns\ScopedToTenant;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Assignment;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\DirectoryGroup;
|
||||
use App\Models\Template;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/** Affectation template <-> groupe AD, avec priorité (la plus haute l'emporte). */
|
||||
class AssignmentController extends Controller
|
||||
{
|
||||
use ScopedToTenant;
|
||||
|
||||
public function index(): View
|
||||
{
|
||||
$tenant = $this->tenantId();
|
||||
|
||||
return view('admin.assignments.index', [
|
||||
'assignments' => Assignment::where('tenant_id', $tenant)
|
||||
->with(['template:id,name,html_full', 'group' => fn ($q) => $q->withCount('members')])
|
||||
->orderByDesc('priority')->orderBy('id')->get(),
|
||||
'templates' => Template::where('tenant_id', $tenant)->orderBy('name')->get(['id', 'name', 'html_full', 'is_default']),
|
||||
// Tous les groupes synchronisés, sauf les groupes de rôles du back-office.
|
||||
'groups' => DirectoryGroup::where('tenant_id', $tenant)
|
||||
->whereNotIn('display_name', array_values(config('signature.roles')))
|
||||
->withCount('members')->orderBy('display_name')->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$tenant = $this->tenantId();
|
||||
$data = $request->validate([
|
||||
'template_id' => ['required', Rule::exists('templates', 'id')->where('tenant_id', $tenant)],
|
||||
'directory_group_id' => [
|
||||
'required',
|
||||
Rule::exists('directory_groups', 'id')->where('tenant_id', $tenant),
|
||||
Rule::unique('assignments')->where(fn ($q) => $q->where('template_id', $request->input('template_id'))),
|
||||
],
|
||||
'priority' => ['required', 'integer', 'between:0,1000'],
|
||||
], ['directory_group_id.unique' => 'Ce groupe est déjà affecté à ce template.']);
|
||||
|
||||
$assignment = Assignment::create($data + ['tenant_id' => $tenant]);
|
||||
AuditLog::record('admin', $this->actor(), 'assignment.create', $assignment, $data);
|
||||
|
||||
return back()->with('status', 'Affectation ajoutée.');
|
||||
}
|
||||
|
||||
public function update(Request $request, Assignment $assignment): RedirectResponse
|
||||
{
|
||||
$this->ensureTenant($assignment);
|
||||
$data = $request->validate(['priority' => ['required', 'integer', 'between:0,1000']]);
|
||||
$old = $assignment->priority;
|
||||
$assignment->update($data);
|
||||
AuditLog::record('admin', $this->actor(), 'assignment.update', $assignment, ['priority' => [$old, $assignment->priority]]);
|
||||
|
||||
return back()->with('status', 'Priorité mise à jour.');
|
||||
}
|
||||
|
||||
public function destroy(Assignment $assignment): RedirectResponse
|
||||
{
|
||||
$this->ensureTenant($assignment);
|
||||
AuditLog::record('admin', $this->actor(), 'assignment.delete', $assignment, [
|
||||
'template_id' => $assignment->template_id, 'directory_group_id' => $assignment->directory_group_id,
|
||||
]);
|
||||
$assignment->delete();
|
||||
|
||||
return back()->with('status', 'Affectation supprimée.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\Concerns;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/** Cloisonnement par tenant : un admin ne voit et ne modifie que les données de sa collectivité. */
|
||||
trait ScopedToTenant
|
||||
{
|
||||
protected function tenantId(): int
|
||||
{
|
||||
return (int) auth()->user()->tenant_id;
|
||||
}
|
||||
|
||||
protected function ensureTenant(Model $model): void
|
||||
{
|
||||
abort_unless((int) $model->tenant_id === $this->tenantId(), 404);
|
||||
}
|
||||
|
||||
protected function actor(): string
|
||||
{
|
||||
return (string) auth()->user()->email;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Assignment;
|
||||
use App\Models\Campaign;
|
||||
use App\Models\DirectoryGroup;
|
||||
use App\Models\DirectoryUser;
|
||||
use App\Models\Template;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/** Tableau de bord du lot 0 : état de la synchro et volumétrie. Les écrans d'édition arrivent aux lots 1 et 2. */
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): View
|
||||
{
|
||||
$tenant = $request->user()->tenant;
|
||||
|
||||
return view('admin.dashboard', [
|
||||
'tenant' => $tenant,
|
||||
'users' => DirectoryUser::where('tenant_id', $tenant->id)->count(),
|
||||
'groups' => DirectoryGroup::where('tenant_id', $tenant->id)->withCount('members')->orderBy('display_name')->get(),
|
||||
'templates' => Template::where('tenant_id', $tenant->id)->count(),
|
||||
'assignments' => Assignment::where('tenant_id', $tenant->id)->count(),
|
||||
'campaigns' => Campaign::running()->where('tenant_id', $tenant->id)->count(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Admin\Concerns\ScopedToTenant;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Graph\DirectorySync;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Throwable;
|
||||
|
||||
/** Synchro annuaire lancée à la main depuis le tableau de bord (en plus de la tâche planifiée). */
|
||||
class SyncController extends Controller
|
||||
{
|
||||
use ScopedToTenant;
|
||||
|
||||
public function __invoke(Request $request, DirectorySync $sync): RedirectResponse
|
||||
{
|
||||
$tenant = Tenant::findOrFail($this->tenantId());
|
||||
$full = $request->boolean('full');
|
||||
|
||||
// Même verrou que la commande planifiée : jamais deux synchros en parallèle.
|
||||
$lock = Cache::lock("signature-sync:{$tenant->id}", 600);
|
||||
if (! $lock->get()) {
|
||||
return back()->withErrors(['sync' => 'Une synchronisation est déjà en cours. Réessayez dans une minute.']);
|
||||
}
|
||||
|
||||
@set_time_limit(300);
|
||||
try {
|
||||
$s = $sync->run($tenant, $full);
|
||||
} catch (Throwable $e) {
|
||||
return back()->withErrors(['sync' => 'Synchronisation en échec : '.mb_substr($e->getMessage(), 0, 300)]);
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
|
||||
AuditLog::record('admin', $this->actor(), $full ? 'sync.full' : 'sync.delta', $tenant, $s, $tenant->id);
|
||||
|
||||
return back()->with('status', sprintf(
|
||||
'Synchronisation %s terminée : %d agents mis à jour, %d retirés, %d groupes, %d appartenances.',
|
||||
$full ? 'complète' : 'incrémentale', $s['users'], $s['removed'], $s['groups'], $s['memberships'],
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Admin\Concerns\ScopedToTenant;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\DirectoryUser;
|
||||
use App\Models\Template;
|
||||
use App\Models\TemplateVersion;
|
||||
use App\Signature\SignatureBuilder;
|
||||
use App\Signature\TemplateRenderer;
|
||||
use App\Signature\TemplateSanitizer;
|
||||
use App\Signature\VariableMap;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/**
|
||||
* Templates : les éditeurs modifient un brouillon, les administrateurs le publient.
|
||||
* L'API ne sert que le contenu publié (html_full / html_short).
|
||||
*/
|
||||
class TemplateController extends Controller
|
||||
{
|
||||
use ScopedToTenant;
|
||||
|
||||
public function __construct(private readonly TemplateSanitizer $sanitizer) {}
|
||||
|
||||
public function index(): View
|
||||
{
|
||||
$templates = Template::where('tenant_id', $this->tenantId())
|
||||
->withCount('assignments')
|
||||
->orderByDesc('is_default')->orderBy('name')
|
||||
->get();
|
||||
|
||||
return view('admin.templates.index', compact('templates'));
|
||||
}
|
||||
|
||||
public function create(): View
|
||||
{
|
||||
return view('admin.templates.edit', [
|
||||
'template' => new Template([
|
||||
'draft_html_full' => file_get_contents(database_path('seeders/templates/agents-full.html')),
|
||||
'draft_html_short' => file_get_contents(database_path('seeders/templates/agents-short.html')),
|
||||
]),
|
||||
'versions' => collect(),
|
||||
'variables' => VariableMap::FIELDS,
|
||||
'previewAgent' => $this->defaultPreviewAgent(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $this->validated($request);
|
||||
$template = Template::create($data + ['tenant_id' => $this->tenantId()]);
|
||||
AuditLog::record('admin', $this->actor(), 'template.create', $template, ['name' => $template->name]);
|
||||
|
||||
return redirect()->route('admin.templates.edit', $template)->with('status', 'Template créé (brouillon non publié).');
|
||||
}
|
||||
|
||||
public function edit(Template $template): View
|
||||
{
|
||||
$this->ensureTenant($template);
|
||||
|
||||
return view('admin.templates.edit', [
|
||||
'template' => $template,
|
||||
'versions' => $template->versions()->with('publisher:id,name')->orderByDesc('version')->limit(20)->get(),
|
||||
'variables' => VariableMap::FIELDS,
|
||||
'previewAgent' => $this->defaultPreviewAgent(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, Template $template): RedirectResponse
|
||||
{
|
||||
$this->ensureTenant($template);
|
||||
$data = $this->validated($request);
|
||||
$template->fill($data);
|
||||
$changed = array_keys($template->getDirty());
|
||||
$template->save();
|
||||
AuditLog::record('admin', $this->actor(), 'template.update', $template, ['fields' => $changed]);
|
||||
|
||||
return back()->with('status', 'Brouillon enregistré.');
|
||||
}
|
||||
|
||||
/** Aperçu du brouillon en cours de saisie (non enregistré) sur un agent réel. */
|
||||
public function preview(Request $request, SignatureBuilder $builder): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'html' => ['nullable', 'string', 'max:100000'],
|
||||
'variant' => ['required', 'in:full,short'],
|
||||
'agent_id' => ['nullable', 'integer'],
|
||||
]);
|
||||
|
||||
$agent = isset($data['agent_id'])
|
||||
? DirectoryUser::where('tenant_id', $this->tenantId())->find($data['agent_id'])
|
||||
: $this->defaultPreviewAgent();
|
||||
abort_unless($agent, 422, 'Aucun agent pour l\'aperçu.');
|
||||
|
||||
$html = $this->sanitizer->clean((string) ($data['html'] ?? ''));
|
||||
|
||||
return response()->json([
|
||||
'html' => $builder->preview($agent, $html, $data['variant']),
|
||||
'agent' => $agent->display_name.' — '.$agent->mail,
|
||||
'variables' => (new TemplateRenderer)->variables($html),
|
||||
]);
|
||||
}
|
||||
|
||||
public function publish(Template $template): RedirectResponse
|
||||
{
|
||||
$this->ensureTenant($template);
|
||||
abort_if(blank($template->draft_html_full) && blank($template->html_full), 422, 'Le template complet est vide.');
|
||||
|
||||
$template->publish(auth()->user());
|
||||
AuditLog::record('admin', $this->actor(), 'template.publish', $template, ['version' => $template->published_version]);
|
||||
|
||||
return back()->with('status', "Version {$template->published_version} publiée : elle est servie immédiatement par l'add-in.");
|
||||
}
|
||||
|
||||
/** Recharge une version publiée dans le brouillon (il faut republier ensuite). */
|
||||
public function restore(Template $template, TemplateVersion $version): RedirectResponse
|
||||
{
|
||||
$this->ensureTenant($template);
|
||||
abort_unless($version->template_id === $template->id, 404);
|
||||
|
||||
$template->update(['draft_html_full' => $version->html_full, 'draft_html_short' => $version->html_short]);
|
||||
AuditLog::record('admin', $this->actor(), 'template.restore', $template, ['version' => $version->version]);
|
||||
|
||||
return back()->with('status', "Version {$version->version} rechargée dans le brouillon. Publiez pour l'appliquer.");
|
||||
}
|
||||
|
||||
public function makeDefault(Template $template): RedirectResponse
|
||||
{
|
||||
$this->ensureTenant($template);
|
||||
abort_if(blank($template->html_full), 422, 'Publiez le template avant d\'en faire le template par défaut.');
|
||||
|
||||
DB::transaction(function () use ($template) {
|
||||
Template::where('tenant_id', $this->tenantId())->update(['is_default' => false]);
|
||||
$template->update(['is_default' => true]);
|
||||
});
|
||||
AuditLog::record('admin', $this->actor(), 'template.default', $template);
|
||||
|
||||
return back()->with('status', "« {$template->name} » est maintenant le template par défaut.");
|
||||
}
|
||||
|
||||
public function destroy(Template $template): RedirectResponse
|
||||
{
|
||||
$this->ensureTenant($template);
|
||||
abort_if($template->is_default, 422, 'Le template par défaut ne peut pas être supprimé.');
|
||||
|
||||
AuditLog::record('admin', $this->actor(), 'template.delete', $template, ['name' => $template->name]);
|
||||
$template->delete(); // les affectations sont supprimées en cascade
|
||||
|
||||
return redirect()->route('admin.templates.index')->with('status', 'Template supprimé.');
|
||||
}
|
||||
|
||||
private function validated(Request $request): array
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:120'],
|
||||
'description' => ['nullable', 'string', 'max:500'],
|
||||
'draft_html_full' => ['required', 'string', 'max:100000'],
|
||||
'draft_html_short' => ['nullable', 'string', 'max:100000'],
|
||||
]);
|
||||
$data['draft_html_full'] = $this->sanitizer->clean($data['draft_html_full']);
|
||||
$data['draft_html_short'] = isset($data['draft_html_short']) ? $this->sanitizer->clean($data['draft_html_short']) : null;
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/** Agent utilisé par défaut pour l'aperçu : l'administrateur lui-même. */
|
||||
private function defaultPreviewAgent(): ?DirectoryUser
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
return DirectoryUser::where('tenant_id', $user->tenant_id)->where('entra_oid', $user->entra_oid)->first()
|
||||
?? DirectoryUser::where('tenant_id', $user->tenant_id)->orderBy('display_name')->first();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Signature\SignatureBuilder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SignatureController extends Controller
|
||||
{
|
||||
/**
|
||||
* GET /api/v1/signature?context=new|reply|forward|appointment&mailbox=adresse
|
||||
* Réponse : { html, variant, template, generated_at }
|
||||
*/
|
||||
public function show(Request $request, SignatureBuilder $builder): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'context' => ['sometimes', 'in:new,reply,forward,appointment'],
|
||||
'mailbox' => ['sometimes', 'nullable', 'email:rfc', 'max:254'],
|
||||
]);
|
||||
|
||||
$result = $builder->build(
|
||||
$request->attributes->get('directory_user'),
|
||||
$data['context'] ?? 'new',
|
||||
$data['mailbox'] ?? null,
|
||||
);
|
||||
|
||||
if ($result === null) {
|
||||
return response()->json(['error' => 'no_template'], 404);
|
||||
}
|
||||
|
||||
return response()->json($result + ['generated_at' => now()->toIso8601String()])
|
||||
->header('Cache-Control', 'private, no-store');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\DirectoryUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
use Laravel\Socialite\Two\InvalidStateException;
|
||||
|
||||
/** Connexion au back-office et au portail agent par SSO Entra (aucun mot de passe local). */
|
||||
class EntraLoginController extends Controller
|
||||
{
|
||||
public function redirect(): RedirectResponse
|
||||
{
|
||||
return Socialite::driver('azure')->scopes(['openid', 'profile', 'email'])->redirect();
|
||||
}
|
||||
|
||||
public function callback(Request $request): RedirectResponse
|
||||
{
|
||||
// Entra renvoie ?error=… en cas de refus ou de mauvaise configuration : on l'affiche au lieu d'une 500.
|
||||
if ($request->filled('error')) {
|
||||
Log::warning('SSO Entra refusé', ['error' => $request->query('error'), 'description' => $request->query('error_description')]);
|
||||
abort(403, 'Connexion Entra refusée : '.Str::limit((string) $request->query('error_description'), 300));
|
||||
}
|
||||
|
||||
try {
|
||||
$entra = Socialite::driver('azure')->user();
|
||||
} catch (InvalidStateException) {
|
||||
// Session perdue entre /login et le retour Entra : cookie expiré, ou connexion lancée
|
||||
// sur un autre nom d'hôte (127.0.0.1 au lieu de localhost). On relance une fois,
|
||||
// depuis l'hôte du callback, ce qui suffit dans ces deux cas.
|
||||
if (! $request->session()->pull('sso_retry')) {
|
||||
$request->session()->put('sso_retry', true);
|
||||
|
||||
return redirect()->route('login');
|
||||
}
|
||||
abort(419, 'Session de connexion expirée. Fermez l\'onglet et ouvrez de nouveau '.config('app.url').'/login');
|
||||
}
|
||||
$request->session()->forget('sso_retry');
|
||||
$tenant = Tenant::where('entra_tenant_id', config('signature.default_tenant.entra_tenant_id'))->firstOrFail();
|
||||
|
||||
$directoryUser = DirectoryUser::where('tenant_id', $tenant->id)->where('entra_oid', $entra->getId())->first();
|
||||
abort_unless($directoryUser && $directoryUser->account_enabled, 403, 'Compte inconnu de l\'annuaire synchronisé.');
|
||||
|
||||
$user = User::updateOrCreate(
|
||||
['entra_oid' => $entra->getId()],
|
||||
[
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => $directoryUser->display_name ?? $entra->getName(),
|
||||
'email' => $directoryUser->mail ?? $entra->getEmail(),
|
||||
'role' => $this->roleFor($directoryUser),
|
||||
],
|
||||
);
|
||||
|
||||
Auth::login($user);
|
||||
$request->session()->regenerate();
|
||||
AuditLog::record('admin', $user->email, 'login', null, ['role' => $user->role], $tenant->id);
|
||||
|
||||
return redirect()->intended($user->role ? route('admin.dashboard') : route('portal.home'));
|
||||
}
|
||||
|
||||
public function logout(Request $request): RedirectResponse
|
||||
{
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
/** Rôle le plus élevé parmi les groupes AD de rôle dont l'agent est membre (recalculé à chaque connexion). */
|
||||
private function roleFor(DirectoryUser $user): ?string
|
||||
{
|
||||
$groups = $user->groups()->pluck('display_name')->map(fn ($n) => strtolower($n))->all();
|
||||
$role = null;
|
||||
foreach (config('signature.roles') as $name => $group) {
|
||||
if (in_array(strtolower($group), $groups, true)
|
||||
&& (User::ROLE_RANK[$name] ?? 0) > (User::ROLE_RANK[$role] ?? 0)) {
|
||||
$role = $name;
|
||||
}
|
||||
}
|
||||
|
||||
return $role;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Portal;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\DirectoryUser;
|
||||
use App\Signature\SignatureBuilder;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
/** Portail agent : aperçu de la signature. Champs modifiables et préférences arrivent au lot 2. */
|
||||
class PortalController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, SignatureBuilder $builder): View
|
||||
{
|
||||
$user = $request->user();
|
||||
$agent = DirectoryUser::where('tenant_id', $user->tenant_id)->where('entra_oid', $user->entra_oid)->firstOrFail();
|
||||
|
||||
return view('portal.home', [
|
||||
'agent' => $agent,
|
||||
'full' => $builder->build($agent, 'new'),
|
||||
'reply' => $builder->build($agent, 'reply'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\DirectoryUser;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Auth\EntraTokenValidator;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/** Authentifie les appels de l'add-in (Bearer = jeton Entra obtenu par NAA). */
|
||||
class AuthenticateEntraToken
|
||||
{
|
||||
public function __construct(private readonly EntraTokenValidator $validator) {}
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$token = $request->bearerToken();
|
||||
if (! $token) {
|
||||
return response()->json(['error' => 'unauthenticated'], 401);
|
||||
}
|
||||
|
||||
try {
|
||||
$claims = $this->validator->validate($token);
|
||||
} catch (RuntimeException $e) {
|
||||
Log::notice('Jeton add-in refusé', ['reason' => $e->getMessage(), 'ip' => $request->ip()]);
|
||||
|
||||
return response()->json(['error' => 'invalid_token'], 401);
|
||||
}
|
||||
|
||||
$tenant = Tenant::where('entra_tenant_id', $claims['tid'])->first();
|
||||
$user = DirectoryUser::where('tenant_id', $tenant->id)->where('entra_oid', $claims['oid'])->first();
|
||||
if (! $user || ! $user->account_enabled) {
|
||||
// Agent pas encore synchronisé (compte créé depuis moins de 15 min) ou désactivé.
|
||||
return response()->json(['error' => 'unknown_user'], 404);
|
||||
}
|
||||
|
||||
$request->attributes->set('entra_claims', $claims);
|
||||
$request->attributes->set('directory_user', $user);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/** Usage : ->middleware('role:admin') ; rangs : editor < admin < superadmin. */
|
||||
class EnsureRole
|
||||
{
|
||||
public function handle(Request $request, Closure $next, string $minimum = 'editor'): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
abort_unless($user && $user->hasRole($minimum), 403, 'Accès réservé.');
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\IpUtils;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Limite /admin aux réseaux internes (SIGNATURE_ADMIN_ALLOWED_CIDRS), en plus du SSO.
|
||||
* Derrière Nginx, configurer TrustProxies pour que $request->ip() soit l'IP réelle.
|
||||
*/
|
||||
class RestrictAdminNetwork
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$cidrs = config('signature.admin_allowed_cidrs');
|
||||
if ($cidrs && ! IpUtils::checkIp($request->ip(), $cidrs)) {
|
||||
abort(403, 'Back-office accessible uniquement depuis le réseau CABM.');
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user