ui-soxo-bootstrap-core 2.6.63-dev.0 → 2.6.63-dev.1
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/core/lib/components/global-header/global-header.js +191 -151
- package/core/lib/components/support-desk/components/{raise-ticket/raise-ticket.js → support-frame/support-frame.js} +42 -19
- package/core/lib/components/support-desk/support-desk.js +8 -8
- package/core/lib/components/support-desk/support-desk.scss +0 -72
- package/core/lib/components/support-desk/support-desk.utils.js +35 -315
- package/core/lib/components/support-desk/support-desk.utils.test.js +51 -308
- package/package.json +1 -1
- package/core/lib/components/support-desk/components/my-tickets/my-tickets.js +0 -343
- package/core/lib/components/support-desk/support-desk.dom.test.js +0 -20
|
@@ -1,349 +1,69 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Support desk logic
|
|
2
|
+
* Support desk logic.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* encryptedToken base64 secret that decodes to 64 bytes (required)
|
|
8
|
-
* myTicketsLink ticket list page, default: /my-tickets on the helpDeskLink host
|
|
9
|
-
* apiUrl Helpdesk API host, default: host of the ticket list page
|
|
10
|
-
* tokenTtlSeconds token lifetime, default 300, minimum 30
|
|
4
|
+
* Help button settings come from `HELPATR` (showSupportBtn, buttonText, toolTipText).
|
|
5
|
+
* Tokens, secrets and Helpdesk links live only in the branch `SEPS` setting on the server.
|
|
6
|
+
* The browser asks the API for a ready-to-open link and never sees a token configuration.
|
|
11
7
|
*
|
|
12
|
-
* A
|
|
13
|
-
*
|
|
14
|
-
* sides of the window so a slightly wrong device clock does not reject a fresh token.
|
|
15
|
-
*
|
|
16
|
-
* Security: the secret is delivered to the browser, so any logged in user can read it.
|
|
17
|
-
* Move token creation to the API if that is not acceptable.
|
|
8
|
+
* A link is requested for every use (open, reload, open in new tab) and never cached, so an
|
|
9
|
+
* idle drawer never holds an expired token.
|
|
18
10
|
*/
|
|
11
|
+
import ApiUtils from '../../utils/api/api.utils';
|
|
19
12
|
|
|
20
|
-
//
|
|
21
|
-
|
|
22
|
-
export const PAGE_SIZE = 10;
|
|
23
|
-
|
|
24
|
-
// Token lifetime (seconds). Used when `tokenTtlSeconds` is missing or too small.
|
|
25
|
-
export const DEFAULT_TTL_SECONDS = 300;
|
|
26
|
-
export const MIN_TTL_SECONDS = 30;
|
|
27
|
-
|
|
28
|
-
// Extra time added on both sides of the token window so a wrong device clock does not reject it.
|
|
29
|
-
export const CLOCK_SKEW_SECONDS = 30;
|
|
30
|
-
|
|
31
|
-
// Ticket list request is cancelled after this long.
|
|
32
|
-
export const REQUEST_TIMEOUT_MS = 15000;
|
|
33
|
-
|
|
34
|
-
// After this long without the form finishing to load, offer "Open in new tab".
|
|
13
|
+
// After this long without the page finishing to load, offer "Open in new tab".
|
|
35
14
|
export const FORM_SLOW_MS = 8000;
|
|
36
15
|
|
|
37
|
-
|
|
38
|
-
export const API_ERROR_MESSAGES = {
|
|
39
|
-
missing_token: 'No token was sent. Please contact your administrator.',
|
|
40
|
-
invalid_token: 'We could not verify your access. Please try again.',
|
|
41
|
-
expired_token: 'Your access link expired. Please try again.',
|
|
42
|
-
project_not_found: 'We could not find the project for this application.',
|
|
43
|
-
branch_not_found: 'We could not find the branch for your account.',
|
|
44
|
-
server_misconfigured: 'The Helpdesk is not available right now. Please try again in a moment.',
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
export const STATUS_COLORS = {
|
|
48
|
-
open: 'blue',
|
|
49
|
-
replied: 'purple',
|
|
50
|
-
resolved: 'green',
|
|
51
|
-
closed: 'default',
|
|
52
|
-
unassigned: 'orange',
|
|
53
|
-
'on-hold': 'orange',
|
|
54
|
-
'work-in-progress': 'processing',
|
|
55
|
-
'ready-for-confirmation': 'green',
|
|
56
|
-
assigned: 'blue',
|
|
57
|
-
};
|
|
16
|
+
export const GENERATE_TOKEN_URL = 'secure-link/generate-token';
|
|
58
17
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
medium: 'gold',
|
|
64
|
-
low: 'green',
|
|
18
|
+
// `mode` values understood by the generate-token API.
|
|
19
|
+
export const MODES = {
|
|
20
|
+
RAISE: 'helpdesk',
|
|
21
|
+
MINE: 'tickets',
|
|
65
22
|
};
|
|
66
23
|
|
|
67
|
-
// ==============================|| HELPERS ||============================== //
|
|
68
|
-
|
|
69
|
-
export const normalize = (value) => (value || '').toLowerCase().replace(/\s+/g, '-');
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Turns the HTML the API returns for `description` into plain text.
|
|
73
|
-
* DOMParser builds an inert document, so nothing in the markup (images, scripts) is loaded or run.
|
|
74
|
-
*/
|
|
75
|
-
export function toPlainText(html) {
|
|
76
|
-
if (!html) return '';
|
|
77
|
-
const doc = new DOMParser().parseFromString(String(html), 'text/html');
|
|
78
|
-
return (doc.body.textContent || '').replace(/\s+/g, ' ').trim();
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** `creation` arrives as "YYYY-MM-DD HH:mm:ss". */
|
|
82
|
-
export function parseCreation(value) {
|
|
83
|
-
if (!value) return null;
|
|
84
|
-
const parsed = new Date(String(value).replace(' ', 'T'));
|
|
85
|
-
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export function formatDate(value) {
|
|
89
|
-
const parsed = parseCreation(value);
|
|
90
|
-
if (!parsed) return value || '-';
|
|
91
|
-
return parsed.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' });
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
export function formatDateTime(value) {
|
|
95
|
-
const parsed = parseCreation(value);
|
|
96
|
-
return parsed ? parsed.toLocaleString() : '';
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// ==============================|| CONFIG ||============================== //
|
|
100
|
-
|
|
101
|
-
function safeUrl(path, base) {
|
|
102
|
-
try {
|
|
103
|
-
return new URL(path, base).toString();
|
|
104
|
-
} catch (e) {
|
|
105
|
-
return '';
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
24
|
/**
|
|
110
25
|
* @param {object} helpDeskSetting value of settings.HELPATR
|
|
111
|
-
* @
|
|
112
|
-
* @returns {object} config. `enabled` is false when a required setting is missing, so the
|
|
113
|
-
* caller should not show the Help button.
|
|
26
|
+
* @returns {object} config. `enabled` is false when the Help button should not be shown.
|
|
114
27
|
*/
|
|
115
|
-
export function resolveSupportConfig(helpDeskSetting = {}
|
|
116
|
-
const { helpDeskLink, myTicketsLink, apiUrl, projectToken, encryptedToken, tokenTtlSeconds } = helpDeskSetting || {};
|
|
117
|
-
|
|
118
|
-
const ticketsUrl = myTicketsLink || (helpDeskLink ? safeUrl('/my-tickets', helpDeskLink) : '');
|
|
119
|
-
const apiBase = apiUrl || (ticketsUrl ? safeUrl('/', ticketsUrl).replace(/\/$/, '') : '');
|
|
120
|
-
|
|
121
|
-
const ttl = Number(tokenTtlSeconds);
|
|
122
|
-
|
|
28
|
+
export function resolveSupportConfig(helpDeskSetting = {}) {
|
|
123
29
|
return {
|
|
124
|
-
enabled: Boolean(
|
|
125
|
-
formUrl: helpDeskLink || '',
|
|
126
|
-
ticketsUrl,
|
|
127
|
-
apiBase: apiBase.replace(/\/+$/, ''),
|
|
128
|
-
projectToken,
|
|
129
|
-
secret: encryptedToken,
|
|
130
|
-
user: (user && (user.email || user.username)) || '',
|
|
131
|
-
ttlSeconds: Number.isFinite(ttl) && ttl > 0 ? Math.max(MIN_TTL_SECONDS, ttl) : DEFAULT_TTL_SECONDS,
|
|
30
|
+
enabled: Boolean(helpDeskSetting?.showSupportBtn),
|
|
132
31
|
};
|
|
133
32
|
}
|
|
134
33
|
|
|
135
|
-
// ==============================|| TOKEN ||============================== //
|
|
136
|
-
|
|
137
|
-
const KEY_BYTES = 64;
|
|
138
|
-
|
|
139
|
-
const encoder = new TextEncoder();
|
|
140
|
-
|
|
141
|
-
function base64ToBytes(value) {
|
|
142
|
-
// Accept standard and url-safe base64, with or without padding / whitespace.
|
|
143
|
-
const normalized = String(value).trim().replace(/-/g, '+').replace(/_/g, '/').replace(/\s+/g, '');
|
|
144
|
-
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
|
|
145
|
-
const binary = atob(padded);
|
|
146
|
-
const bytes = new Uint8Array(binary.length);
|
|
147
|
-
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
|
148
|
-
return bytes;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function bytesToBase64Url(bytes) {
|
|
152
|
-
let binary = '';
|
|
153
|
-
const view = new Uint8Array(bytes);
|
|
154
|
-
for (let i = 0; i < view.length; i += 1) binary += String.fromCharCode(view[i]);
|
|
155
|
-
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function concat(...parts) {
|
|
159
|
-
const total = parts.reduce((sum, part) => sum + part.length, 0);
|
|
160
|
-
const out = new Uint8Array(total);
|
|
161
|
-
let offset = 0;
|
|
162
|
-
parts.forEach((part) => {
|
|
163
|
-
out.set(part, offset);
|
|
164
|
-
offset += part.length;
|
|
165
|
-
});
|
|
166
|
-
return out;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
* Compact JWE with alg "dir" and enc "A256CBC-HS512" (RFC 7518 5.2.6).
|
|
171
|
-
* Key = 32 byte MAC key + 32 byte AES key.
|
|
172
|
-
*/
|
|
173
|
-
async function encryptJwe(payload, secret) {
|
|
174
|
-
const subtle = window.crypto && window.crypto.subtle;
|
|
175
|
-
if (!subtle) {
|
|
176
|
-
throw new Error('Support needs a secure (https) connection to open.');
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const macKey = await subtle.importKey('raw', secret.slice(0, 32), { name: 'HMAC', hash: 'SHA-512' }, false, ['sign']);
|
|
180
|
-
const encKey = await subtle.importKey('raw', secret.slice(32), { name: 'AES-CBC' }, false, ['encrypt']);
|
|
181
|
-
|
|
182
|
-
const header = bytesToBase64Url(encoder.encode(JSON.stringify({ alg: 'dir', enc: 'A256CBC-HS512' })));
|
|
183
|
-
const aad = encoder.encode(header);
|
|
184
|
-
const iv = window.crypto.getRandomValues(new Uint8Array(16));
|
|
185
|
-
|
|
186
|
-
const ciphertext = new Uint8Array(await subtle.encrypt({ name: 'AES-CBC', iv }, encKey, encoder.encode(JSON.stringify(payload))));
|
|
187
|
-
|
|
188
|
-
// AAD length in bits as a 64 bit big endian number.
|
|
189
|
-
const al = new Uint8Array(8);
|
|
190
|
-
new DataView(al.buffer).setUint32(4, aad.length * 8);
|
|
191
|
-
|
|
192
|
-
const mac = new Uint8Array(await subtle.sign('HMAC', macKey, concat(aad, iv, ciphertext, al)));
|
|
193
|
-
const tag = mac.slice(0, 32);
|
|
194
|
-
|
|
195
|
-
return `${header}..${bytesToBase64Url(iv)}.${bytesToBase64Url(ciphertext)}.${bytesToBase64Url(tag)}`;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/** Branch code of the logged in user, saved as `db_ptr` at login. */
|
|
199
|
-
function readBranch() {
|
|
200
|
-
try {
|
|
201
|
-
return localStorage.getItem('db_ptr') || '';
|
|
202
|
-
} catch (e) {
|
|
203
|
-
return '';
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
207
34
|
/**
|
|
208
|
-
*
|
|
35
|
+
* Asks the API for a fresh Helpdesk link for the given mode.
|
|
36
|
+
* @returns {Promise<string>} https link with the encrypted token already attached
|
|
209
37
|
* @throws {Error} with a user-safe message
|
|
210
38
|
*/
|
|
211
|
-
export async function
|
|
212
|
-
|
|
213
|
-
throw new Error('Support is not configured. Contact your administrator.');
|
|
214
|
-
}
|
|
215
|
-
if (!config.user) {
|
|
216
|
-
throw new Error('Your account has no email, so Support cannot be opened.');
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
let secret;
|
|
220
|
-
try {
|
|
221
|
-
secret = base64ToBytes(config.secret);
|
|
222
|
-
} catch (e) {
|
|
223
|
-
throw new Error('Support link secret is not valid base64.');
|
|
224
|
-
}
|
|
225
|
-
if (secret.length !== KEY_BYTES) {
|
|
226
|
-
throw new Error(`Support link secret must decode to ${KEY_BYTES} bytes (got ${secret.length}).`);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
const now = Math.floor(Date.now() / 1000);
|
|
230
|
-
return encryptJwe(
|
|
231
|
-
{
|
|
232
|
-
token: config.projectToken,
|
|
233
|
-
user: config.user,
|
|
234
|
-
branch: readBranch(),
|
|
235
|
-
iat: now - CLOCK_SKEW_SECONDS,
|
|
236
|
-
exp: now + config.ttlSeconds + CLOCK_SKEW_SECONDS,
|
|
237
|
-
},
|
|
238
|
-
secret
|
|
239
|
-
);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
function withToken(baseUrl, token) {
|
|
243
|
-
const separator = baseUrl.includes('?') ? '&' : '?';
|
|
244
|
-
return `${baseUrl}${separator}d=${encodeURIComponent(token)}`;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
export async function buildFormUrl(config) {
|
|
248
|
-
if (!config.formUrl) throw new Error('Support form link is not configured.');
|
|
249
|
-
return withToken(config.formUrl, await createToken(config));
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
export async function buildTicketsPageUrl(config) {
|
|
253
|
-
if (!config.ticketsUrl) throw new Error('Support ticket list link is not configured.');
|
|
254
|
-
return withToken(config.ticketsUrl, await createToken(config));
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
export async function buildTicketsApiUrl(config, params = {}) {
|
|
258
|
-
if (!config.apiBase) throw new Error('Support ticket API is not configured.');
|
|
259
|
-
|
|
260
|
-
const query = new URLSearchParams({ d: await createToken(config) });
|
|
261
|
-
Object.entries(params).forEach(([key, value]) => {
|
|
262
|
-
if (value !== undefined && value !== null && value !== '') query.set(key, value);
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
return `${config.apiBase}/api/method/get_tickets_based_on_token?${query.toString()}`;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// ==============================|| TICKET API ||============================== //
|
|
269
|
-
|
|
270
|
-
export class TicketApiError extends Error {
|
|
271
|
-
constructor(code, message) {
|
|
272
|
-
super(message);
|
|
273
|
-
this.code = code;
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
async function getJson(url, signal) {
|
|
278
|
-
const controller = new AbortController();
|
|
279
|
-
let timedOut = false;
|
|
280
|
-
|
|
281
|
-
const timer = setTimeout(() => {
|
|
282
|
-
timedOut = true;
|
|
283
|
-
controller.abort();
|
|
284
|
-
}, REQUEST_TIMEOUT_MS);
|
|
285
|
-
const onAbort = () => controller.abort();
|
|
286
|
-
if (signal) signal.addEventListener('abort', onAbort);
|
|
287
|
-
|
|
39
|
+
export async function getSupportUrl(config, mode) {
|
|
40
|
+
let result;
|
|
288
41
|
try {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
const body = await response.json();
|
|
295
|
-
// Frappe wraps whitelisted method responses in `message`.
|
|
296
|
-
return body?.message ?? body;
|
|
42
|
+
result = await ApiUtils.post({
|
|
43
|
+
url: GENERATE_TOKEN_URL,
|
|
44
|
+
formBody: { mode },
|
|
45
|
+
hideError: true,
|
|
46
|
+
});
|
|
297
47
|
} catch (err) {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
|
|
302
|
-
throw new TicketApiError('offline', 'You appear to be offline. Check your connection and try again.');
|
|
303
|
-
}
|
|
304
|
-
if (err instanceof TypeError) throw new TicketApiError('blocked', 'The browser blocked the request to the Helpdesk.');
|
|
305
|
-
throw new TicketApiError('unknown', 'We got an unexpected answer from the Helpdesk.');
|
|
306
|
-
} finally {
|
|
307
|
-
clearTimeout(timer);
|
|
308
|
-
if (signal) signal.removeEventListener('abort', onAbort);
|
|
48
|
+
// ApiCall throws the server JSON for 400/404/500, or a network error. Never log the link.
|
|
49
|
+
console.error('Support: generate-token failed', err?.message || err);
|
|
50
|
+
throw new Error(err?.message || 'We could not reach the server. Please try again.');
|
|
309
51
|
}
|
|
310
|
-
}
|
|
311
52
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
* @returns {Promise<{tickets: object[], total: number, statuses: string[], priorities: string[]}>}
|
|
318
|
-
* @throws {TicketApiError|Error}
|
|
319
|
-
*/
|
|
320
|
-
export async function fetchTickets(config, params, signal) {
|
|
321
|
-
let result;
|
|
322
|
-
|
|
323
|
-
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
324
|
-
result = await getJson(await buildTicketsApiUrl(config, params), signal);
|
|
325
|
-
if (result?.success || result?.error !== 'expired_token') break;
|
|
53
|
+
const data = result?.data;
|
|
54
|
+
const url = [data?.secureUrl, result?.secureUrl].find((value) => typeof value === 'string' && /^https:\/\//i.test(value));
|
|
55
|
+
if (!url) {
|
|
56
|
+
console.error('Support: generate-token returned no https url. Response keys:', Object.keys(result || {}), Object.keys(data || {}));
|
|
57
|
+
throw new Error(result?.message || 'Support is not available right now. Please contact your administrator.');
|
|
326
58
|
}
|
|
327
|
-
|
|
328
|
-
if (!result?.success) {
|
|
329
|
-
throw new TicketApiError('http', API_ERROR_MESSAGES[result?.error] || API_ERROR_MESSAGES.invalid_token);
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
return {
|
|
333
|
-
tickets: result.tickets || [],
|
|
334
|
-
total: result.total_count || 0,
|
|
335
|
-
statuses: result.statuses || [],
|
|
336
|
-
priorities: result.priorities || [],
|
|
337
|
-
};
|
|
59
|
+
return url;
|
|
338
60
|
}
|
|
339
61
|
|
|
340
|
-
// ==============================|| NEW TAB ||============================== //
|
|
341
|
-
|
|
342
62
|
/**
|
|
343
63
|
* Opens a link in a new tab. The tab is opened first, so popup blockers allow it, and then
|
|
344
64
|
* pointed at the link once it is ready.
|
|
345
65
|
*
|
|
346
|
-
* @param {Function} buildUrl async function that returns the full link (
|
|
66
|
+
* @param {Function} buildUrl async function that returns the full link (fresh token)
|
|
347
67
|
* @throws {Error} with a user-safe message when the link cannot be made or the tab is blocked
|
|
348
68
|
*/
|
|
349
69
|
export async function openInNewTab(buildUrl) {
|