A member of an organization can be its manager (users join --org X --role manager, or the star on the people page). /org shows a manager their organization: usage over time by team, project or person, its teams and projects with limits and 30-day spend, and its members, each once with every place they belong. Read-only; administrators see every organization. Only what was spent in the organization's projects is shown — UsageBucketsIn filters by where a request was spent. Found on the way: - Usage by org, team and project, and the quotas page's spend, joined through api_keys and so dropped every chat-page request, which has no key row. They now read usage.project_id; keys no longer move between projects, so the two agree. - /org was not exempt from API-key auth and answered browsers with a JSON error. A router test now drives the session pages through the middleware; it fails without the fix. Membership queries, org-scoped usage and the reworked joins pass the PostgreSQL store tests on 16. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
574 lines
19 KiB
Go
574 lines
19 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
|
)
|
|
|
|
// AccountAdmin is the store half of account administration.
|
|
type AccountAdmin interface {
|
|
Users(ctx context.Context, state store.UserState) ([]store.User, error)
|
|
UserByID(ctx context.Context, id string) (store.User, error)
|
|
ApproveUser(ctx context.Context, id, projectID, by string, at time.Time) error
|
|
SetUserState(ctx context.Context, id string, state store.UserState) error
|
|
DeleteUserSessions(ctx context.Context, userID string) error
|
|
AllProjects(ctx context.Context) ([]store.ProjectPath, error)
|
|
|
|
// The tenancy tree an account is approved into. Creating it used to need a
|
|
// shell — `llmgw keys init` — so the first thing an operator had to do
|
|
// after installing was leave the interface they were already in.
|
|
AllOrgs(ctx context.Context) ([]store.Org, error)
|
|
AllTeams(ctx context.Context) ([]store.TeamPath, error)
|
|
CreateOrg(ctx context.Context, name string) (string, error)
|
|
CreateTeam(ctx context.Context, orgID, name string) (string, error)
|
|
CreateProject(ctx context.Context, teamID, name string) (string, error)
|
|
|
|
// And keeping it in shape afterwards, which used to have no interface at
|
|
// all: nothing could be renamed, moved or removed, and an approved person
|
|
// stayed in the project they were first put in.
|
|
Tree(ctx context.Context) ([]store.TreeOrg, error)
|
|
RenameOrg(ctx context.Context, id, name string) error
|
|
RenameTeam(ctx context.Context, id, name string) error
|
|
RenameProject(ctx context.Context, id, name string) error
|
|
MoveTeam(ctx context.Context, teamID, orgID string) error
|
|
MoveProject(ctx context.Context, projectID, teamID string) error
|
|
DeleteOrg(ctx context.Context, id string) error
|
|
DeleteTeam(ctx context.Context, id string) error
|
|
DeleteProject(ctx context.Context, id string) error
|
|
AddMembership(ctx context.Context, userID string, scope store.Scope, scopeID, role, by string, at time.Time) error
|
|
RemoveMembership(ctx context.Context, userID string, scope store.Scope, scopeID string, at time.Time) (int, error)
|
|
AllMemberships(ctx context.Context) ([]store.Membership, error)
|
|
DeleteUser(ctx context.Context, userID string, at time.Time) error
|
|
AllKeys(ctx context.Context) ([]store.KeyRow, error)
|
|
RevokeKey(ctx context.Context, id string, at time.Time) error
|
|
}
|
|
|
|
// userRow is an account as the page shows it: with where it is, not an id.
|
|
type userRow struct {
|
|
store.User
|
|
// Memberships are where the person is, by path; none is a person who uses
|
|
// the gateway on their own.
|
|
Memberships []store.Membership
|
|
// Role is their role in the scope a list of members is drawn for.
|
|
Role string
|
|
}
|
|
|
|
// scopeOption is somewhere a person can be put, for a chooser.
|
|
type scopeOption struct {
|
|
Value string // "project:proj-…"
|
|
Label string // "Acme / ml / training"
|
|
}
|
|
|
|
// keyRow is a key as the page shows it.
|
|
type keyRow struct {
|
|
store.KeyRow
|
|
State string
|
|
}
|
|
|
|
type adminUsersData struct {
|
|
Nav Nav
|
|
Pending []store.User
|
|
Accounts []userRow
|
|
Tree []store.TreeOrg
|
|
// Members is each scope's own members, keyed "project:<id>" and likewise
|
|
// for teams and organizations; Keys is keyed by project id.
|
|
Members map[string][]userRow
|
|
// Scopes is every place a person can be added, organizations and teams
|
|
// included.
|
|
Scopes []scopeOption
|
|
Keys map[string][]keyRow
|
|
Projects []store.ProjectPath
|
|
Orgs []store.Org
|
|
Teams []store.TeamPath
|
|
// Open is the project whose section is shown expanded: the one just acted
|
|
// on, so the result of a change is in view without the reader hunting for
|
|
// it. Scripted pages keep every open section open; this is for the rest.
|
|
Open string
|
|
AdminID string
|
|
Message string
|
|
Error string
|
|
CSRF string
|
|
Version string
|
|
Now string
|
|
// HasMirror and HasQuotas keep the nav from offering a page that was never
|
|
// mounted.
|
|
HasMirror bool
|
|
HasQuotas bool
|
|
}
|
|
|
|
// RegisterAdminUsers mounts account and tenancy administration under
|
|
// /admin/users.
|
|
//
|
|
// What is here and what is not is the whole design. Approving, suspending,
|
|
// moving people and keeping the tree in shape are the work an operator does
|
|
// constantly and should not need a terminal for. Making someone an admin,
|
|
// resetting a password and issuing keys on another person's behalf are not
|
|
// here: each of them turns a stolen admin session into more than it was —
|
|
// more access, or somebody else's account — and each is rare enough that a
|
|
// shell is no hardship. Revoking a key is here, because it only ever takes
|
|
// access away.
|
|
func (p *AccountPages) RegisterAdminUsers(mux *http.ServeMux, s AccountAdmin) {
|
|
mux.HandleFunc("GET /admin/users", p.adminOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) {
|
|
p.renderUsers(w, r, s, admin, "", "", r.URL.Query().Get("open"))
|
|
}))
|
|
|
|
mux.HandleFunc("POST /admin/users/{id}/approve", p.adminAction(s,
|
|
func(ctx context.Context, admin store.User, id string, r *http.Request) (string, error) {
|
|
project := r.FormValue("project")
|
|
if err := s.ApproveUser(ctx, id, project, admin.Email, time.Now()); err != nil {
|
|
return "", err
|
|
}
|
|
if project == "" {
|
|
return "approved, on their own for now", nil
|
|
}
|
|
return "approved into " + projectPath(ctx, s, project), nil
|
|
}))
|
|
|
|
mux.HandleFunc("POST /admin/users/{id}/suspend", p.adminAction(s,
|
|
func(ctx context.Context, admin store.User, id string, _ *http.Request) (string, error) {
|
|
if id == admin.ID {
|
|
// Locking yourself out is not a thing an interface should let
|
|
// you do by misclicking.
|
|
return "", errors.New("you cannot suspend your own account")
|
|
}
|
|
if err := s.SetUserState(ctx, id, store.UserSuspended); err != nil {
|
|
return "", err
|
|
}
|
|
// Otherwise they stay signed in and suspension is advisory until
|
|
// their session happens to expire.
|
|
if err := s.DeleteUserSessions(ctx, id); err != nil {
|
|
return "", err
|
|
}
|
|
return "suspended, and signed out everywhere", nil
|
|
}))
|
|
|
|
mux.HandleFunc("POST /admin/users/{id}/reinstate", p.adminAction(s,
|
|
func(ctx context.Context, _ store.User, id string, _ *http.Request) (string, error) {
|
|
if err := s.SetUserState(ctx, id, store.UserActive); err != nil {
|
|
return "", err
|
|
}
|
|
return "reinstated", nil
|
|
}))
|
|
|
|
mux.HandleFunc("POST /admin/users/{id}/join", p.adminAction(s,
|
|
func(ctx context.Context, admin store.User, id string, r *http.Request) (string, error) {
|
|
scope, scopeID, ok := strings.Cut(r.FormValue("scope"), ":")
|
|
if !ok || scopeID == "" {
|
|
return "", errors.New("choose where they go")
|
|
}
|
|
if err := s.AddMembership(ctx, id, store.Scope(scope), scopeID, store.RoleMember,
|
|
admin.Email, time.Now()); err != nil {
|
|
return "", err
|
|
}
|
|
return "added to " + scopeLabel(ctx, s, store.Scope(scope), scopeID), nil
|
|
}))
|
|
|
|
// Managing is a role on an organization membership: it lets the person see
|
|
// that organization's page, and nothing else changes.
|
|
mux.HandleFunc("POST /admin/users/{id}/role", p.adminAction(s,
|
|
func(ctx context.Context, admin store.User, id string, r *http.Request) (string, error) {
|
|
scope, scopeID, ok := strings.Cut(r.FormValue("scope"), ":")
|
|
role := r.FormValue("role")
|
|
if !ok || store.Scope(scope) != store.ScopeOrg || (role != store.RoleManager && role != store.RoleMember) {
|
|
return "", errors.New("a manager is a role in an organization")
|
|
}
|
|
if err := s.AddMembership(ctx, id, store.ScopeOrg, scopeID, role, admin.Email, time.Now()); err != nil {
|
|
return "", err
|
|
}
|
|
label := scopeLabel(ctx, s, store.ScopeOrg, scopeID)
|
|
if role == store.RoleManager {
|
|
return "now manages " + label, nil
|
|
}
|
|
return "no longer manages " + label, nil
|
|
}))
|
|
|
|
mux.HandleFunc("POST /admin/users/{id}/leave", p.adminAction(s,
|
|
func(ctx context.Context, _ store.User, id string, r *http.Request) (string, error) {
|
|
scope, scopeID, ok := strings.Cut(r.FormValue("scope"), ":")
|
|
if !ok {
|
|
return "", errors.New("nothing to leave")
|
|
}
|
|
label := scopeLabel(ctx, s, store.Scope(scope), scopeID)
|
|
revoked, err := s.RemoveMembership(ctx, id, store.Scope(scope), scopeID, time.Now())
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
msg := "removed from " + label
|
|
if revoked > 0 {
|
|
msg += "; " + plural(revoked, "key", "keys") + " they made there revoked"
|
|
}
|
|
return msg, nil
|
|
}))
|
|
|
|
mux.HandleFunc("POST /admin/users/{id}/delete", p.adminAction(s,
|
|
func(ctx context.Context, admin store.User, id string, _ *http.Request) (string, error) {
|
|
if id == admin.ID {
|
|
return "", errors.New("you cannot remove your own account")
|
|
}
|
|
if err := s.DeleteUser(ctx, id, time.Now()); err != nil {
|
|
return "", err
|
|
}
|
|
return "removed; their keys are revoked", nil
|
|
}))
|
|
|
|
// The tenancy tree. Creating a container is not granting access: nobody can
|
|
// use one until an account is approved into it.
|
|
mux.HandleFunc("POST /admin/tenancy/org", p.tenancyAction(s,
|
|
func(ctx context.Context, r *http.Request) (string, error) {
|
|
_, err := s.CreateOrg(ctx, r.FormValue("name"))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return "organization " + r.FormValue("name") + " created", nil
|
|
}))
|
|
|
|
mux.HandleFunc("POST /admin/tenancy/team", p.tenancyAction(s,
|
|
func(ctx context.Context, r *http.Request) (string, error) {
|
|
org := r.FormValue("org")
|
|
if org == "" {
|
|
return "", errors.New("choose the organization this team belongs to")
|
|
}
|
|
_, err := s.CreateTeam(ctx, org, r.FormValue("name"))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return "team " + r.FormValue("name") + " created", nil
|
|
}))
|
|
|
|
mux.HandleFunc("POST /admin/tenancy/project", p.tenancyAction(s,
|
|
func(ctx context.Context, r *http.Request) (string, error) {
|
|
team := r.FormValue("team")
|
|
if team == "" {
|
|
return "", errors.New("choose the team this project belongs to")
|
|
}
|
|
id, err := s.CreateProject(ctx, team, r.FormValue("name"))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
r.Form.Set("open", id)
|
|
return "project " + r.FormValue("name") + " created", nil
|
|
}))
|
|
|
|
mux.HandleFunc("POST /admin/tenancy/{kind}/{id}/rename", p.tenancyAction(s,
|
|
func(ctx context.Context, r *http.Request) (string, error) {
|
|
id, name := r.PathValue("id"), strings.TrimSpace(r.FormValue("name"))
|
|
var err error
|
|
switch r.PathValue("kind") {
|
|
case "org":
|
|
err = s.RenameOrg(ctx, id, name)
|
|
case "team":
|
|
err = s.RenameTeam(ctx, id, name)
|
|
case "project":
|
|
err = s.RenameProject(ctx, id, name)
|
|
default:
|
|
return "", errors.New("nothing of that kind")
|
|
}
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return "renamed to " + name, nil
|
|
}))
|
|
|
|
mux.HandleFunc("POST /admin/tenancy/{kind}/{id}/move", p.tenancyAction(s,
|
|
func(ctx context.Context, r *http.Request) (string, error) {
|
|
id, to := r.PathValue("id"), r.FormValue("to")
|
|
if to == "" {
|
|
return "", errors.New("choose where it goes")
|
|
}
|
|
switch r.PathValue("kind") {
|
|
case "team":
|
|
if err := s.MoveTeam(ctx, id, to); err != nil {
|
|
return "", err
|
|
}
|
|
return "team moved", nil
|
|
case "project":
|
|
if err := s.MoveProject(ctx, id, to); err != nil {
|
|
return "", err
|
|
}
|
|
return "project moved to " + projectPath(ctx, s, id), nil
|
|
}
|
|
return "", errors.New("only a team or a project moves")
|
|
}))
|
|
|
|
mux.HandleFunc("POST /admin/tenancy/{kind}/{id}/delete", p.tenancyAction(s,
|
|
func(ctx context.Context, r *http.Request) (string, error) {
|
|
id := r.PathValue("id")
|
|
var err error
|
|
switch r.PathValue("kind") {
|
|
case "org":
|
|
err = s.DeleteOrg(ctx, id)
|
|
case "team":
|
|
err = s.DeleteTeam(ctx, id)
|
|
case "project":
|
|
err = s.DeleteProject(ctx, id)
|
|
default:
|
|
return "", errors.New("nothing of that kind")
|
|
}
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
r.Form.Del("open")
|
|
return "removed", nil
|
|
}))
|
|
|
|
// The same as joining, from the scope's side: a form on a project, team or
|
|
// organization that names the person.
|
|
mux.HandleFunc("POST /admin/tenancy/{kind}/{id}/add", p.tenancyAction(s,
|
|
func(ctx context.Context, r *http.Request) (string, error) {
|
|
user := r.FormValue("user")
|
|
if user == "" {
|
|
return "", errors.New("choose who to add")
|
|
}
|
|
scope, id := store.Scope(r.PathValue("kind")), r.PathValue("id")
|
|
if err := s.AddMembership(ctx, user, scope, id, store.RoleMember, "", time.Now()); err != nil {
|
|
return "", err
|
|
}
|
|
who := user
|
|
if u, err := s.UserByID(ctx, user); err == nil {
|
|
who = u.Email
|
|
}
|
|
return who + " added to " + scopeLabel(ctx, s, scope, id), nil
|
|
}))
|
|
|
|
mux.HandleFunc("POST /admin/keys/{id}/revoke", p.tenancyAction(s,
|
|
func(ctx context.Context, r *http.Request) (string, error) {
|
|
if err := s.RevokeKey(ctx, r.PathValue("id"), time.Now()); err != nil {
|
|
return "", err
|
|
}
|
|
return "key revoked; a running server stops accepting it when its cache expires", nil
|
|
}))
|
|
}
|
|
|
|
func projectPath(ctx context.Context, s AccountAdmin, id string) string {
|
|
all, err := s.AllProjects(ctx)
|
|
if err == nil {
|
|
for _, p := range all {
|
|
if p.ID == id {
|
|
return p.Path
|
|
}
|
|
}
|
|
}
|
|
return id
|
|
}
|
|
|
|
// scopeLabel names an org, team or project by its path.
|
|
func scopeLabel(ctx context.Context, s AccountAdmin, scope store.Scope, id string) string {
|
|
switch scope {
|
|
case store.ScopeProject:
|
|
return projectPath(ctx, s, id)
|
|
case store.ScopeTeam:
|
|
if teams, err := s.AllTeams(ctx); err == nil {
|
|
for _, t := range teams {
|
|
if t.ID == id {
|
|
return t.OrgName + " / " + t.Name
|
|
}
|
|
}
|
|
}
|
|
case store.ScopeOrg:
|
|
if orgs, err := s.AllOrgs(ctx); err == nil {
|
|
for _, o := range orgs {
|
|
if o.ID == id {
|
|
return o.Name
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return id
|
|
}
|
|
|
|
// plainError says what a store error means to the person who caused it.
|
|
func plainError(err error) error {
|
|
switch {
|
|
case errors.Is(err, store.ErrDuplicateName):
|
|
return errors.New("there is already one of those here")
|
|
case errors.Is(err, store.ErrNotEmpty):
|
|
return fmt.Errorf("not removed: %s", strings.TrimPrefix(err.Error(), store.ErrNotEmpty.Error()+": "))
|
|
case errors.Is(err, store.ErrNotFound):
|
|
return errors.New("that is no longer there; the page was out of date")
|
|
}
|
|
return err
|
|
}
|
|
|
|
// tenancyAction wraps a change to the tree: admin session, CSRF, audit, render.
|
|
//
|
|
// Its own wrapper rather than adminAction because that one is about a subject
|
|
// account and looks its email up to name it in the log. There is no subject
|
|
// here, and passing an empty id through it would write audit lines about
|
|
// nobody.
|
|
func (p *AccountPages) tenancyAction(s AccountAdmin,
|
|
do func(ctx context.Context, r *http.Request) (string, error),
|
|
) http.HandlerFunc {
|
|
return p.adminOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) {
|
|
sess, _, _ := p.session(r)
|
|
if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) {
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
msg, err := do(r.Context(), r)
|
|
if err != nil {
|
|
err = plainError(err)
|
|
p.log.Warn("a tenancy change was refused", "admin", admin.Email,
|
|
"path", r.URL.Path, "err", err)
|
|
p.renderUsers(w, r, s, admin, "", err.Error(), r.FormValue("open"))
|
|
return
|
|
}
|
|
p.log.Info("the tenancy tree was changed", "admin", admin.Email,
|
|
"path", r.URL.Path, "what", msg)
|
|
p.renderUsers(w, r, s, admin, msg, "", r.FormValue("open"))
|
|
})
|
|
}
|
|
|
|
// adminOnly is the guard every page under /admin shares.
|
|
func (p *AccountPages) adminOnly(
|
|
next func(w http.ResponseWriter, r *http.Request, admin store.User),
|
|
) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
noStore(w)
|
|
_, user, ok := p.session(r)
|
|
if !ok {
|
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
|
return
|
|
}
|
|
if !user.IsAdmin {
|
|
// 404 rather than 403: a 403 confirms the page exists.
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
next(w, p.withNav(r, user), user)
|
|
}
|
|
}
|
|
|
|
// adminAction wraps a change to one account: admin session, CSRF, audit line,
|
|
// render.
|
|
func (p *AccountPages) adminAction(s AccountAdmin,
|
|
do func(ctx context.Context, admin store.User, id string, r *http.Request) (string, error),
|
|
) http.HandlerFunc {
|
|
return p.adminOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) {
|
|
sess, _, _ := p.session(r)
|
|
if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) {
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
id := r.PathValue("id")
|
|
|
|
subject := id
|
|
if u, err := s.UserByID(r.Context(), id); err == nil {
|
|
subject = u.Email
|
|
}
|
|
|
|
msg, err := do(r.Context(), admin, id, r)
|
|
if err != nil {
|
|
err = plainError(err)
|
|
// Logged at warn whether it worked or not: a refused administrative
|
|
// action is as worth knowing about as one that went through.
|
|
p.log.Warn("an administrative action was refused",
|
|
"admin", admin.Email, "subject", subject, "err", err)
|
|
p.renderUsers(w, r, s, admin, "", subject+": "+err.Error(), r.FormValue("open"))
|
|
return
|
|
}
|
|
p.log.Info("an account was changed",
|
|
"admin", admin.Email, "subject", subject, "what", msg)
|
|
p.renderUsers(w, r, s, admin, subject+" "+msg, "", r.FormValue("open"))
|
|
})
|
|
}
|
|
|
|
func (p *AccountPages) renderUsers(w http.ResponseWriter, r *http.Request,
|
|
s AccountAdmin, admin store.User, msg, errMsg, open string,
|
|
) {
|
|
ctx := r.Context()
|
|
d := adminUsersData{
|
|
Nav: navFrom(r, "people"),
|
|
Message: msg, Error: errMsg, Open: open, AdminID: admin.ID,
|
|
CSRF: csrfFor(r),
|
|
Version: p.version,
|
|
HasMirror: p.hasMirror,
|
|
HasQuotas: p.hasQuotas,
|
|
Now: time.Now().UTC().Format(time.RFC3339),
|
|
Members: map[string][]userRow{},
|
|
Keys: map[string][]keyRow{},
|
|
}
|
|
var err error
|
|
logErr := func(what string, err error) {
|
|
if err != nil {
|
|
p.log.Error("could not read "+what, "err", err)
|
|
if d.Error == "" {
|
|
d.Error = "could not read " + what
|
|
}
|
|
}
|
|
}
|
|
|
|
d.Projects, err = s.AllProjects(ctx)
|
|
logErr("the projects", err)
|
|
paths := make(map[string]string, len(d.Projects))
|
|
for _, pp := range d.Projects {
|
|
paths[pp.ID] = pp.Path
|
|
}
|
|
|
|
all, err := s.Users(ctx, "")
|
|
logErr("the accounts", err)
|
|
memberships, err := s.AllMemberships(ctx)
|
|
logErr("the memberships", err)
|
|
byUser := map[string][]store.Membership{}
|
|
for _, m := range memberships {
|
|
byUser[m.UserID] = append(byUser[m.UserID], m)
|
|
}
|
|
users := map[string]store.User{}
|
|
for _, u := range all {
|
|
users[u.ID] = u
|
|
if u.State == store.UserPending {
|
|
d.Pending = append(d.Pending, u)
|
|
continue
|
|
}
|
|
d.Accounts = append(d.Accounts, userRow{User: u, Memberships: byUser[u.ID]})
|
|
}
|
|
for _, m := range memberships {
|
|
if u, ok := users[m.UserID]; ok {
|
|
k := string(m.Scope) + ":" + m.ScopeID
|
|
d.Members[k] = append(d.Members[k], userRow{User: u, Memberships: byUser[u.ID], Role: m.Role})
|
|
}
|
|
}
|
|
|
|
keys, err := s.AllKeys(ctx)
|
|
logErr("the keys", err)
|
|
now := time.Now()
|
|
for _, k := range keys {
|
|
d.Keys[k.ProjectID] = append(d.Keys[k.ProjectID], keyRow{KeyRow: k, State: keyState(k.Key, now)})
|
|
}
|
|
|
|
d.Tree, err = s.Tree(ctx)
|
|
logErr("the tree", err)
|
|
d.Orgs, err = s.AllOrgs(ctx)
|
|
logErr("the organizations", err)
|
|
d.Teams, err = s.AllTeams(ctx)
|
|
logErr("the teams", err)
|
|
for _, o := range d.Orgs {
|
|
d.Scopes = append(d.Scopes, scopeOption{"org:" + o.ID, o.Name})
|
|
}
|
|
for _, t := range d.Teams {
|
|
d.Scopes = append(d.Scopes, scopeOption{"team:" + t.ID, t.OrgName + " / " + t.Name})
|
|
}
|
|
for _, pp := range d.Projects {
|
|
d.Scopes = append(d.Scopes, scopeOption{"project:" + pp.ID, pp.Path})
|
|
}
|
|
sort.Slice(d.Scopes, func(i, j int) bool { return d.Scopes[i].Label < d.Scopes[j].Label })
|
|
render(w, "admin_users", d)
|
|
}
|
|
|
|
func keyState(k store.Key, now time.Time) string {
|
|
switch {
|
|
case !k.RevokedAt.IsZero():
|
|
return "revoked"
|
|
case !k.ExpiresAt.IsZero() && !now.Before(k.ExpiresAt):
|
|
return "expired"
|
|
default:
|
|
return "active"
|
|
}
|
|
}
|