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>
6 files changed,
+117
−65
internal/console/ownership.go+2 −2internal/console/server.go+22 −8internal/console/server_account_test.go+1 −1internal/console/server_test.go+28 −0internal/console/templates/apps.html+0 −54internal/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}} |