mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: Login via CLI (#298)
Fixes #210 - this isPR implements `coder login` in the case where the default user is already created. This change adds: - A prompt in the case where there is not an initial user that opens the server URL + requests a session token - This ports over some code from v1 for the `openURL` and `isWSL` functions to support opening the browser - A `/api/v2/api-keys` endpoint that can be `POST`'d to in order to request a new api key for a user - This route was inspired by the v1 functionality - A `cli-auth` route + page that shows the generated api key - Tests for the new code + storybook for the new UI The `/cli-auth` route, like in v1, is very minimal: <img width="624" alt="Screen Shot 2022-02-16 at 5 05 07 PM" src="https://user-images.githubusercontent.com/88213859/154384627-78ab9841-27bf-490f-9bbe-23f8173c9e97.png"> And the terminal UX looks like this: 
This commit is contained in:
+97
-1
@@ -2,13 +2,17 @@ package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/manifoldco/promptui"
|
||||
"github.com/pkg/browser"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
@@ -16,6 +20,21 @@ import (
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
const (
|
||||
goosWindows = "windows"
|
||||
goosDarwin = "darwin"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Hide output from the browser library,
|
||||
// otherwise we can get really verbose and non-actionable messages
|
||||
// when in SSH or another type of headless session
|
||||
// NOTE: This needs to be in `init` to prevent data races
|
||||
// (multiple threads trying to set the global browser.Std* variables)
|
||||
browser.Stderr = ioutil.Discard
|
||||
browser.Stdout = ioutil.Discard
|
||||
}
|
||||
|
||||
func login() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "login <url>",
|
||||
@@ -116,8 +135,10 @@ func login() *cobra.Command {
|
||||
if err != nil {
|
||||
return xerrors.Errorf("login with password: %w", err)
|
||||
}
|
||||
|
||||
sessionToken := resp.SessionToken
|
||||
config := createConfig(cmd)
|
||||
err = config.Session().Write(resp.SessionToken)
|
||||
err = config.Session().Write(sessionToken)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("write session token: %w", err)
|
||||
}
|
||||
@@ -130,7 +151,82 @@ func login() *cobra.Command {
|
||||
return nil
|
||||
}
|
||||
|
||||
authURL := *serverURL
|
||||
authURL.Path = serverURL.Path + "/cli-auth"
|
||||
if err := openURL(authURL.String()); err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Open the following in your browser:\n\n\t%s\n\n", authURL.String())
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Your browser has been opened to visit:\n\n\t%s\n\n", authURL.String())
|
||||
}
|
||||
|
||||
sessionToken, err := prompt(cmd, &promptui.Prompt{
|
||||
Label: "Paste your token here:",
|
||||
Mask: '*',
|
||||
Validate: func(token string) error {
|
||||
client.SessionToken = token
|
||||
_, err := client.User(cmd.Context(), "me")
|
||||
if err != nil {
|
||||
return xerrors.New("That's not a valid token!")
|
||||
}
|
||||
return err
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("paste token prompt: %w", err)
|
||||
}
|
||||
|
||||
// Login to get user data - verify it is OK before persisting
|
||||
client.SessionToken = sessionToken
|
||||
resp, err := client.User(cmd.Context(), "me")
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get user: %w", err)
|
||||
}
|
||||
|
||||
config := createConfig(cmd)
|
||||
err = config.Session().Write(sessionToken)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("write session token: %w", err)
|
||||
}
|
||||
err = config.URL().Write(serverURL.String())
|
||||
if err != nil {
|
||||
return xerrors.Errorf("write server url: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Welcome to Coder, %s! You're authenticated.\n", color.HiBlackString(">"), color.HiCyanString(resp.Username))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// isWSL determines if coder-cli is running within Windows Subsystem for Linux
|
||||
func isWSL() (bool, error) {
|
||||
if runtime.GOOS == goosDarwin || runtime.GOOS == goosWindows {
|
||||
return false, nil
|
||||
}
|
||||
data, err := ioutil.ReadFile("/proc/version")
|
||||
if err != nil {
|
||||
return false, xerrors.Errorf("read /proc/version: %w", err)
|
||||
}
|
||||
return strings.Contains(strings.ToLower(string(data)), "microsoft"), nil
|
||||
}
|
||||
|
||||
// openURL opens the provided URL via user's default browser
|
||||
func openURL(urlToOpen string) error {
|
||||
var cmd string
|
||||
var args []string
|
||||
|
||||
wsl, err := isWSL()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("test running Windows Subsystem for Linux: %w", err)
|
||||
}
|
||||
|
||||
if wsl {
|
||||
cmd = "cmd.exe"
|
||||
args = []string{"/c", "start"}
|
||||
urlToOpen = strings.ReplaceAll(urlToOpen, "&", "^&")
|
||||
args = append(args, urlToOpen)
|
||||
return exec.Command(cmd, args...).Start()
|
||||
}
|
||||
|
||||
return browser.OpenURL(urlToOpen)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/coderd"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
)
|
||||
@@ -50,4 +52,60 @@ func TestLogin(t *testing.T) {
|
||||
}
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
})
|
||||
|
||||
t.Run("ExistingUserValidTokenTTY", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t)
|
||||
_, err := client.CreateInitialUser(context.Background(), coderd.CreateInitialUserRequest{
|
||||
Username: "test-user",
|
||||
Email: "test-user@coder.com",
|
||||
Organization: "acme-corp",
|
||||
Password: "password",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
token, err := client.LoginWithPassword(context.Background(), coderd.LoginWithPasswordRequest{
|
||||
Email: "test-user@coder.com",
|
||||
Password: "password",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
root, _ := clitest.New(t, "login", client.URL.String(), "--force-tty")
|
||||
pty := ptytest.New(t)
|
||||
root.SetIn(pty.Input())
|
||||
root.SetOut(pty.Output())
|
||||
go func() {
|
||||
err := root.Execute()
|
||||
require.NoError(t, err)
|
||||
}()
|
||||
|
||||
pty.ExpectMatch("Paste your token here:")
|
||||
pty.WriteLine(token.SessionToken)
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
})
|
||||
|
||||
t.Run("ExistingUserInvalidTokenTTY", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t)
|
||||
_, err := client.CreateInitialUser(context.Background(), coderd.CreateInitialUserRequest{
|
||||
Username: "test-user",
|
||||
Email: "test-user@coder.com",
|
||||
Organization: "acme-corp",
|
||||
Password: "password",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
root, _ := clitest.New(t, "login", client.URL.String(), "--force-tty")
|
||||
pty := ptytest.New(t)
|
||||
root.SetIn(pty.Input())
|
||||
root.SetOut(pty.Output())
|
||||
go func() {
|
||||
err := root.Execute()
|
||||
// An error is expected in this case, since the login wasn't successful:
|
||||
require.Error(t, err)
|
||||
}()
|
||||
|
||||
pty.ExpectMatch("Paste your token here:")
|
||||
pty.WriteLine("an-invalid-token")
|
||||
pty.ExpectMatch("That's not a valid token!")
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user