Compare commits

...
Author SHA1 Message Date
Eduard Gert 0841caecbb Fix dns zone domain validation and peers last seen sort (#595) 2026-03-25 17:50:22 +01:00
Eduard Gert c7846760d1 Add reverse proxy auth headers (#593)
* Add reverse proxy access rules

* Fix coderabbit comments

* Fix coderabbit comments

* Fix coderabbit comments

* Add auth header modal

* Remove password managers from auth headers

* fix unique id

* Remove gradient, fix button roundness

* update lucide, add additional event auth methods

* Clear existing header value on change
2026-03-25 14:31:36 +01:00
Viktor Liu 8c283b6ef9 Support optional subdomain for reverse proxy domains (#589) 2026-03-24 16:01:01 +01:00
Eduard Gert 34ae3b4da6 Add reverse proxy access rules (#592)
* Add reverse proxy access rules

* Fix coderabbit comments

* Fix coderabbit comments

* Fix coderabbit comments
2026-03-24 16:00:31 +01:00
25 changed files with 1445 additions and 175 deletions

No files matched your search

+4 -4
View File
@@ -59,7 +59,7 @@
"ip-cidr": "^3.1.0",
"js-cookie": "^3.0.5",
"lodash": "^4.17.23",
"lucide-react": "^0.562.0",
"lucide-react": "^0.566.0",
"next": "^16.1.7",
"next-themes": "^0.2.1",
"punycode": "^2.3.1",
@@ -6923,9 +6923,9 @@
}
},
"node_modules/lucide-react": {
"version": "0.562.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz",
"integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==",
"version": "0.566.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.566.0.tgz",
"integrity": "sha512-b18qC/JAh1X9rVKlF5EtSIyumdIYuh78b0JShynZnHbcaWR4AW4oZyi8Ms/aQYVSnLPlAnMhug2hSr19BgVZAw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+1 -1
View File
@@ -67,7 +67,7 @@
"ip-cidr": "^3.1.0",
"js-cookie": "^3.0.5",
"lodash": "^4.17.23",
"lucide-react": "^0.562.0",
"lucide-react": "^0.566.0",
"next": "^16.1.7",
"next-themes": "^0.2.1",
"punycode": "^2.3.1",
+1 -1
View File
@@ -74,7 +74,7 @@ export const buttonVariants = cva(
"",
],
"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",
"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 rounded-sm",
],
"default-outline": [
"dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20",
+1 -1
View File
@@ -98,7 +98,7 @@ const SelectItem = React.forwardRef<
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="flex-shrink-0">{icon}</span>
<div className="flex flex-col">
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
+25 -7
View File
@@ -48,6 +48,9 @@ interface SelectDropdownProps {
children?: React.ReactNode;
maxHeight?: number;
triggerClassName?: string;
iconSize?: number;
truncate?: boolean;
compact?: boolean;
}
export function SelectDropdown({
@@ -68,6 +71,9 @@ export function SelectDropdown({
children,
maxHeight,
triggerClassName,
iconSize = 14,
truncate = false,
compact = false,
}: Readonly<SelectDropdownProps>) {
const [inputRef, { width }] = useElementSize<HTMLButtonElement>();
@@ -107,15 +113,18 @@ export function SelectDropdown({
const SelectedItem = () => {
return (
<div className={"flex items-center gap-2.5"}>
{selected?.icon && <selected.icon size={14} width={14} />}
<div className={cn("flex items-center gap-2.5", truncate && "min-w-0")}>
{selected?.icon && <selected.icon size={iconSize} width={iconSize} />}
<div
className={cn(
"flex flex-col text-sm font-medium",
size === "xs" && "text-xs",
truncate && "min-w-0",
)}
>
<span className={"text-nb-gray-200"}>{selected?.label}</span>
<span className={cn("text-nb-gray-200", truncate && "truncate")}>
{selected?.label}
</span>
</div>
</div>
);
@@ -216,20 +225,22 @@ export function SelectDropdown({
<ScrollArea
className={cn(
"overflow-y-auto flex flex-col gap-1 pl-2 pr-3",
!showSearch && "pt-2",
"overflow-y-auto flex flex-col gap-1",
compact ? "pl-1 pr-1" : "pl-2 pr-3",
!showSearch && (compact ? "pt-1" : "pt-2"),
)}
style={{
maxHeight: maxHeight ?? 380,
}}
>
<CommandGroup>
<div className={"grid grid-cols-1 gap-1 pb-2 w-full"}>
<div className={cn("grid grid-cols-1 gap-1 w-full", compact ? "pb-1" : "pb-2")}>
{filteredItems.map((option) => (
<SelectDropdownItem
option={option}
toggle={toggle}
key={option.value}
iconSize={iconSize}
showValue={showValues}
size={size}
/>
@@ -249,11 +260,13 @@ const SelectDropdownItem = ({
toggle,
showValue = false,
size = "sm",
iconSize = 14,
}: {
option: SelectOption;
toggle: (value: string) => void;
showValue?: boolean;
size: "xs" | "sm";
iconSize?: number;
}) => {
const value = option.value || "" + option.label || "";
const elementRef = useRef<HTMLDivElement>(null);
@@ -285,7 +298,12 @@ const SelectDropdownItem = ({
option?.disabled && "cursor-not-allowed",
)}
>
{option.icon && <option.icon size={14} width={14} />}
{option.icon && (
<div className={"shrink-0"}>
<option.icon size={iconSize} width={iconSize} />
</div>
)}
{option?.renderItem && option.renderItem()}
{!option?.renderItem && (
<div
+11
View File
@@ -53,6 +53,7 @@ declare module "@tanstack/table-core" {
}
interface SortingFns {
checkbox: SortingFn<unknown>;
datetime: SortingFn<unknown>;
}
}
@@ -99,6 +100,15 @@ const arrIncludesSomeExact: FilterFn<any> = (
return value.some((val) => val === rowValue);
};
const datetimeSort: SortingFn<any> = (rowA, rowB, columnId) => {
const aConnected = rowA.original?.connected;
const bConnected = rowB.original?.connected;
if (aConnected !== bConnected) return aConnected ? 1 : -1;
const a = dayjs(rowA.getValue(columnId)).valueOf();
const b = dayjs(rowB.getValue(columnId)).valueOf();
return a - b;
};
const checkboxSort: SortingFn<any> = (rowA, rowB, columnId) => {
const valueA =
columnId === "select" ? rowA.getIsSelected() : rowA.getValue(columnId);
@@ -324,6 +334,7 @@ export function DataTable<TData, TValue>({
},
sortingFns: {
checkbox: checkboxSort,
datetime: datetimeSort,
},
getRowId: useRowId ? (row) => row.id : undefined,
onRowSelectionChange: setRowSelection,
+9 -2
View File
@@ -14,6 +14,7 @@ type Props = {
center?: boolean;
className?: string;
sorting?: boolean;
onSort?: () => void;
name?: string;
};
export default function DataTableHeader({
@@ -23,14 +24,20 @@ export default function DataTableHeader({
center,
className,
sorting = true,
onSort,
name,
}: Props) {
const serverPagination = useOptionalServerPagination();
const handleSort = () => {
const direction = column.getIsSorted() === "asc" ? "desc" : "asc";
column.toggleSorting(direction === "desc");
if (onSort) {
onSort();
} else {
const direction = column.getIsSorted() === "asc" ? "desc" : "asc";
column.toggleSorting(direction === "desc");
}
if (name && serverPagination?.setSort) {
const direction = column.getIsSorted() === "asc" ? "desc" : "asc";
serverPagination.setSort(name, direction);
}
};
+8 -2
View File
@@ -9,8 +9,11 @@ import { useCountries } from "@/contexts/CountryProvider";
type Props = {
value: string;
onChange: (value: string) => void;
iconSize?: number;
popoverWidth?: "auto" | "content" | number;
truncate?: boolean;
};
export const CountrySelector = ({ value, onChange }: Props) => {
export const CountrySelector = ({ value, onChange, iconSize = 20, popoverWidth, truncate }: Props) => {
const { countries, isLoading } = useCountries();
const countryList = useMemo(() => {
@@ -22,7 +25,7 @@ export const CountrySelector = ({ value, onChange }: Props) => {
}) =>
createElement(RoundedFlag, {
country: country.country_code,
size: 20,
size: iconSize,
...props,
});
return {
@@ -42,7 +45,10 @@ export const CountrySelector = ({ value, onChange }: Props) => {
searchPlaceholder={"Search country..."}
value={value}
onChange={onChange}
iconSize={iconSize}
options={countryList || []}
popoverWidth={popoverWidth}
truncate={truncate}
/>
</div>
);
+15 -5
View File
@@ -13,7 +13,11 @@ 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 +25,11 @@ 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 +55,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],
);
+20
View File
@@ -18,9 +18,17 @@ export interface ReverseProxy {
pass_host_header?: boolean;
rewrite_redirects?: boolean;
auth?: ReverseProxyAuth;
access_restrictions?: AccessRestrictions;
meta?: ReverseProxyMeta;
}
export interface AccessRestrictions {
allowed_cidrs?: string[];
blocked_cidrs?: string[];
allowed_countries?: string[];
blocked_countries?: string[];
}
export interface ReverseProxyMeta {
created_at: string;
status: ReverseProxyStatus;
@@ -77,6 +85,13 @@ export interface ReverseProxyAuth {
link_auth?: {
enabled: boolean;
};
header_auths?: HeaderAuthConfig[];
}
export interface HeaderAuthConfig {
enabled: boolean;
header: string;
value: string;
}
export interface ReverseProxyDomain {
@@ -86,6 +101,7 @@ export interface ReverseProxyDomain {
type: ReverseProxyDomainType;
target_cluster?: string;
supports_custom_ports?: boolean;
require_subdomain?: boolean;
}
export enum ReverseProxyDomainType {
@@ -129,6 +145,7 @@ export interface ReverseProxyEvent {
auth_method_used?: string;
country_code?: string;
city_name?: string;
subdivision_code?: string;
bytes_upload: number;
bytes_download: number;
protocol?: EventProtocol;
@@ -181,5 +198,8 @@ 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_ACCESS_CONTROL_DOCS_LINK =
"https://docs.netbird.io/manage/reverse-proxy";
export const REVERSE_PROXY_TROUBLESHOOTING_DOCS_LINK =
"https://docs.netbird.io/manage/reverse-proxy#troubleshooting";
+3 -2
View File
@@ -91,10 +91,11 @@ export function DNSZoneModalContent({
if (domain == "") return "";
const valid = validator.isValidDomain(domain, {
allowWildcard: false,
allowOnlyTld: false,
allowOnlyTld: true,
preventLeadingAndTrailingDots: true,
});
if (!valid) {
return "Please enter a valid domain, e.g. company.internal or intra.example.com";
return "Please enter a valid domain, e.g. internal, company.internal or intra.example.com";
}
}, [domain]);
+13 -7
View File
@@ -138,8 +138,18 @@ const PeersTableColumns: ColumnDef<Peer>[] = [
},
{
accessorKey: "last_seen",
header: ({ column }) => {
return <DataTableHeader column={column}>Last seen</DataTableHeader>;
header: ({ column, table }) => {
return (
<DataTableHeader
column={column}
onSort={() => {
const desc = column.getIsSorted() === "desc";
table.setSorting([{ id: "last_seen", desc: !desc }]);
}}
>
Last seen
</DataTableHeader>
);
},
sortingFn: "datetime",
cell: ({ row }) => <PeerLastSeenCell peer={row.original} />,
@@ -226,17 +236,13 @@ export default function PeersTable({
"netbird-table-sort" + path,
[
{
id: "connected",
id: "last_seen",
desc: true,
},
{
id: "name",
desc: false,
},
{
id: "last_seen",
desc: true,
},
],
);
@@ -0,0 +1,315 @@
import { useEffect, useMemo, useReducer, useRef } from "react";
import { Label } from "@components/Label";
import HelpText from "@components/HelpText";
import Button from "@components/Button";
import { Input } from "@components/Input";
import cidr from "ip-cidr";
import {
FlagIcon,
MinusCircleIcon,
NetworkIcon,
PlusIcon,
ShieldCheckIcon,
ShieldXIcon,
WorkflowIcon,
} from "lucide-react";
import {
SelectDropdown,
SelectOption,
} from "@components/select/SelectDropdown";
import { CountrySelector } from "@/components/ui/CountrySelector";
import { AccessRestrictions } from "@/interfaces/ReverseProxy";
type AccessAction = "allow" | "block";
type AccessRuleType = "country" | "ip" | "cidr";
const ACTION_OPTIONS: SelectOption[] = [
{
label: "Allow Only",
value: "allow",
icon: (props) => <ShieldCheckIcon {...props} className="text-green-500" />,
},
{
label: "Block Only",
value: "block",
icon: (props) => <ShieldXIcon {...props} className="text-red-500" />,
},
];
const TYPE_OPTIONS: SelectOption[] = [
{
label: "Country",
value: "country",
icon: (props) => <FlagIcon {...props} />,
},
{
label: "IP Address",
value: "ip",
icon: (props) => <WorkflowIcon {...props} />,
},
{
label: "CIDR Block",
value: "cidr",
icon: (props) => <NetworkIcon {...props} />,
},
];
type AccessRule = {
id: string;
action: AccessAction;
type: AccessRuleType;
value: string;
};
type RulesAction =
| { type: "add" }
| { type: "remove"; id: string }
| {
type: "update";
id: string;
field: "action" | "type" | "value";
value: string;
};
const nextId = () => crypto.randomUUID();
function rulesReducer(state: AccessRule[], action: RulesAction): AccessRule[] {
switch (action.type) {
case "add":
return [
...state,
{ id: nextId(), action: "allow", type: "country", value: "" },
];
case "remove":
return state.filter((r) => r.id !== action.id);
case "update":
return state.map((r) => {
if (r.id !== action.id) return r;
if (action.field === "type") {
return { ...r, type: action.value as AccessRuleType, value: "" };
}
return { ...r, [action.field]: action.value };
});
}
}
function restrictionsToRules(
restrictions: AccessRestrictions | undefined,
): AccessRule[] {
if (!restrictions) return [];
const rules: AccessRule[] = [];
restrictions.allowed_countries?.forEach((v) =>
rules.push({ id: nextId(), action: "allow", type: "country", value: v }),
);
restrictions.blocked_countries?.forEach((v) =>
rules.push({ id: nextId(), action: "block", type: "country", value: v }),
);
restrictions.allowed_cidrs?.forEach((v) => {
const isIp = v.endsWith("/32");
rules.push({ id: nextId(), action: "allow", type: isIp ? "ip" : "cidr", value: isIp ? v.replace(/\/32$/, "") : v });
});
restrictions.blocked_cidrs?.forEach((v) => {
const isIp = v.endsWith("/32");
rules.push({ id: nextId(), action: "block", type: isIp ? "ip" : "cidr", value: isIp ? v.replace(/\/32$/, "") : v });
});
return rules;
}
function rulesToRestrictions(
rules: AccessRule[],
): AccessRestrictions | undefined {
const allowed_countries: string[] = [];
const blocked_countries: string[] = [];
const allowed_cidrs: string[] = [];
const blocked_cidrs: string[] = [];
for (const rule of rules) {
if (!rule.value) continue;
if (rule.type === "country") {
if (rule.action === "allow") allowed_countries.push(rule.value);
else blocked_countries.push(rule.value);
} else {
const value = rule.type === "ip" && !rule.value.includes("/") ? `${rule.value}/32` : rule.value;
if (rule.action === "allow") allowed_cidrs.push(value);
else blocked_cidrs.push(value);
}
}
const hasAny =
allowed_countries.length > 0 ||
blocked_countries.length > 0 ||
allowed_cidrs.length > 0 ||
blocked_cidrs.length > 0;
if (!hasAny) return undefined;
return {
...(allowed_countries.length > 0 && { allowed_countries }),
...(blocked_countries.length > 0 && { blocked_countries }),
...(allowed_cidrs.length > 0 && { allowed_cidrs }),
...(blocked_cidrs.length > 0 && { blocked_cidrs }),
};
}
type Props = {
value: AccessRestrictions | undefined;
onChange: (value: AccessRestrictions | undefined) => void;
onValidationChange?: (hasErrors: boolean) => void;
};
function validateRule(rule: AccessRule): string {
if (rule.type === "country" || !rule.value) return "";
if (rule.type === "ip") {
const val = rule.value.includes("/") ? rule.value : `${rule.value}/32`;
if (!cidr.isValidAddress(val)) {
return "Please enter a valid IP address, e.g., 85.203.15.42";
}
} else {
if (!rule.value.includes("/") || !cidr.isValidAddress(rule.value)) {
return "Please enter a valid CIDR block, e.g., 74.125.0.0/16";
}
}
return "";
}
export const ReverseProxyAccessControlRules = ({ value, onChange, onValidationChange }: Props) => {
const [rules, dispatch] = useReducer(
rulesReducer,
value,
restrictionsToRules,
);
const errors = useMemo(
() => Object.fromEntries(rules.map((r) => [r.id, validateRule(r)])),
[rules],
);
const hasErrors = useMemo(
() => Object.values(errors).some((e) => e !== ""),
[errors],
);
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const onValidationChangeRef = useRef(onValidationChange);
onValidationChangeRef.current = onValidationChange;
useEffect(() => {
onChangeRef.current(rulesToRestrictions(rules));
}, [rules]);
useEffect(() => {
onValidationChangeRef.current?.(hasErrors);
}, [hasErrors]);
return (
<div className={"flex-col flex"}>
<div>
<Label>Access Control Rules</Label>
<HelpText>
Define rules to allow or block traffic based on country, IP address,
or CIDR block.
<br />
Block rules always take priority over allow rules.
</HelpText>
</div>
{rules.length > 0 && (
<div className="flex flex-col gap-3 mt-1 mb-4">
{rules.map((rule) => (
<div key={rule.id} className="flex items-center">
<div className="w-[160px] shrink-0 [&_button]:rounded-r-none [&_button]:w-[160px]">
<SelectDropdown
value={rule.action}
onChange={(v) =>
dispatch({
type: "update",
id: rule.id,
field: "action",
value: v,
})
}
options={ACTION_OPTIONS}
compact
/>
</div>
<div className="w-[160px] shrink-0 -ml-px [&_button]:rounded-none [&_button]:w-[160px]">
<SelectDropdown
value={rule.type}
onChange={(v) =>
dispatch({
type: "update",
id: rule.id,
field: "type",
value: v,
})
}
options={TYPE_OPTIONS}
compact
/>
</div>
<div className="flex-1 min-w-0 -ml-px [&_button]:rounded-l-none [&_input]:rounded-l-none">
{rule.type === "country" ? (
<CountrySelector
iconSize={16}
popoverWidth={350}
truncate
value={rule.value}
onChange={(v) =>
dispatch({
type: "update",
id: rule.id,
field: "value",
value: v,
})
}
/>
) : (
<Input
placeholder={
rule.type === "ip"
? "e.g., 85.203.15.42"
: "e.g., 74.125.0.0/16"
}
value={rule.value}
onChange={(e) =>
dispatch({
type: "update",
id: rule.id,
field: "value",
value: e.target.value,
})
}
error={errors[rule.id]}
errorTooltip={true}
maxWidthClass="w-full"
/>
)}
</div>
<Button
variant="default-outline"
className="h-[42px] w-[42px] !px-0 shrink-0 ml-2"
onClick={() => dispatch({ type: "remove", id: rule.id })}
aria-label="Remove rule"
>
<MinusCircleIcon size={14} />
</Button>
</div>
))}
</div>
)}
<Button
variant="dotted"
className="w-full"
size="sm"
onClick={() => dispatch({ type: "add" })}
>
<PlusIcon size={14} />
Add Rule
</Button>
</div>
);
};
+121 -13
View File
@@ -21,12 +21,14 @@ import {
Binary,
ClockFadingIcon,
ExternalLinkIcon,
FileCode2Icon,
GlobeIcon,
LockKeyhole,
MapPinned,
PlusCircle,
RectangleEllipsis,
Settings,
ShieldCheckIcon,
Users,
} from "lucide-react";
import { useRouter } from "next/navigation";
@@ -37,7 +39,10 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
import { Network, NetworkResource } from "@/interfaces/Network";
import { Peer } from "@/interfaces/Peer";
import {
AccessRestrictions,
HeaderAuthConfig,
isL4Mode as isL4ServiceMode,
REVERSE_PROXY_ACCESS_CONTROL_DOCS_LINK,
REVERSE_PROXY_AUTHENTICATION_DOCS_LINK,
REVERSE_PROXY_SERVICES_DOCS_LINK,
REVERSE_PROXY_SETTINGS_DOCS_LINK,
@@ -53,6 +58,7 @@ import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
import ReverseProxyDomainInput from "./domain/ReverseProxyDomainInput";
import { useReverseProxyDomain } from "./domain/useReverseProxyDomain";
import AuthPasswordModal from "@/modules/reverse-proxy/auth/AuthPasswordModal";
import AuthHeaderModal from "@/modules/reverse-proxy/auth/AuthHeaderModal";
import AuthPinModal from "@/modules/reverse-proxy/auth/AuthPinModal";
import AuthSSOModal from "@/modules/reverse-proxy/auth/AuthSSOModal";
import ReverseProxyHTTPTargets from "@/modules/reverse-proxy/ReverseProxyHTTPTargets";
@@ -61,14 +67,15 @@ import ReverseProxyTargetModal from "@/modules/reverse-proxy/targets/ReverseProx
import { type Target } from "@/modules/reverse-proxy/targets/ReverseProxyTargetSelector";
import { useReverseProxyAddress } from "@/modules/reverse-proxy/targets/ReverseProxyAddressInput";
import {
validateTimeout,
validateSessionIdleTimeout,
validateTimeout,
} from "@/modules/reverse-proxy/targets/useReverseProxyTargetOptions";
import useGroupHelper from "@/modules/groups/useGroupHelper";
import {
ReverseProxyServiceModeSelector,
SERVICE_MODES,
} from "@/modules/reverse-proxy/ReverseProxyServiceModeSelector";
import { ReverseProxyAccessControlRules } from "@/modules/reverse-proxy/ReverseProxyAccessControlRules";
type Props = {
open: boolean;
@@ -236,10 +243,24 @@ export default function ReverseProxyModal({
reverseProxy?.auth?.link_auth?.enabled ?? false,
);
const [headerAuthsEnabled, setHeaderAuthsEnabled] = useState(
(reverseProxy?.auth?.header_auths ?? []).some((h) => h.enabled),
);
const [headerAuths, setHeaderAuths] = useState<HeaderAuthConfig[]>(
reverseProxy?.auth?.header_auths ?? [],
);
const [accessRestrictions, setAccessRestrictions] = useState<
AccessRestrictions | undefined
>(reverseProxy?.access_restrictions);
const [accessControlHasErrors, setAccessControlHasErrors] = useState(false);
// Auth modal states
const [passwordModalOpen, setPasswordModalOpen] = useState(false);
const [ssoModalOpen, setSsoModalOpen] = useState(false);
const [pinModalOpen, setPinModalOpen] = useState(false);
const [headerModalOpen, setHeaderModalOpen] = useState(false);
// Target being added/edited
const [targetModalOpen, setTargetModalOpen] = useState(false);
@@ -248,8 +269,12 @@ export default function ReverseProxyModal({
);
const canContinueToSettings = useMemo(() => {
const subdomainRequired =
selectedDomain?.require_subdomain === true;
const isSubdomainValid =
subdomain.length > 0 && baseDomain.length > 0 && !domainAlreadyExists;
baseDomain.length > 0 &&
!domainAlreadyExists &&
(subdomain.length > 0 || !subdomainRequired);
const isValidPort = (port: number) => port >= 1 && port <= 65535;
const hasHttpEndpoint = !isL4Mode && targets.length > 0;
const hasL4Endpoint =
@@ -264,6 +289,7 @@ export default function ReverseProxyModal({
subdomain,
baseDomain,
domainAlreadyExists,
selectedDomain,
serviceMode,
targets.length,
isL4Mode,
@@ -305,16 +331,20 @@ export default function ReverseProxyModal({
);
};
const hasNoAuth =
!passwordEnabled && !pinEnabled && !bearerEnabled && !linkAuthEnabled;
const isUnprotected =
!passwordEnabled &&
!pinEnabled &&
!bearerEnabled &&
!linkAuthEnabled &&
!headerAuthsEnabled &&
!accessRestrictions;
const handleSubmit = async () => {
// Show warning if no authentication is configured (HTTP only; TLS is pass-through)
if (!isL4Mode && hasNoAuth) {
if (isUnprotected) {
const confirmed = await confirm({
title: "No Authentication Configured",
title: "No Protection Configured",
description:
"This service will be publicly accessible to everyone on the internet without any restrictions. Are you sure you want to continue?",
"This service has no authentication or access control rules configured. It will be publicly accessible to everyone on the internet. Are you sure you want to continue?",
type: "warning",
confirmText: reverseProxy ? "Save Changes" : "Add Service",
cancelText: "Cancel",
@@ -341,6 +371,9 @@ export default function ReverseProxyModal({
link_auth: {
enabled: linkAuthEnabled,
},
header_auths: headerAuthsEnabled
? headerAuths.map((h) => ({ ...h, enabled: true }))
: [],
};
const l4TargetPayload: ReverseProxyTarget | undefined = l4Target
@@ -383,6 +416,7 @@ export default function ReverseProxyModal({
pass_host_header: isL4Mode ? undefined : passHostHeader,
rewrite_redirects: isL4Mode ? undefined : rewriteRedirects,
auth: isL4Mode ? undefined : auth,
access_restrictions: accessRestrictions,
},
proxyId: reverseProxy?.id,
onSuccess: () => {
@@ -426,10 +460,17 @@ export default function ReverseProxyModal({
</TabsTrigger>
{!isL4Mode && (
<TabsTrigger value={"auth"} disabled={!canContinueToSettings}>
<LockKeyhole size={16} />
<LockKeyhole size={14} />
Authentication
</TabsTrigger>
)}
<TabsTrigger
value={"access-control"}
disabled={!canContinueToSettings}
>
<ShieldCheckIcon size={14} />
Access Control
</TabsTrigger>
<TabsTrigger value={"settings"} disabled={!canContinueToSettings}>
<Settings size={14} />
Advanced Settings
@@ -444,6 +485,7 @@ export default function ReverseProxyModal({
baseDomain={baseDomain}
onBaseDomainChange={setBaseDomain}
domainAlreadyExists={domainAlreadyExists}
subdomainRequired={selectedDomain?.require_subdomain === true}
clusterOffline={
reverseProxy?.proxy_cluster && !isClusterConnected
? { clusterName: reverseProxy.proxy_cluster }
@@ -527,10 +569,31 @@ export default function ReverseProxyModal({
enabled={pinEnabled}
onClick={() => setPinModalOpen(true)}
/>
<SettingCard.Item
label={
<>
<FileCode2Icon size={15} />
HTTP Headers
</>
}
description="Require specific HTTP headers to access this service."
enabled={headerAuthsEnabled}
onClick={() => setHeaderModalOpen(true)}
/>
</SettingCard>
</div>
</TabsContent>
<TabsContent value={"access-control"} className={"pb-8"}>
<div className={"px-8 flex-col flex gap-4"}>
<ReverseProxyAccessControlRules
value={accessRestrictions}
onChange={setAccessRestrictions}
onValidationChange={setAccessControlHasErrors}
/>
</div>
</TabsContent>
<TabsContent value={"settings"} className={"pb-8"}>
<div className={"px-8 flex-col flex gap-6"}>
{(serviceMode === ServiceMode.TCP ||
@@ -627,6 +690,10 @@ export default function ReverseProxyModal({
href: REVERSE_PROXY_AUTHENTICATION_DOCS_LINK,
label: "Authentication",
},
"access-control": {
href: REVERSE_PROXY_ACCESS_CONTROL_DOCS_LINK,
label: "Access Control",
},
settings: {
href: REVERSE_PROXY_SETTINGS_DOCS_LINK,
label: "Settings",
@@ -653,7 +720,9 @@ export default function ReverseProxyModal({
</ModalClose>
<Button
variant={"primary"}
onClick={() => setTab(isL4Mode ? "settings" : "auth")}
onClick={() =>
setTab(isL4Mode ? "access-control" : "auth")
}
disabled={!canContinueToSettings}
>
Continue
@@ -669,9 +738,27 @@ export default function ReverseProxyModal({
>
Back
</Button>
<Button
variant={"primary"}
onClick={() => setTab("access-control")}
>
Continue
</Button>
</>
)}
{tab === "access-control" && (
<>
<Button
variant={"secondary"}
onClick={() => setTab(isL4Mode ? "targets" : "auth")}
>
Back
</Button>
<Button
variant={"primary"}
onClick={() => setTab("settings")}
disabled={accessControlHasErrors}
>
Continue
</Button>
@@ -682,7 +769,7 @@ export default function ReverseProxyModal({
<>
<Button
variant={"secondary"}
onClick={() => setTab(isL4Mode ? "targets" : "auth")}
onClick={() => setTab("access-control")}
>
Back
</Button>
@@ -691,7 +778,8 @@ export default function ReverseProxyModal({
disabled={
!canContinueToSettings ||
!permission?.services?.create ||
!!timeoutError
!!timeoutError ||
accessControlHasErrors
}
onClick={handleSubmit}
>
@@ -711,7 +799,8 @@ export default function ReverseProxyModal({
disabled={
!canContinueToSettings ||
!permission?.services?.update ||
!!timeoutError
!!timeoutError ||
accessControlHasErrors
}
onClick={handleSubmit}
>
@@ -806,6 +895,25 @@ export default function ReverseProxyModal({
}, 200);
}}
/>
<AuthHeaderModal
open={headerModalOpen}
onOpenChange={setHeaderModalOpen}
key={headerModalOpen ? "h1" : "h0"}
currentHeaders={headerAuths}
onSave={(headers) => {
setTimeout(() => {
setHeaderAuths(headers);
setHeaderAuthsEnabled(true);
}, 200);
}}
onRemove={() => {
setTimeout(() => {
setHeaderAuths([]);
setHeaderAuthsEnabled(false);
}, 200);
}}
/>
</Modal>
);
}
@@ -0,0 +1,454 @@
import Button from "@components/Button";
import { Callout } from "@components/Callout";
import { Input } from "@components/Input";
import { Modal, ModalClose, ModalContent } from "@components/modal/Modal";
import ModalHeader from "@components/modal/ModalHeader";
import {
SelectDropdown,
SelectOption,
} from "@components/select/SelectDropdown";
import {
BracesIcon,
CircleUserIcon,
FileCode2Icon,
KeyRoundIcon,
MinusCircleIcon,
PlusIcon,
UserIcon,
} from "lucide-react";
import React, { useMemo, useReducer, useRef } from "react";
import { useHasChanges } from "@/hooks/useHasChanges";
import type { HeaderAuthConfig } from "@/interfaces/ReverseProxy";
type HeaderType = "basic" | "bearer" | "custom";
interface HeaderAuthItem {
id: string;
type: HeaderType;
header: string;
value: string;
username: string;
password: string;
existingSecret: boolean;
}
const HEADER_TYPE_OPTIONS: SelectOption[] = [
{
value: "basic" satisfies HeaderType,
label: "Basic Auth",
icon: () => <CircleUserIcon size={14} />,
},
{
value: "bearer" satisfies HeaderType,
label: "Bearer Token",
icon: () => <KeyRoundIcon size={14} />,
},
{
value: "custom" satisfies HeaderType,
label: "Custom Header",
icon: () => <BracesIcon size={14} />,
},
];
const MASKED_VALUE = "••••••••";
const INPUT_PROPS = {
autoComplete: "off",
"data-1p-ignore": true,
"data-lpignore": "true",
"data-form-type": "other",
} as const;
function createHeaderEntry(
overrides?: Partial<HeaderAuthItem>,
): HeaderAuthItem {
return {
id: crypto.randomUUID(),
type: "basic",
header: "Authorization",
value: "",
username: "",
password: "",
existingSecret: false,
...overrides,
};
}
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 headerEntryToConfig(entry: HeaderAuthItem): HeaderAuthConfig {
if (entry.existingSecret) {
const value = entry.value === MASKED_VALUE ? "" : entry.value;
return { enabled: true, header: entry.header, value };
}
switch (entry.type) {
case "basic": {
const encoded = toBase64(`${entry.username}:${entry.password}`);
return {
enabled: true,
header: "Authorization",
value: `Basic ${encoded}`,
};
}
case "bearer":
return {
enabled: true,
header: "Authorization",
value: `Bearer ${entry.value}`,
};
case "custom":
return { enabled: true, header: entry.header, value: entry.value };
}
}
function configToHeaderEntry(config: HeaderAuthConfig): HeaderAuthItem {
const isExisting = !config.value;
if (config.header === "Authorization" && config.value?.startsWith("Basic ")) {
try {
const decoded = fromBase64(config.value.slice(6));
const sep = decoded.indexOf(":");
if (sep >= 0) {
return createHeaderEntry({
type: "basic",
username: decoded.slice(0, sep),
password: decoded.slice(sep + 1),
});
}
} catch {}
}
if (
config.header === "Authorization" &&
config.value?.startsWith("Bearer ")
) {
return createHeaderEntry({ type: "bearer", value: config.value.slice(7) });
}
return createHeaderEntry({
type: isExisting && config.header === "Authorization" ? "basic" : "custom",
header: config.header,
value: isExisting ? MASKED_VALUE : config.value ?? "",
existingSecret: isExisting,
});
}
function isHeaderValid(entry: HeaderAuthItem): boolean {
if (entry.existingSecret) return true;
switch (entry.type) {
case "basic":
return entry.username.trim().length > 0 && entry.password.length > 0;
case "bearer":
return entry.value.trim().length > 0;
case "custom":
return entry.header.trim().length > 0 && entry.value.trim().length > 0;
}
}
type HeaderAction =
| { type: "add" }
| { type: "remove"; index: number }
| { type: "update"; index: number; updates: Partial<HeaderAuthItem> };
function headersReducer(
state: HeaderAuthItem[],
action: HeaderAction,
): HeaderAuthItem[] {
switch (action.type) {
case "add":
return [...state, createHeaderEntry()];
case "remove":
return state.length === 1
? [createHeaderEntry()]
: state.filter((_, i) => i !== action.index);
case "update":
return state.map((e, i) =>
i === action.index ? { ...e, ...action.updates } : e,
);
}
}
function initHeaders(headers: HeaderAuthConfig[]): HeaderAuthItem[] {
return headers.length > 0
? headers.map(configToHeaderEntry)
: [createHeaderEntry()];
}
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 [items, dispatch] = useReducer(
headersReducer,
currentHeaders,
initHeaders,
);
const isEditing = currentHeaders.length > 0;
const canSave = useMemo(() => items.every(isHeaderValid), [items]);
const { hasChanges } = useHasChanges(items);
const handleSave = () => {
if (!canSave) return;
onOpenChange(false);
onSave(items.map(headerEntryToConfig));
};
const handleRemoveAll = () => {
onOpenChange(false);
onRemove();
};
return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent
maxWidthClass="max-w-xl"
onOpenAutoFocus={(e) => {
e.preventDefault();
const container = e.currentTarget as HTMLElement | null;
container
?.querySelector<HTMLInputElement>("input:not([type=hidden])")
?.focus();
}}
>
<ModalHeader
title="HTTP Headers"
description="Require specific HTTP headers to access this service."
/>
<div className="px-8">
<div className="flex flex-col gap-3">
{items.map((item, index) => (
<HeaderItemRow
key={item.id}
item={item}
index={index}
onChange={(updates) =>
dispatch({ type: "update", index, updates })
}
onRemove={() => dispatch({ type: "remove", index })}
showRemove={items.length > 1}
/>
))}
</div>
<Button
variant="dotted"
className="w-full mt-4"
size="sm"
onClick={() => dispatch({ type: "add" })}
>
<PlusIcon size={14} />
Add Header
</Button>
{items.length > 1 && (
<Callout className="mt-4" variant="info">
Any request matching one of these headers will grant access.
<br />
Matched headers are stripped before reaching your backend.
</Callout>
)}
<div className="flex gap-3 w-full justify-between mt-6">
{isEditing ? (
<>
<Button variant="danger-text" onClick={handleRemoveAll}>
Remove All
</Button>
<div className="flex gap-3">
<ModalClose asChild>
<Button variant="secondary">Cancel</Button>
</ModalClose>
<Button
variant="primary"
onClick={handleSave}
disabled={!canSave || !hasChanges}
>
Save
</Button>
</div>
</>
) : (
<>
<div />
<div className="flex gap-3">
<ModalClose asChild>
<Button variant="secondary">Cancel</Button>
</ModalClose>
<Button
variant="primary"
onClick={handleSave}
disabled={!canSave}
>
Add Headers
</Button>
</div>
</>
)}
</div>
</div>
</ModalContent>
</Modal>
);
}
type HeaderItemRowProps = {
item: HeaderAuthItem;
index: number;
onChange: (updates: Partial<HeaderAuthItem>) => void;
onRemove: () => void;
showRemove: boolean;
};
function HeaderItemRow({
item,
index,
onChange,
onRemove,
showRemove,
}: Readonly<HeaderItemRowProps>) {
const isMaskedRef = useRef(item.existingSecret);
const handleHeaderTypeChange = (value: string) => {
const type = value as HeaderType;
onChange({
type,
header: type === "custom" ? "" : "Authorization",
value: "",
username: "",
password: "",
});
};
return (
<div className="rounded-md border border-nb-gray-900 bg-nb-gray-920/30 overflow-hidden">
<div className="flex flex-col gap-2 px-4 pt-2 pb-4 bg-nb-gray-920/30">
<div className="flex items-center justify-between h-6 mt-0.5">
<span className="text-xs font-normal text-nb-gray-200 flex items-center gap-2">
<FileCode2Icon size={14} />
{item.existingSecret
? `Header ${index + 1} - ${item.header}`
: `Header ${index + 1}`}
</span>
{showRemove && (
<Button variant="danger-text" size="xs" onClick={onRemove}>
<MinusCircleIcon size={12} />
Remove
</Button>
)}
</div>
{item.existingSecret ? (
<div>
<Input
customPrefix={<span className="min-w-[38px]">Value</span>}
type="password"
showPasswordToggle={!isMaskedRef.current}
value={isMaskedRef.current ? MASKED_VALUE : item.value}
placeholder="e.g., AIiaSyDaGmWKa4JsXZ-HjGw7ISLn_3namBGewQe"
{...INPUT_PROPS}
onChange={(e) => {
if (isMaskedRef.current) {
isMaskedRef.current = false;
const nativeEvent = e.nativeEvent as InputEvent;
onChange({ value: nativeEvent.data ?? "" });
return;
}
onChange({ value: e.target.value });
}}
/>
</div>
) : (
<>
<SelectDropdown
value={item.type}
onChange={handleHeaderTypeChange}
options={HEADER_TYPE_OPTIONS}
/>
{item.type === "basic" && (
<div className="flex flex-col gap-2">
<Input
customPrefix={<UserIcon size={16} />}
placeholder="Username"
maxWidthClass="w-full"
value={item.username}
onChange={(e) => onChange({ username: e.target.value })}
{...INPUT_PROPS}
/>
<Input
customPrefix={<KeyRoundIcon size={16} />}
placeholder="Password"
maxWidthClass="w-full"
value={item.password}
onChange={(e) => onChange({ password: e.target.value })}
type="password"
showPasswordToggle
{...INPUT_PROPS}
/>
</div>
)}
{item.type === "bearer" && (
<Input
customPrefix={"Bearer"}
placeholder="e.g. eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
maxWidthClass="w-full"
value={item.value}
onChange={(e) => onChange({ value: e.target.value })}
type="password"
showPasswordToggle
{...INPUT_PROPS}
/>
)}
{item.type === "custom" && (
<div className="flex flex-col gap-2">
<Input
customPrefix={<span className="min-w-[38px]">Name</span>}
placeholder="e.g., X-API-Key"
maxWidthClass="w-full"
value={item.header}
onChange={(e) => onChange({ header: e.target.value })}
{...INPUT_PROPS}
/>
<Input
customPrefix={<span className="min-w-[38px]">Value</span>}
placeholder="e.g., AIiaSyDaGmWKa4JsXZ-HjGw7ISLn_3namBGewQe"
maxWidthClass="w-full"
value={item.value}
onChange={(e) => onChange({ value: e.target.value })}
type="password"
showPasswordToggle
{...INPUT_PROPS}
/>
</div>
)}
</>
)}
</div>
</div>
);
}
@@ -13,6 +13,7 @@ type Props = {
baseDomain: string;
onBaseDomainChange: (value: string) => void;
domainAlreadyExists: boolean;
subdomainRequired?: boolean;
clusterOffline?: {
clusterName: string;
};
@@ -24,13 +25,16 @@ export default function ReverseProxyDomainInput({
baseDomain,
onBaseDomainChange,
domainAlreadyExists,
subdomainRequired = false,
clusterOffline,
}: Readonly<Props>) {
return (
<div>
<Label>Domain</Label>
<HelpText>
Enter a subdomain and select a domain for your service.
{subdomainRequired
? "Enter a subdomain and select a domain for your service."
: "Optionally enter a subdomain, or use the domain directly."}
</HelpText>
<div className="flex items-start mt-2">
<div className="flex-1 min-w-0">
@@ -47,7 +51,7 @@ export default function ReverseProxyDomainInput({
? "This domain is already used by another service."
: undefined
}
placeholder={"myapp"}
placeholder={subdomainRequired ? "myapp" : "myapp (optional)"}
className="!rounded-r-none !border-r-0"
/>
</div>
@@ -23,6 +23,13 @@ function parseDomain(
.filter((d) => d.domain)
.sort((a, b) => b.domain.length - a.domain.length);
for (const d of sorted) {
if (fullDomain === d.domain) {
return {
subdomain: "",
baseDomain: d.domain,
isCustom: d.type === ReverseProxyDomainType.CUSTOM,
};
}
if (fullDomain.endsWith(`.${d.domain}`)) {
return {
subdomain: fullDomain.slice(0, -(d.domain.length + 1)),
@@ -103,7 +110,11 @@ export function useReverseProxyDomain({
return customDomain?.domain || freeDomain?.domain || "";
});
const fullDomain = baseDomain ? `${subdomain}.${baseDomain}` : subdomain;
const fullDomain = baseDomain
? subdomain
? `${subdomain}.${baseDomain}`
: baseDomain
: subdomain;
const domainAlreadyExists = useMemo(() => {
if (!reverseProxies || !fullDomain) return false;
@@ -1,5 +1,14 @@
import Badge from "@components/Badge";
import { Binary, Mail, RectangleEllipsis, Users } from "lucide-react";
import {
Binary,
FileCode2Icon,
Flag,
GlobeOff,
Mail,
Network,
RectangleEllipsis,
Users,
} from "lucide-react";
import * as React from "react";
import { ReverseProxyEvent } from "@/interfaces/ReverseProxy";
@@ -33,6 +42,11 @@ export const ReverseProxyEventsAuthMethodCell = ({ event }: Props) => {
icon: <Binary size={12} />,
label: "PIN Code",
};
case "header":
return {
icon: <FileCode2Icon size={12} />,
label: "HTTP Headers",
};
case "link":
case "magic_link":
case "magic-link":
@@ -40,6 +54,21 @@ export const ReverseProxyEventsAuthMethodCell = ({ event }: Props) => {
icon: <Mail size={12} />,
label: "Magic Link",
};
case "ip_restricted":
return {
icon: <Network size={12} />,
label: "IP Restricted",
};
case "country_restricted":
return {
icon: <Flag size={12} />,
label: "Country Restricted",
};
case "geo_unavailable":
return {
icon: <GlobeOff size={12} />,
label: "Geo Unavailable",
};
default:
return {
icon: null,
@@ -19,8 +19,17 @@ export const ReverseProxyEventsLocationIpCell = ({ event }: Props) => {
const { getRegionText, isLoading } = useCountries();
const region = useMemo(() => {
return getRegionText(event.country_code || "", event.city_name || "");
}, [getRegionText, event.country_code, event.city_name]);
return getRegionText(
event.country_code || "",
event.city_name || "",
event.subdivision_code,
);
}, [
getRegionText,
event.country_code,
event.city_name,
event.subdivision_code,
]);
return (
<FullTooltip
@@ -0,0 +1,227 @@
import Badge from "@components/Badge";
import Button from "@components/Button";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@components/HoverCard";
import {
FlagIcon,
LucideIcon,
NetworkIcon,
Settings,
ShieldCheck,
ShieldOff,
WorkflowIcon,
} from "lucide-react";
import * as React from "react";
import { useMemo } from "react";
import { useCountries } from "@/contexts/CountryProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
import { ReverseProxy } from "@/interfaces/ReverseProxy";
type RuleEntry = {
key: string;
label: string;
Icon: LucideIcon;
value: string;
blocked?: boolean;
};
type Props = {
reverseProxy: ReverseProxy;
};
export default function ReverseProxyAccessControlCell({
reverseProxy,
}: Readonly<Props>) {
const { permission } = usePermissions();
const { openModal } = useReverseProxies();
const { countries } = useCountries();
const canConfigure = !!permission?.services?.update;
const restrictions = reverseProxy.access_restrictions;
const ruleCount =
(restrictions?.allowed_cidrs?.length ?? 0) +
(restrictions?.blocked_cidrs?.length ?? 0) +
(restrictions?.allowed_countries?.length ?? 0) +
(restrictions?.blocked_countries?.length ?? 0);
const rulesBadge =
ruleCount > 0 ? (
<Badge
variant={"gray"}
disabled={!canConfigure}
className={
"cursor-pointer !rounded-r-none !border-r-0 !h-[34px] min-w-[100px] !justify-start hover:bg-nb-gray-930 transition-all"
}
>
<ShieldCheck size={12} className="text-green-500" />
<span className={"font-medium text-xs"}>
{ruleCount} {ruleCount === 1 ? "Rule" : "Rules"}
</span>
</Badge>
) : null;
const ruleGroups = useMemo(() => {
const getCountryName = (code: string) => {
const country = countries?.find((c) => c.country_code === code);
return country?.country_name ?? code;
};
const entries: RuleEntry[] = [];
if (restrictions?.allowed_countries?.length) {
entries.push({
key: "allowed-countries",
label: "Allowed Countries",
Icon: FlagIcon,
value: restrictions.allowed_countries.map(getCountryName).join(", "),
});
}
if (restrictions?.blocked_countries?.length) {
entries.push({
key: "blocked-countries",
label: "Blocked Countries",
Icon: FlagIcon,
value: restrictions.blocked_countries.map(getCountryName).join(", "),
blocked: true,
});
}
const allowedIps =
restrictions?.allowed_cidrs?.filter((c) => c.endsWith("/32")) ?? [];
const allowedCidrs =
restrictions?.allowed_cidrs?.filter((c) => !c.endsWith("/32")) ?? [];
const blockedIps =
restrictions?.blocked_cidrs?.filter((c) => c.endsWith("/32")) ?? [];
const blockedCidrs =
restrictions?.blocked_cidrs?.filter((c) => !c.endsWith("/32")) ?? [];
if (allowedIps.length) {
entries.push({
key: "allowed-ips",
label: allowedIps.length === 1 ? "Allowed IP" : "Allowed IPs",
Icon: WorkflowIcon,
value: allowedIps.map((c) => c.replace(/\/32$/, "")).join(", "),
});
}
if (allowedCidrs.length) {
entries.push({
key: "allowed-cidrs",
label: allowedCidrs.length === 1 ? "Allowed CIDR" : "Allowed CIDRs",
Icon: NetworkIcon,
value: allowedCidrs.join(", "),
});
}
if (blockedIps.length) {
entries.push({
key: "blocked-ips",
label: blockedIps.length === 1 ? "Blocked IP" : "Blocked IPs",
Icon: WorkflowIcon,
value: blockedIps.map((c) => c.replace(/\/32$/, "")).join(", "),
blocked: true,
});
}
if (blockedCidrs.length) {
entries.push({
key: "blocked-cidrs",
label: blockedCidrs.length === 1 ? "Blocked CIDR" : "Blocked CIDRs",
Icon: NetworkIcon,
value: blockedCidrs.join(", "),
blocked: true,
});
}
return entries;
}, [restrictions, countries]);
const showRulesHover = ruleGroups.length > 0;
return (
<div
className={"flex"}
onClick={(e) => {
e.stopPropagation();
if (permission?.services?.update) {
openModal({ proxy: reverseProxy, initialTab: "access-control" });
}
}}
>
<div className={"flex items-center"}>
{rulesBadge ? (
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild={true}>{rulesBadge}</HoverCardTrigger>
{showRulesHover && (
<HoverCardContent
className={"p-0"}
sideOffset={14}
onClick={(e) => e.stopPropagation()}
>
<div className={"text-xs"}>
{ruleGroups.map(({ key, label, Icon, value, blocked }) => (
<div
key={key}
className={
"flex justify-between gap-12 py-2 px-4 border-b border-nb-gray-920 last:border-b-0"
}
>
<div
className={
"flex items-start gap-2 font-medium whitespace-nowrap text-nb-gray-100 pt-0.5"
}
>
<Icon
size={14}
className={
blocked ? "text-red-500" : "text-green-500"
}
/>
{label}
</div>
<div
className={"max-w-[200px] text-nb-gray-300 text-right"}
>
{value}
</div>
</div>
))}
</div>
</HoverCardContent>
)}
</HoverCard>
) : (
<Badge
variant={"gray"}
disabled={!canConfigure}
className={
"cursor-pointer !rounded-r-none !border-r-0 !h-[34px] min-w-[100px] !justify-start hover:bg-nb-gray-930 transition-all"
}
>
<ShieldOff size={12} className="text-red-500" />
<span className={"font-medium text-xs"}>No Rules</span>
</Badge>
)}
<Button
size={"xs"}
variant={"secondary"}
className={"!rounded-l-none !px-3 !h-[34px]"}
onClick={(e) => {
e.stopPropagation();
openModal({ proxy: reverseProxy, initialTab: "access-control" });
}}
disabled={!permission?.services?.update}
aria-label="Configure access control"
>
<Settings size={12} />
</Button>
</div>
</div>
);
}
Loaded 20 of 25 files, more files were not shown because too many files have changed in this diff. Show more