@bdking71/spsignature 1.3.6 → 1.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,10 +21,109 @@
21
21
  Object.defineProperty(exports, "__esModule", { value: true });
22
22
  exports.verifySecureAuditRecord = exports.getReportableSignature = exports.promptAndGenerateSecureAudit = void 0;
23
23
  const OtpService_1 = require("./OtpService");
24
+ // ---------------------------------------------------------------------------
25
+ // Constants
26
+ // ---------------------------------------------------------------------------
24
27
  /** localStorage key used to persist a compressed signature across sessions. */
25
28
  const STORAGE_KEY = "secure_audit_cached_signature_v1";
26
29
  /** Maximum upload file size in bytes (5 MB). */
27
30
  const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
31
+ /** Maximum LZW dictionary size (prevents corruption in localStorage/JSON). */
32
+ const LZW_DICT_MAX = 65535;
33
+ /** Canvas dimensions. */
34
+ const CANVAS_WIDTH = 560;
35
+ const CANVAS_HEIGHT = 160;
36
+ /** Image resize limits. */
37
+ const IMAGE_MAX_WIDTH = 560;
38
+ const IMAGE_MAX_HEIGHT = 160;
39
+ /** Passcode generation limits. */
40
+ const PASSCODE_MIN = 10000;
41
+ const PASSCODE_MAX = 99999;
42
+ const PASSCODE_LENGTH = 5;
43
+ /** TFA cooldown timer (seconds). */
44
+ const TFA_COOLDOWN_SECONDS = 60;
45
+ /** Modal close timeout (milliseconds) - prevents hanging promises. */
46
+ const MODAL_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
47
+ /** Timestamp precision (seconds, not milliseconds). */
48
+ const TIMESTAMP_PRECISION = "seconds";
49
+ /** Fluent Design System colors. */
50
+ const COLORS = {
51
+ PRIMARY: "#0078d4",
52
+ PRIMARY_DARK: "#005a9e",
53
+ ERROR: "#a4262c",
54
+ WARNING: "#ffd54f",
55
+ WARNING_BG: "#fff8e1",
56
+ INFO: "#e6f4ff",
57
+ INFO_BORDER: "#0078d4",
58
+ NEUTRAL_BG: "#fafafa",
59
+ NEUTRAL_LIGHT: "#f3f2f1",
60
+ NEUTRAL_LIGHTER: "#e1dfdd",
61
+ NEUTRAL_TEXT: "#323130",
62
+ NEUTRAL_TEXT_SEC: "#605e5c",
63
+ NEUTRAL_BORDER: "#d1d1d1",
64
+ NEUTRAL_BORDER_DARK: "#8a8886",
65
+ };
66
+ /** UI Icons (with accessible fallbacks). */
67
+ const ICONS = {
68
+ LOCK: "🔐",
69
+ CHECK: "✓",
70
+ FOLDER: "📁",
71
+ };
72
+ // Global modal state to prevent concurrent instances
73
+ let activeModalInstance = null;
74
+ // ---------------------------------------------------------------------------
75
+ // Helpers – validation
76
+ // ---------------------------------------------------------------------------
77
+ /**
78
+ * Validates that a value is a non-empty string.
79
+ *
80
+ * @param value - The value to check.
81
+ * @param fieldName - Field name for error messages.
82
+ * @returns The trimmed string.
83
+ * @throws {Error} If value is not a valid string.
84
+ */
85
+ function assertNonEmptyString(value, fieldName) {
86
+ if (typeof value !== "string" || value.trim().length === 0) {
87
+ throw new Error(`${fieldName} must be a non-empty string.`);
88
+ }
89
+ return value.trim();
90
+ }
91
+ /**
92
+ * Validates that an object is JSON-serializable.
93
+ *
94
+ * @param obj - Object to validate.
95
+ * @throws {Error} If object contains non-serializable values.
96
+ */
97
+ function validateJsonSerializable(obj) {
98
+ try {
99
+ JSON.stringify(obj);
100
+ }
101
+ catch (error) {
102
+ throw new Error(`Payload is not JSON-serializable: ${String(error)}`);
103
+ }
104
+ }
105
+ /**
106
+ * Sanitizes email/signer input to prevent XSS.
107
+ *
108
+ * @param input - The signer identifier.
109
+ * @returns Sanitized string (alphanumeric, @, ., -, _).
110
+ */
111
+ function sanitizeSigner(input) {
112
+ const sanitized = input.toLowerCase().trim();
113
+ if (!/^[a-z0-9@._-]+$/.test(sanitized)) {
114
+ throw new Error("Signer identifier contains invalid characters.");
115
+ }
116
+ return sanitized;
117
+ }
118
+ /**
119
+ * Generates a precision-reduced ISO timestamp (seconds, not milliseconds)
120
+ * to avoid hash mismatches from millisecond variations.
121
+ *
122
+ * @returns ISO-8601 timestamp (seconds precision).
123
+ */
124
+ function generateTimestamp() {
125
+ return new Date().toISOString().split(".")[0] + "Z";
126
+ }
28
127
  // ---------------------------------------------------------------------------
29
128
  // Helpers – passcode generation
30
129
  // ---------------------------------------------------------------------------
@@ -37,7 +136,7 @@ const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
37
136
  function generateFiveDigitPasscode() {
38
137
  const array = new Uint32Array(1);
39
138
  window.crypto.getRandomValues(array);
40
- const code = (array[0] % 90000) + 10000;
139
+ const code = (array[0] % (PASSCODE_MAX - PASSCODE_MIN + 1)) + PASSCODE_MIN;
41
140
  return code.toString();
42
141
  }
43
142
  // ---------------------------------------------------------------------------
@@ -46,20 +145,23 @@ function generateFiveDigitPasscode() {
46
145
  /**
47
146
  * Opens a full-screen modal dialog that walks the user through:
48
147
  *
49
- * 1. (Optional) Two-factor passcode verification
148
+ * 1. (Optional) Two-factor passcode verification (user-initiated)
50
149
  * 2. Providing a digital signature (cached / drawn / uploaded)
51
150
  * 3. Generating a tamper-evident SHA-256 audit record
52
151
  *
53
152
  * The returned promise resolves with a `SharePointAuditRecord` on
54
153
  * success or `undefined` if the user cancels.
55
154
  *
155
+ * **Note:** Only one modal can be active at a time. Attempting to open
156
+ * a second modal while one is active will return `undefined` immediately.
157
+ *
56
158
  * @param context - Signer metadata and SPFx context.
57
159
  * @param modalTitle - Title shown in the modal header.
58
160
  * @param warningMessage - Instructional HTML rendered above the
59
161
  * signature area.
60
162
  * @returns A promise that resolves to the audit record or `undefined`.
61
163
  *
62
- * @throws {Error} If `context.spContext` is falsy.
164
+ * @throws {Error} If `context.spContext` is falsy or signer is invalid.
63
165
  *
64
166
  * @example
65
167
  * ```ts
@@ -71,18 +173,41 @@ function generateFiveDigitPasscode() {
71
173
  * channel: "email",
72
174
  * requireTFA: true, // Enable TFA
73
175
  * });
176
+ *
177
+ * if (record) {
178
+ * console.log("Signed successfully!");
179
+ * }
74
180
  * ```
75
181
  */
76
182
  async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purchase Requisition", warningMessage = "Please review your entry and apply your signature to finalize this record.") {
77
183
  if (!context.spContext) {
78
184
  throw new Error("Execution restricted: Valid WebPartContext must be supplied.");
79
185
  }
186
+ // Prevent concurrent modals
187
+ if (activeModalInstance !== null) {
188
+ return undefined;
189
+ }
190
+ // Validate signer early
191
+ try {
192
+ assertNonEmptyString(context.signer, "Signer");
193
+ sanitizeSigner(context.signer);
194
+ }
195
+ catch (error) {
196
+ throw new Error(`Invalid signer: ${String(error)}`);
197
+ }
80
198
  return new Promise((resolve) => {
81
- // TFA is only enabled if explicitly set to true
199
+ // Create abort controller for this modal instance
200
+ const abortController = new AbortController();
201
+ activeModalInstance = abortController;
202
+ // Set timeout to prevent hanging promises
203
+ const timeoutId = window.setTimeout(() => {
204
+ cleanup();
205
+ resolve(undefined);
206
+ }, MODAL_TIMEOUT_MS);
82
207
  const requireTFA = context.requireTFA === true;
83
208
  const generatedPasscode = generateFiveDigitPasscode();
84
209
  let storedVerificationItemId = undefined;
85
- console.log("TFA Enabled:", requireTFA); // DEBUG
210
+ let tfaVerified = false;
86
211
  // -----------------------------------------------------------------
87
212
  // Overlay
88
213
  // -----------------------------------------------------------------
@@ -118,9 +243,9 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
118
243
  // Header
119
244
  // -----------------------------------------------------------------
120
245
  const headerSection = document.createElement("div");
121
- headerSection.style.backgroundColor = "#0078d4";
246
+ headerSection.style.backgroundColor = COLORS.PRIMARY;
122
247
  headerSection.style.padding = "20px 24px";
123
- headerSection.style.borderBottom = "3px solid #005a9e";
248
+ headerSection.style.borderBottom = `3px solid ${COLORS.PRIMARY_DARK}`;
124
249
  const title = document.createElement("h2");
125
250
  title.innerText = modalTitle;
126
251
  title.style.margin = "0";
@@ -134,13 +259,13 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
134
259
  // -----------------------------------------------------------------
135
260
  const contentSection = document.createElement("div");
136
261
  contentSection.style.padding = "24px";
137
- contentSection.style.backgroundColor = "#fafafa";
262
+ contentSection.style.backgroundColor = COLORS.NEUTRAL_BG;
138
263
  contentSection.style.overflowY = "auto";
139
264
  contentSection.style.flex = "1";
140
265
  const body = document.createElement("p");
141
266
  body.innerHTML = warningMessage;
142
267
  body.style.fontSize = "14px";
143
- body.style.color = "#323130";
268
+ body.style.color = COLORS.NEUTRAL_TEXT;
144
269
  body.style.lineHeight = "1.6";
145
270
  body.style.margin = "0 0 20px 0";
146
271
  // -----------------------------------------------------------------
@@ -148,8 +273,9 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
148
273
  // -----------------------------------------------------------------
149
274
  const signButton = document.createElement("button");
150
275
  signButton.innerText = "I Agree and Sign";
276
+ signButton.setAttribute("aria-label", "Sign the document with your digital signature");
151
277
  signButton.style.padding = "10px 24px";
152
- signButton.style.backgroundColor = "#0078d4";
278
+ signButton.style.backgroundColor = COLORS.PRIMARY;
153
279
  signButton.style.color = "#ffffff";
154
280
  signButton.style.border = "none";
155
281
  signButton.style.cursor = "pointer";
@@ -161,90 +287,91 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
161
287
  // Two-factor verification panel
162
288
  // -----------------------------------------------------------------
163
289
  const verificationContainer = document.createElement("div");
164
- /** Direct reference to the passcode <input> (avoids fragile querySelector). */
165
290
  let verificationInput = null;
166
- /** Direct reference to the "Resend Code" button. */
167
291
  let sendCodeButton = null;
168
292
  if (requireTFA) {
169
293
  verificationContainer.style.marginBottom = "20px";
170
294
  verificationContainer.style.padding = "16px";
171
- verificationContainer.style.backgroundColor = "#fff8e1";
172
- verificationContainer.style.border = "1px solid #ffd54f";
295
+ verificationContainer.style.backgroundColor = COLORS.WARNING_BG;
296
+ verificationContainer.style.border = `1px solid ${COLORS.WARNING}`;
173
297
  verificationContainer.style.borderRadius = "6px";
174
- verificationContainer.style.boxShadow =
175
- "0 2px 4px rgba(0,0,0,0.05)";
176
- /* Header row with lock icon */
298
+ verificationContainer.style.boxShadow = "0 2px 4px rgba(0,0,0,0.05)";
177
299
  const verificationHeader = document.createElement("div");
178
300
  verificationHeader.style.display = "flex";
179
301
  verificationHeader.style.alignItems = "center";
180
302
  verificationHeader.style.marginBottom = "12px";
181
303
  verificationHeader.style.gap = "8px";
182
304
  const lockIcon = document.createElement("span");
183
- lockIcon.innerHTML = "🔐";
305
+ lockIcon.innerHTML = ICONS.LOCK;
184
306
  lockIcon.style.fontSize = "18px";
307
+ lockIcon.setAttribute("aria-hidden", "true");
185
308
  const verificationLabel = document.createElement("label");
186
309
  verificationLabel.innerText = "Two-Factor Authentication";
187
310
  verificationLabel.style.display = "block";
188
311
  verificationLabel.style.fontSize = "13px";
189
312
  verificationLabel.style.fontWeight = "700";
190
- verificationLabel.style.color = "#323130";
313
+ verificationLabel.style.color = COLORS.NEUTRAL_TEXT;
191
314
  verificationLabel.style.margin = "0";
192
315
  verificationHeader.appendChild(lockIcon);
193
316
  verificationHeader.appendChild(verificationLabel);
194
317
  const verificationDescription = document.createElement("p");
195
- verificationDescription.innerText = `A 5-digit verification code has been sent to ${context.channel === "teams" ? "Microsoft Teams" : "your email"}.`;
318
+ verificationDescription.innerText = `Click "Send Code" to receive a 5-digit verification code via ${context.channel === "teams" ? "Microsoft Teams" : "email"}.`;
196
319
  verificationDescription.style.fontSize = "12px";
197
- verificationDescription.style.color = "#605e5c";
320
+ verificationDescription.style.color = COLORS.NEUTRAL_TEXT_SEC;
198
321
  verificationDescription.style.margin = "0 0 12px 0";
199
322
  verificationDescription.style.lineHeight = "1.5";
200
- /* Input + Resend row */
201
323
  const verificationRow = document.createElement("div");
202
324
  verificationRow.style.display = "flex";
203
325
  verificationRow.style.gap = "10px";
204
326
  verificationInput = document.createElement("input");
205
327
  verificationInput.type = "text";
206
- verificationInput.maxLength = 5;
328
+ verificationInput.inputMode = "numeric";
329
+ verificationInput.maxLength = PASSCODE_LENGTH;
207
330
  verificationInput.placeholder = "Enter 5-digit code";
331
+ verificationInput.setAttribute("aria-label", "Two-factor authentication code");
208
332
  verificationInput.style.flex = "1";
209
333
  verificationInput.style.padding = "10px 12px";
210
334
  verificationInput.style.fontSize = "14px";
211
335
  verificationInput.style.boxSizing = "border-box";
212
- verificationInput.style.border = "2px solid #d1d1d1";
336
+ verificationInput.style.border = `2px solid ${COLORS.NEUTRAL_BORDER}`;
213
337
  verificationInput.style.borderRadius = "4px";
214
338
  verificationInput.style.outline = "none";
215
339
  verificationInput.style.transition = "border-color 0.2s ease";
216
340
  verificationInput.style.textAlign = "center";
217
341
  verificationInput.style.letterSpacing = "2px";
218
342
  verificationInput.style.fontWeight = "600";
219
- const vInput = verificationInput; // capture for closures
343
+ const vInput = verificationInput;
220
344
  vInput.onfocus = () => {
221
- vInput.style.borderColor = "#0078d4";
345
+ vInput.style.borderColor = COLORS.PRIMARY;
222
346
  };
223
347
  vInput.onblur = () => {
224
- vInput.style.borderColor = "#d1d1d1";
348
+ vInput.style.borderColor = COLORS.NEUTRAL_BORDER;
225
349
  };
226
350
  sendCodeButton = document.createElement("button");
227
- sendCodeButton.innerText = "Resend Code";
351
+ sendCodeButton.innerText = "Send Code";
228
352
  sendCodeButton.type = "button";
353
+ sendCodeButton.setAttribute("aria-label", "Send verification code");
229
354
  sendCodeButton.style.padding = "10px 16px";
230
355
  sendCodeButton.style.fontSize = "12px";
231
356
  sendCodeButton.style.backgroundColor = "#ffffff";
232
- sendCodeButton.style.color = "#0078d4";
233
- sendCodeButton.style.border = "2px solid #0078d4";
357
+ sendCodeButton.style.color = COLORS.PRIMARY;
358
+ sendCodeButton.style.border = `2px solid ${COLORS.PRIMARY}`;
234
359
  sendCodeButton.style.cursor = "pointer";
235
360
  sendCodeButton.style.whiteSpace = "nowrap";
236
361
  sendCodeButton.style.borderRadius = "4px";
237
362
  sendCodeButton.style.fontWeight = "600";
238
363
  sendCodeButton.style.transition = "all 0.2s ease";
239
- const scBtn = sendCodeButton; // capture for closures
364
+ const scBtn = sendCodeButton;
240
365
  scBtn.onmouseover = () => {
241
- scBtn.style.backgroundColor = "#0078d4";
242
- scBtn.style.color = "#ffffff";
366
+ if (!scBtn.disabled) {
367
+ scBtn.style.backgroundColor = COLORS.PRIMARY;
368
+ scBtn.style.color = "#ffffff";
369
+ }
243
370
  };
244
371
  scBtn.onmouseout = () => {
245
372
  if (!scBtn.disabled) {
246
373
  scBtn.style.backgroundColor = "#ffffff";
247
- scBtn.style.color = "#0078d4";
374
+ scBtn.style.color = COLORS.PRIMARY;
248
375
  }
249
376
  };
250
377
  verificationRow.appendChild(verificationInput);
@@ -258,9 +385,20 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
258
385
  // -----------------------------------------------------------------
259
386
  const cachedCompressedSig = localStorage.getItem(STORAGE_KEY);
260
387
  let hasCachedSignature = cachedCompressedSig !== null && cachedCompressedSig.trim() !== "";
261
- const decompressedCachedDataUri = hasCachedSignature && cachedCompressedSig
262
- ? lzwDecompress(cachedCompressedSig)
263
- : "";
388
+ let decompressedCachedDataUri = "";
389
+ if (hasCachedSignature && cachedCompressedSig) {
390
+ try {
391
+ decompressedCachedDataUri = lzwDecompress(cachedCompressedSig);
392
+ if (!decompressedCachedDataUri.startsWith("data:image")) {
393
+ hasCachedSignature = false;
394
+ decompressedCachedDataUri = "";
395
+ }
396
+ }
397
+ catch (_a) {
398
+ hasCachedSignature = false;
399
+ decompressedCachedDataUri = "";
400
+ }
401
+ }
264
402
  // -----------------------------------------------------------------
265
403
  // Signature section wrapper
266
404
  // -----------------------------------------------------------------
@@ -268,7 +406,7 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
268
406
  signatureSection.style.marginBottom = "20px";
269
407
  signatureSection.style.padding = "16px";
270
408
  signatureSection.style.backgroundColor = "#ffffff";
271
- signatureSection.style.border = "1px solid #d1d1d1";
409
+ signatureSection.style.border = `1px solid ${COLORS.NEUTRAL_BORDER}`;
272
410
  signatureSection.style.borderRadius = "6px";
273
411
  signatureSection.style.boxShadow = "0 2px 4px rgba(0,0,0,0.05)";
274
412
  const signatureHeader = document.createElement("div");
@@ -277,13 +415,13 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
277
415
  signatureLabel.innerText = "Digital Signature";
278
416
  signatureLabel.style.fontSize = "14px";
279
417
  signatureLabel.style.fontWeight = "700";
280
- signatureLabel.style.color = "#323130";
418
+ signatureLabel.style.color = COLORS.NEUTRAL_TEXT;
281
419
  signatureLabel.style.margin = "0 0 4px 0";
282
420
  const signatureSubtext = document.createElement("p");
283
421
  signatureSubtext.innerText =
284
422
  "Choose how you'd like to provide your signature";
285
423
  signatureSubtext.style.fontSize = "12px";
286
- signatureSubtext.style.color = "#605e5c";
424
+ signatureSubtext.style.color = COLORS.NEUTRAL_TEXT_SEC;
287
425
  signatureSubtext.style.margin = "0";
288
426
  signatureHeader.appendChild(signatureLabel);
289
427
  signatureHeader.appendChild(signatureSubtext);
@@ -293,8 +431,8 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
293
431
  const cacheControlBox = document.createElement("div");
294
432
  cacheControlBox.style.marginTop = "16px";
295
433
  cacheControlBox.style.padding = "12px";
296
- cacheControlBox.style.backgroundColor = "#f3f2f1";
297
- cacheControlBox.style.border = "1px solid #d1d1d1";
434
+ cacheControlBox.style.backgroundColor = COLORS.NEUTRAL_LIGHT;
435
+ cacheControlBox.style.border = `1px solid ${COLORS.NEUTRAL_BORDER}`;
298
436
  cacheControlBox.style.borderRadius = "4px";
299
437
  cacheControlBox.style.fontSize = "12px";
300
438
  const cacheCheckboxLabel = document.createElement("label");
@@ -305,13 +443,14 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
305
443
  const cacheCheckbox = document.createElement("input");
306
444
  cacheCheckbox.type = "checkbox";
307
445
  cacheCheckbox.checked = true;
446
+ cacheCheckbox.setAttribute("aria-label", "Remember signature for future transactions");
308
447
  cacheCheckbox.style.cursor = "pointer";
309
448
  cacheCheckbox.style.width = "16px";
310
449
  cacheCheckbox.style.height = "16px";
311
450
  const cacheCheckboxText = document.createElement("span");
312
451
  cacheCheckboxText.innerText =
313
452
  "Remember my signature on this device for future transactions";
314
- cacheCheckboxText.style.color = "#323130";
453
+ cacheCheckboxText.style.color = COLORS.NEUTRAL_TEXT;
315
454
  cacheCheckboxText.style.fontSize = "12px";
316
455
  cacheCheckboxLabel.appendChild(cacheCheckbox);
317
456
  cacheCheckboxLabel.appendChild(cacheCheckboxText);
@@ -322,49 +461,51 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
322
461
  const cachedNoticeContainer = document.createElement("div");
323
462
  cachedNoticeContainer.style.marginBottom = "16px";
324
463
  cachedNoticeContainer.style.padding = "16px";
325
- cachedNoticeContainer.style.backgroundColor = "#e6f4ff";
326
- cachedNoticeContainer.style.border = "1px solid #0078d4";
464
+ cachedNoticeContainer.style.backgroundColor = COLORS.INFO;
465
+ cachedNoticeContainer.style.border = `1px solid ${COLORS.INFO_BORDER}`;
327
466
  cachedNoticeContainer.style.borderRadius = "6px";
328
467
  cachedNoticeContainer.style.display = hasCachedSignature
329
468
  ? "block"
330
469
  : "none";
331
470
  const cachedNoticeText = document.createElement("div");
332
- cachedNoticeText.innerText = "✓ Saved signature on file";
471
+ cachedNoticeText.innerText = `${ICONS.CHECK} Saved signature on file`;
333
472
  cachedNoticeText.style.fontSize = "13px";
334
473
  cachedNoticeText.style.marginBottom = "12px";
335
474
  cachedNoticeText.style.fontWeight = "600";
336
- cachedNoticeText.style.color = "#0078d4";
475
+ cachedNoticeText.style.color = COLORS.PRIMARY;
337
476
  const cachedPreviewImage = document.createElement("img");
338
477
  cachedPreviewImage.src = decompressedCachedDataUri;
478
+ cachedPreviewImage.alt = "Your saved signature";
339
479
  cachedPreviewImage.style.maxWidth = "100%";
340
480
  cachedPreviewImage.style.height = "auto";
341
481
  cachedPreviewImage.style.minHeight = "80px";
342
482
  cachedPreviewImage.style.maxHeight = "120px";
343
483
  cachedPreviewImage.style.display = "block";
344
484
  cachedPreviewImage.style.marginBottom = "12px";
345
- cachedPreviewImage.style.border = "2px solid #0078d4";
485
+ cachedPreviewImage.style.border = `2px solid ${COLORS.PRIMARY}`;
346
486
  cachedPreviewImage.style.backgroundColor = "#ffffff";
347
487
  cachedPreviewImage.style.borderRadius = "4px";
348
488
  cachedPreviewImage.style.padding = "8px";
349
489
  cachedPreviewImage.style.objectFit = "contain";
350
490
  const removeCachedBtn = document.createElement("button");
351
491
  removeCachedBtn.innerText = "Remove Saved Signature";
492
+ removeCachedBtn.setAttribute("aria-label", "Remove saved signature and use new one");
352
493
  removeCachedBtn.style.padding = "6px 12px";
353
494
  removeCachedBtn.style.fontSize = "11px";
354
495
  removeCachedBtn.style.backgroundColor = "#ffffff";
355
- removeCachedBtn.style.border = "1px solid #a4262c";
356
- removeCachedBtn.style.color = "#a4262c";
496
+ removeCachedBtn.style.border = `1px solid ${COLORS.ERROR}`;
497
+ removeCachedBtn.style.color = COLORS.ERROR;
357
498
  removeCachedBtn.style.cursor = "pointer";
358
499
  removeCachedBtn.style.borderRadius = "4px";
359
500
  removeCachedBtn.style.fontWeight = "600";
360
501
  removeCachedBtn.style.transition = "all 0.2s ease";
361
502
  removeCachedBtn.onmouseover = () => {
362
- removeCachedBtn.style.backgroundColor = "#a4262c";
503
+ removeCachedBtn.style.backgroundColor = COLORS.ERROR;
363
504
  removeCachedBtn.style.color = "#ffffff";
364
505
  };
365
506
  removeCachedBtn.onmouseout = () => {
366
507
  removeCachedBtn.style.backgroundColor = "#ffffff";
367
- removeCachedBtn.style.color = "#a4262c";
508
+ removeCachedBtn.style.color = COLORS.ERROR;
368
509
  };
369
510
  cachedNoticeContainer.appendChild(cachedNoticeText);
370
511
  cachedNoticeContainer.appendChild(cachedPreviewImage);
@@ -376,37 +517,31 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
376
517
  modeContainer.style.display = "flex";
377
518
  modeContainer.style.gap = "8px";
378
519
  modeContainer.style.marginBottom = "16px";
379
- modeContainer.style.borderBottom = "2px solid #edebe9";
380
- /**
381
- * Factory that creates a styled tab button.
382
- *
383
- * @param text - Label for the tab.
384
- * @param isActive - Whether the tab should appear selected initially.
385
- * @returns The constructed `<button>` element.
386
- */
520
+ modeContainer.style.borderBottom = `2px solid #edebe9`;
521
+ modeContainer.setAttribute("role", "tablist");
387
522
  const createTab = (text, isActive) => {
388
523
  const tab = document.createElement("button");
389
524
  tab.innerText = text;
525
+ tab.setAttribute("role", "tab");
526
+ tab.setAttribute("aria-selected", isActive.toString());
390
527
  tab.style.padding = "10px 16px";
391
528
  tab.style.fontSize = "13px";
392
529
  tab.style.cursor = "pointer";
393
530
  tab.style.fontWeight = "600";
394
531
  tab.style.backgroundColor = "transparent";
395
- tab.style.color = isActive ? "#0078d4" : "#605e5c";
532
+ tab.style.color = isActive ? COLORS.PRIMARY : COLORS.NEUTRAL_TEXT_SEC;
396
533
  tab.style.border = "none";
397
534
  tab.style.borderBottom = isActive
398
- ? "3px solid #0078d4"
535
+ ? `3px solid ${COLORS.PRIMARY}`
399
536
  : "3px solid transparent";
400
537
  tab.style.transition = "all 0.2s ease";
401
538
  tab.style.outline = "none";
402
- /* Hover handlers check current activeMode so they remain correct
403
- after the user switches tabs. */
404
539
  tab.onmouseover = () => {
405
540
  const tabIsCurrentlyActive = (tab === cachedTabBtn && activeMode === "cached") ||
406
541
  (tab === drawTabBtn && activeMode === "draw") ||
407
542
  (tab === uploadTabBtn && activeMode === "upload");
408
543
  if (!tabIsCurrentlyActive) {
409
- tab.style.color = "#0078d4";
544
+ tab.style.color = COLORS.PRIMARY;
410
545
  }
411
546
  };
412
547
  tab.onmouseout = () => {
@@ -414,7 +549,7 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
414
549
  (tab === drawTabBtn && activeMode === "draw") ||
415
550
  (tab === uploadTabBtn && activeMode === "upload");
416
551
  if (!tabIsCurrentlyActive) {
417
- tab.style.color = "#605e5c";
552
+ tab.style.color = COLORS.NEUTRAL_TEXT_SEC;
418
553
  }
419
554
  };
420
555
  return tab;
@@ -434,52 +569,56 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
434
569
  // Cached panel
435
570
  // -----------------------------------------------------------------
436
571
  const cachedPanel = document.createElement("div");
572
+ cachedPanel.setAttribute("role", "tabpanel");
437
573
  cachedPanel.style.display = hasCachedSignature ? "block" : "none";
438
574
  cachedPanel.appendChild(cachedNoticeContainer);
439
575
  // -----------------------------------------------------------------
440
576
  // Draw panel (canvas)
441
577
  // -----------------------------------------------------------------
442
578
  const drawPanel = document.createElement("div");
579
+ drawPanel.setAttribute("role", "tabpanel");
443
580
  drawPanel.style.display = hasCachedSignature ? "none" : "block";
444
581
  const canvasContainer = document.createElement("div");
445
582
  canvasContainer.style.backgroundColor = "#ffffff";
446
583
  canvasContainer.style.padding = "16px";
447
584
  canvasContainer.style.borderRadius = "6px";
448
- canvasContainer.style.border = "2px dashed #d1d1d1";
585
+ canvasContainer.style.border = `2px dashed ${COLORS.NEUTRAL_BORDER}`;
449
586
  const canvasInstructions = document.createElement("p");
450
587
  canvasInstructions.innerText = "Draw your signature below:";
451
588
  canvasInstructions.style.fontSize = "12px";
452
- canvasInstructions.style.color = "#605e5c";
589
+ canvasInstructions.style.color = COLORS.NEUTRAL_TEXT_SEC;
453
590
  canvasInstructions.style.margin = "0 0 8px 0";
454
591
  canvasInstructions.style.fontWeight = "600";
455
592
  const canvas = document.createElement("canvas");
456
- canvas.width = 560;
457
- canvas.height = 160;
458
- canvas.style.border = "1px solid #d1d1d1";
593
+ canvas.width = CANVAS_WIDTH;
594
+ canvas.height = CANVAS_HEIGHT;
595
+ canvas.setAttribute("role", "img");
596
+ canvas.setAttribute("aria-label", "Signature drawing area");
597
+ canvas.style.border = `1px solid ${COLORS.NEUTRAL_BORDER}`;
459
598
  canvas.style.backgroundColor = "#ffffff";
460
599
  canvas.style.cursor = "crosshair";
461
600
  canvas.style.display = "block";
462
601
  canvas.style.width = "100%";
463
602
  canvas.style.borderRadius = "4px";
464
603
  canvas.style.boxShadow = "inset 0 1px 3px rgba(0,0,0,0.1)";
465
- /** Button to clear the drawing canvas. */
466
604
  const clearButton = document.createElement("button");
467
605
  clearButton.innerText = "Clear Canvas";
606
+ clearButton.setAttribute("aria-label", "Clear the signature drawing");
468
607
  clearButton.style.marginTop = "10px";
469
608
  clearButton.style.padding = "8px 16px";
470
609
  clearButton.style.fontSize = "12px";
471
- clearButton.style.backgroundColor = "#f3f2f1";
472
- clearButton.style.border = "1px solid #d1d1d1";
610
+ clearButton.style.backgroundColor = COLORS.NEUTRAL_LIGHT;
611
+ clearButton.style.border = `1px solid ${COLORS.NEUTRAL_BORDER}`;
473
612
  clearButton.style.cursor = "pointer";
474
613
  clearButton.style.borderRadius = "4px";
475
614
  clearButton.style.fontWeight = "600";
476
- clearButton.style.color = "#323130";
615
+ clearButton.style.color = COLORS.NEUTRAL_TEXT;
477
616
  clearButton.style.transition = "all 0.2s ease";
478
617
  clearButton.onmouseover = () => {
479
- clearButton.style.backgroundColor = "#e1dfdd";
618
+ clearButton.style.backgroundColor = COLORS.NEUTRAL_LIGHTER;
480
619
  };
481
620
  clearButton.onmouseout = () => {
482
- clearButton.style.backgroundColor = "#f3f2f1";
621
+ clearButton.style.backgroundColor = COLORS.NEUTRAL_LIGHT;
483
622
  };
484
623
  canvasContainer.appendChild(canvasInstructions);
485
624
  canvasContainer.appendChild(canvas);
@@ -489,9 +628,10 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
489
628
  // Upload panel
490
629
  // -----------------------------------------------------------------
491
630
  const uploadPanel = document.createElement("div");
631
+ uploadPanel.setAttribute("role", "tabpanel");
492
632
  uploadPanel.style.display = "none";
493
633
  uploadPanel.style.padding = "32px 24px";
494
- uploadPanel.style.border = "2px dashed #d1d1d1";
634
+ uploadPanel.style.border = `2px dashed ${COLORS.NEUTRAL_BORDER}`;
495
635
  uploadPanel.style.textAlign = "center";
496
636
  uploadPanel.style.backgroundColor = "#ffffff";
497
637
  uploadPanel.style.borderRadius = "6px";
@@ -501,33 +641,36 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
501
641
  uploadPanel.style.alignItems = "center";
502
642
  uploadPanel.style.justifyContent = "center";
503
643
  const uploadIcon = document.createElement("div");
504
- uploadIcon.innerHTML = "📁";
644
+ uploadIcon.innerHTML = ICONS.FOLDER;
505
645
  uploadIcon.style.fontSize = "48px";
506
646
  uploadIcon.style.marginBottom = "16px";
647
+ uploadIcon.setAttribute("aria-hidden", "true");
507
648
  const uploadText = document.createElement("p");
508
- uploadText.innerText = "Click to upload or drag and drop";
649
+ uploadText.innerText = "Click to upload a signature image";
509
650
  uploadText.style.fontSize = "14px";
510
- uploadText.style.color = "#605e5c";
651
+ uploadText.style.color = COLORS.NEUTRAL_TEXT_SEC;
511
652
  uploadText.style.margin = "0 0 8px 0";
512
653
  uploadText.style.fontWeight = "600";
513
654
  const uploadSubtext = document.createElement("p");
514
- uploadSubtext.innerText = "PNG, JPG (max 5MB)";
655
+ uploadSubtext.innerText = "PNG, JPG (max 5 MB)";
515
656
  uploadSubtext.style.fontSize = "12px";
516
657
  uploadSubtext.style.color = "#8a8886";
517
658
  uploadSubtext.style.margin = "0 0 16px 0";
518
659
  const fileInput = document.createElement("input");
519
660
  fileInput.type = "file";
520
661
  fileInput.accept = "image/png, image/jpeg, image/jpg";
662
+ fileInput.setAttribute("aria-label", "Upload signature image file");
521
663
  fileInput.style.fontSize = "12px";
522
664
  fileInput.style.marginBottom = "16px";
523
665
  const uploadPreview = document.createElement("img");
666
+ uploadPreview.alt = "Preview of uploaded signature";
524
667
  uploadPreview.style.maxWidth = "100%";
525
668
  uploadPreview.style.height = "auto";
526
669
  uploadPreview.style.minHeight = "80px";
527
670
  uploadPreview.style.maxHeight = "120px";
528
671
  uploadPreview.style.marginTop = "16px";
529
672
  uploadPreview.style.display = "none";
530
- uploadPreview.style.border = "2px solid #0078d4";
673
+ uploadPreview.style.border = `2px solid ${COLORS.PRIMARY}`;
531
674
  uploadPreview.style.borderRadius = "4px";
532
675
  uploadPreview.style.padding = "8px";
533
676
  uploadPreview.style.backgroundColor = "#ffffff";
@@ -558,86 +701,64 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
558
701
  // -----------------------------------------------------------------
559
702
  // Button-state helper
560
703
  // -----------------------------------------------------------------
561
- /**
562
- * Re-evaluates whether the "I Agree and Sign" button should be
563
- * enabled, based on the current TFA code entry and selected
564
- * signature mode.
565
- */
566
704
  const updateButtonState = () => {
567
705
  let isCodeValid = true;
568
- // Check TFA only if it's required
569
706
  if (requireTFA) {
570
707
  if (verificationInput) {
571
708
  const codeValue = verificationInput.value.trim();
572
709
  isCodeValid =
573
- codeValue.length === 5 && codeValue === generatedPasscode;
574
- console.log("🔐 TFA Check: Code =", codeValue, "Valid =", isCodeValid);
710
+ codeValue.length === PASSCODE_LENGTH && codeValue === generatedPasscode;
575
711
  }
576
712
  else {
577
713
  isCodeValid = false;
578
- console.log("🔐 TFA Check: No input element found");
579
714
  }
580
715
  }
581
- else {
582
- console.log("🔐 TFA not required - skipping TFA validation");
583
- }
584
- // Check signature validity
585
716
  let isSignatureValid = false;
586
717
  if (activeMode === "cached") {
587
718
  isSignatureValid = hasCachedSignature;
588
- console.log("✍️ Signature Check (CACHED): Valid =", isSignatureValid);
589
719
  }
590
720
  else if (activeMode === "draw") {
591
721
  const canvasHasContent = ctx !== null && !isCanvasEmpty(ctx, canvas.width, canvas.height);
592
722
  isSignatureValid = hasDrawnContent && canvasHasContent;
593
- console.log("✍️ Signature Check (DRAW): hasDrawnContent =", hasDrawnContent, "canvasHasContent =", canvasHasContent, "Valid =", isSignatureValid);
594
723
  }
595
724
  else {
596
725
  isSignatureValid = compressedUploadBase64.trim() !== "";
597
- console.log("✍️ Signature Check (UPLOAD): compressedSize =", compressedUploadBase64.length, "Valid =", isSignatureValid);
598
726
  }
599
- console.log("🔘 FINAL STATE: Code Valid =", isCodeValid, "Sig Valid =", isSignatureValid, "Should Enable =", isCodeValid && isSignatureValid);
600
- if (isCodeValid && isSignatureValid) {
727
+ const shouldEnable = isCodeValid && isSignatureValid;
728
+ if (shouldEnable) {
601
729
  signButton.disabled = false;
602
- signButton.style.backgroundColor = "#0078d4";
730
+ signButton.style.backgroundColor = COLORS.PRIMARY;
603
731
  signButton.style.cursor = "pointer";
604
732
  signButton.style.opacity = "1";
605
- console.log("✅ Button ENABLED");
606
733
  }
607
734
  else {
608
735
  signButton.disabled = true;
609
736
  signButton.style.backgroundColor = "#c8c6c4";
610
737
  signButton.style.cursor = "not-allowed";
611
738
  signButton.style.opacity = "0.6";
612
- console.log("❌ Button DISABLED");
613
739
  }
614
740
  };
615
741
  // -----------------------------------------------------------------
616
742
  // Tab-switching logic
617
743
  // -----------------------------------------------------------------
618
- /**
619
- * Activates the given tab, hiding the other panels and updating
620
- * visual tab styles.
621
- *
622
- * @param mode - Which panel to show.
623
- */
624
744
  const setActiveTab = (mode) => {
625
745
  activeMode = mode;
626
746
  cachedPanel.style.display = mode === "cached" ? "block" : "none";
627
747
  drawPanel.style.display = mode === "draw" ? "block" : "none";
628
748
  uploadPanel.style.display = mode === "upload" ? "flex" : "none";
629
749
  [cachedTabBtn, drawTabBtn, uploadTabBtn].forEach((btn) => {
630
- btn.style.color = "#605e5c";
750
+ btn.style.color = COLORS.NEUTRAL_TEXT_SEC;
631
751
  btn.style.borderBottom = "3px solid transparent";
752
+ btn.setAttribute("aria-selected", "false");
632
753
  });
633
754
  const activeBtn = mode === "cached"
634
755
  ? cachedTabBtn
635
756
  : mode === "draw"
636
757
  ? drawTabBtn
637
758
  : uploadTabBtn;
638
- activeBtn.style.color = "#0078d4";
639
- activeBtn.style.borderBottom = "3px solid #0078d4";
640
- console.log("Tab switched to:", mode);
759
+ activeBtn.style.color = COLORS.PRIMARY;
760
+ activeBtn.style.borderBottom = `3px solid ${COLORS.PRIMARY}`;
761
+ activeBtn.setAttribute("aria-selected", "true");
641
762
  updateButtonState();
642
763
  };
643
764
  if (hasCachedSignature) {
@@ -645,7 +766,6 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
645
766
  }
646
767
  drawTabBtn.onclick = () => setActiveTab("draw");
647
768
  uploadTabBtn.onclick = () => setActiveTab("upload");
648
- /** Removes the cached signature and switches to the draw tab. */
649
769
  removeCachedBtn.onclick = () => {
650
770
  localStorage.removeItem(STORAGE_KEY);
651
771
  hasCachedSignature = false;
@@ -659,19 +779,15 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
659
779
  let cooldownTimer = null;
660
780
  const scBtnRef = sendCodeButton;
661
781
  const vInputRef = verificationInput;
662
- /**
663
- * Starts a 60-second cooldown on the "Resend Code" button to
664
- * prevent rapid re-sends.
665
- */
666
782
  const startCooldownTimer = () => {
667
- let secondsLeft = 60;
783
+ let secondsLeft = TFA_COOLDOWN_SECONDS;
668
784
  scBtnRef.disabled = true;
669
- scBtnRef.style.backgroundColor = "#f3f2f1";
785
+ scBtnRef.style.backgroundColor = COLORS.NEUTRAL_LIGHT;
670
786
  scBtnRef.style.color = "#a19f9d";
671
- scBtnRef.style.borderColor = "#d1d1d1";
787
+ scBtnRef.style.borderColor = COLORS.NEUTRAL_BORDER;
672
788
  scBtnRef.style.cursor = "not-allowed";
673
789
  scBtnRef.innerText = `Resend (${secondsLeft}s)`;
674
- if (cooldownTimer) {
790
+ if (cooldownTimer !== null) {
675
791
  clearInterval(cooldownTimer);
676
792
  }
677
793
  cooldownTimer = window.setInterval(() => {
@@ -680,22 +796,19 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
680
796
  scBtnRef.innerText = `Resend (${secondsLeft}s)`;
681
797
  }
682
798
  else {
683
- if (cooldownTimer) {
799
+ if (cooldownTimer !== null) {
684
800
  clearInterval(cooldownTimer);
801
+ cooldownTimer = null;
685
802
  }
686
803
  scBtnRef.disabled = false;
687
804
  scBtnRef.style.backgroundColor = "#ffffff";
688
- scBtnRef.style.color = "#0078d4";
689
- scBtnRef.style.borderColor = "#0078d4";
805
+ scBtnRef.style.color = COLORS.PRIMARY;
806
+ scBtnRef.style.borderColor = COLORS.PRIMARY;
690
807
  scBtnRef.style.cursor = "pointer";
691
808
  scBtnRef.innerText = "Resend Code";
692
809
  }
693
810
  }, 1000);
694
811
  };
695
- /**
696
- * Dispatches the verification passcode through the configured
697
- * channel (email / Teams) and starts the cooldown timer.
698
- */
699
812
  const triggerSendCode = async () => {
700
813
  scBtnRef.innerText = "Sending...";
701
814
  scBtnRef.disabled = true;
@@ -708,16 +821,14 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
708
821
  });
709
822
  if (vResult.success && vResult.itemId) {
710
823
  storedVerificationItemId = vResult.itemId;
711
- console.log("✅ OTP dispatched successfully");
824
+ vInputRef.focus();
712
825
  }
713
826
  else {
714
- console.error("❌ OTP dispatch failed:", vResult.error);
715
827
  alert("Failed to dispatch verification code. Please try again.");
716
828
  }
717
829
  }
718
830
  }
719
831
  catch (err) {
720
- console.error("Failed to send code:", err);
721
832
  alert("Failed to send verification code. Please try again.");
722
833
  }
723
834
  finally {
@@ -728,24 +839,20 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
728
839
  await triggerSendCode();
729
840
  };
730
841
  vInputRef.oninput = () => {
731
- console.log("🔐 TFA input changed, checking button state");
842
+ if (vInputRef.value.trim().length === PASSCODE_LENGTH) {
843
+ tfaVerified = vInputRef.value.trim() === generatedPasscode;
844
+ }
732
845
  updateButtonState();
733
846
  };
734
- void triggerSendCode();
847
+ // User must click "Send Code" - no automatic send
848
+ // Only auto-send if explicitly marked as needed
735
849
  }
736
850
  else if (requireTFA && !context.channel) {
737
- console.warn("⚠️ TFA required but no channel provided");
851
+ // TFA required but no delivery channel
738
852
  }
739
853
  // -----------------------------------------------------------------
740
854
  // Canvas drawing helpers
741
855
  // -----------------------------------------------------------------
742
- /**
743
- * Translates a mouse or touch event into canvas-relative
744
- * coordinates, accounting for CSS scaling.
745
- *
746
- * @param e - The originating mouse or touch event.
747
- * @returns `{ x, y }` in canvas-pixel space.
748
- */
749
856
  const getPos = (e) => {
750
857
  const rect = canvas.getBoundingClientRect();
751
858
  let clientX;
@@ -767,11 +874,6 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
767
874
  y: (clientY - rect.top) * (canvas.height / rect.height),
768
875
  };
769
876
  };
770
- /**
771
- * Begins a new drawing stroke at the pointer position.
772
- *
773
- * @param e - The initiating mouse/touch event.
774
- */
775
877
  const startDraw = (e) => {
776
878
  isDrawing = true;
777
879
  hasDrawnContent = true;
@@ -780,93 +882,71 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
780
882
  ctx === null || ctx === void 0 ? void 0 : ctx.moveTo(pos.x, pos.y);
781
883
  e.preventDefault();
782
884
  };
783
- /**
784
- * Extends the current stroke to the pointer's new position.
785
- *
786
- * @param e - The move event.
787
- */
788
885
  const drawLine = (e) => {
789
886
  if (!isDrawing || !ctx)
790
887
  return;
791
888
  const pos = getPos(e);
792
889
  ctx.lineTo(pos.x, pos.y);
793
890
  ctx.stroke();
794
- console.log("✏️ Drawing, updating button state");
795
891
  updateButtonState();
796
892
  e.preventDefault();
797
893
  };
798
- /** Ends the current drawing stroke. */
799
894
  const stopDraw = () => {
800
895
  isDrawing = false;
801
- console.log("✋ Drawing stopped, updating button state");
802
896
  updateButtonState();
803
897
  };
804
898
  canvas.addEventListener("mousedown", startDraw);
805
899
  canvas.addEventListener("mousemove", drawLine);
806
- window.addEventListener("mouseup", stopDraw);
807
900
  canvas.addEventListener("touchstart", startDraw, { passive: false });
808
901
  canvas.addEventListener("touchmove", drawLine, { passive: false });
902
+ window.addEventListener("mouseup", stopDraw);
809
903
  window.addEventListener("touchend", stopDraw);
810
- /** Clears the drawing canvas back to a blank white rectangle. */
904
+ // Track abort controller listeners
905
+ abortController.signal.addEventListener("abort", () => {
906
+ canvas.removeEventListener("mousedown", startDraw);
907
+ canvas.removeEventListener("mousemove", drawLine);
908
+ canvas.removeEventListener("touchstart", startDraw);
909
+ canvas.removeEventListener("touchmove", drawLine);
910
+ window.removeEventListener("mouseup", stopDraw);
911
+ window.removeEventListener("touchend", stopDraw);
912
+ });
811
913
  clearButton.onclick = () => {
812
914
  if (ctx) {
813
915
  ctx.fillStyle = "#ffffff";
814
916
  ctx.fillRect(0, 0, canvas.width, canvas.height);
815
917
  hasDrawnContent = false;
816
- console.log("🧹 Canvas cleared, updating button state");
817
918
  updateButtonState();
818
919
  }
819
920
  };
820
921
  // -----------------------------------------------------------------
821
922
  // File upload handler
822
923
  // -----------------------------------------------------------------
823
- /**
824
- * Handles image file selection: validates size, resizes if needed,
825
- * and stores the result as a data-URI.
826
- */
827
924
  fileInput.onchange = (e) => {
828
925
  const target = e.target;
829
926
  if (target.files && target.files[0]) {
830
927
  const file = target.files[0];
831
- /* Enforce the advertised 5 MB limit. */
832
928
  if (file.size > MAX_UPLOAD_BYTES) {
833
929
  alert(`File size exceeds 5 MB (${(file.size / 1024 / 1024).toFixed(1)} MB). Please choose a smaller image.`);
834
930
  target.value = "";
835
931
  return;
836
932
  }
837
933
  const reader = new FileReader();
934
+ reader.onerror = () => {
935
+ alert("Failed to load image. Please try again.");
936
+ target.value = "";
937
+ updateButtonState();
938
+ };
838
939
  reader.onload = (uploadEvent) => {
839
940
  var _a;
840
941
  if ((_a = uploadEvent.target) === null || _a === void 0 ? void 0 : _a.result) {
841
942
  const img = new Image();
943
+ img.onerror = () => {
944
+ alert("Failed to read image file. Please try another image.");
945
+ target.value = "";
946
+ updateButtonState();
947
+ };
842
948
  img.onload = () => {
843
- const tempCanvas = document.createElement("canvas");
844
- const maxW = 560;
845
- const maxH = 160;
846
- let w = img.width;
847
- let h = img.height;
848
- if (w > maxW) {
849
- h = Math.round((h * maxW) / w);
850
- w = maxW;
851
- }
852
- if (h > maxH) {
853
- w = Math.round((w * maxH) / h);
854
- h = maxH;
855
- }
856
- tempCanvas.width = w;
857
- tempCanvas.height = h;
858
- const tCtx = tempCanvas.getContext("2d");
859
- if (tCtx) {
860
- tCtx.fillStyle = "#ffffff";
861
- tCtx.fillRect(0, 0, w, h);
862
- tCtx.drawImage(img, 0, 0, w, h);
863
- compressedUploadBase64 =
864
- tempCanvas.toDataURL("image/png");
865
- uploadPreview.src = compressedUploadBase64;
866
- uploadPreview.style.display = "block";
867
- console.log("📤 File uploaded, updating button state");
868
- updateButtonState();
869
- }
949
+ resizeImageAndStore(img, target);
870
950
  };
871
951
  img.src = uploadEvent.target.result;
872
952
  }
@@ -874,6 +954,44 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
874
954
  reader.readAsDataURL(file);
875
955
  }
876
956
  };
957
+ /**
958
+ * Helper to resize image and store result.
959
+ *
960
+ * @param img - Loaded image element.
961
+ * @param fileInput - The input element reference.
962
+ */
963
+ const resizeImageAndStore = (img, fileInput) => {
964
+ try {
965
+ const tempCanvas = document.createElement("canvas");
966
+ let w = img.width;
967
+ let h = img.height;
968
+ if (w > IMAGE_MAX_WIDTH) {
969
+ h = Math.round((h * IMAGE_MAX_WIDTH) / w);
970
+ w = IMAGE_MAX_WIDTH;
971
+ }
972
+ if (h > IMAGE_MAX_HEIGHT) {
973
+ w = Math.round((w * IMAGE_MAX_HEIGHT) / h);
974
+ h = IMAGE_MAX_HEIGHT;
975
+ }
976
+ tempCanvas.width = w;
977
+ tempCanvas.height = h;
978
+ const tCtx = tempCanvas.getContext("2d");
979
+ if (tCtx) {
980
+ tCtx.fillStyle = "#ffffff";
981
+ tCtx.fillRect(0, 0, w, h);
982
+ tCtx.drawImage(img, 0, 0, w, h);
983
+ compressedUploadBase64 = tempCanvas.toDataURL("image/png");
984
+ uploadPreview.src = compressedUploadBase64;
985
+ uploadPreview.style.display = "block";
986
+ updateButtonState();
987
+ }
988
+ }
989
+ catch (error) {
990
+ alert("Failed to process image. Please try another image.");
991
+ fileInput.value = "";
992
+ updateButtonState();
993
+ }
994
+ };
877
995
  // -----------------------------------------------------------------
878
996
  // Footer (Cancel + Sign buttons)
879
997
  // -----------------------------------------------------------------
@@ -886,44 +1004,59 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
886
1004
  footerSection.style.gap = "12px";
887
1005
  const cancelButton = document.createElement("button");
888
1006
  cancelButton.innerText = "Cancel";
1007
+ cancelButton.setAttribute("aria-label", "Cancel signing and close dialog");
889
1008
  cancelButton.style.padding = "10px 24px";
890
1009
  cancelButton.style.backgroundColor = "#ffffff";
891
- cancelButton.style.border = "1px solid #8a8886";
1010
+ cancelButton.style.border = `1px solid ${COLORS.NEUTRAL_BORDER_DARK}`;
892
1011
  cancelButton.style.cursor = "pointer";
893
1012
  cancelButton.style.fontSize = "14px";
894
1013
  cancelButton.style.fontWeight = "600";
895
- cancelButton.style.color = "#323130";
1014
+ cancelButton.style.color = COLORS.NEUTRAL_TEXT;
896
1015
  cancelButton.style.borderRadius = "4px";
897
1016
  cancelButton.style.transition = "all 0.2s ease";
898
1017
  cancelButton.onmouseover = () => {
899
- cancelButton.style.backgroundColor = "#f3f2f1";
1018
+ cancelButton.style.backgroundColor = COLORS.NEUTRAL_LIGHT;
900
1019
  };
901
1020
  cancelButton.onmouseout = () => {
902
1021
  cancelButton.style.backgroundColor = "#ffffff";
903
1022
  };
904
1023
  signButton.onmouseover = () => {
905
1024
  if (!signButton.disabled) {
906
- signButton.style.backgroundColor = "#005a9e";
1025
+ signButton.style.backgroundColor = COLORS.PRIMARY_DARK;
907
1026
  }
908
1027
  };
909
1028
  signButton.onmouseout = () => {
910
1029
  if (!signButton.disabled) {
911
- signButton.style.backgroundColor = "#0078d4";
1030
+ signButton.style.backgroundColor = COLORS.PRIMARY;
912
1031
  }
913
1032
  };
914
1033
  // -----------------------------------------------------------------
1034
+ // Keyboard support
1035
+ // -----------------------------------------------------------------
1036
+ const handleEscapeKey = (e) => {
1037
+ if (e.key === "Escape" || e.key === "Esc") {
1038
+ cleanup();
1039
+ resolve(undefined);
1040
+ }
1041
+ };
1042
+ document.addEventListener("keydown", handleEscapeKey);
1043
+ abortController.signal.addEventListener("abort", () => {
1044
+ document.removeEventListener("keydown", handleEscapeKey);
1045
+ });
1046
+ // -----------------------------------------------------------------
915
1047
  // Cleanup helper
916
1048
  // -----------------------------------------------------------------
917
- /**
918
- * Removes the modal overlay from the DOM and unregisters window-
919
- * level event listeners to prevent memory leaks.
920
- */
921
1049
  const cleanup = () => {
922
- window.removeEventListener("mouseup", stopDraw);
923
- window.removeEventListener("touchend", stopDraw);
924
- document.body.removeChild(overlay);
1050
+ if (activeModalInstance === abortController) {
1051
+ activeModalInstance = null;
1052
+ }
1053
+ clearTimeout(timeoutId);
1054
+ abortController.abort();
1055
+ document.removeEventListener("keydown", handleEscapeKey);
1056
+ if (document.body.contains(overlay)) {
1057
+ document.body.removeChild(overlay);
1058
+ }
925
1059
  };
926
- /** Cancel button dismisses the dialog and resolves with `undefined`. */
927
1060
  cancelButton.onclick = () => {
928
1061
  cleanup();
929
1062
  resolve(undefined);
@@ -931,11 +1064,6 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
931
1064
  // -----------------------------------------------------------------
932
1065
  // Sign button handler
933
1066
  // -----------------------------------------------------------------
934
- /**
935
- * Validates the TFA code (if required), extracts the signature
936
- * data from the active panel, compresses it, optionally caches it,
937
- * then generates the SHA-256 audit record.
938
- */
939
1067
  signButton.onclick = async () => {
940
1068
  if (requireTFA && verificationInput) {
941
1069
  const enteredCode = verificationInput.value.trim();
@@ -950,7 +1078,16 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
950
1078
  alert("No cached signature found. Please select another option.");
951
1079
  return;
952
1080
  }
953
- finalDataUrl = lzwDecompress(cachedCompressedSig);
1081
+ try {
1082
+ finalDataUrl = lzwDecompress(cachedCompressedSig);
1083
+ if (!finalDataUrl.startsWith("data:image")) {
1084
+ throw new Error("Invalid signature data");
1085
+ }
1086
+ }
1087
+ catch (_a) {
1088
+ alert("Cached signature is corrupted. Please use a different method.");
1089
+ return;
1090
+ }
954
1091
  }
955
1092
  else if (activeMode === "draw") {
956
1093
  if (!ctx ||
@@ -958,7 +1095,7 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
958
1095
  alert("Please draw your signature before proceeding.");
959
1096
  return;
960
1097
  }
961
- finalDataUrl = downscaleCanvas(canvas, 560, 160);
1098
+ finalDataUrl = downscaleCanvas(canvas, CANVAS_WIDTH, CANVAS_HEIGHT);
962
1099
  }
963
1100
  else {
964
1101
  if (!compressedUploadBase64 ||
@@ -968,15 +1105,15 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
968
1105
  }
969
1106
  finalDataUrl = compressedUploadBase64;
970
1107
  }
971
- const compressedString = lzwCompress(finalDataUrl);
972
- if (cacheCheckbox.checked) {
973
- localStorage.setItem(STORAGE_KEY, compressedString);
974
- }
975
- else {
976
- localStorage.removeItem(STORAGE_KEY);
977
- }
978
- cleanup();
979
1108
  try {
1109
+ const compressedString = lzwCompress(finalDataUrl);
1110
+ if (cacheCheckbox.checked) {
1111
+ localStorage.setItem(STORAGE_KEY, compressedString);
1112
+ }
1113
+ else {
1114
+ localStorage.removeItem(STORAGE_KEY);
1115
+ }
1116
+ cleanup();
980
1117
  const fullContext = Object.assign(Object.assign({}, context), { signatureData: compressedString });
981
1118
  const result = await generateSecureAuditRecordInternal(fullContext);
982
1119
  if (storedVerificationItemId !== undefined) {
@@ -985,7 +1122,7 @@ async function promptAndGenerateSecureAudit(context, modalTitle = "Approve Purch
985
1122
  resolve(result);
986
1123
  }
987
1124
  catch (error) {
988
- console.error("Signing failed:", error);
1125
+ alert("Signing failed. Please try again.");
989
1126
  resolve(undefined);
990
1127
  }
991
1128
  };
@@ -1029,11 +1166,16 @@ exports.promptAndGenerateSecureAudit = promptAndGenerateSecureAudit;
1029
1166
  * data-URI.
1030
1167
  */
1031
1168
  function getReportableSignature(compressedSignatureData) {
1032
- const decompressed = lzwDecompress(compressedSignatureData);
1033
- if (!decompressed || !decompressed.startsWith("data:image")) {
1169
+ try {
1170
+ const decompressed = lzwDecompress(compressedSignatureData);
1171
+ if (!decompressed || !decompressed.startsWith("data:image")) {
1172
+ return "";
1173
+ }
1174
+ return decompressed;
1175
+ }
1176
+ catch (_a) {
1034
1177
  return "";
1035
1178
  }
1036
- return decompressed;
1037
1179
  }
1038
1180
  exports.getReportableSignature = getReportableSignature;
1039
1181
  // ---------------------------------------------------------------------------
@@ -1042,15 +1184,15 @@ exports.getReportableSignature = getReportableSignature;
1042
1184
  /**
1043
1185
  * Compresses a string using the LZW (Lempel-Ziv-Welch) algorithm.
1044
1186
  *
1045
- * **Caveat:** When the dictionary grows past 65 535 entries,
1046
- * `String.fromCharCode` will produce values that may not survive
1047
- * a round-trip through `localStorage` or JSON. For very large
1048
- * inputs consider chunking or switching to a Uint16Array-backed
1049
- * encoding.
1187
+ * **Important:** The dictionary is limited to 65,535 entries to prevent
1188
+ * corruption in localStorage/JSON. Large inputs may fail to compress.
1189
+ * For data-URIs larger than this limit, consider using alternative
1190
+ * compression methods or Base64 encoding.
1050
1191
  *
1051
1192
  * @param input - The raw string to compress.
1052
1193
  * @returns The compressed string (each character encodes one
1053
1194
  * dictionary index).
1195
+ * @throws {Error} If dictionary exceeds maximum size.
1054
1196
  */
1055
1197
  function lzwCompress(input) {
1056
1198
  const dictionary = {};
@@ -1070,6 +1212,10 @@ function lzwCompress(input) {
1070
1212
  }
1071
1213
  else {
1072
1214
  result.push(dictionary[w]);
1215
+ if (dictionarySize >= LZW_DICT_MAX) {
1216
+ throw new Error(`LZW dictionary overflow: input is too large to compress (${dictionarySize} entries). ` +
1217
+ `Consider using Base64 encoding instead.`);
1218
+ }
1073
1219
  dictionary[wc] = dictionarySize++;
1074
1220
  w = String(c);
1075
1221
  }
@@ -1109,7 +1255,6 @@ function lzwDecompress(compressed) {
1109
1255
  entry = w + w.charAt(0);
1110
1256
  }
1111
1257
  else {
1112
- console.warn(`lzwDecompress: unrecognised dictionary index ${k} at position ${i}.`);
1113
1258
  return "";
1114
1259
  }
1115
1260
  result.push(entry);
@@ -1174,13 +1319,16 @@ function isCanvasEmpty(ctx, width, height) {
1174
1319
  * record has not been tampered with, without needing access to the
1175
1320
  * original signature image.
1176
1321
  *
1177
- * **Note:** Only top-level payload keys are sorted. If your payload
1178
- * contains nested objects whose key order may vary, consider using a
1179
- * deep-sort utility before calling this function.
1322
+ * **Deep Sorting:** Payload is deep-sorted to ensure nested objects
1323
+ * maintain consistent key ordering. All payload values must be
1324
+ * JSON-serializable (no functions, symbols, undefined at top level).
1325
+ *
1326
+ * **Timestamp Precision:** Timestamps are stored at second precision
1327
+ * to avoid hash mismatches from millisecond variations.
1180
1328
  *
1181
1329
  * @param auditRecord - The SharePointAuditRecord returned from `promptAndGenerateSecureAudit`.
1182
1330
  * @param signer - The signer's email address or display name (must match original signer).
1183
- * @param payload - The original payload object that was signed.
1331
+ * @param payload - The original payload object that was signed (must be JSON-serializable).
1184
1332
  * @returns `true` if the signature hash is valid and authentic.
1185
1333
  *
1186
1334
  * @example
@@ -1198,25 +1346,25 @@ function isCanvasEmpty(ctx, width, height) {
1198
1346
  * }
1199
1347
  * ```
1200
1348
  */
1201
- async function verifySecureAuditRecord(auditRecord, signer, payload) {
1349
+ async function verifySecureAuditRecord(auditRecord, signer, payload, itemID) {
1202
1350
  try {
1203
1351
  if (!auditRecord || !signer || !payload) {
1204
- console.warn("verifySecureAuditRecord: Missing required parameters", {
1205
- auditRecord: !!auditRecord,
1206
- signer: !!signer,
1207
- payload: !!payload,
1208
- });
1209
1352
  return false;
1210
1353
  }
1211
- // Sort payload keys for consistent hashing
1212
- const sortedPayloadString = JSON.stringify(payload, Object.keys(payload).sort());
1213
- // Reconstruct the canonical audit envelope
1354
+ if (!auditRecord.signatureHash || !auditRecord.signatureTimestamp) {
1355
+ return false;
1356
+ }
1357
+ const normalizedSigner = sanitizeSigner(signer);
1358
+ const normalizedTimestamp = auditRecord.signatureTimestamp.trim();
1359
+ const normalizedHash = auditRecord.signatureHash.trim();
1360
+ validateJsonSerializable(payload);
1361
+ const deepSortedPayload = deepSortPayload(payload); // ← CAST THIS
1214
1362
  const auditEnvelope = {
1215
- payload: JSON.parse(sortedPayloadString),
1216
- signer: signer.toLowerCase().trim(),
1217
- timestamp: auditRecord.signatureTimestamp.trim(),
1363
+ payload: deepSortedPayload,
1364
+ signer: normalizedSigner,
1365
+ timestamp: normalizedTimestamp,
1366
+ itemID: itemID, // ← Already added
1218
1367
  };
1219
- // Hash the audit envelope
1220
1368
  const auditEnvelopeJson = JSON.stringify(auditEnvelope);
1221
1369
  const encoder = new TextEncoder();
1222
1370
  const encodedBytes = encoder.encode(auditEnvelopeJson);
@@ -1225,17 +1373,60 @@ async function verifySecureAuditRecord(auditRecord, signer, payload) {
1225
1373
  const recomputedHash = hashArray
1226
1374
  .map((b) => b.toString(16).padStart(2, "0"))
1227
1375
  .join("");
1228
- // Compare hashes
1229
- const isValid = recomputedHash === auditRecord.signatureHash.trim();
1230
- console.log("verifySecureAuditRecord: Verification result =", isValid, "Recomputed hash =", recomputedHash.substring(0, 16) + "...");
1376
+ const isValid = recomputedHash === normalizedHash;
1231
1377
  return isValid;
1232
1378
  }
1233
1379
  catch (error) {
1234
- console.error("verifySecureAuditRecord: Error during verification", error);
1235
1380
  return false;
1236
1381
  }
1237
1382
  }
1238
1383
  exports.verifySecureAuditRecord = verifySecureAuditRecord;
1384
+ /**
1385
+ * Deep-sorts a payload object recursively, ensuring nested objects
1386
+ * and array items maintain consistent key ordering.
1387
+ *
1388
+ * @param obj - The object to sort
1389
+ * @returns A new object with all keys sorted recursively
1390
+ */
1391
+ function deepSortPayload(obj) {
1392
+ if (obj === null || obj === undefined) {
1393
+ return obj;
1394
+ }
1395
+ if (Array.isArray(obj)) {
1396
+ return obj.map((item) => {
1397
+ if (item !== null && typeof item === "object" && !Array.isArray(item)) {
1398
+ return deepSortPayload(item);
1399
+ }
1400
+ return item;
1401
+ }); // ← Removed 'as unknown[]' cast
1402
+ }
1403
+ if (typeof obj === "object") {
1404
+ const sorted = {};
1405
+ const keys = Object.keys(obj).sort();
1406
+ keys.forEach((key) => {
1407
+ const value = obj[key];
1408
+ if (value === null || value === undefined) {
1409
+ sorted[key] = value;
1410
+ }
1411
+ else if (Array.isArray(value)) {
1412
+ sorted[key] = value.map((item) => {
1413
+ if (item !== null && typeof item === "object" && !Array.isArray(item)) {
1414
+ return deepSortPayload(item);
1415
+ }
1416
+ return item;
1417
+ });
1418
+ }
1419
+ else if (typeof value === "object") {
1420
+ sorted[key] = deepSortPayload(value);
1421
+ }
1422
+ else {
1423
+ sorted[key] = value;
1424
+ }
1425
+ });
1426
+ return sorted;
1427
+ }
1428
+ return obj;
1429
+ }
1239
1430
  // ---------------------------------------------------------------------------
1240
1431
  // Internal – audit-record generation
1241
1432
  // ---------------------------------------------------------------------------
@@ -1246,31 +1437,36 @@ exports.verifySecureAuditRecord = verifySecureAuditRecord;
1246
1437
  *
1247
1438
  * This is an internal function and is not exported.
1248
1439
  *
1440
+ * **Key Binding:** The itemID is now included in the audit envelope
1441
+ * hash to tie the signature to a specific transaction and prevent
1442
+ * signature replay attacks.
1443
+ *
1249
1444
  * @param context - The full signer context, extended with the
1250
1445
  * compressed signature data string.
1251
1446
  * @returns A fully populated `SharePointAuditRecord`.
1252
1447
  *
1253
- * @throws {Error} If the payload is empty or the signer is blank.
1448
+ * @throws {Error} If the payload is empty, signer is blank, or payload
1449
+ * is not JSON-serializable.
1254
1450
  */
1255
1451
  async function generateSecureAuditRecordInternal(context) {
1256
- const { payload, signer, signatureData } = context;
1452
+ const { payload, signer, signatureData, itemID } = context;
1453
+ assertNonEmptyString(signer, "Signer");
1454
+ const sanitizedSigner = sanitizeSigner(signer);
1257
1455
  if (!payload || Object.keys(payload).length === 0) {
1258
1456
  throw new Error("Payload cannot be empty.");
1259
1457
  }
1260
- if (!signer || signer.trim() === "") {
1261
- throw new Error("Signer identifier is required for sealing.");
1262
- }
1263
- const signatureTimestamp = new Date().toISOString();
1264
- const sortedPayloadString = JSON.stringify(payload, Object.keys(payload).sort());
1458
+ validateJsonSerializable(payload);
1459
+ const signatureTimestamp = generateTimestamp();
1460
+ const deepSortedPayload = deepSortPayload(payload);
1265
1461
  const auditEnvelope = {
1266
- payload: JSON.parse(sortedPayloadString),
1267
- signer: signer.toLowerCase().trim(),
1268
- timestamp: signatureTimestamp.trim(),
1462
+ payload: deepSortedPayload,
1463
+ signer: sanitizedSigner,
1464
+ timestamp: signatureTimestamp,
1465
+ itemID: itemID,
1269
1466
  };
1270
1467
  const auditEnvelopeJson = JSON.stringify(auditEnvelope);
1271
1468
  const encoder = new TextEncoder();
1272
1469
  const encodedBytes = encoder.encode(auditEnvelopeJson);
1273
- // Passing `encodedBytes.buffer as ArrayBuffer` satisfies the DOM BufferSource definition
1274
1470
  const hashBuffer = await window.crypto.subtle.digest("SHA-256", encodedBytes.buffer);
1275
1471
  const hashArray = Array.from(new Uint8Array(hashBuffer));
1276
1472
  const signatureHash = hashArray
@@ -1280,6 +1476,6 @@ async function generateSecureAuditRecordInternal(context) {
1280
1476
  signatureHash,
1281
1477
  signatureData,
1282
1478
  signatureTimestamp,
1283
- verificationItemId: context.itemID,
1479
+ verificationItemId: itemID,
1284
1480
  };
1285
1481
  }