Compare commits
47 Commits
v2.32.0
...
backup/pre
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
635603d62b | ||
|
|
56c0ad9592 | ||
|
|
14d3fec84a | ||
|
|
056e5c867b | ||
|
|
b0421b64ac | ||
|
|
e8f0f20455 | ||
|
|
8c94090e3d | ||
|
|
1917df6f60 | ||
|
|
358b477ded | ||
|
|
f535fe2667 | ||
|
|
52f7020a0f | ||
|
|
a604643f9a | ||
|
|
42cd088c5d | ||
|
|
7400ac806e | ||
|
|
240ff5af9a | ||
|
|
dc86c30463 | ||
|
|
e58f75ae3c | ||
|
|
dc1adebd27 | ||
|
|
d76cbd1122 | ||
|
|
01330e0f58 | ||
|
|
e9ac1a1a23 | ||
|
|
b53802a5c5 | ||
|
|
9addc18956 | ||
|
|
9701e6503b | ||
|
|
0841caecbb | ||
|
|
c7846760d1 | ||
|
|
8c283b6ef9 | ||
|
|
34ae3b4da6 | ||
|
|
aff2365ef7 | ||
|
|
bad057d415 | ||
|
|
4d846e2c94 | ||
|
|
15fb6e0b05 | ||
|
|
55c5525626 | ||
|
|
c0c1f4688e | ||
|
|
b5a8f751ba | ||
|
|
10a8e7b745 | ||
|
|
60e8394010 | ||
|
|
9420214059 | ||
|
|
b949f60afe | ||
|
|
d498e4cc25 | ||
|
|
130dc0c32c | ||
|
|
f5824d6ddb | ||
|
|
829395f908 | ||
|
|
8eebec78b4 | ||
|
|
3e01a6dafd | ||
|
|
1555b94043 | ||
|
|
6c62127d42 |
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 ✅"
|
||||
@@ -1,9 +1,9 @@
|
||||
[
|
||||
{
|
||||
"tag": "New",
|
||||
"text": "Custom DNS Zones for Private Network Resolution",
|
||||
"link": "https://netbird.io/knowledge-hub/custom-dns-zones",
|
||||
"linkText": "Read Release Article",
|
||||
"text": "NetBird Reverse Proxy - Expose internal services to the public with automatic TLS and optional authentication.",
|
||||
"link": "https://docs.netbird.io/manage/reverse-proxy",
|
||||
"linkText": "Learn more",
|
||||
"variant": "important",
|
||||
"isExternal": true,
|
||||
"closeable": true,
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
FROM alpine:3.14
|
||||
|
||||
RUN apk add --no-cache bash curl less ca-certificates git tzdata zip gettext \
|
||||
nginx curl supervisor certbot-nginx && \
|
||||
rm -rf /var/cache/apk/* && mkdir -p /run/nginx
|
||||
|
||||
STOPSIGNAL SIGINT
|
||||
EXPOSE 80
|
||||
EXPOSE 443
|
||||
ENTRYPOINT ["/usr/bin/supervisord","-c","/etc/supervisord.conf"]
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /usr/share/nginx/html
|
||||
# copy configuration files
|
||||
COPY docker/default.conf /etc/nginx/http.d/default.conf
|
||||
COPY docker/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY docker/init_cert.sh /usr/local/init_cert.sh
|
||||
COPY docker/init_react_envs.sh /usr/local/init_react_envs.sh
|
||||
RUN chmod +x /usr/local/init_cert.sh && rm /etc/crontabs/root
|
||||
RUN chmod +x /usr/local/init_react_envs.sh
|
||||
|
||||
# configure supervisor
|
||||
COPY docker/supervisord.conf /etc/supervisord.conf
|
||||
# copy build files
|
||||
COPY out/ /usr/share/nginx/html/
|
||||
# Copy build files
|
||||
COPY out/ /usr/share/nginx/html/
|
||||
|
||||
# Copy server script
|
||||
COPY docker/server.js /server.js
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["node", "/server.js"]
|
||||
|
||||
136
docker/server.js
Normal file
136
docker/server.js
Normal file
@@ -0,0 +1,136 @@
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const root = path.resolve('/usr/share/nginx/html');
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript',
|
||||
'.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg',
|
||||
'.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.wasm': 'application/wasm',
|
||||
'.ttf': 'font/ttf', '.woff': 'font/woff', '.woff2': 'font/woff2',
|
||||
'.txt': 'text/plain', '.xml': 'text/xml'
|
||||
};
|
||||
|
||||
// Replace both placeholder styles used by generated assets and templates.
|
||||
const ENV_KEYS = [
|
||||
'USE_AUTH0',
|
||||
'AUTH_AUDIENCE',
|
||||
'AUTH_AUTHORITY',
|
||||
'AUTH_CLIENT_ID',
|
||||
'AUTH_CLIENT_SECRET',
|
||||
'AUTH_SUPPORTED_SCOPES',
|
||||
'NETBIRD_MGMT_API_ENDPOINT',
|
||||
'NETBIRD_MGMT_GRPC_API_ENDPOINT',
|
||||
'NETBIRD_HOTJAR_TRACK_ID',
|
||||
'NETBIRD_GOOGLE_ANALYTICS_ID',
|
||||
'NETBIRD_GOOGLE_TAG_MANAGER_ID',
|
||||
'AUTH_REDIRECT_URI',
|
||||
'AUTH_SILENT_REDIRECT_URI',
|
||||
'NETBIRD_TOKEN_SOURCE',
|
||||
'NETBIRD_DRAG_QUERY_PARAMS',
|
||||
'NETBIRD_WASM_PATH',
|
||||
'AUTH0_DOMAIN',
|
||||
'AUTH0_CLIENT_ID',
|
||||
'AUTH0_AUDIENCE',
|
||||
];
|
||||
|
||||
function substituteEnv(content) {
|
||||
let changed = false;
|
||||
for (const key of ENV_KEYS) {
|
||||
const val = process.env[key] || '';
|
||||
for (const pattern of ['$$' + key, '$' + key]) {
|
||||
if (content.includes(pattern)) {
|
||||
content = content.split(pattern).join(val);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { content, changed };
|
||||
}
|
||||
|
||||
function walkDir(dir) {
|
||||
try {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walkDir(full);
|
||||
else if (entry.isFile() && /\.(js|html|txt|json)$/.test(entry.name)) {
|
||||
const result = substituteEnv(fs.readFileSync(full, 'utf8'));
|
||||
if (result.changed) fs.writeFileSync(full, result.content, 'utf8');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to substitute environment variables:', e);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Substituting environment variables...');
|
||||
try {
|
||||
const tmpl = path.join(root, 'OidcTrustedDomains.js.tmpl');
|
||||
if (fs.existsSync(tmpl)) {
|
||||
const result = substituteEnv(fs.readFileSync(tmpl, 'utf8'));
|
||||
fs.writeFileSync(path.join(root, 'OidcTrustedDomains.js'), result.content, 'utf8');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to create OidcTrustedDomains.js:', e);
|
||||
}
|
||||
|
||||
walkDir(root);
|
||||
console.log('Environment substitution complete.');
|
||||
|
||||
function isFile(p) {
|
||||
try { return fs.statSync(p).isFile(); } catch(e) { return false; }
|
||||
}
|
||||
|
||||
function safePath(p) {
|
||||
const abs = path.resolve(root, '.' + p);
|
||||
return abs === root || abs.startsWith(root + path.sep) ? abs : null;
|
||||
}
|
||||
|
||||
function resolvePath(url) {
|
||||
let p = url.split('?')[0];
|
||||
if (!p.startsWith('/')) p = '/' + p;
|
||||
if (p === '/' || p.endsWith('/')) p += 'index.html';
|
||||
const abs = safePath(p);
|
||||
if (abs && isFile(abs)) return abs;
|
||||
// Try .html suffix (Next.js static export uses path.html)
|
||||
const asHtml = safePath(p + '.html');
|
||||
if (asHtml && isFile(asHtml)) return asHtml;
|
||||
// Try path/index.html
|
||||
const asDirIndex = safePath(p + '/index.html');
|
||||
if (asDirIndex && isFile(asDirIndex)) return asDirIndex;
|
||||
// Try /zh prefix (next-intl locale)
|
||||
if (!p.startsWith('/zh')) {
|
||||
const zh = safePath('/zh' + p);
|
||||
if (zh && isFile(zh)) return zh;
|
||||
const zhHtml = safePath('/zh' + p + '.html');
|
||||
if (zhHtml && isFile(zhHtml)) return zhHtml;
|
||||
const zhDirIndex = safePath('/zh' + p + '/index.html');
|
||||
if (zhDirIndex && isFile(zhDirIndex)) return zhDirIndex;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
http.createServer((req, res) => {
|
||||
const filePath = resolvePath(req.url);
|
||||
if (filePath) {
|
||||
const ext = path.extname(filePath);
|
||||
fs.readFile(filePath, (err, data) => {
|
||||
if (err) { send404(res); return; }
|
||||
res.writeHead(200, {
|
||||
'Content-Type': MIME[ext] || 'application/octet-stream',
|
||||
'Cache-Control': ['.html', '.js'].includes(ext) ? 'no-store, no-cache, must-revalidate, max-age=0' : 'public, max-age=3600'
|
||||
});
|
||||
res.end(data);
|
||||
});
|
||||
} else {
|
||||
send404(res);
|
||||
}
|
||||
}).listen(80, () => console.log('NetBird Dashboard running on port 80'));
|
||||
|
||||
function send404(res) {
|
||||
fs.readFile(path.join(root, '404.html'), (err, data) => {
|
||||
res.writeHead(404, {'Content-Type': 'text/html', 'Cache-Control': 'no-store'});
|
||||
res.end(err ? '404 Not Found' : data);
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
const createNextIntlPlugin = require('next-intl/plugin');
|
||||
|
||||
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: "export",
|
||||
@@ -12,4 +16,4 @@ const nextConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
module.exports = withNextIntl(nextConfig);
|
||||
|
||||
917
package-lock.json
generated
917
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -67,8 +67,9 @@
|
||||
"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-intl": "^4.13.0",
|
||||
"next-themes": "^0.2.1",
|
||||
"punycode": "^2.3.1",
|
||||
"react": "^19.2.4",
|
||||
@@ -89,6 +90,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",
|
||||
|
||||
@@ -6,5 +6,5 @@ import React from "react";
|
||||
|
||||
export default function Redirect() {
|
||||
useRedirect("/events/audit");
|
||||
return <FullScreenLoading height={"auto"} />;
|
||||
return <FullScreenLoading fullScreen={false} />;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
|
||||
import GroupsProvider from "@/contexts/GroupsProvider";
|
||||
@@ -17,60 +18,56 @@ import { Policy } from "@/interfaces/Policy";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
|
||||
const AccessControlTable = lazy(
|
||||
() => import("@/modules/access-control/table/AccessControlTable"),
|
||||
() => import("@/modules/access-control/table/AccessControlTable"),
|
||||
);
|
||||
export default function AccessControlPage() {
|
||||
const { permission } = usePermissions();
|
||||
const t = useTranslations("policies");
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const { data: policies, isLoading } = useFetchApi<Policy[]>("/policies");
|
||||
const { data: policies, isLoading } = useFetchApi<Policy[]>("/policies");
|
||||
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<GroupsProvider>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/access-control"}
|
||||
label={"Access Control"}
|
||||
icon={<AccessControlIcon size={14} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>Access Control Policies</h1>
|
||||
<Paragraph>
|
||||
Create rules to manage access in your network and define what peers
|
||||
can connect.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Learn more about
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/manage-network-access"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Access Controls
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
</div>
|
||||
return (
|
||||
<PageContainer>
|
||||
<GroupsProvider>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/access-control"}
|
||||
label={t("title")}
|
||||
icon={<AccessControlIcon size={14} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>{t("title")}</h1>
|
||||
<Paragraph>
|
||||
{t("accessControlDescription")}{" "}
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/manage-network-access"}
|
||||
target={"_blank"}
|
||||
>
|
||||
{t("learnMore")}
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<RestrictedAccess
|
||||
page={"Access Control"}
|
||||
hasAccess={permission.policies.read}
|
||||
>
|
||||
<PoliciesProvider>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<AccessControlTable
|
||||
isLoading={isLoading}
|
||||
policies={policies}
|
||||
headingTarget={portalTarget}
|
||||
/>
|
||||
</Suspense>
|
||||
</PoliciesProvider>
|
||||
</RestrictedAccess>
|
||||
</GroupsProvider>
|
||||
</PageContainer>
|
||||
);
|
||||
<RestrictedAccess
|
||||
page={t("title")}
|
||||
hasAccess={permission.policies.read}
|
||||
>
|
||||
<PoliciesProvider>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<AccessControlTable
|
||||
isLoading={isLoading}
|
||||
policies={policies}
|
||||
headingTarget={portalTarget}
|
||||
/>
|
||||
</Suspense>
|
||||
</PoliciesProvider>
|
||||
</RestrictedAccess>
|
||||
</GroupsProvider>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import DNSIcon from "@/assets/icons/DNSIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
@@ -15,63 +16,61 @@ import { NameserverGroup } from "@/interfaces/Nameserver";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
|
||||
const NameserverGroupTable = lazy(
|
||||
() => import("@/modules/dns/nameservers/table/NameserverGroupTable"),
|
||||
() => import("@/modules/dns/nameservers/table/NameserverGroupTable"),
|
||||
);
|
||||
|
||||
export default function NameServers() {
|
||||
const { permission } = usePermissions();
|
||||
const t = useTranslations("dns");
|
||||
const tCommon = useTranslations("common");
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const { data: nameserverGroups, isLoading } =
|
||||
useFetchApi<NameserverGroup[]>("/dns/nameservers");
|
||||
const { data: nameserverGroups, isLoading } =
|
||||
useFetchApi<NameserverGroup[]>("/dns/nameservers");
|
||||
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/dns/nameservers"}
|
||||
label={"DNS"}
|
||||
icon={<DNSIcon size={13} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/dns/nameservers"}
|
||||
label={"Nameservers"}
|
||||
active
|
||||
icon={<DNSIcon size={13} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>Nameservers</h1>
|
||||
<Paragraph>
|
||||
Add nameservers for domain name resolution in your NetBird network.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Learn more about
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/manage-dns-in-your-network"}
|
||||
target={"_blank"}
|
||||
>
|
||||
DNS
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
</div>
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/dns/nameservers"}
|
||||
label={t("title")}
|
||||
icon={<DNSIcon size={13} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/dns/nameservers"}
|
||||
label={t("nameservers")}
|
||||
active
|
||||
icon={<DNSIcon size={13} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>{t("nameservers")}</h1>
|
||||
<Paragraph>
|
||||
{t("nameserversDescription")}{" "}
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/manage-dns-in-your-network"}
|
||||
target={"_blank"}
|
||||
>
|
||||
{tCommon("learnMore")}
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<RestrictedAccess
|
||||
page={"Nameservers"}
|
||||
hasAccess={permission.nameservers.read}
|
||||
>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<NameserverGroupTable
|
||||
nameserverGroups={nameserverGroups}
|
||||
isLoading={isLoading}
|
||||
headingTarget={portalTarget}
|
||||
/>
|
||||
</Suspense>
|
||||
</RestrictedAccess>
|
||||
</PageContainer>
|
||||
);
|
||||
<RestrictedAccess
|
||||
page={t("nameservers")}
|
||||
hasAccess={permission.nameservers.read}
|
||||
>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<NameserverGroupTable
|
||||
nameserverGroups={nameserverGroups}
|
||||
isLoading={isLoading}
|
||||
headingTarget={portalTarget}
|
||||
/>
|
||||
</Suspense>
|
||||
</RestrictedAccess>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,5 +11,5 @@ export default function DNS() {
|
||||
router.push("/dns/nameservers");
|
||||
}, [router]);
|
||||
|
||||
return <FullScreenLoading height={"auto"} />;
|
||||
return <FullScreenLoading fullScreen={false} />;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import { IconSettings2 } from "@tabler/icons-react";
|
||||
import useFetchApi, { useApiCall } from "@utils/api";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import React from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import { useSWRConfig } from "swr";
|
||||
@@ -26,126 +27,128 @@ import useGroupHelper from "@/modules/groups/useGroupHelper";
|
||||
import { useGroupIdsToGroups } from "@/modules/groups/useGroupIdsToGroups";
|
||||
|
||||
export default function NameServerSettings() {
|
||||
const { permission } = usePermissions();
|
||||
const t = useTranslations("dns");
|
||||
const tCommon = useTranslations("common");
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const { data: settings, isLoading } =
|
||||
useFetchApi<NameserverSettings>("/dns/settings");
|
||||
const { data: settings, isLoading } =
|
||||
useFetchApi<NameserverSettings>("/dns/settings");
|
||||
|
||||
const initialDNSGroups = useGroupIdsToGroups(
|
||||
settings?.disabled_management_groups,
|
||||
);
|
||||
const initialDNSGroups = useGroupIdsToGroups(
|
||||
settings?.disabled_management_groups,
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/dns"}
|
||||
label={"DNS"}
|
||||
icon={<DNSIcon size={13} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/dns/settings"}
|
||||
label={"DNS Settings"}
|
||||
active
|
||||
icon={<IconSettings2 size={15} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1>DNS Settings</h1>
|
||||
<Paragraph>{"Manage your account's DNS settings."}</Paragraph>
|
||||
<Paragraph>
|
||||
Learn more about
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/manage-dns-in-your-network"}
|
||||
target={"_blank"}
|
||||
>
|
||||
DNS
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
<RestrictedAccess page={"DNS Settings"} hasAccess={permission.dns.read}>
|
||||
{!isLoading && initialDNSGroups !== undefined ? (
|
||||
<SettingDisabledManagementGroups initialGroups={initialDNSGroups} />
|
||||
) : (
|
||||
<div>
|
||||
<Skeleton
|
||||
width={"100%"}
|
||||
className={"mt-8 max-w-xl"}
|
||||
height={240}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</RestrictedAccess>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/dns"}
|
||||
label={t("title")}
|
||||
icon={<DNSIcon size={13} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/dns/settings"}
|
||||
label={t("dnsSettings")}
|
||||
active
|
||||
icon={<IconSettings2 size={15} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1>{t("dnsSettings")}</h1>
|
||||
<Paragraph>
|
||||
{t("dnsSettingsDescription")}{" "}
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/manage-dns-in-your-network"}
|
||||
target={"_blank"}
|
||||
>
|
||||
{tCommon("learnMore")}
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Paragraph>
|
||||
<RestrictedAccess
|
||||
page={t("dnsSettings")}
|
||||
hasAccess={permission.dns.read}
|
||||
>
|
||||
{!isLoading && initialDNSGroups !== undefined ? (
|
||||
<SettingDisabledManagementGroups initialGroups={initialDNSGroups} />
|
||||
) : (
|
||||
<div>
|
||||
<Skeleton
|
||||
width={"100%"}
|
||||
className={"mt-8 max-w-xl"}
|
||||
height={240}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</RestrictedAccess>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const SettingDisabledManagementGroups = ({
|
||||
initialGroups,
|
||||
initialGroups,
|
||||
}: {
|
||||
initialGroups: Group[];
|
||||
initialGroups: Group[];
|
||||
}) => {
|
||||
const settingRequest = useApiCall<NameserverSettings>("/dns/settings");
|
||||
const { mutate } = useSWRConfig();
|
||||
const { permission } = usePermissions();
|
||||
const t = useTranslations("dns");
|
||||
const settingRequest = useApiCall<NameserverSettings>("/dns/settings");
|
||||
const { mutate } = useSWRConfig();
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const [selectedGroups, setSelectedGroups, { save: saveGroups }] =
|
||||
useGroupHelper({
|
||||
initial: initialGroups,
|
||||
});
|
||||
const [selectedGroups, setSelectedGroups, { save: saveGroups }] =
|
||||
useGroupHelper({
|
||||
initial: initialGroups,
|
||||
});
|
||||
|
||||
const { hasChanges, updateRef: updateChangesRef } = useHasChanges([
|
||||
selectedGroups,
|
||||
]);
|
||||
const { hasChanges, updateRef: updateChangesRef } = useHasChanges([
|
||||
selectedGroups,
|
||||
]);
|
||||
|
||||
const saveSettings = async () => {
|
||||
const savedGroups = await saveGroups();
|
||||
notify({
|
||||
title: "DNS Settings",
|
||||
description: "Settings saved successfully.",
|
||||
promise: settingRequest
|
||||
.put({
|
||||
disabled_management_groups: savedGroups.map((g) => g.id),
|
||||
})
|
||||
.then(() => {
|
||||
mutate("/dns/settings");
|
||||
updateChangesRef([selectedGroups]);
|
||||
}),
|
||||
loadingMessage: "Saving the settings...",
|
||||
});
|
||||
};
|
||||
const saveSettings = async () => {
|
||||
const savedGroups = await saveGroups();
|
||||
notify({
|
||||
title: t("dnsSettings"),
|
||||
description: t("settingsSaved"),
|
||||
promise: settingRequest
|
||||
.put({
|
||||
disabled_management_groups: savedGroups.map((g) => g.id),
|
||||
})
|
||||
.then(() => {
|
||||
mutate("/dns/settings");
|
||||
updateChangesRef([selectedGroups]);
|
||||
}),
|
||||
loadingMessage: t("settingsSaving"),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={"mt-8 max-w-xl"}>
|
||||
<div className={"px-8 py-8"}>
|
||||
<Label>Disable DNS management for these groups</Label>
|
||||
<HelpText>
|
||||
Peers in these groups will require manual domain name resolution
|
||||
</HelpText>
|
||||
<PeerGroupSelector
|
||||
dataCy={"dns-groups-selector"}
|
||||
onChange={setSelectedGroups}
|
||||
values={selectedGroups}
|
||||
disabled={!permission.dns.update}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
"flex justify-end bg-nb-gray-900/20 border-t border-nb-gray-900 px-8 py-5"
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
size={"sm"}
|
||||
onClick={saveSettings}
|
||||
disabled={!hasChanges || !permission.dns.update}
|
||||
data-cy={"save-changes"}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
return (
|
||||
<Card className={"mt-8 max-w-xl"}>
|
||||
<div className={"px-8 py-8"}>
|
||||
<Label>{t("disabledManagementGroup")}</Label>
|
||||
<HelpText>{t("disabledManagementGroupHelp")}</HelpText>
|
||||
<PeerGroupSelector
|
||||
dataCy={"dns-groups-selector"}
|
||||
onChange={setSelectedGroups}
|
||||
values={selectedGroups}
|
||||
disabled={!permission.dns.update}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
"flex justify-end bg-nb-gray-900/20 border-t border-nb-gray-900 px-8 py-5"
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
size={"sm"}
|
||||
onClick={saveSettings}
|
||||
disabled={!hasChanges || !permission.dns.update}
|
||||
data-cy={"save-changes"}
|
||||
>
|
||||
{t("saveChanges")}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,7 +8,8 @@ import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { lazy, Suspense } from "react";
|
||||
import DNSIcon from "@/assets/icons/DNSIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { DNS_ZONE_DOCS_LINK, DNSZone } from "@/interfaces/DNS";
|
||||
@@ -17,54 +18,52 @@ import { DNSZonesProvider } from "@/modules/dns/zones/DNSZonesProvider";
|
||||
import DNSZoneIcon from "@/assets/icons/DNSZoneIcon";
|
||||
|
||||
const DNSZonesTable = lazy(
|
||||
() => import("@/modules/dns/zones/table/DNSZonesTable"),
|
||||
() => import("@/modules/dns/zones/table/DNSZonesTable"),
|
||||
);
|
||||
|
||||
export default function DNSZonePage() {
|
||||
const { permission } = usePermissions();
|
||||
const t = useTranslations("dns");
|
||||
const tCommon = useTranslations("common");
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const { data: zones, isLoading } = useFetchApi<DNSZone[]>("/dns/zones");
|
||||
const { data: zones, isLoading } = useFetchApi<DNSZone[]>("/dns/zones");
|
||||
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item label={"DNS"} icon={<DNSIcon size={13} />} />
|
||||
<Breadcrumbs.Item
|
||||
href={"/dns/zones"}
|
||||
label={"Zones"}
|
||||
active
|
||||
icon={<DNSZoneIcon size={16} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>Zones</h1>
|
||||
<Paragraph>
|
||||
Manage DNS zones to control domain name resolution for your network.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Learn more about
|
||||
<InlineLink href={DNS_ZONE_DOCS_LINK} target={"_blank"}>
|
||||
DNS Zones
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
</div>
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item label={t("title")} icon={<DNSIcon size={13} />} />
|
||||
<Breadcrumbs.Item
|
||||
href={"/dns/zones"}
|
||||
label={t("zones")}
|
||||
active
|
||||
icon={<DNSZoneIcon size={16} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>{t("zones")}</h1>
|
||||
<Paragraph>
|
||||
{t("zonesDescription")}{" "}
|
||||
<InlineLink href={DNS_ZONE_DOCS_LINK} target={"_blank"}>
|
||||
{tCommon("learnMore")}
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<RestrictedAccess page={"DNS Zones"} hasAccess={permission?.dns?.read}>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<DNSZonesProvider>
|
||||
<DNSZonesTable
|
||||
isLoading={isLoading}
|
||||
headingTarget={portalTarget}
|
||||
data={zones}
|
||||
/>
|
||||
</DNSZonesProvider>
|
||||
</Suspense>
|
||||
</RestrictedAccess>
|
||||
</PageContainer>
|
||||
);
|
||||
<RestrictedAccess page={t("zones")} hasAccess={permission?.dns?.read}>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<DNSZonesProvider>
|
||||
<DNSZonesTable
|
||||
isLoading={isLoading}
|
||||
headingTarget={portalTarget}
|
||||
data={zones}
|
||||
/>
|
||||
</DNSZonesProvider>
|
||||
</Suspense>
|
||||
</RestrictedAccess>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { ExternalLinkIcon, LogsIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import React from "react";
|
||||
import ActivityIcon from "@/assets/icons/ActivityIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
@@ -15,6 +16,8 @@ import PageContainer from "@/layouts/PageContainer";
|
||||
import ActivityTable from "@/modules/activity/ActivityTable";
|
||||
|
||||
export default function Activity() {
|
||||
const t = useTranslations("activity");
|
||||
const tCommon = useTranslations("common");
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const { data: events, isLoading } =
|
||||
@@ -28,31 +31,29 @@ export default function Activity() {
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
label={"Activity"}
|
||||
label={t("title")}
|
||||
disabled={true}
|
||||
icon={<ActivityIcon size={13} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/events/audit"}
|
||||
label={"Audit Events"}
|
||||
label={t("auditEvents")}
|
||||
icon={<LogsIcon size={18} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>Audit Events</h1>
|
||||
<Paragraph>Here you can see all the audit activity events.</Paragraph>
|
||||
<h1 ref={headingRef}>{t("auditEvents")}</h1>
|
||||
<Paragraph>
|
||||
Learn more about{" "}
|
||||
{t("auditEventsDescription")}{" "}
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/audit-events-logging"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Audit Events
|
||||
{tCommon("learnMore")}
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<RestrictedAccess page={"Activity"} hasAccess={permission.events.read}>
|
||||
<RestrictedAccess page={t("title")} hasAccess={permission.events.read}>
|
||||
<ActivityTable
|
||||
events={events}
|
||||
isLoading={isLoading}
|
||||
|
||||
@@ -1,78 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import Breadcrumbs from "@components/Breadcrumbs";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import dayjs from "dayjs";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
|
||||
import React, { useMemo } from "react";
|
||||
import ActivityIcon from "@/assets/icons/ActivityIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import ServerPaginationProvider from "@/contexts/ServerPaginationProvider";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import ReverseProxyEventsTable from "@/modules/reverse-proxy/events/ReverseProxyEventsTable";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import { REVERSE_PROXY_EVENTS_DOCS_LINK } from "@/interfaces/ReverseProxy";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function ProxyEventsPage() {
|
||||
const { permission } = usePermissions();
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
const defaultFilters = useMemo(
|
||||
() => ({
|
||||
start_date: dayjs().subtract(7, "day").startOf("day").toISOString(),
|
||||
end_date: dayjs().endOf("day").toISOString(),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className="p-default py-6">
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
label="Activity"
|
||||
disabled
|
||||
icon={<ActivityIcon size={13} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href="/events/proxy"
|
||||
label="Proxy Events"
|
||||
icon={<ReverseProxyIcon size={15} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
|
||||
<h1 ref={headingRef}>Proxy Events</h1>
|
||||
|
||||
<Paragraph>
|
||||
View access logs for your reverse proxy services, including allowed
|
||||
and denied requests.
|
||||
</Paragraph>
|
||||
|
||||
<Paragraph>
|
||||
Learn more about{" "}
|
||||
<InlineLink href={REVERSE_PROXY_EVENTS_DOCS_LINK} target="_blank">
|
||||
Proxy Events <ExternalLinkIcon size={12} />
|
||||
</InlineLink>{" "}
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<RestrictedAccess
|
||||
page="Proxy Events"
|
||||
hasAccess={permission?.services?.read}
|
||||
>
|
||||
<ServerPaginationProvider
|
||||
url="/events/proxy"
|
||||
defaultPageSize={10}
|
||||
defaultFilters={defaultFilters}
|
||||
>
|
||||
<ReverseProxyEventsTable headingTarget={portalTarget} />
|
||||
</ServerPaginationProvider>
|
||||
</RestrictedAccess>
|
||||
</PageContainer>
|
||||
);
|
||||
redirect("/reverse-proxy/logs");
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import useFetchApi from "@utils/api";
|
||||
import { cn, singularize } from "@utils/helpers";
|
||||
import { FolderGit2Icon, Layers3Icon, PencilIcon } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import React, { useState } from "react";
|
||||
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
|
||||
import DNSIcon from "@/assets/icons/DNSIcon";
|
||||
@@ -36,6 +37,7 @@ import { GroupUsersSection } from "@/modules/groups/details/GroupUsersSection";
|
||||
import useGroupDetails from "@/modules/groups/details/useGroupDetails";
|
||||
|
||||
export default function GroupPage() {
|
||||
const t = useTranslations("groups");
|
||||
const queryParameter = useSearchParams();
|
||||
const { isRestricted } = usePermissions();
|
||||
const groupId = queryParameter.get("id");
|
||||
@@ -50,7 +52,7 @@ export default function GroupPage() {
|
||||
if (isRestricted) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<RestrictedAccess page={"Group Information"} />
|
||||
<RestrictedAccess page={t("title")} />
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -73,7 +75,7 @@ export default function GroupPage() {
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/groups"}
|
||||
label={"Groups"}
|
||||
label={t("title")}
|
||||
icon={<FolderGit2Icon size={14} />}
|
||||
/>
|
||||
<Breadcrumbs.Item label={group.name} active />
|
||||
@@ -142,6 +144,8 @@ const validAllGroupTabs = [
|
||||
const validOtherGroupTabs = ["users", "peers", "setup-keys"];
|
||||
|
||||
const GroupOverviewTabs = ({ group }: { group: Group }) => {
|
||||
const t = useTranslations("groups");
|
||||
const tNetworks = useTranslations("networks");
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const getInitialTab = () => {
|
||||
@@ -188,7 +192,7 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => {
|
||||
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
|
||||
}
|
||||
/>
|
||||
{singularize("Users", usersCount)}
|
||||
{singularize(t("users"), usersCount)}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
|
||||
@@ -203,7 +207,7 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => {
|
||||
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
|
||||
}
|
||||
/>
|
||||
{singularize("Peers", peersCount)}
|
||||
{singularize(t("peers"), peersCount)}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
|
||||
@@ -217,7 +221,7 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => {
|
||||
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
|
||||
}
|
||||
/>
|
||||
{singularize("Policies", policiesCount)}
|
||||
{singularize(t("policies"), policiesCount)}
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger
|
||||
@@ -225,7 +229,7 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => {
|
||||
className={groupDetails === null ? "animate-pulse" : ""}
|
||||
>
|
||||
<Layers3Icon size={14} />
|
||||
{singularize("Resources", resourcesCount)}
|
||||
{singularize(t("resources"), resourcesCount)}
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger
|
||||
@@ -238,7 +242,7 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => {
|
||||
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
|
||||
}
|
||||
/>
|
||||
{singularize("Network Routes", routesCount)}
|
||||
{singularize(tNetworks("networkRoutes"), routesCount)}
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger
|
||||
@@ -251,7 +255,7 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => {
|
||||
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
|
||||
}
|
||||
/>
|
||||
{singularize("Nameservers", nameserversCount)}
|
||||
{singularize(t("nameservers"), nameserversCount)}
|
||||
</TabsTrigger>
|
||||
|
||||
<TabsTrigger
|
||||
@@ -264,7 +268,7 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => {
|
||||
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
|
||||
}
|
||||
/>
|
||||
{singularize("Zones", zonesCount)}
|
||||
{singularize(t("zones"), zonesCount)}
|
||||
</TabsTrigger>
|
||||
|
||||
{group.name !== "All" && (
|
||||
@@ -278,7 +282,7 @@ const GroupOverviewTabs = ({ group }: { group: Group }) => {
|
||||
"fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all"
|
||||
}
|
||||
/>
|
||||
{singularize("Setup Keys", setupKeysCount)}
|
||||
{singularize(t("setupKeys"), setupKeysCount)}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
@@ -5,6 +5,7 @@ import SkeletonTable from "@components/skeletons/SkeletonTable";
|
||||
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import { ExternalLinkIcon, FolderGit2Icon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||
import InlineLink from "@/components/InlineLink";
|
||||
@@ -14,43 +15,39 @@ import PageContainer from "@/layouts/PageContainer";
|
||||
const GroupsTable = lazy(() => import("@/modules/groups/table/GroupsTable"));
|
||||
|
||||
export default function GroupsPage() {
|
||||
const { permission } = usePermissions();
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
const t = useTranslations("groups");
|
||||
const { permission } = usePermissions();
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/groups"}
|
||||
label={"Groups"}
|
||||
icon={<FolderGit2Icon size={14} />}
|
||||
active
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>Groups</h1>
|
||||
<Paragraph>
|
||||
Here is the overview of the groups of your organization. You can
|
||||
delete the unused ones.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Learn more about{" "}
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/manage-network-access"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Groups
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<RestrictedAccess hasAccess={permission.groups.read} page={"Groups"}>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<GroupsTable headingTarget={portalTarget} />
|
||||
</Suspense>
|
||||
</RestrictedAccess>
|
||||
</PageContainer>
|
||||
);
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/groups"}
|
||||
label={t("title")}
|
||||
icon={<FolderGit2Icon size={14} />}
|
||||
active
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>{t("title")}</h1>
|
||||
<Paragraph>
|
||||
{t("groupsDescription")}{" "}
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/manage-network-access"}
|
||||
target={"_blank"}
|
||||
>
|
||||
{t("learnMore")}
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Paragraph>
|
||||
</div>
|
||||
<RestrictedAccess hasAccess={permission.groups.read} page={t("title")}>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<GroupsTable headingTarget={portalTarget} />
|
||||
</Suspense>
|
||||
</RestrictedAccess>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { ArrowUpRightIcon, ExternalLinkIcon } from "lucide-react";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { lazy, Suspense } from "react";
|
||||
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
|
||||
import PeersProvider from "@/contexts/PeersProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
@@ -19,72 +20,71 @@ import useGroupedRoutes from "@/modules/route-group/useGroupedRoutes";
|
||||
import { Callout } from "@components/Callout";
|
||||
|
||||
const NetworkRoutesTable = lazy(
|
||||
() => import("@/modules/route-group/NetworkRoutesTable"),
|
||||
() => import("@/modules/route-group/NetworkRoutesTable"),
|
||||
);
|
||||
|
||||
export default function NetworkRoutes() {
|
||||
const { permission } = usePermissions();
|
||||
const { data: routes, isLoading } = useFetchApi<Route[]>("/routes");
|
||||
const groupedRoutes = useGroupedRoutes({ routes });
|
||||
const t = useTranslations("networks");
|
||||
const tCommon = useTranslations("common");
|
||||
const { permission } = usePermissions();
|
||||
const { data: routes, isLoading } = useFetchApi<Route[]>("/routes");
|
||||
const groupedRoutes = useGroupedRoutes({ routes });
|
||||
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<RoutesProvider>
|
||||
<PeersProvider>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/network-routes"}
|
||||
label={"Network Routes"}
|
||||
icon={<NetworkRoutesIcon size={13} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>Network Routes</h1>
|
||||
<Paragraph>
|
||||
Network routes allow you to access other networks like LANs and
|
||||
VPCs without installing NetBird on every resource.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Learn more about
|
||||
<InlineLink
|
||||
href={
|
||||
"https://docs.netbird.io/how-to/routing-traffic-to-private-networks"
|
||||
}
|
||||
target={"_blank"}
|
||||
>
|
||||
Network Routes
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
return (
|
||||
<PageContainer>
|
||||
<RoutesProvider>
|
||||
<PeersProvider>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
label={t("networkRoutes")}
|
||||
icon={<NetworkRoutesIcon size={13} />}
|
||||
/>
|
||||
<Breadcrumbs.Item href={"/network-routes"} label={t("routes")} />
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>{t("routes")}</h1>
|
||||
<Paragraph>
|
||||
{t("routesDescription")}{" "}
|
||||
<InlineLink
|
||||
href={
|
||||
"https://docs.netbird.io/how-to/routing-traffic-to-private-networks"
|
||||
}
|
||||
target={"_blank"}
|
||||
aria-label={
|
||||
"Learn more about routing traffic to private networks"
|
||||
}
|
||||
>
|
||||
<>{tCommon("learnMore")}</>
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Paragraph>
|
||||
|
||||
<Callout className={"max-w-xl mt-5"} variant={"warning"}>
|
||||
<span>
|
||||
We recommend using the new Networks concept to easier visualise
|
||||
and manage access to your resources.{" "}
|
||||
<InlineLink href={"/networks"}>
|
||||
Go to Networks
|
||||
<ArrowUpRightIcon size={14} />
|
||||
</InlineLink>
|
||||
</span>
|
||||
</Callout>
|
||||
</div>
|
||||
<Callout className={"max-w-xl mt-5"} variant={"warning"}>
|
||||
<span>
|
||||
{t("newNetworksRecommendation")}{" "}
|
||||
<InlineLink href={"/networks"}>
|
||||
{t("goToNetworks")}
|
||||
<ArrowUpRightIcon size={14} />
|
||||
</InlineLink>
|
||||
</span>
|
||||
</Callout>
|
||||
</div>
|
||||
|
||||
<RestrictedAccess hasAccess={permission.routes.read}>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<NetworkRoutesTable
|
||||
isLoading={isLoading}
|
||||
groupedRoutes={groupedRoutes}
|
||||
routes={routes}
|
||||
headingTarget={portalTarget}
|
||||
/>
|
||||
</Suspense>
|
||||
</RestrictedAccess>
|
||||
</PeersProvider>
|
||||
</RoutesProvider>
|
||||
</PageContainer>
|
||||
);
|
||||
<RestrictedAccess hasAccess={permission.routes.read}>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<NetworkRoutesTable
|
||||
isLoading={isLoading}
|
||||
groupedRoutes={groupedRoutes}
|
||||
routes={routes}
|
||||
headingTarget={portalTarget}
|
||||
/>
|
||||
</Suspense>
|
||||
</RestrictedAccess>
|
||||
</PeersProvider>
|
||||
</RoutesProvider>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -28,6 +27,7 @@ import {
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import React, { useMemo } from "react";
|
||||
import useUrlTab from "@/hooks/useUrlTab";
|
||||
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
|
||||
@@ -35,6 +35,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 +50,7 @@ import ReverseProxiesProvider, {
|
||||
flattenReverseProxies,
|
||||
useReverseProxies,
|
||||
} from "@/contexts/ReverseProxiesProvider";
|
||||
import { SkeletonNetwork } from "@components/skeletons/SkeletonNetwork";
|
||||
|
||||
export default function NetworkDetailPage() {
|
||||
const queryParameter = useSearchParams();
|
||||
@@ -65,11 +67,13 @@ export default function NetworkDetailPage() {
|
||||
<NetworkOverview network={network} />
|
||||
</ReverseProxiesProvider>
|
||||
) : (
|
||||
<FullScreenLoading />
|
||||
<SkeletonNetwork />
|
||||
);
|
||||
}
|
||||
|
||||
function NetworkOverview({ network }: Readonly<{ network: Network }>) {
|
||||
const t = useTranslations("networks");
|
||||
const tReverseProxy = useTranslations("reverseProxy");
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const { data: resources, isLoading: isResourcesLoading } = useFetchApi<
|
||||
@@ -96,103 +100,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={t("title")}
|
||||
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(t("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(t("routingPeers"), 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(tReverseProxy("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>
|
||||
);
|
||||
}
|
||||
@@ -248,6 +252,8 @@ function NetworkActions() {
|
||||
}
|
||||
|
||||
function NetworkInformationCard({ network }: Readonly<{ network: Network }>) {
|
||||
const t = useTranslations("networks");
|
||||
const tCommon = useTranslations("common");
|
||||
const isHighlyAvailable = !!(
|
||||
network?.routing_peers_count && network?.routing_peers_count >= 2
|
||||
);
|
||||
@@ -256,22 +262,26 @@ function NetworkInformationCard({ network }: Readonly<{ network: Network }>) {
|
||||
() => (
|
||||
<>
|
||||
High availability is currently{" "}
|
||||
<span className={"text-yellow-400 font-medium"}>inactive</span> for this
|
||||
network.
|
||||
<span className={"text-yellow-400 font-medium"}>
|
||||
{tCommon("inactive")}
|
||||
</span>{" "}
|
||||
for this network.
|
||||
</>
|
||||
),
|
||||
[],
|
||||
[tCommon],
|
||||
);
|
||||
|
||||
const enabledText = useMemo(
|
||||
() => (
|
||||
<>
|
||||
High availability is{" "}
|
||||
<span className={"text-green-500 font-medium"}>active</span> for this
|
||||
network.
|
||||
<span className={"text-green-500 font-medium"}>
|
||||
{tCommon("active")}
|
||||
</span>{" "}
|
||||
for this network.
|
||||
</>
|
||||
),
|
||||
[],
|
||||
[tCommon],
|
||||
);
|
||||
|
||||
const policyCount = network.policies?.length ?? 0;
|
||||
@@ -318,7 +328,7 @@ function NetworkInformationCard({ network }: Readonly<{ network: Network }>) {
|
||||
!isHighlyAvailable ? "bg-yellow-400" : "bg-green-500",
|
||||
)}
|
||||
></span>
|
||||
{isHighlyAvailable ? "Active" : "Inactive"}
|
||||
{isHighlyAvailable ? tCommon("active") : tCommon("inactive")}
|
||||
<HelpCircle size={12} />
|
||||
</div>
|
||||
</FullTooltip>
|
||||
@@ -330,20 +340,19 @@ function NetworkInformationCard({ network }: Readonly<{ network: Network }>) {
|
||||
policyCount > 0 ? (
|
||||
<>
|
||||
<ShieldCheckIcon size={16} className={"text-green-500"} />
|
||||
{policyCount}{" "}
|
||||
{policyCount === 1 ? "Active Policy" : "Active Policies"}
|
||||
{t("activePoliciesCount", { count: policyCount })}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShieldXIcon size={16} className={"text-red-500"} />
|
||||
No Active Policies
|
||||
{t("noActivePolicies")}
|
||||
</>
|
||||
)
|
||||
}
|
||||
value={
|
||||
policyCount > 0 ? (
|
||||
<InlineLink href={"/access-control"}>
|
||||
Go to Policies
|
||||
{t("goToPolicies")}
|
||||
<ArrowUpRightIcon size={14} />
|
||||
</InlineLink>
|
||||
) : null
|
||||
|
||||
@@ -8,6 +8,7 @@ import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import React, { Suspense } from "react";
|
||||
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
@@ -16,6 +17,9 @@ import PageContainer from "@/layouts/PageContainer";
|
||||
import NetworksTable from "@/modules/networks/table/NetworksTable";
|
||||
|
||||
export default function Networks() {
|
||||
const t = useTranslations("networks");
|
||||
const tCommon = useTranslations("common");
|
||||
const tNavigation = useTranslations("navigation");
|
||||
const { data: networks, isLoading } = useFetchApi<Network[]>("/networks");
|
||||
const { permission } = usePermissions();
|
||||
const { ref: headingRef, portalTarget } =
|
||||
@@ -26,26 +30,21 @@ export default function Networks() {
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/networks"}
|
||||
label={"Networks"}
|
||||
label={tNavigation("networkRouting")}
|
||||
icon={<NetworkRoutesIcon size={13} />}
|
||||
/>
|
||||
<Breadcrumbs.Item href={"/networks"} label={t("title")} />
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>Networks</h1>
|
||||
<h1 ref={headingRef}>{t("title")}</h1>
|
||||
<Paragraph>
|
||||
Networks allow you to access internal resources in LANs and VPCs
|
||||
without installing NetBird on every machine.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Learn more about
|
||||
{t("pageDescription")}{" "}
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/networks"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Networks
|
||||
{tCommon("learnMore")}
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,116 +1,5 @@
|
||||
"use client";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import Breadcrumbs from "@components/Breadcrumbs";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import SkeletonTable from "@components/skeletons/SkeletonTable";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import PeerIcon from "@/assets/icons/PeerIcon";
|
||||
import PeersProvider, { usePeers } from "@/contexts/PeersProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useUsers } from "@/contexts/UsersProvider";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import { SetupModalContent } from "@/modules/setup-netbird-modal/SetupModal";
|
||||
|
||||
const PeersTable = lazy(() => import("@/modules/peers/PeersTable"));
|
||||
|
||||
export default function Peers() {
|
||||
const { isRestricted } = usePermissions();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
{isRestricted ? (
|
||||
<PeersBlockedView />
|
||||
) : (
|
||||
<PeersProvider>
|
||||
<PeersView />
|
||||
</PeersProvider>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function PeersView() {
|
||||
const { peers, isLoading } = usePeers();
|
||||
const { users } = useUsers();
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
const peersWithUser = peers?.map((peer) => {
|
||||
if (!users) return peer;
|
||||
return {
|
||||
...peer,
|
||||
user: users?.find((user) => user.id === peer.user_id),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/peers"}
|
||||
label={"Peers"}
|
||||
icon={<PeerIcon size={13} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>Peers</h1>
|
||||
<Paragraph>
|
||||
A list of all machines and devices connected to your private network.
|
||||
Use this view to manage peers.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Learn more about{" "}
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/add-machines-to-your-network"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Peers
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<PeersTable
|
||||
isLoading={isLoading}
|
||||
peers={peersWithUser}
|
||||
headingTarget={portalTarget}
|
||||
/>
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PeersBlockedView() {
|
||||
return (
|
||||
<div className={"flex items-center justify-center flex-col"}>
|
||||
<div className={"p-default py-6 max-w-3xl text-center"}>
|
||||
<h1>Add new device to your network</h1>
|
||||
<Paragraph className={"inline"}>
|
||||
To get started, install NetBird and log in using your email account.
|
||||
After that you should be connected. If you have further questions
|
||||
check out our{" "}
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/getting-started#installation"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Installation Guide
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className={"px-3 pt-1 pb-8 max-w-3xl w-full"}>
|
||||
<div
|
||||
className={
|
||||
"rounded-md border border-nb-gray-900/70 grid w-full bg-nb-gray-930/40 stepper-bg-variant"
|
||||
}
|
||||
>
|
||||
<SetupModalContent header={false} footer={false} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
export default function PeersIndex() {
|
||||
redirect("/peers/users");
|
||||
}
|
||||
|
||||
8
src/app/(dashboard)/peers/servers/layout.tsx
Normal file
8
src/app/(dashboard)/peers/servers/layout.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { globalMetaTitle } from "@utils/meta";
|
||||
import type { Metadata } from "next";
|
||||
import BlankLayout from "@/layouts/BlankLayout";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Servers - ${globalMetaTitle}`,
|
||||
};
|
||||
export default BlankLayout;
|
||||
125
src/app/(dashboard)/peers/servers/page.tsx
Normal file
125
src/app/(dashboard)/peers/servers/page.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import Breadcrumbs from "@components/Breadcrumbs";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import SkeletonTable from "@components/skeletons/SkeletonTable";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import React, { lazy, Suspense, useMemo } from "react";
|
||||
import PeerIcon from "@/assets/icons/PeerIcon";
|
||||
import PeersProvider, { usePeers } from "@/contexts/PeersProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useUsers } from "@/contexts/UsersProvider";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import { SetupModalContent } from "@/modules/setup-netbird-modal/SetupModal";
|
||||
|
||||
const PeersTable = lazy(() => import("@/modules/peers/PeersTable"));
|
||||
|
||||
export default function ServersPage() {
|
||||
const { isRestricted } = usePermissions();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
{isRestricted ? (
|
||||
<ServersBlockedView />
|
||||
) : (
|
||||
<PeersProvider>
|
||||
<ServersView />
|
||||
</PeersProvider>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function ServersView() {
|
||||
const t = useTranslations("peers");
|
||||
const { peers, isLoading: isPeersLoading } = usePeers();
|
||||
const { users, isLoading: isUsersLoading } = useUsers();
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
// The kind filter classifies peers by whether their owner is a real
|
||||
// user vs a service/no-user, so we must wait until both peers and
|
||||
// users have loaded before joining them — otherwise peers temporarily
|
||||
// render with peer.user === undefined and get misclassified.
|
||||
const isLoading = isPeersLoading || isUsersLoading;
|
||||
const peersWithUser = useMemo(() => {
|
||||
if (!peers || !users) return undefined;
|
||||
return peers.map((peer) => ({
|
||||
...peer,
|
||||
user: users.find((u) => u.id === peer.user_id),
|
||||
}));
|
||||
}, [peers, users]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item label={t("title")} icon={<PeerIcon size={13} />} />
|
||||
<Breadcrumbs.Item
|
||||
href={"/peers/servers"}
|
||||
label={t("servers")}
|
||||
active
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>{t("servers")}</h1>
|
||||
<Paragraph>
|
||||
{t("serversDescription")}{" "}
|
||||
<InlineLink
|
||||
href={
|
||||
"https://docs.netbird.io/how-to/register-machines-using-setup-keys"
|
||||
}
|
||||
target={"_blank"}
|
||||
>
|
||||
{t("learnMore")}
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Paragraph>
|
||||
</div>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<PeersTable
|
||||
isLoading={isLoading}
|
||||
peers={peersWithUser}
|
||||
headingTarget={portalTarget}
|
||||
kind={"servers"}
|
||||
/>
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ServersBlockedView() {
|
||||
const t = useTranslations("peers");
|
||||
return (
|
||||
<div className={"flex items-center justify-center flex-col"}>
|
||||
<div className={"p-default py-6 max-w-3xl text-center"}>
|
||||
<h1>{t("addNewServerTitle")}</h1>
|
||||
<Paragraph className={"inline"}>
|
||||
{t("addNewServerDescription")}{" "}
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/getting-started#installation"}
|
||||
target={"_blank"}
|
||||
>
|
||||
{t("installationGuide")}
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className={"px-3 pt-1 pb-8 max-w-3xl w-full"}>
|
||||
<div
|
||||
className={
|
||||
"rounded-md border border-nb-gray-900/70 grid w-full bg-nb-gray-930/40 stepper-bg-variant"
|
||||
}
|
||||
>
|
||||
<SetupModalContent
|
||||
header={false}
|
||||
footer={false}
|
||||
isUserDevice={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
src/app/(dashboard)/peers/users/layout.tsx
Normal file
8
src/app/(dashboard)/peers/users/layout.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { globalMetaTitle } from "@utils/meta";
|
||||
import type { Metadata } from "next";
|
||||
import BlankLayout from "@/layouts/BlankLayout";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `User Devices - ${globalMetaTitle}`,
|
||||
};
|
||||
export default BlankLayout;
|
||||
119
src/app/(dashboard)/peers/users/page.tsx
Normal file
119
src/app/(dashboard)/peers/users/page.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import Breadcrumbs from "@components/Breadcrumbs";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import SkeletonTable from "@components/skeletons/SkeletonTable";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import React, { lazy, Suspense, useMemo } from "react";
|
||||
import PeerIcon from "@/assets/icons/PeerIcon";
|
||||
import PeersProvider, { usePeers } from "@/contexts/PeersProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useUsers } from "@/contexts/UsersProvider";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import { SetupModalContent } from "@/modules/setup-netbird-modal/SetupModal";
|
||||
|
||||
const PeersTable = lazy(() => import("@/modules/peers/PeersTable"));
|
||||
|
||||
export default function UserDevicesPage() {
|
||||
const { isRestricted } = usePermissions();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
{isRestricted ? (
|
||||
<UserDevicesBlockedView />
|
||||
) : (
|
||||
<PeersProvider>
|
||||
<UserDevicesView />
|
||||
</PeersProvider>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function UserDevicesView() {
|
||||
const t = useTranslations("peers");
|
||||
const { peers, isLoading: isPeersLoading } = usePeers();
|
||||
const { users, isLoading: isUsersLoading } = useUsers();
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
// The kind filter classifies peers by whether their owner is a real
|
||||
// user vs a service/no-user, so we must wait until both peers and
|
||||
// users have loaded before joining them — otherwise peers temporarily
|
||||
// render with peer.user === undefined and get misclassified.
|
||||
const isLoading = isPeersLoading || isUsersLoading;
|
||||
const peersWithUser = useMemo(() => {
|
||||
if (!peers || !users) return undefined;
|
||||
return peers.map((peer) => ({
|
||||
...peer,
|
||||
user: users.find((u) => u.id === peer.user_id),
|
||||
}));
|
||||
}, [peers, users]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item label={t("title")} icon={<PeerIcon size={13} />} />
|
||||
<Breadcrumbs.Item
|
||||
href={"/peers/users"}
|
||||
label={t("userDevices")}
|
||||
active
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>{t("userDevices")}</h1>
|
||||
<Paragraph>
|
||||
{t("userDevicesDescription")}{" "}
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/add-machines-to-your-network"}
|
||||
target={"_blank"}
|
||||
>
|
||||
{t("learnMore")}
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Paragraph>
|
||||
</div>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<PeersTable
|
||||
isLoading={isLoading}
|
||||
peers={peersWithUser}
|
||||
headingTarget={portalTarget}
|
||||
kind={"users"}
|
||||
/>
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UserDevicesBlockedView() {
|
||||
const t = useTranslations("peers");
|
||||
return (
|
||||
<div className={"flex items-center justify-center flex-col"}>
|
||||
<div className={"p-default py-6 max-w-3xl text-center"}>
|
||||
<h1>{t("addNewDeviceTitle")}</h1>
|
||||
<Paragraph className={"inline"}>
|
||||
{t("addNewDeviceDescription")}{" "}
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/getting-started#installation"}
|
||||
target={"_blank"}
|
||||
>
|
||||
{t("installationGuide")}
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className={"px-3 pt-1 pb-8 max-w-3xl w-full"}>
|
||||
<div
|
||||
className={
|
||||
"rounded-md border border-nb-gray-900/70 grid w-full bg-nb-gray-930/40 stepper-bg-variant"
|
||||
}
|
||||
>
|
||||
<SetupModalContent header={false} footer={false} isUserDevice />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,76 +6,61 @@ import Paragraph from "@components/Paragraph";
|
||||
import SkeletonTable from "@components/skeletons/SkeletonTable";
|
||||
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { ExternalLinkIcon, ShieldCheck } from "lucide-react";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { lazy, Suspense } from "react";
|
||||
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
|
||||
import GroupsProvider from "@/contexts/GroupsProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import PoliciesProvider from "@/contexts/PoliciesProvider";
|
||||
import { PostureCheck } from "@/interfaces/PostureCheck";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import useFetchApi from "@utils/api";
|
||||
|
||||
const PostureCheckTable = lazy(
|
||||
() => import("@/modules/posture-checks/table/PostureCheckTable"),
|
||||
() => import("@/modules/posture-checks/table/PostureCheckTable"),
|
||||
);
|
||||
|
||||
export default function PostureChecksPage() {
|
||||
const { permission } = usePermissions();
|
||||
const { data: postureChecks, isLoading } =
|
||||
useFetchApi<PostureCheck[]>("/posture-checks");
|
||||
const t = useTranslations("postureChecks");
|
||||
const tCommon = useTranslations("common");
|
||||
const { permission } = usePermissions();
|
||||
const { data: postureChecks, isLoading } =
|
||||
useFetchApi<PostureCheck[]>("/posture-checks");
|
||||
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<GroupsProvider>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/access-control"}
|
||||
label={"Access Control"}
|
||||
icon={<AccessControlIcon size={14} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/posture-checks"}
|
||||
label={"Posture Checks"}
|
||||
active
|
||||
icon={<ShieldCheck size={15} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>Posture Checks</h1>
|
||||
<Paragraph>
|
||||
Use posture checks to further restrict access in your network.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Learn more about
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/manage-posture-checks"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Posture Checks
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<RestrictedAccess
|
||||
page={"Posture Checks"}
|
||||
hasAccess={permission.policies.read}
|
||||
>
|
||||
<PoliciesProvider>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<PostureCheckTable
|
||||
headingTarget={portalTarget}
|
||||
isLoading={isLoading}
|
||||
postureChecks={postureChecks}
|
||||
/>
|
||||
</Suspense>
|
||||
</PoliciesProvider>
|
||||
</RestrictedAccess>
|
||||
</GroupsProvider>
|
||||
</PageContainer>
|
||||
);
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/posture-checks"}
|
||||
label={t("title")}
|
||||
icon={<ShieldCheck size={14} />}
|
||||
active
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>{t("title")}</h1>
|
||||
<Paragraph>
|
||||
{t("pageDescription")}{" "}
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/manage-posture-checks"}
|
||||
target={"_blank"}
|
||||
>
|
||||
{tCommon("learnMore")}
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Paragraph>
|
||||
</div>
|
||||
<RestrictedAccess page={t("title")} hasAccess={permission.policies.read}>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<PostureCheckTable
|
||||
postureChecks={postureChecks}
|
||||
isLoading={isLoading}
|
||||
headingTarget={portalTarget}
|
||||
/>
|
||||
</Suspense>
|
||||
</RestrictedAccess>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
8
src/app/(dashboard)/reverse-proxy/clusters/layout.tsx
Normal file
8
src/app/(dashboard)/reverse-proxy/clusters/layout.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { globalMetaTitle } from "@utils/meta";
|
||||
import type { Metadata } from "next";
|
||||
import BlankLayout from "@/layouts/BlankLayout";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Clusters - Reverse Proxy - ${globalMetaTitle}`,
|
||||
};
|
||||
export default BlankLayout;
|
||||
66
src/app/(dashboard)/reverse-proxy/clusters/page.tsx
Normal file
66
src/app/(dashboard)/reverse-proxy/clusters/page.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import Breadcrumbs from "@components/Breadcrumbs";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import SkeletonTable from "@components/skeletons/SkeletonTable";
|
||||
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { lazy, Suspense } from "react";
|
||||
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import ReverseProxiesProvider from "@/contexts/ReverseProxiesProvider";
|
||||
import { REVERSE_PROXY_CLUSTERS_DOCS_LINK } from "@/interfaces/ReverseProxy";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
|
||||
const ClustersTable = lazy(
|
||||
() => import("@/modules/reverse-proxy/clusters/ClustersTable"),
|
||||
);
|
||||
|
||||
export default function ReverseProxyClustersPage() {
|
||||
const t = useTranslations("reverseProxy");
|
||||
const tCommon = useTranslations("common");
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/reverse-proxy/services"}
|
||||
label={t("title")}
|
||||
icon={<ReverseProxyIcon size={16} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/reverse-proxy/clusters"}
|
||||
label={t("clusters")}
|
||||
active={true}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>{t("clusters")}</h1>
|
||||
<Paragraph>
|
||||
{t("clustersDescription")}{" "}
|
||||
<InlineLink href={REVERSE_PROXY_CLUSTERS_DOCS_LINK} target={"_blank"}>
|
||||
{tCommon("learnMore")}
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Paragraph>
|
||||
<RestrictedAccess
|
||||
page={t("clusters")}
|
||||
hasAccess={permission.services?.read}
|
||||
>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<ReverseProxiesProvider>
|
||||
<ClustersTable headingTarget={portalTarget} />
|
||||
</ReverseProxiesProvider>
|
||||
</Suspense>
|
||||
</RestrictedAccess>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,8 @@ import SkeletonTable from "@components/skeletons/SkeletonTable";
|
||||
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { lazy, Suspense } from "react";
|
||||
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import ReverseProxiesProvider from "@/contexts/ReverseProxiesProvider";
|
||||
@@ -15,56 +16,54 @@ import { REVERSE_PROXY_CUSTOM_DOMAINS_DOCS_LINK } from "@/interfaces/ReverseProx
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
|
||||
const CustomDomainsTable = lazy(
|
||||
() => import("@/modules/reverse-proxy/domain/CustomDomainsTable"),
|
||||
() => import("@/modules/reverse-proxy/domain/CustomDomainsTable"),
|
||||
);
|
||||
|
||||
export default function ReverseProxyCustomDomainsPage() {
|
||||
const { permission } = usePermissions();
|
||||
const t = useTranslations("reverseProxy");
|
||||
const tCommon = useTranslations("common");
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/reverse-proxy/services"}
|
||||
label={"Reverse Proxy"}
|
||||
icon={<ReverseProxyIcon size={16} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/reverse-proxy/custom-domains"}
|
||||
label={"Custom Domains"}
|
||||
active={true}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>Domains</h1>
|
||||
<Paragraph>
|
||||
Add and manage custom domains for your reverse proxy services.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Learn more about
|
||||
<InlineLink
|
||||
href={REVERSE_PROXY_CUSTOM_DOMAINS_DOCS_LINK}
|
||||
target={"_blank"}
|
||||
>
|
||||
Custom Domains
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<RestrictedAccess
|
||||
page={"Custom Domains"}
|
||||
hasAccess={permission?.services?.read}
|
||||
>
|
||||
<ReverseProxiesProvider>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<CustomDomainsTable headingTarget={portalTarget} />
|
||||
</Suspense>
|
||||
</ReverseProxiesProvider>
|
||||
</RestrictedAccess>
|
||||
</PageContainer>
|
||||
);
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/reverse-proxy/services"}
|
||||
label={t("title")}
|
||||
icon={<ReverseProxyIcon size={16} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/reverse-proxy/custom-domains"}
|
||||
label={t("customDomains")}
|
||||
active={true}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>{t("customDomains")}</h1>
|
||||
<Paragraph>
|
||||
{t("customDomainsDescription")}{" "}
|
||||
<InlineLink
|
||||
href={REVERSE_PROXY_CUSTOM_DOMAINS_DOCS_LINK}
|
||||
target={"_blank"}
|
||||
>
|
||||
{tCommon("learnMore")}
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Paragraph>
|
||||
<RestrictedAccess
|
||||
page={t("customDomains")}
|
||||
hasAccess={permission.services?.read}
|
||||
>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<ReverseProxiesProvider>
|
||||
<CustomDomainsTable headingTarget={portalTarget} />
|
||||
</ReverseProxiesProvider>
|
||||
</Suspense>
|
||||
</RestrictedAccess>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
8
src/app/(dashboard)/reverse-proxy/logs/layout.tsx
Normal file
8
src/app/(dashboard)/reverse-proxy/logs/layout.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { globalMetaTitle } from "@utils/meta";
|
||||
import type { Metadata } from "next";
|
||||
import BlankLayout from "@/layouts/BlankLayout";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Access Logs - Reverse Proxy - ${globalMetaTitle}`,
|
||||
};
|
||||
export default BlankLayout;
|
||||
63
src/app/(dashboard)/reverse-proxy/logs/page.tsx
Normal file
63
src/app/(dashboard)/reverse-proxy/logs/page.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import Breadcrumbs from "@components/Breadcrumbs";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import SkeletonTable from "@components/skeletons/SkeletonTable";
|
||||
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { lazy, Suspense } from "react";
|
||||
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { REVERSE_PROXY_EVENTS_DOCS_LINK } from "@/interfaces/ReverseProxy";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
|
||||
const ReverseProxyEventsTable = lazy(
|
||||
() => import("@/modules/reverse-proxy/events/ReverseProxyEventsTable"),
|
||||
);
|
||||
|
||||
export default function ProxyLogsPage() {
|
||||
const t = useTranslations("reverseProxy");
|
||||
const tCommon = useTranslations("common");
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/reverse-proxy/services"}
|
||||
label={t("title")}
|
||||
icon={<ReverseProxyIcon size={16} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/reverse-proxy/logs"}
|
||||
label={t("accessLogs")}
|
||||
active={true}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>{t("accessLogs")}</h1>
|
||||
<Paragraph>
|
||||
{t("accessLogsDescription")}{" "}
|
||||
<InlineLink href={REVERSE_PROXY_EVENTS_DOCS_LINK} target={"_blank"}>
|
||||
{tCommon("learnMore")}
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Paragraph>
|
||||
<RestrictedAccess
|
||||
page={t("accessLogs")}
|
||||
hasAccess={permission.services?.read}
|
||||
>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<ReverseProxyEventsTable headingTarget={portalTarget} />
|
||||
</Suspense>
|
||||
</RestrictedAccess>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -11,5 +11,5 @@ export default function ReverseProxyRedirectPage() {
|
||||
router.replace("/reverse-proxy/services");
|
||||
}, [router]);
|
||||
|
||||
return <FullScreenLoading height={"auto"} />;
|
||||
return <FullScreenLoading fullScreen={false} />;
|
||||
}
|
||||
|
||||
@@ -1,83 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import Breadcrumbs from "@components/Breadcrumbs";
|
||||
import { Callout } from "@components/Callout";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import SkeletonTable from "@components/skeletons/SkeletonTable";
|
||||
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import { isNetBirdHosted } from "@utils/netbird";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import ReverseProxiesProvider from "@/contexts/ReverseProxiesProvider";
|
||||
import { REVERSE_PROXY_DOCS_LINK } from "@/interfaces/ReverseProxy";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import { Callout } from "@components/Callout";
|
||||
import { isNetBirdHosted } from "@utils/netbird";
|
||||
|
||||
const ReverseProxyTable = lazy(
|
||||
() => import("@/modules/reverse-proxy/table/ReverseProxyTable"),
|
||||
() => import("@/modules/reverse-proxy/table/ReverseProxyTable"),
|
||||
);
|
||||
|
||||
export default function ReverseProxyServicesPage() {
|
||||
const { permission } = usePermissions();
|
||||
const t = useTranslations("reverseProxy");
|
||||
const tCommon = useTranslations("common");
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/reverse-proxy/services"}
|
||||
label={"Reverse Proxy"}
|
||||
icon={<ReverseProxyIcon size={16} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/reverse-proxy/services"}
|
||||
label={"Services"}
|
||||
active={true}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>Services</h1>
|
||||
<Paragraph>
|
||||
Expose services securely through NetBird's reverse proxy.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Learn more about
|
||||
<InlineLink href={REVERSE_PROXY_DOCS_LINK} target={"_blank"}>
|
||||
Services
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/reverse-proxy/services"}
|
||||
label={t("title")}
|
||||
icon={<ReverseProxyIcon size={16} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/reverse-proxy/services"}
|
||||
label={t("services")}
|
||||
active={true}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>{t("services")}</h1>
|
||||
<Paragraph>
|
||||
{t("servicesDescription")}{" "}
|
||||
<InlineLink href={REVERSE_PROXY_DOCS_LINK} target={"_blank"}>
|
||||
{tCommon("learnMore")}
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Paragraph>
|
||||
|
||||
{isNetBirdHosted() ? (
|
||||
<Callout className={"max-w-xl mt-5"} variant={"info"}>
|
||||
NetBird's Reverse Proxy is currently in beta and available at
|
||||
no cost during this period. Features, functionality, and pricing are
|
||||
subject to change upon release.
|
||||
</Callout>
|
||||
) : (
|
||||
<Callout className={"max-w-xl mt-5"} variant={"info"}>
|
||||
NetBird's Reverse Proxy is currently in beta. <br /> Features
|
||||
and functionality are subject to change upon release.
|
||||
</Callout>
|
||||
)}
|
||||
</div>
|
||||
{isNetBirdHosted() ? (
|
||||
<Callout className={"max-w-xl mt-5"} variant={"info"}>
|
||||
{t("betaNoticeCloud")}
|
||||
</Callout>
|
||||
) : (
|
||||
<Callout className={"max-w-xl mt-5"} variant={"info"}>
|
||||
{t("betaNoticeSelfHosted")}
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
<RestrictedAccess
|
||||
page={"Services"}
|
||||
hasAccess={permission?.services?.read}
|
||||
>
|
||||
<ReverseProxiesProvider>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<ReverseProxyTable headingTarget={portalTarget} />
|
||||
</Suspense>
|
||||
</ReverseProxiesProvider>
|
||||
</RestrictedAccess>
|
||||
</PageContainer>
|
||||
);
|
||||
<RestrictedAccess
|
||||
page={t("services")}
|
||||
hasAccess={permission.services?.read}
|
||||
>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<ReverseProxiesProvider>
|
||||
<ReverseProxyTable headingTarget={portalTarget} />
|
||||
</ReverseProxiesProvider>
|
||||
</Suspense>
|
||||
</RestrictedAccess>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,14 +3,16 @@
|
||||
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import { VerticalTabs } from "@components/VerticalTabs";
|
||||
import {
|
||||
AlertOctagonIcon,
|
||||
FingerprintIcon,
|
||||
FolderGit2Icon,
|
||||
LockIcon,
|
||||
MonitorSmartphoneIcon,
|
||||
NetworkIcon,
|
||||
ShieldIcon,
|
||||
AlertOctagonIcon,
|
||||
FingerprintIcon,
|
||||
FolderGit2Icon,
|
||||
KeyRound,
|
||||
LockIcon,
|
||||
MonitorSmartphoneIcon,
|
||||
NetworkIcon,
|
||||
ShieldIcon,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
@@ -23,95 +25,105 @@ import DangerZoneTab from "@/modules/settings/DangerZoneTab";
|
||||
import IdentityProvidersTab from "@/modules/settings/IdentityProvidersTab";
|
||||
import NetworkSettingsTab from "@/modules/settings/NetworkSettingsTab";
|
||||
import PermissionsTab from "@/modules/settings/PermissionsTab";
|
||||
import SetupKeysTab from "@/modules/settings/SetupKeysTab";
|
||||
import GroupsSettings from "@/modules/settings/GroupsSettings";
|
||||
|
||||
export default function NetBirdSettings() {
|
||||
const queryParams = useSearchParams();
|
||||
const queryTab = queryParams.get("tab");
|
||||
const { permission } = usePermissions();
|
||||
const t = useTranslations("settings");
|
||||
const queryParams = useSearchParams();
|
||||
const queryTab = queryParams.get("tab");
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const initialTab = useMemo(() => {
|
||||
if (permission.settings.read) return "authentication";
|
||||
return "authentication";
|
||||
}, [permission]);
|
||||
const initialTab = useMemo(() => {
|
||||
if (permission.settings.read) return "authentication";
|
||||
return "authentication";
|
||||
}, [permission]);
|
||||
|
||||
const [tab, setTab] = useState(queryTab ?? initialTab);
|
||||
const [tab, setTab] = useState(queryTab ?? initialTab);
|
||||
|
||||
const account = useAccount();
|
||||
const account = useAccount();
|
||||
|
||||
useEffect(() => {
|
||||
if (queryTab) {
|
||||
setTab(queryTab);
|
||||
}
|
||||
}, [queryTab]);
|
||||
useEffect(() => {
|
||||
if (queryTab) {
|
||||
setTab(queryTab);
|
||||
}
|
||||
}, [queryTab]);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<VerticalTabs value={tab} onChange={setTab}>
|
||||
<VerticalTabs.List>
|
||||
{permission.settings.read && (
|
||||
<>
|
||||
<VerticalTabs.Trigger value="authentication">
|
||||
<ShieldIcon size={14} />
|
||||
Authentication
|
||||
</VerticalTabs.Trigger>
|
||||
{account?.settings?.embedded_idp_enabled &&
|
||||
permission?.identity_providers?.read && (
|
||||
<VerticalTabs.Trigger value="identity-providers">
|
||||
<FingerprintIcon size={14} />
|
||||
Identity Providers
|
||||
</VerticalTabs.Trigger>
|
||||
)}
|
||||
<VerticalTabs.Trigger value="groups">
|
||||
<FolderGit2Icon size={14} />
|
||||
Groups
|
||||
</VerticalTabs.Trigger>
|
||||
<VerticalTabs.Trigger value="permissions">
|
||||
<LockIcon size={14} />
|
||||
Permissions
|
||||
</VerticalTabs.Trigger>
|
||||
<VerticalTabs.Trigger value="networks">
|
||||
<NetworkIcon size={14} />
|
||||
Networks
|
||||
</VerticalTabs.Trigger>
|
||||
<VerticalTabs.Trigger value="clients">
|
||||
<MonitorSmartphoneIcon size={14} />
|
||||
Clients
|
||||
</VerticalTabs.Trigger>
|
||||
</>
|
||||
)}
|
||||
return (
|
||||
<PageContainer>
|
||||
<VerticalTabs value={tab} onChange={setTab}>
|
||||
<VerticalTabs.List>
|
||||
{permission.settings.read && (
|
||||
<>
|
||||
<VerticalTabs.Trigger value="authentication">
|
||||
<ShieldIcon size={14} />
|
||||
{t("authentication")}
|
||||
</VerticalTabs.Trigger>
|
||||
{permission.setup_keys.read && (
|
||||
<VerticalTabs.Trigger value="setup-keys">
|
||||
<KeyRound size={14} />
|
||||
{t("setupKeys")}
|
||||
</VerticalTabs.Trigger>
|
||||
)}
|
||||
{account?.settings?.embedded_idp_enabled &&
|
||||
permission?.identity_providers?.read && (
|
||||
<VerticalTabs.Trigger value="identity-providers">
|
||||
<FingerprintIcon size={14} />
|
||||
{t("identityProviders")}
|
||||
</VerticalTabs.Trigger>
|
||||
)}
|
||||
<VerticalTabs.Trigger value="groups">
|
||||
<FolderGit2Icon size={14} />
|
||||
{t("groupsTab")}
|
||||
</VerticalTabs.Trigger>
|
||||
<VerticalTabs.Trigger value="permissions">
|
||||
<LockIcon size={14} />
|
||||
{t("permissions")}
|
||||
</VerticalTabs.Trigger>
|
||||
<VerticalTabs.Trigger value="networks">
|
||||
<NetworkIcon size={14} />
|
||||
{t("networksTab")}
|
||||
</VerticalTabs.Trigger>
|
||||
<VerticalTabs.Trigger value="clients">
|
||||
<MonitorSmartphoneIcon size={14} />
|
||||
{t("clients")}
|
||||
</VerticalTabs.Trigger>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DangerZoneTabTrigger />
|
||||
</VerticalTabs.List>
|
||||
<RestrictedAccess
|
||||
page={"Settings"}
|
||||
hasAccess={permission.settings.read}
|
||||
>
|
||||
<div className={"border-l border-nb-gray-930 w-full"}>
|
||||
{account && <AuthenticationTab account={account} />}
|
||||
{account?.settings?.embedded_idp_enabled &&
|
||||
permission.identity_providers.read && <IdentityProvidersTab />}
|
||||
{account && <PermissionsTab account={account} />}
|
||||
{account && <GroupsSettings account={account} />}
|
||||
{account && <NetworkSettingsTab account={account} />}
|
||||
{account && <ClientSettingsTab account={account} />}
|
||||
{account && <DangerZoneTab account={account} />}
|
||||
</div>
|
||||
</RestrictedAccess>
|
||||
</VerticalTabs>
|
||||
</PageContainer>
|
||||
);
|
||||
<DangerZoneTabTrigger />
|
||||
</VerticalTabs.List>
|
||||
<RestrictedAccess
|
||||
page={t("title")}
|
||||
hasAccess={permission.settings.read}
|
||||
>
|
||||
<div className={"border-l border-nb-gray-930 w-full"}>
|
||||
{account && <AuthenticationTab account={account} />}
|
||||
{permission.setup_keys.read && <SetupKeysTab />}
|
||||
{account?.settings?.embedded_idp_enabled &&
|
||||
permission.identity_providers.read && <IdentityProvidersTab />}
|
||||
{account && <PermissionsTab account={account} />}
|
||||
{account && <GroupsSettings account={account} />}
|
||||
{account && <NetworkSettingsTab account={account} />}
|
||||
{account && <ClientSettingsTab account={account} />}
|
||||
{account && <DangerZoneTab account={account} />}
|
||||
</div>
|
||||
</RestrictedAccess>
|
||||
</VerticalTabs>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const DangerZoneTabTrigger = () => {
|
||||
const { isOwner } = useLoggedInUser();
|
||||
const t = useTranslations("settings");
|
||||
const { isOwner } = useLoggedInUser();
|
||||
|
||||
return (
|
||||
isOwner && (
|
||||
<VerticalTabs.Trigger value="danger-zone" disabled={!isOwner}>
|
||||
<AlertOctagonIcon size={14} />
|
||||
Danger zone
|
||||
</VerticalTabs.Trigger>
|
||||
)
|
||||
);
|
||||
return (
|
||||
isOwner && (
|
||||
<VerticalTabs.Trigger value="danger-zone" disabled={!isOwner}>
|
||||
<AlertOctagonIcon size={14} />
|
||||
{t("dangerZone")}
|
||||
</VerticalTabs.Trigger>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,90 +1,5 @@
|
||||
"use client";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import Breadcrumbs from "@components/Breadcrumbs";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import SkeletonTable from "@components/skeletons/SkeletonTable";
|
||||
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import React, { lazy, Suspense, useMemo } from "react";
|
||||
import SetupKeysIcon from "@/assets/icons/SetupKeysIcon";
|
||||
import { useGroups } from "@/contexts/GroupsProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { SetupKey } from "@/interfaces/SetupKey";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
|
||||
const SetupKeysTable = lazy(
|
||||
() => import("@/modules/setup-keys/SetupKeysTable"),
|
||||
);
|
||||
|
||||
export default function SetupKeys() {
|
||||
const { data: setupKeys, isLoading } = useFetchApi<SetupKey[]>("/setup-keys");
|
||||
const { permission } = usePermissions();
|
||||
const { groups } = useGroups();
|
||||
|
||||
const setupKeysWithGroups = useMemo(() => {
|
||||
if (!setupKeys) return [];
|
||||
return setupKeys?.map((setupKey) => {
|
||||
if (!setupKey.auto_groups) return setupKey;
|
||||
if (!groups) return setupKey;
|
||||
return {
|
||||
...setupKey,
|
||||
groups: setupKey.auto_groups
|
||||
?.map((group) => {
|
||||
return groups.find((g) => g.id === group) || undefined;
|
||||
})
|
||||
.filter((group) => group !== undefined) as Group[],
|
||||
};
|
||||
});
|
||||
}, [setupKeys, groups]);
|
||||
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/setup-keys"}
|
||||
label={"Setup Keys"}
|
||||
icon={<SetupKeysIcon size={13} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>Setup Keys</h1>
|
||||
<Paragraph>
|
||||
Setup keys are pre-authentication keys that allow to register new
|
||||
machines in your network.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Learn more about
|
||||
<InlineLink
|
||||
href={
|
||||
"https://docs.netbird.io/how-to/register-machines-using-setup-keys"
|
||||
}
|
||||
target={"_blank"}
|
||||
>
|
||||
Setup Keys
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<RestrictedAccess
|
||||
page={"Setup Keys"}
|
||||
hasAccess={permission.setup_keys.read}
|
||||
>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<SetupKeysTable
|
||||
headingTarget={portalTarget}
|
||||
setupKeys={setupKeysWithGroups}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</Suspense>
|
||||
</RestrictedAccess>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
export default function SetupKeysIndex() {
|
||||
redirect("/settings?tab=setup-keys");
|
||||
}
|
||||
@@ -11,5 +11,5 @@ export default function Team() {
|
||||
router.push("/team/users");
|
||||
}, [router]);
|
||||
|
||||
return <FullScreenLoading height={"auto"} />;
|
||||
return <FullScreenLoading fullScreen={false} />;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import { IconSettings2 } from "@tabler/icons-react";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import TeamIcon from "@/assets/icons/TeamIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
@@ -16,63 +17,57 @@ import { User } from "@/interfaces/User";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
|
||||
const ServiceUsersTable = lazy(
|
||||
() => import("@/modules/users/ServiceUsersTable"),
|
||||
() => import("@/modules/users/ServiceUsersTable"),
|
||||
);
|
||||
|
||||
export default function ServiceUsers() {
|
||||
const { permission } = usePermissions();
|
||||
const { data: users, isLoading } = useFetchApi<User[]>(
|
||||
"/users?service_user=true",
|
||||
);
|
||||
const t = useTranslations("serviceUsers");
|
||||
const tUsers = useTranslations("users");
|
||||
const { permission } = usePermissions();
|
||||
const { data: users, isLoading } = useFetchApi<User[]>(
|
||||
"/users?service_user=true",
|
||||
);
|
||||
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/team"}
|
||||
label={"Team"}
|
||||
icon={<TeamIcon size={13} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/team/service-users"}
|
||||
label={"Service Users"}
|
||||
active
|
||||
icon={<IconSettings2 size={17} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>Service Users</h1>
|
||||
<Paragraph>
|
||||
Use service users to create API tokens and avoid losing automated
|
||||
access.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Learn more about
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/access-netbird-public-api"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Service Users
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<RestrictedAccess
|
||||
page={"Service Users"}
|
||||
hasAccess={permission.users.read}
|
||||
>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<ServiceUsersTable
|
||||
users={users}
|
||||
isLoading={isLoading}
|
||||
headingTarget={portalTarget}
|
||||
/>
|
||||
</Suspense>
|
||||
</RestrictedAccess>
|
||||
</PageContainer>
|
||||
);
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/team"}
|
||||
label={tUsers("team")}
|
||||
icon={<TeamIcon size={13} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/team/service-users"}
|
||||
label={t("title")}
|
||||
active
|
||||
icon={<IconSettings2 size={17} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>{t("title")}</h1>
|
||||
<Paragraph>
|
||||
{t("serviceUsersDescription")}{" "}
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/access-netbird-public-api"}
|
||||
target={"_blank"}
|
||||
>
|
||||
{tUsers("learnMore")}
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Paragraph>
|
||||
</div>
|
||||
<RestrictedAccess page={t("title")} hasAccess={permission.users.read}>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<ServiceUsersTable
|
||||
users={users}
|
||||
isLoading={isLoading}
|
||||
headingTarget={portalTarget}
|
||||
/>
|
||||
</Suspense>
|
||||
</RestrictedAccess>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { ExternalLinkIcon, User2 } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import TeamIcon from "@/assets/icons/TeamIcon";
|
||||
import { useGroups } from "@/contexts/GroupsProvider";
|
||||
@@ -18,57 +19,53 @@ import PageContainer from "@/layouts/PageContainer";
|
||||
const UsersTable = lazy(() => import("@/modules/users/UsersTable"));
|
||||
|
||||
export default function TeamUsers() {
|
||||
const { isLoading: isGroupsLoading } = useGroups();
|
||||
const { permission } = usePermissions();
|
||||
const { data: users, isLoading } = useFetchApi<User[]>(
|
||||
"/users?service_user=false",
|
||||
);
|
||||
const t = useTranslations("users");
|
||||
const { isLoading: isGroupsLoading } = useGroups();
|
||||
const { permission } = usePermissions();
|
||||
const { data: users, isLoading } = useFetchApi<User[]>(
|
||||
"/users?service_user=false",
|
||||
);
|
||||
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/team"}
|
||||
label={"Team"}
|
||||
icon={<TeamIcon size={13} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/team/users"}
|
||||
label={"Users"}
|
||||
active
|
||||
icon={<User2 size={16} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>Users</h1>
|
||||
<Paragraph>
|
||||
Manage users and their permissions. Same-domain email users are added
|
||||
automatically on first sign-in.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Learn more about
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/add-users-to-your-network"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Users
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<RestrictedAccess page={"Users"} hasAccess={permission.users.read}>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<UsersTable
|
||||
users={users}
|
||||
isLoading={isLoading || isGroupsLoading}
|
||||
headingTarget={portalTarget}
|
||||
/>
|
||||
</Suspense>
|
||||
</RestrictedAccess>
|
||||
</PageContainer>
|
||||
);
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/team"}
|
||||
label={t("team")}
|
||||
icon={<TeamIcon size={13} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/team/users"}
|
||||
label={t("title")}
|
||||
active
|
||||
icon={<User2 size={16} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>{t("title")}</h1>
|
||||
<Paragraph>
|
||||
{t("usersPageDescription")}{" "}
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/add-users-to-your-network"}
|
||||
target={"_blank"}
|
||||
>
|
||||
{t("learnMore")}
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Paragraph>
|
||||
</div>
|
||||
<RestrictedAccess page={t("title")} hasAccess={permission.users.read}>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<UsersTable
|
||||
users={users}
|
||||
isLoading={isLoading || isGroupsLoading}
|
||||
headingTarget={portalTarget}
|
||||
/>
|
||||
</Suspense>
|
||||
</RestrictedAccess>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
} from "@utils/version";
|
||||
|
||||
export default function SSHPage() {
|
||||
const { peerId, username, port } = useSSHQueryParams();
|
||||
const { peerId, username, port, ipVersion } = useSSHQueryParams();
|
||||
|
||||
const {
|
||||
data: peer,
|
||||
@@ -48,6 +48,7 @@ export default function SSHPage() {
|
||||
peer={peer}
|
||||
username={username}
|
||||
port={port}
|
||||
ipVersion={ipVersion}
|
||||
/>
|
||||
) : (
|
||||
<LoadingMessage message={"Starting ssh session..."} />
|
||||
@@ -60,9 +61,10 @@ type Props = {
|
||||
username: string;
|
||||
port: string;
|
||||
peer: Peer;
|
||||
ipVersion: string | null;
|
||||
};
|
||||
|
||||
function SSHTerminal({ username, port, peer }: Props) {
|
||||
function SSHTerminal({ username, port, peer, ipVersion }: Props) {
|
||||
const client = useNetBirdClient();
|
||||
const connected = useRef(false);
|
||||
const sshConnectedOnce = useRef(false);
|
||||
@@ -81,9 +83,12 @@ function SSHTerminal({ username, port, peer }: Props) {
|
||||
const isClientDisconnected = client.status === NetBirdStatus.DISCONNECTED;
|
||||
const isClientConnecting = client.status === NetBirdStatus.CONNECTING;
|
||||
|
||||
// Use the FQDN when an IP version is specified so the dialer resolves to the correct address family.
|
||||
const sshHost = ipVersion ? peer.dns_label || peer.ip : peer.ip;
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `${username}@${peer.ip} - ${peer.hostname}`;
|
||||
}, [username, peer, client]);
|
||||
document.title = `${username}@${sshHost} - ${peer.hostname}`;
|
||||
}, [username, peer, client, sshHost]);
|
||||
|
||||
const handleReconnect = async () => {
|
||||
if (!peer?.id) return;
|
||||
@@ -97,9 +102,10 @@ function SSHTerminal({ username, port, peer }: Props) {
|
||||
const rules = [`${protocol}/${aclPort}`];
|
||||
await client?.connectTemporary(peer.id, rules);
|
||||
await ssh({
|
||||
hostname: peer.ip,
|
||||
hostname: sshHost,
|
||||
port: Number(port),
|
||||
username,
|
||||
ipVersion: ipVersion || undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Reconnection failed:", error);
|
||||
@@ -123,9 +129,10 @@ function SSHTerminal({ username, port, peer }: Props) {
|
||||
const rules = [`${protocol}/${aclPort}`];
|
||||
await client?.connectTemporary(peer.id, rules);
|
||||
const res = await ssh({
|
||||
hostname: peer.ip,
|
||||
hostname: sshHost,
|
||||
port: Number(port),
|
||||
username,
|
||||
ipVersion: ipVersion || undefined,
|
||||
});
|
||||
if (res === SSHStatus.CONNECTED) {
|
||||
sshConnectedOnce.current = true;
|
||||
|
||||
@@ -23,6 +23,7 @@ export const idpIcon = (
|
||||
zitadel: <ZitadelIcon size={size} />,
|
||||
authentik: <AuthentikIcon size={size} />,
|
||||
keycloak: <KeycloakIcon size={size} />,
|
||||
adfs: <MicrosoftIcon size={size} />,
|
||||
oidc: <KeyRound size={size} className="text-nb-gray-400" />,
|
||||
};
|
||||
|
||||
|
||||
@@ -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 |
@@ -4,6 +4,7 @@ import * as React from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
const QUERY_PARAMS_KEY = "netbird-query-params";
|
||||
const PRESERVE_QUERY_PARAMS_PATHS = ["/peer/ssh", "/peer/rdp"];
|
||||
const VALID_PARAMS = [
|
||||
"tab",
|
||||
"search",
|
||||
@@ -28,9 +29,9 @@ export const SecureProvider = ({ children }: Props) => {
|
||||
const currentPath = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
if (isAuthenticated && !PRESERVE_QUERY_PARAMS_PATHS.includes(currentPath)) {
|
||||
localStorage.removeItem(QUERY_PARAMS_KEY);
|
||||
} else {
|
||||
} else if (!isAuthenticated) {
|
||||
try {
|
||||
const params = window.location.search.substring(1);
|
||||
if (params) {
|
||||
@@ -41,7 +42,7 @@ export const SecureProvider = ({ children }: Props) => {
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
}, [isAuthenticated, currentPath]);
|
||||
|
||||
useEffect(() => {
|
||||
let timeout: NodeJS.Timeout | undefined = undefined;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -50,11 +50,11 @@ function CardListItem({
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
"flex justify-between px-4 border-b border-nb-gray-900 py-4 last:border-b-0 items-center h-full",
|
||||
"flex justify-between px-4 border-b border-nb-gray-900 py-3.5 last:border-b-0 items-center h-full",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={"flex gap-2.5 items-center text-sm"}>{label}</div>
|
||||
<div className={"flex gap-2.5 items-center text-[0.84rem]"}>{label}</div>
|
||||
<div className={"flex flex-col gap-2"}>
|
||||
<CardTextItem
|
||||
label={label}
|
||||
@@ -100,7 +100,7 @@ const CardTextItem = ({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"text-right text-nb-gray-400 text-sm flex items-center gap-2",
|
||||
"text-right text-nb-gray-400 text-[0.84rem] flex items-center gap-2",
|
||||
copy && "cursor-pointer hover:text-nb-gray-300 transition-all",
|
||||
)}
|
||||
onClick={() =>
|
||||
|
||||
129
src/components/CardTable.tsx
Normal file
129
src/components/CardTable.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import useCopyToClipboard from "@hooks/useCopyToClipboard";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { Copy } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
type CardTableProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function CardTable({ children, className }: CardTableProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-nb-gray-940 rounded-md border border-nb-gray-900 w-full overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<table className={"w-full border-collapse text-sm"}>{children}</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTableHeader({ children, className }: CardTableProps) {
|
||||
return (
|
||||
<thead>
|
||||
<tr
|
||||
className={cn(
|
||||
"border-b border-nb-gray-900",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</tr>
|
||||
</thead>
|
||||
);
|
||||
}
|
||||
|
||||
type CardTableHeaderCellProps = {
|
||||
children: React.ReactNode;
|
||||
width?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function CardTableHeaderCell({
|
||||
children,
|
||||
width,
|
||||
className,
|
||||
}: CardTableHeaderCellProps) {
|
||||
return (
|
||||
<th
|
||||
className={cn(
|
||||
"px-4 py-2.5 text-left text-sm font-normal",
|
||||
className,
|
||||
)}
|
||||
style={width ? { width } : undefined}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTableBody({ children, className }: CardTableProps) {
|
||||
return <tbody className={className}>{children}</tbody>;
|
||||
}
|
||||
|
||||
type CardTableRowProps = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function CardTableRow({ children, className }: CardTableRowProps) {
|
||||
return (
|
||||
<tr
|
||||
className={cn(
|
||||
"border-b border-nb-gray-900 last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
type CardTableCellProps = {
|
||||
children: React.ReactNode;
|
||||
copy?: boolean;
|
||||
copyText?: string;
|
||||
width?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function CardTableCell({
|
||||
children,
|
||||
copy = false,
|
||||
copyText,
|
||||
width,
|
||||
className,
|
||||
}: CardTableCellProps) {
|
||||
const [, copyToClipBoard] = useCopyToClipboard(copyText ?? "");
|
||||
return (
|
||||
<td
|
||||
className={cn("px-4 py-3", className)}
|
||||
style={width ? { width } : undefined}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"text-nb-gray-400 text-sm flex items-center gap-2",
|
||||
copy && "cursor-pointer hover:text-nb-gray-300 transition-all",
|
||||
)}
|
||||
onClick={() =>
|
||||
copy &&
|
||||
copyToClipBoard(`${copyText} has been copied to clipboard.`)
|
||||
}
|
||||
>
|
||||
{children}
|
||||
{copy && <Copy size={13} className={"shrink-0"} />}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
CardTable.Header = CardTableHeader;
|
||||
CardTable.HeaderCell = CardTableHeaderCell;
|
||||
CardTable.Body = CardTableBody;
|
||||
CardTable.Row = CardTableRow;
|
||||
CardTable.Cell = CardTableCell;
|
||||
|
||||
export default CardTable;
|
||||
@@ -9,6 +9,11 @@ type Props = {
|
||||
iconAlignment?: "left" | "right";
|
||||
className?: string;
|
||||
alwaysShowIcon?: boolean;
|
||||
// Overrides the rendered innerText as the value written to the
|
||||
// clipboard. Use when the displayed text is an abbreviation of the
|
||||
// canonical value (e.g. the short DNS label) but the user should
|
||||
// still get the full string when they click.
|
||||
textToCopy?: string;
|
||||
};
|
||||
|
||||
export default function CopyToClipboardText({
|
||||
@@ -17,8 +22,9 @@ export default function CopyToClipboardText({
|
||||
iconAlignment = "right",
|
||||
className,
|
||||
alwaysShowIcon = false,
|
||||
textToCopy,
|
||||
}: Props) {
|
||||
const [wrapper, copyToClipboard, copied] = useCopyToClipboard();
|
||||
const [wrapper, copyToClipboard, copied] = useCopyToClipboard(textToCopy);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,42 +3,50 @@ import SquareIcon from "@components/SquareIcon";
|
||||
import AddPeerButton from "@components/ui/AddPeerButton";
|
||||
import GetStartedTest from "@components/ui/GetStartedTest";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import * as React from "react";
|
||||
import PeerIcon from "@/assets/icons/PeerIcon";
|
||||
|
||||
type Props = {
|
||||
showBackground?: boolean;
|
||||
showBackground?: boolean;
|
||||
// When set, tailors the empty-state copy and threads isUserDevice
|
||||
// through AddPeerButton so the right Install NetBird flow opens:
|
||||
// true → User Devices empty state (browser/SSO flow, mobile tabs).
|
||||
// false → Servers empty state (setup-key flow, no mobile tabs).
|
||||
// undefined → legacy/global empty state (no kind preference).
|
||||
isUserDevice?: boolean;
|
||||
};
|
||||
|
||||
export const NoPeersGettingStarted = ({ showBackground = true }) => {
|
||||
return (
|
||||
<GetStartedTest
|
||||
showBackground={showBackground}
|
||||
icon={
|
||||
<SquareIcon
|
||||
icon={<PeerIcon className={"fill-nb-gray-200"} size={20} />}
|
||||
color={"gray"}
|
||||
size={"large"}
|
||||
/>
|
||||
}
|
||||
title={"Get Started with NetBird"}
|
||||
description={
|
||||
"It looks like you don't have any connected machines.\n" +
|
||||
"Get started by adding one to your network."
|
||||
}
|
||||
button={<AddPeerButton />}
|
||||
learnMore={
|
||||
<>
|
||||
Learn more in our{" "}
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/getting-started"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Getting Started Guide
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
export const NoPeersGettingStarted = ({
|
||||
showBackground = true,
|
||||
isUserDevice,
|
||||
}: Readonly<Props>) => {
|
||||
const t = useTranslations("peers");
|
||||
return (
|
||||
<GetStartedTest
|
||||
showBackground={showBackground}
|
||||
icon={
|
||||
<SquareIcon
|
||||
icon={<PeerIcon className={"fill-nb-gray-200"} size={20} />}
|
||||
color={"gray"}
|
||||
size={"large"}
|
||||
/>
|
||||
}
|
||||
title={t("getStarted")}
|
||||
description={t("getStartedDescription")}
|
||||
button={<AddPeerButton isUserDevice={isUserDevice} />}
|
||||
learnMore={
|
||||
<>
|
||||
{t("learnMoreInOur")}{" "}
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/getting-started"}
|
||||
target={"_blank"}
|
||||
>
|
||||
{t("gettingStartedGuide")}
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,7 +30,10 @@ import {
|
||||
MonitorSmartphoneIcon,
|
||||
NetworkIcon,
|
||||
SearchIcon,
|
||||
ServerIcon,
|
||||
ShieldCheck,
|
||||
WorkflowIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Fragment, useEffect, useMemo, useState } from "react";
|
||||
@@ -40,13 +43,21 @@ 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";
|
||||
import TruncatedText from "@components/ui/TruncatedText";
|
||||
|
||||
type PeerGroupSelectorTab = "peers" | "groups" | "resources";
|
||||
type PeerGroupSelectorTab = "peers" | "groups" | "resources" | "clusters";
|
||||
|
||||
export type ClusterOption = {
|
||||
/** Cluster apex domain (e.g. "eu.proxy.netbird.io"); also the value
|
||||
* that downstream code stores in target_id / proxy_cluster. */
|
||||
domain: string;
|
||||
/** Human-friendly label; falls back to domain. */
|
||||
label?: string;
|
||||
};
|
||||
|
||||
const groupsSearchPredicate = (item: Group, query: string) => {
|
||||
const lowerCaseQuery = query.toLowerCase();
|
||||
@@ -71,18 +82,29 @@ interface MultiSelectProps {
|
||||
showResourceCounter?: boolean;
|
||||
showResources?: boolean;
|
||||
showPeers?: boolean;
|
||||
showPeerCounter?: boolean;
|
||||
hideGroupsTab?: boolean;
|
||||
tabOrder?: ("groups" | "peers" | "resources")[];
|
||||
tabOrder?: PeerGroupSelectorTab[];
|
||||
closeOnSelect?: boolean;
|
||||
/** Show a Clusters tab. Off by default; flip on with clusters list. */
|
||||
showClusters?: boolean;
|
||||
/** Clusters offered in the Clusters tab. When empty the tab is hidden. */
|
||||
clusters?: ClusterOption[];
|
||||
/** Currently-selected cluster (domain string), if any. */
|
||||
selectedCluster?: string;
|
||||
/** Called when the user picks (or clears) a cluster. */
|
||||
onClusterChange?: (cluster?: string) => void;
|
||||
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 +123,7 @@ export function PeerGroupSelector({
|
||||
showResourceCounter = true,
|
||||
showResources = false,
|
||||
showPeers = false,
|
||||
showPeerCounter = true,
|
||||
hideGroupsTab = false,
|
||||
tabOrder,
|
||||
closeOnSelect = false,
|
||||
@@ -113,11 +136,25 @@ export function PeerGroupSelector({
|
||||
users,
|
||||
placeholderForSearch = 'Search groups or add new group by pressing "Enter"...',
|
||||
resourceIds,
|
||||
additionalResources,
|
||||
policies,
|
||||
showClusters = false,
|
||||
clusters,
|
||||
selectedCluster,
|
||||
onClusterChange,
|
||||
}: 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");
|
||||
|
||||
@@ -275,10 +312,30 @@ export function PeerGroupSelector({
|
||||
const searchPlaceholder = useMemo(() => {
|
||||
if (tab === "groups") return placeholderForSearch;
|
||||
if (tab === "resources") return "Search resource...";
|
||||
if (tab === "peers") return "Search peer...";
|
||||
if (tab === "peers") return "Search peer by name or ip...";
|
||||
if (tab === "clusters") return "Search cluster...";
|
||||
return "Search...";
|
||||
}, [tab, placeholderForSearch]);
|
||||
|
||||
const filteredClusters = useMemo(() => {
|
||||
if (!clusters || clusters.length === 0) return [];
|
||||
if (!search) return clusters;
|
||||
const q = search.toLowerCase();
|
||||
return clusters.filter(
|
||||
(c) =>
|
||||
c.domain.toLowerCase().includes(q) ||
|
||||
c.label?.toLowerCase().includes(q),
|
||||
);
|
||||
}, [clusters, search]);
|
||||
|
||||
const selectCluster = (cluster?: ClusterOption) => {
|
||||
onClusterChange?.(cluster?.domain);
|
||||
onChange([]);
|
||||
if (closeOnSelect) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectResource = (resource?: NetworkResource) => {
|
||||
onResourceChange?.(
|
||||
resource
|
||||
@@ -329,7 +386,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 +400,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();
|
||||
@@ -353,6 +417,36 @@ export function PeerGroupSelector({
|
||||
showX={true}
|
||||
/>
|
||||
)}
|
||||
{selectedCluster && (
|
||||
<Badge
|
||||
useHover={true}
|
||||
data-cy={"cluster-badge"}
|
||||
variant={"gray-ghost"}
|
||||
className={
|
||||
"py-[3px] transition-all group whitespace-nowrap"
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClusterChange?.(undefined);
|
||||
}}
|
||||
>
|
||||
<ServerIcon size={12} className={"shrink-0"} />
|
||||
<TruncatedText
|
||||
text={
|
||||
(clusters ?? []).find((c) => c.domain === selectedCluster)
|
||||
?.label ?? selectedCluster
|
||||
}
|
||||
maxChars={20}
|
||||
/>
|
||||
<XIcon
|
||||
size={12}
|
||||
className={
|
||||
"cursor-pointer group-hover:text-nb-gray-100 transition-all shrink-0"
|
||||
}
|
||||
/>
|
||||
</Badge>
|
||||
)}
|
||||
{values.map((group) => {
|
||||
return (
|
||||
<div
|
||||
@@ -396,8 +490,10 @@ export function PeerGroupSelector({
|
||||
);
|
||||
})}
|
||||
|
||||
{values.length == 0 && !resource && (
|
||||
<span className={"pl-1"}>{placeholder}</span>
|
||||
{values.length == 0 && !resource && !selectedCluster && (
|
||||
<span className={cn(typeof placeholder === "string" && "pl-1")}>
|
||||
{placeholder}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -473,6 +569,7 @@ export function PeerGroupSelector({
|
||||
searchRef={searchRef}
|
||||
showPeers={showPeers}
|
||||
showResources={showResources}
|
||||
showClusters={showClusters}
|
||||
hideGroupsTab={hideGroupsTab}
|
||||
tabOrder={tabOrder}
|
||||
/>
|
||||
@@ -513,9 +610,6 @@ export function PeerGroupSelector({
|
||||
const isSelected =
|
||||
values.find((group) => group.name == option.name) !=
|
||||
undefined;
|
||||
const peerCount =
|
||||
option.peers?.length ?? option?.peers_count ?? 0;
|
||||
|
||||
const isDisabled = disabledGroups
|
||||
? disabledGroups?.findIndex(
|
||||
(g) => g.id === option.id,
|
||||
@@ -567,12 +661,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}
|
||||
@@ -616,6 +719,15 @@ export function PeerGroupSelector({
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
{showClusters && (
|
||||
<TabsContent value={"clusters"} className={"p-0 my-0"}>
|
||||
<ClustersList
|
||||
clusters={filteredClusters}
|
||||
value={selectedCluster}
|
||||
onChange={selectCluster}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</CommandList>
|
||||
</Command>
|
||||
@@ -628,17 +740,22 @@ const TabTriggers = ({
|
||||
searchRef,
|
||||
showResources = false,
|
||||
showPeers = false,
|
||||
showClusters = false,
|
||||
hideGroupsTab = false,
|
||||
tabOrder,
|
||||
}: {
|
||||
searchRef: React.MutableRefObject<HTMLInputElement | null>;
|
||||
showResources?: boolean;
|
||||
showPeers?: boolean;
|
||||
showClusters?: boolean;
|
||||
hideGroupsTab?: boolean;
|
||||
tabOrder?: ("groups" | "peers" | "resources")[];
|
||||
tabOrder?: PeerGroupSelectorTab[];
|
||||
}) => {
|
||||
const tabCount =
|
||||
(!hideGroupsTab ? 1 : 0) + (showResources ? 1 : 0) + (showPeers ? 1 : 0);
|
||||
(!hideGroupsTab ? 1 : 0) +
|
||||
(showResources ? 1 : 0) +
|
||||
(showPeers ? 1 : 0) +
|
||||
(showClusters ? 1 : 0);
|
||||
if (tabCount <= 1) return null;
|
||||
|
||||
const groupsTab = !hideGroupsTab && (
|
||||
@@ -692,10 +809,28 @@ const TabTriggers = ({
|
||||
</TabsTrigger>
|
||||
);
|
||||
|
||||
const tabMap = {
|
||||
const clustersTab = showClusters && (
|
||||
<TabsTrigger
|
||||
key="clusters"
|
||||
value={"clusters"}
|
||||
className={"text-[.8rem] font-normal"}
|
||||
onClick={() => searchRef.current?.focus()}
|
||||
>
|
||||
<ServerIcon
|
||||
className={
|
||||
"text-nb-gray-500 group-data-[state=active]/trigger:text-netbird transition-all"
|
||||
}
|
||||
size={14}
|
||||
/>
|
||||
Proxy Clusters
|
||||
</TabsTrigger>
|
||||
);
|
||||
|
||||
const tabMap: Record<PeerGroupSelectorTab, React.ReactNode> = {
|
||||
groups: groupsTab,
|
||||
peers: peersTab,
|
||||
resources: resourcesTab,
|
||||
clusters: clustersTab,
|
||||
};
|
||||
|
||||
if (tabOrder) {
|
||||
@@ -711,6 +846,7 @@ const TabTriggers = ({
|
||||
{groupsTab}
|
||||
{resourcesTab}
|
||||
{peersTab}
|
||||
{clustersTab}
|
||||
</TabsList>
|
||||
);
|
||||
};
|
||||
@@ -788,6 +924,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;
|
||||
@@ -899,10 +1068,74 @@ const ResourcesList = ({
|
||||
);
|
||||
};
|
||||
|
||||
const ClustersList = ({
|
||||
clusters,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
clusters: ClusterOption[];
|
||||
value?: string;
|
||||
onChange: (cluster?: ClusterOption) => void;
|
||||
}) => {
|
||||
if (clusters.length === 0) {
|
||||
return (
|
||||
<DropdownInfoText className={"mt-5 max-w-sm mx-auto"}>
|
||||
No proxy clusters available. Go to{" "}
|
||||
<InlineLink href={"/reverse-proxy/custom-domains"}>
|
||||
Custom Domains
|
||||
</InlineLink>{" "}
|
||||
to configure one that supports private services.
|
||||
</DropdownInfoText>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Radio defaultValue={value} name={"cluster"} value={value}>
|
||||
<ScrollArea
|
||||
className={"max-h-[195px] flex flex-col gap-1 py-2 px-2"}
|
||||
>
|
||||
{clusters.map((c) => (
|
||||
<CommandItem
|
||||
key={c.domain}
|
||||
value={c.domain}
|
||||
onSelect={() => onChange(c)}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className={"flex items-center gap-2"}>
|
||||
<Badge
|
||||
useHover={false}
|
||||
variant={"gray-ghost"}
|
||||
className={cn(
|
||||
"transition-all group whitespace-nowrap h-7 px-2",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<ServerIcon size={12} className={"shrink-0"} />
|
||||
<TextWithTooltip text={c.label ?? c.domain} maxChars={32} />
|
||||
</Badge>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-2"
|
||||
}
|
||||
>
|
||||
{c.label && c.label !== c.domain ? c.domain : null}
|
||||
<RadioItem value={c.domain} />
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</Radio>
|
||||
);
|
||||
};
|
||||
|
||||
const peersSearchPredicate = (item: Peer, query: string) => {
|
||||
const lowerCaseQuery = query.toLowerCase();
|
||||
if (item.name.toLowerCase().includes(lowerCaseQuery)) return true;
|
||||
return item.ip.toLowerCase().includes(lowerCaseQuery);
|
||||
if (item.ip.toLowerCase().includes(lowerCaseQuery)) return true;
|
||||
return item.ipv6?.toLowerCase().includes(lowerCaseQuery) ?? false;
|
||||
};
|
||||
|
||||
const PeersList = ({
|
||||
|
||||
@@ -10,6 +10,7 @@ import { cn } from "@utils/helpers";
|
||||
import { isRoutingPeerSupported } from "@utils/version";
|
||||
import { sortBy, unionBy } from "lodash";
|
||||
import { ArrowUpCircleIcon, ChevronsUpDown, MapPin } from "lucide-react";
|
||||
import { useTranslations } from 'next-intl';
|
||||
import * as React from "react";
|
||||
import { memo, useEffect, useState } from "react";
|
||||
import { useElementSize } from "@/hooks/useElementSize";
|
||||
@@ -30,7 +31,8 @@ const searchPredicate = (item: Peer, query: string) => {
|
||||
const lowerCaseQuery = query.toLowerCase();
|
||||
if (item.name.toLowerCase().includes(lowerCaseQuery)) return true;
|
||||
if (item.hostname.toLowerCase().includes(lowerCaseQuery)) return true;
|
||||
return item.ip.toLowerCase().startsWith(lowerCaseQuery);
|
||||
if (item.ip.toLowerCase().startsWith(lowerCaseQuery)) return true;
|
||||
return !!item.ipv6?.toLowerCase().startsWith(lowerCaseQuery);
|
||||
};
|
||||
|
||||
export function PeerSelector({
|
||||
@@ -39,6 +41,7 @@ export function PeerSelector({
|
||||
excludedPeers,
|
||||
disabled = false,
|
||||
}: MultiSelectProps) {
|
||||
const t = useTranslations('peers');
|
||||
const { data: peers } = useFetchApi<Peer[]>("/peers");
|
||||
const [inputRef, { width }] = useElementSize<HTMLButtonElement>();
|
||||
|
||||
@@ -124,12 +127,11 @@ export function PeerSelector({
|
||||
"text-neutral-500 dark:text-nb-gray-300 font-medium flex items-center gap-1 font-mono text-[10px]"
|
||||
}
|
||||
>
|
||||
<MapPinIcon />
|
||||
{value.ip}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span>Select a peer...</span>
|
||||
<span>{t('selectPeer')}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -150,20 +152,20 @@ export function PeerSelector({
|
||||
<DropdownInput
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder={"Search for peers by name or ip..."}
|
||||
placeholder={t('searchPlaceholder')}
|
||||
/>
|
||||
|
||||
{unfilteredItems.length == 0 && !search && (
|
||||
<div className={"max-w-xs mx-auto"}>
|
||||
<DropdownInfoText>
|
||||
{"No peers available to select."}
|
||||
{t('noPeersAvailable')}
|
||||
</DropdownInfoText>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredItems.length == 0 && search != "" && (
|
||||
<DropdownInfoText>
|
||||
There are no peers matching your search.
|
||||
{t('noPeersMatching')}
|
||||
</DropdownInfoText>
|
||||
)}
|
||||
|
||||
@@ -193,9 +195,7 @@ export function PeerSelector({
|
||||
className={"w-full flex items-center justify-between"}
|
||||
content={
|
||||
<div className={"max-w-[240px] text-xs"}>
|
||||
Please update NetBird to at least{" "}
|
||||
<span className={"text-netbird"}>v0.36.6</span> or later
|
||||
to use this peer as a routing peer.
|
||||
{t('updateRequired')}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -238,7 +238,6 @@ export function PeerSelector({
|
||||
!isSupported && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<MapPinIcon />
|
||||
{option.ip}
|
||||
</div>
|
||||
</FullTooltip>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -13,6 +13,7 @@ type SettingCardItemProps = {
|
||||
description: React.ReactNode;
|
||||
enabled: boolean;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
function SettingCardItem({
|
||||
@@ -20,21 +21,31 @@ function SettingCardItem({
|
||||
description,
|
||||
enabled,
|
||||
onClick,
|
||||
disabled = false,
|
||||
}: Readonly<SettingCardItemProps>) {
|
||||
const handleClick = () => {
|
||||
if (disabled) return;
|
||||
onClick();
|
||||
};
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
aria-disabled={disabled || undefined}
|
||||
onClick={handleClick}
|
||||
onKeyDown={(e) => {
|
||||
if (disabled) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
className={
|
||||
"flex justify-between gap-10 px-6 border-t border-nb-gray-920 first:border-t-0 py-5 hover:bg-nb-gray-935 cursor-pointer transition-colors"
|
||||
}
|
||||
className={cn(
|
||||
"flex justify-between gap-10 px-6 border-t border-nb-gray-920 first:border-t-0 py-5 transition-colors",
|
||||
disabled
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: "hover:bg-nb-gray-935 cursor-pointer",
|
||||
)}
|
||||
>
|
||||
<div className={"max-w-sm"}>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -56,7 +67,8 @@ function SettingCardItem({
|
||||
variant={"secondaryLighter"}
|
||||
size={"xs"}
|
||||
className={"pl-3 pr-3"}
|
||||
onClick={onClick}
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SquarePen size={12} />
|
||||
Edit
|
||||
@@ -66,7 +78,8 @@ function SettingCardItem({
|
||||
variant={"secondaryLighter"}
|
||||
size={"xs"}
|
||||
className={"pl-3 pr-3"}
|
||||
onClick={onClick}
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
<PlusCircle size={12} />
|
||||
Add
|
||||
|
||||
@@ -38,11 +38,31 @@ export default function SidebarItem({
|
||||
}: Readonly<SidebarItemProps>) {
|
||||
const path = usePathname();
|
||||
|
||||
// Hrefs of nested child items, so a collapsible parent without its
|
||||
// own href (e.g. "Network Routing") can still tell when one of its
|
||||
// children matches the current route.
|
||||
const childRoutes = useMemo(() => {
|
||||
const routes: { href: string; exact: boolean }[] = [];
|
||||
React.Children.forEach(children, (child) => {
|
||||
if (!React.isValidElement(child)) return;
|
||||
const props = child.props as Partial<SidebarItemProps>;
|
||||
if (props.href) {
|
||||
routes.push({ href: props.href, exact: !!props.exactPathMatch });
|
||||
}
|
||||
});
|
||||
return routes;
|
||||
}, [children]);
|
||||
|
||||
// Check if any child route is active (for collapsible items)
|
||||
const hasActiveChild = useMemo(() => {
|
||||
if (!collapsible || !href) return false;
|
||||
return path === href || path.startsWith(href + "/");
|
||||
}, [collapsible, href, path]);
|
||||
if (!collapsible) return false;
|
||||
if (href && (path === href || path.startsWith(href + "/"))) return true;
|
||||
return childRoutes.some(({ href: childHref, exact }) =>
|
||||
exact
|
||||
? path === childHref
|
||||
: path === childHref || path.startsWith(childHref + "/"),
|
||||
);
|
||||
}, [collapsible, href, path, childRoutes]);
|
||||
|
||||
const [open, setOpen] = React.useState(hasActiveChild);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
TableWrapper,
|
||||
} from "@components/table/Table";
|
||||
import NoResults from "@components/ui/NoResults";
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { RankingInfo } from "@tanstack/match-sorter-utils";
|
||||
import {
|
||||
ColumnDef,
|
||||
@@ -53,6 +54,7 @@ declare module "@tanstack/table-core" {
|
||||
}
|
||||
interface SortingFns {
|
||||
checkbox: SortingFn<unknown>;
|
||||
datetime: SortingFn<unknown>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +101,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);
|
||||
@@ -183,12 +194,12 @@ export function DataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
children,
|
||||
searchPlaceholder = "Search...",
|
||||
searchPlaceholder,
|
||||
columnVisibility = {},
|
||||
setColumnVisibility,
|
||||
sorting = [],
|
||||
setSorting,
|
||||
text = "rows",
|
||||
text,
|
||||
onRowClick,
|
||||
getStartedCard,
|
||||
renderExpandedRow,
|
||||
@@ -239,9 +250,13 @@ export function DataTable<TData, TValue>({
|
||||
initialSearch,
|
||||
onSearchClick,
|
||||
}: Readonly<DataTableProps<TData, TValue>>) {
|
||||
const t = useTranslations('table');
|
||||
const path = usePathname();
|
||||
const isInitialRender = useRef(true);
|
||||
|
||||
const resolvedSearchPlaceholder = searchPlaceholder || t('search');
|
||||
const resolvedText = text || t('rows');
|
||||
|
||||
const [showOverlay, setShowOverlay] = useState(false);
|
||||
const overlayTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
useEffect(() => {
|
||||
@@ -296,6 +311,7 @@ export function DataTable<TData, TValue>({
|
||||
autoResetAll: false,
|
||||
autoResetExpanded: false,
|
||||
manualPagination: manualPagination,
|
||||
manualSorting: serverSidePagination,
|
||||
manualFiltering: manualFiltering || manualColumnFiltering,
|
||||
pageCount: pageCount,
|
||||
state: {
|
||||
@@ -323,6 +339,7 @@ export function DataTable<TData, TValue>({
|
||||
},
|
||||
sortingFns: {
|
||||
checkbox: checkboxSort,
|
||||
datetime: datetimeSort,
|
||||
},
|
||||
getRowId: useRowId ? (row) => row.id : undefined,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
@@ -449,7 +466,7 @@ export function DataTable<TData, TValue>({
|
||||
}
|
||||
resetRowSelectionOnSearch && setRowSelection?.({});
|
||||
}}
|
||||
placeholder={searchPlaceholder}
|
||||
placeholder={resolvedSearchPlaceholder}
|
||||
/>
|
||||
{children?.(table)}
|
||||
{showResetFilterButton && (
|
||||
@@ -622,7 +639,7 @@ export function DataTable<TData, TValue>({
|
||||
<div className={paginationClassName}>
|
||||
<DataTablePagination
|
||||
table={table}
|
||||
text={text}
|
||||
text={resolvedText}
|
||||
paginationPadding={paginationPaddingClassName}
|
||||
totalRecords={totalRecords}
|
||||
/>
|
||||
|
||||
@@ -31,8 +31,12 @@ export default function DataTableGlobalSearch({
|
||||
}, [debouncedValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (globalSearch !== undefined && globalSearch !== inputValue) {
|
||||
setInputValue(globalSearch);
|
||||
// Coalesce undefined → "" so a reset (which clears the table's
|
||||
// global filter to undefined) also clears the visible input text,
|
||||
// not just the results.
|
||||
const next = globalSearch ?? "";
|
||||
if (next !== inputValue) {
|
||||
setInputValue(next);
|
||||
}
|
||||
}, [globalSearch]);
|
||||
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -4,6 +4,7 @@ import { IconX } from "@tabler/icons-react";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { MonitorSmartphoneIcon } from "lucide-react";
|
||||
import { useTranslations } from 'next-intl';
|
||||
import * as React from "react";
|
||||
|
||||
type Props<T> = {
|
||||
@@ -15,11 +16,14 @@ type Props<T> = {
|
||||
|
||||
export function DataTableMultiSelectPopup<T>({
|
||||
onCanceled,
|
||||
label = "Peer(s) selected",
|
||||
label,
|
||||
selectedItems,
|
||||
rightSide,
|
||||
}: Props<T>) {
|
||||
const t = useTranslations('table');
|
||||
const count = selectedItems?.length || 0;
|
||||
const defaultLabel = label || t('selected', { count });
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{count > 0 && (
|
||||
@@ -59,13 +63,13 @@ export function DataTableMultiSelectPopup<T>({
|
||||
<span className={"font-medium text-white"}>
|
||||
{count}
|
||||
</span>{" "}
|
||||
{label}
|
||||
{defaultLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className={"flex gap-2 items-center"}>
|
||||
{rightSide}
|
||||
<FullTooltip
|
||||
content={<span className={"text-xs"}>Cancel</span>}
|
||||
content={<span className={"text-xs"}>{t('cancel')}</span>}
|
||||
>
|
||||
<Button
|
||||
onClick={onCanceled}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
interface DataTablePaginationProps<TData> {
|
||||
table: Table<TData>;
|
||||
@@ -21,6 +22,7 @@ export function DataTablePagination<TData>({
|
||||
paginationPadding = "px-8 py-8",
|
||||
totalRecords,
|
||||
}: DataTablePaginationProps<TData>) {
|
||||
const t = useTranslations('table');
|
||||
const rowsPerPage = table.getState().pagination.pageSize;
|
||||
const currentPage = table.getState().pagination.pageIndex + 1;
|
||||
const pageCount = table.getPageCount();
|
||||
@@ -39,11 +41,11 @@ export function DataTablePagination<TData>({
|
||||
className={cn("flex items-center justify-between", paginationPadding)}
|
||||
>
|
||||
<div className="text-nb-gray-400">
|
||||
Showing{" "}
|
||||
{t('showing')}{" "}
|
||||
<span className={"font-medium text-white"}>
|
||||
{showingFrom} to {showingTo}
|
||||
{showingFrom} {t('to')} {showingTo}
|
||||
</span>{" "}
|
||||
of <span className={"font-medium text-white"}>{totalRows}</span>{" "}
|
||||
{t('of')} <span className={"font-medium text-white"}>{totalRows}</span>{" "}
|
||||
{text}
|
||||
</div>
|
||||
{pageCount > 1 && (
|
||||
|
||||
@@ -2,6 +2,7 @@ import Button from "@components/Button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@components/Tooltip";
|
||||
import { Table } from "@tanstack/react-table";
|
||||
import { FilterX } from "lucide-react";
|
||||
import { useTranslations } from 'next-intl';
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
@@ -16,6 +17,7 @@ export default function DataTableResetFilterButton<TData>({
|
||||
onClick,
|
||||
hasServerSideFilters = undefined,
|
||||
}: Props<TData>) {
|
||||
const t = useTranslations('table');
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
const hasClientSideFilters =
|
||||
@@ -52,7 +54,7 @@ export default function DataTableResetFilterButton<TData>({
|
||||
}}
|
||||
>
|
||||
<span className={"text-xs text-neutral-300"}>
|
||||
Reset Filters & Search
|
||||
{t('resetFilters')}
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Table } from "@tanstack/react-table";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { Command, CommandGroup, CommandItem } from "cmdk";
|
||||
import { Check, ChevronDown, RowsIcon } from "lucide-react";
|
||||
import { useTranslations } from 'next-intl';
|
||||
import * as React from "react";
|
||||
|
||||
interface DataTablePaginationProps<TData> {
|
||||
@@ -17,6 +18,7 @@ export function DataTableRowsPerPage<TData>({
|
||||
table,
|
||||
disabled,
|
||||
}: DataTablePaginationProps<TData>) {
|
||||
const t = useTranslations('table');
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
@@ -36,7 +38,7 @@ export function DataTableRowsPerPage<TData>({
|
||||
<span className={"text-white"}>
|
||||
{table.getState().pagination.pageSize}
|
||||
</span>
|
||||
<span className={"text-nb-gray-300"}> rows per page</span>
|
||||
<span className={"text-nb-gray-300"}> {t('rowsPerPage')}</span>
|
||||
</div>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</Button>
|
||||
|
||||
269
src/components/table/TableFilters.tsx
Normal file
269
src/components/table/TableFilters.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
"use client";
|
||||
|
||||
import Button from "@components/Button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@components/Popover";
|
||||
import { Table } from "@tanstack/react-table";
|
||||
import { cn } from "@utils/helpers";
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ChevronsUpDown,
|
||||
FilterIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
// A TableFilterDef wires one TanStack column to the consolidated filter UI.
|
||||
// Each filter renders its own picker — the framework just provides the
|
||||
// popover container and chip row.
|
||||
export type TableFilterDef<V = unknown> = {
|
||||
id: string; // tan-stack column id
|
||||
label: string;
|
||||
renderPicker: (props: {
|
||||
value: V | undefined;
|
||||
onChange: (next: V | undefined) => void;
|
||||
close: () => void;
|
||||
}) => React.ReactNode;
|
||||
// Returns the chip body. Null means no chip (filter inactive).
|
||||
formatChip: (value: V | undefined) => string | null;
|
||||
};
|
||||
|
||||
type ButtonProps<TData> = {
|
||||
table: Table<TData>;
|
||||
filters: TableFilterDef[];
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function TableFiltersButton<TData>({
|
||||
table,
|
||||
filters,
|
||||
disabled,
|
||||
}: ButtonProps<TData>) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeFilterId, setActiveFilterId] = useState<string | null>(null);
|
||||
|
||||
const activeCount = filters.reduce((n, f) => {
|
||||
const v = table.getColumn(f.id)?.getFilterValue();
|
||||
return f.formatChip(v as never) !== null ? n + 1 : n;
|
||||
}, 0);
|
||||
|
||||
const activeFilter = filters.find((f) => f.id === activeFilterId);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setActiveFilterId(null);
|
||||
setOpen(o);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant={"secondary"} disabled={disabled}>
|
||||
<FilterIcon size={16} className={"shrink-0"} />
|
||||
<span className={"flex items-center gap-1.5"}>
|
||||
Filters
|
||||
{activeCount > 0 && (
|
||||
<span
|
||||
className={
|
||||
"inline-flex items-center justify-center min-w-[18px] h-[18px] rounded-full bg-netbird text-white text-[10px] font-semibold !leading-[0] px-1.5"
|
||||
}
|
||||
>
|
||||
{activeCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<ChevronsUpDown size={16} className={"shrink-0"} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className={"w-[280px] p-0 shadow-sm shadow-nb-gray-950"}
|
||||
align={"start"}
|
||||
sideOffset={7}
|
||||
>
|
||||
{activeFilter ? (
|
||||
<div className={"flex flex-col"}>
|
||||
<div
|
||||
className={
|
||||
"flex items-center gap-2 px-3 py-2 border-b border-nb-gray-900"
|
||||
}
|
||||
>
|
||||
<button
|
||||
aria-label={"Back"}
|
||||
className={
|
||||
"flex items-center justify-center w-7 h-7 -ml-1 shrink-0 text-nb-gray-400 hover:text-white hover:bg-nb-gray-900 rounded transition-colors"
|
||||
}
|
||||
onClick={() => setActiveFilterId(null)}
|
||||
>
|
||||
<ChevronLeftIcon size={16} />
|
||||
</button>
|
||||
<span className={"text-sm font-medium text-nb-gray-100"}>
|
||||
{activeFilter.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className={"p-2"}>
|
||||
{activeFilter.renderPicker({
|
||||
value: table.getColumn(activeFilter.id)?.getFilterValue() as never,
|
||||
onChange: (next) => {
|
||||
table.setPageIndex(0);
|
||||
table.getColumn(activeFilter.id)?.setFilterValue(next);
|
||||
},
|
||||
close: () => {
|
||||
setOpen(false);
|
||||
setActiveFilterId(null);
|
||||
},
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={"p-2 flex flex-col gap-0.5"}>
|
||||
{filters.map((f) => {
|
||||
const v = table.getColumn(f.id)?.getFilterValue();
|
||||
const chip = f.formatChip(v as never);
|
||||
return (
|
||||
<button
|
||||
key={f.id}
|
||||
className={
|
||||
"w-full text-left px-2 py-1.5 rounded hover:bg-nb-gray-900 transition-colors text-sm flex items-center gap-2.5 text-nb-gray-200"
|
||||
}
|
||||
onClick={() => setActiveFilterId(f.id)}
|
||||
>
|
||||
<span className={"flex-1"}>{f.label}</span>
|
||||
{chip && (
|
||||
<span
|
||||
className={
|
||||
"text-xs text-nb-gray-400 truncate max-w-[110px]"
|
||||
}
|
||||
>
|
||||
{chip}
|
||||
</span>
|
||||
)}
|
||||
<ChevronRightIcon
|
||||
size={14}
|
||||
className={"shrink-0 text-nb-gray-400"}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
type ChipsProps<TData> = {
|
||||
table: Table<TData>;
|
||||
filters: TableFilterDef[];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function TableFilterChips<TData>({
|
||||
table,
|
||||
filters,
|
||||
className,
|
||||
}: ChipsProps<TData>) {
|
||||
const active = filters
|
||||
.map((f) => {
|
||||
const value = table.getColumn(f.id)?.getFilterValue();
|
||||
const text = f.formatChip(value as never);
|
||||
if (!text) return null;
|
||||
return { def: f, text };
|
||||
})
|
||||
.filter((c): c is { def: TableFilterDef; text: string } => !!c);
|
||||
|
||||
if (active.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-2 p-default pt-6",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{active.map(({ def, text }) => (
|
||||
<FilterChip key={def.id} def={def} text={text} table={table} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type FilterChipProps<TData> = {
|
||||
def: TableFilterDef;
|
||||
text: string;
|
||||
table: Table<TData>;
|
||||
};
|
||||
|
||||
function FilterChip<TData>({ def, text, table }: FilterChipProps<TData>) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-stretch h-8 rounded-md border border-nb-gray-900",
|
||||
"bg-nb-gray-930/40 text-sm text-nb-gray-200 overflow-hidden",
|
||||
"hover:border-nb-gray-700 transition-colors",
|
||||
)}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3",
|
||||
"hover:bg-nb-gray-900 transition-colors",
|
||||
)}
|
||||
>
|
||||
<span className={"text-nb-gray-400"}>{def.label}:</span>
|
||||
<span className={"font-medium"}>{text}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<button
|
||||
aria-label={`Remove ${def.label} filter`}
|
||||
className={cn(
|
||||
"flex items-center justify-center px-2",
|
||||
"border-l border-nb-gray-900",
|
||||
"text-nb-gray-400 hover:bg-nb-gray-900 hover:text-white transition-colors",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
table.setPageIndex(0);
|
||||
table.getColumn(def.id)?.setFilterValue(undefined);
|
||||
}}
|
||||
>
|
||||
<XIcon size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<PopoverContent
|
||||
className={"w-[280px] p-0 shadow-sm shadow-nb-gray-950"}
|
||||
align={"start"}
|
||||
sideOffset={6}
|
||||
>
|
||||
<div className={"flex flex-col"}>
|
||||
<div
|
||||
className={
|
||||
"flex items-center gap-2 px-3 py-2 border-b border-nb-gray-900"
|
||||
}
|
||||
>
|
||||
<span className={"text-sm font-medium text-nb-gray-100"}>
|
||||
{def.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className={"p-2"}>
|
||||
{def.renderPicker({
|
||||
value: table.getColumn(def.id)?.getFilterValue() as never,
|
||||
onChange: (next) => {
|
||||
table.setPageIndex(0);
|
||||
table.getColumn(def.id)?.setFilterValue(next);
|
||||
},
|
||||
close: () => setOpen(false),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
83
src/components/table/filters/CheckboxListPicker.tsx
Normal file
83
src/components/table/filters/CheckboxListPicker.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { Checkbox } from "@components/Checkbox";
|
||||
import { cn } from "@utils/helpers";
|
||||
import * as React from "react";
|
||||
|
||||
// CheckboxListPicker — multi-select with checkboxes. Designed for small
|
||||
// finite option sets (no search). For long, dynamic lists with search,
|
||||
// use GroupsPicker or UsersPicker as the model.
|
||||
export type CheckboxOption<V> = {
|
||||
value: V;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type Props<V extends string | number> = {
|
||||
value: V[] | undefined;
|
||||
onChange: (next: V[] | undefined) => void;
|
||||
close: () => void;
|
||||
options: CheckboxOption<V>[];
|
||||
};
|
||||
|
||||
export function CheckboxListPicker<V extends string | number>({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: Props<V>) {
|
||||
const selected = value ?? [];
|
||||
|
||||
const toggle = (v: V) => {
|
||||
const isSelected = selected.includes(v);
|
||||
const next = isSelected
|
||||
? selected.filter((s) => s !== v)
|
||||
: [...selected, v];
|
||||
onChange(next.length ? next : undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={"flex flex-col gap-0.5"}>
|
||||
{options.map((option) => {
|
||||
const isSelected = selected.includes(option.value);
|
||||
return (
|
||||
<div
|
||||
key={String(option.value)}
|
||||
role={"button"}
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 px-2 py-1.5 rounded text-sm transition-colors cursor-pointer",
|
||||
"text-nb-gray-300 hover:bg-nb-gray-900 hover:text-white",
|
||||
isSelected && "text-white",
|
||||
)}
|
||||
onClick={() => toggle(option.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
toggle(option.value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox checked={isSelected} tabIndex={-1} />
|
||||
{option.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to produce a chip body for multi-select filters with bounded
|
||||
// option sets. Returns:
|
||||
// null when no selections
|
||||
// the option's label when exactly one is selected
|
||||
// `N {plural}` when multiple are selected (e.g. "3 OSes")
|
||||
export function formatCheckboxChip<V>(
|
||||
value: V[] | undefined,
|
||||
options: CheckboxOption<V>[],
|
||||
plural: string,
|
||||
): string | null {
|
||||
if (!value || value.length === 0) return null;
|
||||
if (value.length === 1) {
|
||||
return options.find((o) => o.value === value[0])?.label ?? String(value[0]);
|
||||
}
|
||||
return `${value.length} ${plural}`;
|
||||
}
|
||||
132
src/components/table/filters/GroupsPicker.tsx
Normal file
132
src/components/table/filters/GroupsPicker.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import { Checkbox } from "@components/Checkbox";
|
||||
import { CommandItem } from "@components/Command";
|
||||
import { ScrollArea } from "@components/ScrollArea";
|
||||
import { GroupBadgeIcon } from "@components/ui/GroupBadgeIcon";
|
||||
import TextWithTooltip from "@components/ui/TextWithTooltip";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { Command, CommandGroup, CommandInput, CommandList } from "cmdk";
|
||||
import { orderBy, trim } from "lodash";
|
||||
import { MonitorSmartphoneIcon, SearchIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useRef } from "react";
|
||||
import { Group } from "@/interfaces/Group";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
// GroupsPicker — multi-select search list of group names. The value
|
||||
// stored on the column filter is an array of group *names* (matching
|
||||
// PeersTable's group_names column which uses arrIncludesSome).
|
||||
type Props = {
|
||||
value: string[] | undefined;
|
||||
onChange: (next: string[] | undefined) => void;
|
||||
close: () => void; // unused (multi-select stays open while picking)
|
||||
groups: Group[] | undefined;
|
||||
};
|
||||
|
||||
export function GroupsPicker({ value, onChange, groups }: Props) {
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const selected = value ?? [];
|
||||
|
||||
const toggle = (name: string) => {
|
||||
const isSelected = selected.includes(name);
|
||||
const next = isSelected
|
||||
? selected.filter((n) => n !== name)
|
||||
: [...selected, name];
|
||||
onChange(next.length ? next : undefined);
|
||||
};
|
||||
const t = useTranslations("groups");
|
||||
|
||||
return (
|
||||
<Command
|
||||
className={"w-full flex"}
|
||||
loop
|
||||
filter={(value, search) => {
|
||||
const formatValue = trim(value.toLowerCase());
|
||||
const formatSearch = trim(search.toLowerCase());
|
||||
return formatValue.includes(formatSearch) ? 1 : 0;
|
||||
}}
|
||||
>
|
||||
<CommandList className={"w-full"}>
|
||||
<div className={"relative"}>
|
||||
<CommandInput
|
||||
className={cn(
|
||||
"min-h-[38px] w-full relative bg-transparent text-sm",
|
||||
"border-b border-nb-gray-900 outline-none",
|
||||
"dark:placeholder:text-nb-gray-400 font-light placeholder:text-neutral-500 pl-9",
|
||||
)}
|
||||
ref={searchRef}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
/>
|
||||
<div
|
||||
className={
|
||||
"absolute left-0 top-0 h-full flex items-center pl-3 text-nb-gray-400"
|
||||
}
|
||||
>
|
||||
<SearchIcon size={13} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea
|
||||
className={
|
||||
"max-h-[300px] overflow-y-auto flex flex-col gap-1 p-1.5"
|
||||
}
|
||||
>
|
||||
<CommandGroup>
|
||||
<div className={"grid grid-cols-1 gap-0.5"}>
|
||||
{orderBy(groups, ["peers_count"], ["desc"])?.map((group) => {
|
||||
const name = group?.name;
|
||||
if (!name) return null;
|
||||
const isSelected = selected.includes(name);
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={group.id || name}
|
||||
value={name}
|
||||
className={"p-1"}
|
||||
onSelect={() => {
|
||||
toggle(name);
|
||||
searchRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"text-nb-gray-300 font-medium flex items-center gap-2.5 py-1 px-1 w-full"
|
||||
}
|
||||
>
|
||||
<Checkbox checked={isSelected} />
|
||||
<div
|
||||
className={
|
||||
"flex items-center gap-1.5 whitespace-nowrap text-sm font-normal min-w-0"
|
||||
}
|
||||
>
|
||||
<GroupBadgeIcon id={group?.id} issued={group?.issued} />
|
||||
<TextWithTooltip text={name} maxChars={22} />
|
||||
</div>
|
||||
{group?.peers_count !== undefined && (
|
||||
<span
|
||||
className={
|
||||
"ml-auto text-xs text-nb-gray-400 flex items-center gap-1"
|
||||
}
|
||||
>
|
||||
<MonitorSmartphoneIcon size={11} />
|
||||
{group.peers_count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CommandGroup>
|
||||
</ScrollArea>
|
||||
</CommandList>
|
||||
</Command>
|
||||
);
|
||||
}
|
||||
|
||||
export function formatGroupsChip(value: string[] | undefined): string | null {
|
||||
if (!value || value.length === 0) return null;
|
||||
if (value.length === 1) return value[0];
|
||||
return `${value.length} groups`;
|
||||
}
|
||||
78
src/components/table/filters/RadioPicker.tsx
Normal file
78
src/components/table/filters/RadioPicker.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@utils/helpers";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
// RadioPicker — single-value radio list. Generic over the value type so
|
||||
// it works for any boolean / string / numeric column filter that maps
|
||||
// to a small finite set of options.
|
||||
export type RadioOption<V> = {
|
||||
value: V;
|
||||
label: string;
|
||||
dotClass?: string;
|
||||
};
|
||||
|
||||
type Props<V> = {
|
||||
value: V | undefined;
|
||||
onChange: (next: V | undefined) => void;
|
||||
close: () => void;
|
||||
options: RadioOption<V | undefined>[];
|
||||
};
|
||||
|
||||
export function RadioPicker<V>({
|
||||
value,
|
||||
onChange,
|
||||
close,
|
||||
options,
|
||||
}: Props<V>) {
|
||||
return (
|
||||
<div className={"flex flex-col gap-0.5"}>
|
||||
{options.map((option) => {
|
||||
const selected = value === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.label}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 px-2 py-1.5 rounded text-sm transition-colors",
|
||||
"text-nb-gray-300 hover:bg-nb-gray-900 hover:text-white",
|
||||
selected && "text-white",
|
||||
)}
|
||||
onClick={() => {
|
||||
onChange(option.value);
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
size={14}
|
||||
className={cn(
|
||||
"shrink-0 text-white",
|
||||
selected ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{option.dotClass && (
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full shrink-0",
|
||||
option.dotClass,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Build the chip text given the selected value and options.
|
||||
export function formatRadioChip<V>(
|
||||
value: V | undefined,
|
||||
options: RadioOption<V | undefined>[],
|
||||
): string | null {
|
||||
// Treat undefined-valued option (i.e. "All") as no chip.
|
||||
if (value === undefined) return null;
|
||||
const opt = options.find((o) => o.value === value);
|
||||
return opt?.label ?? null;
|
||||
}
|
||||
73
src/components/table/filters/StatusPicker.tsx
Normal file
73
src/components/table/filters/StatusPicker.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@utils/helpers";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
// StatusPicker — three-way radio that maps to the `connected` column.
|
||||
// undefined → All
|
||||
// true → Online
|
||||
// false → Offline
|
||||
type Option = {
|
||||
value: boolean | undefined;
|
||||
label: string;
|
||||
dotClass: string;
|
||||
};
|
||||
|
||||
const OPTIONS: Option[] = [
|
||||
{ value: undefined, label: "All", dotClass: "bg-nb-gray-500" },
|
||||
{ value: true, label: "Online", dotClass: "bg-green-500" },
|
||||
{ value: false, label: "Offline", dotClass: "bg-nb-gray-700" },
|
||||
];
|
||||
|
||||
type Props = {
|
||||
value: boolean | undefined;
|
||||
onChange: (next: boolean | undefined) => void;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
export function StatusPicker({ value, onChange, close }: Props) {
|
||||
return (
|
||||
<div className={"flex flex-col gap-0.5"}>
|
||||
{OPTIONS.map((option) => {
|
||||
const selected = value === option.value;
|
||||
return (
|
||||
<button
|
||||
key={option.label}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 px-2 py-1.5 rounded text-sm transition-colors",
|
||||
"text-nb-gray-300 hover:bg-nb-gray-900 hover:text-white",
|
||||
selected && "text-white",
|
||||
)}
|
||||
onClick={() => {
|
||||
onChange(option.value);
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
size={14}
|
||||
className={cn(
|
||||
"shrink-0 text-white",
|
||||
selected ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full shrink-0",
|
||||
option.dotClass,
|
||||
)}
|
||||
/>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// formatChip helper exported for use in the filter def's formatChip().
|
||||
export function formatStatusChip(value: boolean | undefined): string | null {
|
||||
if (value === true) return "Online";
|
||||
if (value === false) return "Offline";
|
||||
return null;
|
||||
}
|
||||
54
src/components/table/filters/TextInputPicker.tsx
Normal file
54
src/components/table/filters/TextInputPicker.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { Input } from "@components/Input";
|
||||
import * as React from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
// TextInputPicker — single-value free-text filter. Use for columns
|
||||
// where the user types a token (e.g. a port number) and the table
|
||||
// matches via the column's filterFn (typically `includesString`).
|
||||
type Props = {
|
||||
value: string | undefined;
|
||||
onChange: (next: string | undefined) => void;
|
||||
close: () => void;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
export function TextInputPicker({ value, onChange, placeholder }: Props) {
|
||||
// Mirror the current filter value in a local input state so the
|
||||
// input stays controlled while the user types. Apply downstream
|
||||
// immediately so the table updates as they type.
|
||||
const [local, setLocal] = useState(value ?? "");
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
ref.current?.focus();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setLocal(value ?? "");
|
||||
}, [value]);
|
||||
|
||||
const apply = (next: string) => {
|
||||
setLocal(next);
|
||||
onChange(next.trim() === "" ? undefined : next.trim());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={"p-1"}>
|
||||
<Input
|
||||
ref={ref}
|
||||
value={local}
|
||||
onChange={(e) => apply(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
maxWidthClass={"w-full"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// formatChip returns the typed value if any, otherwise null.
|
||||
export function formatTextChip(value: string | undefined): string | null {
|
||||
if (!value || value.trim() === "") return null;
|
||||
return value;
|
||||
}
|
||||
165
src/components/table/filters/UsersPicker.tsx
Normal file
165
src/components/table/filters/UsersPicker.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
|
||||
import { DropdownInput } from "@components/DropdownInput";
|
||||
import { DropdownInfoText } from "@components/DropdownInfoText";
|
||||
import { VirtualScrollAreaList } from "@components/VirtualScrollAreaList";
|
||||
import TextWithTooltip from "@components/ui/TextWithTooltip";
|
||||
import { useSearch } from "@hooks/useSearch";
|
||||
import { sortBy, uniqBy } from "lodash";
|
||||
import { UserCircle2 } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { SmallUserAvatar } from "@/modules/users/SmallUserAvatar";
|
||||
|
||||
// UsersPicker — single-select search list mirroring the Activity ›
|
||||
// Audit Logs user filter. The value stored on the column filter is the
|
||||
// user's email (matches PeersTable's user_email accessor).
|
||||
export type UserOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
external?: boolean;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
value: string | undefined;
|
||||
onChange: (next: string | undefined) => void;
|
||||
close: () => void;
|
||||
options: UserOption[];
|
||||
};
|
||||
|
||||
const ALL_USERS_ID = "all-users";
|
||||
|
||||
const searchPredicate = (item: UserOption, query: string) => {
|
||||
const q = query.toLowerCase();
|
||||
if (item.email === "NetBird" && "NetBird System".toLowerCase().includes(q))
|
||||
return true;
|
||||
if (item.name?.toLowerCase().includes(q)) return true;
|
||||
if (item.email?.toLowerCase().includes(q)) return true;
|
||||
return item.id.toLowerCase().startsWith(q);
|
||||
};
|
||||
|
||||
export function UsersPicker({ value, onChange, close, options }: Props) {
|
||||
const [filteredItems, search, setSearch] = useSearch(
|
||||
options.concat({
|
||||
id: ALL_USERS_ID,
|
||||
name: "All Users",
|
||||
email: "Include all users",
|
||||
}),
|
||||
searchPredicate,
|
||||
{ filter: true, debounce: 150 },
|
||||
);
|
||||
|
||||
const sortedOptions = useMemo(() => {
|
||||
const sorted = sortBy(
|
||||
uniqBy(filteredItems, (o) => o.email),
|
||||
["external", "name"],
|
||||
);
|
||||
const allUsersIndex = sorted.findIndex((o) => o.id === ALL_USERS_ID);
|
||||
if (allUsersIndex > -1) {
|
||||
const allUsers = sorted.splice(allUsersIndex, 1)[0];
|
||||
sorted.unshift(allUsers);
|
||||
}
|
||||
return sorted;
|
||||
}, [filteredItems]);
|
||||
|
||||
return (
|
||||
<div className={"w-full"}>
|
||||
<DropdownInput
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder={"Search user..."}
|
||||
hideEnterIcon={true}
|
||||
/>
|
||||
|
||||
{options.length === 0 && !search && (
|
||||
<div className={"max-w-xs mx-auto"}>
|
||||
<DropdownInfoText>
|
||||
{"No users available to select."}
|
||||
</DropdownInfoText>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredItems.length === 0 && search !== "" && (
|
||||
<div className={"px-10"}>
|
||||
<DropdownInfoText>
|
||||
There are no users matching your search.
|
||||
</DropdownInfoText>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sortedOptions.length > 0 && (
|
||||
<VirtualScrollAreaList
|
||||
items={sortedOptions}
|
||||
estimatedItemHeight={48}
|
||||
maxHeight={300}
|
||||
scrollAreaClassName={"pt-0"}
|
||||
onSelect={(item) => {
|
||||
if (item.id === ALL_USERS_ID) {
|
||||
onChange(undefined);
|
||||
} else {
|
||||
onChange(item.email === value ? undefined : item.email);
|
||||
}
|
||||
close();
|
||||
}}
|
||||
renderItem={(user) => {
|
||||
const isSystemUser = user.email === "NetBird";
|
||||
const isSelected =
|
||||
value === user.email ||
|
||||
(user.id === ALL_USERS_ID && !value);
|
||||
return (
|
||||
<div
|
||||
className={"flex items-center gap-2 w-full"}
|
||||
data-selected={isSelected || undefined}
|
||||
>
|
||||
{user.id === ALL_USERS_ID ? (
|
||||
<div
|
||||
className={
|
||||
"w-7 h-7 shrink-0 rounded-full flex items-center justify-center uppercase text-[9px] font-medium bg-sky-400 text-white"
|
||||
}
|
||||
>
|
||||
<UserCircle2 size={16} />
|
||||
</div>
|
||||
) : (
|
||||
<SmallUserAvatar
|
||||
name={user?.name}
|
||||
email={user?.email}
|
||||
id={user?.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={"flex flex-col text-xs w-full min-w-0"}>
|
||||
<span className={"text-nb-gray-200 flex items-center gap-1.5"}>
|
||||
<TextWithTooltip
|
||||
text={isSystemUser ? "System" : user?.name || user?.id}
|
||||
maxChars={22}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className={
|
||||
"text-nb-gray-400 font-light flex items-center gap-1"
|
||||
}
|
||||
>
|
||||
<TextWithTooltip
|
||||
text={user?.email || "NetBird"}
|
||||
maxChars={22}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function formatUsersChip(
|
||||
value: string | undefined,
|
||||
options: UserOption[],
|
||||
): string | null {
|
||||
if (!value) return null;
|
||||
const user = options.find((u) => u.email === value);
|
||||
return user?.name || user?.email || value;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ModalTrigger,
|
||||
} from "@components/modal/Modal";
|
||||
import { ExternalLinkIcon, FolderGit2Icon, PlusCircle } from "lucide-react";
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
@@ -23,6 +24,7 @@ import Paragraph from "../Paragraph";
|
||||
import Separator from "../Separator";
|
||||
|
||||
export const AddGroupButton = () => {
|
||||
const t = useTranslations('groups');
|
||||
const create = useApiCall<Group>("/groups", true).post;
|
||||
const { mutate } = useSWRConfig();
|
||||
const [name, setName] = useState<string>("");
|
||||
@@ -32,9 +34,9 @@ export const AddGroupButton = () => {
|
||||
|
||||
const createGroup = () => {
|
||||
notify({
|
||||
title: "Create Group",
|
||||
description: `Group '${name}' successfully created`,
|
||||
loadingMessage: "Creating group...",
|
||||
title: t('create'),
|
||||
description: t('createSuccess', { name }),
|
||||
loadingMessage: t('creating'),
|
||||
promise: create({ name }).then((g) => {
|
||||
setOpen(false);
|
||||
setName("");
|
||||
@@ -54,26 +56,26 @@ export const AddGroupButton = () => {
|
||||
className={"ml-auto h-[42px]"}
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
Create Group
|
||||
{t('create')}
|
||||
</Button>
|
||||
</ModalTrigger>
|
||||
<ModalContent maxWidthClass={"max-w-xl"}>
|
||||
<ModalHeader
|
||||
icon={<FolderGit2Icon size={18} />}
|
||||
title="Create Group"
|
||||
description="Create a group to manage and organize access in your network"
|
||||
title={t('create')}
|
||||
description={t('createDescription')}
|
||||
color="netbird"
|
||||
/>
|
||||
<Separator />
|
||||
<div className={"px-8 flex-col flex gap-6 py-6"}>
|
||||
<div>
|
||||
<Label>Name</Label>
|
||||
<Label>{t('name')}</Label>
|
||||
<HelpText>
|
||||
Set an easily identifiable name for your group
|
||||
{t('nameHelp')}
|
||||
</HelpText>
|
||||
<Input
|
||||
tabIndex={0}
|
||||
placeholder={"e.g., Developers"}
|
||||
placeholder={t('namePlaceholder')}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
@@ -82,19 +84,19 @@ export const AddGroupButton = () => {
|
||||
<ModalFooter className={"items-center"}>
|
||||
<div className={"w-full"}>
|
||||
<Paragraph className={"text-sm mt-auto"}>
|
||||
Learn more about
|
||||
{t('learnMore')}
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/manage-network-access"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Groups
|
||||
{t('title')}
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className={"flex gap-3 w-full justify-end"}>
|
||||
<ModalClose asChild={true}>
|
||||
<Button variant={"secondary"}>Cancel</Button>
|
||||
<Button variant={"secondary"}>{t('cancel')}</Button>
|
||||
</ModalClose>
|
||||
|
||||
<Button
|
||||
@@ -104,7 +106,7 @@ export const AddGroupButton = () => {
|
||||
onClick={createGroup}
|
||||
>
|
||||
<PlusCircle size={16} />
|
||||
Create Group
|
||||
{t('create')}
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
|
||||
@@ -3,51 +3,57 @@ import Button from "@components/Button";
|
||||
import { Modal, ModalTrigger } from "@components/modal/Modal";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { PlusCircle } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import React, { memo, useState } from "react";
|
||||
import { useLocalStorage } from "@/hooks/useLocalStorage";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import SetupModal from "@/modules/setup-netbird-modal/SetupModal";
|
||||
|
||||
function AddPeerButton() {
|
||||
const { data: peers } = useFetchApi<Peer[]>("/peers");
|
||||
const { oidcUser: user } = useOidcUser();
|
||||
type Props = {
|
||||
isUserDevice?: boolean;
|
||||
};
|
||||
|
||||
const [hasOnboardingFormCompleted] = useLocalStorage(
|
||||
"netbird-onboarding-modal",
|
||||
false,
|
||||
);
|
||||
function AddPeerButton({ isUserDevice }: Readonly<Props>) {
|
||||
const t = useTranslations("peers");
|
||||
const { data: peers } = useFetchApi<Peer[]>("/peers");
|
||||
const { oidcUser: user } = useOidcUser();
|
||||
|
||||
const [isFirstRun, setIsFirstRun] = useLocalStorage<boolean>(
|
||||
"netbird-first-run",
|
||||
!(peers && peers.length > 0),
|
||||
);
|
||||
const [hasOnboardingFormCompleted] = useLocalStorage(
|
||||
"netbird-onboarding-modal",
|
||||
false,
|
||||
);
|
||||
|
||||
const [installModal, setInstallModal] = useState(
|
||||
!hasOnboardingFormCompleted
|
||||
? process.env.APP_ENV !== "test"
|
||||
? false
|
||||
: isFirstRun
|
||||
: isFirstRun,
|
||||
);
|
||||
const [isFirstRun, setIsFirstRun] = useLocalStorage<boolean>(
|
||||
"netbird-first-run",
|
||||
!(peers && peers.length > 0),
|
||||
);
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setInstallModal(open);
|
||||
setIsFirstRun(false);
|
||||
};
|
||||
const [installModal, setInstallModal] = useState(
|
||||
!hasOnboardingFormCompleted
|
||||
? process.env.APP_ENV !== "test"
|
||||
? false
|
||||
: isFirstRun
|
||||
: isFirstRun,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal open={installModal} onOpenChange={handleOpenChange}>
|
||||
<ModalTrigger asChild>
|
||||
<Button variant={"primary"} size={"sm"} className={"ml-auto"}>
|
||||
<PlusCircle size={16} />
|
||||
Add Peer
|
||||
</Button>
|
||||
</ModalTrigger>
|
||||
<SetupModal user={user} />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setInstallModal(open);
|
||||
setIsFirstRun(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal open={installModal} onOpenChange={handleOpenChange}>
|
||||
<ModalTrigger asChild>
|
||||
<Button variant={"primary"} size={"sm"} className={"ml-auto"}>
|
||||
<PlusCircle size={16} />
|
||||
{t("addPeer")}
|
||||
</Button>
|
||||
</ModalTrigger>
|
||||
<SetupModal user={user} isUserDevice={isUserDevice} />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(AddPeerButton);
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
} from "@components/select/SelectDropdown";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { MapPin } from "lucide-react";
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { createElement, useMemo } from "react";
|
||||
import { City } from "@/interfaces/City";
|
||||
|
||||
@@ -13,6 +14,7 @@ type Props = {
|
||||
country: string;
|
||||
};
|
||||
export const CitySelector = ({ value, onChange, country = "de" }: Props) => {
|
||||
const t = useTranslations('common');
|
||||
const { data: cities, isLoading } = useFetchApi<City[]>(
|
||||
`/locations/countries/${country}/cities`,
|
||||
);
|
||||
@@ -36,17 +38,17 @@ export const CitySelector = ({ value, onChange, country = "de" }: Props) => {
|
||||
} as SelectOption;
|
||||
}) as SelectOption[];
|
||||
|
||||
all.unshift({ label: "All Locations", value: "", icon: pinIcon });
|
||||
all.unshift({ label: t('allLocations'), value: "", icon: pinIcon });
|
||||
return all;
|
||||
}, [cities]);
|
||||
}, [cities, t]);
|
||||
|
||||
return (
|
||||
<div className={"block w-full"}>
|
||||
<SelectDropdown
|
||||
isLoading={isLoading}
|
||||
showSearch={true}
|
||||
placeholder={"Select city (optional)..."}
|
||||
searchPlaceholder={"Search city..."}
|
||||
placeholder={t('selectCityOptional')}
|
||||
searchPlaceholder={t('searchCity')}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={cityList || []}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
SelectDropdown,
|
||||
SelectOption,
|
||||
} from "@components/select/SelectDropdown";
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { createElement, useMemo } from "react";
|
||||
import RoundedFlag from "@/assets/countries/RoundedFlag";
|
||||
import { useCountries } from "@/contexts/CountryProvider";
|
||||
@@ -9,8 +10,12 @@ 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 t = useTranslations('common');
|
||||
const { countries, isLoading } = useCountries();
|
||||
|
||||
const countryList = useMemo(() => {
|
||||
@@ -22,7 +27,7 @@ export const CountrySelector = ({ value, onChange }: Props) => {
|
||||
}) =>
|
||||
createElement(RoundedFlag, {
|
||||
country: country.country_code,
|
||||
size: 20,
|
||||
size: iconSize,
|
||||
...props,
|
||||
});
|
||||
return {
|
||||
@@ -38,11 +43,14 @@ export const CountrySelector = ({ value, onChange }: Props) => {
|
||||
<SelectDropdown
|
||||
isLoading={isLoading}
|
||||
showSearch={true}
|
||||
placeholder={"Select country..."}
|
||||
searchPlaceholder={"Search country..."}
|
||||
placeholder={t('selectCountry')}
|
||||
searchPlaceholder={t('searchCountry')}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
iconSize={iconSize}
|
||||
options={countryList || []}
|
||||
popoverWidth={popoverWidth}
|
||||
truncate={truncate}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,18 +2,17 @@ import { cn } from "@utils/helpers";
|
||||
import LoadingIcon from "@/assets/icons/LoadingIcon";
|
||||
|
||||
type Props = {
|
||||
height?: "screen" | "auto";
|
||||
fullScreen?: boolean
|
||||
};
|
||||
export default function FullScreenLoading({ height = "screen" }: Props) {
|
||||
export default function FullScreenLoading({ fullScreen = true }: Props) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center w-screen",
|
||||
height == "screen" && "h-screen",
|
||||
height == "auto" && "h-auto",
|
||||
fullScreen && "h-screen",
|
||||
)}
|
||||
>
|
||||
<LoadingIcon className={"fill-netbird"} size={44} />
|
||||
<LoadingIcon className="fill-netbird" size={44} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,62 +12,47 @@ type Props = {
|
||||
domains: string[];
|
||||
};
|
||||
export default function MultipleDomains({ domains }: Props) {
|
||||
const firstDomain = domains.length > 0 ? domains[0] : undefined;
|
||||
const otherDomains = domains.length > 0 ? domains.slice(1) : [];
|
||||
if (domains.length === 0) {
|
||||
return (
|
||||
<Badge
|
||||
variant={"blue-darker"}
|
||||
className={"uppercase tracking-wider font-medium"}
|
||||
>
|
||||
<GlobeIcon size={10} />
|
||||
All
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return domains.length > 0 ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={1}>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<div className={"inline-flex items-center gap-2"}>
|
||||
{firstDomain && (
|
||||
<Badge variant={"blue-darker"}>
|
||||
<GlobeIcon size={10} />
|
||||
{firstDomain}
|
||||
</Badge>
|
||||
)}
|
||||
{otherDomains && otherDomains.length > 0 && (
|
||||
<Badge
|
||||
variant={"blue-darker"}
|
||||
className={"px-3 gap-2 whitespace-nowrap"}
|
||||
>
|
||||
+ {otherDomains.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{otherDomains && otherDomains.length > 0 && (
|
||||
<TooltipContent sideOffset={10}>
|
||||
<div className={"flex flex-col gap-2 items-start "}>
|
||||
{otherDomains.map((domain) => {
|
||||
return (
|
||||
domain && (
|
||||
<div
|
||||
key={domain}
|
||||
className={
|
||||
"flex gap-2 items-center justify-between w-full"
|
||||
}
|
||||
>
|
||||
<Badge variant={"blue-darker"}>
|
||||
<GlobeIcon size={10} />
|
||||
{domain}
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
if (domains.length > 1) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={1}>
|
||||
<TooltipTrigger asChild={true}>
|
||||
<Badge variant={"blue-darker"} className={"cursor-help"}>
|
||||
<GlobeIcon size={10} />
|
||||
{domains.length} Domains
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className={"p-3"}>
|
||||
<div className={"flex flex-col gap-1.5 items-start max-w-sm"}>
|
||||
{domains.map((domain) => (
|
||||
<Badge key={domain} variant={"blue-darker"}>
|
||||
<GlobeIcon size={10} />
|
||||
{domain}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<Badge
|
||||
variant={"blue-darker"}
|
||||
className={"uppercase tracking-wider font-medium"}
|
||||
>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant={"blue-darker"}>
|
||||
<GlobeIcon size={10} />
|
||||
All
|
||||
{domains[0]}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,16 @@ type Props = {
|
||||
showResources?: boolean;
|
||||
redirectGroupTab?: string;
|
||||
showUsers?: boolean;
|
||||
disableRedirect?: boolean;
|
||||
// countOnly collapses the visible chip to a single "N Groups" badge
|
||||
// when there are 2+ groups. The first-group + "+N" pattern is
|
||||
// suppressed in favour of a count summary; the hover card still
|
||||
// shows the full list.
|
||||
countOnly?: boolean;
|
||||
// countThreshold raises the size at which countOnly kicks in.
|
||||
// Default 1 means "collapse when length > 1". A threshold of 2
|
||||
// shows up to 2 groups inline and only collapses when there are 3+.
|
||||
countThreshold?: number;
|
||||
};
|
||||
|
||||
export default function MultipleGroups({
|
||||
@@ -37,11 +47,14 @@ export default function MultipleGroups({
|
||||
showResources = false,
|
||||
showUsers = false,
|
||||
redirectGroupTab,
|
||||
disableRedirect = false,
|
||||
countOnly = false,
|
||||
countThreshold = 1,
|
||||
}: Readonly<Props>) {
|
||||
const { permission } = usePermissions();
|
||||
|
||||
if (!groups || groups?.length === 0) return <EmptyRow />;
|
||||
const orderedGroups = groups.sort((a, b) => {
|
||||
const orderedGroups = [...groups].sort((a, b) => {
|
||||
if (a.name === "All") return 1;
|
||||
if (b.name === "All") return -1;
|
||||
const aPeerCount = a.peers_count ?? 0;
|
||||
@@ -61,15 +74,7 @@ export default function MultipleGroups({
|
||||
data-cy={"multiple-groups"}
|
||||
onClick={onClick}
|
||||
>
|
||||
{firstGroup && (
|
||||
<GroupBadge
|
||||
group={firstGroup}
|
||||
className={
|
||||
permission.groups.update ? "group-hover:bg-nb-gray-800" : ""
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{otherGroups && otherGroups.length > 0 && (
|
||||
{countOnly && orderedGroups.length > countThreshold ? (
|
||||
<Badge
|
||||
variant={"gray-ghost"}
|
||||
useHover={true}
|
||||
@@ -78,8 +83,51 @@ export default function MultipleGroups({
|
||||
permission.groups.update ? "group-hover:bg-nb-gray-800" : "",
|
||||
)}
|
||||
>
|
||||
+ {otherGroups.length}
|
||||
{orderedGroups.length} Groups
|
||||
</Badge>
|
||||
) : countOnly ? (
|
||||
<div className={"inline-flex items-center gap-2"}>
|
||||
{orderedGroups.map((group) => (
|
||||
<GroupBadge
|
||||
key={group?.id || group?.name}
|
||||
group={group}
|
||||
showNewBadge={true}
|
||||
className={
|
||||
permission.groups.update
|
||||
? "group-hover:bg-nb-gray-800"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{firstGroup && (
|
||||
<GroupBadge
|
||||
group={firstGroup}
|
||||
showNewBadge={true}
|
||||
className={
|
||||
permission.groups.update
|
||||
? "group-hover:bg-nb-gray-800"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{otherGroups && otherGroups.length > 0 && (
|
||||
<Badge
|
||||
variant={"gray-ghost"}
|
||||
useHover={true}
|
||||
className={cn(
|
||||
"px-3 gap-2 whitespace-nowrap",
|
||||
permission.groups.update
|
||||
? "group-hover:bg-nb-gray-800"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
+ {otherGroups.length}
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
@@ -101,7 +149,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 +158,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>
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import Button from "@components/Button";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import { cn } from "@utils/helpers";
|
||||
import { FilterX } from "lucide-react";
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import React, { useCallback } from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
@@ -20,18 +21,22 @@ type Props = {
|
||||
|
||||
export default function NoResults({
|
||||
icon,
|
||||
title = "Could not find any results",
|
||||
description = "We couldn't find any results. Please try a different search term or change your filters.",
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
className,
|
||||
hasFiltersApplied = false,
|
||||
onResetFilters,
|
||||
contentClassName,
|
||||
}: Props) {
|
||||
const t = useTranslations('table');
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const defaultTitle = title || t('noResults');
|
||||
const defaultDescription = description || t('noResultsDescription');
|
||||
|
||||
const handleResetClick = useCallback(() => {
|
||||
if (onResetFilters) {
|
||||
onResetFilters();
|
||||
@@ -83,9 +88,9 @@ export default function NoResults({
|
||||
</div>
|
||||
|
||||
<div className={"text-center"}>
|
||||
<h1 className={"text-2xl font-medium max-w-lg mx-auto"}>{title}</h1>
|
||||
<h1 className={"text-2xl font-medium max-w-lg mx-auto"}>{defaultTitle}</h1>
|
||||
<Paragraph className={"justify-center my-2 !text-nb-gray-400"}>
|
||||
{description}
|
||||
{defaultDescription}
|
||||
</Paragraph>
|
||||
{hasFiltersApplied && onResetFilters && (
|
||||
<Button
|
||||
@@ -94,7 +99,7 @@ export default function NoResults({
|
||||
className="mt-4"
|
||||
>
|
||||
<FilterX size={16} />
|
||||
Reset Filters & Search
|
||||
{t('resetFilters')}
|
||||
</Button>
|
||||
)}
|
||||
{children}
|
||||
|
||||
@@ -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`);
|
||||
};
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ import React, {
|
||||
useState,
|
||||
} from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { isNetBirdHosted } from "@utils/netbird";
|
||||
import { isLocalDev, isNetBirdHosted } from "@utils/netbird";
|
||||
import announcementFile from "../../announcements.json";
|
||||
|
||||
const ANNOUNCEMENTS_URL =
|
||||
"https://raw.githubusercontent.com/netbirdio/dashboard/main/announcements.json";
|
||||
@@ -64,7 +65,9 @@ const getAnnouncements = async (): Promise<AnnouncementInfo[]> => {
|
||||
|
||||
let raw: Announcement[];
|
||||
|
||||
if (stored && now - stored.timestamp < CACHE_DURATION_MS) {
|
||||
if (isLocalDev()) {
|
||||
raw = announcementFile as Announcement[];
|
||||
} else if (stored && now - stored.timestamp < CACHE_DURATION_MS) {
|
||||
raw = stored.announcements;
|
||||
} else {
|
||||
const response = await fetch(ANNOUNCEMENTS_URL);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -30,6 +30,7 @@ const PeerContext = React.createContext(
|
||||
inactivityExpiration?: boolean;
|
||||
approval_required?: boolean;
|
||||
ip?: string;
|
||||
ipv6?: string;
|
||||
}) => Promise<Peer>;
|
||||
toggleSSH: (newState: boolean) => Promise<void>;
|
||||
setSSHInstructionsModal: (open: boolean) => void;
|
||||
@@ -43,8 +44,10 @@ export default function PeerProvider({
|
||||
peer,
|
||||
isPeerDetailPage = false,
|
||||
}: Props) {
|
||||
const user = usePeerUser(peer);
|
||||
const { peerGroups, isLoading } = usePeerGroups(peer);
|
||||
const { user, isLoading: isUserLoading } = usePeerUser(peer);
|
||||
const { peerGroups, isLoading: isGroupsLoading } = usePeerGroups(peer);
|
||||
const isLoading =
|
||||
isGroupsLoading || (peer.user_id ? isUserLoading : false);
|
||||
const peerRequest = useApiCall<Peer>("/peers", true);
|
||||
const { confirm } = useDialog();
|
||||
const { mutate } = useSWRConfig();
|
||||
@@ -80,6 +83,7 @@ export default function PeerProvider({
|
||||
inactivityExpiration?: boolean;
|
||||
approval_required?: boolean;
|
||||
ip?: string;
|
||||
ipv6?: string;
|
||||
}) => {
|
||||
return peerRequest.put(
|
||||
{
|
||||
@@ -99,6 +103,7 @@ export default function PeerProvider({
|
||||
? undefined
|
||||
: props.approval_required,
|
||||
ip: props.ip != undefined ? props.ip : undefined,
|
||||
ipv6: props.ipv6 != undefined ? props.ipv6 : undefined,
|
||||
},
|
||||
`/${peer.id}`,
|
||||
);
|
||||
@@ -180,11 +185,13 @@ export const usePeerGroups = (peer?: Peer) => {
|
||||
* @param peer
|
||||
*/
|
||||
export const usePeerUser = (peer: Peer) => {
|
||||
const { users } = useUsers();
|
||||
const { users, isLoading } = useUsers();
|
||||
|
||||
return useMemo(() => {
|
||||
return users?.find((user) => user.id === peer.user_id);
|
||||
const user = useMemo(() => {
|
||||
return users?.find((u) => u.id === peer.user_id);
|
||||
}, [users, peer]);
|
||||
|
||||
return { user, isLoading };
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { notify } from "@components/Notification";
|
||||
import useFetchApi, { useApiCall } from "@utils/api";
|
||||
import { wrapIPv6 } from "@utils/ip";
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
@@ -15,6 +16,8 @@ import { Network, NetworkResource } from "@/interfaces/Network";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import {
|
||||
ReverseProxy,
|
||||
ReverseProxyCluster,
|
||||
ReverseProxyClusterType,
|
||||
ReverseProxyDomain,
|
||||
ReverseProxyFlatTarget,
|
||||
ReverseProxyTarget,
|
||||
@@ -23,9 +26,12 @@ import {
|
||||
} from "@/interfaces/ReverseProxy";
|
||||
import ReverseProxyModal from "@/modules/reverse-proxy/ReverseProxyModal";
|
||||
import ReverseProxyTargetModal from "@/modules/reverse-proxy/targets/ReverseProxyTargetModal";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
|
||||
type ReverseProxiesContextValue = {
|
||||
reverseProxies: ReverseProxy[] | undefined;
|
||||
resources: NetworkResource[] | undefined;
|
||||
peers: Peer[] | undefined;
|
||||
isLoading: boolean;
|
||||
openModal: (options?: OpenModalOptions) => void;
|
||||
openTargetModal: (options: OpenTargetModalOptions) => void;
|
||||
@@ -49,6 +55,9 @@ type ReverseProxiesContextValue = {
|
||||
domain: string,
|
||||
targetCluster: string,
|
||||
) => Promise<ReverseProxyDomain>;
|
||||
clusters: ReverseProxyCluster[] | undefined;
|
||||
isClustersLoading: boolean;
|
||||
isSelfHostedCluster: (clusterAddress?: string) => boolean;
|
||||
};
|
||||
|
||||
type OpenModalOptions = {
|
||||
@@ -88,17 +97,24 @@ export default function ReverseProxiesProvider({
|
||||
}: Readonly<Props>) {
|
||||
const { mutate } = useSWRConfig();
|
||||
const { confirm } = useDialog();
|
||||
const { permission } = usePermissions();
|
||||
|
||||
// Reverse Proxies
|
||||
const { data: rawReverseProxies, isLoading } = useFetchApi<ReverseProxy[]>(
|
||||
"/reverse-proxies/services",
|
||||
false,
|
||||
true,
|
||||
permission?.services.read,
|
||||
);
|
||||
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");
|
||||
const { data: resources } = useFetchApi<NetworkResource[]>(
|
||||
"/networks/resources",
|
||||
false,
|
||||
true,
|
||||
permission?.services.read,
|
||||
);
|
||||
|
||||
const resolveDestination = useCallback(
|
||||
@@ -123,12 +139,28 @@ export default function ReverseProxiesProvider({
|
||||
// Domains
|
||||
const { data: domains, isLoading: isLoadingDomains } = useFetchApi<
|
||||
ReverseProxyDomain[]
|
||||
>("/reverse-proxies/domains");
|
||||
>("/reverse-proxies/domains", false, true, permission.services?.read);
|
||||
const domainRequest = useApiCall<ReverseProxyDomain>(
|
||||
"/reverse-proxies/domains",
|
||||
true,
|
||||
);
|
||||
|
||||
// Clusters
|
||||
const { data: clusters, isLoading: isClustersLoading } = useFetchApi<
|
||||
ReverseProxyCluster[]
|
||||
>("/reverse-proxies/clusters", false, true, permission.services?.read);
|
||||
|
||||
const isSelfHostedCluster = useCallback(
|
||||
(clusterAddress?: string) => {
|
||||
if (!clusterAddress) return false;
|
||||
return (
|
||||
clusters?.find((c) => c.address === clusterAddress)?.type ===
|
||||
ReverseProxyClusterType.ACCOUNT
|
||||
);
|
||||
},
|
||||
[clusters],
|
||||
);
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [currentProxy, setCurrentProxy] = useState<ReverseProxy | undefined>();
|
||||
const [initialTab, setInitialTab] = useState<string | undefined>();
|
||||
@@ -301,7 +333,18 @@ export default function ReverseProxiesProvider({
|
||||
const handleToggleTarget = useCallback(
|
||||
async (proxy: ReverseProxy, target: ReverseProxyTarget) => {
|
||||
const newEnabled = !target.enabled;
|
||||
const targetIndex = proxy.targets.indexOf(target);
|
||||
let targetIndex = proxy.targets.indexOf(target);
|
||||
if (targetIndex === -1) {
|
||||
targetIndex = proxy.targets.findIndex(
|
||||
(t) =>
|
||||
t.target_id === target.target_id &&
|
||||
t.target_type === target.target_type &&
|
||||
t.path === target.path &&
|
||||
t.host === target.host &&
|
||||
t.port === target.port,
|
||||
);
|
||||
}
|
||||
if (targetIndex === -1) return;
|
||||
const updatedTargets = proxy.targets.map((t, i) => {
|
||||
return i === targetIndex ? { ...t, enabled: newEnabled } : t;
|
||||
});
|
||||
@@ -371,7 +414,18 @@ export default function ReverseProxiesProvider({
|
||||
loadingMessage: "Deleting service...",
|
||||
});
|
||||
} else {
|
||||
const targetIndex = proxy.targets.indexOf(target);
|
||||
let targetIndex = proxy.targets.indexOf(target);
|
||||
if (targetIndex === -1) {
|
||||
targetIndex = proxy.targets.findIndex(
|
||||
(t) =>
|
||||
t.target_id === target.target_id &&
|
||||
t.target_type === target.target_type &&
|
||||
t.path === target.path &&
|
||||
t.host === target.host &&
|
||||
t.port === target.port,
|
||||
);
|
||||
}
|
||||
if (targetIndex === -1) return;
|
||||
const updatedTargets = proxy.targets.filter(
|
||||
(_, i) => i !== targetIndex,
|
||||
);
|
||||
@@ -465,6 +519,8 @@ export default function ReverseProxiesProvider({
|
||||
<ReverseProxiesContext.Provider
|
||||
value={{
|
||||
reverseProxies,
|
||||
resources,
|
||||
peers,
|
||||
isLoading,
|
||||
openModal,
|
||||
openTargetModal,
|
||||
@@ -479,6 +535,9 @@ export default function ReverseProxiesProvider({
|
||||
createDomain,
|
||||
validateDomain,
|
||||
deleteDomain,
|
||||
clusters,
|
||||
isClustersLoading,
|
||||
isSelfHostedCluster,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
@@ -581,8 +640,20 @@ export function sanitizeTargets(
|
||||
): ReverseProxyTarget[] {
|
||||
return targets.map((t) => {
|
||||
const { destination: _, ...target } = t;
|
||||
// Subnet targets always own their Host. Cluster targets do too,
|
||||
// and they imply direct_upstream — the proxy peer dials the
|
||||
// operator-supplied upstream via the host network stack instead of
|
||||
// through the embedded WG client. For peer/host/domain targets the
|
||||
// backend resolves Host from the peer/resource unless the operator
|
||||
// explicitly opted into direct_upstream.
|
||||
if (t.target_type === ReverseProxyTargetType.SUBNET)
|
||||
return target as ReverseProxyTarget;
|
||||
if (t.target_type === ReverseProxyTargetType.CLUSTER) {
|
||||
const opts = { ...(target.options ?? {}), direct_upstream: true };
|
||||
return { ...target, options: opts } as ReverseProxyTarget;
|
||||
}
|
||||
if (target.options?.direct_upstream && target.host?.trim())
|
||||
return target as ReverseProxyTarget;
|
||||
const { host: __, ...rest } = target;
|
||||
return rest as ReverseProxyTarget;
|
||||
});
|
||||
@@ -600,7 +671,7 @@ function formatTargetDestination(
|
||||
target: ReverseProxyTarget,
|
||||
resolvedHost?: string,
|
||||
): string {
|
||||
const host = target.host || resolvedHost || "localhost";
|
||||
const host = wrapIPv6(target.host || resolvedHost || "localhost");
|
||||
const isDefault =
|
||||
(target.protocol === "http" && target.port === 80) ||
|
||||
(target.protocol === "https" && target.port === 443) ||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user