ui-soxo-bootstrap-core 2.6.62-dev.1 → 2.6.62-dev.3

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.
@@ -0,0 +1,143 @@
1
+ .support-desk {
2
+ .ant-drawer-body {
3
+ padding: 0;
4
+ overflow: hidden;
5
+ }
6
+
7
+ &__title {
8
+ font-weight: 600;
9
+ }
10
+
11
+ &__tabs {
12
+ height: 100%;
13
+
14
+ .ant-tabs-nav {
15
+ margin: 0;
16
+ padding: 0 16px;
17
+ }
18
+
19
+ .ant-tabs-content-holder {
20
+ flex: 1;
21
+ min-height: 0;
22
+ }
23
+
24
+ .ant-tabs-content,
25
+ .ant-tabs-tabpane {
26
+ height: 100%;
27
+ }
28
+ }
29
+ }
30
+
31
+ .support-frame {
32
+ width: 100%;
33
+ height: 100%;
34
+ border: none;
35
+ display: block;
36
+ }
37
+
38
+ // ---- Raise a ticket ----
39
+ .support-raise {
40
+ display: flex;
41
+ flex-direction: column;
42
+ height: 100%;
43
+
44
+ &__bar {
45
+ display: flex;
46
+ align-items: center;
47
+ justify-content: space-between;
48
+ gap: 12px;
49
+ padding: 8px 16px;
50
+ font-size: 13px;
51
+ background: rgba(0, 0, 0, 0.02);
52
+ border-bottom: 1px solid rgba(0, 0, 0, 0.06);
53
+ }
54
+
55
+ &__frame {
56
+ position: relative;
57
+ flex: 1;
58
+ min-height: 0;
59
+ }
60
+
61
+ &__loading {
62
+ position: absolute;
63
+ inset: 0;
64
+ display: flex;
65
+ align-items: center;
66
+ justify-content: center;
67
+ background: #fff;
68
+ z-index: 1;
69
+ }
70
+ }
71
+
72
+ // ---- My tickets ----
73
+ .support-tickets {
74
+ display: flex;
75
+ flex-direction: column;
76
+ height: 100%;
77
+
78
+ &__body {
79
+ flex: 1;
80
+ min-height: 0;
81
+ overflow: auto;
82
+ padding: 0 16px 16px;
83
+ }
84
+ }
85
+
86
+ .support-toolbar {
87
+ display: flex;
88
+ justify-content: flex-end;
89
+ padding: 4px 16px 0;
90
+ }
91
+
92
+ .support-tickets__frame {
93
+ flex: 1;
94
+ min-height: 0;
95
+ }
96
+
97
+ .support-filters {
98
+ display: flex;
99
+ flex-wrap: wrap;
100
+ align-items: center;
101
+ gap: 8px;
102
+ padding: 12px 16px;
103
+
104
+ &__search {
105
+ width: 260px;
106
+ max-width: 100%;
107
+ }
108
+
109
+ &__select {
110
+ width: 140px;
111
+ }
112
+
113
+ &__dates {
114
+ max-width: 100%;
115
+ }
116
+
117
+ &__actions {
118
+ margin-left: auto;
119
+ display: flex;
120
+ gap: 4px;
121
+ }
122
+ }
123
+
124
+ .support-subject {
125
+ min-width: 200px;
126
+
127
+ &__title {
128
+ font-weight: 500;
129
+ overflow: hidden;
130
+ text-overflow: ellipsis;
131
+ white-space: nowrap;
132
+ max-width: 360px;
133
+ }
134
+
135
+ &__text {
136
+ color: rgba(0, 0, 0, 0.45);
137
+ font-size: 12px;
138
+ overflow: hidden;
139
+ text-overflow: ellipsis;
140
+ white-space: nowrap;
141
+ max-width: 360px;
142
+ }
143
+ }
@@ -0,0 +1,363 @@
1
+ /**
2
+ * Support desk logic: constants, setting/config, encrypted token and the Helpdesk ticket API.
3
+ *
4
+ * Settings come from `HELPATR`:
5
+ * helpDeskLink raise ticket form URL (required)
6
+ * projectToken project code known by the Helpdesk (required)
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
11
+ *
12
+ * A token is made for every use (open, reload, each API call) and never cached, so a short
13
+ * lifetime cannot expire before the Helpdesk reads it. CLOCK_SKEW_SECONDS is added on both
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.
18
+ */
19
+
20
+ // ==============================|| CONSTANTS ||============================== //
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".
35
+ export const FORM_SLOW_MS = 8000;
36
+
37
+ // Messages the Helpdesk API returns in `error`, mapped to text the user can act on.
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
+ };
58
+
59
+ export const PRIORITY_COLORS = {
60
+ urgent: 'red',
61
+ critical: 'red',
62
+ high: 'volcano',
63
+ medium: 'gold',
64
+ low: 'green',
65
+ };
66
+
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
+ /**
110
+ * @param {object} helpDeskSetting value of settings.HELPATR
111
+ * @param {object} user logged in user
112
+ * @returns {object} config. `enabled` is false when a required setting is missing, so the
113
+ * caller should not show the Help button.
114
+ */
115
+ export function resolveSupportConfig(helpDeskSetting = {}, user = {}) {
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
+
123
+ return {
124
+ enabled: Boolean(helpDeskLink && projectToken && encryptedToken),
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,
132
+ };
133
+ }
134
+
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
+ /**
208
+ * Mints a fresh token. Always call right before use.
209
+ * @throws {Error} with a user-safe message
210
+ */
211
+ export async function createToken(config) {
212
+ if (!config.projectToken || !config.secret) {
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
+
288
+ try {
289
+ const response = await fetch(url, { credentials: 'omit', signal: controller.signal });
290
+ if (!response.ok) {
291
+ throw new TicketApiError('http', `The Helpdesk returned an error (HTTP ${response.status}).`);
292
+ }
293
+
294
+ const body = await response.json();
295
+ // Frappe wraps whitelisted method responses in `message`.
296
+ return body?.message ?? body;
297
+ } catch (err) {
298
+ if (err instanceof TicketApiError) throw err;
299
+ if (timedOut) throw new TicketApiError('timeout', 'The Helpdesk took too long to respond.');
300
+ if (err?.name === 'AbortError') throw new TicketApiError('aborted', '');
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);
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Fetches one page of the user's tickets with a fresh token.
314
+ * If the server says the token expired (slow network, short lifetime), a new token is made
315
+ * and the call is repeated once.
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;
326
+ }
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
+ };
338
+ }
339
+
340
+ // ==============================|| NEW TAB ||============================== //
341
+
342
+ /**
343
+ * Opens a link in a new tab. The tab is opened first, so popup blockers allow it, and then
344
+ * pointed at the link once it is ready.
345
+ *
346
+ * @param {Function} buildUrl async function that returns the full link (makes a fresh token)
347
+ * @throws {Error} with a user-safe message when the link cannot be made or the tab is blocked
348
+ */
349
+ export async function openInNewTab(buildUrl) {
350
+ const popup = window.open('', '_blank');
351
+
352
+ try {
353
+ const url = await buildUrl();
354
+ if (!popup) {
355
+ throw new Error('Your browser blocked the new tab. Allow pop-ups for this site and try again.');
356
+ }
357
+ popup.opener = null;
358
+ popup.location.href = url;
359
+ } catch (err) {
360
+ if (popup) popup.close();
361
+ throw err;
362
+ }
363
+ }