mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-18 20:59:07 +02:00
[management,client] 0.75.0 release with new desktop UI (#6473)
- **Wails v3 application** (`client/ui`) with a React + TypeScript + Tailwind frontend replacing the Fyne UI: main connection view, exit-node switcher, networks/peers browser with detail panels, profile management, settings (general, network, SSH, security, troubleshooting, appearance), debug-bundle creation, and a first-run welcome flow. - **Internationalization**: go-i18n bundle with 9 locales (en, de, es, fr, hu, it, pt, ru, zh-CN) shared between the tray and the frontend. - **New system tray** implementation with per-platform theme-aware icons, including a native XEmbed host for Linux (`xembed_tray_linux.c`) and a Linux theme watcher. - **Session handling**: auth session watcher (`client/internal/auth/sessionwatch`), pending login flow, session-expiration dialog and tray notifications, and `netbird login` improvements. - **Daemon API extensions** (`daemon.proto`): status stream subscription, event stream, networks/exit-node selection endpoints, and richer full status — with probe throttling on the daemon side to protect against UI-driven request storms. - **UI preferences store** persisted per profile, autostart management via the daemon (single source of truth in HKCU on Windows). - **Build system**: Taskfile-based builds per platform (macOS, Linux, Windows), Docker cross-compilation images, MSIX/NSIS/nfpm/AppImage packaging, and a new `frontend-ui` CI workflow. Co-authored-by: Zoltan Papp <zoltan.pmail@gmail.com> Co-authored-by: Eduard Gert <kontakt@eduardgert.de> Co-authored-by: braginini <bangvalo@gmail.com> Co-authored-by: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Co-authored-by: riccardom <riccardomanfrin@gmail.com>
This commit is contained in:
co-authored by
Zoltan Papp
Eduard Gert
braginini
Pascal Fischer
riccardom
parent
c9d387bd0d
commit
91acb8147c
@@ -0,0 +1,374 @@
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Check, ChevronDown, ChevronUp, Copy, Eye, EyeOff } from "lucide-react";
|
||||
import {
|
||||
forwardRef,
|
||||
type InputHTMLAttributes,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Label } from "@/components/typography/Label";
|
||||
|
||||
type InputVariants = VariantProps<typeof inputVariants>;
|
||||
|
||||
export interface InputProps extends InputHTMLAttributes<HTMLInputElement>, InputVariants {
|
||||
label?: string;
|
||||
customPrefix?: ReactNode;
|
||||
customSuffix?: ReactNode;
|
||||
maxWidthClass?: string;
|
||||
icon?: ReactNode;
|
||||
error?: string;
|
||||
warning?: string;
|
||||
prefixClassName?: string;
|
||||
showPasswordToggle?: boolean;
|
||||
copy?: boolean;
|
||||
}
|
||||
|
||||
const inputVariants = cva("", {
|
||||
variants: {
|
||||
variant: {
|
||||
default: [
|
||||
"border-neutral-200 placeholder:text-neutral-500 dark:border-nb-gray-700 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70",
|
||||
"ring-offset-neutral-200/20 focus-visible:ring-neutral-300/10 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20",
|
||||
],
|
||||
darker: [
|
||||
"border-neutral-300 placeholder:text-neutral-500 dark:border-nb-gray-800 dark:bg-nb-gray-920 dark:placeholder:text-neutral-400/70",
|
||||
"ring-offset-neutral-200/20 focus-visible:ring-neutral-300/10 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20",
|
||||
],
|
||||
error: [
|
||||
"border-neutral-200 text-red-500 placeholder:text-neutral-500 dark:border-red-500 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70",
|
||||
"ring-offset-red-500/10 focus-visible:ring-red-500/10 dark:ring-offset-red-500/10 dark:focus-visible:ring-red-500/10",
|
||||
],
|
||||
warning: [
|
||||
"border-neutral-200 text-orange-400 placeholder:text-neutral-500 dark:border-orange-400 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70",
|
||||
"ring-offset-orange-400/10 focus-visible:ring-orange-400/10 dark:ring-offset-orange-400/10 dark:focus-visible:ring-orange-400/10",
|
||||
],
|
||||
},
|
||||
prefixSuffixVariant: {
|
||||
default: [
|
||||
"border-neutral-200 text-nb-gray-300 dark:border-nb-gray-700 dark:bg-nb-gray-900",
|
||||
],
|
||||
error: ["border-red-500 text-nb-gray-300 text-red-500 dark:bg-nb-gray-900"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function computeNextStepValue(el: HTMLInputElement, delta: 1 | -1): number {
|
||||
const stepAttr = el.step === "" ? 1 : Number(el.step);
|
||||
const step = Number.isFinite(stepAttr) && stepAttr > 0 ? stepAttr : 1;
|
||||
const min = el.min === "" ? -Infinity : Number(el.min);
|
||||
const max = el.max === "" ? Infinity : Number(el.max);
|
||||
const current = el.value === "" ? 0 : Number(el.value);
|
||||
let next = (Number.isFinite(current) ? current : 0) + delta * step;
|
||||
if (next < min) next = min;
|
||||
if (next > max) next = max;
|
||||
return next;
|
||||
}
|
||||
|
||||
function buildInputClassName(
|
||||
opts: Readonly<{
|
||||
variant: InputVariants["variant"];
|
||||
hasCustomPrefix: boolean;
|
||||
hasSuffix: boolean;
|
||||
hasIcon: boolean;
|
||||
readOnly?: boolean;
|
||||
showStepper: boolean;
|
||||
className?: string;
|
||||
}>,
|
||||
): string {
|
||||
return cn(
|
||||
inputVariants({ variant: opts.variant }),
|
||||
"flex h-[40px] w-full select-text rounded-md bg-white px-3 py-2 text-sm",
|
||||
"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",
|
||||
"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",
|
||||
opts.className,
|
||||
);
|
||||
}
|
||||
|
||||
function InputAffix({
|
||||
content,
|
||||
error,
|
||||
disabled,
|
||||
className,
|
||||
}: Readonly<{ content: ReactNode; error?: string; disabled?: boolean; className?: string }>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
inputVariants({ prefixSuffixVariant: error ? "error" : "default" }),
|
||||
"flex h-[40px] w-auto rounded-l-md bg-white px-3 py-2 text-sm",
|
||||
"items-center whitespace-nowrap border",
|
||||
disabled && "opacity-40",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InputIconSlot({ icon, disabled }: Readonly<{ icon: ReactNode; disabled?: boolean }>) {
|
||||
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",
|
||||
disabled && "opacity-40",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InputSuffixSlot({
|
||||
suffix,
|
||||
disabled,
|
||||
}: Readonly<{ suffix: ReactNode; disabled?: boolean }>) {
|
||||
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",
|
||||
disabled && "opacity-30",
|
||||
)}
|
||||
>
|
||||
{suffix}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberStepper({
|
||||
error,
|
||||
disabled,
|
||||
onStep,
|
||||
}: Readonly<{ error?: string; disabled?: boolean; onStep: (delta: 1 | -1) => void }>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-[40px] shrink-0 flex-col overflow-hidden",
|
||||
"rounded-r-md border border-l-0",
|
||||
"border-neutral-200 dark:border-nb-gray-700 dark:bg-nb-gray-900",
|
||||
error && "dark:border-red-500",
|
||||
disabled && "pointer-events-none opacity-40",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type={"button"}
|
||||
tabIndex={-1}
|
||||
aria-label={t("common.increase")}
|
||||
onClick={() => onStep(1)}
|
||||
className={
|
||||
"flex w-9 flex-1 cursor-default items-center justify-center text-nb-gray-300 transition-colors hover:bg-nb-gray-800"
|
||||
}
|
||||
>
|
||||
<ChevronUp size={12} aria-hidden={"true"} />
|
||||
</button>
|
||||
<button
|
||||
type={"button"}
|
||||
tabIndex={-1}
|
||||
aria-label={t("common.decrease")}
|
||||
onClick={() => onStep(-1)}
|
||||
className={cn(
|
||||
"flex w-9 flex-1 cursor-default items-center justify-center text-nb-gray-300 transition-colors hover:bg-nb-gray-800",
|
||||
"border-t border-neutral-200 dark:border-nb-gray-700",
|
||||
)}
|
||||
>
|
||||
<ChevronDown size={12} aria-hidden={"true"} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldMessage({
|
||||
id,
|
||||
error,
|
||||
warning,
|
||||
}: Readonly<{ id?: string; error?: string; warning?: string }>) {
|
||||
if (!error && !warning) return null;
|
||||
return (
|
||||
<span
|
||||
id={id}
|
||||
role={error ? "alert" : "status"}
|
||||
className={cn(
|
||||
"mt-2 inline-flex items-center gap-1 text-xs",
|
||||
error ? "text-red-500" : "text-orange-400",
|
||||
)}
|
||||
>
|
||||
{error ?? warning}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
{
|
||||
className,
|
||||
type,
|
||||
label,
|
||||
customSuffix,
|
||||
customPrefix,
|
||||
icon,
|
||||
maxWidthClass = "",
|
||||
error,
|
||||
warning,
|
||||
variant = "default",
|
||||
prefixClassName,
|
||||
showPasswordToggle = false,
|
||||
copy = false,
|
||||
id,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const isPasswordType = type === "password";
|
||||
const inputType = isPasswordType && showPassword ? "text" : type;
|
||||
const isNumber = type === "number";
|
||||
|
||||
const reactId = useId();
|
||||
const fallbackId = `input-${reactId}`;
|
||||
const inputId = id ?? (label ? fallbackId : undefined);
|
||||
const messageId = error || warning ? `${inputId ?? fallbackId}-message` : undefined;
|
||||
|
||||
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (copyTimer.current) clearTimeout(copyTimer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const internalRef = useRef<HTMLInputElement | null>(null);
|
||||
const setRefs = (el: HTMLInputElement | null) => {
|
||||
internalRef.current = el;
|
||||
if (typeof ref === "function") ref(el);
|
||||
else if (ref) ref.current = el;
|
||||
};
|
||||
|
||||
const stepBy = (delta: 1 | -1) => {
|
||||
const el = internalRef.current;
|
||||
if (!el || el.disabled || el.readOnly) return;
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
globalThis.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
const next = computeNextStepValue(el, delta);
|
||||
setter?.call(el, String(next));
|
||||
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
};
|
||||
|
||||
const passwordToggle =
|
||||
isPasswordType && showPasswordToggle ? (
|
||||
<button
|
||||
type={"button"}
|
||||
onClick={() => setShowPassword((s) => !s)}
|
||||
className={"pointer-events-auto transition-all hover:text-white"}
|
||||
aria-label={t("common.togglePasswordVisibility")}
|
||||
aria-pressed={showPassword}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff size={18} aria-hidden={"true"} />
|
||||
) : (
|
||||
<Eye size={18} aria-hidden={"true"} />
|
||||
)}
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
const onCopy = async () => {
|
||||
const text = props.value == null ? (internalRef.current?.value ?? "") : String(props.value);
|
||||
if (!text) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
if (copyTimer.current) clearTimeout(copyTimer.current);
|
||||
copyTimer.current = setTimeout(() => setCopied(false), 1500);
|
||||
} catch (e) {
|
||||
console.warn("copy to clipboard failed", e);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToggle = copy ? (
|
||||
<button
|
||||
type={"button"}
|
||||
onClick={onCopy}
|
||||
className={"pointer-events-auto transition-all hover:text-white"}
|
||||
aria-label={t("common.copy")}
|
||||
>
|
||||
{copied ? (
|
||||
<Check size={16} aria-hidden={"true"} />
|
||||
) : (
|
||||
<Copy size={16} aria-hidden={"true"} />
|
||||
)}
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
const suffix = passwordToggle || copyToggle || customSuffix;
|
||||
const showStepper = isNumber;
|
||||
const warningVariant = warning ? "warning" : variant;
|
||||
const resolvedVariant = error ? "error" : warningVariant;
|
||||
|
||||
const inputClassName = buildInputClassName({
|
||||
variant: resolvedVariant,
|
||||
hasCustomPrefix: !!customPrefix,
|
||||
hasSuffix: !!suffix,
|
||||
hasIcon: !!icon,
|
||||
readOnly: props.readOnly,
|
||||
showStepper,
|
||||
className,
|
||||
});
|
||||
|
||||
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)}>
|
||||
{customPrefix && (
|
||||
<InputAffix
|
||||
content={customPrefix}
|
||||
error={error}
|
||||
disabled={props.disabled}
|
||||
className={prefixClassName}
|
||||
/>
|
||||
)}
|
||||
|
||||
{icon && <InputIconSlot icon={icon} disabled={props.disabled} />}
|
||||
|
||||
<div className={"relative flex min-w-0 flex-grow"}>
|
||||
<input
|
||||
id={inputId}
|
||||
type={inputType}
|
||||
ref={setRefs}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={
|
||||
messageId
|
||||
? [props["aria-describedby"], messageId].filter(Boolean).join(" ")
|
||||
: props["aria-describedby"]
|
||||
}
|
||||
{...props}
|
||||
className={inputClassName}
|
||||
/>
|
||||
|
||||
{suffix && <InputSuffixSlot suffix={suffix} disabled={props.disabled} />}
|
||||
</div>
|
||||
|
||||
{showStepper && (
|
||||
<NumberStepper error={error} disabled={props.disabled} onStep={stepBy} />
|
||||
)}
|
||||
</div>
|
||||
<FieldMessage id={messageId} error={error} warning={warning} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default Input;
|
||||
@@ -0,0 +1,59 @@
|
||||
import { forwardRef, type InputHTMLAttributes, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SearchIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
type Props = InputHTMLAttributes<HTMLInputElement> & {
|
||||
iconSize?: number;
|
||||
shortcut?: ReactNode;
|
||||
};
|
||||
|
||||
export const SearchInput = forwardRef<HTMLInputElement, Props>(function SearchInput(
|
||||
{ iconSize = 16, className, disabled, shortcut, "aria-label": ariaLabel, ...props },
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
role={"search"}
|
||||
className={cn("flex h-10 items-center gap-2 px-1", disabled && "opacity-50")}
|
||||
>
|
||||
<SearchIcon
|
||||
size={iconSize}
|
||||
aria-hidden={"true"}
|
||||
className={"shrink-0 text-nb-gray-300"}
|
||||
/>
|
||||
<input
|
||||
ref={ref}
|
||||
type={"search"}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel ?? props.placeholder ?? t("common.search")}
|
||||
autoCorrect={"off"}
|
||||
autoCapitalize={"off"}
|
||||
spellCheck={false}
|
||||
{...props}
|
||||
className={cn(
|
||||
"w-full bg-transparent text-sm text-nb-gray-200 placeholder:text-nb-gray-400",
|
||||
"border-none outline-none",
|
||||
disabled && "cursor-not-allowed",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
{shortcut && (
|
||||
<span
|
||||
aria-hidden={"true"}
|
||||
className={cn(
|
||||
"shrink-0 select-none",
|
||||
"inline-flex items-center justify-center",
|
||||
"h-5 min-w-[20px] rounded px-1.5",
|
||||
"border border-nb-gray-850 bg-nb-gray-920",
|
||||
"text-[10px] font-medium text-nb-gray-400",
|
||||
"wails-no-draggable",
|
||||
)}
|
||||
>
|
||||
{shortcut}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user