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}