Files
llmgw/internal/web/org_page.go
Ilker ManapandClaude Opus 5.5 11231d8c2b The portal in English and Turkish: a catalogue per language, a switcher at the foot of every page, landing.language, Turkish numbers and dates
Every template and handler message in internal/web goes through a JSON
catalogue under internal/web/locales/<code>/, with English as the fallback.
The language is the person's choice (a cookie set by /_lang/{code}), else
Accept-Language, else landing.language. Tests refuse a key some language
lacks, a translation with other arguments than English, and template text
left in English.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 00:12:53 +02:00

239 lines
7.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. Nothing on it writes.
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 {
Email string
State store.UserState
Places []orgPlace
}
type orgPlace struct{ Where, Role 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
Error string
Version string
Now string
}
// RegisterOrgPages mounts /org: an organization's own view of itself, for the
// people who manage it.
//
// Read-only on purpose. A manager sees their organization's use, members and
// limits — what they are accountable for — without being made an administrator
// of the whole cluster, and without anything on the page that changes who may
// do what. That stays with the cluster's administrators.
func (p *AccountPages) RegisterOrgPages(mux *http.ServeMux, s OrgViewer, priced Priced) {
p.orgs = s
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,
) {
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(),
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{Email: u.Email, State: u.State}
byPerson[m.UserID] = om
}
om.Places = append(om.Places, orgPlace{Where: where, Role: 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)
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)
}