45
This commit is contained in:
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 |
@@ -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>
|
||||
Generated
+5462
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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__;
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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.";
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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`),
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user