mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0844507d8e | ||
|
|
7812f76bcf | ||
|
|
ba31f63400 | ||
|
|
5cf41dbc76 | ||
|
|
aff2365ef7 | ||
|
|
bad057d415 | ||
|
|
4d846e2c94 |
No files matched your search
Generated
+44
-44
@@ -60,7 +60,7 @@
|
||||
"js-cookie": "^3.0.5",
|
||||
"lodash": "^4.17.23",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next": "^16.1.6",
|
||||
"next": "^16.1.7",
|
||||
"next-themes": "^0.2.1",
|
||||
"punycode": "^2.3.1",
|
||||
"react": "^19.2.4",
|
||||
@@ -1213,9 +1213,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz",
|
||||
"integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.7.tgz",
|
||||
"integrity": "sha512-rJJbIdJB/RQr2F1nylZr/PJzamvNNhfr3brdKP6s/GW850jbtR70QlSfFselvIBbcPUOlQwBakexjFzqLzF6pg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
@@ -1229,9 +1229,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz",
|
||||
"integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.7.tgz",
|
||||
"integrity": "sha512-b2wWIE8sABdyafc4IM8r5Y/dS6kD80JRtOGrUiKTsACFQfWWgUQ2NwoUX1yjFMXVsAwcQeNpnucF2ZrujsBBPg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1245,9 +1245,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz",
|
||||
"integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.7.tgz",
|
||||
"integrity": "sha512-zcnVaaZulS1WL0Ss38R5Q6D2gz7MtBu8GZLPfK+73D/hp4GFMrC2sudLky1QibfV7h6RJBJs/gOFvYP0X7UVlQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1261,9 +1261,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz",
|
||||
"integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.7.tgz",
|
||||
"integrity": "sha512-2ant89Lux/Q3VyC8vNVg7uBaFVP9SwoK2jJOOR0L8TQnX8CAYnh4uctAScy2Hwj2dgjVHqHLORQZJ2wH6VxhSQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1277,9 +1277,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz",
|
||||
"integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.7.tgz",
|
||||
"integrity": "sha512-uufcze7LYv0FQg9GnNeZ3/whYfo+1Q3HnQpm16o6Uyi0OVzLlk2ZWoY7j07KADZFY8qwDbsmFnMQP3p3+Ftprw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1293,9 +1293,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz",
|
||||
"integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.7.tgz",
|
||||
"integrity": "sha512-KWVf2gxYvHtvuT+c4MBOGxuse5TD7DsMFYSxVxRBnOzok/xryNeQSjXgxSv9QpIVlaGzEn/pIuI6Koosx8CGWA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1309,9 +1309,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz",
|
||||
"integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.7.tgz",
|
||||
"integrity": "sha512-HguhaGwsGr1YAGs68uRKc4aGWxLET+NevJskOcCAwXbwj0fYX0RgZW2gsOCzr9S11CSQPIkxmoSbuVaBp4Z3dA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1325,9 +1325,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz",
|
||||
"integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.7.tgz",
|
||||
"integrity": "sha512-S0n3KrDJokKTeFyM/vGGGR8+pCmXYrjNTk2ZozOL1C/JFdfUIL9O1ATaJOl5r2POe56iRChbsszrjMAdWSv7kQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1341,9 +1341,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz",
|
||||
"integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.7.tgz",
|
||||
"integrity": "sha512-mwgtg8CNZGYm06LeEd+bNnOUfwOyNem/rOiP14Lsz+AnUY92Zq/LXwtebtUiaeVkhbroRCQ0c8GlR4UT1U+0yg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -5768,9 +5768,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/flatted": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
|
||||
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/for-each": {
|
||||
@@ -7067,14 +7067,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz",
|
||||
"integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.1.7.tgz",
|
||||
"integrity": "sha512-WM0L7WrSvKwoLegLYr6V+mz+RIofqQgVAfHhMp9a88ms0cFX8iX9ew+snpWlSBwpkURJOUdvCEt3uLl3NNzvWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "16.1.6",
|
||||
"@next/env": "16.1.7",
|
||||
"@swc/helpers": "0.5.15",
|
||||
"baseline-browser-mapping": "^2.8.3",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
"postcss": "8.4.31",
|
||||
"styled-jsx": "5.1.6"
|
||||
@@ -7086,14 +7086,14 @@
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "16.1.6",
|
||||
"@next/swc-darwin-x64": "16.1.6",
|
||||
"@next/swc-linux-arm64-gnu": "16.1.6",
|
||||
"@next/swc-linux-arm64-musl": "16.1.6",
|
||||
"@next/swc-linux-x64-gnu": "16.1.6",
|
||||
"@next/swc-linux-x64-musl": "16.1.6",
|
||||
"@next/swc-win32-arm64-msvc": "16.1.6",
|
||||
"@next/swc-win32-x64-msvc": "16.1.6",
|
||||
"@next/swc-darwin-arm64": "16.1.7",
|
||||
"@next/swc-darwin-x64": "16.1.7",
|
||||
"@next/swc-linux-arm64-gnu": "16.1.7",
|
||||
"@next/swc-linux-arm64-musl": "16.1.7",
|
||||
"@next/swc-linux-x64-gnu": "16.1.7",
|
||||
"@next/swc-linux-x64-musl": "16.1.7",
|
||||
"@next/swc-win32-arm64-msvc": "16.1.7",
|
||||
"@next/swc-win32-x64-msvc": "16.1.7",
|
||||
"sharp": "^0.34.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@
|
||||
"js-cookie": "^3.0.5",
|
||||
"lodash": "^4.17.23",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next": "^16.1.6",
|
||||
"next": "^16.1.7",
|
||||
"next-themes": "^0.2.1",
|
||||
"punycode": "^2.3.1",
|
||||
"react": "^19.2.4",
|
||||
|
||||
@@ -8,8 +8,12 @@ export default function ReverseProxyIcon(props: IconProps) {
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...iconProperties(props)}
|
||||
fill={"currentColor"}
|
||||
>
|
||||
<path d="M11.4488 2.1499C11.7903 1.95003 12.2097 1.95003 12.5513 2.1499L16.5018 4.46123L12 7.03523L7.49823 4.46123L11.4488 2.1499ZM6.44447 6.46472L6.44444 10.2784L2.93531 12.3315L7.53662 14.8399L10.8889 12.8787V9.00593L6.44447 6.46472ZM2 14.3992V18.7395C2 19.1477 2.21366 19.5247 2.55984 19.7272L6.44446 22V16.8223L2 14.3992ZM8.66668 22L12 20.0497L15.3333 22V16.7994L12 14.8492L8.66668 16.7993V22ZM17.5556 22L21.4401 19.7272C21.7863 19.5247 22 19.1477 22 18.7395V14.3992L17.5556 16.8223V22ZM21.0647 12.3315L17.5556 10.2784V6.46474L13.1111 9.00593V12.8787L16.4634 14.8399L21.0647 12.3315Z" />
|
||||
<path
|
||||
fill={"currentColor"}
|
||||
d="M11.4488 2.1499C11.7903 1.95003 12.2097 1.95003 12.5513 2.1499L16.5018 4.46123L12 7.03523L7.49823 4.46123L11.4488 2.1499ZM6.44447 6.46472L6.44444 10.2784L2.93531 12.3315L7.53662 14.8399L10.8889 12.8787V9.00593L6.44447 6.46472ZM2 14.3992V18.7395C2 19.1477 2.21366 19.5247 2.55984 19.7272L6.44446 22V16.8223L2 14.3992ZM8.66668 22L12 20.0497L15.3333 22V16.7994L12 14.8492L8.66668 16.7993V22ZM17.5556 22L21.4401 19.7272C21.7863 19.5247 22 19.1477 22 18.7395V14.3992L17.5556 16.8223V22ZM21.0647 12.3315L17.5556 10.2784V6.46474L13.1111 9.00593V12.8787L16.4634 14.8399L21.0647 12.3315Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -80,13 +80,15 @@ export const DeviceCard = ({
|
||||
hideTooltip={true}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
"text-sm font-normal text-nb-gray-400 relative whitespace-nowrap"
|
||||
}
|
||||
>
|
||||
<TruncatedText text={descriptionText} maxWidth={"160px"} />
|
||||
</span>
|
||||
{descriptionText && (
|
||||
<span
|
||||
className={
|
||||
"text-sm font-normal text-nb-gray-400 relative whitespace-nowrap"
|
||||
}
|
||||
>
|
||||
<TruncatedText text={descriptionText} maxWidth={"160px"} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ type Props = {
|
||||
description: ReactNode;
|
||||
icon?: ReactNode;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const RadioCard = ({
|
||||
@@ -16,15 +17,18 @@ export const RadioCard = ({
|
||||
description,
|
||||
className,
|
||||
icon,
|
||||
disabled,
|
||||
}: Props) => {
|
||||
return (
|
||||
<RadioGroup.Item
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"peer relative block cursor-pointer rounded-lg border border-nb-gray-900 bg-nb-gray-930/60 px-5 py-3 transition-all focus:outline-none",
|
||||
"data-[state=checked]:border-nb-gray-400 data-[state=checked]:bg-nb-gray-920",
|
||||
"outline-none focus:ring-0 focus:bg-nb-gray-930 focus:border-nb-gray-920",
|
||||
"hover:bg-nb-gray-930",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-nb-gray-930/60",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -77,23 +77,56 @@ const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
|
||||
extra?: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
}
|
||||
>(({ className, children, extra, ...props }, ref) => (
|
||||
>(({ className, children, extra, icon, description, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full select-none items-center rounded-md py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-gray-100 focus:text-neutral-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-nb-gray-900 dark:focus:text-neutral-50 dark:text-gray-400 cursor-pointer",
|
||||
"relative flex w-full select-none items-center rounded-md py-1.5 text-sm outline-none focus:bg-gray-100 focus:text-neutral-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-nb-gray-900 dark:focus:text-neutral-50 dark:text-gray-400 cursor-pointer",
|
||||
icon ? "pl-2 pr-8" : "pl-8 pr-2",
|
||||
description && "py-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
{icon ? (
|
||||
<>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex-shrink-0">{icon}</span>
|
||||
<div className="flex flex-col">
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
{description && (
|
||||
<span className="text-xs text-nb-gray-300 font-normal">
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
{description && (
|
||||
<span className="text-xs text-nb-gray-300 font-normal">
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{extra}
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import * as React from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import { cn } from "@utils/helpers";
|
||||
|
||||
export const SkeletonDeviceCard = () => {
|
||||
type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const SkeletonDeviceCard = ({ className = "min-h-[59px]" }: Props) => {
|
||||
return (
|
||||
<div className={"min-h-[59px] relative -left-2"}>
|
||||
<div className={"py-2 pr-4 pl-2 flex gap-3"}>
|
||||
<Skeleton height={36} width={36} />
|
||||
<div className={"flex flex-col pr-[1.15rem]"}>
|
||||
<Skeleton height={16} width={70} />
|
||||
<Skeleton height={16} width={140} />
|
||||
</div>
|
||||
<div
|
||||
className={cn("py-2 pr-4 pl-2 flex gap-3 relative -left-2", className)}
|
||||
>
|
||||
<Skeleton height={36} width={36} />
|
||||
<div className={"flex flex-col pr-[1.15rem]"}>
|
||||
<Skeleton height={16} width={70} />
|
||||
<Skeleton height={16} width={140} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ const CountryContext = React.createContext(
|
||||
countries: Country[] | undefined;
|
||||
isLoading: boolean;
|
||||
getRegionByPeer: (peer: Peer) => string;
|
||||
getRegionText: (country_code: string, city_name: string) => string;
|
||||
getRegionText: (country_code: string, city_name: string, subdivision_code?: string) => string;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function CountryProvider({ children }: Props) {
|
||||
const { isRestricted } = usePermissions();
|
||||
|
||||
const getRegionByPeer = (peer: Peer) => "Unknown";
|
||||
const getRegionText = (country_code: string, city_name: string) => "Unknown";
|
||||
const getRegionText = (country_code: string, city_name: string, _subdivision_code?: string) => "Unknown";
|
||||
|
||||
return isRestricted ? (
|
||||
<CountryContext.Provider
|
||||
@@ -47,12 +47,14 @@ function CountryProviderContent({ children }: Props) {
|
||||
);
|
||||
|
||||
const getRegionText = useCallback(
|
||||
(country_code: string, city_name: string) => {
|
||||
(country_code: string, city_name: string, subdivision_code?: string) => {
|
||||
if (!countries) return "Unknown";
|
||||
const country = countries.find((c) => c.country_code === country_code);
|
||||
if (!country) return "Unknown";
|
||||
if (!city_name) return country.country_name;
|
||||
return `${country.country_name}, ${city_name}`;
|
||||
const parts = [country.country_name];
|
||||
if (subdivision_code) parts.push(subdivision_code);
|
||||
if (city_name) parts.push(city_name);
|
||||
return parts.join(", ");
|
||||
},
|
||||
[countries],
|
||||
);
|
||||
|
||||
@@ -26,6 +26,8 @@ import ReverseProxyTargetModal from "@/modules/reverse-proxy/targets/ReverseProx
|
||||
|
||||
type ReverseProxiesContextValue = {
|
||||
reverseProxies: ReverseProxy[] | undefined;
|
||||
resources: NetworkResource[] | undefined;
|
||||
peers: Peer[] | undefined;
|
||||
isLoading: boolean;
|
||||
openModal: (options?: OpenModalOptions) => void;
|
||||
openTargetModal: (options: OpenTargetModalOptions) => void;
|
||||
@@ -93,7 +95,7 @@ export default function ReverseProxiesProvider({
|
||||
const { data: rawReverseProxies, isLoading } = useFetchApi<ReverseProxy[]>(
|
||||
"/reverse-proxies/services",
|
||||
);
|
||||
const request = useApiCall<ReverseProxy>("/reverse-proxies/services");
|
||||
const request = useApiCall<ReverseProxy>("/reverse-proxies/services", true);
|
||||
|
||||
// Peers & Resources for resolving target destinations
|
||||
const { data: peers } = useFetchApi<Peer[]>("/peers");
|
||||
@@ -465,6 +467,8 @@ export default function ReverseProxiesProvider({
|
||||
<ReverseProxiesContext.Provider
|
||||
value={{
|
||||
reverseProxies,
|
||||
resources,
|
||||
peers,
|
||||
isLoading,
|
||||
openModal,
|
||||
openTargetModal,
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface Account {
|
||||
lazy_connection_enabled: boolean;
|
||||
embedded_idp_enabled?: boolean;
|
||||
auto_update_version: string;
|
||||
auto_update_always: boolean;
|
||||
local_auth_disabled?: boolean;
|
||||
};
|
||||
onboarding?: AccountOnboarding;
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
export enum ServiceMode {
|
||||
HTTP = "http",
|
||||
TCP = "tcp",
|
||||
UDP = "udp",
|
||||
TLS = "tls",
|
||||
}
|
||||
|
||||
export interface ReverseProxy {
|
||||
id?: string;
|
||||
name: string;
|
||||
domain: string;
|
||||
mode?: ServiceMode;
|
||||
listen_port?: number;
|
||||
port_auto_assigned?: boolean;
|
||||
proxy_cluster?: string;
|
||||
targets: ReverseProxyTarget[];
|
||||
enabled: boolean;
|
||||
pass_host_header?: boolean;
|
||||
rewrite_redirects?: boolean;
|
||||
auth?: ReverseProxyAuth;
|
||||
access_restrictions?: AccessRestrictions;
|
||||
meta?: ReverseProxyMeta;
|
||||
}
|
||||
|
||||
@@ -31,8 +42,10 @@ export type ServiceTargetOptionsPathRewrite = "preserve";
|
||||
export interface ServiceTargetOptions {
|
||||
skip_tls_verify?: boolean;
|
||||
request_timeout?: string;
|
||||
session_idle_timeout?: string;
|
||||
path_rewrite?: ServiceTargetOptionsPathRewrite;
|
||||
custom_headers?: Record<string, string>;
|
||||
proxy_protocol?: boolean;
|
||||
}
|
||||
|
||||
export interface ReverseProxyTarget {
|
||||
@@ -65,6 +78,20 @@ export interface ReverseProxyAuth {
|
||||
link_auth?: {
|
||||
enabled: boolean;
|
||||
};
|
||||
header_auths?: HeaderAuthConfig[];
|
||||
}
|
||||
|
||||
export interface HeaderAuthConfig {
|
||||
enabled: boolean;
|
||||
header: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface AccessRestrictions {
|
||||
allowed_cidrs?: string[];
|
||||
blocked_cidrs?: string[];
|
||||
allowed_countries?: string[];
|
||||
blocked_countries?: string[];
|
||||
}
|
||||
|
||||
export interface ReverseProxyDomain {
|
||||
@@ -73,6 +100,7 @@ export interface ReverseProxyDomain {
|
||||
validated: boolean;
|
||||
type: ReverseProxyDomainType;
|
||||
target_cluster?: string;
|
||||
supports_custom_ports?: boolean;
|
||||
}
|
||||
|
||||
export enum ReverseProxyDomainType {
|
||||
@@ -90,6 +118,15 @@ export enum ReverseProxyTargetType {
|
||||
export enum ReverseProxyTargetProtocol {
|
||||
HTTP = "http",
|
||||
HTTPS = "https",
|
||||
TCP = "tcp",
|
||||
UDP = "udp",
|
||||
}
|
||||
|
||||
export enum EventProtocol {
|
||||
HTTP = "http",
|
||||
TCP = "tcp",
|
||||
UDP = "udp",
|
||||
TLS = "tls",
|
||||
}
|
||||
|
||||
export interface ReverseProxyEvent {
|
||||
@@ -107,12 +144,32 @@ export interface ReverseProxyEvent {
|
||||
auth_method_used?: string;
|
||||
country_code?: string;
|
||||
city_name?: string;
|
||||
subdivision_code?: string;
|
||||
bytes_upload: number;
|
||||
bytes_download: number;
|
||||
protocol?: EventProtocol;
|
||||
}
|
||||
|
||||
export function isL4Event(event: ReverseProxyEvent): boolean {
|
||||
return (
|
||||
event.protocol === EventProtocol.TCP ||
|
||||
event.protocol === EventProtocol.UDP ||
|
||||
event.protocol === EventProtocol.TLS
|
||||
);
|
||||
}
|
||||
|
||||
export interface ReverseProxyFlatTarget extends ReverseProxyTarget {
|
||||
proxy: ReverseProxy;
|
||||
}
|
||||
|
||||
export function isL4Mode(mode?: ServiceMode): boolean {
|
||||
return (
|
||||
mode === ServiceMode.TCP ||
|
||||
mode === ServiceMode.UDP ||
|
||||
mode === ServiceMode.TLS
|
||||
);
|
||||
}
|
||||
|
||||
export const REVERSE_PROXY_DOCS_LINK =
|
||||
"https://docs.netbird.io/manage/reverse-proxy";
|
||||
|
||||
@@ -139,3 +196,6 @@ export const REVERSE_PROXY_DOMAIN_VERIFICATION_LINK =
|
||||
|
||||
export const REVERSE_PROXY_EVENTS_DOCS_LINK =
|
||||
"https://docs.netbird.io/manage/reverse-proxy/access-logs";
|
||||
|
||||
export const REVERSE_PROXY_TROUBLESHOOTING_DOCS_LINK =
|
||||
"https://docs.netbird.io/manage/reverse-proxy#troubleshooting";
|
||||
@@ -309,7 +309,7 @@ export function ResourceModalContent({
|
||||
}
|
||||
>
|
||||
<span className={"relative top-[1px]"}>
|
||||
Additional Options (optional)
|
||||
Optional Settings
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className={""}>
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import Button from "@components/Button";
|
||||
import HelpText from "@components/HelpText";
|
||||
import { Label } from "@components/Label";
|
||||
import { CountrySelector } from "@components/ui/CountrySelector";
|
||||
import { cn } from "@utils/helpers";
|
||||
import cidr from "ip-cidr";
|
||||
import { Globe, MinusCircleIcon, PlusCircle, ShieldCheck } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { AccessRestrictions } from "@/interfaces/ReverseProxy";
|
||||
|
||||
let nextRowId = 0;
|
||||
|
||||
type Props = {
|
||||
value: AccessRestrictions;
|
||||
onChange: (value: AccessRestrictions) => void;
|
||||
};
|
||||
|
||||
type CidrRow = { id: number; value: string };
|
||||
|
||||
type RowListProps = {
|
||||
values: string[];
|
||||
onChange: (values: string[]) => void;
|
||||
};
|
||||
|
||||
function CidrList({ values, onChange }: Readonly<RowListProps>) {
|
||||
const [rows, setRows] = useState<CidrRow[]>(() =>
|
||||
values.map((v) => ({ id: nextRowId++, value: v })),
|
||||
);
|
||||
const [errors, setErrors] = useState<Record<number, string>>({});
|
||||
|
||||
const addRow = () => {
|
||||
const next = [...rows, { id: nextRowId++, value: "" }];
|
||||
setRows(next);
|
||||
onChange(next.map((r) => r.value));
|
||||
};
|
||||
|
||||
const updateRow = (id: number, raw: string) => {
|
||||
const next = rows.map((r) => (r.id === id ? { ...r, value: raw } : r));
|
||||
setRows(next);
|
||||
onChange(next.map((r) => r.value));
|
||||
|
||||
if (raw && !cidr.isValidCIDR(raw)) {
|
||||
setErrors((prev) => ({ ...prev, [id]: "Invalid CIDR format" }));
|
||||
} else {
|
||||
setErrors((prev) => {
|
||||
const copy = { ...prev };
|
||||
delete copy[id];
|
||||
return copy;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const removeRow = (id: number) => {
|
||||
const next = rows.filter((r) => r.id !== id);
|
||||
setRows(next);
|
||||
onChange(next.map((r) => r.value));
|
||||
setErrors((prev) => {
|
||||
const copy = { ...prev };
|
||||
delete copy[id];
|
||||
return copy;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{rows.map((row) => (
|
||||
<div key={row.id}>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={row.value}
|
||||
onChange={(e) => updateRow(row.id, e.target.value.trim())}
|
||||
placeholder="e.g. 10.0.0.0/8"
|
||||
className={cn(
|
||||
"flex-1 h-[42px] px-3 text-sm font-mono bg-nb-gray-900/40 rounded-md outline-none",
|
||||
"border placeholder:text-neutral-400/70",
|
||||
errors[row.id]
|
||||
? "border-red-400"
|
||||
: "border-nb-gray-700",
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
className="h-[42px]"
|
||||
variant="default-outline"
|
||||
onClick={() => removeRow(row.id)}
|
||||
>
|
||||
<MinusCircleIcon size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
{errors[row.id] && (
|
||||
<p className="text-xs text-red-400 mt-1">{errors[row.id]}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button variant="dotted" size="sm" onClick={addRow}>
|
||||
<PlusCircle size={16} />
|
||||
Add CIDR
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type CountryRow = { id: number; value: string };
|
||||
|
||||
function CountryList({ values, onChange }: Readonly<RowListProps>) {
|
||||
const [rows, setRows] = useState<CountryRow[]>(() =>
|
||||
values.map((v) => ({ id: nextRowId++, value: v })),
|
||||
);
|
||||
|
||||
const addCountry = () => {
|
||||
const next = [...rows, { id: nextRowId++, value: "" }];
|
||||
setRows(next);
|
||||
onChange(next.map((r) => r.value));
|
||||
};
|
||||
|
||||
const updateCountry = (id: number, code: string) => {
|
||||
if (rows.some((r) => r.value === code)) return;
|
||||
const next = rows.map((r) => (r.id === id ? { ...r, value: code } : r));
|
||||
setRows(next);
|
||||
onChange(next.map((r) => r.value));
|
||||
};
|
||||
|
||||
const removeCountry = (id: number) => {
|
||||
const next = rows.filter((r) => r.id !== id);
|
||||
setRows(next);
|
||||
onChange(next.map((r) => r.value));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{rows.map((row) => (
|
||||
<div key={row.id} className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<CountrySelector
|
||||
value={row.value}
|
||||
onChange={(v) => updateCountry(row.id, v)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="h-[42px]"
|
||||
variant="default-outline"
|
||||
onClick={() => removeCountry(row.id)}
|
||||
>
|
||||
<MinusCircleIcon size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button variant="dotted" size="sm" onClick={addCountry}>
|
||||
<PlusCircle size={16} />
|
||||
Add Country
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AccessRestrictionsSection({
|
||||
value,
|
||||
onChange,
|
||||
}: Readonly<Props>) {
|
||||
const update = (patch: Partial<AccessRestrictions>) => {
|
||||
onChange({ ...value, ...patch });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-8 flex-col flex gap-6">
|
||||
<div>
|
||||
<Label>
|
||||
<ShieldCheck size={14} />
|
||||
CIDR Allowlist
|
||||
</Label>
|
||||
<HelpText>
|
||||
Only connections from these IP ranges will be allowed. All other
|
||||
IPs will be blocked. Leave empty to allow all.
|
||||
</HelpText>
|
||||
<CidrList
|
||||
values={value.allowed_cidrs ?? []}
|
||||
onChange={(v) => update({ allowed_cidrs: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>
|
||||
<ShieldCheck size={14} />
|
||||
CIDR Blocklist
|
||||
</Label>
|
||||
<HelpText>
|
||||
Block connections from these IP ranges. Takes priority over the
|
||||
allowlist.
|
||||
</HelpText>
|
||||
<CidrList
|
||||
values={value.blocked_cidrs ?? []}
|
||||
onChange={(v) => update({ blocked_cidrs: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>
|
||||
<Globe size={14} />
|
||||
Country Allowlist
|
||||
</Label>
|
||||
<HelpText>
|
||||
Only connections from these countries will be allowed. All other
|
||||
countries will be blocked. Leave empty to allow all.
|
||||
</HelpText>
|
||||
<CountryList
|
||||
values={value.allowed_countries ?? []}
|
||||
onChange={(v) => update({ allowed_countries: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>
|
||||
<Globe size={14} />
|
||||
Country Blocklist
|
||||
</Label>
|
||||
<HelpText>
|
||||
Block connections from these countries. Takes priority over the
|
||||
allowlist.
|
||||
</HelpText>
|
||||
<CountryList
|
||||
values={value.blocked_countries ?? []}
|
||||
onChange={(v) => update({ blocked_countries: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import Button from "@components/Button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@components/DropdownMenu";
|
||||
import HelpText from "@components/HelpText";
|
||||
import { InlineButtonLink } from "@components/InlineLink";
|
||||
import { Label } from "@components/Label";
|
||||
import { ToggleSwitch } from "@components/ToggleSwitch";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
ArrowUpRight,
|
||||
Edit,
|
||||
MinusCircleIcon,
|
||||
MoreVertical,
|
||||
PlusIcon,
|
||||
} from "lucide-react";
|
||||
import { Callout } from "@components/Callout";
|
||||
import React from "react";
|
||||
import { Network } from "@/interfaces/Network";
|
||||
import { ReverseProxyTarget } from "@/interfaces/ReverseProxy";
|
||||
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
|
||||
import { cn } from "@utils/helpers";
|
||||
|
||||
type Props = {
|
||||
targets: ReverseProxyTarget[];
|
||||
onEditTarget: (index: number) => void;
|
||||
onRemoveTarget: (index: number) => void;
|
||||
onToggleTargetEnabled: (index: number) => void;
|
||||
onAddTarget: () => void;
|
||||
initialNetwork?: Network;
|
||||
onNavigateToResources?: () => void;
|
||||
};
|
||||
|
||||
export default function ReverseProxyHTTPTargets({
|
||||
targets,
|
||||
onEditTarget,
|
||||
onRemoveTarget,
|
||||
onToggleTargetEnabled,
|
||||
onAddTarget,
|
||||
initialNetwork,
|
||||
onNavigateToResources,
|
||||
}: Readonly<Props>) {
|
||||
return (
|
||||
<div>
|
||||
<Label>HTTP/S Targets</Label>
|
||||
<HelpText>
|
||||
Add one or more devices running your service or resources to make it
|
||||
publicly accessible.
|
||||
</HelpText>
|
||||
|
||||
{targets.length > 0 && (
|
||||
<div
|
||||
className={
|
||||
"mt-3 mb-3 overflow-hidden border border-nb-gray-900 bg-nb-gray-920/30 py-1 px-1 rounded-md "
|
||||
}
|
||||
>
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{targets.map((target, index) => (
|
||||
<tr
|
||||
key={index}
|
||||
onClick={() => onEditTarget(index)}
|
||||
className="rounded-md hover:bg-nb-gray-900/30 cursor-pointer transition-all"
|
||||
>
|
||||
<td className="py-2.5 pl-5 pr-2 align-middle">
|
||||
<span className="text-[11px] leading-none font-mono px-2.5 py-2 rounded bg-nb-gray-900 text-nb-gray-300 inline-flex items-center">
|
||||
{target.path
|
||||
? target.path.startsWith("/")
|
||||
? target.path
|
||||
: `/${target.path}`
|
||||
: "/"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2.5 px-4 align-middle">
|
||||
<ArrowRight size={12} className="text-nb-gray-400" />
|
||||
</td>
|
||||
<td className="py-2.5 pr-2 align-middle">
|
||||
<TargetDestination target={target} />
|
||||
</td>
|
||||
<td className="py-2.5 pl-2 pr-4">
|
||||
<div
|
||||
className="flex items-center gap-2 justify-end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ToggleSwitch
|
||||
size="small"
|
||||
checked={target.enabled}
|
||||
onCheckedChange={() => onToggleTargetEnabled(index)}
|
||||
/>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="default-outline"
|
||||
className="!px-3"
|
||||
>
|
||||
<MoreVertical size={16} className="shrink-0" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-auto min-w-[200px]"
|
||||
align="end"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onEditTarget(index)}
|
||||
>
|
||||
<div className="flex gap-3 items-center">
|
||||
<Edit size={14} className="shrink-0" />
|
||||
Edit Target
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant={"danger"}
|
||||
onClick={() => onRemoveTarget(index)}
|
||||
>
|
||||
<div className="flex gap-3 items-center">
|
||||
<MinusCircleIcon
|
||||
size={14}
|
||||
className="shrink-0"
|
||||
/>
|
||||
Remove Target
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="dotted"
|
||||
className={cn("w-full mt-1", targets?.length > 0 && "mt-1")}
|
||||
size="sm"
|
||||
onClick={onAddTarget}
|
||||
disabled={!!(initialNetwork && !initialNetwork.resources?.length)}
|
||||
>
|
||||
<PlusIcon size={14} />
|
||||
Add Target
|
||||
</Button>
|
||||
|
||||
{initialNetwork && !initialNetwork.resources?.length && (
|
||||
<Callout
|
||||
variant="warning"
|
||||
className="mt-3"
|
||||
icon={
|
||||
<AlertTriangle
|
||||
size={14}
|
||||
className="shrink-0 relative top-[3px]"
|
||||
/>
|
||||
}
|
||||
>
|
||||
There are currently no resources in your network{" "}
|
||||
<span className={"text-netbird-100 font-medium"}>
|
||||
{initialNetwork?.name}
|
||||
</span>
|
||||
. Add resources to your network before exposing it as a service.{" "}
|
||||
<InlineButtonLink variant={"default"} onClick={onNavigateToResources}>
|
||||
Go to Resources
|
||||
<ArrowUpRight size={14} />
|
||||
</InlineButtonLink>
|
||||
</Callout>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TargetDestination({ target }: { target: ReverseProxyTarget }) {
|
||||
const { resolveDestination } = useReverseProxies();
|
||||
return (
|
||||
<span className="text-[0.76rem] text-nb-gray-200 whitespace-nowrap font-mono">
|
||||
{resolveDestination(target)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { Input } from "@components/Input";
|
||||
import { Label } from "@components/Label";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import React, { useRef } from "react";
|
||||
import { Network, NetworkResource } from "@/interfaces/Network";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import ReverseProxyAddressInput, {
|
||||
CidrHelpText,
|
||||
} from "@/modules/reverse-proxy/targets/ReverseProxyAddressInput";
|
||||
import ReverseProxyTargetSelector, {
|
||||
type Target,
|
||||
} from "@/modules/reverse-proxy/targets/ReverseProxyTargetSelector";
|
||||
import { HelpTooltip } from "@components/HelpTooltip";
|
||||
|
||||
type Props = {
|
||||
l4Target: Target | undefined;
|
||||
setL4Target: React.Dispatch<React.SetStateAction<Target | undefined>>;
|
||||
isListenPortSupported: boolean;
|
||||
listenPort: number;
|
||||
setListenPort: (port: number) => void;
|
||||
port: number;
|
||||
setPort: (port: number) => void;
|
||||
initialResource?: NetworkResource;
|
||||
initialPeer?: Peer;
|
||||
initialNetwork?: Network;
|
||||
};
|
||||
|
||||
export default function ReverseProxyLayer4Content({
|
||||
l4Target,
|
||||
setL4Target,
|
||||
isListenPortSupported,
|
||||
listenPort,
|
||||
setListenPort,
|
||||
port,
|
||||
setPort,
|
||||
initialResource,
|
||||
initialPeer,
|
||||
initialNetwork,
|
||||
}: Readonly<Props>) {
|
||||
const listenPortRef = useRef<HTMLInputElement>(null);
|
||||
const portRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<div className={"-mt-1 flex flex-col gap-8"}>
|
||||
{!initialResource && !initialPeer && (
|
||||
<ReverseProxyTargetSelector
|
||||
value={l4Target}
|
||||
initialNetwork={initialNetwork}
|
||||
onChange={(selection) => {
|
||||
setL4Target(selection);
|
||||
if (selection) {
|
||||
setTimeout(() => {
|
||||
if (isListenPortSupported) {
|
||||
listenPortRef.current?.focus();
|
||||
} else {
|
||||
portRef.current?.focus();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={"flex gap-4 items-center"}>
|
||||
<div className={"w-full max-w-[180px]"}>
|
||||
<Label>
|
||||
Listen Port
|
||||
<HelpTooltip
|
||||
className={"max-w-sm"}
|
||||
content={
|
||||
isListenPortSupported
|
||||
? "Enter the public listen port this service will be reachable on."
|
||||
: "The listen port will be automatically assigned after the service is created."
|
||||
}
|
||||
/>
|
||||
</Label>
|
||||
<div className={"mt-2"}>
|
||||
<Input
|
||||
ref={listenPortRef}
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
placeholder={!isListenPortSupported ? "Auto" : "443"}
|
||||
value={!isListenPortSupported ? "" : listenPort || ""}
|
||||
onChange={(e) => setListenPort(parseInt(e.target.value) || 0)}
|
||||
disabled={!isListenPortSupported || !l4Target}
|
||||
aria-label="Public listen port"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight size={16} className="text-nb-gray-400 shrink-0 mt-6" />
|
||||
<div className={"w-full flex"}>
|
||||
<div className={"w-full"}>
|
||||
<Label>
|
||||
Host / IP
|
||||
<CidrHelpText target={l4Target} />
|
||||
</Label>
|
||||
<div className="flex w-full mt-2 relative">
|
||||
<ReverseProxyAddressInput
|
||||
value={l4Target}
|
||||
onChange={setL4Target}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>
|
||||
Port
|
||||
<HelpTooltip
|
||||
content={
|
||||
"Enter the port where your service (e.g., webserver, app, API) is currently listening."
|
||||
}
|
||||
/>
|
||||
</Label>
|
||||
<div className={"mt-2 min-w-[120px]"}>
|
||||
<Input
|
||||
ref={portRef}
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
placeholder="443"
|
||||
value={port || ""}
|
||||
onChange={(e) => setPort(parseInt(e.target.value) || 0)}
|
||||
disabled={!l4Target}
|
||||
aria-label="Destination port"
|
||||
className={"rounded-l-none"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,125 @@
|
||||
import React, { ReactNode, useEffect } from "react";
|
||||
import {
|
||||
isL4Mode as isL4ServiceMode,
|
||||
type ReverseProxyDomain,
|
||||
ServiceMode,
|
||||
} from "@/interfaces/ReverseProxy";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@components/Select";
|
||||
import { ArrowRightFromLine, Globe, LockKeyhole } from "lucide-react";
|
||||
import { HelpTooltip } from "@components/HelpTooltip";
|
||||
import { Label } from "@components/Label";
|
||||
import HelpText from "@components/HelpText";
|
||||
|
||||
type Props = {
|
||||
value?: ServiceMode;
|
||||
onChange: (value: ServiceMode) => void;
|
||||
disabled?: boolean;
|
||||
domain?: ReverseProxyDomain;
|
||||
};
|
||||
|
||||
type ServiceModeConfig = {
|
||||
label: string;
|
||||
description: string;
|
||||
icon: ReactNode;
|
||||
};
|
||||
|
||||
export const SERVICE_MODES: Record<ServiceMode, ServiceModeConfig> = {
|
||||
[ServiceMode.HTTP]: {
|
||||
label: "HTTP/S Service",
|
||||
description:
|
||||
"Reverse proxy with path routing and built-in authentication (SSO, PIN, password). Typically used for web applications and APIs.",
|
||||
icon: <Globe size={14} />,
|
||||
},
|
||||
[ServiceMode.TLS]: {
|
||||
label: "TLS Passthrough",
|
||||
description:
|
||||
"Passes encrypted TLS traffic straight through to the backend. Typically used for services that manage their own TLS certificates.",
|
||||
icon: <LockKeyhole size={14} />,
|
||||
},
|
||||
[ServiceMode.TCP]: {
|
||||
label: "TCP Service",
|
||||
description:
|
||||
"Forwards raw TCP traffic to your backend on a dedicated port. Typically used for databases, custom protocols, or any TCP-based service.",
|
||||
icon: <ArrowRightFromLine size={14} />,
|
||||
},
|
||||
[ServiceMode.UDP]: {
|
||||
label: "UDP Service",
|
||||
description:
|
||||
"Forwards raw UDP traffic to your backend on a dedicated port. Typically used for real-time services like voice, video, or streaming.",
|
||||
icon: <ArrowRightFromLine size={14} />,
|
||||
},
|
||||
};
|
||||
|
||||
export const ReverseProxyServiceModeSelector = ({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
domain,
|
||||
}: Props) => {
|
||||
const selected = value ?? ServiceMode.HTTP;
|
||||
const selectedMode = SERVICE_MODES[selected];
|
||||
const isL4Supported = domain?.supports_custom_ports === true;
|
||||
|
||||
// Reset to HTTP if the current L4 mode becomes unsupported (e.g. domain changed)
|
||||
useEffect(() => {
|
||||
if (!isL4Supported && isL4ServiceMode(selected)) {
|
||||
onChange(ServiceMode.HTTP);
|
||||
}
|
||||
}, [isL4Supported, selected, onChange]);
|
||||
|
||||
return (
|
||||
<div className="flex justify-between items-center gap-10 mt-2">
|
||||
<div>
|
||||
<Label>Service Type</Label>
|
||||
<HelpText>
|
||||
Select a type to define how the proxy handles and forwards traffic to
|
||||
your backend services.
|
||||
</HelpText>
|
||||
</div>
|
||||
<Select
|
||||
value={selected}
|
||||
onValueChange={(v) => onChange(v as ServiceMode)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="max-w-[240px] min-w-[200px]">
|
||||
<div
|
||||
className={"flex items-center gap-2 whitespace-nowrap"}
|
||||
data-cy={"service-mode-select-button"}
|
||||
>
|
||||
{selectedMode.icon}
|
||||
<SelectValue placeholder="Select type..." />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent data-cy={"service-mode-selection"}>
|
||||
{Object.entries(SERVICE_MODES)
|
||||
.filter(
|
||||
([mode]) =>
|
||||
isL4Supported || !isL4ServiceMode(mode as ServiceMode),
|
||||
)
|
||||
.map(([mode, config]) => (
|
||||
<SelectItem
|
||||
key={mode}
|
||||
value={mode}
|
||||
extra={
|
||||
<HelpTooltip
|
||||
triggerClassName={"ml-[0.01rem]"}
|
||||
align={"center"}
|
||||
side={"right"}
|
||||
content={<>{config.description}</>}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className="whitespace-nowrap">{config.label}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,392 @@
|
||||
import Button from "@components/Button";
|
||||
import HelpText from "@components/HelpText";
|
||||
import { Input } from "@components/Input";
|
||||
import { Label } from "@components/Label";
|
||||
import { Modal, ModalClose, ModalContent } from "@components/modal/Modal";
|
||||
import ModalHeader from "@components/modal/ModalHeader";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@components/Select";
|
||||
import { GradientFadedBackground } from "@components/ui/GradientFadedBackground";
|
||||
import type { HeaderAuthConfig } from "@/interfaces/ReverseProxy";
|
||||
import { MinusCircleIcon, PlusCircle } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
|
||||
type Preset = "basic" | "bearer" | "custom";
|
||||
|
||||
let nextDraftId = 0;
|
||||
|
||||
type DraftEntry = {
|
||||
id: number;
|
||||
preset: Preset;
|
||||
header: string;
|
||||
value: string;
|
||||
// Basic auth convenience fields
|
||||
username: string;
|
||||
password: string;
|
||||
// True when loaded from an existing config (value is hidden server-side).
|
||||
// The entry is valid without re-entering the value.
|
||||
existingSecret: boolean;
|
||||
};
|
||||
|
||||
function newDraft(): DraftEntry {
|
||||
return { id: nextDraftId++, preset: "basic", header: "Authorization", value: "", username: "", password: "", existingSecret: false };
|
||||
}
|
||||
|
||||
function toBase64(str: string): string {
|
||||
return btoa(
|
||||
new TextEncoder()
|
||||
.encode(str)
|
||||
.reduce((acc, byte) => acc + String.fromCharCode(byte), ""),
|
||||
);
|
||||
}
|
||||
|
||||
function fromBase64(b64: string): string {
|
||||
return new TextDecoder().decode(
|
||||
Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)),
|
||||
);
|
||||
}
|
||||
|
||||
function draftToConfig(d: DraftEntry): HeaderAuthConfig {
|
||||
// Existing secret that wasn't re-entered: send empty value so backend keeps the hash.
|
||||
if (d.existingSecret) {
|
||||
return { enabled: true, header: d.header, value: "" };
|
||||
}
|
||||
if (d.preset === "basic") {
|
||||
const encoded = toBase64(`${d.username}:${d.password}`);
|
||||
return { enabled: true, header: "Authorization", value: `Basic ${encoded}` };
|
||||
}
|
||||
if (d.preset === "bearer") {
|
||||
return { enabled: true, header: "Authorization", value: `Bearer ${d.value}` };
|
||||
}
|
||||
return { enabled: true, header: d.header, value: d.value };
|
||||
}
|
||||
|
||||
function configToDraft(c: HeaderAuthConfig): DraftEntry {
|
||||
// When the API returns headers without values (secrets are stripped),
|
||||
// mark them as existing so we don't require re-entry.
|
||||
const isExisting = !c.value;
|
||||
if (c.header === "Authorization" && c.value?.startsWith("Basic ")) {
|
||||
try {
|
||||
const decoded = fromBase64(c.value.slice(6));
|
||||
const sep = decoded.indexOf(":");
|
||||
if (sep >= 0) {
|
||||
return {
|
||||
id: nextDraftId++,
|
||||
preset: "basic",
|
||||
header: "Authorization",
|
||||
value: "",
|
||||
username: decoded.slice(0, sep),
|
||||
password: decoded.slice(sep + 1),
|
||||
existingSecret: false,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// fall through to custom
|
||||
}
|
||||
}
|
||||
if (c.header === "Authorization" && c.value?.startsWith("Bearer ")) {
|
||||
return {
|
||||
id: nextDraftId++,
|
||||
preset: "bearer",
|
||||
header: "Authorization",
|
||||
value: c.value.slice(7),
|
||||
username: "",
|
||||
password: "",
|
||||
existingSecret: false,
|
||||
};
|
||||
}
|
||||
// For existing entries without a value, guess the preset from the header name.
|
||||
const preset: Preset = isExisting && c.header === "Authorization" ? "basic" : "custom";
|
||||
return {
|
||||
id: nextDraftId++,
|
||||
preset,
|
||||
header: c.header,
|
||||
value: c.value ?? "",
|
||||
username: "",
|
||||
password: "",
|
||||
existingSecret: isExisting,
|
||||
};
|
||||
}
|
||||
|
||||
function isDraftValid(d: DraftEntry): boolean {
|
||||
// An existing entry with unchanged secret is always valid.
|
||||
if (d.existingSecret) return true;
|
||||
if (d.preset === "basic") {
|
||||
return d.username.trim().length > 0 && d.password.length > 0;
|
||||
}
|
||||
if (d.preset === "bearer") {
|
||||
return d.value.trim().length > 0;
|
||||
}
|
||||
return d.header.trim().length > 0 && d.value.trim().length > 0;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
currentHeaders: HeaderAuthConfig[];
|
||||
onSave: (headers: HeaderAuthConfig[]) => void;
|
||||
onRemove: () => void;
|
||||
};
|
||||
|
||||
export default function AuthHeaderModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
currentHeaders,
|
||||
onSave,
|
||||
onRemove,
|
||||
}: Readonly<Props>) {
|
||||
const [drafts, setDrafts] = useState<DraftEntry[]>(() =>
|
||||
currentHeaders.length > 0 ? currentHeaders.map(configToDraft) : [newDraft()],
|
||||
);
|
||||
|
||||
const isEditing = currentHeaders.length > 0;
|
||||
|
||||
const allValid = useMemo(() => drafts.every(isDraftValid), [drafts]);
|
||||
|
||||
const updateDraft = (index: number, update: Partial<DraftEntry>) => {
|
||||
setDrafts((prev) => prev.map((d, i) => (i === index ? { ...d, ...update } : d)));
|
||||
};
|
||||
|
||||
const removeDraft = (index: number) => {
|
||||
setDrafts((prev) => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const addDraft = () => {
|
||||
setDrafts((prev) => [...prev, newDraft()]);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!allValid) return;
|
||||
onOpenChange(false);
|
||||
onSave(drafts.map(draftToConfig));
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
onOpenChange(false);
|
||||
onRemove();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<ModalContent maxWidthClass="max-w-lg">
|
||||
<ModalHeader
|
||||
title="Header Authentication"
|
||||
description="Authenticate requests using HTTP headers. Multiple entries are treated as alternatives: any one matching grants access."
|
||||
/>
|
||||
|
||||
<GradientFadedBackground />
|
||||
|
||||
<div className="px-8 flex flex-col gap-4">
|
||||
{drafts.map((draft, index) => (
|
||||
<div
|
||||
key={draft.id}
|
||||
className="border border-nb-gray-920 rounded-md p-4 flex flex-col gap-3"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>
|
||||
{drafts.length > 1 ? `Header ${index + 1}` : "Header"}
|
||||
</Label>
|
||||
{drafts.length > 1 && (
|
||||
<Button
|
||||
variant="danger-text"
|
||||
size="xs"
|
||||
onClick={() => removeDraft(index)}
|
||||
>
|
||||
<MinusCircleIcon size={14} />
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!draft.existingSecret && <div>
|
||||
<HelpText margin={false}>Type</HelpText>
|
||||
<Select
|
||||
value={draft.preset}
|
||||
onValueChange={(val) => {
|
||||
const preset = val as Preset;
|
||||
const updates: Partial<DraftEntry> = { preset };
|
||||
if (preset === "basic" || preset === "bearer") {
|
||||
updates.header = "Authorization";
|
||||
} else {
|
||||
updates.header = "";
|
||||
}
|
||||
updates.value = "";
|
||||
updates.username = "";
|
||||
updates.password = "";
|
||||
updateDraft(index, updates);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="basic">Basic Auth (username + password)</SelectItem>
|
||||
<SelectItem value="bearer">Bearer Token</SelectItem>
|
||||
<SelectItem value="custom">Custom Header</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>}
|
||||
|
||||
{draft.existingSecret ? (
|
||||
<div>
|
||||
<HelpText margin={false}>
|
||||
Header: <code>{draft.header}</code>
|
||||
</HelpText>
|
||||
<p className="text-xs text-nb-gray-400 mt-1 mb-2">
|
||||
Value is configured. Enter a new value below to change it, or leave empty to keep the current one.
|
||||
</p>
|
||||
<Input
|
||||
className="mt-1"
|
||||
type="password"
|
||||
showPasswordToggle
|
||||
placeholder="Enter new value to replace..."
|
||||
autoComplete="off"
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
onChange={(e) => {
|
||||
if (e.target.value) {
|
||||
updateDraft(index, {
|
||||
existingSecret: false,
|
||||
preset: "custom",
|
||||
value: e.target.value,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{draft.preset === "basic" && (
|
||||
<>
|
||||
<div>
|
||||
<HelpText margin={false}>Username</HelpText>
|
||||
<Input
|
||||
className="mt-1"
|
||||
value={draft.username}
|
||||
onChange={(e) => updateDraft(index, { username: e.target.value })}
|
||||
placeholder="admin"
|
||||
autoComplete="off"
|
||||
data-1p-ignore
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<HelpText margin={false}>Password</HelpText>
|
||||
<Input
|
||||
className="mt-1"
|
||||
type="password"
|
||||
value={draft.password}
|
||||
showPasswordToggle
|
||||
onChange={(e) => updateDraft(index, { password: e.target.value })}
|
||||
placeholder="Enter password..."
|
||||
autoComplete="off"
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{draft.preset === "bearer" && (
|
||||
<div>
|
||||
<HelpText margin={false}>Token</HelpText>
|
||||
<Input
|
||||
className="mt-1"
|
||||
type="password"
|
||||
value={draft.value}
|
||||
showPasswordToggle
|
||||
onChange={(e) => updateDraft(index, { value: e.target.value })}
|
||||
placeholder="Enter bearer token..."
|
||||
autoComplete="off"
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{draft.preset === "custom" && (
|
||||
<>
|
||||
<div>
|
||||
<HelpText margin={false}>Header Name</HelpText>
|
||||
<Input
|
||||
className="mt-1"
|
||||
value={draft.header}
|
||||
onChange={(e) => updateDraft(index, { header: e.target.value })}
|
||||
placeholder="X-API-Key"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<HelpText margin={false}>Header Value</HelpText>
|
||||
<Input
|
||||
className="mt-1"
|
||||
type="password"
|
||||
value={draft.value}
|
||||
showPasswordToggle
|
||||
onChange={(e) => updateDraft(index, { value: e.target.value })}
|
||||
placeholder="Enter expected value..."
|
||||
autoComplete="off"
|
||||
data-1p-ignore
|
||||
data-lpignore="true"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="secondaryLighter"
|
||||
size="xs"
|
||||
className="self-start"
|
||||
onClick={addDraft}
|
||||
>
|
||||
<PlusCircle size={14} />
|
||||
Add Another Header
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-nb-gray-400">
|
||||
When multiple headers are configured, a request matching any one of them will be granted access.
|
||||
Each header is stripped before forwarding to the backend.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3 w-full justify-between mt-2">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button variant="danger-text" onClick={handleRemove}>
|
||||
Remove All
|
||||
</Button>
|
||||
<div className="flex gap-3">
|
||||
<ModalClose asChild>
|
||||
<Button variant="secondary">Cancel</Button>
|
||||
</ModalClose>
|
||||
<Button variant="primary" onClick={handleSave} disabled={!allValid}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div />
|
||||
<div className="flex gap-3">
|
||||
<ModalClose asChild>
|
||||
<Button variant="secondary">Cancel</Button>
|
||||
</ModalClose>
|
||||
<Button variant="primary" onClick={handleSave} disabled={!allValid}>
|
||||
Add Header Auth
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from "@/interfaces/ReverseProxy";
|
||||
import HelpText from "@components/HelpText";
|
||||
import Separator from "@components/Separator";
|
||||
import { isNetBirdHosted } from "@/utils/netbird";
|
||||
import {
|
||||
SelectDropdown,
|
||||
SelectOption,
|
||||
@@ -99,18 +100,35 @@ export const CustomDomainModal = ({
|
||||
|
||||
<div className={"px-8 flex flex-col gap-6 pt-6 pb-8"}>
|
||||
{availableClusters.length === 0 ? (
|
||||
<Callout variant="warning">
|
||||
No proxy clusters are currently connected. Please ensure at least
|
||||
one proxy is running before adding a domain. <br /> Learn more
|
||||
about{" "}
|
||||
<InlineLink
|
||||
href={REVERSE_PROXY_CLUSTERS_DOCS_LINK}
|
||||
target={"_blank"}
|
||||
>
|
||||
Proxy Clusters
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Callout>
|
||||
isNetBirdHosted() ? (
|
||||
<Callout variant={"warning"}>
|
||||
No proxy clusters are currently connected. Please try again in a
|
||||
few minutes. If the issue persists, check{" "}
|
||||
<InlineLink
|
||||
href={"https://status.netbird.io/"}
|
||||
target={"_blank"}
|
||||
>
|
||||
NetBird Status
|
||||
</InlineLink>{" "}
|
||||
or reach out to{" "}
|
||||
<InlineLink href={"mailto:support@netbird.io"}>
|
||||
support@netbird.io
|
||||
</InlineLink>
|
||||
</Callout>
|
||||
) : (
|
||||
<Callout variant="warning">
|
||||
No proxy clusters are currently connected. Please ensure at
|
||||
least one proxy is running before adding a domain. <br /> Learn
|
||||
more about{" "}
|
||||
<InlineLink
|
||||
href={REVERSE_PROXY_CLUSTERS_DOCS_LINK}
|
||||
target={"_blank"}
|
||||
>
|
||||
Proxy Clusters
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Callout>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "@/interfaces/ReverseProxy";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import { isNetBirdHosted } from "@/utils/netbird";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
@@ -79,18 +80,36 @@ export const CustomDomainVerificationModal = ({
|
||||
</Steps>
|
||||
<div className={"flex flex-col gap-6"}>
|
||||
{!cnameTarget ? (
|
||||
<Callout variant={"warning"}>
|
||||
No proxy clusters are currently connected. Please ensure at
|
||||
least one proxy is running to configure DNS verification. <br />
|
||||
Learn more about{" "}
|
||||
<InlineLink
|
||||
href={REVERSE_PROXY_CLUSTERS_DOCS_LINK}
|
||||
target={"_blank"}
|
||||
>
|
||||
Proxy Clusters
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Callout>
|
||||
isNetBirdHosted() ? (
|
||||
<Callout variant={"warning"}>
|
||||
No proxy clusters are currently connected. Please try again in
|
||||
a few minutes. If the issue persists, check{" "}
|
||||
<InlineLink
|
||||
href={"https://status.netbird.io/"}
|
||||
target={"_blank"}
|
||||
>
|
||||
NetBird Status
|
||||
</InlineLink>{" "}
|
||||
or reach out to{" "}
|
||||
<InlineLink href={"mailto:support@netbird.io"}>
|
||||
support@netbird.io
|
||||
</InlineLink>
|
||||
</Callout>
|
||||
) : (
|
||||
<Callout variant={"warning"}>
|
||||
No proxy clusters are currently connected. Please ensure at
|
||||
least one proxy is running to configure DNS verification.{" "}
|
||||
<br />
|
||||
Learn more about{" "}
|
||||
<InlineLink
|
||||
href={REVERSE_PROXY_CLUSTERS_DOCS_LINK}
|
||||
target={"_blank"}
|
||||
>
|
||||
Proxy Clusters
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Callout>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<Card className={"w-full"}>
|
||||
|
||||
Loaded 20 of 46 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user