diff --git a/src/components/Select.tsx b/src/components/Select.tsx index 78cca0d0..ce93e691 100644 --- a/src/components/Select.tsx +++ b/src/components/Select.tsx @@ -98,7 +98,7 @@ const SelectItem = React.forwardRef< -
+
{icon}
{children} diff --git a/src/components/select/SelectDropdown.tsx b/src/components/select/SelectDropdown.tsx index 2b9c8ed8..3bf2e025 100644 --- a/src/components/select/SelectDropdown.tsx +++ b/src/components/select/SelectDropdown.tsx @@ -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) { const [inputRef, { width }] = useElementSize(); @@ -107,15 +113,18 @@ export function SelectDropdown({ const SelectedItem = () => { return ( -
- {selected?.icon && } +
+ {selected?.icon && }
- {selected?.label} + + {selected?.label} +
); @@ -216,20 +225,22 @@ export function SelectDropdown({ -
+
{filteredItems.map((option) => ( @@ -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(null); @@ -285,7 +298,12 @@ const SelectDropdownItem = ({ option?.disabled && "cursor-not-allowed", )} > - {option.icon && } + {option.icon && ( +
+ +
+ )} + {option?.renderItem && option.renderItem()} {!option?.renderItem && (
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} />
); diff --git a/src/contexts/CountryProvider.tsx b/src/contexts/CountryProvider.tsx index 1b6d42a4..cf2533ca 100644 --- a/src/contexts/CountryProvider.tsx +++ b/src/contexts/CountryProvider.tsx @@ -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 ? ( { + (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], ); diff --git a/src/interfaces/ReverseProxy.ts b/src/interfaces/ReverseProxy.ts index 50d9816c..5ca3227a 100644 --- a/src/interfaces/ReverseProxy.ts +++ b/src/interfaces/ReverseProxy.ts @@ -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 { @@ -129,6 +144,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 +197,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"; diff --git a/src/modules/reverse-proxy/ReverseProxyAccessControlRules.tsx b/src/modules/reverse-proxy/ReverseProxyAccessControlRules.tsx new file mode 100644 index 00000000..0ced8faa --- /dev/null +++ b/src/modules/reverse-proxy/ReverseProxyAccessControlRules.tsx @@ -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) => , + }, + { + label: "Block Only", + value: "block", + icon: (props) => , + }, +]; + +const TYPE_OPTIONS: SelectOption[] = [ + { + label: "Country", + value: "country", + icon: (props) => , + }, + { + label: "IP Address", + value: "ip", + icon: (props) => , + }, + { + label: "CIDR Block", + value: "cidr", + icon: (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 ( +
+
+ + + Define rules to allow or block traffic based on country, IP address, + or CIDR block. +
+ Block rules always take priority over allow rules. +
+
+ {rules.length > 0 && ( +
+ {rules.map((rule) => ( +
+
+ + dispatch({ + type: "update", + id: rule.id, + field: "action", + value: v, + }) + } + options={ACTION_OPTIONS} + compact + /> +
+ +
+ + dispatch({ + type: "update", + id: rule.id, + field: "type", + value: v, + }) + } + options={TYPE_OPTIONS} + compact + /> +
+ +
+ {rule.type === "country" ? ( + + dispatch({ + type: "update", + id: rule.id, + field: "value", + value: v, + }) + } + /> + ) : ( + + dispatch({ + type: "update", + id: rule.id, + field: "value", + value: e.target.value, + }) + } + error={errors[rule.id]} + errorTooltip={true} + maxWidthClass="w-full" + /> + )} +
+ + +
+ ))} +
+ )} + +
+ ); +}; diff --git a/src/modules/reverse-proxy/ReverseProxyModal.tsx b/src/modules/reverse-proxy/ReverseProxyModal.tsx index 3fd3c361..1d0efccc 100644 --- a/src/modules/reverse-proxy/ReverseProxyModal.tsx +++ b/src/modules/reverse-proxy/ReverseProxyModal.tsx @@ -27,6 +27,7 @@ import { PlusCircle, RectangleEllipsis, Settings, + ShieldCheckIcon, Users, } from "lucide-react"; import { useRouter } from "next/navigation"; @@ -37,7 +38,9 @@ import { usePermissions } from "@/contexts/PermissionsProvider"; import { Network, NetworkResource } from "@/interfaces/Network"; import { Peer } from "@/interfaces/Peer"; import { + AccessRestrictions, isL4Mode as isL4ServiceMode, + REVERSE_PROXY_ACCESS_CONTROL_DOCS_LINK, REVERSE_PROXY_AUTHENTICATION_DOCS_LINK, REVERSE_PROXY_SERVICES_DOCS_LINK, REVERSE_PROXY_SETTINGS_DOCS_LINK, @@ -61,14 +64,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,6 +240,12 @@ export default function ReverseProxyModal({ reverseProxy?.auth?.link_auth?.enabled ?? false, ); + 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); @@ -305,16 +315,19 @@ export default function ReverseProxyModal({ ); }; - const hasNoAuth = - !passwordEnabled && !pinEnabled && !bearerEnabled && !linkAuthEnabled; + const isUnprotected = + !passwordEnabled && + !pinEnabled && + !bearerEnabled && + !linkAuthEnabled && + !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", @@ -383,6 +396,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 +440,17 @@ export default function ReverseProxyModal({ {!isL4Mode && ( - + Authentication )} + + + Access Control + Advanced Settings @@ -531,6 +552,16 @@ export default function ReverseProxyModal({
+ +
+ +
+
+
{(serviceMode === ServiceMode.TCP || @@ -627,6 +658,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 +688,9 @@ export default function ReverseProxyModal({ + + + )} + + {tab === "access-control" && ( + <> + @@ -682,7 +737,7 @@ export default function ReverseProxyModal({ <> @@ -691,7 +746,8 @@ export default function ReverseProxyModal({ disabled={ !canContinueToSettings || !permission?.services?.create || - !!timeoutError + !!timeoutError || + accessControlHasErrors } onClick={handleSubmit} > @@ -711,7 +767,8 @@ export default function ReverseProxyModal({ disabled={ !canContinueToSettings || !permission?.services?.update || - !!timeoutError + !!timeoutError || + accessControlHasErrors } onClick={handleSubmit} > diff --git a/src/modules/reverse-proxy/events/ReverseProxyEventsLocationIpCell.tsx b/src/modules/reverse-proxy/events/ReverseProxyEventsLocationIpCell.tsx index 324dd152..c78f5f88 100644 --- a/src/modules/reverse-proxy/events/ReverseProxyEventsLocationIpCell.tsx +++ b/src/modules/reverse-proxy/events/ReverseProxyEventsLocationIpCell.tsx @@ -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 ( ) { + 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 ? ( + + + + {ruleCount} {ruleCount === 1 ? "Rule" : "Rules"} + + + ) : 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 ( +
{ + e.stopPropagation(); + if (permission?.services?.update) { + openModal({ proxy: reverseProxy, initialTab: "access-control" }); + } + }} + > +
+ {rulesBadge ? ( + + {rulesBadge} + {showRulesHover && ( + e.stopPropagation()} + > +
+ {ruleGroups.map(({ key, label, Icon, value, blocked }) => ( +
+
+ + {label} +
+
+ {value} +
+
+ ))} +
+
+ )} +
+ ) : ( + + + No Rules + + )} + +
+
+ ); +} diff --git a/src/modules/reverse-proxy/table/ReverseProxyAuthCell.tsx b/src/modules/reverse-proxy/table/ReverseProxyAuthCell.tsx index b91ca34d..815bfded 100644 --- a/src/modules/reverse-proxy/table/ReverseProxyAuthCell.tsx +++ b/src/modules/reverse-proxy/table/ReverseProxyAuthCell.tsx @@ -12,11 +12,11 @@ import { ArrowRightIcon, Binary, HelpCircle, + LockKeyhole, + LockOpenIcon, LucideIcon, RectangleEllipsis, Settings, - ShieldCheck, - ShieldOff, Users, } from "lucide-react"; import * as React from "react"; @@ -59,7 +59,6 @@ export default function ReverseProxyAuthCell({ const { openModal } = useReverseProxies(); const { groups } = useGroups(); - // L4 services don't support auth if (isL4Mode(reverseProxy.mode)) { return (
@@ -83,6 +82,7 @@ export default function ReverseProxyAuthCell({ const auth = reverseProxy.auth; const enabled = AUTH_METHODS.filter((m) => auth?.[m.key]?.enabled); + const authCount = enabled.length; const ssoGroups = auth?.bearer_auth?.enabled ? (auth.bearer_auth.distribution_groups ?? []) @@ -90,103 +90,112 @@ export default function ReverseProxyAuthCell({ .filter((g): g is Group => g != undefined) : []; - const showHoverContent = - enabled.length > 1 || (enabled.length === 1 && auth?.bearer_auth?.enabled); + const canConfigure = !!permission?.services?.update; + const SingleAuthIcon = authCount === 1 ? enabled[0].Icon : null; - const SingleIcon = enabled.length === 1 ? enabled[0].Icon : null; - - const badgeContent = SingleIcon ? ( - <> - + const authBadge = SingleAuthIcon ? ( + + {enabled[0].label} - - ) : enabled.length > 1 ? ( - <> - - {enabled.length} Enabled - + + ) : authCount > 1 ? ( + + + {authCount} Enabled + ) : null; + const showAuthHover = + authCount > 1 || (authCount === 1 && auth?.bearer_auth?.enabled); + return ( -
{ - e.stopPropagation(); +
{ + e.stopPropagation(); + if (permission?.services?.update) { openModal({ proxy: reverseProxy, initialTab: "auth" }); - }} - > - - - {badgeContent ? ( - - {badgeContent} - - ) : ( - - - None - - )} - - {showHoverContent && ( - e.stopPropagation()} - > -
- {enabled.map(({ key, hoverLabel, Icon }) => ( - } - label={hoverLabel} - value={ -
- {key === "bearer_auth" && ssoGroups.length === 0 - ? "All Users" - : "Enabled"} -
- } - > - {key === "bearer_auth" && ssoGroups.length > 0 && ( -
- {ssoGroups.map((group) => ( -
- - - + } + }}> +
+ {authBadge ? ( + + {authBadge} + {showAuthHover && ( + e.stopPropagation()} + > +
+ {enabled.map(({ key, hoverLabel, Icon }) => ( + } + label={hoverLabel} + value={ +
+ {key === "bearer_auth" && ssoGroups.length === 0 + ? "All Users" + : "Enabled"}
- ))} -
- )} - - ))} -
- + } + > + {key === "bearer_auth" && ssoGroups.length > 0 && ( +
+ {ssoGroups.map((group) => ( +
+ + + +
+ ))} +
+ )} + + ))} +
+ + )} + + ) : ( + + + No Auth + )} - - - + +
); } diff --git a/src/modules/reverse-proxy/table/ReverseProxyTable.tsx b/src/modules/reverse-proxy/table/ReverseProxyTable.tsx index 6083a002..7daa5b5f 100644 --- a/src/modules/reverse-proxy/table/ReverseProxyTable.tsx +++ b/src/modules/reverse-proxy/table/ReverseProxyTable.tsx @@ -23,6 +23,7 @@ import { } from "@/interfaces/ReverseProxy"; import ReverseProxyActionCell from "@/modules/reverse-proxy/table/ReverseProxyActionCell"; import ReverseProxyActiveCell from "@/modules/reverse-proxy/table/ReverseProxyActiveCell"; +import ReverseProxyAccessControlCell from "@/modules/reverse-proxy/table/ReverseProxyAccessControlCell"; import ReverseProxyAuthCell from "@/modules/reverse-proxy/table/ReverseProxyAuthCell"; import ReverseProxyClusterCell from "@/modules/reverse-proxy/table/ReverseProxyClusterCell"; import ReverseProxyNameCell from "@/modules/reverse-proxy/table/ReverseProxyNameCell"; @@ -90,6 +91,17 @@ const ReverseProxyColumns: ColumnDef[] = [ }, cell: ({ row }) => , }, + { + id: "access_rules", + header: ({ column }) => { + return ( + Access Control + ); + }, + cell: ({ row }) => ( + + ), + }, { accessorKey: "id", header: "", diff --git a/src/modules/reverse-proxy/targets/ReverseProxyTargetModal.tsx b/src/modules/reverse-proxy/targets/ReverseProxyTargetModal.tsx index 88ff09b2..1ba5676b 100644 --- a/src/modules/reverse-proxy/targets/ReverseProxyTargetModal.tsx +++ b/src/modules/reverse-proxy/targets/ReverseProxyTargetModal.tsx @@ -461,7 +461,7 @@ export default function ReverseProxyTargetModal({ Max time to wait for a response as duration string - (max 5m).
Leave this field empty for no + (e.g. 30s, 2m).
Leave this field empty for no timeout.
@@ -487,7 +487,7 @@ export default function ReverseProxyTargetModal({ How long a UDP session stays alive without traffic - (max 10m).
Defaults to 30s when empty. + (e.g., 30s, 2m).
Defaults to 30s when empty.
[] = [ ), }, + { + id: "access_control", + header: ({ column }) => ( + Access Control + ), + cell: ({ row }) => ( + + ), + }, { accessorKey: "actions", header: "", diff --git a/src/modules/reverse-proxy/targets/useReverseProxyTargetOptions.ts b/src/modules/reverse-proxy/targets/useReverseProxyTargetOptions.ts index 75dd70b1..e96aa905 100644 --- a/src/modules/reverse-proxy/targets/useReverseProxyTargetOptions.ts +++ b/src/modules/reverse-proxy/targets/useReverseProxyTargetOptions.ts @@ -7,43 +7,20 @@ import { // Go time.ParseDuration format: one or more {number}{unit} pairs const DURATION_RE = /^(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))+$/; -const MAX_TIMEOUT_MS = 5 * 60 * 1000; // 5m -const MAX_SESSION_IDLE_TIMEOUT_MS = 10 * 60 * 1000; // 10m - -function parseDurationMs(duration: string): number { - const units: Record = { - ns: 1e-6, - us: 1e-3, - µs: 1e-3, - ms: 1, - s: 1000, - m: 60_000, - h: 3_600_000, - }; - let total = 0; - for (const [, val, , unit] of duration.matchAll( - /(\d+(\.\d+)?)(ns|us|µs|ms|s|m|h)/g, - )) { - total += parseFloat(val) * units[unit]; - } - return total; -} export function validateTimeout(timeout: string): string | undefined { if (!timeout) return undefined; if (!DURATION_RE.test(timeout)) return 'Invalid duration, use e.g., "10s", "30s", "1m"'; - if (parseDurationMs(timeout) > MAX_TIMEOUT_MS) - return "Timeout cannot exceed the maximum of 5m."; return undefined; } -export function validateSessionIdleTimeout(timeout: string): string | undefined { +export function validateSessionIdleTimeout( + timeout: string, +): string | undefined { if (!timeout) return undefined; if (!DURATION_RE.test(timeout)) return 'Invalid duration, use e.g., "30s", "2m", "5m"'; - if (parseDurationMs(timeout) > MAX_SESSION_IDLE_TIMEOUT_MS) - return "Session idle timeout cannot exceed the maximum of 10m."; return undefined; }