This commit is contained in:
mrKamoo
2026-09-30 16:27:32 +02:00
commit 310724708f
147 changed files with 21327 additions and 0 deletions
@@ -0,0 +1,45 @@
<?php
namespace App\Console\Commands;
use App\Models\Tenant;
use App\Services\Graph\DirectorySync;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Throwable;
class SyncDirectory extends Command
{
protected $signature = 'signature:sync {--full : Synchro complète (ignore le deltaLink)} {--tenant= : ID local du tenant}';
protected $description = 'Réplique utilisateurs et groupes signature_* depuis Microsoft Graph';
public function handle(DirectorySync $sync): int
{
$tenants = Tenant::where('active', true)
->when($this->option('tenant'), fn ($q, $id) => $q->whereKey($id))
->get();
$failed = false;
foreach ($tenants as $tenant) {
// Même verrou que le bouton du back-office.
$lock = Cache::lock("signature-sync:{$tenant->id}", 600);
if (! $lock->get()) {
$this->warn("{$tenant->name} : synchronisation déjà en cours, ignorée.");
continue;
}
try {
$s = $sync->run($tenant, (bool) $this->option('full'));
$this->info(sprintf('%s : %d utilisateurs mis à jour, %d retirés, %d groupes, %d appartenances',
$tenant->name, $s['users'], $s['removed'], $s['groups'], $s['memberships']));
} catch (Throwable $e) {
$failed = true;
$this->error("{$tenant->name} : {$e->getMessage()}");
} finally {
$lock->release();
}
}
return $failed ? self::FAILURE : self::SUCCESS;
}
}
@@ -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);
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Assignment extends Model
{
protected $fillable = ['tenant_id', 'template_id', 'directory_group_id', 'priority'];
public function template(): BelongsTo { return $this->belongsTo(Template::class); }
public function group(): BelongsTo { return $this->belongsTo(DirectoryGroup::class, 'directory_group_id'); }
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class AuditLog extends Model
{
public const UPDATED_AT = null;
protected $guarded = ['id'];
protected function casts(): array
{
return ['changes' => 'array'];
}
public static function record(string $actorType, ?string $actor, string $action, ?Model $subject = null, array $changes = [], ?int $tenantId = null): self
{
return static::create([
'tenant_id' => $tenantId ?? ($subject?->tenant_id ?? null),
'actor_type' => $actorType,
'actor' => $actor,
'action' => $action,
'subject_type' => $subject ? $subject::class : null,
'subject_id' => $subject?->getKey(),
'changes' => $changes ?: null,
'ip' => request()?->ip(),
]);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Campaign extends Model
{
protected $guarded = ['id'];
protected function casts(): array
{
return ['starts_at' => 'datetime', 'ends_at' => 'datetime', 'active' => 'boolean'];
}
public function groups(): BelongsToMany
{
return $this->belongsToMany(DirectoryGroup::class, 'campaign_group');
}
public function scopeRunning(Builder $q): Builder
{
return $q->where('active', true)->where('starts_at', '<=', now())->where('ends_at', '>=', now());
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class DirectoryGroup extends Model
{
protected $fillable = ['tenant_id', 'entra_oid', 'display_name', 'synced_at'];
protected function casts(): array
{
return ['synced_at' => 'datetime'];
}
public function tenant(): BelongsTo { return $this->belongsTo(Tenant::class); }
public function members(): BelongsToMany
{
return $this->belongsToMany(DirectoryUser::class, 'group_members');
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
/** Copie locale d'un utilisateur Entra (alimentée par la synchro Graph, jamais modifiée à la main). */
class DirectoryUser extends Model
{
use SoftDeletes;
protected $guarded = ['id'];
protected function casts(): array
{
return ['account_enabled' => 'boolean', 'synced_at' => 'datetime'];
}
public function tenant(): BelongsTo { return $this->belongsTo(Tenant::class); }
public function groups(): BelongsToMany
{
return $this->belongsToMany(DirectoryGroup::class, 'group_members');
}
public function overrides(): HasMany { return $this->hasMany(UserOverride::class); }
public function preference(): HasOne { return $this->hasOne(UserPreference::class); }
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class EditableField extends Model
{
protected $fillable = ['tenant_id', 'field', 'label', 'enabled', 'validation_rule'];
protected function casts(): array
{
return ['enabled' => 'boolean'];
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class SharedMailbox extends Model
{
protected $fillable = ['tenant_id', 'address', 'display_name', 'template_id', 'include_sender'];
protected function casts(): array
{
return ['include_sender' => 'boolean'];
}
public function template(): BelongsTo { return $this->belongsTo(Template::class); }
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Template extends Model
{
protected $guarded = ['id'];
protected function casts(): array
{
return ['is_default' => 'boolean', 'editor_state' => 'array', 'published_at' => 'datetime'];
}
public function tenant(): BelongsTo { return $this->belongsTo(Tenant::class); }
public function versions(): HasMany { return $this->hasMany(TemplateVersion::class); }
public function assignments(): HasMany { return $this->hasMany(Assignment::class); }
/** Publie le brouillon : copie vers le contenu servi + nouvelle version d'historique. */
public function publish(?User $by = null): void
{
$this->html_full = $this->draft_html_full ?? $this->html_full;
$this->html_short = $this->draft_html_short ?? $this->html_short;
$this->published_version++;
$this->published_at = now();
$this->save();
$this->versions()->create([
'version' => $this->published_version,
'html_full' => $this->html_full,
'html_short' => $this->html_short,
'published_by' => $by?->id,
]);
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TemplateVersion extends Model
{
protected $guarded = ['id'];
public function template(): BelongsTo { return $this->belongsTo(Template::class); }
public function publisher(): BelongsTo { return $this->belongsTo(User::class, 'published_by'); }
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Tenant extends Model
{
protected $fillable = ['name', 'entra_tenant_id', 'active', 'users_delta_link', 'last_sync_at', 'last_sync_status'];
protected $hidden = ['users_delta_link'];
protected function casts(): array
{
return ['active' => 'boolean', 'last_sync_at' => 'datetime'];
}
public function users(): HasMany { return $this->hasMany(DirectoryUser::class); }
public function groups(): HasMany { return $this->hasMany(DirectoryGroup::class); }
public function templates(): HasMany { return $this->hasMany(Template::class); }
public function assignments(): HasMany { return $this->hasMany(Assignment::class); }
public function campaigns(): HasMany { return $this->hasMany(Campaign::class); }
public function sharedMailboxes(): HasMany { return $this->hasMany(SharedMailbox::class); }
public function editableFields(): HasMany { return $this->hasMany(EditableField::class); }
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
/** Compte du back-office (connexion SSO Entra uniquement). */
class User extends Authenticatable
{
use HasFactory, Notifiable;
public const ROLE_RANK = ['editor' => 1, 'admin' => 2, 'superadmin' => 3];
protected $fillable = ['name', 'email', 'tenant_id', 'entra_oid', 'role'];
protected $hidden = ['password', 'remember_token'];
public function tenant(): BelongsTo { return $this->belongsTo(Tenant::class); }
public function hasRole(string $minimum): bool
{
return (self::ROLE_RANK[$this->role] ?? 0) >= (self::ROLE_RANK[$minimum] ?? PHP_INT_MAX);
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserOverride extends Model
{
protected $fillable = ['directory_user_id', 'field', 'value'];
public function user(): BelongsTo { return $this->belongsTo(DirectoryUser::class, 'directory_user_id'); }
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class UserPreference extends Model
{
protected $fillable = ['directory_user_id', 'reply_variant'];
}
@@ -0,0 +1,32 @@
<?php
namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
//
}
public function boot(): void
{
// Pilote Socialite « azure » (SocialiteProviders) pour le SSO Entra.
Event::listen(function (\SocialiteProviders\Manager\SocialiteWasCalled $event) {
$event->extendSocialite('azure', \SocialiteProviders\Azure\Provider::class);
});
// 120 appels / minute par agent : largement au-dessus d'un usage normal de l'add-in.
RateLimiter::for('signature', function (Request $request) {
$oid = $request->attributes->get('entra_claims')['oid'] ?? null;
return Limit::perMinute(120)->by($oid ?: $request->ip());
});
}
}
@@ -0,0 +1,77 @@
<?php
namespace App\Services\Auth;
use App\Models\Tenant;
use Firebase\JWT\JWK;
use Firebase\JWT\JWT;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use RuntimeException;
use Throwable;
/**
* Valide les jetons d'accès Entra envoyés par l'add-in (obtenus par NAA).
* Contrôles : signature (clés publiques Entra), expiration, audience, émetteur,
* tenant autorisé et actif, scope attendu.
*/
class EntraTokenValidator
{
/** @return array<string, mixed> claims du jeton */
public function validate(string $jwt): array
{
JWT::$leeway = (int) config('signature.addin.leeway_seconds', 120);
try {
$claims = (array) JWT::decode($jwt, $this->keys());
} catch (Throwable $e) {
// Rotation des clés Entra : un seul nouvel essai avec des clés fraîches.
Cache::forget('entra-jwks');
try {
$claims = (array) JWT::decode($jwt, $this->keys());
} catch (Throwable) {
throw new RuntimeException('Jeton invalide : '.$e->getMessage());
}
}
$tid = $claims['tid'] ?? null;
if (! $tid || ! Tenant::where('entra_tenant_id', $tid)->where('active', true)->exists()) {
throw new RuntimeException('Tenant non autorisé');
}
$issuers = ["https://login.microsoftonline.com/{$tid}/v2.0", "https://sts.windows.net/{$tid}/"];
if (! in_array($claims['iss'] ?? null, $issuers, true)) {
throw new RuntimeException('Émetteur inattendu');
}
$audiences = (array) config('signature.addin.audiences');
if (! $audiences || ! in_array($claims['aud'] ?? null, $audiences, true)) {
throw new RuntimeException('Audience inattendue : reçue « '.($claims['aud'] ?? 'aucune').' », attendue(s) « '.implode(', ', $audiences).' »');
}
$scopes = explode(' ', (string) ($claims['scp'] ?? ''));
if (! in_array(config('signature.addin.required_scope'), $scopes, true)) {
throw new RuntimeException('Scope manquant : reçu « '.($claims['scp'] ?? 'aucun').' »');
}
if (empty($claims['oid'])) {
throw new RuntimeException('Identifiant utilisateur absent');
}
return $claims;
}
private function keys(): array
{
$jwks = Cache::remember('entra-jwks', config('signature.addin.jwks_cache_seconds', 86400), function () {
$res = Http::timeout(10)->get('https://login.microsoftonline.com/common/discovery/v2.0/keys');
if (! $res->successful()) {
throw new RuntimeException('Clés Entra indisponibles');
}
return $res->json();
});
return JWK::parseKeySet($jwks, 'RS256');
}
}
@@ -0,0 +1,148 @@
<?php
namespace App\Services\Graph;
use App\Models\DirectoryGroup;
use App\Models\DirectoryUser;
use App\Models\Tenant;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Réplique utilisateurs et groupes Entra dans la base locale.
* - Utilisateurs : requête delta (incrémentale), complète si --full ou si le deltaLink a expiré.
* - Groupes : ceux dont le nom commence par le préfixe configuré, avec appartenance transitive.
*/
class DirectorySync
{
private const USER_SELECT = 'id,displayName,givenName,surname,jobTitle,department,companyName,officeLocation,businessPhones,mobilePhone,mail,userPrincipalName,streetAddress,postalCode,city,accountEnabled';
public function run(Tenant $tenant, bool $full = false): array
{
$graph = new GraphClient($tenant->entra_tenant_id);
$stats = ['users' => 0, 'removed' => 0, 'groups' => 0, 'memberships' => 0];
try {
[$stats['users'], $stats['removed']] = $this->syncUsers($tenant, $graph, $full);
[$stats['groups'], $stats['memberships']] = $this->syncGroups($tenant, $graph);
$tenant->update(['last_sync_at' => now(), 'last_sync_status' => 'ok']);
} catch (Throwable $e) {
$tenant->update(['last_sync_at' => now(), 'last_sync_status' => 'erreur : '.mb_substr($e->getMessage(), 0, 200)]);
Log::error('Synchro annuaire en échec', ['tenant' => $tenant->id, 'error' => $e->getMessage()]);
throw $e;
}
return $stats;
}
private function syncUsers(Tenant $tenant, GraphClient $graph, bool $full): array
{
$start = (! $full && $tenant->users_delta_link)
? $tenant->users_delta_link
: '/users/delta?$select='.self::USER_SELECT;
try {
[$items, $deltaLink] = $graph->all($start);
} catch (Throwable $e) {
if ($full || ! $tenant->users_delta_link) {
throw $e;
}
// deltaLink expiré (410) ou invalide : on repart d'une synchro complète.
[$items, $deltaLink] = $graph->all('/users/delta?$select='.self::USER_SELECT);
$full = true;
}
$now = now();
$upserted = 0;
$removed = 0;
$seen = [];
foreach ($items as $u) {
if (isset($u['@removed'])) {
$removed += DirectoryUser::where('tenant_id', $tenant->id)->where('entra_oid', $u['id'])->delete();
continue;
}
// En delta, seules les propriétés modifiées sont renvoyées : on ne met à jour que celles-là.
$attrs = array_filter([
'display_name' => $u['displayName'] ?? null,
'given_name' => $u['givenName'] ?? null,
'surname' => $u['surname'] ?? null,
'job_title' => $u['jobTitle'] ?? null,
'department' => $u['department'] ?? null,
'company_name' => $u['companyName'] ?? null,
'office_location' => $u['officeLocation'] ?? null,
'business_phone' => array_key_exists('businessPhones', $u) ? ($u['businessPhones'][0] ?? null) : null,
'mobile_phone' => $u['mobilePhone'] ?? null,
'mail' => isset($u['mail']) ? strtolower($u['mail']) : null,
'user_principal_name' => isset($u['userPrincipalName']) ? strtolower($u['userPrincipalName']) : null,
'street_address' => $u['streetAddress'] ?? null,
'postal_code' => $u['postalCode'] ?? null,
'city' => $u['city'] ?? null,
], fn ($v, $k) => array_key_exists($this->graphKey($k), $u), ARRAY_FILTER_USE_BOTH);
if (array_key_exists('accountEnabled', $u)) {
$attrs['account_enabled'] = (bool) $u['accountEnabled'];
}
$attrs['synced_at'] = $now;
$user = DirectoryUser::withTrashed()->firstOrNew(['tenant_id' => $tenant->id, 'entra_oid' => $u['id']]);
$user->fill($attrs);
$user->deleted_at = null;
$user->save();
$seen[] = $u['id'];
$upserted++;
}
if ($full) {
// Synchro complète : tout ce qui n'a pas été vu n'existe plus dans Entra.
$removed += DirectoryUser::where('tenant_id', $tenant->id)->whereNotIn('entra_oid', $seen)->delete();
}
$tenant->users_delta_link = $deltaLink;
$tenant->save();
return [$upserted, $removed];
}
private function syncGroups(Tenant $tenant, GraphClient $graph): array
{
$prefix = str_replace("'", "''", config('signature.graph.group_prefix'));
[$groups] = $graph->all("/groups?\$filter=startswith(displayName,'{$prefix}')&\$select=id,displayName");
$memberships = 0;
$keep = [];
foreach ($groups as $g) {
$group = DirectoryGroup::updateOrCreate(
['tenant_id' => $tenant->id, 'entra_oid' => $g['id']],
['display_name' => $g['displayName'], 'synced_at' => now()],
);
$keep[] = $group->id;
[$members] = $graph->all(
"/groups/{$g['id']}/transitiveMembers/microsoft.graph.user?\$select=id&\$count=true&\$top=999",
['ConsistencyLevel' => 'eventual'],
);
$userIds = DirectoryUser::where('tenant_id', $tenant->id)
->whereIn('entra_oid', array_column($members, 'id'))
->pluck('id')->all();
DB::transaction(fn () => $group->members()->sync($userIds));
$memberships += count($userIds);
}
DirectoryGroup::where('tenant_id', $tenant->id)->whereNotIn('id', $keep)->delete();
return [count($groups), $memberships];
}
private function graphKey(string $column): string
{
return [
'display_name' => 'displayName', 'given_name' => 'givenName', 'surname' => 'surname',
'job_title' => 'jobTitle', 'department' => 'department', 'company_name' => 'companyName',
'office_location' => 'officeLocation', 'business_phone' => 'businessPhones',
'mobile_phone' => 'mobilePhone', 'mail' => 'mail', 'user_principal_name' => 'userPrincipalName',
'street_address' => 'streetAddress', 'postal_code' => 'postalCode', 'city' => 'city',
][$column];
}
}
@@ -0,0 +1,73 @@
<?php
namespace App\Services\Graph;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use RuntimeException;
/**
* Client Microsoft Graph minimal (flux client credentials, permissions « application »).
* Permissions requises, avec consentement administrateur : User.Read.All, GroupMember.Read.All.
*/
class GraphClient
{
public function __construct(private readonly string $tenantId) {}
public function token(): string
{
return Cache::remember("graph-token:{$this->tenantId}", now()->addMinutes(50), function () {
$res = Http::asForm()->timeout(config('signature.graph.timeout'))
->post("https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token", [
'client_id' => config('signature.graph.client_id'),
'client_secret' => config('signature.graph.client_secret'),
'scope' => 'https://graph.microsoft.com/.default',
'grant_type' => 'client_credentials',
]);
if (! $res->successful()) {
throw new RuntimeException('Jeton Graph refusé : '.$res->json('error_description', $res->body()));
}
return $res->json('access_token');
});
}
/** GET sur une URL relative (/users…) ou absolue (@odata.nextLink). */
public function get(string $url, array $headers = []): array
{
$full = str_starts_with($url, 'https://') ? $url : config('signature.graph.base_url').$url;
$res = Http::withToken($this->token())
->withHeaders($headers)
->timeout(config('signature.graph.timeout'))
// Nouvel essai sur limitation (429) ou indisponibilité temporaire de Graph.
->retry(3, 2000, fn ($e) => $e instanceof RequestException && in_array($e->response->status(), [429, 503, 504], true), throw: false)
->get($full);
if (! $res->successful()) {
throw new RuntimeException("Graph {$res->status()} sur {$url} : ".$res->body());
}
return $res->json();
}
/**
* Parcourt toutes les pages d'une collection.
* Retourne [éléments, deltaLink|null].
*/
public function all(string $url, array $headers = []): array
{
$items = [];
$delta = null;
$next = $url;
while ($next) {
$page = $this->get($next, $headers);
array_push($items, ...($page['value'] ?? []));
$next = $page['@odata.nextLink'] ?? null;
$delta = $page['@odata.deltaLink'] ?? $delta;
}
return [$items, $delta];
}
}
+155
View File
@@ -0,0 +1,155 @@
<?php
namespace App\Signature;
use App\Models\Assignment;
use App\Models\Campaign;
use App\Models\DirectoryUser;
use App\Models\EditableField;
use App\Models\SharedMailbox;
use App\Models\Template;
/**
* Calcule la signature d'un agent pour un contexte de rédaction donné.
* Tout est lu dans la base locale (synchro Graph) : aucun appel Graph en direct.
*/
final class SignatureBuilder
{
public function __construct(
private readonly TemplateRenderer $renderer,
private readonly TemplateSelector $selector,
) {}
/**
* @param string $context new | reply | forward | appointment
* @param string|null $mailbox adresse émettrice (boîte partagée éventuelle)
* @return array{html:string, variant:string, template:string}|null
*/
public function build(DirectoryUser $user, string $context, ?string $mailbox = null): ?array
{
$shared = $this->sharedMailbox($user, $mailbox);
$template = $shared?->template ?? $this->templateFor($user);
if (! $template) {
return null;
}
$variant = $this->variant($user, $context);
if ($variant === 'none') {
return ['html' => '', 'variant' => 'none', 'template' => $template->name];
}
$source = $variant === 'short' ? ($template->html_short ?: $template->html_full) : $template->html_full;
if (! $source) {
return null;
}
$vars = $this->variables($user, $variant);
if ($shared) {
// Boîte partagée : ses propres coordonnées, l'agent seulement si l'admin l'a choisi.
$vars['boite_nom'] = $shared->display_name;
$vars['boite_email'] = $shared->address;
$vars['email'] = $shared->address;
if (! $shared->include_sender) {
foreach (['prenom', 'nom', 'nom_complet', 'fonction', 'mobile'] as $k) {
$vars[$k] = null;
}
}
}
return [
'html' => $this->renderer->render($source, $vars),
'variant' => $variant,
'template' => $template->name,
];
}
/** Variables d'un agent (attributs Entra + surcharges autorisées + bannière en variante complète). */
public function variables(DirectoryUser $user, string $variant = 'full'): array
{
$vars = VariableMap::build($user->getAttributes(), $this->allowedOverrides($user));
$vars['banniere'] = $variant === 'full' ? $this->bannerHtml($user) : '';
$vars['site_url'] = config('signature.public_url');
return $vars;
}
/** Aperçu d'un HTML brut (brouillon en cours d'édition) pour un agent donné. */
public function preview(DirectoryUser $user, string $source, string $variant = 'full'): string
{
return $this->renderer->render($source, $this->variables($user, $variant));
}
public function templateFor(DirectoryUser $user): ?Template
{
$assignments = Assignment::query()
->where('tenant_id', $user->tenant_id)
->get(['id', 'template_id', 'directory_group_id', 'priority'])
->map(fn ($a) => [
'id' => $a->id,
'template_id' => $a->template_id,
'group_id' => $a->directory_group_id,
'priority' => $a->priority,
])->all();
$default = Template::where('tenant_id', $user->tenant_id)->where('is_default', true)->value('id');
$id = $this->selector->select($assignments, $user->groups()->pluck('directory_groups.id')->all(), $default);
return $id ? Template::whereNotNull('html_full')->find($id) : null;
}
private function variant(DirectoryUser $user, string $context): string
{
if ($context !== 'reply' && $context !== 'forward') {
return 'full';
}
$pref = $user->preference?->reply_variant ?? config('signature.default_reply_variant', 'short');
return in_array($pref, ['short', 'full', 'none'], true) ? $pref : 'short';
}
/** Surcharges saisies par l'agent, limitées aux champs que l'admin a ouverts. */
private function allowedOverrides(DirectoryUser $user): array
{
$enabled = EditableField::where('tenant_id', $user->tenant_id)->where('enabled', true)->pluck('field')->all();
return $user->overrides()->whereIn('field', $enabled)->pluck('value', 'field')->all();
}
private function sharedMailbox(DirectoryUser $user, ?string $mailbox): ?SharedMailbox
{
if (! $mailbox) {
return null;
}
$mailbox = strtolower($mailbox);
if ($mailbox === strtolower((string) $user->mail) || $mailbox === strtolower((string) $user->user_principal_name)) {
return null;
}
return SharedMailbox::with('template')->where('tenant_id', $user->tenant_id)->where('address', $mailbox)->first();
}
private function bannerHtml(DirectoryUser $user): string
{
$groupIds = $user->groups()->pluck('directory_groups.id')->all();
$campaign = Campaign::running()
->where('tenant_id', $user->tenant_id)
->where(fn ($q) => $q->whereDoesntHave('groups')
->orWhereHas('groups', fn ($g) => $g->whereIn('directory_groups.id', $groupIds)))
->orderByDesc('priority')->orderBy('id')
->first();
if (! $campaign) {
return '';
}
$src = htmlspecialchars(config('signature.public_url').'/img/'.ltrim($campaign->image_path, '/'), ENT_QUOTES);
$alt = htmlspecialchars($campaign->alt_text, ENT_QUOTES);
$img = "<img src=\"{$src}\" alt=\"{$alt}\" width=\"600\" style=\"display:block;max-width:100%;height:auto;border:0\">";
return $campaign->link_url
? '<a href="'.htmlspecialchars($campaign->link_url, ENT_QUOTES).'">'.$img.'</a>'
: $img;
}
}
@@ -0,0 +1,55 @@
<?php
namespace App\Signature;
/**
* Moteur de rendu des templates de signature (sans dépendance au framework).
*
* Syntaxe :
* {{variable}} valeur échappée HTML
* {{{variable}}} valeur brute (réservé aux blocs générés par le serveur, ex. banniere)
* {{#if variable}} … {{/if}} bloc conservé si la variable est non vide (imbrication possible)
* {{#unless variable}} … {{/unless}} bloc conservé si la variable est vide
*
* Une variable inconnue est traitée comme vide : une ligne « Mobile : {{mobile}} »
* entourée de {{#if mobile}} disparaît donc proprement quand le champ n'est pas renseigné.
*/
final class TemplateRenderer
{
private const BLOCK = '/\{\{#(if|unless)\s+([a-z0-9_]+)\s*\}\}((?:(?!\{\{#(?:if|unless)\s).)*?)\{\{\/\1\}\}/is';
/** @param array<string, scalar|null> $vars */
public function render(string $template, array $vars): string
{
$vars = array_change_key_case($vars, CASE_LOWER);
// Blocs conditionnels, du plus interne vers l'extérieur.
$guard = 0;
while (preg_match(self::BLOCK, $template) && $guard++ < 50) {
$template = preg_replace_callback(self::BLOCK, function (array $m) use ($vars) {
$filled = $this->filled($vars[strtolower($m[2])] ?? null);
$keep = strtolower($m[1]) === 'if' ? $filled : ! $filled;
return $keep ? $m[3] : '';
}, $template);
}
// Valeurs brutes puis échappées.
$template = preg_replace_callback('/\{\{\{\s*([a-z0-9_]+)\s*\}\}\}/i', fn ($m) => (string) ($vars[strtolower($m[1])] ?? ''), $template);
return preg_replace_callback('/\{\{\s*([a-z0-9_]+)\s*\}\}/i', fn ($m) => htmlspecialchars((string) ($vars[strtolower($m[1])] ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'), $template);
}
/** Liste des variables utilisées par un template (aide à l'édition). */
public function variables(string $template): array
{
preg_match_all('/\{\{\{?\s*(?:#(?:if|unless)\s+)?([a-z0-9_]+)\s*\}?\}\}/i', $template, $m);
return array_values(array_unique(array_map('strtolower', $m[1])));
}
private function filled(mixed $v): bool
{
return $v !== null && trim((string) $v) !== '';
}
}
@@ -0,0 +1,52 @@
<?php
namespace App\Signature;
/**
* Nettoie le HTML d'un template avant enregistrement (sans dépendance au framework).
*
* Volontairement basé sur des expressions régulières et non sur DOMDocument :
* un parseur HTML déplacerait les marqueurs {{#if}} placés entre deux <tr>,
* ce qui casserait les templates.
*
* Défense en profondeur : les aperçus sont en plus affichés dans des iframes « sandbox ».
*/
final class TemplateSanitizer
{
/** Balises retirées avec leur contenu. */
private const BLOCK_TAGS = ['script', 'iframe', 'object', 'embed', 'applet', 'form', 'noscript', 'template', 'svg', 'math'];
/** Balises orphelines retirées. */
private const VOID_TAGS = ['base', 'meta', 'link', 'input', 'button', 'select', 'textarea', 'frame', 'frameset'];
public function clean(string $html): string
{
foreach (self::BLOCK_TAGS as $tag) {
$html = preg_replace('#<'.$tag.'\b[^>]*>.*?</'.$tag.'\s*>#is', '', $html);
$html = preg_replace('#<'.$tag.'\b[^>]*/?>#i', '', $html); // balise ouvrante sans fermeture
}
foreach (self::VOID_TAGS as $tag) {
$html = preg_replace('#</?'.$tag.'\b[^>]*>#i', '', $html);
}
// Gestionnaires d'événements : onclick=, onerror=…
$html = preg_replace('#\s+on[a-z]+\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)#i', '', $html);
// URL dangereuses dans href / src / action / style url()
$html = preg_replace_callback(
'#\s(href|src|action|formaction|xlink:href|background)\s*=\s*("([^"]*)"|\'([^\']*)\'|([^\s>]+))#i',
function (array $m) {
$value = html_entity_decode($m[3] !== '' ? $m[3] : ($m[4] ?? $m[5] ?? ''), ENT_QUOTES | ENT_HTML5);
$normalized = strtolower(preg_replace('/[\s\x00-\x1f]+/', '', $value));
return preg_match('#^(javascript|vbscript|data):#', $normalized) && ! str_starts_with($normalized, 'data:image/')
? ''
: $m[0];
},
$html,
);
$html = preg_replace('#expression\s*\(|javascript\s*:#i', '', $html);
return trim($html);
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Signature;
/**
* Choix du template d'un agent à partir de ses groupes (sans dépendance au framework).
* Règle : parmi les affectations dont le groupe contient l'agent, la priorité la plus haute l'emporte ;
* à priorité égale, l'affectation la plus ancienne (id le plus petit) gagne, pour un résultat stable.
* Aucun groupe correspondant : template par défaut du tenant (ou null).
*/
final class TemplateSelector
{
/**
* @param array<int, array{id:int, template_id:int, group_id:int, priority:int}> $assignments
* @param array<int, int> $userGroupIds
*/
public function select(array $assignments, array $userGroupIds, ?int $defaultTemplateId): ?int
{
$groups = array_flip($userGroupIds);
$best = null;
foreach ($assignments as $a) {
if (! isset($groups[$a['group_id']])) {
continue;
}
if ($best === null
|| $a['priority'] > $best['priority']
|| ($a['priority'] === $best['priority'] && $a['id'] < $best['id'])) {
$best = $a;
}
}
return $best['template_id'] ?? $defaultTemplateId;
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Signature;
/**
* Variables disponibles dans les templates et correspondance avec les attributs Entra.
* La clé est celle utilisée dans le template ({{telephone}}) et dans les surcharges agent.
*/
final class VariableMap
{
public const FIELDS = [
'prenom' => ['column' => 'given_name', 'label' => 'Prénom'],
'nom' => ['column' => 'surname', 'label' => 'Nom'],
'nom_complet' => ['column' => 'display_name', 'label' => 'Nom complet'],
'fonction' => ['column' => 'job_title', 'label' => 'Fonction'],
'service' => ['column' => 'department', 'label' => 'Service'],
'organisation' => ['column' => 'company_name', 'label' => 'Organisation'],
'bureau' => ['column' => 'office_location', 'label' => 'Bureau / site'],
'telephone' => ['column' => 'business_phone', 'label' => 'Téléphone fixe'],
'mobile' => ['column' => 'mobile_phone', 'label' => 'Téléphone mobile'],
'email' => ['column' => 'mail', 'label' => 'Adresse e-mail'],
'adresse' => ['column' => 'street_address', 'label' => 'Adresse'],
'code_postal' => ['column' => 'postal_code', 'label' => 'Code postal'],
'ville' => ['column' => 'city', 'label' => 'Ville'],
];
/**
* @param array<string, mixed> $user attributs de directory_users
* @param array<string, string|null> $overrides surcharges agent autorisées (clé de variable => valeur)
* @return array<string, string|null>
*/
public static function build(array $user, array $overrides = []): array
{
$vars = [];
foreach (self::FIELDS as $key => $def) {
$vars[$key] = $user[$def['column']] ?? null;
}
foreach ($overrides as $key => $value) {
if (array_key_exists($key, self::FIELDS)) {
$vars[$key] = $value;
}
}
return $vars;
}
}