Files
llmgw/internal/cli/users.go
T
Ilker ManapandClaude Opus 5.5 3104f4b706 Manage people and the tenancy tree, from the web and the CLI
Nothing in the tree could be renamed, moved or removed, and an approved
person stayed in the project they were first put in — from the web or
the CLI. Now:

- /admin/users is the tree, each project opening onto its people and
  keys, with approve, move, suspend, reinstate and remove for people,
  revoke for keys, and add, rename, move and remove at every level,
  plus a search over every account. Forms post and get the page back,
  so it works with no script; htmx 2.0.11 (0BSD, embedded, checked
  against npm's integrity hash) swaps only the changed part, keeping
  open projects open.
- llmgw org tree/add/rename/move/remove, users move/delete, and
  keys list --all.
- Projects, teams and organizations are named by id, path
  (Acme/platform/gateway) or unique name; users by id or email. With a
  single project --project can be left out, instead of defaulting to
  "default", which a tree built from the web does not have.

Moving a person moves the keys they made; operator keys stay with the
project. Only empty things are removed, and never a project that had a
key, since usage history refers to it.

Also: keys init no longer reparents an existing team or project;
reinstating an account with no project is refused (a rejected signup
used to come back active and fail at its first key); rejecting a
signup removes it; keys limits --budget takes whole units like the
quotas page.

Checked in headless Chrome: a move from an open project updates the
page in place, keeps the project open, and the search still filters.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:26:39 +02:00

358 lines
11 KiB
Go

package cli
import (
"fmt"
"text/tabwriter"
"time"
"github.com/spf13/cobra"
"gitea.mynodes.xyz/ilker/llmgw/internal/account"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
func newUsersCmd() *cobra.Command {
var sf storeFlags
cmd := &cobra.Command{
Use: "users",
Short: "Approve and manage the people who sign up",
Long: "Signing up creates an account that cannot do anything. Approval is what\n" +
"makes it work, and it happens here rather than in a browser: approving is\n" +
"the step that decides who gets to spend the cluster, and it needs a shell\n" +
"on this machine rather than a session cookie.",
}
sf.bind(cmd)
open := func(cmd *cobra.Command) (*store.DB, error) {
return sf.open(cmd)
}
cmd.AddCommand(newUserListCmd(open), newUserApproveCmd(open),
newUserStateCmd(open, "suspend", store.UserSuspended,
"Stop an account working, keeping it and its history"),
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
}
type userOpener func(*cobra.Command) (*store.DB, error)
func newUserListCmd(open userOpener) *cobra.Command {
var pending bool
cmd := &cobra.Command{
Use: "list",
Short: "List accounts",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
s, err := open(cmd)
if err != nil {
return err
}
defer s.Close()
state := store.UserState("")
if pending {
state = store.UserPending
}
users, err := s.Users(cmd.Context(), state)
if err != nil {
return err
}
if len(users) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), "no accounts")
return nil
}
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "ID\tEMAIL\tNAME\tSTATE\tADMIN\tPROJECT\tSIGNED UP\tLAST LOGIN")
now := time.Now()
for _, u := range users {
admin := "-"
if u.IsAdmin {
admin = "yes"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
u.ID, u.Email, u.Name, u.State, admin, dash(u.ProjectID),
ago(u.CreatedAt, now), ago(u.LastLoginAt, now))
}
return w.Flush()
},
}
cmd.Flags().BoolVar(&pending, "pending", false, "only accounts waiting for approval")
return cmd
}
func newUserApproveCmd(open userOpener) *cobra.Command {
var project, by string
cmd := &cobra.Command{
Use: "approve <user>",
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" +
"then fail at the first key it tried to make.",
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
}
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", "",
"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")
return cmd
}
func newUserStateCmd(open userOpener, verb string, state store.UserState, short string) *cobra.Command {
return &cobra.Command{
Use: verb + " <user>",
Short: short,
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 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(), 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", u.Email, state)
return nil
},
}
}
func newUserAdminCmd(open userOpener, verb string, admin bool, short string) *cobra.Command {
return &cobra.Command{
Use: verb + " <user>",
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" +
"the CLI, where it needs a shell rather than a session — which keeps the\n" +
"worst case of a stolen admin cookie to \"saw the cluster\".",
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 err := s.SetAdmin(cmd.Context(), u.ID, admin); err != nil {
return err
}
if admin {
fmt.Fprintf(cmd.OutOrStdout(), "%s can now see /admin\n", u.Email)
} else {
fmt.Fprintf(cmd.OutOrStdout(), "%s can no longer see /admin\n", u.Email)
cmd.PrintErrln("Their session stays open; the page is read-only. " +
"Suspend the account to close it.")
}
return nil
},
}
}
func newUserResetPasswordCmd(open userOpener) *cobra.Command {
var password string
cmd := &cobra.Command{
Use: "reset-password <user>",
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" +
"them out of band, and have them change it from their profile.\n\n" +
"Every session they have open is closed: if they lost the password, the\n" +
"reason might be that somebody else has it.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if password == "" {
return fmt.Errorf("--password is required")
}
s, err := open(cmd)
if err != nil {
return err
}
defer s.Close()
hash, err := account.HashPassword(password)
if err != nil {
return err
}
u, err := resolveUser(cmd.Context(), s, args[0])
if err != nil {
return err
}
if err := s.SetPassword(cmd.Context(), u.ID, hash); err != nil {
return err
}
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
},
}
cmd.Flags().StringVar(&password, "password", "", "the new password")
return cmd
}
func newUserCreateCmd(open userOpener) *cobra.Command {
var project, password string
cmd := &cobra.Command{
Use: "create <email>",
Short: "Create an account directly, already approved",
Long: "For when signup is closed, or for the first account on a new deployment.\n" +
"The password is set here and should be changed by its owner.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if password == "" {
return fmt.Errorf("--password is required")
}
s, err := open(cmd)
if err != nil {
return err
}
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
}
id := randomID("usr")
u := store.User{
ID: id, Email: args[0], Name: args[0],
PasswordHash: hash, State: store.UserPending, CreatedAt: time.Now(),
}
if err := s.CreateUser(cmd.Context(), u); err != nil {
return err
}
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 %s\n", args[0], p.Path)
return nil
},
}
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 <user> --project <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 <user>",
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
}