mirror of
https://github.com/netbirdio/netbird.git
synced 2026-04-19 08:46:38 +00:00
[management, reverse proxy] Add reverse proxy feature (#5291)
* implement reverse proxy --------- Co-authored-by: Alisdair MacLeod <git@alisdairmacleod.co.uk> Co-authored-by: mlsmaycon <mlsmaycon@gmail.com> Co-authored-by: Eduard Gert <kontakt@eduardgert.de> Co-authored-by: Viktor Liu <viktor@netbird.io> Co-authored-by: Diego Noguês <diego.sure@gmail.com> Co-authored-by: Diego Noguês <49420+diegocn@users.noreply.github.com> Co-authored-by: Bethuel Mmbaga <bethuelmbaga12@gmail.com> Co-authored-by: Zoltan Papp <zoltan.pmail@gmail.com> Co-authored-by: Ashley Mensah <ashleyamo982@gmail.com>
This commit is contained in:
156
proxy/web/src/components/Button.tsx
Normal file
156
proxy/web/src/components/Button.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { cn } from "@/utils/helpers";
|
||||
import { forwardRef } from "react";
|
||||
|
||||
type Variant =
|
||||
| "default"
|
||||
| "primary"
|
||||
| "secondary"
|
||||
| "secondaryLighter"
|
||||
| "input"
|
||||
| "dropdown"
|
||||
| "dotted"
|
||||
| "tertiary"
|
||||
| "white"
|
||||
| "outline"
|
||||
| "danger-outline"
|
||||
| "danger-text"
|
||||
| "default-outline"
|
||||
| "danger";
|
||||
|
||||
type Size = "xs" | "xs2" | "sm" | "md" | "lg";
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
rounded?: boolean;
|
||||
border?: 0 | 1 | 2;
|
||||
disabled?: boolean;
|
||||
stopPropagation?: boolean;
|
||||
}
|
||||
|
||||
const baseStyles = [
|
||||
"relative cursor-pointer",
|
||||
"text-sm focus:z-10 focus:ring-2 font-medium focus:outline-none whitespace-nowrap shadow-sm",
|
||||
"inline-flex gap-2 items-center justify-center transition-colors focus:ring-offset-1",
|
||||
"disabled:opacity-40 disabled:cursor-not-allowed disabled:text-nb-gray-300 ring-offset-neutral-950/50",
|
||||
];
|
||||
|
||||
const variantStyles: Record<Variant, string[]> = {
|
||||
default: [
|
||||
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900",
|
||||
"dark:focus:ring-zinc-800/50 dark:bg-nb-gray dark:text-gray-400 dark:border-gray-700/30 dark:hover:text-white dark:hover:bg-zinc-800/50",
|
||||
],
|
||||
primary: [
|
||||
"dark:focus:ring-netbird-600/50 dark:ring-offset-neutral-950/50 enabled:dark:bg-netbird disabled:dark:bg-nb-gray-910 dark:text-gray-100 enabled:dark:hover:text-white enabled:dark:hover:bg-netbird-500/80",
|
||||
"enabled:bg-netbird enabled:text-white enabled:focus:ring-netbird-400/50 enabled:hover:bg-netbird-500",
|
||||
],
|
||||
secondary: [
|
||||
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
|
||||
"dark:bg-nb-gray-920 dark:text-gray-400 dark:border-gray-700/40 dark:hover:text-white dark:hover:bg-nb-gray-910",
|
||||
],
|
||||
secondaryLighter: [
|
||||
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
|
||||
"dark:bg-nb-gray-900/70 dark:text-gray-400 dark:border-gray-700/70 dark:hover:text-white dark:hover:bg-nb-gray-800/60",
|
||||
],
|
||||
input: [
|
||||
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-neutral-200 text-gray-900",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
|
||||
"dark:bg-nb-gray-900 dark:text-gray-400 dark:border-nb-gray-700 dark:hover:bg-nb-gray-900/80",
|
||||
],
|
||||
dropdown: [
|
||||
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-neutral-200 text-gray-900",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
|
||||
"dark:bg-nb-gray-900/40 dark:text-gray-400 dark:border-nb-gray-900 dark:hover:bg-nb-gray-900/50",
|
||||
],
|
||||
dotted: [
|
||||
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900 border-dashed",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
|
||||
"dark:bg-nb-gray-900/30 dark:text-gray-400 dark:border-gray-500/40 dark:hover:text-white dark:hover:bg-zinc-800/50",
|
||||
],
|
||||
tertiary: [
|
||||
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900",
|
||||
"dark:focus:ring-zinc-800/50 dark:bg-white dark:text-gray-800 dark:border-gray-700/40 dark:hover:bg-neutral-200 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300",
|
||||
],
|
||||
white: [
|
||||
"focus:ring-white/50 bg-white text-gray-800 border-white outline-none hover:bg-neutral-200 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300",
|
||||
"disabled:dark:bg-nb-gray-900 disabled:dark:text-nb-gray-300 disabled:dark:border-nb-gray-900",
|
||||
],
|
||||
outline: [
|
||||
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900",
|
||||
"dark:focus:ring-zinc-800/50 dark:bg-transparent dark:text-netbird dark:border-netbird dark:hover:bg-nb-gray-900/30",
|
||||
],
|
||||
"danger-outline": [
|
||||
"enabled:dark:focus:ring-red-800/20 enabled:dark:focus:bg-red-950/40 enabled:hover:dark:bg-red-950/50 enabled:dark:hover:border-red-800/50 dark:bg-transparent dark:text-red-500",
|
||||
],
|
||||
"danger-text": [
|
||||
"dark:bg-transparent dark:text-red-500 dark:hover:text-red-600 dark:border-transparent !px-0 !shadow-none !py-0 focus:ring-red-500/30 dark:ring-offset-neutral-950/50",
|
||||
],
|
||||
"default-outline": [
|
||||
"dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20",
|
||||
"dark:bg-transparent dark:text-nb-gray-400 dark:border-transparent dark:hover:text-white dark:hover:bg-nb-gray-900/30 dark:hover:border-nb-gray-800/50",
|
||||
"data-[state=open]:dark:text-white data-[state=open]:dark:bg-nb-gray-900/30 data-[state=open]:dark:border-nb-gray-800/50",
|
||||
],
|
||||
danger: [
|
||||
"dark:focus:ring-red-700/20 dark:focus:bg-red-700 hover:dark:bg-red-700 dark:hover:border-red-800/50 dark:bg-red-600 dark:text-red-100",
|
||||
],
|
||||
};
|
||||
|
||||
const sizeStyles: Record<Size, string> = {
|
||||
xs: "text-xs py-2 px-4",
|
||||
xs2: "text-[0.78rem] py-2 px-4",
|
||||
sm: "text-sm py-2.5 px-4",
|
||||
md: "text-sm py-2.5 px-4",
|
||||
lg: "text-base py-2.5 px-4",
|
||||
};
|
||||
|
||||
const borderStyles: Record<0 | 1 | 2, string> = {
|
||||
0: "border",
|
||||
1: "border border-transparent",
|
||||
2: "border border-t-0 border-b-0",
|
||||
};
|
||||
|
||||
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{
|
||||
variant = "default",
|
||||
rounded = true,
|
||||
border = 1,
|
||||
size = "md",
|
||||
stopPropagation = true,
|
||||
className,
|
||||
onClick,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
baseStyles,
|
||||
variantStyles[variant],
|
||||
sizeStyles[size],
|
||||
borderStyles[border ? 1 : 0],
|
||||
rounded && "rounded-md",
|
||||
className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (stopPropagation) e.stopPropagation();
|
||||
onClick?.(e);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Button.displayName = "Button";
|
||||
|
||||
export default Button;
|
||||
23
proxy/web/src/components/Card.tsx
Normal file
23
proxy/web/src/components/Card.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { cn } from "@/utils/helpers";
|
||||
import { GradientFadedBackground } from "@/components/GradientFadedBackground";
|
||||
|
||||
export const Card = ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"px-6 sm:px-10 py-10 pt-8",
|
||||
"bg-nb-gray-940 border border-nb-gray-910 rounded-lg relative",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<GradientFadedBackground />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
26
proxy/web/src/components/ConnectionLine.tsx
Normal file
26
proxy/web/src/components/ConnectionLine.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { X } from "lucide-react";
|
||||
|
||||
interface ConnectionLineProps {
|
||||
success?: boolean;
|
||||
}
|
||||
|
||||
export function ConnectionLine({ success = true }: Readonly<ConnectionLineProps>) {
|
||||
if (success) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center h-12 w-full px-5">
|
||||
<div className="w-full border-t-2 border-dashed border-green-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center h-12 min-w-10 px-5 relative">
|
||||
<div className="w-full border-t-2 border-dashed border-nb-gray-900" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center">
|
||||
<X size={18} className="text-netbird" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
proxy/web/src/components/Description.tsx
Normal file
14
proxy/web/src/components/Description.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { cn } from "@/utils/helpers";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function Description({ children, className }: Readonly<Props>) {
|
||||
return (
|
||||
<div className={cn("text-sm text-nb-gray-300 font-light mt-2 block text-center z-10 relative", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
proxy/web/src/components/ErrorMessage.tsx
Normal file
7
proxy/web/src/components/ErrorMessage.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export const ErrorMessage = ({ error }: { error?: string }) => {
|
||||
return (
|
||||
<div className="text-red-400 bg-red-800/20 border border-red-800/50 rounded-lg px-4 py-3 whitespace-break-spaces text-sm">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
22
proxy/web/src/components/GradientFadedBackground.tsx
Normal file
22
proxy/web/src/components/GradientFadedBackground.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { cn } from "@/utils/helpers";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const GradientFadedBackground = ({ className }: Props) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-full w-full absolute left-0 top-0 rounded-md overflow-hidden z-0 pointer-events-none",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"bg-linear-to-b from-nb-gray-900/10 via-transparent to-transparent w-full h-full rounded-md"
|
||||
}
|
||||
></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
19
proxy/web/src/components/HelpText.tsx
Normal file
19
proxy/web/src/components/HelpText.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { cn } from "@/utils/helpers";
|
||||
|
||||
interface HelpTextProps {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function HelpText({ children, className }: Readonly<HelpTextProps>) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[.8rem] text-nb-gray-300 block font-light tracking-wide",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
137
proxy/web/src/components/Input.tsx
Normal file
137
proxy/web/src/components/Input.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { cn } from "@/utils/helpers";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
customPrefix?: React.ReactNode;
|
||||
customSuffix?: React.ReactNode;
|
||||
maxWidthClass?: string;
|
||||
icon?: React.ReactNode;
|
||||
error?: string;
|
||||
prefixClassName?: string;
|
||||
showPasswordToggle?: boolean;
|
||||
variant?: "default" | "darker";
|
||||
}
|
||||
|
||||
const variantStyles = {
|
||||
default: [
|
||||
"bg-nb-gray-900 placeholder:text-neutral-400/70 border-nb-gray-700",
|
||||
"ring-offset-neutral-950/50 focus-visible:ring-neutral-500/20",
|
||||
],
|
||||
darker: [
|
||||
"bg-nb-gray-920 placeholder:text-neutral-400/70 border-nb-gray-800",
|
||||
"ring-offset-neutral-950/50 focus-visible:ring-neutral-500/20",
|
||||
],
|
||||
error: [
|
||||
"bg-nb-gray-900 placeholder:text-neutral-400/70 border-red-500 text-red-500",
|
||||
"ring-offset-red-500/10 focus-visible:ring-red-500/10",
|
||||
],
|
||||
};
|
||||
|
||||
const prefixSuffixStyles = {
|
||||
default: "bg-nb-gray-900 border-nb-gray-700 text-nb-gray-300",
|
||||
error: "bg-nb-gray-900 border-red-500 text-nb-gray-300 text-red-500",
|
||||
};
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
type,
|
||||
customSuffix,
|
||||
customPrefix,
|
||||
icon,
|
||||
maxWidthClass = "",
|
||||
error,
|
||||
variant = "default",
|
||||
prefixClassName,
|
||||
showPasswordToggle = false,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const isPasswordType = type === "password";
|
||||
const inputType = isPasswordType && showPassword ? "text" : type;
|
||||
|
||||
const passwordToggle =
|
||||
isPasswordType && showPasswordToggle ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="hover:text-white transition-all"
|
||||
aria-label="Toggle password visibility"
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
const suffix = passwordToggle || customSuffix;
|
||||
const activeVariant = error ? "error" : variant;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn("flex relative h-[42px]", maxWidthClass)}>
|
||||
{customPrefix && (
|
||||
<div
|
||||
className={cn(
|
||||
prefixSuffixStyles[error ? "error" : "default"],
|
||||
"flex h-[42px] w-auto rounded-l-md px-3 py-2 text-sm",
|
||||
"border items-center whitespace-nowrap",
|
||||
props.disabled && "opacity-40",
|
||||
prefixClassName
|
||||
)}
|
||||
>
|
||||
{customPrefix}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-0 top-0 h-full flex items-center text-xs text-nb-gray-300 pl-3 leading-[0]",
|
||||
props.disabled && "opacity-40"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
|
||||
<input
|
||||
type={inputType}
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={cn(
|
||||
variantStyles[activeVariant],
|
||||
"flex h-[42px] w-full rounded-md px-3 py-2 text-sm",
|
||||
"file:bg-transparent file:text-sm file:font-medium file:border-0",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
|
||||
"disabled:cursor-not-allowed disabled:opacity-40",
|
||||
"border",
|
||||
customPrefix && "!border-l-0 !rounded-l-none",
|
||||
suffix && "!pr-16",
|
||||
icon && "!pl-10",
|
||||
className
|
||||
)}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-0 top-0 h-full flex items-center text-xs text-nb-gray-300 pr-4 leading-[0] select-none",
|
||||
props.disabled && "opacity-30"
|
||||
)}
|
||||
>
|
||||
{suffix}
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-xs text-red-500 mt-2">{error}</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
19
proxy/web/src/components/Label.tsx
Normal file
19
proxy/web/src/components/Label.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { cn } from "@/utils/helpers";
|
||||
|
||||
type LabelProps = React.LabelHTMLAttributes<HTMLLabelElement>;
|
||||
|
||||
export function Label({ className, htmlFor, ...props }: Readonly<LabelProps>) {
|
||||
return (
|
||||
<label
|
||||
htmlFor={htmlFor}
|
||||
className={cn(
|
||||
"text-sm font-medium tracking-wider leading-none",
|
||||
"peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
"mb-2.5 inline-block text-nb-gray-200",
|
||||
"flex items-center gap-2 select-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
46
proxy/web/src/components/NetBirdLogo.tsx
Normal file
46
proxy/web/src/components/NetBirdLogo.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { cn } from "@/utils/helpers";
|
||||
import netbirdFull from "@/assets/netbird-full.svg";
|
||||
import netbirdMark from "@/assets/netbird.svg";
|
||||
|
||||
type Props = {
|
||||
size?: "small" | "default" | "large";
|
||||
mobile?: boolean;
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
small: {
|
||||
desktop: 14,
|
||||
mobile: 20,
|
||||
},
|
||||
default: {
|
||||
desktop: 22,
|
||||
mobile: 30,
|
||||
},
|
||||
large: {
|
||||
desktop: 24,
|
||||
mobile: 40,
|
||||
},
|
||||
};
|
||||
|
||||
export const NetBirdLogo = ({ size = "default", mobile = true }: Props) => {
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
src={netbirdFull}
|
||||
height={sizes[size].desktop}
|
||||
style={{ height: sizes[size].desktop }}
|
||||
alt="NetBird Logo"
|
||||
className={cn(mobile && "hidden md:block", "group-hover:opacity-80 transition-all")}
|
||||
/>
|
||||
{mobile && (
|
||||
<img
|
||||
src={netbirdMark}
|
||||
width={sizes[size].mobile}
|
||||
style={{ width: sizes[size].mobile }}
|
||||
alt="NetBird Logo"
|
||||
className={cn(mobile && "md:hidden ml-4")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
109
proxy/web/src/components/PinCodeInput.tsx
Normal file
109
proxy/web/src/components/PinCodeInput.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { cn } from "@/utils/helpers";
|
||||
import React, {
|
||||
useRef,
|
||||
type KeyboardEvent,
|
||||
type ClipboardEvent,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from "react";
|
||||
|
||||
export interface PinCodeInputRef {
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
length?: number;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
const PinCodeInput = forwardRef<PinCodeInputRef, Readonly<Props>>(function PinCodeInput(
|
||||
{ value, onChange, length = 6, disabled = false, className, autoFocus = false },
|
||||
ref,
|
||||
) {
|
||||
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => {
|
||||
inputRefs.current[0]?.focus();
|
||||
},
|
||||
}));
|
||||
|
||||
const digits = value.split("").concat(new Array(length).fill("")).slice(0, length);
|
||||
const slotIds = Array.from({ length }, (_, i) => `pin-${i}`);
|
||||
|
||||
const handleChange = (index: number, digit: string) => {
|
||||
if (!/^\d*$/.test(digit)) return;
|
||||
|
||||
const newDigits = [...digits];
|
||||
newDigits[index] = digit.slice(-1);
|
||||
const newValue = newDigits.join("").replaceAll(/\s/g, "");
|
||||
onChange(newValue);
|
||||
|
||||
if (digit && index < length - 1) {
|
||||
inputRefs.current[index + 1]?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (index: number, e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Backspace" && !digits[index] && index > 0) {
|
||||
inputRefs.current[index - 1]?.focus();
|
||||
}
|
||||
if (e.key === "ArrowLeft" && index > 0) {
|
||||
inputRefs.current[index - 1]?.focus();
|
||||
}
|
||||
if (e.key === "ArrowRight" && index < length - 1) {
|
||||
inputRefs.current[index + 1]?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = (e: ClipboardEvent<HTMLInputElement>) => {
|
||||
e.preventDefault();
|
||||
const pastedData = e.clipboardData.getData("text").replaceAll(/\D/g, "").slice(0, length);
|
||||
onChange(pastedData);
|
||||
|
||||
const nextIndex = Math.min(pastedData.length, length - 1);
|
||||
inputRefs.current[nextIndex]?.focus();
|
||||
};
|
||||
|
||||
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
e.target.select();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex gap-2 w-full min-w-0", className)}>
|
||||
{digits.map((digit, index) => (
|
||||
<input
|
||||
key={slotIds[index]}
|
||||
id={slotIds[index]}
|
||||
ref={(el) => {
|
||||
inputRefs.current[index] = el;
|
||||
}}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={1}
|
||||
value={digit}
|
||||
onChange={(e) => handleChange(index, e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(index, e)}
|
||||
onPaste={handlePaste}
|
||||
onFocus={handleFocus}
|
||||
disabled={disabled}
|
||||
autoFocus={autoFocus && index === 0}
|
||||
className={cn(
|
||||
"flex-1 min-w-0 h-[42px] text-center text-sm rounded-md",
|
||||
"dark:bg-nb-gray-900 border dark:border-nb-gray-700",
|
||||
"dark:placeholder:text-neutral-400/70",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
|
||||
"ring-offset-neutral-200/20 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20",
|
||||
"disabled:cursor-not-allowed disabled:opacity-40"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default PinCodeInput;
|
||||
17
proxy/web/src/components/PoweredByNetBird.tsx
Normal file
17
proxy/web/src/components/PoweredByNetBird.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { NetBirdLogo } from "./NetBirdLogo";
|
||||
|
||||
export function PoweredByNetBird() {
|
||||
return (
|
||||
<a
|
||||
href="https://netbird.io?utm_source=netbird-proxy&utm_medium=web&utm_campaign=powered_by"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center mt-8 gap-2 group cursor-pointer"
|
||||
>
|
||||
<span className="text-sm text-nb-gray-400 font-light text-center group-hover:opacity-80 transition-all">
|
||||
Powered by
|
||||
</span>
|
||||
<NetBirdLogo size="small" mobile={false} />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
145
proxy/web/src/components/SegmentedTabs.tsx
Normal file
145
proxy/web/src/components/SegmentedTabs.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { cn } from "@/utils/helpers";
|
||||
import { useState, useMemo, useCallback } from "react";
|
||||
import { TabContext, useTabContext } from "./TabContext";
|
||||
|
||||
type TabsProps = {
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
onChange?: (value: string) => void;
|
||||
children:
|
||||
| React.ReactNode
|
||||
| ((context: { value: string; onChange: (value: string) => void }) => React.ReactNode);
|
||||
};
|
||||
|
||||
function SegmentedTabs({ value, defaultValue, onChange, children }: Readonly<TabsProps>) {
|
||||
const [internalValue, setInternalValue] = useState(defaultValue ?? "");
|
||||
const currentValue = value ?? internalValue;
|
||||
|
||||
const handleChange = useCallback((newValue: string) => {
|
||||
if (value === undefined) {
|
||||
setInternalValue(newValue);
|
||||
}
|
||||
onChange?.(newValue);
|
||||
}, [value, onChange]);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({ value: currentValue, onChange: handleChange }),
|
||||
[currentValue, handleChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<TabContext.Provider value={contextValue}>
|
||||
<div>
|
||||
{typeof children === "function"
|
||||
? children({ value: currentValue, onChange: handleChange })
|
||||
: children}
|
||||
</div>
|
||||
</TabContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function List({
|
||||
children,
|
||||
className,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}>) {
|
||||
return (
|
||||
<div
|
||||
role="tablist"
|
||||
className={cn(
|
||||
"bg-nb-gray-930/70 p-1.5 flex justify-center gap-1 border-nb-gray-900",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Trigger({
|
||||
children,
|
||||
value,
|
||||
disabled = false,
|
||||
className,
|
||||
selected,
|
||||
onClick,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
selected?: boolean;
|
||||
onClick?: () => void;
|
||||
}>) {
|
||||
const context = useTabContext();
|
||||
const isSelected = selected ?? value === context.value;
|
||||
|
||||
let stateClassName = "";
|
||||
if (isSelected) {
|
||||
stateClassName = "bg-nb-gray-900 text-white";
|
||||
} else if (!disabled) {
|
||||
stateClassName = "text-nb-gray-400 hover:bg-nb-gray-900/50";
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
context.onChange(value);
|
||||
onClick?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
role="tab"
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-selected={isSelected}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm rounded-md w-full transition-all cursor-pointer",
|
||||
disabled && "opacity-30 cursor-not-allowed",
|
||||
stateClassName,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center w-full justify-center gap-2">
|
||||
{children}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Content({
|
||||
children,
|
||||
value,
|
||||
className,
|
||||
visible,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
value: string;
|
||||
className?: string;
|
||||
visible?: boolean;
|
||||
}>) {
|
||||
const context = useTabContext();
|
||||
const isVisible = visible ?? value === context.value;
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="tabpanel"
|
||||
className={cn(
|
||||
"bg-nb-gray-930/70 px-4 pt-4 pb-5 rounded-b-md border border-t-0 border-nb-gray-900",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
SegmentedTabs.List = List;
|
||||
SegmentedTabs.Trigger = Trigger;
|
||||
SegmentedTabs.Content = Content;
|
||||
|
||||
export { SegmentedTabs };
|
||||
10
proxy/web/src/components/Separator.tsx
Normal file
10
proxy/web/src/components/Separator.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
export const Separator = () => {
|
||||
return (
|
||||
<div className="flex items-center justify-center relative my-4">
|
||||
<span className="bg-nb-gray-940 relative z-10 px-4 text-xs text-nb-gray-400 font-medium">
|
||||
OR
|
||||
</span>
|
||||
<span className="h-px bg-nb-gray-900 w-full absolute z-0" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
38
proxy/web/src/components/StatusCard.tsx
Normal file
38
proxy/web/src/components/StatusCard.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { ConnectionLine } from "./ConnectionLine";
|
||||
|
||||
interface StatusCardProps {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
detail?: string;
|
||||
success?: boolean;
|
||||
line?: boolean;
|
||||
}
|
||||
|
||||
export function StatusCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
detail,
|
||||
success = true,
|
||||
line = true,
|
||||
}: Readonly<StatusCardProps>) {
|
||||
return (
|
||||
<>
|
||||
{line && <ConnectionLine success={success} />}
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="w-14 h-14 rounded-md flex items-center justify-center from-nb-gray-940 to-nb-gray-930/70 bg-gradient-to-br border border-nb-gray-910">
|
||||
<Icon size={20} className="text-nb-gray-200" />
|
||||
</div>
|
||||
<span className="text-sm text-nb-gray-200 font-normal mt-1">{label}</span>
|
||||
<span className={`text-xs font-medium uppercase ${success ? "text-green-500" : "text-netbird"}`}>
|
||||
{success ? "Connected" : "Unreachable"}
|
||||
</span>
|
||||
{detail && (
|
||||
<span className="text-xs text-nb-gray-400 truncate text-center">
|
||||
{detail}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
13
proxy/web/src/components/TabContext.tsx
Normal file
13
proxy/web/src/components/TabContext.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
type TabContextValue = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export const TabContext = createContext<TabContextValue>({
|
||||
value: "",
|
||||
onChange: () => {},
|
||||
});
|
||||
|
||||
export const useTabContext = () => useContext(TabContext);
|
||||
14
proxy/web/src/components/Title.tsx
Normal file
14
proxy/web/src/components/Title.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { cn } from "@/utils/helpers";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function Title({ children, className }: Readonly<Props>) {
|
||||
return (
|
||||
<h1 className={cn("text-xl! text-center z-10 relative", className)}>
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user