Dashboard pass

This commit is contained in:
Jonathon Leight
2026-06-20 15:29:27 -04:00
parent ba133dd065
commit 9d8a24e9e5
11 changed files with 775 additions and 211 deletions
+41
View File
@@ -104,6 +104,47 @@ func (s *Store) ListCommandLogSessions(ctx context.Context, repeaterID int64, li
return groups, rows.Err()
}
// OwnerCommandLogEntry is a recent command across all repeaters a user owns,
// joined with the repeater's name and the sender's name (for the dashboard).
type OwnerCommandLogEntry struct {
RepeaterID int64
RepeaterName string
SenderName string
CommandText string
SentAt time.Time
AckReceived bool
ResponseText *string
}
// ListRecentCommandsForOwner returns the most recent commands run on any
// repeater owned by ownerID, newest first, bounded by limit.
func (s *Store) ListRecentCommandsForOwner(ctx context.Context, ownerID int64, limit int) ([]OwnerCommandLogEntry, error) {
rows, err := s.pool.Query(ctx, `
SELECT l.repeater_id, r.name,
COALESCE(NULLIF(u.display_name, ''), u.username, '(deleted)'),
l.command_text, l.sent_at, l.ack_received, l.response_text
FROM command_log l
JOIN repeaters r ON r.id = l.repeater_id
LEFT JOIN users u ON u.id = l.user_id
WHERE r.owner_id = $1
ORDER BY l.sent_at DESC
LIMIT $2`, ownerID, limit)
if err != nil {
return nil, fmt.Errorf("list owner commands: %w", err)
}
defer rows.Close()
var out []OwnerCommandLogEntry
for rows.Next() {
var e OwnerCommandLogEntry
if err := rows.Scan(&e.RepeaterID, &e.RepeaterName, &e.SenderName,
&e.CommandText, &e.SentAt, &e.AckReceived, &e.ResponseText); err != nil {
return nil, fmt.Errorf("scan owner command: %w", err)
}
out = append(out, e)
}
return out, rows.Err()
}
// ListCommandLog returns recent log entries for a repeater, newest first.
func (s *Store) ListCommandLog(ctx context.Context, repeaterID int64, limit int) ([]*CommandLogEntry, error) {
rows, err := s.pool.Query(ctx, `
+20 -11
View File
@@ -107,23 +107,32 @@ func (s *Server) pageOrg(w http.ResponseWriter, r *http.Request) {
http.Error(w, "could not load repeaters", http.StatusInternalServerError)
return
}
hasMap := false
mapped := 0
for _, rp := range repeaters {
if rp.HasLocation {
hasMap = true
break
mapped++
}
}
adminCount := 0
for _, m := range members {
if m.Role == "admin" {
adminCount++
}
}
isAdmin := role == "admin"
data := map[string]any{
"Org": org,
"Role": role,
"IsAdmin": isAdmin,
"Members": members,
"Repeaters": repeaters,
"HasMap": hasMap,
"Self": uid,
"Error": r.URL.Query().Get("error"),
"Org": org,
"Role": role,
"IsAdmin": isAdmin,
"Members": members,
"Repeaters": repeaters,
"HasMap": mapped > 0,
"MemberCount": len(members),
"RepeaterCount": len(repeaters),
"AdminCount": adminCount,
"MappedCount": mapped,
"Self": uid,
"Error": r.URL.Query().Get("error"),
}
if isAdmin {
invites, err := s.store.ListOrgInvites(r.Context(), id)
+16 -2
View File
@@ -41,10 +41,24 @@ code.pubkey {
.ev-sent { color: var(--tblr-azure); }
.ev-reply { color: var(--tblr-body-color); white-space: pre-wrap; }
/* ---------- Leaflet map ---------- */
/* ---------- Leaflet map (dark) ---------- */
#map {
height: 360px;
border-radius: var(--tblr-border-radius);
border: 1px solid var(--tblr-border-color);
}
.leaflet-popup-content { color: #111; }
.leaflet-container { background: #14171c; }
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: var(--tblr-bg-surface, #1a1d24);
color: var(--tblr-body-color, #e6edf3);
box-shadow: var(--tblr-box-shadow, 0 1px 2px rgba(0, 0, 0, 0.4));
}
.leaflet-popup-content { color: var(--tblr-body-color, #e6edf3); }
.leaflet-bar a,
.leaflet-control-zoom a {
background: var(--tblr-bg-surface, #1a1d24);
color: var(--tblr-body-color, #e6edf3);
border-bottom-color: var(--tblr-border-color, #2b2f36);
}
.leaflet-bar a:hover { background: var(--tblr-bg-surface-secondary, #22262e); }
+24
View File
@@ -0,0 +1,24 @@
// meshMap renders a dark-mode Leaflet map of points into the element with the
// given id. pts is an array of {name, lat, lon}. Does nothing if pts is empty.
function meshMap(elId, pts) {
if (!pts || !pts.length) return;
var map = L.map(elId, { scrollWheelZoom: false });
// CARTO "dark matter" basemap (OpenStreetMap data) for a dark UI.
L.tileLayer("https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", {
maxZoom: 19,
subdomains: "abcd",
attribution: "© OpenStreetMap © CARTO",
}).addTo(map);
var group = L.featureGroup(
pts.map(function (p) {
return L.circleMarker([p.lat, p.lon], {
radius: 7,
color: "#4dabf7",
weight: 2,
fillColor: "#4dabf7",
fillOpacity: 0.6,
}).bindPopup(p.name);
})
).addTo(map);
map.fitBounds(group.getBounds().pad(0.3));
}
+46 -6
View File
@@ -16,12 +16,10 @@
<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>
<div class="navbar-nav flex-row order-md-last">
<a href="/" class="navbar-brand navbar-brand-autodark d-flex align-items-center text-reset text-decoration-none">
{{template "icon-tower" "me-2"}}<span class="fw-bold fs-3">MeshTender</span>
</a>
<div class="navbar-nav flex-row order-md-last align-items-center gap-2">
{{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">
@@ -45,6 +43,12 @@
{{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-home" ""}}</span>
<span class="nav-link-title">Dashboard</span>
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/repeaters">
<span class="nav-link-icon d-md-none d-lg-inline-block">{{template "icon-antenna" ""}}</span>
<span class="nav-link-title">Repeaters</span>
</a>
@@ -126,6 +130,42 @@
</body>
</html>{{end}}
{{/* landingbase is the public marketing layout: a transparent navbar with
sign-in / sign-up actions, the page content, and a simple footer. Pages opt
in via the "Layout":"landingbase" render key. */}}
{{define "landingbase"}}<!doctype html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>{{template "title" .}}</title>
<link rel="stylesheet" href="/static/tabler.min.css">
<link rel="stylesheet" href="/static/app.css">
<script src="/static/tabler.min.js" defer></script>
</head>
<body>
<header class="navbar navbar-expand-md navbar-transparent py-3">
<div class="container">
<a href="/" class="navbar-brand navbar-brand-autodark d-flex align-items-center text-reset text-decoration-none">
{{template "icon-tower" "me-2"}}<span class="fw-bold fs-3">MeshTender</span>
</a>
<div class="navbar-nav flex-row order-md-last align-items-center gap-2">
<a href="/login" class="btn btn-ghost-secondary">Sign in</a>
<a href="/signup" class="btn btn-primary">Create account</a>
</div>
</div>
</header>
{{template "content" .}}
<footer class="footer footer-transparent">
<div class="container">
<div class="row align-items-center justify-content-center">
<div class="col-auto text-secondary">MeshTender &mdash; tend your mesh.</div>
</div>
</div>
</footer>
</body>
</html>{{end}}
{{/* revokedoc explains how to revoke MeshTender's access. Dot is the revoke command string. */}}
{{define "revokedoc"}}
<details class="mt-3">
+160 -61
View File
@@ -2,8 +2,8 @@
{{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 class="page-pretitle">Dashboard</div>
<h2 class="page-title">Welcome back{{if .UserName}}, {{.UserName}}{{end}}</h2>
</div>
<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>
@@ -12,77 +12,176 @@
{{end}}
{{define "content"}}
{{if .Error}}<div class="alert alert-danger" role="alert">{{.Error}}</div>{{end}}
{{if .Reconsent}}
<div class="alert alert-warning" role="alert">
<div class="d-flex"><div>{{template "icon-alert" "alert-icon"}}</div>
<div>One or more of your repeaters need re-consent after an organization changed its command policy.
<a href="/repeaters" class="alert-link">Review on the Repeaters page</a>.</div></div>
</div>
{{end}}
{{if .Owned}}
<!-- Summary stats -->
<div class="row row-cards">
{{range .Owned}}
<div class="col-12">
<div class="card">
<div class="col-sm-6 col-lg-3">
<div class="card card-sm">
<div class="card-body">
<div class="d-flex align-items-center flex-wrap gap-2 mb-2">
<h3 class="card-title mb-0 me-1">{{.Name}}</h3>
{{template "repstatus" .}}
{{if index $.Reconsent .ID}}<a class="badge bg-warning-lt" href="/repeaters/{{.ID}}/share">re-consent needed</a>{{end}}
</div>
<code class="pk">{{.PublicKeyHex}}</code>
<div class="text-secondary small mt-2">{{.RadioFreqHz}} Hz · BW {{.RadioBwHz}} · SF{{.RadioSF}} · CR{{.RadioCR}}</div>
{{if and .Confirmed .AccessKnown (not .IsAdmin)}}
<div class="alert alert-warning mt-3 mb-0">
MeshTender only has <strong>guest</strong> access here. Guest is available to anyone with a blank password, so there's no point operating at this level — re-run <code>setperm &lt;your key&gt; 3</code> on the repeater to grant admin, then re-test.
</div>
{{end}}
<div class="btn-list mt-3">
<a class="btn btn-primary" href="/repeaters/{{.ID}}/console">{{template "icon-terminal" "me-1"}}Console</a>
<div class="dropdown">
<button class="btn dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">Manage</button>
<div class="dropdown-menu dropdown-menu-end">
<a class="dropdown-item" href="/repeaters/{{.ID}}/edit">{{template "icon-pencil" "dropdown-item-icon"}}Edit details</a>
<a class="dropdown-item" href="/repeaters/{{.ID}}/confirm">{{template "icon-antenna" "dropdown-item-icon"}}Confirm access</a>
<a class="dropdown-item" href="/repeaters/{{.ID}}/log">{{template "icon-list" "dropdown-item-icon"}}Logs</a>
<a class="dropdown-item" href="/repeaters/{{.ID}}/share">{{template "icon-share" "dropdown-item-icon"}}Sharing</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item text-danger" href="/repeaters/{{.ID}}/delete">{{template "icon-trash" "dropdown-item-icon"}}Delete repeater</a>
</div>
<div class="row align-items-center">
<div class="col-auto"><span class="bg-primary text-white avatar">{{template "icon-antenna" ""}}</span></div>
<div class="col">
<div class="h1 mb-0">{{.OwnedCount}}</div>
<div class="text-secondary">Repeater{{if ne .OwnedCount 1}}s{{end}} owned</div>
</div>
</div>
</div>
</div>
</div>
{{end}}
</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}}
<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="col-sm-6 col-lg-3">
<div class="card card-sm">
<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 class="row align-items-center">
<div class="col-auto"><span class="bg-green text-white avatar">{{template "icon-check" ""}}</span></div>
<div class="col">
<div class="h1 mb-0">{{.Confirmed}}</div>
<div class="text-secondary">Confirmed{{if .Unconfirmed}} · {{.Unconfirmed}} pending{{end}}</div>
</div>
</div>
</div>
</div>
</div>
{{end}}
<div class="col-sm-6 col-lg-3">
<div class="card card-sm">
<div class="card-body">
<div class="row align-items-center">
<div class="col-auto"><span class="bg-azure text-white avatar">{{template "icon-world" ""}}</span></div>
<div class="col">
<div class="h1 mb-0">{{.OrgCount}}</div>
<div class="text-secondary">Organization{{if ne .OrgCount 1}}s{{end}}</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card card-sm">
<div class="card-body">
<div class="row align-items-center">
<div class="col-auto"><span class="bg-purple text-white avatar">{{template "icon-share" ""}}</span></div>
<div class="col">
<div class="h1 mb-0">{{.SharedCount}}</div>
<div class="text-secondary">Shared with you</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row row-cards mt-1">
<!-- Left column -->
<div class="col-lg-8">
<!-- Your repeaters -->
<div class="card">
<div class="card-header">
<h3 class="card-title">Your repeaters</h3>
<div class="card-actions"><a href="/repeaters" class="btn btn-sm">View all{{if .OwnedCount}} ({{.OwnedCount}}){{end}}</a></div>
</div>
{{if .Owned}}
<div class="list-group list-group-flush">
{{range .Owned}}
<div class="list-group-item d-flex align-items-center gap-2">
<span class="text-secondary">{{template "icon-antenna" ""}}</span>
<div class="flex-fill text-truncate">
<a class="fw-bold text-reset text-decoration-none" href="/repeaters/{{.ID}}/console">{{.Name}}</a>
<div class="text-secondary small text-truncate">{{.RadioFreqHz}} Hz · SF{{.RadioSF}}</div>
</div>
{{template "repstatus" .}}
<a class="btn btn-sm" href="/repeaters/{{.ID}}/console">{{template "icon-terminal" "me-1"}}Console</a>
</div>
{{end}}
</div>
{{else}}
<div class="card-body">
<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>
</div>
{{end}}
</div>
<!-- Recent activity -->
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Recent activity</h3></div>
{{if .Recent}}
<div class="table-responsive">
<table class="table table-vcenter card-table">
<thead><tr><th>Repeater</th><th>Command</th><th>By</th><th>When</th><th></th></tr></thead>
<tbody>
{{range .Recent}}
<tr>
<td class="text-truncate" style="max-width:10rem"><a class="text-reset" href="/repeaters/{{.RepeaterID}}/log">{{.RepeaterName}}</a></td>
<td><code>{{.CommandText}}</code></td>
<td class="text-secondary">{{.SenderName}}</td>
<td class="text-secondary text-nowrap">{{.SentAt.Format "Jan 2, 15:04"}}</td>
<td>{{if .AckReceived}}<span class="badge bg-success-lt">reply</span>{{else}}<span class="badge bg-yellow-lt">no reply</span>{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="card-body"><p class="text-secondary mb-0">No commands have been run on your repeaters yet. Open a repeater's console to send one.</p></div>
{{end}}
</div>
</div>
<!-- Right column -->
<div class="col-lg-4">
<!-- Map -->
<div class="card">
<div class="card-header"><h3 class="card-title">Repeater map</h3></div>
{{if .Mapped}}
<div class="card-body p-2">
<link rel="stylesheet" href="/static/leaflet.css">
<div id="map" style="height:260px"></div>
<script src="/static/leaflet.js"></script>
<script src="/static/meshmap.js"></script>
<script>
meshMap('map', [
{{range .Mapped}}{name: {{.Name}}, lat: {{.Latitude}}, lon: {{.Longitude}}},{{end}}
]);
</script>
</div>
{{else}}
<div class="card-body"><p class="text-secondary mb-0">No repeater locations yet. Enable “store location” when editing a repeater and confirm it with a modem to place it on the map.</p></div>
{{end}}
</div>
<!-- Organizations -->
<div class="card mt-3">
<div class="card-header">
<h3 class="card-title">Organizations</h3>
<div class="card-actions"><a href="/orgs" class="btn btn-sm">View all{{if .OrgCount}} ({{.OrgCount}}){{end}}</a></div>
</div>
{{if .Orgs}}
<div class="list-group list-group-flush">
{{range .Orgs}}
<a href="/orgs/{{.Org.ID}}" class="list-group-item list-group-item-action d-flex align-items-center gap-2">
<span class="text-secondary">{{template "icon-world" ""}}</span>
<span class="flex-fill text-truncate 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>
{{end}}
</div>
{{else}}
<div class="card-body">
<p class="text-secondary">You're not in any organizations yet. Join one to help tend a mesh together.</p>
<a href="/orgs" class="btn btn-sm">Browse organizations</a>
</div>
{{end}}
</div>
</div>
</div>
{{end}}
{{end}}
+113
View File
@@ -0,0 +1,113 @@
{{define "title"}}MeshTender · Tend your mesh{{end}}
{{define "content"}}
<!-- Hero -->
<div class="page-header">
<div class="container">
<div class="row align-items-center py-5">
<div class="col-lg-7">
<h1 class="display-5 fw-bold mb-3">Tend your MeshCore network — without handing out keys.</h1>
<p class="fs-3 text-secondary mb-4">
MeshTender manages your LoRa repeaters through a single trusted identity. Grant access once,
then share, organize, and audit who can do what — and revoke any time.
</p>
<div class="btn-list">
<a href="/signup" class="btn btn-primary btn-lg">{{template "icon-key" "me-2"}}Get started</a>
<a href="/orgs" class="btn btn-lg">Browse organizations</a>
</div>
<p class="text-secondary mt-3 mb-0">No email required. Sign in with a passkey.</p>
</div>
<div class="col-lg-5 d-none d-lg-flex justify-content-center">
<span class="text-primary" style="opacity:.85">
<svg xmlns="http://www.w3.org/2000/svg" width="220" height="220" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round"><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>
</span>
</div>
</div>
</div>
</div>
<!-- Features -->
<div class="page-body">
<div class="container">
<div class="row row-cards">
<div class="col-md-6 col-lg-4">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-center mb-2">
<span class="avatar bg-primary-lt text-primary me-3">{{template "icon-key" ""}}</span>
<h3 class="card-title m-0">One identity, no key sharing</h3>
</div>
<p class="text-secondary mb-0">Grant MeshTender admin on a repeater once. Everyone you authorize operates through that single identity — you never distribute private keys.</p>
</div>
</div>
</div>
<div class="col-md-6 col-lg-4">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-center mb-2">
<span class="avatar bg-primary-lt text-primary me-3">{{template "icon-share" ""}}</span>
<h3 class="card-title m-0">Share with precision</h3>
</div>
<p class="text-secondary mb-0">Hand specific people single-use links, and approve the exact commands each may run. Contribute repeaters to organizations with command policies you review first.</p>
</div>
</div>
</div>
<div class="col-md-6 col-lg-4">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-center mb-2">
<span class="avatar bg-primary-lt text-primary me-3">{{template "icon-antenna" ""}}</span>
<h3 class="card-title m-0">Confirm reachability</h3>
</div>
<p class="text-secondary mb-0">Connect a KISS modem in your browser and verify a repeater is reachable. Others corroborate it from their own modems — proof it's truly on the air.</p>
</div>
</div>
</div>
<div class="col-md-6 col-lg-4">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-center mb-2">
<span class="avatar bg-primary-lt text-primary me-3">{{template "icon-list" ""}}</span>
<h3 class="card-title m-0">Full audit log</h3>
</div>
<p class="text-secondary mb-0">Every command sent through MeshTender is logged by session, with who sent it and whether the repeater replied.</p>
</div>
</div>
</div>
<div class="col-md-6 col-lg-4">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-center mb-2">
<span class="avatar bg-primary-lt text-primary me-3">{{template "icon-world" ""}}</span>
<h3 class="card-title m-0">Map your network</h3>
</div>
<p class="text-secondary mb-0">Opt repeaters into a map so your organization — or the public — can see where coverage reaches.</p>
</div>
</div>
</div>
<div class="col-md-6 col-lg-4">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-center mb-2">
<span class="avatar bg-primary-lt text-primary me-3">{{template "icon-user" ""}}</span>
<h3 class="card-title m-0">Passwordless by default</h3>
</div>
<p class="text-secondary mb-0">Create an account with just a username and a passkey. No email, no password to leak — with a password as an optional fallback.</p>
</div>
</div>
</div>
</div>
<!-- CTA -->
<div class="card card-md mt-4">
<div class="card-body text-center py-5">
<h2 class="mb-3">Ready to tend your mesh?</h2>
<p class="text-secondary mb-4">Register a repeater in minutes and bring your team along.</p>
<div class="btn-list justify-content-center">
<a href="/signup" class="btn btn-primary btn-lg">Create your account</a>
<a href="/login" class="btn btn-lg">Sign in</a>
</div>
</div>
</div>
</div>
</div>
{{end}}
+162 -111
View File
@@ -2,142 +2,193 @@
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Organization</div>
<div class="page-pretitle">Organization · you're {{if .IsAdmin}}an admin{{else}}a member{{end}}</div>
<h2 class="page-title">{{.Org.Name}}</h2>
</div>
<div class="col-auto ms-auto d-print-none">
<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"}}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</button>
</form>
</div>
</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">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>
<!-- Summary stats -->
<div class="row row-cards">
<div class="col-sm-6 col-lg-3">
<div class="card card-sm">
<div class="card-body">
<div class="row align-items-center">
<div class="col-auto"><span class="bg-primary text-white avatar">{{template "icon-users" ""}}</span></div>
<div class="col"><div class="h1 mb-0">{{.MemberCount}}</div><div class="text-secondary">Member{{if ne .MemberCount 1}}s{{end}}</div></div>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card card-sm">
<div class="card-body">
<div class="row align-items-center">
<div class="col-auto"><span class="bg-azure text-white avatar">{{template "icon-antenna" ""}}</span></div>
<div class="col"><div class="h1 mb-0">{{.RepeaterCount}}</div><div class="text-secondary">Repeater{{if ne .RepeaterCount 1}}s{{end}}</div></div>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card card-sm">
<div class="card-body">
<div class="row align-items-center">
<div class="col-auto"><span class="bg-green text-white avatar">{{template "icon-user" ""}}</span></div>
<div class="col"><div class="h1 mb-0">{{.AdminCount}}</div><div class="text-secondary">Admin{{if ne .AdminCount 1}}s{{end}}</div></div>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card card-sm">
<div class="card-body">
<div class="row align-items-center">
<div class="col-auto"><span class="bg-purple text-white avatar">{{template "icon-world" ""}}</span></div>
<div class="col"><div class="h1 mb-0">{{.MappedCount}}</div><div class="text-secondary">On the map</div></div>
</div>
</div>
</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 class="row row-cards mt-1">
<!-- Left column -->
<div class="col-lg-8">
{{if .HasMap}}
<div class="card">
<div class="card-header"><h3 class="card-title">Repeater map</h3></div>
<div class="card-body p-2">
<link rel="stylesheet" href="/static/leaflet.css">
<div id="map" style="height:300px"></div>
<script src="/static/leaflet.js"></script>
<script src="/static/meshmap.js"></script>
<script>
meshMap('map', [
{{range .Repeaters}}{{if .HasLocation}}{name: {{.Name}}, lat: {{.Lat}}, lon: {{.Lon}}},{{end}}{{end}}
]);
</script>
</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>
</div>
{{end}}
</div>
{{end}}
<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>
<div class="card {{if .HasMap}}mt-3{{end}}">
<div class="card-header"><h3 class="card-title">Repeaters</h3></div>
{{if .Repeaters}}
<div class="list-group list-group-flush">
{{range .Repeaters}}
<div class="list-group-item d-flex align-items-center gap-2">
<span class="text-secondary">{{template "icon-antenna" ""}}</span>
<div class="flex-fill text-truncate">
<span class="fw-bold">{{.Name}}</span>
<div class="text-secondary small">owner {{.OwnerName}}</div>
</div>
<a class="btn btn-primary btn-sm" href="/repeaters/{{.RepeaterID}}/console">{{template "icon-terminal" "me-1"}}Console</a>
</div>
{{end}}
</div>
{{else}}
<div class="card-body"><p class="text-secondary mb-0">No repeaters have been contributed yet. Members can contribute their own from a repeater's “Organizations” page.</p></div>
{{end}}
</div>
</div>
</div>
<div class="card mt-3">
<div class="card-body">
<h3 class="card-title">Repeaters</h3>
{{if .HasMap}}
<link rel="stylesheet" href="/static/leaflet.css">
<div id="map" class="mb-3"></div>
<script src="/static/leaflet.js"></script>
<script>
var pts = [
{{range .Repeaters}}{{if .HasLocation}}{name: {{.Name}}, lat: {{.Lat}}, lon: {{.Lon}}},{{end}}{{end}}
];
var map = L.map('map');
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19, attribution: '&copy; OpenStreetMap'
}).addTo(map);
var group = L.featureGroup(pts.map(function (p) {
return L.circleMarker([p.lat, p.lon], { radius: 7, color: '#066fd1', fillOpacity: 0.7 }).bindPopup(p.name);
})).addTo(map);
map.fitBounds(group.getBounds().pad(0.2));
</script>
{{end}}
{{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}}
<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 class="card mt-3">
<div class="card-header"><h3 class="card-title">Members</h3></div>
<div class="list-group list-group-flush">
{{range .Members}}
<div class="list-group-item d-flex align-items-center flex-wrap gap-2">
<div class="flex-fill text-truncate">
<span class="fw-bold">{{.Name}}</span>
<span class="text-secondary">@{{.Username}}</span>
</div>
{{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 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}}
</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>
{{end}}
</div>
</div>
</div>
<!-- Right column -->
<div class="col-lg-4">
{{if .Org.Description}}
<div class="card">
<div class="card-header"><h3 class="card-title">About</h3></div>
<div class="card-body"><p class="mb-0">{{.Org.Description}}</p></div>
</div>
{{end}}
{{if .IsAdmin}}
<div class="card {{if .Org.Description}}mt-3{{end}}">
<div class="card-header"><h3 class="card-title">Organization profile <span class="text-secondary">(public)</span></h3></div>
<div class="card-body">
<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>
<textarea class="form-control" name="description" rows="3" maxlength="2000" placeholder="What this organization is about, where it operates, etc.">{{.Org.Description}}</textarea>
</div>
<button type="submit" class="btn btn-primary">Save profile</button>
</form>
</div>
</div>
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Join links</h3></div>
<div class="card-body">
<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>
{{end}}
</div>
{{end}}
<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>
{{end}}
</div>
{{end}}
<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}}
<a class="btn btn-link px-0 mt-3" href="/orgs">{{template "icon-arrow-left" "me-1"}}All organizations</a>
{{end}}
+3 -10
View File
@@ -35,18 +35,11 @@
<link rel="stylesheet" href="/static/leaflet.css">
<div id="map"></div>
<script src="/static/leaflet.js"></script>
<script src="/static/meshmap.js"></script>
<script>
var pts = [
meshMap('map', [
{{range .Repeaters}}{{if .HasLocation}}{name: {{.Name}}, lat: {{.Lat}}, lon: {{.Lon}}},{{end}}{{end}}
];
var map = L.map('map');
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19, attribution: '&copy; OpenStreetMap'
}).addTo(map);
var group = L.featureGroup(pts.map(function (p) {
return L.circleMarker([p.lat, p.lon], { radius: 7, color: '#066fd1', fillOpacity: 0.7 }).bindPopup(p.name);
})).addTo(map);
map.fitBounds(group.getBounds().pad(0.2));
]);
</script>
</div>
</div>
+88
View File
@@ -0,0 +1,88 @@
{{define "title"}}Repeaters · MeshTender{{end}}
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Your equipment</div>
<h2 class="page-title">Repeaters</h2>
</div>
<div class="col-auto ms-auto d-print-none">
<a class="btn btn-primary" href="/repeaters/add">{{template "icon-plus" "me-1"}}Add repeater</a>
</div>
</div>
{{end}}
{{define "content"}}
{{if .Error}}<div class="alert alert-danger" role="alert">{{.Error}}</div>{{end}}
{{if .Owned}}
<div class="row row-cards">
{{range .Owned}}
<div class="col-12">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-center flex-wrap gap-2 mb-2">
<h3 class="card-title mb-0 me-1">{{.Name}}</h3>
{{template "repstatus" .}}
{{if index $.Reconsent .ID}}<a class="badge bg-warning-lt" href="/repeaters/{{.ID}}/share">re-consent needed</a>{{end}}
</div>
<code class="pk">{{.PublicKeyHex}}</code>
<div class="text-secondary small mt-2">{{.RadioFreqHz}} Hz · BW {{.RadioBwHz}} · SF{{.RadioSF}} · CR{{.RadioCR}}</div>
{{if and .Confirmed .AccessKnown (not .IsAdmin)}}
<div class="alert alert-warning mt-3 mb-0">
MeshTender only has <strong>guest</strong> access here. Guest is available to anyone with a blank password, so there's no point operating at this level — re-run <code>setperm &lt;your key&gt; 3</code> on the repeater to grant admin, then re-test.
</div>
{{end}}
<div class="btn-list mt-3">
<a class="btn btn-primary" href="/repeaters/{{.ID}}/console">{{template "icon-terminal" "me-1"}}Console</a>
<div class="dropdown">
<button class="btn dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">Manage</button>
<div class="dropdown-menu dropdown-menu-end">
<a class="dropdown-item" href="/repeaters/{{.ID}}/edit">{{template "icon-pencil" "dropdown-item-icon"}}Edit details</a>
<a class="dropdown-item" href="/repeaters/{{.ID}}/confirm">{{template "icon-antenna" "dropdown-item-icon"}}Confirm access</a>
<a class="dropdown-item" href="/repeaters/{{.ID}}/log">{{template "icon-list" "dropdown-item-icon"}}Logs</a>
<a class="dropdown-item" href="/repeaters/{{.ID}}/share">{{template "icon-share" "dropdown-item-icon"}}Sharing</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item text-danger" href="/repeaters/{{.ID}}/delete">{{template "icon-trash" "dropdown-item-icon"}}Delete repeater</a>
</div>
</div>
</div>
</div>
</div>
</div>
{{end}}
</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}}
<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>
</div>
</div>
{{end}}
</div>
{{end}}
{{end}}
+102 -10
View File
@@ -63,6 +63,9 @@ func (s *Server) routes() {
staticSub, _ := fs.Sub(staticFS, "static")
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))))
// Root: marketing landing for visitors, overview dashboard for members.
r.Get("/", s.pageHome)
// Public auth pages + JSON ceremony endpoints.
r.Get("/login", s.pageLogin)
r.Get("/signup", s.pageSignup)
@@ -82,7 +85,7 @@ func (s *Server) routes() {
// Authenticated area.
r.Group(func(r chi.Router) {
r.Use(s.auth.RequireUser)
r.Get("/", s.pageDashboard)
r.Get("/repeaters", s.pageRepeaters)
r.Post("/logout", s.handleLogout)
r.Get("/account", s.pageAccount)
r.Post("/account/profile", s.handleUpdateProfile)
@@ -194,7 +197,19 @@ func (s *Server) pageSignup(w http.ResponseWriter, r *http.Request) {
})
}
func (s *Server) pageDashboard(w http.ResponseWriter, r *http.Request) {
// pageHome serves the marketing landing page to visitors and the overview
// dashboard to signed-in members.
func (s *Server) pageHome(w http.ResponseWriter, r *http.Request) {
if s.auth.CurrentUserID(r.Context()) == 0 {
s.render(w, r, "landing.html", map[string]any{"Layout": "landingbase"})
return
}
s.pageDashboard(w, r)
}
// pageRepeaters lists every repeater the user owns or has been shared, split
// into owned and shared sections.
func (s *Server) pageRepeaters(w http.ResponseWriter, r *http.Request) {
uid := s.auth.CurrentUserID(r.Context())
repeaters, err := s.store.ListRepeatersForUser(r.Context(), uid)
if err != nil {
@@ -206,8 +221,76 @@ func (s *Server) pageDashboard(w http.ResponseWriter, r *http.Request) {
http.Error(w, "could not load org state", http.StatusInternalServerError)
return
}
// Split owned vs directly-shared for the two dashboard sections.
var owned, shared []*store.Repeater
owned, shared := splitOwnedShared(repeaters)
s.render(w, r, "repeaters.html", map[string]any{
"Owned": owned,
"Shared": shared,
"Reconsent": reconsent,
"Error": r.URL.Query().Get("error"),
})
}
// pageDashboard renders the signed-in overview: summary stats, short lists of
// repeaters and organizations, a map of owned repeaters, and recent activity.
func (s *Server) pageDashboard(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
uid := s.auth.CurrentUserID(ctx)
repeaters, err := s.store.ListRepeatersForUser(ctx, uid)
if err != nil {
http.Error(w, "could not load repeaters", http.StatusInternalServerError)
return
}
owned, shared := splitOwnedShared(repeaters)
reconsent, err := s.store.OwnedRepeatersNeedingReconsent(ctx, uid)
if err != nil {
http.Error(w, "could not load org state", http.StatusInternalServerError)
return
}
orgs, err := s.store.ListOrgsForUser(ctx, uid)
if err != nil {
http.Error(w, "could not load organizations", http.StatusInternalServerError)
return
}
recent, err := s.store.ListRecentCommandsForOwner(ctx, uid, 8)
if err != nil {
http.Error(w, "could not load activity", http.StatusInternalServerError)
return
}
// Summary counts and the owned repeaters that have a stored location.
confirmed, unconfirmed := 0, 0
var mapped []*store.Repeater
for _, rp := range owned {
if rp.Confirmed {
confirmed++
} else {
unconfirmed++
}
if rp.Latitude != nil && rp.Longitude != nil {
mapped = append(mapped, rp)
}
}
s.render(w, r, "dashboard.html", map[string]any{
"OwnedCount": len(owned),
"SharedCount": len(shared),
"OrgCount": len(orgs),
"Confirmed": confirmed,
"Unconfirmed": unconfirmed,
"Owned": firstRepeaters(owned, 5),
"Shared": firstRepeaters(shared, 5),
"Orgs": firstOrgs(orgs, 5),
"Mapped": mapped,
"Recent": recent,
"Reconsent": reconsent,
"Error": r.URL.Query().Get("error"),
})
}
// splitOwnedShared partitions a repeater list into owned and directly-shared.
func splitOwnedShared(repeaters []*store.Repeater) (owned, shared []*store.Repeater) {
for _, rp := range repeaters {
if rp.Shared {
shared = append(shared, rp)
@@ -215,12 +298,21 @@ func (s *Server) pageDashboard(w http.ResponseWriter, r *http.Request) {
owned = append(owned, rp)
}
}
s.render(w, r, "dashboard.html", map[string]any{
"Owned": owned,
"Shared": shared,
"Reconsent": reconsent,
"Error": r.URL.Query().Get("error"),
})
return owned, shared
}
func firstRepeaters(rs []*store.Repeater, n int) []*store.Repeater {
if len(rs) > n {
return rs[:n]
}
return rs
}
func firstOrgs(os []store.OrgMembership, n int) []store.OrgMembership {
if len(os) > n {
return os[:n]
}
return os
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {