gestion addin
This commit is contained in:
+58
-1
@@ -5,7 +5,7 @@ export type ComposeContext = "new" | "reply" | "forward" | "appointment";
|
|||||||
|
|
||||||
export interface SignatureResponse {
|
export interface SignatureResponse {
|
||||||
html: string;
|
html: string;
|
||||||
variant: "full" | "short";
|
variant: "full" | "short" | "none";
|
||||||
template: string;
|
template: string;
|
||||||
generated_at: string;
|
generated_at: string;
|
||||||
}
|
}
|
||||||
@@ -40,3 +40,60 @@ export async function fetchSignature(
|
|||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Profil de l'agent (champs ouverts par l'admin + préférence en réponse)
|
||||||
|
|
||||||
|
export type ReplyVariant = "short" | "full" | "none";
|
||||||
|
|
||||||
|
export interface ProfileField {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: "text" | "phone";
|
||||||
|
directory_value: string | null;
|
||||||
|
override: string | null;
|
||||||
|
overridden: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Profile {
|
||||||
|
fields: ProfileField[];
|
||||||
|
reply_variant: ReplyVariant | null;
|
||||||
|
reply_variant_default: ReplyVariant;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProfileUpdate {
|
||||||
|
/** null = revenir à l'annuaire */
|
||||||
|
fields: Record<string, string | null>;
|
||||||
|
/** champs à ne pas afficher */
|
||||||
|
hidden: string[];
|
||||||
|
/** null = réglage par défaut */
|
||||||
|
reply_variant: ReplyVariant | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(message: string, public readonly status: number, public readonly errors: Record<string, string[]> = {}) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function call<T>(token: string, path: string, init: RequestInit = {}): Promise<T> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const res = await fetch(new URL(path, CONFIG.apiBaseUrl).toString(), {
|
||||||
|
...init,
|
||||||
|
headers: { Authorization: `Bearer ${token}`, Accept: "application/json", "Content-Type": "application/json" },
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new ApiError(body.message ?? `Erreur ${res.status}`, res.status, body.errors ?? {});
|
||||||
|
return body as T;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchProfile = (token: string) => call<Profile>(token, "/api/v1/profile");
|
||||||
|
|
||||||
|
export const saveProfile = (token: string, update: ProfileUpdate) =>
|
||||||
|
call<Profile>(token, "/api/v1/profile", { method: "PUT", body: JSON.stringify(update) });
|
||||||
|
|||||||
@@ -6,15 +6,25 @@
|
|||||||
<title>Ma signature</title>
|
<title>Ma signature</title>
|
||||||
<script type="text/javascript" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
|
<script type="text/javascript" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
|
||||||
<style>
|
<style>
|
||||||
:root { --ink: #1b1b1b; --muted: #5c5c5c; --line: #d6d6d6; --accent: #0b5cad; }
|
:root { --ink: #1b1b1b; --muted: #5c5c5c; --line: #d6d6d6; --accent: #0b5cad; --danger: #b3261e; --ok: #1f7a3d; }
|
||||||
body { font-family: "Segoe UI", system-ui, sans-serif; color: var(--ink); margin: 0; padding: 16px; font-size: 14px; }
|
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; }
|
h1 { font-size: 18px; margin: 0 0 12px; }
|
||||||
|
h2 { font-size: 15px; margin: 20px 0 8px; }
|
||||||
.muted { color: var(--muted); font-size: 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; }
|
#preview { border: 1px solid var(--line); border-radius: 6px; margin: 12px 0; min-height: 60px; overflow: hidden; background: #fff; }
|
||||||
button { background: var(--accent); color: #fff; border: 0; border-radius: 4px; padding: 8px 14px; font-size: 14px; cursor: pointer; }
|
button { background: var(--accent); color: #fff; border: 0; border-radius: 4px; padding: 8px 14px; font-size: 14px; cursor: pointer; }
|
||||||
|
button.secondary { background: #fff; color: var(--accent); border: 1px solid var(--accent); }
|
||||||
button:disabled { opacity: .5; cursor: default; }
|
button:disabled { opacity: .5; cursor: default; }
|
||||||
a { color: var(--accent); }
|
a { color: var(--accent); }
|
||||||
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.field { border: 1px solid var(--line); border-radius: 6px; padding: 8px 10px; margin: 8px 0; }
|
||||||
|
.field label { display: block; font-weight: 600; font-size: 13px; margin-bottom: 4px; }
|
||||||
|
select, input[type=text] { width: 100%; box-sizing: border-box; padding: 6px 8px; border: 1px solid var(--line); border-radius: 4px; font: inherit; }
|
||||||
|
input[type=text] { margin-top: 6px; }
|
||||||
|
.error { color: var(--danger); font-size: 12px; margin-top: 4px; }
|
||||||
|
#form-status.ok { color: var(--ok); }
|
||||||
|
#form-status.ko { color: var(--danger); }
|
||||||
|
[hidden] { display: none !important; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -23,7 +33,24 @@
|
|||||||
<div id="preview" aria-live="polite"></div>
|
<div id="preview" aria-live="polite"></div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button id="apply" disabled>Insérer dans ce message</button>
|
<button id="apply" disabled>Insérer dans ce message</button>
|
||||||
<a id="portal" target="_blank" rel="noopener">Modifier mes informations</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section id="profile" hidden>
|
||||||
|
<h2>Mes informations</h2>
|
||||||
|
<p class="muted">Ces valeurs ne modifient que votre signature, pas l'annuaire.</p>
|
||||||
|
<form id="profile-form" novalidate>
|
||||||
|
<div id="fields"></div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="reply_variant">En réponse et transfert</label>
|
||||||
|
<select id="reply_variant"></select>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<button type="submit" id="save">Enregistrer</button>
|
||||||
|
<span class="muted" id="form-status" aria-live="polite"></span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<p class="muted" id="no-fields" hidden>Vos informations viennent de l'annuaire de la collectivité. Pour les corriger, contactez le support informatique.</p>
|
||||||
|
<p class="muted"><a id="portal" target="_blank" rel="noopener">Ouvrir le portail « Ma signature »</a></p>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+174
-31
@@ -1,8 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* Volet « Ma signature » : aperçu, réinsertion manuelle et lien vers le portail agent.
|
* Volet « Ma signature » : aperçu, réinsertion, et modification par l'agent des champs
|
||||||
|
* que l'admin a ouverts (+ choix de la signature en réponse).
|
||||||
* C'est aussi le seul endroit où l'authentification interactive (popup) est possible.
|
* C'est aussi le seul endroit où l'authentification interactive (popup) est possible.
|
||||||
*/
|
*/
|
||||||
import { fetchSignature } from "../shared/api";
|
import {
|
||||||
|
ApiError,
|
||||||
|
fetchProfile,
|
||||||
|
fetchSignature,
|
||||||
|
saveProfile,
|
||||||
|
type ComposeContext,
|
||||||
|
type Profile,
|
||||||
|
type ReplyVariant,
|
||||||
|
type SignatureResponse,
|
||||||
|
} from "../shared/api";
|
||||||
import { getTokenInteractive } from "../shared/auth";
|
import { getTokenInteractive } from "../shared/auth";
|
||||||
import { writeCache } from "../shared/cache";
|
import { writeCache } from "../shared/cache";
|
||||||
import { CONFIG } from "../shared/config";
|
import { CONFIG } from "../shared/config";
|
||||||
@@ -10,50 +20,183 @@ import { applySignature, currentItem, getComposeContext, getMailbox } from "../s
|
|||||||
|
|
||||||
const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
|
const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
|
||||||
|
|
||||||
async function load(): Promise<void> {
|
const REPLY_LABELS: Record<ReplyVariant, string> = { short: "Signature courte", full: "Signature complète", none: "Aucune signature" };
|
||||||
const status = $("status");
|
|
||||||
const preview = $("preview");
|
|
||||||
const apply = $<HTMLButtonElement>("apply");
|
|
||||||
$<HTMLAnchorElement>("portal").href = CONFIG.portalUrl;
|
|
||||||
|
|
||||||
const item = currentItem();
|
let token: string | null = null;
|
||||||
const context = await getComposeContext(item);
|
let context: ComposeContext = "new";
|
||||||
const mailbox = await getMailbox(item);
|
let mailbox: string | null = null;
|
||||||
|
let current: SignatureResponse | null = null;
|
||||||
|
|
||||||
const token = await getTokenInteractive();
|
function option(value: string, text: string): HTMLOptionElement {
|
||||||
if (!token) {
|
const o = document.createElement("option");
|
||||||
status.textContent = "Connexion impossible. Contactez le support informatique.";
|
o.value = value;
|
||||||
return;
|
o.textContent = text;
|
||||||
}
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
function div(className: string, ...children: Node[]): HTMLDivElement {
|
||||||
|
const d = document.createElement("div");
|
||||||
|
d.className = className;
|
||||||
|
d.append(...children);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aperçu isolé dans une iframe sandbox : le HTML d'un template n'accède ni au jeton ni à Office. */
|
||||||
|
function showPreview(res: SignatureResponse): void {
|
||||||
|
const frame = document.createElement("iframe");
|
||||||
|
frame.title = "Aperçu de la signature";
|
||||||
|
frame.setAttribute("sandbox", "");
|
||||||
|
frame.style.cssText = "width:100%;min-height:180px;border:0";
|
||||||
|
frame.srcdoc = `<!doctype html><meta charset="utf-8"><body style="margin:8px">${res.html || "<em>Aucune signature pour ce contexte.</em>"}</body>`;
|
||||||
|
$("preview").replaceChildren(frame);
|
||||||
|
$("status").textContent = `Modèle : ${res.template} · variante ${res.variant === "short" ? "courte" : res.variant === "none" ? "aucune" : "complète"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSignature(): Promise<void> {
|
||||||
|
if (!token) return;
|
||||||
const res = await fetchSignature(token, context, mailbox);
|
const res = await fetchSignature(token, context, mailbox);
|
||||||
if (!res) {
|
if (!res) {
|
||||||
status.textContent = "Le service de signature ne répond pas.";
|
$("status").textContent = "Le service de signature ne répond pas.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
current = res;
|
||||||
|
await writeCache(context, mailbox, res.html);
|
||||||
|
showPreview(res);
|
||||||
|
$<HTMLButtonElement>("apply").disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProfile(profile: Profile): void {
|
||||||
|
const container = $("fields");
|
||||||
|
container.replaceChildren();
|
||||||
|
$("profile").hidden = false;
|
||||||
|
$("no-fields").hidden = profile.fields.length > 0;
|
||||||
|
|
||||||
|
for (const f of profile.fields) {
|
||||||
|
const mode = !f.overridden ? "directory" : f.override === "" ? "hidden" : "custom";
|
||||||
|
const select = document.createElement("select");
|
||||||
|
select.id = `mode-${f.key}`;
|
||||||
|
select.append(
|
||||||
|
option("directory", `Annuaire : ${f.directory_value || "(vide)"}`),
|
||||||
|
option("custom", "Valeur personnalisée"),
|
||||||
|
option("hidden", "Ne pas afficher"),
|
||||||
|
);
|
||||||
|
select.value = mode;
|
||||||
|
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "text";
|
||||||
|
input.id = `value-${f.key}`;
|
||||||
|
input.value = mode === "custom" ? f.override ?? "" : "";
|
||||||
|
input.maxLength = 120;
|
||||||
|
input.placeholder = f.type === "phone" ? "ex. 06 12 34 56 78" : "";
|
||||||
|
input.setAttribute("aria-label", `${f.label} personnalisé`);
|
||||||
|
if (f.type === "phone") input.inputMode = "tel";
|
||||||
|
input.hidden = mode !== "custom";
|
||||||
|
select.addEventListener("change", () => {
|
||||||
|
input.hidden = select.value !== "custom";
|
||||||
|
if (!input.hidden) input.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
const label = document.createElement("label");
|
||||||
|
label.htmlFor = select.id;
|
||||||
|
label.textContent = f.label;
|
||||||
|
const error = div("error");
|
||||||
|
error.id = `error-${f.key}`;
|
||||||
|
container.append(div("field", label, select, input, error));
|
||||||
|
}
|
||||||
|
|
||||||
|
const reply = $<HTMLSelectElement>("reply_variant");
|
||||||
|
reply.replaceChildren(
|
||||||
|
option("default", `Réglage par défaut (${REPLY_LABELS[profile.reply_variant_default].toLowerCase()})`),
|
||||||
|
...(Object.keys(REPLY_LABELS) as ReplyVariant[]).map((v) => option(v, REPLY_LABELS[v])),
|
||||||
|
);
|
||||||
|
reply.value = profile.reply_variant ?? "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSave(ev: SubmitEvent, profile: Profile): Promise<void> {
|
||||||
|
ev.preventDefault();
|
||||||
|
if (!token) return;
|
||||||
|
const status = $("form-status");
|
||||||
|
const save = $<HTMLButtonElement>("save");
|
||||||
|
document.querySelectorAll<HTMLElement>(".error").forEach((e) => (e.textContent = ""));
|
||||||
|
|
||||||
|
const fields: Record<string, string | null> = {};
|
||||||
|
const hidden: string[] = [];
|
||||||
|
for (const f of profile.fields) {
|
||||||
|
const mode = $<HTMLSelectElement>(`mode-${f.key}`).value;
|
||||||
|
const value = $<HTMLInputElement>(`value-${f.key}`).value.trim();
|
||||||
|
if (mode === "hidden") hidden.push(f.key);
|
||||||
|
else fields[f.key] = mode === "custom" && value !== "" ? value : null;
|
||||||
|
}
|
||||||
|
const replyValue = $<HTMLSelectElement>("reply_variant").value;
|
||||||
|
|
||||||
|
save.disabled = true;
|
||||||
|
status.className = "";
|
||||||
|
status.textContent = "Enregistrement…";
|
||||||
|
try {
|
||||||
|
const updated = await saveProfile(token, {
|
||||||
|
fields,
|
||||||
|
hidden,
|
||||||
|
reply_variant: replyValue === "default" ? null : (replyValue as ReplyVariant),
|
||||||
|
});
|
||||||
|
profile.fields = updated.fields;
|
||||||
|
profile.reply_variant = updated.reply_variant;
|
||||||
|
renderProfile(updated);
|
||||||
|
await refreshSignature();
|
||||||
|
status.className = "ok";
|
||||||
|
status.textContent = "Enregistré. Cliquez sur « Insérer dans ce message » pour l'appliquer ici.";
|
||||||
|
} catch (e) {
|
||||||
|
status.className = "ko";
|
||||||
|
if (e instanceof ApiError && e.status === 422) {
|
||||||
|
status.textContent = "Certaines valeurs sont invalides.";
|
||||||
|
for (const [key, messages] of Object.entries(e.errors)) {
|
||||||
|
const target = document.getElementById(`error-${key.replace(/^fields\./, "")}`);
|
||||||
|
if (target) target.textContent = messages.join(" ");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
status.textContent = "Enregistrement impossible. Réessayez plus tard.";
|
||||||
|
console.error("[signature] profil", e);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
save.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
$<HTMLAnchorElement>("portal").href = CONFIG.portalUrl;
|
||||||
|
const item = currentItem();
|
||||||
|
context = await getComposeContext(item);
|
||||||
|
mailbox = await getMailbox(item);
|
||||||
|
|
||||||
|
token = await getTokenInteractive();
|
||||||
|
if (!token) {
|
||||||
|
$("status").textContent = "Connexion impossible. Contactez le support informatique.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await writeCache(context, mailbox, res.html);
|
$<HTMLButtonElement>("apply").onclick = async () => {
|
||||||
status.textContent = `Modèle : ${res.template} · variante ${res.variant === "short" ? "courte" : "complète"}`;
|
if (!current) return;
|
||||||
// Aperçu isolé dans une iframe sandbox (aucun script, origine opaque) : le HTML d'un template
|
const apply = $<HTMLButtonElement>("apply");
|
||||||
// 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;
|
apply.disabled = true;
|
||||||
try {
|
try {
|
||||||
await applySignature(item, res.html);
|
await applySignature(item, current.html);
|
||||||
status.textContent = "Signature insérée.";
|
$("status").textContent = "Signature insérée.";
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
status.textContent = "Insertion impossible.";
|
$("status").textContent = "Insertion impossible.";
|
||||||
console.error(e);
|
console.error(e);
|
||||||
} finally {
|
} finally {
|
||||||
apply.disabled = false;
|
apply.disabled = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
await refreshSignature();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profile = await fetchProfile(token);
|
||||||
|
renderProfile(profile);
|
||||||
|
$<HTMLFormElement>("profile-form").addEventListener("submit", (ev) => onSave(ev, profile));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[signature] profil indisponible", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Office.onReady(() => {
|
Office.onReady(() => {
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
<?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\EditableField;
|
||||||
|
use App\Models\UserOverride;
|
||||||
|
use App\Signature\ProfileService;
|
||||||
|
use App\Signature\VariableMap;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
/** Choix des champs que les agents peuvent modifier eux-mêmes (add-in et portail). */
|
||||||
|
class EditableFieldController extends Controller
|
||||||
|
{
|
||||||
|
use ScopedToTenant;
|
||||||
|
|
||||||
|
/** L'adresse e-mail n'est jamais modifiable : elle doit rester celle de la boîte. */
|
||||||
|
private const EXCLUDED = ['email'];
|
||||||
|
|
||||||
|
public function index(): View
|
||||||
|
{
|
||||||
|
$tenant = $this->tenantId();
|
||||||
|
foreach (array_diff(array_keys(VariableMap::FIELDS), self::EXCLUDED) as $key) {
|
||||||
|
EditableField::firstOrCreate(
|
||||||
|
['tenant_id' => $tenant, 'field' => $key],
|
||||||
|
['label' => VariableMap::FIELDS[$key]['label'], 'enabled' => false, 'validation_rule' => in_array($key, ['telephone', 'mobile'], true) ? 'phone' : 'text'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$usage = UserOverride::query()
|
||||||
|
->join('directory_users', 'directory_users.id', '=', 'user_overrides.directory_user_id')
|
||||||
|
->where('directory_users.tenant_id', $tenant)
|
||||||
|
->selectRaw('user_overrides.field, count(*) as n')->groupBy('user_overrides.field')->pluck('n', 'field');
|
||||||
|
|
||||||
|
return view('admin.fields.index', [
|
||||||
|
'fields' => EditableField::where('tenant_id', $tenant)->whereNotIn('field', self::EXCLUDED)
|
||||||
|
->get()->sortBy(fn ($f) => array_search($f->field, array_keys(VariableMap::FIELDS)))->values(),
|
||||||
|
'types' => ProfileService::TYPES,
|
||||||
|
'usage' => $usage,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request): RedirectResponse
|
||||||
|
{
|
||||||
|
$tenant = $this->tenantId();
|
||||||
|
$data = $request->validate([
|
||||||
|
'fields' => ['array'],
|
||||||
|
'fields.*.enabled' => ['nullable', 'boolean'],
|
||||||
|
'fields.*.label' => ['required', 'string', 'max:60'],
|
||||||
|
'fields.*.type' => ['required', Rule::in(array_keys(ProfileService::TYPES))],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$changes = [];
|
||||||
|
foreach ($data['fields'] ?? [] as $key => $row) {
|
||||||
|
$field = EditableField::where('tenant_id', $tenant)->where('field', $key)->whereNotIn('field', self::EXCLUDED)->first();
|
||||||
|
if (! $field) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$field->fill(['enabled' => (bool) ($row['enabled'] ?? false), 'label' => $row['label'], 'validation_rule' => $row['type']]);
|
||||||
|
if ($field->isDirty()) {
|
||||||
|
$changes[$key] = $field->getDirty();
|
||||||
|
$field->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($changes) {
|
||||||
|
AuditLog::record('admin', $this->actor(), 'fields.update', null, $changes, $tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('status', $changes ? 'Champs modifiables enregistrés : effet immédiat dans l\'add-in et le portail.' : 'Aucun changement.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Signature\ProfileService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
/** Profil de l'agent pour le volet de l'add-in (jeton Entra NAA). */
|
||||||
|
class ProfileController extends Controller
|
||||||
|
{
|
||||||
|
public function show(Request $request, ProfileService $profiles): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json($profiles->profile($request->attributes->get('directory_user')))
|
||||||
|
->header('Cache-Control', 'private, no-store');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT { fields: { mobile: "06…" | null, … }, hidden: ["bureau"], reply_variant?: short|full|none|null }
|
||||||
|
* null = revenir à l'annuaire ; « hidden » = ne pas afficher le champ (les chaînes vides
|
||||||
|
* étant converties en null par Laravel, le masquage passe par cette liste explicite).
|
||||||
|
*/
|
||||||
|
public function update(Request $request, ProfileService $profiles): JsonResponse
|
||||||
|
{
|
||||||
|
$user = $request->attributes->get('directory_user');
|
||||||
|
$fields = $request->input('fields', []);
|
||||||
|
$hidden = $request->input('hidden', []);
|
||||||
|
abort_unless(is_array($fields) && is_array($hidden), 422, 'fields doit être un objet et hidden une liste.');
|
||||||
|
foreach ($hidden as $key) {
|
||||||
|
if (is_string($key)) {
|
||||||
|
$fields[$key] = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$profiles->update(
|
||||||
|
$user,
|
||||||
|
$fields,
|
||||||
|
$request->input('reply_variant'),
|
||||||
|
$request->exists('reply_variant'),
|
||||||
|
(string) ($user->mail ?? $user->user_principal_name),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($profiles->profile($user->fresh()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,22 +4,53 @@ namespace App\Http\Controllers\Portal;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\DirectoryUser;
|
use App\Models\DirectoryUser;
|
||||||
|
use App\Signature\ProfileService;
|
||||||
use App\Signature\SignatureBuilder;
|
use App\Signature\SignatureBuilder;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
|
|
||||||
/** Portail agent : aperçu de la signature. Champs modifiables et préférences arrivent au lot 2. */
|
/** Portail agent : aperçu de la signature et modification des champs ouverts par l'admin. */
|
||||||
class PortalController extends Controller
|
class PortalController extends Controller
|
||||||
{
|
{
|
||||||
public function __invoke(Request $request, SignatureBuilder $builder): View
|
public function show(Request $request, SignatureBuilder $builder, ProfileService $profiles): View
|
||||||
{
|
{
|
||||||
$user = $request->user();
|
$agent = $this->agent($request);
|
||||||
$agent = DirectoryUser::where('tenant_id', $user->tenant_id)->where('entra_oid', $user->entra_oid)->firstOrFail();
|
|
||||||
|
|
||||||
return view('portal.home', [
|
return view('portal.home', [
|
||||||
'agent' => $agent,
|
'agent' => $agent,
|
||||||
|
'profile' => $profiles->profile($agent),
|
||||||
'full' => $builder->build($agent, 'new'),
|
'full' => $builder->build($agent, 'new'),
|
||||||
'reply' => $builder->build($agent, 'reply'),
|
'reply' => $builder->build($agent, 'reply'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, ProfileService $profiles): RedirectResponse
|
||||||
|
{
|
||||||
|
$agent = $this->agent($request);
|
||||||
|
$values = [];
|
||||||
|
foreach ((array) $request->input('fields', []) as $key => $row) {
|
||||||
|
$mode = $row['mode'] ?? 'directory';
|
||||||
|
$values[$key] = match ($mode) {
|
||||||
|
'custom' => (string) ($row['value'] ?? ''),
|
||||||
|
'hidden' => '',
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
if ($mode === 'custom' && trim((string) ($row['value'] ?? '')) === '') {
|
||||||
|
$values[$key] = null; // valeur perso vide : on revient à l'annuaire
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$reply = $request->input('reply_variant');
|
||||||
|
|
||||||
|
$profiles->update($agent, $values, $reply === 'default' ? null : $reply, $request->has('reply_variant'), (string) $agent->mail);
|
||||||
|
|
||||||
|
return back()->with('status', 'Vos informations sont enregistrées : elles s\'appliquent à vos prochains messages.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function agent(Request $request): DirectoryUser
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
return DirectoryUser::where('tenant_id', $user->tenant_id)->where('entra_oid', $user->entra_oid)->firstOrFail();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Signature;
|
||||||
|
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\DirectoryUser;
|
||||||
|
use App\Models\EditableField;
|
||||||
|
use App\Models\UserOverride;
|
||||||
|
use App\Models\UserPreference;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Informations modifiables par l'agent lui-même (add-in et portail).
|
||||||
|
* - Seuls les champs ouverts par l'admin sont acceptés.
|
||||||
|
* - Une surcharge ne modifie que la signature : l'annuaire (AD / Entra) reste la référence.
|
||||||
|
* - Valeur null = revenir à la valeur de l'annuaire ; chaîne vide = ne pas afficher ce champ.
|
||||||
|
* - Publication immédiate, chaque modification est tracée dans le journal d'audit.
|
||||||
|
*/
|
||||||
|
final class ProfileService
|
||||||
|
{
|
||||||
|
public const TYPES = [
|
||||||
|
'text' => ['label' => 'Texte libre', 'rule' => ['string', 'max:120']],
|
||||||
|
'phone' => ['label' => 'Téléphone', 'rule' => ['string', 'max:25', 'regex:/^[0-9 +().\-]{6,25}$/']],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @return array{fields: array<int, array>, reply_variant: ?string, reply_variant_default: string} */
|
||||||
|
public function profile(DirectoryUser $user): array
|
||||||
|
{
|
||||||
|
$editable = $this->editableFields($user);
|
||||||
|
$overrides = $user->overrides()->pluck('value', 'field');
|
||||||
|
|
||||||
|
$fields = [];
|
||||||
|
foreach ($editable as $f) {
|
||||||
|
$column = VariableMap::FIELDS[$f->field]['column'] ?? null;
|
||||||
|
if (! $column) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$fields[] = [
|
||||||
|
'key' => $f->field,
|
||||||
|
'label' => $f->label,
|
||||||
|
'type' => $f->validation_rule ?: 'text',
|
||||||
|
'directory_value' => $user->getAttribute($column),
|
||||||
|
'override' => $overrides->has($f->field) ? $overrides[$f->field] : null,
|
||||||
|
'overridden' => $overrides->has($f->field),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'fields' => $fields,
|
||||||
|
'reply_variant' => $user->preference?->reply_variant,
|
||||||
|
'reply_variant_default' => config('signature.default_reply_variant', 'short'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, string|null> $values clé de variable => valeur (null = rétablir l'annuaire)
|
||||||
|
*
|
||||||
|
* @throws \Illuminate\Validation\ValidationException
|
||||||
|
*/
|
||||||
|
public function update(DirectoryUser $user, array $values, ?string $replyVariant, bool $replyVariantProvided, string $actor): void
|
||||||
|
{
|
||||||
|
$editable = $this->editableFields($user)->keyBy('field');
|
||||||
|
|
||||||
|
$forbidden = array_diff(array_keys($values), $editable->keys()->all());
|
||||||
|
if ($forbidden) {
|
||||||
|
throw ValidationException::withMessages(
|
||||||
|
collect($forbidden)->mapWithKeys(fn ($k) => ["fields.{$k}" => 'Ce champ n\'est pas modifiable.'])->all()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rules = ['reply_variant' => ['nullable', Rule::in(['short', 'full', 'none'])]];
|
||||||
|
foreach (array_keys($values) as $key) {
|
||||||
|
$type = $editable[$key]->validation_rule ?: 'text';
|
||||||
|
$rules["fields.{$key}"] = array_merge(['nullable'], self::TYPES[$type]['rule'] ?? self::TYPES['text']['rule']);
|
||||||
|
}
|
||||||
|
// Chaîne vide = masquer le champ : rien à valider pour elle.
|
||||||
|
Validator::make(
|
||||||
|
['fields' => array_filter($values, fn ($v) => $v !== ''), 'reply_variant' => $replyVariant],
|
||||||
|
$rules,
|
||||||
|
['fields.*.regex' => 'Numéro invalide (chiffres, espaces, + . - ( ) uniquement).', 'fields.*.max' => 'Valeur trop longue.'],
|
||||||
|
)->validate();
|
||||||
|
|
||||||
|
DB::transaction(function () use ($user, $values, $replyVariant, $replyVariantProvided, $actor) {
|
||||||
|
$changes = [];
|
||||||
|
foreach ($values as $key => $value) {
|
||||||
|
$value = $value === null ? null : trim((string) $value);
|
||||||
|
$current = UserOverride::where('directory_user_id', $user->id)->where('field', $key)->first();
|
||||||
|
|
||||||
|
if ($value === null) {
|
||||||
|
if ($current) {
|
||||||
|
$changes[$key] = [$current->value, '(annuaire)'];
|
||||||
|
$current->delete();
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($current?->value === $value) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$changes[$key] = [$current?->value ?? '(annuaire)', $value === '' ? '(masqué)' : $value];
|
||||||
|
UserOverride::updateOrCreate(['directory_user_id' => $user->id, 'field' => $key], ['value' => $value]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($replyVariantProvided) {
|
||||||
|
$pref = UserPreference::firstOrNew(['directory_user_id' => $user->id]);
|
||||||
|
if ($pref->reply_variant !== $replyVariant) {
|
||||||
|
$changes['reply_variant'] = [$pref->reply_variant ?? '(défaut)', $replyVariant ?? '(défaut)'];
|
||||||
|
$pref->reply_variant = $replyVariant;
|
||||||
|
$pref->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($changes) {
|
||||||
|
AuditLog::record('agent', $actor, 'profile.update', $user, $changes);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function editableFields(DirectoryUser $user)
|
||||||
|
{
|
||||||
|
return EditableField::where('tenant_id', $user->tenant_id)->where('enabled', true)
|
||||||
|
->whereIn('field', array_keys(VariableMap::FIELDS))
|
||||||
|
->orderBy('id')->get();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
@extends('layouts.app')
|
||||||
|
@section('title', 'Champs agents — Signatures CABM')
|
||||||
|
@section('content')
|
||||||
|
<h1>Champs modifiables par les agents</h1>
|
||||||
|
<p class="muted" style="margin-top:-8px">
|
||||||
|
Les agents modifient ces champs dans l'add-in (bouton « Ma signature ») ou sur le portail.
|
||||||
|
La valeur saisie remplace celle de l'annuaire <strong>dans la signature uniquement</strong> : l'AD reste la référence.
|
||||||
|
Publication immédiate, chaque modification est tracée. Fermer un champ fait revenir les signatures concernées à la valeur de l'annuaire.
|
||||||
|
</p>
|
||||||
|
<form method="post" action="{{ route('admin.fields.update') }}" class="card">
|
||||||
|
@csrf @method('PUT')
|
||||||
|
<table class="list">
|
||||||
|
<thead><tr><th>Ouvert</th><th>Variable</th><th>Libellé affiché à l'agent</th><th>Contrôle</th><th>Agents ayant une valeur perso</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach ($fields as $f)
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<input type="hidden" name="fields[{{ $f->field }}][enabled]" value="0">
|
||||||
|
<input type="checkbox" name="fields[{{ $f->field }}][enabled]" value="1" @checked($f->enabled) aria-label="Ouvrir {{ $f->label }}">
|
||||||
|
</td>
|
||||||
|
<td><code>{{ $f->field }}</code></td>
|
||||||
|
<td><input type="text" name="fields[{{ $f->field }}][label]" value="{{ $f->label }}" maxlength="60" required></td>
|
||||||
|
<td>
|
||||||
|
<select name="fields[{{ $f->field }}][type]">
|
||||||
|
@foreach ($types as $key => $type)
|
||||||
|
<option value="{{ $key }}" @selected(($f->validation_rule ?: 'text') === $key)>{{ $type['label'] }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td class="muted">{{ $usage[$f->field] ?? 0 }}</td>
|
||||||
|
</tr>
|
||||||
|
@endforeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="row" style="margin-top:12px">
|
||||||
|
<button class="btn" type="submit">Enregistrer</button>
|
||||||
|
<span class="muted">L'adresse e-mail n'est jamais modifiable.</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
@endsection
|
||||||
@@ -58,6 +58,7 @@
|
|||||||
<a href="{{ route('admin.images.index') }}" @class(['active' => request()->routeIs('admin.images.*')])>Images</a>
|
<a href="{{ route('admin.images.index') }}" @class(['active' => request()->routeIs('admin.images.*')])>Images</a>
|
||||||
@if (auth()->user()->hasRole('admin'))
|
@if (auth()->user()->hasRole('admin'))
|
||||||
<a href="{{ route('admin.assignments.index') }}" @class(['active' => request()->routeIs('admin.assignments.*')])>Affectations</a>
|
<a href="{{ route('admin.assignments.index') }}" @class(['active' => request()->routeIs('admin.assignments.*')])>Affectations</a>
|
||||||
|
<a href="{{ route('admin.fields.index') }}" @class(['active' => request()->routeIs('admin.fields.*')])>Champs agents</a>
|
||||||
@endif
|
@endif
|
||||||
<a href="{{ route('admin.agents.index') }}" @class(['active' => request()->routeIs('admin.agents.*')])>Agents</a>
|
<a href="{{ route('admin.agents.index') }}" @class(['active' => request()->routeIs('admin.agents.*')])>Agents</a>
|
||||||
<a href="{{ route('portal.home') }}" @class(['active' => request()->routeIs('portal.*')])>Ma signature</a>
|
<a href="{{ route('portal.home') }}" @class(['active' => request()->routeIs('portal.*')])>Ma signature</a>
|
||||||
|
|||||||
@@ -1,13 +1,64 @@
|
|||||||
@extends('layouts.app')
|
@extends('layouts.app')
|
||||||
@section('title', 'Ma signature — CABM')
|
@section('title', 'Ma signature — CABM')
|
||||||
@section('content')
|
@section('content')
|
||||||
<div class="card">
|
<h1>Ma signature</h1>
|
||||||
<h1>Ma signature</h1>
|
<div class="split">
|
||||||
<p class="muted">Modèle : {{ $full['template'] ?? 'aucun' }}. Vos informations proviennent de l'annuaire de la collectivité ;
|
<div>
|
||||||
la modification de certains champs arrivera prochainement.</p>
|
<div class="card">
|
||||||
<h2>Nouveau message</h2>
|
<h2>Nouveau message</h2>
|
||||||
@include('partials.signature-frame', ['html' => $full['html'] ?? '<em>Aucune signature</em>'])
|
<p class="muted">Modèle : {{ $full['template'] ?? 'aucun' }}</p>
|
||||||
<h2 style="margin-top:16px">Réponse / transfert</h2>
|
@include('partials.signature-frame', ['html' => $full['html'] ?? '<em>Aucune signature</em>'])
|
||||||
@include('partials.signature-frame', ['html' => ($reply['html'] ?? '') !== '' ? $reply['html'] : '<em>Aucune signature</em>'])
|
<h2 style="margin-top:16px">Réponse / transfert</h2>
|
||||||
|
@include('partials.signature-frame', ['html' => ($reply['html'] ?? '') !== '' ? $reply['html'] : '<em>Aucune signature</em>'])
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="{{ route('portal.update') }}" class="card">
|
||||||
|
@csrf @method('PUT')
|
||||||
|
<h2>Mes informations</h2>
|
||||||
|
@if (! $profile['fields'])
|
||||||
|
<p class="muted">Aucun champ n'est modifiable pour le moment. Vos informations viennent de l'annuaire de la collectivité :
|
||||||
|
pour les corriger, contactez le service des ressources humaines ou le support informatique.</p>
|
||||||
|
@else
|
||||||
|
<p class="muted">Ces valeurs ne modifient que votre signature, pas l'annuaire.</p>
|
||||||
|
@foreach ($profile['fields'] as $f)
|
||||||
|
@php
|
||||||
|
$mode = ! $f['overridden'] ? 'directory' : ($f['override'] === '' ? 'hidden' : 'custom');
|
||||||
|
$id = 'f-'.$f['key'];
|
||||||
|
@endphp
|
||||||
|
<fieldset style="border:1px solid var(--line);border-radius:6px;padding:10px 12px;margin:12px 0" data-field>
|
||||||
|
<legend style="font-weight:600;font-size:13px;padding:0 4px">{{ $f['label'] }}</legend>
|
||||||
|
<label style="font-weight:400;margin:4px 0"><input type="radio" name="fields[{{ $f['key'] }}][mode]" value="directory" @checked($mode === 'directory')>
|
||||||
|
Annuaire : <strong>{{ $f['directory_value'] ?: '(vide)' }}</strong></label>
|
||||||
|
<label style="font-weight:400;margin:4px 0"><input type="radio" name="fields[{{ $f['key'] }}][mode]" value="custom" @checked($mode === 'custom')>
|
||||||
|
Valeur personnalisée</label>
|
||||||
|
<input type="text" id="{{ $id }}" name="fields[{{ $f['key'] }}][value]" value="{{ $mode === 'custom' ? $f['override'] : '' }}"
|
||||||
|
maxlength="120" @if ($f['type'] === 'phone') inputmode="tel" placeholder="ex. 06 12 34 56 78" @endif aria-label="{{ $f['label'] }} personnalisé">
|
||||||
|
<label style="font-weight:400;margin:4px 0"><input type="radio" name="fields[{{ $f['key'] }}][mode]" value="hidden" @checked($mode === 'hidden')>
|
||||||
|
Ne pas afficher</label>
|
||||||
|
</fieldset>
|
||||||
|
@endforeach
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<h2 style="margin-top:16px">En réponse et transfert</h2>
|
||||||
|
@php $rv = $profile['reply_variant'] ?? 'default'; $labels = ['short' => 'signature courte', 'full' => 'signature complète', 'none' => 'aucune signature']; @endphp
|
||||||
|
@foreach (['default' => 'Réglage par défaut ('.$labels[$profile['reply_variant_default']].')', 'short' => 'Signature courte', 'full' => 'Signature complète', 'none' => 'Aucune signature'] as $value => $label)
|
||||||
|
<label style="font-weight:400;margin:4px 0"><input type="radio" name="reply_variant" value="{{ $value }}" @checked($rv === $value)> {{ $label }}</label>
|
||||||
|
@endforeach
|
||||||
|
|
||||||
|
<div class="row" style="margin-top:16px"><button class="btn" type="submit">Enregistrer</button></div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@endsection
|
@endsection
|
||||||
|
|
||||||
|
@push('scripts')
|
||||||
|
<script>
|
||||||
|
// Saisir une valeur sélectionne automatiquement « Valeur personnalisée »
|
||||||
|
document.querySelectorAll('[data-field] input[type=text]').forEach((input) => {
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
const custom = input.closest('[data-field]').querySelector('input[value=custom]');
|
||||||
|
if (custom) custom.checked = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
@endpush
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\Api\ProfileController;
|
||||||
use App\Http\Controllers\Api\SignatureController;
|
use App\Http\Controllers\Api\SignatureController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
// Appelé par l'add-in Outlook (jeton Entra NAA en Bearer).
|
// Appelé par l'add-in Outlook (jeton Entra NAA en Bearer).
|
||||||
Route::prefix('v1')->middleware(['entra.token', 'throttle:signature'])->group(function () {
|
Route::prefix('v1')->middleware(['entra.token', 'throttle:signature'])->group(function () {
|
||||||
Route::get('/signature', [SignatureController::class, 'show'])->name('api.signature');
|
Route::get('/signature', [SignatureController::class, 'show'])->name('api.signature');
|
||||||
|
Route::get('/profile', [ProfileController::class, 'show'])->name('api.profile');
|
||||||
|
Route::put('/profile', [ProfileController::class, 'update'])->name('api.profile.update');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
use App\Http\Controllers\Admin\AgentController;
|
use App\Http\Controllers\Admin\AgentController;
|
||||||
use App\Http\Controllers\Admin\AssignmentController;
|
use App\Http\Controllers\Admin\AssignmentController;
|
||||||
use App\Http\Controllers\Admin\DashboardController;
|
use App\Http\Controllers\Admin\DashboardController;
|
||||||
|
use App\Http\Controllers\Admin\EditableFieldController;
|
||||||
use App\Http\Controllers\Admin\ImageController;
|
use App\Http\Controllers\Admin\ImageController;
|
||||||
use App\Http\Controllers\Admin\SyncController;
|
use App\Http\Controllers\Admin\SyncController;
|
||||||
use App\Http\Controllers\Admin\TemplateController;
|
use App\Http\Controllers\Admin\TemplateController;
|
||||||
@@ -19,7 +20,8 @@ Route::post('/logout', [EntraLoginController::class, 'logout'])->name('logout')-
|
|||||||
|
|
||||||
// Portail agent (tout agent synchronisé)
|
// Portail agent (tout agent synchronisé)
|
||||||
Route::middleware('auth')->group(function () {
|
Route::middleware('auth')->group(function () {
|
||||||
Route::get('/me', PortalController::class)->name('portal.home');
|
Route::get('/me', [PortalController::class, 'show'])->name('portal.home');
|
||||||
|
Route::put('/me', [PortalController::class, 'update'])->name('portal.update');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Back-office : réseau interne + rôle éditeur minimum
|
// Back-office : réseau interne + rôle éditeur minimum
|
||||||
@@ -50,6 +52,8 @@ Route::prefix('admin')->middleware(['admin.network', 'auth', 'role:editor'])->na
|
|||||||
Route::delete('/affectations/{assignment}', [AssignmentController::class, 'destroy'])->name('assignments.destroy');
|
Route::delete('/affectations/{assignment}', [AssignmentController::class, 'destroy'])->name('assignments.destroy');
|
||||||
|
|
||||||
Route::post('/synchro', SyncController::class)->name('sync');
|
Route::post('/synchro', SyncController::class)->name('sync');
|
||||||
|
Route::get('/champs', [EditableFieldController::class, 'index'])->name('fields.index');
|
||||||
|
Route::put('/champs', [EditableFieldController::class, 'update'])->name('fields.update');
|
||||||
Route::delete('/images/{name}', [ImageController::class, 'destroy'])->name('images.destroy')->where('name', '[A-Za-z0-9._-]+');
|
Route::delete('/images/{name}', [ImageController::class, 'destroy'])->name('images.destroy')->where('name', '[A-Za-z0-9._-]+');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use App\Models\DirectoryUser;
|
||||||
|
use App\Models\EditableField;
|
||||||
|
use App\Models\Template;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserOverride;
|
||||||
|
use App\Services\Auth\EntraTokenValidator;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use RuntimeException;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class ProfileTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
private Tenant $tenant;
|
||||||
|
private DirectoryUser $agent;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
config(['signature.admin_allowed_cidrs' => []]);
|
||||||
|
$this->tenant = Tenant::create(['name' => 'CABM', 'entra_tenant_id' => '11111111-1111-1111-1111-111111111111']);
|
||||||
|
$this->agent = DirectoryUser::create([
|
||||||
|
'tenant_id' => $this->tenant->id, 'entra_oid' => '22222222-2222-2222-2222-222222222222',
|
||||||
|
'given_name' => 'Jean', 'mobile_phone' => '06 00 00 00 00', 'office_location' => 'Bâtiment A',
|
||||||
|
'mail' => 'j.martin@beziers-mediterranee.fr', 'account_enabled' => true,
|
||||||
|
]);
|
||||||
|
EditableField::create(['tenant_id' => $this->tenant->id, 'field' => 'mobile', 'label' => 'Mobile', 'enabled' => true, 'validation_rule' => 'phone']);
|
||||||
|
EditableField::create(['tenant_id' => $this->tenant->id, 'field' => 'bureau', 'label' => 'Bureau', 'enabled' => true, 'validation_rule' => 'text']);
|
||||||
|
EditableField::create(['tenant_id' => $this->tenant->id, 'field' => 'fonction', 'label' => 'Fonction', 'enabled' => false]);
|
||||||
|
Template::create(['tenant_id' => $this->tenant->id, 'name' => 'D', 'is_default' => true,
|
||||||
|
'draft_html_full' => '{{prenom}}{{#if mobile}} M:{{mobile}}{{/if}}{{#if bureau}} B:{{bureau}}{{/if}}'])->publish();
|
||||||
|
|
||||||
|
$this->app->instance(EntraTokenValidator::class, new class extends EntraTokenValidator {
|
||||||
|
public function validate(string $jwt): array
|
||||||
|
{
|
||||||
|
if ($jwt !== 'ok') {
|
||||||
|
throw new RuntimeException('bad');
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['tid' => '11111111-1111-1111-1111-111111111111', 'oid' => '22222222-2222-2222-2222-222222222222'];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_api_liste_seulement_les_champs_ouverts(): void
|
||||||
|
{
|
||||||
|
$this->withToken('ok')->getJson('/api/v1/profile')->assertOk()
|
||||||
|
->assertJsonCount(2, 'fields')
|
||||||
|
->assertJsonPath('fields.0.key', 'mobile')
|
||||||
|
->assertJsonPath('fields.0.directory_value', '06 00 00 00 00')
|
||||||
|
->assertJsonPath('fields.0.overridden', false)
|
||||||
|
->assertJsonPath('reply_variant_default', 'short');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_api_modifie_masque_puis_retablit_et_trace(): void
|
||||||
|
{
|
||||||
|
$this->withToken('ok')->putJson('/api/v1/profile', ['fields' => ['mobile' => '07 11 22 33 44'], 'hidden' => ['bureau'], 'reply_variant' => 'none'])
|
||||||
|
->assertOk()->assertJsonPath('fields.0.override', '07 11 22 33 44')->assertJsonPath('fields.1.override', '')->assertJsonPath('reply_variant', 'none');
|
||||||
|
|
||||||
|
$this->withToken('ok')->getJson('/api/v1/signature')->assertJson(['html' => 'Jean M:07 11 22 33 44']);
|
||||||
|
$this->withToken('ok')->getJson('/api/v1/signature?context=reply')->assertJson(['html' => '', 'variant' => 'none']);
|
||||||
|
$this->assertTrue(AuditLog::where('action', 'profile.update')->where('actor', 'j.martin@beziers-mediterranee.fr')->exists());
|
||||||
|
|
||||||
|
$this->withToken('ok')->putJson('/api/v1/profile', ['fields' => ['mobile' => null, 'bureau' => null], 'reply_variant' => null])->assertOk();
|
||||||
|
$this->assertSame(0, UserOverride::count());
|
||||||
|
$this->withToken('ok')->getJson('/api/v1/signature')->assertJson(['html' => 'Jean M:06 00 00 00 00 B:Bâtiment A']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_api_refuse_champ_ferme_et_numero_invalide(): void
|
||||||
|
{
|
||||||
|
$this->withToken('ok')->putJson('/api/v1/profile', ['fields' => ['fonction' => 'Directeur']])
|
||||||
|
->assertStatus(422)->assertJsonValidationErrors('fields.fonction');
|
||||||
|
$this->withToken('ok')->putJson('/api/v1/profile', ['fields' => ['mobile' => '<b>06</b>']])
|
||||||
|
->assertStatus(422)->assertJsonValidationErrors('fields.mobile');
|
||||||
|
$this->withToken('ok')->putJson('/api/v1/profile', ['fields' => []], )->assertOk();
|
||||||
|
$this->assertSame(0, UserOverride::count());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_portail_enregistre_les_trois_modes(): void
|
||||||
|
{
|
||||||
|
$user = User::create(['name' => 'Jean', 'email' => 'j.martin@beziers-mediterranee.fr', 'tenant_id' => $this->tenant->id, 'entra_oid' => '22222222-2222-2222-2222-222222222222']);
|
||||||
|
$this->actingAs($user)->get('/me')->assertOk()->assertSee('Mes informations')->assertSee('06 00 00 00 00');
|
||||||
|
|
||||||
|
$this->actingAs($user)->put('/me', [
|
||||||
|
'fields' => ['mobile' => ['mode' => 'custom', 'value' => '07 99 99 99 99'], 'bureau' => ['mode' => 'hidden']],
|
||||||
|
'reply_variant' => 'full',
|
||||||
|
])->assertRedirect()->assertSessionHasNoErrors();
|
||||||
|
|
||||||
|
$this->assertSame('07 99 99 99 99', UserOverride::where('field', 'mobile')->value('value'));
|
||||||
|
$this->assertSame('', UserOverride::where('field', 'bureau')->value('value'));
|
||||||
|
$this->assertSame('full', $this->agent->fresh()->preference->reply_variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_ouvre_et_ferme_les_champs(): void
|
||||||
|
{
|
||||||
|
$admin = User::create(['name' => 'A', 'email' => 'a@x.fr', 'tenant_id' => $this->tenant->id, 'entra_oid' => '33333333-3333-3333-3333-333333333333', 'role' => 'admin']);
|
||||||
|
$this->actingAs($admin)->get('/admin/champs')->assertOk()->assertSee('telephone')->assertDontSee('<code>email</code>', false);
|
||||||
|
|
||||||
|
$this->actingAs($admin)->put('/admin/champs', ['fields' => [
|
||||||
|
'mobile' => ['enabled' => '0', 'label' => 'Mobile', 'type' => 'phone'],
|
||||||
|
'fonction' => ['enabled' => '1', 'label' => 'Ma fonction', 'type' => 'text'],
|
||||||
|
]])->assertRedirect();
|
||||||
|
|
||||||
|
$this->assertFalse(EditableField::where('field', 'mobile')->value('enabled'));
|
||||||
|
$this->assertSame('Ma fonction', EditableField::where('field', 'fonction')->value('label'));
|
||||||
|
$this->withToken('ok')->getJson('/api/v1/profile')->assertJsonMissing(['key' => 'mobile'])->assertJsonFragment(['key' => 'fonction']);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user