Compare commits

...
10 changed files with 2280 additions and 718 deletions

No files matched your search

+7 -5
View File
@@ -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],
);
+16
View File
@@ -18,6 +18,7 @@ export interface ReverseProxy {
pass_host_header?: boolean;
rewrite_redirects?: boolean;
auth?: ReverseProxyAuth;
access_restrictions?: AccessRestrictions;
meta?: ReverseProxyMeta;
}
@@ -77,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 {
@@ -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;
@@ -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>
);
}
File diff suppressed because it is too large. Load diff
@@ -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>
);
}
@@ -1,5 +1,5 @@
import Badge from "@components/Badge";
import { Binary, Mail, RectangleEllipsis, Users } from "lucide-react";
import { Binary, Globe, KeyRound, Mail, Network, RectangleEllipsis, ShieldOff, Users } from "lucide-react";
import * as React from "react";
import { ReverseProxyEvent } from "@/interfaces/ReverseProxy";
@@ -33,6 +33,11 @@ export const ReverseProxyEventsAuthMethodCell = ({ event }: Props) => {
icon: <Binary size={12} />,
label: "PIN Code",
};
case "header":
return {
icon: <KeyRound size={12} />,
label: "Header Auth",
};
case "link":
case "magic_link":
case "magic-link":
@@ -40,6 +45,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: <Globe size={12} />,
label: "Country Restricted",
};
case "geo_unavailable":
return {
icon: <ShieldOff size={12} />,
label: "Geo Unavailable",
};
default:
return {
icon: null,
@@ -19,8 +19,8 @@ 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
@@ -11,8 +11,10 @@ import { UserCountStack } from "@components/ui/MultipleGroups";
import {
ArrowRightIcon,
Binary,
HelpCircle,
Globe,
KeyRound,
LucideIcon,
Network,
RectangleEllipsis,
Settings,
ShieldCheck,
@@ -24,8 +26,7 @@ import { useGroups } from "@/contexts/GroupsProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
import { Group } from "@/interfaces/Group";
import { isL4Mode, ReverseProxy } from "@/interfaces/ReverseProxy";
import FullTooltip from "@components/FullTooltip";
import { AccessRestrictions, ReverseProxy, isL4Mode } from "@/interfaces/ReverseProxy";
const AUTH_METHODS: {
key: "password_auth" | "pin_auth" | "bearer_auth";
@@ -59,30 +60,22 @@ export default function ReverseProxyAuthCell({
const { openModal } = useReverseProxies();
const { groups } = useGroups();
// L4 services don't support auth
// L4 services don't support auth, show access restrictions instead
if (isL4Mode(reverseProxy.mode)) {
return (
<div className={"flex"}>
<FullTooltip
content={
<div className={"flex text-xs max-w-[340px]"}>
Auth methods are not supported for TCP/UDP and TLS passthrough
services as they operate at the network layer.
</div>
}
>
<Badge variant={"gray"}>
N/A
<HelpCircle size={12} />
</Badge>
</FullTooltip>
</div>
<L4AccessBadge
reverseProxy={reverseProxy}
permission={permission}
openModal={openModal}
/>
);
}
const auth = reverseProxy.auth;
const enabled = AUTH_METHODS.filter((m) => auth?.[m.key]?.enabled);
const headerAuthCount = auth?.header_auths?.length ?? 0;
const totalEnabled = enabled.length + (headerAuthCount > 0 ? 1 : 0);
const ssoGroups = auth?.bearer_auth?.enabled
? (auth.bearer_auth.distribution_groups ?? [])
@@ -91,21 +84,30 @@ export default function ReverseProxyAuthCell({
: [];
const showHoverContent =
enabled.length > 1 || (enabled.length === 1 && auth?.bearer_auth?.enabled);
totalEnabled > 1 || (totalEnabled === 1 && auth?.bearer_auth?.enabled);
const SingleIcon = enabled.length === 1 ? enabled[0].Icon : null;
const isSingleStandard = totalEnabled === 1 && enabled.length === 1;
const SingleIcon = isSingleStandard ? enabled[0].Icon : null;
const badgeContent = SingleIcon ? (
<>
<SingleIcon size={12} className="text-green-500" />
<span className={"font-medium text-xs"}>{enabled[0].label}</span>
</>
) : enabled.length > 1 ? (
<>
<ShieldCheck size={12} className="text-green-400" />
<span className={"font-medium text-xs"}>{enabled.length} Enabled</span>
</>
) : null;
const badgeContent =
isSingleStandard && SingleIcon ? (
<>
<SingleIcon size={12} className="text-green-500" />
<span className={"font-medium text-xs"}>{enabled[0].label}</span>
</>
) : totalEnabled === 1 && headerAuthCount > 0 ? (
<>
<KeyRound size={12} className="text-green-500" />
<span className={"font-medium text-xs"}>Header Auth</span>
</>
) : totalEnabled > 1 ? (
<>
<ShieldCheck size={12} className="text-green-400" />
<span className={"font-medium text-xs"}>
{totalEnabled} Enabled
</span>
</>
) : null;
return (
<div
@@ -158,7 +160,9 @@ export default function ReverseProxyAuthCell({
{ssoGroups.map((group) => (
<div
key={group.id}
className={"flex gap-2 items-center justify-between"}
className={
"flex gap-2 items-center justify-between"
}
>
<GroupBadge group={group} />
<ArrowRightIcon size={14} />
@@ -190,3 +194,112 @@ export default function ReverseProxyAuthCell({
</div>
);
}
function hasRestrictions(r?: AccessRestrictions): boolean {
if (!r) return false;
return (
(r.allowed_cidrs?.length ?? 0) > 0 ||
(r.blocked_cidrs?.length ?? 0) > 0 ||
(r.allowed_countries?.length ?? 0) > 0 ||
(r.blocked_countries?.length ?? 0) > 0
);
}
type L4AccessBadgeProps = {
reverseProxy: ReverseProxy;
permission: ReturnType<typeof usePermissions>["permission"];
openModal: ReturnType<typeof useReverseProxies>["openModal"];
};
function L4AccessBadge({
reverseProxy,
permission,
openModal,
}: Readonly<L4AccessBadgeProps>) {
const r = reverseProxy.access_restrictions;
const active = hasRestrictions(r);
return (
<div
className={"flex gap-3"}
onClick={(e) => {
e.stopPropagation();
openModal({ proxy: reverseProxy, initialTab: "access" });
}}
>
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild={true}>
{active ? (
<Badge variant={"gray"} useHover={false} className={"cursor-pointer"}>
<ShieldCheck size={12} className="text-green-500" />
<span className={"font-medium text-xs"}>
Access Control
</span>
</Badge>
) : (
<Badge variant={"gray"}>
<ShieldOff size={12} className="text-red-500" />
<span className={"font-medium text-xs"}>No Restrictions</span>
</Badge>
)}
</HoverCardTrigger>
{active && r && (
<HoverCardContent
className={"p-0"}
sideOffset={14}
onClick={(e) => e.stopPropagation()}
>
<div className={"text-xs"}>
{(r.allowed_cidrs?.length ?? 0) > 0 && (
<ListItem
className={"py-0.5"}
icon={<Network size={14} />}
label={`${r.allowed_cidrs!.length} CIDR${r.allowed_cidrs!.length > 1 ? "s" : ""} allowed`}
value={<span className="text-green-500">Active</span>}
/>
)}
{(r.blocked_cidrs?.length ?? 0) > 0 && (
<ListItem
className={"py-0.5"}
icon={<Network size={14} />}
label={`${r.blocked_cidrs!.length} CIDR${r.blocked_cidrs!.length > 1 ? "s" : ""} blocked`}
value={<span className="text-red-400">Active</span>}
/>
)}
{(r.allowed_countries?.length ?? 0) > 0 && (
<ListItem
className={"py-0.5"}
icon={<Globe size={14} />}
label={`${r.allowed_countries!.length} ${r.allowed_countries!.length > 1 ? "countries" : "country"} allowed`}
value={<span className="text-green-500">Active</span>}
/>
)}
{(r.blocked_countries?.length ?? 0) > 0 && (
<ListItem
className={"py-0.5"}
icon={<Globe size={14} />}
label={`${r.blocked_countries!.length} ${r.blocked_countries!.length > 1 ? "countries" : "country"} blocked`}
value={<span className="text-red-400">Active</span>}
/>
)}
</div>
</HoverCardContent>
)}
</HoverCard>
<Button
size={"xs"}
variant={"secondary"}
onClick={(e) => {
e.stopPropagation();
openModal({ proxy: reverseProxy, initialTab: "access" });
}}
className={"!px-3"}
disabled={!permission?.services?.update}
>
<Settings size={12} />
Configure
</Button>
</div>
);
}
File diff suppressed because it is too large. Load diff
@@ -7,43 +7,17 @@ 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<string, number> = {
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 {
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 {
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;
}