mirror of
https://github.com/netbirdio/dashboard.git
synced 2026-09-23 00:30:43 -07:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0844507d8e | ||
|
|
7812f76bcf | ||
|
|
ba31f63400 | ||
|
|
5cf41dbc76 |
No files matched your search
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user