Skip to content
quarry Artifact [60c22502df]
Login

Artifact [60c22502df]

Artifact 60c22502df970587425731d4c4d2d8336b4acec430d849f35ff3fae7fff995fe:


// meagents.go is the web side of RFC 0021's agents: the "Your agents"
// section on /me and an agent's own page. Every act is an agent.* op
// called through the same invoke path the other /me sections use; the
// rules (the operator's letters, the caps, the one-time value) live in
// the ops, and this file only draws them and posts to them.
package web

import (
	"errors"
	"fmt"
	"html/template"
	"log"
	"net/http"
	"net/url"
	"sort"
	"strings"

	"craftdesign.group/quarry/internal/oltp"
	"craftdesign.group/quarry/internal/ops"
)

// meAgentsView is the "Your agents" section. Show is false on a forge
// whose account store has no agents, and for a person who has none and
// may make none: a section with nothing to list and nothing to do is
// not drawn.
type meAgentsView struct {
	Show      bool
	CanCreate bool
	// SiteAdmin offers the opt-in that a new site-wide agent inherits
	// the caller's site-admin flag (RFC 0026, decision 4).
	SiteAdmin bool
	// Teams and Repos are the scopes the create form offers: the
	// caller's teams and the repositories they reach (RFC 0026).
	Teams []string
	Repos []string
	Rows  []agentView
	// Live says some agent holds a token that still works, which is
	// when "revoke every agent token" has something to do.
	Live bool
}

// agentView is one agent as agent.list reports it, plus what the pages
// derive from it.
type agentView struct {
	Name, Operator, Created string
	Disabled, Suspended     string
	// Admin is the site-admin flag, which only a forge agent holds.
	Admin bool
	// Answerable is who answers for a forge, repository or team agent.
	Answerable string
	// Inherit: a person's agent holding its operator's letters wherever
	// Repos has no row (RFC 0026).
	Inherit bool
	// Asks: it may ask its person for what it may not do (RFC 0027).
	Asks bool
	// Scope is where it reaches: "" the site, team:NAME, repo:NAME, or
	// gone (RFC 0026).
	Scope  string
	Tokens []meTokenView
	Repos  []agentRepoView
}

// HeldOn is what the agent holds on repo, or nil when it holds nothing
// there: no row, or an empty string.
func (a agentView) HeldOn(repo string) *agentRepoView {
	for i := range a.Repos {
		if a.Repos[i].Repo == repo && a.Repos[i].Caps != "" {
			return &a.Repos[i]
		}
	}
	return nil
}

// isRepoAgent reports whether name is one of the agents listed.
func isRepoAgent(v *meAgentsView, name string) bool {
	if v == nil {
		return false
	}
	for _, a := range v.Rows {
		if a.Name == name {
			return true
		}
	}
	return false
}

// repoAgentRole is the role a repository's settings form may give its
// own agent: a preset below admin, developer when none is named.
func repoAgentRole(v string) (string, bool) {
	switch v = strings.TrimSpace(v); v {
	case "":
		return "developer", true
	case "reader", "developer", "maintainer":
		return v, true
	}
	return "", false
}

// opsSentence is an op's refusal as a sentence for the page, without
// the category it was classified under.
func opsSentence(err error) string {
	s := strings.TrimSpace(ops.Sentence(err))
	if s != "" && !strings.HasSuffix(s, ".") {
		s += "."
	}
	return s
}

// Dedicated says the agent belongs to a repository or a team rather
// than a person (RFC 0026).
func (a agentView) Dedicated() bool {
	return strings.HasPrefix(a.Operator, "repo:") || strings.HasPrefix(a.Operator, "team:")
}

// Personal says the agent is a person's: not the forge's, not a
// repository's or a team's.
func (a agentView) Personal() bool { return a.Operator != "forge" && !a.Dedicated() }

// OwnerRepo and OwnerTeam name what a dedicated agent belongs to.
func (a agentView) OwnerRepo() string { return strings.TrimPrefix(a.Operator, "repo:") }
func (a agentView) OwnerTeam() string { return strings.TrimPrefix(a.Operator, "team:") }

// ScopeName is a team or repository scope in words: "the team crew",
// "the repository quarry".
func (a agentView) ScopeName() string {
	switch {
	case strings.HasPrefix(a.Scope, "team:"):
		return "the team " + strings.TrimPrefix(a.Scope, "team:")
	case strings.HasPrefix(a.Scope, "repo:"):
		return "the repository " + strings.TrimPrefix(a.Scope, "repo:")
	}
	return a.Scope
}

// Newest is the agent's most recently made token that still works, or
// nil when it holds none.
func (a agentView) Newest() *meTokenView {
	var best *meTokenView
	for i := range a.Tokens {
		t := &a.Tokens[i]
		if t.Ended() {
			continue
		}
		if best == nil || t.Created > best.Created {
			best = t
		}
	}
	return best
}

// agentRepoView is one repository an agent holds a string on. Caps is
// what is stored, Effective what it resolves to under the operator's
// letters and Role the nearest preset to that.
type agentRepoView struct {
	Repo      string `json:"repo"`
	Caps      string `json:"caps"`
	Effective string `json:"effective"`
	// Role is the nearest preset to Caps, which is what the role
	// dropdown shows; EffRole the same for Effective.
	Role     string `json:"role"`
	EffRole  string `json:"-"`
	Sentence string `json:"-"`
}

// within is the stored string's letters that survive the operator's:
// the string read as the role it still amounts to. Effective itself
// carries category letters nobody stored, which no preset would name.
func (r agentRepoView) within() string {
	var kept strings.Builder
	for _, l := range oltp.Closure(r.Caps) {
		if strings.ContainsRune(r.Effective, l) {
			kept.WriteRune(l)
		}
	}
	return kept.String()
}

// Narrowed says the operator's letters took something from the stored
// string.
func (r agentRepoView) Narrowed() bool {
	for _, l := range oltp.Closure(r.Caps) {
		if !strings.ContainsRune(r.Effective, l) {
			return true
		}
	}
	return false
}

// agentPageView is an agent's own page: the agent, the dropdown
// choices its forms offer, and whatever the response that drew it has to
// say once (a new token's value, a clipped grant).
type agentPageView struct {
	agentView
	// Grantable are the repositories the operator reaches that this
	// agent holds nothing on yet.
	Grantable []string
	// Ceilings are the roles a token may be limited to: those whose
	// letters the agent resolves to somewhere.
	Ceilings []oltp.Preset
	Roles    []oltp.Preset
	// NewValue is a token's value, present for the one response that
	// minted it. NewName is that token's name.
	NewValue string
	NewName  string
	Warning  string
	Error    string
	// Site offers the site scope: a forge agent holding the flag, on the
	// page a site admin opened (RFC 0021, Forge agents).
	Site bool
	// TokenRepos are the repositories a token may be narrowed to: the
	// agent's grants, and for an inheriting agent every repository its
	// operator reaches (RFC 0026).
	TokenRepos []string
}

// agentsOf reads agent.list for the caller. A forge whose account store
// has no agents answers an error, which is no section.
func agentsOf(r *ops.Registry, req *http.Request, user string) (meAgentsView, bool) {
	return agentsFor(r, req, user, url.Values{})
}

// agentsFor is agentsOf for the agent.list form given: operator=forge
// is the forge's own agents, which only a site admin is shown.
func agentsFor(r *ops.Registry, req *http.Request, user string, form url.Values) (meAgentsView, bool) {
	out, err := invoke(ops.WithUser(req.Context(), user), r, "agent.list", form)
	if err != nil {
		// A forge with no agents, and a caller who may not manage them
		// (a token), are no section; anything else is worth a trace.
		if !errors.Is(err, ops.ErrUnavailable) && !errors.Is(err, ops.ErrForbidden) {
			log.Printf("web: agent.list for %s: %v", user, err)
		}
		return meAgentsView{}, false
	}
	var l struct {
		Agents []struct {
			Name       string          `json:"name"`
			Operator   string          `json:"operator"`
			Admin      bool            `json:"admin"`
			Created    string          `json:"created"`
			Disabled   string          `json:"disabled"`
			Suspended  string          `json:"suspended"`
			Inherit    bool            `json:"inherit"`
			Asks       bool            `json:"asks"`
			Answerable string          `json:"answerable"`
			Scope      string          `json:"scope"`
			Tokens     []meTokenView   `json:"tokens"`
			Repos      []agentRepoView `json:"repos"`
		} `json:"agents"`
		CanCreate bool `json:"can_create"`
		SiteAdmin bool `json:"site_admin"`
	}
	remarshal(out, &l)
	v := meAgentsView{CanCreate: l.CanCreate, SiteAdmin: l.SiteAdmin}
	for _, a := range l.Agents {
		row := agentView{Name: a.Name, Operator: a.Operator, Admin: a.Admin, Created: a.Created,
			Disabled: a.Disabled, Suspended: a.Suspended, Inherit: a.Inherit, Asks: a.Asks, Scope: a.Scope, Answerable: a.Answerable, Tokens: a.Tokens, Repos: a.Repos}
		for i := range row.Repos {
			g := &row.Repos[i]
			g.EffRole = oltp.Infer(g.within()).Role
			g.Sentence = oltp.Explain(row.Name, oltp.CapSet(g.Effective))
		}
		for _, t := range row.Tokens {
			if !t.Ended() {
				v.Live = true
			}
		}
		v.Rows = append(v.Rows, row)
	}
	v.Show = len(v.Rows) > 0 || v.CanCreate
	return v, true
}

// agentPageOf is one agent's page, or false when the caller has no
// such agent. A site admin's are their own agents and the forge's.
func agentPageOf(r *ops.Registry, req *http.Request, user, name string) (*agentPageView, bool) {
	all, ok := agentsOf(r, req, user)
	if !ok {
		return nil, false
	}
	if forge, ok := agentsFor(r, req, user, url.Values{"operator": {"forge"}}); ok {
		all.Rows = append(all.Rows, forge.Rows...)
	}
	// A repository's or a team's own agent, for its admins or owners.
	if owned, ok := agentsFor(r, req, user, url.Values{"agent": {name}}); ok {
		all.Rows = append(all.Rows, owned.Rows...)
	}
	for _, a := range all.Rows {
		if a.Name != name {
			continue
		}
		p := &agentPageView{agentView: a, Roles: oltp.Presets, Site: a.Operator == "forge" && a.Admin}
		held := map[string]bool{}
		have := ""
		for _, g := range a.Repos {
			held[g.Repo] = true
			have += g.Effective
		}
		have = oltp.Closure(have)
		for _, pr := range oltp.Presets {
			if strings.Trim(oltp.Closure(pr.Caps), have) == "" {
				p.Ceilings = append(p.Ceilings, pr)
			}
		}
		reach := reachableRepos(r, req, user)
		for _, n := range reach {
			// A repository-scoped agent is offered its one repository; a
			// grant anywhere else would do nothing (jva, ^2636).
			if s := strings.TrimPrefix(a.Scope, "repo:"); s != a.Scope && s != n {
				continue
			}
			if !held[n] {
				p.Grantable = append(p.Grantable, n)
			}
		}
		for _, g := range a.Repos {
			if g.Effective != "" {
				p.TokenRepos = append(p.TokenRepos, g.Repo)
			}
		}
		if a.Inherit {
			// It reaches what its operator reaches, less what it is
			// narrowed out of; a ceiling may be any role.
			p.TokenRepos = p.TokenRepos[:0]
			for _, n := range reach {
				if s := strings.TrimPrefix(a.Scope, "repo:"); s != a.Scope && s != n {
					continue
				}
				if !closedRow(a.Repos, n) {
					p.TokenRepos = append(p.TokenRepos, n)
				}
			}
			p.Ceilings = oltp.Presets
		}
		return p, true
	}
	return nil, false
}

// closedRow says the agent's grant on repo is No access.
func closedRow(rows []agentRepoView, repo string) bool {
	for _, g := range rows {
		if g.Repo == repo {
			return g.Role == "No access"
		}
	}
	return false
}

// repoAgentsFor is a repository's own agents for its settings page: nil
// for a caller who may not manage them or a forge without agents, which
// draws no section.
func repoAgentsFor(r *ops.Registry, req *http.Request, repo string) *meAgentsView {
	user := ops.User(req.Context())
	if user == "" {
		return nil
	}
	v, ok := agentsFor(r, req, user, url.Values{"operator": {"repo:" + repo}})
	if !ok {
		return nil
	}
	return &v
}

// myTeams are the caller's teams, the team scopes an agent may take. A
// failure offers none.
func myTeams(r *ops.Registry, req *http.Request, user string) []string {
	out, err := invoke(ops.WithUser(req.Context(), user), r, "team.list", url.Values{"mine": {"true"}})
	if err != nil {
		return nil
	}
	var tl struct {
		Teams []struct {
			Name string `json:"name"`
		} `json:"teams"`
	}
	remarshal(out, &tl)
	var names []string
	for _, t := range tl.Teams {
		names = append(names, t.Name)
	}
	return names
}

// reachableRepos are the repositories the caller can see, which is the
// set agent.grant accepts. A failure offers none rather than failing
// the page.
func reachableRepos(r *ops.Registry, req *http.Request, user string) []string {
	out, err := invoke(ops.WithUser(req.Context(), user), r, "repo.list", nil)
	if err != nil {
		log.Printf("web: repo.list for %s: %v", user, err)
		return nil
	}
	var rl struct {
		Repos []struct {
			Name string `json:"name"`
		} `json:"repos"`
	}
	remarshal(out, &rl)
	var names []string
	for _, repo := range rl.Repos {
		names = append(names, repo.Name)
	}
	sort.Strings(names)
	return names
}

// registerAgentPages wires the agent routes. They live beside the /me
// routes, on the same session cookie.
func registerAgentPages(mux *http.ServeMux, r *ops.Registry, sessions Sessions) {
	var signOffPageOf func(w http.ResponseWriter, req *http.Request, user, problem string)
	signedIn := func(w http.ResponseWriter, req *http.Request) (string, bool) {
		user, ok := sessions.Resolve(cookieToken(req))
		if !ok {
			http.Redirect(w, req, "/login?next=%2Fme", http.StatusSeeOther)
		}
		return user, ok
	}
	// agentPage draws one agent's page. The ops decide what is allowed;
	// a refusal comes back as the page's own sentence.
	agentPage := func(w http.ResponseWriter, req *http.Request, user string, p *agentPageView, code int) {
		renderStatus(r, w, req, agentPageTmpl, pageData{Title: "Agent", Agent: p,
			SettingsJS: true, Appear: appearanceOf(req)}, code)
	}
	// refuse draws the agent's page again with the op's sentence beside
	// the form that caused it.
	refuse := func(w http.ResponseWriter, req *http.Request, user string, err error) {
		name := req.PathValue("name")
		if p, ok := agentPageOf(r, req, user, name); ok {
			p.Error = err.Error()
			agentPage(w, req, user, p, http.StatusBadRequest)
			return
		}
		fail(r, w, req, err)
	}
	// act runs one op for the agent in the path.
	act := func(w http.ResponseWriter, req *http.Request, user, op string, form url.Values) bool {
		form.Set("agent", req.PathValue("name"))
		if _, err := invoke(ops.WithUser(req.Context(), user), r, op, form); err != nil {
			refuse(w, req, user, err)
			return false
		}
		return true
	}
	here := func(req *http.Request) string { return "/me/agents/" + url.PathEscape(req.PathValue("name")) }

	// One request for sign-off on a page of its own (RFC 0027): what the
	// email links to. A session, never a token: a forwarded email or a
	// stolen link approves nothing. The GET only shows; the buttons post.
	signOffPageOf = func(w http.ResponseWriter, req *http.Request, user, problem string) {
		out, err := invoke(ops.WithUser(req.Context(), user), r, "signoff.get", url.Values{"id": {req.PathValue("id")}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var got struct {
			Request meSignOff       `json:"request"`
			Steps   []meSignOffStep `json:"steps"`
			Setup   *meSetupView    `json:"setup"`
		}
		remarshal(out, &got)
		got.Request.Steps = got.Steps
		got.Request.Setup = got.Setup
		code := http.StatusOK
		if problem != "" {
			code = http.StatusBadRequest
		}
		renderStatus(r, w, req, signOffPageTmpl, pageData{Title: "Approve?", SignOff: &got.Request, Error: problem, OwnH1: true}, code)
	}
	// RFC 0028: requests have a page of their own. Waiting ones first,
	// each answered in place; everything decided folds into History,
	// filterable by agent. Session only, like every decision.
	mux.HandleFunc("GET /me/requests", func(w http.ResponseWriter, req *http.Request) {
		user, ok := sessions.Resolve(cookieToken(req))
		if !ok {
			http.Redirect(w, req, "/login?next="+url.QueryEscape(req.URL.RequestURI()), http.StatusSeeOther)
			return
		}
		out, err := invoke(ops.WithUser(req.Context(), user), r, "signoff.list", url.Values{})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var l struct {
			Requests []meSignOff                `json:"requests"`
			Plans    map[string][]meSignOffStep `json:"plans"`
		}
		remarshal(out, &l)
		v := requestsView{Tab: "waiting", Agent: req.URL.Query().Get("agent")}
		if req.URL.Query().Get("tab") == "history" {
			v.Tab = "history"
		}
		seen := map[string]bool{}
		for _, s := range l.Requests {
			s.Steps = l.Plans[s.ID]
			if s.State == "pending" {
				v.Waiting = append(v.Waiting, s)
				continue
			}
			if !seen[s.Agent] {
				seen[s.Agent] = true
				v.Agents = append(v.Agents, s.Agent)
			}
			if v.Agent == "" || v.Agent == s.Agent {
				v.History = append(v.History, s)
			}
		}
		render(r, w, req, requestsPageTmpl, pageData{Title: "Requests", Requests: &v, OwnH1: true})
	})
	// The badge in the bar: drawn only while something waits, so an
	// account with no requests sees no button and no zero.
	mux.HandleFunc("GET /me/requests.badge", func(w http.ResponseWriter, req *http.Request) {
		w.Header().Set("Content-Type", "text/event-stream")
		user, ok := sessions.Resolve(cookieToken(req))
		if !ok {
			return
		}
		out, err := invoke(ops.WithUser(req.Context(), user), r, "signoff.list", url.Values{})
		if err != nil {
			return
		}
		var l struct {
			Requests []meSignOff `json:"requests"`
		}
		remarshal(out, &l)
		waiting := 0
		for _, s := range l.Requests {
			if s.State == "pending" {
				waiting++
			}
		}
		if waiting == 0 {
			return
		}
		fmt.Fprint(w, "event: datastar-patch-elements\n")
		fmt.Fprintf(w, "data: elements %s\n\n", requestsBadgeHTML(waiting))
	})
	mux.HandleFunc("GET /me/requests/{id}", func(w http.ResponseWriter, req *http.Request) {
		user, ok := sessions.Resolve(cookieToken(req))
		if !ok {
			http.Redirect(w, req, "/login?next="+url.QueryEscape(req.URL.RequestURI()), http.StatusSeeOther)
			return
		}
		signOffPageOf(w, req, user, "")
	})
	mux.HandleFunc("GET /me/signoffs/{id}", func(w http.ResponseWriter, req *http.Request) {
		user, ok := sessions.Resolve(cookieToken(req))
		if !ok {
			http.Redirect(w, req, "/login?next="+url.QueryEscape(req.URL.RequestURI()), http.StatusSeeOther)
			return
		}
		signOffPageOf(w, req, user, "")
	})
	// A decision on a request for sign-off. Posted, never a link: a
	// request that deletes a repository must not run from a GET.
	mux.HandleFunc("POST /me/signoffs/{id}", func(w http.ResponseWriter, req *http.Request) {
		user, ok := signedIn(w, req)
		if !ok {
			return
		}
		form := url.Values{"id": {req.PathValue("id")}, "approve": {req.FormValue("approve")}}
		// From the request's own page, the answer is that page again,
		// now saying what became of it.
		if req.FormValue("back") == "page" {
			if _, err := invoke(ops.WithUser(req.Context(), user), r, "signoff.decide", form); err != nil {
				signOffPageOf(w, req, user, err.Error())
				return
			}
			http.Redirect(w, req, "/me/requests/"+url.PathEscape(req.PathValue("id")), http.StatusSeeOther)
			return
		}
		// From the Requests list, back to the list.
		if req.FormValue("back") == "list" {
			if _, err := invoke(ops.WithUser(req.Context(), user), r, "signoff.decide", form); err != nil {
				fail(r, w, req, err)
				return
			}
			http.Redirect(w, req, "/me/requests", http.StatusSeeOther)
			return
		}
		if _, err := invoke(ops.WithUser(req.Context(), user), r, "signoff.decide", form); err != nil {
			v, verr := meAccountPageOf(r, req, user)
			if verr != nil {
				fail(r, w, req, verr)
				return
			}
			renderMe(r, w, req, user, v, "", "signoffs", err.Error(), http.StatusBadRequest)
			return
		}
		http.Redirect(w, req, "/me#signoffs", http.StatusSeeOther)
	})
	mux.HandleFunc("POST /me/agents", func(w http.ResponseWriter, req *http.Request) {
		user, ok := signedIn(w, req)
		if !ok {
			return
		}
		if err := req.ParseForm(); err != nil {
			fail(r, w, req, err)
			return
		}
		name := strings.TrimSpace(req.FormValue("name"))
		form := url.Values{"name": {name}, "scope": {req.FormValue("scope")}}
		if req.FormValue("site_admin") != "" {
			form.Set("site_admin", "true")
			if req.FormValue("confirm") != "" {
				form.Set("confirm", "true")
			}
		}
		if _, err := invoke(ops.WithUser(req.Context(), user), r, "agent.new", form); err != nil {
			v, verr := meAccountPageOf(r, req, user)
			if verr != nil {
				fail(r, w, req, verr)
				return
			}
			renderMe(r, w, req, user, v, "", "agents", err.Error(), http.StatusBadRequest)
			return
		}
		http.Redirect(w, req, "/me/agents/"+url.PathEscape(name), http.StatusSeeOther)
	})
	mux.HandleFunc("POST /me/agents/revoke-all", func(w http.ResponseWriter, req *http.Request) {
		user, ok := signedIn(w, req)
		if !ok {
			return
		}
		if _, err := invoke(ops.WithUser(req.Context(), user), r, "agent.token.revoke_all", url.Values{}); err != nil {
			v, verr := meAccountPageOf(r, req, user)
			if verr != nil {
				fail(r, w, req, verr)
				return
			}
			renderMe(r, w, req, user, v, "", "agents", err.Error(), http.StatusBadRequest)
			return
		}
		http.Redirect(w, req, "/me#agents", http.StatusSeeOther)
	})
	mux.HandleFunc("GET /me/agents/{name}", func(w http.ResponseWriter, req *http.Request) {
		user, ok := signedIn(w, req)
		if !ok {
			return
		}
		p, ok := agentPageOf(r, req, user, req.PathValue("name"))
		if !ok {
			fail(r, w, req, notYourAgent(req.PathValue("name")))
			return
		}
		agentPage(w, req, user, p, http.StatusOK)
	})
	mux.HandleFunc("POST /me/agents/{name}/delete", func(w http.ResponseWriter, req *http.Request) {
		user, ok := signedIn(w, req)
		if !ok {
			return
		}
		if act(w, req, user, "agent.delete", url.Values{"name": {req.PathValue("name")}}) {
			http.Redirect(w, req, "/me#agents", http.StatusSeeOther)
		}
	})
	// Minting renders rather than redirects, for the reason the push
	// credential does: the value is readable once, and a redirect would
	// either lose it or carry it in a URL.
	mux.HandleFunc("POST /me/agents/{name}/token", func(w http.ResponseWriter, req *http.Request) {
		user, ok := signedIn(w, req)
		if !ok {
			return
		}
		if err := req.ParseForm(); err != nil {
			fail(r, w, req, err)
			return
		}
		form := url.Values{
			"name":    {strings.TrimSpace(req.FormValue("name"))},
			"scopes":  {strings.Join(req.PostForm["scope"], ",")},
			"repos":   {strings.Join(req.PostForm["repo"], ",")},
			"ceiling": {req.FormValue("ceiling")},
		}
		if d := req.FormValue("days"); d != "" {
			form.Set("days", d)
		}
		form.Set("agent", req.PathValue("name"))
		out, err := invoke(ops.WithUser(req.Context(), user), r, "agent.token.new", form)
		if err != nil {
			refuse(w, req, user, err)
			return
		}
		p, found := agentPageOf(r, req, user, req.PathValue("name"))
		if !found {
			fail(r, w, req, notYourAgent(req.PathValue("name")))
			return
		}
		var t struct {
			Value string `json:"value"`
			Token struct {
				Name string `json:"name"`
			} `json:"token"`
		}
		remarshal(out, &t)
		p.NewValue, p.NewName = t.Value, t.Token.Name
		// The value is in this body and must not outlive it: no cache,
		// bfcache or intermediary keeps the page, and nothing is leaked
		// through a Referer from it.
		w.Header().Set("Cache-Control", "no-store")
		w.Header().Set("Referrer-Policy", "no-referrer")
		agentPage(w, req, user, p, http.StatusOK)
	})
	// Revoke tokens renders like a mint: the replacement's value is in
	// this body and nowhere else.
	mux.HandleFunc("POST /me/agents/{name}/token/reissue", func(w http.ResponseWriter, req *http.Request) {
		user, ok := signedIn(w, req)
		if !ok {
			return
		}
		out, err := invoke(ops.WithUser(req.Context(), user), r, "agent.token.reissue", url.Values{"name": {req.PathValue("name")}})
		if err != nil {
			refuse(w, req, user, err)
			return
		}
		p, found := agentPageOf(r, req, user, req.PathValue("name"))
		if !found {
			fail(r, w, req, notYourAgent(req.PathValue("name")))
			return
		}
		var t struct {
			Name  string `json:"name"`
			Value string `json:"value"`
		}
		remarshal(out, &t)
		p.NewValue, p.NewName = t.Value, t.Name
		w.Header().Set("Cache-Control", "no-store")
		w.Header().Set("Referrer-Policy", "no-referrer")
		agentPage(w, req, user, p, http.StatusOK)
	})
	mux.HandleFunc("POST /me/agents/{name}/token/revoke", func(w http.ResponseWriter, req *http.Request) {
		user, ok := signedIn(w, req)
		if !ok {
			return
		}
		if act(w, req, user, "agent.token.revoke", url.Values{"id": {req.FormValue("id")}}) {
			http.Redirect(w, req, here(req), http.StatusSeeOther)
		}
	})
	mux.HandleFunc("POST /me/agents/{name}/ask", func(w http.ResponseWriter, req *http.Request) {
		user, ok := signedIn(w, req)
		if !ok {
			return
		}
		if !postedField(r, w, req, "on") {
			return
		}
		if act(w, req, user, "agent.ask", url.Values{"agent": {req.PathValue("name")}, "on": {req.FormValue("on")}}) {
			http.Redirect(w, req, here(req), http.StatusSeeOther)
		}
	})
	mux.HandleFunc("POST /me/agents/{name}/inherit", func(w http.ResponseWriter, req *http.Request) {
		user, ok := signedIn(w, req)
		if !ok {
			return
		}
		if !postedField(r, w, req, "on") {
			return
		}
		if act(w, req, user, "agent.inherit", url.Values{"agent": {req.PathValue("name")}, "on": {req.FormValue("on")}}) {
			http.Redirect(w, req, here(req), http.StatusSeeOther)
		}
	})
	// One door for a role change, a new grant and a removal: an empty
	// role is the op's own revoke.
	mux.HandleFunc("POST /me/agents/{name}/grant", func(w http.ResponseWriter, req *http.Request) {
		user, ok := signedIn(w, req)
		if !ok {
			return
		}
		if err := req.ParseForm(); err != nil {
			fail(r, w, req, err)
			return
		}
		role := req.FormValue("role")
		// An empty role is the op's revoke, so only the Remove button
		// and an inheriting agent's explicit "No access" may send one: a
		// dropdown left on its blank "Custom" must not take an agent's
		// access away.
		if req.FormValue("revoke") != "" || role == "none" {
			role = ""
		} else if role == "" && req.FormValue("inherit") == "" {
			refuse(w, req, user, fmt.Errorf("choose a role: %w", ops.ErrInvalid))
			return
		}
		form := url.Values{"repo": {req.FormValue("repo")}, "role": {role}, "agent": {req.PathValue("name")}}
		if req.FormValue("inherit") != "" {
			form.Set("inherit", "true")
		}
		out, err := invoke(ops.WithUser(req.Context(), user), r, "agent.grant", form)
		if err != nil {
			refuse(w, req, user, err)
			return
		}
		var g struct {
			Warning string `json:"warning"`
		}
		remarshal(out, &g)
		if g.Warning == "" {
			http.Redirect(w, req, here(req), http.StatusSeeOther)
			return
		}
		p, found := agentPageOf(r, req, user, req.PathValue("name"))
		if !found {
			fail(r, w, req, notYourAgent(req.PathValue("name")))
			return
		}
		p.Warning = g.Warning
		agentPage(w, req, user, p, http.StatusOK)
	})
}

func notYourAgent(name string) error {
	return fmt.Errorf("no agent of yours is called %s: %w", name, ops.ErrNotFound)
}

// Limit names what a token is limited to: the role its ceiling is, or
// the letters when it is none.
func (t meTokenView) Limit() string {
	if n := oltp.PresetOf(t.Ceiling); n != "" {
		return n
	}
	return t.Ceiling
}

// meAgentsTmpl is the "Your agents" section of /me.
const meAgentsTmpl = `{{define "me-agents"}}{{with .V}}{{if .Show}}<section id="agents" data-hook="account-agents">
<h2>Your agents</h2>
{{if $.Error}}<p class="status err" role="alert"><strong>{{$.Error}}</strong></p>{{end}}
{{if .Rows}}<div class="dt-wrap" data-hook="agent-list"><table class="dt dt-stack"><thead><tr>
<th>Agent</th><th>Repositories</th><th>Newest token</th><th class="dt-act"></th></tr></thead><tbody>
{{range .Rows}}<tr data-hook="agent-row" data-agent="{{.Name}}">
<td><span class="who">{{userIcon .Name}} <a href="/me/agents/{{pathesc .Name}}" data-hook="agent-link">{{.Name}}</a></span>{{if or .Disabled .Suspended}} <span class="b-pill" data-hook="agent-off">off</span>{{end}}</td>
<td data-label="Repositories" data-hook="agent-repos">{{range $i, $g := .Repos}}{{if $i}}, {{end}}<a href="{{repoURL $g.Repo}}">{{$g.Repo}}</a>{{else}}&mdash;{{end}}</td>
<td data-label="Newest token" data-hook="agent-newest">{{with .Newest}}{{.Name}} <span class="sub">{{ago .Created}}</span>{{else}}&mdash;{{end}}</td>
<td class="dt-act"><form method="post" action="/me/agents/{{pathesc .Name}}/delete" class="inline"><button type="submit" class="btn-warn" data-hook="agent-delete" data-confirm="{{.Name}} is deleted with its tokens and sessions; what it wrote stays under its name.">Delete</button></form></td>
</tr>{{end}}
</tbody></table></div>{{end}}
{{if .CanCreate}}<form method="post" action="/me/agents" class="acct-email-form" data-hook="agent-new">
<label class="sr-only" for="agent-new-name">Agent name</label>
<input id="agent-new-name" name="name" maxlength="64" required placeholder="name">
<label class="sr-only" for="agent-new-scope">Reaches</label>
<select id="agent-new-scope" name="scope" data-hook="agent-new-scope"><option value="">everywhere you reach</option>{{with .Teams}}<optgroup label="one team">{{range .}}<option value="team:{{.}}">{{.}}</option>{{end}}</optgroup>{{end}}{{with .Repos}}<optgroup label="one repository">{{range .}}<option value="repo:{{.}}">{{.}}</option>{{end}}</optgroup>{{end}}</select>
{{if .SiteAdmin}}<p class="sub" data-hook="agent-new-site"><label><input type="checkbox" name="site_admin" value="true"> also a site admin while you are one (only with &ldquo;everywhere you reach&rdquo;)</label><br>
<label><input type="checkbox" name="confirm" value="true"> I understand anyone holding its token can then manage the whole forge</label></p>{{end}}
<button type="submit" class="btn-primary">New agent</button>
</form>{{end}}
{{if .Live}}<form method="post" action="/me/agents/revoke-all"><p><button type="submit" class="btn-warn" data-hook="agents-revoke-all" data-confirm="Every token of every agent of yours stops working now.">Revoke every agent token</button></p></form>{{end}}
</section>{{end}}{{end}}{{end}}`

// agentPageTmpl is an agent's own page.
var agentPageTmpl = page(template.Must(notifyBase.clone().Parse(`{{define "content"}}
{{template "account-styles"}}
{{with .Agent}}{{$name := .Name}}{{$agent := .}}
<p><a href="/me#agents" data-hook="agent-back">Your agents</a></p>
<p data-hook="agent-head">{{userIcon .Name}} <strong>{{.Name}}</strong> <span class="agent-tag" data-hook="agent-mark">agent</span>{{if or .Disabled .Suspended}} <span class="b-pill" data-hook="agent-off">off</span>{{end}}</p>
{{if eq .Operator "forge"}}<p data-hook="agent-operator">forge agent</p>{{else if .Dedicated}}<p data-hook="agent-operator" data-owner="{{.Operator}}">belongs to {{if eq .Operator (print "repo:" .OwnerRepo)}}the repository <a href="{{repoURL .OwnerRepo}}">{{.OwnerRepo}}</a>{{else}}the team <a href="/t/{{pathesc .OwnerTeam}}">{{.OwnerTeam}}</a>{{end}}{{with .Answerable}} &middot; answerable: <a href="/people/{{pathesc .}}">{{.}}</a>{{end}}</p>{{else}}<p data-hook="agent-operator">operated by <a href="/people/{{pathesc .Operator}}">{{.Operator}}</a></p>{{end}}
{{if .Error}}<p class="status err" role="alert" data-hook="agent-error"><strong>{{.Error}}</strong></p>{{end}}
{{if .Warning}}<p class="status" role="status" data-hook="agent-warning">{{.Warning}}</p>{{end}}
{{if .NewValue}}<div class="agent-once" data-hook="agent-token-once">
<p><strong>{{.NewName}}</strong> <span class="chip">shown once</span></p>
<pre class="push-token" data-hook="agent-token-value">{{.NewValue}}</pre>
<p data-signals:_copied="false"><button type="button" class="btn" data-hook="agent-token-copy" data-on:click="navigator.clipboard.writeText(evt.currentTarget.parentElement.previousElementSibling.textContent).then(() => { $_copied = true; setTimeout(() => { $_copied = false }, 4000) })" data-text="$_copied ? 'Copied' : 'Copy'">Copy</button> <span class="sr-only" role="status" aria-live="polite" data-hook="agent-token-copied" data-text="$_copied ? 'token copied' : ''"></span></p>
</div>{{end}}
<section id="asking" data-hook="agent-asking">
<h2>Asking</h2>
<form method="post" action="/me/agents/{{pathesc .Name}}/ask" class="inline" data-hook="agent-ask" data-asks="{{.Asks}}">
{{if .Asks}}<p>{{.Name}} <strong>may ask you</strong> for what it cannot do itself. Each request reaches you by inbox, push and email, and runs only if you approve it, as you.</p>
<p><input type="hidden" name="on" value="false"><button type="submit" class="btn" data-hook="agent-ask-off">Stop it asking</button></p>
{{else}}<p>{{.Name}} <strong>does not ask you</strong>: what it cannot do itself is refused.</p>
<p><input type="hidden" name="on" value="true"><button type="submit" class="btn" data-hook="agent-ask-on">Let it ask</button></p>
{{end}}</form>
</section>
<section id="access" data-hook="agent-access">
<h2>Access</h2>
{{if .Personal}}<p data-hook="agent-scope" data-scope="{{.Scope}}">Reaches {{if eq .Scope ""}}every repository you reach{{else if eq .Scope "gone"}}nothing: the team or repository it was made for was deleted{{else}}{{.ScopeName}} only{{end}}.</p>{{end}}
{{if .Personal}}<form method="post" action="/me/agents/{{pathesc .Name}}/inherit" class="inline" data-hook="agent-inherit" data-inherit="{{.Inherit}}">
{{if .Inherit}}<p>{{.Name}} <strong>inherits your access</strong>: on {{if .Scope}}{{.ScopeName}}{{else}}every repository you reach{{end}} it holds what you hold, except where you narrow it below. Never your site-admin flag.</p>
<p><input type="hidden" name="on" value="false"><button type="submit" class="btn" data-hook="agent-inherit-off">Only where set</button></p>
{{else}}<p>{{.Name}} holds <strong>only the access set below</strong>; anywhere else, only what every signed-in account holds.</p>
<p><input type="hidden" name="on" value="true"><button type="submit" class="btn" data-hook="agent-inherit-on">Inherit my access</button></p>
{{end}}</form>{{end}}
{{if .Dedicated}}<p data-hook="agent-dedicated-access">It holds what {{if eq .Operator (print "repo:" .OwnerRepo)}}the repository&rsquo;s admins grant it on <a href="{{repoURL .OwnerRepo}}/settings#people">its People page</a>, on {{.OwnerRepo}} only{{else}}the team&rsquo;s owners give it as a member on <a href="/t/{{pathesc .OwnerTeam}}">the team&rsquo;s page</a>, on the team&rsquo;s repositories only{{end}}. Never the site-admin flag.</p>
{{range .Repos}}{{if .Effective}}<p data-hook="agent-grant" data-repo="{{.Repo}}" data-effective="{{.Effective}}">{{repoIcon .Repo}} <a href="{{repoURL .Repo}}">{{repoName .Repo}}</a> <span class="chip role">{{.EffRole}}</span> <span class="sub">{{.Sentence}}</span></p>{{end}}{{end}}
{{else}}{{if .Repos}}<div class="dt-wrap"><table class="dt dt-stack"><thead><tr>
<th>Repository</th><th>Role</th><th>Resolves to</th><th class="dt-act"></th></tr></thead><tbody>
{{range .Repos}}{{$g := .}}<tr data-hook="agent-grant" data-repo="{{.Repo}}" data-caps="{{.Caps}}" data-effective="{{.Effective}}">
<td><span class="who">{{repoIcon .Repo}} <a href="{{repoURL .Repo}}">{{repoName .Repo}}</a></span></td>
<td data-label="Role"><form method="post" action="/me/agents/{{pathesc $name}}/grant" class="inline">
<input type="hidden" name="repo" value="{{.Repo}}">
<select name="role" data-hook="agent-role" aria-label="role on {{repoName .Repo}}">{{if not (eq .Role "Reader" "Publisher" "Developer" "Maintainer" "Admin")}}<option value="{{if eq .Role "No access"}}none{{end}}" selected>{{.Role}}</option>{{end}}{{range $agent.Roles}}<option value="{{.Name}}"{{if eq .Name $g.Role}} selected{{end}}>{{.Name}}</option>{{end}}</select>
<button type="submit" class="btn" data-hook="agent-role-change">Change</button></form></td>
<td class="agent-resolves" data-label="Resolves to" data-hook="agent-resolves">{{if .Effective}}<span class="chip role" data-hook="agent-effective-role">{{.EffRole}}</span> <code data-hook="agent-effective-caps">{{.Effective}}</code>{{if .Narrowed}} <span class="chip minus" data-hook="agent-narrowed">narrowed</span>{{end}}<span class="sub" data-hook="agent-sentence">{{.Sentence}}</span>{{else}}<span class="d-sub" data-hook="agent-no-access">nothing</span>{{end}}</td>
<td class="dt-act">{{if $agent.Inherit}}<form method="post" action="/me/agents/{{pathesc $name}}/grant" class="inline"><input type="hidden" name="repo" value="{{.Repo}}"><input type="hidden" name="inherit" value="1"><button type="submit" class="btn" data-hook="agent-grant-inherit">Inherit here</button></form>{{else}}<form method="post" action="/me/agents/{{pathesc $name}}/grant" class="inline"><input type="hidden" name="repo" value="{{.Repo}}"><input type="hidden" name="revoke" value="1"><button type="submit" class="btn-warn" data-hook="agent-grant-remove" data-confirm="{{$name}} loses its access to {{.Repo}}.">Remove</button></form>{{end}}</td>
</tr>{{end}}
</tbody></table></div>{{end}}
{{if .Grantable}}<form method="post" action="/me/agents/{{pathesc .Name}}/grant" class="acct-email-form" data-hook="agent-grant-new">
<label class="sr-only" for="agent-grant-repo">Repository</label>
<select id="agent-grant-repo" name="repo">{{range .Grantable}}<option value="{{.}}">{{.}}</option>{{end}}</select>
<label class="sr-only" for="agent-grant-role">Role</label>
<select id="agent-grant-role" name="role">{{if .Inherit}}<option value="none">No access</option>{{end}}{{range .Roles}}<option value="{{.Name}}">{{.Name}}</option>{{end}}</select>
<button type="submit" class="btn-primary">{{if .Inherit}}Narrow{{else}}Give access{{end}}</button>
</form>{{end}}{{end}}
</section>
<section id="tokens" data-hook="agent-tokens">
<h2>Tokens</h2>
{{if .Tokens}}<div class="dt-wrap" data-hook="agent-token-list"><table class="dt dt-stack"><thead><tr>
<th>Name</th><th>Can use</th><th>Repositories</th><th>Limited to</th><th>Ends</th><th>Last used</th><th class="dt-act"></th></tr></thead><tbody>
{{range .Tokens}}<tr data-hook="agent-token" data-token="{{.ID}}" data-state="{{.State}}"{{if .Ended}} class="tok-ended"{{end}}>
<td data-hook="agent-token-name" title="{{.ID}}">{{.Name}}{{if eq .State "revoked"}} <span class="b-pill">revoked</span>{{else if eq .State "expired"}} <span class="b-pill">expired</span>{{else if eq .State "expiring"}} <span class="b-pill b-warn">expires soon</span>{{end}}</td>
<td data-label="Can use" data-hook="agent-token-scope">{{range $i, $s := .Scopes}}{{if $i}}, {{end}}{{$s}}{{end}}</td>
<td data-label="Repositories" data-hook="agent-token-repos">{{range $i, $r := .Repos}}{{if $i}}, {{end}}{{$r}}{{else}}all{{end}}</td>
<td data-label="Limited to" data-hook="agent-token-limit">{{if .Ceiling}}{{.Limit}}{{else}}its role{{end}}</td>
<td data-label="Ends" data-hook="agent-token-expires" data-idle="{{.Idle}}">{{if eq .State "revoked"}}&mdash;{{else}}{{if .Idle}}<span class="sub">if unused until</span> {{end}}<time datetime="{{.Lapses}}">{{if ge (len .Lapses) 10}}{{slice .Lapses 0 10}}{{else}}{{.Lapses}}{{end}}</time>{{end}}</td>
<td data-label="Last used" data-hook="agent-token-used">{{if .LastUsed}}{{ago .LastUsed}}{{else}}&mdash;{{end}}</td>
<td class="dt-act">{{if not .Ended}}<form method="post" action="/me/agents/{{pathesc $name}}/token/revoke" class="inline"><input type="hidden" name="id" value="{{.ID}}"><button type="submit" class="btn-warn" data-hook="agent-token-revoke">Revoke</button></form>{{end}}</td>
</tr>{{end}}
</tbody></table></div>
{{if .Newest}}<form method="post" action="/me/agents/{{pathesc .Name}}/token/reissue" class="inline"><p><button type="submit" class="btn-warn" data-hook="agent-token-reissue" data-confirm="Every token of {{.Name}} stops working now, and one new token like the last one used is shown once.">Revoke tokens</button> <span class="sub">revokes every token and issues one fresh one in its place</span></p></form>{{end}}{{end}}
<p class="sub" data-hook="agent-token-life">A token has no expiry date: it works while it is used, and lapses after 90 days unused.</p>
<form method="post" action="/me/agents/{{pathesc .Name}}/token" data-hook="agent-token-new">
<p><label>Name<br><input name="name" maxlength="80" required></label></p>
<p data-hook="agent-token-scopes">Can use<br><label><input type="checkbox" name="scope" value="sync" checked> sync</label> <label><input type="checkbox" name="scope" value="api" checked> api</label> <label><input type="checkbox" name="scope" value="web"> web</label>{{if .Site}} <label title="carries {{.Name}}'s site-admin flag to the api and web doors; never past a private repository it holds no grant on"><input type="checkbox" name="scope" value="site" data-hook="agent-token-site"> site</label>{{end}}</p>
{{if .TokenRepos}}<p data-hook="agent-token-repos-pick">Repositories<br>{{range .TokenRepos}}<label><input type="checkbox" name="repo" value="{{.}}"> {{repoName .}}</label> {{end}}</p>{{end}}
{{if .Ceilings}}<p><label>Limited to<br><select name="ceiling" data-hook="agent-token-ceiling"><option value="">its role</option>{{range .Ceilings}}<option value="{{.Caps}}">{{.Name}}</option>{{end}}</select></label></p>{{end}}
<p><button type="submit" class="btn-primary">New token</button></p>
</form>
</section>
<form method="post" action="/me/agents/{{pathesc .Name}}/delete"><p><button type="submit" class="btn-warn" data-hook="agent-delete" data-confirm="{{.Name}} is deleted with its tokens and sessions; what it wrote stays under its name.">Delete {{.Name}}</button></p></form>
{{end}}
{{end}}`)))

// signOffPageTmpl is one request for sign-off: who asks, for what and
// why, and the two buttons (RFC 0027, Answering).
var signOffPageTmpl = page(template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}{{with .SignOff}}
<section class="approve" data-hook="signoff-page" data-state="{{.State}}">
<h1>{{if eq .State "pending"}}Approve?{{else if eq .State "ran"}}Ran on its own right{{else}}Request {{if eq .State "approved"}}approved{{else if eq .State "failed"}}approved, did not run{{else}}closed{{end}}{{end}}</h1>
<p class="approve-title"><strong data-hook="signoff-agent">{{.Agent}}</strong> {{if eq .State "ran"}}ran{{else if eq .State "pending"}}asks you to run{{else}}asked you to run{{end}} <code data-hook="signoff-summary">{{.Summary}}</code></p>
{{with .Reason}}<p data-hook="signoff-reason">“{{.}}”</p>{{end}}
{{if eq .State "ran"}}<p data-hook="signoff-parked"><strong>{{.Agent}}</strong> ran this on its own right, with nobody's sign-off. The value it made is held for pickup and is not shown here.</p>{{end}}
{{with .Setup}}<dl class="review-definition" data-hook="setup" data-kind="{{.Kind}}" data-done="{{.Done}}">
<dt>Agent</dt><dd data-hook="setup-agent" data-agent="{{.Agent}}" data-belongs="{{.Belongs}}" data-owner="{{.Owner}}"><strong>{{.Agent}}</strong>{{if .Done}}, made{{else if eq $.SignOff.State "pending"}}, new{{end}}. {{if eq .Belongs "repository"}}Belongs to the repository <a href="{{repoURL .Owner}}">{{.Owner}}</a>; its admins manage it.{{else if .Belongs}}Yours: you answer for it, and it never holds more than you do.{{end}}</dd>
<dt>{{if gt (len .Repos) 1}}Repositories{{else}}Repository{{end}}</dt><dd data-hook="setup-repos" data-repos="{{range $i, $r := .Repos}}{{if $i}},{{end}}{{$r}}{{end}}">{{range $i, $r := .Repos}}{{if $i}}, {{end}}<a href="{{repoURL $r}}">{{$r}}</a>{{end}}.</dd>
<dt>Role</dt><dd data-hook="setup-role" data-role="{{.Role}}"><strong>{{.Role}}</strong>{{if gt (len .Repos) 1}} on each{{else}} on {{index .Repos 0}}{{end}}. {{.RoleSentence}}</dd>
<dt>Credential</dt><dd data-hook="setup-scope" data-scopes="{{range $i, $s := .Scopes}}{{if $i}},{{end}}{{$s}}{{end}}">{{if .Push}}Sync only: push and pull. No API, no browser session.{{else}}API only: it reads. It cannot sync, post or change anything.{{end}}</dd>
<dt>Reaches</dt><dd data-hook="setup-reach" data-repos="{{range $i, $r := .TokenRepos}}{{if $i}},{{end}}{{$r}}{{end}}">{{range $i, $r := .TokenRepos}}{{if $i}}, {{end}}{{$r}}{{end}}{{if gt (len .TokenRepos) 1}}, and nothing else{{else}} only{{end}}.</dd>
<dt>Ceiling</dt><dd data-hook="setup-ceiling" data-ceiling="{{.Ceiling}}">{{if .Push}}The letters of {{.Role}}, never more.{{else}}What a visitor reads on a public repository, and seeing CI runs.{{end}} <code>{{.Ceiling}}</code></dd>
<dt>Expires</dt><dd data-hook="setup-expires" data-days="{{.Days}}">{{if gt .Days 0}}Ends {{.Days}} days after {{if .Done}}it was made{{else}}you approve{{end}}.{{else}}No fixed date. Lapses after 90 days unused.{{end}}</dd>
<dt>Collected by</dt><dd data-hook="setup-collector"{{with .Pickup}} data-pickup="{{slice . 0 12}}"{{end}}>{{with .Pickup}}The holder of pickup code <code>{{slice . 0 4}} {{slice . 4 8}} {{slice . 8 12}}</code>.{{else}}The asking agent, {{$.SignOff.Agent}}.{{end}}</dd>
{{if ne $.SignOff.State "failed"}}<dt>If not collected</dt><dd data-hook="setup-lapse">{{if $.SignOff.Collected}}Collected {{ago $.SignOff.Collected}}.{{else}}Revoked 24 hours after {{if .Done}}you said yes{{else}}you approve{{end}}.{{end}}</dd>{{end}}
{{if .Done}}<dt>Token</dt><dd data-hook="setup-token">{{.Token}}</dd>{{end}}
{{if .Push}}<dt>Check-ins</dt><dd data-hook="setup-checkins">Pushes are recorded as pushed by {{.Agent}}. Each check-in keeps the author it names.</dd>{{end}}
</dl>
{{with .Problem}}<p class="status err" role="alert" data-hook="setup-problem">This could not run as things stand: {{.}}</p>{{end}}
{{if eq $.SignOff.State "failed"}}{{if eq .Outcome "left"}}<p class="status err" role="alert" data-hook="setup-left" data-outcome="left">Not everything could be taken back. Still there: {{range $i, $l := .Left}}{{if $i}}, {{end}}{{$l}}{{end}}. An administrator should remove {{if gt (len .Left) 1}}them{{else}}it{{end}}.</p>{{else if eq .Outcome "unknown"}}<p class="status err" role="alert" data-hook="setup-left" data-outcome="unknown">It could not be recorded whether anything was made. An administrator should check for the agent {{.Agent}} and a token named for this request.</p>{{else}}<p data-hook="setup-undone" data-outcome="undone">Nothing was made: no agent, no grant and no token.</p>{{end}}{{end}}{{end}}
{{with .Pickup}}{{if not $.SignOff.Setup}}<p data-hook="signoff-pickup" data-pickup="{{slice . 0 12}}">Collected by the holder of pickup code <code>{{slice . 0 4}} {{slice . 4 8}} {{slice . 8 12}}</code>, not through the asking agent.</p>{{end}}
{{if $.SignOff.Setup}}{{else if $.SignOff.Collected}}<p class="tl-meta-inline" data-hook="signoff-collected">Collected {{ago $.SignOff.Collected}}.</p>{{else if or (eq $.SignOff.State "approved") (eq $.SignOff.State "ran")}}<p class="tl-meta-inline" data-hook="signoff-uncollected">Not collected yet; revoked 24 hours after {{if eq $.SignOff.State "ran"}}it ran{{else}}you said yes{{end}} if nobody collects it.</p>{{end}}{{end}}
{{with .Steps}}<ol class="plan-steps" data-hook="signoff-steps">{{range .}}<li data-hook="signoff-step" data-state="{{.State}}">{{.Sentence}}{{with .State}} <span class="tl-meta-inline">{{.}}</span>{{end}}{{with .Err}} <span class="tl-meta-inline">{{.}}</span>{{end}}</li>{{end}}</ol>{{end}}
<p class="tl-meta-inline" data-hook="signoff-when">{{if eq .State "ran"}}ran {{ago .Requested}}{{else}}asked {{ago .Requested}}{{with .Decided}} · decided {{ago .}}{{end}}{{end}}</p>
{{with $.Error}}<p class="status err" role="alert"><strong>{{.}}</strong></p>{{end}}
{{if eq .State "pending"}}<p>Approving runs exactly this, as you, with your own permissions; nothing happens until you choose.</p>
<div class="signoff-acts"><form method="post" action="/me/signoffs/{{.ID}}"><input type="hidden" name="approve" value="false"><input type="hidden" name="back" value="page"><button type="submit" class="btn" data-hook="signoff-deny">Deny</button></form>
<form method="post" action="/me/signoffs/{{.ID}}"><input type="hidden" name="approve" value="true"><input type="hidden" name="back" value="page"><button type="submit" class="btn btn-primary" data-hook="signoff-approve">Approve</button></form></div>
{{else}}{{with .Err}}<p class="tl-meta-inline" data-hook="signoff-err">{{refusal .}}</p>{{end}}{{end}}
<p><a href="/me/requests">All your requests</a></p>
</section>{{end}}{{end}}`)))

// requestsView is /me/requests (RFC 0028).
type requestsView struct {
	Tab     string
	Agent   string
	Agents  []string
	Waiting []meSignOff
	History []meSignOff
}

// requestsBadgeHTML is the bar's Requests button with its count.
func requestsBadgeHTML(n int) string {
	return fmt.Sprintf(`<span id="requests-btn"><a class="bar-btn reqbtn" href="/me/requests" data-hook="requests-badge" data-count="%d" title="%d waiting for you" aria-label="requests, %d waiting"><svg class="ic" aria-hidden="true"><use href="#i-stamp"/></svg><span class="req-n">%d</span></a></span>`, n, n, n, n)
}

// requestsPageTmpl is the Requests page: waiting ones answered in
// place, history folded behind its tab.
var requestsPageTmpl = page(template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}{{with .Requests}}
<section class="requests" data-hook="requests" data-tab="{{.Tab}}">
<h1>Requests</h1>
<nav class="tabs" aria-label="requests"><a href="/me/requests"{{if eq .Tab "waiting"}} aria-current="page" class="on"{{end}} data-hook="requests-tab-waiting">Waiting{{with len .Waiting}} {{.}}{{end}}</a> <a href="/me/requests?tab=history"{{if eq .Tab "history"}} aria-current="page" class="on"{{end}} data-hook="requests-tab-history">History</a></nav>
{{if eq .Tab "waiting"}}{{if .Waiting}}<ul class="signoff-list">{{range .Waiting}}
<li class="signoff-row" data-hook="request" data-state="{{.State}}">
<div class="signoff-what"><a href="/me/requests/{{.ID}}"><strong>{{.Summary}}</strong></a>{{with .Reason}} <span data-hook="signoff-reason">“{{.}}”</span>{{end}}<span class="tl-meta-inline">{{.Agent}} asked {{ago .Requested}}</span>{{with .Steps}}<ol class="plan-steps" data-hook="signoff-steps">{{range .}}<li data-hook="signoff-step" data-state="{{.State}}">{{.Sentence}}{{with .State}} <span class="tl-meta-inline">{{.}}</span>{{end}}{{with .Err}} <span class="tl-meta-inline">{{.}}</span>{{end}}</li>{{end}}</ol>{{end}}</div>
<div class="signoff-acts"><form method="post" action="/me/signoffs/{{.ID}}"><input type="hidden" name="approve" value="false"><input type="hidden" name="back" value="list"><button type="submit" class="btn" data-hook="request-deny">Deny</button></form><form method="post" action="/me/signoffs/{{.ID}}"><input type="hidden" name="approve" value="true"><input type="hidden" name="back" value="list"><button type="submit" class="btn btn-primary" data-hook="request-approve">Approve</button></form></div>
</li>{{end}}</ul>{{else}}<p data-hook="requests-none">Nothing is waiting for you.</p>{{end}}
{{else}}{{if .Agents}}<p class="tabs" data-hook="requests-agents"><a href="/me/requests?tab=history"{{if not .Agent}} class="on"{{end}}>every agent</a>{{range .Agents}} <a href="/me/requests?tab=history&amp;agent={{.}}"{{if eq . $.Requests.Agent}} class="on"{{end}}>{{.}}</a>{{end}}</p>{{end}}
{{if .History}}<ul class="signoff-list">{{range .History}}
<li class="signoff-row" data-hook="request" data-state="{{.State}}">
<div class="signoff-what"><a href="/me/requests/{{.ID}}">{{.Summary}}</a><span class="tl-meta-inline">{{.Agent}} asked {{ago .Requested}}</span></div>
<div class="signoff-end"><span class="chip">{{if eq .State "approved"}}approved{{else if eq .State "failed"}}approved, did not run{{else if eq .State "ran"}}ran on its own right{{else}}denied{{end}}</span></div>
</li>{{end}}</ul>{{else}}<p data-hook="requests-none">Nothing decided yet.</p>{{end}}{{end}}
</section>{{end}}{{end}}`)))