mirror of
https://github.com/immich-app/yucca.git
synced 2026-09-30 21:37:50 +08:00
507 lines
13 KiB
Go
507 lines
13 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"text/tabwriter"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"yuctl/internal/adminapi"
|
|
)
|
|
|
|
// newUsersCmd builds the `users` subtree.
|
|
func newUsersCmd() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "users",
|
|
Short: "User administration via yucca-admin-api",
|
|
}
|
|
cmd.AddCommand(newUsersListCmd())
|
|
cmd.AddCommand(newUsersAllowlistCmd())
|
|
cmd.AddCommand(newUsersViewDashboardCmd())
|
|
cmd.AddCommand(newUsersFeaturesCmd())
|
|
cmd.AddCommand(newUsersConnectionsCmd())
|
|
return cmd
|
|
}
|
|
|
|
// newUsersFeaturesCmd builds `users features`: per-user feature-flag overrides.
|
|
func newUsersFeaturesCmd() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "features",
|
|
Short: "Per-user feature-flag overrides",
|
|
}
|
|
cmd.AddCommand(newUsersFeaturesListCmd())
|
|
cmd.AddCommand(newUsersFeaturesSetCmd())
|
|
cmd.AddCommand(newUsersFeaturesClearCmd())
|
|
return cmd
|
|
}
|
|
|
|
func newUsersFeaturesListCmd() *cobra.Command {
|
|
flags := &adminFlags{}
|
|
c := &cobra.Command{
|
|
Use: "list <email>",
|
|
Short: "Show a user's resolved feature flags and overrides",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
ctx := cmd.Context()
|
|
client, _, err := flags.allowlistClient(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
userID, err := resolveUserID(ctx, client, args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
features, err := client.GetUserFeatures(ctx, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
overridden := map[string]adminapi.FeatureOverride{}
|
|
for _, o := range features.Overrides {
|
|
overridden[o.Flag] = o
|
|
}
|
|
|
|
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 2, 2, ' ', 0)
|
|
fmt.Fprintln(w, "FLAG\tVALUE\tSOURCE\tSET BY\tREASON")
|
|
for flag, value := range features.Features {
|
|
if o, ok := overridden[flag]; ok {
|
|
reason := ""
|
|
if o.Reason != nil {
|
|
reason = *o.Reason
|
|
}
|
|
fmt.Fprintf(w, "%s\t%t\toverride\t%s\t%s\n", flag, value, o.SetBy, reason)
|
|
} else {
|
|
fmt.Fprintf(w, "%s\t%t\tdefault\t\t\n", flag, value)
|
|
}
|
|
}
|
|
w.Flush()
|
|
return nil
|
|
},
|
|
}
|
|
flags.register(c)
|
|
return c
|
|
}
|
|
|
|
func newUsersFeaturesSetCmd() *cobra.Command {
|
|
flags := &adminFlags{}
|
|
var reason string
|
|
c := &cobra.Command{
|
|
Use: "set <email> <flag> on|off",
|
|
Short: "Set a per-user feature-flag override",
|
|
Args: cobra.ExactArgs(3),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
var value bool
|
|
switch args[2] {
|
|
case "on", "true":
|
|
value = true
|
|
case "off", "false":
|
|
value = false
|
|
default:
|
|
return fmt.Errorf("value must be on|off, got %q", args[2])
|
|
}
|
|
|
|
ctx := cmd.Context()
|
|
client, _, err := flags.allowlistClient(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
userID, err := resolveUserID(ctx, client, args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := client.SetUserFeature(ctx, userID, args[1], value, reason); err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(cmd.ErrOrStderr(), "%s set to %t for %s\n", args[1], value, args[0])
|
|
return nil
|
|
},
|
|
}
|
|
c.Flags().StringVar(&reason, "reason", "", "audit note stored on the override")
|
|
flags.register(c)
|
|
return c
|
|
}
|
|
|
|
func newUsersFeaturesClearCmd() *cobra.Command {
|
|
flags := &adminFlags{}
|
|
c := &cobra.Command{
|
|
Use: "clear <email> <flag>",
|
|
Short: "Clear an override (revert to the registry default)",
|
|
Args: cobra.ExactArgs(2),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
ctx := cmd.Context()
|
|
client, _, err := flags.allowlistClient(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
userID, err := resolveUserID(ctx, client, args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := client.ClearUserFeature(ctx, userID, args[1]); err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(cmd.ErrOrStderr(), "%s override cleared for %s\n", args[1], args[0])
|
|
return nil
|
|
},
|
|
}
|
|
flags.register(c)
|
|
return c
|
|
}
|
|
|
|
// newUsersConnectionsCmd builds `users connections`.
|
|
func newUsersConnectionsCmd() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "connections",
|
|
Short: "A user's connection instances (immich/restic)",
|
|
}
|
|
|
|
flags := &adminFlags{}
|
|
list := &cobra.Command{
|
|
Use: "list <email>",
|
|
Short: "List a user's connections",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
ctx := cmd.Context()
|
|
client, _, err := flags.allowlistClient(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
userID, err := resolveUserID(ctx, client, args[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
connections, err := client.GetUserConnections(ctx, userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 2, 2, ' ', 0)
|
|
fmt.Fprintln(w, "ID\tTYPE\tNAME\tCREATED\tLAST SEEN")
|
|
for _, connection := range connections {
|
|
lastSeen := ""
|
|
if connection.LastSeenAt != nil {
|
|
lastSeen = *connection.LastSeenAt
|
|
}
|
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", connection.ID, connection.Type, connection.Name, connection.CreatedAt, lastSeen)
|
|
}
|
|
w.Flush()
|
|
return nil
|
|
},
|
|
}
|
|
flags.register(list)
|
|
cmd.AddCommand(list)
|
|
return cmd
|
|
}
|
|
|
|
const defaultGrafanaURL = "https://grafana.futostatus.com"
|
|
|
|
func newUsersViewDashboardCmd() *cobra.Command {
|
|
flags := &adminFlags{}
|
|
var userID, email, grafanaURL string
|
|
var noOpen bool
|
|
c := &cobra.Command{
|
|
Use: "view-dashboard",
|
|
Short: "Open the per-user Grafana dashboard for a user",
|
|
Long: "Build the Grafana per-user drill-down URL (dashboard uid yucca-per-user) for\n" +
|
|
"a user and open it in the browser. --id builds the URL without contacting the\n" +
|
|
"admin-api; --email resolves the user via the partition's admin-api first.",
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
id := userID
|
|
if email != "" {
|
|
client, partition, err := flags.allowlistClient(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
users, err := client.ListUsers(cmd.Context(), 0)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
id = ""
|
|
for _, u := range users {
|
|
if strings.EqualFold(u.Email, email) {
|
|
id = u.ID
|
|
break
|
|
}
|
|
}
|
|
if id == "" {
|
|
return fmt.Errorf("no user with email %q in partition %s", email, partition)
|
|
}
|
|
}
|
|
|
|
base := grafanaURL
|
|
if base == "" {
|
|
base = os.Getenv("YUCTL_GRAFANA_URL")
|
|
}
|
|
if base == "" {
|
|
base = defaultGrafanaURL
|
|
}
|
|
dashboardURL := strings.TrimRight(base, "/") + "/d/yucca-per-user?var-user=" + url.QueryEscape(id)
|
|
|
|
fmt.Fprintln(cmd.OutOrStdout(), dashboardURL)
|
|
if noOpen {
|
|
return nil
|
|
}
|
|
if err := openBrowser(dashboardURL); err != nil {
|
|
return fmt.Errorf("open browser: %w", err)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
c.Flags().StringVar(&userID, "id", "", "user id (uuid)")
|
|
c.Flags().StringVar(&email, "email", "", "user email; resolved to an id via the admin-api")
|
|
c.Flags().StringVar(&grafanaURL, "grafana-url", "", "Grafana base URL (default: $YUCTL_GRAFANA_URL or "+defaultGrafanaURL+")")
|
|
c.Flags().BoolVar(&noOpen, "no-open", false, "print the dashboard URL instead of opening the browser")
|
|
c.MarkFlagsOneRequired("id", "email")
|
|
c.MarkFlagsMutuallyExclusive("id", "email")
|
|
flags.register(c)
|
|
return c
|
|
}
|
|
|
|
func newUsersListCmd() *cobra.Command {
|
|
flags := &adminFlags{}
|
|
var limitFlag string
|
|
c := &cobra.Command{
|
|
Use: "list",
|
|
Short: "List users in the partition's primary region",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
ctx := cmd.Context()
|
|
cc, err := requireContext()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
limit, err := adminapi.ParseLimit(limitFlag)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
topo, err := resolveTopology(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
client, _, err := flags.adminLogin(ctx, cmd, cc, topo)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
users, err := client.ListUsers(ctx, limit)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 2, 2, ' ', 0)
|
|
fmt.Fprintln(w, "ID\tSUB\tNAME\tEMAIL\tDISABLED")
|
|
for _, u := range users {
|
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%t\n", u.ID, u.Sub, u.Name, u.Email, u.Disabled)
|
|
}
|
|
w.Flush()
|
|
fmt.Fprintf(cmd.ErrOrStderr(), "\n%d user(s) in partition %s\n", len(users), cc.Partition)
|
|
return nil
|
|
},
|
|
}
|
|
c.Flags().StringVar(&limitFlag, "limit", "", "page size for the admin-api (default: server default)")
|
|
flags.register(c)
|
|
return c
|
|
}
|
|
|
|
// newUsersAllowlistCmd builds the `users allowlist` subtree (beta email
|
|
// allowlist + invites).
|
|
func newUsersAllowlistCmd() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "allowlist",
|
|
Short: "Manage the beta email allowlist and invites",
|
|
}
|
|
cmd.AddCommand(newAllowlistListCmd())
|
|
cmd.AddCommand(newAllowlistAddCmd())
|
|
cmd.AddCommand(newAllowlistRemoveCmd())
|
|
cmd.AddCommand(newAllowlistInviteCmd())
|
|
cmd.AddCommand(newAllowlistInviteBatchCmd())
|
|
return cmd
|
|
}
|
|
|
|
func (f *adminFlags) allowlistClient(cmd *cobra.Command) (*adminapi.Client, string, error) {
|
|
ctx := cmd.Context()
|
|
cc, err := requireContext()
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
topo, err := resolveTopology(ctx)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
client, _, err := f.adminLogin(ctx, cmd, cc, topo)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
return client, cc.Partition, nil
|
|
}
|
|
|
|
func printAllowlistEntries(cmd *cobra.Command, entries []adminapi.AllowlistEntry) {
|
|
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 2, 2, ' ', 0)
|
|
fmt.Fprintln(w, "EMAIL\tCODE\tINVITED\tUSED\tUSED AT\tCREATED")
|
|
for _, e := range entries {
|
|
usedAt := ""
|
|
if e.InviteUsedAt != nil {
|
|
usedAt = *e.InviteUsedAt
|
|
}
|
|
fmt.Fprintf(w, "%s\t%s\t%t\t%t\t%s\t%s\n", e.Email, e.InviteCode, e.Invited, e.InviteUsed, usedAt, e.CreatedAt)
|
|
}
|
|
w.Flush()
|
|
}
|
|
|
|
func newAllowlistListCmd() *cobra.Command {
|
|
flags := &adminFlags{}
|
|
var limitFlag string
|
|
c := &cobra.Command{
|
|
Use: "list",
|
|
Short: "List allowlist entries",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
limit, err := adminapi.ParseLimit(limitFlag)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
client, partition, err := flags.allowlistClient(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
entries, err := client.ListAllowlist(cmd.Context(), limit)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
printAllowlistEntries(cmd, entries)
|
|
fmt.Fprintf(cmd.ErrOrStderr(), "\n%d entries in partition %s\n", len(entries), partition)
|
|
return nil
|
|
},
|
|
}
|
|
c.Flags().StringVar(&limitFlag, "limit", "", "page size for the admin-api (default: server default)")
|
|
flags.register(c)
|
|
return c
|
|
}
|
|
|
|
func newAllowlistAddCmd() *cobra.Command {
|
|
flags := &adminFlags{}
|
|
var staged bool
|
|
c := &cobra.Command{
|
|
Use: "add <email>",
|
|
Short: "Allow an email to sign up (--staged to waitlist it instead)",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
client, _, err := flags.allowlistClient(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
entry, err := client.AddAllowlistEntry(cmd.Context(), args[0], staged)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
printAllowlistEntries(cmd, []adminapi.AllowlistEntry{*entry})
|
|
return nil
|
|
},
|
|
}
|
|
c.Flags().BoolVar(&staged, "staged", false, "stage the email without allowing login yet")
|
|
flags.register(c)
|
|
return c
|
|
}
|
|
|
|
func newAllowlistRemoveCmd() *cobra.Command {
|
|
flags := &adminFlags{}
|
|
c := &cobra.Command{
|
|
Use: "remove <email>",
|
|
Short: "Remove an email from the allowlist",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
client, _, err := flags.allowlistClient(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := client.RemoveAllowlistEntry(cmd.Context(), args[0]); err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprintf(cmd.ErrOrStderr(), "removed %s\n", args[0])
|
|
return nil
|
|
},
|
|
}
|
|
flags.register(c)
|
|
return c
|
|
}
|
|
|
|
func newAllowlistInviteCmd() *cobra.Command {
|
|
flags := &adminFlags{}
|
|
c := &cobra.Command{
|
|
Use: "invite <email>[,<email>...]",
|
|
Short: "Invite emails: allow them to sign up, creating entries as needed",
|
|
Args: cobra.MinimumNArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
var emails []string
|
|
for _, arg := range args {
|
|
for _, email := range strings.Split(arg, ",") {
|
|
if email = strings.TrimSpace(email); email != "" {
|
|
emails = append(emails, email)
|
|
}
|
|
}
|
|
}
|
|
client, _, err := flags.allowlistClient(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
entries, err := client.InviteEmails(cmd.Context(), emails)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
printAllowlistEntries(cmd, entries)
|
|
return nil
|
|
},
|
|
}
|
|
flags.register(c)
|
|
return c
|
|
}
|
|
|
|
func newAllowlistInviteBatchCmd() *cobra.Command {
|
|
flags := &adminFlags{}
|
|
c := &cobra.Command{
|
|
Use: "invite-batch <count>",
|
|
Short: "Invite the oldest <count> staged (waitlisted) emails",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
count, err := strconv.Atoi(args[0])
|
|
if err != nil || count < 1 {
|
|
return fmt.Errorf("count must be a positive integer")
|
|
}
|
|
client, _, err := flags.allowlistClient(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
entries, err := client.InviteBatch(cmd.Context(), count)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
printAllowlistEntries(cmd, entries)
|
|
fmt.Fprintf(cmd.ErrOrStderr(), "\ninvited %d entries\n", len(entries))
|
|
return nil
|
|
},
|
|
}
|
|
flags.register(c)
|
|
return c
|
|
}
|
|
|
|
// resolveUserID turns an --user email into a user id via the admin-api.
|
|
func resolveUserID(ctx context.Context, client *adminapi.Client, email string) (string, error) {
|
|
users, err := client.ListUsers(ctx, 0)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for _, u := range users {
|
|
if strings.EqualFold(u.Email, email) {
|
|
return u.ID, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("no user with email %q", email)
|
|
}
|