mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-21 06:09:07 +02:00
add onboarding
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user