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;
|
countries: Country[] | undefined;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
getRegionByPeer: (peer: Peer) => string;
|
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 { isRestricted } = usePermissions();
|
||||||
|
|
||||||
const getRegionByPeer = (peer: Peer) => "Unknown";
|
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 ? (
|
return isRestricted ? (
|
||||||
<CountryContext.Provider
|
<CountryContext.Provider
|
||||||
@@ -47,12 +47,14 @@ function CountryProviderContent({ children }: Props) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const getRegionText = useCallback(
|
const getRegionText = useCallback(
|
||||||
(country_code: string, city_name: string) => {
|
(country_code: string, city_name: string, subdivision_code?: string) => {
|
||||||
if (!countries) return "Unknown";
|
if (!countries) return "Unknown";
|
||||||
const country = countries.find((c) => c.country_code === country_code);
|
const country = countries.find((c) => c.country_code === country_code);
|
||||||
if (!country) return "Unknown";
|
if (!country) return "Unknown";
|
||||||
if (!city_name) return country.country_name;
|
const parts = [country.country_name];
|
||||||
return `${country.country_name}, ${city_name}`;
|
if (subdivision_code) parts.push(subdivision_code);
|
||||||
|
if (city_name) parts.push(city_name);
|
||||||
|
return parts.join(", ");
|
||||||
},
|
},
|
||||||
[countries],
|
[countries],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export interface ReverseProxy {
|
|||||||
pass_host_header?: boolean;
|
pass_host_header?: boolean;
|
||||||
rewrite_redirects?: boolean;
|
rewrite_redirects?: boolean;
|
||||||
auth?: ReverseProxyAuth;
|
auth?: ReverseProxyAuth;
|
||||||
|
access_restrictions?: AccessRestrictions;
|
||||||
meta?: ReverseProxyMeta;
|
meta?: ReverseProxyMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +78,20 @@ export interface ReverseProxyAuth {
|
|||||||
link_auth?: {
|
link_auth?: {
|
||||||
enabled: boolean;
|
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 {
|
export interface ReverseProxyDomain {
|
||||||
@@ -129,6 +144,7 @@ export interface ReverseProxyEvent {
|
|||||||
auth_method_used?: string;
|
auth_method_used?: string;
|
||||||
country_code?: string;
|
country_code?: string;
|
||||||
city_name?: string;
|
city_name?: string;
|
||||||
|
subdivision_code?: string;
|
||||||
bytes_upload: number;
|
bytes_upload: number;
|
||||||
bytes_download: number;
|
bytes_download: number;
|
||||||
protocol?: EventProtocol;
|
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 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 * as React from "react";
|
||||||
import { ReverseProxyEvent } from "@/interfaces/ReverseProxy";
|
import { ReverseProxyEvent } from "@/interfaces/ReverseProxy";
|
||||||
|
|
||||||
@@ -33,6 +33,11 @@ export const ReverseProxyEventsAuthMethodCell = ({ event }: Props) => {
|
|||||||
icon: <Binary size={12} />,
|
icon: <Binary size={12} />,
|
||||||
label: "PIN Code",
|
label: "PIN Code",
|
||||||
};
|
};
|
||||||
|
case "header":
|
||||||
|
return {
|
||||||
|
icon: <KeyRound size={12} />,
|
||||||
|
label: "Header Auth",
|
||||||
|
};
|
||||||
case "link":
|
case "link":
|
||||||
case "magic_link":
|
case "magic_link":
|
||||||
case "magic-link":
|
case "magic-link":
|
||||||
@@ -40,6 +45,21 @@ export const ReverseProxyEventsAuthMethodCell = ({ event }: Props) => {
|
|||||||
icon: <Mail size={12} />,
|
icon: <Mail size={12} />,
|
||||||
label: "Magic Link",
|
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:
|
default:
|
||||||
return {
|
return {
|
||||||
icon: null,
|
icon: null,
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ export const ReverseProxyEventsLocationIpCell = ({ event }: Props) => {
|
|||||||
const { getRegionText, isLoading } = useCountries();
|
const { getRegionText, isLoading } = useCountries();
|
||||||
|
|
||||||
const region = useMemo(() => {
|
const region = useMemo(() => {
|
||||||
return 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]);
|
}, [getRegionText, event.country_code, event.city_name, event.subdivision_code]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FullTooltip
|
<FullTooltip
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import { UserCountStack } from "@components/ui/MultipleGroups";
|
|||||||
import {
|
import {
|
||||||
ArrowRightIcon,
|
ArrowRightIcon,
|
||||||
Binary,
|
Binary,
|
||||||
HelpCircle,
|
Globe,
|
||||||
|
KeyRound,
|
||||||
LucideIcon,
|
LucideIcon,
|
||||||
|
Network,
|
||||||
RectangleEllipsis,
|
RectangleEllipsis,
|
||||||
Settings,
|
Settings,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
@@ -24,8 +26,7 @@ import { useGroups } from "@/contexts/GroupsProvider";
|
|||||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||||
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
|
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
|
||||||
import { Group } from "@/interfaces/Group";
|
import { Group } from "@/interfaces/Group";
|
||||||
import { isL4Mode, ReverseProxy } from "@/interfaces/ReverseProxy";
|
import { AccessRestrictions, ReverseProxy, isL4Mode } from "@/interfaces/ReverseProxy";
|
||||||
import FullTooltip from "@components/FullTooltip";
|
|
||||||
|
|
||||||
const AUTH_METHODS: {
|
const AUTH_METHODS: {
|
||||||
key: "password_auth" | "pin_auth" | "bearer_auth";
|
key: "password_auth" | "pin_auth" | "bearer_auth";
|
||||||
@@ -59,30 +60,22 @@ export default function ReverseProxyAuthCell({
|
|||||||
const { openModal } = useReverseProxies();
|
const { openModal } = useReverseProxies();
|
||||||
const { groups } = useGroups();
|
const { groups } = useGroups();
|
||||||
|
|
||||||
// L4 services don't support auth
|
// L4 services don't support auth, show access restrictions instead
|
||||||
if (isL4Mode(reverseProxy.mode)) {
|
if (isL4Mode(reverseProxy.mode)) {
|
||||||
return (
|
return (
|
||||||
<div className={"flex"}>
|
<L4AccessBadge
|
||||||
<FullTooltip
|
reverseProxy={reverseProxy}
|
||||||
content={
|
permission={permission}
|
||||||
<div className={"flex text-xs max-w-[340px]"}>
|
openModal={openModal}
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const auth = reverseProxy.auth;
|
const auth = reverseProxy.auth;
|
||||||
|
|
||||||
const enabled = AUTH_METHODS.filter((m) => auth?.[m.key]?.enabled);
|
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
|
const ssoGroups = auth?.bearer_auth?.enabled
|
||||||
? (auth.bearer_auth.distribution_groups ?? [])
|
? (auth.bearer_auth.distribution_groups ?? [])
|
||||||
@@ -91,21 +84,30 @@ export default function ReverseProxyAuthCell({
|
|||||||
: [];
|
: [];
|
||||||
|
|
||||||
const showHoverContent =
|
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 ? (
|
const badgeContent =
|
||||||
<>
|
isSingleStandard && SingleIcon ? (
|
||||||
<SingleIcon size={12} className="text-green-500" />
|
<>
|
||||||
<span className={"font-medium text-xs"}>{enabled[0].label}</span>
|
<SingleIcon size={12} className="text-green-500" />
|
||||||
</>
|
<span className={"font-medium text-xs"}>{enabled[0].label}</span>
|
||||||
) : enabled.length > 1 ? (
|
</>
|
||||||
<>
|
) : totalEnabled === 1 && headerAuthCount > 0 ? (
|
||||||
<ShieldCheck size={12} className="text-green-400" />
|
<>
|
||||||
<span className={"font-medium text-xs"}>{enabled.length} Enabled</span>
|
<KeyRound size={12} className="text-green-500" />
|
||||||
</>
|
<span className={"font-medium text-xs"}>Header Auth</span>
|
||||||
) : null;
|
</>
|
||||||
|
) : totalEnabled > 1 ? (
|
||||||
|
<>
|
||||||
|
<ShieldCheck size={12} className="text-green-400" />
|
||||||
|
<span className={"font-medium text-xs"}>
|
||||||
|
{totalEnabled} Enabled
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -158,7 +160,9 @@ export default function ReverseProxyAuthCell({
|
|||||||
{ssoGroups.map((group) => (
|
{ssoGroups.map((group) => (
|
||||||
<div
|
<div
|
||||||
key={group.id}
|
key={group.id}
|
||||||
className={"flex gap-2 items-center justify-between"}
|
className={
|
||||||
|
"flex gap-2 items-center justify-between"
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<GroupBadge group={group} />
|
<GroupBadge group={group} />
|
||||||
<ArrowRightIcon size={14} />
|
<ArrowRightIcon size={14} />
|
||||||
@@ -190,3 +194,112 @@ export default function ReverseProxyAuthCell({
|
|||||||
</div>
|
</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
|
// Go time.ParseDuration format: one or more {number}{unit} pairs
|
||||||
const DURATION_RE = /^(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))+$/;
|
const DURATION_RE = /^(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))+$/;
|
||||||
const MAX_TIMEOUT_MS = 5 * 60 * 1000; // 5m
|
function validateTimeout(timeout: string): string | undefined {
|
||||||
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 {
|
|
||||||
if (!timeout) return undefined;
|
if (!timeout) return undefined;
|
||||||
if (!DURATION_RE.test(timeout))
|
if (!DURATION_RE.test(timeout))
|
||||||
return 'Invalid duration, use e.g., "10s", "30s", "1m"';
|
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;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateSessionIdleTimeout(timeout: string): string | undefined {
|
function validateSessionIdleTimeout(timeout: string): string | undefined {
|
||||||
if (!timeout) return undefined;
|
if (!timeout) return undefined;
|
||||||
if (!DURATION_RE.test(timeout))
|
if (!DURATION_RE.test(timeout))
|
||||||
return 'Invalid duration, use e.g., "30s", "2m", "5m"';
|
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;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user