UI tweaks

This commit is contained in:
Jonathon Leight
2026-06-20 13:24:07 -04:00
parent 33f56606a8
commit cb119ea99a
35 changed files with 1112 additions and 316 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"format_on_save": "off"
}
+7
View File
@@ -29,6 +29,13 @@ func (s *Service) SetPermCommand() string {
return fmt.Sprintf("setperm %s 3", s.local.String())
}
// RevokePermCommand returns the repeater CLI command that revokes this server
// identity's access by removing it from the ACL entirely (no numeric argument,
// as opposed to setting it to 0 / guest). Owners run it to revoke MeshTender.
func (s *Service) RevokePermCommand() string {
return fmt.Sprintf("setperm %s", s.local.String())
}
// LoadOrCreate loads the singleton server identity from the store, decrypting
// its seed with masterKey. If none exists, it generates a fresh identity,
// seals the seed, and persists it.
@@ -0,0 +1,27 @@
-- +goose Up
-- Organizations get a public-facing description (orgs are publicly viewable).
ALTER TABLE organizations ADD COLUMN description TEXT NOT NULL DEFAULT '';
-- Per-repeater opt-in to appear on the *public* org map. Distinct from
-- store_location: storing coordinates (for members) does not imply publishing
-- them on a page anonymous visitors can see.
ALTER TABLE repeaters ADD COLUMN public_map BOOLEAN NOT NULL DEFAULT FALSE;
-- Per-user confirmation history. Each successful login round-trip records who
-- reached the repeater and the access learned. The owner's own row is a
-- "self-confirmation"; a row by anyone else "corroborates" that MeshTender can
-- reach the node. The repeaters.confirmed* columns remain a cached "latest".
CREATE TABLE repeater_confirmations (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
repeater_id BIGINT NOT NULL REFERENCES repeaters(id) ON DELETE CASCADE,
user_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
is_admin BOOLEAN NOT NULL,
perms SMALLINT NOT NULL,
confirmed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX repeater_confirmations_repeater_id_idx ON repeater_confirmations(repeater_id);
-- +goose Down
DROP TABLE repeater_confirmations;
ALTER TABLE repeaters DROP COLUMN public_map;
ALTER TABLE organizations DROP COLUMN description;
+30
View File
@@ -84,6 +84,36 @@ func (s *Store) ListOrgRepeaters(ctx context.Context, orgID int64) ([]OrgRepeate
return out, rows.Err()
}
// ListPublicMapRepeaters returns the contributed repeaters an org may show on
// its public map: those whose owner opted into public_map and have coordinates.
func (s *Store) ListPublicMapRepeaters(ctx context.Context, orgID int64) ([]OrgRepeaterInfo, error) {
rows, err := s.pool.Query(ctx, `
SELECT r.id, r.name, COALESCE(NULLIF(ou.display_name, ''), ou.username, '?'),
r.latitude, r.longitude
FROM org_repeaters orp
JOIN repeaters r ON r.id = orp.repeater_id
JOIN users ou ON ou.id = r.owner_id
WHERE orp.org_id = $1 AND r.public_map AND r.latitude IS NOT NULL AND r.longitude IS NOT NULL
ORDER BY r.name`, orgID)
if err != nil {
return nil, fmt.Errorf("list public map repeaters: %w", err)
}
defer rows.Close()
var out []OrgRepeaterInfo
for rows.Next() {
var ri OrgRepeaterInfo
var lat, lon *float64
if err := rows.Scan(&ri.RepeaterID, &ri.Name, &ri.OwnerName, &lat, &lon); err != nil {
return nil, fmt.Errorf("scan public map repeater: %w", err)
}
if lat != nil && lon != nil {
ri.HasLocation, ri.Lat, ri.Lon = true, *lat, *lon
}
out = append(out, ri)
}
return out, rows.Err()
}
// ConsentedVersionID returns the permission version a repeater is pinned to for
// an org, or (0, false) if it isn't contributed there.
func (s *Store) ConsentedVersionID(ctx context.Context, orgID, repeaterID int64) (int64, bool, error) {
+71 -12
View File
@@ -11,10 +11,20 @@ import (
// Org is an organization.
type Org struct {
ID int64
Name string
CreatedBy *int64
CreatedAt time.Time
ID int64
Name string
Description string
CreatedBy *int64
CreatedAt time.Time
}
// OrgSummary is a public directory entry for an organization.
type OrgSummary struct {
ID int64
Name string
Description string
MemberCount int
RepeaterCount int
}
// OrgMembership pairs an org with the querying user's role in it.
@@ -51,8 +61,8 @@ func (s *Store) CreateOrg(ctx context.Context, name string, creatorID int64) (*O
var o Org
if err := tx.QueryRow(ctx,
`INSERT INTO organizations (name, created_by) VALUES ($1, $2)
RETURNING id, name, created_by, created_at`,
name, creatorID).Scan(&o.ID, &o.Name, &o.CreatedBy, &o.CreatedAt); err != nil {
RETURNING id, name, description, created_by, created_at`,
name, creatorID).Scan(&o.ID, &o.Name, &o.Description, &o.CreatedBy, &o.CreatedAt); err != nil {
return nil, fmt.Errorf("insert org: %w", err)
}
if _, err := tx.Exec(ctx,
@@ -90,8 +100,8 @@ func (s *Store) CreateOrg(ctx context.Context, name string, creatorID int64) (*O
func (s *Store) GetOrg(ctx context.Context, id int64) (*Org, error) {
var o Org
err := s.pool.QueryRow(ctx,
`SELECT id, name, created_by, created_at FROM organizations WHERE id = $1`, id).
Scan(&o.ID, &o.Name, &o.CreatedBy, &o.CreatedAt)
`SELECT id, name, description, created_by, created_at FROM organizations WHERE id = $1`, id).
Scan(&o.ID, &o.Name, &o.Description, &o.CreatedBy, &o.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
@@ -101,10 +111,59 @@ func (s *Store) GetOrg(ctx context.Context, id int64) (*Org, error) {
return &o, nil
}
// UpdateOrg updates an org's name and description.
func (s *Store) UpdateOrg(ctx context.Context, orgID int64, name, description string) error {
tag, err := s.pool.Exec(ctx,
`UPDATE organizations SET name = $2, description = $3 WHERE id = $1`, orgID, name, description)
if err != nil {
return fmt.Errorf("update org: %w", err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
// ListPublicOrgs returns every organization with member/repeater counts, for the
// public directory. Orgs are publicly listed by default.
func (s *Store) ListPublicOrgs(ctx context.Context) ([]OrgSummary, error) {
rows, err := s.pool.Query(ctx, `
SELECT o.id, o.name, o.description,
(SELECT count(*) FROM org_members m WHERE m.org_id = o.id),
(SELECT count(*) FROM org_repeaters orp WHERE orp.org_id = o.id)
FROM organizations o
ORDER BY o.name`)
if err != nil {
return nil, fmt.Errorf("list public orgs: %w", err)
}
defer rows.Close()
var out []OrgSummary
for rows.Next() {
var s OrgSummary
if err := rows.Scan(&s.ID, &s.Name, &s.Description, &s.MemberCount, &s.RepeaterCount); err != nil {
return nil, fmt.Errorf("scan org summary: %w", err)
}
out = append(out, s)
}
return out, rows.Err()
}
// OrgCounts returns the member and contributed-repeater counts for an org.
func (s *Store) OrgCounts(ctx context.Context, orgID int64) (members, repeaters int, err error) {
err = s.pool.QueryRow(ctx, `
SELECT (SELECT count(*) FROM org_members WHERE org_id = $1),
(SELECT count(*) FROM org_repeaters WHERE org_id = $1)`, orgID).
Scan(&members, &repeaters)
if err != nil {
return 0, 0, fmt.Errorf("org counts: %w", err)
}
return members, repeaters, nil
}
// ListOrgsForUser returns the orgs a user belongs to with their role.
func (s *Store) ListOrgsForUser(ctx context.Context, userID int64) ([]OrgMembership, error) {
rows, err := s.pool.Query(ctx, `
SELECT o.id, o.name, o.created_by, o.created_at, m.role
SELECT o.id, o.name, o.description, o.created_by, o.created_at, m.role
FROM org_members m JOIN organizations o ON o.id = m.org_id
WHERE m.user_id = $1 ORDER BY o.name`, userID)
if err != nil {
@@ -115,7 +174,7 @@ func (s *Store) ListOrgsForUser(ctx context.Context, userID int64) ([]OrgMembers
for rows.Next() {
var o Org
var role string
if err := rows.Scan(&o.ID, &o.Name, &o.CreatedBy, &o.CreatedAt, &role); err != nil {
if err := rows.Scan(&o.ID, &o.Name, &o.Description, &o.CreatedBy, &o.CreatedAt, &role); err != nil {
return nil, fmt.Errorf("scan org: %w", err)
}
out = append(out, OrgMembership{Org: &o, Role: role})
@@ -295,9 +354,9 @@ func (s *Store) DeleteOrgInvite(ctx context.Context, orgID, inviteID int64) erro
func (s *Store) OrgByInviteToken(ctx context.Context, token string) (*Org, error) {
var o Org
err := s.pool.QueryRow(ctx, `
SELECT o.id, o.name, o.created_by, o.created_at
SELECT o.id, o.name, o.description, o.created_by, o.created_at
FROM org_invites i JOIN organizations o ON o.id = i.org_id
WHERE i.token = $1`, token).Scan(&o.ID, &o.Name, &o.CreatedBy, &o.CreatedAt)
WHERE i.token = $1`, token).Scan(&o.ID, &o.Name, &o.Description, &o.CreatedBy, &o.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
+55 -18
View File
@@ -29,14 +29,26 @@ type Repeater struct {
StoreLocation bool
Latitude *float64
Longitude *float64
// PublicMap opts this repeater into the public org map (independent of
// StoreLocation, which only governs whether coordinates are stored at all).
PublicMap bool
// Shared is true when the row is visible to the querying user via a share
// rather than ownership.
Shared bool
// Owner identity, for display on shared repeaters.
OwnerUsername string
OwnerDisplayName *string
// Confirmation provenance, derived from repeater_confirmations:
// SelfConfirmed = the owner reached it; Corroborators = distinct non-owner
// names that also reached it.
SelfConfirmed bool
Corroborators []string
}
// Corroborated reports whether someone other than the owner has confirmed the
// repeater is reachable.
func (r *Repeater) Corroborated() bool { return len(r.Corroborators) > 0 }
// AccessKnown reports whether the repeater's access level has been determined.
func (r *Repeater) AccessKnown() bool { return r.ConfirmedAdmin != nil }
@@ -64,12 +76,12 @@ func (r *Repeater) OwnerName() string {
func (s *Store) CreateRepeater(ctx context.Context, r *Repeater) (*Repeater, error) {
var out Repeater
err := s.pool.QueryRow(ctx, `
INSERT INTO repeaters (owner_id, name, public_key_hex, radio_freq_hz, radio_bw_hz, radio_sf, radio_cr, store_location)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, owner_id, name, public_key_hex, radio_freq_hz, radio_bw_hz, radio_sf, radio_cr, confirmed, confirmed_at, created_at, store_location`,
r.OwnerID, r.Name, r.PublicKeyHex, r.RadioFreqHz, r.RadioBwHz, r.RadioSF, r.RadioCR, r.StoreLocation).
INSERT INTO repeaters (owner_id, name, public_key_hex, radio_freq_hz, radio_bw_hz, radio_sf, radio_cr, store_location, public_map)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, owner_id, name, public_key_hex, radio_freq_hz, radio_bw_hz, radio_sf, radio_cr, confirmed, confirmed_at, created_at, store_location, public_map`,
r.OwnerID, r.Name, r.PublicKeyHex, r.RadioFreqHz, r.RadioBwHz, r.RadioSF, r.RadioCR, r.StoreLocation, r.PublicMap).
Scan(&out.ID, &out.OwnerID, &out.Name, &out.PublicKeyHex, &out.RadioFreqHz, &out.RadioBwHz,
&out.RadioSF, &out.RadioCR, &out.Confirmed, &out.ConfirmedAt, &out.CreatedAt, &out.StoreLocation)
&out.RadioSF, &out.RadioCR, &out.Confirmed, &out.ConfirmedAt, &out.CreatedAt, &out.StoreLocation, &out.PublicMap)
if isUniqueViolation(err) {
return nil, ErrDuplicate
}
@@ -85,8 +97,13 @@ const repeaterSelect = `
SELECT r.id, r.owner_id, r.name, r.public_key_hex, r.radio_freq_hz, r.radio_bw_hz,
r.radio_sf, r.radio_cr, r.confirmed, r.confirmed_at, r.created_at,
r.confirmed_admin, r.confirmed_perms,
r.store_location, r.latitude, r.longitude,
(r.owner_id <> $1) AS shared, ou.username, ou.display_name
r.store_location, r.latitude, r.longitude, r.public_map,
(r.owner_id <> $1) AS shared, ou.username, ou.display_name,
EXISTS(SELECT 1 FROM repeater_confirmations c
WHERE c.repeater_id = r.id AND c.user_id = r.owner_id) AS self_confirmed,
ARRAY(SELECT DISTINCT COALESCE(NULLIF(cu.display_name, ''), cu.username)
FROM repeater_confirmations c JOIN users cu ON cu.id = c.user_id
WHERE c.repeater_id = r.id AND c.user_id <> r.owner_id) AS corroborators
FROM repeaters r JOIN users ou ON ou.id = r.owner_id`
func scanRepeater(row pgx.Row) (*Repeater, error) {
@@ -94,8 +111,9 @@ func scanRepeater(row pgx.Row) (*Repeater, error) {
err := row.Scan(&r.ID, &r.OwnerID, &r.Name, &r.PublicKeyHex, &r.RadioFreqHz, &r.RadioBwHz,
&r.RadioSF, &r.RadioCR, &r.Confirmed, &r.ConfirmedAt, &r.CreatedAt,
&r.ConfirmedAdmin, &r.ConfirmedPerms,
&r.StoreLocation, &r.Latitude, &r.Longitude,
&r.Shared, &r.OwnerUsername, &r.OwnerDisplayName)
&r.StoreLocation, &r.Latitude, &r.Longitude, &r.PublicMap,
&r.Shared, &r.OwnerUsername, &r.OwnerDisplayName,
&r.SelfConfirmed, &r.Corroborators)
if err != nil {
return nil, err
}
@@ -147,31 +165,50 @@ func (s *Store) GetRepeaterForUser(ctx context.Context, userID, repeaterID int64
return r, nil
}
// SetRepeaterConfirmed marks a repeater confirmed, recording the access level
// learned from the login reply and stamping the time.
func (s *Store) SetRepeaterConfirmed(ctx context.Context, repeaterID int64, admin bool, perms int16) error {
_, err := s.pool.Exec(ctx, `
// SetRepeaterConfirmed marks a repeater confirmed by userID, recording the
// access level learned from the login reply. It updates the cached "latest"
// columns on the repeater and appends a row to the confirmation history (so a
// non-owner confirmation can corroborate the owner's own). Both writes happen
// in one transaction.
func (s *Store) SetRepeaterConfirmed(ctx context.Context, repeaterID, userID int64, admin bool, perms int16) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin: %w", err)
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, `
UPDATE repeaters
SET confirmed = TRUE, confirmed_at = now(), confirmed_admin = $2, confirmed_perms = $3
WHERE id = $1`, repeaterID, admin, perms)
if err != nil {
WHERE id = $1`, repeaterID, admin, perms); err != nil {
return fmt.Errorf("set confirmed: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO repeater_confirmations (repeater_id, user_id, is_admin, perms)
VALUES ($1, $2, $3, $4)`, repeaterID, userID, admin, perms); err != nil {
return fmt.Errorf("record confirmation: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit: %w", err)
}
return nil
}
// UpdateRepeater updates an owned repeater's settings (the public key is fixed).
// When storeLocation is turned off, any stored coordinates are cleared. Returns
// ErrNotFound if the repeater isn't owned by ownerID.
func (s *Store) UpdateRepeater(ctx context.Context, ownerID, repeaterID int64, name string, freq, bw int64, sf, cr int16, storeLocation bool) error {
func (s *Store) UpdateRepeater(ctx context.Context, ownerID, repeaterID int64, name string, freq, bw int64, sf, cr int16, storeLocation, publicMap bool) error {
// public_map only makes sense when coordinates are stored; clear it when
// location storage is turned off (which also clears the coordinates).
tag, err := s.pool.Exec(ctx, `
UPDATE repeaters SET
name = $3, radio_freq_hz = $4, radio_bw_hz = $5, radio_sf = $6, radio_cr = $7,
store_location = $8,
latitude = CASE WHEN $8 THEN latitude ELSE NULL END,
longitude = CASE WHEN $8 THEN longitude ELSE NULL END
longitude = CASE WHEN $8 THEN longitude ELSE NULL END,
public_map = ($8 AND $9)
WHERE id = $1 AND owner_id = $2`,
repeaterID, ownerID, name, freq, bw, sf, cr, storeLocation)
repeaterID, ownerID, name, freq, bw, sf, cr, storeLocation, publicMap)
if err != nil {
return fmt.Errorf("update repeater: %w", err)
}
+1 -1
View File
@@ -177,7 +177,7 @@ func (s *Server) wsConfirm(w http.ResponseWriter, r *http.Request) {
return // context cancelled or a build/transmit error already reported
}
if err := s.store.SetRepeaterConfirmed(ctx, id, lr.IsAdmin, int16(lr.Permissions)); err != nil {
if err := s.store.SetRepeaterConfirmed(ctx, id, uid, lr.IsAdmin, int16(lr.Permissions)); err != nil {
_ = bridge.Status("error", "could not save confirmation: "+err.Error())
return
}
+2 -43
View File
@@ -9,47 +9,6 @@ import (
"github.com/jleight/meshtender/internal/store"
)
// pageRepeaterOrgs shows which orgs a repeater is contributed to and which the
// owner could contribute it to (owner only).
func (s *Server) pageRepeaterOrgs(w http.ResponseWriter, r *http.Request) {
owner := s.auth.CurrentUserID(r.Context())
id, ok := parseID(r)
if !ok {
http.NotFound(w, r)
return
}
rep, err := s.store.GetRepeaterOwned(r.Context(), owner, id)
if err != nil {
http.NotFound(w, r) // owner-only
return
}
contributed, err := s.store.ListRepeaterOrgs(r.Context(), id)
if err != nil {
http.Error(w, "could not load orgs", http.StatusInternalServerError)
return
}
memberships, err := s.store.ListOrgsForUser(r.Context(), owner)
if err != nil {
http.Error(w, "could not load memberships", http.StatusInternalServerError)
return
}
in := map[int64]bool{}
for _, c := range contributed {
in[c.OrgID] = true
}
var available []*store.Org
for _, m := range memberships {
if !in[m.Org.ID] {
available = append(available, m.Org)
}
}
s.render(w, r, "repeater_orgs.html", map[string]any{
"Repeater": rep,
"Contributed": contributed,
"Available": available,
})
}
// orgContext resolves the {id} repeater (owned) and {orgID} the user belongs to.
func (s *Server) orgContext(w http.ResponseWriter, r *http.Request) (*store.Repeater, int64, bool) {
owner := s.auth.CurrentUserID(r.Context())
@@ -187,7 +146,7 @@ func (s *Server) handleContribute(w http.ResponseWriter, r *http.Request) {
http.Error(w, "could not contribute", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/repeaters/"+strconv.FormatInt(rep.ID, 10)+"/orgs", http.StatusSeeOther)
http.Redirect(w, r, "/repeaters/"+strconv.FormatInt(rep.ID, 10)+"/share", http.StatusSeeOther)
}
// handleWithdraw removes the repeater from the org.
@@ -200,5 +159,5 @@ func (s *Server) handleWithdraw(w http.ResponseWriter, r *http.Request) {
http.Error(w, "could not withdraw", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/repeaters/"+strconv.FormatInt(rep.ID, 10)+"/orgs", http.StatusSeeOther)
http.Redirect(w, r, "/repeaters/"+strconv.FormatInt(rep.ID, 10)+"/share", http.StatusSeeOther)
}
+90 -12
View File
@@ -21,18 +21,33 @@ func orgErr(w http.ResponseWriter, r *http.Request, orgID int64, msg string) {
http.Redirect(w, r, "/orgs/"+strconv.FormatInt(orgID, 10)+"?error="+url.QueryEscape(msg), http.StatusSeeOther)
}
// pageOrgs lists the user's organizations and offers to create one.
// pageOrgs is the public organization directory. Everyone sees the list; signed-
// in users also get the create form and a marker on orgs they belong to.
func (s *Server) pageOrgs(w http.ResponseWriter, r *http.Request) {
uid := s.auth.CurrentUserID(r.Context())
orgs, err := s.store.ListOrgsForUser(r.Context(), uid)
all, err := s.store.ListPublicOrgs(r.Context())
if err != nil {
http.Error(w, "could not load orgs", http.StatusInternalServerError)
return
}
s.render(w, r, "orgs.html", map[string]any{
"Orgs": orgs,
"Error": r.URL.Query().Get("error"),
})
data := map[string]any{
"All": all,
"LoggedIn": uid != 0,
"Error": r.URL.Query().Get("error"),
}
if uid != 0 {
mine, err := s.store.ListOrgsForUser(r.Context(), uid)
if err != nil {
http.Error(w, "could not load orgs", http.StatusInternalServerError)
return
}
memberOf := map[int64]string{}
for _, m := range mine {
memberOf[m.Org.ID] = m.Role
}
data["MemberOf"] = memberOf
}
s.render(w, r, "orgs.html", data)
}
// handleCreateOrg creates an org with the current user as its first admin.
@@ -51,7 +66,9 @@ func (s *Server) handleCreateOrg(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/orgs/"+strconv.FormatInt(org.ID, 10), http.StatusSeeOther)
}
// pageOrg shows an org's home (members; admins see management actions).
// pageOrg shows an org's home. Members get the full management view; everyone
// else (anonymous or non-member) gets the public view. Members can preview the
// public view with ?view=public.
func (s *Server) pageOrg(w http.ResponseWriter, r *http.Request) {
uid := s.auth.CurrentUserID(r.Context())
id, ok := orgIDParam(r)
@@ -59,16 +76,20 @@ func (s *Server) pageOrg(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
role, isMember, err := s.store.OrgRole(r.Context(), id, uid)
if err != nil || !isMember {
http.NotFound(w, r) // non-members can't see the org
return
}
org, err := s.store.GetOrg(r.Context(), id)
if err != nil {
http.NotFound(w, r)
return
}
role, isMember, err := s.store.OrgRole(r.Context(), id, uid)
if err != nil {
http.Error(w, "could not load org", http.StatusInternalServerError)
return
}
if !isMember || r.URL.Query().Get("view") == "public" {
s.renderOrgPublic(w, r, org, isMember)
return
}
members, err := s.store.ListOrgMembers(r.Context(), id)
if err != nil {
http.Error(w, "could not load members", http.StatusInternalServerError)
@@ -118,6 +139,63 @@ func (s *Server) pageOrg(w http.ResponseWriter, r *http.Request) {
s.render(w, r, "org.html", data)
}
// renderOrgPublic renders the public-facing org page (name, description, admins,
// counts, and a map of repeaters opted into public display).
func (s *Server) renderOrgPublic(w http.ResponseWriter, r *http.Request, org *store.Org, isMember bool) {
members, err := s.store.ListOrgMembers(r.Context(), org.ID)
if err != nil {
http.Error(w, "could not load org", http.StatusInternalServerError)
return
}
var admins []string
for _, m := range members {
if m.Role == "admin" {
admins = append(admins, m.Name())
}
}
memberCount, repeaterCount, err := s.store.OrgCounts(r.Context(), org.ID)
if err != nil {
http.Error(w, "could not load org", http.StatusInternalServerError)
return
}
pubReps, err := s.store.ListPublicMapRepeaters(r.Context(), org.ID)
if err != nil {
http.Error(w, "could not load org", http.StatusInternalServerError)
return
}
s.render(w, r, "org_public.html", map[string]any{
"Org": org,
"Admins": admins,
"MemberCount": memberCount,
"RepeaterCount": repeaterCount,
"Repeaters": pubReps,
"HasMap": len(pubReps) > 0,
"IsMember": isMember,
})
}
// handleEditOrg updates an org's name and description (admin only).
func (s *Server) handleEditOrg(w http.ResponseWriter, r *http.Request) {
id, ok := s.requireOrgAdmin(w, r)
if !ok {
return
}
name := strings.TrimSpace(r.FormValue("name"))
desc := strings.TrimSpace(r.FormValue("description"))
if name == "" || len(name) > 80 {
orgErr(w, r, id, "Enter an organization name.")
return
}
if len(desc) > 2000 {
desc = desc[:2000]
}
if err := s.store.UpdateOrg(r.Context(), id, name, desc); err != nil {
orgErr(w, r, id, "Could not save changes.")
return
}
http.Redirect(w, r, "/orgs/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
}
// requireOrgAdmin resolves {id} and verifies the current user is an org admin.
func (s *Server) requireOrgAdmin(w http.ResponseWriter, r *http.Request) (int64, bool) {
uid := s.auth.CurrentUserID(r.Context())
+68 -7
View File
@@ -14,12 +14,20 @@ import (
"github.com/jleight/meshtender/internal/store"
)
// pageAddRepeater shows the MeshTender identity/setperm instructions and the
// add-repeater form.
// pageAddRepeater drives the add-repeater wizard. Step 1 ("grant") is a
// mandatory acknowledgment that the owner has granted MeshTender admin on the
// repeater; step 2 ("details") collects the repeater's name/key/radio. The two
// post-creation steps (confirm, contribute) live on pageRepeaterAdded.
func (s *Server) pageAddRepeater(w http.ResponseWriter, r *http.Request) {
step := r.URL.Query().Get("step")
if step != "details" {
step = "grant"
}
s.render(w, r, "add_repeater.html", map[string]any{
"Step": step,
"ServerPubKey": s.identity.PublicKeyHex(),
"SetPermCommand": s.identity.SetPermCommand(),
"RevokeCommand": s.identity.RevokePermCommand(),
"Defaults": s.cfg.DefaultRadio,
"Presets": radioPresets,
"DefaultPresetID": defaultPresetID(s.cfg.DefaultRadio),
@@ -28,7 +36,34 @@ func (s *Server) pageAddRepeater(w http.ResponseWriter, r *http.Request) {
}
func addErr(w http.ResponseWriter, r *http.Request, msg string) {
http.Redirect(w, r, "/repeaters/add?error="+url.QueryEscape(msg), http.StatusSeeOther)
http.Redirect(w, r, "/repeaters/add?step=details&error="+url.QueryEscape(msg), http.StatusSeeOther)
}
// pageRepeaterAdded is the wizard's final two steps for a freshly-added
// repeater: optionally confirm it with a modem now, and optionally contribute
// it to an organization the owner belongs to.
func (s *Server) pageRepeaterAdded(w http.ResponseWriter, r *http.Request) {
uid := s.auth.CurrentUserID(r.Context())
id, ok := parseID(r)
if !ok {
http.NotFound(w, r)
return
}
rep, err := s.store.GetRepeaterOwned(r.Context(), uid, id)
if err != nil {
http.NotFound(w, r)
return
}
orgs, err := s.store.ListOrgsForUser(r.Context(), uid)
if err != nil {
http.Error(w, "could not load orgs", http.StatusInternalServerError)
return
}
s.render(w, r, "repeater_added.html", map[string]any{
"Repeater": rep,
"Orgs": orgs,
"RevokeCommand": s.identity.RevokePermCommand(),
})
}
// handleAddRepeater registers a new repeater (unconfirmed) for the current user.
@@ -53,7 +88,8 @@ func (s *Server) handleAddRepeater(w http.ResponseWriter, r *http.Request) {
return
}
_, err := s.store.CreateRepeater(r.Context(), &store.Repeater{
storeLocation := r.FormValue("store_location") != ""
rep, err := s.store.CreateRepeater(r.Context(), &store.Repeater{
OwnerID: uid,
Name: name,
PublicKeyHex: pubHex,
@@ -61,7 +97,8 @@ func (s *Server) handleAddRepeater(w http.ResponseWriter, r *http.Request) {
RadioBwHz: bw,
RadioSF: int16(sf),
RadioCR: int16(cr),
StoreLocation: r.FormValue("store_location") != "",
StoreLocation: storeLocation,
PublicMap: storeLocation && r.FormValue("public_map") != "",
})
if errors.Is(err, store.ErrDuplicate) {
addErr(w, r, "You already added a repeater with that public key.")
@@ -71,7 +108,8 @@ func (s *Server) handleAddRepeater(w http.ResponseWriter, r *http.Request) {
addErr(w, r, "Could not add repeater.")
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
// Continue the wizard: offer to confirm and contribute.
http.Redirect(w, r, "/repeaters/"+strconv.FormatInt(rep.ID, 10)+"/added", http.StatusSeeOther)
}
// parseRadioForm reads and validates the radio fields from a repeater form.
@@ -103,6 +141,7 @@ func (s *Server) pageEditRepeater(w http.ResponseWriter, r *http.Request) {
"Repeater": rep,
"Presets": radioPresets,
"SelectedPreset": defaultPresetID(config.RadioDefaults{FreqHz: uint32(rep.RadioFreqHz), BwHz: uint32(rep.RadioBwHz), SF: uint8(rep.RadioSF), CR: uint8(rep.RadioCR)}),
"RevokeCommand": s.identity.RevokePermCommand(),
"Error": r.URL.Query().Get("error"),
})
}
@@ -129,13 +168,35 @@ func (s *Server) handleEditRepeater(w http.ResponseWriter, r *http.Request) {
return
}
storeLocation := r.FormValue("store_location") != ""
if err := s.store.UpdateRepeater(r.Context(), uid, id, name, freq, bw, sf, cr, storeLocation); err != nil {
publicMap := r.FormValue("public_map") != ""
if err := s.store.UpdateRepeater(r.Context(), uid, id, name, freq, bw, sf, cr, storeLocation, publicMap); err != nil {
editErr("Could not save changes.")
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// pageDeleteRepeater shows a confirmation page before deleting, reminding the
// owner that removing the repeater here does not revoke MeshTender's access on
// the device.
func (s *Server) pageDeleteRepeater(w http.ResponseWriter, r *http.Request) {
uid := s.auth.CurrentUserID(r.Context())
id, ok := parseID(r)
if !ok {
http.NotFound(w, r)
return
}
rep, err := s.store.GetRepeaterOwned(r.Context(), uid, id)
if err != nil {
http.NotFound(w, r)
return
}
s.render(w, r, "delete_repeater.html", map[string]any{
"Repeater": rep,
"RevokeCommand": s.identity.RevokePermCommand(),
})
}
// handleDeleteRepeater removes a repeater the current user owns.
func (s *Server) handleDeleteRepeater(w http.ResponseWriter, r *http.Request) {
uid := s.auth.CurrentUserID(r.Context())
+29 -5
View File
@@ -40,12 +40,36 @@ func (s *Server) pageShare(w http.ResponseWriter, r *http.Request) {
http.Error(w, "could not load links", http.StatusInternalServerError)
return
}
// Organizations section: orgs this repeater is contributed to, plus orgs the
// owner belongs to but hasn't contributed it to yet.
contributed, err := s.store.ListRepeaterOrgs(r.Context(), id)
if err != nil {
http.Error(w, "could not load orgs", http.StatusInternalServerError)
return
}
memberships, err := s.store.ListOrgsForUser(r.Context(), uid)
if err != nil {
http.Error(w, "could not load memberships", http.StatusInternalServerError)
return
}
in := map[int64]bool{}
for _, c := range contributed {
in[c.OrgID] = true
}
var available []*store.Org
for _, m := range memberships {
if !in[m.Org.ID] {
available = append(available, m.Org)
}
}
s.render(w, r, "share.html", map[string]any{
"Repeater": rep,
"Shares": shares,
"Invites": invites,
"BaseURL": s.absoluteURL(r, ""),
"Error": r.URL.Query().Get("error"),
"Repeater": rep,
"Shares": shares,
"Invites": invites,
"Contributed": contributed,
"Available": available,
"BaseURL": s.absoluteURL(r, ""),
"Error": r.URL.Query().Get("error"),
})
}
+300 -74
View File
@@ -1,119 +1,345 @@
:root {
--bg: #0f1419;
--panel: #1a2230;
--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: #2b3543;
--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.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
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;
}
/* ---------- Top bar ---------- */
.topbar {
display: flex; align-items: center; justify-content: space-between;
padding: 0.8rem 1.2rem; border-bottom: 1px solid var(--line);
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; text-decoration: none; color: var(--ink); }
.topbar nav { display: flex; align-items: center; gap: 1rem; }
.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: 720px; margin: 2rem auto; padding: 0 1rem; }
main { max-width: 800px; margin: 2.2rem auto 4rem; padding: 0 1.1rem; }
/* ---------- Cards & sections ---------- */
.card {
background: var(--panel); border: 1px solid var(--line);
border-radius: 12px; padding: 1.5rem; margin-bottom: 1.5rem;
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; }
h1 { margin-top: 0; }
label { display: block; margin: 1rem 0 0.3rem; font-size: 0.9rem; color: var(--muted); }
.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; border-radius: 8px;
border: 1px solid var(--line); background: #0d1117; color: var(--ink);
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[readonly] { opacity: 0.6; cursor: not-allowed; }
button {
margin-top: 1rem; padding: 0.6rem 1rem; border-radius: 8px;
border: 1px solid var(--line); background: #222c3a; color: var(--ink);
cursor: pointer; font-size: 0.95rem;
input:focus, select:focus {
outline: none; border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(63,185,80,0.18);
}
button.primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); font-weight: 600; }
button.link { background: none; border: none; color: var(--muted); padding: 0; margin: 0; }
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; }
.muted { color: var(--muted); font-size: 0.9rem; }
/* ---------- 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; }
.fallback { margin-top: 1.2rem; border-top: 1px solid var(--line); padding-top: 0.8rem; }
.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: #0d1117; padding: 0.5rem; border-radius: 6px; font-size: 0.8rem; }
.copyrow { display: flex; gap: 0.5rem; align-items: stretch; }
.copyrow code { flex: 1; background: #0d1117; padding: 0.6rem; border-radius: 6px; }
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; }
.repeaters { list-style: none; padding: 0; }
.repeaters li { padding: 0.8rem 0; border-bottom: 1px solid var(--line); }
.rname { font-weight: 600; margin-right: 0.5rem; }
.badge { font-size: 0.75rem; padding: 0.15rem 0.5rem; border-radius: 999px; }
.badge.ok { background: var(--accent); color: var(--accent-ink); }
.badge.pending { background: #3a2f12; color: #e3b341; }
.badge.shared { background: #16313a; color: #58a6ff; }
.badge.warn { background: #3a2f12; color: #e3b341; }
.warn-note { background: #2b2410; border: 1px solid #6a5418; color: #e3b341; padding: 0.6rem 0.8rem; border-radius: 8px; font-size: 0.85rem; margin: 0.5rem 0; }
.banner { background: #3a1416; border: 1px solid var(--error); padding: 0.6rem 1rem; border-radius: 8px; }
.rhead { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.3rem; }
.rmeta { font-size: 0.8rem; margin: 0.3rem 0; }
.ractions { display: flex; align-items: center; gap: 1rem; margin-top: 0.5rem; }
.btnlink { color: var(--accent); text-decoration: none; font-size: 0.9rem; }
.link.danger { color: var(--error); }
.radiorow { display: grid; grid-template-columns: 1fr 1fr 0.5fr 0.5fr; gap: 0.6rem; }
.radiorow label { margin-top: 0.5rem; }
.checkrow { display: flex; gap: 0.5rem; align-items: flex-start; margin-top: 1rem; color: var(--ink); font-size: 0.9rem; }
.checkrow input { width: auto; margin-top: 0.2rem; }
.evlog { list-style: none; padding: 0.8rem; margin-top: 1rem; background: #0d1117; border-radius: 8px; max-height: 240px; overflow-y: auto; font-family: ui-monospace, Menlo, monospace; font-size: 0.85rem; }
/* ---------- 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 ---------- */
.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;
}
.evlog:empty { display: none; }
.ev { padding: 0.15rem 0; border-bottom: 1px solid #161b22; }
.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: #e3b341; }
.ev-debug { color: #58a6ff; word-break: break-all; }
.ev-warning { color: var(--warn); }
.ev-debug { color: var(--info); word-break: break-all; }
.evlog.console { max-height: 320px; }
.ev-sent { color: #58a6ff; }
.ev-sent { color: var(--info); }
.ev-reply { color: var(--ink); white-space: pre-wrap; }
.ev-denied, .ev-noreply { color: #e3b341; }
#cmdform { display: flex; gap: 0.5rem; margin-top: 0.8rem; }
.ev-denied, .ev-noreply { color: var(--warn); }
#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.25rem 0.55rem; font-size: 0.8rem; font-family: ui-monospace, Menlo, monospace; border-radius: 6px; background: #161b22; }
.chip.risky { border-color: var(--error); color: #f0a; }
.console-layout { display: flex; gap: 1rem; margin-top: 0.8rem; align-items: flex-start; }
.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);
}
.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: 0.8rem; }
.console-sidebar h3 { margin: 0 0 0.5rem; font-size: 0.85rem; color: var(--muted); }
.cmdlist { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.3rem; max-height: 320px; overflow-y: auto; }
.cmdlist .chip { width: 100%; text-align: left; }
@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: 0.8rem; } }
.cmdgroup { border: 1px solid var(--line); border-radius: 8px; margin: 0.8rem 0; padding: 0.6rem 0.9rem; }
.cmdgroup legend { color: var(--muted); text-transform: capitalize; font-size: 0.85rem; padding: 0 0.4rem; }
.cmdrow { display: flex; align-items: center; gap: 0.5rem; margin: 0.25rem 0; font-size: 0.9rem; }
.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: #f0a; }
.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, .logtable td { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid var(--line); vertical-align: top; }
.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: 8px; margin: 0.8rem 0; overflow: hidden; }
.session-head { background: #161b22; padding: 0.5rem 0.8rem; font-size: 0.9rem; }
.session .logtable-wrap { padding: 0 0.4rem; }
#map { height: 360px; border-radius: 8px; margin-bottom: 1rem; }
.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; }
.navlink { color: var(--accent); text-decoration: none; font-size: 0.9rem; }
.adminlinks { line-height: 1.8; }
.catrow { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; padding: 0.35rem 0; border-bottom: 1px solid var(--line); }
/* ---------- 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.3rem; font-size: 0.85rem; color: var(--ink); }
.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; }
+62 -12
View File
@@ -2,28 +2,65 @@
{{define "content"}}
{{if .Error}}<p class="error banner">{{.Error}}</p>{{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>
{{if eq .Step "grant"}}
<section class="card">
<h1>Add a repeater</h1>
<h1>Step 1 · Grant MeshTender access</h1>
<p class="muted">
First, grant MeshTender admin on the repeater you own by running this command on it (via its
BLE/USB companion app or another admin client). Then enter the repeater's public key below.
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
<label>Grant-access command — run this on your repeater
<div class="copyrow">
<code id="setperm">{{.SetPermCommand}}</code>
<button type="button" onclick="navigator.clipboard.writeText(document.getElementById('setperm').textContent)">Copy</button>
<code>{{.SetPermCommand}}</code>
<button type="button" onclick="navigator.clipboard.writeText(this.previousElementSibling.textContent)">Copy</button>
</div>
</label>
{{template "revokedoc" .RevokeCommand}}
<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>
</div>
</section>
{{else}}
<section class="card">
<h2>Repeater details</h2>
<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>
<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>
@@ -44,13 +81,19 @@
</div>
<label class="checkrow">
<input type="checkbox" name="store_location" value="1">
<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
organization maps. The location is already public via the repeater's adverts.
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>
<button type="submit" class="primary">Add repeater</button>
<a class="btnlink" href="/">Cancel</a>
<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>
<script>
@@ -70,6 +113,13 @@
fields.forEach(function (el) { el.readOnly = false; });
}
}
function syncPublicMap() {
var on = document.getElementById("store_location").checked;
document.getElementById("public_map_row").style.display = on ? "" : "none";
if (!on) document.querySelector("#public_map_row input").checked = false;
}
applyRegion();
syncPublicMap();
</script>
{{end}}
{{end}}
+3 -3
View File
@@ -3,9 +3,9 @@
<section class="card">
<h1>Admin</h1>
<ul class="adminlinks">
{{if .CanCatalog}}<li><a href="/admin/catalog">Command catalog</a> — edit risk tags and the default command sets.</li>{{end}}
{{if .CanUsers}}<li><a href="/admin/users">Users &amp; capabilities</a> — grant or revoke admin capabilities.</li>{{end}}
{{if .CanCatalog}}<li><a href="/admin/catalog">Command catalog<span class="desc">Edit risk tags and the default command sets.</span></a></li>{{end}}
{{if .CanUsers}}<li><a href="/admin/users">Users &amp; capabilities<span class="desc">Grant or revoke admin capabilities.</span></a></li>{{end}}
</ul>
<p><a href="/">← Back to dashboard</a></p>
<p><a class="backlink" href="/">← Back to dashboard</a></p>
</section>
{{end}}
+1 -1
View File
@@ -25,6 +25,6 @@
{{end}}
</fieldset>
{{end}}
<p><a href="/admin">← Admin</a></p>
<p><a class="backlink" href="/admin">← Admin</a></p>
</section>
{{end}}
+1 -1
View File
@@ -18,6 +18,6 @@
{{if eq .ID $.Self}}<span class="muted">(you)</span>{{end}}
</form>
{{end}}
<p><a href="/admin">← Admin</a></p>
<p><a class="backlink" href="/admin">← Admin</a></p>
</section>
{{end}}
+57 -4
View File
@@ -12,17 +12,70 @@
<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}}
<span class="who">{{.UserName}}</span>
<form method="post" action="/logout" class="inline">
<button type="submit" class="link">Sign out</button>
</form>
<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>
</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>
</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">
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>
<p class="muted">With no level argument, that <strong>removes MeshTender from the repeater's ACL entirely</strong>.</p>
</details>
{{end}}
{{/* repstatus renders confirmation provenance + access badges for a *store.Repeater. */}}
{{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>
{{- else if .SelfConfirmed -}}
<span class="badge shared" title="You reached it from your own modem; no one else has corroborated yet">Self-confirmed</span>
{{- else -}}
<span class="badge ok">Confirmed</span>
{{- end -}}
{{- if .AccessKnown}}{{if .IsAdmin}}<span class="badge ok">admin</span>{{else}}<span class="badge warn">guest only</span>{{end}}{{end -}}
{{- else -}}
<span class="badge pending" title="No one has reached this repeater through MeshTender yet">Unconfirmed</span>
{{- end -}}
{{end}}
+1 -1
View File
@@ -35,6 +35,6 @@
<p class="muted">No commands have been sent yet.</p>
{{end}}
<p><a href="/">← Back to dashboard</a></p>
<p><a class="backlink" href="/">← Back to dashboard</a></p>
</section>
{{end}}
+3 -3
View File
@@ -18,11 +18,11 @@
<ul id="log" class="evlog"></ul>
<p>
<a href="/">← Back to dashboard</a>
<a class="backlink" href="/">← Back to dashboard</a>
{{if .Debug}}
&nbsp;·&nbsp; <a href="/repeaters/{{.Repeater.ID}}/confirm">Disable debug</a>
&nbsp;·&nbsp; <a class="backlink" href="/repeaters/{{.Repeater.ID}}/confirm">Disable debug</a>
{{else}}
&nbsp;·&nbsp; <a href="/repeaters/{{.Repeater.ID}}/confirm?debug=1">Debug: show raw frames</a>
&nbsp;·&nbsp; <a class="backlink" href="/repeaters/{{.Repeater.ID}}/confirm?debug=1">Debug: show raw frames</a>
{{end}}
</p>
</section>
+1 -1
View File
@@ -36,7 +36,7 @@
</aside>
</div>
<p><a href="/">← Back to dashboard</a></p>
<p><a class="backlink" href="/">← Back to dashboard</a></p>
</section>
<script>
+4 -2
View File
@@ -39,8 +39,10 @@
{{end}}
<form method="post" action="/repeaters/{{.Repeater.ID}}/orgs/{{.Org.ID}}/contribute">
<button type="submit" class="primary">I consent — contribute this repeater</button>
<a class="btnlink" href="/repeaters/{{.Repeater.ID}}/orgs">Cancel</a>
<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>
{{end}}
+18 -18
View File
@@ -5,7 +5,7 @@
<section class="card">
<div class="rhead">
<h1 style="flex:1;margin:0">Your repeaters</h1>
<a class="btnlink" href="/repeaters/add">+ Add repeater</a>
<a class="btn primary sm" href="/repeaters/add">+ Add repeater</a>
</div>
{{if .Owned}}
<ul class="repeaters">
@@ -13,12 +13,8 @@
<li>
<div class="rhead">
<span class="rname">{{.Name}}</span>
{{if .Confirmed}}
{{if .AccessKnown}}
{{if .IsAdmin}}<span class="badge ok">Confirmed · admin</span>{{else}}<span class="badge warn">Confirmed · guest</span>{{end}}
{{else}}<span class="badge ok">Confirmed</span>{{end}}
{{else}}<span class="badge pending">Unconfirmed</span>{{end}}
{{if index $.Reconsent .ID}}<a class="badge warn" href="/repeaters/{{.ID}}/orgs">re-consent needed</a>{{end}}
{{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>
@@ -26,15 +22,19 @@
<p class="warn-note">⚠ MeshTender only has <strong>guest</strong> access here. Guest is available to anyone with a blank password, so there's no point operating at this level — re-run <code>setperm &lt;your key&gt; 3</code> on the repeater to grant admin, then re-test.</p>
{{end}}
<div class="ractions">
<a class="btnlink" href="/repeaters/{{.ID}}/console">Console</a>
<a class="btnlink" href="/repeaters/{{.ID}}/confirm">Confirm / test</a>
<a class="btnlink" href="/repeaters/{{.ID}}/share">Share</a>
<a class="btnlink" href="/repeaters/{{.ID}}/orgs">Organizations</a>
<a class="btnlink" href="/repeaters/{{.ID}}/edit">Edit</a>
<a class="btnlink" href="/repeaters/{{.ID}}/log">Log</a>
<form method="post" action="/repeaters/{{.ID}}/delete" class="inline" onsubmit="return confirm('Delete this repeater?')">
<button type="submit" class="link danger">Delete</button>
</form>
<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>
</details>
</div>
</li>
{{end}}
@@ -52,13 +52,13 @@
<li>
<div class="rhead">
<span class="rname">{{.Name}}</span>
{{if .Confirmed}}<span class="badge ok">Confirmed</span>{{else}}<span class="badge pending">Unconfirmed</span>{{end}}
{{template "repstatus" .}}
<span class="badge shared">via {{.OwnerName}}</span>
</div>
<code class="pk">{{.PublicKeyHex}}</code>
<div class="rmeta muted">{{.RadioFreqHz}} Hz · BW {{.RadioBwHz}} · SF{{.RadioSF}} · CR{{.RadioCR}}</div>
<div class="ractions">
<a class="btnlink" href="/repeaters/{{.ID}}/console">Console</a>
<a class="btn primary sm" href="/repeaters/{{.ID}}/console">Console</a>
<a class="btnlink" href="/repeaters/{{.ID}}/confirm">Confirm / test</a>
</div>
</li>
@@ -0,0 +1,27 @@
{{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>
{{end}}
+17 -3
View File
@@ -26,14 +26,22 @@
</div>
<label class="checkrow">
<input type="checkbox" name="store_location" value="1"{{if .Repeater.StoreLocation}} checked{{end}}>
<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>
<button type="submit" class="primary">Save changes</button>
<a class="btnlink" href="/">Cancel</a>
<div class="ractions">
<button type="submit" class="primary">Save changes</button>
<a class="btn ghost" href="/">Cancel</a>
</div>
</form>
{{template "revokedoc" .RevokeCommand}}
</section>
<script>
function applyRegion() {
@@ -52,6 +60,12 @@
fields.forEach(function (el) { el.readOnly = false; });
}
}
function syncPublicMap() {
var on = document.getElementById("store_location").checked;
document.getElementById("public_map_row").style.display = on ? "" : "none";
if (!on) document.querySelector("#public_map_row input").checked = false;
}
applyRegion();
syncPublicMap();
</script>
{{end}}
+10 -8
View File
@@ -4,27 +4,29 @@
{{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 href="/">← Go to dashboard</a></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>
<p>
<a class="btnlink" href="/login?next={{.Next}}">Sign in</a>
&nbsp;·&nbsp;
<a class="btnlink" href="/signup?next={{.Next}}">Create account</a>
</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>
<p><a href="/repeaters/{{.Repeater.ID}}/share">Manage sharing</a> · <a href="/">Dashboard</a></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 href="/">← Go to dashboard</a></p>
<p><a class="backlink" href="/">← Go to dashboard</a></p>
{{else}}{{/* confirm */}}
<h1>Accept invite</h1>
+20 -8
View File
@@ -4,15 +4,27 @@
<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 .IsAdmin}}
{{if .Org.Description}}<p>{{.Org.Description}}</p>{{end}}
<div class="ractions">
<a class="btnlink" href="/orgs/{{.Org.ID}}/permissions">Edit permissions</a>
{{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>
</form>
</div>
{{end}}
<form method="post" action="/orgs/{{.Org.ID}}/leave" class="inline" onsubmit="return confirm('Leave this organization?')">
<button type="submit" class="link danger">Leave organization</button>
</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>
{{end}}
<section class="card">
<h2>Members</h2>
@@ -23,7 +35,7 @@
<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">
<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}}
@@ -63,7 +75,7 @@
<li class="rhead">
<span class="rname">{{.Name}}</span>
<span class="muted">owner {{.OwnerName}}</span>
<a class="btnlink" href="/repeaters/{{.RepeaterID}}/console">Console</a>
<a class="btn primary sm" style="margin-left:auto" href="/repeaters/{{.RepeaterID}}/console">Console</a>
</li>
{{end}}
</ul>
@@ -100,5 +112,5 @@
</section>
{{end}}
<p><a href="/orgs">← All organizations</a></p>
<p><a class="backlink" href="/orgs">← All organizations</a></p>
{{end}}
+6 -7
View File
@@ -4,21 +4,20 @@
{{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 href="/orgs">← Organizations</a></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>
<p>
<a class="btnlink" href="/login?next={{.Next}}">Sign in</a>
&nbsp;·&nbsp;
<a class="btnlink" href="/signup?next={{.Next}}">Create account</a>
</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 href="/orgs/{{.Org.ID}}">Go to {{.Org.Name}}</a></p>
<p><a class="btn primary" href="/orgs/{{.Org.ID}}">Go to {{.Org.Name}}</a></p>
{{else}}{{/* confirm */}}
<h1>Join {{.Org.Name}}</h1>
+4 -2
View File
@@ -25,8 +25,10 @@
<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>
<button type="submit" class="primary">Publish new version</button>
<a class="btnlink" href="/orgs/{{.Org.ID}}">Cancel</a>
<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>
{{end}}
+43
View File
@@ -0,0 +1,43 @@
{{define "title"}}{{.Org.Name}} · MeshTender{{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>
<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>
{{if .HasMap}}
<section class="card">
<h2 style="margin-top:0">Public repeater map</h2>
<p class="muted">Repeaters whose owners chose to show them publicly.</p>
<link rel="stylesheet" href="/static/leaflet.css">
<div id="map"></div>
<script src="/static/leaflet.js"></script>
<script>
var pts = [
{{range .Repeaters}}{{if .HasLocation}}{name: {{.Name}}, lat: {{.Lat}}, lon: {{.Lon}}},{{end}}{{end}}
];
var map = L.map('map');
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19, attribution: '&copy; OpenStreetMap'
}).addTo(map);
var group = L.featureGroup(pts.map(function (p) {
return L.circleMarker([p.lat, p.lon], { radius: 7, color: '#3fb950', fillOpacity: 0.7 }).bindPopup(p.name);
})).addTo(map);
map.fitBounds(group.getBounds().pad(0.2));
</script>
</section>
{{end}}
<p><a class="backlink" href="/orgs">← All organizations</a></p>
{{end}}
+20 -9
View File
@@ -4,29 +4,40 @@
<section class="card">
<h1>Organizations</h1>
<p class="muted">
Join an organization to let its admins help keep your repeaters in spec — under a permission
envelope you review and consent to per repeater. A step up in trust from one-off sharing.
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 .Orgs}}
{{if .All}}
<ul class="repeaters">
{{range .Orgs}}
<li class="rhead">
<a class="rname" href="/orgs/{{.Org.ID}}">{{.Org.Name}}</a>
{{if eq .Role "admin"}}<span class="badge ok">admin</span>{{else}}<span class="badge shared">member</span>{{end}}
{{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">You're not in any organizations yet.</p>
<p class="muted">No organizations yet.</p>
{{end}}
</section>
{{if .LoggedIn}}
<section class="card">
<h2>Create an organization</h2>
<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>
{{end}}
{{end}}
@@ -0,0 +1,60 @@
{{define "title"}}Repeater added · MeshTender{{end}}
{{define "content"}}
<ol class="steps">
<li class="done">Grant access</li>
<li class="done">Repeater details</li>
<li class="active">Confirm</li>
<li class="active">Share</li>
</ol>
<section class="card">
<h1>✓ “{{.Repeater.Name}}” added</h1>
<p class="muted">
It's registered but <strong>unconfirmed</strong> — MeshTender hasn't reached it yet. The two steps
below are optional; your repeater is already usable by anyone you share it with.
</p>
</section>
<section class="card">
<h2 style="margin-top:0">Step 3 · Confirm it works <span class="muted">(optional)</span></h2>
<p class="muted">
Connect a MeshCore KISS modem and have MeshTender login to verify it has admin access. Confirming
now marks it <span class="badge shared">Self-confirmed</span>. Later, if another member reaches it
from their modem, it becomes <span class="badge ok">✓ Corroborated</span> — proof that more than
one person can reach it.
</p>
<p class="muted">No modem handy? Skip this — anyone you share it with can corroborate it for you later.</p>
<div class="ractions">
<a class="btn primary" href="/repeaters/{{.Repeater.ID}}/confirm">Connect modem &amp; confirm now</a>
<a class="btn ghost" href="/">Skip for now</a>
</div>
</section>
<section class="card">
<h2 style="margin-top:0">Step 4 · Contribute to an organization <span class="muted">(optional)</span></h2>
{{if .Orgs}}
<p class="muted">
Let an organization's admins and members help operate this repeater. You'll be aasked to review and
consent to the list of commands that organization members and admins will be able to run on your
repeater. You can withdraw anytime.
</p>
<ul class="repeaters">
{{range .Orgs}}
<li class="rhead">
<span class="rname">{{.Org.Name}}</span>
{{if eq .Role "admin"}}<span class="badge ok">admin</span>{{else}}<span class="badge shared">member</span>{{end}}
<a class="btn sm" style="margin-left:auto" href="/repeaters/{{$.Repeater.ID}}/orgs/{{.Org.ID}}/contribute">Review &amp; contribute</a>
</li>
{{end}}
</ul>
{{else}}
<p class="muted">
You're not in any organizations yet. Joining one lets its admins help keep your repeater in spec.
<a class="navlink" href="/orgs">Browse organizations →</a>
</p>
{{end}}
<div class="ractions">
<a class="btn primary" href="/">Done — go to dashboard</a>
</div>
</section>
{{end}}
-46
View File
@@ -1,46 +0,0 @@
{{define "title"}}Organizations · {{.Repeater.Name}} · MeshTender{{end}}
{{define "content"}}
<section class="card">
<h1>Organizations · {{.Repeater.Name}}</h1>
<p class="muted">
Contributing this repeater to an organization lets that org's admins/members run their permitted
commands on it (over the mesh) — within the envelope you consent to. You can withdraw anytime.
</p>
<h2>Contributed to</h2>
{{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}}
<h2>Contribute to</h2>
<ul class="repeaters">
{{range .Available}}
<li class="rhead">
<span class="rname">{{.Name}}</span>
<a class="btnlink" href="/repeaters/{{$.Repeater.ID}}/orgs/{{.ID}}/contribute">Review &amp; contribute</a>
</li>
{{end}}
</ul>
{{else}}
<p class="muted">Join more organizations to contribute this repeater to them.</p>
{{end}}
<p><a href="/">← Back to dashboard</a></p>
</section>
{{end}}
+62 -10
View File
@@ -1,15 +1,67 @@
{{define "title"}}Share {{.Repeater.Name}} · MeshTender{{end}}
{{define "title"}}Sharing · {{.Repeater.Name}} · MeshTender{{end}}
{{define "content"}}
{{if .Error}}<p class="error banner">{{.Error}}</p>{{end}}
<section class="card">
<h1>Share “{{.Repeater.Name}}”</h1>
<h1>Sharing · “{{.Repeater.Name}}”</h1>
<p class="muted">
Anyone with the share link can accept it (while signed in) to control this repeater from their
own browser-connected KISS modem. All commands still use MeshTender's single identity, so you
never hand out keys. Revoke the link anytime, or remove individuals below.
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>
<h2>Share links</h2>
<h3>Contributed to</h3>
{{if .Contributed}}
<ul class="repeaters">
{{range .Contributed}}
<li class="rhead">
<span class="rname">{{.OrgName}}</span>
<span class="muted">consented to v{{.ConsentedVersion}}{{if .NeedsReconsent}} · org is now on v{{.CurrentVersion}}{{end}}</span>
{{if .NeedsReconsent}}
<a class="btnlink" href="/repeaters/{{$.Repeater.ID}}/orgs/{{.OrgID}}/contribute">Review changes</a>
{{end}}
<form method="post" action="/repeaters/{{$.Repeater.ID}}/orgs/{{.OrgID}}/withdraw" class="inline" onsubmit="return confirm('Withdraw this repeater from {{.OrgName}}?')">
<button type="submit" class="link danger">Withdraw</button>
</form>
</li>
{{end}}
</ul>
{{else}}
<p class="muted">Not contributed to any organization.</p>
{{end}}
{{if .Available}}
<h3>Contribute to</h3>
<ul class="repeaters">
{{range .Available}}
<li class="rhead">
<span class="rname">{{.Name}}</span>
<a class="btnlink" style="margin-left:auto" href="/repeaters/{{$.Repeater.ID}}/orgs/{{.ID}}/contribute">Review &amp; contribute</a>
</li>
{{end}}
</ul>
{{else if .Contributed}}
{{else}}
<p class="muted">Join an organization to contribute this repeater to it.</p>
{{end}}
</section>
<section class="card">
<h2 style="margin-top:0">People</h2>
<p class="muted">
Share single-use links with specific people. The recipient signs in and accepts to control this
repeater from their own browser-connected KISS modem.
</p>
<h3>Share links</h3>
<p class="muted">Each link works once. Mint one per person and label it so you remember who it's for.</p>
<form method="post" action="/repeaters/{{.Repeater.ID}}/share/link">
<label>Description (optional)
@@ -43,14 +95,14 @@
<p class="muted">No links yet.</p>
{{end}}
<h2>People with access</h2>
<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" href="/repeaters/{{$.Repeater.ID}}/share/{{.UserID}}/commands">Edit commands</a>
<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>
@@ -61,7 +113,7 @@
{{else}}
<p class="muted">No one has accepted yet.</p>
{{end}}
<p><a href="/">← Back to dashboard</a></p>
</section>
<p><a class="backlink" href="/">← Back to dashboard</a></p>
{{end}}
+4 -2
View File
@@ -22,8 +22,10 @@
{{end}}
</fieldset>
{{end}}
<button type="submit" class="primary">Save commands</button>
<a class="btnlink" href="/repeaters/{{.Repeater.ID}}/share">Cancel</a>
<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>
<script>
+5 -3
View File
@@ -74,6 +74,8 @@ func (s *Server) routes() {
r.Post("/api/login/finish", s.auth.LoginFinish)
r.Get("/invite/{token}", s.pageInvite) // public: handles logged-out state
r.Get("/org-invite/{token}", s.pageOrgInvite) // public: handles logged-out state
r.Get("/orgs", s.pageOrgs) // public: organization directory
r.Get("/orgs/{id}", s.pageOrg) // public: org page (public view for non-members)
// Authenticated area.
r.Group(func(r chi.Router) {
@@ -82,8 +84,10 @@ func (s *Server) routes() {
r.Post("/logout", s.handleLogout)
r.Get("/repeaters/add", s.pageAddRepeater)
r.Post("/repeaters", s.handleAddRepeater)
r.Get("/repeaters/{id}/added", s.pageRepeaterAdded)
r.Get("/repeaters/{id}/edit", s.pageEditRepeater)
r.Post("/repeaters/{id}/edit", s.handleEditRepeater)
r.Get("/repeaters/{id}/delete", s.pageDeleteRepeater)
r.Post("/repeaters/{id}/delete", s.handleDeleteRepeater)
r.Get("/repeaters/{id}/confirm", s.pageConfirm)
r.Get("/repeaters/{id}/ws", s.wsConfirm)
@@ -96,15 +100,13 @@ func (s *Server) routes() {
r.Post("/repeaters/{id}/unshare", s.handleUnshare)
r.Get("/repeaters/{id}/share/{userID}/commands", s.pageShareCommands)
r.Post("/repeaters/{id}/share/{userID}/commands", s.handleSetShareCommands)
r.Get("/repeaters/{id}/orgs", s.pageRepeaterOrgs)
r.Get("/repeaters/{id}/orgs/{orgID}/contribute", s.pageContribute)
r.Post("/repeaters/{id}/orgs/{orgID}/contribute", s.handleContribute)
r.Post("/repeaters/{id}/orgs/{orgID}/withdraw", s.handleWithdraw)
r.Post("/invite/{token}/accept", s.handleAcceptInvite)
r.Get("/orgs", s.pageOrgs)
r.Post("/orgs", s.handleCreateOrg)
r.Get("/orgs/{id}", s.pageOrg)
r.Post("/orgs/{id}/edit", s.handleEditOrg)
r.Post("/orgs/{id}/leave", s.handleLeaveOrg)
r.Post("/orgs/{id}/invite", s.handleCreateOrgInvite)
r.Post("/orgs/{id}/invite/delete", s.handleDeleteOrgInvite)