Tabler refactor

This commit is contained in:
Jonathon Leight
2026-06-20 13:55:30 -04:00
parent cb119ea99a
commit bd600e9f90
30 changed files with 1446 additions and 1318 deletions
+9 -2
View File
@@ -50,17 +50,24 @@ func (s *Server) pageOrgs(w http.ResponseWriter, r *http.Request) {
s.render(w, r, "orgs.html", data)
}
// pageNewOrg renders the standalone "create an organization" form.
func (s *Server) pageNewOrg(w http.ResponseWriter, r *http.Request) {
s.render(w, r, "new_org.html", map[string]any{
"Error": r.URL.Query().Get("error"),
})
}
// handleCreateOrg creates an org with the current user as its first admin.
func (s *Server) handleCreateOrg(w http.ResponseWriter, r *http.Request) {
uid := s.auth.CurrentUserID(r.Context())
name := strings.TrimSpace(r.FormValue("name"))
if name == "" || len(name) > 80 {
http.Redirect(w, r, "/orgs?error="+url.QueryEscape("Enter an organization name."), http.StatusSeeOther)
http.Redirect(w, r, "/orgs/new?error="+url.QueryEscape("Enter an organization name."), http.StatusSeeOther)
return
}
org, err := s.store.CreateOrg(r.Context(), name, uid)
if err != nil {
http.Redirect(w, r, "/orgs?error="+url.QueryEscape("Could not create organization."), http.StatusSeeOther)
http.Redirect(w, r, "/orgs/new?error="+url.QueryEscape("Could not create organization."), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/orgs/"+strconv.FormatInt(org.ID, 10), http.StatusSeeOther)
+41 -336
View File
@@ -1,345 +1,50 @@
:root {
--bg: #0d1117;
--bg-elev: #11161f;
--panel: #161d29;
--panel-2: #1b2433;
--ink: #e6edf3;
--muted: #8b98a9;
--faint: #5d6b7d;
--accent: #3fb950;
--accent-2: #2ea043;
--accent-ink: #03210b;
--info: #58a6ff;
--warnbg: #2b2410;
--warn: #e3b341;
--error: #f85149;
--line: #28303d;
--line-2: #323c4b;
--radius: 14px;
--radius-sm: 9px;
--shadow: 0 1px 2px rgba(0,0,0,0.4), 0 8px 24px -12px rgba(0,0,0,0.6);
--shadow-pop: 0 6px 28px -6px rgba(0,0,0,0.7);
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
font: 16px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background:
radial-gradient(1100px 500px at 80% -10%, rgba(63,185,80,0.07), transparent 60%),
radial-gradient(900px 600px at -10% 10%, rgba(88,166,255,0.05), transparent 55%),
var(--bg);
background-attachment: fixed;
color: var(--ink);
-webkit-font-smoothing: antialiased;
/* MeshTender app-specific styles layered on top of Tabler.
Everything generic (cards, forms, buttons, tables, badges, dropdowns) comes
from Tabler; this file only covers things Tabler has no component for: the
live console/event log, the Leaflet map, and the public-key code block. */
/* ---------- Public-key / monospace code blocks ---------- */
code.pk,
code.pubkey {
display: block;
word-break: break-all;
background: var(--tblr-bg-surface-secondary, rgba(0, 0, 0, 0.2));
border: 1px solid var(--tblr-border-color);
border-radius: var(--tblr-border-radius);
padding: 0.5rem 0.625rem;
font-size: 0.8125rem;
color: var(--tblr-secondary);
}
/* ---------- Top bar ---------- */
.topbar {
display: flex; align-items: center; justify-content: space-between;
gap: 1rem;
padding: 0.7rem 1.4rem;
border-bottom: 1px solid var(--line);
background: rgba(13,17,23,0.7);
backdrop-filter: saturate(140%) blur(10px);
position: sticky; top: 0; z-index: 50;
}
.brand {
font-weight: 700; font-size: 1.05rem; text-decoration: none; color: var(--ink);
display: inline-flex; align-items: center; gap: 0.45rem; letter-spacing: -0.01em;
}
.brand:hover { color: #fff; }
.topbar nav { display: flex; align-items: center; gap: 0.4rem; }
.navlink {
color: var(--muted); text-decoration: none; font-size: 0.9rem; font-weight: 500;
padding: 0.4rem 0.7rem; border-radius: 8px; transition: background 0.15s, color 0.15s;
}
.navlink:hover { color: var(--ink); background: var(--panel-2); }
.who { color: var(--muted); font-size: 0.9rem; }
main { max-width: 800px; margin: 2.2rem auto 4rem; padding: 0 1.1rem; }
/* ---------- Cards & sections ---------- */
.card {
background: linear-gradient(180deg, var(--panel), var(--bg-elev));
border: 1px solid var(--line);
border-radius: var(--radius); padding: 1.6rem; margin-bottom: 1.4rem;
box-shadow: var(--shadow);
}
.card.auth { max-width: 420px; margin: 3rem auto; }
.card > h2:first-child, .card > h1:first-child { margin-top: 0; }
.card h2 {
font-size: 1.05rem; margin: 1.8rem 0 0.8rem;
padding-top: 1.2rem; border-top: 1px solid var(--line);
}
.card h2:first-of-type { border-top: none; padding-top: 0; margin-top: 1.2rem; }
.card h3 { font-size: 0.95rem; margin: 1.5rem 0 0.6rem; color: var(--ink); }
h1 { margin-top: 0; font-size: 1.5rem; letter-spacing: -0.02em; }
/* ---------- Forms ---------- */
label { display: block; margin: 1rem 0 0.35rem; font-size: 0.85rem; color: var(--muted); font-weight: 500; }
input, select {
width: 100%; padding: 0.6rem 0.7rem; border-radius: var(--radius-sm);
border: 1px solid var(--line-2); background: #0b0f16; color: var(--ink);
font-size: 0.95rem; transition: border-color 0.15s, box-shadow 0.15s;
}
input:focus, select:focus {
outline: none; border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(63,185,80,0.18);
}
input[readonly] { opacity: 0.55; cursor: not-allowed; }
/* ---------- Buttons ---------- */
button, .btn {
display: inline-flex; align-items: center; justify-content: center; gap: 0.4rem;
margin-top: 1rem; padding: 0.55rem 0.95rem; border-radius: var(--radius-sm);
border: 1px solid var(--line-2); background: var(--panel-2); color: var(--ink);
cursor: pointer; font-size: 0.92rem; font-weight: 500; text-decoration: none;
transition: background 0.15s, border-color 0.15s, transform 0.05s, color 0.15s;
white-space: nowrap;
}
button:hover, .btn:hover { background: #232e3f; border-color: var(--line-2); color: #fff; }
button:active, .btn:active { transform: translateY(1px); }
button:focus-visible, .btn:focus-visible {
outline: none; box-shadow: 0 0 0 3px rgba(88,166,255,0.35);
}
button.primary, .btn.primary {
background: linear-gradient(180deg, var(--accent), var(--accent-2));
color: var(--accent-ink); border-color: var(--accent-2); font-weight: 600;
}
button.primary:hover, .btn.primary:hover { filter: brightness(1.07); color: var(--accent-ink); }
.btn.sm { margin-top: 0; padding: 0.4rem 0.7rem; font-size: 0.85rem; }
.btn.ghost { background: transparent; border-color: transparent; color: var(--muted); }
.btn.ghost:hover { background: var(--panel-2); color: var(--ink); }
.btn.danger { color: var(--error); border-color: transparent; background: transparent; }
.btn.danger:hover { background: rgba(248,81,73,0.12); border-color: rgba(248,81,73,0.4); color: var(--error); }
/* link-style button (kept for inline text actions like "Sign out") */
button.link {
margin: 0; padding: 0; border: none; background: none; color: var(--muted);
font-weight: 500;
}
button.link:hover { background: none; color: var(--ink); }
button.link.danger { color: var(--error); background: none; border: none; }
button.link.danger:hover { color: #ff6b63; background: none; }
.inline { display: inline; }
/* ---------- Action toolbars ---------- */
.ractions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin-top: 1.1rem; }
.ractions form.inline { display: inline-flex; margin: 0; }
/* btnlink: legacy class, now rendered as a small secondary button */
.btnlink {
display: inline-flex; align-items: center; gap: 0.35rem;
color: var(--ink); text-decoration: none; font-size: 0.88rem; font-weight: 500;
padding: 0.4rem 0.75rem; border-radius: var(--radius-sm);
border: 1px solid var(--line-2); background: var(--panel-2);
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.btnlink:hover { background: #232e3f; color: #fff; }
/* ---------- Dropdown / overflow menu (JS-free via <details>) ---------- */
.menu { position: relative; display: inline-block; }
.menu > summary {
list-style: none; cursor: pointer; user-select: none;
display: inline-flex; align-items: center; gap: 0.35rem;
padding: 0.4rem 0.75rem; border-radius: var(--radius-sm);
border: 1px solid var(--line-2); background: var(--panel-2); color: var(--ink);
font-size: 0.88rem; font-weight: 500;
}
.menu > summary::-webkit-details-marker { display: none; }
.menu > summary:hover { background: #232e3f; }
.menu[open] > summary { background: #232e3f; border-color: var(--accent); }
.menu-panel {
position: absolute; right: 0; top: calc(100% + 6px); z-index: 40;
min-width: 184px; padding: 0.4rem;
background: var(--panel); border: 1px solid var(--line-2);
border-radius: var(--radius-sm); box-shadow: var(--shadow-pop);
display: flex; flex-direction: column; gap: 0.1rem;
}
.menu-panel.left { right: auto; left: 0; }
.menu-panel a, .menu-panel button {
display: flex; align-items: center; gap: 0.55rem; width: 100%;
margin: 0; padding: 0.5rem 0.65rem; border-radius: 7px;
border: none; background: none; color: var(--ink); text-decoration: none;
font-size: 0.9rem; font-weight: 500; text-align: left; cursor: pointer;
}
.menu-panel a:hover, .menu-panel button:hover { background: var(--panel-2); color: #fff; }
.menu-panel form { margin: 0; }
.menu-panel button.danger, .menu-panel a.danger { color: var(--error); }
.menu-panel button.danger:hover, .menu-panel a.danger:hover { background: rgba(248,81,73,0.12); color: #ff6b63; }
.menu-sep { height: 1px; background: var(--line); margin: 0.3rem 0.2rem; }
/* ---------- Text helpers ---------- */
.muted { color: var(--muted); font-size: 0.92rem; }
.faint { color: var(--faint); }
.error { color: var(--error); font-weight: 500; }
.status { min-height: 1.2em; color: var(--muted); font-size: 0.9rem; }
.backlink {
display: inline-flex; align-items: center; gap: 0.4rem; margin-top: 0.4rem;
color: var(--muted); text-decoration: none; font-size: 0.9rem; font-weight: 500;
}
.backlink:hover { color: var(--ink); }
.fallback { margin-top: 1.4rem; border-top: 1px solid var(--line); padding-top: 1rem; }
.fallback summary { cursor: pointer; color: var(--muted); }
.fallback summary:hover { color: var(--ink); }
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
code.pubkey, code.pk {
display: block; word-break: break-all; background: #0b0f16; padding: 0.6rem 0.7rem;
border-radius: var(--radius-sm); font-size: 0.78rem; color: var(--muted);
border: 1px solid var(--line);
}
.copyrow { display: flex; gap: 0.5rem; align-items: stretch; margin-top: 0.4rem; }
.copyrow code { flex: 1; min-width: 0; background: #0b0f16; padding: 0.6rem 0.7rem; border-radius: var(--radius-sm); border: 1px solid var(--line); font-size: 0.82rem; white-space: nowrap; overflow-x: auto; }
.copyrow button { margin: 0; }
/* ---------- Repeater tiles ---------- */
.repeaters { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.9rem; }
.repeaters li {
padding: 1.1rem 1.2rem; border: 1px solid var(--line); border-radius: var(--radius-sm);
background: var(--bg-elev); transition: border-color 0.15s, background 0.15s;
}
.repeaters li:hover { border-color: var(--line-2); background: var(--panel); }
/* compact one-line list rows (members, contributions, …) */
.repeaters li.rhead { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; padding: 0.85rem 1rem; }
.rname { font-weight: 600; }
.rname a, a.rname { color: var(--ink); text-decoration: none; }
.rname a:hover, a.rname:hover { color: var(--accent); }
.badge {
font-size: 0.72rem; font-weight: 600; padding: 0.2rem 0.55rem; border-radius: 999px;
text-decoration: none; display: inline-flex; align-items: center; gap: 0.3rem; line-height: 1.4;
}
.badge.ok { background: rgba(63,185,80,0.18); color: var(--accent); border: 1px solid rgba(63,185,80,0.35); }
.badge.pending { background: rgba(227,179,65,0.12); color: var(--warn); border: 1px solid rgba(227,179,65,0.3); }
.badge.shared { background: rgba(88,166,255,0.14); color: var(--info); border: 1px solid rgba(88,166,255,0.32); }
.badge.warn { background: rgba(227,179,65,0.14); color: var(--warn); border: 1px solid rgba(227,179,65,0.35); }
a.badge.warn:hover { background: rgba(227,179,65,0.25); }
.warn-note {
background: var(--warnbg); border: 1px solid #6a5418; color: var(--warn);
padding: 0.7rem 0.85rem; border-radius: var(--radius-sm); font-size: 0.85rem; margin: 0.7rem 0;
}
.warn-note code { color: #f2d999; }
.banner {
background: rgba(248,81,73,0.1); border: 1px solid rgba(248,81,73,0.45);
padding: 0.7rem 1rem; border-radius: var(--radius-sm); margin-bottom: 1.2rem;
}
.rhead { display: flex; align-items: center; gap: 0.55rem; margin-bottom: 0.5rem; }
.rmeta { font-size: 0.8rem; margin: 0.4rem 0; color: var(--muted); }
/* ---------- Console / event log ---------- */
/* ---------- Live event / console log ---------- */
.evlog {
list-style: none; padding: 0.85rem; margin-top: 1.1rem; background: #0a0e14;
border: 1px solid var(--line); border-radius: var(--radius-sm); max-height: 240px;
overflow-y: auto; font-family: ui-monospace, Menlo, monospace; font-size: 0.85rem;
list-style: none;
margin: 0;
padding: 0.75rem;
background: var(--tblr-bg-surface-secondary, rgba(0, 0, 0, 0.25));
border: 1px solid var(--tblr-border-color);
border-radius: var(--tblr-border-radius);
max-height: 240px;
overflow-y: auto;
font-family: var(--tblr-font-monospace);
font-size: 0.8125rem;
}
.evlog:empty { display: none; }
.ev { padding: 0.18rem 0; border-bottom: 1px solid #11161f; }
.ev:last-child { border-bottom: none; }
.ev-info { color: var(--muted); }
.ev-confirmed { color: var(--accent); font-weight: 600; }
.ev-error, .ev-timeout { color: var(--error); }
.ev-warning { color: var(--warn); }
.ev-debug { color: var(--info); word-break: break-all; }
.evlog.console { max-height: 320px; }
.ev-sent { color: var(--info); }
.ev-reply { color: var(--ink); white-space: pre-wrap; }
.ev-denied, .ev-noreply { color: var(--warn); }
.ev { padding: 0.15rem 0; border-bottom: 1px solid var(--tblr-border-color-translucent, rgba(255, 255, 255, 0.05)); }
.ev:last-child { border-bottom: none; }
.ev-info { color: var(--tblr-secondary); }
.ev-confirmed { color: var(--tblr-success); font-weight: 600; }
.ev-error, .ev-timeout { color: var(--tblr-danger); }
.ev-warning, .ev-denied, .ev-noreply { color: var(--tblr-warning); }
.ev-debug { color: var(--tblr-info); word-break: break-all; }
.ev-sent { color: var(--tblr-azure); }
.ev-reply { color: var(--tblr-body-color); white-space: pre-wrap; }
#cmdform { display: flex; gap: 0.5rem; margin-top: 0.9rem; }
#cmdform input { flex: 1; font-family: ui-monospace, Menlo, monospace; }
#cmdform button { margin: 0; }
.chip {
margin: 0; padding: 0.3rem 0.6rem; font-size: 0.8rem; font-family: ui-monospace, Menlo, monospace;
border-radius: 7px; background: #0b0f16; border: 1px solid var(--line-2); color: var(--ink);
/* ---------- Leaflet map ---------- */
#map {
height: 360px;
border-radius: var(--tblr-border-radius);
border: 1px solid var(--tblr-border-color);
}
.chip:hover { background: var(--panel-2); }
.chip.risky { border-color: rgba(248,81,73,0.5); color: #ff8fb0; }
.console-layout { display: flex; gap: 1.2rem; margin-top: 1rem; align-items: flex-start; }
.console-main { flex: 1; min-width: 0; }
.console-sidebar { width: 14rem; flex-shrink: 0; border-left: 1px solid var(--line); padding-left: 1rem; }
.console-sidebar h3 { margin: 0 0 0.6rem; font-size: 0.8rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; }
.cmdlist { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.35rem; max-height: 320px; overflow-y: auto; }
.cmdlist .chip { width: 100%; text-align: left; cursor: pointer; }
@media (max-width: 640px) {
.console-layout { flex-direction: column; }
.console-sidebar { width: 100%; border-left: none; padding-left: 0; border-top: 1px solid var(--line); padding-top: 1rem; }
}
/* ---------- Command groups (fieldsets) ---------- */
.cmdgroup { border: 1px solid var(--line); border-radius: var(--radius-sm); margin: 0.9rem 0; padding: 0.4rem 1rem 0.8rem; background: var(--bg-elev); }
.cmdgroup legend { color: var(--muted); text-transform: capitalize; font-size: 0.78rem; font-weight: 600; letter-spacing: 0.04em; padding: 0 0.45rem; }
.cmdrow { display: flex; align-items: center; gap: 0.5rem; margin: 0.3rem 0; font-size: 0.9rem; padding: 0.25rem 0.3rem; border-radius: 7px; }
.cmdrow:hover { background: var(--panel-2); }
.cmdrow input { width: auto; }
.cmdrow.risky code { color: #ff8fb0; }
.checkrow { display: flex; gap: 0.6rem; align-items: flex-start; margin-top: 1.1rem; color: var(--ink); font-size: 0.9rem; }
.checkrow input { width: auto; margin-top: 0.25rem; }
.radiorow { display: grid; grid-template-columns: 1fr 1fr 0.5fr 0.5fr; gap: 0.6rem; }
.radiorow label { margin-top: 0.6rem; }
/* ---------- Log table ---------- */
.logtable-wrap { overflow-x: auto; }
.logtable { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
.logtable th { text-align: left; padding: 0.45rem 0.65rem; border-bottom: 1px solid var(--line-2); color: var(--muted); font-weight: 600; }
.logtable td { text-align: left; padding: 0.45rem 0.65rem; border-bottom: 1px solid var(--line); vertical-align: top; }
.logtable code.resp { white-space: pre-wrap; word-break: break-word; }
.session { border: 1px solid var(--line); border-radius: var(--radius-sm); margin: 0.9rem 0; overflow: hidden; }
.session-head { background: var(--panel-2); padding: 0.6rem 0.9rem; font-size: 0.9rem; }
.session .logtable-wrap { padding: 0 0.5rem 0.4rem; }
/* ---------- Map ---------- */
#map { height: 360px; border-radius: var(--radius-sm); margin-bottom: 1rem; border: 1px solid var(--line); }
.leaflet-popup-content { color: #111; }
/* ---------- Admin links ---------- */
.adminlinks { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.7rem; }
.adminlinks li {
padding: 0; border: 1px solid var(--line); border-radius: var(--radius-sm);
background: var(--bg-elev); transition: border-color 0.15s, background 0.15s;
}
.adminlinks li:hover { border-color: var(--line-2); background: var(--panel); }
.adminlinks a {
display: block; padding: 0.9rem 1.1rem; color: var(--ink); text-decoration: none; font-weight: 600;
}
.adminlinks .desc { display: block; color: var(--muted); font-weight: 400; font-size: 0.88rem; margin-top: 0.15rem; }
.catrow { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; padding: 0.45rem 0.3rem; border-bottom: 1px solid var(--line); margin: 0; }
.catrow:last-child { border-bottom: none; }
.catrow .catcmd { flex: 1; min-width: 12rem; }
.catrow label { margin: 0; display: flex; align-items: center; gap: 0.35rem; font-size: 0.85rem; color: var(--ink); }
.catrow label input { width: auto; }
.catrow button { margin: 0; }
/* ---------- Wizard step indicator ---------- */
.steps { display: flex; flex-wrap: wrap; gap: 0.5rem; list-style: none; padding: 0; margin: 0 0 1.6rem; counter-reset: step; }
.steps li {
display: inline-flex; align-items: center; gap: 0.45rem;
font-size: 0.85rem; color: var(--faint); padding: 0.3rem 0.7rem 0.3rem 0.5rem;
border: 1px solid var(--line); border-radius: 999px; background: var(--bg-elev);
}
.steps li::before {
counter-increment: step; content: counter(step);
display: inline-flex; align-items: center; justify-content: center;
width: 1.4rem; height: 1.4rem; border-radius: 50%;
background: var(--panel-2); color: var(--muted); font-size: 0.78rem; font-weight: 600;
}
.steps li.active { color: var(--ink); border-color: var(--accent); }
.steps li.active::before { background: var(--accent); color: var(--accent-ink); }
.steps li.done { color: var(--muted); }
.steps li.done::before { content: "✓"; background: rgba(63,185,80,0.25); color: var(--accent); }
.danger-note {
background: rgba(248,81,73,0.08); border: 1px solid rgba(248,81,73,0.4);
border-radius: var(--radius-sm); padding: 1rem 1.1rem; margin: 1.1rem 0;
}
.danger-note h3 { margin: 0 0 0.5rem; font-size: 0.95rem; color: #ff9a93; }
.danger-note ul { margin: 0.5rem 0 0; padding-left: 1.1rem; color: var(--muted); font-size: 0.9rem; }
.danger-note li { margin: 0.3rem 0; }
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+102 -84
View File
@@ -1,101 +1,119 @@
{{define "title"}}Add a repeater · MeshTender{{end}}
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col"><h2 class="page-title">Add a repeater</h2></div>
</div>
{{end}}
{{define "content"}}
{{if .Error}}<p class="error banner">{{.Error}}</p>{{end}}
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
<ol class="steps">
<li class="{{if eq .Step "grant"}}active{{else}}done{{end}}">Grant access</li>
<li class="{{if eq .Step "details"}}active{{end}}">Repeater details</li>
<li>Confirm</li>
<li>Share</li>
</ol>
<ul class="steps steps-counter mb-4">
<li class="step-item {{if eq .Step "grant"}}active{{end}}">Grant access</li>
<li class="step-item {{if eq .Step "details"}}active{{end}}">Repeater details</li>
<li class="step-item">Confirm</li>
<li class="step-item">Share</li>
</ul>
{{if eq .Step "grant"}}
<section class="card">
<h1>Step 1 · Grant MeshTender access</h1>
<p class="muted">
MeshTender controls repeaters through a single MeshTender identity. Before it can do anything with
your repeater, you must grant that identity admin access by running the command below on the repeater
(via its BLE/USB companion app or another admin client).
</p>
<div class="card">
<div class="card-body">
<h2 class="card-title">Step 1 · Grant MeshTender access</h2>
<p class="text-secondary">
MeshTender controls repeaters through a single MeshTender identity. Before it can do anything with
your repeater, you must grant that identity admin access by running the command below on the repeater
(via its BLE/USB companion app or another admin client).
</p>
<div class="danger-note">
<h3>⚠ This grants full admin control to MeshTender</h3>
<ul>
<li>This grants admin to <strong>MeshTender itself</strong><strong>not</strong> to other
MeshTender users. Letting specific people operate the repeater is a separate step you control later
via sharing and contributing to organizations.</li>
<li>MeshTender will not abuse this access — but that is a <strong>promise, not a technical
limit</strong>.</li>
<li>This is a standing grant. It remains until <strong>you</strong> revoke it on the repeater
(see below) — removing the repeater from this site does not revoke MeshTender's access.</li>
</ul>
</div>
<label>MeshTender server public key
<code class="pubkey">{{.ServerPubKey}}</code>
</label>
<label>Grant-access command — run this on your repeater
<div class="copyrow">
<code>{{.SetPermCommand}}</code>
<button type="button" onclick="navigator.clipboard.writeText(this.previousElementSibling.textContent)">Copy</button>
<div class="alert alert-danger" role="alert">
<div class="d-flex">
<div>{{template "icon-alert" "alert-icon"}}</div>
<div>
<h4 class="alert-title">This grants full admin control to MeshTender</h4>
<ul class="mb-0 text-secondary">
<li>This grants admin to <strong>MeshTender itself</strong><strong>not</strong> to other
MeshTender users. Letting specific people operate the repeater is a separate step you control later
via sharing and contributing to organizations.</li>
<li>MeshTender will not abuse this access — but that is a <strong>promise, not a technical limit</strong>.</li>
<li>This is a standing grant. It remains until <strong>you</strong> revoke it on the repeater
(see below) — removing the repeater from this site does not revoke MeshTender's access.</li>
</ul>
</div>
</div>
</div>
</label>
{{template "revokedoc" .RevokeCommand}}
<div class="mb-3">
<label class="form-label">MeshTender server public key</label>
<code class="pubkey">{{.ServerPubKey}}</code>
</div>
<div class="mb-3">
<label class="form-label">Grant-access command — run this on your repeater</label>
<div class="input-group">
<code class="form-control font-monospace">{{.SetPermCommand}}</code>
<button type="button" class="btn" onclick="navigator.clipboard.writeText(this.previousElementSibling.textContent)">{{template "icon-copy" ""}}Copy</button>
</div>
</div>
<label class="checkrow">
<input type="checkbox" id="ack" onchange="document.getElementById('continue').toggleAttribute('disabled', !this.checked)">
I've granted MeshTender admin on this repeater (or I understand I must, before it will work here).
</label>
<div class="ractions">
<a class="btn primary" id="continue" href="/repeaters/add?step=details" disabled
onclick="if(this.hasAttribute('disabled')){return false;}">Continue →</a>
<a class="btn ghost" href="/">Cancel</a>
{{template "revokedoc" .RevokeCommand}}
<label class="form-check mt-3">
<input class="form-check-input" type="checkbox" id="ack" onchange="var c=document.getElementById('continue');c.classList.toggle('disabled', !this.checked);c.toggleAttribute('disabled', !this.checked);">
<span class="form-check-label">I've granted MeshTender admin on this repeater (or I understand I must, before it will work here).</span>
</label>
<div class="btn-list mt-3">
<a class="btn btn-primary disabled" id="continue" href="/repeaters/add?step=details" disabled
onclick="if(this.hasAttribute('disabled')){return false;}">Continue →</a>
<a class="btn" href="/">Cancel</a>
</div>
</div>
</section>
</div>
{{else}}
<section class="card">
<h1>Step 2 · Repeater details</h1>
<p class="muted">Tell MeshTender which repeater you just granted access to, and how its radio is configured.</p>
<form method="post" action="/repeaters">
<label>Name
<input type="text" name="name" placeholder="Hilltop repeater" required autofocus>
</label>
<label>Repeater public key (64 hex chars)
<input type="text" name="public_key" pattern="[0-9a-fA-F]{64}" placeholder="a1b2…" required>
</label>
<label>Region preset
<select id="region" onchange="applyRegion()">
{{range .Presets}}
<option value="{{.ID}}" data-freq="{{.FreqHz}}" data-bw="{{.BwHz}}" data-sf="{{.SF}}" data-cr="{{.CR}}"{{if eq .ID $.DefaultPresetID}} selected{{end}}>{{.Label}}</option>
{{end}}
<option value="custom"{{if eq "custom" $.DefaultPresetID}} selected{{end}}>Custom…</option>
</select>
</label>
<div class="radiorow">
<label>Freq (Hz)<input type="number" id="radio_freq_hz" name="radio_freq_hz" value="{{.Defaults.FreqHz}}" required></label>
<label>Bandwidth (Hz)<input type="number" id="radio_bw_hz" name="radio_bw_hz" value="{{.Defaults.BwHz}}" required></label>
<label>SF<input type="number" id="radio_sf" name="radio_sf" value="{{.Defaults.SF}}" min="5" max="12" required></label>
<label>CR<input type="number" id="radio_cr" name="radio_cr" value="{{.Defaults.CR}}" min="5" max="8" required></label>
</div>
<div class="card">
<div class="card-body">
<h2 class="card-title">Step 2 · Repeater details</h2>
<p class="text-secondary">Tell MeshTender which repeater you just granted access to, and how its radio is configured.</p>
<form method="post" action="/repeaters">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control" name="name" placeholder="Hilltop repeater" required autofocus>
</div>
<div class="mb-3">
<label class="form-label">Repeater public key (64 hex chars)</label>
<input type="text" class="form-control font-monospace" name="public_key" pattern="[0-9a-fA-F]{64}" placeholder="a1b2…" required>
</div>
<div class="mb-3">
<label class="form-label">Region preset</label>
<select class="form-select" id="region" onchange="applyRegion()">
{{range .Presets}}
<option value="{{.ID}}" data-freq="{{.FreqHz}}" data-bw="{{.BwHz}}" data-sf="{{.SF}}" data-cr="{{.CR}}"{{if eq .ID $.DefaultPresetID}} selected{{end}}>{{.Label}}</option>
{{end}}
<option value="custom"{{if eq "custom" $.DefaultPresetID}} selected{{end}}>Custom…</option>
</select>
</div>
<div class="row g-2">
<div class="col-6 col-md-3"><label class="form-label">Freq (Hz)</label><input type="number" class="form-control" id="radio_freq_hz" name="radio_freq_hz" value="{{.Defaults.FreqHz}}" required></div>
<div class="col-6 col-md-3"><label class="form-label">Bandwidth (Hz)</label><input type="number" class="form-control" id="radio_bw_hz" name="radio_bw_hz" value="{{.Defaults.BwHz}}" required></div>
<div class="col-6 col-md-3"><label class="form-label">SF</label><input type="number" class="form-control" id="radio_sf" name="radio_sf" value="{{.Defaults.SF}}" min="5" max="12" required></div>
<div class="col-6 col-md-3"><label class="form-label">CR</label><input type="number" class="form-control" id="radio_cr" name="radio_cr" value="{{.Defaults.CR}}" min="5" max="8" required></div>
</div>
<label class="checkrow">
<input type="checkbox" name="store_location" id="store_location" value="1" onchange="syncPublicMap()">
Store this repeater's location (lat/lon), fetched during the modem test, so it can appear on
the organization map to other organization members.
</label>
<label class="checkrow" id="public_map_row" style="display:none">
<input type="checkbox" name="public_map" value="1">
Also show this repeater on the <strong>public</strong> organization map.
</label>
<label class="form-check mt-3">
<input class="form-check-input" type="checkbox" name="store_location" id="store_location" value="1" onchange="syncPublicMap()">
<span class="form-check-label">Store this repeater's location (lat/lon), fetched during the modem test, so it can appear on
the organization map to other organization members.</span>
</label>
<label class="form-check" id="public_map_row" style="display:none">
<input class="form-check-input" type="checkbox" name="public_map" value="1">
<span class="form-check-label">Also show this repeater on the <strong>public</strong> organization map.</span>
</label>
<div class="ractions">
<button type="submit" class="primary">Add repeater</button>
<a class="btn ghost" href="/repeaters/add?step=grant">← Back</a>
</div>
</form>
</section>
<div class="btn-list mt-3">
<button type="submit" class="btn btn-primary">Add repeater</button>
<a class="btn" href="/repeaters/add?step=grant">← Back</a>
</div>
</form>
</div>
</div>
<script>
function applyRegion() {
var sel = document.getElementById("region");
+32 -9
View File
@@ -1,11 +1,34 @@
{{define "title"}}Admin · MeshTender{{end}}
{{define "content"}}
<section class="card">
<h1>Admin</h1>
<ul class="adminlinks">
{{if .CanCatalog}}<li><a href="/admin/catalog">Command catalog<span class="desc">Edit risk tags and the default command sets.</span></a></li>{{end}}
{{if .CanUsers}}<li><a href="/admin/users">Users &amp; capabilities<span class="desc">Grant or revoke admin capabilities.</span></a></li>{{end}}
</ul>
<p><a class="backlink" href="/">← Back to dashboard</a></p>
</section>
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Administration</div>
<h2 class="page-title">Admin</h2>
</div>
</div>
{{end}}
{{define "content"}}
<div class="row row-cards">
{{if .CanCatalog}}
<div class="col-md-6">
<a class="card card-link" href="/admin/catalog">
<div class="card-body">
<h3 class="card-title">{{template "icon-list" "me-1"}}Command catalog</h3>
<p class="text-secondary mb-0">Edit risk tags and the default command sets.</p>
</div>
</a>
</div>
{{end}}
{{if .CanUsers}}
<div class="col-md-6">
<a class="card card-link" href="/admin/users">
<div class="card-body">
<h3 class="card-title">{{template "icon-users" "me-1"}}Users &amp; capabilities</h3>
<p class="text-secondary mb-0">Grant or revoke admin capabilities.</p>
</div>
</a>
</div>
{{end}}
</div>
<a class="btn btn-link px-0 mt-3" href="/">{{template "icon-arrow-left" "me-1"}}Back to dashboard</a>
{{end}}
+37 -28
View File
@@ -1,30 +1,39 @@
{{define "title"}}Command catalog · MeshTender{{end}}
{{define "content"}}
<section class="card">
<h1>Command catalog</h1>
<p class="muted">
The firmware commands MeshTender can send. There's no global on/off — a repeater owner can run
anything. These flags seed what other people are offered: <strong>share</strong> = default for new
shares; <strong>member</strong>/<strong>admin</strong> = the org default sets (Phase 2).
<strong>Risky</strong> commands can take over or brick a node.
</p>
{{if .Saved}}<p class="badge ok">Saved</p>{{end}}
{{range .Groups}}
<fieldset class="cmdgroup">
<legend>{{.Name}}</legend>
{{range .Commands}}
<form method="post" action="/admin/catalog/{{.ID}}" class="catrow">
<code class="catcmd">{{.Template}}</code>
<label><input type="checkbox" name="risky" {{if .Risky}}checked{{end}}> risky</label>
<label><input type="checkbox" name="share" {{if .InShareDefault}}checked{{end}}> share</label>
<label><input type="checkbox" name="org_member" {{if .InOrgMemberDefault}}checked{{end}}> member</label>
<label><input type="checkbox" name="org_admin" {{if .InOrgAdminDefault}}checked{{end}}> admin</label>
<button type="submit" class="link">Save</button>
</form>
{{end}}
</fieldset>
{{end}}
<p><a class="backlink" href="/admin">← Admin</a></p>
</section>
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Administration</div>
<h2 class="page-title">Command catalog</h2>
</div>
</div>
{{end}}
{{define "content"}}
<div class="card">
<div class="card-body">
<p class="text-secondary">
The firmware commands MeshTender can send. There's no global on/off — a repeater owner can run
anything. These flags seed what other people are offered: <strong>share</strong> = default for new
shares; <strong>member</strong>/<strong>admin</strong> = the org default sets (Phase 2).
<strong>Risky</strong> commands can take over or brick a node.
</p>
{{if .Saved}}<div class="alert alert-success">Saved</div>{{end}}
{{range .Groups}}
<fieldset class="form-fieldset">
<div class="form-label fw-bold text-capitalize">{{.Name}}</div>
{{range .Commands}}
<form method="post" action="/admin/catalog/{{.ID}}" class="d-flex align-items-center flex-wrap gap-3 py-1 border-bottom">
<code class="flex-fill">{{.Template}}</code>
<label class="form-check form-check-inline m-0"><input class="form-check-input" type="checkbox" name="risky" {{if .Risky}}checked{{end}}><span class="form-check-label">risky</span></label>
<label class="form-check form-check-inline m-0"><input class="form-check-input" type="checkbox" name="share" {{if .InShareDefault}}checked{{end}}><span class="form-check-label">share</span></label>
<label class="form-check form-check-inline m-0"><input class="form-check-input" type="checkbox" name="org_member" {{if .InOrgMemberDefault}}checked{{end}}><span class="form-check-label">member</span></label>
<label class="form-check form-check-inline m-0"><input class="form-check-input" type="checkbox" name="org_admin" {{if .InOrgAdminDefault}}checked{{end}}><span class="form-check-label">admin</span></label>
<button type="submit" class="btn btn-sm">Save</button>
</form>
{{end}}
</fieldset>
{{end}}
</div>
</div>
<a class="btn btn-link px-0 mt-3" href="/admin">{{template "icon-arrow-left" "me-1"}}Admin</a>
{{end}}
+37 -21
View File
@@ -1,23 +1,39 @@
{{define "title"}}Users · MeshTender{{end}}
{{define "content"}}
<section class="card">
<h1>Users &amp; capabilities</h1>
{{if .Error}}<p class="error banner">{{.Error}}</p>{{end}}
<p class="muted">
<strong>Manage users</strong> = can grant/revoke these capabilities (superadmin).
<strong>Manage catalog</strong> = can edit the command catalog. Neither grants access to any
repeater — that's still per-repeater sharing.
</p>
{{range .Users}}
<form method="post" action="/admin/users/{{.ID}}" class="catrow">
<span class="catcmd">{{.Name}} <span class="muted">@{{.Username}}</span></span>
<label><input type="checkbox" name="manage_users" {{if .CapManageUsers}}checked{{end}}> manage users</label>
<label><input type="checkbox" name="manage_catalog" {{if .CapManageCatalog}}checked{{end}}> manage catalog</label>
<button type="submit" class="link">Save</button>
{{if eq .ID $.Self}}<span class="muted">(you)</span>{{end}}
</form>
{{end}}
<p><a class="backlink" href="/admin">← Admin</a></p>
</section>
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Administration</div>
<h2 class="page-title">Users &amp; capabilities</h2>
</div>
</div>
{{end}}
{{define "content"}}
<div class="card">
<div class="card-body">
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
<p class="text-secondary">
<strong>Manage users</strong> = can grant/revoke these capabilities (superadmin).
<strong>Manage catalog</strong> = can edit the command catalog. Neither grants access to any
repeater — that's still per-repeater sharing.
</p>
<div class="list-group list-group-flush">
{{range .Users}}
<form method="post" action="/admin/users/{{.ID}}" class="list-group-item d-flex align-items-center flex-wrap gap-3 px-0">
<span class="flex-fill">{{.Name}} <span class="text-secondary">@{{.Username}}</span>{{if eq .ID $.Self}} <span class="badge bg-azure-lt">you</span>{{end}}</span>
<label class="form-check form-check-inline m-0">
<input class="form-check-input" type="checkbox" name="manage_users" {{if .CapManageUsers}}checked{{end}}>
<span class="form-check-label">manage users</span>
</label>
<label class="form-check form-check-inline m-0">
<input class="form-check-input" type="checkbox" name="manage_catalog" {{if .CapManageCatalog}}checked{{end}}>
<span class="form-check-label">manage catalog</span>
</label>
<button type="submit" class="btn btn-sm">Save</button>
</form>
{{end}}
</div>
</div>
</div>
<a class="btn btn-link px-0 mt-3" href="/admin">{{template "icon-arrow-left" "me-1"}}Admin</a>
{{end}}
+101 -49
View File
@@ -1,66 +1,118 @@
{{define "base"}}<!doctype html>
<html lang="en">
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>{{block "title" .}}MeshTender{{end}}</title>
<link rel="stylesheet" href="/static/tabler.min.css">
<link rel="stylesheet" href="/static/app.css">
<script src="/static/tabler.min.js" defer></script>
<script src="/static/htmx.min.js" defer></script>
</head>
<body>
<header class="topbar">
<a class="brand" href="/">📡 MeshTender</a>
{{if .UserName}}
<nav>
<a href="/" class="navlink">Repeaters</a>
<a href="/orgs" class="navlink">Organizations</a>
{{if .CanAdmin}}<a href="/admin" class="navlink">Admin</a>{{end}}
<details class="menu">
<summary>👤 {{.UserName}}</summary>
<div class="menu-panel">
<span class="who" style="padding:0.4rem 0.65rem">Signed in as <strong>{{.UserName}}</strong></span>
<div class="menu-sep"></div>
<form method="post" action="/logout">
<button type="submit" class="danger">Sign out</button>
</form>
<div class="page">
<header class="navbar navbar-expand-md navbar-overlap d-print-none" data-bs-theme="dark">
<div class="container-xl">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-menu" aria-controls="navbar-menu" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="navbar-brand navbar-brand-autodark d-none-navbar-horizontal pe-0 pe-md-3">
<a href="/" class="d-flex align-items-center text-reset text-decoration-none">
{{template "icon-tower" "me-2"}}<span class="fw-bold">MeshTender</span>
</a>
</div>
</details>
</nav>
{{else}}
<nav>
<a href="/orgs" class="navlink">Organizations</a>
<a href="/login" class="btn sm">Sign in</a>
</nav>
{{end}}
</header>
<main>
{{block "content" .}}{{end}}
</main>
<script>
// Close any open <details class="menu"> when clicking outside it.
document.addEventListener("click", function (e) {
document.querySelectorAll("details.menu[open]").forEach(function (d) {
if (!d.contains(e.target)) d.removeAttribute("open");
});
});
</script>
<div class="navbar-nav flex-row order-md-last">
{{if .UserName}}
<div class="nav-item dropdown">
<a href="#" class="nav-link d-flex lh-1 p-0 px-2" data-bs-toggle="dropdown" aria-label="Open user menu">
<span class="avatar avatar-sm">{{template "icon-user" ""}}</span>
<div class="d-none d-xl-block ps-2"><div>{{.UserName}}</div></div>
</a>
<div class="dropdown-menu dropdown-menu-end dropdown-menu-arrow">
<span class="dropdown-header">Signed in as <strong>{{.UserName}}</strong></span>
<div class="dropdown-divider"></div>
<form method="post" action="/logout" class="m-0">
<button type="submit" class="dropdown-item text-danger">{{template "icon-logout" "dropdown-item-icon"}}Sign out</button>
</form>
</div>
</div>
{{else}}
<a href="/login" class="btn btn-primary">{{template "icon-login" "me-1"}}Sign in</a>
{{end}}
</div>
<div class="collapse navbar-collapse" id="navbar-menu">
<ul class="navbar-nav">
{{if .UserName}}
<li class="nav-item">
<a class="nav-link" href="/">
<span class="nav-link-icon d-md-none d-lg-inline-block">{{template "icon-antenna" ""}}</span>
<span class="nav-link-title">Repeaters</span>
</a>
</li>
{{end}}
<li class="nav-item">
<a class="nav-link" href="/orgs">
<span class="nav-link-icon d-md-none d-lg-inline-block">{{template "icon-world" ""}}</span>
<span class="nav-link-title">Organizations</span>
</a>
</li>
{{if .CanAdmin}}
<li class="nav-item">
<a class="nav-link" href="/admin">
<span class="nav-link-icon d-md-none d-lg-inline-block">{{template "icon-settings" ""}}</span>
<span class="nav-link-title">Admin</span>
</a>
</li>
{{end}}
</ul>
</div>
</div>
</header>
<div class="page-wrapper">
<div class="page-header d-print-none text-white" aria-label="Page header">
<div class="container-xl">
{{block "header" .}}
<div class="row g-2 align-items-center">
<div class="col">
<h2 class="page-title">MeshTender</h2>
</div>
</div>
{{end}}
</div>
</div>
<div class="page-body">
<div class="container-xl">
{{block "content" .}}{{end}}
</div>
</div>
<footer class="footer footer-transparent d-print-none">
<div class="container-xl">
<div class="row text-center align-items-center flex-row-reverse">
<div class="col-12 col-lg-auto mt-3 mt-lg-0">
<span class="text-secondary">MeshTender &mdash; tend your mesh.</span>
</div>
</div>
</div>
</footer>
</div>
</div>
</body>
</html>{{end}}
{{/* revokedoc explains how to revoke MeshTender's access. Dot is the revoke command string. */}}
{{define "revokedoc"}}
<details class="fallback">
<summary>How to revoke MeshTender's access later</summary>
<p class="muted">
<details class="mt-3">
<summary class="text-secondary" style="cursor:pointer">How to revoke MeshTender's access later</summary>
<p class="text-secondary mt-2">
Granting access is a standing trust: MeshTender keeps admin on your repeater until <strong>you</strong>
remove it — deleting the repeater from this site does <strong>not</strong> revoke it. To revoke, run this
on the repeater (via its BLE/USB companion app or another admin client):
</p>
<div class="copyrow">
<code>{{.}}</code>
<button type="button" onclick="navigator.clipboard.writeText(this.previousElementSibling.textContent)">Copy</button>
<div class="input-group">
<code class="form-control font-monospace text-secondary">{{.}}</code>
<button type="button" class="btn" onclick="navigator.clipboard.writeText(this.previousElementSibling.textContent)">{{template "icon-copy" ""}}Copy</button>
</div>
<p class="muted">With no level argument, that <strong>removes MeshTender from the repeater's ACL entirely</strong>.</p>
<p class="text-secondary mt-2 mb-0">With no level argument, that <strong>removes MeshTender from the repeater's ACL entirely</strong>.</p>
</details>
{{end}}
@@ -68,14 +120,14 @@
{{define "repstatus"}}
{{- if .Confirmed -}}
{{- if .Corroborated -}}
<span class="badge ok" title="Independently reached by {{range $i, $n := .Corroborators}}{{if $i}}, {{end}}{{$n}}{{end}}">Corroborated</span>
<span class="badge bg-success-lt" title="Independently reached by {{range $i, $n := .Corroborators}}{{if $i}}, {{end}}{{$n}}{{end}}">Corroborated</span>
{{- else if .SelfConfirmed -}}
<span class="badge shared" title="You reached it from your own modem; no one else has corroborated yet">Self-confirmed</span>
<span class="badge bg-azure-lt" title="You reached it from your own modem; no one else has corroborated yet">Self-confirmed</span>
{{- else -}}
<span class="badge ok">Confirmed</span>
<span class="badge bg-success-lt">Confirmed</span>
{{- end -}}
{{- if .AccessKnown}}{{if .IsAdmin}}<span class="badge ok">admin</span>{{else}}<span class="badge warn">guest only</span>{{end}}{{end -}}
{{- if .AccessKnown}}{{if .IsAdmin}}<span class="badge bg-success-lt">admin</span>{{else}}<span class="badge bg-warning-lt">guest only</span>{{end}}{{end -}}
{{- else -}}
<span class="badge pending" title="No one has reached this repeater through MeshTender yet">Unconfirmed</span>
<span class="badge bg-yellow-lt" title="No one has reached this repeater through MeshTender yet">Unconfirmed</span>
{{- end -}}
{{end}}
+36 -31
View File
@@ -1,40 +1,45 @@
{{define "title"}}Command log · {{.Repeater.Name}} · MeshTender{{end}}
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Command log</div>
<h2 class="page-title">{{.Repeater.Name}}</h2>
</div>
</div>
{{end}}
{{define "content"}}
<section class="card">
<h1>Command log · {{.Repeater.Name}}</h1>
<p class="muted">
Commands grouped by console session (one modem connection). “Confirmed” means a reply came back;
no reply doesn't mean the command failed — it may have run without us hearing the response.
</p>
<div class="card">
<div class="card-body">
<p class="text-secondary">
Commands grouped by console session (one modem connection). “Confirmed” means a reply came back;
no reply doesn't mean the command failed — it may have run without us hearing the response.
</p>
</div>
{{if .Sessions}}
{{range .Sessions}}
<div class="session">
<div class="session-head">
<strong>{{.SenderName}}</strong>
<span class="muted">· {{.StartedAt.Format "2006-01-02 15:04"}}{{if .EndedAt}}{{.EndedAt.Format "15:04"}}{{else}} · in progress{{end}}</span>
</div>
<div class="logtable-wrap">
<table class="logtable">
<thead><tr><th>When</th><th>Command</th><th>Confirmed</th><th>Response</th></tr></thead>
<tbody>
{{range .Entries}}
<tr>
<td class="muted">{{.SentAt.Format "15:04:05"}}</td>
<td><code>{{.CommandText}}</code></td>
<td>{{if .AckReceived}}<span class="badge ok">yes</span>{{else}}<span class="badge pending">no reply</span>{{end}}</td>
<td><code class="resp">{{if .ResponseText}}{{.ResponseText}}{{else}}—{{end}}</code></td>
</tr>
{{end}}
</tbody>
</table>
</div>
<div class="card-header bg-transparent">
<h3 class="card-title mb-0">{{.SenderName}}</h3>
<span class="text-secondary ms-2">{{.StartedAt.Format "2006-01-02 15:04"}}{{if .EndedAt}}{{.EndedAt.Format "15:04"}}{{else}} · in progress{{end}}</span>
</div>
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead><tr><th>When</th><th>Command</th><th>Confirmed</th><th>Response</th></tr></thead>
<tbody>
{{range .Entries}}
<tr>
<td class="text-secondary">{{.SentAt.Format "15:04:05"}}</td>
<td><code>{{.CommandText}}</code></td>
<td>{{if .AckReceived}}<span class="badge bg-success-lt">yes</span>{{else}}<span class="badge bg-yellow-lt">no reply</span>{{end}}</td>
<td><code class="text-wrap">{{if .ResponseText}}{{.ResponseText}}{{else}}—{{end}}</code></td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
{{else}}
<p class="muted">No commands have been sent yet.</p>
<div class="card-body pt-0"><p class="text-secondary mb-0">No commands have been sent yet.</p></div>
{{end}}
<p><a class="backlink" href="/">Back to dashboard</a></p>
</section>
</div>
<a class="btn btn-link px-0 mt-3" href="/">{{template "icon-arrow-left" "me-1"}}Back to dashboard</a>
{{end}}
+33 -24
View File
@@ -1,31 +1,40 @@
{{define "title"}}Confirm {{.Repeater.Name}} · MeshTender{{end}}
{{define "content"}}
<section class="card">
<h1>Confirm “{{.Repeater.Name}}”</h1>
<p class="muted">
Plug a MeshCore <strong>KISS modem</strong> into this computer, then connect it below.
MeshTender will send a signed login to the repeater through your modem and read the reply
to verify it has access. This is optional — the repeater works either way.
</p>
<code class="pk">{{.Repeater.PublicKeyHex}}</code>
<div class="rmeta muted">{{.Repeater.RadioFreqHz}} Hz · BW {{.Repeater.RadioBwHz}} · SF{{.Repeater.RadioSF}} · CR{{.Repeater.RadioCR}}</div>
<div id="unsupported" class="error" hidden>
This browser does not support WebSerial. Use Chrome, Edge, or another Chromium-based browser over HTTPS or on localhost.
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Confirm access</div>
<h2 class="page-title">{{.Repeater.Name}}</h2>
</div>
</div>
{{end}}
{{define "content"}}
<div class="card">
<div class="card-body">
<p class="text-secondary">
Plug a MeshCore <strong>KISS modem</strong> into this computer, then connect it below.
MeshTender will send a signed login to the repeater through your modem and read the reply
to verify it has access. This is optional — the repeater works either way.
</p>
<code class="pk">{{.Repeater.PublicKeyHex}}</code>
<div class="text-secondary small mt-2">{{.Repeater.RadioFreqHz}} Hz · BW {{.Repeater.RadioBwHz}} · SF{{.Repeater.RadioSF}} · CR{{.Repeater.RadioCR}}</div>
<button id="connect" class="primary" type="button">Connect modem &amp; confirm</button>
<div id="unsupported" class="alert alert-danger mt-3" hidden>
This browser does not support WebSerial. Use Chrome, Edge, or another Chromium-based browser over HTTPS or on localhost.
</div>
<ul id="log" class="evlog"></ul>
<p>
<a class="backlink" href="/">← Back to dashboard</a>
{{if .Debug}}
&nbsp;·&nbsp; <a class="backlink" href="/repeaters/{{.Repeater.ID}}/confirm">Disable debug</a>
{{else}}
&nbsp;·&nbsp; <a class="backlink" href="/repeaters/{{.Repeater.ID}}/confirm?debug=1">Debug: show raw frames</a>
{{end}}
</p>
</section>
<button id="connect" class="btn btn-primary mt-3" type="button">{{template "icon-plug" "me-1"}}Connect modem &amp; confirm</button>
<ul id="log" class="evlog mt-3"></ul>
<div class="mt-3">
<a class="btn btn-link px-0" href="/">{{template "icon-arrow-left" "me-1"}}Back to dashboard</a>
{{if .Debug}}
<a class="text-secondary ms-2" href="/repeaters/{{.Repeater.ID}}/confirm">Disable debug</a>
{{else}}
<a class="text-secondary ms-2" href="/repeaters/{{.Repeater.ID}}/confirm?debug=1">Debug: show raw frames</a>
{{end}}
</div>
</div>
</div>
<script>
window.MESHTENDER_WS = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/repeaters/{{.Repeater.ID}}/ws{{if .Debug}}?debug=1{{end}}";
+44 -35
View File
@@ -1,43 +1,52 @@
{{define "title"}}Console · {{.Repeater.Name}} · MeshTender{{end}}
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Console</div>
<h2 class="page-title">{{.Repeater.Name}}</h2>
</div>
</div>
{{end}}
{{define "content"}}
<section class="card">
<h1>Console · {{.Repeater.Name}}</h1>
<p class="muted">
Connect a MeshCore KISS modem and send commands to this repeater. You can run the commands listed
below; the server enforces what you're allowed to send.
</p>
<code class="pk">{{.Repeater.PublicKeyHex}}</code>
<div class="card">
<div class="card-body">
<p class="text-secondary">
Connect a MeshCore KISS modem and send commands to this repeater. You can run the commands listed
below; the server enforces what you're allowed to send.
</p>
<code class="pk">{{.Repeater.PublicKeyHex}}</code>
<div id="unsupported" class="error" hidden>
This browser does not support WebSerial. Use Chrome, Edge, or another Chromium-based browser over HTTPS or on localhost.
</div>
<button id="connect" class="primary" type="button">Connect modem</button>
<div class="console-layout">
<div class="console-main">
<ul id="log" class="evlog console"></ul>
<form id="cmdform" autocomplete="off" hidden>
<input type="text" id="cmdinput" placeholder="type a command, e.g. ver">
<button type="submit" id="cmdsend">Send</button>
</form>
<div id="unsupported" class="alert alert-danger mt-3" hidden>
This browser does not support WebSerial. Use Chrome, Edge, or another Chromium-based browser over HTTPS or on localhost.
</div>
<aside class="console-sidebar">
<h3>Allowed commands</h3>
{{if .Commands}}
<ul class="cmdlist">
{{range .Commands}}
<li><button type="button" class="chip{{if .Risky}} risky{{end}}" data-template="{{.Template}}" title="{{.Args}}">{{.Template}}</button></li>
{{end}}
</ul>
{{else}}
<p class="muted">No commands allowed here yet.</p>
{{end}}
</aside>
</div>
<p><a class="backlink" href="/">← Back to dashboard</a></p>
</section>
<button id="connect" class="btn btn-primary mt-3" type="button">{{template "icon-plug" "me-1"}}Connect modem</button>
<div class="row mt-3">
<div class="col-12 col-md-8">
<ul id="log" class="evlog console"></ul>
<form id="cmdform" class="input-group mt-3" autocomplete="off" hidden>
<input type="text" id="cmdinput" class="form-control font-monospace" placeholder="type a command, e.g. ver">
<button type="submit" id="cmdsend" class="btn btn-primary">Send</button>
</form>
</div>
<div class="col-12 col-md-4 mt-3 mt-md-0">
<div class="subheader mb-2">Allowed commands</div>
{{if .Commands}}
<div class="d-flex flex-column gap-1" style="max-height:320px;overflow-y:auto">
{{range .Commands}}
<button type="button" class="chip btn btn-sm w-100 text-start{{if .Risky}} btn-outline-danger{{end}}" data-template="{{.Template}}" title="{{.Args}}">{{.Template}}</button>
{{end}}
</div>
{{else}}
<p class="text-secondary">No commands allowed here yet.</p>
{{end}}
</div>
</div>
<a class="btn btn-link px-0 mt-3" href="/">{{template "icon-arrow-left" "me-1"}}Back to dashboard</a>
</div>
</div>
<script>
window.MESHTENDER_WS = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/repeaters/{{.Repeater.ID}}/console/ws";
+50 -41
View File
@@ -1,48 +1,57 @@
{{define "title"}}Contribute {{.Repeater.Name}} · MeshTender{{end}}
{{define "content"}}
<section class="card">
<h1>Contribute “{{.Repeater.Name}}” to {{.Org.Name}}</h1>
<p class="muted">
By consenting, you allow {{.Org.Name}}'s admins and members to run these commands on this repeater
over the mesh (policy v{{.Version}}). Org-admins also get everything members get. If the org later
adds commands, this repeater stays on v{{.Version}} until you review and re-consent. You can
withdraw anytime.
</p>
{{if .Reconsent}}
<div class="warn-note">
<strong>Changes since you consented (v{{.ConsentedVersion}} → v{{.Version}}):</strong>
{{if .Added}}<div>Newly granted: {{range .Added}}<code>{{.}}</code> {{end}}</div>{{end}}
{{if .Removed}}<div>No longer granted: {{range .Removed}}<code>{{.}}</code> {{end}}</div>{{end}}
{{if not (or .Added .Removed)}}<div>No command changes (notes/metadata only).</div>{{end}}
{{if .Notes}}<div class="muted" style="margin-top:0.4rem">
{{range .Notes}}v{{.Version}}{{if .Note}}: {{.Note}}{{end}}<br>{{end}}
</div>{{end}}
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Contribute to {{.Org.Name}}</div>
<h2 class="page-title">{{.Repeater.Name}}</h2>
</div>
{{end}}
</div>
{{end}}
{{define "content"}}
<div class="card">
<div class="card-body">
<p class="text-secondary">
By consenting, you allow {{.Org.Name}}'s admins and members to run these commands on this repeater
over the mesh (policy v{{.Version}}). Org-admins also get everything members get. If the org later
adds commands, this repeater stays on v{{.Version}} until you review and re-consent. You can
withdraw anytime.
</p>
{{if .Envelope}}
{{range .Envelope}}
<fieldset class="cmdgroup">
<legend>{{.Name}}</legend>
{{range .Commands}}
<div class="catrow{{if .Risky}} risky{{end}}">
<code class="catcmd">{{.Template}}{{if .Risky}} <span class="badge warn">risky</span>{{end}}</code>
{{if .AdminChecked}}<span class="badge ok">admin</span>{{end}}
{{if .MemberChecked}}<span class="badge shared">member</span>{{end}}
{{if .Reconsent}}
<div class="alert alert-warning">
<h4 class="alert-title">Changes since you consented (v{{.ConsentedVersion}} → v{{.Version}})</h4>
{{if .Added}}<div>Newly granted: {{range .Added}}<code>{{.}}</code> {{end}}</div>{{end}}
{{if .Removed}}<div>No longer granted: {{range .Removed}}<code>{{.}}</code> {{end}}</div>{{end}}
{{if not (or .Added .Removed)}}<div>No command changes (notes/metadata only).</div>{{end}}
{{if .Notes}}<div class="text-secondary mt-2">
{{range .Notes}}v{{.Version}}{{if .Note}}: {{.Note}}{{end}}<br>{{end}}
</div>{{end}}
</div>
{{end}}
</fieldset>
{{end}}
{{else}}
<p class="muted">This org's policy currently grants no commands.</p>
{{end}}
<form method="post" action="/repeaters/{{.Repeater.ID}}/orgs/{{.Org.ID}}/contribute">
<div class="ractions">
<button type="submit" class="primary">I consent — contribute this repeater</button>
<a class="btn ghost" href="/repeaters/{{.Repeater.ID}}/share">Cancel</a>
</div>
</form>
</section>
{{if .Envelope}}
{{range .Envelope}}
<fieldset class="form-fieldset">
<div class="form-label fw-bold text-capitalize">{{.Name}}</div>
{{range .Commands}}
<div class="d-flex align-items-center flex-wrap gap-2 py-1">
<code class="flex-fill">{{.Template}}{{if .Risky}} <span class="badge bg-warning-lt">risky</span>{{end}}</code>
{{if .AdminChecked}}<span class="badge bg-success-lt">admin</span>{{end}}
{{if .MemberChecked}}<span class="badge bg-azure-lt">member</span>{{end}}
</div>
{{end}}
</fieldset>
{{end}}
{{else}}
<p class="text-secondary">This org's policy currently grants no commands.</p>
{{end}}
<form method="post" action="/repeaters/{{.Repeater.ID}}/orgs/{{.Org.ID}}/contribute">
<div class="btn-list mt-3">
<button type="submit" class="btn btn-primary">I consent — contribute this repeater</button>
<a class="btn" href="/repeaters/{{.Repeater.ID}}/share">Cancel</a>
</div>
</form>
</div>
</div>
{{end}}
+78 -59
View File
@@ -1,69 +1,88 @@
{{define "title"}}Dashboard · MeshTender{{end}}
{{define "content"}}
{{if .Error}}<p class="error banner">{{.Error}}</p>{{end}}
<section class="card">
<div class="rhead">
<h1 style="flex:1;margin:0">Your repeaters</h1>
<a class="btn primary sm" href="/repeaters/add">+ Add repeater</a>
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Overview</div>
<h2 class="page-title">Your repeaters</h2>
</div>
{{if .Owned}}
<ul class="repeaters">
{{range .Owned}}
<li>
<div class="rhead">
<span class="rname">{{.Name}}</span>
{{template "repstatus" .}}
{{if index $.Reconsent .ID}}<a class="badge warn" href="/repeaters/{{.ID}}/share">re-consent needed</a>{{end}}
</div>
<code class="pk">{{.PublicKeyHex}}</code>
<div class="rmeta muted">{{.RadioFreqHz}} Hz · BW {{.RadioBwHz}} · SF{{.RadioSF}} · CR{{.RadioCR}}</div>
{{if and .Confirmed .AccessKnown (not .IsAdmin)}}
<p class="warn-note">⚠ MeshTender only has <strong>guest</strong> access here. Guest is available to anyone with a blank password, so there's no point operating at this level — re-run <code>setperm &lt;your key&gt; 3</code> on the repeater to grant admin, then re-test.</p>
{{end}}
<div class="ractions">
<a class="btn primary sm" href="/repeaters/{{.ID}}/console">▸ Console</a>
<details class="menu">
<summary>Manage ▾</summary>
<div class="menu-panel">
<a href="/repeaters/{{.ID}}/edit">✎ Edit details</a>
<a href="/repeaters/{{.ID}}/confirm">📶 Confirm access</a>
<a href="/repeaters/{{.ID}}/log">📋 Logs</a>
<div class="menu-sep"></div>
<a href="/repeaters/{{.ID}}/share">🔗 Sharing</a>
<div class="menu-sep"></div>
<a class="danger" href="/repeaters/{{.ID}}/delete">🗑 Delete repeater</a>
<div class="col-auto ms-auto d-print-none">
<a class="btn btn-primary" href="/repeaters/add">{{template "icon-plus" "me-1"}}Add repeater</a>
</div>
</div>
{{end}}
{{define "content"}}
{{if .Error}}<div class="alert alert-danger" role="alert">{{.Error}}</div>{{end}}
{{if .Owned}}
<div class="row row-cards">
{{range .Owned}}
<div class="col-12">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-center flex-wrap gap-2 mb-2">
<h3 class="card-title mb-0 me-1">{{.Name}}</h3>
{{template "repstatus" .}}
{{if index $.Reconsent .ID}}<a class="badge bg-warning-lt" href="/repeaters/{{.ID}}/share">re-consent needed</a>{{end}}
</div>
<code class="pk">{{.PublicKeyHex}}</code>
<div class="text-secondary small mt-2">{{.RadioFreqHz}} Hz · BW {{.RadioBwHz}} · SF{{.RadioSF}} · CR{{.RadioCR}}</div>
{{if and .Confirmed .AccessKnown (not .IsAdmin)}}
<div class="alert alert-warning mt-3 mb-0">
MeshTender only has <strong>guest</strong> access here. Guest is available to anyone with a blank password, so there's no point operating at this level — re-run <code>setperm &lt;your key&gt; 3</code> on the repeater to grant admin, then re-test.
</div>
{{end}}
<div class="btn-list mt-3">
<a class="btn btn-primary" href="/repeaters/{{.ID}}/console">{{template "icon-terminal" "me-1"}}Console</a>
<div class="dropdown">
<button class="btn dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">Manage</button>
<div class="dropdown-menu dropdown-menu-end">
<a class="dropdown-item" href="/repeaters/{{.ID}}/edit">{{template "icon-pencil" "dropdown-item-icon"}}Edit details</a>
<a class="dropdown-item" href="/repeaters/{{.ID}}/confirm">{{template "icon-antenna" "dropdown-item-icon"}}Confirm access</a>
<a class="dropdown-item" href="/repeaters/{{.ID}}/log">{{template "icon-list" "dropdown-item-icon"}}Logs</a>
<a class="dropdown-item" href="/repeaters/{{.ID}}/share">{{template "icon-share" "dropdown-item-icon"}}Sharing</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item text-danger" href="/repeaters/{{.ID}}/delete">{{template "icon-trash" "dropdown-item-icon"}}Delete repeater</a>
</div>
</div>
</details>
</div>
</div>
</li>
{{end}}
</ul>
{{else}}
<p class="muted">No repeaters yet. Add one to grant MeshTender access and test it with a modem.</p>
</div>
</div>
{{end}}
</section>
</div>
{{else}}
<div class="empty">
<div class="empty-icon">{{template "icon-antenna" ""}}</div>
<p class="empty-title">No repeaters yet</p>
<p class="empty-subtitle text-secondary">Add one to grant MeshTender access and test it with a modem.</p>
<div class="empty-action">
<a href="/repeaters/add" class="btn btn-primary">{{template "icon-plus" "me-1"}}Add repeater</a>
</div>
</div>
{{end}}
{{if .Shared}}
<section class="card">
<h2>Shared with you</h2>
<ul class="repeaters">
{{range .Shared}}
<li>
<div class="rhead">
<span class="rname">{{.Name}}</span>
{{template "repstatus" .}}
<span class="badge shared">via {{.OwnerName}}</span>
<h3 class="mt-4 mb-3">Shared with you</h3>
<div class="row row-cards">
{{range .Shared}}
<div class="col-12">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-center flex-wrap gap-2 mb-2">
<h3 class="card-title mb-0 me-1">{{.Name}}</h3>
{{template "repstatus" .}}
<span class="badge bg-azure-lt">via {{.OwnerName}}</span>
</div>
<code class="pk">{{.PublicKeyHex}}</code>
<div class="text-secondary small mt-2">{{.RadioFreqHz}} Hz · BW {{.RadioBwHz}} · SF{{.RadioSF}} · CR{{.RadioCR}}</div>
<div class="btn-list mt-3">
<a class="btn btn-primary" href="/repeaters/{{.ID}}/console">{{template "icon-terminal" "me-1"}}Console</a>
<a class="btn" href="/repeaters/{{.ID}}/confirm">Confirm / test</a>
</div>
</div>
<code class="pk">{{.PublicKeyHex}}</code>
<div class="rmeta muted">{{.RadioFreqHz}} Hz · BW {{.RadioBwHz}} · SF{{.RadioSF}} · CR{{.RadioCR}}</div>
<div class="ractions">
<a class="btn primary sm" href="/repeaters/{{.ID}}/console">▸ Console</a>
<a class="btnlink" href="/repeaters/{{.ID}}/confirm">Confirm / test</a>
</div>
</li>
{{end}}
</ul>
</section>
</div>
</div>
{{end}}
</div>
{{end}}
{{end}}
+37 -25
View File
@@ -1,27 +1,39 @@
{{define "title"}}Delete {{.Repeater.Name}} · MeshTender{{end}}
{{define "content"}}
<section class="card">
<h1>Delete “{{.Repeater.Name}}”?</h1>
<p class="muted">
This removes the repeater from MeshTender — its shares, org contributions, and command history here
all go away. This can't be undone.
</p>
<div class="danger-note">
<h3>⚠ This does not revoke MeshTender's access</h3>
<p class="muted" style="margin:0">
Your repeater still trusts MeshTender's identity at the firmware level. If you want MeshTender to
truly lose access, revoke it on the device too — see below.
</p>
</div>
{{template "revokedoc" .RevokeCommand}}
<form method="post" action="/repeaters/{{.Repeater.ID}}/delete">
<div class="ractions">
<button type="submit" class="btn danger">Delete repeater</button>
<a class="btn ghost" href="/">Cancel</a>
</div>
</form>
</section>
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col"><h2 class="page-title">Delete “{{.Repeater.Name}}”?</h2></div>
</div>
{{end}}
{{define "content"}}
<div class="row justify-content-center">
<div class="col-12 col-lg-8">
<div class="card">
<div class="card-body">
<p class="text-secondary">
This removes the repeater from MeshTender — its shares, org contributions, and command history here
all go away. This can't be undone.
</p>
<div class="alert alert-danger" role="alert">
<div class="d-flex">
<div>{{template "icon-alert" "alert-icon"}}</div>
<div>
<h4 class="alert-title">This does not revoke MeshTender's access</h4>
<div class="text-secondary">Your repeater still trusts MeshTender's identity at the firmware level. If you want MeshTender to truly lose access, revoke it on the device too — see below.</div>
</div>
</div>
</div>
{{template "revokedoc" .RevokeCommand}}
<form method="post" action="/repeaters/{{.Repeater.ID}}/delete" class="mt-3">
<div class="btn-list">
<button type="submit" class="btn btn-danger">{{template "icon-trash" "me-1"}}Delete repeater</button>
<a class="btn" href="/">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{{end}}
+50 -39
View File
@@ -1,48 +1,59 @@
{{define "title"}}Edit {{.Repeater.Name}} · MeshTender{{end}}
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Edit repeater</div>
<h2 class="page-title">{{.Repeater.Name}}</h2>
</div>
</div>
{{end}}
{{define "content"}}
{{if .Error}}<p class="error banner">{{.Error}}</p>{{end}}
<section class="card">
<h1>Edit “{{.Repeater.Name}}”</h1>
<p class="muted">The public key identifies the repeater and can't be changed — delete and re-add to change it.</p>
<code class="pk">{{.Repeater.PublicKeyHex}}</code>
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
<div class="card">
<div class="card-body">
<p class="text-secondary">The public key identifies the repeater and can't be changed — delete and re-add to change it.</p>
<code class="pk">{{.Repeater.PublicKeyHex}}</code>
<form method="post" action="/repeaters/{{.Repeater.ID}}/edit">
<label>Name
<input type="text" name="name" value="{{.Repeater.Name}}" required>
</label>
<label>Region preset
<select id="region" onchange="applyRegion()">
{{range .Presets}}
<option value="{{.ID}}" data-freq="{{.FreqHz}}" data-bw="{{.BwHz}}" data-sf="{{.SF}}" data-cr="{{.CR}}"{{if eq .ID $.SelectedPreset}} selected{{end}}>{{.Label}}</option>
{{end}}
<option value="custom"{{if eq "custom" $.SelectedPreset}} selected{{end}}>Custom…</option>
</select>
</label>
<div class="radiorow">
<label>Freq (Hz)<input type="number" id="radio_freq_hz" name="radio_freq_hz" value="{{.Repeater.RadioFreqHz}}" required></label>
<label>Bandwidth (Hz)<input type="number" id="radio_bw_hz" name="radio_bw_hz" value="{{.Repeater.RadioBwHz}}" required></label>
<label>SF<input type="number" id="radio_sf" name="radio_sf" value="{{.Repeater.RadioSF}}" min="5" max="12" required></label>
<label>CR<input type="number" id="radio_cr" name="radio_cr" value="{{.Repeater.RadioCR}}" min="5" max="8" required></label>
</div>
<form method="post" action="/repeaters/{{.Repeater.ID}}/edit" class="mt-3">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control" name="name" value="{{.Repeater.Name}}" required>
</div>
<div class="mb-3">
<label class="form-label">Region preset</label>
<select class="form-select" id="region" onchange="applyRegion()">
{{range .Presets}}
<option value="{{.ID}}" data-freq="{{.FreqHz}}" data-bw="{{.BwHz}}" data-sf="{{.SF}}" data-cr="{{.CR}}"{{if eq .ID $.SelectedPreset}} selected{{end}}>{{.Label}}</option>
{{end}}
<option value="custom"{{if eq "custom" $.SelectedPreset}} selected{{end}}>Custom…</option>
</select>
</div>
<div class="row g-2">
<div class="col-6 col-md-3"><label class="form-label">Freq (Hz)</label><input type="number" class="form-control" id="radio_freq_hz" name="radio_freq_hz" value="{{.Repeater.RadioFreqHz}}" required></div>
<div class="col-6 col-md-3"><label class="form-label">Bandwidth (Hz)</label><input type="number" class="form-control" id="radio_bw_hz" name="radio_bw_hz" value="{{.Repeater.RadioBwHz}}" required></div>
<div class="col-6 col-md-3"><label class="form-label">SF</label><input type="number" class="form-control" id="radio_sf" name="radio_sf" value="{{.Repeater.RadioSF}}" min="5" max="12" required></div>
<div class="col-6 col-md-3"><label class="form-label">CR</label><input type="number" class="form-control" id="radio_cr" name="radio_cr" value="{{.Repeater.RadioCR}}" min="5" max="8" required></div>
</div>
<label class="checkrow">
<input type="checkbox" name="store_location" id="store_location" value="1"{{if .Repeater.StoreLocation}} checked{{end}} onchange="syncPublicMap()">
Store this repeater's location (lat/lon), fetched during the modem test, so it can appear on
organization maps. Unchecking this also clears any stored coordinates.
</label>
<label class="checkrow" id="public_map_row"{{if not .Repeater.StoreLocation}} style="display:none"{{end}}>
<input type="checkbox" name="public_map" value="1"{{if .Repeater.PublicMap}} checked{{end}}>
Also show this repeater on the <strong>public</strong> organization map (visible to anyone, signed in or not).
</label>
<label class="form-check mt-3">
<input class="form-check-input" type="checkbox" name="store_location" id="store_location" value="1"{{if .Repeater.StoreLocation}} checked{{end}} onchange="syncPublicMap()">
<span class="form-check-label">Store this repeater's location (lat/lon), fetched during the modem test, so it can appear on
organization maps. Unchecking this also clears any stored coordinates.</span>
</label>
<label class="form-check" id="public_map_row"{{if not .Repeater.StoreLocation}} style="display:none"{{end}}>
<input class="form-check-input" type="checkbox" name="public_map" value="1"{{if .Repeater.PublicMap}} checked{{end}}>
<span class="form-check-label">Also show this repeater on the <strong>public</strong> organization map (visible to anyone, signed in or not).</span>
</label>
<div class="ractions">
<button type="submit" class="primary">Save changes</button>
<a class="btn ghost" href="/">Cancel</a>
</div>
</form>
<div class="btn-list mt-3">
<button type="submit" class="btn btn-primary">Save changes</button>
<a class="btn" href="/">Cancel</a>
</div>
</form>
{{template "revokedoc" .RevokeCommand}}
</section>
{{template "revokedoc" .RevokeCommand}}
</div>
</div>
<script>
function applyRegion() {
var sel = document.getElementById("region");
+22
View File
@@ -0,0 +1,22 @@
{{/* Tabler inline SVG icons. Render with {{template "icon-NAME" .}}; the dot is
an optional extra class string appended to the default "icon" class. */}}
{{define "icon-tower"}}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon{{if .}} {{.}}{{end}}"><path d="M12 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/><path d="M16.616 13.924a5 5 0 1 0 -9.23 0"/><path d="M20.307 15.469a9 9 0 1 0 -16.615 0"/><path d="M9 21l3 -9l3 9"/><path d="M10 19h4"/></svg>{{end}}
{{define "icon-home"}}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon{{if .}} {{.}}{{end}}"><path d="M5 12l-2 0l9 -9l9 9l-2 0"/><path d="M5 12v7a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-7"/><path d="M9 21v-6a2 2 0 0 1 2 -2h2a2 2 0 0 1 2 2v6"/></svg>{{end}}
{{define "icon-plus"}}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon{{if .}} {{.}}{{end}}"><path d="M12 5l0 14"/><path d="M5 12l14 0"/></svg>{{end}}
{{define "icon-terminal"}}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon{{if .}} {{.}}{{end}}"><path d="M8 9l3 3l-3 3"/><path d="M13 15l3 0"/><path d="M3 4m0 2a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2z"/></svg>{{end}}
{{define "icon-pencil"}}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon{{if .}} {{.}}{{end}}"><path d="M4 20h4l10.5 -10.5a2.828 2.828 0 1 0 -4 -4l-10.5 10.5v4"/><path d="M13.5 6.5l4 4"/></svg>{{end}}
{{define "icon-antenna"}}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon{{if .}} {{.}}{{end}}"><path d="M6 18l0 -3"/><path d="M10 18l0 -6"/><path d="M14 18l0 -9"/><path d="M18 18l0 -12"/></svg>{{end}}
{{define "icon-list"}}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon{{if .}} {{.}}{{end}}"><path d="M13 5h8"/><path d="M13 9h5"/><path d="M13 15h8"/><path d="M13 19h5"/><path d="M3 4m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z"/><path d="M3 14m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z"/></svg>{{end}}
{{define "icon-share"}}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon{{if .}} {{.}}{{end}}"><path d="M6 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"/><path d="M18 6m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"/><path d="M18 18m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"/><path d="M8.7 10.7l6.6 -3.4"/><path d="M8.7 13.3l6.6 3.4"/></svg>{{end}}
{{define "icon-trash"}}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon{{if .}} {{.}}{{end}}"><path d="M4 7l16 0"/><path d="M10 11l0 6"/><path d="M14 11l0 6"/><path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12"/><path d="M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3"/></svg>{{end}}
{{define "icon-settings"}}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon{{if .}} {{.}}{{end}}"><path d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z"/><path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"/></svg>{{end}}
{{define "icon-world"}}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon{{if .}} {{.}}{{end}}"><path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0"/><path d="M3.6 9h16.8"/><path d="M3.6 15h16.8"/><path d="M11.5 3a17 17 0 0 0 0 18"/><path d="M12.5 3a17 17 0 0 1 0 18"/></svg>{{end}}
{{define "icon-users"}}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon{{if .}} {{.}}{{end}}"><path d="M9 7m-4 0a4 4 0 1 0 8 0a4 4 0 1 0 -8 0"/><path d="M3 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/><path d="M21 21v-2a4 4 0 0 0 -3 -3.85"/></svg>{{end}}
{{define "icon-user"}}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon{{if .}} {{.}}{{end}}"><path d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0"/><path d="M6 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2"/></svg>{{end}}
{{define "icon-logout"}}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon{{if .}} {{.}}{{end}}"><path d="M14 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2"/><path d="M9 12h12l-3 -3"/><path d="M18 15l3 -3"/></svg>{{end}}
{{define "icon-login"}}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon{{if .}} {{.}}{{end}}"><path d="M15 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2"/><path d="M21 12h-13l3 -3"/><path d="M11 15l-3 -3"/></svg>{{end}}
{{define "icon-plug"}}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon{{if .}} {{.}}{{end}}"><path d="M7 12l5 5l-1.5 1.5a3.536 3.536 0 1 1 -5 -5l1.5 -1.5z"/><path d="M17 12l-5 -5l1.5 -1.5a3.536 3.536 0 1 1 5 5l-1.5 1.5z"/><path d="M3 21l2.5 -2.5"/><path d="M18.5 5.5l2.5 -2.5"/><path d="M10 11l-2 2"/><path d="M13 14l-2 2"/></svg>{{end}}
{{define "icon-arrow-left"}}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon{{if .}} {{.}}{{end}}"><path d="M5 12l14 0"/><path d="M5 12l6 6"/><path d="M5 12l6 -6"/></svg>{{end}}
{{define "icon-check"}}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon{{if .}} {{.}}{{end}}"><path d="M5 12l5 5l10 -10"/></svg>{{end}}
{{define "icon-alert"}}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon{{if .}} {{.}}{{end}}"><path d="M12 9v4"/><path d="M12 17h.01"/><path d="M10.24 3.957l-8.422 14.06a1.989 1.989 0 0 0 1.7 2.983h16.845a1.989 1.989 0 0 0 1.7 -2.983l-8.423 -14.06a1.989 1.989 0 0 0 -3.4 0z"/></svg>{{end}}
{{define "icon-copy"}}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon{{if .}} {{.}}{{end}}"><path d="M8 8m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z"/><path d="M16 8v-2a2 2 0 0 0 -2 -2h-8a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h2"/></svg>{{end}}
+55 -42
View File
@@ -1,44 +1,57 @@
{{define "title"}}Repeater invite · MeshTender{{end}}
{{define "content"}}
<section class="card auth">
{{if eq .State "invalid"}}
<h1>Invalid link</h1>
<p class="muted">This share link is no longer valid — it's single-use and may have already been used or revoked. Ask the owner for a new one.</p>
<p><a class="backlink" href="/">← Go to dashboard</a></p>
{{else if eq .State "auth_required"}}
<h1>You've been invited</h1>
<p>You've been invited to control <strong>{{.Repeater.Name}}</strong>, shared by {{.Repeater.OwnerName}}.</p>
<p class="muted">Sign in or create an account to accept.</p>
<div class="ractions">
<a class="btn primary" href="/login?next={{.Next}}">Sign in</a>
<a class="btn" href="/signup?next={{.Next}}">Create account</a>
</div>
{{else if eq .State "owner"}}
<h1>This is your repeater</h1>
<p>You own <strong>{{.Repeater.Name}}</strong> — no need to accept your own link.</p>
<div class="ractions">
<a class="btn primary" href="/repeaters/{{.Repeater.ID}}/share">Manage sharing</a>
<a class="btn" href="/">Dashboard</a>
</div>
{{else if eq .State "already"}}
<h1>Already shared with you</h1>
<p>You already have access to <strong>{{.Repeater.Name}}</strong>.</p>
<p><a class="backlink" href="/">← Go to dashboard</a></p>
{{else}}{{/* confirm */}}
<h1>Accept invite</h1>
<p>Accept access to <strong>{{.Repeater.Name}}</strong>, shared by {{.Repeater.OwnerName}}?</p>
<p class="muted">You're signed in as <strong>{{.UserName}}</strong>.</p>
<form method="post" action="/invite/{{.Token}}/accept">
<button type="submit" class="primary">Accept as {{.UserName}}</button>
</form>
<form method="post" action="/logout" class="inline">
<input type="hidden" name="next" value="/invite/{{.Token}}">
<button type="submit" class="link">Switch account</button>
</form>
{{end}}
</section>
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col"><h2 class="page-title">Repeater invite</h2></div>
</div>
{{end}}
{{define "content"}}
<div class="row justify-content-center">
<div class="col-12 col-md-7 col-lg-5">
<div class="card">
<div class="card-body">
{{if eq .State "invalid"}}
<h2 class="card-title">Invalid link</h2>
<p class="text-secondary">This share link is no longer valid — it's single-use and may have already been used or revoked. Ask the owner for a new one.</p>
<a class="btn btn-link px-0" href="/">{{template "icon-arrow-left" "me-1"}}Go to dashboard</a>
{{else if eq .State "auth_required"}}
<h2 class="card-title">You've been invited</h2>
<p>You've been invited to control <strong>{{.Repeater.Name}}</strong>, shared by {{.Repeater.OwnerName}}.</p>
<p class="text-secondary">Sign in or create an account to accept.</p>
<div class="btn-list">
<a class="btn btn-primary" href="/login?next={{.Next}}">Sign in</a>
<a class="btn" href="/signup?next={{.Next}}">Create account</a>
</div>
{{else if eq .State "owner"}}
<h2 class="card-title">This is your repeater</h2>
<p>You own <strong>{{.Repeater.Name}}</strong> — no need to accept your own link.</p>
<div class="btn-list">
<a class="btn btn-primary" href="/repeaters/{{.Repeater.ID}}/share">Manage sharing</a>
<a class="btn" href="/">Dashboard</a>
</div>
{{else if eq .State "already"}}
<h2 class="card-title">Already shared with you</h2>
<p>You already have access to <strong>{{.Repeater.Name}}</strong>.</p>
<a class="btn btn-link px-0" href="/">{{template "icon-arrow-left" "me-1"}}Go to dashboard</a>
{{else}}{{/* confirm */}}
<h2 class="card-title">Accept invite</h2>
<p>Accept access to <strong>{{.Repeater.Name}}</strong>, shared by {{.Repeater.OwnerName}}?</p>
<p class="text-secondary">You're signed in as <strong>{{.UserName}}</strong>.</p>
<div class="btn-list">
<form method="post" action="/invite/{{.Token}}/accept" class="m-0">
<button type="submit" class="btn btn-primary">Accept as {{.UserName}}</button>
</form>
<form method="post" action="/logout" class="m-0">
<input type="hidden" name="next" value="/invite/{{.Token}}">
<button type="submit" class="btn btn-link">Switch account</button>
</form>
</div>
{{end}}
</div>
</div>
</div>
</div>
{{end}}
+41 -26
View File
@@ -1,32 +1,47 @@
{{define "title"}}Sign in · MeshTender{{end}}
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col"><h2 class="page-title">Sign in</h2></div>
</div>
{{end}}
{{define "content"}}
<section class="card auth">
<h1>Sign in</h1>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<div class="row justify-content-center">
<div class="col-12 col-md-6 col-lg-5">
<div class="card">
<div class="card-body">
<h2 class="card-title text-center mb-4">Sign in to MeshTender</h2>
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
<form id="passkey-form" onsubmit="return false;">
<label>Username
<input type="text" id="username" name="username" autocomplete="username webauthn" autocapitalize="none" required>
</label>
<button type="button" class="primary" onclick="passkeyLogin()">Sign in with passkey</button>
</form>
<p id="passkey-status" class="status"></p>
<form id="passkey-form" onsubmit="return false;">
<div class="mb-3">
<label class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username" autocomplete="username webauthn" autocapitalize="none" required>
</div>
<div class="form-footer">
<button type="button" class="btn btn-primary w-100" onclick="passkeyLogin()">Sign in with passkey</button>
</div>
</form>
<p id="passkey-status" class="text-secondary text-center mt-2 mb-0" style="min-height:1.2em"></p>
<details class="fallback">
<summary>Use a password instead</summary>
<form method="post" action="/login/password">
{{if .Next}}<input type="hidden" name="next" value="{{.Next}}">{{end}}
<label>Username
<input type="text" name="username" autocomplete="username" autocapitalize="none" required>
</label>
<label>Password
<input type="password" name="password" autocomplete="current-password" required>
</label>
<button type="submit">Sign in with password</button>
</form>
</details>
<p class="muted">No account? <a href="/signup{{if .Next}}?next={{.Next}}{{end}}">Create one</a></p>
</section>
<details class="mt-3">
<summary class="text-secondary" style="cursor:pointer">Use a password instead</summary>
<form method="post" action="/login/password" class="mt-2">
{{if .Next}}<input type="hidden" name="next" value="{{.Next}}">{{end}}
<div class="mb-3">
<label class="form-label">Username</label>
<input type="text" class="form-control" name="username" autocomplete="username" autocapitalize="none" required>
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<input type="password" class="form-control" name="password" autocomplete="current-password" required>
</div>
<button type="submit" class="btn w-100">Sign in with password</button>
</form>
</details>
</div>
</div>
<p class="text-secondary text-center mt-3">No account? <a href="/signup{{if .Next}}?next={{.Next}}{{end}}">Create one</a></p>
</div>
</div>
<script src="/static/webauthn.js"></script>
{{end}}
+34
View File
@@ -0,0 +1,34 @@
{{define "title"}}Create an organization · MeshTender{{end}}
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Organizations</div>
<h2 class="page-title">Create an organization</h2>
</div>
</div>
{{end}}
{{define "content"}}
<div class="row justify-content-center">
<div class="col-12 col-lg-8">
<div class="card">
<div class="card-body">
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
<p class="text-secondary">
An organization lets its admins help keep members' repeaters in spec. You'll be its first
admin, and you can invite others and set what commands they may run once it's created.
</p>
<form method="post" action="/orgs">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control" name="name" maxlength="80" placeholder="e.g. Bay Area Mesh" required autofocus>
</div>
<div class="btn-list">
<button type="submit" class="btn btn-primary">Create organization</button>
<a class="btn" href="/orgs">Cancel</a>
</div>
</form>
</div>
</div>
</div>
</div>
{{end}}
+126 -99
View File
@@ -1,116 +1,143 @@
{{define "title"}}{{.Org.Name}} · MeshTender{{end}}
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Organization</div>
<h2 class="page-title">{{.Org.Name}}</h2>
</div>
</div>
{{end}}
{{define "content"}}
{{if .Error}}<p class="error banner">{{.Error}}</p>{{end}}
<section class="card">
<h1>{{.Org.Name}}</h1>
<p class="muted">You are {{if .IsAdmin}}an <strong>admin</strong>{{else}}a <strong>member</strong>{{end}} of this organization.</p>
{{if .Org.Description}}<p>{{.Org.Description}}</p>{{end}}
<div class="ractions">
{{if .IsAdmin}}<a class="btnlink" href="/orgs/{{.Org.ID}}/permissions">⚙ Edit permissions</a>{{end}}
<a class="btnlink" href="/orgs/{{.Org.ID}}?view=public">🌐 View public page</a>
<form method="post" action="/orgs/{{.Org.ID}}/leave" class="inline" onsubmit="return confirm('Leave this organization?')">
<button type="submit" class="btn danger sm">Leave organization</button>
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
<div class="card">
<div class="card-body">
<p class="text-secondary">You are {{if .IsAdmin}}an <strong>admin</strong>{{else}}a <strong>member</strong>{{end}} of this organization.</p>
{{if .Org.Description}}<p>{{.Org.Description}}</p>{{end}}
<div class="btn-list">
{{if .IsAdmin}}<a class="btn" href="/orgs/{{.Org.ID}}/permissions">{{template "icon-settings" "me-1"}}Edit permissions</a>{{end}}
<a class="btn" href="/orgs/{{.Org.ID}}?view=public">{{template "icon-world" "me-1"}}View public page</a>
<form method="post" action="/orgs/{{.Org.ID}}/leave" class="m-0" onsubmit="return confirm('Leave this organization?')">
<button type="submit" class="btn btn-ghost-danger">Leave organization</button>
</form>
</div>
</div>
</div>
{{if .IsAdmin}}
<div class="card mt-3">
<div class="card-body">
<h3 class="card-title">Organization profile <span class="text-secondary">(public)</span></h3>
<p class="text-secondary">Name and description are shown on the public organization page and directory.</p>
<form method="post" action="/orgs/{{.Org.ID}}/edit">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control" name="name" value="{{.Org.Name}}" maxlength="80" required>
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<input type="text" class="form-control" name="description" value="{{.Org.Description}}" maxlength="2000" placeholder="What this organization is about, where it operates, etc.">
</div>
<button type="submit" class="btn btn-primary">Save profile</button>
</form>
</div>
</section>
{{if .IsAdmin}}
<section class="card">
<h2 style="margin-top:0">Organization profile <span class="muted">(public)</span></h2>
<p class="muted">Name and description are shown on the public organization page and directory.</p>
<form method="post" action="/orgs/{{.Org.ID}}/edit">
<label>Name<input type="text" name="name" value="{{.Org.Name}}" maxlength="80" required></label>
<label>Description<input type="text" name="description" value="{{.Org.Description}}" maxlength="2000" placeholder="What this organization is about, where it operates, etc."></label>
<button type="submit" class="primary">Save profile</button>
</form>
</section>
</div>
{{end}}
<section class="card">
<h2>Members</h2>
<ul class="repeaters">
{{range .Members}}
<li class="rhead">
<span class="rname">{{.Name}}</span>
<span class="muted">@{{.Username}}</span>
{{if eq .Role "admin"}}<span class="badge ok">admin</span>{{else}}<span class="badge shared">member</span>{{end}}
{{if and $.IsAdmin (ne .UserID $.Self)}}
<form method="post" action="/orgs/{{$.Org.ID}}/members/{{.UserID}}" class="inline" style="margin-left:auto;display:flex;gap:0.6rem">
{{if eq .Role "admin"}}
<button type="submit" name="action" value="demote" class="link">Demote</button>
{{else}}
<button type="submit" name="action" value="promote" class="link">Make admin</button>
<div class="card mt-3">
<div class="card-body">
<h3 class="card-title">Members</h3>
<div class="list-group list-group-flush">
{{range .Members}}
<div class="list-group-item d-flex align-items-center flex-wrap gap-2 px-0">
<span class="fw-bold">{{.Name}}</span>
<span class="text-secondary">@{{.Username}}</span>
{{if eq .Role "admin"}}<span class="badge bg-success-lt">admin</span>{{else}}<span class="badge bg-azure-lt">member</span>{{end}}
{{if and $.IsAdmin (ne .UserID $.Self)}}
<form method="post" action="/orgs/{{$.Org.ID}}/members/{{.UserID}}" class="m-0 ms-auto d-flex gap-2">
{{if eq .Role "admin"}}
<button type="submit" name="action" value="demote" class="btn btn-sm">Demote</button>
{{else}}
<button type="submit" name="action" value="promote" class="btn btn-sm">Make admin</button>
{{end}}
<button type="submit" name="action" value="remove" class="btn btn-sm btn-ghost-danger">Remove</button>
</form>
{{end}}
<button type="submit" name="action" value="remove" class="link danger">Remove</button>
</form>
</div>
{{end}}
</li>
{{end}}
</ul>
</section>
</div>
</div>
</div>
<section class="card">
<h2>Repeaters</h2>
{{if .HasMap}}
<link rel="stylesheet" href="/static/leaflet.css">
<div id="map"></div>
<script src="/static/leaflet.js"></script>
<script>
var pts = [
{{range .Repeaters}}{{if .HasLocation}}{name: {{.Name}}, lat: {{.Lat}}, lon: {{.Lon}}},{{end}}{{end}}
];
var map = L.map('map');
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19, attribution: '&copy; OpenStreetMap'
}).addTo(map);
var group = L.featureGroup(pts.map(function (p) {
return L.circleMarker([p.lat, p.lon], { radius: 7, color: '#3fb950', fillOpacity: 0.7 }).bindPopup(p.name);
})).addTo(map);
map.fitBounds(group.getBounds().pad(0.2));
</script>
{{end}}
{{if .Repeaters}}
<ul class="repeaters">
{{range .Repeaters}}
<li class="rhead">
<span class="rname">{{.Name}}</span>
<span class="muted">owner {{.OwnerName}}</span>
<a class="btn primary sm" style="margin-left:auto" href="/repeaters/{{.RepeaterID}}/console">▸ Console</a>
</li>
<div class="card mt-3">
<div class="card-body">
<h3 class="card-title">Repeaters</h3>
{{if .HasMap}}
<link rel="stylesheet" href="/static/leaflet.css">
<div id="map" class="mb-3"></div>
<script src="/static/leaflet.js"></script>
<script>
var pts = [
{{range .Repeaters}}{{if .HasLocation}}{name: {{.Name}}, lat: {{.Lat}}, lon: {{.Lon}}},{{end}}{{end}}
];
var map = L.map('map');
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19, attribution: '&copy; OpenStreetMap'
}).addTo(map);
var group = L.featureGroup(pts.map(function (p) {
return L.circleMarker([p.lat, p.lon], { radius: 7, color: '#066fd1', fillOpacity: 0.7 }).bindPopup(p.name);
})).addTo(map);
map.fitBounds(group.getBounds().pad(0.2));
</script>
{{end}}
</ul>
{{else}}
<p class="muted">No repeaters have been contributed yet. Members can contribute their own from a repeater's “Organizations” page.</p>
{{end}}
</section>
{{if .Repeaters}}
<div class="list-group list-group-flush">
{{range .Repeaters}}
<div class="list-group-item d-flex align-items-center gap-2 px-0">
<span class="fw-bold">{{.Name}}</span>
<span class="text-secondary">owner {{.OwnerName}}</span>
<a class="btn btn-primary btn-sm ms-auto" href="/repeaters/{{.RepeaterID}}/console">{{template "icon-terminal" "me-1"}}Console</a>
</div>
{{end}}
</div>
{{else}}
<p class="text-secondary mb-0">No repeaters have been contributed yet. Members can contribute their own from a repeater's “Organizations” page.</p>
{{end}}
</div>
</div>
{{if .IsAdmin}}
<section class="card">
<h2>Join links</h2>
<p class="muted">Anyone with a link joins as a member. Promote them to admin above if needed.</p>
{{if .Invites}}
<ul class="repeaters">
{{range .Invites}}
<li>
<div class="rhead"><span class="rname">{{if .Description}}{{.Description}}{{else}}(no label){{end}}</span></div>
<div class="copyrow">
<code id="ol-{{.ID}}">{{.Link}}</code>
<button type="button" onclick="navigator.clipboard.writeText(document.getElementById('ol-{{.ID}}').textContent)">Copy</button>
<div class="card mt-3">
<div class="card-body">
<h3 class="card-title">Join links</h3>
<p class="text-secondary">Anyone with a link joins as a member. Promote them to admin above if needed.</p>
{{if .Invites}}
<div class="list-group list-group-flush mb-3">
{{range .Invites}}
<div class="list-group-item px-0">
<div class="fw-bold mb-1">{{if .Description}}{{.Description}}{{else}}(no label){{end}}</div>
<div class="input-group mb-2">
<code class="form-control font-monospace" id="ol-{{.ID}}">{{.Link}}</code>
<button type="button" class="btn" onclick="navigator.clipboard.writeText(document.getElementById('ol-{{.ID}}').textContent)">{{template "icon-copy" ""}}Copy</button>
</div>
<form method="post" action="/orgs/{{$.Org.ID}}/invite/delete" class="m-0">
<input type="hidden" name="invite_id" value="{{.ID}}">
<button type="submit" class="btn btn-sm btn-ghost-danger">Revoke</button>
</form>
</div>
<form method="post" action="/orgs/{{$.Org.ID}}/invite/delete" class="inline">
<input type="hidden" name="invite_id" value="{{.ID}}">
<button type="submit" class="link danger">Revoke</button>
</form>
</li>
{{end}}
</div>
{{end}}
</ul>
{{end}}
<form method="post" action="/orgs/{{.Org.ID}}/invite">
<label>New link description (optional)<input type="text" name="description" maxlength="100" placeholder="e.g. forum post"></label>
<button type="submit" class="primary">Create join link</button>
</form>
</section>
<form method="post" action="/orgs/{{.Org.ID}}/invite">
<div class="mb-3">
<label class="form-label">New link description <span class="text-secondary">(optional)</span></label>
<input type="text" class="form-control" name="description" maxlength="100" placeholder="e.g. forum post">
</div>
<button type="submit" class="btn btn-primary">Create join link</button>
</form>
</div>
</div>
{{end}}
<p><a class="backlink" href="/orgs">All organizations</a></p>
<a class="btn btn-link px-0 mt-3" href="/orgs">{{template "icon-arrow-left" "me-1"}}All organizations</a>
{{end}}
+40 -29
View File
@@ -1,31 +1,42 @@
{{define "title"}}Join organization · MeshTender{{end}}
{{define "content"}}
<section class="card auth">
{{if eq .State "invalid"}}
<h1>Invalid link</h1>
<p class="muted">This join link is no longer valid — it may have been revoked.</p>
<p><a class="backlink" href="/orgs">← Organizations</a></p>
{{else if eq .State "auth_required"}}
<h1>Join {{.Org.Name}}</h1>
<p class="muted">Sign in or create an account to join as a member.</p>
<div class="ractions">
<a class="btn primary" href="/login?next={{.Next}}">Sign in</a>
<a class="btn" href="/signup?next={{.Next}}">Create account</a>
</div>
{{else if eq .State "already"}}
<h1>Already a member</h1>
<p>You're already in <strong>{{.Org.Name}}</strong>.</p>
<p><a class="btn primary" href="/orgs/{{.Org.ID}}">Go to {{.Org.Name}}</a></p>
{{else}}{{/* confirm */}}
<h1>Join {{.Org.Name}}</h1>
<p>Join <strong>{{.Org.Name}}</strong> as a member? You can later contribute repeaters to it and
review what the org may do with them.</p>
<form method="post" action="/org-invite/{{.Token}}/accept">
<button type="submit" class="primary">Join</button>
</form>
{{end}}
</section>
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col"><h2 class="page-title">Join organization</h2></div>
</div>
{{end}}
{{define "content"}}
<div class="row justify-content-center">
<div class="col-12 col-md-7 col-lg-5">
<div class="card">
<div class="card-body">
{{if eq .State "invalid"}}
<h2 class="card-title">Invalid link</h2>
<p class="text-secondary">This join link is no longer valid — it may have been revoked.</p>
<a class="btn btn-link px-0" href="/orgs">{{template "icon-arrow-left" "me-1"}}Organizations</a>
{{else if eq .State "auth_required"}}
<h2 class="card-title">Join {{.Org.Name}}</h2>
<p class="text-secondary">Sign in or create an account to join as a member.</p>
<div class="btn-list">
<a class="btn btn-primary" href="/login?next={{.Next}}">Sign in</a>
<a class="btn" href="/signup?next={{.Next}}">Create account</a>
</div>
{{else if eq .State "already"}}
<h2 class="card-title">Already a member</h2>
<p>You're already in <strong>{{.Org.Name}}</strong>.</p>
<a class="btn btn-primary" href="/orgs/{{.Org.ID}}">Go to {{.Org.Name}}</a>
{{else}}{{/* confirm */}}
<h2 class="card-title">Join {{.Org.Name}}</h2>
<p>Join <strong>{{.Org.Name}}</strong> as a member? You can later contribute repeaters to it and
review what the org may do with them.</p>
<form method="post" action="/org-invite/{{.Token}}/accept" class="m-0">
<button type="submit" class="btn btn-primary">Join</button>
</form>
{{end}}
</div>
</div>
</div>
</div>
{{end}}
+42 -32
View File
@@ -1,34 +1,44 @@
{{define "title"}}Permissions · {{.Org.Name}} · MeshTender{{end}}
{{define "content"}}
<section class="card">
<h1>Permissions · {{.Org.Name}}</h1>
<p class="muted">
Current version: <strong>v{{.Version}}</strong>. Choose which commands org <strong>admins</strong>
and <strong>members</strong> may run on contributed repeaters. Saving publishes a new version;
members' repeaters stay on their consented version until owners re-consent to the additions.
Admins effectively also get everything members get. Risky commands can take over or brick a node.
</p>
<form method="post" action="/orgs/{{.Org.ID}}/permissions">
{{range .Groups}}
<fieldset class="cmdgroup">
<legend>{{.Name}}</legend>
{{range .Commands}}
<div class="catrow{{if .Risky}} risky{{end}}">
<code class="catcmd">{{.Template}}{{if .Risky}} <span class="badge warn">risky</span>{{end}}</code>
<label><input type="checkbox" name="admin" value="{{.ID}}" {{if .AdminChecked}}checked{{end}}> admin</label>
<label><input type="checkbox" name="member" value="{{.ID}}" {{if .MemberChecked}}checked{{end}}> member</label>
</div>
{{end}}
</fieldset>
{{end}}
<label>Change note (shown to owners when they re-consent)
<input type="text" name="note" maxlength="200" placeholder="e.g. added set tx for the new region plan">
</label>
<div class="ractions">
<button type="submit" class="primary">Publish new version</button>
<a class="btn ghost" href="/orgs/{{.Org.ID}}">Cancel</a>
</div>
</form>
</section>
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">{{.Org.Name}}</div>
<h2 class="page-title">Permissions</h2>
</div>
</div>
{{end}}
{{define "content"}}
<div class="card">
<div class="card-body">
<p class="text-secondary">
Current version: <strong>v{{.Version}}</strong>. Choose which commands org <strong>admins</strong>
and <strong>members</strong> may run on contributed repeaters. Saving publishes a new version;
members' repeaters stay on their consented version until owners re-consent to the additions.
Admins effectively also get everything members get. Risky commands can take over or brick a node.
</p>
<form method="post" action="/orgs/{{.Org.ID}}/permissions">
{{range .Groups}}
<fieldset class="form-fieldset">
<div class="form-label fw-bold text-capitalize">{{.Name}}</div>
{{range .Commands}}
<div class="d-flex align-items-center flex-wrap gap-3 py-1">
<code class="flex-fill">{{.Template}}{{if .Risky}} <span class="badge bg-warning-lt">risky</span>{{end}}</code>
<label class="form-check form-check-inline m-0"><input class="form-check-input" type="checkbox" name="admin" value="{{.ID}}" {{if .AdminChecked}}checked{{end}}><span class="form-check-label">admin</span></label>
<label class="form-check form-check-inline m-0"><input class="form-check-input" type="checkbox" name="member" value="{{.ID}}" {{if .MemberChecked}}checked{{end}}><span class="form-check-label">member</span></label>
</div>
{{end}}
</fieldset>
{{end}}
<div class="mb-3">
<label class="form-label">Change note <span class="text-secondary">(shown to owners when they re-consent)</span></label>
<input type="text" class="form-control" name="note" maxlength="200" placeholder="e.g. added set tx for the new region plan">
</div>
<div class="btn-list">
<button type="submit" class="btn btn-primary">Publish new version</button>
<a class="btn" href="/orgs/{{.Org.ID}}">Cancel</a>
</div>
</form>
</div>
</div>
{{end}}
+48 -35
View File
@@ -1,43 +1,56 @@
{{define "title"}}{{.Org.Name}} · MeshTender{{end}}
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Organization</div>
<h2 class="page-title">{{.Org.Name}}</h2>
</div>
</div>
{{end}}
{{define "content"}}
<section class="card">
<h1>{{.Org.Name}}</h1>
{{if .IsMember}}<p class="muted">Previewing the public page. <a class="navlink" href="/orgs/{{.Org.ID}}">← Back to member view</a></p>{{end}}
{{if .Org.Description}}<p>{{.Org.Description}}</p>{{else}}<p class="muted">This organization hasn't added a description yet.</p>{{end}}
<div class="rmeta">{{.MemberCount}} member{{if ne .MemberCount 1}}s{{end}} · {{.RepeaterCount}} repeater{{if ne .RepeaterCount 1}}s{{end}}</div>
</section>
<div class="card">
<div class="card-body">
{{if .IsMember}}<p class="text-secondary"><a href="/orgs/{{.Org.ID}}">{{template "icon-arrow-left" "me-1"}}Back to member view</a> — you're previewing the public page.</p>{{end}}
{{if .Org.Description}}<p>{{.Org.Description}}</p>{{else}}<p class="text-secondary">This organization hasn't added a description yet.</p>{{end}}
<div class="text-secondary small">{{.MemberCount}} member{{if ne .MemberCount 1}}s{{end}} · {{.RepeaterCount}} repeater{{if ne .RepeaterCount 1}}s{{end}}</div>
</div>
</div>
<section class="card">
<h2 style="margin-top:0">Admins</h2>
{{if .Admins}}
<ul class="repeaters">
{{range .Admins}}<li class="rhead"><span class="rname">{{.}}</span><span class="badge ok">admin</span></li>{{end}}
</ul>
{{else}}<p class="muted">No admins listed.</p>{{end}}
</section>
<div class="card mt-3">
<div class="card-body">
<h3 class="card-title">Admins</h3>
{{if .Admins}}
<div class="d-flex flex-wrap gap-2">
{{range .Admins}}<span class="badge bg-success-lt">{{.}}</span>{{end}}
</div>
{{else}}<p class="text-secondary mb-0">No admins listed.</p>{{end}}
</div>
</div>
{{if .HasMap}}
<section class="card">
<h2 style="margin-top:0">Public repeater map</h2>
<p class="muted">Repeaters whose owners chose to show them publicly.</p>
<link rel="stylesheet" href="/static/leaflet.css">
<div id="map"></div>
<script src="/static/leaflet.js"></script>
<script>
var pts = [
{{range .Repeaters}}{{if .HasLocation}}{name: {{.Name}}, lat: {{.Lat}}, lon: {{.Lon}}},{{end}}{{end}}
];
var map = L.map('map');
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19, attribution: '&copy; OpenStreetMap'
}).addTo(map);
var group = L.featureGroup(pts.map(function (p) {
return L.circleMarker([p.lat, p.lon], { radius: 7, color: '#3fb950', fillOpacity: 0.7 }).bindPopup(p.name);
})).addTo(map);
map.fitBounds(group.getBounds().pad(0.2));
</script>
</section>
<div class="card mt-3">
<div class="card-body">
<h3 class="card-title">Public repeater map</h3>
<p class="text-secondary">Repeaters whose owners chose to show them publicly.</p>
<link rel="stylesheet" href="/static/leaflet.css">
<div id="map"></div>
<script src="/static/leaflet.js"></script>
<script>
var pts = [
{{range .Repeaters}}{{if .HasLocation}}{name: {{.Name}}, lat: {{.Lat}}, lon: {{.Lon}}},{{end}}{{end}}
];
var map = L.map('map');
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19, attribution: '&copy; OpenStreetMap'
}).addTo(map);
var group = L.featureGroup(pts.map(function (p) {
return L.circleMarker([p.lat, p.lon], { radius: 7, color: '#066fd1', fillOpacity: 0.7 }).bindPopup(p.name);
})).addTo(map);
map.fitBounds(group.getBounds().pad(0.2));
</script>
</div>
</div>
{{end}}
<p><a class="backlink" href="/orgs">All organizations</a></p>
<a class="btn btn-link px-0 mt-3" href="/orgs">{{template "icon-arrow-left" "me-1"}}All organizations</a>
{{end}}
+42 -38
View File
@@ -1,43 +1,47 @@
{{define "title"}}Organizations · MeshTender{{end}}
{{define "content"}}
{{if .Error}}<p class="error banner">{{.Error}}</p>{{end}}
<section class="card">
<h1>Organizations</h1>
<p class="muted">
Organizations tend meshes together with MeshTender. Join one (via an invite link) to let its admins
help keep your repeaters in spec — you stay in control of exactly which commands they can run on each
repeater you contribute.
</p>
{{if .All}}
<ul class="repeaters">
{{range .All}}
<li>
<div class="rhead">
<a class="rname" href="/orgs/{{.ID}}">{{.Name}}</a>
{{if $.LoggedIn}}{{$role := index $.MemberOf .ID}}{{if $role}}{{if eq $role "admin"}}<span class="badge ok">admin</span>{{else}}<span class="badge shared">member</span>{{end}}{{end}}{{end}}
</div>
{{if .Description}}<p class="muted" style="margin:0.2rem 0 0.5rem">{{.Description}}</p>{{end}}
<div class="rmeta">{{.MemberCount}} member{{if ne .MemberCount 1}}s{{end}} · {{.RepeaterCount}} repeater{{if ne .RepeaterCount 1}}s{{end}}</div>
</li>
{{end}}
</ul>
{{else}}
<p class="muted">No organizations yet.</p>
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col"><h2 class="page-title">Organizations</h2></div>
{{if .LoggedIn}}
<div class="col-auto ms-auto d-print-none">
<a class="btn btn-primary" href="/orgs/new">{{template "icon-plus" "me-1"}}Create organization</a>
</div>
{{end}}
</section>
</div>
{{end}}
{{define "content"}}
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
<div class="card">
<div class="card-body">
<p class="text-secondary">
Organizations tend meshes together with MeshTender. Join one (via an invite link) to let its admins
help keep your repeaters in spec — you stay in control of exactly which commands they can run on each
repeater you contribute.
</p>
</div>
{{if .All}}
<div class="list-group list-group-flush">
{{range .All}}
<div class="list-group-item">
<div class="d-flex align-items-center gap-2">
<a class="fw-bold text-reset text-decoration-none" href="/orgs/{{.ID}}">{{.Name}}</a>
{{if $.LoggedIn}}{{$role := index $.MemberOf .ID}}{{if $role}}{{if eq $role "admin"}}<span class="badge bg-success-lt">admin</span>{{else}}<span class="badge bg-azure-lt">member</span>{{end}}{{end}}{{end}}
</div>
{{if .Description}}<p class="text-secondary mb-1 mt-1">{{.Description}}</p>{{end}}
<div class="text-secondary small">{{.MemberCount}} member{{if ne .MemberCount 1}}s{{end}} · {{.RepeaterCount}} repeater{{if ne .RepeaterCount 1}}s{{end}}</div>
</div>
{{end}}
</div>
{{else}}
<div class="card-body pt-0"><p class="text-secondary mb-0">No organizations yet.</p></div>
{{end}}
</div>
{{if .LoggedIn}}
<section class="card">
<h2 style="margin-top:0">Create an organization</h2>
<form method="post" action="/orgs">
<label>Name<input type="text" name="name" maxlength="80" placeholder="e.g. Bay Area Mesh" required></label>
<button type="submit" class="primary">Create</button>
</form>
<p class="muted">You'll be its first admin.</p>
</section>
{{else}}
<section class="card">
<p class="muted" style="margin:0">Want to start or join one? <a class="navlink" href="/login">Sign in or create an account →</a></p>
</section>
{{if not .LoggedIn}}
<div class="card mt-3">
<div class="card-body">
<p class="text-secondary mb-0">Want to start or join one? <a href="/login">Sign in or create an account →</a></p>
</div>
</div>
{{end}}
{{end}}
+69 -58
View File
@@ -1,60 +1,71 @@
{{define "title"}}Repeater added · MeshTender{{end}}
{{define "content"}}
<ol class="steps">
<li class="done">Grant access</li>
<li class="done">Repeater details</li>
<li class="active">Confirm</li>
<li class="active">Share</li>
</ol>
<section class="card">
<h1>✓ “{{.Repeater.Name}}” added</h1>
<p class="muted">
It's registered but <strong>unconfirmed</strong> — MeshTender hasn't reached it yet. The two steps
below are optional; your repeater is already usable by anyone you share it with.
</p>
</section>
<section class="card">
<h2 style="margin-top:0">Step 3 · Confirm it works <span class="muted">(optional)</span></h2>
<p class="muted">
Connect a MeshCore KISS modem and have MeshTender login to verify it has admin access. Confirming
now marks it <span class="badge shared">Self-confirmed</span>. Later, if another member reaches it
from their modem, it becomes <span class="badge ok">✓ Corroborated</span> — proof that more than
one person can reach it.
</p>
<p class="muted">No modem handy? Skip this — anyone you share it with can corroborate it for you later.</p>
<div class="ractions">
<a class="btn primary" href="/repeaters/{{.Repeater.ID}}/confirm">Connect modem &amp; confirm now</a>
<a class="btn ghost" href="/">Skip for now</a>
</div>
</section>
<section class="card">
<h2 style="margin-top:0">Step 4 · Contribute to an organization <span class="muted">(optional)</span></h2>
{{if .Orgs}}
<p class="muted">
Let an organization's admins and members help operate this repeater. You'll be aasked to review and
consent to the list of commands that organization members and admins will be able to run on your
repeater. You can withdraw anytime.
</p>
<ul class="repeaters">
{{range .Orgs}}
<li class="rhead">
<span class="rname">{{.Org.Name}}</span>
{{if eq .Role "admin"}}<span class="badge ok">admin</span>{{else}}<span class="badge shared">member</span>{{end}}
<a class="btn sm" style="margin-left:auto" href="/repeaters/{{$.Repeater.ID}}/orgs/{{.Org.ID}}/contribute">Review &amp; contribute</a>
</li>
{{end}}
</ul>
{{else}}
<p class="muted">
You're not in any organizations yet. Joining one lets its admins help keep your repeater in spec.
<a class="navlink" href="/orgs">Browse organizations →</a>
</p>
{{end}}
<div class="ractions">
<a class="btn primary" href="/">Done — go to dashboard</a>
</div>
</section>
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col"><h2 class="page-title">“{{.Repeater.Name}}” added</h2></div>
</div>
{{end}}
{{define "content"}}
<ul class="steps steps-counter mb-4">
<li class="step-item">Grant access</li>
<li class="step-item">Repeater details</li>
<li class="step-item active">Confirm</li>
<li class="step-item">Share</li>
</ul>
<div class="card">
<div class="card-body">
<h2 class="card-title">{{template "icon-check" "me-1 text-success"}}“{{.Repeater.Name}}” added</h2>
<p class="text-secondary mb-0">
It's registered but <strong>unconfirmed</strong> — MeshTender hasn't reached it yet. The two steps
below are optional; your repeater is already usable by anyone you share it with.
</p>
</div>
</div>
<div class="card mt-3">
<div class="card-body">
<h3 class="card-title">Step 3 · Confirm it works <span class="text-secondary">(optional)</span></h3>
<p class="text-secondary">
Connect a MeshCore KISS modem and have MeshTender login to verify it has admin access. Confirming
now marks it <span class="badge bg-azure-lt">Self-confirmed</span>. Later, if another member reaches it
from their modem, it becomes <span class="badge bg-success-lt">Corroborated</span> — proof that more than
one person can reach it.
</p>
<p class="text-secondary">No modem handy? Skip this — anyone you share it with can corroborate it for you later.</p>
<div class="btn-list">
<a class="btn btn-primary" href="/repeaters/{{.Repeater.ID}}/confirm">Connect modem &amp; confirm now</a>
<a class="btn" href="/">Skip for now</a>
</div>
</div>
</div>
<div class="card mt-3">
<div class="card-body">
<h3 class="card-title">Step 4 · Contribute to an organization <span class="text-secondary">(optional)</span></h3>
{{if .Orgs}}
<p class="text-secondary">
Let an organization's admins and members help operate this repeater. You'll be asked to review and
consent to the list of commands that organization members and admins will be able to run on your
repeater. You can withdraw anytime.
</p>
<div class="list-group list-group-flush">
{{range .Orgs}}
<div class="list-group-item d-flex align-items-center gap-2 px-0">
<span class="fw-bold">{{.Org.Name}}</span>
{{if eq .Role "admin"}}<span class="badge bg-success-lt">admin</span>{{else}}<span class="badge bg-azure-lt">member</span>{{end}}
<a class="btn btn-sm ms-auto" href="/repeaters/{{$.Repeater.ID}}/orgs/{{.Org.ID}}/contribute">Review &amp; contribute</a>
</div>
{{end}}
</div>
{{else}}
<p class="text-secondary">
You're not in any organizations yet. Joining one lets its admins help keep your repeater in spec.
<a href="/orgs">Browse organizations →</a>
</p>
{{end}}
<div class="btn-list mt-3">
<a class="btn btn-primary" href="/">Done — go to dashboard</a>
</div>
</div>
</div>
{{end}}
+131 -117
View File
@@ -1,119 +1,133 @@
{{define "title"}}Sharing · {{.Repeater.Name}} · MeshTender{{end}}
{{define "content"}}
{{if .Error}}<p class="error banner">{{.Error}}</p>{{end}}
<section class="card">
<h1>Sharing · “{{.Repeater.Name}}</h1>
<p class="muted">
Decide who can operate this repeater. Everyone you grant access uses MeshTender's single identity, so
you never hand out keys — and you can revoke or withdraw at any time.
</p>
</section>
<section class="card">
<h2 style="margin-top:0">Organizations</h2>
<p class="muted">
Contribute this repeater to an organization so its admins and members can run their permitted
commands on it over the mesh — but only the specific commands you review and approve first. You can
withdraw anytime.
</p>
<h3>Contributed to</h3>
{{if .Contributed}}
<ul class="repeaters">
{{range .Contributed}}
<li class="rhead">
<span class="rname">{{.OrgName}}</span>
<span class="muted">consented to v{{.ConsentedVersion}}{{if .NeedsReconsent}} · org is now on v{{.CurrentVersion}}{{end}}</span>
{{if .NeedsReconsent}}
<a class="btnlink" href="/repeaters/{{$.Repeater.ID}}/orgs/{{.OrgID}}/contribute">Review changes</a>
{{end}}
<form method="post" action="/repeaters/{{$.Repeater.ID}}/orgs/{{.OrgID}}/withdraw" class="inline" onsubmit="return confirm('Withdraw this repeater from {{.OrgName}}?')">
<button type="submit" class="link danger">Withdraw</button>
</form>
</li>
{{end}}
</ul>
{{else}}
<p class="muted">Not contributed to any organization.</p>
{{end}}
{{if .Available}}
<h3>Contribute to</h3>
<ul class="repeaters">
{{range .Available}}
<li class="rhead">
<span class="rname">{{.Name}}</span>
<a class="btnlink" style="margin-left:auto" href="/repeaters/{{$.Repeater.ID}}/orgs/{{.ID}}/contribute">Review &amp; contribute</a>
</li>
{{end}}
</ul>
{{else if .Contributed}}
{{else}}
<p class="muted">Join an organization to contribute this repeater to it.</p>
{{end}}
</section>
<section class="card">
<h2 style="margin-top:0">People</h2>
<p class="muted">
Share single-use links with specific people. The recipient signs in and accepts to control this
repeater from their own browser-connected KISS modem.
</p>
<h3>Share links</h3>
<p class="muted">Each link works once. Mint one per person and label it so you remember who it's for.</p>
<form method="post" action="/repeaters/{{.Repeater.ID}}/share/link">
<label>Description (optional)
<input type="text" name="description" placeholder="e.g. for Bob's modem" maxlength="100">
</label>
<button type="submit" class="primary">Create single-use link</button>
</form>
{{if .Invites}}
<ul class="repeaters">
{{range .Invites}}
<li>
<div class="rhead">
<span class="rname">{{if .Description}}{{.Description}}{{else}}(no label){{end}}</span>
{{if .UsedAt}}<span class="badge ok">Used by {{.UsedByName}}</span>{{else}}<span class="badge pending">Unused</span>{{end}}
</div>
{{if not .UsedAt}}
<div class="copyrow">
<code id="link-{{.ID}}">{{$.BaseURL}}/invite/{{.Token}}</code>
<button type="button" onclick="navigator.clipboard.writeText(document.getElementById('link-{{.ID}}').textContent)">Copy</button>
</div>
{{end}}
<form method="post" action="/repeaters/{{$.Repeater.ID}}/share/link/delete" class="inline">
<input type="hidden" name="invite_id" value="{{.ID}}">
<button type="submit" class="link danger">{{if .UsedAt}}Remove from list{{else}}Revoke link{{end}}</button>
</form>
</li>
{{end}}
</ul>
{{else}}
<p class="muted">No links yet.</p>
{{end}}
<h3>People with access</h3>
{{if .Shares}}
<ul class="repeaters">
{{range .Shares}}
<li class="rhead">
<span class="rname">{{.Name}}</span>
<span class="muted">@{{.Username}}</span>
<a class="btnlink" style="margin-left:auto" href="/repeaters/{{$.Repeater.ID}}/share/{{.UserID}}/commands">Edit commands</a>
<form method="post" action="/repeaters/{{$.Repeater.ID}}/unshare" class="inline">
<input type="hidden" name="user_id" value="{{.UserID}}">
<button type="submit" class="link danger">Revoke</button>
</form>
</li>
{{end}}
</ul>
{{else}}
<p class="muted">No one has accepted yet.</p>
{{end}}
</section>
<p><a class="backlink" href="/">← Back to dashboard</a></p>
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Sharing</div>
<h2 class="page-title">{{.Repeater.Name}}</h2>
</div>
</div>
{{end}}
{{define "content"}}
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
<div class="card">
<div class="card-body">
<p class="text-secondary mb-0">
Decide who can operate this repeater. Everyone you grant access uses MeshTender's single identity, so
you never hand out keys — and you can revoke or withdraw at any time.
</p>
</div>
</div>
<div class="card mt-3">
<div class="card-body">
<h3 class="card-title">Organizations</h3>
<p class="text-secondary">
Contribute this repeater to an organization so its admins and members can run their permitted
commands on it over the mesh — but only the specific commands you review and approve first. You can
withdraw anytime.
</p>
<div class="subheader mt-3 mb-2">Contributed to</div>
{{if .Contributed}}
<div class="list-group list-group-flush">
{{range .Contributed}}
<div class="list-group-item d-flex align-items-center flex-wrap gap-2 px-0">
<span class="fw-bold">{{.OrgName}}</span>
<span class="text-secondary">consented to v{{.ConsentedVersion}}{{if .NeedsReconsent}} · org is now on v{{.CurrentVersion}}{{end}}</span>
{{if .NeedsReconsent}}
<a class="btn btn-sm" href="/repeaters/{{$.Repeater.ID}}/orgs/{{.OrgID}}/contribute">Review changes</a>
{{end}}
<form method="post" action="/repeaters/{{$.Repeater.ID}}/orgs/{{.OrgID}}/withdraw" class="m-0 ms-auto" onsubmit="return confirm('Withdraw this repeater from {{.OrgName}}?')">
<button type="submit" class="btn btn-sm btn-ghost-danger">Withdraw</button>
</form>
</div>
{{end}}
</div>
{{else}}
<p class="text-secondary">Not contributed to any organization.</p>
{{end}}
{{if .Available}}
<div class="subheader mt-3 mb-2">Contribute to</div>
<div class="list-group list-group-flush">
{{range .Available}}
<div class="list-group-item d-flex align-items-center gap-2 px-0">
<span class="fw-bold">{{.Name}}</span>
<a class="btn btn-sm ms-auto" href="/repeaters/{{$.Repeater.ID}}/orgs/{{.ID}}/contribute">Review &amp; contribute</a>
</div>
{{end}}
</div>
{{else if .Contributed}}
{{else}}
<p class="text-secondary">Join an organization to contribute this repeater to it.</p>
{{end}}
</div>
</div>
<div class="card mt-3">
<div class="card-body">
<h3 class="card-title">People</h3>
<p class="text-secondary">
Share single-use links with specific people. The recipient signs in and accepts to control this
repeater from their own browser-connected KISS modem.
</p>
<div class="subheader mt-3 mb-2">Share links</div>
<p class="text-secondary">Each link works once. Mint one per person and label it so you remember who it's for.</p>
<form method="post" action="/repeaters/{{.Repeater.ID}}/share/link">
<div class="mb-3">
<label class="form-label">Description <span class="text-secondary">(optional)</span></label>
<input type="text" class="form-control" name="description" placeholder="e.g. for Bob's modem" maxlength="100">
</div>
<button type="submit" class="btn btn-primary">Create single-use link</button>
</form>
{{if .Invites}}
<div class="list-group list-group-flush mt-3">
{{range .Invites}}
<div class="list-group-item px-0">
<div class="d-flex align-items-center gap-2 mb-1">
<span class="fw-bold">{{if .Description}}{{.Description}}{{else}}(no label){{end}}</span>
{{if .UsedAt}}<span class="badge bg-success-lt">Used by {{.UsedByName}}</span>{{else}}<span class="badge bg-yellow-lt">Unused</span>{{end}}
</div>
{{if not .UsedAt}}
<div class="input-group mb-2">
<code class="form-control font-monospace" id="link-{{.ID}}">{{$.BaseURL}}/invite/{{.Token}}</code>
<button type="button" class="btn" onclick="navigator.clipboard.writeText(document.getElementById('link-{{.ID}}').textContent)">{{template "icon-copy" ""}}Copy</button>
</div>
{{end}}
<form method="post" action="/repeaters/{{$.Repeater.ID}}/share/link/delete" class="m-0">
<input type="hidden" name="invite_id" value="{{.ID}}">
<button type="submit" class="btn btn-sm btn-ghost-danger">{{if .UsedAt}}Remove from list{{else}}Revoke link{{end}}</button>
</form>
</div>
{{end}}
</div>
{{else}}
<p class="text-secondary mt-2">No links yet.</p>
{{end}}
<div class="subheader mt-3 mb-2">People with access</div>
{{if .Shares}}
<div class="list-group list-group-flush">
{{range .Shares}}
<div class="list-group-item d-flex align-items-center flex-wrap gap-2 px-0">
<span class="fw-bold">{{.Name}}</span>
<span class="text-secondary">@{{.Username}}</span>
<a class="btn btn-sm ms-auto" href="/repeaters/{{$.Repeater.ID}}/share/{{.UserID}}/commands">Edit commands</a>
<form method="post" action="/repeaters/{{$.Repeater.ID}}/unshare" class="m-0">
<input type="hidden" name="user_id" value="{{.UserID}}">
<button type="submit" class="btn btn-sm btn-ghost-danger">Revoke</button>
</form>
</div>
{{end}}
</div>
{{else}}
<p class="text-secondary">No one has accepted yet.</p>
{{end}}
</div>
</div>
<a class="btn btn-link px-0 mt-3" href="/">{{template "icon-arrow-left" "me-1"}}Back to dashboard</a>
{{end}}
+36 -26
View File
@@ -1,33 +1,43 @@
{{define "title"}}Commands for {{.Target.Name}} · MeshTender{{end}}
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Sharing</div>
<h2 class="page-title">Commands for {{.Target.Name}}</h2>
</div>
</div>
{{end}}
{{define "content"}}
<section class="card">
<h1>Commands for {{.Target.Name}}</h1>
<p class="muted">
Choose which commands <strong>{{.Target.Name}}</strong> (@{{.Target.Username}}) may send to
“{{.Repeater.Name}}”. Risky commands can take over or brick the node — grant them only with care.
</p>
<div class="card">
<div class="card-body">
<p class="text-secondary">
Choose which commands <strong>{{.Target.Name}}</strong> (@{{.Target.Username}}) may send to
“{{.Repeater.Name}}”. Risky commands can take over or brick the node — grant them only with care.
</p>
<form method="post" action="/repeaters/{{.Repeater.ID}}/share/{{.Target.ID}}/commands" id="cmdform-perms">
{{range .Groups}}
<fieldset class="cmdgroup">
<legend>{{.Name}}</legend>
{{range .Commands}}
<label class="cmdrow{{if .Risky}} risky{{end}}">
<input type="checkbox" name="cmd" value="{{.ID}}" {{if .Checked}}checked{{end}}
{{if .Risky}}data-risky="1"{{end}}>
<code>{{.Template}}</code>
{{if .Risky}}<span class="badge warn">risky</span>{{end}}
{{if .Args}}<span class="muted">{{.Args}}</span>{{end}}
</label>
<form method="post" action="/repeaters/{{.Repeater.ID}}/share/{{.Target.ID}}/commands" id="cmdform-perms">
{{range .Groups}}
<fieldset class="form-fieldset">
<div class="form-label fw-bold text-capitalize">{{.Name}}</div>
{{range .Commands}}
<label class="form-check">
<input class="form-check-input" type="checkbox" name="cmd" value="{{.ID}}" {{if .Checked}}checked{{end}} {{if .Risky}}data-risky="1"{{end}}>
<span class="form-check-label">
<code>{{.Template}}</code>
{{if .Risky}}<span class="badge bg-warning-lt">risky</span>{{end}}
{{if .Args}}<span class="text-secondary">{{.Args}}</span>{{end}}
</span>
</label>
{{end}}
</fieldset>
{{end}}
</fieldset>
{{end}}
<div class="ractions">
<button type="submit" class="primary">Save commands</button>
<a class="btn ghost" href="/repeaters/{{.Repeater.ID}}/share">Cancel</a>
</div>
</form>
</section>
<div class="btn-list mt-3">
<button type="submit" class="btn btn-primary">Save commands</button>
<a class="btn" href="/repeaters/{{.Repeater.ID}}/share">Cancel</a>
</div>
</form>
</div>
</div>
<script>
// Confirm before enabling a risky command.
document.querySelectorAll('input[data-risky]').forEach(function (cb) {
+50 -33
View File
@@ -1,39 +1,56 @@
{{define "title"}}Create account · MeshTender{{end}}
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col"><h2 class="page-title">Create account</h2></div>
</div>
{{end}}
{{define "content"}}
<section class="card auth">
<h1>Create your account</h1>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<p class="muted">No email required. Pick a username; optionally add a display name (shown to people you share repeaters with).</p>
<div class="row justify-content-center">
<div class="col-12 col-md-6 col-lg-5">
<div class="card">
<div class="card-body">
<h2 class="card-title text-center mb-2">Create your account</h2>
{{if .Error}}<div class="alert alert-danger">{{.Error}}</div>{{end}}
<p class="text-secondary text-center mb-4">No email required. Pick a username; optionally add a display name (shown to people you share repeaters with).</p>
<form id="passkey-form" onsubmit="return false;">
<label>Username
<input type="text" id="username" name="username" autocomplete="username webauthn" autocapitalize="none" required>
</label>
<label>Display name (optional)
<input type="text" id="displayName" name="display_name" autocomplete="off">
</label>
<button type="button" class="primary" onclick="passkeyRegister()">Create with passkey</button>
</form>
<p id="passkey-status" class="status"></p>
<form id="passkey-form" onsubmit="return false;">
<div class="mb-3">
<label class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username" autocomplete="username webauthn" autocapitalize="none" required>
</div>
<div class="mb-3">
<label class="form-label">Display name <span class="text-secondary">(optional)</span></label>
<input type="text" class="form-control" id="displayName" name="display_name" autocomplete="off">
</div>
<div class="form-footer">
<button type="button" class="btn btn-primary w-100" onclick="passkeyRegister()">Create with passkey</button>
</div>
</form>
<p id="passkey-status" class="text-secondary text-center mt-2 mb-0" style="min-height:1.2em"></p>
<details class="fallback">
<summary>Use a password instead</summary>
<form method="post" action="/signup/password">
{{if .Next}}<input type="hidden" name="next" value="{{.Next}}">{{end}}
<label>Username
<input type="text" name="username" autocomplete="username" autocapitalize="none" required>
</label>
<label>Display name (optional)
<input type="text" name="display_name" autocomplete="off">
</label>
<label>Password (min 8 chars)
<input type="password" name="password" autocomplete="new-password" minlength="8" required>
</label>
<button type="submit">Create with password</button>
</form>
</details>
<p class="muted">Already have an account? <a href="/login{{if .Next}}?next={{.Next}}{{end}}">Sign in</a></p>
</section>
<details class="mt-3">
<summary class="text-secondary" style="cursor:pointer">Use a password instead</summary>
<form method="post" action="/signup/password" class="mt-2">
{{if .Next}}<input type="hidden" name="next" value="{{.Next}}">{{end}}
<div class="mb-3">
<label class="form-label">Username</label>
<input type="text" class="form-control" name="username" autocomplete="username" autocapitalize="none" required>
</div>
<div class="mb-3">
<label class="form-label">Display name <span class="text-secondary">(optional)</span></label>
<input type="text" class="form-control" name="display_name" autocomplete="off">
</div>
<div class="mb-3">
<label class="form-label">Password <span class="text-secondary">(min 8 chars)</span></label>
<input type="password" class="form-control" name="password" autocomplete="new-password" minlength="8" required>
</div>
<button type="submit" class="btn w-100">Create with password</button>
</form>
</details>
</div>
</div>
<p class="text-secondary text-center mt-3">Already have an account? <a href="/login{{if .Next}}?next={{.Next}}{{end}}">Sign in</a></p>
</div>
</div>
<script src="/static/webauthn.js"></script>
{{end}}
+1
View File
@@ -105,6 +105,7 @@ func (s *Server) routes() {
r.Post("/repeaters/{id}/orgs/{orgID}/withdraw", s.handleWithdraw)
r.Post("/invite/{token}/accept", s.handleAcceptInvite)
r.Get("/orgs/new", s.pageNewOrg)
r.Post("/orgs", s.handleCreateOrg)
r.Post("/orgs/{id}/edit", s.handleEditOrg)
r.Post("/orgs/{id}/leave", s.handleLeaveOrg)