Files
netbird/client/ui/services/profile.go
Zoltan Papp 2f721ec0d5 [client] Keep the account email backing the SSO login hint correct (#6986)
## Describe your changes

Three fixes to how the desktop GUI keeps the account email that backs
the SSO `login_hint`. Each is independent and reviewable on its own.

**1. Store the email after a GUI SSO login**

The daemon returns the authenticated user's email from `WaitSSOLogin`
but cannot persist it: it runs as root while the per-profile state file
is user-owned. The CLI's `handleSSOLogin` writes it after its own
`WaitSSOLogin`; the GUI path read the value and dropped it.

The profile was therefore left with no email, so `Profiles.List` showed
no account for it, and later logins and session extends went out with no
`login_hint` — leaving the IdP to pick an account instead of reusing the
one the profile belongs to. Mirror the CLI and store it, next to the
`Logout` path that already clears the same file for the same reason.

**2. File the email against the profile the login ran for**

`SetActiveProfileState` resolves the target itself, so it writes to
whichever profile is active when it is called. A GUI SSO login spans
seconds of user interaction in the browser, and the tray stays clickable
throughout: switching profiles in that window left the email filed under
the profile that happened to be active when the flow returned. The wrong
profile then advertised an account it does not own, and offered it as
the `login_hint` next time.

Adds `SetProfileState(id, state)`, the write-side counterpart of the
existing `GetProfileState(id)`, and keeps `SetActiveProfileState` as a
wrapper for callers with no particular profile in mind. `Login` now
reports the profile it resolved so the frontend can hand it back with
the SSO wait, which closes the window.

**3. Delete the email when a profile is removed**

Removing a profile left its state file behind: the daemon deletes what
it owns, but the email file is user-owned and out of reach for a root
daemon — the same split that already puts the `Logout` cleanup on the UI
side.

Beyond the stray file, legacy profiles are keyed by name rather than by
a generated ID, so recreating a profile under a removed one's name
inherited its email — shown as the account in the profile list and sent
as the `login_hint` on the next login.

## Issue ticket number and link

## Stack

<!-- branch-stack -->

### Checklist
- [x] Is it a bug fix
- [ ] Is a typo/documentation fix
- [ ] Is a feature enhancement
- [ ] It is a refactor
- [ ] Created tests that fail without the change (if possible)
- [ ] This change does **not** modify the public API, gRPC protocols,
functionality behavior, CLI / service flags, or introduce a new feature
— **OR** I have discussed it with the NetBird team beforehand (link the
issue / Slack thread in the description). See
[CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first).

> By submitting this pull request, you confirm that you have read and
agree to the terms of the [Contributor License
Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md).

## Documentation
Select exactly one:

- [ ] I added/updated documentation for this change
- [x] Documentation is **not needed** for this change (explain why)

### Docs PR URL (required if "docs added" is checked)
Paste the PR link from https://github.com/netbirdio/docs here:

https://github.com/netbirdio/docs/pull/__


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- SSO login details can now be saved to the specific profile selected
during sign-in.
  - Profile state can be managed independently for different profiles.

- **Bug Fixes**
  - Removing a profile now also cleans up its associated saved state.
- Cleanup issues no longer prevent successful profile removal and are
handled gracefully.
  - Existing active-profile behavior remains unchanged.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-03 14:21:40 +02:00

202 lines
6.0 KiB
Go

//go:build !android && !ios && !freebsd && !js
package services
import (
"context"
"os/user"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/proto"
)
type Profile struct {
// ID is the daemon-generated on-disk identity of the profile. Display
// names can collide and be renamed, so the ID is the stable handle the
// daemon resolves switch/remove/logout requests against.
ID string `json:"id"`
Name string `json:"name"`
IsActive bool `json:"isActive"`
// Email is read from the user-owned per-profile state file (CLI writes it
// after SSO login), not via ListProfiles: the daemon runs as root and can't
// reach it, while the UI runs as the logged-in user.
Email string `json:"email"`
}
type ProfileRef struct {
ProfileName string `json:"profileName"`
Username string `json:"username"`
}
type ActiveProfile struct {
// ID is the active profile's stable on-disk identity. Use it (not the
// display name) as the handle for daemon requests and active-profile
// comparisons, since names can collide.
ID string `json:"id"`
ProfileName string `json:"profileName"`
Username string `json:"username"`
}
// RenameProfileParams selects a profile by handle and carries its new display
// name.
type RenameProfileParams struct {
// Handle selects the profile to rename: an exact ID, a unique ID prefix,
// or a unique display name. The daemon resolves it server-side.
Handle string `json:"handle"`
// NewName is the new free-form display name. The daemon sanitizes it
// (strips control characters, trims, caps length) but keeps spaces, emoji,
// punctuation, and non-ASCII letters.
NewName string `json:"newName"`
Username string `json:"username"`
}
type Profiles struct {
conn DaemonConn
}
func NewProfiles(conn DaemonConn) *Profiles {
return &Profiles{conn: conn}
}
// Username returns the OS username the daemon expects for profile lookups.
func (s *Profiles) Username() (string, error) {
u, err := user.Current()
if err != nil {
return "", err
}
return u.Username, nil
}
func (s *Profiles) List(ctx context.Context, username string) ([]Profile, error) {
cli, err := s.conn.Client()
if err != nil {
return nil, err
}
resp, err := cli.ListProfiles(ctx, &proto.ListProfilesRequest{Username: username})
if err != nil {
return nil, err
}
pm := profilemanager.NewProfileManager()
out := make([]Profile, 0, len(resp.GetProfiles()))
for _, p := range resp.GetProfiles() {
prof := Profile{ID: p.GetId(), Name: p.GetName(), IsActive: p.GetIsActive()}
if state, err := pm.GetProfileState(profilemanager.ID(p.GetId())); err == nil {
prof.Email = state.Email
}
out = append(out, prof)
}
return out, nil
}
func (s *Profiles) GetActive(ctx context.Context) (ActiveProfile, error) {
cli, err := s.conn.Client()
if err != nil {
return ActiveProfile{}, err
}
resp, err := cli.GetActiveProfile(ctx, &proto.GetActiveProfileRequest{})
if err != nil {
return ActiveProfile{}, err
}
return ActiveProfile{
ID: resp.GetId(),
ProfileName: resp.GetProfileName(),
Username: resp.GetUsername(),
}, nil
}
// Switch sends a profile switch to the daemon and returns the resolved
// on-disk ID of the now-active profile. ProfileName is treated as a handle
// (exact ID, unique ID prefix, or unique display name); the daemon resolves
// it server-side and echoes back the canonical ID.
func (s *Profiles) Switch(ctx context.Context, p ProfileRef) (string, error) {
cli, err := s.conn.Client()
if err != nil {
return "", err
}
req := &proto.SwitchProfileRequest{}
if p.ProfileName != "" {
req.ProfileName = ptrStr(p.ProfileName)
}
if p.Username != "" {
req.Username = ptrStr(p.Username)
}
resp, err := cli.SwitchProfile(ctx, req)
if err != nil {
return "", err
}
return resp.GetId(), nil
}
// Add creates a profile with the given display name and returns its
// daemon-generated on-disk ID, so callers can address the new profile by ID
// (e.g. to write config or switch to it) without re-resolving the name.
func (s *Profiles) Add(ctx context.Context, p ProfileRef) (string, error) {
cli, err := s.conn.Client()
if err != nil {
return "", err
}
resp, err := cli.AddProfile(ctx, &proto.AddProfileRequest{
ProfileName: p.ProfileName,
Username: p.Username,
})
if err != nil {
return "", err
}
return resp.GetId(), nil
}
func (s *Profiles) Remove(ctx context.Context, p ProfileRef) error {
cli, err := s.conn.Client()
if err != nil {
return err
}
resp, err := cli.RemoveProfile(ctx, &proto.RemoveProfileRequest{
ProfileName: p.ProfileName,
Username: p.Username,
})
if err != nil {
return err
}
// The daemon deletes what it owns but runs as root, so it leaves the
// user-owned state file holding the account email behind (same split as
// Connection.Logout). Legacy profiles are keyed by name rather than by a
// generated ID, so a recreated profile of the same name would inherit the
// deleted one's email and offer it as the login_hint.
//
// Keyed on the ID the daemon resolved, not on the request handle: that may
// have been a display name or an ID prefix, which would name a different
// file (or none).
if id := resp.GetId(); id != "" {
if err := profilemanager.NewProfileManager().RemoveProfileState(id); err != nil {
// Non-fatal: the profile itself is gone.
log.Warnf("failed to remove profile state for %s: %v", id, err)
}
}
return nil
}
// Rename changes a profile's display name. The on-disk ID is unaffected, so
// the active profile and any ID-based references stay valid (the default
// profile can be renamed too — only its display name changes). Returns the
// profile's previous display name as confirmation.
func (s *Profiles) Rename(ctx context.Context, p RenameProfileParams) (string, error) {
cli, err := s.conn.Client()
if err != nil {
return "", err
}
resp, err := cli.RenameProfile(ctx, &proto.RenameProfileRequest{
Username: p.Username,
Handle: p.Handle,
NewProfileName: p.NewName,
})
if err != nil {
return "", err
}
return resp.GetOldProfileName(), nil
}