Give every dashboard page its own URL and move add-forms into dialogs
CI / test (push) Successful in 7m15s

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 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-26 13:43:46 -06:00
1 parent 6f204e71c9
commit 09f4f30984
3 files changed
+79 -16

No files matched your search

+34
View File
@@ -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)
}
}
}
+12 -1
View File
@@ -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 { func (s *Server) Handler() http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
index := page(indexHTML) index := page(indexHTML)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" { if !isDashboardPath(r.URL.Path) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
+33 -15
View File
@@ -46,6 +46,7 @@
.brand svg { color:var(--accent); } .brand svg { color:var(--accent); }
.orgsel { margin:0 0 var(--s2); } .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); } .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 { 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:hover { background:var(--surface-2); color:var(--text); }
.navitem { border-radius:var(--radius); } .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 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 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); 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); const cmdBox = t => h('pre', {class:'mono'}, t);
function modal(title, body, opts = {}) { function modal(title, body, opts = {}) {
const shade = h('div', {class:'shade'}); const shade = h('div', {class:'shade'});
@@ -236,6 +241,8 @@ async function boot() {
if (!S.me) return $app.replaceChildren(loginView()); 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.me.orgs.find(o => o.id === S.org)) S.org = (S.me.orgs[0] || {}).id || null;
if (S.org) localStorage.setItem('org', S.org); if (S.org) localStorage.setItem('org', S.org);
const fromPath = tabFromPath(location.pathname);
if (fromPath) selectTab(fromPath);
await render(); await render();
} }
@@ -302,8 +309,17 @@ const PAGES = {
// server pages are top-level sidebar entries that map onto serverViews // 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'}; 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() { 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())]; 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(); })}, 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))))); 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'); all.push('account');
if (!all.includes(S.tab)) S.tab = all[0]; 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'), 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'))); 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'})))); $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', 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('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.'); })))) : 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', {}, 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, !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), h('div', {class:'card flush'}, table),
await dkimCard(), 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', {}, 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('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('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('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',
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'))); async () => { const k = await api('POST', O('/dkim'), {domain:dom.value, selector:sel.value}); show(k); refresh(); })}, '+ Create key')));
} }
function mailForm(existing) { 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', onclick:() => channelForm(base, c, trusted)}, 'Edit'),
h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', base + '/channels/' + c.id); refresh(); })}, 'Remove'))))))); 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}); 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', {}, 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, !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)))))) : 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.')), 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.')), 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.'), 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('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 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 rname = h('input', {placeholder:'e.g. Staff'}), radmin = chk('Can manage the organization', false);
const kname = h('input', {placeholder:'e.g. CI deploy'}); 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', {}, 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', '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('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', {}, 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.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'))))))), 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 () => { 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()),
const r = await api('POST', O('/invites'), {email:email.value, roleId:roleSel.value}); email.value = ''; h('td', {}, h('button', {class:'ghost danger', onclick: act(async () => { await api('DELETE', O('/invites/' + i.id)); refresh(); })}, 'Cancel'))))))) : null,
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),
card('Roles', h('p', {class:'dim'}, 'Admin roles manage the organization. Other roles are used to decide who may open protected apps.'), 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: act(async () => { await api('DELETE', O('/roles/' + r.id)); refresh(); })}, 'Delete')))))), 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 () => { serverViews.orgs = async () => {
const name = h('input', {placeholder:'Organization name'}), local = chk('Allow targeting the edge machine itself', false); const name = h('input', {placeholder:'Organization name'}), local = chk('Allow targeting the edge machine itself', false);
await boot0(); 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', {}, 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.'), 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('table', {}, h('tbody', {}, S.me.orgs.map(o => h('tr', {}, h('td', {}, o.name),
h('td', {}, h('button', {class:'ghost', onclick: act(async () => { 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 () => { serverViews.users = async () => {
const users = await api('GET', '/api/admin/users'); 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); 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', {}, 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)))), 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('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), 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)}; 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}); 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('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'), 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:'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'));
}; };
serverViews.settings = async () => { serverViews.settings = async () => {