diff --git a/CLAUDE.md b/CLAUDE.md index ffd2d92..394b8d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,7 +106,7 @@ Credentials stored in system keyring (service: `"nylas"`) via `nylas auth config **Quick lookup:** CLI helpers in `internal/cli/common/`, HTTP in `client.go`, Air at `internal/air/`, Chat at `internal/chat/` -**CLI packages:** admin, ai, audit, auth, calendar, config, contacts, email, inbound, mcp, notetaker, otp, scheduler, slack, timezone, webhook +**CLI packages:** admin, ai, audit, auth, calendar, config, contacts, email, inbound, mcp, notetaker, otp, scheduler, setup, slack, timezone, webhook **Additional packages:** - `internal/ports/output.go` - OutputWriter interface for pluggable formatting @@ -115,6 +115,7 @@ Credentials stored in system keyring (service: `"nylas"`) via `nylas auth config - `internal/adapters/gpg/` - GPG/PGP email signing service (2026) - `internal/adapters/mime/` - RFC 3156 PGP/MIME message builder (2026) - `internal/chat/` - AI chat interface with local agent support (2026) +- `internal/cli/setup/` - First-time setup wizard (`nylas init`) **Full inventory:** `docs/ARCHITECTURE.md` @@ -173,6 +174,7 @@ Credentials stored in system keyring (service: `"nylas"`) via `nylas auth config | `make ci-full` | Complete CI (quality + tests) - **run before commits** | | `make ci` | Quick quality checks (no integration) | | `make build` | Build binary | +| `nylas init` | First-time setup wizard | | `nylas air` | Start Air web UI (localhost:7365) | | `nylas chat` | Start AI chat interface (localhost:7367) | diff --git a/README.md b/README.md index d53ccbe..29369ab 100644 --- a/README.md +++ b/README.md @@ -28,13 +28,17 @@ go install github.com/nylas/cli/cmd/nylas@latest nylas tui --demo ``` -**Ready to connect your account?** [Get API credentials](https://dashboard.nylas.com/) (free tier available), then: +**Ready to connect your account?** The setup wizard handles everything: ```bash -nylas auth config # Enter your API key -nylas auth login # Connect your email provider +nylas init # Guided setup — account, app, API key, done nylas email list # You're ready! ``` +Already have an API key? Skip the wizard: +```bash +nylas init --api-key +``` + ## Basic Commands | Command | Example | diff --git a/cmd/nylas/main.go b/cmd/nylas/main.go index 46edb52..c7c7a74 100644 --- a/cmd/nylas/main.go +++ b/cmd/nylas/main.go @@ -15,6 +15,7 @@ import ( "github.com/nylas/cli/internal/cli/calendar" "github.com/nylas/cli/internal/cli/config" "github.com/nylas/cli/internal/cli/contacts" + "github.com/nylas/cli/internal/cli/dashboard" "github.com/nylas/cli/internal/cli/demo" "github.com/nylas/cli/internal/cli/email" "github.com/nylas/cli/internal/cli/inbound" @@ -22,6 +23,7 @@ import ( "github.com/nylas/cli/internal/cli/notetaker" "github.com/nylas/cli/internal/cli/otp" "github.com/nylas/cli/internal/cli/scheduler" + "github.com/nylas/cli/internal/cli/setup" "github.com/nylas/cli/internal/cli/slack" "github.com/nylas/cli/internal/cli/timezone" "github.com/nylas/cli/internal/cli/update" @@ -43,6 +45,8 @@ func main() { rootCmd.AddCommand(email.NewEmailCmd()) rootCmd.AddCommand(calendar.NewCalendarCmd()) rootCmd.AddCommand(contacts.NewContactsCmd()) + rootCmd.AddCommand(dashboard.NewDashboardCmd()) + rootCmd.AddCommand(setup.NewSetupCmd()) rootCmd.AddCommand(scheduler.NewSchedulerCmd()) rootCmd.AddCommand(admin.NewAdminCmd()) rootCmd.AddCommand(webhook.NewWebhookCmd()) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 676cc30..2a5a704 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -40,6 +40,7 @@ internal/ notetaker/ # Meeting notetaker otp/ # OTP extraction scheduler/ # Booking pages + setup/ # First-time setup wizard (nylas init) slack/ # Slack integration timezone/ # Timezone utilities update/ # Self-update diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index ca41811..a688541 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -71,6 +71,25 @@ nylas completion powershell >> $PROFILE --- +## Getting Started + +```bash +nylas init # Guided first-time setup +nylas init --api-key # Quick setup with existing API key +nylas init --api-key --region eu # Setup with EU region +nylas init --google # Setup with Google SSO shortcut +``` + +The `init` command walks you through: +1. Creating or logging into your Nylas account (SSO) +2. Selecting or creating an application +3. Generating and activating an API key +4. Syncing existing email accounts + +Run `nylas init` again after partial setup — it skips completed steps. + +--- + ## Authentication ```bash @@ -91,6 +110,57 @@ nylas auth migrate # Migrate from v2 to v3 --- +## Dashboard + +Manage your Nylas Dashboard account, applications, and API keys directly from the CLI. + +### Account + +```bash +nylas dashboard register # Create a new account (SSO) +nylas dashboard register --google # Register with Google SSO +nylas dashboard register --microsoft # Register with Microsoft SSO +nylas dashboard register --github # Register with GitHub SSO + +nylas dashboard login # Log in (interactive) +nylas dashboard login --google # Log in with Google SSO +nylas dashboard login --email --user user@example.com # Email/password + +nylas dashboard logout # Log out +nylas dashboard status # Show current auth status +nylas dashboard refresh # Refresh session tokens +``` + +### SSO (Direct) + +```bash +nylas dashboard sso login --provider google # SSO login +nylas dashboard sso register --provider github # SSO registration +``` + +### Applications + +```bash +nylas dashboard apps list # List all applications +nylas dashboard apps list --region us # Filter by region +nylas dashboard apps create --name "My App" --region us # Create app +nylas dashboard apps use --region us # Set active app +``` + +### API Keys + +```bash +nylas dashboard apps apikeys list # List keys (active app) +nylas dashboard apps apikeys list --app --region us # Explicit app +nylas dashboard apps apikeys create # Create key (active app) +nylas dashboard apps apikeys create --name "CI" # Custom name +nylas dashboard apps apikeys create --expires 30 # Expire in 30 days +``` + +After creating a key, you choose: activate in CLI (recommended), copy to clipboard, or save to file. + +--- + ## Demo Mode (No Account Required) Explore the CLI with sample data before connecting your accounts: diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index c7c668c..d115875 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -70,7 +70,8 @@ internal/ ├── domain/ # Domain models ├── ports/ # Interfaces ├── adapters/ # Implementations - └── cli/ # Commands + ├── cli/ # Commands (incl. setup/ for nylas init) + └── ... ``` --- diff --git a/docs/INDEX.md b/docs/INDEX.md index 95fab48..5d7d20e 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -10,6 +10,7 @@ Quick navigation guide to find the right documentation for your needs. ### Get Started +- **First-time setup** → `nylas init` ([details](COMMANDS.md#getting-started)) - **Learn about Nylas CLI** → [README.md](../README.md) - **Quick command reference** → [COMMANDS.md](COMMANDS.md) - **See examples** → [COMMANDS.md](COMMANDS.md) and [commands/](commands/) diff --git a/go.mod b/go.mod index f9ca83e..c5451cf 100644 --- a/go.mod +++ b/go.mod @@ -22,8 +22,21 @@ require ( require ( al.essio.dev/pkg/shellescape v1.6.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect + github.com/charmbracelet/bubbletea v1.3.6 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/huh v1.0.0 // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.9.3 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/danieljoos/wincred v1.2.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/gdamore/encoding v1.0.1 // indirect github.com/godbus/dbus/v5 v5.2.1 // indirect github.com/gorilla/websocket v1.5.3 // indirect @@ -31,12 +44,20 @@ require ( github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/ncruces/julianday v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/tetratelabs/wazero v1.11.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/crypto v0.46.0 // indirect + golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.39.0 // indirect lukechampine.com/adiantum v1.1.1 // indirect ) diff --git a/go.sum b/go.sum index 36f2d59..ab25656 100644 --- a/go.sum +++ b/go.sum @@ -2,11 +2,37 @@ al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeX al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw= +github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= +github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= @@ -31,6 +57,18 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/ncruces/go-sqlite3 v0.30.4 h1:j9hEoOL7f9ZoXl8uqXVniaq1VNwlWAXihZbTvhqPPjA= github.com/ncruces/go-sqlite3 v0.30.4/go.mod h1:7WR20VSC5IZusKhUdiR9y1NsUqnZgqIYCmKKoMEYg68= github.com/ncruces/julianday v1.0.0 h1:fH0OKwa7NWvniGQtxdJRxAgkBMolni2BjDHaWTxqt7M= @@ -39,6 +77,7 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c= github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -54,6 +93,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA= github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= @@ -72,9 +113,12 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/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-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/adapters/dashboard/account_client.go b/internal/adapters/dashboard/account_client.go new file mode 100644 index 0000000..0eeeb47 --- /dev/null +++ b/internal/adapters/dashboard/account_client.go @@ -0,0 +1,208 @@ +// Package dashboard implements clients for the Nylas Dashboard account +// and API gateway services. +package dashboard + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" +) + +// AccountClient implements ports.DashboardAccountClient for the +// dashboard-account CLI auth endpoints. +type AccountClient struct { + baseURL string + httpClient *http.Client + dpop ports.DPoP +} + +// NewAccountClient creates a new dashboard account client. +func NewAccountClient(baseURL string, dpop ports.DPoP) *AccountClient { + return &AccountClient{ + baseURL: baseURL, + httpClient: newNonRedirectClient(), + dpop: dpop, + } +} + +// Register creates a new dashboard account and triggers email verification. +func (c *AccountClient) Register(ctx context.Context, email, password string, privacyPolicyAccepted bool) (*domain.DashboardRegisterResponse, error) { + body := map[string]any{ + "email": email, + "password": password, + "privacyPolicyAccepted": privacyPolicyAccepted, + } + + var result domain.DashboardRegisterResponse + if err := c.doPost(ctx, "/auth/cli/register", body, nil, "", &result); err != nil { + return nil, fmt.Errorf("registration failed: %w", err) + } + return &result, nil +} + +// VerifyEmailCode verifies the email verification code after registration. +func (c *AccountClient) VerifyEmailCode(ctx context.Context, email, code, region string) (*domain.DashboardAuthResponse, error) { + body := map[string]any{ + "email": email, + "code": code, + "region": region, + } + + var result domain.DashboardAuthResponse + if err := c.doPost(ctx, "/auth/cli/verify-email-code", body, nil, "", &result); err != nil { + return nil, fmt.Errorf("verification code invalid or expired: %w", err) + } + return &result, nil +} + +// ResendVerificationCode resends the email verification code. +func (c *AccountClient) ResendVerificationCode(ctx context.Context, email string) error { + body := map[string]any{"email": email} + return c.doPost(ctx, "/auth/cli/resend-verification-code", body, nil, "", nil) +} + +// Login authenticates with email and password. +func (c *AccountClient) Login(ctx context.Context, email, password, orgPublicID string) (*domain.DashboardAuthResponse, *domain.DashboardMFARequired, error) { + body := map[string]any{ + "email": email, + "password": password, + } + if orgPublicID != "" { + body["orgPublicId"] = orgPublicID + } + + raw, err := c.doPostRaw(ctx, "/auth/cli/login", body, nil, "") + if err != nil { + return nil, nil, fmt.Errorf("%w", domain.ErrDashboardLoginFailed) + } + + // Check if response contains userToken (success) or totpFactor (MFA required) + var probe struct { + UserToken string `json:"userToken"` + TOTPFactor any `json:"totpFactor"` + } + if err := json.Unmarshal(raw, &probe); err != nil { + return nil, nil, fmt.Errorf("failed to parse login response: %w", err) + } + + if probe.UserToken != "" { + var auth domain.DashboardAuthResponse + if err := json.Unmarshal(raw, &auth); err != nil { + return nil, nil, fmt.Errorf("failed to parse auth response: %w", err) + } + return &auth, nil, nil + } + + if probe.TOTPFactor != nil { + var mfa domain.DashboardMFARequired + if err := json.Unmarshal(raw, &mfa); err != nil { + return nil, nil, fmt.Errorf("failed to parse MFA response: %w", err) + } + return nil, &mfa, nil + } + + return nil, nil, fmt.Errorf("%w", domain.ErrDashboardLoginFailed) +} + +// LoginMFA completes MFA authentication with a TOTP code. +func (c *AccountClient) LoginMFA(ctx context.Context, userPublicID, code, orgPublicID string) (*domain.DashboardAuthResponse, error) { + body := map[string]any{ + "userPublicId": userPublicID, + "code": code, + } + if orgPublicID != "" { + body["orgPublicId"] = orgPublicID + } + + var result domain.DashboardAuthResponse + if err := c.doPost(ctx, "/auth/cli/login/mfa", body, nil, "", &result); err != nil { + return nil, fmt.Errorf("%w", domain.ErrDashboardLoginFailed) + } + return &result, nil +} + +// Refresh refreshes the session tokens. +func (c *AccountClient) Refresh(ctx context.Context, userToken, orgToken string) (*domain.DashboardRefreshResponse, error) { + headers := bearerHeaders(userToken, orgToken) + var result domain.DashboardRefreshResponse + if err := c.doPost(ctx, "/auth/cli/refresh", nil, headers, userToken, &result); err != nil { + return nil, fmt.Errorf("%w", domain.ErrDashboardSessionExpired) + } + return &result, nil +} + +// Logout invalidates the session tokens. +func (c *AccountClient) Logout(ctx context.Context, userToken, orgToken string) error { + headers := bearerHeaders(userToken, orgToken) + return c.doPost(ctx, "/auth/cli/logout", nil, headers, userToken, nil) +} + +// SSOStart initiates an SSO device authorization flow. +func (c *AccountClient) SSOStart(ctx context.Context, loginType, mode string, privacyPolicyAccepted bool) (*domain.DashboardSSOStartResponse, error) { + body := map[string]any{ + "loginType": loginType, + "mode": mode, + } + if mode == "register" { + body["privacyPolicyAccepted"] = privacyPolicyAccepted + } + + var result domain.DashboardSSOStartResponse + if err := c.doPost(ctx, "/auth/cli/sso/start", body, nil, "", &result); err != nil { + return nil, fmt.Errorf("%w: %w", domain.ErrDashboardSSOFailed, err) + } + return &result, nil +} + +// SSOPoll polls the SSO device flow for completion. +func (c *AccountClient) SSOPoll(ctx context.Context, flowID, orgPublicID string) (*domain.DashboardSSOPollResponse, error) { + body := map[string]any{ + "flowId": flowID, + } + if orgPublicID != "" { + body["orgPublicId"] = orgPublicID + } + + raw, err := c.doPostRaw(ctx, "/auth/cli/sso/poll", body, nil, "") + if err != nil { + return nil, fmt.Errorf("%w: %w", domain.ErrDashboardSSOFailed, err) + } + + var result domain.DashboardSSOPollResponse + if err := json.Unmarshal(raw, &result); err != nil { + return nil, fmt.Errorf("failed to parse SSO poll response: %w", err) + } + + switch result.Status { + case domain.SSOStatusComplete: + var auth domain.DashboardAuthResponse + if err := json.Unmarshal(raw, &auth); err != nil { + return nil, fmt.Errorf("failed to parse SSO auth: %w", err) + } + result.Auth = &auth + + case domain.SSOStatusMFARequired: + var mfa domain.DashboardMFARequired + if err := json.Unmarshal(raw, &mfa); err != nil { + return nil, fmt.Errorf("failed to parse SSO MFA: %w", err) + } + result.MFA = &mfa + } + + return &result, nil +} + +// bearerHeaders creates the Authorization and X-Nylas-Org headers. +func bearerHeaders(userToken, orgToken string) map[string]string { + h := map[string]string{ + "Authorization": "Bearer " + userToken, + } + if orgToken != "" { + h["X-Nylas-Org"] = orgToken + } + return h +} diff --git a/internal/adapters/dashboard/gateway_client.go b/internal/adapters/dashboard/gateway_client.go new file mode 100644 index 0000000..b4331d3 --- /dev/null +++ b/internal/adapters/dashboard/gateway_client.go @@ -0,0 +1,311 @@ +package dashboard + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" +) + +// GatewayClient implements ports.DashboardGatewayClient for the +// dashboard API gateway GraphQL endpoints. +type GatewayClient struct { + httpClient *http.Client + dpop ports.DPoP +} + +// NewGatewayClient creates a new dashboard gateway GraphQL client. +func NewGatewayClient(dpop ports.DPoP) *GatewayClient { + return &GatewayClient{ + httpClient: newNonRedirectClient(), + dpop: dpop, + } +} + +// ListApplications retrieves applications from the dashboard API gateway. +func (c *GatewayClient) ListApplications(ctx context.Context, orgPublicID, region, userToken, orgToken string) ([]domain.GatewayApplication, error) { + query := `query V3_GetApplications($filter: ApplicationFilter!) { + applications(filter: $filter) { + applications { + applicationId + organizationId + region + environment + branding { name description } + } + } +}` + + variables := map[string]any{ + "filter": map[string]any{ + "orgPublicId": orgPublicID, + }, + } + + url := gatewayURL(region) + raw, err := c.doGraphQL(ctx, url, query, variables, userToken, orgToken) + if err != nil { + return nil, fmt.Errorf("failed to list applications: %w", err) + } + + var resp struct { + Data struct { + Applications struct { + Applications []domain.GatewayApplication `json:"applications"` + } `json:"applications"` + } `json:"data"` + Errors []graphQLError `json:"errors"` + } + + if err := json.Unmarshal(raw, &resp); err != nil { + return nil, fmt.Errorf("failed to decode applications response: %w", err) + } + + if len(resp.Errors) > 0 { + return nil, fmt.Errorf("GraphQL error: %s", formatGraphQLError(resp.Errors[0])) + } + + return resp.Data.Applications.Applications, nil +} + +// CreateApplication creates a new application via the dashboard API gateway. +func (c *GatewayClient) CreateApplication(ctx context.Context, orgPublicID, region, name, userToken, orgToken string) (*domain.GatewayCreatedApplication, error) { + query := `mutation V3_CreateApplication($orgPublicId: String!, $options: ApplicationOptions!) { + createApplication(orgPublicId: $orgPublicId, options: $options) { + applicationId + clientSecret + organizationId + region + environment + branding { name } + } +}` + + variables := map[string]any{ + "orgPublicId": orgPublicID, + "options": map[string]any{ + "region": region, + "branding": map[string]any{ + "name": name, + }, + }, + } + + url := gatewayURL(region) + raw, err := c.doGraphQL(ctx, url, query, variables, userToken, orgToken) + if err != nil { + return nil, fmt.Errorf("failed to create application: %w", err) + } + + var resp struct { + Data struct { + CreateApplication domain.GatewayCreatedApplication `json:"createApplication"` + } `json:"data"` + Errors []graphQLError `json:"errors"` + } + + if err := json.Unmarshal(raw, &resp); err != nil { + return nil, fmt.Errorf("failed to decode create response: %w", err) + } + + if len(resp.Errors) > 0 { + return nil, fmt.Errorf("GraphQL error: %s", formatGraphQLError(resp.Errors[0])) + } + + return &resp.Data.CreateApplication, nil +} + +// ListAPIKeys retrieves API keys for an application. +func (c *GatewayClient) ListAPIKeys(ctx context.Context, appID, region, userToken, orgToken string) ([]domain.GatewayAPIKey, error) { + query := `query V3_ApiKeys($appId: String!) { + apiKeys(appId: $appId) { + id + name + status + permissions + expiresAt + createdAt + } +}` + + variables := map[string]any{ + "appId": appID, + } + + url := gatewayURL(region) + raw, err := c.doGraphQL(ctx, url, query, variables, userToken, orgToken) + if err != nil { + return nil, fmt.Errorf("failed to list API keys: %w", err) + } + + var resp struct { + Data struct { + APIKeys []domain.GatewayAPIKey `json:"apiKeys"` + } `json:"data"` + Errors []graphQLError `json:"errors"` + } + + if err := json.Unmarshal(raw, &resp); err != nil { + return nil, fmt.Errorf("failed to decode API keys response: %w", err) + } + + if len(resp.Errors) > 0 { + return nil, fmt.Errorf("GraphQL error: %s", formatGraphQLError(resp.Errors[0])) + } + + return resp.Data.APIKeys, nil +} + +// CreateAPIKey creates a new API key for an application. +func (c *GatewayClient) CreateAPIKey(ctx context.Context, appID, region, name string, expiresInDays int, userToken, orgToken string) (*domain.GatewayCreatedAPIKey, error) { + query := `mutation V3_CreateApiKey($appId: String!, $options: ApiKeyOptions) { + createApiKey(appId: $appId, options: $options) { + id + name + apiKey + status + permissions + expiresAt + createdAt + } +}` + + options := map[string]any{ + "name": name, + } + if expiresInDays > 0 { + options["expiresIn"] = expiresInDays + } + + variables := map[string]any{ + "appId": appID, + "options": options, + } + + url := gatewayURL(region) + raw, err := c.doGraphQL(ctx, url, query, variables, userToken, orgToken) + if err != nil { + return nil, fmt.Errorf("failed to create API key: %w", err) + } + + var resp struct { + Data struct { + CreateAPIKey domain.GatewayCreatedAPIKey `json:"createApiKey"` + } `json:"data"` + Errors []graphQLError `json:"errors"` + } + + if err := json.Unmarshal(raw, &resp); err != nil { + return nil, fmt.Errorf("failed to decode create API key response: %w", err) + } + + if len(resp.Errors) > 0 { + return nil, fmt.Errorf("GraphQL error: %s", formatGraphQLError(resp.Errors[0])) + } + + return &resp.Data.CreateAPIKey, nil +} + +// doGraphQL sends a GraphQL request with auth headers and DPoP proof. +func (c *GatewayClient) doGraphQL(ctx context.Context, url, query string, variables map[string]any, userToken, orgToken string) ([]byte, error) { + reqBody := map[string]any{ + "query": query, + "variables": variables, + } + + bodyJSON, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to encode GraphQL request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyJSON)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+userToken) + if orgToken != "" { + req.Header.Set("X-Nylas-Org", orgToken) + } + + // Add DPoP proof with access token hash + proof, err := c.dpop.GenerateProof(http.MethodPost, url, userToken) + if err != nil { + return nil, err + } + req.Header.Set("DPoP", proof) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody)) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode >= 300 && resp.StatusCode < 400 { + location := resp.Header.Get("Location") + return nil, fmt.Errorf("server redirected to %s — the gateway URL may be incorrect", location) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, parseErrorResponse(resp.StatusCode, respBody) + } + + return respBody, nil +} + +// gatewayURL returns the API gateway GraphQL URL for the given region. +// Per-region env vars take priority, then the shared override, then defaults. +// +// NYLAS_DASHBOARD_GATEWAY_US_URL → overrides US only +// NYLAS_DASHBOARD_GATEWAY_EU_URL → overrides EU only +// NYLAS_DASHBOARD_GATEWAY_URL → overrides both (single local gateway) +func gatewayURL(region string) string { + if region == "eu" { + if envURL := os.Getenv("NYLAS_DASHBOARD_GATEWAY_EU_URL"); envURL != "" { + return envURL + } + if envURL := os.Getenv("NYLAS_DASHBOARD_GATEWAY_URL"); envURL != "" { + return envURL + } + return domain.GatewayBaseURLEU + } + if envURL := os.Getenv("NYLAS_DASHBOARD_GATEWAY_US_URL"); envURL != "" { + return envURL + } + if envURL := os.Getenv("NYLAS_DASHBOARD_GATEWAY_URL"); envURL != "" { + return envURL + } + return domain.GatewayBaseURLUS +} + +// graphQLError represents a GraphQL error from the gateway. +type graphQLError struct { + Message string `json:"message"` + Extensions *graphQLExtensions `json:"extensions,omitempty"` +} + +type graphQLExtensions struct { + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` +} + +// formatGraphQLError returns a human-readable error from a GraphQL error. +func formatGraphQLError(e graphQLError) string { + // Prefer extensions.message (more specific), fall back to top-level message + if e.Extensions != nil && e.Extensions.Message != "" && e.Extensions.Message != e.Message { + return e.Extensions.Message + } + return e.Message +} diff --git a/internal/adapters/dashboard/http.go b/internal/adapters/dashboard/http.go new file mode 100644 index 0000000..ae9917d --- /dev/null +++ b/internal/adapters/dashboard/http.go @@ -0,0 +1,168 @@ +package dashboard + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) + +const ( + maxResponseBody = 1 << 20 // 1 MB +) + +// newNonRedirectClient creates an HTTP client that does not follow redirects. +// DPoP proofs are bound to a specific URL (the htu claim), so following a +// redirect would cause the proof to be invalid at the destination. +func newNonRedirectClient() *http.Client { + return &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} + +// doPost sends a JSON POST request and decodes the response into result. +// The server wraps responses in {"request_id","success","data":{...}}. +// This method unwraps the data field before decoding into result. +// If result is nil, the response body is discarded. +func (c *AccountClient) doPost(ctx context.Context, path string, body any, extraHeaders map[string]string, accessToken string, result any) error { + raw, err := c.doPostRaw(ctx, path, body, extraHeaders, accessToken) + if err != nil { + return err + } + + if result != nil { + data, unwrapErr := unwrapEnvelope(raw) + if unwrapErr != nil { + return unwrapErr + } + if err := json.Unmarshal(data, result); err != nil { + return fmt.Errorf("failed to decode response: %w", err) + } + } + return nil +} + +// doPostRaw sends a JSON POST request and returns the raw response body. +func (c *AccountClient) doPostRaw(ctx context.Context, path string, body any, extraHeaders map[string]string, accessToken string) ([]byte, error) { + fullURL := c.baseURL + path + + var bodyReader io.Reader + if body != nil { + bodyJSON, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to encode request: %w", err) + } + bodyReader = bytes.NewReader(bodyJSON) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bodyReader) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + // Add DPoP proof + proof, err := c.dpop.GenerateProof(http.MethodPost, fullURL, accessToken) + if err != nil { + return nil, err + } + req.Header.Set("DPoP", proof) + + // Add extra headers (Authorization, X-Nylas-Org) + for k, v := range extraHeaders { + req.Header.Set(k, v) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBody)) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode >= 300 && resp.StatusCode < 400 { + location := resp.Header.Get("Location") + return nil, fmt.Errorf("server redirected to %s — the dashboard URL may be incorrect (set NYLAS_DASHBOARD_ACCOUNT_URL)", location) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, parseErrorResponse(resp.StatusCode, respBody) + } + + // Unwrap the {request_id, success, data} envelope + data, unwrapErr := unwrapEnvelope(respBody) + if unwrapErr != nil { + return nil, unwrapErr + } + + return data, nil +} + +// unwrapEnvelope extracts the "data" field from the API response envelope. +// The dashboard-account API wraps all successful responses in: +// +// {"request_id": "...", "success": true, "data": {...}} +func unwrapEnvelope(body []byte) ([]byte, error) { + var envelope struct { + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(body, &envelope); err != nil { + return nil, fmt.Errorf("failed to decode response envelope: %w", err) + } + if len(envelope.Data) == 0 { + return body, nil // no envelope, return as-is + } + return envelope.Data, nil +} + +// DashboardAPIError represents an error from the dashboard API. +// It carries the status code and server message for debugging. +type DashboardAPIError struct { + StatusCode int + ServerMsg string +} + +func (e *DashboardAPIError) Error() string { + if e.ServerMsg != "" { + return fmt.Sprintf("dashboard API error (HTTP %d): %s", e.StatusCode, e.ServerMsg) + } + return fmt.Sprintf("dashboard API error (HTTP %d)", e.StatusCode) +} + +// parseErrorResponse extracts a user-friendly error from an HTTP error response. +// The dashboard-account error envelope is: +// +// {"request_id":"...","success":false,"error":{"code":"...","message":"..."}} +func parseErrorResponse(statusCode int, body []byte) error { + var errResp struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + msg := "" + if json.Unmarshal(body, &errResp) == nil && errResp.Error.Message != "" { + msg = errResp.Error.Message + if errResp.Error.Code != "" { + msg = errResp.Error.Code + ": " + msg + } + } + if msg == "" { + msg = string(body) + if len(msg) > 200 { + msg = msg[:200] + } + } + return &DashboardAPIError{StatusCode: statusCode, ServerMsg: msg} +} diff --git a/internal/adapters/dashboard/mock.go b/internal/adapters/dashboard/mock.go new file mode 100644 index 0000000..f3721ef --- /dev/null +++ b/internal/adapters/dashboard/mock.go @@ -0,0 +1,69 @@ +package dashboard + +import ( + "context" + + "github.com/nylas/cli/internal/domain" +) + +// MockAccountClient is a test mock for ports.DashboardAccountClient. +type MockAccountClient struct { + RegisterFn func(ctx context.Context, email, password string, privacyPolicyAccepted bool) (*domain.DashboardRegisterResponse, error) + VerifyEmailCodeFn func(ctx context.Context, email, code, region string) (*domain.DashboardAuthResponse, error) + ResendVerificationCodeFn func(ctx context.Context, email string) error + LoginFn func(ctx context.Context, email, password, orgPublicID string) (*domain.DashboardAuthResponse, *domain.DashboardMFARequired, error) + LoginMFAFn func(ctx context.Context, userPublicID, code, orgPublicID string) (*domain.DashboardAuthResponse, error) + RefreshFn func(ctx context.Context, userToken, orgToken string) (*domain.DashboardRefreshResponse, error) + LogoutFn func(ctx context.Context, userToken, orgToken string) error + SSOStartFn func(ctx context.Context, loginType, mode string, privacyPolicyAccepted bool) (*domain.DashboardSSOStartResponse, error) + SSOPollFn func(ctx context.Context, flowID, orgPublicID string) (*domain.DashboardSSOPollResponse, error) +} + +func (m *MockAccountClient) Register(ctx context.Context, email, password string, privacyPolicyAccepted bool) (*domain.DashboardRegisterResponse, error) { + return m.RegisterFn(ctx, email, password, privacyPolicyAccepted) +} +func (m *MockAccountClient) VerifyEmailCode(ctx context.Context, email, code, region string) (*domain.DashboardAuthResponse, error) { + return m.VerifyEmailCodeFn(ctx, email, code, region) +} +func (m *MockAccountClient) ResendVerificationCode(ctx context.Context, email string) error { + return m.ResendVerificationCodeFn(ctx, email) +} +func (m *MockAccountClient) Login(ctx context.Context, email, password, orgPublicID string) (*domain.DashboardAuthResponse, *domain.DashboardMFARequired, error) { + return m.LoginFn(ctx, email, password, orgPublicID) +} +func (m *MockAccountClient) LoginMFA(ctx context.Context, userPublicID, code, orgPublicID string) (*domain.DashboardAuthResponse, error) { + return m.LoginMFAFn(ctx, userPublicID, code, orgPublicID) +} +func (m *MockAccountClient) Refresh(ctx context.Context, userToken, orgToken string) (*domain.DashboardRefreshResponse, error) { + return m.RefreshFn(ctx, userToken, orgToken) +} +func (m *MockAccountClient) Logout(ctx context.Context, userToken, orgToken string) error { + return m.LogoutFn(ctx, userToken, orgToken) +} +func (m *MockAccountClient) SSOStart(ctx context.Context, loginType, mode string, privacyPolicyAccepted bool) (*domain.DashboardSSOStartResponse, error) { + return m.SSOStartFn(ctx, loginType, mode, privacyPolicyAccepted) +} +func (m *MockAccountClient) SSOPoll(ctx context.Context, flowID, orgPublicID string) (*domain.DashboardSSOPollResponse, error) { + return m.SSOPollFn(ctx, flowID, orgPublicID) +} + +// MockGatewayClient is a test mock for ports.DashboardGatewayClient. +type MockGatewayClient struct { + ListApplicationsFn func(ctx context.Context, orgPublicID, region, userToken, orgToken string) ([]domain.GatewayApplication, error) + CreateApplicationFn func(ctx context.Context, orgPublicID, region, name, userToken, orgToken string) (*domain.GatewayCreatedApplication, error) + ListAPIKeysFn func(ctx context.Context, appID, region, userToken, orgToken string) ([]domain.GatewayAPIKey, error) + CreateAPIKeyFn func(ctx context.Context, appID, region, name string, expiresInDays int, userToken, orgToken string) (*domain.GatewayCreatedAPIKey, error) +} + +func (m *MockGatewayClient) ListApplications(ctx context.Context, orgPublicID, region, userToken, orgToken string) ([]domain.GatewayApplication, error) { + return m.ListApplicationsFn(ctx, orgPublicID, region, userToken, orgToken) +} +func (m *MockGatewayClient) CreateApplication(ctx context.Context, orgPublicID, region, name, userToken, orgToken string) (*domain.GatewayCreatedApplication, error) { + return m.CreateApplicationFn(ctx, orgPublicID, region, name, userToken, orgToken) +} +func (m *MockGatewayClient) ListAPIKeys(ctx context.Context, appID, region, userToken, orgToken string) ([]domain.GatewayAPIKey, error) { + return m.ListAPIKeysFn(ctx, appID, region, userToken, orgToken) +} +func (m *MockGatewayClient) CreateAPIKey(ctx context.Context, appID, region, name string, expiresInDays int, userToken, orgToken string) (*domain.GatewayCreatedAPIKey, error) { + return m.CreateAPIKeyFn(ctx, appID, region, name, expiresInDays, userToken, orgToken) +} diff --git a/internal/adapters/dpop/dpop.go b/internal/adapters/dpop/dpop.go new file mode 100644 index 0000000..9f18091 --- /dev/null +++ b/internal/adapters/dpop/dpop.go @@ -0,0 +1,171 @@ +// Package dpop implements DPoP (Demonstrating Proof-of-Possession) proof +// generation using Ed25519 keys for CLI authentication. +package dpop + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net/url" + "strings" + "time" + + "github.com/google/uuid" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" +) + +// Service implements the ports.DPoP interface using Ed25519 keys. +type Service struct { + privateKey ed25519.PrivateKey + publicKey ed25519.PublicKey + thumbprint string +} + +// New creates a DPoP service, loading an existing key from the secret store +// or generating a new Ed25519 keypair if none exists. +func New(secrets ports.SecretStore) (*Service, error) { + s := &Service{} + + // Try to load existing key + seedB64, err := secrets.Get(ports.KeyDashboardDPoPKey) + if err == nil && seedB64 != "" { + seed, decErr := base64.StdEncoding.DecodeString(seedB64) + if decErr == nil && len(seed) == ed25519.SeedSize { + s.privateKey = ed25519.NewKeyFromSeed(seed) + s.publicKey = s.privateKey.Public().(ed25519.PublicKey) + s.thumbprint = s.computeThumbprint() + return s, nil + } + } + + // Generate new keypair + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("%w: %w", domain.ErrDashboardDPoP, err) + } + s.privateKey = priv + s.publicKey = pub + + // Persist the seed (first 32 bytes of the 64-byte private key) + seed := priv.Seed() + if err := secrets.Set(ports.KeyDashboardDPoPKey, base64.StdEncoding.EncodeToString(seed)); err != nil { + return nil, fmt.Errorf("failed to store DPoP key: %w", err) + } + + s.thumbprint = s.computeThumbprint() + return s, nil +} + +// GenerateProof creates a DPoP proof JWT for the given HTTP method and URL. +// If accessToken is non-empty, the proof includes an ath claim. +func (s *Service) GenerateProof(method, rawURL string, accessToken string) (string, error) { + // Normalize the URL: strip fragment and query + htu, err := normalizeHTU(rawURL) + if err != nil { + return "", fmt.Errorf("%w: invalid URL: %w", domain.ErrDashboardDPoP, err) + } + + // Build header + header := jwtHeader{ + Typ: "dpop+jwt", + Alg: "EdDSA", + JWK: &jwkOKP{ + Kty: "OKP", + Crv: "Ed25519", + X: base64urlEncode(s.publicKey), + }, + } + + // Build claims + claims := jwtClaims{ + JTI: uuid.NewString(), + HTM: strings.ToUpper(method), + HTU: htu, + IAT: time.Now().Unix(), + } + + // Add access token hash if provided + if accessToken != "" { + hash := sha256.Sum256([]byte(accessToken)) + claims.ATH = base64urlEncode(hash[:]) + } + + headerJSON, err := json.Marshal(header) + if err != nil { + return "", fmt.Errorf("%w: %w", domain.ErrDashboardDPoP, err) + } + + claimsJSON, err := json.Marshal(claims) + if err != nil { + return "", fmt.Errorf("%w: %w", domain.ErrDashboardDPoP, err) + } + + // Create signing input + headerB64 := base64urlEncode(headerJSON) + claimsB64 := base64urlEncode(claimsJSON) + signingInput := headerB64 + "." + claimsB64 + + // Sign with Ed25519 + signature := ed25519.Sign(s.privateKey, []byte(signingInput)) + + return signingInput + "." + base64urlEncode(signature), nil +} + +// Thumbprint returns the JWK thumbprint (RFC 7638) of the DPoP public key. +func (s *Service) Thumbprint() string { + return s.thumbprint +} + +// computeThumbprint computes the RFC 7638 JWK thumbprint. +// For OKP keys, the canonical JSON uses lexicographically sorted members: +// {"crv":"Ed25519","kty":"OKP","x":""} +func (s *Service) computeThumbprint() string { + canonical := fmt.Sprintf( + `{"crv":"Ed25519","kty":"OKP","x":"%s"}`, + base64urlEncode(s.publicKey), + ) + hash := sha256.Sum256([]byte(canonical)) + return base64urlEncode(hash[:]) +} + +// normalizeHTU strips the fragment and query from a URL per DPoP spec. +func normalizeHTU(rawURL string) (string, error) { + u, err := url.Parse(rawURL) + if err != nil { + return "", err + } + u.Fragment = "" + u.RawQuery = "" + return u.String(), nil +} + +// base64urlEncode encodes bytes as base64url without padding. +func base64urlEncode(data []byte) string { + return base64.RawURLEncoding.EncodeToString(data) +} + +// JWT types for serialization. + +type jwtHeader struct { + Typ string `json:"typ"` + Alg string `json:"alg"` + JWK *jwkOKP `json:"jwk"` +} + +type jwkOKP struct { + Kty string `json:"kty"` + Crv string `json:"crv"` + X string `json:"x"` +} + +type jwtClaims struct { + JTI string `json:"jti"` + HTM string `json:"htm"` + HTU string `json:"htu"` + IAT int64 `json:"iat"` + ATH string `json:"ath,omitempty"` +} diff --git a/internal/adapters/dpop/dpop_test.go b/internal/adapters/dpop/dpop_test.go new file mode 100644 index 0000000..9c07ef3 --- /dev/null +++ b/internal/adapters/dpop/dpop_test.go @@ -0,0 +1,288 @@ +package dpop + +import ( + "crypto/ed25519" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockSecretStore is a simple in-memory secret store for testing. +type mockSecretStore struct { + data map[string]string +} + +func newMockSecretStore() *mockSecretStore { + return &mockSecretStore{data: make(map[string]string)} +} + +func (m *mockSecretStore) Set(key, value string) error { m.data[key] = value; return nil } +func (m *mockSecretStore) Get(key string) (string, error) { + v, ok := m.data[key] + if !ok { + return "", nil + } + return v, nil +} +func (m *mockSecretStore) Delete(key string) error { delete(m.data, key); return nil } +func (m *mockSecretStore) IsAvailable() bool { return true } +func (m *mockSecretStore) Name() string { return "mock" } + +func TestNew_GeneratesKey(t *testing.T) { + t.Parallel() + store := newMockSecretStore() + + svc, err := New(store) + require.NoError(t, err) + require.NotNil(t, svc) + + // Key should be persisted + seedB64, err := store.Get("dashboard_dpop_key") + require.NoError(t, err) + assert.NotEmpty(t, seedB64) + + // Seed should be 32 bytes + seed, err := base64.StdEncoding.DecodeString(seedB64) + require.NoError(t, err) + assert.Len(t, seed, ed25519.SeedSize) +} + +func TestNew_LoadsExistingKey(t *testing.T) { + t.Parallel() + store := newMockSecretStore() + + // Create first instance + svc1, err := New(store) + require.NoError(t, err) + thumb1 := svc1.Thumbprint() + + // Create second instance - should load same key + svc2, err := New(store) + require.NoError(t, err) + thumb2 := svc2.Thumbprint() + + assert.Equal(t, thumb1, thumb2, "reloaded key should produce same thumbprint") +} + +func TestGenerateProof_Structure(t *testing.T) { + t.Parallel() + store := newMockSecretStore() + svc, err := New(store) + require.NoError(t, err) + + proof, err := svc.GenerateProof("POST", "https://example.com/auth/cli/login", "") + require.NoError(t, err) + + parts := strings.Split(proof, ".") + require.Len(t, parts, 3, "JWT should have 3 parts") + + // Decode and verify header + headerJSON, err := base64.RawURLEncoding.DecodeString(parts[0]) + require.NoError(t, err) + + var header jwtHeader + require.NoError(t, json.Unmarshal(headerJSON, &header)) + assert.Equal(t, "dpop+jwt", header.Typ) + assert.Equal(t, "EdDSA", header.Alg) + require.NotNil(t, header.JWK) + assert.Equal(t, "OKP", header.JWK.Kty) + assert.Equal(t, "Ed25519", header.JWK.Crv) + assert.NotEmpty(t, header.JWK.X) + + // Decode and verify claims + claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[1]) + require.NoError(t, err) + + var claims jwtClaims + require.NoError(t, json.Unmarshal(claimsJSON, &claims)) + assert.NotEmpty(t, claims.JTI, "jti must be present") + assert.Equal(t, "POST", claims.HTM) + assert.Equal(t, "https://example.com/auth/cli/login", claims.HTU) + assert.NotZero(t, claims.IAT) + assert.Empty(t, claims.ATH, "ath should be empty when no access token") +} + +func TestGenerateProof_WithAccessToken(t *testing.T) { + t.Parallel() + store := newMockSecretStore() + svc, err := New(store) + require.NoError(t, err) + + accessToken := "test-access-token" + proof, err := svc.GenerateProof("GET", "https://example.com/api", accessToken) + require.NoError(t, err) + + parts := strings.Split(proof, ".") + require.Len(t, parts, 3) + + claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[1]) + require.NoError(t, err) + + var claims jwtClaims + require.NoError(t, json.Unmarshal(claimsJSON, &claims)) + assert.NotEmpty(t, claims.ATH, "ath should be present when access token provided") + + // Verify ath is SHA-256 of the access token + expectedHash := sha256.Sum256([]byte(accessToken)) + expectedATH := base64.RawURLEncoding.EncodeToString(expectedHash[:]) + assert.Equal(t, expectedATH, claims.ATH) +} + +func TestGenerateProof_SignatureVerifies(t *testing.T) { + t.Parallel() + store := newMockSecretStore() + svc, err := New(store) + require.NoError(t, err) + + proof, err := svc.GenerateProof("POST", "https://example.com/test", "") + require.NoError(t, err) + + parts := strings.Split(proof, ".") + require.Len(t, parts, 3) + + // Extract public key from header + headerJSON, err := base64.RawURLEncoding.DecodeString(parts[0]) + require.NoError(t, err) + + var header jwtHeader + require.NoError(t, json.Unmarshal(headerJSON, &header)) + + pubKeyBytes, err := base64.RawURLEncoding.DecodeString(header.JWK.X) + require.NoError(t, err) + pubKey := ed25519.PublicKey(pubKeyBytes) + + // Verify signature + signingInput := []byte(parts[0] + "." + parts[1]) + signature, err := base64.RawURLEncoding.DecodeString(parts[2]) + require.NoError(t, err) + + assert.True(t, ed25519.Verify(pubKey, signingInput, signature), "signature should verify") +} + +func TestGenerateProof_UniqueJTI(t *testing.T) { + t.Parallel() + store := newMockSecretStore() + svc, err := New(store) + require.NoError(t, err) + + proof1, err := svc.GenerateProof("POST", "https://example.com/test", "") + require.NoError(t, err) + + proof2, err := svc.GenerateProof("POST", "https://example.com/test", "") + require.NoError(t, err) + + // Extract JTIs + jti1 := extractClaim(t, proof1, "jti") + jti2 := extractClaim(t, proof2, "jti") + + assert.NotEqual(t, jti1, jti2, "each proof should have a unique jti") +} + +func TestGenerateProof_MethodUppercased(t *testing.T) { + t.Parallel() + store := newMockSecretStore() + svc, err := New(store) + require.NoError(t, err) + + proof, err := svc.GenerateProof("post", "https://example.com/test", "") + require.NoError(t, err) + + htm := extractClaim(t, proof, "htm") + assert.Equal(t, "POST", htm) +} + +func TestGenerateProof_URLNormalization(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + inputURL string + expected string + }{ + { + name: "strips fragment", + inputURL: "https://example.com/path#fragment", + expected: "https://example.com/path", + }, + { + name: "strips query", + inputURL: "https://example.com/path?key=value", + expected: "https://example.com/path", + }, + { + name: "preserves path", + inputURL: "https://example.com/auth/cli/login", + expected: "https://example.com/auth/cli/login", + }, + } + + store := newMockSecretStore() + svc, err := New(store) + require.NoError(t, err) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + proof, err := svc.GenerateProof("POST", tt.inputURL, "") + require.NoError(t, err) + + htu := extractClaim(t, proof, "htu") + assert.Equal(t, tt.expected, htu) + }) + } +} + +func TestThumbprint_Consistent(t *testing.T) { + t.Parallel() + store := newMockSecretStore() + svc, err := New(store) + require.NoError(t, err) + + thumb1 := svc.Thumbprint() + thumb2 := svc.Thumbprint() + + assert.NotEmpty(t, thumb1) + assert.Equal(t, thumb1, thumb2, "thumbprint should be deterministic") +} + +func TestThumbprint_MatchesRFC7638(t *testing.T) { + t.Parallel() + store := newMockSecretStore() + svc, err := New(store) + require.NoError(t, err) + + // Manually compute the expected thumbprint + xB64 := base64urlEncode(svc.publicKey) + canonical := `{"crv":"Ed25519","kty":"OKP","x":"` + xB64 + `"}` + hash := sha256.Sum256([]byte(canonical)) + expected := base64urlEncode(hash[:]) + + assert.Equal(t, expected, svc.Thumbprint()) +} + +// extractClaim extracts a string claim value from a JWT proof. +func extractClaim(t *testing.T, proof, key string) string { + t.Helper() + parts := strings.Split(proof, ".") + require.Len(t, parts, 3) + + claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[1]) + require.NoError(t, err) + + var raw map[string]any + require.NoError(t, json.Unmarshal(claimsJSON, &raw)) + + val, ok := raw[key] + require.True(t, ok, "claim %q not found", key) + + str, ok := val.(string) + if ok { + return str + } + return "" +} diff --git a/internal/app/auth/config_test.go b/internal/app/auth/config_test.go new file mode 100644 index 0000000..a504d5d --- /dev/null +++ b/internal/app/auth/config_test.go @@ -0,0 +1,61 @@ +package auth + +import ( + "testing" + + "github.com/nylas/cli/internal/ports" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockSecretStore is a simple in-memory secret store for testing. +type mockSecretStore struct { + data map[string]string +} + +func newMockSecretStore() *mockSecretStore { + return &mockSecretStore{data: make(map[string]string)} +} + +func (m *mockSecretStore) Set(key, value string) error { m.data[key] = value; return nil } +func (m *mockSecretStore) Get(key string) (string, error) { + if v, ok := m.data[key]; ok { + return v, nil + } + return "", nil +} +func (m *mockSecretStore) Delete(key string) error { delete(m.data, key); return nil } +func (m *mockSecretStore) IsAvailable() bool { return true } +func (m *mockSecretStore) Name() string { return "mock" } + +func TestConfigService_ResetConfig(t *testing.T) { + t.Run("clears only API credentials", func(t *testing.T) { + secrets := newMockSecretStore() + configStore := newMockConfigStore() + + // Populate API credentials + secrets.data[ports.KeyClientID] = "client-123" + secrets.data[ports.KeyClientSecret] = "secret-456" + secrets.data[ports.KeyAPIKey] = "nyl_abc" + secrets.data[ports.KeyOrgID] = "org-789" + + // Populate dashboard credentials (should NOT be cleared) + secrets.data[ports.KeyDashboardUserToken] = "user-token" + secrets.data[ports.KeyDashboardAppID] = "app-id" + + svc := NewConfigService(configStore, secrets) + + err := svc.ResetConfig() + require.NoError(t, err) + + // API credentials should be cleared + assert.Empty(t, secrets.data[ports.KeyClientID]) + assert.Empty(t, secrets.data[ports.KeyClientSecret]) + assert.Empty(t, secrets.data[ports.KeyAPIKey]) + assert.Empty(t, secrets.data[ports.KeyOrgID]) + + // Dashboard credentials should be untouched + assert.Equal(t, "user-token", secrets.data[ports.KeyDashboardUserToken]) + assert.Equal(t, "app-id", secrets.data[ports.KeyDashboardAppID]) + }) +} diff --git a/internal/app/dashboard/app_service.go b/internal/app/dashboard/app_service.go new file mode 100644 index 0000000..37e4ff5 --- /dev/null +++ b/internal/app/dashboard/app_service.go @@ -0,0 +1,138 @@ +package dashboard + +import ( + "context" + "fmt" + "sync" + + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" +) + +// AppService handles application management via the dashboard API gateway. +type AppService struct { + gateway ports.DashboardGatewayClient + secrets ports.SecretStore +} + +// NewAppService creates a new application management service. +func NewAppService(gateway ports.DashboardGatewayClient, secrets ports.SecretStore) *AppService { + return &AppService{ + gateway: gateway, + secrets: secrets, + } +} + +// ListApplications retrieves applications from both US and EU regions in parallel. +// If regionFilter is non-empty, only that region is queried. +func (s *AppService) ListApplications(ctx context.Context, orgPublicID, regionFilter string) ([]domain.GatewayApplication, error) { + userToken, orgToken, err := s.loadTokens() + if err != nil { + return nil, err + } + + if regionFilter != "" { + return s.gateway.ListApplications(ctx, orgPublicID, regionFilter, userToken, orgToken) + } + + // Query both regions in parallel + type result struct { + apps []domain.GatewayApplication + err error + } + + var wg sync.WaitGroup + results := make([]result, 2) + regions := []string{"us", "eu"} + + for i, region := range regions { + wg.Add(1) + go func(idx int, r string) { + defer wg.Done() + apps, err := s.gateway.ListApplications(ctx, orgPublicID, r, userToken, orgToken) + results[idx] = result{apps: apps, err: err} + }(i, region) + } + wg.Wait() + + var allApps []domain.GatewayApplication + var errs []error + for _, r := range results { + if r.err != nil { + errs = append(errs, r.err) + continue + } + allApps = append(allApps, r.apps...) + } + + // If both failed, return the first error + if len(errs) == len(regions) { + return nil, fmt.Errorf("failed to list applications: %w", errs[0]) + } + + allApps = deduplicateApps(allApps) + + return allApps, nil +} + +// CreateApplication creates a new application in the specified region. +func (s *AppService) CreateApplication(ctx context.Context, orgPublicID, region, name string) (*domain.GatewayCreatedApplication, error) { + userToken, orgToken, err := s.loadTokens() + if err != nil { + return nil, err + } + + return s.gateway.CreateApplication(ctx, orgPublicID, region, name, userToken, orgToken) +} + +// ListAPIKeys retrieves API keys for an application. +func (s *AppService) ListAPIKeys(ctx context.Context, appID, region string) ([]domain.GatewayAPIKey, error) { + userToken, orgToken, err := s.loadTokens() + if err != nil { + return nil, err + } + + return s.gateway.ListAPIKeys(ctx, appID, region, userToken, orgToken) +} + +// CreateAPIKey creates a new API key for an application. +func (s *AppService) CreateAPIKey(ctx context.Context, appID, region, name string, expiresInDays int) (*domain.GatewayCreatedAPIKey, error) { + userToken, orgToken, err := s.loadTokens() + if err != nil { + return nil, err + } + + return s.gateway.CreateAPIKey(ctx, appID, region, name, expiresInDays, userToken, orgToken) +} + +// deduplicateApps removes duplicate applications (same applicationId). +func deduplicateApps(apps []domain.GatewayApplication) []domain.GatewayApplication { + seen := make(map[string]bool, len(apps)) + out := make([]domain.GatewayApplication, 0, len(apps)) + for _, app := range apps { + key := app.ApplicationID + if key == "" { + // Use a composite key for apps without an ID + key = app.Region + ":" + app.Environment + ":" + if app.Branding != nil { + key += app.Branding.Name + } + } + if seen[key] { + continue + } + seen[key] = true + out = append(out, app) + } + return out +} + +// loadTokens retrieves the stored dashboard tokens. +func (s *AppService) loadTokens() (userToken, orgToken string, err error) { + userToken, err = s.secrets.Get(ports.KeyDashboardUserToken) + if err != nil || userToken == "" { + return "", "", fmt.Errorf("%w", domain.ErrDashboardNotLoggedIn) + } + orgToken, _ = s.secrets.Get(ports.KeyDashboardOrgToken) + return userToken, orgToken, nil +} diff --git a/internal/app/dashboard/auth_service.go b/internal/app/dashboard/auth_service.go new file mode 100644 index 0000000..631785b --- /dev/null +++ b/internal/app/dashboard/auth_service.go @@ -0,0 +1,213 @@ +// Package dashboard provides the application-layer orchestration for +// dashboard authentication and application management. +package dashboard + +import ( + "context" + "fmt" + + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" +) + +// AuthService orchestrates dashboard auth flows and manages token lifecycle. +type AuthService struct { + account ports.DashboardAccountClient + secrets ports.SecretStore +} + +// NewAuthService creates a new dashboard auth service. +func NewAuthService(account ports.DashboardAccountClient, secrets ports.SecretStore) *AuthService { + return &AuthService{ + account: account, + secrets: secrets, + } +} + +// Register creates a new dashboard account and triggers email verification. +func (s *AuthService) Register(ctx context.Context, email, password string, privacyPolicyAccepted bool) (*domain.DashboardRegisterResponse, error) { + return s.account.Register(ctx, email, password, privacyPolicyAccepted) +} + +// VerifyEmailCode verifies the email code and stores resulting tokens. +func (s *AuthService) VerifyEmailCode(ctx context.Context, email, code, region string) (*domain.DashboardAuthResponse, error) { + resp, err := s.account.VerifyEmailCode(ctx, email, code, region) + if err != nil { + return nil, err + } + + if err := s.storeTokens(resp); err != nil { + return nil, fmt.Errorf("failed to store credentials: %w", err) + } + return resp, nil +} + +// ResendVerificationCode resends the email verification code. +func (s *AuthService) ResendVerificationCode(ctx context.Context, email string) error { + return s.account.ResendVerificationCode(ctx, email) +} + +// Login authenticates with email and password. +// Returns (auth, nil) on success, (nil, mfa) when MFA is required. +func (s *AuthService) Login(ctx context.Context, email, password, orgPublicID string) (*domain.DashboardAuthResponse, *domain.DashboardMFARequired, error) { + auth, mfa, err := s.account.Login(ctx, email, password, orgPublicID) + if err != nil { + return nil, nil, err + } + + if auth != nil { + if err := s.storeTokens(auth); err != nil { + return nil, nil, fmt.Errorf("failed to store credentials: %w", err) + } + return auth, nil, nil + } + + return nil, mfa, nil +} + +// CompleteMFA finishes MFA authentication and stores tokens. +func (s *AuthService) CompleteMFA(ctx context.Context, userPublicID, code, orgPublicID string) (*domain.DashboardAuthResponse, error) { + resp, err := s.account.LoginMFA(ctx, userPublicID, code, orgPublicID) + if err != nil { + return nil, err + } + + if err := s.storeTokens(resp); err != nil { + return nil, fmt.Errorf("failed to store credentials: %w", err) + } + return resp, nil +} + +// Refresh refreshes the session tokens using the stored tokens. +func (s *AuthService) Refresh(ctx context.Context) error { + userToken, orgToken, err := s.loadTokens() + if err != nil { + return err + } + + resp, err := s.account.Refresh(ctx, userToken, orgToken) + if err != nil { + return err + } + + if err := s.secrets.Set(ports.KeyDashboardUserToken, resp.UserToken); err != nil { + return fmt.Errorf("failed to store refreshed user token: %w", err) + } + if resp.OrgToken != "" { + if err := s.secrets.Set(ports.KeyDashboardOrgToken, resp.OrgToken); err != nil { + return fmt.Errorf("failed to store refreshed org token: %w", err) + } + } + + return nil +} + +// Logout invalidates the session and clears local tokens. +func (s *AuthService) Logout(ctx context.Context) error { + userToken, orgToken, _ := s.loadTokens() + + // Best effort: call the server to invalidate tokens + if userToken != "" { + _ = s.account.Logout(ctx, userToken, orgToken) + } + + // Always clear local state + s.clearTokens() + return nil +} + +// SSOStart initiates an SSO device authorization flow. +func (s *AuthService) SSOStart(ctx context.Context, loginType, mode string, privacyPolicyAccepted bool) (*domain.DashboardSSOStartResponse, error) { + return s.account.SSOStart(ctx, loginType, mode, privacyPolicyAccepted) +} + +// SSOPoll polls the SSO device flow. On completion, stores tokens. +func (s *AuthService) SSOPoll(ctx context.Context, flowID, orgPublicID string) (*domain.DashboardSSOPollResponse, error) { + resp, err := s.account.SSOPoll(ctx, flowID, orgPublicID) + if err != nil { + return nil, err + } + + if resp.Status == domain.SSOStatusComplete && resp.Auth != nil { + if err := s.storeTokens(resp.Auth); err != nil { + return nil, fmt.Errorf("failed to store credentials: %w", err) + } + } + + return resp, nil +} + +// IsLoggedIn returns true if dashboard tokens exist in the keyring. +func (s *AuthService) IsLoggedIn() bool { + token, err := s.secrets.Get(ports.KeyDashboardUserToken) + return err == nil && token != "" +} + +// Status represents the current dashboard authentication status. +type Status struct { + LoggedIn bool + UserID string + OrgID string + HasOrgToken bool +} + +// GetStatus returns the current dashboard auth status. +func (s *AuthService) GetStatus() Status { + st := Status{} + userToken, _ := s.secrets.Get(ports.KeyDashboardUserToken) + st.LoggedIn = userToken != "" + st.UserID, _ = s.secrets.Get(ports.KeyDashboardUserPublicID) + st.OrgID, _ = s.secrets.Get(ports.KeyDashboardOrgPublicID) + orgToken, _ := s.secrets.Get(ports.KeyDashboardOrgToken) + st.HasOrgToken = orgToken != "" + return st +} + +// storeTokens persists auth tokens and user/org identifiers. +func (s *AuthService) storeTokens(resp *domain.DashboardAuthResponse) error { + if err := s.secrets.Set(ports.KeyDashboardUserToken, resp.UserToken); err != nil { + return err + } + if resp.OrgToken != "" { + if err := s.secrets.Set(ports.KeyDashboardOrgToken, resp.OrgToken); err != nil { + return err + } + } + if resp.User.PublicID != "" { + if err := s.secrets.Set(ports.KeyDashboardUserPublicID, resp.User.PublicID); err != nil { + return err + } + } + if len(resp.Organizations) > 0 { + if err := s.secrets.Set(ports.KeyDashboardOrgPublicID, resp.Organizations[0].PublicID); err != nil { + return err + } + } + return nil +} + +// SetActiveOrg updates the active organization. +func (s *AuthService) SetActiveOrg(orgPublicID string) error { + return s.secrets.Set(ports.KeyDashboardOrgPublicID, orgPublicID) +} + +// clearTokens removes all dashboard auth data from the keyring, +// including the active app selection to prevent stale state after re-login. +func (s *AuthService) clearTokens() { + _ = s.secrets.Delete(ports.KeyDashboardUserToken) + _ = s.secrets.Delete(ports.KeyDashboardOrgToken) + _ = s.secrets.Delete(ports.KeyDashboardUserPublicID) + _ = s.secrets.Delete(ports.KeyDashboardOrgPublicID) + _ = s.secrets.Delete(ports.KeyDashboardAppID) + _ = s.secrets.Delete(ports.KeyDashboardAppRegion) +} + +// loadTokens retrieves the stored tokens. +func (s *AuthService) loadTokens() (userToken, orgToken string, err error) { + userToken, err = s.secrets.Get(ports.KeyDashboardUserToken) + if err != nil || userToken == "" { + return "", "", fmt.Errorf("%w", domain.ErrDashboardNotLoggedIn) + } + orgToken, _ = s.secrets.Get(ports.KeyDashboardOrgToken) + return userToken, orgToken, nil +} diff --git a/internal/cli/auth/config.go b/internal/cli/auth/config.go index 9e509ac..e6cb50d 100644 --- a/internal/cli/auth/config.go +++ b/internal/cli/auth/config.go @@ -11,6 +11,7 @@ import ( nylasadapter "github.com/nylas/cli/internal/adapters/nylas" "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/cli/setup" "github.com/nylas/cli/internal/domain" ) @@ -222,59 +223,22 @@ The CLI only requires your API Key - Client ID is auto-detected.`, fmt.Println() fmt.Println("Checking for existing grants...") - client := nylasadapter.NewHTTPClient() - client.SetRegion(region) - client.SetCredentials(clientID, "", apiKey) - - ctx, cancel := common.CreateContext() - defer cancel() - - grants, err := client.ListGrants(ctx) + grantStore, err := createGrantStore() if err != nil { - _, _ = common.Yellow.Printf(" Could not fetch grants: %v\n", err) - fmt.Println() - fmt.Println("Next steps:") - fmt.Println(" nylas auth login Authenticate with your email provider") + _, _ = common.Yellow.Printf(" Could not access grant store: %v\n", err) return nil } - if len(grants) == 0 { - fmt.Println(" No existing grants found") + result, err := setup.SyncGrants(grantStore, apiKey, clientID, region) + if err != nil { + _, _ = common.Yellow.Printf(" Could not fetch grants: %v\n", err) fmt.Println() fmt.Println("Next steps:") fmt.Println(" nylas auth login Authenticate with your email provider") return nil } - // Get grant store to save grants locally - grantStore, err := createGrantStore() - if err != nil { - _, _ = common.Yellow.Printf(" Could not save grants locally: %v\n", err) - return nil - } - - // First pass: Add all valid grants without setting default - var validGrants []domain.Grant - for _, grant := range grants { - if !grant.IsValid() { - continue - } - - grantInfo := domain.GrantInfo{ - ID: grant.ID, - Email: grant.Email, - Provider: grant.Provider, - } - - if err := grantStore.SaveGrant(grantInfo); err != nil { - continue - } - - validGrants = append(validGrants, grant) - _, _ = common.Green.Printf(" ✓ Added %s (%s)\n", grant.Email, grant.Provider.DisplayName()) - } - - if len(validGrants) == 0 { + if len(result.ValidGrants) == 0 { fmt.Println(" No valid grants found") fmt.Println() fmt.Println("Next steps:") @@ -282,53 +246,30 @@ The CLI only requires your API Key - Client ID is auto-detected.`, return nil } - // Second pass: Set default grant - var defaultGrantID string - if len(validGrants) == 1 { - // Single grant - auto-select as default - defaultGrantID = validGrants[0].ID - _ = grantStore.SetDefaultGrant(defaultGrantID) + // Set default grant + defaultGrantID := result.DefaultGrantID + if defaultGrantID != "" { + // Single grant, auto-selected fmt.Println() - _, _ = common.Green.Printf("✓ Set %s as default account\n", validGrants[0].Email) - } else { - // Multiple grants - let user choose default - fmt.Println() - fmt.Println("Select default account:") - for i, grant := range validGrants { - fmt.Printf(" [%d] %s (%s)\n", i+1, grant.Email, grant.Provider.DisplayName()) - } - fmt.Println() - fmt.Print("Select default account (1-", len(validGrants), "): ") - input, _ := reader.ReadString('\n') - choice := strings.TrimSpace(input) - - var selected int - if _, err := fmt.Sscanf(choice, "%d", &selected); err != nil || selected < 1 || selected > len(validGrants) { - // If invalid selection, default to first - _, _ = common.Yellow.Printf("Invalid selection, defaulting to %s\n", validGrants[0].Email) - defaultGrantID = validGrants[0].ID - } else { - defaultGrantID = validGrants[selected-1].ID - } - - _ = grantStore.SetDefaultGrant(defaultGrantID) - selectedGrant := validGrants[0] - for _, g := range validGrants { + _, _ = common.Green.Printf("✓ Set %s as default account\n", result.ValidGrants[0].Email) + } else if len(result.ValidGrants) > 1 { + // Multiple grants, prompt + defaultGrantID, _ = setup.PromptDefaultGrant(grantStore, result.ValidGrants) + for _, g := range result.ValidGrants { if g.ID == defaultGrantID { - selectedGrant = g + _, _ = common.Green.Printf("✓ Set %s as default account\n", g.Email) break } } - _, _ = common.Green.Printf("✓ Set %s as default account\n", selectedGrant.Email) } fmt.Println() - fmt.Printf("Added %d grant(s). Run 'nylas auth list' to see all accounts.\n", len(validGrants)) + fmt.Printf("Added %d grant(s). Run 'nylas auth list' to see all accounts.\n", len(result.ValidGrants)) // Update config file with default grant and grants list cfg.DefaultGrant = defaultGrantID - cfg.Grants = make([]domain.GrantInfo, len(validGrants)) - for i, grant := range validGrants { + cfg.Grants = make([]domain.GrantInfo, len(result.ValidGrants)) + for i, grant := range result.ValidGrants { cfg.Grants[i] = domain.GrantInfo{ ID: grant.ID, Email: grant.Email, diff --git a/internal/cli/common/colors.go b/internal/cli/common/colors.go index feb96ba..a4a4bc7 100644 --- a/internal/cli/common/colors.go +++ b/internal/cli/common/colors.go @@ -1,6 +1,9 @@ package common -import "github.com/fatih/color" +import ( + "github.com/charmbracelet/lipgloss" + "github.com/fatih/color" +) // Common color definitions used across CLI commands. // Import these instead of defining package-local color vars. @@ -29,4 +32,8 @@ var ( // Reset (no formatting) Reset = color.New(color.Reset) + + // Brand — matches the Nylas theme primary color. + // Use for ASCII art, banners, and branded elements. + Brand = lipgloss.NewStyle().Foreground(ColorPrimary).Bold(true) ) diff --git a/internal/cli/common/prompt.go b/internal/cli/common/prompt.go new file mode 100644 index 0000000..1751b3f --- /dev/null +++ b/internal/cli/common/prompt.go @@ -0,0 +1,107 @@ +// Package common provides shared CLI utilities. +package common + +import ( + "os" + + "github.com/charmbracelet/huh" + "golang.org/x/term" +) + +// theme is the shared huh theme applied to all prompts. +var theme = NylasTheme() + +// SelectOption represents a labeled option for Select prompts. +type SelectOption[T comparable] struct { + Label string + Value T +} + +// Select presents an interactive select menu with arrow-key navigation. +// Falls back to the first option if stdin is not a TTY. +func Select[T comparable](title string, options []SelectOption[T]) (T, error) { + if len(options) == 0 { + var zero T + return zero, nil + } + + // Non-interactive fallback + if !term.IsTerminal(int(os.Stdin.Fd())) { + return options[0].Value, nil + } + + var result T + huhOpts := make([]huh.Option[T], len(options)) + for i, opt := range options { + huhOpts[i] = huh.NewOption(opt.Label, opt.Value) + } + + err := huh.NewSelect[T](). + Title(title). + Options(huhOpts...). + Value(&result). + WithTheme(theme). + Run() + + return result, err +} + +// ConfirmPrompt presents an interactive yes/no confirmation with arrow-key navigation. +func ConfirmPrompt(title string, defaultYes bool) (bool, error) { + if !term.IsTerminal(int(os.Stdin.Fd())) { + return defaultYes, nil + } + + result := defaultYes + err := huh.NewConfirm(). + Title(title). + Affirmative("Yes"). + Negative("No"). + Value(&result). + WithTheme(theme). + Run() + + return result, err +} + +// InputPrompt presents an interactive text input. +func InputPrompt(title, placeholder string) (string, error) { + if !term.IsTerminal(int(os.Stdin.Fd())) { + return placeholder, nil + } + + var result string + field := huh.NewInput(). + Title(title). + Value(&result) + + if placeholder != "" { + field = field.Placeholder(placeholder) + } + + err := field.WithTheme(theme).Run() + if err != nil { + return "", err + } + if result == "" && placeholder != "" { + return placeholder, nil + } + return result, nil +} + +// PasswordPrompt presents an interactive masked password input. +func PasswordPrompt(title string) (string, error) { + if !term.IsTerminal(int(os.Stdin.Fd())) { + return "", nil + } + + var result string + err := huh.NewInput(). + Title(title). + EchoMode(huh.EchoModePassword). + Value(&result). + WithTheme(theme). + Run() + + return result, err +} diff --git a/internal/cli/common/theme.go b/internal/cli/common/theme.go new file mode 100644 index 0000000..55b4ec0 --- /dev/null +++ b/internal/cli/common/theme.go @@ -0,0 +1,118 @@ +package common + +import ( + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" +) + +// Nylas brand color palette — used consistently across all CLI output. +const ( + ColorPrimary = lipgloss.Color("#4169E1") // Royal Blue — brand accent + ColorSuccess = lipgloss.Color("#4CAF50") // Green + ColorWarning = lipgloss.Color("#FFC107") // Amber + ColorError = lipgloss.Color("#F44336") // Red + ColorMuted = lipgloss.Color("#6B7280") // Gray + ColorText = lipgloss.Color("#E0E0E0") // Light gray + ColorDim = lipgloss.Color("#4A4A4A") // Dark gray +) + +// NylasTheme returns the huh theme used for all interactive prompts. +func NylasTheme() *huh.Theme { + t := huh.ThemeBase() + + // Focused field styles + t.Focused.Base = lipgloss.NewStyle(). + PaddingLeft(1). + BorderStyle(lipgloss.ThickBorder()). + BorderLeft(true). + BorderForeground(ColorPrimary) + + t.Focused.Title = lipgloss.NewStyle(). + Foreground(ColorPrimary). + Bold(true) + + t.Focused.Description = lipgloss.NewStyle(). + Foreground(ColorMuted) + + t.Focused.ErrorIndicator = lipgloss.NewStyle(). + Foreground(ColorError). + SetString(" *") + + t.Focused.ErrorMessage = lipgloss.NewStyle(). + Foreground(ColorError) + + // Select + t.Focused.SelectSelector = lipgloss.NewStyle(). + Foreground(ColorPrimary). + SetString("❯ ") + + t.Focused.Option = lipgloss.NewStyle(). + Foreground(ColorText) + + t.Focused.NextIndicator = lipgloss.NewStyle(). + Foreground(ColorMuted). + SetString(" →") + + t.Focused.PrevIndicator = lipgloss.NewStyle(). + Foreground(ColorMuted). + SetString("← ") + + // MultiSelect + t.Focused.MultiSelectSelector = lipgloss.NewStyle(). + Foreground(ColorPrimary). + SetString("❯ ") + + t.Focused.SelectedOption = lipgloss.NewStyle(). + Foreground(ColorSuccess) + + t.Focused.SelectedPrefix = lipgloss.NewStyle(). + Foreground(ColorSuccess). + SetString("✓ ") + + t.Focused.UnselectedOption = lipgloss.NewStyle(). + Foreground(ColorText) + + t.Focused.UnselectedPrefix = lipgloss.NewStyle(). + Foreground(ColorMuted). + SetString("○ ") + + // Confirm buttons + t.Focused.FocusedButton = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFFFF")). + Background(ColorPrimary). + Padding(0, 2). + Bold(true) + + t.Focused.BlurredButton = lipgloss.NewStyle(). + Foreground(ColorMuted). + Background(ColorDim). + Padding(0, 2) + + // Text input + t.Focused.TextInput.Cursor = lipgloss.NewStyle(). + Foreground(ColorPrimary) + + t.Focused.TextInput.Placeholder = lipgloss.NewStyle(). + Foreground(ColorMuted) + + t.Focused.TextInput.Prompt = lipgloss.NewStyle(). + Foreground(ColorPrimary). + SetString("❯ ") + + t.Focused.TextInput.Text = lipgloss.NewStyle(). + Foreground(ColorText) + + // Card / Note + t.Focused.Card = t.Focused.Base + t.Focused.NoteTitle = t.Focused.Title + t.Focused.Next = t.Focused.FocusedButton + + // Blurred state — same styles but hidden border + t.Blurred = t.Focused + t.Blurred.Base = t.Focused.Base.BorderStyle(lipgloss.HiddenBorder()) + t.Blurred.Card = t.Blurred.Base + t.Blurred.NextIndicator = lipgloss.NewStyle() + t.Blurred.PrevIndicator = lipgloss.NewStyle() + + return t +} diff --git a/internal/cli/config/config.go b/internal/cli/config/config.go index 2053cc7..9bdaffa 100644 --- a/internal/cli/config/config.go +++ b/internal/cli/config/config.go @@ -43,7 +43,10 @@ If the config file doesn't exist, sensible defaults are used automatically.`, nylas config set gpg.auto_sign true # Initialize config with defaults - nylas config init`, + nylas config init + + # Reset everything (credentials, grants, config) + nylas config reset`, } cmd.AddCommand(newListCmd()) @@ -51,6 +54,7 @@ If the config file doesn't exist, sensible defaults are used automatically.`, cmd.AddCommand(newSetCmd()) cmd.AddCommand(newInitCmd()) cmd.AddCommand(newPathCmd()) + cmd.AddCommand(newResetCmd()) return cmd } diff --git a/internal/cli/config/reset.go b/internal/cli/config/reset.go new file mode 100644 index 0000000..e58f0d1 --- /dev/null +++ b/internal/cli/config/reset.go @@ -0,0 +1,102 @@ +package config + +import ( + "fmt" + + "github.com/spf13/cobra" + + adapterconfig "github.com/nylas/cli/internal/adapters/config" + "github.com/nylas/cli/internal/adapters/keyring" + authapp "github.com/nylas/cli/internal/app/auth" + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" +) + +func newResetCmd() *cobra.Command { + var force bool + + cmd := &cobra.Command{ + Use: "reset", + Short: "Reset all CLI configuration and credentials", + Long: `Reset the Nylas CLI to a clean state by clearing all stored data: + + - API credentials (API key, client ID, client secret) + - Dashboard session (login tokens, selected app) + - Grants (authenticated email accounts) + - Config file (reset to defaults) + +After reset, run 'nylas init' to set up again. + +To reset only part of the CLI: + nylas auth config --reset Reset API credentials only + nylas dashboard logout Log out of Dashboard only`, + Example: ` # Reset with confirmation prompt + nylas config reset + + # Reset without confirmation + nylas config reset --force`, + RunE: func(cmd *cobra.Command, args []string) error { + if !force { + fmt.Println("This will remove all stored credentials, grants, and configuration.") + fmt.Println() + if !common.Confirm("Are you sure you want to reset the CLI?", false) { + fmt.Println("Reset cancelled.") + return nil + } + fmt.Println() + } + + secretStore, err := keyring.NewSecretStore(adapterconfig.DefaultConfigDir()) + if err != nil { + return fmt.Errorf("access secret store: %w", err) + } + + // 1. Clear API credentials + configSvc := authapp.NewConfigService(configStore, secretStore) + if err := configSvc.ResetConfig(); err != nil { + return fmt.Errorf("reset API config: %w", err) + } + _, _ = common.Green.Println(" ✓ API credentials cleared") + + // 2. Clear dashboard credentials + clearDashboardCredentials(secretStore) + _, _ = common.Green.Println(" ✓ Dashboard session cleared") + + // 3. Clear grants + grantStore := keyring.NewGrantStore(secretStore) + if err := grantStore.ClearGrants(); err != nil { + return fmt.Errorf("clear grants: %w", err) + } + _, _ = common.Green.Println(" ✓ Grants cleared") + + // 4. Reset config file to defaults + if err := configStore.Save(domain.DefaultConfig()); err != nil { + return fmt.Errorf("reset config file: %w", err) + } + _, _ = common.Green.Println(" ✓ Config file reset") + + fmt.Println() + _, _ = common.Green.Println("CLI has been reset.") + fmt.Println() + fmt.Println("Run 'nylas init' to set up again.") + + return nil + }, + } + + cmd.Flags().BoolVar(&force, "force", false, "Skip confirmation prompt") + + return cmd +} + +// clearDashboardCredentials removes all dashboard-related keys from the secret store. +func clearDashboardCredentials(secrets ports.SecretStore) { + _ = secrets.Delete(ports.KeyDashboardUserToken) + _ = secrets.Delete(ports.KeyDashboardOrgToken) + _ = secrets.Delete(ports.KeyDashboardUserPublicID) + _ = secrets.Delete(ports.KeyDashboardOrgPublicID) + _ = secrets.Delete(ports.KeyDashboardDPoPKey) + _ = secrets.Delete(ports.KeyDashboardAppID) + _ = secrets.Delete(ports.KeyDashboardAppRegion) +} diff --git a/internal/cli/config/reset_test.go b/internal/cli/config/reset_test.go new file mode 100644 index 0000000..08b42f9 --- /dev/null +++ b/internal/cli/config/reset_test.go @@ -0,0 +1,65 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewResetCmd(t *testing.T) { + t.Run("command name and flags", func(t *testing.T) { + cmd := newResetCmd() + + assert.Equal(t, "reset", cmd.Use) + assert.NotEmpty(t, cmd.Short) + assert.NotEmpty(t, cmd.Long) + assert.NotEmpty(t, cmd.Example) + + flag := cmd.Flags().Lookup("force") + require.NotNil(t, flag, "expected --force flag") + assert.Equal(t, "false", flag.DefValue) + }) +} + +func TestClearDashboardCredentials(t *testing.T) { + t.Run("clears all dashboard keys", func(t *testing.T) { + store := &memStore{data: map[string]string{ + "dashboard_user_token": "tok", + "dashboard_org_token": "org-tok", + "dashboard_user_public_id": "uid", + "dashboard_org_public_id": "oid", + "dashboard_dpop_key": "dpop", + "dashboard_app_id": "app", + "dashboard_app_region": "us", + "api_key": "keep-me", + }} + + clearDashboardCredentials(store) + + // Dashboard keys should be gone + assert.Empty(t, store.data["dashboard_user_token"]) + assert.Empty(t, store.data["dashboard_org_token"]) + assert.Empty(t, store.data["dashboard_user_public_id"]) + assert.Empty(t, store.data["dashboard_org_public_id"]) + assert.Empty(t, store.data["dashboard_dpop_key"]) + assert.Empty(t, store.data["dashboard_app_id"]) + assert.Empty(t, store.data["dashboard_app_region"]) + + // Non-dashboard keys should be untouched + assert.Equal(t, "keep-me", store.data["api_key"]) + }) +} + +// memStore is a minimal in-memory SecretStore for testing. +type memStore struct { + data map[string]string +} + +func (m *memStore) Set(key, value string) error { m.data[key] = value; return nil } +func (m *memStore) Get(key string) (string, error) { + return m.data[key], nil +} +func (m *memStore) Delete(key string) error { delete(m.data, key); return nil } +func (m *memStore) IsAvailable() bool { return true } +func (m *memStore) Name() string { return "mem" } diff --git a/internal/cli/dashboard/apps.go b/internal/cli/dashboard/apps.go new file mode 100644 index 0000000..5313c7f --- /dev/null +++ b/internal/cli/dashboard/apps.go @@ -0,0 +1,282 @@ +package dashboard + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" +) + +func newAppsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "apps", + Short: "Manage Nylas applications", + Long: `List and create Nylas applications via the Dashboard API.`, + } + + cmd.AddCommand(newAppsListCmd()) + cmd.AddCommand(newAppsCreateCmd()) + cmd.AddCommand(newAppsUseCmd()) + cmd.AddCommand(newAPIKeysCmd()) + + return cmd +} + +// appRow is a flat struct for table output. +type appRow struct { + ApplicationID string `json:"application_id"` + Region string `json:"region"` + Environment string `json:"environment"` + Name string `json:"name"` +} + +var appColumns = []ports.Column{ + {Header: "APPLICATION ID", Field: "ApplicationID"}, + {Header: "REGION", Field: "Region"}, + {Header: "ENVIRONMENT", Field: "Environment"}, + {Header: "NAME", Field: "Name"}, +} + +func newAppsListCmd() *cobra.Command { + var region string + + cmd := &cobra.Command{ + Use: "list", + Short: "List applications", + Long: `List all Nylas applications in your organization. + +By default, queries both US and EU regions and merges results. +Use --region to filter to a specific region.`, + Example: ` # List all applications + nylas dashboard apps list + + # List only US applications + nylas dashboard apps list --region us`, + RunE: func(cmd *cobra.Command, args []string) error { + appSvc, err := createAppService() + if err != nil { + return wrapDashboardError(err) + } + + orgPublicID, err := getActiveOrgID() + if err != nil { + return wrapDashboardError(err) + } + + ctx, cancel := common.CreateContext() + defer cancel() + + apps, err := appSvc.ListApplications(ctx, orgPublicID, region) + if err != nil { + return wrapDashboardError(err) + } + + if len(apps) == 0 { + fmt.Println("No applications found.") + fmt.Println("\nCreate one with: nylas dashboard apps create --name MyApp --region us") + return nil + } + + rows := toAppRows(apps) + return common.WriteListWithColumns(cmd, rows, appColumns) + }, + } + + cmd.Flags().StringVarP(®ion, "region", "r", "", "Filter by region (us or eu)") + + return cmd +} + +func newAppsCreateCmd() *cobra.Command { + var ( + name string + region string + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a new application", + Long: `Create a new Nylas application in the specified region.`, + Example: ` # Create a US application + nylas dashboard apps create --name "My App" --region us + + # Create an EU application + nylas dashboard apps create --name "EU App" --region eu`, + RunE: func(cmd *cobra.Command, args []string) error { + if name == "" { + return dashboardError("application name is required", "Use --name to specify the application name") + } + if region == "" { + return dashboardError("region is required", "Use --region us or --region eu") + } + if region != "us" && region != "eu" { + return dashboardError("invalid region", "Use --region us or --region eu") + } + + appSvc, err := createAppService() + if err != nil { + return wrapDashboardError(err) + } + + orgPublicID, err := getActiveOrgID() + if err != nil { + return wrapDashboardError(err) + } + + ctx, cancel := common.CreateContext() + defer cancel() + + app, err := appSvc.CreateApplication(ctx, orgPublicID, region, name) + if err != nil { + return wrapDashboardError(err) + } + + _, _ = common.Green.Println("✓ Application created!") + fmt.Printf(" Application ID: %s\n", app.ApplicationID) + fmt.Printf(" Region: %s\n", app.Region) + if app.Environment != "" { + fmt.Printf(" Environment: %s\n", app.Environment) + } + + if app.ClientSecret != "" { + _, _ = common.Yellow.Println("\n Client Secret (available once — save it now):") + if err := handleSecretDelivery(app.ClientSecret, "Client Secret"); err != nil { + return err + } + } + + fmt.Println("\nTo configure the CLI with this application:") + fmt.Printf(" nylas auth config --api-key --region %s\n", app.Region) + + return nil + }, + } + + cmd.Flags().StringVarP(&name, "name", "n", "", "Application name (required)") + cmd.Flags().StringVarP(®ion, "region", "r", "", "Region (required: us or eu)") + + return cmd +} + +func newAppsUseCmd() *cobra.Command { + var region string + + cmd := &cobra.Command{ + Use: "use ", + Short: "Set the active application for subsequent commands", + Long: `Set an application as active so you don't need to pass --app and --region +to every apikeys command.`, + Example: ` # Set active app + nylas dashboard apps use b09141da-ead2-46bd-8f4c-c9ec5af4c6cc --region us + + # Now apikeys commands use the active app automatically + nylas dashboard apps apikeys list + nylas dashboard apps apikeys create --name "My key"`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + appID := args[0] + if region == "" { + return dashboardError("region is required", "Use --region us or --region eu") + } + + _, secrets, err := createDPoPService() + if err != nil { + return wrapDashboardError(err) + } + + if err := secrets.Set(ports.KeyDashboardAppID, appID); err != nil { + return wrapDashboardError(err) + } + if err := secrets.Set(ports.KeyDashboardAppRegion, region); err != nil { + return wrapDashboardError(err) + } + + _, _ = common.Green.Printf("✓ Active app: %s (%s)\n", appID, region) + return nil + }, + } + + cmd.Flags().StringVarP(®ion, "region", "r", "", "Region of the application (required: us or eu)") + + return cmd +} + +// getActiveApp returns the active app ID and region from the keyring. +// Flags take priority over the stored active app. +func getActiveApp(appFlag, regionFlag string) (appID, region string, err error) { + if appFlag != "" && regionFlag != "" { + return appFlag, regionFlag, nil + } + + _, secrets, sErr := createDPoPService() + if sErr != nil { + return appFlag, regionFlag, sErr + } + + if appFlag == "" { + appID, _ = secrets.Get(ports.KeyDashboardAppID) + } else { + appID = appFlag + } + if regionFlag == "" { + region, _ = secrets.Get(ports.KeyDashboardAppRegion) + } else { + region = regionFlag + } + + if appID == "" { + return "", "", dashboardError( + "no active application", + "Run 'nylas dashboard apps use --region ' or pass --app and --region", + ) + } + if region == "" { + return "", "", dashboardError( + "no region set for active application", + "Run 'nylas dashboard apps use --region ' or pass --region", + ) + } + return appID, region, nil +} + +// getActiveOrgID retrieves the active organization ID from the keyring. +func getActiveOrgID() (string, error) { + _, secrets, err := createDPoPService() + if err != nil { + return "", err + } + + orgID, err := secrets.Get(ports.KeyDashboardOrgPublicID) + if err != nil || orgID == "" { + return "", dashboardError( + "no active organization", + "Run 'nylas dashboard login' first", + ) + } + return orgID, nil +} + +// toAppRows converts gateway applications to flat display rows. +func toAppRows(apps []domain.GatewayApplication) []appRow { + rows := make([]appRow, len(apps)) + for i, app := range apps { + name := "" + if app.Branding != nil { + name = app.Branding.Name + } + env := app.Environment + if env == "" { + env = "production" + } + rows[i] = appRow{ + ApplicationID: app.ApplicationID, + Region: app.Region, + Environment: env, + Name: name, + } + } + return rows +} diff --git a/internal/cli/dashboard/dashboard.go b/internal/cli/dashboard/dashboard.go new file mode 100644 index 0000000..61054f7 --- /dev/null +++ b/internal/cli/dashboard/dashboard.go @@ -0,0 +1,35 @@ +// Package dashboard provides the CLI commands for Nylas Dashboard +// account authentication and application management. +package dashboard + +import ( + "github.com/spf13/cobra" +) + +// NewDashboardCmd creates the dashboard command group. +func NewDashboardCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "dashboard", + Short: "Nylas Dashboard account and application management", + Long: `Authenticate with the Nylas Dashboard and manage applications. + +Commands: + register Create a new Nylas Dashboard account (SSO only) + login Log in to your Nylas Dashboard account + sso Authenticate via SSO (Google, Microsoft, GitHub) + logout Log out of the Nylas Dashboard + status Show current dashboard authentication status + refresh Refresh dashboard session tokens + apps Manage Nylas applications`, + } + + cmd.AddCommand(newRegisterCmd()) + cmd.AddCommand(newLoginCmd()) + cmd.AddCommand(newSSOCmd()) + cmd.AddCommand(newLogoutCmd()) + cmd.AddCommand(newStatusCmd()) + cmd.AddCommand(newRefreshCmd()) + cmd.AddCommand(newAppsCmd()) + + return cmd +} diff --git a/internal/cli/dashboard/exports.go b/internal/cli/dashboard/exports.go new file mode 100644 index 0000000..e212c67 --- /dev/null +++ b/internal/cli/dashboard/exports.go @@ -0,0 +1,42 @@ +package dashboard + +import ( + dashboardapp "github.com/nylas/cli/internal/app/dashboard" + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/ports" +) + +// CreateAuthService creates the dashboard auth service chain (exported for setup wizard). +func CreateAuthService() (*dashboardapp.AuthService, ports.SecretStore, error) { + return createAuthService() +} + +// CreateAppService creates the dashboard app management service (exported for setup wizard). +func CreateAppService() (*dashboardapp.AppService, error) { + return createAppService() +} + +// RunSSO executes the SSO device-code flow (exported for setup wizard). +func RunSSO(provider, mode string, privacyAccepted bool) error { + return runSSO(provider, mode, privacyAccepted, "") +} + +// AcceptPrivacyPolicy prompts for privacy policy acceptance (exported for setup wizard). +func AcceptPrivacyPolicy() error { + return acceptPrivacyPolicy() +} + +// ActivateAPIKey stores an API key in the keyring and configures the CLI (exported for setup wizard). +func ActivateAPIKey(apiKey, clientID, region string) error { + return activateAPIKey(apiKey, clientID, region) +} + +// GetActiveOrgID retrieves the active organization ID (exported for setup wizard). +func GetActiveOrgID() (string, error) { + return getActiveOrgID() +} + +// ReadLine prompts for a line of text input (exported for setup wizard). +func ReadLine(prompt string) (string, error) { + return common.InputPrompt(prompt, "") +} diff --git a/internal/cli/dashboard/helpers.go b/internal/cli/dashboard/helpers.go new file mode 100644 index 0000000..64efea6 --- /dev/null +++ b/internal/cli/dashboard/helpers.go @@ -0,0 +1,199 @@ +package dashboard + +import ( + "errors" + "fmt" + "os" + + "github.com/nylas/cli/internal/adapters/config" + "github.com/nylas/cli/internal/adapters/dashboard" + "github.com/nylas/cli/internal/adapters/dpop" + "github.com/nylas/cli/internal/adapters/keyring" + dashboardapp "github.com/nylas/cli/internal/app/dashboard" + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" +) + +// createDPoPService creates a DPoP service backed by the keyring. +func createDPoPService() (ports.DPoP, ports.SecretStore, error) { + secretStore, err := keyring.NewSecretStore(config.DefaultConfigDir()) + if err != nil { + return nil, nil, err + } + + dpopSvc, err := dpop.New(secretStore) + if err != nil { + return nil, nil, err + } + + return dpopSvc, secretStore, nil +} + +// createAuthService creates the full dashboard auth service chain. +func createAuthService() (*dashboardapp.AuthService, ports.SecretStore, error) { + dpopSvc, secretStore, err := createDPoPService() + if err != nil { + return nil, nil, err + } + + baseURL := getDashboardAccountBaseURL(secretStore) + accountClient := dashboard.NewAccountClient(baseURL, dpopSvc) + + return dashboardapp.NewAuthService(accountClient, secretStore), secretStore, nil +} + +// createAppService creates the dashboard app management service. +func createAppService() (*dashboardapp.AppService, error) { + dpopSvc, secretStore, err := createDPoPService() + if err != nil { + return nil, err + } + + gatewayClient := dashboard.NewGatewayClient(dpopSvc) + return dashboardapp.NewAppService(gatewayClient, secretStore), nil +} + +// getDashboardAccountBaseURL returns the dashboard-account base URL. +// Priority: NYLAS_DASHBOARD_ACCOUNT_URL env var > config file > default. +func getDashboardAccountBaseURL(secrets ports.SecretStore) string { + if envURL := os.Getenv("NYLAS_DASHBOARD_ACCOUNT_URL"); envURL != "" { + return envURL + } + configStore := config.NewDefaultFileStore() + cfg, err := configStore.Load() + if err == nil && cfg.Dashboard != nil && cfg.Dashboard.AccountBaseURL != "" { + return cfg.Dashboard.AccountBaseURL + } + return domain.DefaultDashboardAccountBaseURL +} + +// wrapDashboardError wraps a dashboard error as a CLIError, preserving +// the actual error message. +func wrapDashboardError(err error) error { + if err == nil { + return nil + } + var cliErr *common.CLIError + if errors.As(err, &cliErr) { + return cliErr + } + return &common.CLIError{ + Err: err, + Message: err.Error(), + } +} + +// dashboardError creates a user-friendly error with the hint included in the +// message itself, so it's always visible regardless of how the error is displayed. +func dashboardError(message, hint string) error { + if hint != "" { + message = message + "\n Hint: " + hint + } + return &common.CLIError{Message: message} +} + +// Auth method resolution: flags take priority, then interactive menu. +const ( + methodGoogle = "google" + methodMicrosoft = "microsoft" + methodGitHub = "github" + methodEmailPassword = "email" +) + +// resolveAuthMethod determines the auth method from flags or prompts interactively. +func resolveAuthMethod(google, microsoft, github, email bool, action string) (string, error) { + // Count how many flags were set + set := 0 + if google { + set++ + } + if microsoft { + set++ + } + if github { + set++ + } + if email { + set++ + } + if set > 1 { + return "", dashboardError("only one auth method flag allowed", "Use --google, --microsoft, --github, or --email") + } + + switch { + case google: + return methodGoogle, nil + case microsoft: + return methodMicrosoft, nil + case github: + return methodGitHub, nil + case email: + if action == "register" { + return "", dashboardError("email/password registration is temporarily disabled", "Use SSO instead: --google, --microsoft, or --github") + } + return methodEmailPassword, nil + default: + return chooseAuthMethod(action) + } +} + +// chooseAuthMethod presents an interactive menu. SSO first. +// Email/password registration is temporarily disabled. +func chooseAuthMethod(action string) (string, error) { + opts := []common.SelectOption[string]{ + {Label: "Google (recommended)", Value: methodGoogle}, + {Label: "Microsoft", Value: methodMicrosoft}, + {Label: "GitHub", Value: methodGitHub}, + } + if action != "register" { + opts = append(opts, common.SelectOption[string]{Label: "Email and password", Value: methodEmailPassword}) + } + + return common.Select(fmt.Sprintf("How would you like to %s?", action), opts) +} + +// selectOrg prompts the user to select an organization if multiple are available. +func selectOrg(orgs []domain.DashboardOrganization) string { + if len(orgs) <= 1 { + if len(orgs) == 1 { + return orgs[0].PublicID + } + return "" + } + + opts := make([]common.SelectOption[string], len(orgs)) + for i, org := range orgs { + label := org.Name + if label == "" { + label = org.PublicID + } + opts[i] = common.SelectOption[string]{Label: label, Value: org.PublicID} + } + + selected, err := common.Select("Select organization", opts) + if err != nil { + return orgs[0].PublicID + } + return selected +} + +// printAuthSuccess prints the standard post-login success message. +func printAuthSuccess(auth *domain.DashboardAuthResponse) { + _, _ = common.Green.Printf("✓ Authenticated as %s\n", auth.User.PublicID) + if len(auth.Organizations) > 0 { + fmt.Printf(" Organization: %s\n", auth.Organizations[0].PublicID) + } +} + +// acceptPrivacyPolicy prompts for or validates privacy policy acceptance. +func acceptPrivacyPolicy() error { + accepted, err := common.ConfirmPrompt("Accept Nylas Privacy Policy?", true) + if err != nil { + return err + } + if !accepted { + return dashboardError("privacy policy must be accepted to continue", "") + } + return nil +} diff --git a/internal/cli/dashboard/keys.go b/internal/cli/dashboard/keys.go new file mode 100644 index 0000000..9fb346b --- /dev/null +++ b/internal/cli/dashboard/keys.go @@ -0,0 +1,303 @@ +package dashboard + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "github.com/spf13/cobra" + + "github.com/nylas/cli/internal/adapters/config" + "github.com/nylas/cli/internal/adapters/keyring" + authapp "github.com/nylas/cli/internal/app/auth" + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" +) + +func newAPIKeysCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "apikeys", + Aliases: []string{"keys"}, + Short: "Manage API keys for an application", + } + + cmd.AddCommand(newAPIKeysListCmd()) + cmd.AddCommand(newAPIKeysCreateCmd()) + + return cmd +} + +// apiKeyRow is a flat struct for table output. +type apiKeyRow struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + ExpiresAt string `json:"expires_at"` + CreatedAt string `json:"created_at"` +} + +var apiKeyColumns = []ports.Column{ + {Header: "ID", Field: "ID"}, + {Header: "NAME", Field: "Name"}, + {Header: "STATUS", Field: "Status"}, + {Header: "EXPIRES", Field: "ExpiresAt"}, + {Header: "CREATED", Field: "CreatedAt"}, +} + +func newAPIKeysListCmd() *cobra.Command { + var ( + appID string + region string + ) + + cmd := &cobra.Command{ + Use: "list", + Short: "List API keys for an application", + Long: `List API keys for an application. Uses the active app if --app is not specified. +Set an active app with: nylas dashboard apps use --region `, + Example: ` # Using active app + nylas dashboard apps apikeys list + + # Explicit app + nylas dashboard apps apikeys list --app --region us`, + RunE: func(cmd *cobra.Command, args []string) error { + resolvedApp, resolvedRegion, err := getActiveApp(appID, region) + if err != nil { + return err + } + appID = resolvedApp + region = resolvedRegion + + appSvc, err := createAppService() + if err != nil { + return wrapDashboardError(err) + } + + ctx, cancel := common.CreateContext() + defer cancel() + + var keys []domain.GatewayAPIKey + err = common.RunWithSpinner("Fetching API keys...", func() error { + keys, err = appSvc.ListAPIKeys(ctx, appID, region) + return err + }) + if err != nil { + return wrapDashboardError(err) + } + + if len(keys) == 0 { + fmt.Println("No API keys found.") + fmt.Printf("\nCreate one with: nylas dashboard apps apikeys create --app %s --region %s\n", appID, region) + return nil + } + + rows := toAPIKeyRows(keys) + return common.WriteListWithColumns(cmd, rows, apiKeyColumns) + }, + } + + cmd.Flags().StringVar(&appID, "app", "", "Application ID (overrides active app)") + cmd.Flags().StringVarP(®ion, "region", "r", "", "Region (overrides active app)") + + return cmd +} + +func newAPIKeysCreateCmd() *cobra.Command { + var ( + appID string + region string + name string + expiresIn int + ) + + cmd := &cobra.Command{ + Use: "create", + Short: "Create an API key for an application", + Long: `Create a new API key for an application. Uses the active app if --app is not specified. + +After creation, you choose what to do with the key: + 1. Activate it — store in CLI keyring as the active API key (recommended) + 2. Copy to clipboard — for use in other tools + 3. Print to terminal — for piping or scripts + +Set an active app with: nylas dashboard apps use --region `, + Example: ` # Using active app (simplest) + nylas dashboard apps apikeys create + + # With a custom name + nylas dashboard apps apikeys create --name "My key" + + # Explicit app + nylas dashboard apps apikeys create --app --region us + + # Create with custom expiration (days) + nylas dashboard apps apikeys create --expires 30`, + RunE: func(cmd *cobra.Command, args []string) error { + resolvedApp, resolvedRegion, err := getActiveApp(appID, region) + if err != nil { + return err + } + appID = resolvedApp + region = resolvedRegion + + if name == "" { + name = "CLI-" + time.Now().Format("20060102-150405") + } + + appSvc, err := createAppService() + if err != nil { + return wrapDashboardError(err) + } + + ctx, cancel := common.CreateContext() + defer cancel() + + var key *domain.GatewayCreatedAPIKey + err = common.RunWithSpinner("Creating API key...", func() error { + key, err = appSvc.CreateAPIKey(ctx, appID, region, name, expiresIn) + return err + }) + if err != nil { + return wrapDashboardError(err) + } + + _, _ = common.Green.Println("✓ API key created") + fmt.Printf(" ID: %s\n", key.ID) + fmt.Printf(" Name: %s\n", key.Name) + + return handleAPIKeyDelivery(key.APIKey, appID, region) + }, + } + + cmd.Flags().StringVar(&appID, "app", "", "Application ID (overrides active app)") + cmd.Flags().StringVarP(®ion, "region", "r", "", "Region (overrides active app)") + cmd.Flags().StringVarP(&name, "name", "n", "", "API key name (default: CLI-)") + cmd.Flags().IntVar(&expiresIn, "expires", 0, "Expiration in days (default: no expiration)") + + return cmd +} + +// handleAPIKeyDelivery prompts the user to choose how to handle the newly created key. +// The API key is never printed to stdout to prevent leaking it in terminal history or logs. +func handleAPIKeyDelivery(apiKey, appID, region string) error { + type deliveryChoice string + const ( + choiceActivate deliveryChoice = "activate" + choiceClipboard deliveryChoice = "clipboard" + choiceFile deliveryChoice = "file" + ) + + choice, err := common.Select("What would you like to do with this API key?", []common.SelectOption[deliveryChoice]{ + {Label: "Activate for this CLI (recommended)", Value: choiceActivate}, + {Label: "Copy to clipboard", Value: choiceClipboard}, + {Label: "Save to file", Value: choiceFile}, + }) + if err != nil { + return wrapDashboardError(err) + } + + switch choice { + case choiceActivate: + if err := activateAPIKey(apiKey, appID, region); err != nil { + _, _ = common.Yellow.Printf(" Could not activate: %v\n", err) + return nil + } + _, _ = common.Green.Println("✓ API key activated — CLI is ready to use") + _, _ = common.Dim.Println(" Try: nylas auth status") + + case choiceClipboard: + if err := common.CopyToClipboard(apiKey); err != nil { + _, _ = common.Yellow.Printf(" Clipboard unavailable: %v\n", err) + _, _ = common.Dim.Println(" Falling back to file save") + return saveSecretToFile(apiKey, "nylas-api-key.txt", "API key") + } + _, _ = common.Green.Println("✓ API key copied to clipboard") + + case choiceFile: + return saveSecretToFile(apiKey, "nylas-api-key.txt", "API key") + } + + return nil +} + +// handleSecretDelivery prompts the user to choose how to receive a secret. +// Secrets are never printed to stdout to prevent leaking in terminal history or logs. +func handleSecretDelivery(secret, label string) error { + type deliveryChoice string + const ( + choiceClipboard deliveryChoice = "clipboard" + choiceFile deliveryChoice = "file" + ) + + choice, err := common.Select(fmt.Sprintf("How would you like to receive the %s?", label), []common.SelectOption[deliveryChoice]{ + {Label: "Copy to clipboard (recommended)", Value: choiceClipboard}, + {Label: "Save to file", Value: choiceFile}, + }) + if err != nil { + return wrapDashboardError(err) + } + + switch choice { + case choiceClipboard: + if err := common.CopyToClipboard(secret); err != nil { + _, _ = common.Yellow.Printf(" Clipboard unavailable: %v\n", err) + _, _ = common.Dim.Println(" Falling back to file save") + return saveSecretToFile(secret, "nylas-client-secret.txt", label) + } + _, _ = common.Green.Printf("✓ %s copied to clipboard\n", label) + + case choiceFile: + return saveSecretToFile(secret, "nylas-client-secret.txt", label) + } + + return nil +} + +// saveSecretToFile writes a secret to a temp file with restrictive permissions. +func saveSecretToFile(secret, filename, label string) error { + keyFile := filepath.Join(os.TempDir(), filename) + if err := os.WriteFile(keyFile, []byte(secret+"\n"), 0o600); err != nil { // #nosec G306 + return wrapDashboardError(fmt.Errorf("failed to write file: %w", err)) + } + _, _ = common.Green.Printf("✓ %s saved to: %s\n", label, keyFile) + _, _ = common.Dim.Println(" Read it, then delete the file") + return nil +} + +// activateAPIKey stores the API key and configures the CLI to use it. +func activateAPIKey(apiKey, clientID, region string) error { + configStore := config.NewDefaultFileStore() + secretStore, err := keyring.NewSecretStore(config.DefaultConfigDir()) + if err != nil { + return err + } + + configSvc := authapp.NewConfigService(configStore, secretStore) + return configSvc.SetupConfig(region, clientID, "", apiKey, "") +} + +// toAPIKeyRows converts API keys to flat display rows. +func toAPIKeyRows(keys []domain.GatewayAPIKey) []apiKeyRow { + rows := make([]apiKeyRow, len(keys)) + for i, k := range keys { + rows[i] = apiKeyRow{ + ID: k.ID, + Name: k.Name, + Status: k.Status, + ExpiresAt: formatEpoch(k.ExpiresAt), + CreatedAt: formatEpoch(k.CreatedAt), + } + } + return rows +} + +// formatEpoch formats a Unix epoch (seconds) as a human-readable date. +func formatEpoch(epoch float64) string { + if epoch == 0 { + return "-" + } + t := time.Unix(int64(epoch), 0) + return t.Format("2006-01-02") +} diff --git a/internal/cli/dashboard/login.go b/internal/cli/dashboard/login.go new file mode 100644 index 0000000..d95b58a --- /dev/null +++ b/internal/cli/dashboard/login.go @@ -0,0 +1,145 @@ +package dashboard + +import ( + "github.com/spf13/cobra" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" +) + +func newLoginCmd() *cobra.Command { + var ( + orgPublicID string + google bool + microsoft bool + github bool + emailFlag bool + userFlag string + passFlag string + ) + + cmd := &cobra.Command{ + Use: "login", + Short: "Log in to your Nylas Dashboard account", + Long: `Authenticate with the Nylas Dashboard. + +Choose SSO (recommended) or email/password. Pass a flag to skip the menu.`, + Example: ` # Interactive — choose auth method + nylas dashboard login + + # Google SSO (non-interactive) + nylas dashboard login --google + + # Email/password (non-interactive) + nylas dashboard login --email --user user@example.com --password secret + + # Login to a specific organization + nylas dashboard login --google --org org_123`, + RunE: func(cmd *cobra.Command, args []string) error { + method, err := resolveAuthMethod(google, microsoft, github, emailFlag, "log in") + if err != nil { + return wrapDashboardError(err) + } + + switch method { + case methodGoogle, methodMicrosoft, methodGitHub: + return runSSO(method, "login", false, orgPublicID) + case methodEmailPassword: + return runEmailLogin(userFlag, passFlag, orgPublicID) + default: + return dashboardError("invalid selection", "Choose a valid option") + } + }, + } + + cmd.Flags().BoolVar(&google, "google", false, "Log in with Google SSO") + cmd.Flags().BoolVar(µsoft, "microsoft", false, "Log in with Microsoft SSO") + cmd.Flags().BoolVar(&github, "github", false, "Log in with GitHub SSO") + cmd.Flags().BoolVar(&emailFlag, "email", false, "Log in with email and password") + cmd.Flags().StringVar(&orgPublicID, "org", "", "Organization public ID") + cmd.Flags().StringVar(&userFlag, "user", "", "Email address (non-interactive)") + cmd.Flags().StringVar(&passFlag, "password", "", "Password (non-interactive, use with care)") + + return cmd +} + +func runEmailLogin(userFlag, passFlag, orgPublicID string) error { + authSvc, _, err := createAuthService() + if err != nil { + return wrapDashboardError(err) + } + + email := userFlag + if email == "" { + email, err = common.InputPrompt("Email", "") + if err != nil { + return wrapDashboardError(err) + } + } + if email == "" { + return dashboardError("email is required", "Use --user or enter at prompt") + } + + password := passFlag + if password == "" { + password, err = common.PasswordPrompt("Password") + if err != nil { + return wrapDashboardError(err) + } + } + if password == "" { + return dashboardError("password is required", "Use --password or enter at prompt") + } + + ctx, cancel := common.CreateContext() + defer cancel() + + var auth *domain.DashboardAuthResponse + var mfa *domain.DashboardMFARequired + + err = common.RunWithSpinner("Authenticating...", func() error { + auth, mfa, err = authSvc.Login(ctx, email, password, orgPublicID) + return err + }) + if err != nil { + return wrapDashboardError(err) + } + + if mfa != nil { + code, readErr := common.PasswordPrompt("MFA code") + if readErr != nil { + return wrapDashboardError(readErr) + } + if code == "" { + return dashboardError("MFA code is required", "Enter the code from your authenticator app") + } + + mfaOrg := orgPublicID + if mfaOrg == "" && len(mfa.Organizations) > 0 { + if len(mfa.Organizations) > 1 { + mfaOrg = selectOrg(mfa.Organizations) + } else { + mfaOrg = mfa.Organizations[0].PublicID + } + } + + ctx2, cancel2 := common.CreateContext() + defer cancel2() + + err = common.RunWithSpinner("Verifying MFA...", func() error { + auth, err = authSvc.CompleteMFA(ctx2, mfa.User.PublicID, code, mfaOrg) + return err + }) + if err != nil { + return wrapDashboardError(err) + } + } + + if orgPublicID == "" && len(auth.Organizations) > 1 { + orgID := selectOrg(auth.Organizations) + _ = authSvc.SetActiveOrg(orgID) + } + + printAuthSuccess(auth) + return nil +} diff --git a/internal/cli/dashboard/logout.go b/internal/cli/dashboard/logout.go new file mode 100644 index 0000000..fe3f9e4 --- /dev/null +++ b/internal/cli/dashboard/logout.go @@ -0,0 +1,33 @@ +package dashboard + +import ( + "github.com/spf13/cobra" + + "github.com/nylas/cli/internal/cli/common" +) + +func newLogoutCmd() *cobra.Command { + return &cobra.Command{ + Use: "logout", + Short: "Log out of the Nylas Dashboard", + RunE: func(cmd *cobra.Command, args []string) error { + authSvc, _, err := createAuthService() + if err != nil { + return wrapDashboardError(err) + } + + ctx, cancel := common.CreateContext() + defer cancel() + + err = common.RunWithSpinner("Logging out...", func() error { + return authSvc.Logout(ctx) + }) + if err != nil { + return wrapDashboardError(err) + } + + _, _ = common.Green.Println("✓ Logged out") + return nil + }, + } +} diff --git a/internal/cli/dashboard/refresh.go b/internal/cli/dashboard/refresh.go new file mode 100644 index 0000000..d423060 --- /dev/null +++ b/internal/cli/dashboard/refresh.go @@ -0,0 +1,37 @@ +package dashboard + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/nylas/cli/internal/cli/common" +) + +func newRefreshCmd() *cobra.Command { + return &cobra.Command{ + Use: "refresh", + Short: "Refresh dashboard session tokens", + RunE: func(cmd *cobra.Command, args []string) error { + authSvc, _, err := createAuthService() + if err != nil { + return wrapDashboardError(err) + } + + ctx, cancel := common.CreateContext() + defer cancel() + + err = common.RunWithSpinner("Refreshing session...", func() error { + return authSvc.Refresh(ctx) + }) + if err != nil { + fmt.Println("Session expired. Please log in again:") + fmt.Println(" nylas dashboard login") + return wrapDashboardError(err) + } + + _, _ = common.Green.Println("✓ Session refreshed") + return nil + }, + } +} diff --git a/internal/cli/dashboard/register.go b/internal/cli/dashboard/register.go new file mode 100644 index 0000000..11280e1 --- /dev/null +++ b/internal/cli/dashboard/register.go @@ -0,0 +1,58 @@ +package dashboard + +import ( + "github.com/spf13/cobra" +) + +func newRegisterCmd() *cobra.Command { + var ( + google bool + microsoft bool + github bool + ) + + cmd := &cobra.Command{ + Use: "register", + Short: "Create a new Nylas Dashboard account", + Long: `Register a new Nylas Dashboard account using SSO. + +Email/password registration is temporarily disabled. Use SSO instead.`, + Example: ` # Interactive — choose SSO provider + nylas dashboard register + + # Google SSO (non-interactive) + nylas dashboard register --google + + # Microsoft SSO + nylas dashboard register --microsoft + + # GitHub SSO + nylas dashboard register --github`, + RunE: func(cmd *cobra.Command, args []string) error { + method, err := resolveAuthMethod(google, microsoft, github, false, "register") + if err != nil { + return wrapDashboardError(err) + } + + switch method { + case methodGoogle, methodMicrosoft, methodGitHub: + return runSSORegister(method) + default: + return dashboardError("invalid selection", "Choose a valid SSO provider") + } + }, + } + + cmd.Flags().BoolVar(&google, "google", false, "Register with Google SSO") + cmd.Flags().BoolVar(µsoft, "microsoft", false, "Register with Microsoft SSO") + cmd.Flags().BoolVar(&github, "github", false, "Register with GitHub SSO") + + return cmd +} + +func runSSORegister(provider string) error { + if err := acceptPrivacyPolicy(); err != nil { + return err + } + return runSSO(provider, "register", true) +} diff --git a/internal/cli/dashboard/sso.go b/internal/cli/dashboard/sso.go new file mode 100644 index 0000000..88b77b3 --- /dev/null +++ b/internal/cli/dashboard/sso.go @@ -0,0 +1,216 @@ +package dashboard + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/nylas/cli/internal/adapters/browser" + dashboardapp "github.com/nylas/cli/internal/app/dashboard" + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" +) + +func newSSOCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "sso", + Short: "Authenticate via SSO", + Long: `Authenticate with the Nylas Dashboard using SSO (Google, Microsoft, or GitHub).`, + } + + cmd.AddCommand(newSSOLoginCmd()) + cmd.AddCommand(newSSORegisterCmd()) + + return cmd +} + +func newSSOLoginCmd() *cobra.Command { + var provider string + + cmd := &cobra.Command{ + Use: "login", + Short: "Log in via SSO", + Example: ` nylas dashboard sso login --provider google + nylas dashboard sso login --provider microsoft + nylas dashboard sso login --provider github`, + RunE: func(cmd *cobra.Command, args []string) error { + return runSSO(provider, "login", false) + }, + } + + cmd.Flags().StringVarP(&provider, "provider", "p", "google", "SSO provider (google, microsoft, github)") + + return cmd +} + +func newSSORegisterCmd() *cobra.Command { + var provider string + + cmd := &cobra.Command{ + Use: "register", + Short: "Register via SSO", + Example: ` nylas dashboard sso register --provider google`, + RunE: func(cmd *cobra.Command, args []string) error { + if err := acceptPrivacyPolicy(); err != nil { + return err + } + return runSSO(provider, "register", true) + }, + } + + cmd.Flags().StringVarP(&provider, "provider", "p", "google", "SSO provider (google, microsoft, github)") + + return cmd +} + +func runSSO(provider, mode string, privacyPolicyAccepted bool, orgPublicIDs ...string) error { + orgPublicID := "" + if len(orgPublicIDs) > 0 { + orgPublicID = orgPublicIDs[0] + } + + loginType, err := mapProvider(provider) + if err != nil { + return err + } + + authSvc, _, err := createAuthService() + if err != nil { + return wrapDashboardError(err) + } + + ctx, cancel := common.CreateLongContext() + defer cancel() + + var resp *domain.DashboardSSOStartResponse + err = common.RunWithSpinner("Starting SSO...", func() error { + resp, err = authSvc.SSOStart(ctx, loginType, mode, privacyPolicyAccepted) + return err + }) + if err != nil { + return wrapDashboardError(err) + } + + // Show the URL and code + url := resp.VerificationURIComplete + if url == "" { + url = resp.VerificationURI + } + + fmt.Println() + _, _ = common.BoldCyan.Printf(" Open: %s\n", url) + if resp.UserCode != "" && resp.VerificationURIComplete == "" { + _, _ = common.Bold.Printf(" Code: %s\n", resp.UserCode) + } + fmt.Println() + + // Try to open browser + b := browser.NewDefaultBrowser() + if openErr := b.Open(url); openErr == nil { + _, _ = common.Dim.Println(" Browser opened. Complete sign-in there.") + fmt.Println() + } + + // Poll with spinner + interval := time.Duration(resp.Interval) * time.Second + if interval < time.Second { + interval = 5 * time.Second + } + + auth, err := pollSSO(ctx, authSvc, resp.FlowID, orgPublicID, interval) + if err != nil { + return wrapDashboardError(err) + } + + printAuthSuccess(auth) + return nil +} + +func pollSSO(ctx context.Context, authSvc *dashboardapp.AuthService, flowID, orgPublicID string, interval time.Duration) (*domain.DashboardAuthResponse, error) { + spinner := common.NewSpinner("Waiting for browser authentication...") + spinner.Start() + defer spinner.Stop() + + for { + select { + case <-ctx.Done(): + spinner.StopWithError("Timed out") + return nil, fmt.Errorf("authentication timed out") + case <-time.After(interval): + } + + resp, err := authSvc.SSOPoll(ctx, flowID, orgPublicID) + if err != nil { + spinner.StopWithError("Failed") + return nil, err + } + + switch resp.Status { + case domain.SSOStatusComplete: + spinner.StopWithSuccess("Authenticated!") + if resp.Auth != nil { + return resp.Auth, nil + } + return nil, fmt.Errorf("unexpected empty auth response") + + case domain.SSOStatusMFARequired: + spinner.Stop() + if resp.MFA == nil { + return nil, fmt.Errorf("unexpected empty MFA response") + } + code, readErr := common.PasswordPrompt("MFA code") + if readErr != nil { + return nil, readErr + } + + ctx2, cancel := common.CreateContext() + var auth *domain.DashboardAuthResponse + mfaOrg := orgPublicID + if mfaOrg == "" && len(resp.MFA.Organizations) > 0 { + mfaOrg = resp.MFA.Organizations[0].PublicID + } + mfaErr := common.RunWithSpinner("Verifying MFA...", func() error { + auth, err = authSvc.CompleteMFA(ctx2, resp.MFA.User.PublicID, code, mfaOrg) + return err + }) + cancel() + if mfaErr != nil { + return nil, mfaErr + } + return auth, nil + + case domain.SSOStatusAccessDenied: + spinner.StopWithError("Access denied") + return nil, fmt.Errorf("%w: access denied by provider", domain.ErrDashboardSSOFailed) + + case domain.SSOStatusExpired: + spinner.StopWithError("Device code expired") + return nil, fmt.Errorf("%w: device code expired, please try again", domain.ErrDashboardSSOFailed) + + case domain.SSOStatusPending: + if resp.RetryAfter > 0 { + interval = time.Duration(resp.RetryAfter) * time.Second + } + } + } +} + +// mapProvider maps a user-friendly provider name to the server login type. +func mapProvider(provider string) (string, error) { + switch strings.ToLower(provider) { + case "google": + return domain.SSOLoginTypeGoogle, nil + case "microsoft": + return domain.SSOLoginTypeMicrosoft, nil + case "github": + return domain.SSOLoginTypeGitHub, nil + default: + return "", dashboardError( + fmt.Sprintf("unsupported SSO provider: %s", provider), + "Use one of: google, microsoft, github", + ) + } +} diff --git a/internal/cli/dashboard/status.go b/internal/cli/dashboard/status.go new file mode 100644 index 0000000..3f3449f --- /dev/null +++ b/internal/cli/dashboard/status.go @@ -0,0 +1,61 @@ +package dashboard + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/ports" +) + +func newStatusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show current dashboard authentication status", + RunE: func(cmd *cobra.Command, args []string) error { + authSvc, secrets, err := createAuthService() + if err != nil { + return wrapDashboardError(err) + } + + status := authSvc.GetStatus() + + if !status.LoggedIn { + _, _ = common.Yellow.Println("Not logged in") + fmt.Println(" nylas dashboard login") + return nil + } + + _, _ = common.Green.Println("✓ Logged in") + if status.UserID != "" { + fmt.Printf(" User: %s\n", status.UserID) + } + if status.OrgID != "" { + fmt.Printf(" Organization: %s\n", status.OrgID) + } + fmt.Printf(" Org token: %s\n", presentAbsent(status.HasOrgToken)) + + // Active app + appID, _ := secrets.Get(ports.KeyDashboardAppID) + appRegion, _ := secrets.Get(ports.KeyDashboardAppRegion) + if appID != "" { + fmt.Printf(" Active app: %s (%s)\n", appID, appRegion) + } + + dpopSvc, _, dpopErr := createDPoPService() + if dpopErr == nil { + fmt.Printf(" DPoP key: %s\n", dpopSvc.Thumbprint()) + } + + return nil + }, + } +} + +func presentAbsent(present bool) string { + if present { + return "present" + } + return "absent" +} diff --git a/internal/cli/root.go b/internal/cli/root.go index aae3803..65ff8b8 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -2,71 +2,112 @@ package cli import ( + "fmt" + "github.com/spf13/cobra" + + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/cli/setup" ) var rootCmd = &cobra.Command{ Use: "nylas", - Short: "Nylas CLI - Email, Authentication, and OTP management", + Short: "Nylas CLI - Email, calendar, and contacts from your terminal", Version: Version, - Long: `nylas is a command-line tool for managing emails, Nylas API authentication, -and retrieving OTP codes from email. - -AUTHENTICATION: - nylas auth login Authenticate with an email provider - nylas auth logout Logout from current account - nylas auth status Check authentication status - nylas auth list List all authenticated accounts - nylas auth switch Switch between accounts - nylas auth add Manually add an existing grant - nylas auth whoami Show current user info - -EMAIL MANAGEMENT: - nylas email list List recent emails - nylas email read Read a specific email - nylas email send Send an email - nylas email search Search emails - nylas email folders list List folders - nylas email threads list List email threads - nylas email drafts list List drafts - -CALENDAR MANAGEMENT: - nylas calendar list List calendars - nylas calendar events list List upcoming events - nylas calendar events show Show event details - nylas calendar events create Create a new event - nylas calendar events delete Delete an event - nylas calendar availability check Check free/busy status - nylas calendar availability find Find available meeting times - -CONTACTS MANAGEMENT: - nylas contacts list List contacts - nylas contacts show Show contact details - nylas contacts create Create a new contact - nylas contacts delete Delete a contact - nylas contacts groups List contact groups - -WEBHOOK MANAGEMENT: - nylas webhook list List all webhooks - nylas webhook show Show webhook details - nylas webhook create Create a new webhook - nylas webhook update Update a webhook - nylas webhook delete Delete a webhook - nylas webhook triggers List available trigger types - nylas webhook test send Send a test event - nylas webhook test payload Get mock payload for trigger - -OTP MANAGEMENT: - nylas otp get Get the latest OTP code - nylas otp watch Watch for new OTP codes - nylas otp list List configured accounts - -INTERACTIVE TUI: - nylas tui Launch k9s-style terminal UI for emails + Long: `Quick start: + nylas init Guided setup (first time) + nylas email list List recent emails + nylas calendar events Upcoming events + nylas contacts list List contacts Documentation: https://cli.nylas.com/`, SilenceUsage: true, SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + if setup.IsFirstRun() { + printWelcome() + return nil + } + printHelpHeader() + return cmd.Help() + }, +} + +// printHelpHeader prints the branded ASCII art header. +func printHelpHeader() { + fmt.Println() + fmt.Println(common.Brand.Render(" ░█▀█░█░█░█░░░█▀█░█▀▀")) + fmt.Println(common.Brand.Render(" ░█░█░░█░░█░░░█▀█░▀▀█")) + fmt.Println(common.Brand.Render(" ░▀░▀░░▀░░▀▀▀░▀░▀░▀▀▀")) + fmt.Println() +} + +// printWelcome displays the first-run welcome message. +func printWelcome() { + // Banner + fmt.Println() + _, _ = common.Dim.Println(" ╭──────────────────────────────────────────╮") + _, _ = common.Dim.Println(" │ │") + fmt.Print(" ") + _, _ = common.Dim.Print("│") + fmt.Print(" ") + fmt.Print(common.Brand.Render("◈ N Y L A S C L I")) + fmt.Print(" ") + _, _ = common.Dim.Println("│") + _, _ = common.Dim.Println(" │ │") + fmt.Print(" ") + _, _ = common.Dim.Print("│") + fmt.Print(" Email, calendar, and contacts ") + _, _ = common.Dim.Println("│") + fmt.Print(" ") + _, _ = common.Dim.Print("│") + fmt.Print(" from your terminal. ") + _, _ = common.Dim.Println("│") + _, _ = common.Dim.Println(" │ │") + _, _ = common.Dim.Println(" ╰──────────────────────────────────────────╯") + + // Getting started + fmt.Println() + _, _ = common.Bold.Println(" Get started in under a minute:") + fmt.Println() + fmt.Print(" ") + fmt.Print(common.Brand.Render("❯ nylas init")) + fmt.Println(" Guided setup") + fmt.Print(" ") + _, _ = common.Dim.Println(" nylas init --api-key Quick setup with existing key") + + // Capabilities box + fmt.Println() + _, _ = common.Dim.Print(" ╭─") + _, _ = common.Bold.Print(" What you can do ") + _, _ = common.Dim.Println("────────────────────────╮") + _, _ = common.Dim.Println(" │ │") + printCapability("email", "Send, search, and read") + printCapability("calendar", "Events and availability") + printCapability("contacts", "People and groups") + printCapability("webhook", "Real-time notifications") + printCapability("ai", "Chat with your data") + _, _ = common.Dim.Println(" │ │") + _, _ = common.Dim.Println(" ╰──────────────────────────────────────────╯") + + // Footer + fmt.Println() + fmt.Print(" ") + _, _ = common.Dim.Print("nylas --help") + fmt.Println(" All commands") + fmt.Print(" ") + _, _ = common.Dim.Println("https://cli.nylas.com Documentation") + fmt.Println() +} + +// printCapability prints a single capability row inside the box. +func printCapability(name, desc string) { + fmt.Print(" ") + _, _ = common.Dim.Print("│") + fmt.Print(" ") + fmt.Print(common.Brand.Render(fmt.Sprintf("%-12s", name))) + fmt.Printf("%-28s", desc) + _, _ = common.Dim.Println("│") } func init() { diff --git a/internal/cli/setup/detect.go b/internal/cli/setup/detect.go new file mode 100644 index 0000000..3f04bb6 --- /dev/null +++ b/internal/cli/setup/detect.go @@ -0,0 +1,85 @@ +// Package setup provides the first-time user experience wizard for the Nylas CLI. +package setup + +import ( + "os" + + "github.com/nylas/cli/internal/adapters/config" + "github.com/nylas/cli/internal/adapters/keyring" + "github.com/nylas/cli/internal/ports" +) + +// SetupStatus describes what is already configured in the CLI. +type SetupStatus struct { + HasDashboardAuth bool + HasAPIKey bool + HasActiveApp bool + HasGrants bool + ActiveAppID string + ActiveAppRegion string +} + +// IsFirstRun returns true when the CLI has never been configured. +// A user is "first run" when there is no API key (keyring or env) and +// no dashboard session token. +func IsFirstRun() bool { + // Check environment variable first (cheapest check). + if os.Getenv("NYLAS_API_KEY") != "" { + return false + } + + secretStore, err := keyring.NewSecretStore(config.DefaultConfigDir()) + if err != nil { + // Can't access secrets — treat as first run so the welcome message shows. + return true + } + + if hasKey(secretStore, ports.KeyAPIKey) { + return false + } + if hasKey(secretStore, ports.KeyDashboardUserToken) { + return false + } + + return true +} + +// GetSetupStatus returns a detailed view of the current setup state. +func GetSetupStatus() SetupStatus { + status := SetupStatus{} + + // Check env-based API key. + if os.Getenv("NYLAS_API_KEY") != "" { + status.HasAPIKey = true + } + + secretStore, err := keyring.NewSecretStore(config.DefaultConfigDir()) + if err != nil { + return status + } + + status.HasAPIKey = status.HasAPIKey || hasKey(secretStore, ports.KeyAPIKey) + status.HasDashboardAuth = hasKey(secretStore, ports.KeyDashboardUserToken) + + appID, _ := secretStore.Get(ports.KeyDashboardAppID) + appRegion, _ := secretStore.Get(ports.KeyDashboardAppRegion) + if appID != "" && appRegion != "" { + status.HasActiveApp = true + status.ActiveAppID = appID + status.ActiveAppRegion = appRegion + } + + grantStore := keyring.NewGrantStore(secretStore) + grants, err := grantStore.ListGrants() + if err == nil && len(grants) > 0 { + status.HasGrants = true + } + + return status +} + +// hasKey returns true if a non-empty value exists for the given key. +func hasKey(store ports.SecretStore, key string) bool { + val, err := store.Get(key) + return err == nil && val != "" +} diff --git a/internal/cli/setup/grants.go b/internal/cli/setup/grants.go new file mode 100644 index 0000000..10096ec --- /dev/null +++ b/internal/cli/setup/grants.go @@ -0,0 +1,83 @@ +package setup + +import ( + "fmt" + + nylasadapter "github.com/nylas/cli/internal/adapters/nylas" + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" +) + +// SyncResult holds the result of a grant sync operation. +type SyncResult struct { + ValidGrants []domain.Grant + DefaultGrantID string +} + +// SyncGrants fetches grants from the Nylas API and saves them to the local keyring. +// It returns the list of valid grants and the chosen default grant ID. +// The caller is responsible for setting the default if multiple grants exist +// (use PromptDefaultGrant for interactive selection). +func SyncGrants(grantStore ports.GrantStore, apiKey, clientID, region string) (*SyncResult, error) { + client := nylasadapter.NewHTTPClient() + client.SetRegion(region) + client.SetCredentials(clientID, "", apiKey) + + ctx, cancel := common.CreateContext() + defer cancel() + + grants, err := client.ListGrants(ctx) + if err != nil { + return nil, fmt.Errorf("could not fetch grants: %w", err) + } + + var validGrants []domain.Grant + for _, grant := range grants { + if !grant.IsValid() { + continue + } + + grantInfo := domain.GrantInfo{ + ID: grant.ID, + Email: grant.Email, + Provider: grant.Provider, + } + + if saveErr := grantStore.SaveGrant(grantInfo); saveErr != nil { + continue + } + + validGrants = append(validGrants, grant) + _, _ = common.Green.Printf(" ✓ Added %s (%s)\n", grant.Email, grant.Provider.DisplayName()) + } + + result := &SyncResult{ValidGrants: validGrants} + + // Auto-set default if there's exactly one valid grant. + if len(validGrants) == 1 { + result.DefaultGrantID = validGrants[0].ID + _ = grantStore.SetDefaultGrant(result.DefaultGrantID) + } + + return result, nil +} + +// PromptDefaultGrant presents an interactive menu for the user to select a default grant. +func PromptDefaultGrant(grantStore ports.GrantStore, grants []domain.Grant) (string, error) { + opts := make([]common.SelectOption[string], len(grants)) + for i, grant := range grants { + opts[i] = common.SelectOption[string]{ + Label: fmt.Sprintf("%s (%s)", grant.Email, grant.Provider.DisplayName()), + Value: grant.ID, + } + } + + chosen, err := common.Select("Select default account", opts) + if err != nil { + chosen = grants[0].ID + } + + _ = grantStore.SetDefaultGrant(chosen) + return chosen, nil +} diff --git a/internal/cli/setup/setup.go b/internal/cli/setup/setup.go new file mode 100644 index 0000000..ff1bb42 --- /dev/null +++ b/internal/cli/setup/setup.go @@ -0,0 +1,47 @@ +package setup + +import ( + "github.com/spf13/cobra" +) + +// NewSetupCmd creates the "init" command for first-time CLI setup. +func NewSetupCmd() *cobra.Command { + var opts wizardOpts + + cmd := &cobra.Command{ + Use: "init", + Short: "Set up the Nylas CLI", + Long: `Guided setup for first-time users. + +This wizard walks you through: + 1. Creating or logging into your Nylas account + 2. Selecting or creating an application + 3. Generating and activating an API key + 4. Syncing existing email accounts + +Already have an API key? Skip the wizard: + nylas init --api-key `, + Example: ` # Interactive guided setup + nylas init + + # Quick setup with existing API key + nylas init --api-key nyl_abc123 + + # Quick setup with region + nylas init --api-key nyl_abc123 --region eu + + # Skip SSO provider menu + nylas init --google`, + RunE: func(cmd *cobra.Command, args []string) error { + return runWizard(opts) + }, + } + + cmd.Flags().StringVar(&opts.apiKey, "api-key", "", "Nylas API key (skips interactive setup)") + cmd.Flags().StringVarP(&opts.region, "region", "r", "us", "API region (us or eu)") + cmd.Flags().BoolVar(&opts.google, "google", false, "Use Google SSO") + cmd.Flags().BoolVar(&opts.microsoft, "microsoft", false, "Use Microsoft SSO") + cmd.Flags().BoolVar(&opts.github, "github", false, "Use GitHub SSO") + + return cmd +} diff --git a/internal/cli/setup/setup_test.go b/internal/cli/setup/setup_test.go new file mode 100644 index 0000000..bb80d81 --- /dev/null +++ b/internal/cli/setup/setup_test.go @@ -0,0 +1,202 @@ +package setup + +import ( + "testing" + + "github.com/nylas/cli/internal/domain" +) + +func TestSanitizeAPIKey(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "clean key", + input: "nyl_abc123def456", + want: "nyl_abc123def456", + }, + { + name: "key with newline", + input: "nyl_abc123\n", + want: "nyl_abc123", + }, + { + name: "key with carriage return", + input: "nyl_abc123\r\n", + want: "nyl_abc123", + }, + { + name: "key with tab", + input: "\tnyl_abc123\t", + want: "nyl_abc123", + }, + { + name: "key with control characters", + input: "\x00nyl_abc123\x01\x02", + want: "nyl_abc123", + }, + { + name: "key with leading/trailing spaces", + input: " nyl_abc123 ", + want: "nyl_abc123", + }, + { + name: "empty key", + input: "", + want: "", + }, + { + name: "only whitespace", + input: " \n\r\t ", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := sanitizeAPIKey(tt.input) + if got != tt.want { + t.Errorf("sanitizeAPIKey(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestAppDisplayName(t *testing.T) { + tests := []struct { + name string + app appDisplayInput + want string + }{ + { + name: "basic app", + app: appDisplayInput{ + appID: "abc123", + environment: "production", + region: "us", + }, + want: "abc123 (production, us)", + }, + { + name: "app with name", + app: appDisplayInput{ + appID: "abc123", + environment: "production", + region: "us", + brandName: "My App", + }, + want: "My App — abc123 (production, us)", + }, + { + name: "long app ID truncated", + app: appDisplayInput{ + appID: "abcdefghij1234567890xyz", + environment: "production", + region: "eu", + }, + want: "abcdefghij1234567... (production, eu)", + }, + { + name: "empty environment defaults to production", + app: appDisplayInput{ + appID: "abc123", + environment: "", + region: "us", + }, + want: "abc123 (production, us)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + app := tt.app.toGatewayApp() + got := appDisplayName(app) + if got != tt.want { + t.Errorf("appDisplayName() = %q, want %q", got, tt.want) + } + }) + } +} + +// appDisplayInput is a test helper for constructing GatewayApplication. +type appDisplayInput struct { + appID string + environment string + region string + brandName string +} + +func (a appDisplayInput) toGatewayApp() domain.GatewayApplication { + app := domain.GatewayApplication{ + ApplicationID: a.appID, + Environment: a.environment, + Region: a.region, + } + if a.brandName != "" { + app.Branding = &domain.GatewayApplicationBrand{Name: a.brandName} + } + return app +} + +func TestResolveProvider(t *testing.T) { + tests := []struct { + name string + opts wizardOpts + want string + }{ + { + name: "google flag", + opts: wizardOpts{google: true}, + want: "google", + }, + { + name: "microsoft flag", + opts: wizardOpts{microsoft: true}, + want: "microsoft", + }, + { + name: "github flag", + opts: wizardOpts{github: true}, + want: "github", + }, + { + name: "no flags", + opts: wizardOpts{}, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := resolveProvider(tt.opts) + if got != tt.want { + t.Errorf("resolveProvider() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestSetupStatus(t *testing.T) { + // Test that a zero-value SetupStatus has all fields false. + status := SetupStatus{} + if status.HasDashboardAuth { + t.Error("zero SetupStatus.HasDashboardAuth should be false") + } + if status.HasAPIKey { + t.Error("zero SetupStatus.HasAPIKey should be false") + } + if status.HasActiveApp { + t.Error("zero SetupStatus.HasActiveApp should be false") + } + if status.HasGrants { + t.Error("zero SetupStatus.HasGrants should be false") + } + if status.ActiveAppID != "" { + t.Error("zero SetupStatus.ActiveAppID should be empty") + } + if status.ActiveAppRegion != "" { + t.Error("zero SetupStatus.ActiveAppRegion should be empty") + } +} diff --git a/internal/cli/setup/wizard.go b/internal/cli/setup/wizard.go new file mode 100644 index 0000000..8374b90 --- /dev/null +++ b/internal/cli/setup/wizard.go @@ -0,0 +1,456 @@ +package setup + +import ( + "fmt" + "os" + "time" + + "golang.org/x/term" + + "github.com/nylas/cli/internal/adapters/config" + "github.com/nylas/cli/internal/adapters/keyring" + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/cli/dashboard" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" +) + +// wizardOpts holds the options parsed from CLI flags. +type wizardOpts struct { + apiKey string + region string + google bool + microsoft bool + github bool +} + +// pathChoice represents the user's initial choice in the wizard. +type pathChoice int + +const ( + pathRegister pathChoice = iota + 1 + pathLogin + pathAPIKey +) + +const ( + stepTotal = 4 + divider = "──────────────────────────────────────────" +) + +func runWizard(opts wizardOpts) error { + fmt.Println() + _, _ = common.Bold.Println(" Welcome to Nylas! Let's get you set up.") + fmt.Println() + + status := GetSetupStatus() + + // Non-interactive: --api-key was provided. + if opts.apiKey != "" { + return runNonInteractive(opts, status) + } + + // Interactive: must be a TTY. + if !term.IsTerminal(int(os.Stdin.Fd())) { + common.PrintError("--api-key is required in non-interactive mode") + fmt.Println() + fmt.Println(" Usage: nylas init --api-key [--region us|eu]") + return fmt.Errorf("non-interactive mode requires --api-key") + } + + // Step 1: Account + if err := stepAccount(opts, &status); err != nil { + return err + } + + // Step 2: Application (skipped for API key path — handled in stepAccount) + if err := stepApplication(&status); err != nil { + printStepRecovery("application", []string{ + "nylas dashboard apps list", + "nylas dashboard apps create --name 'My App' --region us", + }) + return fmt.Errorf("application setup failed: %w", err) + } + + // Step 3: API Key + if err := stepAPIKey(&status); err != nil { + printStepRecovery("API key", []string{ + "nylas dashboard apps apikeys create", + }) + return fmt.Errorf("API key setup failed: %w", err) + } + + // Step 4: Grants + stepGrantSync(&status) + + // Done! + printComplete() + return nil +} + +// runNonInteractive handles the --api-key flag path with no prompts. +func runNonInteractive(opts wizardOpts, status SetupStatus) error { + if status.HasAPIKey { + _, _ = common.Green.Println(" ✓ API key already configured") + stepGrantSync(&status) + printComplete() + return nil + } + + region := opts.region + if region == "" { + region = "us" + } + + apiKey := sanitizeAPIKey(opts.apiKey) + + fmt.Println() + var verifyErr error + _ = common.RunWithSpinner("Verifying API key...", func() error { + verifyErr = verifyAPIKey(apiKey, region) + return verifyErr + }) + if verifyErr != nil { + common.PrintError("Invalid API key: %v", verifyErr) + return verifyErr + } + _, _ = common.Green.Println(" ✓ API key is valid") + + if err := dashboard.ActivateAPIKey(apiKey, "", region); err != nil { + common.PrintError("Could not activate API key: %v", err) + return err + } + _, _ = common.Green.Println(" ✓ Configuration saved") + + // Refresh status after activation. + status = GetSetupStatus() + stepGrantSync(&status) + printComplete() + return nil +} + +// stepAccount handles Step 1: account registration, login, or API key entry. +func stepAccount(opts wizardOpts, status *SetupStatus) error { + _, _ = common.Dim.Printf(" %s\n", divider) + fmt.Println() + _, _ = common.Bold.Printf(" Step 1 of %d: Account\n", stepTotal) + fmt.Println() + + if status.HasDashboardAuth { + _, _ = common.Green.Println(" ✓ Already logged in to Nylas Dashboard") + return nil + } + if status.HasAPIKey { + _, _ = common.Green.Println(" ✓ API key already configured") + return nil + } + + // Determine the path. + path, err := chooseAccountPath(opts) + if err != nil { + return err + } + + switch path { + case pathRegister: + return accountSSO(opts, "register") + case pathLogin: + return accountSSO(opts, "login") + case pathAPIKey: + return accountAPIKey(status) + } + return nil +} + +// chooseAccountPath presents the three-option menu or resolves from flags. +func chooseAccountPath(opts wizardOpts) (pathChoice, error) { + // If SSO flag was provided, determine register vs login. + if opts.google || opts.microsoft || opts.github { + return pathLogin, nil + } + + return common.Select("Do you have a Nylas account?", []common.SelectOption[pathChoice]{ + {Label: "No, create one (free)", Value: pathRegister}, + {Label: "Yes, log me in", Value: pathLogin}, + {Label: "I already have an API key", Value: pathAPIKey}, + }) +} + +// accountSSO handles SSO registration or login. +func accountSSO(opts wizardOpts, mode string) error { + if mode == "register" { + if err := dashboard.AcceptPrivacyPolicy(); err != nil { + return err + } + } + + provider := resolveProvider(opts) + if provider == "" { + var err error + provider, err = chooseProvider() + if err != nil { + return err + } + } + + return dashboard.RunSSO(provider, mode, mode == "register") +} + +// accountAPIKey handles the "I have an API key" path. +func accountAPIKey(status *SetupStatus) error { + apiKeyRaw, err := common.PasswordPrompt("API Key") + if err != nil { + return fmt.Errorf("failed to read API key: %w", err) + } + + apiKey := sanitizeAPIKey(apiKeyRaw) + if apiKey == "" { + return fmt.Errorf("API key is required") + } + + region, err := common.Select("Region", []common.SelectOption[string]{ + {Label: "US", Value: "us"}, + {Label: "EU", Value: "eu"}, + }) + if err != nil { + return err + } + + var verifyErr error + _ = common.RunWithSpinner("Verifying API key...", func() error { + verifyErr = verifyAPIKey(apiKey, region) + return verifyErr + }) + if verifyErr != nil { + common.PrintError("Invalid API key: %v", verifyErr) + return verifyErr + } + + if err := dashboard.ActivateAPIKey(apiKey, "", region); err != nil { + return fmt.Errorf("could not activate API key: %w", err) + } + + _, _ = common.Green.Println(" ✓ API key activated") + + // Update status — the API key path skips Steps 2 and 3. + *status = GetSetupStatus() + return nil +} + +// stepApplication handles Step 2: list or create an application. +func stepApplication(status *SetupStatus) error { + _, _ = common.Dim.Printf(" %s\n", divider) + fmt.Println() + _, _ = common.Bold.Printf(" Step 2 of %d: Application\n", stepTotal) + fmt.Println() + + // If user entered an API key directly, app is already resolved. + if status.HasAPIKey && !status.HasDashboardAuth { + _, _ = common.Green.Println(" ✓ Application configured via API key") + return nil + } + + if status.HasActiveApp { + _, _ = common.Green.Printf(" ✓ Active application: %s (%s)\n", status.ActiveAppID, status.ActiveAppRegion) + return nil + } + + appSvc, err := dashboard.CreateAppService() + if err != nil { + return err + } + + orgID, err := dashboard.GetActiveOrgID() + if err != nil { + return err + } + + ctx, cancel := common.CreateContext() + defer cancel() + + var apps []domain.GatewayApplication + err = common.RunWithSpinner("Checking for existing applications...", func() error { + apps, err = appSvc.ListApplications(ctx, orgID, "") + return err + }) + if err != nil { + return err + } + + var selectedApp domain.GatewayApplication + + switch len(apps) { + case 0: + // Create a new application. + app, createErr := createDefaultApp(appSvc, orgID) + if createErr != nil { + return createErr + } + selectedApp = domain.GatewayApplication{ + ApplicationID: app.ApplicationID, + Region: app.Region, + Environment: app.Environment, + Branding: app.Branding, + } + case 1: + selectedApp = apps[0] + name := appDisplayName(selectedApp) + _, _ = common.Green.Printf(" ✓ Found application: %s\n", name) + default: + selected, selectErr := selectApp(apps) + if selectErr != nil { + return selectErr + } + selectedApp = selected + } + + // Set as active app. + if err := setActiveApp(selectedApp.ApplicationID, selectedApp.Region); err != nil { + return err + } + + *status = GetSetupStatus() + return nil +} + +// stepAPIKey handles Step 3: create and activate an API key. +func stepAPIKey(status *SetupStatus) error { + _, _ = common.Dim.Printf(" %s\n", divider) + fmt.Println() + _, _ = common.Bold.Printf(" Step 3 of %d: API Key\n", stepTotal) + fmt.Println() + + if status.HasAPIKey { + _, _ = common.Green.Println(" ✓ API key already configured") + return nil + } + + if !status.HasActiveApp { + return fmt.Errorf("no active application — cannot create API key") + } + + appSvc, err := dashboard.CreateAppService() + if err != nil { + return err + } + + keyName := "CLI-" + time.Now().Format("20060102-150405") + + ctx, cancel := common.CreateContext() + defer cancel() + + var key *domain.GatewayCreatedAPIKey + err = common.RunWithSpinner("Creating API key...", func() error { + key, err = appSvc.CreateAPIKey(ctx, status.ActiveAppID, status.ActiveAppRegion, keyName, 0) + return err + }) + if err != nil { + return err + } + + _, _ = common.Green.Println(" ✓ API key created") + + // Activate the key directly (no 3-option menu in the wizard). + err = common.RunWithSpinner("Activating API key...", func() error { + return dashboard.ActivateAPIKey(key.APIKey, status.ActiveAppID, status.ActiveAppRegion) + }) + if err != nil { + return err + } + + _, _ = common.Green.Println(" ✓ API key activated") + *status = GetSetupStatus() + return nil +} + +// stepGrantSync handles Step 4: sync grants from the Nylas API. +func stepGrantSync(status *SetupStatus) { + _, _ = common.Dim.Printf(" %s\n", divider) + fmt.Println() + _, _ = common.Bold.Printf(" Step 4 of %d: Email Accounts\n", stepTotal) + fmt.Println() + + if status.HasGrants { + _, _ = common.Green.Println(" ✓ Email accounts already synced") + return + } + + if !status.HasAPIKey { + _, _ = common.Yellow.Println(" Skipped — no API key configured") + return + } + + secretStore, err := keyring.NewSecretStore(config.DefaultConfigDir()) + if err != nil { + _, _ = common.Yellow.Printf(" Could not access keyring: %v\n", err) + return + } + + apiKey, _ := secretStore.Get(ports.KeyAPIKey) + clientID, _ := secretStore.Get(ports.KeyClientID) + configStore := config.NewDefaultFileStore() + cfg, _ := configStore.Load() + region := cfg.Region + + grantStore := keyring.NewGrantStore(secretStore) + + var result *SyncResult + err = common.RunWithSpinner("Checking for existing email accounts...", func() error { + result, err = SyncGrants(grantStore, apiKey, clientID, region) + return err + }) + if err != nil { + _, _ = common.Yellow.Printf(" Could not sync grants: %v\n", err) + fmt.Println() + fmt.Println(" To authenticate later:") + fmt.Println(" nylas auth login") + return + } + + if len(result.ValidGrants) == 0 { + _, _ = common.Dim.Println(" No existing email accounts found") + fmt.Println() + fmt.Println(" To authenticate with your email provider:") + fmt.Println(" nylas auth login") + return + } + + // Handle default grant selection. + if result.DefaultGrantID != "" { + // Single grant, auto-set. + fmt.Println() + _, _ = common.Green.Printf(" ✓ Set %s as default account\n", result.ValidGrants[0].Email) + } else if len(result.ValidGrants) > 1 { + // Multiple grants, prompt. + defaultID, _ := PromptDefaultGrant(grantStore, result.ValidGrants) + if defaultID != "" { + result.DefaultGrantID = defaultID + for _, g := range result.ValidGrants { + if g.ID == defaultID { + _, _ = common.Green.Printf(" ✓ Set %s as default account\n", g.Email) + break + } + } + } + } + + // Update config file with grants. + updateConfigGrants(configStore, cfg, result) +} + +// updateConfigGrants writes grant info to the config file. +func updateConfigGrants(configStore *config.FileStore, cfg *domain.Config, result *SyncResult) { + if cfg == nil || result == nil { + return + } + cfg.DefaultGrant = result.DefaultGrantID + cfg.Grants = make([]domain.GrantInfo, len(result.ValidGrants)) + for i, grant := range result.ValidGrants { + cfg.Grants[i] = domain.GrantInfo{ + ID: grant.ID, + Email: grant.Email, + Provider: grant.Provider, + } + } + _ = configStore.Save(cfg) +} diff --git a/internal/cli/setup/wizard_helpers.go b/internal/cli/setup/wizard_helpers.go new file mode 100644 index 0000000..5f5adc9 --- /dev/null +++ b/internal/cli/setup/wizard_helpers.go @@ -0,0 +1,171 @@ +package setup + +import ( + "fmt" + "strings" + + nylasadapter "github.com/nylas/cli/internal/adapters/nylas" + dashboardapp "github.com/nylas/cli/internal/app/dashboard" + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/cli/dashboard" + "github.com/nylas/cli/internal/domain" + "github.com/nylas/cli/internal/ports" +) + +// printComplete prints the final success message. +func printComplete() { + fmt.Println() + _, _ = common.Bold.Println(" ══════════════════════════════════════════") + fmt.Println() + _, _ = common.Green.Println(" ✓ Setup complete! You're ready to go.") + fmt.Println() + fmt.Println(" Try these commands:") + fmt.Println(" nylas email list List recent emails") + fmt.Println(" nylas calendar events Upcoming events") + fmt.Println(" nylas auth status Check configuration") + fmt.Println() + fmt.Println(" Documentation: https://cli.nylas.com/") + fmt.Println() +} + +// printStepRecovery prints manual recovery instructions when a step fails. +func printStepRecovery(step string, commands []string) { + fmt.Println() + _, _ = common.Yellow.Printf(" Could not complete %s setup automatically.\n", step) + fmt.Println(" To continue manually:") + for _, cmd := range commands { + fmt.Printf(" %s\n", cmd) + } + fmt.Println() +} + +// resolveProvider returns the SSO provider from flags, or empty string if not set. +func resolveProvider(opts wizardOpts) string { + switch { + case opts.google: + return "google" + case opts.microsoft: + return "microsoft" + case opts.github: + return "github" + default: + return "" + } +} + +// chooseProvider presents an SSO provider menu. +func chooseProvider() (string, error) { + return common.Select("How would you like to authenticate?", []common.SelectOption[string]{ + {Label: "Google (recommended)", Value: "google"}, + {Label: "Microsoft", Value: "microsoft"}, + {Label: "GitHub", Value: "github"}, + }) +} + +// selectApp prompts the user to select from multiple applications. +func selectApp(apps []domain.GatewayApplication) (domain.GatewayApplication, error) { + opts := make([]common.SelectOption[int], len(apps)) + for i, app := range apps { + opts[i] = common.SelectOption[int]{Label: appDisplayName(app), Value: i} + } + + idx, err := common.Select("Select application", opts) + if err != nil { + return apps[0], nil + } + return apps[idx], nil +} + +// createDefaultApp creates a new application with defaults. +func createDefaultApp(appSvc *dashboardapp.AppService, orgID string) (*domain.GatewayCreatedApplication, error) { + fmt.Println(" No applications found. Creating one for you...") + fmt.Println() + + name, err := common.InputPrompt("App name", "My First App") + if err != nil { + name = "My First App" + } + + region, err := common.Select("Region", []common.SelectOption[string]{ + {Label: "US", Value: "us"}, + {Label: "EU", Value: "eu"}, + }) + if err != nil { + region = "us" + } + + ctx, cancel := common.CreateContext() + defer cancel() + + var app *domain.GatewayCreatedApplication + err = common.RunWithSpinner("Creating application...", func() error { + app, err = appSvc.CreateApplication(ctx, orgID, region, name) + return err + }) + if err != nil { + return nil, err + } + + _, _ = common.Green.Printf(" ✓ Application created: %s (%s)\n", app.ApplicationID, region) + return app, nil +} + +// setActiveApp stores the active application in the keyring. +func setActiveApp(appID, region string) error { + _, secrets, err := dashboard.CreateAuthService() + if err != nil { + return err + } + + if err := secrets.Set(ports.KeyDashboardAppID, appID); err != nil { + return err + } + return secrets.Set(ports.KeyDashboardAppRegion, region) +} + +// appDisplayName returns a human-readable display name for an application. +func appDisplayName(app domain.GatewayApplication) string { + name := "" + if app.Branding != nil { + name = app.Branding.Name + } + env := app.Environment + if env == "" { + env = "production" + } + + displayID := app.ApplicationID + if len(displayID) > 20 { + displayID = displayID[:17] + "..." + } + + if name != "" { + return fmt.Sprintf("%s — %s (%s, %s)", name, displayID, env, app.Region) + } + return fmt.Sprintf("%s (%s, %s)", displayID, env, app.Region) +} + +// verifyAPIKey checks that an API key works by listing applications. +func verifyAPIKey(apiKey, region string) error { + client := nylasadapter.NewHTTPClient() + client.SetRegion(region) + client.SetCredentials("", "", apiKey) + + ctx, cancel := common.CreateContext() + defer cancel() + + _, err := client.ListApplications(ctx) + return err +} + +// sanitizeAPIKey removes invisible characters from a pasted API key. +func sanitizeAPIKey(key string) string { + var result strings.Builder + result.Grow(len(key)) + for _, r := range key { + if r >= ' ' && r <= '~' { + result.WriteRune(r) + } + } + return strings.TrimSpace(result.String()) +} diff --git a/internal/domain/config.go b/internal/domain/config.go index ad44de1..ddf3d32 100644 --- a/internal/domain/config.go +++ b/internal/domain/config.go @@ -62,6 +62,9 @@ type Config struct { // GPG settings GPG *GPGConfig `yaml:"gpg,omitempty"` + + // Dashboard authentication settings + Dashboard *DashboardConfig `yaml:"dashboard,omitempty"` } // APIConfig represents API-specific configuration. diff --git a/internal/domain/dashboard.go b/internal/domain/dashboard.go new file mode 100644 index 0000000..04dc0e8 --- /dev/null +++ b/internal/domain/dashboard.go @@ -0,0 +1,148 @@ +package domain + +// DashboardUser represents an authenticated dashboard user. +type DashboardUser struct { + PublicID string `json:"publicId"` + EmailAddress string `json:"emailAddress,omitempty"` + FirstName string `json:"firstName,omitempty"` + LastName string `json:"lastName,omitempty"` +} + +// DashboardOrganization represents a Nylas organization. +type DashboardOrganization struct { + PublicID string `json:"publicId"` + Name string `json:"name,omitempty"` + Region string `json:"region,omitempty"` + Role string `json:"role,omitempty"` +} + +// DashboardRegisterResponse is the response from a successful registration. +type DashboardRegisterResponse struct { + VerificationChannel string `json:"verificationChannel"` + ExpiresAt string `json:"expiresAt"` +} + +// DashboardAuthResponse is the response from a successful login or verification. +type DashboardAuthResponse struct { + UserToken string `json:"userToken"` + OrgToken string `json:"orgToken"` + User DashboardUser `json:"user"` + Organizations []DashboardOrganization `json:"organizations"` +} + +// DashboardMFARequired is returned when MFA is needed after login. +type DashboardMFARequired struct { + User DashboardUser `json:"user"` + Organizations []DashboardOrganization `json:"organizations"` + TOTPFactor *DashboardTOTPFactor `json:"totpFactor"` +} + +// DashboardTOTPFactor contains the TOTP factor details for MFA. +type DashboardTOTPFactor struct { + FactorSID string `json:"factorSid"` + Binding string `json:"binding,omitempty"` +} + +// DashboardRefreshResponse is the response from a session refresh. +type DashboardRefreshResponse struct { + UserToken string `json:"userToken"` + OrgToken string `json:"orgToken,omitempty"` +} + +// DashboardSSOStartResponse is the response from starting an SSO device flow. +type DashboardSSOStartResponse struct { + FlowID string `json:"flowId"` + VerificationURI string `json:"verificationUri"` + VerificationURIComplete string `json:"verificationUriComplete,omitempty"` + UserCode string `json:"userCode"` + ExpiresIn int `json:"expiresIn"` + Interval int `json:"interval"` +} + +// DashboardSSOPollResponse represents the poll result for an SSO device flow. +type DashboardSSOPollResponse struct { + Status string `json:"status"` + RetryAfter int `json:"retryAfter,omitempty"` + + // Populated when Status == "complete" + Auth *DashboardAuthResponse `json:"-"` + + // Populated when Status == "mfa_required" + MFA *DashboardMFARequired `json:"-"` +} + +// SSO poll status constants. +const ( + SSOStatusPending = "authorization_pending" + SSOStatusAccessDenied = "access_denied" + SSOStatusExpired = "expired_token" + SSOStatusComplete = "complete" + SSOStatusMFARequired = "mfa_required" +) + +// SSO login type constants matching the server schema. +const ( + SSOLoginTypeGoogle = "google_SSO" + SSOLoginTypeMicrosoft = "microsoft_SSO" + SSOLoginTypeGitHub = "github_SSO" +) + +// GatewayApplication is an application as returned by the dashboard API gateway. +type GatewayApplication struct { + ApplicationID string `json:"applicationId"` + OrganizationID string `json:"organizationId"` + Region string `json:"region"` + Environment string `json:"environment"` + Branding *GatewayApplicationBrand `json:"branding,omitempty"` +} + +// GatewayCreatedApplication includes the client secret shown once on creation. +type GatewayCreatedApplication struct { + ApplicationID string `json:"applicationId"` + ClientSecret string `json:"clientSecret"` + OrganizationID string `json:"organizationId"` + Region string `json:"region"` + Environment string `json:"environment"` + Branding *GatewayApplicationBrand `json:"branding,omitempty"` +} + +// GatewayApplicationBrand holds application branding info. +type GatewayApplicationBrand struct { + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` +} + +// GatewayAPIKey represents an API key as returned by the dashboard API gateway. +type GatewayAPIKey struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Permissions []string `json:"permissions"` + ExpiresAt float64 `json:"expiresAt"` + CreatedAt float64 `json:"createdAt"` +} + +// GatewayCreatedAPIKey includes the actual key value (shown once on creation). +type GatewayCreatedAPIKey struct { + ID string `json:"id"` + Name string `json:"name"` + APIKey string `json:"apiKey"` + Status string `json:"status"` + Permissions []string `json:"permissions"` + ExpiresAt float64 `json:"expiresAt"` + CreatedAt float64 `json:"createdAt"` +} + +// DashboardConfig holds dashboard authentication settings. +type DashboardConfig struct { + AccountBaseURL string `yaml:"account_base_url,omitempty"` +} + +// DefaultDashboardAccountBaseURL is the global dashboard-account endpoint. +const DefaultDashboardAccountBaseURL = "https://dashboard-account.eu.nylas.com" + +// Dashboard API gateway URLs by region. +const ( + GatewayBaseURLUS = "https://dashboard-api-gateway.us.nylas.com/graphql" + GatewayBaseURLEU = "https://dashboard-api-gateway.eu.nylas.com/graphql" +) diff --git a/internal/domain/errors.go b/internal/domain/errors.go index 2f1060a..3c14ec6 100644 --- a/internal/domain/errors.go +++ b/internal/domain/errors.go @@ -56,6 +56,14 @@ var ( ErrConnectorNotFound = errors.New("connector not found") ErrCredentialNotFound = errors.New("credential not found") + // Dashboard auth errors + ErrDashboardNotLoggedIn = errors.New("not logged in to Nylas Dashboard") + ErrDashboardSessionExpired = errors.New("dashboard session expired") + ErrDashboardLoginFailed = errors.New("dashboard login failed") + ErrDashboardMFARequired = errors.New("MFA required") + ErrDashboardSSOFailed = errors.New("SSO authentication failed") + ErrDashboardDPoP = errors.New("DPoP proof generation failed") + // Scheduler errors ErrBookingNotFound = errors.New("booking not found") ErrSessionNotFound = errors.New("session not found") diff --git a/internal/ports/dashboard.go b/internal/ports/dashboard.go new file mode 100644 index 0000000..c126e0f --- /dev/null +++ b/internal/ports/dashboard.go @@ -0,0 +1,63 @@ +package ports + +import ( + "context" + + "github.com/nylas/cli/internal/domain" +) + +// DashboardAccountClient defines the interface for dashboard-account CLI auth endpoints. +type DashboardAccountClient interface { + // Register creates a new dashboard account and triggers email verification. + Register(ctx context.Context, email, password string, privacyPolicyAccepted bool) (*domain.DashboardRegisterResponse, error) + + // VerifyEmailCode verifies the email verification code after registration. + VerifyEmailCode(ctx context.Context, email, code, region string) (*domain.DashboardAuthResponse, error) + + // ResendVerificationCode resends the email verification code. + ResendVerificationCode(ctx context.Context, email string) error + + // Login authenticates with email and password. + // Returns auth response on success, or MFA info if MFA is required. + Login(ctx context.Context, email, password, orgPublicID string) (*domain.DashboardAuthResponse, *domain.DashboardMFARequired, error) + + // LoginMFA completes MFA authentication with a TOTP code. + LoginMFA(ctx context.Context, userPublicID, code, orgPublicID string) (*domain.DashboardAuthResponse, error) + + // Refresh refreshes the session tokens. + Refresh(ctx context.Context, userToken, orgToken string) (*domain.DashboardRefreshResponse, error) + + // Logout invalidates the session tokens. + Logout(ctx context.Context, userToken, orgToken string) error + + // SSOStart initiates an SSO device authorization flow. + SSOStart(ctx context.Context, loginType, mode string, privacyPolicyAccepted bool) (*domain.DashboardSSOStartResponse, error) + + // SSOPoll polls the SSO device flow for completion. + SSOPoll(ctx context.Context, flowID, orgPublicID string) (*domain.DashboardSSOPollResponse, error) +} + +// DashboardGatewayClient defines the interface for dashboard API gateway GraphQL operations. +type DashboardGatewayClient interface { + // ListApplications retrieves applications from the dashboard API gateway. + ListApplications(ctx context.Context, orgPublicID, region, userToken, orgToken string) ([]domain.GatewayApplication, error) + + // CreateApplication creates a new application via the dashboard API gateway. + CreateApplication(ctx context.Context, orgPublicID, region, name, userToken, orgToken string) (*domain.GatewayCreatedApplication, error) + + // ListAPIKeys retrieves API keys for an application. + ListAPIKeys(ctx context.Context, appID, region, userToken, orgToken string) ([]domain.GatewayAPIKey, error) + + // CreateAPIKey creates a new API key for an application. + CreateAPIKey(ctx context.Context, appID, region, name string, expiresInDays int, userToken, orgToken string) (*domain.GatewayCreatedAPIKey, error) +} + +// DPoP defines the interface for DPoP proof generation. +type DPoP interface { + // GenerateProof creates a DPoP proof JWT for the given HTTP method and URL. + // If accessToken is non-empty, the proof includes an ath (access token hash) claim. + GenerateProof(method, url string, accessToken string) (string, error) + + // Thumbprint returns the JWK thumbprint (RFC 7638) of the DPoP public key. + Thumbprint() string +} diff --git a/internal/ports/secrets.go b/internal/ports/secrets.go index f473a8d..dee81ff 100644 --- a/internal/ports/secrets.go +++ b/internal/ports/secrets.go @@ -25,6 +25,15 @@ const ( KeyClientSecret = "client_secret" KeyAPIKey = "api_key" KeyOrgID = "org_id" + + // Dashboard auth keys + KeyDashboardUserToken = "dashboard_user_token" + KeyDashboardOrgToken = "dashboard_org_token" + KeyDashboardUserPublicID = "dashboard_user_public_id" + KeyDashboardOrgPublicID = "dashboard_org_public_id" + KeyDashboardDPoPKey = "dashboard_dpop_key" + KeyDashboardAppID = "dashboard_app_id" + KeyDashboardAppRegion = "dashboard_app_region" ) // GrantTokenKey returns the keystore key for a grant's access token.