Review pass on the Lightwell redesign: layout, copy and behavior fixes
CI / test (push) Successful in 3m20s
Release / release (push) Successful in 5m6s

Dialog buttons are Cancel then the primary action at the bottom right, and each page has one primary
action. List pages no longer repeat their title. Dates have the month written out. Fields in a row
line up, hints sit under fields instead of inside labels, and long certificate errors are clamped.
Organizations show whether local access is on and name the action; setting a user's password uses a
dialog instead of a browser prompt. Server errors for sign-in and ranges are reworded with a next step,
and the stray scrollbar inside tables is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Opus 5.5 committed 2026-10-26 18:38:56 -06:00
1 parent d74e6c1e72
commit c585ddbf63
9 files changed
+130 -108

No files matched your search

+97 -84
View File
@@ -61,6 +61,8 @@
.skip:focus { top:var(--s3); }
.dim { color:var(--text-muted); } .err { color:var(--danger); } .small { font-size:var(--text-sm); }
.num { font-variant-numeric:tabular-nums; }
/* long machine messages (certificate errors) wrap and stop at three lines; the full text is in the tooltip */
.clip { display:-webkit-box; -webkit-line-clamp:3; -webkit-box-orient:vertical; overflow:hidden; overflow-wrap:anywhere; max-width:340px; }
code, .mono { font-family:var(--font-mono); font-size:var(--text-sm); }
.icon { width:18px; height:18px; flex:none; stroke-width:1.75; }
@@ -93,8 +95,11 @@
.card > h2 { font-size:var(--text-lg); margin:0 0 var(--s3); }
.row-h { display:flex; align-items:center; justify-content:flex-end; gap:var(--s3); margin-bottom:var(--s3); flex-wrap:wrap; }
.row-h h2 { display:none; }
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); gap:var(--s4); align-items:end; }
.stack { display:flex; flex-direction:column; gap:var(--s3); }
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); gap:var(--s4); align-items:start; }
.stack { display:flex; flex-direction:column; gap:var(--s4); }
/* a button or checkbox in a row of fields lines up with the inputs, below their labels */
.grid > button { justify-self:start; margin-top:calc(var(--text-sm) * var(--leading) + var(--s1)); }
.grid > label.chk { margin-top:calc(var(--text-sm) * var(--leading) + var(--s1) + var(--s2)); }
.sect-h { padding:var(--s3) var(--s4); font-weight:600; border-bottom:var(--border-w) solid var(--border); }
.scroll { overflow-x:auto; }
.actions { display:flex; gap:var(--s2); flex-wrap:wrap; }
@@ -144,10 +149,9 @@
/* tables */
table { width:100%; border-collapse:collapse; }
th { text-align:left; font-size:var(--text-sm); color:var(--text-muted); font-weight:600; padding:var(--s3) var(--s4); border-bottom:var(--border-w) solid var(--border); white-space:nowrap; background:var(--surface); }
td { padding:var(--s3) var(--s4); border-bottom:var(--border-w) solid var(--border); vertical-align:top; }
td { padding:var(--s3) var(--s4); border-bottom:var(--border-w) solid var(--border); vertical-align:middle; }
tbody tr:hover td { background:var(--surface-2); }
tr:last-child td { border-bottom:0; }
.card > table, .card > .scroll > table { margin:calc(var(--s1) * -1) 0; }
/* badges and tags: status always carries a text label, never color alone */
.badge { display:inline-flex; align-items:center; gap:var(--s1); padding:2px var(--s2); border-radius:999px; font-size:var(--text-xs); font-weight:600; background:var(--neutral-bg); color:var(--text-muted); white-space:nowrap; }
@@ -244,6 +248,7 @@ function toast(msg, bad) {
toastBox.append(t);
setTimeout(() => t.remove(), bad ? 7000 : 3000);
}
const SIGNED_OUT = 'Your session ended. Sign in again to continue.';
async function api(method, url, body) {
const r = await fetch(url, { method, headers: {'Content-Type':'application/json'}, body: body !== undefined ? JSON.stringify(body) : undefined });
const j = await r.json().catch(() => ({}));
@@ -251,7 +256,7 @@ async function api(method, url, body) {
// the session ended while we were signed in: go back to the login page once.
// (Not for /api/me or /api/public: boot() calls those, and reacting to them would loop forever.)
if (S.me) { S.me = null; boot(); }
throw new Error('Please sign in');
throw new Error(SIGNED_OUT);
}
if (!r.ok) throw new Error(j.error || r.statusText);
return j;
@@ -260,6 +265,9 @@ async function api(method, url, body) {
const act = fn => async (...a) => { try { await fn(...a); } catch (e) { toast(e.message, true); } };
const bytes = n => { n = n || 0; const u = ['B','KB','MB','GB','TB']; let i = 0; while (n >= 1024 && i < 4) { n /= 1024; i++; } return (i ? n.toFixed(1) : n) + ' ' + u[i]; };
const ago = d => { if (!d || d.startsWith('0001')) return 'never'; const s = (Date.now() - new Date(d)) / 1000; return s < 90 ? 'just now' : s < 5400 ? Math.round(s / 60) + ' min ago' : s < 129600 ? Math.round(s / 3600) + ' h ago' : Math.round(s / 86400) + ' d ago'; };
// dates with the month written out, as the design language asks ("Oct 21, 2026")
const fmtDate = d => new Date(d).toLocaleDateString(undefined, {month:'short', day:'numeric', year:'numeric'});
const fmtTime = d => new Date(d).toLocaleString(undefined, {month:'short', day:'numeric', year:'numeric', hour:'numeric', minute:'2-digit'});
const byId = (list, id) => (list || []).find(x => x.id === id);
const field = (label, el, hint) => h('label', {}, label, el, hint ? h('span', {class:'sub'}, hint) : null);
const chk = (label, checked, onchange) => { const i = h('input', {type:'checkbox', checked: !!checked}); if (onchange) i.onchange = () => onchange(i.checked); return Object.assign(h('label', {class:'chk'}, i, label), {box:i}); };
@@ -283,7 +291,7 @@ function updatePanel(load, onDone) {
if (!s.running && (s.stage === 'done' || s.stage === 'failed')) { again = false; if (onDone) onDone(s); }
} catch (e) {
misses++; // the server is probably restarting into the new version
if (misses > 2) stageEl.textContent = 'Restarting… waiting for it to come back';
if (misses > 2) stageEl.textContent = 'Restarting. Waiting for it to come back.';
if (misses > 150) { again = false; stageEl.textContent = 'It has not come back after a few minutes. Check its log on the server.'; }
}
if (again) setTimeout(tick, 800);
@@ -320,7 +328,7 @@ const versionCell = (m, latest, pending, updateUrl, logUrl) => {
const card = (title, ...kids) => h('div', {class:'card'}, title ? h('h2', {}, title) : null, ...kids);
function dialogForm(title, intro, fields, label, submit) {
const close = modal(title, h('div', {class:'stack'}, intro ? h('p', {class:'dim'}, intro) : null, ...fields,
h('div', {class:'actions'}, h('button', {class:'btn', onclick: act(async () => { await submit(); close(); })}, label), h('button', {class:'ghost', onclick:() => close()}, 'Cancel'))));
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => close()}, 'Cancel'), h('button', {class:'btn', onclick: act(async () => { await submit(); close(); })}, label))));
}
const cmdBox = t => h('pre', {class:'mono'}, t);
// A dialog: focus moves in, Tab stays inside, Escape closes, and focus goes back to what opened it.
@@ -432,8 +440,8 @@ async function boot() {
// ---------- first run, login, invites ----------
function setupView() {
const f = { org: h('input', {value:'Default'}), name: h('input', {placeholder:'Your name'}), email: h('input', {type:'email'}), pw: h('input', {type:'password'}) };
return h('div', {class:'center'}, card('', brand(), h('h2', {style:'text-align:center'}, 'Welcome to Hammerhead'), h('p', {class:'dim'}, 'Create the first administrator.'),
h('div', {class:'stack'}, field('Organization name', f.org), field('Your name', f.name), field('Email', f.email), field('Password (8+ characters)', f.pw),
return h('div', {class:'center'}, card('', brand(), h('h2', {}, 'Welcome to Hammerhead'), h('p', {class:'dim'}, 'Create the first administrator.'),
h('div', {class:'stack'}, field('Organization name', f.org), field('Your name', f.name), field('Email', f.email), field('Password', f.pw, 'At least 8 characters'),
h('button', {class:'btn', onclick: act(async () => {
await api('POST', '/api/setup', {orgName:f.org.value, name:f.name.value, email:f.email.value, password:f.pw.value});
await api('POST', '/api/login', {email:f.email.value, password:f.pw.value}); boot();
@@ -447,10 +455,11 @@ function loginView() {
const go = act(async () => {
const j = ticket ? await api('POST', '/api/login/totp', {ticket, code:code.value}) : await api('POST', '/api/login', {email:email.value, password:pw.value});
if (j.needTotp) { ticket = j.ticket; codeBox.style.display = ''; pw.parentElement.style.display = email.parentElement.style.display = 'none'; code.focus(); return; }
if (toastBox) toastBox.replaceChildren(); // an earlier failed attempt no longer applies
boot();
});
[email, pw, code].forEach(i => i.addEventListener('keydown', e => e.key === 'Enter' && go()));
return h('div', {class:'center'}, card('', brand(), h('h2', {style:'text-align:center'}, 'Sign in'), h('div', {class:'stack'},
return h('div', {class:'center'}, card('', brand(), h('h2', {}, 'Sign in'), h('div', {class:'stack'},
field('Email', email), field('Password', pw), codeBox, h('button', {class:'btn', onclick:go}, 'Sign in'),
(S.pub.providers || []).map(p => h('a', {href:'/auth/oidc/' + p.id + '/start?return=/'}, h('button', {class:'ghost', style:'width:100%;padding:8px'}, 'Continue with ' + p.name))))));
}
@@ -464,7 +473,7 @@ async function inviteView(tok) {
S.me && S.me.email === info.email ? h('button', {class:'btn', onclick:accept}, 'Join ' + info.org) : h('p', {}, 'Sign in as that account first, then open this link again.'),
S.me ? null : h('button', {class:'ghost', onclick:() => { location.hash = ''; boot(); }}, 'Go to sign in')));
return h('div', {class:'center'}, card('Join ' + info.org, h('p', {class:'dim'}, 'Create your account for ' + info.email + '.'),
h('div', {class:'stack'}, field('Your name', name), field('Password (8+ characters)', pw), h('button', {class:'btn', onclick:accept}, 'Create account'))));
h('div', {class:'stack'}, field('Your name', name), field('Password', pw, 'At least 8 characters'), h('button', {class:'btn', onclick:accept}, 'Create account'))));
}
// ---------- shell ----------
@@ -561,7 +570,7 @@ async function refresh() {
if (my !== seq) return;
const v = document.getElementById('view');
if (v) { v.replaceChildren(pageHeader(S.tab), view); S.drawn = S.tab; }
} catch (e) { if (e.message !== 'Please sign in') toast(e.message, true); }
} catch (e) { if (e.message !== SIGNED_OUT) toast(e.message, true); }
}
setInterval(() => {
if (!S.me || document.hidden || modalOpen()) return;
@@ -587,7 +596,8 @@ const PROTOS = [['tcp','TCP'],['udp','UDP'],['both','TCP + UDP'],['http','HTTP (
views.ports = async () => {
const st = S.st;
if (!st.edges.length) return card('No edges yet', h('p', {class:'dim'}, S.me.serverAdmin ? 'Add an edge (Blacktip) on the Server tab first: it is the public server that owns your IP addresses.' : 'Ask a server administrator to add an edge for your organization.'));
if (!st.edges.length) return emptyState('edges', 'No edges yet', S.me.serverAdmin ? 'An edge is the public server, running Blacktip, that owns your IP addresses. Add one under Edges first.' : 'Ask a server administrator to add an edge for your organization.',
S.me.serverAdmin ? h('button', {class:'btn', onclick:() => { selectTab('edges'); history.pushState(null, '', TAB_PATH.edges); render(); }}, 'Go to Edges') : null);
const unsetDash = !st.dashboardUrl && st.mappings.some(m => m.auth && (m.auth.sso || m.auth.password || m.auth.pin || m.auth.email));
const traffic = await api('GET', O('/traffic?hours=24')).catch(() => ({}));
const rows = st.mappings.slice().sort((a, b) => a.edgeId.localeCompare(b.edgeId) || a.bindIp.localeCompare(b.bindIp) || a.port - b.port);
@@ -632,12 +642,12 @@ function mappingRow(m, st, series) {
return h('tr', {},
h('td', {title:why}, badge(label, tone)),
h('td', {}, h('div', {}, m.name, ' ', h('span', {class:'tag'}, m.proto), m.proxyProtocol ? h('span', {class:'tag', style:'margin-left:4px', title:'the target is told the real client address'}, 'proxy ' + m.proxyProtocol) : null, prot ? h('span', {style:'margin-left:4px', title:'Visitors have to sign in'}, badge('Login', 'accent')) : null),
s.error ? h('div', {class:'err small mono'}, s.error) : null,
s.cert && m.proto === 'https' ? h('div', {class:'small ' + (s.cert.startsWith('error') ? 'err' : 'dim')}, 'Certificate: ' + s.cert) : null),
h('td', {class:'mono'}, pubAddr, h('div', {class:'dim small'}, (edge ? edge.name : '?') + (web && m.bindIp !== '0.0.0.0' ? ' · ' + m.bindIp : ''))),
h('td', {class:'mono small'}, m.targets.map((t, i) => {
s.error ? h('div', {class:'err small mono clip', title:s.error}, s.error) : null,
s.cert && m.proto === 'https' ? h('div', {class:'small clip ' + (s.cert.startsWith('error') ? 'err' : 'dim'), title:s.cert}, 'Certificate: ' + s.cert) : null),
h('td', {}, h('span', {class:'mono'}, pubAddr), h('div', {class:'dim small'}, (edge ? edge.name : '?') + (web && m.bindIp !== '0.0.0.0' ? ' · ' + m.bindIp : ''))),
h('td', {class:'small'}, m.targets.map((t, i) => {
const th = (s.targets || [])[i], site = byId(st.sites, t.siteId);
return h('div', {title:th && !th.healthy ? th.detail : ''}, badge(th ? (th.healthy ? 'Up' : 'Down') : 'Unknown', th ? (th.healthy ? 'success' : 'danger') : ''), ' ', (site ? site.name : '?') + ' → ' + t.host + ':' + t.port);
return h('div', {title:th && !th.healthy ? th.detail : '', style:'margin-bottom:4px'}, badge(th ? (th.healthy ? 'Up' : 'Down') : 'Unknown', th ? (th.healthy ? 'success' : 'danger') : ''), ' ', site ? site.name : '?', h('div', {class:'mono'}, t.host + ':' + t.port));
})),
h('td', {class:'small dim'}, m.total && m.total.conns ? [`${m.total.conns} conn`, h('br'), `${bytes(m.total.bytesIn)} in, ${bytes(m.total.bytesOut)} out`] : 'None yet', spark(series)),
h('td', {}, h('div', {class:'actions'},
@@ -664,16 +674,16 @@ function portForm(existing) {
let targets = m.targets.map(t => ({...t}));
const roleChecks = st.roles.map(r => ({r, c:chk(r.name + (r.admin ? ' (admin)' : ''), (a.allowedRoles || []).includes(r.id))}));
const pwIn = h('input', {type:'password', placeholder:existing && a.password ? 'set: leave blank to keep' : 'shared password', autocomplete:'new-password'});
const pinIn = h('input', {type:'password', placeholder:existing && a.pin ? 'set: leave blank to keep' : '4-12 digits', inputmode:'numeric', autocomplete:'off'});
const pinIn = h('input', {type:'password', placeholder:existing && a.pin ? 'set: leave blank to keep' : '4 to 12 digits', inputmode:'numeric', autocomplete:'off'});
const ssoC = chk('Sign in with a Hammerhead account', a.sso), pwC = chk('Shared password', a.password), pinC = chk('PIN', a.pin), mailC = chk('Emailed one-time code', a.email);
const mails = h('input', {value:(a.allowedMails || []).join(', '), placeholder:'a@x.com, *@company.com'});
const hours = h('input', {type:'number', min:1, max:2160, value:a.sessionHours || 24});
const ruleBox = h('div', {class:'stack'}); let rules = (m.rules || []).map(r => ({...r}));
const healthPath = h('input', {value:m.healthPath || '', placeholder:'/healthz (blank = TCP connect check)'});
const healthPath = h('input', {value:m.healthPath || '', placeholder:'/healthz, or leave blank to just connect'});
const healthSec = h('input', {type:'number', value:m.healthSeconds > 0 ? m.healthSeconds : 30, min:5});
const healthOff = chk('Turn health checks off', m.healthSeconds === -1);
const redir = chk('Also answer on port 80 with a redirect to HTTPS', m.redirectHttp), bhttps = chk('Talk HTTPS to the targets (certificate not verified)', m.backendHttps);
const rewrite = h('input', {value:m.rewriteHost || '', placeholder:'blank = keep the visitor\'s Host'});
const rewrite = h('input', {value:m.rewriteHost || '', placeholder:'Leave blank to keep the visitor\'s Host'});
const webBox = h('div', {class:'stack'});
const hostLbl = hostBox;
@@ -689,7 +699,7 @@ function portForm(existing) {
return h('div', {class:'tgt'}, field(i ? 'Target ' + (i + 1) : 'Target site', site), field('Host', th), field('Port', tp),
h('button', {class:'ghost', type:'button', title:'Pick a Docker container on that site', onclick:() => discover(t, th, tp)}, 'Containers'),
targets.length > 1 ? h('button', {class:'ghost danger', type:'button', 'aria-label':'Remove target', onclick:() => { targets.splice(i, 1); drawTargets(); }}, icon('close', 16)) : h('span'));
}), many ? h('button', {class:'ghost', type:'button', style:'align-self:flex-start', onclick:() => { targets.push({siteId:targets[0] ? targets[0].siteId : '', host:'127.0.0.1', port:''}); drawTargets(); }}, '+ Add another target (load balance / failover)') : null);
}), many ? h('button', {class:'ghost', type:'button', style:'align-self:flex-start', title:'Spread visitors across targets, or fail over when one is down', onclick:() => { targets.push({siteId:targets[0] ? targets[0].siteId : '', host:'127.0.0.1', port:''}); drawTargets(); }}, icon('add', 16), 'Add another target') : null);
};
const discover = act(async (t, th, tp) => {
if (!t.siteId) throw new Error('Choose a site first');
@@ -708,7 +718,7 @@ function portForm(existing) {
h('select', {onchange:e => r.match = e.target.value}, ['path', 'ip'].map(v => h('option', {value:v, selected:r.match === v}, v === 'path' ? 'Path starts with' : 'IP / CIDR'))),
h('input', {value:r.value, oninput:e => r.value = e.target.value, placeholder:r.match === 'ip' ? '203.0.113.0/24' : '/api/public'}),
h('button', {class:'ghost danger', type:'button', 'aria-label':'Remove rule', onclick:() => { rules.splice(i, 1); drawRules(); }}, icon('close', 16)))),
h('button', {class:'ghost', type:'button', style:'align-self:flex-start', onclick:() => { rules.push({action:'allow', match:'path', value:''}); drawRules(); }}, '+ Add a rule'));
h('button', {class:'ghost', type:'button', style:'align-self:flex-start', onclick:() => { rules.push({action:'allow', match:'path', value:''}); drawRules(); }}, icon('add', 16), 'Add a rule'));
const drawWeb = () => {
const p = protoSel.value, web = p === 'http' || p === 'https';
hostLbl.style.display = ['http', 'https', 'tls'].includes(p) ? '' : 'none';
@@ -720,7 +730,7 @@ function portForm(existing) {
webBox.append(
h('fieldset', {}, h('legend', {}, 'Who can open this?'), h('div', {class:'stack'},
h('div', {class:'sub'}, 'Leave everything off for a public site. Turn on one or more methods to put a login in front.'),
ssoC, h('div', {class:'stack', style:'margin-left:26px'}, h('div', {class:'sub'}, 'Limit to these roles (none ticked = every member of the organization):'), h('div', {class:'pillrow'}, roleChecks.map(x => x.c))),
ssoC, h('div', {class:'stack', style:'margin-left:26px'}, h('div', {class:'sub'}, 'Limit to these roles. If none are ticked, every member of the organization can open it.'), h('div', {class:'pillrow'}, roleChecks.map(x => x.c))),
pwC, field('Password', pwIn), pinC, field('PIN', pinIn), mailC, field('Allowed email addresses', mails, 'Needs email set up under Server → Settings'),
field('Stay signed in for (hours)', hours))),
h('fieldset', {}, h('legend', {}, 'Rules (checked before login, first match wins)'), ruleBox),
@@ -745,12 +755,12 @@ function portForm(existing) {
const protos = protoSel.value === 'both' ? ['tcp', 'udp'] : [protoSel.value];
if (existing) await api('PUT', O('/mappings/' + m.id), {...base, proto:m.proto, proxyProtocol:['tcp', 'tls'].includes(m.proto) ? proxySel.value : ''});
else for (const p of protos) await api('POST', O('/mappings'), {...base, proto:p, targets:p === 'udp' ? targets.slice(0, 1) : targets, proxyProtocol:['tcp', 'tls'].includes(p) ? proxySel.value : ''});
close(); toast('Saved'); refresh();
close(); toast(existing ? `Saved ${base.name || 'the resource'}` : `Added ${base.name || 'the resource'}`); refresh();
});
const close = existing ? modal('Edit port', h('div', {class:'stack'},
h('div', {class:'grid'}, field('Name', name), field('Edge', edgeSel), field('Public IP', ipIn), field('Protocol', protoSel), field('Public port', port, 'One port, or a range like 2456-2458 (tcp/udp): each port goes to the target port plus the same offset')),
const close = existing ? modal('Edit ' + (m.name || 'resource'), h('div', {class:'stack'},
h('div', {class:'grid'}, field('Name', name), field('Edge', edgeSel), field('Public IP', ipIn), field('Protocol', protoSel), field('Public port', port, 'One port, or a range like 2456-2458 for TCP and UDP')),
ipList, domList, hostBox, proxyBox, h('fieldset', {}, h('legend', {}, 'Where does the traffic go?'), tgtBox), adv, webBox,
h('div', {class:'actions'}, h('button', {class:'btn', onclick:save}, existing ? 'Save changes' : 'Open port'), h('button', {class:'ghost', onclick:() => close()}, 'Cancel'))), {sticky:true}) : wizardModal();
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => close()}, 'Cancel'), h('button', {class:'btn', onclick:save}, existing ? 'Save changes' : 'Open port'))), {sticky:true}) : wizardModal();
// The add flow for a new resource: a few short steps that reuse the controls above.
function wizardModal() {
@@ -848,7 +858,7 @@ function portForm(existing) {
}
draw();
return modal('Add a resource', h('div', {class:'stack'}, crumbs, body, err,
h('div', {class:'actions'}, back, next, h('button', {class:'ghost', type:'button', onclick:() => close()}, 'Cancel'))), {sticky:true});
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', type:'button', onclick:() => close()}, 'Cancel'), back, next)), {sticky:true});
}
}
@@ -856,7 +866,7 @@ async function shareDialog(m) {
const body = h('div', {class:'stack'});
const draw = async () => {
const links = await api('GET', O(`/mappings/${m.id}/shares`));
const name = h('input', {placeholder:'e.g. For Sam'}), hrs = h('input', {type:'number', placeholder:'hours (blank = no expiry)'}), uses = h('input', {type:'number', placeholder:'max uses (blank = unlimited)'});
const name = h('input', {placeholder:'e.g. For Sam'}), hrs = h('input', {type:'number', placeholder:'Hours, or leave blank for no expiry'}), uses = h('input', {type:'number', placeholder:'Leave blank for no limit'});
const out = h('div');
body.replaceChildren(
h('p', {class:'dim'}, 'A share link lets someone in without an account or password. Anyone with the link gets access, so keep it private.'),
@@ -865,7 +875,7 @@ async function shareDialog(m) {
out.replaceChildren(h('p', {class:'small'}, 'Copy this now; it is not shown again:'), cmdBox(r.url)); }
)}, 'Create link')), out,
links.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Label', 'Used', 'Expires', ''].map(t => h('th', {}, t)))),
h('tbody', {}, links.map(l => h('tr', {}, h('td', {}, l.name || 'Unnamed'), h('td', {}, l.uses + (l.maxUses ? ' / ' + l.maxUses : '')), h('td', {}, l.expires.startsWith('0001') ? 'never' : new Date(l.expires).toLocaleString()),
h('tbody', {}, links.map(l => h('tr', {}, h('td', {}, l.name || 'Unnamed'), h('td', {}, l.uses + (l.maxUses ? ' / ' + l.maxUses : '')), h('td', {}, l.expires.startsWith('0001') ? 'never' : fmtTime(l.expires)),
h('td', {}, h('button', {class:'ghost danger', onclick: ask({title:`Revoke ${l.name || 'this share link'}?`, body:'Anyone using the link loses access straight away. This cannot be undone.', confirm:'Revoke link'}, async () => { await api('DELETE', O(`/mappings/${m.id}/shares/${l.id}`)); draw(); })}, 'Revoke')))))) : h('div', {class:'dim'}, 'No links yet.'));
};
await draw(); modal('Share links for ' + m.name, body);
@@ -879,15 +889,15 @@ views.sites = async () => {
cmdBox(`bamboo -hammerhead ${dashURL()} -token ${s.token} -docker`)));
S.headerAction = ['Add site', () => { const close = modal('Add a site', h('div', {class:'stack'},
h('p', {class:'dim'}, 'A site is a private network that holds your services. Every edge also has a built-in "this machine" site for services on the same box as Blacktip.'), field('Name', name),
h('div', {class:'actions'}, h('button', {class:'btn', onclick:() => { close(); add(); }}, 'Add site'), h('button', {class:'ghost', onclick:() => close()}, 'Cancel')))); }];
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => close()}, 'Cancel'), h('button', {class:'btn', onclick:() => { close(); add(); }}, 'Add site')))); }];
const latest = await latestVersions();
return h('div', {},
card('Sites', h('table', {}, h('thead', {}, h('tr', {}, ['Status', 'Name', 'Kind', 'Tunnels to', 'Version', 'Containers', ''].map(t => h('th', {}, t)))),
card('', h('table', {}, h('thead', {}, h('tr', {}, ['Status', 'Name', 'Kind', 'Tunnels to', 'Version', 'Containers', ''].map(t => h('th', {}, t)))),
h('tbody', {}, st.sites.map(s => h('tr', {},
h('td', {}, st.online[s.id] ? badge('Online', 'success') : badge('Offline', 'danger')), h('td', {}, s.name),
h('td', {}, s.local ? 'this machine (direct)' : 'bamboo agent'),
h('td', {}, s.local ? 'Not applicable' : ((s.connected || []).map(id => (byId(st.edges, id) || {name:id}).name).join(', ') || 'nothing connected')),
s.local ? h('td', {class:'dim'}, 'Not applicable') : versionCell(s, latest.bamboo, !!s.updateId, O(`/sites/${s.id}/update`), O(`/sites/${s.id}/update/log`)),
h('td', {}, s.local ? 'This machine' : 'Bamboo agent'),
h('td', {}, s.local ? 'Direct' : ((s.connected || []).map(id => (byId(st.edges, id) || {name:id}).name).join(', ') || 'nothing connected')),
s.local ? h('td', {class:'dim'}, 'Same as its edge') : versionCell(s, latest.bamboo, !!s.updateId, O(`/sites/${s.id}/update`), O(`/sites/${s.id}/update/log`)),
h('td', {}, h('button', {class:'ghost', onclick: act(async () => {
const l = await api('GET', O(`/sites/${s.id}/containers`));
modal('Containers on ' + s.name, l.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Image', 'Ports'].map(t => h('th', {}, t)))), h('tbody', {}, l.map(c => h('tr', {}, h('td', {}, c.name), h('td', {class:'dim'}, c.image), h('td', {class:'mono small'}, c.ports.map(p => (p.public ? p.public + '→' : '') + p.private + '/' + p.type).join(', ')))))) :
@@ -903,9 +913,9 @@ views.domains = async () => {
S.headerAction = ['Add domain', () => { const close = modal('Add a domain', h('div', {class:'stack'},
h('p', {class:'dim'}, 'Hostnames on http, https and tls ports must be under one of your domains. Create the DNS record at your DNS provider, then use Check DNS.'),
field('Domain', name), field('Edge', edgeSel), field('IP', ip, 'One of the edge\'s addresses'),
h('div', {class:'actions'}, h('button', {class:'btn', onclick: act(async () => { await api('POST', O('/domains'), {name:name.value, edgeId:edgeSel.value, ip:ip.value}); close(); refresh(); })}, 'Add domain'), h('button', {class:'ghost', onclick:() => close()}, 'Cancel')))); }];
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => close()}, 'Cancel'), h('button', {class:'btn', onclick: act(async () => { await api('POST', O('/domains'), {name:name.value, edgeId:edgeSel.value, ip:ip.value}); close(); refresh(); })}, 'Add domain')))); }];
return h('div', {},
card('Domains', st.domains.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Domain', 'Edge', 'DNS record to create', ''].map(t => h('th', {}, t)))),
card('', st.domains.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Domain', 'Edge', 'DNS record to create', ''].map(t => h('th', {}, t)))),
h('tbody', {}, st.domains.map(d => h('tr', {}, h('td', {}, d.name), h('td', {}, (byId(st.edges, d.edgeId) || {name:'?'}).name),
h('td', {class:'mono small'}, d.ip ? `A ${d.name} ${d.ip}` : 'No IP set', h('br'), d.ip ? `A *.${d.name} ${d.ip} (for subdomains)` : ''),
h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost', onclick: act(async () => { const r = await api('POST', O(`/domains/${d.id}/check`)); toast(r.matches ? `Resolves to ${r.resolved.join(', ')}` : r.error || `Resolves to ${r.resolved.join(', ')}, which isn't this edge`, !r.matches); })}, 'Check DNS'),
@@ -975,7 +985,7 @@ views.egress = async () => {
async function dkimCard() {
const keys = await api('GET', O('/dkim'));
const dom = h('input', {placeholder:'example.com'}), sel = h('input', {placeholder:'blank = automatic, e.g. hh202610'});
const dom = h('input', {placeholder:'example.com'}), sel = h('input', {placeholder:'Leave blank to pick one automatically'});
const chip = k => ({published:['success', 'Published'], missing:['warning', 'Not in DNS yet'], mismatch:['danger', 'DNS has a different key'], unknown:['', 'Waiting for a check']}[k.status] || ['', k.status]);
const show = k => modal('DNS record for ' + k.domain, h('div', {}, h('p', {class:'dim'}, 'Add this TXT record at your DNS provider. Some providers want the name without the domain part, or the value split into quoted pieces: both are fine.'),
h('div', {class:'sub'}, 'Name'), cmdBox(k.recordName), h('div', {class:'sub', style:'margin-top:10px'}, 'Value'), cmdBox(k.record)));
@@ -984,8 +994,8 @@ async function dkimCard() {
h('td', {}, k.domain), h('td', {class:'mono'}, k.selector), h('td', {}, badge(chip(k)[1], chip(k)[0])),
h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost', onclick:() => show(k)}, 'Show DNS record'),
h('button', {class:'ghost danger', onclick: ask({title:`Stop signing mail for ${k.domain}?`, body:'Outgoing mail for this domain is no longer signed, which can hurt delivery. The key is deleted and cannot be recovered.', confirm:'Remove key'}, async () => { await api('DELETE', O('/dkim/' + k.id)); refresh(); })}, 'Remove'))))))) : null,
h('div', {class:'row-h'}, h('button', {class:'btn', onclick:() => dialogForm('Create a DKIM key', 'Make a key for each domain you send mail from, then publish its DNS record.', [field('Domain', dom), field('Selector (optional)', sel)], 'Create key',
async () => { const k = await api('POST', O('/dkim'), {domain:dom.value, selector:sel.value}); show(k); refresh(); })}, '+ Create key')));
h('div', {class:'row-h'}, h('button', {class:'ghost', onclick:() => dialogForm('Create a DKIM key', 'Make a key for each domain you send mail from, then publish its DNS record.', [field('Domain', dom), field('Selector (optional)', sel)], 'Create key',
async () => { const k = await api('POST', O('/dkim'), {domain:dom.value, selector:sel.value}); show(k); refresh(); })}, icon('add', 16), 'Create key')));
}
function mailForm(existing) {
@@ -998,7 +1008,7 @@ function mailForm(existing) {
const ipList = h('datalist', {id:'srcips'});
const helo = h('input', {value:e.heloName || '', placeholder:'mail.example.com'});
const listen = h('input', {value:e.listen || '127.0.0.1:2525'});
const smart = h('input', {value:e.smarthost || '', placeholder:'blank = deliver to each recipient\'s own server'});
const smart = h('input', {value:e.smarthost || '', placeholder:'Leave blank to deliver to each recipient\'s own server'});
const user = h('input', {value:e.username || '', autocomplete:'off', placeholder:'needed if you listen beyond 127.0.0.1'});
const pass = h('input', {type:'password', autocomplete:'new-password', placeholder:e.passwordSet ? 'unchanged' : ''});
const rate = h('input', {type:'number', min:1, max:6000, value:e.ratePerMinute || 60});
@@ -1017,7 +1027,7 @@ function mailForm(existing) {
h('div', {class:'grid'}, field('Relay listens on (on the site)', listen, '127.0.0.1 keeps it private to that machine'), field('Messages per minute (limit)', rate, 'Caps what a compromised server could send from your IP')),
h('div', {class:'grid'}, field('Username', user), field('Password', pass)),
field('Smarthost (optional)', smart, 'Use this if you relay through a provider that allows your edge IP. Leave blank for direct delivery.'), tls,
h('div', {class:'actions'}, h('button', {class:'btn', onclick:save}, existing ? 'Save changes' : 'Add mail egress'), h('button', {class:'ghost', onclick:() => close()}, 'Cancel'))), {sticky:true});
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => close()}, 'Cancel'), h('button', {class:'btn', onclick:save}, existing ? 'Save changes' : 'Add mail egress'))), {sticky:true});
}
@@ -1043,11 +1053,11 @@ function channelForm(base, existing, trusted) {
kind.onchange = redraw; redraw();
const save = act(async () => {
await api(existing ? 'PUT' : 'POST', base + '/channels' + (existing ? '/' + existing.id : ''), {name:name.value, kind:kind.value, target:target.value, format:kind.value === 'webhook' ? fmt.value : '', allowPrivate:trusted && priv.box.checked, enabled:en.box.checked});
close(); toast('Saved'); refresh();
close(); toast(existing ? 'Alert channel saved' : 'Alert channel added'); refresh();
});
const close = modal(existing ? 'Edit channel' : 'Add an alert channel', h('div', {class:'stack'},
h('div', {class:'grid'}, field('Name', name), field('Type', kind)), targetBox, fmtBox, priv, en,
h('div', {class:'actions'}, h('button', {class:'btn', onclick:save}, 'Save'), h('button', {class:'ghost', onclick:() => close()}, 'Cancel'))), {sticky:true});
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => close()}, 'Cancel'), h('button', {class:'btn', onclick:save}, 'Save'))), {sticky:true});
}
async function alertsView(base, scoped, trusted) {
@@ -1068,9 +1078,9 @@ async function alertsView(base, scoped, trusted) {
card('Where alerts go',
d.channels.length ? channels : emptyState('alerts', 'No channels yet', 'Add an email address or a webhook to be told when something stops working.')),
scoped ? card('Timing', h('p', {class:'dim'}, 'A problem has to last this long before you are told, so a restart or a brief blip stays quiet. You get one message when it starts and one when it clears.'),
h('div', {class:'grid'}, field('Wait this many minutes first', debounce), field('Remind me every (hours, 0 = never)', repeat),
h('button', {class:'btn', onclick: act(async () => { await api('PUT', base + '/settings', {debounceMin:+debounce.value, repeatHours:+repeat.value}); toast('Saved'); })}, 'Save'))) : null,
card('History', d.history.length ? h('table', {}, h('tbody', {}, d.history.map(e => h('tr', {}, h('td', {class:'dim small'}, new Date(e.time).toLocaleString()),
h('div', {class:'grid'}, field('Wait this many minutes first', debounce), field('Remind me every (hours)', repeat, 'Use 0 to send no reminders'),
h('button', {class:'ghost', onclick: act(async () => { await api('PUT', base + '/settings', {debounceMin:+debounce.value, repeatHours:+repeat.value}); toast('Alert timing saved'); })}, 'Save timing'))) : null,
card('History', d.history.length ? h('table', {}, h('tbody', {}, d.history.map(e => h('tr', {}, h('td', {class:'dim small'}, fmtTime(e.time)),
h('td', {}, h('span', {class:'tag'}, e.status)), h('td', {}, e.message))))) : emptyState('alerts', 'No alerts yet', 'Alerts that fired and recovered are listed here.')));
}
views.alerts = () => alertsView(O('/alerts'), true, !!(S.me.serverAdmin || (S.st.org && S.st.org.allowLocal)));
@@ -1086,20 +1096,20 @@ views.access = async () => {
const r = await api('POST', O('/invites'), {email:email.value, roleId:roleSel.value}); email.value = '';
modal('Invitation created', h('div', {}, h('p', {class:'dim'}, r.emailed ? 'An email was sent. You can also share this link:' : 'Send them this link (it works for 7 days):'), cmdBox(r.link))); refresh(); })];
return h('div', {},
card('Members', h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Email', 'Role', '2FA', ''].map(t => h('th', {}, t)))),
card('Members', h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Email', 'Role', 'Two-factor', ''].map(t => h('th', {}, t)))),
h('tbody', {}, members.map(mm => h('tr', {}, h('td', {}, mm.name), h('td', {}, mm.email, mm.sso ? h('span', {class:'tag', style:'margin-left:6px'}, 'sso') : null),
h('td', {}, h('select', {style:'width:auto', onchange: act(async e => { await api('PUT', O('/members/' + mm.userId), {roleId:e.target.value}); toast('Role changed'); })}, roles.map(r => h('option', {value:r.id, selected:r.id === mm.roleId}, r.name)))),
h('td', {}, mm.totp ? badge('On', 'success') : badge('Off')),
h('td', {}, mm.userId === S.me.id ? null : h('button', {class:'ghost danger', onclick: ask({title:`Remove ${mm.name}?`, body:'They lose access to this organization straight away. You can invite them again later.', confirm:'Remove member'}, async () => { await api('DELETE', O('/members/' + mm.userId)); refresh(); })}, 'Remove'))))))),
invites.length ? card('Pending invitations', h('table', {}, h('tbody', {}, invites.map(i => h('tr', {}, h('td', {}, i.email), h('td', {class:'dim'}, (byId(roles, i.roleId) || {name:'?'}).name), h('td', {class:'dim'}, 'expires ' + new Date(i.expires).toLocaleDateString()),
invites.length ? card('Pending invitations', h('table', {}, h('tbody', {}, invites.map(i => h('tr', {}, h('td', {}, i.email), h('td', {class:'dim'}, (byId(roles, i.roleId) || {name:'?'}).name), h('td', {class:'dim'}, 'Expires ' + fmtDate(i.expires)),
h('td', {}, h('button', {class:'ghost danger', onclick: ask({title:`Cancel the invitation for ${i.email}?`, body:'The invitation link stops working. You can invite them again.', confirm:'Cancel invitation', cancel:'Keep invitation'}, async () => { await api('DELETE', O('/invites/' + i.id)); refresh(); })}, 'Cancel'))))))) : null,
card('Roles', h('p', {class:'dim'}, 'Admin roles manage the organization. Other roles are used to decide who may open protected apps.'),
h('table', {}, h('tbody', {}, roles.map(r => h('tr', {}, h('td', {}, r.name, r.builtin ? h('span', {class:'tag', style:'margin-left:6px'}, 'built-in') : null), h('td', {class:'dim'}, r.admin ? 'can manage the organization' : 'member'),
h('table', {}, h('tbody', {}, roles.map(r => h('tr', {}, h('td', {}, r.name, r.builtin ? h('span', {class:'tag', style:'margin-left:6px'}, 'built-in') : null), h('td', {class:'dim'}, r.admin ? 'Can manage the organization' : 'Member'),
h('td', {}, r.builtin ? null : h('button', {class:'ghost danger', onclick: ask({title:`Delete the ${r.name} role?`, body:'The role is deleted. This cannot be undone.', confirm:'Delete role'}, async () => { await api('DELETE', O('/roles/' + r.id)); refresh(); })}, 'Delete')))))),
h('div', {class:'grid', style:'margin-top:12px'}, field('New role', rname), radmin, h('button', {class:'btn', onclick: act(async () => { await api('POST', O('/roles'), {name:rname.value, admin:radmin.box.checked}); refresh(); })}, 'Add role'))),
h('div', {class:'grid', style:'margin-top:12px'}, field('New role', rname), radmin, h('button', {class:'ghost', onclick: act(async () => { await api('POST', O('/roles'), {name:rname.value, admin:radmin.box.checked}); refresh(); })}, 'Add role'))),
card('API keys', h('p', {class:'dim'}, 'Keys act as an administrator of this organization only. Send as "Authorization: Bearer <key>" to /api/orgs/' + S.org + '/…'),
keys.length ? h('table', {}, h('tbody', {}, keys.map(k => h('tr', {}, h('td', {}, k.name), h('td', {class:'dim'}, 'last used ' + ago(k.lastUsed)), h('td', {}, h('button', {class:'ghost danger', onclick: ask({title:`Revoke ${k.name}?`, body:'Anything using this key stops working straight away. This cannot be undone.', confirm:'Revoke key'}, async () => { await api('DELETE', O('/apikeys/' + k.id)); refresh(); })}, 'Revoke'))))) ) : null,
h('div', {class:'grid', style:'margin-top:12px'}, field('Name', kname), h('button', {class:'btn', onclick: act(async () => { const r = await api('POST', O('/apikeys'), {name:kname.value}); modal('New API key', h('div', {}, h('p', {class:'dim'}, 'Copy it now; it is not shown again.'), cmdBox(r.key))); refresh(); })}, 'Create key'))));
keys.length ? h('table', {}, h('tbody', {}, keys.map(k => h('tr', {}, h('td', {}, k.name), h('td', {class:'dim'}, (ago(k.lastUsed) === 'never' ? 'Never used' : 'Last used ' + ago(k.lastUsed))), h('td', {}, h('button', {class:'ghost danger', onclick: ask({title:`Revoke ${k.name}?`, body:'Anything using this key stops working straight away. This cannot be undone.', confirm:'Revoke key'}, async () => { await api('DELETE', O('/apikeys/' + k.id)); refresh(); })}, 'Revoke'))))) ) : null,
h('div', {class:'grid', style:'margin-top:12px'}, field('Name', kname), h('button', {class:'ghost', onclick: act(async () => { const r = await api('POST', O('/apikeys'), {name:kname.value}); modal('New API key', h('div', {}, h('p', {class:'dim'}, 'Copy it now; it is not shown again.'), cmdBox(r.key))); refresh(); })}, 'Create key'))));
};
// ----- logs -----
@@ -1115,7 +1125,7 @@ views.logs = async () => {
const resultSel = h('select', {style:'width:auto', onchange:set(v => logResult = v)}, [['', 'Any result'], ['denied', 'Denied'], ['auth-required', 'Login required'], ['login', 'Logins'], ['mail', 'Mail sent'], ['mail-failed', 'Mail failed'], ['mail-refused', 'Mail refused'], ['no-target', 'No target']].map(([v, t]) => h('option', {value:v, selected:v === logResult}, t)));
const search = h('input', {placeholder:'Search host, path, address, user…', value:logQ, style:'width:260px'});
search.addEventListener('keydown', e => { if (e.key === 'Enter') { logQ = search.value.trim(); logPaged = false; refresh(); } });
const t = d => new Date(d).toLocaleString();
const t = fmtTime;
const tbody = h('tbody');
const addRows = list => list.forEach(r => {
if (logKind === 'audit') { tbody.append(h('tr', {}, h('td', {class:'dim'}, t(r.time)), h('td', {}, r.actor), h('td', {}, r.action), h('td', {class:'dim'}, r.detail))); return; }
@@ -1134,7 +1144,8 @@ views.logs = async () => {
return h('div', {}, h('div', {class:'row-h', style:'justify-content:space-between'},
h('div', {class:'tabs2', style:'margin:0'}, [['access', 'Access'], ['audit', 'Audit']].map(([k, l]) => h('button', {class:logKind === k ? 'on' : '', onclick:() => { logKind = k; logPaged = false; refresh(); }}, l))),
logKind === 'access' ? h('div', {class:'actions'}, search, mapSel, statusSel, resultSel) : null),
card('', rows.length ? h('div', {class:'scroll'}, h('table', {}, h('thead', {}, h('tr', {}, head.map(x => h('th', {}, x)))), tbody), rows.length >= 200 ? more : null) : emptyState('logs', 'No entries match', 'Nothing was logged for these filters. Try a wider search.')));
card('', rows.length ? h('div', {class:'scroll'}, h('table', {}, h('thead', {}, h('tr', {}, head.map(x => h('th', {}, x)))), tbody), rows.length >= 200 ? more : null) : ((logQ || logMap || logStatus || logResult) ? emptyState('logs', 'No entries match', 'Nothing was logged for these filters. Try a wider search.')
: emptyState('logs', logKind === 'audit' ? 'No changes yet' : 'No requests yet', logKind === 'audit' ? 'Changes to this organization are listed here.' : 'Requests to your resources are listed here as they happen.'))));
};
// ----- server administration -----
@@ -1154,13 +1165,12 @@ serverViews.edges = async () => {
S.headerAction = ['Add edge', () => { const close = modal('Add an edge', h('div', {class:'stack'},
h('p', {class:'dim'}, 'An edge (Blacktip) is a public server that owns IP addresses and opens ports.'),
field('Name', name), field('WireGuard endpoint (public host:UDP port)', addr, 'host:port'),
orgs.length > 1 ? h('div', {class:'stack'}, h('div', {class:'sub'}, 'Restrict to these organizations (none ticked = all):'), h('div', {class:'pillrow'}, orgChecks.map(x => x.c))) : null,
h('div', {class:'actions'}, h('button', {class:'btn', onclick: act(async () => {
const e = await api('POST', '/api/edges', {name:name.value, tunnelAddr:addr.value, orgIds:orgChecks.filter(x => x.c.box.checked).map(x => x.o.id)}); close(); showCmd(e); refresh(); })}, 'Add edge'),
h('button', {class:'ghost', onclick:() => close()}, 'Cancel')))); }];
orgs.length > 1 ? h('div', {class:'stack'}, h('div', {class:'sub'}, 'Restrict to these organizations. If none are ticked, every organization can use it.'), h('div', {class:'pillrow'}, orgChecks.map(x => x.c))) : null,
h('div', {class:'actions form-actions'}, h('button', {class:'ghost', onclick:() => close()}, 'Cancel'), h('button', {class:'btn', onclick: act(async () => {
const e = await api('POST', '/api/edges', {name:name.value, tunnelAddr:addr.value, orgIds:orgChecks.filter(x => x.c.box.checked).map(x => x.o.id)}); close(); showCmd(e); refresh(); })}, 'Add edge')))); }];
const latest = await latestVersions();
return h('div', {},
card('Edges', edges.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Status', 'Name', 'WireGuard endpoint', 'IP addresses', 'Version', 'Organizations', ''].map(t => h('th', {}, t)))),
card('', edges.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Status', 'Name', 'WireGuard endpoint', 'IP addresses', 'Version', 'Organizations', ''].map(t => h('th', {}, t)))),
h('tbody', {}, edges.map(e => h('tr', {}, h('td', {}, e.online ? badge('Online', 'success') : badge('Offline', 'danger')), h('td', {}, e.name), h('td', {class:'mono'}, e.tunnelAddr),
h('td', {class:'mono small'}, (e.ips || []).join(', ') || 'None reported yet'), versionCell(e, latest.blacktip, e.updatePending, '/api/edges/' + e.id + '/update', '/api/edges/' + e.id + '/update/log'), h('td', {class:'small dim'}, (e.orgIds || []).length ? e.orgIds.map(id => (byId(orgs, id) || {name:id}).name).join(', ') : 'all'),
h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost', onclick:() => showCmd(e)}, 'Show command'),
@@ -1171,13 +1181,16 @@ serverViews.orgs = async () => {
const name = h('input', {placeholder:'Organization name'}), local = chk('Allow targeting the edge machine itself', false);
await boot0();
S.headerAction = ['New organization', () => dialogForm('New organization', null, [field('Name', name), local], 'Create', async () => { await api('POST', '/api/orgs', {name:name.value, allowLocal:local.box.checked}); S.me = await api('GET', '/api/me'); refresh(); })];
const states = await Promise.all(S.me.orgs.map(o => api('GET', `/api/orgs/${o.id}/state`).catch(() => null)));
const setLocal = (o, on) => async () => { await api('PUT', `/api/orgs/${o.id}`, {allowLocal:on}); toast(`Local access turned ${on ? 'on' : 'off'} for ${o.name}`); refresh(); };
return h('div', {},
card('Organizations', h('p', {class:'dim'}, '"Local" access lets an organization point ports at services on the edge machine itself (127.0.0.1), which includes Hammerhead. Only turn it on for organizations you fully trust.'),
h('table', {}, h('tbody', {}, S.me.orgs.map(o => h('tr', {}, h('td', {}, o.name),
h('td', {}, h('button', {class:'ghost', onclick: act(async () => {
const st = await api('GET', `/api/orgs/${o.id}/state`);
await api('PUT', `/api/orgs/${o.id}`, {allowLocal:!st.org.allowLocal}); toast(`Local access ${st.org.allowLocal ? 'off' : 'on'} for ${o.name}`); })}, 'Toggle local access')),
h('td', {}, h('button', {class:'ghost danger', onclick: ask({title:`Delete ${o.name}?`, body:'Everything in it is deleted: resources, sites, domains, members and logs. This cannot be undone.', confirm:'Delete organization'}, async () => { await api('DELETE', '/api/orgs/' + o.id); S.me = await api('GET', '/api/me'); if (S.org === o.id) S.org = (S.me.orgs[0] || {}).id; refresh(); })}, 'Delete'))))))));
card('', h('p', {class:'dim'}, 'Local access lets an organization point ports at services on the edge machine itself (127.0.0.1), which includes Hammerhead. Only turn it on for organizations you fully trust.'),
h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Local access', '', ''].map(t => h('th', {}, t)))), h('tbody', {}, S.me.orgs.map((o, i) => {
const on = !!(states[i] && states[i].org.allowLocal);
return h('tr', {}, h('td', {}, o.name), h('td', {}, on ? badge('On', 'warning') : badge('Off')),
h('td', {}, h('button', {class:'ghost', onclick:on ? act(setLocal(o, false)) : ask({title:`Allow local access for ${o.name}?`, body:'Its administrators can point ports at services on the edge machine itself, including Hammerhead. Only do this for an organization you fully trust.', confirm:'Allow local access', danger:false}, setLocal(o, true))}, on ? 'Turn off local access' : 'Allow local access')),
h('td', {}, h('button', {class:'ghost danger', onclick: ask({title:`Delete ${o.name}?`, body:'Everything in it is deleted: resources, sites, domains, members and logs. This cannot be undone.', confirm:'Delete organization'}, async () => { await api('DELETE', '/api/orgs/' + o.id); S.me = await api('GET', '/api/me'); if (S.org === o.id) S.org = (S.me.orgs[0] || {}).id; refresh(); })}, 'Delete')));
})))));
};
async function boot0() { S.me = await api('GET', '/api/me'); }
@@ -1187,14 +1200,14 @@ serverViews.users = async () => {
S.headerAction = ['Add user', () => dialogForm('Add a user', 'To add someone to an organization by email, use an invitation on the Access page instead.', [field('Email', f.e), field('Name', f.n), field('Password', f.p), adm], 'Create',
async () => { await api('POST', '/api/admin/users', {email:f.e.value, name:f.n.value, password:f.p.value, serverAdmin:adm.box.checked}); refresh(); })];
return h('div', {},
card('Users', h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Email', 'Organizations', 'Flags', ''].map(t => h('th', {}, t)))),
card('', h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Email', 'Organizations', 'Access', ''].map(t => h('th', {}, t)))),
h('tbody', {}, users.map(u => h('tr', {}, h('td', {}, u.name), h('td', {}, u.email), h('td', {class:'dim'}, u.orgs.join(', ') || 'None'),
h('td', {}, u.serverAdmin ? h('span', {class:'tag'}, 'admin') : null, u.totp ? h('span', {class:'tag', style:'margin-left:4px'}, '2fa') : null, u.sso ? h('span', {class:'tag', style:'margin-left:4px'}, 'sso') : null),
h('td', {}, h('div', {class:'actions'}, u.serverAdmin ? badge('Server administrator', 'accent') : null, u.totp ? badge('Two-factor on', 'success') : null, u.sso ? badge('Single sign-on') : null)),
h('td', {}, u.id === S.me.id ? null : h('div', {class:'actions'},
h('button', {class:'ghost', onclick: act(async () => { await api('PUT', '/api/admin/users/' + u.id, {serverAdmin:!u.serverAdmin}); refresh(); })}, u.serverAdmin ? 'Remove admin' : 'Make admin'),
u.totp ? h('button', {class:'ghost', onclick: act(async () => { await api('PUT', '/api/admin/users/' + u.id, {resetTotp:true}); toast('2FA reset'); refresh(); })}, 'Reset 2FA') : null,
h('button', {class:'ghost', onclick: act(async () => { await api('PUT', '/api/admin/users/' + u.id, {signOut:true}); toast('Signed out everywhere'); })}, 'Sign out'),
h('button', {class:'ghost', onclick: act(async () => { const p = prompt('New password (8+ characters)'); if (p) { await api('PUT', '/api/admin/users/' + u.id, {password:p}); toast('Password changed'); } })}, 'Set password'),
u.totp ? h('button', {class:'ghost', onclick: ask({title:`Turn off two-factor for ${u.email}?`, body:'They can sign in with their password alone until they set it up again. Use this when someone has lost their authenticator.', confirm:'Turn off two-factor'}, async () => { await api('PUT', '/api/admin/users/' + u.id, {resetTotp:true}); toast('Two-factor authentication turned off for ' + u.email); refresh(); })}, 'Turn off two-factor') : null,
h('button', {class:'ghost', onclick: act(async () => { await api('PUT', '/api/admin/users/' + u.id, {signOut:true}); toast(u.email + ' was signed out on every device'); })}, 'Sign out everywhere'),
h('button', {class:'ghost', onclick:() => { const pw = h('input', {type:'password', autocomplete:'new-password'}); dialogForm('Set a password for ' + u.email, 'They are signed out everywhere and use this password from now on.', [field('New password', pw, 'At least 8 characters')], 'Set password', async () => { await api('PUT', '/api/admin/users/' + u.id, {password:pw.value}); toast('Password set for ' + u.email); }); }}, 'Set password'),
h('button', {class:'ghost danger', onclick: ask({title:`Delete ${u.email}?`, body:'This person can no longer sign in. This cannot be undone.', confirm:'Delete user'}, async () => { await api('DELETE', '/api/admin/users/' + u.id); refresh(); })}, 'Delete')))))))));
};
@@ -1214,7 +1227,7 @@ serverViews.idps = async () => {
await api(e.id ? 'PUT' : 'POST', '/api/admin/idps' + (e.id ? '/' + e.id : ''), body); close(); refresh(); })}, 'Save')), {sticky:true});
};
S.headerAction = ['Add provider', () => form()];
return card('Single sign-on', h('p', {class:'dim'}, 'Let people sign in with Google, Authentik, Keycloak, Entra ID or any OpenID Connect provider.'),
return card('', h('p', {class:'dim'}, 'Let people sign in with Google, Authentik, Keycloak, Entra ID or any OpenID Connect provider.'),
idps.length ? h('table', {}, h('tbody', {}, idps.map(i => h('tr', {}, h('td', {}, i.name), h('td', {class:'mono small dim'}, i.issuer), h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost', onclick:() => form(i)}, 'Edit'),
h('button', {class:'ghost danger', onclick: ask({title:`Delete ${i.name}?`, body:'People can no longer sign in with this provider. This cannot be undone.', confirm:'Delete provider'}, async () => { await api('DELETE', '/api/admin/idps/' + i.id); refresh(); })}, 'Delete')))))) ) : emptyState('idps', 'No sign-in providers yet', 'Add an OpenID Connect provider so people can sign in with it.'));
};
@@ -1223,12 +1236,12 @@ serverViews.settings = async () => {
const { settings: s, smtpPassSet } = await api('GET', '/api/admin/settings');
const f = {url:h('input', {value:s.dashboardUrl, placeholder:'https://hammerhead.example.com'}), acme:h('input', {type:'email', value:s.acmeEmail, placeholder:'you@example.com'}),
host:h('input', {value:s.smtp.host}), port:h('input', {type:'number', value:s.smtp.port || ''}), user:h('input', {value:s.smtp.user}), pass:h('input', {type:'password', placeholder:smtpPassSet ? 'unchanged' : ''}), from:h('input', {value:s.smtp.from, placeholder:'Hammerhead <no-reply@example.com>'}),
mode:h('select', {}, ['starttls', 'tls', 'none'].map(m => h('option', {value:m, selected:(s.smtp.mode || 'starttls') === m}, m)))};
mode:h('select', {}, [['starttls', 'STARTTLS'], ['tls', 'TLS'], ['none', 'None']].map(([m, l]) => h('option', {value:m, selected:(s.smtp.mode || 'starttls') === m}, l)))};
const staging = chk('Use the Let\'s Encrypt staging server (untrusted test certificates)', s.acmeStaging);
const save = act(async () => { await api('PUT', '/api/admin/settings', {dashboardUrl:f.url.value, acmeEmail:f.acme.value, acmeStaging:staging.box.checked,
smtp:{host:f.host.value, port:+f.port.value, user:f.user.value, pass:f.pass.value, from:f.from.value, mode:f.mode.value}}); S.me = await api('GET', '/api/me'); toast('Saved'); });
smtp:{host:f.host.value, port:+f.port.value, user:f.user.value, pass:f.pass.value, from:f.from.value, mode:f.mode.value}}); S.me = await api('GET', '/api/me'); toast('Settings saved'); });
const updOut = h('div', {class:'dim'}, 'Not checked yet.');
const updNow = h('button', {class:'btn', style:'display:none', onclick: act(async () => {
const updNow = h('button', {class:'ghost', style:'display:none', onclick: act(async () => {
await api('POST', '/api/admin/update/apply');
updNow.style.display = 'none';
showPanel();
@@ -1254,14 +1267,14 @@ serverViews.settings = async () => {
card('Email', h('p', {class:'dim'}, 'Needed for invitation emails and one-time sign-in codes.'),
h('div', {class:'grid'}, field('SMTP host', f.host), field('Port', f.port), field('Security', f.mode), field('Username', f.user), field('Password', f.pass), field('From', f.from)),
h('div', {class:'actions', style:'margin-top:10px'}, h('button', {class:'ghost', onclick: act(async () => { await save(); await api('POST', '/api/admin/settings/test-mail'); toast('Test email sent to ' + S.me.email); })}, 'Save and send a test email'))),
h('div', {class:'actions form-actions', style:'margin-bottom:24px'}, h('button', {class:'btn', onclick:save}, 'Save settings')),
card('Updates', h('p', {class:'dim'}, 'Hammerhead, Blacktip and Bamboo can update themselves from new releases when auto-update is on (HAMMERHEAD_AUTO_UPDATE=1). This checks the newest release for Hammerhead; it installs nothing.'),
updOut, h('div', {class:'actions', style:'margin-top:10px'}, h('button', {class:'ghost', onclick:checkUpdate}, 'Check for updates'), updNow), updBox),
h('button', {class:'btn', onclick:save}, 'Save settings'));
updOut, h('div', {class:'actions', style:'margin-top:10px'}, h('button', {class:'ghost', onclick:checkUpdate}, 'Check for updates'), updNow), updBox));
};
serverViews.audit = async () => {
const rows = await api('GET', '/api/admin/audit');
return card('Server audit log', rows.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Time', 'Who', 'What', 'Detail'].map(x => h('th', {}, x)))), h('tbody', {}, rows.map(r => h('tr', {}, h('td', {class:'dim'}, new Date(r.time).toLocaleString()), h('td', {}, r.actor), h('td', {}, r.action), h('td', {class:'dim'}, r.detail))))) : emptyState('audit', 'No activity yet', 'Server level changes appear here.'));
return card('', rows.length ? h('table', {}, h('thead', {}, h('tr', {}, ['Time', 'Who', 'What', 'Detail'].map(x => h('th', {}, x)))), h('tbody', {}, rows.map(r => h('tr', {}, h('td', {class:'dim'}, fmtTime(r.time)), h('td', {}, r.actor), h('td', {}, r.action), h('td', {class:'dim'}, r.detail))))) : emptyState('audit', 'No activity yet', 'Server level changes appear here.'));
};
// ----- account -----
@@ -1286,12 +1299,12 @@ views.account = async () => {
draw2fa();
const sessions = await api('GET', '/api/me/sessions');
const devices = card('Signed-in devices', sessions.length > 1 ? h('div', {class:'row-h'}, h('button', {class:'ghost', onclick: act(async () => { await api('POST', '/api/me/sessions/revoke-others'); toast('Signed out everywhere else'); refresh(); })}, 'Sign out everywhere else')) : null,
h('table', {}, h('tbody', {}, sessions.map(x => h('tr', {}, h('td', {}, x.current ? h('span', {class:'tag'}, 'this device') : null),
h('td', {class:'small'}, (x.ua || 'unknown browser').slice(0, 80), h('div', {class:'dim'}, x.ip + ' · signed in ' + ago(x.created))),
h('table', {}, h('tbody', {}, sessions.map(x => h('tr', {}, h('td', {}, x.current ? badge('This device', 'accent') : null),
h('td', {class:'small'}, (x.ua || 'Unknown browser').slice(0, 80), h('div', {class:'dim'}, x.ip + ' · signed in ' + ago(x.created))),
h('td', {}, x.current ? null : h('button', {class:'ghost danger', onclick: ask({title:'Sign out this device?', body:'That session ends and its device has to sign in again.', confirm:'Sign out', danger:false}, async () => { await api('DELETE', '/api/me/sessions/' + x.id); refresh(); })}, 'Sign out')))))));
return h('div', {}, card('Profile', h('div', {class:'grid'}, field('Name', name), field('Email', h('input', {value:S.me.email, disabled:true})), h('button', {class:'btn', onclick: act(async () => { await api('PUT', '/api/me', {name:name.value}); toast('Saved'); })}, 'Save'))),
card('Password', h('div', {class:'grid'}, S.me.hasPassword ? field('Current password', cur) : null, field('New password (8+ characters)', nw),
h('button', {class:'btn', onclick: act(async () => { await api('POST', '/api/me/password', {current:cur.value, new:nw.value}); cur.value = nw.value = ''; toast('Password changed; other sessions were signed out'); })}, 'Change password'))),
return h('div', {}, card('Profile', h('div', {class:'grid'}, field('Name', name), field('Email', h('input', {value:S.me.email, disabled:true})), h('button', {class:'ghost', onclick: act(async () => { await api('PUT', '/api/me', {name:name.value}); toast('Profile saved'); })}, 'Save profile'))),
card('Password', h('div', {class:'grid'}, S.me.hasPassword ? field('Current password', cur) : null, field('New password', nw, 'At least 8 characters'),
h('button', {class:'ghost', onclick: act(async () => { await api('POST', '/api/me/password', {current:cur.value, new:nw.value}); cur.value = nw.value = ''; toast('Password changed. Your other devices were signed out.'); })}, 'Change password'))),
card('Two-factor authentication', twofa), devices);
};
+1 -1
View File
@@ -120,7 +120,7 @@ function passwordForm(kind, label, type) {
const i = h('input', {type, autocomplete: kind === 'password' ? 'current-password' : 'off', inputmode: kind === 'pin' ? 'numeric' : null});
const go = () => finish(kind, { [kind]: i.value });
i.addEventListener('keydown', e => e.key === 'Enter' && go());
return section(label, field(label, i), h('button', {onclick:go}, 'Continue'));
return h('div', {class:'sec'}, field(label, i), h('button', {onclick:go}, 'Continue'));
}
function emailForm() {