add onboarding

This commit is contained in:
Eduard Gert
2026-06-03 17:33:13 +02:00
parent 1710868a09
commit 64199209cf
23 changed files with 731 additions and 92 deletions
@@ -12,37 +12,36 @@ import { formatErrorMessage } from "@/lib/errors.ts";
import { CopyToClipboard } from "@/components/CopyToClipboard";
import netbirdFullLogo from "@/assets/logos/netbird-full.svg";
enum ConnectionState {
Disconnected = "disconnected",
Connecting = "connecting",
Connected = "connected",
Disconnecting = "disconnecting",
}
// NeedsLogin / SessionExpired / DaemonUnavailable never reach this map —
// connState collapses them into Connecting or Disconnected upstream.
const STATUS_KEY: Record<ConnectionState, string> = {
[ConnectionState.Disconnected]: "connect.status.disconnected",
[ConnectionState.Connecting]: "connect.status.connecting",
[ConnectionState.Connected]: "connect.status.connected",
[ConnectionState.Disconnecting]: "connect.status.disconnecting",
};
// EVENT_BROWSER_LOGIN_CANCEL is emitted by the BrowserLogin window's close
// button (Go side) and by the in-dialog Cancel button. startLogin uses it
// to break the WaitSSOLogin race so the daemon doesn't hang on a stale
// device code.
const EVENT_BROWSER_LOGIN_CANCEL = "browser-login:cancel";
// EVENT_TRIGGER_LOGIN lets any window ask the main window's connect-toggle
// to drive a login flow. Mirrors services.EventTriggerLogin on the Go side.
// The tray emits it from menu items so the React UI (which owns the SSO
// orchestration and the browser-login window) takes over.
const EVENT_TRIGGER_LOGIN = "trigger-login";
const NEEDS_LOGIN_STATES = new Set(["NeedsLogin", "SessionExpired", "LoginFailed"]);
// Re-enable the switch after this long in a transitioning state so the user
// can force a Connection.Down on a stuck Connecting/Disconnecting flow.
const FORCE_TOGGLE_DELAY_MS = 7000;
const errorMessage = formatErrorMessage;
// startLogin drives the daemon's SSO login end-to-end. The BrowserLogin
// popup window is the only login UI; errors surface as a native
// Dialogs.Error. Concurrent calls are dropped via the inFlight guard.
// loginInFlight is a module-level guard. SSO login involves multiple async
// hops (Login → BrowserLogin window → WaitSSOLogin → Up); a second concurrent
// call would race on the daemon's pending device code and on the popup
// window's singleton, leading to confusing UX. Calls past the first are
// dropped silently — the first invocation owns the flow until it settles.
let loginInFlight = false;
// startLogin drives the daemon's SSO login end-to-end:
// 1. Connection.Login — daemon returns a verification URI if SSO is needed.
// 2. WindowManager.OpenBrowserLogin — show the in-app sign-in popup.
// 3. Race WaitSSOLogin vs the user clicking Cancel.
// 4. On success: Connection.Up.
// 5. On cancel: cancel the in-flight WaitSSOLogin gRPC so the daemon
// drops the abandoned device code (avoids an Idle blink on the tray).
//
// Errors that aren't user cancellations surface via errorDialog. Concurrent
// calls are dropped via loginInFlight. The BrowserLogin window is closed in
// all exit paths so a stray popup doesn't outlive the flow.
async function startLogin(): Promise<void> {
if (loginInFlight) return;
loginInFlight = true;
@@ -64,10 +63,6 @@ async function startLogin(): Promise<void> {
if (result.needsSsoLogin) {
const uri = result.verificationUriComplete || result.verificationUri;
if (uri) {
// Open the in-app sign-in popup first; the dialog itself
// fires Connection.OpenURL after it's actually on screen
// (see WaitingForBrowserDialog) so the system browser
// doesn't land on top of a still-hidden NetBird window.
try {
await WindowManager.OpenBrowserLogin(uri);
} catch (e) {
@@ -94,12 +89,6 @@ async function startLogin(): Promise<void> {
}
if (cancelled) {
// Cancel the in-flight WaitSSOLogin gRPC instead of a heavy
// Down. The daemon ties the wait to this call's context
// (server.go WaitSSOLogin), so cancelling ends the wait and
// clears the abandoned OAuth flow — a fresh Login then starts
// a new device code, with no Idle blink on the tray. Swallow
// the cancellation rejection on the abandoned promise.
waitPromise.cancel?.();
void waitPromise.catch(() => {});
return;
@@ -112,7 +101,7 @@ async function startLogin(): Promise<void> {
if (cancelled) return;
await errorDialog({
Title: i18next.t("connect.error.loginTitle"),
Message: errorMessage(e),
Message: formatErrorMessage(e),
});
} finally {
offCancel?.();
@@ -120,6 +109,30 @@ async function startLogin(): Promise<void> {
}
}
enum ConnectionState {
Disconnected = "disconnected",
Connecting = "connecting",
Connected = "connected",
Disconnecting = "disconnecting",
}
// NeedsLogin / SessionExpired / DaemonUnavailable never reach this map —
// connState collapses them into Connecting or Disconnected upstream.
const STATUS_KEY: Record<ConnectionState, string> = {
[ConnectionState.Disconnected]: "connect.status.disconnected",
[ConnectionState.Connecting]: "connect.status.connecting",
[ConnectionState.Connected]: "connect.status.connected",
[ConnectionState.Disconnecting]: "connect.status.disconnecting",
};
const NEEDS_LOGIN_STATES = new Set(["NeedsLogin", "SessionExpired", "LoginFailed"]);
// Re-enable the switch after this long in a transitioning state so the user
// can force a Connection.Down on a stuck Connecting/Disconnecting flow.
const FORCE_TOGGLE_DELAY_MS = 7000;
const errorMessage = formatErrorMessage;
export const MainConnectionStatusSwitch = () => {
const { t } = useTranslation();
const { status, refresh } = useStatus();
@@ -0,0 +1,194 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Preferences,
Profiles as ProfilesSvc,
Settings as SettingsSvc,
WindowManager,
} from "@bindings/services";
import { SetConfigParams } from "@bindings/services/models.js";
import { ConfirmDialog } from "@/components/dialog/ConfirmDialog";
import { useAutoSizeWindow } from "@/hooks/useAutoSizeWindow";
import { errorDialog } from "@/lib/dialogs";
import { formatErrorMessage } from "@/lib/errors";
import i18next from "@/lib/i18n";
import { isCloudManagementUrl } from "@/hooks/useManagementUrl";
import { WelcomeStepTray } from "./WelcomeStepTray";
import { WelcomeStepManagement } from "./WelcomeStepManagement";
const WINDOW_WIDTH = 360;
// WelcomeStep is the orchestrator's state machine. The transitions:
// tray → management (if eligible) → finish
// tray → finish (otherwise)
// Login itself is no longer part of onboarding — once the welcome window
// closes the user lands in the main window and clicks Connect there.
type WelcomeStep = "tray" | "management";
// shouldShowManagementStep asks the user about Cloud vs self-hosted only
// on a pristine setup — default profile, no email recorded (no successful
// login yet), and the management URL is either unset or already the cloud
// default. Any other state means the user (or a previous run) already
// made a deliberate choice and we shouldn't second-guess it.
function shouldShowManagementStep(
activeProfile: string,
email: string,
managementUrl: string,
): boolean {
if (activeProfile !== "default") return false;
if (email.trim() !== "") return false;
return isCloudManagementUrl(managementUrl);
}
// initial flow snapshot resolved at mount. Held in component state so the
// step-2 management input can hydrate from initialUrl, and so the
// "should we even show step 2" check is computed once (the user can't
// change profile / URL from inside the welcome window).
type InitialState = {
profileName: string;
username: string;
managementUrl: string;
needsManagementStep: boolean;
};
export default function WelcomeDialog() {
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH);
const [step, setStep] = useState<WelcomeStep>("tray");
const [initial, setInitial] = useState<InitialState | null>(null);
const [closing, setClosing] = useState(false);
// Probe daemon state on mount: who's the active profile, do they
// have an email recorded, and what management URL is configured?
// Errors fall through to "skip the management step" so a daemon
// hiccup never blocks onboarding entirely.
useEffect(() => {
let cancelled = false;
(async () => {
try {
// Resolve username + active profile first so GetConfig + List
// can target the actual profile (passing empty strings would
// work today since the daemon falls back to the default
// profile, but being explicit shields us from future
// changes to that fallback).
const [username, active] = await Promise.all([
ProfilesSvc.Username(),
ProfilesSvc.GetActive(),
]);
const profileName = active.profileName || "default";
const [config, list] = await Promise.all([
SettingsSvc.GetConfig({ profileName, username }),
ProfilesSvc.List(username),
]);
const profile = list.find((p) => p.name === profileName);
const email = profile?.email ?? "";
if (cancelled) return;
setInitial({
profileName,
username,
managementUrl: config.managementUrl,
needsManagementStep: shouldShowManagementStep(
profileName,
email,
config.managementUrl,
),
});
} catch (e) {
console.error("welcome: initial probe failed", e);
if (cancelled) return;
// Conservative fallback: skip the management step rather
// than block onboarding behind a daemon hiccup.
setInitial({
profileName: "default",
username: "",
managementUrl: "",
needsManagementStep: false,
});
}
})();
return () => {
cancelled = true;
};
}, []);
// finish persists the onboarding flag, opens the main window so the
// user has somewhere to land, and closes the welcome window. Called
// at the end of every successful flow (tray-only and tray→management
// alike). The Connect button in the main window picks up from here.
const finish = useCallback(async () => {
if (closing) return;
setClosing(true);
try {
await Preferences.SetOnboardingCompleted(true);
} catch (e) {
console.error("persist onboarding flag:", e);
}
try {
await WindowManager.OpenMain();
} catch (e) {
console.error("open main window:", e);
}
try {
await WindowManager.CloseWelcome();
} catch (e) {
console.error("close welcome window:", e);
}
}, [closing]);
const handleTrayContinue = useCallback(async () => {
if (initial?.needsManagementStep) {
setStep("management");
} else {
await finish();
}
}, [initial, finish]);
const handleManagementContinue = useCallback(
async (url: string) => {
if (!initial) return;
try {
// SetConfig is a partial update — pointer fields left
// undefined are preserved (services/settings.go). We only
// touch managementUrl; adminUrl stays empty here because
// the daemon already has its own value loaded.
await SettingsSvc.SetConfig(
new SetConfigParams({
profileName: initial.profileName,
username: initial.username,
managementUrl: url,
}),
);
} catch (e) {
await errorDialog({
Title: i18next.t("settings.error.saveTitle"),
Message: formatErrorMessage(e),
});
throw e;
}
setInitial((s) => (s ? { ...s, managementUrl: url } : s));
await finish();
},
[initial, finish],
);
const content = useMemo(() => {
if (!initial) {
// Probe in flight — render an empty container so the dialog
// window measures something tiny instead of flashing the
// tray step before we know whether step 2 applies. The probe
// completes within a single tick on a healthy daemon.
return <div className={"h-32"} />;
}
switch (step) {
case "tray":
return <WelcomeStepTray onContinue={handleTrayContinue} />;
case "management":
return (
<WelcomeStepManagement
initialUrl={initial.managementUrl}
onContinue={handleManagementContinue}
/>
);
}
}, [initial, step, handleTrayContinue, handleManagementContinue]);
return <ConfirmDialog ref={contentRef}>{content}</ConfirmDialog>;
}
@@ -0,0 +1,138 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/buttons/Button";
import { DialogActions } from "@/components/dialog/DialogActions";
import { DialogDescription } from "@/components/dialog/DialogDescription";
import { DialogHeading } from "@/components/dialog/DialogHeading";
import { Input } from "@/components/inputs/Input";
import { ManagementServerSwitch } from "@/components/ManagementServerSwitch";
import {
CLOUD_MANAGEMENT_URL,
ManagementMode,
checkManagementUrlReachable,
isCloudManagementUrl,
isValidManagementUrl,
normalizeManagementUrl,
} from "@/hooks/useManagementUrl";
import { cn } from "@/lib/cn.ts";
import { isMacOS } from "@/lib/platform.ts";
type WelcomeStepManagementProps = {
// initialUrl is the management URL the daemon is already configured
// with (empty / cloud-default both render as Cloud selected).
initialUrl: string;
// onContinue is invoked with the URL the user wants to persist. The
// parent owns the actual Settings.SetConfig call so the dialog stays
// free of context dependencies.
onContinue: (url: string) => Promise<void>;
};
export function WelcomeStepManagement({ initialUrl, onContinue }: WelcomeStepManagementProps) {
const { t } = useTranslation();
const startsCloud = isCloudManagementUrl(initialUrl);
const [mode, setMode] = useState<ManagementMode>(
startsCloud ? ManagementMode.Cloud : ManagementMode.SelfHosted,
);
const [url, setUrl] = useState(startsCloud ? "" : initialUrl);
const [syntaxError, setSyntaxError] = useState<string | null>(null);
// unreachable: soft warning. Continue stays enabled — user can confirm
// they typed it right and proceed (matches self-hosted-behind-internal-
// DNS / VPN scenarios where the in-app fetch would false-negative).
const [unreachable, setUnreachable] = useState(false);
const [checking, setChecking] = useState(false);
const trimmedUrl = url.trim();
const syntaxValid = mode === ManagementMode.Cloud || isValidManagementUrl(trimmedUrl);
// Continue is no longer disabled for an empty / invalid self-hosted
// URL; a Continue click in that state focuses the input and renders
// an inline error so the user actively notices what's missing.
const inputRef = useRef<HTMLInputElement | null>(null);
// Reset inline error/warning whenever the user edits the URL or flips
// mode — otherwise the warning lingers next to a just-corrected value.
useEffect(() => {
setSyntaxError(null);
setUnreachable(false);
}, [url, mode]);
const handleContinue = useCallback(async () => {
if (checking) return;
if (mode === ManagementMode.SelfHosted && (!trimmedUrl || !syntaxValid)) {
// Empty or syntactically invalid URL — Continue stays enabled
// so the click registers; surface the error inline and focus
// the input so the user has somewhere to fix it.
setSyntaxError(t("welcome.management.urlInvalid"));
inputRef.current?.focus();
return;
}
const target =
mode === ManagementMode.Cloud
? CLOUD_MANAGEMENT_URL
: normalizeManagementUrl(trimmedUrl);
if (mode === ManagementMode.SelfHosted) {
setChecking(true);
const reachable = await checkManagementUrlReachable(target);
setChecking(false);
// First failed check: show soft warning + bail. A second click
// with the same URL skips the check (unreachable still true)
// so the user can proceed if they're sure.
if (!reachable && !unreachable) {
setUnreachable(true);
return;
}
}
try {
await onContinue(target);
} catch (e) {
// Parent surfaces save errors via errorDialog; keep a console
// breadcrumb but don't double-render.
console.error("save management url:", e);
}
}, [checking, mode, syntaxValid, trimmedUrl, unreachable, onContinue, t]);
const inputError = useMemo(() => {
if (syntaxError) return syntaxError;
if (unreachable) return t("welcome.management.urlUnreachable");
return undefined;
}, [syntaxError, unreachable, t]);
return (
<>
<div className={cn("flex flex-col items-center gap-1", isMacOS() && "mt-4")}>
<DialogHeading align={"left"}>{t("welcome.management.title")}</DialogHeading>
<DialogDescription align={"left"}>
{t("welcome.management.description")}
</DialogDescription>
</div>
<div className={"wails-no-draggable w-full"}>
<ManagementServerSwitch value={mode} onChange={setMode} fullWidth />
</div>
{mode === ManagementMode.SelfHosted && (
<div className={"wails-no-draggable w-full text-left"}>
<Input
ref={inputRef}
placeholder={t("welcome.management.urlPlaceholder")}
value={url}
onChange={(e) => setUrl(e.target.value)}
error={inputError}
autoFocus
/>
</div>
)}
<DialogActions>
<Button
variant={"primary"}
size={"md"}
className={"w-full"}
onClick={handleContinue}
disabled={checking}
>
{checking ? t("welcome.management.checking") : t("welcome.continue")}
</Button>
</DialogActions>
</>
);
}
@@ -0,0 +1,61 @@
import { useTranslation } from "react-i18next";
import { Button } from "@/components/buttons/Button";
import { DialogActions } from "@/components/dialog/DialogActions";
import { DialogDescription } from "@/components/dialog/DialogDescription";
import { DialogHeading } from "@/components/dialog/DialogHeading";
import { isMacOS, isWindows } from "@/lib/platform";
import trayScreenshotDarwin from "@/assets/img/tray-darwin.png";
import trayScreenshotWindows from "@/assets/img/tray-windows.png";
import trayScreenshotLinux from "@/assets/img/tray-linux.png";
// trayScreenshotForOS picks the marketing screenshot that shows the
// NetBird tray icon in its native menu/task bar — so the onboarding pitch
// matches the chrome the user will actually be hunting for. Evaluated
// inside the component so initPlatform() has finished by the time
// isMacOS/isWindows run (the static imports above only load the bytes,
// no platform check).
function trayScreenshotForOS(): string {
if (isMacOS()) return trayScreenshotDarwin;
if (isWindows()) return trayScreenshotWindows;
return trayScreenshotLinux;
}
type WelcomeStepTrayProps = {
onContinue: () => void;
};
export function WelcomeStepTray({ onContinue }: WelcomeStepTrayProps) {
const { t } = useTranslation();
const trayScreenshot = trayScreenshotForOS();
return (
<>
<div className={"px-1.5"}>
<img
src={trayScreenshot}
alt={""}
className={"w-full h-auto select-none pointer-events-none rounded-2xl"}
draggable={false}
/>
</div>
<div className={"flex flex-col w-full gap-1"}>
<DialogHeading align={"left"}>{t("welcome.title")}</DialogHeading>
<DialogDescription align={"left"}>{t("welcome.description")}</DialogDescription>
</div>
<DialogActions>
<Button
autoFocus
variant={"primary"}
size={"md"}
tabIndex={0}
className={"w-full"}
onClick={onContinue}
>
{t("welcome.continue")}
</Button>
</DialogActions>
</>
);
}