tacilytics 2.0.1 โ 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +7 -1
- package/README.md +32 -3
- package/auth.js +608 -0
- package/cli.js +36 -0
- package/package.json +5 -2
- package/public/account.js +335 -0
- package/public/app.js +15 -18
- package/public/assets/icons.svg +5 -0
- package/public/index.html +76 -33
- package/public/style.css +43 -0
- package/server.js +38 -45
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tacilytics",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "Privacy-first, cookieless analytics for websites and apps. Self-hosted: one Node process, one SQLite file.",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -12,8 +12,10 @@
|
|
|
12
12
|
"author": "Nyx ๐ฆ & Fabian Budde",
|
|
13
13
|
"license": "MIT",
|
|
14
14
|
"dependencies": {
|
|
15
|
+
"@simplewebauthn/server": "^14.0.3",
|
|
15
16
|
"better-sqlite3": "^12.10.0",
|
|
16
|
-
"express": "^4.21.2"
|
|
17
|
+
"express": "^4.21.2",
|
|
18
|
+
"qrcode": "^1.5.4"
|
|
17
19
|
},
|
|
18
20
|
"bin": {
|
|
19
21
|
"tacilytics": "cli.js",
|
|
@@ -46,6 +48,7 @@
|
|
|
46
48
|
"server.js",
|
|
47
49
|
"cli.js",
|
|
48
50
|
"day-salt.js",
|
|
51
|
+
"auth.js",
|
|
49
52
|
"tz-country.js",
|
|
50
53
|
"public/",
|
|
51
54
|
"sdk/flutter/",
|
|
@@ -0,0 +1,335 @@
|
|
|
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) { return r.json().catch(function () { return {}; }).then(function (j) { return { ok: r.ok, status: r.status, j: j }; }); });
|
|
15
|
+
}
|
|
16
|
+
// authenticated calls that also report errors (Taci.api only throws on 401)
|
|
17
|
+
function call(method, path, body) {
|
|
18
|
+
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; }); });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// โโ WebAuthn: JSON options (base64url) โ browser ArrayBuffers โโ
|
|
23
|
+
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; }
|
|
24
|
+
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(/=+$/, ''); }
|
|
25
|
+
function pkCreate(o) {
|
|
26
|
+
var pub = Object.assign({}, o, {
|
|
27
|
+
challenge: b64uToBuf(o.challenge),
|
|
28
|
+
user: Object.assign({}, o.user, { id: b64uToBuf(o.user.id) }),
|
|
29
|
+
excludeCredentials: (o.excludeCredentials || []).map(function (c) { return Object.assign({}, c, { id: b64uToBuf(c.id) }); }),
|
|
30
|
+
});
|
|
31
|
+
return navigator.credentials.create({ publicKey: pub }).then(function (c) {
|
|
32
|
+
return {
|
|
33
|
+
id: c.id, rawId: bufToB64u(c.rawId), type: c.type, clientExtensionResults: c.getClientExtensionResults ? c.getClientExtensionResults() : {},
|
|
34
|
+
authenticatorAttachment: c.authenticatorAttachment || undefined,
|
|
35
|
+
response: {
|
|
36
|
+
clientDataJSON: bufToB64u(c.response.clientDataJSON), attestationObject: bufToB64u(c.response.attestationObject),
|
|
37
|
+
transports: c.response.getTransports ? c.response.getTransports() : undefined,
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
function pkGet(o) {
|
|
43
|
+
var pub = Object.assign({}, o, { challenge: b64uToBuf(o.challenge), allowCredentials: (o.allowCredentials || []).map(function (c) { return Object.assign({}, c, { id: b64uToBuf(c.id) }); }) });
|
|
44
|
+
return navigator.credentials.get({ 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), authenticatorData: bufToB64u(c.response.authenticatorData),
|
|
50
|
+
signature: bufToB64u(c.response.signature), userHandle: c.response.userHandle ? bufToB64u(c.response.userHandle) : undefined,
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
var pkCancelled = function (e) { return e && (e.name === 'NotAllowedError' || e.name === 'AbortError'); };
|
|
56
|
+
|
|
57
|
+
// โโ sign in โโ
|
|
58
|
+
var accounts = false, ticket = '';
|
|
59
|
+
function loginErr(t) { $('loginErr').textContent = t || ''; }
|
|
60
|
+
function step(n) { $('loginStep1').classList.toggle('hide', n !== 1); $('loginStep2').classList.toggle('hide', n !== 2); }
|
|
61
|
+
function prepareLogin() {
|
|
62
|
+
step(1); loginErr('');
|
|
63
|
+
fetch('/auth/status').then(function (r) { return r.json(); }).then(function (s) {
|
|
64
|
+
accounts = !!s.accounts;
|
|
65
|
+
$('user').classList.toggle('hide', !accounts);
|
|
66
|
+
$('pw').placeholder = accounts ? 'Password' : 'Passphrase';
|
|
67
|
+
$('loginHint').textContent = accounts ? 'Cookieless analytics. Sign in to your account.' : 'Cookieless analytics. Sign in with your passphrase.';
|
|
68
|
+
$('passkeyBtn').classList.toggle('hide', !(accounts && hasPasskeys));
|
|
69
|
+
(accounts ? $('user') : $('pw')).focus();
|
|
70
|
+
}).catch(function () {});
|
|
71
|
+
}
|
|
72
|
+
function login() {
|
|
73
|
+
loginErr('');
|
|
74
|
+
var body = { password: $('pw').value };
|
|
75
|
+
if (accounts) body.username = $('user').value.trim();
|
|
76
|
+
post('/auth/login', body).then(function (r) {
|
|
77
|
+
if (!r.ok) return loginErr(r.j.error || 'Wrong password');
|
|
78
|
+
$('pw').value = '';
|
|
79
|
+
if (r.j.mfa === 'totp') { ticket = r.j.ticket; step(2); $('otp').value = ''; $('otp').focus(); return; }
|
|
80
|
+
T().signedIn(r.j.token);
|
|
81
|
+
}).catch(function () { loginErr('Connection error'); });
|
|
82
|
+
}
|
|
83
|
+
function verifyOtp() {
|
|
84
|
+
loginErr('');
|
|
85
|
+
post('/auth/login/totp', { ticket: ticket, code: $('otp').value.trim() }).then(function (r) {
|
|
86
|
+
if (r.ok) return T().signedIn(r.j.token);
|
|
87
|
+
if (r.j.ticket) { ticket = r.j.ticket; $('otp').value = ''; $('otp').focus(); return loginErr(r.j.error || 'Wrong code'); }
|
|
88
|
+
step(1); loginErr(r.j.error || 'Sign-in expired โ enter your password again');
|
|
89
|
+
}).catch(function () { loginErr('Connection error'); });
|
|
90
|
+
}
|
|
91
|
+
function passkeyLogin() {
|
|
92
|
+
loginErr('');
|
|
93
|
+
post('/auth/passkey/options').then(function (r) {
|
|
94
|
+
if (!r.ok) throw new Error(r.j.error || 'Passkeys are not available');
|
|
95
|
+
return pkGet(r.j.options).then(function (resp) { return post('/auth/passkey/verify', { ticket: r.j.ticket, response: resp }); });
|
|
96
|
+
}).then(function (r) {
|
|
97
|
+
if (!r.ok) return loginErr(r.j.error || 'Passkey not accepted');
|
|
98
|
+
T().signedIn(r.j.token);
|
|
99
|
+
}).catch(function (e) { if (!pkCancelled(e)) loginErr(e.message || 'Passkey sign-in failed'); });
|
|
100
|
+
}
|
|
101
|
+
$('loginBtn').onclick = login;
|
|
102
|
+
$('pw').addEventListener('keydown', function (e) { if (e.key === 'Enter') login(); });
|
|
103
|
+
$('user').addEventListener('keydown', function (e) { if (e.key === 'Enter') $('pw').focus(); });
|
|
104
|
+
$('otpBtn').onclick = verifyOtp;
|
|
105
|
+
$('otp').addEventListener('keydown', function (e) { if (e.key === 'Enter') verifyOtp(); });
|
|
106
|
+
$('passkeyBtn').onclick = passkeyLogin;
|
|
107
|
+
|
|
108
|
+
// โโ invite page โโ
|
|
109
|
+
function showInvite(token) {
|
|
110
|
+
$('app').classList.add('hide'); $('invite').classList.remove('hide');
|
|
111
|
+
fetch('/api/invite/' + token).then(function (r) { return r.json(); }).then(function (inv) {
|
|
112
|
+
if (inv.error) { $('inviteHint').textContent = inv.error; $('inviteForm').classList.add('hide'); return; }
|
|
113
|
+
$('inviteHint').textContent = 'You were invited as ' + ROLE_LABEL[inv.role].toLowerCase() + '. Choose a username and a password.';
|
|
114
|
+
$('invUser').focus();
|
|
115
|
+
});
|
|
116
|
+
var go = function () {
|
|
117
|
+
$('inviteErr').textContent = '';
|
|
118
|
+
post('/api/invite/' + token, { username: $('invUser').value.trim(), password: $('invPw').value }).then(function (r) {
|
|
119
|
+
if (!r.ok) { $('inviteErr').textContent = r.j.error || 'Could not create the account'; return; }
|
|
120
|
+
history.replaceState(null, '', '/');
|
|
121
|
+
$('invite').classList.add('hide');
|
|
122
|
+
T().signedIn(r.j.token);
|
|
123
|
+
});
|
|
124
|
+
};
|
|
125
|
+
$('invBtn').onclick = go;
|
|
126
|
+
$('invPw').addEventListener('keydown', function (e) { if (e.key === 'Enter') go(); });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// โโ settings โโ
|
|
130
|
+
var ROLE_LABEL = { owner: 'Owner', admin: 'Admin', viewer: 'Viewer' };
|
|
131
|
+
var ROLE_HELP = { owner: 'everything, including the team', admin: 'sites, share links and funnels', viewer: 'read only' };
|
|
132
|
+
var me = null;
|
|
133
|
+
function setErr(t) { $('setErr').textContent = t || ''; }
|
|
134
|
+
function afterBoot() {
|
|
135
|
+
call('GET', '/api/me').then(function (m) {
|
|
136
|
+
me = m;
|
|
137
|
+
$('meName').textContent = m.username || 'Settings';
|
|
138
|
+
document.body.classList.toggle('role-viewer', m.role === 'viewer');
|
|
139
|
+
}).catch(function () {});
|
|
140
|
+
}
|
|
141
|
+
function openSettings(tab) {
|
|
142
|
+
setErr(''); $('settingsModal').classList.remove('hide');
|
|
143
|
+
call('GET', '/api/me').then(function (m) {
|
|
144
|
+
me = m;
|
|
145
|
+
$('setTabs').querySelector('[data-t="team"]').classList.toggle('hide', m.role !== 'owner');
|
|
146
|
+
showTab(tab || 'security');
|
|
147
|
+
}).catch(function (e) { setErr(e.message); });
|
|
148
|
+
}
|
|
149
|
+
function showTab(t) {
|
|
150
|
+
Array.prototype.forEach.call($('setTabs').children, function (b) { b.classList.toggle('active', b.dataset.t === t); });
|
|
151
|
+
$('setSecurity').classList.toggle('hide', t !== 'security');
|
|
152
|
+
$('setTeam').classList.toggle('hide', t !== 'team');
|
|
153
|
+
setErr('');
|
|
154
|
+
if (t === 'security') renderSecurity(); else renderTeam();
|
|
155
|
+
}
|
|
156
|
+
$('settingsBtn').onclick = function () { openSettings(); };
|
|
157
|
+
$('closeSettings').onclick = function () { $('settingsModal').classList.add('hide'); };
|
|
158
|
+
$('setTabs').onclick = function (e) { var b = e.target.closest('button'); if (b) showTab(b.dataset.t); };
|
|
159
|
+
function date(ms) { return ms ? new Date(ms).toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }) : 'โ'; }
|
|
160
|
+
|
|
161
|
+
// security tab
|
|
162
|
+
function renderSecurity() {
|
|
163
|
+
var el = $('setSecurity');
|
|
164
|
+
if (me.kind !== 'user') {
|
|
165
|
+
// legacy passphrase (or API key): offer the first account
|
|
166
|
+
el.innerHTML =
|
|
167
|
+
'<div class="set-card"><h3>' + ico('i-user-plus') + 'Create your account</h3>' +
|
|
168
|
+
'<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
|
+
'<strong>After that the passphrase (WEB_PASSWORD) stops working</strong>, so 2FA cannot be bypassed.</p>' +
|
|
170
|
+
'<input id="bsUser" type="text" placeholder="Username" autocomplete="username" autocapitalize="off" spellcheck="false">' +
|
|
171
|
+
'<input id="bsPw" type="password" placeholder="Password (at least 10 characters)" autocomplete="new-password">' +
|
|
172
|
+
'<button class="btn" id="bsBtn">Create owner account</button></div>';
|
|
173
|
+
$('bsBtn').onclick = function () {
|
|
174
|
+
setErr('');
|
|
175
|
+
post('/api/team/bootstrap', { username: $('bsUser').value.trim(), password: $('bsPw').value }, localStorage.getItem('nyx_token')).then(function (r) {
|
|
176
|
+
if (!r.ok) return setErr(r.j.error || 'Could not create the account');
|
|
177
|
+
localStorage.setItem('nyx_token', r.j.token);
|
|
178
|
+
openSettings('security'); afterBoot();
|
|
179
|
+
});
|
|
180
|
+
};
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
var pk = (me.passkeyList || []).map(function (k) {
|
|
184
|
+
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
|
+
'<button class="btn ghost danger mini" data-delpk="' + esc(k.id) + '">Remove</button></div>';
|
|
186
|
+
}).join('');
|
|
187
|
+
el.innerHTML =
|
|
188
|
+
'<div class="set-card"><h3>' + ico('i-key') + 'Passkeys <span class="pill">optional</span></h3>' +
|
|
189
|
+
'<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
|
+
(pk || '<p class="muted small">No passkey yet.</p>') +
|
|
191
|
+
(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>'
|
|
192
|
+
: '<p class="muted small">This browser does not support passkeys.</p>') +
|
|
193
|
+
'</div>' +
|
|
194
|
+
'<div class="set-card"><h3>' + ico('i-smartphone') + 'Two-factor code (OTP) <span class="pill">' + (me.totp ? 'on' : 'optional') + '</span></h3>' +
|
|
195
|
+
(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>' +
|
|
197
|
+
'<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
|
+
: '<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
|
+
'</div>' +
|
|
200
|
+
'<div class="set-card"><h3>' + ico('i-shield') + 'Password</h3>' +
|
|
201
|
+
'<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
|
+
'<p class="muted small">Changing it signs you out on all other devices.</p></div>' +
|
|
203
|
+
'<p class="muted small">Signed in as <strong>' + esc(me.username) + '</strong> ยท ' + ROLE_LABEL[me.role] + '</p>';
|
|
204
|
+
|
|
205
|
+
el.querySelectorAll('[data-delpk]').forEach(function (b) {
|
|
206
|
+
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
|
+
});
|
|
208
|
+
if ($('pkAdd')) $('pkAdd').onclick = function () {
|
|
209
|
+
setErr('');
|
|
210
|
+
call('POST', '/api/me/passkeys/options').then(function (r) {
|
|
211
|
+
return pkCreate(r.options).then(function (resp) { return call('POST', '/api/me/passkeys', { ticket: r.ticket, response: resp, name: $('pkName').value.trim() }); });
|
|
212
|
+
}).then(function () { openSettings('security'); }).catch(function (e) { if (!pkCancelled(e)) setErr(e.message || 'Could not add the passkey'); });
|
|
213
|
+
};
|
|
214
|
+
if ($('totpStart')) $('totpStart').onclick = function () {
|
|
215
|
+
call('POST', '/api/me/totp/setup').then(function (s) {
|
|
216
|
+
$('totpSetup').innerHTML =
|
|
217
|
+
'<div class="totp-qr"><img src="' + s.qr + '" alt="QR code for your authenticator app"></div>' +
|
|
218
|
+
'<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>' +
|
|
219
|
+
'<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>';
|
|
220
|
+
$('totpCode').focus();
|
|
221
|
+
var on = function () {
|
|
222
|
+
setErr('');
|
|
223
|
+
call('POST', '/api/me/totp/enable', { code: $('totpCode').value.trim() }).then(function (r) { showRecovery(r.recoveryCodes); }).catch(function (e) { setErr(e.message); });
|
|
224
|
+
};
|
|
225
|
+
$('totpOn').onclick = on;
|
|
226
|
+
$('totpCode').addEventListener('keydown', function (e) { if (e.key === 'Enter') on(); });
|
|
227
|
+
}).catch(function (e) { setErr(e.message); });
|
|
228
|
+
};
|
|
229
|
+
if ($('totpOff')) $('totpOff').onclick = function () {
|
|
230
|
+
if (!confirm('Turn off two-factor codes?')) return;
|
|
231
|
+
call('POST', '/api/me/totp/disable', { password: $('totpPw').value }).then(function () { openSettings('security'); }).catch(function (e) { setErr(e.message); });
|
|
232
|
+
};
|
|
233
|
+
if ($('newRecovery')) $('newRecovery').onclick = function () {
|
|
234
|
+
call('POST', '/api/me/recovery', { password: $('totpPw').value }).then(function (r) { showRecovery(r.recoveryCodes); }).catch(function (e) { setErr(e.message); });
|
|
235
|
+
};
|
|
236
|
+
$('pwSave').onclick = function () {
|
|
237
|
+
setErr('');
|
|
238
|
+
call('POST', '/api/me/password', { password: $('pwOld').value, newPassword: $('pwNew').value }).then(function () {
|
|
239
|
+
$('pwOld').value = ''; $('pwNew').value = ''; setErr(''); $('pwSave').textContent = 'Changed โ';
|
|
240
|
+
}).catch(function (e) { setErr(e.message); });
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
function showRecovery(codes) {
|
|
244
|
+
$('setSecurity').innerHTML =
|
|
245
|
+
'<div class="set-card"><h3>' + ico('i-shield') + 'Save your recovery codes</h3>' +
|
|
246
|
+
'<p class="muted">Each code works once, instead of a 6-digit code. Keep them somewhere safe โ they are shown only now.</p>' +
|
|
247
|
+
'<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>';
|
|
249
|
+
$('copyRec').onclick = function () { navigator.clipboard.writeText(codes.join('\n')); $('copyRec').textContent = 'Copied โ'; };
|
|
250
|
+
$('recDone').onclick = function () { openSettings('security'); };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// team tab (owner)
|
|
254
|
+
function siteNames(ids) {
|
|
255
|
+
if (!ids) return 'all sites';
|
|
256
|
+
var all = T().sites() || [];
|
|
257
|
+
return ids.map(function (id) { var s = all.filter(function (x) { return x.id === id; })[0]; return s ? s.domain : '#' + id; }).join(', ');
|
|
258
|
+
}
|
|
259
|
+
function sitePicker(idPrefix, selected) {
|
|
260
|
+
var all = T().sites() || [];
|
|
261
|
+
return '<div class="site-pick" id="' + idPrefix + '">' + all.map(function (s) {
|
|
262
|
+
var on = !selected || selected.indexOf(s.id) >= 0;
|
|
263
|
+
return '<label><input type="checkbox" value="' + s.id + '"' + (on ? ' checked' : '') + '> ' + esc(s.domain) + '</label>';
|
|
264
|
+
}).join('') + '</div>';
|
|
265
|
+
}
|
|
266
|
+
function pickedSites(idPrefix) {
|
|
267
|
+
var box = $(idPrefix); if (!box) return null;
|
|
268
|
+
var all = box.querySelectorAll('input'), ids = [];
|
|
269
|
+
all.forEach(function (c) { if (c.checked) ids.push(Number(c.value)); });
|
|
270
|
+
return ids.length === all.length ? null : ids; // everything ticked = all sites, also future ones
|
|
271
|
+
}
|
|
272
|
+
function renderTeam() {
|
|
273
|
+
var el = $('setTeam');
|
|
274
|
+
call('GET', '/api/team').then(function (t) {
|
|
275
|
+
var rows = t.users.map(function (u) {
|
|
276
|
+
var self = me && me.id === u.id;
|
|
277
|
+
return '<div class="set-row team-row" data-uid="' + u.id + '">' +
|
|
278
|
+
'<span><strong>' + esc(u.username) + '</strong>' + (self ? ' <span class="muted small">(you)</span>' : '') +
|
|
279
|
+
'<br><span class="muted small">' + (u.role === 'viewer' ? siteNames(u.sites) + ' ยท ' : '') + (u.totp ? '2FA on' : 'no 2FA') + (u.passkeys ? ' ยท ' + u.passkeys + ' passkey' + (u.passkeys > 1 ? 's' : '') : '') + ' ยท last sign-in ' + date(u.last_login) + '</span></span>' +
|
|
280
|
+
'<select data-role>' + ['owner', 'admin', 'viewer'].map(function (r) { return '<option value="' + r + '"' + (r === u.role ? ' selected' : '') + '>' + ROLE_LABEL[r] + '</option>'; }).join('') + '</select>' +
|
|
281
|
+
'<span class="row-actions">' + (u.role === 'viewer' ? '<button class="btn ghost mini" data-sites>Sites</button>' : '') +
|
|
282
|
+
(u.totp && !self ? '<button class="btn ghost mini" data-reset2fa>Reset 2FA</button>' : '') +
|
|
283
|
+
(!self ? '<button class="btn ghost danger mini" data-remove>Remove</button>' : '') + '</span></div>';
|
|
284
|
+
}).join('');
|
|
285
|
+
var invites = t.invites.map(function (i) {
|
|
286
|
+
return '<div class="set-row"><span>' + ico('i-user-plus') + ROLE_LABEL[i.role] + (i.role === 'viewer' ? ' ยท ' + esc(siteNames(i.sites)) : '') + '</span><span class="muted small">expires ' + date(i.expires) + '</span><button class="btn ghost danger mini" data-delinv="' + i.id + '">Revoke</button></div>';
|
|
287
|
+
}).join('');
|
|
288
|
+
el.innerHTML =
|
|
289
|
+
'<div class="set-card"><h3>' + ico('i-users') + 'Members</h3>' + rows + '</div>' +
|
|
290
|
+
'<div class="set-card"><h3>' + ico('i-user-plus') + 'Invite someone</h3>' +
|
|
291
|
+
'<p class="muted">Creates a link that works once, for 7 days. Send it yourself โ Tacilytics sends no e-mail.</p>' +
|
|
292
|
+
'<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>' +
|
|
293
|
+
'<div id="invSites">' + sitePicker('invPick', null) + '</div>' +
|
|
294
|
+
'<div id="invOut"></div>' + (invites ? '<h4 class="muted small" style="margin-top:12px">Open invites</h4>' + invites : '') + '</div>';
|
|
295
|
+
|
|
296
|
+
$('invRole').onchange = function () { $('invSites').classList.toggle('hide', this.value !== 'viewer'); };
|
|
297
|
+
$('invCreate').onclick = function () {
|
|
298
|
+
setErr('');
|
|
299
|
+
var role = $('invRole').value;
|
|
300
|
+
call('POST', '/api/team/invites', { role: role, sites: role === 'viewer' ? pickedSites('invPick') : null }).then(function (r) {
|
|
301
|
+
var url = location.origin + r.url;
|
|
302
|
+
$('invOut').innerHTML = '<div class="snippet" id="invUrl">' + esc(url) + '</div><button class="btn ghost" id="invCopy">Copy link</button>';
|
|
303
|
+
$('invCopy').onclick = function () { navigator.clipboard.writeText(url); $('invCopy').textContent = 'Copied โ'; };
|
|
304
|
+
}).catch(function (e) { setErr(e.message); });
|
|
305
|
+
};
|
|
306
|
+
el.querySelectorAll('[data-delinv]').forEach(function (b) {
|
|
307
|
+
b.onclick = function () { call('DELETE', '/api/team/invites/' + b.dataset.delinv).then(renderTeam).catch(function (e) { setErr(e.message); }); };
|
|
308
|
+
});
|
|
309
|
+
el.querySelectorAll('.team-row').forEach(function (row) {
|
|
310
|
+
var uid = row.dataset.uid;
|
|
311
|
+
row.querySelector('[data-role]').onchange = function () {
|
|
312
|
+
setErr('');
|
|
313
|
+
call('PATCH', '/api/team/users/' + uid, { role: this.value }).then(renderTeam).catch(function (e) { setErr(e.message); renderTeam(); });
|
|
314
|
+
};
|
|
315
|
+
var rm = row.querySelector('[data-remove]');
|
|
316
|
+
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); }); };
|
|
317
|
+
var r2 = row.querySelector('[data-reset2fa]');
|
|
318
|
+
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
|
+
var st = row.querySelector('[data-sites]');
|
|
320
|
+
if (st) st.onclick = function () {
|
|
321
|
+
var u = t.users.filter(function (x) { return String(x.id) === uid; })[0];
|
|
322
|
+
if (row.nextElementSibling && row.nextElementSibling.classList.contains('site-edit')) { row.nextElementSibling.remove(); return; }
|
|
323
|
+
var box = document.createElement('div'); box.className = 'site-edit';
|
|
324
|
+
box.innerHTML = sitePicker('pick' + uid, u.sites) + '<button class="btn mini" data-savesites>Save</button>';
|
|
325
|
+
row.after(box);
|
|
326
|
+
box.querySelector('[data-savesites]').onclick = function () {
|
|
327
|
+
call('PATCH', '/api/team/users/' + uid, { sites: pickedSites('pick' + uid) }).then(renderTeam).catch(function (e) { setErr(e.message); });
|
|
328
|
+
};
|
|
329
|
+
};
|
|
330
|
+
});
|
|
331
|
+
}).catch(function (e) { setErr(e.message); });
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
window.TaciAccount = { prepareLogin: prepareLogin, showInvite: showInvite, afterBoot: afterBoot };
|
|
335
|
+
})();
|
package/public/app.js
CHANGED
|
@@ -87,23 +87,16 @@
|
|
|
87
87
|
function flag(cc) { return flagIcon(cc); } // returns HTML now (img), never a text emoji
|
|
88
88
|
var NAMES = { DE: 'Germany', AT: 'Austria', CH: 'Switzerland', GB: 'United Kingdom', US: 'United States', FR: 'France', NL: 'Netherlands', ES: 'Spain', IT: 'Italy', SE: 'Sweden', NO: 'Norway', DK: 'Denmark', FI: 'Finland', PL: 'Poland', CZ: 'Czechia', BE: 'Belgium', IE: 'Ireland', PT: 'Portugal', CA: 'Canada', AU: 'Australia', BR: 'Brazil', IN: 'India', JP: 'Japan', CN: 'China', RU: 'Russia', TR: 'Turkey', MX: 'Mexico', HU: 'Hungary', RO: 'Romania', GR: 'Greece', UA: 'Ukraine', HR: 'Croatia', NZ: 'New Zealand', SG: 'Singapore', KR: 'South Korea', IL: 'Israel', AE: 'UAE', ZA: 'South Africa' };
|
|
89
89
|
|
|
90
|
-
// โโ login โโ
|
|
91
|
-
function showLogin() { $('login').classList.remove('hide'); $('app').classList.add('hide'); }
|
|
90
|
+
// โโ login (the form itself lives in account.js: passphrase, accounts, TOTP, passkeys) โโ
|
|
91
|
+
function showLogin() { clearInterval(window.__rt); $('login').classList.remove('hide'); $('app').classList.add('hide'); if (window.TaciAccount) window.TaciAccount.prepareLogin(); }
|
|
92
92
|
function hideLogin() { $('login').classList.add('hide'); $('app').classList.remove('hide'); }
|
|
93
|
-
function
|
|
94
|
-
|
|
95
|
-
fetch('/auth/
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
boot();
|
|
101
|
-
})
|
|
102
|
-
.catch(function () { $('loginErr').textContent = 'Connection error'; });
|
|
103
|
-
}
|
|
104
|
-
$('loginBtn').onclick = login;
|
|
105
|
-
$('pw').addEventListener('keydown', function (e) { if (e.key === 'Enter') login(); });
|
|
106
|
-
$('logoutBtn').onclick = function () { localStorage.removeItem('nyx_token'); TOKEN = ''; showLogin(); };
|
|
93
|
+
function signedIn(token) { TOKEN = token; localStorage.setItem('nyx_token', TOKEN); $('loginErr').textContent = ''; boot(); }
|
|
94
|
+
$('logoutBtn').onclick = function () {
|
|
95
|
+
fetch('/auth/logout', { method: 'POST', headers: { 'x-session-token': TOKEN } }).catch(function () {});
|
|
96
|
+
localStorage.removeItem('nyx_token'); TOKEN = ''; showLogin();
|
|
97
|
+
};
|
|
98
|
+
// hooks for account.js
|
|
99
|
+
window.Taci = { api: api, esc: esc, ico: ico, signedIn: signedIn, sites: function () { return state.sites; }, reloadSites: function () { return loadSites().then(load); } };
|
|
107
100
|
|
|
108
101
|
// โโ sites โโ
|
|
109
102
|
function loadSites() {
|
|
@@ -758,6 +751,7 @@
|
|
|
758
751
|
|
|
759
752
|
function boot() {
|
|
760
753
|
hideLogin();
|
|
754
|
+
if (window.TaciAccount) window.TaciAccount.afterBoot();
|
|
761
755
|
syncLegend();
|
|
762
756
|
loadSites().then(function () { load(); loadFunnels(); });
|
|
763
757
|
// live dashboard: refresh every 15s while the tab is visible
|
|
@@ -776,7 +770,7 @@
|
|
|
776
770
|
state.site = info.domain;
|
|
777
771
|
var sel = $('siteSelect');
|
|
778
772
|
sel.innerHTML = '<option>' + esc(info.domain) + '</option>'; sel.disabled = true;
|
|
779
|
-
['addSiteBtn', 'logoutBtn', 'shareBtn', 'snippetBtn', 'addFunnelBtn'].forEach(function (id) { var el = $(id); if (el) el.classList.add('hide'); });
|
|
773
|
+
['addSiteBtn', 'logoutBtn', 'settingsBtn', 'shareBtn', 'snippetBtn', 'addFunnelBtn'].forEach(function (id) { var el = $(id); if (el) el.classList.add('hide'); });
|
|
780
774
|
syncLegend();
|
|
781
775
|
load();
|
|
782
776
|
clearInterval(window.__rt);
|
|
@@ -784,5 +778,8 @@
|
|
|
784
778
|
});
|
|
785
779
|
}
|
|
786
780
|
|
|
787
|
-
|
|
781
|
+
var INVITE = (location.pathname.match(/^\/invite\/([a-f0-9]{32,64})$/) || [])[1] || '';
|
|
782
|
+
if (SHARE) bootShare();
|
|
783
|
+
else if (INVITE) { $('login').classList.add('hide'); window.TaciAccount.showInvite(INVITE); }
|
|
784
|
+
else if (TOKEN) boot(); else showLogin();
|
|
788
785
|
})();
|
package/public/assets/icons.svg
CHANGED
|
@@ -197,4 +197,9 @@
|
|
|
197
197
|
<symbol id="b-bluesky" viewBox="0 0 24 24"><path fill="currentColor" d="M5.202 2.857C7.954 4.922 10.913 9.11 12 11.358c1.087-2.247 4.046-6.436 6.798-8.501C20.783 1.366 24 .213 24 3.883c0 .732-.42 6.156-.667 7.037-.856 3.061-3.978 3.842-6.755 3.37 4.854.826 6.089 3.562 3.422 6.299-5.065 5.196-7.28-1.304-7.847-2.97-.104-.305-.152-.448-.153-.327 0-.121-.05.022-.153.327-.568 1.666-2.782 8.166-7.847 2.97-2.667-2.737-1.432-5.473 3.422-6.3-2.777.473-5.899-.308-6.755-3.369C.42 10.04 0 4.615 0 3.883c0-3.67 3.217-2.517 5.202-1.026"/></symbol>
|
|
198
198
|
<symbol id="b-mastodon" viewBox="0 0 24 24"><path fill="currentColor" d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/></symbol>
|
|
199
199
|
<symbol id="b-signal" viewBox="0 0 24 24"><path fill="currentColor" d="M12 0q-.934 0-1.83.139l.17 1.111a11 11 0 0 1 3.32 0l.172-1.111A12 12 0 0 0 12 0M9.152.34A12 12 0 0 0 5.77 1.742l.584.961a10.8 10.8 0 0 1 3.066-1.27zm5.696 0-.268 1.094a10.8 10.8 0 0 1 3.066 1.27l.584-.962A12 12 0 0 0 14.848.34M12 2.25a9.75 9.75 0 0 0-8.539 14.459c.074.134.1.292.064.441l-1.013 4.338 4.338-1.013a.62.62 0 0 1 .441.064A9.7 9.7 0 0 0 12 21.75c5.385 0 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25m-7.092.068a12 12 0 0 0-2.59 2.59l.909.664a11 11 0 0 1 2.345-2.345zm14.184 0-.664.909a11 11 0 0 1 2.345 2.345l.909-.664a12 12 0 0 0-2.59-2.59M1.742 5.77A12 12 0 0 0 .34 9.152l1.094.268a10.8 10.8 0 0 1 1.269-3.066zm20.516 0-.961.584a10.8 10.8 0 0 1 1.27 3.066l1.093-.268a12 12 0 0 0-1.402-3.383M.138 10.168A12 12 0 0 0 0 12q0 .934.139 1.83l1.111-.17A11 11 0 0 1 1.125 12q0-.848.125-1.66zm23.723.002-1.111.17q.125.812.125 1.66c0 .848-.042 1.12-.125 1.66l1.111.172a12.1 12.1 0 0 0 0-3.662M1.434 14.58l-1.094.268a12 12 0 0 0 .96 2.591l-.265 1.14 1.096.255.36-1.539-.188-.365a10.8 10.8 0 0 1-.87-2.35m21.133 0a10.8 10.8 0 0 1-1.27 3.067l.962.584a12 12 0 0 0 1.402-3.383zm-1.793 3.848a11 11 0 0 1-2.345 2.345l.664.909a12 12 0 0 0 2.59-2.59zm-19.959 1.1L.357 21.48a1.8 1.8 0 0 0 2.162 2.161l1.954-.455-.256-1.095-1.953.455a.675.675 0 0 1-.81-.81l.454-1.954zm16.832 1.769a10.8 10.8 0 0 1-3.066 1.27l.268 1.093a12 12 0 0 0 3.382-1.402zm-10.94.213-1.54.36.256 1.095 1.139-.266c.814.415 1.683.74 2.591.961l.268-1.094a10.8 10.8 0 0 1-2.35-.869zm3.634 1.24-.172 1.111a12.1 12.1 0 0 0 3.662 0l-.17-1.111q-.812.125-1.66.125a11 11 0 0 1-1.66-.125"/></symbol>
|
|
200
|
+
<symbol id="i-key" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z" /><circle cx="16.5" cy="7.5" r=".5" fill="currentColor" /></symbol>
|
|
201
|
+
<symbol id="i-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" /><circle cx="12" cy="12" r="3" /></symbol>
|
|
202
|
+
<symbol id="i-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" /><path d="m9 12 2 2 4-4" /></symbol>
|
|
203
|
+
<symbol id="i-user-plus" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><line x1="19" x2="19" y1="8" y2="14" /><line x1="22" x2="16" y1="11" y2="11" /></symbol>
|
|
204
|
+
<symbol id="i-smartphone" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="20" x="5" y="2" rx="2" ry="2" /><path d="M12 18h.01" /></symbol>
|
|
200
205
|
</svg>
|