Files
netbird/client/ui/frontend/src/lib/connection.ts
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

134 lines
4.0 KiB
TypeScript

import { Events } from "@wailsio/runtime";
import { Connection, WindowManager } from "@bindings/services";
import i18next from "@/lib/i18n";
import { errorDialog, formatErrorMessage } from "@/lib/errors.ts";
export const EVENT_BROWSER_LOGIN_CANCEL = "browser-login:cancel";
export const EVENT_TRIGGER_LOGIN = "trigger-login";
let connectionInFlight = false;
type SsoState = {
cancelled: boolean;
offCancel?: () => void;
offSignal?: () => void;
};
async function openBrowserLoginUri(uri: string): Promise<void> {
try {
await WindowManager.OpenBrowserLogin(uri);
} catch (e) {
console.error(e);
}
}
function buildSsoCancelPromise(state: SsoState, signal?: AbortSignal): Promise<void> {
return new Promise<void>((resolve) => {
state.offCancel = Events.On(EVENT_BROWSER_LOGIN_CANCEL, () => {
state.cancelled = true;
resolve();
});
if (!signal) return;
const onAbort = () => {
state.cancelled = true;
resolve();
};
if (signal.aborted) {
onAbort();
return;
}
signal.addEventListener("abort", onAbort);
state.offSignal = () => signal.removeEventListener("abort", onAbort);
});
}
async function runSsoLogin(
result: {
verificationUri: string;
verificationUriComplete: string;
userCode: string;
profileId: string;
},
state: SsoState,
signal?: AbortSignal,
): Promise<void> {
const uri = result.verificationUriComplete || result.verificationUri;
if (uri) await openBrowserLoginUri(uri);
const cancelPromise = buildSsoCancelPromise(state, signal);
// Combine wait + up in Go so the connection comes up the moment SSO
// completes. During SSO the tray window is hidden and the webview is
// suspended, so a frontend-driven Up (a promise continuation) would not
// fire until the user woke the window (e.g. hovering the tray icon).
const waitPromise = Connection.WaitSSOLoginAndUp(
{ userCode: result.userCode, hostname: "", profileId: result.profileId },
{ profileName: "", username: "" },
);
try {
await Promise.race([waitPromise, cancelPromise]);
} finally {
WindowManager.CloseBrowserLogin().catch(console.error);
}
if (state.cancelled) {
waitPromise.cancel?.();
waitPromise.catch(() => {});
}
}
export async function startConnection(onSettled?: () => void, signal?: AbortSignal): Promise<void> {
if (connectionInFlight || signal?.aborted) {
onSettled?.();
return;
}
connectionInFlight = true;
const state: SsoState = { cancelled: false };
let connectError: unknown;
try {
const result = await Connection.Login({
profileName: "",
username: "",
managementUrl: "",
setupKey: "",
preSharedKey: "",
hostname: "",
hint: "",
});
if (signal?.aborted) state.cancelled = true;
if (!state.cancelled && result.needsSsoLogin) {
// runSsoLogin brings the connection up in Go once SSO completes.
await runSsoLogin(result, state, signal);
} else {
if (!state.cancelled && signal?.aborted) state.cancelled = true;
if (!state.cancelled) {
await Connection.Up({ profileName: "", username: "" });
}
}
} catch (e) {
WindowManager.CloseBrowserLogin().catch(console.error);
if (!state.cancelled) connectError = e;
} finally {
state.offCancel?.();
state.offSignal?.();
connectionInFlight = false;
onSettled?.();
}
if (connectError !== undefined) {
await errorDialog({
Title: i18next.t("connect.error.loginTitle"),
Message: formatErrorMessage(connectError),
});
return;
}
if (state.cancelled && signal) {
throw new DOMException("aborted", "AbortError");
}
}