Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9ac1a1a23 | ||
|
|
b53802a5c5 | ||
|
|
9addc18956 | ||
|
|
9701e6503b | ||
|
|
0841caecbb | ||
|
|
c7846760d1 | ||
|
|
8c283b6ef9 | ||
|
|
34ae3b4da6 | ||
|
|
aff2365ef7 | ||
|
|
bad057d415 | ||
|
|
4d846e2c94 | ||
|
|
15fb6e0b05 | ||
|
|
55c5525626 | ||
|
|
c0c1f4688e | ||
|
|
b5a8f751ba | ||
|
|
10a8e7b745 | ||
|
|
60e8394010 | ||
|
|
9420214059 | ||
|
|
b949f60afe | ||
|
|
d498e4cc25 | ||
|
|
130dc0c32c | ||
|
|
f5824d6ddb | ||
|
|
829395f908 |
12
.github/pull_request_template.md
vendored
Normal file
12
.github/pull_request_template.md
vendored
Normal 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
105
.github/workflows/docs-ack.yml
vendored
Normal 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 ✅"
|
||||
190
package-lock.json
generated
190
package-lock.json
generated
@@ -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.6",
|
||||
"lucide-react": "^0.566.0",
|
||||
"next": "16.1.7",
|
||||
"next-themes": "^0.2.1",
|
||||
"punycode": "^2.3.1",
|
||||
"react": "^19.2.4",
|
||||
@@ -1213,9 +1213,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.6.tgz",
|
||||
"integrity": "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.7.tgz",
|
||||
"integrity": "sha512-rJJbIdJB/RQr2F1nylZr/PJzamvNNhfr3brdKP6s/GW850jbtR70QlSfFselvIBbcPUOlQwBakexjFzqLzF6pg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
@@ -1229,9 +1229,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.6.tgz",
|
||||
"integrity": "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.7.tgz",
|
||||
"integrity": "sha512-b2wWIE8sABdyafc4IM8r5Y/dS6kD80JRtOGrUiKTsACFQfWWgUQ2NwoUX1yjFMXVsAwcQeNpnucF2ZrujsBBPg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1245,9 +1245,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.6.tgz",
|
||||
"integrity": "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.7.tgz",
|
||||
"integrity": "sha512-zcnVaaZulS1WL0Ss38R5Q6D2gz7MtBu8GZLPfK+73D/hp4GFMrC2sudLky1QibfV7h6RJBJs/gOFvYP0X7UVlQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1261,9 +1261,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.6.tgz",
|
||||
"integrity": "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.7.tgz",
|
||||
"integrity": "sha512-2ant89Lux/Q3VyC8vNVg7uBaFVP9SwoK2jJOOR0L8TQnX8CAYnh4uctAScy2Hwj2dgjVHqHLORQZJ2wH6VxhSQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1277,9 +1277,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.6.tgz",
|
||||
"integrity": "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.7.tgz",
|
||||
"integrity": "sha512-uufcze7LYv0FQg9GnNeZ3/whYfo+1Q3HnQpm16o6Uyi0OVzLlk2ZWoY7j07KADZFY8qwDbsmFnMQP3p3+Ftprw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1293,9 +1293,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.6.tgz",
|
||||
"integrity": "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.7.tgz",
|
||||
"integrity": "sha512-KWVf2gxYvHtvuT+c4MBOGxuse5TD7DsMFYSxVxRBnOzok/xryNeQSjXgxSv9QpIVlaGzEn/pIuI6Koosx8CGWA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1309,9 +1309,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.6.tgz",
|
||||
"integrity": "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.7.tgz",
|
||||
"integrity": "sha512-HguhaGwsGr1YAGs68uRKc4aGWxLET+NevJskOcCAwXbwj0fYX0RgZW2gsOCzr9S11CSQPIkxmoSbuVaBp4Z3dA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1325,9 +1325,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.6.tgz",
|
||||
"integrity": "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.7.tgz",
|
||||
"integrity": "sha512-S0n3KrDJokKTeFyM/vGGGR8+pCmXYrjNTk2ZozOL1C/JFdfUIL9O1ATaJOl5r2POe56iRChbsszrjMAdWSv7kQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1341,9 +1341,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.6.tgz",
|
||||
"integrity": "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.7.tgz",
|
||||
"integrity": "sha512-mwgtg8CNZGYm06LeEd+bNnOUfwOyNem/rOiP14Lsz+AnUY92Zq/LXwtebtUiaeVkhbroRCQ0c8GlR4UT1U+0yg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -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.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"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",
|
||||
@@ -3665,9 +3639,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
|
||||
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
@@ -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",
|
||||
@@ -5768,9 +5741,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/flatted": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
|
||||
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
|
||||
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/for-each": {
|
||||
@@ -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.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"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": {
|
||||
@@ -7067,14 +7043,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "16.1.6",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.1.6.tgz",
|
||||
"integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==",
|
||||
"version": "16.1.7",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-16.1.7.tgz",
|
||||
"integrity": "sha512-WM0L7WrSvKwoLegLYr6V+mz+RIofqQgVAfHhMp9a88ms0cFX8iX9ew+snpWlSBwpkURJOUdvCEt3uLl3NNzvWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "16.1.6",
|
||||
"@next/env": "16.1.7",
|
||||
"@swc/helpers": "0.5.15",
|
||||
"baseline-browser-mapping": "^2.8.3",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
"postcss": "8.4.31",
|
||||
"styled-jsx": "5.1.6"
|
||||
@@ -7086,14 +7062,14 @@
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "16.1.6",
|
||||
"@next/swc-darwin-x64": "16.1.6",
|
||||
"@next/swc-linux-arm64-gnu": "16.1.6",
|
||||
"@next/swc-linux-arm64-musl": "16.1.6",
|
||||
"@next/swc-linux-x64-gnu": "16.1.6",
|
||||
"@next/swc-linux-x64-musl": "16.1.6",
|
||||
"@next/swc-win32-arm64-msvc": "16.1.6",
|
||||
"@next/swc-win32-x64-msvc": "16.1.6",
|
||||
"@next/swc-darwin-arm64": "16.1.7",
|
||||
"@next/swc-darwin-x64": "16.1.7",
|
||||
"@next/swc-linux-arm64-gnu": "16.1.7",
|
||||
"@next/swc-linux-arm64-musl": "16.1.7",
|
||||
"@next/swc-linux-x64-gnu": "16.1.7",
|
||||
"@next/swc-linux-x64-musl": "16.1.7",
|
||||
"@next/swc-win32-arm64-msvc": "16.1.7",
|
||||
"@next/swc-win32-x64-msvc": "16.1.7",
|
||||
"sharp": "^0.34.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -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": {
|
||||
|
||||
@@ -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.6",
|
||||
"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",
|
||||
|
||||
@@ -25,6 +25,8 @@ export default function ProxyEventsPage() {
|
||||
() => ({
|
||||
start_date: dayjs().subtract(7, "day").startOf("day").toISOString(),
|
||||
end_date: dayjs().endOf("day").toISOString(),
|
||||
sort_by: "timestamp",
|
||||
sort_order: "desc",
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
} from "@components/DropdownMenu";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||
import useRedirect from "@hooks/useRedirect";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { cn, singularize } from "@utils/helpers";
|
||||
@@ -35,6 +34,7 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Network, NetworkResource, NetworkRouter } from "@/interfaces/Network";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import { NetworkInformationSquare } from "@/modules/networks/misc/NetworkInformationSquare";
|
||||
import { NetworkAccessControlProvider } from "@/modules/networks/NetworkAccessControlProvider";
|
||||
import {
|
||||
NetworkProvider,
|
||||
useNetworksContext,
|
||||
@@ -49,6 +49,7 @@ import ReverseProxiesProvider, {
|
||||
flattenReverseProxies,
|
||||
useReverseProxies,
|
||||
} from "@/contexts/ReverseProxiesProvider";
|
||||
import { SkeletonNetwork } from "@components/skeletons/SkeletonNetwork";
|
||||
|
||||
export default function NetworkDetailPage() {
|
||||
const queryParameter = useSearchParams();
|
||||
@@ -65,7 +66,7 @@ export default function NetworkDetailPage() {
|
||||
<NetworkOverview network={network} />
|
||||
</ReverseProxiesProvider>
|
||||
) : (
|
||||
<FullScreenLoading />
|
||||
<SkeletonNetwork />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -96,103 +97,103 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) {
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NetworkProvider network={network}>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/networks"}
|
||||
label={"Networks"}
|
||||
disabled={!permission.networks.read}
|
||||
icon={<NetworkRoutesIcon size={13} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/network"}
|
||||
label={network.name}
|
||||
active={true}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<NetworkAccessControlProvider>
|
||||
<NetworkProvider network={network}>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/networks"}
|
||||
label={"Networks"}
|
||||
disabled={!permission.networks.read}
|
||||
icon={<NetworkRoutesIcon size={13} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/network"}
|
||||
label={network.name}
|
||||
active={true}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
|
||||
<div className={"flex justify-between max-w-6xl"}>
|
||||
<div
|
||||
className={"w-full lg:w-1/2 flex justify-between items-center"}
|
||||
>
|
||||
<div className={"flex justify-between max-w-6xl"}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center w-full",
|
||||
!network.description && "gap-2",
|
||||
)}
|
||||
className={"w-full lg:w-1/2 flex justify-between items-center"}
|
||||
>
|
||||
<NetworkInformationSquare
|
||||
name={network.name}
|
||||
active={isActive}
|
||||
size={"lg"}
|
||||
description={network.description}
|
||||
/>
|
||||
</div>
|
||||
<NetworkProvider network={network}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center w-full",
|
||||
!network.description && "gap-2",
|
||||
)}
|
||||
>
|
||||
<NetworkInformationSquare
|
||||
name={network.name}
|
||||
active={isActive}
|
||||
size={"lg"}
|
||||
description={network.description}
|
||||
/>
|
||||
</div>
|
||||
<NetworkActions />
|
||||
</NetworkProvider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={"flex gap-10 w-full mt-8 max-w-6xl items-start"}>
|
||||
<NetworkInformationCard network={network} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={"flex gap-10 w-full mt-8 max-w-6xl items-start"}>
|
||||
<NetworkInformationCard network={network} />
|
||||
</div>
|
||||
</div>
|
||||
<Tabs
|
||||
defaultValue={tab}
|
||||
onValueChange={setTab}
|
||||
value={tab}
|
||||
className={"pb-0 mb-0"}
|
||||
>
|
||||
<TabsList justify={"start"} className={"px-8"}>
|
||||
<TabsTrigger value={"resources"}>
|
||||
<Layers3Icon size={14} />
|
||||
{singularize("Resources", network?.resources?.length)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value={"routing-peers"}>
|
||||
<PeerIcon
|
||||
size={12}
|
||||
className={
|
||||
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
|
||||
}
|
||||
/>
|
||||
{singularize("Routing Peers", network?.routing_peers_count)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value={"services"}>
|
||||
<ReverseProxyIcon
|
||||
size={16}
|
||||
className={
|
||||
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
|
||||
}
|
||||
/>
|
||||
{singularize("Services", services.length)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<Tabs
|
||||
defaultValue={tab}
|
||||
onValueChange={setTab}
|
||||
value={tab}
|
||||
className={"pb-0 mb-0"}
|
||||
>
|
||||
<TabsList justify={"start"} className={"px-8"}>
|
||||
<TabsTrigger value={"resources"}>
|
||||
<Layers3Icon size={14} />
|
||||
{singularize("Resources", network?.resources?.length)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value={"routing-peers"}>
|
||||
<PeerIcon
|
||||
size={12}
|
||||
className={
|
||||
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
|
||||
}
|
||||
<TabsContent value={"resources"} className={"pb-8"}>
|
||||
<ResourcesTabContent
|
||||
data={resources}
|
||||
isLoading={isResourcesLoading}
|
||||
/>
|
||||
{singularize("Routing Peers", network?.routing_peers_count)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value={"services"}>
|
||||
<ReverseProxyIcon
|
||||
size={16}
|
||||
className={
|
||||
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
|
||||
}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"routing-peers"} className={"pb-8"}>
|
||||
<NetworkRoutingPeersTabContent
|
||||
routers={routers}
|
||||
isLoading={isRoutersLoading}
|
||||
/>
|
||||
{singularize("Services", services.length)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"resources"} className={"pb-8"}>
|
||||
<ResourcesTabContent
|
||||
data={resources}
|
||||
isLoading={isResourcesLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"routing-peers"} className={"pb-8"}>
|
||||
<NetworkRoutingPeersTabContent
|
||||
routers={routers}
|
||||
isLoading={isRoutersLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"services"} className={"pb-8"}>
|
||||
<ReverseProxyFlatTargetsTabContent
|
||||
targets={services}
|
||||
isLoading={isServicesLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</NetworkProvider>
|
||||
<TabsContent value={"services"} className={"pb-8"}>
|
||||
<ReverseProxyFlatTargetsTabContent
|
||||
targets={services}
|
||||
isLoading={isServicesLoading}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</NetworkProvider>
|
||||
</NetworkAccessControlProvider>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,8 +8,12 @@ export default function ReverseProxyIcon(props: IconProps) {
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...iconProperties(props)}
|
||||
fill={"currentColor"}
|
||||
>
|
||||
<path d="M11.4488 2.1499C11.7903 1.95003 12.2097 1.95003 12.5513 2.1499L16.5018 4.46123L12 7.03523L7.49823 4.46123L11.4488 2.1499ZM6.44447 6.46472L6.44444 10.2784L2.93531 12.3315L7.53662 14.8399L10.8889 12.8787V9.00593L6.44447 6.46472ZM2 14.3992V18.7395C2 19.1477 2.21366 19.5247 2.55984 19.7272L6.44446 22V16.8223L2 14.3992ZM8.66668 22L12 20.0497L15.3333 22V16.7994L12 14.8492L8.66668 16.7993V22ZM17.5556 22L21.4401 19.7272C21.7863 19.5247 22 19.1477 22 18.7395V14.3992L17.5556 16.8223V22ZM21.0647 12.3315L17.5556 10.2784V6.46474L13.1111 9.00593V12.8787L16.4634 14.8399L21.0647 12.3315Z" />
|
||||
<path
|
||||
fill={"currentColor"}
|
||||
d="M11.4488 2.1499C11.7903 1.95003 12.2097 1.95003 12.5513 2.1499L16.5018 4.46123L12 7.03523L7.49823 4.46123L11.4488 2.1499ZM6.44447 6.46472L6.44444 10.2784L2.93531 12.3315L7.53662 14.8399L10.8889 12.8787V9.00593L6.44447 6.46472ZM2 14.3992V18.7395C2 19.1477 2.21366 19.5247 2.55984 19.7272L6.44446 22V16.8223L2 14.3992ZM8.66668 22L12 20.0497L15.3333 22V16.7994L12 14.8492L8.66668 16.7993V22ZM17.5556 22L21.4401 19.7272C21.7863 19.5247 22 19.1477 22 18.7395V14.3992L17.5556 16.8223V22ZM21.0647 12.3315L17.5556 10.2784V6.46474L13.1111 9.00593V12.8787L16.4634 14.8399L21.0647 12.3315Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
BIN
src/assets/integrations/crowdsec.png
Normal file
BIN
src/assets/integrations/crowdsec.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -2,6 +2,7 @@
|
||||
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { motion } from "framer-motion";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
@@ -23,7 +24,7 @@ const AccordionTrigger = React.forwardRef<
|
||||
<AccordionPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-1 items-center gap-4 font-medium transition-all [&[data-state=open]>svg.chevron]:rotate-180 hover:opacity-80 my-2",
|
||||
"flex flex-1 items-center gap-4 font-medium [&[data-state=open]>svg.chevron]:rotate-180 hover:opacity-80 my-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -36,20 +37,41 @@ const AccordionTrigger = React.forwardRef<
|
||||
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ElementRef<typeof AccordionPrimitive.Content>,
|
||||
HTMLDivElement,
|
||||
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className=" pt-0">{children}</div>
|
||||
</AccordionPrimitive.Content>
|
||||
));
|
||||
>(({ className, children }, ref) => {
|
||||
const wrapperRef = React.useRef<HTMLDivElement>(null);
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const el = wrapperRef.current?.closest("[data-state]");
|
||||
if (!el) return;
|
||||
|
||||
const update = () => setIsOpen(el.getAttribute("data-state") === "open");
|
||||
update();
|
||||
|
||||
const observer = new MutationObserver(update);
|
||||
observer.observe(el, { attributes: true, attributeFilter: ["data-state"] });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef}>
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial={false}
|
||||
animate={{
|
||||
height: isOpen ? "auto" : 0,
|
||||
opacity: isOpen ? 1 : 0,
|
||||
}}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
className={cn("overflow-hidden text-sm", className)}
|
||||
>
|
||||
<div className="pt-0">{children}</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
AccordionContent.displayName = AccordionPrimitive.Content.displayName;
|
||||
|
||||
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -80,13 +80,15 @@ export const DeviceCard = ({
|
||||
hideTooltip={true}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
"text-sm font-normal text-nb-gray-400 relative whitespace-nowrap"
|
||||
}
|
||||
>
|
||||
<TruncatedText text={descriptionText} maxWidth={"160px"} />
|
||||
</span>
|
||||
{descriptionText && (
|
||||
<span
|
||||
className={
|
||||
"text-sm font-normal text-nb-gray-400 relative whitespace-nowrap"
|
||||
}
|
||||
>
|
||||
<TruncatedText text={descriptionText} maxWidth={"160px"} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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"}>
|
||||
|
||||
@@ -1,29 +1,68 @@
|
||||
import * as React from "react";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { TooltipVariants } from "@components/Tooltip";
|
||||
|
||||
type Props = {
|
||||
content: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
interactive?: boolean;
|
||||
};
|
||||
className?: string;
|
||||
triggerClassName?: string;
|
||||
align?: "start" | "center" | "end";
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
alignOffset?: number;
|
||||
sideOffset?: number;
|
||||
iconSize?: number;
|
||||
delayDuration?: number;
|
||||
} & TooltipVariants;
|
||||
export const HelpTooltip = ({
|
||||
content,
|
||||
children,
|
||||
interactive = true,
|
||||
interactive = false,
|
||||
className,
|
||||
variant = "default",
|
||||
triggerClassName,
|
||||
align = "start",
|
||||
side = "top",
|
||||
alignOffset = 0,
|
||||
sideOffset,
|
||||
iconSize = 12,
|
||||
delayDuration = 300,
|
||||
}: Props) => {
|
||||
return (
|
||||
<>
|
||||
<FullTooltip
|
||||
interactive={interactive}
|
||||
side={"top"}
|
||||
align={"start"}
|
||||
alignOffset={0}
|
||||
side={side}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
delayDuration={delayDuration}
|
||||
variant={variant}
|
||||
className={
|
||||
"inline underline decoration-dashed underline-offset-[3px] decoration-nb-gray-300 cursor-help transition-all hover:decoration-white"
|
||||
}
|
||||
content={content}
|
||||
content={
|
||||
<div className={cn("max-w-xs text-xs", className)}>{content}</div>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
{children ? (
|
||||
children
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
"p-2 -m-2 inline-flex items-center justify-center relative top-[1px] group/help",
|
||||
triggerClassName,
|
||||
)}
|
||||
>
|
||||
<HelpCircle
|
||||
size={iconSize}
|
||||
className={"text-nb-gray-300 group-hover/help:text-nb-gray-100"}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</FullTooltip>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -6,24 +6,26 @@ export const ListItem = ({
|
||||
label,
|
||||
value,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
icon?: React.ReactNode;
|
||||
label: string;
|
||||
value: string | React.ReactNode;
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex justify-between gap-12 border-b border-nb-gray-920 py-2 px-4 last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
className={cn(" border-b border-nb-gray-920 last:border-b-0", className)}
|
||||
>
|
||||
<div className={"flex items-center gap-2 text-nb-gray-100 font-medium"}>
|
||||
{icon}
|
||||
{label}
|
||||
<div className={cn("flex justify-between gap-12 py-2 px-4")}>
|
||||
<div className={"flex items-center gap-2 text-nb-gray-100 font-medium"}>
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
<div className={"text-nb-gray-300"}>{value}</div>
|
||||
</div>
|
||||
<div className={"text-nb-gray-300"}>{value}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface NotifyProps<T> {
|
||||
icon?: React.ReactNode;
|
||||
backgroundColor?: string;
|
||||
preventSuccessToast?: boolean;
|
||||
showOnlyError?: boolean;
|
||||
errorMessages?: ErrorResponse[];
|
||||
}
|
||||
|
||||
@@ -36,6 +37,7 @@ export default function Notification<T>({
|
||||
loadingMessage,
|
||||
duration = 3500,
|
||||
preventSuccessToast = false,
|
||||
showOnlyError = false,
|
||||
errorMessages,
|
||||
}: NotificationProps<T>) {
|
||||
const [error, setError] = useState("");
|
||||
@@ -49,10 +51,13 @@ export default function Notification<T>({
|
||||
const startTimer = useCallback(() => {
|
||||
if (timerRef.current) return;
|
||||
startTimeRef.current = Date.now();
|
||||
timerRef.current = setTimeout(() => {
|
||||
timerRef.current = null;
|
||||
toast.dismiss(toastId);
|
||||
}, Math.max(0, remainingRef.current));
|
||||
timerRef.current = setTimeout(
|
||||
() => {
|
||||
timerRef.current = null;
|
||||
toast.dismiss(toastId);
|
||||
},
|
||||
Math.max(0, remainingRef.current),
|
||||
);
|
||||
}, [toastId]);
|
||||
|
||||
const pauseTimer = useCallback(() => {
|
||||
@@ -88,7 +93,10 @@ export default function Notification<T>({
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(toastEl, { attributes: true, attributeFilter: ["data-expanded"] });
|
||||
observer.observe(toastEl, {
|
||||
attributes: true,
|
||||
attributeFilter: ["data-expanded"],
|
||||
});
|
||||
|
||||
// Start immediately if not expanded
|
||||
const expanded = toastEl.getAttribute("data-expanded") === "true";
|
||||
@@ -106,7 +114,7 @@ export default function Notification<T>({
|
||||
promise
|
||||
.then(() => {
|
||||
setLoading(false);
|
||||
if (preventSuccessToast) {
|
||||
if (showOnlyError || preventSuccessToast) {
|
||||
toast.dismiss(toastId);
|
||||
} else {
|
||||
setReadyToDismiss(true);
|
||||
@@ -136,6 +144,9 @@ export default function Notification<T>({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const hideUntilError = showOnlyError && loading && !error;
|
||||
if (hideUntilError) return null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={notificationRef}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
MonitorSmartphoneIcon,
|
||||
NetworkIcon,
|
||||
SearchIcon,
|
||||
ShieldCheck,
|
||||
WorkflowIcon,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
@@ -40,7 +41,7 @@ import { useElementSize } from "@/hooks/useElementSize";
|
||||
import type { Group, GroupPeer, GroupResource } from "@/interfaces/Group";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import type { Peer } from "@/interfaces/Peer";
|
||||
import { PolicyRuleResource } from "@/interfaces/Policy";
|
||||
import { Policy, PolicyRuleResource } from "@/interfaces/Policy";
|
||||
import { User } from "@/interfaces/User";
|
||||
import { HorizontalUsersStack } from "@/modules/users/HorizontalUsersStack";
|
||||
import { PeerOperatingSystemIcon } from "@/modules/peers/PeerOperatingSystemIcon";
|
||||
@@ -71,18 +72,21 @@ interface MultiSelectProps {
|
||||
showResourceCounter?: boolean;
|
||||
showResources?: boolean;
|
||||
showPeers?: boolean;
|
||||
showPeerCounter?: boolean;
|
||||
hideGroupsTab?: boolean;
|
||||
tabOrder?: ("groups" | "peers" | "resources")[];
|
||||
closeOnSelect?: boolean;
|
||||
resource?: PolicyRuleResource;
|
||||
onResourceChange?: (resource?: PolicyRuleResource) => void;
|
||||
placeholder?: string;
|
||||
placeholder?: React.ReactNode | string;
|
||||
customTrigger?: React.ReactNode;
|
||||
align?: "start" | "end";
|
||||
side?: "top" | "bottom";
|
||||
users?: User[];
|
||||
placeholderForSearch?: string;
|
||||
resourceIds?: string[];
|
||||
additionalResources?: NetworkResource[];
|
||||
policies?: Policy[];
|
||||
}
|
||||
export function PeerGroupSelector({
|
||||
onChange,
|
||||
@@ -101,6 +105,7 @@ export function PeerGroupSelector({
|
||||
showResourceCounter = true,
|
||||
showResources = false,
|
||||
showPeers = false,
|
||||
showPeerCounter = true,
|
||||
hideGroupsTab = false,
|
||||
tabOrder,
|
||||
closeOnSelect = false,
|
||||
@@ -113,11 +118,21 @@ export function PeerGroupSelector({
|
||||
users,
|
||||
placeholderForSearch = 'Search groups or add new group by pressing "Enter"...',
|
||||
resourceIds,
|
||||
additionalResources,
|
||||
policies,
|
||||
}: Readonly<MultiSelectProps>) {
|
||||
const { data: resources, isLoading: isResourcesLoading } = useFetchApi<
|
||||
const { data: fetchedResources, isLoading: isResourcesLoading } = useFetchApi<
|
||||
NetworkResource[]
|
||||
>("/networks/resources");
|
||||
|
||||
const resources = useMemo(() => {
|
||||
if (!additionalResources?.length) return fetchedResources;
|
||||
const additional = additionalResources.filter(
|
||||
(ar) => !fetchedResources?.some((r) => r.id === ar.id),
|
||||
);
|
||||
return [...(fetchedResources || []), ...additional];
|
||||
}, [fetchedResources, additionalResources]);
|
||||
|
||||
const { data: peers, isLoading: isPeersLoading } =
|
||||
useFetchApi<Peer[]>("/peers");
|
||||
|
||||
@@ -329,7 +344,7 @@ export function PeerGroupSelector({
|
||||
"min-h-[46px] w-full relative items-center group",
|
||||
"border border-neutral-200 dark:border-nb-gray-700 justify-between py-2 px-3",
|
||||
"rounded-md bg-white text-sm dark:bg-nb-gray-900/40 flex dark:text-neutral-400/70 text-neutral-500 cursor-pointer hover:dark:bg-nb-gray-900/50",
|
||||
"disabled:pointer-events-none disabled:opacity-30 transition-all",
|
||||
"disabled:pointer-events-none disabled:opacity-60 transition-all",
|
||||
)}
|
||||
disabled={disabled}
|
||||
data-cy={dataCy}
|
||||
@@ -343,7 +358,14 @@ export function PeerGroupSelector({
|
||||
{resource && (
|
||||
<ResourceBadge
|
||||
className={"py-[3px]"}
|
||||
resource={resources?.find((r) => r.id === resource.id)}
|
||||
resource={
|
||||
resources?.find((r) => r.id === resource.id) ??
|
||||
({
|
||||
id: resource.id,
|
||||
name: resource.id,
|
||||
type: resource.type,
|
||||
} as NetworkResource)
|
||||
}
|
||||
peer={peers?.find((p) => p.id === resource.id)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -397,7 +419,9 @@ export function PeerGroupSelector({
|
||||
})}
|
||||
|
||||
{values.length == 0 && !resource && (
|
||||
<span className={"pl-1"}>{placeholder}</span>
|
||||
<span className={cn(typeof placeholder === "string" && "pl-1")}>
|
||||
{placeholder}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -567,12 +591,21 @@ export function PeerGroupSelector({
|
||||
<ResourcesCounter group={option} />
|
||||
)}
|
||||
|
||||
{policies && (
|
||||
<PolicyCounter
|
||||
group={option}
|
||||
policies={policies}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={"flex gap-4 items-center"}>
|
||||
{!users ? (
|
||||
<PeerCounter
|
||||
group={option}
|
||||
showResourceCounter={showResourceCounter}
|
||||
/>
|
||||
showPeerCounter && (
|
||||
<PeerCounter
|
||||
group={option}
|
||||
showResourceCounter={showResourceCounter}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<UsersCounter
|
||||
group={option}
|
||||
@@ -788,6 +821,39 @@ const ResourcesCounter = ({ group }: { group: Group }) => {
|
||||
) : null;
|
||||
};
|
||||
|
||||
const PolicyCounter = ({
|
||||
group,
|
||||
policies,
|
||||
}: {
|
||||
group: Group;
|
||||
policies: Policy[];
|
||||
}) => {
|
||||
const count = useMemo(() => {
|
||||
if (!group.id) return 0;
|
||||
return policies.filter((policy) => {
|
||||
const destinations = policy.rules?.[0]?.destinations as
|
||||
| (Group | string)[]
|
||||
| undefined;
|
||||
return destinations?.some((d) =>
|
||||
typeof d === "string" ? d === group.id : d.id === group.id,
|
||||
);
|
||||
}).length;
|
||||
}, [group.id, policies]);
|
||||
|
||||
if (count === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"text-nb-gray-300 font-medium flex items-center gap-2 transition-all"
|
||||
}
|
||||
>
|
||||
<ShieldCheck size={14} className={"shrink-0"} />
|
||||
{count} {count === 1 ? "Policy" : "Policies"}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const resourcesSearchPredicate = (item: NetworkResource, query: string) => {
|
||||
const lowerCaseQuery = query.toLowerCase();
|
||||
if (item.name.toLowerCase().includes(lowerCaseQuery)) return true;
|
||||
|
||||
@@ -8,6 +8,7 @@ type Props = {
|
||||
description: ReactNode;
|
||||
icon?: ReactNode;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const RadioCard = ({
|
||||
@@ -16,15 +17,18 @@ export const RadioCard = ({
|
||||
description,
|
||||
className,
|
||||
icon,
|
||||
disabled,
|
||||
}: Props) => {
|
||||
return (
|
||||
<RadioGroup.Item
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"peer relative block cursor-pointer rounded-lg border border-nb-gray-900 bg-nb-gray-930/60 px-5 py-3 transition-all focus:outline-none",
|
||||
"data-[state=checked]:border-nb-gray-400 data-[state=checked]:bg-nb-gray-920",
|
||||
"outline-none focus:ring-0 focus:bg-nb-gray-930 focus:border-nb-gray-920",
|
||||
"hover:bg-nb-gray-930",
|
||||
"disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-nb-gray-930/60",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -75,23 +75,59 @@ SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
|
||||
extra?: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
}
|
||||
>(({ className, children, extra, icon, description, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full select-none items-center rounded-md py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-gray-100 focus:text-neutral-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-nb-gray-900 dark:focus:text-neutral-50 dark:text-gray-400 cursor-pointer",
|
||||
"relative flex w-full select-none items-center rounded-md py-1.5 text-sm outline-none focus:bg-gray-100 focus:text-neutral-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-nb-gray-900 dark:focus:text-neutral-50 dark:text-gray-400 cursor-pointer",
|
||||
icon ? "pl-2 pr-8" : "pl-8 pr-2",
|
||||
description && "py-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
{icon ? (
|
||||
<>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex-shrink-0">{icon}</span>
|
||||
<div className="flex flex-col">
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
{description && (
|
||||
<span className="text-xs text-nb-gray-300 font-normal">
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
{description && (
|
||||
<span className="text-xs text-nb-gray-300 font-normal">
|
||||
{description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{extra}
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
@@ -38,6 +38,7 @@ const ModalOverlay = React.forwardRef<
|
||||
"bg-black/30 dark:bg-black/40 backdrop-blur-sm",
|
||||
className,
|
||||
)}
|
||||
style={{ scrollbarGutter: "stable both-edges" }}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import * as React from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import { cn } from "@utils/helpers";
|
||||
|
||||
export const SkeletonDeviceCard = () => {
|
||||
type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const SkeletonDeviceCard = ({ className = "min-h-[59px]" }: Props) => {
|
||||
return (
|
||||
<div className={"min-h-[59px] relative -left-2"}>
|
||||
<div className={"py-2 pr-4 pl-2 flex gap-3"}>
|
||||
<Skeleton height={36} width={36} />
|
||||
<div className={"flex flex-col pr-[1.15rem]"}>
|
||||
<Skeleton height={16} width={70} />
|
||||
<Skeleton height={16} width={140} />
|
||||
</div>
|
||||
<div
|
||||
className={cn("py-2 pr-4 pl-2 flex gap-3 relative -left-2", className)}
|
||||
>
|
||||
<Skeleton height={36} width={36} />
|
||||
<div className={"flex flex-col pr-[1.15rem]"}>
|
||||
<Skeleton height={16} width={70} />
|
||||
<Skeleton height={16} width={140} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
42
src/components/skeletons/SkeletonNetwork.tsx
Normal file
42
src/components/skeletons/SkeletonNetwork.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import * as React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import SkeletonTable from "@components/skeletons/SkeletonTable";
|
||||
|
||||
export const SkeletonNetwork = ({ delay = 400 }: { delay?: number }) => {
|
||||
const [show, setShow] = useState(delay === 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (delay === 0) return;
|
||||
const timer = setTimeout(() => setShow(true), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [delay]);
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<div className={"p-default py-6 w-full"}>
|
||||
<Skeleton height={24} width={240} className={"mb-4"} />
|
||||
<div className={"mb-8 flex items-center gap-4"}>
|
||||
<Skeleton height={48} width={48} />
|
||||
<Skeleton height={20} width={200} />
|
||||
</div>
|
||||
<div className={"mb-4"}>
|
||||
<Skeleton height={106} className={"mb-2 w-full max-w-[574px]"} />
|
||||
</div>
|
||||
<div className={"flex items-center gap-4 mb-8"}>
|
||||
<Skeleton height={24} width={130} />
|
||||
<Skeleton height={24} width={130} />
|
||||
<Skeleton height={24} width={130} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Skeleton height={16} width={530} className={"w-full max-w-[530px]"} />
|
||||
<Skeleton height={16} width={430} className={"w-full max-w-[430px]"} />
|
||||
</div>
|
||||
<div className={"w-full"}>
|
||||
<SkeletonTable withHeader={false} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
20
src/components/skeletons/SkeletonSettings.tsx
Normal file
20
src/components/skeletons/SkeletonSettings.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as React from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
|
||||
export const SkeletonSettings = () => {
|
||||
return (
|
||||
<div className={"p-default py-6 max-w-2xl"}>
|
||||
<Skeleton height={24} width={200} className={"mb-6"} />
|
||||
<Skeleton height={32} width={110} className={"mb-10"} />
|
||||
<div className={"mb-8"}>
|
||||
<Skeleton height={17} width={200} className={"mb-2"} />
|
||||
<Skeleton height={80} width={"100%"} />
|
||||
</div>
|
||||
<div className={"mb-8"}>
|
||||
<Skeleton height={17} width={200} className={"mb-2"} />
|
||||
<Skeleton height={80} width={"100%"} />
|
||||
</div>
|
||||
<Skeleton height={80} width={"100%"} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
@@ -296,6 +306,7 @@ export function DataTable<TData, TValue>({
|
||||
autoResetAll: false,
|
||||
autoResetExpanded: false,
|
||||
manualPagination: manualPagination,
|
||||
manualSorting: serverSidePagination,
|
||||
manualFiltering: manualFiltering || manualColumnFiltering,
|
||||
pageCount: pageCount,
|
||||
state: {
|
||||
@@ -323,6 +334,7 @@ export function DataTable<TData, TValue>({
|
||||
},
|
||||
sortingFns: {
|
||||
checkbox: checkboxSort,
|
||||
datetime: datetimeSort,
|
||||
},
|
||||
getRowId: useRowId ? (row) => row.id : undefined,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { IconSortAscending, IconSortDescending } from "@tabler/icons-react";
|
||||
import type { Column } from "@tanstack/table-core";
|
||||
import { cn } from "@utils/helpers";
|
||||
import React from "react";
|
||||
import { useOptionalServerPagination } from "@/contexts/ServerPaginationProvider";
|
||||
|
||||
type Props = {
|
||||
column: Column<any>;
|
||||
@@ -13,6 +14,8 @@ type Props = {
|
||||
center?: boolean;
|
||||
className?: string;
|
||||
sorting?: boolean;
|
||||
onSort?: () => void;
|
||||
name?: string;
|
||||
};
|
||||
export default function DataTableHeader({
|
||||
children,
|
||||
@@ -21,15 +24,28 @@ export default function DataTableHeader({
|
||||
center,
|
||||
className,
|
||||
sorting = true,
|
||||
onSort,
|
||||
name,
|
||||
}: Props) {
|
||||
const serverPagination = useOptionalServerPagination();
|
||||
|
||||
const handleSort = () => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FullTooltip content={tooltip} disabled={!tooltip}>
|
||||
<div
|
||||
onClick={
|
||||
sorting
|
||||
? () => column.toggleSorting(column.getIsSorted() === "asc")
|
||||
: undefined
|
||||
}
|
||||
onClick={sorting ? handleSort : undefined}
|
||||
className={cn(
|
||||
"flex items-center whitespace-nowrap gap-2 dark:text-gray-400 transition-all select-none text-xs tracking-wide",
|
||||
sorting &&
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -26,6 +26,7 @@ type Props = {
|
||||
showResources?: boolean;
|
||||
redirectGroupTab?: string;
|
||||
showUsers?: boolean;
|
||||
disableRedirect?: boolean;
|
||||
};
|
||||
|
||||
export default function MultipleGroups({
|
||||
@@ -37,6 +38,7 @@ export default function MultipleGroups({
|
||||
showResources = false,
|
||||
showUsers = false,
|
||||
redirectGroupTab,
|
||||
disableRedirect = false,
|
||||
}: Readonly<Props>) {
|
||||
const { permission } = usePermissions();
|
||||
|
||||
@@ -64,6 +66,7 @@ export default function MultipleGroups({
|
||||
{firstGroup && (
|
||||
<GroupBadge
|
||||
group={firstGroup}
|
||||
showNewBadge={true}
|
||||
className={
|
||||
permission.groups.update ? "group-hover:bg-nb-gray-800" : ""
|
||||
}
|
||||
@@ -101,7 +104,7 @@ export default function MultipleGroups({
|
||||
return (
|
||||
group && (
|
||||
<div
|
||||
key={group.id}
|
||||
key={group?.id || group?.name}
|
||||
className={
|
||||
"flex gap-2 items-center justify-between w-full"
|
||||
}
|
||||
@@ -110,16 +113,23 @@ export default function MultipleGroups({
|
||||
group={group}
|
||||
className={"py-0"}
|
||||
textClassName={"py-1.5"}
|
||||
redirectToGroupPage={true}
|
||||
showNewBadge={true}
|
||||
redirectToGroupPage={!disableRedirect}
|
||||
redirectGroupTab={redirectGroupTab}
|
||||
></GroupBadge>
|
||||
<ArrowRightIcon size={14} />
|
||||
{showResources ? (
|
||||
<ResourceCountBadge group={group} />
|
||||
<ResourceCountBadge
|
||||
group={group}
|
||||
disableRedirect={disableRedirect}
|
||||
/>
|
||||
) : showUsers ? (
|
||||
<UserCountStack group={group} />
|
||||
) : (
|
||||
<PeerCountBadge group={group} />
|
||||
<PeerCountBadge
|
||||
group={group}
|
||||
disableRedirect={disableRedirect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ import ResourceCountBadge from "@components/ui/ResourceCountBadge";
|
||||
|
||||
type Props = {
|
||||
group?: Group;
|
||||
disableRedirect?: boolean;
|
||||
} & React.HTMLAttributes<HTMLDivElement> &
|
||||
BadgeVariants;
|
||||
|
||||
@@ -17,6 +18,7 @@ export default function PeerCountBadge({
|
||||
group,
|
||||
variant = "gray",
|
||||
className,
|
||||
disableRedirect = false,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const { dropdownOptions, groups } = useGroups();
|
||||
@@ -35,7 +37,8 @@ export default function PeerCountBadge({
|
||||
return peerCount;
|
||||
}, [currentGroup]);
|
||||
|
||||
const canRedirect = !!group?.id && group?.name !== "All";
|
||||
const canRedirect =
|
||||
!!group?.id && group?.name !== "All" && !disableRedirect;
|
||||
|
||||
const onClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
@@ -46,7 +49,7 @@ export default function PeerCountBadge({
|
||||
const showResources = resourcesCount > 0 && peerCount === 0;
|
||||
|
||||
return showResources ? (
|
||||
<ResourceCountBadge group={group} />
|
||||
<ResourceCountBadge group={group} disableRedirect={disableRedirect} />
|
||||
) : (
|
||||
<Badge
|
||||
variant={variant}
|
||||
|
||||
@@ -7,15 +7,20 @@ import { Group } from "@/interfaces/Group";
|
||||
|
||||
type Props = {
|
||||
group?: Group;
|
||||
disableRedirect?: boolean;
|
||||
} & React.HTMLAttributes<HTMLDivElement> &
|
||||
BadgeVariants;
|
||||
|
||||
export default function ResourceCountBadge({ group }: Props) {
|
||||
export default function ResourceCountBadge({
|
||||
group,
|
||||
disableRedirect = false,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const hasId = !!group?.id;
|
||||
|
||||
const onClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
if (disableRedirect) return;
|
||||
if (hasId) router.push(`/group?id=${group?.id}&tab=resources`);
|
||||
};
|
||||
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -27,6 +27,8 @@ type DialogOptions = {
|
||||
type?: "default" | "warning" | "danger" | "center";
|
||||
children?: React.ReactNode;
|
||||
maxWidthClass?: string;
|
||||
hideIcon?: boolean;
|
||||
center?: boolean;
|
||||
};
|
||||
|
||||
export default function DialogProvider({ children }: Props) {
|
||||
@@ -70,14 +72,14 @@ export default function DialogProvider({ children }: Props) {
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<ModalHeader
|
||||
center={dialogOptions.type == "center"}
|
||||
center={dialogOptions.center ?? dialogOptions.type == "center"}
|
||||
title={dialogOptions.title || "Confirmation"}
|
||||
margin={"mt-1"}
|
||||
description={
|
||||
dialogOptions.description ||
|
||||
"Are you sure you want to continue? This action cannot be undone."
|
||||
}
|
||||
icon={dialogTypes[dialogOptions.type || "default"]}
|
||||
icon={dialogOptions.hideIcon ? "" : dialogTypes[dialogOptions.type || "default"]}
|
||||
color={
|
||||
dialogOptions.type == "default"
|
||||
? "blue"
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { Modal } from "@components/modal/Modal";
|
||||
import { notify } from "@components/Notification";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import { cloneDeep } from "@utils/helpers";
|
||||
import React, { useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { useGroups } from "@/contexts/GroupsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { AccessControlModalContent } from "@/modules/access-control/AccessControlModal";
|
||||
|
||||
@@ -18,18 +23,115 @@ const PoliciesContext = React.createContext(
|
||||
message?: string,
|
||||
) => void;
|
||||
createPolicy: (policy: Policy) => Promise<Policy>;
|
||||
createPoliciesForResource: (
|
||||
policies: Policy[],
|
||||
resource: NetworkResource,
|
||||
knownGroups?: Group[],
|
||||
) => Promise<void>;
|
||||
openEditPolicyModal: (policy: Policy, tab?: string) => void;
|
||||
deletePolicy: (policy: Policy, onSuccess?: () => void) => Promise<void>;
|
||||
serializeRules: (
|
||||
rules: Policy["rules"],
|
||||
enabled?: boolean,
|
||||
) => Policy["rules"];
|
||||
},
|
||||
);
|
||||
|
||||
export default function PoliciesProvider({ children }: Props) {
|
||||
const { mutate } = useSWRConfig();
|
||||
const request = useApiCall<Policy>("/policies");
|
||||
const { createOrUpdate: createOrUpdateGroup, groups } = useGroups();
|
||||
const [policyModal, setPolicyModal] = useState(false);
|
||||
const [currentPolicy, setCurrentPolicy] = useState<Policy>();
|
||||
const [initialPolicyTab, setInitialPolicyTab] = useState("");
|
||||
|
||||
const createPolicy = async (policy: Policy) => request.post(policy);
|
||||
|
||||
const createPolicyForResource = async (
|
||||
policy: Policy,
|
||||
resource: NetworkResource,
|
||||
knownGroups?: Group[],
|
||||
) => {
|
||||
const rule = policy.rules[0];
|
||||
|
||||
const allGroups = [...(knownGroups || []), ...(groups || [])];
|
||||
const resolveGroup = async (g: Group | string): Promise<string> => {
|
||||
if (typeof g === "string") return g;
|
||||
if (g.id) return g.id;
|
||||
const existing = allGroups.find((eg) => eg.name === g.name);
|
||||
if (existing?.id) return existing.id;
|
||||
const created = await createOrUpdateGroup(g);
|
||||
return created.id!;
|
||||
};
|
||||
|
||||
const sources = await Promise.all(
|
||||
(rule.sources ?? []).map(resolveGroup),
|
||||
).then((ids) => ids.filter(Boolean) as string[]);
|
||||
|
||||
const destinations = rule.destinationResource
|
||||
? undefined
|
||||
: await Promise.all((rule.destinations ?? []).map(resolveGroup)).then(
|
||||
(ids) => ids.filter(Boolean) as string[],
|
||||
);
|
||||
|
||||
const destinationResource = rule.destinationResource
|
||||
? { id: resource.id, type: resource.type }
|
||||
: undefined;
|
||||
|
||||
return createPolicy({
|
||||
...policy,
|
||||
source_posture_checks: (policy.source_posture_checks ?? []).map((c) =>
|
||||
typeof c === "string" ? c : c.id,
|
||||
),
|
||||
rules: [
|
||||
{
|
||||
...rule,
|
||||
sources,
|
||||
destinations,
|
||||
destinationResource,
|
||||
},
|
||||
],
|
||||
} as Policy);
|
||||
};
|
||||
|
||||
const createPoliciesForResource = async (
|
||||
newPolicies: Policy[],
|
||||
resource: NetworkResource,
|
||||
knownGroups?: Group[],
|
||||
) => {
|
||||
const policiesToCreate = newPolicies.filter((p) => !p.id);
|
||||
if (policiesToCreate.length === 0) return;
|
||||
|
||||
await Promise.all(
|
||||
policiesToCreate.map((p) =>
|
||||
createPolicyForResource(p, resource, knownGroups),
|
||||
),
|
||||
);
|
||||
await mutate("/policies");
|
||||
};
|
||||
|
||||
const serializeRules = (rules: Policy["rules"], enabled?: boolean) => {
|
||||
rules = cloneDeep(rules);
|
||||
rules.forEach((rule) => {
|
||||
if (enabled !== undefined) rule.enabled = enabled;
|
||||
rule.sources = rule.sources
|
||||
? (rule.sources.map((s) => {
|
||||
const group = s as Group;
|
||||
return group.id ?? s;
|
||||
}) as string[])
|
||||
: [];
|
||||
rule.destinations = rule.destinations
|
||||
? (rule.destinations.map((d) => {
|
||||
const group = d as Group;
|
||||
return group.id ?? d;
|
||||
}) as string[])
|
||||
: [];
|
||||
if (rule.destinationResource) rule.destinations = null;
|
||||
if (rule.sourceResource) rule.sources = null;
|
||||
});
|
||||
return rules;
|
||||
};
|
||||
|
||||
const updatePolicy = async (
|
||||
policy: Policy,
|
||||
toUpdate: Partial<Policy>,
|
||||
@@ -62,6 +164,20 @@ export default function PoliciesProvider({ children }: Props) {
|
||||
});
|
||||
};
|
||||
|
||||
const deletePolicy = async (policy: Policy, onSuccess?: () => void) => {
|
||||
const promise = request.del("", `/${policy.id}`).then(() => {
|
||||
mutate("/policies");
|
||||
onSuccess?.();
|
||||
});
|
||||
notify({
|
||||
title: "Access Control Policy " + policy.name,
|
||||
description: "The policy was successfully deleted.",
|
||||
promise,
|
||||
loadingMessage: "Deleting policy...",
|
||||
});
|
||||
return promise;
|
||||
};
|
||||
|
||||
const openEditPolicyModal = (policy: Policy, tab?: string) => {
|
||||
setCurrentPolicy(policy);
|
||||
tab && setInitialPolicyTab(tab);
|
||||
@@ -70,7 +186,14 @@ export default function PoliciesProvider({ children }: Props) {
|
||||
|
||||
return (
|
||||
<PoliciesContext.Provider
|
||||
value={{ updatePolicy, createPolicy, openEditPolicyModal }}
|
||||
value={{
|
||||
updatePolicy,
|
||||
createPolicy,
|
||||
createPoliciesForResource,
|
||||
openEditPolicyModal,
|
||||
deletePolicy,
|
||||
serializeRules,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<Modal
|
||||
|
||||
@@ -26,6 +26,8 @@ import ReverseProxyTargetModal from "@/modules/reverse-proxy/targets/ReverseProx
|
||||
|
||||
type ReverseProxiesContextValue = {
|
||||
reverseProxies: ReverseProxy[] | undefined;
|
||||
resources: NetworkResource[] | undefined;
|
||||
peers: Peer[] | undefined;
|
||||
isLoading: boolean;
|
||||
openModal: (options?: OpenModalOptions) => void;
|
||||
openTargetModal: (options: OpenTargetModalOptions) => void;
|
||||
@@ -93,7 +95,7 @@ export default function ReverseProxiesProvider({
|
||||
const { data: rawReverseProxies, isLoading } = useFetchApi<ReverseProxy[]>(
|
||||
"/reverse-proxies/services",
|
||||
);
|
||||
const request = useApiCall<ReverseProxy>("/reverse-proxies/services");
|
||||
const request = useApiCall<ReverseProxy>("/reverse-proxies/services", true);
|
||||
|
||||
// Peers & Resources for resolving target destinations
|
||||
const { data: peers } = useFetchApi<Peer[]>("/peers");
|
||||
@@ -465,6 +467,8 @@ export default function ReverseProxiesProvider({
|
||||
<ReverseProxiesContext.Provider
|
||||
value={{
|
||||
reverseProxies,
|
||||
resources,
|
||||
peers,
|
||||
isLoading,
|
||||
openModal,
|
||||
openTargetModal,
|
||||
|
||||
@@ -31,6 +31,7 @@ type ServerPaginationContextValue<T = unknown> = {
|
||||
onGlobalFilterChange: (value: string) => void;
|
||||
setFilter: (key: string, value: string | undefined) => void;
|
||||
getFilter: (key: string) => string | undefined;
|
||||
setSort: (name: string, direction: "asc" | "desc") => void;
|
||||
hasActiveFilters: boolean;
|
||||
resetFilters: () => void;
|
||||
onFilterReset: () => void;
|
||||
@@ -146,6 +147,15 @@ export default function ServerPaginationProvider({
|
||||
|
||||
const getFilter = useCallback((key: string) => filters[key], [filters]);
|
||||
|
||||
const setSort = useCallback((name: string, direction: "asc" | "desc") => {
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
sort_by: name,
|
||||
sort_order: direction,
|
||||
}));
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
const hasActiveFilters =
|
||||
search !== "" ||
|
||||
Object.entries(filters).some(
|
||||
@@ -170,6 +180,7 @@ export default function ServerPaginationProvider({
|
||||
mutate,
|
||||
setFilter,
|
||||
getFilter,
|
||||
setSort,
|
||||
hasActiveFilters,
|
||||
resetFilters,
|
||||
pagination: { pageIndex: page - 1, pageSize },
|
||||
@@ -193,6 +204,7 @@ export default function ServerPaginationProvider({
|
||||
mutate,
|
||||
setFilter,
|
||||
getFilter,
|
||||
setSort,
|
||||
hasActiveFilters,
|
||||
resetFilters,
|
||||
page,
|
||||
@@ -220,3 +232,8 @@ export function useServerPagination<T>() {
|
||||
}
|
||||
return context as ServerPaginationContextValue<T>;
|
||||
}
|
||||
|
||||
export function useOptionalServerPagination<T>() {
|
||||
const context = useContext(ServerPaginationContext);
|
||||
return context as ServerPaginationContextValue<T> | null;
|
||||
}
|
||||
|
||||
@@ -28,14 +28,30 @@ const UserProfileContext = React.createContext(
|
||||
);
|
||||
|
||||
export default function UsersProvider({ children }: Readonly<Props>) {
|
||||
const { data: users, mutate, isLoading } = useFetchApi<User[]>("/users");
|
||||
const { data: users, mutate, isLoading } = useFetchApi<User[]>(
|
||||
"/users?service_user=false",
|
||||
);
|
||||
const { data: serviceUsers, mutate: mutateServiceUsers, isLoading: isLoadingServiceUsers } = useFetchApi<
|
||||
User[]
|
||||
>("/users?service_user=true");
|
||||
|
||||
const refresh = () => {
|
||||
mutate().then();
|
||||
mutateServiceUsers().then();
|
||||
};
|
||||
|
||||
const allUsers = useMemo(() => {
|
||||
return [...(users ?? []), ...(serviceUsers ?? [])];
|
||||
}, [users, serviceUsers]);
|
||||
|
||||
return (
|
||||
<UsersContext.Provider value={{ users, refresh, isLoading }}>
|
||||
<UsersContext.Provider
|
||||
value={{
|
||||
users: allUsers,
|
||||
refresh,
|
||||
isLoading: isLoading || isLoadingServiceUsers,
|
||||
}}
|
||||
>
|
||||
<UserProfileProvider>{children}</UserProfileProvider>
|
||||
</UsersContext.Provider>
|
||||
);
|
||||
|
||||
17
src/hooks/useEmbeddedIdentityProviders.ts
Normal file
17
src/hooks/useEmbeddedIdentityProviders.ts
Normal 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 };
|
||||
}
|
||||
@@ -4,23 +4,30 @@ 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 tab = useMemo(() => {
|
||||
const tabParam = searchParams.get("tab");
|
||||
if (tabParam && validTabs.includes(tabParam)) return tabParam;
|
||||
return defaultTab;
|
||||
}, [searchParams, validTabs, defaultTab]);
|
||||
const getTab = useCallback(
|
||||
(params: URLSearchParams) => {
|
||||
const tabParam = params.get(paramName);
|
||||
if (tabParam && validTabs.includes(tabParam)) return tabParam;
|
||||
return defaultTab;
|
||||
},
|
||||
[validTabs, defaultTab, paramName],
|
||||
);
|
||||
|
||||
const tab = useMemo(() => getTab(searchParams), [searchParams, getTab]);
|
||||
|
||||
const setTab = useCallback(
|
||||
(value: string) => {
|
||||
const nextTab = validTabs.includes(value) ? value : defaultTab;
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("tab", value);
|
||||
params.set(paramName, nextTab);
|
||||
router.replace(`?${params.toString()}`, { scroll: false });
|
||||
},
|
||||
[searchParams, router],
|
||||
[searchParams, router, validTabs, defaultTab, paramName],
|
||||
);
|
||||
|
||||
return [tab, setTab];
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface Account {
|
||||
user_approval_required: boolean;
|
||||
};
|
||||
peer_login_expiration_enabled: boolean;
|
||||
peer_expose_enabled?: boolean;
|
||||
peer_expose_groups?: string[];
|
||||
peer_login_expiration: number;
|
||||
peer_inactivity_expiration_enabled: boolean;
|
||||
peer_inactivity_expiration: number;
|
||||
@@ -24,6 +26,7 @@ export interface Account {
|
||||
lazy_connection_enabled: boolean;
|
||||
embedded_idp_enabled?: boolean;
|
||||
auto_update_version: string;
|
||||
auto_update_always: boolean;
|
||||
local_auth_disabled?: boolean;
|
||||
};
|
||||
onboarding?: AccountOnboarding;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,16 +1,43 @@
|
||||
export enum ServiceMode {
|
||||
HTTP = "http",
|
||||
TCP = "tcp",
|
||||
UDP = "udp",
|
||||
TLS = "tls",
|
||||
}
|
||||
|
||||
export interface ReverseProxy {
|
||||
id?: string;
|
||||
name: string;
|
||||
domain: string;
|
||||
mode?: ServiceMode;
|
||||
listen_port?: number;
|
||||
port_auto_assigned?: boolean;
|
||||
proxy_cluster?: string;
|
||||
targets: ReverseProxyTarget[];
|
||||
enabled: boolean;
|
||||
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;
|
||||
@@ -26,6 +53,17 @@ export enum ReverseProxyStatus {
|
||||
ERROR = "error",
|
||||
}
|
||||
|
||||
export type ServiceTargetOptionsPathRewrite = "preserve";
|
||||
|
||||
export interface ServiceTargetOptions {
|
||||
skip_tls_verify?: boolean;
|
||||
request_timeout?: string;
|
||||
session_idle_timeout?: string;
|
||||
path_rewrite?: ServiceTargetOptionsPathRewrite;
|
||||
custom_headers?: Record<string, string>;
|
||||
proxy_protocol?: boolean;
|
||||
}
|
||||
|
||||
export interface ReverseProxyTarget {
|
||||
target_id?: string;
|
||||
target_type: ReverseProxyTargetType;
|
||||
@@ -35,6 +73,7 @@ export interface ReverseProxyTarget {
|
||||
port: number;
|
||||
enabled: boolean;
|
||||
access_local?: boolean;
|
||||
options?: ServiceTargetOptions;
|
||||
// Frontend
|
||||
destination?: string;
|
||||
}
|
||||
@@ -55,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 {
|
||||
@@ -63,6 +109,9 @@ export interface ReverseProxyDomain {
|
||||
validated: boolean;
|
||||
type: ReverseProxyDomainType;
|
||||
target_cluster?: string;
|
||||
supports_custom_ports?: boolean;
|
||||
require_subdomain?: boolean;
|
||||
supports_crowdsec?: boolean;
|
||||
}
|
||||
|
||||
export enum ReverseProxyDomainType {
|
||||
@@ -80,6 +129,15 @@ export enum ReverseProxyTargetType {
|
||||
export enum ReverseProxyTargetProtocol {
|
||||
HTTP = "http",
|
||||
HTTPS = "https",
|
||||
TCP = "tcp",
|
||||
UDP = "udp",
|
||||
}
|
||||
|
||||
export enum EventProtocol {
|
||||
HTTP = "http",
|
||||
TCP = "tcp",
|
||||
UDP = "udp",
|
||||
TLS = "tls",
|
||||
}
|
||||
|
||||
export interface ReverseProxyEvent {
|
||||
@@ -97,12 +155,33 @@ 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 {
|
||||
return (
|
||||
event.protocol === EventProtocol.TCP ||
|
||||
event.protocol === EventProtocol.UDP ||
|
||||
event.protocol === EventProtocol.TLS
|
||||
);
|
||||
}
|
||||
|
||||
export interface ReverseProxyFlatTarget extends ReverseProxyTarget {
|
||||
proxy: ReverseProxy;
|
||||
}
|
||||
|
||||
export function isL4Mode(mode?: ServiceMode): boolean {
|
||||
return (
|
||||
mode === ServiceMode.TCP ||
|
||||
mode === ServiceMode.UDP ||
|
||||
mode === ServiceMode.TLS
|
||||
);
|
||||
}
|
||||
|
||||
export const REVERSE_PROXY_DOCS_LINK =
|
||||
"https://docs.netbird.io/manage/reverse-proxy";
|
||||
|
||||
@@ -129,3 +208,9 @@ 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";
|
||||
|
||||
@@ -46,6 +46,7 @@ import React, { useMemo, useState } from "react";
|
||||
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import { Policy, PolicyRuleResource, Protocol } from "@/interfaces/Policy";
|
||||
import { PostureCheck } from "@/interfaces/PostureCheck";
|
||||
import { useAccessControl } from "@/modules/access-control/useAccessControl";
|
||||
@@ -54,6 +55,7 @@ import { PostureCheckTabTrigger } from "@/modules/posture-checks/ui/PostureCheck
|
||||
import { SSHAccessType } from "@/modules/access-control/ssh/SSHAccessType";
|
||||
import { SSHAuthorizedGroups } from "@/modules/access-control/ssh/SSHAuthorizedGroups";
|
||||
import { useUsers } from "@/contexts/UsersProvider";
|
||||
import { HelpTooltip } from "@components/HelpTooltip";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
@@ -124,6 +126,8 @@ type ModalProps = {
|
||||
initialPorts?: number[];
|
||||
initialDestinationResource?: PolicyRuleResource;
|
||||
initialTab?: string;
|
||||
disableDestinationSelector?: boolean;
|
||||
additionalResources?: NetworkResource[];
|
||||
};
|
||||
|
||||
export function AccessControlModalContent({
|
||||
@@ -140,6 +144,8 @@ export function AccessControlModalContent({
|
||||
initialPorts,
|
||||
initialDestinationResource,
|
||||
initialTab,
|
||||
disableDestinationSelector = false,
|
||||
additionalResources,
|
||||
}: Readonly<ModalProps>) {
|
||||
const { permission } = usePermissions();
|
||||
const { users } = useUsers();
|
||||
@@ -293,7 +299,25 @@ export function AccessControlModalContent({
|
||||
<SelectItem value="tcp">TCP</SelectItem>
|
||||
<SelectItem value="udp">UDP</SelectItem>
|
||||
<SelectItem value="icmp">ICMP</SelectItem>
|
||||
<SelectItem value="netbird-ssh">NetBird SSH</SelectItem>
|
||||
<SelectItem
|
||||
value="netbird-ssh"
|
||||
extra={
|
||||
<HelpTooltip
|
||||
triggerClassName={"ml-[0.01rem]"}
|
||||
align={"center"}
|
||||
side={"right"}
|
||||
content={
|
||||
<>
|
||||
Select NetBird SSH for SSH-specific policies with
|
||||
fine-grained access control, or use TCP with port 22
|
||||
for basic network-level SSH access
|
||||
</>
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
NetBird SSH
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -303,6 +327,15 @@ export function AccessControlModalContent({
|
||||
<Label className={"mb-2"}>
|
||||
<FolderDown size={15} />
|
||||
Source
|
||||
<HelpTooltip
|
||||
content={
|
||||
<>
|
||||
Typically a group of user devices (e.g., Developers,
|
||||
Marketing) or individual devices in peer-to-peer
|
||||
connections that will access the destination.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Label>
|
||||
<PeerGroupSelector
|
||||
dataCy={"source-group-selector"}
|
||||
@@ -337,6 +370,15 @@ export function AccessControlModalContent({
|
||||
<Label className={"mb-2"}>
|
||||
<FolderInput size={15} />
|
||||
Destination
|
||||
<HelpTooltip
|
||||
content={
|
||||
<>
|
||||
Typically a group of peers or resources (e.g., Servers,
|
||||
Databases, Internal Services) that will be accessed by
|
||||
the source. Can also be an individual peer or resource.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Label>
|
||||
<PeerGroupSelector
|
||||
dataCy={"destination-group-selector"}
|
||||
@@ -353,8 +395,11 @@ export function AccessControlModalContent({
|
||||
resource={destinationResource}
|
||||
onResourceChange={setDestinationResource}
|
||||
saveGroupAssignments={useSave}
|
||||
additionalResources={additionalResources}
|
||||
disabled={
|
||||
!permission.policies.update || !permission.policies.create
|
||||
disableDestinationSelector ||
|
||||
!permission.policies.update ||
|
||||
!permission.policies.create
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -575,7 +620,13 @@ export function AccessControlModalContent({
|
||||
<Button
|
||||
variant={"primary"}
|
||||
disabled={submitDisabled || !permission.policies.create}
|
||||
onClick={submit}
|
||||
onClick={() => {
|
||||
if (useSave) {
|
||||
submit();
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
}}
|
||||
data-cy={"submit-policy"}
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
|
||||
@@ -1,33 +1,19 @@
|
||||
import Button from "@components/Button";
|
||||
import { notify } from "@components/Notification";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { useDialog } from "@/contexts/DialogProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { Route } from "@/interfaces/Route";
|
||||
import { usePolicies } from "@/contexts/PoliciesProvider";
|
||||
|
||||
type Props = {
|
||||
policy: Policy;
|
||||
};
|
||||
|
||||
export default function AccessControlActionCell({ policy }: Readonly<Props>) {
|
||||
const { confirm } = useDialog();
|
||||
const policyRequest = useApiCall<Route>("/policies");
|
||||
const { mutate } = useSWRConfig();
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const deleteRule = async () => {
|
||||
notify({
|
||||
title: "Access Control Policy " + policy.name,
|
||||
description: "The policy was successfully removed.",
|
||||
promise: policyRequest.del("", `/${policy.id}`).then(() => {
|
||||
mutate("/policies");
|
||||
}),
|
||||
loadingMessage: "Deleting the policy...",
|
||||
});
|
||||
};
|
||||
const { deletePolicy } = usePolicies();
|
||||
|
||||
const openConfirm = async () => {
|
||||
const choice = await confirm({
|
||||
@@ -39,7 +25,7 @@ export default function AccessControlActionCell({ policy }: Readonly<Props>) {
|
||||
type: "danger",
|
||||
});
|
||||
if (!choice) return;
|
||||
deleteRule().then();
|
||||
await deletePolicy(policy);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { ToggleSwitch } from "@components/ToggleSwitch";
|
||||
import { cloneDeep } from "@utils/helpers";
|
||||
import React, { useMemo } from "react";
|
||||
import { mutate } from "swr";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { usePolicies } from "@/contexts/PoliciesProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
|
||||
type Props = {
|
||||
policy: Policy;
|
||||
};
|
||||
export default function AccessControlActiveCell({ policy }: Readonly<Props>) {
|
||||
const { updatePolicy } = usePolicies();
|
||||
const { updatePolicy, serializeRules } = usePolicies();
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const isChecked = useMemo(() => {
|
||||
@@ -19,32 +17,9 @@ export default function AccessControlActiveCell({ policy }: Readonly<Props>) {
|
||||
}, [policy]);
|
||||
|
||||
const update = async (enabled: boolean) => {
|
||||
const rules = cloneDeep(policy.rules);
|
||||
rules.forEach((rule) => {
|
||||
rule.enabled = enabled;
|
||||
rule.sources = rule.sources
|
||||
? (rule.sources.map((source) => {
|
||||
const group = source as Group;
|
||||
return group.id;
|
||||
}) as string[])
|
||||
: [];
|
||||
rule.destinations = rule.destinations
|
||||
? (rule.destinations.map((destination) => {
|
||||
const group = destination as Group;
|
||||
return group.id;
|
||||
}) as string[])
|
||||
: [];
|
||||
if (rule.destinationResource) {
|
||||
rule.destinations = null;
|
||||
}
|
||||
if (rule.sourceResource) {
|
||||
rule.sources = null;
|
||||
}
|
||||
});
|
||||
|
||||
updatePolicy(
|
||||
policy,
|
||||
{ enabled, rules },
|
||||
{ enabled, rules: serializeRules(policy.rules, enabled) },
|
||||
() => {
|
||||
mutate("/policies");
|
||||
},
|
||||
|
||||
@@ -11,9 +11,13 @@ import { parsePortsToStrings } from "@/modules/access-control/useAccessControl";
|
||||
|
||||
type Props = {
|
||||
policy: Policy;
|
||||
visiblePorts?: number;
|
||||
};
|
||||
|
||||
export default function AccessControlPortsCell({ policy }: Readonly<Props>) {
|
||||
export default function AccessControlPortsCell({
|
||||
policy,
|
||||
visiblePorts = 2,
|
||||
}: Readonly<Props>) {
|
||||
const rule = useMemo(() => {
|
||||
if (policy.rules.length > 0) return policy.rules[0];
|
||||
return undefined;
|
||||
@@ -25,13 +29,13 @@ export default function AccessControlPortsCell({ policy }: Readonly<Props>) {
|
||||
|
||||
const allPorts = useMemo(() => parsePortsToStrings(rule), [rule]);
|
||||
|
||||
const firstTwoPorts = useMemo(() => {
|
||||
return allPorts?.slice(0, 2) ?? [];
|
||||
}, [allPorts]);
|
||||
const visiblePortsList = useMemo(() => {
|
||||
return allPorts?.slice(0, visiblePorts) ?? [];
|
||||
}, [allPorts, visiblePorts]);
|
||||
|
||||
const otherPorts = useMemo(() => {
|
||||
return allPorts?.slice(2) ?? [];
|
||||
}, [allPorts]);
|
||||
return allPorts?.slice(visiblePorts) ?? [];
|
||||
}, [allPorts, visiblePorts]);
|
||||
|
||||
return (
|
||||
<div className={"flex-1"}>
|
||||
@@ -48,7 +52,7 @@ export default function AccessControlPortsCell({ policy }: Readonly<Props>) {
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{firstTwoPorts?.map((port) => {
|
||||
{visiblePortsList?.map((port) => {
|
||||
return (
|
||||
<Badge
|
||||
key={port}
|
||||
@@ -75,12 +79,8 @@ export default function AccessControlPortsCell({ policy }: Readonly<Props>) {
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{otherPorts && otherPorts.length > 0 && (
|
||||
<TooltipContent>
|
||||
<div
|
||||
className={
|
||||
"flex gap-2 items-start mt-3 mb-2 flex-wrap max-w-sm"
|
||||
}
|
||||
>
|
||||
<TooltipContent className={"p-3"}>
|
||||
<div className={"flex gap-2 items-start flex-wrap max-w-sm"}>
|
||||
{otherPorts.map((port) => {
|
||||
return (
|
||||
<Badge key={port} variant={"gray"}>
|
||||
|
||||
@@ -11,9 +11,15 @@ import EmptyRow from "@/modules/common-table-rows/EmptyRow";
|
||||
|
||||
type Props = {
|
||||
policy: Policy;
|
||||
hideEdit?: boolean;
|
||||
disableRedirect?: boolean;
|
||||
};
|
||||
|
||||
export default function AccessControlSourcesCell({ policy }: Props) {
|
||||
export default function AccessControlSourcesCell({
|
||||
policy,
|
||||
hideEdit = false,
|
||||
disableRedirect = false,
|
||||
}: Props) {
|
||||
const { permission } = usePermissions();
|
||||
const canUpdate = permission?.policies?.update;
|
||||
|
||||
@@ -27,12 +33,18 @@ export default function AccessControlSourcesCell({ policy }: Props) {
|
||||
}
|
||||
|
||||
return firstRule ? (
|
||||
<div className={cn("flex items-center gap-1", canUpdate && "group")}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1",
|
||||
canUpdate && !hideEdit && "group",
|
||||
)}
|
||||
>
|
||||
<MultipleGroups
|
||||
groups={firstRule.sources as Group[]}
|
||||
showUsers={firstRule.protocol === "netbird-ssh"}
|
||||
disableRedirect={disableRedirect}
|
||||
/>
|
||||
{canUpdate && <TransparentEditIconButton />}
|
||||
{canUpdate && !hideEdit && <TransparentEditIconButton />}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyRow />
|
||||
|
||||
@@ -664,6 +664,35 @@ export default function ActivityDescription({ event }: Props) {
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Reverse Proxy
|
||||
*/
|
||||
|
||||
if (event.activity_code == "service.peer.expose")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Peer <Value>{m.peer_name}</Value> exposed service{" "}
|
||||
<Value>{m.domain}</Value> with auth{" "}
|
||||
<Value>{m.auth ? "Enabled" : "Disabled"}</Value>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (event.activity_code == "service.peer.unexpose")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Peer <Value>{m.peer_name}</Value> unexposed service{" "}
|
||||
<Value>{m.domain}</Value>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (event.activity_code == "service.peer.expose.expire")
|
||||
return (
|
||||
<div className={"inline"}>
|
||||
Service <Value>{m.domain}</Value> exposed by peer{" "}
|
||||
<Value>{m.peer_name}</Value> was removed due to renewal expiration
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Networks
|
||||
*/
|
||||
@@ -792,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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)} />;
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ export default function DNSRecordsTable({ zone }: Props) {
|
||||
className={"bg-nb-gray-960 py-2"}
|
||||
inset={true}
|
||||
text={"DNS Records"}
|
||||
initialPageSize={zone?.records?.length}
|
||||
manualPagination={true}
|
||||
sorting={sorting}
|
||||
columnVisibility={{}}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ArrowUpRightIcon, Layers3Icon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { NetworkAccessControlProvider } from "@/modules/networks/NetworkAccessControlProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { NetworkResourceWithNetwork } from "@/interfaces/Network";
|
||||
@@ -115,67 +116,70 @@ export const GroupResourcesSection = ({
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
return (
|
||||
<GroupDetailsTableContainer>
|
||||
<DataTable
|
||||
wrapperComponent={Card}
|
||||
wrapperProps={{ className: "mt-6 pb-2 w-full" }}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
minimal={true}
|
||||
isLoading={isLoading}
|
||||
showSearchAndFilters={true}
|
||||
renderRow={(row, children) => (
|
||||
<NetworkProvider
|
||||
network={row.network}
|
||||
onResourceUpdate={() => mutate("/networks/resources")}
|
||||
onResourceDelete={() => mutate("/networks/resources")}
|
||||
>
|
||||
{children}
|
||||
</NetworkProvider>
|
||||
)}
|
||||
inset={false}
|
||||
tableClassName={"mt-0"}
|
||||
text={"Resources"}
|
||||
columns={GroupResourcesColumns}
|
||||
keepStateInLocalStorage={false}
|
||||
data={resources}
|
||||
searchPlaceholder={"Search by name, address or group..."}
|
||||
getStartedCard={
|
||||
<NoResults
|
||||
className={"py-4"}
|
||||
title={"This group has no assigned resources"}
|
||||
description={
|
||||
"Assign this group to your resources inside your networks to see them listed here."
|
||||
}
|
||||
icon={<Layers3Icon size={20} />}
|
||||
>
|
||||
{permission?.networks?.create && (
|
||||
<>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={"mt-4"}
|
||||
onClick={() => router.push("/networks")}
|
||||
>
|
||||
Go to Networks
|
||||
<ArrowUpRightIcon size={16} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</NoResults>
|
||||
}
|
||||
columnVisibility={{
|
||||
description: false,
|
||||
id: false,
|
||||
}}
|
||||
paginationPaddingClassName={"px-0 pt-8"}
|
||||
>
|
||||
{(table) => (
|
||||
<DataTableRowsPerPage
|
||||
table={table}
|
||||
disabled={!resources || resources?.length == 0}
|
||||
/>
|
||||
)}
|
||||
</DataTable>
|
||||
</GroupDetailsTableContainer>
|
||||
<NetworkAccessControlProvider>
|
||||
<GroupDetailsTableContainer>
|
||||
<DataTable
|
||||
wrapperComponent={Card}
|
||||
wrapperProps={{ className: "mt-6 pb-2 w-full" }}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
minimal={true}
|
||||
isLoading={isLoading}
|
||||
showSearchAndFilters={true}
|
||||
renderRow={(row, children) => (
|
||||
<NetworkProvider
|
||||
key={row.network.id + row.name}
|
||||
network={row.network}
|
||||
onResourceUpdate={() => mutate("/networks/resources")}
|
||||
onResourceDelete={() => mutate("/networks/resources")}
|
||||
>
|
||||
{children}
|
||||
</NetworkProvider>
|
||||
)}
|
||||
inset={false}
|
||||
tableClassName={"mt-0"}
|
||||
text={"Resources"}
|
||||
columns={GroupResourcesColumns}
|
||||
keepStateInLocalStorage={false}
|
||||
data={resources}
|
||||
searchPlaceholder={"Search by name, address or group..."}
|
||||
getStartedCard={
|
||||
<NoResults
|
||||
className={"py-4"}
|
||||
title={"This group has no assigned resources"}
|
||||
description={
|
||||
"Assign this group to your resources inside your networks to see them listed here."
|
||||
}
|
||||
icon={<Layers3Icon size={20} />}
|
||||
>
|
||||
{permission?.networks?.create && (
|
||||
<>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={"mt-4"}
|
||||
onClick={() => router.push("/networks")}
|
||||
>
|
||||
Go to Networks
|
||||
<ArrowUpRightIcon size={16} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</NoResults>
|
||||
}
|
||||
columnVisibility={{
|
||||
description: false,
|
||||
id: false,
|
||||
}}
|
||||
paginationPaddingClassName={"px-0 pt-8"}
|
||||
>
|
||||
{(table) => (
|
||||
<DataTableRowsPerPage
|
||||
table={table}
|
||||
disabled={!resources || resources?.length == 0}
|
||||
/>
|
||||
)}
|
||||
</DataTable>
|
||||
</GroupDetailsTableContainer>
|
||||
</NetworkAccessControlProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -240,7 +240,7 @@ export const GroupsTableColumns: ColumnDef<GroupUsage>[] = [
|
||||
row.setup_keys_count > 0 ||
|
||||
row.users_count > 0 ||
|
||||
row.resources_count > 0 ||
|
||||
row.zones_count
|
||||
row.zones_count > 0
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
151
src/modules/networks/NetworkAccessControlProvider.tsx
Normal file
151
src/modules/networks/NetworkAccessControlProvider.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import useFetchApi from "@utils/api";
|
||||
import { orderBy } from "lodash";
|
||||
import * as React from "react";
|
||||
import { useCallback, useContext } from "react";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
|
||||
type NetworkAccessControlContextValue = {
|
||||
policies?: Policy[];
|
||||
policiesLoading: boolean;
|
||||
resources?: NetworkResource[];
|
||||
assignedPolicies: (
|
||||
resource?: NetworkResource,
|
||||
groups?: Group[],
|
||||
) => {
|
||||
policies: Policy[];
|
||||
enabledPolicies: Policy[];
|
||||
isLoading: boolean;
|
||||
policyCount: number;
|
||||
};
|
||||
resourceExists: (name: string, excludeId?: string) => boolean;
|
||||
getPolicyDestinationResources: (policy: Policy) => NetworkResource[];
|
||||
};
|
||||
|
||||
const NetworkAccessControlContext =
|
||||
React.createContext<NetworkAccessControlContextValue | null>(null);
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
const toGroupId = (g: Group | string): string | undefined =>
|
||||
typeof g === "string" ? g : g?.id;
|
||||
|
||||
export const NetworkAccessControlProvider = ({ children }: Props) => {
|
||||
const { data: policies, isLoading: policiesLoading } =
|
||||
useFetchApi<Policy[]>("/policies");
|
||||
const { data: resources } = useFetchApi<NetworkResource[]>(
|
||||
"/networks/resources",
|
||||
);
|
||||
|
||||
const resourceExists = useCallback(
|
||||
(name: string, excludeId?: string) => {
|
||||
if (!name) return false;
|
||||
return !!resources?.find(
|
||||
(r) =>
|
||||
r.name.toLowerCase() === name.toLowerCase() && r.id !== excludeId,
|
||||
);
|
||||
},
|
||||
[resources],
|
||||
);
|
||||
|
||||
const assignedPolicies = useCallback(
|
||||
(resource?: NetworkResource, groups?: Group[]) => {
|
||||
const resourceGroups = (groups || resource?.groups) as
|
||||
| (Group | string)[]
|
||||
| undefined;
|
||||
if (!resource && !resourceGroups?.length) {
|
||||
return {
|
||||
policies: [],
|
||||
enabledPolicies: [],
|
||||
isLoading: policiesLoading,
|
||||
policyCount: 0,
|
||||
};
|
||||
}
|
||||
const resourceGroupIds = new Set(
|
||||
resourceGroups?.map(toGroupId).filter(Boolean),
|
||||
);
|
||||
const resourcePolicies = orderBy(
|
||||
policies?.filter((policy) => {
|
||||
const rule = policy.rules?.[0];
|
||||
if (!rule) return false;
|
||||
if (resource && rule.destinationResource?.id === resource.id)
|
||||
return true;
|
||||
const destinations = (rule.destinations ?? []) as (Group | string)[];
|
||||
return destinations.some((d) => {
|
||||
const destId = toGroupId(d);
|
||||
return !!destId && resourceGroupIds.has(destId);
|
||||
});
|
||||
}),
|
||||
"enabled",
|
||||
"desc",
|
||||
);
|
||||
const enabledPolicies = resourcePolicies?.filter(
|
||||
(policy) => policy?.enabled,
|
||||
);
|
||||
return {
|
||||
policies: resourcePolicies,
|
||||
enabledPolicies,
|
||||
isLoading: policiesLoading,
|
||||
policyCount: resourcePolicies?.length || 0,
|
||||
};
|
||||
},
|
||||
[policies, policiesLoading],
|
||||
);
|
||||
|
||||
const getPolicyDestinationResources = useCallback(
|
||||
(policy: Policy): NetworkResource[] => {
|
||||
const rule = policy?.rules?.[0];
|
||||
const destinationGroups = rule?.destinations as
|
||||
| (Group | string)[]
|
||||
| undefined;
|
||||
const destinationGroupIds = new Set(
|
||||
destinationGroups?.map(toGroupId).filter(Boolean),
|
||||
);
|
||||
const directDestinationId = rule?.destinationResource?.id;
|
||||
|
||||
return (
|
||||
resources?.filter((resource) => {
|
||||
if (directDestinationId && resource.id === directDestinationId)
|
||||
return true;
|
||||
const resourceGroups = resource.groups as
|
||||
| (Group | string)[]
|
||||
| undefined;
|
||||
return resourceGroups?.some((g) => {
|
||||
const groupId = toGroupId(g);
|
||||
return !!groupId && destinationGroupIds.has(groupId);
|
||||
});
|
||||
}) ?? []
|
||||
);
|
||||
},
|
||||
[resources],
|
||||
);
|
||||
|
||||
return (
|
||||
<NetworkAccessControlContext.Provider
|
||||
value={{
|
||||
policies,
|
||||
policiesLoading,
|
||||
resources,
|
||||
assignedPolicies,
|
||||
resourceExists,
|
||||
getPolicyDestinationResources,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</NetworkAccessControlContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useNetworkAccessControl =
|
||||
(): NetworkAccessControlContextValue => {
|
||||
const context = useContext(NetworkAccessControlContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"useNetworkAccessControl must be used within a NetworkAccessControlProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { useDialog } from "@/contexts/DialogProvider";
|
||||
import { useNetworkAccessControl } from "@/modules/networks/NetworkAccessControlProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { Network, NetworkResource, NetworkRouter } from "@/interfaces/Network";
|
||||
import { AccessControlModalContent } from "@/modules/access-control/AccessControlModal";
|
||||
@@ -14,6 +15,9 @@ import { ResourceGroupModal } from "@/modules/networks/resources/ResourceGroupMo
|
||||
import NetworkRoutingPeerModal from "@/modules/networks/routing-peers/NetworkRoutingPeerModal";
|
||||
import { Policy, PolicyRuleResource } from "@/interfaces/Policy";
|
||||
import PoliciesProvider from "@/contexts/PoliciesProvider";
|
||||
import { ResourceIcon } from "@/assets/icons/ResourceIcon";
|
||||
import CopyToClipboardText from "@components/CopyToClipboardText";
|
||||
import { cn } from "@utils/helpers";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
@@ -27,7 +31,11 @@ const NetworksContext = React.createContext(
|
||||
openAddRoutingPeerModal: (network: Network, router?: NetworkRouter) => void;
|
||||
openEditNetworkModal: (network: Network) => void;
|
||||
openCreateNetworkModal: () => void;
|
||||
openResourceModal: (network: Network, resource?: NetworkResource) => void;
|
||||
openResourceModal: (
|
||||
network: Network,
|
||||
resource?: NetworkResource,
|
||||
initialTab?: string,
|
||||
) => void;
|
||||
openResourceGroupModal: (
|
||||
network: Network,
|
||||
resource?: NetworkResource,
|
||||
@@ -38,6 +46,24 @@ const NetworksContext = React.createContext(
|
||||
deleteResource: (network: Network, resource: NetworkResource) => void;
|
||||
deleteRouter: (network: Network, router: NetworkRouter) => void;
|
||||
network?: Network;
|
||||
assignedPolicies: (
|
||||
resource?: NetworkResource,
|
||||
groups?: Group[],
|
||||
) => {
|
||||
policies: Policy[];
|
||||
enabledPolicies: Policy[];
|
||||
isLoading: boolean;
|
||||
policyCount: number;
|
||||
};
|
||||
resourceExists: (name: string, excludeId?: string) => boolean;
|
||||
resources?: NetworkResource[];
|
||||
getPolicyDestinationResources: (policy: Policy) => NetworkResource[];
|
||||
confirmMultiResourceAction: (
|
||||
policy: Policy,
|
||||
action: "edit" | "delete",
|
||||
additionalResource?: NetworkResource,
|
||||
) => Promise<boolean>;
|
||||
policies?: Policy[];
|
||||
},
|
||||
);
|
||||
|
||||
@@ -50,6 +76,13 @@ export const NetworkProvider = ({
|
||||
const { mutate } = useSWRConfig();
|
||||
const { confirm } = useDialog();
|
||||
const deleteCall = useApiCall("/networks").del;
|
||||
const {
|
||||
policies,
|
||||
resources,
|
||||
assignedPolicies,
|
||||
resourceExists,
|
||||
getPolicyDestinationResources,
|
||||
} = useNetworkAccessControl();
|
||||
|
||||
const [currentNetwork, setCurrentNetwork] = useState<Network>();
|
||||
const [currentResource, setCurrentResource] = useState<NetworkResource>();
|
||||
@@ -88,9 +121,18 @@ export const NetworkProvider = ({
|
||||
setNetworkModal(true);
|
||||
};
|
||||
|
||||
const openResourceModal = (network: Network, resource?: NetworkResource) => {
|
||||
const [resourceModalInitialTab, setResourceModalInitialTab] = useState<
|
||||
string | undefined
|
||||
>();
|
||||
|
||||
const openResourceModal = (
|
||||
network: Network,
|
||||
resource?: NetworkResource,
|
||||
initialTab?: string,
|
||||
) => {
|
||||
setCurrentNetwork(network);
|
||||
resource && setCurrentResource(resource);
|
||||
setResourceModalInitialTab(initialTab);
|
||||
setResourceModal(true);
|
||||
};
|
||||
|
||||
@@ -110,11 +152,11 @@ export const NetworkProvider = ({
|
||||
destinationResource: hasResourceGroups
|
||||
? undefined
|
||||
: resource
|
||||
? ({
|
||||
id: resource.id,
|
||||
type: resource.type,
|
||||
} as PolicyRuleResource)
|
||||
: undefined,
|
||||
? ({
|
||||
id: resource.id,
|
||||
type: resource.type,
|
||||
} as PolicyRuleResource)
|
||||
: undefined,
|
||||
name:
|
||||
network && !resource
|
||||
? `${network?.name} Policy`
|
||||
@@ -138,6 +180,46 @@ export const NetworkProvider = ({
|
||||
setPolicyModal(true);
|
||||
};
|
||||
|
||||
const confirmMultiResourceAction = async (
|
||||
policy: Policy,
|
||||
action: "edit" | "delete",
|
||||
additionalResource?: NetworkResource,
|
||||
) => {
|
||||
const fetchedResources = getPolicyDestinationResources(policy);
|
||||
const affectedResources =
|
||||
additionalResource &&
|
||||
!fetchedResources.some((r) => r.id === additionalResource.id)
|
||||
? [...fetchedResources, additionalResource]
|
||||
: fetchedResources;
|
||||
const isMulti = affectedResources.length > 1;
|
||||
if (!isMulti && action === "edit") return true;
|
||||
return confirm({
|
||||
title: isMulti ? (
|
||||
<>This policy is used by multiple resources</>
|
||||
) : (
|
||||
<>
|
||||
{action === "edit" ? "Edit" : "Delete"} policy '{policy.name}
|
||||
'?
|
||||
</>
|
||||
),
|
||||
description: isMulti
|
||||
? `This policy uses one or many resource group(s) as destinations. ${
|
||||
action === "edit" ? "Updating" : "Deleting"
|
||||
} this policy will also affect following resources:`
|
||||
: action === "delete"
|
||||
? "Are you sure you want to delete this policy? This action cannot be undone."
|
||||
: undefined,
|
||||
children: isMulti ? (
|
||||
<AffectedResourceList resources={affectedResources} />
|
||||
) : undefined,
|
||||
confirmText: action === "edit" ? "Edit Policy" : "Delete Policy",
|
||||
cancelText: "Cancel",
|
||||
hideIcon: isMulti,
|
||||
type: action === "edit" ? "warning" : "danger",
|
||||
maxWidthClass: isMulti ? "max-w-lg" : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const deleteNetwork = async (network: Network) => {
|
||||
const choice = await confirm({
|
||||
title: `Delete network '${network.name}'?`,
|
||||
@@ -244,19 +326,6 @@ export const NetworkProvider = ({
|
||||
openResourceModal(network);
|
||||
};
|
||||
|
||||
const askForAccessControlPolicy = async (res: NetworkResource) => {
|
||||
const choice = await confirm({
|
||||
title: `Add policy for '${res.name}'?`,
|
||||
description:
|
||||
"Without a policy, the resource will not be accessible by any peers. Create a policy to control access to this resource.",
|
||||
confirmText: "Create Policy",
|
||||
cancelText: "Later",
|
||||
type: "default",
|
||||
});
|
||||
if (!choice) return;
|
||||
openPolicyModal(currentNetwork, res);
|
||||
};
|
||||
|
||||
return (
|
||||
<NetworksContext.Provider
|
||||
value={{
|
||||
@@ -271,24 +340,30 @@ export const NetworkProvider = ({
|
||||
deleteResource,
|
||||
deleteRouter,
|
||||
network,
|
||||
assignedPolicies,
|
||||
resourceExists,
|
||||
resources,
|
||||
getPolicyDestinationResources,
|
||||
confirmMultiResourceAction,
|
||||
policies,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
<NetworkModal
|
||||
open={networkModal}
|
||||
setOpen={setNetworkModal}
|
||||
network={currentNetwork}
|
||||
onCreated={async (network) => {
|
||||
mutate("/networks");
|
||||
await askForResource(network);
|
||||
}}
|
||||
onUpdated={(n) => {
|
||||
mutate("/networks");
|
||||
mutate(`/networks/${n.id}`);
|
||||
}}
|
||||
/>
|
||||
<PoliciesProvider>
|
||||
{children}
|
||||
|
||||
<NetworkModal
|
||||
open={networkModal}
|
||||
setOpen={setNetworkModal}
|
||||
network={currentNetwork}
|
||||
onCreated={async (network) => {
|
||||
mutate("/networks");
|
||||
await askForResource(network);
|
||||
}}
|
||||
onUpdated={(n) => {
|
||||
mutate("/networks");
|
||||
mutate(`/networks/${n.id}`);
|
||||
}}
|
||||
/>
|
||||
<Modal
|
||||
open={policyModal}
|
||||
onOpenChange={(state) => {
|
||||
@@ -321,93 +396,99 @@ export const NetworkProvider = ({
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
{currentNetwork && (
|
||||
<>
|
||||
<NetworkRoutingPeerModal
|
||||
network={currentNetwork}
|
||||
router={currentRouter}
|
||||
open={routingPeerModal}
|
||||
onCreated={async () => {
|
||||
setRoutingPeerModal(false);
|
||||
setCurrentRouter(undefined);
|
||||
mutate(`/networks`);
|
||||
mutate("/groups");
|
||||
if (network) {
|
||||
mutate(`/networks/${currentNetwork.id}/routers`);
|
||||
mutate(`/networks/${network.id}`);
|
||||
}
|
||||
}}
|
||||
onUpdated={async () => {
|
||||
setRoutingPeerModal(false);
|
||||
setCurrentRouter(undefined);
|
||||
mutate(`/networks`);
|
||||
mutate("/groups");
|
||||
if (network) {
|
||||
mutate(`/networks/${network.id}`);
|
||||
mutate(`/networks/${currentNetwork.id}/routers`);
|
||||
}
|
||||
}}
|
||||
setOpen={(state) => {
|
||||
setCurrentRouter(undefined);
|
||||
setRoutingPeerModal(state);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ResourceGroupModal
|
||||
network={currentNetwork}
|
||||
resource={currentResource}
|
||||
open={resourceGroupModal}
|
||||
onOpenChange={(state) => {
|
||||
setCurrentResource(undefined);
|
||||
setResourceGroupModal(state);
|
||||
}}
|
||||
onUpdated={() => {
|
||||
setResourceGroupModal(false);
|
||||
setCurrentResource(undefined);
|
||||
mutate("/groups");
|
||||
mutate("/networks/resources");
|
||||
if (network) {
|
||||
onResourceUpdate?.();
|
||||
mutate(`/networks/${network.id}/resources`);
|
||||
mutate(`/networks/${network.id}`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<NetworkResourceModal
|
||||
network={currentNetwork}
|
||||
resource={currentResource}
|
||||
initialTab={resourceModalInitialTab}
|
||||
onCreated={async (r) => {
|
||||
setResourceModal(false);
|
||||
setCurrentResource(undefined);
|
||||
mutate("/networks");
|
||||
mutate("/groups");
|
||||
mutate("/networks/resources");
|
||||
if (network) {
|
||||
mutate(`/networks/${network.id}/resources`);
|
||||
mutate(`/networks/${network.id}`);
|
||||
} else {
|
||||
currentNetwork?.routing_peers_count === 0 &&
|
||||
(await askForRoutingPeer(currentNetwork));
|
||||
}
|
||||
}}
|
||||
onUpdated={() => {
|
||||
setResourceModal(false);
|
||||
setCurrentResource(undefined);
|
||||
mutate("/networks");
|
||||
mutate("/groups");
|
||||
mutate("/networks/resources");
|
||||
if (network) {
|
||||
onResourceUpdate?.();
|
||||
mutate(`/networks/${network.id}/resources`);
|
||||
mutate(`/networks/${network.id}`);
|
||||
}
|
||||
}}
|
||||
open={resourceModal}
|
||||
setOpen={(state) => {
|
||||
setCurrentResource(undefined);
|
||||
setResourceModalInitialTab(undefined);
|
||||
setResourceModal(state);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</PoliciesProvider>
|
||||
{currentNetwork && (
|
||||
<>
|
||||
<NetworkRoutingPeerModal
|
||||
network={currentNetwork}
|
||||
router={currentRouter}
|
||||
open={routingPeerModal}
|
||||
onCreated={async () => {
|
||||
setRoutingPeerModal(false);
|
||||
setCurrentRouter(undefined);
|
||||
mutate(`/networks`);
|
||||
mutate("/groups");
|
||||
if (network) {
|
||||
mutate(`/networks/${currentNetwork.id}/routers`);
|
||||
mutate(`/networks/${network.id}`);
|
||||
}
|
||||
}}
|
||||
onUpdated={async () => {
|
||||
setRoutingPeerModal(false);
|
||||
setCurrentRouter(undefined);
|
||||
mutate(`/networks`);
|
||||
mutate("/groups");
|
||||
if (network) {
|
||||
mutate(`/networks/${network.id}`);
|
||||
mutate(`/networks/${currentNetwork.id}/routers`);
|
||||
}
|
||||
}}
|
||||
setOpen={(state) => {
|
||||
setCurrentRouter(undefined);
|
||||
setRoutingPeerModal(state);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ResourceGroupModal
|
||||
network={currentNetwork}
|
||||
resource={currentResource}
|
||||
open={resourceGroupModal}
|
||||
onOpenChange={(state) => {
|
||||
setCurrentResource(undefined);
|
||||
setResourceGroupModal(state);
|
||||
}}
|
||||
onUpdated={() => {
|
||||
setResourceGroupModal(false);
|
||||
setCurrentResource(undefined);
|
||||
mutate("/groups");
|
||||
if (network) {
|
||||
onResourceUpdate?.();
|
||||
mutate(`/networks/${network.id}/resources`);
|
||||
mutate(`/networks/${network.id}`);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<NetworkResourceModal
|
||||
network={currentNetwork}
|
||||
resource={currentResource}
|
||||
onCreated={async (r) => {
|
||||
setResourceModal(false);
|
||||
setCurrentResource(undefined);
|
||||
mutate("/networks");
|
||||
mutate("/groups");
|
||||
if (network) {
|
||||
mutate(`/networks/${network.id}/resources`);
|
||||
mutate(`/networks/${network.id}`);
|
||||
} else {
|
||||
await askForAccessControlPolicy(r);
|
||||
}
|
||||
}}
|
||||
onUpdated={() => {
|
||||
setResourceModal(false);
|
||||
setCurrentResource(undefined);
|
||||
mutate("/networks");
|
||||
mutate("/groups");
|
||||
if (network) {
|
||||
onResourceUpdate?.();
|
||||
mutate(`/networks/${network.id}/resources`);
|
||||
mutate(`/networks/${network.id}`);
|
||||
}
|
||||
}}
|
||||
open={resourceModal}
|
||||
setOpen={(state) => {
|
||||
setCurrentResource(undefined);
|
||||
setResourceModal(state);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</NetworksContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -419,3 +500,37 @@ export const useNetworksContext = () => {
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
function AffectedResourceList({ resources }: { resources: NetworkResource[] }) {
|
||||
const maxVisible = 6;
|
||||
const visible = resources.slice(0, maxVisible);
|
||||
const remaining = resources.length - maxVisible;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-md bg-nb-gray-930 border border-nb-gray-900 text-xs mt-4",
|
||||
)}
|
||||
>
|
||||
{visible.map((r, i) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 px-3 py-2.5",
|
||||
i > 0 && "border-t border-nb-gray-900",
|
||||
)}
|
||||
>
|
||||
<ResourceIcon type={r.type || "host"} size={12} />
|
||||
<span className="font-medium text-nb-gray-200">{r.name}</span>
|
||||
<CopyToClipboardText className={"text-nb-gray-300"}>
|
||||
{r.address}
|
||||
</CopyToClipboardText>
|
||||
</div>
|
||||
))}
|
||||
{remaining > 0 && (
|
||||
<div className="border-t border-nb-gray-900 px-3 py-2 text-nb-gray-200">
|
||||
+ {remaining} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
308
src/modules/networks/resources/NetworkResourceAccessControl.tsx
Normal file
308
src/modules/networks/resources/NetworkResourceAccessControl.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import Button from "@components/Button";
|
||||
import HelpText from "@components/HelpText";
|
||||
import { Label } from "@components/Label";
|
||||
import { Modal } from "@components/modal/Modal";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@components/DropdownMenu";
|
||||
import { Edit2, MoreVertical, PlusIcon, Trash2 } from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import { Policy, PolicyRuleResource } from "@/interfaces/Policy";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { usePolicies } from "@/contexts/PoliciesProvider";
|
||||
import AccessControlSourcesCell from "@/modules/access-control/table/AccessControlSourcesCell";
|
||||
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
|
||||
import { AccessControlModalContent } from "@/modules/access-control/AccessControlModal";
|
||||
import CircleIcon from "@/assets/icons/CircleIcon";
|
||||
import AccessControlProtocolCell from "@/modules/access-control/table/AccessControlProtocolCell";
|
||||
import AccessControlPortsCell from "@/modules/access-control/table/AccessControlPortsCell";
|
||||
import TruncatedText from "@components/ui/TruncatedText";
|
||||
|
||||
type Props = {
|
||||
existingPolicies: Policy[];
|
||||
newPolicies: Policy[];
|
||||
onNewPoliciesChange: (policies: Policy[]) => void;
|
||||
address: string;
|
||||
resourceName?: string;
|
||||
resourceId?: string;
|
||||
hasResourceGroups?: boolean;
|
||||
};
|
||||
|
||||
function getResourceType(address: string): "domain" | "host" | "subnet" {
|
||||
const hasChars = !!address.match(/[a-z*]/i);
|
||||
const isCIDR = !!address.match(/\//);
|
||||
return hasChars ? "domain" : isCIDR ? "subnet" : "host";
|
||||
}
|
||||
|
||||
export default function NetworkResourceAccessControl({
|
||||
existingPolicies,
|
||||
newPolicies,
|
||||
onNewPoliciesChange,
|
||||
address,
|
||||
resourceName,
|
||||
resourceId,
|
||||
hasResourceGroups = false,
|
||||
}: Readonly<Props>) {
|
||||
const { network, confirmMultiResourceAction } = useNetworksContext();
|
||||
const { openEditPolicyModal, deletePolicy } = usePolicies();
|
||||
const [policyModalOpen, setPolicyModalOpen] = useState(false);
|
||||
const [editingPolicyIndex, setEditingPolicyIndex] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const allPolicies = useMemo(
|
||||
() => [...existingPolicies, ...newPolicies],
|
||||
[existingPolicies, newPolicies],
|
||||
);
|
||||
|
||||
const destinationResource: PolicyRuleResource = useMemo(() => {
|
||||
return {
|
||||
id: resourceId || resourceName || address,
|
||||
type: getResourceType(address),
|
||||
};
|
||||
}, [address, resourceName, resourceId]);
|
||||
|
||||
const currentResource = useMemo<NetworkResource>(() => {
|
||||
return {
|
||||
id: resourceId || resourceName || address,
|
||||
name: resourceName || address,
|
||||
address,
|
||||
type: getResourceType(address),
|
||||
enabled: true,
|
||||
};
|
||||
}, [resourceId, resourceName, address]);
|
||||
|
||||
const openAddPolicy = () => {
|
||||
setEditingPolicyIndex(null);
|
||||
setPolicyModalOpen(true);
|
||||
};
|
||||
|
||||
const openEditPolicy = async (policy: Policy) => {
|
||||
if (policy.id) {
|
||||
const confirm = await confirmMultiResourceAction(
|
||||
policy,
|
||||
"edit",
|
||||
currentResource,
|
||||
);
|
||||
if (!confirm) return;
|
||||
openEditPolicyModal(policy);
|
||||
} else {
|
||||
const idx = newPolicies.indexOf(policy);
|
||||
if (idx === -1) return;
|
||||
setEditingPolicyIndex(idx);
|
||||
setPolicyModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const savePolicy = (policy: Policy) => {
|
||||
if (editingPolicyIndex !== null) {
|
||||
onNewPoliciesChange(
|
||||
newPolicies.map((p, i) => (i === editingPolicyIndex ? policy : p)),
|
||||
);
|
||||
} else {
|
||||
onNewPoliciesChange([...newPolicies, policy]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePolicy = async (policy: Policy) => {
|
||||
const confirm = await confirmMultiResourceAction(
|
||||
policy,
|
||||
"delete",
|
||||
currentResource,
|
||||
);
|
||||
if (!confirm) return;
|
||||
if (policy.id) {
|
||||
await deletePolicy(policy);
|
||||
} else {
|
||||
onNewPoliciesChange(newPolicies.filter((p) => p !== policy));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={"px-8 flex-col flex gap-6"}>
|
||||
<div>
|
||||
<Label>Access Control Policies</Label>
|
||||
<HelpText>
|
||||
Define which source groups are allowed to access this resource. You
|
||||
can also restrict access to specific protocols and ports. Without
|
||||
policies access to this resource will not be possible.
|
||||
</HelpText>
|
||||
|
||||
{allPolicies.length > 0 && (
|
||||
<div
|
||||
className={
|
||||
"mt-3 mb-3 overflow-hidden border border-nb-gray-900 bg-nb-gray-920/30 py-1 px-1 rounded-md"
|
||||
}
|
||||
>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="py-2 px-4 text-left text-[11px] uppercase tracking-wider text-nb-gray-400 font-medium">
|
||||
Name
|
||||
</th>
|
||||
<th className="py-2 pl-5 pr-2 text-left text-[11px] uppercase tracking-wider text-nb-gray-400 font-medium">
|
||||
Source Groups
|
||||
</th>
|
||||
<th className="py-2 px-4 text-left text-[11px] uppercase tracking-wider text-nb-gray-400 font-medium">
|
||||
Protocol & Ports
|
||||
</th>
|
||||
<th className="py-2 pr-4 pl-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{allPolicies.map((policy, index) => {
|
||||
return (
|
||||
<tr
|
||||
key={policy.id || `new-${index}`}
|
||||
onClick={() => openEditPolicy(policy)}
|
||||
className="rounded-md hover:bg-nb-gray-900/30 cursor-pointer transition-all"
|
||||
>
|
||||
<td className="py-2.5 px-4 align-middle">
|
||||
<div
|
||||
className={
|
||||
"text-[13px] mt-1 flex items-center gap-2 leading-none font-medium text-nb-gray-300 group-hover:text-nb-gray-200 whitespace-nowrap"
|
||||
}
|
||||
>
|
||||
<div className={"self-start flex"}>
|
||||
<CircleIcon
|
||||
size={8}
|
||||
active={policy.enabled}
|
||||
className={cn("shrink-0 relative top-[5px]")}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
"flex flex-col items-start justify-start"
|
||||
}
|
||||
>
|
||||
<TruncatedText
|
||||
text={policy.name}
|
||||
maxWidth={"130px"}
|
||||
className={"leading-normal"}
|
||||
/>
|
||||
{policy.description && (
|
||||
<div className={"text-nb-gray-400 text-xs"}>
|
||||
<TruncatedText
|
||||
text={policy.description}
|
||||
maxWidth={"130px"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2.5 pl-5 pr-2 align-middle">
|
||||
<AccessControlSourcesCell
|
||||
policy={policy}
|
||||
hideEdit
|
||||
disableRedirect
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2.5 pl-3 pr-2 align-middle">
|
||||
<div className={"flex items-center gap-2"}>
|
||||
<AccessControlProtocolCell policy={policy} />
|
||||
<AccessControlPortsCell
|
||||
policy={policy}
|
||||
visiblePorts={1}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="py-2.5 pl-2 pr-3">
|
||||
<div
|
||||
className="flex items-center gap-6 justify-end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="default-outline"
|
||||
className="!px-3"
|
||||
>
|
||||
<MoreVertical size={16} className="shrink-0" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-auto min-w-[200px]"
|
||||
align="end"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => openEditPolicy(policy)}
|
||||
>
|
||||
<div className="flex gap-3 items-center">
|
||||
<Edit2 size={14} className="shrink-0" />
|
||||
Edit Policy
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant={"danger"}
|
||||
onClick={() => handleDeletePolicy(policy)}
|
||||
>
|
||||
<div className="flex gap-3 items-center">
|
||||
<Trash2 size={14} className="shrink-0" />
|
||||
Delete Policy
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="dotted"
|
||||
className={"w-full mt-1"}
|
||||
size="sm"
|
||||
onClick={openAddPolicy}
|
||||
>
|
||||
<PlusIcon size={14} />
|
||||
Add Policy
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={policyModalOpen}
|
||||
onOpenChange={(open) => {
|
||||
setPolicyModalOpen(open);
|
||||
if (!open) setEditingPolicyIndex(null);
|
||||
}}
|
||||
key={policyModalOpen ? 1 : 0}
|
||||
>
|
||||
<AccessControlModalContent
|
||||
useSave={false}
|
||||
policy={
|
||||
editingPolicyIndex !== null
|
||||
? newPolicies[editingPolicyIndex]
|
||||
: undefined
|
||||
}
|
||||
initialDestinationResource={
|
||||
editingPolicyIndex === null ? destinationResource : undefined
|
||||
}
|
||||
disableDestinationSelector={!hasResourceGroups}
|
||||
additionalResources={[currentResource]}
|
||||
initialName={`${resourceName || address} Access`}
|
||||
initialDescription={
|
||||
network?.description
|
||||
? `${network.name}, ${network.description}`
|
||||
: network?.name || ""
|
||||
}
|
||||
onSuccess={(policy) => {
|
||||
savePolicy(policy);
|
||||
setPolicyModalOpen(false);
|
||||
setEditingPolicyIndex(null);
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import Button from "@components/Button";
|
||||
import FancyToggleSwitch from "@components/FancyToggleSwitch";
|
||||
import { Callout } from "@components/Callout";
|
||||
import HelpText from "@components/HelpText";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import InlineLink, { InlineButtonLink } from "@components/InlineLink";
|
||||
import { Input } from "@components/Input";
|
||||
import { Label } from "@components/Label";
|
||||
import {
|
||||
@@ -15,18 +15,31 @@ import {
|
||||
import ModalHeader from "@components/modal/ModalHeader";
|
||||
import { notify } from "@components/Notification";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import { HelpTooltip } from "@components/HelpTooltip";
|
||||
import { PeerGroupSelector } from "@components/PeerGroupSelector";
|
||||
import Separator from "@components/Separator";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/Tabs";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import { useDialog } from "@/contexts/DialogProvider";
|
||||
import { usePolicies } from "@/contexts/PoliciesProvider";
|
||||
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
|
||||
import {
|
||||
ExternalLinkIcon,
|
||||
PlusCircle,
|
||||
Power,
|
||||
ShieldCheck,
|
||||
WorkflowIcon,
|
||||
} from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@components/Accordion";
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { Network, NetworkResource } from "@/interfaces/Network";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import useGroupHelper from "@/modules/groups/useGroupHelper";
|
||||
import NetworkResourceAccessControl from "@/modules/networks/resources/NetworkResourceAccessControl";
|
||||
import { ResourceSingleAddressInput } from "@/modules/networks/resources/ResourceSingleAddressInput";
|
||||
|
||||
type Props = {
|
||||
@@ -36,6 +49,7 @@ type Props = {
|
||||
resource?: NetworkResource;
|
||||
onCreated?: (r: NetworkResource) => void;
|
||||
onUpdated?: (r: NetworkResource) => void;
|
||||
initialTab?: string;
|
||||
};
|
||||
|
||||
export default function NetworkResourceModal({
|
||||
@@ -45,6 +59,7 @@ export default function NetworkResourceModal({
|
||||
resource,
|
||||
onUpdated,
|
||||
onCreated,
|
||||
initialTab,
|
||||
}: Props) {
|
||||
return (
|
||||
<Modal open={open} onOpenChange={setOpen}>
|
||||
@@ -54,6 +69,7 @@ export default function NetworkResourceModal({
|
||||
resource={resource}
|
||||
onCreated={onCreated}
|
||||
onUpdated={onUpdated}
|
||||
initialTab={initialTab}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
@@ -64,6 +80,7 @@ type ModalProps = {
|
||||
onUpdated?: (r: NetworkResource) => void;
|
||||
network: Network;
|
||||
resource?: NetworkResource;
|
||||
initialTab?: string;
|
||||
};
|
||||
|
||||
export function ResourceModalContent({
|
||||
@@ -71,6 +88,7 @@ export function ResourceModalContent({
|
||||
onUpdated,
|
||||
network,
|
||||
resource,
|
||||
initialTab,
|
||||
}: ModalProps) {
|
||||
const create = useApiCall<NetworkResource>(
|
||||
`/networks/${network.id}/resources`,
|
||||
@@ -88,50 +106,121 @@ export function ResourceModalContent({
|
||||
const [enabled, setEnabled] = useState<boolean>(
|
||||
resource ? resource.enabled : true,
|
||||
);
|
||||
const nameRef = useRef<HTMLInputElement>(null);
|
||||
const [tab, setTab] = useState(initialTab || "resource");
|
||||
const [addressError, setAddressError] = useState("");
|
||||
|
||||
const { confirm } = useDialog();
|
||||
|
||||
// Access control policies
|
||||
const [policies, setPolicies] = useState<Policy[]>([]);
|
||||
const { createPoliciesForResource } = usePolicies();
|
||||
const {
|
||||
assignedPolicies,
|
||||
resourceExists,
|
||||
policies: allPolicies,
|
||||
} = useNetworksContext();
|
||||
|
||||
const { policies: existingPolicies } = useMemo(
|
||||
() => assignedPolicies(resource, groups),
|
||||
[assignedPolicies, resource, groups],
|
||||
);
|
||||
|
||||
const allResourcePolicies = useMemo(() => {
|
||||
return [...(existingPolicies || []), ...policies];
|
||||
}, [existingPolicies, policies]);
|
||||
|
||||
const groupPolicyCount = useMemo(() => {
|
||||
if (!groups.length || !allPolicies) return 0;
|
||||
const groupIds = new Set(groups.map((g) => g.id));
|
||||
return allPolicies.filter((policy) => {
|
||||
const rule = policy.rules?.[0];
|
||||
if (!rule || rule.destinationResource) return false;
|
||||
const destinations = rule.destinations as (Group | string)[] | undefined;
|
||||
return destinations?.some((d) => {
|
||||
const id = typeof d === "string" ? d : d.id;
|
||||
return !!id && groupIds.has(id);
|
||||
});
|
||||
}).length;
|
||||
}, [groups, allPolicies]);
|
||||
|
||||
const isAddressValid = address.length > 0 && addressError === "";
|
||||
|
||||
const nameError = useMemo(() => {
|
||||
if (name === "") return "";
|
||||
if (resourceExists(name, resource?.id))
|
||||
return "A resource with this name already exists. Please use another name.";
|
||||
return "";
|
||||
}, [name, resourceExists, resource?.id]);
|
||||
|
||||
const confirmMissingPolicies = async () => {
|
||||
if (allResourcePolicies.length > 0) return true;
|
||||
return confirm({
|
||||
title: "No Access Control Policies Configured",
|
||||
description:
|
||||
"Without access control policies, this resource will not be accessible by any peers. You can also create policies later. Are you sure you want to continue?",
|
||||
type: "warning",
|
||||
confirmText: resource ? "Save Changes" : "Add Resource",
|
||||
cancelText: "Cancel",
|
||||
maxWidthClass: "max-w-lg",
|
||||
});
|
||||
};
|
||||
|
||||
const createResource = async () => {
|
||||
if (!(await confirmMissingPolicies())) return;
|
||||
const savedGroups = await saveGroups();
|
||||
const promise = create({
|
||||
name,
|
||||
description,
|
||||
address,
|
||||
groups: savedGroups ? savedGroups.map((g) => g.id) : undefined,
|
||||
enabled,
|
||||
}).then(async (r) => {
|
||||
await createPoliciesForResource(policies, r, savedGroups);
|
||||
onCreated?.(r);
|
||||
});
|
||||
|
||||
notify({
|
||||
title: "Resource Created",
|
||||
description: `The resource "${name}" has been created successfully.`,
|
||||
loadingMessage: "Creating resource...",
|
||||
promise: create({
|
||||
name,
|
||||
description,
|
||||
address,
|
||||
groups: savedGroups ? savedGroups.map((g) => g.id) : undefined,
|
||||
enabled,
|
||||
}).then((r) => {
|
||||
onCreated?.(r);
|
||||
}),
|
||||
promise,
|
||||
});
|
||||
|
||||
return promise;
|
||||
};
|
||||
|
||||
const updateResource = async () => {
|
||||
if (!(await confirmMissingPolicies())) return;
|
||||
const savedGroups = await saveGroups();
|
||||
const promise = update({
|
||||
name,
|
||||
description,
|
||||
address,
|
||||
groups: savedGroups ? savedGroups.map((g) => g.id) : undefined,
|
||||
enabled,
|
||||
}).then(async (r) => {
|
||||
await createPoliciesForResource(policies, r, savedGroups);
|
||||
onUpdated?.(r);
|
||||
});
|
||||
notify({
|
||||
title: "Resource Updated",
|
||||
description: `The resource "${name}" has been updated successfully.`,
|
||||
description: `Resource "${name}" has been updated successfully.`,
|
||||
loadingMessage: "Updating resource...",
|
||||
promise: update({
|
||||
name,
|
||||
description,
|
||||
address,
|
||||
groups: savedGroups ? savedGroups.map((g) => g.id) : undefined,
|
||||
enabled,
|
||||
}).then((r) => {
|
||||
onUpdated?.(r);
|
||||
}),
|
||||
promise,
|
||||
});
|
||||
};
|
||||
|
||||
// TODO: Address validation is missing for proper handling of submit button
|
||||
const canCreate = useMemo(() => {
|
||||
return name.length > 0 && address.length > 0;
|
||||
}, [name, address, groups]);
|
||||
return name.length > 0 && isAddressValid && nameError === "";
|
||||
}, [name, isAddressValid, nameError]);
|
||||
|
||||
return (
|
||||
<ModalContent maxWidthClass={"max-w-xl"}>
|
||||
<ModalContent
|
||||
maxWidthClass={
|
||||
tab === "access-control" ? "max-w-[790px]" : "max-w-[680px]"
|
||||
}
|
||||
>
|
||||
<ModalHeader
|
||||
icon={<WorkflowIcon size={20} />}
|
||||
title={resource ? "Edit Resource" : "Add Resource"}
|
||||
@@ -143,55 +232,164 @@ export function ResourceModalContent({
|
||||
color={"yellow"}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
<Tabs defaultValue={tab} onValueChange={(v) => setTab(v)} value={tab}>
|
||||
<TabsList justify={"start"} className={"px-8"}>
|
||||
<TabsTrigger value={"resource"}>
|
||||
<WorkflowIcon size={16} />
|
||||
Resource
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value={"access-control"}
|
||||
disabled={!resource && !canCreate}
|
||||
>
|
||||
<ShieldCheck size={16} />
|
||||
Access Control
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className={"px-8 flex-col flex gap-6 py-6"}>
|
||||
<div>
|
||||
<Label>Name</Label>
|
||||
<HelpText>Provide a name for your resource</HelpText>
|
||||
<Input
|
||||
tabIndex={0}
|
||||
placeholder={"e.g., Postgres Database"}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Description (optional)</Label>
|
||||
<HelpText>
|
||||
Write a short description to add more context to this resource.
|
||||
</HelpText>
|
||||
<Input
|
||||
placeholder={"e.g., Production, Development"}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<TabsContent value={"resource"} className={"pb-4"}>
|
||||
<div className={"px-8 flex-col flex gap-6"}>
|
||||
<div>
|
||||
<Label>Name</Label>
|
||||
<HelpText>
|
||||
Set an easily identifiable name for your resource
|
||||
</HelpText>
|
||||
<Input
|
||||
ref={nameRef}
|
||||
autoFocus={true}
|
||||
tabIndex={0}
|
||||
placeholder={"e.g., Postgres Database"}
|
||||
value={name}
|
||||
error={nameError}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<ResourceSingleAddressInput
|
||||
value={address}
|
||||
onChange={setAddress}
|
||||
onError={setAddressError}
|
||||
description={
|
||||
<>
|
||||
Enter a single{" "}
|
||||
<HelpTooltip
|
||||
content={
|
||||
"A single host address, e.g., 10.0.0.1 or 192.168.1.5. Use this to give access to a specific machine or service."
|
||||
}
|
||||
>
|
||||
IP Address
|
||||
</HelpTooltip>
|
||||
,{" "}
|
||||
<HelpTooltip
|
||||
content={
|
||||
"To give access to an entire subnet, use a CIDR block. For example, 10.0.0.0/24 or 192.168.1.0/24."
|
||||
}
|
||||
>
|
||||
CIDR Block
|
||||
</HelpTooltip>{" "}
|
||||
or{" "}
|
||||
<HelpTooltip
|
||||
content={
|
||||
"A DNS domain name, e.g., service.internal, example.com or *.example.com to match all subdomains."
|
||||
}
|
||||
>
|
||||
Domain Name
|
||||
</HelpTooltip>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<ResourceSingleAddressInput value={address} onChange={setAddress} />
|
||||
<Accordion
|
||||
type={"multiple"}
|
||||
className={"flex flex-col gap-2 -mt-2"}
|
||||
>
|
||||
<AccordionItem value={"resource-groups"}>
|
||||
<AccordionTrigger
|
||||
className={
|
||||
"text-[0.8rem] tracking-wider text-nb-gray-200 py-4 my-0 leading-none gap-2 flex items-center"
|
||||
}
|
||||
>
|
||||
<span className={"relative top-[1px]"}>
|
||||
Optional Settings
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className={""}>
|
||||
<div className={"flex flex-col gap-6 pb-4 pt-2"}>
|
||||
<div>
|
||||
<Label>Description</Label>
|
||||
<HelpText>
|
||||
Write a short description to add more context to this
|
||||
resource.
|
||||
</HelpText>
|
||||
<Input
|
||||
placeholder={"e.g., Production, Development"}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Resource Groups</Label>
|
||||
<HelpText className={"mt-1"}>
|
||||
Add this resource to a group (e.g., Databases, Web
|
||||
Servers) and reference the group <br /> in access
|
||||
policies to simplify management.
|
||||
</HelpText>
|
||||
<PeerGroupSelector
|
||||
side={"top"}
|
||||
onChange={setGroups}
|
||||
values={groups}
|
||||
showPeerCounter={false}
|
||||
placeholder={"Add or select resource group(s)..."}
|
||||
policies={allPolicies}
|
||||
/>
|
||||
{groupPolicyCount > 0 && (
|
||||
<Callout variant={"info"} className={"mt-3"}>
|
||||
Your selected resource groups are used in{" "}
|
||||
<span className="text-white font-medium">
|
||||
{groupPolicyCount} Access Control{" "}
|
||||
{groupPolicyCount === 1 ? "Policy" : "Policies"}
|
||||
</span>
|
||||
. This resource will inherit access from{" "}
|
||||
{groupPolicyCount === 1
|
||||
? "this policy"
|
||||
: "these policies"}
|
||||
.
|
||||
{isAddressValid || resource ? (
|
||||
<>
|
||||
{" "}
|
||||
Please review them in the{" "}
|
||||
<InlineButtonLink
|
||||
onClick={() => setTab("access-control")}
|
||||
variant={"dashed"}
|
||||
>
|
||||
Access Control
|
||||
</InlineButtonLink>{" "}
|
||||
tab.
|
||||
</>
|
||||
) : (
|
||||
" Please review them in the Access Control tab."
|
||||
)}
|
||||
</Callout>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<div>
|
||||
<Label>Destination Groups (optional)</Label>
|
||||
<HelpText>
|
||||
Add this resource to groups and use them as destinations when
|
||||
creating policies
|
||||
</HelpText>
|
||||
<PeerGroupSelector onChange={setGroups} values={groups} />
|
||||
</div>
|
||||
<div className={"mt-2 mb-2"}>
|
||||
<FancyToggleSwitch
|
||||
value={enabled}
|
||||
onChange={setEnabled}
|
||||
label={
|
||||
<>
|
||||
<Power size={15} />
|
||||
Enable Resource
|
||||
</>
|
||||
}
|
||||
helpText={"Use this switch to enable or disable the resource."}
|
||||
<TabsContent value={"access-control"} className={"pb-8"}>
|
||||
<NetworkResourceAccessControl
|
||||
existingPolicies={existingPolicies || []}
|
||||
newPolicies={policies}
|
||||
onNewPoliciesChange={setPolicies}
|
||||
address={address}
|
||||
resourceName={name}
|
||||
resourceId={resource?.id}
|
||||
hasResourceGroups={groups.length > 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<ModalFooter className={"items-center"}>
|
||||
<div className={"w-full"}>
|
||||
@@ -207,25 +405,58 @@ export function ResourceModalContent({
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className={"flex gap-3 w-full justify-end"}>
|
||||
<ModalClose asChild={true}>
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
</ModalClose>
|
||||
{!resource ? (
|
||||
<>
|
||||
{tab === "resource" && (
|
||||
<>
|
||||
<ModalClose asChild={true}>
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
</ModalClose>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
onClick={() => setTab("access-control")}
|
||||
disabled={!canCreate}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant={"primary"}
|
||||
data-cy={"submit-route"}
|
||||
onClick={resource ? updateResource : createResource}
|
||||
disabled={!canCreate}
|
||||
>
|
||||
{resource ? (
|
||||
<>Save Changes</>
|
||||
) : (
|
||||
<>
|
||||
<PlusCircle size={16} />
|
||||
Add Resource
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{tab === "access-control" && (
|
||||
<>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
onClick={() => setTab("resource")}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
data-cy={"submit-route"}
|
||||
onClick={createResource}
|
||||
disabled={!canCreate}
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
Add Resource
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ModalClose asChild={true}>
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
</ModalClose>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
data-cy={"submit-route"}
|
||||
onClick={updateResource}
|
||||
disabled={!canCreate}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import Badge from "@components/Badge";
|
||||
import MultipleGroups, {
|
||||
TransparentEditIconButton,
|
||||
} from "@components/ui/MultipleGroups";
|
||||
import { IconCirclePlus } from "@tabler/icons-react";
|
||||
import * as React from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
@@ -15,6 +17,9 @@ export const ResourceGroupCell = ({ resource }: Props) => {
|
||||
|
||||
const { network, openResourceGroupModal } = useNetworksContext();
|
||||
|
||||
const groups = resource?.groups as Group[] | undefined;
|
||||
const hasGroups = groups && groups.length > 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={"flex cursor-pointer items-center justify-center gap-1 group"}
|
||||
@@ -23,12 +28,25 @@ export const ResourceGroupCell = ({ resource }: Props) => {
|
||||
openResourceGroupModal(network, resource);
|
||||
}}
|
||||
>
|
||||
<MultipleGroups
|
||||
groups={resource?.groups as Group[]}
|
||||
showResources={true}
|
||||
redirectGroupTab={"resources"}
|
||||
/>
|
||||
{permission.networks.update && <TransparentEditIconButton />}
|
||||
{hasGroups ? (
|
||||
<>
|
||||
<MultipleGroups
|
||||
groups={groups}
|
||||
showResources={true}
|
||||
redirectGroupTab={"resources"}
|
||||
/>
|
||||
{permission.networks.update && <TransparentEditIconButton />}
|
||||
</>
|
||||
) : (
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
useHover={true}
|
||||
disabled={!permission.networks.update}
|
||||
>
|
||||
<IconCirclePlus size={14} />
|
||||
Add Groups
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,12 +8,13 @@ import {
|
||||
import ModalHeader from "@components/modal/ModalHeader";
|
||||
import { notify } from "@components/Notification";
|
||||
import { PeerGroupSelector } from "@components/PeerGroupSelector";
|
||||
import Separator from "@components/Separator";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import { FolderGit2 } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Network, NetworkResource } from "@/interfaces/Network";
|
||||
import useGroupHelper from "@/modules/groups/useGroupHelper";
|
||||
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
|
||||
import { FolderGit2 } from "lucide-react";
|
||||
import Separator from "@components/Separator";
|
||||
|
||||
type ResourceGroupModalProps = {
|
||||
resource?: NetworkResource;
|
||||
@@ -58,6 +59,7 @@ const ResourceGroupModalContent = ({
|
||||
`/networks/${network?.id}/resources/${resource?.id}`,
|
||||
).put;
|
||||
|
||||
const { policies } = useNetworksContext();
|
||||
const [groups, setGroups, { save: saveGroups }] = useGroupHelper({
|
||||
initial: resource?.groups || [],
|
||||
});
|
||||
@@ -78,21 +80,26 @@ const ResourceGroupModalContent = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalContent maxWidthClass={"max-w-xl"}>
|
||||
<ModalContent maxWidthClass={"max-w-2xl"}>
|
||||
<ModalHeader
|
||||
icon={<FolderGit2 size={18} />}
|
||||
title={"Assigned Groups"}
|
||||
title={"Resource Groups"}
|
||||
description={
|
||||
"Add this resource to groups and use them as destinations when creating policies"
|
||||
"Add this resource to a group (e.g., Databases, Web Servers) and reference the group in access policies to simplify management."
|
||||
}
|
||||
color={"blue"}
|
||||
icon={<FolderGit2 size={18} />}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className={"px-8 py-6 flex flex-col gap-8"}>
|
||||
<div className={"px-8 py-6 pt-6 flex flex-col gap-8"}>
|
||||
<div>
|
||||
<PeerGroupSelector onChange={setGroups} values={groups} />
|
||||
<PeerGroupSelector
|
||||
onChange={setGroups}
|
||||
values={groups}
|
||||
showPeerCounter={false}
|
||||
placeholder={"Add or select resource group(s)..."}
|
||||
policies={policies}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ export default function ResourceNameCell({ resource }: Readonly<Props>) {
|
||||
/>
|
||||
<DescriptionWithTooltip
|
||||
maxChars={25}
|
||||
className={cn("font-normal mt-0.5 ")}
|
||||
className={cn("font-normal")}
|
||||
text={resource.description}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,52 +1,37 @@
|
||||
import Badge from "@components/Badge";
|
||||
import Button from "@components/Button";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { orderBy } from "lodash";
|
||||
import { PlusCircle, ShieldIcon, SquarePenIcon } from "lucide-react";
|
||||
import { Settings, ShieldIcon, ShieldOff, SquarePenIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import CircleIcon from "@/assets/icons/CircleIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { NetworkResource } from "@/interfaces/Network";
|
||||
import { Policy } from "@/interfaces/Policy";
|
||||
import { useNetworksContext } from "@/modules/networks/NetworkProvider";
|
||||
import { cn } from "@utils/helpers";
|
||||
|
||||
type Props = {
|
||||
resource?: NetworkResource;
|
||||
};
|
||||
export const ResourcePolicyCell = ({ resource }: Props) => {
|
||||
const { permission } = usePermissions();
|
||||
const { openPolicyModal, network, openEditPolicyModal } =
|
||||
useNetworksContext();
|
||||
const { data: policies, isLoading } = useFetchApi<Policy[]>("/policies");
|
||||
const {
|
||||
openResourceModal,
|
||||
network,
|
||||
openEditPolicyModal,
|
||||
assignedPolicies,
|
||||
confirmMultiResourceAction,
|
||||
} = useNetworksContext();
|
||||
const {
|
||||
policies: resourcePolicies,
|
||||
enabledPolicies,
|
||||
isLoading,
|
||||
policyCount,
|
||||
} = assignedPolicies(resource);
|
||||
const [tooltipOpen, setTooltipOpen] = useState(false);
|
||||
|
||||
const assignedPolicies = useMemo(() => {
|
||||
const resourceGroups = resource?.groups as Group[];
|
||||
return orderBy(
|
||||
policies?.filter((policy) => {
|
||||
const destinationResource = policy.rules
|
||||
?.map((rule) => rule?.destinationResource?.id === resource?.id)
|
||||
.some((id) => id);
|
||||
if (destinationResource) return true;
|
||||
const destinationPolicyGroups = policy.rules
|
||||
?.map((rule) => rule?.destinations)
|
||||
.flat() as Group[];
|
||||
const policyGroups = [...destinationPolicyGroups];
|
||||
return resourceGroups?.some((resourceGroup) =>
|
||||
policyGroups.some(
|
||||
(policyGroup) => policyGroup?.id === resourceGroup.id,
|
||||
),
|
||||
);
|
||||
}),
|
||||
"enabled",
|
||||
"desc",
|
||||
);
|
||||
}, [policies, resource]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={"flex gap-3"}>
|
||||
@@ -55,13 +40,16 @@ export const ResourcePolicyCell = ({ resource }: Props) => {
|
||||
);
|
||||
}
|
||||
|
||||
const enabledPolicies = assignedPolicies?.filter((policy) => policy?.enabled);
|
||||
|
||||
const policyCount = assignedPolicies?.length || 0;
|
||||
|
||||
return (
|
||||
network && (
|
||||
<div className={"flex gap-3"}>
|
||||
{policyCount === 0 && (
|
||||
<Badge variant={"gray"}>
|
||||
<ShieldOff size={12} className="text-red-500" />
|
||||
<span className={"font-medium text-xs"}>None</span>
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{policyCount > 0 && (
|
||||
<FullTooltip
|
||||
contentClassName={"p-0"}
|
||||
@@ -72,17 +60,23 @@ export const ResourcePolicyCell = ({ resource }: Props) => {
|
||||
className={"border-nb-gray-800"}
|
||||
content={
|
||||
<div className={"text-xs flex flex-col p-1"}>
|
||||
{assignedPolicies?.map((policy: Policy) => {
|
||||
{resourcePolicies?.map((policy: Policy) => {
|
||||
const rule = policy?.rules?.[0];
|
||||
if (!rule) return;
|
||||
if (!rule) return null;
|
||||
return (
|
||||
<button
|
||||
key={policy.id}
|
||||
className={
|
||||
"m-0 pl-3 py-2.5 leading-none flex justify-between group hover:bg-nb-gray-900 rounded-md"
|
||||
}
|
||||
onClick={() => {
|
||||
onClick={async () => {
|
||||
setTooltipOpen(false);
|
||||
const confirm = await confirmMultiResourceAction(
|
||||
policy,
|
||||
"edit",
|
||||
resource,
|
||||
);
|
||||
if (!confirm) return;
|
||||
openEditPolicyModal(policy);
|
||||
}}
|
||||
>
|
||||
@@ -118,18 +112,29 @@ export const ResourcePolicyCell = ({ resource }: Props) => {
|
||||
>
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
useHover={false}
|
||||
className={"select-none hover:bg-nb-gray-910"}
|
||||
useHover={true}
|
||||
className={"select-none hover:bg-nb-gray-910 cursor-pointer"}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!tooltipOpen) setTooltipOpen(true);
|
||||
if (!permission.networks.update) return;
|
||||
if (tooltipOpen) setTooltipOpen(false);
|
||||
openResourceModal(network, resource, "access-control");
|
||||
}}
|
||||
>
|
||||
<ShieldIcon size={14} className={"text-green-500"} />
|
||||
<ShieldIcon
|
||||
size={14}
|
||||
className={cn(
|
||||
enabledPolicies?.length > 0
|
||||
? "text-green-500"
|
||||
: "text-nb-gray-400",
|
||||
)}
|
||||
/>
|
||||
<div>
|
||||
<span className={"font-medium text-xs"}>
|
||||
{enabledPolicies?.length}
|
||||
{enabledPolicies?.length > 0
|
||||
? enabledPolicies?.length
|
||||
: `${policyCount} Disabled`}
|
||||
</span>
|
||||
</div>
|
||||
</Badge>
|
||||
@@ -139,11 +144,12 @@ export const ResourcePolicyCell = ({ resource }: Props) => {
|
||||
<Button
|
||||
size={"xs"}
|
||||
variant={"secondary"}
|
||||
className={"!px-3"}
|
||||
disabled={!permission.networks.update}
|
||||
onClick={() => openPolicyModal(network, resource)}
|
||||
onClick={() => openResourceModal(network, resource, "access-control")}
|
||||
>
|
||||
<PlusCircle size={12} />
|
||||
Add Policy
|
||||
<Settings size={12} />
|
||||
Configure
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -13,8 +13,9 @@ type Props = {
|
||||
label?: string;
|
||||
className?: string;
|
||||
onError?: (error: string) => void;
|
||||
description?: string;
|
||||
description?: React.ReactNode;
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
};
|
||||
export const ResourceSingleAddressInput = ({
|
||||
value,
|
||||
@@ -24,6 +25,7 @@ export const ResourceSingleAddressInput = ({
|
||||
onError,
|
||||
description = "Enter a single IP address, CIDR block or domain name",
|
||||
placeholder = "Address (IP, CIDR or Domain)",
|
||||
autoFocus,
|
||||
}: Props) => {
|
||||
const hasChars = useMemo(() => {
|
||||
return !!value.match(/[a-z*]/i);
|
||||
@@ -71,6 +73,7 @@ export const ResourceSingleAddressInput = ({
|
||||
<Label>{label}</Label>
|
||||
<HelpText>{description}</HelpText>
|
||||
<Input
|
||||
autoFocus={autoFocus}
|
||||
customPrefix={PrefixIcon}
|
||||
error={error}
|
||||
placeholder={placeholder}
|
||||
|
||||
@@ -77,7 +77,7 @@ const NetworkResourceColumns: ColumnDef<NetworkResource>[] = [
|
||||
return groups.map((group) => group.name).join(", ");
|
||||
},
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Groups</DataTableHeader>;
|
||||
return <DataTableHeader column={column}>Resource Groups</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return <ResourceGroupCell resource={row.original} />;
|
||||
@@ -121,7 +121,12 @@ export default function ResourcesTable({
|
||||
const params = useSearchParams();
|
||||
const resourceId = params.get("resource") ?? undefined;
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{
|
||||
id: "name",
|
||||
desc: false,
|
||||
},
|
||||
]);
|
||||
const { openResourceModal, network } = useNetworksContext();
|
||||
const router = useRouter();
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||
import { Network } from "@/interfaces/Network";
|
||||
import { NetworkAccessControlProvider } from "@/modules/networks/NetworkAccessControlProvider";
|
||||
import {
|
||||
NetworkProvider,
|
||||
useNetworksContext,
|
||||
@@ -99,82 +100,84 @@ export default function NetworksTable({
|
||||
return (
|
||||
<>
|
||||
<GlobalSearchModal open={searchModal} setOpen={setSearchModal} />
|
||||
<NetworkProvider>
|
||||
<DataTable
|
||||
headingTarget={headingTarget}
|
||||
isLoading={isLoading}
|
||||
text={"Networks"}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
columns={NetworkTableColumns}
|
||||
data={data}
|
||||
searchPlaceholder={"Search by network name or description..."}
|
||||
columnVisibility={{
|
||||
description: false,
|
||||
}}
|
||||
onSearchClick={() => setSearchModal(true)}
|
||||
getStartedCard={
|
||||
<GetStartedTest
|
||||
icon={
|
||||
<SquareIcon
|
||||
icon={
|
||||
<NetworkRoutesIcon
|
||||
className={"fill-nb-gray-200"}
|
||||
size={20}
|
||||
/>
|
||||
}
|
||||
color={"gray"}
|
||||
size={"large"}
|
||||
/>
|
||||
}
|
||||
title={"Create New Network"}
|
||||
description={
|
||||
"It looks like you don't have any networks. Access internal resources in your LANs and VPC by adding a network."
|
||||
}
|
||||
button={
|
||||
<div className={"gap-x-4 flex items-center justify-center"}>
|
||||
<NetworkAccessControlProvider>
|
||||
<NetworkProvider>
|
||||
<DataTable
|
||||
headingTarget={headingTarget}
|
||||
isLoading={isLoading}
|
||||
text={"Networks"}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
columns={NetworkTableColumns}
|
||||
data={data}
|
||||
searchPlaceholder={"Search by network name or description..."}
|
||||
columnVisibility={{
|
||||
description: false,
|
||||
}}
|
||||
onSearchClick={() => setSearchModal(true)}
|
||||
getStartedCard={
|
||||
<GetStartedTest
|
||||
icon={
|
||||
<SquareIcon
|
||||
icon={
|
||||
<NetworkRoutesIcon
|
||||
className={"fill-nb-gray-200"}
|
||||
size={20}
|
||||
/>
|
||||
}
|
||||
color={"gray"}
|
||||
size={"large"}
|
||||
/>
|
||||
}
|
||||
title={"Create New Network"}
|
||||
description={
|
||||
"It looks like you don't have any networks. Access internal resources in your LANs and VPC by adding a network."
|
||||
}
|
||||
button={
|
||||
<div className={"gap-x-4 flex items-center justify-center"}>
|
||||
<AddNetworkButton />
|
||||
</div>
|
||||
}
|
||||
learnMore={
|
||||
<>
|
||||
Learn more about
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/networks"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Networks
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
}
|
||||
rightSide={() =>
|
||||
data &&
|
||||
data.length > 0 && (
|
||||
<div className={cn("gap-x-4 ml-auto flex")}>
|
||||
<AddNetworkButton />
|
||||
</div>
|
||||
}
|
||||
learnMore={
|
||||
<>
|
||||
Learn more about
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/networks"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Networks
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
}
|
||||
rightSide={() =>
|
||||
data &&
|
||||
data.length > 0 && (
|
||||
<div className={cn("gap-x-4 ml-auto flex")}>
|
||||
<AddNetworkButton />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
{(table) => (
|
||||
<>
|
||||
<DataTableRowsPerPage
|
||||
table={table}
|
||||
disabled={data?.length == 0}
|
||||
/>
|
||||
<DataTableRefreshButton
|
||||
isDisabled={data?.length == 0}
|
||||
onClick={() => {
|
||||
mutate("/networks").then();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DataTable>
|
||||
</NetworkProvider>
|
||||
)
|
||||
}
|
||||
>
|
||||
{(table) => (
|
||||
<>
|
||||
<DataTableRowsPerPage
|
||||
table={table}
|
||||
disabled={data?.length == 0}
|
||||
/>
|
||||
<DataTableRefreshButton
|
||||
isDisabled={data?.length == 0}
|
||||
onClick={() => {
|
||||
mutate("/networks").then();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DataTable>
|
||||
</NetworkProvider>
|
||||
</NetworkAccessControlProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
359
src/modules/reverse-proxy/ReverseProxyAccessControlRules.tsx
Normal file
359
src/modules/reverse-proxy/ReverseProxyAccessControlRules.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
109
src/modules/reverse-proxy/ReverseProxyCrowdSecIPReputation.tsx
Normal file
109
src/modules/reverse-proxy/ReverseProxyCrowdSecIPReputation.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
171
src/modules/reverse-proxy/ReverseProxyHTTPTargets.tsx
Normal file
171
src/modules/reverse-proxy/ReverseProxyHTTPTargets.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import Button from "@components/Button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@components/DropdownMenu";
|
||||
import HelpText from "@components/HelpText";
|
||||
import { InlineButtonLink } from "@components/InlineLink";
|
||||
import { Label } from "@components/Label";
|
||||
import { ToggleSwitch } from "@components/ToggleSwitch";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
ArrowUpRight,
|
||||
Edit,
|
||||
MinusCircleIcon,
|
||||
MoreVertical,
|
||||
PlusIcon,
|
||||
} from "lucide-react";
|
||||
import { Callout } from "@components/Callout";
|
||||
import React from "react";
|
||||
import { Network } from "@/interfaces/Network";
|
||||
import { ReverseProxyTarget } from "@/interfaces/ReverseProxy";
|
||||
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
|
||||
import { cn } from "@utils/helpers";
|
||||
|
||||
type Props = {
|
||||
targets: ReverseProxyTarget[];
|
||||
onEditTarget: (index: number) => void;
|
||||
onRemoveTarget: (index: number) => void;
|
||||
onToggleTargetEnabled: (index: number) => void;
|
||||
onAddTarget: () => void;
|
||||
initialNetwork?: Network;
|
||||
onNavigateToResources?: () => void;
|
||||
};
|
||||
|
||||
export default function ReverseProxyHTTPTargets({
|
||||
targets,
|
||||
onEditTarget,
|
||||
onRemoveTarget,
|
||||
onToggleTargetEnabled,
|
||||
onAddTarget,
|
||||
initialNetwork,
|
||||
onNavigateToResources,
|
||||
}: Readonly<Props>) {
|
||||
return (
|
||||
<div>
|
||||
<Label>HTTPS Targets</Label>
|
||||
<HelpText>
|
||||
Add one or more devices running your service or resources to make it
|
||||
publicly accessible.
|
||||
</HelpText>
|
||||
|
||||
{targets.length > 0 && (
|
||||
<div
|
||||
className={
|
||||
"mt-3 mb-3 overflow-hidden border border-nb-gray-900 bg-nb-gray-920/30 py-1 px-1 rounded-md "
|
||||
}
|
||||
>
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{targets.map((target, index) => (
|
||||
<tr
|
||||
key={index}
|
||||
onClick={() => onEditTarget(index)}
|
||||
className="rounded-md hover:bg-nb-gray-900/30 cursor-pointer transition-all"
|
||||
>
|
||||
<td className="py-2.5 pl-5 pr-2 align-middle">
|
||||
<span className="text-[11px] leading-none font-mono px-2.5 py-2 rounded bg-nb-gray-900 text-nb-gray-300 inline-flex items-center">
|
||||
{target.path
|
||||
? target.path.startsWith("/")
|
||||
? target.path
|
||||
: `/${target.path}`
|
||||
: "/"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2.5 px-4 align-middle">
|
||||
<ArrowRight size={12} className="text-nb-gray-400" />
|
||||
</td>
|
||||
<td className="py-2.5 pr-2 align-middle">
|
||||
<TargetDestination target={target} />
|
||||
</td>
|
||||
<td className="py-2.5 pl-2 pr-4">
|
||||
<div
|
||||
className="flex items-center gap-2 justify-end"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ToggleSwitch
|
||||
size="small"
|
||||
checked={target.enabled}
|
||||
onCheckedChange={() => onToggleTargetEnabled(index)}
|
||||
/>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="default-outline" className="!px-3">
|
||||
<MoreVertical size={16} className="shrink-0" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-auto min-w-[200px]"
|
||||
align="end"
|
||||
>
|
||||
<DropdownMenuItem onClick={() => onEditTarget(index)}>
|
||||
<div className="flex gap-3 items-center">
|
||||
<Edit size={14} className="shrink-0" />
|
||||
Edit Target
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant={"danger"}
|
||||
onClick={() => onRemoveTarget(index)}
|
||||
>
|
||||
<div className="flex gap-3 items-center">
|
||||
<MinusCircleIcon size={14} className="shrink-0" />
|
||||
Remove Target
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="dotted"
|
||||
className={cn("w-full mt-1", targets?.length > 0 && "mt-1")}
|
||||
size="sm"
|
||||
onClick={onAddTarget}
|
||||
disabled={!!(initialNetwork && !initialNetwork.resources?.length)}
|
||||
>
|
||||
<PlusIcon size={14} />
|
||||
Add Target
|
||||
</Button>
|
||||
|
||||
{initialNetwork && !initialNetwork.resources?.length && (
|
||||
<Callout
|
||||
variant="warning"
|
||||
className="mt-3"
|
||||
icon={
|
||||
<AlertTriangle size={14} className="shrink-0 relative top-[3px]" />
|
||||
}
|
||||
>
|
||||
There are currently no resources in your network{" "}
|
||||
<span className={"text-netbird-100 font-medium"}>
|
||||
{initialNetwork?.name}
|
||||
</span>
|
||||
. Add resources to your network before exposing it as a service.{" "}
|
||||
<InlineButtonLink variant={"default"} onClick={onNavigateToResources}>
|
||||
Go to Resources
|
||||
<ArrowUpRight size={14} />
|
||||
</InlineButtonLink>
|
||||
</Callout>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TargetDestination({ target }: { target: ReverseProxyTarget }) {
|
||||
const { resolveDestination } = useReverseProxies();
|
||||
return (
|
||||
<span className="text-[0.76rem] text-nb-gray-200 whitespace-nowrap font-mono">
|
||||
{resolveDestination(target)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
133
src/modules/reverse-proxy/ReverseProxyLayer4Content.tsx
Normal file
133
src/modules/reverse-proxy/ReverseProxyLayer4Content.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { Input } from "@components/Input";
|
||||
import { Label } from "@components/Label";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import React, { useRef } from "react";
|
||||
import { Network, NetworkResource } from "@/interfaces/Network";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import ReverseProxyAddressInput, {
|
||||
CidrHelpText,
|
||||
} from "@/modules/reverse-proxy/targets/ReverseProxyAddressInput";
|
||||
import ReverseProxyTargetSelector, {
|
||||
type Target,
|
||||
} from "@/modules/reverse-proxy/targets/ReverseProxyTargetSelector";
|
||||
import { HelpTooltip } from "@components/HelpTooltip";
|
||||
|
||||
type Props = {
|
||||
l4Target: Target | undefined;
|
||||
setL4Target: React.Dispatch<React.SetStateAction<Target | undefined>>;
|
||||
isListenPortSupported: boolean;
|
||||
listenPort: number;
|
||||
setListenPort: (port: number) => void;
|
||||
port: number;
|
||||
setPort: (port: number) => void;
|
||||
initialResource?: NetworkResource;
|
||||
initialPeer?: Peer;
|
||||
initialNetwork?: Network;
|
||||
};
|
||||
|
||||
export default function ReverseProxyLayer4Content({
|
||||
l4Target,
|
||||
setL4Target,
|
||||
isListenPortSupported,
|
||||
listenPort,
|
||||
setListenPort,
|
||||
port,
|
||||
setPort,
|
||||
initialResource,
|
||||
initialPeer,
|
||||
initialNetwork,
|
||||
}: Readonly<Props>) {
|
||||
const listenPortRef = useRef<HTMLInputElement>(null);
|
||||
const portRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<div className={"-mt-1 flex flex-col gap-8"}>
|
||||
{!initialResource && !initialPeer && (
|
||||
<ReverseProxyTargetSelector
|
||||
value={l4Target}
|
||||
initialNetwork={initialNetwork}
|
||||
onChange={(selection) => {
|
||||
setL4Target(selection);
|
||||
if (selection) {
|
||||
setTimeout(() => {
|
||||
if (isListenPortSupported) {
|
||||
listenPortRef.current?.focus();
|
||||
} else {
|
||||
portRef.current?.focus();
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={"flex gap-4 items-center"}>
|
||||
<div className={"w-full max-w-[180px]"}>
|
||||
<Label>
|
||||
Listen Port
|
||||
<HelpTooltip
|
||||
className={isListenPortSupported ? "max-w-sm" : "max-w-xs"}
|
||||
content={
|
||||
isListenPortSupported
|
||||
? "Enter the public listen port this service will be reachable on."
|
||||
: "The listen port will be automatically assigned after the service is created."
|
||||
}
|
||||
/>
|
||||
</Label>
|
||||
<div className={"mt-2"}>
|
||||
<Input
|
||||
ref={listenPortRef}
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
placeholder={!isListenPortSupported ? "Auto" : "443"}
|
||||
value={!isListenPortSupported ? "" : listenPort || ""}
|
||||
onChange={(e) => setListenPort(parseInt(e.target.value) || 0)}
|
||||
disabled={!isListenPortSupported || !l4Target}
|
||||
aria-label="Public listen port"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight size={16} className="text-nb-gray-400 shrink-0 mt-6" />
|
||||
<div className={"w-full flex"}>
|
||||
<div className={"w-full"}>
|
||||
<Label>
|
||||
Host / IP
|
||||
<CidrHelpText target={l4Target} />
|
||||
</Label>
|
||||
<div className="flex w-full mt-2 relative">
|
||||
<ReverseProxyAddressInput
|
||||
value={l4Target}
|
||||
onChange={setL4Target}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>
|
||||
Port
|
||||
<HelpTooltip
|
||||
content={
|
||||
"Enter the port where your service (e.g., webserver, app, API) is currently listening."
|
||||
}
|
||||
/>
|
||||
</Label>
|
||||
<div className={"mt-2 min-w-[120px]"}>
|
||||
<Input
|
||||
ref={portRef}
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
placeholder="443"
|
||||
value={port || ""}
|
||||
onChange={(e) => setPort(parseInt(e.target.value) || 0)}
|
||||
disabled={!l4Target}
|
||||
aria-label="Destination port"
|
||||
className={"rounded-l-none"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
125
src/modules/reverse-proxy/ReverseProxyServiceModeSelector.tsx
Normal file
125
src/modules/reverse-proxy/ReverseProxyServiceModeSelector.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import React, { ReactNode, useEffect } from "react";
|
||||
import {
|
||||
isL4Mode as isL4ServiceMode,
|
||||
type ReverseProxyDomain,
|
||||
ServiceMode,
|
||||
} from "@/interfaces/ReverseProxy";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@components/Select";
|
||||
import { ArrowRightFromLine, Globe, LockKeyhole } from "lucide-react";
|
||||
import { HelpTooltip } from "@components/HelpTooltip";
|
||||
import { Label } from "@components/Label";
|
||||
import HelpText from "@components/HelpText";
|
||||
|
||||
type Props = {
|
||||
value?: ServiceMode;
|
||||
onChange: (value: ServiceMode) => void;
|
||||
disabled?: boolean;
|
||||
domain?: ReverseProxyDomain;
|
||||
};
|
||||
|
||||
type ServiceModeConfig = {
|
||||
label: string;
|
||||
description: string;
|
||||
icon: ReactNode;
|
||||
};
|
||||
|
||||
export const SERVICE_MODES: Record<ServiceMode, ServiceModeConfig> = {
|
||||
[ServiceMode.HTTP]: {
|
||||
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} />,
|
||||
},
|
||||
[ServiceMode.TLS]: {
|
||||
label: "TLS Passthrough",
|
||||
description:
|
||||
"Passes encrypted TLS traffic straight through to the backend. Typically used for services that manage their own TLS certificates.",
|
||||
icon: <LockKeyhole size={14} />,
|
||||
},
|
||||
[ServiceMode.TCP]: {
|
||||
label: "TCP Service",
|
||||
description:
|
||||
"Forwards raw TCP traffic to your backend on a dedicated port. Typically used for databases, custom protocols, or any TCP-based service.",
|
||||
icon: <ArrowRightFromLine size={14} />,
|
||||
},
|
||||
[ServiceMode.UDP]: {
|
||||
label: "UDP Service",
|
||||
description:
|
||||
"Forwards raw UDP traffic to your backend on a dedicated port. Typically used for real-time services like voice, video, or streaming.",
|
||||
icon: <ArrowRightFromLine size={14} />,
|
||||
},
|
||||
};
|
||||
|
||||
export const ReverseProxyServiceModeSelector = ({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
domain,
|
||||
}: Props) => {
|
||||
const selected = value ?? ServiceMode.HTTP;
|
||||
const selectedMode = SERVICE_MODES[selected];
|
||||
const isL4Supported = domain?.supports_custom_ports !== undefined;
|
||||
|
||||
// Reset to HTTP if the current L4 mode becomes unsupported (e.g. domain changed)
|
||||
useEffect(() => {
|
||||
if (!isL4Supported && isL4ServiceMode(selected)) {
|
||||
onChange(ServiceMode.HTTP);
|
||||
}
|
||||
}, [isL4Supported, selected, onChange]);
|
||||
|
||||
return (
|
||||
<div className="flex justify-between items-center gap-10 mt-2">
|
||||
<div>
|
||||
<Label>Service Type</Label>
|
||||
<HelpText>
|
||||
Select a type to define how the proxy handles and forwards traffic to
|
||||
your backend services.
|
||||
</HelpText>
|
||||
</div>
|
||||
<Select
|
||||
value={selected}
|
||||
onValueChange={(v) => onChange(v as ServiceMode)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="max-w-[240px] min-w-[200px]">
|
||||
<div
|
||||
className={"flex items-center gap-2 whitespace-nowrap"}
|
||||
data-cy={"service-mode-select-button"}
|
||||
>
|
||||
{selectedMode.icon}
|
||||
<SelectValue placeholder="Select type..." />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent data-cy={"service-mode-selection"}>
|
||||
{Object.entries(SERVICE_MODES)
|
||||
.filter(
|
||||
([mode]) =>
|
||||
isL4Supported || !isL4ServiceMode(mode as ServiceMode),
|
||||
)
|
||||
.map(([mode, config]) => (
|
||||
<SelectItem
|
||||
key={mode}
|
||||
value={mode}
|
||||
extra={
|
||||
<HelpTooltip
|
||||
triggerClassName={"ml-[0.01rem]"}
|
||||
align={"center"}
|
||||
side={"right"}
|
||||
content={<>{config.description}</>}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className="whitespace-nowrap">{config.label}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
454
src/modules/reverse-proxy/auth/AuthHeaderModal.tsx
Normal file
454
src/modules/reverse-proxy/auth/AuthHeaderModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -5,11 +5,15 @@ import { PeerGroupSelector } from "@components/PeerGroupSelector";
|
||||
import { GradientFadedBackground } from "@components/ui/GradientFadedBackground";
|
||||
import React, { useState } from "react";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { useUsers } from "@/contexts/UsersProvider";
|
||||
import Badge from "@components/Badge";
|
||||
import { CircleUser } from "lucide-react";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
currentGroups: Group[];
|
||||
isEnabled: boolean;
|
||||
onSave: (groups: Group[]) => void;
|
||||
onRemove: () => void;
|
||||
};
|
||||
@@ -18,17 +22,17 @@ export default function AuthSSOModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
currentGroups,
|
||||
isEnabled,
|
||||
onSave,
|
||||
onRemove,
|
||||
}: Readonly<Props>) {
|
||||
const { users } = useUsers();
|
||||
const [groups, setGroups] = useState<Group[]>(currentGroups);
|
||||
const isEditing = currentGroups.length > 0;
|
||||
const isEditing = isEnabled;
|
||||
|
||||
const handleSave = () => {
|
||||
if (groups.length > 0) {
|
||||
onOpenChange(false);
|
||||
onSave(groups);
|
||||
}
|
||||
onOpenChange(false);
|
||||
onSave(groups);
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
@@ -51,7 +55,17 @@ export default function AuthSSOModal({
|
||||
<PeerGroupSelector
|
||||
values={groups}
|
||||
onChange={setGroups}
|
||||
placeholder="Select distribution groups..."
|
||||
placeholder={
|
||||
<div className={"flex items-center gap-2"}>
|
||||
<Badge className={"py-[3px]"} variant={"gray-ghost"}>
|
||||
<CircleUser size={12} />
|
||||
All Users
|
||||
</Badge>
|
||||
Select user groups...
|
||||
</div>
|
||||
}
|
||||
users={users}
|
||||
hideAllGroup={true}
|
||||
/>
|
||||
<div className="flex gap-3 w-full justify-between mt-6">
|
||||
{isEditing ? (
|
||||
@@ -63,11 +77,7 @@ export default function AuthSSOModal({
|
||||
<ModalClose asChild>
|
||||
<Button variant="secondary">Cancel</Button>
|
||||
</ModalClose>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
disabled={groups.length === 0}
|
||||
>
|
||||
<Button variant="primary" onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
@@ -79,12 +89,8 @@ export default function AuthSSOModal({
|
||||
<ModalClose asChild>
|
||||
<Button variant="secondary">Cancel</Button>
|
||||
</ModalClose>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
disabled={groups.length === 0}
|
||||
>
|
||||
Add Groups
|
||||
<Button variant="primary" onClick={handleSave}>
|
||||
Add SSO
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from "@/interfaces/ReverseProxy";
|
||||
import HelpText from "@components/HelpText";
|
||||
import Separator from "@components/Separator";
|
||||
import { isNetBirdHosted } from "@/utils/netbird";
|
||||
import {
|
||||
SelectDropdown,
|
||||
SelectOption,
|
||||
@@ -99,18 +100,35 @@ export const CustomDomainModal = ({
|
||||
|
||||
<div className={"px-8 flex flex-col gap-6 pt-6 pb-8"}>
|
||||
{availableClusters.length === 0 ? (
|
||||
<Callout variant="warning">
|
||||
No proxy clusters are currently connected. Please ensure at least
|
||||
one proxy is running before adding a domain. <br /> Learn more
|
||||
about{" "}
|
||||
<InlineLink
|
||||
href={REVERSE_PROXY_CLUSTERS_DOCS_LINK}
|
||||
target={"_blank"}
|
||||
>
|
||||
Proxy Clusters
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Callout>
|
||||
isNetBirdHosted() ? (
|
||||
<Callout variant={"warning"}>
|
||||
No proxy clusters are currently connected. Please try again in a
|
||||
few minutes. If the issue persists, check{" "}
|
||||
<InlineLink
|
||||
href={"https://status.netbird.io/"}
|
||||
target={"_blank"}
|
||||
>
|
||||
NetBird Status
|
||||
</InlineLink>{" "}
|
||||
or reach out to{" "}
|
||||
<InlineLink href={"mailto:support@netbird.io"}>
|
||||
support@netbird.io
|
||||
</InlineLink>
|
||||
</Callout>
|
||||
) : (
|
||||
<Callout variant="warning">
|
||||
No proxy clusters are currently connected. Please ensure at
|
||||
least one proxy is running before adding a domain. <br /> Learn
|
||||
more about{" "}
|
||||
<InlineLink
|
||||
href={REVERSE_PROXY_CLUSTERS_DOCS_LINK}
|
||||
target={"_blank"}
|
||||
>
|
||||
Proxy Clusters
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Callout>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "@/interfaces/ReverseProxy";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import { isNetBirdHosted } from "@/utils/netbird";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
@@ -79,18 +80,36 @@ export const CustomDomainVerificationModal = ({
|
||||
</Steps>
|
||||
<div className={"flex flex-col gap-6"}>
|
||||
{!cnameTarget ? (
|
||||
<Callout variant={"warning"}>
|
||||
No proxy clusters are currently connected. Please ensure at
|
||||
least one proxy is running to configure DNS verification. <br />
|
||||
Learn more about{" "}
|
||||
<InlineLink
|
||||
href={REVERSE_PROXY_CLUSTERS_DOCS_LINK}
|
||||
target={"_blank"}
|
||||
>
|
||||
Proxy Clusters
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Callout>
|
||||
isNetBirdHosted() ? (
|
||||
<Callout variant={"warning"}>
|
||||
No proxy clusters are currently connected. Please try again in
|
||||
a few minutes. If the issue persists, check{" "}
|
||||
<InlineLink
|
||||
href={"https://status.netbird.io/"}
|
||||
target={"_blank"}
|
||||
>
|
||||
NetBird Status
|
||||
</InlineLink>{" "}
|
||||
or reach out to{" "}
|
||||
<InlineLink href={"mailto:support@netbird.io"}>
|
||||
support@netbird.io
|
||||
</InlineLink>
|
||||
</Callout>
|
||||
) : (
|
||||
<Callout variant={"warning"}>
|
||||
No proxy clusters are currently connected. Please ensure at
|
||||
least one proxy is running to configure DNS verification.{" "}
|
||||
<br />
|
||||
Learn more about{" "}
|
||||
<InlineLink
|
||||
href={REVERSE_PROXY_CLUSTERS_DOCS_LINK}
|
||||
target={"_blank"}
|
||||
>
|
||||
Proxy Clusters
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Callout>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<Card className={"w-full"}>
|
||||
|
||||
88
src/modules/reverse-proxy/domain/ReverseProxyDomainInput.tsx
Normal file
88
src/modules/reverse-proxy/domain/ReverseProxyDomainInput.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import HelpText from "@components/HelpText";
|
||||
import { Input } from "@components/Input";
|
||||
import { Label } from "@components/Label";
|
||||
import { Callout } from "@components/Callout";
|
||||
import React from "react";
|
||||
import { CustomDomainSelector } from "./CustomDomainSelector";
|
||||
import { isNetBirdHosted } from "@utils/netbird";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
|
||||
type Props = {
|
||||
subdomain: string;
|
||||
onSubdomainChange: (value: string) => void;
|
||||
baseDomain: string;
|
||||
onBaseDomainChange: (value: string) => void;
|
||||
domainAlreadyExists: boolean;
|
||||
subdomainRequired?: boolean;
|
||||
clusterOffline?: {
|
||||
clusterName: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default function ReverseProxyDomainInput({
|
||||
subdomain,
|
||||
onSubdomainChange,
|
||||
baseDomain,
|
||||
onBaseDomainChange,
|
||||
domainAlreadyExists,
|
||||
subdomainRequired = false,
|
||||
clusterOffline,
|
||||
}: Readonly<Props>) {
|
||||
return (
|
||||
<div>
|
||||
<Label>Domain</Label>
|
||||
<HelpText>
|
||||
{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">
|
||||
<Input
|
||||
autoFocus
|
||||
value={subdomain}
|
||||
onChange={(e) => {
|
||||
onSubdomainChange(
|
||||
e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""),
|
||||
);
|
||||
}}
|
||||
error={
|
||||
domainAlreadyExists
|
||||
? "This domain is already used by another service."
|
||||
: undefined
|
||||
}
|
||||
placeholder={subdomainRequired ? "myapp" : "myapp (optional)"}
|
||||
className="!rounded-r-none !border-r-0"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<CustomDomainSelector
|
||||
value={baseDomain}
|
||||
onChange={onBaseDomainChange}
|
||||
className="!rounded-l-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{clusterOffline &&
|
||||
(isNetBirdHosted() ? (
|
||||
<Callout variant={"warning"} className={"mt-3"}>
|
||||
Cluster {clusterOffline.clusterName} is offline. Please try again in
|
||||
a few minutes. If the issue persists, check{" "}
|
||||
<InlineLink href={"https://status.netbird.io/"} target={"_blank"}>
|
||||
NetBird Status
|
||||
</InlineLink>{" "}
|
||||
or reach out to{" "}
|
||||
<InlineLink href={"mailto:support@netbird.io"}>
|
||||
support@netbird.io
|
||||
</InlineLink>
|
||||
</Callout>
|
||||
) : (
|
||||
<Callout variant={"error"} className={"mt-3"}>
|
||||
Cluster {clusterOffline.clusterName} is offline. Make sure the proxy
|
||||
server is running and connected to the right management address.
|
||||
</Callout>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
144
src/modules/reverse-proxy/domain/useReverseProxyDomain.ts
Normal file
144
src/modules/reverse-proxy/domain/useReverseProxyDomain.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
ReverseProxy,
|
||||
ReverseProxyDomain,
|
||||
ReverseProxyDomainType,
|
||||
} from "@/interfaces/ReverseProxy";
|
||||
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
|
||||
|
||||
// Helper to parse domain into subdomain and base domain.
|
||||
// When availableDomains is provided, matches against them first (longest match wins)
|
||||
// to avoid e.g. "netbird.io" matching when the actual domain is "eu.proxy.netbird.io".
|
||||
function parseDomain(
|
||||
fullDomain: string,
|
||||
availableDomains?: ReverseProxyDomain[],
|
||||
): {
|
||||
subdomain: string;
|
||||
baseDomain: string;
|
||||
isCustom: boolean;
|
||||
} {
|
||||
// Try matching against actual available domains first (sorted longest-first for specificity)
|
||||
if (availableDomains?.length) {
|
||||
const sorted = [...availableDomains]
|
||||
.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)),
|
||||
baseDomain: d.domain,
|
||||
isCustom: d.type === ReverseProxyDomainType.CUSTOM,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to hardcoded known domains
|
||||
const knownDomains = ["netbird.cloud", "netbird.io", "netbird.app"];
|
||||
|
||||
for (const known of knownDomains) {
|
||||
if (fullDomain.endsWith(`.${known}`)) {
|
||||
return {
|
||||
subdomain: fullDomain.slice(0, -(known.length + 1)),
|
||||
baseDomain: known,
|
||||
isCustom: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Custom domain - find the first dot to split
|
||||
const firstDot = fullDomain.indexOf(".");
|
||||
if (firstDot > 0) {
|
||||
return {
|
||||
subdomain: fullDomain.slice(0, firstDot),
|
||||
baseDomain: fullDomain.slice(firstDot + 1),
|
||||
isCustom: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
subdomain: fullDomain,
|
||||
baseDomain: "netbird.cloud",
|
||||
isCustom: false,
|
||||
};
|
||||
}
|
||||
|
||||
type UseReverseProxyDomainOptions = {
|
||||
reverseProxy?: ReverseProxy;
|
||||
domains?: ReverseProxyDomain[];
|
||||
initialSubdomain?: string;
|
||||
};
|
||||
|
||||
export function useReverseProxyDomain({
|
||||
reverseProxy,
|
||||
domains,
|
||||
initialSubdomain,
|
||||
}: UseReverseProxyDomainOptions) {
|
||||
const { reverseProxies } = useReverseProxies();
|
||||
|
||||
const parsed = reverseProxy?.domain
|
||||
? parseDomain(reverseProxy.domain, domains)
|
||||
: null;
|
||||
|
||||
const [subdomain, setSubdomain] = useState(() => {
|
||||
return (
|
||||
parsed?.subdomain ||
|
||||
initialSubdomain
|
||||
?.toLowerCase()
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^a-z0-9-]/g, "") ||
|
||||
""
|
||||
);
|
||||
});
|
||||
|
||||
const [baseDomain, setBaseDomain] = useState(() => {
|
||||
if (parsed?.baseDomain) return parsed.baseDomain;
|
||||
const validatedDomains = domains?.filter((d) => d.validated) || [];
|
||||
const customDomain = validatedDomains.find(
|
||||
(d) => d.type === ReverseProxyDomainType.CUSTOM,
|
||||
);
|
||||
const freeDomain = validatedDomains.find(
|
||||
(d) => d.type === ReverseProxyDomainType.FREE,
|
||||
);
|
||||
return customDomain?.domain || freeDomain?.domain || "";
|
||||
});
|
||||
|
||||
const fullDomain = baseDomain
|
||||
? subdomain
|
||||
? `${subdomain}.${baseDomain}`
|
||||
: baseDomain
|
||||
: subdomain;
|
||||
|
||||
const domainAlreadyExists = useMemo(() => {
|
||||
if (!reverseProxies || !fullDomain) return false;
|
||||
return reverseProxies.some(
|
||||
(p) => p.domain === fullDomain && p.id !== reverseProxy?.id,
|
||||
);
|
||||
}, [reverseProxies, fullDomain, reverseProxy?.id]);
|
||||
|
||||
const isClusterConnected = useMemo(() => {
|
||||
if (!reverseProxy?.proxy_cluster) return false;
|
||||
return domains?.some(
|
||||
(d) =>
|
||||
d.type === ReverseProxyDomainType.FREE &&
|
||||
d.domain === reverseProxy.proxy_cluster,
|
||||
);
|
||||
}, [reverseProxy?.proxy_cluster, domains]);
|
||||
|
||||
return {
|
||||
subdomain,
|
||||
setSubdomain,
|
||||
baseDomain,
|
||||
setBaseDomain,
|
||||
fullDomain,
|
||||
domainAlreadyExists,
|
||||
isClusterConnected,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { cn, formatBytes } from "@utils/helpers";
|
||||
import { ArrowDownIcon, ArrowUpIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { ReverseProxyEvent } from "@/interfaces/ReverseProxy";
|
||||
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
|
||||
|
||||
type Props = {
|
||||
event: ReverseProxyEvent;
|
||||
};
|
||||
|
||||
export const ReverseProxyEventsBytesCell = ({ event }: Props) => {
|
||||
if (
|
||||
(event.bytes_download === undefined || event.bytes_download === 0) &&
|
||||
(event.bytes_upload === undefined || event.bytes_upload === 0)
|
||||
)
|
||||
return <EmptyRow />;
|
||||
|
||||
return (
|
||||
<div className={"flex flex-col text-xs gap-1 text-nb-gray-300 font-medium"}>
|
||||
<div className={"flex gap-2 items-center whitespace-nowrap"}>
|
||||
<ArrowDownIcon size={15} className={cn("text-sky-400")} />
|
||||
<span className="sr-only">Download:</span>
|
||||
{formatBytes(event.bytes_download ?? 0)}
|
||||
</div>
|
||||
<div className={"flex gap-2 items-center whitespace-nowrap"}>
|
||||
<ArrowUpIcon size={15} className={cn("text-netbird")} />
|
||||
<span className="sr-only">Upload:</span>
|
||||
{formatBytes(event.bytes_upload ?? 0)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { ReverseProxyEvent } from "@/interfaces/ReverseProxy";
|
||||
import { formatDuration } from "@utils/helpers";
|
||||
|
||||
type Props = {
|
||||
event: ReverseProxyEvent;
|
||||
@@ -8,7 +9,7 @@ type Props = {
|
||||
export const ReverseProxyEventsDurationCell = ({ event }: Props) => {
|
||||
return (
|
||||
<span className="text-nb-gray-300 text-[0.82rem] px-3 py-2 font-mono">
|
||||
{event.duration_ms}ms
|
||||
{formatDuration(event.duration_ms)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 || "-"}
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
import CopyToClipboardText from "@components/CopyToClipboardText";
|
||||
import TruncatedText from "@components/ui/TruncatedText";
|
||||
import * as React from "react";
|
||||
import { ReverseProxyEvent } from "@/interfaces/ReverseProxy";
|
||||
import {
|
||||
isL4Event,
|
||||
ReverseProxy,
|
||||
ReverseProxyEvent,
|
||||
} from "@/interfaces/ReverseProxy";
|
||||
|
||||
type Props = {
|
||||
event: ReverseProxyEvent;
|
||||
service?: ReverseProxy;
|
||||
};
|
||||
|
||||
export const ReverseProxyEventsMethodCell = ({ event }: Props) => {
|
||||
if (isL4Event(event)) {
|
||||
return (
|
||||
<span className="font-mono text-[0.82rem] font-medium py-2 text-nb-gray-200 uppercase">
|
||||
{event.protocol}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="font-mono text-[0.82rem] font-medium py-2 text-nb-gray-300">
|
||||
{event.method}
|
||||
@@ -15,8 +28,13 @@ export const ReverseProxyEventsMethodCell = ({ event }: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
export const ReverseProxyEventsUrlCell = ({ event }: Props) => {
|
||||
const fullUrl = `${event.host}${event.path}`;
|
||||
export const ReverseProxyEventsUrlCell = ({ event, service }: Props) => {
|
||||
const isL4 = isL4Event(event);
|
||||
const listenPort = service?.listen_port;
|
||||
|
||||
const hostWithPort =
|
||||
isL4 && listenPort ? `${event.host}:${listenPort}` : event.host || "-";
|
||||
const fullUrl = isL4 ? hostWithPort : `${event.host}${event.path}`;
|
||||
|
||||
return (
|
||||
<TruncatedText
|
||||
@@ -34,7 +52,12 @@ export const ReverseProxyEventsUrlCell = ({ event }: Props) => {
|
||||
<CopyToClipboardText message={"URL has been copied to your clipboard"}>
|
||||
<span className="font-mono text-[0.82rem] whitespace-nowrap">
|
||||
<span className="text-nb-gray-200">{event.host}</span>
|
||||
<span className="text-nb-gray-300">{event.path}</span>
|
||||
{isL4 && listenPort && (
|
||||
<span className="text-nb-gray-300">:{listenPort}</span>
|
||||
)}
|
||||
{!isL4 && (
|
||||
<span className="text-nb-gray-300">{event.path}</span>
|
||||
)}
|
||||
</span>
|
||||
</CopyToClipboardText>
|
||||
</TruncatedText>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import * as React from "react";
|
||||
import { ReverseProxyEvent } from "@/interfaces/ReverseProxy";
|
||||
import { isL4Event, ReverseProxyEvent } from "@/interfaces/ReverseProxy";
|
||||
import Badge from "@components/Badge";
|
||||
import EmptyRow from "@/modules/common-table-rows/EmptyRow";
|
||||
|
||||
type Props = {
|
||||
event: ReverseProxyEvent;
|
||||
};
|
||||
|
||||
export const ReverseProxyEventsStatusCell = ({ event }: Props) => {
|
||||
if (isL4Event(event)) return <EmptyRow />;
|
||||
const isSuccess = event.status_code >= 200 && event.status_code < 400;
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,17 +11,17 @@ import GetStartedTest from "@components/ui/GetStartedTest";
|
||||
import type { ColumnDef, SortingState } from "@tanstack/react-table";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
|
||||
import { usePathname } from "next/navigation";
|
||||
import dayjs from "dayjs";
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { DateRange } from "react-day-picker";
|
||||
import { DatePickerWithRange } from "@components/DatePickerWithRange";
|
||||
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||
import { useServerPagination } from "@/contexts/ServerPaginationProvider";
|
||||
import {
|
||||
REVERSE_PROXY_EVENTS_DOCS_LINK,
|
||||
ReverseProxy,
|
||||
ReverseProxyEvent,
|
||||
} from "@/interfaces/ReverseProxy";
|
||||
import useFetchApi from "@/utils/api";
|
||||
import { ReverseProxyEventsStatusCell } from "@/modules/reverse-proxy/events/ReverseProxyEventsStatusCell";
|
||||
import { ReverseProxyEventsUserCell } from "@/modules/reverse-proxy/events/ReverseProxyEventsUserCell";
|
||||
import { ReverseProxyEventsLocationIpCell } from "@/modules/reverse-proxy/events/ReverseProxyEventsLocationIpCell";
|
||||
@@ -33,12 +33,17 @@ import { ReverseProxyEventsTimeCell } from "@/modules/reverse-proxy/events/Rever
|
||||
import { ReverseProxyEventsAuthMethodCell } from "@/modules/reverse-proxy/events/ReverseProxyEventsAuthMethodCell";
|
||||
import { ReverseProxyEventsReasonCell } from "@/modules/reverse-proxy/events/ReverseProxyEventsReasonCell";
|
||||
import { ReverseProxyEventsDurationCell } from "@/modules/reverse-proxy/events/ReverseProxyEventsDurationCell";
|
||||
import { ReverseProxyEventsBytesCell } from "@/modules/reverse-proxy/events/ReverseProxyEventsBytesCell";
|
||||
|
||||
export const ReverseProxyEventsTableColumns: ColumnDef<ReverseProxyEvent>[] = [
|
||||
export const makeEventsColumns = (
|
||||
servicesMap: Map<string, ReverseProxy>,
|
||||
): ColumnDef<ReverseProxyEvent>[] => [
|
||||
{
|
||||
id: "timestamp",
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column} sorting={false}>Time</DataTableHeader>
|
||||
<DataTableHeader column={column} name="timestamp">
|
||||
Time
|
||||
</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<ReverseProxyEventsTimeCell timestamp={row.original.timestamp} />
|
||||
@@ -52,7 +57,9 @@ export const ReverseProxyEventsTableColumns: ColumnDef<ReverseProxyEvent>[] = [
|
||||
accessorFn: (row) =>
|
||||
`${row.source_ip} ${row.city_name || ""} ${row.country_code || ""}`,
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column} sorting={false}>Location / IP</DataTableHeader>
|
||||
<DataTableHeader column={column} name="source_ip">
|
||||
Location / IP
|
||||
</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<ReverseProxyEventsLocationIpCell event={row.original} />
|
||||
@@ -62,24 +69,35 @@ export const ReverseProxyEventsTableColumns: ColumnDef<ReverseProxyEvent>[] = [
|
||||
id: "method",
|
||||
accessorKey: "method",
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column} sorting={false}>Method</DataTableHeader>
|
||||
<DataTableHeader column={column} name="method">
|
||||
Method
|
||||
</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => <ReverseProxyEventsMethodCell event={row.original} />,
|
||||
filterFn: "arrIncludesSomeExact",
|
||||
},
|
||||
{
|
||||
id: "url",
|
||||
accessorFn: (row) => `${row.host} ${row.path}`,
|
||||
accessorFn: (row) => `${row.host} ${row.path || ""}`,
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column} sorting={false}>URL</DataTableHeader>
|
||||
<DataTableHeader column={column} name="url" sorting={false}>
|
||||
Host / URL
|
||||
</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<ReverseProxyEventsUrlCell
|
||||
event={row.original}
|
||||
service={servicesMap.get(row.original.service_id)}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => <ReverseProxyEventsUrlCell event={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
accessorKey: "status_code",
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column} sorting={false}>Status</DataTableHeader>
|
||||
<DataTableHeader column={column} name="status_code">
|
||||
Status
|
||||
</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => <ReverseProxyEventsStatusCell event={row.original} />,
|
||||
size: 80,
|
||||
@@ -94,15 +112,29 @@ export const ReverseProxyEventsTableColumns: ColumnDef<ReverseProxyEvent>[] = [
|
||||
id: "duration",
|
||||
accessorKey: "duration_ms",
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column} sorting={false}>Duration</DataTableHeader>
|
||||
<DataTableHeader column={column} name="duration">
|
||||
Duration
|
||||
</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => <ReverseProxyEventsDurationCell event={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "bytes",
|
||||
accessorFn: (row) => (row.bytes_download ?? 0) + (row.bytes_upload ?? 0),
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column} sorting={false}>
|
||||
Bytes
|
||||
</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => <ReverseProxyEventsBytesCell event={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "auth_method",
|
||||
accessorKey: "auth_method_used",
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column} sorting={false}>Auth Method</DataTableHeader>
|
||||
<DataTableHeader column={column} name="auth_method">
|
||||
Auth Method
|
||||
</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<ReverseProxyEventsAuthMethodCell event={row.original} />
|
||||
@@ -112,7 +144,9 @@ export const ReverseProxyEventsTableColumns: ColumnDef<ReverseProxyEvent>[] = [
|
||||
id: "reason",
|
||||
accessorKey: "reason",
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column} sorting={false}>Reason</DataTableHeader>
|
||||
<DataTableHeader column={column} name="reason">
|
||||
Reason
|
||||
</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => <ReverseProxyEventsReasonCell event={row.original} />,
|
||||
},
|
||||
@@ -120,7 +154,9 @@ export const ReverseProxyEventsTableColumns: ColumnDef<ReverseProxyEvent>[] = [
|
||||
id: "user",
|
||||
accessorFn: (row) => row.user_id || "",
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column} sorting={false}>User</DataTableHeader>
|
||||
<DataTableHeader column={column} name="user_id">
|
||||
User
|
||||
</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => <ReverseProxyEventsUserCell event={row.original} />,
|
||||
},
|
||||
@@ -136,8 +172,6 @@ type Props = {
|
||||
export default function ReverseProxyEventsTable({
|
||||
headingTarget,
|
||||
}: Readonly<Props>) {
|
||||
const path = usePathname();
|
||||
|
||||
const {
|
||||
data: events,
|
||||
isLoading,
|
||||
@@ -148,6 +182,20 @@ export default function ReverseProxyEventsTable({
|
||||
...paginationProps
|
||||
} = useServerPagination<ReverseProxyEvent[]>();
|
||||
|
||||
const { data: services } = useFetchApi<ReverseProxy[]>(
|
||||
"/reverse-proxies/services",
|
||||
);
|
||||
|
||||
const servicesMap = useMemo(() => {
|
||||
const map = new Map<string, ReverseProxy>();
|
||||
for (const svc of services ?? []) {
|
||||
if (svc.id) map.set(svc.id, svc);
|
||||
}
|
||||
return map;
|
||||
}, [services]);
|
||||
|
||||
const columns = useMemo(() => makeEventsColumns(servicesMap), [servicesMap]);
|
||||
|
||||
const activeStatus = getFilter("status");
|
||||
|
||||
const dateRange = useMemo<DateRange | undefined>(() => {
|
||||
@@ -174,15 +222,12 @@ export default function ReverseProxyEventsTable({
|
||||
[setFilter],
|
||||
);
|
||||
|
||||
const [sorting, setSorting] = useLocalStorage<SortingState>(
|
||||
"netbird-table-sort" + path,
|
||||
[
|
||||
{
|
||||
id: "timestamp",
|
||||
desc: true,
|
||||
},
|
||||
],
|
||||
);
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{
|
||||
id: "timestamp",
|
||||
desc: true,
|
||||
},
|
||||
]);
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
@@ -195,7 +240,7 @@ export default function ReverseProxyEventsTable({
|
||||
text={"Proxy Events"}
|
||||
sorting={sorting}
|
||||
setSorting={setSorting}
|
||||
columns={ReverseProxyEventsTableColumns}
|
||||
columns={columns}
|
||||
columnVisibility={{ is_success: false, id: false }}
|
||||
searchPlaceholder={"Search by IP, host, path, user..."}
|
||||
getStartedCard={
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,10 +1,59 @@
|
||||
import Badge from "@components/Badge";
|
||||
import Button from "@components/Button";
|
||||
import { Settings, ShieldCheck, ShieldOff } from "lucide-react";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@components/HoverCard";
|
||||
import { ListItem } from "@components/ListItem";
|
||||
import GroupBadge from "@components/ui/GroupBadge";
|
||||
import { UserCountStack } from "@components/ui/MultipleGroups";
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
Binary,
|
||||
FileCode2Icon,
|
||||
HelpCircle,
|
||||
LockKeyhole,
|
||||
LockOpenIcon,
|
||||
LucideIcon,
|
||||
RectangleEllipsis,
|
||||
Settings,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useGroups } from "@/contexts/GroupsProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
|
||||
import { ReverseProxy } from "@/interfaces/ReverseProxy";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { isL4Mode, ReverseProxy } from "@/interfaces/ReverseProxy";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
|
||||
const AUTH_METHODS: {
|
||||
key: "password_auth" | "pin_auth" | "bearer_auth";
|
||||
label: string;
|
||||
hoverLabel: string;
|
||||
Icon: LucideIcon;
|
||||
}[] = [
|
||||
{
|
||||
key: "password_auth",
|
||||
label: "Password",
|
||||
hoverLabel: "Password",
|
||||
Icon: RectangleEllipsis,
|
||||
},
|
||||
{ key: "pin_auth", label: "PIN Code", hoverLabel: "PIN Code", Icon: Binary },
|
||||
{
|
||||
key: "bearer_auth",
|
||||
label: "SSO",
|
||||
hoverLabel: "SSO (Single Sign On)",
|
||||
Icon: Users,
|
||||
},
|
||||
];
|
||||
|
||||
const HEADER_AUTH_METHOD = {
|
||||
label: "HTTP Headers",
|
||||
hoverLabel: "HTTP Headers",
|
||||
Icon: FileCode2Icon,
|
||||
};
|
||||
|
||||
type Props = {
|
||||
reverseProxy: ReverseProxy;
|
||||
@@ -15,50 +64,183 @@ export default function ReverseProxyAuthCell({
|
||||
}: Readonly<Props>) {
|
||||
const { permission } = usePermissions();
|
||||
const { openModal } = useReverseProxies();
|
||||
const { groups } = useGroups();
|
||||
|
||||
if (isL4Mode(reverseProxy.mode)) {
|
||||
return (
|
||||
<div className={"flex"}>
|
||||
<FullTooltip
|
||||
content={
|
||||
<div className={"flex text-xs max-w-[340px]"}>
|
||||
Auth methods are not supported for TCP/UDP and TLS passthrough
|
||||
services as they operate at the network layer.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Badge variant={"gray"}>
|
||||
N/A
|
||||
<HelpCircle size={12} />
|
||||
</Badge>
|
||||
</FullTooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const auth = reverseProxy.auth;
|
||||
|
||||
const enabledCount = [
|
||||
auth?.bearer_auth?.enabled,
|
||||
auth?.link_auth?.enabled,
|
||||
auth?.password_auth?.enabled,
|
||||
auth?.pin_auth?.enabled,
|
||||
].filter(Boolean).length;
|
||||
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 ?? [])
|
||||
.map((groupId) => groups?.find((g) => g.id === groupId))
|
||||
.filter((g): g is Group => g != undefined)
|
||||
: [];
|
||||
|
||||
const canConfigure = !!permission?.services?.update;
|
||||
const singleAuth =
|
||||
authCount === 1
|
||||
? enabled.length === 1
|
||||
? enabled[0]
|
||||
: HEADER_AUTH_METHOD
|
||||
: null;
|
||||
const SingleAuthIcon = singleAuth?.Icon ?? null;
|
||||
|
||||
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" });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{enabledCount > 0 ? (
|
||||
<Badge variant={"gray"} useHover={false} className={"cursor-pointer"}>
|
||||
<ShieldCheck size={12} className="text-green-400" />
|
||||
<div>
|
||||
<span className={"font-medium text-xs"}>Enabled</span>
|
||||
</div>
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant={"gray"}>
|
||||
<ShieldOff size={12} className="text-red-500" />
|
||||
<span className={"font-medium text-xs"}>None</span>
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<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>
|
||||
<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>
|
||||
}
|
||||
>
|
||||
{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>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
ReverseProxyDomainType,
|
||||
} from "@/interfaces/ReverseProxy";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import { isNetBirdHosted } from "@/utils/netbird";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
|
||||
type Props = {
|
||||
reverseProxy: ReverseProxy;
|
||||
@@ -29,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
|
||||
@@ -40,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}
|
||||
@@ -52,15 +54,29 @@ export default function ReverseProxyClusterCell({
|
||||
return (
|
||||
<FullTooltip
|
||||
content={
|
||||
<div className={"flex flex-col gap-1 text-xs max-w-xs"}>
|
||||
Cluster {reverseProxy.proxy_cluster} is offline. Make sure the proxy
|
||||
server is running and connected to the right management address.
|
||||
</div>
|
||||
isNetBirdHosted() ? (
|
||||
<div className={"text-xs max-w-xs"}>
|
||||
Cluster {reverseProxy.proxy_cluster} is offline. Please try again in
|
||||
a few minutes. If the issue persists, check{" "}
|
||||
<InlineLink href={"https://status.netbird.io/"} target={"_blank"}>
|
||||
NetBird Status
|
||||
</InlineLink>{" "}
|
||||
or reach out to{" "}
|
||||
<InlineLink href={"mailto:support@netbird.io"}>
|
||||
support@netbird.io
|
||||
</InlineLink>
|
||||
</div>
|
||||
) : (
|
||||
<div className={"flex flex-col gap-1 text-xs max-w-xs"}>
|
||||
Cluster {reverseProxy.proxy_cluster} is offline. Make sure the proxy
|
||||
server is running and connected to the right management address.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { cn } from "@utils/helpers";
|
||||
import { ChevronDown, ChevronRightIcon, LockIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { ReverseProxy } from "@/interfaces/ReverseProxy";
|
||||
import { isL4Mode, ReverseProxy, ServiceMode } from "@/interfaces/ReverseProxy";
|
||||
import ExternalLinkText from "@components/ExternalLinkText";
|
||||
import CopyToClipboardText from "@components/CopyToClipboardText";
|
||||
|
||||
type Props = {
|
||||
reverseProxy?: ReverseProxy;
|
||||
@@ -18,8 +19,14 @@ export default function ReverseProxyNameCell({
|
||||
showChevron = true,
|
||||
}: Readonly<Props>) {
|
||||
const displayDomain = domain ?? reverseProxy?.domain ?? "";
|
||||
const isL4 = reverseProxy?.mode && isL4Mode(reverseProxy.mode);
|
||||
const portSuffix =
|
||||
isL4 && reverseProxy?.listen_port ? `:${reverseProxy.listen_port}` : "";
|
||||
const isLinkable = !isL4 || reverseProxy?.mode === ServiceMode.TLS;
|
||||
|
||||
const isEnabled = enabled ?? reverseProxy?.enabled ?? false;
|
||||
const hasTargets = (reverseProxy?.targets?.length ?? 0) > 0;
|
||||
const hasExpandableTargets =
|
||||
(reverseProxy?.targets?.length ?? 0) > 0 && !isL4Mode(reverseProxy?.mode);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -29,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 && (
|
||||
<>
|
||||
@@ -36,14 +44,14 @@ export default function ReverseProxyNameCell({
|
||||
size={20}
|
||||
className={cn(
|
||||
"group-data-[accordion=opened]/accordion:hidden text-nb-gray-400 shrink-0",
|
||||
!hasTargets && "cursor-default opacity-0",
|
||||
!hasExpandableTargets && "cursor-default opacity-0",
|
||||
)}
|
||||
/>
|
||||
<ChevronDown
|
||||
size={20}
|
||||
className={cn(
|
||||
"group-data-[accordion=closed]/accordion:hidden text-nb-gray-400 shrink-0",
|
||||
!hasTargets && "cursor-default opacity-0",
|
||||
!hasExpandableTargets && "cursor-default opacity-0",
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
@@ -57,13 +65,21 @@ export default function ReverseProxyNameCell({
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col gap-0 dark:text-neutral-300 text-neutral-500 truncate">
|
||||
{displayDomain ? (
|
||||
<ExternalLinkText href={`https://${displayDomain}`}>
|
||||
<span className="font-medium truncate">{displayDomain}</span>
|
||||
</ExternalLinkText>
|
||||
) : (
|
||||
<span className="font-medium truncate">{displayDomain}</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{displayDomain && isLinkable ? (
|
||||
<ExternalLinkText href={`https://${displayDomain}${portSuffix}`}>
|
||||
<span className="font-medium truncate">
|
||||
{displayDomain}
|
||||
{portSuffix}
|
||||
</span>
|
||||
</ExternalLinkText>
|
||||
) : (
|
||||
<CopyToClipboardText>
|
||||
{displayDomain}
|
||||
{portSuffix}
|
||||
</CopyToClipboardText>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import {
|
||||
REVERSE_PROXY_TROUBLESHOOTING_DOCS_LINK,
|
||||
ReverseProxy,
|
||||
ReverseProxyMeta,
|
||||
ReverseProxyStatus,
|
||||
} from "@/interfaces/ReverseProxy";
|
||||
import useFetchApi from "@utils/api";
|
||||
import Badge from "@components/Badge";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import { CircleAlert, Loader2 } from "lucide-react";
|
||||
import { useRef } from "react";
|
||||
|
||||
type Props = {
|
||||
serviceId: string;
|
||||
meta?: ReverseProxyMeta;
|
||||
enabled?: boolean;
|
||||
isL4?: boolean;
|
||||
};
|
||||
|
||||
const POLL_INTERVAL_MS = 3500;
|
||||
@@ -20,41 +24,112 @@ export default function ReverseProxyStatusCell({
|
||||
serviceId,
|
||||
meta,
|
||||
enabled,
|
||||
isL4,
|
||||
}: Readonly<Props>) {
|
||||
const status = meta?.status;
|
||||
const certificateIssued = !!meta?.certificate_issued_at;
|
||||
const dataRef = useRef<ReverseProxy | undefined>(undefined);
|
||||
|
||||
const isSettingUp =
|
||||
enabled &&
|
||||
status !== undefined &&
|
||||
status !== ReverseProxyStatus.ACTIVE &&
|
||||
!certificateIssued;
|
||||
const isActive =
|
||||
meta?.status === ReverseProxyStatus.ACTIVE ||
|
||||
dataRef.current?.meta?.status === ReverseProxyStatus.ACTIVE;
|
||||
|
||||
const hasError =
|
||||
meta?.status === ReverseProxyStatus.ERROR ||
|
||||
dataRef.current?.meta?.status === ReverseProxyStatus.ERROR;
|
||||
|
||||
const isTunnelNotCreated =
|
||||
meta?.status === ReverseProxyStatus.TUNNEL_NOT_CREATED ||
|
||||
dataRef.current?.meta?.status === ReverseProxyStatus.TUNNEL_NOT_CREATED;
|
||||
|
||||
const certificateIssued =
|
||||
!!meta?.certificate_issued_at ||
|
||||
!!dataRef.current?.meta?.certificate_issued_at;
|
||||
|
||||
const shouldPoll = !!enabled && !(isActive && (isL4 || certificateIssued));
|
||||
|
||||
const { data } = useFetchApi<ReverseProxy>(
|
||||
`/reverse-proxies/services/${serviceId}`,
|
||||
true,
|
||||
false,
|
||||
isSettingUp,
|
||||
shouldPoll,
|
||||
{ refreshInterval: POLL_INTERVAL_MS },
|
||||
);
|
||||
|
||||
const currentStatus = data?.meta?.status ?? status;
|
||||
dataRef.current = data;
|
||||
|
||||
const currentCertificateIssued = useMemo(() => {
|
||||
if (data && data?.meta) return !!data?.meta?.certificate_issued_at;
|
||||
return certificateIssued;
|
||||
}, [data]);
|
||||
if (!enabled) return null;
|
||||
|
||||
if (
|
||||
!enabled ||
|
||||
(currentStatus === ReverseProxyStatus.ACTIVE && currentCertificateIssued)
|
||||
) {
|
||||
return null;
|
||||
// L4 services don't need certificates
|
||||
if (isL4) {
|
||||
if (isActive) return null;
|
||||
if (hasError) {
|
||||
return (
|
||||
<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>
|
||||
</FullTooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isTunnelNotCreated) {
|
||||
return (
|
||||
<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>
|
||||
</FullTooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <SettingUpService />;
|
||||
}
|
||||
|
||||
if (!currentCertificateIssued) {
|
||||
// HTTP services: hide once active with certificate issued
|
||||
if (isActive && certificateIssued) {
|
||||
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...
|
||||
@@ -63,12 +138,16 @@ export default function ReverseProxyStatusCell({
|
||||
);
|
||||
}
|
||||
|
||||
return <SettingUpService />;
|
||||
}
|
||||
|
||||
const SettingUpService = () => {
|
||||
return (
|
||||
<div className={"flex"}>
|
||||
<div className={"flex"} data-status-cell>
|
||||
<Badge variant={"yellow"}>
|
||||
<Loader2 size={14} className={"animate-spin"} />
|
||||
Setting up service...
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -17,17 +17,20 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
|
||||
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||
import {
|
||||
isL4Mode,
|
||||
REVERSE_PROXY_DOCS_LINK,
|
||||
ReverseProxy,
|
||||
} 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";
|
||||
import ReverseProxyTargetsCell from "@/modules/reverse-proxy/table/ReverseProxyTargetsCell";
|
||||
import ReverseProxyTargetsTable from "@/modules/reverse-proxy/targets/ReverseProxyTargetsTable";
|
||||
import ReverseProxyStatusCell from "@/modules/reverse-proxy/table/ReverseProxyStatusCell";
|
||||
import { ReverseProxyTypeCell } from "@/modules/reverse-proxy/table/ReverseProxyTypeCell";
|
||||
|
||||
const ReverseProxyColumns: ColumnDef<ReverseProxy>[] = [
|
||||
{
|
||||
@@ -38,6 +41,14 @@ const ReverseProxyColumns: ColumnDef<ReverseProxy>[] = [
|
||||
sortingFn: "text",
|
||||
cell: ({ row }) => <ReverseProxyNameCell reverseProxy={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "mode",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Type</DataTableHeader>;
|
||||
},
|
||||
sortingFn: "text",
|
||||
cell: ({ row }) => <ReverseProxyTypeCell reverseProxy={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
accessorFn: (proxy) => proxy?.meta?.certificate_issued_at,
|
||||
@@ -48,6 +59,7 @@ const ReverseProxyColumns: ColumnDef<ReverseProxy>[] = [
|
||||
serviceId={row.original.id}
|
||||
meta={row.original.meta}
|
||||
enabled={row.original.enabled}
|
||||
isL4={isL4Mode(row.original.mode)}
|
||||
/>
|
||||
) : null,
|
||||
},
|
||||
@@ -68,17 +80,28 @@ const ReverseProxyColumns: ColumnDef<ReverseProxy>[] = [
|
||||
{
|
||||
accessorKey: "targets",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Targets</DataTableHeader>;
|
||||
return <DataTableHeader column={column}>Target(s)</DataTableHeader>;
|
||||
},
|
||||
cell: ({ row }) => <ReverseProxyTargetsCell reverseProxy={row.original} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "auth",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Authentication</DataTableHeader>;
|
||||
return <DataTableHeader column={column}>Auth Methods</DataTableHeader>;
|
||||
},
|
||||
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: "",
|
||||
@@ -131,7 +154,9 @@ export default function ReverseProxyTable({ headingTarget }: Readonly<Props>) {
|
||||
useRowId={true}
|
||||
searchPlaceholder={"Search by URL, domain, or target..."}
|
||||
columnVisibility={{ searchString: false }}
|
||||
tableCellClassName={"h-[80px]"}
|
||||
renderExpandedRow={(reverseProxy) => {
|
||||
if (isL4Mode(reverseProxy.mode)) return;
|
||||
const hasTargets = (reverseProxy?.targets?.length ?? 0) > 0;
|
||||
if (!hasTargets) return;
|
||||
return (
|
||||
@@ -159,7 +184,6 @@ export default function ReverseProxyTable({ headingTarget }: Readonly<Props>) {
|
||||
button={
|
||||
<Button
|
||||
variant={"primary"}
|
||||
className={""}
|
||||
onClick={() => openModal()}
|
||||
disabled={!permission?.services?.create}
|
||||
>
|
||||
|
||||
@@ -4,7 +4,8 @@ import { PlusCircle, Server } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
|
||||
import { ReverseProxy } from "@/interfaces/ReverseProxy";
|
||||
import { isL4Mode, ReverseProxy } from "@/interfaces/ReverseProxy";
|
||||
import { ReverseProxyTargetDevice } from "@/modules/reverse-proxy/targets/ReverseProxyTargetDevice";
|
||||
|
||||
type Props = {
|
||||
reverseProxy: ReverseProxy;
|
||||
@@ -16,10 +17,26 @@ export default function ReverseProxyTargetsCell({
|
||||
const { permission } = usePermissions();
|
||||
const { openTargetModal } = useReverseProxies();
|
||||
|
||||
if (isL4Mode(reverseProxy.mode)) {
|
||||
const target = reverseProxy?.targets?.[0];
|
||||
const address = target.host
|
||||
? `${target.host}:${target.port}`
|
||||
: `:${target.port}`;
|
||||
|
||||
return (
|
||||
<ReverseProxyTargetDevice
|
||||
target={target}
|
||||
address={address}
|
||||
wrapperClassName={"h-[48px]"}
|
||||
skeletonClassName={"h-[48px]"}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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"}
|
||||
|
||||
71
src/modules/reverse-proxy/table/ReverseProxyTypeCell.tsx
Normal file
71
src/modules/reverse-proxy/table/ReverseProxyTypeCell.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import * as React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { ReverseProxy, ServiceMode } from "@/interfaces/ReverseProxy";
|
||||
import { trim } from "lodash";
|
||||
import { SERVICE_MODES } from "@/modules/reverse-proxy/ReverseProxyServiceModeSelector";
|
||||
import Badge from "@components/Badge";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { ArrowRightFromLineIcon, GlobeIcon, LockKeyhole } from "lucide-react";
|
||||
|
||||
type Props = {
|
||||
reverseProxy?: ReverseProxy;
|
||||
};
|
||||
|
||||
export const ReverseProxyTypeCell = ({ reverseProxy }: Props) => {
|
||||
const serviceModeLabel = useMemo(() => {
|
||||
if (!reverseProxy?.mode) return "HTTPS";
|
||||
const mode = SERVICE_MODES[reverseProxy.mode];
|
||||
if (!mode) return "HTTPS";
|
||||
return trim(mode.label.replace("Service", ""));
|
||||
}, [reverseProxy]);
|
||||
|
||||
return (
|
||||
<div className={"flex"}>
|
||||
<Badge variant={"gray"} className={"font-normal"}>
|
||||
<ReverseProxyServiceIcon
|
||||
reverseProxy={reverseProxy}
|
||||
className={"text-nb-gray-200"}
|
||||
size={11}
|
||||
/>
|
||||
{serviceModeLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type ReverseProxyServiceIconProps = {
|
||||
reverseProxy?: ReverseProxy;
|
||||
className?: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export const ReverseProxyServiceIcon = ({
|
||||
reverseProxy,
|
||||
className,
|
||||
size = 14,
|
||||
}: ReverseProxyServiceIconProps) => {
|
||||
const mode = reverseProxy?.mode;
|
||||
|
||||
switch (mode) {
|
||||
case ServiceMode.HTTP:
|
||||
return <GlobeIcon size={size} className={cn("shrink-0", className)} />;
|
||||
case ServiceMode.TLS:
|
||||
return <LockKeyhole size={size} className={cn("shrink-0", className)} />;
|
||||
case ServiceMode.TCP:
|
||||
return (
|
||||
<ArrowRightFromLineIcon
|
||||
size={size}
|
||||
className={cn("shrink-0", className)}
|
||||
/>
|
||||
);
|
||||
case ServiceMode.UDP:
|
||||
return (
|
||||
<ArrowRightFromLineIcon
|
||||
size={size}
|
||||
className={cn("shrink-0", className)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <GlobeIcon size={size} className={cn("shrink-0", className)} />;
|
||||
}
|
||||
};
|
||||
100
src/modules/reverse-proxy/targets/ReverseProxyAddressInput.tsx
Normal file
100
src/modules/reverse-proxy/targets/ReverseProxyAddressInput.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Input } from "@components/Input";
|
||||
import React, { useMemo } from "react";
|
||||
import cidr from "ip-cidr";
|
||||
import type { Target } from "@/modules/reverse-proxy/targets/ReverseProxyTargetSelector";
|
||||
import { ReverseProxyTargetType } from "@/interfaces/ReverseProxy";
|
||||
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { HelpTooltip } from "@components/HelpTooltip";
|
||||
|
||||
export function useReverseProxyAddress(target: Target | undefined) {
|
||||
const { resources } = useReverseProxies();
|
||||
const resource = useMemo(
|
||||
() => resources?.find((r) => r.id === target?.resourceId),
|
||||
[resources, target?.resourceId],
|
||||
);
|
||||
const resourceAddress = resource?.address || "";
|
||||
|
||||
const isCidrRange = useMemo(() => {
|
||||
if (target?.type === ReverseProxyTargetType.SUBNET) return true;
|
||||
if (!resourceAddress) return false;
|
||||
if (!cidr.isValidCIDR(resourceAddress)) return false;
|
||||
const parts = resourceAddress.split("/");
|
||||
const mask = parts.length === 2 ? parseInt(parts[1], 10) : 32;
|
||||
return mask < 32;
|
||||
}, [target?.type, resourceAddress]);
|
||||
|
||||
const cidrInfo = useMemo(() => {
|
||||
if (!resourceAddress) return null;
|
||||
if (!cidr.isValidCIDR(resourceAddress)) return null;
|
||||
try {
|
||||
return new cidr(resourceAddress);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [resourceAddress]);
|
||||
|
||||
const isHostEditable = isCidrRange;
|
||||
|
||||
const isHostInCidrRange = useMemo(() => {
|
||||
if (!cidrInfo || !target?.host) return false;
|
||||
if (!cidr.isValidAddress(target.host)) return false;
|
||||
return cidrInfo.contains(target.host);
|
||||
}, [cidrInfo, target?.host]);
|
||||
|
||||
const isValidCidrHost =
|
||||
!isCidrRange ||
|
||||
(!!target?.host && !!cidrInfo && isHostInCidrRange);
|
||||
|
||||
return {
|
||||
resourceAddress,
|
||||
cidrInfo,
|
||||
isCidrRange,
|
||||
isHostEditable,
|
||||
isHostInCidrRange,
|
||||
isValidCidrHost,
|
||||
};
|
||||
}
|
||||
|
||||
export function CidrHelpText({ target }: { target: Target | undefined }) {
|
||||
const { cidrInfo, resourceAddress } = useReverseProxyAddress(target);
|
||||
if (!cidrInfo) return null;
|
||||
return (
|
||||
<HelpTooltip content={`Enter an IP address within ${resourceAddress}`} />
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
value: Target | undefined;
|
||||
onChange: React.Dispatch<React.SetStateAction<Target | undefined>>;
|
||||
className?: string;
|
||||
autoFocus?: boolean;
|
||||
};
|
||||
|
||||
export default function ReverseProxyAddressInput({
|
||||
value: target,
|
||||
onChange,
|
||||
className,
|
||||
autoFocus,
|
||||
}: Readonly<Props>) {
|
||||
const { isHostEditable } = useReverseProxyAddress(target);
|
||||
|
||||
return (
|
||||
<Input
|
||||
value={target?.host ?? ""}
|
||||
onChange={(e) => {
|
||||
const host = isHostEditable
|
||||
? e.target.value.replace(/[^0-9.]/g, "")
|
||||
: e.target.value;
|
||||
onChange((prev) => prev && { ...prev, host });
|
||||
}}
|
||||
maxWidthClass={"w-full"}
|
||||
customSuffix={":"}
|
||||
placeholder="e.g., 192.168.0.10"
|
||||
disabled={!target}
|
||||
readOnly={target && !isHostEditable ? true : undefined}
|
||||
className={cn("rounded-r-none border-r-0", className)}
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import Button from "@components/Button";
|
||||
import HelpText from "@components/HelpText";
|
||||
import { Input } from "@components/Input";
|
||||
import { Label } from "@components/Label";
|
||||
import { MinusCircleIcon, PlusIcon } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
const HEADER_NAME_RE = /^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$/;
|
||||
const BLOCKED_HEADERS = new Set([
|
||||
"host",
|
||||
"connection",
|
||||
"transfer-encoding",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailer",
|
||||
"upgrade",
|
||||
]);
|
||||
|
||||
type HeaderEntry = { id: number; name: string; value: string };
|
||||
|
||||
function recordToHeaderEntries(
|
||||
record: Record<string, string> | undefined,
|
||||
nextId: () => number,
|
||||
): HeaderEntry[] {
|
||||
if (!record) return [];
|
||||
return Object.entries(record).map(([name, value]) => ({
|
||||
id: nextId(),
|
||||
name,
|
||||
value,
|
||||
}));
|
||||
}
|
||||
|
||||
export function headerEntriesToRecord(
|
||||
entries: HeaderEntry[],
|
||||
): Record<string, string> | undefined {
|
||||
if (entries.length === 0) return undefined;
|
||||
const record: Record<string, string> = {};
|
||||
for (const entry of entries) {
|
||||
if (entry.name) record[entry.name] = entry.value;
|
||||
}
|
||||
return Object.keys(record).length > 0 ? record : undefined;
|
||||
}
|
||||
|
||||
function validateHeaderName(
|
||||
name: string,
|
||||
allNames: string[],
|
||||
): string | undefined {
|
||||
if (!name) return undefined;
|
||||
if (!HEADER_NAME_RE.test(name))
|
||||
return "Invalid characters in header name. Please use another one.";
|
||||
if (BLOCKED_HEADERS.has(name.toLowerCase()))
|
||||
return `"${name}" is a reserved header. Please use another one.`;
|
||||
const dupeCount = allNames.filter(
|
||||
(n) => n.toLowerCase() === name.toLowerCase(),
|
||||
).length;
|
||||
if (dupeCount > 1) return "Duplicate header name. Please use another one.";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function validateHeaderValue(value: string): string | undefined {
|
||||
if (value.includes("\r") || value.includes("\n"))
|
||||
return "Value must not contain line breaks";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function useCustomHeaders(initialHeaders?: Record<string, string>) {
|
||||
const [nextId] = useState(() => {
|
||||
let id = 0;
|
||||
return () => ++id;
|
||||
});
|
||||
|
||||
const [headerEntries, setHeaderEntries] = useState<HeaderEntry[]>(() =>
|
||||
recordToHeaderEntries(initialHeaders, nextId),
|
||||
);
|
||||
|
||||
const addHeader = useCallback(() => {
|
||||
setHeaderEntries((prev) => [
|
||||
...prev,
|
||||
{ id: nextId(), name: "", value: "" },
|
||||
]);
|
||||
}, [nextId]);
|
||||
|
||||
const removeHeader = useCallback((id: number) => {
|
||||
setHeaderEntries((prev) => prev.filter((h) => h.id !== id));
|
||||
}, []);
|
||||
|
||||
const updateHeaderEntry = useCallback(
|
||||
(id: number, field: "name" | "value", fieldValue: string) => {
|
||||
setHeaderEntries((prev) =>
|
||||
prev.map((h) => (h.id === id ? { ...h, [field]: fieldValue } : h)),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const allHeaderNames = headerEntries.map((h) => h.name);
|
||||
const headerErrors = headerEntries.map((entry) => ({
|
||||
name: validateHeaderName(entry.name, allHeaderNames),
|
||||
value: validateHeaderValue(entry.value),
|
||||
}));
|
||||
|
||||
const hasHeaderErrors = headerErrors.some((e) => e.name || e.value);
|
||||
|
||||
return {
|
||||
headerEntries,
|
||||
setHeaderEntries,
|
||||
addHeader,
|
||||
removeHeader,
|
||||
updateHeaderEntry,
|
||||
headerErrors,
|
||||
hasHeaderErrors,
|
||||
};
|
||||
}
|
||||
|
||||
export type CustomHeadersProps = Pick<
|
||||
ReturnType<typeof useCustomHeaders>,
|
||||
| "headerEntries"
|
||||
| "addHeader"
|
||||
| "removeHeader"
|
||||
| "updateHeaderEntry"
|
||||
| "headerErrors"
|
||||
>;
|
||||
|
||||
export default function ReverseProxyTargetCustomHeaders({
|
||||
headerEntries,
|
||||
addHeader,
|
||||
removeHeader,
|
||||
updateHeaderEntry,
|
||||
headerErrors,
|
||||
}: CustomHeadersProps) {
|
||||
return (
|
||||
<div>
|
||||
<Label>Custom Headers</Label>
|
||||
<HelpText>
|
||||
Add additional headers to include when forwarding requests.
|
||||
<br />
|
||||
Hop-by-hop headers like Host or Connection are not allowed.
|
||||
</HelpText>
|
||||
{headerEntries.length > 0 && (
|
||||
<div className="flex flex-col gap-2 mb-3">
|
||||
{headerEntries.map((entry, index) => (
|
||||
<div key={entry.id} className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="Header, e.g., Authorization"
|
||||
aria-label={`Header name for entry ${entry.id}`}
|
||||
value={entry.name}
|
||||
onChange={(e) =>
|
||||
updateHeaderEntry(entry.id, "name", e.target.value)
|
||||
}
|
||||
maxWidthClass="flex-1"
|
||||
error={headerErrors[index]?.name}
|
||||
errorTooltip
|
||||
/>
|
||||
<Input
|
||||
placeholder="Value, e.g., Bearer eyJhbGci..."
|
||||
aria-label={`Header value for entry ${entry.id}`}
|
||||
value={entry.value}
|
||||
onChange={(e) =>
|
||||
updateHeaderEntry(entry.id, "value", e.target.value)
|
||||
}
|
||||
maxWidthClass="flex-1"
|
||||
error={headerErrors[index]?.value}
|
||||
errorTooltip
|
||||
/>
|
||||
<Button
|
||||
variant="default-outline"
|
||||
className="!px-2 shrink-0"
|
||||
onClick={() => removeHeader(entry.id)}
|
||||
aria-label="Remove header"
|
||||
>
|
||||
<MinusCircleIcon size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Button variant="dotted" className="w-full" size="sm" onClick={addHeader}>
|
||||
<PlusIcon size={14} />
|
||||
Add Header
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,11 +17,19 @@ import EmptyRow from "@/modules/common-table-rows/EmptyRow";
|
||||
type Props = {
|
||||
target: ReverseProxyTarget;
|
||||
showDescription?: boolean;
|
||||
wrapperClassName?: string;
|
||||
skeletonClassName?: string;
|
||||
deviceClassName?: string;
|
||||
address?: string;
|
||||
};
|
||||
|
||||
export const ReverseProxyTargetDevice = ({
|
||||
target,
|
||||
showDescription,
|
||||
wrapperClassName = "h-[59px]",
|
||||
skeletonClassName = "min-h-[59px]",
|
||||
deviceClassName = "",
|
||||
address,
|
||||
}: Props) => {
|
||||
const router = useRouter();
|
||||
const { data: peers, isLoading: isPeersLoading } =
|
||||
@@ -55,17 +63,19 @@ export const ReverseProxyTargetDevice = ({
|
||||
};
|
||||
|
||||
if (isPeersLoading || isResourceLoading || isNetworksLoading)
|
||||
return <SkeletonDeviceCard />;
|
||||
return <SkeletonDeviceCard className={skeletonClassName} />;
|
||||
|
||||
if (!peer && !resource)
|
||||
return (
|
||||
<div className={"min-h-[59px] flex items-center relative left-1"}>
|
||||
<div
|
||||
className={cn("flex items-center relative left-1", wrapperClassName)}
|
||||
>
|
||||
<EmptyRow />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={"min-h-[59px] flex items-center relative -left-2"}>
|
||||
<div className={cn("flex items-center relative -left-2", wrapperClassName)}>
|
||||
<div
|
||||
className={cn(
|
||||
"cursor-pointer rounded-md hover:bg-nb-gray-900/40 flex items-center justify-between group pr-4",
|
||||
@@ -73,8 +83,13 @@ export const ReverseProxyTargetDevice = ({
|
||||
onClick={handleClick}
|
||||
>
|
||||
<DeviceCard
|
||||
address={address}
|
||||
device={peer}
|
||||
className={cn(!target.enabled && "opacity-40", "pl-2")}
|
||||
className={cn(
|
||||
!target.enabled && "opacity-40",
|
||||
"pl-2",
|
||||
deviceClassName,
|
||||
)}
|
||||
resource={resource}
|
||||
description={showDescription ? resource?.description : undefined}
|
||||
/>
|
||||
|
||||
@@ -7,25 +7,22 @@ import {
|
||||
AccordionTrigger,
|
||||
} from "@components/Accordion";
|
||||
import Button from "@components/Button";
|
||||
import { Checkbox } from "@components/Checkbox";
|
||||
import FancyToggleSwitch from "@components/FancyToggleSwitch";
|
||||
import HelpText from "@components/HelpText";
|
||||
import { Input } from "@components/Input";
|
||||
import { Label } from "@components/Label";
|
||||
import { Modal, ModalContent, ModalFooter } from "@components/modal/Modal";
|
||||
import ModalHeader from "@components/modal/ModalHeader";
|
||||
import { PeerGroupSelector } from "@components/PeerGroupSelector";
|
||||
import { SelectDropdown } from "@components/select/SelectDropdown";
|
||||
import useFetchApi from "@utils/api";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ClockFadingIcon,
|
||||
ExternalLinkIcon,
|
||||
HelpCircle,
|
||||
PlusCircle,
|
||||
Server,
|
||||
Settings,
|
||||
ShieldXIcon,
|
||||
} from "lucide-react";
|
||||
import { Callout } from "@components/Callout";
|
||||
import cidr from "ip-cidr";
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
import { Network, NetworkResource } from "@/interfaces/Network";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
@@ -35,18 +32,27 @@ import {
|
||||
ReverseProxyTarget,
|
||||
ReverseProxyTargetProtocol,
|
||||
ReverseProxyTargetType,
|
||||
ServiceMode,
|
||||
ServiceTargetOptionsPathRewrite,
|
||||
} from "@/interfaces/ReverseProxy";
|
||||
import {
|
||||
defaultPortForProtocol,
|
||||
isResourceTargetType,
|
||||
} from "@/contexts/ReverseProxiesProvider";
|
||||
import Separator from "@components/Separator";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { HelpTooltip } from "@components/HelpTooltip";
|
||||
import InlineLink, { InlineButtonLink } from "@components/InlineLink";
|
||||
import SetupModal from "@/modules/setup-netbird-modal/SetupModal";
|
||||
import FullTooltip from "@components/FullTooltip";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import ReverseProxyTargetCustomHeaders from "@/modules/reverse-proxy/targets/ReverseProxyTargetCustomHeaders";
|
||||
import ReverseProxyTargetSelector, {
|
||||
Target,
|
||||
} from "@/modules/reverse-proxy/targets/ReverseProxyTargetSelector";
|
||||
import { useReverseProxyTargetOptions } from "@/modules/reverse-proxy/targets/useReverseProxyTargetOptions";
|
||||
import ReverseProxyAddressInput, {
|
||||
CidrHelpText,
|
||||
useReverseProxyAddress,
|
||||
} from "@/modules/reverse-proxy/targets/ReverseProxyAddressInput";
|
||||
import Separator from "@components/Separator";
|
||||
|
||||
/** Get initial host value based on target, resource, or peer */
|
||||
function getInitialHost(
|
||||
@@ -87,88 +93,44 @@ export default function ReverseProxyTargetModal({
|
||||
}: Readonly<Props>) {
|
||||
const existingTargets = reverseProxy.targets || [];
|
||||
const domain = reverseProxy.domain;
|
||||
// Fetch resources and peers for target selection
|
||||
const { data: resources } = useFetchApi<NetworkResource[]>(
|
||||
"/networks/resources",
|
||||
);
|
||||
const { data: peers } = useFetchApi<Peer[]>("/peers");
|
||||
|
||||
const [targetType, setTargetType] = useState<ReverseProxyTargetType>(
|
||||
currentTarget?.target_type ??
|
||||
(initialResource
|
||||
? (initialResource.type as ReverseProxyTargetType) ??
|
||||
ReverseProxyTargetType.HOST
|
||||
: ReverseProxyTargetType.PEER),
|
||||
);
|
||||
const [targetPeerId, setTargetPeerId] = useState<string | undefined>(
|
||||
currentTarget?.target_type === ReverseProxyTargetType.PEER
|
||||
? currentTarget?.target_id
|
||||
: initialPeer?.id,
|
||||
);
|
||||
const [targetResourceId, setTargetResourceId] = useState<string | undefined>(
|
||||
currentTarget && isResourceTargetType(currentTarget.target_type)
|
||||
? currentTarget?.target_id
|
||||
: initialResource?.id,
|
||||
const [target, setTarget] = useState<Target | undefined>(
|
||||
currentTarget || initialResource || initialPeer
|
||||
? {
|
||||
type:
|
||||
currentTarget?.target_type ??
|
||||
(initialResource
|
||||
? (initialResource.type as ReverseProxyTargetType) ??
|
||||
ReverseProxyTargetType.HOST
|
||||
: ReverseProxyTargetType.PEER),
|
||||
peerId:
|
||||
currentTarget?.target_type === ReverseProxyTargetType.PEER
|
||||
? currentTarget?.target_id
|
||||
: initialPeer?.id,
|
||||
resourceId:
|
||||
currentTarget && isResourceTargetType(currentTarget.target_type)
|
||||
? currentTarget?.target_id
|
||||
: initialResource?.id,
|
||||
host: getInitialHost(currentTarget, initialResource, initialPeer),
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const [targetProtocol, setTargetProtocol] =
|
||||
useState<ReverseProxyTargetProtocol>(
|
||||
currentTarget?.protocol ?? ReverseProxyTargetProtocol.HTTP,
|
||||
);
|
||||
const [targetHost, setTargetHost] = useState(
|
||||
getInitialHost(currentTarget, initialResource, initialPeer),
|
||||
);
|
||||
const [targetPort, setTargetPort] = useState<number>(
|
||||
currentTarget?.port ?? 0,
|
||||
);
|
||||
const [targetPath, setTargetPath] = useState(currentTarget?.path ?? "");
|
||||
const [accessLocal, setAccessLocal] = useState(
|
||||
currentTarget?.access_local ?? false,
|
||||
);
|
||||
const [accessLocal] = useState(currentTarget?.access_local ?? false);
|
||||
const [options, setOption, { getTargetOptions, headers, errors }] =
|
||||
useReverseProxyTargetOptions(currentTarget?.options);
|
||||
const portInputRef = useRef<HTMLInputElement>(null);
|
||||
const [installModal, setInstallModal] = useState(false);
|
||||
|
||||
// Get the current resource's address (from initialResource or selected resource)
|
||||
const currentResourceAddress = useMemo(() => {
|
||||
if (initialResource) return initialResource.address;
|
||||
if (targetResourceId) {
|
||||
const resource = resources?.find((r) => r.id === targetResourceId);
|
||||
return resource?.address || "";
|
||||
}
|
||||
return "";
|
||||
}, [initialResource, targetResourceId, resources]);
|
||||
|
||||
// Parse the CIDR using ip-cidr library
|
||||
const cidrInfo = useMemo(() => {
|
||||
if (!currentResourceAddress) return null;
|
||||
if (!cidr.isValidCIDR(currentResourceAddress)) return null;
|
||||
try {
|
||||
return new cidr(currentResourceAddress);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [currentResourceAddress]);
|
||||
|
||||
// Get the CIDR mask (e.g., 24 for /24)
|
||||
const cidrMask = useMemo(() => {
|
||||
if (!cidrInfo) return null;
|
||||
const parts = currentResourceAddress.split("/");
|
||||
return parts.length === 2 ? parseInt(parts[1], 10) : 32;
|
||||
}, [cidrInfo, currentResourceAddress]);
|
||||
|
||||
// Check if address is a CIDR range (has more than one address)
|
||||
const isCidrRange = useMemo(() => {
|
||||
return cidrMask !== null && cidrMask < 32;
|
||||
}, [cidrMask]);
|
||||
|
||||
// Host should be editable if it's a CIDR range with multiple addresses
|
||||
const isHostEditable = isCidrRange;
|
||||
|
||||
// Validate if current host is within CIDR range
|
||||
const isHostInCidrRange = useMemo(() => {
|
||||
if (!cidrInfo || !targetHost) return false;
|
||||
if (!cidr.isValidAddress(targetHost)) return false;
|
||||
return cidrInfo.contains(targetHost);
|
||||
}, [cidrInfo, targetHost]);
|
||||
const { isCidrRange, isHostEditable, isValidCidrHost } =
|
||||
useReverseProxyAddress(target);
|
||||
|
||||
// Normalize path for comparison (ensure it starts with / and handle empty as /)
|
||||
const normalizePath = (path: string | undefined) => {
|
||||
@@ -196,7 +158,6 @@ export default function ReverseProxyTargetModal({
|
||||
|
||||
const isValidPort =
|
||||
targetPort === 0 || (targetPort >= 1 && targetPort <= 65535);
|
||||
const isValidCidrHost = !isCidrRange || (targetHost && isHostInCidrRange);
|
||||
|
||||
const canAddTarget = useMemo(() => {
|
||||
// Don't allow if path is duplicate or port is invalid
|
||||
@@ -209,11 +170,12 @@ export default function ReverseProxyTargetModal({
|
||||
if (initialPeer) {
|
||||
return true;
|
||||
}
|
||||
if (targetType === ReverseProxyTargetType.PEER) {
|
||||
return !!targetPeerId;
|
||||
if (!target) return false;
|
||||
if (target.type === ReverseProxyTargetType.PEER) {
|
||||
return !!target.peerId;
|
||||
}
|
||||
if (isResourceTargetType(targetType)) {
|
||||
return !!targetResourceId && isValidCidrHost;
|
||||
if (isResourceTargetType(target.type)) {
|
||||
return !!target.resourceId && isValidCidrHost;
|
||||
}
|
||||
return false;
|
||||
}, [
|
||||
@@ -221,43 +183,44 @@ export default function ReverseProxyTargetModal({
|
||||
isValidPort,
|
||||
initialResource,
|
||||
initialPeer,
|
||||
targetType,
|
||||
targetPeerId,
|
||||
targetResourceId,
|
||||
target,
|
||||
isValidCidrHost,
|
||||
]);
|
||||
|
||||
const hasTarget =
|
||||
initialResource || initialPeer || targetPeerId || targetResourceId;
|
||||
const hasTarget = !!(initialResource || initialPeer || target);
|
||||
|
||||
const handleSave = () => {
|
||||
const resolvedType = initialPeer ? ReverseProxyTargetType.PEER : targetType;
|
||||
const isResource = isResourceTargetType(resolvedType) || !!initialResource;
|
||||
if (!target) return;
|
||||
const resolvedType = initialPeer
|
||||
? ReverseProxyTargetType.PEER
|
||||
: target.type;
|
||||
const resolvedIsResource =
|
||||
isResourceTargetType(resolvedType) || !!initialResource;
|
||||
const targetData: ReverseProxyTarget = {
|
||||
target_type: resolvedType,
|
||||
target_id:
|
||||
resolvedType === ReverseProxyTargetType.PEER
|
||||
? targetPeerId
|
||||
: targetResourceId,
|
||||
? target.peerId
|
||||
: target.resourceId,
|
||||
protocol: targetProtocol,
|
||||
host:
|
||||
resolvedType === ReverseProxyTargetType.SUBNET ? targetHost : undefined,
|
||||
resolvedType === ReverseProxyTargetType.SUBNET
|
||||
? target.host
|
||||
: undefined,
|
||||
port: targetPort,
|
||||
path: targetPath || undefined,
|
||||
enabled: currentTarget?.enabled ?? true,
|
||||
access_local: isResource ? accessLocal : undefined,
|
||||
access_local: resolvedIsResource ? accessLocal : undefined,
|
||||
options: getTargetOptions(),
|
||||
};
|
||||
onSave(targetData);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const showAdvancedSettings = false;
|
||||
// const showAdvancedSettings = !!hasTarget && (isResourceTargetType(targetType) || !!initialResource);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<ModalContent maxWidthClass="max-w-xl">
|
||||
<ModalContent maxWidthClass="max-w-2xl">
|
||||
<ModalHeader
|
||||
icon={<Server className="text-netbird" size={16} />}
|
||||
title={currentTarget ? "Edit Target" : "Add Target"}
|
||||
@@ -267,141 +230,18 @@ export default function ReverseProxyTargetModal({
|
||||
|
||||
<Separator />
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"px-8 py-6 pt-4 flex flex-col gap-6",
|
||||
!showAdvancedSettings && "mb-3",
|
||||
)}
|
||||
>
|
||||
<div className="px-8 pt-5 pb-4 flex flex-col gap-6">
|
||||
{!initialResource && !initialPeer && (
|
||||
<div>
|
||||
<Label className={"gap-0 inline"}>
|
||||
{initialNetwork ? (
|
||||
"Select Resource"
|
||||
) : (
|
||||
<>
|
||||
Select{" "}
|
||||
<HelpTooltip
|
||||
content={
|
||||
<div className="max-w-sm text-xs">
|
||||
A{" "}
|
||||
<span className={"text-white font-medium"}>
|
||||
peer
|
||||
</span>{" "}
|
||||
is a machine (e.g., laptop, server, container)
|
||||
running NetBird. Select a peer if your service runs
|
||||
directly on it.
|
||||
<span className={"mt-1 block"}>
|
||||
If you don't have a peer yet, you can{" "}
|
||||
<InlineButtonLink
|
||||
onClick={() => setInstallModal(true)}
|
||||
>
|
||||
Install NetBird
|
||||
</InlineButtonLink>
|
||||
.
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
Peer
|
||||
</HelpTooltip>{" "}
|
||||
or{" "}
|
||||
<HelpTooltip
|
||||
content={
|
||||
<div className="max-w-sm text-xs">
|
||||
A{" "}
|
||||
<span className={"text-white font-medium"}>
|
||||
resource
|
||||
</span>{" "}
|
||||
is a destination (IP, subnet, or domain) that
|
||||
can't run NetBird directly. Resources are part
|
||||
of a network and are reached through a routing peer
|
||||
that forwards traffic to them.
|
||||
<span className={"mt-1 block"}>
|
||||
If you don't have resources yet, go to{" "}
|
||||
<InlineLink href={"/networks"}>
|
||||
Networks
|
||||
</InlineLink>{" "}
|
||||
to create some.
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
Resource
|
||||
</HelpTooltip>
|
||||
</>
|
||||
)}
|
||||
</Label>
|
||||
|
||||
<HelpText>
|
||||
{initialNetwork
|
||||
? "Select the resource from your network you want to expose."
|
||||
: "Select the peer where your service is running or select a resource to expose it."}
|
||||
</HelpText>
|
||||
<PeerGroupSelector
|
||||
values={[]}
|
||||
onChange={() => {}}
|
||||
placeholder={
|
||||
initialNetwork
|
||||
? "Select a resource..."
|
||||
: "Select a peer or resource..."
|
||||
<ReverseProxyTargetSelector
|
||||
value={target}
|
||||
initialNetwork={initialNetwork}
|
||||
onChange={(selection) => {
|
||||
setTarget(selection);
|
||||
if (selection) {
|
||||
setTimeout(() => portInputRef.current?.focus(), 0);
|
||||
}
|
||||
showPeers={!initialNetwork}
|
||||
showResources={true}
|
||||
showRoutes={false}
|
||||
hideAllGroup={true}
|
||||
hideGroupsTab={true}
|
||||
resourceIds={
|
||||
initialNetwork ? initialNetwork.resources ?? [] : undefined
|
||||
}
|
||||
tabOrder={
|
||||
initialNetwork ? ["resources"] : ["peers", "resources"]
|
||||
}
|
||||
closeOnSelect={true}
|
||||
max={1}
|
||||
resource={
|
||||
isResourceTargetType(targetType) && targetResourceId
|
||||
? { id: targetResourceId, type: "host" }
|
||||
: targetType === ReverseProxyTargetType.PEER &&
|
||||
targetPeerId
|
||||
? { id: targetPeerId, type: "peer" }
|
||||
: undefined
|
||||
}
|
||||
onResourceChange={(res) => {
|
||||
if (res) {
|
||||
if (res.type === "peer") {
|
||||
setTargetType(ReverseProxyTargetType.PEER);
|
||||
setTargetPeerId(res.id);
|
||||
setTargetResourceId(undefined);
|
||||
const peer = peers?.find((p) => p.id === res.id);
|
||||
setTargetHost(peer?.ip || "localhost");
|
||||
} else {
|
||||
const selectedResource = resources?.find(
|
||||
(r) => r.id === res.id,
|
||||
);
|
||||
setTargetType(
|
||||
(selectedResource?.type as ReverseProxyTargetType) ??
|
||||
ReverseProxyTargetType.HOST,
|
||||
);
|
||||
setTargetResourceId(res.id);
|
||||
setTargetPeerId(undefined);
|
||||
const address = selectedResource?.address || "";
|
||||
// If CIDR range, pre-fill with base IP
|
||||
if (address.includes("/")) {
|
||||
setTargetHost(address.split("/")[0]);
|
||||
} else {
|
||||
setTargetHost(address);
|
||||
}
|
||||
}
|
||||
setTimeout(() => portInputRef.current?.focus(), 0);
|
||||
} else {
|
||||
setTargetPeerId(undefined);
|
||||
setTargetResourceId(undefined);
|
||||
setTargetHost("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
@@ -430,6 +270,9 @@ export default function ReverseProxyTargetModal({
|
||||
value = "/" + value;
|
||||
}
|
||||
setTargetPath(value);
|
||||
if (!value || value === "/") {
|
||||
setOption("path_rewrite", undefined);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -444,128 +287,231 @@ export default function ReverseProxyTargetModal({
|
||||
/>
|
||||
}
|
||||
>
|
||||
Please use a different location. This location is already used
|
||||
by another target and cannot be added.
|
||||
This location is already used by another target and cannot be
|
||||
added. <br /> Please use a different location.
|
||||
</Callout>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 mt-1">
|
||||
<div className="flex-1">
|
||||
<Label>Protocol & Host / IP</Label>
|
||||
{cidrInfo && (
|
||||
<HelpText className="!mt-1">
|
||||
Enter an IP address within {currentResourceAddress}
|
||||
</HelpText>
|
||||
)}
|
||||
<div className="flex items-center mt-2">
|
||||
<div className="w-[120px]">
|
||||
<SelectDropdown
|
||||
value={targetProtocol}
|
||||
onChange={(v) =>
|
||||
setTargetProtocol(v as ReverseProxyTargetProtocol)
|
||||
}
|
||||
options={[
|
||||
{
|
||||
value: ReverseProxyTargetProtocol.HTTP,
|
||||
label: "http://",
|
||||
},
|
||||
{
|
||||
value: ReverseProxyTargetProtocol.HTTPS,
|
||||
label: "https://",
|
||||
},
|
||||
]}
|
||||
className="!rounded-r-none !border-r-0"
|
||||
disabled={!hasTarget}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
value={targetHost}
|
||||
onChange={(e) => {
|
||||
// Only allow valid IP characters for CIDR ranges
|
||||
const value = isHostEditable
|
||||
? e.target.value.replace(/[^0-9.]/g, "")
|
||||
: e.target.value;
|
||||
setTargetHost(value);
|
||||
}}
|
||||
placeholder="e.g., 192.168.0.10"
|
||||
className="!rounded-l-none"
|
||||
disabled={!hasTarget}
|
||||
readOnly={hasTarget && !isHostEditable ? true : undefined}
|
||||
autoFocus={!!initialResource && isHostEditable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-[150px]">
|
||||
<Label>
|
||||
Port
|
||||
<FullTooltip
|
||||
content={
|
||||
<div className={"text-xs max-w-xs"}>
|
||||
Enter the port where your service (e.g., webserver, app,
|
||||
API) is currently listening. If left empty, defaults to
|
||||
port 80 for HTTP or 443 for HTTPS.
|
||||
{targetPath &&
|
||||
targetPath !== "/" &&
|
||||
hasTarget &&
|
||||
!isPathDuplicate && (
|
||||
<FancyToggleSwitch
|
||||
value={options.path_rewrite === "preserve"}
|
||||
onChange={(v) =>
|
||||
setOption(
|
||||
"path_rewrite",
|
||||
v
|
||||
? ("preserve" as ServiceTargetOptionsPathRewrite)
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
className={"mt-3.5"}
|
||||
label={
|
||||
<>
|
||||
Preserve Full Path
|
||||
<HelpTooltip
|
||||
content={
|
||||
<div className="text-xs max-w-xs flex flex-col gap-2">
|
||||
<div>
|
||||
When disabled, a request to e.g.,{" "}
|
||||
<span className="font-mono text-white">
|
||||
{targetPath}/users
|
||||
</span>{" "}
|
||||
is forwarded as{" "}
|
||||
<span className="font-mono text-white">
|
||||
/users
|
||||
</span>
|
||||
.
|
||||
</div>
|
||||
<div>
|
||||
When enabled, a request to e.g.,{" "}
|
||||
<span className="font-mono text-white">
|
||||
{targetPath}/users
|
||||
</span>{" "}
|
||||
is forwarded as{" "}
|
||||
<span className="font-mono text-white">
|
||||
{targetPath}/users
|
||||
</span>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
helpText={
|
||||
<div>
|
||||
Keep the original full request path when forwarding.{" "}
|
||||
<br />
|
||||
When disabled the matched prefix path is stripped.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<HelpCircle
|
||||
size={12}
|
||||
className="cursor-help hover:text-nb-gray-100 transition-colors"
|
||||
/>
|
||||
</FullTooltip>
|
||||
</Label>
|
||||
{cidrInfo && <HelpText className="!mt-1"> </HelpText>}
|
||||
<div className="mt-2">
|
||||
<Input
|
||||
ref={portInputRef}
|
||||
type="number"
|
||||
value={targetPort === 0 ? "" : targetPort}
|
||||
onChange={(e) =>
|
||||
setTargetPort(parseInt(e.target.value) || 0)
|
||||
}
|
||||
placeholder={String(defaultPortForProtocol(targetProtocol))}
|
||||
min={0}
|
||||
max={65535}
|
||||
disabled={!hasTarget}
|
||||
autoFocus={!!initialResource && !isHostEditable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showAdvancedSettings && (
|
||||
<Accordion type="single" collapsible>
|
||||
<AccordionItem value="advanced">
|
||||
<AccordionTrigger className="text-sm text-nb-gray-300">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings size={14} />
|
||||
Advanced Settings
|
||||
<div>
|
||||
<div className="flex mt-1">
|
||||
<div className="flex-1">
|
||||
<Label>
|
||||
Protocol & Host / IP
|
||||
<CidrHelpText target={target} />
|
||||
</Label>
|
||||
<div className="flex items-center mt-2">
|
||||
<div className="w-[120px]">
|
||||
<SelectDropdown
|
||||
value={targetProtocol}
|
||||
onChange={(v) => {
|
||||
const proto = v as ReverseProxyTargetProtocol;
|
||||
setTargetProtocol(proto);
|
||||
if (proto !== ReverseProxyTargetProtocol.HTTPS) {
|
||||
setOption("skip_tls_verify", undefined);
|
||||
}
|
||||
}}
|
||||
options={[
|
||||
{
|
||||
value: ReverseProxyTargetProtocol.HTTP,
|
||||
label: "http://",
|
||||
},
|
||||
{
|
||||
value: ReverseProxyTargetProtocol.HTTPS,
|
||||
label: "https://",
|
||||
},
|
||||
]}
|
||||
className="!rounded-r-none !border-r-0"
|
||||
disabled={!hasTarget}
|
||||
/>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className="flex flex-col gap-4 mt-4">
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={accessLocal}
|
||||
onCheckedChange={(v) => setAccessLocal(v === true)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<ReverseProxyAddressInput
|
||||
value={target}
|
||||
onChange={setTarget}
|
||||
autoFocus={!!initialResource && isHostEditable}
|
||||
className="!rounded-l-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-[150px]">
|
||||
<Label>
|
||||
Port
|
||||
<HelpTooltip
|
||||
content={
|
||||
"Enter the port where your service (e.g., webserver, app, API) is currently listening. If left empty, defaults to port 80 for HTTP or 443 for HTTPS."
|
||||
}
|
||||
/>
|
||||
</Label>
|
||||
<div className="mt-2">
|
||||
<Input
|
||||
ref={portInputRef}
|
||||
type="number"
|
||||
className={"rounded-l-none"}
|
||||
value={targetPort === 0 ? "" : targetPort}
|
||||
onChange={(e) =>
|
||||
setTargetPort(parseInt(e.target.value) || 0)
|
||||
}
|
||||
placeholder={String(
|
||||
defaultPortForProtocol(targetProtocol),
|
||||
)}
|
||||
min={0}
|
||||
max={65535}
|
||||
disabled={!hasTarget}
|
||||
autoFocus={!!initialResource && !isHostEditable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{targetProtocol === ReverseProxyTargetProtocol.HTTPS &&
|
||||
hasTarget && (
|
||||
<FancyToggleSwitch
|
||||
className={"mt-3.5"}
|
||||
value={options.skip_tls_verify ?? false}
|
||||
onChange={(v) =>
|
||||
setOption("skip_tls_verify", v || undefined)
|
||||
}
|
||||
label={
|
||||
<>
|
||||
<ShieldXIcon size={15} />
|
||||
Skip TLS Verification
|
||||
</>
|
||||
}
|
||||
helpText="Skip certificate verification when connecting to this target. Useful if your service already uses a self-signed certificate."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Accordion
|
||||
type={"multiple"}
|
||||
className={"flex flex-col gap-2 -mt-2"}
|
||||
>
|
||||
<AccordionItem value={"optional-settings"}>
|
||||
<AccordionTrigger
|
||||
className={
|
||||
"text-[0.8rem] tracking-wider text-nb-gray-200 py-4 my-0 leading-none gap-2 flex items-center"
|
||||
}
|
||||
>
|
||||
<span className={"relative top-[1px]"}>
|
||||
Optional Settings
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<div className={"flex flex-col gap-8 pb-6 pt-2"}>
|
||||
<div className={"flex items-center justify-between"}>
|
||||
<div>
|
||||
<Label>Request Timeout</Label>
|
||||
<HelpText className={"mb-0"}>
|
||||
Max time to wait for a response as duration string
|
||||
(e.g. 30s, 2m). <br /> Leave this field empty for no
|
||||
timeout.
|
||||
</HelpText>
|
||||
</div>
|
||||
<Input
|
||||
customPrefix={<ClockFadingIcon size={16} />}
|
||||
placeholder="e.g. 10s, 30s, 1m"
|
||||
value={options.request_timeout ?? ""}
|
||||
onChange={(e) =>
|
||||
setOption(
|
||||
"request_timeout",
|
||||
e.target.value || undefined,
|
||||
)
|
||||
}
|
||||
maxWidthClass="w-[180px]"
|
||||
errorTooltip={true}
|
||||
error={errors.timeout}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{reverseProxy.mode === ServiceMode.UDP && (
|
||||
<div className={"flex items-center justify-between"}>
|
||||
<div>
|
||||
<Label className="!mb-0" as={"div"}>
|
||||
This is the routing peer
|
||||
</Label>
|
||||
<HelpText className="!mt-1">
|
||||
Enable if the service runs directly on the routing
|
||||
peer rather than behind it.
|
||||
<Label>Session Idle Timeout</Label>
|
||||
<HelpText className={"mb-0"}>
|
||||
How long a UDP session stays alive without traffic
|
||||
(e.g., 30s, 2m). <br /> Defaults to 30s when empty.
|
||||
</HelpText>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
)}
|
||||
<Input
|
||||
customPrefix={<ClockFadingIcon size={16} />}
|
||||
placeholder="e.g. 30s, 2m, 5m"
|
||||
value={options.session_idle_timeout ?? ""}
|
||||
onChange={(e) =>
|
||||
setOption(
|
||||
"session_idle_timeout",
|
||||
e.target.value || undefined,
|
||||
)
|
||||
}
|
||||
maxWidthClass="w-[180px]"
|
||||
errorTooltip={true}
|
||||
error={errors.sessionIdleTimeout}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ReverseProxyTargetCustomHeaders {...headers} />
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
<ModalFooter className={"items-center"}>
|
||||
@@ -588,7 +534,7 @@ export default function ReverseProxyTargetModal({
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
disabled={!canAddTarget}
|
||||
disabled={!canAddTarget || errors.options}
|
||||
>
|
||||
{currentTarget ? (
|
||||
"Save Changes"
|
||||
@@ -603,10 +549,6 @@ export default function ReverseProxyTargetModal({
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
<Modal open={installModal} onOpenChange={setInstallModal}>
|
||||
<SetupModal />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
151
src/modules/reverse-proxy/targets/ReverseProxyTargetSelector.tsx
Normal file
151
src/modules/reverse-proxy/targets/ReverseProxyTargetSelector.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import HelpText from "@components/HelpText";
|
||||
import { Label } from "@components/Label";
|
||||
import { Modal } from "@components/modal/Modal";
|
||||
import { PeerGroupSelector } from "@components/PeerGroupSelector";
|
||||
import React, { useState } from "react";
|
||||
import { Network } from "@/interfaces/Network";
|
||||
import { ReverseProxyTargetType } from "@/interfaces/ReverseProxy";
|
||||
import {
|
||||
isResourceTargetType,
|
||||
useReverseProxies,
|
||||
} from "@/contexts/ReverseProxiesProvider";
|
||||
import { HelpTooltip } from "@components/HelpTooltip";
|
||||
import InlineLink, { InlineButtonLink } from "@components/InlineLink";
|
||||
import SetupModal from "@/modules/setup-netbird-modal/SetupModal";
|
||||
|
||||
export type Target = {
|
||||
type: ReverseProxyTargetType;
|
||||
peerId?: string;
|
||||
resourceId?: string;
|
||||
host: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
value?: Target;
|
||||
initialNetwork?: Network;
|
||||
onChange: (value: Target | undefined) => void;
|
||||
};
|
||||
|
||||
export default function ReverseProxyTargetSelector({
|
||||
value,
|
||||
initialNetwork,
|
||||
onChange,
|
||||
}: Readonly<Props>) {
|
||||
const { resources, peers } = useReverseProxies();
|
||||
const [installModal, setInstallModal] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label className={"gap-0 inline"}>
|
||||
{initialNetwork ? (
|
||||
"Select Resource"
|
||||
) : (
|
||||
<>
|
||||
Select{" "}
|
||||
<HelpTooltip
|
||||
className={"max-w-sm"}
|
||||
content={
|
||||
<>
|
||||
A <span className={"text-white font-medium"}>peer</span> is a
|
||||
machine (e.g., laptop, server, container) running NetBird.
|
||||
Select a peer if your service runs directly on it.
|
||||
<span className={"mt-1 block"}>
|
||||
If you don't have a peer yet, you can{" "}
|
||||
<InlineButtonLink onClick={() => setInstallModal(true)}>
|
||||
Install NetBird
|
||||
</InlineButtonLink>
|
||||
.
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
interactive={true}
|
||||
>
|
||||
Peer
|
||||
</HelpTooltip>{" "}
|
||||
or{" "}
|
||||
<HelpTooltip
|
||||
className={"max-w-sm"}
|
||||
content={
|
||||
<>
|
||||
A <span className={"text-white font-medium"}>resource</span>{" "}
|
||||
is a destination (IP, subnet, or domain) that can't run
|
||||
NetBird directly. Resources are part of a network and are
|
||||
reached through a routing peer that forwards traffic to them.
|
||||
<span className={"mt-1 block"}>
|
||||
If you don't have resources yet, go to{" "}
|
||||
<InlineLink href={"/networks"}>Networks</InlineLink> to
|
||||
create some.
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
interactive={true}
|
||||
>
|
||||
Resource
|
||||
</HelpTooltip>
|
||||
</>
|
||||
)}
|
||||
</Label>
|
||||
<HelpText>
|
||||
{initialNetwork
|
||||
? "Select the resource from your network you want to expose."
|
||||
: "Select the peer or resource where your service is running."}
|
||||
</HelpText>
|
||||
<PeerGroupSelector
|
||||
values={[]}
|
||||
onChange={() => {}}
|
||||
placeholder={
|
||||
initialNetwork
|
||||
? "Select a resource..."
|
||||
: "Select a peer or resource..."
|
||||
}
|
||||
showPeers={!initialNetwork}
|
||||
showResources={true}
|
||||
showRoutes={false}
|
||||
hideAllGroup={true}
|
||||
hideGroupsTab={true}
|
||||
resourceIds={
|
||||
initialNetwork ? initialNetwork.resources ?? [] : undefined
|
||||
}
|
||||
tabOrder={initialNetwork ? ["resources"] : ["peers", "resources"]}
|
||||
closeOnSelect={true}
|
||||
max={1}
|
||||
resource={
|
||||
value?.type && isResourceTargetType(value.type) && value.resourceId
|
||||
? { id: value.resourceId, type: value.type }
|
||||
: value?.type === ReverseProxyTargetType.PEER && value.peerId
|
||||
? { id: value.peerId, type: "peer" }
|
||||
: undefined
|
||||
}
|
||||
onResourceChange={(res) => {
|
||||
if (res) {
|
||||
if (res.type === "peer") {
|
||||
const peer = peers?.find((p) => p.id === res.id);
|
||||
onChange({
|
||||
type: ReverseProxyTargetType.PEER,
|
||||
peerId: res.id,
|
||||
host: peer?.ip || "localhost",
|
||||
});
|
||||
} else {
|
||||
const selectedResource = resources?.find((r) => r.id === res.id);
|
||||
const address = selectedResource?.address || "";
|
||||
onChange({
|
||||
type:
|
||||
(selectedResource?.type as ReverseProxyTargetType) ??
|
||||
ReverseProxyTargetType.HOST,
|
||||
resourceId: res.id,
|
||||
host: address.includes("/") ? address.split("/")[0] : address,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
onChange(undefined);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Modal open={installModal} onOpenChange={setInstallModal}>
|
||||
<SetupModal />
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user