mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-01 17:38:15 +00:00
UI tweaks
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"format_on_save": "off"
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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())
|
||||
|
||||
@@ -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
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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,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 & 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 & 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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
· <a href="/repeaters/{{.Repeater.ID}}/confirm">Disable debug</a>
|
||||
· <a class="backlink" href="/repeaters/{{.Repeater.ID}}/confirm">Disable debug</a>
|
||||
{{else}}
|
||||
· <a href="/repeaters/{{.Repeater.ID}}/confirm?debug=1">Debug: show raw frames</a>
|
||||
· <a class="backlink" href="/repeaters/{{.Repeater.ID}}/confirm?debug=1">Debug: show raw frames</a>
|
||||
{{end}}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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 <your key> 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}}
|
||||
@@ -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}}
|
||||
|
||||
@@ -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>
|
||||
·
|
||||
<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>
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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>
|
||||
·
|
||||
<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>
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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: '© 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}}
|
||||
@@ -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 & confirm now</a>
|
||||
<a class="btn ghost" href="/">Skip for now</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 style="margin-top:0">Step 4 · Contribute to an organization <span class="muted">(optional)</span></h2>
|
||||
{{if .Orgs}}
|
||||
<p class="muted">
|
||||
Let an organization's admins and members help operate this repeater. You'll be aasked to review and
|
||||
consent to the list of commands that organization members and admins will be able to run on your
|
||||
repeater. You can withdraw anytime.
|
||||
</p>
|
||||
<ul class="repeaters">
|
||||
{{range .Orgs}}
|
||||
<li class="rhead">
|
||||
<span class="rname">{{.Org.Name}}</span>
|
||||
{{if eq .Role "admin"}}<span class="badge ok">admin</span>{{else}}<span class="badge shared">member</span>{{end}}
|
||||
<a class="btn sm" style="margin-left:auto" href="/repeaters/{{$.Repeater.ID}}/orgs/{{.Org.ID}}/contribute">Review & contribute</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p class="muted">
|
||||
You're not in any organizations yet. Joining one lets its admins help keep your repeater in spec.
|
||||
<a class="navlink" href="/orgs">Browse organizations →</a>
|
||||
</p>
|
||||
{{end}}
|
||||
<div class="ractions">
|
||||
<a class="btn primary" href="/">Done — go to dashboard</a>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -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 & 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}}
|
||||
@@ -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 & 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}}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user