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 "—"