diff --git a/internal/core/endpoints_repeater_test.go b/internal/core/endpoints_repeater_test.go index 5597a13..821e214 100644 --- a/internal/core/endpoints_repeater_test.go +++ b/internal/core/endpoints_repeater_test.go @@ -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) { diff --git a/internal/core/shares.go b/internal/core/shares.go index 129066b..5619d7f 100644 --- a/internal/core/shares.go +++ b/internal/core/shares.go @@ -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 } diff --git a/internal/core/templates/share.html b/internal/core/templates/share.html index e79097d..722b275 100644 --- a/internal/core/templates/share.html +++ b/internal/core/templates/share.html @@ -17,21 +17,16 @@
- 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.
- -Each link works once. Mint one per person and label it so you remember who it's for.
- + {{if .Invites}}No links yet.
{{end}} +Mark a trusted person a steward to make them a co-operator: they can run every command (including risky ones), just like you, and they're listed as a @@ -138,7 +137,13 @@