carlos / platform Public

console: team is its own page

Members, invites, transfer and remove live at /a/{sqid}/team; their
POSTs stay at their existing paths and redirect back to the page.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell d8d983d5355d732c5873397ee40689379e506f6f parent 29ce168
6 files changed, +117 −65
  • internal/console/ownership.go +2 −2
  • internal/console/server.go +22 −8
  • internal/console/server_account_test.go +1 −1
  • internal/console/server_test.go +28 −0
  • internal/console/templates/apps.html +0 −54
  • internal/console/templates/team.html +64 −0
diff --git a/internal/console/ownership.go b/internal/console/ownership.go
index f88dbe2d3..36a2ce876 100644
--- a/internal/console/ownership.go
+++ b/internal/console/ownership.go
@@ -49,7 +49,7 @@ func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
} else {
s.setFlash(w, "ok", target+" is no longer a member of this account.")
}
- http.Redirect(w, r, "/a/"+acct.Sqid+"/", http.StatusSeeOther)
+ http.Redirect(w, r, "/a/"+acct.Sqid+"/team", http.StatusSeeOther)
}
// handleTransferOwnership hands the account to another member and steps the
@@ -74,7 +74,7 @@ func (s *Server) handleTransferOwnership(w http.ResponseWriter, r *http.Request)
} else {
s.setFlash(w, "ok", target+" now owns this account. You are a member of it.")
}
- http.Redirect(w, r, "/a/"+acct.Sqid+"/", http.StatusSeeOther)
+ http.Redirect(w, r, "/a/"+acct.Sqid+"/team", http.StatusSeeOther)
}
// handleDeleteAccount retires an EMPTY account. Deliberately narrow: an account
diff --git a/internal/console/server.go b/internal/console/server.go
index 31a859973..8611e758c 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -255,6 +255,7 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
// Go 1.22's mux prefers this literal over apps/{app} below — exactly why
// "new" must be a reserved app name (claimApp): an app so named could
// never be viewed.
+ s.mux.HandleFunc("GET /a/{sqid}/team", s.handleTeam)
s.mux.HandleFunc("GET /a/{sqid}/apps/new", s.handleNewAppForm)
s.mux.HandleFunc("POST /a/{sqid}/apps/new", s.handleNewApp)
s.mux.HandleFunc("GET /a/{sqid}/apps/{app}", s.handleApp)
@@ -765,7 +766,7 @@ func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
}
back := func(kind, msg string) {
s.setFlash(w, kind, msg)
- http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/", http.StatusSeeOther)
+ http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/team", http.StatusSeeOther)
}
to := strings.ToLower(strings.TrimSpace(r.FormValue("address")))
if !ValidAddress(to) {
@@ -834,7 +835,7 @@ func (s *Server) handleRevokeInvite(w http.ResponseWriter, r *http.Request) {
break
}
}
- http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/", http.StatusSeeOther)
+ http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/team", http.StatusSeeOther)
}
// handleAcceptInvite consumes an invite link. It requires a session as the
@@ -1159,6 +1160,24 @@ func (s *Server) handleAccountHome(w http.ResponseWriter, r *http.Request) {
} else {
p.TrashN = len(trashed)
}
+ s.render(w, http.StatusOK, "apps", p)
+}
+
+// handleTeam lists the account's members and, for owners, the invite
+// machinery. The mutations it hosts all POST to their existing paths.
+func (s *Server) handleTeam(w http.ResponseWriter, r *http.Request) {
+ addr, raw, acct, role, ok := s.account(w, r)
+ if !ok {
+ return
+ }
+ p := s.accountPage(w, r, addr, raw, acct, role)
+ p.Title = "Team"
+ p.ActiveNav = "team"
+ if members, err := s.store.Members(acct.ID); err != nil {
+ log.Printf("console: members: %v", err)
+ } else {
+ p.Members = members
+ }
if role == RoleOwner {
if invites, err := s.store.PendingInvites(acct.ID); err != nil {
log.Printf("console: pending invites: %v", err)
@@ -1166,12 +1185,7 @@ func (s *Server) handleAccountHome(w http.ResponseWriter, r *http.Request) {
p.Invites = invites
}
}
- if members, err := s.store.Members(acct.ID); err != nil {
- log.Printf("console: members: %v", err)
- } else {
- p.Members = members
- }
- s.render(w, http.StatusOK, "apps", p)
+ s.render(w, http.StatusOK, "team", p)
}
// accountPage fills the chrome every account-scoped page shares — the
diff --git a/internal/console/server_account_test.go b/internal/console/server_account_test.go
index a36eb87ae..46be4103e 100644
--- a/internal/console/server_account_test.go
+++ b/internal/console/server_account_test.go
@@ -311,7 +311,7 @@ func TestInviteAcceptCreatesMembership(t *testing.T) {
st, loc, _ := postForm(t, owner, fx.u("/invite"),
url.Values{"csrf": {csrf}, "address": {"sam@keymail.dev"}})
- if st != http.StatusSeeOther || loc != fx.path("/") {
+ if st != http.StatusSeeOther || loc != fx.path("/team") {
t.Fatalf("invite = %d -> %q", st, loc)
}
pending, _ := fx.st.PendingInvites(OpsAccountID)
diff --git a/internal/console/server_test.go b/internal/console/server_test.go
index 79f69efc7..67334d91e 100644
--- a/internal/console/server_test.go
+++ b/internal/console/server_test.go
@@ -510,6 +510,34 @@ func TestCreateAppPage(t *testing.T) {
assertNonMember404(t, fx, "/apps/new")
}
+func TestTeamPage(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if err := fx.st.AddMember(OpsAccountID, "kim@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ c := signInPaul(t, fx)
+ status, _, body := get(t, c, fx.u("/team"))
+ if status != 200 {
+ t.Fatalf("GET /team = %d", status)
+ }
+ for _, want := range []string{
+ "Team", "people who can see and ship",
+ `action="` + fx.path("/invite") + `"`,
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("team page missing %q", want)
+ }
+ }
+ // The account home no longer carries the member machinery.
+ _, _, home := get(t, c, fx.u("/"))
+ for _, gone := range []string{"Invite a teammate", "Hand over ownership"} {
+ if strings.Contains(home, gone) {
+ t.Fatalf("account home still shows %q", gone)
+ }
+ }
+ assertNonMember404(t, fx, "/team")
+}
+
func TestAppNamedNewIsRefused(t *testing.T) {
fx := newConsoleFixture(t)
c := signInPaul(t, fx)
diff --git a/internal/console/templates/apps.html b/internal/console/templates/apps.html
index e6c156bcd..4a1dca1ed 100644
--- a/internal/console/templates/apps.html
+++ b/internal/console/templates/apps.html
@@ -27,61 +27,7 @@
restorable until the platform destroys them.</p>{{end}}
<p><a class="btn" href="{{.Nav}}/apps/new">Create app</a></p>
-<h2>Members</h2>
-<div class="tablewrap">
-<table>
- <tr><th>Address</th><th>Role</th>{{if .IsOwner}}<th></th>{{end}}</tr>
- {{range .Members}}<tr>
- <td>{{.Name}}</td>
- <td>{{.Role}}</td>
- {{if $.IsOwner}}<td>
- {{if ne .Name $.Address}}
- <form method="post" action="{{$.Base}}/members/transfer">
- <input type="hidden" name="csrf" value="{{$.CSRF}}">
- <input type="hidden" name="address" value="{{.Name}}">
- <button class="linkish" type="submit">Hand over ownership</button>
- </form>
- <form method="post" action="{{$.Base}}/members/remove">
- <input type="hidden" name="csrf" value="{{$.CSRF}}">
- <input type="hidden" name="address" value="{{.Name}}">
- <button class="linkish" type="submit">Remove</button>
- </form>
- {{end}}
- </td>{{end}}
- </tr>{{end}}
-</table>
-</div>
{{if .IsOwner}}
-<p class="small">Removing a member, handing over ownership and deleting the account each ask
- you to confirm with your passkey — a signed-in browser on its own isn't enough.</p>
-<h2>Invite a teammate</h2>
-<form class="signin" method="post" action="{{.Base}}/invite">
- <input type="hidden" name="csrf" value="{{.CSRF}}">
- <input type="text" name="address" placeholder="them@keymail.dev">
- <button type="submit">Send invitation</button>
-</form>
-<p class="small">The invitation arrives in their Keymail Apps inbox — the platform never emails anyone. It works once and expires in 7 days.</p>
-{{if .Invites}}
-<div class="tablewrap">
-<table>
- <tr><th>Invited</th><th>By</th><th>Expires</th><th></th></tr>
- {{range .Invites}}
- <tr>
- <td>{{.Address}}</td>
- <td>{{.InvitedBy}}</td>
- <td>{{fmtTime .ExpiresAt}}</td>
- <td>
- <form method="post" action="{{$.Base}}/invite/revoke">
- <input type="hidden" name="csrf" value="{{$.CSRF}}">
- <input type="hidden" name="token" value="{{.Token}}">
- <button class="linkish" type="submit">Revoke</button>
- </form>
- </td>
- </tr>
- {{end}}
-</table>
-</div>
-{{end}}
{{if not .Apps}}
<h2>Delete this account</h2>
<p class="small">An account with no apps can be retired. Type <strong>{{.Account.Name}}</strong> to
diff --git a/internal/console/templates/team.html b/internal/console/templates/team.html
new file mode 100644
index 000000000..c835842a6
--- /dev/null
+++ b/internal/console/templates/team.html
@@ -0,0 +1,64 @@
+{{define "team"}}{{template "top" .}}
+{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}}
+{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
+<div class="pagehead">
+ <p class="eyebrow">Account · {{.Account.Name}}</p>
+ <h1>Team</h1>
+ <p class="lede">The people who can see and ship this account's apps.</p>
+</div>
+<div class="tablewrap">
+<table>
+ <tr><th>Address</th><th>Role</th>{{if .IsOwner}}<th></th>{{end}}</tr>
+ {{range .Members}}<tr>
+ <td>{{.Name}}</td>
+ <td>{{.Role}}</td>
+ {{if $.IsOwner}}<td>
+ {{if ne .Name $.Address}}
+ <form method="post" action="{{$.Base}}/members/transfer">
+ <input type="hidden" name="csrf" value="{{$.CSRF}}">
+ <input type="hidden" name="address" value="{{.Name}}">
+ <button class="linkish" type="submit">Hand over ownership</button>
+ </form>
+ <form method="post" action="{{$.Base}}/members/remove">
+ <input type="hidden" name="csrf" value="{{$.CSRF}}">
+ <input type="hidden" name="address" value="{{.Name}}">
+ <button class="linkish danger" type="submit">Remove</button>
+ </form>
+ {{end}}
+ </td>{{end}}
+ </tr>{{end}}
+</table>
+</div>
+{{if .IsOwner}}
+<p class="small">Removing a member, handing over ownership and deleting the account each ask
+ you to confirm with your passkey — a signed-in browser on its own isn't enough.</p>
+<h2>Invite a teammate</h2>
+<form class="signin" method="post" action="{{.Base}}/invite">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <input type="text" name="address" placeholder="them@keymail.dev">
+ <button type="submit">Send invitation</button>
+</form>
+<p class="small">The invitation arrives in their Keymail Apps inbox — the platform never emails anyone. It works once and expires in 7 days.</p>
+{{if .Invites}}
+<div class="tablewrap">
+<table>
+ <tr><th>Invited</th><th>By</th><th>Expires</th><th></th></tr>
+ {{range .Invites}}
+ <tr>
+ <td>{{.Address}}</td>
+ <td>{{.InvitedBy}}</td>
+ <td>{{fmtTime .ExpiresAt}}</td>
+ <td>
+ <form method="post" action="{{$.Base}}/invite/revoke">
+ <input type="hidden" name="csrf" value="{{$.CSRF}}">
+ <input type="hidden" name="token" value="{{.Token}}">
+ <button class="linkish danger" type="submit">Revoke</button>
+ </form>
+ </td>
+ </tr>
+ {{end}}
+</table>
+</div>
+{{end}}
+{{end}}
+{{template "bottom" .}}{{end}}