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/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
- <input id="pw" type="password" placeholder="Passphrase" autocomplete="current-password">
23
- <button class="btn" id="loginBtn">Sign in</button>
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=1#i-plus"></use></svg>Add site</button>
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=1#i-arrow"></use></svg></span><input type="date" id="toDate" title="To">
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=1#i-download"></use></svg>CSV</button>
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=1#i-share"></use></svg>Share</button>
60
- <button class="btn ghost" id="snippetBtn"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=1#i-code"></use></svg>Embed code</button>
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=1#i-pages"></use></svg> Pages
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=1#i-sources"></use></svg> Sources
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=1#i-globe"></use></svg> Locations</h3><div class="bars" id="p-countries"></div></div>
117
- <div class="card panel"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=1#i-browser"></use></svg> Browsers</h3><div class="bars" id="p-browsers"></div></div>
118
- <div class="card panel"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=1#i-os"></use></svg> OS</h3><div class="bars" id="p-os"></div></div>
119
- <div class="card panel"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=1#i-device"></use></svg> Devices</h3><div class="bars" id="p-devices"></div></div>
120
- <div class="card panel hide" id="w-platforms"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=1#i-platform"></use></svg> Platforms
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=1#i-goal"></use></svg> Goals &amp; conversions</h3><div class="bars" id="p-goals"></div></div>
124
- <div class="card panel"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=1#i-lang"></use></svg> Languages</h3><div class="bars" id="p-languages"></div></div>
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 &amp; 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=1#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>
130
- <div class="card panel hide" id="w-revpf"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=1#i-platform"></use></svg> Revenue by platform <span class="muted hint">web vs. apps</span></h3><div class="bars" id="p-revpf"></div></div>
131
- <div class="card panel hide" id="w-search"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=1#i-search"></use></svg> Site search
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=1#i-404"></use></svg> Broken pages (404)</h3><div class="bars" id="p-404"></div></div>
135
- <div class="card panel hide" id="w-scroll"><h3><svg class="ico h" aria-hidden="true"><use href="/assets/icons.svg?v=1#i-scroll"></use></svg> Scroll depth <span class="muted hint">avg · share reaching 75 %</span></h3><div class="bars" id="p-scroll"></div></div>
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=1#i-journey"></use></svg> Visitor journeys <span class="muted" id="journeyMeta" style="font-size:.75rem;font-weight:400"></span></h3>
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=1#i-funnel"></use></svg> Funnels
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=1#i-site"></use></svg></span>
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=1#i-code"></use></svg></span>
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=1#i-web"></use></svg>Website</button>
179
- <button data-t="flutter"><svg class="ico brand" style="color:#02569B" aria-hidden="true"><use href="/assets/icons.svg?v=1#b-flutter"></use></svg>Flutter app</button>
180
- <button data-t="other"><svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=1#i-mobile"></use></svg>Other apps</button>
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=1#i-share"></use></svg></span>
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=1#i-funnel"></use></svg></span>
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="/app.js?v=2.2.0"></script>
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
- const xff = (req.headers['x-forwarded-for'] || '').split(',')[0].trim();
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 (password → in-memory session token) ──────────────
330
- const sessions = new Map();
331
- function newToken() { return crypto.randomBytes(24).toString('hex'); }
332
- app.post('/auth/login', (req, res) => {
333
- if (rateLimited('login:' + clientIp(req), 10, 15 * 60000)) return res.status(429).json({ error: 'Too many attempts — wait 15 minutes' });
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
- function checkApiKey(req) {
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 verify req.shareSite matches the queried site.
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
- function shareScopeOk(req, site) { return req.shareSiteId == null || req.shareSiteId === site.id; }
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 = (_req, res) => res.json(allSites.all());
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', auth, sitesCreateHandler);
386
- app.delete('/api/sites/:id', auth, (req, res) => {
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', auth, sitesCreateHandler);
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
- const start = period === 'today' ? new Date(new Date(now).toISOString().slice(0, 10)).getTime() : now - days * 864e5;
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 (!shareScopeOk(req, site)) return res.status(403).json({ error: 'Share link is limited to another site' });
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 (!shareScopeOk(req, site)) return res.status(403).json({ error: 'Share link is limited to another site' });
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', auth, (req, res) => {
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', auth, (req, res) => {
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', auth, (req, res) => {
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', auth, (req, res) => {
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', auth, (req, res) => {
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 (!shareScopeOk(req, site)) return res.status(403).json({ error: 'Share link is limited to another site' });
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 (!shareScopeOk(req, site)) return res.status(403).json({ error: 'Share link is limited to another site' });
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;