github.com/apex/up@v1.7.1/internal/cli/team/team.go (about)

     1  package team
     2  
     3  import (
     4  	"context"
     5  	"encoding/base64"
     6  	"encoding/json"
     7  	"fmt"
     8  	"sort"
     9  	"strings"
    10  	"time"
    11  
    12  	"github.com/pkg/errors"
    13  	"github.com/segmentio/go-snakecase"
    14  	"github.com/stripe/stripe-go"
    15  	"github.com/stripe/stripe-go/token"
    16  	"github.com/tj/go/clipboard"
    17  	"github.com/tj/go/env"
    18  	"github.com/tj/go/http/request"
    19  	"github.com/tj/go/term"
    20  	"github.com/tj/kingpin"
    21  	"github.com/tj/survey"
    22  
    23  	"github.com/apex/log"
    24  	"github.com/apex/up/internal/account"
    25  	"github.com/apex/up/internal/cli/root"
    26  	"github.com/apex/up/internal/colors"
    27  	"github.com/apex/up/internal/stats"
    28  	"github.com/apex/up/internal/userconfig"
    29  	"github.com/apex/up/internal/util"
    30  	"github.com/apex/up/platform/event"
    31  	"github.com/apex/up/reporter"
    32  )
    33  
    34  // api endpoint.
    35  var api = env.GetDefault("APEX_TEAMS_API", "https://teams.apex.sh")
    36  
    37  // api client.
    38  var a = account.New(api)
    39  
    40  // plan amounts.
    41  var amounts = map[string]int{
    42  	"monthly":  2000,
    43  	"annually": 21600,
    44  }
    45  
    46  // plan amount select options.
    47  var amountOptions = map[string]string{
    48  	"Monthly at $20.00 USD":   "monthly",
    49  	"Annually at $216.00 USD": "annually",
    50  }
    51  
    52  func init() {
    53  	cmd := root.Command("team", "Manage team members, plans, and billing.")
    54  	cmd.Example(`up team`, "Show active team and subscription status.")
    55  	cmd.Example(`up team login`, "Sign in or create account with interactive prompt.")
    56  	cmd.Example(`up team login --email tj@example.com --team apex-software`, "Sign in to a team.")
    57  	cmd.Example(`up team add "Apex Software"`, "Add a new team.")
    58  	cmd.Example(`up team subscribe`, "Subscribe to the Pro plan.")
    59  	cmd.Example(`up team members add asya@example.com`, "Invite a team member to your active team.")
    60  	cmd.Example(`up team members rm tobi@example.com`, "Remove a team member from your active team.")
    61  	cmd.Example(`up team card change`, "Change the default credit card.")
    62  	cmd.Example(`up team switch`, "Switch teams interactively.")
    63  	status(cmd)
    64  	switchTeam(cmd)
    65  	login(cmd)
    66  	logout(cmd)
    67  	members(cmd)
    68  	subscribe(cmd)
    69  	unsubscribe(cmd)
    70  	card(cmd)
    71  	copy(cmd)
    72  	add(cmd)
    73  }
    74  
    75  // add command.
    76  func add(cmd *kingpin.Cmd) {
    77  	c := cmd.Command("add", "Add a new team.")
    78  	name := c.Arg("name", "Name of the team.").Required().String()
    79  
    80  	c.Action(func(_ *kingpin.ParseContext) error {
    81  		var config userconfig.Config
    82  		if err := config.Load(); err != nil {
    83  			return errors.Wrap(err, "loading config")
    84  		}
    85  
    86  		if !config.Authenticated() {
    87  			return errors.New("Must sign in to create a new team.")
    88  		}
    89  
    90  		team := strings.Replace(snakecase.Snakecase(*name), "_", "-", -1)
    91  
    92  		stats.Track("Add Team", map[string]interface{}{
    93  			"team": team,
    94  			"name": name,
    95  		})
    96  
    97  		t := config.GetActiveTeam()
    98  
    99  		if err := a.AddTeam(t.Token, team, *name); err != nil {
   100  			return errors.Wrap(err, "creating team")
   101  		}
   102  
   103  		defer util.Pad()()
   104  		util.Log("Created team %s with id %s", *name, team)
   105  
   106  		code, err := a.LoginWithToken(t.Token, t.Email, team)
   107  		if err != nil {
   108  			return errors.Wrap(err, "login")
   109  		}
   110  
   111  		// access key
   112  		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
   113  		defer cancel()
   114  
   115  		token, err := a.PollAccessToken(ctx, t.Email, team, code)
   116  		if err != nil {
   117  			return errors.Wrap(err, "getting access token")
   118  		}
   119  
   120  		err = userconfig.Alter(func(c *userconfig.Config) {
   121  			c.Team = team
   122  			c.AddTeam(&userconfig.Team{
   123  				Token: token,
   124  				ID:    team,
   125  				Email: t.Email,
   126  			})
   127  		})
   128  
   129  		if err != nil {
   130  			return errors.Wrap(err, "config")
   131  		}
   132  
   133  		util.Log("%s is now the active team.\n", *name)
   134  		util.Log("Use `up team switch` to change teams.")
   135  
   136  		return nil
   137  	})
   138  }
   139  
   140  // copy commands.
   141  func copy(cmd *kingpin.Cmd) {
   142  	c := cmd.Command("ci", "Credentials for CI.")
   143  	copy := c.Flag("copy", "Credentials to the clipboard.").Short('c').Bool()
   144  
   145  	c.Action(func(_ *kingpin.ParseContext) error {
   146  		var config userconfig.Config
   147  		if err := config.Load(); err != nil {
   148  			return errors.Wrap(err, "loading")
   149  		}
   150  
   151  		stats.Track("Copy Credentials", map[string]interface{}{
   152  			"copy": *copy,
   153  		})
   154  
   155  		b, err := json.Marshal(config)
   156  		if err != nil {
   157  			return errors.Wrap(err, "marshaling")
   158  		}
   159  
   160  		s := base64.StdEncoding.EncodeToString(b)
   161  
   162  		if *copy {
   163  			clipboard.Write(s)
   164  			fmt.Println("Copied to clipboard!")
   165  			return nil
   166  		}
   167  
   168  		fmt.Printf("%s\n", s)
   169  
   170  		return nil
   171  	})
   172  }
   173  
   174  // status of account.
   175  func status(cmd *kingpin.Cmd) {
   176  	c := cmd.Command("status", "Status of your account.").Default()
   177  
   178  	c.Action(func(_ *kingpin.ParseContext) error {
   179  		var config userconfig.Config
   180  		if err := config.Load(); err != nil {
   181  			return errors.Wrap(err, "loading config")
   182  		}
   183  
   184  		defer util.Pad()()
   185  		stats.Track("Account Status", nil)
   186  
   187  		if !config.Authenticated() {
   188  			util.LogName("status", "Signed out")
   189  			return nil
   190  		}
   191  
   192  		t := config.GetActiveTeam()
   193  
   194  		defer util.Pad()()
   195  		util.LogName("team", t.ID)
   196  
   197  		team, err := a.GetTeam(t.Token)
   198  		if err != nil {
   199  			return errors.Wrap(err, "fetching team")
   200  		}
   201  
   202  		plans, err := a.GetPlans(t.Token)
   203  		if err != nil {
   204  			return errors.Wrap(err, "fetching plans")
   205  		}
   206  
   207  		if len(plans) == 0 {
   208  			util.LogName("subscription", "none")
   209  			return nil
   210  		}
   211  
   212  		if c := team.Card; c != nil {
   213  			util.LogName("card", "%s ending with %s", c.Brand, c.LastFour)
   214  		}
   215  
   216  		// TODO: filter on plan type (later may be other products)
   217  		p := plans[0]
   218  
   219  		if d := p.Discount; d != nil {
   220  			p.Amount = d.Coupon.Discount(p.Amount)
   221  			util.LogName("coupon", d.Coupon.ID)
   222  		}
   223  
   224  		util.LogName("amount", "%s USD per %s", currency(p.Amount), p.Interval)
   225  		util.LogName("owner", team.Owner)
   226  		util.LogName("created", p.CreatedAt.Format("January 2, 2006"))
   227  		if p.Canceled {
   228  			util.LogName("canceled", p.CanceledAt.Format("January 2, 2006"))
   229  		}
   230  
   231  		return nil
   232  	})
   233  }
   234  
   235  // switchTeam team.
   236  func switchTeam(cmd *kingpin.Cmd) {
   237  	c := cmd.Command("switch", "Switch active team.")
   238  	c.Example(`up team switch`, "Switch teams interactively.")
   239  
   240  	c.Action(func(_ *kingpin.ParseContext) error {
   241  		defer util.Pad()()
   242  
   243  		var config userconfig.Config
   244  		if err := config.Load(); err != nil {
   245  			return errors.Wrap(err, "loading user config")
   246  		}
   247  
   248  		var options []string
   249  		for _, t := range config.GetTeams() {
   250  			options = append(options, t.ID)
   251  		}
   252  		sort.Strings(options)
   253  
   254  		var team string
   255  		prompt := &survey.Select{
   256  			Message: "",
   257  			Options: options,
   258  			Default: config.Team,
   259  		}
   260  
   261  		if err := survey.AskOne(prompt, &team, survey.Required); err != nil {
   262  			return err
   263  		}
   264  
   265  		stats.Track("Switch Team", nil)
   266  
   267  		err := userconfig.Alter(func(c *userconfig.Config) {
   268  			c.Team = team
   269  		})
   270  
   271  		if err != nil {
   272  			return errors.Wrap(err, "saving config")
   273  		}
   274  
   275  		return nil
   276  	})
   277  }
   278  
   279  // login user.
   280  func login(cmd *kingpin.Cmd) {
   281  	c := cmd.Command("login", "Sign in to your account.")
   282  	c.Example(`up team login`, "Sign in or create account with interactive prompt.")
   283  	c.Example(`up team login --team apex-software`, "Sign in to a team using your existing email.")
   284  	c.Example(`up team login --email tj@example.com --team apex-software`, "Sign in to a team with email.")
   285  	email := c.Flag("email", "Email address.").String()
   286  	team := c.Flag("team", "Team id.").String()
   287  
   288  	c.Action(func(_ *kingpin.ParseContext) error {
   289  		var config userconfig.Config
   290  		if err := config.Load(); err != nil {
   291  			return errors.Wrap(err, "loading user config")
   292  		}
   293  
   294  		defer util.Pad()()
   295  
   296  		stats.Track("Login", map[string]interface{}{
   297  			"team_count": len(config.GetTeams()),
   298  			"has_email":  *email != "",
   299  			"has_team":   *team != "",
   300  		})
   301  
   302  		t := config.GetActiveTeam()
   303  
   304  		// both team and email are specified,
   305  		// so we want to disregard the active team
   306  		// entirely and sign in using these creds.
   307  		if *email != "" && *team != "" {
   308  			t = nil
   309  		}
   310  
   311  		// ensure we have an email
   312  		if *email == "" {
   313  			if t == nil {
   314  				if err := prompt("email:", email); err != nil {
   315  					return err
   316  				}
   317  			} else {
   318  				*email = t.Email
   319  			}
   320  		}
   321  
   322  		// ensure we have a team if already signed-in,
   323  		// this lets the user specify only --team xxx to
   324  		// join a team they were invited to, or add one
   325  		// which they own.
   326  		if t != nil && *team == "" {
   327  			util.Log("Already signed in as %s on team %s.", t.Email, t.ID)
   328  			util.Log("Use `up team login --team <id>` to join a team.")
   329  			return nil
   330  		}
   331  
   332  		// events
   333  		events := make(event.Events)
   334  		go reporter.Text(events)
   335  		events.Emit("account.login.verify", nil)
   336  
   337  		l := log.WithFields(log.Fields{
   338  			"email": *email,
   339  			"team":  *team,
   340  		})
   341  
   342  		// authenticate
   343  		var code string
   344  		var err error
   345  		if t == nil {
   346  			l.Debug("login without token")
   347  			code, err = a.Login(*email, *team)
   348  		} else {
   349  			l.Debug("login with token")
   350  			code, err = a.LoginWithToken(t.Token, *email, *team)
   351  		}
   352  
   353  		if err != nil {
   354  			return errors.Wrap(err, "login")
   355  		}
   356  
   357  		// personal team
   358  		if *team == "" {
   359  			team = email
   360  		}
   361  
   362  		// access key
   363  		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
   364  		defer cancel()
   365  
   366  		l.WithField("team", *team).Debug("poll for access token")
   367  		token, err := a.PollAccessToken(ctx, *email, *team, code)
   368  		if err != nil {
   369  			return errors.Wrap(err, "getting access token")
   370  		}
   371  
   372  		events.Emit("account.login.verified", nil)
   373  		err = userconfig.Alter(func(c *userconfig.Config) {
   374  			c.Team = *team
   375  			c.AddTeam(&userconfig.Team{
   376  				Token: token,
   377  				ID:    *team,
   378  				Email: *email,
   379  			})
   380  		})
   381  
   382  		if err != nil {
   383  			return errors.Wrap(err, "config")
   384  		}
   385  
   386  		return nil
   387  	})
   388  }
   389  
   390  // logout user.
   391  func logout(cmd *kingpin.Cmd) {
   392  	c := cmd.Command("logout", "Sign out of your account.")
   393  
   394  	c.Action(func(_ *kingpin.ParseContext) error {
   395  		stats.Track("Logout", nil)
   396  
   397  		var config userconfig.Config
   398  		if err := config.Save(); err != nil {
   399  			return errors.Wrap(err, "saving")
   400  		}
   401  
   402  		util.LogPad("Signed out")
   403  
   404  		return nil
   405  	})
   406  }
   407  
   408  // subscribe to plan.
   409  func subscribe(cmd *kingpin.Cmd) {
   410  	c := cmd.Command("subscribe", "Subscribe to the Pro plan.")
   411  
   412  	c.Action(func(_ *kingpin.ParseContext) error {
   413  		t, err := userconfig.Require()
   414  		if err != nil {
   415  			return err
   416  		}
   417  
   418  		defer util.Pad()()
   419  
   420  		// plan
   421  		util.LogTitle("Subscription")
   422  		util.Log("Choose a monthly billing period, or 10%% off annually.")
   423  		println()
   424  
   425  		var interval string
   426  		err = survey.AskOne(&survey.Select{
   427  			Message: "Plan:",
   428  			Options: keys(amountOptions),
   429  		}, &interval, survey.Required)
   430  
   431  		// amount
   432  		interval = amountOptions[interval]
   433  		amount := amounts[interval]
   434  
   435  		util.LogTitle("Coupon")
   436  		util.Log("Enter a coupon, or press enter to skip this step")
   437  		util.Log("and move on to adding a credit card.")
   438  		println()
   439  
   440  		// coupon
   441  		var couponID string
   442  		err = survey.AskOne(&survey.Input{
   443  			Message: "Coupon:",
   444  		}, &couponID, nil)
   445  
   446  		if err != nil {
   447  			return err
   448  		}
   449  
   450  		// coupon
   451  		if strings.TrimSpace(couponID) == "" {
   452  			util.LogClear("No coupon provided")
   453  		} else {
   454  			coupon, err := a.GetCoupon(couponID)
   455  			if err != nil && !request.IsNotFound(err) {
   456  				return errors.Wrap(err, "fetching coupon")
   457  			}
   458  
   459  			if coupon == nil {
   460  				util.LogClear("Coupon is invalid")
   461  			} else {
   462  				amount = coupon.Discount(amount)
   463  				msg := colors.Gray(fmt.Sprintf("%s — now %s %s", coupon.Description(), currency(amount), interval))
   464  				util.LogClear("Savings: %s", msg)
   465  			}
   466  		}
   467  
   468  		// add card
   469  		util.LogTitle("Credit Card")
   470  		util.Log("First add your credit card details, these are transferred")
   471  		util.Log("directly to Stripe over HTTPS and never touch our servers.")
   472  		println()
   473  
   474  		card, err := account.PromptForCard()
   475  		if err != nil {
   476  			return errors.Wrap(err, "prompting for card")
   477  		}
   478  
   479  		tok, err := token.New(&stripe.TokenParams{
   480  			Card: &card,
   481  		})
   482  
   483  		if err != nil {
   484  			return errors.Wrap(err, "requesting card token")
   485  		}
   486  
   487  		if err := a.AddCard(t.Token, tok.ID); err != nil {
   488  			return errors.Wrap(err, "adding card")
   489  		}
   490  
   491  		util.LogTitle("Confirm")
   492  
   493  		// confirm
   494  		var ok bool
   495  		err = survey.AskOne(&survey.Confirm{
   496  			Message: fmt.Sprintf("Subscribe to Up Pro for %s USD %s?", currency(amount), interval),
   497  		}, &ok, nil)
   498  
   499  		if err != nil {
   500  			return err
   501  		}
   502  
   503  		if !ok {
   504  			util.LogPad("Aborted")
   505  			stats.Track("Abort Subscription", nil)
   506  			return nil
   507  		}
   508  
   509  		stats.Track("Subscribe", map[string]interface{}{
   510  			"coupon":   couponID,
   511  			"interval": interval,
   512  		})
   513  
   514  		if err := a.AddPlan(t.Token, "up", interval, couponID); err != nil {
   515  			return errors.Wrap(err, "subscribing")
   516  		}
   517  
   518  		util.LogClear("Thanks for subscribing! Run `up upgrade` to install the Pro release.")
   519  
   520  		return nil
   521  	})
   522  }
   523  
   524  // unsubscribe from plan.
   525  func unsubscribe(cmd *kingpin.Cmd) {
   526  	c := cmd.Command("unsubscribe", "Unsubscribe from the Pro plan.")
   527  
   528  	c.Action(func(_ *kingpin.ParseContext) error {
   529  		config, err := userconfig.Require()
   530  		if err != nil {
   531  			return err
   532  		}
   533  
   534  		defer util.Pad()()
   535  
   536  		// confirm
   537  		var ok bool
   538  		err = survey.AskOne(&survey.Confirm{
   539  			Message: "Are you sure you want to unsubscribe?",
   540  		}, &ok, nil)
   541  
   542  		if err != nil {
   543  			return err
   544  		}
   545  
   546  		if !ok {
   547  			util.LogPad("Aborted")
   548  			return nil
   549  		}
   550  
   551  		term.MoveUp(1)
   552  		term.ClearLine()
   553  
   554  		msg, err := feedback()
   555  		if err != nil {
   556  			util.LogPad("Aborted")
   557  			return nil
   558  		}
   559  
   560  		if strings.TrimSpace(msg) != "" {
   561  			util.Log("Thanks for the feedback!")
   562  			_ = a.AddFeedback(config.Token, msg)
   563  		}
   564  
   565  		stats.Track("Unsubscribe", nil)
   566  
   567  		if err := a.RemovePlan(config.Token, "up"); err != nil {
   568  			return errors.Wrap(err, "unsubscribing")
   569  		}
   570  
   571  		util.LogClear("Unsubscribed!")
   572  
   573  		return nil
   574  	})
   575  }
   576  
   577  // members commands.
   578  func members(cmd *kingpin.Cmd) {
   579  	c := cmd.Command("members", "Member management.")
   580  	addMember(c)
   581  	removeMember(c)
   582  	listMembers(c)
   583  }
   584  
   585  // addMember command.
   586  func addMember(cmd *kingpin.Cmd) {
   587  	c := cmd.Command("add", "Add invites a team member.")
   588  	c.Example(`up team members add asya@apex.sh`, "Invite a team member to the active team.")
   589  	email := c.Arg("email", "Email address.").Required().String()
   590  
   591  	c.Action(func(_ *kingpin.ParseContext) error {
   592  		t, err := userconfig.Require()
   593  		if err != nil {
   594  			return err
   595  		}
   596  
   597  		stats.Track("Add Member", map[string]interface{}{
   598  			"team":  t.ID,
   599  			"email": *email,
   600  		})
   601  
   602  		if err := a.AddInvite(t.Token, *email); err != nil {
   603  			return errors.Wrap(err, "adding invite")
   604  		}
   605  
   606  		util.LogPad("Invited %s to team %s", *email, t.ID)
   607  
   608  		return nil
   609  	})
   610  }
   611  
   612  // removeMember command.
   613  func removeMember(cmd *kingpin.Cmd) {
   614  	c := cmd.Command("rm", "Remove a member or invite.").Alias("remove")
   615  	c.Example(`up team members rm tobi@apex.sh`, "Remove a team member or invite from the active team.")
   616  	email := c.Arg("email", "Email address.").Required().String()
   617  
   618  	c.Action(func(_ *kingpin.ParseContext) error {
   619  		t, err := userconfig.Require()
   620  		if err != nil {
   621  			return err
   622  		}
   623  
   624  		stats.Track("Remove Member", map[string]interface{}{
   625  			"team":  t.ID,
   626  			"email": *email,
   627  		})
   628  
   629  		if err := a.RemoveMember(t.Token, *email); err != nil {
   630  			return errors.Wrap(err, "removing member")
   631  		}
   632  
   633  		util.LogPad("Removed %s from team %s", *email, t.ID)
   634  
   635  		return nil
   636  	})
   637  }
   638  
   639  // list members
   640  func listMembers(cmd *kingpin.Cmd) {
   641  	c := cmd.Command("ls", "List team members and invites.").Alias("list").Default()
   642  
   643  	c.Action(func(_ *kingpin.ParseContext) error {
   644  		t, err := userconfig.Require()
   645  		if err != nil {
   646  			return err
   647  		}
   648  
   649  		stats.Track("List Members", map[string]interface{}{
   650  			"team": t.ID,
   651  		})
   652  
   653  		team, err := a.GetTeam(t.Token)
   654  		if err != nil {
   655  			return errors.Wrap(err, "fetching team")
   656  		}
   657  
   658  		defer util.Pad()()
   659  
   660  		util.LogName("team", t.ID)
   661  
   662  		if len(team.Members) > 0 {
   663  			util.LogTitle("Members")
   664  			for _, u := range team.Members {
   665  				util.LogListItem(u.Email)
   666  			}
   667  		}
   668  
   669  		if len(team.Invites) > 0 {
   670  			util.LogTitle("Invites")
   671  			for _, email := range team.Invites {
   672  				util.LogListItem(email)
   673  			}
   674  		}
   675  
   676  		return nil
   677  	})
   678  }
   679  
   680  // card commands.
   681  func card(cmd *kingpin.Cmd) {
   682  	c := cmd.Command("card", "Card management.")
   683  	changeCard(c)
   684  }
   685  
   686  // changeCard command.
   687  func changeCard(cmd *kingpin.Cmd) {
   688  	c := cmd.Command("change", "Change the default card.")
   689  
   690  	c.Action(func(_ *kingpin.ParseContext) error {
   691  		t, err := userconfig.Require()
   692  		if err != nil {
   693  			return err
   694  		}
   695  
   696  		defer util.Pad()()
   697  
   698  		util.LogTitle("Credit Card")
   699  		util.Log("Enter new credit card details to replace the existing card.")
   700  		println()
   701  
   702  		card, err := account.PromptForCard()
   703  		if err != nil {
   704  			return errors.Wrap(err, "prompting for card")
   705  		}
   706  
   707  		tok, err := token.New(&stripe.TokenParams{
   708  			Card: &card,
   709  		})
   710  
   711  		if err != nil {
   712  			return errors.Wrap(err, "requesting card token")
   713  		}
   714  
   715  		if err := a.AddCard(t.Token, tok.ID); err != nil {
   716  			return errors.Wrap(err, "adding card")
   717  		}
   718  
   719  		println()
   720  		util.Log("Updated")
   721  
   722  		return nil
   723  	})
   724  }
   725  
   726  // prompt helper.
   727  func prompt(name string, s *string) error {
   728  	prompt := &survey.Input{Message: name}
   729  	return survey.AskOne(prompt, s, survey.Required)
   730  }
   731  
   732  // feedback prompt helper.
   733  func feedback() (string, error) {
   734  	var s string
   735  	prompt := &survey.Input{Message: "Have any feedback? (optional)"}
   736  	if err := survey.AskOne(prompt, &s, nil); err != nil {
   737  		return "", err
   738  	}
   739  	return s, nil
   740  }
   741  
   742  // currency returns formatted currency.
   743  func currency(n int) string {
   744  	return fmt.Sprintf("$%0.2f", float64(n)/100)
   745  }
   746  
   747  // keys returns the keys of a string map.
   748  func keys(m map[string]string) (v []string) {
   749  	for k := range m {
   750  		v = append(v, k)
   751  	}
   752  
   753  	sort.Slice(v, func(i int, j int) bool {
   754  		return v[i] > v[j]
   755  	})
   756  
   757  	return
   758  }