tacilytics 2.1.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/public/account.js CHANGED
@@ -11,12 +11,25 @@
11
11
  var h = { 'Content-Type': 'application/json' };
12
12
  if (token) h['x-session-token'] = token;
13
13
  return fetch(path, { method: 'POST', headers: h, body: JSON.stringify(body || {}) })
14
- .then(function (r) { return r.json().catch(function () { return {}; }).then(function (j) { return { ok: r.ok, status: r.status, j: j }; }); });
14
+ .then(function (r) {
15
+ // non-JSON (route missing, proxy page) → a visible error in r.j.error, never a silent {} (Tyto)
16
+ return readJson(r).then(function (j) { return { ok: r.ok, status: r.status, j: j || {} }; },
17
+ function (e) { return { ok: false, status: r.status, j: { error: e.message } }; });
18
+ });
19
+ }
20
+ // a body that is not JSON (e.g. the SPA page from a server that lacks the route) is an ERROR, never {} —
21
+ // {} or a string would reach rows.map / t.users and break far from the cause (Kiro, 2.2.0)
22
+ function readJson(r) {
23
+ if (r.status === 204) return Promise.resolve(null); // no body by definition (Kiro)
24
+ var ct = (r.headers && r.headers.get && r.headers.get('content-type')) || '';
25
+ if (!/\bjson\b/i.test(ct)) return Promise.reject(new Error(r.ok ? 'Server did not answer with JSON (HTTP ' + r.status + ')' : 'HTTP ' + r.status));
26
+ return r.json().catch(function () { throw new Error('Server did not answer with JSON (HTTP ' + r.status + ')'); });
15
27
  }
28
+ window.TaciReadJson = readJson; // app.js api() uses the same rule
16
29
  // authenticated calls that also report errors (Taci.api only throws on 401)
17
30
  function call(method, path, body) {
18
31
  return fetch(path, { method: method, headers: { 'Content-Type': 'application/json', 'x-session-token': localStorage.getItem('nyx_token') || '' }, body: body ? JSON.stringify(body) : undefined })
19
- .then(function (r) { return r.json().catch(function () { return {}; }).then(function (j) { if (!r.ok) throw new Error(j.error || ('HTTP ' + r.status)); return j; }); });
32
+ .then(function (r) { return readJson(r).then(function (j) { if (!r.ok) throw new Error((j && j.error) || ('HTTP ' + r.status)); return j; }); });
20
33
  }
21
34
 
22
35
  // ── WebAuthn: JSON options (base64url) ⇄ browser ArrayBuffers ──
@@ -126,16 +139,51 @@
126
139
  $('invPw').addEventListener('keydown', function (e) { if (e.key === 'Enter') go(); });
127
140
  }
128
141
 
142
+ // ── password reset page ──
143
+ function showReset(token) {
144
+ $('app').classList.add('hide'); $('reset').classList.remove('hide');
145
+ fetch('/api/reset/' + token).then(function (r) { return r.json(); }).then(function (r) {
146
+ if (r.error) { $('resetHint').textContent = r.error; $('resetForm').classList.add('hide'); return; }
147
+ $('resetHint').textContent = 'Choose a new password for ' + r.username + '.' + (r.totp ? ' Your two-factor code stays on.' : '');
148
+ $('resetPw').focus();
149
+ });
150
+ var go = function () {
151
+ $('resetErr').textContent = '';
152
+ post('/api/reset/' + token, { password: $('resetPw').value }).then(function (r) {
153
+ if (!r.ok) { $('resetErr').textContent = r.j.error || 'Could not save the password'; return; }
154
+ history.replaceState(null, '', '/');
155
+ $('reset').classList.add('hide');
156
+ $('login').classList.remove('hide'); prepareLogin();
157
+ setTimeout(function () { $('user').value = r.j.username; $('pw').focus(); $('loginHint').textContent = 'Password saved. Sign in with your new password.'; }, 300);
158
+ });
159
+ };
160
+ $('resetBtn').onclick = go;
161
+ $('resetPw').addEventListener('keydown', function (e) { if (e.key === 'Enter') go(); });
162
+ }
163
+
129
164
  // ── settings ──
130
165
  var ROLE_LABEL = { owner: 'Owner', admin: 'Admin', viewer: 'Viewer' };
131
- var ROLE_HELP = { owner: 'everything, including the team', admin: 'sites, share links and funnels', viewer: 'read only' };
166
+ var ROLE_HELP = { owner: 'everything, including the team', admin: 'sites, share links and funnels (all or picked sites)', viewer: 'read only' };
132
167
  var me = null;
133
168
  function setErr(t) { $('setErr').textContent = t || ''; }
169
+ // footer version: only after sign-in (from /api/me), links to the release tag; strict format check so
170
+ // a strange value can never become markup or a link somewhere else
171
+ function showVersion(v) {
172
+ var el = $('appVersion'); if (!el) return;
173
+ el.textContent = '';
174
+ if (typeof v !== 'string' || !/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(v)) return;
175
+ var a = document.createElement('a');
176
+ a.href = 'https://github.com/Open-Cosmic-Software/tacilytics/releases/tag/v' + v;
177
+ a.target = '_blank'; a.rel = 'noopener'; a.textContent = v;
178
+ el.appendChild(document.createTextNode(' ')); el.appendChild(a);
179
+ }
134
180
  function afterBoot() {
135
181
  call('GET', '/api/me').then(function (m) {
136
182
  me = m;
137
183
  $('meName').textContent = m.username || 'Settings';
184
+ showVersion(m.version);
138
185
  document.body.classList.toggle('role-viewer', m.role === 'viewer');
186
+ document.body.classList.toggle('site-limited', !!(m.sites && m.role !== 'owner')); // the operator adds their sites
139
187
  }).catch(function () {});
140
188
  }
141
189
  function openSettings(tab) {
@@ -143,6 +191,7 @@
143
191
  call('GET', '/api/me').then(function (m) {
144
192
  me = m;
145
193
  $('setTabs').querySelector('[data-t="team"]').classList.toggle('hide', m.role !== 'owner');
194
+ $('setTabs').querySelector('[data-t="activity"]').classList.toggle('hide', m.role !== 'owner');
146
195
  showTab(tab || 'security');
147
196
  }).catch(function (e) { setErr(e.message); });
148
197
  }
@@ -150,26 +199,43 @@
150
199
  Array.prototype.forEach.call($('setTabs').children, function (b) { b.classList.toggle('active', b.dataset.t === t); });
151
200
  $('setSecurity').classList.toggle('hide', t !== 'security');
152
201
  $('setTeam').classList.toggle('hide', t !== 'team');
202
+ $('setActivity').classList.toggle('hide', t !== 'activity');
153
203
  setErr('');
154
- if (t === 'security') renderSecurity(); else renderTeam();
204
+ if (t === 'security') renderSecurity(); else if (t === 'team') renderTeam(); else renderActivity();
155
205
  }
156
206
  $('settingsBtn').onclick = function () { openSettings(); };
157
- $('closeSettings').onclick = function () { $('settingsModal').classList.add('hide'); };
207
+ $('closeSettings').onclick = function () { closeModal($('settingsModal')); };
158
208
  $('setTabs').onclick = function (e) { var b = e.target.closest('button'); if (b) showTab(b.dataset.t); };
159
209
  function date(ms) { return ms ? new Date(ms).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }) : '—'; }
160
210
 
161
211
  // security tab
212
+ // language + theme: per browser, for everyone (also before an account exists)
213
+ function prefsCard() {
214
+ var lang = window.TaciLang ? window.TaciLang.get() : 'auto';
215
+ var theme = window.TaciTheme ? window.TaciTheme.get() : 'system';
216
+ var opt = function (v, label, cur) { return '<option value="' + v + '"' + (v === cur ? ' selected' : '') + '>' + label + '</option>'; };
217
+ return '<div class="set-card"><h3>' + ico('i-settings') + 'Appearance</h3>' +
218
+ '<div class="set-inline">' +
219
+ '<label class="small muted" style="flex:0 0 auto">Language</label><select id="prefLang" style="flex:1 1 140px">' + opt('auto', 'Automatic', lang) + opt('en', 'English', lang) + opt('de', 'Deutsch', lang) + '</select>' +
220
+ '<label class="small muted" style="flex:0 0 auto">Theme</label><select id="prefTheme" style="flex:1 1 140px">' + opt('system', 'System', theme) + opt('light', 'Light', theme) + opt('dark', 'Dark', theme) + '</select>' +
221
+ '</div><p class="muted small" style="margin:8px 0 0">Language and appearance are saved in this browser.</p></div>';
222
+ }
223
+ function bindPrefs() {
224
+ if ($('prefLang')) $('prefLang').onchange = function () { window.TaciLang.set(this.value); };
225
+ if ($('prefTheme')) $('prefTheme').onchange = function () { window.TaciTheme.set(this.value); };
226
+ }
162
227
  function renderSecurity() {
163
228
  var el = $('setSecurity');
164
229
  if (me.kind !== 'user') {
165
230
  // legacy passphrase (or API key): offer the first account
166
- el.innerHTML =
231
+ el.innerHTML = prefsCard() +
167
232
  '<div class="set-card"><h3>' + ico('i-user-plus') + 'Create your account</h3>' +
168
233
  '<p class="muted">You are signed in with the shared passphrase. Create an owner account to unlock two-factor sign-in, passkeys and the team. ' +
169
234
  '<strong>After that the passphrase (WEB_PASSWORD) stops working</strong>, so 2FA cannot be bypassed.</p>' +
170
235
  '<input id="bsUser" type="text" placeholder="Username" autocomplete="username" autocapitalize="off" spellcheck="false">' +
171
236
  '<input id="bsPw" type="password" placeholder="Password (at least 10 characters)" autocomplete="new-password">' +
172
237
  '<button class="btn" id="bsBtn">Create owner account</button></div>';
238
+ bindPrefs();
173
239
  $('bsBtn').onclick = function () {
174
240
  setErr('');
175
241
  post('/api/team/bootstrap', { username: $('bsUser').value.trim(), password: $('bsPw').value }, localStorage.getItem('nyx_token')).then(function (r) {
@@ -184,7 +250,7 @@
184
250
  return '<div class="set-row"><span>' + ico('i-key') + esc(k.name) + '</span><span class="muted small">added ' + date(k.created) + (k.last_used ? ' · used ' + date(k.last_used) : '') + '</span>' +
185
251
  '<button class="btn ghost danger mini" data-delpk="' + esc(k.id) + '">Remove</button></div>';
186
252
  }).join('');
187
- el.innerHTML =
253
+ el.innerHTML = prefsCard() +
188
254
  '<div class="set-card"><h3>' + ico('i-key') + 'Passkeys <span class="pill">optional</span></h3>' +
189
255
  '<p class="muted">Sign in with Face ID, Touch ID, Windows Hello or a security key. No password, no code — and it cannot be phished.</p>' +
190
256
  (pk || '<p class="muted small">No passkey yet.</p>') +
@@ -193,15 +259,35 @@
193
259
  '</div>' +
194
260
  '<div class="set-card"><h3>' + ico('i-smartphone') + 'Two-factor code (OTP) <span class="pill">' + (me.totp ? 'on' : 'optional') + '</span></h3>' +
195
261
  (me.totp
196
- ? '<p class="muted">Signing in with your password also asks for a 6-digit code from your authenticator app. Recovery codes left: <strong>' + me.recoveryLeft + '</strong>.</p>' +
262
+ ? '<p class="muted">Signing in with your password also asks for a 6-digit code from your authenticator app.</p><p class="muted small"><span>Recovery codes left:</span> <strong>' + me.recoveryLeft + '</strong></p>' +
197
263
  '<div class="set-inline"><input id="totpPw" type="password" placeholder="Your password" autocomplete="current-password"><button class="btn ghost" id="newRecovery">New recovery codes</button><button class="btn ghost danger" id="totpOff">Turn off</button></div>'
198
264
  : '<p class="muted">Use any authenticator app (1Password, Google Authenticator, Authy, Bitwarden …).</p><div id="totpSetup"><button class="btn" id="totpStart">Set up</button></div>') +
199
265
  '</div>' +
266
+ '<div class="set-card"><h3>' + ico('i-eye') + 'Don\u2019t count my own visits</h3>' +
267
+ '<p class="muted">Open your site once with one of these links, in every browser and on every phone you use. That browser is then ignored on that site (stored in the browser, nothing on the server). The second link undoes it.</p>' +
268
+ ((T().sites() || []).length ? '' : '<p class="muted small">Add a site first, then the links appear here. Or open any of your pages with <code>?tacilytics=off</code> at the end.</p>') +
269
+ (T().sites() || []).map(function (st) {
270
+ var d = 'https://' + st.domain + '/';
271
+ return '<div class="set-row"><span>' + esc(st.domain) + '</span>' +
272
+ '<a class="btn ghost mini" target="_blank" rel="noopener" href="' + esc(d + '?tacilytics=off') + '">Ignore this browser</a>' +
273
+ '<a class="btn ghost mini" target="_blank" rel="noopener" href="' + esc(d + '?tacilytics=on') + '">Count again</a></div>';
274
+ }).join('') +
275
+ '</div>' +
276
+ '<div class="set-card"><h3>' + ico('i-calendar') + 'Weekly report <span class="pill">e-mail</span></h3>' +
277
+ '<p class="muted">Every Monday morning, for each of your sites: key figures, a day-by-day chart, highlights, top pages, sources, countries, devices, goals, shop numbers and your notes — compared with the week before.</p>' +
278
+ '<div id="repBox"><p class="muted small">Loading…</p></div></div>' +
279
+ '<div class="set-card"><h3>' + ico('i-code') + 'API keys</h3>' +
280
+ '<p class="muted">For scripts, agents and the CLI (<code>TACILYTICS_API_KEY</code>). A key acts as you, sees only your sites, and cannot touch the team or your security settings. Read-only unless you untick it.</p>' +
281
+ '<div id="keyList"><p class="muted small">Loading…</p></div>' +
282
+ '<div class="set-inline"><input id="keyName" type="text" placeholder="Name, e.g. reporting script" maxlength="60">' +
283
+ (me.role !== 'viewer' ? '<label class="small muted" style="display:inline-flex;gap:6px;align-items:center;flex:0 0 auto"><input type="checkbox" id="keyRo" checked style="width:auto;margin:0"> read-only</label>' : '') +
284
+ '<button class="btn" id="keyAdd">Create key</button></div><div id="keyOut"></div></div>' +
200
285
  '<div class="set-card"><h3>' + ico('i-shield') + 'Password</h3>' +
201
286
  '<div class="set-inline"><input id="pwOld" type="password" placeholder="Current password" autocomplete="current-password"><input id="pwNew" type="password" placeholder="New password (10+)" autocomplete="new-password"><button class="btn ghost" id="pwSave">Change</button></div>' +
202
287
  '<p class="muted small">Changing it signs you out on all other devices.</p></div>' +
203
288
  '<p class="muted small">Signed in as <strong>' + esc(me.username) + '</strong> · ' + ROLE_LABEL[me.role] + '</p>';
204
289
 
290
+ bindPrefs();
205
291
  el.querySelectorAll('[data-delpk]').forEach(function (b) {
206
292
  b.onclick = function () { if (confirm('Remove this passkey?')) call('DELETE', '/api/me/passkeys/' + encodeURIComponent(b.dataset.delpk)).then(function () { openSettings('security'); }).catch(function (e) { setErr(e.message); }); };
207
293
  });
@@ -233,6 +319,48 @@
233
319
  if ($('newRecovery')) $('newRecovery').onclick = function () {
234
320
  call('POST', '/api/me/recovery', { password: $('totpPw').value }).then(function (r) { showRecovery(r.recoveryCodes); }).catch(function (e) { setErr(e.message); });
235
321
  };
322
+ call('GET', '/api/me/report').then(function (r) {
323
+ if (!r.configured) { $('repBox').innerHTML = '<p class="muted small">E-mail is not set up on this server yet (SMTP_URL and REPORT_FROM in .env).</p>'; return; }
324
+ $('repBox').innerHTML = '<div class="set-inline"><input id="repMail" type="email" placeholder="you@example.com" value="' + esc(r.email || '') + '">' +
325
+ '<label class="small muted" style="display:inline-flex;gap:6px;align-items:center;flex:0 0 auto"><input type="checkbox" id="repOn"' + (r.weekly ? ' checked' : '') + ' style="width:auto;margin:0"> every Monday</label>' +
326
+ '<select id="repLang" style="flex:0 0 auto;width:auto" title="Language of the e-mail"><option value="en">English</option><option value="de">Deutsch</option></select>' +
327
+ '<button class="btn ghost" id="repSave">Save</button><button class="btn ghost" id="repNow">Send one now</button></div>' +
328
+ (r.last ? '<p class="muted small" style="margin:6px 0 0">Last sent ' + date(r.last) + '</p>' : '');
329
+ $('repLang').value = r.lang || ((window.TaciLang && window.TaciLang.current) || 'en');
330
+ $('repLang').setAttribute('data-no-i18n', '');
331
+ $('repSave').onclick = function () {
332
+ setErr('');
333
+ call('POST', '/api/me/report', { email: $('repMail').value.trim(), weekly: $('repOn').checked, lang: $('repLang').value }).then(function () { $('repSave').textContent = 'Saved ✓'; }).catch(function (e) { setErr(e.message); });
334
+ };
335
+ $('repNow').onclick = function () {
336
+ setErr(''); $('repNow').textContent = 'Sending…';
337
+ call('POST', '/api/me/report', { email: $('repMail').value.trim(), weekly: $('repOn').checked, lang: $('repLang').value })
338
+ .then(function () { return call('POST', '/api/me/report/send'); })
339
+ .then(function () { $('repNow').textContent = 'Sent ✓'; })
340
+ .catch(function (e) { $('repNow').textContent = 'Send one now'; setErr(e.message); });
341
+ };
342
+ }).catch(function () { $('repBox').innerHTML = ''; });
343
+ function loadKeys() {
344
+ call('GET', '/api/me/keys').then(function (keys) {
345
+ $('keyList').innerHTML = keys.length ? keys.map(function (k) {
346
+ return '<div class="set-row"><span>' + ico('i-code') + esc(k.name) + ' <code class="muted small">' + esc(k.prefix) + '…</code>' + (k.read_only ? ' <span class="pill">read-only</span>' : ' <span class="pill">read + write</span>') + '</span>' +
347
+ '<span class="muted small">' + (k.last_used ? 'used ' + date(k.last_used) : 'never used') + '</span><button class="btn ghost danger mini" data-delkey="' + k.id + '">Revoke</button></div>';
348
+ }).join('') : '<p class="muted small">No keys yet.</p>';
349
+ $('keyList').querySelectorAll('[data-delkey]').forEach(function (b) {
350
+ b.onclick = function () { if (confirm('Revoke this key? Scripts using it stop working immediately.')) call('DELETE', '/api/me/keys/' + b.dataset.delkey).then(loadKeys).catch(function (e) { setErr(e.message); }); };
351
+ });
352
+ }).catch(function (e) { setErr(e.message); });
353
+ }
354
+ loadKeys();
355
+ $('keyAdd').onclick = function () {
356
+ setErr('');
357
+ call('POST', '/api/me/keys', { name: $('keyName').value.trim(), readOnly: $('keyRo') ? $('keyRo').checked : true }).then(function (k) {
358
+ $('keyName').value = '';
359
+ $('keyOut').innerHTML = '<p class="muted small" style="margin:10px 0 4px">Copy it now — it is shown only once.</p><div class="snippet">' + esc(k.key) + '</div><button class="btn ghost mini" id="keyCopy">Copy key</button>';
360
+ $('keyCopy').onclick = function () { navigator.clipboard.writeText(k.key); this.textContent = 'Copied ✓'; };
361
+ loadKeys();
362
+ }).catch(function (e) { setErr(e.message); });
363
+ };
236
364
  $('pwSave').onclick = function () {
237
365
  setErr('');
238
366
  call('POST', '/api/me/password', { password: $('pwOld').value, newPassword: $('pwNew').value }).then(function () {
@@ -245,8 +373,23 @@
245
373
  '<div class="set-card"><h3>' + ico('i-shield') + 'Save your recovery codes</h3>' +
246
374
  '<p class="muted">Each code works once, instead of a 6-digit code. Keep them somewhere safe — they are shown only now.</p>' +
247
375
  '<div class="snippet recovery">' + codes.map(esc).join('<br>') + '</div>' +
248
- '<div class="set-inline"><button class="btn ghost" id="copyRec">Copy</button><button class="btn" id="recDone">I saved them</button></div></div>';
376
+ '<div class="set-inline"><button class="btn ghost" id="dlRec">' + ico('i-download') + 'Download</button><button class="btn ghost" id="copyRec">Copy</button><button class="btn" id="recDone">I saved them</button></div></div>';
249
377
  $('copyRec').onclick = function () { navigator.clipboard.writeText(codes.join('\n')); $('copyRec').textContent = 'Copied ✓'; };
378
+ $('dlRec').onclick = function () {
379
+ // plain .txt, built in the browser; the codes never go to the server again
380
+ var host = location.hostname;
381
+ var text = 'Tacilytics recovery codes\n' +
382
+ 'Server: ' + location.origin + '\n' +
383
+ 'Account: ' + ((me && me.username) || '') + '\n' +
384
+ 'Created: ' + new Date().toISOString().slice(0, 10) + '\n\n' +
385
+ 'Each code works once, instead of a 6-digit code.\n\n' + codes.join('\n') + '\n';
386
+ var url = URL.createObjectURL(new Blob([text], { type: 'text/plain;charset=utf-8' }));
387
+ var a = document.createElement('a');
388
+ a.href = url; a.download = 'tacilytics-recovery-codes-' + host.replace(/[^a-z0-9.-]/gi, '_') + '.txt';
389
+ document.body.appendChild(a); a.click(); a.remove();
390
+ setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
391
+ $('dlRec').innerHTML = ico('i-check') + 'Downloaded';
392
+ };
250
393
  $('recDone').onclick = function () { openSettings('security'); };
251
394
  }
252
395
 
@@ -275,15 +418,16 @@
275
418
  var rows = t.users.map(function (u) {
276
419
  var self = me && me.id === u.id;
277
420
  return '<div class="set-row team-row" data-uid="' + u.id + '">' +
278
- '<span><strong>' + esc(u.username) + '</strong>' + (self ? ' <span class="muted small">(you)</span>' : '') +
279
- '<br><span class="muted small">' + (u.role === 'viewer' ? siteNames(u.sites) + ' · ' : '') + (u.totp ? '2FA on' : 'no 2FA') + (u.passkeys ? ' · ' + u.passkeys + ' passkey' + (u.passkeys > 1 ? 's' : '') : '') + ' · last sign-in ' + date(u.last_login) + '</span></span>' +
421
+ '<span><strong data-no-i18n>' + esc(u.username) + '</strong>' + (self ? ' <span class="muted small">(you)</span>' : '') +
422
+ '<br><span class="muted small">' + (u.role !== 'owner' ? siteNames(u.sites) + ' · ' : '') + (u.totp ? '2FA on' : 'no 2FA') + (u.passkeys ? ' · ' + u.passkeys + ' passkey' + (u.passkeys > 1 ? 's' : '') : '') + ' · last sign-in ' + date(u.last_login) + '</span></span>' +
280
423
  '<select data-role>' + ['owner', 'admin', 'viewer'].map(function (r) { return '<option value="' + r + '"' + (r === u.role ? ' selected' : '') + '>' + ROLE_LABEL[r] + '</option>'; }).join('') + '</select>' +
281
- '<span class="row-actions">' + (u.role === 'viewer' ? '<button class="btn ghost mini" data-sites>Sites</button>' : '') +
424
+ '<span class="row-actions">' + (u.role !== 'owner' ? '<button class="btn ghost mini" data-sites>Sites</button>' : '') +
425
+ (!self ? '<button class="btn ghost mini" data-resetlink title="One-time link to set a new password (24 h)">Password link</button>' : '') +
282
426
  (u.totp && !self ? '<button class="btn ghost mini" data-reset2fa>Reset 2FA</button>' : '') +
283
427
  (!self ? '<button class="btn ghost danger mini" data-remove>Remove</button>' : '') + '</span></div>';
284
428
  }).join('');
285
429
  var invites = t.invites.map(function (i) {
286
- return '<div class="set-row"><span>' + ico('i-user-plus') + ROLE_LABEL[i.role] + (i.role === 'viewer' ? ' · ' + esc(siteNames(i.sites)) : '') + '</span><span class="muted small">expires ' + date(i.expires) + '</span><button class="btn ghost danger mini" data-delinv="' + i.id + '">Revoke</button></div>';
430
+ return '<div class="set-row"><span>' + ico('i-user-plus') + ROLE_LABEL[i.role] + (i.role !== 'owner' ? ' · ' + esc(siteNames(i.sites)) : '') + '</span><span class="muted small">expires ' + date(i.expires) + '</span><button class="btn ghost danger mini" data-delinv="' + i.id + '">Revoke</button></div>';
287
431
  }).join('');
288
432
  el.innerHTML =
289
433
  '<div class="set-card"><h3>' + ico('i-users') + 'Members</h3>' + rows + '</div>' +
@@ -293,11 +437,11 @@
293
437
  '<div id="invSites">' + sitePicker('invPick', null) + '</div>' +
294
438
  '<div id="invOut"></div>' + (invites ? '<h4 class="muted small" style="margin-top:12px">Open invites</h4>' + invites : '') + '</div>';
295
439
 
296
- $('invRole').onchange = function () { $('invSites').classList.toggle('hide', this.value !== 'viewer'); };
440
+ $('invRole').onchange = function () { $('invSites').classList.toggle('hide', this.value === 'owner'); };
297
441
  $('invCreate').onclick = function () {
298
442
  setErr('');
299
443
  var role = $('invRole').value;
300
- call('POST', '/api/team/invites', { role: role, sites: role === 'viewer' ? pickedSites('invPick') : null }).then(function (r) {
444
+ call('POST', '/api/team/invites', { role: role, sites: role !== 'owner' ? pickedSites('invPick') : null }).then(function (r) {
301
445
  var url = location.origin + r.url;
302
446
  $('invOut').innerHTML = '<div class="snippet" id="invUrl">' + esc(url) + '</div><button class="btn ghost" id="invCopy">Copy link</button>';
303
447
  $('invCopy').onclick = function () { navigator.clipboard.writeText(url); $('invCopy').textContent = 'Copied ✓'; };
@@ -314,6 +458,18 @@
314
458
  };
315
459
  var rm = row.querySelector('[data-remove]');
316
460
  if (rm) rm.onclick = function () { if (confirm('Remove this member? They are signed out immediately.')) call('DELETE', '/api/team/users/' + uid).then(renderTeam).catch(function (e) { setErr(e.message); }); };
461
+ var rl = row.querySelector('[data-resetlink]');
462
+ if (rl) rl.onclick = function () {
463
+ setErr('');
464
+ call('POST', '/api/team/users/' + uid + '/reset-link').then(function (r) {
465
+ var url = location.origin + r.url;
466
+ var old = row.nextElementSibling; if (old && old.classList.contains('site-edit')) old.remove();
467
+ var box = document.createElement('div'); box.className = 'site-edit';
468
+ box.innerHTML = '<p class="muted small" style="margin:0 0 6px">Send this to ' + esc(r.username) + '. It works once, for 24 hours. Their 2FA stays on.</p><div class="snippet">' + esc(url) + '</div><button class="btn ghost mini" data-copy>Copy link</button>';
469
+ row.after(box);
470
+ box.querySelector('[data-copy]').onclick = function () { navigator.clipboard.writeText(url); this.textContent = 'Copied ✓'; };
471
+ }).catch(function (e) { setErr(e.message); });
472
+ };
317
473
  var r2 = row.querySelector('[data-reset2fa]');
318
474
  if (r2) r2.onclick = function () { if (confirm('Turn off 2FA for this member? Use this when they lost their phone.')) call('POST', '/api/team/users/' + uid + '/reset-2fa').then(renderTeam).catch(function (e) { setErr(e.message); }); };
319
475
  var st = row.querySelector('[data-sites]');
@@ -331,5 +487,85 @@
331
487
  }).catch(function (e) { setErr(e.message); });
332
488
  }
333
489
 
334
- window.TaciAccount = { prepareLogin: prepareLogin, showInvite: showInvite, afterBoot: afterBoot };
490
+ // activity (audit log, owner)
491
+ var ACTION_LABEL = {
492
+ 'site.add': 'added site', 'site.remove': 'removed site', 'share.add': 'created share link', 'share.revoke': 'revoked share link',
493
+ 'note.add': 'added note', 'note.remove': 'removed note', 'funnel.add': 'added funnel', 'funnel.remove': 'removed funnel',
494
+ 'team.first-owner': 'created the first account', 'team.invite': 'created invite', 'team.invite.revoke': 'revoked invite',
495
+ 'team.join': 'joined via invite', 'team.change': 'changed role', 'team.remove': 'removed member', 'team.2fa.reset': 'reset 2FA of',
496
+ 'team.reset-link': 'created password link for', 'me.password': 'changed own password', 'me.password.reset-link': 'set a new password via link',
497
+ 'me.2fa.on': 'turned 2FA on', 'me.2fa.off': 'turned 2FA off', 'me.recovery.new': 'made new recovery codes',
498
+ 'cli.reset': 'reset the login (password, 2FA, sessions) of', 'me.report.on': 'turned the weekly report on', 'me.report.off': 'turned the weekly report off',
499
+ 'alert.add': 'added alert', 'alert.remove': 'removed alert', 'alert.on': 'turned alert on', 'alert.off': 'turned alert off',
500
+ 'me.passkey.add': 'added passkey', 'me.passkey.remove': 'removed passkey', 'me.apikey.add': 'created API key', 'me.apikey.revoke': 'revoked API key',
501
+ };
502
+ function renderActivity(before, append) {
503
+ var el = $('setActivity');
504
+ call('GET', '/api/audit?limit=60' + (before ? '&before=' + before : '')).then(function (rows) {
505
+ var html = rows.map(function (e) {
506
+ var when = new Date(e.ts);
507
+ return '<div class="set-row act-row"><span class="muted small act-time" title="' + esc(when.toISOString()) + '">' +
508
+ esc(when.toLocaleDateString(undefined, { day: 'numeric', month: 'short' }) + ' ' + when.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })) + '</span>' +
509
+ '<span><strong' + (e.actor && e.actor !== 'API key' && e.actor !== 'passphrase' ? ' data-no-i18n' : '') + '>' + esc(e.actor || 'someone') + '</strong> ' + esc(ACTION_LABEL[e.action] || e.action) + (e.target ? ' <span class="act-target">' + esc(e.target) + '</span>' : '') + '</span>' +
510
+ '<span class="muted small act-ip">' + esc(e.ip || '') + '</span></div>';
511
+ }).join('');
512
+ if (!append) el.innerHTML = '<div class="set-card"><h3>' + ico('i-timer') + 'Activity</h3><p class="muted">Every change by anyone on the team, newest first. Kept for a year (AUDIT_RETENTION_DAYS).</p><div id="actRows"></div><div id="actMore"></div></div>';
513
+ $('actRows').insertAdjacentHTML('beforeend', html || (append ? '' : '<p class="muted small">Nothing yet.</p>'));
514
+ $('actMore').innerHTML = rows.length === 60 ? '<button class="btn ghost mini" id="actMoreBtn">Older</button>' : '';
515
+ if (rows.length === 60) $('actMoreBtn').onclick = function () { renderActivity(rows[rows.length - 1].ts, true); };
516
+ }).catch(function (e) { setErr(e.message); });
517
+ }
518
+
519
+ window.TaciAccount = { prepareLogin: prepareLogin, showInvite: showInvite, showReset: showReset, afterBoot: afterBoot };
520
+
521
+ // ── closing modals: ONE function for every exit (X, Close button, Esc, click on the backdrop) ──
522
+ // login / reset / invite are not closable — there is nothing behind them without signing in.
523
+ var LOCKED = { login: 1, reset: 1, invite: 1 };
524
+ // form modals (data-guard): Esc and the backdrop ask before throwing away what was typed (Fabian, variant C).
525
+ // "Changed" = the fields differ from a snapshot taken when the modal opened (or after a save) — typing
526
+ // something and deleting it again is no change. X and Cancel are a deliberate click and close directly.
527
+ function fields(ov) { return [].map.call(ov.querySelectorAll('input, select, textarea'), function (f) { return f.type === 'checkbox' || f.type === 'radio' ? String(f.checked) : f.value; }).join('\u0000'); }
528
+ function snapshot(ov) { if (ov) ov.__taciSnap = fields(ov); }
529
+ function isDirty(ov) { return !!ov && ov.hasAttribute('data-guard') && ov.__taciSnap != null && fields(ov) !== ov.__taciSnap; }
530
+ function closeModal(ov, opt) {
531
+ if (!ov || LOCKED[ov.id] || ov.classList.contains('hide')) return false;
532
+ if (!(opt && opt.force) && isDirty(ov) && !window.confirm(tr('Discard your changes?'))) return false;
533
+ ov.__taciSnap = null;
534
+ ov.classList.add('hide');
535
+ ov.dispatchEvent(new CustomEvent('taci:close')); // e.g. the live view stops polling
536
+ return true;
537
+ }
538
+ function tr(t) { return (window.TaciLang && window.TaciLang.t) ? window.TaciLang.t(t) : t; }
539
+ // a guarded modal takes its snapshot when it becomes visible — after its opener has filled the defaults
540
+ new MutationObserver(function (list) {
541
+ list.forEach(function (m) {
542
+ var o = m.target; if (!o.hasAttribute('data-guard')) return;
543
+ if (o.classList.contains('hide')) o.__taciSnap = null; // closed by any path (also a successful save) → next open starts fresh
544
+ else if (o.__taciSnap == null) snapshot(o);
545
+ });
546
+ }).observe(document.body, { subtree: true, attributes: true, attributeFilter: ['class'] });
547
+ function topModal() {
548
+ var open = [].filter.call(document.querySelectorAll('.modal-overlay'), function (o) { return !o.classList.contains('hide'); });
549
+ // a locked one (e.g. login after the session expired) wins even though it sits first in the DOM —
550
+ // otherwise Esc would close the settings BEHIND the login
551
+ var locked = open.filter(function (o) { return LOCKED[o.id]; })[0];
552
+ return locked || open[open.length - 1] || null; // later in the DOM = drawn on top
553
+ }
554
+ document.addEventListener('click', function (e) {
555
+ var x = e.target.closest && e.target.closest('[data-close]');
556
+ if (x) closeModal(x.closest('.modal-overlay'), { force: true });
557
+ });
558
+ document.addEventListener('keydown', function (e) {
559
+ if (e.key !== 'Escape' || e.defaultPrevented) return;
560
+ var top = topModal();
561
+ if (top && !LOCKED[top.id]) { e.preventDefault(); closeModal(top); }
562
+ });
563
+ // backdrop: press AND release on the overlay itself — selecting text inside and letting go outside must not close
564
+ var downOn = null;
565
+ document.addEventListener('mousedown', function (e) { downOn = e.target; });
566
+ document.addEventListener('click', function (e) {
567
+ var t = e.target;
568
+ if (t.classList && t.classList.contains('modal-overlay') && downOn === t && topModal() === t) closeModal(t);
569
+ });
570
+ window.TaciModal = { close: closeModal, snapshot: snapshot, isDirty: isDirty };
335
571
  })();