45
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[{compose,docker-compose}.{yml,yaml}]
|
||||
indent_size = 4
|
||||
@@ -0,0 +1,65 @@
|
||||
APP_NAME=Laravel
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost:8000
|
||||
|
||||
APP_LOCALE=en
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
# APP_MAINTENANCE_STORE=database
|
||||
|
||||
# PHP_CLI_SERVER_WORKERS=4
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
DB_CONNECTION=sqlite
|
||||
# DB_HOST=127.0.0.1
|
||||
# DB_PORT=3306
|
||||
# DB_DATABASE=laravel
|
||||
# DB_USERNAME=root
|
||||
# DB_PASSWORD=
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
CACHE_STORE=database
|
||||
# CACHE_PREFIX=
|
||||
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
MAIL_MAILER=log
|
||||
MAIL_SCHEME=null
|
||||
MAIL_HOST=127.0.0.1
|
||||
MAIL_PORT=2525
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
@@ -0,0 +1,11 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.blade.php diff=html
|
||||
*.css diff=css
|
||||
*.html diff=html
|
||||
*.md diff=markdown
|
||||
*.php diff=php
|
||||
|
||||
/.github export-ignore
|
||||
CHANGELOG.md export-ignore
|
||||
.styleci.yml export-ignore
|
||||
@@ -0,0 +1,27 @@
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
.phpactor.json
|
||||
.phpunit.result.cache
|
||||
/.codex
|
||||
/.cursor/
|
||||
/.idea
|
||||
/.nova
|
||||
/.phpunit.cache
|
||||
/.vscode
|
||||
/.zed
|
||||
/auth.json
|
||||
/node_modules
|
||||
/public/build
|
||||
/public/fonts-manifest.dev.json
|
||||
/public/hot
|
||||
/public/storage
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/vendor
|
||||
_ide_helper.php
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,2 @@
|
||||
ignore-scripts=true
|
||||
audit=true
|
||||
@@ -0,0 +1,47 @@
|
||||
<laravel-boost-guidelines>
|
||||
# Laravel Application
|
||||
|
||||
This repository contains a Laravel application. Complete the following setup before working on the user's request.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Verify that PHP and Composer are available:
|
||||
|
||||
```sh
|
||||
php -v
|
||||
composer -V
|
||||
```
|
||||
|
||||
If either command is unavailable, detect the user's operating system and install the prerequisites with the appropriate command:
|
||||
|
||||
macOS:
|
||||
|
||||
```sh
|
||||
/bin/bash -c "$(curl -fsSL https://php.new/install/mac/8.5)"
|
||||
```
|
||||
|
||||
Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://php.new/install/windows/8.5'))
|
||||
```
|
||||
|
||||
Linux:
|
||||
|
||||
```sh
|
||||
/bin/bash -c "$(curl -fsSL https://php.new/install/linux/8.5)"
|
||||
```
|
||||
|
||||
After installation, ask the user to restart their terminal. If the agent needs the restarted shell to continue, ask the user to reopen their terminal and rerun their original prompt.
|
||||
|
||||
## Agent Setup
|
||||
|
||||
Install Laravel Boost from the application root before making application changes:
|
||||
|
||||
```sh
|
||||
composer require laravel/boost --dev
|
||||
php artisan boost:install
|
||||
```
|
||||
|
||||
Boost replaces these bootstrap instructions with guidelines tailored to the application. After installation, read `AGENTS.md` again and continue with the user's original request using the generated guidelines.
|
||||
</laravel-boost-guidelines>
|
||||
@@ -0,0 +1,47 @@
|
||||
<laravel-boost-guidelines>
|
||||
# Laravel Application
|
||||
|
||||
This repository contains a Laravel application. Complete the following setup before working on the user's request.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Verify that PHP and Composer are available:
|
||||
|
||||
```sh
|
||||
php -v
|
||||
composer -V
|
||||
```
|
||||
|
||||
If either command is unavailable, detect the user's operating system and install the prerequisites with the appropriate command:
|
||||
|
||||
macOS:
|
||||
|
||||
```sh
|
||||
/bin/bash -c "$(curl -fsSL https://php.new/install/mac/8.5)"
|
||||
```
|
||||
|
||||
Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
Set-ExecutionPolicy Bypass -Scope Process -Force; [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; iex ((New-Object System.Net.WebClient).DownloadString('https://php.new/install/windows/8.5'))
|
||||
```
|
||||
|
||||
Linux:
|
||||
|
||||
```sh
|
||||
/bin/bash -c "$(curl -fsSL https://php.new/install/linux/8.5)"
|
||||
```
|
||||
|
||||
After installation, ask the user to restart their terminal. If the agent needs the restarted shell to continue, ask the user to reopen their terminal and rerun their original prompt.
|
||||
|
||||
## Agent Setup
|
||||
|
||||
Install Laravel Boost from the application root before making application changes:
|
||||
|
||||
```sh
|
||||
composer require laravel/boost --dev
|
||||
php artisan boost:install
|
||||
```
|
||||
|
||||
Boost replaces these bootstrap instructions with guidelines tailored to the application. After installation, read `AGENTS.md` again and continue with the user's original request using the generated guidelines.
|
||||
</laravel-boost-guidelines>
|
||||
@@ -0,0 +1,58 @@
|
||||
<p align="center"><a href="https://laravel.com" target="_blank"><img src="https://raw.githubusercontent.com/laravel/art/master/logo-lockup/5%20SVG/2%20CMYK/1%20Full%20Color/laravel-logolockup-cmyk-red.svg" width="400" alt="Laravel Logo"></a></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/laravel/framework/actions"><img src="https://github.com/laravel/framework/workflows/tests/badge.svg" alt="Build Status"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/dt/laravel/framework" alt="Total Downloads"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/v/laravel/framework" alt="Latest Stable Version"></a>
|
||||
<a href="https://packagist.org/packages/laravel/framework"><img src="https://img.shields.io/packagist/l/laravel/framework" alt="License"></a>
|
||||
</p>
|
||||
|
||||
## About Laravel
|
||||
|
||||
Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as:
|
||||
|
||||
- [Simple, fast routing engine](https://laravel.com/docs/routing).
|
||||
- [Powerful dependency injection container](https://laravel.com/docs/container).
|
||||
- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage.
|
||||
- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent).
|
||||
- Database agnostic [schema migrations](https://laravel.com/docs/migrations).
|
||||
- [Robust background job processing](https://laravel.com/docs/queues).
|
||||
- [Real-time event broadcasting](https://laravel.com/docs/broadcasting).
|
||||
|
||||
Laravel is accessible, powerful, and provides tools required for large, robust applications.
|
||||
|
||||
## Learning Laravel
|
||||
|
||||
Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework.
|
||||
|
||||
In addition, [Laracasts](https://laracasts.com) contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library.
|
||||
|
||||
You can also watch bite-sized lessons with real-world projects on [Laravel Learn](https://laravel.com/learn), where you will be guided through building a Laravel application from scratch while learning PHP fundamentals.
|
||||
|
||||
## Agentic Development
|
||||
|
||||
Laravel's predictable structure and conventions make it ideal for AI coding agents like Claude Code, Cursor, and GitHub Copilot. Install [Laravel Boost](https://laravel.com/docs/ai) to supercharge your AI workflow:
|
||||
|
||||
```bash
|
||||
composer require laravel/boost --dev
|
||||
|
||||
php artisan boost:install
|
||||
```
|
||||
|
||||
Boost provides your agent 15+ tools and skills that help agents build Laravel applications while following best practices.
|
||||
|
||||
## Contributing
|
||||
|
||||
Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions).
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct).
|
||||
|
||||
## Security Vulnerabilities
|
||||
|
||||
If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed.
|
||||
|
||||
## License
|
||||
|
||||
The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT).
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'); }
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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'); }
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'); }
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Symfony\Component\Console\Input\ArgvInput;
|
||||
|
||||
define('LARAVEL_START', microtime(true));
|
||||
|
||||
// Register the Composer autoloader...
|
||||
require __DIR__.'/vendor/autoload.php';
|
||||
|
||||
// Bootstrap Laravel and handle the command...
|
||||
/** @var Application $app */
|
||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||
|
||||
$status = $app->handleCommand(new ArgvInput);
|
||||
|
||||
exit($status);
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
api: __DIR__.'/../routes/api.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->alias([
|
||||
'entra.token' => \App\Http\Middleware\AuthenticateEntraToken::class,
|
||||
'role' => \App\Http\Middleware\EnsureRole::class,
|
||||
'admin.network' => \App\Http\Middleware\RestrictAdminNetwork::class,
|
||||
]);
|
||||
// Si un reverse proxy s'intercale devant Nginx, déclarer son IP ici (ex. at: ['10.10.0.5']).
|
||||
// $middleware->trustProxies(at: []);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
//
|
||||
})->create();
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Providers\AppServiceProvider;
|
||||
|
||||
return [
|
||||
AppServiceProvider::class,
|
||||
];
|
||||
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"$schema": "https://getcomposer.org/schema.json",
|
||||
"name": "laravel/laravel",
|
||||
"type": "project",
|
||||
"description": "The skeleton application for the Laravel framework.",
|
||||
"keywords": ["laravel", "framework"],
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"firebase/php-jwt": "^7.2",
|
||||
"laravel/framework": "^13.17",
|
||||
"laravel/socialite": "^5.31",
|
||||
"laravel/tinker": "^3.0",
|
||||
"socialiteproviders/microsoft-azure": "^5.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
"laravel/pail": "^1.2.5",
|
||||
"laravel/pao": "^1.0.6",
|
||||
"laravel/pint": "^1.27",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"phpunit/phpunit": "^12.5.12"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "app/",
|
||||
"Database\\Factories\\": "database/factories/",
|
||||
"Database\\Seeders\\": "database/seeders/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"setup": [
|
||||
"composer install",
|
||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
|
||||
"@php artisan key:generate",
|
||||
"@php artisan migrate --force",
|
||||
"npm install --ignore-scripts",
|
||||
"npm run build"
|
||||
],
|
||||
"dev": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"@php artisan dev"
|
||||
],
|
||||
"test": [
|
||||
"@php artisan config:clear --ansi @no_additional_args",
|
||||
"@php artisan test"
|
||||
],
|
||||
"post-autoload-dump": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||
"@php artisan package:discover --ansi"
|
||||
],
|
||||
"post-update-cmd": [
|
||||
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
|
||||
],
|
||||
"post-root-package-install": [
|
||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
|
||||
],
|
||||
"post-create-project-cmd": [
|
||||
"@php artisan key:generate --ansi",
|
||||
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
|
||||
"@php artisan migrate --graceful --ansi"
|
||||
],
|
||||
"pre-package-uninstall": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
|
||||
]
|
||||
},
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"dont-discover": []
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"optimize-autoloader": true,
|
||||
"preferred-install": "dist",
|
||||
"sort-packages": true,
|
||||
"allow-plugins": {
|
||||
"pestphp/pest-plugin": true,
|
||||
"php-http/discovery": true
|
||||
}
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true
|
||||
}
|
||||
Generated
+8893
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value is the name of your application, which will be used when the
|
||||
| framework needs to place the application's name in a notification or
|
||||
| other UI elements where an application name needs to be displayed.
|
||||
|
|
||||
*/
|
||||
|
||||
'name' => env('APP_NAME', 'Laravel'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Environment
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value determines the "environment" your application is currently
|
||||
| running in. This may determine how you prefer to configure various
|
||||
| services the application utilizes. Set this in your ".env" file.
|
||||
|
|
||||
*/
|
||||
|
||||
'env' => env('APP_ENV', 'production'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Debug Mode
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When your application is in debug mode, detailed error messages with
|
||||
| stack traces will be shown on every error that occurs within your
|
||||
| application. If disabled, a simple generic error page is shown.
|
||||
|
|
||||
*/
|
||||
|
||||
'debug' => (bool) env('APP_DEBUG', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application URL
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This URL is used by the console to properly generate URLs when using
|
||||
| the Artisan command line tool. You should set this to the root of
|
||||
| the application so that it's available within Artisan commands.
|
||||
|
|
||||
*/
|
||||
|
||||
'url' => env('APP_URL', 'http://localhost'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Timezone
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the default timezone for your application, which
|
||||
| will be used by the PHP date and date-time functions. The timezone
|
||||
| is set to "UTC" by default as it is suitable for most use cases.
|
||||
|
|
||||
*/
|
||||
|
||||
'timezone' => 'UTC',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Locale Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The application locale determines the default locale that will be used
|
||||
| by Laravel's translation / localization methods. This option can be
|
||||
| set to any locale for which you plan to have translation strings.
|
||||
|
|
||||
*/
|
||||
|
||||
'locale' => env('APP_LOCALE', 'en'),
|
||||
|
||||
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
|
||||
|
||||
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Encryption Key
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This key is utilized by Laravel's encryption services and should be set
|
||||
| to a random, 32 character string to ensure that all encrypted values
|
||||
| are secure. You should do this prior to deploying the application.
|
||||
|
|
||||
*/
|
||||
|
||||
'cipher' => 'AES-256-CBC',
|
||||
|
||||
'key' => env('APP_KEY'),
|
||||
|
||||
'previous_keys' => [
|
||||
...array_filter(
|
||||
explode(',', (string) env('APP_PREVIOUS_KEYS', ''))
|
||||
),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Maintenance Mode Driver
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These configuration options determine the driver used to determine and
|
||||
| manage Laravel's "maintenance mode" status. The "cache" driver will
|
||||
| allow maintenance mode to be controlled across multiple machines.
|
||||
|
|
||||
| Supported drivers: "file", "cache", "array"
|
||||
|
|
||||
*/
|
||||
|
||||
'maintenance' => [
|
||||
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
|
||||
'store' => env('APP_MAINTENANCE_STORE', 'database'),
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authentication Defaults
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option defines the default authentication "guard" and password
|
||||
| reset "broker" for your application. You may change these values
|
||||
| as required, but they're a perfect start for most applications.
|
||||
|
|
||||
*/
|
||||
|
||||
'defaults' => [
|
||||
'guard' => env('AUTH_GUARD', 'web'),
|
||||
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authentication Guards
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Next, you may define every authentication guard for your application.
|
||||
| Of course, a great default configuration has been defined for you
|
||||
| which utilizes session storage plus the Eloquent user provider.
|
||||
|
|
||||
| All authentication guards have a user provider, which defines how the
|
||||
| users are actually retrieved out of your database or other storage
|
||||
| system used by the application. Typically, Eloquent is utilized.
|
||||
|
|
||||
| Supported: "session"
|
||||
|
|
||||
*/
|
||||
|
||||
'guards' => [
|
||||
'web' => [
|
||||
'driver' => 'session',
|
||||
'provider' => 'users',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| User Providers
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| All authentication guards have a user provider, which defines how the
|
||||
| users are actually retrieved out of your database or other storage
|
||||
| system used by the application. Typically, Eloquent is utilized.
|
||||
|
|
||||
| If you have multiple user tables or models you may configure multiple
|
||||
| providers to represent the model / table. These providers may then
|
||||
| be assigned to any extra authentication guards you have defined.
|
||||
|
|
||||
| Supported: "database", "eloquent"
|
||||
|
|
||||
*/
|
||||
|
||||
'providers' => [
|
||||
'users' => [
|
||||
'driver' => 'eloquent',
|
||||
'model' => env('AUTH_MODEL', User::class),
|
||||
],
|
||||
|
||||
// 'users' => [
|
||||
// 'driver' => 'database',
|
||||
// 'table' => 'users',
|
||||
// ],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Resetting Passwords
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These configuration options specify the behavior of Laravel's password
|
||||
| reset functionality, including the table utilized for token storage
|
||||
| and the user provider that is invoked to actually retrieve users.
|
||||
|
|
||||
| The expiry time is the number of minutes that each reset token will be
|
||||
| considered valid. This security feature keeps tokens short-lived so
|
||||
| they have less time to be guessed. You may change this as needed.
|
||||
|
|
||||
| The throttle setting is the number of seconds a user must wait before
|
||||
| generating more password reset tokens. This prevents the user from
|
||||
| quickly generating a very large amount of password reset tokens.
|
||||
|
|
||||
*/
|
||||
|
||||
'passwords' => [
|
||||
'users' => [
|
||||
'provider' => 'users',
|
||||
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
|
||||
'expire' => 60,
|
||||
'throttle' => 60,
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Password Confirmation Timeout
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define the number of seconds before a password confirmation
|
||||
| window expires and users are asked to re-enter their password via the
|
||||
| confirmation screen. By default, the timeout lasts for three hours.
|
||||
|
|
||||
*/
|
||||
|
||||
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
|
||||
|
||||
];
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Cache Store
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default cache store that will be used by the
|
||||
| framework. This connection is utilized if another isn't explicitly
|
||||
| specified when running a cache operation inside the application.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('CACHE_STORE', 'database'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cache Stores
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define all of the cache "stores" for your application as
|
||||
| well as their drivers. You may even define multiple stores for the
|
||||
| same cache driver to group types of items stored in your caches.
|
||||
|
|
||||
| Supported drivers: "array", "database", "file", "memcached",
|
||||
| "redis", "dynamodb", "storage", "octane",
|
||||
| "session", "failover", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'stores' => [
|
||||
|
||||
'array' => [
|
||||
'driver' => 'array',
|
||||
'serialize' => false,
|
||||
],
|
||||
|
||||
'database' => [
|
||||
'driver' => 'database',
|
||||
'connection' => env('DB_CACHE_CONNECTION'),
|
||||
'table' => env('DB_CACHE_TABLE', 'cache'),
|
||||
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
|
||||
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
|
||||
],
|
||||
|
||||
'file' => [
|
||||
'driver' => 'file',
|
||||
'path' => storage_path('framework/cache/data'),
|
||||
'lock_path' => storage_path('framework/cache/data'),
|
||||
],
|
||||
|
||||
'storage' => [
|
||||
'driver' => 'storage',
|
||||
'disk' => env('CACHE_STORAGE_DISK'),
|
||||
'path' => env('CACHE_STORAGE_PATH', 'framework/cache/data'),
|
||||
],
|
||||
|
||||
'memcached' => [
|
||||
'driver' => 'memcached',
|
||||
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
|
||||
'sasl' => [
|
||||
env('MEMCACHED_USERNAME'),
|
||||
env('MEMCACHED_PASSWORD'),
|
||||
],
|
||||
'options' => [
|
||||
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
|
||||
],
|
||||
'servers' => [
|
||||
[
|
||||
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
|
||||
'port' => env('MEMCACHED_PORT', 11211),
|
||||
'weight' => 100,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'redis' => [
|
||||
'driver' => 'redis',
|
||||
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
|
||||
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
|
||||
],
|
||||
|
||||
'dynamodb' => [
|
||||
'driver' => 'dynamodb',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
|
||||
'endpoint' => env('DYNAMODB_ENDPOINT'),
|
||||
],
|
||||
|
||||
'octane' => [
|
||||
'driver' => 'octane',
|
||||
],
|
||||
|
||||
'failover' => [
|
||||
'driver' => 'failover',
|
||||
'stores' => [
|
||||
'database',
|
||||
'array',
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cache Key Prefix
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
|
||||
| stores, there might be other applications using the same cache. For
|
||||
| that reason, you may prefix every cache key to avoid collisions.
|
||||
|
|
||||
*/
|
||||
|
||||
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Serializable Classes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value determines the classes that can be unserialized from cache
|
||||
| storage. By default, no PHP classes will be unserialized from your
|
||||
| cache to prevent gadget chain attacks if your APP_KEY is leaked.
|
||||
|
|
||||
*/
|
||||
|
||||
'serializable_classes' => false,
|
||||
|
||||
];
|
||||
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use Pdo\Mysql;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Database Connection Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which of the database connections below you wish
|
||||
| to use as your default connection for database operations. This is
|
||||
| the connection which will be utilized unless another connection
|
||||
| is explicitly specified when you execute a query / statement.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('DB_CONNECTION', 'sqlite'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Database Connections
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Below are all of the database connections defined for your application.
|
||||
| An example configuration is provided for each database system which
|
||||
| is supported by Laravel. You're free to add / remove connections.
|
||||
|
|
||||
*/
|
||||
|
||||
'connections' => [
|
||||
|
||||
'sqlite' => [
|
||||
'driver' => 'sqlite',
|
||||
'url' => env('DB_URL'),
|
||||
'database' => env('DB_DATABASE', database_path('database.sqlite')),
|
||||
'prefix' => '',
|
||||
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
|
||||
'busy_timeout' => null,
|
||||
'journal_mode' => null,
|
||||
'synchronous' => null,
|
||||
'transaction_mode' => 'DEFERRED',
|
||||
],
|
||||
|
||||
'mysql' => [
|
||||
'driver' => 'mysql',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '3306'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'unix_socket' => env('DB_SOCKET', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'strict' => true,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
||||
]) : [],
|
||||
],
|
||||
|
||||
'mariadb' => [
|
||||
'driver' => 'mariadb',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '3306'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'unix_socket' => env('DB_SOCKET', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'strict' => true,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
||||
]) : [],
|
||||
],
|
||||
|
||||
'pgsql' => [
|
||||
'driver' => 'pgsql',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '5432'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'search_path' => 'public',
|
||||
'sslmode' => env('DB_SSLMODE', 'prefer'),
|
||||
],
|
||||
|
||||
'sqlsrv' => [
|
||||
'driver' => 'sqlsrv',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', 'localhost'),
|
||||
'port' => env('DB_PORT', '1433'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
|
||||
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Migration Repository Table
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This table keeps track of all the migrations that have already run for
|
||||
| your application. Using this information, we can determine which of
|
||||
| the migrations on disk haven't actually been run on the database.
|
||||
|
|
||||
*/
|
||||
|
||||
'migrations' => [
|
||||
'table' => 'migrations',
|
||||
'update_date_on_publish' => true,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Redis Databases
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Redis is an open source, fast, and advanced key-value store that also
|
||||
| provides a richer body of commands than a typical key-value system
|
||||
| such as Memcached. You may define your connection settings here.
|
||||
|
|
||||
*/
|
||||
|
||||
'redis' => [
|
||||
|
||||
'client' => env('REDIS_CLIENT', 'phpredis'),
|
||||
|
||||
'options' => [
|
||||
'cluster' => env('REDIS_CLUSTER', 'redis'),
|
||||
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
|
||||
'persistent' => env('REDIS_PERSISTENT', false),
|
||||
],
|
||||
|
||||
'default' => [
|
||||
'url' => env('REDIS_URL'),
|
||||
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||
'username' => env('REDIS_USERNAME'),
|
||||
'password' => env('REDIS_PASSWORD'),
|
||||
'port' => env('REDIS_PORT', '6379'),
|
||||
'database' => env('REDIS_DB', '0'),
|
||||
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||
],
|
||||
|
||||
'cache' => [
|
||||
'url' => env('REDIS_URL'),
|
||||
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||
'username' => env('REDIS_USERNAME'),
|
||||
'password' => env('REDIS_PASSWORD'),
|
||||
'port' => env('REDIS_PORT', '6379'),
|
||||
'database' => env('REDIS_CACHE_DB', '1'),
|
||||
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Filesystem Disk
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the default filesystem disk that should be used
|
||||
| by the framework. The "local" disk, as well as a variety of cloud
|
||||
| based disks are available to your application for file storage.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('FILESYSTEM_DISK', 'local'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Filesystem Disks
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Below you may configure as many filesystem disks as necessary, and you
|
||||
| may even configure multiple disks for the same driver. Examples for
|
||||
| most supported storage drivers are configured here for reference.
|
||||
|
|
||||
| Supported drivers: "local", "ftp", "sftp", "s3"
|
||||
|
|
||||
*/
|
||||
|
||||
'disks' => [
|
||||
|
||||
'local' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/private'),
|
||||
'serve' => true,
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
'public' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/public'),
|
||||
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
|
||||
'visibility' => 'public',
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
's3' => [
|
||||
'driver' => 's3',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION'),
|
||||
'bucket' => env('AWS_BUCKET'),
|
||||
'url' => env('AWS_URL'),
|
||||
'endpoint' => env('AWS_ENDPOINT'),
|
||||
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Symbolic Links
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the symbolic links that will be created when the
|
||||
| `storage:link` Artisan command is executed. The array keys should be
|
||||
| the locations of the links and the values should be their targets.
|
||||
|
|
||||
*/
|
||||
|
||||
'links' => [
|
||||
public_path('storage') => storage_path('app/public'),
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
use Monolog\Handler\NullHandler;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use Monolog\Handler\SyslogUdpHandler;
|
||||
use Monolog\Processor\PsrLogMessageProcessor;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Log Channel
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option defines the default log channel that is utilized to write
|
||||
| messages to your logs. The value provided here should match one of
|
||||
| the channels present in the list of "channels" configured below.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('LOG_CHANNEL', 'stack'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Deprecations Log Channel
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the log channel that should be used to log warnings
|
||||
| regarding deprecated PHP and library features. This allows you to get
|
||||
| your application ready for upcoming major versions of dependencies.
|
||||
|
|
||||
*/
|
||||
|
||||
'deprecations' => [
|
||||
'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'),
|
||||
'trace' => env('LOG_DEPRECATIONS_TRACE', false),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Log Channels
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the log channels for your application. Laravel
|
||||
| utilizes the Monolog PHP logging library, which includes a variety
|
||||
| of powerful log handlers and formatters that you're free to use.
|
||||
|
|
||||
| Available drivers: "single", "daily", "monthly", "slack", "syslog",
|
||||
| "errorlog", "monolog", "custom", "stack"
|
||||
|
|
||||
*/
|
||||
|
||||
'channels' => [
|
||||
|
||||
'stack' => [
|
||||
'driver' => 'stack',
|
||||
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
|
||||
'ignore_exceptions' => false,
|
||||
],
|
||||
|
||||
'single' => [
|
||||
'driver' => 'single',
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'daily' => [
|
||||
'driver' => 'daily',
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'max_files' => env('LOG_DAILY_DAYS', 14),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'monthly' => [
|
||||
'driver' => 'monthly',
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'max_files' => 3,
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'slack' => [
|
||||
'driver' => 'slack',
|
||||
'url' => env('LOG_SLACK_WEBHOOK_URL'),
|
||||
'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')),
|
||||
'emoji' => env('LOG_SLACK_EMOJI', ':boom:'),
|
||||
'level' => env('LOG_LEVEL', 'critical'),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'papertrail' => [
|
||||
'driver' => 'monolog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class),
|
||||
'handler_with' => [
|
||||
'host' => env('PAPERTRAIL_URL'),
|
||||
'port' => env('PAPERTRAIL_PORT'),
|
||||
'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'),
|
||||
],
|
||||
'processors' => [PsrLogMessageProcessor::class],
|
||||
],
|
||||
|
||||
'stderr' => [
|
||||
'driver' => 'monolog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'handler' => StreamHandler::class,
|
||||
'handler_with' => [
|
||||
'stream' => 'php://stderr',
|
||||
],
|
||||
'formatter' => env('LOG_STDERR_FORMATTER'),
|
||||
'processors' => [PsrLogMessageProcessor::class],
|
||||
],
|
||||
|
||||
'syslog' => [
|
||||
'driver' => 'syslog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'errorlog' => [
|
||||
'driver' => 'errorlog',
|
||||
'level' => env('LOG_LEVEL', 'debug'),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'null' => [
|
||||
'driver' => 'monolog',
|
||||
'handler' => NullHandler::class,
|
||||
],
|
||||
|
||||
'emergency' => [
|
||||
'path' => storage_path('logs/laravel.log'),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Mailer
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default mailer that is used to send all email
|
||||
| messages unless another mailer is explicitly specified when sending
|
||||
| the message. All additional mailers can be configured within the
|
||||
| "mailers" array. Examples of each type of mailer are provided.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('MAIL_MAILER', 'log'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Mailer Configurations
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure all of the mailers used by your application plus
|
||||
| their respective settings. Several examples have been configured for
|
||||
| you and you are free to add your own as your application requires.
|
||||
|
|
||||
| Laravel supports a variety of mail "transport" drivers that can be used
|
||||
| when delivering an email. You may specify which one you're using for
|
||||
| your mailers below. You may also add additional mailers if needed.
|
||||
|
|
||||
| Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2",
|
||||
| "postmark", "resend", "log", "array",
|
||||
| "failover", "roundrobin"
|
||||
|
|
||||
*/
|
||||
|
||||
'mailers' => [
|
||||
|
||||
'smtp' => [
|
||||
'transport' => 'smtp',
|
||||
'scheme' => env('MAIL_SCHEME'),
|
||||
'url' => env('MAIL_URL'),
|
||||
'host' => env('MAIL_HOST', '127.0.0.1'),
|
||||
'port' => env('MAIL_PORT', 2525),
|
||||
'username' => env('MAIL_USERNAME'),
|
||||
'password' => env('MAIL_PASSWORD'),
|
||||
'timeout' => null,
|
||||
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
|
||||
],
|
||||
|
||||
'ses' => [
|
||||
'transport' => 'ses',
|
||||
],
|
||||
|
||||
'postmark' => [
|
||||
'transport' => 'postmark',
|
||||
// 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'),
|
||||
// 'client' => [
|
||||
// 'timeout' => 5,
|
||||
// ],
|
||||
],
|
||||
|
||||
'resend' => [
|
||||
'transport' => 'resend',
|
||||
],
|
||||
|
||||
'sendmail' => [
|
||||
'transport' => 'sendmail',
|
||||
'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'),
|
||||
],
|
||||
|
||||
'log' => [
|
||||
'transport' => 'log',
|
||||
'channel' => env('MAIL_LOG_CHANNEL'),
|
||||
],
|
||||
|
||||
'array' => [
|
||||
'transport' => 'array',
|
||||
],
|
||||
|
||||
'failover' => [
|
||||
'transport' => 'failover',
|
||||
'mailers' => [
|
||||
'smtp',
|
||||
'log',
|
||||
],
|
||||
'retry_after' => 60,
|
||||
],
|
||||
|
||||
'roundrobin' => [
|
||||
'transport' => 'roundrobin',
|
||||
'mailers' => [
|
||||
'ses',
|
||||
'postmark',
|
||||
],
|
||||
'retry_after' => 60,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Global "From" Address
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| You may wish for all emails sent by your application to be sent from
|
||||
| the same address. Here you may specify a name and address that is
|
||||
| used globally for all emails that are sent by your application.
|
||||
|
|
||||
*/
|
||||
|
||||
'from' => [
|
||||
'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'),
|
||||
'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')),
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Queue Connection Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Laravel's queue supports a variety of backends via a single, unified
|
||||
| API, giving you convenient access to each backend using identical
|
||||
| syntax for each. The default queue connection is defined below.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('QUEUE_CONNECTION', 'database'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Queue Connections
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the connection options for every queue backend
|
||||
| used by your application. An example configuration is provided for
|
||||
| each backend supported by Laravel. You're also free to add more.
|
||||
|
|
||||
| Drivers: "sync", "database", "beanstalkd", "sqs", "redis",
|
||||
| "deferred", "background", "failover", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'connections' => [
|
||||
|
||||
'sync' => [
|
||||
'driver' => 'sync',
|
||||
],
|
||||
|
||||
'database' => [
|
||||
'driver' => 'database',
|
||||
'connection' => env('DB_QUEUE_CONNECTION'),
|
||||
'table' => env('DB_QUEUE_TABLE', 'jobs'),
|
||||
'queue' => env('DB_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90),
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'beanstalkd' => [
|
||||
'driver' => 'beanstalkd',
|
||||
'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'),
|
||||
'queue' => env('BEANSTALKD_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90),
|
||||
'block_for' => 0,
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'sqs' => [
|
||||
'driver' => 'sqs',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'),
|
||||
'queue' => env('SQS_QUEUE', 'default'),
|
||||
'suffix' => env('SQS_SUFFIX'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'redis' => [
|
||||
'driver' => 'redis',
|
||||
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
|
||||
'queue' => env('REDIS_QUEUE', 'default'),
|
||||
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90),
|
||||
'block_for' => null,
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
'deferred' => [
|
||||
'driver' => 'deferred',
|
||||
],
|
||||
|
||||
'background' => [
|
||||
'driver' => 'background',
|
||||
],
|
||||
|
||||
'failover' => [
|
||||
'driver' => 'failover',
|
||||
'connections' => [
|
||||
'database',
|
||||
'deferred',
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Job Batching
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The following options configure the database and table that store job
|
||||
| batching information. These options can be updated to any database
|
||||
| connection and table which has been defined by your application.
|
||||
|
|
||||
*/
|
||||
|
||||
'batching' => [
|
||||
'database' => env('DB_CONNECTION', 'sqlite'),
|
||||
'table' => 'job_batches',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Failed Queue Jobs
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These options configure the behavior of failed queue job logging so you
|
||||
| can control how and where failed jobs are stored. Laravel ships with
|
||||
| support for storing failed jobs in a simple file or in a database.
|
||||
|
|
||||
| Supported drivers: "database-uuids", "dynamodb", "file", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'failed' => [
|
||||
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
|
||||
'database' => env('DB_CONNECTION', 'sqlite'),
|
||||
'table' => 'failed_jobs',
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
'postmark' => [
|
||||
'key' => env('POSTMARK_API_KEY'),
|
||||
],
|
||||
|
||||
'resend' => [
|
||||
'key' => env('RESEND_API_KEY'),
|
||||
],
|
||||
|
||||
'ses' => [
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
],
|
||||
|
||||
'slack' => [
|
||||
'notifications' => [
|
||||
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
|
||||
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
|
||||
],
|
||||
],
|
||||
|
||||
// SSO Entra du back-office (même App Registration que la synchro Graph).
|
||||
'azure' => [
|
||||
'client_id' => env('GRAPH_CLIENT_ID'),
|
||||
'client_secret' => env('GRAPH_CLIENT_SECRET'),
|
||||
'redirect' => env('APP_URL').'/auth/callback',
|
||||
'tenant' => env('ENTRA_TENANT_ID'),
|
||||
'proxy' => env('PROXY'),
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Session Driver
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option determines the default session driver that is utilized for
|
||||
| incoming requests. Laravel supports a variety of storage options to
|
||||
| persist session data. Database storage is a great default choice.
|
||||
|
|
||||
| Supported: "file", "cookie", "database", "memcached",
|
||||
| "redis", "dynamodb", "array"
|
||||
|
|
||||
*/
|
||||
|
||||
'driver' => env('SESSION_DRIVER', 'database'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Lifetime
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the number of minutes that you wish the session
|
||||
| to be allowed to remain idle before it expires. If you want them
|
||||
| to expire immediately when the browser is closed then you may
|
||||
| indicate that via the expire_on_close configuration option.
|
||||
|
|
||||
*/
|
||||
|
||||
'lifetime' => (int) env('SESSION_LIFETIME', 120),
|
||||
|
||||
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Encryption
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option allows you to easily specify that all of your session data
|
||||
| should be encrypted before it's stored. All encryption is performed
|
||||
| automatically by Laravel and you may use the session like normal.
|
||||
|
|
||||
*/
|
||||
|
||||
'encrypt' => env('SESSION_ENCRYPT', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session File Location
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When utilizing the "file" session driver, the session files are placed
|
||||
| on disk. The default storage location is defined here; however, you
|
||||
| are free to provide another location where they should be stored.
|
||||
|
|
||||
*/
|
||||
|
||||
'files' => storage_path('framework/sessions'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Database Connection
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using the "database" or "redis" session drivers, you may specify a
|
||||
| connection that should be used to manage these sessions. This should
|
||||
| correspond to a connection in your database configuration options.
|
||||
|
|
||||
*/
|
||||
|
||||
'connection' => env('SESSION_CONNECTION'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Database Table
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using the "database" session driver, you may specify the table to
|
||||
| be used to store sessions. Of course, a sensible default is defined
|
||||
| for you; however, you're welcome to change this to another table.
|
||||
|
|
||||
*/
|
||||
|
||||
'table' => env('SESSION_TABLE', 'sessions'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cache Store
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When using one of the framework's cache driven session backends, you may
|
||||
| define the cache store which should be used to store the session data
|
||||
| between requests. This must match one of your defined cache stores.
|
||||
|
|
||||
| Affects: "dynamodb", "memcached", "redis"
|
||||
|
|
||||
*/
|
||||
|
||||
'store' => env('SESSION_STORE'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Sweeping Lottery
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Some session drivers must manually sweep their storage location to get
|
||||
| rid of old sessions from storage. Here are the chances that it will
|
||||
| happen on a given request. By default, the odds are 2 out of 100.
|
||||
|
|
||||
*/
|
||||
|
||||
'lottery' => [2, 100],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cookie Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may change the name of the session cookie that is created by
|
||||
| the framework. Typically, you should not need to change this value
|
||||
| since doing so does not grant a meaningful security improvement.
|
||||
|
|
||||
*/
|
||||
|
||||
'cookie' => env(
|
||||
'SESSION_COOKIE',
|
||||
Str::slug((string) env('APP_NAME', 'laravel')).'-session'
|
||||
),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cookie Path
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The session cookie path determines the path for which the cookie will
|
||||
| be regarded as available. Typically, this will be the root path of
|
||||
| your application, but you're free to change this when necessary.
|
||||
|
|
||||
*/
|
||||
|
||||
'path' => env('SESSION_PATH', '/'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Cookie Domain
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value determines the domain and subdomains the session cookie is
|
||||
| available to. By default, the cookie will be available to the root
|
||||
| domain without subdomains. Typically, this shouldn't be changed.
|
||||
|
|
||||
*/
|
||||
|
||||
'domain' => env('SESSION_DOMAIN'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HTTPS Only Cookies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| By setting this option to true, session cookies will only be sent back
|
||||
| to the server if the browser has a HTTPS connection. This will keep
|
||||
| the cookie from being sent to you when it can't be done securely.
|
||||
|
|
||||
*/
|
||||
|
||||
'secure' => env('SESSION_SECURE_COOKIE'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| HTTP Access Only
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Setting this value to true will prevent JavaScript from accessing the
|
||||
| value of the cookie and the cookie will only be accessible through
|
||||
| the HTTP protocol. It's unlikely you should disable this option.
|
||||
|
|
||||
*/
|
||||
|
||||
'http_only' => env('SESSION_HTTP_ONLY', true),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Same-Site Cookies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option determines how your cookies behave when cross-site requests
|
||||
| take place, and can be used to mitigate CSRF attacks. By default, we
|
||||
| will set this value to "lax" to permit secure cross-site requests.
|
||||
|
|
||||
| See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
||||
|
|
||||
| Supported: "lax", "strict", "none", null
|
||||
|
|
||||
*/
|
||||
|
||||
'same_site' => env('SESSION_SAME_SITE', 'lax'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Partitioned Cookies
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Setting this value to true will tie the cookie to the top-level site for
|
||||
| a cross-site context. Partitioned cookies are accepted by the browser
|
||||
| when flagged "secure" and the Same-Site attribute is set to "none".
|
||||
|
|
||||
*/
|
||||
|
||||
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Session Serialization
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value controls the serialization strategy for session data, which
|
||||
| is JSON by default. Setting this to "php" allows the storage of PHP
|
||||
| objects in the session but can make an application vulnerable to
|
||||
| "gadget chain" serialization attacks if the APP_KEY is leaked.
|
||||
|
|
||||
| Supported: "json", "php"
|
||||
|
|
||||
*/
|
||||
|
||||
'serialization' => 'json',
|
||||
|
||||
];
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Signatures CABM
|
||||
|--------------------------------------------------------------------------
|
||||
| Paramètres de l'application. Les valeurs sensibles viennent du .env.
|
||||
| Voir docs/01-entra-app-registrations.md pour l'origine de chaque identifiant.
|
||||
*/
|
||||
|
||||
return [
|
||||
|
||||
// Tenant d'origine (CABM). D'autres tenants (communes) pourront être ajoutés en base plus tard.
|
||||
'default_tenant' => [
|
||||
'name' => env('SIGNATURE_TENANT_NAME', 'CA Béziers Méditerranée'),
|
||||
'entra_tenant_id' => env('ENTRA_TENANT_ID'),
|
||||
],
|
||||
|
||||
// App Registration « back-office » (confidentielle) : connexion admin + synchro Graph.
|
||||
'graph' => [
|
||||
'client_id' => env('GRAPH_CLIENT_ID'),
|
||||
'client_secret' => env('GRAPH_CLIENT_SECRET'),
|
||||
'base_url' => 'https://graph.microsoft.com/v1.0',
|
||||
// Seuls les groupes dont le nom commence par ce préfixe sont synchronisés
|
||||
// (signature_mail_* pour les templates, signature_role_* pour les rôles).
|
||||
'group_prefix' => env('SIGNATURE_GROUP_PREFIX', 'signature_'),
|
||||
'timeout' => 30,
|
||||
],
|
||||
|
||||
// App Registration « add-in » (SPA + NAA) : audience des jetons reçus par l'API.
|
||||
'addin' => [
|
||||
'client_id' => env('ADDIN_CLIENT_ID'),
|
||||
// Audiences acceptées (client id et/ou Application ID URI).
|
||||
'audiences' => array_filter(array_map('trim', explode(',', (string) env('ADDIN_API_AUDIENCES', env('ADDIN_CLIENT_ID', ''))))),
|
||||
'required_scope' => env('ADDIN_API_SCOPE_NAME', 'access_as_user'),
|
||||
'jwks_cache_seconds' => 86400,
|
||||
'leeway_seconds' => 120,
|
||||
],
|
||||
|
||||
// Rôles du back-office : nom exact du groupe AD synchronisé -> rôle.
|
||||
// Un utilisateur membre de plusieurs groupes reçoit le rôle le plus élevé.
|
||||
'roles' => [
|
||||
'superadmin' => env('SIGNATURE_ROLE_SUPERADMIN_GROUP', 'signature_role_superadmin'),
|
||||
'admin' => env('SIGNATURE_ROLE_ADMIN_GROUP', 'signature_role_admin'),
|
||||
'editor' => env('SIGNATURE_ROLE_EDITOR_GROUP', 'signature_role_editeur'),
|
||||
],
|
||||
|
||||
// Variante utilisée en réponse / transfert quand l'agent n'a rien choisi : short | full | none
|
||||
'default_reply_variant' => env('SIGNATURE_DEFAULT_REPLY_VARIANT', 'short'),
|
||||
|
||||
// URL publique (images, liens).
|
||||
'public_url' => rtrim((string) env('APP_URL', 'https://signature.beziers-mediterranee.fr'), '/'),
|
||||
|
||||
// Réseaux autorisés pour /admin (vide = pas de filtrage). Ex. "10.0.0.0/8,192.168.0.0/16"
|
||||
'admin_allowed_cidrs' => array_filter(array_map('trim', explode(',', (string) env('SIGNATURE_ADMIN_ALLOWED_CIDRS', '')))),
|
||||
];
|
||||
@@ -0,0 +1 @@
|
||||
*.sqlite*
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<User>
|
||||
*/
|
||||
class UserFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* The current password being used by the factory.
|
||||
*/
|
||||
protected static ?string $password;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => fake()->name(),
|
||||
'email' => fake()->unique()->safeEmail(),
|
||||
'email_verified_at' => now(),
|
||||
'password' => static::$password ??= Hash::make('password'),
|
||||
'remember_token' => Str::random(10),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that the model's email address should be unverified.
|
||||
*/
|
||||
public function unverified(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('users', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('email')->unique();
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->string('password');
|
||||
$table->rememberToken();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('password_reset_tokens', function (Blueprint $table) {
|
||||
$table->string('email')->primary();
|
||||
$table->string('token');
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('sessions', function (Blueprint $table) {
|
||||
$table->string('id')->primary();
|
||||
$table->foreignId('user_id')->nullable()->index();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->text('user_agent')->nullable();
|
||||
$table->longText('payload');
|
||||
$table->integer('last_activity')->index();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('users');
|
||||
Schema::dropIfExists('password_reset_tokens');
|
||||
Schema::dropIfExists('sessions');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('cache', function (Blueprint $table) {
|
||||
$table->string('key')->primary();
|
||||
$table->mediumText('value');
|
||||
$table->bigInteger('expiration')->index();
|
||||
});
|
||||
|
||||
Schema::create('cache_locks', function (Blueprint $table) {
|
||||
$table->string('key')->primary();
|
||||
$table->string('owner');
|
||||
$table->bigInteger('expiration')->index();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('cache');
|
||||
Schema::dropIfExists('cache_locks');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('jobs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('queue')->index();
|
||||
$table->longText('payload');
|
||||
$table->unsignedSmallInteger('attempts');
|
||||
$table->unsignedInteger('reserved_at')->nullable();
|
||||
$table->unsignedInteger('available_at');
|
||||
$table->unsignedInteger('created_at');
|
||||
});
|
||||
|
||||
Schema::create('job_batches', function (Blueprint $table) {
|
||||
$table->string('id')->primary();
|
||||
$table->string('name');
|
||||
$table->integer('total_jobs');
|
||||
$table->integer('pending_jobs');
|
||||
$table->integer('failed_jobs');
|
||||
$table->longText('failed_job_ids');
|
||||
$table->mediumText('options')->nullable();
|
||||
$table->integer('cancelled_at')->nullable();
|
||||
$table->integer('created_at');
|
||||
$table->integer('finished_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('failed_jobs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('uuid')->unique();
|
||||
$table->string('connection');
|
||||
$table->string('queue');
|
||||
$table->longText('payload');
|
||||
$table->longText('exception');
|
||||
$table->timestamp('failed_at')->useCurrent();
|
||||
|
||||
$table->index(['connection', 'queue', 'failed_at']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('jobs');
|
||||
Schema::dropIfExists('job_batches');
|
||||
Schema::dropIfExists('failed_jobs');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Modèle de données des signatures.
|
||||
* Toutes les tables métier portent un tenant_id : c'est ce qui permettra
|
||||
* d'accueillir une commune membre sans refonte.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('tenants', function (Blueprint $t) {
|
||||
$t->id();
|
||||
$t->string('name');
|
||||
$t->uuid('entra_tenant_id')->unique();
|
||||
$t->boolean('active')->default(true);
|
||||
$t->text('users_delta_link')->nullable();
|
||||
$t->timestamp('last_sync_at')->nullable();
|
||||
$t->string('last_sync_status')->nullable();
|
||||
$t->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('directory_users', function (Blueprint $t) {
|
||||
$t->id();
|
||||
$t->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
||||
$t->uuid('entra_oid');
|
||||
$t->string('display_name')->nullable();
|
||||
$t->string('given_name')->nullable();
|
||||
$t->string('surname')->nullable();
|
||||
$t->string('job_title')->nullable();
|
||||
$t->string('department')->nullable();
|
||||
$t->string('company_name')->nullable();
|
||||
$t->string('office_location')->nullable();
|
||||
$t->string('business_phone')->nullable();
|
||||
$t->string('mobile_phone')->nullable();
|
||||
$t->string('mail')->nullable()->index();
|
||||
$t->string('user_principal_name')->nullable()->index();
|
||||
$t->string('street_address')->nullable();
|
||||
$t->string('postal_code')->nullable();
|
||||
$t->string('city')->nullable();
|
||||
$t->boolean('account_enabled')->default(true);
|
||||
$t->timestamp('synced_at')->nullable();
|
||||
$t->softDeletes();
|
||||
$t->timestamps();
|
||||
$t->unique(['tenant_id', 'entra_oid']);
|
||||
});
|
||||
|
||||
Schema::create('directory_groups', function (Blueprint $t) {
|
||||
$t->id();
|
||||
$t->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
||||
$t->uuid('entra_oid');
|
||||
$t->string('display_name');
|
||||
$t->timestamp('synced_at')->nullable();
|
||||
$t->timestamps();
|
||||
$t->unique(['tenant_id', 'entra_oid']);
|
||||
});
|
||||
|
||||
// Appartenance transitive (groupes imbriqués inclus).
|
||||
Schema::create('group_members', function (Blueprint $t) {
|
||||
$t->foreignId('directory_group_id')->constrained()->cascadeOnDelete();
|
||||
$t->foreignId('directory_user_id')->constrained()->cascadeOnDelete();
|
||||
$t->primary(['directory_group_id', 'directory_user_id']);
|
||||
});
|
||||
|
||||
Schema::create('templates', function (Blueprint $t) {
|
||||
$t->id();
|
||||
$t->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
||||
$t->string('name');
|
||||
$t->text('description')->nullable();
|
||||
// Contenu publié (celui que sert l'API).
|
||||
$t->longText('html_full')->nullable();
|
||||
$t->longText('html_short')->nullable();
|
||||
// Brouillon en cours d'édition (éditeur visuel / HTML).
|
||||
$t->longText('draft_html_full')->nullable();
|
||||
$t->longText('draft_html_short')->nullable();
|
||||
$t->json('editor_state')->nullable();
|
||||
$t->boolean('is_default')->default(false);
|
||||
$t->unsignedInteger('published_version')->default(0);
|
||||
$t->timestamp('published_at')->nullable();
|
||||
$t->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('template_versions', function (Blueprint $t) {
|
||||
$t->id();
|
||||
$t->foreignId('template_id')->constrained()->cascadeOnDelete();
|
||||
$t->unsignedInteger('version');
|
||||
$t->longText('html_full')->nullable();
|
||||
$t->longText('html_short')->nullable();
|
||||
$t->foreignId('published_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$t->timestamps();
|
||||
$t->unique(['template_id', 'version']);
|
||||
});
|
||||
|
||||
// Template <-> groupe AD, avec priorité (la plus haute l'emporte).
|
||||
Schema::create('assignments', function (Blueprint $t) {
|
||||
$t->id();
|
||||
$t->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
||||
$t->foreignId('template_id')->constrained()->cascadeOnDelete();
|
||||
$t->foreignId('directory_group_id')->constrained()->cascadeOnDelete();
|
||||
$t->integer('priority')->default(10);
|
||||
$t->timestamps();
|
||||
$t->unique(['template_id', 'directory_group_id']);
|
||||
});
|
||||
|
||||
// Champs ouverts à la modification par les agents (choix de l'admin).
|
||||
Schema::create('editable_fields', function (Blueprint $t) {
|
||||
$t->id();
|
||||
$t->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
||||
$t->string('field'); // clé de variable : mobile, telephone, bureau…
|
||||
$t->string('label');
|
||||
$t->boolean('enabled')->default(false);
|
||||
$t->string('validation_rule')->nullable(); // règle Laravel, ex. "regex:/^[0-9 +.]{6,20}$/"
|
||||
$t->timestamps();
|
||||
$t->unique(['tenant_id', 'field']);
|
||||
});
|
||||
|
||||
// Surcharges saisies par l'agent : publication immédiate, tracée dans audit_logs.
|
||||
Schema::create('user_overrides', function (Blueprint $t) {
|
||||
$t->id();
|
||||
$t->foreignId('directory_user_id')->constrained()->cascadeOnDelete();
|
||||
$t->string('field');
|
||||
$t->string('value')->nullable();
|
||||
$t->timestamps();
|
||||
$t->unique(['directory_user_id', 'field']);
|
||||
});
|
||||
|
||||
Schema::create('user_preferences', function (Blueprint $t) {
|
||||
$t->id();
|
||||
$t->foreignId('directory_user_id')->unique()->constrained()->cascadeOnDelete();
|
||||
$t->string('reply_variant')->nullable(); // short | full | none (null = défaut du tenant)
|
||||
$t->timestamps();
|
||||
});
|
||||
|
||||
// Boîtes partagées : template dédié et choix d'afficher ou non l'agent expéditeur.
|
||||
Schema::create('shared_mailboxes', function (Blueprint $t) {
|
||||
$t->id();
|
||||
$t->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
||||
$t->string('address');
|
||||
$t->string('display_name')->nullable();
|
||||
$t->foreignId('template_id')->nullable()->constrained()->nullOnDelete();
|
||||
$t->boolean('include_sender')->default(false);
|
||||
$t->timestamps();
|
||||
$t->unique(['tenant_id', 'address']);
|
||||
});
|
||||
|
||||
Schema::create('campaigns', function (Blueprint $t) {
|
||||
$t->id();
|
||||
$t->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
||||
$t->string('name');
|
||||
$t->string('image_path');
|
||||
$t->string('alt_text');
|
||||
$t->string('link_url')->nullable();
|
||||
$t->timestamp('starts_at');
|
||||
$t->timestamp('ends_at');
|
||||
$t->integer('priority')->default(0);
|
||||
$t->boolean('active')->default(true);
|
||||
$t->timestamps();
|
||||
});
|
||||
|
||||
// Ciblage des campagnes (aucune ligne = tous les agents).
|
||||
Schema::create('campaign_group', function (Blueprint $t) {
|
||||
$t->foreignId('campaign_id')->constrained()->cascadeOnDelete();
|
||||
$t->foreignId('directory_group_id')->constrained()->cascadeOnDelete();
|
||||
$t->primary(['campaign_id', 'directory_group_id']);
|
||||
});
|
||||
|
||||
Schema::create('audit_logs', function (Blueprint $t) {
|
||||
$t->id();
|
||||
$t->foreignId('tenant_id')->nullable()->constrained()->nullOnDelete();
|
||||
$t->string('actor_type'); // admin | agent | system
|
||||
$t->string('actor')->nullable();
|
||||
$t->string('action');
|
||||
$t->string('subject_type')->nullable();
|
||||
$t->unsignedBigInteger('subject_id')->nullable();
|
||||
$t->json('changes')->nullable();
|
||||
$t->string('ip', 45)->nullable();
|
||||
$t->timestamp('created_at')->useCurrent();
|
||||
$t->index(['subject_type', 'subject_id']);
|
||||
});
|
||||
|
||||
// Comptes du back-office (connexion SSO Entra, pas de mot de passe local).
|
||||
Schema::table('users', function (Blueprint $t) {
|
||||
$t->foreignId('tenant_id')->nullable()->after('id')->constrained()->nullOnDelete();
|
||||
$t->uuid('entra_oid')->nullable()->unique()->after('tenant_id');
|
||||
$t->string('role')->nullable()->after('email'); // superadmin | admin | editor | null
|
||||
$t->string('password')->nullable()->change();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $t) {
|
||||
$t->dropConstrainedForeignId('tenant_id');
|
||||
$t->dropColumn(['entra_oid', 'role']);
|
||||
});
|
||||
foreach ([
|
||||
'audit_logs', 'campaign_group', 'campaigns', 'shared_mailboxes', 'user_preferences',
|
||||
'user_overrides', 'editable_fields', 'assignments', 'template_versions', 'templates',
|
||||
'group_members', 'directory_groups', 'directory_users', 'tenants',
|
||||
] as $table) {
|
||||
Schema::dropIfExists($table);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class DatabaseSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$this->call(SignatureSeeder::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\EditableField;
|
||||
use App\Models\Template;
|
||||
use App\Models\Tenant;
|
||||
use App\Signature\VariableMap;
|
||||
use Illuminate\Database\Seeder;
|
||||
use RuntimeException;
|
||||
|
||||
/** Données initiales : tenant CABM, champs modifiables (tous fermés), template par défaut. */
|
||||
class SignatureSeeder extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
$tid = config('signature.default_tenant.entra_tenant_id');
|
||||
if (! $tid) {
|
||||
throw new RuntimeException('ENTRA_TENANT_ID absent du .env');
|
||||
}
|
||||
|
||||
$tenant = Tenant::firstOrCreate(
|
||||
['entra_tenant_id' => $tid],
|
||||
['name' => config('signature.default_tenant.name'), 'active' => true],
|
||||
);
|
||||
|
||||
// L'admin choisira dans le back-office les champs ouverts aux agents.
|
||||
foreach (['mobile', 'telephone', 'bureau', 'fonction'] as $field) {
|
||||
EditableField::firstOrCreate(
|
||||
['tenant_id' => $tenant->id, 'field' => $field],
|
||||
['label' => VariableMap::FIELDS[$field]['label'], 'enabled' => false],
|
||||
);
|
||||
}
|
||||
|
||||
if (! Template::where('tenant_id', $tenant->id)->exists()) {
|
||||
$template = Template::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Agents — défaut',
|
||||
'description' => 'Template par défaut, appliqué aux agents hors groupe signature_mail_*.',
|
||||
'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')),
|
||||
'is_default' => true,
|
||||
]);
|
||||
$template->publish();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:18px;color:#1b1b1b;border-collapse:collapse">
|
||||
<tr>
|
||||
<td style="padding:0 16px 0 0;vertical-align:top;border-right:2px solid #0b5cad">
|
||||
<img src="{{site_url}}/img/logo-cabm.png" alt="Agglo Béziers Méditerranée" width="120" style="display:block;border:0">
|
||||
</td>
|
||||
<td style="padding:0 0 0 16px;vertical-align:top">
|
||||
<strong style="font-size:14px">{{prenom}} {{nom}}</strong><br>
|
||||
{{#if fonction}}{{fonction}}<br>{{/if}}
|
||||
{{#if service}}<span style="color:#5c5c5c">{{service}}</span><br>{{/if}}
|
||||
{{#if telephone}}Tél. {{telephone}}<br>{{/if}}
|
||||
{{#if mobile}}Mob. {{mobile}}<br>{{/if}}
|
||||
{{#if adresse}}<span style="color:#5c5c5c">{{adresse}}{{#if code_postal}}, {{code_postal}} {{ville}}{{/if}}</span><br>{{/if}}
|
||||
<a href="https://www.beziers-mediterranee.fr" style="color:#0b5cad;text-decoration:none">beziers-mediterranee.fr</a>
|
||||
</td>
|
||||
</tr>
|
||||
{{#if banniere}}<tr><td colspan="2" style="padding:12px 0 0 0">{{{banniere}}}</td></tr>{{/if}}
|
||||
</table>
|
||||
@@ -0,0 +1,4 @@
|
||||
<p style="font-family:Arial,Helvetica,sans-serif;font-size:13px;line-height:18px;color:#1b1b1b;margin:0">
|
||||
<strong>{{prenom}} {{nom}}</strong>{{#if fonction}} · {{fonction}}{{/if}}<br>
|
||||
Agglo Béziers Méditerranée{{#if telephone}} · {{telephone}}{{/if}}
|
||||
</p>
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"$schema": "https://www.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "vite"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"concurrently": "^10.0.3",
|
||||
"laravel-vite-plugin": "^3.1",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"vite": "^8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@laravel/multiplex": "^0.4.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||
bootstrap="vendor/autoload.php"
|
||||
colors="true"
|
||||
>
|
||||
<testsuites>
|
||||
<testsuite name="Unit">
|
||||
<directory>tests/Unit</directory>
|
||||
</testsuite>
|
||||
<testsuite name="Feature">
|
||||
<directory>tests/Feature</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
<source>
|
||||
<include>
|
||||
<directory>app</directory>
|
||||
</include>
|
||||
</source>
|
||||
<php>
|
||||
<env name="APP_ENV" value="testing"/>
|
||||
<env name="APP_MAINTENANCE_DRIVER" value="file"/>
|
||||
<env name="BCRYPT_ROUNDS" value="4"/>
|
||||
<env name="BROADCAST_CONNECTION" value="null"/>
|
||||
<env name="CACHE_STORE" value="array"/>
|
||||
<env name="DB_CONNECTION" value="sqlite"/>
|
||||
<env name="DB_DATABASE" value=":memory:"/>
|
||||
<env name="DB_URL" value=""/>
|
||||
<env name="MAIL_MAILER" value="array"/>
|
||||
<env name="QUEUE_CONNECTION" value="sync"/>
|
||||
<env name="SESSION_DRIVER" value="array"/>
|
||||
<env name="PULSE_ENABLED" value="false"/>
|
||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
@@ -0,0 +1,25 @@
|
||||
<IfModule mod_rewrite.c>
|
||||
<IfModule mod_negotiation.c>
|
||||
Options -MultiViews -Indexes
|
||||
</IfModule>
|
||||
|
||||
RewriteEngine On
|
||||
|
||||
# Handle Authorization Header
|
||||
RewriteCond %{HTTP:Authorization} .
|
||||
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
|
||||
|
||||
# Handle X-XSRF-Token Header
|
||||
RewriteCond %{HTTP:x-xsrf-token} .
|
||||
RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}]
|
||||
|
||||
# Redirect Trailing Slashes If Not A Folder...
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{REQUEST_URI} (.+)/$
|
||||
RewriteRule ^ %1 [L,R=301]
|
||||
|
||||
# Send Requests To Front Controller...
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteRule ^ index.php [L]
|
||||
</IfModule>
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
define('LARAVEL_START', microtime(true));
|
||||
|
||||
// Determine if the application is in maintenance mode...
|
||||
if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) {
|
||||
require $maintenance;
|
||||
}
|
||||
|
||||
// Register the Composer autoloader...
|
||||
require __DIR__.'/../vendor/autoload.php';
|
||||
|
||||
// Bootstrap Laravel and handle the request...
|
||||
/** @var Application $app */
|
||||
$app = require_once __DIR__.'/../bootstrap/app.php';
|
||||
|
||||
$app->handleRequest(Request::capture());
|
||||
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -0,0 +1,9 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
|
||||
@source '../../storage/framework/views/*.php';
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
|
||||
'Segoe UI Symbol', 'Noto Color Emoji';
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
//
|
||||
@@ -0,0 +1,42 @@
|
||||
@extends('layouts.app')
|
||||
@section('title', 'Agents — Signatures CABM')
|
||||
@section('content')
|
||||
<h1>Agents</h1>
|
||||
<form method="get" class="row" style="margin-bottom:16px">
|
||||
<input type="search" name="q" value="{{ $q }}" placeholder="Nom, adresse ou service" style="max-width:360px" aria-label="Rechercher un agent">
|
||||
<button class="btn" type="submit">Rechercher</button>
|
||||
<span class="muted">{{ $agents->total() }} agent(s)</span>
|
||||
</form>
|
||||
<div class="card">
|
||||
<table class="list">
|
||||
<thead><tr><th>Agent</th><th>Service</th><th>Groupes signature_*</th><th>Template appliqué</th></tr></thead>
|
||||
<tbody>
|
||||
@forelse ($agents as $a)
|
||||
<tr>
|
||||
<td><a href="{{ route('admin.agents.show', $a) }}">{{ $a->display_name }}</a><div class="muted">{{ $a->mail }}</div></td>
|
||||
<td>{{ $a->department }}</td>
|
||||
<td class="muted">{{ $a->groups->pluck('display_name')->join(', ') ?: '—' }}</td>
|
||||
<td>{{ $resolved[$a->id] ?? '— aucun —' }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="4" class="muted">Aucun agent trouvé.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
@if ($agents->hasPages())
|
||||
<nav class="row" style="margin-top:12px" aria-label="Pagination">
|
||||
@if ($agents->onFirstPage())
|
||||
<span class="muted">← Précédent</span>
|
||||
@else
|
||||
<a href="{{ $agents->previousPageUrl() }}" rel="prev">← Précédent</a>
|
||||
@endif
|
||||
<span class="muted">Page {{ $agents->currentPage() }} / {{ $agents->lastPage() }}</span>
|
||||
@if ($agents->hasMorePages())
|
||||
<a href="{{ $agents->nextPageUrl() }}" rel="next">Suivant →</a>
|
||||
@else
|
||||
<span class="muted">Suivant →</span>
|
||||
@endif
|
||||
</nav>
|
||||
@endif
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,33 @@
|
||||
@extends('layouts.app')
|
||||
@section('title', $agent->display_name.' — Signatures CABM')
|
||||
@section('content')
|
||||
<div class="row" style="margin-bottom:16px">
|
||||
<a href="{{ route('admin.agents.index') }}">← Agents</a>
|
||||
<h1 style="margin:0 0 0 12px">{{ $agent->display_name }}</h1>
|
||||
</div>
|
||||
<div class="split">
|
||||
<div class="card">
|
||||
<h2>Annuaire (Entra)</h2>
|
||||
<table class="list">
|
||||
<tr><th>Adresse</th><td>{{ $agent->mail }}</td></tr>
|
||||
<tr><th>Fonction</th><td>{{ $agent->job_title }}</td></tr>
|
||||
<tr><th>Service</th><td>{{ $agent->department }}</td></tr>
|
||||
<tr><th>Téléphone</th><td>{{ $agent->business_phone }}</td></tr>
|
||||
<tr><th>Mobile</th><td>{{ $agent->mobile_phone }}</td></tr>
|
||||
<tr><th>Groupes signature_*</th><td>{{ $agent->groups->pluck('display_name')->join(', ') ?: '—' }}</td></tr>
|
||||
<tr><th>Template appliqué</th><td>
|
||||
@if ($template)
|
||||
<a href="{{ route('admin.templates.edit', $template) }}">{{ $template->name }}</a>
|
||||
@else — aucun (pas de template par défaut publié) — @endif
|
||||
</td></tr>
|
||||
<tr><th>Synchronisé le</th><td class="muted">{{ $agent->synced_at?->timezone('Europe/Paris')->format('d/m/Y H:i') }}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Nouveau message</h2>
|
||||
@include('partials.signature-frame', ['html' => $full['html'] ?? ''])
|
||||
<h2 style="margin-top:16px">Réponse / transfert</h2>
|
||||
@include('partials.signature-frame', ['html' => $reply['html'] ?? ''])
|
||||
</div>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,79 @@
|
||||
@extends('layouts.app')
|
||||
@section('title', 'Affectations — Signatures CABM')
|
||||
@section('content')
|
||||
<h1>Affectations</h1>
|
||||
<p class="muted" style="margin-top:-8px">
|
||||
Un agent membre de plusieurs groupes reçoit le template de l'affectation dont la <strong>priorité est la plus haute</strong>
|
||||
(à égalité : la plus ancienne). Sans groupe affecté, il reçoit le template par défaut.
|
||||
Tous les groupes AD synchronisés sont proposés, sauf les groupes de rôles du back-office.
|
||||
</p>
|
||||
|
||||
<div class="card">
|
||||
<h2>Ajouter une affectation</h2>
|
||||
<form method="post" action="{{ route('admin.assignments.store') }}" class="row" style="align-items:flex-end">
|
||||
@csrf
|
||||
<div style="flex:2;min-width:220px">
|
||||
<label for="directory_group_id">Groupe AD</label>
|
||||
<select id="directory_group_id" name="directory_group_id" required>
|
||||
<option value="">— choisir —</option>
|
||||
@if ($groups->isEmpty())
|
||||
<option value="" disabled>Aucun groupe synchronisé : lancez php artisan signature:sync --full</option>
|
||||
@endif
|
||||
@foreach ($groups as $g)
|
||||
<option value="{{ $g->id }}" @selected(old('directory_group_id') == $g->id)>{{ $g->display_name }} ({{ $g->members_count }})</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div style="flex:2;min-width:220px">
|
||||
<label for="template_id">Template</label>
|
||||
<select id="template_id" name="template_id" required>
|
||||
<option value="">— choisir —</option>
|
||||
@foreach ($templates as $t)
|
||||
<option value="{{ $t->id }}" @selected(old('template_id') == $t->id)>{{ $t->name }}{{ $t->html_full ? '' : ' (non publié)' }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="priority">Priorité</label>
|
||||
<input class="small" type="number" id="priority" name="priority" min="0" max="1000" value="{{ old('priority', 10) }}" required>
|
||||
</div>
|
||||
<button class="btn" type="submit">Ajouter</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Affectations actives, par ordre de priorité</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Priorité</th><th>Groupe AD</th><th>Membres</th><th>Template</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
@forelse ($assignments as $a)
|
||||
<tr>
|
||||
<td>
|
||||
<form class="row" method="post" action="{{ route('admin.assignments.update', $a) }}">
|
||||
@csrf @method('PUT')
|
||||
<input class="small" type="number" name="priority" min="0" max="1000" value="{{ $a->priority }}" aria-label="Priorité">
|
||||
<button class="btn secondary small" type="submit">OK</button>
|
||||
</form>
|
||||
</td>
|
||||
<td>{{ $a->group?->display_name }}</td>
|
||||
<td>{{ $a->group?->members_count }}</td>
|
||||
<td>
|
||||
<a href="{{ route('admin.templates.edit', $a->template_id) }}">{{ $a->template?->name }}</a>
|
||||
@unless ($a->template?->html_full) <span class="badge warn">non publié : ignoré</span> @endunless
|
||||
</td>
|
||||
<td style="text-align:right">
|
||||
<form class="inline" method="post" action="{{ route('admin.assignments.destroy', $a) }}"
|
||||
onsubmit="return confirm('Supprimer cette affectation ?')">
|
||||
@csrf @method('DELETE')
|
||||
<button class="btn danger small" type="submit">Supprimer</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="5" class="muted">Aucune affectation : tous les agents reçoivent le template par défaut.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="muted">Vérifiez le résultat agent par agent dans l'onglet <a href="{{ route('admin.agents.index') }}">Agents</a>.</p>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,55 @@
|
||||
@extends('layouts.app')
|
||||
@section('title', 'Back-office — Signatures CABM')
|
||||
@section('content')
|
||||
<div class="card">
|
||||
<h1>{{ $tenant->name }}</h1>
|
||||
<div class="row" style="margin-bottom:12px">
|
||||
<p class="muted" style="margin:0">
|
||||
Dernière synchro annuaire :
|
||||
{{ $tenant->last_sync_at?->timezone('Europe/Paris')->format('d/m/Y H:i') ?? 'jamais' }}
|
||||
— {{ $tenant->last_sync_status ?? 'n/a' }}
|
||||
<br>Automatique toutes les 15 minutes (incrémentale) et chaque nuit (complète).
|
||||
</p>
|
||||
<span class="spacer"></span>
|
||||
@if (auth()->user()->hasRole('admin'))
|
||||
<form method="post" action="{{ route('admin.sync') }}" class="row" id="sync-form">
|
||||
@csrf
|
||||
<label class="muted" style="margin:0;font-weight:400">
|
||||
<input type="checkbox" name="full" value="1"> complète
|
||||
</label>
|
||||
<button class="btn" type="submit" id="sync-btn">Synchroniser maintenant</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div><div class="stat">{{ $users }}</div><div class="muted">agents synchronisés</div></div>
|
||||
<div><div class="stat">{{ $groups->count() }}</div><div class="muted">groupes signature_*</div></div>
|
||||
<div><div class="stat"><a href="{{ route('admin.templates.index') }}">{{ $templates }}</a></div><div class="muted">templates</div></div>
|
||||
<div><div class="stat">{{ $assignments }}</div><div class="muted">affectations</div></div>
|
||||
<div><div class="stat">{{ $campaigns }}</div><div class="muted">campagnes en cours</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Groupes synchronisés</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Groupe</th><th>Membres (transitifs)</th></tr></thead>
|
||||
<tbody>
|
||||
@forelse ($groups as $g)
|
||||
<tr><td>{{ $g->display_name }}</td><td>{{ $g->members_count }}</td></tr>
|
||||
@empty
|
||||
<tr><td colspan="2" class="muted">Aucun groupe : lancez « php artisan signature:sync --full ».</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
document.getElementById('sync-form')?.addEventListener('submit', () => {
|
||||
const b = document.getElementById('sync-btn');
|
||||
b.disabled = true;
|
||||
b.textContent = 'Synchronisation en cours…';
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
@@ -0,0 +1,231 @@
|
||||
@extends('layouts.app')
|
||||
@section('title', ($template->exists ? $template->name : 'Nouveau template').' — Signatures CABM')
|
||||
@section('content')
|
||||
@php
|
||||
$isAdmin = auth()->user()->hasRole('admin');
|
||||
$dirty = $template->exists && ($template->draft_html_full !== $template->html_full || $template->draft_html_short !== $template->html_short);
|
||||
$tag = fn (string $inner) => '{'.'{'.$inner.'}'.'}';
|
||||
$snippets = [
|
||||
'banniere' => '{'.'{'.'{banniere}'.'}'.'}',
|
||||
'site_url' => $tag('site_url'),
|
||||
'if' => $tag('#if mobile').'Mob. '.$tag('mobile').'<br>'.$tag('/if'),
|
||||
'if_open' => $tag('#if variable'),
|
||||
'if_close' => $tag('/if'),
|
||||
'img' => $tag('site_url').'/img/fichier.png',
|
||||
];
|
||||
@endphp
|
||||
|
||||
<div class="row" style="margin-bottom:16px">
|
||||
<a href="{{ route('admin.templates.index') }}">← Templates</a>
|
||||
<h1 style="margin:0 0 0 12px">{{ $template->exists ? $template->name : 'Nouveau template' }}</h1>
|
||||
@if ($template->is_default) <span class="badge">par défaut</span> @endif
|
||||
@if ($template->exists)
|
||||
@if (! $template->published_version)
|
||||
<span class="badge warn">jamais publié</span>
|
||||
@elseif ($dirty)
|
||||
<span class="badge warn">brouillon modifié depuis v{{ $template->published_version }}</span>
|
||||
@else
|
||||
<span class="badge ok">publié · v{{ $template->published_version }}</span>
|
||||
@endif
|
||||
@endif
|
||||
<span class="spacer"></span>
|
||||
@if ($template->exists && $isAdmin)
|
||||
<form class="inline" method="post" action="{{ route('admin.templates.publish', $template) }}"
|
||||
onsubmit="return confirm('Publier le brouillon enregistré ? Il sera servi immédiatement aux agents concernés.')">
|
||||
@csrf
|
||||
<button class="btn" type="submit" @disabled(! $dirty && $template->published_version)>Publier</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<form method="post" id="template-form"
|
||||
action="{{ $template->exists ? route('admin.templates.update', $template) : route('admin.templates.store') }}">
|
||||
@csrf
|
||||
@if ($template->exists) @method('PUT') @endif
|
||||
|
||||
<div class="split">
|
||||
<div class="card">
|
||||
<label for="name">Nom</label>
|
||||
<input type="text" id="name" name="name" required maxlength="120" value="{{ old('name', $template->name) }}">
|
||||
|
||||
<label for="description">Description</label>
|
||||
<input type="text" id="description" name="description" maxlength="500" value="{{ old('description', $template->description) }}">
|
||||
|
||||
<label for="draft_html_full">Signature complète (nouveaux messages, invitations)</label>
|
||||
<textarea class="code" id="draft_html_full" name="draft_html_full" data-variant="full" spellcheck="false" required>{{ old('draft_html_full', $template->draft_html_full) }}</textarea>
|
||||
|
||||
<label for="draft_html_short">Signature courte (réponses et transferts)</label>
|
||||
<textarea class="code" id="draft_html_short" name="draft_html_short" data-variant="short" spellcheck="false" style="min-height:120px">{{ old('draft_html_short', $template->draft_html_short) }}</textarea>
|
||||
|
||||
<div class="row" style="margin-top:12px">
|
||||
<button class="btn" type="submit">Enregistrer le brouillon</button>
|
||||
<span class="muted">L'enregistrement ne change rien pour les agents tant que le template n'est pas publié.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<h2 style="margin:0">Aperçu</h2>
|
||||
<span class="spacer"></span>
|
||||
<select id="preview-variant" aria-label="Variante affichée" style="width:auto">
|
||||
<option value="full">Complète</option>
|
||||
<option value="short">Courte</option>
|
||||
</select>
|
||||
</div>
|
||||
<label for="agent-search">Agent utilisé pour l'aperçu</label>
|
||||
<input type="search" id="agent-search" list="agent-options" autocomplete="off"
|
||||
placeholder="Nom ou adresse (2 caractères min.)"
|
||||
value="{{ $previewAgent ? $previewAgent->display_name.' — '.$previewAgent->mail : '' }}">
|
||||
<datalist id="agent-options"></datalist>
|
||||
<input type="hidden" id="agent-id" value="{{ $previewAgent?->id }}">
|
||||
<p class="muted" id="preview-status" aria-live="polite"> </p>
|
||||
@include('partials.signature-frame', ['id' => 'preview', 'html' => ''])
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Variables</h2>
|
||||
<p class="muted">Cliquez pour insérer à l'emplacement du curseur. Une ligne entourée de
|
||||
<code>{{ $snippets['if_open'] }}…{{ $snippets['if_close'] }}</code> disparaît quand le champ est vide.</p>
|
||||
<div class="chips" id="variables">
|
||||
@foreach ($variables as $key => $def)
|
||||
<button type="button" data-insert="{{ $tag($key) }}" title="{{ $def['label'] }}">{{ $key }}</button>
|
||||
@endforeach
|
||||
<button type="button" data-insert="{{ $snippets['banniere'] }}" title="Bannière de campagne">banniere</button>
|
||||
<button type="button" data-insert="{{ $snippets['site_url'] }}" title="URL publique (images)">site_url</button>
|
||||
<button type="button" data-insert="{{ $snippets['if'] }}" title="Ligne conditionnelle">#if …</button>
|
||||
</div>
|
||||
<p class="muted">Images : déposez-les dans <code>public/img</code> et référencez
|
||||
<code>{{ $snippets['img'] }}</code>. Pas de script ni de formulaire : ils sont retirés à l'enregistrement.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@if ($template->exists)
|
||||
<div class="card">
|
||||
<h2>Historique des publications</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Version</th><th>Publiée le</th><th>Par</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
@forelse ($versions as $v)
|
||||
<tr>
|
||||
<td>v{{ $v->version }} @if ($v->version === $template->published_version) <span class="badge ok">en ligne</span> @endif</td>
|
||||
<td>{{ $v->created_at->timezone('Europe/Paris')->format('d/m/Y H:i') }}</td>
|
||||
<td>{{ $v->publisher?->name ?? '—' }}</td>
|
||||
<td style="text-align:right">
|
||||
<form class="inline" method="post" action="{{ route('admin.templates.restore', [$template, $v]) }}"
|
||||
onsubmit="return confirm('Remplacer le brouillon actuel par la version {{ $v->version }} ?')">
|
||||
@csrf
|
||||
<button class="btn secondary small" type="submit">Recharger dans le brouillon</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="4" class="muted">Pas encore publié.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@if ($isAdmin)
|
||||
<div class="card row">
|
||||
@unless ($template->is_default)
|
||||
<form class="inline" method="post" action="{{ route('admin.templates.default', $template) }}">
|
||||
@csrf
|
||||
<button class="btn secondary" type="submit" @disabled(! $template->html_full)>Définir comme template par défaut</button>
|
||||
</form>
|
||||
<span class="spacer"></span>
|
||||
<form class="inline" method="post" action="{{ route('admin.templates.destroy', $template) }}"
|
||||
onsubmit="return confirm('Supprimer ce template et ses affectations ?')">
|
||||
@csrf @method('DELETE')
|
||||
<button class="btn danger" type="submit">Supprimer</button>
|
||||
</form>
|
||||
@else
|
||||
<span class="muted">Le template par défaut s'applique aux agents qui ne sont dans aucun groupe affecté. Il ne peut pas être supprimé.</span>
|
||||
@endunless
|
||||
</div>
|
||||
@endif
|
||||
@endif
|
||||
@endsection
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
(() => {
|
||||
const csrf = document.querySelector('meta[name="csrf-token"]').content;
|
||||
const frame = document.getElementById('preview');
|
||||
const status = document.getElementById('preview-status');
|
||||
const variantSel = document.getElementById('preview-variant');
|
||||
const agentId = document.getElementById('agent-id');
|
||||
const agentSearch = document.getElementById('agent-search');
|
||||
const options = document.getElementById('agent-options');
|
||||
const areas = { full: document.getElementById('draft_html_full'), short: document.getElementById('draft_html_short') };
|
||||
let lastFocused = areas.full, timer = null, found = [];
|
||||
|
||||
const shell = (html) => '<!doctype html><meta charset="utf-8"><body style="margin:12px">' + html + '</body>';
|
||||
|
||||
async function refresh() {
|
||||
const variant = variantSel.value;
|
||||
const html = areas[variant].value || (variant === 'short' ? areas.full.value : '');
|
||||
status.textContent = 'Mise à jour…';
|
||||
try {
|
||||
const res = await fetch(@json(route('admin.templates.preview')), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': csrf },
|
||||
body: JSON.stringify({ html, variant, agent_id: agentId.value || null }),
|
||||
});
|
||||
if (!res.ok) throw new Error(res.status);
|
||||
const data = await res.json();
|
||||
frame.srcdoc = shell(data.html);
|
||||
status.textContent = 'Aperçu pour ' + data.agent + (variant === 'short' && !areas.short.value ? ' (variante courte vide : la complète sera utilisée)' : '');
|
||||
} catch (e) {
|
||||
status.textContent = 'Aperçu indisponible (' + e.message + ').';
|
||||
}
|
||||
}
|
||||
const schedule = () => { clearTimeout(timer); timer = setTimeout(refresh, 400); };
|
||||
|
||||
Object.values(areas).forEach((ta) => {
|
||||
ta.addEventListener('input', schedule);
|
||||
ta.addEventListener('focus', () => {
|
||||
lastFocused = ta;
|
||||
if (variantSel.value !== ta.dataset.variant) { variantSel.value = ta.dataset.variant; refresh(); }
|
||||
});
|
||||
// Tabulation dans l'éditeur au lieu de changer de champ
|
||||
ta.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Tab') return;
|
||||
e.preventDefault();
|
||||
ta.setRangeText(' ', ta.selectionStart, ta.selectionEnd, 'end');
|
||||
schedule();
|
||||
});
|
||||
});
|
||||
variantSel.addEventListener('change', refresh);
|
||||
|
||||
document.getElementById('variables').addEventListener('click', (e) => {
|
||||
const text = e.target.dataset && e.target.dataset.insert;
|
||||
if (!text) return;
|
||||
lastFocused.focus();
|
||||
lastFocused.setRangeText(text, lastFocused.selectionStart, lastFocused.selectionEnd, 'end');
|
||||
schedule();
|
||||
});
|
||||
|
||||
agentSearch.addEventListener('input', async () => {
|
||||
const q = agentSearch.value.trim();
|
||||
const match = found.find((a) => a.label === q);
|
||||
if (match) { agentId.value = match.id; refresh(); return; }
|
||||
if (q.length < 2) return;
|
||||
const res = await fetch(@json(route('admin.agents.lookup')) + '?q=' + encodeURIComponent(q), { headers: { 'Accept': 'application/json' } });
|
||||
if (!res.ok) return;
|
||||
found = await res.json();
|
||||
options.replaceChildren(...found.map((a) => Object.assign(document.createElement('option'), { value: a.label })));
|
||||
});
|
||||
|
||||
// Prévenir la perte de modifications non enregistrées
|
||||
let changed = false;
|
||||
document.getElementById('template-form').addEventListener('input', () => { changed = true; });
|
||||
document.getElementById('template-form').addEventListener('submit', () => { changed = false; });
|
||||
window.addEventListener('beforeunload', (e) => { if (changed) { e.preventDefault(); e.returnValue = ''; } });
|
||||
|
||||
refresh();
|
||||
})();
|
||||
</script>
|
||||
@endpush
|
||||
@@ -0,0 +1,39 @@
|
||||
@extends('layouts.app')
|
||||
@section('title', 'Templates — Signatures CABM')
|
||||
@section('content')
|
||||
<div class="row" style="margin-bottom:16px">
|
||||
<h1 style="margin:0">Templates</h1>
|
||||
<span class="spacer"></span>
|
||||
<a class="btn" href="{{ route('admin.templates.create') }}">Nouveau template</a>
|
||||
</div>
|
||||
<div class="card">
|
||||
<table class="list">
|
||||
<thead><tr><th>Nom</th><th>État</th><th>Version publiée</th><th>Groupes affectés</th><th>Mis à jour</th></tr></thead>
|
||||
<tbody>
|
||||
@forelse ($templates as $t)
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ route('admin.templates.edit', $t) }}">{{ $t->name }}</a>
|
||||
@if ($t->is_default) <span class="badge">par défaut</span> @endif
|
||||
@if ($t->description)<div class="muted">{{ $t->description }}</div>@endif
|
||||
</td>
|
||||
<td>
|
||||
@if (! $t->published_version)
|
||||
<span class="badge warn">jamais publié</span>
|
||||
@elseif ($t->draft_html_full !== $t->html_full || $t->draft_html_short !== $t->html_short)
|
||||
<span class="badge warn">brouillon modifié</span>
|
||||
@else
|
||||
<span class="badge ok">publié</span>
|
||||
@endif
|
||||
</td>
|
||||
<td>{{ $t->published_version ? 'v'.$t->published_version : '—' }}</td>
|
||||
<td>{{ $t->assignments_count }}</td>
|
||||
<td class="muted">{{ $t->updated_at?->timezone('Europe/Paris')->format('d/m/Y H:i') }}</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="5" class="muted">Aucun template.</td></tr>
|
||||
@endforelse
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@endsection
|
||||
@@ -0,0 +1,84 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>@yield('title', 'Signatures CABM')</title>
|
||||
<style>
|
||||
:root { --ink:#1b1b1b; --muted:#5c5c5c; --line:#dcdcdc; --accent:#0b5cad; --accent-soft:#e7f0fa; --bg:#f6f7f9; --ok:#1f7a3d; --warn:#9a5b00; --danger:#b3261e; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; font-family:"Segoe UI", system-ui, sans-serif; color:var(--ink); background:var(--bg); font-size:15px; }
|
||||
header { background:#fff; border-bottom:1px solid var(--line); padding:0 24px; display:flex; justify-content:space-between; align-items:center; gap:16px; flex-wrap:wrap; }
|
||||
header .brand { color:var(--accent); font-weight:600; padding:14px 0; }
|
||||
nav a { display:inline-block; padding:14px 10px; color:var(--muted); text-decoration:none; border-bottom:2px solid transparent; }
|
||||
nav a.active, nav a:hover { color:var(--ink); border-bottom-color:var(--accent); }
|
||||
main { max-width:1200px; margin:24px auto; padding:0 16px; }
|
||||
h1 { font-size:22px; margin:0 0 16px; }
|
||||
h2 { font-size:17px; margin:0 0 12px; }
|
||||
.card { background:#fff; border:1px solid var(--line); border-radius:8px; padding:20px; margin-bottom:16px; }
|
||||
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(160px,1fr)); gap:12px; }
|
||||
.split { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:16px; align-items:start; }
|
||||
@media (max-width: 900px) { .split { grid-template-columns:1fr; } }
|
||||
.stat { font-size:28px; font-weight:600; }
|
||||
.muted { color:var(--muted); font-size:13px; }
|
||||
.row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
||||
.spacer { flex:1; }
|
||||
table.list { width:100%; border-collapse:collapse; }
|
||||
table.list th, table.list td { text-align:left; padding:8px; border-bottom:1px solid var(--line); vertical-align:middle; }
|
||||
table.list th { font-size:13px; color:var(--muted); font-weight:600; }
|
||||
label { display:block; font-weight:600; font-size:13px; margin:12px 0 4px; }
|
||||
input[type=text], input[type=number], input[type=search], select, textarea { width:100%; padding:8px 10px; border:1px solid var(--line); border-radius:6px; font:inherit; background:#fff; }
|
||||
textarea.code { font-family:Consolas, "Cascadia Mono", monospace; font-size:13px; line-height:1.45; min-height:260px; resize:vertical; tab-size:2; }
|
||||
input.small { width:90px; }
|
||||
.btn { display:inline-block; background:var(--accent); color:#fff; border:1px solid var(--accent); border-radius:6px; padding:7px 14px; font:inherit; font-size:14px; cursor:pointer; text-decoration:none; }
|
||||
.btn.secondary { background:#fff; color:var(--accent); }
|
||||
.btn.danger { background:#fff; color:var(--danger); border-color:var(--danger); }
|
||||
.btn.small { padding:4px 10px; font-size:13px; }
|
||||
.btn:disabled { opacity:.5; cursor:default; }
|
||||
button.link { background:none; border:0; color:var(--accent); cursor:pointer; font:inherit; padding:0; }
|
||||
.badge { display:inline-block; padding:2px 8px; border-radius:10px; font-size:12px; background:var(--accent-soft); color:var(--accent); }
|
||||
.badge.ok { background:#e5f4ea; color:var(--ok); }
|
||||
.badge.warn { background:#fdf1dc; color:var(--warn); }
|
||||
.flash { padding:10px 14px; border-radius:6px; margin-bottom:16px; background:#e5f4ea; color:var(--ok); border:1px solid #bfe3cb; }
|
||||
.errors { padding:10px 14px; border-radius:6px; margin-bottom:16px; background:#fde8e6; color:var(--danger); border:1px solid #f5c2bd; }
|
||||
.chips button { background:var(--accent-soft); color:var(--accent); border:0; border-radius:12px; padding:3px 9px; margin:2px; font:12px Consolas, monospace; cursor:pointer; }
|
||||
iframe.sig { width:100%; min-height:220px; border:1px solid var(--line); border-radius:6px; background:#fff; resize:vertical; }
|
||||
form.inline { display:inline; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<span class="brand">Signatures CABM</span>
|
||||
@auth
|
||||
@if (auth()->user()->role)
|
||||
<nav>
|
||||
<a href="{{ route('admin.dashboard') }}" @class(['active' => request()->routeIs('admin.dashboard')])>Tableau de bord</a>
|
||||
<a href="{{ route('admin.templates.index') }}" @class(['active' => request()->routeIs('admin.templates.*')])>Templates</a>
|
||||
@if (auth()->user()->hasRole('admin'))
|
||||
<a href="{{ route('admin.assignments.index') }}" @class(['active' => request()->routeIs('admin.assignments.*')])>Affectations</a>
|
||||
@endif
|
||||
<a href="{{ route('admin.agents.index') }}" @class(['active' => request()->routeIs('admin.agents.*')])>Agents</a>
|
||||
<a href="{{ route('portal.home') }}" @class(['active' => request()->routeIs('portal.*')])>Ma signature</a>
|
||||
</nav>
|
||||
@endif
|
||||
<form method="post" action="{{ route('logout') }}" class="row">@csrf
|
||||
<span class="muted">{{ auth()->user()->name }}@if (auth()->user()->role) · {{ auth()->user()->role }}@endif</span>
|
||||
<button class="link" type="submit">Se déconnecter</button>
|
||||
</form>
|
||||
@endauth
|
||||
</header>
|
||||
<main>
|
||||
@if (session('status'))
|
||||
<div class="flash" role="status">{{ session('status') }}</div>
|
||||
@endif
|
||||
@if ($errors->any())
|
||||
<div class="errors" role="alert">
|
||||
@foreach ($errors->all() as $error)<div>{{ $error }}</div>@endforeach
|
||||
</div>
|
||||
@endif
|
||||
@yield('content')
|
||||
</main>
|
||||
@stack('scripts')
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,3 @@
|
||||
{{-- Rendu isolé d'une signature : iframe sandbox (aucun script, origine opaque). --}}
|
||||
<iframe class="sig" sandbox title="{{ $title ?? 'Aperçu de la signature' }}" @isset($id) id="{{ $id }}" @endisset
|
||||
srcdoc="{{ '<!doctype html><meta charset="utf-8"><base target="_blank"><body style="margin:12px">'.($html ?? '').'</body>' }}"></iframe>
|
||||
@@ -0,0 +1,13 @@
|
||||
@extends('layouts.app')
|
||||
@section('title', 'Ma signature — CABM')
|
||||
@section('content')
|
||||
<div class="card">
|
||||
<h1>Ma signature</h1>
|
||||
<p class="muted">Modèle : {{ $full['template'] ?? 'aucun' }}. Vos informations proviennent de l'annuaire de la collectivité ;
|
||||
la modification de certains champs arrivera prochainement.</p>
|
||||
<h2>Nouveau message</h2>
|
||||
@include('partials.signature-frame', ['html' => $full['html'] ?? '<em>Aucune signature</em>'])
|
||||
<h2 style="margin-top:16px">Réponse / transfert</h2>
|
||||
@include('partials.signature-frame', ['html' => ($reply['html'] ?? '') !== '' ? $reply['html'] : '<em>Aucune signature</em>'])
|
||||
</div>
|
||||
@endsection
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Api\SignatureController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
// Appelé par l'add-in Outlook (jeton Entra NAA en Bearer).
|
||||
Route::prefix('v1')->middleware(['entra.token', 'throttle:signature'])->group(function () {
|
||||
Route::get('/signature', [SignatureController::class, 'show'])->name('api.signature');
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
// Synchro incrémentale (delta) toutes les 15 minutes, complète chaque nuit.
|
||||
Schedule::command('signature:sync')->everyFifteenMinutes()->withoutOverlapping(10);
|
||||
Schedule::command('signature:sync --full')->dailyAt('02:30')->withoutOverlapping(60);
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Admin\AgentController;
|
||||
use App\Http\Controllers\Admin\AssignmentController;
|
||||
use App\Http\Controllers\Admin\DashboardController;
|
||||
use App\Http\Controllers\Admin\SyncController;
|
||||
use App\Http\Controllers\Admin\TemplateController;
|
||||
use App\Http\Controllers\Auth\EntraLoginController;
|
||||
use App\Http\Controllers\Portal\PortalController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::redirect('/', '/me');
|
||||
|
||||
// SSO Entra
|
||||
Route::get('/login', [EntraLoginController::class, 'redirect'])->name('login');
|
||||
Route::get('/auth/callback', [EntraLoginController::class, 'callback'])->name('auth.callback');
|
||||
Route::post('/logout', [EntraLoginController::class, 'logout'])->name('logout')->middleware('auth');
|
||||
|
||||
// Portail agent (tout agent synchronisé)
|
||||
Route::middleware('auth')->group(function () {
|
||||
Route::get('/me', PortalController::class)->name('portal.home');
|
||||
});
|
||||
|
||||
// Back-office : réseau interne + rôle éditeur minimum
|
||||
Route::prefix('admin')->middleware(['admin.network', 'auth', 'role:editor'])->name('admin.')->group(function () {
|
||||
Route::get('/', DashboardController::class)->name('dashboard');
|
||||
|
||||
// Templates — éditeur : brouillons ; administrateur : publication, défaut, suppression
|
||||
Route::get('/templates', [TemplateController::class, 'index'])->name('templates.index');
|
||||
Route::get('/templates/create', [TemplateController::class, 'create'])->name('templates.create');
|
||||
Route::post('/templates', [TemplateController::class, 'store'])->name('templates.store');
|
||||
Route::post('/templates/preview', [TemplateController::class, 'preview'])->name('templates.preview');
|
||||
Route::get('/templates/{template}', [TemplateController::class, 'edit'])->name('templates.edit');
|
||||
Route::put('/templates/{template}', [TemplateController::class, 'update'])->name('templates.update');
|
||||
Route::post('/templates/{template}/versions/{version}/restore', [TemplateController::class, 'restore'])->name('templates.restore');
|
||||
|
||||
Route::middleware('role:admin')->group(function () {
|
||||
Route::post('/templates/{template}/publish', [TemplateController::class, 'publish'])->name('templates.publish');
|
||||
Route::post('/templates/{template}/default', [TemplateController::class, 'makeDefault'])->name('templates.default');
|
||||
Route::delete('/templates/{template}', [TemplateController::class, 'destroy'])->name('templates.destroy');
|
||||
|
||||
Route::get('/affectations', [AssignmentController::class, 'index'])->name('assignments.index');
|
||||
Route::post('/affectations', [AssignmentController::class, 'store'])->name('assignments.store');
|
||||
Route::put('/affectations/{assignment}', [AssignmentController::class, 'update'])->name('assignments.update');
|
||||
Route::delete('/affectations/{assignment}', [AssignmentController::class, 'destroy'])->name('assignments.destroy');
|
||||
|
||||
Route::post('/synchro', SyncController::class)->name('sync');
|
||||
});
|
||||
|
||||
// Agents : simulation « qui reçoit quoi » et recherche pour l'aperçu
|
||||
Route::get('/agents', [AgentController::class, 'index'])->name('agents.index');
|
||||
Route::get('/agents/recherche', [AgentController::class, 'lookup'])->name('agents.lookup');
|
||||
Route::get('/agents/{agent}', [AgentController::class, 'show'])->name('agents.show');
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
*
|
||||
!private/
|
||||
!public/
|
||||
!.gitignore
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -0,0 +1,10 @@
|
||||
compiled.php
|
||||
config.php
|
||||
down
|
||||
events.scanned.php
|
||||
lsp-*.php
|
||||
maintenance.php
|
||||
routes.php
|
||||
routes.scanned.php
|
||||
schedule-*
|
||||
services.json
|
||||
@@ -0,0 +1,3 @@
|
||||
*
|
||||
!data/
|
||||
!.gitignore
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user