package console import ( "context" "encoding/json" "fmt" "strconv" "strings" "time" "git.golem15.com/golem15/sm-user-plugin/classes" "git.golem15.com/golem15/sm-user-plugin/models" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bonfire" "gorm.io/gorm" ) // APITokenCommands are the application-wide token create/list/revoke // commands. Domain plugins deliberately do not mint their own credentials. // withDB is the plugin's database accessor so the commands work both during // serve and as one-shot CLI runs that open Postgres from config. func APITokenCommands(app *backpack.App, withDB func(context.Context, func(*gorm.DB) error) error) []bonfire.Command { open := func(ctx context.Context, fn func(*gorm.DB) error) error { if withDB == nil { return fmt.Errorf("golem15.user: database is not configured") } return withDB(ctx, fn) } return []bonfire.Command{ { Name: "user:token:create", Description: "Create one API token usable by application plugins", Long: `Mints a personal API token that acts as the user with this e-mail, for scripts and integrations that call the application's API. --scopes limits what it may do (read, write, ai); --expires sets an RFC3339 expiry, and without it the token does not expire. The secret is printed once, on its own line (also under -q), and only its hash is stored, so keep it now; with --json the token and its details are printed as one JSON object instead. Revoke a token with user:token:revoke and list them with user:token:list.`, Args: []bonfire.Arg{{Name: "email", Description: "E-mail of the user the token acts as", Required: true}}, Flags: []bonfire.Flag{ {Name: "name", Description: "What the token is for", Default: "api"}, {Name: "scopes", Description: "Comma-separated scopes: read, write, ai", Default: "read,write"}, {Name: "expires", Description: "RFC3339 expiry; empty means no expiry"}, {Name: "json", Description: "Print the token and its details as JSON", Bare: true}, }, Examples: []bonfire.Example{ {Command: "user:token:create jane@example.com --name backup-script --scopes read", Description: "A read-only token for a script"}, {Command: "user:token:create jane@example.com --expires 2027-01-01T00:00:00Z", Description: "A read and write token that expires"}, {Command: "user:token:create jane@example.com --json", Description: "Print the token and its details as JSON"}, }, Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { email, _ := in.Argument("email") name, _ := in.Flag("name") rawScopes, _ := in.Flag("scopes") scopes := []string{} for _, scope := range strings.Split(rawScopes, ",") { if scope = strings.TrimSpace(scope); scope != "" { scopes = append(scopes, scope) } } var expiresAt *time.Time if raw, _ := in.Flag("expires"); strings.TrimSpace(raw) != "" { parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(raw)) if err != nil { return fmt.Errorf("user:token:create: --expires: %w", err) } expiresAt = &parsed } return open(ctx, func(db *gorm.DB) error { user, err := classes.LookupByEmail(ctx, db, strings.TrimSpace(email)) if err != nil { return err } if user == nil { return fmt.Errorf("user:token:create: no user with the e-mail %q", email) } prefix, err := classes.APITokenPrefix(app) if err != nil { return err } secret, token, err := classes.MintAPIToken(prefix, user.ID, name, scopes, expiresAt, nil) if err != nil { return fmt.Errorf("user:token:create: %w", err) } if err := db.Create(token).Error; err != nil { return fmt.Errorf("user:token:create: %w", err) } if flagOn(in, "json") { return printJSON(out, map[string]any{ "id": token.ID, "user_id": token.UserID, "email": user.Email, "name": token.Name, "token": secret, "scopes": token.Scopes.Get(), "expires_at": token.ExpiresAt, }) } out.Printf("token %d for %s, scopes %s\n", token.ID, email, strings.Join(token.Scopes.Get(), ",")) out.Println(secret) out.Success("user:token:create: store the secret now; it is not shown again") return nil }) }, }, { Name: "user:token:list", Description: "List API tokens", Long: `Lists every personal API token: its id, the user it acts as, its name, its scopes and its state (active, expired or revoked). Secrets are never shown; only their hashes are stored. --json prints the tokens as a JSON array with their expiry, revocation and last use times.`, Flags: []bonfire.Flag{ {Name: "json", Description: "Print the tokens as JSON", Bare: true}, }, Examples: []bonfire.Example{ {Command: "user:token:list", Description: "Show every token as a table"}, {Command: "user:token:list --json", Description: "Print the tokens as JSON for a script"}, }, Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { return open(ctx, func(db *gorm.DB) error { var tokens []models.APIToken if err := db.Order("id").Find(&tokens).Error; err != nil { return fmt.Errorf("user:token:list: %w", err) } rows := make([][]string, 0, len(tokens)) list := make([]map[string]any, 0, len(tokens)) for _, token := range tokens { state := "active" if token.RevokedAt != nil { state = "revoked" } else if !token.IsUsableAt(time.Now()) { state = "expired" } name := "" if token.Name != nil { name = *token.Name } rows = append(rows, []string{strconv.FormatUint(uint64(token.ID), 10), strconv.FormatUint(uint64(token.UserID), 10), name, strings.Join(token.Scopes.Get(), ","), state}) list = append(list, map[string]any{ "id": token.ID, "user_id": token.UserID, "name": token.Name, "scopes": token.Scopes.Get(), "state": state, "expires_at": token.ExpiresAt, "revoked_at": token.RevokedAt, "last_used_at": token.LastUsedAt, "created_at": token.CreatedAt, }) } if flagOn(in, "json") { return printJSON(out, list) } out.Table([]string{"id", "user", "name", "scopes", "state"}, rows) return nil }) }, }, { Name: "user:token:revoke", Description: "Revoke an API token", Long: `Revokes the active API token with this id (see user:token:list): requests that present it are refused from now on. The token row stays, marked revoked; a token that is already revoked or does not exist is an error.`, Args: []bonfire.Arg{{Name: "id", Description: "Token id", Required: true}}, Examples: []bonfire.Example{ {Command: "user:token:revoke 12", Description: "Revoke the token with id 12"}, }, Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { raw, _ := in.Argument("id") id, err := strconv.ParseUint(raw, 10, 64) if err != nil || id == 0 { return fmt.Errorf("user:token:revoke: %q is not a token id", raw) } return open(ctx, func(db *gorm.DB) error { res := db.Model(&models.APIToken{}).Where("id = ? AND revoked_at IS NULL", id).UpdateColumn("revoked_at", time.Now()) if res.Error != nil { return fmt.Errorf("user:token:revoke: %w", res.Error) } if res.RowsAffected == 0 { return fmt.Errorf("user:token:revoke: no active token with id %d", id) } out.Success(fmt.Sprintf("user:token:revoke: token %d revoked", id)) return nil }) }, }, } } // printJSON writes v as indented JSON and a newline on out: data, printed // under -q too. func printJSON(out bonfire.Output, v any) error { b, err := json.MarshalIndent(v, "", " ") if err != nil { return err } out.Println(string(b)) return nil } // flagOn reads a bare flag ("true" when given). func flagOn(in bonfire.Input, name string) bool { v, _ := in.Flag(name) switch strings.ToLower(strings.TrimSpace(v)) { case "true", "1", "yes": return true } return false }