From 3104f4b706bcc89652d66958edf2c4b46e79537d Mon Sep 17 00:00:00 2001 From: Ilker Manap Date: Mon, 28 Sep 2026 16:26:39 +0200 Subject: [PATCH] Manage people and the tenancy tree, from the web and the CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing in the tree could be renamed, moved or removed, and an approved person stayed in the project they were first put in — from the web or the CLI. Now: - /admin/users is the tree, each project opening onto its people and keys, with approve, move, suspend, reinstate and remove for people, revoke for keys, and add, rename, move and remove at every level, plus a search over every account. Forms post and get the page back, so it works with no script; htmx 2.0.11 (0BSD, embedded, checked against npm's integrity hash) swaps only the changed part, keeping open projects open. - llmgw org tree/add/rename/move/remove, users move/delete, and keys list --all. - Projects, teams and organizations are named by id, path (Acme/platform/gateway) or unique name; users by id or email. With a single project --project can be left out, instead of defaulting to "default", which a tree built from the web does not have. Moving a person moves the keys they made; operator keys stay with the project. Only empty things are removed, and never a project that had a key, since usage history refers to it. Also: keys init no longer reparents an existing team or project; reinstating an account with no project is refused (a rejected signup used to come back active and fail at its first key); rejecting a signup removes it; keys limits --budget takes whole units like the quotas page. Checked in headless Chrome: a move from an open project updates the page in place, keeps the project open, and the search still filters. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 42 +++ docs/choosing-models.md | 2 +- docs/onboarding.md | 52 +++- internal/cli/keys.go | 88 ++++-- internal/cli/memory.go | 9 +- internal/cli/org.go | 334 ++++++++++++++++++++++ internal/cli/org_test.go | 124 +++++++++ internal/cli/resolve.go | 131 +++++++++ internal/cli/root.go | 1 + internal/cli/threads.go | 9 +- internal/cli/users.go | 136 +++++++-- internal/store/hierarchy.go | 336 ++++++++++++++++++++++ internal/store/hierarchy_test.go | 253 +++++++++++++++++ internal/store/sqlite.go | 20 +- internal/store/users.go | 12 + internal/web/admin_tenancy_test.go | 37 ++- internal/web/admin_users.go | 315 +++++++++++++++++---- internal/web/static/base.css | 40 +++ internal/web/static/htmx-2.0.11.min.js | 1 + internal/web/templates/admin_users.html | 354 ++++++++++++++++++------ internal/web/web.go | 2 + 21 files changed, 2081 insertions(+), 217 deletions(-) create mode 100644 internal/cli/org.go create mode 100644 internal/cli/org_test.go create mode 100644 internal/cli/resolve.go create mode 100644 internal/store/hierarchy.go create mode 100644 internal/store/hierarchy_test.go create mode 100644 internal/web/static/htmx-2.0.11.min.js diff --git a/CHANGELOG.md b/CHANGELOG.md index a1b9c712..5e2062c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,48 @@ is only released once its **exit criterion** is met, not when its code lands. Until v1.0.0 the minor version is the breaking one: configuration formats and Go APIs change between minors without a deprecation period. +## Unreleased + +### Managing people and the tenancy tree + +Organizations, teams and projects can now be renamed, moved and removed, and an +approved person can be moved to another project or removed — none of which +could be done at all before, from the web or the CLI. + +- **`/admin/users`** shows the whole tree, each project opening onto its people + and keys, with approve, move, suspend, reinstate and remove for people, + revoke for keys, and add, rename, move and remove at every level. A search + over every account sits below it. Changes update the page in place (htmx, + embedded in the binary) and it still works with scripts off. +- **`llmgw org`** — `tree [--people]`, `add`, `rename`, `move`, `remove`. +- **`llmgw users move`** and **`llmgw users delete`**. +- **`llmgw keys list --all`** lists every key with its project and owner. +- Every command that takes a project, team or organization accepts an id, a + path such as `Acme/platform/gateway`, or a unique name, and `--project` may + be omitted when there is only one. Users are named by id or email. + +Moving a person takes the keys they made with them; keys an operator issued stay +with the project. Only empty things are removed, and a project that ever had a +key is never removed, because usage history refers to it. + +### Changes + +- **`--project` no longer defaults to `default`** in `keys create`, `keys list`, + `users approve`, `users create`, `keys threads` and `keys memory`: a tree + built from the web has no project of that name. With one project it is + chosen; with several the command lists them. +- **`keys limits --budget` is in whole units**, as the quotas page takes it, + rather than micro-units. `--budget 50000000` now means fifty million units; + divide existing scripts' figures by 1 000 000. + +### Fixes + +- `keys init` could silently move an existing team or project to another + organization or team by redeclaring it. It now refuses. +- An account rejected while pending and then reinstated became active with no + project, and failed at its first key. Reinstating one is refused; approving + gives it a project. Rejecting a signup now removes it. + ## v0.8.8 — 2026-09-28 ### Fixes diff --git a/docs/choosing-models.md b/docs/choosing-models.md index 0de296af..a6431b40 100644 --- a/docs/choosing-models.md +++ b/docs/choosing-models.md @@ -141,7 +141,7 @@ not count the same free terabyte once per node. Set budgets and limits on day one, not later: ``` -llmgw keys limits --scope project --id --rpm 60 --budget 50000000 +llmgw keys limits --scope project --id --rpm 60 --budget 50 ``` The reason is not technical. A resource that appears free and unlimited becomes, diff --git a/docs/onboarding.md b/docs/onboarding.md index f0d4e65e..82e9e928 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -88,11 +88,36 @@ means anything. If you have no structure yet, one of each is fine: ``` -llmgw keys init --store … --org mynodes --team default --project default +llmgw org add org mynodes +llmgw org add team default --in mynodes +llmgw org add project default --in mynodes/default +llmgw org tree --people ``` -It is idempotent: running it again renames rather than duplicating, so it can -live in whatever provisions the machine. +Everything that takes an organization, team or project — here, in `users` and +in `keys` — accepts its id, its path (`mynodes/default/default`) or its name +when the name is unique, so an id never has to be copied off a page. When there +is only one project, `--project` can be left out. + +The tree is not fixed once made: + +``` +llmgw org rename project Acme/platform/gateway api-gateway +llmgw org move project Acme/platform/gateway --to Scania/ml +llmgw org remove team Acme/old # only when nothing is in it +llmgw users move ayse@example.com --project Acme/ml/research +llmgw users delete ayse@example.com --yes # signs out, revokes their keys +llmgw keys list --all # every key, its project and owner +``` + +Moving a person takes the keys they made with them, so they stop spending the +old project's budget; keys an operator issued in the project stay with it. A +project that ever had a key cannot be removed, because usage history refers to +it — rename it instead. + +`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; +it says so and stops. Split it when you want separate limits or separate reporting, not before. @@ -156,16 +181,18 @@ Allowlists compose by intersection: a key cannot add a model its team withheld. A model's canonical name and any of its aliases name the same thing, so either form works. -Budgets are counted in micro-units, and what a unit means is yours to decide. -Self-hosted models have no per-token price, so it can be GPU-seconds, a notional -rate, or 1 000 000 everywhere — which turns a budget into a plain token quota. +Budgets are given in whole units, as the quotas page shows them, and what a unit +means is yours to decide. Self-hosted models have no per-token price, so it can +be GPU-seconds, a notional rate, or one unit per thousand tokens everywhere — +a price of 1 000 000 in `auth.pricing`, which is in micro-units — and a budget +becomes a plain quota of thousands of tokens. Models are priced in the server's `auth.pricing` section; a model priced at zero is exempt from budget checks, which is what lets a key with nothing left still be served a cheap fallback. ``` -llmgw keys limits --store … --scope project --id default \ - --budget 50000000 --period month # 50 units a month +llmgw keys limits --store … --scope project --id Acme/platform/gateway \ + --budget 50 --period month # 50 units a month ``` ## The landing page @@ -286,9 +313,12 @@ page exists. ## Managing accounts from the browser -`/admin/users` lists who is waiting and who is active. Approving (into a project) -and suspending are there, because that is the work an operator does constantly -and it should not need a terminal. +`/admin/users` is the tree: organizations, teams and projects, each project +opening onto its people and its keys. From there an admin approves, moves, +suspends and removes people, revokes keys, and adds, renames, moves and removes +organizations, teams and projects. A searchable list of every account is at the +bottom. The page works as plain forms; with scripts on, a change updates it in +place and the projects you had open stay open. Three things are deliberately not there: diff --git a/internal/cli/keys.go b/internal/cli/keys.go index 42eb7ba9..881ffdb6 100644 --- a/internal/cli/keys.go +++ b/internal/cli/keys.go @@ -6,6 +6,7 @@ import ( "encoding/base32" "errors" "fmt" + "math" "strings" "text/tabwriter" "time" @@ -107,7 +108,11 @@ func newKeyCreateCmd(open opener) *cobra.Command { } defer s.Close() - secret, k, err := store.NewKey(randomID("key"), args[0], project) + p, err := resolveProject(cmd.Context(), s, project) + if err != nil { + return err + } + secret, k, err := store.NewKey(randomID("key"), args[0], p.ID) if err != nil { return err } @@ -117,8 +122,7 @@ func newKeyCreateCmd(open opener) *cobra.Command { k.ExpiresAt = k.CreatedAt.Add(expires) } if err := s.CreateKey(cmd.Context(), k); err != nil { - return fmt.Errorf("creating the key (does project %q exist? run `llmgw keys init`): %w", - project, err) + return fmt.Errorf("creating the key in %s: %w", p.Path, err) } // The one output that matters goes to stdout and everything else to @@ -141,13 +145,13 @@ func newKeyCreateCmd(open opener) *cobra.Command { link := strings.TrimRight(baseURL, "/") + "/claim/" + token fmt.Fprintln(out, link) cmd.PrintErrf("\nid %s\nproject %s\nexpires %s\n", - k.ID, k.ProjectID, time.Now().Add(orDefault(claimTTL, store.ClaimTTL)).Format(time.RFC3339)) + k.ID, p.Path, time.Now().Add(orDefault(claimTTL, store.ClaimTTL)).Format(time.RFC3339)) cmd.PrintErrln("\nSend the link. It works once, and the key is shown only to whoever opens it.") return nil } fmt.Fprintln(out, secret) - cmd.PrintErrf("\nid %s\nproject %s\n", k.ID, k.ProjectID) + cmd.PrintErrf("\nid %s\nproject %s\n", k.ID, p.Path) if !k.ExpiresAt.IsZero() { cmd.PrintErrf("expires %s\n", k.ExpiresAt.Format(time.RFC3339)) } @@ -155,7 +159,8 @@ func newKeyCreateCmd(open opener) *cobra.Command { return nil }, } - cmd.Flags().StringVar(&project, "project", "default", "project the key belongs to") + cmd.Flags().StringVar(&project, "project", "", + "project the key belongs to: id, path (Acme/platform/gateway) or unique name") cmd.Flags().StringVar(&band, "band", "", "admission band; empty inherits") cmd.Flags().StringVar(&profile, "profile", "", "routing profile; empty inherits") cmd.Flags().DurationVar(&expires, "expires-in", 0, "lifetime; 0 never expires") @@ -174,9 +179,10 @@ func orDefault(d, fallback time.Duration) time.Duration { func newKeyListCmd(open opener) *cobra.Command { var project string + var all bool cmd := &cobra.Command{ Use: "list", - Short: "List a project's keys", + Short: "List a project's keys, or with --all every key and whose it is", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { s, err := open(cmd.Context()) @@ -185,12 +191,31 @@ func newKeyListCmd(open opener) *cobra.Command { } defer s.Close() - keys, err := s.Keys(cmd.Context(), project) + if all { + rows, err := s.AllKeys(cmd.Context()) + if err != nil { + return err + } + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "ID\tNAME\tKEY\tPROJECT\tOWNER\tSTATE\tLAST USED") + now := time.Now() + for _, r := range rows { + fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", r.ID, r.Name, r.Prefix, + r.ProjectPath, dash(r.OwnerEmail), keyState(r.Key, now), ago(r.LastUsedAt, now)) + } + return w.Flush() + } + + p, err := resolveProject(cmd.Context(), s, project) + if err != nil { + return err + } + keys, err := s.Keys(cmd.Context(), p.ID) if err != nil { return err } if len(keys) == 0 { - fmt.Fprintf(cmd.OutOrStdout(), "no keys in project %q\n", project) + fmt.Fprintf(cmd.OutOrStdout(), "no keys in %s\n", p.Path) return nil } @@ -204,7 +229,8 @@ func newKeyListCmd(open opener) *cobra.Command { return w.Flush() }, } - cmd.Flags().StringVar(&project, "project", "default", "project to list") + cmd.Flags().StringVar(&project, "project", "", "project to list: id, path or unique name") + cmd.Flags().BoolVar(&all, "all", false, "every key in every project, with its owner") return cmd } @@ -235,7 +261,7 @@ func newKeyRevokeCmd(open opener) *cobra.Command { func newLimitsCmd(open opener) *cobra.Command { var scope, id, period string var rpm, tpm int - var budget int64 + var budget float64 var models []string cmd := &cobra.Command{ @@ -252,7 +278,34 @@ func newLimitsCmd(open opener) *cobra.Command { } defer s.Close() - l := store.Limits{Scope: store.Scope(scope), ScopeID: id} + scopeID := id + switch store.Scope(scope) { + case store.ScopeOrg: + o, err := resolveOrg(cmd.Context(), s, id) + if err != nil { + return err + } + scopeID = o.ID + case store.ScopeTeam: + t, err := resolveTeam(cmd.Context(), s, id) + if err != nil { + return err + } + scopeID = t.ID + case store.ScopeProject: + p, err := resolveProject(cmd.Context(), s, id) + if err != nil { + return err + } + scopeID = p.ID + case store.ScopeKey: + if id == "" { + return errors.New("--scope key needs --id, the key's id") + } + default: + return fmt.Errorf("--scope is org, team, project or key, not %q", scope) + } + l := store.Limits{Scope: store.Scope(scope), ScopeID: scopeID} if cmd.Flags().Changed("rpm") { l.RPM = &rpm } @@ -260,7 +313,10 @@ func newLimitsCmd(open opener) *cobra.Command { l.TPM = &tpm } if cmd.Flags().Changed("budget") { - l.Budget = &budget + // Whole units, as the quotas page takes them; stored as + // micro-units so repeated addition does not drift. + micros := int64(math.Round(budget * 1e6)) + l.Budget = µs l.BudgetPeriod = store.Period(period) } if cmd.Flags().Changed("models") { @@ -274,15 +330,15 @@ func newLimitsCmd(open opener) *cobra.Command { if err := s.PutLimits(cmd.Context(), l); err != nil { return err } - fmt.Fprintf(cmd.OutOrStdout(), "limits set on %s %s\n", scope, id) + fmt.Fprintf(cmd.OutOrStdout(), "limits set on %s %s\n", scope, scopeID) return nil }, } cmd.Flags().StringVar(&scope, "scope", "project", "org | team | project | key") - cmd.Flags().StringVar(&id, "id", "default", "id at that scope") + cmd.Flags().StringVar(&id, "id", "", "which one: id, path or unique name (a key's id for --scope key)") cmd.Flags().IntVar(&rpm, "rpm", 0, "requests per minute") cmd.Flags().IntVar(&tpm, "tpm", 0, "tokens per minute") - cmd.Flags().Int64Var(&budget, "budget", 0, "budget per period, in micro-units") + cmd.Flags().Float64Var(&budget, "budget", 0, "budget per period, in whole units as the quotas page shows them") cmd.Flags().StringVar(&period, "period", "month", "day | month") cmd.Flags().StringSliceVar(&models, "models", nil, "allowed models; empty allows none") return cmd diff --git a/internal/cli/memory.go b/internal/cli/memory.go index a11bab71..e39c274d 100644 --- a/internal/cli/memory.go +++ b/internal/cli/memory.go @@ -42,7 +42,12 @@ func newMemoryCmd(open opener) *cobra.Command { if off { keep = 0 } - if err := s.SetMemoryRetention(cmd.Context(), project, keep); err != nil { + p, err := resolveProject(cmd.Context(), s, project) + if err != nil { + return err + } + project = p.Path + if err := s.SetMemoryRetention(cmd.Context(), p.ID, keep); err != nil { return err } if keep == 0 { @@ -55,7 +60,7 @@ func newMemoryCmd(open opener) *cobra.Command { return nil }, } - cmd.Flags().StringVar(&project, "project", "default", "project id") + cmd.Flags().StringVar(&project, "project", "", "project: id, path or unique name") cmd.Flags().IntVar(&hours, "retain-hours", 0, "how long a note is kept after it was last written") cmd.Flags().BoolVar(&off, "off", false, "stop keeping notes for this project") return cmd diff --git a/internal/cli/org.go b/internal/cli/org.go new file mode 100644 index 00000000..bc1211af --- /dev/null +++ b/internal/cli/org.go @@ -0,0 +1,334 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "io" + + "github.com/spf13/cobra" + + "gitea.mynodes.xyz/ilker/llmgw/internal/store" +) + +func newOrgCmd() *cobra.Command { + var sf storeFlags + cmd := &cobra.Command{ + Use: "org", + Short: "Organizations, teams and projects: see, add, rename, move, remove", + Long: "The tenancy tree an account lives in. A person is in a project, a project\n" + + "in a team, a team in an organization, and limits can be set on any of them.\n\n" + + "Anything that takes one of them accepts its id, its path\n" + + "(\"Acme/platform/gateway\") or its name when that name is unique.", + } + sf.bind(cmd) + open := func(cmd *cobra.Command) (*store.DB, error) { return sf.open(cmd) } + cmd.AddCommand(newOrgTreeCmd(open), newOrgAddCmd(open), newOrgRenameCmd(open), + newOrgMoveCmd(open), newOrgRemoveCmd(open)) + return cmd +} + +type dbOpener func(*cobra.Command) (*store.DB, error) + +func newOrgTreeCmd(open dbOpener) *cobra.Command { + var people bool + cmd := &cobra.Command{ + Use: "tree", + Short: "Show the whole tree, with how many accounts and keys each project has", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + s, err := open(cmd) + if err != nil { + return err + } + defer s.Close() + tree, err := s.Tree(cmd.Context()) + if err != nil { + return err + } + var byProject map[string][]store.User + if people { + users, err := s.Users(cmd.Context(), "") + if err != nil { + return err + } + byProject = map[string][]store.User{} + for _, u := range users { + byProject[u.ProjectID] = append(byProject[u.ProjectID], u) + } + } + printTree(cmd.OutOrStdout(), tree, byProject) + if people && len(byProject[""]) > 0 { + fmt.Fprintf(cmd.OutOrStdout(), "\nwaiting for approval\n") + for _, u := range byProject[""] { + fmt.Fprintf(cmd.OutOrStdout(), " %s %s %s\n", u.Email, u.State, u.ID) + } + } + return nil + }, + } + cmd.Flags().BoolVar(&people, "people", false, "list the accounts in each project") + return cmd +} + +func printTree(w io.Writer, tree []store.TreeOrg, people map[string][]store.User) { + if len(tree) == 0 { + fmt.Fprintln(w, "no organizations yet; `llmgw org add org ` starts one") + return + } + for _, o := range tree { + fmt.Fprintf(w, "%s (%s)\n", o.Name, o.ID) + if len(o.Teams) == 0 { + fmt.Fprintln(w, " no teams") + } + for _, t := range o.Teams { + fmt.Fprintf(w, " %s (%s)\n", t.Name, t.ID) + if len(t.Projects) == 0 { + fmt.Fprintln(w, " no projects") + } + for _, p := range t.Projects { + fmt.Fprintf(w, " %s (%s) %s, %s\n", p.Name, p.ID, + countOf(p.Users, "account"), keysOf(p)) + for _, u := range people[p.ID] { + admin := "" + if u.IsAdmin { + admin = " admin" + } + fmt.Fprintf(w, " %s %s%s\n", u.Email, u.State, admin) + } + } + } + } +} + +func keysOf(p store.TreeProject) string { + s := countOf(p.ActiveKeys, "key") + if revoked := p.Keys - p.ActiveKeys; revoked > 0 { + s += fmt.Sprintf(" (+%d revoked)", revoked) + } + return s +} + +func countOf(n int, noun string) string { + if n == 1 { + return "1 " + noun + } + return fmt.Sprintf("%d %ss", n, noun) +} + +func levelArg(arg string) (string, error) { + switch arg { + case "org", "organization": + return "org", nil + case "team", "project": + return arg, nil + } + return "", fmt.Errorf("%q is not org, team or project", arg) +} + +func newOrgAddCmd(open dbOpener) *cobra.Command { + var in string + cmd := &cobra.Command{ + Use: "add ", + Short: "Add an organization, a team to one, or a project to a team", + Example: " llmgw org add org Acme\n" + + " llmgw org add team platform --in Acme\n" + + " llmgw org add project gateway --in Acme/platform", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + level, err := levelArg(args[0]) + if err != nil { + return err + } + s, err := open(cmd) + if err != nil { + return err + } + defer s.Close() + ctx := cmd.Context() + var id, where string + switch level { + case "org": + id, err = s.CreateOrg(ctx, args[1]) + case "team": + var o store.Org + if o, err = resolveOrg(ctx, s, in); err == nil { + id, err = s.CreateTeam(ctx, o.ID, args[1]) + where = " in " + o.Name + } + case "project": + var t store.TeamPath + if t, err = resolveTeam(ctx, s, in); err == nil { + id, err = s.CreateProject(ctx, t.ID, args[1]) + where = " in " + t.OrgName + " / " + t.Name + } + } + if errors.Is(err, store.ErrDuplicateName) { + return fmt.Errorf("there is already a %s named %q there", level, args[1]) + } + if err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "%s %s%s (%s)\n", level, args[1], where, id) + return nil + }, + } + cmd.Flags().StringVar(&in, "in", "", "the organization a team goes in, or the team a project goes in") + return cmd +} + +func newOrgRenameCmd(open dbOpener) *cobra.Command { + return &cobra.Command{ + Use: "rename ", + Short: "Rename an organization, team or project; ids and everything under it stay", + Example: " llmgw org rename project Acme/platform/gateway api-gateway", + Args: cobra.ExactArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + level, err := levelArg(args[0]) + if err != nil { + return err + } + s, err := open(cmd) + if err != nil { + return err + } + defer s.Close() + id, err := resolveLevel(cmd.Context(), s, level, args[1]) + if err != nil { + return err + } + switch level { + case "org": + err = s.RenameOrg(cmd.Context(), id, args[2]) + case "team": + err = s.RenameTeam(cmd.Context(), id, args[2]) + case "project": + err = s.RenameProject(cmd.Context(), id, args[2]) + } + if errors.Is(err, store.ErrDuplicateName) { + return fmt.Errorf("there is already a %s named %q beside it", level, args[2]) + } + if err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "%s %s is now %q\n", level, id, args[2]) + return nil + }, + } +} + +func newOrgMoveCmd(open dbOpener) *cobra.Command { + var to string + cmd := &cobra.Command{ + Use: "move --to ", + Short: "Put a team in another organization, or a project in another team", + Long: "Everything under it moves too: projects, accounts and keys. From then on it\n" + + "inherits the new parent's limits. Usage already recorded is not rewritten.", + Example: " llmgw org move project Acme/platform/gateway --to Scania/ml\n" + + " llmgw org move team platform --to Scania", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + s, err := open(cmd) + if err != nil { + return err + } + defer s.Close() + ctx := cmd.Context() + switch args[0] { + case "team": + t, err := resolveTeam(ctx, s, args[1]) + if err != nil { + return err + } + o, err := resolveOrg(ctx, s, to) + if err != nil { + return err + } + if err := s.MoveTeam(ctx, t.ID, o.ID); err != nil { + return dupAs(err, "team", t.Name) + } + fmt.Fprintf(cmd.OutOrStdout(), "team %s is now in %s\n", t.Name, o.Name) + case "project": + p, err := resolveProject(ctx, s, args[1]) + if err != nil { + return err + } + t, err := resolveTeam(ctx, s, to) + if err != nil { + return err + } + if err := s.MoveProject(ctx, p.ID, t.ID); err != nil { + return dupAs(err, "project", p.Path) + } + fmt.Fprintf(cmd.OutOrStdout(), "%s is now in %s / %s\n", p.Path, t.OrgName, t.Name) + default: + return fmt.Errorf("only a team or a project moves; to move an account, `llmgw users move`") + } + return nil + }, + } + cmd.Flags().StringVar(&to, "to", "", "the organization or team it goes to") + _ = cmd.MarkFlagRequired("to") + return cmd +} + +func dupAs(err error, level, name string) error { + if errors.Is(err, store.ErrDuplicateName) { + return fmt.Errorf("the destination already has a %s named like %s", level, name) + } + return err +} + +func newOrgRemoveCmd(open dbOpener) *cobra.Command { + return &cobra.Command{ + Use: "remove ", + Short: "Remove an organization, team or project that nothing is in", + Long: "Only something empty is removed: an organization with no teams, a team with\n" + + "no projects, a project with no accounts and no keys. A project that ever had\n" + + "a key keeps it, revoked, because usage history refers to it; rename such a\n" + + "project instead. Its limits go with it.", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + level, err := levelArg(args[0]) + if err != nil { + return err + } + s, err := open(cmd) + if err != nil { + return err + } + defer s.Close() + id, err := resolveLevel(cmd.Context(), s, level, args[1]) + if err != nil { + return err + } + switch level { + case "org": + err = s.DeleteOrg(cmd.Context(), id) + case "team": + err = s.DeleteTeam(cmd.Context(), id) + case "project": + err = s.DeleteProject(cmd.Context(), id) + } + if err != nil { + return fmt.Errorf("%s %s was not removed: %w", level, args[1], err) + } + fmt.Fprintf(cmd.OutOrStdout(), "%s %s removed\n", level, id) + return nil + }, + } +} + +func resolveLevel(ctx context.Context, s *store.DB, level, arg string) (string, error) { + switch level { + case "org": + o, err := resolveOrg(ctx, s, arg) + return o.ID, err + case "team": + t, err := resolveTeam(ctx, s, arg) + return t.ID, err + default: + p, err := resolveProject(ctx, s, arg) + return p.ID, err + } +} diff --git a/internal/cli/org_test.go b/internal/cli/org_test.go new file mode 100644 index 00000000..dad4fad7 --- /dev/null +++ b/internal/cli/org_test.go @@ -0,0 +1,124 @@ +package cli + +import ( + "bytes" + "context" + "path/filepath" + "strings" + "testing" + + "gitea.mynodes.xyz/ilker/llmgw/internal/store" +) + +// run executes the CLI against one store file and returns stdout. +func run(t *testing.T, db string, args ...string) (string, error) { + t.Helper() + cmd := Root() + var out, errOut bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&errOut) + // --config pointed nowhere, so a server.yaml on the test machine is never read. + cmd.SetArgs(append(args, "--store", db, "--config", "")) + err := cmd.ExecuteContext(context.Background()) + return out.String(), err +} + +func mustRun(t *testing.T, db string, args ...string) string { + t.Helper() + out, err := run(t, db, args...) + if err != nil { + t.Fatalf("%v: %v", args, err) + } + return out +} + +func TestTheTreeIsManagedByNameNotId(t *testing.T) { + db := filepath.Join(t.TempDir(), "s.db") + mustRun(t, db, "org", "add", "org", "Acme") + mustRun(t, db, "org", "add", "org", "Scania") + mustRun(t, db, "org", "add", "team", "platform", "--in", "Acme") + mustRun(t, db, "org", "add", "team", "platform", "--in", "Scania") + + // "platform" is in both, so a project needs the path. + _, err := run(t, db, "org", "add", "project", "gateway", "--in", "platform") + if err == nil || !strings.Contains(err.Error(), "more than one team") { + t.Fatalf("an ambiguous name was accepted: %v", err) + } + mustRun(t, db, "org", "add", "project", "gateway", "--in", "acme/Platform") + mustRun(t, db, "org", "add", "project", "research", "--in", "Scania / platform") + + mustRun(t, db, "users", "create", "ayse@example.com", "--password", "correct horse battery", "--project", "Acme/platform/gateway") + mustRun(t, db, "keys", "create", "laptop", "--project", "gateway") + + out := mustRun(t, db, "org", "tree", "--people") + for _, want := range []string{"Acme", "gateway", "1 account, 1 key", "ayse@example.com"} { + if !strings.Contains(out, want) { + t.Errorf("tree lacks %q:\n%s", want, out) + } + } + + out = mustRun(t, db, "users", "move", "ayse@example.com", "--project", "research") + if !strings.Contains(out, "Scania / platform / research") { + t.Errorf("move said: %s", out) + } + + mustRun(t, db, "org", "rename", "project", "research", "lab") + mustRun(t, db, "org", "move", "project", "Acme/platform/gateway", "--to", "Scania/platform") + + // gateway still has the operator's key, so it stays. + if _, err := run(t, db, "org", "remove", "project", "gateway"); err == nil { + t.Error("a project with a key was removed") + } + mustRun(t, db, "org", "remove", "team", "Acme/platform") + mustRun(t, db, "org", "remove", "org", "Acme") + + s, err := store.Open(context.Background(), db) + if err != nil { + t.Fatal(err) + } + defer s.Close() + paths, _ := s.AllProjects(context.Background()) + var got []string + for _, p := range paths { + got = append(got, p.Path) + } + if strings.Join(got, "|") != "Scania / platform / gateway|Scania / platform / lab" { + t.Errorf("projects = %v", got) + } +} + +func TestOneProjectNeedsNoFlag(t *testing.T) { + db := filepath.Join(t.TempDir(), "s.db") + mustRun(t, db, "org", "add", "org", "Acme") + mustRun(t, db, "org", "add", "team", "platform") + mustRun(t, db, "org", "add", "project", "gateway") + mustRun(t, db, "keys", "create", "laptop") + if out := mustRun(t, db, "keys", "list"); !strings.Contains(out, "laptop") { + t.Errorf("list: %s", out) + } + mustRun(t, db, "org", "add", "project", "search", "--in", "platform") + if _, err := run(t, db, "keys", "create", "other"); err == nil || + !strings.Contains(err.Error(), "which project?") { + t.Errorf("two projects and no flag: %v", err) + } +} + +func TestKeyBudgetsAreWholeUnits(t *testing.T) { + db := filepath.Join(t.TempDir(), "s.db") + mustRun(t, db, "org", "add", "org", "Acme") + mustRun(t, db, "org", "add", "team", "platform") + mustRun(t, db, "org", "add", "project", "gateway") + mustRun(t, db, "keys", "limits", "--scope", "project", "--id", "gateway", "--budget", "12.5") + + s, err := store.Open(context.Background(), db) + if err != nil { + t.Fatal(err) + } + defer s.Close() + all, _ := s.AllLimits(context.Background()) + for _, l := range all { + if l.Budget == nil || *l.Budget != 12_500_000 { + t.Errorf("budget stored as %v micro-units", l.Budget) + } + } +} diff --git a/internal/cli/resolve.go b/internal/cli/resolve.go new file mode 100644 index 00000000..0d93cf47 --- /dev/null +++ b/internal/cli/resolve.go @@ -0,0 +1,131 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "strings" + + "gitea.mynodes.xyz/ilker/llmgw/internal/store" +) + +// The tree can be built from the web, which mints ids like proj-3kq7..., or +// from `keys init`, which makes slugs. A command that wanted the id made the +// operator copy one off a page, and every --project defaulted to "default", +// which a tree built from the web does not have. These take what a person +// would type instead: an id, a path, or a name that is unique. + +// normPath lowercases a path and spaces its separators the way the store joins +// them, so "acme/platform/gateway" matches "Acme / platform / gateway". +func normPath(p string) string { + parts := strings.Split(p, "/") + for i := range parts { + parts[i] = strings.ToLower(strings.TrimSpace(parts[i])) + } + return strings.Join(parts, " / ") +} + +type candidate struct{ id, path string } + +// pick finds the one candidate an argument names. +func pick(kind, arg string, all []candidate) (candidate, error) { + if arg == "" { + if len(all) == 1 { + return all[0], nil + } + if len(all) == 0 { + return candidate{}, fmt.Errorf("there is no %s yet; create one with `llmgw org add %s`", kind, kind) + } + return candidate{}, fmt.Errorf("which %s? give an id, a path or a name:\n%s", kind, listCandidates(all)) + } + for _, c := range all { + if c.id == arg { + return c, nil + } + } + want := normPath(arg) + var matches []candidate + for _, c := range all { + full := normPath(c.path) + segs := strings.Split(full, " / ") + if full == want || segs[len(segs)-1] == want { + matches = append(matches, c) + } + } + switch len(matches) { + case 1: + return matches[0], nil + case 0: + return candidate{}, fmt.Errorf("no %s %q; there is:\n%s", kind, arg, listCandidates(all)) + default: + return candidate{}, fmt.Errorf("%q names more than one %s; give its path:\n%s", + arg, kind, listCandidates(matches)) + } +} + +func listCandidates(cs []candidate) string { + var b strings.Builder + for _, c := range cs { + fmt.Fprintf(&b, " %-40s %s\n", c.path, c.id) + } + return strings.TrimRight(b.String(), "\n") +} + +func resolveProject(ctx context.Context, s *store.DB, arg string) (store.ProjectPath, error) { + all, err := s.AllProjects(ctx) + if err != nil { + return store.ProjectPath{}, err + } + cs := make([]candidate, len(all)) + for i, p := range all { + cs[i] = candidate{p.ID, p.Path} + } + c, err := pick("project", arg, cs) + return store.ProjectPath{ID: c.id, Path: c.path}, err +} + +func resolveTeam(ctx context.Context, s *store.DB, arg string) (store.TeamPath, error) { + all, err := s.AllTeams(ctx) + if err != nil { + return store.TeamPath{}, err + } + cs := make([]candidate, len(all)) + for i, t := range all { + cs[i] = candidate{t.ID, t.OrgName + " / " + t.Name} + } + c, err := pick("team", arg, cs) + if err != nil { + return store.TeamPath{}, err + } + for _, t := range all { + if t.ID == c.id { + return t, nil + } + } + return store.TeamPath{}, store.ErrNotFound +} + +func resolveOrg(ctx context.Context, s *store.DB, arg string) (store.Org, error) { + all, err := s.AllOrgs(ctx) + if err != nil { + return store.Org{}, err + } + cs := make([]candidate, len(all)) + for i, o := range all { + cs[i] = candidate{o.ID, o.Name} + } + c, err := pick("org", arg, cs) + return store.Org{ID: c.id, Name: c.path}, err +} + +// resolveUser takes an account's id or its email. +func resolveUser(ctx context.Context, s *store.DB, arg string) (store.User, error) { + if u, err := s.UserByID(ctx, arg); err == nil { + return u, nil + } + u, err := s.UserByEmail(ctx, arg) + if errors.Is(err, store.ErrNotFound) { + return u, fmt.Errorf("no account with id or email %q; `llmgw users list` shows them", arg) + } + return u, err +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 556421d5..a3209801 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -42,6 +42,7 @@ func Root() *cobra.Command { newCtlCmd(), newTenancyCmd(), newUsersCmd(), + newOrgCmd(), newMirrorCmd(), newSimEngineCmd(), ) diff --git a/internal/cli/threads.go b/internal/cli/threads.go index c02504a6..bfae745f 100644 --- a/internal/cli/threads.go +++ b/internal/cli/threads.go @@ -50,7 +50,12 @@ func newThreadsCmd(open opener) *cobra.Command { if off { keep = 0 } - if err := s.SetThreadRetention(cmd.Context(), project, keep); err != nil { + p, err := resolveProject(cmd.Context(), s, project) + if err != nil { + return err + } + project = p.Path + if err := s.SetThreadRetention(cmd.Context(), p.ID, keep); err != nil { return err } if keep == 0 { @@ -63,7 +68,7 @@ func newThreadsCmd(open opener) *cobra.Command { return nil }, } - cmd.Flags().StringVar(&project, "project", "default", "project id") + cmd.Flags().StringVar(&project, "project", "", "project: id, path or unique name") cmd.Flags().IntVar(&hours, "retain-hours", 0, "how long a conversation is kept after its last turn") cmd.Flags().BoolVar(&off, "off", false, "stop keeping conversations for this project") return cmd diff --git a/internal/cli/users.go b/internal/cli/users.go index e4f17ae8..bfb93fc7 100644 --- a/internal/cli/users.go +++ b/internal/cli/users.go @@ -33,6 +33,7 @@ func newUsersCmd() *cobra.Command { newUserStateCmd(open, "reinstate", store.UserActive, "Let a suspended account work again"), newUserCreateCmd(open), newUserResetPasswordCmd(open), + newUserMoveCmd(open), newUserDeleteCmd(open), newUserAdminCmd(open, "promote", true, "Let an account see the cluster dashboard at /admin"), newUserAdminCmd(open, "demote", false, "Take away access to the cluster dashboard")) return cmd @@ -88,7 +89,7 @@ func newUserListCmd(open userOpener) *cobra.Command { func newUserApproveCmd(open userOpener) *cobra.Command { var project, by string cmd := &cobra.Command{ - Use: "approve ", + Use: "approve ", Short: "Let an account sign in and issue its own keys", Long: "The project is required: it decides which limits this person's keys\n" + "inherit. An account approved into no project would pass every check and\n" + @@ -101,27 +102,31 @@ func newUserApproveCmd(open userOpener) *cobra.Command { } defer s.Close() - if err := s.ApproveUser(cmd.Context(), args[0], project, by, time.Now()); err != nil { - return fmt.Errorf("approving %s: %w", args[0], err) - } - u, err := s.UserByID(cmd.Context(), args[0]) + u, err := resolveUser(cmd.Context(), s, args[0]) if err != nil { return err } - fmt.Fprintf(cmd.OutOrStdout(), "%s approved into project %s\n", u.Email, project) + p, err := resolveProject(cmd.Context(), s, project) + if err != nil { + return err + } + if err := s.ApproveUser(cmd.Context(), u.ID, p.ID, by, time.Now()); err != nil { + return fmt.Errorf("approving %s: %w", u.Email, err) + } + fmt.Fprintf(cmd.OutOrStdout(), "%s approved into %s\n", u.Email, p.Path) cmd.PrintErrln("They can sign in and create their own keys from /profile.") return nil }, } - cmd.Flags().StringVar(&project, "project", "default", "project the person's keys belong to") + cmd.Flags().StringVar(&project, "project", "", + "project the person's keys belong to: id, path (Acme/platform/gateway) or unique name") cmd.Flags().StringVar(&by, "by", "", "who approved, for the record") - _ = cmd.MarkFlagRequired("project") return cmd } func newUserStateCmd(open userOpener, verb string, state store.UserState, short string) *cobra.Command { return &cobra.Command{ - Use: verb + " ", + Use: verb + " ", Short: short, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -131,18 +136,22 @@ func newUserStateCmd(open userOpener, verb string, state store.UserState, short } defer s.Close() - if err := s.SetUserState(cmd.Context(), args[0], state); err != nil { + u, err := resolveUser(cmd.Context(), s, args[0]) + if err != nil { + return err + } + if err := s.SetUserState(cmd.Context(), u.ID, state); err != nil { return err } if state == store.UserSuspended { // Otherwise the person stays signed in and suspension is // advisory until their session happens to expire. - if err := s.DeleteUserSessions(cmd.Context(), args[0]); err != nil { + if err := s.DeleteUserSessions(cmd.Context(), u.ID); err != nil { return err } cmd.PrintErrln("Their sessions were closed. Their API keys still work — revoke those separately.") } - fmt.Fprintf(cmd.OutOrStdout(), "%s is now %s\n", args[0], state) + fmt.Fprintf(cmd.OutOrStdout(), "%s is now %s\n", u.Email, state) return nil }, } @@ -150,7 +159,7 @@ func newUserStateCmd(open userOpener, verb string, state store.UserState, short func newUserAdminCmd(open userOpener, verb string, admin bool, short string) *cobra.Command { return &cobra.Command{ - Use: verb + " ", + Use: verb + " ", Short: short, Long: "The dashboard is read-only, so this grants looking and nothing else.\n" + "Everything that changes the cluster or issues a credential stays here on\n" + @@ -164,11 +173,11 @@ func newUserAdminCmd(open userOpener, verb string, admin bool, short string) *co } defer s.Close() - if err := s.SetAdmin(cmd.Context(), args[0], admin); err != nil { + u, err := resolveUser(cmd.Context(), s, args[0]) + if err != nil { return err } - u, err := s.UserByID(cmd.Context(), args[0]) - if err != nil { + if err := s.SetAdmin(cmd.Context(), u.ID, admin); err != nil { return err } if admin { @@ -186,7 +195,7 @@ func newUserAdminCmd(open userOpener, verb string, admin bool, short string) *co func newUserResetPasswordCmd(open userOpener) *cobra.Command { var password string cmd := &cobra.Command{ - Use: "reset-password ", + Use: "reset-password ", Short: "Set a new password for someone who lost theirs", Long: "There is no self-service reset, because this gateway sends no email and\n" + "a reset link with nowhere to send it is not a reset. Set one here, tell\n" + @@ -208,13 +217,17 @@ func newUserResetPasswordCmd(open userOpener) *cobra.Command { if err != nil { return err } - if err := s.SetPassword(cmd.Context(), args[0], hash); err != nil { + u, err := resolveUser(cmd.Context(), s, args[0]) + if err != nil { return err } - if err := s.DeleteUserSessions(cmd.Context(), args[0]); err != nil { + if err := s.SetPassword(cmd.Context(), u.ID, hash); err != nil { return err } - fmt.Fprintf(cmd.OutOrStdout(), "password set for %s\n", args[0]) + if err := s.DeleteUserSessions(cmd.Context(), u.ID); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "password set for %s\n", u.Email) cmd.PrintErrln("Their sessions were closed. Their API keys still work.") return nil }, @@ -241,6 +254,10 @@ func newUserCreateCmd(open userOpener) *cobra.Command { } defer s.Close() + p, err := resolveProject(cmd.Context(), s, project) + if err != nil { + return err + } hash, err := account.HashPassword(password) if err != nil { return err @@ -253,15 +270,88 @@ func newUserCreateCmd(open userOpener) *cobra.Command { if err := s.CreateUser(cmd.Context(), u); err != nil { return err } - if err := s.ApproveUser(cmd.Context(), id, project, "cli", time.Now()); err != nil { + if err := s.ApproveUser(cmd.Context(), id, p.ID, "cli", time.Now()); err != nil { return err } fmt.Fprintf(cmd.OutOrStdout(), "%s\n", id) - cmd.PrintErrf("%s created and approved into project %s\n", args[0], project) + cmd.PrintErrf("%s created and approved into %s\n", args[0], p.Path) return nil }, } - cmd.Flags().StringVar(&project, "project", "default", "project the person's keys belong to") + cmd.Flags().StringVar(&project, "project", "", + "project the person's keys belong to: id, path or unique name (optional when there is one)") cmd.Flags().StringVar(&password, "password", "", "initial password") return cmd } + +func newUserMoveCmd(open userOpener) *cobra.Command { + var project string + cmd := &cobra.Command{ + Use: "move --project ", + Short: "Put an approved account in another project, with the keys it made", + Long: "The person's own keys go with them, so they stop spending the old project's\n" + + "budget. Keys an operator issued in the old project stay there. Usage already\n" + + "recorded is not rewritten. A running server picks the change up when its key\n" + + "cache expires (auth.cache_ttl).", + Example: " llmgw users move ayse@example.com --project Acme/ml/research", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + s, err := open(cmd) + if err != nil { + return err + } + defer s.Close() + u, err := resolveUser(cmd.Context(), s, args[0]) + if err != nil { + return err + } + p, err := resolveProject(cmd.Context(), s, project) + if err != nil { + return err + } + moved, err := s.MoveUser(cmd.Context(), u.ID, p.ID) + if err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "%s is now in %s (%s moved with them)\n", + u.Email, p.Path, countOf(moved, "key")) + return nil + }, + } + cmd.Flags().StringVar(&project, "project", "", "where they go: id, path or unique name") + _ = cmd.MarkFlagRequired("project") + return cmd +} + +func newUserDeleteCmd(open userOpener) *cobra.Command { + var yes bool + cmd := &cobra.Command{ + Use: "delete ", + Short: "Remove an account for good", + Long: "Signs them out, revokes every key they made, and removes the account. The\n" + + "keys are kept, revoked and ownerless, because usage history refers to them.\n" + + "To stop an account working and keep it, use suspend.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + s, err := open(cmd) + if err != nil { + return err + } + defer s.Close() + u, err := resolveUser(cmd.Context(), s, args[0]) + if err != nil { + return err + } + if !yes { + return fmt.Errorf("this removes %s and revokes their keys; run again with --yes", u.Email) + } + if err := s.DeleteUser(cmd.Context(), u.ID, time.Now()); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "%s removed\n", u.Email) + return nil + }, + } + cmd.Flags().BoolVar(&yes, "yes", false, "confirm") + return cmd +} diff --git a/internal/store/hierarchy.go b/internal/store/hierarchy.go new file mode 100644 index 00000000..813eee26 --- /dev/null +++ b/internal/store/hierarchy.go @@ -0,0 +1,336 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" +) + +// ErrNotEmpty means something still hangs off what was to be deleted. +var ErrNotEmpty = errors.New("not empty") + +// TreeOrg, TreeTeam and TreeProject are the tenancy tree as one value, with +// what hangs off each project counted, for a page or a command that shows it +// whole. +type TreeOrg struct { + ID, Name string + Teams []TreeTeam +} + +// TreeTeam is a team and its projects. +type TreeTeam struct { + ID, Name string + Projects []TreeProject +} + +// TreeProject is a project and what uses it. +type TreeProject struct { + ID, Name string + // Users counts accounts in the project, whatever their state. + Users int + // Keys counts every key ever issued in it; ActiveKeys those not revoked. + Keys, ActiveKeys int +} + +// Tree reads the whole tenancy tree, organizations with no teams and teams +// with no projects included. +func (s *DB) Tree(ctx context.Context) ([]TreeOrg, error) { + orgs, err := s.AllOrgs(ctx) + if err != nil { + return nil, err + } + teams, err := s.AllTeams(ctx) + if err != nil { + return nil, err + } + type proj struct { + TreeProject + teamID string + } + projects, err := queryAll(ctx, s.db, ` + SELECT p.id, p.name, p.team_id, + (SELECT COUNT(*) FROM users u WHERE u.project_id = p.id), + (SELECT COUNT(*) FROM api_keys k WHERE k.project_id = p.id), + (SELECT COUNT(*) FROM api_keys k WHERE k.project_id = p.id AND k.revoked_at IS NULL) + FROM projects p ORDER BY p.name`, nil, func(rows *sql.Rows) (proj, error) { + var p proj + return p, rows.Scan(&p.ID, &p.Name, &p.teamID, &p.Users, &p.Keys, &p.ActiveKeys) + }) + if err != nil { + return nil, err + } + + byTeam := map[string][]TreeProject{} + for _, p := range projects { + byTeam[p.teamID] = append(byTeam[p.teamID], p.TreeProject) + } + byOrg := map[string][]TreeTeam{} + for _, t := range teams { + byOrg[t.OrgID] = append(byOrg[t.OrgID], TreeTeam{ID: t.ID, Name: t.Name, Projects: byTeam[t.ID]}) + } + out := make([]TreeOrg, 0, len(orgs)) + for _, o := range orgs { + out = append(out, TreeOrg{ID: o.ID, Name: o.Name, Teams: byOrg[o.ID]}) + } + return out, nil +} + +// RenameOrg renames an organization. Names are unique among organizations. +func (s *DB) RenameOrg(ctx context.Context, id, name string) error { + name = strings.TrimSpace(name) + if name == "" { + return errors.New("a name cannot be empty") + } + if err := s.nameIsFree(ctx, + `SELECT COUNT(*) FROM organizations WHERE name = ? AND id <> ?`, name, id); err != nil { + return err + } + return s.updateOne(ctx, `UPDATE organizations SET name = ? WHERE id = ?`, name, id) +} + +// RenameTeam renames a team. Names are unique within an organization. +func (s *DB) RenameTeam(ctx context.Context, id, name string) error { + name = strings.TrimSpace(name) + if name == "" { + return errors.New("a name cannot be empty") + } + if err := s.nameIsFree(ctx, `SELECT COUNT(*) FROM teams + WHERE name = ? AND id <> ? AND org_id = (SELECT org_id FROM teams WHERE id = ?)`, + name, id, id); err != nil { + return err + } + return s.updateOne(ctx, `UPDATE teams SET name = ? WHERE id = ?`, name, id) +} + +// RenameProject renames a project. Names are unique within a team. +func (s *DB) RenameProject(ctx context.Context, id, name string) error { + name = strings.TrimSpace(name) + if name == "" { + return errors.New("a name cannot be empty") + } + if err := s.nameIsFree(ctx, `SELECT COUNT(*) FROM projects + WHERE name = ? AND id <> ? AND team_id = (SELECT team_id FROM projects WHERE id = ?)`, + name, id, id); err != nil { + return err + } + return s.updateOne(ctx, `UPDATE projects SET name = ? WHERE id = ?`, name, id) +} + +// MoveTeam puts a team, and everything under it, in another organization. +// +// Its limits go with it, and from now on it inherits the new organization's. +func (s *DB) MoveTeam(ctx context.Context, teamID, orgID string) error { + if err := s.exists(ctx, "organization", `SELECT COUNT(*) FROM organizations WHERE id = ?`, orgID); err != nil { + return err + } + if err := s.nameIsFree(ctx, `SELECT COUNT(*) FROM teams + WHERE org_id = ? AND id <> ? AND name = (SELECT name FROM teams WHERE id = ?)`, + orgID, teamID, teamID); err != nil { + return err + } + return s.updateOne(ctx, `UPDATE teams SET org_id = ? WHERE id = ?`, orgID, teamID) +} + +// MoveProject puts a project, with its people and keys, in another team. +func (s *DB) MoveProject(ctx context.Context, projectID, teamID string) error { + if err := s.exists(ctx, "team", `SELECT COUNT(*) FROM teams WHERE id = ?`, teamID); err != nil { + return err + } + if err := s.nameIsFree(ctx, `SELECT COUNT(*) FROM projects + WHERE team_id = ? AND id <> ? AND name = (SELECT name FROM projects WHERE id = ?)`, + teamID, projectID, projectID); err != nil { + return err + } + return s.updateOne(ctx, `UPDATE projects SET team_id = ? WHERE id = ?`, teamID, projectID) +} + +// DeleteOrg removes an organization with no teams, and its limits. +func (s *DB) DeleteOrg(ctx context.Context, id string) error { + return s.deleteEmpty(ctx, ScopeOrg, id, `DELETE FROM organizations WHERE id = ?`, + emptyCheck{`SELECT COUNT(*) FROM teams WHERE org_id = ?`, "team"}) +} + +// DeleteTeam removes a team with no projects, and its limits. +func (s *DB) DeleteTeam(ctx context.Context, id string) error { + return s.deleteEmpty(ctx, ScopeTeam, id, `DELETE FROM teams WHERE id = ?`, + emptyCheck{`SELECT COUNT(*) FROM projects WHERE team_id = ?`, "project"}) +} + +// DeleteProject removes a project nobody is in and no key was ever issued in. +// +// A revoked key still counts. Usage history names its key and project, and a +// project deleted from under that history leaves rows that belong to nothing; +// such a project can be renamed or left, not removed. +func (s *DB) DeleteProject(ctx context.Context, id string) error { + return s.deleteEmpty(ctx, ScopeProject, id, `DELETE FROM projects WHERE id = ?`, + emptyCheck{`SELECT COUNT(*) FROM users WHERE project_id = ?`, "account"}, + emptyCheck{`SELECT COUNT(*) FROM api_keys WHERE project_id = ?`, "key"}) +} + +type emptyCheck struct { + query, noun string +} + +func (s *DB) deleteEmpty(ctx context.Context, scope Scope, id, del string, checks ...emptyCheck) error { + return s.inTx(ctx, func(c conn) error { + var busy []string + for _, ch := range checks { + var n int + if err := c.QueryRowContext(ctx, ch.query, id).Scan(&n); err != nil { + return err + } + if n > 0 { + busy = append(busy, plural(n, ch.noun)) + } + } + if len(busy) > 0 { + return fmt.Errorf("%w: it still has %s", ErrNotEmpty, strings.Join(busy, " and ")) + } + if _, err := c.ExecContext(ctx, + `DELETE FROM limits WHERE scope_type = ? AND scope_id = ?`, string(scope), id); err != nil { + return err + } + res, err := c.ExecContext(ctx, del, id) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil + }) +} + +// MoveUser puts an approved account in another project, with the keys it +// made for itself, and returns how many keys moved. +// +// The keys move because they are the person's: left behind they would keep +// spending the old project's budget under the new project's name. Keys an +// operator issued in the project belong to the project and stay. Usage already +// recorded stays where it was spent. +func (s *DB) MoveUser(ctx context.Context, userID, projectID string) (int, error) { + if err := s.exists(ctx, "project", `SELECT COUNT(*) FROM projects WHERE id = ?`, projectID); err != nil { + return 0, err + } + var moved int + err := s.inTx(ctx, func(c conn) error { + var state string + if err := c.QueryRowContext(ctx, `SELECT state FROM users WHERE id = ?`, userID).Scan(&state); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + return err + } + if UserState(state) == UserPending { + return errors.New("this account is waiting for approval; approve it into a project instead") + } + if _, err := c.ExecContext(ctx, `UPDATE users SET project_id = ? WHERE id = ?`, projectID, userID); err != nil { + return err + } + res, err := c.ExecContext(ctx, `UPDATE api_keys SET project_id = ? WHERE user_id = ?`, projectID, userID) + if err != nil { + return err + } + n, _ := res.RowsAffected() + moved = int(n) + return nil + }) + return moved, err +} + +// DeleteUser removes an account: its sessions end, its keys are revoked, and +// the row goes. +// +// The keys stay, revoked and without an owner, because usage history refers +// to them; the account itself is what the person asked to have removed. +func (s *DB) DeleteUser(ctx context.Context, userID string, at time.Time) error { + return s.inTx(ctx, func(c conn) error { + if _, err := c.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ?`, userID); err != nil { + return err + } + if _, err := c.ExecContext(ctx, `UPDATE api_keys + SET revoked_at = COALESCE(revoked_at, ?), user_id = NULL WHERE user_id = ?`, + millis(at), userID); err != nil { + return err + } + res, err := c.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil + }) +} + +// KeyRow is a key with where it sits and who made it, for an administrator's +// list of every key. +type KeyRow struct { + Key + ProjectPath string + // OwnerEmail is empty for a key an operator issued in the project. + OwnerEmail string +} + +// AllKeys lists every key, newest first, with its project's path and owner. +func (s *DB) AllKeys(ctx context.Context) ([]KeyRow, error) { + cols := make([]string, 0, 11) + for _, c := range strings.Split(keyColumns, ",") { + cols = append(cols, "k."+strings.TrimSpace(c)) + } + return queryAll(ctx, s.db, ` + SELECT `+strings.Join(cols, ", ")+`, + o.name || ' / ' || t.name || ' / ' || p.name, COALESCE(u.email, '') + FROM api_keys k + JOIN projects p ON p.id = k.project_id + JOIN teams t ON t.id = p.team_id + JOIN organizations o ON o.id = t.org_id + LEFT JOIN users u ON u.id = k.user_id + ORDER BY k.created_at DESC`, nil, func(rows *sql.Rows) (KeyRow, error) { + var r KeyRow + k, err := scanKey(keyAndMore{rows, []any{&r.ProjectPath, &r.OwnerEmail}}) + r.Key = k + return r, err + }) +} + +// keyAndMore lets scanKey read a row that carries columns after the key's. +type keyAndMore struct { + sc scanner + more []any +} + +func (k keyAndMore) Scan(dest ...any) error { return k.sc.Scan(append(dest, k.more...)...) } + +func (s *DB) updateOne(ctx context.Context, q string, args ...any) error { + res, err := s.db.ExecContext(ctx, q, args...) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +func (s *DB) exists(ctx context.Context, noun, q string, id string) error { + var n int + if err := s.db.QueryRowContext(ctx, q, id).Scan(&n); err != nil { + return err + } + if n == 0 { + return fmt.Errorf("no %s %q", noun, id) + } + return nil +} + +func plural(n int, noun string) string { + if n == 1 { + return "1 " + noun + } + return fmt.Sprintf("%d %ss", n, noun) +} diff --git a/internal/store/hierarchy_test.go b/internal/store/hierarchy_test.go new file mode 100644 index 00000000..9b156562 --- /dev/null +++ b/internal/store/hierarchy_test.go @@ -0,0 +1,253 @@ +package store + +import ( + "context" + "errors" + "strings" + "testing" + "time" +) + +// tree builds Acme/platform/{gateway,search} and Scania/ml, and an active +// user in gateway with one key of their own and one operator key there. +type fixture struct { + s *DB + acme, scania string + platform, ml string + gateway, search, research string + user, userKey, operatorKey string +} + +func newFixture(t *testing.T) fixture { + t.Helper() + ctx := t.Context() + f := fixture{s: tenancyStore(t)} + must := func(id string, err error) string { + t.Helper() + if err != nil { + t.Fatal(err) + } + return id + } + f.acme = must(f.s.CreateOrg(ctx, "Acme")) + f.scania = must(f.s.CreateOrg(ctx, "Scania")) + f.platform = must(f.s.CreateTeam(ctx, f.acme, "platform")) + f.ml = must(f.s.CreateTeam(ctx, f.scania, "ml")) + f.gateway = must(f.s.CreateProject(ctx, f.platform, "gateway")) + f.search = must(f.s.CreateProject(ctx, f.platform, "search")) + f.research = must(f.s.CreateProject(ctx, f.ml, "research")) + + f.user = "usr-1" + if err := f.s.CreateUser(ctx, User{ID: f.user, Email: "a@x", Name: "a", PasswordHash: "h", + State: UserPending, CreatedAt: time.Now()}); err != nil { + t.Fatal(err) + } + if err := f.s.ApproveUser(ctx, f.user, f.gateway, "test", time.Now()); err != nil { + t.Fatal(err) + } + _, k, _ := NewKey("key-u", "mine", f.gateway) + if err := f.s.CreateUserKey(ctx, f.user, k); err != nil { + t.Fatal(err) + } + f.userKey = k.ID + _, k, _ = NewKey("key-o", "ci", f.gateway) + if err := f.s.CreateKey(ctx, k); err != nil { + t.Fatal(err) + } + f.operatorKey = k.ID + return f +} + +func TestTreeCountsWhatUsesEachProject(t *testing.T) { + f := newFixture(t) + tree, err := f.s.Tree(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(tree) != 2 || tree[0].Name != "Acme" || len(tree[0].Teams) != 1 { + t.Fatalf("tree = %+v", tree) + } + gw := tree[0].Teams[0].Projects[0] + if gw.Name != "gateway" || gw.Users != 1 || gw.Keys != 2 || gw.ActiveKeys != 2 { + t.Errorf("gateway = %+v", gw) + } +} + +func TestMovingAUserTakesTheirKeysAndLeavesTheProjects(t *testing.T) { + f := newFixture(t) + ctx := t.Context() + moved, err := f.s.MoveUser(ctx, f.user, f.research) + if err != nil { + t.Fatal(err) + } + if moved != 1 { + t.Errorf("moved %d keys, want the one the person made", moved) + } + u, _ := f.s.UserByID(ctx, f.user) + if u.ProjectID != f.research { + t.Errorf("user is in %s", u.ProjectID) + } + keys, _ := f.s.Keys(ctx, f.gateway) + if len(keys) != 1 || keys[0].ID != f.operatorKey { + t.Errorf("the operator's key should stay in the project: %+v", keys) + } +} + +func TestAPendingAccountIsApprovedNotMoved(t *testing.T) { + f := newFixture(t) + ctx := t.Context() + _ = f.s.CreateUser(ctx, User{ID: "usr-2", Email: "b@x", Name: "b", PasswordHash: "h", + State: UserPending, CreatedAt: time.Now()}) + if _, err := f.s.MoveUser(ctx, "usr-2", f.gateway); err == nil { + t.Error("moving a pending account should be refused") + } +} + +func TestAnAccountWithNoProjectCannotBeMadeActive(t *testing.T) { + f := newFixture(t) + ctx := t.Context() + _ = f.s.CreateUser(ctx, User{ID: "usr-2", Email: "b@x", Name: "b", PasswordHash: "h", + State: UserPending, CreatedAt: time.Now()}) + // Rejected, then reinstated: the path that used to produce an active + // account whose first key failed. + if err := f.s.SetUserState(ctx, "usr-2", UserSuspended); err != nil { + t.Fatal(err) + } + if err := f.s.SetUserState(ctx, "usr-2", UserActive); err == nil { + t.Error("an account with no project was made active") + } +} + +func TestRenamingKeepsNamesUniqueAmongSiblingsOnly(t *testing.T) { + f := newFixture(t) + ctx := t.Context() + if err := f.s.RenameProject(ctx, f.search, "gateway"); !errors.Is(err, ErrDuplicateName) { + t.Errorf("a sibling's name was accepted: %v", err) + } + // research is in another team, so "gateway" is free there. + if err := f.s.RenameProject(ctx, f.research, "gateway"); err != nil { + t.Error(err) + } + if err := f.s.RenameOrg(ctx, f.acme, "Acme AB"); err != nil { + t.Error(err) + } + if err := f.s.RenameTeam(ctx, f.platform, " "); err == nil { + t.Error("an empty name was accepted") + } +} + +func TestMovingATeamOrProjectChecksTheNewParent(t *testing.T) { + f := newFixture(t) + ctx := t.Context() + if err := f.s.MoveProject(ctx, f.search, f.ml); err != nil { + t.Fatal(err) + } + projects, _ := f.s.AllProjects(ctx) + var found bool + for _, p := range projects { + if p.ID == f.search && p.Path == "Scania / ml / search" { + found = true + } + } + if !found { + t.Errorf("search did not move: %+v", projects) + } + if err := f.s.MoveTeam(ctx, f.platform, "org-nope"); err == nil { + t.Error("moved into an organization that does not exist") + } + // A team named ml already exists in Scania. + if _, err := f.s.CreateTeam(ctx, f.acme, "ml"); err != nil { + t.Fatal(err) + } + teams, _ := f.s.AllTeams(ctx) + for _, tm := range teams { + if tm.OrgID == f.acme && tm.Name == "ml" { + if err := f.s.MoveTeam(ctx, tm.ID, f.scania); !errors.Is(err, ErrDuplicateName) { + t.Errorf("two teams named ml in one organization: %v", err) + } + } + } +} + +func TestOnlyEmptyThingsAreDeleted(t *testing.T) { + f := newFixture(t) + ctx := t.Context() + err := f.s.DeleteProject(ctx, f.gateway) + if !errors.Is(err, ErrNotEmpty) || !strings.Contains(err.Error(), "1 account and 2 keys") { + t.Errorf("deleting a used project: %v", err) + } + if err := f.s.DeleteTeam(ctx, f.platform); !errors.Is(err, ErrNotEmpty) { + t.Errorf("deleting a team with projects: %v", err) + } + if err := f.s.DeleteProject(ctx, f.search); err != nil { + t.Errorf("deleting an unused project: %v", err) + } + if err := f.s.DeleteProject(ctx, f.research); err != nil { + t.Fatal(err) + } + if err := f.s.DeleteTeam(ctx, f.ml); err != nil { + t.Fatal(err) + } + if err := f.s.DeleteOrg(ctx, f.scania); err != nil { + t.Fatal(err) + } + if err := f.s.DeleteOrg(ctx, f.scania); !errors.Is(err, ErrNotFound) { + t.Errorf("deleting twice: %v", err) + } +} + +func TestDeletingAUserRevokesTheirKeysAndKeepsThem(t *testing.T) { + f := newFixture(t) + ctx := t.Context() + if err := f.s.DeleteUser(ctx, f.user, time.Now()); err != nil { + t.Fatal(err) + } + if _, err := f.s.UserByID(ctx, f.user); !errors.Is(err, ErrNotFound) { + t.Errorf("user still there: %v", err) + } + keys, _ := f.s.Keys(ctx, f.gateway) + for _, k := range keys { + if k.ID == f.userKey && k.RevokedAt.IsZero() { + t.Error("the deleted person's key still works") + } + if k.ID == f.operatorKey && !k.RevokedAt.IsZero() { + t.Error("an operator key was revoked with the person") + } + } + if len(keys) != 2 { + t.Errorf("keys are kept for usage history; have %d", len(keys)) + } +} + +func TestAllKeysNamesProjectAndOwner(t *testing.T) { + f := newFixture(t) + rows, err := f.s.AllKeys(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(rows) != 2 { + t.Fatalf("rows = %+v", rows) + } + for _, r := range rows { + if r.ProjectPath != "Acme / platform / gateway" { + t.Errorf("path %q", r.ProjectPath) + } + if (r.ID == f.userKey) != (r.OwnerEmail == "a@x") { + t.Errorf("owner of %s is %q", r.ID, r.OwnerEmail) + } + } +} + +func TestRedeclaringATeamDoesNotMoveIt(t *testing.T) { + f := newFixture(t) + err := f.s.PutTeam(context.Background(), Team{ID: f.platform, OrgID: f.scania, Name: "platform"}) + if err == nil { + t.Fatal("PutTeam moved a team to another organization") + } + teams, _ := f.s.AllTeams(context.Background()) + for _, tm := range teams { + if tm.ID == f.platform && tm.OrgID != f.acme { + t.Error("the team changed owner") + } + } +} diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index f265414e..9dcb8df4 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -96,17 +96,31 @@ func (s *DB) PutOrg(ctx context.Context, o Org) error { } // PutTeam upserts a team. +// +// It will rename a team but not move it: an upsert that rewrote the parent +// let `keys init --org B --team platform` take organization A's team without +// a word. Moving is MoveTeam, asked for by name. func (s *DB) PutTeam(ctx context.Context, t Team) error { - _, err := s.db.ExecContext(ctx, + var org string + err := s.db.QueryRowContext(ctx, `SELECT org_id FROM teams WHERE id = ?`, t.ID).Scan(&org) + if err == nil && org != t.OrgID { + return fmt.Errorf("team %s belongs to organization %s; move it rather than redeclare it", t.ID, org) + } + _, err = s.db.ExecContext(ctx, `INSERT INTO teams (id, org_id, name) VALUES (?, ?, ?) ON CONFLICT (id) DO UPDATE SET org_id = excluded.org_id, name = excluded.name`, t.ID, t.OrgID, t.Name) return err } -// PutProject upserts a project. +// PutProject upserts a project. Like PutTeam, it renames but never moves. func (s *DB) PutProject(ctx context.Context, p Project) error { - _, err := s.db.ExecContext(ctx, + var team string + err := s.db.QueryRowContext(ctx, `SELECT team_id FROM projects WHERE id = ?`, p.ID).Scan(&team) + if err == nil && team != p.TeamID { + return fmt.Errorf("project %s belongs to team %s; move it rather than redeclare it", p.ID, team) + } + _, err = s.db.ExecContext(ctx, `INSERT INTO projects (id, team_id, name) VALUES (?, ?, ?) ON CONFLICT (id) DO UPDATE SET team_id = excluded.team_id, name = excluded.name`, p.ID, p.TeamID, p.Name) diff --git a/internal/store/users.go b/internal/store/users.go index 217ccae2..4cf1b56a 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -120,7 +120,19 @@ func (s *DB) ApproveUser(ctx context.Context, id, projectID, by string, at time. } // SetUserState suspends or reinstates an account. +// +// An account with no project cannot be made active. A pending account that +// was rejected and then reinstated used to become exactly that: able to sign +// in, and failing with "something went wrong" at the first key it made. +// Approving is what gives it a project. func (s *DB) SetUserState(ctx context.Context, id string, state UserState) error { + if state == UserActive { + var project sql.NullString + if err := s.db.QueryRowContext(ctx, `SELECT project_id FROM users WHERE id = ?`, id). + Scan(&project); err == nil && !project.Valid { + return errors.New("this account has no project; approve it into one instead") + } + } res, err := s.db.ExecContext(ctx, `UPDATE users SET state = ? WHERE id = ?`, string(state), id) if err != nil { return err diff --git a/internal/web/admin_tenancy_test.go b/internal/web/admin_tenancy_test.go index 5b7c4511..0f1fa4ee 100644 --- a/internal/web/admin_tenancy_test.go +++ b/internal/web/admin_tenancy_test.go @@ -60,6 +60,26 @@ func (f *fakeAdmin) CreateProject(_ context.Context, teamID, name string) (strin return "proj-1", f.err } +func (f *fakeAdmin) Tree(context.Context) ([]store.TreeOrg, error) { + var out []store.TreeOrg + for _, o := range f.orgs { + out = append(out, store.TreeOrg{ID: o.ID, Name: o.Name}) + } + return out, nil +} +func (f *fakeAdmin) RenameOrg(context.Context, string, string) error { return f.err } +func (f *fakeAdmin) RenameTeam(context.Context, string, string) error { return f.err } +func (f *fakeAdmin) RenameProject(context.Context, string, string) error { return f.err } +func (f *fakeAdmin) MoveTeam(context.Context, string, string) error { return f.err } +func (f *fakeAdmin) MoveProject(context.Context, string, string) error { return f.err } +func (f *fakeAdmin) DeleteOrg(context.Context, string) error { return f.err } +func (f *fakeAdmin) DeleteTeam(context.Context, string) error { return f.err } +func (f *fakeAdmin) DeleteProject(context.Context, string) error { return f.err } +func (f *fakeAdmin) MoveUser(context.Context, string, string) (int, error) { return 0, f.err } +func (f *fakeAdmin) DeleteUser(context.Context, string, time.Time) error { return f.err } +func (f *fakeAdmin) AllKeys(context.Context) ([]store.KeyRow, error) { return nil, nil } +func (f *fakeAdmin) RevokeKey(context.Context, string, time.Time) error { return f.err } + func tenancyPage(t *testing.T, s *fakeAdmin, admin bool) *http.ServeMux { t.Helper() a := fakeAccounts{user: store.User{ID: "u1", Email: "a@example.com", IsAdmin: admin}} @@ -156,16 +176,17 @@ func TestThePageSaysWhatToDoWhenTheTreeIsEmpty(t *testing.T) { } } -func TestQuotasAreStillNotSettableFromTheBrowser(t *testing.T) { +func TestThisPageSetsNoQuotas(t *testing.T) { body := mirrorGet(t, tenancyPage(t, &fakeAdmin{}, true), "/admin/users").Body.String() - // Making somewhere to put people is structural; raising what they may spend - // is financial. A stolen admin session should be able to do the first and - // not the second, and the page says so. - if !strings.Contains(body, "llmgw keys limits") { - t.Error("the page should still send quota changes to the shell") - } + // Quotas have their own page; this one arranges people. What stays off + // the web entirely is what would hand a stolen session more access. if strings.Contains(body, `name="budget"`) { - t.Error("a budget field has appeared on the accounts page") + t.Error("a budget field has appeared on the people page") + } + for _, shell := range []string{"llmgw users promote", "reset-password", "llmgw keys create"} { + if !strings.Contains(body, shell) { + t.Errorf("the page should send %q to the shell", shell) + } } } diff --git a/internal/web/admin_users.go b/internal/web/admin_users.go index 415395f7..93d96ba8 100644 --- a/internal/web/admin_users.go +++ b/internal/web/admin_users.go @@ -3,7 +3,9 @@ package web import ( "context" "errors" + "fmt" "net/http" + "strings" "time" "gitea.mynodes.xyz/ilker/llmgw/internal/store" @@ -26,36 +28,77 @@ type AccountAdmin interface { CreateOrg(ctx context.Context, name string) (string, error) CreateTeam(ctx context.Context, orgID, name string) (string, error) CreateProject(ctx context.Context, teamID, name string) (string, error) + + // And keeping it in shape afterwards, which used to have no interface at + // all: nothing could be renamed, moved or removed, and an approved person + // stayed in the project they were first put in. + Tree(ctx context.Context) ([]store.TreeOrg, error) + RenameOrg(ctx context.Context, id, name string) error + RenameTeam(ctx context.Context, id, name string) error + RenameProject(ctx context.Context, id, name string) error + MoveTeam(ctx context.Context, teamID, orgID string) error + MoveProject(ctx context.Context, projectID, teamID string) error + DeleteOrg(ctx context.Context, id string) error + DeleteTeam(ctx context.Context, id string) error + DeleteProject(ctx context.Context, id string) error + MoveUser(ctx context.Context, userID, projectID string) (int, error) + DeleteUser(ctx context.Context, userID string, at time.Time) error + AllKeys(ctx context.Context) ([]store.KeyRow, error) + RevokeKey(ctx context.Context, id string, at time.Time) error +} + +// userRow is an account as the page shows it: with where it is, not an id. +type userRow struct { + store.User + ProjectPath string +} + +// keyRow is a key as the page shows it. +type keyRow struct { + store.KeyRow + State string } type adminUsersData struct { Pending []store.User - Active []store.User + Accounts []userRow + Tree []store.TreeOrg + // People and Keys are keyed by project id. + People map[string][]userRow + Keys map[string][]keyRow Projects []store.ProjectPath Orgs []store.Org Teams []store.TeamPath - Message string - Error string - CSRF string - Version string - Now string + // 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 + AdminID string + Message string + Error string + CSRF string + Version string + Now string // HasMirror and HasQuotas keep the nav from offering a page that was never // mounted. HasMirror bool HasQuotas bool } -// RegisterAdminUsers mounts account administration under /admin/users. +// RegisterAdminUsers mounts account and tenancy administration under +// /admin/users. // -// What is here and what is not is the whole design. Approving and suspending -// are the work an operator does constantly and should not need a terminal for. -// Making someone an admin, resetting a password and issuing keys on another -// person's behalf are not here: each of them turns a stolen admin session into -// more than it was — more access, or somebody else's account — and each is rare -// enough that a shell is no hardship. +// What is here and what is not is the whole design. Approving, suspending, +// moving people and keeping the tree in shape are the work an operator does +// constantly and should not need a terminal for. Making someone an admin, +// resetting a password and issuing keys on another person's behalf are not +// here: each of them turns a stolen admin session into more than it was — +// more access, or somebody else's account — and each is rare enough that a +// shell is no hardship. Revoking a key is here, because it only ever takes +// access away. func (p *AccountPages) RegisterAdminUsers(mux *http.ServeMux, s AccountAdmin) { mux.HandleFunc("GET /admin/users", p.adminOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) { - p.renderUsers(w, r, s, "", "") + p.renderUsers(w, r, s, admin, "", "", r.URL.Query().Get("open")) })) mux.HandleFunc("POST /admin/users/{id}/approve", p.adminAction(s, @@ -67,7 +110,7 @@ func (p *AccountPages) RegisterAdminUsers(mux *http.ServeMux, s AccountAdmin) { if err := s.ApproveUser(ctx, id, project, admin.Email, time.Now()); err != nil { return "", err } - return "approved into " + project, nil + return "approved into " + projectPath(ctx, s, project), nil })) mux.HandleFunc("POST /admin/users/{id}/suspend", p.adminAction(s, @@ -88,17 +131,47 @@ func (p *AccountPages) RegisterAdminUsers(mux *http.ServeMux, s AccountAdmin) { return "suspended, and signed out everywhere", nil })) - // The tenancy tree. Creating a container is not granting access: nobody can - // use one until an account is approved into it, which is the step above and - // already lives here. What is still not here is limits — raising a budget - // is a financial act, and a stolen session must not be able to perform one. - mux.HandleFunc("POST /admin/tenancy/org", p.tenancyAction(s, - func(ctx context.Context, r *http.Request) (string, error) { - id, err := s.CreateOrg(ctx, r.FormValue("name")) + mux.HandleFunc("POST /admin/users/{id}/reinstate", p.adminAction(s, + func(ctx context.Context, _ store.User, id string, _ *http.Request) (string, error) { + if err := s.SetUserState(ctx, id, store.UserActive); err != nil { + return "", err + } + return "reinstated", nil + })) + + mux.HandleFunc("POST /admin/users/{id}/move", p.adminAction(s, + func(ctx context.Context, _ store.User, id string, r *http.Request) (string, error) { + project := r.FormValue("project") + if project == "" { + return "", errors.New("choose the project they go to") + } + moved, err := s.MoveUser(ctx, id, project) if err != nil { return "", err } - return "organization " + r.FormValue("name") + " created as " + id, nil + return fmt.Sprintf("moved to %s, with %s", projectPath(ctx, s, project), plural(moved, "key", "keys")), nil + })) + + mux.HandleFunc("POST /admin/users/{id}/delete", p.adminAction(s, + func(ctx context.Context, admin store.User, id string, _ *http.Request) (string, error) { + if id == admin.ID { + return "", errors.New("you cannot remove your own account") + } + if err := s.DeleteUser(ctx, id, time.Now()); err != nil { + return "", err + } + return "removed; their keys are revoked", nil + })) + + // The tenancy tree. Creating a container is not granting access: nobody can + // use one until an account is approved into it. + mux.HandleFunc("POST /admin/tenancy/org", p.tenancyAction(s, + func(ctx context.Context, r *http.Request) (string, error) { + _, err := s.CreateOrg(ctx, r.FormValue("name")) + if err != nil { + return "", err + } + return "organization " + r.FormValue("name") + " created", nil })) mux.HandleFunc("POST /admin/tenancy/team", p.tenancyAction(s, @@ -107,11 +180,11 @@ func (p *AccountPages) RegisterAdminUsers(mux *http.ServeMux, s AccountAdmin) { if org == "" { return "", errors.New("choose the organization this team belongs to") } - id, err := s.CreateTeam(ctx, org, r.FormValue("name")) + _, err := s.CreateTeam(ctx, org, r.FormValue("name")) if err != nil { return "", err } - return "team " + r.FormValue("name") + " created as " + id, nil + return "team " + r.FormValue("name") + " created", nil })) mux.HandleFunc("POST /admin/tenancy/project", p.tenancyAction(s, @@ -124,16 +197,104 @@ func (p *AccountPages) RegisterAdminUsers(mux *http.ServeMux, s AccountAdmin) { if err != nil { return "", err } - return "project " + r.FormValue("name") + " created as " + id, nil + r.Form.Set("open", id) + return "project " + r.FormValue("name") + " created", nil })) - mux.HandleFunc("POST /admin/users/{id}/reinstate", p.adminAction(s, - func(ctx context.Context, _ store.User, id string, _ *http.Request) (string, error) { - if err := s.SetUserState(ctx, id, store.UserActive); err != nil { + mux.HandleFunc("POST /admin/tenancy/{kind}/{id}/rename", p.tenancyAction(s, + func(ctx context.Context, r *http.Request) (string, error) { + id, name := r.PathValue("id"), strings.TrimSpace(r.FormValue("name")) + var err error + switch r.PathValue("kind") { + case "org": + err = s.RenameOrg(ctx, id, name) + case "team": + err = s.RenameTeam(ctx, id, name) + case "project": + err = s.RenameProject(ctx, id, name) + default: + return "", errors.New("nothing of that kind") + } + if err != nil { return "", err } - return "reinstated", nil + return "renamed to " + name, nil })) + + mux.HandleFunc("POST /admin/tenancy/{kind}/{id}/move", p.tenancyAction(s, + func(ctx context.Context, r *http.Request) (string, error) { + id, to := r.PathValue("id"), r.FormValue("to") + if to == "" { + return "", errors.New("choose where it goes") + } + switch r.PathValue("kind") { + case "team": + if err := s.MoveTeam(ctx, id, to); err != nil { + return "", err + } + return "team moved", nil + case "project": + if err := s.MoveProject(ctx, id, to); err != nil { + return "", err + } + return "project moved to " + projectPath(ctx, s, id), nil + } + return "", errors.New("only a team or a project moves") + })) + + mux.HandleFunc("POST /admin/tenancy/{kind}/{id}/delete", p.tenancyAction(s, + func(ctx context.Context, r *http.Request) (string, error) { + id := r.PathValue("id") + var err error + switch r.PathValue("kind") { + case "org": + err = s.DeleteOrg(ctx, id) + case "team": + err = s.DeleteTeam(ctx, id) + case "project": + err = s.DeleteProject(ctx, id) + default: + return "", errors.New("nothing of that kind") + } + if err != nil { + return "", err + } + r.Form.Del("open") + return "removed", nil + })) + + mux.HandleFunc("POST /admin/keys/{id}/revoke", p.tenancyAction(s, + func(ctx context.Context, r *http.Request) (string, error) { + if err := s.RevokeKey(ctx, r.PathValue("id"), time.Now()); err != nil { + return "", err + } + return "key revoked; a running server stops accepting it when its cache expires", nil + })) +} + +func projectPath(ctx context.Context, s AccountAdmin, id string) string { + all, err := s.AllProjects(ctx) + if err == nil { + for _, p := range all { + if p.ID == id { + return p.Path + } + } + } + return id +} + +// plainError says what a store error means to the person who caused it. +func plainError(err error) error { + switch { + case errors.Is(err, store.ErrDuplicateName): + return errors.New("there is already one of those here") + case errors.Is(err, store.ErrNotEmpty): + return fmt.Errorf("not removed: %s", strings.TrimPrefix(err.Error(), store.ErrNotEmpty.Error()+": ")) + case errors.Is(err, store.ErrNotFound): + return errors.New("that is no longer there; the page was out of date") + } + return err } // tenancyAction wraps a change to the tree: admin session, CSRF, audit, render. @@ -153,15 +314,15 @@ func (p *AccountPages) tenancyAction(s AccountAdmin, } msg, err := do(r.Context(), r) if err != nil { - if errors.Is(err, store.ErrDuplicateName) { - err = errors.New("there is already one of those here") - } - p.log.Warn("a tenancy change was refused", "admin", admin.Email, "err", err) - p.renderUsers(w, r, s, "", err.Error()) + err = plainError(err) + p.log.Warn("a tenancy change was refused", "admin", admin.Email, + "path", r.URL.Path, "err", err) + p.renderUsers(w, r, s, admin, "", err.Error(), r.FormValue("open")) return } - p.log.Info("the tenancy tree was changed", "admin", admin.Email, "what", msg) - p.renderUsers(w, r, s, msg, "") + p.log.Info("the tenancy tree was changed", "admin", admin.Email, + "path", r.URL.Path, "what", msg) + p.renderUsers(w, r, s, admin, msg, "", r.FormValue("open")) }) } @@ -185,7 +346,8 @@ func (p *AccountPages) adminOnly( } } -// adminAction wraps a state change: admin session, CSRF, audit line, redirect. +// adminAction wraps a change to one account: admin session, CSRF, audit line, +// render. func (p *AccountPages) adminAction(s AccountAdmin, do func(ctx context.Context, admin store.User, id string, r *http.Request) (string, error), ) http.HandlerFunc { @@ -204,51 +366,86 @@ func (p *AccountPages) adminAction(s AccountAdmin, msg, err := do(r.Context(), admin, id, r) if err != nil { + err = plainError(err) // Logged at warn whether it worked or not: a refused administrative // action is as worth knowing about as one that went through. p.log.Warn("an administrative action was refused", "admin", admin.Email, "subject", subject, "err", err) - p.renderUsers(w, r, s, "", err.Error()) + p.renderUsers(w, r, s, admin, "", subject+": "+err.Error(), r.FormValue("open")) return } p.log.Info("an account was changed", "admin", admin.Email, "subject", subject, "what", msg) - p.renderUsers(w, r, s, subject+" "+msg, "") + p.renderUsers(w, r, s, admin, subject+" "+msg, "", r.FormValue("open")) }) } func (p *AccountPages) renderUsers(w http.ResponseWriter, r *http.Request, - s AccountAdmin, msg, errMsg string, + s AccountAdmin, admin store.User, msg, errMsg, open string, ) { + ctx := r.Context() d := adminUsersData{ - Message: msg, Error: errMsg, + Message: msg, Error: errMsg, Open: open, AdminID: admin.ID, CSRF: csrfFor(r), Version: p.version, HasMirror: p.hasMirror, HasQuotas: p.hasQuotas, Now: time.Now().UTC().Format(time.RFC3339), + People: map[string][]userRow{}, + Keys: map[string][]keyRow{}, } - - all, err := s.Users(r.Context(), "") - if err != nil { - p.log.Error("could not list accounts", "err", err) - d.Error = "could not read the accounts" - } - for _, u := range all { - if u.State == store.UserPending { - d.Pending = append(d.Pending, u) - } else { - d.Active = append(d.Active, u) + var err error + logErr := func(what string, err error) { + if err != nil { + p.log.Error("could not read "+what, "err", err) + if d.Error == "" { + d.Error = "could not read " + what + } } } - if d.Projects, err = s.AllProjects(r.Context()); err != nil { - p.log.Error("could not list projects", "err", err) + + d.Projects, err = s.AllProjects(ctx) + logErr("the projects", err) + paths := make(map[string]string, len(d.Projects)) + for _, pp := range d.Projects { + paths[pp.ID] = pp.Path } - if d.Orgs, err = s.AllOrgs(r.Context()); err != nil { - p.log.Error("could not list organizations", "err", err) + + all, err := s.Users(ctx, "") + logErr("the accounts", err) + for _, u := range all { + if u.State == store.UserPending && u.ProjectID == "" { + d.Pending = append(d.Pending, u) + continue + } + row := userRow{User: u, ProjectPath: paths[u.ProjectID]} + d.Accounts = append(d.Accounts, row) + d.People[u.ProjectID] = append(d.People[u.ProjectID], row) } - if d.Teams, err = s.AllTeams(r.Context()); err != nil { - p.log.Error("could not list teams", "err", err) + + keys, err := s.AllKeys(ctx) + logErr("the keys", err) + now := time.Now() + for _, k := range keys { + d.Keys[k.ProjectID] = append(d.Keys[k.ProjectID], keyRow{KeyRow: k, State: keyState(k.Key, now)}) } + + d.Tree, err = s.Tree(ctx) + logErr("the tree", err) + d.Orgs, err = s.AllOrgs(ctx) + logErr("the organizations", err) + d.Teams, err = s.AllTeams(ctx) + logErr("the teams", err) render(w, "admin_users", d) } + +func keyState(k store.Key, now time.Time) string { + switch { + case !k.RevokedAt.IsZero(): + return "revoked" + case !k.ExpiresAt.IsZero() && !now.Before(k.ExpiresAt): + return "expired" + default: + return "active" + } +} diff --git a/internal/web/static/base.css b/internal/web/static/base.css index 4cfa5e45..9ed6debd 100644 --- a/internal/web/static/base.css +++ b/internal/web/static/base.css @@ -317,3 +317,43 @@ tr.editing form { margin: .4rem 0; } difference has to survive a glance. */ .chip.on { background: var(--accent); border-color: var(--accent); color: var(--bg); font-weight: 600; } + +/* People: the tenancy tree with its people and keys. Indented by level and + ruled on the left, so which team a project is in reads at a glance without + a table per level. */ +.org { background: var(--card); border: 1px solid var(--line); border-radius: 8px; + padding: .9rem 1rem; margin: 0 0 1rem; } +.team { margin: .8rem 0 0 .4rem; padding-left: .9rem; border-left: 2px solid var(--line); } +.node-head { display: flex; align-items: center; gap: .4rem .9rem; flex-wrap: wrap; } +.node-name { font-weight: 600; } +.org > .node-head .node-name { font-size: 1.05rem; } +.node-meta { color: var(--muted); font-size: .82rem; font-weight: 400; } +details.edit { display: inline-block; font-size: .85rem; } +details.edit > summary { cursor: pointer; color: var(--accent); list-style: none; + text-decoration: underline; } +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.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; + gap: .8rem; align-items: baseline; } +details.project[open] > summary { border-bottom: 1px solid var(--line); } +.project-body { padding: .6rem .7rem .8rem; display: grid; gap: .7rem; } +table.compact { font-size: .83rem; } +table.compact th, table.compact td { padding: .35rem .55rem; } +form.add { display: flex; gap: .4rem; margin: .5rem 0 0; } +form.add input[type=text], details.edit input[type=text] { margin: 0; padding: .35rem .55rem; } +form.add input[type=text] { max-width: 18rem; } +form.add button, button.small { padding: .3rem .7rem; font-size: .82rem; } +form.org-add { margin: 0 0 1rem; } +td.actions { white-space: nowrap; } +td.actions form + form { margin-left: .6rem; } +.tag { font-size: .7rem; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; + color: var(--accent); border: 1px solid var(--accent); border-radius: 4px; + padding: 0 .3rem; margin-left: .3rem; } +input.search { width: 100%; max-width: 26rem; margin: 0 0 .7rem; padding: .45rem .6rem; + font: inherit; border: 1px solid var(--line); border-radius: 6px; + background: var(--card); color: var(--fg); } +tr[hidden] { display: none; } diff --git a/internal/web/static/htmx-2.0.11.min.js b/internal/web/static/htmx-2.0.11.min.js new file mode 100644 index 00000000..e6b8394a --- /dev/null +++ b/internal/web/static/htmx-2.0.11.min.js @@ -0,0 +1 @@ +var htmx=function(){"use strict";const Y={onLoad:null,process:null,on:null,off:null,trigger:null,ajax:null,find:null,findAll:null,closest:null,values:function(e,t){const n=pn(e,t||"post");return n.values},remove:null,addClass:null,removeClass:null,toggleClass:null,takeClass:null,swap:null,defineExtension:null,removeExtension:null,logAll:null,logNone:null,logger:null,config:{historyEnabled:true,historyCacheSize:10,refreshOnHistoryMiss:false,defaultSwapStyle:"innerHTML",defaultSwapDelay:0,defaultSettleDelay:20,includeIndicatorStyles:true,indicatorClass:"htmx-indicator",requestClass:"htmx-request",addedClass:"htmx-added",settlingClass:"htmx-settling",swappingClass:"htmx-swapping",allowEval:true,allowScriptTags:true,inlineScriptNonce:"",inlineStyleNonce:"",attributesToSettle:["class","style","width","height"],withCredentials:false,timeout:0,wsReconnectDelay:"full-jitter",wsBinaryType:"blob",disableSelector:"[hx-disable], [data-hx-disable]",scrollBehavior:"instant",defaultFocusScroll:false,getCacheBusterParam:false,globalViewTransitions:false,methodsThatUseUrlParams:["get","delete"],selfRequestsOnly:true,ignoreTitle:false,scrollIntoViewOnBoost:true,triggerSpecsCache:null,disableInheritance:false,responseHandling:[{code:"204",swap:false},{code:"[23]..",swap:true},{code:"[45]..",swap:false,error:true}],allowNestedOobSwaps:true,historyRestoreAsHxRequest:true,reportValidityOfForms:false},parseInterval:null,location:location,_:null,version:"2.0.11"};Y.onLoad=V;Y.process=Bt;Y.on=ye;Y.off=xe;Y.trigger=ae;Y.ajax=In;Y.find=a;Y.findAll=x;Y.closest=g;Y.remove=z;Y.addClass=w;Y.removeClass=S;Y.toggleClass=G;Y.takeClass=W;Y.swap=C;Y.defineExtension=zn;Y.removeExtension=Jn;Y.logAll=$;Y.logNone=_;Y.parseInterval=d;Y._=e;const n={addTriggerHandler:Et,bodyContains:ie,canAccessLocalStorage:U,findThisElement:we,filterValues:xn,swap:C,hasAttribute:s,getAttributeValue:f,getClosestAttributeValue:te,getClosestMatch:A,getExpressionVars:qn,getHeaders:yn,getInputValues:pn,getInternalData:re,getSwapSpecification:vn,getTriggerSpecs:lt,getTarget:Se,makeFragment:D,mergeObjects:se,makeSettleInfo:En,oobSwap:Te,querySelectorExt:ce,settleImmediately:Qt,shouldCancel:dt,triggerEvent:ae,triggerErrorEvent:ue,withExtensions:Vt};const he=["get","post","put","delete","patch"];const R=he.map(function(e){return"[hx-"+e+"], [data-hx-"+e+"]"}).join(", ");function d(e){if(e==undefined){return undefined}let t=NaN;if(e.slice(-2)=="ms"){t=parseFloat(e.slice(0,-2))}else if(e.slice(-1)=="s"){t=parseFloat(e.slice(0,-1))*1e3}else if(e.slice(-1)=="m"){t=parseFloat(e.slice(0,-1))*1e3*60}else{t=parseFloat(e)}return isNaN(t)?undefined:t}function Q(e,t){return e instanceof Element&&e.getAttribute(t)}function s(e,t){return!!e.hasAttribute&&(e.hasAttribute(t)||e.hasAttribute("data-"+t))}function f(e,t){return Q(e,t)||Q(e,"data-"+t)}function u(e){const t=e.parentElement;if(!t&&e.parentNode instanceof ShadowRoot)return e.parentNode;return t}function ee(){return document}function q(e,t){return e.getRootNode?e.getRootNode({composed:t}):ee()}function A(e,t){while(e&&!t(e)){e=u(e)}return e||null}function o(e,t,n){const r=f(t,n);const o=f(t,"hx-disinherit");var i=f(t,"hx-inherit");if(e!==t){if(Y.config.disableInheritance){if(i&&(i==="*"||i.split(" ").indexOf(n)>=0)){return r}else{return null}}if(o&&(o==="*"||o.split(" ").indexOf(n)>=0)){return"unset"}}return r}function te(t,n){let r=null;A(t,function(e){return!!(r=o(t,le(e),n))});if(r!=="unset"){return r}}function h(e,t){return e instanceof Element&&e.matches(t)}function N(e){const t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;const n=t.exec(e);if(n){return n[1].toLowerCase()}else{return""}}function I(e){if("parseHTMLUnsafe"in Document){return Document.parseHTMLUnsafe(e)}const t=new DOMParser;return t.parseFromString(e,"text/html")}function L(e,t){while(t.childNodes.length>0){e.append(t.childNodes[0])}}function r(e){const t=ee().createElement("script");oe(e.attributes,function(e){t.setAttribute(e.name,e.value)});t.textContent=e.textContent;t.async=false;if(Y.config.inlineScriptNonce){t.nonce=Y.config.inlineScriptNonce}return t}function i(e){return e.matches("script")&&(e.type==="text/javascript"||e.type==="module"||e.type==="")}function k(e){if(!Y.config.allowScriptTags){e.querySelectorAll("script").forEach(e=>e.remove());return}Array.from(e.querySelectorAll("script")).forEach(e=>{if(i(e)){const t=r(e);const n=e.parentNode;try{n.insertBefore(t,e)}catch(e){T(e)}finally{e.remove()}}})}function D(e){e=e.replace(/]*)?)>/gi,'");const t=e.replace(/]*)?>[\s\S]*?<\/head>/i,"");const n=N(t);let r;if(n==="html"){r=new DocumentFragment;const i=I(e);L(r,i.body);r.title=i.title}else if(n==="body"){r=new DocumentFragment;const i=I(t);L(r,i.body);r.title=i.title}else{const i=I('");r=i.querySelector("template").content;r.title=i.title;var o=r.querySelector("title");if(o&&o.parentNode===r){o.remove();r.title=o.innerText}}if(r){k(r)}return r}function ne(e){if(e){e()}}function t(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function P(e){return typeof e==="function"}function M(e){return t(e,"Object")}function re(e){const t="htmx-internal-data";let n=e[t];if(!n){n=e[t]={}}return n}function F(t){const n=[];if(t){for(let e=0;e=0}function ie(e){return e.getRootNode({composed:true})===document}function X(e){return e.trim().split(/\s+/)}function se(e,t){return Object.assign({},e,t)}function v(e){try{return JSON.parse(e)}catch(e){T(e);return null}}function U(){const e="htmx:sessionStorageTest";try{sessionStorage.setItem(e,e);sessionStorage.removeItem(e);return true}catch(e){return false}}function j(e){try{const t=new URL(e,window.location.href);e=t.pathname+t.search}catch(e){}if(e!="/"){e=e.replace(/\/+$/,"")}return e}function e(e){return Tn(ee().body,function(){return eval(e)})}function V(t){const e=Y.on("htmx:load",function(e){t(e.detail.elt)});return e}function $(){Y.logger=function(e,t,n){if(console){console.log(t,e,n)}}}function _(){Y.logger=null}function a(e,t){if(typeof e!=="string"){return e.querySelector(t)}else{return a(ee(),e)}}function x(e,t){if(typeof e!=="string"){return e.querySelectorAll(t)}else{return x(ee(),e)}}function b(){return window}function z(e,t){e=E(e);if(t){b().setTimeout(function(){z(e);e=null},t)}else{u(e).removeChild(e)}}function le(e){return e instanceof Element?e:null}function J(e){return e instanceof HTMLElement?e:null}function K(e){return typeof e==="string"?e:null}function p(e){return e instanceof Element||e instanceof Document||e instanceof DocumentFragment?e:null}function w(e,t,n){e=le(E(e));if(!e){return}if(n){b().setTimeout(function(){w(e,t);e=null},n)}else{e.classList&&e.classList.add(t)}}function S(e,t,n){let r=le(E(e));if(!r){return}if(n){b().setTimeout(function(){S(r,t);r=null},n)}else{if(r.classList){r.classList.remove(t);if(r.classList.length===0){r.removeAttribute("class")}}}}function G(e,t){e=E(e);e.classList.toggle(t)}function W(e,t){e=E(e);oe(e.parentElement.children,function(e){S(e,t)});w(le(e),t)}function g(e,t){e=le(E(e));if(e){return e.closest(t)}return null}function l(e,t){return e.substring(0,t.length)===t}function Z(e,t){return e.substring(e.length-t.length)===t}function de(e){const t=e.trim();if(l(t,"<")&&Z(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function m(t,r,n){if(r.indexOf("global ")===0){return m(t,r.slice(7),true)}t=E(t);const o=[];{let t=0;let n=0;for(let e=0;e"){t--}}if(n0){const r=de(o.shift());let e;if(r.indexOf("closest ")===0){e=g(le(t),de(r.slice(8)))}else if(r.indexOf("find ")===0){e=a(p(t),de(r.slice(5)))}else if(r==="next"||r==="nextElementSibling"){e=le(t).nextElementSibling}else if(r.indexOf("next ")===0){e=pe(t,de(r.slice(5)),!!n)}else if(r==="previous"||r==="previousElementSibling"){e=le(t).previousElementSibling}else if(r.indexOf("previous ")===0){e=ge(t,de(r.slice(9)),!!n)}else if(r==="document"){e=document}else if(r==="window"){e=window}else if(r==="body"){e=document.body}else if(r==="root"){e=q(t,!!n)}else if(r==="host"){e=t.getRootNode().host}else{s.push(r)}if(e){i.push(e)}}if(s.length>0){const e=s.join(",");const c=p(q(t,!!n));i.push(...F(c.querySelectorAll(e)))}return i}var pe=function(t,e,n){const r=p(q(t,n)).querySelectorAll(e);for(let e=0;e=0;e--){const o=r[e];if(o.compareDocumentPosition(t)===Node.DOCUMENT_POSITION_FOLLOWING){return o}}};function ce(e,t){if(typeof e!=="string"){return m(e,t)[0]}else{return m(ee().body,e)[0]}}function E(e,t){if(typeof e==="string"){return a(p(t)||document,e)}else{return e}}function me(e,t,n,r){if(P(t)){return{target:ee().body,event:K(e),listener:t,options:n}}else{return{target:E(e),event:K(t),listener:n,options:r}}}function ye(t,n,r,o){Wn(function(){const e=me(t,n,r,o);e.target.addEventListener(e.event,e.listener,e.options)});const e=P(n);return e?n:r}function xe(t,n,r){Wn(function(){const e=me(t,n,r);e.target.removeEventListener(e.event,e.listener)});return P(n)?n:r}const be=ee().createElement("output");function ve(t,n){const e=te(t,n);if(e){if(e==="this"){return[we(t,n)]}else{const r=m(t,e);const o=/(^|,)(\s*)inherit(\s*)($|,)/.test(e);if(o){const i=le(A(t,function(e){return e!==t&&s(le(e),n)}));if(i){r.push(...ve(i,n))}}if(r.length===0){T('The selector "'+e+'" on '+n+" returned no matches!");return[be]}else{return r}}}}function we(e,t){return le(A(e,function(e){return f(le(e),t)!=null}))}function Se(e){const t=te(e,"hx-target");if(t){if(t==="this"){return we(e,"hx-target")}else{return ce(e,t)}}else{const n=re(e);if(n.boosted){return ee().body}else{return e}}}function Ee(e){return Y.config.attributesToSettle.includes(e)}function Ce(t,n){oe(Array.from(t.attributes),function(e){if(!n.hasAttribute(e.name)&&Ee(e.name)){t.removeAttribute(e.name)}});oe(n.attributes,function(e){if(Ee(e.name)){t.setAttribute(e.name,e.value)}})}function Oe(t,e){const n=Kn(e);for(let e=0;e0){s=e.substring(0,e.indexOf(":"));n=e.substring(e.indexOf(":")+1)}else{s=e}o.removeAttribute("hx-swap-oob");o.removeAttribute("data-hx-swap-oob");const r=m(t,n,false);if(r.length){oe(r,function(e){let t;const n=o.cloneNode(true);t=ee().createDocumentFragment();t.appendChild(n);if(!Oe(s,e)){t=p(n)}const r={shouldSwap:true,target:e,fragment:t};if(!ae(e,"htmx:oobBeforeSwap",r))return;e=r.target;if(r.shouldSwap){Re(t);Ve(s,e,e,t,i);He()}oe(i.elts,function(e){ae(e,"htmx:oobAfterSwap",r)})});o.parentNode.removeChild(o)}else{o.parentNode.removeChild(o);ue(ee().body,"htmx:oobErrorNoTarget",{content:o,target:n})}return e}function He(){const e=a("#--htmx-preserve-pantry--");if(e){for(const t of[...e.children]){const n=a("#"+t.id);n.parentNode.moveBefore(t,n);n.remove()}e.remove()}}function Re(e){oe(x(e,"[hx-preserve], [data-hx-preserve]"),function(e){const t=f(e,"id");const n=ee().getElementById(t);if(n!=null){if(e.moveBefore){let e=a("#--htmx-preserve-pantry--");if(e==null){ee().body.insertAdjacentHTML("afterend","
");e=a("#--htmx-preserve-pantry--")}e.moveBefore(n,null)}else{e.parentNode.replaceChild(n,e)}}})}function qe(i,e,s){oe(e.querySelectorAll("[id]"),function(t){const n=Q(t,"id");if(n&&n.length>0){const e=p(i);const r=e&&e.querySelector(CSS.escape(t.tagName)+"#"+CSS.escape(n));if(r&&r!==e){const o=t.cloneNode();Ce(t,r);s.tasks.push(function(){Ce(t,o)})}}})}function Ae(e){return function(){S(e,Y.config.addedClass);Bt(le(e));Ne(p(e));ae(e,"htmx:load")}}function Ne(e){const t="[autofocus]";const n=J(h(e,t)?e:e.querySelector(t));if(n!=null){n.focus()}}function c(e,t,n,r){qe(e,n,r);while(n.childNodes.length>0){const o=n.firstChild;w(le(o),Y.config.addedClass);e.insertBefore(o,t);if(o.nodeType!==Node.TEXT_NODE&&o.nodeType!==Node.COMMENT_NODE){r.tasks.push(Ae(o))}}}function Ie(e,t){let n=0;while(n0}function _e(e,n,r){var t=x(e,"[hx-swap-oob], [data-hx-swap-oob]");oe(t,function(e){if(Y.config.allowNestedOobSwaps||e.parentElement===null){const t=f(e,"hx-swap-oob");if(t!=null){Te(t,e,n,r)}}else{e.removeAttribute("hx-swap-oob");e.removeAttribute("data-hx-swap-oob")}});return t.length>0}function C(d,p,g,m){if(!m){m={}}let y=null;let n=null;let e=function(){ne(m.beforeSwapCallback);d=E(d);const r=m.contextElement&&m.contextElement.isConnected?q(m.contextElement,false):ee();const e=document.activeElement;let t={};t={elt:e,start:e?e.selectionStart:null,end:e?e.selectionEnd:null};const o=En(d);if(g.swapStyle==="textContent"){d.textContent=p}else{let n=typeof p==="string"?D(p):p;o.title=m.title||n.title;if(m.historyRequest){n=n.querySelector("[hx-history-elt],[data-hx-history-elt]")||n}if(m.selectOOB){const s=m.selectOOB.split(",");for(let t=0;t0){b().setTimeout(n,g.settleDelay)}else{n()}};let t=Y.config.globalViewTransitions;if(g.hasOwnProperty("transition")){t=g.transition}const r=m.contextElement||ee();if(t&&ae(r,"htmx:beforeTransition",m.eventInfo)&&typeof Promise!=="undefined"&&document.startViewTransition){const o=new Promise(function(e,t){y=e;n=t});const i=e;e=function(){document.startViewTransition(function(){i();return o})}}try{if(g?.swapDelay&&g.swapDelay>0){b().setTimeout(e,g.swapDelay)}else{e()}}catch(e){ue(r,"htmx:swapError",m.eventInfo);ne(n);throw e}}function ze(e,t,n){const r=e.getResponseHeader(t);if(r.indexOf("{")===0){const o=v(r)||{};for(const i of Object.keys(o)){let e=o[i];if(M(e)){n=e.target!==undefined?e.target:n}else{e={value:e}}ae(n,i,e)}}else{const s=r.split(",");for(let e=0;e0){const s=o[0];if(s==="]"){e--;if(e===0){if(n===null){t=t+"true"}o.shift();t+=")})";try{const l=Tn(r,function(){return Function(t)()},function(){return true});l.source=t;return l}catch(e){ue(ee().body,"htmx:syntax:error",{error:e,source:t});return null}}}else if(s==="["){e++}if(nt(s,n,i)){t+="(("+i+"."+s+") ? ("+i+"."+s+") : (window."+s+"))"}else{t=t+s}n=o.shift()}}}function O(e,t){let n="";while(e.length>0&&!t.test(e[0])){n+=e.shift()}return n}function ot(e){let t;if(e.length>0&&Qe.test(e[0])){e.shift();t=O(e,et).trim();e.shift()}else{t=O(e,Ke)}return t}const it="input, textarea, select";function st(e,t,n){const r=[];const o=tt(t);do{O(o,Ye);const l=o.length;const c=O(o,/[,\[\s]/);if(c!==""){if(c==="every"){const u={trigger:"every"};O(o,Ye);u.pollInterval=d(O(o,/[,\[\s]/));O(o,Ye);var i=rt(e,o,"event");if(i){u.eventFilter=i}r.push(u)}else{const a={trigger:c};var i=rt(e,o,"event");if(i){a.eventFilter=i}O(o,Ye);while(o.length>0&&o[0]!==","){const f=o.shift();if(f==="changed"){a.changed=true}else if(f==="once"){a.once=true}else if(f==="consume"){a.consume=true}else if(f==="delay"&&o[0]===":"){o.shift();a.delay=d(O(o,Ke))}else if(f==="from"&&o[0]===":"){o.shift();if(Qe.test(o[0])){var s=ot(o)}else{var s=O(o,Ke);if(s==="closest"||s==="find"||s==="next"||s==="previous"){o.shift();const h=ot(o);if(h.length>0){s+=" "+h}}}a.from=s}else if(f==="target"&&o[0]===":"){o.shift();a.target=ot(o)}else if(f==="throttle"&&o[0]===":"){o.shift();a.throttle=d(O(o,Ke))}else if(f==="queue"&&o[0]===":"){o.shift();a.queue=O(o,Ke)}else if(f==="root"&&o[0]===":"){o.shift();a[f]=ot(o)}else if(f==="threshold"&&o[0]===":"){o.shift();a[f]=O(o,Ke)}else{ue(e,"htmx:syntax:error",{token:o.shift()})}O(o,Ye)}r.push(a)}}if(o.length===l){ue(e,"htmx:syntax:error",{token:o.shift()})}O(o,Ye)}while(o[0]===","&&o.shift());if(n){n[t]=r}return r}function lt(e){const t=f(e,"hx-trigger");let n=[];if(t){const r=Y.config.triggerSpecsCache;n=r&&r[t]||st(e,t,r)}if(n.length>0){return n}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,it)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function ct(e){re(e).cancelled=true}function ut(e,t,n){const r=re(e);r.timeout=b().setTimeout(function(){if(ie(e)&&r.cancelled!==true){if(!gt(n,e,Ut("hx:poll:trigger",{triggerSpec:n,target:e}))){t(e)}ut(e,t,n)}},n.pollInterval)}function at(e){return location.hostname===e.hostname&&Q(e,"href")&&Q(e,"href").indexOf("#")!==0}function ft(e){return g(e,Y.config.disableSelector)}function ht(t,n,e){if(t instanceof HTMLAnchorElement&&at(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"&&String(Q(t,"method")).toLowerCase()!=="dialog"){n.boosted=true;let r,o;if(t.tagName==="A"){r="get";o=Q(t,"href")}else{const i=Q(t,"method");r=i?i.toLowerCase():"get";o=Q(t,"action");if(o==null||o===""){o=location.href}if(r==="get"&&o.includes("?")){o=o.replace(/\?[^#]+/,"")}}e.forEach(function(e){mt(t,function(e,t){const n=le(e);if(ft(n)){y(n);return}fe(r,o,n,t)},n,e,true)})}}function dt(e,t){if(e.type==="submit"&&t.tagName==="FORM"){return true}else if(e.type==="click"){const n=t.closest('input[type="submit"], button');if(n&&n.form&&n.type==="submit"){return true}const r=t.closest("a");const o=/^#.+/;if(r&&r.href&&!o.test(r.getAttribute("href"))){return true}}return false}function pt(e,t){return re(e).boosted&&e instanceof HTMLAnchorElement&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function gt(e,t,n){const r=e.eventFilter;if(r){try{return r.call(t,n)!==true}catch(e){const o=r.source;ue(ee().body,"htmx:eventFilter:error",{error:e,source:o});return true}}return false}function mt(l,c,e,u,a){const f=re(l);let t;if(u.from){t=m(l,u.from)}else{t=[l]}if(u.changed){if(!("lastValue"in f)){f.lastValue=new WeakMap}t.forEach(function(e){if(!f.lastValue.has(u)){f.lastValue.set(u,new WeakMap)}f.lastValue.get(u).set(e,e.value)})}oe(t,function(i){const s=function(e){if(!ie(l)){i.removeEventListener(u.trigger,s);return}if(pt(l,e)){return}if(a||dt(e,i)){e.preventDefault()}if(gt(u,l,e)){return}const t=re(e);t.triggerSpec=u;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(l)<0){t.handledFor.push(l);if(u.consume){e.stopPropagation()}if(u.target&&e.target){if(!h(le(e.target),u.target)){return}}if(u.once){if(f.triggeredOnce){return}else{f.triggeredOnce=true}}if(u.changed){const n=e.target;const r=n.value;const o=f.lastValue.get(u);if(o.has(n)&&o.get(n)===r){return}o.set(n,r)}if(f.delayed){clearTimeout(f.delayed)}if(f.throttle){return}if(u.throttle>0){if(!f.throttle){ae(l,"htmx:trigger");c(l,e);f.throttle=b().setTimeout(function(){f.throttle=null},u.throttle)}}else if(u.delay>0){f.delayed=b().setTimeout(function(){ae(l,"htmx:trigger");c(l,e)},u.delay)}else{ae(l,"htmx:trigger");c(l,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:u.trigger,listener:s,on:i});i.addEventListener(u.trigger,s)})}let yt=false;let xt=null;function bt(){if(!xt){xt=function(){yt=true};window.addEventListener("scroll",xt);window.addEventListener("resize",xt);setInterval(function(){if(yt){yt=false;oe(ee().querySelectorAll("[hx-trigger*='revealed'],[data-hx-trigger*='revealed']"),function(e){vt(e)})}},200)}}function vt(e){if(!s(e,"data-hx-revealed")&&B(e)){e.setAttribute("data-hx-revealed","true");const t=re(e);if(t.initHash){ae(e,"revealed")}else{e.addEventListener("htmx:afterProcessNode",function(){ae(e,"revealed")},{once:true})}}}function wt(e,t,n,r){const o=function(){if(!n.loaded){n.loaded=true;ae(e,"htmx:trigger");t(e)}};if(r>0){b().setTimeout(o,r)}else{o()}}function St(t,n,e){let i=false;oe(he,function(r){if(s(t,"hx-"+r)){const o=f(t,"hx-"+r);i=true;n.path=o;n.verb=r;e.forEach(function(e){Et(t,e,n,function(e,t){const n=le(e);if(ft(n)){y(n);return}fe(r,o,n,t)})})}});return i}function Et(r,e,t,n){if(e.trigger==="revealed"){bt();mt(r,n,t,e);vt(le(r))}else if(e.trigger==="intersect"){const o={};if(e.root){o.root=ce(r,e.root)}if(e.threshold){o.threshold=parseFloat(e.threshold)}const i=new IntersectionObserver(function(t){for(let e=0;e0){t.polling=true;ut(le(r),n,e)}else{mt(r,n,t,e)}}function Ct(e){const t=le(e);if(!t){return false}const n=t.attributes;for(let e=0;e", "+e).join(""));return o}else{return[]}}function qt(e){const t=Nt(e.target);const n=Lt(e);if(n){n.lastButtonClicked=t}}function At(e){const t=Lt(e);if(t){t.lastButtonClicked=null}}function Nt(e){return g(le(e),"button, input[type='submit']")}function It(e){return e.form||g(e,"form")}function Lt(e){const t=Nt(e.target);if(!t){return}const n=It(t);if(!n){return}return re(n)}function kt(e){e.addEventListener("click",qt);e.addEventListener("focusin",qt);e.addEventListener("focusout",At)}function Dt(t,e,n){const r=re(t);if(!Array.isArray(r.onHandlers)){r.onHandlers=[]}let o;const i=function(e){Tn(t,function(){if(ft(t)){return}if(!o){o=new Function("event",n)}o.call(t,e)})};t.addEventListener(e,i);r.onHandlers.push({event:e,listener:i})}function Pt(t){ke(t);for(let e=0;eY.config.historyCacheSize){i.shift()}while(i.length>0){try{sessionStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){ue(ee().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function Kt(t){if(!U()){return null}t=j(t);const n=v(sessionStorage.getItem("htmx-history-cache"))||[];for(let e=0;e=200&&this.status<400){r.response=this.response;ae(ee().body,"htmx:historyCacheMissLoad",r);C(r.historyElt,r.response,n,{contextElement:r.historyElt,historyRequest:true});_t(r.path);ae(ee().body,"htmx:historyRestore",{path:e,cacheMiss:true,serverResponse:r.response})}else{ue(ee().body,"htmx:historyCacheMissLoadError",r)}};if(ae(ee().body,"htmx:historyCacheMiss",r)){t.send()}}function tn(e){Wt();e=e||location.pathname+location.search;const t=Kt(e);if(t){const n={swapStyle:"innerHTML",swapDelay:0,settleDelay:0,scroll:t.scroll};const r={path:e,item:t,historyElt:zt(),swapSpec:n};if(ae(ee().body,"htmx:historyCacheHit",r)){C(r.historyElt,t.content,n,{contextElement:r.historyElt,title:t.title});_t(r.path);ae(ee().body,"htmx:historyRestore",r)}}else{if(Y.config.refreshOnHistoryMiss){Y.location.reload(true)}else{en(e)}}}function nn(e){let t=ve(e,"hx-indicator");if(t==null){t=[e]}oe(t,function(e){const t=re(e);t.requestCount=(t.requestCount||0)+1;w(e,Y.config.requestClass)});return t}function rn(e){let t=ve(e,"hx-disabled-elt");if(t==null){t=[]}oe(t,function(e){const t=re(e);t.requestCount=(t.requestCount||0)+1;if(!e.hasAttribute("disabled")){e.setAttribute("disabled","");e.setAttribute("data-disabled-by-htmx","")}});return t}function on(e,t){oe(e.concat(t),function(e){const t=re(e);t.requestCount=(t.requestCount||1)-1});oe(e,function(e){const t=re(e);if(t.requestCount===0){S(e,Y.config.requestClass)}});oe(t,function(e){const t=re(e);if(t.requestCount===0&&e.hasAttribute("data-disabled-by-htmx")){e.removeAttribute("disabled");e.removeAttribute("data-disabled-by-htmx")}})}function sn(t,n){for(let e=0;en.indexOf(e)<0)}else{e=e.filter(e=>e!==n)}r.delete(t);oe(e,e=>r.append(t,e))}}function an(e){if(e instanceof HTMLSelectElement&&e.multiple){return F(e.querySelectorAll("option:checked")).map(function(e){return e.value})}if(e instanceof HTMLInputElement&&e.files){return F(e.files)}return e.value}function fn(t,n,r,e,o){if(e==null||sn(t,e)){return}else{t.push(e)}if(ln(e)){const i=Q(e,"name");cn(i,an(e),n);if(o){hn(e,r)}}if(e instanceof HTMLFormElement){oe(e.elements,function(e){if(t.indexOf(e)>=0){un(e.name,an(e),n)}else{t.push(e)}if(o){hn(e,r)}});new FormData(e).forEach(function(e,t){if(e instanceof File&&e.name===""){return}cn(t,e,n)})}}function hn(e,t){const n=e;if(n.willValidate){ae(n,"htmx:validation:validate");if(!n.checkValidity()){if(ae(n,"htmx:validation:failed",{message:n.validationMessage,validity:n.validity})&&!t.length&&Y.config.reportValidityOfForms){n.reportValidity()}t.push({elt:n,message:n.validationMessage,validity:n.validity})}}}function dn(n,e){for(const t of e.keys()){n.delete(t)}e.forEach(function(e,t){n.append(t,e)});return n}function pn(e,t){const n=[];const r=new FormData;const o=new FormData;const i=[];const s=re(e);if(s.lastButtonClicked&&!ie(s.lastButtonClicked)){s.lastButtonClicked=null}let l=e instanceof HTMLFormElement&&e.noValidate!==true||f(e,"hx-validate")==="true";if(s.lastButtonClicked){l=l&&s.lastButtonClicked.formNoValidate!==true}if(t!=="get"){fn(n,o,i,It(e),l)}fn(n,r,i,e,l);if(s.lastButtonClicked||e.tagName==="BUTTON"||e.tagName==="INPUT"&&Q(e,"type")==="submit"){const u=s.lastButtonClicked||e;const a=Q(u,"name");cn(a,u.value,o)}const c=ve(e,"hx-include");oe(c,function(e){fn(n,r,i,le(e),l);if(!h(e,"form")){oe(p(e).querySelectorAll(it),function(e){fn(n,r,i,e,l)})}});dn(r,o);return{errors:i,formData:r,values:Mn(r)}}function gn(e,t,n){if(e!==""){e+="&"}if(String(n)==="[object Object]"){n=JSON.stringify(n)}const r=encodeURIComponent(n);e+=encodeURIComponent(t)+"="+r;return e}function mn(e){e=Dn(e);let n="";e.forEach(function(e,t){n=gn(n,t,e)});return n}function yn(e,t,n){const r={"HX-Request":"true","HX-Trigger":Q(e,"id"),"HX-Trigger-Name":Q(e,"name"),"HX-Target":f(t,"id"),"HX-Current-URL":location.href};On(e,"hx-headers",false,r);if(n!==undefined){r["HX-Prompt"]=n}if(re(e).boosted){r["HX-Boosted"]="true"}return r}function xn(n,e){const t=te(e,"hx-params");if(t){if(t==="none"){return new FormData}else if(t==="*"){return n}else if(t.indexOf("not ")===0){oe(t.slice(4).split(","),function(e){e=e.trim();n.delete(e)});return n}else{const r=new FormData;oe(t.split(","),function(t){t=t.trim();if(n.has(t)){n.getAll(t).forEach(function(e){r.append(t,e)})}});return r}}else{return n}}function bn(e){return!!Q(e,"href")&&Q(e,"href").indexOf("#")>=0}function vn(e,t){const n=t||te(e,"hx-swap");const r={swapStyle:re(e).boosted?"innerHTML":Y.config.defaultSwapStyle,swapDelay:Y.config.defaultSwapDelay,settleDelay:Y.config.defaultSettleDelay};if(Y.config.scrollIntoViewOnBoost&&re(e).boosted&&!bn(e)){r.show="top"}if(n){const s=X(n);if(s.length>0){for(let e=0;e0?o.join(":"):null;r.scroll=u;r.scrollTarget=i}else if(l.indexOf("show:")===0){const a=l.slice(5);var o=a.split(":");const f=o.pop();var i=o.length>0?o.join(":"):null;r.show=f;r.showTarget=i}else if(l.indexOf("focus-scroll:")===0){const h=l.slice("focus-scroll:".length);r.focusScroll=h=="true"}else if(e==0){r.swapStyle=l}else{T("Unknown modifier in hx-swap: "+l)}}}}return r}function wn(e){return te(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&Q(e,"enctype")==="multipart/form-data"}function Sn(t,n,r){let o=null;Vt(n,function(e){if(o==null){o=e.encodeParameters(t,r,n)}});if(o!=null){return o}else{if(wn(n)){return dn(new FormData,Dn(r))}else{return mn(r)}}}function En(e){return{tasks:[],elts:[e]}}function Cn(e,t){const n=e[0];const r=e[e.length-1];if(t.scroll){var o=null;if(t.scrollTarget){o=le(ce(n,t.scrollTarget))}if(t.scroll==="top"&&(n||o)){o=o||n;o.scrollTop=0}if(t.scroll==="bottom"&&(r||o)){o=o||r;o.scrollTop=o.scrollHeight}if(typeof t.scroll==="number"){b().setTimeout(function(){window.scrollTo(0,t.scroll)},0)}}if(t.show){var o=null;if(t.showTarget){let e=t.showTarget;if(t.showTarget==="window"){e="body"}o=le(ce(n,e))}if(t.show==="top"&&(n||o)){o=o||n;o.scrollIntoView({block:"start",behavior:Y.config.scrollBehavior})}if(t.show==="bottom"&&(r||o)){o=o||r;o.scrollIntoView({block:"end",behavior:Y.config.scrollBehavior})}}}function On(r,e,o,i,s){if(i==null){i={}}if(r==null){return i}const l=f(r,e);if(l){let e=l.trim();let t=o;if(e==="unset"){return null}if(e.indexOf("javascript:")===0){e=e.slice(11);t=true}else if(e.indexOf("js:")===0){e=e.slice(3);t=true}if(e.indexOf("{")!==0){e="{"+e+"}"}let n;if(t){n=Tn(r,function(){if(s){return Function("event","return ("+e+")").call(r,s)}else{return Function("return ("+e+")").call(r)}},{})}else{n=v(e)}for(const c of Object.keys(n)){if(i[c]==null){i[c]=n[c]}}}return On(le(u(r)),e,o,i,s)}function Tn(e,t,n){if(Y.config.allowEval){return t()}else{ue(e,"htmx:evalDisallowedError");return n}}function Hn(e,t,n){return On(e,"hx-vars",true,n,t)}function Rn(e,t,n){return On(e,"hx-vals",false,n,t)}function qn(e,t){return se(Hn(e,t),Rn(e,t))}function An(t,n,r){if(r!==null){try{t.setRequestHeader(n,r)}catch(e){t.setRequestHeader(n,encodeURIComponent(r));t.setRequestHeader(n+"-URI-AutoEncoded","true")}}}function Nn(t){if(t.responseURL){try{const e=new URL(t.responseURL);return e.pathname+e.search}catch(e){ue(ee().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function H(e,t){return e.getResponseHeader(t)!==null}function In(t,n,r){t=t.toLowerCase();if(r){if(r instanceof Element||typeof r==="string"){return fe(t,n,null,null,{targetOverride:E(r)||be,returnPromise:true})}else{let e=E(r.target);if(r.target&&!e||r.source&&!e&&!E(r.source)){e=be}return fe(t,n,E(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:e,swapOverride:r.swap,select:r.select,returnPromise:true,push:r.push,replace:r.replace,selectOOB:r.selectOOB})}}else{return fe(t,n,null,null,{returnPromise:true})}}function Ln(e){const t=[];while(e){t.push(e);e=e.parentElement}return t}function kn(e,t,n){const r=new URL(t,location.protocol!=="about:"?location.href:window.origin);const o=location.protocol!=="about:"?location.origin:window.origin;const i=o===r.origin;if(Y.config.selfRequestsOnly){if(!i){return false}}return ae(e,"htmx:validateUrl",se({url:r,sameHost:i},n))}function Dn(e){if(e instanceof FormData)return e;const t=new FormData;for(const n of Object.keys(e)){if(e[n]&&typeof e[n].forEach==="function"){e[n].forEach(function(e){t.append(n,e)})}else if(typeof e[n]==="object"&&!(e[n]instanceof Blob)){t.append(n,JSON.stringify(e[n]))}else{t.append(n,e[n])}}return t}function Pn(r,o,e){return new Proxy(e,{get:function(t,e){if(typeof e==="number")return t[e];if(e==="length")return t.length;if(e==="push"){return function(e){t.push(e);r.append(o,e)}}if(typeof t[e]==="function"){return function(){t[e].apply(t,arguments);r.delete(o);t.forEach(function(e){r.append(o,e)})}}if(t[e]&&t[e].length===1){return t[e][0]}else{return t[e]}},set:function(e,t,n){e[t]=n;r.delete(o);e.forEach(function(e){r.append(o,e)});return true}})}function Mn(o){return new Proxy(o,{get:function(e,t){if(typeof t==="symbol"){const r=Reflect.get(e,t);if(typeof r==="function"){return function(){return r.apply(o,arguments)}}else{return r}}if(t==="toJSON"){return()=>Object.fromEntries(o)}if(t in e){if(typeof e[t]==="function"){return function(){return o[t].apply(o,arguments)}}}const n=o.getAll(t);if(n.length===0){return undefined}else if(n.length===1){return n[0]}else{return Pn(e,t,n)}},set:function(t,n,e){if(typeof n!=="string"){return false}t.delete(n);if(e&&typeof e.forEach==="function"){e.forEach(function(e){t.append(n,e)})}else if(typeof e==="object"&&!(e instanceof Blob)){t.append(n,JSON.stringify(e))}else{t.append(n,e)}return true},deleteProperty:function(e,t){if(typeof t==="string"){e.delete(t)}return true},ownKeys:function(e){return Reflect.ownKeys(Object.fromEntries(e))},getOwnPropertyDescriptor:function(e,t){return Reflect.getOwnPropertyDescriptor(Object.fromEntries(e),t)}})}function fe(t,n,r,o,i,P){let s=null;let l=null;i=i!=null?i:{};if(i.returnPromise&&typeof Promise!=="undefined"){var e=new Promise(function(e,t){s=e;l=t})}if(r==null){r=ee().body}const M=i.handler||Vn;const F=i.select||null;if(!ie(r)){ne(s);return e}const c=i.targetOverride||le(Se(r));if(c==null||c==be){ue(r,"htmx:targetError",{target:te(r,"hx-target")});ne(l);return e}let u=re(r);const a=u.lastButtonClicked;if(a){const A=Q(a,"formaction");if(A!=null){n=A}const N=Q(a,"formmethod");if(N!=null){if(he.includes(N.toLowerCase())){t=N}else{ne(s);return e}}}const f=te(r,"hx-confirm");if(P===undefined){const K=function(e){return fe(t,n,r,o,i,!!e)};const G={target:c,elt:r,path:n,verb:t,triggeringEvent:o,etc:i,issueRequest:K,question:f};if(ae(r,"htmx:confirm",G)===false){ne(s);return e}}let h=r;let d=te(r,"hx-sync");let p=null;let B=false;if(d){const I=d.split(":");const L=I[0].trim();if(L==="this"){h=we(r,"hx-sync")}else{h=le(ce(r,L))}d=(I[1]||"drop").trim();u=re(h);if(d==="drop"&&u.xhr&&u.abortable!==true){ne(s);return e}else if(d==="abort"){if(u.xhr){ne(s);return e}else{B=true}}else if(d==="replace"){ae(h,"htmx:abort")}else if(d.indexOf("queue")===0){const W=d.split(" ");p=(W[1]||"last").trim()}}if(u.xhr){if(u.abortable){ae(h,"htmx:abort")}else{if(p==null){if(o){const k=re(o);if(k&&k.triggerSpec&&k.triggerSpec.queue){p=k.triggerSpec.queue}}if(p==null){p="last"}}if(u.queuedRequests==null){u.queuedRequests=[]}if(p==="first"&&u.queuedRequests.length===0){u.queuedRequests.push(function(){fe(t,n,r,o,i)})}else if(p==="all"){u.queuedRequests.push(function(){fe(t,n,r,o,i)})}else if(p==="last"){u.queuedRequests=[];u.queuedRequests.push(function(){fe(t,n,r,o,i)})}ne(s);return e}}const g=new XMLHttpRequest;u.xhr=g;u.abortable=B;const m=function(){u.xhr=null;u.abortable=false;if(u.queuedRequests!=null&&u.queuedRequests.length>0){const e=u.queuedRequests.shift();e()}};const X=te(r,"hx-prompt");if(X){var y=prompt(X);if(y===null||!ae(r,"htmx:prompt",{prompt:y,target:c})){ne(s);m();return e}}if(f&&!P){if(!confirm(f)){ne(s);m();return e}}let x=yn(r,c,y);if(t!=="get"&&!wn(r)){x["Content-Type"]="application/x-www-form-urlencoded"}if(i.headers){x=se(x,i.headers)}const U=pn(r,t);let b=U.errors;const j=U.formData;if(i.values){dn(j,Dn(i.values))}const V=Dn(qn(r,o));const v=dn(j,V);let w=xn(v,r);if(Y.config.getCacheBusterParam&&t==="get"){w.set("org.htmx.cache-buster",Q(c,"id")||"true")}if(n==null||n===""){n=location.href}const S=On(r,"hx-request");const $=re(r).boosted;let E=Y.config.methodsThatUseUrlParams.indexOf(t)>=0;const C={boosted:$,useUrlParams:E,formData:w,parameters:Mn(w),unfilteredFormData:v,unfilteredParameters:Mn(v),headers:x,elt:r,target:c,verb:t,errors:b,withCredentials:i.credentials||S.credentials||Y.config.withCredentials,timeout:i.timeout||S.timeout||Y.config.timeout,path:n,triggeringEvent:o};if(!ae(r,"htmx:configRequest",C)){ne(s);m();return e}n=C.path;t=C.verb;x=C.headers;w=Dn(C.parameters);b=C.errors;E=C.useUrlParams;if(b&&b.length>0){ae(r,"htmx:validation:halted",C);ne(s);m();return e}const _=n.split("#");const z=_[0];const O=_[1];let T=n;if(E){T=z;const Z=!w.keys().next().done;if(Z){if(T.indexOf("?")<0){T+="?"}else{T+="&"}T+=mn(w);if(O){T+="#"+O}}}if(!kn(r,T,C)){ue(r,"htmx:invalidPath",C);ne(l);m();return e}g.open(t.toUpperCase(),T,true);g.overrideMimeType("text/html");g.withCredentials=C.withCredentials;g.timeout=C.timeout;if(S.noHeaders){}else{for(const D of Object.keys(x)){An(g,D,x[D])}}const H={xhr:g,target:c,requestConfig:C,etc:i,boosted:$,select:F,pathInfo:{requestPath:n,finalRequestPath:T,responsePath:null,anchor:O}};g.onload=function(){try{const t=Ln(r);H.pathInfo.responsePath=Nn(g);M(r,H);if(H.keepIndicators!==true){on(R,q)}ae(r,"htmx:afterRequest",H);ae(r,"htmx:afterOnLoad",H);if(!ie(r)){let e=null;while(t.length>0&&e==null){const n=t.shift();if(ie(n)){e=n}}if(e){ae(e,"htmx:afterRequest",H);ae(e,"htmx:afterOnLoad",H)}}ne(s)}catch(e){ue(r,"htmx:onLoadError",se({error:e},H));throw e}finally{m()}};g.onerror=function(){on(R,q);ue(r,"htmx:afterRequest",H);ue(r,"htmx:sendError",H);ne(l);m()};g.onabort=function(){on(R,q);ue(r,"htmx:afterRequest",H);ue(r,"htmx:sendAbort",H);ne(l);m()};g.ontimeout=function(){on(R,q);ue(r,"htmx:afterRequest",H);ue(r,"htmx:timeout",H);ne(l);m()};if(!ae(r,"htmx:beforeRequest",H)){ne(s);m();return e}var R=nn(r);var q=rn(r);oe(["loadstart","loadend","progress","abort"],function(t){oe([g,g.upload],function(e){e.addEventListener(t,function(e){ae(r,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});ae(r,"htmx:beforeSend",H);const J=E?null:Sn(g,r,w);g.send(J);return e}function Fn(e,t){const n=t.xhr;let r=null;let o=null;if(H(n,"HX-Push")){r=n.getResponseHeader("HX-Push");o="push"}else if(H(n,"HX-Push-Url")){r=n.getResponseHeader("HX-Push-Url");o="push"}else if(H(n,"HX-Replace-Url")){r=n.getResponseHeader("HX-Replace-Url");o="replace"}if(r){if(r==="false"){return{}}else{return{type:o,path:r}}}const i=t.pathInfo.finalRequestPath;const s=t.pathInfo.responsePath;const l=t.etc.push||te(e,"hx-push-url");let c=t.etc.replace||te(e,"hx-replace-url");if(c==="false")c=null;const u=re(e).boosted;let a=null;let f=null;if(l){a="push";f=l}else if(c){a="replace";f=c}else if(u){a="push";f=s||i}if(f){if(f==="false"){return{}}if(f==="true"){f=s||i}if(t.pathInfo.anchor&&f.indexOf("#")===-1){f=f+"#"+t.pathInfo.anchor}return{type:a,path:f}}else{return{}}}function Bn(e,t){var n=new RegExp(e.code);return n.test(t.toString(10))}function Xn(e){for(var t=0;t`+`.${t}{opacity:0;visibility: hidden} `+`.${n} .${t}, .${n}.${t}{opacity:1;visibility: visible;transition: opacity 200ms ease-in}`+"")}}function Yn(){const e=ee().querySelector('meta[name="htmx-config"]');if(e){return v(e.content)}else{return null}}function Qn(){const e=Yn();if(e){Y.config=se(Y.config,e)}}Wn(function(){Qn();Zn();let e=ee().body;Bt(e);const t=ee().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){const t=e.detail.elt||e.target;const n=re(t);if(n&&n.xhr){n.xhr.abort()}});const n=window.onpopstate?window.onpopstate.bind(window):null;window.onpopstate=function(e){if(e.state&&e.state.htmx){tn();oe(t,function(e){ae(e,"htmx:restored",{document:ee(),triggerEvent:ae})})}else{if(n){n(e)}}};b().setTimeout(function(){ae(e,"htmx:load",{});e=null},0)});return Y}(); \ No newline at end of file diff --git a/internal/web/templates/admin_users.html b/internal/web/templates/admin_users.html index 0c0385c4..fe893ec1 100644 --- a/internal/web/templates/admin_users.html +++ b/internal/web/templates/admin_users.html @@ -1,11 +1,16 @@ {{define "admin_users"}} -Accounts · llmgw +People · llmgw +{{/* Forms here post and get the whole page back, which works with no script at + all. htmx, when it loads, swaps only #people from that page instead of + reloading it, so an open project stays open and the page does not jump. */}} + +
-

Accounts

-

{{len .Pending}} waiting for approval · {{len .Active}} active

+

People

+

{{len .Pending}} waiting for approval · {{len .Accounts}} accounts · {{len .Projects}} projects

-{{if .Error}}

{{.Error}}

{{end}} -{{if .Message}}

{{.Message}}

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

{{.Message}}

{{end}} -

Waiting for approval

{{if .Pending}} - +

Waiting for approval

+
EmailNameSigned upProject
{{range .Pending}} - + {{end}}
EmailNameSigned upPut them in
{{.Email}}{{.Name}}{{ago .CreatedAt}} + + {{if $.Projects}}
- {{range $.Projects}}{{end}}
-
+ {{else}}Create a project first, below.{{end}} +
+
-{{else}}

Nobody is waiting.

{{end}} - -

Accounts

-{{if .Active}} - -{{range .Active}} - - - - - - -{{end}}
EmailStateAdminProjectLast login
{{.Email}}{{.State}}{{if .IsAdmin}}yes{{else}}—{{end}}{{or .ProjectID "—"}}{{ago .LastLoginAt}} - {{if eq (printf "%s" .State) "active"}} -
- - -
- {{else}} -
- - -
- {{end}} -
-{{else}}

No accounts yet.

{{end}} - -

Organizations, teams and projects

-

Where an account lives. A person is approved into a project, a -project belongs to a team, and a team to an organization — and a quota can be -written on any of the three, so the shape here is the shape the bill takes.

- -
-
- - - -
- -
- - - - -
- -
- - - - -
-
- -{{if not .Orgs}} -

No organizations yet. Create one, then a team in it, then a -project in the team — an account can only be approved into a project.

-{{else}} -{{/* One path column, not three: the store returns the chain already joined, - which is the string the approval chooser shows, and three headers over one - value line up with nothing. */}} - -{{range .Projects}}{{end}} -{{if not .Projects}}{{end}} -
Organization / team / projectID
{{.Path}}{{.ID}}
No projects yet, so nobody can be approved.
{{end}} -

Four things are deliberately not here. Making someone an admin, -resetting a password, issuing keys on another -person's behalf and setting any quota all need a shell on the control plane — llmgw users promote, -reset-password, llmgw keys, llmgw keys limits. -A stolen admin session can approve, suspend and make somewhere to put people; it -cannot hand itself more access, take over an account, or raise what anybody is -allowed to spend.

+

Organizations, teams and projects

+

A person is in a project, a project in a team, a team in an +organization{{if .HasQuotas}}, and quotas can be set +on any of them{{end}}. Open a project to see its people and keys.

+ +{{if not .Tree}} +

No organizations yet. Create one, then a team in it, then a +project in the team — an account can only be approved into a project.

+{{end}} + +{{range $org := .Tree}} +
+
+ {{$org.Name}} + {{len $org.Teams}} team{{if ne (len $org.Teams) 1}}s{{end}} +
rename +
+ + + +
+
+ {{if not $org.Teams}} +
+ + +
+ {{end}} +
+ + {{range $team := $org.Teams}} +
+
+ {{$team.Name}} + {{len $team.Projects}} project{{if ne (len $team.Projects) 1}}s{{end}} +
rename +
+ + + +
+
+ {{if gt (len $.Orgs) 1}} +
move +
+ + + +
+
+ {{end}} + {{if not $team.Projects}} +
+ + +
+ {{end}} +
+ + {{range $p := $team.Projects}} +
+ + {{$p.Name}} + {{$p.Users}} {{if eq $p.Users 1}}person{{else}}people{{end}} + · {{$p.ActiveKeys}} key{{if ne $p.ActiveKeys 1}}s{{end}}{{if gt $p.Keys $p.ActiveKeys}} ({{sub $p.Keys $p.ActiveKeys}} revoked){{end}} + +
+
+
rename +
+ + + + +
+
+ {{if gt (len $.Teams) 1}} +
move to another team +
+ + + + +
+
+ {{end}} + {{if and (eq $p.Users 0) (eq $p.Keys 0)}} +
+ + +
+ {{else}}Removable once it has no people and never had a key.{{end}} +
+ + {{with index $.People $p.ID}} + + {{range .}}{{template "user-row-actions" (dict "U" . "D" $ "Open" $p.ID)}}{{end}} +
PersonStateLast sign-inMove to
+ {{else}}

Nobody is in this project.

{{end}} + + {{with index $.Keys $p.ID}} + + {{range .}} + + + + + + {{end}} +
KeyOwnerStateLast used
{{.Name}} {{.Prefix}}{{or .OwnerEmail "issued by an operator"}}{{.State}}{{ago .LastUsedAt}}{{if eq .State "active"}} +
+ + + +
{{end}}
+ {{end}} +
+
+ {{end}} + +
+ + + + +
+
+ {{end}} + +
+ + + + +
+
+{{end}} + +
+ + + +
+ +

All accounts

+{{if .Accounts}} + +{{range .Accounts}}{{template "user-row-actions" (dict "U" . "D" $ "Open" "" "Path" true)}}{{end}} +
PersonStateProjectLast sign-inMove to
+{{else}}

No accounts yet.

{{end}} + +

Not here on purpose: making someone an admin, resetting a password +and issuing a key for someone else. Each turns a stolen admin session into more +access than it had, so they need a shell on the control plane — +llmgw users promote, llmgw users reset-password, +llmgw keys create.

+
llmgw {{.Version}}{{.Now}}
-
{{end}} + + +{{end}} + +{{define "user-row-actions"}}{{$u := .U}}{{$d := .D}} + + {{$u.Email}}{{if $u.IsAdmin}} admin{{end}} + {{$u.State}} + {{if .Path}}{{or $u.ProjectPath "—"}}{{end}} + {{ago $u.LastLoginAt}} + +
+ + + + +
+ + + {{if ne $u.ID $d.AdminID}} + {{if eq (printf "%s" $u.State) "active"}} +
+ + + +
+ {{else}} +
+ + + +
+ {{end}} +
+ + + +
+ {{else}}you{{end}} + +{{end}} diff --git a/internal/web/web.go b/internal/web/web.go index 475b2046..d955bb3a 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -36,6 +36,8 @@ var files embed.FS var staticFiles embed.FS var funcs = template.FuncMap{ + "lower": strings.ToLower, + "sub": func(a, b int) int { return a - b }, "join": func(v []string) string { if len(v) == 0 { return "—"