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/.env.example +5 -0
- package/README.md +66 -2
- package/auth.js +188 -8
- package/importers.js +223 -0
- package/package.json +6 -1
- package/public/account.js +245 -10
- package/public/app.js +216 -25
- package/public/assets/icons.svg +1 -0
- package/public/i18n.js +242 -0
- package/public/index.html +120 -6
- package/public/style.css +101 -0
- package/public/tacilytics.js +41 -1
- package/public/theme.js +14 -0
- package/report.js +300 -0
- package/server.js +540 -68
- package/tz.js +96 -0
- package/webhook.js +109 -0
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) {
|
|
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
|
|
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')
|
|
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
|
|
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
|
-
|
|
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
|
})();
|