tacilytics 2.0.1 → 2.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.
- package/.env.example +7 -1
- package/README.md +32 -3
- package/auth.js +608 -0
- package/cli.js +36 -0
- package/package.json +5 -2
- package/public/account.js +335 -0
- package/public/app.js +15 -18
- package/public/assets/icons.svg +5 -0
- package/public/index.html +76 -33
- package/public/style.css +43 -0
- package/server.js +38 -45
package/public/index.html
CHANGED
|
@@ -17,10 +17,34 @@
|
|
|
17
17
|
<div class="modal">
|
|
18
18
|
<img class="logo" src="/assets/tacilytics-mark.svg" alt="">
|
|
19
19
|
<h2>Tacilytics</h2>
|
|
20
|
-
<p>Cookieless analytics. Sign in with your passphrase.</p>
|
|
21
|
-
<div id="loginErr" class="err"></div>
|
|
22
|
-
<
|
|
23
|
-
|
|
20
|
+
<p id="loginHint">Cookieless analytics. Sign in with your passphrase.</p>
|
|
21
|
+
<div id="loginErr" class="err" role="alert"></div>
|
|
22
|
+
<div id="loginStep1">
|
|
23
|
+
<input id="user" class="hide" type="text" placeholder="Username" autocomplete="username webauthn" autocapitalize="off" spellcheck="false">
|
|
24
|
+
<input id="pw" type="password" placeholder="Passphrase" autocomplete="current-password">
|
|
25
|
+
<button class="btn" id="loginBtn">Sign in</button>
|
|
26
|
+
<button class="btn ghost hide" id="passkeyBtn" style="margin-top:10px"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-key"></use></svg>Sign in with a passkey</button>
|
|
27
|
+
</div>
|
|
28
|
+
<div id="loginStep2" class="hide">
|
|
29
|
+
<input id="otp" type="text" inputmode="numeric" placeholder="6-digit code" autocomplete="one-time-code" maxlength="20">
|
|
30
|
+
<button class="btn" id="otpBtn">Verify</button>
|
|
31
|
+
<p class="muted small" style="margin:12px 0 0">Lost your phone? Enter one of your recovery codes instead.</p>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<!-- Invite: create an account from an invite link -->
|
|
37
|
+
<div id="invite" class="modal-overlay hide">
|
|
38
|
+
<div class="modal">
|
|
39
|
+
<img class="logo" src="/assets/tacilytics-mark.svg" alt="">
|
|
40
|
+
<h2>Join Tacilytics</h2>
|
|
41
|
+
<p id="inviteHint">Choose a username and a password.</p>
|
|
42
|
+
<div id="inviteErr" class="err" role="alert"></div>
|
|
43
|
+
<div id="inviteForm">
|
|
44
|
+
<input id="invUser" type="text" placeholder="Username" autocomplete="username" autocapitalize="off" spellcheck="false">
|
|
45
|
+
<input id="invPw" type="password" placeholder="Password (at least 10 characters)" autocomplete="new-password">
|
|
46
|
+
<button class="btn" id="invBtn">Create account</button>
|
|
47
|
+
</div>
|
|
24
48
|
</div>
|
|
25
49
|
</div>
|
|
26
50
|
|
|
@@ -37,7 +61,8 @@
|
|
|
37
61
|
<div class="header-right">
|
|
38
62
|
<div class="realtime" id="realtimeBtn" title="View live visitors" style="cursor:pointer"><span class="dot"></span><span id="realtime">0</span> online now</div>
|
|
39
63
|
<select id="siteSelect"></select>
|
|
40
|
-
<button class="btn ghost" id="addSiteBtn"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
64
|
+
<button class="btn ghost" id="addSiteBtn"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-plus"></use></svg>Add site</button>
|
|
65
|
+
<button class="btn ghost" id="settingsBtn" title="Account, security and team"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-settings"></use></svg><span id="meName">Settings</span></button>
|
|
41
66
|
<button class="btn ghost danger" id="logoutBtn">Sign out</button>
|
|
42
67
|
</div>
|
|
43
68
|
</header>
|
|
@@ -52,12 +77,12 @@
|
|
|
52
77
|
</div>
|
|
53
78
|
<div class="toolbar-right">
|
|
54
79
|
<div class="range" id="range">
|
|
55
|
-
<input type="date" id="fromDate" title="From"><span class="arrow"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
80
|
+
<input type="date" id="fromDate" title="From"><span class="arrow"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-arrow"></use></svg></span><input type="date" id="toDate" title="To">
|
|
56
81
|
<button class="btn ghost" id="applyRange">Apply</button>
|
|
57
82
|
</div>
|
|
58
|
-
<button class="btn ghost" id="exportBtn"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
59
|
-
<button class="btn ghost" id="shareBtn" title="Create a public read-only link"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
60
|
-
<button class="btn ghost" id="snippetBtn"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
83
|
+
<button class="btn ghost" id="exportBtn"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-download"></use></svg>CSV</button>
|
|
84
|
+
<button class="btn ghost" id="shareBtn" title="Create a public read-only link"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-share"></use></svg>Share</button>
|
|
85
|
+
<button class="btn ghost" id="snippetBtn"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-code"></use></svg>Embed code</button>
|
|
61
86
|
</div>
|
|
62
87
|
</div>
|
|
63
88
|
|
|
@@ -94,7 +119,7 @@
|
|
|
94
119
|
|
|
95
120
|
<div class="panels">
|
|
96
121
|
<div class="card panel">
|
|
97
|
-
<h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
122
|
+
<h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-pages"></use></svg> Pages
|
|
98
123
|
<span class="tabs" id="pageTabs">
|
|
99
124
|
<button data-t="pages" class="active">Top</button>
|
|
100
125
|
<button data-t="entryPages">Entry</button>
|
|
@@ -104,7 +129,7 @@
|
|
|
104
129
|
<div class="bars" id="p-pages"></div>
|
|
105
130
|
</div>
|
|
106
131
|
<div class="card panel">
|
|
107
|
-
<h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
132
|
+
<h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-sources"></use></svg> Sources
|
|
108
133
|
<span class="tabs" id="srcTabs">
|
|
109
134
|
<button data-t="sources" class="active">Source</button>
|
|
110
135
|
<button data-t="mediums">Medium</button>
|
|
@@ -113,37 +138,37 @@
|
|
|
113
138
|
</h3>
|
|
114
139
|
<div class="bars" id="p-sources"></div>
|
|
115
140
|
</div>
|
|
116
|
-
<div class="card panel"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
117
|
-
<div class="card panel"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
118
|
-
<div class="card panel"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
119
|
-
<div class="card panel"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
120
|
-
<div class="card panel hide" id="w-platforms"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
141
|
+
<div class="card panel"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-globe"></use></svg> Locations</h3><div class="bars" id="p-countries"></div></div>
|
|
142
|
+
<div class="card panel"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-browser"></use></svg> Browsers</h3><div class="bars" id="p-browsers"></div></div>
|
|
143
|
+
<div class="card panel"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-os"></use></svg> OS</h3><div class="bars" id="p-os"></div></div>
|
|
144
|
+
<div class="card panel"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-device"></use></svg> Devices</h3><div class="bars" id="p-devices"></div></div>
|
|
145
|
+
<div class="card panel hide" id="w-platforms"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-platform"></use></svg> Platforms
|
|
121
146
|
<span class="tabs" id="pfTabs"><button data-t="platforms" class="active">Platform</button><button data-t="appVersions">App version</button></span>
|
|
122
147
|
</h3><div class="bars" id="p-platforms"></div></div>
|
|
123
|
-
<div class="card panel"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
124
|
-
<div class="card panel"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
148
|
+
<div class="card panel"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-goal"></use></svg> Goals & conversions</h3><div class="bars" id="p-goals"></div></div>
|
|
149
|
+
<div class="card panel"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-lang"></use></svg> Languages</h3><div class="bars" id="p-languages"></div></div>
|
|
125
150
|
</div>
|
|
126
151
|
|
|
127
152
|
<!-- shop insights: revenue by source, site search, broken pages, scroll depth (13.10.2026) -->
|
|
128
153
|
<div class="panels" id="insights" style="margin-top:16px">
|
|
129
|
-
<div class="card panel hide" id="w-revsrc"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
130
|
-
<div class="card panel hide" id="w-revpf"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
131
|
-
<div class="card panel hide" id="w-search"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
154
|
+
<div class="card panel hide" id="w-revsrc"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-revenue"></use></svg> Revenue by source <span class="muted hint">first source of the buying visit</span></h3><div class="bars" id="p-revsrc"></div></div>
|
|
155
|
+
<div class="card panel hide" id="w-revpf"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-platform"></use></svg> Revenue by platform <span class="muted hint">web vs. apps</span></h3><div class="bars" id="p-revpf"></div></div>
|
|
156
|
+
<div class="card panel hide" id="w-search"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-search"></use></svg> Site search
|
|
132
157
|
<span class="tabs" id="searchTabs"><button data-t="top" class="active">Top</button><button data-t="zeroResults">No results</button></span>
|
|
133
158
|
</h3><p class="muted hint" id="searchMeta"></p><div class="bars" id="p-search"></div></div>
|
|
134
|
-
<div class="card panel hide" id="w-404"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
135
|
-
<div class="card panel hide" id="w-scroll"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
159
|
+
<div class="card panel hide" id="w-404"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-404"></use></svg> Broken pages (404)</h3><div class="bars" id="p-404"></div></div>
|
|
160
|
+
<div class="card panel hide" id="w-scroll"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-scroll"></use></svg> Scroll depth <span class="muted hint">avg · share reaching 75 %</span></h3><div class="bars" id="p-scroll"></div></div>
|
|
136
161
|
</div>
|
|
137
162
|
|
|
138
163
|
<!-- visitor journeys (sankey) -->
|
|
139
164
|
<div class="card panel" style="margin-top:16px">
|
|
140
|
-
<h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
165
|
+
<h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-journey"></use></svg> Visitor journeys <span class="muted" id="journeyMeta" style="font-size:.75rem;font-weight:400"></span></h3>
|
|
141
166
|
<div id="journeyWrap" style="overflow-x:auto"><svg id="journey" width="100%" height="340"></svg></div>
|
|
142
167
|
</div>
|
|
143
168
|
|
|
144
169
|
<!-- funnels -->
|
|
145
170
|
<div class="card panel" style="margin-top:16px">
|
|
146
|
-
<h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
171
|
+
<h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-funnel"></use></svg> Funnels
|
|
147
172
|
<span class="tabs"><button id="addFunnelBtn">+ New funnel</button></span>
|
|
148
173
|
</h3>
|
|
149
174
|
<div id="funnels" class="funnels"></div>
|
|
@@ -158,7 +183,7 @@
|
|
|
158
183
|
<!-- Add site modal -->
|
|
159
184
|
<div id="addModal" class="modal-overlay hide">
|
|
160
185
|
<div class="modal">
|
|
161
|
-
<span class="logo em"><svg class="ico big" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
186
|
+
<span class="logo em"><svg class="ico big" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-site"></use></svg></span>
|
|
162
187
|
<h2>Add a site</h2>
|
|
163
188
|
<p>Enter the domain you want to track.</p>
|
|
164
189
|
<div id="addErr" class="err"></div>
|
|
@@ -171,13 +196,13 @@
|
|
|
171
196
|
<!-- Snippet modal: website + apps (13.10.2026) -->
|
|
172
197
|
<div id="snippetModal" class="modal-overlay hide">
|
|
173
198
|
<div class="modal modal-wide">
|
|
174
|
-
<span class="logo em"><svg class="ico big" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
199
|
+
<span class="logo em"><svg class="ico big" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-code"></use></svg></span>
|
|
175
200
|
<h2>Add tracking</h2>
|
|
176
201
|
<p style="margin-bottom:12px">for <strong id="snipDomain"></strong>. Same dashboard for website and apps.</p>
|
|
177
202
|
<div class="tabs snip-tabs" id="snipTabs">
|
|
178
|
-
<button data-t="web" class="active"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
179
|
-
<button data-t="flutter"><svg class="ico brand" style="color:#02569B" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
180
|
-
<button data-t="other"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
203
|
+
<button data-t="web" class="active"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-web"></use></svg>Website</button>
|
|
204
|
+
<button data-t="flutter"><svg class="ico brand" style="color:#02569B" aria-hidden="true"><use href="/assets/icons.svg?v=2#b-flutter"></use></svg>Flutter app</button>
|
|
205
|
+
<button data-t="other"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-mobile"></use></svg>Other apps</button>
|
|
181
206
|
</div>
|
|
182
207
|
<p class="muted snip-step" id="snipStep1"></p>
|
|
183
208
|
<div class="snippet" id="snippetCode"></div>
|
|
@@ -193,7 +218,7 @@
|
|
|
193
218
|
<!-- Share modal -->
|
|
194
219
|
<div id="shareModal" class="modal-overlay hide">
|
|
195
220
|
<div class="modal">
|
|
196
|
-
<span class="logo em"><svg class="ico big" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
221
|
+
<span class="logo em"><svg class="ico big" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-share"></use></svg></span>
|
|
197
222
|
<h2>Share dashboard</h2>
|
|
198
223
|
<p>Anyone with this link can view stats for <strong id="shareDomain"></strong> — read-only, no login.</p>
|
|
199
224
|
<div class="snippet" id="shareUrl" style="word-break:break-all"></div>
|
|
@@ -206,7 +231,7 @@
|
|
|
206
231
|
<!-- Funnel builder modal -->
|
|
207
232
|
<div id="funnelModal" class="modal-overlay hide">
|
|
208
233
|
<div class="modal">
|
|
209
|
-
<span class="logo em"><svg class="ico big" aria-hidden="true"><use href="/assets/icons.svg?v=
|
|
234
|
+
<span class="logo em"><svg class="ico big" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-funnel"></use></svg></span>
|
|
210
235
|
<h2>New funnel</h2>
|
|
211
236
|
<p>2–8 ordered steps. A visitor advances only after completing the previous step.</p>
|
|
212
237
|
<div id="funnelErr" class="err"></div>
|
|
@@ -233,7 +258,25 @@
|
|
|
233
258
|
</div>
|
|
234
259
|
</div>
|
|
235
260
|
|
|
261
|
+
<!-- Settings: account, security, team -->
|
|
262
|
+
<div id="settingsModal" class="modal-overlay hide">
|
|
263
|
+
<div class="modal modal-wide settings">
|
|
264
|
+
<div class="settings-head">
|
|
265
|
+
<h2>Settings</h2>
|
|
266
|
+
<div class="tabs set-tabs" id="setTabs">
|
|
267
|
+
<button data-t="security" class="active"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-shield"></use></svg>Security</button>
|
|
268
|
+
<button data-t="team"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#i-users"></use></svg>Team</button>
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
<div id="setErr" class="err" role="alert"></div>
|
|
272
|
+
<div id="setSecurity"></div>
|
|
273
|
+
<div id="setTeam" class="hide"></div>
|
|
274
|
+
<button class="btn ghost" id="closeSettings" style="margin-top:18px">Close</button>
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
|
|
236
278
|
<div class="tip" id="tip"></div>
|
|
237
|
-
<script src="/
|
|
279
|
+
<script src="/account.js?v=2.1.0"></script>
|
|
280
|
+
<script src="/app.js?v=2.3.1"></script>
|
|
238
281
|
</body>
|
|
239
282
|
</html>
|
package/public/style.css
CHANGED
|
@@ -409,3 +409,46 @@ header::after { content: ""; position: absolute; left: 18px; right: 18px; bottom
|
|
|
409
409
|
@keyframes taci-pulse { 0%,100% { box-shadow: 0 0 0 3px rgba(45,212,191,.18); } 50% { box-shadow: 0 0 0 6px rgba(45,212,191,.05); } }
|
|
410
410
|
@media (prefers-reduced-motion: reduce) { .realtime .dot { animation: none; } }
|
|
411
411
|
@media (max-width: 760px) { header { margin: 10px 0 18px; top: 8px; padding: 10px 12px; } .brand .subtitle { display: none; } }
|
|
412
|
+
|
|
413
|
+
/* native dropdown lists follow the dark theme (otherwise light text on a white popup) */
|
|
414
|
+
select { color-scheme: dark; }
|
|
415
|
+
select option { background: #0c1a1d; color: var(--text); }
|
|
416
|
+
|
|
417
|
+
/* ── settings: security + team (2.1) ───────────────────────────────────────── */
|
|
418
|
+
.modal.settings { text-align: left; max-width: 680px; }
|
|
419
|
+
.settings-head { display: flex; align-items: center; gap: 14px; margin-bottom: 12px; flex-wrap: wrap; }
|
|
420
|
+
.settings-head h2 { margin: 0; }
|
|
421
|
+
.set-tabs { margin-left: auto; display: inline-flex; gap: 2px; background: rgba(255,255,255,.03); border: 1px solid var(--line); border-radius: 9px; padding: 2px; }
|
|
422
|
+
.set-tabs button { background: transparent; border: none; color: var(--text-dim); padding: 6px 12px; border-radius: 7px; cursor: pointer; font: 550 0.8rem inherit; font-family: inherit; display: inline-flex; align-items: center; gap: 6px; }
|
|
423
|
+
.set-tabs button.active { background: var(--surface-2); color: var(--text); box-shadow: inset 0 0 0 1px var(--line-strong); }
|
|
424
|
+
.set-card { border: 1px solid var(--line); border-radius: 14px; padding: 16px 18px; margin-bottom: 12px; background: rgba(255,255,255,.02); }
|
|
425
|
+
.set-card h3 { font-size: 0.95rem; font-weight: 650; margin: 0 0 6px; display: flex; align-items: center; gap: 8px; }
|
|
426
|
+
.set-card p { margin-bottom: 12px; font-size: 0.84rem; }
|
|
427
|
+
.modal .set-card input, .modal .set-card select { text-align: left; margin-bottom: 0; }
|
|
428
|
+
.modal .set-card .btn { width: auto; padding: 9px 14px; }
|
|
429
|
+
.set-card > input { margin-bottom: 10px !important; display: block; }
|
|
430
|
+
.set-card > .btn { width: 100%; }
|
|
431
|
+
.set-inline { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-top: 8px; }
|
|
432
|
+
.set-inline input, .set-inline select { flex: 1 1 160px; min-width: 0; }
|
|
433
|
+
.set-row { display: flex; align-items: center; gap: 10px; padding: 9px 0; border-top: 1px solid var(--line); font-size: 0.86rem; }
|
|
434
|
+
.set-row:first-of-type { border-top: none; }
|
|
435
|
+
.set-row > span:first-child { flex: 1; min-width: 0; display: inline-flex; align-items: center; gap: 7px; flex-wrap: wrap; }
|
|
436
|
+
.team-row > span:first-child { display: block; }
|
|
437
|
+
.team-row select { width: auto; padding: 6px 8px; font-size: 0.8rem; }
|
|
438
|
+
.row-actions { display: inline-flex; gap: 6px; }
|
|
439
|
+
.btn.mini, .modal .btn.mini { padding: 5px 10px; font-size: 0.76rem; width: auto; }
|
|
440
|
+
.small { font-size: 0.76rem; }
|
|
441
|
+
.pill { font-size: 0.66rem; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; padding: 2px 7px; border-radius: 99px; border: 1px solid var(--line-strong); color: var(--text-dim); }
|
|
442
|
+
.totp-qr { display: grid; place-items: center; margin: 6px 0 10px; }
|
|
443
|
+
.totp-qr img { width: 180px; height: 180px; background: #fff; border-radius: 12px; padding: 8px; }
|
|
444
|
+
.snippet.center { text-align: center; letter-spacing: .08em; }
|
|
445
|
+
.snippet.recovery { columns: 2; text-align: center; font-size: 0.86rem; line-height: 1.9; }
|
|
446
|
+
.site-pick { display: flex; flex-wrap: wrap; gap: 6px 14px; margin: 10px 0 4px; font-size: 0.82rem; }
|
|
447
|
+
.site-pick label { display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }
|
|
448
|
+
.modal .site-pick input { width: auto; margin: 0; accent-color: var(--accent-strong, var(--accent)); }
|
|
449
|
+
.site-edit { padding: 4px 0 10px; }
|
|
450
|
+
#user, #pw { margin-bottom: 10px; }
|
|
451
|
+
#login .btn.ghost .ico, #settingsBtn .ico { margin-right: 6px; }
|
|
452
|
+
/* viewers: hide everything that would only answer with 403 */
|
|
453
|
+
body.role-viewer #addSiteBtn, body.role-viewer #shareBtn, body.role-viewer #addFunnelBtn { display: none !important; }
|
|
454
|
+
@media (max-width: 560px) { #meName { display: none; } .team-row { flex-wrap: wrap; } }
|
package/server.js
CHANGED
|
@@ -216,14 +216,16 @@ function visitorHash(siteId, ip, ua, day) {
|
|
|
216
216
|
if (!salt) return crypto.randomBytes(12).toString('hex'); // unlinkable one-off visitor
|
|
217
217
|
return crypto.createHash('sha256').update(`${INGEST_SALT}|${salt}|${day}|${siteId}|${ip}|${ua}`).digest('hex').slice(0, 24);
|
|
218
218
|
}
|
|
219
|
+
// The client writes the FIRST X-Forwarded-For entry itself, so it must never be used for rate limits.
|
|
220
|
+
// req.ip honours 'trust proxy': with TRUST_PROXY=1 (default, one reverse proxy in front) it is the
|
|
221
|
+
// entry that proxy appended. Set TRUST_PROXY=0 when the server is reachable without a proxy.
|
|
219
222
|
function clientIp(req) {
|
|
220
|
-
|
|
221
|
-
return xff || req.socket.remoteAddress || '0.0.0.0';
|
|
223
|
+
return req.ip || req.socket.remoteAddress || '0.0.0.0';
|
|
222
224
|
}
|
|
223
225
|
|
|
224
226
|
// ── app ─────────────────────────────────────────────────────────────────────
|
|
225
227
|
const app = express();
|
|
226
|
-
app.set('trust proxy', 1);
|
|
228
|
+
app.set('trust proxy', Number.isFinite(+process.env.TRUST_PROXY) && process.env.TRUST_PROXY !== '' && process.env.TRUST_PROXY != null ? +process.env.TRUST_PROXY : 1);
|
|
227
229
|
// Accept both application/json (dashboard) and text/plain (cross-origin beacons).
|
|
228
230
|
app.use(express.json({ limit: '32kb', type: ['application/json', 'text/plain'] }));
|
|
229
231
|
app.disable('x-powered-by');
|
|
@@ -326,35 +328,16 @@ app.options('/api/collect', (_req, res) => { res.set('Access-Control-Allow-Origi
|
|
|
326
328
|
app.post('/api/collect', collect);
|
|
327
329
|
app.get('/api/collect', collect);
|
|
328
330
|
|
|
329
|
-
// ── auth
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
app
|
|
333
|
-
|
|
334
|
-
const pw = String((req.body && req.body.password) || '');
|
|
335
|
-
const ok = pw.length === WEB_PASSWORD.length && crypto.timingSafeEqual(Buffer.from(pw), Buffer.from(WEB_PASSWORD));
|
|
336
|
-
if (!ok) return res.status(401).json({ error: 'Wrong password' });
|
|
337
|
-
const token = newToken();
|
|
338
|
-
sessions.set(token, { expires: Date.now() + 7 * 864e5 });
|
|
339
|
-
res.json({ token });
|
|
331
|
+
// ── auth: accounts, team, optional 2FA + passkeys (auth.js) ───────────────────
|
|
332
|
+
const { setupAuth } = require('./auth');
|
|
333
|
+
const { auth, requireRole, siteAllowed } = setupAuth({
|
|
334
|
+
app, db, WEB_PASSWORD, API_KEY, rateLimited, clientIp,
|
|
335
|
+
allSiteIds: () => db.prepare('SELECT id FROM sites').all().map((r) => r.id),
|
|
340
336
|
});
|
|
341
|
-
|
|
342
|
-
if (!API_KEY) return false;
|
|
343
|
-
const hdr = String(req.headers['authorization'] || '');
|
|
344
|
-
const key = hdr.startsWith('Bearer ') ? hdr.slice(7) : String(req.headers['x-api-key'] || '');
|
|
345
|
-
return key.length === API_KEY.length && crypto.timingSafeEqual(Buffer.from(key), Buffer.from(API_KEY));
|
|
346
|
-
}
|
|
347
|
-
function auth(req, res, next) {
|
|
348
|
-
if (checkApiKey(req)) return next(); // agents / CLI
|
|
349
|
-
const t = req.headers['x-session-token'];
|
|
350
|
-
const s = t && sessions.get(t);
|
|
351
|
-
if (s && s.expires > Date.now()) return next();
|
|
352
|
-
if (s) sessions.delete(t);
|
|
353
|
-
res.status(401).json({ error: 'Not authenticated' });
|
|
354
|
-
}
|
|
337
|
+
const adminOnly = requireRole('admin');
|
|
355
338
|
|
|
356
339
|
// share-token auth: read-only stats access scoped to exactly one site.
|
|
357
|
-
// Accepted as ?share=<token>; the handler must
|
|
340
|
+
// Accepted as ?share=<token>; the handler must check siteOk(req, site).
|
|
358
341
|
const getShare = db.prepare('SELECT * FROM shares WHERE token = ?');
|
|
359
342
|
function authOrShare(req, res, next) {
|
|
360
343
|
const tok = String(req.query.share || '');
|
|
@@ -364,10 +347,13 @@ function authOrShare(req, res, next) {
|
|
|
364
347
|
}
|
|
365
348
|
return auth(req, res, next);
|
|
366
349
|
}
|
|
367
|
-
|
|
350
|
+
// share links see exactly their site; viewers only the sites they were given
|
|
351
|
+
const siteOk = (req, site) => siteAllowed(req, site.id);
|
|
352
|
+
const NO_SITE = { error: 'You have no access to this site' };
|
|
353
|
+
function newToken() { return crypto.randomBytes(24).toString('hex'); }
|
|
368
354
|
|
|
369
355
|
// ── sites management ─────────────────────────────────────────────────────────
|
|
370
|
-
const sitesListHandler = (
|
|
356
|
+
const sitesListHandler = (req, res) => res.json(allSites.all().filter((st) => siteAllowed(req, st.id)));
|
|
371
357
|
const sitesCreateHandler = (req, res) => {
|
|
372
358
|
const rawDomain = norm(req.body && req.body.domain);
|
|
373
359
|
let domain = rawDomain;
|
|
@@ -382,8 +368,8 @@ const sitesCreateHandler = (req, res) => {
|
|
|
382
368
|
res.json(getSiteByDomain.get(domain));
|
|
383
369
|
};
|
|
384
370
|
app.get('/api/sites', auth, sitesListHandler);
|
|
385
|
-
app.post('/api/sites',
|
|
386
|
-
app.delete('/api/sites/:id',
|
|
371
|
+
app.post('/api/sites', adminOnly, sitesCreateHandler);
|
|
372
|
+
app.delete('/api/sites/:id', adminOnly, (req, res) => {
|
|
387
373
|
const id = Number(req.params.id);
|
|
388
374
|
db.prepare('DELETE FROM events WHERE site_id = ?').run(id);
|
|
389
375
|
db.prepare('DELETE FROM sites WHERE id = ?').run(id);
|
|
@@ -403,7 +389,7 @@ app.get('/api/v1', (_req, res) => res.json({
|
|
|
403
389
|
filters: 'add any of country,source,page,browser,os,device,lang,medium,campaign,goal to /stats to segment',
|
|
404
390
|
}));
|
|
405
391
|
app.get('/api/v1/sites', auth, sitesListHandler);
|
|
406
|
-
app.post('/api/v1/sites',
|
|
392
|
+
app.post('/api/v1/sites', adminOnly, sitesCreateHandler);
|
|
407
393
|
|
|
408
394
|
// ── stats ────────────────────────────────────────────────────────────────────
|
|
409
395
|
const PERIODS = { today: 1, '7d': 7, '30d': 30, '90d': 90, '12mo': 365 };
|
|
@@ -419,7 +405,10 @@ function rangeFor(query) {
|
|
|
419
405
|
}
|
|
420
406
|
const period = PERIODS[query.period] ? query.period : '7d';
|
|
421
407
|
const days = PERIODS[period];
|
|
422
|
-
|
|
408
|
+
// Whole UTC days: "7d" = today + the 6 full days before it. Starting at now - 7×24h would
|
|
409
|
+
// cut the oldest day off at the current clock time and undercount it.
|
|
410
|
+
const today0 = Date.parse(new Date(now).toISOString().slice(0, 10) + 'T00:00:00Z');
|
|
411
|
+
const start = today0 - (days - 1) * 864e5;
|
|
423
412
|
return { start, end: now, days, period, hourly: period === 'today' };
|
|
424
413
|
}
|
|
425
414
|
|
|
@@ -449,7 +438,7 @@ function eventPlatformFilter(query) {
|
|
|
449
438
|
function statsHandler(req, res) {
|
|
450
439
|
const site = getSiteByDomain.get(norm(req.query.site));
|
|
451
440
|
if (!site) return res.status(404).json({ error: 'Unknown site' });
|
|
452
|
-
if (!
|
|
441
|
+
if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
|
|
453
442
|
const sid = site.id;
|
|
454
443
|
const { start, end, days, period, hourly } = rangeFor(req.query);
|
|
455
444
|
const { clauses, params, goal } = buildFilters(req.query);
|
|
@@ -666,7 +655,7 @@ app.get('/api/v1/stats', authOrShare, statsHandler);
|
|
|
666
655
|
function realtimeHandler(req, res) {
|
|
667
656
|
const site = getSiteByDomain.get(norm(req.query.site));
|
|
668
657
|
if (!site) return res.status(404).json({ error: 'Unknown site' });
|
|
669
|
-
if (!
|
|
658
|
+
if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
|
|
670
659
|
const sid = site.id, now = Date.now(), since = now - 30 * 60000;
|
|
671
660
|
const online = db.prepare('SELECT COUNT(DISTINCT vh) v FROM events WHERE site_id = ? AND name IS NULL AND ts >= ?').get(sid, now - 5 * 60000).v;
|
|
672
661
|
const rows = db.prepare('SELECT CAST((ts/60000) AS INT) b, COUNT(*) pv, COUNT(DISTINCT vh) v FROM events WHERE site_id = ? AND name IS NULL AND ts >= ? GROUP BY b').all(sid, since);
|
|
@@ -680,18 +669,20 @@ app.get('/api/realtime', authOrShare, realtimeHandler);
|
|
|
680
669
|
app.get('/api/v1/realtime', authOrShare, realtimeHandler);
|
|
681
670
|
|
|
682
671
|
// ── share links: create / list / revoke (owner only) + resolve (public) ──────────
|
|
683
|
-
app.get('/api/shares',
|
|
684
|
-
const rows = db.prepare('SELECT s.token, s.created, st.domain FROM shares s JOIN sites st ON st.id = s.site_id ORDER BY s.created DESC').all()
|
|
672
|
+
app.get('/api/shares', adminOnly, (req, res) => {
|
|
673
|
+
const rows = db.prepare('SELECT s.token, s.created, s.site_id, st.domain FROM shares s JOIN sites st ON st.id = s.site_id ORDER BY s.created DESC').all()
|
|
674
|
+
.filter((r) => siteAllowed(req, r.site_id)).map(({ site_id, ...r }) => r);
|
|
685
675
|
res.json(rows);
|
|
686
676
|
});
|
|
687
|
-
app.post('/api/shares',
|
|
677
|
+
app.post('/api/shares', adminOnly, (req, res) => {
|
|
688
678
|
const site = getSiteByDomain.get(norm(req.body && req.body.site));
|
|
689
679
|
if (!site) return res.status(404).json({ error: 'Unknown site' });
|
|
680
|
+
if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
|
|
690
681
|
const token = newToken();
|
|
691
682
|
db.prepare('INSERT INTO shares (token, site_id, created) VALUES (?, ?, ?)').run(token, site.id, Date.now());
|
|
692
683
|
res.json({ token, domain: site.domain, url: '/share/' + token });
|
|
693
684
|
});
|
|
694
|
-
app.delete('/api/shares/:token',
|
|
685
|
+
app.delete('/api/shares/:token', adminOnly, (req, res) => {
|
|
695
686
|
db.prepare('DELETE FROM shares WHERE token = ?').run(String(req.params.token));
|
|
696
687
|
res.json({ ok: true });
|
|
697
688
|
});
|
|
@@ -707,11 +698,13 @@ app.get('/api/share-info/:token', (req, res) => {
|
|
|
707
698
|
app.get('/api/funnels', auth, (req, res) => {
|
|
708
699
|
const site = getSiteByDomain.get(norm(req.query.site));
|
|
709
700
|
if (!site) return res.status(404).json({ error: 'Unknown site' });
|
|
701
|
+
if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
|
|
710
702
|
res.json(db.prepare('SELECT id, name, steps, created FROM funnels WHERE site_id = ? ORDER BY id').all(site.id).map((f) => ({ ...f, steps: JSON.parse(f.steps) })));
|
|
711
703
|
});
|
|
712
|
-
app.post('/api/funnels',
|
|
704
|
+
app.post('/api/funnels', adminOnly, (req, res) => {
|
|
713
705
|
const site = getSiteByDomain.get(norm(req.body && req.body.site));
|
|
714
706
|
if (!site) return res.status(404).json({ error: 'Unknown site' });
|
|
707
|
+
if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
|
|
715
708
|
const name = String((req.body && req.body.name) || '').slice(0, 80).trim();
|
|
716
709
|
const raw = (req.body && req.body.steps) || [];
|
|
717
710
|
if (!name) return res.status(400).json({ error: 'Funnel needs a name' });
|
|
@@ -725,7 +718,7 @@ app.post('/api/funnels', auth, (req, res) => {
|
|
|
725
718
|
const info = db.prepare('INSERT INTO funnels (site_id, name, steps, created) VALUES (?, ?, ?, ?)').run(site.id, name, JSON.stringify(steps), Date.now());
|
|
726
719
|
res.json({ id: info.lastInsertRowid, name, steps });
|
|
727
720
|
});
|
|
728
|
-
app.delete('/api/funnels/:id',
|
|
721
|
+
app.delete('/api/funnels/:id', adminOnly, (req, res) => {
|
|
729
722
|
db.prepare('DELETE FROM funnels WHERE id = ?').run(Number(req.params.id));
|
|
730
723
|
res.json({ ok: true });
|
|
731
724
|
});
|
|
@@ -734,7 +727,7 @@ app.get('/api/funnels/:id/stats', authOrShare, (req, res) => {
|
|
|
734
727
|
const f = db.prepare('SELECT * FROM funnels WHERE id = ?').get(Number(req.params.id));
|
|
735
728
|
if (!f) return res.status(404).json({ error: 'Unknown funnel' });
|
|
736
729
|
const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(f.site_id);
|
|
737
|
-
if (!
|
|
730
|
+
if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
|
|
738
731
|
const { start, end } = rangeFor(req.query);
|
|
739
732
|
const steps = JSON.parse(f.steps);
|
|
740
733
|
// one pass per visitor: fetch matching events sorted by time, walk the step ladder.
|
|
@@ -768,7 +761,7 @@ app.use(express.static(path.join(__dirname, 'public')));
|
|
|
768
761
|
app.get('/api/journeys', authOrShare, (req, res) => {
|
|
769
762
|
const site = getSiteByDomain.get(norm(req.query.site));
|
|
770
763
|
if (!site) return res.status(404).json({ error: 'Unknown site' });
|
|
771
|
-
if (!
|
|
764
|
+
if (!siteOk(req, site)) return res.status(403).json(NO_SITE);
|
|
772
765
|
const { start, end } = rangeFor(req.query);
|
|
773
766
|
const maxDepth = Math.min(5, Math.max(2, parseInt(req.query.depth, 10) || 4));
|
|
774
767
|
const GAP = 30 * 60000;
|