gestion addin

This commit is contained in:
mrKamoo
2026-10-01 13:38:01 +02:00
parent 28eba5e1e4
commit 1b91590cca
13 changed files with 769 additions and 48 deletions
+58 -1
View File
@@ -5,7 +5,7 @@ export type ComposeContext = "new" | "reply" | "forward" | "appointment";
export interface SignatureResponse {
html: string;
variant: "full" | "short";
variant: "full" | "short" | "none";
template: string;
generated_at: string;
}
@@ -40,3 +40,60 @@ export async function fetchSignature(
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) });
+30 -3
View File
@@ -6,15 +6,25 @@
<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; }
: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; }
h1 { font-size: 18px; margin: 0 0 12px; }
h2 { font-size: 15px; margin: 20px 0 8px; }
.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.secondary { background: #fff; color: var(--accent); border: 1px solid var(--accent); }
button:disabled { opacity: .5; cursor: default; }
a { color: var(--accent); }
.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>
</head>
<body>
@@ -23,7 +33,24 @@
<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>
<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>
</html>
+174 -31
View File
@@ -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.
*/
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 { writeCache } from "../shared/cache";
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;
async function load(): Promise<void> {
const status = $("status");
const preview = $("preview");
const apply = $<HTMLButtonElement>("apply");
$<HTMLAnchorElement>("portal").href = CONFIG.portalUrl;
const REPLY_LABELS: Record<ReplyVariant, string> = { short: "Signature courte", full: "Signature complète", none: "Aucune signature" };
const item = currentItem();
const context = await getComposeContext(item);
const mailbox = await getMailbox(item);
let token: string | null = null;
let context: ComposeContext = "new";
let mailbox: string | null = null;
let current: SignatureResponse | null = null;
const token = await getTokenInteractive();
if (!token) {
status.textContent = "Connexion impossible. Contactez le support informatique.";
return;
}
function option(value: string, text: string): HTMLOptionElement {
const o = document.createElement("option");
o.value = value;
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);
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;
}
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 () => {
$<HTMLButtonElement>("apply").onclick = async () => {
if (!current) return;
const apply = $<HTMLButtonElement>("apply");
apply.disabled = true;
try {
await applySignature(item, res.html);
status.textContent = "Signature insérée.";
await applySignature(item, current.html);
$("status").textContent = "Signature insérée.";
} catch (e) {
status.textContent = "Insertion impossible.";
$("status").textContent = "Insertion impossible.";
console.error(e);
} finally {
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(() => {