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>
This commit is contained in:
Ilker ManapandClaude Opus 5.5 committed 2026-11-08 11:38:06 +01:00
1 parent 2cb1afda55
commit 102ca9556f
37 files changed
+1204 -212

No files matched your search

+5 -4
View File
@@ -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`
+33
View File
@@ -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
+2 -1
View File
@@ -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**
+32 -11
View File
@@ -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.
+2 -2
View File
@@ -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] == "" {
+4 -2
View File
@@ -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
}
+6 -2
View File
@@ -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 {
+2 -2
View File
@@ -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
}
}
+2 -2
View File
@@ -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())
}
+1 -1
View File
@@ -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
}
+54 -5
View File
@@ -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
}
+75
View File
@@ -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")
}
}
+2
View File
@@ -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
+7 -2
View File
@@ -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})
}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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),
}
+24 -10
View File
@@ -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.
+3 -3
View File
@@ -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</b>", "kuruluşun tamamı", "son 7 gün", `lang="tr"`} {
for _, want := range []string{"2 kişi", "Kullanım", "Sınırlar", "Üyeler",
">sorumlu</b>", "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")
}
}
+42 -3
View File
@@ -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": "<a href=\"/admin/users\">Create one on the people page</a>, then make someone its manager with the star on their membership.",
"org.none.how": "<a href=\"/admin/users\">Create one on the people page</a>, 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"
}
+6 -5
View File
@@ -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",
+42 -3
View File
@@ -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": "<a href=\"/admin/users\">Kişiler sayfasında bir kuruluş oluşturun</a>, ardından birinin üyeliğindeki yıldızla onu kuruluşun yöneticisi yapın.",
"org.none.how": "<a href=\"/admin/users\">Kişiler sayfasında bir kuruluş oluşturun</a>, 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ı"
}
+6 -5
View File
@@ -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",
+2 -2
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
}
+296
View File
@@ -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
}
+210
View File
@@ -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(), "<form method=\"post\" action=\"/org/") {
t.Error("a manager is shown forms they cannot send")
}
if w := mirrorPost(t, manager, "/org/org-a/teams", form("name", "x")); w.Code != http.StatusNotFound {
t.Errorf("a manager changed the organization: %d", w.Code)
}
if len(f.did) != 0 {
t.Errorf("a manager's change reached the store: %v", f.did)
}
}
func TestAnOrganizationsAdminChangesOnlyTheirOwn(t *testing.T) {
mux, f := orgAdminPage(t, store.User{ID: "u1", Email: "boss@acme"})
if w := mirrorPost(t, mux, "/org/org-a/teams", form("name", "vision")); w.Code != http.StatusOK ||
!strings.Contains(w.Body.String(), "Team vision added") {
t.Errorf("adding a team: %d", w.Code)
}
if w := mirrorPost(t, mux, "/org/org-s/teams", form("name", "x")); w.Code != http.StatusNotFound {
t.Errorf("an admin of Acme added a team to Scania: %d", w.Code)
}
// Ids in a form are the sender's to choose: a team of another
// organization, named on Acme's page, is refused.
mirrorPost(t, mux, "/org/org-a/projects", form("team", "t-scania", "name", "x"))
mirrorPost(t, mux, "/org/org-a/members", form("email", "new@acme", "where", "org:org-s"))
mirrorPost(t, mux, "/org/org-a/keys/k-scania/revoke", form())
mirrorPost(t, mux, "/org/org-a/project/p-scania/delete", form())
if len(f.revoked) != 0 {
t.Errorf("a key of another organization was revoked: %v", f.revoked)
}
mirrorPost(t, mux, "/org/org-a/projects", form("team", "t1", "name", "eval"))
mirrorPost(t, mux, "/org/org-a/members", form("email", "new@acme", "where", "project:p1"))
mirrorPost(t, mux, "/org/org-a/members/u2/role", form("role", "manager"))
mirrorPost(t, mux, "/org/org-a/keys/k-acme/revoke", form())
want := []string{"team org-a vision", "project t1 eval", "join u5 project:p1 member", "join u2 org:org-a manager"}
if strings.Join(f.did, "|") != strings.Join(want, "|") {
t.Errorf("the store was asked\n %v\nwant\n %v", f.did, want)
}
if len(f.revoked) != 1 || f.revoked[0] != "k-acme" {
t.Errorf("revoked %v", f.revoked)
}
}
func TestAnOrganizationsAdminAddsOnlyApprovedPeople(t *testing.T) {
mux, f := orgAdminPage(t, store.User{ID: "u1", Email: "boss@acme"})
for email, says := range map[string]string{
"nobody@acme": "there is no account for nobody@acme",
"waiting@acme": "waits for the cluster",
} {
w := mirrorPost(t, mux, "/org/org-a/members", form("email", email, "where", "org:org-a"))
if !strings.Contains(w.Body.String(), says) {
t.Errorf("%s: the page does not say %q", email, says)
}
}
if len(f.did) != 0 {
t.Errorf("someone not approved was added: %v", f.did)
}
}
func TestAnOrganizationsAdminCannotLockThemselvesOut(t *testing.T) {
mux, f := orgAdminPage(t, store.User{ID: "u1", Email: "boss@acme"})
mirrorPost(t, mux, "/org/org-a/members/u1/role", form("role", "member"))
mirrorPost(t, mux, "/org/org-a/members/u1/leave", form("where", "org:org-a"))
if len(f.did) != 0 {
t.Errorf("an admin took their own role away: %v", f.did)
}
}
func TestAViewerIsShownNoFormsOnThePeoplePage(t *testing.T) {
for role, wantForms := range map[string]bool{store.RoleViewer: false, store.RoleAdmin: true} {
u := store.User{ID: "s", Email: "s@x", Role: role, IsAdmin: role == store.RoleAdmin}
p := NewAccountPages(fakeAccounts{user: u}, nil, nil, "v0", slog.New(slog.NewTextHandler(io.Discard, nil)))
mux := http.NewServeMux()
p.RegisterAdminUsers(mux, &fakeAdmin{})
body := mirrorGet(t, mux, "/admin/users").Body.String()
if got := strings.Contains(body, `method="post" action="/admin`); got != wantForms {
t.Errorf("%s: forms on the page = %v, want %v", role, got, wantForms)
}
}
}
+72 -10
View File
@@ -10,7 +10,8 @@ import (
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// OrgViewer is what an organization's page reads. Nothing on it writes.
// 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)
@@ -27,12 +28,18 @@ type OrgViewer interface {
// 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
}
type orgPlace struct{ Where, Role 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() {
@@ -58,6 +65,16 @@ type orgPageData struct {
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
@@ -66,12 +83,16 @@ type orgPageData struct {
// 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.
// 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
@@ -107,7 +128,7 @@ func (p *AccountPages) RegisterOrgPages(mux *http.ServeMux, s OrgViewer, priced
return
}
}
p.renderOrg(w, r, s, user, id, priced)
p.renderOrg(w, r, s, user, id, priced, "", "")
}))
}
@@ -149,7 +170,7 @@ func (p *AccountPages) orgGuard(next func(http.ResponseWriter, *http.Request, st
}
func (p *AccountPages) renderOrg(w http.ResponseWriter, r *http.Request, s OrgViewer,
user store.User, orgID string, priced Priced,
user store.User, orgID string, priced Priced, msg, errMsg string,
) {
ctx := r.Context()
by := store.Grouping(r.URL.Query().Get("by"))
@@ -162,6 +183,7 @@ func (p *AccountPages) renderOrg(w http.ResponseWriter, r *http.Request, s OrgVi
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),
}
@@ -217,10 +239,13 @@ func (p *AccountPages) renderOrg(w http.ResponseWriter, r *http.Request, s OrgVi
om := byPerson[m.UserID]
if om == nil {
u := byID[m.UserID]
om = &orgMember{Email: u.Email, State: u.State}
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})
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)
@@ -228,6 +253,8 @@ func (p *AccountPages) renderOrg(w http.ResponseWriter, r *http.Request, s OrgVi
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)
@@ -236,3 +263,38 @@ func (p *AccountPages) renderOrg(w http.ResponseWriter, r *http.Request, s OrgVi
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)})
}
}
}
+34 -2
View File
@@ -24,10 +24,42 @@ func staffMay(u store.User, r *http.Request) bool {
case store.RoleViewer:
return read
case store.RoleOperator:
return read || strings.HasPrefix(path, "/admin/mirror/") ||
strings.HasPrefix(path, "/admin/nodes/") && strings.HasSuffix(path, "/engines")
return read || staffMayChange(u.Role, path)
case store.RoleBilling:
return read && (path == "/admin/usage" || path == "/admin/quotas")
}
return false
}
// staffMayChange reports whether a staff role may send a change to an
// administration path. It is staffMay's rule for everything but reading, and
// what the pages ask before they draw a form, so nobody is shown a form whose
// answer would be "not found".
func staffMayChange(role, path string) bool {
switch role {
case store.RoleAdmin:
return true
case store.RoleOperator:
return strings.HasPrefix(path, "/admin/mirror/") ||
strings.HasPrefix(path, "/admin/nodes/") && strings.HasSuffix(path, "/engines")
}
return false
}
// csrfIfMay is the form token for a page whose forms send to path, or ""
// when this person may not send them: a page draws a form only where it has a
// token for it, so a viewer is drawn the page without its forms.
func csrfIfMay(r *http.Request, path string) string {
if !navFrom(r, "").May(path) {
return ""
}
return csrfFor(r)
}
// May reports whether the person this menu is for may send a change to an
// administration path: a template asks it before drawing a form, with the
// form's action ("/admin/mirror/fetch"); a part of the path that varies may be
// anything ("/admin/nodes/x/engines").
func (n Nav) May(path string) bool {
return n.Admin && staffMayChange(n.Role, path)
}
+36 -10
View File
@@ -52,19 +52,30 @@ footer { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid var(--line);
button {
font: inherit; font-weight: 600; cursor: pointer;
background: var(--accent); color: var(--bg); border: none;
border-radius: 6px; padding: .55rem 1.1rem;
border-radius: 6px; padding: .4rem .9rem; font-size: .875rem;
}
button:hover { filter: brightness(1.1); }
ul { padding-left: 1.2rem; }
li { margin: .25rem 0; }
a { color: var(--accent); }
.wrap.narrow { max-width: 30rem; }
label { display: block; margin: .9rem 0; font-size: .9rem; font-weight: 600; }
/* Forms are compact: a label sits close to its field, a field is as wide as
what goes in it rather than the page, and a short form fits on one line
(form.row). The simplest form used to take a screen's height. */
label { display: block; margin: .5rem 0; font-size: .85rem; font-weight: 600; }
input[type=text], input[type=email], input[type=password], input[type=search], input[type=number] {
display: block; width: 100%; margin-top: .3rem; font: inherit; font-weight: 400;
padding: .5rem .6rem; border: 1px solid var(--line); border-radius: 6px;
display: block; width: 100%; max-width: 30rem; margin-top: .2rem; font: inherit; font-size: .9rem;
font-weight: 400; padding: .32rem .5rem; border: 1px solid var(--line); border-radius: 6px;
background: var(--card); color: var(--fg);
}
input[type=number] { max-width: 10rem; }
form.row { display: flex; flex-wrap: wrap; gap: .4rem .6rem; align-items: flex-end; margin: .5rem 0; }
form.row label { margin: 0; }
form.row input[type=text], form.row input[type=email], form.row input[type=password],
form.row input[type=number], form.row select { width: auto; min-width: 12rem; margin: 0; }
form.row input[type=number] { min-width: 6rem; width: 7rem; }
form.row > 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; }
+3 -3
View File
@@ -119,8 +119,8 @@
{{else}}<input type="text" name="model" placeholder="{{t "connect.model_placeholder"}}" required>{{end}}</label>
{{if .Contexts}}<label>{{t "connect.spends_against"}}
<select name="context">{{range .Contexts}}<option value="{{.Value}}">{{.Label}}</option>{{end}}</select></label>{{end}}
<button type="submit">{{t "connect.submit"}}</button>
</div>
<button type="submit">{{t "connect.submit"}}</button>
</form>
<script>
// Copy and download for what the connect panel shows, done in the page: the
@@ -145,7 +145,7 @@ document.addEventListener("click", (e) => {
</script>
<h2>{{t "profile.create_key"}}</h2>
<form method="post" action="/profile/keys" class="narrow">
<form method="post" action="/profile/keys" class="row">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label>{{t "profile.key_for"}}<input type="text" name="name" placeholder="{{t "profile.key_for_placeholder"}}" required></label>
{{if .Contexts}}
@@ -235,7 +235,7 @@ document.addEventListener("click", (e) => {
<h2>{{t "profile.password"}}</h2>
{{if .PasswordChanged}}<p>{{t "profile.password_changed"}}</p>{{end}}
{{if .PasswordError}}<p class="error">{{.PasswordError}}</p>{{end}}
<form method="post" action="/profile/password" autocomplete="on" class="narrow">
<form method="post" action="/profile/password" autocomplete="on" class="row">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label>{{t "profile.current_password"}}<input type="password" name="current" required
autocomplete="current-password"></label>
+33 -33
View File
@@ -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. */}}
<form method="post" action="/admin/mirror/mode" class="inline">
{{if $.CSRF}}<form method="post" action="/admin/mirror/mode" class="inline">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="model" value="{{.Name}}">
<input type="hidden" name="replicas" value="{{.Replicas}}">
@@ -81,7 +81,7 @@
<option value="stopped"{{if eq .Mode "stopped"}} selected{{end}}>{{t "mirror.mode.stopped"}}</option>
</select>
{{/* The button is in the catalogue: what a noscript holds is read as text, not markup. */}}<noscript>{{th "mirror.use.set"}}</noscript>
</form>
</form>{{end}}
{{end}}
{{if .Fleet}}<span class="runs-now{{if or .Parked .Unplaceable}} bad{{else if lt .Ready .Wanted}} short{{end}}" title="{{if .Starting}}{{t "mirror.use.runs_title_starting"}}{{else}}{{t "mirror.use.runs_title"}}{{end}}">
{{- 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. */}}
<form method="post" action="/admin/mirror/fetch" class="inline">
{{if $.CSRF}}<form method="post" action="/admin/mirror/fetch" class="inline">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="source" value="{{.Source}}">
<input type="hidden" name="model" value="{{.Model}}">
<input type="hidden" name="version" value="{{.Version}}">
<input type="hidden" name="file" value="{{.File}}">
<button type="submit" class="link">{{t "mirror.retry"}}</button>
</form>
</form>{{end}}
{{else if eq (printf "%s" .State) "present"}}
<button type="button" class="link copy-url" data-url="{{.URL}}" title="{{.URL}}">{{t "mirror.copy_address"}}</button>
{{if and .UsedBy (not .Detail)}}<a href="/admin/mirror?tab=held&amp;open={{.Open}}">{{t "mirror.settings.open"}}</a>{{end}}
{{if and $.HasCatalog (not .UsedBy)}}{{if .Serve}}<a href="/admin/mirror">{{t "mirror.close"}}</a>
{{else}}<a href="/admin/mirror?serve={{.Model}}:{{.Version}}">{{t "mirror.serve"}}</a>{{end}}{{end}}
<form method="post" action="/admin/mirror/{{.Model}}/{{.Version}}/remove"
{{if $.CSRF}}<form method="post" action="/admin/mirror/{{.Model}}/{{.Version}}/remove"
class="inline" data-confirm="{{if .UsedBy}}{{t "mirror.remove_confirm_in_use" .Model .Version}}{{else}}{{t "mirror.remove_confirm" .Model .Version}}{{end}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button type="submit" class="link danger">{{t "mirror.remove"}}</button>
</form>
</form>{{end}}
{{end}}
</td>
</tr>
@@ -130,7 +130,7 @@
<p class="error">{{t "serve.refusal" .Refusal}}</p>
{{else}}
{{if .Warning}}<p class="note error-row">{{.Warning}}.</p>{{end}}
<form method="post" action="/admin/mirror/serve" class="serve-form" data-engine="{{.Engine}}" data-free-gpus="{{.FreeGPUs}}">
{{if $.CSRF}}<form method="post" action="/admin/mirror/serve" class="serve-form" data-engine="{{.Engine}}" data-free-gpus="{{.FreeGPUs}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="source" value="{{.Source}}">
<input type="hidden" name="engine" value="{{.Engine}}">
@@ -191,7 +191,7 @@
</div>
<p class="sub serve-summary" hidden></p>
<button type="submit">{{t "serve.add"}}</button>
</form>
</form>{{end}}
<script>
// What it will run as, in one line, kept up to date as the form changes:
// engine, where, how many cards, memory, context and how it is shared. The
@@ -473,18 +473,18 @@ document.querySelector("table.explore").addEventListener("submit", async functio
<td>{{.DownloadGB}} GB</td>
<td>{{.RAMGB}} GB</td>
<td>{{if .Fits}}{{.Runs}}{{else}}<span class="opt">{{.Runs}}</span>{{end}}</td>
<td>{{if and $fam.FetchesItself .Fits}}<form method="post" action="/admin/mirror/strata" class="inline">
<td>{{if and $fam.FetchesItself .Fits}}{{if $.CSRF}}<form method="post" action="/admin/mirror/strata" class="inline">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="tab" value="strata">
<input type="hidden" name="action" value="serve">
<input type="hidden" name="family" value="{{$fam.Name}}">
<input type="hidden" name="size" value="{{.Size}}">
<button type="submit">{{t "strata.serve"}}</button></form>{{end}}{{if and $fam.Fetchable .Fits}}<form method="post" action="/admin/mirror/strata" class="inline">
<button type="submit">{{t "strata.serve"}}</button></form>{{end}}{{end}}{{if and $fam.Fetchable .Fits}}{{if $.CSRF}}<form method="post" action="/admin/mirror/strata" class="inline">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="tab" value="strata">
<input type="hidden" name="family" value="{{$fam.Name}}">
<input type="hidden" name="size" value="{{.Size}}">
<button type="submit">{{t "strata.fetch"}}</button></form>{{end}}</td>
<button type="submit">{{t "strata.fetch"}}</button></form>{{end}}{{end}}</td>
</tr>
{{end}}
</table>
@@ -493,7 +493,7 @@ document.querySelector("table.explore").addEventListener("submit", async functio
{{if eq .Tab "fetch"}}
<p class="note">{{t "mirror.fetch.intro"}}</p>
<form method="post" action="/admin/mirror/fetch">
{{if .CSRF}}<form method="post" action="/admin/mirror/fetch">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="hidden" name="tab" value="fetch">
<label>{{t "mirror.fetch.source"}}
@@ -508,7 +508,7 @@ document.querySelector("table.explore").addEventListener("submit", async functio
<label class="inline-check"><input type="checkbox" name="force" value="1">
{{t "mirror.fetch.force"}} <span class="opt">{{t "mirror.fetch.force_hint"}}</span></label>
<button type="submit">{{t "mirror.fetch.start"}}</button>
</form>
</form>{{end}}
<p class="note">{{t "mirror.fetch.note"}}</p>
{{end}}
@@ -552,7 +552,7 @@ document.querySelector("table.explore").addEventListener("submit", async functio
<span class="clip-inline" title="{{.Verdict.Reason}}">{{.Verdict.Reason}}</span>
{{else}}
{{if .Verdict.Architecture}}<span class="opt" title="{{.Verdict.Reason}}">{{.Verdict.Architecture}}{{if .Verdict.Checked}} ✓{{end}}</span>{{end}}
<form method="post" action="/admin/mirror/fetch" class="inline">
{{if $.CSRF}}<form method="post" action="/admin/mirror/fetch" class="inline">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="tab" value="explore">
{{range .Sources}}<input type="hidden" name="source" value="{{.URL}}">
@@ -561,7 +561,7 @@ document.querySelector("table.explore").addEventListener("submit", async functio
<input type="text" name="model" value="{{.Model}}" size="24">
<input type="text" name="version" value="{{.Version}}" size="10">
<button type="submit">{{t "card.fetch"}}</button>
</form>
</form>{{end}}
{{end}}
</td>
</tr>{{end}}
@@ -628,17 +628,17 @@ document.querySelector("table.explore").addEventListener("submit", async functio
<td><code>{{.Name}}</code></td>
<td class="clip" title="{{.Source}}">{{.Source}}</td>
<td class="num">{{.EffectiveScale}}</td>
<td class="actions"><form method="post" action="/admin/mirror/adapters" class="inline">
<td class="actions">{{if $csrf}}<form method="post" action="/admin/mirror/adapters" class="inline">
<input type="hidden" name="csrf" value="{{$csrf}}">
<input type="hidden" name="model" value="{{$mname}}">
<input type="hidden" name="open" value="{{$row.Open}}">
<input type="hidden" name="op" value="remove">
<input type="hidden" name="name" value="{{$a.Name}}">
<button type="submit" class="link danger">{{t "mirror.remove"}}</button></form></td>
<button type="submit" class="link danger">{{t "mirror.remove"}}</button></form>{{end}}</td>
</tr>{{end}}</table>
{{else}}<p class="note">{{t "mirror.adapters.none"}}</p>{{end}}
{{if .AdapterChoices}}{{$m := .}}
<form method="post" action="/admin/mirror/adapters" class="adapter-form">
{{if $csrf}}<form method="post" action="/admin/mirror/adapters" class="adapter-form">
<input type="hidden" name="csrf" value="{{$csrf}}">
<input type="hidden" name="model" value="{{.Name}}">
<input type="hidden" name="open" value="{{$row.Open}}">
@@ -651,14 +651,14 @@ document.querySelector("table.explore").addEventListener("submit", async functio
<label>{{t "mirror.adapters.scale"}} <input type="text" name="scale" value="1" size="4"></label>
<button type="submit">{{t "mirror.adapters.attach"}}</button>
</div>
</form>{{else}}<p class="note">{{t "mirror.adapters.no_choice"}}</p>{{end}}
</form>{{end}}{{else}}<p class="note">{{t "mirror.adapters.no_choice"}}</p>{{end}}
</div>
{{end}}
{{if .Declared}}
<p class="note">{{t "mirror.detail.declared"}}</p>
{{else}}
<form method="post" action="/admin/mirror/replicas" class="replicas-form">
{{if $csrf}}<form method="post" action="/admin/mirror/replicas" class="replicas-form">
<input type="hidden" name="csrf" value="{{$csrf}}">
<input type="hidden" name="model" value="{{.Name}}">
<input type="hidden" name="open" value="{{$row.Open}}">
@@ -688,7 +688,7 @@ document.querySelector("table.explore").addEventListener("submit", async functio
<button type="submit">{{t "common.save"}}</button>
</div>
<p class="note">{{if .Split}}{{t "mirror.split.scaling_note"}} {{end}}{{if .MostNow}}{{if and .Headroom .Headroom.ByPreempting}}{{t "mirror.form.most_now_preempting" .MostNow}}{{else}}{{t "mirror.form.most_now" .MostNow}}{{end}} {{end}}{{t "mirror.form.note"}}</p>
</form>
</form>{{end}}
{{template "mirror_model_settings" dict "M" . "Row" $row "CSRF" $csrf}}
{{end}}
</section>
@@ -703,7 +703,7 @@ document.querySelector("table.explore").addEventListener("submit", async functio
<details class="model-settings" open>
<summary><h4>{{t "mirror.settings.heading"}}</h4></summary>
<form method="post" action="/admin/mirror/context" class="settings-form">
{{if $csrf}}<form method="post" action="/admin/mirror/context" class="settings-form">
<input type="hidden" name="csrf" value="{{$csrf}}">
<input type="hidden" name="model" value="{{$m.Name}}">
<input type="hidden" name="open" value="{{$row.Open}}">
@@ -713,10 +713,10 @@ document.querySelector("table.explore").addEventListener("submit", async functio
<button type="submit">{{t "mirror.settings.context"}}</button>
</div>
<p class="note">{{t "mirror.settings.context_note"}}</p>
</form>
</form>{{end}}
{{if $m.GPUProfile}}
<form method="post" action="/admin/mirror/gpus" class="settings-form">
{{if $csrf}}<form method="post" action="/admin/mirror/gpus" class="settings-form">
<input type="hidden" name="csrf" value="{{$csrf}}">
<input type="hidden" name="model" value="{{$m.Name}}">
<input type="hidden" name="profile" value="{{$m.GPUProfile}}">
@@ -726,11 +726,11 @@ document.querySelector("table.explore").addEventListener("submit", async functio
<button type="submit">{{t "mirror.settings.gpus_apply"}}</button>
</div>
<p class="note">{{t "mirror.settings.gpus_note"}}</p>
</form>
</form>{{end}}
{{end}}
{{if or (gt (len $m.Profiles) 1) $m.CanCPU}}
<form method="post" action="/admin/mirror/split" class="settings-form">
{{if $csrf}}<form method="post" action="/admin/mirror/split" class="settings-form">
<input type="hidden" name="csrf" value="{{$csrf}}">
<input type="hidden" name="model" value="{{$m.Name}}">
<input type="hidden" name="open" value="{{$row.Open}}">
@@ -746,29 +746,29 @@ document.querySelector("table.explore").addEventListener("submit", async functio
</div>
{{if and $m.CanCPU $m.CPUSpeed}}<p class="note{{if lt $m.CPUSpeed 5.0}} error{{end}}">{{t "mirror.split.cpu_speed" (num $m.CPUSpeed 1) $m.CPUSpeedNode}}</p>{{end}}
<p class="note">{{t "mirror.split.note"}}</p>
</form>
</form>{{end}}
{{end}}
<div class="settings-actions">
<form method="post" action="/admin/mirror/state" class="inline">
{{if $csrf}}<form method="post" action="/admin/mirror/state" class="inline">
<input type="hidden" name="csrf" value="{{$csrf}}">
<input type="hidden" name="model" value="{{$m.Name}}">
<input type="hidden" name="open" value="{{$row.Open}}">
{{if $m.Stopped}}<button type="submit" name="action" value="start">{{t "mirror.settings.start"}}</button>
{{else}}<button type="submit" name="action" value="stop" data-confirm="{{t "mirror.settings.stop_confirm" $m.Name}}">{{t "mirror.settings.stop"}}</button>
<button type="submit" name="action" value="restart" data-confirm="{{t "mirror.settings.restart_confirm" $m.Name}}">{{t "mirror.settings.restart"}}</button>{{end}}
</form>
<form method="post" action="/admin/mirror/unserve" class="inline" data-confirm="{{t "mirror.settings.unserve_confirm" $m.Name}}">
</form>{{end}}
{{if $csrf}}<form method="post" action="/admin/mirror/unserve" class="inline" data-confirm="{{t "mirror.settings.unserve_confirm" $m.Name}}">
<input type="hidden" name="csrf" value="{{$csrf}}">
<input type="hidden" name="model" value="{{$m.Name}}">
<button type="submit" class="link danger">{{t "mirror.settings.unserve"}}</button>
</form>
<form method="post" action="/admin/mirror/unserve" class="inline" data-confirm="{{t "mirror.settings.purge_confirm" $m.Name}}">
</form>{{end}}
{{if $csrf}}<form method="post" action="/admin/mirror/unserve" class="inline" data-confirm="{{t "mirror.settings.purge_confirm" $m.Name}}">
<input type="hidden" name="csrf" value="{{$csrf}}">
<input type="hidden" name="model" value="{{$m.Name}}">
<input type="hidden" name="purge" value="yes">
<button type="submit" class="link danger">{{t "mirror.settings.purge"}}</button>
</form>
</form>{{end}}
</div>
</details>
{{end}}
+7 -6
View File
@@ -8,6 +8,7 @@
<title>{{t "people.title"}} · llmgw</title><link rel="stylesheet" href="{{stylesheet}}">
<meta name="htmx-config" content='{"scrollIntoViewOnBoost":false,"defaultFocusScroll":false,"allowEval":false}'>
<script src="/_static/htmx-2.0.11.min.js" defer></script>
<script defer src="{{asset "pages.js"}}"></script>
</head><body><div class="wrap">
<header>
<h1>{{t "people.title"}}</h1>
@@ -94,17 +95,17 @@
{{if .Member}}<dt>{{t "people.each_member"}}</dt><dd>{{.Member}}</dd>{{end}}
</dl>
{{if .Shadowed}}<p class="q-sub q-warn">{{.Shadowed}}</p>{{end}}
<details class="edit"><summary>{{if .HasAny}}{{t "people.change_limits"}}{{else}}{{t "people.set_limits"}}{{end}}</summary>
{{if $d.CSRF}}<details class="edit"><summary>{{if .HasAny}}{{t "people.change_limits"}}{{else}}{{t "people.set_limits"}}{{end}}</summary>
{{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"))}}
</details>
<details class="edit"><summary>{{t "people.limit_each_member"}}</summary>
</details>{{end}}
{{if $d.CSRF}}<details class="edit"><summary>{{t "people.limit_each_member"}}</summary>
{{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"))}}
</details>
</details>{{end}}
{{end}}
{{with .Person}}
<dl class="pd-limits">
@@ -114,12 +115,12 @@
<dt>{{t "people.spent" $d.QuotaWindow}}</dt><dd><span class="q-spend">{{.Spend}}</span>{{if .HasPct}} <span class="q-meter{{if ge .Percent 90}} hot{{end}}"><i style="width:{{pct .Percent 100}}%"></i></span> <span class="q-pct">{{t "quotas.pct" .Percent}}</span>{{end}}</dd>
<dt>{{t "people.models"}}</dt><dd>{{if .Models}}{{th "people.models_only" .Models}}{{else}}{{t "people.models_every"}}{{end}}</dd>
</dl>
<details class="edit"><summary>{{if .HasAny}}{{t "people.change_limits"}}{{else}}{{t "people.set_limits"}}{{end}}</summary>
{{if $d.CSRF}}<details class="edit"><summary>{{if .HasAny}}{{t "people.change_limits"}}{{else}}{{t "people.set_limits"}}{{end}}</summary>
{{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"))}}
</details>
</details>{{end}}
{{end}}
{{end}}
+25 -24
View File
@@ -41,12 +41,12 @@
{{if .HasPct}}<span class="q-meter{{if ge .Percent 90}} hot{{end}}" title="{{t "quotas.pct_of_budget" .Percent}}"><i style="width:{{pct .Percent 100}}%"></i></span>
<span class="q-pct">{{t "quotas.pct" .Percent}}</span>{{end}}
</td>
<td class="q-act">
<td class="q-act">{{if $.CSRF}}
{{if .Editing}}<a href="/admin/quotas">{{t "quotas.close"}}</a>
{{else}}<a href="/admin/quotas?edit={{.Scope}}:{{.ID}}">{{if .HasAny}}{{t "quotas.change"}}{{else}}{{t "quotas.set"}}{{end}}</a>{{end}}
{{if .MemberEditing}}<a href="/admin/quotas">{{t "quotas.close"}}</a>
{{else}}<a href="/admin/quotas?edit={{.Scope}}-member:{{.ID}}" title="{{t "quotas.per_member_title"}}">{{t "quotas.per_member"}}</a>{{end}}
</td>
{{end}}</td>
</tr>
{{if .MemberEditing}}
<tr class="editing"><td colspan="6">
@@ -58,7 +58,7 @@
{{if .Editing}}
<tr class="editing"><td colspan="6">
<form method="post" action="/admin/quotas" class="q-form">
{{if $.CSRF}}<form method="post" action="/admin/quotas" class="q-form">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="scope" value="{{.Scope}}">
<input type="hidden" name="id" value="{{.ID}}">
@@ -84,7 +84,7 @@
<button type="submit" name="clear" value="1" class="link danger"
{{if not .HasAny}}disabled{{end}}>{{t "quotas.form.remove_all"}}</button>
</div>
</form>
</form>{{end}}
</td></tr>
{{end}}
{{end}}
@@ -110,8 +110,8 @@
<td class="q-spent"><span class="q-spend">{{.Spend}}</span>
{{if .HasPct}}<span class="q-meter{{if ge .Percent 90}} hot{{end}}"><i style="width:{{pct .Percent 100}}%"></i></span>
<span class="q-pct">{{t "quotas.pct" .Percent}}</span>{{end}}</td>
<td class="q-act">{{if .Editing}}<a href="/admin/quotas">{{t "quotas.close"}}</a>
{{else}}<a href="/admin/quotas?edit=user:{{.ID}}">{{if .HasAny}}{{t "quotas.change"}}{{else}}{{t "quotas.set"}}{{end}}</a>{{end}}</td>
<td class="q-act">{{if $.CSRF}}{{if .Editing}}<a href="/admin/quotas">{{t "quotas.close"}}</a>
{{else}}<a href="/admin/quotas?edit=user:{{.ID}}">{{if .HasAny}}{{t "quotas.change"}}{{else}}{{t "quotas.set"}}{{end}}</a>{{end}}{{end}}</td>
</tr>
{{if .Editing}}
<tr class="editing"><td colspan="6">
@@ -138,38 +138,39 @@
<td class="num">{{or .Memory "—"}}</td>
<td class="note">{{.UpdatedBy}}</td>
<td class="q-act"><a href="/admin/quotas?cap={{.Scope}}#capacity">{{t "quotas.change"}}</a>
<form method="post" action="/admin/quotas/capacity" class="inline">
{{if $.CSRF}}<form method="post" action="/admin/quotas/capacity" class="inline">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="scope" value="{{.Scope}}">
<button type="submit" name="clear" value="1" class="link danger">{{t "capacity.remove"}}</button>
</form></td>
</form>{{end}}</td>
</tr>{{end}}
</table>
<p class="q-legend">{{t "capacity.legend"}}</p>
{{else}}<p class="empty">{{t "capacity.empty"}}</p>{{end}}
{{if .Scopes}}
<form method="post" action="/admin/quotas/capacity" class="q-form capacity-form">
{{if .CSRF}}<form method="post" action="/admin/quotas/capacity" class="q-form capacity-form">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<p class="q-form-title">{{if .CapForm.Scope}}{{th "capacity.form.title_change"}}{{else}}{{th "capacity.form.title_set"}}{{end}}</p>
<div class="field-row">
<label>{{t "quotas.col.where"}}
<select name="scope" required>
<option value="">{{t "capacity.form.choose"}}</option>
{{range .Scopes}}<option value="{{.Key}}"{{if eq .Key $.CapForm.Scope}} selected{{end}}>{{.Name}}</option>{{end}}
</select></label>
<div class="field-row">
<label>{{t "capacity.form.g_gpus"}} <input type="text" name="gpus" value="{{.CapForm.GPUs}}" placeholder="—"></label>
<label>{{t "capacity.col.cores"}} <input type="text" name="cores" value="{{.CapForm.Cores}}" placeholder="—"></label>
<label>{{t "capacity.col.memory"}} <input type="text" name="memory" value="{{.CapForm.Memory}}" placeholder="—"></label>
</div>
<div class="field-row">
<label>{{t "capacity.form.l_gpus"}} <input type="text" name="limit_gpus" value="{{.CapForm.LGPUs}}" placeholder="—"></label>
<label>{{t "capacity.col.cores"}} <input type="text" name="limit_cores" value="{{.CapForm.LCores}}" placeholder="—"></label>
<label>{{t "capacity.col.memory"}} <input type="text" name="limit_memory" value="{{.CapForm.LMem}}" placeholder="—"></label>
<label class="short">{{t "capacity.form.g_gpus"}} <input type="text" name="gpus" value="{{.CapForm.GPUs}}" placeholder="—"></label>
<label class="short">{{t "capacity.col.cores"}} <input type="text" name="cores" value="{{.CapForm.Cores}}" placeholder="—"></label>
<label class="short">{{t "capacity.col.memory"}} <input type="text" name="memory" value="{{.CapForm.Memory}}" placeholder="—"></label>
<span class="field-gap"></span>
<label class="short">{{t "capacity.form.l_gpus"}} <input type="text" name="limit_gpus" value="{{.CapForm.LGPUs}}" placeholder="—"></label>
<label class="short">{{t "capacity.col.cores"}} <input type="text" name="limit_cores" value="{{.CapForm.LCores}}" placeholder="—"></label>
<label class="short">{{t "capacity.col.memory"}} <input type="text" name="limit_memory" value="{{.CapForm.LMem}}" placeholder="—"></label>
</div>
<div class="q-form-actions"><button type="submit">{{t "common.save"}}</button>
{{if .CapForm.Scope}}<a href="/admin/quotas#capacity">{{t "common.cancel"}}</a>{{end}}</div>
</form>
</form>{{end}}
{{else}}<p class="empty">{{th "capacity.empty_tree"}}</p>{{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"}}
<form method="post" action="/admin/quotas" class="q-form">
{{if .CSRF}}<form method="post" action="/admin/quotas" class="q-form">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="hidden" name="scope" value="{{.Scope}}">
<input type="hidden" name="id" value="{{.ID}}">
@@ -193,24 +194,24 @@
{{with .Sel}}<input type="hidden" name="tab" value="people"><input type="hidden" name="sel" value="{{.}}">{{end}}
<p class="q-form-title">{{th "quotas.qform.title" .Title .Note}}</p>
<div class="field-row">
<label>{{t "quotas.form.rpm"}} <input type="text" name="rpm" value="{{.F.RPM}}" placeholder="{{t "common.none"}}"></label>
<label>{{t "quotas.form.tpm"}} <input type="text" name="tpm" value="{{.F.TPM}}" placeholder="{{t "common.none"}}"></label>
<label>{{t "quotas.col.budget"}} <input type="text" name="budget" value="{{.F.Budget}}" placeholder="{{t "common.none"}}"></label>
<label class="short">{{t "quotas.form.rpm"}} <input type="text" name="rpm" value="{{.F.RPM}}" placeholder="{{t "common.none"}}"></label>
<label class="short">{{t "quotas.form.tpm"}} <input type="text" name="tpm" value="{{.F.TPM}}" placeholder="{{t "common.none"}}"></label>
<label class="short">{{t "quotas.col.budget"}} <input type="text" name="budget" value="{{.F.Budget}}" placeholder="{{t "common.none"}}"></label>
<label class="q-period">{{t "quotas.form.per"}}
<select name="period">
<option value="month"{{if eq .F.Period "month"}} selected{{end}}>{{t "quotas.per.month"}}</option>
<option value="day"{{if eq .F.Period "day"}} selected{{end}}>{{t "quotas.per.day"}}</option>
</select></label>
<label class="grow">{{t "quotas.form.models"}} <span class="opt">{{t "quotas.form.models_hint"}}</span>
<input type="text" name="models" value="{{.F.Models}}" placeholder="{{t "quotas.form.every_model"}}"></label>
</div>
<label>{{t "quotas.form.models"}} <span class="opt">{{t "quotas.form.models_hint"}}</span>
<input type="text" name="models" value="{{.F.Models}}" placeholder="{{t "quotas.form.every_model"}}"></label>
<div class="q-form-actions">
<button type="submit">{{t "common.save"}}</button>
{{if not .Sel}}<a href="/admin/quotas">{{t "common.cancel"}}</a>{{end}}
<button type="submit" name="clear" value="1" class="link danger"
{{if not .F.HasAny}}disabled{{end}}>{{t "quotas.qform.remove"}}</button>
</div>
</form>
</form>{{end}}
{{end}}
{{define "quota-value"}}{{if .Own}}{{.Own}}{{else if .Up}}<span class="q-up" title="{{t "quotas.set_on" .From}}">{{.Up}} ↑</span>{{else}}<span class="q-none">—</span>{{end}}{{end}}
+48 -48
View File
@@ -10,21 +10,21 @@
{{range .Pending}}<tr>
<td>{{.Email}}</td><td>{{.Name}}</td><td>{{ago .CreatedAt}}</td>
<td>
<form method="post" action="/admin/users/{{.ID}}/approve" class="inline">
{{if $.Edit}}<form method="post" action="/admin/users/{{.ID}}/approve" class="inline">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<select name="project" aria-label="{{t "users.project_for" .Email}}">
<option value="">{{t "users.no_project"}}</option>
{{range $.Projects}}<option value="{{.ID}}">{{.Path}}</option>{{end}}
</select>
<button type="submit">{{t "users.approve"}}</button>
</form>
</form>{{end}}
</td>
<td>
<form method="post" action="/admin/users/{{.ID}}/delete" class="inline"
{{if $.Edit}}<form method="post" action="/admin/users/{{.ID}}/delete" class="inline"
hx-confirm="{{t "users.reject_confirm" .Email}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button type="submit" class="link danger">{{t "users.reject"}}</button>
</form>
</form>{{end}}
</td>
</tr>{{end}}</table>
{{end}}
@@ -42,19 +42,19 @@
<strong class="node-name"><a href="/admin/users?sel=org:{{$org.ID}}" class="sel-link{{if eq $.Sel (printf "org:%s" $org.ID)}} on{{end}}">{{$org.Name}}</a></strong>
<span class="node-meta">{{tn "users.n_teams" (len $org.Teams)}}</span>
{{template "scope-members" (dict "Kind" "org" "ID" $org.ID "Name" $org.Name "D" $)}}
<details class="edit"><summary>{{t "users.rename"}}</summary>
{{if $.Edit}}<details class="edit"><summary>{{t "users.rename"}}</summary>
<form method="post" action="/admin/tenancy/org/{{$org.ID}}/rename">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="text" name="name" value="{{$org.Name}}" required aria-label="{{t "users.new_name"}}">
<button type="submit">{{t "common.save"}}</button>
</form>
</details>
</details>{{end}}
{{if not $org.Teams}}
<form method="post" action="/admin/tenancy/org/{{$org.ID}}/delete" class="inline"
{{if $.Edit}}<form method="post" action="/admin/tenancy/org/{{$org.ID}}/delete" class="inline"
hx-confirm="{{t "users.remove_org_confirm" $org.Name}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button type="submit" class="link danger">{{t "users.remove"}}</button>
</form>
</form>{{end}}
{{end}}
</div>
@@ -64,15 +64,15 @@
<span class="node-name"><a href="/admin/users?sel=team:{{$team.ID}}" class="sel-link{{if eq $.Sel (printf "team:%s" $team.ID)}} on{{end}}">{{$team.Name}}</a></span>
<span class="node-meta">{{tn "users.n_projects" (len $team.Projects)}}</span>
{{template "scope-members" (dict "Kind" "team" "ID" $team.ID "Name" $team.Name "D" $)}}
<details class="edit"><summary>{{t "users.rename"}}</summary>
{{if $.Edit}}<details class="edit"><summary>{{t "users.rename"}}</summary>
<form method="post" action="/admin/tenancy/team/{{$team.ID}}/rename">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="text" name="name" value="{{$team.Name}}" required aria-label="{{t "users.new_name"}}">
<button type="submit">{{t "common.save"}}</button>
</form>
</details>
</details>{{end}}
{{if gt (len $.Orgs) 1}}
<details class="edit"><summary>{{t "users.move"}}</summary>
{{if $.Edit}}<details class="edit"><summary>{{t "users.move"}}</summary>
<form method="post" action="/admin/tenancy/team/{{$team.ID}}/move">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<select name="to" aria-label="{{t "users.move_team_to" $team.Name}}">
@@ -80,14 +80,14 @@
</select>
<button type="submit">{{t "users.move_button"}}</button>
</form>
</details>
</details>{{end}}
{{end}}
{{if not $team.Projects}}
<form method="post" action="/admin/tenancy/team/{{$team.ID}}/delete" class="inline"
{{if $.Edit}}<form method="post" action="/admin/tenancy/team/{{$team.ID}}/delete" class="inline"
hx-confirm="{{t "users.remove_team_confirm" $team.Name}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button type="submit" class="link danger">{{t "users.remove"}}</button>
</form>
</form>{{end}}
{{end}}
</div>
@@ -101,16 +101,16 @@
</summary>
<div class="project-body">
<div class="node-head">
<details class="edit"><summary>{{t "users.rename"}}</summary>
{{if $.Edit}}<details class="edit"><summary>{{t "users.rename"}}</summary>
<form method="post" action="/admin/tenancy/project/{{$p.ID}}/rename">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="open" value="{{$p.ID}}">
<input type="text" name="name" value="{{$p.Name}}" required aria-label="{{t "users.new_name"}}">
<button type="submit">{{t "common.save"}}</button>
</form>
</details>
</details>{{end}}
{{if gt (len $.Teams) 1}}
<details class="edit"><summary>{{t "users.move_project"}}</summary>
{{if $.Edit}}<details class="edit"><summary>{{t "users.move_project"}}</summary>
<form method="post" action="/admin/tenancy/project/{{$p.ID}}/move">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="open" value="{{$p.ID}}">
@@ -119,14 +119,14 @@
</select>
<button type="submit">{{t "users.move_button"}}</button>
</form>
</details>
</details>{{end}}
{{end}}
{{if and (eq $p.Users 0) (eq $p.Keys 0)}}
<form method="post" action="/admin/tenancy/project/{{$p.ID}}/delete" class="inline"
{{if $.Edit}}<form method="post" action="/admin/tenancy/project/{{$p.ID}}/delete" class="inline"
hx-confirm="{{t "users.remove_project_confirm" $p.Name}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button type="submit" class="link danger">{{t "users.remove"}}</button>
</form>
</form>{{end}}
{{else}}<span class="note">{{t "users.not_removable"}}</span>{{end}}
</div>
@@ -145,12 +145,12 @@
<td class="state state-{{.State}}">{{t (print "users.key_state." .State)}}</td>
<td>{{ago .LastUsedAt}}</td>
<td>{{if eq .State "active"}}
<form method="post" action="/admin/keys/{{.ID}}/revoke" class="inline"
{{if $.Edit}}<form method="post" action="/admin/keys/{{.ID}}/revoke" class="inline"
hx-confirm="{{t "users.revoke_confirm" .Name}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="open" value="{{$p.ID}}">
<button type="submit" class="link danger">{{t "users.revoke"}}</button>
</form>{{end}}</td>
</form>{{end}}{{end}}</td>
</tr>{{end}}
</table>
{{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. */}}
<details class="add-inline">
{{if $.Edit}}<details class="add-inline">
<summary>{{t "users.add_project"}}</summary>
<form method="post" action="/admin/tenancy/project" class="add">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
@@ -168,11 +168,11 @@
<input type="text" name="name" required placeholder="{{t "users.new_project_in" $team.Name}}" aria-label="{{t "users.new_project_in_label" $team.Name}}">
<button type="submit">{{t "common.add"}}</button>
</form>
</details>
</details>{{end}}
</div>
{{end}}
<details class="add-inline team-add">
{{if $.Edit}}<details class="add-inline team-add">
<summary>{{t "users.add_team"}}</summary>
<form method="post" action="/admin/tenancy/team" class="add">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
@@ -180,15 +180,15 @@
<input type="text" name="name" required placeholder="{{t "users.new_team_in" $org.Name}}" aria-label="{{t "users.new_team_in_label" $org.Name}}">
<button type="submit">{{t "common.add"}}</button>
</form>
</details>
</details>{{end}}
</section>
{{end}}
<form method="post" action="/admin/tenancy/org" class="add org-add">
{{if $.Edit}}<form method="post" action="/admin/tenancy/org" class="add org-add">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="text" name="name" required placeholder="{{t "users.new_org"}}" aria-label="{{t "users.new_org_label"}}">
<button type="submit">{{t "users.add_org"}}</button>
</form>
</form>{{end}}
<h2 id="accounts">{{t "users.accounts"}}</h2>
{{if .Accounts}}
@@ -211,24 +211,24 @@
<summary aria-label="{{t "users.more_for" $u.Email}}" title="{{t "users.more"}}">⋯</summary>
<div class="menu">
{{if eq (printf "%s" $u.State) "active"}}
<form method="post" action="/admin/users/{{$u.ID}}/suspend" class="inline">
{{if $d.Edit}}<form method="post" action="/admin/users/{{$u.ID}}/suspend" class="inline">
<input type="hidden" name="csrf" value="{{$d.CSRF}}">
<input type="hidden" name="open" value="{{.Open}}">
<button type="submit" class="link danger">{{t "users.suspend"}}</button>
</form>
</form>{{end}}
{{else}}
<form method="post" action="/admin/users/{{$u.ID}}/reinstate" class="inline">
{{if $d.Edit}}<form method="post" action="/admin/users/{{$u.ID}}/reinstate" class="inline">
<input type="hidden" name="csrf" value="{{$d.CSRF}}">
<input type="hidden" name="open" value="{{.Open}}">
<button type="submit" class="link">{{t "users.reinstate"}}</button>
</form>
</form>{{end}}
{{end}}
<form method="post" action="/admin/users/{{$u.ID}}/delete" class="inline"
{{if $d.Edit}}<form method="post" action="/admin/users/{{$u.ID}}/delete" class="inline"
hx-confirm="{{t "users.remove_confirm" $u.Email}}">
<input type="hidden" name="csrf" value="{{$d.CSRF}}">
<input type="hidden" name="open" value="{{.Open}}">
<button type="submit" class="link danger">{{t "users.remove"}}</button>
</form>
</form>{{end}}
</div>
</details>
{{else}}<span class="note">{{t "users.you"}}</span>{{end}}
@@ -241,22 +241,22 @@
<td><a href="/admin/users?sel=user:{{$u.ID}}" class="sel-link{{if eq $d.Sel (printf "user:%s" $u.ID)}} on{{end}}">{{$u.Email}}</a>{{if $u.IsAdmin}} <span class="tag">{{t "users.tag.admin"}}</span>{{end}}</td>
<td class="state state-{{$u.State}}">{{t (print "users.state." $u.State)}}</td>
<td class="where">
{{range $i, $m := $u.Memberships}}{{if lt $i 2}}{{with $m}}<span class="chip-m">{{.Path}}{{if eq .Role "manager"}} <b class="chip-role">{{t "users.role.manager"}}</b>{{end}}
{{if eq (printf "%s" .Scope) "org"}}
{{range $i, $m := $u.Memberships}}{{if lt $i 2}}{{with $m}}<span class="chip-m">{{.Path}}
{{if and (eq (printf "%s" .Scope) "org") $d.Edit}}
<form method="post" action="/admin/users/{{$u.ID}}/role" class="inline">
<input type="hidden" name="csrf" value="{{$d.CSRF}}">
<input type="hidden" name="scope" value="org:{{.ScopeID}}">
<input type="hidden" name="role" value="{{if eq .Role "manager"}}member{{else}}manager{{end}}">
<button type="submit" class="chip-x" title="{{if eq .Role "manager"}}{{t "users.stop_managing_of" .Path}}{{else}}{{t "users.make_manager_of" .Path}}{{end}}"
aria-label="{{if eq .Role "manager"}}{{t "users.stop_managing"}}{{else}}{{t "users.make_manager"}}{{end}}">{{if eq .Role "manager"}}☆{{else}}★{{end}}</button>
<select name="role" class="chip-sel" data-autosubmit aria-label="{{t "users.role_in" .Path}}" title="{{t "users.role_note"}}">
{{$r := .Role}}{{range $role := roles}}<option value="{{$role}}"{{if eq $role $r}} selected{{end}}>{{t (print "users.role." $role)}}</option>{{end}}
</select>
</form>
{{end}}
<form method="post" action="/admin/users/{{$u.ID}}/leave" class="inline"
{{else if ne .Role "member"}} <b class="chip-role">{{t (print "users.role." .Role)}}</b>{{end}}
{{if $d.Edit}}<form method="post" action="/admin/users/{{$u.ID}}/leave" class="inline"
hx-confirm="{{t "users.take_out_confirm" $u.Email .Path}}">
<input type="hidden" name="csrf" value="{{$d.CSRF}}">
<input type="hidden" name="scope" value="{{.Scope}}:{{.ScopeID}}">
<button type="submit" class="chip-x" aria-label="{{t "users.take_out_of" .Path}}" title="{{t "users.take_out_of" .Path}}">×</button>
</form></span>{{end}}{{end}}
</form>{{end}}</span>{{end}}{{end}}
{{else}}<span class="opt">{{t "users.on_own"}}</span>{{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 @@
</td>
<td>{{ago $u.LastLoginAt}}</td>
<td>
<form method="post" action="/admin/users/{{$u.ID}}/join" class="inline add-to">
{{if $d.Edit}}<form method="post" action="/admin/users/{{$u.ID}}/join" class="inline add-to">
<input type="hidden" name="csrf" value="{{$d.CSRF}}">
<select name="scope" aria-label="{{t "users.add_to_label" $u.Email}}" required>
<option value="">{{t "users.choose"}}</option>
{{range $d.Scopes}}<option value="{{.Value}}">{{.Label}}</option>{{end}}
</select>
<button type="submit" class="small">{{t "common.add"}}</button>
</form>
</form>{{end}}
</td>
<td class="actions">{{template "state-actions" (dict "U" $u "D" $d "Open" "")}}</td>
</tr>{{end}}
@@ -285,19 +285,19 @@
<td class="state state-{{$u.State}}">{{t (print "users.state." $u.State)}}</td>
<td>{{ago $u.LastLoginAt}}</td>
<td class="actions">
<form method="post" action="/admin/users/{{$u.ID}}/leave" class="inline"
{{if $d.Edit}}<form method="post" action="/admin/users/{{$u.ID}}/leave" class="inline"
hx-confirm="{{t "users.take_out_here_confirm" $u.Email}}">
<input type="hidden" name="csrf" value="{{$d.CSRF}}">
<input type="hidden" name="scope" value="{{.Scope}}">
<input type="hidden" name="open" value="{{.Open}}">
<button type="submit" class="link danger">{{t "users.take_out"}}</button>
</form>
</form>{{end}}
</td>
</tr>{{end}}
{{/* Adding a person to a scope, from the scope. */}}
{{define "add-member"}}{{$d := .D}}
<form method="post" action="/admin/tenancy/{{.Kind}}/{{.ID}}/add" class="add">
{{if $d.Edit}}<form method="post" action="/admin/tenancy/{{.Kind}}/{{.ID}}/add" class="add">
<input type="hidden" name="csrf" value="{{$d.CSRF}}">
<input type="hidden" name="open" value="{{.Open}}">
<select name="user" aria-label="{{t "users.person_to_add"}}" required>
@@ -305,7 +305,7 @@
{{range $d.Accounts}}<option value="{{.ID}}">{{.Email}}</option>{{end}}
</select>
<button type="submit">{{t "users.add_member"}}</button>
</form>{{end}}
</form>{{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}}
+3 -3
View File
@@ -109,7 +109,7 @@
administrator, on the page reached through a session. */}}
{{if $.AnyGPUs}}<td class="clip">{{if .GPUTotal}}{{$node := .}}
{{range .Provision}}{{if eq .Engine "vllm"}}<span class="note" title="{{.Detail}}">{{.Version}} {{with .Variant}}{{buildLabel .}} {{end}}{{t (print "dash.state." .State)}}</span>{{end}}{{end}}
{{if and $.Menu.CSRF .EngineBuilds}}
{{if and ($.Menu.May "/admin/nodes/x/engines") .EngineBuilds}}
<form method="post" action="/admin/nodes/{{.ID}}/engines" class="inline build-choice">
<input type="hidden" name="csrf" value="{{$.Menu.CSRF}}">
<input type="hidden" name="engine" value="vllm">
@@ -177,7 +177,7 @@ document.querySelectorAll("form.build-choice select").forEach(function (s) {
<td>{{or .Fits (t "dash.presets.fits_none")}}</td>
{{/* Why the leader does not add it itself is the control plane's, in English. */}}
<td{{if .Declined}} class="note"{{end}}>{{or .State "—"}}{{with .Blocked}} <span class="note">({{.}})</span>{{end}}</td>
<td>{{if and $.Menu.CSRF (not .InCatalog)}}
<td>{{if and ($.Menu.May "/admin/presets") (not .InCatalog)}}
<form method="post" action="/admin/presets/{{.Name}}" class="inline">
<input type="hidden" name="csrf" value="{{$.Menu.CSRF}}">
<button type="submit"{{if .AddsComfyUI}} title="{{t "dash.presets.adds_comfyui"}}"{{end}}>{{if .Declined}}{{t "dash.presets.add_again"}}{{else}}{{t "dash.presets.add"}}{{end}}</button>
@@ -185,7 +185,7 @@ document.querySelectorAll("form.build-choice select").forEach(function (s) {
</tr>
{{end}}</table>
<p>{{if .Auto}}{{t "dash.presets.auto_on"}}{{else}}{{t "dash.presets.auto_off"}}{{end}}
{{if $.Menu.CSRF}}
{{if $.Menu.May "/admin/presets"}}
<form method="post" action="/admin/presets/auto" class="inline">
<input type="hidden" name="csrf" value="{{$.Menu.CSRF}}">
<input type="hidden" name="on" value="{{if .Auto}}false{{else}}true{{end}}">
+80 -5
View File
@@ -2,6 +2,7 @@
<html lang="{{lang}}"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{.Org.Name}} · llmgw</title><link rel="stylesheet" href="{{stylesheet}}">
{{if .Edit}}<script defer src="{{asset "pages.js"}}"></script>{{end}}
</head><body><div class="wrap">
<header>
<h1>{{.Org.Name}}</h1>
@@ -17,6 +18,7 @@
{{end}}
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
{{if .Message}}<p class="ok">{{.Message}}</p>{{end}}
<h2>{{t "org.use"}}</h2>
<div class="filters">
@@ -78,14 +80,87 @@
<h2>{{t "org.members.title"}}</h2>
{{if .Members}}
<table class="rows compact">
<tr><th>{{t "org.col.person"}}</th><th>{{t "org.col.member_of"}}</th><th>{{t "org.col.state"}}</th></tr>
{{range .Members}}<tr>
<td>{{.Email}}</td>
<td>{{range .Places}}<span class="chip-m">{{.Where}}{{if eq .Role "manager"}} <b class="chip-role">{{t "org.role.manager"}}</b>{{end}}</span>{{end}}</td>
<td class="state state-{{.State}}">{{with .State}}{{t (print "org.state." .)}}{{end}}</td>
<tr><th>{{t "org.col.person"}}</th><th>{{t "org.col.member_of"}}</th>{{if .Edit}}<th>{{t "org.col.role"}}</th>{{end}}<th>{{t "org.col.state"}}</th></tr>
{{range $m := .Members}}<tr>
<td>{{$m.Email}}</td>
<td>{{range $m.Places}}<span class="chip-m">{{.Where}}{{if and (ne .Role "member") (not $.Edit)}} <b class="chip-role">{{t (print "users.role." .Role)}}</b>{{end}}
{{if $.Edit}}<form method="post" action="/org/{{$.Org.ID}}/members/{{$m.ID}}/leave" class="inline"
data-confirm="{{t "users.take_out_confirm" $m.Email .Where}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}"><input type="hidden" name="where" value="{{.Value}}">
<button type="submit" class="chip-x" title="{{t "users.take_out_of" .Where}}" aria-label="{{t "users.take_out_of" .Where}}">×</button>
</form>{{end}}</span>{{end}}</td>
{{if $.Edit}}<td>{{if and $m.OrgRole (eq $m.ID $.Me)}}<span class="chip-role">{{t (print "users.role." $m.OrgRole)}}</span>{{else if $m.OrgRole}}
<form method="post" action="/org/{{$.Org.ID}}/members/{{$m.ID}}/role" class="inline">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<select name="role" class="chip-sel" data-autosubmit aria-label="{{t "users.role_in" $.Org.Name}}" title="{{t "users.role_note"}}">
{{$r := $m.OrgRole}}{{range $role := roles}}<option value="{{$role}}"{{if eq $role $r}} selected{{end}}>{{t (print "users.role." $role)}}</option>{{end}}
</select>
</form>{{else}}<span class="opt">—</span>{{end}}</td>{{end}}
<td class="state state-{{$m.State}}">{{with $m.State}}{{t (print "org.state." .)}}{{end}}</td>
</tr>{{end}}
</table>
{{else}}<p class="empty">{{t "org.members.empty"}}</p>{{end}}
{{if .Edit}}
<form method="post" action="/org/{{.Org.ID}}/members" class="row">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="email" name="email" required placeholder="{{t "org.add.email"}}" aria-label="{{t "org.add.email"}}">
<select name="where" required aria-label="{{t "org.add.where"}}">
{{range .Places}}<option value="{{.Value}}">{{.Label}}</option>{{end}}
</select>
<button type="submit">{{t "org.add.button"}}</button>
</form>
<p class="note">{{t "org.add.note"}}</p>
<h2>{{t "org.tree.title"}}</h2>
{{if .Teams}}
<table class="rows compact">
<tr><th>{{t "org.col.team"}}</th><th>{{t "org.col.projects"}}</th><th></th></tr>
{{range $tm := .Teams}}<tr>
<td><details class="edit"><summary>{{$tm.Name}}</summary>
<form method="post" action="/org/{{$.Org.ID}}/team/{{$tm.ID}}/rename" class="row">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="text" name="name" value="{{$tm.Name}}" required aria-label="{{t "org.tree.rename"}}">
<button type="submit" class="small">{{t "org.tree.rename"}}</button>
</form></details></td>
<td>{{range $tm.Projects}}<span class="chip-m">{{.Name}}
<form method="post" action="/org/{{$.Org.ID}}/project/{{.ID}}/delete" class="inline" data-confirm="{{t "org.tree.remove_confirm" .Name}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button type="submit" class="chip-x" title="{{t "org.tree.remove" .Name}}" aria-label="{{t "org.tree.remove" .Name}}">×</button>
</form></span>{{end}}
<form method="post" action="/org/{{$.Org.ID}}/projects" class="inline add-to">
<input type="hidden" name="csrf" value="{{$.CSRF}}"><input type="hidden" name="team" value="{{$tm.ID}}">
<input type="text" name="name" required placeholder="{{t "org.tree.new_project"}}" aria-label="{{t "org.tree.new_project_in" $tm.Name}}">
<button type="submit" class="small">{{t "common.add"}}</button>
</form></td>
<td class="num"><form method="post" action="/org/{{$.Org.ID}}/team/{{$tm.ID}}/delete" class="inline" data-confirm="{{t "org.tree.remove_confirm" $tm.Name}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button type="submit" class="link danger">{{t "org.tree.remove_team"}}</button>
</form></td>
</tr>{{end}}
</table>
{{else}}<p class="empty">{{t "org.tree.empty"}}</p>{{end}}
<form method="post" action="/org/{{.Org.ID}}/teams" class="row">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="text" name="name" required placeholder="{{t "org.tree.new_team"}}" aria-label="{{t "org.tree.new_team"}}">
<button type="submit">{{t "org.tree.add_team"}}</button>
</form>
<p class="note">{{t "org.tree.note"}}</p>
<h2>{{t "org.keys.title"}}</h2>
{{if .Keys}}
<table class="rows compact">
<tr><th>{{t "org.keys.col.key"}}</th><th>{{t "org.col.project"}}</th><th>{{t "org.keys.col.owner"}}</th><th>{{t "org.keys.col.used"}}</th><th></th></tr>
{{range .Keys}}<tr>
<td><code>{{.Prefix}}…</code> {{.Name}}</td><td>{{.ProjectPath}}</td><td>{{or .OwnerEmail "—"}}</td>
<td>{{if .LastUsedAt.IsZero}}—{{else}}{{ago .LastUsedAt}}{{end}}</td>
<td class="num"><form method="post" action="/org/{{$.Org.ID}}/keys/{{.ID}}/revoke" class="inline" data-confirm="{{t "org.keys.revoke_confirm" .Prefix}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button type="submit" class="link danger">{{t "org.keys.revoke"}}</button>
</form></td>
</tr>{{end}}
</table>
{{else}}<p class="empty">{{t "org.keys.empty"}}</p>{{end}}
{{end}}
<footer><span>llmgw {{.Version}}</span><span>{{stamp .Now}}</span>{{template "langs"}}</footer>
</div></body></html>{{end}}
+2
View File
@@ -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,