Skip to content
29 changes: 24 additions & 5 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -757,24 +757,37 @@ func (m *Manager) GetOAuthType() string {
return creds.OAuthType
}

// GetUserID returns the stored user ID for the current credential key.
func (m *Manager) GetUserID() string {
// GetUserEmail returns the stored user email for the current credential key.
func (m *Manager) GetUserEmail() string {
credKey := m.credentialKey()
creds, err := m.store.Load(credKey)
if err != nil {
return ""
}
return creds.UserID
return creds.UserEmail
}

// SetUserID stores the user ID for the current credential key.
func (m *Manager) SetUserID(userID string) error {
// SetUserEmail stores the user email for the current credential key
// without modifying the stored user ID.
func (m *Manager) SetUserEmail(email string) error {
credKey := m.credentialKey()
creds, err := m.store.Load(credKey)
if err != nil {
return err
}
creds.UserEmail = email
return m.store.Save(credKey, creds)
}

// SetUserIdentity stores the user ID and email for the current credential key.
func (m *Manager) SetUserIdentity(userID, email string) error {
credKey := m.credentialKey()
creds, err := m.store.Load(credKey)
if err != nil {
return err
}
creds.UserID = userID
creds.UserEmail = email
return m.store.Save(credKey, creds)
}

Expand All @@ -788,3 +801,9 @@ func (m *Manager) CredentialKey() string {
func (m *Manager) GetStore() *Store {
return m.store
}

// SetStore replaces the credential store. Used in tests to inject
// a file-backed store rooted in a temp directory.
func (m *Manager) SetStore(s *Store) {
m.store = s
}
34 changes: 21 additions & 13 deletions internal/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ func TestIsAuthenticatedWithStoredCreds(t *testing.T) {
assert.True(t, manager.IsAuthenticated(), "Should be authenticated with stored credentials")
}

func TestGetUserID(t *testing.T) {
func TestSetUserEmail(t *testing.T) {
tmpDir := t.TempDir()

cfg := &config.Config{
Expand All @@ -240,19 +240,26 @@ func TestGetUserID(t *testing.T) {
manager := NewManager(cfg, http.DefaultClient)
manager.store = newTestStore(t, tmpDir)

// Save credentials with user ID
// Save initial credentials with a user ID
creds := &Credentials{
AccessToken: "test-token",
ExpiresAt: time.Now().Unix() + 3600,
UserID: "12345",
UserID: "original-id",
}
manager.store.Save("https://3.basecampapi.com", creds)
require.NoError(t, manager.store.Save("https://3.basecampapi.com", creds))

// Set email only
err := manager.SetUserEmail("test@example.com")
require.NoError(t, err)

userID := manager.GetUserID()
assert.Equal(t, "12345", userID)
// Verify email was saved and UserID was not modified
loaded, err := manager.store.Load("https://3.basecampapi.com")
require.NoError(t, err)
assert.Equal(t, "test@example.com", loaded.UserEmail)
assert.Equal(t, "original-id", loaded.UserID)
}

func TestSetUserID(t *testing.T) {
func TestSetUserIdentity(t *testing.T) {
tmpDir := t.TempDir()

cfg := &config.Config{
Expand All @@ -266,16 +273,17 @@ func TestSetUserID(t *testing.T) {
AccessToken: "test-token",
ExpiresAt: time.Now().Unix() + 3600,
}
manager.store.Save("https://3.basecampapi.com", creds)
require.NoError(t, manager.store.Save("https://3.basecampapi.com", creds))

// Set user ID
err := manager.SetUserID("67890")
require.NoError(t, err, "SetUserID failed")
// Set user identity
err := manager.SetUserIdentity("67890", "test@example.com")
require.NoError(t, err)

// Verify it was saved
// Verify both were saved
loaded, err := manager.store.Load("https://3.basecampapi.com")
require.NoError(t, err, "Load failed")
require.NoError(t, err)
assert.Equal(t, "67890", loaded.UserID)
assert.Equal(t, "test@example.com", loaded.UserEmail)
}

func TestLogout(t *testing.T) {
Expand Down
1 change: 1 addition & 0 deletions internal/auth/keyring.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type Credentials struct {
OAuthType string `json:"oauth_type"` // "bc3" or "launchpad"
TokenEndpoint string `json:"token_endpoint"`
UserID string `json:"user_id,omitempty"`
UserEmail string `json:"user_email,omitempty"`
}

// Store wraps credstore.Store with typed Credentials marshaling.
Expand Down
7 changes: 4 additions & 3 deletions internal/commands/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -270,11 +270,12 @@ func buildLoginCmd(use string) *cobra.Command {
resp, err := app.SDK.Get(cmd.Context(), "/my/profile.json")
if err == nil {
var profile struct {
ID int `json:"id"`
Name string `json:"name"`
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email_address"`
}
if err := resp.UnmarshalData(&profile); err == nil {
if err := app.Auth.SetUserID(fmt.Sprintf("%d", profile.ID)); err == nil {
if err := app.Auth.SetUserIdentity(fmt.Sprintf("%d", profile.ID), profile.Email); err == nil {
fmt.Fprintln(w, r.Data.Render(fmt.Sprintf("Logged in as: %s", profile.Name)))
}
}
Expand Down
6 changes: 4 additions & 2 deletions internal/commands/people.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,10 @@ func runMe(cmd *cobra.Command, args []string) error {
return convertSDKError(err)
}

// Store user ID for "me" resolution in future commands (non-fatal if fails)
_ = app.Auth.SetUserID(fmt.Sprintf("%d", authInfo.Identity.ID))
// Store user email for "me" resolution in future commands (non-fatal if fails).
// Note: authInfo.Identity.ID is a cross-account identity ID, not an account-scoped
// person ID, so we only store the email here.
_ = app.Auth.SetUserEmail(authInfo.Identity.EmailAddress)

// Build account output (already filtered to bc3 by SDK)
var accounts []AccountInfo
Expand Down
7 changes: 4 additions & 3 deletions internal/commands/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -300,11 +300,12 @@ Examples:
resp, err := app.SDK.Get(cmd.Context(), "/my/profile.json")
if err == nil {
var profile struct {
ID int `json:"id"`
Name string `json:"name"`
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email_address"`
}
if err := resp.UnmarshalData(&profile); err == nil {
_ = app.Auth.SetUserID(fmt.Sprintf("%d", profile.ID))
_ = app.Auth.SetUserIdentity(fmt.Sprintf("%d", profile.ID), profile.Email)
}
}

Expand Down
8 changes: 4 additions & 4 deletions internal/commands/timeline.go
Original file line number Diff line number Diff line change
Expand Up @@ -413,11 +413,11 @@ func runTimelineWatch(cmd *cobra.Command, args []string, project, person string,

if len(args) > 0 && args[0] == "me" {
// Personal timeline
userID := app.Auth.GetUserID()
if userID == "" {
return output.ErrAuth("User ID not available")
resolvedID, _, err := app.Names.ResolvePerson(ctx, "me")
if err != nil {
return err
}
personID, err := strconv.ParseInt(userID, 10, 64)
personID, err := strconv.ParseInt(resolvedID, 10, 64)
if err != nil {
return output.ErrUsage("Invalid user ID")
}
Expand Down
7 changes: 4 additions & 3 deletions internal/commands/wizard.go
Original file line number Diff line number Diff line change
Expand Up @@ -330,11 +330,12 @@ func wizardAuth(cmd *cobra.Command, app *appctx.App, styles *tui.Styles) error {
resp, err := app.SDK.Get(cmd.Context(), "/my/profile.json")
if err == nil {
var profile struct {
ID int `json:"id"`
Name string `json:"name"`
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email_address"`
}
if err := resp.UnmarshalData(&profile); err == nil {
_ = app.Auth.SetUserID(fmt.Sprintf("%d", profile.ID))
_ = app.Auth.SetUserIdentity(fmt.Sprintf("%d", profile.ID), profile.Email)
fmt.Println(styles.Success.Render(fmt.Sprintf(" Logged in as %s.", profile.Name)))
}
} else {
Expand Down
56 changes: 33 additions & 23 deletions internal/names/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,22 +139,24 @@ func (r *Resolver) ResolveProject(ctx context.Context, input string) (string, st
// Special case: "me" resolves to the current user.
// Returns the ID and the person's name for display.
func (r *Resolver) ResolvePerson(ctx context.Context, input string) (string, string, error) {
// Handle "me" keyword
// Handle "me" keyword — resolve via stored email against account people list.
// The stored user ID is a cross-account identity ID which doesn't match
// account-scoped person IDs, so we match by email instead.
if strings.ToLower(input) == "me" {
userID := r.auth.GetUserID()
if userID != "" {
// Try to get the name from people list
people, err := r.getPeople(ctx)
if err == nil {
for _, p := range people {
if strconv.FormatInt(p.ID, 10) == userID {
return userID, p.Name, nil
}
}
email := r.auth.GetUserEmail()
if email == "" {
return "", "", output.ErrAuth("Could not resolve your identity. Run: basecamp auth login")
}
people, err := r.getPeople(ctx)
if err != nil {
return "", "", err
}
for _, p := range people {
if strings.EqualFold(p.Email, email) {
return strconv.FormatInt(p.ID, 10), p.Name, nil
}
return userID, "me", nil
}
return "", "", output.ErrAuth("User ID not available. Run: basecamp auth login")
return "", "", output.ErrAuth(fmt.Sprintf("Your email (%s) was not found in this account. Check your account selection or run: basecamp auth login", email))
}

// Numeric ID passthrough
Expand Down Expand Up @@ -284,15 +286,19 @@ func (r *Resolver) getProjects(ctx context.Context) ([]Project, error) {
return r.projects, nil
}

// Fetch from API
resp, err := r.forAccount().Get(ctx, "/projects.json")
// Fetch all pages from API
pages, err := r.forAccount().GetAll(ctx, "/projects.json")
if err != nil {
return nil, convertSDKError(err)
}

var projects []Project
if err := json.Unmarshal(resp.Data, &projects); err != nil {
return nil, err
projects := make([]Project, 0, len(pages))
for _, page := range pages {
var p Project
if err := json.Unmarshal(page, &p); err != nil {
return nil, err
}
projects = append(projects, p)
}

r.projects = projects
Expand All @@ -315,15 +321,19 @@ func (r *Resolver) getPeople(ctx context.Context) ([]Person, error) {
return r.people, nil
}

// Fetch from API
resp, err := r.forAccount().Get(ctx, "/people.json")
// Fetch all pages from API
pages, err := r.forAccount().GetAll(ctx, "/people.json")
if err != nil {
return nil, convertSDKError(err)
}

var people []Person
if err := json.Unmarshal(resp.Data, &people); err != nil {
return nil, err
people := make([]Person, 0, len(pages))
for _, page := range pages {
var p Person
if err := json.Unmarshal(page, &p); err != nil {
return nil, err
}
people = append(people, p)
}

r.people = people
Expand Down
Loading
Loading