// 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}}—{{end}}</td>
<td data-label="Newest token" data-hook="agent-newest">{{with .Newest}}{{.Name}} <span class="sub">{{ago .Created}}</span>{{else}}—{{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 “everywhere you reach”)</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}} · 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’s admins grant it on <a href="{{repoURL .OwnerRepo}}/settings#people">its People page</a>, on {{.OwnerRepo}} only{{else}}the team’s owners give it as a member on <a href="/t/{{pathesc .OwnerTeam}}">the team’s page</a>, on the team’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"}}—{{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}}—{{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&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}}`)))