mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-16 00:25:59 +00:00
Configure permissions before shares accepted
This commit is contained in:
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user