diff --git a/BACKLOG.md b/BACKLOG.md index cc7b03f..3a7275d 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -49,7 +49,8 @@ Face, done 2026-10-30), then M5 (engines without the internet), M3, M4, M6. **Scale.** O: more than one control plane answering clients; O1 (measuring one router's ceiling) first. **Governance.** -7. **C1**: a tenant-admin role, and forms hidden from viewers. +7. ~~**C1**~~: done 2026-11-08. An organization's admin (`--role admin` on an org membership) changes its teams, + projects, people and keys from /org; viewers are drawn no forms; forms made compact. 8. **Audit before and after**: what a change changed, and changes made with the CLI recorded too. **When there is room.** @@ -435,9 +436,9 @@ portal is not done — the page shows; the console and ctl change. API middleware and the /admin pages (with each change's result), `ctl audit`, the console's Audit view; staff roles viewer / operator / billing / admin. Left: before/after values (the request's fields are - recorded, not the state they replaced); a tenant-admin role that changes - its own organization (managers still only view it); hiding the forms a - viewer cannot submit. **Audit log and roles (M).** Today "audit" is slog lines + recorded, not the state they replaced). Done 2026-11-08: an organization's + admin who changes its own organization from /org (managers still only view + it), and the forms a viewer cannot submit no longer drawn. **Audit log and roles (M).** Today "audit" is slog lines (`internal/web/admin_users.go:403-480`) and roles are the `IsAdmin` flag plus an org "manager" who can view. Add `audit_events` (who, what, before/after, source) written by the `adminAction`/`tenancyAction` diff --git a/CHANGELOG.md b/CHANGELOG.md index 4baeff6..c63154f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,8 +28,41 @@ APIs change between minors without a deprecation period. ## Unreleased +### Added + +- **An organization's admin.** Besides member and manager, a membership of + an organization may now be `admin` (`llmgw users join … --org Acme --role + admin`, the role chosen on a membership on the people page, the console, + the admin API). An admin sees the organization's page as a manager does and + also changes the organization from it: adds, renames and removes its teams + and projects, adds people with an approved account by their address and + takes them out, makes its members managers or admins, and revokes keys spent + in its projects. Limits, approving and suspending accounts, the + organization's name and the rest of the cluster stay with the cluster's + administrators; an admin cannot take away their own role. Every change is in + the audit log. + +### Changed + +- **Forms only for those who may send them.** A viewer sees the people, + limits, mirror and cluster pages without their forms, an operator the + mirror's and a node's engines but not the rest; before, they were drawn and + answered "not found". +- **Smaller forms.** A label sits close to its field, a field is as wide as + what goes in it rather than the page, buttons are smaller, and the short + forms — a new key, a password, connecting an editor, a capacity or a limit — + sit on one line or two instead of a screen. + ### Fixed +- **A sign-in took an organization's manager role away.** A group from single + sign-on, SCIM or Active Directory that puts someone in an organization wrote + the membership again at every sign-in or sync, as a plain member: a manager + made one on the people page was one only until they next signed in. A + membership the directory names is now added if missing and otherwise left + as it is. +- **The limits form on the people page's panel** was laid out on one line, its + title squeezed into a column. - **Deleting the model files nothing uses said what it was asked to free, not what it freed.** The console's "Delete model files nothing uses" and `ctl weights --collect` added up every file the nodes were asked to diff --git a/docs/console.md b/docs/console.md index 5d4daff..4d7e116 100644 --- a/docs/console.md +++ b/docs/console.md @@ -220,7 +220,8 @@ and change it with the same commands as `llmgw users`, `llmgw keys` and dashboard, and what they belong to. **A** creates an account, already approved. On a row: approve one waiting, suspend or reinstate, give or take the cluster dashboard, add to or take out of an organization, team or -project, their limits, a new password (their sessions close), delete, and +project (on an organization as a member, its manager, who sees its page, or +its admin, who also changes it), their limits, a new password (their sessions close), delete, and **Forget them…** for a request to be forgotten (`llmgw users forget`). **API keys** — every key, its project, owner, state and last use. **A** diff --git a/docs/onboarding.md b/docs/onboarding.md index f4c3fc4..569458b 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -192,22 +192,41 @@ auth: A personal key has no project, so it keeps no conversations or agent notes; those are kept per project. -### Organization managers +### Organization managers and admins -A member of an organization can be made its manager: +A member of an organization can be made its manager, who sees it, or its +admin, who also changes it: ``` llmgw users join boss@acme.com --org Acme --role manager +llmgw users join lead@acme.com --org Acme --role admin ``` -or with the star on their organization membership on `/admin/users`. A manager -gets an **Organization** link and a page at `/org`: usage over time by team, +or with the role chosen on their organization membership on `/admin/users`. +Both get an **Organization** link and a page at `/org`: usage over time by team, project or person, the teams and projects with their limits and spend, and every member. Only what was spent in that organization's projects is on it; what its -people do elsewhere is not. The page is read-only — a manager is not an -administrator of the cluster, and limits and memberships stay with those who -are. An administrator following `/org` lands on the same organization's panel -on the people page instead, where all of that can be changed. +people do elsewhere is not. + +A manager's page is read-only. An organization's admin also changes the +organization from it: + +- adds and renames its teams and projects, and removes an empty one; +- adds a person by their sign-in address to the organization, a team or a + project, and takes them out (their keys there are revoked with it); +- makes a member of the organization a manager or an admin, or neither; +- revokes a key spent in its projects. + +What reaches past the organization stays with the cluster's administrators: +its limits and budgets, approving or suspending an account (someone not yet +approved cannot be added), the organization's own name, and anything else on +the cluster. An admin cannot take away their own role. A group from single +sign-on or Active Directory that puts someone in the organization does not, by +saying so again at the next sign-in, take a manager's or admin's role away. + +An administrator of the cluster following `/org` lands on the same +organization's panel on the people page instead, where all of that can be +changed. `llmgw keys init` still works for provisioning scripts and is idempotent, but it will no longer move a team or project that already exists under another parent; @@ -517,8 +536,10 @@ included: `llmgw ctl audit`, or **Audit** in the console. suspends accounts from it; see [install.md](install.md#single-sign-on). Without them accounts are local, with local passwords. - **Staff roles are coarse.** `viewer`, `operator`, `billing` and `admin` - (`llmgw users role`) decide what someone may see and do under /admin; an - organization's manager can view it but not change it. Every change is in - the audit log (`llmgw ctl audit`). + (`llmgw users role`) decide what someone may see and do under /admin, and a + page draws only the forms that person may send: a viewer sees no forms. An + organization's manager can view it; its admin also changes its teams, + projects, people and keys. Every change is in the audit log (`llmgw ctl + audit`). - **Rate limits are per gateway instance.** With two instances each counts its own, so the effective limit doubles. v1 is single-instance by design. diff --git a/internal/cli/tui_people.go b/internal/cli/tui_people.go index 46e848e..58e3405 100644 --- a/internal/cli/tui_people.go +++ b/internal/cli/tui_people.go @@ -221,11 +221,11 @@ func (m consoleModel) userMenu(u store.User) *menuDialog { items = append(items, menuItem{"Add to an organization, team or project…", func(m *consoleModel) tea.Cmd { m.openForm(&formDialog{title: "Membership for " + who, - note: "A member of a team or organization may use every project in it.", + note: "A member of a team or organization may use every project in it. On an organization, a manager also sees its page and an admin also changes it.", fields: []formField{ choiceF("level", []string{"org", "team", "project"}, "team"), textF("which", "", "Acme, Acme/ml or Acme/ml/training"), - choiceF("role", []string{store.RoleMember, "manager"}, store.RoleMember), + choiceF("role", store.MembershipRoles, store.RoleMember), }, submit: func(m *consoleModel, v []string) (tea.Cmd, error) { if v[1] == "" { diff --git a/internal/cli/users.go b/internal/cli/users.go index b8c6b0d..4f51923 100644 --- a/internal/cli/users.go +++ b/internal/cli/users.go @@ -391,7 +391,8 @@ func newUserJoinCmd(open userOpener) *cobra.Command { Long: "A person can be a member of any number of them. A member of a team or\n" + "organization may use every project in it.", Example: " llmgw users join ayse@example.com --team Acme/ml\n" + - " llmgw users join ayse@example.com --project Scania/research/vision", + " llmgw users join ayse@example.com --project Scania/research/vision\n" + + " llmgw users join lars@example.com --org Scania --role admin", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { s, err := open(cmd) @@ -415,7 +416,8 @@ func newUserJoinCmd(open userOpener) *cobra.Command { }, } where.bind(cmd) - cmd.Flags().StringVar(&role, "role", store.RoleMember, "member, or manager: with --org, may see the organization's page") + cmd.Flags().StringVar(&role, "role", store.RoleMember, + "member; with --org also manager, who sees the organization's page, or admin, who also changes its teams, projects, people and keys") cmd.Flags().StringVar(&by, "by", "", "who added them, for the record") return cmd } diff --git a/internal/control/adminapi/tenancy.go b/internal/control/adminapi/tenancy.go index 3c0c9c5..9265618 100644 --- a/internal/control/adminapi/tenancy.go +++ b/internal/control/adminapi/tenancy.go @@ -634,8 +634,12 @@ func (a *API) membership(add bool) http.HandlerFunc { if role == "" { role = store.RoleMember } - if role != store.RoleMember && role != store.RoleManager { - writeError(w, http.StatusBadRequest, fmt.Sprintf("role %q: member or manager", role)) + if role != store.RoleMember && role != store.RoleManager && role != store.RoleOrgAdmin { + writeError(w, http.StatusBadRequest, fmt.Sprintf("role %q: member, manager or admin", role)) + return + } + if role != store.RoleMember && scope != store.ScopeOrg { + writeError(w, http.StatusBadRequest, fmt.Sprintf("%s is a role on an organization", role)) return } if err := a.tenancy.AddMembership(ctx, u.ID, scope, id, role, actorOf(r), time.Now()); err != nil { diff --git a/internal/directory/directory.go b/internal/directory/directory.go index f64c1ee..d8f7ad4 100644 --- a/internal/directory/directory.go +++ b/internal/directory/directory.go @@ -94,7 +94,7 @@ type Store interface { SetStaffRole(ctx context.Context, id, role string) error SetUserState(ctx context.Context, id string, state store.UserState) error DeleteUserSessions(ctx context.Context, userID string) error - AddMembership(ctx context.Context, user string, scope store.Scope, id, role, by string, at time.Time) error + EnsureMembership(ctx context.Context, user string, scope store.Scope, id, by string, at time.Time) error RemoveMembership(ctx context.Context, userID string, scope store.Scope, scopeID string, at time.Time) (int, error) MembershipsOf(ctx context.Context, userID string) ([]store.Membership, error) AllOrgs(ctx context.Context) ([]store.Org, error) @@ -221,7 +221,7 @@ func (k *Keeper) apply(ctx context.Context, u *store.User, id Identity, remove b continue // a mapping to a place the tree lacks: said by Sync } want[string(scope)+"/"+sid] = scope - if err := k.St.AddMembership(ctx, u.ID, scope, sid, store.RoleMember, k.Name, k.now()); err != nil { + if err := k.St.EnsureMembership(ctx, u.ID, scope, sid, k.Name, k.now()); err != nil { return err } } diff --git a/internal/scim/scim.go b/internal/scim/scim.go index 353435b..d8968b1 100644 --- a/internal/scim/scim.go +++ b/internal/scim/scim.go @@ -39,7 +39,7 @@ type Store interface { SetUserState(ctx context.Context, id string, state store.UserState) error DeleteUserSessions(ctx context.Context, userID string) error SetStaffRole(ctx context.Context, id, role string) error - AddMembership(ctx context.Context, user string, scope store.Scope, id, role, by string, at time.Time) error + EnsureMembership(ctx context.Context, user string, scope store.Scope, id, by string, at time.Time) error RemoveMembership(ctx context.Context, user string, scope store.Scope, id string, at time.Time) (int, error) AllOrgs(ctx context.Context) ([]store.Org, error) AllTeams(ctx context.Context) ([]store.TeamPath, error) @@ -548,7 +548,7 @@ func (s *Server) member(ctx context.Context, group, userID string, joined bool) return } if joined { - err = s.st.AddMembership(ctx, u.ID, scope, id, store.RoleMember, "scim", time.Now()) + err = s.st.EnsureMembership(ctx, u.ID, scope, id, "scim", time.Now()) } else { _, err = s.st.RemoveMembership(ctx, u.ID, scope, id, time.Now()) } diff --git a/internal/scim/scim_test.go b/internal/scim/scim_test.go index aff2808..88868e4 100644 --- a/internal/scim/scim_test.go +++ b/internal/scim/scim_test.go @@ -58,7 +58,7 @@ func (m *mem) DeleteUserSessions(_ context.Context, id string) error { return nil } func (m *mem) SetStaffRole(_ context.Context, id, role string) error { m.roles[id] = role; return nil } -func (m *mem) AddMembership(_ context.Context, u string, sc store.Scope, id, _, _ string, _ time.Time) error { +func (m *mem) EnsureMembership(_ context.Context, u string, sc store.Scope, id, _ string, _ time.Time) error { m.members[u+"@"+string(sc)+":"+id] = true return nil } diff --git a/internal/store/memberships.go b/internal/store/memberships.go index 7ab8a26..cf5475c 100644 --- a/internal/store/memberships.go +++ b/internal/store/memberships.go @@ -14,10 +14,19 @@ var ErrNotMember = errors.New("not a member of that project") // Roles a membership can carry. const ( RoleMember = "member" - // RoleManager is reserved for delegated administration of a scope. + // RoleManager, on an organization, may look at that organization's page: + // its use, members and limits. It changes nothing. RoleManager = "manager" + // RoleOrgAdmin, on an organization, also changes it: its teams and + // projects, who is in them, and their keys. Its limits, the accounts + // themselves and the rest of the cluster stay with the cluster's staff. + RoleOrgAdmin = "admin" ) +// MembershipRoles are the roles a membership may carry; manager and admin mean +// something only on an organization. +var MembershipRoles = []string{RoleMember, RoleManager, RoleOrgAdmin} + // Membership is a person's place at one level of the tree. type Membership struct { UserID string @@ -43,6 +52,12 @@ func (s *DB) AddMembership(ctx context.Context, userID string, scope Scope, scop if role == "" { role = RoleMember } + if role != RoleMember && role != RoleManager && role != RoleOrgAdmin { + return fmt.Errorf("a membership's role is member, manager or admin, not %q", role) + } + if role != RoleMember && scope != ScopeOrg { + return fmt.Errorf("%s is a role on an organization, not on a %s", role, scope) + } table := map[Scope]string{ScopeOrg: "organizations", ScopeTeam: "teams", ScopeProject: "projects"}[scope] if err := s.exists(ctx, string(scope), `SELECT COUNT(*) FROM `+table+` WHERE id = ?`, scopeID); err != nil { return err @@ -55,6 +70,27 @@ func (s *DB) AddMembership(ctx context.Context, userID string, scope Scope, scop return err } +// EnsureMembership puts a person in a scope as a member if they are not there +// yet, and leaves a membership that exists as it is. It is for what a +// directory or an identity provider says on every sign-in or sync: a group +// that places someone in an organization must not, by saying so again, take +// away the manager or admin role an administrator gave them there. +func (s *DB) EnsureMembership(ctx context.Context, userID string, scope Scope, scopeID, by string, at time.Time) error { + if !validMembershipScope(scope) { + return fmt.Errorf("a membership is in an org, team or project, not %q", scope) + } + table := map[Scope]string{ScopeOrg: "organizations", ScopeTeam: "teams", ScopeProject: "projects"}[scope] + if err := s.exists(ctx, string(scope), `SELECT COUNT(*) FROM `+table+` WHERE id = ?`, scopeID); err != nil { + return err + } + _, err := s.db.ExecContext(ctx, ` + INSERT INTO memberships (user_id, scope_type, scope_id, role, added_at, added_by) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (user_id, scope_type, scope_id) DO NOTHING`, + userID, string(scope), scopeID, RoleMember, millis(at), nullString(by)) + return err +} + // RemoveMembership takes a person out of a scope and revokes the keys they // made in any project they can no longer use, returning how many. // @@ -306,23 +342,36 @@ func (s *DB) resolveChain(ctx context.Context, e *Effective, keyLevel bool) erro return nil } -// ManagedOrgs lists the organizations a person manages. +// ManagedOrgs lists the organizations a person manages or administers: those +// whose page they may see. func (s *DB) ManagedOrgs(ctx context.Context, userID string) ([]Org, error) { return queryAll(ctx, s.db, ` SELECT o.id, o.name FROM memberships m JOIN organizations o ON o.id = m.scope_id - WHERE m.user_id = ? AND m.scope_type = 'org' AND m.role = 'manager' + WHERE m.user_id = ? AND m.scope_type = 'org' AND m.role IN ('manager', 'admin') ORDER BY o.name`, []any{userID}, func(rows *sql.Rows) (Org, error) { var o Org return o, rows.Scan(&o.ID, &o.Name) }) } -// ManagesOrg reports whether a person manages an organization. +// ManagesOrg reports whether a person manages or administers an +// organization: whether they may see its page. func (s *DB) ManagesOrg(ctx context.Context, userID, orgID string) (bool, error) { var n int err := s.db.QueryRowContext(ctx, ` SELECT COUNT(*) FROM memberships - WHERE user_id = ? AND scope_type = 'org' AND scope_id = ? AND role = 'manager'`, + WHERE user_id = ? AND scope_type = 'org' AND scope_id = ? AND role IN ('manager', 'admin')`, + userID, orgID).Scan(&n) + return n > 0, err +} + +// AdministersOrg reports whether a person administers an organization: +// whether they may change it. +func (s *DB) AdministersOrg(ctx context.Context, userID, orgID string) (bool, error) { + var n int + err := s.db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM memberships + WHERE user_id = ? AND scope_type = 'org' AND scope_id = ? AND role = 'admin'`, userID, orgID).Scan(&n) return n > 0, err } diff --git a/internal/store/memberships_test.go b/internal/store/memberships_test.go index 5d6fc8e..53d8cbf 100644 --- a/internal/store/memberships_test.go +++ b/internal/store/memberships_test.go @@ -307,3 +307,78 @@ func TestASuspendedPersonsKeysStopWorkingAndComeBackWithThem(t *testing.T) { t.Errorf("reinstated, the key still refused: %v", err) } } + +func TestAnOrganizationsAdminAlsoManagesIt(t *testing.T) { + f := newFixture(t) + ctx := t.Context() + if err := f.s.AddMembership(ctx, f.user, ScopeOrg, f.acme, RoleOrgAdmin, "t", time.Now()); err != nil { + t.Fatal(err) + } + if ok, _ := f.s.AdministersOrg(ctx, f.user, f.acme); !ok { + t.Error("an admin of Acme is not recognised as one") + } + if ok, _ := f.s.ManagesOrg(ctx, f.user, f.acme); !ok { + t.Error("an admin of Acme may not see its page") + } + if ok, _ := f.s.AdministersOrg(ctx, f.user, f.scania); ok { + t.Error("administering Acme made them an admin of Scania") + } + if orgs, _ := f.s.ManagedOrgs(ctx, f.user); len(orgs) != 1 || orgs[0].Name != "Acme" { + t.Errorf("ManagedOrgs = %+v", orgs) + } + // A manager sees the page and changes nothing. + if err := f.s.AddMembership(ctx, f.user, ScopeOrg, f.acme, RoleManager, "t", time.Now()); err != nil { + t.Fatal(err) + } + if ok, _ := f.s.AdministersOrg(ctx, f.user, f.acme); ok { + t.Error("a manager is taken for an admin") + } +} + +func TestManagerAndAdminAreRolesOnAnOrganizationOnly(t *testing.T) { + f := newFixture(t) + ctx := t.Context() + for _, c := range []struct { + scope Scope + id string + role string + }{ + {ScopeProject, f.gateway, RoleOrgAdmin}, + {ScopeTeam, f.platform, RoleManager}, + {ScopeOrg, f.acme, "owner"}, + } { + if err := f.s.AddMembership(ctx, f.user, c.scope, c.id, c.role, "t", time.Now()); err == nil { + t.Errorf("%s on a %s was taken", c.role, c.scope) + } + } +} + +func TestASyncedGroupLeavesARoleAsItIs(t *testing.T) { + // A directory group that places someone in Acme says so at every sign-in; + // saying it again must not take away the admin role they were given there. + f := newFixture(t) + ctx := t.Context() + if err := f.s.AddMembership(ctx, f.user, ScopeOrg, f.acme, RoleOrgAdmin, "t", time.Now()); err != nil { + t.Fatal(err) + } + if err := f.s.EnsureMembership(ctx, f.user, ScopeOrg, f.acme, "oidc", time.Now()); err != nil { + t.Fatal(err) + } + if ok, _ := f.s.AdministersOrg(ctx, f.user, f.acme); !ok { + t.Error("a sign-in's group took the admin role away") + } + // And one that is not there yet is added as a member. + if err := f.s.EnsureMembership(ctx, f.user, ScopeOrg, f.scania, "oidc", time.Now()); err != nil { + t.Fatal(err) + } + ms, _ := f.s.AllMemberships(ctx) + found := false + for _, m := range ms { + if m.UserID == f.user && m.Scope == ScopeOrg && m.ScopeID == f.scania { + found = m.Role == RoleMember + } + } + if !found { + t.Error("a new group did not make them a member of Scania") + } +} diff --git a/internal/web/account.go b/internal/web/account.go index 5a0d852..aca225a 100644 --- a/internal/web/account.go +++ b/internal/web/account.go @@ -123,6 +123,8 @@ type AccountPages struct { personalDefaults *store.Limits // orgs is set when /org is mounted, so the menu can offer it to managers. orgs OrgViewer + // orgEditor changes an organization from its page, for its admin. + orgEditor OrgEditor // llamaVersion is the llama.cpp build nodes install, for judging whether a // GGUF can be served before it is fetched or catalogued. llamaVersion string diff --git a/internal/web/admin_mirror.go b/internal/web/admin_mirror.go index 3d45014..c432ee0 100644 --- a/internal/web/admin_mirror.go +++ b/internal/web/admin_mirror.go @@ -267,7 +267,7 @@ func (p *AccountPages) RegisterAdminMirror(mux *http.ServeMux, d MirrorDeps) { http.NotFound(w, r) return } - d := adminMirrorData{CSRF: csrfFor(r), Inline: true, HasRegistry: true} + d := adminMirrorData{CSRF: csrfIfMay(r, "/admin/mirror/x"), Inline: true, HasRegistry: true} p.openRepo(r, &d, repo) if d.RegistryErr != "" { // Read by the page's own script, which shows it beneath the @@ -502,7 +502,7 @@ func (p *AccountPages) renderMirror(w http.ResponseWriter, r *http.Request, Addr: m.Addr(), Message: msg, Error: errMsg, - CSRF: csrfFor(r), + CSRF: csrfIfMay(r, "/admin/mirror/x"), Version: p.version, Now: time.Now().UTC().Format(time.RFC3339), } @@ -736,6 +736,11 @@ func mirrorTabs(l *Lang, d adminMirrorData) []mirrorTab { tabs = append(tabs, mirrorTab{Label: l.T("mirror.tab.strata"), Href: "/admin/mirror?tab=strata", Active: d.Tab == tabStrata}) } + if d.CSRF == "" { + // Fetching is all that tab does: someone who may not fetch is not + // offered an empty page. + return tabs + } return append(tabs, mirrorTab{Label: l.T("mirror.tab.fetch"), Href: "/admin/mirror?tab=fetch", Active: d.Tab == tabFetch}) } diff --git a/internal/web/admin_people.go b/internal/web/admin_people.go index 3fe5ba6..161b585 100644 --- a/internal/web/admin_people.go +++ b/internal/web/admin_people.go @@ -149,7 +149,7 @@ func (p *AccountPages) detail(r *http.Request, sel string, u *adminUsersData) *p } ctx := r.Context() l := langOf(r) - d := &peopleDetail{Kind: store.Scope(kind), ID: id, CSRF: csrfFor(r), + d := &peopleDetail{Kind: store.Scope(kind), ID: id, CSRF: csrfIfMay(r, "/admin/users"), UsageWindow: l.T("usage.window.7d"), QuotaWindow: l.T("quotas.since")} if !p.nameDetail(d, u) { return nil diff --git a/internal/web/admin_quotas.go b/internal/web/admin_quotas.go index 79dcb2c..d919554 100644 --- a/internal/web/admin_quotas.go +++ b/internal/web/admin_quotas.go @@ -353,7 +353,7 @@ func (p *AccountPages) buildQuotas(r *http.Request, s QuotaAdmin, msg, errMsg st d := adminQuotasData{ Nav: navFrom(r, "quotas"), Since: l.T("quotas.since"), Message: msg, Error: errMsg, - CSRF: csrfFor(r), Version: p.version, + CSRF: csrfIfMay(r, "/admin/quotas"), Version: p.version, Now: time.Now().UTC().Format(time.RFC3339), } diff --git a/internal/web/admin_users.go b/internal/web/admin_users.go index d19b977..af141ff 100644 --- a/internal/web/admin_users.go +++ b/internal/web/admin_users.go @@ -4,6 +4,7 @@ import ( "context" "errors" "net/http" + "slices" "sort" "strings" "time" @@ -92,7 +93,10 @@ type adminUsersData struct { // 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 + Open string + // Edit is whether this person may change what the page shows: a viewer + // is drawn the page without its forms. + Edit bool AdminID string Message string Error string @@ -173,23 +177,19 @@ func (p *AccountPages) RegisterAdminUsers(mux *http.ServeMux, s AccountAdmin) { return say("users.msg.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. + // A role on an organization membership: a manager sees that organization's + // page, an admin also changes it; a member neither. mux.HandleFunc("POST /admin/users/{id}/role", p.adminAction(s, func(ctx context.Context, admin store.User, id string, r *http.Request) (notice, 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) { + if !ok || store.Scope(scope) != store.ScopeOrg || !slices.Contains(store.MembershipRoles, role) { return notice{}, refuse("users.err.manager_role") } if err := s.AddMembership(ctx, id, store.ScopeOrg, scopeID, role, admin.Email, time.Now()); err != nil { return notice{}, err } - label := scopeLabel(ctx, s, store.ScopeOrg, scopeID) - if role == store.RoleManager { - return say("users.msg.now_manages", label), nil - } - return say("users.msg.no_longer_manages", label), nil + return roleNotice(role, scopeLabel(ctx, s, store.ScopeOrg, scopeID)), nil })) mux.HandleFunc("POST /admin/users/{id}/leave", p.adminAction(s, @@ -351,6 +351,17 @@ func (p *AccountPages) RegisterAdminUsers(mux *http.ServeMux, s AccountAdmin) { })) } +// roleNotice says what a person may now do in an organization. +func roleNotice(role, org string) notice { + switch role { + case store.RoleManager: + return say("users.msg.now_manages", org) + case store.RoleOrgAdmin: + return say("users.msg.now_administers", org) + } + return say("users.msg.no_longer_manages", org) +} + // notice is what the page says about a change, kept as a catalogue key and // its arguments rather than as text: the log and the audit record take it in // English, which is what they are searched in, and the page in the language @@ -394,7 +405,7 @@ func init() { registerKeys( "users.msg.approved_alone", "users.msg.approved_into", "users.msg.suspended", "users.msg.reinstated", "users.msg.added_to", "users.msg.now_manages", - "users.msg.no_longer_manages", "users.msg.removed_from", "users.msg.removed_from_one_key", + "users.msg.no_longer_manages", "users.msg.now_administers", "users.msg.removed_from", "users.msg.removed_from_one_key", "users.msg.removed_from_keys", "users.msg.removed_keys_revoked", "users.msg.org_created", "users.msg.team_created", "users.msg.project_created", "users.msg.renamed", "users.msg.team_moved", "users.msg.project_moved", "users.msg.removed", @@ -408,6 +419,8 @@ func init() { // state, which is also the row's class. "users.state.active", "users.state.pending", "users.state.suspended", "users.key_state.active", "users.key_state.revoked", "users.key_state.expired", + // A membership's role, looked up by the role. + "users.role.member", "users.role.manager", "users.role.admin", ) } @@ -573,6 +586,7 @@ func (p *AccountPages) buildUsers(r *http.Request, s AccountAdmin, admin store.U Members: map[string][]userRow{}, Keys: map[string][]keyRow{}, } + d.Edit = d.Nav.May("/admin/users") var err error // logErr logs what could not be read in English and says so on the page, // once, in the reader's language. diff --git a/internal/web/i18n_quotas_test.go b/internal/web/i18n_quotas_test.go index f03aff3..3afcc00 100644 --- a/internal/web/i18n_quotas_test.go +++ b/internal/web/i18n_quotas_test.go @@ -134,13 +134,13 @@ func TestTheUsageReportWritesTurkishNumbers(t *testing.T) { func TestTheOrganizationPageSpeaksTurkishWhenAsked(t *testing.T) { body := inTurkish(t, orgPage(t, store.User{ID: "u1", Email: "boss@acme"}), "/org/org-a", nil) - for _, want := range []string{"2 kişi", "Kullanım", "Ekipler, projeler ve sınırlar", "Üyeler", - ">yönetici", "kuruluşun tamamı", "son 7 gün", `lang="tr"`} { + for _, want := range []string{"2 kişi", "Kullanım", "Sınırlar", "Üyeler", + ">sorumlu", "kuruluşun tamamı", "son 7 gün", `lang="tr"`} { if !strings.Contains(body, want) { t.Errorf("the organization page in Turkish lacks %q", want) } } - if strings.Contains(body, "Teams, projects and limits") { + if strings.Contains(body, ">Limits<") { t.Error("the organization page in Turkish still has English headings") } } diff --git a/internal/web/locales/en/org.json b/internal/web/locales/en/org.json index 4fced8c..f949e80 100644 --- a/internal/web/locales/en/org.json +++ b/internal/web/locales/en/org.json @@ -13,14 +13,13 @@ "org.use_empty": "Nothing used in this window.", "org.use_scope": "Only what was spent in this organization's projects. What its people do elsewhere — in another organization, or on their own — is not shown.", - "org.limits.title": "Teams, projects and limits", + "org.limits.title": "Limits", "org.col.spent_30d": "Spent · 30 days", "org.limits.note": "Limits are set by the cluster's administrators.", "org.members.title": "Members", "org.col.member_of": "Member of", "org.col.state": "State", - "org.role.manager": "manager", "org.state.pending": "pending", "org.state.active": "active", "org.state.suspended": "suspended", @@ -30,7 +29,47 @@ "org.none.title": "Organizations", "org.none.sub": "There are none on this cluster", "org.none.about": "Everyone uses the gateway on their own, under their personal quota. An organization, once there is one, gets a page here for its managers: its use by team, project and person, its limits, and its members.", - "org.none.how": "Create one on the people page, then make someone its manager with the star on their membership.", + "org.none.how": "Create one on the people page, then choose manager or org admin on someone's membership of it.", + "org.col.role": "Role here", + "org.col.projects": "Projects", + "org.add.email": "their sign-in address", + "org.add.where": "Where", + "org.add.button": "Add", + "org.add.note": "Anyone with an approved account can be added. Someone new signs up first; the cluster's administrators approve them.", + "org.tree.title": "Teams and projects", + "org.tree.rename": "Rename", + "org.tree.remove": "Remove %s", + "org.tree.remove_confirm": "Remove %s? Only an empty one can be removed.", + "org.tree.remove_team": "Remove", + "org.tree.new_project": "new project", + "org.tree.new_project_in": "New project in %s", + "org.tree.new_team": "new team", + "org.tree.add_team": "Add team", + "org.tree.empty": "No teams yet.", + "org.tree.note": "Click a team's name to rename it. A team or project is removed only when nobody and no key is left in it.", + "org.keys.title": "Keys", + "org.keys.col.key": "Key", + "org.keys.col.owner": "Owner", + "org.keys.col.used": "Last used", + "org.keys.revoke": "Revoke", + "org.keys.revoke_confirm": "Revoke %s…? Whatever uses it stops working.", + "org.keys.empty": "No keys in this organization's projects.", + "org.msg.team_created": "Team %s added.", + "org.msg.project_created": "Project %s added.", + "org.msg.renamed": "Renamed to %s.", + "org.msg.removed": "%s removed.", + "org.msg.added": "%s added to %s.", + "org.msg.taken_out": "Taken out of %s.", + "org.msg.taken_out_keys": "Taken out of %s; %d of their keys there revoked.", + "org.msg.key_revoked": "Key %s… revoked.", + "org.err.not_here": "that is not in this organization", + "org.err.no_account": "there is no account for %s: they sign up first", + "org.err.not_approved": "%s waits for the cluster's administrators to approve them", + "org.err.suspended": "%s is suspended", + "org.err.self_role": "your own role here is changed by the cluster's administrators", + "org.err.self_leave": "you cannot take yourself out of the organization you administer", + "org.err.name": "a name is needed, without a slash", + "org.err.role": "a role is member, manager or org admin", "org.err.read_tree": "could not read the organization" } diff --git a/internal/web/locales/en/people.json b/internal/web/locales/en/people.json index 10099fc..a0f7bc1 100644 --- a/internal/web/locales/en/people.json +++ b/internal/web/locales/en/people.json @@ -139,10 +139,10 @@ "users.state.pending": "pending", "users.state.suspended": "suspended", "users.role.manager": "manager", - "users.stop_managing_of": "Stop managing %s", - "users.make_manager_of": "Make manager of %s: can see its page", - "users.stop_managing": "Stop managing", - "users.make_manager": "Make manager", + "users.role.member": "member", + "users.role.admin": "org admin", + "users.role_in": "Role in %s", + "users.role_note": "A manager sees the organization's page; an admin also changes its teams, projects, people and keys.", "users.take_out_confirm": "Take %s out of %s? Keys they made there are revoked.", "users.take_out_of": "Take out of %s", "users.on_own": "on their own", @@ -164,6 +164,7 @@ "users.msg.added_to": "added to %s", "users.msg.now_manages": "now manages %s", "users.msg.no_longer_manages": "no longer manages %s", + "users.msg.now_administers": "now administers %s", "users.msg.removed_from": "removed from %s", "users.msg.removed_from_one_key": "removed from %s; 1 key they made there revoked", "users.msg.removed_from_keys": "removed from %s; %d keys they made there revoked", @@ -181,7 +182,7 @@ "users.err.about": "%s: %s", "users.err.self_suspend": "you cannot suspend your own account", "users.err.choose_where": "choose where they go", - "users.err.manager_role": "a manager is a role in an organization", + "users.err.manager_role": "manager and admin are roles in an organization", "users.err.nothing_to_leave": "nothing to leave", "users.err.self_remove": "you cannot remove your own account", "users.err.choose_org": "choose the organization this team belongs to", diff --git a/internal/web/locales/tr/org.json b/internal/web/locales/tr/org.json index c86f31e..481876f 100644 --- a/internal/web/locales/tr/org.json +++ b/internal/web/locales/tr/org.json @@ -12,14 +12,13 @@ "org.use_empty": "Bu dönemde hiçbir şey kullanılmadı.", "org.use_scope": "Yalnızca bu kuruluşun projelerinde harcananlar. Kişilerinin başka yerlerde — başka bir kuruluşta ya da kendi başlarına — yaptıkları gösterilmez.", - "org.limits.title": "Ekipler, projeler ve sınırlar", + "org.limits.title": "Sınırlar", "org.col.spent_30d": "Harcanan · 30 gün", "org.limits.note": "Sınırları kümenin yöneticileri belirler.", "org.members.title": "Üyeler", "org.col.member_of": "Üyesi olduğu yer", "org.col.state": "Durum", - "org.role.manager": "yönetici", "org.state.pending": "onay bekliyor", "org.state.active": "etkin", "org.state.suspended": "askıya alındı", @@ -29,7 +28,47 @@ "org.none.title": "Kuruluşlar", "org.none.sub": "Bu kümede hiç kuruluş yok", "org.none.about": "Herkes ağ geçidini kendi kişisel kotası altında, kendi başına kullanıyor. Bir kuruluş oluşturulduğunda, yöneticileri için burada bir sayfası olur: ekibe, projeye ve kişiye göre kullanımı, sınırları ve üyeleri.", - "org.none.how": "Kişiler sayfasında bir kuruluş oluşturun, ardından birinin üyeliğindeki yıldızla onu kuruluşun yöneticisi yapın.", + "org.none.how": "Kişiler sayfasında bir kuruluş oluşturun, ardından birinin oradaki üyeliğinde sorumlu ya da kuruluş yöneticisi rolünü seçin.", + "org.col.role": "Buradaki rolü", + "org.col.projects": "Projeler", + "org.add.email": "giriş adresi", + "org.add.where": "Nereye", + "org.add.button": "Ekle", + "org.add.note": "Hesabı onaylanmış herkes eklenebilir. Yeni biri önce kaydolur; kümenin yöneticileri onu onaylar.", + "org.tree.title": "Ekipler ve projeler", + "org.tree.rename": "Yeniden adlandır", + "org.tree.remove": "%s kaldır", + "org.tree.remove_confirm": "%s kaldırılsın mı? Yalnızca boş olan kaldırılabilir.", + "org.tree.remove_team": "Kaldır", + "org.tree.new_project": "yeni proje", + "org.tree.new_project_in": "%s içinde yeni proje", + "org.tree.new_team": "yeni ekip", + "org.tree.add_team": "Ekip ekle", + "org.tree.empty": "Henüz ekip yok.", + "org.tree.note": "Adını değiştirmek için ekibin adına tıklayın. Bir ekip ya da proje, içinde kimse ve hiçbir anahtar kalmadığında kaldırılabilir.", + "org.keys.title": "Anahtarlar", + "org.keys.col.key": "Anahtar", + "org.keys.col.owner": "Sahibi", + "org.keys.col.used": "Son kullanım", + "org.keys.revoke": "İptal et", + "org.keys.revoke_confirm": "%s… iptal edilsin mi? Onu kullanan her şey çalışmayı bırakır.", + "org.keys.empty": "Bu kuruluşun projelerinde anahtar yok.", + "org.msg.team_created": "%s ekibi eklendi.", + "org.msg.project_created": "%s projesi eklendi.", + "org.msg.renamed": "Yeni adı %s.", + "org.msg.removed": "%s kaldırıldı.", + "org.msg.added": "%s, %s içine eklendi.", + "org.msg.taken_out": "%s içinden çıkarıldı.", + "org.msg.taken_out_keys": "%s içinden çıkarıldı; oradaki %d anahtarı iptal edildi.", + "org.msg.key_revoked": "%s… anahtarı iptal edildi.", + "org.err.not_here": "bu, bu kuruluşta değil", + "org.err.no_account": "%s için bir hesap yok: önce kaydolması gerekiyor", + "org.err.not_approved": "%s, kümenin yöneticilerinin onayını bekliyor", + "org.err.suspended": "%s askıya alınmış", + "org.err.self_role": "buradaki kendi rolünüzü kümenin yöneticileri değiştirir", + "org.err.self_leave": "yönettiğiniz kuruluştan kendinizi çıkaramazsınız", + "org.err.name": "bir ad gerekli, eğik çizgi olmadan", + "org.err.role": "rol üye, sorumlu ya da kuruluş yöneticisidir", "org.err.read_tree": "kuruluş okunamadı" } diff --git a/internal/web/locales/tr/people.json b/internal/web/locales/tr/people.json index 0e5fb79..21692cc 100644 --- a/internal/web/locales/tr/people.json +++ b/internal/web/locales/tr/people.json @@ -135,10 +135,10 @@ "users.state.pending": "onay bekliyor", "users.state.suspended": "askıda", "users.role.manager": "sorumlu", - "users.stop_managing_of": "%s sorumluluğunu bırak", - "users.make_manager_of": "%s sorumlusu yap: sayfasını görebilir", - "users.stop_managing": "Sorumluluğu bırak", - "users.make_manager": "Sorumlu yap", + "users.role.member": "üye", + "users.role.admin": "kuruluş yöneticisi", + "users.role_in": "%s içindeki rolü", + "users.role_note": "Sorumlu, kuruluşun sayfasını görür; kuruluş yöneticisi ayrıca takımlarını, projelerini, kişilerini ve anahtarlarını değiştirir.", "users.take_out_confirm": "%[1]s, %[2]s içinden çıkarılsın mı? Orada oluşturduğu anahtarlar iptal edilir.", "users.take_out_of": "%s içinden çıkar", "users.on_own": "tek başına", @@ -160,6 +160,7 @@ "users.msg.added_to": "%s içine eklendi", "users.msg.now_manages": "artık %s sorumlusu", "users.msg.no_longer_manages": "artık %s sorumlusu değil", + "users.msg.now_administers": "artık %s kuruluş yöneticisi", "users.msg.removed_from": "%s içinden çıkarıldı", "users.msg.removed_from_one_key": "%s içinden çıkarıldı; orada oluşturduğu 1 anahtar iptal edildi", "users.msg.removed_from_keys": "%s içinden çıkarıldı; orada oluşturduğu %d anahtar iptal edildi", @@ -177,7 +178,7 @@ "users.err.about": "%s: %s", "users.err.self_suspend": "kendi hesabınızı askıya alamazsınız", "users.err.choose_where": "nereye gideceğini seçin", - "users.err.manager_role": "sorumluluk, bir kuruluştaki roldür", + "users.err.manager_role": "sorumlu ve kuruluş yöneticisi, bir kuruluştaki rollerdir", "users.err.nothing_to_leave": "çıkılacak bir yer yok", "users.err.self_remove": "kendi hesabınızı kaldıramazsınız", "users.err.choose_org": "bu ekibin ait olduğu kuruluşu seçin", diff --git a/internal/web/oidc.go b/internal/web/oidc.go index de68cb1..925c3af 100644 --- a/internal/web/oidc.go +++ b/internal/web/oidc.go @@ -29,7 +29,7 @@ type OIDCStore interface { UserByEmail(ctx context.Context, email string) (store.User, error) CreateUser(ctx context.Context, u store.User) error SetStaffRole(ctx context.Context, id, role string) error - AddMembership(ctx context.Context, user string, scope store.Scope, id, role, by string, at time.Time) error + EnsureMembership(ctx context.Context, user string, scope store.Scope, id, by string, at time.Time) error AllOrgs(ctx context.Context) ([]store.Org, error) AllTeams(ctx context.Context) ([]store.TeamPath, error) AllProjects(ctx context.Context) ([]store.ProjectPath, error) @@ -338,7 +338,7 @@ func (p *AccountPages) oidcUser(ctx context.Context, claims map[string]any) (sto p.log.Warn("a group maps to a place the tree does not have", "group", g, "place", where, "err", err) continue } - if err := o.st.AddMembership(ctx, u.ID, scope, id, store.RoleMember, "oidc", time.Now()); err != nil { + if err := o.st.EnsureMembership(ctx, u.ID, scope, id, "oidc", time.Now()); err != nil { p.log.Warn("could not add a membership from a group", "group", g, "err", err) } } diff --git a/internal/web/oidc_test.go b/internal/web/oidc_test.go index e3b03f7..d3706fe 100644 --- a/internal/web/oidc_test.go +++ b/internal/web/oidc_test.go @@ -89,7 +89,7 @@ func (m *oidcStoreMem) SetStaffRole(_ context.Context, id, role string) error { m.roles[id] = role return nil } -func (m *oidcStoreMem) AddMembership(_ context.Context, user string, scope store.Scope, id, _, _ string, _ time.Time) error { +func (m *oidcStoreMem) EnsureMembership(_ context.Context, user string, scope store.Scope, id, _ string, _ time.Time) error { m.joined = append(m.joined, user+"@"+string(scope)+":"+id) return nil } diff --git a/internal/web/org_admin.go b/internal/web/org_admin.go new file mode 100644 index 0000000..eaf7356 --- /dev/null +++ b/internal/web/org_admin.go @@ -0,0 +1,296 @@ +package web + +import ( + "context" + "errors" + "net/http" + "slices" + "strings" + "time" + + "gitea.mynodes.xyz/ilker/llmgw/internal/store" +) + +// An organization's admin changes it from its own page. +// +// What they may change is what is theirs: the teams and projects in their +// organization, who of the cluster's people is in them and in what role, and +// the keys spent there. What stays with the cluster's staff is everything that +// reaches past the organization or into the cluster's capacity: its limits and +// budgets, approving or suspending an account, making anyone staff, and the +// organization's own name and existence. A person not yet approved is not +// added here either: approval says who may use the cluster at all. + +// OrgEditor is what an organization's admin changes through. +type OrgEditor interface { + AdministersOrg(ctx context.Context, userID, orgID string) (bool, error) + UserByEmail(ctx context.Context, email string) (store.User, error) + CreateTeam(ctx context.Context, orgID, name string) (string, error) + CreateProject(ctx context.Context, teamID, name string) (string, error) + RenameTeam(ctx context.Context, id, name string) error + RenameProject(ctx context.Context, id, name 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) + AllKeys(ctx context.Context) ([]store.KeyRow, error) + RevokeKey(ctx context.Context, id string, at time.Time) error +} + +func init() { + registerKeys( + "org.msg.team_created", "org.msg.project_created", "org.msg.renamed", "org.msg.removed", + "org.msg.added", "org.msg.taken_out", "org.msg.taken_out_keys", "org.msg.key_revoked", + "org.err.not_here", "org.err.no_account", "org.err.not_approved", "org.err.suspended", + "org.err.self_role", "org.err.self_leave", "org.err.name", "org.err.role", + ) +} + +// orgChange is one change an organization's admin makes, given the +// organization as it is. +type orgChange func(ctx context.Context, r *http.Request, by store.User, org store.TreeOrg) (notice, error) + +// registerOrgAdmin mounts the changes, when the store makes them. +func (p *AccountPages) registerOrgAdmin(mux *http.ServeMux, s OrgViewer, e OrgEditor, priced Priced) { + act := func(do orgChange) http.HandlerFunc { return p.orgAction(s, e, priced, do) } + + mux.HandleFunc("POST /org/{id}/teams", act(func(ctx context.Context, r *http.Request, _ store.User, o store.TreeOrg) (notice, error) { + name, err := orgName(r) + if err != nil { + return notice{}, err + } + if _, err := e.CreateTeam(ctx, o.ID, name); err != nil { + return notice{}, err + } + return say("org.msg.team_created", name), nil + })) + + mux.HandleFunc("POST /org/{id}/projects", act(func(ctx context.Context, r *http.Request, _ store.User, o store.TreeOrg) (notice, error) { + team := r.FormValue("team") + if !orgHas(o, store.ScopeTeam, team) { + return notice{}, refuse("org.err.not_here") + } + name, err := orgName(r) + if err != nil { + return notice{}, err + } + if _, err := e.CreateProject(ctx, team, name); err != nil { + return notice{}, err + } + return say("org.msg.project_created", name), nil + })) + + mux.HandleFunc("POST /org/{id}/{kind}/{sid}/rename", act(func(ctx context.Context, r *http.Request, _ store.User, o store.TreeOrg) (notice, error) { + kind, sid := store.Scope(r.PathValue("kind")), r.PathValue("sid") + if (kind != store.ScopeTeam && kind != store.ScopeProject) || !orgHas(o, kind, sid) { + return notice{}, refuse("org.err.not_here") + } + name, err := orgName(r) + if err != nil { + return notice{}, err + } + if kind == store.ScopeTeam { + err = e.RenameTeam(ctx, sid, name) + } else { + err = e.RenameProject(ctx, sid, name) + } + if err != nil { + return notice{}, err + } + return say("org.msg.renamed", name), nil + })) + + mux.HandleFunc("POST /org/{id}/{kind}/{sid}/delete", act(func(ctx context.Context, r *http.Request, _ store.User, o store.TreeOrg) (notice, error) { + kind, sid := store.Scope(r.PathValue("kind")), r.PathValue("sid") + if (kind != store.ScopeTeam && kind != store.ScopeProject) || !orgHas(o, kind, sid) { + return notice{}, refuse("org.err.not_here") + } + name := orgPlaceName(o, kind, sid) + var err error + if kind == store.ScopeTeam { + err = e.DeleteTeam(ctx, sid) + } else { + err = e.DeleteProject(ctx, sid) + } + if err != nil { + return notice{}, err + } + return say("org.msg.removed", name), nil + })) + + // A person is added by their address: the page does not list the whole + // cluster's accounts to someone who administers one organization. + mux.HandleFunc("POST /org/{id}/members", act(func(ctx context.Context, r *http.Request, by store.User, o store.TreeOrg) (notice, error) { + scope, sid, ok := strings.Cut(r.FormValue("where"), ":") + if !ok || !orgHas(o, store.Scope(scope), sid) { + return notice{}, refuse("org.err.not_here") + } + email := strings.TrimSpace(r.FormValue("email")) + u, err := e.UserByEmail(ctx, email) + if errors.Is(err, store.ErrNotFound) { + return notice{}, refuse("org.err.no_account", email) + } + if err != nil { + return notice{}, err + } + switch u.State { + case store.UserPending: + return notice{}, refuse("org.err.not_approved", u.Email) + case store.UserSuspended: + return notice{}, refuse("org.err.suspended", u.Email) + } + if err := e.AddMembership(ctx, u.ID, store.Scope(scope), sid, store.RoleMember, by.Email, time.Now()); err != nil { + return notice{}, err + } + return say("org.msg.added", u.Email, orgPlaceName(o, store.Scope(scope), sid)), nil + })) + + mux.HandleFunc("POST /org/{id}/members/{uid}/role", act(func(ctx context.Context, r *http.Request, by store.User, o store.TreeOrg) (notice, error) { + uid, role := r.PathValue("uid"), r.FormValue("role") + if !slices.Contains(store.MembershipRoles, role) { + return notice{}, refuse("org.err.role") + } + if uid == by.ID && !by.IsAdmin { + // Giving up one's own admin role by a misclick would leave + // nobody able to give it back but the cluster's staff. + return notice{}, refuse("org.err.self_role") + } + if err := e.AddMembership(ctx, uid, store.ScopeOrg, o.ID, role, by.Email, time.Now()); err != nil { + return notice{}, err + } + return roleNotice(role, o.Name), nil + })) + + mux.HandleFunc("POST /org/{id}/members/{uid}/leave", act(func(ctx context.Context, r *http.Request, by store.User, o store.TreeOrg) (notice, error) { + uid := r.PathValue("uid") + scope, sid, ok := strings.Cut(r.FormValue("where"), ":") + if !ok || !orgHas(o, store.Scope(scope), sid) { + return notice{}, refuse("org.err.not_here") + } + if uid == by.ID && store.Scope(scope) == store.ScopeOrg && !by.IsAdmin { + return notice{}, refuse("org.err.self_leave") + } + where := orgPlaceName(o, store.Scope(scope), sid) + revoked, err := e.RemoveMembership(ctx, uid, store.Scope(scope), sid, time.Now()) + if err != nil { + return notice{}, err + } + if revoked > 0 { + return say("org.msg.taken_out_keys", where, revoked), nil + } + return say("org.msg.taken_out", where), nil + })) + + mux.HandleFunc("POST /org/{id}/keys/{kid}/revoke", act(func(ctx context.Context, r *http.Request, _ store.User, o store.TreeOrg) (notice, error) { + kid := r.PathValue("kid") + keys, err := e.AllKeys(ctx) + if err != nil { + return notice{}, err + } + i := slices.IndexFunc(keys, func(k store.KeyRow) bool { return k.ID == kid }) + if i < 0 || !orgHas(o, store.ScopeProject, keys[i].ProjectID) { + return notice{}, refuse("org.err.not_here") + } + if err := e.RevokeKey(ctx, kid, time.Now()); err != nil { + return notice{}, err + } + return say("org.msg.key_revoked", keys[i].Prefix), nil + })) +} + +// orgAction is the guard every change on an organization's page shares: a +// session, the organization's admin role (or the cluster's), the form's CSRF +// token, an audit record, and the page drawn again with what happened. +func (p *AccountPages) orgAction(s OrgViewer, e OrgEditor, priced Priced, do orgChange) http.HandlerFunc { + return p.orgGuard(func(w http.ResponseWriter, r *http.Request, user store.User) { + id := r.PathValue("id") + if !user.IsAdmin { + ok, err := e.AdministersOrg(r.Context(), user.ID, id) + if err != nil || !ok { + // 404, as for a page they may not see: a manager learns + // nothing from trying. + http.NotFound(w, r) + return + } + } + sess, _, _ := p.session(r) + if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) { + badForm(w, r) + return + } + p.audited(w, r, user, func(w http.ResponseWriter, r *http.Request) { + l := langOf(r) + org, ok := orgIn(r.Context(), s, id) + if !ok { + http.NotFound(w, r) + return + } + msg, err := do(r.Context(), r, user, org) + if err != nil { + err = plainError(err) + p.log.Warn("a change to an organization was refused", "by", user.Email, "org", org.Name, + "path", r.URL.Path, "err", err) + noteAudit(r, org.Name, "", err.Error()) + p.renderOrg(w, r, s, user, id, priced, "", errorIn(l, err)) + return + } + p.log.Info("an organization was changed", "by", user.Email, "org", org.Name, + "what", msg.in(english)) + noteAudit(r, org.Name, msg.in(english), "") + p.renderOrg(w, r, s, user, id, priced, msg.in(l), "") + }) + }) +} + +// orgIn finds an organization in the tree. +func orgIn(ctx context.Context, s OrgViewer, id string) (store.TreeOrg, bool) { + tree, err := s.Tree(ctx) + if err != nil { + return store.TreeOrg{}, false + } + for _, o := range tree { + if o.ID == id { + return o, true + } + } + return store.TreeOrg{}, false +} + +// orgHas reports whether a place is the organization or lies in it. Every +// change checks it: an id in a form is the sender's to choose. +func orgHas(o store.TreeOrg, scope store.Scope, id string) bool { + return orgPlaceName(o, scope, id) != "" +} + +// orgPlaceName is a place in the organization by its path, "" when it is not +// in it. +func orgPlaceName(o store.TreeOrg, scope store.Scope, id string) string { + if id == "" { + return "" + } + if scope == store.ScopeOrg { + if id == o.ID { + return o.Name + } + return "" + } + for _, t := range o.Teams { + if scope == store.ScopeTeam && t.ID == id { + return o.Name + " / " + t.Name + } + for _, pr := range t.Projects { + if scope == store.ScopeProject && pr.ID == id { + return o.Name + " / " + t.Name + " / " + pr.Name + } + } + } + return "" +} + +func orgName(r *http.Request) (string, error) { + name := strings.TrimSpace(r.FormValue("name")) + if name == "" || strings.Contains(name, "/") { + return "", refuse("org.err.name") + } + return name, nil +} diff --git a/internal/web/org_admin_test.go b/internal/web/org_admin_test.go new file mode 100644 index 0000000..690317a --- /dev/null +++ b/internal/web/org_admin_test.go @@ -0,0 +1,210 @@ +package web + +import ( + "context" + "io" + "log/slog" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "gitea.mynodes.xyz/ilker/llmgw/internal/store" +) + +// fakeOrgEditor is fakeOrgs that also changes: u1 administers Acme, u4 +// manages it, and what each change did is written down. +type fakeOrgEditor struct { + fakeOrgs + admins map[string]string // org -> admin user id + did []string + revoked []string +} + +func (f *fakeOrgEditor) ManagesOrg(_ context.Context, user, org string) (bool, error) { + return f.admins[org] == user || f.managers[org] == user, nil +} +func (f *fakeOrgEditor) ManagedOrgs(ctx context.Context, user string) ([]store.Org, error) { + var out []store.Org + for _, o := range []store.Org{{ID: "org-a", Name: "Acme"}, {ID: "org-s", Name: "Scania"}} { + if ok, _ := f.ManagesOrg(ctx, user, o.ID); ok { + out = append(out, o) + } + } + return out, nil +} +func (f *fakeOrgEditor) AdministersOrg(_ context.Context, user, org string) (bool, error) { + return f.admins[org] == user, nil +} +func (f *fakeOrgEditor) UserByEmail(_ context.Context, email string) (store.User, error) { + switch email { + case "new@acme": + return store.User{ID: "u5", Email: email, State: store.UserActive}, nil + case "waiting@acme": + return store.User{ID: "u6", Email: email, State: store.UserPending}, nil + } + return store.User{}, store.ErrNotFound +} +func (f *fakeOrgEditor) CreateTeam(_ context.Context, org, name string) (string, error) { + f.did = append(f.did, "team "+org+" "+name) + return "t9", nil +} +func (f *fakeOrgEditor) CreateProject(_ context.Context, team, name string) (string, error) { + f.did = append(f.did, "project "+team+" "+name) + return "p9", nil +} +func (f *fakeOrgEditor) RenameTeam(_ context.Context, id, name string) error { + f.did = append(f.did, "rename team "+id+" "+name) + return nil +} +func (f *fakeOrgEditor) RenameProject(_ context.Context, id, name string) error { + f.did = append(f.did, "rename project "+id+" "+name) + return nil +} +func (f *fakeOrgEditor) DeleteTeam(_ context.Context, id string) error { + f.did = append(f.did, "delete team "+id) + return nil +} +func (f *fakeOrgEditor) DeleteProject(_ context.Context, id string) error { + f.did = append(f.did, "delete project "+id) + return nil +} +func (f *fakeOrgEditor) AddMembership(_ context.Context, user string, scope store.Scope, id, role, _ string, _ time.Time) error { + f.did = append(f.did, "join "+user+" "+string(scope)+":"+id+" "+role) + return nil +} +func (f *fakeOrgEditor) RemoveMembership(_ context.Context, user string, scope store.Scope, id string, _ time.Time) (int, error) { + f.did = append(f.did, "leave "+user+" "+string(scope)+":"+id) + return 0, nil +} +func (f *fakeOrgEditor) AllKeys(context.Context) ([]store.KeyRow, error) { + return []store.KeyRow{ + {Key: store.Key{ID: "k-acme", Prefix: "sk-a", ProjectID: "p1"}, ProjectPath: "Acme / ml / training"}, + {Key: store.Key{ID: "k-scania", Prefix: "sk-s", ProjectID: "p-s"}}, + }, nil +} +func (f *fakeOrgEditor) RevokeKey(_ context.Context, id string, _ time.Time) error { + f.revoked = append(f.revoked, id) + return nil +} + +func orgAdminPage(t *testing.T, user store.User) (*http.ServeMux, *fakeOrgEditor) { + t.Helper() + f := &fakeOrgEditor{ + fakeOrgs: fakeOrgs{managers: map[string]string{"org-a": "u4"}}, + admins: map[string]string{"org-a": "u1"}, + } + p := NewAccountPages(fakeAccounts{user: user}, nil, nil, "v0", slog.New(slog.NewTextHandler(io.Discard, nil))) + mux := http.NewServeMux() + p.RegisterOrgPages(mux, f, nil) + return mux, f +} + +func form(kv ...string) url.Values { + v := url.Values{"csrf": {"tok"}} + for i := 0; i+1 < len(kv); i += 2 { + v.Set(kv[i], kv[i+1]) + } + return v +} + +func TestAnOrganizationsAdminIsGivenTheFormsAndAManagerIsNot(t *testing.T) { + admin, _ := orgAdminPage(t, store.User{ID: "u1", Email: "boss@acme"}) + body := mirrorGet(t, admin, "/org/org-a").Body.String() + for _, want := range []string{`action="/org/org-a/teams"`, `action="/org/org-a/members"`, + `action="/org/org-a/keys/k-acme/revoke"`, "sk-a"} { + if !strings.Contains(body, want) { + t.Errorf("the admin's page lacks %q", want) + } + } + if strings.Contains(body, "k-scania") { + t.Error("a key of another organization is on the page") + } + + manager, f := orgAdminPage(t, store.User{ID: "u4", Email: "watch@acme"}) + w := mirrorGet(t, manager, "/org/org-a") + if w.Code != http.StatusOK { + t.Fatalf("the manager lost their page: %d", w.Code) + } + if strings.Contains(w.Body.String(), "
button, form.row label > button { margin: 0; } +form.row .grow { flex: 1 1 16rem; } input:focus { outline: 2px solid var(--accent); outline-offset: 1px; } .error { color: var(--bad); border: 1px solid var(--bad); border-radius: 6px; padding: .6rem .8rem; background: var(--card); } @@ -74,7 +85,7 @@ button.link { background: none; color: var(--accent); padding: 0; button.link.danger { color: var(--bad); } .ok { color: var(--ok); border: 1px solid var(--ok); border-radius: 6px; padding: .6rem .8rem; background: var(--card); } -select { font: inherit; padding: .35rem .4rem; border: 1px solid var(--line); +select { font: inherit; font-size: .9rem; padding: .28rem .4rem; border: 1px solid var(--line); border-radius: 6px; background: var(--card); color: var(--fg); } .chart-wrap { position: relative; margin: .5rem 0 1.5rem; } .chart { width: 100%; height: 120px; display: block; @@ -277,8 +288,11 @@ tr:has(+ .url-row) td, tr:has(+ .error-row) td { border-bottom: none; } /* The fetch form's three short fields sit on one line on a wide screen and stack on a narrow one, because two of them are a word long and a full-width input for "2026-09" reads as though more is wanted. */ -.field-row { display: flex; gap: 1rem; flex-wrap: wrap; } -.field-row label { flex: 1 1 12rem; } +.field-row { display: flex; gap: .4rem .75rem; flex-wrap: wrap; align-items: flex-end; } +.field-row label { flex: 0 1 12rem; margin: .3rem 0; } +.field-row label.short { flex: 0 0 7.5rem; } +.field-row label.grow { flex: 1 1 14rem; } +.field-row .field-gap { flex: 0 0 1rem; } .opt { font-weight: 400; color: var(--muted); font-size: .8rem; } /* The row whose quota form is open. Tinted rather than outlined: a border @@ -334,6 +348,8 @@ details.edit > summary::-webkit-details-marker { display: none; } details.edit[open] > summary { color: var(--muted); } details.edit form { display: flex; gap: .4rem; align-items: center; margin: .35rem 0; } details.edit input[type=text] { width: 14rem; } +details.edit form.q-form { display: block; } +details.edit form.q-form input[type=text] { width: 100%; } details.project { margin: .45rem 0; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); } details.project > summary { cursor: pointer; padding: .45rem .7rem; display: flex; @@ -418,10 +434,11 @@ table.quota-tree tr.q-depth-0 .q-name { font-weight: 600; } .q-meter.hot i { background: var(--bad); } .q-pct { font-size: .78rem; color: var(--muted); } .q-act { text-align: right; white-space: nowrap; } -.q-form { max-width: 46rem; } +.q-form { max-width: 62rem; } .q-form .field-row { align-items: flex-end; } -.q-form .field-row label { flex: 1 1 8rem; } +.q-form .field-row label { flex: 0 1 8rem; } .q-form .field-row label.q-period { flex: 0 0 auto; } +.q-form .field-row label.grow { flex: 1 1 14rem; } .q-form-title { margin: .2rem 0 .4rem; font-size: .88rem; } .q-form-actions { display: flex; gap: 1rem; align-items: center; margin-top: .6rem; } .q-form-actions .danger { margin-left: auto; } @@ -757,6 +774,7 @@ table.gpu-list { width: auto; flex: 1 1 22rem; } form.add-to { display: flex; gap: .35rem; align-items: center; flex-wrap: nowrap; } form.add-to select { width: auto; max-width: 13rem; margin: 0; padding: .25rem .4rem; font-size: .8rem; } form.add-to button { padding: .25rem .6rem; font-size: .78rem; } +form.add-to input[type=text] { width: 11rem; margin: 0; padding: .22rem .45rem; font-size: .8rem; } details.row-menu { position: relative; display: inline-block; } details.row-menu > summary { list-style: none; cursor: pointer; padding: .1rem .55rem; border-radius: 6px; color: var(--muted); font-weight: 700; letter-spacing: .05em; user-select: none; } @@ -813,7 +831,8 @@ details.chat-settings output { font-variant-numeric: tabular-nums; font-weight: .copyable { position: relative; } .copyable pre { margin-bottom: .25rem; overflow-x: auto; } .copyable button { margin-right: .75rem; font-size: .8rem; } -.connect-form .field-row label { flex: 1 1 12rem; } +.connect-form .field-row label { flex: 0 1 auto; } +.connect-form .field-row > button { margin: .3rem 0; } table.connect-fields th { white-space: nowrap; } /* The card opened beneath a held version on the mirror page. */ @@ -941,3 +960,10 @@ a.sel-link.small { font-size: .78rem; color: var(--muted); margin-left: .4rem; } .meshes-picture input { display: block; margin-top: .3rem; font-weight: 400; } .meshes-preview { max-width: min(100%, 16rem); max-height: 16rem; border: 1px solid var(--line); border-radius: 6px; } .meshes-viewer { display: block; width: 100%; height: 22rem; background: var(--card); } + +/* A role chosen inside a membership chip. */ +select.chip-sel { font-size: .75rem; padding: 0 .2rem; margin-left: .3rem; border-radius: 4px; } +fieldset { border: 1px solid var(--line); border-radius: 6px; padding: .3rem .8rem .5rem; margin: .5rem 0; } +legend { font-size: .82rem; font-weight: 600; padding: 0 .3rem; } +fieldset label.check { margin: .2rem 0; font-weight: 400; font-size: .85rem; } + diff --git a/internal/web/templates/account.html b/internal/web/templates/account.html index caa51b6..11114b7 100644 --- a/internal/web/templates/account.html +++ b/internal/web/templates/account.html @@ -119,8 +119,8 @@ {{else}}{{end}} {{if .Contexts}}{{end}} + -

{{t "profile.create_key"}}

-
+ {{if .Contexts}} @@ -235,7 +235,7 @@ document.addEventListener("click", (e) => {

{{t "profile.password"}}

{{if .PasswordChanged}}

{{t "profile.password_changed"}}

{{end}} {{if .PasswordError}}

{{.PasswordError}}

{{end}} - + diff --git a/internal/web/templates/admin_mirror.html b/internal/web/templates/admin_mirror.html index d98459c..d74c689 100644 --- a/internal/web/templates/admin_mirror.html +++ b/internal/web/templates/admin_mirror.html @@ -71,7 +71,7 @@ {{else}} {{/* Submitted on change: the choice is the action, and a second click to confirm it would only be a way to forget to. */}} - + {{if $.CSRF}} @@ -81,7 +81,7 @@ {{/* The button is in the catalogue: what a noscript holds is read as text, not markup. */}} -
+ {{end}} {{end}} {{if .Fleet}} {{- if .Parked}}{{t "mirror.use.parked"}} @@ -99,24 +99,24 @@ whose only outcome is a confusing error. Starting the same download again is what somebody looking at a failed row wants, and the mirror allows it: only a download still running is refused. */}} -
+ {{if $.CSRF}} -
+ {{end}} {{else if eq (printf "%s" .State) "present"}} {{if and .UsedBy (not .Detail)}}{{t "mirror.settings.open"}}{{end}} {{if and $.HasCatalog (not .UsedBy)}}{{if .Serve}}{{t "mirror.close"}} {{else}}{{t "mirror.serve"}}{{end}}{{end}} -
-
+ {{end}} {{end}} @@ -130,7 +130,7 @@

{{t "serve.refusal" .Refusal}}

{{else}} {{if .Warning}}

{{.Warning}}.

{{end}} -
+{{if $.CSRF}} @@ -191,7 +191,7 @@ -
+{{end}} +

{{t "people.title"}}

@@ -94,17 +95,17 @@ {{if .Member}}
{{t "people.each_member"}}
{{.Member}}
{{end}} {{if .Shadowed}}

{{.Shadowed}}

{{end}} -
{{if .HasAny}}{{t "people.change_limits"}}{{else}}{{t "people.set_limits"}}{{end}} +{{if $d.CSRF}}
{{if .HasAny}}{{t "people.change_limits"}}{{else}}{{t "people.set_limits"}}{{end}} {{template "quota-form" (dict "Scope" (printf "%s" .Scope) "ID" .ID "CSRF" $d.CSRF "Sel" $sel "F" (dict "RPM" .RPM "TPM" .TPM "Budget" .Budget "Period" .Period "Models" .Models "HasAny" .HasAny) "Title" (t "people.quota_for" .Crumb) "Note" (t "people.note.shared"))}} -
-
{{t "people.limit_each_member"}} +
{{end}} +{{if $d.CSRF}}
{{t "people.limit_each_member"}} {{template "quota-form" (dict "Scope" (printf "%s-member" .Scope) "ID" .ID "F" .MemberRaw "CSRF" $d.CSRF "Sel" $sel "Title" (t "people.each_member_of" .Crumb) "Note" (t "people.note.each_member"))}} -
+
{{end}} {{end}} {{with .Person}}
@@ -114,12 +115,12 @@
{{t "people.spent" $d.QuotaWindow}}
{{.Spend}}{{if .HasPct}} {{t "quotas.pct" .Percent}}{{end}}
{{t "people.models"}}
{{if .Models}}{{th "people.models_only" .Models}}{{else}}{{t "people.models_every"}}{{end}}
-
{{if .HasAny}}{{t "people.change_limits"}}{{else}}{{t "people.set_limits"}}{{end}} +{{if $d.CSRF}}
{{if .HasAny}}{{t "people.change_limits"}}{{else}}{{t "people.set_limits"}}{{end}} {{template "quota-form" (dict "Scope" "user" "ID" .ID "CSRF" $d.CSRF "Sel" $sel "F" (dict "RPM" .RPM "TPM" .TPM "Budget" .Budget "Period" .Period "Models" .Models "HasAny" .HasAny) "Title" (t "people.everywhere" .Email) "Note" (t "people.note.person"))}} -
+
{{end}} {{end}} {{end}} diff --git a/internal/web/templates/admin_quotas.html b/internal/web/templates/admin_quotas.html index 7c91f78..4347d7a 100644 --- a/internal/web/templates/admin_quotas.html +++ b/internal/web/templates/admin_quotas.html @@ -41,12 +41,12 @@ {{if .HasPct}} {{t "quotas.pct" .Percent}}{{end}} - + {{if $.CSRF}} {{if .Editing}}{{t "quotas.close"}} {{else}}{{if .HasAny}}{{t "quotas.change"}}{{else}}{{t "quotas.set"}}{{end}}{{end}} {{if .MemberEditing}}{{t "quotas.close"}} {{else}}{{t "quotas.per_member"}}{{end}} - + {{end}} {{if .MemberEditing}} @@ -58,7 +58,7 @@ {{if .Editing}} -
+ {{if $.CSRF}} @@ -84,7 +84,7 @@
- + {{end}} {{end}} {{end}} @@ -110,8 +110,8 @@ {{.Spend}} {{if .HasPct}} {{t "quotas.pct" .Percent}}{{end}} - {{if .Editing}}{{t "quotas.close"}} - {{else}}{{if .HasAny}}{{t "quotas.change"}}{{else}}{{t "quotas.set"}}{{end}}{{end}} + {{if $.CSRF}}{{if .Editing}}{{t "quotas.close"}} + {{else}}{{if .HasAny}}{{t "quotas.change"}}{{else}}{{t "quotas.set"}}{{end}}{{end}}{{end}} {{if .Editing}} @@ -138,38 +138,39 @@ {{or .Memory "—"}} {{.UpdatedBy}} {{t "quotas.change"}} -
+ {{if $.CSRF}} -
+ {{end}} {{end}}

{{t "capacity.legend"}}

{{else}}

{{t "capacity.empty"}}

{{end}} {{if .Scopes}} -
+{{if .CSRF}}

{{if .CapForm.Scope}}{{th "capacity.form.title_change"}}{{else}}{{th "capacity.form.title_set"}}{{end}}

+
-
- - -
- - - + + + + + + +
{{if .CapForm.Scope}}{{t "common.cancel"}}{{end}}
- +{{end}} {{else}}

{{th "capacity.empty_tree"}}

{{end}} {{end}} @@ -185,7 +186,7 @@ held to from above, marked as inherited and saying from where. */}} {{/* The form for a limit that is not a branch's own: per member, or a person. */}} {{define "quota-form"}} -
+ {{if .CSRF}} @@ -193,24 +194,24 @@ {{with .Sel}}{{end}}

{{th "quotas.qform.title" .Title .Note}}

- - - + + + +
-
{{if not .Sel}}{{t "common.cancel"}}{{end}}
-
+ {{end}} {{end}} {{define "quota-value"}}{{if .Own}}{{.Own}}{{else if .Up}}{{.Up}} ↑{{else}}—{{end}}{{end}} diff --git a/internal/web/templates/admin_users.html b/internal/web/templates/admin_users.html index 1166148..a668616 100644 --- a/internal/web/templates/admin_users.html +++ b/internal/web/templates/admin_users.html @@ -10,21 +10,21 @@ {{range .Pending}} {{.Email}}{{.Name}}{{ago .CreatedAt}} -
+ {{if $.Edit}} -
+ {{end}} -
-
+ {{end}} {{end}} {{end}} @@ -42,19 +42,19 @@ {{$org.Name}} {{tn "users.n_teams" (len $org.Teams)}} {{template "scope-members" (dict "Kind" "org" "ID" $org.ID "Name" $org.Name "D" $)}} -
{{t "users.rename"}} + {{if $.Edit}}
{{t "users.rename"}}
-
+
{{end}} {{if not $org.Teams}} -
-
+ {{end}} {{end}}
@@ -64,15 +64,15 @@ {{$team.Name}} {{tn "users.n_projects" (len $team.Projects)}} {{template "scope-members" (dict "Kind" "team" "ID" $team.ID "Name" $team.Name "D" $)}} -
{{t "users.rename"}} + {{if $.Edit}}
{{t "users.rename"}}
-
+
{{end}} {{if gt (len $.Orgs) 1}} -
{{t "users.move"}} + {{if $.Edit}}
{{t "users.move"}}
-
+
{{end}} {{end}} {{if not $team.Projects}} -
-
+ {{end}} {{end}} @@ -101,16 +101,16 @@
-
{{t "users.rename"}} + {{if $.Edit}}
{{t "users.rename"}}
-
+
{{end}} {{if gt (len $.Teams) 1}} -
{{t "users.move_project"}} + {{if $.Edit}}
{{t "users.move_project"}}
@@ -119,14 +119,14 @@
-
+
{{end}} {{end}} {{if and (eq $p.Users 0) (eq $p.Keys 0)}} -
-
+ {{end}} {{else}}{{t "users.not_removable"}}{{end}}
@@ -145,12 +145,12 @@ {{t (print "users.key_state." .State)}} {{ago .LastUsedAt}} {{if eq .State "active"}} -
-
{{end}} + {{end}}{{end}} {{end}} {{end}} @@ -160,7 +160,7 @@ {{/* Folded until wanted: a box open under every team was most of the tree's height and read as something to fill in. */}} -
+ {{if $.Edit}}
{{t "users.add_project"}}
@@ -168,11 +168,11 @@
-
+
{{end}}
{{end}} -
+ {{if $.Edit}}
{{t "users.add_team"}}
@@ -180,15 +180,15 @@
-
+
{{end}} {{end}} -
+{{if $.Edit}} -
+{{end}}

{{t "users.accounts"}}

{{if .Accounts}} @@ -211,24 +211,24 @@ ⋯ {{else}}{{t "users.you"}}{{end}} @@ -241,22 +241,22 @@ {{$u.Email}}{{if $u.IsAdmin}} {{t "users.tag.admin"}}{{end}} {{t (print "users.state." $u.State)}} - {{range $i, $m := $u.Memberships}}{{if lt $i 2}}{{with $m}}{{.Path}}{{if eq .Role "manager"}} {{t "users.role.manager"}}{{end}} - {{if eq (printf "%s" .Scope) "org"}} + {{range $i, $m := $u.Memberships}}{{if lt $i 2}}{{with $m}}{{.Path}} + {{if and (eq (printf "%s" .Scope) "org") $d.Edit}}
- - +
- {{end}} -
{{t (print "users.role." .Role)}}{{end}} + {{if $d.Edit}} -
{{end}}{{end}} + {{end}}
{{end}}{{end}} {{else}}{{t "users.on_own"}}{{end}} {{/* Two, then a count: a person in six teams was six chips wrapping the row into a paragraph. The rest are named on hover and in their own @@ -265,14 +265,14 @@ {{ago $u.LastLoginAt}} -
+ {{if $d.Edit}} -
+ {{end}} {{template "state-actions" (dict "U" $u "D" $d "Open" "")}} {{end}} @@ -285,19 +285,19 @@ {{t (print "users.state." $u.State)}} {{ago $u.LastLoginAt}} -
-
+ {{end}} {{end}} {{/* Adding a person to a scope, from the scope. */}} {{define "add-member"}}{{$d := .D}} -
+{{if $d.Edit}} -
{{end}} +{{end}}{{end}} {{/* An org's or team's own members, folded away until asked for. */}} {{define "scope-members"}}{{$d := .D}}{{$k := printf "%s:%s" .Kind .ID}} diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html index 1454c93..af75cac 100644 --- a/internal/web/templates/dashboard.html +++ b/internal/web/templates/dashboard.html @@ -109,7 +109,7 @@ administrator, on the page reached through a session. */}} {{if $.AnyGPUs}}{{if .GPUTotal}}{{$node := .}} {{range .Provision}}{{if eq .Engine "vllm"}}{{.Version}} {{with .Variant}}{{buildLabel .}} {{end}}{{t (print "dash.state." .State)}}{{end}}{{end}} - {{if and $.Menu.CSRF .EngineBuilds}} + {{if and ($.Menu.May "/admin/nodes/x/engines") .EngineBuilds}}
@@ -177,7 +177,7 @@ document.querySelectorAll("form.build-choice select").forEach(function (s) { {{or .Fits (t "dash.presets.fits_none")}} {{/* Why the leader does not add it itself is the control plane's, in English. */}} {{or .State "—"}}{{with .Blocked}} ({{.}}){{end}} - {{if and $.Menu.CSRF (not .InCatalog)}} + {{if and ($.Menu.May "/admin/presets") (not .InCatalog)}} @@ -185,7 +185,7 @@ document.querySelectorAll("form.build-choice select").forEach(function (s) { {{end}}

{{if .Auto}}{{t "dash.presets.auto_on"}}{{else}}{{t "dash.presets.auto_off"}}{{end}} -{{if $.Menu.CSRF}} +{{if $.Menu.May "/admin/presets"}} diff --git a/internal/web/templates/org.html b/internal/web/templates/org.html index a50de22..150421b 100644 --- a/internal/web/templates/org.html +++ b/internal/web/templates/org.html @@ -2,6 +2,7 @@ {{.Org.Name}} · llmgw +{{if .Edit}}{{end}}

{{.Org.Name}}

@@ -17,6 +18,7 @@ {{end}} {{if .Error}}

{{.Error}}

{{end}} +{{if .Message}}

{{.Message}}

{{end}}

{{t "org.use"}}

@@ -78,14 +80,87 @@

{{t "org.members.title"}}

{{if .Members}} - -{{range .Members}} - - - +{{if .Edit}}{{end}} +{{range $m := .Members}} + + + {{if $.Edit}}{{end}} + {{end}}
{{t "org.col.person"}}{{t "org.col.member_of"}}{{t "org.col.state"}}
{{.Email}}{{range .Places}}{{.Where}}{{if eq .Role "manager"}} {{t "org.role.manager"}}{{end}}{{end}}{{with .State}}{{t (print "org.state." .)}}{{end}}
{{t "org.col.person"}}{{t "org.col.member_of"}}{{t "org.col.role"}}{{t "org.col.state"}}
{{$m.Email}}{{range $m.Places}}{{.Where}}{{if and (ne .Role "member") (not $.Edit)}} {{t (print "users.role." .Role)}}{{end}} + {{if $.Edit}} + + + {{end}}{{end}}{{if and $m.OrgRole (eq $m.ID $.Me)}}{{t (print "users.role." $m.OrgRole)}}{{else if $m.OrgRole}} +
+ + +
{{else}}—{{end}}
{{with $m.State}}{{t (print "org.state." .)}}{{end}}
{{else}}

{{t "org.members.empty"}}

{{end}} +{{if .Edit}} +
+ + + + +
+

{{t "org.add.note"}}

+ +

{{t "org.tree.title"}}

+{{if .Teams}} + + +{{range $tm := .Teams}} + + + +{{end}} +
{{t "org.col.team"}}{{t "org.col.projects"}}
{{$tm.Name}} +
+ + + +
{{range $tm.Projects}}{{.Name}} +
+ + +
{{end}} +
+ + + +
+ + +
+{{else}}

{{t "org.tree.empty"}}

{{end}} +
+ + + +
+

{{t "org.tree.note"}}

+ +

{{t "org.keys.title"}}

+{{if .Keys}} + + +{{range .Keys}} + + + +{{end}} +
{{t "org.keys.col.key"}}{{t "org.col.project"}}{{t "org.keys.col.owner"}}{{t "org.keys.col.used"}}
{{.Prefix}}… {{.Name}}{{.ProjectPath}}{{or .OwnerEmail "—"}}{{if .LastUsedAt.IsZero}}—{{else}}{{ago .LastUsedAt}}{{end}}
+ + +
+{{else}}

{{t "org.keys.empty"}}

{{end}} +{{end}}
{{end}} diff --git a/internal/web/web.go b/internal/web/web.go index 35bb942..6c34f6b 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -81,6 +81,8 @@ var ( ) var funcs = template.FuncMap{ + // roles are a membership's roles, for a chooser. + "roles": func() []string { return store.MembershipRoles }, "stylesheet": func() string { return stylesheetURL }, "asset": assetURL, "lower": strings.ToLower,