@harshankur/viewcounter 3.0.1 → 3.1.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.
Files changed (44) hide show
  1. package/.env.example +20 -0
  2. package/README.md +174 -4
  3. package/admin/apple-touch-icon.png +0 -0
  4. package/admin/assets/world-map.json +1 -0
  5. package/admin/css/admin.css +1875 -0
  6. package/admin/favicon.ico +0 -0
  7. package/admin/favicon.svg +9 -0
  8. package/admin/icon-192.png +0 -0
  9. package/admin/icon-512.png +0 -0
  10. package/admin/index.html +89 -0
  11. package/admin/js/api.js +100 -0
  12. package/admin/js/charts.js +502 -0
  13. package/admin/js/clamp.js +41 -0
  14. package/admin/js/constants.js +150 -0
  15. package/admin/js/dom.js +83 -0
  16. package/admin/js/format.js +79 -0
  17. package/admin/js/i18n.js +80 -0
  18. package/admin/js/insights.js +192 -0
  19. package/admin/js/listbox.js +144 -0
  20. package/admin/js/logs.js +167 -0
  21. package/admin/js/main.js +235 -0
  22. package/admin/js/modal.js +171 -0
  23. package/admin/js/table.js +134 -0
  24. package/admin/js/theme.js +72 -0
  25. package/admin/js/toast.js +47 -0
  26. package/admin/js/viewDialogs.js +208 -0
  27. package/admin/js/views.js +685 -0
  28. package/admin/locales/en.json +394 -0
  29. package/admin/site.webmanifest +20 -0
  30. package/config/index.js +83 -0
  31. package/constants.js +215 -2
  32. package/db/AdminRepository.js +562 -0
  33. package/db/DatabaseManager.js +94 -20
  34. package/db/LogRepository.js +217 -0
  35. package/db/adminSchema.js +244 -0
  36. package/db/retention.js +97 -0
  37. package/index.js +39 -6
  38. package/middleware/adminAuth.js +204 -0
  39. package/middleware/adminValidation.js +253 -0
  40. package/package.json +16 -9
  41. package/routes/admin.js +438 -0
  42. package/routes/analytics.js +11 -2
  43. package/utils/cookieUtils.js +47 -0
  44. package/utils/errorUtils.js +35 -0
@@ -0,0 +1,144 @@
1
+ /**
2
+ * A select replacement (FRONTEND.md §0, §7): a button that opens a listbox the
3
+ * page renders itself, so it can be styled and behaves identically on every
4
+ * machine.
5
+ *
6
+ * ARIA listbox pattern: the button has aria-haspopup/aria-expanded; the
7
+ * options carry role=option and aria-selected; the active option is announced
8
+ * through aria-activedescendant. Keyboard: ArrowUp/ArrowDown move, Home/End
9
+ * jump, Enter/Space commit, Escape and Tab close.
10
+ */
11
+
12
+ import { el, clear, uniqueId } from './dom.js';
13
+ import { KEY } from './constants.js';
14
+
15
+ /**
16
+ * @param {{ label: string, options: Array<{value: string, label: string}>, value?: string,
17
+ * onChange?: (value: string) => void, className?: string }} config
18
+ * @returns {{ element: HTMLElement, getValue: () => string, setValue: (value: string) => void,
19
+ * setOptions: (options: Array<{value: string, label: string}>, value?: string) => void }}
20
+ */
21
+ export function createListbox({ label, options, value, onChange = () => {}, className = '' }) {
22
+ const listId = uniqueId('listbox');
23
+ let items = options;
24
+ let selected = value ?? options[0]?.value;
25
+ let activeIndex = -1;
26
+
27
+ const valueText = el('span', { className: 'listbox-value' });
28
+ const button = el('button', {
29
+ className: 'listbox-button',
30
+ attrs: { type: 'button', 'aria-haspopup': 'listbox', 'aria-expanded': 'false', 'aria-label': label },
31
+ }, [valueText, el('span', { className: 'listbox-caret', text: '▾', attrs: { 'aria-hidden': 'true' } })]);
32
+
33
+ const list = el('ul', {
34
+ className: 'listbox-popup',
35
+ attrs: { role: 'listbox', id: listId, tabindex: '-1', 'aria-label': label },
36
+ });
37
+ list.hidden = true;
38
+ button.setAttribute('aria-controls', listId);
39
+
40
+ const wrapper = el('div', { className: `listbox ${className}`.trim() }, [button, list]);
41
+
42
+ const labelOf = (v) => items.find((item) => item.value === v)?.label ?? '';
43
+
44
+ function render() {
45
+ valueText.textContent = labelOf(selected);
46
+ clear(list);
47
+ items.forEach((item, index) => {
48
+ const isSelected = item.value === selected;
49
+ list.append(el('li', {
50
+ className: `listbox-option${index === activeIndex ? ' active' : ''}`,
51
+ text: item.label,
52
+ attrs: { role: 'option', id: `${listId}-${index}`, 'aria-selected': String(isSelected) },
53
+ on: {
54
+ mousedown: (event) => event.preventDefault(),
55
+ click: () => commit(index),
56
+ },
57
+ }));
58
+ });
59
+ if (activeIndex >= 0) list.setAttribute('aria-activedescendant', `${listId}-${activeIndex}`);
60
+ else list.removeAttribute('aria-activedescendant');
61
+ }
62
+
63
+ function isOpen() {
64
+ return !list.hidden;
65
+ }
66
+
67
+ function open() {
68
+ if (isOpen()) return;
69
+ activeIndex = Math.max(0, items.findIndex((item) => item.value === selected));
70
+ list.hidden = false;
71
+ button.setAttribute('aria-expanded', 'true');
72
+ render();
73
+ list.focus();
74
+ list.querySelector('.active')?.scrollIntoView({ block: 'nearest' });
75
+ document.addEventListener('mousedown', onOutside, true);
76
+ }
77
+
78
+ function close({ refocus = true } = {}) {
79
+ if (!isOpen()) return;
80
+ list.hidden = true;
81
+ button.setAttribute('aria-expanded', 'false');
82
+ activeIndex = -1;
83
+ document.removeEventListener('mousedown', onOutside, true);
84
+ if (refocus) button.focus();
85
+ }
86
+
87
+ function commit(index) {
88
+ const item = items[index];
89
+ if (!item) return;
90
+ const changed = item.value !== selected;
91
+ selected = item.value;
92
+ close();
93
+ render();
94
+ if (changed) onChange(selected);
95
+ }
96
+
97
+ function move(index) {
98
+ activeIndex = Math.min(Math.max(index, 0), items.length - 1);
99
+ render();
100
+ list.querySelector('.active')?.scrollIntoView({ block: 'nearest' });
101
+ }
102
+
103
+ function onOutside(event) {
104
+ if (!wrapper.contains(event.target)) close({ refocus: false });
105
+ }
106
+
107
+ button.addEventListener('click', () => (isOpen() ? close() : open()));
108
+ button.addEventListener('keydown', (event) => {
109
+ if (event.key === KEY.ARROW_DOWN || event.key === KEY.ARROW_UP) {
110
+ event.preventDefault();
111
+ open();
112
+ }
113
+ });
114
+
115
+ list.addEventListener('keydown', (event) => {
116
+ switch (event.key) {
117
+ case KEY.ARROW_DOWN: event.preventDefault(); move(activeIndex + 1); break;
118
+ case KEY.ARROW_UP: event.preventDefault(); move(activeIndex - 1); break;
119
+ case KEY.HOME: event.preventDefault(); move(0); break;
120
+ case KEY.END: event.preventDefault(); move(items.length - 1); break;
121
+ case KEY.ENTER:
122
+ case KEY.SPACE: event.preventDefault(); commit(activeIndex); break;
123
+ case KEY.ESCAPE: event.preventDefault(); event.stopPropagation(); close(); break;
124
+ case KEY.TAB: close({ refocus: false }); break;
125
+ default: break;
126
+ }
127
+ });
128
+
129
+ render();
130
+
131
+ return {
132
+ element: wrapper,
133
+ getValue: () => selected,
134
+ setValue(next) {
135
+ selected = next;
136
+ render();
137
+ },
138
+ setOptions(nextOptions, nextValue) {
139
+ items = nextOptions;
140
+ selected = nextValue ?? (items.some((item) => item.value === selected) ? selected : items[0]?.value);
141
+ render();
142
+ },
143
+ };
144
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * The admin operation log and the view register log. Both are read-only here:
3
+ * the UI has no way to alter a log entry, and neither does the API.
4
+ */
5
+
6
+ import { api } from './api.js';
7
+ import { clampText } from './clamp.js';
8
+ import { el, replaceChildren } from './dom.js';
9
+ import { formatNumber, orNone } from './format.js';
10
+ import { t, tOr } from './i18n.js';
11
+ import { createListbox } from './listbox.js';
12
+ import { createPager, headerCell, messageRow, timeCell } from './table.js';
13
+
14
+ /** Sentinel for "no filter" in a filter listbox. */
15
+ const ALL = '';
16
+
17
+ function filterListbox(label, allLabel, values, labelFor, onChange) {
18
+ return createListbox({
19
+ label,
20
+ options: [{ value: ALL, label: allLabel }, ...values.map((value) => ({ value, label: labelFor(value) }))],
21
+ value: ALL,
22
+ onChange,
23
+ });
24
+ }
25
+
26
+ /**
27
+ * A paged, filterable, read-only log table, optionally with a notice above it.
28
+ * @param {{ title: string, columns: string[], filters: object[], fetchPage: (query: object) => Promise<object>,
29
+ * renderRow: (entry: object) => HTMLElement, meta: object, reportError: (error: unknown) => void,
30
+ * emptyKey: string, notice?: string }} options
31
+ */
32
+ function createLogPanel({ title, columns, filters, fetchPage, renderRow, meta, reportError, emptyKey, notice }) {
33
+ const state = { page: 1, pageSize: meta.pageSizeDefault, total: 0, entries: [], query: {}, seq: 0 };
34
+
35
+ const tbody = el('tbody');
36
+ const table = el('table', { className: 'data-table log-table' }, [
37
+ el('thead', {}, [el('tr', {}, columns.map((key) => headerCell({ label: t(`logColumns.${key}`), className: `col-${key}` })))]),
38
+ tbody,
39
+ ]);
40
+ const pager = createPager((page) => {
41
+ state.page = page;
42
+ load();
43
+ });
44
+
45
+ const toolbar = el('div', { className: 'toolbar' }, [
46
+ el('div', { className: 'toolbar-group' }, filters.map((filter) => filter.listbox.element)),
47
+ ]);
48
+
49
+ const element = el('section', { className: 'panel', attrs: { 'aria-label': title } }, [
50
+ toolbar,
51
+ notice ? el('p', { className: 'notice', text: notice }) : null,
52
+ el('div', { className: 'table-wrap' }, [table]),
53
+ pager.element,
54
+ ]);
55
+
56
+ async function load() {
57
+ const seq = ++state.seq;
58
+ table.setAttribute('aria-busy', 'true');
59
+ try {
60
+ const result = await fetchPage({ ...state.query, page: state.page, pageSize: state.pageSize });
61
+ if (seq !== state.seq) return;
62
+ state.entries = result.entries;
63
+ state.total = result.total;
64
+ replaceChildren(tbody, state.entries.length
65
+ ? state.entries.map(renderRow)
66
+ : [messageRow(columns.length, t(emptyKey))]);
67
+ pager.update(state.page, state.pageSize, state.total);
68
+ } catch (error) {
69
+ if (seq === state.seq) reportError(error);
70
+ } finally {
71
+ if (seq === state.seq) table.removeAttribute('aria-busy');
72
+ }
73
+ }
74
+
75
+ for (const filter of filters) {
76
+ filter.onValue = (value) => {
77
+ state.query[filter.param] = value || undefined;
78
+ state.page = 1;
79
+ load();
80
+ };
81
+ }
82
+
83
+ return { element, load };
84
+ }
85
+
86
+ /**
87
+ * @param {{ meta: object, appIds: () => string[], reportError: (error: unknown) => void }} deps
88
+ */
89
+ export function createAdminLogPanel({ meta, appIds, reportError }) {
90
+ const filters = [];
91
+ const actionFilter = { param: 'action' };
92
+ actionFilter.listbox = filterListbox(t('logs.filterAction'), t('logs.allActions'), meta.actions,
93
+ (action) => tOr(`actions.${action}`, action), (value) => actionFilter.onValue(value));
94
+ const appFilter = { param: 'appId' };
95
+ appFilter.listbox = filterListbox(t('logs.filterApp'), t('logs.allApps'), appIds(), (id) => id,
96
+ (value) => appFilter.onValue(value));
97
+ filters.push(actionFilter, appFilter);
98
+
99
+ const renderRow = (entry) => el('tr', {}, [
100
+ timeCell(entry.createdAt, { seconds: true }),
101
+ el('td', { className: 'col-action' }, [clampText(tOr(`actions.${entry.action}`, entry.action))]),
102
+ el('td', { className: 'col-app' }, [clampText(orNone(entry.appId))]),
103
+ el('td', { className: 'col-rows' }, [clampText(formatNumber(entry.targetCount))]),
104
+ el('td', { className: 'col-fields' }, [clampText(
105
+ entry.fields.length ? entry.fields.map((field) => tOr(`fields.${field}`, field)).join(', ') : t('common.none'),
106
+ )]),
107
+ el('td', { className: 'col-session' }, [clampText(entry.sessionId ? entry.sessionId.slice(0, 8) : t('logs.system'), {
108
+ className: 'mono',
109
+ })]),
110
+ el('td', { className: 'col-ip' }, [clampText(orNone(entry.maskedIp), { className: 'mono' })]),
111
+ ]);
112
+
113
+ const panel = createLogPanel({
114
+ title: t('tabs.adminLog'),
115
+ columns: ['time', 'action', 'app', 'rows', 'fields', 'session', 'ip'],
116
+ filters,
117
+ fetchPage: api.adminLog,
118
+ renderRow,
119
+ meta,
120
+ reportError,
121
+ emptyKey: 'logs.emptyAdmin',
122
+ });
123
+ return panel;
124
+ }
125
+
126
+ /**
127
+ * Why old entries leave the view log. Empty when the host did not say (an
128
+ * embedding app that configures no retention), so the UI never guesses.
129
+ * @param {number|undefined} days
130
+ */
131
+ function viewLogNotice(days) {
132
+ if (typeof days !== 'number') return '';
133
+ return days > 0 ? t('logs.viewLogRetention', { count: days }) : t('logs.viewLogRetentionOff');
134
+ }
135
+
136
+ /**
137
+ * @param {{ meta: object, appIds: () => string[], reportError: (error: unknown) => void }} deps
138
+ */
139
+ export function createViewLogPanel({ meta, appIds, reportError }) {
140
+ const sourceFilter = { param: 'source' };
141
+ sourceFilter.listbox = filterListbox(t('logs.filterSource'), t('logs.allSources'), meta.sources,
142
+ (source) => tOr(`logSources.${source}`, source), (value) => sourceFilter.onValue(value));
143
+ const appFilter = { param: 'appId' };
144
+ appFilter.listbox = filterListbox(t('logs.filterApp'), t('logs.allApps'), appIds(), (id) => id,
145
+ (value) => appFilter.onValue(value));
146
+
147
+ const renderRow = (entry) => el('tr', {}, [
148
+ timeCell(entry.createdAt, { seconds: true }),
149
+ el('td', { className: 'col-app' }, [clampText(entry.appId)]),
150
+ el('td', { className: 'col-source' }, [clampText(tOr(`logSources.${entry.source}`, entry.source))]),
151
+ el('td', { className: 'col-event' }, [clampText(orNone(entry.eventType))]),
152
+ el('td', { className: 'col-unique' }, [clampText(entry.isUnique ? t('common.yes') : t('common.no'))]),
153
+ el('td', { className: 'col-view' }, [clampText(entry.viewId, { className: 'mono' })]),
154
+ ]);
155
+
156
+ return createLogPanel({
157
+ title: t('tabs.viewLog'),
158
+ columns: ['time', 'app', 'source', 'event', 'unique', 'view'],
159
+ filters: [sourceFilter, appFilter],
160
+ fetchPage: api.viewLog,
161
+ renderRow,
162
+ meta,
163
+ reportError,
164
+ emptyKey: 'logs.emptyViews',
165
+ notice: viewLogNotice(meta.viewLogRetentionDays),
166
+ });
167
+ }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Admin UI entry point: session check, login, tabs, and wiring.
3
+ */
4
+
5
+ import { api, ApiError, onUnauthenticated, setCsrfToken } from './api.js';
6
+ import { byId, el, replaceChildren } from './dom.js';
7
+ import { applyTranslations, loadLocale, t } from './i18n.js';
8
+ import { createAdminLogPanel, createViewLogPanel } from './logs.js';
9
+ import { closeModal } from './modal.js';
10
+ import { initTheme } from './theme.js';
11
+ import { showToast, TOAST_TYPE } from './toast.js';
12
+ import { createViewsPanel, PANEL_MODE } from './views.js';
13
+ import { ALL_APPS, ERROR_CODE, KEY, STORAGE_KEY, TAB, THEME } from './constants.js';
14
+
15
+ const TAB_ORDER = [TAB.VIEWS, TAB.TRASH, TAB.ADMIN_LOG, TAB.VIEW_LOG];
16
+
17
+ const shell = {
18
+ meta: null,
19
+ apps: [],
20
+ appId: null,
21
+ panels: new Map(),
22
+ activeTab: TAB.VIEWS,
23
+ tabButtons: new Map(),
24
+ };
25
+
26
+ /** Translate any failure into a message and show it. */
27
+ function reportError(error) {
28
+ const code = error instanceof ApiError ? error.code : ERROR_CODE.SERVER_ERROR;
29
+ if (code === ERROR_CODE.UNAUTHENTICATED) return;
30
+ showToast(t(`errors.${code}`), TOAST_TYPE.ERROR);
31
+ }
32
+
33
+ function readLastApp() {
34
+ try {
35
+ return localStorage.getItem(STORAGE_KEY.LAST_APP);
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ function rememberApp(appId) {
42
+ try {
43
+ localStorage.setItem(STORAGE_KEY.LAST_APP, appId);
44
+ } catch {
45
+ // Storage blocked: the choice simply is not remembered.
46
+ }
47
+ }
48
+
49
+ // ---- Login ------------------------------------------------------------------
50
+
51
+ function showLogin(messageKey) {
52
+ closeModal();
53
+ byId('app-screen').hidden = true;
54
+ byId('logout-btn').hidden = true;
55
+ byId('login-screen').hidden = false;
56
+ const error = byId('login-error');
57
+ error.hidden = !messageKey;
58
+ error.textContent = messageKey ? t(messageKey) : '';
59
+ const input = byId('login-password');
60
+ input.value = '';
61
+ input.focus();
62
+ }
63
+
64
+ function wireLogin() {
65
+ const form = byId('login-form');
66
+ const submit = byId('login-submit');
67
+ form.addEventListener('submit', async (event) => {
68
+ event.preventDefault();
69
+ const password = byId('login-password').value;
70
+ if (!password) return;
71
+ submit.disabled = true;
72
+ try {
73
+ const session = await api.login(password);
74
+ setCsrfToken(session.csrfToken);
75
+ await enterApp();
76
+ } catch (error) {
77
+ const code = error instanceof ApiError ? error.code : ERROR_CODE.SERVER_ERROR;
78
+ showLogin(`errors.${code}`);
79
+ } finally {
80
+ submit.disabled = false;
81
+ }
82
+ });
83
+ }
84
+
85
+ // ---- App shell --------------------------------------------------------------
86
+
87
+ async function refreshApps() {
88
+ const { apps } = await api.apps();
89
+ shell.apps = apps;
90
+ for (const panel of shell.panels.values()) panel.syncApps?.();
91
+ }
92
+
93
+ function setAppId(appId) {
94
+ shell.appId = appId;
95
+ rememberApp(appId);
96
+ for (const tab of [TAB.VIEWS, TAB.TRASH]) {
97
+ const panel = shell.panels.get(tab);
98
+ panel.syncApps();
99
+ panel.reload();
100
+ }
101
+ }
102
+
103
+ function selectTab(tab, { focus = false } = {}) {
104
+ shell.activeTab = TAB_ORDER.includes(tab) ? tab : TAB.VIEWS;
105
+ for (const [key, button] of shell.tabButtons) {
106
+ const active = key === shell.activeTab;
107
+ button.setAttribute('aria-selected', String(active));
108
+ button.tabIndex = active ? 0 : -1;
109
+ button.classList.toggle('active', active);
110
+ if (active && focus) button.focus();
111
+ }
112
+ for (const [key, panel] of shell.panels) panel.element.hidden = key !== shell.activeTab;
113
+
114
+ const panel = shell.panels.get(shell.activeTab);
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
+ }
121
+ }
122
+
123
+ function buildTabs() {
124
+ const tablist = byId('tabs');
125
+ const buttons = TAB_ORDER.map((tab) => {
126
+ const button = el('button', {
127
+ className: 'tab',
128
+ attrs: { type: 'button', role: 'tab', id: `tab-${tab}`, 'aria-controls': `panel-${tab}` },
129
+ on: { click: () => selectTab(tab) },
130
+ }, [
131
+ el('span', { className: 'prompt-char', text: '❯', attrs: { 'aria-hidden': 'true' } }),
132
+ el('span', { text: t(`tabs.${tab}`) }),
133
+ ]);
134
+ shell.tabButtons.set(tab, button);
135
+ return button;
136
+ });
137
+
138
+ // Arrow keys move between tabs (ARIA tabs pattern).
139
+ tablist.addEventListener('keydown', (event) => {
140
+ const index = TAB_ORDER.indexOf(shell.activeTab);
141
+ if (event.key === KEY.ARROW_RIGHT) selectTab(TAB_ORDER[(index + 1) % TAB_ORDER.length], { focus: true });
142
+ if (event.key === KEY.ARROW_LEFT) selectTab(TAB_ORDER[(index - 1 + TAB_ORDER.length) % TAB_ORDER.length], { focus: true });
143
+ });
144
+ replaceChildren(tablist, buttons);
145
+ }
146
+
147
+ function buildPanels() {
148
+ const context = {
149
+ apps: () => shell.apps,
150
+ appId: () => shell.appId,
151
+ setAppId,
152
+ refreshApps,
153
+ reportError,
154
+ };
155
+ const appIds = () => shell.apps.map((app) => app.appId);
156
+
157
+ shell.panels.set(TAB.VIEWS, createViewsPanel({ mode: PANEL_MODE.VIEWS, meta: shell.meta, context }));
158
+ shell.panels.set(TAB.TRASH, createViewsPanel({ mode: PANEL_MODE.TRASH, meta: shell.meta, context }));
159
+ 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
+
162
+ const container = byId('panels');
163
+ replaceChildren(container, TAB_ORDER.map((tab) => {
164
+ const panel = shell.panels.get(tab);
165
+ panel.element.id = `panel-${tab}`;
166
+ panel.element.setAttribute('role', 'tabpanel');
167
+ panel.element.setAttribute('aria-labelledby', `tab-${tab}`);
168
+ return panel.element;
169
+ }));
170
+ }
171
+
172
+ async function enterApp() {
173
+ shell.meta = await api.meta();
174
+ const { apps } = await api.apps();
175
+ shell.apps = apps;
176
+ // Every app together by default; a remembered app wins while it still exists.
177
+ const remembered = readLastApp();
178
+ shell.appId = apps.some((app) => app.appId === remembered) ? remembered : ALL_APPS;
179
+
180
+ buildTabs();
181
+ buildPanels();
182
+ for (const tab of [TAB.VIEWS, TAB.TRASH]) shell.panels.get(tab).syncApps();
183
+
184
+ byId('login-screen').hidden = true;
185
+ byId('app-screen').hidden = false;
186
+ byId('logout-btn').hidden = false;
187
+
188
+ selectTab(window.location.hash.slice(1));
189
+ }
190
+
191
+ async function logout() {
192
+ try {
193
+ await api.logout();
194
+ } catch (error) {
195
+ reportError(error);
196
+ }
197
+ setCsrfToken(null);
198
+ showLogin('login.signedOut');
199
+ }
200
+
201
+ // ---- Boot -------------------------------------------------------------------
202
+
203
+ async function boot() {
204
+ await loadLocale();
205
+ applyTranslations();
206
+ document.title = t('app.documentTitle');
207
+
208
+ initTheme({
209
+ [THEME.LIGHT]: byId('theme-light'),
210
+ [THEME.DARK]: byId('theme-dark'),
211
+ [THEME.AUTO]: byId('theme-auto'),
212
+ });
213
+ wireLogin();
214
+ byId('logout-btn').addEventListener('click', logout);
215
+ onUnauthenticated(() => {
216
+ setCsrfToken(null);
217
+ showLogin('errors.UNAUTHENTICATED');
218
+ });
219
+
220
+ try {
221
+ const session = await api.session();
222
+ if (session.authenticated) {
223
+ setCsrfToken(session.csrfToken);
224
+ await enterApp();
225
+ } else {
226
+ showLogin();
227
+ }
228
+ } catch (error) {
229
+ showLogin(`errors.${error instanceof ApiError ? error.code : ERROR_CODE.SERVER_ERROR}`);
230
+ } finally {
231
+ document.body.classList.remove('booting');
232
+ }
233
+ }
234
+
235
+ boot();