Merge remote-tracking branch 'origin/main' into fix_debug_upload_url_from_mgmt

# Conflicts:
#	client/proto/daemon.pb.go
#	management/internals/controllers/network_map/controller/controller.go
#	management/internals/controllers/network_map/nmaptest/runner.go
#	management/internals/shared/grpc/server.go
#	management/server/account.go
#	shared/management/proto/management.pb.go
#	upload-server/server/server.go
This commit is contained in:
riccardom
2026-10-09 11:13:57 +02:00
518 changed files with 30626 additions and 12765 deletions
+1 -16
View File
@@ -10,7 +10,7 @@ Every method returns `$CancellablePromise<T>` (a Wails3 wrapper around `Promise`
// Services
import {
Connection, Peers, ProfileSwitcher, Profiles,
Settings, Networks, Forwarding, Debug, Update, WindowManager,
Settings, Networks, Debug, Update, WindowManager,
I18n, Preferences,
} from "@bindings/services";
@@ -20,7 +20,6 @@ import type {
Profile, ProfileRef, ActiveProfile,
Config, ConfigParams, SetConfigParams, Features,
Network, SelectNetworksParams,
ForwardingRule, PortInfo, PortRange,
LoginParams, LoginResult, LogoutParams, WaitSSOParams, UpParams,
DebugBundleParams, DebugBundleResult, LogLevel,
UpdateResult, UpdateAvailable, UpdateProgress,
@@ -129,14 +128,6 @@ Networks.Deselect(p: SelectNetworksParams): Promise<void>
Exit-node filter: `range === "0.0.0.0/0" || range === "::/0"`. Domain network: `domains.length > 0`. CIDR overlap check is client-side.
## `Forwarding`
```ts
Forwarding.List(): Promise<ForwardingRule[]>
```
`PortInfo` is a daemon-side oneof — exactly one of `port?: number` or `range?: PortRange` is populated. `protocol` is the lowercase daemon string (`"tcp"` / `"udp"`).
## `Debug`
```ts
@@ -269,12 +260,6 @@ The tray also reads a tray-only synthetic `"Error"` for icon purposes; the front
`Network`: `{ id, range: string; selected: boolean; domains: string[]; resolvedIps: Record<string, string[]> }`.
`ForwardingRule`: `{ protocol: string; destinationPort: PortInfo; translatedAddress, translatedHostname: string; translatedPort: PortInfo }`.
`PortInfo`: `{ port?: number | null; range?: PortRange | null }` (exactly one populated).
`PortRange`: `{ start, end: number }` (inclusive).
`LoginParams`: `{ profileName, username, managementUrl, setupKey, preSharedKey, hostname, hint: string }`.
`LoginResult`: `{ needsSsoLogin: boolean; userCode, verificationUri, verificationUriComplete: string }`.
@@ -80,7 +80,7 @@ export const CopyToClipboard = ({
aria-label={resolvedLabel}
aria-live={"polite"}
className={cn(
"group/copy wails-no-draggable pointer-events-auto inline-flex cursor-default items-center gap-2 rounded-sm text-left outline-none",
"group/copy wails-no-draggable pointer-events-auto inline-flex cursor-default items-center gap-2 rounded-sm text-start outline-none",
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
className,
)}
@@ -97,14 +97,14 @@ export const CopyToClipboard = ({
<span
aria-hidden={"true"}
className={
"pointer-events-none absolute bottom-0 left-0 right-0 border-b border-dashed border-transparent group-hover/copy:border-nb-gray-500"
"pointer-events-none absolute inset-x-0 bottom-0 border-b border-dashed border-transparent group-hover/copy:border-nb-gray-500"
}
/>
</span>
<span
aria-hidden={"true"}
className={cn(
"relative right-[1px] top-[2px] inline-flex shrink-0",
"relative end-[1px] top-[2px] inline-flex shrink-0",
iconAlignment === "left" ? "order-first" : "order-last",
iconClassName,
)}
@@ -1,10 +1,14 @@
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { cva } from "class-variance-authority";
import { Check, ChevronRight, Circle } from "lucide-react";
import { Check, ChevronRight } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/cn";
import { useDirection } from "@/hooks/useDirection";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenu = (props: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) => {
const dir = useDirection();
return <DropdownMenuPrimitive.Root dir={dir} {...props} />;
};
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
@@ -32,16 +36,16 @@ const DropdownMenuSubTrigger = React.forwardRef<
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-md py-1.5 pl-3 pr-2 text-sm outline-none",
"relative flex cursor-default select-none items-center rounded-md py-1.5 pe-2 ps-3 text-sm outline-none",
"transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
inset && "ps-8",
menuItemVariants({ variant }),
className,
)}
{...props}
>
{children}
<ChevronRight className={"ml-auto h-4 w-4"} aria-hidden={"true"} />
<ChevronRight className={"ms-auto h-4 w-4 rtl:-scale-x-100"} aria-hidden={"true"} />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
@@ -102,9 +106,9 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-md py-1.5 pl-2 pr-2 text-sm outline-none",
"relative flex cursor-default select-none items-center rounded-md px-2 py-1.5 text-sm outline-none",
"transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
inset && "ps-8",
menuItemVariants({ variant }),
className,
)}
@@ -134,7 +138,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none",
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pe-2 ps-8 text-sm outline-none",
"text-nb-gray-200 transition-colors hover:bg-nb-gray-900 hover:text-nb-gray-50 focus-visible:bg-nb-gray-900 focus-visible:text-nb-gray-50",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
@@ -142,7 +146,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
checked={checked}
{...props}
>
<span className={"absolute left-2 flex h-3.5 w-3.5 items-center justify-center"}>
<span className={"absolute start-2 flex h-3.5 w-3.5 items-center justify-center"}>
<DropdownMenuPrimitive.ItemIndicator>
<Check className={"h-4 w-4"} />
</DropdownMenuPrimitive.ItemIndicator>
@@ -159,19 +163,23 @@ const DropdownMenuRadioItem = React.forwardRef<
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none",
"text-nb-gray-200 transition-colors hover:bg-nb-gray-900 hover:text-nb-gray-50 focus-visible:bg-nb-gray-900 focus-visible:text-nb-gray-50",
"my-0.5 flex cursor-default select-none items-center gap-2 rounded-md px-2 py-2 outline-none",
"text-xs font-semibold text-nb-gray-200 transition-colors",
"data-[highlighted]:bg-nb-gray-850 data-[highlighted]:text-nb-gray-50",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className={"absolute left-2 flex h-3.5 w-3.5 items-center justify-center"}>
{children}
<span
aria-hidden={"true"}
className={"ms-auto flex w-4 shrink-0 items-center justify-center"}
>
<DropdownMenuPrimitive.ItemIndicator>
<Circle className={"h-2 w-2 fill-current"} />
<Check size={14} className={"text-netbird"} />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
@@ -186,7 +194,7 @@ const DropdownMenuLabel = React.forwardRef<
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-nb-gray-200",
inset && "pl-8",
inset && "ps-8",
className,
)}
{...props}
@@ -208,7 +216,7 @@ DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (
<span
className={cn("ml-auto text-xs tracking-widest text-nb-gray-100 opacity-60", className)}
className={cn("ms-auto text-xs tracking-widest text-nb-gray-100 opacity-60", className)}
{...props}
/>
);
@@ -12,6 +12,7 @@ import { useFocusVisible } from "@/hooks/useFocusVisible";
import { loadLanguages } from "@/lib/i18n";
import { cn } from "@/lib/cn";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
import { useDirection } from "@/hooks/useDirection";
// No flag icons: flags represent countries, not languages. https://www.flagsarenotlanguages.com/blog/
@@ -21,6 +22,7 @@ const labelFor = (lang: Language): string =>
: lang.displayName;
export function LanguagePicker() {
const dir = useDirection();
const { t, i18n } = useTranslation();
const [languages, setLanguages] = useState<Language[]>([]);
const [open, setOpen] = useState(false);
@@ -89,7 +91,11 @@ export function LanguagePicker() {
tabIndex={0}
disabled={busy || languages.length === 0}
onKeyDown={handleTriggerKeyDown}
aria-label={t("settings.general.language.label")}
aria-label={
current
? `${t("settings.general.language.label")}: ${labelFor(current)}`
: t("settings.general.language.label")
}
aria-haspopup={"listbox"}
aria-expanded={open}
className={cn(
@@ -108,7 +114,7 @@ export function LanguagePicker() {
aria-hidden={"true"}
className={"shrink-0 text-nb-gray-200"}
/>
<span className={"flex-1 truncate text-left"}>
<span className={"flex-1 truncate text-start"}>
{current ? labelFor(current) : "—"}
</span>
<ChevronDown
@@ -164,7 +170,11 @@ export function LanguagePicker() {
</div>
</div>
<ScrollArea.Root type={"auto"} className={"-mx-1 overflow-hidden"}>
<ScrollArea.Root
dir={dir}
type={"auto"}
className={"-mx-1 overflow-hidden"}
>
<ScrollArea.Viewport className={"max-h-64 px-1"}>
<Command.List>
<Command.Empty>
@@ -1,4 +1,5 @@
import { useEffect, useRef } from "react";
import { useSearchParams } from "react-router-dom";
import { Events } from "@wailsio/runtime";
import { useStatus } from "@/contexts/StatusContext.tsx";
@@ -6,13 +7,15 @@ const EVENT_WINDOW_PAINTED = "netbird:window-painted";
export const ReadySignal = () => {
const { isReady } = useStatus();
const sent = useRef(false);
const [params] = useSearchParams();
const generation = params.get("gen") ?? "";
const sent = useRef<string | null>(null);
useEffect(() => {
if (!isReady || sent.current) return;
sent.current = true;
void Events.Emit(EVENT_WINDOW_PAINTED);
}, [isReady]);
if (!isReady || sent.current === generation) return;
sent.current = generation;
void Events.Emit(EVENT_WINDOW_PAINTED, generation);
}, [isReady, generation]);
return null;
};
@@ -1,18 +1,10 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, MonitorIcon, MoonIcon, SunMediumIcon, type LucideIcon } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/DropdownMenu";
import { MonitorIcon, MoonIcon, SunMediumIcon, type LucideIcon } from "lucide-react";
import { Select } from "@/components/inputs/Select";
import { HelpText } from "@/components/typography/HelpText";
import { Label } from "@/components/typography/Label";
import { useTheme, type ThemePreference } from "@/contexts/ThemeContext";
import { useFocusVisible } from "@/hooks/useFocusVisible";
import { cn } from "@/lib/cn";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
const OPTIONS: { value: ThemePreference; icon: LucideIcon; labelKey: string }[] = [
@@ -25,16 +17,12 @@ export function ThemePicker() {
const { t } = useTranslation();
const { theme, setTheme } = useTheme();
const [busy, setBusy] = useState(false);
const isFocusVisible = useFocusVisible();
const current = OPTIONS.find((o) => o.value === theme) ?? OPTIONS[0];
const CurrentIcon = current.icon;
const select = async (value: string) => {
const select = async (value: ThemePreference) => {
if (busy || value === theme) return;
setBusy(true);
try {
await setTheme(value as ThemePreference);
await setTheme(value);
} catch (e) {
await errorDialog({
Title: t("settings.error.saveTitle"),
@@ -52,57 +40,17 @@ export function ThemePicker() {
<HelpText margin={false}>{t("settings.general.theme.help")}</HelpText>
</div>
<div className={"shrink-0"}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type={"button"}
tabIndex={0}
disabled={busy}
aria-label={t("settings.general.theme.label")}
className={cn(
"inline-flex h-[40px] min-w-[160px] items-center gap-2 px-3",
"rounded-md border bg-white dark:bg-nb-gray-900",
"border-neutral-200 dark:border-nb-gray-700",
"cursor-default text-xs font-semibold text-nb-gray-100 outline-none",
"hover:border-nb-gray-700 data-[state=open]:border-nb-gray-700 dark:hover:border-nb-gray-600 dark:data-[state=open]:border-nb-gray-600",
isFocusVisible &&
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"disabled:opacity-50",
)}
>
<CurrentIcon
size={16}
aria-hidden={"true"}
className={"shrink-0 text-nb-gray-200"}
/>
<span className={"flex-1 truncate text-left"}>
{t(current.labelKey)}
</span>
<ChevronDown
size={12}
aria-hidden={"true"}
className={"shrink-0 text-nb-gray-400"}
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align={"end"}
className={"w-[var(--radix-dropdown-menu-trigger-width)]"}
>
<DropdownMenuRadioGroup value={theme} onValueChange={(v) => void select(v)}>
{OPTIONS.map(({ value, icon: Icon, labelKey }) => (
<DropdownMenuRadioItem key={value} value={value}>
<Icon
size={14}
aria-hidden={"true"}
className={"mr-2 shrink-0 text-nb-gray-300"}
/>
{t(labelKey)}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<Select
value={theme}
options={OPTIONS.map(({ value, icon, labelKey }) => ({
value,
icon,
label: t(labelKey),
}))}
onChange={(v) => void select(v)}
ariaLabel={t("settings.general.theme.label")}
disabled={busy}
/>
</div>
</div>
);
+10 -1
View File
@@ -1,6 +1,7 @@
import { type ReactNode, useEffect, useRef, useState } from "react";
import * as RTooltip from "@radix-ui/react-tooltip";
import { cn } from "@/lib/cn";
import { useDirection } from "@/hooks/useDirection";
type Props = {
content: ReactNode;
@@ -29,6 +30,8 @@ export const Tooltip = ({
contentClassName,
closeDelay = 0,
}: Props) => {
const dir = useDirection();
const physicalSide = dir === "rtl" ? mirrorSide(side) : side;
const [open, setOpen] = useState(false);
const hoveringRef = useRef(false);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -73,7 +76,7 @@ export const Tooltip = ({
</RTooltip.Trigger>
<RTooltip.Portal>
<RTooltip.Content
side={side}
side={physicalSide}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
@@ -96,3 +99,9 @@ export const Tooltip = ({
</RTooltip.Provider>
);
};
function mirrorSide(side: Props["side"]): Props["side"] {
if (side === "left") return "right";
if (side === "right") return "left";
return side;
}
@@ -6,9 +6,16 @@ type Props = {
className?: string;
tooltipContent?: ReactNode;
delayDuration?: number;
dir?: "ltr" | "rtl" | "auto";
};
export const TruncatedText = ({ text, className, tooltipContent, delayDuration = 600 }: Props) => {
export const TruncatedText = ({
text,
className,
tooltipContent,
delayDuration = 600,
dir = "auto",
}: Props) => {
const ref = useRef<HTMLSpanElement>(null);
const [overflowing, setOverflowing] = useState(false);
@@ -19,7 +26,7 @@ export const TruncatedText = ({ text, className, tooltipContent, delayDuration =
}, [text]);
const span = (
<span ref={ref} className={className}>
<span ref={ref} dir={dir} className={className}>
{text}
</span>
);
@@ -3,12 +3,15 @@ import * as Tabs from "@radix-ui/react-tabs";
import { type LucideProps } from "lucide-react";
import { cn } from "@/lib/cn";
import { useFocusVisible } from "@/hooks/useFocusVisible";
import { useDirection } from "@/hooks/useDirection";
const Root = forwardRef<HTMLDivElement, Omit<Tabs.TabsProps, "orientation">>(
function VerticalTabsRoot({ className, ...props }, ref) {
const dir = useDirection();
return (
<Tabs.Root
ref={ref}
dir={dir}
orientation={"vertical"}
className={cn("flex min-h-0 flex-1", className)}
{...props}
@@ -24,7 +27,7 @@ const List = forwardRef<HTMLDivElement, Tabs.TabsListProps>(function VerticalTab
return (
<Tabs.List
ref={ref}
className={cn("flex w-full flex-col gap-1 p-5 pr-0", className)}
className={cn("flex w-full flex-col gap-1 p-5 pe-0", className)}
{...props}
/>
);
@@ -46,7 +49,7 @@ const Trigger = forwardRef<HTMLButtonElement, TriggerProps>(function VerticalTab
<Tabs.Trigger
ref={ref}
className={cn(
"group flex w-full cursor-default items-center gap-3 rounded-md border border-transparent px-2 py-2.5 text-left outline-none dark:border-0",
"group flex w-full cursor-default items-center gap-3 rounded-md border border-transparent px-2 py-2.5 text-start outline-none dark:border-0",
"transition-colors duration-150",
"data-[state=active]:border-nb-gray-800 data-[state=active]:bg-white dark:data-[state=active]:bg-nb-gray-930",
"data-[state=inactive]:hover:bg-nb-gray-850 dark:data-[state=inactive]:hover:bg-nb-gray-935",
@@ -60,7 +63,7 @@ const Trigger = forwardRef<HTMLButtonElement, TriggerProps>(function VerticalTab
size={iconSize}
aria-hidden={"true"}
className={cn(
"ml-2 shrink-0 transition-colors duration-150",
"ms-2 shrink-0 transition-colors duration-150",
"text-nb-gray-350 dark:text-nb-gray-400",
"group-data-[state=active]:text-nb-gray-100",
)}
@@ -75,7 +78,7 @@ const Trigger = forwardRef<HTMLButtonElement, TriggerProps>(function VerticalTab
{title}
</span>
{adornment && (
<div aria-hidden={"true"} className={"ml-auto mr-2 shrink-0"}>
<div aria-hidden={"true"} className={"me-2 ms-auto shrink-0"}>
{adornment}
</div>
)}
@@ -147,7 +147,8 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
ref={ref}
type={type}
tabIndex={0}
disabled={disabled || loading}
disabled={disabled}
aria-disabled={loading || undefined}
aria-busy={loading || undefined}
className={cn(
buttonVariants({
@@ -156,10 +157,15 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
border: border ? 1 : 0,
size,
}),
loading && "pointer-events-none",
className,
)}
onClick={(e) => {
if (stopPropagation) e.stopPropagation();
if (loading) {
e.preventDefault();
return;
}
if (copy !== undefined) {
void navigator.clipboard
.writeText(copy)
@@ -14,6 +14,7 @@ type ConfirmModalProps = {
cancelLabel?: string;
danger?: boolean;
busy?: boolean;
cancellable?: boolean;
onConfirm: () => void;
onCancel: () => void;
};
@@ -26,11 +27,13 @@ export const ConfirmModal = ({
cancelLabel,
danger = false,
busy = false,
cancellable,
onConfirm,
onCancel,
}: ConfirmModalProps) => {
const { t } = useTranslation();
const resolvedCancel = cancelLabel ?? t("common.cancel");
const canCancel = cancellable ?? !busy;
const srTitle = typeof title === "string" ? title : undefined;
const srDescription = typeof description === "string" ? description : undefined;
@@ -39,7 +42,7 @@ export const ConfirmModal = ({
<Dialog.Root
open={open}
onOpenChange={(next) => {
if (!next && !busy) onCancel();
if (!next && canCancel) onCancel();
}}
>
<Dialog.Content
@@ -51,9 +54,9 @@ export const ConfirmModal = ({
onOpenAutoFocus={(e) => e.preventDefault()}
>
<div className={"flex flex-col gap-5 px-5"}>
<div className={"flex flex-col gap-1 pl-1"}>
<DialogHeading align={"left"}>{title}</DialogHeading>
<DialogDescription align={"left"} className={"whitespace-pre-line"}>
<div className={"flex flex-col gap-1 ps-1"}>
<DialogHeading align={"start"}>{title}</DialogHeading>
<DialogDescription align={"start"} className={"whitespace-pre-line"}>
{description}
</DialogDescription>
</div>
@@ -62,7 +65,7 @@ export const ConfirmModal = ({
<Button
variant={"secondary"}
size={"sm"}
disabled={busy}
disabled={!canCancel}
onClick={onCancel}
>
{resolvedCancel}
@@ -71,7 +74,7 @@ export const ConfirmModal = ({
autoFocus
variant={danger ? "danger" : "primary"}
size={"sm"}
disabled={busy}
loading={busy}
onClick={onConfirm}
>
{confirmLabel}
@@ -95,7 +95,7 @@ export const Content = forwardRef<ElementRef<typeof DialogPrimitive.Content>, Co
{showClose && (
<DialogPrimitive.Close
className={cn(
"absolute right-3 top-3 z-10 rounded-md p-3 transition-colors",
"absolute end-3 top-3 z-10 rounded-md p-3 transition-colors",
"text-nb-gray-300 hover:text-nb-gray-100",
"focus:outline-none disabled:pointer-events-none",
)}
@@ -1,12 +1,12 @@
import { type ReactNode } from "react";
import { cn } from "@/lib/cn";
type DialogAlign = "left" | "center" | "right";
type DialogAlign = "start" | "center" | "end";
const alignClass: Record<DialogAlign, string> = {
left: "text-left",
start: "text-start",
center: "text-center",
right: "text-right",
end: "text-end",
};
type DialogDescriptionProps = {
@@ -1,12 +1,12 @@
import { type ReactNode } from "react";
import { cn } from "@/lib/cn";
type DialogAlign = "left" | "center" | "right";
type DialogAlign = "start" | "center" | "end";
const alignClass: Record<DialogAlign, string> = {
left: "text-left",
start: "text-start",
center: "text-center",
right: "text-right",
end: "text-end",
};
type DialogHeadingProps = {
@@ -65,7 +65,7 @@ export const DaemonOutdatedOverlay = () => {
{clientVersion === "development" ? (
<span>
{t("settings.about.clientName")}{" "}
<span className={"font-mono text-yellow-400"}>
<span dir={"auto"} className={"font-mono text-yellow-400"}>
{t("settings.about.development")}
</span>
</span>
@@ -77,7 +77,7 @@ export const DaemonOutdatedOverlay = () => {
{guiVersion === "development" ? (
<span>
{t("settings.about.guiName")}{" "}
<span className={"font-mono text-yellow-400"}>
<span dir={"auto"} className={"font-mono text-yellow-400"}>
{t("settings.about.development")}
</span>
</span>
@@ -86,13 +86,13 @@ function buildInputClassName(
"file:border-0 file:bg-transparent file:text-sm file:font-medium",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
"disabled:cursor-not-allowed disabled:opacity-40",
opts.hasCustomPrefix && "!rounded-l-none !border-l-0",
opts.hasSuffix && "!pr-9",
opts.hasIcon && "!pl-10",
opts.hasCustomPrefix && "!rounded-s-none !border-s-0",
opts.hasSuffix && "!pe-9",
opts.hasIcon && "!ps-10",
"border",
opts.readOnly && "!border-nb-gray-800 !bg-nb-gray-910 text-nb-gray-350",
opts.showStepper &&
"!rounded-r-none [-moz-appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none",
"!rounded-e-none [-moz-appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none",
opts.className,
);
}
@@ -107,7 +107,7 @@ function InputAffix({
<div
className={cn(
inputVariants({ prefixSuffixVariant: error ? "error" : "default" }),
"flex h-[40px] w-auto rounded-l-md bg-white px-3 py-2 text-sm",
"flex h-[40px] w-auto rounded-s-md bg-white px-3 py-2 text-sm",
"items-center whitespace-nowrap border",
disabled && "opacity-40",
className,
@@ -122,7 +122,7 @@ function InputIconSlot({ icon, disabled }: Readonly<{ icon: ReactNode; disabled?
return (
<div
className={cn(
"absolute left-0 top-0 flex h-full items-center pl-3 text-xs leading-[0] dark:text-nb-gray-300",
"absolute start-0 top-0 flex h-full items-center ps-3 text-xs leading-[0] dark:text-nb-gray-300",
disabled && "opacity-40",
)}
>
@@ -138,7 +138,7 @@ function InputSuffixSlot({
return (
<div
className={cn(
"pointer-events-none absolute right-0 top-0 flex h-full select-none items-center pr-3 text-xs leading-[0] dark:text-nb-gray-300",
"pointer-events-none absolute end-0 top-0 flex h-full select-none items-center pe-3 text-xs leading-[0] dark:text-nb-gray-300",
disabled && "opacity-30",
)}
>
@@ -157,7 +157,7 @@ function NumberStepper({
<div
className={cn(
"flex h-[40px] shrink-0 flex-col overflow-hidden",
"rounded-r-md border border-l-0",
"rounded-e-md border border-s-0",
"border-neutral-200 bg-white dark:border-nb-gray-700 dark:bg-nb-gray-900",
error && "dark:border-red-500",
disabled && "pointer-events-none opacity-40",
@@ -226,6 +226,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
showPasswordToggle = false,
copy = false,
id,
dir,
...props
},
ref,
@@ -336,7 +337,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
return (
<div className={"flex w-full min-w-0 flex-col"}>
{label && <Label htmlFor={inputId}>{label}</Label>}
<div className={cn("relative flex h-[40px] w-full", maxWidthClass)}>
<div dir={dir} className={cn("relative flex h-[40px] w-full", maxWidthClass)}>
{customPrefix && (
<InputAffix
content={customPrefix}
@@ -0,0 +1,99 @@
import type { LucideIcon } from "lucide-react";
import { ChevronDown } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/DropdownMenu";
import { useFocusVisible } from "@/hooks/useFocusVisible";
import { cn } from "@/lib/cn";
export type SelectOption<T extends string> = {
value: T;
label: string;
icon?: LucideIcon;
};
type SelectProps<T extends string> = {
value: T;
options: SelectOption<T>[];
onChange: (value: T) => void;
ariaLabel: string;
disabled?: boolean;
className?: string;
};
export function Select<T extends string>({
value,
options,
onChange,
ariaLabel,
disabled,
className,
}: SelectProps<T>) {
const isFocusVisible = useFocusVisible();
const current = options.find((o) => o.value === value) ?? options[0];
const CurrentIcon = current?.icon;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type={"button"}
tabIndex={0}
disabled={disabled}
aria-label={current ? `${ariaLabel}: ${current.label}` : ariaLabel}
className={cn(
"inline-flex h-[40px] min-w-[160px] items-center gap-2 px-3",
"rounded-md border bg-white dark:bg-nb-gray-900",
"border-neutral-200 dark:border-nb-gray-700",
"cursor-default text-xs font-semibold text-nb-gray-100 outline-none",
"hover:border-nb-gray-700 data-[state=open]:border-nb-gray-700 dark:hover:border-nb-gray-600 dark:data-[state=open]:border-nb-gray-600",
isFocusVisible &&
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
"disabled:opacity-50",
className,
)}
>
{CurrentIcon && (
<CurrentIcon
size={16}
aria-hidden={"true"}
className={"shrink-0 text-nb-gray-200"}
/>
)}
<span className={"flex-1 truncate text-start"}>{current?.label ?? "—"}</span>
<ChevronDown
size={12}
aria-hidden={"true"}
className={"shrink-0 text-nb-gray-400"}
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align={"start"}
sideOffset={6}
className={
"w-[var(--radix-dropdown-menu-trigger-width)] border-nb-gray-850 bg-nb-gray-920"
}
>
<DropdownMenuRadioGroup value={value} onValueChange={(v) => onChange(v as T)}>
{options.map(({ value: optionValue, label, icon: Icon }) => (
<DropdownMenuRadioItem key={optionValue} value={optionValue}>
{Icon && (
<Icon
size={14}
aria-hidden={"true"}
className={"shrink-0 text-nb-gray-300"}
/>
)}
<span className={"min-w-0 flex-1 truncate"}>{label}</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -42,7 +42,7 @@ export default function FancyToggleSwitch({
role={"status"}
aria-busy={"true"}
aria-live={"polite"}
className={cn("inline-block w-full text-left", className)}
className={cn("inline-block w-full text-start", className)}
>
<div className={"flex justify-between gap-10"}>
<div className={cn(textWrapperClassName)}>
@@ -55,7 +55,7 @@ export default function FancyToggleSwitch({
</span>
</HelpText>
</div>
<div className={"mt-2 pr-1"}>
<div className={"mt-2 pe-1"}>
<div
aria-hidden={"true"}
className={
@@ -73,7 +73,7 @@ export default function FancyToggleSwitch({
{...(disabled ? { inert: "" } : {})}
className={cn(
"relative z-[1] cursor-default transition-all duration-300",
"inline-block w-full text-left",
"inline-block w-full text-start",
disabled && "pointer-events-none opacity-30",
className,
)}
@@ -87,7 +87,7 @@ export default function FancyToggleSwitch({
<span id={descriptionId}>{helpText}</span>
</HelpText>
</div>
<div className={"mt-2 pr-1"}>
<div className={"mt-2 pe-1"}>
<ToggleSwitch
id={switchId}
checked={value}
@@ -1,6 +1,7 @@
import * as RadioGroup from "@radix-ui/react-radio-group";
import { createContext, type ReactNode, useContext, useId, useMemo } from "react";
import { cn } from "@/lib/cn";
import { useDirection } from "@/hooks/useDirection";
type SwitchItemGroupContextValue = {
value: string;
@@ -37,11 +38,13 @@ export const SwitchItemGroup = ({
"aria-labelledby": ariaLabelledBy,
}: Props) => {
const layoutId = useId();
const dir = useDirection();
const contextValue = useMemo(() => ({ value, layoutId }), [value, layoutId]);
return (
<SwitchItemGroupContext.Provider value={contextValue}>
<RadioGroup.Root
dir={dir}
value={value}
onValueChange={onChange}
disabled={disabled}
@@ -36,9 +36,9 @@ const switchVariants = cva("", {
},
"thumb-size": {
default:
"h-5 w-5 data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
small: "h-[14px] w-[14px] data-[state=checked]:translate-x-[17px] data-[state=unchecked]:translate-x-0",
large: "h-[30px] w-[30px] data-[state=checked]:translate-x-[31px] data-[state=unchecked]:translate-x-[1px]",
"h-5 w-5 data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0 rtl:data-[state=checked]:-translate-x-5",
small: "h-[14px] w-[14px] data-[state=checked]:translate-x-[17px] data-[state=unchecked]:translate-x-0 rtl:data-[state=checked]:-translate-x-[17px]",
large: "h-[30px] w-[30px] data-[state=checked]:translate-x-[31px] data-[state=unchecked]:translate-x-[1px] rtl:data-[state=checked]:-translate-x-[31px] rtl:data-[state=unchecked]:-translate-x-[1px]",
},
},
});
@@ -8,6 +8,28 @@ import {
useState,
} from "react";
import { ConfirmModal } from "@/components/dialog/ConfirmModal";
import i18next from "@/lib/i18n";
// Nothing on the daemon path carries a deadline, so a hung call would leave the
// modal spinning with no way out. Cancel comes back once the wait stops looking
// normal, and the wait is abandoned entirely at the deadline.
const CANCELLABLE_AFTER_MS = 15_000;
const TIMEOUT_MS = 30_000;
const withTimeout = async (action: () => Promise<unknown>) => {
let timer: ReturnType<typeof setTimeout> | undefined;
const expiry = new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error(i18next.t("error.daemon_unreachable"))),
TIMEOUT_MS,
);
});
try {
await Promise.race([action(), expiry]);
} finally {
clearTimeout(timer);
}
};
export type ConfirmOptions = {
title: ReactNode;
@@ -15,6 +37,7 @@ export type ConfirmOptions = {
confirmLabel: string;
cancelLabel?: string;
danger?: boolean;
onConfirm?: () => Promise<unknown>;
};
type DialogContextValue = {
@@ -23,23 +46,50 @@ type DialogContextValue = {
const DialogContext = createContext<DialogContextValue | null>(null);
type Settler = { resolve: (result: boolean) => void; reject: (reason: unknown) => void };
export function DialogProvider({ children }: Readonly<{ children: ReactNode }>) {
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [stalled, setStalled] = useState(false);
const [options, setOptions] = useState<ConfirmOptions | null>(null);
const resolverRef = useRef<((result: boolean) => void) | null>(null);
const resolverRef = useRef<Settler | null>(null);
const confirm = useCallback((opts: ConfirmOptions) => {
setOptions(opts);
setOpen(true);
return new Promise<boolean>((resolve) => {
resolverRef.current = resolve;
return new Promise<boolean>((resolve, reject) => {
resolverRef.current = { resolve, reject };
});
}, []);
const settle = (result: boolean) => {
resolverRef.current?.(result);
const take = (expected?: Settler | null) => {
const settler = resolverRef.current;
if (expected && settler !== expected) return null;
resolverRef.current = null;
setBusy(false);
setStalled(false);
setOpen(false);
return settler;
};
const handleConfirm = async () => {
const action = options?.onConfirm;
if (!action) {
take()?.resolve(true);
return;
}
const dispatched = resolverRef.current;
setBusy(true);
const stallTimer = setTimeout(() => setStalled(true), CANCELLABLE_AFTER_MS);
try {
await withTimeout(action);
take(dispatched)?.resolve(true);
} catch (e) {
take(dispatched)?.reject(e);
} finally {
clearTimeout(stallTimer);
}
};
const value = useMemo<DialogContextValue>(() => ({ confirm }), [confirm]);
@@ -54,8 +104,10 @@ export function DialogProvider({ children }: Readonly<{ children: ReactNode }>)
confirmLabel={options?.confirmLabel ?? ""}
cancelLabel={options?.cancelLabel}
danger={options?.danger}
onConfirm={() => settle(true)}
onCancel={() => settle(false)}
busy={busy}
cancellable={!busy || stalled}
onConfirm={() => void handleConfirm()}
onCancel={() => take()?.resolve(false)}
/>
</DialogContext.Provider>
);
+5
View File
@@ -96,6 +96,11 @@ body {
@apply bg-nb-gray font-sans text-nb-gray-200 antialiased;
}
[dir="rtl"] :is(.font-mono, code, kbd):not([dir]) {
direction: ltr;
unicode-bidi: isolate;
}
.wails-draggable {
--wails-draggable: drag;
cursor: default;
@@ -1,23 +1,27 @@
import { useLayoutEffect, useRef } from "react";
import { Window } from "@wailsio/runtime";
import { useSearchParams } from "react-router-dom";
import { Events, Window } from "@wailsio/runtime";
import i18next from "@/lib/i18n";
import { isLinux } from "@/lib/platform";
const EVENT_WINDOW_PAINTED = "netbird:window-painted";
// Sizes the current Wails window to the measured content height (keeping `width`),
// then shows it. Re-applies on content resize and language change.
// then reports it as painted so Go shows it. Re-applies on content resize and language change.
export function useAutoSizeWindow<T extends HTMLElement>(width: number, ready: boolean = true) {
const ref = useRef<T | null>(null);
const [params] = useSearchParams();
const generation = params.get("gen") ?? "";
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
let shown = false;
let painted = false;
let raf1 = 0;
let raf2 = 0;
const showOnce = () => {
if (shown) return;
shown = true;
Window.Show().catch(() => {});
Window.Focus().catch(() => {});
const paintedOnce = () => {
if (painted) return;
painted = true;
Events.Emit(EVENT_WINDOW_PAINTED, generation).catch(() => {});
};
const apply = async () => {
if (!ready) return;
@@ -33,7 +37,7 @@ export function useAutoSizeWindow<T extends HTMLElement>(width: number, ready: b
await Window.SetMaxSize(width, targetH);
}
await Window.SetSize(width, targetH);
showOnce();
paintedOnce();
} catch {
// window gone / not ready — ignore
}
@@ -55,6 +59,6 @@ export function useAutoSizeWindow<T extends HTMLElement>(width: number, ready: b
cancelAnimationFrame(raf2);
i18next.off("languageChanged", scheduleApply);
};
}, [width, ready]);
}, [width, ready, generation]);
return ref;
}
@@ -0,0 +1,6 @@
import { useTranslation } from "react-i18next";
export function useDirection(): "ltr" | "rtl" {
const { i18n } = useTranslation();
return i18n.dir(i18n.language);
}
@@ -1,6 +1,7 @@
import { type ReactNode } from "react";
import { motion } from "framer-motion";
import { cn } from "@/lib/cn.ts";
import { useDirection } from "@/hooks/useDirection";
type Props = {
children: ReactNode;
@@ -15,17 +16,19 @@ const PANEL_TRANSITION = {
};
export const AppRightPanel = ({ children, overlay, overlayOpen = false, className }: Props) => {
const dir = useDirection();
const parallaxX = dir === "rtl" ? 48 : -48;
return (
<div
className={cn(
"wails-no-draggable relative m-5",
"border border-nb-gray-800 bg-nb-gray-940 dark:border-nb-gray-920",
"flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-xl rounded-br-2xl",
"flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-xl rounded-ee-2xl",
className,
)}
>
<motion.div
animate={{ x: overlayOpen ? -48 : 0 }}
animate={{ x: overlayOpen ? parallaxX : 0 }}
transition={PANEL_TRANSITION}
className={"flex min-h-0 min-w-0 flex-1 flex-col"}
style={{ pointerEvents: overlayOpen ? "none" : "auto" }}
+20 -4
View File
@@ -40,14 +40,29 @@ function detectBrowserLanguage(available: string[]): string | null {
return null;
}
// An empty persisted language code is the Go-side signal for first run.
// Only locales listed in _index.json are shipped. A bundle on disk without an
// _index.json row (e.g. a new Crowdin language not released yet) must not be
// auto-detected or loaded, matching the Go side, which rejects saving it.
async function shippedLanguages(): Promise<string[]> {
const onDisk = Object.keys(resources);
try {
const listed = new Set<string>((await I18n.Languages()).map((l) => l.code));
return onDisk.filter((code) => listed.has(code));
} catch (e) {
console.warn("load shipped languages failed, using all bundled locales", e);
return onDisk;
}
}
// An empty persisted language code is the Go-side signal for first run. A
// persisted code that is no longer shipped is treated the same way.
export async function initI18n(): Promise<void> {
const available = Object.keys(resources);
const available = await shippedLanguages();
let language = "en";
let firstRun = false;
try {
const prefs = await Preferences.Get();
if (prefs?.language) {
if (prefs?.language && available.includes(prefs.language)) {
language = prefs.language;
} else {
firstRun = true;
@@ -68,7 +83,7 @@ export async function initI18n(): Promise<void> {
fallbackLng: "en",
defaultNS: "common",
ns: ["common"],
resources,
resources: Object.fromEntries(available.map((code) => [code, resources[code]])),
interpolation: {
prefix: "{",
suffix: "}",
@@ -93,6 +108,7 @@ export async function initI18n(): Promise<void> {
function syncDocumentLang() {
if (typeof document !== "undefined") {
document.documentElement.lang = i18next.language;
document.documentElement.dir = i18next.dir(i18next.language);
}
}
@@ -66,7 +66,7 @@ export default function ErrorDialog() {
wrap
variant={"bright"}
className={
"mt-2 w-full items-start gap-2 rounded-md bg-nb-gray-930 px-3 py-2 text-left"
"mt-2 w-full items-start gap-2 rounded-md bg-nb-gray-930 px-3 py-2 text-start"
}
aria-label={t("common.copy")}
>
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef } from "react";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import { Events } from "@wailsio/runtime";
@@ -21,7 +21,6 @@ export default function LoginWaitingForBrowserDialog() {
const [params] = useSearchParams();
const uri = params.get("uri") ?? "";
const contentRef = useAutoSizeWindow<HTMLDivElement>(WINDOW_WIDTH);
const openedRef = useRef(false);
const reportOpenFailure = useCallback(
(e: unknown) => {
@@ -33,13 +32,6 @@ export default function LoginWaitingForBrowserDialog() {
[t],
);
// Open the browser only after mount, or it lands on top of the still-hidden popup.
useEffect(() => {
if (!uri || openedRef.current) return;
openedRef.current = true;
Connection.OpenURL(uri).catch(reportOpenFailure);
}, [uri, reportOpenFailure]);
const tryAgain = useCallback(() => {
if (!uri) return;
Connection.OpenURL(uri).catch(reportOpenFailure);
@@ -267,12 +267,13 @@ export const MainConnectionStatusSwitch = () => {
tabIndex={show && fqdn ? 0 : -1}
className={cn(
"mt-1 max-h-[1em] min-h-[1em] max-w-full transition-opacity duration-300",
"relative left-[0.55rem]",
"relative start-[0.55rem]",
show && fqdn ? "opacity-100" : "pointer-events-none opacity-0",
)}
>
<TruncatedText
text={shortenDns(fqdn) || " "}
dir={"ltr"}
className={
"block h-[18px] max-w-[310px] truncate font-mono text-[0.8rem] leading-tight text-nb-gray-300"
}
@@ -298,7 +299,7 @@ const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boole
tabIndex={show && ip ? 0 : -1}
className={cn(
"mt-1 max-h-[1em] min-h-[1em] transition-opacity duration-300",
"relative left-[0.55rem]",
"relative start-[0.55rem]",
show && ip ? "opacity-100" : "pointer-events-none opacity-0",
)}
>
@@ -345,7 +346,7 @@ const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boole
size={14}
aria-hidden={"true"}
className={cn(
"absolute -right-5 top-1/2 -translate-y-1/2",
"absolute -end-5 top-1/2 -translate-y-1/2",
"shrink-0 text-nb-gray-300 transition-colors",
"group-hover:text-nb-gray-200",
"group-data-[state=open]:text-nb-gray-200",
@@ -398,7 +399,7 @@ const IpRow = ({ value }: { value: string }) => {
aria-label={`${t("common.copy")} ${value}`}
className={cn(
"group/iprow relative flex items-center justify-between gap-3",
"rounded-md px-2 py-1.5 text-left",
"rounded-md px-2 py-1.5 text-start",
"text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50",
"cursor-default outline-none transition-colors",
isFocusVisible &&
@@ -9,10 +9,12 @@ import { TruncatedText } from "@/components/TruncatedText";
import { useNetworks } from "@/contexts/NetworksContext";
import { useStatus } from "@/contexts/StatusContext";
import { useFocusVisible } from "@/hooks/useFocusVisible";
import { useDirection } from "@/hooks/useDirection";
const NONE_VALUE = "__none__";
export const MainExitNodeSwitcher = () => {
const dir = useDirection();
const { t } = useTranslation();
const { status } = useStatus();
const { exitNodes, toggleExitNode } = useNetworks();
@@ -101,7 +103,11 @@ export const MainExitNodeSwitcher = () => {
<NoneRow isActive={!active} onSelect={() => handleSelect(NONE_VALUE)} />
{hasAny && <div className={"-mx-1 my-1 h-px bg-nb-gray-910"} />}
{hasAny && (
<ScrollArea.Root type={"auto"} className={"-mx-1 overflow-hidden"}>
<ScrollArea.Root
dir={dir}
type={"auto"}
className={"-mx-1 overflow-hidden"}
>
<ScrollArea.Viewport className={"max-h-72 px-1"}>
{exitNodes.map((n) => (
<ExitNodeRow
@@ -155,7 +161,7 @@ const ExitNodeTriggerCard = forwardRef<HTMLButtonElement, TriggerProps>(
tabIndex={0}
disabled={disabled}
className={cn(
"flex w-full items-center gap-3 rounded-xl p-2.5 pr-5 text-left outline-none",
"flex w-full items-center gap-3 rounded-xl p-2.5 pe-5 text-start outline-none",
"border border-nb-gray-800 bg-nb-gray-940 dark:border-nb-gray-920",
"transition-colors duration-150",
"wails-no-draggable",
@@ -212,7 +218,7 @@ const NoneRow = ({ isActive, onSelect }: NoneRowProps) => {
value={NONE_VALUE}
onSelect={onSelect}
className={cn(
"flex items-center gap-2 px-2 py-2 pr-3",
"flex items-center gap-2 px-2 py-2 pe-3",
"cursor-default rounded-md text-sm outline-none",
"data-[selected=true]:bg-nb-gray-900",
)}
@@ -237,7 +243,7 @@ const ExitNodeRow = ({ id, label, isActive, onSelect }: ExitNodeRowProps) => (
value={id}
onSelect={onSelect}
className={cn(
"flex items-center gap-2 px-2 py-2 pr-3",
"flex items-center gap-2 px-2 py-2 pe-3",
"cursor-default rounded-md text-sm outline-none",
"data-[selected=true]:bg-nb-gray-900",
)}
@@ -25,7 +25,8 @@ import { cn } from "@/lib/cn";
import { formatShortcut, useKeyboardShortcut } from "@/hooks/useKeyboardShortcut";
import { useViewMode, type ViewMode } from "@/contexts/ViewModeContext";
import { useRestrictions } from "@/contexts/RestrictionsContext";
import { isWindows } from "@/lib/platform.ts";
import { isMacOS, isWindows } from "@/lib/platform.ts";
import { useDirection } from "@/hooks/useDirection";
const SETTINGS_SHORTCUT = { key: ",", cmd: true } as const;
@@ -35,6 +36,8 @@ export const MainHeader = () => {
const { viewMode, setViewMode } = useViewMode();
const { updateAvailable } = useClientVersion();
const { mdm, features } = useRestrictions();
const dir = useDirection();
const pinSettingsRight = isMacOS();
const openSettings = useCallback(() => {
setMenuOpen(false);
@@ -83,7 +86,7 @@ export const MainHeader = () => {
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align={"end"}
align={pinSettingsRight && dir === "rtl" ? "start" : "end"}
sideOffset={8}
className={
"min-w-52 select-none data-[state=closed]:!animate-none data-[state=closed]:!duration-0"
@@ -138,7 +141,7 @@ export const MainHeader = () => {
<span
aria-hidden={"true"}
className={
"pointer-events-none absolute right-1.5 top-1.5 flex h-2.5 w-2.5 items-center justify-center"
"pointer-events-none absolute end-1.5 top-1.5 flex h-2.5 w-2.5 items-center justify-center"
}
>
<span
@@ -168,10 +171,17 @@ export const MainHeader = () => {
)}
>
<div />
<div className={"ml-4 flex justify-center"}>{profileSlot}</div>
<div className={"ml-4 flex justify-center rtl:ml-1"}>{profileSlot}</div>
<div />
</div>
<div className={"absolute right-[1.3rem] top-1/2 -translate-y-1/2"}>{settingsSlot}</div>
<div
className={cn(
"absolute top-1/2 -translate-y-1/2",
pinSettingsRight ? "right-[1.3rem]" : "end-[1.3rem]",
)}
>
{settingsSlot}
</div>
</header>
);
};
@@ -55,7 +55,7 @@ const MainBody = () => {
>
<MainConnectionStatusSwitch />
{!features.disableNetworks && (
<div className={"wails-no-draggable absolute bottom-5 left-5 right-5"}>
<div className={"wails-no-draggable absolute inset-x-5 bottom-5"}>
<MainExitNodeSwitcher />
</div>
)}
@@ -79,7 +79,7 @@ const AdvancedAppRightPanel = () => {
<AppRightPanel
overlay={<PeerDetailPanel />}
overlayOpen={selected !== null}
className={"m-5 ml-0"}
className={"m-5 ms-0"}
>
<div
ref={(el) => {
@@ -5,6 +5,7 @@ import { cn } from "@/lib/cn";
import { useNavSection, type NavSection } from "@/contexts/NavSectionContext";
import { useStatus } from "@/contexts/StatusContext";
import { useRestrictions } from "@/contexts/RestrictionsContext";
import { useDirection } from "@/hooks/useDirection";
type TabEntry = {
value: NavSection;
@@ -48,6 +49,10 @@ export const Navigation = () => {
requestAnimationFrame(() => tabRefs.current[value]?.focus());
};
const dir = useDirection();
const forwardKey = dir === "rtl" ? "ArrowLeft" : "ArrowRight";
const backwardKey = dir === "rtl" ? "ArrowRight" : "ArrowLeft";
const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
const enabled = tabs.filter((t) => isConnected || t.value === section);
if (enabled.length < 2) return;
@@ -55,10 +60,10 @@ export const Navigation = () => {
if (currentIndex === -1) return;
let nextIndex: number;
switch (e.key) {
case "ArrowRight":
case forwardKey:
nextIndex = (currentIndex + 1) % enabled.length;
break;
case "ArrowLeft":
case backwardKey:
nextIndex = (currentIndex - 1 + enabled.length) % enabled.length;
break;
case "Home":
@@ -106,8 +111,8 @@ export const Navigation = () => {
"group relative flex flex-1 items-center justify-center",
"gap-2.5 px-5 py-3.5",
"outline-none transition-all",
isFirst && "rounded-tl-xl",
isLast && "rounded-tr-xl",
isFirst && "rounded-ss-xl",
isLast && "rounded-se-xl",
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-nb-gray-50/60",
isActive ? "text-netbird" : "text-nb-gray-400 hover:text-nb-gray-300",
isDisabled ? "cursor-not-allowed opacity-50" : "cursor-default",
@@ -52,7 +52,7 @@ export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) =>
<span>
{active.label} <span className={"tabular-nums"}>({counts[active.value]})</span>
</span>
<ChevronDown size={14} aria-hidden={"true"} className={"ml-0.5 shrink-0"} />
<ChevronDown size={14} aria-hidden={"true"} className={"ms-0.5 shrink-0"} />
</DropdownMenuTrigger>
<DropdownMenuContent align={"end"} className={"min-w-[10rem]"}>
{filters.map((f) => {
@@ -23,6 +23,7 @@ import { NoResults } from "@/components/empty-state/NoResults";
import { useStatus } from "@/contexts/StatusContext";
import { useNetworks } from "@/contexts/NetworksContext";
import { type NetworkFilter, NetworkFilters } from "./NetworkFilters";
import { useDirection } from "@/hooks/useDirection";
// Daemon renders DNS-route prefixes (zero netip.Prefix) as "invalid Prefix".
const INVALID_PREFIX = "invalid Prefix";
@@ -70,6 +71,7 @@ const buildOverlapMap = (
};
export const Networks = () => {
const dir = useDirection();
const { t } = useTranslation();
const { status } = useStatus();
const isConnected = status?.status === "Connected";
@@ -175,7 +177,11 @@ export const Networks = () => {
{filtered.length === 0 ? (
<NoResults />
) : (
<ScrollArea.Root type={"auto"} className={"min-h-0 flex-1 overflow-hidden"}>
<ScrollArea.Root
dir={dir}
type={"auto"}
className={"min-h-0 flex-1 overflow-hidden"}
>
<ScrollArea.Viewport ref={setScrollParent} className={"h-full w-full"}>
{scrollParent && (
<NetworksList
@@ -343,7 +349,7 @@ const NetworkRow = ({ network: n, index, onKeyDown, onToggle, setRowRef }: Netwo
return (
<div
className={cn(
"group relative flex min-w-0 items-start gap-2.5 py-3 pl-6 pr-9",
"group relative flex min-w-0 items-start gap-2.5 py-3 pe-9 ps-6",
"transition-colors hover:bg-nb-gray-900/40",
"wails-no-draggable",
)}
@@ -423,6 +429,7 @@ const Subtitle = ({ network, onKeyDown }: SubtitleProps) => {
<CopyToClipboard message={network.range} onKeyDown={onKeyDown}>
<TruncatedText
text={network.range}
dir={"ltr"}
className={
"block max-w-[300px] truncate font-mono text-xs text-nb-gray-400"
}
@@ -463,7 +470,7 @@ const DomainSubtitle = ({ domain, ips, onKeyDown }: DomainSubtitleProps) => {
contentClassName={cn(
"max-h-72 max-w-[18rem] overflow-auto",
"rounded-lg border border-nb-gray-800 bg-white dark:border-nb-gray-900 dark:bg-nb-gray-935",
"p-2 pr-4",
"p-2 pe-4",
)}
>
{span}
@@ -508,7 +515,9 @@ type ToggleProps = {
};
const NetworkToggle = ({ checked, mixed }: ToggleProps) => {
const checkedTranslate = checked ? "translate-x-[1.125rem]" : "translate-x-0.5";
const checkedTranslate = checked
? "translate-x-[1.125rem] rtl:-translate-x-[1.125rem]"
: "translate-x-0.5 rtl:-translate-x-0.5";
return (
<span
className={cn(
@@ -521,7 +530,7 @@ const NetworkToggle = ({ checked, mixed }: ToggleProps) => {
<span
className={cn(
"inline-block h-4 w-4 rounded-full bg-white transition-transform",
mixed ? "translate-x-2.5" : checkedTranslate,
mixed ? "translate-x-2.5 rtl:-translate-x-2.5" : checkedTranslate,
)}
/>
</span>
@@ -43,6 +43,7 @@ import { useStatus } from "@/contexts/StatusContext";
import { usePeerDetail } from "@/contexts/PeerDetailContext";
import { useFocusVisible } from "@/hooks/useFocusVisible";
import { peerStatusLabelKey } from "./Peers";
import { useDirection } from "@/hooks/useDirection";
const DEFAULT_TRANSITION: Transition = {
duration: 0.32,
@@ -70,6 +71,8 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
const { t } = useTranslation();
const { selected, setSelected } = usePeerDetail();
const { status, refresh } = useStatus();
const dir = useDirection();
const offscreenX = dir === "rtl" ? "-100%" : "100%";
useEffect(() => {
if (!selected) return;
@@ -111,7 +114,7 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
setSelected(null);
return;
}
if (e.key === "ArrowLeft") {
if (e.key === (dir === "rtl" ? "ArrowRight" : "ArrowLeft")) {
const target = e.target as HTMLElement | null;
const tag = target?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) return;
@@ -120,7 +123,7 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
};
globalThis.addEventListener("keydown", onKey);
return () => globalThis.removeEventListener("keydown", onKey);
}, [selected, setSelected]);
}, [selected, setSelected, dir]);
const dialogRef = useRef<HTMLDivElement>(null);
const backButtonRef = useRef<HTMLButtonElement>(null);
@@ -172,9 +175,9 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
aria-modal={"true"}
aria-labelledby={"nb-peer-detail-title"}
onKeyDown={onDialogKeyDown}
initial={{ x: "100%" }}
initial={{ x: offscreenX }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
exit={{ x: offscreenX }}
transition={transition}
style={{ willChange: "transform" }}
className={cn("absolute inset-0 z-20 flex flex-col", "bg-nb-gray-940")}
@@ -199,7 +202,11 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
"wails-no-draggable",
)}
>
<ArrowLeftIcon size={16} aria-hidden={"true"} />
<ArrowLeftIcon
size={16}
aria-hidden={"true"}
className={"rtl:-scale-x-100"}
/>
</button>
<Tooltip content={t(peerStatusLabelKey(selected.connStatus))} side={"top"}>
<span
@@ -248,7 +255,11 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
</button>
</Tooltip>
</div>
<ScrollArea.Root type={"auto"} className={"min-h-0 flex-1 overflow-hidden"}>
<ScrollArea.Root
dir={dir}
type={"auto"}
className={"min-h-0 flex-1 overflow-hidden"}
>
<ScrollArea.Viewport className={"h-full w-full"}>
<PeerDetails peer={selected} now={now} />
</ScrollArea.Viewport>
@@ -466,7 +477,7 @@ const ResourcesPopover = ({ networks }: { networks: string[] }) => {
"inline-flex shrink-0 items-center gap-1 rounded",
"bg-nb-gray-930 hover:bg-nb-gray-910/80 data-[state=open]:bg-nb-gray-910",
"border border-nb-gray-900",
"py-1 pl-2.5 pr-2 text-xs font-medium text-nb-gray-300",
"py-1 pe-2 ps-2.5 text-xs font-medium text-nb-gray-300",
"wails-no-draggable cursor-default outline-none transition-all",
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
)}
@@ -526,7 +537,7 @@ const ResourceRow = ({ value }: { value: string }) => {
aria-label={`${t("common.copy")} ${value}`}
className={cn(
"group/resourcerow relative flex items-center justify-between gap-3",
"rounded-md px-2 py-1.5 text-left",
"rounded-md px-2 py-1.5 text-start",
"text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50",
"cursor-default outline-none transition-colors",
isFocusVisible &&
@@ -547,6 +558,7 @@ const ResourceRow = ({ value }: { value: string }) => {
const TruncatedRowValue = ({ value, mono }: { value: string; mono?: boolean }) => (
<TruncatedText
text={value}
dir={mono ? "ltr" : "auto"}
className={cn(
"inline-block min-w-0 max-w-[260px] truncate align-middle",
mono && "font-mono",
@@ -564,7 +576,7 @@ const Row = ({ icon: Icon, iconClassName, label, children }: RowProps) => (
<span className={"shrink-0 font-semibold text-nb-gray-200"}>{label}</span>
<span
className={cn(
"min-w-0 flex-1 pl-8 text-right",
"min-w-0 flex-1 ps-8 text-end",
"font-medium text-nb-gray-350",
"flex items-center justify-end",
)}
@@ -52,7 +52,7 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
<span>
{active.label} <span className={"tabular-nums"}>({counts[active.value]})</span>
</span>
<ChevronDown size={14} aria-hidden={"true"} className={"ml-0.5 shrink-0"} />
<ChevronDown size={14} aria-hidden={"true"} className={"ms-0.5 shrink-0"} />
</DropdownMenuTrigger>
<DropdownMenuContent align={"end"} className={"min-w-[10rem]"}>
{filters.map((f) => {
@@ -15,6 +15,7 @@ import { useStatus } from "@/contexts/StatusContext";
import { usePeerDetail } from "@/contexts/PeerDetailContext";
import { Tooltip } from "@/components/Tooltip";
import { TruncatedText } from "@/components/TruncatedText";
import { useDirection } from "@/hooks/useDirection";
import { PeerFilters, type StatusFilter } from "./PeerFilters";
const isOnline = (connStatus: string) => connStatus === "Connected";
@@ -42,6 +43,7 @@ export const peerStatusLabelKey = (connStatus: string): string => {
};
export const Peers = () => {
const dir = useDirection();
const { t } = useTranslation();
const { status } = useStatus();
const [search, setSearch] = useState("");
@@ -135,7 +137,11 @@ export const Peers = () => {
{filtered.length === 0 ? (
<NoResults />
) : (
<ScrollArea.Root type={"auto"} className={"min-h-0 flex-1 overflow-hidden"}>
<ScrollArea.Root
dir={dir}
type={"auto"}
className={"min-h-0 flex-1 overflow-hidden"}
>
<ScrollArea.Viewport ref={setScrollParent} className={"h-full w-full"}>
{scrollParent && <PeersList data={filtered} scrollParent={scrollParent} />}
</ScrollArea.Viewport>
@@ -167,6 +173,7 @@ type PeersListProps = {
const PeersList = ({ data, scrollParent }: PeersListProps) => {
const { setSelected } = usePeerDetail();
const dir = useDirection();
const virtuosoRef = useRef<VirtuosoHandle>(null);
const rowRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
@@ -200,7 +207,7 @@ const PeersList = ({ data, scrollParent }: PeersListProps) => {
e.preventDefault();
focusRow(Math.max(index - 1, 0));
break;
case "ArrowRight":
case dir === "rtl" ? "ArrowLeft" : "ArrowRight":
e.preventDefault();
setSelected(data[index]);
break;
@@ -223,7 +230,7 @@ const PeersList = ({ data, scrollParent }: PeersListProps) => {
const ctx = useMemo<PeerRowContext>(
() => ({ onKeyDown: handleRowKeyDown, onSelect: setSelected, setRowRef }),
// eslint-disable-next-line react-hooks/exhaustive-deps
[data, setSelected],
[data, setSelected, dir],
);
return (
@@ -273,7 +280,7 @@ const PeerRow = ({ peer, index, onKeyDown, onSelect, setRowRef }: PeerRowProps)
return (
<div
className={cn(
"group relative flex min-w-0 items-start gap-2.5 py-3 pl-6 pr-4",
"group relative flex min-w-0 items-start gap-2.5 py-3 pe-4 ps-6",
"transition-colors hover:bg-nb-gray-900/40",
"wails-no-draggable",
)}
@@ -344,7 +351,7 @@ const PeerRow = ({ peer, index, onKeyDown, onSelect, setRowRef }: PeerRowProps)
size={16}
aria-hidden={"true"}
className={cn(
"pointer-events-none relative shrink-0 self-center text-nb-gray-300",
"pointer-events-none relative shrink-0 self-center text-nb-gray-300 rtl:-scale-x-100",
"opacity-0 transition-opacity group-hover:opacity-100",
)}
/>
@@ -175,7 +175,7 @@ export const ProfileCreationModal = ({ open, onOpenChange, onSubmit, initial }:
<form onSubmit={handleSubmit}>
<div className={"flex flex-col gap-6 px-7"}>
<div className={"flex flex-col gap-2"}>
<div className={"pl-1"}>
<div className={"ps-1"}>
<Label htmlFor={nameId} className={"mb-0.5"}>
{t("profile.dialog.nameLabel")}
</Label>
@@ -200,7 +200,7 @@ export const ProfileCreationModal = ({ open, onOpenChange, onSubmit, initial }:
{!managedManagementUrl && (
<div className={"flex flex-col gap-2"}>
<div className={"pl-1"}>
<div className={"ps-1"}>
<Label as={"div"} className={"mb-0.5"}>
{t("settings.general.management.label")}
</Label>
@@ -217,6 +217,7 @@ export const ProfileCreationModal = ({ open, onOpenChange, onSubmit, initial }:
{mode === ManagementMode.SelfHosted && (
<Input
id={urlId}
dir={"ltr"}
ref={urlRef}
aria-label={t("settings.general.management.label")}
placeholder={t(
@@ -11,6 +11,7 @@ import { useProfile } from "@/contexts/ProfileContext";
import { useFocusVisible } from "@/hooks/useFocusVisible";
import { cn } from "@/lib/cn";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
import { useDirection } from "@/hooks/useDirection";
type ProfileDropdownProps = {
onManageProfiles?: () => void;
@@ -19,6 +20,7 @@ type ProfileDropdownProps = {
const MANAGE_VALUE = "__manage_profiles__";
export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
const dir = useDirection();
const { t } = useTranslation();
const { activeProfile, activeProfileId, profiles, switchProfile, loaded } = useProfile();
const [open, setOpen] = useState(false);
@@ -116,6 +118,7 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
{sortedProfiles.length > 0 && (
<>
<ScrollArea.Root
dir={dir}
type={"auto"}
className={"-mx-1 overflow-hidden"}
>
@@ -252,7 +255,7 @@ const ProfileRow = ({ profile, isActive, onSelect }: ProfileRowProps) => {
value={profile.id}
onSelect={() => onSelect(profile.id)}
className={cn(
"flex w-auto gap-2 px-2 py-2 pr-3 last:mb-1",
"flex w-auto gap-2 px-2 py-2 pe-3 last:mb-1",
"cursor-default rounded-md text-sm outline-none",
"data-[selected=true]:bg-nb-gray-900",
showEmail ? "items-start" : "items-center",
@@ -78,7 +78,7 @@ export function ProfilesTab() {
return items;
}, [profiles, activeProfileId]);
const guarded = async (title: string, fn: () => Promise<void>) => {
const guarded = async (title: string, fn: () => Promise<unknown>) => {
if (busy) return;
setBusy(true);
try {
@@ -115,14 +115,15 @@ export function ProfilesTab() {
const handleDelete = async (id: string, name: string) => {
if (id === DEFAULT_PROFILE_ID) return;
const ok = await confirm({
title: t("profile.delete.title", { name }),
description: t("profile.delete.message", { name }),
confirmLabel: t("common.delete"),
danger: true,
});
if (!ok) return;
void guarded(i18next.t("profile.error.deleteTitle"), () => removeProfile(id));
await guarded(i18next.t("profile.error.deleteTitle"), () =>
confirm({
title: t("profile.delete.title", { name }),
description: t("profile.delete.message", { name }),
confirmLabel: t("common.delete"),
danger: true,
onConfirm: () => removeProfile(id),
}),
);
};
const handleCreate = async (name: string, managementUrl: string) => {
@@ -439,7 +440,7 @@ const ProfileRow = ({
{showEmail && <TruncatedEmail email={profile.email} />}
</div>
</td>
<td className={"shrink-0 text-right"}>
<td className={"shrink-0 text-end"}>
<RowActions
canSwitch={!isActive}
canDeregister={!!profile.email}
@@ -112,7 +112,7 @@ export function SettingsAbout() {
{daemonVersion === "development" ? (
<span>
{t("settings.about.clientName")}{" "}
<span className={"font-mono text-yellow-400"}>
<span dir={"auto"} className={"font-mono text-yellow-400"}>
{t("settings.about.development")}
</span>
</span>
@@ -124,7 +124,7 @@ export function SettingsAbout() {
{guiVersion === "development" ? (
<span>
{t("settings.about.guiName")}{" "}
<span className={"font-mono text-yellow-400"}>
<span dir={"auto"} className={"font-mono text-yellow-400"}>
{t("settings.about.development")}
</span>
</span>
@@ -1,5 +1,21 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import netbirdLogo from "@/assets/logos/netbird.svg";
const scratch = new Uint32Array(1);
function random() {
crypto.getRandomValues(scratch);
return scratch[0] / 2 ** 32;
}
type Mask = {
cols: number;
rows: number;
cells: Uint8Array;
seeds: Uint8Array;
glow: Float32Array;
};
export function useAccentTrigger() {
const clicksRef = useRef(0);
@@ -50,24 +66,45 @@ function Accent({ onDone }: Readonly<{ onDone: () => void }>) {
const ctx = canvas.getContext("2d");
if (!ctx) return;
const chars = "DRIBTENMAET".split("").reverse().join("");
let disposed = false;
let mask: Mask | null = null;
const dpr = window.devicePixelRatio || 1;
let columns = 0;
let drops: number[] = [];
let latestBuild = 0;
let rebuild: ReturnType<typeof setTimeout> | undefined;
const resize = () => {
canvas.width = window.innerWidth * dpr;
canvas.height = window.innerHeight * dpr;
canvas.style.width = `${window.innerWidth}px`;
canvas.style.height = `${window.innerHeight}px`;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const next = Math.floor(window.innerWidth / 15);
if (next !== columns) {
columns = next;
drops = Array.from({ length: columns }, () => random() * -60);
mask = null;
}
globalThis.clearTimeout(rebuild);
rebuild = globalThis.setTimeout(() => {
const build = ++latestBuild;
void buildMask().then((m) => {
if (!disposed && build === latestBuild) mask = m;
});
}, 100);
};
resize();
window.addEventListener("resize", resize);
const chars = "TEAMNETBIRD";
const fontSize = 16;
const columns = Math.floor(window.innerWidth / fontSize);
const drops = Array.from({ length: columns }, () => Math.random() * -50);
let raf = 0;
let last = 0;
let frame = 0;
const draw = (t: number) => {
if (t - last > 50) {
last = t;
@@ -77,18 +114,26 @@ function Accent({ onDone }: Readonly<{ onDone: () => void }>) {
ctx.fillRect(0, 0, window.innerWidth, window.innerHeight);
ctx.globalCompositeOperation = "source-over";
ctx.font = `${fontSize}px ui-monospace, monospace`;
ctx.fillStyle = "#f68330";
ctx.font = "15px ui-monospace, monospace";
ctx.textBaseline = "top";
ctx.shadowBlur = 0;
ctx.fillStyle = "rgba(246, 131, 48, 0.5)";
for (let i = 0; i < drops.length; i++) {
const ch = chars[Math.floor(Math.random() * chars.length)];
const y = drops[i] * fontSize;
ctx.fillText(ch, i * fontSize, y);
if (y > window.innerHeight && Math.random() > 0.975) {
drops[i] = 0;
const ch = chars[Math.floor(random() * chars.length)];
const y = drops[i] * 15;
ctx.fillText(ch, i * 15, y);
igniteTrail(mask, i, Math.floor(drops[i]));
if (y > window.innerHeight && random() > 0.86) {
drops[i] = random() * -12;
}
drops[i]++;
}
drawGlow(ctx, mask, frame, chars);
frame++;
}
raf = requestAnimationFrame(draw);
};
@@ -100,8 +145,10 @@ function Accent({ onDone }: Readonly<{ onDone: () => void }>) {
}, 9000);
return () => {
disposed = true;
cancelAnimationFrame(raf);
globalThis.clearTimeout(timeout);
globalThis.clearTimeout(rebuild);
window.removeEventListener("resize", resize);
};
}, [onDone]);
@@ -114,3 +161,103 @@ function Accent({ onDone }: Readonly<{ onDone: () => void }>) {
</div>
);
}
function igniteTrail(mask: Mask | null, col: number, row: number) {
if (!mask || col < 0 || col >= mask.cols) return;
for (let k = 0; k < 6; k++) {
const r = row - k;
if (r < 0 || r >= mask.rows) continue;
const idx = r * mask.cols + col;
if (mask.cells[idx] === 0) continue;
const heat = 1 - k / 6;
if (heat > mask.glow[idx]) mask.glow[idx] = heat;
}
}
function drawGlow(ctx: CanvasRenderingContext2D, mask: Mask | null, frame: number, chars: string) {
if (!mask) return;
for (let idx = 0; idx < mask.cells.length; idx++) {
const heat = fade(mask, idx);
if (heat === 0) continue;
const seed = mask.seeds[idx];
const core = mask.cells[idx] === 2;
ctx.shadowColor = core ? "#f05252" : "#f68330";
ctx.shadowBlur = 10 * heat;
ctx.fillStyle = core ? `rgba(255, 226, 210, ${heat})` : `rgba(255, 255, 255, ${heat})`;
ctx.fillText(
chars[(seed + Math.floor(frame / (3 + (seed % 5)))) % chars.length],
(idx % mask.cols) * 15,
Math.floor(idx / mask.cols) * 15,
);
}
ctx.shadowBlur = 0;
}
function fade(mask: Mask, idx: number) {
if (mask.cells[idx] === 0) return 0;
const heat = mask.glow[idx];
if (heat <= 0.02) {
mask.glow[idx] = 0;
return 0;
}
mask.glow[idx] = heat * 0.94;
return heat;
}
function loadLogo() {
return new Promise<HTMLImageElement>((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = netbirdLogo;
});
}
async function buildMask(): Promise<Mask | null> {
const cols = Math.floor(window.innerWidth / 15);
const rows = Math.ceil(window.innerHeight / 15);
if (cols <= 0 || rows <= 0) return null;
let img: HTMLImageElement;
try {
img = await loadLogo();
} catch {
return null;
}
const off = document.createElement("canvas");
off.width = cols;
off.height = rows;
const offCtx = off.getContext("2d", { willReadFrequently: true });
if (!offCtx) return null;
const aspect = (img.naturalWidth || 31) / (img.naturalHeight || 23);
let w = cols * 0.8;
let h = w / aspect;
if (h > rows * 0.8) {
h = rows * 0.8;
w = h * aspect;
}
offCtx.imageSmoothingEnabled = false;
offCtx.drawImage(img, (cols - w) / 2, (rows - h) / 2, w, h);
const { data } = offCtx.getImageData(0, 0, cols, rows);
const cells = new Uint8Array(cols * rows);
const seeds = new Uint8Array(cols * rows);
const glow = new Float32Array(cols * rows);
for (let i = 0; i < cells.length; i++) {
seeds[i] = Math.floor(random() * 251);
const alpha = data[i * 4 + 3];
if (alpha < 64) continue;
const r = data[i * 4];
const g = data[i * 4 + 1];
const b = data[i * 4 + 2];
cells[i] = r > 180 && g < 130 && b < 130 && g <= b + 24 ? 2 : 1;
}
return { cols, rows, cells, seeds, glow };
}
@@ -102,6 +102,7 @@ export function SettingsAdvanced() {
<SectionGroup title={t("settings.advanced.section.interface")}>
<Input
label={t("settings.advanced.interfaceName.label")}
dir={"ltr"}
value={values.interfaceName}
error={errors.interfaceName}
onChange={(e) => setValues((v) => ({ ...v, interfaceName: e.target.value }))}
@@ -151,6 +152,7 @@ export function SettingsAdvanced() {
<HelpText>{t("settings.advanced.psk.help")}</HelpText>
<Input
type={"password"}
dir={"ltr"}
showPasswordToggle={pskInputValue !== PSK_MASK}
placeholder={"kQv0qF3oQpJYdgD5mC9hL7sB2xZ8nT4eU6wY1aR3jK0="}
value={pskInputValue}
@@ -88,6 +88,7 @@ export function SettingsGeneral() {
<div className={"mt-2 flex items-start gap-3"}>
<Input
id={managementUrlId}
dir={"ltr"}
ref={inputRef}
value={displayUrl}
onChange={(e) => setUrl(e.target.value)}
@@ -17,6 +17,7 @@ import { SettingsAdvanced } from "@/modules/settings/SettingsAdvanced.tsx";
import { SettingsTroubleshooting } from "@/modules/settings/SettingsTroubleshooting.tsx";
import { SettingsAbout } from "@/modules/settings/SettingsAbout.tsx";
import { useRestrictions } from "@/contexts/RestrictionsContext.tsx";
import { useDirection } from "@/hooks/useDirection";
const EVENT_SETTINGS_OPEN = "netbird:settings:open";
@@ -43,6 +44,7 @@ const TAB_CONTENT: Record<Tab, ReactNode> = {
};
export const SettingsPage = () => {
const dir = useDirection();
const location = useLocation();
const navState = location.state as { tab?: string } | null;
const { mdm, features } = useRestrictions();
@@ -94,6 +96,7 @@ export const SettingsPage = () => {
<AutostartSettingsProvider>
<SettingsProvider>
<ScrollArea.Root
dir={dir}
key={active}
type={"auto"}
className={"min-h-0 flex-1 overflow-hidden"}
@@ -30,7 +30,7 @@ export const SectionGroup = ({
export const SettingsBottomBar = ({ children }: { children: ReactNode }) => (
<>
<div className={"h-[3.2rem] shrink-0"} aria-hidden={"true"} />
<div className={"absolute bottom-0 left-0 w-full"}>
<div className={"absolute bottom-0 start-0 w-full"}>
<div
className={
"flex w-full justify-end gap-3 border-t border-nb-gray-800 bg-nb-gray-940 px-8 py-5 dark:border-nb-gray-920"
@@ -1,6 +1,15 @@
import { useId, type ReactNode } from "react";
import { Trans, useTranslation } from "react-i18next";
import { ChevronDown, CircleCheckBig, FolderOpen, Info, Loader2 } from "lucide-react";
import {
CircleCheckBig,
FolderOpen,
Info,
Loader2,
Shield,
ShieldCheck,
ShieldOff,
type LucideIcon,
} from "lucide-react";
import { Browser } from "@wailsio/runtime";
import { Debug as DebugSvc } from "@bindings/services";
import type { DebugBundleResult } from "@bindings/services/models.js";
@@ -8,20 +17,13 @@ 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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/DropdownMenu";
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
import HelpText from "@/components/typography/HelpText.tsx";
import { Input } from "@/components/inputs/Input";
import { Label } from "@/components/typography/Label";
import { Select } from "@/components/inputs/Select";
import { SquareIcon } from "@/components/SquareIcon";
import { Tooltip } from "@/components/Tooltip";
import { cn } from "@/lib/cn";
import { formatRemaining } from "@/lib/formatters";
import type { AnonymizeLevel, DebugStage } from "@/contexts/DebugBundleContext";
import { useDebugBundleContext } from "@/contexts/DebugBundleContext";
@@ -29,6 +31,12 @@ import { SectionGroup, SettingsBottomBar } from "@/modules/settings/SettingsSect
const SUPPORT_DOCS_URL = "https://docs.netbird.io/help/report-bug-issues";
const ANONYMIZE_LEVELS: { value: AnonymizeLevel; icon: LucideIcon }[] = [
{ value: "none", icon: ShieldOff },
{ value: "default", icon: Shield },
{ value: "strict", icon: ShieldCheck },
];
export function SettingsTroubleshooting() {
const { t } = useTranslation();
const durationId = useId();
@@ -89,44 +97,16 @@ export function SettingsTroubleshooting() {
</HelpText>
</div>
<div className={"shrink-0"}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type={"button"}
aria-label={t("settings.troubleshooting.anonymize.label")}
className={cn(
"inline-flex h-[40px] min-w-[160px] items-center justify-between gap-2 px-3",
"rounded-md border bg-white dark:bg-nb-gray-900",
"border-neutral-200 dark:border-nb-gray-700",
"cursor-default text-xs font-semibold text-nb-gray-100 outline-none",
"hover:border-nb-gray-700 data-[state=open]:border-nb-gray-700 dark:hover:border-nb-gray-600 dark:data-[state=open]:border-nb-gray-600",
)}
>
{t(`settings.troubleshooting.anonymize.${anonymizeLevel}`)}
<ChevronDown
size={16}
aria-hidden={"true"}
className={"shrink-0 text-nb-gray-200"}
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align={"end"} className={"min-w-[160px]"}>
<DropdownMenuRadioGroup
value={anonymizeLevel}
onValueChange={(v) => setAnonymizeLevel(v as AnonymizeLevel)}
>
<DropdownMenuRadioItem value={"none"}>
{t("settings.troubleshooting.anonymize.none")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value={"default"}>
{t("settings.troubleshooting.anonymize.default")}
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value={"strict"}>
{t("settings.troubleshooting.anonymize.strict")}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<Select
value={anonymizeLevel}
options={ANONYMIZE_LEVELS.map(({ value, icon }) => ({
value,
icon,
label: t(`settings.troubleshooting.anonymize.${value}`),
}))}
onChange={setAnonymizeLevel}
ariaLabel={t("settings.troubleshooting.anonymize.label")}
/>
</div>
</div>
<FancyToggleSwitch
@@ -318,10 +298,11 @@ function DoneResult({
</div>
<div className={"flex w-full max-w-sm flex-col gap-3"}>
{showKey && <Input value={result.uploadedKey} readOnly copy />}
{showKey && <Input dir={"ltr"} value={result.uploadedKey} readOnly copy />}
{result.path && !showKey && (
<Input
dir={"ltr"}
value={result.path}
readOnly
aria-label={t("settings.troubleshooting.done.savedTitle")}
@@ -90,10 +90,10 @@ export function WelcomeStepManagement({
return (
<>
<div className={cn("flex flex-col items-center gap-1", isMacOS() && "mt-4")}>
<DialogHeading id={"nb-welcome-management-title"} align={"left"}>
<DialogHeading id={"nb-welcome-management-title"} align={"start"}>
{t("welcome.management.title")}
</DialogHeading>
<DialogDescription align={"left"}>
<DialogDescription align={"start"}>
{t("welcome.management.description")}
</DialogDescription>
</div>
@@ -103,9 +103,10 @@ export function WelcomeStepManagement({
</div>
{mode === ManagementMode.SelfHosted && (
<div className={"wails-no-draggable w-full text-left"}>
<div className={"wails-no-draggable w-full text-start"}>
<Input
ref={inputRef}
dir={"ltr"}
placeholder={t("welcome.management.urlPlaceholder")}
value={url}
onChange={(e) => setUrl(e.target.value)}
@@ -38,10 +38,10 @@ export function WelcomeStepTray({ onContinue }: Readonly<WelcomeStepTrayProps>)
</div>
<div className={"flex w-full flex-col gap-1"}>
<DialogHeading id={"nb-welcome-title"} align={"left"}>
<DialogHeading id={"nb-welcome-title"} align={"start"}>
{t(titleKey)}
</DialogHeading>
<DialogDescription align={"left"}>{t(descriptionKey)}</DialogDescription>
<DialogDescription align={"start"}>{t(descriptionKey)}</DialogDescription>
</div>
<DialogActions>