diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 0000000..2f5c674 --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,3 @@ +{ + "format_on_save": "off" +} diff --git a/internal/identity/service.go b/internal/identity/service.go index e8cf294..191ee0f 100644 --- a/internal/identity/service.go +++ b/internal/identity/service.go @@ -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. diff --git a/internal/store/migrations/0009_personas.sql b/internal/store/migrations/0009_personas.sql new file mode 100644 index 0000000..35e8d0e --- /dev/null +++ b/internal/store/migrations/0009_personas.sql @@ -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; diff --git a/internal/store/org_repeaters.go b/internal/store/org_repeaters.go index be6ad16..6edde20 100644 --- a/internal/store/org_repeaters.go +++ b/internal/store/org_repeaters.go @@ -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) { diff --git a/internal/store/orgs.go b/internal/store/orgs.go index 2d43d94..964e3e7 100644 --- a/internal/store/orgs.go +++ b/internal/store/orgs.go @@ -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 } diff --git a/internal/store/repeaters.go b/internal/store/repeaters.go index 7c18a6c..9efa3fa 100644 --- a/internal/store/repeaters.go +++ b/internal/store/repeaters.go @@ -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) } diff --git a/internal/web/confirm.go b/internal/web/confirm.go index 6bd44ce..473dfd6 100644 --- a/internal/web/confirm.go +++ b/internal/web/confirm.go @@ -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 } diff --git a/internal/web/contribute.go b/internal/web/contribute.go index c0ace1a..9328d96 100644 --- a/internal/web/contribute.go +++ b/internal/web/contribute.go @@ -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) } diff --git a/internal/web/orgs.go b/internal/web/orgs.go index b3d4c99..91ee638 100644 --- a/internal/web/orgs.go +++ b/internal/web/orgs.go @@ -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()) diff --git a/internal/web/repeaters.go b/internal/web/repeaters.go index 4247a00..5e2cc44 100644 --- a/internal/web/repeaters.go +++ b/internal/web/repeaters.go @@ -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()) diff --git a/internal/web/shares.go b/internal/web/shares.go index 0b3aa45..ae13b36 100644 --- a/internal/web/shares.go +++ b/internal/web/shares.go @@ -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"), }) } diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 681760b..e3b8d74 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -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
) ---------- */ +.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; } diff --git a/internal/web/templates/add_repeater.html b/internal/web/templates/add_repeater.html index 353e701..784fa08 100644 --- a/internal/web/templates/add_repeater.html +++ b/internal/web/templates/add_repeater.html @@ -2,28 +2,65 @@ {{define "content"}} {{if .Error}}{{end}} +
    +
  1. Grant access
  2. +
  3. Repeater details
  4. +
  5. Confirm
  6. +
  7. Share
  8. +
+ +{{if eq .Step "grant"}}
-

Add a repeater

+

Step 1 · Grant MeshTender access

- 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).

+ +
+

⚠ This grants full admin control to MeshTender

+
    +
  • This grants admin to MeshTender itselfnot to other + MeshTender users. Letting specific people operate the repeater is a separate step you control later + via sharing and contributing to organizations.
  • +
  • MeshTender will not abuse this access — but that is a promise, not a technical + limit.
  • +
  • This is a standing grant. It remains until you revoke it on the repeater + (see below) — removing the repeater from this site does not revoke MeshTender's access.
  • +
+
+ -
+{{else}}
-

Repeater details

+

Step 2 · Repeater details

+

Tell MeshTender which repeater you just granted access to, and how its radio is configured.

{{end}} +{{end}} diff --git a/internal/web/templates/admin.html b/internal/web/templates/admin.html index d41737c..77781eb 100644 --- a/internal/web/templates/admin.html +++ b/internal/web/templates/admin.html @@ -3,9 +3,9 @@

Admin

-

← Back to dashboard

+

← Back to dashboard

{{end}} diff --git a/internal/web/templates/admin_catalog.html b/internal/web/templates/admin_catalog.html index e5f52ad..377dbcc 100644 --- a/internal/web/templates/admin_catalog.html +++ b/internal/web/templates/admin_catalog.html @@ -25,6 +25,6 @@ {{end}} {{end}} -

← Admin

+

← Admin

{{end}} diff --git a/internal/web/templates/admin_users.html b/internal/web/templates/admin_users.html index 5671774..00dea88 100644 --- a/internal/web/templates/admin_users.html +++ b/internal/web/templates/admin_users.html @@ -18,6 +18,6 @@ {{if eq .ID $.Self}}(you){{end}} {{end}} -

← Admin

+

← Admin

{{end}} diff --git a/internal/web/templates/base.html b/internal/web/templates/base.html index 500252f..224c69d 100644 --- a/internal/web/templates/base.html +++ b/internal/web/templates/base.html @@ -12,17 +12,70 @@ 📡 MeshTender {{if .UserName}} + {{else}} + {{end}}
{{block "content" .}}{{end}}
+ {{end}} + +{{/* revokedoc explains how to revoke MeshTender's access. Dot is the revoke command string. */}} +{{define "revokedoc"}} +
+ How to revoke MeshTender's access later +

+ Granting access is a standing trust: MeshTender keeps admin on your repeater until you + remove it — deleting the repeater from this site does not revoke it. To revoke, run this + on the repeater (via its BLE/USB companion app or another admin client): +

+
+ {{.}} + +
+

With no level argument, that removes MeshTender from the repeater's ACL entirely.

+
+{{end}} + +{{/* repstatus renders confirmation provenance + access badges for a *store.Repeater. */}} +{{define "repstatus"}} +{{- if .Confirmed -}} + {{- if .Corroborated -}} + ✓ Corroborated + {{- else if .SelfConfirmed -}} + Self-confirmed + {{- else -}} + Confirmed + {{- end -}} + {{- if .AccessKnown}}{{if .IsAdmin}}admin{{else}}guest only{{end}}{{end -}} +{{- else -}} + Unconfirmed +{{- end -}} +{{end}} diff --git a/internal/web/templates/command_log.html b/internal/web/templates/command_log.html index 6c68325..d0ac1c2 100644 --- a/internal/web/templates/command_log.html +++ b/internal/web/templates/command_log.html @@ -35,6 +35,6 @@

No commands have been sent yet.

{{end}} -

← Back to dashboard

+

← Back to dashboard

{{end}} diff --git a/internal/web/templates/confirm.html b/internal/web/templates/confirm.html index b7a1941..d2d7c07 100644 --- a/internal/web/templates/confirm.html +++ b/internal/web/templates/confirm.html @@ -18,11 +18,11 @@

- ← Back to dashboard + ← Back to dashboard {{if .Debug}} -  ·  Disable debug +  ·  Disable debug {{else}} -  ·  Debug: show raw frames +  ·  Debug: show raw frames {{end}}

diff --git a/internal/web/templates/console.html b/internal/web/templates/console.html index 8e2ccc7..ba9a3bc 100644 --- a/internal/web/templates/console.html +++ b/internal/web/templates/console.html @@ -36,7 +36,7 @@ -

← Back to dashboard

+

← Back to dashboard

{{end}} diff --git a/internal/web/templates/invite.html b/internal/web/templates/invite.html index c670ffd..e6975c2 100644 --- a/internal/web/templates/invite.html +++ b/internal/web/templates/invite.html @@ -4,27 +4,29 @@ {{if eq .State "invalid"}}

Invalid link

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.

-

← Go to dashboard

+

← Go to dashboard

{{else if eq .State "auth_required"}}

You've been invited

You've been invited to control {{.Repeater.Name}}, shared by {{.Repeater.OwnerName}}.

Sign in or create an account to accept.

-

- Sign in -  ·  - Create account -

+
+ Sign in + Create account +
{{else if eq .State "owner"}}

This is your repeater

You own {{.Repeater.Name}} — no need to accept your own link.

-

Manage sharing · Dashboard

+
+ Manage sharing + Dashboard +
{{else if eq .State "already"}}

Already shared with you

You already have access to {{.Repeater.Name}}.

-

← Go to dashboard

+

← Go to dashboard

{{else}}{{/* confirm */}}

Accept invite

diff --git a/internal/web/templates/org.html b/internal/web/templates/org.html index 6ab7dd8..b5623ba 100644 --- a/internal/web/templates/org.html +++ b/internal/web/templates/org.html @@ -4,15 +4,27 @@

{{.Org.Name}}

You are {{if .IsAdmin}}an admin{{else}}a member{{end}} of this organization.

- {{if .IsAdmin}} + {{if .Org.Description}}

{{.Org.Description}}

{{end}}
- Edit permissions + {{if .IsAdmin}}⚙ Edit permissions{{end}} + 🌐 View public page +
+ +
- {{end}} -
- +
+ +{{if .IsAdmin}} +
+

Organization profile (public)

+

Name and description are shown on the public organization page and directory.

+ + + +
+{{end}}

Members

@@ -23,7 +35,7 @@ @{{.Username}} {{if eq .Role "admin"}}admin{{else}}member{{end}} {{if and $.IsAdmin (ne .UserID $.Self)}} -
+ {{if eq .Role "admin"}} {{else}} @@ -63,7 +75,7 @@
  • {{.Name}} owner {{.OwnerName}} - Console + ▸ Console
  • {{end}} @@ -100,5 +112,5 @@
    {{end}} -

    ← All organizations

    +

    ← All organizations

    {{end}} diff --git a/internal/web/templates/org_invite.html b/internal/web/templates/org_invite.html index b10b4ca..7aaa44f 100644 --- a/internal/web/templates/org_invite.html +++ b/internal/web/templates/org_invite.html @@ -4,21 +4,20 @@ {{if eq .State "invalid"}}

    Invalid link

    This join link is no longer valid — it may have been revoked.

    -

    ← Organizations

    +

    ← Organizations

    {{else if eq .State "auth_required"}}

    Join {{.Org.Name}}

    Sign in or create an account to join as a member.

    -

    - Sign in -  ·  - Create account -

    +
    + Sign in + Create account +
    {{else if eq .State "already"}}

    Already a member

    You're already in {{.Org.Name}}.

    -

    Go to {{.Org.Name}}

    +

    Go to {{.Org.Name}}

    {{else}}{{/* confirm */}}

    Join {{.Org.Name}}

    diff --git a/internal/web/templates/org_permissions.html b/internal/web/templates/org_permissions.html index bd120cd..e863fb8 100644 --- a/internal/web/templates/org_permissions.html +++ b/internal/web/templates/org_permissions.html @@ -25,8 +25,10 @@ - - Cancel +
    + + Cancel +
    {{end}} diff --git a/internal/web/templates/org_public.html b/internal/web/templates/org_public.html new file mode 100644 index 0000000..4c9c2ae --- /dev/null +++ b/internal/web/templates/org_public.html @@ -0,0 +1,43 @@ +{{define "title"}}{{.Org.Name}} · MeshTender{{end}} +{{define "content"}} +
    +

    {{.Org.Name}}

    + {{if .IsMember}}

    Previewing the public page. ← Back to member view

    {{end}} + {{if .Org.Description}}

    {{.Org.Description}}

    {{else}}

    This organization hasn't added a description yet.

    {{end}} +
    {{.MemberCount}} member{{if ne .MemberCount 1}}s{{end}} · {{.RepeaterCount}} repeater{{if ne .RepeaterCount 1}}s{{end}}
    +
    + +
    +

    Admins

    + {{if .Admins}} + + {{else}}

    No admins listed.

    {{end}} +
    + +{{if .HasMap}} +
    +

    Public repeater map

    +

    Repeaters whose owners chose to show them publicly.

    + +
    + + +
    +{{end}} + +

    ← All organizations

    +{{end}} diff --git a/internal/web/templates/orgs.html b/internal/web/templates/orgs.html index dc89295..0436d89 100644 --- a/internal/web/templates/orgs.html +++ b/internal/web/templates/orgs.html @@ -4,29 +4,40 @@

    Organizations

    - 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.

    - {{if .Orgs}} + {{if .All}} {{else}} -

    You're not in any organizations yet.

    +

    No organizations yet.

    {{end}}
    +{{if .LoggedIn}}
    -

    Create an organization

    +

    Create an organization

    You'll be its first admin.

    +{{else}} +
    +

    Want to start or join one? Sign in or create an account →

    +
    +{{end}} {{end}} diff --git a/internal/web/templates/repeater_added.html b/internal/web/templates/repeater_added.html new file mode 100644 index 0000000..b8b4288 --- /dev/null +++ b/internal/web/templates/repeater_added.html @@ -0,0 +1,60 @@ +{{define "title"}}Repeater added · MeshTender{{end}} +{{define "content"}} +
      +
    1. Grant access
    2. +
    3. Repeater details
    4. +
    5. Confirm
    6. +
    7. Share
    8. +
    + +
    +

    ✓ “{{.Repeater.Name}}” added

    +

    + It's registered but unconfirmed — MeshTender hasn't reached it yet. The two steps + below are optional; your repeater is already usable by anyone you share it with. +

    +
    + +
    +

    Step 3 · Confirm it works (optional)

    +

    + Connect a MeshCore KISS modem and have MeshTender login to verify it has admin access. Confirming + now marks it Self-confirmed. Later, if another member reaches it + from their modem, it becomes ✓ Corroborated — proof that more than + one person can reach it. +

    +

    No modem handy? Skip this — anyone you share it with can corroborate it for you later.

    + +
    + +
    +

    Step 4 · Contribute to an organization (optional)

    + {{if .Orgs}} +

    + 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. +

    + + {{else}} +

    + You're not in any organizations yet. Joining one lets its admins help keep your repeater in spec. + Browse organizations → +

    + {{end}} + +
    +{{end}} diff --git a/internal/web/templates/repeater_orgs.html b/internal/web/templates/repeater_orgs.html deleted file mode 100644 index 719542c..0000000 --- a/internal/web/templates/repeater_orgs.html +++ /dev/null @@ -1,46 +0,0 @@ -{{define "title"}}Organizations · {{.Repeater.Name}} · MeshTender{{end}} -{{define "content"}} -
    -

    Organizations · {{.Repeater.Name}}

    -

    - 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. -

    - -

    Contributed to

    - {{if .Contributed}} - - {{else}} -

    Not contributed to any organization.

    - {{end}} - - {{if .Available}} -

    Contribute to

    - - {{else}} -

    Join more organizations to contribute this repeater to them.

    - {{end}} - -

    ← Back to dashboard

    -
    -{{end}} diff --git a/internal/web/templates/share.html b/internal/web/templates/share.html index 06ea26a..0552eb1 100644 --- a/internal/web/templates/share.html +++ b/internal/web/templates/share.html @@ -1,15 +1,67 @@ -{{define "title"}}Share {{.Repeater.Name}} · MeshTender{{end}} +{{define "title"}}Sharing · {{.Repeater.Name}} · MeshTender{{end}} {{define "content"}} {{if .Error}}{{end}} +
    -

    Share “{{.Repeater.Name}}”

    +

    Sharing · “{{.Repeater.Name}}”

    - 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. +

    +
    + +
    +

    Organizations

    +

    + 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.

    -

    Share links

    +

    Contributed to

    + {{if .Contributed}} + + {{else}} +

    Not contributed to any organization.

    + {{end}} + + {{if .Available}} +

    Contribute to

    + + {{else if .Contributed}} + {{else}} +

    Join an organization to contribute this repeater to it.

    + {{end}} +
    + +
    +

    People

    +

    + Share single-use links with specific people. The recipient signs in and accepts to control this + repeater from their own browser-connected KISS modem. +

    + +

    Share links

    Each link works once. Mint one per person and label it so you remember who it's for.

    + +

    ← Back to dashboard

    {{end}} diff --git a/internal/web/templates/share_commands.html b/internal/web/templates/share_commands.html index 27b94d6..447f6c8 100644 --- a/internal/web/templates/share_commands.html +++ b/internal/web/templates/share_commands.html @@ -22,8 +22,10 @@ {{end}} {{end}} - - Cancel +
    + + Cancel +