@modernman00/shared-js-lib 1.2.81 → 1.2.83
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/AcctMgt/AdminAuthForm.js +26 -0
- package/AcctMgt/createFormHandler.js +25 -13
- package/AcctMgt/processAll.js +23 -1
- package/AcctMgt/recaptchaToken.js +25 -10
- package/AcctMgt/update.js +66 -33
- package/Alert.js +158 -0
- package/AuthInterceptor.js +56 -0
- package/ButtonLoader.js +133 -0
- package/CardCanvasGenerator.js +367 -0
- package/Clipboard.js +45 -0
- package/Cookie.js +1 -19
- package/FormLock.js +58 -0
- package/Http.js +158 -51
- package/InlineValidation.js +78 -0
- package/NetworkGuard.js +54 -0
- package/OfflineQueue.js +278 -0
- package/PwaInstall.js +165 -0
- package/PwaManager.js +224 -0
- package/README.md +74 -0
- package/ShareManager.js +462 -0
- package/ShowResponse.js +120 -0
- package/axiosWrapper.js +3 -0
- package/config/version.php +1 -1
- package/index.js +14 -1
- package/package.json +18 -3
- package/release-history.md +35 -0
- package/templates/sw.template.js +239 -0
package/Http.js
CHANGED
|
@@ -1,90 +1,191 @@
|
|
|
1
1
|
import { id, log } from './UtilityHtml.js';
|
|
2
2
|
import axios from './axiosWrapper.js';
|
|
3
3
|
import { redirectAfterDelay, parseErrorResponse } from './general.js';
|
|
4
|
+
import { swalSuccess, swalError, swalToast } from './Alert.js';
|
|
5
|
+
import { setButtonLoading, clearButtonLoading } from './ButtonLoader.js';
|
|
6
|
+
import { acquireLock, releaseLock } from './FormLock.js';
|
|
7
|
+
import { isOnline } from './NetworkGuard.js';
|
|
4
8
|
|
|
5
9
|
/**
|
|
10
|
+
* Sends form data via POST request with automatic button spinner, SweetAlert2 alerts, and robust error handling.
|
|
11
|
+
*
|
|
12
|
+
* Can be called with positional arguments (backward-compatible):
|
|
13
|
+
* postFormData(url, formId, redirect, css, recaptchaToken, button)
|
|
14
|
+
*
|
|
15
|
+
* Or with a modern config object:
|
|
16
|
+
* postFormData(url, formId, { redirect, css, button, recaptchaToken, resetForm })
|
|
6
17
|
*
|
|
7
|
-
* Sends form data via POST request.
|
|
8
18
|
* @param {string} url - The URL to post the data to.
|
|
9
|
-
* @param {string} formId - The ID or
|
|
10
|
-
* @param {string|null}
|
|
11
|
-
* @param {string|null} css -
|
|
12
|
-
|
|
13
|
-
|
|
19
|
+
* @param {string|HTMLFormElement} formId - The ID string or DOM element of the form.
|
|
20
|
+
* @param {string|Object|null} [redirectOrOptions=null] - Redirect URL, or options configuration object.
|
|
21
|
+
* @param {string|null} [css=null] - CSS framework ('bootstrap', 'bulma', 'W3css', 'tailwind').
|
|
22
|
+
* @param {Object|null} [recaptchaToken=null] - reCAPTCHA token payload ({ token, action }).
|
|
23
|
+
* @param {string|HTMLElement|null} [button=null] - Specific button ID or element to show spinner on.
|
|
24
|
+
* @returns {Promise<Object>} Axios response object on success.
|
|
14
25
|
*/
|
|
15
|
-
export const postFormData = async (
|
|
26
|
+
export const postFormData = async (
|
|
27
|
+
url,
|
|
28
|
+
formId,
|
|
29
|
+
redirectOrOptions = null,
|
|
30
|
+
css = null,
|
|
31
|
+
recaptchaToken = null,
|
|
32
|
+
button = null
|
|
33
|
+
) => {
|
|
34
|
+
// Determine if third argument is an options object
|
|
35
|
+
const isOptionsObj = redirectOrOptions !== null && typeof redirectOrOptions === 'object';
|
|
36
|
+
const redirect = isOptionsObj ? redirectOrOptions.redirect : redirectOrOptions;
|
|
37
|
+
const themeCss = isOptionsObj ? (redirectOrOptions.css || css) : css;
|
|
38
|
+
const recaptcha = isOptionsObj ? (redirectOrOptions.recaptchaToken || recaptchaToken) : recaptchaToken;
|
|
39
|
+
const buttonTarget = isOptionsObj ? (redirectOrOptions.button || button) : button;
|
|
40
|
+
const shouldResetForm = isOptionsObj && typeof redirectOrOptions.resetForm === 'boolean'
|
|
41
|
+
? redirectOrOptions.resetForm
|
|
42
|
+
: true;
|
|
43
|
+
|
|
44
|
+
// 1. Connectivity Check
|
|
45
|
+
if (!isOnline()) {
|
|
46
|
+
swalToast('No internet connection. Please check your network and retry.', 'warning', 4000);
|
|
47
|
+
return { error: true, message: 'Offline' };
|
|
48
|
+
}
|
|
16
49
|
|
|
17
|
-
|
|
18
|
-
|
|
50
|
+
// 2. Double-Submit Protection Lock
|
|
51
|
+
const lockKey = `${url}::${typeof formId === 'string' ? formId : (formId.id || 'form')}`;
|
|
52
|
+
if (!acquireLock(lockKey)) {
|
|
53
|
+
swalToast('Please wait, a submission is already in progress...', 'warning', 2500);
|
|
54
|
+
return { error: true, message: 'In flight' };
|
|
55
|
+
}
|
|
19
56
|
|
|
20
|
-
|
|
21
|
-
const
|
|
57
|
+
// Resolve form element
|
|
58
|
+
const form = typeof formId === 'string' ? id(formId) : formId;
|
|
59
|
+
if (!form) {
|
|
60
|
+
releaseLock(lockKey);
|
|
61
|
+
throw new Error('Form element not found');
|
|
62
|
+
}
|
|
22
63
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
64
|
+
// Resolve button for spinner:
|
|
65
|
+
// Works with any button type: type="button", type="submit", or no type.
|
|
66
|
+
let btnEl = null;
|
|
67
|
+
if (buttonTarget) {
|
|
68
|
+
btnEl = typeof buttonTarget === 'string'
|
|
69
|
+
? (id(buttonTarget.replace(/^#/, '')) || document.querySelector(buttonTarget))
|
|
70
|
+
: buttonTarget;
|
|
71
|
+
}
|
|
72
|
+
if (!btnEl && form instanceof HTMLFormElement) {
|
|
73
|
+
// Smart fallback priority:
|
|
74
|
+
// 1. type="submit" or input submit
|
|
75
|
+
// 2. Any button with common action classes or IDs (save, submit)
|
|
76
|
+
// 3. ANY button in the form, including type="button"
|
|
77
|
+
btnEl = form.querySelector('button[type="submit"], input[type="submit"]')
|
|
78
|
+
|| form.querySelector('button#submit, button#save, button.btn-primary, button.is-primary')
|
|
79
|
+
|| form.querySelector('button');
|
|
26
80
|
}
|
|
27
|
-
// Cleanup previous notification styles
|
|
28
|
-
notificationId.style.display = 'none';
|
|
29
81
|
|
|
30
|
-
|
|
82
|
+
// Set button spinner and disable button
|
|
83
|
+
if (btnEl) {
|
|
84
|
+
setButtonLoading(btnEl);
|
|
85
|
+
}
|
|
31
86
|
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
// const csrfInput = id('token');
|
|
36
|
-
// if (csrfInput) {
|
|
37
|
-
// csrfInput.value = csrfToken; // Set the CSRF token in the hidden input field
|
|
38
|
-
// }
|
|
87
|
+
// Optional legacy external loader
|
|
88
|
+
const loader = id('setLoader');
|
|
89
|
+
if (loader) loader.style.display = 'block';
|
|
39
90
|
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
91
|
+
// Determine notification requirement:
|
|
92
|
+
// If called with modern options object, notification is optional (SweetAlert2 handles alerts).
|
|
93
|
+
// If called with legacy positional arguments, enforce legacy expectation if configured.
|
|
94
|
+
const formIdStr = typeof formId === 'string' ? formId : (form.id || '');
|
|
95
|
+
const notificationId = formIdStr ? id(`${formIdStr}_notification`) : null;
|
|
43
96
|
|
|
44
|
-
if (!
|
|
45
|
-
throw new Error('
|
|
97
|
+
if (!notificationId && !isOptionsObj) {
|
|
98
|
+
throw new Error('Notification element not found');
|
|
46
99
|
}
|
|
47
100
|
|
|
48
|
-
|
|
101
|
+
if (notificationId) {
|
|
102
|
+
notificationId.style.display = 'none';
|
|
103
|
+
['is-danger', 'is-success', 'w3-red', 'w3-green', 'bg-danger', 'bg-success'].forEach(cls =>
|
|
104
|
+
notificationId.classList.remove(cls)
|
|
105
|
+
);
|
|
106
|
+
}
|
|
49
107
|
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
if (
|
|
108
|
+
// Build form data
|
|
109
|
+
let formEntries;
|
|
110
|
+
if (form instanceof FormData) {
|
|
111
|
+
formEntries = form;
|
|
112
|
+
} else if (form instanceof HTMLFormElement) {
|
|
113
|
+
formEntries = new FormData(form);
|
|
114
|
+
} else {
|
|
115
|
+
formEntries = new FormData();
|
|
116
|
+
}
|
|
53
117
|
|
|
54
|
-
|
|
55
|
-
|
|
118
|
+
// Append recaptcha token if present
|
|
119
|
+
if (recaptcha) {
|
|
120
|
+
formEntries.append('siteKey', recaptcha.token || '');
|
|
121
|
+
formEntries.append('action', recaptcha.action || 'SUBMIT');
|
|
56
122
|
}
|
|
57
123
|
|
|
58
124
|
formEntries.delete('submit');
|
|
59
125
|
formEntries.delete('checkbox');
|
|
60
126
|
|
|
61
127
|
const options = {
|
|
62
|
-
baseURL: '/',
|
|
63
|
-
withCredentials: true,
|
|
128
|
+
baseURL: '/',
|
|
129
|
+
withCredentials: true,
|
|
64
130
|
};
|
|
65
131
|
|
|
66
|
-
// AXIOS POST FUNCTIONALITY
|
|
67
132
|
try {
|
|
68
133
|
const response = await axios.post(url, formEntries, options);
|
|
69
|
-
const successClass = getNotificationClassByCSS(
|
|
134
|
+
const successClass = getNotificationClassByCSS(themeCss || 'bulma', 'green');
|
|
70
135
|
|
|
71
|
-
|
|
72
|
-
const { message } = response.data || {};
|
|
136
|
+
const { message } = response?.data || {};
|
|
73
137
|
if (!message) {
|
|
74
138
|
throw new Error('Response data does not contain a message');
|
|
75
139
|
}
|
|
76
|
-
|
|
140
|
+
|
|
141
|
+
const successMsg = typeof message === 'string' && message.trim()
|
|
142
|
+
? message.trim()
|
|
143
|
+
: 'Operation completed successfully.';
|
|
144
|
+
|
|
145
|
+
// Modern SweetAlert2 toast notification
|
|
146
|
+
swalSuccess(successMsg, true);
|
|
147
|
+
|
|
148
|
+
// Fallback DOM notification if element exists
|
|
149
|
+
if (notificationId) {
|
|
150
|
+
processFormDataAction(successClass, successMsg, notificationId);
|
|
151
|
+
}
|
|
77
152
|
|
|
78
153
|
if (redirect) {
|
|
79
|
-
redirectAfterDelay(redirect,
|
|
154
|
+
redirectAfterDelay(redirect, 1500);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (shouldResetForm && typeof form.reset === 'function') {
|
|
158
|
+
form.reset();
|
|
80
159
|
}
|
|
81
160
|
|
|
82
|
-
|
|
161
|
+
return response;
|
|
83
162
|
|
|
84
163
|
} catch (error) {
|
|
85
|
-
const errorClass = getNotificationClassByCSS(
|
|
86
|
-
const
|
|
87
|
-
|
|
164
|
+
const errorClass = getNotificationClassByCSS(themeCss || 'bulma', 'red');
|
|
165
|
+
const errorMsg = parseErrorResponse(error) || 'An error occurred during submission.';
|
|
166
|
+
|
|
167
|
+
// Modern SweetAlert2 error modal/toast
|
|
168
|
+
swalError(errorMsg, true);
|
|
169
|
+
|
|
170
|
+
// Fallback DOM notification if element exists
|
|
171
|
+
if (notificationId) {
|
|
172
|
+
processFormDataAction(errorClass, errorMsg, notificationId);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return { error: true, message: errorMsg, rawError: error };
|
|
176
|
+
|
|
177
|
+
} finally {
|
|
178
|
+
// Release lock
|
|
179
|
+
releaseLock(lockKey);
|
|
180
|
+
|
|
181
|
+
// Guaranteed spinner cleanup
|
|
182
|
+
if (btnEl) {
|
|
183
|
+
clearButtonLoading(btnEl);
|
|
184
|
+
}
|
|
185
|
+
if (loader) {
|
|
186
|
+
loader.style.display = 'none';
|
|
187
|
+
loader.classList.remove('loader');
|
|
188
|
+
}
|
|
88
189
|
}
|
|
89
190
|
};
|
|
90
191
|
|
|
@@ -167,11 +268,14 @@ export const getApiData = async (URL, token = null) => {
|
|
|
167
268
|
headers: {
|
|
168
269
|
"X-Requested-With": "XMLHttpRequest",
|
|
169
270
|
"Content-Type": "application/json",
|
|
170
|
-
Accept: "application/json"
|
|
171
|
-
...(token && { Authorization: `Bearer ${token}` }), // only add if token exists
|
|
271
|
+
Accept: "application/json"
|
|
172
272
|
},
|
|
173
273
|
};
|
|
174
274
|
|
|
275
|
+
if (token) {
|
|
276
|
+
config.headers['Authorization'] = `Bearer ${token}`;
|
|
277
|
+
}
|
|
278
|
+
|
|
175
279
|
const response = await axios.get(URL, config);
|
|
176
280
|
return response.data;
|
|
177
281
|
} catch (error) {
|
|
@@ -208,11 +312,14 @@ export const getMultipleApiData = async (url1, url2, token = null) => {
|
|
|
208
312
|
headers: {
|
|
209
313
|
"X-Requested-With": "XMLHttpRequest",
|
|
210
314
|
"Content-Type": "application/json",
|
|
211
|
-
Accept: "application/json"
|
|
212
|
-
...(token && { Authorization: `Bearer ${token}` }), // add only if token exists
|
|
315
|
+
Accept: "application/json"
|
|
213
316
|
},
|
|
214
317
|
};
|
|
215
318
|
|
|
319
|
+
if (token) {
|
|
320
|
+
config.headers['Authorization'] = `Bearer ${token}`;
|
|
321
|
+
}
|
|
322
|
+
|
|
216
323
|
// run requests in parallel
|
|
217
324
|
const [res1, res2] = await axios.all([
|
|
218
325
|
axios.get(url1, config),
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
import { id } from './UtilityHtml.js';
|
|
3
|
+
|
|
4
|
+
const VALID_EMAIL_REGEX = /^[\w\-\.\+]+\@[a-zA-Z0-9\.\-]+\.[a-zA-Z0-9]{2,6}$/;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Attaches real-time validation and micro-feedback to form fields on blur/input.
|
|
8
|
+
*
|
|
9
|
+
* @param {string|HTMLElement} fieldId - ID or DOM element of the input
|
|
10
|
+
* @param {Object} options
|
|
11
|
+
* @param {'email'|'password'|'required'|'custom'} [options.type='required']
|
|
12
|
+
* @param {(value: string) => { valid: boolean, message?: string }} [options.validator]
|
|
13
|
+
* @param {string} [options.errorColor='#e74c3c']
|
|
14
|
+
* @param {string} [options.successColor='#27ae60']
|
|
15
|
+
*/
|
|
16
|
+
export const attachInlineValidation = (fieldId, options = {}) => {
|
|
17
|
+
if (typeof document === 'undefined') return;
|
|
18
|
+
|
|
19
|
+
const el = typeof fieldId === 'string' ? id(fieldId) : fieldId;
|
|
20
|
+
if (!el) return;
|
|
21
|
+
|
|
22
|
+
const {
|
|
23
|
+
type = 'required',
|
|
24
|
+
validator = null,
|
|
25
|
+
errorColor = '#e74c3c',
|
|
26
|
+
successColor = '#27ae60'
|
|
27
|
+
} = options;
|
|
28
|
+
|
|
29
|
+
const errorElId = `${el.id || el.name}_error`;
|
|
30
|
+
let feedbackEl = id(errorElId);
|
|
31
|
+
|
|
32
|
+
const validate = () => {
|
|
33
|
+
const val = (el.value || '').trim();
|
|
34
|
+
let isValid = true;
|
|
35
|
+
let message = '';
|
|
36
|
+
|
|
37
|
+
if (typeof validator === 'function') {
|
|
38
|
+
const result = validator(val);
|
|
39
|
+
isValid = !!result.valid;
|
|
40
|
+
message = result.message || '';
|
|
41
|
+
} else if (type === 'required') {
|
|
42
|
+
isValid = val.length > 0;
|
|
43
|
+
message = isValid ? '' : 'This field is required.';
|
|
44
|
+
} else if (type === 'email') {
|
|
45
|
+
isValid = VALID_EMAIL_REGEX.test(val);
|
|
46
|
+
message = isValid ? '' : 'Please enter a valid email address.';
|
|
47
|
+
} else if (type === 'password') {
|
|
48
|
+
isValid = val.length >= 8;
|
|
49
|
+
message = isValid ? '' : 'Password must be at least 8 characters long.';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Apply micro-feedback styling
|
|
53
|
+
if (isValid) {
|
|
54
|
+
el.style.borderColor = val ? successColor : '';
|
|
55
|
+
if (feedbackEl) {
|
|
56
|
+
feedbackEl.innerHTML = '';
|
|
57
|
+
feedbackEl.style.display = 'none';
|
|
58
|
+
}
|
|
59
|
+
} else {
|
|
60
|
+
el.style.borderColor = errorColor;
|
|
61
|
+
if (feedbackEl) {
|
|
62
|
+
feedbackEl.style.display = 'block';
|
|
63
|
+
feedbackEl.style.color = errorColor;
|
|
64
|
+
feedbackEl.innerHTML = message;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return isValid;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
el.addEventListener('blur', validate);
|
|
72
|
+
el.addEventListener('input', () => {
|
|
73
|
+
// Clear error border while user is re-typing
|
|
74
|
+
if (el.style.borderColor === errorColor) {
|
|
75
|
+
validate();
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
};
|
package/NetworkGuard.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
import { swalToast } from './Alert.js';
|
|
3
|
+
|
|
4
|
+
let isListening = false;
|
|
5
|
+
let wasOffline = false;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Checks if the browser is currently online.
|
|
9
|
+
* @returns {boolean}
|
|
10
|
+
*/
|
|
11
|
+
export const isOnline = () => {
|
|
12
|
+
return typeof navigator !== 'undefined' && typeof navigator.onLine === 'boolean'
|
|
13
|
+
? navigator.onLine
|
|
14
|
+
: true;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Attaches automatic online/offline listeners to show clean toasts.
|
|
19
|
+
* Safe to call multiple times (idempotent).
|
|
20
|
+
*/
|
|
21
|
+
export const setupNetworkListeners = () => {
|
|
22
|
+
if (typeof window === 'undefined' || isListening) return;
|
|
23
|
+
isListening = true;
|
|
24
|
+
|
|
25
|
+
window.addEventListener('offline', () => {
|
|
26
|
+
wasOffline = true;
|
|
27
|
+
swalToast('Internet connection lost. You are offline.', 'warning', 4000);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
window.addEventListener('online', () => {
|
|
31
|
+
if (wasOffline) {
|
|
32
|
+
swalToast('Connection restored. Back online!', 'success', 3000);
|
|
33
|
+
wasOffline = false;
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Wraps an async request to check connectivity before dispatching.
|
|
40
|
+
*
|
|
41
|
+
* @param {() => Promise<any>} requestFn
|
|
42
|
+
* @param {string} [offlineMsg='No internet connection. Please check your network.']
|
|
43
|
+
* @returns {Promise<any>}
|
|
44
|
+
*/
|
|
45
|
+
export const withNetworkGuard = async (
|
|
46
|
+
requestFn,
|
|
47
|
+
offlineMsg = 'No internet connection. Please check your network.'
|
|
48
|
+
) => {
|
|
49
|
+
if (!isOnline()) {
|
|
50
|
+
swalToast(offlineMsg, 'warning', 4000);
|
|
51
|
+
throw new Error(offlineMsg);
|
|
52
|
+
}
|
|
53
|
+
return await requestFn();
|
|
54
|
+
};
|
package/OfflineQueue.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 📦 OfflineQueue — Resilient IndexedDB Offline Request Queue
|
|
3
|
+
* Part of @modernman00/shared-js-lib
|
|
4
|
+
*
|
|
5
|
+
* Implements the Deloitte Digital & BRATS-approved enterprise architecture:
|
|
6
|
+
* 1. UUIDv4 Idempotency Key injection ('X-Idempotency-Key') to eliminate duplicate writes
|
|
7
|
+
* 2. Strict Security Blacklist: Refuses to store credentials, passwords, or /auth/* /admin/* requests
|
|
8
|
+
* 3. Kieran Memory Cap: 2MB max per payload, 30 items max queue depth
|
|
9
|
+
* 4. 24-Hour automatic request expiration
|
|
10
|
+
* 5. Automatic replay on 'online' event with fresh CSRF token header
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export class OfflineQueue {
|
|
14
|
+
constructor(options = {}) {
|
|
15
|
+
this.appName = options.appName || (typeof window !== 'undefined' ? window.location.hostname.replace(/[^a-zA-Z0-9]/g, '_') : 'app');
|
|
16
|
+
this.dbName = options.dbName || `${this.appName}_offline_db`;
|
|
17
|
+
this.storeName = 'offline_requests';
|
|
18
|
+
this.dbVersion = 1;
|
|
19
|
+
this.maxItems = options.maxItems || 30;
|
|
20
|
+
this.maxPayloadBytes = options.maxPayloadBytes || 2 * 1024 * 1024; // 2MB cap
|
|
21
|
+
this.maxAgeMs = options.maxAgeMs || 24 * 60 * 60 * 1000; // 24 hours
|
|
22
|
+
this.db = null;
|
|
23
|
+
this.isReplaying = false;
|
|
24
|
+
|
|
25
|
+
// Security Blacklist (never cache credentials/auth/passwords offline)
|
|
26
|
+
this.blacklistPatterns = [
|
|
27
|
+
/\/auth\//i,
|
|
28
|
+
/\/admin\//i,
|
|
29
|
+
/\/login/i,
|
|
30
|
+
/\/logout/i,
|
|
31
|
+
/\/signout/i,
|
|
32
|
+
/\/password/i,
|
|
33
|
+
/\/2fa/i,
|
|
34
|
+
/\/totp/i
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
if (typeof window !== 'undefined') {
|
|
38
|
+
window.addEventListener('online', () => this.replay());
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Generates standard RFC4122 v4 UUID for request idempotency
|
|
44
|
+
*/
|
|
45
|
+
static generateUUID() {
|
|
46
|
+
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
47
|
+
return crypto.randomUUID();
|
|
48
|
+
}
|
|
49
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
50
|
+
const r = (Math.random() * 16) | 0;
|
|
51
|
+
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
52
|
+
return v.toString(16);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Initializes or returns the open IndexedDB instance
|
|
58
|
+
*/
|
|
59
|
+
async getDB() {
|
|
60
|
+
if (this.db) return this.db;
|
|
61
|
+
if (typeof indexedDB === 'undefined') {
|
|
62
|
+
throw new Error('IndexedDB is not supported in this browser environment');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
const request = indexedDB.open(this.dbName, this.dbVersion);
|
|
67
|
+
|
|
68
|
+
request.onerror = () => reject(request.error);
|
|
69
|
+
request.onsuccess = () => {
|
|
70
|
+
this.db = request.result;
|
|
71
|
+
resolve(this.db);
|
|
72
|
+
};
|
|
73
|
+
request.onupgradeneeded = (event) => {
|
|
74
|
+
const db = event.target.result;
|
|
75
|
+
if (!db.objectStoreNames.contains(this.storeName)) {
|
|
76
|
+
const store = db.createObjectStore(this.storeName, { keyPath: 'id', autoIncrement: true });
|
|
77
|
+
store.createIndex('timestamp', 'timestamp', { unique: false });
|
|
78
|
+
store.createIndex('idempotencyKey', 'idempotencyKey', { unique: true });
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Check if URL matches the security blacklist
|
|
86
|
+
*/
|
|
87
|
+
isBlacklisted(url) {
|
|
88
|
+
if (!url || typeof url !== 'string') return true;
|
|
89
|
+
return this.blacklistPatterns.some(pattern => pattern.test(url));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Enqueues an outgoing request for offline storage and later replay
|
|
94
|
+
*/
|
|
95
|
+
async enqueue(requestData) {
|
|
96
|
+
const { url, method = 'POST', headers = {}, body = null } = requestData;
|
|
97
|
+
|
|
98
|
+
// Security Gate
|
|
99
|
+
if (this.isBlacklisted(url)) {
|
|
100
|
+
console.warn(`[OfflineQueue] Blocked security-sensitive request from offline storage: ${url}`);
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Payload Size Guard (Kieran Memory Protection)
|
|
105
|
+
const serializedBody = typeof body === 'string' ? body : JSON.stringify(body || {});
|
|
106
|
+
const bodySize = typeof Blob !== 'undefined' ? new Blob([serializedBody]).size : serializedBody.length;
|
|
107
|
+
if (bodySize > this.maxPayloadBytes) {
|
|
108
|
+
console.warn(`[OfflineQueue] Request payload exceeds 2MB memory cap (${bodySize} bytes). Dropping.`);
|
|
109
|
+
if (typeof window !== 'undefined' && window.dispatchEvent) {
|
|
110
|
+
window.dispatchEvent(new CustomEvent('offlinequeue:payload-too-large', { detail: { url } }));
|
|
111
|
+
}
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Filter headers to strip sensitive authorization tokens from plaintext IndexedDB
|
|
116
|
+
const safeHeaders = {};
|
|
117
|
+
const headerEntries = headers.entries ? Array.from(headers.entries()) : Object.entries(headers || {});
|
|
118
|
+
for (const [key, value] of headerEntries) {
|
|
119
|
+
const lower = key.toLowerCase();
|
|
120
|
+
if (lower !== 'authorization' && lower !== 'cookie' && lower !== 'set-cookie') {
|
|
121
|
+
safeHeaders[key] = value;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const idempotencyKey = safeHeaders['x-idempotency-key'] || safeHeaders['X-Idempotency-Key'] || OfflineQueue.generateUUID();
|
|
126
|
+
safeHeaders['X-Idempotency-Key'] = idempotencyKey;
|
|
127
|
+
|
|
128
|
+
const db = await this.getDB();
|
|
129
|
+
|
|
130
|
+
return new Promise((resolve, reject) => {
|
|
131
|
+
const tx = db.transaction(this.storeName, 'readwrite');
|
|
132
|
+
const store = tx.objectStore(this.storeName);
|
|
133
|
+
|
|
134
|
+
// Check max queue length
|
|
135
|
+
const countReq = store.count();
|
|
136
|
+
countReq.onsuccess = () => {
|
|
137
|
+
if (countReq.result >= this.maxItems) {
|
|
138
|
+
console.warn(`[OfflineQueue] Queue reached max depth (${this.maxItems}). Dropping oldest request.`);
|
|
139
|
+
const openCursor = store.openCursor();
|
|
140
|
+
openCursor.onsuccess = (e) => {
|
|
141
|
+
const cursor = e.target.result;
|
|
142
|
+
if (cursor) {
|
|
143
|
+
store.delete(cursor.primaryKey);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const item = {
|
|
149
|
+
url,
|
|
150
|
+
method: method.toUpperCase(),
|
|
151
|
+
headers: safeHeaders,
|
|
152
|
+
body: serializedBody,
|
|
153
|
+
idempotencyKey,
|
|
154
|
+
timestamp: Date.now()
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
store.add(item);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
tx.oncomplete = () => {
|
|
161
|
+
console.log(`[OfflineQueue] Request queued successfully with Idempotency Key: ${idempotencyKey}`);
|
|
162
|
+
if (typeof window !== 'undefined' && window.dispatchEvent) {
|
|
163
|
+
window.dispatchEvent(new CustomEvent('offlinequeue:enqueued', { detail: { url, idempotencyKey } }));
|
|
164
|
+
}
|
|
165
|
+
resolve(idempotencyKey);
|
|
166
|
+
};
|
|
167
|
+
tx.onerror = () => reject(tx.error);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Replays all stored offline requests sequentially
|
|
173
|
+
*/
|
|
174
|
+
async replay() {
|
|
175
|
+
if (this.isReplaying || (typeof navigator !== 'undefined' && !navigator.onLine)) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
this.isReplaying = true;
|
|
180
|
+
const db = await this.getDB();
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
const items = await new Promise((resolve, reject) => {
|
|
184
|
+
const tx = db.transaction(this.storeName, 'readonly');
|
|
185
|
+
const store = tx.objectStore(this.storeName);
|
|
186
|
+
const req = store.getAll();
|
|
187
|
+
req.onsuccess = () => resolve(req.result || []);
|
|
188
|
+
req.onerror = () => reject(req.error);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
if (items.length === 0) {
|
|
192
|
+
this.isReplaying = false;
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
console.log(`[OfflineQueue] Network restored. Replaying ${items.length} queued request(s)...`);
|
|
197
|
+
|
|
198
|
+
// Refresh CSRF Token if available in DOM
|
|
199
|
+
let currentCsrf = null;
|
|
200
|
+
if (typeof document !== 'undefined') {
|
|
201
|
+
const metaCsrf = document.querySelector('meta[name="csrf-token"]') || document.querySelector('meta[name="csrf"]');
|
|
202
|
+
if (metaCsrf) currentCsrf = metaCsrf.getAttribute('content');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const now = Date.now();
|
|
206
|
+
|
|
207
|
+
for (const item of items) {
|
|
208
|
+
// Expiration check (24h)
|
|
209
|
+
if (now - item.timestamp > this.maxAgeMs) {
|
|
210
|
+
console.warn(`[OfflineQueue] Request to ${item.url} expired (>24h). Removing from queue.`);
|
|
211
|
+
await this.deleteItem(item.id);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const headers = { ...item.headers };
|
|
216
|
+
if (currentCsrf) {
|
|
217
|
+
headers['X-CSRF-TOKEN'] = currentCsrf;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
const response = await fetch(item.url, {
|
|
222
|
+
method: item.method,
|
|
223
|
+
headers,
|
|
224
|
+
body: item.body
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// If server successfully processed (2xx or 4xx business error), delete from queue
|
|
228
|
+
// (4xx means invalid input or already processed, no point in infinite retry loop)
|
|
229
|
+
if (response.ok || (response.status >= 400 && response.status < 500)) {
|
|
230
|
+
console.log(`[OfflineQueue] Replayed ${item.url} -> Status ${response.status}`);
|
|
231
|
+
await this.deleteItem(item.id);
|
|
232
|
+
} else {
|
|
233
|
+
console.warn(`[OfflineQueue] Server returned ${response.status} for ${item.url}. Will retry later.`);
|
|
234
|
+
break; // Stop replay if server is returning 5xx errors
|
|
235
|
+
}
|
|
236
|
+
} catch (networkErr) {
|
|
237
|
+
console.warn(`[OfflineQueue] Network still unstable during replay: ${networkErr.message}`);
|
|
238
|
+
break; // Stop loop and wait for next online event
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
} catch (err) {
|
|
242
|
+
console.error('[OfflineQueue] Replay cycle error:', err);
|
|
243
|
+
} finally {
|
|
244
|
+
this.isReplaying = false;
|
|
245
|
+
if (typeof window !== 'undefined' && window.dispatchEvent) {
|
|
246
|
+
window.dispatchEvent(new CustomEvent('offlinequeue:replay-complete'));
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Deletes a single item by primary key ID
|
|
253
|
+
*/
|
|
254
|
+
async deleteItem(id) {
|
|
255
|
+
const db = await this.getDB();
|
|
256
|
+
return new Promise((resolve, reject) => {
|
|
257
|
+
const tx = db.transaction(this.storeName, 'readwrite');
|
|
258
|
+
const store = tx.objectStore(this.storeName);
|
|
259
|
+
store.delete(id);
|
|
260
|
+
tx.oncomplete = () => resolve();
|
|
261
|
+
tx.onerror = () => reject(tx.error);
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Clears all queued items
|
|
267
|
+
*/
|
|
268
|
+
async clear() {
|
|
269
|
+
const db = await this.getDB();
|
|
270
|
+
return new Promise((resolve, reject) => {
|
|
271
|
+
const tx = db.transaction(this.storeName, 'readwrite');
|
|
272
|
+
const store = tx.objectStore(this.storeName);
|
|
273
|
+
store.clear();
|
|
274
|
+
tx.oncomplete = () => resolve();
|
|
275
|
+
tx.onerror = () => reject(tx.error);
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|