diff --git a/client/ui/frontend/src/components/CopyToClipboard.tsx b/client/ui/frontend/src/components/CopyToClipboard.tsx index 9a4508310..30169cf33 100644 --- a/client/ui/frontend/src/components/CopyToClipboard.tsx +++ b/client/ui/frontend/src/components/CopyToClipboard.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, type ReactNode } from "react"; +import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Check, Copy } from "lucide-react"; import { cn } from "@/lib/cn"; @@ -21,6 +21,7 @@ type CopyToClipboardProps = { variant?: CopyToClipboardVariant; "aria-label"?: string; tabIndex?: number; + onKeyDown?: (e: KeyboardEvent) => void; }; export const CopyToClipboard = ({ @@ -34,6 +35,7 @@ export const CopyToClipboard = ({ variant = "default", "aria-label": ariaLabel, tabIndex = 0, + onKeyDown, }: CopyToClipboardProps) => { const { t } = useTranslation(); const wrapperRef = useRef(null); @@ -69,6 +71,7 @@ export const CopyToClipboard = ({ type="button" ref={wrapperRef} onClick={handleClick} + onKeyDown={onKeyDown} tabIndex={tabIndex} aria-label={resolvedLabel} aria-live="polite" diff --git a/client/ui/frontend/src/components/dialog/ConfirmDialog.tsx b/client/ui/frontend/src/components/dialog/ConfirmDialog.tsx index 0d4be8865..aa61e6645 100644 --- a/client/ui/frontend/src/components/dialog/ConfirmDialog.tsx +++ b/client/ui/frontend/src/components/dialog/ConfirmDialog.tsx @@ -13,12 +13,13 @@ export const ConfirmDialog = forwardRef(func ref, ) { return ( -
(func > {children}
-
+ ); }); diff --git a/client/ui/frontend/src/components/switches/FancyToggleSwitch.tsx b/client/ui/frontend/src/components/switches/FancyToggleSwitch.tsx index 682a5abb8..ef87873ed 100644 --- a/client/ui/frontend/src/components/switches/FancyToggleSwitch.tsx +++ b/client/ui/frontend/src/components/switches/FancyToggleSwitch.tsx @@ -33,8 +33,6 @@ export default function FancyToggleSwitch({ }: Readonly) { const switchId = React.useId(); const descriptionId = React.useId(); - const childrenRef = React.useRef(null); - const switchRef = React.useRef(null); if (loading) { const shimmer = @@ -68,21 +66,8 @@ export default function FancyToggleSwitch({ ); } - const fromChildren = (target: EventTarget | null) => - target instanceof Node && childrenRef.current?.contains(target); - - const handleClick = (event: React.MouseEvent) => { - if (disabled || fromChildren(event.target)) return; - const target = event.target as HTMLElement; - // Let the switch own its own click so focus + state stay together. - if (target.closest("button,input,a,[role=switch]")) return; - switchRef.current?.click(); - switchRef.current?.focus(); - }; - return (
-
- {children && value ? ( -
- {children} -
- ) : null} + {children && value ?
{children}
: null}
); } diff --git a/client/ui/frontend/src/contexts/PeerDetailContext.tsx b/client/ui/frontend/src/contexts/PeerDetailContext.tsx index f2a0de0a3..3ab20891f 100644 --- a/client/ui/frontend/src/contexts/PeerDetailContext.tsx +++ b/client/ui/frontend/src/contexts/PeerDetailContext.tsx @@ -25,26 +25,26 @@ export const usePeerDetail = (): PeerDetailContextValue => { }; export const PeerDetailProvider = ({ children }: { children: ReactNode }) => { - const [selected, setSelectedState] = useState(null); + const [selected, setSelected] = useState(null); const openerRef = useRef(null); - const setSelected = useCallback((p: PeerStatus | null) => { + const select = useCallback((p: PeerStatus | null) => { if (p) { const active = document.activeElement; openerRef.current = active instanceof HTMLElement ? active : null; } else { const opener = openerRef.current; openerRef.current = null; - if (opener && opener.isConnected) { + if (opener?.isConnected) { queueMicrotask(() => opener.focus()); } } - setSelectedState(p); + setSelected(p); }, []); const value = useMemo( - () => ({ selected, setSelected }), - [selected, setSelected], + () => ({ selected, setSelected: select }), + [selected, select], ); return {children}; }; diff --git a/client/ui/frontend/src/lib/connection.ts b/client/ui/frontend/src/lib/connection.ts index 0ef313c18..b9e98bf24 100644 --- a/client/ui/frontend/src/lib/connection.ts +++ b/client/ui/frontend/src/lib/connection.ts @@ -8,20 +8,71 @@ export const EVENT_TRIGGER_LOGIN = "trigger-login"; let connectionInFlight = false; -export async function startConnection(onSettled?: () => void, signal?: AbortSignal): Promise { - if (connectionInFlight) { - onSettled?.(); - return; +type SsoState = { + cancelled: boolean; + offCancel?: () => void; + offSignal?: () => void; +}; + +async function openBrowserLoginUri(uri: string): Promise { + try { + await WindowManager.OpenBrowserLogin(uri); + } catch (e) { + console.error(e); } - if (signal?.aborted) { +} + +function buildSsoCancelPromise(state: SsoState, signal?: AbortSignal): Promise { + return new Promise((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 }, + state: SsoState, + signal?: AbortSignal, +): Promise { + const uri = result.verificationUriComplete || result.verificationUri; + if (uri) await openBrowserLoginUri(uri); + + const cancelPromise = buildSsoCancelPromise(state, signal); + const waitPromise = Connection.WaitSSOLogin({ userCode: result.userCode, hostname: "" }); + + 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 { + if (connectionInFlight || signal?.aborted) { onSettled?.(); return; } connectionInFlight = true; - let cancelled = false; - let offCancel: (() => void) | undefined; - let offSignal: (() => void) | undefined; + const state: SsoState = { cancelled: false }; let connectError: unknown; try { @@ -35,65 +86,23 @@ export async function startConnection(onSettled?: () => void, signal?: AbortSign hint: "", }); - if (signal?.aborted) cancelled = true; + if (signal?.aborted) state.cancelled = true; - if (!cancelled && result.needsSsoLogin) { - const uri = result.verificationUriComplete || result.verificationUri; - if (uri) { - try { - await WindowManager.OpenBrowserLogin(uri); - } catch (e) { - console.error(e); - } - } - - const cancelPromise = new Promise((resolve) => { - offCancel = Events.On(EVENT_BROWSER_LOGIN_CANCEL, () => { - cancelled = true; - resolve(); - }); - if (signal) { - const onAbort = () => { - cancelled = true; - resolve(); - }; - if (signal.aborted) { - onAbort(); - } else { - signal.addEventListener("abort", onAbort); - offSignal = () => signal.removeEventListener("abort", onAbort); - } - } - }); - - const waitPromise = Connection.WaitSSOLogin({ - userCode: result.userCode, - hostname: "", - }); - - try { - await Promise.race([waitPromise, cancelPromise]); - } finally { - WindowManager.CloseBrowserLogin().catch(console.error); - } - - if (cancelled) { - waitPromise.cancel?.(); - waitPromise.catch(() => {}); - } + if (!state.cancelled && result.needsSsoLogin) { + await runSsoLogin(result, state, signal); } - if (!cancelled && signal?.aborted) cancelled = true; + if (!state.cancelled && signal?.aborted) state.cancelled = true; - if (!cancelled) { + if (!state.cancelled) { await Connection.Up({ profileName: "", username: "" }); } } catch (e) { WindowManager.CloseBrowserLogin().catch(console.error); - if (!cancelled) connectError = e; + if (!state.cancelled) connectError = e; } finally { - offCancel?.(); - offSignal?.(); + state.offCancel?.(); + state.offSignal?.(); connectionInFlight = false; onSettled?.(); } @@ -106,7 +115,7 @@ export async function startConnection(onSettled?: () => void, signal?: AbortSign return; } - if (cancelled && signal) { + if (state.cancelled && signal) { throw new DOMException("aborted", "AbortError"); } } diff --git a/client/ui/frontend/src/lib/logs.ts b/client/ui/frontend/src/lib/logs.ts index 6dd8aec42..190c10a99 100644 --- a/client/ui/frontend/src/lib/logs.ts +++ b/client/ui/frontend/src/lib/logs.ts @@ -21,18 +21,32 @@ let inForward = false; let windowStart = 0; let windowCount = 0; +function describeCause(rawCause: unknown): string { + if (rawCause instanceof Error) return `${rawCause.name}: ${rawCause.message}`; + if (typeof rawCause === "object" && rawCause !== null) { + try { + return JSON.stringify(rawCause); + } catch { + // Circular ref — fall through to a tag instead of "[object Object]". + return `<${rawCause.constructor?.name ?? "object"}>`; + } + } + return String(rawCause); +} + +function formatCause(rawCause: unknown): string { + if (rawCause === undefined) return ""; + return `\ncaused by ${describeCause(rawCause)}`; +} + // WebKit (macOS WKWebView) omits the "Name: message" header from Error.stack, // so a bare stack hides the real cause. Prepend name+message, then the stack. function formatError(e: Error): string { const head = `${e.name}: ${e.message}`; - const rawCause = (e as { cause?: unknown }).cause; - const cause = - rawCause instanceof Error - ? `\ncaused by ${rawCause.name}: ${rawCause.message}` - : rawCause !== undefined - ? `\ncaused by ${String(rawCause)}` - : ""; - return e.stack && !e.stack.startsWith(head) ? `${head}${cause}\n${e.stack}` : `${head}${cause}`; + const cause = formatCause((e as { cause?: unknown }).cause); + if (!e.stack) return `${head}${cause}`; + if (e.stack.startsWith(head)) return `${head}${cause}`; + return `${head}${cause}\n${e.stack}`; } function format(args: unknown[]): string { @@ -49,13 +63,34 @@ function format(args: unknown[]): string { .join(" "); } +function parseStackLine(line: string): string { + // Find the file:line:col tail at the end of the path. + const colonCol = line.lastIndexOf(":"); + if (colonCol <= 0) return ""; + const colonLine = line.lastIndexOf(":", colonCol - 1); + if (colonLine <= 0) return ""; + const col = line.slice(colonCol + 1); + const lineNo = line.slice(colonLine + 1, colonCol); + if (!/^\d+$/.test(col) || !/^\d+$/.test(lineNo)) return ""; + const before = line.slice(0, colonLine); + const sep = Math.max( + before.lastIndexOf("/"), + before.lastIndexOf("\\"), + before.lastIndexOf("("), + before.lastIndexOf(" "), + ); + const file = before.slice(sep + 1); + if (!file.includes(".")) return ""; + return `${file}:${lineNo}`; +} + function callerSource(): string { const stack = new Error().stack; if (!stack) return ""; for (const line of stack.split("\n").slice(1)) { if (line.includes("/logs.ts")) continue; - const m = /([^/\\() ]+\.[a-z]+):(\d+):\d+/i.exec(line); - if (m) return `${m[1]}:${m[2]}`; + const parsed = parseStackLine(line); + if (parsed) return parsed; } return ""; } diff --git a/client/ui/frontend/src/lib/welcome.ts b/client/ui/frontend/src/lib/welcome.ts index 6d46c6b64..e7c4d466a 100644 --- a/client/ui/frontend/src/lib/welcome.ts +++ b/client/ui/frontend/src/lib/welcome.ts @@ -11,7 +11,7 @@ export function welcome() { NetBird — The Only Secure Access Platform You'll Ever Need. WEBSITE: https://netbird.io/ -WE'RE HIRING: https://careers.netbird.io/ +WE'RE HIRING: https://netbird.io/careers OPEN SOURCE: https://github.com/netbirdio/netbird `; diff --git a/client/ui/frontend/src/modules/main/advanced/Navigation.tsx b/client/ui/frontend/src/modules/main/advanced/Navigation.tsx index a77cb030c..920af8ce4 100644 --- a/client/ui/frontend/src/modules/main/advanced/Navigation.tsx +++ b/client/ui/frontend/src/modules/main/advanced/Navigation.tsx @@ -1,11 +1,10 @@ -import { ComponentType, KeyboardEvent, useRef } from "react"; +import { ComponentType, KeyboardEvent, useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import { Layers3Icon, LucideProps, MonitorSmartphoneIcon } from "lucide-react"; import { cn } from "@/lib/cn"; import { useNavSection, type NavSection } from "@/contexts/NavSectionContext"; import { useStatus } from "@/contexts/StatusContext"; import { useRestrictions } from "@/contexts/RestrictionsContext"; -import { useEffect } from "react"; type TabEntry = { value: NavSection; diff --git a/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx b/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx index ef5c1aeb9..ff7b668a0 100644 --- a/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx +++ b/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx @@ -5,6 +5,7 @@ import { useRef, useState, type ComponentType, + type ReactNode, } from "react"; import { useTranslation } from "react-i18next"; import * as ScrollArea from "@radix-ui/react-scroll-area"; @@ -242,7 +243,6 @@ type NetworksListProps = { const NetworksHeader = () =>
; const NetworksList = ({ data, onToggle, scrollParent }: NetworksListProps) => { - const { t } = useTranslation(); const virtuosoRef = useRef(null); const rowRefs = useRef>(new Map()); @@ -265,7 +265,7 @@ const NetworksList = ({ data, onToggle, scrollParent }: NetworksListProps) => { } }; - const handleRowKeyDown = (e: KeyboardEvent, index: number) => { + const handleRowKeyDown = (e: KeyboardEvent, index: number) => { switch (e.key) { case "ArrowDown": e.preventDefault(); @@ -286,69 +286,106 @@ const NetworksList = ({ data, onToggle, scrollParent }: NetworksListProps) => { } }; + const setRowRef = (id: string, el: HTMLButtonElement | null) => { + if (el) rowRefs.current.set(id, el); + else rowRefs.current.delete(id); + }; + + const ctx = useMemo( + () => ({ onKeyDown: handleRowKeyDown, onToggle, setRowRef }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [data, onToggle], + ); + return ( - ref={virtuosoRef} data={data} customScrollParent={scrollParent} increaseViewportBy={400} computeItemKey={(_, n) => n.id} components={{ Header: NetworksHeader }} - itemContent={(index, n) => ( -
handleRowKeyDown(e, index)} - className={cn( - "group relative flex items-start gap-2.5 pl-6 pr-9 py-3 min-w-0", - "hover:bg-nb-gray-900/40 transition-colors", - "wails-no-draggable", - )} - > -
- )} + context={ctx} + itemContent={renderNetworkRow} /> ); }; +type NetworkRowContext = { + onKeyDown: (e: KeyboardEvent, index: number) => void; + onToggle: (id: string, selected: boolean) => void; + setRowRef: (id: string, el: HTMLButtonElement | null) => void; +}; + +const renderNetworkRow = (index: number, n: Network, ctx: NetworkRowContext): ReactNode => ( + +); + +type NetworkRowProps = { + network: Network; + index: number; + onKeyDown: (e: KeyboardEvent, index: number) => void; + onToggle: (id: string, selected: boolean) => void; + setRowRef: (id: string, el: HTMLButtonElement | null) => void; +}; + +const NetworkRow = ({ network: n, index, onKeyDown, onToggle, setRowRef }: NetworkRowProps) => { + const { t } = useTranslation(); + // Same handler is attached to the overlay button and to the network-id copy + // button so arrow nav works wherever focus sits inside the row. + const handleKey = (e: KeyboardEvent) => onKeyDown(e, index); + return ( +
+
+ ); +}; + const ResourceIconBadge = ({ type }: { type: ResourceType }) => { const Icon = resourceIconFor(type); return ( @@ -364,17 +401,22 @@ const ResourceIconBadge = ({ type }: { type: ResourceType }) => { ); }; -const Subtitle = ({ network }: { network: Network }) => { +type SubtitleProps = { + network: Network; + onKeyDown: (e: KeyboardEvent) => void; +}; + +const Subtitle = ({ network, onKeyDown }: SubtitleProps) => { if (isDnsRoute(network)) { const domain = network.domains[0]; const ips = network.resolvedIps[domain] ?? []; - return ; + return ; } if (network.range && network.range !== INVALID_PREFIX) { return (
- + { return null; }; -const DomainSubtitle = ({ domain, ips }: { domain: string; ips: string[] }) => { +type DomainSubtitleProps = { + domain: string; + ips: string[]; + onKeyDown: (e: KeyboardEvent) => void; +}; + +const DomainSubtitle = ({ domain, ips, onKeyDown }: DomainSubtitleProps) => { const span = ( {domain} @@ -397,7 +445,7 @@ const DomainSubtitle = ({ domain, ips }: { domain: string; ips: string[] }) => { ); return (
- + {ips.length > 0 ? ( } diff --git a/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx b/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx index 8b7a14b0c..a8619fe3e 100644 --- a/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx +++ b/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx @@ -195,8 +195,7 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
-
+ + -
- + + ); }; diff --git a/client/ui/frontend/src/modules/settings/SettingsAbout.tsx b/client/ui/frontend/src/modules/settings/SettingsAbout.tsx index d97e0c602..9bc548078 100644 --- a/client/ui/frontend/src/modules/settings/SettingsAbout.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsAbout.tsx @@ -92,9 +92,12 @@ export function SettingsAbout() { > {t("common.netbird")}
-

{daemonVersion === "development" ? ( @@ -106,7 +109,7 @@ export function SettingsAbout() { ) : ( t("settings.about.client", { version: daemonVersion }) )} -

+

{guiVersion === "development" ? ( diff --git a/client/ui/frontend/src/modules/settings/SettingsSection.tsx b/client/ui/frontend/src/modules/settings/SettingsSection.tsx index 7b05864d8..f84df2f83 100644 --- a/client/ui/frontend/src/modules/settings/SettingsSection.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsSection.tsx @@ -12,7 +12,6 @@ export const SectionGroup = ({ }) => (