From 59bebb525b77e9373c21f4772b5e99e456bba775 Mon Sep 17 00:00:00 2001 From: Qasim Date: Sat, 4 Apr 2026 11:54:20 -0400 Subject: [PATCH] [TW-4786] feat(admin): add callback URI CRUD commands and fix auth config Fix `nylas auth config` callback URI creation which was using the wrong API endpoint (PATCH /v3/applications/{appID}), and add full CRUD management commands at `nylas admin callback-uris`. API endpoints verified and implemented: - GET /v3/applications/callback-uris (list) - GET /v3/applications/callback-uris/:id (get) - POST /v3/applications/callback-uris (create) - PATCH /v3/applications/callback-uris/:id (update) - DELETE /v3/applications/callback-uris/:id (delete) --- docs/COMMANDS.md | 9 +- docs/commands/admin.md | 56 ++++ internal/adapters/nylas/admin.go | 88 ++++++ .../nylas/admin_callback_uris_test.go | 288 ++++++++++++++++++ internal/adapters/nylas/demo_admin.go | 41 +++ internal/adapters/nylas/mock_admin.go | 41 +++ internal/cli/admin/admin.go | 1 + internal/cli/admin/admin_test.go | 2 +- internal/cli/admin/callback_uris.go | 240 +++++++++++++++ internal/cli/admin/callback_uris_test.go | 140 +++++++++ internal/cli/auth/config.go | 56 ++-- .../integration/admin_callback_uris_test.go | 171 +++++++++++ internal/domain/admin.go | 12 + internal/domain/errors.go | 1 + internal/ports/admin.go | 15 + 15 files changed, 1124 insertions(+), 37 deletions(-) create mode 100644 internal/adapters/nylas/admin_callback_uris_test.go create mode 100644 internal/cli/admin/callback_uris.go create mode 100644 internal/cli/admin/callback_uris_test.go create mode 100644 internal/cli/integration/admin_callback_uris_test.go diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index a688541..9bcafbc 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -788,7 +788,7 @@ nylas scheduler sessions show # Show session details ## Admin (API Management) -Manage Nylas applications, connectors, credentials, and grants. +Manage Nylas applications, callback URIs, connectors, credentials, and grants. ```bash # Applications @@ -798,6 +798,13 @@ nylas admin applications create # Create application nylas admin applications update # Update application nylas admin applications delete # Delete application +# Callback URIs (OAuth redirect endpoints) +nylas admin callback-uris list # List callback URIs +nylas admin callback-uris show # Show callback URI +nylas admin callback-uris create --url # Create callback URI +nylas admin callback-uris update --url # Update callback URI +nylas admin callback-uris delete # Delete callback URI + # Connectors (provider integrations) nylas admin connectors list # List connectors nylas admin connectors show # Show connector diff --git a/docs/commands/admin.md b/docs/commands/admin.md index 1120c02..a337d81 100644 --- a/docs/commands/admin.md +++ b/docs/commands/admin.md @@ -63,6 +63,62 @@ Callback URIs (2): 2. https://myapp.com/oauth/redirect ``` +### Callback URIs + +Manage OAuth callback URIs for your Nylas application. These are the redirect endpoints used during OAuth authentication flows. + +```bash +# List callback URIs +nylas admin callback-uris list +nylas admin cb list # Alias +nylas admin callbacks list --json # Output as JSON + +# Show callback URI details +nylas admin callback-uris show +nylas admin cb show --json + +# Create callback URI +nylas admin callback-uris create --url http://localhost:9007/callback +nylas admin cb create --url https://myapp.com/oauth/callback --platform web + +# Update callback URI +nylas admin callback-uris update --url https://myapp.com/new-callback +nylas admin cb update --platform ios + +# Delete callback URI +nylas admin callback-uris delete +nylas admin cb delete --yes # Skip confirmation +``` + +**Example: List callback URIs** +```bash +$ nylas admin callback-uris list + +Found 2 callback URI(s): + +ID URL PLATFORM +f454b6d7-22e4-4f22-8536-35b6c2706a5d http://localhost:8080/callback web +732ae831-06e4-4fe2-91de-bd20b099ff38 http://localhost:9007/callback web +``` + +**Example: Create callback URI** +```bash +$ nylas admin cb create --url http://localhost:9007/callback + +✓ Created callback URI + ID: 732ae831-06e4-4fe2-91de-bd20b099ff38 + URL: http://localhost:9007/callback + Platform: web +``` + +**Flags:** +- `--url` - Callback URL (required for create) +- `--platform` - Platform type: `web`, `ios`, `android` (default: `web`) +- `--yes`, `-y` - Skip delete confirmation +- `--json` - Output as JSON + +> **Note:** `nylas auth config` automatically creates a callback URI (`http://localhost:/callback`) during initial setup. Use these commands to manage additional URIs or troubleshoot OAuth issues. + ### Connectors Manage email provider connectors (Google, Microsoft, IMAP, etc.). diff --git a/internal/adapters/nylas/admin.go b/internal/adapters/nylas/admin.go index 1264769..c13bd46 100644 --- a/internal/adapters/nylas/admin.go +++ b/internal/adapters/nylas/admin.go @@ -127,6 +127,94 @@ func (c *HTTPClient) DeleteApplication(ctx context.Context, appID string) error return c.doDelete(ctx, queryURL) } +// Callback URI Operations + +// ListCallbackURIs retrieves all callback URIs for the application. +func (c *HTTPClient) ListCallbackURIs(ctx context.Context) ([]domain.CallbackURI, error) { + queryURL := fmt.Sprintf("%s/v3/applications/callback-uris", c.baseURL) + + var result struct { + Data []domain.CallbackURI `json:"data"` + } + if err := c.doGet(ctx, queryURL, &result); err != nil { + return nil, err + } + return result.Data, nil +} + +// GetCallbackURI retrieves a specific callback URI. +func (c *HTTPClient) GetCallbackURI(ctx context.Context, uriID string) (*domain.CallbackURI, error) { + if err := validateRequired("callback URI ID", uriID); err != nil { + return nil, err + } + + queryURL := fmt.Sprintf("%s/v3/applications/callback-uris/%s", c.baseURL, uriID) + + var result struct { + Data domain.CallbackURI `json:"data"` + } + if err := c.doGetWithNotFound(ctx, queryURL, &result, domain.ErrCallbackURINotFound); err != nil { + return nil, err + } + return &result.Data, nil +} + +// CreateCallbackURI creates a new callback URI for the application. +func (c *HTTPClient) CreateCallbackURI(ctx context.Context, req *domain.CreateCallbackURIRequest) (*domain.CallbackURI, error) { + if req == nil { + return nil, fmt.Errorf("create callback URI request is required") + } + if err := validateRequired("callback URI URL", req.URL); err != nil { + return nil, err + } + + queryURL := fmt.Sprintf("%s/v3/applications/callback-uris", c.baseURL) + + resp, err := c.doJSONRequest(ctx, "POST", queryURL, req) + if err != nil { + return nil, err + } + + var result struct { + Data domain.CallbackURI `json:"data"` + } + if err := c.decodeJSONResponse(resp, &result); err != nil { + return nil, err + } + return &result.Data, nil +} + +// UpdateCallbackURI updates an existing callback URI. +func (c *HTTPClient) UpdateCallbackURI(ctx context.Context, uriID string, req *domain.UpdateCallbackURIRequest) (*domain.CallbackURI, error) { + if err := validateRequired("callback URI ID", uriID); err != nil { + return nil, err + } + + queryURL := fmt.Sprintf("%s/v3/applications/callback-uris/%s", c.baseURL, uriID) + + resp, err := c.doJSONRequest(ctx, "PATCH", queryURL, req) + if err != nil { + return nil, err + } + + var result struct { + Data domain.CallbackURI `json:"data"` + } + if err := c.decodeJSONResponse(resp, &result); err != nil { + return nil, err + } + return &result.Data, nil +} + +// DeleteCallbackURI deletes a callback URI. +func (c *HTTPClient) DeleteCallbackURI(ctx context.Context, uriID string) error { + if err := validateRequired("callback URI ID", uriID); err != nil { + return err + } + queryURL := fmt.Sprintf("%s/v3/applications/callback-uris/%s", c.baseURL, uriID) + return c.doDelete(ctx, queryURL) +} + // Admin Connectors // ListConnectors retrieves all connectors. diff --git a/internal/adapters/nylas/admin_callback_uris_test.go b/internal/adapters/nylas/admin_callback_uris_test.go new file mode 100644 index 0000000..8c073a0 --- /dev/null +++ b/internal/adapters/nylas/admin_callback_uris_test.go @@ -0,0 +1,288 @@ +package nylas_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/nylas/cli/internal/adapters/nylas" + "github.com/nylas/cli/internal/domain" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Callback URI Tests + +func TestHTTPClient_ListCallbackURIs(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v3/applications/callback-uris", r.URL.Path) + assert.Equal(t, "GET", r.Method) + + response := map[string]any{ + "data": []map[string]any{ + { + "id": "cb-1", + "url": "http://localhost:9007/callback", + "platform": "web", + }, + { + "id": "cb-2", + "url": "https://myapp.com/oauth/callback", + "platform": "web", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + client.SetBaseURL(server.URL) + + ctx := context.Background() + uris, err := client.ListCallbackURIs(ctx) + + require.NoError(t, err) + assert.Len(t, uris, 2) + assert.Equal(t, "cb-1", uris[0].ID) + assert.Equal(t, "http://localhost:9007/callback", uris[0].URL) + assert.Equal(t, "web", uris[0].Platform) + assert.Equal(t, "cb-2", uris[1].ID) +} + +func TestHTTPClient_ListCallbackURIs_Empty(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response := map[string]any{ + "data": []map[string]any{}, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + client.SetBaseURL(server.URL) + + ctx := context.Background() + uris, err := client.ListCallbackURIs(ctx) + + require.NoError(t, err) + assert.Empty(t, uris) +} + +func TestHTTPClient_GetCallbackURI(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v3/applications/callback-uris/cb-123", r.URL.Path) + assert.Equal(t, "GET", r.Method) + + response := map[string]any{ + "data": map[string]any{ + "id": "cb-123", + "url": "http://localhost:9007/callback", + "platform": "web", + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + client.SetBaseURL(server.URL) + + ctx := context.Background() + uri, err := client.GetCallbackURI(ctx, "cb-123") + + require.NoError(t, err) + assert.Equal(t, "cb-123", uri.ID) + assert.Equal(t, "http://localhost:9007/callback", uri.URL) + assert.Equal(t, "web", uri.Platform) +} + +func TestHTTPClient_GetCallbackURI_EmptyID(t *testing.T) { + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + + ctx := context.Background() + uri, err := client.GetCallbackURI(ctx, "") + + require.Error(t, err) + assert.Nil(t, uri) + assert.Contains(t, err.Error(), "callback URI ID") +} + +func TestHTTPClient_CreateCallbackURI(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v3/applications/callback-uris", r.URL.Path) + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + assert.Equal(t, "http://localhost:9007/callback", body["url"]) + assert.Equal(t, "web", body["platform"]) + + response := map[string]any{ + "data": map[string]any{ + "id": "cb-new", + "url": "http://localhost:9007/callback", + "platform": "web", + }, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + client.SetBaseURL(server.URL) + + ctx := context.Background() + req := &domain.CreateCallbackURIRequest{ + URL: "http://localhost:9007/callback", + Platform: "web", + } + uri, err := client.CreateCallbackURI(ctx, req) + + require.NoError(t, err) + assert.Equal(t, "cb-new", uri.ID) + assert.Equal(t, "http://localhost:9007/callback", uri.URL) + assert.Equal(t, "web", uri.Platform) +} + +func TestHTTPClient_UpdateCallbackURI(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v3/applications/callback-uris/cb-456", r.URL.Path) + assert.Equal(t, "PATCH", r.Method) + + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + assert.Equal(t, "https://myapp.com/new-callback", body["url"]) + + response := map[string]any{ + "data": map[string]any{ + "id": "cb-456", + "url": "https://myapp.com/new-callback", + "platform": "web", + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + client.SetBaseURL(server.URL) + + ctx := context.Background() + newURL := "https://myapp.com/new-callback" + req := &domain.UpdateCallbackURIRequest{ + URL: &newURL, + } + uri, err := client.UpdateCallbackURI(ctx, "cb-456", req) + + require.NoError(t, err) + assert.Equal(t, "cb-456", uri.ID) + assert.Equal(t, "https://myapp.com/new-callback", uri.URL) +} + +func TestHTTPClient_UpdateCallbackURI_EmptyID(t *testing.T) { + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + + ctx := context.Background() + testURL := "http://test.com" + req := &domain.UpdateCallbackURIRequest{URL: &testURL} + uri, err := client.UpdateCallbackURI(ctx, "", req) + + require.Error(t, err) + assert.Nil(t, uri) + assert.Contains(t, err.Error(), "callback URI ID") +} + +func TestHTTPClient_DeleteCallbackURI(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/v3/applications/callback-uris/cb-delete", r.URL.Path) + assert.Equal(t, "DELETE", r.Method) + + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + client.SetBaseURL(server.URL) + + ctx := context.Background() + err := client.DeleteCallbackURI(ctx, "cb-delete") + + require.NoError(t, err) +} + +func TestHTTPClient_DeleteCallbackURI_EmptyID(t *testing.T) { + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + + ctx := context.Background() + err := client.DeleteCallbackURI(ctx, "") + + require.Error(t, err) + assert.Contains(t, err.Error(), "callback URI ID") +} + +func TestHTTPClient_ListCallbackURIs_ServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{ + "type": "api.server_error", + "message": "internal server error", + }, + }) + })) + defer server.Close() + + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + client.SetBaseURL(server.URL) + + ctx := context.Background() + uris, err := client.ListCallbackURIs(ctx) + + require.Error(t, err) + assert.Nil(t, uris) +} + +func TestHTTPClient_GetCallbackURI_NotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{ + "type": "api.not_found_error", + "message": "RedirectURI not found", + }, + }) + })) + defer server.Close() + + client := nylas.NewHTTPClient() + client.SetCredentials("client-id", "secret", "api-key") + client.SetBaseURL(server.URL) + + ctx := context.Background() + uri, err := client.GetCallbackURI(ctx, "nonexistent") + + require.Error(t, err) + assert.Nil(t, uri) +} diff --git a/internal/adapters/nylas/demo_admin.go b/internal/adapters/nylas/demo_admin.go index eb7e9ee..c9aedb8 100644 --- a/internal/adapters/nylas/demo_admin.go +++ b/internal/adapters/nylas/demo_admin.go @@ -174,6 +174,47 @@ func (d *DemoClient) DeleteApplication(ctx context.Context, appID string) error return nil } +func (d *DemoClient) ListCallbackURIs(ctx context.Context) ([]domain.CallbackURI, error) { + return []domain.CallbackURI{ + {ID: "cb-demo-1", URL: "http://localhost:9007/callback", Platform: "web"}, + }, nil +} + +func (d *DemoClient) GetCallbackURI(ctx context.Context, uriID string) (*domain.CallbackURI, error) { + return &domain.CallbackURI{ + ID: uriID, + URL: "http://localhost:9007/callback", + Platform: "web", + }, nil +} + +func (d *DemoClient) CreateCallbackURI(ctx context.Context, req *domain.CreateCallbackURIRequest) (*domain.CallbackURI, error) { + return &domain.CallbackURI{ + ID: "cb-demo-new", + URL: req.URL, + Platform: req.Platform, + }, nil +} + +func (d *DemoClient) UpdateCallbackURI(ctx context.Context, uriID string, req *domain.UpdateCallbackURIRequest) (*domain.CallbackURI, error) { + uri := &domain.CallbackURI{ + ID: uriID, + URL: "http://localhost:9007/callback", + Platform: "web", + } + if req.URL != nil { + uri.URL = *req.URL + } + if req.Platform != nil { + uri.Platform = *req.Platform + } + return uri, nil +} + +func (d *DemoClient) DeleteCallbackURI(ctx context.Context, uriID string) error { + return nil +} + func (d *DemoClient) ListConnectors(ctx context.Context) ([]domain.Connector, error) { return []domain.Connector{ {ID: "conn-demo-1", Name: "Google Demo Connector", Provider: "google"}, diff --git a/internal/adapters/nylas/mock_admin.go b/internal/adapters/nylas/mock_admin.go index 87685ee..55de91e 100644 --- a/internal/adapters/nylas/mock_admin.go +++ b/internal/adapters/nylas/mock_admin.go @@ -40,6 +40,47 @@ func (m *MockClient) DeleteApplication(ctx context.Context, appID string) error return nil } +func (m *MockClient) ListCallbackURIs(ctx context.Context) ([]domain.CallbackURI, error) { + return []domain.CallbackURI{ + {ID: "cb-1", URL: "http://localhost:9007/callback", Platform: "web"}, + }, nil +} + +func (m *MockClient) GetCallbackURI(ctx context.Context, uriID string) (*domain.CallbackURI, error) { + return &domain.CallbackURI{ + ID: uriID, + URL: "http://localhost:9007/callback", + Platform: "web", + }, nil +} + +func (m *MockClient) CreateCallbackURI(ctx context.Context, req *domain.CreateCallbackURIRequest) (*domain.CallbackURI, error) { + return &domain.CallbackURI{ + ID: "cb-new", + URL: req.URL, + Platform: req.Platform, + }, nil +} + +func (m *MockClient) UpdateCallbackURI(ctx context.Context, uriID string, req *domain.UpdateCallbackURIRequest) (*domain.CallbackURI, error) { + uri := &domain.CallbackURI{ + ID: uriID, + URL: "http://localhost:9007/callback", + Platform: "web", + } + if req.URL != nil { + uri.URL = *req.URL + } + if req.Platform != nil { + uri.Platform = *req.Platform + } + return uri, nil +} + +func (m *MockClient) DeleteCallbackURI(ctx context.Context, uriID string) error { + return nil +} + func (m *MockClient) ListConnectors(ctx context.Context) ([]domain.Connector, error) { return []domain.Connector{ {ID: "conn-1", Name: "Google Connector", Provider: "google"}, diff --git a/internal/cli/admin/admin.go b/internal/cli/admin/admin.go index 8caa781..63fba62 100644 --- a/internal/cli/admin/admin.go +++ b/internal/cli/admin/admin.go @@ -17,6 +17,7 @@ the Nylas platform at an organizational level.`, } cmd.AddCommand(newApplicationsCmd()) + cmd.AddCommand(newCallbackURIsCmd()) cmd.AddCommand(newConnectorsCmd()) cmd.AddCommand(newCredentialsCmd()) cmd.AddCommand(newGrantsCmd()) diff --git a/internal/cli/admin/admin_test.go b/internal/cli/admin/admin_test.go index 0dfb28b..3463a5e 100644 --- a/internal/cli/admin/admin_test.go +++ b/internal/cli/admin/admin_test.go @@ -28,7 +28,7 @@ func TestNewAdminCmd(t *testing.T) { t.Run("has_required_subcommands", func(t *testing.T) { // TODO: Add "credentials" back when implemented - expectedCmds := []string{"applications", "connectors", "grants"} + expectedCmds := []string{"applications", "callback-uris", "connectors", "grants"} cmdMap := make(map[string]bool) for _, sub := range cmd.Commands() { diff --git a/internal/cli/admin/callback_uris.go b/internal/cli/admin/callback_uris.go new file mode 100644 index 0000000..733b2ee --- /dev/null +++ b/internal/cli/admin/callback_uris.go @@ -0,0 +1,240 @@ +package admin + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" + "github.com/spf13/cobra" +) + +func newCallbackURIsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "callback-uris", + Aliases: []string{"callbacks", "cb"}, + Short: "Manage application callback URIs", + Long: "Manage OAuth callback URIs for your Nylas application.", + } + + cmd.AddCommand(newCallbackURIListCmd()) + cmd.AddCommand(newCallbackURIShowCmd()) + cmd.AddCommand(newCallbackURICreateCmd()) + cmd.AddCommand(newCallbackURIUpdateCmd()) + cmd.AddCommand(newCallbackURIDeleteCmd()) + + return cmd +} + +func newCallbackURIListCmd() *cobra.Command { + var jsonOutput bool + + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List callback URIs", + Long: "List all callback URIs for your application.", + RunE: func(cmd *cobra.Command, args []string) error { + _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { + uris, err := client.ListCallbackURIs(ctx) + if err != nil { + return struct{}{}, common.WrapListError("callback URIs", err) + } + + if jsonOutput { + return struct{}{}, json.NewEncoder(cmd.OutOrStdout()).Encode(uris) + } + + if len(uris) == 0 { + common.PrintEmptyState("callback URIs") + return struct{}{}, nil + } + + fmt.Printf("Found %d callback URI(s):\n\n", len(uris)) + + table := common.NewTable("ID", "URL", "PLATFORM") + for _, uri := range uris { + platform := uri.Platform + if platform == "" { + platform = "-" + } + table.AddRow(common.Cyan.Sprint(uri.ID), uri.URL, platform) + } + table.Render() + + return struct{}{}, nil + }) + return err + }, + } + + cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output as JSON") + + return cmd +} + +func newCallbackURIShowCmd() *cobra.Command { + var jsonOutput bool + + cmd := &cobra.Command{ + Use: "show ", + Short: "Show callback URI details", + Long: "Show detailed information about a specific callback URI.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + uriID := args[0] + _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { + uri, err := client.GetCallbackURI(ctx, uriID) + if err != nil { + return struct{}{}, common.WrapGetError("callback URI", err) + } + + if jsonOutput { + return struct{}{}, json.NewEncoder(cmd.OutOrStdout()).Encode(uri) + } + + _, _ = common.Bold.Println("Callback URI Details") + fmt.Printf(" ID: %s\n", common.Cyan.Sprint(uri.ID)) + fmt.Printf(" URL: %s\n", uri.URL) + fmt.Printf(" Platform: %s\n", uri.Platform) + + return struct{}{}, nil + }) + return err + }, + } + + cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output as JSON") + + return cmd +} + +func newCallbackURICreateCmd() *cobra.Command { + var ( + url string + platform string + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a callback URI", + Long: `Create a new callback URI for your application. + +Examples: + nylas admin callback-uris create --url http://localhost:9007/callback + nylas admin callback-uris create --url https://myapp.com/oauth/callback --platform web`, + RunE: func(cmd *cobra.Command, args []string) error { + _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { + req := &domain.CreateCallbackURIRequest{ + URL: url, + Platform: platform, + } + + uri, err := client.CreateCallbackURI(ctx, req) + if err != nil { + return struct{}{}, common.WrapCreateError("callback URI", err) + } + + _, _ = common.Green.Println("✓ Created callback URI") + fmt.Printf(" ID: %s\n", common.Cyan.Sprint(uri.ID)) + fmt.Printf(" URL: %s\n", uri.URL) + fmt.Printf(" Platform: %s\n", uri.Platform) + + return struct{}{}, nil + }) + return err + }, + } + + cmd.Flags().StringVar(&url, "url", "", "Callback URL (required)") + cmd.Flags().StringVar(&platform, "platform", "web", "Platform (web, ios, android)") + + _ = cmd.MarkFlagRequired("url") + + return cmd +} + +func newCallbackURIUpdateCmd() *cobra.Command { + var ( + url string + platform string + ) + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update a callback URI", + Long: `Update an existing callback URI. + +Examples: + nylas admin callback-uris update --url https://myapp.com/new-callback + nylas admin callback-uris update --platform ios`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + uriID := args[0] + _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { + req := &domain.UpdateCallbackURIRequest{} + if url != "" { + req.URL = &url + } + if platform != "" { + req.Platform = &platform + } + + uri, err := client.UpdateCallbackURI(ctx, uriID, req) + if err != nil { + return struct{}{}, common.WrapUpdateError("callback URI", err) + } + + _, _ = common.Green.Printf("✓ Updated callback URI: %s\n", uri.ID) + + return struct{}{}, nil + }) + return err + }, + } + + cmd.Flags().StringVar(&url, "url", "", "Callback URL") + cmd.Flags().StringVar(&platform, "platform", "", "Platform (web, ios, android)") + + return cmd +} + +func newCallbackURIDeleteCmd() *cobra.Command { + var yes bool + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a callback URI", + Long: "Delete a callback URI permanently.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if !yes { + fmt.Printf("Are you sure you want to delete callback URI %s? (y/N): ", args[0]) + var confirm string + _, _ = fmt.Scanln(&confirm) + if confirm != "y" && confirm != "Y" { + fmt.Println("Cancelled.") + return nil + } + } + + uriID := args[0] + _, err := common.WithClientNoGrant(func(ctx context.Context, client ports.NylasClient) (struct{}, error) { + if err := client.DeleteCallbackURI(ctx, uriID); err != nil { + return struct{}{}, common.WrapDeleteError("callback URI", err) + } + + _, _ = common.Green.Printf("✓ Deleted callback URI: %s\n", uriID) + + return struct{}{}, nil + }) + return err + }, + } + + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Skip confirmation prompt") + + return cmd +} diff --git a/internal/cli/admin/callback_uris_test.go b/internal/cli/admin/callback_uris_test.go new file mode 100644 index 0000000..f2418cf --- /dev/null +++ b/internal/cli/admin/callback_uris_test.go @@ -0,0 +1,140 @@ +package admin + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewCallbackURIsCmd(t *testing.T) { + cmd := newCallbackURIsCmd() + + t.Run("command_name", func(t *testing.T) { + assert.Equal(t, "callback-uris", cmd.Use) + }) + + t.Run("has_aliases", func(t *testing.T) { + assert.Contains(t, cmd.Aliases, "callbacks") + assert.Contains(t, cmd.Aliases, "cb") + }) + + t.Run("has_short_description", func(t *testing.T) { + assert.NotEmpty(t, cmd.Short) + }) + + t.Run("has_subcommands", func(t *testing.T) { + expectedCmds := []string{"list", "show", "create", "update", "delete"} + + cmdMap := make(map[string]bool) + for _, sub := range cmd.Commands() { + cmdMap[sub.Name()] = true + } + + for _, expected := range expectedCmds { + assert.True(t, cmdMap[expected], "Missing expected subcommand: %s", expected) + } + }) +} + +func TestCallbackURIListCmd(t *testing.T) { + cmd := newCallbackURIListCmd() + + t.Run("command_name", func(t *testing.T) { + assert.Equal(t, "list", cmd.Use) + }) + + t.Run("has_ls_alias", func(t *testing.T) { + assert.Contains(t, cmd.Aliases, "ls") + }) + + t.Run("has_json_flag", func(t *testing.T) { + flag := cmd.Flags().Lookup("json") + assert.NotNil(t, flag) + assert.Equal(t, "false", flag.DefValue) + }) +} + +func TestCallbackURIShowCmd(t *testing.T) { + cmd := newCallbackURIShowCmd() + + t.Run("command_name", func(t *testing.T) { + assert.Equal(t, "show ", cmd.Use) + }) + + t.Run("requires_exactly_one_arg", func(t *testing.T) { + assert.NotNil(t, cmd.Args) + }) + + t.Run("has_json_flag", func(t *testing.T) { + flag := cmd.Flags().Lookup("json") + assert.NotNil(t, flag) + }) +} + +func TestCallbackURICreateCmd(t *testing.T) { + cmd := newCallbackURICreateCmd() + + t.Run("command_name", func(t *testing.T) { + assert.Equal(t, "create", cmd.Use) + }) + + t.Run("has_url_flag", func(t *testing.T) { + flag := cmd.Flags().Lookup("url") + assert.NotNil(t, flag) + }) + + t.Run("has_platform_flag", func(t *testing.T) { + flag := cmd.Flags().Lookup("platform") + assert.NotNil(t, flag) + assert.Equal(t, "web", flag.DefValue) + }) + + t.Run("has_short_description", func(t *testing.T) { + assert.NotEmpty(t, cmd.Short) + }) +} + +func TestCallbackURIUpdateCmd(t *testing.T) { + cmd := newCallbackURIUpdateCmd() + + t.Run("command_name", func(t *testing.T) { + assert.Equal(t, "update ", cmd.Use) + }) + + t.Run("requires_exactly_one_arg", func(t *testing.T) { + assert.NotNil(t, cmd.Args) + }) + + t.Run("has_url_flag", func(t *testing.T) { + flag := cmd.Flags().Lookup("url") + assert.NotNil(t, flag) + }) + + t.Run("has_platform_flag", func(t *testing.T) { + flag := cmd.Flags().Lookup("platform") + assert.NotNil(t, flag) + }) +} + +func TestCallbackURIDeleteCmd(t *testing.T) { + cmd := newCallbackURIDeleteCmd() + + t.Run("command_name", func(t *testing.T) { + assert.Equal(t, "delete ", cmd.Use) + }) + + t.Run("requires_exactly_one_arg", func(t *testing.T) { + assert.NotNil(t, cmd.Args) + }) + + t.Run("has_yes_flag", func(t *testing.T) { + flag := cmd.Flags().Lookup("yes") + assert.NotNil(t, flag) + assert.Equal(t, "false", flag.DefValue) + }) + + t.Run("has_yes_shorthand", func(t *testing.T) { + flag := cmd.Flags().ShorthandLookup("y") + assert.NotNil(t, flag) + }) +} diff --git a/internal/cli/auth/config.go b/internal/cli/auth/config.go index e6cb50d..064dcd8 100644 --- a/internal/cli/auth/config.go +++ b/internal/cli/auth/config.go @@ -78,7 +78,6 @@ The CLI only requires your API Key - Client ID is auto-detected.`, } // Auto-detect Client ID from API key if not provided - var selectedApp *domain.Application var orgID string if clientID == "" { @@ -104,7 +103,6 @@ The CLI only requires your API Key - Client ID is auto-detected.`, } else if len(apps) == 1 { // Single app - auto-select app := apps[0] - selectedApp = &app clientID = getAppClientID(app) orgID = app.OrganizationID _, _ = common.Green.Printf(" ✓ Found application: %s\n", getAppDisplayName(app)) @@ -125,7 +123,6 @@ The CLI only requires your API Key - Client ID is auto-detected.`, } app := apps[selected-1] - selectedApp = &app clientID = getAppClientID(app) orgID = app.OrganizationID _, _ = common.Green.Printf(" ✓ Selected: %s\n", getAppDisplayName(app)) @@ -154,7 +151,7 @@ The CLI only requires your API Key - Client ID is auto-detected.`, } // Ensure callback URI exists in the application - if selectedApp != nil && cfg != nil { + if cfg != nil { // Get callback port from config (defaults to 9007) callbackPort := cfg.CallbackPort if callbackPort == 0 { @@ -162,13 +159,23 @@ The CLI only requires your API Key - Client ID is auto-detected.`, } requiredCallbackURI := fmt.Sprintf("http://localhost:%d/callback", callbackPort) - hasCallbackURI := false - // Check if callback URI already exists - for _, cb := range selectedApp.CallbackURIs { - if cb.URL == requiredCallbackURI { - hasCallbackURI = true - break + client := nylasadapter.NewHTTPClient() + client.SetRegion(region) + client.SetCredentials(clientID, "", apiKey) + + // List existing callback URIs via dedicated endpoint + ctx, cancel := common.CreateContext() + existingURIs, err := client.ListCallbackURIs(ctx) + cancel() + + hasCallbackURI := false + if err == nil { + for _, cb := range existingURIs { + if cb.URL == requiredCallbackURI { + hasCallbackURI = true + break + } } } @@ -176,35 +183,14 @@ The CLI only requires your API Key - Client ID is auto-detected.`, if !hasCallbackURI { fmt.Println("Setting up callback URI for OAuth authentication...") - client := nylasadapter.NewHTTPClient() - client.SetRegion(region) - client.SetCredentials(clientID, "", apiKey) - - // Get the application ID to use for update - appID := selectedApp.ID - if appID == "" { - appID = selectedApp.ApplicationID - } - - // Build list of all callback URIs (existing + new) - callbackURIs := make([]string, 0, len(selectedApp.CallbackURIs)+1) - for _, cb := range selectedApp.CallbackURIs { - if cb.URL != "" { - callbackURIs = append(callbackURIs, cb.URL) - } - } - callbackURIs = append(callbackURIs, requiredCallbackURI) - - // Try to update the application ctx, cancel := common.CreateContext() - updateReq := &domain.UpdateApplicationRequest{ - CallbackURIs: callbackURIs, - } - _, err := client.UpdateApplication(ctx, appID, updateReq) + _, err := client.CreateCallbackURI(ctx, &domain.CreateCallbackURIRequest{ + URL: requiredCallbackURI, + Platform: "web", + }) cancel() if err != nil { - // If update fails (e.g., sandbox limitation), provide manual instructions _, _ = common.Yellow.Printf(" Could not add callback URI automatically: %v\n", err) fmt.Printf(" Please add this callback URI manually in the Nylas dashboard:\n") fmt.Printf(" %s\n", requiredCallbackURI) diff --git a/internal/cli/integration/admin_callback_uris_test.go b/internal/cli/integration/admin_callback_uris_test.go new file mode 100644 index 0000000..5f633e9 --- /dev/null +++ b/internal/cli/integration/admin_callback_uris_test.go @@ -0,0 +1,171 @@ +//go:build integration + +package integration + +import ( + "encoding/json" + "strings" + "testing" +) + +// ============================================================================= +// ADMIN CALLBACK URI TESTS +// ============================================================================= + +func TestCLI_AdminCallbackURIsHelp(t *testing.T) { + if testBinary == "" { + t.Skip("CLI binary not found") + } + + stdout, stderr, err := runCLI("admin", "callback-uris", "--help") + + if err != nil { + t.Fatalf("admin callback-uris --help failed: %v\nstderr: %s", err, stderr) + } + + if !strings.Contains(stdout, "list") || !strings.Contains(stdout, "create") { + t.Errorf("Expected callback URI subcommands in help, got: %s", stdout) + } + + if !strings.Contains(stdout, "delete") || !strings.Contains(stdout, "update") { + t.Errorf("Expected delete and update subcommands in help, got: %s", stdout) + } + + t.Logf("admin callback-uris --help output:\n%s", stdout) +} + +func TestCLI_AdminCallbackURIsList(t *testing.T) { + skipIfMissingCreds(t) + + stdout, stderr, err := runCLI("admin", "callback-uris", "list") + skipIfProviderNotSupported(t, stderr) + + if err != nil { + t.Fatalf("admin callback-uris list failed: %v\nstderr: %s", err, stderr) + } + + if !strings.Contains(stdout, "Found") && !strings.Contains(stdout, "No callback URIs found") { + t.Errorf("Expected callback URIs list output, got: %s", stdout) + } + + t.Logf("admin callback-uris list output:\n%s", stdout) +} + +func TestCLI_AdminCallbackURIsListJSON(t *testing.T) { + skipIfMissingCreds(t) + + stdout, stderr, err := runCLI("admin", "callback-uris", "list", "--json") + skipIfProviderNotSupported(t, stderr) + + if err != nil { + t.Fatalf("admin callback-uris list --json failed: %v\nstderr: %s", err, stderr) + } + + trimmed := strings.TrimSpace(stdout) + if len(trimmed) > 0 && !strings.HasPrefix(trimmed, "[") { + t.Errorf("Expected JSON array output, got: %s", stdout) + } + + t.Logf("admin callback-uris list --json output:\n%s", stdout) +} + +func TestCLI_AdminCallbackURIs_CRUD(t *testing.T) { + skipIfMissingCreds(t) + + // Create + createStdout, createStderr, createErr := runCLIWithRateLimit(t, "admin", "callback-uris", "create", + "--url", "http://localhost:19876/test-callback-crud", + "--platform", "web") + skipIfProviderNotSupported(t, createStderr) + + if createErr != nil { + t.Fatalf("admin callback-uris create failed: %v\nstderr: %s", createErr, createStderr) + } + + if !strings.Contains(createStdout, "Created callback URI") { + t.Fatalf("Expected success message, got: %s", createStdout) + } + + // Extract the created URI ID from JSON list + listStdout, listStderr, listErr := runCLIWithRateLimit(t, "admin", "callback-uris", "list", "--json") + skipIfProviderNotSupported(t, listStderr) + + if listErr != nil { + t.Fatalf("admin callback-uris list --json failed: %v\nstderr: %s", listErr, listStderr) + } + + var uris []struct { + ID string `json:"id"` + URL string `json:"url"` + Platform string `json:"platform"` + } + if err := json.Unmarshal([]byte(strings.TrimSpace(listStdout)), &uris); err != nil { + t.Fatalf("Failed to parse callback URIs JSON: %v\noutput: %s", err, listStdout) + } + + // Find our test URI + var testURIID string + for _, uri := range uris { + if uri.URL == "http://localhost:19876/test-callback-crud" { + testURIID = uri.ID + break + } + } + + if testURIID == "" { + t.Fatal("Could not find created test callback URI in list") + } + + // Cleanup: delete the test URI regardless of subsequent test results + t.Cleanup(func() { + _, _, _ = runCLI("admin", "callback-uris", "delete", testURIID, "--yes") + }) + + // Show + showStdout, showStderr, showErr := runCLIWithRateLimit(t, "admin", "callback-uris", "show", testURIID) + skipIfProviderNotSupported(t, showStderr) + + if showErr != nil { + t.Fatalf("admin callback-uris show failed: %v\nstderr: %s", showErr, showStderr) + } + + if !strings.Contains(showStdout, testURIID) { + t.Errorf("Expected URI ID in show output, got: %s", showStdout) + } + + if !strings.Contains(showStdout, "http://localhost:19876/test-callback-crud") { + t.Errorf("Expected URL in show output, got: %s", showStdout) + } + + // Update + updateStdout, updateStderr, updateErr := runCLIWithRateLimit(t, "admin", "callback-uris", "update", testURIID, + "--url", "http://localhost:19876/test-callback-updated") + skipIfProviderNotSupported(t, updateStderr) + + if updateErr != nil { + t.Fatalf("admin callback-uris update failed: %v\nstderr: %s", updateErr, updateStderr) + } + + if !strings.Contains(updateStdout, "Updated callback URI") { + t.Errorf("Expected update success message, got: %s", updateStdout) + } + + t.Logf("CRUD test passed: created %s, verified show+update, cleanup scheduled", testURIID) +} + +func TestCLI_AdminCallbackURIsAlias(t *testing.T) { + if testBinary == "" { + t.Skip("CLI binary not found") + } + + // Test "cb" alias works + stdout, stderr, err := runCLI("admin", "cb", "--help") + + if err != nil { + t.Fatalf("admin cb --help failed: %v\nstderr: %s", err, stderr) + } + + if !strings.Contains(stdout, "list") { + t.Errorf("Expected subcommands via alias, got: %s", stdout) + } +} diff --git a/internal/domain/admin.go b/internal/domain/admin.go index 456ccda..73246ca 100644 --- a/internal/domain/admin.go +++ b/internal/domain/admin.go @@ -21,6 +21,18 @@ type CallbackURI struct { URL string `json:"url,omitempty"` } +// CreateCallbackURIRequest represents a request to create a callback URI +type CreateCallbackURIRequest struct { + URL string `json:"url"` + Platform string `json:"platform"` +} + +// UpdateCallbackURIRequest represents a request to update a callback URI +type UpdateCallbackURIRequest struct { + URL *string `json:"url,omitempty"` + Platform *string `json:"platform,omitempty"` +} + // BrandingSettings represents application branding configuration type BrandingSettings struct { Name string `json:"name,omitempty"` diff --git a/internal/domain/errors.go b/internal/domain/errors.go index 3c14ec6..f46e0f0 100644 --- a/internal/domain/errors.go +++ b/internal/domain/errors.go @@ -53,6 +53,7 @@ var ( ErrNotetakerNotFound = errors.New("notetaker not found") ErrTemplateNotFound = errors.New("template not found") ErrApplicationNotFound = errors.New("application not found") + ErrCallbackURINotFound = errors.New("callback URI not found") ErrConnectorNotFound = errors.New("connector not found") ErrCredentialNotFound = errors.New("credential not found") diff --git a/internal/ports/admin.go b/internal/ports/admin.go index 9be9f60..c5b2a9a 100644 --- a/internal/ports/admin.go +++ b/internal/ports/admin.go @@ -27,6 +27,21 @@ type AdminClient interface { // DeleteApplication deletes an application. DeleteApplication(ctx context.Context, appID string) error + // ListCallbackURIs retrieves all callback URIs for the application. + ListCallbackURIs(ctx context.Context) ([]domain.CallbackURI, error) + + // GetCallbackURI retrieves a specific callback URI. + GetCallbackURI(ctx context.Context, uriID string) (*domain.CallbackURI, error) + + // CreateCallbackURI creates a new callback URI for the application. + CreateCallbackURI(ctx context.Context, req *domain.CreateCallbackURIRequest) (*domain.CallbackURI, error) + + // UpdateCallbackURI updates an existing callback URI. + UpdateCallbackURI(ctx context.Context, uriID string, req *domain.UpdateCallbackURIRequest) (*domain.CallbackURI, error) + + // DeleteCallbackURI deletes a callback URI. + DeleteCallbackURI(ctx context.Context, uriID string) error + // ================================ // CONNECTOR OPERATIONS // ================================