Configure permissions before shares accepted

This commit is contained in:
Jonathon Leight
2026-07-07 20:57:26 -04:00
parent 9fc6dfd83a
commit e0336f3a9a
9 changed files with 258 additions and 37 deletions
+54 -1
View File
@@ -314,7 +314,7 @@ func TestAcceptInvitePost(t *testing.T) {
t.Fatal(err)
}
rep := newOwnedRepeater(t, st, ctx, owner.ID, "Invite Rep")
token, err := st.CreateInvite(ctx, rep.ID, "come join")
token, err := st.CreateInvite(ctx, rep.ID, "come join", nil)
if err != nil {
t.Fatal(err)
}
@@ -340,6 +340,59 @@ func TestAcceptInvitePost(t *testing.T) {
}
}
// TestCreateInviteWithCommands covers the "Create single-use link" modal: the GET
// fragment renders the description + command grid, and the POST persists the chosen
// initial grant on the invite so AcceptInvite can seed exactly it.
func TestCreateInviteWithCommands(t *testing.T) {
t.Parallel()
st, ctx, ts, h := splitServer(t)
owner, sess := appLogin(t, ts, st, ctx, h.app, "invmodalowner")
rep := newOwnedRepeater(t, st, ctx, owner.ID, "Modal Rep")
// GET renders the modal fragment (no page chrome) with the description + boxes.
frag := readBody(t, do(t, ts, h.app, "/repeaters/"+rep.PublicID+"/share/link/new", sess))
if !strings.Contains(frag, `name="description"`) || !strings.Contains(frag, `name="cmd"`) {
t.Fatalf("new-invite fragment missing expected fields:\n%s", frag)
}
if strings.Contains(frag, "back-link") {
t.Fatal("new-invite fragment should be modal chrome, not a full page")
}
catalog, err := st.ListCommands(ctx)
if err != nil || len(catalog) == 0 {
t.Fatalf("list commands: %v (n=%d)", err, len(catalog))
}
grant := catalog[0].ID
share := "/repeaters/" + rep.PublicID + "/share"
create := post(t, ts, h.app, share+"/link",
url.Values{"description": {"for Bob"}, "cmd": {strconv.FormatInt(grant, 10)}}, sess)
create.Body.Close()
assertRedirect(t, create, share, "create invite with commands")
invites, err := st.ListInvites(ctx, rep.ID)
if err != nil || len(invites) != 1 {
t.Fatalf("ListInvites = %d, %v; want 1", len(invites), err)
}
// The chosen grant is recorded on the invite (seeded on accept).
var got []int64
rows, err := st.Pool().Query(ctx, `SELECT command_id FROM invite_commands WHERE invite_id = $1`, invites[0].ID)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
t.Fatal(err)
}
got = append(got, id)
}
if len(got) != 1 || got[0] != grant {
t.Fatalf("invite_commands = %v, want [%d]", got, grant)
}
}
// #92 catalog update, #95 set user capabilities. Both require an admin cap, which
// the session picks up live from the store on the next request.
func TestAdminPosts(t *testing.T) {
+33 -2
View File
@@ -48,14 +48,45 @@ func (s *Handlers) pageShare(w http.ResponseWriter, r *http.Request) {
})
}
// handleCreateLink mints a new single-use share link with a description (owner only).
// pageNewInvite renders the "create single-use link" modal fragment: a
// description field plus the command grid (share defaults pre-checked) the owner
// picks the initial grant from. Loaded via htmx into the share page's shared modal.
func (s *Handlers) pageNewInvite(w http.ResponseWriter, r *http.Request) {
rep, _, ok := s.requireRepeaterOwned(w, r)
if !ok {
return
}
catalog, err := s.Store.ListCommands(r.Context())
if err != nil {
s.ServerError(w, r, "could not load commands", err)
return
}
checked := make(map[int64]bool, len(catalog))
for _, c := range catalog {
if c.InShareDefault {
checked[c.ID] = true
}
}
s.Render(w, r, "share_invite_new.html", map[string]any{
"Repeater": rep,
"Groups": groupCommands(catalog, checked),
"Layout": "invite-new-modal",
})
}
// handleCreateLink mints a new single-use share link with a description and the
// initial command grant the owner chose (owner only).
func (s *Handlers) handleCreateLink(w http.ResponseWriter, r *http.Request) {
id, ok := s.requireOwned(w, r)
if !ok {
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
description := web.Clip(strings.TrimSpace(r.FormValue("description")), 100)
if _, err := s.Store.CreateInvite(r.Context(), id, description); err != nil {
if _, err := s.Store.CreateInvite(r.Context(), id, description, parseCommandIDs(r.Form["cmd"])); err != nil {
shareErr(w, r, "Could not create share link.")
return
}
+20 -15
View File
@@ -17,21 +17,16 @@
<div class="col-lg-7">
<div class="card">
<div class="card-body">
<h3 class="card-title">People</h3>
<h3 class="card-title">Share links</h3>
<p class="text-secondary">
Share single-use links with specific people. The recipient signs in and accepts to control this
repeater from their own browser-connected KISS modem.
Invite specific people with single-use links: the recipient signs in and accepts to control this
repeater from their own browser-connected KISS modem. Each link works once — mint one per person,
label it, and choose what they can do.
</p>
<div class="subheader mt-3 mb-2">Share links</div>
<p class="text-secondary">Each link works once. Mint one per person and label it so you remember who it's for.</p>
<form method="post" action="/repeaters/{{.Repeater.PublicID}}/share/link">
<div class="mb-3">
<label class="form-label" for="share_description">Description <span class="text-secondary">(optional)</span></label>
<input type="text" class="form-control" id="share_description" name="description" placeholder="e.g. for Bob's modem" maxlength="100">
</div>
<button type="submit" class="btn btn-primary">Create single-use link</button>
</form>
<button type="button" class="btn btn-primary" data-testid="new-invite"
data-bs-toggle="modal" data-bs-target="#invite-modal"
hx-get="/repeaters/{{.Repeater.PublicID}}/share/link/new"
hx-target="#invite-modal-content" hx-swap="innerHTML">Create single-use link</button>
{{if .Invites}}
<div class="list-group list-group-flush mt-3">
@@ -57,8 +52,12 @@
{{else}}
<p class="text-secondary mt-2">No links yet.</p>
{{end}}
</div>
</div>
<div class="subheader mt-3 mb-2">People with access</div>
<div class="card mt-3">
<div class="card-body">
<h3 class="card-title">People with access</h3>
<p class="text-secondary">
Mark a trusted person a <strong>steward</strong> to make them a co-operator: they can run
<strong>every</strong> command (including risky ones), just like you, and they're listed as a
@@ -138,7 +137,13 @@
</div>
</div>
<!-- Shared modal; each org's "Edit limits" button loads its fragment here via htmx. -->
<!-- Shared modals; the People "Create single-use link" and each org's "Edit
limits" button load their fragments here via htmx. -->
<div class="modal modal-blur fade" id="invite-modal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg" role="document">
<div class="modal-content" id="invite-modal-content"></div>
</div>
</div>
<div class="modal modal-blur fade" id="limits-modal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg" role="document">
<div class="modal-content" id="limits-modal-content"></div>
@@ -0,0 +1,26 @@
{{/* invite-new-modal is the htmx fragment swapped into the share page's shared
#invite-modal. The form posts normally (full navigation back to the share
page), which also closes the modal — no client JS needed. */}}
{{define "invite-new-modal"}}
<div class="modal-header">
<h5 class="modal-title">Create single-use link</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="post" action="/repeaters/{{.Repeater.PublicID}}/share/link">
<div class="modal-body">
<div class="mb-3">
<label class="form-label" for="share_description">Description <span class="text-secondary">(optional)</span></label>
<input type="text" class="form-control" id="share_description" name="description" placeholder="e.g. for Bob's modem" maxlength="100">
</div>
<p class="text-secondary mb-0">
Choose which commands the recipient may send once they accept — you can change these any time
afterwards. Risky commands can take over or brick the node; grant them only with care.
</p>
{{template "command-grid" .}}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-link link-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Create link</button>
</div>
</form>
{{end}}
+1
View File
@@ -200,6 +200,7 @@ func (s *Handlers) appRouter() chi.Router {
r.Post("/repeaters/{id}/maintenance", s.handleAddMaintenance)
r.Post("/repeaters/{id}/maintenance/delete", s.handleDeleteMaintenance)
r.Get("/repeaters/{id}/share", s.pageShare)
r.Get("/repeaters/{id}/share/link/new", s.pageNewInvite)
r.Post("/repeaters/{id}/share/link", s.handleCreateLink)
r.Post("/repeaters/{id}/share/link/delete", s.handleDeleteInvite)
r.Post("/repeaters/{id}/unshare", s.handleUnshare)
+60
View File
@@ -0,0 +1,60 @@
//go:build browser
package e2e
import (
"strings"
"testing"
cdplog "github.com/chromedp/cdproto/log"
"github.com/chromedp/cdproto/network"
"github.com/chromedp/chromedp"
"github.com/jleight/meshtender/internal/store"
)
// TestE2ECreateInviteModal drives the share page's "Create single-use link"
// button: it opens the shared modal (Bootstrap data-bs-toggle under the strict
// CSP) and htmx loads the description field + command grid into it. Asserts the
// fragment renders and the page runs clean under the CSP.
func TestE2ECreateInviteModal(t *testing.T) {
srv := newE2EServer(t)
user, cookie := srv.login(t, "e2einvite")
rep, err := srv.store.CreateRepeater(srv.ctx, &store.Repeater{
OwnerID: user.ID, Name: "Rep", PublicKeyHex: strings.Repeat("a", 64),
RadioFreqHz: 1, RadioBwHz: 1, RadioSF: 11, RadioCR: 5,
})
if err != nil {
t.Fatalf("create repeater: %v", err)
}
bctx, cancel, watch := startBrowser(t)
defer cancel()
shareURL := srv.appURL + "/repeaters/" + rep.PublicID + "/share"
var hasDescription, boxes bool
if err := chromedp.Run(bctx,
network.Enable(),
cdplog.Enable(),
setSessionCookie(cookie),
chromedp.Navigate(shareURL),
chromedp.WaitVisible(`[data-testid="new-invite"]`, chromedp.ByQuery),
chromedp.Click(`[data-testid="new-invite"]`, chromedp.ByQuery),
// htmx loads the fragment: wait for the command grid, then check the fields.
chromedp.WaitVisible(`#invite-modal-content [data-check-scope]`, chromedp.ByQuery),
chromedp.Evaluate(`!!document.querySelector('#invite-modal-content input[name=description]')`, &hasDescription),
chromedp.Evaluate(`document.querySelectorAll('#invite-modal-content input[name=cmd]').length > 0`, &boxes),
); err != nil {
t.Fatalf("browser run against %s: %v", shareURL, err)
}
if !hasDescription {
t.Fatal("invite modal missing the description field")
}
if !boxes {
t.Fatal("invite modal missing command checkboxes")
}
watch.assertClean(t)
}
@@ -0,0 +1,22 @@
-- +goose Up
-- Record the initial command grant an owner chooses when minting a share link, so
-- AcceptInvite can seed exactly that set instead of the site-wide share default.
-- Deny-by-default like share_commands: no rows = the accepter is granted nothing
-- (the owner picked none), NOT the old default set.
CREATE TABLE invite_commands (
invite_id BIGINT NOT NULL REFERENCES repeater_invites(id) ON DELETE CASCADE,
command_id BIGINT NOT NULL REFERENCES command_catalog(id) ON DELETE CASCADE,
PRIMARY KEY (invite_id, command_id)
);
-- Preserve behavior for links already outstanding: before this change they seeded
-- the share-default set on accept, so record that set explicitly for every link
-- that can still be redeemed (used ones are done and need nothing).
INSERT INTO invite_commands (invite_id, command_id)
SELECT i.id, c.id
FROM repeater_invites i
JOIN command_catalog c ON c.in_share_default
WHERE i.used_at IS NULL;
-- +goose Down
DROP TABLE invite_commands;
+31 -15
View File
@@ -182,17 +182,33 @@ type Invite struct {
}
// CreateInvite mints a new single-use share link for a repeater, returning its
// token. description is an owner-facing label (may be empty).
func (s *Store) CreateInvite(ctx context.Context, repeaterID int64, description string) (string, error) {
// token. description is an owner-facing label (may be empty). commandIDs is the
// initial command set the accepter is granted on redemption (may be empty — the
// owner can grant more afterwards); it and the invite row are written together so
// the link never exists without its recorded grant.
func (s *Store) CreateInvite(ctx context.Context, repeaterID int64, description string, commandIDs []int64) (string, error) {
token, err := randomToken()
if err != nil {
return "", err
}
_, err = s.pool.Exec(ctx,
`INSERT INTO repeater_invites (repeater_id, token, description) VALUES ($1, $2, $3)`,
repeaterID, token, description)
err = s.inTx(ctx, func(tx pgx.Tx) error {
var inviteID int64
if err := tx.QueryRow(ctx,
`INSERT INTO repeater_invites (repeater_id, token, description) VALUES ($1, $2, $3) RETURNING id`,
repeaterID, token, description).Scan(&inviteID); err != nil {
return fmt.Errorf("create invite: %w", err)
}
for _, cid := range commandIDs {
if _, err := tx.Exec(ctx,
`INSERT INTO invite_commands (invite_id, command_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
inviteID, cid); err != nil {
return fmt.Errorf("seed invite commands: %w", err)
}
}
return nil
})
if err != nil {
return "", fmt.Errorf("create invite: %w", err)
return "", err
}
return token, nil
}
@@ -243,23 +259,23 @@ func (s *Store) RepeaterByInviteToken(ctx context.Context, queryUserID int64, to
// AcceptInvite redeems a single-use share link for userID, doing all three steps
// in one transaction so they are all-or-nothing: it consumes the link, grants the
// share, and seeds the share's default command set. If any step fails the whole
// thing rolls back, so a failure can never spend the link without granting access
// (or grant access without spending the link).
// share, and seeds the command set the owner chose for this link (invite_commands).
// If any step fails the whole thing rolls back, so a failure can never spend the
// link without granting access (or grant access without spending the link).
//
// It returns whether a new share was created (false if the user already had one)
// and ErrNotFound if the token is unknown, revoked, or already used — the
// single-use guard, safe against concurrent accepts.
func (s *Store) AcceptInvite(ctx context.Context, token string, userID int64) (added bool, err error) {
err = s.inTx(ctx, func(tx pgx.Tx) error {
var repeaterID int64
var inviteID, repeaterID int64
// Consume the link. The used_at IS NULL guard makes this the single-use
// gate: only one accept can flip it, so a concurrent (or repeat) accept
// matches no row and falls through to ErrNotFound.
if err := tx.QueryRow(ctx,
`UPDATE repeater_invites SET used_at = now(), used_by = $2
WHERE token = $1 AND used_at IS NULL
RETURNING repeater_id`, token, userID).Scan(&repeaterID); err != nil {
RETURNING id, repeater_id`, token, userID).Scan(&inviteID, &repeaterID); err != nil {
return notFoundOr(err, "consume invite")
}
tag, err := tx.Exec(ctx,
@@ -272,12 +288,12 @@ func (s *Store) AcceptInvite(ctx context.Context, token string, userID int64) (a
if !added {
return nil // already shared: nothing to seed
}
// Seed the new share with the share-default command set; the owner can
// adjust it afterwards.
// Seed the new share with exactly the command set the owner chose for this
// link; the owner can adjust it afterwards.
if _, err := tx.Exec(ctx, `
INSERT INTO share_commands (repeater_id, user_id, command_id)
SELECT $1, $2, id FROM command_catalog WHERE in_share_default
ON CONFLICT DO NOTHING`, repeaterID, userID); err != nil {
SELECT $1, $2, command_id FROM invite_commands WHERE invite_id = $3
ON CONFLICT DO NOTHING`, repeaterID, userID, inviteID); err != nil {
return fmt.Errorf("seed share commands: %w", err)
}
return nil
+11 -4
View File
@@ -24,7 +24,14 @@ func TestAcceptInvite(t *testing.T) {
if err != nil {
t.Fatalf("create repeater: %v", err)
}
token, err := st.CreateInvite(ctx, rep.ID, "join")
// The owner picks an explicit initial grant when minting the link; accept must
// seed exactly that set (not the site-wide share default).
catalog, err := st.ListCommands(ctx)
if err != nil || len(catalog) == 0 {
t.Fatalf("list commands: %v (n=%d)", err, len(catalog))
}
grant := catalog[0].ID
token, err := st.CreateInvite(ctx, rep.ID, "join", []int64{grant})
if err != nil {
t.Fatalf("create invite: %v", err)
}
@@ -51,8 +58,8 @@ func TestAcceptInvite(t *testing.T) {
if err != nil {
t.Fatalf("ListShareCommandIDs: %v", err)
}
if len(cmds) == 0 {
t.Fatalf("no default commands seeded for the new share")
if len(cmds) != 1 || cmds[0] != grant {
t.Fatalf("seeded commands = %v, want exactly [%d] (the chosen grant)", cmds, grant)
}
invites, err := st.ListInvites(ctx, rep.ID)
if err != nil || len(invites) != 1 {
@@ -98,7 +105,7 @@ func TestAcceptInviteRollsBack(t *testing.T) {
if err != nil {
t.Fatalf("create repeater: %v", err)
}
token, err := st.CreateInvite(ctx, rep.ID, "join")
token, err := st.CreateInvite(ctx, rep.ID, "join", nil)
if err != nil {
t.Fatalf("create invite: %v", err)
}