apikey.go

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: AGPL-3.0-or-later
  4
  5package init
  6
  7import (
  8	"context"
  9	"errors"
 10	"fmt"
 11	"io"
 12	"time"
 13
 14	"git.secluded.site/go-lunatask"
 15	"github.com/charmbracelet/huh"
 16	"github.com/spf13/cobra"
 17
 18	"git.secluded.site/lune/internal/client"
 19	"git.secluded.site/lune/internal/ui"
 20)
 21
 22const tokenValidationTimeout = 10 * time.Second
 23
 24// handleKeyringError prints keyring error messages and returns a wrapped error.
 25func handleKeyringError(out io.Writer, err error) error {
 26	fmt.Fprintln(out, ui.Error.Render("Failed to access system keyring: "+err.Error()))
 27	fmt.Fprintln(out, "Please resolve the keyring issue and try again.")
 28
 29	return fmt.Errorf("keyring access failed: %w", err)
 30}
 31
 32func configureAccessToken(cmd *cobra.Command) error {
 33	out := cmd.OutOrStdout()
 34
 35	hasToken, keyringErr := client.HasKeyringToken()
 36	if keyringErr != nil {
 37		return handleKeyringError(out, keyringErr)
 38	}
 39
 40	if hasToken {
 41		shouldPrompt, err := handleExistingToken(out)
 42		if err != nil {
 43			return err
 44		}
 45
 46		if !shouldPrompt {
 47			return nil
 48		}
 49	}
 50
 51	return promptForToken(out)
 52}
 53
 54// ensureAccessToken checks for a token in the keyring and prompts for one if
 55// missing or invalid. Used at the start of reconfigure mode so users copying
 56// config to a new device are immediately prompted for their token.
 57func ensureAccessToken(cmd *cobra.Command) error {
 58	out := cmd.OutOrStdout()
 59
 60	hasToken, keyringErr := client.HasKeyringToken()
 61	if keyringErr != nil {
 62		return handleKeyringError(out, keyringErr)
 63	}
 64
 65	if !hasToken {
 66		fmt.Fprintln(out, ui.Warning.Render("No access token found in system keyring."))
 67
 68		return promptForToken(out)
 69	}
 70
 71	existingToken, err := client.GetToken()
 72	if err != nil {
 73		return fmt.Errorf("reading access token from keyring: %w", err)
 74	}
 75
 76	if err := validateWithSpinner(existingToken); err != nil {
 77		fmt.Fprintln(out, ui.Warning.Render("Existing access token failed validation: "+err.Error()))
 78
 79		var replace bool
 80
 81		confirmErr := huh.NewConfirm().
 82			Title("Would you like to provide a new access token?").
 83			Affirmative("Yes").
 84			Negative("No").
 85			Value(&replace).
 86			Run()
 87		if confirmErr != nil {
 88			if errors.Is(confirmErr, huh.ErrUserAborted) {
 89				return errQuit
 90			}
 91
 92			return confirmErr
 93		}
 94
 95		if replace {
 96			return promptForToken(out)
 97		}
 98	}
 99
100	return nil
101}
102
103func handleExistingToken(out io.Writer) (bool, error) {
104	existingToken, err := client.GetToken()
105	if err != nil {
106		return false, fmt.Errorf("reading access token from keyring: %w", err)
107	}
108
109	fmt.Fprintln(out, "Access token found in system keyring.")
110
111	if err := validateWithSpinner(existingToken); err != nil {
112		fmt.Fprintln(out, ui.Warning.Render("Validation failed: "+err.Error()))
113	} else {
114		fmt.Fprintln(out, ui.Success.Render("βœ“ Authentication successful"))
115	}
116
117	var action string
118
119	err = huh.NewSelect[string]().
120		Title("Access token is already configured").
121		Options(
122			huh.NewOption("Keep existing token", "keep"),
123			huh.NewOption("Replace with new token", "replace"),
124			huh.NewOption("Delete from keyring", actionDelete),
125		).
126		Value(&action).
127		Run()
128	if err != nil {
129		if errors.Is(err, huh.ErrUserAborted) {
130			return false, errQuit
131		}
132
133		return false, err
134	}
135
136	switch action {
137	case "keep":
138		return false, nil
139	case actionDelete:
140		if err := client.DeleteToken(); err != nil {
141			return false, fmt.Errorf("deleting access token: %w", err)
142		}
143
144		fmt.Fprintln(out, ui.Success.Render("Access token removed from keyring."))
145
146		return false, nil
147	default:
148		return true, nil
149	}
150}
151
152func promptForToken(out io.Writer) error {
153	fmt.Fprintln(out)
154	fmt.Fprintln(out, "You can get your access token from:")
155	fmt.Fprintln(out, "  Lunatask β†’ Settings β†’ Access Tokens")
156	fmt.Fprintln(out)
157
158	var token string
159
160	for {
161		err := huh.NewInput().
162			Title("Lunatask Access Token").
163			Description("Paste your access token (it will be stored securely in your system keyring).").
164			EchoMode(huh.EchoModePassword).
165			Value(&token).
166			Validate(func(s string) error {
167				if s == "" {
168					return errTokenRequired
169				}
170
171				return nil
172			}).
173			Run()
174		if err != nil {
175			if errors.Is(err, huh.ErrUserAborted) {
176				return errQuit
177			}
178
179			return err
180		}
181
182		if err := validateWithSpinner(token); err != nil {
183			fmt.Fprintln(out, ui.Error.Render("βœ— Authentication failed: "+err.Error()))
184			fmt.Fprintln(out, "Please check your access token and try again.")
185			fmt.Fprintln(out)
186
187			token = ""
188
189			continue
190		}
191
192		fmt.Fprintln(out, ui.Success.Render("βœ“ Authentication successful"))
193
194		break
195	}
196
197	if err := client.SetToken(token); err != nil {
198		return fmt.Errorf("saving access token to keyring: %w", err)
199	}
200
201	fmt.Fprintln(out, ui.Success.Render("Access token saved to system keyring."))
202
203	return nil
204}
205
206func validateWithSpinner(token string) error {
207	return ui.SpinVoid("Verifying access token…", func() error {
208		return validateTokenWithPing(token)
209	})
210}
211
212func validateTokenWithPing(token string) error {
213	c := lunatask.NewClient(token, lunatask.UserAgent("lune/init"))
214
215	ctx, cancel := context.WithTimeout(context.Background(), tokenValidationTimeout)
216	defer cancel()
217
218	_, err := c.Ping(ctx)
219
220	return err
221}