ui-soxo-bootstrap-core 2.6.62-dev.4 → 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.
@@ -69,75 +69,3 @@
69
69
  }
70
70
  }
71
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
- }
@@ -1,349 +1,69 @@
1
1
  /**
2
- * Support desk logic: constants, setting/config, encrypted token and the Helpdesk ticket API.
2
+ * Support desk logic.
3
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
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 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.
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
- // ==============================|| 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".
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
- // 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
- };
16
+ export const GENERATE_TOKEN_URL = 'secure-link/generate-token';
58
17
 
59
- export const PRIORITY_COLORS = {
60
- urgent: 'red',
61
- critical: 'red',
62
- high: 'volcano',
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
- * @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.
26
+ * @returns {object} config. `enabled` is false when the Help button should not be shown.
114
27
  */
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
-
28
+ export function resolveSupportConfig(helpDeskSetting = {}) {
123
29
  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,
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
- * Mints a fresh token. Always call right before use.
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 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
-
39
+ export async function getSupportUrl(config, mode) {
40
+ let result;
288
41
  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;
42
+ result = await ApiUtils.post({
43
+ url: GENERATE_TOKEN_URL,
44
+ formBody: { mode },
45
+ hideError: true,
46
+ });
297
47
  } 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);
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
- * 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;
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 (makes a fresh token)
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) {