C1. A membership of an organization may be admin as well as member and manager. An admin sees /org as a manager does and changes the organization from it: its teams and projects, who of the approved accounts is in them, its members' roles, and the keys spent in its projects. Every id a form names is checked against the organization; limits, accounts, the organization's name and the cluster stay with the cluster's staff, and an admin cannot take their own role away. A group from OIDC, SCIM or a directory no longer rewrites a membership it names as a plain member at every sign-in, which took a manager's role away (EnsureMembership). Pages draw a form only for someone who may send it: Nav.May asks the staff rule staffMay keeps, and a page whose forms a person may not send gets no form token. A viewer sees the people, limits and mirror pages without forms; an operator the mirror's. Forms are compact: labels close to their fields, fields as wide as what goes in them, smaller buttons, and the short forms on one line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
301 lines
9.1 KiB
Go
301 lines
9.1 KiB
Go
package web
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/url"
|
|
"sort"
|
|
"time"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
|
)
|
|
|
|
// OrgViewer is what an organization's page reads. What its admin changes
|
|
// goes through OrgEditor, in org_admin.go.
|
|
type OrgViewer interface {
|
|
ManagesOrg(ctx context.Context, userID, orgID string) (bool, error)
|
|
ManagedOrgs(ctx context.Context, userID string) ([]store.Org, error)
|
|
AllOrgs(ctx context.Context) ([]store.Org, error)
|
|
Tree(ctx context.Context) ([]store.TreeOrg, error)
|
|
AllMemberships(ctx context.Context) ([]store.Membership, error)
|
|
Users(ctx context.Context, state store.UserState) ([]store.User, error)
|
|
AllLimits(ctx context.Context) (map[string]store.Limits, error)
|
|
UsageBucketsIn(ctx context.Context, g store.Grouping, orgID string,
|
|
since time.Time, width time.Duration) ([]store.UsageSeries, error)
|
|
UsageByScope(ctx context.Context, scope store.Scope, since time.Time) ([]store.ScopeUsage, error)
|
|
}
|
|
|
|
// orgMember is a person as an organization's page lists them: once, with
|
|
// every place in the organization they are a member of.
|
|
type orgMember struct {
|
|
ID string
|
|
Email string
|
|
State store.UserState
|
|
Places []orgPlace
|
|
// OrgRole is their role on the organization itself, "" when they are
|
|
// only in its teams or projects.
|
|
OrgRole string
|
|
}
|
|
|
|
// orgPlace is one place a member is in: its path within the organization,
|
|
// the role there, and the "scope:id" a form names it by.
|
|
type orgPlace struct{ Where, Role, Value string }
|
|
|
|
// A member's state is a word the page shows and a class it styles by.
|
|
func init() {
|
|
registerKeys("org.state.pending", "org.state.active", "org.state.suspended")
|
|
}
|
|
|
|
type orgPageData struct {
|
|
Nav Nav
|
|
Org store.Org
|
|
Orgs []store.Org // the others this person may look at
|
|
|
|
Groups []usageGroup
|
|
Windows []windowChoice
|
|
By, Window string
|
|
|
|
Requests TimeSeries
|
|
Tokens TimeSeries
|
|
Spend TimeSeries
|
|
Rows []usageRow
|
|
Priced bool
|
|
|
|
Quotas []quotaRow
|
|
Members []orgMember
|
|
People int
|
|
|
|
// Edit is whether this person may change the organization; the rest is
|
|
// what the forms for that need.
|
|
Edit bool
|
|
Me string // the person looking, whose own role is not theirs to change
|
|
CSRF string
|
|
Teams []store.TreeTeam
|
|
Places []scopeOption
|
|
Keys []keyRow
|
|
Message string
|
|
|
|
Error string
|
|
Version string
|
|
Now string
|
|
}
|
|
|
|
// RegisterOrgPages mounts /org: an organization's own view of itself, for the
|
|
// people who manage it.
|
|
//
|
|
// A manager sees their organization's use, members and limits — what they are
|
|
// accountable for — without being made an administrator of the whole cluster.
|
|
// An organization's admin also changes it here (org_admin.go); its limits stay
|
|
// with the cluster's administrators.
|
|
func (p *AccountPages) RegisterOrgPages(mux *http.ServeMux, s OrgViewer, priced Priced) {
|
|
p.orgs = s
|
|
if e, ok := s.(OrgEditor); ok {
|
|
p.orgEditor = e
|
|
p.registerOrgAdmin(mux, s, e, priced)
|
|
}
|
|
mux.HandleFunc("GET /org", p.orgGuard(func(w http.ResponseWriter, r *http.Request, user store.User) {
|
|
if p.toPeople(w, r, user, "") {
|
|
return
|
|
}
|
|
orgs, err := p.viewableOrgs(r.Context(), s, user)
|
|
if err == nil && len(orgs) == 0 && user.IsAdmin {
|
|
// Said, not a 404: an administrator following the link on a
|
|
// cluster with no organizations should learn that, and where to
|
|
// make one.
|
|
render(w, r, "org-none", struct {
|
|
Nav Nav
|
|
Version, Now string
|
|
}{navFrom(r, "org"), p.version, time.Now().UTC().Format(time.RFC3339)})
|
|
return
|
|
}
|
|
if err != nil || len(orgs) == 0 {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/org/"+orgs[0].ID, http.StatusSeeOther)
|
|
}))
|
|
mux.HandleFunc("GET /org/{id}", p.orgGuard(func(w http.ResponseWriter, r *http.Request, user store.User) {
|
|
id := r.PathValue("id")
|
|
if p.toPeople(w, r, user, id) {
|
|
return
|
|
}
|
|
if !user.IsAdmin {
|
|
ok, err := s.ManagesOrg(r.Context(), user.ID, id)
|
|
if err != nil || !ok {
|
|
// 404 rather than 403, like /admin: it does not confirm the
|
|
// organization exists to someone who may not see it.
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
}
|
|
p.renderOrg(w, r, s, user, id, priced, "", "")
|
|
}))
|
|
}
|
|
|
|
// toPeople sends an administrator to the people page, which shows an
|
|
// organization's members, limits and use beside the whole tree and can change
|
|
// them; this page stays for the managers who may not see that one. It says
|
|
// whether it did.
|
|
func (p *AccountPages) toPeople(w http.ResponseWriter, r *http.Request, user store.User, orgID string) bool {
|
|
if !user.IsAdmin || p.peopleStore == nil {
|
|
return false
|
|
}
|
|
to := "/admin/users"
|
|
if orgID != "" {
|
|
to += "?sel=org:" + url.QueryEscape(orgID)
|
|
}
|
|
http.Redirect(w, r, to, http.StatusSeeOther)
|
|
return true
|
|
}
|
|
|
|
// viewableOrgs is every organization an administrator may look at, or the
|
|
// ones a person manages.
|
|
func (p *AccountPages) viewableOrgs(ctx context.Context, s OrgViewer, user store.User) ([]store.Org, error) {
|
|
if user.IsAdmin {
|
|
return s.AllOrgs(ctx)
|
|
}
|
|
return s.ManagedOrgs(ctx, user.ID)
|
|
}
|
|
|
|
func (p *AccountPages) orgGuard(next func(http.ResponseWriter, *http.Request, 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
|
|
}
|
|
next(w, p.withNav(r, user), user)
|
|
}
|
|
}
|
|
|
|
func (p *AccountPages) renderOrg(w http.ResponseWriter, r *http.Request, s OrgViewer,
|
|
user store.User, orgID string, priced Priced, msg, errMsg string,
|
|
) {
|
|
ctx := r.Context()
|
|
by := store.Grouping(r.URL.Query().Get("by"))
|
|
if by != store.GroupByProject && by != store.GroupByUser {
|
|
by = store.GroupByTeam
|
|
}
|
|
win := windowFor(r.URL.Query().Get("window"))
|
|
l := langOf(r)
|
|
d := orgPageData{
|
|
Nav: navFrom(r, "org"),
|
|
By: string(by), Window: win.Key,
|
|
Priced: priced != nil && priced.AnythingPriced(),
|
|
Message: msg, Error: errMsg,
|
|
Version: p.version,
|
|
Now: time.Now().UTC().Format(time.RFC3339),
|
|
}
|
|
for _, g := range []store.Grouping{store.GroupByTeam, store.GroupByProject, store.GroupByUser} {
|
|
d.Groups = append(d.Groups, usageGroup{Key: string(g), Label: groupLabel(l, g), Selected: g == by})
|
|
}
|
|
d.Windows = windowChoices(l, win.Key)
|
|
|
|
d.Orgs, _ = p.viewableOrgs(ctx, s, user)
|
|
tree, err := s.Tree(ctx)
|
|
if err != nil {
|
|
p.log.Error("could not read the tree", "err", err)
|
|
d.Error = l.T("org.err.read_tree")
|
|
}
|
|
var mine []store.TreeOrg
|
|
inOrg := map[string]string{} // scope:id -> path within the org
|
|
for _, o := range tree {
|
|
if o.ID != orgID {
|
|
continue
|
|
}
|
|
d.Org = store.Org{ID: o.ID, Name: o.Name}
|
|
mine = []store.TreeOrg{o}
|
|
inOrg["org:"+o.ID] = l.T("org.whole")
|
|
for _, t := range o.Teams {
|
|
inOrg["team:"+t.ID] = t.Name
|
|
for _, pr := range t.Projects {
|
|
inOrg["project:"+pr.ID] = t.Name + " / " + pr.Name
|
|
}
|
|
}
|
|
}
|
|
if d.Org.ID == "" {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
const quotaWindow = 30 * 24 * time.Hour
|
|
if limits, err := s.AllLimits(ctx); err == nil {
|
|
d.Quotas = quotaRowsIn(l, mine, limits, p.spendByScope(ctx, s, time.Now().Add(-quotaWindow)))
|
|
}
|
|
|
|
users, _ := s.Users(ctx, "")
|
|
byID := map[string]store.User{}
|
|
for _, u := range users {
|
|
byID[u.ID] = u
|
|
}
|
|
ms, _ := s.AllMemberships(ctx)
|
|
byPerson := map[string]*orgMember{}
|
|
for _, m := range ms {
|
|
where, ok := inOrg[string(m.Scope)+":"+m.ScopeID]
|
|
if !ok {
|
|
continue
|
|
}
|
|
om := byPerson[m.UserID]
|
|
if om == nil {
|
|
u := byID[m.UserID]
|
|
om = &orgMember{ID: u.ID, Email: u.Email, State: u.State}
|
|
byPerson[m.UserID] = om
|
|
}
|
|
om.Places = append(om.Places, orgPlace{Where: where, Role: m.Role, Value: string(m.Scope) + ":" + m.ScopeID})
|
|
if m.Scope == store.ScopeOrg {
|
|
om.OrgRole = m.Role
|
|
}
|
|
}
|
|
for _, om := range byPerson {
|
|
d.Members = append(d.Members, *om)
|
|
}
|
|
sort.Slice(d.Members, func(i, j int) bool { return d.Members[i].Email < d.Members[j].Email })
|
|
d.People = len(byPerson)
|
|
|
|
p.orgEditing(r, &d, user, mine)
|
|
|
|
rows, err := s.UsageBucketsIn(ctx, by, orgID, time.Now().Add(-win.Span), win.Width)
|
|
if err != nil {
|
|
p.log.Error("could not read an organization's usage", "org", orgID, "err", err)
|
|
d.Error = l.T("usage.err.read")
|
|
}
|
|
d.Requests, d.Tokens, d.Spend, d.Rows = usageChartsIn(l, rows)
|
|
render(w, r, "org", d)
|
|
}
|
|
|
|
// orgEditing fills what the page's forms need, when this person may change
|
|
// the organization.
|
|
func (p *AccountPages) orgEditing(r *http.Request, d *orgPageData, user store.User, mine []store.TreeOrg) {
|
|
ctx, e := r.Context(), p.orgEditor
|
|
if e == nil || len(mine) != 1 {
|
|
return
|
|
}
|
|
o := mine[0]
|
|
if !user.IsAdmin {
|
|
if ok, err := e.AdministersOrg(ctx, user.ID, o.ID); err != nil || !ok {
|
|
return
|
|
}
|
|
}
|
|
d.Edit, d.Teams, d.Me = true, o.Teams, user.ID
|
|
d.CSRF = csrfFor(r)
|
|
d.Places = append(d.Places, scopeOption{Value: "org:" + o.ID, Label: o.Name})
|
|
for _, t := range o.Teams {
|
|
d.Places = append(d.Places, scopeOption{Value: "team:" + t.ID, Label: o.Name + " / " + t.Name})
|
|
for _, pr := range t.Projects {
|
|
d.Places = append(d.Places, scopeOption{Value: "project:" + pr.ID, Label: o.Name + " / " + t.Name + " / " + pr.Name})
|
|
}
|
|
}
|
|
keys, err := e.AllKeys(ctx)
|
|
if err != nil {
|
|
p.log.Error("could not read the keys", "err", err)
|
|
return
|
|
}
|
|
now := time.Now()
|
|
for _, k := range keys {
|
|
if orgHas(o, store.ScopeProject, k.ProjectID) && k.RevokedAt.IsZero() {
|
|
d.Keys = append(d.Keys, keyRow{KeyRow: k, State: keyState(k.Key, now)})
|
|
}
|
|
}
|
|
}
|