From 71f4ace21504e016f9897c95c1bb3f1fc36c026e Mon Sep 17 00:00:00 2001
From: Youngjin Jo <yjinjo@berkeley.edu>
Date: Thu, 28 Nov 2024 00:58:20 +0900
Subject: [PATCH 1/6] chore: add workspace owner role type

Signed-off-by: Youngjin Jo <yjinjo@berkeley.edu>
---
 cmd/other/login.go | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/cmd/other/login.go b/cmd/other/login.go
index b37f7bd..198a1d8 100644
--- a/cmd/other/login.go
+++ b/cmd/other/login.go
@@ -587,7 +587,7 @@ func determineScope(roleType string, workspaceCount int) string {
 	switch roleType {
 	case "DOMAIN_ADMIN":
 		return "DOMAIN"
-	case "USER":
+	case "WORKSPACE_OWNER", "WORKSPACE_MEMBER", "USER":
 		return "WORKSPACE"
 	default:
 		pterm.Error.Println("Unknown role_type:", roleType)

From cc1fd86a95f6f36251da012a378e4577977cc265 Mon Sep 17 00:00:00 2001
From: Youngjin Jo <yjinjo@berkeley.edu>
Date: Thu, 28 Nov 2024 01:24:10 +0900
Subject: [PATCH 2/6] feat: add multiple user login feature

Signed-off-by: Youngjin Jo <yjinjo@berkeley.edu>
---
 cmd/other/login.go | 184 +++++++++++++++++++++++++++++++--------------
 1 file changed, 128 insertions(+), 56 deletions(-)

diff --git a/cmd/other/login.go b/cmd/other/login.go
index 198a1d8..c1a529b 100644
--- a/cmd/other/login.go
+++ b/cmd/other/login.go
@@ -200,7 +200,7 @@ func executeAppLogin(currentEnv string, mainViper *viper.Viper) {
 }
 
 func executeUserLogin(currentEnv string) {
-	loadEnvironmentConfig()
+	loadEnvironmentConfig() // Load the environment-specific configuration
 
 	baseUrl := providedUrl
 	if baseUrl == "" {
@@ -219,28 +219,59 @@ func executeUserLogin(currentEnv string) {
 	cacheConfigPath := filepath.Join(homeDir, ".cfctl", "cache", "config.yaml")
 	cacheViper.SetConfigFile(cacheConfigPath)
 
-	var userID, password string
+	var userID string
+	var password string
+
 	if err := cacheViper.ReadInConfig(); err == nil {
-		savedUserID := cacheViper.GetString(fmt.Sprintf("environments.%s.userID", currentEnv))
-		savedEncryptedPassword := cacheViper.GetString(fmt.Sprintf("environments.%s.password", currentEnv))
-
-		if savedUserID != "" && savedEncryptedPassword != "" {
-			userID = savedUserID
-			var err error
-			password, err = decrypt(savedEncryptedPassword)
-			if err != nil {
-				pterm.Warning.Println("Failed to decrypt saved password, requesting new credentials")
-				userID, password = promptCredentials()
+		// Access the 'users' field in the configuration
+		usersField := cacheViper.Get("environments." + currentEnv + ".users")
+		if usersField != nil {
+			// Make sure we are getting a slice of interfaces
+			users, ok := usersField.([]interface{})
+			if !ok {
+				pterm.Error.Println("Failed to load users correctly.")
+				exitWithError()
+			}
+
+			if len(users) > 0 {
+				pterm.Info.Println("Multiple users detected. Please select an account to login:")
+
+				// Display the existing users
+				for i, user := range users {
+					userMap, ok := user.(map[string]interface{})
+					if !ok {
+						pterm.Error.Println("Invalid user format.")
+						exitWithError()
+					}
+
+					pterm.Printf("%d. %s\n", i+1, userMap["userid"].(string)) // Print actual userID
+				}
+
+				// Prompt user for selection
+				userSelection := promptUserSelection(len(users), users) // Pass the 'users' slice
+				selectedUser := users[userSelection-1].(map[string]interface{})
+
+				userID = selectedUser["userid"].(string)
+				password = selectedUser["password"].(string)
+				token := selectedUser["token"].(string)
+
+				// Check if the token is still valid
+				if !isTokenExpired(token) {
+					pterm.Success.Printf("Using saved credentials for %s\n", userID)
+				} else {
+					pterm.Warning.Println("Token expired. Please enter your password again.")
+					password = promptPassword() // Prompt for password if token expired
+				}
 			} else {
-				pterm.Info.Printf("Using saved credentials for %s\n", userID)
+				userID, password = promptCredentials() // Prompt for new credentials if no user exists
 			}
 		} else {
-			userID, password = promptCredentials()
+			pterm.Error.Println("No users found in the configuration.")
+			exitWithError()
 		}
 	} else {
-		userID, password = promptCredentials()
+		userID, password = promptCredentials() // Prompt if the configuration cannot be read
 	}
-
 	// Load the main config file specifically for environment name
 	mainViper := viper.New()
 	mainViper.SetConfigFile(filepath.Join(homeDir, ".cfctl", "config.yaml"))
@@ -265,18 +296,15 @@ func executeUserLogin(currentEnv string) {
 		exitWithError()
 	}
 
-	// Issue tokens using user credentials
+	// Proceed with issuing the token and saving credentials
 	accessToken, refreshToken, err := issueToken(baseUrl, userID, password, domainID)
 	if err != nil {
 		pterm.Error.Println("Failed to retrieve token:", err)
 		exitWithError()
 	}
 
-	if encryptedPassword, err := encrypt(password); err == nil {
-		saveCredentials(currentEnv, userID, encryptedPassword)
-	} else {
-		pterm.Warning.Printf("Failed to encrypt password: %v\n", err)
-	}
+	// Save the new credentials to the configuration file
+	saveCredentials(currentEnv, userID, password, accessToken)
 
 	// Fetch workspaces
 	workspaces, err := fetchWorkspaces(baseUrl, accessToken)
@@ -320,6 +348,75 @@ func executeUserLogin(currentEnv string) {
 	pterm.Success.Println("Successfully logged in and saved token.")
 }
 
+// Prompt for user credentials if they aren't saved
+func promptCredentials() (string, string) {
+	userId, _ := pterm.DefaultInteractiveTextInput.Show("Enter your user ID")
+	passwordInput := pterm.DefaultInteractiveTextInput.WithMask("*")
+	password, _ := passwordInput.Show("Enter your password")
+	return userId, password
+}
+
+// Prompt for password when token is expired
+func promptPassword() string {
+	passwordInput := pterm.DefaultInteractiveTextInput.WithMask("*")
+	password, _ := passwordInput.Show("Enter your password")
+	return password
+}
+
+// Prompt for user selection, now receiving 'users' slice as an argument
+func promptUserSelection(max int, users []interface{}) int {
+	// Open the keyboard input once
+	if err := keyboard.Open(); err != nil {
+		pterm.Error.Println("Failed to initialize keyboard:", err)
+		exitWithError()
+	}
+	defer keyboard.Close() // Ensure keyboard is closed at the end
+
+	selectedIndex := 0
+	for {
+		fmt.Print("\033[H\033[2J") // Clear the screen
+
+		// Display the list of available users
+		pterm.DefaultHeader.WithFullWidth().
+			WithBackgroundStyle(pterm.NewStyle(pterm.BgDarkGray)).
+			WithTextStyle(pterm.NewStyle(pterm.FgLightWhite)).
+			Println("Select a user account:")
+
+		for i := 0; i < max; i++ {
+			userMap := users[i].(map[string]interface{})
+			pterm.Printf("→ %d: %s\n", i+1, userMap["userid"].(string)) // Print actual userID
+		}
+
+		pterm.DefaultBasicText.WithStyle(pterm.NewStyle(pterm.FgGray)).
+			Println("\nNavigation: [j]down [k]up, [Enter]select, [q]quit")
+
+		// Get the keyboard input for selection
+		_, key, err := keyboard.GetKey()
+		if err != nil {
+			pterm.Error.Println("Error reading keyboard input:", err)
+			exitWithError()
+		}
+
+		switch key {
+		case keyboard.KeyEnter:
+			if selectedIndex >= 0 && selectedIndex < max {
+				return selectedIndex + 1
+			}
+		case 'j': // Down
+			if selectedIndex < max-1 {
+				selectedIndex++
+			}
+		case 'k': // Up
+			if selectedIndex > 0 {
+				selectedIndex--
+			}
+		case 'q', 'Q': // Quit
+			pterm.Error.Println("Selection cancelled.")
+			os.Exit(1)
+		}
+	}
+}
+
 func getEncryptionKey() ([]byte, error) {
 	key, err := keyring.Get(keyringService, keyringUser)
 	if err == keyring.ErrNotFound {
@@ -395,7 +492,7 @@ func decrypt(cryptoText string) (string, error) {
 	return string(ciphertext), nil
 }
 
-func saveCredentials(currentEnv, userID, encryptedPassword string) {
+func saveCredentials(currentEnv, userID, password, token string) {
 	homeDir, err := os.UserHomeDir()
 	if err != nil {
 		pterm.Error.Printf("Failed to get user home directory: %v\n", err)
@@ -409,14 +506,6 @@ func saveCredentials(currentEnv, userID, encryptedPassword string) {
 	}
 
 	cacheConfigPath := filepath.Join(cacheDir, "config.yaml")
-
-	if _, err := os.Stat(cacheConfigPath); os.IsNotExist(err) {
-		if err := os.WriteFile(cacheConfigPath, []byte{}, 0600); err != nil {
-			pterm.Error.Printf("Failed to create cache config file: %v\n", err)
-			return
-		}
-	}
-
 	cacheViper := viper.New()
 	cacheViper.SetConfigFile(cacheConfigPath)
 
@@ -435,29 +524,19 @@ func saveCredentials(currentEnv, userID, encryptedPassword string) {
 		envSettings = make(map[string]interface{})
 	}
 
-	orderedSettings := make(map[string]interface{})
-
-	if endpoint, exists := envSettings["endpoint"]; exists {
-		orderedSettings["endpoint"] = endpoint
-	} else {
-		orderedSettings["endpoint"] = providedUrl
-	}
-
-	orderedSettings["proxy"] = true
-
-	if token, exists := envSettings["token"]; exists {
-		orderedSettings["token"] = token
+	users := envSettings["users"].([]interface{})
+	newUser := map[string]interface{}{
+		"userid":   userID,
+		"password": password,
+		"token":    token,
 	}
 
-	orderedSettings["userid"] = userID
-
-	orderedSettings["password"] = encryptedPassword
-
-	cacheViper.Set(envPath, orderedSettings)
+	users = append(users, newUser)
+	envSettings["users"] = users
 
+	cacheViper.Set(envPath, envSettings)
 	if err := cacheViper.WriteConfig(); err != nil {
 		pterm.Error.Printf("Failed to save credentials: %v\n", err)
-		return
 	}
 }
 
@@ -635,13 +714,6 @@ func exitWithError() {
 	os.Exit(1)
 }
 
-func promptCredentials() (string, string) {
-	userId, _ := pterm.DefaultInteractiveTextInput.Show("Enter your user ID")
-	passwordInput := pterm.DefaultInteractiveTextInput.WithMask("*")
-	password, _ := passwordInput.Show("Enter your password")
-	return userId, password
-}
-
 func fetchDomainID(baseUrl string, name string) (string, error) {
 	// Parse the endpoint
 	parts := strings.Split(baseUrl, "://")

From 552d08f647c9f99edd6e54c6e121af34ac3f8393 Mon Sep 17 00:00:00 2001
From: Youngjin Jo <yjinjo@berkeley.edu>
Date: Thu, 28 Nov 2024 03:21:23 +0900
Subject: [PATCH 3/6] feat: add multi-user login feature of one domain

Signed-off-by: Youngjin Jo <yjinjo@berkeley.edu>
---
 cmd/other/login.go | 321 ++++++++++++++++++++++++++++++++++-----------
 1 file changed, 243 insertions(+), 78 deletions(-)

diff --git a/cmd/other/login.go b/cmd/other/login.go
index c1a529b..6e36fa8 100644
--- a/cmd/other/login.go
+++ b/cmd/other/login.go
@@ -223,10 +223,8 @@ func executeUserLogin(currentEnv string) {
 	var password string
 
 	if err := cacheViper.ReadInConfig(); err == nil {
-		// Access the 'users' field in the configuration
 		usersField := cacheViper.Get("environments." + currentEnv + ".users")
 		if usersField != nil {
-			// Make sure we are getting a slice of interfaces
 			users, ok := usersField.([]interface{})
 			if !ok {
 				pterm.Error.Println("Failed to load users correctly.")
@@ -234,45 +232,60 @@ func executeUserLogin(currentEnv string) {
 			}
 
 			if len(users) > 0 {
-				pterm.Info.Println("Multiple users detected. Please select an account to login:")
-
-				// Display the existing users
-				for i, user := range users {
-					userMap, ok := user.(map[string]interface{})
-					if !ok {
-						pterm.Error.Println("Invalid user format.")
-						exitWithError()
+				pterm.Info.Println("Select an account to login or add a new user:")
+
+				// Display user selection including "Add new user" option
+				userSelection := promptUserSelection(len(users), users)
+
+				if userSelection <= len(users) {
+					// Selected existing user
+					selectedUser := users[userSelection-1].(map[string]interface{})
+					userID = selectedUser["userid"].(string)
+					encryptedPassword := selectedUser["password"].(string)
+					token := selectedUser["token"].(string)
+
+					// Check if token is still valid
+					if !isTokenExpired(token) {
+						// Use stored password
+						decryptedPassword, err := decrypt(encryptedPassword)
+						if err != nil {
+							pterm.Error.Printf("Failed to decrypt password: %v\n", err)
+							exitWithError()
+						}
+						password = decryptedPassword
+						pterm.Success.Printf("Using saved credentials for %s\n", userID)
+					} else {
+						// Token expired, ask for password again
+						password = promptPassword()
+						// Verify the password matches
+						decryptedPassword, err := decrypt(encryptedPassword)
+						if err != nil {
+							pterm.Error.Printf("Failed to decrypt password: %v\n", err)
+							exitWithError()
+						}
+						if password != decryptedPassword {
+							pterm.Error.Println("Password does not match.")
+							exitWithError()
+						}
 					}
-
-					pterm.Printf("%d. %s\n", i+1, userMap["userid"].(string)) // Print actual userID
-				}
-
-				// Prompt user for selection
-				userSelection := promptUserSelection(len(users), users) // Pass the 'users' slice
-				selectedUser := users[userSelection-1].(map[string]interface{})
-
-				userID = selectedUser["userid"].(string)
-				password = selectedUser["password"].(string)
-				token := selectedUser["token"].(string)
-
-				// Check if the token is still valid
-				if !isTokenExpired(token) {
-					pterm.Success.Printf("Using saved credentials for %s\n", userID)
 				} else {
-					pterm.Warning.Println("Token expired. Please enter your password again.")
-					password = promptPassword() // Prompt for password if token expired
+					// Selected to add new user
+					userID, password = promptCredentials()
 				}
 			} else {
-				userID, password = promptCredentials() // Prompt for new credentials if no user exists
+				// No existing users, prompt for new credentials
+				userID, password = promptCredentials()
 			}
 		} else {
-			pterm.Error.Println("No users found in the configuration.")
-			exitWithError()
+			// Users field doesn't exist, prompt for new credentials
+			userID, password = promptCredentials()
 		}
 	} else {
-		userID, password = promptCredentials() // Prompt if the configuration cannot be read
+		// Configuration cannot be read, prompt for new credentials
+		userID, password = promptCredentials()
 	}
-	// Load the main config file specifically for environment name
+
+	// Proceed with domain ID fetching and token issuance
 	mainViper := viper.New()
 	mainViper.SetConfigFile(filepath.Join(homeDir, ".cfctl", "config.yaml"))
 	if err := mainViper.ReadInConfig(); err != nil {
@@ -296,21 +309,23 @@ func executeUserLogin(currentEnv string) {
 		exitWithError()
 	}
 
-	// Proceed with issuing the token and saving credentials
+	// Attempt to issue token
 	accessToken, refreshToken, err := issueToken(baseUrl, userID, password, domainID)
 	if err != nil {
 		pterm.Error.Println("Failed to retrieve token:", err)
 		exitWithError()
 	}
 
-	// Save the new credentials to the configuration file
-	saveCredentials(currentEnv, userID, password, accessToken)
+	// Encrypt password before saving
+	encryptedPassword, err := encrypt(password)
+	if err != nil {
+		pterm.Error.Printf("Failed to encrypt password: %v\n", err)
+		exitWithError()
+	}
 
-	// Fetch workspaces
 	workspaces, err := fetchWorkspaces(baseUrl, accessToken)
 	if err != nil {
 		pterm.Error.Println("Failed to fetch workspaces:", err)
-		exitWithError()
 	}
 
 	// Fetch Domain ID and Role Type
@@ -343,8 +358,10 @@ func executeUserLogin(currentEnv string) {
 		exitWithError()
 	}
 
-	// Save the new access token
-	saveToken(newAccessToken)
+	// Save the new credentials to the configuration file
+	saveCredentials(currentEnv, userID, encryptedPassword, newAccessToken)
+
+	fmt.Println()
 	pterm.Success.Println("Successfully logged in and saved token.")
 }
 
@@ -365,58 +382,179 @@ func promptPassword() string {
 
 // Prompt for user selection, now receiving 'users' slice as an argument
 func promptUserSelection(max int, users []interface{}) int {
-	// Open the keyboard input once
 	if err := keyboard.Open(); err != nil {
 		pterm.Error.Println("Failed to initialize keyboard:", err)
 		exitWithError()
 	}
-	defer keyboard.Close() // Ensure keyboard is closed at the end
+	defer keyboard.Close()
 
 	selectedIndex := 0
+	currentPage := 0
+	pageSize := 10
+	searchMode := false
+	searchTerm := ""
+	filteredUsers := users
+
 	for {
 		fmt.Print("\033[H\033[2J") // Clear the screen
 
-		// Display the list of available users
+		// Apply search filter
+		if searchTerm != "" {
+			filteredUsers = filterUsers(users, searchTerm)
+			if len(filteredUsers) == 0 {
+				filteredUsers = users // Show all users if no search results
+			}
+		} else {
+			filteredUsers = users
+		}
+
+		// Calculate pagination
+		totalUsers := len(filteredUsers)
+		totalPages := (totalUsers + pageSize - 1) / pageSize
+		startIndex := currentPage * pageSize
+		endIndex := startIndex + pageSize
+		if endIndex > totalUsers {
+			endIndex = totalUsers
+		}
+
+		// Display header with page information
 		pterm.DefaultHeader.WithFullWidth().
 			WithBackgroundStyle(pterm.NewStyle(pterm.BgDarkGray)).
 			WithTextStyle(pterm.NewStyle(pterm.FgLightWhite)).
-			Println("Select a user account:")
+			Printf("Select a user account (Page %d of %d)", currentPage+1, totalPages)
 
-		for i := 0; i < max; i++ {
-			userMap := users[i].(map[string]interface{})
-			pterm.Printf("→ %d: %s\n", i+1, userMap["userid"].(string)) // Print actual userID
+		// Display existing users
+		for i := startIndex; i < endIndex; i++ {
+			userMap := filteredUsers[i].(map[string]interface{})
+			if i == selectedIndex {
+				pterm.Printf("→ %d: %s\n", i+1, userMap["userid"].(string))
+			} else {
+				pterm.Printf("  %d: %s\n", i+1, userMap["userid"].(string))
+			}
 		}
 
+		// Display option to add new user
+		if selectedIndex == totalUsers {
+			pterm.Printf("→ %d: Add new user\n", totalUsers+1)
+		} else {
+			pterm.Printf("  %d: Add new user\n", totalUsers+1)
+		}
+
+		// Show navigation help
 		pterm.DefaultBasicText.WithStyle(pterm.NewStyle(pterm.FgGray)).
-			Println("\nNavigation: [j]down [k]up, [Enter]select, [q]quit")
+			Println("\nNavigation: [h]prev-page [j]down [k]up [l]next-page [/]search [Enter]select [q]quit")
 
-		// Get the keyboard input for selection
-		_, key, err := keyboard.GetKey()
+		// Show search prompt if in search mode
+		if searchMode {
+			fmt.Println()
+			pterm.Info.Printf("Search (ESC to cancel, Enter to confirm): %s", searchTerm)
+		}
+
+		// Get keyboard input
+		char, key, err := keyboard.GetKey()
 		if err != nil {
 			pterm.Error.Println("Error reading keyboard input:", err)
 			exitWithError()
 		}
 
+		// Handle search mode input
+		if searchMode {
+			switch key {
+			case keyboard.KeyEsc:
+				searchMode = false
+				searchTerm = ""
+				filteredUsers = users // Return to full user list when search term is cleared
+			case keyboard.KeyBackspace, keyboard.KeyBackspace2:
+				if len(searchTerm) > 0 {
+					searchTerm = searchTerm[:len(searchTerm)-1]
+				}
+			case keyboard.KeyEnter:
+				searchMode = false
+			default:
+				if char != 0 {
+					searchTerm += string(char)
+				}
+			}
+			currentPage = 0
+			selectedIndex = 0
+			continue
+		}
+
+		// Handle normal mode input
 		switch key {
 		case keyboard.KeyEnter:
-			if selectedIndex >= 0 && selectedIndex < max {
-				return selectedIndex + 1
+			if selectedIndex <= len(filteredUsers) {
+				// If "Add new user" is selected
+				if selectedIndex == len(filteredUsers) {
+					return len(users) + 1
+				}
+				// Find the original index of the selected user
+				selectedUserMap := filteredUsers[selectedIndex].(map[string]interface{})
+				selectedUserID := selectedUserMap["userid"].(string)
+
+				for i, user := range users {
+					userMap := user.(map[string]interface{})
+					if userMap["userid"].(string) == selectedUserID {
+						return i + 1
+					}
+				}
 			}
+		}
+
+		switch char {
 		case 'j': // Down
-			if selectedIndex < max-1 {
+			if selectedIndex < min(endIndex, totalUsers) {
 				selectedIndex++
 			}
 		case 'k': // Up
-			if selectedIndex > 0 {
+			if selectedIndex > startIndex {
 				selectedIndex--
 			}
-		case 'q', 'Q': // Quit
-			pterm.Error.Println("Selection cancelled.")
+		case 'l': // Next page
+			if currentPage < totalPages-1 {
+				currentPage++
+				selectedIndex = currentPage * pageSize
+			}
+		case 'h': // Previous page
+			if currentPage > 0 {
+				currentPage--
+				selectedIndex = currentPage * pageSize
+			}
+		case '/': // Enter search mode
+			searchMode = true
+			searchTerm = ""
+			selectedIndex = 0
+		case 'q', 'Q':
+			fmt.Println()
+			pterm.Error.Println("User selection cancelled.")
 			os.Exit(1)
 		}
 	}
 }
 
+// filterUsers filters the users list based on the search term
+func filterUsers(users []interface{}, searchTerm string) []interface{} {
+	var filtered []interface{}
+	searchTerm = strings.ToLower(searchTerm)
+
+	for _, user := range users {
+		userMap := user.(map[string]interface{})
+		userid := strings.ToLower(userMap["userid"].(string))
+		if strings.Contains(userid, searchTerm) {
+			filtered = append(filtered, user)
+		}
+	}
+	return filtered
+}
+
+// min returns the minimum of two integers
+func min(a, b int) int {
+	if a < b {
+		return a
+	}
+	return b
+}
+
 func getEncryptionKey() ([]byte, error) {
 	key, err := keyring.Get(keyringService, keyringUser)
 	if err == keyring.ErrNotFound {
@@ -492,6 +630,14 @@ func decrypt(cryptoText string) (string, error) {
 	return string(ciphertext), nil
 }
 
+// Define a struct for user credentials
+type UserCredentials struct {
+	UserID   string `yaml:"userid"`
+	Password string `yaml:"password"`
+	Token    string `yaml:"token"`
+}
+
+// saveCredentials saves the user's credentials to the configuration
 func saveCredentials(currentEnv, userID, password, token string) {
 	homeDir, err := os.UserHomeDir()
 	if err != nil {
@@ -514,29 +660,53 @@ func saveCredentials(currentEnv, userID, password, token string) {
 		return
 	}
 
-	if !cacheViper.IsSet("environments") {
-		cacheViper.Set("environments", map[string]interface{}{})
-	}
-
 	envPath := fmt.Sprintf("environments.%s", currentEnv)
 	envSettings := cacheViper.GetStringMap(envPath)
 	if envSettings == nil {
 		envSettings = make(map[string]interface{})
 	}
 
-	users := envSettings["users"].([]interface{})
-	newUser := map[string]interface{}{
-		"userid":   userID,
-		"password": password,
-		"token":    token,
+	var users []UserCredentials
+	if existingUsers, ok := envSettings["users"]; ok {
+		if userList, ok := existingUsers.([]interface{}); ok {
+			for _, u := range userList {
+				if userMap, ok := u.(map[string]interface{}); ok {
+					user := UserCredentials{
+						UserID:   userMap["userid"].(string),
+						Password: userMap["password"].(string),
+						Token:    userMap["token"].(string),
+					}
+					users = append(users, user)
+				}
+			}
+		}
 	}
 
-	users = append(users, newUser)
-	envSettings["users"] = users
+	// Update existing user or add new user
+	userExists := false
+	for i, user := range users {
+		if user.UserID == userID {
+			users[i].Password = password
+			users[i].Token = token
+			userExists = true
+			break
+		}
+	}
+
+	if !userExists {
+		newUser := UserCredentials{
+			UserID:   userID,
+			Password: password,
+			Token:    token,
+		}
+		users = append(users, newUser)
+	}
 
+	envSettings["users"] = users
 	cacheViper.Set(envPath, envSettings)
+
 	if err := cacheViper.WriteConfig(); err != nil {
-		pterm.Error.Printf("Failed to save credentials: %v\n", err)
+		pterm.Error.Printf("Failed to save user credentials: %v\n", err)
 	}
 }
 
@@ -1311,12 +1481,13 @@ func selectScopeOrWorkspace(workspaces []map[string]interface{}, roleType string
 			exitWithError()
 		}
 
+		// Handle navigation and other commands
 		switch key {
 		case keyboard.KeyEnter:
 			if selectedIndex == 0 {
-				return "0" // DOMAIN ADMIN 선택
+				return "0"
 			} else {
-				return selectWorkspaceOnly(workspaces) // WORKSPACES 선택
+				return selectWorkspaceOnly(workspaces)
 			}
 		}
 
@@ -1391,13 +1562,14 @@ func selectWorkspaceOnly(workspaces []map[string]interface{}) string {
 			}
 		}
 
-		// Show navigation help
+		// Show navigation help and search prompt
 		pterm.DefaultBasicText.WithStyle(pterm.NewStyle(pterm.FgGray)).
 			Println("\nNavigation: [h]prev-page [j]down [k]up  [l]next-page [/]search [q]uit")
 
 		// Show search or input prompt at the bottom
 		if searchMode {
-			pterm.Info.Printf("\nSearch (ESC to cancel, Enter to confirm): %s", searchTerm)
+			fmt.Println()
+			pterm.Info.Printf("Search (ESC to cancel, Enter to confirm): %s", searchTerm)
 		} else {
 			fmt.Print("\nSelect a workspace above or input a number: ")
 			if inputBuffer != "" {
@@ -1487,13 +1659,6 @@ func selectWorkspaceOnly(workspaces []map[string]interface{}) string {
 	}
 }
 
-func min(a, b int) int {
-	if a < b {
-		return a
-	}
-	return b
-}
-
 func filterWorkspaces(workspaces []map[string]interface{}, searchTerm string) []map[string]interface{} {
 	var filtered []map[string]interface{}
 	searchTerm = strings.ToLower(searchTerm)

From eb1e6a62e286d21a5fccea97788ef5ceccc44252 Mon Sep 17 00:00:00 2001
From: Youngjin Jo <yjinjo@berkeley.edu>
Date: Thu, 28 Nov 2024 03:39:37 +0900
Subject: [PATCH 4/6] feat: add multi-app login feature

Signed-off-by: Youngjin Jo <yjinjo@berkeley.edu>
---
 cmd/other/login.go | 376 ++++++++++++++++++++++-----------------------
 go.mod             |   5 +
 go.sum             |  22 +++
 3 files changed, 211 insertions(+), 192 deletions(-)

diff --git a/cmd/other/login.go b/cmd/other/login.go
index 6e36fa8..6050dcf 100644
--- a/cmd/other/login.go
+++ b/cmd/other/login.go
@@ -17,6 +17,7 @@ import (
 	"strings"
 	"time"
 
+	"github.com/AlecAivazis/survey/v2"
 	"github.com/eiannone/keyboard"
 
 	"google.golang.org/grpc/metadata"
@@ -69,134 +70,200 @@ func (t *tokenAuth) RequireTransportSecurity() bool {
 }
 
 func executeLogin(cmd *cobra.Command, args []string) {
-	// Load the environment-specific configuration without printing endpoint
-	loadEnvironmentConfig()
-
-	// Get current environment
 	homeDir, err := os.UserHomeDir()
 	if err != nil {
 		pterm.Error.Println("Failed to get user home directory:", err)
-		exitWithError()
+		return
 	}
 
-	mainViper := viper.New()
-	mainViper.SetConfigFile(filepath.Join(homeDir, ".cfctl", "config.yaml"))
-	if err := mainViper.ReadInConfig(); err != nil {
-		pterm.Error.Println("Failed to read main config file:", err)
-		exitWithError()
+	configPath := filepath.Join(homeDir, ".cfctl", "config.yaml")
+	viper.SetConfigFile(configPath)
+
+	if err := viper.ReadInConfig(); err != nil {
+		pterm.Error.Printf("Failed to read config file: %v\n", err)
+		return
 	}
 
-	currentEnv := mainViper.GetString("environment")
+	currentEnv := viper.GetString("environment")
 	if currentEnv == "" {
-		pterm.Error.Println("No environment specified in config.yaml")
-		exitWithError()
+		pterm.Error.Println("No environment selected")
+		return
 	}
 
-	// Print endpoint once here
-	pterm.Info.Printf("Using endpoint: %s\n", providedUrl)
-
+	// Check if it's an app environment
 	if strings.HasSuffix(currentEnv, "-app") {
-		executeAppLogin(currentEnv, mainViper)
+		if err := executeAppLogin(currentEnv); err != nil {
+			pterm.Error.Printf("Login failed: %v\n", err)
+			return
+		}
 	} else {
+		// Execute normal user login
 		executeUserLogin(currentEnv)
 	}
 }
 
-func executeAppLogin(currentEnv string, mainViper *viper.Viper) {
-	token := mainViper.GetString(fmt.Sprintf("environments.%s.token", currentEnv))
-	if token == "" {
-		pterm.Error.Println("No App token found for app environment.")
+type TokenInfo struct {
+	Token string `yaml:"token"`
+}
 
-		// Create a styled box for the app key type guidance
-		headerBox := pterm.DefaultBox.WithTitle("App Guide").
-			WithTitleTopCenter().
-			WithRightPadding(4).
-			WithLeftPadding(4).
-			WithBoxStyle(pterm.NewStyle(pterm.FgLightCyan))
-
-		appTokenExplain := "Please create a Domain Admin App in SpaceONE Console.\n" +
-			"This requires Domain Admin privilege.\n\n" +
-			"Or Please create a Workspace App in SpaceONE Console.\n" +
-			"This requires Workspace Owner privilege."
-
-		headerBox.Println(appTokenExplain)
-		fmt.Println()
-
-		// Create the steps content
-		steps := []string{
-			"1. Go to SpaceONE Console",
-			"2. Navigate to either 'Admin > App Page' or specific 'Workspace > App page'",
-			"3. Click 'Create' to create your App",
-			"4. Copy value of either 'client_secret' from Client ID or 'token' from Spacectl (CLI)",
+// promptToken prompts for token input
+func promptToken() (string, error) {
+	prompt := &survey.Password{
+		Message: "Enter your token:",
+	}
+
+	var token string
+	err := survey.AskOne(prompt, &token, survey.WithValidator(survey.Required))
+	if err != nil {
+		return "", err
+	}
+
+	return token, nil
+}
+
+// saveAppToken saves the token
+func saveAppToken(currentEnv, token string) error {
+	homeDir, _ := os.UserHomeDir()
+	configPath := filepath.Join(homeDir, ".cfctl", "config.yaml")
+
+	viper.SetConfigFile(configPath)
+	if err := viper.ReadInConfig(); err != nil && !os.IsNotExist(err) {
+		return err
+	}
+
+	envPath := fmt.Sprintf("environments.%s", currentEnv)
+	envSettings := viper.GetStringMap(envPath)
+	if envSettings == nil {
+		envSettings = make(map[string]interface{})
+	}
+
+	// Initialize tokens array if it doesn't exist
+	var tokens []TokenInfo
+	if existingTokens, ok := envSettings["tokens"]; ok {
+		if tokenList, ok := existingTokens.([]interface{}); ok {
+			for _, t := range tokenList {
+				if tokenMap, ok := t.(map[string]interface{}); ok {
+					tokenInfo := TokenInfo{
+						Token: tokenMap["token"].(string),
+					}
+					tokens = append(tokens, tokenInfo)
+				}
+			}
 		}
+	}
 
-		// Determine proxy value based on endpoint
-		isIdentityEndpoint := strings.Contains(strings.ToLower(providedUrl), "identity")
-		proxyValue := "true"
-		if !isIdentityEndpoint {
-			proxyValue = "false"
+	// Add new token if it doesn't exist
+	tokenExists := false
+	for _, t := range tokens {
+		if t.Token == token {
+			tokenExists = true
+			break
 		}
+	}
 
-		// Create yaml config example with highlighting
-		yamlExample := pterm.DefaultBox.WithTitle("Config Example").
-			WithTitleTopCenter().
-			WithRightPadding(4).
-			WithLeftPadding(4).
-			Sprint(fmt.Sprintf("environment: %s\nenvironments:\n    %s:\n        endpoint: %s\n        proxy: %s\n        token: %s",
-				currentEnv,
-				currentEnv,
-				providedUrl,
-				proxyValue,
-				pterm.FgLightCyan.Sprint("YOUR_COPIED_TOKEN")))
-
-		// Create instruction box
-		instructionBox := pterm.DefaultBox.WithTitle("Required Steps").
-			WithTitleTopCenter().
-			WithRightPadding(4).
-			WithLeftPadding(4)
+	if !tokenExists {
+		newToken := TokenInfo{
+			Token: token,
+		}
+		tokens = append(tokens, newToken)
+	}
 
-		// Combine all steps
-		allSteps := append(steps,
-			fmt.Sprintf("5. Add the token under the proxy in your config file:\n%s", yamlExample),
-			"6. Run 'cfctl login' again")
+	// Update environment settings
+	envSettings["tokens"] = tokens
 
-		// Print all steps in the instruction box
-		instructionBox.Println(strings.Join(allSteps, "\n\n"))
+	// Keep the existing endpoint and proxy settings
+	if endpoint, ok := envSettings["endpoint"]; ok {
+		envSettings["endpoint"] = endpoint
+	}
+	if proxy, ok := envSettings["proxy"]; ok {
+		envSettings["proxy"] = proxy
+	}
 
-		exitWithError()
+	viper.Set(envPath, envSettings)
+	return viper.WriteConfig()
+}
+
+// promptTokenSelection shows available tokens and lets user select one
+func promptTokenSelection(tokens []TokenInfo) (string, error) {
+	if len(tokens) == 0 {
+		return "", fmt.Errorf("no tokens available")
 	}
 
-	claims, ok := verifyAppToken(token)
-	if !ok {
-		exitWithError()
+	if err := keyboard.Open(); err != nil {
+		return "", err
 	}
+	defer keyboard.Close()
 
-	headerBox := pterm.DefaultBox.WithTitle("App Token Information").
-		WithTitleTopCenter().
-		WithRightPadding(4).
-		WithLeftPadding(4).
-		WithBoxStyle(pterm.NewStyle(pterm.FgLightCyan))
+	selectedIndex := 0
+	for {
+		fmt.Print("\033[H\033[2J") // Clear screen
 
-	var tokenInfo string
-	roleType := claims["rol"].(string)
+		pterm.DefaultHeader.WithFullWidth().
+			WithBackgroundStyle(pterm.NewStyle(pterm.BgDarkGray)).
+			WithTextStyle(pterm.NewStyle(pterm.FgLightWhite)).
+			Println("Select a token:")
 
-	if roleType == "DOMAIN_ADMIN" {
-		tokenInfo = fmt.Sprintf("Role Type: %s\nDomain ID: %s\nAccess Scope: All Workspaces\nExpires: %s",
-			pterm.FgGreen.Sprint("DOMAIN ADMIN"),
-			claims["did"].(string),
-			time.Unix(int64(claims["exp"].(float64)), 0).Format("2006-01-02 15:04:05"))
-	} else if roleType == "WORKSPACE_OWNER" {
-		tokenInfo = fmt.Sprintf("Role Type: %s\nDomain ID: %s\nWorkspace ID: %s\nExpires: %s",
-			pterm.FgYellow.Sprint("WORKSPACE OWNER"),
-			claims["did"].(string),
-			claims["wid"].(string),
-			time.Unix(int64(claims["exp"].(float64)), 0).Format("2006-01-02 15:04:05"))
-	}
-
-	headerBox.Println(tokenInfo)
-	fmt.Println()
+		// Display available tokens
+		for i, token := range tokens {
+			maskedToken := maskToken(token.Token)
+			if i == selectedIndex {
+				pterm.Printf("→ %d: %s\n", i+1, maskedToken)
+			} else {
+				pterm.Printf("  %d: %s\n", i+1, maskedToken)
+			}
+		}
 
-	pterm.Success.Println("Successfully authenticated with App token.")
+		pterm.DefaultBasicText.WithStyle(pterm.NewStyle(pterm.FgGray)).
+			Println("\nNavigation: [j]down [k]up [Enter]select [q]quit")
+
+		char, key, err := keyboard.GetKey()
+		if err != nil {
+			return "", err
+		}
+
+		switch key {
+		case keyboard.KeyEnter:
+			return tokens[selectedIndex].Token, nil
+		}
+
+		switch char {
+		case 'j':
+			if selectedIndex < len(tokens)-1 {
+				selectedIndex++
+			}
+		case 'k':
+			if selectedIndex > 0 {
+				selectedIndex--
+			}
+		case 'q', 'Q':
+			return "", fmt.Errorf("selection cancelled")
+		}
+	}
+}
+
+// maskToken returns a masked version of the token for display
+func maskToken(token string) string {
+	if len(token) <= 10 {
+		return strings.Repeat("*", len(token))
+	}
+	return token[:5] + "..." + token[len(token)-5:]
+}
+
+// executeAppLogin handles login for app environments
+func executeAppLogin(currentEnv string) error {
+	// Get token from user
+	token, err := promptToken()
+	if err != nil {
+		return err
+	}
+
+	// Save token to tokens array
+	if err := saveAppToken(currentEnv, token); err != nil {
+		return err
+	}
+
+	pterm.Success.Printf("Token successfully saved\n")
+	return nil
 }
 
 func executeUserLogin(currentEnv string) {
@@ -1330,113 +1397,38 @@ func grantToken(baseUrl, refreshToken, scope, domainID, workspaceID string) (str
 	return accessToken.(string), nil
 }
 
-// saveToken updates the token in the appropriate configuration file based on the environment suffix
-func saveToken(newToken string) {
-	homeDir, err := os.UserHomeDir()
-	if err != nil {
-		pterm.Error.Printf("Failed to get user home directory: %v\n", err)
-		exitWithError()
-	}
-
-	// Get current environment from main config
-	mainViper := viper.New()
-	mainConfigPath := filepath.Join(homeDir, ".cfctl", "config.yaml")
-	mainViper.SetConfigFile(mainConfigPath)
-
-	if err := mainViper.ReadInConfig(); err != nil {
-		pterm.Error.Printf("Failed to read main config: %v\n", err)
-		exitWithError()
-	}
-
-	currentEnvironment := mainViper.GetString("environment")
-	if currentEnvironment == "" {
-		pterm.Error.Printf("No environment specified in config\n")
-		exitWithError()
-	}
-
-	// Determine which config file to use based on environment suffix
-	var configPath string
-	v := viper.New()
+// saveSelectedToken saves the selected token as the current token for the environment
+func saveSelectedToken(currentEnv, selectedToken string) error {
+	homeDir, _ := os.UserHomeDir()
+	configPath := filepath.Join(homeDir, ".cfctl", "config.yaml")
 
-	if strings.HasSuffix(currentEnvironment, "-user") {
-		// User configuration goes in cache directory
-		cacheDir := filepath.Join(homeDir, ".cfctl", "cache")
-		if err := os.MkdirAll(cacheDir, 0755); err != nil {
-			pterm.Error.Printf("Failed to create cache directory: %v\n", err)
-			exitWithError()
-		}
-		configPath = filepath.Join(cacheDir, "config.yaml")
-	} else if strings.HasSuffix(currentEnvironment, "-app") {
-		// App configuration goes in main config
-		configPath = mainConfigPath
-	} else {
-		pterm.Error.Printf("Invalid environment suffix (must end with -app or -user): %s\n", currentEnvironment)
-		exitWithError()
+	viper.SetConfigFile(configPath)
+	if err := viper.ReadInConfig(); err != nil && !os.IsNotExist(err) {
+		return err
 	}
 
-	// Initialize or read the config file
-	v.SetConfigFile(configPath)
-
-	// Create config file with basic structure if it doesn't exist
-	if _, err := os.Stat(configPath); os.IsNotExist(err) {
-		initialConfig := []byte("environments:\n")
-		if err := os.WriteFile(configPath, initialConfig, 0644); err != nil {
-			pterm.Error.Printf("Failed to create config file: %v\n", err)
-			exitWithError()
-		}
-	}
-
-	if err := v.ReadInConfig(); err != nil {
-		pterm.Error.Printf("Failed to read config: %v\n", err)
-		exitWithError()
-	}
-
-	// Get current environment settings
-	envPath := fmt.Sprintf("environments.%s", currentEnvironment)
-	envSettings := v.GetStringMap(envPath)
+	envPath := fmt.Sprintf("environments.%s", currentEnv)
+	envSettings := viper.GetStringMap(envPath)
 	if envSettings == nil {
 		envSettings = make(map[string]interface{})
 	}
 
-	// Update token while preserving other settings
-	envSettings["token"] = newToken
-
-	// Save updated settings
-	v.Set(envPath, envSettings)
-
-	if err := v.WriteConfig(); err != nil {
-		pterm.Error.Printf("Failed to save token: %v\n", err)
-		exitWithError()
+	// Keep only endpoint and proxy settings
+	newEnvSettings := make(map[string]interface{})
+	if endpoint, ok := envSettings["endpoint"]; ok {
+		newEnvSettings["endpoint"] = endpoint
 	}
-
-	fmt.Println()
-	pterm.Success.Printf("Token successfully saved to %s\n", configPath)
-}
-
-// sortEnvironmentContent sorts the environment content to ensure token is at the end
-func sortEnvironmentContent(content []string, token string, indentLevel int) []string {
-	var sorted []string
-	var endpointLine, proxyLine string
-
-	for _, line := range content {
-		trimmed := strings.TrimSpace(line)
-		if strings.HasPrefix(trimmed, "endpoint:") {
-			endpointLine = line
-		} else if strings.HasPrefix(trimmed, "proxy:") {
-			proxyLine = line
-		}
+	if proxy, ok := envSettings["proxy"]; ok {
+		newEnvSettings["proxy"] = proxy
 	}
 
-	if endpointLine != "" {
-		sorted = append(sorted, endpointLine)
+	// Keep the tokens array
+	if tokens, ok := envSettings["tokens"]; ok {
+		newEnvSettings["tokens"] = tokens
 	}
-	if proxyLine != "" {
-		sorted = append(sorted, proxyLine)
-	}
-
-	sorted = append(sorted, strings.Repeat(" ", indentLevel)+"token: "+token)
 
-	return sorted
+	viper.Set(envPath, newEnvSettings)
+	return viper.WriteConfig()
 }
 
 func selectScopeOrWorkspace(workspaces []map[string]interface{}, roleType string) string {
diff --git a/go.mod b/go.mod
index 1d38c81..01cc449 100644
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,7 @@ module github.com/cloudforet-io/cfctl
 go 1.23.1
 
 require (
+	github.com/AlecAivazis/survey/v2 v2.3.7
 	github.com/atotto/clipboard v0.1.4
 	github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203
 	github.com/jhump/protoreflect v1.17.0
@@ -30,9 +31,13 @@ require (
 	github.com/gookit/color v1.5.4 // indirect
 	github.com/hashicorp/hcl v1.0.0 // indirect
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
+	github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
 	github.com/lithammer/fuzzysearch v1.1.8 // indirect
 	github.com/magiconair/properties v1.8.7 // indirect
+	github.com/mattn/go-colorable v0.1.13 // indirect
+	github.com/mattn/go-isatty v0.0.17 // indirect
 	github.com/mattn/go-runewidth v0.0.15 // indirect
+	github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
 	github.com/mitchellh/mapstructure v1.5.0 // indirect
 	github.com/pelletier/go-toml/v2 v2.2.2 // indirect
 	github.com/rivo/uniseg v0.4.4 // indirect
diff --git a/go.sum b/go.sum
index a350b81..4ffe438 100644
--- a/go.sum
+++ b/go.sum
@@ -8,6 +8,8 @@ atomicgo.dev/keyboard v0.2.9 h1:tOsIid3nlPLZ3lwgG8KZMp/SFmr7P0ssEN5JUsm78K8=
 atomicgo.dev/keyboard v0.2.9/go.mod h1:BC4w9g00XkxH/f1HXhW2sXmJFOCWbKn9xrOunSFtExQ=
 atomicgo.dev/schedule v0.1.0 h1:nTthAbhZS5YZmgYbb2+DH8uQIZcTlIrd4eYr3UQxEjs=
 atomicgo.dev/schedule v0.1.0/go.mod h1:xeUa3oAkiuHYh8bKiQBRojqAMq3PXXbJujjb0hw8pEU=
+github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
+github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
 github.com/MarvinJWendt/testza v0.1.0/go.mod h1:7AxNvlfeHP7Z/hDQ5JtE3OKYT3XFUeLCDE2DQninSqs=
 github.com/MarvinJWendt/testza v0.2.1/go.mod h1:God7bhG8n6uQxwdScay+gjm9/LnO4D3kkcZX4hv9Rp8=
 github.com/MarvinJWendt/testza v0.2.8/go.mod h1:nwIcjmr0Zz+Rcwfh3/4UhBp7ePKVhuBExvZqnKYWlII=
@@ -17,6 +19,8 @@ github.com/MarvinJWendt/testza v0.3.0/go.mod h1:eFcL4I0idjtIx8P9C6KkAuLgATNKpX4/
 github.com/MarvinJWendt/testza v0.4.2/go.mod h1:mSdhXiKH8sg/gQehJ63bINcCKp7RtYewEjXsvsVUPbE=
 github.com/MarvinJWendt/testza v0.5.2 h1:53KDo64C1z/h/d/stCYCPY69bt/OSwjq5KpFNwi+zB4=
 github.com/MarvinJWendt/testza v0.5.2/go.mod h1:xu53QFE5sCdjtMCKk8YMQ2MnymimEctc4n3EjyIYvEY=
+github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s=
+github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w=
 github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk=
 github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
 github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
@@ -25,6 +29,8 @@ github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU
 github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
 github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
 github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
+github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
+github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
 github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ0i0=
 github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -51,10 +57,14 @@ github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0=
 github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w=
 github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
 github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
+github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
+github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
 github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94=
 github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8=
+github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
+github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
 github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
 github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
 github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
@@ -72,9 +82,18 @@ github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8
 github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
 github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
 github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
+github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
+github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
+github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
+github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
+github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
+github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
 github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
 github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
 github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
+github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
 github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
 github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
 github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
@@ -159,6 +178,7 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
 golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -167,6 +187,7 @@ golang.org/x/sys v0.0.0-20211013075003-97ac67df715c/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
 golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
@@ -180,6 +201,7 @@ golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
 golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=

From 82cf4726050e5714c992485405a2b6545206e0a2 Mon Sep 17 00:00:00 2001
From: Youngjin Jo <yjinjo@berkeley.edu>
Date: Thu, 28 Nov 2024 04:10:23 +0900
Subject: [PATCH 5/6] refactor: add multi-app login

Signed-off-by: Youngjin Jo <yjinjo@berkeley.edu>
---
 cmd/other/login.go | 266 +++++++++++++++++++++++++++++++++++++++------
 1 file changed, 235 insertions(+), 31 deletions(-)

diff --git a/cmd/other/login.go b/cmd/other/login.go
index 6050dcf..73fc2cf 100644
--- a/cmd/other/login.go
+++ b/cmd/other/login.go
@@ -251,19 +251,143 @@ func maskToken(token string) string {
 
 // executeAppLogin handles login for app environments
 func executeAppLogin(currentEnv string) error {
-	// Get token from user
-	token, err := promptToken()
-	if err != nil {
+	homeDir, _ := os.UserHomeDir()
+	configPath := filepath.Join(homeDir, ".cfctl", "config.yaml")
+
+	viper.SetConfigFile(configPath)
+	if err := viper.ReadInConfig(); err != nil && !os.IsNotExist(err) {
 		return err
 	}
 
-	// Save token to tokens array
-	if err := saveAppToken(currentEnv, token); err != nil {
+	envPath := fmt.Sprintf("environments.%s.tokens", currentEnv)
+	var tokens []TokenInfo
+	if tokensList := viper.Get(envPath); tokensList != nil {
+		if tokenList, ok := tokensList.([]interface{}); ok {
+			for _, t := range tokenList {
+				if tokenMap, ok := t.(map[string]interface{}); ok {
+					tokenInfo := TokenInfo{
+						Token: tokenMap["token"].(string),
+					}
+					tokens = append(tokens, tokenInfo)
+				}
+			}
+		}
+	}
+
+	if err := keyboard.Open(); err != nil {
 		return err
 	}
+	defer keyboard.Close()
+
+	selectedIndex := 0
+	options := []string{"Enter a new token"}
+	var validTokens []TokenInfo // 유효한 토큰만 저장할 새로운 슬라이스
+
+	for _, tokenInfo := range tokens {
+		claims, err := validateAndDecodeToken(tokenInfo.Token)
+		if err != nil {
+			pterm.Warning.Printf("Invalid token found in config: %v\n", err)
+			continue
+		}
+
+		displayName := getTokenDisplayName(claims)
+		options = append(options, displayName)
+		validTokens = append(validTokens, tokenInfo)
+	}
+
+	if len(validTokens) == 0 && len(tokens) > 0 {
+		pterm.Warning.Println("All existing tokens are invalid. Please enter a new token.")
+		// Clear invalid tokens from config
+		if err := clearInvalidTokens(currentEnv); err != nil {
+			pterm.Warning.Printf("Failed to clear invalid tokens: %v\n", err)
+		}
+	}
+
+	for {
+		fmt.Print("\033[H\033[2J") // Clear screen
+
+		pterm.DefaultHeader.WithFullWidth().
+			WithBackgroundStyle(pterm.NewStyle(pterm.BgDarkGray)).
+			WithTextStyle(pterm.NewStyle(pterm.FgLightWhite)).
+			Println("Choose an option:")
+
+		for i, option := range options {
+			if i == selectedIndex {
+				pterm.Printf("→ %d: %s\n", i, option)
+			} else {
+				pterm.Printf("  %d: %s\n", i, option)
+			}
+		}
+
+		pterm.DefaultBasicText.WithStyle(pterm.NewStyle(pterm.FgGray)).
+			Println("\nNavigation: [j]down [k]up [Enter]select [q]uit")
+
+		char, key, err := keyboard.GetKey()
+		if err != nil {
+			return err
+		}
+
+		switch key {
+		case keyboard.KeyEnter:
+			if selectedIndex == 0 {
+				// Enter a new token
+				token, err := promptToken()
+				if err != nil {
+					return err
+				}
+
+				// Validate new token before saving
+				if _, err := validateAndDecodeToken(token); err != nil {
+					return fmt.Errorf("invalid token: %v", err)
+				}
+
+				// First save to tokens array
+				if err := saveAppToken(currentEnv, token); err != nil {
+					return err
+				}
+				// Then set as current token
+				if err := saveSelectedToken(currentEnv, token); err != nil {
+					return err
+				}
+				pterm.Success.Printf("Token successfully saved and selected\n")
+				return nil
+			} else {
+				// Use selected token from existing valid tokens
+				selectedToken := validTokens[selectedIndex-1].Token
+				if err := saveSelectedToken(currentEnv, selectedToken); err != nil {
+					return fmt.Errorf("failed to save selected token: %v", err)
+				}
+				pterm.Success.Printf("Token successfully selected\n")
+				return nil
+			}
+		}
+
+		switch char {
+		case 'j':
+			if selectedIndex < len(options)-1 {
+				selectedIndex++
+			}
+		case 'k':
+			if selectedIndex > 0 {
+				selectedIndex--
+			}
+		case 'q', 'Q':
+			pterm.Error.Println("Selection cancelled.")
+			os.Exit(1)
+		}
+	}
+}
+
+func getTokenDisplayName(claims map[string]interface{}) string {
+	role := claims["rol"].(string)
+	domainID := claims["did"].(string)
+
+	if role == "WORKSPACE_OWNER" {
+		workspaceID := claims["wid"].(string)
+		return fmt.Sprintf("%s (%s, %s)", role, domainID, workspaceID)
+	}
 
-	pterm.Success.Printf("Token successfully saved\n")
-	return nil
+	return fmt.Sprintf("%s (%s)", role, domainID)
 }
 
 func executeUserLogin(currentEnv string) {
@@ -912,33 +1036,17 @@ func determineScope(roleType string, workspaceCount int) string {
 	}
 }
 
+// isTokenExpired checks if the token is expired
 func isTokenExpired(token string) bool {
-	parts := strings.Split(token, ".")
-	if len(parts) != 3 {
-		pterm.Error.Println("Invalid token format.")
-		return true
-	}
-
-	payload, err := base64.RawURLEncoding.DecodeString(parts[1])
+	claims, err := decodeJWT(token)
 	if err != nil {
-		pterm.Error.Println("Failed to decode token payload:", err)
-		return true
-	}
-
-	var claims map[string]interface{}
-	if err := json.Unmarshal(payload, &claims); err != nil {
-		pterm.Error.Println("Failed to unmarshal token payload:", err)
-		return true
+		return true // 디코딩 실패 시 만료된 것으로 간주
 	}
 
-	exp, ok := claims["exp"].(float64)
-	if !ok {
-		pterm.Error.Println("Expiration time (exp) not found in token.")
-		return true
+	if exp, ok := claims["exp"].(float64); ok {
+		return time.Now().Unix() > int64(exp)
 	}
-
-	expirationTime := time.Unix(int64(exp), 0)
-	return time.Now().After(expirationTime)
+	return true // exp 필드가 없거나 잘못된 형식이면 만료된 것으로 간주
 }
 
 func verifyToken(token string) bool {
@@ -1413,8 +1521,10 @@ func saveSelectedToken(currentEnv, selectedToken string) error {
 		envSettings = make(map[string]interface{})
 	}
 
-	// Keep only endpoint and proxy settings
+	// Keep all existing settings
 	newEnvSettings := make(map[string]interface{})
+
+	// Keep endpoint and proxy settings
 	if endpoint, ok := envSettings["endpoint"]; ok {
 		newEnvSettings["endpoint"] = endpoint
 	}
@@ -1422,11 +1532,14 @@ func saveSelectedToken(currentEnv, selectedToken string) error {
 		newEnvSettings["proxy"] = proxy
 	}
 
-	// Keep the tokens array
+	// Keep tokens array
 	if tokens, ok := envSettings["tokens"]; ok {
 		newEnvSettings["tokens"] = tokens
 	}
 
+	// Set the selected token as current token
+	newEnvSettings["token"] = selectedToken
+
 	viper.Set(envPath, newEnvSettings)
 	return viper.WriteConfig()
 }
@@ -1667,3 +1780,94 @@ func filterWorkspaces(workspaces []map[string]interface{}, searchTerm string) []
 func init() {
 	LoginCmd.Flags().StringVarP(&providedUrl, "url", "u", "", "The URL to use for login (e.g. cfctl login -u https://example.com)")
 }
+
+// decodeJWT decodes a JWT token and returns the claims
+func decodeJWT(token string) (map[string]interface{}, error) {
+	parts := strings.Split(token, ".")
+	if len(parts) != 3 {
+		return nil, fmt.Errorf("invalid token format")
+	}
+
+	payload, err := base64.RawURLEncoding.DecodeString(parts[1])
+	if err != nil {
+		return nil, err
+	}
+
+	var claims map[string]interface{}
+	if err := json.Unmarshal(payload, &claims); err != nil {
+		return nil, err
+	}
+
+	return claims, nil
+}
+
+// validateAndDecodeToken decodes a JWT token and validates its expiration
+func validateAndDecodeToken(token string) (map[string]interface{}, error) {
+	// Check if token has three parts (header.payload.signature)
+	parts := strings.Split(token, ".")
+	if len(parts) != 3 {
+		return nil, fmt.Errorf("invalid token format: token must have three parts")
+	}
+
+	// Try to decode the payload
+	payload, err := base64.RawURLEncoding.DecodeString(parts[1])
+	if err != nil {
+		return nil, fmt.Errorf("invalid token format: failed to decode payload: %v", err)
+	}
+
+	var claims map[string]interface{}
+	if err := json.Unmarshal(payload, &claims); err != nil {
+		return nil, fmt.Errorf("invalid token format: failed to parse payload: %v", err)
+	}
+
+	// Check required fields
+	requiredFields := []string{"exp", "rol", "did"}
+	for _, field := range requiredFields {
+		if _, ok := claims[field]; !ok {
+			return nil, fmt.Errorf("invalid token format: missing required field '%s'", field)
+		}
+	}
+
+	// Check expiration
+	if isTokenExpired(token) {
+		return nil, fmt.Errorf("token has expired")
+	}
+
+	return claims, nil
+}
+
+// clearInvalidTokens removes invalid tokens from the config
+func clearInvalidTokens(currentEnv string) error {
+	homeDir, _ := os.UserHomeDir()
+	configPath := filepath.Join(homeDir, ".cfctl", "config.yaml")
+
+	viper.SetConfigFile(configPath)
+	if err := viper.ReadInConfig(); err != nil {
+		return err
+	}
+
+	envPath := fmt.Sprintf("environments.%s", currentEnv)
+	envSettings := viper.GetStringMap(envPath)
+	if envSettings == nil {
+		return nil
+	}
+
+	var validTokens []TokenInfo
+	if tokensList := viper.Get(fmt.Sprintf("%s.tokens", envPath)); tokensList != nil {
+		if tokenList, ok := tokensList.([]interface{}); ok {
+			for _, t := range tokenList {
+				if tokenMap, ok := t.(map[string]interface{}); ok {
+					token := tokenMap["token"].(string)
+					if _, err := validateAndDecodeToken(token); err == nil {
+						validTokens = append(validTokens, TokenInfo{Token: token})
+					}
+				}
+			}
+		}
+	}
+
+	// Update config with only valid tokens
+	envSettings["tokens"] = validTokens
+	viper.Set(envPath, envSettings)
+	return viper.WriteConfig()
+}

From 53d78ed71e0b2adc286822fe1eef7b29808023db Mon Sep 17 00:00:00 2001
From: Youngjin Jo <yjinjo@berkeley.edu>
Date: Thu, 28 Nov 2024 04:37:23 +0900
Subject: [PATCH 6/6] refactor: implement user login and add token

Signed-off-by: Youngjin Jo <yjinjo@berkeley.edu>
---
 cmd/other/login.go | 45 +++++++++++++++++++++++----------------------
 1 file changed, 23 insertions(+), 22 deletions(-)

diff --git a/cmd/other/login.go b/cmd/other/login.go
index 73fc2cf..e46d46d 100644
--- a/cmd/other/login.go
+++ b/cmd/other/login.go
@@ -61,7 +61,7 @@ type tokenAuth struct {
 
 func (t *tokenAuth) GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) {
 	return map[string]string{
-		"token": t.token, // "Authorization: Bearer" 대신 "token" 키 사용
+		"token": t.token, // Use "token" key instead of "Authorization: Bearer"
 	}, nil
 }
 
@@ -281,7 +281,7 @@ func executeAppLogin(currentEnv string) error {
 
 	selectedIndex := 0
 	options := []string{"Enter a new token"}
-	var validTokens []TokenInfo // 유효한 토큰만 저장할 새로운 슬라이스
+	var validTokens []TokenInfo // New slice to store only valid tokens
 
 	for _, tokenInfo := range tokens {
 		claims, err := validateAndDecodeToken(tokenInfo.Token)
@@ -614,23 +614,23 @@ func promptUserSelection(max int, users []interface{}) int {
 			WithTextStyle(pterm.NewStyle(pterm.FgLightWhite)).
 			Printf("Select a user account (Page %d of %d)", currentPage+1, totalPages)
 
+		// Display option to add new user first
+		if selectedIndex == 0 {
+			pterm.Printf("→ %d: Add new user\n", 1)
+		} else {
+			pterm.Printf("  %d: Add new user\n", 1)
+		}
+
 		// Display existing users
 		for i := startIndex; i < endIndex; i++ {
 			userMap := filteredUsers[i].(map[string]interface{})
-			if i == selectedIndex {
-				pterm.Printf("→ %d: %s\n", i+1, userMap["userid"].(string))
+			if i+1 == selectedIndex {
+				pterm.Printf("→ %d: %s\n", i+2, userMap["userid"].(string))
 			} else {
-				pterm.Printf("  %d: %s\n", i+1, userMap["userid"].(string))
+				pterm.Printf("  %d: %s\n", i+2, userMap["userid"].(string))
 			}
 		}
 
-		// Display option to add new user
-		if selectedIndex == totalUsers {
-			pterm.Printf("→ %d: Add new user\n", totalUsers+1)
-		} else {
-			pterm.Printf("  %d: Add new user\n", totalUsers+1)
-		}
-
 		// Show navigation help
 		pterm.DefaultBasicText.WithStyle(pterm.NewStyle(pterm.FgGray)).
 			Println("\nNavigation: [h]prev-page [j]down [k]up [l]next-page [/]search [Enter]select [q]quit")
@@ -674,13 +674,11 @@ func promptUserSelection(max int, users []interface{}) int {
 		// Handle normal mode input
 		switch key {
 		case keyboard.KeyEnter:
-			if selectedIndex <= len(filteredUsers) {
-				// If "Add new user" is selected
-				if selectedIndex == len(filteredUsers) {
-					return len(users) + 1
-				}
+			if selectedIndex == 0 {
+				return len(users) + 1 // Add new user
+			} else if selectedIndex <= len(filteredUsers) {
 				// Find the original index of the selected user
-				selectedUserMap := filteredUsers[selectedIndex].(map[string]interface{})
+				selectedUserMap := filteredUsers[selectedIndex-1].(map[string]interface{})
 				selectedUserID := selectedUserMap["userid"].(string)
 
 				for i, user := range users {
@@ -694,22 +692,22 @@ func promptUserSelection(max int, users []interface{}) int {
 
 		switch char {
 		case 'j': // Down
-			if selectedIndex < min(endIndex, totalUsers) {
+			if selectedIndex < min(endIndex-startIndex, totalUsers) {
 				selectedIndex++
 			}
 		case 'k': // Up
-			if selectedIndex > startIndex {
+			if selectedIndex > 0 {
 				selectedIndex--
 			}
 		case 'l': // Next page
 			if currentPage < totalPages-1 {
 				currentPage++
-				selectedIndex = currentPage * pageSize
+				selectedIndex = 0
 			}
 		case 'h': // Previous page
 			if currentPage > 0 {
 				currentPage--
-				selectedIndex = currentPage * pageSize
+				selectedIndex = 0
 			}
 		case '/': // Enter search mode
 			searchMode = true
@@ -857,6 +855,9 @@ func saveCredentials(currentEnv, userID, password, token string) {
 		envSettings = make(map[string]interface{})
 	}
 
+	// Save token at the root level of the environment
+	envSettings["token"] = token
+
 	var users []UserCredentials
 	if existingUsers, ok := envSettings["users"]; ok {
 		if userList, ok := existingUsers.([]interface{}); ok {