Compare commits

..
12 changed files with 2303 additions and 795 deletions

No files matched your search

+19 -1
View File
@@ -168,6 +168,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -3030,6 +3031,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz",
"integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -3039,6 +3041,7 @@
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -3097,6 +3100,7 @@
"integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.54.0",
"@typescript-eslint/types": "8.54.0",
@@ -3603,7 +3607,8 @@
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@xyflow/react": {
"version": "12.10.0",
@@ -3642,6 +3647,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4059,6 +4065,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -4687,6 +4694,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -5215,6 +5223,7 @@
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -5412,6 +5421,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -6705,6 +6715,7 @@
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"license": "MIT",
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -7459,6 +7470,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -7666,6 +7678,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -7706,6 +7719,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -8600,6 +8614,7 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
@@ -8766,6 +8781,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -8931,6 +8947,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -9258,6 +9275,7 @@
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+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;
+4 -76
View File
@@ -97,10 +97,6 @@ const PeersTableColumns: ColumnDef<Peer>[] = [
accessorKey: "ip",
sortingFn: "text",
},
{
id: "has_user",
accessorFn: (peer) => !!peer.user_id,
},
{
id: "user_name",
accessorFn: (peer) => (peer.user ? peer.user?.name : "Unknown"),
@@ -318,7 +314,6 @@ export default function PeersTable({
group_name_strings: false,
group_names: false,
ip: false,
has_user: false,
serial: false,
user_name: false,
user_email: false,
@@ -341,9 +336,6 @@ export default function PeersTable({
let groupFilters = table
.getColumn("group_names")
?.getFilterValue();
let hasUserFilter = table
.getColumn("has_user")
?.getFilterValue();
table.setColumnFilters([
{
id: "connected",
@@ -358,8 +350,8 @@ export default function PeersTable({
value: groupFilters ?? [],
},
{
id: "has_user",
value: hasUserFilter,
id: "group_names",
value: groupFilters ?? [],
},
]);
resetSelectedRows();
@@ -378,9 +370,6 @@ export default function PeersTable({
let groupFilters = table
.getColumn("group_names")
?.getFilterValue();
let hasUserFilter = table
.getColumn("has_user")
?.getFilterValue();
table.setColumnFilters([
{
id: "connected",
@@ -395,8 +384,8 @@ export default function PeersTable({
value: groupFilters ?? [],
},
{
id: "has_user",
value: hasUserFilter,
id: "group_names",
value: groupFilters ?? [],
},
]);
resetSelectedRows();
@@ -416,9 +405,6 @@ export default function PeersTable({
let groupFilters = table
.getColumn("group_names")
?.getFilterValue();
let hasUserFilter = table
.getColumn("has_user")
?.getFilterValue();
table.setColumnFilters([
{
id: "connected",
@@ -432,10 +418,6 @@ export default function PeersTable({
id: "group_names",
value: groupFilters ?? [],
},
{
id: "has_user",
value: hasUserFilter,
},
]);
resetSelectedRows();
}}
@@ -460,9 +442,6 @@ export default function PeersTable({
undefined
? true
: undefined;
let hasUserFilter = table
.getColumn("has_user")
?.getFilterValue();
table.setColumnFilters([
{
@@ -473,10 +452,6 @@ export default function PeersTable({
id: "approval_required",
value: current,
},
{
id: "has_user",
value: hasUserFilter,
},
]);
resetSelectedRows();
@@ -493,53 +468,6 @@ export default function PeersTable({
</Button>
)}
{!isUser && (
<ButtonGroup disabled={peers?.length == 0}>
<ButtonGroup.Button
disabled={peers?.length == 0}
onClick={() => {
table.setPageIndex(0);
const current =
table.getColumn("has_user")?.getFilterValue();
table
.getColumn("has_user")
?.setFilterValue(
current === true ? undefined : true,
);
resetSelectedRows();
}}
variant={
table.getColumn("has_user")?.getFilterValue() === true
? "tertiary"
: "secondary"
}
>
With Users
</ButtonGroup.Button>
<ButtonGroup.Button
disabled={peers?.length == 0}
onClick={() => {
table.setPageIndex(0);
const current =
table.getColumn("has_user")?.getFilterValue();
table
.getColumn("has_user")
?.setFilterValue(
current === false ? undefined : false,
);
resetSelectedRows();
}}
variant={
table.getColumn("has_user")?.getFilterValue() === false
? "tertiary"
: "secondary"
}
>
No Users
</ButtonGroup.Button>
</ButtonGroup>
)}
<DataTableRowsPerPage table={table} disabled={peers?.length == 0} />
{!isUser && (
@@ -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;
}