Files
llmgw/internal/web/org_page.go
T
Ilker ManapandClaude Opus 5.5 102ca9556f An organization's admin, and forms only for those who may send them
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>
2026-11-08 11:38:06 +01:00

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)})
}
}
}