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:
1 parent
2cb1afda55
commit
102ca9556f
37 files changed
+1204
-212
No files matched your search
+5
-4
@@ -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`
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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] == "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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ı"
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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&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}}
|
||||
@@ -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}}
|
||||
|
||||
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}">
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user