This commit is contained in:
mrKamoo
2026-09-30 16:27:32 +02:00
commit 310724708f
147 changed files with 21327 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
**/node_modules
**/.git
addin/dist
backend/vendor
backend/.env
backend/storage/logs/*
backend/storage/framework/cache/*
backend/storage/framework/sessions/*
backend/storage/framework/views/*
docker/certs
+3
View File
@@ -0,0 +1,3 @@
*.sh text eol=lf
docker/** text eol=lf
*.png binary
+8
View File
@@ -0,0 +1,8 @@
node_modules/
addin/dist/
addin/.env
backend/vendor/
backend/.env
docker/certs/*
!docker/certs/README.txt
deploy.env
+91
View File
@@ -0,0 +1,91 @@
# Signatures CABM
Gestion centralisée des signatures email des agents de la CA Béziers Méditerranée :
un **add-in Outlook** insère automatiquement la signature (OWA, nouvel Outlook, Outlook mobile),
un **back-office Laravel** gère templates, affectations par groupes AD, campagnes et portail agent.
Architecture et lotissement : document « Signatures email CABM — Architecture et lotissement ».
## Structure
```
AddinOutlook/
├── addin/ Add-in Outlook (TypeScript, manifeste XML, NAA)
│ ├── manifest.xml Modèle ; la version finale est générée dans dist/ au build
│ └── src/
│ ├── launchevent/ Gestionnaires OnNewMessageCompose / OnNewAppointmentOrganizer / OnMessageFromChanged
│ ├── taskpane/ Volet « Ma signature » (aperçu, réinsertion, lien portail)
│ └── shared/ Authentification NAA, appel API, cache roamingSettings
├── backend/ Fichiers du back-office, à fusionner avec un Laravel neuf (install-backend.ps1)
│ ├── app/Signature/ Rendu des templates, choix du template, calcul de la signature
│ ├── app/Services/ Client Graph, synchro annuaire, validation des jetons Entra
│ ├── database/ Migration du modèle de données, seeders, templates d'exemple
│ └── tests/ Tests unitaires et fonctionnels (php artisan test)
├── docker/ Image PHP-FPM, Nginx (certificat wildcard), entrypoint
├── docker-compose.yml app + scheduler + nginx + mariadb
├── docs/
│ ├── 01-entra-app-registrations.md Inscriptions Entra, groupes AD, déploiement Integrated Apps
│ └── 02-deploiement.md VM DMZ, flux Stormshield, exploitation
└── install-backend.ps1 Installation du back-office sur le poste de dev (Laragon)
```
## Démarrage sur le poste de dev (Laragon)
1. Créer les deux inscriptions Entra et les groupes AD : `docs/01-entra-app-registrations.md`.
2. Back-office, dans le terminal Laragon :
```powershell
cd C:\laragon\www\AddinOutlook
powershell -ExecutionPolicy Bypass -File .\install-backend.ps1
```
Le script crée un Laravel neuf, y ajoute nos fichiers, installe `firebase/php-jwt`,
`laravel/socialite`, `socialiteproviders/microsoft-azure`, prépare `.env` et lance les tests.
3. Compléter `backend\.env` (base MySQL Laragon, identifiants Entra), puis :
```powershell
cd backend
php artisan migrate --seed
php artisan signature:sync --full
php artisan serve
```
Pour un hôte virtuel Laragon, faire pointer la racine web sur `backend\public`.
4. Add-in :
```powershell
cd ..\addin
copy .env.example .env # compléter
npm install
npm run build # produit dist\ (dont dist\manifest.xml)
npm run validate # validation du manifeste par le service Microsoft
```
L'add-in doit être servi en HTTPS avec un certificat de confiance : les tests se font sur la VM DMZ
(`docs/02-deploiement.md`) avec un chargement manuel du manifeste dans Outlook web.
## API
`GET /api/v1/signature?context=new|reply|forward|appointment&mailbox=<adresse>`
En-tête `Authorization: Bearer <jeton Entra NAA>` → `{ html, variant, template, generated_at }`.
- Template : groupe `signature_mail_*` de plus haute priorité, sinon template par défaut.
- Réponse / transfert : variante choisie par l'agent (courte, complète, aucune), défaut `short`.
- Boîte partagée déclarée : son template, avec ou sans le nom de l'agent selon le réglage de la boîte.
- Bannière : campagne active ciblant un des groupes de l'agent (ou tous), insérée par `{{{banniere}}}`.
## Syntaxe des templates
| Syntaxe | Effet |
| --- | --- |
| `{{prenom}}` | Valeur échappée |
| `{{#if mobile}}Mob. {{mobile}}<br>{{/if}}` | Ligne masquée si le champ est vide |
| `{{#unless x}}…{{/unless}}` | Bloc affiché si le champ est vide |
| `{{{banniere}}}` | Bannière de campagne (HTML généré par le serveur) |
Variables : `prenom`, `nom`, `nom_complet`, `fonction`, `service`, `organisation`, `bureau`, `telephone`,
`mobile`, `email`, `adresse`, `code_postal`, `ville`, `site_url`, `banniere`, et pour une boîte partagée
`boite_nom`, `boite_email`.
## Contenu du lot 0 et suite
Livré : socle complet (modèle de données, synchro Graph delta toutes les 15 min, API signature,
validation des jetons, add-in avec cache de secours, SSO, rôles par groupes AD, tableau de bord, portail en aperçu).
Lots suivants : écrans d'édition des templates (HTML + éditeur visuel), affectations et priorités,
boîtes partagées, campagnes, champs modifiables par l'agent et préférence de réponse, journal d'audit consultable.
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

+161
View File
@@ -0,0 +1,161 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Manifeste de l'add-in Signatures CABM (format XML « add-in only »).
Le format XML est obligatoire pour Outlook mobile (le manifeste unifié JSON n'y est pas supporté).
{{BASE_URL}} est remplacé au build (webpack) : https://signature.beziers-mediterranee.fr/addin en production.
-->
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0"
xmlns:mailappor="http://schemas.microsoft.com/office/mailappversionoverrides/1.0"
xsi:type="MailApp">
<Id>8102c04b-a358-496c-99f3-70fac75e23b5</Id>
<Version>0.1.0.0</Version>
<ProviderName>Communauté d'agglomération Béziers Méditerranée</ProviderName>
<DefaultLocale>fr-FR</DefaultLocale>
<DisplayName DefaultValue="Signatures CABM"/>
<Description DefaultValue="Insère automatiquement la signature officielle de l'agent dans les messages et invitations."/>
<IconUrl DefaultValue="{{BASE_URL}}/assets/icon-64.png"/>
<HighResolutionIconUrl DefaultValue="{{BASE_URL}}/assets/icon-128.png"/>
<SupportUrl DefaultValue="{{SUPPORT_URL}}"/>
<AppDomains>
<AppDomain>{{ORIGIN}}</AppDomain>
<AppDomain>https://login.microsoftonline.com</AppDomain>
</AppDomains>
<Hosts>
<Host Name="Mailbox"/>
</Hosts>
<Requirements>
<Sets>
<Set Name="Mailbox" MinVersion="1.1"/>
</Sets>
</Requirements>
<FormSettings>
<Form xsi:type="ItemEdit">
<DesktopSettings>
<SourceLocation DefaultValue="{{BASE_URL}}/taskpane.html"/>
</DesktopSettings>
</Form>
</FormSettings>
<Permissions>ReadWriteItem</Permissions>
<Rule xsi:type="RuleCollection" Mode="Or">
<Rule xsi:type="ItemIs" ItemType="Message" FormType="Edit"/>
<Rule xsi:type="ItemIs" ItemType="Appointment" FormType="Edit"/>
</Rule>
<DisableEntityHighlighting>false</DisableEntityHighlighting>
<VersionOverrides xmlns="http://schemas.microsoft.com/office/mailappversionoverrides" xsi:type="VersionOverridesV1_0">
<VersionOverrides xmlns="http://schemas.microsoft.com/office/mailappversionoverrides/1.1" xsi:type="VersionOverridesV1_1">
<!-- 1.5 = maximum déclaré par Outlook mobile ; les API récentes (setSignatureAsync…) y sont ouvertes par exception. -->
<Requirements>
<bt:Sets DefaultMinVersion="1.5">
<bt:Set Name="Mailbox"/>
</bt:Sets>
</Requirements>
<Hosts>
<Host xsi:type="MailHost">
<Runtimes>
<!-- Runtime HTML (OWA, nouvel Outlook, mobile) ; override JS pour Outlook classique Windows. -->
<Runtime resid="WebViewRuntime.Url">
<Override type="javascript" resid="JSRuntime.Url"/>
</Runtime>
</Runtimes>
<DesktopFormFactor>
<SupportsSharedFolders>true</SupportsSharedFolders>
<FunctionFile resid="WebViewRuntime.Url"/>
<!-- Bouton « Ma signature » dans la fenêtre de rédaction d'un message -->
<ExtensionPoint xsi:type="MessageComposeCommandSurface">
<OfficeTab id="TabDefault">
<Group id="msgComposeGroup">
<Label resid="GroupLabel"/>
<Control xsi:type="Button" id="msgComposeOpenPaneButton">
<Label resid="TaskpaneButton.Label"/>
<Supertip>
<Title resid="TaskpaneButton.Label"/>
<Description resid="TaskpaneButton.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Icon.16x16"/>
<bt:Image size="32" resid="Icon.32x32"/>
<bt:Image size="80" resid="Icon.80x80"/>
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="Taskpane.Url"/>
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
<!-- Même bouton dans une invitation -->
<ExtensionPoint xsi:type="AppointmentOrganizerCommandSurface">
<OfficeTab id="TabDefault">
<Group id="apptComposeGroup">
<Label resid="GroupLabel"/>
<Control xsi:type="Button" id="apptComposeOpenPaneButton">
<Label resid="TaskpaneButton.Label"/>
<Supertip>
<Title resid="TaskpaneButton.Label"/>
<Description resid="TaskpaneButton.Tooltip"/>
</Supertip>
<Icon>
<bt:Image size="16" resid="Icon.16x16"/>
<bt:Image size="32" resid="Icon.32x32"/>
<bt:Image size="80" resid="Icon.80x80"/>
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="Taskpane.Url"/>
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
<!-- Activation automatique -->
<ExtensionPoint xsi:type="LaunchEvent">
<LaunchEvents>
<LaunchEvent Type="OnNewMessageCompose" FunctionName="onNewMessageComposeHandler"/>
<LaunchEvent Type="OnNewAppointmentOrganizer" FunctionName="onNewAppointmentOrganizerHandler"/>
<LaunchEvent Type="OnMessageFromChanged" FunctionName="onMessageFromChangedHandler"/>
</LaunchEvents>
<SourceLocation resid="WebViewRuntime.Url"/>
</ExtensionPoint>
</DesktopFormFactor>
<MobileFormFactor>
<!-- OnNewAppointmentOrganizer n'est pas supporté sur mobile -->
<ExtensionPoint xsi:type="LaunchEvent">
<LaunchEvents>
<LaunchEvent Type="OnNewMessageCompose" FunctionName="onNewMessageComposeHandler"/>
<LaunchEvent Type="OnMessageFromChanged" FunctionName="onMessageFromChangedHandler"/>
</LaunchEvents>
<SourceLocation resid="WebViewRuntime.Url"/>
</ExtensionPoint>
</MobileFormFactor>
</Host>
</Hosts>
<Resources>
<bt:Images>
<bt:Image id="Icon.16x16" DefaultValue="{{BASE_URL}}/assets/icon-16.png"/>
<bt:Image id="Icon.32x32" DefaultValue="{{BASE_URL}}/assets/icon-32.png"/>
<bt:Image id="Icon.80x80" DefaultValue="{{BASE_URL}}/assets/icon-80.png"/>
</bt:Images>
<bt:Urls>
<bt:Url id="WebViewRuntime.Url" DefaultValue="{{BASE_URL}}/commands.html"/>
<bt:Url id="JSRuntime.Url" DefaultValue="{{BASE_URL}}/launchevent.js"/>
<bt:Url id="Taskpane.Url" DefaultValue="{{BASE_URL}}/taskpane.html"/>
</bt:Urls>
<bt:ShortStrings>
<bt:String id="GroupLabel" DefaultValue="Signatures CABM"/>
<bt:String id="TaskpaneButton.Label" DefaultValue="Ma signature"/>
</bt:ShortStrings>
<bt:LongStrings>
<bt:String id="TaskpaneButton.Tooltip" DefaultValue="Afficher, réinsérer ou modifier ma signature."/>
</bt:LongStrings>
</Resources>
</VersionOverrides>
</VersionOverrides>
</OfficeApp>
+5462
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "cabm-signature-addin",
"version": "0.1.0",
"private": true,
"description": "Add-in Outlook CABM : insertion automatique des signatures email",
"scripts": {
"build": "webpack --mode production",
"build:dev": "webpack --mode development",
"watch": "webpack --mode development --watch",
"typecheck": "tsc --noEmit",
"validate": "office-addin-manifest validate dist/manifest.xml",
"dev-server": "webpack serve --mode development"
},
"dependencies": {
"@azure/msal-browser": "^5.23.0"
},
"devDependencies": {
"@types/office-js": "^1.0.610",
"copy-webpack-plugin": "^13.0.1",
"html-webpack-plugin": "^5.6.8",
"office-addin-dev-certs": "^3.0.1",
"office-addin-manifest": "^3.0.1",
"ts-loader": "^9.5.2",
"typescript": "^5.8.0",
"webpack": "^5.101.0",
"webpack-cli": "^6.0.1",
"webpack-dev-server": "^6.0.0"
}
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
<title>Signatures CABM — runtime</title>
<script type="text/javascript" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
</head>
<body>
<!-- Page sans interface : charge le runtime des gestionnaires d'événements (launchevent.js injecté au build). -->
</body>
</html>
+54
View File
@@ -0,0 +1,54 @@
/**
* Gestionnaires d'événements (activation automatique, sans action de l'agent).
* Limites : ~300 s sur le web / nouvel Outlook, 60 s sur mobile. event.completed() est obligatoire.
*/
import { fetchSignature } from "../shared/api";
import { getTokenSilent } from "../shared/auth";
import { readCache, writeCache } from "../shared/cache";
import { applySignature, currentItem, getComposeContext, getMailbox, notify } from "../shared/office";
async function insertSignature(): Promise<void> {
const item = currentItem();
const context = await getComposeContext(item);
const mailbox = await getMailbox(item);
console.info("[signature] événement reçu", { context, mailbox });
let html: string | null = null;
const token = await getTokenSilent();
console.info("[signature] jeton", token ? "obtenu" : "absent");
if (token) {
const res = await fetchSignature(token, context, mailbox);
if (res) {
html = res.html;
await writeCache(context, mailbox, html);
}
}
if (html === null) html = readCache(context, mailbox);
if (html === null) {
await notify(item, "Signature CABM indisponible pour le moment. Ouvrez « Ma signature » pour réessayer.");
return;
}
console.info("[signature] insertion", html.length, "caractères");
// Chaîne vide = choix explicite « aucune signature » (ex. réponse sans signature).
await applySignature(item, html);
}
function handler(event: Office.AddinCommands.Event): void {
insertSignature()
.catch((e) => console.error("[signature]", e))
.finally(() => event.completed());
}
export const onNewMessageComposeHandler = handler;
export const onNewAppointmentOrganizerHandler = handler;
export const onMessageFromChangedHandler = handler;
Office.onReady((info) => {
console.info("[signature] runtime chargé", info.host, info.platform);
});
// Association nom (manifeste) -> fonction. Indispensable avec un bundle webpack.
Office.actions.associate("onNewMessageComposeHandler", onNewMessageComposeHandler);
Office.actions.associate("onNewAppointmentOrganizerHandler", onNewAppointmentOrganizerHandler);
Office.actions.associate("onMessageFromChangedHandler", onMessageFromChangedHandler);
+42
View File
@@ -0,0 +1,42 @@
import { CONFIG } from "./config";
/** Contexte de rédaction transmis à l'API : elle choisit la variante (complète / courte). */
export type ComposeContext = "new" | "reply" | "forward" | "appointment";
export interface SignatureResponse {
html: string;
variant: "full" | "short";
template: string;
generated_at: string;
}
const TIMEOUT_MS = 8000;
export async function fetchSignature(
token: string,
context: ComposeContext,
mailbox: string | null,
): Promise<SignatureResponse | null> {
const url = new URL("/api/v1/signature", CONFIG.apiBaseUrl);
url.searchParams.set("context", context);
if (mailbox) url.searchParams.set("mailbox", mailbox);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const res = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
signal: controller.signal,
});
if (!res.ok) {
console.warn("[signature] API", res.status);
return null;
}
return (await res.json()) as SignatureResponse;
} catch (e) {
console.warn("[signature] API injoignable", e);
return null;
} finally {
clearTimeout(timer);
}
}
+65
View File
@@ -0,0 +1,65 @@
import {
BrowserCacheLocation,
createNestablePublicClientApplication,
type IPublicClientApplication,
} from "@azure/msal-browser";
import { CONFIG } from "./config";
/**
* Authentification par Nested App Authentication (NAA).
* Les jetons Exchange historiques (getUserIdentityTokenAsync) sont désactivés sur tous les tenants :
* NAA est la seule voie supportée pour obtenir un jeton Entra depuis l'add-in.
*/
let pcaPromise: Promise<IPublicClientApplication> | null = null;
function getPca(): Promise<IPublicClientApplication> {
if (!pcaPromise) {
pcaPromise = createNestablePublicClientApplication({
auth: {
clientId: CONFIG.clientId,
authority: `https://login.microsoftonline.com/${CONFIG.tenantId}`,
},
cache: { cacheLocation: BrowserCacheLocation.LocalStorage },
});
}
return pcaPromise;
}
export function isNaaSupported(): boolean {
try {
return Office.context.requirements.isSetSupported("NestedAppAuth", "1.1");
} catch {
return false;
}
}
/**
* Jeton silencieux uniquement : c'est la seule option dans un gestionnaire d'événement
* (aucune fenêtre ne peut s'ouvrir). Le consentement administrateur rend ce cas nominal.
*/
export async function getTokenSilent(): Promise<string | null> {
if (!isNaaSupported()) return null;
try {
const pca = await getPca();
const result = await pca.acquireTokenSilent({ scopes: [CONFIG.apiScope] });
return result.accessToken;
} catch (e) {
console.warn("[signature] jeton silencieux indisponible", e);
return null;
}
}
/** Jeton avec repli interactif : utilisable uniquement depuis le volet (taskpane). */
export async function getTokenInteractive(): Promise<string | null> {
const silent = await getTokenSilent();
if (silent) return silent;
if (!isNaaSupported()) return null;
try {
const pca = await getPca();
const result = await pca.acquireTokenPopup({ scopes: [CONFIG.apiScope] });
return result.accessToken;
} catch (e) {
console.error("[signature] authentification interactive échouée", e);
return null;
}
}
+36
View File
@@ -0,0 +1,36 @@
import type { ComposeContext } from "./api";
/**
* Cache de la dernière signature reçue, dans les roamingSettings de la boîte
* (persistés côté Exchange, disponibles sur tous les clients, 32 Ko max au total).
* Sert de repli si l'API ou l'authentification est indisponible.
*/
const PREFIX = "sig:";
function key(context: ComposeContext, mailbox: string | null): string {
const variant = context === "reply" || context === "forward" ? "reply" : context === "appointment" ? "appt" : "new";
return `${PREFIX}${variant}:${mailbox ?? "self"}`;
}
export function readCache(context: ComposeContext, mailbox: string | null): string | null {
try {
const v = Office.context.roamingSettings.get(key(context, mailbox));
return typeof v === "string" && v.length > 0 ? v : null;
} catch {
return null;
}
}
export function writeCache(context: ComposeContext, mailbox: string | null, html: string): Promise<void> {
return new Promise((resolve) => {
try {
const rs = Office.context.roamingSettings;
const k = key(context, mailbox);
if (rs.get(k) === html) return resolve();
rs.set(k, html);
rs.saveAsync(() => resolve());
} catch {
resolve();
}
});
}
+13
View File
@@ -0,0 +1,13 @@
/**
* Configuration injectée au build par webpack (DefinePlugin), à partir de .env / variables d'environnement.
* Voir addin/.env.example.
*/
declare const __ADDIN_CONFIG__: {
tenantId: string;
clientId: string;
apiBaseUrl: string;
apiScope: string;
portalUrl: string;
};
export const CONFIG = __ADDIN_CONFIG__;
+83
View File
@@ -0,0 +1,83 @@
import type { ComposeContext } from "./api";
/** Promesse autour des API asynchrones Office (callback AsyncResult). */
export function officeAsync<T>(call: (cb: (r: Office.AsyncResult<T>) => void) => void): Promise<T> {
return new Promise((resolve, reject) => {
call((r) => (r.status === Office.AsyncResultStatus.Succeeded ? resolve(r.value) : reject(r.error)));
});
}
type ComposeItem = Office.MessageCompose | Office.AppointmentCompose;
export function currentItem(): ComposeItem {
return Office.context.mailbox.item as ComposeItem;
}
export function isAppointment(item: ComposeItem): item is Office.AppointmentCompose {
return item.itemType === Office.MailboxEnums.ItemType.Appointment;
}
/** new / reply / forward pour un message, appointment pour une invitation. */
export async function getComposeContext(item: ComposeItem): Promise<ComposeContext> {
if (isAppointment(item)) return "appointment";
const msg = item as Office.MessageCompose;
if (typeof msg.getComposeTypeAsync !== "function") return "new";
try {
const v = await officeAsync<{ composeType: string }>((cb) => msg.getComposeTypeAsync(cb));
switch (v.composeType) {
case Office.MailboxEnums.ComposeType.Reply:
return "reply";
case Office.MailboxEnums.ComposeType.Forward:
return "forward";
default:
return "new";
}
} catch {
return "new";
}
}
/** Adresse de la boîte émettrice (boîte partagée si l'agent a changé le champ De). */
export async function getMailbox(item: ComposeItem): Promise<string | null> {
try {
if (isAppointment(item)) {
const o = await officeAsync<Office.EmailAddressDetails>((cb) => item.organizer.getAsync(cb));
return o?.emailAddress?.toLowerCase() ?? null;
}
const f = await officeAsync<Office.EmailAddressDetails>((cb) => (item as Office.MessageCompose).from.getAsync(cb));
return f?.emailAddress?.toLowerCase() ?? null;
} catch {
return Office.context.mailbox.userProfile?.emailAddress?.toLowerCase() ?? null;
}
}
export async function applySignature(item: ComposeItem, html: string): Promise<void> {
await officeAsync<void>((cb) => item.body.setSignatureAsync(html, { coercionType: Office.CoercionType.Html }, cb));
// Neutralise la signature native d'Outlook (sinon doublon). Ignoré si non supporté.
if (typeof item.disableClientSignatureAsync === "function") {
try {
await officeAsync<void>((cb) => item.disableClientSignatureAsync(cb));
} catch {
/* non bloquant */
}
}
}
export function notify(item: ComposeItem, message: string): Promise<void> {
return new Promise((resolve) => {
try {
item.notificationMessages.replaceAsync(
"cabm_signature",
{
type: Office.MailboxEnums.ItemNotificationMessageType.InformationalMessage,
message,
icon: "Icon.16x16",
persistent: false,
},
() => resolve(),
);
} catch {
resolve();
}
});
}
+29
View File
@@ -0,0 +1,29 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Ma signature</title>
<script type="text/javascript" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
<style>
:root { --ink: #1b1b1b; --muted: #5c5c5c; --line: #d6d6d6; --accent: #0b5cad; }
body { font-family: "Segoe UI", system-ui, sans-serif; color: var(--ink); margin: 0; padding: 16px; font-size: 14px; }
h1 { font-size: 18px; margin: 0 0 12px; }
.muted { color: var(--muted); font-size: 12px; }
#preview { border: 1px solid var(--line); border-radius: 6px; padding: 12px; margin: 12px 0; min-height: 60px; overflow-x: auto; background: #fff; }
button { background: var(--accent); color: #fff; border: 0; border-radius: 4px; padding: 8px 14px; font-size: 14px; cursor: pointer; }
button:disabled { opacity: .5; cursor: default; }
a { color: var(--accent); }
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
</style>
</head>
<body>
<h1>Ma signature</h1>
<p class="muted" id="status">Chargement…</p>
<div id="preview" aria-live="polite"></div>
<div class="row">
<button id="apply" disabled>Insérer dans ce message</button>
<a id="portal" target="_blank" rel="noopener">Modifier mes informations</a>
</div>
</body>
</html>
+64
View File
@@ -0,0 +1,64 @@
/**
* Volet « Ma signature » : aperçu, réinsertion manuelle et lien vers le portail agent.
* C'est aussi le seul endroit où l'authentification interactive (popup) est possible.
*/
import { fetchSignature } from "../shared/api";
import { getTokenInteractive } from "../shared/auth";
import { writeCache } from "../shared/cache";
import { CONFIG } from "../shared/config";
import { applySignature, currentItem, getComposeContext, getMailbox } from "../shared/office";
const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
async function load(): Promise<void> {
const status = $("status");
const preview = $("preview");
const apply = $<HTMLButtonElement>("apply");
$<HTMLAnchorElement>("portal").href = CONFIG.portalUrl;
const item = currentItem();
const context = await getComposeContext(item);
const mailbox = await getMailbox(item);
const token = await getTokenInteractive();
if (!token) {
status.textContent = "Connexion impossible. Contactez le support informatique.";
return;
}
const res = await fetchSignature(token, context, mailbox);
if (!res) {
status.textContent = "Le service de signature ne répond pas.";
return;
}
await writeCache(context, mailbox, res.html);
status.textContent = `Modèle : ${res.template} · variante ${res.variant === "short" ? "courte" : "complète"}`;
// Aperçu isolé dans une iframe sandbox (aucun script, origine opaque) : le HTML d'un template
// ne peut pas accéder au jeton ni au contexte Office du volet.
const frame = document.createElement("iframe");
frame.setAttribute("sandbox", "");
frame.title = "Aperçu de la signature";
frame.style.cssText = "width:100%;min-height:200px;border:0";
frame.srcdoc = `<!doctype html><meta charset="utf-8"><body style="margin:0">${res.html || "<em>Aucune signature pour ce contexte.</em>"}</body>`;
preview.replaceChildren(frame);
apply.disabled = false;
apply.onclick = async () => {
apply.disabled = true;
try {
await applySignature(item, res.html);
status.textContent = "Signature insérée.";
} catch (e) {
status.textContent = "Insertion impossible.";
console.error(e);
} finally {
apply.disabled = false;
}
};
}
Office.onReady(() => {
load().catch((e) => {
console.error(e);
$("status").textContent = "Erreur inattendue.";
});
});
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2020", "DOM"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"sourceMap": true,
"types": ["office-js"]
},
"include": ["src/**/*.ts"]
}
+98
View File
@@ -0,0 +1,98 @@
/* eslint-disable */
const path = require("path");
const fs = require("fs");
const webpack = require("webpack");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const CopyWebpackPlugin = require("copy-webpack-plugin");
/** Lecture minimale de .env (sans dépendance) ; les variables d'environnement priment. */
function loadEnv() {
const env = {};
const file = path.resolve(__dirname, ".env");
if (fs.existsSync(file)) {
for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
if (m) env[m[1]] = m[2].replace(/^["']|["']$/g, "");
}
}
return { ...env, ...process.env };
}
module.exports = async (_env, argv) => {
const e = loadEnv();
const baseUrl = (e.ADDIN_BASE_URL || "https://signature.beziers-mediterranee.fr/addin").replace(/\/$/, "");
const origin = new URL(baseUrl).origin;
const config = {
tenantId: e.ENTRA_TENANT_ID || "",
clientId: e.ADDIN_CLIENT_ID || "",
apiBaseUrl: e.API_BASE_URL || origin,
apiScope: e.API_SCOPE || "",
portalUrl: e.PORTAL_URL || `${origin}/me`,
};
if (argv.mode === "production" && (!config.tenantId || !config.clientId || !config.apiScope)) {
console.warn("\n[ATTENTION] ENTRA_TENANT_ID, ADDIN_CLIENT_ID ou API_SCOPE vide : renseignez addin/.env\n");
}
// Serveur de dev HTTPS (npm run dev-server) : certificat localhost approuvé par office-addin-dev-certs,
// /api et /img relayés vers le Laravel local pour éviter le contenu mixte.
let devServer;
if (process.env.WEBPACK_SERVE) {
const devCerts = require("office-addin-dev-certs");
const https = await devCerts.getHttpsServerOptions();
const backend = e.DEV_BACKEND_URL || "http://127.0.0.1:8000";
devServer = {
port: Number(new URL(baseUrl).port || 3000),
server: { type: "https", options: { ca: https.ca, key: https.key, cert: https.cert } },
headers: { "Access-Control-Allow-Origin": "*" },
static: false,
devMiddleware: { publicPath: new URL(baseUrl).pathname.replace(/\/?$/, "/") },
proxy: [{ context: ["/api", "/img"], target: backend, changeOrigin: true }],
};
}
return {
devServer,
entry: {
launchevent: "./src/launchevent/launchevent.ts",
taskpane: "./src/taskpane/taskpane.ts",
},
output: {
path: path.resolve(__dirname, "dist"),
filename: "[name].js",
clean: true,
},
resolve: { extensions: [".ts", ".js"] },
module: { rules: [{ test: /\.ts$/, use: "ts-loader", exclude: /node_modules/ }] },
devtool: argv.mode === "production" ? false : "source-map",
performance: { hints: false },
plugins: [
new webpack.DefinePlugin({ __ADDIN_CONFIG__: JSON.stringify(config) }),
new HtmlWebpackPlugin({
filename: "commands.html",
template: "./src/launchevent/commands.html",
chunks: ["launchevent"],
scriptLoading: "blocking",
}),
new HtmlWebpackPlugin({
filename: "taskpane.html",
template: "./src/taskpane/taskpane.html",
chunks: ["taskpane"],
}),
new CopyWebpackPlugin({
patterns: [
{ from: "assets", to: "assets" },
{
from: "manifest.xml",
to: "manifest.xml",
transform: (content) =>
content
.toString()
.replace(/\{\{BASE_URL\}\}/g, baseUrl)
.replace(/\{\{ORIGIN\}\}/g, origin)
.replace(/\{\{SUPPORT_URL\}\}/g, e.SUPPORT_URL || `${origin}/aide`),
},
],
}),
],
};
};
+18
View File
@@ -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
+65
View File
@@ -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}"
+11
View File
@@ -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
+27
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
ignore-scripts=true
audit=true
+47
View File
@@ -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>
+47
View File
@@ -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>
+58
View File
@@ -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);
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Assignment extends Model
{
protected $fillable = ['tenant_id', 'template_id', 'directory_group_id', 'priority'];
public function template(): BelongsTo { return $this->belongsTo(Template::class); }
public function group(): BelongsTo { return $this->belongsTo(DirectoryGroup::class, 'directory_group_id'); }
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class AuditLog extends Model
{
public const UPDATED_AT = null;
protected $guarded = ['id'];
protected function casts(): array
{
return ['changes' => 'array'];
}
public static function record(string $actorType, ?string $actor, string $action, ?Model $subject = null, array $changes = [], ?int $tenantId = null): self
{
return static::create([
'tenant_id' => $tenantId ?? ($subject?->tenant_id ?? null),
'actor_type' => $actorType,
'actor' => $actor,
'action' => $action,
'subject_type' => $subject ? $subject::class : null,
'subject_id' => $subject?->getKey(),
'changes' => $changes ?: null,
'ip' => request()?->ip(),
]);
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Campaign extends Model
{
protected $guarded = ['id'];
protected function casts(): array
{
return ['starts_at' => 'datetime', 'ends_at' => 'datetime', 'active' => 'boolean'];
}
public function groups(): BelongsToMany
{
return $this->belongsToMany(DirectoryGroup::class, 'campaign_group');
}
public function scopeRunning(Builder $q): Builder
{
return $q->where('active', true)->where('starts_at', '<=', now())->where('ends_at', '>=', now());
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class DirectoryGroup extends Model
{
protected $fillable = ['tenant_id', 'entra_oid', 'display_name', 'synced_at'];
protected function casts(): array
{
return ['synced_at' => 'datetime'];
}
public function tenant(): BelongsTo { return $this->belongsTo(Tenant::class); }
public function members(): BelongsToMany
{
return $this->belongsToMany(DirectoryUser::class, 'group_members');
}
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
/** Copie locale d'un utilisateur Entra (alimentée par la synchro Graph, jamais modifiée à la main). */
class DirectoryUser extends Model
{
use SoftDeletes;
protected $guarded = ['id'];
protected function casts(): array
{
return ['account_enabled' => 'boolean', 'synced_at' => 'datetime'];
}
public function tenant(): BelongsTo { return $this->belongsTo(Tenant::class); }
public function groups(): BelongsToMany
{
return $this->belongsToMany(DirectoryGroup::class, 'group_members');
}
public function overrides(): HasMany { return $this->hasMany(UserOverride::class); }
public function preference(): HasOne { return $this->hasOne(UserPreference::class); }
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class EditableField extends Model
{
protected $fillable = ['tenant_id', 'field', 'label', 'enabled', 'validation_rule'];
protected function casts(): array
{
return ['enabled' => 'boolean'];
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class SharedMailbox extends Model
{
protected $fillable = ['tenant_id', 'address', 'display_name', 'template_id', 'include_sender'];
protected function casts(): array
{
return ['include_sender' => 'boolean'];
}
public function template(): BelongsTo { return $this->belongsTo(Template::class); }
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Template extends Model
{
protected $guarded = ['id'];
protected function casts(): array
{
return ['is_default' => 'boolean', 'editor_state' => 'array', 'published_at' => 'datetime'];
}
public function tenant(): BelongsTo { return $this->belongsTo(Tenant::class); }
public function versions(): HasMany { return $this->hasMany(TemplateVersion::class); }
public function assignments(): HasMany { return $this->hasMany(Assignment::class); }
/** Publie le brouillon : copie vers le contenu servi + nouvelle version d'historique. */
public function publish(?User $by = null): void
{
$this->html_full = $this->draft_html_full ?? $this->html_full;
$this->html_short = $this->draft_html_short ?? $this->html_short;
$this->published_version++;
$this->published_at = now();
$this->save();
$this->versions()->create([
'version' => $this->published_version,
'html_full' => $this->html_full,
'html_short' => $this->html_short,
'published_by' => $by?->id,
]);
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class TemplateVersion extends Model
{
protected $guarded = ['id'];
public function template(): BelongsTo { return $this->belongsTo(Template::class); }
public function publisher(): BelongsTo { return $this->belongsTo(User::class, 'published_by'); }
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Tenant extends Model
{
protected $fillable = ['name', 'entra_tenant_id', 'active', 'users_delta_link', 'last_sync_at', 'last_sync_status'];
protected $hidden = ['users_delta_link'];
protected function casts(): array
{
return ['active' => 'boolean', 'last_sync_at' => 'datetime'];
}
public function users(): HasMany { return $this->hasMany(DirectoryUser::class); }
public function groups(): HasMany { return $this->hasMany(DirectoryGroup::class); }
public function templates(): HasMany { return $this->hasMany(Template::class); }
public function assignments(): HasMany { return $this->hasMany(Assignment::class); }
public function campaigns(): HasMany { return $this->hasMany(Campaign::class); }
public function sharedMailboxes(): HasMany { return $this->hasMany(SharedMailbox::class); }
public function editableFields(): HasMany { return $this->hasMany(EditableField::class); }
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
/** Compte du back-office (connexion SSO Entra uniquement). */
class User extends Authenticatable
{
use HasFactory, Notifiable;
public const ROLE_RANK = ['editor' => 1, 'admin' => 2, 'superadmin' => 3];
protected $fillable = ['name', 'email', 'tenant_id', 'entra_oid', 'role'];
protected $hidden = ['password', 'remember_token'];
public function tenant(): BelongsTo { return $this->belongsTo(Tenant::class); }
public function hasRole(string $minimum): bool
{
return (self::ROLE_RANK[$this->role] ?? 0) >= (self::ROLE_RANK[$minimum] ?? PHP_INT_MAX);
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserOverride extends Model
{
protected $fillable = ['directory_user_id', 'field', 'value'];
public function user(): BelongsTo { return $this->belongsTo(DirectoryUser::class, 'directory_user_id'); }
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class UserPreference extends Model
{
protected $fillable = ['directory_user_id', 'reply_variant'];
}
@@ -0,0 +1,32 @@
<?php
namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
//
}
public function boot(): void
{
// Pilote Socialite « azure » (SocialiteProviders) pour le SSO Entra.
Event::listen(function (\SocialiteProviders\Manager\SocialiteWasCalled $event) {
$event->extendSocialite('azure', \SocialiteProviders\Azure\Provider::class);
});
// 120 appels / minute par agent : largement au-dessus d'un usage normal de l'add-in.
RateLimiter::for('signature', function (Request $request) {
$oid = $request->attributes->get('entra_claims')['oid'] ?? null;
return Limit::perMinute(120)->by($oid ?: $request->ip());
});
}
}
@@ -0,0 +1,77 @@
<?php
namespace App\Services\Auth;
use App\Models\Tenant;
use Firebase\JWT\JWK;
use Firebase\JWT\JWT;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use RuntimeException;
use Throwable;
/**
* Valide les jetons d'accès Entra envoyés par l'add-in (obtenus par NAA).
* Contrôles : signature (clés publiques Entra), expiration, audience, émetteur,
* tenant autorisé et actif, scope attendu.
*/
class EntraTokenValidator
{
/** @return array<string, mixed> claims du jeton */
public function validate(string $jwt): array
{
JWT::$leeway = (int) config('signature.addin.leeway_seconds', 120);
try {
$claims = (array) JWT::decode($jwt, $this->keys());
} catch (Throwable $e) {
// Rotation des clés Entra : un seul nouvel essai avec des clés fraîches.
Cache::forget('entra-jwks');
try {
$claims = (array) JWT::decode($jwt, $this->keys());
} catch (Throwable) {
throw new RuntimeException('Jeton invalide : '.$e->getMessage());
}
}
$tid = $claims['tid'] ?? null;
if (! $tid || ! Tenant::where('entra_tenant_id', $tid)->where('active', true)->exists()) {
throw new RuntimeException('Tenant non autorisé');
}
$issuers = ["https://login.microsoftonline.com/{$tid}/v2.0", "https://sts.windows.net/{$tid}/"];
if (! in_array($claims['iss'] ?? null, $issuers, true)) {
throw new RuntimeException('Émetteur inattendu');
}
$audiences = (array) config('signature.addin.audiences');
if (! $audiences || ! in_array($claims['aud'] ?? null, $audiences, true)) {
throw new RuntimeException('Audience inattendue : reçue « '.($claims['aud'] ?? 'aucune').' », attendue(s) « '.implode(', ', $audiences).' »');
}
$scopes = explode(' ', (string) ($claims['scp'] ?? ''));
if (! in_array(config('signature.addin.required_scope'), $scopes, true)) {
throw new RuntimeException('Scope manquant : reçu « '.($claims['scp'] ?? 'aucun').' »');
}
if (empty($claims['oid'])) {
throw new RuntimeException('Identifiant utilisateur absent');
}
return $claims;
}
private function keys(): array
{
$jwks = Cache::remember('entra-jwks', config('signature.addin.jwks_cache_seconds', 86400), function () {
$res = Http::timeout(10)->get('https://login.microsoftonline.com/common/discovery/v2.0/keys');
if (! $res->successful()) {
throw new RuntimeException('Clés Entra indisponibles');
}
return $res->json();
});
return JWK::parseKeySet($jwks, 'RS256');
}
}
@@ -0,0 +1,148 @@
<?php
namespace App\Services\Graph;
use App\Models\DirectoryGroup;
use App\Models\DirectoryUser;
use App\Models\Tenant;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Réplique utilisateurs et groupes Entra dans la base locale.
* - Utilisateurs : requête delta (incrémentale), complète si --full ou si le deltaLink a expiré.
* - Groupes : ceux dont le nom commence par le préfixe configuré, avec appartenance transitive.
*/
class DirectorySync
{
private const USER_SELECT = 'id,displayName,givenName,surname,jobTitle,department,companyName,officeLocation,businessPhones,mobilePhone,mail,userPrincipalName,streetAddress,postalCode,city,accountEnabled';
public function run(Tenant $tenant, bool $full = false): array
{
$graph = new GraphClient($tenant->entra_tenant_id);
$stats = ['users' => 0, 'removed' => 0, 'groups' => 0, 'memberships' => 0];
try {
[$stats['users'], $stats['removed']] = $this->syncUsers($tenant, $graph, $full);
[$stats['groups'], $stats['memberships']] = $this->syncGroups($tenant, $graph);
$tenant->update(['last_sync_at' => now(), 'last_sync_status' => 'ok']);
} catch (Throwable $e) {
$tenant->update(['last_sync_at' => now(), 'last_sync_status' => 'erreur : '.mb_substr($e->getMessage(), 0, 200)]);
Log::error('Synchro annuaire en échec', ['tenant' => $tenant->id, 'error' => $e->getMessage()]);
throw $e;
}
return $stats;
}
private function syncUsers(Tenant $tenant, GraphClient $graph, bool $full): array
{
$start = (! $full && $tenant->users_delta_link)
? $tenant->users_delta_link
: '/users/delta?$select='.self::USER_SELECT;
try {
[$items, $deltaLink] = $graph->all($start);
} catch (Throwable $e) {
if ($full || ! $tenant->users_delta_link) {
throw $e;
}
// deltaLink expiré (410) ou invalide : on repart d'une synchro complète.
[$items, $deltaLink] = $graph->all('/users/delta?$select='.self::USER_SELECT);
$full = true;
}
$now = now();
$upserted = 0;
$removed = 0;
$seen = [];
foreach ($items as $u) {
if (isset($u['@removed'])) {
$removed += DirectoryUser::where('tenant_id', $tenant->id)->where('entra_oid', $u['id'])->delete();
continue;
}
// En delta, seules les propriétés modifiées sont renvoyées : on ne met à jour que celles-là.
$attrs = array_filter([
'display_name' => $u['displayName'] ?? null,
'given_name' => $u['givenName'] ?? null,
'surname' => $u['surname'] ?? null,
'job_title' => $u['jobTitle'] ?? null,
'department' => $u['department'] ?? null,
'company_name' => $u['companyName'] ?? null,
'office_location' => $u['officeLocation'] ?? null,
'business_phone' => array_key_exists('businessPhones', $u) ? ($u['businessPhones'][0] ?? null) : null,
'mobile_phone' => $u['mobilePhone'] ?? null,
'mail' => isset($u['mail']) ? strtolower($u['mail']) : null,
'user_principal_name' => isset($u['userPrincipalName']) ? strtolower($u['userPrincipalName']) : null,
'street_address' => $u['streetAddress'] ?? null,
'postal_code' => $u['postalCode'] ?? null,
'city' => $u['city'] ?? null,
], fn ($v, $k) => array_key_exists($this->graphKey($k), $u), ARRAY_FILTER_USE_BOTH);
if (array_key_exists('accountEnabled', $u)) {
$attrs['account_enabled'] = (bool) $u['accountEnabled'];
}
$attrs['synced_at'] = $now;
$user = DirectoryUser::withTrashed()->firstOrNew(['tenant_id' => $tenant->id, 'entra_oid' => $u['id']]);
$user->fill($attrs);
$user->deleted_at = null;
$user->save();
$seen[] = $u['id'];
$upserted++;
}
if ($full) {
// Synchro complète : tout ce qui n'a pas été vu n'existe plus dans Entra.
$removed += DirectoryUser::where('tenant_id', $tenant->id)->whereNotIn('entra_oid', $seen)->delete();
}
$tenant->users_delta_link = $deltaLink;
$tenant->save();
return [$upserted, $removed];
}
private function syncGroups(Tenant $tenant, GraphClient $graph): array
{
$prefix = str_replace("'", "''", config('signature.graph.group_prefix'));
[$groups] = $graph->all("/groups?\$filter=startswith(displayName,'{$prefix}')&\$select=id,displayName");
$memberships = 0;
$keep = [];
foreach ($groups as $g) {
$group = DirectoryGroup::updateOrCreate(
['tenant_id' => $tenant->id, 'entra_oid' => $g['id']],
['display_name' => $g['displayName'], 'synced_at' => now()],
);
$keep[] = $group->id;
[$members] = $graph->all(
"/groups/{$g['id']}/transitiveMembers/microsoft.graph.user?\$select=id&\$count=true&\$top=999",
['ConsistencyLevel' => 'eventual'],
);
$userIds = DirectoryUser::where('tenant_id', $tenant->id)
->whereIn('entra_oid', array_column($members, 'id'))
->pluck('id')->all();
DB::transaction(fn () => $group->members()->sync($userIds));
$memberships += count($userIds);
}
DirectoryGroup::where('tenant_id', $tenant->id)->whereNotIn('id', $keep)->delete();
return [count($groups), $memberships];
}
private function graphKey(string $column): string
{
return [
'display_name' => 'displayName', 'given_name' => 'givenName', 'surname' => 'surname',
'job_title' => 'jobTitle', 'department' => 'department', 'company_name' => 'companyName',
'office_location' => 'officeLocation', 'business_phone' => 'businessPhones',
'mobile_phone' => 'mobilePhone', 'mail' => 'mail', 'user_principal_name' => 'userPrincipalName',
'street_address' => 'streetAddress', 'postal_code' => 'postalCode', 'city' => 'city',
][$column];
}
}
@@ -0,0 +1,73 @@
<?php
namespace App\Services\Graph;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use RuntimeException;
/**
* Client Microsoft Graph minimal (flux client credentials, permissions « application »).
* Permissions requises, avec consentement administrateur : User.Read.All, GroupMember.Read.All.
*/
class GraphClient
{
public function __construct(private readonly string $tenantId) {}
public function token(): string
{
return Cache::remember("graph-token:{$this->tenantId}", now()->addMinutes(50), function () {
$res = Http::asForm()->timeout(config('signature.graph.timeout'))
->post("https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token", [
'client_id' => config('signature.graph.client_id'),
'client_secret' => config('signature.graph.client_secret'),
'scope' => 'https://graph.microsoft.com/.default',
'grant_type' => 'client_credentials',
]);
if (! $res->successful()) {
throw new RuntimeException('Jeton Graph refusé : '.$res->json('error_description', $res->body()));
}
return $res->json('access_token');
});
}
/** GET sur une URL relative (/users…) ou absolue (@odata.nextLink). */
public function get(string $url, array $headers = []): array
{
$full = str_starts_with($url, 'https://') ? $url : config('signature.graph.base_url').$url;
$res = Http::withToken($this->token())
->withHeaders($headers)
->timeout(config('signature.graph.timeout'))
// Nouvel essai sur limitation (429) ou indisponibilité temporaire de Graph.
->retry(3, 2000, fn ($e) => $e instanceof RequestException && in_array($e->response->status(), [429, 503, 504], true), throw: false)
->get($full);
if (! $res->successful()) {
throw new RuntimeException("Graph {$res->status()} sur {$url} : ".$res->body());
}
return $res->json();
}
/**
* Parcourt toutes les pages d'une collection.
* Retourne [éléments, deltaLink|null].
*/
public function all(string $url, array $headers = []): array
{
$items = [];
$delta = null;
$next = $url;
while ($next) {
$page = $this->get($next, $headers);
array_push($items, ...($page['value'] ?? []));
$next = $page['@odata.nextLink'] ?? null;
$delta = $page['@odata.deltaLink'] ?? $delta;
}
return [$items, $delta];
}
}
+155
View File
@@ -0,0 +1,155 @@
<?php
namespace App\Signature;
use App\Models\Assignment;
use App\Models\Campaign;
use App\Models\DirectoryUser;
use App\Models\EditableField;
use App\Models\SharedMailbox;
use App\Models\Template;
/**
* Calcule la signature d'un agent pour un contexte de rédaction donné.
* Tout est lu dans la base locale (synchro Graph) : aucun appel Graph en direct.
*/
final class SignatureBuilder
{
public function __construct(
private readonly TemplateRenderer $renderer,
private readonly TemplateSelector $selector,
) {}
/**
* @param string $context new | reply | forward | appointment
* @param string|null $mailbox adresse émettrice (boîte partagée éventuelle)
* @return array{html:string, variant:string, template:string}|null
*/
public function build(DirectoryUser $user, string $context, ?string $mailbox = null): ?array
{
$shared = $this->sharedMailbox($user, $mailbox);
$template = $shared?->template ?? $this->templateFor($user);
if (! $template) {
return null;
}
$variant = $this->variant($user, $context);
if ($variant === 'none') {
return ['html' => '', 'variant' => 'none', 'template' => $template->name];
}
$source = $variant === 'short' ? ($template->html_short ?: $template->html_full) : $template->html_full;
if (! $source) {
return null;
}
$vars = $this->variables($user, $variant);
if ($shared) {
// Boîte partagée : ses propres coordonnées, l'agent seulement si l'admin l'a choisi.
$vars['boite_nom'] = $shared->display_name;
$vars['boite_email'] = $shared->address;
$vars['email'] = $shared->address;
if (! $shared->include_sender) {
foreach (['prenom', 'nom', 'nom_complet', 'fonction', 'mobile'] as $k) {
$vars[$k] = null;
}
}
}
return [
'html' => $this->renderer->render($source, $vars),
'variant' => $variant,
'template' => $template->name,
];
}
/** Variables d'un agent (attributs Entra + surcharges autorisées + bannière en variante complète). */
public function variables(DirectoryUser $user, string $variant = 'full'): array
{
$vars = VariableMap::build($user->getAttributes(), $this->allowedOverrides($user));
$vars['banniere'] = $variant === 'full' ? $this->bannerHtml($user) : '';
$vars['site_url'] = config('signature.public_url');
return $vars;
}
/** Aperçu d'un HTML brut (brouillon en cours d'édition) pour un agent donné. */
public function preview(DirectoryUser $user, string $source, string $variant = 'full'): string
{
return $this->renderer->render($source, $this->variables($user, $variant));
}
public function templateFor(DirectoryUser $user): ?Template
{
$assignments = Assignment::query()
->where('tenant_id', $user->tenant_id)
->get(['id', 'template_id', 'directory_group_id', 'priority'])
->map(fn ($a) => [
'id' => $a->id,
'template_id' => $a->template_id,
'group_id' => $a->directory_group_id,
'priority' => $a->priority,
])->all();
$default = Template::where('tenant_id', $user->tenant_id)->where('is_default', true)->value('id');
$id = $this->selector->select($assignments, $user->groups()->pluck('directory_groups.id')->all(), $default);
return $id ? Template::whereNotNull('html_full')->find($id) : null;
}
private function variant(DirectoryUser $user, string $context): string
{
if ($context !== 'reply' && $context !== 'forward') {
return 'full';
}
$pref = $user->preference?->reply_variant ?? config('signature.default_reply_variant', 'short');
return in_array($pref, ['short', 'full', 'none'], true) ? $pref : 'short';
}
/** Surcharges saisies par l'agent, limitées aux champs que l'admin a ouverts. */
private function allowedOverrides(DirectoryUser $user): array
{
$enabled = EditableField::where('tenant_id', $user->tenant_id)->where('enabled', true)->pluck('field')->all();
return $user->overrides()->whereIn('field', $enabled)->pluck('value', 'field')->all();
}
private function sharedMailbox(DirectoryUser $user, ?string $mailbox): ?SharedMailbox
{
if (! $mailbox) {
return null;
}
$mailbox = strtolower($mailbox);
if ($mailbox === strtolower((string) $user->mail) || $mailbox === strtolower((string) $user->user_principal_name)) {
return null;
}
return SharedMailbox::with('template')->where('tenant_id', $user->tenant_id)->where('address', $mailbox)->first();
}
private function bannerHtml(DirectoryUser $user): string
{
$groupIds = $user->groups()->pluck('directory_groups.id')->all();
$campaign = Campaign::running()
->where('tenant_id', $user->tenant_id)
->where(fn ($q) => $q->whereDoesntHave('groups')
->orWhereHas('groups', fn ($g) => $g->whereIn('directory_groups.id', $groupIds)))
->orderByDesc('priority')->orderBy('id')
->first();
if (! $campaign) {
return '';
}
$src = htmlspecialchars(config('signature.public_url').'/img/'.ltrim($campaign->image_path, '/'), ENT_QUOTES);
$alt = htmlspecialchars($campaign->alt_text, ENT_QUOTES);
$img = "<img src=\"{$src}\" alt=\"{$alt}\" width=\"600\" style=\"display:block;max-width:100%;height:auto;border:0\">";
return $campaign->link_url
? '<a href="'.htmlspecialchars($campaign->link_url, ENT_QUOTES).'">'.$img.'</a>'
: $img;
}
}
@@ -0,0 +1,55 @@
<?php
namespace App\Signature;
/**
* Moteur de rendu des templates de signature (sans dépendance au framework).
*
* Syntaxe :
* {{variable}} valeur échappée HTML
* {{{variable}}} valeur brute (réservé aux blocs générés par le serveur, ex. banniere)
* {{#if variable}} … {{/if}} bloc conservé si la variable est non vide (imbrication possible)
* {{#unless variable}} … {{/unless}} bloc conservé si la variable est vide
*
* Une variable inconnue est traitée comme vide : une ligne « Mobile : {{mobile}} »
* entourée de {{#if mobile}} disparaît donc proprement quand le champ n'est pas renseigné.
*/
final class TemplateRenderer
{
private const BLOCK = '/\{\{#(if|unless)\s+([a-z0-9_]+)\s*\}\}((?:(?!\{\{#(?:if|unless)\s).)*?)\{\{\/\1\}\}/is';
/** @param array<string, scalar|null> $vars */
public function render(string $template, array $vars): string
{
$vars = array_change_key_case($vars, CASE_LOWER);
// Blocs conditionnels, du plus interne vers l'extérieur.
$guard = 0;
while (preg_match(self::BLOCK, $template) && $guard++ < 50) {
$template = preg_replace_callback(self::BLOCK, function (array $m) use ($vars) {
$filled = $this->filled($vars[strtolower($m[2])] ?? null);
$keep = strtolower($m[1]) === 'if' ? $filled : ! $filled;
return $keep ? $m[3] : '';
}, $template);
}
// Valeurs brutes puis échappées.
$template = preg_replace_callback('/\{\{\{\s*([a-z0-9_]+)\s*\}\}\}/i', fn ($m) => (string) ($vars[strtolower($m[1])] ?? ''), $template);
return preg_replace_callback('/\{\{\s*([a-z0-9_]+)\s*\}\}/i', fn ($m) => htmlspecialchars((string) ($vars[strtolower($m[1])] ?? ''), ENT_QUOTES | ENT_HTML5, 'UTF-8'), $template);
}
/** Liste des variables utilisées par un template (aide à l'édition). */
public function variables(string $template): array
{
preg_match_all('/\{\{\{?\s*(?:#(?:if|unless)\s+)?([a-z0-9_]+)\s*\}?\}\}/i', $template, $m);
return array_values(array_unique(array_map('strtolower', $m[1])));
}
private function filled(mixed $v): bool
{
return $v !== null && trim((string) $v) !== '';
}
}
@@ -0,0 +1,52 @@
<?php
namespace App\Signature;
/**
* Nettoie le HTML d'un template avant enregistrement (sans dépendance au framework).
*
* Volontairement basé sur des expressions régulières et non sur DOMDocument :
* un parseur HTML déplacerait les marqueurs {{#if}} placés entre deux <tr>,
* ce qui casserait les templates.
*
* Défense en profondeur : les aperçus sont en plus affichés dans des iframes « sandbox ».
*/
final class TemplateSanitizer
{
/** Balises retirées avec leur contenu. */
private const BLOCK_TAGS = ['script', 'iframe', 'object', 'embed', 'applet', 'form', 'noscript', 'template', 'svg', 'math'];
/** Balises orphelines retirées. */
private const VOID_TAGS = ['base', 'meta', 'link', 'input', 'button', 'select', 'textarea', 'frame', 'frameset'];
public function clean(string $html): string
{
foreach (self::BLOCK_TAGS as $tag) {
$html = preg_replace('#<'.$tag.'\b[^>]*>.*?</'.$tag.'\s*>#is', '', $html);
$html = preg_replace('#<'.$tag.'\b[^>]*/?>#i', '', $html); // balise ouvrante sans fermeture
}
foreach (self::VOID_TAGS as $tag) {
$html = preg_replace('#</?'.$tag.'\b[^>]*>#i', '', $html);
}
// Gestionnaires d'événements : onclick=, onerror=…
$html = preg_replace('#\s+on[a-z]+\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)#i', '', $html);
// URL dangereuses dans href / src / action / style url()
$html = preg_replace_callback(
'#\s(href|src|action|formaction|xlink:href|background)\s*=\s*("([^"]*)"|\'([^\']*)\'|([^\s>]+))#i',
function (array $m) {
$value = html_entity_decode($m[3] !== '' ? $m[3] : ($m[4] ?? $m[5] ?? ''), ENT_QUOTES | ENT_HTML5);
$normalized = strtolower(preg_replace('/[\s\x00-\x1f]+/', '', $value));
return preg_match('#^(javascript|vbscript|data):#', $normalized) && ! str_starts_with($normalized, 'data:image/')
? ''
: $m[0];
},
$html,
);
$html = preg_replace('#expression\s*\(|javascript\s*:#i', '', $html);
return trim($html);
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Signature;
/**
* Choix du template d'un agent à partir de ses groupes (sans dépendance au framework).
* Règle : parmi les affectations dont le groupe contient l'agent, la priorité la plus haute l'emporte ;
* à priorité égale, l'affectation la plus ancienne (id le plus petit) gagne, pour un résultat stable.
* Aucun groupe correspondant : template par défaut du tenant (ou null).
*/
final class TemplateSelector
{
/**
* @param array<int, array{id:int, template_id:int, group_id:int, priority:int}> $assignments
* @param array<int, int> $userGroupIds
*/
public function select(array $assignments, array $userGroupIds, ?int $defaultTemplateId): ?int
{
$groups = array_flip($userGroupIds);
$best = null;
foreach ($assignments as $a) {
if (! isset($groups[$a['group_id']])) {
continue;
}
if ($best === null
|| $a['priority'] > $best['priority']
|| ($a['priority'] === $best['priority'] && $a['id'] < $best['id'])) {
$best = $a;
}
}
return $best['template_id'] ?? $defaultTemplateId;
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Signature;
/**
* Variables disponibles dans les templates et correspondance avec les attributs Entra.
* La clé est celle utilisée dans le template ({{telephone}}) et dans les surcharges agent.
*/
final class VariableMap
{
public const FIELDS = [
'prenom' => ['column' => 'given_name', 'label' => 'Prénom'],
'nom' => ['column' => 'surname', 'label' => 'Nom'],
'nom_complet' => ['column' => 'display_name', 'label' => 'Nom complet'],
'fonction' => ['column' => 'job_title', 'label' => 'Fonction'],
'service' => ['column' => 'department', 'label' => 'Service'],
'organisation' => ['column' => 'company_name', 'label' => 'Organisation'],
'bureau' => ['column' => 'office_location', 'label' => 'Bureau / site'],
'telephone' => ['column' => 'business_phone', 'label' => 'Téléphone fixe'],
'mobile' => ['column' => 'mobile_phone', 'label' => 'Téléphone mobile'],
'email' => ['column' => 'mail', 'label' => 'Adresse e-mail'],
'adresse' => ['column' => 'street_address', 'label' => 'Adresse'],
'code_postal' => ['column' => 'postal_code', 'label' => 'Code postal'],
'ville' => ['column' => 'city', 'label' => 'Ville'],
];
/**
* @param array<string, mixed> $user attributs de directory_users
* @param array<string, string|null> $overrides surcharges agent autorisées (clé de variable => valeur)
* @return array<string, string|null>
*/
public static function build(array $user, array $overrides = []): array
{
$vars = [];
foreach (self::FIELDS as $key => $def) {
$vars[$key] = $user[$def['column']] ?? null;
}
foreach ($overrides as $key => $value) {
if (array_key_exists($key, self::FIELDS)) {
$vars[$key] = $value;
}
}
return $vars;
}
}
+18
View File
@@ -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);
+25
View File
@@ -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();
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+7
View File
@@ -0,0 +1,7 @@
<?php
use App\Providers\AppServiceProvider;
return [
AppServiceProvider::class,
];
+89
View File
@@ -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
}
+8893
View File
File diff suppressed because it is too large Load Diff
+126
View File
@@ -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'),
],
];
+117
View File
@@ -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),
];
+136
View File
@@ -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,
];
+184
View File
@@ -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),
],
],
];
+80
View File
@@ -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'),
],
];
+140
View File
@@ -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'),
],
],
];
+118
View File
@@ -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')),
],
];
+129
View File
@@ -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',
],
];
+35
View File
@@ -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'),
],
];
+233
View File
@@ -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',
];
+56
View File
@@ -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', '')))),
];
+1
View File
@@ -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>
+19
View File
@@ -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"
}
}
+36
View File
@@ -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>
+25
View File
@@ -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>
View File
View File
+20
View File
@@ -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());

Some files were not shown because too many files have changed in this diff Show More