tacilytics 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,15 +139,49 @@
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
166
  var ROLE_HELP = { owner: 'everything, including the team', admin: 'sites, share links and funnels', 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');
139
186
  }).catch(function () {});
140
187
  }
@@ -143,6 +190,7 @@
143
190
  call('GET', '/api/me').then(function (m) {
144
191
  me = m;
145
192
  $('setTabs').querySelector('[data-t="team"]').classList.toggle('hide', m.role !== 'owner');
193
+ $('setTabs').querySelector('[data-t="activity"]').classList.toggle('hide', m.role !== 'owner');
146
194
  showTab(tab || 'security');
147
195
  }).catch(function (e) { setErr(e.message); });
148
196
  }
@@ -150,26 +198,43 @@
150
198
  Array.prototype.forEach.call($('setTabs').children, function (b) { b.classList.toggle('active', b.dataset.t === t); });
151
199
  $('setSecurity').classList.toggle('hide', t !== 'security');
152
200
  $('setTeam').classList.toggle('hide', t !== 'team');
201
+ $('setActivity').classList.toggle('hide', t !== 'activity');
153
202
  setErr('');
154
- if (t === 'security') renderSecurity(); else renderTeam();
203
+ if (t === 'security') renderSecurity(); else if (t === 'team') renderTeam(); else renderActivity();
155
204
  }
156
205
  $('settingsBtn').onclick = function () { openSettings(); };
157
- $('closeSettings').onclick = function () { $('settingsModal').classList.add('hide'); };
206
+ $('closeSettings').onclick = function () { closeModal($('settingsModal')); };
158
207
  $('setTabs').onclick = function (e) { var b = e.target.closest('button'); if (b) showTab(b.dataset.t); };
159
208
  function date(ms) { return ms ? new Date(ms).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }) : '—'; }
160
209
 
161
210
  // security tab
211
+ // language + theme: per browser, for everyone (also before an account exists)
212
+ function prefsCard() {
213
+ var lang = window.TaciLang ? window.TaciLang.get() : 'auto';
214
+ var theme = window.TaciTheme ? window.TaciTheme.get() : 'system';
215
+ var opt = function (v, label, cur) { return '<option value="' + v + '"' + (v === cur ? ' selected' : '') + '>' + label + '</option>'; };
216
+ return '<div class="set-card"><h3>' + ico('i-settings') + 'Appearance</h3>' +
217
+ '<div class="set-inline">' +
218
+ '<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>' +
219
+ '<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>' +
220
+ '</div><p class="muted small" style="margin:8px 0 0">Language and appearance are saved in this browser.</p></div>';
221
+ }
222
+ function bindPrefs() {
223
+ if ($('prefLang')) $('prefLang').onchange = function () { window.TaciLang.set(this.value); };
224
+ if ($('prefTheme')) $('prefTheme').onchange = function () { window.TaciTheme.set(this.value); };
225
+ }
162
226
  function renderSecurity() {
163
227
  var el = $('setSecurity');
164
228
  if (me.kind !== 'user') {
165
229
  // legacy passphrase (or API key): offer the first account
166
- el.innerHTML =
230
+ el.innerHTML = prefsCard() +
167
231
  '<div class="set-card"><h3>' + ico('i-user-plus') + 'Create your account</h3>' +
168
232
  '<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
233
  '<strong>After that the passphrase (WEB_PASSWORD) stops working</strong>, so 2FA cannot be bypassed.</p>' +
170
234
  '<input id="bsUser" type="text" placeholder="Username" autocomplete="username" autocapitalize="off" spellcheck="false">' +
171
235
  '<input id="bsPw" type="password" placeholder="Password (at least 10 characters)" autocomplete="new-password">' +
172
236
  '<button class="btn" id="bsBtn">Create owner account</button></div>';
237
+ bindPrefs();
173
238
  $('bsBtn').onclick = function () {
174
239
  setErr('');
175
240
  post('/api/team/bootstrap', { username: $('bsUser').value.trim(), password: $('bsPw').value }, localStorage.getItem('nyx_token')).then(function (r) {
@@ -184,7 +249,7 @@
184
249
  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
250
  '<button class="btn ghost danger mini" data-delpk="' + esc(k.id) + '">Remove</button></div>';
186
251
  }).join('');
187
- el.innerHTML =
252
+ el.innerHTML = prefsCard() +
188
253
  '<div class="set-card"><h3>' + ico('i-key') + 'Passkeys <span class="pill">optional</span></h3>' +
189
254
  '<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
255
  (pk || '<p class="muted small">No passkey yet.</p>') +
@@ -193,15 +258,35 @@
193
258
  '</div>' +
194
259
  '<div class="set-card"><h3>' + ico('i-smartphone') + 'Two-factor code (OTP) <span class="pill">' + (me.totp ? 'on' : 'optional') + '</span></h3>' +
195
260
  (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>' +
261
+ ? '<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
262
  '<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
263
  : '<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
264
  '</div>' +
265
+ '<div class="set-card"><h3>' + ico('i-eye') + 'Don\u2019t count my own visits</h3>' +
266
+ '<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>' +
267
+ ((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>') +
268
+ (T().sites() || []).map(function (st) {
269
+ var d = 'https://' + st.domain + '/';
270
+ return '<div class="set-row"><span>' + esc(st.domain) + '</span>' +
271
+ '<a class="btn ghost mini" target="_blank" rel="noopener" href="' + esc(d + '?tacilytics=off') + '">Ignore this browser</a>' +
272
+ '<a class="btn ghost mini" target="_blank" rel="noopener" href="' + esc(d + '?tacilytics=on') + '">Count again</a></div>';
273
+ }).join('') +
274
+ '</div>' +
275
+ '<div class="set-card"><h3>' + ico('i-calendar') + 'Weekly report <span class="pill">e-mail</span></h3>' +
276
+ '<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>' +
277
+ '<div id="repBox"><p class="muted small">Loading…</p></div></div>' +
278
+ '<div class="set-card"><h3>' + ico('i-code') + 'API keys</h3>' +
279
+ '<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>' +
280
+ '<div id="keyList"><p class="muted small">Loading…</p></div>' +
281
+ '<div class="set-inline"><input id="keyName" type="text" placeholder="Name, e.g. reporting script" maxlength="60">' +
282
+ (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>' : '') +
283
+ '<button class="btn" id="keyAdd">Create key</button></div><div id="keyOut"></div></div>' +
200
284
  '<div class="set-card"><h3>' + ico('i-shield') + 'Password</h3>' +
201
285
  '<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
286
  '<p class="muted small">Changing it signs you out on all other devices.</p></div>' +
203
287
  '<p class="muted small">Signed in as <strong>' + esc(me.username) + '</strong> · ' + ROLE_LABEL[me.role] + '</p>';
204
288
 
289
+ bindPrefs();
205
290
  el.querySelectorAll('[data-delpk]').forEach(function (b) {
206
291
  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
292
  });
@@ -233,6 +318,48 @@
233
318
  if ($('newRecovery')) $('newRecovery').onclick = function () {
234
319
  call('POST', '/api/me/recovery', { password: $('totpPw').value }).then(function (r) { showRecovery(r.recoveryCodes); }).catch(function (e) { setErr(e.message); });
235
320
  };
321
+ call('GET', '/api/me/report').then(function (r) {
322
+ 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; }
323
+ $('repBox').innerHTML = '<div class="set-inline"><input id="repMail" type="email" placeholder="you@example.com" value="' + esc(r.email || '') + '">' +
324
+ '<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>' +
325
+ '<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>' +
326
+ '<button class="btn ghost" id="repSave">Save</button><button class="btn ghost" id="repNow">Send one now</button></div>' +
327
+ (r.last ? '<p class="muted small" style="margin:6px 0 0">Last sent ' + date(r.last) + '</p>' : '');
328
+ $('repLang').value = r.lang || ((window.TaciLang && window.TaciLang.current) || 'en');
329
+ $('repLang').setAttribute('data-no-i18n', '');
330
+ $('repSave').onclick = function () {
331
+ setErr('');
332
+ 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); });
333
+ };
334
+ $('repNow').onclick = function () {
335
+ setErr(''); $('repNow').textContent = 'Sending…';
336
+ call('POST', '/api/me/report', { email: $('repMail').value.trim(), weekly: $('repOn').checked, lang: $('repLang').value })
337
+ .then(function () { return call('POST', '/api/me/report/send'); })
338
+ .then(function () { $('repNow').textContent = 'Sent ✓'; })
339
+ .catch(function (e) { $('repNow').textContent = 'Send one now'; setErr(e.message); });
340
+ };
341
+ }).catch(function () { $('repBox').innerHTML = ''; });
342
+ function loadKeys() {
343
+ call('GET', '/api/me/keys').then(function (keys) {
344
+ $('keyList').innerHTML = keys.length ? keys.map(function (k) {
345
+ 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>' +
346
+ '<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>';
347
+ }).join('') : '<p class="muted small">No keys yet.</p>';
348
+ $('keyList').querySelectorAll('[data-delkey]').forEach(function (b) {
349
+ 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); }); };
350
+ });
351
+ }).catch(function (e) { setErr(e.message); });
352
+ }
353
+ loadKeys();
354
+ $('keyAdd').onclick = function () {
355
+ setErr('');
356
+ call('POST', '/api/me/keys', { name: $('keyName').value.trim(), readOnly: $('keyRo') ? $('keyRo').checked : true }).then(function (k) {
357
+ $('keyName').value = '';
358
+ $('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>';
359
+ $('keyCopy').onclick = function () { navigator.clipboard.writeText(k.key); this.textContent = 'Copied ✓'; };
360
+ loadKeys();
361
+ }).catch(function (e) { setErr(e.message); });
362
+ };
236
363
  $('pwSave').onclick = function () {
237
364
  setErr('');
238
365
  call('POST', '/api/me/password', { password: $('pwOld').value, newPassword: $('pwNew').value }).then(function () {
@@ -245,8 +372,23 @@
245
372
  '<div class="set-card"><h3>' + ico('i-shield') + 'Save your recovery codes</h3>' +
246
373
  '<p class="muted">Each code works once, instead of a 6-digit code. Keep them somewhere safe — they are shown only now.</p>' +
247
374
  '<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>';
375
+ '<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
376
  $('copyRec').onclick = function () { navigator.clipboard.writeText(codes.join('\n')); $('copyRec').textContent = 'Copied ✓'; };
377
+ $('dlRec').onclick = function () {
378
+ // plain .txt, built in the browser; the codes never go to the server again
379
+ var host = location.hostname;
380
+ var text = 'Tacilytics recovery codes\n' +
381
+ 'Server: ' + location.origin + '\n' +
382
+ 'Account: ' + ((me && me.username) || '') + '\n' +
383
+ 'Created: ' + new Date().toISOString().slice(0, 10) + '\n\n' +
384
+ 'Each code works once, instead of a 6-digit code.\n\n' + codes.join('\n') + '\n';
385
+ var url = URL.createObjectURL(new Blob([text], { type: 'text/plain;charset=utf-8' }));
386
+ var a = document.createElement('a');
387
+ a.href = url; a.download = 'tacilytics-recovery-codes-' + host.replace(/[^a-z0-9.-]/gi, '_') + '.txt';
388
+ document.body.appendChild(a); a.click(); a.remove();
389
+ setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
390
+ $('dlRec').innerHTML = ico('i-check') + 'Downloaded';
391
+ };
250
392
  $('recDone').onclick = function () { openSettings('security'); };
251
393
  }
252
394
 
@@ -275,10 +417,11 @@
275
417
  var rows = t.users.map(function (u) {
276
418
  var self = me && me.id === u.id;
277
419
  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>' : '') +
420
+ '<span><strong data-no-i18n>' + esc(u.username) + '</strong>' + (self ? ' <span class="muted small">(you)</span>' : '') +
279
421
  '<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>' +
280
422
  '<select data-role>' + ['owner', 'admin', 'viewer'].map(function (r) { return '<option value="' + r + '"' + (r === u.role ? ' selected' : '') + '>' + ROLE_LABEL[r] + '</option>'; }).join('') + '</select>' +
281
423
  '<span class="row-actions">' + (u.role === 'viewer' ? '<button class="btn ghost mini" data-sites>Sites</button>' : '') +
424
+ (!self ? '<button class="btn ghost mini" data-resetlink title="One-time link to set a new password (24 h)">Password link</button>' : '') +
282
425
  (u.totp && !self ? '<button class="btn ghost mini" data-reset2fa>Reset 2FA</button>' : '') +
283
426
  (!self ? '<button class="btn ghost danger mini" data-remove>Remove</button>' : '') + '</span></div>';
284
427
  }).join('');
@@ -314,6 +457,18 @@
314
457
  };
315
458
  var rm = row.querySelector('[data-remove]');
316
459
  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); }); };
460
+ var rl = row.querySelector('[data-resetlink]');
461
+ if (rl) rl.onclick = function () {
462
+ setErr('');
463
+ call('POST', '/api/team/users/' + uid + '/reset-link').then(function (r) {
464
+ var url = location.origin + r.url;
465
+ var old = row.nextElementSibling; if (old && old.classList.contains('site-edit')) old.remove();
466
+ var box = document.createElement('div'); box.className = 'site-edit';
467
+ 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>';
468
+ row.after(box);
469
+ box.querySelector('[data-copy]').onclick = function () { navigator.clipboard.writeText(url); this.textContent = 'Copied ✓'; };
470
+ }).catch(function (e) { setErr(e.message); });
471
+ };
317
472
  var r2 = row.querySelector('[data-reset2fa]');
318
473
  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
474
  var st = row.querySelector('[data-sites]');
@@ -331,5 +486,85 @@
331
486
  }).catch(function (e) { setErr(e.message); });
332
487
  }
333
488
 
334
- window.TaciAccount = { prepareLogin: prepareLogin, showInvite: showInvite, afterBoot: afterBoot };
489
+ // activity (audit log, owner)
490
+ var ACTION_LABEL = {
491
+ 'site.add': 'added site', 'site.remove': 'removed site', 'share.add': 'created share link', 'share.revoke': 'revoked share link',
492
+ 'note.add': 'added note', 'note.remove': 'removed note', 'funnel.add': 'added funnel', 'funnel.remove': 'removed funnel',
493
+ 'team.first-owner': 'created the first account', 'team.invite': 'created invite', 'team.invite.revoke': 'revoked invite',
494
+ 'team.join': 'joined via invite', 'team.change': 'changed role', 'team.remove': 'removed member', 'team.2fa.reset': 'reset 2FA of',
495
+ 'team.reset-link': 'created password link for', 'me.password': 'changed own password', 'me.password.reset-link': 'set a new password via link',
496
+ 'me.2fa.on': 'turned 2FA on', 'me.2fa.off': 'turned 2FA off', 'me.recovery.new': 'made new recovery codes',
497
+ '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',
498
+ 'alert.add': 'added alert', 'alert.remove': 'removed alert', 'alert.on': 'turned alert on', 'alert.off': 'turned alert off',
499
+ 'me.passkey.add': 'added passkey', 'me.passkey.remove': 'removed passkey', 'me.apikey.add': 'created API key', 'me.apikey.revoke': 'revoked API key',
500
+ };
501
+ function renderActivity(before, append) {
502
+ var el = $('setActivity');
503
+ call('GET', '/api/audit?limit=60' + (before ? '&before=' + before : '')).then(function (rows) {
504
+ var html = rows.map(function (e) {
505
+ var when = new Date(e.ts);
506
+ return '<div class="set-row act-row"><span class="muted small act-time" title="' + esc(when.toISOString()) + '">' +
507
+ esc(when.toLocaleDateString(undefined, { day: 'numeric', month: 'short' }) + ' ' + when.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })) + '</span>' +
508
+ '<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>' +
509
+ '<span class="muted small act-ip">' + esc(e.ip || '') + '</span></div>';
510
+ }).join('');
511
+ 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>';
512
+ $('actRows').insertAdjacentHTML('beforeend', html || (append ? '' : '<p class="muted small">Nothing yet.</p>'));
513
+ $('actMore').innerHTML = rows.length === 60 ? '<button class="btn ghost mini" id="actMoreBtn">Older</button>' : '';
514
+ if (rows.length === 60) $('actMoreBtn').onclick = function () { renderActivity(rows[rows.length - 1].ts, true); };
515
+ }).catch(function (e) { setErr(e.message); });
516
+ }
517
+
518
+ window.TaciAccount = { prepareLogin: prepareLogin, showInvite: showInvite, showReset: showReset, afterBoot: afterBoot };
519
+
520
+ // ── closing modals: ONE function for every exit (X, Close button, Esc, click on the backdrop) ──
521
+ // login / reset / invite are not closable — there is nothing behind them without signing in.
522
+ var LOCKED = { login: 1, reset: 1, invite: 1 };
523
+ // form modals (data-guard): Esc and the backdrop ask before throwing away what was typed (Fabian, variant C).
524
+ // "Changed" = the fields differ from a snapshot taken when the modal opened (or after a save) — typing
525
+ // something and deleting it again is no change. X and Cancel are a deliberate click and close directly.
526
+ 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'); }
527
+ function snapshot(ov) { if (ov) ov.__taciSnap = fields(ov); }
528
+ function isDirty(ov) { return !!ov && ov.hasAttribute('data-guard') && ov.__taciSnap != null && fields(ov) !== ov.__taciSnap; }
529
+ function closeModal(ov, opt) {
530
+ if (!ov || LOCKED[ov.id] || ov.classList.contains('hide')) return false;
531
+ if (!(opt && opt.force) && isDirty(ov) && !window.confirm(tr('Discard your changes?'))) return false;
532
+ ov.__taciSnap = null;
533
+ ov.classList.add('hide');
534
+ ov.dispatchEvent(new CustomEvent('taci:close')); // e.g. the live view stops polling
535
+ return true;
536
+ }
537
+ function tr(t) { return (window.TaciLang && window.TaciLang.t) ? window.TaciLang.t(t) : t; }
538
+ // a guarded modal takes its snapshot when it becomes visible — after its opener has filled the defaults
539
+ new MutationObserver(function (list) {
540
+ list.forEach(function (m) {
541
+ var o = m.target; if (!o.hasAttribute('data-guard')) return;
542
+ if (o.classList.contains('hide')) o.__taciSnap = null; // closed by any path (also a successful save) → next open starts fresh
543
+ else if (o.__taciSnap == null) snapshot(o);
544
+ });
545
+ }).observe(document.body, { subtree: true, attributes: true, attributeFilter: ['class'] });
546
+ function topModal() {
547
+ var open = [].filter.call(document.querySelectorAll('.modal-overlay'), function (o) { return !o.classList.contains('hide'); });
548
+ // a locked one (e.g. login after the session expired) wins even though it sits first in the DOM —
549
+ // otherwise Esc would close the settings BEHIND the login
550
+ var locked = open.filter(function (o) { return LOCKED[o.id]; })[0];
551
+ return locked || open[open.length - 1] || null; // later in the DOM = drawn on top
552
+ }
553
+ document.addEventListener('click', function (e) {
554
+ var x = e.target.closest && e.target.closest('[data-close]');
555
+ if (x) closeModal(x.closest('.modal-overlay'), { force: true });
556
+ });
557
+ document.addEventListener('keydown', function (e) {
558
+ if (e.key !== 'Escape' || e.defaultPrevented) return;
559
+ var top = topModal();
560
+ if (top && !LOCKED[top.id]) { e.preventDefault(); closeModal(top); }
561
+ });
562
+ // backdrop: press AND release on the overlay itself — selecting text inside and letting go outside must not close
563
+ var downOn = null;
564
+ document.addEventListener('mousedown', function (e) { downOn = e.target; });
565
+ document.addEventListener('click', function (e) {
566
+ var t = e.target;
567
+ if (t.classList && t.classList.contains('modal-overlay') && downOn === t && topModal() === t) closeModal(t);
568
+ });
569
+ window.TaciModal = { close: closeModal, snapshot: snapshot, isDirty: isDirty };
335
570
  })();