mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-02 18:13:46 +00:00
Tabler refactor
This commit is contained in:
@@ -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
@@ -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; }
|
||||
|
||||
Vendored
+9
File diff suppressed because one or more lines are too long
Vendored
+13
File diff suppressed because one or more lines are too long
@@ -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");
|
||||
|
||||
@@ -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 & 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 & 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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -1,23 +1,39 @@
|
||||
{{define "title"}}Users · MeshTender{{end}}
|
||||
{{define "content"}}
|
||||
<section class="card">
|
||||
<h1>Users & 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 & 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}}
|
||||
|
||||
@@ -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 — 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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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 & 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}}
|
||||
· <a class="backlink" href="/repeaters/{{.Repeater.ID}}/confirm">Disable debug</a>
|
||||
{{else}}
|
||||
· <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 & 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}}";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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 <your key> 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 <your key> 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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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
@@ -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: '© 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: '© 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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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: '© 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: '© 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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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 & 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 & 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 & 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 & 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
@@ -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 & 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 & 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}}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user