also looking at this
A read-scoped site key, so a Stacks dashboard can read analytics without a person's credentials
#68
7 files
+550
-11
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -373,6 +373,8 @@ let shareMode = false | ||
| 373 | 373 | // The owner's existing share token for the active site ('' if none), so the owner |
| 374 | 374 | // UI can show/copy/revoke the link. Bridged into the client block. |
| 375 | 375 | let __shareToken = '' |
| 376 | let __hasReadKey = false | |
| 377 | let __readKeyUsed = '' | |
| 376 | 378 | |
| 377 | 379 | // Raw rows, read into the shapes in types/dashboard.d.ts as each query returns. |
| 378 | 380 | // `db.unsafe` answers Record<string, unknown>, and Postgres hands COUNT(*) back as |
| @@ -498,6 +500,16 @@ async function loadData(): Promise<void> { | ||
| 498 | 500 | if (!shareMode) |
| 499 | 501 | __shareToken = text((await readSettings(siteId)).share_token) || '' |
| 500 | 502 | |
| 503 | // The read key's presence and last use, never the key (#67). It is a secret, | |
| 504 | // shown once when it is issued and never again, so the page is only ever told | |
| 505 | // whether one exists — enough to tell a live key from a forgotten one before | |
| 506 | // revoking it. | |
| 507 | if (!shareMode) { | |
| 508 | const keyRow = (await pgq(`SELECT read_key, read_key_last_used_at FROM sites WHERE id = ? LIMIT 1`, [String(siteId)]))?.[0] | |
| 509 | __hasReadKey = !!(keyRow && keyRow.read_key) | |
| 510 | __readKeyUsed = keyRow && keyRow.read_key_last_used_at ? String(keyRow.read_key_last_used_at) : '' | |
| 511 | } | |
| 512 | ||
| 501 | 513 | // Team: who can already reach this site, and who has been invited but has not |
| 502 | 514 | // accepted yet. Resolved server-side and rendered with @foreach so the panel |
| 503 | 515 | // says something before the bundle runs — an x-for over a reactive list is the |
| @@ -1382,12 +1394,16 @@ const exportPayload = { | ||
| 1382 | 1394 | // UI so it can display the existing share link. The underlying server binding |
| 1383 | 1395 | // stays private and only this exact scalar crosses the bridge. |
| 1384 | 1396 | const shareCapability = __shareToken |
| 1397 | // Presence and last use only. The key itself is never bridged, because it is | |
| 1398 | // never on the server side of this page either — see the read of it above. | |
| 1399 | const readKeyPresent = __hasReadKey | |
| 1400 | const readKeyUsedAt = __readKeyUsed | |
| 1385 | 1401 | // regionGeo and cityGeo seed the two geo toggles. They were missing from this |
| 1386 | 1402 | // list for regions, and a declared payload bridges ONLY what it names, so |
| 1387 | 1403 | // `typeof regionGeo` was 'undefined' in the client block and the box always |
| 1388 | 1404 | // loaded unticked on a site that had regions on -- one click then sent `true` |
| 1389 | 1405 | // again rather than turning it off. |
| 1390 | defineClientPayload({ activeSiteId, chartTz, cityGeo, countryTotals, exportPayload, installOptions, liveNow, livePlaces, liveWhere, range, regionGeo, savedTz, shareCapability, visitorTimelines }) | |
| 1406 | defineClientPayload({ activeSiteId, chartTz, cityGeo, countryTotals, exportPayload, installOptions, liveNow, livePlaces, liveWhere, range, readKeyPresent, readKeyUsedAt, regionGeo, savedTz, shareCapability, visitorTimelines }) | |
| 1391 | 1407 | |
| 1392 | 1408 | useSeoMeta({ |
| 1393 | 1409 | title: "Dashboard - analyticshq", |
| @@ -1412,6 +1428,96 @@ const isPro = state(false) | ||
| 1412 | 1428 | // declared further down and reached by hoisting, which is the trade for having one |
| 1413 | 1429 | // place that decides what a share URL looks like. |
| 1414 | 1430 | const shareUrl = state(shareCapability ? shareLinkFor(shareCapability) : '') |
| 1431 | ||
| 1432 | // --- Read key --------------------------------------------------------------- | |
| 1433 | // Issued on demand, shown once, revocable (#67). The key lives in a signal only | |
| 1434 | // for as long as the page that issued it is open: nothing renders it from the | |
| 1435 | // server and nothing stores it, so leaving the page is the end of the only copy | |
| 1436 | // the browser ever had. | |
| 1437 | const hasReadKey = state(readKeyPresent) | |
| 1438 | const readKeyUsed = state(readKeyUsedAt) | |
| 1439 | const readKeyOnce = state('') | |
| 1440 | const readKeyBusy = state(false) | |
| 1441 | const readKeyCopied = state(false) | |
| 1442 | ||
| 1443 | function readKeyStatus(): string { | |
| 1444 | if (!hasReadKey()) | |
| 1445 | return 'No read key. Issue one to let a server read this site.' | |
| 1446 | const at = readKeyUsed() | |
| 1447 | if (!at) | |
| 1448 | return 'A read key is active — never used.' | |
| 1449 | const when = new Date(at) | |
| 1450 | return Number.isNaN(when.getTime()) | |
| 1451 | ? 'A read key is active — used recently.' | |
| 1452 | : `A read key is active — last used ${when.toLocaleString()}.` | |
| 1453 | } | |
| 1454 | ||
| 1455 | function copyReadKey() { | |
| 1456 | const value = readKeyOnce() | |
| 1457 | if (!value || !navigator.clipboard?.writeText) | |
| 1458 | return | |
| 1459 | void navigator.clipboard.writeText(value).then(() => { | |
| 1460 | readKeyCopied.set(true) | |
| 1461 | setTimeout(() => readKeyCopied.set(false), 1600) | |
| 1462 | }, () => {}) | |
| 1463 | } | |
| 1464 | ||
| 1465 | async function issueReadKey() { | |
| 1466 | if (readKeyBusy()) | |
| 1467 | return | |
| 1468 | // Replacing is destructive in the same way revoking is, so it asks — but only | |
| 1469 | // when there is something to break. | |
| 1470 | if (hasReadKey() && !(await stxConfirm('Replace the read key? Anything using the current one stops reading immediately.'))) | |
| 1471 | return | |
| 1472 | readKeyBusy.set(true) | |
| 1473 | let failed = false | |
| 1474 | try { | |
| 1475 | const res = await fetch(`/api/sites/${encodeURIComponent(activeSiteId)}/read-key`, { method: 'POST', credentials: 'same-origin' }) | |
| 1476 | const d: { read_key?: string } = await res.json().catch(() => ({})) | |
| 1477 | if (res.ok && d.read_key) { | |
| 1478 | readKeyOnce.set(d.read_key) | |
| 1479 | hasReadKey.set(true) | |
| 1480 | readKeyUsed.set('') | |
| 1481 | } | |
| 1482 | else { | |
| 1483 | failed = true | |
| 1484 | } | |
| 1485 | } | |
| 1486 | catch { | |
| 1487 | failed = true | |
| 1488 | } | |
| 1489 | readKeyBusy.set(false) | |
| 1490 | if (failed) | |
| 1491 | await stxAlert('Could not issue a read key. Please try again.') | |
| 1492 | } | |
| 1493 | ||
| 1494 | async function revokeReadKey() { | |
| 1495 | if (readKeyBusy()) | |
| 1496 | return | |
| 1497 | if (!(await stxConfirm('Revoke the read key? Anything using it stops reading immediately.'))) | |
| 1498 | return | |
| 1499 | readKeyBusy.set(true) | |
| 1500 | let failed = false | |
| 1501 | try { | |
| 1502 | const res = await fetch(`/api/sites/${encodeURIComponent(activeSiteId)}/read-key`, { method: 'DELETE', credentials: 'same-origin' }) | |
| 1503 | if (res.ok) { | |
| 1504 | hasReadKey.set(false) | |
| 1505 | readKeyUsed.set('') | |
| 1506 | readKeyOnce.set('') | |
| 1507 | } | |
| 1508 | else { | |
| 1509 | failed = true | |
| 1510 | } | |
| 1511 | } | |
| 1512 | catch { | |
| 1513 | failed = true | |
| 1514 | } | |
| 1515 | readKeyBusy.set(false) | |
| 1516 | // Worth surfacing rather than swallowing, for the same reason a failed share | |
| 1517 | // revoke is: the old key is still live, and clearing nothing looks like success. | |
| 1518 | if (failed) | |
| 1519 | await stxAlert('Could not revoke the read key. It may still be active. Please try again.') | |
| 1520 | } | |
| 1415 | 1521 | const shareBusy = state(false) |
| 1416 | 1522 | const shareCopied = state(false) |
| 1417 | 1523 | // Live "current visitors": seeded from the server render, then polled so the |
| @@ -3775,6 +3881,44 @@ onMount(() => { | ||
| 3775 | 3881 | </div> |
| 3776 | 3882 | @endif |
| 3777 | 3883 | |
| 3884 | {{-- Read key: the credential a server presents to read this site's reports | |
| 3885 | without signing in as anybody (#67). Sits beside the share link because | |
| 3886 | both hand reading to someone who is not you — but a share link is a URL | |
| 3887 | for a person's browser, and this is a secret for a server, which is the | |
| 3888 | distinction the copy has to carry. | |
| 3889 | ||
| 3890 | Same :show discipline as the share panel above, and for the reason | |
| 3891 | spelled out there: a client-signal conditional nested inside a server | |
| 3892 | one breaks stx's directive matching (§7.3), so :show is the binding, | |
| 3893 | and the SSR state rides in the static style (§7.1). | |
| 3894 | ||
| 3895 | Written without naming those directives literally — prose that spells | |
| 3896 | them has made a matcher start inside a comment before. --}} | |
| 3897 | @if (ownsActive) | |
| 3898 | <div class="p-5 panel site-settings-panel"> | |
| 3899 | <div class="flex flex-wrap gap-3 items-center justify-between"> | |
| 3900 | <div> | |
| 3901 | <div class="font-semibold text-sm">Read key</div> | |
| 3902 | <div class="mt-0.5 text-text-3 text-xs">Lets a server read this site's reports machine-to-machine. Unlike a share link this is a secret: it belongs to no account and keeps working after you leave.</div> | |
| 3903 | </div> | |
| 3904 | ||
| 3905 | {{-- Just issued: the only time the key is ever shown. --}} | |
| 3906 | <div class="flex flex-wrap gap-2 items-center" :show="readKeyOnce()" style="display:none"> | |
| 3907 | <input readonly :value="readKeyOnce()" aria-label="Read key" class="text-xs mono" style="min-width:220px;max-width:340px;padding:6px 9px;border-radius:8px;border:1px solid var(--border);background:var(--bg);color:var(--text-2)"> | |
| 3908 | <button type="button" @click="copyReadKey" style="padding:6px 11px;border-radius:8px;border:1px solid var(--border);background:var(--panel);color:var(--text-2);font:600 12px/1 var(--sans,ui-sans-serif);cursor:pointer">{{ readKeyCopied() ? 'Copied' : 'Copy' }}</button> | |
| 3909 | </div> | |
| 3910 | ||
| 3911 | {{-- Otherwise: whether one exists, and whether it is still in use. --}} | |
| 3912 | <div class="flex flex-wrap gap-2 items-center" :show="!readKeyOnce()" style="display:none"> | |
| 3913 | <span class="text-text-3 text-xs" style="max-width:340px">{{ readKeyStatus() }}</span> | |
| 3914 | <button type="button" @click="issueReadKey" :disabled="readKeyBusy()" style="padding:6px 11px;border-radius:8px;border:1px solid var(--border);background:var(--panel);color:var(--text-2);font:600 12px/1 var(--sans,ui-sans-serif);cursor:pointer">{{ readKeyBusy() ? 'Working…' : (hasReadKey() ? 'Replace' : 'Issue key') }}</button> | |
| 3915 | <button type="button" @click="revokeReadKey" :disabled="readKeyBusy()" :show="hasReadKey()" style="display:none;padding:6px 11px;border-radius:8px;border:1px solid var(--border);background:var(--panel);color:var(--text-3);font:600 12px/1 var(--sans,ui-sans-serif);cursor:pointer">Revoke</button> | |
| 3916 | </div> | |
| 3917 | </div> | |
| 3918 | <div class="mt-2 text-text-3" style="font-size:11px">Shown once when issued. The last-used time is written at most once a minute, so a key in constant use shows a time up to a minute old.</div> | |
| 3919 | </div> | |
| 3920 | @endif | |
| 3921 | ||
| 3778 | 3922 | <footer class="mt-12 text-center text-text-3 text-xs"> |
| 3779 | 3923 | Privacy-first, cookieless analytics on Stacks and PostgreSQL. <span class="mono">analyticshq.org</span> |
| 3780 | 3924 | </footer> |