Skip to content

Commit 3552ff0

Browse files
feat: gram auth changes (#838)
- modifies gram auth so it respects current project context on the initial auth and sets that as defaultProjectSlug - modifies the cli callback in the dashboard so it sets current project in query params on response - implements a very simple/stupid `gram auth switch --project ryan-proj` command to switch project slug - implements a very simple/stupid `gram auth clear` to clear auth existing auth config
1 parent d9f4980 commit 3552ff0

File tree

6 files changed

+190
-22
lines changed

6 files changed

+190
-22
lines changed

.changeset/curvy-camels-allow.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"dashboard": patch
3+
"cli": patch
4+
---
5+
6+
modifies gram auth so it respects current project context on the initial auth and sets that as defaultProjectSlug

cli/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
1. Setup environment
66
- `export GRAM_API_URL=https://localhost:8080`
7+
- `export GRAM_DASHBOARD_URL=https://localhost:5173`
78
- `export GRAM_ORG=organization-123`
89
- `export GRAM_PROJECT=default`
910
- `export GRAM_API_KEY=<API-KEY>`

cli/internal/app/auth.go

Lines changed: 89 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,82 @@ func newAuthCommand() *cli.Command {
2525
Flags: []cli.Flag{
2626
&cli.StringFlag{
2727
Name: "api-url",
28-
Usage: "URL of the Gram web application",
28+
Usage: "URL of the Gram API server",
2929
EnvVars: []string{"GRAM_API_URL"},
3030
},
31+
&cli.StringFlag{
32+
Name: "dashboard-url",
33+
Usage: "URL of the Gram dashboard for authentication",
34+
EnvVars: []string{"GRAM_DASHBOARD_URL"},
35+
},
36+
},
37+
Subcommands: []*cli.Command{
38+
newAuthSwitchCommand(),
39+
newAuthClearCommand(),
3140
},
3241
Action: doAuth,
3342
}
3443
}
3544

45+
func newAuthSwitchCommand() *cli.Command {
46+
return &cli.Command{
47+
Name: "switch",
48+
Usage: "Switch the default project for the current profile",
49+
Description: `
50+
Switch the default project for the current profile.
51+
52+
The project slug must be one of the projects available in your current profile.
53+
Use 'gram status' to see your current project.`,
54+
Flags: []cli.Flag{
55+
&cli.StringFlag{
56+
Name: "project",
57+
Usage: "The project slug to switch to",
58+
Required: true,
59+
},
60+
},
61+
Action: func(c *cli.Context) error {
62+
projectSlug := c.String("project")
63+
64+
profilePath, err := profile.DefaultProfilePath()
65+
if err != nil {
66+
return fmt.Errorf("failed to get profile path: %w", err)
67+
}
68+
69+
if err := profile.UpdateProjectSlug(profilePath, projectSlug); err != nil {
70+
return fmt.Errorf("failed to switch project: %w", err)
71+
}
72+
73+
fmt.Printf("Successfully switched to project: %s\n", projectSlug)
74+
return nil
75+
},
76+
}
77+
}
78+
79+
func newAuthClearCommand() *cli.Command {
80+
return &cli.Command{
81+
Name: "clear",
82+
Usage: "Clear all authentication profiles",
83+
Description: `
84+
Clear all authentication profiles from the profile configuration file.
85+
86+
This will remove all stored API keys and profile information.
87+
You will need to run 'gram auth' again to authenticate.`,
88+
Action: func(c *cli.Context) error {
89+
profilePath, err := profile.DefaultProfilePath()
90+
if err != nil {
91+
return fmt.Errorf("failed to get profile path: %w", err)
92+
}
93+
94+
if err := profile.Clear(profilePath); err != nil {
95+
return fmt.Errorf("failed to clear profiles: %w", err)
96+
}
97+
98+
fmt.Println("Successfully cleared all profiles")
99+
return nil
100+
},
101+
}
102+
}
103+
36104
func profileNameFromURL(apiURL string) string {
37105
parsed, err := url.Parse(apiURL)
38106
if err != nil || parsed.Host == "" {
@@ -70,10 +138,10 @@ func mintKey(
70138
ctx context.Context,
71139
logger *slog.Logger,
72140
apiURL string,
73-
) (string, error) {
141+
) (*auth.CallbackResult, error) {
74142
listener, err := auth.NewListener()
75143
if err != nil {
76-
return "", fmt.Errorf("failed to create callback listener: %w", err)
144+
return nil, fmt.Errorf("failed to create callback listener: %w", err)
77145
}
78146

79147
defer func() {
@@ -89,15 +157,15 @@ func mintKey(
89157

90158
dispatcher := auth.NewDispatcher(logger)
91159
if err := dispatcher.Dispatch(ctx, apiURL, callbackURL); err != nil {
92-
return "", fmt.Errorf("failed to dispatch auth request: %w", err)
160+
return nil, fmt.Errorf("failed to dispatch auth request: %w", err)
93161
}
94162

95-
apiKey, err := listener.Wait(ctx)
163+
result, err := listener.Wait(ctx)
96164
if err != nil {
97-
return "", fmt.Errorf("authentication failed: %w", err)
165+
return nil, fmt.Errorf("authentication failed: %w", err)
98166
}
99167

100-
return apiKey, nil
168+
return result, nil
101169
}
102170

103171
func saveProfile(
@@ -108,6 +176,7 @@ func saveProfile(
108176
result *keys.ValidateKeyResult,
109177
profilePath string,
110178
profileName string,
179+
projectSlug string,
111180
) error {
112181
err := profile.UpdateOrCreate(
113182
apiKey,
@@ -116,6 +185,7 @@ func saveProfile(
116185
result.Projects,
117186
profilePath,
118187
profileName,
188+
projectSlug,
119189
)
120190
if err != nil {
121191
return fmt.Errorf("failed to save profile: %w", err)
@@ -148,28 +218,29 @@ func refreshProfile(
148218
return fmt.Errorf("failed to refresh profile: %w", err)
149219
}
150220

151-
return saveProfile(ctx, logger, prof.Secret, apiURL, result, profilePath, profileName)
221+
return saveProfile(ctx, logger, prof.Secret, apiURL, result, profilePath, profileName, prof.DefaultProjectSlug)
152222
}
153223

154224
func authenticateNewProfile(
155225
ctx context.Context,
156226
logger *slog.Logger,
157227
profileName string,
158228
apiURL string,
229+
dashboardURL string,
159230
keysClient *api.KeysClient,
160231
profilePath string,
161232
) error {
162-
apiKey, err := mintKey(ctx, logger, apiURL)
233+
callbackResult, err := mintKey(ctx, logger, dashboardURL)
163234
if err != nil {
164235
return err
165236
}
166237

167-
result, err := keysClient.Verify(ctx, secret.Secret(apiKey))
238+
result, err := keysClient.Verify(ctx, secret.Secret(callbackResult.APIKey))
168239
if err != nil {
169240
return fmt.Errorf("failed to authenticate profile: %w", err)
170241
}
171242

172-
return saveProfile(ctx, logger, apiKey, apiURL, result, profilePath, profileName)
243+
return saveProfile(ctx, logger, callbackResult.APIKey, apiURL, result, profilePath, profileName, callbackResult.Project)
173244
}
174245

175246
func doAuth(c *cli.Context) error {
@@ -182,6 +253,12 @@ func doAuth(c *cli.Context) error {
182253
return fmt.Errorf("invalid API URL: %w", err)
183254
}
184255

256+
// Get dashboard URL for browser authentication
257+
dashboardURL := apiURL.String()
258+
if c.IsSet("dashboard-url") {
259+
dashboardURL = c.String("dashboard-url")
260+
}
261+
185262
profileName := c.String("profile")
186263
if profileName == "" {
187264
profileName = determineProfileName(prof, apiURL.String())
@@ -213,6 +290,7 @@ func doAuth(c *cli.Context) error {
213290
logger,
214291
profileName,
215292
apiURL.String(),
293+
dashboardURL,
216294
keysClient,
217295
profilePath,
218296
)

cli/internal/auth/listener.go

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,17 @@ const (
1414
callbackTimeout = 5 * time.Minute
1515
)
1616

17+
// CallbackResult contains the data returned from the auth callback.
18+
type CallbackResult struct {
19+
APIKey string
20+
Project string
21+
}
22+
1723
// Listener manages an HTTP server that waits for OAuth callback.
1824
type Listener struct {
1925
server *http.Server
2026
listener net.Listener
21-
apiKey chan string
27+
result chan CallbackResult
2228
errChan chan error
2329
}
2430

@@ -32,7 +38,7 @@ func NewListener() (*Listener, error) {
3238
l := &Listener{
3339
server: nil,
3440
listener: ln,
35-
apiKey: make(chan string, 1),
41+
result: make(chan CallbackResult, 1),
3642
errChan: make(chan error, 1),
3743
}
3844

@@ -67,17 +73,17 @@ func (l *Listener) Start() {
6773
}
6874

6975
// Wait blocks until an API key is received or timeout occurs.
70-
func (l *Listener) Wait(ctx context.Context) (string, error) {
76+
func (l *Listener) Wait(ctx context.Context) (*CallbackResult, error) {
7177
timeoutCtx, cancel := context.WithTimeout(ctx, callbackTimeout)
7278
defer cancel()
7379

7480
select {
75-
case key := <-l.apiKey:
76-
return key, nil
81+
case result := <-l.result:
82+
return &result, nil
7783
case err := <-l.errChan:
78-
return "", err
84+
return nil, err
7985
case <-timeoutCtx.Done():
80-
return "", fmt.Errorf("timeout waiting for authentication callback")
86+
return nil, fmt.Errorf("timeout waiting for authentication callback")
8187
}
8288
}
8389

@@ -203,7 +209,12 @@ func (l *Listener) handleCallback(w http.ResponseWriter, r *http.Request) {
203209
return
204210
}
205211

206-
l.apiKey <- apiKey
212+
project := r.URL.Query().Get("project")
213+
214+
l.result <- CallbackResult{
215+
APIKey: apiKey,
216+
Project: project,
217+
}
207218
w.Header().Set("Content-Type", "text/html")
208219
w.WriteHeader(http.StatusOK)
209220
_, _ = fmt.Fprint(w, authSuccessHTML)

cli/internal/profile/writer.go

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,15 +52,32 @@ func preserveDefaultProjectSlug(existingProfile *Profile) string {
5252
return ""
5353
}
5454

55+
func validateProjectSlug(projectSlug string, projects []*keys.ValidateKeyProject) bool {
56+
if projectSlug == "" {
57+
return true
58+
}
59+
for _, proj := range projects {
60+
if proj != nil && proj.Slug == projectSlug {
61+
return true
62+
}
63+
}
64+
return false
65+
}
66+
5567
func buildProfile(
5668
name string,
5769
apiKey string,
5870
apiURL string,
5971
defaultProjectSlug string,
6072
org *keys.ValidateKeyOrganization,
6173
projects []*keys.ValidateKeyProject,
74+
providedProjectSlug string,
6275
) *Profile {
63-
if defaultProjectSlug == "" && len(projects) > 0 {
76+
// Use provided project slug if it's valid
77+
if providedProjectSlug != "" && validateProjectSlug(providedProjectSlug, projects) {
78+
defaultProjectSlug = providedProjectSlug
79+
} else if defaultProjectSlug == "" && len(projects) > 0 {
80+
// Fall back to first project if no default and no valid provided
6481
defaultProjectSlug = projects[0].Slug
6582
}
6683

@@ -83,6 +100,7 @@ func UpdateOrCreate(
83100
projects []*keys.ValidateKeyProject,
84101
path string,
85102
profileName string,
103+
projectSlug string,
86104
) error {
87105
config, err := loadOrCreateConfig(path)
88106
if err != nil {
@@ -96,6 +114,7 @@ func UpdateOrCreate(
96114
preserveDefaultProjectSlug(config.Profiles[profileName]),
97115
org,
98116
projects,
117+
projectSlug,
99118
)
100119

101120
config.Current = profileName
@@ -120,3 +139,38 @@ func loadConfig(path string) (*Config, error) {
120139

121140
return &config, nil
122141
}
142+
143+
// UpdateProjectSlug updates the default project slug for the current profile.
144+
func UpdateProjectSlug(path string, projectSlug string) error {
145+
config, err := loadConfig(path)
146+
if err != nil {
147+
return fmt.Errorf("failed to load profile: %w", err)
148+
}
149+
150+
if config == nil {
151+
return fmt.Errorf("no profile configuration found")
152+
}
153+
154+
if config.Current == "" {
155+
return fmt.Errorf("no current profile set")
156+
}
157+
158+
profile, ok := config.Profiles[config.Current]
159+
if !ok {
160+
return fmt.Errorf("current profile '%s' not found", config.Current)
161+
}
162+
163+
// Validate that the project slug exists in the profile's projects
164+
if !validateProjectSlug(projectSlug, profile.Projects) {
165+
return fmt.Errorf("project '%s' not found in available projects", projectSlug)
166+
}
167+
168+
profile.DefaultProjectSlug = projectSlug
169+
return Save(config, path)
170+
}
171+
172+
// Clear removes all profiles from the configuration file.
173+
func Clear(path string) error {
174+
config := EmptyConfig()
175+
return Save(config, path)
176+
}

0 commit comments

Comments
 (0)