diff --git a/.claude/skills/gitnexus/gitnexus-cli/SKILL.md b/.claude/skills/gitnexus/gitnexus-cli/SKILL.md new file mode 100644 index 000000000..989c08277 --- /dev/null +++ b/.claude/skills/gitnexus/gitnexus-cli/SKILL.md @@ -0,0 +1,85 @@ +--- +name: gitnexus-cli +description: "Use when the user needs to run GitNexus CLI commands like analyze/index a repo, check status, clean the index, generate a wiki, or list indexed repos. Examples: \"Index this repo\", \"Reanalyze the codebase\", \"Generate a wiki\"" +--- + +# GitNexus CLI Commands + +Commands below use `node .gitnexus/run.cjs ` — the project-local runner `gitnexus analyze` drops next to the index. It auto-selects an available runner at call time (global `gitnexus`, else `pnpm dlx`, else `npx`), so no package-manager assumption and no global install is required. + +> **Not analyzed yet, or `node .gitnexus/run.cjs` reports `Cannot find module`** (the gitignored runner is absent — e.g. a fresh clone or `git clean`)? (Re)generate it with `npx gitnexus analyze` from the project root. On **npm 11.x**, if `npx` crashes during install (`node.target is null`), install once with `npm i -g gitnexus` (then `gitnexus analyze`) or use `pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze`. See [#1939](https://github.com/abhigyanpatwari/GitNexus/issues/1939). + +## Commands + +### analyze — Build or refresh the index + +```bash +node .gitnexus/run.cjs analyze +``` + +Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files. + +| Flag | Effect | +| -------------- | ---------------------------------------------------------------- | +| `--force` | Force full re-index even if up to date | +| `--embeddings` | Enable embedding generation for semantic search (off by default) | +| `--drop-embeddings` | Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves them. | + +**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook detects staleness after `git commit` and `git merge` and notifies the agent to run `analyze` — the hook does not run analyze itself, to avoid blocking the agent for up to 120s and risking KuzuDB corruption on timeout. + +### status — Check index freshness + +```bash +node .gitnexus/run.cjs status +``` + +Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed. + +### clean — Delete the index + +```bash +node .gitnexus/run.cjs clean +``` + +Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project. + +| Flag | Effect | +| --------- | ------------------------------------------------- | +| `--force` | Skip confirmation prompt | +| `--all` | Clean all indexed repos, not just the current one | + +### wiki — Generate documentation from the graph + +```bash +node .gitnexus/run.cjs wiki +``` + +Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use). + +| Flag | Effect | +| ------------------- | ----------------------------------------- | +| `--force` | Force full regeneration | +| `--model ` | LLM model (default: minimax/minimax-m2.5) | +| `--base-url ` | LLM API base URL | +| `--api-key ` | LLM API key | +| `--concurrency ` | Parallel LLM calls (default: 3) | +| `--gist` | Publish wiki as a public GitHub Gist | + +### list — Show all indexed repos + +```bash +node .gitnexus/run.cjs list +``` + +Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information. + +## After Indexing + +1. **Read `gitnexus://repo/{name}/context`** to verify the index loaded +2. Use the other GitNexus skills (`exploring`, `debugging`, `impact-analysis`, `refactoring`) for your task + +## Troubleshooting + +- **"Not inside a git repository"**: Run from a directory inside a git repo +- **Index is stale after re-analyzing**: Restart Claude Code to reload the MCP server +- **Embeddings slow**: Omit `--embeddings` (it's off by default) or set `OPENAI_API_KEY` for faster API-based embedding diff --git a/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md b/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md new file mode 100644 index 000000000..9834f94b7 --- /dev/null +++ b/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md @@ -0,0 +1,89 @@ +--- +name: gitnexus-debugging +description: "Use when the user is debugging a bug, tracing an error, or asking why something fails. Examples: \"Why is X failing?\", \"Where does this error come from?\", \"Trace this bug\"" +--- + +# Debugging with GitNexus + +## When to Use + +- "Why is this function failing?" +- "Trace where this error comes from" +- "Who calls this method?" +- "This endpoint returns 500" +- Investigating bugs, errors, or unexpected behavior + +## Workflow + +``` +1. query({query: ""}) → Find related execution flows +2. context({name: ""}) → See callers/callees/processes +3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow +4. cypher({query: "MATCH path..."}) → Custom traces if needed +``` + +> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. + +## Checklist + +``` +- [ ] Understand the symptom (error message, unexpected behavior) +- [ ] query for error text or related code +- [ ] Identify the suspect function from returned processes +- [ ] context to see callers and callees +- [ ] Trace execution flow via process resource if applicable +- [ ] cypher for custom call chain traces if needed +- [ ] Read source files to confirm root cause +``` + +## Debugging Patterns + +| Symptom | GitNexus Approach | +| -------------------- | ---------------------------------------------------------- | +| Error message | `query` for error text → `context` on throw sites | +| Wrong return value | `context` on the function → trace callees for data flow | +| Intermittent failure | `context` → look for external calls, async deps | +| Performance issue | `context` → find symbols with many callers (hot paths) | +| Recent regression | `detect_changes` to see what your changes affect | + +## Tools + +**query** — find code related to error: + +``` +query({query: "payment validation error"}) +→ Processes: CheckoutFlow, ErrorHandling +→ Symbols: validatePayment, handlePaymentError, PaymentException +``` + +**context** — full context for a suspect: + +``` +context({name: "validatePayment"}) +→ Incoming calls: processCheckout, webhookHandler +→ Outgoing calls: verifyCard, fetchRates (external API!) +→ Processes: CheckoutFlow (step 3/7) +``` + +**cypher** — custom call chain traces: + +```cypher +MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"}) +RETURN [n IN nodes(path) | n.name] AS chain +``` + +## Example: "Payment endpoint returns 500 intermittently" + +``` +1. query({query: "payment error handling"}) + → Processes: CheckoutFlow, ErrorHandling + → Symbols: validatePayment, handlePaymentError + +2. context({name: "validatePayment"}) + → Outgoing calls: verifyCard, fetchRates (external API!) + +3. READ gitnexus://repo/my-app/process/CheckoutFlow + → Step 3: validatePayment → calls fetchRates (external) + +4. Root cause: fetchRates calls external API without proper timeout +``` diff --git a/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md b/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md new file mode 100644 index 000000000..ccf684c28 --- /dev/null +++ b/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md @@ -0,0 +1,78 @@ +--- +name: gitnexus-exploring +description: "Use when the user asks how code works, wants to understand architecture, trace execution flows, or explore unfamiliar parts of the codebase. Examples: \"How does X work?\", \"What calls this function?\", \"Show me the auth flow\"" +--- + +# Exploring Codebases with GitNexus + +## When to Use + +- "How does authentication work?" +- "What's the project structure?" +- "Show me the main components" +- "Where is the database logic?" +- Understanding code you haven't seen before + +## Workflow + +``` +1. READ gitnexus://repos → Discover indexed repos +2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness +3. query({query: ""}) → Find related execution flows +4. context({name: ""}) → Deep dive on specific symbol +5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow +``` + +> If step 2 says "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. + +## Checklist + +``` +- [ ] READ gitnexus://repo/{name}/context +- [ ] query for the concept you want to understand +- [ ] Review returned processes (execution flows) +- [ ] context on key symbols for callers/callees +- [ ] READ process resource for full execution traces +- [ ] Read source files for implementation details +``` + +## Resources + +| Resource | What you get | +| --------------------------------------- | ------------------------------------------------------- | +| `gitnexus://repo/{name}/context` | Stats, staleness warning (~150 tokens) | +| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores (~300 tokens) | +| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens) | +| `gitnexus://repo/{name}/process/{name}` | Step-by-step execution trace (~200 tokens) | + +## Tools + +**query** — find execution flows related to a concept: + +``` +query({query: "payment processing"}) +→ Processes: CheckoutFlow, RefundFlow, WebhookHandler +→ Symbols grouped by flow with file locations +``` + +**context** — 360-degree view of a symbol: + +``` +context({name: "validateUser"}) +→ Incoming calls: loginHandler, apiMiddleware +→ Outgoing calls: checkToken, getUserById +→ Processes: LoginFlow (step 2/5), TokenRefresh (step 1/3) +``` + +## Example: "How does payment processing work?" + +``` +1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes +2. query({query: "payment processing"}) + → CheckoutFlow: processPayment → validateCard → chargeStripe + → RefundFlow: initiateRefund → calculateRefund → processRefund +3. context({name: "processPayment"}) + → Incoming: checkoutHandler, webhookHandler + → Outgoing: validateCard, chargeStripe, saveTransaction +4. Read src/payments/processor.ts for implementation details +``` diff --git a/.claude/skills/gitnexus/gitnexus-guide/SKILL.md b/.claude/skills/gitnexus/gitnexus-guide/SKILL.md new file mode 100644 index 000000000..b81900b5e --- /dev/null +++ b/.claude/skills/gitnexus/gitnexus-guide/SKILL.md @@ -0,0 +1,64 @@ +--- +name: gitnexus-guide +description: "Use when the user asks about GitNexus itself — available tools, how to query the knowledge graph, MCP resources, graph schema, or workflow reference. Examples: \"What GitNexus tools are available?\", \"How do I use GitNexus?\"" +--- + +# GitNexus Guide + +Quick reference for all GitNexus MCP tools, resources, and the knowledge graph schema. + +## Always Start Here + +For any task involving code understanding, debugging, impact analysis, or refactoring: + +1. **Read `gitnexus://repo/{name}/context`** — codebase overview + check index freshness +2. **Match your task to a skill below** and **read that skill file** +3. **Follow the skill's workflow and checklist** + +> If step 1 warns the index is stale, run `node .gitnexus/run.cjs analyze` in the terminal first. + +## Skills + +| Task | Skill to read | +| -------------------------------------------- | ------------------- | +| Understand architecture / "How does X work?" | `gitnexus-exploring` | +| Blast radius / "What breaks if I change X?" | `gitnexus-impact-analysis` | +| Trace bugs / "Why is X failing?" | `gitnexus-debugging` | +| Rename / extract / split / refactor | `gitnexus-refactoring` | +| Tools, resources, schema reference | `gitnexus-guide` (this file) | +| Index, status, clean, wiki CLI commands | `gitnexus-cli` | + +## Tools Reference + +| Tool | What it gives you | +| ---------------- | ------------------------------------------------------------------------ | +| `query` | Process-grouped code intelligence — execution flows related to a concept | +| `context` | 360-degree symbol view — categorized refs, processes it participates in | +| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence | +| `detect_changes` | Git-diff impact — what do your current changes affect | +| `rename` | Multi-file coordinated rename with confidence-tagged edits | +| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) | +| `list_repos` | Discover indexed repos | + +## Resources Reference + +Lightweight reads (~100-500 tokens) for navigation: + +| Resource | Content | +| ---------------------------------------------- | ----------------------------------------- | +| `gitnexus://repo/{name}/context` | Stats, staleness check | +| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores | +| `gitnexus://repo/{name}/cluster/{clusterName}` | Area members | +| `gitnexus://repo/{name}/processes` | All execution flows | +| `gitnexus://repo/{name}/process/{processName}` | Step-by-step trace | +| `gitnexus://repo/{name}/schema` | Graph schema for Cypher | + +## Graph Schema + +**Nodes:** File, Function, Class, Interface, Method, Community, Process +**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, STEP_IN_PROCESS + +```cypher +MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "myFunc"}) +RETURN caller.name, caller.filePath +``` diff --git a/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md b/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md new file mode 100644 index 000000000..45eb7ce87 --- /dev/null +++ b/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md @@ -0,0 +1,97 @@ +--- +name: gitnexus-impact-analysis +description: "Use when the user wants to know what will break if they change something, or needs safety analysis before editing code. Examples: \"Is it safe to change X?\", \"What depends on this?\", \"What will break?\"" +--- + +# Impact Analysis with GitNexus + +## When to Use + +- "Is it safe to change this function?" +- "What will break if I modify X?" +- "Show me the blast radius" +- "Who uses this code?" +- Before making non-trivial code changes +- Before committing — to understand what your changes affect + +## Workflow + +``` +1. impact({target: "X", direction: "upstream"}) → What depends on this +2. READ gitnexus://repo/{name}/processes → Check affected execution flows +3. detect_changes() → Map current git changes to affected flows +4. Assess risk and report to user +``` + +> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. + +## Checklist + +``` +- [ ] impact({target, direction: "upstream"}) to find dependents +- [ ] Review d=1 items first (these WILL BREAK) +- [ ] Check high-confidence (>0.8) dependencies +- [ ] READ processes to check affected execution flows +- [ ] detect_changes() for pre-commit check +- [ ] Assess risk level and report to user +``` + +## Understanding Output + +| Depth | Risk Level | Meaning | +| ----- | ---------------- | ------------------------ | +| d=1 | **WILL BREAK** | Direct callers/importers | +| d=2 | LIKELY AFFECTED | Indirect dependencies | +| d=3 | MAY NEED TESTING | Transitive effects | + +## Risk Assessment + +| Affected | Risk | +| ------------------------------ | -------- | +| <5 symbols, few processes | LOW | +| 5-15 symbols, 2-5 processes | MEDIUM | +| >15 symbols or many processes | HIGH | +| Critical path (auth, payments) | CRITICAL | + +## Tools + +**impact** — the primary tool for symbol blast radius: + +``` +impact({ + target: "validateUser", + direction: "upstream", + minConfidence: 0.8, + maxDepth: 3 +}) + +→ d=1 (WILL BREAK): + - loginHandler (src/auth/login.ts:42) [CALLS, 100%] + - apiMiddleware (src/api/middleware.ts:15) [CALLS, 100%] + +→ d=2 (LIKELY AFFECTED): + - authRouter (src/routes/auth.ts:22) [CALLS, 95%] +``` + +**detect_changes** — git-diff based impact analysis: + +``` +detect_changes({scope: "staged"}) + +→ Changed: 5 symbols in 3 files +→ Affected: LoginFlow, TokenRefresh, APIMiddlewarePipeline +→ Risk: MEDIUM +``` + +## Example: "What breaks if I change validateUser?" + +``` +1. impact({target: "validateUser", direction: "upstream"}) + → d=1: loginHandler, apiMiddleware (WILL BREAK) + → d=2: authRouter, sessionManager (LIKELY AFFECTED) + +2. READ gitnexus://repo/my-app/processes + → LoginFlow and TokenRefresh touch validateUser + +3. Risk: 2 direct callers, 2 processes = MEDIUM +``` diff --git a/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md b/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md new file mode 100644 index 000000000..e13c04e14 --- /dev/null +++ b/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md @@ -0,0 +1,121 @@ +--- +name: gitnexus-refactoring +description: "Use when the user wants to rename, extract, split, move, or restructure code safely. Examples: \"Rename this function\", \"Extract this into a module\", \"Refactor this class\", \"Move this to a separate file\"" +--- + +# Refactoring with GitNexus + +## When to Use + +- "Rename this function safely" +- "Extract this into a module" +- "Split this service" +- "Move this to a new file" +- Any task involving renaming, extracting, splitting, or restructuring code + +## Workflow + +``` +1. impact({target: "X", direction: "upstream"}) → Map all dependents +2. query({query: "X"}) → Find execution flows involving X +3. context({name: "X"}) → See all incoming/outgoing refs +4. Plan update order: interfaces → implementations → callers → tests +``` + +> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. + +## Checklists + +### Rename Symbol + +``` +- [ ] rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits +- [ ] Review graph edits (high confidence) and ast_search edits (review carefully) +- [ ] If satisfied: rename({..., dry_run: false}) — apply edits +- [ ] detect_changes() — verify only expected files changed +- [ ] Run tests for affected processes +``` + +### Extract Module + +``` +- [ ] context({name: target}) — see all incoming/outgoing refs +- [ ] impact({target, direction: "upstream"}) — find all external callers +- [ ] Define new module interface +- [ ] Extract code, update imports +- [ ] detect_changes() — verify affected scope +- [ ] Run tests for affected processes +``` + +### Split Function/Service + +``` +- [ ] context({name: target}) — understand all callees +- [ ] Group callees by responsibility +- [ ] impact({target, direction: "upstream"}) — map callers to update +- [ ] Create new functions/services +- [ ] Update callers +- [ ] detect_changes() — verify affected scope +- [ ] Run tests for affected processes +``` + +## Tools + +**rename** — automated multi-file rename: + +``` +rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true}) +→ 12 edits across 8 files +→ 10 graph edits (high confidence), 2 ast_search edits (review) +→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}] +``` + +**impact** — map all dependents first: + +``` +impact({target: "validateUser", direction: "upstream"}) +→ d=1: loginHandler, apiMiddleware, testUtils +→ Affected Processes: LoginFlow, TokenRefresh +``` + +**detect_changes** — verify your changes after refactoring: + +``` +detect_changes({scope: "all"}) +→ Changed: 8 files, 12 symbols +→ Affected processes: LoginFlow, TokenRefresh +→ Risk: MEDIUM +``` + +**cypher** — custom reference queries: + +```cypher +MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "validateUser"}) +RETURN caller.name, caller.filePath ORDER BY caller.filePath +``` + +## Risk Rules + +| Risk Factor | Mitigation | +| ------------------- | ----------------------------------------- | +| Many callers (>5) | Use rename for automated updates | +| Cross-area refs | Use detect_changes after to verify scope | +| String/dynamic refs | query to find them | +| External/public API | Version and deprecate properly | + +## Example: Rename `validateUser` to `authenticateUser` + +``` +1. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true}) + → 12 edits: 10 graph (safe), 2 ast_search (review) + → Files: validator.ts, login.ts, middleware.ts, config.json... + +2. Review ast_search edits (config.json: dynamic reference!) + +3. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false}) + → Applied 12 edits across 8 files + +4. detect_changes({scope: "all"}) + → Affected: LoginFlow, TokenRefresh + → Risk: MEDIUM — run tests for these flows +``` diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..893c93e0a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,377 @@ +# 仓库指南 + +## 项目概述 + +NetBird Dashboard 是 NetBird 管理服务的 Web 界面。这是一个 Next.js 应用程序,为 NetBird 网络提供网络管理、对等节点监控、访问控制和配置功能。 + +**在线版本:** https://app.netbird.io/ +**源代码:** https://github.com/netbirdio/dashboard + +## 架构与数据流 + +### 技术栈 +- **框架:** Next.js 13+ 使用 App Router +- **语言:** TypeScript +- **样式:** Tailwind CSS + shadcn/ui 组件 +- **状态管理:** React Context + SWR 用于服务器状态 +- **认证:** OIDC 通过 @axa-fr/react-oidc +- **国际化:** next-intl +- **测试:** Cypress (E2E) +- **部署:** Docker + Nginx + +### 高级结构 + +``` +src/ +├── app/ # Next.js App Router 页面 +│ ├── (dashboard)/ # 主仪表板路由(分组布局) +│ ├── (remote-access)/ # 远程访问路由 +│ ├── install/ # 安装向导 +│ ├── invite/ # 用户邀请流程 +│ └── setup/ # 初始设置流程 +├── assets/ # 静态资源(图标、图片、字体) +├── auth/ # OIDC 认证组件 +├── components/ # 共享 UI 组件(基于 shadcn/ui) +├── contexts/ # React Context 提供者 +├── hooks/ # 自定义 React 钩子 +├── i18n/ # 国际化配置和消息 +├── interfaces/ # TypeScript 类型定义 +├── layouts/ # 布局组件 +├── modules/ # 功能模块(领域特定) +└── utils/ # 工具函数 +``` + +### 数据流 + +1. **认证:** OIDC 提供者处理认证 → 令牌存储在内存中 +2. **API 调用:** `useFetchApi` 钩子 → SWR → OIDC 请求 → 管理 API +3. **状态:** 服务器状态通过 SWR 缓存,UI 状态通过 React Context +4. **渲染:** 默认使用服务器组件,需要时使用客户端组件 + +## 关键目录 + +### `src/app/` - 页面和路由 +- 使用 Next.js App Router 和路由分组 +- `(dashboard)/` 包含主要应用页面和共享布局 +- 每个路由有 `page.tsx` 和可选的 `layout.tsx` +- 通过 `error/page.tsx` 实现错误边界 + +### `src/modules/` - 功能模块 +按功能组织的领域特定组件: +- `peers/` - 对等节点管理组件 +- `networks/` - 网络配置 +- `access-control/` - ACL 策略 +- `dns/` - DNS 管理 +- `routes/` - 网络路由 +- `users/` - 用户管理 +- `groups/` - 分组管理 +- `setup-keys/` - 设置密钥管理 +- `activity/` - 活动日志 +- `settings/` - 账户设置 + +### `src/components/` - 共享 UI 组件 +基于 shadcn/ui 构建,具有自定义变体: +- `Input.tsx` - 带验证的表单输入 +- `Select.tsx` - 下拉选择 +- `Dialog.tsx` - 模态对话框 +- `Table.tsx` - 数据表格 +- `Button.tsx` - 操作按钮 +- `Badge.tsx` - 状态徽章 +- `Tooltip.tsx` - 信息提示 + +### `src/contexts/` - 状态提供者 +全局状态的 React Context 提供者: +- `ApplicationProvider.tsx` - 应用级配置 +- `PeersProvider.tsx` - 对等节点数据 +- `GroupsProvider.tsx` - 分组数据 +- `RoutesProvider.tsx` - 路由数据 +- `PoliciesProvider.tsx` - ACL 策略 +- `PermissionsProvider.tsx` - 用户权限 +- `GlobalThemeProvider.tsx` - 主题管理 +- `LocaleProvider.tsx` - 语言/区域设置 + +### `src/hooks/` - 自定义钩子 +可复用的 React 钩子: +- `useLocalStorage.tsx` - 持久化本地存储 +- `useDebounce.tsx` - 防抖值 +- `useSearch.ts` - 搜索功能 +- `useCopyToClipboard.ts` - 剪贴板操作 +- `useElementSize.ts` - DOM 元素尺寸 +- `useIntersectionObserver.ts` - 可见性检测 + +### `src/interfaces/` - 类型定义 +领域模型的 TypeScript 接口: +- `Peer.ts` - 网络对等节点 +- `Group.ts` - 对等节点分组 +- `Route.ts` - 网络路由 +- `Nameserver.ts` - DNS 名称服务器 +- `Account.ts` - 用户账户 +- `SetupKey.ts` - 设置密钥 +- `AccessToken.ts` - API 访问令牌 + +### `src/utils/` - 工具函数 +辅助函数: +- `api.tsx` - 集成 SWR 的 API 客户端 +- `helpers.ts` - 通用工具(cn, randomString 等) +- `config.ts` - 配置加载器 +- `ip.ts` - IP 地址工具 +- `wireguard.ts` - WireGuard 辅助函数 +- `version.ts` - 版本比较 + +## 开发命令 + +```bash +# 安装依赖 +npm install + +# 启动开发服务器(端口 3000) +npm run dev + +# 使用 Turbopack 启动(更快) +npm run turbo + +# 构建生产版本 +npm run build + +# 启动生产服务器 +npm start + +# 运行代码检查 +npm run lint + +# 打开 Cypress 测试运行器 +npm run cypress:open + +# 复制 OIDC 服务工作者(认证必需) +npm run copy +npm run copytrusted +``` + +## 代码规范和常见模式 + +### 组件模式 +```tsx +// 使用 shadcn/ui 和 class-variance-authority 实现变体 +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@utils/helpers"; + +const buttonVariants = cva("base-classes", { + variants: { + variant: { + default: "default-classes", + destructive: "destructive-classes", + }, + }, +}); + +interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps {} + +export function Button({ className, variant, ...props }: ButtonProps) { + return ( + - - - ); +return ( + +
+ + {t("disabledManagementGroupHelp")} + +
+
+ +
+
+ ); }; diff --git a/src/app/(dashboard)/dns/zones/layout.tsx b/src/app/(dashboard)/dns/zones/layout.tsx index 640fa1fc1..388a45110 100644 --- a/src/app/(dashboard)/dns/zones/layout.tsx +++ b/src/app/(dashboard)/dns/zones/layout.tsx @@ -1,8 +1,13 @@ +import { getTranslations } from "next-intl/server"; import { globalMetaTitle } from "@utils/meta"; import type { Metadata } from "next"; import BlankLayout from "@/layouts/BlankLayout"; -export const metadata: Metadata = { - title: `Zones - DNS - ${globalMetaTitle}`, -}; +export async function generateMetadata(): Promise { + const t = await getTranslations(); + return { + title: `${t("dnsZones")} - ${globalMetaTitle}`, + }; +} + export default BlankLayout; diff --git a/src/app/(dashboard)/dns/zones/page.tsx b/src/app/(dashboard)/dns/zones/page.tsx index 0e5857626..660953d0b 100644 --- a/src/app/(dashboard)/dns/zones/page.tsx +++ b/src/app/(dashboard)/dns/zones/page.tsx @@ -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,50 +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("/dns/zones"); + const { data: zones, isLoading } = useFetchApi("/dns/zones"); - const { ref: headingRef, portalTarget } = - usePortalElement(); + const { ref: headingRef, portalTarget } = + usePortalElement(); - return ( - -
- - } /> - } - /> - -

Zones

- - Manage DNS zones to control domain name resolution for your network.{" "} - - Learn more - - - -
+ return ( + +
+ + } /> + } + /> + +

{t("zones")}

+ + {t("zonesDescription")}{" "} + + {tCommon("learnMore")} + + + +
- - }> - - - - - -
- ); + + }> + + + + + +
+ ); } diff --git a/src/app/(dashboard)/events/audit/page.tsx b/src/app/(dashboard)/events/audit/page.tsx index 2c6d2370e..4b8e03404 100644 --- a/src/app/(dashboard)/events/audit/page.tsx +++ b/src/app/(dashboard)/events/audit/page.tsx @@ -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"; @@ -16,6 +17,8 @@ import ActivityTable from "@/modules/activity/ActivityTable"; import { EventStreamingCard } from "@/modules/integrations/event-streaming/EventStreamingCard"; export default function Activity() { + const t = useTranslations("activity"); + const tCommon = useTranslations("common"); const { permission } = usePermissions(); const { data: events, isLoading } = @@ -29,31 +32,30 @@ export default function Activity() {
} /> } /> -

Audit Events

+

{t("auditEvents")}

- Audit configuration changes, access policy updates, and peer - registration and login events across your network.{" "} + {t("auditEventsDescription")}{" "} - Learn more + {tCommon("learnMore")}
- - + + - + ); } @@ -73,7 +75,7 @@ export default function GroupPage() { } /> @@ -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 = () => { @@ -189,7 +193,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)} )} @@ -205,7 +209,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)} )} @@ -220,7 +224,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)} { className={groupDetails === null ? "animate-pulse" : ""} > - {singularize("Resources", resourcesCount)} + {singularize(t("resources"), resourcesCount)} { "fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all" } /> - {singularize("Network Routes", routesCount)} + {singularize(tNetworks("networkRoutes"), routesCount)} { "fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all" } /> - {singularize("Nameservers", nameserversCount)} + {singularize(t("nameservers"), nameserversCount)} { "fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all" } /> - {singularize("Zones", zonesCount)} + {singularize(t("zones"), zonesCount)} {group.name !== "All" && ( @@ -286,7 +290,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)} )} diff --git a/src/app/(dashboard)/groups/layout.tsx b/src/app/(dashboard)/groups/layout.tsx index 83b95052d..ea9d34f5e 100644 --- a/src/app/(dashboard)/groups/layout.tsx +++ b/src/app/(dashboard)/groups/layout.tsx @@ -1,8 +1,13 @@ +import { getTranslations } from "next-intl/server"; import { globalMetaTitle } from "@utils/meta"; import type { Metadata } from "next"; import BlankLayout from "@/layouts/BlankLayout"; -export const metadata: Metadata = { - title: `Groups - ${globalMetaTitle}`, -}; +export async function generateMetadata(): Promise { + const t = await getTranslations(); + return { + title: `${t("navigation.groups")} - ${globalMetaTitle}`, + }; +} + export default BlankLayout; diff --git a/src/app/(dashboard)/groups/page.tsx b/src/app/(dashboard)/groups/page.tsx index c00f12809..f3228a469 100644 --- a/src/app/(dashboard)/groups/page.tsx +++ b/src/app/(dashboard)/groups/page.tsx @@ -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,38 +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(); + const t = useTranslations("groups"); + const { permission } = usePermissions(); + const { ref: headingRef, portalTarget } = + usePortalElement(); - return ( - -
- - } - active - /> - -

Groups

- - Organize peers, users and resources into groups to manage access.{" "} - - Learn more - - - -
- - }> - - - -
- ); + return ( + +
+ + } + active + /> + +

{t("title")}

+ + {t("groupsDescription")}{" "} + + {t("learnMore")} + + + +
+ + }> + + + +
+ ); } diff --git a/src/app/(dashboard)/network-routes/layout.tsx b/src/app/(dashboard)/network-routes/layout.tsx index fe954bc12..b7cd15eb5 100644 --- a/src/app/(dashboard)/network-routes/layout.tsx +++ b/src/app/(dashboard)/network-routes/layout.tsx @@ -1,8 +1,13 @@ +import { getTranslations } from "next-intl/server"; import { globalMetaTitle } from "@utils/meta"; import type { Metadata } from "next"; import BlankLayout from "@/layouts/BlankLayout"; -export const metadata: Metadata = { - title: `Network Routes - ${globalMetaTitle}`, -}; +export async function generateMetadata(): Promise { + const t = await getTranslations(); + return { + title: `${t("networkRoutes")} - ${globalMetaTitle}`, + }; +} + export default BlankLayout; diff --git a/src/app/(dashboard)/network-routes/page.tsx b/src/app/(dashboard)/network-routes/page.tsx index a85115afd..dbfeae88a 100644 --- a/src/app/(dashboard)/network-routes/page.tsx +++ b/src/app/(dashboard)/network-routes/page.tsx @@ -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,71 +20,69 @@ 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("/routes"); - const groupedRoutes = useGroupedRoutes({ routes }); + const t = useTranslations("networks"); + const tCommon = useTranslations("common"); + const { permission } = usePermissions(); + const { data: routes, isLoading } = useFetchApi("/routes"); + const groupedRoutes = useGroupedRoutes({ routes }); - const { ref: headingRef, portalTarget } = - usePortalElement(); + const { ref: headingRef, portalTarget } = + usePortalElement(); - return ( - - - -
- - } - /> - - -

Routes

- - Access other networks like LANs and VPCs without installing - NetBird on every resource.{" "} - - Learn more - - - + return ( + + + +
+ + } + /> + + +

{t("routes")}

+ + {t("routesDescription")}{" "} + - - We recommend using the new Networks concept to easier visualise - and manage access to your resources.{" "} - - Go to Networks - - - - -
+ > + <>{tCommon("learnMore")} + + + - - }> - - - -
-
-
- ); + + + {t("newNetworksRecommendation")}{" "} + + {t("goToNetworks")} + + + + +
+ + + }> + + + +
+
+
+ ); } diff --git a/src/app/(dashboard)/network/page.tsx b/src/app/(dashboard)/network/page.tsx index 4751e8bbe..0d92b4d00 100644 --- a/src/app/(dashboard)/network/page.tsx +++ b/src/app/(dashboard)/network/page.tsx @@ -27,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"; @@ -71,6 +72,8 @@ export default function NetworkDetailPage() { } function NetworkOverview({ network }: Readonly<{ network: Network }>) { + const t = useTranslations("networks"); + const tReverseProxy = useTranslations("reverseProxy"); const { permission } = usePermissions(); const { data: resources, isLoading: isResourcesLoading } = useFetchApi< @@ -103,7 +106,7 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) { } /> @@ -149,7 +152,7 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) { - {singularize("Resources", network?.resources?.length)} + {singularize(t("resources"), network?.resources?.length)} ) { "fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all" } /> - {singularize("Routing Peers", network?.routing_peers_count)} + {singularize(t("routingPeers"), network?.routing_peers_count)} ) { "fill-nb-gray-500 group-data-[state=active]/trigger:fill-netbird transition-all" } /> - {singularize("Services", services.length)} + {singularize(tReverseProxy("services"), services.length)} @@ -202,6 +205,8 @@ function NetworkOverview({ network }: Readonly<{ network: Network }>) { } function NetworkActions() { + const t = useTranslations("networks"); + const tCommon = useTranslations("common"); const { permission } = usePermissions(); const { deleteNetwork, openEditNetworkModal, network } = useNetworksContext(); const router = useRouter(); @@ -229,7 +234,7 @@ function NetworkActions() { >
- Rename + {t("renameNetwork")}
@@ -244,7 +249,7 @@ function NetworkActions() { >
- Delete + {tCommon("delete")}
@@ -253,6 +258,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 ); @@ -260,23 +267,19 @@ function NetworkInformationCard({ network }: Readonly<{ network: Network }>) { const disabledText = useMemo( () => ( <> - High availability is currently{" "} - inactive for this - network. + {t("highAvailabilityInactiveText", { status: tCommon("inactive") })} ), - [], + [t, tCommon], ); const enabledText = useMemo( () => ( <> - High availability is{" "} - active for this - network. + {t("highAvailabilityActiveText", { status: tCommon("active") })} ), - [], + [t, tCommon], ); const policyCount = network.policies?.length ?? 0; @@ -289,7 +292,7 @@ function NetworkInformationCard({ network }: Readonly<{ network: Network }>) { label={ <> - High Availability + {t("highAvailability")} } value={ @@ -300,13 +303,11 @@ function NetworkInformationCard({ network }: Readonly<{ network: Network }>) { {isHighlyAvailable ? enabledText : disabledText} {isHighlyAvailable ? (
- You can add more routing peers to increase the - availability of this network. + {t("highAvailabilityHelpActive")}
) : (
- Go ahead and add more routing peers or groups with routing - peers to enable high availability for this network. + {t("highAvailabilityHelpInactive")}
)} @@ -323,7 +324,7 @@ function NetworkInformationCard({ network }: Readonly<{ network: Network }>) { !isHighlyAvailable ? "bg-yellow-400" : "bg-green-500", )} > - {isHighlyAvailable ? "Active" : "Inactive"} + {isHighlyAvailable ? tCommon("active") : tCommon("inactive")} @@ -335,20 +336,19 @@ function NetworkInformationCard({ network }: Readonly<{ network: Network }>) { policyCount > 0 ? ( <> - {policyCount}{" "} - {policyCount === 1 ? "Active Policy" : "Active Policies"} + {t("activePoliciesCount", { count: policyCount })} ) : ( <> - No Active Policies + {t("noActivePolicies")} ) } value={ policyCount > 0 ? ( - Go to Policies + {t("goToPolicies")} ) : null diff --git a/src/app/(dashboard)/networks/page.tsx b/src/app/(dashboard)/networks/page.tsx index 3eb1a74d1..32224c414 100644 --- a/src/app/(dashboard)/networks/page.tsx +++ b/src/app/(dashboard)/networks/page.tsx @@ -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("/networks"); const { permission } = usePermissions(); const { ref: headingRef, portalTarget } = @@ -26,20 +30,19 @@ export default function Networks() {
} /> - + -

Networks

+

{t("title")}

- Access internal resources in LANs and VPCs without installing NetBird - on every machine.{" "} + {t("pageDescription")}{" "} - Learn more + {tCommon("learnMore")} diff --git a/src/app/(dashboard)/peer/page.tsx b/src/app/(dashboard)/peer/page.tsx index c9e10e1f7..9469b8852 100644 --- a/src/app/(dashboard)/peer/page.tsx +++ b/src/app/(dashboard)/peer/page.tsx @@ -7,11 +7,11 @@ import HelpText from "@components/HelpText"; import { Input } from "@components/Input"; import { Label } from "@components/Label"; import { - Modal, - ModalClose, - ModalContent, - ModalFooter, - ModalTrigger, + Modal, + ModalClose, + ModalContent, + ModalFooter, + ModalTrigger, } from "@components/modal/Modal"; import ModalHeader from "@components/modal/ModalHeader"; import { notify } from "@components/Notification"; @@ -29,20 +29,21 @@ import { singularize } from "@utils/helpers"; import dayjs from "dayjs"; import { isEmpty, trim } from "lodash"; import { - ArrowRightIcon, - Barcode, - CalendarDays, - Cpu, - FlagIcon, - Globe, - History, - ListIcon, - MapPin, - MonitorSmartphoneIcon, - NetworkIcon, - PencilIcon, - RadioTowerIcon, + ArrowRightIcon, + Barcode, + CalendarDays, + Cpu, + FlagIcon, + Globe, + History, + ListIcon, + MapPin, + MonitorSmartphoneIcon, + NetworkIcon, + PencilIcon, + RadioTowerIcon, } from "lucide-react"; +import { useTranslations } from "next-intl"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; import { toASCII } from "punycode"; @@ -72,8 +73,8 @@ import { AccessiblePeersSection } from "@/modules/peer/AccessiblePeersSection"; import { PeerNetworkRoutesSection } from "@/modules/peer/PeerNetworkRoutesSection"; import { PeerRemoteJobsSection } from "@/modules/peer/PeerRemoteJobsSection"; import ReverseProxiesProvider, { - flattenReverseProxies, - useReverseProxies, + flattenReverseProxies, + useReverseProxies, } from "@/contexts/ReverseProxiesProvider"; import { ReverseProxyFlatTargetsTabContent } from "@/modules/reverse-proxy/targets/flat/ReverseProxyFlatTargetsTabContent"; import { PeerEditIPModal } from "@/modules/peer/PeerEditIPModal"; @@ -83,44 +84,43 @@ import { SSHButton } from "@/modules/remote-access/ssh/SSHButton"; import { PeerExpirationSettings } from "@/modules/peer/PeerExpirationSettings"; export default function PeerPage() { - const queryParameter = useSearchParams(); - const { isRestricted } = usePermissions(); - const peerId = queryParameter.get("id"); - const { - data: peer, - isLoading, - error, - } = useFetchApi("/peers/" + peerId, true); - - useRedirect("/peers", false, !peerId || isRestricted); - - if (isRestricted) { - return ( - - - - ); - } - - if (error) - return ( - - ); - - return peer && peer.id && !isLoading ? ( - - - - - - ) : ( - - ); + const t = useTranslations("peers"); + const queryParameter = useSearchParams(); + const { isRestricted } = usePermissions(); + const peerId = queryParameter.get("id"); + const { + data: peer, + isLoading, + error, + } = useFetchApi("/peers/" + peerId, true); + + useRedirect("/peers", false, !peerId || isRestricted); + + if (isRestricted) { + return ( + + + + ); + } + + if (error) + return ( + + ); + + return peer && peer.id && !isLoading ? ( + + + + + + ) : ( + + ); } // Route the user back to the list view that matches the peer's kind @@ -128,725 +128,733 @@ export default function PeerPage() { // for the breadcrumb and the Cancel back-button so they don't bounce // through the legacy /peers redirect. function peerListPath(user: User | undefined): string { - const hasRealUser = !!user && !user.is_service_user; - return hasRealUser ? "/peers/users" : "/peers/servers"; + const hasRealUser = !!user && !user.is_service_user; + return hasRealUser ? "/peers/users" : "/peers/servers"; } function PeerOverview() { - const { peer, user } = usePeer(); - - return ( - - - -
- - } - /> - - - -
- -
-
-
- ); + const t = useTranslations("peers"); + const { peer, user } = usePeer(); + + return ( + + + +
+ + } + /> + + + +
+ +
+
+
+ ); } type PeerSettingsContextType = { - selectedGroups: Group[]; - setSelectedGroups: React.Dispatch>; - hasChanges: boolean; - updatePeer: (newName?: string) => Promise; - name: string; - setName: (name: string) => void; - tab: string; - setTab: (tab: string) => void; + selectedGroups: Group[]; + setSelectedGroups: React.Dispatch>; + hasChanges: boolean; + updatePeer: (newName?: string) => Promise; + name: string; + setName: (name: string) => void; + tab: string; + setTab: (tab: string) => void; }; const PeerSettingsContext = React.createContext( - null, + null, ); const usePeerSettings = () => { - const context = React.useContext(PeerSettingsContext); - if (!context) { - throw new Error("usePeerSettings must be used within PeerSettingsProvider"); - } - return context; + const context = React.useContext(PeerSettingsContext); + if (!context) { + throw new Error("usePeerSettings must be used within PeerSettingsProvider"); + } + return context; }; const PeerSettingsProvider = ({ children }: { children: React.ReactNode }) => { - const { mutate } = useSWRConfig(); - const { peer, peerGroups, update } = usePeer(); - const { permission } = usePermissions(); - const [name, setName] = useState(peer.name); - const [tab, setTab] = useState("overview"); - const [selectedGroups, setSelectedGroups, { getAllGroupCalls }] = - useGroupHelper({ - initial: peerGroups?.filter((g) => g?.name !== "All"), - peer, - }); - - const { hasChanges, updateRef: updateHasChangedRef } = useHasChanges([ - selectedGroups, - ]); - - const updatePeer = async (newName?: string) => { - let batchCall: Promise[] = []; - const groupCalls = getAllGroupCalls(); - - if (permission.peers.update) { - const updateRequest = update({ - name: newName ?? name, - }); - batchCall = groupCalls ? [...groupCalls, updateRequest] : [updateRequest]; - } else { - batchCall = [...groupCalls]; - } - - notify({ - title: name, - description: "Peer was successfully saved", - promise: Promise.all(batchCall).then(() => { - mutate("/peers/" + peer.id); - mutate("/groups"); - updateHasChangedRef([selectedGroups]); - }), - loadingMessage: "Saving the peer...", - }); - }; - - return ( - - {children} - - ); + const t = useTranslations("peers"); + const { mutate } = useSWRConfig(); + const { peer, peerGroups, update } = usePeer(); + const { permission } = usePermissions(); + const [name, setName] = useState(peer.name); + const [tab, setTab] = useState("overview"); + const [selectedGroups, setSelectedGroups, { getAllGroupCalls }] = + useGroupHelper({ + initial: peerGroups?.filter((g) => g?.name !== "All"), + peer, + }); + + const { hasChanges, updateRef: updateHasChangedRef } = useHasChanges([ + selectedGroups, + ]); + + const updatePeer = async (newName?: string) => { + let batchCall: Promise[] = []; + const groupCalls = getAllGroupCalls(); + + if (permission.peers.update) { + const updateRequest = update({ + name: newName ?? name, + }); + batchCall = groupCalls ? [...groupCalls, updateRequest] : [updateRequest]; + } else { + batchCall = [...groupCalls]; + } + + notify({ + title: name, + description: t("peerSaved"), + promise: Promise.all(batchCall).then(() => { + mutate("/peers/" + peer.id); + mutate("/groups"); + updateHasChangedRef([selectedGroups]); + }), + loadingMessage: t("peerSaving"), + }); + }; + + return ( + + {children} + + ); }; const PeerHeader = () => { - const router = useRouter(); - const { peer, user } = usePeer(); - const { permission } = usePermissions(); - const { name, setName, hasChanges, updatePeer, tab } = usePeerSettings(); - const [showEditNameModal, setShowEditNameModal] = useState(false); - const isOverviewTab = tab === "overview"; - - return ( - <> -
-
-
-

- - - - {permission.peers.update && ( - - -
- -
-
- { - updatePeer(newName).then(() => { - setName(newName); - setShowEditNameModal(false); - }); - }} - peer={peer} - initialName={name} - key={showEditNameModal ? 1 : 0} - /> -
- )} -

- -
- {(user?.id || user?.email) && ( -
- - - {user?.email || user?.id} - - - -
- )} -
- {isOverviewTab && ( -
- - -
- )} -
- - ); + const t = useTranslations("peers"); + const tCommon = useTranslations("common"); + const router = useRouter(); + const { peer, user } = usePeer(); + const { permission } = usePermissions(); + const { name, setName, hasChanges, updatePeer, tab } = usePeerSettings(); + const [showEditNameModal, setShowEditNameModal] = useState(false); + const isOverviewTab = tab === "overview"; + + return ( + <> +
+
+
+

+ + + + {permission.peers.update && ( + + +
+ +
+
+ { + updatePeer(newName).then(() => { + setName(newName); + setShowEditNameModal(false); + }); + }} + peer={peer} + initialName={name} + key={showEditNameModal ? 1 : 0} + /> +
+ )} +

+ +
+ {(user?.id || user?.email) && ( +
+ + + {user?.email || user?.id} + + + +
+ )} +
+ {isOverviewTab && ( +
+ + +
+ )} +
+ + ); }; const PeerOverviewTabs = () => { - const { peer } = usePeer(); - const { permission } = usePermissions(); - const { reverseProxies, isLoading: isServicesLoading } = useReverseProxies(); - const { tab, setTab } = usePeerSettings(); - - const flatTargets = useMemo( - () => flattenReverseProxies({ reverseProxies, peer }), - [reverseProxies, peer], - ); - - return ( - - - - - Overview - - - {permission.routes.read && ( - - - Network Routes - - )} - - {peer?.id && ( - - - Accessible Peers - - )} - - {peer?.id && permission.services?.read && ( - - - {singularize("Services", flatTargets.length)} - - )} - - {peer?.id && permission.peers.delete && ( - - - Remote Jobs - - )} - - {permission.events.read && } - - - - - - - {permission.routes.read && ( - - - - )} - - {peer?.id && ( - - - - )} - - {peer?.id && permission.services?.read && ( - - - - )} - - {peer.id && permission.peers.delete && ( - - - - )} - - {permission.events.read && ( - - - - )} - - ); + const t = useTranslations("peers"); + const tReverse = useTranslations("reverseProxy"); + const { peer } = usePeer(); + const { permission } = usePermissions(); + const { reverseProxies, isLoading: isServicesLoading } = useReverseProxies(); + const { tab, setTab } = usePeerSettings(); + + const flatTargets = useMemo( + () => flattenReverseProxies({ reverseProxies, peer }), + [reverseProxies, peer], + ); + + return ( + + + + + {t("tabOverview")} + + + {permission.routes.read && ( + + + {t("tabNetworkRoutes")} + + )} + +{peer?.id && permission.peers.read && ( + + + {t("tabAccessiblePeers")} + + )} + + {peer?.id && permission.services?.read && ( + + + {singularize(tReverse("services"), flatTargets.length)} + + )} + +{peer?.id && permission.peers.delete && ( + + + {t("tabRemoteJobs")} + + )} + + {permission.events.read && } + + + + + + + {permission.routes.read && ( + + + + )} + +{peer?.id && permission.peers.read && ( + + + + )} + + {peer?.id && permission.services?.read && ( + + + + )} + +{peer.id && permission.peers.delete && ( + + + + )} + + {permission.events.read && ( + + + + )} + + ); }; const PeerOverviewTabContent = () => { - const { peer } = usePeer(); - const { permission } = usePermissions(); - const { selectedGroups, setSelectedGroups } = usePeerSettings(); - - return ( -
-
- - -
- - {permission.groups.read && ( -
- - - Use groups to control what this peer can access. - - -
- )} - - - - {/* Remote Access Buttons */} -
- - Connect directly to this peer via SSH or RDP. -
- - -
-
-
-
-
- ); + const t = useTranslations("peers"); + const { peer } = usePeer(); + const { permission } = usePermissions(); + const { selectedGroups, setSelectedGroups } = usePeerSettings(); + + return ( +
+
+ + +
+ + {permission.groups.read && ( +
+ + {t("assignedGroupsDescription")} + +
+ )} + + + + {/* Remote Access Buttons */} +
+ + {t("remoteAccessDescription")} +
+ + +
+
+
+
+
+ ); }; function PeerInformationCard({ peer }: Readonly<{ peer: Peer }>) { - const { isLoading, getRegionByPeer } = useCountries(); - const { update } = usePeer(); - const { mutate } = useSWRConfig(); - const [showEditIPModal, setShowEditIPModal] = useState(false); - const [showEditIPv6Modal, setShowEditIPv6Modal] = useState(false); - const { permission } = usePermissions(); - - const countryText = useMemo(() => { - return getRegionByPeer(peer); - }, [getRegionByPeer, peer]); - - const handleSaveIP = (newIP: string) => { - notify({ - title: peer.name, - description: "NetBird Peer IP was successfully updated", - promise: update({ ip: newIP }).then(() => { - mutate("/peers/" + peer.id); - setShowEditIPModal(false); - }), - loadingMessage: "Updating peer IP...", - }); - }; - - const handleSaveIPv6 = (newIPv6: string) => { - notify({ - title: peer.name, - description: "NetBird Peer IPv6 was successfully updated", - promise: update({ ipv6: newIPv6 }).then(() => { - mutate("/peers/" + peer.id); - setShowEditIPv6Modal(false); - }), - loadingMessage: "Updating peer IPv6...", - }); - }; - - return ( - <> - - - - - - - NetBird IP Address - - } - valueToCopy={peer.ip} - value={ - setShowEditIPModal(true)} - /> - } - /> - - {peer.ipv6 && ( - - - NetBird IPv6 Address - - } - valueToCopy={peer.ipv6} - value={ - setShowEditIPv6Modal(true)} - /> - } - /> - )} - - - - Public IP Address - - } - value={peer.connection_ip} - /> - - - - Domain Name - - } - className={ - peer?.extra_dns_labels && peer.extra_dns_labels.length > 0 - ? "items-start" - : "" - } - value={peer.dns_label} - extraText={peer?.extra_dns_labels} - /> - - - - Hostname - - } - value={peer.hostname} - /> - - - - Region - - } - tooltip={false} - value={ - isEmpty(peer.country_code) ? ( - "Unknown" - ) : ( - <> - {isLoading ? ( - - ) : ( -
-
- -
- {countryText} -
- )} - - ) - } - /> - - - - Operating System - - } - value={peer.os} - /> - - {peer.serial_number && peer.serial_number !== "" && ( - - - Serial Number - - } - value={peer.serial_number} - /> - )} - - {peer.created_at && ( - - - Registered on - - } - value={ - dayjs(peer.created_at).format("D MMMM, YYYY [at] h:mm A") + - " (" + - dayjs().to(peer.created_at) + - ")" - } - /> - )} - - - - Last seen - - } - value={ - peer.connected - ? "just now" - : dayjs(peer.last_seen).format("D MMMM, YYYY [at] h:mm A") + - " (" + - dayjs().to(peer.last_seen) + - ")" - } - /> - - - - Agent Version - - } - value={peer.version} - /> - - {peer.ui_version && ( - - - UI Version - - } - value={peer.ui_version?.replace("netbird-desktop-ui/", "")} - /> - )} -
-
- - ); + const t = useTranslations("peers"); + const tCommon = useTranslations("common"); + const { isLoading, getRegionByPeer } = useCountries(); + const { update } = usePeer(); + const { mutate } = useSWRConfig(); + const [showEditIPModal, setShowEditIPModal] = useState(false); + const [showEditIPv6Modal, setShowEditIPv6Modal] = useState(false); + const { permission } = usePermissions(); + + const countryText = useMemo(() => { + return getRegionByPeer(peer); + }, [getRegionByPeer, peer]); + + const handleSaveIP = (newIP: string) => { + notify({ + title: peer.name, + description: t("peerIpUpdated"), + promise: update({ ip: newIP }).then(() => { + mutate("/peers/" + peer.id); + setShowEditIPModal(false); + }), + loadingMessage: t("peerIpUpdating"), + }); + }; + + const handleSaveIPv6 = (newIPv6: string) => { + notify({ + title: peer.name, + description: t("peerIpv6Updated"), + promise: update({ ipv6: newIPv6 }).then(() => { + mutate("/peers/" + peer.id); + setShowEditIPv6Modal(false); + }), + loadingMessage: t("peerIpv6Updating"), + }); + }; + + return ( + <> + + + + + + + {t("netbirdIp")} + + } + valueToCopy={peer.ip} + value={ + setShowEditIPModal(true)} + /> + } + /> + + {peer.ipv6 && ( + + + {t("netbirdIpv6")} + + } + valueToCopy={peer.ipv6} + value={ + setShowEditIPv6Modal(true)} + /> + } + /> + )} + + + + {t("publicIp")} + + } + value={peer.connection_ip} + /> + + + + {t("domainName")} + + } + className={ + peer?.extra_dns_labels && peer.extra_dns_labels.length > 0 + ? "items-start" + : "" + } + value={peer.dns_label} + extraText={peer?.extra_dns_labels} + /> + + + + {t("hostname")} + + } + value={peer.hostname} + /> + + + + {t("region")} + + } + tooltip={false} + value={ + isEmpty(peer.country_code) ? ( + tCommon("unknown") + ) : ( + <> + {isLoading ? ( + + ) : ( +
+
+ +
+ {countryText} +
+ )} + + ) + } + /> + + + + {t("operatingSystemLabel")} + + } + value={peer.os} + /> + + {peer.serial_number && peer.serial_number !== "" && ( + + + {t("serialNumber")} + + } + value={peer.serial_number} + /> + )} + + {peer.created_at && ( + + + {t("registeredOn")} + + } + value={ + dayjs(peer.created_at).format("D MMMM, YYYY") + + ` ${t("at")} ` + + dayjs(peer.created_at).format("h:mm A") + + " (" + + dayjs().to(peer.created_at) + + ")" + } + /> + )} + + + + {t("lastSeen")} + + } + value={ + peer.connected + ? t("justNow") + : dayjs(peer.last_seen).format("D MMMM, YYYY") + + ` ${t("at")} ` + + dayjs(peer.last_seen).format("h:mm A") + + " (" + + dayjs().to(peer.last_seen) + + ")" + } + /> + + + + {t("agentVersion")} + + } + value={peer.version} + /> + + {peer.ui_version && ( + + + {t("uiVersion")} + + } + value={peer.ui_version?.replace("netbird-desktop-ui/", "")} + /> + )} +
+
+ + ); } interface ModalProps { - onSuccess: (name: string) => void; - peer: Peer; - initialName: string; + onSuccess: (name: string) => void; + peer: Peer; + initialName: string; } function EditNameModal({ onSuccess, peer, initialName }: Readonly) { - const [name, setName] = useState(initialName); - - const isDisabled = useMemo(() => { - if (name === peer.name) return true; - const trimmedName = trim(name); - return trimmedName.length === 0; - }, [name, peer]); - - const domainNamePreview = useMemo(() => { - let punyName = toASCII(name.toLowerCase()); - punyName = punyName.replace(/[^a-z0-9]/g, "-"); - let domain = ""; - if (peer.dns_label) { - const labelList = peer.dns_label.split("."); - if (labelList.length > 1) { - labelList.splice(0, 1); - domain = "." + labelList.join("."); - } - } - return punyName + domain; - }, [name, peer]); - - return ( - -
- - -
-
- setName(e.target.value)} - /> -
- - - - If the domain name already exists, we add an increment number - suffix to it. - -
- {domainNamePreview} -
-
-
- - -
- - - - - -
-
- -
- ); + const t = useTranslations("peers"); + const tCommon = useTranslations("common"); + const [name, setName] = useState(initialName); + + const isDisabled = useMemo(() => { + if (name === peer.name) return true; + const trimmedName = trim(name); + return trimmedName.length === 0; + }, [name, peer]); + + const domainNamePreview = useMemo(() => { + let punyName = toASCII(name.toLowerCase()); + punyName = punyName.replace(/[^a-z0-9]/g, "-"); + let domain = ""; + if (peer.dns_label) { + const labelList = peer.dns_label.split("."); + if (labelList.length > 1) { + labelList.splice(0, 1); + domain = "." + labelList.join("."); + } + } + return punyName + domain; + }, [name, peer]); + + return ( + +
+ + +
+
+ setName(e.target.value)} + /> +
+ + + {t("domainNamePreviewHelp")} +
+ {domainNamePreview} +
+
+
+ + +
+ + + + + +
+
+ +
+ ); } function EditableValue({ - value, - canEdit, - onEdit, + value, + canEdit, + onEdit, }: { - value: string; - canEdit: boolean; - onEdit: () => void; + value: string; + canEdit: boolean; + onEdit: () => void; }) { - return ( -
- {value} - {canEdit && ( - - )} -
- ); + return ( +
+ {value} + {canEdit && ( + + )} +
+ ); } diff --git a/src/app/(dashboard)/peers/layout.tsx b/src/app/(dashboard)/peers/layout.tsx index c18ce32c3..9e02a5f8d 100644 --- a/src/app/(dashboard)/peers/layout.tsx +++ b/src/app/(dashboard)/peers/layout.tsx @@ -1,8 +1,13 @@ +import { getTranslations } from "next-intl/server"; import { globalMetaTitle } from "@utils/meta"; import type { Metadata } from "next"; import BlankLayout from "@/layouts/BlankLayout"; -export const metadata: Metadata = { - title: `Peers - ${globalMetaTitle}`, -}; +export async function generateMetadata(): Promise { + const t = await getTranslations(); + return { + title: `${t("navigation.peers")} - ${globalMetaTitle}`, + }; +} + export default BlankLayout; diff --git a/src/app/(dashboard)/peers/servers/page.tsx b/src/app/(dashboard)/peers/servers/page.tsx index ab67d17c0..1971db48c 100644 --- a/src/app/(dashboard)/peers/servers/page.tsx +++ b/src/app/(dashboard)/peers/servers/page.tsx @@ -6,6 +6,7 @@ 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 { useBypassedPeers } from "@/cloud/edr/useBypass"; @@ -20,111 +21,111 @@ import { SetupModalContent } from "@/modules/setup-netbird-modal/SetupModal"; const PeersTable = lazy(() => import("@/modules/peers/PeersTable")); export default function ServersPage() { - const { isRestricted } = usePermissions(); - const { isLoading: isDistributorRedirecting } = useDistributorRedirect(); - if (isDistributorRedirecting) return ; +const { isRestricted } = usePermissions(); + const { isLoading: isDistributorRedirecting } = useDistributorRedirect(); + if (isDistributorRedirecting) return ; - return ( - - {isRestricted ? ( - - ) : ( - - - - )} - - ); + return ( + + {isRestricted ? ( + + ) : ( + + + + )} + + ); } function ServersView() { - const { peers, isLoading: isPeersLoading } = usePeers(); - const { users, isLoading: isUsersLoading } = useUsers(); - const { isBypassed } = useBypassedPeers(); - const { ref: headingRef, portalTarget } = - usePortalElement(); +const t = useTranslations("peers"); + const { peers, isLoading: isPeersLoading } = usePeers(); + const { users, isLoading: isUsersLoading } = useUsers(); + const { isBypassed } = useBypassedPeers(); + const { ref: headingRef, portalTarget } = + usePortalElement(); - // 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), - force_approved: peer.id ? isBypassed(peer.id) : false, - })); - }, [peers, users, isBypassed]); - - return ( - <> -
- - } - /> - - -

Servers

- - Servers, VMs, autonomous agents and other unattended machines with no - user behind them, typically enrolled with a setup key.{" "} - - Learn more - - - -
- }> - - - - ); + // 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), + force_approved: peer.id ? isBypassed(peer.id) : false, + })); + }, [peers, users, isBypassed]); +return ( + <> +
+ + } /> + + +

{t("servers")}

+ + {t("serversDescription")}{" "} + + {t("learnMore")} + + + +
+ }> + + + + ); } function ServersBlockedView() { - return ( -
-
-

Add new server to your network

- - To get started, install NetBird on the server and enroll it using a - setup key. If you have further questions check out our{" "} - - Installation Guide - - - -
-
-
- -
-
-
- ); + const t = useTranslations("peers"); + return ( +
+
+

{t("addNewServerTitle")}

+ + {t("addNewServerDescription")}{" "} + + {t("installationGuide")} + + + +
+
+
+ +
+
+
+ ); } diff --git a/src/app/(dashboard)/peers/users/page.tsx b/src/app/(dashboard)/peers/users/page.tsx index f0997d93a..51bc34c70 100644 --- a/src/app/(dashboard)/peers/users/page.tsx +++ b/src/app/(dashboard)/peers/users/page.tsx @@ -6,6 +6,7 @@ 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 { useBypassedPeers } from "@/cloud/edr/useBypass"; @@ -20,110 +21,106 @@ import { SetupModalContent } from "@/modules/setup-netbird-modal/SetupModal"; const PeersTable = lazy(() => import("@/modules/peers/PeersTable")); export default function UserDevicesPage() { - const { isRestricted } = usePermissions(); - const { isLoading: isDistributorRedirecting } = useDistributorRedirect(); - if (isDistributorRedirecting) return ; +const { isRestricted } = usePermissions(); + const { isLoading: isDistributorRedirecting } = useDistributorRedirect(); + if (isDistributorRedirecting) return ; - return ( - - {isRestricted ? ( - - ) : ( - - - - )} - - ); + return ( + + {isRestricted ? ( + + ) : ( + + + + )} + + ); } function UserDevicesView() { - const { peers, isLoading: isPeersLoading } = usePeers(); - const { users, isLoading: isUsersLoading } = useUsers(); - const { isBypassed } = useBypassedPeers(); - const { ref: headingRef, portalTarget } = - usePortalElement(); +const t = useTranslations("peers"); + const { peers, isLoading: isPeersLoading } = usePeers(); + const { users, isLoading: isUsersLoading } = useUsers(); + const { isBypassed } = useBypassedPeers(); + const { ref: headingRef, portalTarget } = + usePortalElement(); - // 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), - force_approved: peer.id ? isBypassed(peer.id) : false, - })); - }, [peers, users, isBypassed]); + // 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), + force_approved: peer.id ? isBypassed(peer.id) : false, + })); + }, [peers, users, isBypassed]); - return ( - <> -
- - } - /> - - -

User Devices

- - Laptops, phones and other personal devices with a user behind them, - typically added when the user signs in with SSO.{" "} - - Learn more - - - -
- }> - - - - ); + return ( + <> +
+ + } /> + + +

{t("userDevices")}

+ + {t("userDevicesDescription")}{" "} + + {t("learnMore")} + + + +
+ }> + + + + ); } function UserDevicesBlockedView() { - return ( -
-
-

Add new device to your network

- - 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{" "} - - Installation Guide - - - -
-
-
- -
-
-
- ); + const t = useTranslations("peers"); + return ( +
+
+

{t("addNewDeviceTitle")}

+ + {t("addNewDeviceDescription")}{" "} + + {t("installationGuide")} + + + +
+
+
+ +
+
+
+ ); } diff --git a/src/app/(dashboard)/posture-checks/page.tsx b/src/app/(dashboard)/posture-checks/page.tsx index a4a527708..8278005d3 100644 --- a/src/app/(dashboard)/posture-checks/page.tsx +++ b/src/app/(dashboard)/posture-checks/page.tsx @@ -6,72 +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("/posture-checks"); - const { ref: headingRef, portalTarget } = - usePortalElement(); +export default function PostureChecksPage() { + const t = useTranslations("postureChecks"); + const tCommon = useTranslations("common"); + const { permission } = usePermissions(); + const { data: postureChecks, isLoading } = + useFetchApi("/posture-checks"); - return ( - - -
- - } - /> - } - /> - -

Posture Checks

- - Use posture checks to further restrict access in your network.{" "} - - Learn more - - - -
+ const { ref: headingRef, portalTarget } = + usePortalElement(); - - - }> - - - - -
-
- ); + return ( + +
+ + } + active + /> + +

{t("title")}

+ + {t("pageDescription")}{" "} + + {tCommon("learnMore")} + + + +
+ + }> + + + +
+ ); } diff --git a/src/app/(dashboard)/reverse-proxy/clusters/page.tsx b/src/app/(dashboard)/reverse-proxy/clusters/page.tsx index f6582726d..ac1a49b4c 100644 --- a/src/app/(dashboard)/reverse-proxy/clusters/page.tsx +++ b/src/app/(dashboard)/reverse-proxy/clusters/page.tsx @@ -7,56 +7,60 @@ 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"; import { REVERSE_PROXY_CLUSTERS_DOCS_LINK } from "@/interfaces/ReverseProxy"; import PageContainer from "@/layouts/PageContainer"; const ClustersTable = lazy( - () => import("@/modules/reverse-proxy/clusters/ClustersTable"), + () => import("@/modules/reverse-proxy/clusters/ClustersTable"), ); export default function ReverseProxyClustersPage() { - const { permission } = usePermissions(); + const t = useTranslations("reverseProxy"); + const tCommon = useTranslations("common"); + const { permission } = usePermissions(); - const { ref: headingRef, portalTarget } = - usePortalElement(); + const { ref: headingRef, portalTarget } = + usePortalElement(); - return ( - -
- - } - /> - - -

Clusters

- - Proxy clusters that route inbound traffic to your services. Shared - clusters are deployed at the server level; account clusters are - self-hosted on your own infrastructure.{" "} - - Learn more - - - -
- - }> - - - -
- ); + return ( + +
+ + } + /> + + +

{t("clusters")}

+ + {t("clustersDescription")}{" "} + + {tCommon("learnMore")} + + + + + }> + + + + + +
+
+ ); } diff --git a/src/app/(dashboard)/reverse-proxy/custom-domains/page.tsx b/src/app/(dashboard)/reverse-proxy/custom-domains/page.tsx index b73d16e7b..46d6f02c9 100644 --- a/src/app/(dashboard)/reverse-proxy/custom-domains/page.tsx +++ b/src/app/(dashboard)/reverse-proxy/custom-domains/page.tsx @@ -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,52 +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(); + const { ref: headingRef, portalTarget } = + usePortalElement(); - return ( - -
- - } - /> - - -

Domains

- - Add and manage custom domains for your reverse proxy services.{" "} - - Learn more - - - -
- - - }> - - - - -
- ); + return ( + +
+ + } + /> + + +

{t("customDomains")}

+ + {t("customDomainsDescription")}{" "} + + {tCommon("learnMore")} + + + + + }> + + + + + +
+
+ ); } diff --git a/src/app/(dashboard)/reverse-proxy/logs/page.tsx b/src/app/(dashboard)/reverse-proxy/logs/page.tsx index 93de6edb0..365a337d9 100644 --- a/src/app/(dashboard)/reverse-proxy/logs/page.tsx +++ b/src/app/(dashboard)/reverse-proxy/logs/page.tsx @@ -3,75 +3,61 @@ 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 dayjs from "dayjs"; +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 React, { useMemo } from "react"; -import PeersProvider from "@/contexts/PeersProvider"; 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 PageContainer from "@/layouts/PageContainer"; -export default function ProxyLogsPage() { - const { permission } = usePermissions(); - const { ref: headingRef, portalTarget } = - usePortalElement(); - - const defaultFilters = useMemo( - () => ({ - start_date: dayjs().subtract(7, "day").startOf("day").toISOString(), - end_date: dayjs().endOf("day").toISOString(), - sort_by: "timestamp", - sort_order: "desc", - }), - [], - ); - - return ( - -
- - } - /> - } - /> - +const ReverseProxyEventsTable = lazy( + () => import("@/modules/reverse-proxy/events/ReverseProxyEventsTable"), +); -

Access Logs

+export default function ProxyLogsPage() { + const t = useTranslations("reverseProxy"); + const tCommon = useTranslations("common"); + const { permission } = usePermissions(); - - View access logs for your reverse proxy services, including allowed - and denied requests.{" "} - - Learn more - - -
+ const { ref: headingRef, portalTarget } = + usePortalElement(); - - - - - - - -
- ); + return ( + +
+ + } + /> + + +

{t("accessLogs")}

+ + {t("accessLogsDescription")}{" "} + + {tCommon("learnMore")} + + + + + }> + + + +
+
+ ); } diff --git a/src/app/(dashboard)/reverse-proxy/services/page.tsx b/src/app/(dashboard)/reverse-proxy/services/page.tsx index cf2c714aa..691e95bb3 100644 --- a/src/app/(dashboard)/reverse-proxy/services/page.tsx +++ b/src/app/(dashboard)/reverse-proxy/services/page.tsx @@ -7,6 +7,7 @@ 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 React, { lazy, Suspense } from "react"; import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon"; import { usePermissions } from "@/contexts/PermissionsProvider"; @@ -17,63 +18,62 @@ import { Callout } from "@components/Callout"; import { isNetBirdCloud } 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(); + const { ref: headingRef, portalTarget } = + usePortalElement(); - return ( - -
- - } - /> - - -

Services

- - Expose services securely through NetBird's reverse proxy.{" "} - - Learn more - - - + return ( + +
+ + } + /> + + +

{t("services")}

+ + {t("servicesDescription")}{" "} + + {tCommon("learnMore")} + + + - {isNetBirdCloud() ? ( - - 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. - - ) : ( - - NetBird's Reverse Proxy is currently in beta.
Features - and functionality are subject to change upon release. -
- )} -
+{isNetBirdCloud() ? ( + + {t("betaNoticeCloud")} + + ) : ( + + {t("betaNoticeSelfHosted")} + + )} - - - }> - - - - -
- ); + + }> + + + + + +
+
+ ); } diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index f85aa5998..c53896f44 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -3,18 +3,19 @@ import { RestrictedAccess } from "@components/ui/RestrictedAccess"; import { VerticalTabs } from "@components/VerticalTabs"; import { - AlertOctagonIcon, - FingerprintIcon, - FolderGit2Icon, - KeyRound, - LockIcon, - MonitorSmartphoneIcon, - NetworkIcon, - ShieldIcon, + AlertOctagonIcon, + FingerprintIcon, + FolderGit2Icon, + KeyRound, + LanguagesIcon, + 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 { useMSP } from "@/cloud/msp/contexts/MSPProvider"; import { usePermissions } from "@/contexts/PermissionsProvider"; import { useLoggedInUser } from "@/contexts/UsersProvider"; import PageContainer from "@/layouts/PageContainer"; @@ -23,114 +24,113 @@ import AuthenticationTab from "@/modules/settings/AuthenticationTab"; import ClientSettingsTab from "@/modules/settings/ClientSettingsTab"; import DangerZoneTab from "@/modules/settings/DangerZoneTab"; import IdentityProvidersTab from "@/modules/settings/IdentityProvidersTab"; +import LanguageTab from "@/modules/settings/LanguageTab"; import NetworkSettingsTab from "@/modules/settings/NetworkSettingsTab"; import PermissionsTab from "@/modules/settings/PermissionsTab"; import SetupKeysTab from "@/modules/settings/SetupKeysTab"; import GroupsSettings from "@/modules/settings/GroupsSettings"; -import { - CloudSettingsTabContent, - CloudSettingsTabTrigger, -} from "@/cloud/settings/CloudSettings"; 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"; - if (permission?.billing?.update) return "plans-and-billing"; - 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 ( + + + + {permission.settings.read && ( + <> + + + {t("authentication")} + + {permission.setup_keys.read && ( + + + {t("setupKeys")} + + )} + {account?.settings?.embedded_idp_enabled && + permission?.identity_providers?.read && ( + + + {t("identityProviders")} + + )} + + + {t("groupsTab")} + + + + {t("permissions")} + + + + {t("networksTab")} + + + + {t("clients")} + + + + {t("language")} + + + )} - return ( - - - - {permission.settings.read && ( - <> - - - Authentication - - {permission.setup_keys.read && ( - - - Setup Keys - - )} - {account?.settings?.embedded_idp_enabled && - permission?.identity_providers?.read && ( - - - Identity Providers - - )} - - - Groups - - - - Permissions - - - - Networks - - - - Clients - - - )} - - - - -
- {account && } - {permission.setup_keys.read && } - {account?.settings?.embedded_idp_enabled && - permission?.identity_providers?.read && } - {account && } - {account && } - {account && } - {account && } - {account && } - -
-
-
-
- ); + +
+ +
+ {account && } + {permission.setup_keys.read && } + {account?.settings?.embedded_idp_enabled && + permission.identity_providers.read && } + {account && } + {account && } + {account && } + {account && } + + {account && } +
+
+
+
+ ); } const DangerZoneTabTrigger = () => { - const { isOwner } = useLoggedInUser(); - - const { isAccountWithMSPParent } = useMSP(); - if (isAccountWithMSPParent) return; + const t = useTranslations("settings"); + const { isOwner } = useLoggedInUser(); - return ( - isOwner && ( - - - Danger zone - - ) - ); + return ( + isOwner && ( + + + {t("dangerZone")} + + ) + ); }; diff --git a/src/app/(dashboard)/team/service-users/page.tsx b/src/app/(dashboard)/team/service-users/page.tsx index 68f82cc88..ca810f51a 100644 --- a/src/app/(dashboard)/team/service-users/page.tsx +++ b/src/app/(dashboard)/team/service-users/page.tsx @@ -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,59 +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( - "/users?service_user=true", - ); + const t = useTranslations("serviceUsers"); + const tUsers = useTranslations("users"); + const { permission } = usePermissions(); + const { data: users, isLoading } = useFetchApi( + "/users?service_user=true", + ); - const { ref: headingRef, portalTarget } = - usePortalElement(); + const { ref: headingRef, portalTarget } = + usePortalElement(); - return ( - -
- - } - /> - } - /> - -

Service Users

- - Use service users to create API tokens and avoid losing automated - access.{" "} - - Learn more - - - -
- - }> - - - -
- ); + return ( + +
+ + } + /> + } + /> + +

{t("title")}

+ + {t("serviceUsersDescription")}{" "} + + {tUsers("learnMore")} + + + +
+ + }> + + + +
+ ); } diff --git a/src/app/(dashboard)/team/user/page.tsx b/src/app/(dashboard)/team/user/page.tsx index 512c9ddfb..d2497a374 100644 --- a/src/app/(dashboard)/team/user/page.tsx +++ b/src/app/(dashboard)/team/user/page.tsx @@ -18,19 +18,19 @@ import useFetchApi, { useApiCall } from "@utils/api"; import { generateColorFromString } from "@utils/helpers"; import dayjs from "dayjs"; import { - Ban, - GalleryHorizontalEnd, - History, - KeyRoundIcon, - Mail, - MonitorSmartphoneIcon, - User2, + Ban, + GalleryHorizontalEnd, + History, + KeyRoundIcon, + Mail, + MonitorSmartphoneIcon, + User2, } from "lucide-react"; import { useRouter, useSearchParams } from "next/navigation"; import React, { useMemo, useState } from "react"; import { useSWRConfig } from "swr"; +import { useTranslations } from "next-intl"; import TeamIcon from "@/assets/icons/TeamIcon"; -import { UserMfaListItem } from "@/cloud/mfa/UserMFAListItem"; import { usePermissions } from "@/contexts/PermissionsProvider"; import { useLoggedInUser } from "@/contexts/UsersProvider"; import { useHasChanges } from "@/hooks/useHasChanges"; @@ -47,377 +47,368 @@ import { UserPeersSection } from "@/modules/users/UserPeersSection"; import { UserRoleSelector } from "@/modules/users/UserRoleSelector"; export default function UserPage() { - const queryParameter = useSearchParams(); - const userId = queryParameter.get("id"); - const { permission } = usePermissions(); - const isServiceUser = queryParameter.get("service_user") === "true"; - const { data: users, isLoading } = useFetchApi( - `/users?service_user=${isServiceUser}`, - ); - const { isOwnerOrAdmin } = useLoggedInUser(); - - const user = useMemo(() => { - return users?.find((u) => u.id === userId); - }, [users, userId]); - - useRedirect("/team/users", false, !userId); - - const userGroups = useGroupIdsToGroups(user?.auto_groups); - - if (!permission.users.read) { - return ( - - - - ); - } - - if (!isOwnerOrAdmin && user && !isLoading) { - return ; - } - - if (isOwnerOrAdmin && user && !isLoading && userGroups) { - return ; - } - - return ; + const queryParameter = useSearchParams(); + const userId = queryParameter.get("id"); + const { permission } = usePermissions(); + const isServiceUser = queryParameter.get("service_user") === "true"; + const { data: users, isLoading } = useFetchApi( + `/users?service_user=${isServiceUser}`, + ); + const { isOwnerOrAdmin } = useLoggedInUser(); + + const user = useMemo(() => { + return users?.find((u) => u.id === userId); + }, [users, userId]); + + useRedirect("/team/users", false, !userId); + + const userGroups = useGroupIdsToGroups(user?.auto_groups); + + if (!permission.users.read) { + return ( + + + + ); + } + + if (!isOwnerOrAdmin && user && !isLoading) { + return ; + } + + if (isOwnerOrAdmin && user && !isLoading && userGroups) { + return ; + } + + return ; } type Props = { - user: User; - initialGroups: Group[]; + user: User; + initialGroups: Group[]; }; function UserOverview({ user, initialGroups }: Readonly) { - const router = useRouter(); - const userRequest = useApiCall("/users"); - const isServiceUser = !!user?.is_service_user; - const { mutate } = useSWRConfig(); - const { loggedInUser, isOwnerOrAdmin, isUser } = useLoggedInUser(); - const isLoggedInUser = loggedInUser ? loggedInUser?.id === user.id : false; - const { permission } = usePermissions(); - - const [selectedGroups, setSelectedGroups, { save: saveGroups }] = - useGroupHelper({ - initial: initialGroups, - }); - - const [role, setRole] = useState(user.role || Role.User); - const { hasChanges, updateRef: updateChangesRef } = useHasChanges([ - role, - selectedGroups, - ]); - - const save = async () => { - const groups = await saveGroups(); - const groupIds = groups.map((group) => group.id) as string[]; - - notify({ - title: user.name, - description: "Changes successfully saved.", - promise: userRequest - .put( - { - role: role, - auto_groups: groupIds, - is_blocked: user.is_blocked, - }, - `/${user.id}`, - ) - .then(() => { - mutate(`/users?service_user=${isServiceUser}`); - mutate(`/integrations/msp/switcher`); - updateChangesRef([role, selectedGroups]); - }), - loadingMessage: "Saving changes...", - }); - }; - - const isProfilePage = !!user?.is_current && !isServiceUser; - const canViewTokens = permission?.pats?.read; - const canViewPeers = permission?.peers?.read; - - const showAccessTokens = (user?.is_current || isServiceUser) && canViewTokens; - const showPeers = !isServiceUser && canViewPeers; - const showTabs = isProfilePage && showPeers && showAccessTokens; - const showSeparator = !showTabs; - - const [tab, setTab] = useState(isServiceUser ? "access-tokens" : "peers"); - - return ( - -
- - } - /> - - {isServiceUser ? ( - } - /> - ) : ( - } - /> - )} - - - - -
-
-
-
- {isServiceUser ? ( - - ) : ( - user?.name?.charAt(0) || user?.id?.charAt(0) - )} -
-

- {user.name || user.id} -

-
-
- {!isUser && ( -
- - - -
- )} -
- -
- -
- {!isServiceUser && isOwnerOrAdmin && ( -
- - - Groups will be assigned to peers added by this user. - - -
- )} -
-
- - - Set a role for the user to assign access permissions. - -
-
- -
-
-
-
-
- - {showSeparator && } - - - - {showPeers && ( - - - - )} - {showAccessTokens && ( - -
-
-
-
-

Access Tokens

- - Access tokens give access to NetBird API. - -
-
-
- - - -
-
-
- -
-
-
- )} -
-
- ); + const t = useTranslations("users"); + const router = useRouter(); + const userRequest = useApiCall("/users"); + const isServiceUser = !!user?.is_service_user; + const { mutate } = useSWRConfig(); + const { loggedInUser, isOwnerOrAdmin, isUser } = useLoggedInUser(); + const isLoggedInUser = loggedInUser ? loggedInUser?.id === user.id : false; + const { permission } = usePermissions(); + + const [selectedGroups, setSelectedGroups, { save: saveGroups }] = + useGroupHelper({ + initial: initialGroups, + }); + + const [role, setRole] = useState(user.role || Role.User); + const { hasChanges, updateRef: updateChangesRef } = useHasChanges([ + role, + selectedGroups, + ]); + + const save = async () => { + const groups = await saveGroups(); + const groupIds = groups.map((group) => group.id) as string[]; + + notify({ + title: user.name, + description: "Changes successfully saved.", + promise: userRequest + .put( + { + role: role, + auto_groups: groupIds, + is_blocked: user.is_blocked, + }, + `/${user.id}`, + ) + .then(() => { + mutate(`/users?service_user=${isServiceUser}`); + updateChangesRef([role, selectedGroups]); + }), + loadingMessage: "Saving changes...", + }); + }; + + const isProfilePage = !!user?.is_current && !isServiceUser; + const canViewTokens = permission?.pats?.read; + const canViewPeers = permission?.peers?.read; + + const showAccessTokens = (user?.is_current || isServiceUser) && canViewTokens; + const showPeers = !isServiceUser && canViewPeers; + const showTabs = isProfilePage && showPeers && showAccessTokens; + const showSeparator = !showTabs; + + const [tab, setTab] = useState(isServiceUser ? "access-tokens" : "peers"); + + return ( + +
+ + } + /> + + {isServiceUser ? ( + } + /> + ) : ( + } + /> + )} + + + + +
+
+
+
+ {isServiceUser ? ( + + ) : ( + user?.name?.charAt(0) || user?.id?.charAt(0) + )} +
+

+ {user.name || user.id} +

+
+
+ {!isUser && ( +
+ + + +
+ )} +
+ +
+ +
+ {!isServiceUser && isOwnerOrAdmin && ( +
+ + + Groups will be assigned to peers added by this user. + + +
+ )} +
+
+ + {t("userRoleHelp")} +
+
+ +
+
+
+
+
+ + {showSeparator && } + + + + {showPeers && ( + + + + )} + {showAccessTokens && ( + +
+
+
+
+

{t("accessTokens")}

+ {t("accessTokensDescription")} +
+
+
+ + + +
+
+
+ +
+
+
+ )} +
+
+ ); } function UserInformationCard({ user }: Readonly<{ user: User }>) { - const isServiceUser = user.is_service_user || false; - const neverLoggedIn = dayjs(user.last_login).isBefore( - dayjs().subtract(1000, "years"), - ); - const isPendingApproval = user?.pending_approval; - - return ( - - - - - {user.name ? "Name" : "User ID"} - - } - value={user.name || user.id} - /> - - {!isServiceUser && ( - - - E-Mail - - } - value={user.email || "-"} - /> - )} - - - - Status - - } - value={} - /> - - {!isServiceUser && user && } - - {!isServiceUser && ( - <> - {!user.is_current && - user.role != Role.Owner && - !isPendingApproval && ( - - - Block User - - } - value={} - /> - )} - - - - Last login - - } - value={ - neverLoggedIn - ? "Never" - : dayjs(user.last_login).format("D MMMM, YYYY [at] h:mm A") + - " (" + - dayjs().to(user.last_login) + - ")" - } - /> - - )} - - - ); + const isServiceUser = user.is_service_user || false; + const neverLoggedIn = dayjs(user.last_login).isBefore( + dayjs().subtract(1000, "years"), + ); + const isPendingApproval = user?.pending_approval; + + return ( + + + + + {user.name ? "Name" : "User ID"} + + } + value={user.name || user.id} + /> + + {!isServiceUser && ( + + + E-Mail + + } + value={user.email || "-"} + /> + )} + + + + Status + + } + value={} + /> + + {!isServiceUser && ( + <> + {!user.is_current && + user.role != Role.Owner && + !isPendingApproval && ( + + + Block User + + } + value={} + /> + )} + + + + Last login + + } + value={ + neverLoggedIn + ? "Never" + : dayjs(user.last_login).format("D MMMM, YYYY [at] h:mm A") + + " (" + + dayjs().to(user.last_login) + + ")" + } + /> + + )} + + + ); } diff --git a/src/app/(dashboard)/team/users/page.tsx b/src/app/(dashboard)/team/users/page.tsx index 5bff477d1..15e9618ae 100644 --- a/src/app/(dashboard)/team/users/page.tsx +++ b/src/app/(dashboard)/team/users/page.tsx @@ -9,73 +9,75 @@ import { usePortalElement } from "@hooks/usePortalElement"; import useFetchApi from "@utils/api"; import { isNetBirdCloud } from "@utils/netbird"; import { ExternalLinkIcon, User2 } from "lucide-react"; -import React, { lazy, Suspense } from "react"; +import { useTranslations } from "next-intl"; +import { lazy, Suspense } from "react"; import TeamIcon from "@/assets/icons/TeamIcon"; -import { AccountMfaCard } from "@/cloud/mfa/AccountMFACard"; import { useGroups } from "@/contexts/GroupsProvider"; import { usePermissions } from "@/contexts/PermissionsProvider"; import { User } from "@/interfaces/User"; import PageContainer from "@/layouts/PageContainer"; +import { AccountMfaCard } from "@/cloud/mfa/AccountMFACard"; import { IdentityProviderCard } from "@/modules/integrations/idp-sync/IdentityProviderCard"; const UsersTable = lazy(() => import("@/modules/users/UsersTable")); export default function TeamUsers() { - const { isLoading: isGroupsLoading } = useGroups(); - const { permission } = usePermissions(); - const { data: users, isLoading } = useFetchApi( - "/users?service_user=false", - ); + const t = useTranslations("users"); + const { isLoading: isGroupsLoading } = useGroups(); + const { permission } = usePermissions(); + const { data: users, isLoading } = useFetchApi( + "/users?service_user=false", + ); + + const { ref: headingRef, portalTarget } = + usePortalElement(); - const { ref: headingRef, portalTarget } = - usePortalElement(); - return ( - -
- - } - /> - } - /> - -

Users

- - Manage users and their permissions. Same-domain email users are added - automatically on first sign-in.{" "} - - Learn more - - - -
- - }> - {permission.settings.read && ( -
- {(permission?.idp?.read || !isNetBirdCloud()) && ( - - )} - -
- )} - -
-
-
- ); + return ( + +
+ + } + /> + } + /> + +

{t("title")}

+ + {t("usersPageDescription")} {" "} + + {t("learnMore")} + + + +
+ + }> + {permission.settings.read && ( +
+ {(permission?.idp?.read || !isNetBirdCloud()) && ( + + )} + +
+ )} + +
+
+
+ ); } diff --git a/src/app/error/page.tsx b/src/app/error/page.tsx index 72a6a3f2c..40376edb5 100644 --- a/src/app/error/page.tsx +++ b/src/app/error/page.tsx @@ -5,6 +5,7 @@ import Button from "@components/Button"; import Paragraph from "@components/Paragraph"; import loadConfig from "@utils/config"; import { ArrowRightIcon, RefreshCw } from "lucide-react"; +import { useTranslations } from "next-intl"; import { useRouter, useSearchParams } from "next/navigation"; import { useEffect, useState } from "react"; import NetBirdIcon from "@/assets/icons/NetBirdIcon"; @@ -15,6 +16,9 @@ export default function ErrorPage() { const { logout, isAuthenticated } = useOidc(); const router = useRouter(); const searchParams = useSearchParams(); + const t = useTranslations("errors"); + const tAuth = useTranslations("auth"); + const tCommon = useTranslations("common"); const [error, setError] = useState<{ code: number; message: string; @@ -58,19 +62,19 @@ export default function ErrorPage() { error?.message?.toLowerCase().includes("pending approval"); const getTitle = () => { - if (isBlockedUser) return "User Account Blocked"; - if (isPendingApproval) return "User Approval Pending"; - return "Access Error"; + if (isBlockedUser) return t("userAccountBlocked"); + if (isPendingApproval) return t("userApprovalPending"); + return t("accessError"); }; const getDescription = () => { if (isBlockedUser) { - return "Your access has been blocked by the NetBird account administrator, possibly due to new user approval requirements or security policies. Please contact your administrator to regain access."; + return t("accessBlockedDescription"); } if (isPendingApproval) { - return "Your account is pending approval from an administrator. Please wait for approval before accessing the dashboard."; + return t("pendingApprovalDescription"); } - return "An error occurred while trying to access the dashboard. Please try again or contact your administrator."; + return t("accessGenericDescription"); }; return ( @@ -94,19 +98,19 @@ export default function ErrorPage() { )} - If you believe this is an error, please contact your administrator. + {t("contactAdminDescription")}
{!isBlockedUser && !isPendingApproval && ( )}
diff --git a/src/auth/SessionLost.tsx b/src/auth/SessionLost.tsx index 72b6ce3c9..69a745683 100644 --- a/src/auth/SessionLost.tsx +++ b/src/auth/SessionLost.tsx @@ -4,6 +4,7 @@ import Paragraph from "@components/Paragraph"; import loadConfig from "@utils/config"; import { LogIn } from "lucide-react"; import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; import * as React from "react"; import { useEffect } from "react"; import NetBirdIcon from "@/assets/icons/NetBirdIcon"; @@ -11,40 +12,40 @@ import NetBirdIcon from "@/assets/icons/NetBirdIcon"; const config = loadConfig(); export const SessionLost = () => { - const router = useRouter(); - const { logout } = useOidc(); + const t = useTranslations("auth"); + const router = useRouter(); + const { logout } = useOidc(); - useEffect(() => { - router.push("/peers"); - }); + useEffect(() => { + router.push("/peers"); + }); - return ( -
-
- -
-

Session Expired

- - It looks like your login session is no longer active or has expired. - Please login again to continue using the app. - - -
- ); + return ( +
+
+ +
+

{t("sessionExpired")}

+ + {t("sessionExpiredDescription")} + + +
+ ); }; diff --git a/src/cloud/invoices/InvoicesTab.tsx b/src/cloud/invoices/InvoicesTab.tsx index 85fed2652..0dc7e5243 100644 --- a/src/cloud/invoices/InvoicesTab.tsx +++ b/src/cloud/invoices/InvoicesTab.tsx @@ -1,3 +1,4 @@ +import { useTranslations } from "next-intl"; import Breadcrumbs from "@components/Breadcrumbs"; import Paragraph from "@components/Paragraph"; import SkeletonTable, { @@ -29,6 +30,7 @@ export const InvoicesTab = () => { export const InvoicesTabTrigger = () => { const { permission } = usePermissions(); + const t = useTranslations("invoices"); const { isAccountWithMSPParent } = useMSP(); if (isAccountWithMSPParent) return; @@ -41,13 +43,15 @@ export const InvoicesTabTrigger = () => { data-testid="settings-tab-invoices" > - Invoices + {t("title")} ) ); }; const InvoicesTabContent = () => { + const t = useTranslations("invoices"); + const tc = useTranslations("common"); const { isActive: isDistributor } = useDistributor(); const apiPath = isDistributor ? "/integrations/msp/reseller/invoices" @@ -72,12 +76,12 @@ const InvoicesTabContent = () => { } /> } active /> @@ -86,8 +90,8 @@ const InvoicesTabContent = () => {
-

Invoices

- View and export all your available invoices +

{t("title")}

+ {t("description")}
) { + const t = useTranslations("invoices"); const { isActive: isDistributor } = useDistributor(); const apiRequestPath = isDistributor ? "/integrations/msp/reseller/invoices" @@ -49,9 +51,9 @@ export default function InvoicesActionCell({ invoice }: Readonly) { }); notify({ - title: `Download Invoice (CSV)`, + title: t("downloadInvoiceCSV"), description: `Downloading ${invoice.id}.csv...`, - loadingMessage: `Getting invoice for this billing period...`, + loadingMessage: t("gettingInvoice"), promise, }); return promise; @@ -60,9 +62,9 @@ export default function InvoicesActionCell({ invoice }: Readonly) { const redirectToStripe = async () => { let promise = pdfInvoiceRequest.get(`/${invoice?.id}/pdf`); notify({ - title: `Download Invoice (PDF)`, - description: `Redirecting to Stripe to download the invoice...`, - loadingMessage: `Getting invoice for this billing period...`, + title: t("downloadInvoicePDF"), + description: t("redirectingToStripe"), + loadingMessage: t("gettingInvoice"), promise, }); return promise; @@ -92,14 +94,14 @@ export default function InvoicesActionCell({ invoice }: Readonly) { >
- Download as PDF + {t("downloadPDF")}
- Download as CSV + {t("downloadCSV")}
diff --git a/src/cloud/invoices/table/InvoicesTable.tsx b/src/cloud/invoices/table/InvoicesTable.tsx index 2bf6cf189..b41a6669c 100644 --- a/src/cloud/invoices/table/InvoicesTable.tsx +++ b/src/cloud/invoices/table/InvoicesTable.tsx @@ -1,3 +1,4 @@ +import { useTranslations } from "next-intl"; import Card from "@components/Card"; import { DataTable } from "@components/table/DataTable"; import DataTableHeader from "@components/table/DataTableHeader"; @@ -21,37 +22,40 @@ type Props = { headingTarget?: HTMLHeadingElement | null; }; -const InvoicesColumns: ColumnDef[] = [ - { - header: ({ column }) => { - return Date; +function getInvoiceColumns(t: (key: string) => string): ColumnDef[] { + return [ + { + header: ({ column }) => { + return {t("date")}; + }, + accessorKey: "period_start", + cell: ({ row }) => , + }, + { + accessorKey: "period_end", }, - accessorKey: "period_start", - cell: ({ row }) => , - }, - { - accessorKey: "period_end", - }, - { - header: ({ column }) => { - return Type; + { + header: ({ column }) => { + return {t("type")}; + }, + accessorKey: "type", + cell: ({ row }) => , }, - accessorKey: "type", - cell: ({ row }) => , - }, - { - accessorKey: "id", - header: () => null, - sortingFn: "text", - cell: ({ row }) => , - }, -]; + { + accessorKey: "id", + header: () => null, + sortingFn: "text", + cell: ({ row }) => , + }, + ]; +} export default function InvoicesTable({ invoices, isLoading, headingTarget, }: Readonly) { + const t = useTranslations("invoices"); const { isActive: isDistributor } = useDistributor(); const apiRequestPath = isDistributor ? "/integrations/msp/reseller/invoices" @@ -79,19 +83,17 @@ export default function InvoicesTable({ tableClassName={"mt-0"} tableCellClassName={""} rowClassName={"last:mb-5"} - text={"Invoices"} - columns={InvoicesColumns} + text={t("title")} + columns={getInvoiceColumns(t)} keepStateInLocalStorage={false} data={invoices} - searchPlaceholder={"Search by invoice number or date..."} + searchPlaceholder={t("searchPlaceholder")} isLoading={isLoading} getStartedCard={ } /> } diff --git a/src/cloud/invoices/table/InvoicesTypeCell.tsx b/src/cloud/invoices/table/InvoicesTypeCell.tsx index 56b55b62b..215c3d602 100644 --- a/src/cloud/invoices/table/InvoicesTypeCell.tsx +++ b/src/cloud/invoices/table/InvoicesTypeCell.tsx @@ -1,3 +1,4 @@ +import { useTranslations } from "next-intl"; import { UserIcon, UsersIcon } from "lucide-react"; import React from "react"; import { useDistributor } from "@/cloud/distributor/contexts/DistributorProvider"; @@ -8,6 +9,7 @@ type Props = { }; export default function InvoicesTypeCell({ invoice }: Readonly) { + const t = useTranslations("invoices"); const { isActive: isDistributor } = useDistributor(); const { type } = invoice; return ( @@ -15,12 +17,12 @@ export default function InvoicesTypeCell({ invoice }: Readonly) { {type == "account" ? ( <> - Account + {t("account")} ) : ( <> - {isDistributor ? "Customers" : "Tenants"} + {isDistributor ? t("customers") : t("tenants")} )}
diff --git a/src/cloud/notifications/NotificationChannelListItem.tsx b/src/cloud/notifications/NotificationChannelListItem.tsx index 7470304c2..919c95770 100644 --- a/src/cloud/notifications/NotificationChannelListItem.tsx +++ b/src/cloud/notifications/NotificationChannelListItem.tsx @@ -1,3 +1,4 @@ +import { useTranslations } from "next-intl"; import React, { useEffect, useRef, useState } from "react"; import { ChevronRight, GlobeIcon, Loader2, MailIcon } from "lucide-react"; import { cn } from "@utils/helpers"; @@ -17,36 +18,39 @@ type NotificationChannelListItemProps = { disabled?: boolean; }; -const channelMeta: Record< +const channelMeta = ( + t: ReturnType, +): Record< NotificationChannelType, { name: string; icon: React.ReactNode } -> = { - [NotificationChannelType.Email]: { name: "Email", icon: }, - [NotificationChannelType.Webhook]: { name: "Webhook", icon: }, - [NotificationChannelType.Slack]: { name: "Slack", icon: }, -}; +> => ({ + [NotificationChannelType.Email]: { name: t("email"), icon: }, + [NotificationChannelType.Webhook]: { name: t("webhook"), icon: }, + [NotificationChannelType.Slack]: { name: t("slack"), icon: }, +}); const getChannelDescription = ( type: NotificationChannelType, - channel?: NotificationChannel, + channel: NotificationChannel | undefined, + t: ReturnType, ) => { - if (!channel) return "Disabled"; - if (!channel.enabled) return "Disabled"; + if (!channel) return t("disabled"); + if (!channel.enabled) return t("disabled"); const totalTypes = ALL_NOTIFICATION_EVENT_TYPES.length; const activeTypes = channel.event_types.length; const notificationLabel = activeTypes === totalTypes - ? "All Notifications" - : `${activeTypes} of ${totalTypes} Notifications`; + ? t("allNotifications") + : t("notificationsCount", { active: activeTypes, total: totalTypes }); if (type === NotificationChannelType.Email) { const emails = (channel.target as NotificationEmailChannel)?.emails ?? []; - return `Enabled · ${notificationLabel} · ${emails.length} Recipient${ - emails.length !== 1 ? "s" : "" + return `${t("enabled")} · ${notificationLabel} · ${emails.length} ${ + emails.length !== 1 ? t("recipients") : t("recipient") }`; } - return `Enabled · ${notificationLabel}`; + return `${t("enabled")} · ${notificationLabel}`; }; export const NotificationChannelListItem = ({ @@ -59,9 +63,10 @@ export const NotificationChannelListItem = ({ const [isCreating, setIsCreating] = useState(false); const [showLoading, setShowLoading] = useState(false); const timerRef = useRef>(undefined); - const meta = channelMeta[type]; + const t = useTranslations("notifications"); + const meta = channelMeta(t)[type]; const active = channel?.enabled ?? false; - const description = getChannelDescription(type, channel); + const description = getChannelDescription(type, channel, t); useEffect(() => { return () => clearTimeout(timerRef.current); diff --git a/src/cloud/notifications/NotificationEventTypes.tsx b/src/cloud/notifications/NotificationEventTypes.tsx index a8e4359ee..73e435f8a 100644 --- a/src/cloud/notifications/NotificationEventTypes.tsx +++ b/src/cloud/notifications/NotificationEventTypes.tsx @@ -1,3 +1,4 @@ +import { useTranslations } from "next-intl"; import * as React from "react"; import { Label } from "@components/Label"; import PeerIcon from "@/assets/icons/PeerIcon"; @@ -14,77 +15,79 @@ type EventTypeMeta = { group: "peer" | "user" | "integration"; }; -const EVENT_TYPE_METADATA: EventTypeMeta[] = [ +const getEventTypeMetadata = ( + t: ReturnType, +): EventTypeMeta[] => [ { key: NotificationEventType.PeerPendingApproval, - label: "Pending Approval", - helpText: "Notify when a peer is waiting for approval to join the network", + label: t("pendingApproval"), + helpText: t("peerPendingApprovalHelp"), group: "peer", }, { key: NotificationEventType.PeerAdd, - label: "Peer Added", - helpText: "Notify when a new peer is added to the network", + label: t("peerAdded"), + helpText: t("peerAddedHelp"), group: "peer", }, { key: NotificationEventType.RoutingPeerDisconnect, - label: "Routing Peer Disconnected", - helpText: "Notify when a routing peer loses its connection", + label: t("routingPeerDisconnected"), + helpText: t("routingPeerDisconnectedHelp"), group: "peer", }, { key: NotificationEventType.RoutingPeerDelete, - label: "Routing Peer Deleted", - helpText: "Notify when a routing peer is deleted from the network", + label: t("routingPeerDeleted"), + helpText: t("routingPeerDeletedHelp"), group: "peer", }, { key: NotificationEventType.UserPendingApproval, - label: "User Pending Approval", - helpText: "Notify when a user is waiting for approval to join the network", + label: t("userPendingApproval"), + helpText: t("userPendingApprovalHelp"), group: "user", }, { key: NotificationEventType.UserJoin, - label: "User Joined", - helpText: "Notify when a new user joins the account", + label: t("userJoined"), + helpText: t("userJoinedHelp"), group: "user", }, { key: NotificationEventType.ServiceUserCreate, - label: "Service User Created", - helpText: "Notify when a new service user is created", + label: t("serviceUserCreated"), + helpText: t("serviceUserCreatedHelp"), group: "user", }, { key: NotificationEventType.IdpSyncTokenExpire, - label: "IdP Sync Token Expired", - helpText: "Notify when the IdP sync token has expired and needs renewal", + label: t("idpSyncTokenExpired"), + helpText: t("idpSyncTokenExpiredHelp"), group: "integration", }, { key: NotificationEventType.EdrSyncTokenExpire, - label: "EDR Sync Token Expired", - helpText: "Notify when the EDR sync token has expired and needs renewal", + label: t("edrSyncTokenExpired"), + helpText: t("edrSyncTokenExpiredHelp"), group: "integration", }, ]; -const GROUP_CONFIG = { +const getGroupConfig = (t: ReturnType) => ({ peer: { - label: "Peer Notifications", + label: t("peerNotifications"), icon: , }, user: { - label: "User Notifications", + label: t("userNotifications"), icon: , }, integration: { - label: "Integration Notifications", + label: t("integrationNotifications"), icon: , }, -} as const; +} as const); const GROUPS: Array<"peer" | "user" | "integration"> = [ "peer", @@ -99,11 +102,15 @@ type Props = { }; export const NotificationEventTypes = ({ event_types, onToggle, disabled }: Props) => { + const t = useTranslations("notifications"); + const EVENT_TYPE_METADATA = getEventTypeMetadata(t); + const GROUP_CONFIG = getGroupConfig(t); + return ( <> {GROUPS.map((group) => { const config = GROUP_CONFIG[group]; - const types = EVENT_TYPE_METADATA.filter((t) => t.group === group); + const types = EVENT_TYPE_METADATA.filter((type) => type.group === group); return (