tacilytics 2.0.2 → 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 +12 -1
- package/README.md +96 -3
- package/auth.js +788 -0
- package/cli.js +36 -0
- package/importers.js +223 -0
- package/package.json +10 -2
- package/public/account.js +570 -0
- package/public/app.js +229 -41
- package/public/assets/icons.svg +6 -0
- package/public/i18n.js +242 -0
- package/public/index.html +194 -37
- package/public/style.css +140 -0
- package/public/tacilytics.js +41 -1
- package/public/theme.js +14 -0
- package/report.js +300 -0
- package/server.js +572 -110
- package/tz.js +96 -0
- package/webhook.js +109 -0
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
/* Tacilytics — sign-in (passphrase / account / TOTP / passkey), invites, Settings (security + team).
|
|
2
|
+
Loaded before app.js; uses window.Taci (api, esc, ico, signedIn, sites) lazily. */
|
|
3
|
+
(function () {
|
|
4
|
+
var $ = function (id) { return document.getElementById(id); };
|
|
5
|
+
var T = function () { return window.Taci; };
|
|
6
|
+
var esc = function (s) { return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; }); };
|
|
7
|
+
var ico = function (id) { return '<svg class="ico ui" aria-hidden="true"><use href="/assets/icons.svg?v=2#' + id + '"></use></svg>'; };
|
|
8
|
+
var hasPasskeys = !!(window.PublicKeyCredential && navigator.credentials && navigator.credentials.create);
|
|
9
|
+
|
|
10
|
+
function post(path, body, token) {
|
|
11
|
+
var h = { 'Content-Type': 'application/json' };
|
|
12
|
+
if (token) h['x-session-token'] = token;
|
|
13
|
+
return fetch(path, { method: 'POST', headers: h, body: JSON.stringify(body || {}) })
|
|
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 + ')'); });
|
|
27
|
+
}
|
|
28
|
+
window.TaciReadJson = readJson; // app.js api() uses the same rule
|
|
29
|
+
// authenticated calls that also report errors (Taci.api only throws on 401)
|
|
30
|
+
function call(method, path, body) {
|
|
31
|
+
return fetch(path, { method: method, headers: { 'Content-Type': 'application/json', 'x-session-token': localStorage.getItem('nyx_token') || '' }, body: body ? JSON.stringify(body) : undefined })
|
|
32
|
+
.then(function (r) { return readJson(r).then(function (j) { if (!r.ok) throw new Error((j && j.error) || ('HTTP ' + r.status)); return j; }); });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── WebAuthn: JSON options (base64url) ⇄ browser ArrayBuffers ──
|
|
36
|
+
function b64uToBuf(s) { s = s.replace(/-/g, '+').replace(/_/g, '/'); while (s.length % 4) s += '='; var b = atob(s), u = new Uint8Array(b.length); for (var i = 0; i < b.length; i++) u[i] = b.charCodeAt(i); return u.buffer; }
|
|
37
|
+
function bufToB64u(buf) { var u = new Uint8Array(buf), s = ''; for (var i = 0; i < u.length; i++) s += String.fromCharCode(u[i]); return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }
|
|
38
|
+
function pkCreate(o) {
|
|
39
|
+
var pub = Object.assign({}, o, {
|
|
40
|
+
challenge: b64uToBuf(o.challenge),
|
|
41
|
+
user: Object.assign({}, o.user, { id: b64uToBuf(o.user.id) }),
|
|
42
|
+
excludeCredentials: (o.excludeCredentials || []).map(function (c) { return Object.assign({}, c, { id: b64uToBuf(c.id) }); }),
|
|
43
|
+
});
|
|
44
|
+
return navigator.credentials.create({ publicKey: pub }).then(function (c) {
|
|
45
|
+
return {
|
|
46
|
+
id: c.id, rawId: bufToB64u(c.rawId), type: c.type, clientExtensionResults: c.getClientExtensionResults ? c.getClientExtensionResults() : {},
|
|
47
|
+
authenticatorAttachment: c.authenticatorAttachment || undefined,
|
|
48
|
+
response: {
|
|
49
|
+
clientDataJSON: bufToB64u(c.response.clientDataJSON), attestationObject: bufToB64u(c.response.attestationObject),
|
|
50
|
+
transports: c.response.getTransports ? c.response.getTransports() : undefined,
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
function pkGet(o) {
|
|
56
|
+
var pub = Object.assign({}, o, { challenge: b64uToBuf(o.challenge), allowCredentials: (o.allowCredentials || []).map(function (c) { return Object.assign({}, c, { id: b64uToBuf(c.id) }); }) });
|
|
57
|
+
return navigator.credentials.get({ publicKey: pub }).then(function (c) {
|
|
58
|
+
return {
|
|
59
|
+
id: c.id, rawId: bufToB64u(c.rawId), type: c.type, clientExtensionResults: c.getClientExtensionResults ? c.getClientExtensionResults() : {},
|
|
60
|
+
authenticatorAttachment: c.authenticatorAttachment || undefined,
|
|
61
|
+
response: {
|
|
62
|
+
clientDataJSON: bufToB64u(c.response.clientDataJSON), authenticatorData: bufToB64u(c.response.authenticatorData),
|
|
63
|
+
signature: bufToB64u(c.response.signature), userHandle: c.response.userHandle ? bufToB64u(c.response.userHandle) : undefined,
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
var pkCancelled = function (e) { return e && (e.name === 'NotAllowedError' || e.name === 'AbortError'); };
|
|
69
|
+
|
|
70
|
+
// ── sign in ──
|
|
71
|
+
var accounts = false, ticket = '';
|
|
72
|
+
function loginErr(t) { $('loginErr').textContent = t || ''; }
|
|
73
|
+
function step(n) { $('loginStep1').classList.toggle('hide', n !== 1); $('loginStep2').classList.toggle('hide', n !== 2); }
|
|
74
|
+
function prepareLogin() {
|
|
75
|
+
step(1); loginErr('');
|
|
76
|
+
fetch('/auth/status').then(function (r) { return r.json(); }).then(function (s) {
|
|
77
|
+
accounts = !!s.accounts;
|
|
78
|
+
$('user').classList.toggle('hide', !accounts);
|
|
79
|
+
$('pw').placeholder = accounts ? 'Password' : 'Passphrase';
|
|
80
|
+
$('loginHint').textContent = accounts ? 'Cookieless analytics. Sign in to your account.' : 'Cookieless analytics. Sign in with your passphrase.';
|
|
81
|
+
$('passkeyBtn').classList.toggle('hide', !(accounts && hasPasskeys));
|
|
82
|
+
(accounts ? $('user') : $('pw')).focus();
|
|
83
|
+
}).catch(function () {});
|
|
84
|
+
}
|
|
85
|
+
function login() {
|
|
86
|
+
loginErr('');
|
|
87
|
+
var body = { password: $('pw').value };
|
|
88
|
+
if (accounts) body.username = $('user').value.trim();
|
|
89
|
+
post('/auth/login', body).then(function (r) {
|
|
90
|
+
if (!r.ok) return loginErr(r.j.error || 'Wrong password');
|
|
91
|
+
$('pw').value = '';
|
|
92
|
+
if (r.j.mfa === 'totp') { ticket = r.j.ticket; step(2); $('otp').value = ''; $('otp').focus(); return; }
|
|
93
|
+
T().signedIn(r.j.token);
|
|
94
|
+
}).catch(function () { loginErr('Connection error'); });
|
|
95
|
+
}
|
|
96
|
+
function verifyOtp() {
|
|
97
|
+
loginErr('');
|
|
98
|
+
post('/auth/login/totp', { ticket: ticket, code: $('otp').value.trim() }).then(function (r) {
|
|
99
|
+
if (r.ok) return T().signedIn(r.j.token);
|
|
100
|
+
if (r.j.ticket) { ticket = r.j.ticket; $('otp').value = ''; $('otp').focus(); return loginErr(r.j.error || 'Wrong code'); }
|
|
101
|
+
step(1); loginErr(r.j.error || 'Sign-in expired — enter your password again');
|
|
102
|
+
}).catch(function () { loginErr('Connection error'); });
|
|
103
|
+
}
|
|
104
|
+
function passkeyLogin() {
|
|
105
|
+
loginErr('');
|
|
106
|
+
post('/auth/passkey/options').then(function (r) {
|
|
107
|
+
if (!r.ok) throw new Error(r.j.error || 'Passkeys are not available');
|
|
108
|
+
return pkGet(r.j.options).then(function (resp) { return post('/auth/passkey/verify', { ticket: r.j.ticket, response: resp }); });
|
|
109
|
+
}).then(function (r) {
|
|
110
|
+
if (!r.ok) return loginErr(r.j.error || 'Passkey not accepted');
|
|
111
|
+
T().signedIn(r.j.token);
|
|
112
|
+
}).catch(function (e) { if (!pkCancelled(e)) loginErr(e.message || 'Passkey sign-in failed'); });
|
|
113
|
+
}
|
|
114
|
+
$('loginBtn').onclick = login;
|
|
115
|
+
$('pw').addEventListener('keydown', function (e) { if (e.key === 'Enter') login(); });
|
|
116
|
+
$('user').addEventListener('keydown', function (e) { if (e.key === 'Enter') $('pw').focus(); });
|
|
117
|
+
$('otpBtn').onclick = verifyOtp;
|
|
118
|
+
$('otp').addEventListener('keydown', function (e) { if (e.key === 'Enter') verifyOtp(); });
|
|
119
|
+
$('passkeyBtn').onclick = passkeyLogin;
|
|
120
|
+
|
|
121
|
+
// ── invite page ──
|
|
122
|
+
function showInvite(token) {
|
|
123
|
+
$('app').classList.add('hide'); $('invite').classList.remove('hide');
|
|
124
|
+
fetch('/api/invite/' + token).then(function (r) { return r.json(); }).then(function (inv) {
|
|
125
|
+
if (inv.error) { $('inviteHint').textContent = inv.error; $('inviteForm').classList.add('hide'); return; }
|
|
126
|
+
$('inviteHint').textContent = 'You were invited as ' + ROLE_LABEL[inv.role].toLowerCase() + '. Choose a username and a password.';
|
|
127
|
+
$('invUser').focus();
|
|
128
|
+
});
|
|
129
|
+
var go = function () {
|
|
130
|
+
$('inviteErr').textContent = '';
|
|
131
|
+
post('/api/invite/' + token, { username: $('invUser').value.trim(), password: $('invPw').value }).then(function (r) {
|
|
132
|
+
if (!r.ok) { $('inviteErr').textContent = r.j.error || 'Could not create the account'; return; }
|
|
133
|
+
history.replaceState(null, '', '/');
|
|
134
|
+
$('invite').classList.add('hide');
|
|
135
|
+
T().signedIn(r.j.token);
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
$('invBtn').onclick = go;
|
|
139
|
+
$('invPw').addEventListener('keydown', function (e) { if (e.key === 'Enter') go(); });
|
|
140
|
+
}
|
|
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
|
+
|
|
164
|
+
// ── settings ──
|
|
165
|
+
var ROLE_LABEL = { owner: 'Owner', admin: 'Admin', viewer: 'Viewer' };
|
|
166
|
+
var ROLE_HELP = { owner: 'everything, including the team', admin: 'sites, share links and funnels', viewer: 'read only' };
|
|
167
|
+
var me = null;
|
|
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
|
+
}
|
|
180
|
+
function afterBoot() {
|
|
181
|
+
call('GET', '/api/me').then(function (m) {
|
|
182
|
+
me = m;
|
|
183
|
+
$('meName').textContent = m.username || 'Settings';
|
|
184
|
+
showVersion(m.version);
|
|
185
|
+
document.body.classList.toggle('role-viewer', m.role === 'viewer');
|
|
186
|
+
}).catch(function () {});
|
|
187
|
+
}
|
|
188
|
+
function openSettings(tab) {
|
|
189
|
+
setErr(''); $('settingsModal').classList.remove('hide');
|
|
190
|
+
call('GET', '/api/me').then(function (m) {
|
|
191
|
+
me = m;
|
|
192
|
+
$('setTabs').querySelector('[data-t="team"]').classList.toggle('hide', m.role !== 'owner');
|
|
193
|
+
$('setTabs').querySelector('[data-t="activity"]').classList.toggle('hide', m.role !== 'owner');
|
|
194
|
+
showTab(tab || 'security');
|
|
195
|
+
}).catch(function (e) { setErr(e.message); });
|
|
196
|
+
}
|
|
197
|
+
function showTab(t) {
|
|
198
|
+
Array.prototype.forEach.call($('setTabs').children, function (b) { b.classList.toggle('active', b.dataset.t === t); });
|
|
199
|
+
$('setSecurity').classList.toggle('hide', t !== 'security');
|
|
200
|
+
$('setTeam').classList.toggle('hide', t !== 'team');
|
|
201
|
+
$('setActivity').classList.toggle('hide', t !== 'activity');
|
|
202
|
+
setErr('');
|
|
203
|
+
if (t === 'security') renderSecurity(); else if (t === 'team') renderTeam(); else renderActivity();
|
|
204
|
+
}
|
|
205
|
+
$('settingsBtn').onclick = function () { openSettings(); };
|
|
206
|
+
$('closeSettings').onclick = function () { closeModal($('settingsModal')); };
|
|
207
|
+
$('setTabs').onclick = function (e) { var b = e.target.closest('button'); if (b) showTab(b.dataset.t); };
|
|
208
|
+
function date(ms) { return ms ? new Date(ms).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }) : '—'; }
|
|
209
|
+
|
|
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
|
+
}
|
|
226
|
+
function renderSecurity() {
|
|
227
|
+
var el = $('setSecurity');
|
|
228
|
+
if (me.kind !== 'user') {
|
|
229
|
+
// legacy passphrase (or API key): offer the first account
|
|
230
|
+
el.innerHTML = prefsCard() +
|
|
231
|
+
'<div class="set-card"><h3>' + ico('i-user-plus') + 'Create your account</h3>' +
|
|
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. ' +
|
|
233
|
+
'<strong>After that the passphrase (WEB_PASSWORD) stops working</strong>, so 2FA cannot be bypassed.</p>' +
|
|
234
|
+
'<input id="bsUser" type="text" placeholder="Username" autocomplete="username" autocapitalize="off" spellcheck="false">' +
|
|
235
|
+
'<input id="bsPw" type="password" placeholder="Password (at least 10 characters)" autocomplete="new-password">' +
|
|
236
|
+
'<button class="btn" id="bsBtn">Create owner account</button></div>';
|
|
237
|
+
bindPrefs();
|
|
238
|
+
$('bsBtn').onclick = function () {
|
|
239
|
+
setErr('');
|
|
240
|
+
post('/api/team/bootstrap', { username: $('bsUser').value.trim(), password: $('bsPw').value }, localStorage.getItem('nyx_token')).then(function (r) {
|
|
241
|
+
if (!r.ok) return setErr(r.j.error || 'Could not create the account');
|
|
242
|
+
localStorage.setItem('nyx_token', r.j.token);
|
|
243
|
+
openSettings('security'); afterBoot();
|
|
244
|
+
});
|
|
245
|
+
};
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
var pk = (me.passkeyList || []).map(function (k) {
|
|
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>' +
|
|
250
|
+
'<button class="btn ghost danger mini" data-delpk="' + esc(k.id) + '">Remove</button></div>';
|
|
251
|
+
}).join('');
|
|
252
|
+
el.innerHTML = prefsCard() +
|
|
253
|
+
'<div class="set-card"><h3>' + ico('i-key') + 'Passkeys <span class="pill">optional</span></h3>' +
|
|
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>' +
|
|
255
|
+
(pk || '<p class="muted small">No passkey yet.</p>') +
|
|
256
|
+
(hasPasskeys ? '<div class="set-inline"><input id="pkName" type="text" placeholder="Name, e.g. MacBook" maxlength="60"><button class="btn" id="pkAdd">Add passkey</button></div>'
|
|
257
|
+
: '<p class="muted small">This browser does not support passkeys.</p>') +
|
|
258
|
+
'</div>' +
|
|
259
|
+
'<div class="set-card"><h3>' + ico('i-smartphone') + 'Two-factor code (OTP) <span class="pill">' + (me.totp ? 'on' : 'optional') + '</span></h3>' +
|
|
260
|
+
(me.totp
|
|
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>' +
|
|
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>'
|
|
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>') +
|
|
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>' +
|
|
284
|
+
'<div class="set-card"><h3>' + ico('i-shield') + 'Password</h3>' +
|
|
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>' +
|
|
286
|
+
'<p class="muted small">Changing it signs you out on all other devices.</p></div>' +
|
|
287
|
+
'<p class="muted small">Signed in as <strong>' + esc(me.username) + '</strong> · ' + ROLE_LABEL[me.role] + '</p>';
|
|
288
|
+
|
|
289
|
+
bindPrefs();
|
|
290
|
+
el.querySelectorAll('[data-delpk]').forEach(function (b) {
|
|
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); }); };
|
|
292
|
+
});
|
|
293
|
+
if ($('pkAdd')) $('pkAdd').onclick = function () {
|
|
294
|
+
setErr('');
|
|
295
|
+
call('POST', '/api/me/passkeys/options').then(function (r) {
|
|
296
|
+
return pkCreate(r.options).then(function (resp) { return call('POST', '/api/me/passkeys', { ticket: r.ticket, response: resp, name: $('pkName').value.trim() }); });
|
|
297
|
+
}).then(function () { openSettings('security'); }).catch(function (e) { if (!pkCancelled(e)) setErr(e.message || 'Could not add the passkey'); });
|
|
298
|
+
};
|
|
299
|
+
if ($('totpStart')) $('totpStart').onclick = function () {
|
|
300
|
+
call('POST', '/api/me/totp/setup').then(function (s) {
|
|
301
|
+
$('totpSetup').innerHTML =
|
|
302
|
+
'<div class="totp-qr"><img src="' + s.qr + '" alt="QR code for your authenticator app"></div>' +
|
|
303
|
+
'<p class="muted small">Scan the code, or enter this key by hand:</p><div class="snippet center">' + esc(s.secret.replace(/(.{4})/g, '$1 ').trim()) + '</div>' +
|
|
304
|
+
'<div class="set-inline"><input id="totpCode" type="text" inputmode="numeric" placeholder="6-digit code" autocomplete="one-time-code" maxlength="6"><button class="btn" id="totpOn">Turn on</button></div>';
|
|
305
|
+
$('totpCode').focus();
|
|
306
|
+
var on = function () {
|
|
307
|
+
setErr('');
|
|
308
|
+
call('POST', '/api/me/totp/enable', { code: $('totpCode').value.trim() }).then(function (r) { showRecovery(r.recoveryCodes); }).catch(function (e) { setErr(e.message); });
|
|
309
|
+
};
|
|
310
|
+
$('totpOn').onclick = on;
|
|
311
|
+
$('totpCode').addEventListener('keydown', function (e) { if (e.key === 'Enter') on(); });
|
|
312
|
+
}).catch(function (e) { setErr(e.message); });
|
|
313
|
+
};
|
|
314
|
+
if ($('totpOff')) $('totpOff').onclick = function () {
|
|
315
|
+
if (!confirm('Turn off two-factor codes?')) return;
|
|
316
|
+
call('POST', '/api/me/totp/disable', { password: $('totpPw').value }).then(function () { openSettings('security'); }).catch(function (e) { setErr(e.message); });
|
|
317
|
+
};
|
|
318
|
+
if ($('newRecovery')) $('newRecovery').onclick = function () {
|
|
319
|
+
call('POST', '/api/me/recovery', { password: $('totpPw').value }).then(function (r) { showRecovery(r.recoveryCodes); }).catch(function (e) { setErr(e.message); });
|
|
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
|
+
};
|
|
363
|
+
$('pwSave').onclick = function () {
|
|
364
|
+
setErr('');
|
|
365
|
+
call('POST', '/api/me/password', { password: $('pwOld').value, newPassword: $('pwNew').value }).then(function () {
|
|
366
|
+
$('pwOld').value = ''; $('pwNew').value = ''; setErr(''); $('pwSave').textContent = 'Changed ✓';
|
|
367
|
+
}).catch(function (e) { setErr(e.message); });
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
function showRecovery(codes) {
|
|
371
|
+
$('setSecurity').innerHTML =
|
|
372
|
+
'<div class="set-card"><h3>' + ico('i-shield') + 'Save your recovery codes</h3>' +
|
|
373
|
+
'<p class="muted">Each code works once, instead of a 6-digit code. Keep them somewhere safe — they are shown only now.</p>' +
|
|
374
|
+
'<div class="snippet recovery">' + codes.map(esc).join('<br>') + '</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>';
|
|
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
|
+
};
|
|
392
|
+
$('recDone').onclick = function () { openSettings('security'); };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// team tab (owner)
|
|
396
|
+
function siteNames(ids) {
|
|
397
|
+
if (!ids) return 'all sites';
|
|
398
|
+
var all = T().sites() || [];
|
|
399
|
+
return ids.map(function (id) { var s = all.filter(function (x) { return x.id === id; })[0]; return s ? s.domain : '#' + id; }).join(', ');
|
|
400
|
+
}
|
|
401
|
+
function sitePicker(idPrefix, selected) {
|
|
402
|
+
var all = T().sites() || [];
|
|
403
|
+
return '<div class="site-pick" id="' + idPrefix + '">' + all.map(function (s) {
|
|
404
|
+
var on = !selected || selected.indexOf(s.id) >= 0;
|
|
405
|
+
return '<label><input type="checkbox" value="' + s.id + '"' + (on ? ' checked' : '') + '> ' + esc(s.domain) + '</label>';
|
|
406
|
+
}).join('') + '</div>';
|
|
407
|
+
}
|
|
408
|
+
function pickedSites(idPrefix) {
|
|
409
|
+
var box = $(idPrefix); if (!box) return null;
|
|
410
|
+
var all = box.querySelectorAll('input'), ids = [];
|
|
411
|
+
all.forEach(function (c) { if (c.checked) ids.push(Number(c.value)); });
|
|
412
|
+
return ids.length === all.length ? null : ids; // everything ticked = all sites, also future ones
|
|
413
|
+
}
|
|
414
|
+
function renderTeam() {
|
|
415
|
+
var el = $('setTeam');
|
|
416
|
+
call('GET', '/api/team').then(function (t) {
|
|
417
|
+
var rows = t.users.map(function (u) {
|
|
418
|
+
var self = me && me.id === u.id;
|
|
419
|
+
return '<div class="set-row team-row" data-uid="' + u.id + '">' +
|
|
420
|
+
'<span><strong data-no-i18n>' + esc(u.username) + '</strong>' + (self ? ' <span class="muted small">(you)</span>' : '') +
|
|
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>' +
|
|
422
|
+
'<select data-role>' + ['owner', 'admin', 'viewer'].map(function (r) { return '<option value="' + r + '"' + (r === u.role ? ' selected' : '') + '>' + ROLE_LABEL[r] + '</option>'; }).join('') + '</select>' +
|
|
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>' : '') +
|
|
425
|
+
(u.totp && !self ? '<button class="btn ghost mini" data-reset2fa>Reset 2FA</button>' : '') +
|
|
426
|
+
(!self ? '<button class="btn ghost danger mini" data-remove>Remove</button>' : '') + '</span></div>';
|
|
427
|
+
}).join('');
|
|
428
|
+
var invites = t.invites.map(function (i) {
|
|
429
|
+
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
|
+
}).join('');
|
|
431
|
+
el.innerHTML =
|
|
432
|
+
'<div class="set-card"><h3>' + ico('i-users') + 'Members</h3>' + rows + '</div>' +
|
|
433
|
+
'<div class="set-card"><h3>' + ico('i-user-plus') + 'Invite someone</h3>' +
|
|
434
|
+
'<p class="muted">Creates a link that works once, for 7 days. Send it yourself — Tacilytics sends no e-mail.</p>' +
|
|
435
|
+
'<div class="set-inline"><select id="invRole">' + ['viewer', 'admin', 'owner'].map(function (r) { return '<option value="' + r + '">' + ROLE_LABEL[r] + ' — ' + ROLE_HELP[r] + '</option>'; }).join('') + '</select><button class="btn" id="invCreate">Create link</button></div>' +
|
|
436
|
+
'<div id="invSites">' + sitePicker('invPick', null) + '</div>' +
|
|
437
|
+
'<div id="invOut"></div>' + (invites ? '<h4 class="muted small" style="margin-top:12px">Open invites</h4>' + invites : '') + '</div>';
|
|
438
|
+
|
|
439
|
+
$('invRole').onchange = function () { $('invSites').classList.toggle('hide', this.value !== 'viewer'); };
|
|
440
|
+
$('invCreate').onclick = function () {
|
|
441
|
+
setErr('');
|
|
442
|
+
var role = $('invRole').value;
|
|
443
|
+
call('POST', '/api/team/invites', { role: role, sites: role === 'viewer' ? pickedSites('invPick') : null }).then(function (r) {
|
|
444
|
+
var url = location.origin + r.url;
|
|
445
|
+
$('invOut').innerHTML = '<div class="snippet" id="invUrl">' + esc(url) + '</div><button class="btn ghost" id="invCopy">Copy link</button>';
|
|
446
|
+
$('invCopy').onclick = function () { navigator.clipboard.writeText(url); $('invCopy').textContent = 'Copied ✓'; };
|
|
447
|
+
}).catch(function (e) { setErr(e.message); });
|
|
448
|
+
};
|
|
449
|
+
el.querySelectorAll('[data-delinv]').forEach(function (b) {
|
|
450
|
+
b.onclick = function () { call('DELETE', '/api/team/invites/' + b.dataset.delinv).then(renderTeam).catch(function (e) { setErr(e.message); }); };
|
|
451
|
+
});
|
|
452
|
+
el.querySelectorAll('.team-row').forEach(function (row) {
|
|
453
|
+
var uid = row.dataset.uid;
|
|
454
|
+
row.querySelector('[data-role]').onchange = function () {
|
|
455
|
+
setErr('');
|
|
456
|
+
call('PATCH', '/api/team/users/' + uid, { role: this.value }).then(renderTeam).catch(function (e) { setErr(e.message); renderTeam(); });
|
|
457
|
+
};
|
|
458
|
+
var rm = row.querySelector('[data-remove]');
|
|
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
|
+
};
|
|
472
|
+
var r2 = row.querySelector('[data-reset2fa]');
|
|
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); }); };
|
|
474
|
+
var st = row.querySelector('[data-sites]');
|
|
475
|
+
if (st) st.onclick = function () {
|
|
476
|
+
var u = t.users.filter(function (x) { return String(x.id) === uid; })[0];
|
|
477
|
+
if (row.nextElementSibling && row.nextElementSibling.classList.contains('site-edit')) { row.nextElementSibling.remove(); return; }
|
|
478
|
+
var box = document.createElement('div'); box.className = 'site-edit';
|
|
479
|
+
box.innerHTML = sitePicker('pick' + uid, u.sites) + '<button class="btn mini" data-savesites>Save</button>';
|
|
480
|
+
row.after(box);
|
|
481
|
+
box.querySelector('[data-savesites]').onclick = function () {
|
|
482
|
+
call('PATCH', '/api/team/users/' + uid, { sites: pickedSites('pick' + uid) }).then(renderTeam).catch(function (e) { setErr(e.message); });
|
|
483
|
+
};
|
|
484
|
+
};
|
|
485
|
+
});
|
|
486
|
+
}).catch(function (e) { setErr(e.message); });
|
|
487
|
+
}
|
|
488
|
+
|
|
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 };
|
|
570
|
+
})();
|