gestion addin
This commit is contained in:
+58
-1
@@ -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) });
|
||||
|
||||
@@ -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
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user