Files
design-v3/07-access.html
2026-09-25 10:26:24 -04:00

59 lines
9.1 KiB
HTML

<style>
.roles { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); gap: var(--space-3); }
.role { padding: var(--space-4); display: flex; flex-direction: column; gap: 4px; font-size: var(--fs-label); }
.role b { font-size: 14px; }
.person { padding: var(--space-4) var(--space-5); display: grid; grid-template-columns: 44px minmax(0, 1fr) auto; gap: var(--space-4); align-items: center; }
.avatar { width: 44px; height: 44px; border-radius: 50%; display: grid; place-items: center; font-weight: 800; background: var(--surface-inset); box-shadow: var(--nm-inset-1); border: 1px solid var(--border-control); }
.sessions { grid-column: 2 / -1; display: flex; flex-direction: column; gap: 6px; }
.sess { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 2px 12px; padding: 8px 12px; border-radius: var(--radius-sm); background: var(--surface-inset); border: 1px solid var(--border-hairline); font-size: var(--fs-label); }
.token-once { padding: var(--space-4); border: 2px solid var(--status-warning); background: var(--wash-warning); border-radius: var(--radius-md); display: flex; flex-direction: column; gap: 8px; }
.token-once code { font-size: 13px; padding: 8px 10px; display: block; overflow-wrap: anywhere; }
.gap-tag { font-size: var(--fs-caption); }
@media (max-width: 699px) { .person { grid-template-columns: 36px minmax(0, 1fr); } .person > .act { grid-column: 1 / -1; } .avatar { width: 36px; height: 36px; } .sessions { grid-column: 1 / -1; } }
</style>
<script>
(function () {
var x = X;
var S = { revoke: null, newToken: null, expanded: { 1: true } };
function rel(iso) { return x.when(iso); }
var ROLE = { reader: ['Reader', 'Sees everything: inventory, prints, printers, camera snapshots, holder labels.'], operator: ['Operator', 'Reader, plus changing inventory: spools, weigh-ins, moves, labels, prints, lists.'], admin: ['Admin', 'Operator, plus printers and holders, access and system settings.'] };
function people() {
return PEOPLE.map(function (p) {
var active = p.sessions.filter(function (s) { return !s.revoked; });
var sess = S.expanded[p.id] ? '<div class="sessions">' + (active.length ? active.map(function (s) { return '<div class="sess"><span><b>' + (s.you ? 'This browser' : 'Session') + '</b> · signed in ' + rel(s.created) + '</span><span class="muted">last seen ' + rel(s.lastSeen) + '</span><span class="muted">idle expiry ' + rel(s.idleExp) + '</span><span class="muted">hard expiry ' + rel(s.maxExp) + '</span></div>'; }).join('') : '<span class="small muted">No local sessions recorded.</span>') + '</div>' : '';
var act = S.revoke === p.id
? '<div class="stack act" style="gap:6px;align-items:flex-end"><span class="small">Sign ' + x.esc(p.name) + ' out of ' + active.length + ' session' + (active.length > 1 ? 's' : '') + '?' + (p.id === 1 ? ' <b>Including this one.</b>' : '') + '</span><div class="row" style="gap:6px"><button class="btn btn-sm" type="button" data-keep>Cancel</button><button class="btn btn-sm btn-danger" type="button" data-do-revoke="' + p.id + '">Revoke</button></div></div>'
: '<div class="row act" style="gap:6px;justify-content:flex-end">' + (active.length ? '<button class="btn btn-sm" type="button" data-expand="' + p.id + '" aria-expanded="' + !!S.expanded[p.id] + '">' + active.length + ' session' + (active.length > 1 ? 's' : '') + ' ' + (S.expanded[p.id] ? '▴' : '▾') + '</button><button class="btn btn-sm" type="button" data-revoke="' + p.id + '">Revoke…</button>' : '<span class="small muted">no sessions</span>') + '</div>';
return '<article class="person nm-raised"><span class="avatar" aria-hidden="true">' + x.esc(p.name[0]) + '</span><div style="min-width:0"><b>' + x.esc(p.name) + '</b>' + (p.id === 1 ? ' <span class="sub" style="display:inline">(you)</span>' : '') + '<span class="sub">' + (p.email ? x.esc(p.email) + ' · ' : '') + '<span class="st ' + (p.role === 'admin' ? 'run' : p.role === 'operator' ? 'ok' : 'idle') + '" style="font-size:11px">' + ROLE[p.role][0] + '</span> · last seen ' + rel(p.lastSeen) + '</span></div>' + act + sess + '</article>';
}).join('');
}
function machines() {
return MACHINES.map(function (m) {
return '<article class="nm-raised" style="padding:0;overflow:hidden"><div class="row" style="padding:14px 16px;gap:12px"><span class="avatar" style="border-radius:12px" aria-hidden="true">⚙</span><span style="flex:1;min-width:0"><b>' + x.esc(m.name) + '</b><span class="sub">machine · <span class="st ' + (m.role === 'operator' ? 'ok' : 'idle') + '" style="font-size:11px">' + ROLE[m.role][0] + '</span></span></span><button class="btn btn-sm" type="button" data-newtoken="' + m.id + '">+ New token</button></div>' +
(S.newToken === m.id ? '<div style="padding:0 16px 16px"><div class="token-once" role="alert"><b>Copy this token now. It won\'t be shown again.</b><code>exd_9f2c…(sample, the full token appears once)</code><span class="small">Only its hash is stored. If it\'s lost, revoke it and make a new one.</span><div class="row" style="gap:8px"><button class="btn btn-sm btn-primary" type="button" data-fake="Token copied to clipboard">Copy</button><button class="btn btn-sm" type="button" data-done-token>I\'ve stored it</button></div></div></div>' : '') +
'<table class="tbl"><thead><tr><th>Token</th><th class="hide-m">Created</th><th>Last used</th><th class="r"><span class="sr-only">Actions</span></th></tr></thead><tbody>' + m.tokens.map(function (t) { return '<tr' + (t.revoked ? ' style="opacity:.6"' : '') + '><td><b>' + x.esc(t.name) + '</b>' + (t.revoked ? '<span class="sub">revoked ' + rel(t.revoked) + '</span>' : '') + '</td><td class="hide-m">' + rel(t.created) + '</td><td>' + (t.lastUsed ? rel(t.lastUsed) : '<span class="muted">never</span>') + '</td><td class="r">' + (t.revoked ? '' : '<button class="btn btn-sm btn-danger" type="button" data-fake="Revoked ' + x.esc(t.name) + '. Requests using it now get 401.">Revoke</button>') + '</td></tr>'; }).join('') + '</tbody></table></article>';
}).join('');
}
function render() {
return '<header class="page-head"><div><h1>Access</h1><p class="sum">Admin only. Who can use Extrudex, and what they can do.</p></div><a class="btn" href="#" data-fake="Opens Authentik in a new tab">Open Authentik ↗</a></header>' +
'<div class="banner" style="border-color:var(--status-info);background:var(--wash-info)"><b style="color:var(--status-info)">i</b><span style="flex:1">People, roles, passwords and account activation are managed in <b>Authentik</b>. This page shows Extrudex\'s local view: roles as Extrudex sees them, and sign-in sessions you can revoke here.</span></div>' +
'<section class="roles" aria-label="Roles">' + ['reader', 'operator', 'admin'].map(function (r) { return '<div class="role nm-inset"><b>' + ROLE[r][0] + '</b><span class="muted">' + ROLE[r][1] + '</span></div>'; }).join('') + '</section>' +
'<section class="stack" style="gap:12px" aria-labelledby="ppl"><h2 class="section" id="ppl" style="margin:0">People · ' + PEOPLE.length + '</h2>' + people() + '</section>' +
'<section class="stack" style="gap:12px" aria-labelledby="mach"><h2 class="section" id="mach" style="margin:0">Machines &amp; API tokens <span class="prov warn gap-tag"><i>⚠</i>API exists · no screen on dev yet</span></h2><p class="small muted" style="margin:0">For integrations like the gateway and health checks. Each machine gets a role; tokens are bearer credentials and can be revoked one at a time.</p>' + machines() +
'<button class="btn" type="button" style="align-self:flex-start" data-fake="New machine form: name + role">+ Add machine</button></section>' +
'<details class="nm-inset panel"><summary class="small strong" style="cursor:pointer">Runbook: changing someone\'s access</summary><ol class="small" style="margin:10px 0 0;padding-left:18px"><li>Change the group in Authentik.</li><li>Existing sessions pick it up when Extrudex next re-checks authorization (each session records when it was last checked). Revoke their sessions here to make it take effect now.</li><li>Removed people keep their name on audit history.</li></ol></details>';
}
function redraw() { document.getElementById('page').innerHTML = render(); }
document.addEventListener('click', function (e) {
var t = e.target, a;
if ((a = t.closest('[data-expand]'))) { var id = +a.getAttribute('data-expand'); S.expanded[id] = !S.expanded[id]; return redraw(); }
if ((a = t.closest('[data-revoke]'))) { S.revoke = +a.getAttribute('data-revoke'); return redraw(); }
if (t.closest('[data-keep]')) { S.revoke = null; return redraw(); }
if ((a = t.closest('[data-do-revoke]'))) { var p = PEOPLE.filter(function (pp) { return pp.id === +a.getAttribute('data-do-revoke'); })[0]; p.sessions.forEach(function (s) { s.revoked = '2026-09-18T09:41'; }); S.revoke = null; X.toast('Revoked ' + x.esc(p.name) + '\'s local sessions. They\'ll need to sign in again.'); return redraw(); }
if ((a = t.closest('[data-newtoken]'))) { S.newToken = +a.getAttribute('data-newtoken'); return redraw(); }
if (t.closest('[data-done-token]')) { S.newToken = null; return redraw(); }
});
X.init({ name: 'access' }, render);
})();
</script>