Compare commits

...

8 Commits

Author SHA1 Message Date
Viktor Liu
e9ac1a1a23 Add CrowdSec IP reputation (#600)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-04-21 12:29:37 +02:00
raghvendra
b53802a5c5 fix: prevent storage clear and logout on failed account deletion (#611) 2026-04-13 09:07:14 +02:00
Eduard Gert
9addc18956 Fix reverse proxy mode selection (#606)
* Fix reverse proxy mode selection

* Fix isNetBirdHosted

* Fix activity description
2026-04-09 09:52:35 +02:00
shuuri-labs
9701e6503b Add new pull request template + enforce documentation acknowledgement… (#602)
* Add new pull request template + enforce documentation acknowledgement in new workflow

* fix docs-ack workflow: pass PR number via env and simplify checkbox validation
2026-04-02 21:39:38 +02:00
Eduard Gert
0841caecbb Fix dns zone domain validation and peers last seen sort (#595)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-03-25 17:50:22 +01:00
Eduard Gert
c7846760d1 Add reverse proxy auth headers (#593)
* Add reverse proxy access rules

* Fix coderabbit comments

* Fix coderabbit comments

* Fix coderabbit comments

* Add auth header modal

* Remove password managers from auth headers

* fix unique id

* Remove gradient, fix button roundness

* update lucide, add additional event auth methods

* Clear existing header value on change
2026-03-25 14:31:36 +01:00
Viktor Liu
8c283b6ef9 Support optional subdomain for reverse proxy domains (#589) 2026-03-24 16:01:01 +01:00
Eduard Gert
34ae3b4da6 Add reverse proxy access rules (#592)
* Add reverse proxy access rules

* Fix coderabbit comments

* Fix coderabbit comments

* Fix coderabbit comments
2026-03-24 16:00:31 +01:00
52 changed files with 2087 additions and 352 deletions

12
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,12 @@
## Issue ticket number and link
## Documentation
Select exactly one:
- [ ] I added/updated documentation for this change
- [ ] Documentation is **not needed** for this change (explain why)
### Docs PR URL (required if "docs added" is checked)
Paste the PR link from https://github.com/netbirdio/docs here:
https://github.com/netbirdio/docs/pull/__

105
.github/workflows/docs-ack.yml vendored Normal file
View File

@@ -0,0 +1,105 @@
name: Docs Acknowledgement
on:
pull_request:
types: [opened, edited, synchronize]
permissions:
contents: read
pull-requests: read
jobs:
docs-ack:
name: Require docs PR URL or explicit "not needed"
runs-on: ubuntu-latest
steps:
- name: Read PR body
id: body
shell: bash
run: |
set -euo pipefail
BODY_B64=$(jq -r '.pull_request.body // "" | @base64' "$GITHUB_EVENT_PATH")
{
echo "body_b64=$BODY_B64"
} >> "$GITHUB_OUTPUT"
- name: Validate checkbox selection
id: validate
shell: bash
env:
BODY_B64: ${{ steps.body.outputs.body_b64 }}
run: |
set -euo pipefail
if ! body="$(printf '%s' "$BODY_B64" | base64 -d)"; then
echo "::error::Failed to decode PR body from base64. Data may be corrupted or missing."
exit 1
fi
added_checked=$(printf '%s' "$body" | grep -Ei '^[[:space:]]*-\s*\[x\]\s*I added/updated documentation' | wc -l | tr -d '[:space:]' || true)
noneed_checked=$(printf '%s' "$body" | grep -Ei '^[[:space:]]*-\s*\[x\]\s*Documentation is \*\*not needed\*\*' | wc -l | tr -d '[:space:]' || true)
total=$((added_checked + noneed_checked))
if [ "$total" -ne 1 ]; then
echo "::error::You must check exactly one docs option in the PR template (either 'docs added' OR 'not needed')."
exit 1
fi
if [ "$added_checked" -eq 1 ]; then
echo "mode=added" >> "$GITHUB_OUTPUT"
else
echo "mode=noneed" >> "$GITHUB_OUTPUT"
fi
- name: Extract docs PR URL (when 'docs added')
if: steps.validate.outputs.mode == 'added'
id: extract
shell: bash
env:
BODY_B64: ${{ steps.body.outputs.body_b64 }}
run: |
set -euo pipefail
body="$(printf '%s' "$BODY_B64" | base64 -d)"
# Strictly require HTTPS and that it's a PR in netbirdio/docs
# e.g., https://github.com/netbirdio/docs/pull/1234
url="$(printf '%s' "$body" | grep -Eo 'https://github\.com/netbirdio/docs/pull/[0-9]+' | head -n1 || true)"
if [ -z "${url:-}" ]; then
echo "::error::You checked 'docs added' but didn't include a valid HTTPS PR link to netbirdio/docs (e.g., https://github.com/netbirdio/docs/pull/1234)."
exit 1
fi
pr_number="$(printf '%s' "$url" | sed -E 's#.*/pull/([0-9]+)$#\1#')"
{
echo "url=$url"
echo "pr_number=$pr_number"
} >> "$GITHUB_OUTPUT"
- name: Verify docs PR exists (and is open or merged)
if: steps.validate.outputs.mode == 'added'
uses: actions/github-script@v7
id: verify
env:
PR_NUMBER: ${{ steps.extract.outputs.pr_number }}
with:
script: |
const prNumber = parseInt(process.env.PR_NUMBER, 10);
const { data } = await github.rest.pulls.get({
owner: 'netbirdio',
repo: 'docs',
pull_number: prNumber
});
// Allow open or merged PRs
const ok = data.state === 'open' || data.merged === true;
core.setOutput('state', data.state);
core.setOutput('merged', String(!!data.merged));
if (!ok) {
core.setFailed(`Docs PR #${prNumber} exists but is neither open nor merged (state=${data.state}, merged=${data.merged}).`);
}
result-encoding: string
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: All good
run: echo "Documentation requirement satisfied ✅"

98
package-lock.json generated
View File

@@ -59,8 +59,8 @@
"ip-cidr": "^3.1.0",
"js-cookie": "^3.0.5",
"lodash": "^4.17.23",
"lucide-react": "^0.562.0",
"next": "^16.1.7",
"lucide-react": "^0.566.0",
"next": "16.1.7",
"next-themes": "^0.2.1",
"punycode": "^2.3.1",
"react": "^19.2.4",
@@ -3244,32 +3244,6 @@
"typescript": ">=4.8.4 <6.0.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
@@ -3998,10 +3972,13 @@
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.9.19",
@@ -4025,13 +4002,15 @@
}
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/braces": {
@@ -4320,12 +4299,6 @@
"node": ">= 10"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"license": "MIT"
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -6889,9 +6862,9 @@
}
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash.merge": {
@@ -6923,9 +6896,9 @@
}
},
"node_modules/lucide-react": {
"version": "0.562.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz",
"integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==",
"version": "0.566.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.566.0.tgz",
"integrity": "sha512-b18qC/JAh1X9rVKlF5EtSIyumdIYuh78b0JShynZnHbcaWR4AW4oZyi8Ms/aQYVSnLPlAnMhug2hSr19BgVZAw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -6973,15 +6946,18 @@
}
},
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"license": "ISC",
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^1.1.7"
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "*"
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minimist": {
@@ -7412,9 +7388,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -8777,9 +8753,9 @@
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
"peer": true,
"engines": {

View File

@@ -67,8 +67,8 @@
"ip-cidr": "^3.1.0",
"js-cookie": "^3.0.5",
"lodash": "^4.17.23",
"lucide-react": "^0.562.0",
"next": "^16.1.7",
"lucide-react": "^0.566.0",
"next": "16.1.7",
"next-themes": "^0.2.1",
"punycode": "^2.3.1",
"react": "^19.2.4",
@@ -89,6 +89,9 @@
"timescape": "^0.7.1",
"typescript": "^5"
},
"overrides": {
"minimatch": ">=10.2.1"
},
"devDependencies": {
"@faker-js/faker": "^9.5.1",
"@types/chroma-js": "^3.1.1",

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -23,6 +23,7 @@ const variants = cva("", {
purple: ["bg-purple-950/50 border-purple-500 border text-purple-500"],
yellow: ["bg-yellow-950 border-yellow-500 border text-yellow-400"],
gray: ["bg-nb-gray-930/60 border-nb-gray-800/40 text-nb-gray-300 border"],
lightGray: ["bg-nb-gray-910 text-nb-gray-200 border border-nb-gray-900"],
grayer: [
"bg-nb-gray-900/40 border-nb-gray-800/40 text-nb-gray-300 border",
],
@@ -45,6 +46,7 @@ const variants = cva("", {
"blue-darker": ["hover:bg-sky-800"],
red: ["hover:bg-red-950/40"],
gray: ["hover:bg-nb-gray-900"],
lightGray: ["hover:bg-nb-gray-900"],
grayer: ["hover:bg-nb-gray-900"],
"gray-ghost": ["hover:bg-nb-gray-800 cursor-pointer"],
green: ["hover:bg-green-950/50"],

View File

@@ -74,7 +74,7 @@ export const buttonVariants = cva(
"",
],
"danger-text": [
"dark:bg-transparent dark:text-red-500 dark:hover:text-red-600 dark:border-transparent !px-0 !shadow-none !py-0 focus:ring-red-500/30 dark:ring-offset-neutral-950/50",
"dark:bg-transparent dark:text-red-500 dark:hover:text-red-600 dark:border-transparent !px-0 !shadow-none !py-0 focus:ring-red-500/30 dark:ring-offset-neutral-950/50 rounded-sm",
],
"default-outline": [
"dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20",

View File

@@ -8,7 +8,7 @@ import React from "react";
export const fancyToggleSwitchVariants = cva([], {
variants: {
variant: {
default: ["px-6 py-4 border rounded-md"],
default: ["px-5 py-4 border rounded-md"],
blank: null,
},
state: {
@@ -45,6 +45,8 @@ interface Props extends FancyToggleSwitchVariants {
disabled?: boolean;
dataCy?: string;
className?: string;
labelClassName?: string;
textWrapperClassName?: string;
}
export default function FancyToggleSwitch({
@@ -57,6 +59,8 @@ export default function FancyToggleSwitch({
dataCy,
className,
variant = "default",
labelClassName,
textWrapperClassName = "max-w-sm",
}: Readonly<Props>) {
const handleToggle = () => {
if (disabled) return;
@@ -87,8 +91,8 @@ export default function FancyToggleSwitch({
)}
>
<div className={"flex justify-between gap-10"}>
<div className={"max-w-sm"}>
<Label>{label}</Label>
<div className={cn(textWrapperClassName)}>
<Label className={labelClassName}>{label}</Label>
<HelpText margin={false}>{helpText}</HelpText>
</div>
<div className={"mt-2 pr-1"}>

View File

@@ -98,7 +98,7 @@ const SelectItem = React.forwardRef<
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="flex-shrink-0">{icon}</span>
<div className="flex flex-col">
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>

View File

@@ -48,6 +48,9 @@ interface SelectDropdownProps {
children?: React.ReactNode;
maxHeight?: number;
triggerClassName?: string;
iconSize?: number;
truncate?: boolean;
compact?: boolean;
}
export function SelectDropdown({
@@ -68,6 +71,9 @@ export function SelectDropdown({
children,
maxHeight,
triggerClassName,
iconSize = 14,
truncate = false,
compact = false,
}: Readonly<SelectDropdownProps>) {
const [inputRef, { width }] = useElementSize<HTMLButtonElement>();
@@ -107,15 +113,18 @@ export function SelectDropdown({
const SelectedItem = () => {
return (
<div className={"flex items-center gap-2.5"}>
{selected?.icon && <selected.icon size={14} width={14} />}
<div className={cn("flex items-center gap-2.5", truncate && "min-w-0")}>
{selected?.icon && <selected.icon size={iconSize} width={iconSize} />}
<div
className={cn(
"flex flex-col text-sm font-medium",
size === "xs" && "text-xs",
truncate && "min-w-0",
)}
>
<span className={"text-nb-gray-200"}>{selected?.label}</span>
<span className={cn("text-nb-gray-200", truncate && "truncate")}>
{selected?.label}
</span>
</div>
</div>
);
@@ -216,20 +225,22 @@ export function SelectDropdown({
<ScrollArea
className={cn(
"overflow-y-auto flex flex-col gap-1 pl-2 pr-3",
!showSearch && "pt-2",
"overflow-y-auto flex flex-col gap-1",
compact ? "pl-1 pr-1" : "pl-2 pr-3",
!showSearch && (compact ? "pt-1" : "pt-2"),
)}
style={{
maxHeight: maxHeight ?? 380,
}}
>
<CommandGroup>
<div className={"grid grid-cols-1 gap-1 pb-2 w-full"}>
<div className={cn("grid grid-cols-1 gap-1 w-full", compact ? "pb-1" : "pb-2")}>
{filteredItems.map((option) => (
<SelectDropdownItem
option={option}
toggle={toggle}
key={option.value}
iconSize={iconSize}
showValue={showValues}
size={size}
/>
@@ -249,11 +260,13 @@ const SelectDropdownItem = ({
toggle,
showValue = false,
size = "sm",
iconSize = 14,
}: {
option: SelectOption;
toggle: (value: string) => void;
showValue?: boolean;
size: "xs" | "sm";
iconSize?: number;
}) => {
const value = option.value || "" + option.label || "";
const elementRef = useRef<HTMLDivElement>(null);
@@ -285,7 +298,12 @@ const SelectDropdownItem = ({
option?.disabled && "cursor-not-allowed",
)}
>
{option.icon && <option.icon size={14} width={14} />}
{option.icon && (
<div className={"shrink-0"}>
<option.icon size={iconSize} width={iconSize} />
</div>
)}
{option?.renderItem && option.renderItem()}
{!option?.renderItem && (
<div

View File

@@ -53,6 +53,7 @@ declare module "@tanstack/table-core" {
}
interface SortingFns {
checkbox: SortingFn<unknown>;
datetime: SortingFn<unknown>;
}
}
@@ -99,6 +100,15 @@ const arrIncludesSomeExact: FilterFn<any> = (
return value.some((val) => val === rowValue);
};
const datetimeSort: SortingFn<any> = (rowA, rowB, columnId) => {
const aConnected = rowA.original?.connected;
const bConnected = rowB.original?.connected;
if (aConnected !== bConnected) return aConnected ? 1 : -1;
const a = dayjs(rowA.getValue(columnId)).valueOf();
const b = dayjs(rowB.getValue(columnId)).valueOf();
return a - b;
};
const checkboxSort: SortingFn<any> = (rowA, rowB, columnId) => {
const valueA =
columnId === "select" ? rowA.getIsSelected() : rowA.getValue(columnId);
@@ -324,6 +334,7 @@ export function DataTable<TData, TValue>({
},
sortingFns: {
checkbox: checkboxSort,
datetime: datetimeSort,
},
getRowId: useRowId ? (row) => row.id : undefined,
onRowSelectionChange: setRowSelection,

View File

@@ -14,6 +14,7 @@ type Props = {
center?: boolean;
className?: string;
sorting?: boolean;
onSort?: () => void;
name?: string;
};
export default function DataTableHeader({
@@ -23,14 +24,20 @@ export default function DataTableHeader({
center,
className,
sorting = true,
onSort,
name,
}: Props) {
const serverPagination = useOptionalServerPagination();
const handleSort = () => {
const direction = column.getIsSorted() === "asc" ? "desc" : "asc";
column.toggleSorting(direction === "desc");
if (onSort) {
onSort();
} else {
const direction = column.getIsSorted() === "asc" ? "desc" : "asc";
column.toggleSorting(direction === "desc");
}
if (name && serverPagination?.setSort) {
const direction = column.getIsSorted() === "asc" ? "desc" : "asc";
serverPagination.setSort(name, direction);
}
};

View File

@@ -9,8 +9,11 @@ import { useCountries } from "@/contexts/CountryProvider";
type Props = {
value: string;
onChange: (value: string) => void;
iconSize?: number;
popoverWidth?: "auto" | "content" | number;
truncate?: boolean;
};
export const CountrySelector = ({ value, onChange }: Props) => {
export const CountrySelector = ({ value, onChange, iconSize = 20, popoverWidth, truncate }: Props) => {
const { countries, isLoading } = useCountries();
const countryList = useMemo(() => {
@@ -22,7 +25,7 @@ export const CountrySelector = ({ value, onChange }: Props) => {
}) =>
createElement(RoundedFlag, {
country: country.country_code,
size: 20,
size: iconSize,
...props,
});
return {
@@ -42,7 +45,10 @@ export const CountrySelector = ({ value, onChange }: Props) => {
searchPlaceholder={"Search country..."}
value={value}
onChange={onChange}
iconSize={iconSize}
options={countryList || []}
popoverWidth={popoverWidth}
truncate={truncate}
/>
</div>
);

View File

@@ -13,7 +13,11 @@ const CountryContext = React.createContext(
countries: Country[] | undefined;
isLoading: boolean;
getRegionByPeer: (peer: Peer) => string;
getRegionText: (country_code: string, city_name: string) => string;
getRegionText: (
country_code: string,
city_name: string,
subdivision_code?: string,
) => string;
},
);
@@ -21,7 +25,11 @@ export default function CountryProvider({ children }: Props) {
const { isRestricted } = usePermissions();
const getRegionByPeer = (peer: Peer) => "Unknown";
const getRegionText = (country_code: string, city_name: string) => "Unknown";
const getRegionText = (
country_code: string,
city_name: string,
subdivision_code?: string,
) => "Unknown";
return isRestricted ? (
<CountryContext.Provider
@@ -47,12 +55,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],
);

View File

@@ -0,0 +1,17 @@
import { useAccount } from "@/modules/account/useAccount";
import { SSOIdentityProvider } from "@/interfaces/IdentityProvider";
import useFetchApi from "@utils/api";
export function useEmbeddedIdentityProviders() {
const account = useAccount();
const isEmbeddedIdPEnabled = !!account?.settings?.embedded_idp_enabled;
const { data: providers } = useFetchApi<SSOIdentityProvider[]>(
"/identity-providers",
true,
true,
isEmbeddedIdPEnabled,
);
return { providers, isEmbeddedIdPEnabled };
}

View File

@@ -1,37 +1,33 @@
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useMemo } from "react";
export default function useUrlTab(
validTabs: string[],
defaultTab: string,
paramName: string = "tab",
): [string, (value: string) => void] {
const searchParams = useSearchParams();
const router = useRouter();
const getTab = useCallback(
(params: URLSearchParams) => {
const tabParam = params.get("tab");
const tabParam = params.get(paramName);
if (tabParam && validTabs.includes(tabParam)) return tabParam;
return defaultTab;
},
[validTabs, defaultTab],
[validTabs, defaultTab, paramName],
);
const [tab, setTabState] = useState(() => getTab(searchParams));
useEffect(() => {
const newTab = getTab(searchParams);
setTabState(newTab);
}, [searchParams, getTab]);
const tab = useMemo(() => getTab(searchParams), [searchParams, getTab]);
const setTab = useCallback(
(value: string) => {
const nextTab = validTabs.includes(value) ? value : defaultTab;
setTabState(nextTab);
const params = new URLSearchParams(window.location.search);
params.set("tab", nextTab);
window.history.replaceState(null, "", `?${params.toString()}`);
const params = new URLSearchParams(searchParams.toString());
params.set(paramName, nextTab);
router.replace(`?${params.toString()}`, { scroll: false });
},
[validTabs, defaultTab],
[searchParams, router, validTabs, defaultTab, paramName],
);
return [tab, setTab];

View File

@@ -1,20 +1,22 @@
export interface GoogleWorkspaceIntegration {
id: string;
customerId: string;
syncInterval: number;
customer_id: string;
sync_interval: number;
enabled: boolean;
group_prefixes: string[];
user_group_prefixes: string[];
connector_id?: string;
}
export interface AzureADIntegration {
id: string;
clientId: string;
tenantId: string;
syncInterval: number;
client_id: string;
tenant_id: string;
sync_interval: number;
enabled: boolean;
group_prefixes: string[];
user_group_prefixes: string[];
connector_id?: string;
}
export interface OktaIntegration {
@@ -23,6 +25,8 @@ export interface OktaIntegration {
group_prefixes: string[];
user_group_prefixes: string[];
auth_token: string;
connection_name?: string;
connector_id?: string;
}
export interface IdentityProviderLog {

View File

@@ -18,9 +18,26 @@ export interface ReverseProxy {
pass_host_header?: boolean;
rewrite_redirects?: boolean;
auth?: ReverseProxyAuth;
access_restrictions?: AccessRestrictions;
meta?: ReverseProxyMeta;
}
export const CrowdSecMode = {
OFF: "off",
ENFORCE: "enforce",
OBSERVE: "observe",
} as const;
export type CrowdSecMode = (typeof CrowdSecMode)[keyof typeof CrowdSecMode];
export interface AccessRestrictions {
allowed_cidrs?: string[];
blocked_cidrs?: string[];
allowed_countries?: string[];
blocked_countries?: string[];
crowdsec_mode?: CrowdSecMode;
}
export interface ReverseProxyMeta {
created_at: string;
status: ReverseProxyStatus;
@@ -77,6 +94,13 @@ export interface ReverseProxyAuth {
link_auth?: {
enabled: boolean;
};
header_auths?: HeaderAuthConfig[];
}
export interface HeaderAuthConfig {
enabled: boolean;
header: string;
value: string;
}
export interface ReverseProxyDomain {
@@ -86,6 +110,8 @@ export interface ReverseProxyDomain {
type: ReverseProxyDomainType;
target_cluster?: string;
supports_custom_ports?: boolean;
require_subdomain?: boolean;
supports_crowdsec?: boolean;
}
export enum ReverseProxyDomainType {
@@ -129,9 +155,11 @@ export interface ReverseProxyEvent {
auth_method_used?: string;
country_code?: string;
city_name?: string;
subdivision_code?: string;
bytes_upload: number;
bytes_download: number;
protocol?: EventProtocol;
metadata?: Record<string, string>;
}
export function isL4Event(event: ReverseProxyEvent): boolean {
@@ -181,5 +209,8 @@ export const REVERSE_PROXY_DOMAIN_VERIFICATION_LINK =
export const REVERSE_PROXY_EVENTS_DOCS_LINK =
"https://docs.netbird.io/manage/reverse-proxy/access-logs";
export const REVERSE_PROXY_ACCESS_CONTROL_DOCS_LINK =
"https://docs.netbird.io/manage/reverse-proxy";
export const REVERSE_PROXY_TROUBLESHOOTING_DOCS_LINK =
"https://docs.netbird.io/manage/reverse-proxy#troubleshooting";

View File

@@ -821,6 +821,36 @@ export default function ActivityDescription({ event }: Props) {
</div>
);
/**
* Reverse Proxy
*/
if (event.activity_code == "service.create")
return (
<div className={"inline"}>
Service <Value>{m.domain}</Value> in cluster{" "}
<Value>{m.proxy_cluster}</Value> was created with authentication{" "}
<Value>{m.auth ? "Enabled" : "Disabled"}</Value>
</div>
);
if (event.activity_code == "service.update")
return (
<div className={"inline"}>
Service <Value>{m.domain}</Value> in cluster{" "}
<Value>{m.proxy_cluster}</Value> was updated with authentication{" "}
<Value>{m.auth === "true" ? "Enabled" : "Disabled"}</Value>
</div>
);
if (event.activity_code == "service.delete")
return (
<div className={"inline"}>
Service <Value>{m.domain}</Value> in cluster{" "}
<Value>{m.proxy_cluster}</Value> was deleted
</div>
);
return (
<div className={"flex gap-2.5 items-center"}>
<span className={"mb-[1px]"}>{event.activity}</span>

View File

@@ -49,7 +49,9 @@ export function ActivityEventCodeSelector({
return {
activity_code: event.activity_code,
activity: event.activity,
group: event.activity_code.split(".")[0],
group: event.activity_code.startsWith("service.user")
? "Service User"
: event.activity_code.split(".")[0],
};
});
return items.reduce((acc, item) => {

View File

@@ -11,7 +11,6 @@ import {
KeyRound,
Layers3Icon,
LogIn,
type LucideIcon,
MonitorSmartphoneIcon,
NetworkIcon,
RefreshCcw,
@@ -21,6 +20,7 @@ import {
User,
} from "lucide-react";
import React from "react";
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
type Props = {
code: string;
@@ -46,7 +46,7 @@ const ActivityTypeMappings = {
dashboard: LogIn,
integration: Blocks,
personal: User,
service: Cog,
"service.user": Cog,
billing: CreditCardIcon,
integrated: ShieldCheck,
posture: ShieldCheck,
@@ -54,21 +54,24 @@ const ActivityTypeMappings = {
resource: Layers3Icon,
network: NetworkIcon,
identityprovider: FingerprintIcon,
} as const satisfies Record<string, LucideIcon>;
service: ReverseProxyIcon,
} as const;
export default function ActivityTypeIcon({
code,
size = 18,
className,
}: Props) {
const prefixParts = code?.split(".") || [];
const prefix = (prefixParts[0] || "").toLowerCase();
const parts = code?.split(".") || [];
const twoPartKey = parts.slice(0, 2).join(".").toLowerCase();
const onePartKey = (parts[0] || "").toLowerCase();
const key = (
twoPartKey in ActivityTypeMappings ? twoPartKey : onePartKey
) as ActivityTypeKey;
// Check if prefix is a valid key, otherwise use fallback
const Icon =
prefix in ActivityTypeMappings
? ActivityTypeMappings[prefix as ActivityTypeKey]
: HelpCircleIcon;
key in ActivityTypeMappings ? ActivityTypeMappings[key] : HelpCircleIcon;
return <Icon size={size} className={cn(DEFAULT_CLASSES, className)} />;
}

View File

@@ -91,10 +91,11 @@ export function DNSZoneModalContent({
if (domain == "") return "";
const valid = validator.isValidDomain(domain, {
allowWildcard: false,
allowOnlyTld: false,
allowOnlyTld: true,
preventLeadingAndTrailingDots: true,
});
if (!valid) {
return "Please enter a valid domain, e.g. company.internal or intra.example.com";
return "Please enter a valid domain, e.g. internal, company.internal or intra.example.com";
}
}, [domain]);

View File

@@ -138,8 +138,18 @@ const PeersTableColumns: ColumnDef<Peer>[] = [
},
{
accessorKey: "last_seen",
header: ({ column }) => {
return <DataTableHeader column={column}>Last seen</DataTableHeader>;
header: ({ column, table }) => {
return (
<DataTableHeader
column={column}
onSort={() => {
const desc = column.getIsSorted() === "desc";
table.setSorting([{ id: "last_seen", desc: !desc }]);
}}
>
Last seen
</DataTableHeader>
);
},
sortingFn: "datetime",
cell: ({ row }) => <PeerLastSeenCell peer={row.original} />,
@@ -226,17 +236,13 @@ export default function PeersTable({
"netbird-table-sort" + path,
[
{
id: "connected",
id: "last_seen",
desc: true,
},
{
id: "name",
desc: false,
},
{
id: "last_seen",
desc: true,
},
],
);

View File

@@ -0,0 +1,359 @@
import { useEffect, useMemo, useReducer, useRef, useState } from "react";
import { Label } from "@components/Label";
import HelpText from "@components/HelpText";
import Button from "@components/Button";
import { Input } from "@components/Input";
import cidr from "ip-cidr";
import {
FlagIcon,
MinusCircleIcon,
NetworkIcon,
PlusIcon,
ShieldCheckIcon,
ShieldXIcon,
WorkflowIcon,
} from "lucide-react";
import {
SelectDropdown,
SelectOption,
} from "@components/select/SelectDropdown";
import { CountrySelector } from "@/components/ui/CountrySelector";
import { AccessRestrictions, CrowdSecMode } from "@/interfaces/ReverseProxy";
import { ReverseProxyCrowdSecIPReputation } from "@/modules/reverse-proxy/ReverseProxyCrowdSecIPReputation";
type AccessAction = "allow" | "block";
type AccessRuleType = "country" | "ip" | "cidr";
const ACTION_OPTIONS: SelectOption[] = [
{
label: "Allow Only",
value: "allow",
icon: (props) => <ShieldCheckIcon {...props} className="text-green-500" />,
},
{
label: "Block Only",
value: "block",
icon: (props) => <ShieldXIcon {...props} className="text-red-500" />,
},
];
const TYPE_OPTIONS: SelectOption[] = [
{
label: "Country",
value: "country",
icon: (props) => <FlagIcon {...props} />,
},
{
label: "IP Address",
value: "ip",
icon: (props) => <WorkflowIcon {...props} />,
},
{
label: "CIDR Block",
value: "cidr",
icon: (props) => <NetworkIcon {...props} />,
},
];
type AccessRule = {
id: string;
action: AccessAction;
type: AccessRuleType;
value: string;
};
type RulesAction =
| { type: "add" }
| { type: "remove"; id: string }
| {
type: "update";
id: string;
field: "action" | "type" | "value";
value: string;
};
const nextId = () => crypto.randomUUID();
function rulesReducer(state: AccessRule[], action: RulesAction): AccessRule[] {
switch (action.type) {
case "add":
return [
...state,
{ id: nextId(), action: "allow", type: "country", value: "" },
];
case "remove":
return state.filter((r) => r.id !== action.id);
case "update":
return state.map((r) => {
if (r.id !== action.id) return r;
if (action.field === "type") {
return { ...r, type: action.value as AccessRuleType, value: "" };
}
return { ...r, [action.field]: action.value };
});
}
}
function pushCidrRules(
rules: AccessRule[],
values: string[] | undefined,
action: AccessAction,
) {
values?.forEach((v) => {
const isIp = v.includes(":") ? v.endsWith("/128") : v.endsWith("/32");
rules.push({
id: nextId(),
action,
type: isIp ? "ip" : "cidr",
value: isIp ? v.replace(/\/(32|128)$/, "") : v,
});
});
}
function restrictionsToRules(
restrictions: AccessRestrictions | undefined,
): AccessRule[] {
if (!restrictions) return [];
const rules: AccessRule[] = [];
pushCidrRules(rules, restrictions.blocked_cidrs, "block");
restrictions.blocked_countries?.forEach((v) =>
rules.push({ id: nextId(), action: "block", type: "country", value: v }),
);
pushCidrRules(rules, restrictions.allowed_cidrs, "allow");
restrictions.allowed_countries?.forEach((v) =>
rules.push({ id: nextId(), action: "allow", type: "country", value: v }),
);
return rules;
}
function rulesToRestrictions(
rules: AccessRule[],
crowdsecMode?: CrowdSecMode,
): AccessRestrictions | undefined {
const allowed_countries: string[] = [];
const blocked_countries: string[] = [];
const allowed_cidrs: string[] = [];
const blocked_cidrs: string[] = [];
for (const rule of rules) {
if (!rule.value) continue;
if (rule.type === "country") {
if (rule.action === "allow") allowed_countries.push(rule.value);
else blocked_countries.push(rule.value);
} else {
const suffix = rule.value.includes(":") ? "/128" : "/32";
const value =
rule.type === "ip" && !rule.value.includes("/")
? `${rule.value}${suffix}`
: rule.value;
if (rule.action === "allow") allowed_cidrs.push(value);
else blocked_cidrs.push(value);
}
}
const hasCrowdSec = crowdsecMode != null && crowdsecMode !== CrowdSecMode.OFF;
const hasAny =
allowed_countries.length > 0 ||
blocked_countries.length > 0 ||
allowed_cidrs.length > 0 ||
blocked_cidrs.length > 0 ||
hasCrowdSec;
if (!hasAny) return undefined;
return {
...(allowed_countries.length > 0 && { allowed_countries }),
...(blocked_countries.length > 0 && { blocked_countries }),
...(allowed_cidrs.length > 0 && { allowed_cidrs }),
...(blocked_cidrs.length > 0 && { blocked_cidrs }),
...(hasCrowdSec && { crowdsec_mode: crowdsecMode }),
};
}
type Props = {
value: AccessRestrictions | undefined;
onChange: (value: AccessRestrictions | undefined) => void;
onValidationChange?: (hasErrors: boolean) => void;
supportsCrowdSec?: boolean;
};
function validateRule(rule: AccessRule): string {
if (rule.type === "country" || !rule.value) return "";
if (rule.type === "ip") {
const val = rule.value.includes("/") ? rule.value : `${rule.value}/32`;
if (!cidr.isValidAddress(val)) {
return "Please enter a valid IP address, e.g., 85.203.15.42";
}
} else {
if (!rule.value.includes("/") || !cidr.isValidAddress(rule.value)) {
return "Please enter a valid CIDR block, e.g., 74.125.0.0/16";
}
}
return "";
}
export const ReverseProxyAccessControlRules = ({
value,
onChange,
onValidationChange,
supportsCrowdSec,
}: Props) => {
const [rules, dispatch] = useReducer(
rulesReducer,
value,
restrictionsToRules,
);
const [crowdsecMode, setCrowdsecMode] = useState<CrowdSecMode>(
value?.crowdsec_mode ?? CrowdSecMode.OFF,
);
const errors = useMemo(
() => Object.fromEntries(rules.map((r) => [r.id, validateRule(r)])),
[rules],
);
const hasErrors = useMemo(
() => Object.values(errors).some((e) => e !== ""),
[errors],
);
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const onValidationChangeRef = useRef(onValidationChange);
onValidationChangeRef.current = onValidationChange;
useEffect(() => {
if (!supportsCrowdSec) {
setCrowdsecMode(CrowdSecMode.OFF);
}
}, [supportsCrowdSec]);
useEffect(() => {
onChangeRef.current(rulesToRestrictions(rules, crowdsecMode));
}, [rules, crowdsecMode]);
useEffect(() => {
onValidationChangeRef.current?.(hasErrors);
}, [hasErrors]);
return (
<div className={"flex-col flex"}>
{supportsCrowdSec && (
<ReverseProxyCrowdSecIPReputation
value={crowdsecMode}
onChange={setCrowdsecMode}
/>
)}
<div>
<Label>Access Control Rules</Label>
<HelpText>
Define rules to allow or block traffic based on country, IP address,
or CIDR block.
<br />
Block rules always take priority over allow rules.
</HelpText>
</div>
{rules.length > 0 && (
<div className="flex flex-col gap-3 mt-1 mb-4">
{rules.map((rule) => (
<div key={rule.id} className="flex items-center">
<div className="w-[160px] shrink-0 [&_button]:rounded-r-none [&_button]:w-[160px]">
<SelectDropdown
value={rule.action}
onChange={(v) =>
dispatch({
type: "update",
id: rule.id,
field: "action",
value: v,
})
}
options={ACTION_OPTIONS}
compact
/>
</div>
<div className="w-[160px] shrink-0 -ml-px [&_button]:rounded-none [&_button]:w-[160px]">
<SelectDropdown
value={rule.type}
onChange={(v) =>
dispatch({
type: "update",
id: rule.id,
field: "type",
value: v,
})
}
options={TYPE_OPTIONS}
compact
/>
</div>
<div className="flex-1 min-w-0 -ml-px [&_button]:rounded-l-none [&_input]:rounded-l-none">
{rule.type === "country" ? (
<CountrySelector
iconSize={16}
popoverWidth={350}
truncate
value={rule.value}
onChange={(v) =>
dispatch({
type: "update",
id: rule.id,
field: "value",
value: v,
})
}
/>
) : (
<Input
placeholder={
rule.type === "ip"
? "e.g., 85.203.15.42"
: "e.g., 74.125.0.0/16"
}
value={rule.value}
onChange={(e) =>
dispatch({
type: "update",
id: rule.id,
field: "value",
value: e.target.value,
})
}
error={errors[rule.id]}
errorTooltip={true}
maxWidthClass="w-full"
/>
)}
</div>
<Button
variant="default-outline"
className="h-[42px] w-[42px] !px-0 shrink-0 ml-2"
onClick={() => dispatch({ type: "remove", id: rule.id })}
aria-label="Remove rule"
>
<MinusCircleIcon size={14} />
</Button>
</div>
))}
</div>
)}
<div className="flex gap-2">
<Button
variant="dotted"
className="flex-1"
size="sm"
onClick={() => dispatch({ type: "add" })}
>
<PlusIcon size={14} />
Add Rule
</Button>
</div>
</div>
);
};

View File

@@ -0,0 +1,109 @@
import * as React from "react";
import { ReactNode } from "react";
import { Label } from "@components/Label";
import HelpText from "@components/HelpText";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@components/Select";
import { EyeIcon, PowerOffIcon, ShieldCheckIcon } from "lucide-react";
import { HelpTooltip } from "@components/HelpTooltip";
import { CrowdSecMode } from "@/interfaces/ReverseProxy";
import Image from "next/image";
import CrowdSecIconImage from "@/assets/integrations/crowdsec.png";
type Props = {
value: CrowdSecMode;
onChange: (value: CrowdSecMode) => void;
};
type CrowdSecOption = {
label: string;
description?: string;
icon: ReactNode;
};
const CROWDSEC_OPTIONS: Record<CrowdSecMode, CrowdSecOption> = {
[CrowdSecMode.OFF]: {
label: "Disabled",
icon: <PowerOffIcon size={14} />,
},
[CrowdSecMode.ENFORCE]: {
label: "Enforce",
description:
"Blocked IPs are denied immediately. If the bouncer is not yet synced, connections are denied (fail-closed).",
icon: <ShieldCheckIcon size={14} />,
},
[CrowdSecMode.OBSERVE]: {
label: "Observe",
description:
"Blocked IPs are logged but not denied. Use this to evaluate CrowdSec before enforcing.",
icon: <EyeIcon size={14} />,
},
};
export const ReverseProxyCrowdSecIPReputation = ({
value,
onChange,
}: Props) => {
const selected = CROWDSEC_OPTIONS[value];
return (
<div className="flex items-center gap-0 justify-between mb-6">
<div className="flex gap-4">
<div
className={
"h-12 w-12 flex items-center justify-center rounded-md bg-nb-gray-900/70 p-2 border border-nb-gray-900/70 shrink-0 relative"
}
>
<Image
src={CrowdSecIconImage}
alt={"CrowdSec"}
className={"rounded-[4px]"}
/>
</div>
<div>
<Label>CrowdSec IP Reputation</Label>
<HelpText>
Detect malicious IPs with CrowdSec.{" "}
<b className={"text-white"}>Enforce</b> to block them or{" "}
<b className={"text-white"}>Observe</b> to only log without
blocking.
</HelpText>
</div>
</div>
<Select value={value} onValueChange={(v) => onChange(v as CrowdSecMode)}>
<SelectTrigger className="w-[260px]">
<div className="flex items-center gap-2 whitespace-nowrap">
{selected.icon}
<SelectValue />
</div>
</SelectTrigger>
<SelectContent>
{Object.entries(CROWDSEC_OPTIONS).map(([mode, config]) => (
<SelectItem
key={mode}
value={mode}
extra={
config.description ? (
<HelpTooltip
triggerClassName="ml-[0.01rem]"
align="center"
side="right"
content={<>{config.description}</>}
/>
) : undefined
}
>
<span className="whitespace-nowrap">{config.label}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
};

View File

@@ -46,7 +46,7 @@ export default function ReverseProxyHTTPTargets({
}: Readonly<Props>) {
return (
<div>
<Label>HTTP/S Targets</Label>
<Label>HTTPS Targets</Label>
<HelpText>
Add one or more devices running your service or resources to make it
publicly accessible.
@@ -93,10 +93,7 @@ export default function ReverseProxyHTTPTargets({
/>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant="default-outline"
className="!px-3"
>
<Button variant="default-outline" className="!px-3">
<MoreVertical size={16} className="shrink-0" />
</Button>
</DropdownMenuTrigger>
@@ -104,9 +101,7 @@ export default function ReverseProxyHTTPTargets({
className="w-auto min-w-[200px]"
align="end"
>
<DropdownMenuItem
onClick={() => onEditTarget(index)}
>
<DropdownMenuItem onClick={() => onEditTarget(index)}>
<div className="flex gap-3 items-center">
<Edit size={14} className="shrink-0" />
Edit Target
@@ -117,10 +112,7 @@ export default function ReverseProxyHTTPTargets({
onClick={() => onRemoveTarget(index)}
>
<div className="flex gap-3 items-center">
<MinusCircleIcon
size={14}
className="shrink-0"
/>
<MinusCircleIcon size={14} className="shrink-0" />
Remove Target
</div>
</DropdownMenuItem>
@@ -151,10 +143,7 @@ export default function ReverseProxyHTTPTargets({
variant="warning"
className="mt-3"
icon={
<AlertTriangle
size={14}
className="shrink-0 relative top-[3px]"
/>
<AlertTriangle size={14} className="shrink-0 relative top-[3px]" />
}
>
There are currently no resources in your network{" "}

View File

@@ -66,7 +66,7 @@ export default function ReverseProxyLayer4Content({
<Label>
Listen Port
<HelpTooltip
className={"max-w-sm"}
className={isListenPortSupported ? "max-w-sm" : "max-w-xs"}
content={
isListenPortSupported
? "Enter the public listen port this service will be reachable on."

View File

@@ -21,12 +21,14 @@ import {
Binary,
ClockFadingIcon,
ExternalLinkIcon,
FileCode2Icon,
GlobeIcon,
LockKeyhole,
MapPinned,
PlusCircle,
RectangleEllipsis,
Settings,
ShieldCheckIcon,
Users,
} from "lucide-react";
import { useRouter } from "next/navigation";
@@ -37,7 +39,10 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
import { Network, NetworkResource } from "@/interfaces/Network";
import { Peer } from "@/interfaces/Peer";
import {
AccessRestrictions,
HeaderAuthConfig,
isL4Mode as isL4ServiceMode,
REVERSE_PROXY_ACCESS_CONTROL_DOCS_LINK,
REVERSE_PROXY_AUTHENTICATION_DOCS_LINK,
REVERSE_PROXY_SERVICES_DOCS_LINK,
REVERSE_PROXY_SETTINGS_DOCS_LINK,
@@ -53,6 +58,7 @@ import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
import ReverseProxyDomainInput from "./domain/ReverseProxyDomainInput";
import { useReverseProxyDomain } from "./domain/useReverseProxyDomain";
import AuthPasswordModal from "@/modules/reverse-proxy/auth/AuthPasswordModal";
import AuthHeaderModal from "@/modules/reverse-proxy/auth/AuthHeaderModal";
import AuthPinModal from "@/modules/reverse-proxy/auth/AuthPinModal";
import AuthSSOModal from "@/modules/reverse-proxy/auth/AuthSSOModal";
import ReverseProxyHTTPTargets from "@/modules/reverse-proxy/ReverseProxyHTTPTargets";
@@ -61,14 +67,15 @@ import ReverseProxyTargetModal from "@/modules/reverse-proxy/targets/ReverseProx
import { type Target } from "@/modules/reverse-proxy/targets/ReverseProxyTargetSelector";
import { useReverseProxyAddress } from "@/modules/reverse-proxy/targets/ReverseProxyAddressInput";
import {
validateTimeout,
validateSessionIdleTimeout,
validateTimeout,
} from "@/modules/reverse-proxy/targets/useReverseProxyTargetOptions";
import useGroupHelper from "@/modules/groups/useGroupHelper";
import {
ReverseProxyServiceModeSelector,
SERVICE_MODES,
} from "@/modules/reverse-proxy/ReverseProxyServiceModeSelector";
import { ReverseProxyAccessControlRules } from "@/modules/reverse-proxy/ReverseProxyAccessControlRules";
type Props = {
open: boolean;
@@ -236,10 +243,24 @@ export default function ReverseProxyModal({
reverseProxy?.auth?.link_auth?.enabled ?? false,
);
const [headerAuthsEnabled, setHeaderAuthsEnabled] = useState(
(reverseProxy?.auth?.header_auths ?? []).some((h) => h.enabled),
);
const [headerAuths, setHeaderAuths] = useState<HeaderAuthConfig[]>(
reverseProxy?.auth?.header_auths ?? [],
);
const [accessRestrictions, setAccessRestrictions] = useState<
AccessRestrictions | undefined
>(reverseProxy?.access_restrictions);
const [accessControlHasErrors, setAccessControlHasErrors] = useState(false);
// Auth modal states
const [passwordModalOpen, setPasswordModalOpen] = useState(false);
const [ssoModalOpen, setSsoModalOpen] = useState(false);
const [pinModalOpen, setPinModalOpen] = useState(false);
const [headerModalOpen, setHeaderModalOpen] = useState(false);
// Target being added/edited
const [targetModalOpen, setTargetModalOpen] = useState(false);
@@ -248,8 +269,12 @@ export default function ReverseProxyModal({
);
const canContinueToSettings = useMemo(() => {
const subdomainRequired =
selectedDomain?.require_subdomain === true;
const isSubdomainValid =
subdomain.length > 0 && baseDomain.length > 0 && !domainAlreadyExists;
baseDomain.length > 0 &&
!domainAlreadyExists &&
(subdomain.length > 0 || !subdomainRequired);
const isValidPort = (port: number) => port >= 1 && port <= 65535;
const hasHttpEndpoint = !isL4Mode && targets.length > 0;
const hasL4Endpoint =
@@ -264,6 +289,7 @@ export default function ReverseProxyModal({
subdomain,
baseDomain,
domainAlreadyExists,
selectedDomain,
serviceMode,
targets.length,
isL4Mode,
@@ -305,16 +331,20 @@ export default function ReverseProxyModal({
);
};
const hasNoAuth =
!passwordEnabled && !pinEnabled && !bearerEnabled && !linkAuthEnabled;
const isUnprotected =
!passwordEnabled &&
!pinEnabled &&
!bearerEnabled &&
!linkAuthEnabled &&
!headerAuthsEnabled &&
!accessRestrictions;
const handleSubmit = async () => {
// Show warning if no authentication is configured (HTTP only; TLS is pass-through)
if (!isL4Mode && hasNoAuth) {
if (isUnprotected) {
const confirmed = await confirm({
title: "No Authentication Configured",
title: "No Protection Configured",
description:
"This service will be publicly accessible to everyone on the internet without any restrictions. Are you sure you want to continue?",
"This service has no authentication or access control rules configured. It will be publicly accessible to everyone on the internet. Are you sure you want to continue?",
type: "warning",
confirmText: reverseProxy ? "Save Changes" : "Add Service",
cancelText: "Cancel",
@@ -341,6 +371,9 @@ export default function ReverseProxyModal({
link_auth: {
enabled: linkAuthEnabled,
},
header_auths: headerAuthsEnabled
? headerAuths.map((h) => ({ ...h, enabled: true }))
: [],
};
const l4TargetPayload: ReverseProxyTarget | undefined = l4Target
@@ -383,6 +416,7 @@ export default function ReverseProxyModal({
pass_host_header: isL4Mode ? undefined : passHostHeader,
rewrite_redirects: isL4Mode ? undefined : rewriteRedirects,
auth: isL4Mode ? undefined : auth,
access_restrictions: accessRestrictions,
},
proxyId: reverseProxy?.id,
onSuccess: () => {
@@ -426,10 +460,17 @@ export default function ReverseProxyModal({
</TabsTrigger>
{!isL4Mode && (
<TabsTrigger value={"auth"} disabled={!canContinueToSettings}>
<LockKeyhole size={16} />
<LockKeyhole size={14} />
Authentication
</TabsTrigger>
)}
<TabsTrigger
value={"access-control"}
disabled={!canContinueToSettings}
>
<ShieldCheckIcon size={14} />
Access Control
</TabsTrigger>
<TabsTrigger value={"settings"} disabled={!canContinueToSettings}>
<Settings size={14} />
Advanced Settings
@@ -444,6 +485,7 @@ export default function ReverseProxyModal({
baseDomain={baseDomain}
onBaseDomainChange={setBaseDomain}
domainAlreadyExists={domainAlreadyExists}
subdomainRequired={selectedDomain?.require_subdomain === true}
clusterOffline={
reverseProxy?.proxy_cluster && !isClusterConnected
? { clusterName: reverseProxy.proxy_cluster }
@@ -527,10 +569,32 @@ export default function ReverseProxyModal({
enabled={pinEnabled}
onClick={() => setPinModalOpen(true)}
/>
<SettingCard.Item
label={
<>
<FileCode2Icon size={15} />
HTTP Headers
</>
}
description="Require specific HTTP headers to access this service."
enabled={headerAuthsEnabled}
onClick={() => setHeaderModalOpen(true)}
/>
</SettingCard>
</div>
</TabsContent>
<TabsContent value={"access-control"} className={"pb-8"}>
<div className={"px-8 flex-col flex gap-4"}>
<ReverseProxyAccessControlRules
value={accessRestrictions}
onChange={setAccessRestrictions}
onValidationChange={setAccessControlHasErrors}
supportsCrowdSec={selectedDomain?.supports_crowdsec}
/>
</div>
</TabsContent>
<TabsContent value={"settings"} className={"pb-8"}>
<div className={"px-8 flex-col flex gap-6"}>
{(serviceMode === ServiceMode.TCP ||
@@ -627,6 +691,10 @@ export default function ReverseProxyModal({
href: REVERSE_PROXY_AUTHENTICATION_DOCS_LINK,
label: "Authentication",
},
"access-control": {
href: REVERSE_PROXY_ACCESS_CONTROL_DOCS_LINK,
label: "Access Control",
},
settings: {
href: REVERSE_PROXY_SETTINGS_DOCS_LINK,
label: "Settings",
@@ -653,7 +721,9 @@ export default function ReverseProxyModal({
</ModalClose>
<Button
variant={"primary"}
onClick={() => setTab(isL4Mode ? "settings" : "auth")}
onClick={() =>
setTab(isL4Mode ? "access-control" : "auth")
}
disabled={!canContinueToSettings}
>
Continue
@@ -669,9 +739,27 @@ export default function ReverseProxyModal({
>
Back
</Button>
<Button
variant={"primary"}
onClick={() => setTab("access-control")}
>
Continue
</Button>
</>
)}
{tab === "access-control" && (
<>
<Button
variant={"secondary"}
onClick={() => setTab(isL4Mode ? "targets" : "auth")}
>
Back
</Button>
<Button
variant={"primary"}
onClick={() => setTab("settings")}
disabled={accessControlHasErrors}
>
Continue
</Button>
@@ -682,7 +770,7 @@ export default function ReverseProxyModal({
<>
<Button
variant={"secondary"}
onClick={() => setTab(isL4Mode ? "targets" : "auth")}
onClick={() => setTab("access-control")}
>
Back
</Button>
@@ -691,7 +779,8 @@ export default function ReverseProxyModal({
disabled={
!canContinueToSettings ||
!permission?.services?.create ||
!!timeoutError
!!timeoutError ||
accessControlHasErrors
}
onClick={handleSubmit}
>
@@ -711,7 +800,8 @@ export default function ReverseProxyModal({
disabled={
!canContinueToSettings ||
!permission?.services?.update ||
!!timeoutError
!!timeoutError ||
accessControlHasErrors
}
onClick={handleSubmit}
>
@@ -806,6 +896,25 @@ export default function ReverseProxyModal({
}, 200);
}}
/>
<AuthHeaderModal
open={headerModalOpen}
onOpenChange={setHeaderModalOpen}
key={headerModalOpen ? "h1" : "h0"}
currentHeaders={headerAuths}
onSave={(headers) => {
setTimeout(() => {
setHeaderAuths(headers);
setHeaderAuthsEnabled(true);
}, 200);
}}
onRemove={() => {
setTimeout(() => {
setHeaderAuths([]);
setHeaderAuthsEnabled(false);
}, 200);
}}
/>
</Modal>
);
}

View File

@@ -31,7 +31,7 @@ type ServiceModeConfig = {
export const SERVICE_MODES: Record<ServiceMode, ServiceModeConfig> = {
[ServiceMode.HTTP]: {
label: "HTTP/S Service",
label: "HTTPS Service",
description:
"Reverse proxy with path routing and built-in authentication (SSO, PIN, password). Typically used for web applications and APIs.",
icon: <Globe size={14} />,
@@ -64,7 +64,7 @@ export const ReverseProxyServiceModeSelector = ({
}: Props) => {
const selected = value ?? ServiceMode.HTTP;
const selectedMode = SERVICE_MODES[selected];
const isL4Supported = domain?.supports_custom_ports === true;
const isL4Supported = domain?.supports_custom_ports !== undefined;
// Reset to HTTP if the current L4 mode becomes unsupported (e.g. domain changed)
useEffect(() => {

View File

@@ -0,0 +1,454 @@
import Button from "@components/Button";
import { Callout } from "@components/Callout";
import { Input } from "@components/Input";
import { Modal, ModalClose, ModalContent } from "@components/modal/Modal";
import ModalHeader from "@components/modal/ModalHeader";
import {
SelectDropdown,
SelectOption,
} from "@components/select/SelectDropdown";
import {
BracesIcon,
CircleUserIcon,
FileCode2Icon,
KeyRoundIcon,
MinusCircleIcon,
PlusIcon,
UserIcon,
} from "lucide-react";
import React, { useMemo, useReducer, useRef } from "react";
import { useHasChanges } from "@/hooks/useHasChanges";
import type { HeaderAuthConfig } from "@/interfaces/ReverseProxy";
type HeaderType = "basic" | "bearer" | "custom";
interface HeaderAuthItem {
id: string;
type: HeaderType;
header: string;
value: string;
username: string;
password: string;
existingSecret: boolean;
}
const HEADER_TYPE_OPTIONS: SelectOption[] = [
{
value: "basic" satisfies HeaderType,
label: "Basic Auth",
icon: () => <CircleUserIcon size={14} />,
},
{
value: "bearer" satisfies HeaderType,
label: "Bearer Token",
icon: () => <KeyRoundIcon size={14} />,
},
{
value: "custom" satisfies HeaderType,
label: "Custom Header",
icon: () => <BracesIcon size={14} />,
},
];
const MASKED_VALUE = "••••••••";
const INPUT_PROPS = {
autoComplete: "off",
"data-1p-ignore": true,
"data-lpignore": "true",
"data-form-type": "other",
} as const;
function createHeaderEntry(
overrides?: Partial<HeaderAuthItem>,
): HeaderAuthItem {
return {
id: crypto.randomUUID(),
type: "basic",
header: "Authorization",
value: "",
username: "",
password: "",
existingSecret: false,
...overrides,
};
}
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 headerEntryToConfig(entry: HeaderAuthItem): HeaderAuthConfig {
if (entry.existingSecret) {
const value = entry.value === MASKED_VALUE ? "" : entry.value;
return { enabled: true, header: entry.header, value };
}
switch (entry.type) {
case "basic": {
const encoded = toBase64(`${entry.username}:${entry.password}`);
return {
enabled: true,
header: "Authorization",
value: `Basic ${encoded}`,
};
}
case "bearer":
return {
enabled: true,
header: "Authorization",
value: `Bearer ${entry.value}`,
};
case "custom":
return { enabled: true, header: entry.header, value: entry.value };
}
}
function configToHeaderEntry(config: HeaderAuthConfig): HeaderAuthItem {
const isExisting = !config.value;
if (config.header === "Authorization" && config.value?.startsWith("Basic ")) {
try {
const decoded = fromBase64(config.value.slice(6));
const sep = decoded.indexOf(":");
if (sep >= 0) {
return createHeaderEntry({
type: "basic",
username: decoded.slice(0, sep),
password: decoded.slice(sep + 1),
});
}
} catch {}
}
if (
config.header === "Authorization" &&
config.value?.startsWith("Bearer ")
) {
return createHeaderEntry({ type: "bearer", value: config.value.slice(7) });
}
return createHeaderEntry({
type: isExisting && config.header === "Authorization" ? "basic" : "custom",
header: config.header,
value: isExisting ? MASKED_VALUE : config.value ?? "",
existingSecret: isExisting,
});
}
function isHeaderValid(entry: HeaderAuthItem): boolean {
if (entry.existingSecret) return true;
switch (entry.type) {
case "basic":
return entry.username.trim().length > 0 && entry.password.length > 0;
case "bearer":
return entry.value.trim().length > 0;
case "custom":
return entry.header.trim().length > 0 && entry.value.trim().length > 0;
}
}
type HeaderAction =
| { type: "add" }
| { type: "remove"; index: number }
| { type: "update"; index: number; updates: Partial<HeaderAuthItem> };
function headersReducer(
state: HeaderAuthItem[],
action: HeaderAction,
): HeaderAuthItem[] {
switch (action.type) {
case "add":
return [...state, createHeaderEntry()];
case "remove":
return state.length === 1
? [createHeaderEntry()]
: state.filter((_, i) => i !== action.index);
case "update":
return state.map((e, i) =>
i === action.index ? { ...e, ...action.updates } : e,
);
}
}
function initHeaders(headers: HeaderAuthConfig[]): HeaderAuthItem[] {
return headers.length > 0
? headers.map(configToHeaderEntry)
: [createHeaderEntry()];
}
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 [items, dispatch] = useReducer(
headersReducer,
currentHeaders,
initHeaders,
);
const isEditing = currentHeaders.length > 0;
const canSave = useMemo(() => items.every(isHeaderValid), [items]);
const { hasChanges } = useHasChanges(items);
const handleSave = () => {
if (!canSave) return;
onOpenChange(false);
onSave(items.map(headerEntryToConfig));
};
const handleRemoveAll = () => {
onOpenChange(false);
onRemove();
};
return (
<Modal open={open} onOpenChange={onOpenChange}>
<ModalContent
maxWidthClass="max-w-xl"
onOpenAutoFocus={(e) => {
e.preventDefault();
const container = e.currentTarget as HTMLElement | null;
container
?.querySelector<HTMLInputElement>("input:not([type=hidden])")
?.focus();
}}
>
<ModalHeader
title="HTTP Headers"
description="Require specific HTTP headers to access this service."
/>
<div className="px-8">
<div className="flex flex-col gap-3">
{items.map((item, index) => (
<HeaderItemRow
key={item.id}
item={item}
index={index}
onChange={(updates) =>
dispatch({ type: "update", index, updates })
}
onRemove={() => dispatch({ type: "remove", index })}
showRemove={items.length > 1}
/>
))}
</div>
<Button
variant="dotted"
className="w-full mt-4"
size="sm"
onClick={() => dispatch({ type: "add" })}
>
<PlusIcon size={14} />
Add Header
</Button>
{items.length > 1 && (
<Callout className="mt-4" variant="info">
Any request matching one of these headers will grant access.
<br />
Matched headers are stripped before reaching your backend.
</Callout>
)}
<div className="flex gap-3 w-full justify-between mt-6">
{isEditing ? (
<>
<Button variant="danger-text" onClick={handleRemoveAll}>
Remove All
</Button>
<div className="flex gap-3">
<ModalClose asChild>
<Button variant="secondary">Cancel</Button>
</ModalClose>
<Button
variant="primary"
onClick={handleSave}
disabled={!canSave || !hasChanges}
>
Save
</Button>
</div>
</>
) : (
<>
<div />
<div className="flex gap-3">
<ModalClose asChild>
<Button variant="secondary">Cancel</Button>
</ModalClose>
<Button
variant="primary"
onClick={handleSave}
disabled={!canSave}
>
Add Headers
</Button>
</div>
</>
)}
</div>
</div>
</ModalContent>
</Modal>
);
}
type HeaderItemRowProps = {
item: HeaderAuthItem;
index: number;
onChange: (updates: Partial<HeaderAuthItem>) => void;
onRemove: () => void;
showRemove: boolean;
};
function HeaderItemRow({
item,
index,
onChange,
onRemove,
showRemove,
}: Readonly<HeaderItemRowProps>) {
const isMaskedRef = useRef(item.existingSecret);
const handleHeaderTypeChange = (value: string) => {
const type = value as HeaderType;
onChange({
type,
header: type === "custom" ? "" : "Authorization",
value: "",
username: "",
password: "",
});
};
return (
<div className="rounded-md border border-nb-gray-900 bg-nb-gray-920/30 overflow-hidden">
<div className="flex flex-col gap-2 px-4 pt-2 pb-4 bg-nb-gray-920/30">
<div className="flex items-center justify-between h-6 mt-0.5">
<span className="text-xs font-normal text-nb-gray-200 flex items-center gap-2">
<FileCode2Icon size={14} />
{item.existingSecret
? `Header ${index + 1} - ${item.header}`
: `Header ${index + 1}`}
</span>
{showRemove && (
<Button variant="danger-text" size="xs" onClick={onRemove}>
<MinusCircleIcon size={12} />
Remove
</Button>
)}
</div>
{item.existingSecret ? (
<div>
<Input
customPrefix={<span className="min-w-[38px]">Value</span>}
type="password"
showPasswordToggle={!isMaskedRef.current}
value={isMaskedRef.current ? MASKED_VALUE : item.value}
placeholder="e.g., AIiaSyDaGmWKa4JsXZ-HjGw7ISLn_3namBGewQe"
{...INPUT_PROPS}
onChange={(e) => {
if (isMaskedRef.current) {
isMaskedRef.current = false;
const nativeEvent = e.nativeEvent as InputEvent;
onChange({ value: nativeEvent.data ?? "" });
return;
}
onChange({ value: e.target.value });
}}
/>
</div>
) : (
<>
<SelectDropdown
value={item.type}
onChange={handleHeaderTypeChange}
options={HEADER_TYPE_OPTIONS}
/>
{item.type === "basic" && (
<div className="flex flex-col gap-2">
<Input
customPrefix={<UserIcon size={16} />}
placeholder="Username"
maxWidthClass="w-full"
value={item.username}
onChange={(e) => onChange({ username: e.target.value })}
{...INPUT_PROPS}
/>
<Input
customPrefix={<KeyRoundIcon size={16} />}
placeholder="Password"
maxWidthClass="w-full"
value={item.password}
onChange={(e) => onChange({ password: e.target.value })}
type="password"
showPasswordToggle
{...INPUT_PROPS}
/>
</div>
)}
{item.type === "bearer" && (
<Input
customPrefix={"Bearer"}
placeholder="e.g. eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
maxWidthClass="w-full"
value={item.value}
onChange={(e) => onChange({ value: e.target.value })}
type="password"
showPasswordToggle
{...INPUT_PROPS}
/>
)}
{item.type === "custom" && (
<div className="flex flex-col gap-2">
<Input
customPrefix={<span className="min-w-[38px]">Name</span>}
placeholder="e.g., X-API-Key"
maxWidthClass="w-full"
value={item.header}
onChange={(e) => onChange({ header: e.target.value })}
{...INPUT_PROPS}
/>
<Input
customPrefix={<span className="min-w-[38px]">Value</span>}
placeholder="e.g., AIiaSyDaGmWKa4JsXZ-HjGw7ISLn_3namBGewQe"
maxWidthClass="w-full"
value={item.value}
onChange={(e) => onChange({ value: e.target.value })}
type="password"
showPasswordToggle
{...INPUT_PROPS}
/>
</div>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -13,6 +13,7 @@ type Props = {
baseDomain: string;
onBaseDomainChange: (value: string) => void;
domainAlreadyExists: boolean;
subdomainRequired?: boolean;
clusterOffline?: {
clusterName: string;
};
@@ -24,13 +25,16 @@ export default function ReverseProxyDomainInput({
baseDomain,
onBaseDomainChange,
domainAlreadyExists,
subdomainRequired = false,
clusterOffline,
}: Readonly<Props>) {
return (
<div>
<Label>Domain</Label>
<HelpText>
Enter a subdomain and select a domain for your service.
{subdomainRequired
? "Enter a subdomain and select a domain for your service."
: "Optionally enter a subdomain, or use the domain directly."}
</HelpText>
<div className="flex items-start mt-2">
<div className="flex-1 min-w-0">
@@ -47,7 +51,7 @@ export default function ReverseProxyDomainInput({
? "This domain is already used by another service."
: undefined
}
placeholder={"myapp"}
placeholder={subdomainRequired ? "myapp" : "myapp (optional)"}
className="!rounded-r-none !border-r-0"
/>
</div>

View File

@@ -23,6 +23,13 @@ function parseDomain(
.filter((d) => d.domain)
.sort((a, b) => b.domain.length - a.domain.length);
for (const d of sorted) {
if (fullDomain === d.domain) {
return {
subdomain: "",
baseDomain: d.domain,
isCustom: d.type === ReverseProxyDomainType.CUSTOM,
};
}
if (fullDomain.endsWith(`.${d.domain}`)) {
return {
subdomain: fullDomain.slice(0, -(d.domain.length + 1)),
@@ -103,7 +110,11 @@ export function useReverseProxyDomain({
return customDomain?.domain || freeDomain?.domain || "";
});
const fullDomain = baseDomain ? `${subdomain}.${baseDomain}` : subdomain;
const fullDomain = baseDomain
? subdomain
? `${subdomain}.${baseDomain}`
: baseDomain
: subdomain;
const domainAlreadyExists = useMemo(() => {
if (!reverseProxies || !fullDomain) return false;

View File

@@ -1,5 +1,16 @@
import Badge from "@components/Badge";
import { Binary, Mail, RectangleEllipsis, Users } from "lucide-react";
import {
Binary,
FileCode2Icon,
Flag,
GlobeOff,
Mail,
Network,
RectangleEllipsis,
ShieldAlert,
ShieldOff,
Users,
} from "lucide-react";
import * as React from "react";
import { ReverseProxyEvent } from "@/interfaces/ReverseProxy";
@@ -33,6 +44,11 @@ export const ReverseProxyEventsAuthMethodCell = ({ event }: Props) => {
icon: <Binary size={12} />,
label: "PIN Code",
};
case "header":
return {
icon: <FileCode2Icon size={12} />,
label: "HTTP Headers",
};
case "link":
case "magic_link":
case "magic-link":
@@ -40,6 +56,41 @@ 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: <Flag size={12} />,
label: "Country Restricted",
};
case "geo_unavailable":
return {
icon: <GlobeOff size={12} />,
label: "Geo Unavailable",
};
case "crowdsec_ban":
return {
icon: <ShieldAlert size={12} />,
label: "CrowdSec Ban",
};
case "crowdsec_captcha":
return {
icon: <ShieldAlert size={12} />,
label: "CrowdSec Captcha",
};
case "crowdsec_throttle":
return {
icon: <ShieldAlert size={12} />,
label: "CrowdSec Throttle",
};
case "crowdsec_unavailable":
return {
icon: <ShieldOff size={12} />,
label: "CrowdSec Unavailable",
};
default:
return {
icon: null,

View File

@@ -19,8 +19,17 @@ export const ReverseProxyEventsLocationIpCell = ({ event }: Props) => {
const { getRegionText, isLoading } = useCountries();
const region = useMemo(() => {
return getRegionText(event.country_code || "", event.city_name || "");
}, [getRegionText, event.country_code, event.city_name]);
return getRegionText(
event.country_code || "",
event.city_name || "",
event.subdivision_code,
);
}, [
getRegionText,
event.country_code,
event.city_name,
event.subdivision_code,
]);
return (
<FullTooltip

View File

@@ -1,11 +1,61 @@
import Badge from "@components/Badge";
import FullTooltip from "@components/FullTooltip";
import { ListItem } from "@components/ListItem";
import { Info, ShieldAlert } from "lucide-react";
import * as React from "react";
import { ReverseProxyEvent } from "@/interfaces/ReverseProxy";
const VERDICT_LABELS: Record<string, string> = {
crowdsec_ban: "Ban",
crowdsec_captcha: "Captcha",
crowdsec_throttle: "Throttle",
};
type Props = {
event: ReverseProxyEvent;
};
export const ReverseProxyEventsReasonCell = ({ event }: Props) => {
const metadata = event.metadata;
const verdict = metadata?.crowdsec_verdict;
if (verdict && !event.auth_method_used?.startsWith("crowdsec_")) {
const verdictLabel = VERDICT_LABELS[verdict] ?? verdict;
const metaEntries = Object.entries(metadata!).filter(
([k]) => k !== "crowdsec_verdict",
);
return (
<FullTooltip
side="top"
interactive
delayDuration={250}
skipDelayDuration={100}
disabled={metaEntries.length === 0}
contentClassName="p-0"
content={
<div className="text-xs flex flex-col">
{metaEntries.map(([key, val]) => (
<ListItem
key={key}
icon={<Info size={14} />}
label={key.replaceAll("_", " ")}
value={<span className="text-nb-gray-200">{val}</span>}
/>
))}
</div>
}
>
<div className="px-3 py-2">
<Badge variant="gray" className="gap-1.5">
<ShieldAlert size={12} className="text-yellow-500" />
CrowdSec Observe: {verdictLabel}
</Badge>
</div>
</FullTooltip>
);
}
return (
<span className="text-nb-gray-300 text-[0.82rem] py-2 text-left">
{event.reason || "-"}

View File

@@ -0,0 +1,252 @@
import Badge from "@components/Badge";
import Button from "@components/Button";
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@components/HoverCard";
import {
FlagIcon,
LucideIcon,
NetworkIcon,
Settings,
ShieldAlert,
ShieldCheck,
ShieldOff,
WorkflowIcon,
} from "lucide-react";
import * as React from "react";
import { useMemo } from "react";
import { useCountries } from "@/contexts/CountryProvider";
import { usePermissions } from "@/contexts/PermissionsProvider";
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
import { CrowdSecMode, ReverseProxy } from "@/interfaces/ReverseProxy";
type RuleEntry = {
key: string;
label: string;
Icon: LucideIcon;
value: string;
blocked?: boolean;
};
type Props = {
reverseProxy: ReverseProxy;
};
export default function ReverseProxyAccessControlCell({
reverseProxy,
}: Readonly<Props>) {
const { permission } = usePermissions();
const { openModal, domains } = useReverseProxies();
const { countries } = useCountries();
const canConfigure = !!permission?.services?.update;
const restrictions = reverseProxy.access_restrictions;
const supportsCrowdSec = domains?.find(
(d) => d.domain === reverseProxy.proxy_cluster,
)?.supports_crowdsec;
const hasCrowdSec =
supportsCrowdSec &&
restrictions?.crowdsec_mode != null &&
restrictions.crowdsec_mode !== CrowdSecMode.OFF;
const ruleCount =
(restrictions?.allowed_cidrs?.length ?? 0) +
(restrictions?.blocked_cidrs?.length ?? 0) +
(restrictions?.allowed_countries?.length ?? 0) +
(restrictions?.blocked_countries?.length ?? 0) +
(hasCrowdSec ? 1 : 0);
const rulesBadge =
ruleCount > 0 ? (
<Badge
variant={"gray"}
disabled={!canConfigure}
className={
"cursor-pointer !rounded-r-none !border-r-0 !h-[34px] min-w-[100px] !justify-start hover:bg-nb-gray-930 transition-all"
}
>
<ShieldCheck size={12} className="text-green-500" />
<span className={"font-medium text-xs"}>
{ruleCount} {ruleCount === 1 ? "Rule" : "Rules"}
</span>
</Badge>
) : null;
const ruleGroups = useMemo(() => {
const getCountryName = (code: string) => {
const country = countries?.find((c) => c.country_code === code);
return country?.country_name ?? code;
};
const entries: RuleEntry[] = [];
if (restrictions?.allowed_countries?.length) {
entries.push({
key: "allowed-countries",
label: "Allowed Countries",
Icon: FlagIcon,
value: restrictions.allowed_countries.map(getCountryName).join(", "),
});
}
if (restrictions?.blocked_countries?.length) {
entries.push({
key: "blocked-countries",
label: "Blocked Countries",
Icon: FlagIcon,
value: restrictions.blocked_countries.map(getCountryName).join(", "),
blocked: true,
});
}
const isHostCidr = (c: string) =>
c.includes(":") ? c.endsWith("/128") : c.endsWith("/32");
const allowedIps =
restrictions?.allowed_cidrs?.filter(isHostCidr) ?? [];
const allowedCidrs =
restrictions?.allowed_cidrs?.filter((c) => !isHostCidr(c)) ?? [];
const blockedIps =
restrictions?.blocked_cidrs?.filter(isHostCidr) ?? [];
const blockedCidrs =
restrictions?.blocked_cidrs?.filter((c) => !isHostCidr(c)) ?? [];
if (allowedIps.length) {
entries.push({
key: "allowed-ips",
label: allowedIps.length === 1 ? "Allowed IP" : "Allowed IPs",
Icon: WorkflowIcon,
value: allowedIps.map((c) => c.replace(/\/(32|128)$/, "")).join(", "),
});
}
if (allowedCidrs.length) {
entries.push({
key: "allowed-cidrs",
label: allowedCidrs.length === 1 ? "Allowed CIDR" : "Allowed CIDRs",
Icon: NetworkIcon,
value: allowedCidrs.join(", "),
});
}
if (blockedIps.length) {
entries.push({
key: "blocked-ips",
label: blockedIps.length === 1 ? "Blocked IP" : "Blocked IPs",
Icon: WorkflowIcon,
value: blockedIps.map((c) => c.replace(/\/(32|128)$/, "")).join(", "),
blocked: true,
});
}
if (blockedCidrs.length) {
entries.push({
key: "blocked-cidrs",
label: blockedCidrs.length === 1 ? "Blocked CIDR" : "Blocked CIDRs",
Icon: NetworkIcon,
value: blockedCidrs.join(", "),
blocked: true,
});
}
if (hasCrowdSec) {
entries.push({
key: "crowdsec",
label: "CrowdSec",
Icon: ShieldAlert,
value:
restrictions?.crowdsec_mode === CrowdSecMode.ENFORCE
? "Enforce"
: "Observe",
});
}
return entries;
}, [restrictions, countries, hasCrowdSec]);
const showRulesHover = ruleGroups.length > 0;
return (
<div
className={"flex"}
onClick={(e) => {
e.stopPropagation();
if (permission?.services?.update) {
openModal({ proxy: reverseProxy, initialTab: "access-control" });
}
}}
>
<div className={"flex items-center"}>
{rulesBadge ? (
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild={true}>{rulesBadge}</HoverCardTrigger>
{showRulesHover && (
<HoverCardContent
className={"p-0"}
sideOffset={14}
onClick={(e) => e.stopPropagation()}
>
<div className={"text-xs"}>
{ruleGroups.map(({ key, label, Icon, value, blocked }) => (
<div
key={key}
className={
"flex justify-between gap-12 py-2 px-4 border-b border-nb-gray-920 last:border-b-0"
}
>
<div
className={
"flex items-start gap-2 font-medium whitespace-nowrap text-nb-gray-100 pt-0.5"
}
>
<Icon
size={14}
className={
blocked ? "text-red-500" : "text-green-500"
}
/>
{label}
</div>
<div
className={"max-w-[200px] text-nb-gray-300 text-right"}
>
{value}
</div>
</div>
))}
</div>
</HoverCardContent>
)}
</HoverCard>
) : (
<Badge
variant={"gray"}
disabled={!canConfigure}
className={
"cursor-pointer !rounded-r-none !border-r-0 !h-[34px] min-w-[100px] !justify-start hover:bg-nb-gray-930 transition-all"
}
>
<ShieldOff size={12} className="text-red-500" />
<span className={"font-medium text-xs"}>No Rules</span>
</Badge>
)}
<Button
size={"xs"}
variant={"secondary"}
className={"!rounded-l-none !px-3 !h-[34px]"}
onClick={(e) => {
e.stopPropagation();
openModal({ proxy: reverseProxy, initialTab: "access-control" });
}}
disabled={!permission?.services?.update}
aria-label="Configure access control"
>
<Settings size={12} />
</Button>
</div>
</div>
);
}

View File

@@ -37,6 +37,7 @@ export default function ReverseProxyActionCell({
</DropdownMenuTrigger>
<DropdownMenuContent className="w-auto" align="end">
<DropdownMenuItem
data-proxy-edit-action={reverseProxy.id}
onClick={(e) => {
e.stopPropagation();
openModal({ proxy: reverseProxy });

View File

@@ -15,7 +15,7 @@ export default function ReverseProxyActiveCell({
const { handleToggle } = useReverseProxies();
return (
<div className={"flex min-w-[0px]"}>
<div className={"flex min-w-[0px]"} data-active-cell>
<ToggleSwitch
disabled={!permission?.services?.update}
checked={reverseProxy.enabled}

View File

@@ -11,12 +11,13 @@ import { UserCountStack } from "@components/ui/MultipleGroups";
import {
ArrowRightIcon,
Binary,
FileCode2Icon,
HelpCircle,
LockKeyhole,
LockOpenIcon,
LucideIcon,
RectangleEllipsis,
Settings,
ShieldCheck,
ShieldOff,
Users,
} from "lucide-react";
import * as React from "react";
@@ -48,6 +49,12 @@ const AUTH_METHODS: {
},
];
const HEADER_AUTH_METHOD = {
label: "HTTP Headers",
hoverLabel: "HTTP Headers",
Icon: FileCode2Icon,
};
type Props = {
reverseProxy: ReverseProxy;
};
@@ -59,7 +66,6 @@ export default function ReverseProxyAuthCell({
const { openModal } = useReverseProxies();
const { groups } = useGroups();
// L4 services don't support auth
if (isL4Mode(reverseProxy.mode)) {
return (
<div className={"flex"}>
@@ -83,6 +89,8 @@ export default function ReverseProxyAuthCell({
const auth = reverseProxy.auth;
const enabled = AUTH_METHODS.filter((m) => auth?.[m.key]?.enabled);
const hasHeaderAuths = (auth?.header_auths ?? []).some((h) => h.enabled);
const authCount = enabled.length + (hasHeaderAuths ? 1 : 0);
const ssoGroups = auth?.bearer_auth?.enabled
? (auth.bearer_auth.distribution_groups ?? [])
@@ -90,103 +98,149 @@ export default function ReverseProxyAuthCell({
.filter((g): g is Group => g != undefined)
: [];
const showHoverContent =
enabled.length > 1 || (enabled.length === 1 && auth?.bearer_auth?.enabled);
const canConfigure = !!permission?.services?.update;
const singleAuth =
authCount === 1
? enabled.length === 1
? enabled[0]
: HEADER_AUTH_METHOD
: null;
const SingleAuthIcon = singleAuth?.Icon ?? null;
const SingleIcon = enabled.length === 1 ? 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>
</>
const authBadge = SingleAuthIcon ? (
<Badge
variant={"gray"}
useHover={false}
disabled={!canConfigure}
className={
"cursor-pointer !rounded-r-none !border-r-0 !h-[34px] min-w-[100px] !justify-start hover:bg-nb-gray-930 transition-all"
}
>
<SingleAuthIcon size={12} className="text-green-500" />
<span className={"font-medium text-xs"}>{singleAuth!.label}</span>
</Badge>
) : authCount > 1 ? (
<Badge
variant={"gray"}
useHover={false}
disabled={!canConfigure}
className={
"cursor-pointer !rounded-r-none !border-r-0 !h-[34px] min-w-[100px] !justify-start hover:bg-nb-gray-930 transition-all"
}
>
<LockKeyhole size={12} className="text-green-500" />
<span className={"font-medium text-xs"}>{authCount} Enabled</span>
</Badge>
) : null;
const showAuthHover =
authCount > 1 ||
(authCount === 1 && (auth?.bearer_auth?.enabled || hasHeaderAuths));
return (
<div
className={"flex gap-3"}
className={"flex"}
data-auth-cell
onClick={(e) => {
e.stopPropagation();
openModal({ proxy: reverseProxy, initialTab: "auth" });
if (permission?.services?.update) {
openModal({ proxy: reverseProxy, initialTab: "auth" });
}
}}
>
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild={true}>
{badgeContent ? (
<Badge
variant={"gray"}
useHover={false}
className={"cursor-pointer"}
>
{badgeContent}
</Badge>
) : (
<Badge variant={"gray"}>
<ShieldOff size={12} className="text-red-500" />
<span className={"font-medium text-xs"}>None</span>
</Badge>
)}
</HoverCardTrigger>
{showHoverContent && (
<HoverCardContent
className={"p-0"}
sideOffset={14}
onClick={(e) => e.stopPropagation()}
>
<div className={"text-xs"}>
{enabled.map(({ key, hoverLabel, Icon }) => (
<ListItem
key={key}
className={"py-0.5"}
icon={<Icon size={14} />}
label={hoverLabel}
value={
<div className={"text-green-500"}>
{key === "bearer_auth" && ssoGroups.length === 0
? "All Users"
: "Enabled"}
</div>
}
>
{key === "bearer_auth" && ssoGroups.length > 0 && (
<div className={"flex flex-col gap-2 px-4 pt-2 pb-3"}>
{ssoGroups.map((group) => (
<div
key={group.id}
className={"flex gap-2 items-center justify-between"}
>
<GroupBadge group={group} />
<ArrowRightIcon size={14} />
<UserCountStack group={group} />
<div className={"flex items-center"}>
{authBadge ? (
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild={true}>{authBadge}</HoverCardTrigger>
{showAuthHover && (
<HoverCardContent
className={"p-0"}
sideOffset={14}
onClick={(e) => e.stopPropagation()}
>
<div className={"text-xs"}>
{enabled.map(({ key, hoverLabel, Icon }) => (
<ListItem
key={key}
className={"py-0.5"}
icon={<Icon size={14} />}
label={hoverLabel}
value={
<div className={"text-green-500"}>
{key === "bearer_auth" && ssoGroups.length === 0
? "All Users"
: "Enabled"}
</div>
))}
</div>
}
>
{key === "bearer_auth" && ssoGroups.length > 0 && (
<div className={"flex flex-col gap-2 px-4 pt-2 pb-3"}>
{ssoGroups.map((group) => (
<div
key={group.id}
className={
"flex gap-2 items-center justify-between"
}
>
<GroupBadge group={group} />
<ArrowRightIcon size={14} />
<UserCountStack group={group} />
</div>
))}
</div>
)}
</ListItem>
))}
{hasHeaderAuths && (
<ListItem
className={"py-0.5"}
icon={<FileCode2Icon size={14} />}
label={HEADER_AUTH_METHOD.hoverLabel}
value={
<div className={"text-green-500"}>
{
(auth?.header_auths ?? []).filter((h) => h.enabled)
.length
}{" "}
Header
{(auth?.header_auths ?? []).filter((h) => h.enabled)
.length !== 1
? "s"
: ""}
</div>
}
/>
)}
</ListItem>
))}
</div>
</HoverCardContent>
</div>
</HoverCardContent>
)}
</HoverCard>
) : (
<Badge
variant={"gray"}
disabled={!canConfigure}
className={
"cursor-pointer !rounded-r-none !border-r-0 !h-[34px] min-w-[100px] !justify-start hover:bg-nb-gray-930 transition-all"
}
>
<LockOpenIcon size={12} className="text-red-500" />
<span className={"font-medium text-xs"}>No Auth</span>
</Badge>
)}
</HoverCard>
<Button
size={"xs"}
variant={"secondary"}
onClick={(e) => {
e.stopPropagation();
openModal({ proxy: reverseProxy, initialTab: "auth" });
}}
className={"!px-3"}
disabled={!permission?.services?.update}
>
<Settings size={12} />
Configure
</Button>
<Button
size={"xs"}
variant={"secondary"}
className={"!rounded-l-none !px-3 !h-[34px]"}
onClick={(e) => {
e.stopPropagation();
openModal({ proxy: reverseProxy, initialTab: "auth" });
}}
disabled={!permission?.services?.update}
aria-label="Configure authentication"
>
<Settings size={12} />
</Button>
</div>
</div>
);
}

View File

@@ -31,7 +31,7 @@ export default function ReverseProxyClusterCell({
if (!hasCluster) {
return (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2" data-cluster-cell>
<Badge variant="gray" className="font-normal">
<Globe size={12} />
All
@@ -42,7 +42,7 @@ export default function ReverseProxyClusterCell({
if (isConnected) {
return (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2" data-cluster-cell>
<Badge variant={"gray"} className={cn("font-normal")}>
<Server size={11} className={cn("text-green-500")} />
{reverseProxy.proxy_cluster}
@@ -76,7 +76,7 @@ export default function ReverseProxyClusterCell({
align={"center"}
alignOffset={0}
>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2" data-cluster-cell>
<Badge variant={"red"} className={cn("font-normal")}>
<AlertTriangle size={11} />
{reverseProxy.proxy_cluster}

View File

@@ -36,6 +36,7 @@ export default function ReverseProxyNameCell({
? "gap-6 min-w-[270px] max-w-[270px]"
: "gap-2.5 min-w-[200px]",
)}
data-name-cell
>
{showChevron && (
<>

View File

@@ -63,56 +63,60 @@ export default function ReverseProxyStatusCell({
if (isActive) return null;
if (hasError) {
return (
<FullTooltip
content={
<div className={"text-xs max-w-xs"}>
Something went wrong while setting up this service. See our{" "}
<InlineLink
href={REVERSE_PROXY_TROUBLESHOOTING_DOCS_LINK}
target={"_blank"}
>
Troubleshooting Docs
</InlineLink>{" "}
for more details.
<div className={"flex"} data-status-cell>
<FullTooltip
content={
<div className={"text-xs max-w-xs"}>
Something went wrong while setting up this service. See our{" "}
<InlineLink
href={REVERSE_PROXY_TROUBLESHOOTING_DOCS_LINK}
target={"_blank"}
>
Troubleshooting Docs
</InlineLink>{" "}
for more details.
</div>
}
align={"center"}
alignOffset={0}
>
<div className={"flex"}>
<Badge variant={"red"}>
<CircleAlert size={11} />
Error
</Badge>
</div>
}
align={"center"}
alignOffset={0}
>
<div className={"flex"}>
<Badge variant={"red"}>
<CircleAlert size={11} />
Error
</Badge>
</div>
</FullTooltip>
</FullTooltip>
</div>
);
}
if (isTunnelNotCreated) {
return (
<FullTooltip
content={
<div className={"text-xs max-w-xs"}>
The tunnel to the target peer could not be established. See our{" "}
<InlineLink
href={REVERSE_PROXY_TROUBLESHOOTING_DOCS_LINK}
target={"_blank"}
>
Troubleshooting Docs
</InlineLink>{" "}
for more details.
<div className={"flex"} data-status-cell>
<FullTooltip
content={
<div className={"text-xs max-w-xs"}>
The tunnel to the target peer could not be established. See our{" "}
<InlineLink
href={REVERSE_PROXY_TROUBLESHOOTING_DOCS_LINK}
target={"_blank"}
>
Troubleshooting Docs
</InlineLink>{" "}
for more details.
</div>
}
align={"center"}
alignOffset={0}
>
<div className={"flex"}>
<Badge variant={"red"}>
<CircleAlert size={11} />
Tunnel not created
</Badge>
</div>
}
align={"center"}
alignOffset={0}
>
<div className={"flex"}>
<Badge variant={"red"}>
<CircleAlert size={11} />
Tunnel not created
</Badge>
</div>
</FullTooltip>
</FullTooltip>
</div>
);
}
return <SettingUpService />;
@@ -120,12 +124,12 @@ export default function ReverseProxyStatusCell({
// HTTP services: hide once active with certificate issued
if (isActive && certificateIssued) {
return null;
return <div data-status-cell />;
}
if (!certificateIssued) {
return (
<div className={"flex"}>
<div className={"flex"} data-status-cell>
<Badge variant={"yellow"}>
<Loader2 size={12} className={"animate-spin"} />
Issuing certificate...
@@ -139,7 +143,7 @@ export default function ReverseProxyStatusCell({
const SettingUpService = () => {
return (
<div className={"flex"}>
<div className={"flex"} data-status-cell>
<Badge variant={"yellow"}>
<Loader2 size={14} className={"animate-spin"} />
Setting up service...

View File

@@ -23,6 +23,7 @@ import {
} from "@/interfaces/ReverseProxy";
import ReverseProxyActionCell from "@/modules/reverse-proxy/table/ReverseProxyActionCell";
import ReverseProxyActiveCell from "@/modules/reverse-proxy/table/ReverseProxyActiveCell";
import ReverseProxyAccessControlCell from "@/modules/reverse-proxy/table/ReverseProxyAccessControlCell";
import ReverseProxyAuthCell from "@/modules/reverse-proxy/table/ReverseProxyAuthCell";
import ReverseProxyClusterCell from "@/modules/reverse-proxy/table/ReverseProxyClusterCell";
import ReverseProxyNameCell from "@/modules/reverse-proxy/table/ReverseProxyNameCell";
@@ -90,6 +91,17 @@ const ReverseProxyColumns: ColumnDef<ReverseProxy>[] = [
},
cell: ({ row }) => <ReverseProxyAuthCell reverseProxy={row.original} />,
},
{
id: "access_rules",
header: ({ column }) => {
return (
<DataTableHeader column={column}>Access Control</DataTableHeader>
);
},
cell: ({ row }) => (
<ReverseProxyAccessControlCell reverseProxy={row.original} />
),
},
{
accessorKey: "id",
header: "",

View File

@@ -36,7 +36,7 @@ export default function ReverseProxyTargetsCell({
const targetsCount = reverseProxy?.targets?.length ?? 0;
return (
<div className={"flex gap-3"}>
<div className={"flex gap-3"} data-targets-cell>
{targetsCount > 0 && (
<Badge
variant={"gray"}

View File

@@ -13,9 +13,9 @@ type Props = {
export const ReverseProxyTypeCell = ({ reverseProxy }: Props) => {
const serviceModeLabel = useMemo(() => {
if (!reverseProxy?.mode) return "HTTP/S";
if (!reverseProxy?.mode) return "HTTPS";
const mode = SERVICE_MODES[reverseProxy.mode];
if (!mode) return "HTTP/S";
if (!mode) return "HTTPS";
return trim(mode.label.replace("Service", ""));
}, [reverseProxy]);

View File

@@ -461,7 +461,7 @@ export default function ReverseProxyTargetModal({
<Label>Request Timeout</Label>
<HelpText className={"mb-0"}>
Max time to wait for a response as duration string
(max 5m). <br /> Leave this field empty for no
(e.g. 30s, 2m). <br /> Leave this field empty for no
timeout.
</HelpText>
</div>
@@ -487,7 +487,7 @@ export default function ReverseProxyTargetModal({
<Label>Session Idle Timeout</Label>
<HelpText className={"mb-0"}>
How long a UDP session stays alive without traffic
(max 10m). <br /> Defaults to 30s when empty.
(e.g., 30s, 2m). <br /> Defaults to 30s when empty.
</HelpText>
</div>
<Input

View File

@@ -38,6 +38,7 @@ export default function ReverseProxyFlatTargetActionCell({
</DropdownMenuTrigger>
<DropdownMenuContent className="w-auto" align="end">
<DropdownMenuItem
data-proxy-edit-action={target.proxy.id}
onClick={(e) => {
e.stopPropagation();
if (isL4Mode(target.proxy.mode)) {
@@ -55,6 +56,7 @@ export default function ReverseProxyFlatTargetActionCell({
</DropdownMenuItem>
<DropdownMenuItem
data-proxy-settings-action={target.proxy.id}
onClick={(e) => {
e.stopPropagation();
openModal({ proxy: target.proxy, initialTab: "settings" });

View File

@@ -14,6 +14,7 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
import { ReverseProxyFlatTarget } from "@/interfaces/ReverseProxy";
import ReverseProxyArrowCell from "@/modules/reverse-proxy/table/ReverseProxyArrowCell";
import ReverseProxyAccessControlCell from "@/modules/reverse-proxy/table/ReverseProxyAccessControlCell";
import ReverseProxyAuthCell from "@/modules/reverse-proxy/table/ReverseProxyAuthCell";
import ReverseProxyClusterCell from "@/modules/reverse-proxy/table/ReverseProxyClusterCell";
import ReverseProxyDestinationCell from "@/modules/reverse-proxy/table/ReverseProxyDestinationCell";
@@ -48,13 +49,19 @@ const FlatTargetsTableColumns: ColumnDef<ReverseProxyFlatTarget>[] = [
const isEnabled = target.proxy.enabled && target.enabled;
return (
<div className={disabled ? "opacity-40" : ""}>
<ReverseProxyNameCell
domain={fullUrl}
enabled={isEnabled}
reverseProxy={row.original.proxy}
showChevron={false}
/>
<div className="flex items-center gap-2">
<div
className={disabled ? "opacity-40" : ""}
data-proxy-id={target.proxy.id}
>
<ReverseProxyNameCell
domain={fullUrl}
enabled={isEnabled}
reverseProxy={row.original.proxy}
showChevron={false}
/>
</div>
<div data-status-cell />
</div>
);
},
@@ -63,7 +70,9 @@ const FlatTargetsTableColumns: ColumnDef<ReverseProxyFlatTarget>[] = [
accessorKey: "arrow",
header: "",
cell: ({ row }) => (
<ReverseProxyArrowCell disabled={!row.original.enabled} />
<div data-proxy-id={row.original.proxy.id}>
<ReverseProxyArrowCell disabled={row.original.enabled === false} />
</div>
),
},
{
@@ -71,7 +80,11 @@ const FlatTargetsTableColumns: ColumnDef<ReverseProxyFlatTarget>[] = [
header: ({ column }) => (
<DataTableHeader column={column}>Destination</DataTableHeader>
),
cell: ({ row }) => <ReverseProxyDestinationCell target={row.original} />,
cell: ({ row }) => (
<div data-proxy-id={row.original.proxy.id}>
<ReverseProxyDestinationCell target={row.original} />
</div>
),
},
{
accessorKey: "enabled",
@@ -79,9 +92,11 @@ const FlatTargetsTableColumns: ColumnDef<ReverseProxyFlatTarget>[] = [
<DataTableHeader column={column}>Active</DataTableHeader>
),
cell: ({ row }) => (
<ReverseProxyTargetProvider value={row.original.proxy}>
<ReverseProxyTargetActiveCell target={row.original} />
</ReverseProxyTargetProvider>
<div data-proxy-id={row.original.proxy.id}>
<ReverseProxyTargetProvider value={row.original.proxy}>
<ReverseProxyTargetActiveCell target={row.original} />
</ReverseProxyTargetProvider>
</div>
),
},
{
@@ -90,7 +105,9 @@ const FlatTargetsTableColumns: ColumnDef<ReverseProxyFlatTarget>[] = [
<DataTableHeader column={column}>Cluster</DataTableHeader>
),
cell: ({ row }) => (
<ReverseProxyClusterCell reverseProxy={row.original.proxy} />
<div data-proxy-id={row.original.proxy.id}>
<ReverseProxyClusterCell reverseProxy={row.original.proxy} />
</div>
),
},
{
@@ -99,7 +116,9 @@ const FlatTargetsTableColumns: ColumnDef<ReverseProxyFlatTarget>[] = [
<DataTableHeader column={column}>Resource</DataTableHeader>
),
cell: ({ row }) => (
<ReverseProxyTargetDevice target={row.original} showDescription />
<div data-proxy-id={row.original.proxy.id}>
<ReverseProxyTargetDevice target={row.original} showDescription />
</div>
),
},
{
@@ -108,7 +127,20 @@ const FlatTargetsTableColumns: ColumnDef<ReverseProxyFlatTarget>[] = [
<DataTableHeader column={column}>Auth Methods</DataTableHeader>
),
cell: ({ row }) => (
<ReverseProxyAuthCell reverseProxy={row.original.proxy} />
<div data-proxy-id={row.original.proxy.id}>
<ReverseProxyAuthCell reverseProxy={row.original.proxy} />
</div>
),
},
{
id: "access_control",
header: ({ column }) => (
<DataTableHeader column={column}>Access Control</DataTableHeader>
),
cell: ({ row }) => (
<div data-proxy-id={row.original.proxy.id}>
<ReverseProxyAccessControlCell reverseProxy={row.original.proxy} />
</div>
),
},
{

View File

@@ -7,43 +7,20 @@ import {
// Go time.ParseDuration format: one or more {number}{unit} pairs
const DURATION_RE = /^(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))+$/;
const MAX_TIMEOUT_MS = 5 * 60 * 1000; // 5m
const MAX_SESSION_IDLE_TIMEOUT_MS = 10 * 60 * 1000; // 10m
function parseDurationMs(duration: string): number {
const units: Record<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 (!DURATION_RE.test(timeout))
return 'Invalid duration, use e.g., "10s", "30s", "1m"';
if (parseDurationMs(timeout) > MAX_TIMEOUT_MS)
return "Timeout cannot exceed the maximum of 5m.";
return undefined;
}
export function validateSessionIdleTimeout(timeout: string): string | undefined {
export function validateSessionIdleTimeout(
timeout: string,
): string | undefined {
if (!timeout) return undefined;
if (!DURATION_RE.test(timeout))
return 'Invalid duration, use e.g., "30s", "2m", "5m"';
if (parseDurationMs(timeout) > MAX_SESSION_IDLE_TIMEOUT_MS)
return "Session idle timeout cannot exceed the maximum of 10m.";
return undefined;
}

View File

@@ -26,9 +26,8 @@ export default function DangerZoneTab({ account }: Props) {
const deletePromise = new Promise<void>((resolve, reject) => {
return deleteRequest
.del()
.catch((error) => reject(error))
.then(() => {
// Clear browser storage after account deletion
// Clear browser storage only after confirmed account deletion
if (typeof window !== "undefined") {
localStorage.clear();
sessionStorage.clear();
@@ -37,7 +36,8 @@ export default function DangerZoneTab({ account }: Props) {
}
logout().then();
resolve();
});
})
.catch((error) => reject(error));
});
notify({

View File

@@ -6,13 +6,22 @@ type Props = {
name?: string;
email?: string;
id?: string;
size?: "default" | "sm";
className?: string;
};
export const SmallUserAvatar = ({ name, id, email, className }: Props) => {
export const SmallUserAvatar = ({
name,
id,
email,
size = "default",
className,
}: Props) => {
return (
<div
className={cn(
"w-7 h-7 rounded-full shrink-0 flex items-center justify-center text-white uppercase text-[12px] font-medium bg-nb-gray-850",
"rounded-full shrink-0 flex items-center justify-center text-white uppercase font-medium bg-nb-gray-850",
size === "default" && "w-7 h-7 text-[12px]",
size === "sm" && "w-5 h-5 text-[9px] leading-[0]",
className,
)}
style={{

View File

@@ -18,7 +18,9 @@ export const getInstallUrl = () => {
export const isNetBirdHosted = () => {
const hostname = window.location.hostname;
if (hostname.includes("selfhosted")) return false;
return hostname.endsWith(".netbird.io") || hostname.endsWith(".wiretrustee.com");
return (
hostname.endsWith(".netbird.io") || hostname.endsWith(".wiretrustee.com")
);
};
export const isLocalDev = () => {