diff --git a/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/models.ts b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/models.ts index d91da7d75..d561338bf 100644 --- a/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/models.ts +++ b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/models.ts @@ -755,6 +755,18 @@ export class Profile { "name": string; "isActive": boolean; + /** + * Email is the account address associated with this profile, sourced from + * the per-profile state file written by the CLI after a successful SSO + * login (e.g. ~/Library/Application Support/netbird/default.state.json on + * macOS). The daemon always runs as root, so its getConfigDir() resolves to + * the root home directory and cannot reach the user-owned state file. The + * UI process runs as the logged-in user and can read it directly via + * profilemanager.ProfileManager, which is why the email is fetched here + * instead of being returned by the ListProfiles RPC. + */ + "email": string; + /** Creates a new Profile instance. */ constructor($$source: Partial = {}) { if (!("name" in $$source)) { @@ -763,6 +775,9 @@ export class Profile { if (!("isActive" in $$source)) { this["isActive"] = false; } + if (!("email" in $$source)) { + this["email"] = ""; + } Object.assign(this, $$source); } diff --git a/client/ui/services/profile.go b/client/ui/services/profile.go index 7efcf46bc..8700df606 100644 --- a/client/ui/services/profile.go +++ b/client/ui/services/profile.go @@ -6,6 +6,7 @@ import ( "context" "os/user" + "github.com/netbirdio/netbird/client/internal/profilemanager" "github.com/netbirdio/netbird/client/proto" ) @@ -13,6 +14,15 @@ import ( type Profile struct { Name string `json:"name"` IsActive bool `json:"isActive"` + // Email is the account address associated with this profile, sourced from + // the per-profile state file written by the CLI after a successful SSO + // login (e.g. ~/Library/Application Support/netbird/default.state.json on + // macOS). The daemon always runs as root, so its getConfigDir() resolves to + // the root home directory and cannot reach the user-owned state file. The + // UI process runs as the logged-in user and can read it directly via + // profilemanager.ProfileManager, which is why the email is fetched here + // instead of being returned by the ListProfiles RPC. + Email string `json:"email"` } // ProfileRef identifies a profile by name+username. @@ -55,9 +65,14 @@ func (s *Profiles) List(ctx context.Context, username string) ([]Profile, error) if err != nil { return nil, err } + pm := profilemanager.NewProfileManager() out := make([]Profile, 0, len(resp.GetProfiles())) for _, p := range resp.GetProfiles() { - out = append(out, Profile{Name: p.GetName(), IsActive: p.GetIsActive()}) + prof := Profile{Name: p.GetName(), IsActive: p.GetIsActive()} + if state, err := pm.GetProfileState(p.GetName()); err == nil { + prof.Email = state.Email + } + out = append(out, prof) } return out, nil } diff --git a/client/ui/tray.go b/client/ui/tray.go index d68d00d03..3d10c8519 100644 --- a/client/ui/tray.go +++ b/client/ui/tray.go @@ -128,7 +128,9 @@ type Tray struct { downItem *application.MenuItem exitNodeItem *application.MenuItem networksItem *application.MenuItem - profileSubmenu *application.Menu + profileSubmenu *application.Menu + profileSubmenuItem *application.MenuItem + profileEmailItem *application.MenuItem settingsItem *application.MenuItem debugItem *application.MenuItem updateItem *application.MenuItem @@ -220,6 +222,16 @@ func (t *Tray) buildMenu() *application.Menu { // has started — Menu.Update() is a no-op before app.running is true, // so the initial fill is gated on the ApplicationStarted hook. t.profileSubmenu = menu.AddSubmenu(menuProfiles) + // profileSubmenuItem is the parent MenuItem whose label is the active + // profile name. AddSubmenu returns the child *Menu, so we retrieve the + // parent *MenuItem via FindByLabel immediately after insertion. + t.profileSubmenuItem = menu.FindByLabel(menuProfiles) + // profileEmailItem shows the account email of the active profile directly + // in the main menu, below the Profiles submenu — matching the behaviour of + // the legacy Fyne/systray UI. It is hidden until loadProfiles resolves a + // non-empty email for the active profile. + t.profileEmailItem = menu.Add("").SetEnabled(false) + t.profileEmailItem.SetHidden(true) menu.AddSeparator() // Only the action that applies to the current state is visible: Connect // when disconnected, Disconnect when connected. applyStatus swaps them on @@ -736,6 +748,7 @@ func (t *Tray) loadProfiles() { log.Infof("tray loadProfiles: received %d profile(s) for user %q", len(profiles), username) t.profileSubmenu.Clear() + var activeName, activeEmail string for _, p := range profiles { name := p.Name active := p.IsActive @@ -748,6 +761,21 @@ func (t *Tray) loadProfiles() { } t.switchProfile(name) }) + if active { + activeName = name + activeEmail = p.Email + } + } + if t.profileSubmenuItem != nil && activeName != "" { + t.profileSubmenuItem.SetLabel(activeName) + } + if t.profileEmailItem != nil { + if activeEmail != "" { + t.profileEmailItem.SetLabel(fmt.Sprintf("(%s)", activeEmail)) + t.profileEmailItem.SetHidden(false) + } else { + t.profileEmailItem.SetHidden(true) + } } // Wails v3 alpha's submenu.Update() builds a fresh, detached NSMenu on // darwin that never replaces the empty NSMenu attached to the parent