From 09f4f309848987791654106f98e184ab47cbaafd Mon Sep 17 00:00:00 2001 From: Veillax <126523099+Veillax@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:43:46 -0600 Subject: [PATCH] Give every dashboard page its own URL and move add-forms into dialogs Pages are served at /resources, /sites, /domains, /mail-egress, /alerts, /access, /logs, /apps, /account and /server/*, with working links, reloads and back button. Mail egress, alerts, access, users, organizations, SSO and DKIM follow the same header-button and dialog pattern as the other pages. Co-Authored-By: Claude Sonnet 5.5 --- paths_test.go | 34 ++++++++++++++++++++++++++++++++++ server.go | 13 ++++++++++++- web/index.html | 48 +++++++++++++++++++++++++++++++++--------------- 3 files changed, 79 insertions(+), 16 deletions(-) create mode 100644 paths_test.go diff --git a/paths_test.go b/paths_test.go new file mode 100644 index 0000000..d99839f --- /dev/null +++ b/paths_test.go @@ -0,0 +1,34 @@ +package hammerhead + +import ( + "os" + "regexp" + "testing" +) + +// Every address the dashboard JavaScript navigates to must be served by the server, or a +// reload on that page would 404. +func TestDashboardPathsMatchThePage(t *testing.T) { + src, err := os.ReadFile("web/index.html") + if err != nil { + t.Fatal(err) + } + m := regexp.MustCompile(`TAB_PATH = \{([^}]*)\}`).FindSubmatch(src) + if m == nil { + t.Fatal("TAB_PATH not found in web/index.html") + } + paths := regexp.MustCompile(`'(/[a-z/-]+)'`).FindAllSubmatch(m[1], -1) + if len(paths) < 10 { + t.Fatalf("found only %d page paths", len(paths)) + } + for _, p := range paths { + if !isDashboardPath(string(p[1])) { + t.Errorf("%s is a dashboard page but the server doesn't serve it", p[1]) + } + } + for _, p := range []string{"/api/me", "/nope", "/server", "/server/nope"} { + if isDashboardPath(p) { + t.Errorf("%s should not serve the dashboard", p) + } + } +} diff --git a/server.go b/server.go index 083cdf5..56dd0d1 100644 --- a/server.go +++ b/server.go @@ -21,11 +21,22 @@ func page(html []byte) http.HandlerFunc { } } +// dashboardPaths are the addresses the dashboard shows pages at. They all serve the same app, +// which reads the path to decide what to show. Keep in step with TAB_PATH in web/index.html. +var dashboardPaths = map[string]bool{ + "/": true, "/apps": true, "/resources": true, "/sites": true, "/domains": true, "/mail-egress": true, + "/alerts": true, "/access": true, "/logs": true, "/account": true, + "/server/edges": true, "/server/organizations": true, "/server/users": true, "/server/sso": true, + "/server/settings": true, "/server/alerts": true, "/server/audit": true, +} + +func isDashboardPath(p string) bool { return dashboardPaths[p] } + func (s *Server) Handler() http.Handler { mux := http.NewServeMux() index := page(indexHTML) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/" { + if !isDashboardPath(r.URL.Path) { http.NotFound(w, r) return } diff --git a/web/index.html b/web/index.html index d3a4270..3ae4096 100644 --- a/web/index.html +++ b/web/index.html @@ -46,6 +46,7 @@ .brand svg { color:var(--accent); } .orgsel { margin:0 0 var(--s2); } .navsec { font-size:var(--text-xs); font-weight:600; letter-spacing:.06em; text-transform:uppercase; color:var(--text-faint); padding:var(--s4) var(--s3) var(--s1); } + a.navitem { text-decoration:none; } .navitem { display:flex; align-items:center; gap:var(--s3); width:100%; background:none; border:0; color:var(--text-muted); padding:var(--s2) var(--s3); min-height:34px; border-radius:var(--radius); cursor:pointer; font:inherit; font-weight:500; text-align:left; transition:background var(--ease), color var(--ease); } .navitem:hover { background:var(--surface-2); color:var(--text); } .navitem { border-radius:var(--radius); } @@ -184,6 +185,10 @@ 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}); }; 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')))); +} const cmdBox = t => h('pre', {class:'mono'}, t); function modal(title, body, opts = {}) { const shade = h('div', {class:'shade'}); @@ -236,6 +241,8 @@ async function boot() { if (!S.me) return $app.replaceChildren(loginView()); if (!S.me.orgs.find(o => o.id === S.org)) S.org = (S.me.orgs[0] || {}).id || null; if (S.org) localStorage.setItem('org', S.org); + const fromPath = tabFromPath(location.pathname); + if (fromPath) selectTab(fromPath); await render(); } @@ -302,8 +309,17 @@ const PAGES = { // server pages are top-level sidebar entries that map onto serverViews const SRV = {edges:'edges', srvorgs:'orgs', users:'users', idps:'idps', settings:'settings', srvalerts:'alerts', srvaudit:'audit'}; +// every page has its own address, so links, reloads and the back button work +const TAB_PATH = {mine:'/apps', ports:'/resources', sites:'/sites', domains:'/domains', egress:'/mail-egress', alerts:'/alerts', access:'/access', logs:'/logs', account:'/account', + edges:'/server/edges', srvorgs:'/server/organizations', users:'/server/users', idps:'/server/sso', settings:'/server/settings', srvalerts:'/server/alerts', srvaudit:'/server/audit'}; +const tabFromPath = p => Object.keys(TAB_PATH).find(k => TAB_PATH[k] === p.replace(/\/+$/, '')) || null; +function selectTab(key) { S.tab = key; if (SRV[key]) serverTab = SRV[key]; } +window.addEventListener('popstate', () => { const k = tabFromPath(location.pathname); if (S.me && k) { selectTab(k); render(); } }); + async function render() { - const nav = (key, label, ic) => h('button', {class:'navitem' + (S.tab === key ? ' on' : ''), onclick:() => { S.tab = key; if (SRV[key]) serverTab = SRV[key]; render(); }}, icon(ic), label); + const nav = (key, label, ic) => h('a', {class:'navitem' + (S.tab === key ? ' on' : ''), href:TAB_PATH[key], onclick:e => { + if (e.metaKey || e.ctrlKey || e.shiftKey || e.button) return; + e.preventDefault(); selectTab(key); history.pushState(null, '', TAB_PATH[key]); render(); }}, icon(ic), label); const side = [h('div', {}, brand())]; if (S.me.orgs.length) side.push(h('div', {class:'orgsel'}, h('select', {onchange: act(async e => { S.org = e.target.value; localStorage.setItem('org', S.org); await render(); })}, S.me.orgs.map(o => h('option', {value:o.id, selected:o.id === S.org}, o.name))))); @@ -322,6 +338,7 @@ async function render() { } all.push('account'); if (!all.includes(S.tab)) S.tab = all[0]; + if (location.pathname !== TAB_PATH[S.tab]) history.replaceState(null, '', TAB_PATH[S.tab]); side.push(h('div', {class:'grow'}), h('div', {class:'userbox'}, h('div', {class:'who'}, S.me.email), nav('account', 'Account', 'account'), h('button', {class:'navitem', onclick: act(async () => { await api('POST', '/api/logout'); S.me = null; boot(); })}, icon('out'), 'Sign out'))); $app.replaceChildren(h('div', {class:'app'}, h('aside', {class:'side'}, side), h('div', {class:'content'}, h('main', {id:'view'})))); @@ -645,8 +662,8 @@ views.egress = async () => { h('button', {class:'ghost', onclick: act(async () => { await api('PUT', O('/mail-egress/' + e.id), {...e, enabled:!e.enabled}); refresh(); })}, e.enabled ? 'Pause' : 'Enable'), h('button', {class:'ghost danger', onclick: act(async () => { if (confirm(`Remove ${e.name}?`)) { await api('DELETE', O('/mail-egress/' + e.id)); refresh(); } })}, 'Remove')))); })))) : h('div', {class:'empty'}, 'Nothing set up yet. Add one so your mail server sends from the edge\'s IP.'); + S.headerAction = ['Add mail egress', () => mailForm()]; return h('div', {}, - h('div', {class:'row-h'}, h('button', {class:'btn', onclick:() => mailForm()}, '+ Add mail egress')), !remote.length ? h('div', {class:'banner'}, 'This runs on a Bamboo site: add the network your mail server lives in on the Sites page and start Bamboo there first.') : null, h('div', {class:'card flush'}, table), await dkimCard(), @@ -666,8 +683,8 @@ async function dkimCard() { h('td', {}, k.domain), h('td', {class:'mono'}, k.selector), h('td', {}, h('span', {class:'dot ' + chip(k)[0]}), chip(k)[1]), h('td', {}, h('div', {class:'actions'}, h('button', {class:'ghost', onclick:() => show(k)}, 'Show DNS record'), h('button', {class:'ghost danger', onclick: act(async () => { if (confirm(`Stop signing mail for ${k.domain}?`)) { await api('DELETE', O('/dkim/' + k.id)); refresh(); } })}, 'Remove'))))))) : null, - h('div', {class:'grid', style:'margin-top:12px'}, field('Domain', dom), field('Selector (optional)', sel), - h('button', {class:'btn', onclick: act(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:'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'))); } function mailForm(existing) { @@ -742,11 +759,12 @@ async function alertsView(base, scoped, trusted) { h('button', {class:'ghost', onclick:() => channelForm(base, c, trusted)}, 'Edit'), h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', base + '/channels/' + c.id); refresh(); })}, 'Remove'))))))); const debounce = h('input', {type:'number', min:1, max:120, value:d.settings.debounceMin}), repeat = h('input', {type:'number', min:0, max:168, value:d.settings.repeatHours}); + S.headerAction = ['Add channel', () => channelForm(base, null, trusted)]; return h('div', {}, !d.emailConfigured ? h('div', {class:'banner'}, 'Email alerts need an email server. Set one up under Server → Settings, or use a webhook.') : null, card('Right now', d.active.length ? h('table', {}, h('tbody', {}, d.active.map(a => h('tr', {}, h('td', {}, h('span', {class:'dot ' + (a.fired ? 'bad' : 'warn')}), a.fired ? 'Firing' : 'Waiting…'), h('td', {}, a.message), h('td', {class:'dim small'}, 'since ' + ago(a.since)))))) : h('div', {class:'empty'}, '✓ Nothing is wrong.')), - card('Where alerts go', h('div', {class:'row-h'}, h('button', {class:'btn', onclick:() => channelForm(base, null, trusted)}, '+ Add channel')), + card('Where alerts go', d.channels.length ? channels : h('div', {class:'empty'}, 'No channels yet. Add an email address or a webhook.')), 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), @@ -763,17 +781,17 @@ views.access = async () => { const email = h('input', {type:'email', placeholder:'person@example.com'}), roleSel = h('select', {}, roles.map(r => h('option', {value:r.id, selected:r.name === 'Member'}, r.name))); const rname = h('input', {placeholder:'e.g. Staff'}), radmin = chk('Can manage the organization', false); const kname = h('input', {placeholder:'e.g. CI deploy'}); + S.headerAction = ['Invite someone', () => dialogForm('Invite someone', 'They get a link that works for 7 days.', [field('Email', email), field('Role', roleSel)], 'Create invitation', 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)))), 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 ? '✓' : '—'), h('td', {}, mm.userId === S.me.id ? null : h('button', {class:'ghost danger', onclick: act(async () => { if (confirm('Remove this member?')) { await api('DELETE', O('/members/' + mm.userId)); refresh(); } })}, 'Remove'))))))), - card('Invite someone', h('div', {class:'grid'}, field('Email', email), field('Role', roleSel), h('button', {class:'btn', onclick: act(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(); })}, 'Create invitation')), - invites.length ? h('table', {style:'margin-top:12px'}, 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()), - h('td', {}, h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', O('/invites/' + i.id)); refresh(); })}, 'Cancel')))))) : null), + 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()), + h('td', {}, h('button', {class:'ghost danger', onclick: act(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('td', {}, r.builtin ? null : h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', O('/roles/' + r.id)); refresh(); })}, 'Delete')))))), @@ -850,8 +868,8 @@ serverViews.edges = async () => { 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(); })]; return h('div', {}, - card('New organization', h('div', {class:'grid'}, field('Name', name), local, h('button', {class:'btn', onclick: act(async () => { await api('POST', '/api/orgs', {name:name.value, allowLocal:local.box.checked}); S.me = await api('GET', '/api/me'); refresh(); })}, 'Create'))), 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 () => { @@ -864,9 +882,9 @@ async function boot0() { S.me = await api('GET', '/api/me'); } serverViews.users = async () => { const users = await api('GET', '/api/admin/users'); const f = {e:h('input', {type:'email'}), n:h('input'), p:h('input', {type:'password'})}, adm = chk('Server administrator', false); + 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('Add a user', h('p', {class:'dim'}, 'To add someone to an organization by email, use an invitation on the Access tab instead.'), - h('div', {class:'grid'}, field('Email', f.e), field('Name', f.n), field('Password', f.p), adm, h('button', {class:'btn', onclick: act(async () => { await api('POST', '/api/admin/users', {email:f.e.value, name:f.n.value, password:f.p.value, serverAdmin:adm.box.checked}); refresh(); })}, 'Create'))), card('Users', h('table', {}, h('thead', {}, h('tr', {}, ['Name', 'Email', 'Organizations', 'Flags', ''].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(', ') || '—'), 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), @@ -893,10 +911,10 @@ serverViews.idps = async () => { const body = {name:f.name.value, issuer:f.issuer.value, clientId:f.cid.value, clientSecret:f.sec.value, scopes:f.scopes.value, autoProvision:auto.box.checked, orgId:orgSel.value, allowedDomains:f.dom.value.split(',').map(s => s.trim()).filter(Boolean)}; 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.'), 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: act(async () => { await api('DELETE', '/api/admin/idps/' + i.id); refresh(); })}, 'Delete')))))) ) : h('div', {class:'empty'}, 'None configured.'), - h('button', {class:'btn', style:'margin-top:10px', onclick:() => form()}, 'Add provider')); + h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', '/api/admin/idps/' + i.id); refresh(); })}, 'Delete')))))) ) : h('div', {class:'empty'}, 'None configured.')); }; serverViews.settings = async () => {