@harshankur/viewcounter 3.0.1 → 3.2.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 (64) hide show
  1. package/.env.example +50 -6
  2. package/README.md +444 -104
  3. package/admin/apple-touch-icon.png +0 -0
  4. package/admin/assets/world-map.json +1 -0
  5. package/admin/css/admin.css +2568 -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 +95 -0
  11. package/admin/js/api.js +146 -0
  12. package/admin/js/appTabs.js +100 -0
  13. package/admin/js/charts.js +842 -0
  14. package/admin/js/clamp.js +41 -0
  15. package/admin/js/constants.js +239 -0
  16. package/admin/js/dataTable.js +478 -0
  17. package/admin/js/dom.js +83 -0
  18. package/admin/js/format.js +130 -0
  19. package/admin/js/i18n.js +80 -0
  20. package/admin/js/icons.js +168 -0
  21. package/admin/js/listbox.js +145 -0
  22. package/admin/js/logs.js +318 -0
  23. package/admin/js/main.js +399 -0
  24. package/admin/js/modal.js +171 -0
  25. package/admin/js/overview.js +905 -0
  26. package/admin/js/passwordPrompt.js +75 -0
  27. package/admin/js/table.js +94 -0
  28. package/admin/js/theme.js +72 -0
  29. package/admin/js/toast.js +47 -0
  30. package/admin/js/viewDialogs.js +224 -0
  31. package/admin/js/views.js +751 -0
  32. package/admin/locales/en.json +683 -0
  33. package/admin/site.webmanifest +20 -0
  34. package/config/index.js +122 -4
  35. package/constants.js +334 -3
  36. package/db/AdminRepository.js +488 -0
  37. package/db/DatabaseManager.js +148 -26
  38. package/db/LogRepository.js +354 -0
  39. package/db/adminSchema.js +329 -0
  40. package/db/adminSessionStore.js +104 -0
  41. package/db/analysis.js +479 -0
  42. package/db/rejectionCounter.js +117 -0
  43. package/db/retention.js +97 -0
  44. package/index.js +91 -24
  45. package/middleware/adminAuth.js +244 -0
  46. package/middleware/adminValidation.js +319 -0
  47. package/middleware/auth.js +2 -2
  48. package/middleware/security.js +26 -2
  49. package/middleware/validation.js +50 -2
  50. package/package.json +20 -10
  51. package/routes/admin.js +546 -0
  52. package/routes/analytics.js +207 -19
  53. package/tracker/tracker.js +191 -0
  54. package/utils/appIdUtils.js +1 -1
  55. package/utils/cookieUtils.js +47 -0
  56. package/utils/durationUtils.js +33 -0
  57. package/utils/errorUtils.js +39 -1
  58. package/utils/geoCity.js +87 -0
  59. package/utils/ipUtils.js +1 -1
  60. package/utils/privacyUtils.js +2 -2
  61. package/utils/referrerParser.js +23 -5
  62. package/utils/secretStore.js +1 -1
  63. package/utils/userAgentParser.js +52 -3
  64. package/utils/visitorContext.js +70 -0
@@ -0,0 +1,75 @@
1
+ /**
2
+ * A password dialog over the current page, for the two times the admin UI
3
+ * needs the password without leaving what the admin was doing: signing in
4
+ * again after a session ends, and confirming an action that cannot be undone.
5
+ */
6
+
7
+ import { ApiError } from './api.js';
8
+ import { el, uniqueId } from './dom.js';
9
+ import { t } from './i18n.js';
10
+ import { openModal, VARIANT } from './modal.js';
11
+ import { ERROR_CODE } from './constants.js';
12
+
13
+ /**
14
+ * @param {{ title: string, message: string, submitLabel: string,
15
+ * submit: (password: string) => Promise<void> }} options
16
+ * @returns {Promise<boolean>} whether `submit` succeeded; false if cancelled
17
+ */
18
+ export function promptPassword({ title, message, submitLabel, submit }) {
19
+ return new Promise((resolve) => {
20
+ const inputId = uniqueId('password');
21
+ const input = el('input', {
22
+ className: 'input',
23
+ attrs: { id: inputId, type: 'password', autocomplete: 'current-password', required: 'true' },
24
+ });
25
+ const error = el('p', { className: 'field-error', attrs: { role: 'alert' } });
26
+ error.hidden = true;
27
+ let settled = false;
28
+
29
+ const attempt = async () => {
30
+ if (!input.value) {
31
+ input.focus();
32
+ return false;
33
+ }
34
+ try {
35
+ await submit(input.value);
36
+ settled = true;
37
+ resolve(true);
38
+ return true;
39
+ } catch (failure) {
40
+ const code = failure instanceof ApiError ? failure.code : ERROR_CODE.SERVER_ERROR;
41
+ error.textContent = t(`errors.${code}`);
42
+ error.hidden = false;
43
+ input.select();
44
+ return false;
45
+ }
46
+ };
47
+
48
+ const dialog = openModal({
49
+ title,
50
+ body: [
51
+ el('p', { text: message }),
52
+ el('label', { className: 'field-label', text: t('login.password'), attrs: { for: inputId } }),
53
+ input,
54
+ error,
55
+ ],
56
+ actions: [
57
+ { label: t('common.cancel'), variant: VARIANT.SECONDARY },
58
+ { label: submitLabel, variant: VARIANT.PRIMARY, onClick: attempt },
59
+ ],
60
+ onCancel: () => { if (!settled) resolve(false); },
61
+ initialFocus: input,
62
+ });
63
+
64
+ // Enter submits, as on the sign-in screen.
65
+ input.addEventListener('keydown', (event) => {
66
+ if (event.key !== 'Enter') return;
67
+ event.preventDefault();
68
+ dialog.querySelector('.modal-actions .btn-primary')?.click();
69
+ });
70
+ // A cancel button that closes the dialog is a cancel, too.
71
+ dialog.querySelector('.modal-actions .btn-secondary')?.addEventListener('click', () => {
72
+ if (!settled) resolve(false);
73
+ });
74
+ });
75
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Controls shared by the tables: the pager and the segmented control. The
3
+ * table itself is dataTable.js.
4
+ */
5
+
6
+ import { el, replaceChildren } from './dom.js';
7
+ import { formatNumber } from './format.js';
8
+ import { t } from './i18n.js';
9
+
10
+ /**
11
+ * Previous / next pagination with a live summary.
12
+ * @param {(page: number) => void} onChange
13
+ */
14
+ export function createPager(onChange) {
15
+ let page = 1;
16
+ let pages = 1;
17
+
18
+ const summary = el('span', { className: 'pager-summary', attrs: { 'aria-live': 'polite' } });
19
+ const prev = el('button', {
20
+ className: 'btn btn-secondary btn-small',
21
+ text: t('pager.previous'),
22
+ attrs: { type: 'button' },
23
+ on: { click: () => page > 1 && onChange(page - 1) },
24
+ });
25
+ const next = el('button', {
26
+ className: 'btn btn-secondary btn-small',
27
+ text: t('pager.next'),
28
+ attrs: { type: 'button' },
29
+ on: { click: () => page < pages && onChange(page + 1) },
30
+ });
31
+
32
+ const element = el('div', { className: 'pager' }, [prev, summary, next]);
33
+
34
+ return {
35
+ element,
36
+ /** @param {number} currentPage @param {number} pageSize @param {number} total */
37
+ update(currentPage, pageSize, total) {
38
+ page = currentPage;
39
+ pages = Math.max(1, Math.ceil(total / pageSize));
40
+ prev.disabled = page <= 1;
41
+ next.disabled = page >= pages;
42
+ replaceChildren(summary, [t('pager.summary', {
43
+ page: formatNumber(page),
44
+ pages: formatNumber(pages),
45
+ total: formatNumber(total),
46
+ count: total,
47
+ })]);
48
+ },
49
+ };
50
+ }
51
+
52
+ /** A segmented control: one pressed button among several (a single choice). */
53
+ export function createSegmented({ label, options, value, onChange }) {
54
+ let current = value;
55
+ const buttons = new Map();
56
+ const group = el('div', { className: 'segmented', attrs: { role: 'group', 'aria-label': label } });
57
+
58
+ const render = () => {
59
+ for (const [key, button] of buttons) {
60
+ const active = key === current;
61
+ button.classList.toggle('active', active);
62
+ button.setAttribute('aria-pressed', String(active));
63
+ }
64
+ };
65
+
66
+ for (const option of options) {
67
+ const button = el('button', {
68
+ className: 'segmented-btn',
69
+ text: option.label,
70
+ attrs: { type: 'button' },
71
+ on: {
72
+ click: () => {
73
+ if (current === option.value) return;
74
+ current = option.value;
75
+ render();
76
+ onChange(current);
77
+ },
78
+ },
79
+ });
80
+ buttons.set(option.value, button);
81
+ group.append(button);
82
+ }
83
+ render();
84
+
85
+ return {
86
+ element: group,
87
+ getValue: () => current,
88
+ /** Show a value chosen elsewhere, without reporting it as a change. */
89
+ setValue(value) {
90
+ current = value;
91
+ render();
92
+ },
93
+ };
94
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Light / dark / system theme (FRONTEND.md §6).
3
+ *
4
+ * All colours are tokens on :root; the theme is a class on <html>, so
5
+ * switching it re-themes everything without per-component branching. The
6
+ * choice is a per-viewer convenience kept in localStorage, which may be
7
+ * unavailable (private mode, blocked storage); the UI works without it.
8
+ */
9
+
10
+ import { STORAGE_KEY, THEME } from './constants.js';
11
+
12
+ const CLASS = {
13
+ [THEME.LIGHT]: 'theme-light',
14
+ [THEME.DARK]: 'theme-dark',
15
+ };
16
+
17
+ const darkQuery = window.matchMedia('(prefers-color-scheme: dark)');
18
+ let current = THEME.AUTO;
19
+ const buttons = new Map();
20
+
21
+ function readStored() {
22
+ try {
23
+ const value = localStorage.getItem(STORAGE_KEY.THEME);
24
+ return Object.values(THEME).includes(value) ? value : THEME.AUTO;
25
+ } catch {
26
+ return THEME.AUTO;
27
+ }
28
+ }
29
+
30
+ function store(value) {
31
+ try {
32
+ localStorage.setItem(STORAGE_KEY.THEME, value);
33
+ } catch {
34
+ // Storage blocked: the theme still applies for this page view.
35
+ }
36
+ }
37
+
38
+ function resolved() {
39
+ if (current !== THEME.AUTO) return current;
40
+ return darkQuery.matches ? THEME.DARK : THEME.LIGHT;
41
+ }
42
+
43
+ function apply() {
44
+ const root = document.documentElement;
45
+ root.classList.remove(...Object.values(CLASS));
46
+ root.classList.add(CLASS[resolved()]);
47
+ for (const [value, button] of buttons) {
48
+ const active = value === current;
49
+ button.classList.toggle('active', active);
50
+ button.setAttribute('aria-pressed', String(active));
51
+ }
52
+ }
53
+
54
+ export function setTheme(value) {
55
+ current = Object.values(THEME).includes(value) ? value : THEME.AUTO;
56
+ store(current);
57
+ apply();
58
+ }
59
+
60
+ /**
61
+ * Wire the switcher buttons and apply the stored theme.
62
+ * @param {Record<string, HTMLButtonElement>} themeButtons keyed by THEME value
63
+ */
64
+ export function initTheme(themeButtons) {
65
+ for (const [value, button] of Object.entries(themeButtons)) {
66
+ buttons.set(value, button);
67
+ button.addEventListener('click', () => setTheme(value));
68
+ }
69
+ current = readStored();
70
+ darkQuery.addEventListener('change', apply);
71
+ apply();
72
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Non-blocking notices (FRONTEND.md §2): the same shape as the modal, one
3
+ * helper and one root. Errors interrupt (role=alert); everything else is
4
+ * polite (role=status).
5
+ */
6
+
7
+ import { byId, el } from './dom.js';
8
+ import { TOAST_DURATION_MS } from './constants.js';
9
+ import { t } from './i18n.js';
10
+
11
+ export const TOAST_TYPE = Object.freeze({
12
+ SUCCESS: 'success',
13
+ ERROR: 'error',
14
+ INFO: 'info',
15
+ });
16
+
17
+ /**
18
+ * @param {string} message
19
+ * @param {string} [type]
20
+ */
21
+ export function showToast(message, type = TOAST_TYPE.INFO) {
22
+ const toast = el('div', {
23
+ className: `toast toast-${type}`,
24
+ attrs: { role: type === TOAST_TYPE.ERROR ? 'alert' : 'status' },
25
+ }, [
26
+ el('span', { className: 'toast-prompt', text: '❯', attrs: { 'aria-hidden': 'true' } }),
27
+ el('span', { className: 'toast-message', text: message }),
28
+ ]);
29
+
30
+ const dismiss = () => {
31
+ toast.classList.add('toast-leaving');
32
+ toast.addEventListener('animationend', () => toast.remove(), { once: true });
33
+ // Reduced-motion users get no animation, so no animationend either.
34
+ setTimeout(() => toast.remove(), TOAST_DURATION_MS);
35
+ };
36
+
37
+ toast.append(el('button', {
38
+ className: 'icon-btn toast-close',
39
+ text: '×',
40
+ attrs: { type: 'button', 'aria-label': t('common.dismiss') },
41
+ on: { click: dismiss },
42
+ }));
43
+
44
+ byId('toast-root').append(toast);
45
+ setTimeout(dismiss, TOAST_DURATION_MS);
46
+ return toast;
47
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Dialogs for one or many views: details, edit, and note.
3
+ */
4
+
5
+ import { el, uniqueId } from './dom.js';
6
+ import { openModal, VARIANT } from './modal.js';
7
+ import { createListbox } from './listbox.js';
8
+ import { formatDateTime, formatDuration, orNone } from './format.js';
9
+ import { regionName } from './charts.js';
10
+ import { valueLabel } from './overview.js';
11
+ import { t, tOr } from './i18n.js';
12
+ import {
13
+ DEVICE_SIZE_FIELD,
14
+ EVENT_DATA_FIELD,
15
+ EVENT_DATA_ROWS,
16
+ JSON_INDENT,
17
+ NOTE_ROWS,
18
+ TEXT_FIELDS,
19
+ VIEW_DETAIL_GROUPS,
20
+ } from './constants.js';
21
+
22
+ const DATE_FIELDS = new Set(['timestamp', 'adminModifiedAt', 'deletedAt']);
23
+
24
+ /** Render one field of a view for reading. */
25
+ function displayValue(field, value) {
26
+ if (DATE_FIELDS.has(field)) return formatDateTime(value);
27
+ if (field === 'isUnique') return value ? t('common.yes') : t('common.no');
28
+ if (value === null || value === undefined || value === '') return t('common.none');
29
+ if (field === 'sourceType') return tOr(`sources.${value}`, value);
30
+ if (field === 'deviceSize') return tOr(`deviceSizes.${value}`, value);
31
+ if (field === 'deviceType') return valueLabel('deviceType', value);
32
+ if (field === 'language') return valueLabel('language', value);
33
+ if (field === 'eventType') return valueLabel('eventType', value);
34
+ if (field === 'country') return `${regionName(value)} (${value})`;
35
+ if (field === 'engagedMs') return formatDuration(value);
36
+ if (field === 'scrollDepth') return t('details.scrolled', { percent: value });
37
+ if (field === EVENT_DATA_FIELD) return JSON.stringify(value, null, JSON_INDENT);
38
+ return orNone(value);
39
+ }
40
+
41
+ /** Read-only view of every field, grouped: the view, its page, source, visitor, engagement, and admin changes. */
42
+ export function openDetails(view) {
43
+ const body = el('div', { className: 'detail-groups' }, VIEW_DETAIL_GROUPS.map((group) => {
44
+ const list = el('dl', { className: 'detail-list' });
45
+ for (const field of group.fields) {
46
+ const value = displayValue(field, view[field]);
47
+ const valueNode = field === EVENT_DATA_FIELD && view[field] !== null && view[field] !== undefined
48
+ ? el('pre', { className: 'detail-json', text: value })
49
+ : el('span', { className: 'detail-value', text: value });
50
+ list.append(el('dt', { text: t(`fields.${field}`) }), el('dd', {}, [valueNode]));
51
+ }
52
+ return el('section', { className: 'detail-group' }, [
53
+ el('h3', { className: 'detail-group-title', text: t(`details.groups.${group.key}`) }),
54
+ list,
55
+ ]);
56
+ }));
57
+ openModal({
58
+ title: t('details.title'),
59
+ body,
60
+ actions: [{ label: t('common.close'), variant: VARIANT.SECONDARY }],
61
+ wide: true,
62
+ });
63
+ }
64
+
65
+ /**
66
+ * Edit content fields of one or many views.
67
+ *
68
+ * With one view the fields start from its values and a field is included as
69
+ * soon as it is changed. With several, fields start empty and the admin ticks
70
+ * the ones to overwrite, so a batch never touches a field by accident.
71
+ *
72
+ * @param {{ views: object[], meta: object, onSubmit: (changes: object) => Promise<boolean> }} options
73
+ */
74
+ export function openEditor({ views, meta, onSubmit }) {
75
+ const single = views.length === 1 ? views[0] : null;
76
+ const rows = [];
77
+ const controls = new Map();
78
+
79
+ for (const field of meta.editableFields) {
80
+ const inputId = uniqueId(`edit-${field}`);
81
+ const include = el('input', {
82
+ className: 'field-include',
83
+ attrs: { type: 'checkbox', 'aria-label': t('edit.include', { field: t(`fields.${field}`) }) },
84
+ });
85
+ const markIncluded = () => { include.checked = true; };
86
+
87
+ let control;
88
+ let read;
89
+ if (field === DEVICE_SIZE_FIELD) {
90
+ const listbox = createListbox({
91
+ label: t(`fields.${field}`),
92
+ options: meta.deviceSizes.map((size) => ({ value: size, label: tOr(`deviceSizes.${size}`, size) })),
93
+ value: single?.deviceSize ?? meta.deviceSizes[0],
94
+ onChange: markIncluded,
95
+ });
96
+ control = listbox.element;
97
+ read = () => ({ ok: true, value: listbox.getValue() });
98
+ } else if (field === EVENT_DATA_FIELD) {
99
+ const initial = single?.eventData === null || single?.eventData === undefined
100
+ ? '' : JSON.stringify(single.eventData, null, JSON_INDENT);
101
+ const textarea = el('textarea', {
102
+ className: 'input mono',
103
+ text: initial,
104
+ attrs: { id: inputId, rows: EVENT_DATA_ROWS, spellcheck: 'false' },
105
+ on: { input: markIncluded },
106
+ });
107
+ control = textarea;
108
+ read = () => {
109
+ const text = textarea.value.trim();
110
+ if (text === '') return { ok: true, value: null };
111
+ try {
112
+ return { ok: true, value: JSON.parse(text) };
113
+ } catch {
114
+ return { ok: false, error: t('edit.invalidJson') };
115
+ }
116
+ };
117
+ } else if (TEXT_FIELDS.includes(field)) {
118
+ const input = el('input', {
119
+ className: 'input',
120
+ attrs: {
121
+ id: inputId,
122
+ type: 'text',
123
+ maxlength: meta.maxLength[field],
124
+ value: single ? (single[field] ?? '') : '',
125
+ spellcheck: 'false',
126
+ },
127
+ on: { input: markIncluded },
128
+ });
129
+ control = input;
130
+ read = () => ({ ok: true, value: input.value === '' ? null : input.value });
131
+ } else {
132
+ continue;
133
+ }
134
+
135
+ const error = el('p', { className: 'field-error', attrs: { role: 'alert' } });
136
+ error.hidden = true;
137
+ controls.set(field, { include, read, error });
138
+ rows.push(el('div', { className: 'edit-row' }, [
139
+ include,
140
+ el('label', { className: 'edit-label', text: t(`fields.${field}`), attrs: { for: inputId } }),
141
+ el('div', { className: 'edit-control' }, [control, error]),
142
+ ]));
143
+ }
144
+
145
+ const formError = el('p', { className: 'field-error', attrs: { role: 'alert' } });
146
+ formError.hidden = true;
147
+
148
+ const body = [
149
+ el('p', { className: 'modal-message', text: single ? t('edit.introSingle') : t('edit.introBatch', { count: views.length }) }),
150
+ el('p', { className: 'notice', text: t('edit.lockedNotice') }),
151
+ el('div', { className: 'edit-grid' }, rows),
152
+ formError,
153
+ ];
154
+
155
+ openModal({
156
+ title: single ? t('edit.titleSingle') : t('edit.titleBatch', { count: views.length }),
157
+ body,
158
+ wide: true,
159
+ actions: [
160
+ { label: t('common.cancel'), variant: VARIANT.SECONDARY },
161
+ {
162
+ label: t('edit.save'),
163
+ variant: VARIANT.PRIMARY,
164
+ onClick: async () => {
165
+ const changes = {};
166
+ let valid = true;
167
+ for (const [field, { include, read, error }] of controls) {
168
+ error.hidden = true;
169
+ if (!include.checked) continue;
170
+ const result = read();
171
+ if (!result.ok) {
172
+ error.textContent = result.error;
173
+ error.hidden = false;
174
+ valid = false;
175
+ } else {
176
+ changes[field] = result.value;
177
+ }
178
+ }
179
+ formError.hidden = true;
180
+ if (!valid) return false;
181
+ if (Object.keys(changes).length === 0) {
182
+ formError.textContent = t('edit.nothingSelected');
183
+ formError.hidden = false;
184
+ return false;
185
+ }
186
+ return onSubmit(changes);
187
+ },
188
+ },
189
+ ],
190
+ });
191
+ }
192
+
193
+ /**
194
+ * Set or clear the note on one or many views.
195
+ * @param {{ views: object[], meta: object, onSubmit: (note: string|null) => Promise<boolean> }} options
196
+ */
197
+ export function openNoteEditor({ views, meta, onSubmit }) {
198
+ const single = views.length === 1 ? views[0] : null;
199
+ const inputId = uniqueId('note');
200
+ const textarea = el('textarea', {
201
+ className: 'input',
202
+ text: single?.note ?? '',
203
+ attrs: { id: inputId, rows: NOTE_ROWS, maxlength: meta.maxLength.note },
204
+ });
205
+
206
+ openModal({
207
+ title: single ? t('note.titleSingle') : t('note.titleBatch', { count: views.length }),
208
+ body: [
209
+ el('p', { className: 'modal-message', text: t('note.intro') }),
210
+ el('label', { className: 'sr-only', text: t('fields.note'), attrs: { for: inputId } }),
211
+ textarea,
212
+ ],
213
+ initialFocus: textarea,
214
+ actions: [
215
+ { label: t('common.cancel'), variant: VARIANT.SECONDARY },
216
+ { label: t('note.clear'), variant: VARIANT.SECONDARY, onClick: () => onSubmit(null) },
217
+ {
218
+ label: t('note.save'),
219
+ variant: VARIANT.PRIMARY,
220
+ onClick: () => onSubmit(textarea.value.trim() === '' ? null : textarea.value),
221
+ },
222
+ ],
223
+ });
224
+ }