@bdking71/spsignature 1.3.5 → 1.3.7
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.
- package/README.md +116 -41
- package/lib/SignatureDisplay.d.ts +62 -0
- package/lib/SignatureDisplay.js +156 -0
- package/lib/TransactionSigner.d.ts +17 -6
- package/lib/TransactionSigner.js +456 -261
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/package.json +7 -3
package/lib/TransactionSigner.js
CHANGED
|
@@ -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] %
|
|
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
|
-
//
|
|
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
|
-
|
|
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 =
|
|
246
|
+
headerSection.style.backgroundColor = COLORS.PRIMARY;
|
|
122
247
|
headerSection.style.padding = "20px 24px";
|
|
123
|
-
headerSection.style.borderBottom =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
172
|
-
verificationContainer.style.border =
|
|
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 =
|
|
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 = `
|
|
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 =
|
|
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.
|
|
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 =
|
|
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;
|
|
343
|
+
const vInput = verificationInput;
|
|
220
344
|
vInput.onfocus = () => {
|
|
221
|
-
vInput.style.borderColor =
|
|
345
|
+
vInput.style.borderColor = COLORS.PRIMARY;
|
|
222
346
|
};
|
|
223
347
|
vInput.onblur = () => {
|
|
224
|
-
vInput.style.borderColor =
|
|
348
|
+
vInput.style.borderColor = COLORS.NEUTRAL_BORDER;
|
|
225
349
|
};
|
|
226
350
|
sendCodeButton = document.createElement("button");
|
|
227
|
-
sendCodeButton.innerText = "
|
|
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 =
|
|
233
|
-
sendCodeButton.style.border =
|
|
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;
|
|
364
|
+
const scBtn = sendCodeButton;
|
|
240
365
|
scBtn.onmouseover = () => {
|
|
241
|
-
scBtn.
|
|
242
|
-
|
|
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 =
|
|
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
|
-
|
|
262
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
297
|
-
cacheControlBox.style.border =
|
|
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 =
|
|
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 =
|
|
326
|
-
cachedNoticeContainer.style.border =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
356
|
-
removeCachedBtn.style.color =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 ?
|
|
532
|
+
tab.style.color = isActive ? COLORS.PRIMARY : COLORS.NEUTRAL_TEXT_SEC;
|
|
396
533
|
tab.style.border = "none";
|
|
397
534
|
tab.style.borderBottom = isActive
|
|
398
|
-
?
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
457
|
-
canvas.height =
|
|
458
|
-
canvas.
|
|
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 =
|
|
472
|
-
clearButton.style.border =
|
|
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 =
|
|
615
|
+
clearButton.style.color = COLORS.NEUTRAL_TEXT;
|
|
477
616
|
clearButton.style.transition = "all 0.2s ease";
|
|
478
617
|
clearButton.onmouseover = () => {
|
|
479
|
-
clearButton.style.backgroundColor =
|
|
618
|
+
clearButton.style.backgroundColor = COLORS.NEUTRAL_LIGHTER;
|
|
480
619
|
};
|
|
481
620
|
clearButton.onmouseout = () => {
|
|
482
|
-
clearButton.style.backgroundColor =
|
|
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 =
|
|
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
|
|
649
|
+
uploadText.innerText = "Click to upload a signature image";
|
|
509
650
|
uploadText.style.fontSize = "14px";
|
|
510
|
-
uploadText.style.color =
|
|
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
|
|
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 =
|
|
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 ===
|
|
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
|
-
|
|
600
|
-
if (
|
|
727
|
+
const shouldEnable = isCodeValid && isSignatureValid;
|
|
728
|
+
if (shouldEnable) {
|
|
601
729
|
signButton.disabled = false;
|
|
602
|
-
signButton.style.backgroundColor =
|
|
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 =
|
|
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 =
|
|
639
|
-
activeBtn.style.borderBottom =
|
|
640
|
-
|
|
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 =
|
|
783
|
+
let secondsLeft = TFA_COOLDOWN_SECONDS;
|
|
668
784
|
scBtnRef.disabled = true;
|
|
669
|
-
scBtnRef.style.backgroundColor =
|
|
785
|
+
scBtnRef.style.backgroundColor = COLORS.NEUTRAL_LIGHT;
|
|
670
786
|
scBtnRef.style.color = "#a19f9d";
|
|
671
|
-
scBtnRef.style.borderColor =
|
|
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 =
|
|
689
|
-
scBtnRef.style.borderColor =
|
|
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
|
-
|
|
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
|
-
|
|
842
|
+
if (vInputRef.value.trim().length === PASSCODE_LENGTH) {
|
|
843
|
+
tfaVerified = vInputRef.value.trim() === generatedPasscode;
|
|
844
|
+
}
|
|
732
845
|
updateButtonState();
|
|
733
846
|
};
|
|
734
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
1025
|
+
signButton.style.backgroundColor = COLORS.PRIMARY_DARK;
|
|
907
1026
|
}
|
|
908
1027
|
};
|
|
909
1028
|
signButton.onmouseout = () => {
|
|
910
1029
|
if (!signButton.disabled) {
|
|
911
|
-
signButton.style.backgroundColor =
|
|
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
|
-
|
|
923
|
-
|
|
924
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
1033
|
-
|
|
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
|
-
* **
|
|
1046
|
-
*
|
|
1047
|
-
*
|
|
1048
|
-
*
|
|
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
|
-
* **
|
|
1178
|
-
*
|
|
1179
|
-
*
|
|
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
|
|
@@ -1201,22 +1349,21 @@ function isCanvasEmpty(ctx, width, height) {
|
|
|
1201
1349
|
async function verifySecureAuditRecord(auditRecord, signer, payload) {
|
|
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
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
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);
|
|
1214
1362
|
const auditEnvelope = {
|
|
1215
|
-
payload:
|
|
1216
|
-
signer:
|
|
1217
|
-
timestamp:
|
|
1363
|
+
payload: deepSortedPayload,
|
|
1364
|
+
signer: normalizedSigner,
|
|
1365
|
+
timestamp: normalizedTimestamp,
|
|
1218
1366
|
};
|
|
1219
|
-
// Hash the audit envelope
|
|
1220
1367
|
const auditEnvelopeJson = JSON.stringify(auditEnvelope);
|
|
1221
1368
|
const encoder = new TextEncoder();
|
|
1222
1369
|
const encodedBytes = encoder.encode(auditEnvelopeJson);
|
|
@@ -1225,17 +1372,60 @@ async function verifySecureAuditRecord(auditRecord, signer, payload) {
|
|
|
1225
1372
|
const recomputedHash = hashArray
|
|
1226
1373
|
.map((b) => b.toString(16).padStart(2, "0"))
|
|
1227
1374
|
.join("");
|
|
1228
|
-
|
|
1229
|
-
const isValid = recomputedHash === auditRecord.signatureHash.trim();
|
|
1230
|
-
console.log("verifySecureAuditRecord: Verification result =", isValid, "Recomputed hash =", recomputedHash.substring(0, 16) + "...");
|
|
1375
|
+
const isValid = recomputedHash === normalizedHash;
|
|
1231
1376
|
return isValid;
|
|
1232
1377
|
}
|
|
1233
1378
|
catch (error) {
|
|
1234
|
-
console.error("verifySecureAuditRecord: Error during verification", error);
|
|
1235
1379
|
return false;
|
|
1236
1380
|
}
|
|
1237
1381
|
}
|
|
1238
1382
|
exports.verifySecureAuditRecord = verifySecureAuditRecord;
|
|
1383
|
+
/**
|
|
1384
|
+
* Deep-sorts a payload object recursively, ensuring nested objects
|
|
1385
|
+
* and array items maintain consistent key ordering.
|
|
1386
|
+
*
|
|
1387
|
+
* @param obj - The object to sort
|
|
1388
|
+
* @returns A new object with all keys sorted recursively
|
|
1389
|
+
*/
|
|
1390
|
+
function deepSortPayload(obj) {
|
|
1391
|
+
if (obj === null || obj === undefined) {
|
|
1392
|
+
return obj;
|
|
1393
|
+
}
|
|
1394
|
+
if (Array.isArray(obj)) {
|
|
1395
|
+
return obj.map((item) => {
|
|
1396
|
+
if (item !== null && typeof item === "object" && !Array.isArray(item)) {
|
|
1397
|
+
return deepSortPayload(item);
|
|
1398
|
+
}
|
|
1399
|
+
return item;
|
|
1400
|
+
}); // ← Removed 'as unknown[]' cast
|
|
1401
|
+
}
|
|
1402
|
+
if (typeof obj === "object") {
|
|
1403
|
+
const sorted = {};
|
|
1404
|
+
const keys = Object.keys(obj).sort();
|
|
1405
|
+
keys.forEach((key) => {
|
|
1406
|
+
const value = obj[key];
|
|
1407
|
+
if (value === null || value === undefined) {
|
|
1408
|
+
sorted[key] = value;
|
|
1409
|
+
}
|
|
1410
|
+
else if (Array.isArray(value)) {
|
|
1411
|
+
sorted[key] = value.map((item) => {
|
|
1412
|
+
if (item !== null && typeof item === "object" && !Array.isArray(item)) {
|
|
1413
|
+
return deepSortPayload(item);
|
|
1414
|
+
}
|
|
1415
|
+
return item;
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
1418
|
+
else if (typeof value === "object") {
|
|
1419
|
+
sorted[key] = deepSortPayload(value);
|
|
1420
|
+
}
|
|
1421
|
+
else {
|
|
1422
|
+
sorted[key] = value;
|
|
1423
|
+
}
|
|
1424
|
+
});
|
|
1425
|
+
return sorted;
|
|
1426
|
+
}
|
|
1427
|
+
return obj;
|
|
1428
|
+
}
|
|
1239
1429
|
// ---------------------------------------------------------------------------
|
|
1240
1430
|
// Internal – audit-record generation
|
|
1241
1431
|
// ---------------------------------------------------------------------------
|
|
@@ -1246,31 +1436,36 @@ exports.verifySecureAuditRecord = verifySecureAuditRecord;
|
|
|
1246
1436
|
*
|
|
1247
1437
|
* This is an internal function and is not exported.
|
|
1248
1438
|
*
|
|
1439
|
+
* **Key Binding:** The itemID is now included in the audit envelope
|
|
1440
|
+
* hash to tie the signature to a specific transaction and prevent
|
|
1441
|
+
* signature replay attacks.
|
|
1442
|
+
*
|
|
1249
1443
|
* @param context - The full signer context, extended with the
|
|
1250
1444
|
* compressed signature data string.
|
|
1251
1445
|
* @returns A fully populated `SharePointAuditRecord`.
|
|
1252
1446
|
*
|
|
1253
|
-
* @throws {Error} If the payload is empty
|
|
1447
|
+
* @throws {Error} If the payload is empty, signer is blank, or payload
|
|
1448
|
+
* is not JSON-serializable.
|
|
1254
1449
|
*/
|
|
1255
1450
|
async function generateSecureAuditRecordInternal(context) {
|
|
1256
|
-
const { payload, signer, signatureData } = context;
|
|
1451
|
+
const { payload, signer, signatureData, itemID } = context;
|
|
1452
|
+
assertNonEmptyString(signer, "Signer");
|
|
1453
|
+
const sanitizedSigner = sanitizeSigner(signer);
|
|
1257
1454
|
if (!payload || Object.keys(payload).length === 0) {
|
|
1258
1455
|
throw new Error("Payload cannot be empty.");
|
|
1259
1456
|
}
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
const signatureTimestamp = new Date().toISOString();
|
|
1264
|
-
const sortedPayloadString = JSON.stringify(payload, Object.keys(payload).sort());
|
|
1457
|
+
validateJsonSerializable(payload);
|
|
1458
|
+
const signatureTimestamp = generateTimestamp();
|
|
1459
|
+
const deepSortedPayload = deepSortPayload(payload);
|
|
1265
1460
|
const auditEnvelope = {
|
|
1266
|
-
payload:
|
|
1267
|
-
signer:
|
|
1268
|
-
timestamp: signatureTimestamp
|
|
1461
|
+
payload: deepSortedPayload,
|
|
1462
|
+
signer: sanitizedSigner,
|
|
1463
|
+
timestamp: signatureTimestamp,
|
|
1464
|
+
itemID: itemID,
|
|
1269
1465
|
};
|
|
1270
1466
|
const auditEnvelopeJson = JSON.stringify(auditEnvelope);
|
|
1271
1467
|
const encoder = new TextEncoder();
|
|
1272
1468
|
const encodedBytes = encoder.encode(auditEnvelopeJson);
|
|
1273
|
-
// Passing `encodedBytes.buffer as ArrayBuffer` satisfies the DOM BufferSource definition
|
|
1274
1469
|
const hashBuffer = await window.crypto.subtle.digest("SHA-256", encodedBytes.buffer);
|
|
1275
1470
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
1276
1471
|
const signatureHash = hashArray
|
|
@@ -1280,6 +1475,6 @@ async function generateSecureAuditRecordInternal(context) {
|
|
|
1280
1475
|
signatureHash,
|
|
1281
1476
|
signatureData,
|
|
1282
1477
|
signatureTimestamp,
|
|
1283
|
-
verificationItemId:
|
|
1478
|
+
verificationItemId: itemID,
|
|
1284
1479
|
};
|
|
1285
1480
|
}
|