@harshankur/viewcounter 3.1.0 → 3.3.0
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/.env.example +37 -11
- package/README.md +330 -138
- package/admin/css/admin.css +891 -198
- package/admin/index.html +13 -7
- package/admin/js/api.js +52 -6
- package/admin/js/appTabs.js +100 -0
- package/admin/js/charts.js +529 -189
- package/admin/js/constants.js +98 -9
- package/admin/js/dataTable.js +478 -0
- package/admin/js/format.js +58 -7
- package/admin/js/icons.js +168 -0
- package/admin/js/listbox.js +2 -1
- package/admin/js/logs.js +211 -60
- package/admin/js/main.js +201 -37
- package/admin/js/overview.js +905 -0
- package/admin/js/passwordPrompt.js +75 -0
- package/admin/js/table.js +12 -52
- package/admin/js/viewDialogs.js +30 -14
- package/admin/js/views.js +273 -207
- package/admin/locales/en.json +352 -63
- package/config/index.js +40 -5
- package/constants.js +126 -8
- package/db/AdminRepository.js +85 -159
- package/db/DatabaseManager.js +57 -7
- package/db/LogRepository.js +172 -35
- package/db/adminSchema.js +93 -4
- package/db/adminSessionStore.js +104 -0
- package/db/analysis.js +484 -0
- package/db/rejectionCounter.js +117 -0
- package/index.js +49 -26
- package/middleware/adminAuth.js +83 -43
- package/middleware/adminValidation.js +69 -3
- package/middleware/auth.js +2 -2
- package/middleware/security.js +26 -2
- package/middleware/validation.js +50 -2
- package/package.json +5 -2
- package/routes/admin.js +130 -22
- package/routes/analytics.js +236 -18
- package/tracker/tracker.js +240 -0
- package/utils/appIdUtils.js +1 -1
- package/utils/durationUtils.js +33 -0
- package/utils/errorUtils.js +4 -1
- package/utils/geoCity.js +87 -0
- package/utils/ipUtils.js +1 -1
- package/utils/privacyUtils.js +2 -2
- package/utils/referrerParser.js +23 -5
- package/utils/secretStore.js +1 -1
- package/utils/userAgentParser.js +52 -3
- package/utils/visitorContext.js +70 -0
- package/admin/js/insights.js +0 -192
package/admin/js/main.js
CHANGED
|
@@ -1,33 +1,59 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Admin UI entry point: session check, login,
|
|
2
|
+
* Admin UI entry point: session check, login, sections, and wiring.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { api, ApiError, onUnauthenticated, setCsrfToken } from './api.js';
|
|
5
|
+
import { api, ApiError, onUnauthenticated, setCsrfToken, setRecovery } from './api.js';
|
|
6
|
+
import { promptPassword } from './passwordPrompt.js';
|
|
6
7
|
import { byId, el, replaceChildren } from './dom.js';
|
|
7
8
|
import { applyTranslations, loadLocale, t } from './i18n.js';
|
|
8
|
-
import {
|
|
9
|
+
import { icon } from './icons.js';
|
|
10
|
+
import { createAdminLogPanel, createTrackingLogPanel } from './logs.js';
|
|
9
11
|
import { closeModal } from './modal.js';
|
|
12
|
+
import { createOverviewPanel } from './overview.js';
|
|
10
13
|
import { initTheme } from './theme.js';
|
|
11
14
|
import { showToast, TOAST_TYPE } from './toast.js';
|
|
12
15
|
import { createViewsPanel, PANEL_MODE } from './views.js';
|
|
13
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
ALL_APPS, APP_NAME, COPYRIGHT, ERROR_CODE, KEY, LEGACY_TAB_HASH, LINKS, SESSION_PING_INTERVAL_MS, STORAGE_KEY, TAB, TAB_HASH, THEME,
|
|
18
|
+
} from './constants.js';
|
|
14
19
|
|
|
15
|
-
const TAB_ORDER = [TAB.VIEWS, TAB.TRASH, TAB.
|
|
20
|
+
const TAB_ORDER = [TAB.OVERVIEW, TAB.VIEWS, TAB.TRASH, TAB.TRACKING_LOG, TAB.ADMIN_LOG];
|
|
21
|
+
const TAB_ICON = {
|
|
22
|
+
[TAB.OVERVIEW]: 'overview',
|
|
23
|
+
[TAB.VIEWS]: 'views',
|
|
24
|
+
[TAB.TRASH]: 'trash',
|
|
25
|
+
[TAB.TRACKING_LOG]: 'trackingLog',
|
|
26
|
+
[TAB.ADMIN_LOG]: 'adminLog',
|
|
27
|
+
};
|
|
28
|
+
/** Sections that show one app (or all), and follow the app chosen in any of them. */
|
|
29
|
+
const APP_TABS = [TAB.OVERVIEW, TAB.VIEWS, TAB.TRASH];
|
|
16
30
|
|
|
17
31
|
const shell = {
|
|
18
32
|
meta: null,
|
|
19
33
|
apps: [],
|
|
20
34
|
appId: null,
|
|
21
35
|
panels: new Map(),
|
|
22
|
-
activeTab:
|
|
36
|
+
activeTab: null,
|
|
23
37
|
tabButtons: new Map(),
|
|
24
38
|
};
|
|
25
39
|
|
|
40
|
+
/** Hand an exception to the browser's own error reporting (the console, and error listeners). */
|
|
41
|
+
function reportToBrowser(error) {
|
|
42
|
+
window.reportError?.(error);
|
|
43
|
+
}
|
|
44
|
+
|
|
26
45
|
/** Translate any failure into a message and show it. */
|
|
27
46
|
function reportError(error) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
47
|
+
if (!(error instanceof ApiError)) {
|
|
48
|
+
// A bug in this page, not an answer from the server: say so, and
|
|
49
|
+
// report the exception to the browser as if uncaught, where a
|
|
50
|
+
// developer finds it with its stack.
|
|
51
|
+
reportToBrowser(error);
|
|
52
|
+
showToast(t(`errors.${ERROR_CODE.UNEXPECTED}`), TOAST_TYPE.ERROR);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (error.code === ERROR_CODE.UNAUTHENTICATED) return;
|
|
56
|
+
showToast(t(`errors.${error.code}`), TOAST_TYPE.ERROR);
|
|
31
57
|
}
|
|
32
58
|
|
|
33
59
|
function readLastApp() {
|
|
@@ -50,8 +76,10 @@ function rememberApp(appId) {
|
|
|
50
76
|
|
|
51
77
|
function showLogin(messageKey) {
|
|
52
78
|
closeModal();
|
|
79
|
+
shell.panels.get(shell.activeTab)?.hide?.();
|
|
53
80
|
byId('app-screen').hidden = true;
|
|
54
81
|
byId('logout-btn').hidden = true;
|
|
82
|
+
byId('app-footer').hidden = true;
|
|
55
83
|
byId('login-screen').hidden = false;
|
|
56
84
|
const error = byId('login-error');
|
|
57
85
|
error.hidden = !messageKey;
|
|
@@ -61,6 +89,10 @@ function showLogin(messageKey) {
|
|
|
61
89
|
input.focus();
|
|
62
90
|
}
|
|
63
91
|
|
|
92
|
+
function errorKey(error) {
|
|
93
|
+
return `errors.${error instanceof ApiError ? error.code : ERROR_CODE.UNEXPECTED}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
64
96
|
function wireLogin() {
|
|
65
97
|
const form = byId('login-form');
|
|
66
98
|
const submit = byId('login-submit');
|
|
@@ -74,50 +106,141 @@ function wireLogin() {
|
|
|
74
106
|
setCsrfToken(session.csrfToken);
|
|
75
107
|
await enterApp();
|
|
76
108
|
} catch (error) {
|
|
77
|
-
|
|
78
|
-
showLogin(
|
|
109
|
+
if (!(error instanceof ApiError)) reportToBrowser(error);
|
|
110
|
+
showLogin(errorKey(error));
|
|
79
111
|
} finally {
|
|
80
112
|
submit.disabled = false;
|
|
81
113
|
}
|
|
82
114
|
});
|
|
83
115
|
}
|
|
84
116
|
|
|
117
|
+
// ---- Staying signed in ------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
/** Sign in again over whatever the admin was doing, then carry on. */
|
|
120
|
+
function reauthenticate() {
|
|
121
|
+
return promptPassword({
|
|
122
|
+
title: t('session.endedTitle'),
|
|
123
|
+
message: t('session.endedMessage'),
|
|
124
|
+
submitLabel: t('login.submit'),
|
|
125
|
+
submit: async (password) => {
|
|
126
|
+
const session = await api.login(password);
|
|
127
|
+
setCsrfToken(session.csrfToken);
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The password again, before something that cannot be undone. */
|
|
133
|
+
function confirmPassword() {
|
|
134
|
+
return promptPassword({
|
|
135
|
+
title: t('session.confirmTitle'),
|
|
136
|
+
message: t('session.confirmMessage'),
|
|
137
|
+
submitLabel: t('session.confirmAction'),
|
|
138
|
+
submit: (password) => api.reauth(password),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Reading counts as using the UI: clicks, keys, and scrolling extend the
|
|
144
|
+
* session even when they cause no request, at most once per interval.
|
|
145
|
+
*/
|
|
146
|
+
function keepSessionAlive() {
|
|
147
|
+
let lastPing = Date.now();
|
|
148
|
+
const ping = () => {
|
|
149
|
+
if (document.visibilityState !== 'visible' || Date.now() - lastPing < SESSION_PING_INTERVAL_MS) return;
|
|
150
|
+
lastPing = Date.now();
|
|
151
|
+
api.session().catch(() => {});
|
|
152
|
+
};
|
|
153
|
+
for (const type of ['pointerdown', 'keydown', 'wheel', 'scroll', 'visibilitychange']) {
|
|
154
|
+
document.addEventListener(type, ping, { passive: true, capture: true });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---- Header and footer ------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
/** An outbound link: new tab, no referrer, no opener. */
|
|
161
|
+
function outboundLink(href, label, iconName) {
|
|
162
|
+
return el('a', {
|
|
163
|
+
className: 'footer-link',
|
|
164
|
+
attrs: { href, target: '_blank', rel: 'noopener noreferrer' },
|
|
165
|
+
}, [iconName ? icon(iconName) : null, el('span', { text: label })]);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function renderChrome() {
|
|
169
|
+
const version = byId('app-version');
|
|
170
|
+
version.textContent = t('app.version', { version: shell.meta.version });
|
|
171
|
+
version.hidden = false;
|
|
172
|
+
|
|
173
|
+
const credits = (shell.meta.attributions ?? []).map((credit) => (credit.url
|
|
174
|
+
? el('a', { text: credit.text, attrs: { href: credit.url, target: '_blank', rel: 'noopener noreferrer' } })
|
|
175
|
+
: el('span', { text: credit.text })));
|
|
176
|
+
const external = (text, href) => el('a', { text, attrs: { href, target: '_blank', rel: 'noopener noreferrer' } });
|
|
177
|
+
// "ViewCounter © 2026 Harsh Ankur", with the name a link: built around
|
|
178
|
+
// the name so a translation can put the parts in its own order.
|
|
179
|
+
const [beforeHolder, afterHolder] = t('footer.copyright', { product: APP_NAME, year: COPYRIGHT.YEAR, holder: '\u0000' })
|
|
180
|
+
.split('\u0000');
|
|
181
|
+
replaceChildren(byId('app-footer'), [
|
|
182
|
+
el('div', { className: 'footer-row' }, [
|
|
183
|
+
el('p', { className: 'footer-copyright' }, [
|
|
184
|
+
beforeHolder, external(COPYRIGHT.HOLDER, COPYRIGHT.HOLDER_URL), afterHolder ?? '',
|
|
185
|
+
]),
|
|
186
|
+
el('nav', { className: 'footer-links', attrs: { 'aria-label': t('footer.links') } }, [
|
|
187
|
+
outboundLink(LINKS.WEBSITE, t('footer.website'), 'globe'),
|
|
188
|
+
outboundLink(LINKS.DOCS_ADMIN, t('footer.docs'), 'book'),
|
|
189
|
+
outboundLink(LINKS.CHANGELOG, t('footer.changelog'), 'clock'),
|
|
190
|
+
outboundLink(LINKS.SOURCE, t('footer.source'), 'code'),
|
|
191
|
+
outboundLink(LINKS.NPM, t('footer.npm'), 'package'),
|
|
192
|
+
]),
|
|
193
|
+
]),
|
|
194
|
+
el('div', { className: 'footer-row footer-legal' }, [
|
|
195
|
+
el('p', { className: 'footer-version', text: t('footer.product', { version: shell.meta.version }) }),
|
|
196
|
+
credits.length ? el('p', { className: 'footer-credits' }, [
|
|
197
|
+
el('span', { text: t('footer.credits') }),
|
|
198
|
+
...credits.flatMap((credit, index) => [index ? ' · ' : ' ', credit]),
|
|
199
|
+
]) : null,
|
|
200
|
+
]),
|
|
201
|
+
]);
|
|
202
|
+
byId('app-footer').hidden = false;
|
|
203
|
+
}
|
|
204
|
+
|
|
85
205
|
// ---- App shell --------------------------------------------------------------
|
|
86
206
|
|
|
87
207
|
async function refreshApps() {
|
|
88
208
|
const { apps } = await api.apps();
|
|
89
209
|
shell.apps = apps;
|
|
90
|
-
for (const
|
|
210
|
+
for (const tab of APP_TABS) shell.panels.get(tab)?.syncApps();
|
|
91
211
|
}
|
|
92
212
|
|
|
93
213
|
function setAppId(appId) {
|
|
94
214
|
shell.appId = appId;
|
|
95
215
|
rememberApp(appId);
|
|
96
|
-
for (const tab of
|
|
216
|
+
for (const tab of APP_TABS) {
|
|
97
217
|
const panel = shell.panels.get(tab);
|
|
98
218
|
panel.syncApps();
|
|
99
|
-
panel.
|
|
219
|
+
panel.appChanged();
|
|
100
220
|
}
|
|
101
221
|
}
|
|
102
222
|
|
|
223
|
+
/** The section named by the address, including the names earlier versions used. */
|
|
224
|
+
function tabFromHash(hash) {
|
|
225
|
+
const name = hash.replace(/^#/, '');
|
|
226
|
+
return TAB_ORDER.find((tab) => TAB_HASH[tab] === name) ?? LEGACY_TAB_HASH[name] ?? TAB.OVERVIEW;
|
|
227
|
+
}
|
|
228
|
+
|
|
103
229
|
function selectTab(tab, { focus = false } = {}) {
|
|
104
|
-
|
|
230
|
+
const next = TAB_ORDER.includes(tab) ? tab : TAB.OVERVIEW;
|
|
231
|
+
if (shell.activeTab && shell.activeTab !== next) shell.panels.get(shell.activeTab)?.hide();
|
|
232
|
+
shell.activeTab = next;
|
|
105
233
|
for (const [key, button] of shell.tabButtons) {
|
|
106
|
-
const active = key ===
|
|
234
|
+
const active = key === next;
|
|
107
235
|
button.setAttribute('aria-selected', String(active));
|
|
108
236
|
button.tabIndex = active ? 0 : -1;
|
|
109
237
|
button.classList.toggle('active', active);
|
|
110
238
|
if (active && focus) button.focus();
|
|
111
239
|
}
|
|
112
|
-
for (const [key, panel] of shell.panels) panel.element.hidden = key !==
|
|
240
|
+
for (const [key, panel] of shell.panels) panel.element.hidden = key !== next;
|
|
241
|
+
shell.panels.get(next).show();
|
|
113
242
|
|
|
114
|
-
|
|
115
|
-
if (shell.activeTab === TAB.ADMIN_LOG || shell.activeTab === TAB.VIEW_LOG) panel.load();
|
|
116
|
-
else panel.refresh();
|
|
117
|
-
|
|
118
|
-
if (window.location.hash !== `#${shell.activeTab}`) {
|
|
119
|
-
history.replaceState(null, '', `#${shell.activeTab}`);
|
|
120
|
-
}
|
|
243
|
+
if (window.location.hash !== `#${TAB_HASH[next]}`) history.replaceState(null, '', `#${TAB_HASH[next]}`);
|
|
121
244
|
}
|
|
122
245
|
|
|
123
246
|
function buildTabs() {
|
|
@@ -127,21 +250,27 @@ function buildTabs() {
|
|
|
127
250
|
className: 'tab',
|
|
128
251
|
attrs: { type: 'button', role: 'tab', id: `tab-${tab}`, 'aria-controls': `panel-${tab}` },
|
|
129
252
|
on: { click: () => selectTab(tab) },
|
|
130
|
-
}, [
|
|
131
|
-
el('span', { className: 'prompt-char', text: '❯', attrs: { 'aria-hidden': 'true' } }),
|
|
132
|
-
el('span', { text: t(`tabs.${tab}`) }),
|
|
133
|
-
]);
|
|
253
|
+
}, [icon(TAB_ICON[tab]), el('span', { text: t(`tabs.${tab}`) })]);
|
|
134
254
|
shell.tabButtons.set(tab, button);
|
|
135
255
|
return button;
|
|
136
256
|
});
|
|
257
|
+
replaceChildren(tablist, buttons);
|
|
258
|
+
}
|
|
137
259
|
|
|
138
|
-
|
|
139
|
-
|
|
260
|
+
/** Arrow keys, Home, and End move between sections (ARIA tabs pattern). Wired once. */
|
|
261
|
+
function wireTabKeys() {
|
|
262
|
+
byId('tabs').addEventListener('keydown', (event) => {
|
|
140
263
|
const index = TAB_ORDER.indexOf(shell.activeTab);
|
|
141
|
-
|
|
142
|
-
|
|
264
|
+
const moves = {
|
|
265
|
+
[KEY.ARROW_RIGHT]: (index + 1) % TAB_ORDER.length,
|
|
266
|
+
[KEY.ARROW_LEFT]: (index - 1 + TAB_ORDER.length) % TAB_ORDER.length,
|
|
267
|
+
[KEY.HOME]: 0,
|
|
268
|
+
[KEY.END]: TAB_ORDER.length - 1,
|
|
269
|
+
};
|
|
270
|
+
if (!(event.key in moves)) return;
|
|
271
|
+
event.preventDefault();
|
|
272
|
+
selectTab(TAB_ORDER[moves[event.key]], { focus: true });
|
|
143
273
|
});
|
|
144
|
-
replaceChildren(tablist, buttons);
|
|
145
274
|
}
|
|
146
275
|
|
|
147
276
|
function buildPanels() {
|
|
@@ -151,13 +280,35 @@ function buildPanels() {
|
|
|
151
280
|
setAppId,
|
|
152
281
|
refreshApps,
|
|
153
282
|
reportError,
|
|
283
|
+
/** Views were edited, trashed, restored, or erased: every count is out of date. */
|
|
284
|
+
viewsChanged() {
|
|
285
|
+
for (const tab of APP_TABS) {
|
|
286
|
+
if (tab !== shell.activeTab) shell.panels.get(tab).invalidate();
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
/** Open the Views table on exactly the rows an Overview describes. */
|
|
290
|
+
openViews(filters) {
|
|
291
|
+
shell.panels.get(TAB.VIEWS).applyFilters(filters);
|
|
292
|
+
selectTab(TAB.VIEWS);
|
|
293
|
+
},
|
|
154
294
|
};
|
|
155
295
|
const appIds = () => shell.apps.map((app) => app.appId);
|
|
156
296
|
|
|
297
|
+
shell.panels.set(TAB.OVERVIEW, createOverviewPanel({ meta: shell.meta, context }));
|
|
157
298
|
shell.panels.set(TAB.VIEWS, createViewsPanel({ mode: PANEL_MODE.VIEWS, meta: shell.meta, context }));
|
|
158
299
|
shell.panels.set(TAB.TRASH, createViewsPanel({ mode: PANEL_MODE.TRASH, meta: shell.meta, context }));
|
|
300
|
+
shell.panels.set(TAB.TRACKING_LOG, createTrackingLogPanel({
|
|
301
|
+
meta: shell.meta,
|
|
302
|
+
appIds,
|
|
303
|
+
reportError,
|
|
304
|
+
// A logged view, found in the table whether or not it is in the trash.
|
|
305
|
+
openView: (appId, viewId) => {
|
|
306
|
+
if (shell.appId !== appId && shell.apps.some((app) => app.appId === appId)) setAppId(appId);
|
|
307
|
+
shell.panels.get(TAB.VIEWS).findView(viewId);
|
|
308
|
+
selectTab(TAB.VIEWS);
|
|
309
|
+
},
|
|
310
|
+
}));
|
|
159
311
|
shell.panels.set(TAB.ADMIN_LOG, createAdminLogPanel({ meta: shell.meta, appIds, reportError }));
|
|
160
|
-
shell.panels.set(TAB.VIEW_LOG, createViewLogPanel({ meta: shell.meta, appIds, reportError }));
|
|
161
312
|
|
|
162
313
|
const container = byId('panels');
|
|
163
314
|
replaceChildren(container, TAB_ORDER.map((tab) => {
|
|
@@ -165,6 +316,7 @@ function buildPanels() {
|
|
|
165
316
|
panel.element.id = `panel-${tab}`;
|
|
166
317
|
panel.element.setAttribute('role', 'tabpanel');
|
|
167
318
|
panel.element.setAttribute('aria-labelledby', `tab-${tab}`);
|
|
319
|
+
panel.element.hidden = true;
|
|
168
320
|
return panel.element;
|
|
169
321
|
}));
|
|
170
322
|
}
|
|
@@ -177,15 +329,19 @@ async function enterApp() {
|
|
|
177
329
|
const remembered = readLastApp();
|
|
178
330
|
shell.appId = apps.some((app) => app.appId === remembered) ? remembered : ALL_APPS;
|
|
179
331
|
|
|
332
|
+
shell.activeTab = null;
|
|
333
|
+
shell.panels.clear();
|
|
334
|
+
shell.tabButtons.clear();
|
|
180
335
|
buildTabs();
|
|
181
336
|
buildPanels();
|
|
182
|
-
for (const tab of
|
|
337
|
+
for (const tab of APP_TABS) shell.panels.get(tab).syncApps();
|
|
338
|
+
renderChrome();
|
|
183
339
|
|
|
184
340
|
byId('login-screen').hidden = true;
|
|
185
341
|
byId('app-screen').hidden = false;
|
|
186
342
|
byId('logout-btn').hidden = false;
|
|
187
343
|
|
|
188
|
-
selectTab(window.location.hash
|
|
344
|
+
selectTab(tabFromHash(window.location.hash));
|
|
189
345
|
}
|
|
190
346
|
|
|
191
347
|
async function logout() {
|
|
@@ -205,17 +361,24 @@ async function boot() {
|
|
|
205
361
|
applyTranslations();
|
|
206
362
|
document.title = t('app.documentTitle');
|
|
207
363
|
|
|
364
|
+
for (const node of document.querySelectorAll('[data-icon]')) node.prepend(icon(node.dataset.icon));
|
|
208
365
|
initTheme({
|
|
209
366
|
[THEME.LIGHT]: byId('theme-light'),
|
|
210
367
|
[THEME.DARK]: byId('theme-dark'),
|
|
211
368
|
[THEME.AUTO]: byId('theme-auto'),
|
|
212
369
|
});
|
|
213
370
|
wireLogin();
|
|
371
|
+
wireTabKeys();
|
|
214
372
|
byId('logout-btn').addEventListener('click', logout);
|
|
373
|
+
setRecovery({ reauthenticate, confirmPassword });
|
|
374
|
+
keepSessionAlive();
|
|
215
375
|
onUnauthenticated(() => {
|
|
216
376
|
setCsrfToken(null);
|
|
217
377
|
showLogin('errors.UNAUTHENTICATED');
|
|
218
378
|
});
|
|
379
|
+
window.addEventListener('hashchange', () => {
|
|
380
|
+
if (!byId('app-screen').hidden) selectTab(tabFromHash(window.location.hash));
|
|
381
|
+
});
|
|
219
382
|
|
|
220
383
|
try {
|
|
221
384
|
const session = await api.session();
|
|
@@ -226,7 +389,8 @@ async function boot() {
|
|
|
226
389
|
showLogin();
|
|
227
390
|
}
|
|
228
391
|
} catch (error) {
|
|
229
|
-
|
|
392
|
+
if (!(error instanceof ApiError)) reportToBrowser(error);
|
|
393
|
+
showLogin(errorKey(error));
|
|
230
394
|
} finally {
|
|
231
395
|
document.body.classList.remove('booting');
|
|
232
396
|
}
|