Compare commits
111 Commits
v2.27.0
...
rainycy-sn
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
783294ce55 | ||
|
|
3cb8244d77 | ||
|
|
1e0b124a03 | ||
|
|
9d1cd3e189 | ||
|
|
3b870a0e74 | ||
|
|
9bbbeead1a | ||
|
|
fdc7af186e | ||
|
|
f9326f65c5 | ||
|
|
722c9fb8bf | ||
|
|
249da7788a | ||
|
|
9cd0d6ccbe | ||
|
|
e89c56b8ec | ||
|
|
11ca0c64a4 | ||
|
|
c8eec5a818 | ||
|
|
8c22e64d02 | ||
|
|
c0fe4d07e4 | ||
|
|
0ccc0f93f3 | ||
|
|
fcb1e8c4d4 | ||
|
|
acf427db4d | ||
|
|
8e40fd8d84 | ||
|
|
dc9c9d9735 | ||
|
|
312c32f6ea | ||
|
|
055252d68b | ||
|
|
7653e3411c | ||
|
|
86b6e23981 | ||
|
|
bddc357b0e | ||
|
|
d894c7440f | ||
|
|
389a0c0785 | ||
|
|
5c4df7aeb1 | ||
|
|
50c2ffac0b | ||
|
|
77b413d960 | ||
|
|
2dc523ca9e | ||
|
|
96f15d7b48 | ||
|
|
c05a3ee051 | ||
|
|
98a7923400 | ||
|
|
6c363b681d | ||
|
|
4bd5eda04b | ||
|
|
21e4ceb11f | ||
|
|
bb6c3043f2 | ||
|
|
da01b5bd93 | ||
|
|
27fe31099a | ||
|
|
c4469af733 | ||
|
|
bd2c5ce473 | ||
|
|
f2fc11b89e | ||
|
|
bf6601782d | ||
|
|
5eac5162e0 | ||
|
|
1d8d32dd12 | ||
|
|
578d890bb5 | ||
|
|
e8f0f20455 | ||
|
|
8c94090e3d | ||
|
|
1917df6f60 | ||
|
|
358b477ded | ||
|
|
f535fe2667 | ||
|
|
52f7020a0f | ||
|
|
a604643f9a | ||
|
|
42cd088c5d | ||
|
|
7400ac806e | ||
|
|
240ff5af9a | ||
|
|
dc86c30463 | ||
|
|
e58f75ae3c | ||
|
|
dc1adebd27 | ||
|
|
d76cbd1122 | ||
|
|
01330e0f58 | ||
|
|
e9ac1a1a23 | ||
|
|
b53802a5c5 | ||
|
|
9addc18956 | ||
|
|
9701e6503b | ||
|
|
0841caecbb | ||
|
|
c7846760d1 | ||
|
|
8c283b6ef9 | ||
|
|
34ae3b4da6 | ||
|
|
aff2365ef7 | ||
|
|
bad057d415 | ||
|
|
4d846e2c94 | ||
|
|
15fb6e0b05 | ||
|
|
55c5525626 | ||
|
|
c0c1f4688e | ||
|
|
b5a8f751ba | ||
|
|
10a8e7b745 | ||
|
|
60e8394010 | ||
|
|
9420214059 | ||
|
|
b949f60afe | ||
|
|
d498e4cc25 | ||
|
|
130dc0c32c | ||
|
|
f5824d6ddb | ||
|
|
829395f908 | ||
|
|
8eebec78b4 | ||
|
|
3e01a6dafd | ||
|
|
1555b94043 | ||
|
|
6c62127d42 | ||
|
|
b71d0fde89 | ||
|
|
84c239ce30 | ||
|
|
ba66201c64 | ||
|
|
c6341e000f | ||
|
|
750f660bcc | ||
|
|
ea148545e8 | ||
|
|
d2febbf27b | ||
|
|
615b4487ad | ||
|
|
a7c7800916 | ||
|
|
3d51e0893e | ||
|
|
d7d44b5817 | ||
|
|
f67f39b68b | ||
|
|
d2bc7a1f57 | ||
|
|
818ba5daa4 | ||
|
|
3a30f76629 | ||
|
|
34dc21c89d | ||
|
|
2e37703622 | ||
|
|
8aec338c43 | ||
|
|
f4f0c240fd | ||
|
|
04e22a3c7e | ||
|
|
54ef076303 |
85
.claude/skills/gitnexus/gitnexus-cli/SKILL.md
Normal file
85
.claude/skills/gitnexus/gitnexus-cli/SKILL.md
Normal file
@@ -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 <command>` — 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 <model>` | LLM model (default: minimax/minimax-m2.5) |
|
||||
| `--base-url <url>` | LLM API base URL |
|
||||
| `--api-key <key>` | LLM API key |
|
||||
| `--concurrency <n>` | 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
|
||||
89
.claude/skills/gitnexus/gitnexus-debugging/SKILL.md
Normal file
89
.claude/skills/gitnexus/gitnexus-debugging/SKILL.md
Normal file
@@ -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: "<error or symptom>"}) → Find related execution flows
|
||||
2. context({name: "<suspect>"}) → 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
|
||||
```
|
||||
78
.claude/skills/gitnexus/gitnexus-exploring/SKILL.md
Normal file
78
.claude/skills/gitnexus/gitnexus-exploring/SKILL.md
Normal file
@@ -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: "<what you want to understand>"}) → Find related execution flows
|
||||
4. context({name: "<symbol>"}) → 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
|
||||
```
|
||||
64
.claude/skills/gitnexus/gitnexus-guide/SKILL.md
Normal file
64
.claude/skills/gitnexus/gitnexus-guide/SKILL.md
Normal file
@@ -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
|
||||
```
|
||||
97
.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md
Normal file
97
.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md
Normal file
@@ -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
|
||||
```
|
||||
121
.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md
Normal file
121
.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md
Normal file
@@ -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
|
||||
```
|
||||
19
.github/pull_request_template.md
vendored
Normal file
19
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
## Issue ticket number and link
|
||||
|
||||
## Documentation
|
||||
Select exactly one:
|
||||
|
||||
- [ ] I added/updated documentation for this change
|
||||
- [ ] Documentation is **not needed** for this change (explain why)
|
||||
|
||||
### Docs PR URL (required if "docs added" is checked)
|
||||
Paste the PR link from https://github.com/netbirdio/docs here:
|
||||
|
||||
https://github.com/netbirdio/docs/pull/__
|
||||
|
||||
## E2E tests
|
||||
Optional: override the image tags used by the Playwright e2e workflow.
|
||||
Defaults to `main` when omitted.
|
||||
|
||||
management-cloud-tag: main
|
||||
reverse-proxy-tag: main
|
||||
68
.github/workflows/build_and_push.yml
vendored
68
.github/workflows/build_and_push.yml
vendored
@@ -7,19 +7,26 @@ on:
|
||||
- "**"
|
||||
pull_request:
|
||||
|
||||
# Cancel in-progress runs on the same ref (PR or branch) when a new commit
|
||||
# arrives, so we don't waste CI building superseded commits.
|
||||
concurrency:
|
||||
group: build-and-push-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
IMAGE_NAME: netbirdio/dashboard
|
||||
DOCKERHUB_IMAGE: netbirdio/dashboard
|
||||
GHCR_IMAGE: ghcr.io/netbirdio/dashboard-cloud
|
||||
|
||||
jobs:
|
||||
build_n_push:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: setup-node
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '18'
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -54,29 +61,58 @@ jobs:
|
||||
fileName: "ironrdp_web_bg.wasm"
|
||||
out-file-path: 'public/ironrdp-pkg'
|
||||
|
||||
- name: Get version from tag
|
||||
id: version
|
||||
run: |
|
||||
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
|
||||
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "version=development" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
env:
|
||||
NEXT_PUBLIC_DASHBOARD_VERSION: ${{ steps.version.outputs.version }}
|
||||
-
|
||||
name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
uses: docker/setup-qemu-action@v3
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGE_NAME }}
|
||||
-
|
||||
name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
images: |
|
||||
${{ env.DOCKERHUB_IMAGE }}
|
||||
${{ env.GHCR_IMAGE }}
|
||||
flavor: |
|
||||
latest=false
|
||||
tags: |
|
||||
type=schedule
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
type=ref,event=pr
|
||||
type=sha
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.NB_DOCKER_USER }}
|
||||
password: ${{ secrets.NB_DOCKER_TOKEN }}
|
||||
-
|
||||
name: Docker build and push
|
||||
uses: docker/build-push-action@v3
|
||||
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
@@ -84,3 +120,7 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64,linux/arm
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- run: |
|
||||
echo '### Pushed tags' >> $GITHUB_STEP_SUMMARY
|
||||
echo '${{ steps.meta.outputs.tags }}' >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
105
.github/workflows/docs-ack.yml
vendored
Normal file
105
.github/workflows/docs-ack.yml
vendored
Normal file
@@ -0,0 +1,105 @@
|
||||
name: Docs Acknowledgement
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, synchronize]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
docs-ack:
|
||||
name: Require docs PR URL or explicit "not needed"
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Read PR body
|
||||
id: body
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BODY_B64=$(jq -r '.pull_request.body // "" | @base64' "$GITHUB_EVENT_PATH")
|
||||
{
|
||||
echo "body_b64=$BODY_B64"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate checkbox selection
|
||||
id: validate
|
||||
shell: bash
|
||||
env:
|
||||
BODY_B64: ${{ steps.body.outputs.body_b64 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! body="$(printf '%s' "$BODY_B64" | base64 -d)"; then
|
||||
echo "::error::Failed to decode PR body from base64. Data may be corrupted or missing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
added_checked=$(printf '%s' "$body" | grep -Ei '^[[:space:]]*-\s*\[x\]\s*I added/updated documentation' | wc -l | tr -d '[:space:]' || true)
|
||||
noneed_checked=$(printf '%s' "$body" | grep -Ei '^[[:space:]]*-\s*\[x\]\s*Documentation is \*\*not needed\*\*' | wc -l | tr -d '[:space:]' || true)
|
||||
|
||||
total=$((added_checked + noneed_checked))
|
||||
if [ "$total" -ne 1 ]; then
|
||||
echo "::error::You must check exactly one docs option in the PR template (either 'docs added' OR 'not needed')."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$added_checked" -eq 1 ]; then
|
||||
echo "mode=added" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "mode=noneed" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Extract docs PR URL (when 'docs added')
|
||||
if: steps.validate.outputs.mode == 'added'
|
||||
id: extract
|
||||
shell: bash
|
||||
env:
|
||||
BODY_B64: ${{ steps.body.outputs.body_b64 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
body="$(printf '%s' "$BODY_B64" | base64 -d)"
|
||||
|
||||
# Strictly require HTTPS and that it's a PR in netbirdio/docs
|
||||
# e.g., https://github.com/netbirdio/docs/pull/1234
|
||||
url="$(printf '%s' "$body" | grep -Eo 'https://github\.com/netbirdio/docs/pull/[0-9]+' | head -n1 || true)"
|
||||
|
||||
if [ -z "${url:-}" ]; then
|
||||
echo "::error::You checked 'docs added' but didn't include a valid HTTPS PR link to netbirdio/docs (e.g., https://github.com/netbirdio/docs/pull/1234)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
pr_number="$(printf '%s' "$url" | sed -E 's#.*/pull/([0-9]+)$#\1#')"
|
||||
{
|
||||
echo "url=$url"
|
||||
echo "pr_number=$pr_number"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify docs PR exists (and is open or merged)
|
||||
if: steps.validate.outputs.mode == 'added'
|
||||
uses: actions/github-script@v7
|
||||
id: verify
|
||||
env:
|
||||
PR_NUMBER: ${{ steps.extract.outputs.pr_number }}
|
||||
with:
|
||||
script: |
|
||||
const prNumber = parseInt(process.env.PR_NUMBER, 10);
|
||||
const { data } = await github.rest.pulls.get({
|
||||
owner: 'netbirdio',
|
||||
repo: 'docs',
|
||||
pull_number: prNumber
|
||||
});
|
||||
|
||||
// Allow open or merged PRs
|
||||
const ok = data.state === 'open' || data.merged === true;
|
||||
core.setOutput('state', data.state);
|
||||
core.setOutput('merged', String(!!data.merged));
|
||||
if (!ok) {
|
||||
core.setFailed(`Docs PR #${prNumber} exists but is neither open nor merged (state=${data.state}, merged=${data.merged}).`);
|
||||
}
|
||||
result-encoding: string
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: All good
|
||||
run: echo "Documentation requirement satisfied ✅"
|
||||
162
.github/workflows/e2e-test.yml
vendored
Normal file
162
.github/workflows/e2e-test.yml
vendored
Normal file
@@ -0,0 +1,162 @@
|
||||
name: Playwright E2E Tests
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
# `edited` is included so that updating the PR description (e.g. to set
|
||||
# `management-cloud-tag: <tag>` or `reverse-proxy-tag: <tag>`) re-triggers
|
||||
# the e2e run with the new tag.
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
management-cloud-tag:
|
||||
description: 'Management Cloud image tag'
|
||||
required: true
|
||||
type: string
|
||||
default: 'main'
|
||||
reverse-proxy-tag:
|
||||
description: 'Reverse Proxy image tag'
|
||||
required: true
|
||||
type: string
|
||||
default: 'main'
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
|
||||
jobs:
|
||||
playwright-run:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.ref }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: setup-node
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.CI_DOCKER_PULL_GITHUB_TOKEN }}
|
||||
|
||||
- run: echo '{}' > .local-config.json
|
||||
|
||||
- name: Install jq
|
||||
run: sudo apt-get install jq
|
||||
|
||||
- name: Resolve management-cloud image tag
|
||||
id: management_tag
|
||||
env:
|
||||
INPUT_TAG: ${{ inputs.management-cloud-tag }}
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
run: |
|
||||
# Use workflow_dispatch input if provided, otherwise parse PR body.
|
||||
# Falls back to `main` when not specified.
|
||||
if [ -n "$INPUT_TAG" ]; then
|
||||
TAG="$INPUT_TAG"
|
||||
else
|
||||
TAG=$(printf '%s' "$PR_BODY" \
|
||||
| grep -iE '^[[:space:]]*management-cloud-tag:[[:space:]]*[A-Za-z0-9._-]+[[:space:]]*$' \
|
||||
| head -n1 \
|
||||
| sed -E 's/^[[:space:]]*[Mm]anagement-cloud-tag:[[:space:]]*([A-Za-z0-9._-]+)[[:space:]]*$/\1/' \
|
||||
|| true)
|
||||
if [ -z "$TAG" ]; then
|
||||
TAG="main"
|
||||
fi
|
||||
fi
|
||||
echo "Using management-cloud tag: $TAG"
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Resolve reverse-proxy image tag
|
||||
id: reverse_proxy_tag
|
||||
env:
|
||||
INPUT_TAG: ${{ inputs.reverse-proxy-tag }}
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
run: |
|
||||
if [ -n "$INPUT_TAG" ]; then
|
||||
TAG="$INPUT_TAG"
|
||||
else
|
||||
TAG=$(printf '%s' "$PR_BODY" \
|
||||
| grep -iE '^[[:space:]]*reverse-proxy-tag:[[:space:]]*[A-Za-z0-9._-]+[[:space:]]*$' \
|
||||
| head -n1 \
|
||||
| sed -E 's/^[[:space:]]*[Rr]everse-proxy-tag:[[:space:]]*([A-Za-z0-9._-]+)[[:space:]]*$/\1/' \
|
||||
|| true)
|
||||
if [ -z "$TAG" ]; then
|
||||
TAG="main"
|
||||
fi
|
||||
fi
|
||||
echo "Using reverse-proxy tag: $TAG"
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup test environment
|
||||
env:
|
||||
MANAGEMENT_IMAGE_TAG: ${{ steps.management_tag.outputs.tag }}
|
||||
REVERSE_PROXY_IMAGE_TAG: ${{ steps.reverse_proxy_tag.outputs.tag }}
|
||||
run: cd ./e2e/environment && bash create-test-env.sh
|
||||
|
||||
- name: Run Playwright tests
|
||||
id: playwright
|
||||
run: |
|
||||
set -o pipefail
|
||||
npm run test:ci 2>&1 | tee playwright-output.log
|
||||
|
||||
- name: Append Playwright summary to job summary
|
||||
if: always() && hashFiles('e2e/test-results/results.json') != ''
|
||||
run: |
|
||||
if [ -f e2e/test-results/results.json ]; then
|
||||
passed=$(jq '.stats.expected // 0' e2e/test-results/results.json)
|
||||
failed=$(jq '.stats.unexpected // 0' e2e/test-results/results.json)
|
||||
skipped=$(jq '.stats.skipped // 0' e2e/test-results/results.json)
|
||||
duration=$(jq '.stats.duration // 0' e2e/test-results/results.json)
|
||||
{
|
||||
echo '### Playwright results'
|
||||
echo ''
|
||||
echo "| Passed | Failed | Skipped | Duration |"
|
||||
echo "|--------|--------|---------|----------|"
|
||||
echo "| $passed | $failed | $skipped | ${duration}ms |"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Collect container logs
|
||||
if: failure()
|
||||
run: |
|
||||
cd e2e/environment
|
||||
docker compose logs management --tail=500 --no-color > management.log 2>&1 || true
|
||||
docker compose logs reverse-proxy --tail=500 --no-color > reverse-proxy.log 2>&1 || true
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: playwright-report
|
||||
path: e2e/playwright-report/
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: ${{ failure() }}
|
||||
with:
|
||||
name: playwright-traces
|
||||
path: e2e/test-results/
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: ${{ failure() }}
|
||||
with:
|
||||
name: management-logs
|
||||
path: e2e/environment/management.log
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: ${{ failure() }}
|
||||
with:
|
||||
name: reverse-proxy-logs
|
||||
path: e2e/environment/reverse-proxy.log
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -36,7 +36,14 @@ yarn-error.log*
|
||||
next-env.d.ts
|
||||
|
||||
# config
|
||||
.local-config.json
|
||||
.local*config*.json
|
||||
.test-config.json
|
||||
e2e/playwright.env.json
|
||||
e2e/fixtures/auth/*.json
|
||||
e2e/test-results/
|
||||
e2e/playwright-report/
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
.configs/.local-config.zitadel.json
|
||||
.configs/.staging-config.json
|
||||
.configs/.temp-config.json
|
||||
|
||||
10
.omo/run-continuation/ses_10af25468ffezWZn49GbDsWmkw.json
Normal file
10
.omo/run-continuation/ses_10af25468ffezWZn49GbDsWmkw.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"sessionID": "ses_10af25468ffezWZn49GbDsWmkw",
|
||||
"updatedAt": "2026-06-23T15:17:44.982Z",
|
||||
"sources": {
|
||||
"background-task": {
|
||||
"state": "idle",
|
||||
"updatedAt": "2026-06-23T15:17:44.982Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
10
.omo/run-continuation/ses_10af30226ffeXZKbIMUr35P91Y.json
Normal file
10
.omo/run-continuation/ses_10af30226ffeXZKbIMUr35P91Y.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"sessionID": "ses_10af30226ffeXZKbIMUr35P91Y",
|
||||
"updatedAt": "2026-06-23T17:30:43.458Z",
|
||||
"sources": {
|
||||
"background-task": {
|
||||
"state": "idle",
|
||||
"updatedAt": "2026-06-23T17:30:43.458Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
377
AGENTS.md
Normal file
377
AGENTS.md
Normal file
@@ -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<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {}
|
||||
|
||||
export function Button({ className, variant, ...props }: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={cn(buttonVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Context 提供者模式
|
||||
```tsx
|
||||
import React, { useMemo } from "react";
|
||||
import useFetchApi from "@utils/api";
|
||||
|
||||
const DataContext = React.createContext({} as DataType);
|
||||
|
||||
export default function DataProvider({ children }: { children: React.ReactNode }) {
|
||||
const { data, isLoading } = useFetchApi<Data[]>("/endpoint");
|
||||
|
||||
const value = useMemo(() => ({ data, isLoading }), [data, isLoading]);
|
||||
|
||||
return (
|
||||
<DataContext.Provider value={value}>
|
||||
{children}
|
||||
</DataContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useData = () => React.useContext(DataContext);
|
||||
```
|
||||
|
||||
### API 钩子模式
|
||||
```tsx
|
||||
import useFetchApi from "@utils/api";
|
||||
|
||||
// GET 请求使用 SWR
|
||||
const { data, isLoading, error } = useFetchApi<Data[]>("/endpoint");
|
||||
|
||||
// POST/PUT/DELETE 请求
|
||||
const { mutate } = useFetchApi("/endpoint", { method: "POST" });
|
||||
```
|
||||
|
||||
### 样式模式
|
||||
```tsx
|
||||
import { cn } from "@utils/helpers";
|
||||
|
||||
// 合并 Tailwind 类
|
||||
<div className={cn(
|
||||
"base-classes",
|
||||
condition && "conditional-classes",
|
||||
className
|
||||
)} />
|
||||
```
|
||||
|
||||
### 导入顺序
|
||||
由 `simple-import-sort` ESLint 插件强制执行:
|
||||
1. 副作用导入 (`import "polyfill"`)
|
||||
2. 外部包 (`import React from "react"`)
|
||||
3. 内部别名 (`import { Button } from "@/components"`)
|
||||
4. 相对导入 (`import { useData } from "./context"`)
|
||||
|
||||
### 文件命名
|
||||
- **组件:** PascalCase (`PeerTable.tsx`, `GroupSelector.tsx`)
|
||||
- **钩子:** camelCase 带 `use` 前缀 (`useLocalStorage.tsx`)
|
||||
- **工具函数:** camelCase (`helpers.ts`, `api.tsx`)
|
||||
- **接口:** PascalCase (`Peer.ts`, `Group.ts`)
|
||||
- **页面:** `page.tsx`(Next.js App Router 要求)
|
||||
- **布局:** `layout.tsx`(Next.js App Router 要求)
|
||||
|
||||
## 重要文件
|
||||
|
||||
### 入口点
|
||||
- `src/app/layout.tsx` - 根布局(提供者、字体、元数据)
|
||||
- `src/app/(dashboard)/layout.tsx` - 仪表板布局(导航、认证)
|
||||
- `src/app/page.tsx` - 首页重定向
|
||||
|
||||
### 配置文件
|
||||
- `next.config.js` - Next.js 配置
|
||||
- `tailwind.config.ts` - Tailwind CSS 配置
|
||||
- `components.json` - shadcn/ui 配置
|
||||
- `config.json` - 应用配置(API 端点、认证)
|
||||
- `.eslintrc.json` - ESLint 规则
|
||||
- `tsconfig.json` - TypeScript 配置
|
||||
|
||||
### 关键工具
|
||||
- `src/utils/api.tsx` - API 客户端(SWR + OIDC)
|
||||
- `src/utils/config.ts` - 配置加载器
|
||||
- `src/utils/helpers.ts` - 共享工具
|
||||
- `src/auth/OIDCProvider.tsx` - 认证提供者
|
||||
|
||||
## 运行时/工具偏好
|
||||
|
||||
### 必需环境
|
||||
- Node.js 18+(推荐 LTS)
|
||||
- npm(包管理器)
|
||||
|
||||
### 本地开发设置
|
||||
1. 克隆仓库
|
||||
2. 创建 `.local-config.json` 覆盖 `config.json` 中的值
|
||||
3. 运行 `npm install`
|
||||
4. 运行 `npm run copy`(复制 OIDC 服务工作者)
|
||||
5. 运行 `npm run dev`
|
||||
|
||||
### Docker 部署
|
||||
```bash
|
||||
docker run -d --name netbird-dashboard \
|
||||
-p 80:80 \
|
||||
-e AUTH0_DOMAIN=<domain> \
|
||||
-e AUTH0_CLIENT_ID=<client-id> \
|
||||
-e AUTH0_AUDIENCE=<audience> \
|
||||
-e NETBIRD_MGMT_API_ENDPOINT=<api-url> \
|
||||
netbirdio/dashboard:main
|
||||
```
|
||||
|
||||
### 配置
|
||||
- `config.json` - 默认配置
|
||||
- `.local-config.json` - 本地覆盖(已忽略)
|
||||
- Docker 部署的环境变量
|
||||
|
||||
## 测试与质量保证
|
||||
|
||||
### E2E 测试(Cypress)
|
||||
```bash
|
||||
# 打开 Cypress UI
|
||||
npm run cypress:open
|
||||
|
||||
# 无头运行测试
|
||||
npx cypress run
|
||||
```
|
||||
|
||||
**测试位置:** `cypress/e2e/`
|
||||
**支持文件:** `cypress/support/`
|
||||
**测试数据:** `cypress/fixtures/`
|
||||
|
||||
### 代码检查
|
||||
```bash
|
||||
npm run lint
|
||||
```
|
||||
|
||||
ESLint 配置:
|
||||
- `next/core-web-vitals` - Next.js 最佳实践
|
||||
- `prettier` - 代码格式化
|
||||
- `simple-import-sort` - 导入排序
|
||||
|
||||
### 类型检查
|
||||
TypeScript 严格模式已启用。运行 `npx tsc --noEmit` 检查类型。
|
||||
|
||||
## 模块上下文
|
||||
|
||||
参见 `docs/contexts/` 获取特定模块的详细文档:
|
||||
- `peers.md` - 对等节点管理模块
|
||||
- `networks.md` - 网络配置模块
|
||||
- `access-control.md` - ACL 策略模块
|
||||
- `dns.md` - DNS 管理模块
|
||||
- `api-client.md` - API 客户端模式
|
||||
- `authentication.md` - OIDC 认证流程
|
||||
|
||||
## 常见问题与注意事项
|
||||
|
||||
### OIDC 服务工作者
|
||||
安装后必须运行 `npm run copy` 将 OIDC 服务工作者复制到 `public/`。
|
||||
|
||||
### 本地配置
|
||||
创建 `.local-config.json` 覆盖 `config.json` 中的本地开发值。
|
||||
|
||||
### 静态导出
|
||||
应用在 Next.js 配置中使用 `output: "export"` - 运行时无服务器端渲染。
|
||||
|
||||
### 暗黑模式
|
||||
主题通过 `GlobalThemeProvider` 管理。使用 Tailwind 暗黑模式类。
|
||||
|
||||
### API 端点
|
||||
所有 API 调用通过 `src/utils/api.tsx`,它处理:
|
||||
- OIDC 令牌注入
|
||||
- 令牌刷新
|
||||
- 错误处理
|
||||
- SWR 缓存
|
||||
|
||||
## 快速参考
|
||||
|
||||
### 添加新页面
|
||||
1. 创建 `src/app/(dashboard)/new-page/page.tsx`
|
||||
2. 在 `src/layouts/Navigation.tsx` 中添加导航
|
||||
3. 如需要,在 `src/contexts/` 中创建上下文提供者
|
||||
4. 在 `src/interfaces/` 中添加类型
|
||||
|
||||
### 添加新组件
|
||||
1. 在 `src/components/`(共享)或 `src/modules/<feature>/`(功能特定)中创建
|
||||
2. 遵循 shadcn/ui 模式并实现变体
|
||||
3. 从组件文件导出
|
||||
|
||||
### 添加新 API 端点
|
||||
1. 在组件或上下文中使用 `useFetchApi` 钩子
|
||||
2. 在 `src/interfaces/` 中添加 TypeScript 接口
|
||||
3. 如果数据在组件间共享,创建上下文提供者
|
||||
|
||||
### 添加新模块
|
||||
1. 在 `src/modules/<feature>/` 中创建目录
|
||||
2. 为该功能添加组件
|
||||
3. 在 `src/contexts/` 中创建上下文提供者
|
||||
4. 在 `src/app/(dashboard)/<feature>/` 中添加页面
|
||||
5. 更新导航
|
||||
|
||||
---
|
||||
|
||||
*本文档由 AI 自动生成。随着代码库的发展而更新。*
|
||||
2
AUTHORS
2
AUTHORS
@@ -1,3 +1,3 @@
|
||||
Mikhail Bragin (https://github.com/braginini)
|
||||
Maycon Santos (https://github.com/mlsmaycon)
|
||||
Wiretrustee UG (haftungsbeschränkt)
|
||||
NetBird GmbH
|
||||
43
CLAUDE.md
Normal file
43
CLAUDE.md
Normal file
@@ -0,0 +1,43 @@
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
This project is indexed by GitNexus as **dashboard** (4993 symbols, 15422 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
||||
|
||||
> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939).
|
||||
|
||||
## Always Do
|
||||
|
||||
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||
- **MUST run `detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. For regression review, compare against the default branch: `detect_changes({scope: "compare", base_ref: "main"})`.
|
||||
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||
- When exploring unfamiliar code, use `query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`.
|
||||
|
||||
## Never Do
|
||||
|
||||
- NEVER edit a function, class, or method without first running `impact` on it.
|
||||
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||
- NEVER rename symbols with find-and-replace — use `rename` which understands the call graph.
|
||||
- NEVER commit changes without running `detect_changes()` to check affected scope.
|
||||
|
||||
## Resources
|
||||
|
||||
| Resource | Use for |
|
||||
|----------|---------|
|
||||
| `gitnexus://repo/dashboard/context` | Codebase overview, check index freshness |
|
||||
| `gitnexus://repo/dashboard/clusters` | All functional areas |
|
||||
| `gitnexus://repo/dashboard/processes` | All execution flows |
|
||||
| `gitnexus://repo/dashboard/process/{name}` | Step-by-step execution trace |
|
||||
|
||||
## CLI
|
||||
|
||||
| Task | Read this skill file |
|
||||
|------|---------------------|
|
||||
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
|
||||
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
|
||||
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
|
||||
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
|
||||
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
||||
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
@@ -1,7 +1,7 @@
|
||||
## Contributor License Agreement
|
||||
|
||||
This Contributor License Agreement (referred to as the "Agreement") is entered into by the individual
|
||||
submitting this Agreement and NetBird GmbH, c/o Max-Beer-Straße 2-4 Münzstraße 12 10178 Berlin, Germany,
|
||||
submitting this Agreement and NetBird GmbH, Brunnenstraße 196, 10119 Berlin, Germany,
|
||||
referred to as "NetBird" (collectively, the "Parties"). The Agreement outlines the terms and conditions
|
||||
under which NetBird may utilize software contributions provided by the Contributor for inclusion in
|
||||
its software development projects. By submitting this Agreement, the Contributor confirms their acceptance
|
||||
|
||||
19
README.md
19
README.md
@@ -10,6 +10,7 @@ See [NetBird repo](https://github.com/netbirdio/netbird)
|
||||
|
||||
The purpose of this project is simple - make it easy to manage VPN built with [NetBird](https://github.com/netbirdio/netbird).
|
||||
The dashboard makes it possible to:
|
||||
|
||||
- track the status of your peers
|
||||
- remove peers
|
||||
- manage Setup Keys (to authenticate new peers)
|
||||
@@ -17,10 +18,10 @@ The dashboard makes it possible to:
|
||||
- define access controls
|
||||
|
||||
## Some Screenshots
|
||||
|
||||
<img src="./src/assets/screenshots/peers.png" alt="peers"/>
|
||||
<img src="./src/assets/screenshots/add-peer.png" alt="add-peer"/>
|
||||
|
||||
|
||||
## Technologies Used
|
||||
|
||||
- NextJS
|
||||
@@ -33,8 +34,9 @@ The dashboard makes it possible to:
|
||||
- Let's Encrypt
|
||||
|
||||
## How to run
|
||||
|
||||
Disclaimer. We believe that proper user management system is not a trivial task and requires quite some effort to make it right. Therefore we decided to
|
||||
use Auth0 service that covers all our needs (user management, social login, JTW for the management API).
|
||||
use Auth0 service that covers all our needs (user management, social login, JWT for the management API).
|
||||
Auth0 so far is the only 3rd party dependency that can't be really self-hosted.
|
||||
|
||||
1. Install [Docker](https://docs.docker.com/get-docker/)
|
||||
@@ -43,9 +45,9 @@ Auth0 so far is the only 3rd party dependency that can't be really self-hosted.
|
||||
|
||||
`AUTH0_DOMAIN` `AUTH0_CLIENT_ID` `AUTH0_AUDIENCE`
|
||||
|
||||
To obtain these, please use [Auth0 React SDK Guide](https://auth0.com/docs/quickstart/spa/react/01-login#configure-auth0) up until "Configure Allowed Web Origins"
|
||||
To obtain these, please use [Auth0 React SDK Guide](https://auth0.com/docs/quickstart/spa/react) up until "Configure Allowed Web Origins"
|
||||
|
||||
4. NetBird UI Dashboard uses NetBirds Management Service HTTP API, so setting `NETBIRD_MGMT_API_ENDPOINT` is required. Most likely it will be `http://localhost:33071` if you are hosting Management API on the same server.
|
||||
4. NetBird UI Dashboard uses NetBird's Management Service HTTP API, so setting `NETBIRD_MGMT_API_ENDPOINT` is required. Most likely it will be `http://localhost:33071` if you are hosting Management API on the same server.
|
||||
5. Run docker container without SSL (Let's Encrypt):
|
||||
|
||||
```shell
|
||||
@@ -54,9 +56,10 @@ Auth0 so far is the only 3rd party dependency that can't be really self-hosted.
|
||||
-e AUTH0_DOMAIN=<SET YOUR AUTH DOMAIN> \
|
||||
-e AUTH0_CLIENT_ID=<SET YOUR CLIENT ID> \
|
||||
-e AUTH0_AUDIENCE=<SET YOUR AUDIENCE> \
|
||||
-e NETBIRD_MGMT_API_ENDPOINT=<SET YOUR MANAGEMETN API URL> \
|
||||
-e NETBIRD_MGMT_API_ENDPOINT=<SET YOUR MANAGEMENT API URL> \
|
||||
netbirdio/dashboard:main
|
||||
```
|
||||
|
||||
6. Run docker container with SSL (Let's Encrypt):
|
||||
|
||||
```shell
|
||||
@@ -68,7 +71,7 @@ Auth0 so far is the only 3rd party dependency that can't be really self-hosted.
|
||||
-e AUTH0_DOMAIN=<SET YOUR AUTH DOMAIN> \
|
||||
-e AUTH0_CLIENT_ID=<SET YOUR CLEITN ID> \
|
||||
-e AUTH0_AUDIENCE=<SET YOUR AUDIENCE> \
|
||||
-e NETBIRD_MGMT_API_ENDPOINT=<SET YOUR MANAGEMETN API URL> \
|
||||
-e NETBIRD_MGMT_API_ENDPOINT=<SET YOUR MANAGEMENT API URL> \
|
||||
netbirdio/dashboard:main
|
||||
```
|
||||
|
||||
@@ -84,11 +87,11 @@ Open [http://localhost:3000](http://localhost:3000) with your browser to see the
|
||||
You can start editing by modifying the code inside `src/..`
|
||||
The page auto-updates as you edit the file.
|
||||
|
||||
## How to migrate from old dashboard (v1)
|
||||
## How to migrate from old dashboard (v1)
|
||||
|
||||
The new dashboard comes with a new docker image `netbirdio/dashboard:main`.
|
||||
To migrate from the old dashboard (v1) `wiretrustee/dashboard:main` to the new one, please follow the steps below.
|
||||
|
||||
1. Stop the dashboard container `docker compose down dashboard`
|
||||
2. Replace the docker image name in your `docker-compose.yml` with `netbirdio/dashboard:main`
|
||||
3. Recreate the dashboard container `docker compose up -d --force-recreate dashboard`
|
||||
3. Recreate the dashboard container `docker compose up -d --force-recreate dashboard`
|
||||
|
||||
12
announcements.json
Normal file
12
announcements.json
Normal file
@@ -0,0 +1,12 @@
|
||||
[
|
||||
{
|
||||
"tag": "Preview",
|
||||
"text": "The new NetBird desktop app is here - now available as a 0.75 release candidate.",
|
||||
"link": "https://github.com/netbirdio/netbird/discussions/6483",
|
||||
"linkText": "Learn more",
|
||||
"variant": "important",
|
||||
"isExternal": true,
|
||||
"closeable": true,
|
||||
"isCloudOnly": false
|
||||
}
|
||||
]
|
||||
103
batch-i18n.py
Normal file
103
batch-i18n.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""Batch i18n localization for remaining simple files"""
|
||||
import re, os
|
||||
|
||||
os.chdir('/root/github_projects/dashboard')
|
||||
|
||||
# Helper: replace first occurrence after a marker
|
||||
def replace_in_file(filepath, replacements):
|
||||
with open(filepath) as f:
|
||||
content = f.read()
|
||||
changed = False
|
||||
for old, new in replacements:
|
||||
if old in content:
|
||||
content = content.replace(old, new, 1)
|
||||
changed = True
|
||||
print(f" {os.path.basename(filepath)}: replaced '{old[:40]}'")
|
||||
else:
|
||||
print(f" WARN: '{old[:40]}' not in {os.path.basename(filepath)}")
|
||||
if changed:
|
||||
with open(filepath, 'w') as f:
|
||||
f.write(content)
|
||||
|
||||
# === 1. AddRouteDropdownButton.tsx ===
|
||||
replace_in_file('src/modules/peer/AddRouteDropdownButton.tsx', [
|
||||
('import Button from "@components/Button";',
|
||||
'import { useTranslations } from "next-intl";\nimport Button from "@components/Button";'),
|
||||
('export default function AddRouteDropdownButton() {',
|
||||
'export default function AddRouteDropdownButton() {\n const t = useTranslations("common");'),
|
||||
('New Network Route', '{t("newNetworkRoute")}'),
|
||||
('Existing Network', '{t("existingNetwork")}'),
|
||||
])
|
||||
|
||||
# === 2. RemoteJobDropdownButton.tsx ===
|
||||
replace_in_file('src/modules/peer/RemoteJobDropdownButton.tsx', [
|
||||
('import Button from "@components/Button";',
|
||||
'import { useTranslations } from "next-intl";\nimport Button from "@components/Button";'),
|
||||
('export const RemoteJobDropdownButton = () => {',
|
||||
'export const RemoteJobDropdownButton = () => {\n const t = useTranslations("common");'),
|
||||
('Debug Bundle', '{t("debugBundle")}'),
|
||||
])
|
||||
|
||||
# === 3. RouteMetricCell.tsx ===
|
||||
replace_in_file('src/modules/routes/RouteMetricCell.tsx', [
|
||||
('import FullTooltip from "@components/FullTooltip";',
|
||||
'import { useTranslations } from "next-intl";\nimport FullTooltip from "@components/FullTooltip";'),
|
||||
('export default function RouteMetricCell({',
|
||||
'export default function RouteMetricCell({\n const t = useTranslations("common");'),
|
||||
('Lower metrics have higher priority.', '{t("metricPriority")}'),
|
||||
])
|
||||
|
||||
# === 4. PeerRoutesTable.tsx ===
|
||||
replace_in_file('src/modules/peer/PeerRoutesTable.tsx', [
|
||||
('import Card from "@components/Card";',
|
||||
'import { useTranslations } from "next-intl";\nimport Card from "@components/Card";'),
|
||||
('export const RouteTableColumns: ColumnDef<Route>[] = [',
|
||||
'function RouteTableColumns(t: ReturnType<typeof useTranslations>): ColumnDef<Route>[] {\n return ['),
|
||||
('];\n\nexport default function PeerRoutesTable({',
|
||||
'];\n}\n\nexport default function PeerRoutesTable({'),
|
||||
('export default function PeerRoutesTable({',
|
||||
'export default function PeerRoutesTable({\n const t = useTranslations("common");'),
|
||||
('];\n\nfunction RouteTableColumns', '];\n}\n\nfunction RouteTableColumns'), # fix double close
|
||||
])
|
||||
|
||||
# Replace column headers in PeerRoutesTable
|
||||
with open('src/modules/peer/PeerRoutesTable.tsx') as f:
|
||||
c = f.read()
|
||||
c = c.replace('DataTableHeader column={column}>Name<', 'DataTableHeader column={column}>{t("name")}<')
|
||||
c = c.replace('DataTableHeader column={column}>Network<', 'DataTableHeader column={column}>{t("network")}<')
|
||||
c = c.replace('DataTableHeader column={column}>Distribution Groups<', 'DataTableHeader column={column}>{t("distributionGroups")}<')
|
||||
c = c.replace('DataTableHeader column={column}>Active<', 'DataTableHeader column={column}>{t("active")}<')
|
||||
with open('src/modules/peer/PeerRoutesTable.tsx', 'w') as f:
|
||||
f.write(c)
|
||||
print(" PeerRoutesTable: column headers replaced")
|
||||
|
||||
# === 5. Add keys to en.ts ===
|
||||
with open('src/i18n/messages/en.ts') as f:
|
||||
en = f.read()
|
||||
|
||||
# Add to common namespace (after debugBundle or similar)
|
||||
if 'debugBundle' not in en:
|
||||
en = en.replace(
|
||||
'routingPeer: "Routing Peer",',
|
||||
'routingPeer: "Routing Peer",\n newNetworkRoute: "New Network Route",\n existingNetwork: "Existing Network",\n debugBundle: "Debug Bundle",\n metricPriority: "Lower metrics have higher priority.",'
|
||||
)
|
||||
|
||||
with open('src/i18n/messages/en.ts', 'w') as f:
|
||||
f.write(en)
|
||||
print(" en.ts: keys added")
|
||||
|
||||
# === 6. Add keys to zh.ts ===
|
||||
with open('src/i18n/messages/zh.ts') as f:
|
||||
zh = f.read()
|
||||
|
||||
if 'debugBundle' not in zh:
|
||||
zh = zh.replace(
|
||||
'routingPeer: "路由节点",',
|
||||
'routingPeer: "路由节点",\n newNetworkRoute: "新网络路由",\n existingNetwork: "现有网络",\n debugBundle: "调试包",\n metricPriority: "较低的度量值具有更高的优先级。"'
|
||||
)
|
||||
|
||||
with open('src/i18n/messages/zh.ts', 'w') as f:
|
||||
f.write(zh)
|
||||
print(" zh.ts: Chinese translations added")
|
||||
|
||||
print("\nDone!")
|
||||
32
config.json
32
config.json
@@ -1,10 +1,26 @@
|
||||
{
|
||||
"auth0Auth": "true",
|
||||
"authAuthority": "https://netbird-localdev.eu.auth0.com",
|
||||
"authClientId": "kBRMAOqIZ7hvpVCaypQLCJvTzkYYIXVt",
|
||||
"authScopesSupported": "openid profile email api offline_access email_verified",
|
||||
"authAudience": "http://localhost:3000/",
|
||||
"apiOrigin": "http://localhost",
|
||||
"grpcApiOrigin": "http://localhost:80",
|
||||
"latestVersion": "v0.6.3"
|
||||
"auth0Auth": "$USE_AUTH0",
|
||||
"authAuthority": "$AUTH_AUTHORITY",
|
||||
"authClientId": "$AUTH_CLIENT_ID",
|
||||
"authClientSecret": "$AUTH_CLIENT_SECRET",
|
||||
"authScopesSupported": "$AUTH_SUPPORTED_SCOPES",
|
||||
"authAudience": "$AUTH_AUDIENCE",
|
||||
"apiOrigin": "$NETBIRD_MGMT_API_ENDPOINT",
|
||||
"grpcApiOrigin": "$NETBIRD_MGMT_GRPC_API_ENDPOINT",
|
||||
"redirectURI": "$AUTH_REDIRECT_URI",
|
||||
"silentRedirectURI": "$AUTH_SILENT_REDIRECT_URI",
|
||||
"tokenSource": "$NETBIRD_TOKEN_SOURCE",
|
||||
"dragQueryParams": "$NETBIRD_DRAG_QUERY_PARAMS",
|
||||
"hotjarTrackID": "$NETBIRD_HOTJAR_TRACK_ID",
|
||||
"googleAnalyticsID": "$NETBIRD_GOOGLE_ANALYTICS_ID",
|
||||
"googleTagManagerID": "$NETBIRD_GOOGLE_TAG_MANAGER_ID",
|
||||
"authServiceUrl": "$NETBIRD_AUTH_SERVICE_URL",
|
||||
"wasmPath": "$NETBIRD_WASM_PATH",
|
||||
"licensed": "$NETBIRD_LICENSED",
|
||||
"cloud": "$NETBIRD_CLOUD",
|
||||
"hubspotPortalId": "$NETBIRD_HUBSPOT_PORTAL_ID",
|
||||
"hubspotSignupFormId": "$NETBIRD_HUBSPOT_SIGNUP_FORM_ID",
|
||||
"hubspotOnboardingFormId": "$NETBIRD_HUBSPOT_ONBOARDING_FORM_ID",
|
||||
"hubspotSurveyFormId": "$NETBIRD_HUBSPOT_SURVEY_FORM_ID",
|
||||
"analyticsExcludedEmails": "$NETBIRD_ANALYTICS_EXCLUDED_EMAILS"
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { defineConfig } from "cypress";
|
||||
|
||||
export default defineConfig({
|
||||
e2e: {
|
||||
baseUrl: "http://localhost:3000",
|
||||
},
|
||||
component: {
|
||||
devServer: {
|
||||
framework: "next",
|
||||
bundler: "webpack",
|
||||
},
|
||||
},
|
||||
viewportWidth: 1920,
|
||||
viewportHeight: 1080,
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
describe("Click all tabs in peer modal", () => {
|
||||
it("passes", () => {
|
||||
cy.visit("/install");
|
||||
cy.get("div").contains("Linux").click();
|
||||
cy.get("[data-cy=copy-to-clipboard]").click();
|
||||
cy.get("div").contains("Windows").click();
|
||||
cy.get("[data-cy=copy-to-clipboard]").click();
|
||||
cy.get("div").contains("Android").click();
|
||||
cy.get("[data-cy=copy-to-clipboard]").click();
|
||||
cy.get("div").contains("Docker").click();
|
||||
cy.get("[data-cy=copy-to-clipboard]").click();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"name": "Using fixtures to represent data",
|
||||
"email": "hello@cypress.io",
|
||||
"body": "Fixtures are a great way to mock data for responses to routes"
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
/// <reference types="cypress" />
|
||||
// ***********************************************
|
||||
// This example commands.ts shows you how to
|
||||
// create various custom commands and overwrite
|
||||
// existing commands.
|
||||
//
|
||||
// For more comprehensive examples of custom
|
||||
// commands please read more here:
|
||||
// https://on.cypress.io/custom-commands
|
||||
// ***********************************************
|
||||
//
|
||||
//
|
||||
// -- This is a parent command --
|
||||
// Cypress.Commands.add('login', (email, password) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This is a child command --
|
||||
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This is a dual command --
|
||||
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
|
||||
//
|
||||
//
|
||||
// -- This will overwrite an existing command --
|
||||
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
||||
//
|
||||
// declare global {
|
||||
// namespace Cypress {
|
||||
// interface Chainable {
|
||||
// login(email: string, password: string): Chainable<void>
|
||||
// drag(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
|
||||
// dismiss(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
|
||||
// visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element>
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
@@ -1,20 +0,0 @@
|
||||
// ***********************************************************
|
||||
// This example support/e2e.ts is processed and
|
||||
// loaded automatically before your test files.
|
||||
//
|
||||
// This is a great place to put global configuration and
|
||||
// behavior that modifies Cypress.
|
||||
//
|
||||
// You can change the location of this file or turn off
|
||||
// automatically serving support files with the
|
||||
// 'supportFile' configuration option.
|
||||
//
|
||||
// You can read more here:
|
||||
// https://on.cypress.io/configuration
|
||||
// ***********************************************************
|
||||
|
||||
// Import commands.js using ES2015 syntax:
|
||||
import './commands'
|
||||
|
||||
// Alternatively you can use CommonJS syntax:
|
||||
// require('./commands')
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["es5", "dom"],
|
||||
"baseUrl": "http://localhost:3000",
|
||||
"types": ["cypress", "node"],
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
@@ -1,24 +1,13 @@
|
||||
FROM alpine:3.14
|
||||
|
||||
RUN apk add --no-cache bash curl less ca-certificates git tzdata zip gettext \
|
||||
nginx curl supervisor certbot-nginx && \
|
||||
rm -rf /var/cache/apk/* && mkdir -p /run/nginx
|
||||
|
||||
STOPSIGNAL SIGINT
|
||||
EXPOSE 80
|
||||
EXPOSE 443
|
||||
ENTRYPOINT ["/usr/bin/supervisord","-c","/etc/supervisord.conf"]
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /usr/share/nginx/html
|
||||
# copy configuration files
|
||||
COPY docker/default.conf /etc/nginx/http.d/default.conf
|
||||
COPY docker/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY docker/init_cert.sh /usr/local/init_cert.sh
|
||||
COPY docker/init_react_envs.sh /usr/local/init_react_envs.sh
|
||||
RUN chmod +x /usr/local/init_cert.sh && rm /etc/crontabs/root
|
||||
RUN chmod +x /usr/local/init_react_envs.sh
|
||||
|
||||
# configure supervisor
|
||||
COPY docker/supervisord.conf /etc/supervisord.conf
|
||||
# copy build files
|
||||
COPY out/ /usr/share/nginx/html/
|
||||
# Copy build files
|
||||
COPY out/ /usr/share/nginx/html/
|
||||
|
||||
# Copy server script
|
||||
COPY docker/server.js /server.js
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["node", "/server.js"]
|
||||
|
||||
@@ -14,14 +14,24 @@ server {
|
||||
|
||||
location / {
|
||||
try_files $uri $uri.html $uri/ =404;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header Content-Security-Policy "default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob: ws: wss: https:;" always;
|
||||
add_header Last-Modified "";
|
||||
expires off;
|
||||
}
|
||||
|
||||
error_page 404 /404.html;
|
||||
location = /404.html {
|
||||
internal;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header Content-Security-Policy "default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob: ws: wss: https:;" always;
|
||||
add_header Last-Modified "";
|
||||
expires off;
|
||||
}
|
||||
}
|
||||
@@ -61,12 +61,95 @@ export NETBIRD_GOOGLE_ANALYTICS_ID=${NETBIRD_GOOGLE_ANALYTICS_ID}
|
||||
export NETBIRD_GOOGLE_TAG_MANAGER_ID=${NETBIRD_GOOGLE_TAG_MANAGER_ID}
|
||||
export NETBIRD_TOKEN_SOURCE=${NETBIRD_TOKEN_SOURCE:-accessToken}
|
||||
export NETBIRD_DRAG_QUERY_PARAMS=${NETBIRD_DRAG_QUERY_PARAMS:-false}
|
||||
export NETBIRD_AUTH_SERVICE_URL=${NETBIRD_AUTH_SERVICE_URL}
|
||||
export NETBIRD_WASM_PATH=${NETBIRD_WASM_PATH}
|
||||
export NETBIRD_CSP=${NETBIRD_CSP}
|
||||
export NETBIRD_LICENSED=${NETBIRD_LICENSED:-false}
|
||||
export NETBIRD_CLOUD=${NETBIRD_CLOUD:-false}
|
||||
export NETBIRD_HUBSPOT_PORTAL_ID=${NETBIRD_HUBSPOT_PORTAL_ID}
|
||||
export NETBIRD_HUBSPOT_SIGNUP_FORM_ID=${NETBIRD_HUBSPOT_SIGNUP_FORM_ID}
|
||||
export NETBIRD_HUBSPOT_ONBOARDING_FORM_ID=${NETBIRD_HUBSPOT_ONBOARDING_FORM_ID}
|
||||
export NETBIRD_HUBSPOT_SURVEY_FORM_ID=${NETBIRD_HUBSPOT_SURVEY_FORM_ID}
|
||||
export NETBIRD_ANALYTICS_EXCLUDED_EMAILS=${NETBIRD_ANALYTICS_EXCLUDED_EMAILS}
|
||||
|
||||
echo "NetBird latest version: ${NETBIRD_LATEST_VERSION}"
|
||||
|
||||
# Build CSP
|
||||
FIRST_PARTY_CSP="pkgs.netbird.io"
|
||||
FIRST_PARTY_CSP_CONNECT_SRC="wss://*.netbird.io"
|
||||
THIRD_PARTY_CSP="*.licdn.com *.linkedin.com *.vector.co *.sibforms.com *.hotjar.com *.hotjar.io *.redditstatic.com pixel-config.reddit.com *.clarity.ms c.bing.com *.microsoft.com googleads.g.doubleclick.net pagead2.googlesyndication.com www.google.com www.googleadservices.com *.google-analytics.com *.googletagmanager.com analytics.google.com *.hubapi.com *.hs-banner.com *.hubspot.com *.hubspot.net js.hs-analytics.com *.hsforms.net *.hscollectedforms.net *.hs-analytics.net *.hsforms.com track.hubspot.com *.hsadspixel.net static.hsappstatic.net"
|
||||
THIRD_PARTY_CSP_CONNECT_SRC="https://api.github.com/repos/netbirdio/netbird/releases/latest https://raw.githubusercontent.com/netbirdio/dashboard/ wss://ws.hotjar.com"
|
||||
THIRD_PARTY_CSP_SCRIPT_SRC="'sha256-7knV6EIjKUvCpYWE2rCYx8dYV2WCNb2bpTuitFXzBcA=' *.hs-scripts.com"
|
||||
|
||||
CSP_DOMAINS=""
|
||||
CSP_DOMAINS_CONNECT_SRC=""
|
||||
|
||||
if [[ -n "${NETBIRD_CSP}" ]]; then
|
||||
CSP_DOMAINS="$CSP_DOMAINS $NETBIRD_CSP"
|
||||
fi
|
||||
|
||||
# Add AUTH_AUTHORITY to CSP
|
||||
if [[ -n "${AUTH_AUTHORITY}" ]]; then
|
||||
CSP_DOMAINS="$CSP_DOMAINS $AUTH_AUTHORITY"
|
||||
fi
|
||||
|
||||
# Add AUTH_AUDIENCE to CSP
|
||||
if [[ -n "${AUTH_AUDIENCE}" && ("${AUTH_AUDIENCE}" == *"http://"* || "${AUTH_AUDIENCE}" == *"https://"*) ]]; then
|
||||
CSP_DOMAINS="$CSP_DOMAINS $AUTH_AUDIENCE"
|
||||
fi
|
||||
|
||||
# Add NETBIRD_AUTH_SERVICE_URL to CSP
|
||||
if [[ -n "${NETBIRD_AUTH_SERVICE_URL}" ]]; then
|
||||
CSP_DOMAINS="$CSP_DOMAINS $NETBIRD_AUTH_SERVICE_URL"
|
||||
fi
|
||||
|
||||
# Add NETBIRD_MGMT_API_ENDPOINT to CSP
|
||||
if [[ -n "${NETBIRD_MGMT_API_ENDPOINT}" ]]; then
|
||||
MGMT_DOMAIN=$(echo "$NETBIRD_MGMT_API_ENDPOINT" | sed -E 's|https?://||' | cut -d'/' -f1 | cut -d':' -f1)
|
||||
if [[ -n "$MGMT_DOMAIN" ]]; then
|
||||
if [[ "$NETBIRD_MGMT_API_ENDPOINT" == https://* ]]; then
|
||||
CSP_DOMAINS="$CSP_DOMAINS $NETBIRD_MGMT_API_ENDPOINT"
|
||||
CSP_DOMAINS_CONNECT_SRC="$CSP_DOMAINS_CONNECT_SRC wss://$MGMT_DOMAIN"
|
||||
elif [[ "$NETBIRD_MGMT_API_ENDPOINT" == http://* ]]; then
|
||||
CSP_DOMAINS="$CSP_DOMAINS $NETBIRD_MGMT_API_ENDPOINT"
|
||||
CSP_DOMAINS_CONNECT_SRC="$CSP_DOMAINS_CONNECT_SRC ws://$MGMT_DOMAIN"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Add LETSENCRYPT_DOMAIN to CSP
|
||||
if [[ -n "${LETSENCRYPT_DOMAIN}" ]]; then
|
||||
if [[ "$LETSENCRYPT_DOMAIN" == *"localhost"* ]]; then
|
||||
CSP_DOMAINS="$CSP_DOMAINS http://$LETSENCRYPT_DOMAIN"
|
||||
CSP_DOMAINS_CONNECT_SRC="$CSP_DOMAINS_CONNECT_SRC ws://$LETSENCRYPT_DOMAIN"
|
||||
else
|
||||
CSP_DOMAINS="$CSP_DOMAINS https://$LETSENCRYPT_DOMAIN"
|
||||
CSP_DOMAINS_CONNECT_SRC="$CSP_DOMAINS_CONNECT_SRC wss://$LETSENCRYPT_DOMAIN"
|
||||
fi
|
||||
fi
|
||||
|
||||
CSP_CONNECT_SRC="$CSP_DOMAINS $CSP_DOMAINS_CONNECT_SRC $FIRST_PARTY_CSP $FIRST_PARTY_CSP_CONNECT_SRC $THIRD_PARTY_CSP $THIRD_PARTY_CSP_CONNECT_SRC"
|
||||
CSP_FRAME_SRC="$CSP_DOMAINS $FIRST_PARTY_CSP $THIRD_PARTY_CSP"
|
||||
CSP_SCRIPT_SRC="$CSP_DOMAINS $FIRST_PARTY_CSP $THIRD_PARTY_CSP $THIRD_PARTY_CSP_SCRIPT_SRC"
|
||||
|
||||
# Remove duplicates
|
||||
CSP_CONNECT_SRC=$(echo $CSP_CONNECT_SRC | tr ' ' '\n' | sort -u | tr '\n' ' ' | sed 's/ $//')
|
||||
CSP_FRAME_SRC=$(echo $CSP_FRAME_SRC | tr ' ' '\n' | sort -u | tr '\n' ' ' | sed 's/ $//')
|
||||
CSP_SCRIPT_SRC=$(echo $CSP_SCRIPT_SRC | tr ' ' '\n' | sort -u | tr '\n' ' ' | sed 's/ $//')
|
||||
|
||||
# Update CSP in nginx config
|
||||
CSP_POLICY="default-src 'none'; connect-src 'self' $CSP_CONNECT_SRC; frame-src 'self' $CSP_FRAME_SRC; script-src 'self' 'wasm-unsafe-eval' $CSP_SCRIPT_SRC; font-src 'self'; img-src * data:; manifest-src 'self'; style-src 'self' 'unsafe-inline'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests;"
|
||||
CSP_HEADER="add_header Content-Security-Policy \"$CSP_POLICY\" always;"
|
||||
|
||||
echo "CSP header: $CSP_HEADER"
|
||||
|
||||
# Replace CSP header in nginx config
|
||||
sed -i "s|add_header Content-Security-Policy \"[^\"]*\" always;|$CSP_HEADER|g" /etc/nginx/http.d/default.conf || {
|
||||
echo "Failed to replace CSP header"
|
||||
}
|
||||
|
||||
# replace ENVs in the config
|
||||
ENV_STR="\$\$USE_AUTH0 \$\$AUTH_AUDIENCE \$\$AUTH_AUTHORITY \$\$AUTH_CLIENT_ID \$\$AUTH_CLIENT_SECRET \$\$AUTH_SUPPORTED_SCOPES \$\$NETBIRD_MGMT_API_ENDPOINT \$\$NETBIRD_MGMT_GRPC_API_ENDPOINT \$\$NETBIRD_HOTJAR_TRACK_ID \$\$NETBIRD_GOOGLE_ANALYTICS_ID \$\$NETBIRD_GOOGLE_TAG_MANAGER_ID \$\$AUTH_REDIRECT_URI \$\$AUTH_SILENT_REDIRECT_URI \$\$NETBIRD_TOKEN_SOURCE \$\$NETBIRD_DRAG_QUERY_PARAMS \$\$NETBIRD_WASM_PATH"
|
||||
ENV_STR="\$\$USE_AUTH0 \$\$AUTH_AUDIENCE \$\$AUTH_AUTHORITY \$\$AUTH_CLIENT_ID \$\$AUTH_CLIENT_SECRET \$\$AUTH_SUPPORTED_SCOPES \$\$NETBIRD_MGMT_API_ENDPOINT \$\$NETBIRD_MGMT_GRPC_API_ENDPOINT \$\$NETBIRD_HOTJAR_TRACK_ID \$\$NETBIRD_GOOGLE_ANALYTICS_ID \$\$NETBIRD_GOOGLE_TAG_MANAGER_ID \$\$AUTH_REDIRECT_URI \$\$AUTH_SILENT_REDIRECT_URI \$\$NETBIRD_TOKEN_SOURCE \$\$NETBIRD_DRAG_QUERY_PARAMS \$\$NETBIRD_AUTH_SERVICE_URL \$\$NETBIRD_WASM_PATH \$\$NETBIRD_LICENSED \$\$NETBIRD_CLOUD \$\$NETBIRD_HUBSPOT_PORTAL_ID \$\$NETBIRD_HUBSPOT_SIGNUP_FORM_ID \$\$NETBIRD_HUBSPOT_ONBOARDING_FORM_ID \$\$NETBIRD_HUBSPOT_SURVEY_FORM_ID \$\$NETBIRD_ANALYTICS_EXCLUDED_EMAILS"
|
||||
|
||||
OIDC_TRUSTED_DOMAINS="/usr/share/nginx/html/OidcTrustedDomains.js"
|
||||
envsubst "$ENV_STR" < "$OIDC_TRUSTED_DOMAINS".tmpl > "$OIDC_TRUSTED_DOMAINS"
|
||||
|
||||
138
docker/server.js
Normal file
138
docker/server.js
Normal file
@@ -0,0 +1,138 @@
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const root = path.resolve('/usr/share/nginx/html');
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript',
|
||||
'.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg',
|
||||
'.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.wasm': 'application/wasm',
|
||||
'.ttf': 'font/ttf', '.woff': 'font/woff', '.woff2': 'font/woff2',
|
||||
'.txt': 'text/plain', '.xml': 'text/xml'
|
||||
};
|
||||
|
||||
// Replace both placeholder styles used by generated assets and templates.
|
||||
const ENV_KEYS = [
|
||||
'USE_AUTH0',
|
||||
'AUTH_AUDIENCE',
|
||||
'AUTH_AUTHORITY',
|
||||
'AUTH_CLIENT_ID',
|
||||
'AUTH_CLIENT_SECRET',
|
||||
'AUTH_SUPPORTED_SCOPES',
|
||||
'NETBIRD_MGMT_API_ENDPOINT',
|
||||
'NETBIRD_MGMT_GRPC_API_ENDPOINT',
|
||||
'NETBIRD_HOTJAR_TRACK_ID',
|
||||
'NETBIRD_GOOGLE_ANALYTICS_ID',
|
||||
'NETBIRD_GOOGLE_TAG_MANAGER_ID',
|
||||
'AUTH_REDIRECT_URI',
|
||||
'AUTH_SILENT_REDIRECT_URI',
|
||||
'NETBIRD_TOKEN_SOURCE',
|
||||
'NETBIRD_DRAG_QUERY_PARAMS',
|
||||
'NETBIRD_WASM_PATH',
|
||||
'AUTH0_DOMAIN',
|
||||
'AUTH0_CLIENT_ID',
|
||||
'AUTH0_AUDIENCE',
|
||||
];
|
||||
|
||||
function substituteEnv(content) {
|
||||
let changed = false;
|
||||
for (const key of ENV_KEYS) {
|
||||
const val = process.env[key] || '';
|
||||
for (const pattern of ['$$' + key, '$' + key]) {
|
||||
if (content.includes(pattern)) {
|
||||
content = content.split(pattern).join(val);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { content, changed };
|
||||
}
|
||||
|
||||
function walkDir(dir) {
|
||||
try {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walkDir(full);
|
||||
else if (entry.isFile() && /\.(js|html|txt|json)$/.test(entry.name)) {
|
||||
const result = substituteEnv(fs.readFileSync(full, 'utf8'));
|
||||
if (result.changed) fs.writeFileSync(full, result.content, 'utf8');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to substitute environment variables:', e);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Substituting environment variables...');
|
||||
try {
|
||||
const tmpl = path.join(root, 'OidcTrustedDomains.js.tmpl');
|
||||
if (fs.existsSync(tmpl)) {
|
||||
const result = substituteEnv(fs.readFileSync(tmpl, 'utf8'));
|
||||
fs.writeFileSync(path.join(root, 'OidcTrustedDomains.js'), result.content, 'utf8');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to create OidcTrustedDomains.js:', e);
|
||||
}
|
||||
|
||||
walkDir(root);
|
||||
console.log('Environment substitution complete.');
|
||||
|
||||
function isFile(p) {
|
||||
try { return fs.statSync(p).isFile(); } catch(e) { return false; }
|
||||
}
|
||||
|
||||
function safePath(p) {
|
||||
const abs = path.resolve(root, '.' + p);
|
||||
return abs === root || abs.startsWith(root + path.sep) ? abs : null;
|
||||
}
|
||||
|
||||
function resolvePath(url) {
|
||||
let p = url.split('?')[0];
|
||||
if (!p.startsWith('/')) p = '/' + p;
|
||||
if (p === '/' || p.endsWith('/')) p += 'index.html';
|
||||
const abs = safePath(p);
|
||||
if (abs && isFile(abs)) return abs;
|
||||
// Try .html suffix (Next.js static export uses path.html)
|
||||
const asHtml = safePath(p + '.html');
|
||||
if (asHtml && isFile(asHtml)) return asHtml;
|
||||
// Try path/index.html
|
||||
const asDirIndex = safePath(p + '/index.html');
|
||||
if (asDirIndex && isFile(asDirIndex)) return asDirIndex;
|
||||
// Single-locale build: the static export places pages under /zh/.
|
||||
// Fall back to /zh so that paths like /networks still resolve when the
|
||||
// user navigates directly to a non-prefixed URL.
|
||||
if (!p.startsWith('/zh')) {
|
||||
const zh = safePath('/zh' + p);
|
||||
if (zh && isFile(zh)) return zh;
|
||||
const zhHtml = safePath('/zh' + p + '.html');
|
||||
if (zhHtml && isFile(zhHtml)) return zhHtml;
|
||||
const zhDirIndex = safePath('/zh' + p + '/index.html');
|
||||
if (zhDirIndex && isFile(zhDirIndex)) return zhDirIndex;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
http.createServer((req, res) => {
|
||||
const filePath = resolvePath(req.url);
|
||||
if (filePath) {
|
||||
const ext = path.extname(filePath);
|
||||
fs.readFile(filePath, (err, data) => {
|
||||
if (err) { send404(res); return; }
|
||||
res.writeHead(200, {
|
||||
'Content-Type': MIME[ext] || 'application/octet-stream',
|
||||
'Cache-Control': ['.html', '.js'].includes(ext) ? 'no-store, no-cache, must-revalidate, max-age=0' : 'public, max-age=3600'
|
||||
});
|
||||
res.end(data);
|
||||
});
|
||||
} else {
|
||||
send404(res);
|
||||
}
|
||||
}).listen(80, () => console.log('NetBird Dashboard running on port 80'));
|
||||
|
||||
function send404(res) {
|
||||
fs.readFile(path.join(root, '404.html'), (err, data) => {
|
||||
res.writeHead(404, {'Content-Type': 'text/html', 'Cache-Control': 'no-store'});
|
||||
res.end(err ? '404 Not Found' : data);
|
||||
});
|
||||
}
|
||||
59
docs/contexts/access-control.md
Normal file
59
docs/contexts/access-control.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# 访问控制模块
|
||||
|
||||
## 功能
|
||||
管理访问控制策略(ACL)- 定义哪些对等节点可以通信。
|
||||
|
||||
## API 接口
|
||||
- `GET /api/policies` - 列出策略
|
||||
- `GET /api/policies/:id` - 获取策略详情
|
||||
- `POST /api/policies` - 创建策略
|
||||
- `PUT /api/policies/:id` - 更新策略
|
||||
- `DELETE /api/policies/:id` - 删除策略
|
||||
|
||||
## 关键类型
|
||||
```typescript
|
||||
interface Policy {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
rules: PolicyRule[];
|
||||
// ... 更多字段
|
||||
}
|
||||
|
||||
interface PolicyRule {
|
||||
name: string;
|
||||
sources: Group[];
|
||||
destinations: Group[];
|
||||
// ... 更多字段
|
||||
}
|
||||
```
|
||||
|
||||
## 文件路径
|
||||
- `src/modules/access-control/` - UI 组件
|
||||
- `src/contexts/PoliciesProvider.tsx` - 数据提供者
|
||||
- `src/app/(dashboard)/access-control/page.tsx` - 页面组件
|
||||
|
||||
## 组件
|
||||
- 策略列表表格
|
||||
- 策略编辑器
|
||||
- 规则配置
|
||||
|
||||
## 使用方法
|
||||
```tsx
|
||||
import { usePolicies } from "@/contexts/PoliciesProvider";
|
||||
|
||||
function MyComponent() {
|
||||
const { policies, isLoading } = usePolicies();
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## 命令
|
||||
- 页面:`/access-control`
|
||||
|
||||
## 注意事项
|
||||
- 策略按顺序评估
|
||||
- 禁用的策略会被跳过
|
||||
- 规则引用分组,而不是单个对等节点
|
||||
- 可以向策略添加姿态检查
|
||||
97
docs/contexts/api-client.md
Normal file
97
docs/contexts/api-client.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# API Client
|
||||
|
||||
## Purpose
|
||||
Centralized API client with SWR integration and OIDC authentication.
|
||||
|
||||
## File Path
|
||||
- `src/utils/api.tsx`
|
||||
|
||||
## Key Exports
|
||||
```typescript
|
||||
// Main hook for API calls
|
||||
export default function useFetchApi<T>(
|
||||
url: string,
|
||||
options?: RequestOptions
|
||||
): SWRResponse<T, ErrorResponse>;
|
||||
|
||||
// Request options
|
||||
type RequestOptions = {
|
||||
key?: string;
|
||||
signal?: AbortSignal;
|
||||
origin?: string;
|
||||
globalParams?: Params;
|
||||
ignoreGlobalParams?: boolean;
|
||||
refreshInterval?: number;
|
||||
blob?: boolean;
|
||||
shouldRetryOnError?: boolean;
|
||||
};
|
||||
|
||||
// Error response type
|
||||
export type ErrorResponse = {
|
||||
code: number;
|
||||
message: string;
|
||||
};
|
||||
|
||||
// Query params type
|
||||
export type Params = Record<string, string | number | boolean>;
|
||||
```
|
||||
|
||||
## Usage Patterns
|
||||
|
||||
### GET Request
|
||||
```tsx
|
||||
const { data, isLoading, error } = useFetchApi<Peer[]>("/peers");
|
||||
```
|
||||
|
||||
### GET with Refresh
|
||||
```tsx
|
||||
const { data } = useFetchApi<Peer[]>("/peers", {
|
||||
refreshInterval: 5000, // Poll every 5 seconds
|
||||
});
|
||||
```
|
||||
|
||||
### POST/PUT/DELETE
|
||||
```tsx
|
||||
const { mutate } = useFetchApi("/peers", { method: "POST" });
|
||||
// Or use direct apiRequest function
|
||||
```
|
||||
|
||||
### Custom Key
|
||||
```tsx
|
||||
const { data } = useFetchApi("/peers", {
|
||||
key: "my-custom-key",
|
||||
});
|
||||
```
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Authentication
|
||||
- Uses OIDC tokens from `@axa-fr/react-oidc`
|
||||
- Automatically injects Authorization header
|
||||
- Handles token refresh
|
||||
|
||||
### Caching
|
||||
- Uses SWR for caching and revalidation
|
||||
- Global config in `ApplicationProvider`
|
||||
- Supports refresh intervals
|
||||
|
||||
### Error Handling
|
||||
- Returns structured `ErrorResponse`
|
||||
- Integrates with `ErrorBoundary`
|
||||
- Configurable retry behavior
|
||||
|
||||
### Configuration
|
||||
- Base URL from `config.json` (`apiOrigin`)
|
||||
- Can override with `origin` option
|
||||
- Global params merged from `ApplicationContext`
|
||||
|
||||
## Dependencies
|
||||
- `swr` - Data fetching and caching
|
||||
- `@axa-fr/react-oidc` - OIDC authentication
|
||||
- `react-jwt` - JWT token handling
|
||||
|
||||
## Gotchas
|
||||
- Requires OIDC provider to be initialized
|
||||
- Tokens stored in memory (not localStorage)
|
||||
- Global params applied to all requests unless `ignoreGlobalParams: true`
|
||||
- Use `shouldRetryOnError: false` to disable automatic retries
|
||||
93
docs/contexts/authentication.md
Normal file
93
docs/contexts/authentication.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Authentication
|
||||
|
||||
## Purpose
|
||||
OIDC-based authentication using Auth0 or compatible providers.
|
||||
|
||||
## File Paths
|
||||
- `src/auth/OIDCProvider.tsx` - Main OIDC provider
|
||||
- `src/auth/SecureProvider.tsx` - Protected route wrapper
|
||||
- `src/auth/OIDCError.tsx` - Error display
|
||||
- `src/auth/SessionLost.tsx` - Session lost handling
|
||||
|
||||
## Key Dependencies
|
||||
- `@axa-fr/react-oidc` - OIDC client library
|
||||
|
||||
## Configuration
|
||||
Configuration in `config.json`:
|
||||
```json
|
||||
{
|
||||
"auth0Domain": "your-tenant.auth0.com",
|
||||
"auth0ClientId": "your-client-id",
|
||||
"auth0Audience": "your-audience",
|
||||
"auth0Authority": "https://your-tenant.auth0.com",
|
||||
"auth0RedirectUri": "http://localhost:3000",
|
||||
"authScope": "openid profile email"
|
||||
}
|
||||
```
|
||||
|
||||
## Environment Variables (Docker)
|
||||
- `AUTH0_DOMAIN` - Auth0 tenant domain
|
||||
- `AUTH0_CLIENT_ID` - Auth0 application client ID
|
||||
- `AUTH0_AUDIENCE` - API audience identifier
|
||||
|
||||
## Usage
|
||||
|
||||
### Protected Routes
|
||||
```tsx
|
||||
import SecureProvider from "@/auth/SecureProvider";
|
||||
|
||||
export default function Layout({ children }) {
|
||||
return <SecureProvider>{children}</SecureProvider>;
|
||||
}
|
||||
```
|
||||
|
||||
### Access User Info
|
||||
```tsx
|
||||
import { useOidc, useOidcAccessToken, useOidcIdToken } from "@axa-fr/react-oidc";
|
||||
|
||||
function MyComponent() {
|
||||
const { isAuthenticated, login, logout } = useOidc();
|
||||
const { oidcAccessToken } = useOidcAccessToken();
|
||||
const { oidcIdToken } = useOidcIdToken();
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Flow
|
||||
1. User visits protected route
|
||||
2. `SecureProvider` checks authentication
|
||||
3. If not authenticated, redirects to Auth0 login
|
||||
4. After login, Auth0 redirects back with tokens
|
||||
5. Tokens stored in memory (not localStorage)
|
||||
6. API calls use access token for authorization
|
||||
|
||||
## Service Worker
|
||||
OIDC service worker must be copied to `public/`:
|
||||
```bash
|
||||
npm run copy
|
||||
npm run copytrusted
|
||||
```
|
||||
|
||||
This enables:
|
||||
- Token refresh without page reload
|
||||
- Secure token storage
|
||||
- Silent authentication
|
||||
|
||||
## Local Development
|
||||
Create `.local-config.json`:
|
||||
```json
|
||||
{
|
||||
"auth0Domain": "localhost",
|
||||
"auth0ClientId": "test-client-id",
|
||||
"auth0Audience": "test-audience",
|
||||
"auth0Authority": "http://localhost:9999",
|
||||
"auth0RedirectUri": "http://localhost:3000"
|
||||
}
|
||||
```
|
||||
|
||||
## Gotchas
|
||||
- Service worker file must be in `public/` directory
|
||||
- Tokens are in-memory only (cleared on page refresh)
|
||||
- CORS must be configured on Auth0 for local development
|
||||
- Redirect URI must match Auth0 configuration exactly
|
||||
- Silent authentication requires HTTPS in production
|
||||
46
docs/contexts/dns.md
Normal file
46
docs/contexts/dns.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# DNS 模块
|
||||
|
||||
## 功能
|
||||
管理网络的 DNS 名称服务器和 DNS 设置。
|
||||
|
||||
## API 接口
|
||||
- `GET /api/nameservers` - 列出名称服务器
|
||||
- `GET /api/nameservers/:id` - 获取名称服务器详情
|
||||
- `POST /api/nameservers` - 创建名称服务器
|
||||
- `PUT /api/nameservers/:id` - 更新名称服务器
|
||||
- `DELETE /api/nameservers/:id` - 删除名称服务器
|
||||
|
||||
## 关键类型
|
||||
```typescript
|
||||
interface Nameserver {
|
||||
id: string;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
groups: Group[];
|
||||
// ... 更多字段
|
||||
}
|
||||
```
|
||||
|
||||
## 文件路径
|
||||
- `src/modules/dns/` - UI 组件
|
||||
- `src/interfaces/Nameserver.ts` - 类型定义
|
||||
- `src/app/(dashboard)/dns/page.tsx` - 页面组件
|
||||
|
||||
## 组件
|
||||
- 名称服务器列表表格
|
||||
- 名称服务器编辑器
|
||||
- 分组分配
|
||||
|
||||
## 使用方法
|
||||
```tsx
|
||||
// 通过自定义钩子或上下文访问
|
||||
```
|
||||
|
||||
## 命令
|
||||
- 页面:`/dns`
|
||||
|
||||
## 注意事项
|
||||
- 名称服务器分配给分组
|
||||
- 分组决定哪些对等节点使用哪些名称服务器
|
||||
- 可以配置默认名称服务器
|
||||
53
docs/contexts/networks.md
Normal file
53
docs/contexts/networks.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# 网络模块
|
||||
|
||||
## 功能
|
||||
配置和管理网络设置、路由和网络级策略。
|
||||
|
||||
## API 接口
|
||||
- `GET /api/networks` - 列出网络
|
||||
- `GET /api/networks/:id` - 获取网络详情
|
||||
- `POST /api/networks` - 创建网络
|
||||
- `PUT /api/networks/:id` - 更新网络
|
||||
- `DELETE /api/networks/:id` - 删除网络
|
||||
|
||||
## 关键类型
|
||||
```typescript
|
||||
interface Network {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
// ... 更多字段
|
||||
}
|
||||
```
|
||||
|
||||
## 文件路径
|
||||
- `src/modules/networks/` - UI 组件
|
||||
- `src/contexts/RoutesProvider.tsx` - 路由数据
|
||||
- `src/contexts/GroupRouteProvider.tsx` - 分组路由
|
||||
- `src/interfaces/Network.ts` - 类型定义
|
||||
- `src/interfaces/Route.ts` - 路由类型
|
||||
- `src/app/(dashboard)/networks/page.tsx` - 网络页面
|
||||
- `src/app/(dashboard)/network-routes/page.tsx` - 路由页面
|
||||
|
||||
## 组件
|
||||
- 网络列表组件在 `src/modules/networks/` 中
|
||||
- 路由管理在 `src/modules/routes/` 中
|
||||
|
||||
## 使用方法
|
||||
```tsx
|
||||
import { useRoutes } from "@/contexts/RoutesProvider";
|
||||
|
||||
function MyComponent() {
|
||||
const { routes, isLoading } = useRoutes();
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## 命令
|
||||
- 网络:`/networks`
|
||||
- 路由:`/network-routes`
|
||||
|
||||
## 注意事项
|
||||
- 网络按账户范围划分
|
||||
- 路由定义对等节点之间的流量流向
|
||||
- 分组路由允许将路由分配给对等节点分组
|
||||
61
docs/contexts/peers.md
Normal file
61
docs/contexts/peers.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# 对等节点模块
|
||||
|
||||
## 功能
|
||||
管理网络对等节点 - 查看、配置和监控已连接的设备。
|
||||
|
||||
## API 接口
|
||||
- `GET /api/peers` - 列出所有对等节点
|
||||
- `GET /api/peers/:id` - 获取对等节点详情
|
||||
- `PUT /api/peers/:id` - 更新对等节点
|
||||
- `DELETE /api/peers/:id` - 删除对等节点
|
||||
|
||||
## 关键类型
|
||||
```typescript
|
||||
interface Peer {
|
||||
id: string;
|
||||
name: string;
|
||||
ip: string;
|
||||
connected: boolean;
|
||||
last_seen: string;
|
||||
os: OperatingSystem;
|
||||
version: string;
|
||||
groups: Group[];
|
||||
// ... 更多字段
|
||||
}
|
||||
```
|
||||
|
||||
## 文件路径
|
||||
- `src/modules/peers/` - UI 组件
|
||||
- `src/contexts/PeersProvider.tsx` - 数据提供者
|
||||
- `src/interfaces/Peer.ts` - 类型定义
|
||||
- `src/app/(dashboard)/peers/page.tsx` - 页面组件
|
||||
- `src/app/(dashboard)/peer/[id]/page.tsx` - 详情页面
|
||||
|
||||
## 组件
|
||||
- `PeersTable.tsx` - 主要对等节点列表表格
|
||||
- `PeerNameCell.tsx` - 对等节点名称显示
|
||||
- `PeerAddressCell.tsx` - IP 地址显示
|
||||
- `PeerStatusCell.tsx` - 连接状态
|
||||
- `PeerOSCell.tsx` - 操作系统图标
|
||||
- `PeerGroupCell.tsx` - 分组成员资格
|
||||
- `PeerActionCell.tsx` - 操作按钮
|
||||
- `PeerMultiSelect.tsx` - 多对等节点选择器
|
||||
|
||||
## 使用方法
|
||||
```tsx
|
||||
import { usePeers } from "@/contexts/PeersProvider";
|
||||
|
||||
function MyComponent() {
|
||||
const { peers, isLoading } = usePeers();
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## 命令
|
||||
- 页面:`/peers`
|
||||
- 详情:`/peer/:id`
|
||||
|
||||
## 注意事项
|
||||
- 对等节点列表可能很大 - 使用虚拟滚动
|
||||
- 状态更新通过轮询实现(SWR refreshInterval)
|
||||
- 操作系统图标位于 `src/assets/os-icons/`
|
||||
233
e2e/CLAUDE.md
Normal file
233
e2e/CLAUDE.md
Normal file
@@ -0,0 +1,233 @@
|
||||
# Playwright E2E Testing Guide
|
||||
|
||||
Complete reference for writing, running, and debugging Playwright E2E tests in the NetBird Dashboard.
|
||||
|
||||
## Philosophy
|
||||
|
||||
Tests simulate real user behavior: navigate via sidebar, click buttons, type into inputs, verify outcomes on screen. Use `{ force: true }` for Radix modal pointer-events issues.
|
||||
|
||||
## Setup & Running
|
||||
|
||||
```bash
|
||||
npm run test:setup # Create docker-based test environment with Zitadel
|
||||
npm run test:dev # Start app in test mode on http://localhost:1337
|
||||
npm run test # Run all e2e tests headless
|
||||
npm run test:ui # Open Playwright interactive UI
|
||||
npx playwright test --config=e2e/playwright.config.ts tests/networks.spec.ts # Single spec
|
||||
npm run test:clean # Tear down test environment
|
||||
```
|
||||
|
||||
Config: `e2e/playwright.config.ts` (baseURL: `http://localhost:1337`). Auth: `e2e/playwright.env.json` (gitignored).
|
||||
|
||||
### Config Details
|
||||
|
||||
- `fullyParallel: false` — tests run sequentially within each spec
|
||||
- Workers: 2 in CI, 4 locally
|
||||
- Retries: 1
|
||||
- Viewport: 1920x1080
|
||||
- Timeouts: action 10s, navigation 15s
|
||||
- On failure: screenshot, trace, video retained
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
e2e/
|
||||
playwright.config.ts
|
||||
helpers/
|
||||
fixtures.ts # dashboardAsOwner / dashboardAsUser fixtures
|
||||
auth.ts # loginToApp(), navigateTo()
|
||||
navigation.ts # visitByNavigation()
|
||||
utils.ts # generateRandomName(), clearScrollLock()
|
||||
api.ts # Direct REST API helpers (list/delete for all entities)
|
||||
reverse-proxy-l4.ts # Shared L4 reverse proxy helpers
|
||||
fixtures/auth/ # Generated storageState files (gitignored)
|
||||
environment/ # Docker compose, setup/teardown scripts
|
||||
tests/
|
||||
login.spec.ts # Auth setup (login both users, save storageState)
|
||||
*.spec.ts # Test specs
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Auth is handled by `login.spec.ts`, which runs as a separate Playwright project (`"login"`) that all other tests depend on via `dependencies: ["login"]` in the config. It logs in both users and saves Zitadel session cookies to `fixtures/auth/`. If auth files already exist, login is skipped. Each test file that modifies shared state (e.g., user roles) must restore it before finishing.
|
||||
|
||||
## Authentication
|
||||
|
||||
Two test users authenticated via the `login` project, saved as `storageState`:
|
||||
|
||||
| User | File | Role | Usage |
|
||||
|------|------|------|-------|
|
||||
| owner | `fixtures/auth/owner.json` | Owner | Default for all tests |
|
||||
| user | `fixtures/auth/user.json` | User (changeable) | Role-based testing |
|
||||
|
||||
### Custom Fixtures (`helpers/fixtures.ts`)
|
||||
|
||||
Tests use custom fixtures instead of raw `page`:
|
||||
|
||||
```typescript
|
||||
import { test, expect } from "../helpers/fixtures";
|
||||
|
||||
test("example", async ({ dashboardAsOwner: page }) => {
|
||||
// Pre-authenticated as owner, reused across worker
|
||||
});
|
||||
|
||||
test("multi-user", async ({ dashboardAsUser: page }) => {
|
||||
// Pre-authenticated as user
|
||||
});
|
||||
```
|
||||
|
||||
- `dashboardAsOwner` — Pre-authenticated Page for the owner user (worker-scoped, reused across tests)
|
||||
- `dashboardAsUser` — Pre-authenticated Page for the user user (worker-scoped)
|
||||
|
||||
For multi-context scenarios (e.g., approval/billing tests), create a new browser context directly:
|
||||
|
||||
```typescript
|
||||
const context = await browser.newContext({ storageState: "e2e/fixtures/auth/user.json" });
|
||||
const page = await context.newPage();
|
||||
```
|
||||
|
||||
## Helpers Reference
|
||||
|
||||
### `auth.ts`
|
||||
- **`loginToApp(page, user?)`** — Full Zitadel OIDC login flow. Handles app ready, setup modal, approval pending, onboarding, account selection, and login form states.
|
||||
- **`navigateTo(page, path)`** — `page.goto(path)` + dismisses setup modal if present + clears scroll-lock.
|
||||
|
||||
### `navigation.ts`
|
||||
- **`visitByNavigation(page, navText)`** — Clicks sidebar items by exact text via `left-navigation-item` testid.
|
||||
|
||||
### `utils.ts`
|
||||
- **`generateRandomName(prefix?)`** — Returns `prefix` + 7 random alphanumeric chars.
|
||||
- **`clearScrollLock(page)`** — Removes Radix artifacts: `data-scroll-locked`, `pointer-events: none`, stale overlay divs.
|
||||
|
||||
### `api.ts`
|
||||
Direct REST API helpers that extract Bearer tokens from intercepted responses. Used for cleanup (deleting test artifacts by prefix). Covers: groups, networks, policies, routes, setup keys, DNS zones, nameserver groups, notification channels, reverse proxy services, users.
|
||||
|
||||
Pattern: `listX(page)` / `deleteXById(page, id)` / `deleteXByPrefix(page, prefix)`
|
||||
|
||||
### `reverse-proxy-l4.ts`
|
||||
Shared helpers for TCP/TLS/UDP reverse proxy service tests:
|
||||
- **`createNetwork(page)`** — Creates network, returns name
|
||||
- **`addResource(page, networkName, address)`** — Adds resource to a network
|
||||
- **`selectL4Resource(page, resourceName)`** — Selects resource in L4 target dropdown
|
||||
- **`addAccessControlRules(page)`** / **`removeAllAccessControlRules(page)`** — Manages standard test rules
|
||||
- **`resetServiceFilters(page)`** — Clicks "Reset Filters & Search" button if visible
|
||||
- **`openServiceEdit(page, subdomain)`** — Navigates to services, resets filters, opens edit modal
|
||||
- **`deleteService(page, subdomain)`** — Deletes service via action dropdown
|
||||
- **`saveServiceEdit(page)`** — Saves with "No Protection" confirmation handling
|
||||
- **`deleteNetwork(page, networkName)`** — Navigates to networks and deletes by name
|
||||
|
||||
## Writing Tests
|
||||
|
||||
### Standard Structure
|
||||
|
||||
```typescript
|
||||
import { test, expect } from "../helpers/fixtures";
|
||||
import { navigateTo } from "../helpers/auth";
|
||||
import { generateRandomName } from "../helpers/utils";
|
||||
|
||||
test.describe.serial("Feature Name", () => {
|
||||
test("Should create an item", async ({ dashboardAsOwner: page }) => {
|
||||
await navigateTo(page, "/feature-page");
|
||||
const name = generateRandomName("prefix-");
|
||||
// ... create item
|
||||
});
|
||||
|
||||
test("Should delete the item", async ({ dashboardAsOwner: page }) => {
|
||||
// ... cleanup
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### Key Patterns
|
||||
|
||||
**Selectors** — Always use `data-testid` via `page.getByTestId()`:
|
||||
```typescript
|
||||
page.getByTestId("group-name-input") // [data-testid="group-name-input"]
|
||||
page.getByTestId("confirmation.confirm") // Confirmation dialogs
|
||||
```
|
||||
|
||||
**Text matching:**
|
||||
```typescript
|
||||
page.getByText("Some text")
|
||||
page.locator("tr").filter({ hasText: name })
|
||||
```
|
||||
|
||||
**Assertions:**
|
||||
```typescript
|
||||
await expect(locator).toBeVisible()
|
||||
await expect(locator).not.toBeVisible()
|
||||
await expect(locator).toHaveAttribute("data-state", "checked")
|
||||
await expect(locator).toContainText("text")
|
||||
```
|
||||
|
||||
**Form inputs:**
|
||||
```typescript
|
||||
await input.fill("text") // Clears and types
|
||||
await input.press("Enter")
|
||||
await input.press("Escape")
|
||||
```
|
||||
|
||||
**Radix modal workaround:**
|
||||
```typescript
|
||||
await button.click({ force: true }); // Force click, bypasses pointer-events checks
|
||||
```
|
||||
|
||||
**Waiting for API responses:**
|
||||
```typescript
|
||||
const responsePromise = page.waitForResponse(
|
||||
resp => resp.url().includes("/api/...") && resp.request().method() === "POST",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await page.getByTestId("submit").click();
|
||||
const response = await responsePromise;
|
||||
expect([200, 201]).toContain(response.status());
|
||||
```
|
||||
|
||||
**Cleanup with API helpers:**
|
||||
```typescript
|
||||
import { deleteGroupsByPrefix, deleteServicesByPrefix } from "../helpers/api";
|
||||
|
||||
// At the start of a test or in cleanup
|
||||
await deleteServicesByPrefix(page, "my-prefix-");
|
||||
await deleteGroupsByPrefix(page, "my-prefix-");
|
||||
```
|
||||
|
||||
### Sidebar Navigation
|
||||
|
||||
```typescript
|
||||
await visitByNavigation(page, "Access Control"); // Expand parent
|
||||
await visitByNavigation(page, "Policies"); // Click child
|
||||
```
|
||||
|
||||
| Parent | Children |
|
||||
|--------|----------|
|
||||
| Access Control | Policies, Groups, Posture Checks |
|
||||
| Team | Users, Service Users |
|
||||
| DNS | Nameservers, Zones, DNS Settings |
|
||||
| Reverse Proxy | Custom Domains, Services |
|
||||
|
||||
## Test Coverage
|
||||
|
||||
| Area | Spec Files | Tag |
|
||||
|------|-----------|-----|
|
||||
| Access Control | `access-control.spec.ts`, `access-control-groups.spec.ts` | `@access-control` |
|
||||
| DNS | `dns-zones.spec.ts`, `dns-nameservers.spec.ts`, `dns-settings.spec.ts` | `@dns` |
|
||||
| Networks | `networks.spec.ts`, `network-routes.spec.ts` | `@network` |
|
||||
| Reverse Proxy | `reverse-proxy-services-https.spec.ts`, `reverse-proxy-services-tcp.spec.ts`, `reverse-proxy-services-tls.spec.ts`, `reverse-proxy-services-udp.spec.ts`, `reverse-proxy-custom-domains.spec.ts` | `@reverse-proxy` |
|
||||
| Settings | `settings-authentication.spec.ts`, `settings-clients.spec.ts`, `settings-groups.spec.ts`, `settings-networks.spec.ts`, `settings-permissions.spec.ts` | `@settings` |
|
||||
| Notifications | `settings-notifications-email.spec.ts`, `settings-notifications-slack.spec.ts`, `settings-notifications-webhook.spec.ts` | `@notifications` |
|
||||
| Team | `team-users.spec.ts`, `team-service-users.spec.ts`, `team-users-approval-and-billing.spec.ts` | `@team` |
|
||||
| Setup Keys | `setup-keys.spec.ts` | `@setup-keys` |
|
||||
|
||||
## Debugging
|
||||
|
||||
1. `e2e/test-results/` — traces and screenshots on failure
|
||||
2. `npx playwright show-report` — open the HTML report
|
||||
3. `npm run test:ui` — interactive mode with step-by-step execution
|
||||
4. `npx playwright test --config=e2e/playwright.config.ts --debug tests/<file>` — debugger mode
|
||||
|
||||
## `data-testid` Conventions
|
||||
|
||||
- Use `data-testid` selectors throughout. Add new ones to React components as needed.
|
||||
- Kebab-case naming: `feature-field-input`, `action-feature`, `feature-actions`.
|
||||
- Always use `data-testid` — both on native HTML elements and custom components. Custom components declare `"data-testid"?: string` in their props interface and place it on the appropriate internal DOM element.
|
||||
12
e2e/environment/.gitignore
vendored
Normal file
12
e2e/environment/.gitignore
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
# Ignore zitadel environment
|
||||
.env
|
||||
Caddyfile
|
||||
management.json
|
||||
turnserver.conf
|
||||
zitadel.env
|
||||
proxy.env
|
||||
proxy-no-ports.env
|
||||
proxy-certs/
|
||||
proxy-certs-no-ports/
|
||||
docker-compose.yml
|
||||
/machinekey
|
||||
25
e2e/environment/clean-test-env.sh
Normal file
25
e2e/environment/clean-test-env.sh
Normal file
@@ -0,0 +1,25 @@
|
||||
#!/bin/bash
|
||||
|
||||
check_docker_compose() {
|
||||
if command -v docker-compose &> /dev/null
|
||||
then
|
||||
echo "docker-compose"
|
||||
return
|
||||
fi
|
||||
if docker compose --help &> /dev/null
|
||||
then
|
||||
echo "docker compose"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "docker-compose is not installed or not in PATH. Please follow the steps from the official guide: https://docs.docker.com/engine/install/" > /dev/stderr
|
||||
exit 1
|
||||
}
|
||||
|
||||
DOCKER_COMPOSE_COMMAND=$(check_docker_compose)
|
||||
|
||||
$DOCKER_COMPOSE_COMMAND down --volumes
|
||||
rm -f docker-compose.yml Caddyfile zitadel.env .env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env
|
||||
rm -rf proxy-certs proxy-certs-no-ports
|
||||
rm -f ../../.test-config.json ../playwright.env.json
|
||||
rm -f ../fixtures/auth/owner.json ../fixtures/auth/user.json
|
||||
927
e2e/environment/create-test-env.sh
Normal file
927
e2e/environment/create-test-env.sh
Normal file
@@ -0,0 +1,927 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
# Tag of the management-cloud image to pull. Override via env var to pin the
|
||||
# tests to a specific management-cloud build (e.g., a feature branch image).
|
||||
MANAGEMENT_IMAGE_TAG="${MANAGEMENT_IMAGE_TAG:-main}"
|
||||
echo "Using ghcr.io/netbirdio/management-cloud:${MANAGEMENT_IMAGE_TAG}"
|
||||
|
||||
# Tag of the reverse-proxy image to pull. Override via env var to pin the
|
||||
# tests to a specific reverse-proxy build (e.g., a feature branch image).
|
||||
REVERSE_PROXY_IMAGE_TAG="${REVERSE_PROXY_IMAGE_TAG:-main}"
|
||||
echo "Using ghcr.io/netbirdio/reverse-proxy:${REVERSE_PROXY_IMAGE_TAG}"
|
||||
|
||||
handle_request_command_status() {
|
||||
PARSED_RESPONSE=$1
|
||||
FUNCTION_NAME=$2
|
||||
RESPONSE=$3
|
||||
if [[ $PARSED_RESPONSE -ne 0 ]]; then
|
||||
echo "ERROR calling $FUNCTION_NAME:" $(echo "$RESPONSE" | jq -r '.message') > /dev/stderr
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
handle_zitadel_request_response() {
|
||||
PARSED_RESPONSE=$1
|
||||
FUNCTION_NAME=$2
|
||||
RESPONSE=$3
|
||||
if [[ $PARSED_RESPONSE == "null" ]]; then
|
||||
echo "ERROR calling $FUNCTION_NAME:" $(echo "$RESPONSE" | jq -r '.message') > /dev/stderr
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
}
|
||||
|
||||
check_docker_compose() {
|
||||
if command -v docker-compose &> /dev/null
|
||||
then
|
||||
echo "docker-compose"
|
||||
return
|
||||
fi
|
||||
if docker compose --help &> /dev/null
|
||||
then
|
||||
echo "docker compose"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "docker-compose is not installed or not in PATH. Please follow the steps from the official guide: https://docs.docker.com/engine/install/" > /dev/stderr
|
||||
exit 1
|
||||
}
|
||||
|
||||
check_jq() {
|
||||
if ! command -v jq &> /dev/null
|
||||
then
|
||||
echo "jq is not installed or not in PATH, please install with your package manager. e.g. sudo apt install jq" > /dev/stderr
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
wait_proxy_cluster() {
|
||||
SERVICE_NAME=${1:-reverse-proxy}
|
||||
echo -n "Waiting for $SERVICE_NAME to register with management "
|
||||
set +e
|
||||
local attempts=60
|
||||
local i
|
||||
for ((i = 1; i <= attempts; i++)); do
|
||||
if $DOCKER_COMPOSE_COMMAND logs "$SERVICE_NAME" 2>&1 | grep -q "Initial mapping sync complete"; then
|
||||
echo " done"
|
||||
set -e
|
||||
return
|
||||
fi
|
||||
echo -n " ."
|
||||
sleep 2
|
||||
done
|
||||
echo ""
|
||||
echo "ERROR: $SERVICE_NAME did not register with management after $((attempts * 2))s"
|
||||
echo "--- $SERVICE_NAME logs ---"
|
||||
$DOCKER_COMPOSE_COMMAND logs --tail=50 "$SERVICE_NAME" || true
|
||||
exit 1
|
||||
}
|
||||
|
||||
wait_crdb() {
|
||||
set +e
|
||||
while true; do
|
||||
if $DOCKER_COMPOSE_COMMAND exec -T crdb curl -sf -o /dev/null 'http://localhost:8080/health?ready=1'; then
|
||||
break
|
||||
fi
|
||||
echo -n " ."
|
||||
sleep 5
|
||||
done
|
||||
echo " done"
|
||||
set -e
|
||||
}
|
||||
|
||||
init_crdb() {
|
||||
echo -e "\nInitializing Zitadel's CockroachDB\n\n"
|
||||
$DOCKER_COMPOSE_COMMAND up -d crdb
|
||||
echo ""
|
||||
# shellcheck disable=SC2028
|
||||
echo -n "Waiting cockroachDB to become ready "
|
||||
wait_crdb
|
||||
$DOCKER_COMPOSE_COMMAND exec -T crdb /bin/bash -c "cp /cockroach/certs/* /zitadel-certs/ && cockroach cert create-client --overwrite --certs-dir /zitadel-certs/ --ca-key /zitadel-certs/ca.key zitadel_user && chown -R 1000:1000 /zitadel-certs/"
|
||||
handle_request_command_status $? "init_crdb failed" ""
|
||||
}
|
||||
|
||||
get_main_ip_address() {
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
interface=$(route -n get default | grep 'interface:' | awk '{print $2}')
|
||||
ip_address=$(ifconfig "$interface" | grep 'inet ' | awk '{print $2}')
|
||||
else
|
||||
interface=$(ip route | grep default | awk '{print $5}' | head -n 1)
|
||||
ip_address=$(ip addr show "$interface" | grep 'inet ' | awk '{print $2}' | cut -d'/' -f1)
|
||||
fi
|
||||
|
||||
echo "$ip_address"
|
||||
}
|
||||
|
||||
wait_pat() {
|
||||
PAT_PATH=$1
|
||||
set +e
|
||||
while true; do
|
||||
if [[ -f "$PAT_PATH" ]]; then
|
||||
break
|
||||
fi
|
||||
echo -n " ."
|
||||
sleep 1
|
||||
done
|
||||
echo " done"
|
||||
set -e
|
||||
}
|
||||
|
||||
wait_api() {
|
||||
INSTANCE_URL=$1
|
||||
PAT=$2
|
||||
set +e
|
||||
while true; do
|
||||
curl -s --fail -o /dev/null "$INSTANCE_URL/auth/v1/users/me" -H "Authorization: Bearer $PAT"
|
||||
if [[ $? -eq 0 ]]; then
|
||||
break
|
||||
fi
|
||||
echo -n " ."
|
||||
sleep 1
|
||||
done
|
||||
echo " done"
|
||||
set -e
|
||||
}
|
||||
|
||||
create_new_project() {
|
||||
INSTANCE_URL=$1
|
||||
PAT=$2
|
||||
PROJECT_NAME="NETBIRD"
|
||||
|
||||
RESPONSE=$(
|
||||
curl -sS -X POST "$INSTANCE_URL/management/v1/projects" \
|
||||
-H "Authorization: Bearer $PAT" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "'"$PROJECT_NAME"'"}'
|
||||
)
|
||||
PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.id')
|
||||
handle_zitadel_request_response "$PARSED_RESPONSE" "create_new_project" "$RESPONSE"
|
||||
echo "$PARSED_RESPONSE"
|
||||
}
|
||||
|
||||
create_new_application() {
|
||||
INSTANCE_URL=$1
|
||||
PAT=$2
|
||||
APPLICATION_NAME=$3
|
||||
BASE_REDIRECT_URL1=$4
|
||||
BASE_REDIRECT_URL2=$5
|
||||
LOGOUT_URL=$6
|
||||
ZITADEL_DEV_MODE=$7
|
||||
|
||||
RESPONSE=$(
|
||||
curl -sS -X POST "$INSTANCE_URL/management/v1/projects/$PROJECT_ID/apps/oidc" \
|
||||
-H "Authorization: Bearer $PAT" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "'"$APPLICATION_NAME"'",
|
||||
"redirectUris": [
|
||||
"'"$BASE_REDIRECT_URL1"'",
|
||||
"'"$BASE_REDIRECT_URL2"'"
|
||||
],
|
||||
"postLogoutRedirectUris": [
|
||||
"'"$LOGOUT_URL"'"
|
||||
],
|
||||
"RESPONSETypes": [
|
||||
"OIDC_RESPONSE_TYPE_CODE"
|
||||
],
|
||||
"grantTypes": [
|
||||
"OIDC_GRANT_TYPE_AUTHORIZATION_CODE",
|
||||
"OIDC_GRANT_TYPE_REFRESH_TOKEN"
|
||||
],
|
||||
"appType": "OIDC_APP_TYPE_USER_AGENT",
|
||||
"authMethodType": "OIDC_AUTH_METHOD_TYPE_NONE",
|
||||
"version": "OIDC_VERSION_1_0",
|
||||
"devMode": '"$ZITADEL_DEV_MODE"',
|
||||
"accessTokenType": "OIDC_TOKEN_TYPE_JWT",
|
||||
"accessTokenRoleAssertion": true,
|
||||
"skipNativeAppSuccessPage": true
|
||||
}'
|
||||
)
|
||||
|
||||
PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.clientId')
|
||||
handle_zitadel_request_response "$PARSED_RESPONSE" "create_new_application" "$RESPONSE"
|
||||
echo "$PARSED_RESPONSE"
|
||||
}
|
||||
|
||||
create_service_user() {
|
||||
INSTANCE_URL=$1
|
||||
PAT=$2
|
||||
|
||||
RESPONSE=$(
|
||||
curl -sS -X POST "$INSTANCE_URL/management/v1/users/machine" \
|
||||
-H "Authorization: Bearer $PAT" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"userName": "netbird-service-account",
|
||||
"name": "Netbird Service Account",
|
||||
"description": "Netbird Service Account for IDP management",
|
||||
"accessTokenType": "ACCESS_TOKEN_TYPE_JWT"
|
||||
}'
|
||||
)
|
||||
PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.userId')
|
||||
handle_zitadel_request_response "$PARSED_RESPONSE" "create_service_user" "$RESPONSE"
|
||||
echo "$PARSED_RESPONSE"
|
||||
}
|
||||
|
||||
create_service_user_secret() {
|
||||
INSTANCE_URL=$1
|
||||
PAT=$2
|
||||
USER_ID=$3
|
||||
|
||||
RESPONSE=$(
|
||||
curl -sS -X PUT "$INSTANCE_URL/management/v1/users/$USER_ID/secret" \
|
||||
-H "Authorization: Bearer $PAT" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{}'
|
||||
)
|
||||
SERVICE_USER_CLIENT_ID=$(echo "$RESPONSE" | jq -r '.clientId')
|
||||
handle_zitadel_request_response "$SERVICE_USER_CLIENT_ID" "create_service_user_secret_id" "$RESPONSE"
|
||||
SERVICE_USER_CLIENT_SECRET=$(echo "$RESPONSE" | jq -r '.clientSecret')
|
||||
handle_zitadel_request_response "$SERVICE_USER_CLIENT_SECRET" "create_service_user_secret" "$RESPONSE"
|
||||
}
|
||||
|
||||
add_organization_user_manager() {
|
||||
INSTANCE_URL=$1
|
||||
PAT=$2
|
||||
USER_ID=$3
|
||||
|
||||
RESPONSE=$(
|
||||
curl -sS -X POST "$INSTANCE_URL/management/v1/orgs/me/members" \
|
||||
-H "Authorization: Bearer $PAT" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"userId": "'"$USER_ID"'",
|
||||
"roles": [
|
||||
"ORG_USER_MANAGER"
|
||||
]
|
||||
}'
|
||||
)
|
||||
PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.details.creationDate')
|
||||
handle_zitadel_request_response "$PARSED_RESPONSE" "add_organization_user_manager" "$RESPONSE"
|
||||
echo "$PARSED_RESPONSE"
|
||||
}
|
||||
|
||||
create_admin_user() {
|
||||
INSTANCE_URL=$1
|
||||
PAT=$2
|
||||
USERNAME=$3
|
||||
PASSWORD=$4
|
||||
FIRST_NAME=${5:-"Zitadel"}
|
||||
LAST_NAME=${6:-"Admin"}
|
||||
RESPONSE=$(
|
||||
curl -sS -X POST "$INSTANCE_URL/management/v1/users/human/_import" \
|
||||
-H "Authorization: Bearer $PAT" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"userName": "'"$USERNAME"'",
|
||||
"profile": {
|
||||
"firstName": "'"$FIRST_NAME"'",
|
||||
"lastName": "'"$LAST_NAME"'"
|
||||
},
|
||||
"email": {
|
||||
"email": "'"$USERNAME"'",
|
||||
"isEmailVerified": true
|
||||
},
|
||||
"password": "'"$PASSWORD"'",
|
||||
"passwordChangeRequired": false
|
||||
}'
|
||||
)
|
||||
PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.userId')
|
||||
handle_zitadel_request_response "$PARSED_RESPONSE" "create_admin_user" "$RESPONSE"
|
||||
echo "$PARSED_RESPONSE"
|
||||
}
|
||||
|
||||
add_instance_admin() {
|
||||
INSTANCE_URL=$1
|
||||
PAT=$2
|
||||
USER_ID=$3
|
||||
|
||||
RESPONSE=$(
|
||||
curl -sS -X POST "$INSTANCE_URL/admin/v1/members" \
|
||||
-H "Authorization: Bearer $PAT" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"userId": "'"$USER_ID"'",
|
||||
"roles": [
|
||||
"IAM_OWNER"
|
||||
]
|
||||
}'
|
||||
)
|
||||
PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.details.creationDate')
|
||||
handle_zitadel_request_response "$PARSED_RESPONSE" "add_instance_admin" "$RESPONSE"
|
||||
echo "$PARSED_RESPONSE"
|
||||
}
|
||||
|
||||
delete_auto_service_user() {
|
||||
INSTANCE_URL=$1
|
||||
PAT=$2
|
||||
|
||||
RESPONSE=$(
|
||||
curl -sS -X GET "$INSTANCE_URL/auth/v1/users/me" \
|
||||
-H "Authorization: Bearer $PAT" \
|
||||
-H "Content-Type: application/json" \
|
||||
)
|
||||
USER_ID=$(echo "$RESPONSE" | jq -r '.user.id')
|
||||
handle_zitadel_request_response "$USER_ID" "delete_auto_service_user_get_user" "$RESPONSE"
|
||||
|
||||
RESPONSE=$(
|
||||
curl -sS -X DELETE "$INSTANCE_URL/admin/v1/members/$USER_ID" \
|
||||
-H "Authorization: Bearer $PAT" \
|
||||
-H "Content-Type: application/json" \
|
||||
)
|
||||
PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.details.changeDate')
|
||||
handle_zitadel_request_response "$PARSED_RESPONSE" "delete_auto_service_user_remove_instance_permissions" "$RESPONSE"
|
||||
|
||||
RESPONSE=$(
|
||||
curl -sS -X DELETE "$INSTANCE_URL/management/v1/orgs/me/members/$USER_ID" \
|
||||
-H "Authorization: Bearer $PAT" \
|
||||
-H "Content-Type: application/json" \
|
||||
)
|
||||
PARSED_RESPONSE=$(echo "$RESPONSE" | jq -r '.details.changeDate')
|
||||
handle_zitadel_request_response "$PARSED_RESPONSE" "delete_auto_service_user_remove_org_permissions" "$RESPONSE"
|
||||
echo "$PARSED_RESPONSE"
|
||||
}
|
||||
|
||||
create_proxy_token() {
|
||||
TOKEN_NAME=$1
|
||||
echo "Creating proxy token '$TOKEN_NAME'..." >&2
|
||||
local attempts=30
|
||||
local delay=2
|
||||
local i
|
||||
local out=""
|
||||
local tok=""
|
||||
for ((i = 1; i <= attempts; i++)); do
|
||||
out=$($DOCKER_COMPOSE_COMMAND exec -T management /go/bin/netbird-mgmt token create \
|
||||
--name "$TOKEN_NAME" \
|
||||
--config /etc/netbird/management.json \
|
||||
--log-file console \
|
||||
--log-level error 2>&1 || true)
|
||||
|
||||
tok=$(echo "$out" | grep "^Token:" | awk '{print $2}')
|
||||
if [ -n "$tok" ]; then
|
||||
break
|
||||
fi
|
||||
echo " attempt $i/$attempts: management not ready yet, retrying in ${delay}s..." >&2
|
||||
sleep "$delay"
|
||||
done
|
||||
|
||||
if [ -z "$tok" ]; then
|
||||
echo "ERROR: Failed to create proxy token '$TOKEN_NAME' after $attempts attempts" >&2
|
||||
echo "Last output from management:" >&2
|
||||
echo "$out" >&2
|
||||
echo "--- docker compose ps ---" >&2
|
||||
$DOCKER_COMPOSE_COMMAND ps >&2 || true
|
||||
echo "--- management logs ---" >&2
|
||||
$DOCKER_COMPOSE_COMMAND logs --tail=200 management >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
echo "Proxy token '$TOKEN_NAME' created: ${tok:0:10}..." >&2
|
||||
echo "$tok"
|
||||
}
|
||||
|
||||
init_proxy_tokens() {
|
||||
echo "Waiting for management container to become ready..."
|
||||
# Default proxy (supports custom ports)
|
||||
NB_PROXY_TOKEN=$(create_proxy_token "test-proxy")
|
||||
cat > proxy.env <<PROXYEOF
|
||||
NB_PROXY_TOKEN=$NB_PROXY_TOKEN
|
||||
NB_PROXY_ALLOW_INSECURE=true
|
||||
NB_PROXY_LOG_LEVEL=trace
|
||||
PROXYEOF
|
||||
|
||||
# Secondary proxy (custom ports disabled)
|
||||
NB_PROXY_TOKEN_NO_PORTS=$(create_proxy_token "test-proxy-no-ports")
|
||||
cat > proxy-no-ports.env <<PROXYEOF
|
||||
NB_PROXY_TOKEN=$NB_PROXY_TOKEN_NO_PORTS
|
||||
NB_PROXY_ALLOW_INSECURE=true
|
||||
PROXYEOF
|
||||
}
|
||||
|
||||
init_zitadel() {
|
||||
echo -e "\nInitializing Zitadel with NetBird's applications\n"
|
||||
INSTANCE_URL="$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN:$NETBIRD_PORT"
|
||||
|
||||
TOKEN_PATH=./machinekey/zitadel-admin-sa.token
|
||||
|
||||
echo -n "Waiting for Zitadel's PAT to be created "
|
||||
wait_pat "$TOKEN_PATH"
|
||||
echo "Reading Zitadel PAT"
|
||||
PAT=$(cat $TOKEN_PATH)
|
||||
if [ "$PAT" = "null" ]; then
|
||||
echo "Failed requesting getting Zitadel PAT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -n "Waiting for Zitadel to become ready "
|
||||
wait_api "$INSTANCE_URL" "$PAT"
|
||||
|
||||
# create the zitadel project
|
||||
echo "Creating new zitadel project"
|
||||
PROJECT_ID=$(create_new_project "$INSTANCE_URL" "$PAT")
|
||||
|
||||
ZITADEL_DEV_MODE=false
|
||||
BASE_REDIRECT_URL=$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN
|
||||
if [[ $NETBIRD_HTTP_PROTOCOL == "http" ]]; then
|
||||
ZITADEL_DEV_MODE=true
|
||||
fi
|
||||
|
||||
# create zitadel spa applications
|
||||
echo "Creating new Zitadel SPA Dashboard application"
|
||||
DASHBOARD_APPLICATION_CLIENT_ID=$(create_new_application "$INSTANCE_URL" "$PAT" "Dashboard" "http://localhost:1337/nb-auth" "http://localhost:1337/nb-silent-auth" "http://localhost:1337/" "true")
|
||||
|
||||
echo "Creating new Zitadel SPA Cli application"
|
||||
CLI_APPLICATION_CLIENT_ID=$(create_new_application "$INSTANCE_URL" "$PAT" "Cli" "http://localhost:53000/" "http://localhost:54000/" "http://localhost:53000/" "true")
|
||||
|
||||
MACHINE_USER_ID=$(create_service_user "$INSTANCE_URL" "$PAT")
|
||||
|
||||
SERVICE_USER_CLIENT_ID="null"
|
||||
SERVICE_USER_CLIENT_SECRET="null"
|
||||
|
||||
create_service_user_secret "$INSTANCE_URL" "$PAT" "$MACHINE_USER_ID"
|
||||
|
||||
DATE=$(add_organization_user_manager "$INSTANCE_URL" "$PAT" "$MACHINE_USER_ID")
|
||||
|
||||
ZITADEL_ADMIN_USERNAME="owner@localhost.test"
|
||||
ZITADEL_ADMIN_PASSWORD="testMe123@"
|
||||
|
||||
HUMAN_USER_ID=$(create_admin_user "$INSTANCE_URL" "$PAT" "$ZITADEL_ADMIN_USERNAME" "$ZITADEL_ADMIN_PASSWORD")
|
||||
|
||||
DATE="null"
|
||||
|
||||
DATE=$(add_instance_admin "$INSTANCE_URL" "$PAT" "$HUMAN_USER_ID")
|
||||
|
||||
# Create a second user for role-based testing (e.g., Billing Admin)
|
||||
ZITADEL_SECOND_USERNAME="user@localhost.test"
|
||||
ZITADEL_SECOND_PASSWORD="testMe123@"
|
||||
|
||||
SECOND_USER_ID=$(create_admin_user "$INSTANCE_URL" "$PAT" "$ZITADEL_SECOND_USERNAME" "$ZITADEL_SECOND_PASSWORD" "Zitadel" "User")
|
||||
DATE=$(add_instance_admin "$INSTANCE_URL" "$PAT" "$SECOND_USER_ID")
|
||||
|
||||
DATE="null"
|
||||
DATE=$(delete_auto_service_user "$INSTANCE_URL" "$PAT")
|
||||
if [ "$DATE" = "null" ]; then
|
||||
echo "Failed deleting auto service user"
|
||||
echo "Please remove it manually"
|
||||
fi
|
||||
|
||||
export NETBIRD_AUTH_CLIENT_ID=$DASHBOARD_APPLICATION_CLIENT_ID
|
||||
export NETBIRD_AUTH_CLIENT_ID_CLI=$CLI_APPLICATION_CLIENT_ID
|
||||
export NETBIRD_IDP_MGMT_CLIENT_ID=$SERVICE_USER_CLIENT_ID
|
||||
export NETBIRD_IDP_MGMT_CLIENT_SECRET=$SERVICE_USER_CLIENT_SECRET
|
||||
export ZITADEL_ADMIN_USERNAME
|
||||
export ZITADEL_ADMIN_PASSWORD
|
||||
}
|
||||
|
||||
check_nb_domain() {
|
||||
DOMAIN=$1
|
||||
if [ "$DOMAIN-x" == "-x" ]; then
|
||||
echo "The NETBIRD_DOMAIN variable cannot be empty." > /dev/stderr
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ "$DOMAIN" == "netbird.example.com" ]; then
|
||||
echo "The NETBIRD_DOMAIN cannot be netbird.example.com" > /dev/stderr
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
read_nb_domain() {
|
||||
READ_NETBIRD_DOMAIN=""
|
||||
echo -n "Enter the domain you want to use for NetBird (e.g. netbird.my-domain.com): " > /dev/stderr
|
||||
read -r READ_NETBIRD_DOMAIN < /dev/tty
|
||||
if ! check_nb_domain "$READ_NETBIRD_DOMAIN"; then
|
||||
read_nb_domain
|
||||
fi
|
||||
echo "$READ_NETBIRD_DOMAIN"
|
||||
}
|
||||
|
||||
initEnvironment() {
|
||||
CADDY_SECURE_DOMAIN=""
|
||||
ZITADEL_EXTERNALSECURE="false"
|
||||
ZITADEL_TLS_MODE="disabled"
|
||||
ZITADEL_MASTERKEY="$(openssl rand -base64 32 | head -c 32)"
|
||||
NETBIRD_PORT=33080
|
||||
NETBIRD_HTTP_PROTOCOL="http"
|
||||
TURN_USER="self"
|
||||
TURN_PASSWORD=$(openssl rand -base64 32 | sed 's/=//g')
|
||||
TURN_MIN_PORT=49152
|
||||
TURN_MAX_PORT=65535
|
||||
|
||||
NETBIRD_DOMAIN=$(get_main_ip_address)
|
||||
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
ZIDATE_TOKEN_EXPIRATION_DATE=$(date -u -v+30M "+%Y-%m-%dT%H:%M:%SZ")
|
||||
else
|
||||
ZIDATE_TOKEN_EXPIRATION_DATE=$(date -u -d "+30 minutes" "+%Y-%m-%dT%H:%M:%SZ")
|
||||
fi
|
||||
|
||||
check_jq
|
||||
|
||||
DOCKER_COMPOSE_COMMAND=$(check_docker_compose)
|
||||
|
||||
if [ -f zitadel.env ]; then
|
||||
echo "Generated files already exist, if you want to reinitialize the environment, please remove them first."
|
||||
echo "You can use the following commands:"
|
||||
echo " $DOCKER_COMPOSE_COMMAND down --volumes # to remove all containers and volumes"
|
||||
echo " rm -f docker-compose.yml Caddyfile zitadel.env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json proxy.env proxy-no-ports.env && rm -rf proxy-certs proxy-certs-no-ports"
|
||||
echo "Be aware that this will remove all data from the database, and you will have to reconfigure the dashboard."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo Rendering initial files...
|
||||
renderDockerCompose > docker-compose.yml
|
||||
renderCaddyfile > Caddyfile
|
||||
renderZitadelEnv > zitadel.env
|
||||
echo "" > turnserver.conf
|
||||
echo "" > management.json
|
||||
echo "" > proxy.env
|
||||
echo "" > proxy-no-ports.env
|
||||
|
||||
mkdir -p machinekey
|
||||
chmod 777 machinekey
|
||||
|
||||
init_crdb
|
||||
|
||||
echo -e "\nStarting Zidatel IDP for user management\n\n"
|
||||
$DOCKER_COMPOSE_COMMAND up -d caddy zitadel
|
||||
init_zitadel
|
||||
|
||||
echo -e "\nRendering NetBird files...\n"
|
||||
renderTurnServerConf > turnserver.conf
|
||||
renderManagementJson > management.json
|
||||
renderDashboardEnv > "../../.test-config.json"
|
||||
|
||||
echo -e "\nRendering Playwright environment file...\n"
|
||||
renderPlaywrightEnv > "../playwright.env.json"
|
||||
|
||||
echo -e "\nPulling latest images...\n"
|
||||
docker pull "ghcr.io/netbirdio/management-cloud:${MANAGEMENT_IMAGE_TAG}"
|
||||
docker pull "ghcr.io/netbirdio/reverse-proxy:${REVERSE_PROXY_IMAGE_TAG}"
|
||||
|
||||
# Pre-create the proxy cert directories BEFORE starting containers so that
|
||||
# docker's bind-mounts (./proxy-certs and ./proxy-certs-no-ports) reuse our
|
||||
# runner-owned dirs instead of creating root-owned ones, which would
|
||||
# prevent openssl from writing the generated keys/certs below. Each proxy
|
||||
# gets its own cert dir so it registers with a distinct identity (a shared
|
||||
# cert collapses both proxies onto one proxy ID and management superseding
|
||||
# flaps cluster registration).
|
||||
mkdir -p proxy-certs proxy-certs-no-ports
|
||||
|
||||
echo -e "\nStarting NetBird services\n"
|
||||
$DOCKER_COMPOSE_COMMAND up -d
|
||||
|
||||
echo -e "\nWaiting for management to be ready...\n"
|
||||
sleep 5
|
||||
|
||||
echo -e "\nGenerating self-signed TLS certificates for reverse proxies...\n"
|
||||
openssl req -x509 -newkey rsa:2048 -keyout proxy-certs/tls.key -out proxy-certs/tls.crt \
|
||||
-days 365 -nodes -subj "/CN=example.com" \
|
||||
-addext "subjectAltName=DNS:example.com,DNS:*.example.com,DNS:noports.example.com,DNS:*.noports.example.com"
|
||||
chmod 644 proxy-certs/tls.key proxy-certs/tls.crt
|
||||
openssl req -x509 -newkey rsa:2048 -keyout proxy-certs-no-ports/tls.key -out proxy-certs-no-ports/tls.crt \
|
||||
-days 365 -nodes -subj "/CN=noports.example.com" \
|
||||
-addext "subjectAltName=DNS:noports.example.com,DNS:*.noports.example.com"
|
||||
chmod 644 proxy-certs-no-ports/tls.key proxy-certs-no-ports/tls.crt
|
||||
|
||||
echo -e "\nCreating proxy access tokens...\n"
|
||||
init_proxy_tokens
|
||||
|
||||
echo -e "\nStarting reverse proxy services...\n"
|
||||
$DOCKER_COMPOSE_COMMAND up -d reverse-proxy reverse-proxy-no-ports
|
||||
|
||||
echo -e "\nWaiting for reverse proxies to register with management...\n"
|
||||
wait_proxy_cluster reverse-proxy
|
||||
wait_proxy_cluster reverse-proxy-no-ports
|
||||
|
||||
echo -e "\nDone!\n"
|
||||
echo "Run 'npm run test:dev' to start the dashboard at http://localhost:1337"
|
||||
echo "Management API is at $NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN:$NETBIRD_PORT"
|
||||
echo "Login with the following credentials:"
|
||||
echo "Username: $ZITADEL_ADMIN_USERNAME" | tee .env
|
||||
echo "Password: $ZITADEL_ADMIN_PASSWORD" | tee -a .env
|
||||
}
|
||||
|
||||
renderCaddyfile() {
|
||||
cat <<EOF
|
||||
{
|
||||
debug
|
||||
servers :80,:443 {
|
||||
protocols h1 h2c
|
||||
}
|
||||
}
|
||||
|
||||
:80${CADDY_SECURE_DOMAIN} {
|
||||
# Signal
|
||||
reverse_proxy /signalexchange.SignalExchange/* h2c://signal:10000
|
||||
# Management
|
||||
reverse_proxy /api/* management:80
|
||||
reverse_proxy /management.ManagementService/* h2c://management:80
|
||||
# Zitadel
|
||||
reverse_proxy /zitadel.admin.v1.AdminService/* h2c://zitadel:8080
|
||||
reverse_proxy /admin/v1/* h2c://zitadel:8080
|
||||
reverse_proxy /zitadel.auth.v1.AuthService/* h2c://zitadel:8080
|
||||
reverse_proxy /auth/v1/* h2c://zitadel:8080
|
||||
reverse_proxy /zitadel.management.v1.ManagementService/* h2c://zitadel:8080
|
||||
reverse_proxy /management/v1/* h2c://zitadel:8080
|
||||
reverse_proxy /zitadel.system.v1.SystemService/* h2c://zitadel:8080
|
||||
reverse_proxy /system/v1/* h2c://zitadel:8080
|
||||
reverse_proxy /assets/v1/* h2c://zitadel:8080
|
||||
reverse_proxy /ui/* h2c://zitadel:8080
|
||||
reverse_proxy /oidc/v1/* h2c://zitadel:8080
|
||||
reverse_proxy /saml/v2/* h2c://zitadel:8080
|
||||
reverse_proxy /oauth/v2/* h2c://zitadel:8080
|
||||
reverse_proxy /.well-known/openid-configuration h2c://zitadel:8080
|
||||
reverse_proxy /openapi/* h2c://zitadel:8080
|
||||
reverse_proxy /debug/* h2c://zitadel:8080
|
||||
# Dashboard
|
||||
reverse_proxy /* dashboard:80
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
renderTurnServerConf() {
|
||||
cat <<EOF
|
||||
listening-port=3478
|
||||
tls-listening-port=5349
|
||||
min-port=$TURN_MIN_PORT
|
||||
max-port=$TURN_MAX_PORT
|
||||
fingerprint
|
||||
lt-cred-mech
|
||||
user=$TURN_USER:$TURN_PASSWORD
|
||||
realm=netbird.io
|
||||
cert=/etc/coturn/certs/cert.pem
|
||||
pkey=/etc/coturn/private/privkey.pem
|
||||
log-file=stdout
|
||||
no-software-attribute
|
||||
pidfile="/var/tmp/turnserver.pid"
|
||||
no-cli
|
||||
EOF
|
||||
}
|
||||
|
||||
renderManagementJson() {
|
||||
cat <<EOF
|
||||
{
|
||||
"StoreConfig": {
|
||||
"Engine": "postgres"
|
||||
},
|
||||
"Stuns": [
|
||||
{
|
||||
"Proto": "udp",
|
||||
"URI": "stun:$NETBIRD_DOMAIN:3478"
|
||||
}
|
||||
],
|
||||
"TURNConfig": {
|
||||
"Turns": [
|
||||
{
|
||||
"Proto": "udp",
|
||||
"URI": "turn:$NETBIRD_DOMAIN:3478",
|
||||
"Username": "$TURN_USER",
|
||||
"Password": "$TURN_PASSWORD"
|
||||
}
|
||||
],
|
||||
"TimeBasedCredentials": false
|
||||
},
|
||||
"Signal": {
|
||||
"Proto": "$NETBIRD_HTTP_PROTOCOL",
|
||||
"URI": "$NETBIRD_DOMAIN:$NETBIRD_PORT"
|
||||
},
|
||||
"HttpConfig": {
|
||||
"AuthIssuer": "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN:$NETBIRD_PORT",
|
||||
"AuthAudience": "$NETBIRD_AUTH_CLIENT_ID",
|
||||
"OIDCConfigEndpoint":"$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN:$NETBIRD_PORT/.well-known/openid-configuration"
|
||||
},
|
||||
"IdpManagerConfig": {
|
||||
"ManagerType": "zitadel",
|
||||
"ClientConfig": {
|
||||
"Issuer": "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN:$NETBIRD_PORT",
|
||||
"TokenEndpoint": "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN:$NETBIRD_PORT/oauth/v2/token",
|
||||
"ClientID": "$NETBIRD_IDP_MGMT_CLIENT_ID",
|
||||
"ClientSecret": "$NETBIRD_IDP_MGMT_CLIENT_SECRET",
|
||||
"GrantType": "client_credentials"
|
||||
},
|
||||
"ExtraConfig": {
|
||||
"ManagementEndpoint": "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN:$NETBIRD_PORT/management/v1"
|
||||
}
|
||||
},
|
||||
"PKCEAuthorizationFlow": {
|
||||
"ProviderConfig": {
|
||||
"Audience": "$NETBIRD_AUTH_CLIENT_ID_CLI",
|
||||
"ClientID": "$NETBIRD_AUTH_CLIENT_ID_CLI",
|
||||
"Scope": "openid profile email offline_access",
|
||||
"RedirectURLs": ["http://localhost:53000/","http://localhost:54000/"]
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
renderDashboardEnv() {
|
||||
cat <<EOF
|
||||
{
|
||||
"auth0Auth": "false",
|
||||
"authAuthority": "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN:$NETBIRD_PORT",
|
||||
"authClientId": "$NETBIRD_AUTH_CLIENT_ID",
|
||||
"authScopesSupported": "openid profile email offline_access",
|
||||
"authAudience": "$NETBIRD_AUTH_CLIENT_ID",
|
||||
"apiOrigin": "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN:$NETBIRD_PORT",
|
||||
"grpcApiOrigin": "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN:$NETBIRD_PORT",
|
||||
"redirectURI": "/nb-auth",
|
||||
"silentRedirectURI": "/nb-silent-auth"
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
renderZitadelEnv() {
|
||||
cat <<EOF
|
||||
ZITADEL_LOG_LEVEL=debug
|
||||
ZITADEL_MASTERKEY=$ZITADEL_MASTERKEY
|
||||
ZITADEL_DATABASE_COCKROACH_HOST=crdb
|
||||
ZITADEL_DATABASE_COCKROACH_USER_USERNAME=zitadel_user
|
||||
ZITADEL_DATABASE_COCKROACH_USER_SSL_MODE=verify-full
|
||||
ZITADEL_DATABASE_COCKROACH_USER_SSL_ROOTCERT="/crdb-certs/ca.crt"
|
||||
ZITADEL_DATABASE_COCKROACH_USER_SSL_CERT="/crdb-certs/client.zitadel_user.crt"
|
||||
ZITADEL_DATABASE_COCKROACH_USER_SSL_KEY="/crdb-certs/client.zitadel_user.key"
|
||||
ZITADEL_DATABASE_COCKROACH_ADMIN_SSL_MODE=verify-full
|
||||
ZITADEL_DATABASE_COCKROACH_ADMIN_SSL_ROOTCERT="/crdb-certs/ca.crt"
|
||||
ZITADEL_DATABASE_COCKROACH_ADMIN_SSL_CERT="/crdb-certs/client.root.crt"
|
||||
ZITADEL_DATABASE_COCKROACH_ADMIN_SSL_KEY="/crdb-certs/client.root.key"
|
||||
ZITADEL_EXTERNALSECURE=$ZITADEL_EXTERNALSECURE
|
||||
ZITADEL_TLS_ENABLED="false"
|
||||
ZITADEL_EXTERNALPORT=$NETBIRD_PORT
|
||||
ZITADEL_EXTERNALDOMAIN=$NETBIRD_DOMAIN
|
||||
ZITADEL_FIRSTINSTANCE_PATPATH=/machinekey/zitadel-admin-sa.token
|
||||
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME=zitadel-admin-sa
|
||||
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME=Admin
|
||||
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_SCOPES=openid
|
||||
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE=$ZIDATE_TOKEN_EXPIRATION_DATE
|
||||
EOF
|
||||
}
|
||||
|
||||
renderDockerCompose() {
|
||||
cat <<EOF
|
||||
version: "3.4"
|
||||
services:
|
||||
# Caddy reverse proxy
|
||||
caddy:
|
||||
image: caddy
|
||||
restart: unless-stopped
|
||||
networks: [ netbird ]
|
||||
ports:
|
||||
- '33443:443'
|
||||
- '33080:80'
|
||||
- '33880:8080'
|
||||
volumes:
|
||||
- netbird_caddy_data:/data
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile
|
||||
# Management
|
||||
management:
|
||||
image: ghcr.io/netbirdio/management-cloud:${MANAGEMENT_IMAGE_TAG}
|
||||
restart: unless-stopped
|
||||
networks: [netbird]
|
||||
environment:
|
||||
- NETBIRD_STORE_ENGINE_POSTGRES_DSN=host=postgres user=netbird password=netbird dbname=netbird port=5432
|
||||
- NB_TRAFFIC_EVENT_POSTGRES_DSN=host=postgres user=netbird password=netbird dbname=netbird port=5432
|
||||
- NETBIRD_STORE_CONFIG_ENGINE=postgres
|
||||
- NB_TRAFFIC_EVENT_STORE_ENGINE=postgres
|
||||
- NB_LICENSE_KEY=${NB_LICENSE_KEY}
|
||||
- NB_TRAFFIC_FLOW_ADDRESS=http://127.0.0.1:8084
|
||||
- NETBIRD_DATADIR=/var/lib/netbird/
|
||||
- NETBIRD_ENCRYPTION_KEY=saFhCwIBtO+4QfRqMA19kKYqNPSrtXq7+TVWfHax+3I=
|
||||
- NETBIRD_LICENSE_SERVER_BASE_URL=${NETBIRD_LICENSE_SERVER_BASE_URL}
|
||||
- NB_TRAFFIC_FLOW_INTERVAL=20s
|
||||
- NB_SINGLE_INSTANCE_MODE=true
|
||||
volumes:
|
||||
- netbird_management:/var/lib/netbird
|
||||
- ./management.json:/etc/netbird/management.json
|
||||
command: [
|
||||
"--port", "80",
|
||||
"--log-file", "console",
|
||||
"--log-level", "trace",
|
||||
"--disable-anonymous-metrics=false",
|
||||
"--single-account-mode-domain=netbird.selfhosted",
|
||||
"--dns-domain=netbird.selfhosted",
|
||||
"--idp-sign-key-refresh-enabled",
|
||||
]
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: 'service_healthy'
|
||||
# PostgreSQL for management
|
||||
postgres:
|
||||
image: postgres:17
|
||||
restart: unless-stopped
|
||||
networks: [netbird]
|
||||
environment:
|
||||
- POSTGRES_USER=netbird
|
||||
- POSTGRES_PASSWORD=netbird
|
||||
- POSTGRES_DB=netbird
|
||||
volumes:
|
||||
- netbird_postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U netbird"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
# Zitadel - identity provider
|
||||
zitadel:
|
||||
restart: 'always'
|
||||
networks: [netbird]
|
||||
image: 'ghcr.io/zitadel/zitadel:v2.31.3'
|
||||
command: 'start-from-init --masterkeyFromEnv --tlsMode $ZITADEL_TLS_MODE'
|
||||
env_file:
|
||||
- ./zitadel.env
|
||||
depends_on:
|
||||
crdb:
|
||||
condition: 'service_healthy'
|
||||
volumes:
|
||||
- ./machinekey:/machinekey
|
||||
- netbird_zitadel_certs:/crdb-certs:ro
|
||||
# Reverse proxy (supports custom listen ports for UDP/TCP)
|
||||
reverse-proxy:
|
||||
image: ghcr.io/netbirdio/reverse-proxy:${REVERSE_PROXY_IMAGE_TAG}
|
||||
restart: unless-stopped
|
||||
networks: [netbird]
|
||||
env_file:
|
||||
- ./proxy.env
|
||||
volumes:
|
||||
- ./proxy-certs:/certs:ro
|
||||
command: [
|
||||
"--mgmt", "http://management:80",
|
||||
"--addr", "0.0.0.0:8443",
|
||||
"--domain", "example.com",
|
||||
"--cert-dir", "/certs",
|
||||
"--debug-endpoint",
|
||||
"--debug-endpoint-addr", "0.0.0.0:8444",
|
||||
"--health-addr", "0.0.0.0:8080",
|
||||
"--log-level", "debug",
|
||||
]
|
||||
depends_on:
|
||||
- management
|
||||
# Reverse proxy with custom ports disabled (auto-assigned listen ports only)
|
||||
reverse-proxy-no-ports:
|
||||
image: ghcr.io/netbirdio/reverse-proxy:${REVERSE_PROXY_IMAGE_TAG}
|
||||
restart: unless-stopped
|
||||
networks: [netbird]
|
||||
env_file:
|
||||
- ./proxy-no-ports.env
|
||||
volumes:
|
||||
# Distinct cert dir so this proxy has a distinct identity from the
|
||||
# primary proxy; a shared cert makes both register under the same
|
||||
# proxy ID and management superseding kicks one off in a loop, which
|
||||
# makes cluster registration (and the reverse-proxy suite) flaky.
|
||||
- ./proxy-certs-no-ports:/certs:ro
|
||||
command: [
|
||||
"--mgmt", "http://management:80",
|
||||
"--addr", "0.0.0.0:9443",
|
||||
"--domain", "noports.example.com",
|
||||
"--cert-dir", "/certs",
|
||||
"--debug-endpoint",
|
||||
"--debug-endpoint-addr", "0.0.0.0:9444",
|
||||
"--health-addr", "0.0.0.0:9080",
|
||||
"--log-level", "debug",
|
||||
"--supports-custom-ports=false",
|
||||
]
|
||||
depends_on:
|
||||
- management
|
||||
# CockroachDB for zitadel
|
||||
crdb:
|
||||
restart: 'always'
|
||||
networks: [netbird]
|
||||
image: 'cockroachdb/cockroach:v22.2.2'
|
||||
command: 'start-single-node --advertise-addr crdb'
|
||||
volumes:
|
||||
- netbird_crdb_data:/cockroach/cockroach-data
|
||||
- netbird_crdb_certs:/cockroach/certs
|
||||
- netbird_zitadel_certs:/zitadel-certs
|
||||
healthcheck:
|
||||
test: [ "CMD", "curl", "-f", "http://localhost:8080/health?ready=1" ]
|
||||
interval: '10s'
|
||||
timeout: '30s'
|
||||
retries: 5
|
||||
start_period: '20s'
|
||||
|
||||
volumes:
|
||||
netbird_management:
|
||||
netbird_caddy_data:
|
||||
netbird_crdb_data:
|
||||
netbird_crdb_certs:
|
||||
netbird_zitadel_certs:
|
||||
netbird_postgres_data:
|
||||
|
||||
networks:
|
||||
netbird:
|
||||
EOF
|
||||
}
|
||||
|
||||
renderPlaywrightEnv() {
|
||||
cat <<EOF
|
||||
{
|
||||
"ZITADEL_URL": "$NETBIRD_HTTP_PROTOCOL://$NETBIRD_DOMAIN:$NETBIRD_PORT",
|
||||
"BASE_URL": "http://localhost:1337"
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
initEnvironment
|
||||
0
e2e/fixtures/auth/.gitkeep
Normal file
0
e2e/fixtures/auth/.gitkeep
Normal file
435
e2e/helpers/api.ts
Normal file
435
e2e/helpers/api.ts
Normal file
@@ -0,0 +1,435 @@
|
||||
/**
|
||||
* Direct API helpers for fast CRUD operations in tests.
|
||||
*
|
||||
* The app uses OIDC service-worker auth, so page.request doesn't carry
|
||||
* the Bearer token. We extract it from the browser context and pass it
|
||||
* explicitly via page.evaluate + fetch.
|
||||
*/
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
type Group = {
|
||||
id: string;
|
||||
name: string;
|
||||
peers_count: number;
|
||||
resources_count: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Capture the auth token and API origin by intercepting a real network
|
||||
* response from the management API. We listen for any /api/ response
|
||||
* and extract the request's Authorization header (injected by the OIDC
|
||||
* service worker at the network level).
|
||||
*/
|
||||
const apiContextCache = new WeakMap<Page, { token: string; origin: string }>();
|
||||
|
||||
async function getApiContext(
|
||||
page: Page,
|
||||
): Promise<{ token: string; origin: string }> {
|
||||
const cached = apiContextCache.get(page);
|
||||
if (cached) return cached;
|
||||
|
||||
// Navigate to the users page to trigger an API call we can intercept.
|
||||
// The predicate runs for EVERY response the page receives and returns
|
||||
// whether it's the one we want: a successful GET to the management API.
|
||||
// Non-matching responses (4xx/5xx, non-GET, non-API) are skipped — the
|
||||
// wait keeps going until a match or the 10s timeout. Network-level
|
||||
// request failures never produce a response, so they can't match either;
|
||||
// if nothing succeeds, this throws a TimeoutError.
|
||||
// Set E2E_DEBUG_API=1 to log every API response the predicate considers.
|
||||
const debugApi = !!process.env.E2E_DEBUG_API;
|
||||
const [response] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(resp) => {
|
||||
const req = resp.request();
|
||||
if (!resp.url().includes("/api/")) return false;
|
||||
const isMatch = req.method() === "GET" && resp.status() === 200;
|
||||
if (debugApi) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`[api-context] ${req.method()} ${resp.status()} ${resp.url()} ${
|
||||
isMatch ? "← MATCH" : "(skipped)"
|
||||
}`,
|
||||
);
|
||||
}
|
||||
return isMatch;
|
||||
},
|
||||
{ timeout: 10_000 },
|
||||
),
|
||||
page.goto("/team/users"),
|
||||
]);
|
||||
|
||||
const request = response.request();
|
||||
const authHeader =
|
||||
(await request.allHeaders())["authorization"] || "";
|
||||
const token = authHeader.replace("Bearer ", "");
|
||||
const url = new URL(request.url());
|
||||
const origin = `${url.protocol}//${url.host}`;
|
||||
|
||||
if (!token) {
|
||||
throw new Error("Could not capture auth token from API response");
|
||||
}
|
||||
|
||||
const ctx = { token, origin };
|
||||
apiContextCache.set(page, ctx);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
async function apiGet<T>(page: Page, path: string): Promise<T> {
|
||||
const { token, origin } = await getApiContext(page);
|
||||
const resp = await page.request.get(`${origin}/api${path}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function apiDelete(page: Page, path: string): Promise<void> {
|
||||
const { token, origin } = await getApiContext(page);
|
||||
await page.request.delete(`${origin}/api${path}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
}
|
||||
|
||||
/** List all groups. */
|
||||
export async function listGroups(page: Page): Promise<Group[]> {
|
||||
return apiGet<Group[]>(page, "/groups");
|
||||
}
|
||||
|
||||
/** Delete a group by ID. */
|
||||
export async function deleteGroup(page: Page, groupId: string) {
|
||||
await apiDelete(page, `/groups/${groupId}`);
|
||||
}
|
||||
|
||||
/** Delete all groups matching a prefix. */
|
||||
export async function deleteGroupsByPrefix(page: Page, prefix: string) {
|
||||
const groups = await listGroups(page);
|
||||
const toDelete = groups.filter((g) => g.name.startsWith(prefix));
|
||||
for (const g of toDelete) {
|
||||
await deleteGroup(page, g.id);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Networks ────────────────────────────────────────────────────────────
|
||||
|
||||
type Network = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
/** List all networks. */
|
||||
export async function listNetworks(page: Page): Promise<Network[]> {
|
||||
return apiGet<Network[]>(page, "/networks");
|
||||
}
|
||||
|
||||
/** Delete a network by ID. */
|
||||
export async function deleteNetworkById(page: Page, networkId: string) {
|
||||
await apiDelete(page, `/networks/${networkId}`);
|
||||
}
|
||||
|
||||
/** Delete all networks matching a prefix. */
|
||||
export async function deleteNetworksByPrefix(page: Page, prefix: string) {
|
||||
const networks = await listNetworks(page);
|
||||
const toDelete = networks.filter((n) => n.name.startsWith(prefix));
|
||||
for (const n of toDelete) {
|
||||
await deleteNetworkById(page, n.id);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Policies ───────────────────────────────────────────────────────────
|
||||
|
||||
type Policy = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
rules: { sources: string[]; destinations: string[] }[];
|
||||
};
|
||||
|
||||
/** List all policies. */
|
||||
export async function listPolicies(page: Page): Promise<Policy[]> {
|
||||
return apiGet<Policy[]>(page, "/policies");
|
||||
}
|
||||
|
||||
/** Delete a policy by ID. */
|
||||
export async function deletePolicyById(page: Page, policyId: string) {
|
||||
await apiDelete(page, `/policies/${policyId}`);
|
||||
}
|
||||
|
||||
/** Delete all policies whose name or description contains a substring. */
|
||||
export async function deletePoliciesBySubstring(page: Page, substring: string) {
|
||||
const policies = await listPolicies(page);
|
||||
const toDelete = policies.filter(
|
||||
(p) => p.name?.includes(substring) || p.description?.includes(substring),
|
||||
);
|
||||
for (const p of toDelete) {
|
||||
await deletePolicyById(page, p.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete all policies that reference a group name in sources or destinations. */
|
||||
export async function deletePoliciesByGroupName(page: Page, groupName: string) {
|
||||
const [policies, groups] = await Promise.all([
|
||||
listPolicies(page),
|
||||
listGroups(page),
|
||||
]);
|
||||
const groupId = groups.find((g) => g.name === groupName)?.id;
|
||||
if (!groupId) return;
|
||||
|
||||
const toDelete = policies.filter((p) =>
|
||||
p.rules.some(
|
||||
(r) => r.sources?.includes(groupId) || r.destinations?.includes(groupId),
|
||||
),
|
||||
);
|
||||
for (const p of toDelete) {
|
||||
await deletePolicyById(page, p.id);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Routes ─────────────────────────────────────────────────────────────
|
||||
|
||||
type Route = {
|
||||
id: string;
|
||||
network_id: string;
|
||||
};
|
||||
|
||||
/** List all routes. */
|
||||
export async function listRoutes(page: Page): Promise<Route[]> {
|
||||
return apiGet<Route[]>(page, "/routes");
|
||||
}
|
||||
|
||||
/** Delete a route by ID. */
|
||||
export async function deleteRouteById(page: Page, routeId: string) {
|
||||
await apiDelete(page, `/routes/${routeId}`);
|
||||
}
|
||||
|
||||
/** Delete all routes matching a network_id prefix. */
|
||||
export async function deleteRoutesByNetworkIdPrefix(page: Page, prefix: string) {
|
||||
const routes = await listRoutes(page);
|
||||
const toDelete = routes.filter((r) => r.network_id.startsWith(prefix));
|
||||
for (const r of toDelete) {
|
||||
await deleteRouteById(page, r.id);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Setup Keys ─────────────────────────────────────────────────────────
|
||||
|
||||
type SetupKey = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
/** List all setup keys. */
|
||||
export async function listSetupKeys(page: Page): Promise<SetupKey[]> {
|
||||
return apiGet<SetupKey[]>(page, "/setup-keys");
|
||||
}
|
||||
|
||||
/** Delete a setup key by ID. */
|
||||
export async function deleteSetupKeyById(page: Page, keyId: string) {
|
||||
await apiDelete(page, `/setup-keys/${keyId}`);
|
||||
}
|
||||
|
||||
/** Delete all setup keys matching a name prefix. */
|
||||
export async function deleteSetupKeysByPrefix(page: Page, prefix: string) {
|
||||
const keys = await listSetupKeys(page);
|
||||
const toDelete = keys.filter((k) => k.name.startsWith(prefix));
|
||||
for (const k of toDelete) {
|
||||
await deleteSetupKeyById(page, k.id);
|
||||
}
|
||||
}
|
||||
|
||||
// ── DNS Zones ──────────────────────────────────────────────────────────
|
||||
|
||||
type DnsZone = {
|
||||
id: string;
|
||||
domain: string;
|
||||
};
|
||||
|
||||
/** List all DNS zones. */
|
||||
export async function listDnsZones(page: Page): Promise<DnsZone[]> {
|
||||
return apiGet<DnsZone[]>(page, "/dns/zones");
|
||||
}
|
||||
|
||||
/** Delete a DNS zone by ID. */
|
||||
export async function deleteDnsZoneById(page: Page, zoneId: string) {
|
||||
await apiDelete(page, `/dns/zones/${zoneId}`);
|
||||
}
|
||||
|
||||
/** Delete all DNS zones matching a domain prefix. */
|
||||
export async function deleteDnsZonesByPrefix(page: Page, prefix: string) {
|
||||
const zones = await listDnsZones(page);
|
||||
const toDelete = zones.filter((z) => z.domain.startsWith(prefix));
|
||||
for (const z of toDelete) {
|
||||
await deleteDnsZoneById(page, z.id);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Notification Channels ─────────────────────────────────────────────
|
||||
|
||||
type NotificationChannel = {
|
||||
id: string;
|
||||
type: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
/** List all notification channels. */
|
||||
export async function listNotificationChannels(page: Page): Promise<NotificationChannel[]> {
|
||||
return apiGet<NotificationChannel[]>(page, "/integrations/notifications/channels");
|
||||
}
|
||||
|
||||
/** Delete a notification channel by ID. */
|
||||
export async function deleteNotificationChannel(page: Page, channelId: string) {
|
||||
await apiDelete(page, `/integrations/notifications/channels/${channelId}`);
|
||||
}
|
||||
|
||||
/** Delete all notification channels. */
|
||||
export async function deleteAllNotificationChannels(page: Page) {
|
||||
const channels = await listNotificationChannels(page);
|
||||
for (const c of channels) {
|
||||
await deleteNotificationChannel(page, c.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete notification channels by type (e.g., "email", "slack", "webhook"). */
|
||||
export async function deleteNotificationChannelsByType(page: Page, type: string) {
|
||||
const channels = await listNotificationChannels(page);
|
||||
const toDelete = channels.filter((c) => c.type === type);
|
||||
for (const c of toDelete) {
|
||||
await deleteNotificationChannel(page, c.id);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Nameservers ───────────────────────────────────────────────────────
|
||||
|
||||
type NameserverGroup = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
/** List all nameserver groups. */
|
||||
export async function listNameserverGroups(page: Page): Promise<NameserverGroup[]> {
|
||||
return apiGet<NameserverGroup[]>(page, "/dns/nameservers");
|
||||
}
|
||||
|
||||
/** Delete a nameserver group by ID. */
|
||||
export async function deleteNameserverGroupById(page: Page, id: string) {
|
||||
await apiDelete(page, `/dns/nameservers/${id}`);
|
||||
}
|
||||
|
||||
/** Delete all nameserver groups matching a name prefix. */
|
||||
export async function deleteNameserverGroupsByPrefix(page: Page, prefix: string) {
|
||||
const groups = await listNameserverGroups(page);
|
||||
const toDelete = groups.filter((g) => g.name.startsWith(prefix));
|
||||
for (const g of toDelete) {
|
||||
await deleteNameserverGroupById(page, g.id);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reverse Proxy Services ────────────────────────────────────────────
|
||||
|
||||
type ReverseProxyService = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
/** List all reverse proxy services. */
|
||||
export async function listReverseProxyServices(page: Page): Promise<ReverseProxyService[]> {
|
||||
return apiGet<ReverseProxyService[]>(page, "/reverse-proxies/services");
|
||||
}
|
||||
|
||||
/** Delete a reverse proxy service by ID. */
|
||||
export async function deleteReverseProxyServiceById(page: Page, serviceId: string) {
|
||||
await apiDelete(page, `/reverse-proxies/services/${serviceId}`);
|
||||
}
|
||||
|
||||
/** Delete all reverse proxy services matching a name prefix. */
|
||||
export async function deleteServicesByPrefix(page: Page, prefix: string) {
|
||||
const services = await listReverseProxyServices(page);
|
||||
const toDelete = services.filter((s) => s.name.startsWith(prefix));
|
||||
for (const s of toDelete) {
|
||||
await deleteReverseProxyServiceById(page, s.id);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reverse Proxy Clusters ────────────────────────────────────────────
|
||||
|
||||
type ReverseProxyCluster = {
|
||||
id?: string;
|
||||
address: string;
|
||||
online: boolean;
|
||||
connected_proxies: number;
|
||||
};
|
||||
|
||||
/** List all reverse proxy clusters. */
|
||||
export async function listReverseProxyClusters(
|
||||
page: Page,
|
||||
): Promise<ReverseProxyCluster[]> {
|
||||
return apiGet<ReverseProxyCluster[]>(page, "/reverse-proxies/clusters");
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll the management API until every given cluster address is present and
|
||||
* online with at least one connected proxy. The test reverse-proxy
|
||||
* containers register asynchronously after `test:setup` returns, so the
|
||||
* domain picker can be briefly empty; gating here keeps the reverse-proxy
|
||||
* suite deterministic instead of flaking on a half-registered env.
|
||||
*/
|
||||
export async function waitForProxyClustersOnline(
|
||||
page: Page,
|
||||
addresses: string[],
|
||||
timeoutMs = 120_000,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let last: ReverseProxyCluster[] = [];
|
||||
while (Date.now() < deadline) {
|
||||
// Don't silently coerce errors to "no clusters" — a failed call (token
|
||||
// capture timeout, 401, network) is a different problem than an empty
|
||||
// list, and hiding it makes the gate undiagnosable.
|
||||
last = await listReverseProxyClusters(page).catch((err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[clusters-gate] list call failed: ${(err as Error).message}`,
|
||||
);
|
||||
return [];
|
||||
});
|
||||
const ready = addresses.every((addr) =>
|
||||
last.some(
|
||||
(c) => c.address === addr && c.online && c.connected_proxies > 0,
|
||||
),
|
||||
);
|
||||
if (ready) return;
|
||||
await page.waitForTimeout(3000);
|
||||
}
|
||||
throw new Error(
|
||||
`Proxy clusters not online after ${timeoutMs}ms. Expected ${addresses.join(
|
||||
", ",
|
||||
)}; got ${JSON.stringify(last.map((c) => ({ a: c.address, online: c.online, n: c.connected_proxies })))}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Users ─────────────────────────────────────────────────────────────
|
||||
|
||||
type User = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
status: string;
|
||||
is_current: boolean;
|
||||
};
|
||||
|
||||
/** List all users. */
|
||||
export async function listUsers(page: Page): Promise<User[]> {
|
||||
return apiGet<User[]>(page, "/users");
|
||||
}
|
||||
|
||||
/** Delete a user by ID. */
|
||||
export async function deleteUserById(page: Page, userId: string) {
|
||||
await apiDelete(page, `/users/${userId}`);
|
||||
}
|
||||
|
||||
/** Delete a user by email (skip current user). */
|
||||
export async function deleteUserByEmail(page: Page, email: string) {
|
||||
const users = await listUsers(page);
|
||||
const user = users.find((u) => u.email === email && !u.is_current);
|
||||
if (user) {
|
||||
await deleteUserById(page, user.id);
|
||||
}
|
||||
}
|
||||
117
e2e/helpers/auth.ts
Normal file
117
e2e/helpers/auth.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Login helper for Playwright tests.
|
||||
*
|
||||
* The OIDC library (@axa-fr/react-oidc) uses a service worker for token
|
||||
* management, so storageState alone can't restore a session. Each test
|
||||
* goes through the OIDC redirect flow. Zitadel session cookies from
|
||||
* storageState make re-auth fast (account selection, no credentials).
|
||||
*/
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
import { clearScrollLock } from "./utils";
|
||||
|
||||
export type TestUser = "owner" | "user";
|
||||
|
||||
const credentials: Record<TestUser, { username: string; password: string }> = {
|
||||
owner: { username: "owner@localhost.test", password: "testMe123@" },
|
||||
user: { username: "user@localhost.test", password: "testMe123@" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Navigate to the app, authenticate via Zitadel, and wait for the app to load.
|
||||
*/
|
||||
export async function loginToApp(page: Page, user: TestUser = "owner") {
|
||||
const { username, password } = credentials[user];
|
||||
|
||||
await page.goto("/");
|
||||
|
||||
// The app either loads directly or redirects to Zitadel.
|
||||
// Use locators that match either outcome — Playwright auto-waits.
|
||||
const appReady = page.getByTestId("left-navigation-item").first();
|
||||
const setupModal = page.getByTestId("setup-netbird-modal");
|
||||
const approvalPending = page.getByText("User Approval Pending");
|
||||
const onboarding = page.getByText("Add new device to your network");
|
||||
const selectAccount = page.getByText("Select account");
|
||||
const loginInput = page.locator("input[id=loginName]");
|
||||
const passwordInput = page.locator("input[id=password]");
|
||||
|
||||
// Wait for any of these outcomes
|
||||
const which = await Promise.race([
|
||||
appReady.waitFor({ timeout: 20_000 }).then(() => "app" as const),
|
||||
setupModal.waitFor({ timeout: 20_000 }).then(() => "modal" as const),
|
||||
approvalPending.waitFor({ timeout: 20_000 }).then(() => "approval" as const),
|
||||
onboarding.waitFor({ timeout: 20_000 }).then(() => "onboarding" as const),
|
||||
selectAccount.waitFor({ timeout: 20_000 }).then(() => "select" as const),
|
||||
loginInput.waitFor({ timeout: 20_000 }).then(() => "login" as const),
|
||||
passwordInput.waitFor({ timeout: 20_000 }).then(() => "password" as const),
|
||||
]);
|
||||
|
||||
if (which === "app") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (which === "modal") {
|
||||
await setupModal.getByTestId("modal-close").click();
|
||||
await expect(setupModal).not.toBeVisible();
|
||||
return;
|
||||
}
|
||||
|
||||
if (which === "approval" || which === "onboarding") {
|
||||
return;
|
||||
}
|
||||
|
||||
// We're on Zitadel
|
||||
if (which === "select") {
|
||||
await page.getByText(username).click();
|
||||
} else if (which === "login") {
|
||||
await loginInput.fill(username);
|
||||
await page.locator("button[id=submit-button]").click();
|
||||
await passwordInput.waitFor({ state: "visible" });
|
||||
await passwordInput.fill(password);
|
||||
await page.locator("button[id=submit-button]").click();
|
||||
} else {
|
||||
// password form directly
|
||||
await passwordInput.fill(password);
|
||||
await page.locator("button[id=submit-button]").click();
|
||||
}
|
||||
|
||||
// Handle 2FA skip if shown
|
||||
const skipButton = page.locator("button[name=skip]");
|
||||
if (await skipButton.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await skipButton.click();
|
||||
}
|
||||
|
||||
// Wait for either nav or modal to appear
|
||||
await Promise.race([
|
||||
appReady.waitFor({ timeout: 15_000 }),
|
||||
setupModal.waitFor({ timeout: 15_000 }),
|
||||
approvalPending.waitFor({ timeout: 15_000 }),
|
||||
onboarding.waitFor({ timeout: 15_000 }),
|
||||
]);
|
||||
|
||||
// Dismiss setup modal if present
|
||||
if (await setupModal.isVisible().catch(() => false)) {
|
||||
await setupModal.getByTestId("modal-close").click();
|
||||
await expect(setupModal).not.toBeVisible();
|
||||
}
|
||||
|
||||
// Clear any stale Radix overlays
|
||||
await clearScrollLock(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a path within the app, dismissing the setup modal if it appears.
|
||||
* Use this instead of page.goto() for in-app navigation after loginToApp().
|
||||
*/
|
||||
export async function navigateTo(page: Page, path: string) {
|
||||
await page.goto(path, { waitUntil: "domcontentloaded" });
|
||||
const modal = page.getByTestId("setup-netbird-modal");
|
||||
try {
|
||||
await modal.waitFor({ state: "visible", timeout: 3_000 });
|
||||
await modal.getByTestId("modal-close").click();
|
||||
await expect(modal).not.toBeVisible();
|
||||
} catch {
|
||||
// No modal — fine
|
||||
}
|
||||
await clearScrollLock(page);
|
||||
}
|
||||
49
e2e/helpers/fixtures.ts
Normal file
49
e2e/helpers/fixtures.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Custom Playwright fixtures that provide pre-authenticated pages.
|
||||
*
|
||||
* Usage:
|
||||
* import { test, expect } from "../helpers/fixtures";
|
||||
* test.describe.serial("My Feature", () => {
|
||||
* test("first test", async ({ dashboardAsOwner }) => { ... });
|
||||
* });
|
||||
*
|
||||
* `dashboardAsOwner` logs in once (via OIDC redirect) and reuses the same
|
||||
* browser page for every test in the worker — no per-test login overhead.
|
||||
*/
|
||||
import { test as base, type Page, type BrowserContext } from "@playwright/test";
|
||||
import { loginToApp, type TestUser } from "./auth";
|
||||
|
||||
type Fixtures = {
|
||||
dashboardAsOwner: Page;
|
||||
dashboardAsUser: Page;
|
||||
};
|
||||
|
||||
export const test = base.extend<{}, Fixtures>({
|
||||
dashboardAsOwner: [
|
||||
async ({ browser }, use) => {
|
||||
const context = await browser.newContext({
|
||||
storageState: "e2e/fixtures/auth/owner.json",
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await loginToApp(page, "owner");
|
||||
await use(page);
|
||||
await context.close();
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
|
||||
dashboardAsUser: [
|
||||
async ({ browser }, use) => {
|
||||
const context = await browser.newContext({
|
||||
storageState: "e2e/fixtures/auth/user.json",
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await loginToApp(page, "user");
|
||||
await use(page);
|
||||
await context.close();
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
});
|
||||
|
||||
export { expect } from "@playwright/test";
|
||||
8
e2e/helpers/navigation.ts
Normal file
8
e2e/helpers/navigation.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
export async function visitByNavigation(page: Page, navText: string) {
|
||||
await page
|
||||
.getByTestId("left-navigation-item")
|
||||
.getByText(navText, { exact: true })
|
||||
.click();
|
||||
}
|
||||
232
e2e/helpers/reverse-proxy-l4.ts
Normal file
232
e2e/helpers/reverse-proxy-l4.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Shared helpers for L4 reverse proxy tests (TLS, TCP, UDP).
|
||||
* Keeps the individual spec files DRY.
|
||||
*/
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
import { navigateTo } from "./auth";
|
||||
import { generateRandomName, waitForApiCalls } from "./utils";
|
||||
|
||||
/** Create a network and return its name. */
|
||||
export async function createNetwork(page: Page): Promise<string> {
|
||||
// Networks now lives under the collapsible "Network Routing" sidebar
|
||||
// group, so navigate by URL instead of clicking the (hidden) child item.
|
||||
await navigateTo(page, "/networks");
|
||||
const name = generateRandomName("rp-network-");
|
||||
|
||||
await page.getByTestId("add-network").click();
|
||||
await page.getByTestId("network-name-input").fill(name);
|
||||
await page.getByTestId("submit-network").click();
|
||||
|
||||
await page
|
||||
.getByTestId("confirmation.cancel")
|
||||
.click({ force: true });
|
||||
|
||||
// force: true because Radix dialog leaves data-scroll-locked on body
|
||||
const searchInput = page.getByTestId("table-search-input");
|
||||
await searchInput.fill(name, { force: true });
|
||||
await expect(page.locator("tr").filter({ hasText: name })).toBeVisible();
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
/** Add a resource to an already-visible network row. */
|
||||
export async function addResource(
|
||||
page: Page,
|
||||
networkName: string,
|
||||
address: string,
|
||||
): Promise<string> {
|
||||
const name = generateRandomName("rp-resource-");
|
||||
|
||||
const searchInput = page.getByTestId("table-search-input");
|
||||
await searchInput.fill(networkName, { force: true });
|
||||
await page
|
||||
.locator("tr")
|
||||
.filter({ hasText: networkName })
|
||||
.getByTestId("add-resource")
|
||||
.click({ force: true });
|
||||
|
||||
await page.getByTestId("resource-name-input").fill(name);
|
||||
await page.getByTestId("resource-address-input").fill(address);
|
||||
await page.getByTestId("resource-continue").click();
|
||||
|
||||
const responsePromise = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/api/networks/") &&
|
||||
resp.url().includes("/resources") &&
|
||||
resp.request().method() === "POST",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await page.getByTestId("submit-resource").click();
|
||||
await page
|
||||
.getByTestId("confirmation.confirm")
|
||||
.click({ force: true });
|
||||
const response = await responsePromise;
|
||||
expect([200, 201]).toContain(response.status());
|
||||
|
||||
await page
|
||||
.getByTestId("confirmation.cancel")
|
||||
.click({ force: true });
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
/** Domains advertised by the test reverse-proxy clusters. */
|
||||
export const CUSTOM_PORTS_DOMAIN = "example.com";
|
||||
export const NO_CUSTOM_PORTS_DOMAIN = "noports.example.com";
|
||||
|
||||
/** Pick a base domain (cluster) in the service modal — deterministic when multiple clusters exist. */
|
||||
export async function selectProxyDomain(page: Page, domain: string) {
|
||||
const trigger = page.getByTestId("proxy-domain-selector");
|
||||
await trigger.click({ force: true });
|
||||
// Find the option whose label span contains the exact ".<domain>" text,
|
||||
// so ".example.com" doesn't also match ".noports.example.com".
|
||||
const option = page
|
||||
.locator('[role="option"]')
|
||||
.filter({ has: page.getByText(`.${domain}`, { exact: true }) })
|
||||
.first();
|
||||
await option.click({ force: true });
|
||||
// Wait for the trigger to reflect the new selection and the popover
|
||||
// options to detach, so subsequent clicks aren't intercepted by Radix's
|
||||
// outside-click handling during the close animation.
|
||||
await expect(trigger.getByText(`.${domain}`, { exact: true })).toBeVisible();
|
||||
await option.waitFor({ state: "detached", timeout: 5_000 }).catch(() => {});
|
||||
}
|
||||
|
||||
/** Select a resource target in the L4 target selector. */
|
||||
export async function selectL4Resource(page: Page, resourceName: string) {
|
||||
await expect(page.getByTestId("group-selector-dropdown")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("group-selector-dropdown").click();
|
||||
await page
|
||||
.locator('[role="tab"]')
|
||||
.filter({ hasText: "Resources" })
|
||||
.click({ force: true });
|
||||
const search = page.getByTestId("group-selector-dropdown-search");
|
||||
await expect(search).toBeVisible({ timeout: 5_000 });
|
||||
await search.fill(resourceName);
|
||||
await page
|
||||
.locator('[role="option"], [role="listbox"] >> text=' + resourceName)
|
||||
.or(page.getByText(resourceName))
|
||||
.first()
|
||||
.click({ force: true, timeout: 15_000 });
|
||||
}
|
||||
|
||||
/** Add the standard two access control rules (Allow Germany + Block IP). */
|
||||
export async function addAccessControlRules(page: Page) {
|
||||
// Rule 1: Allow Country (Germany)
|
||||
await page.getByTestId("add-access-rule").click();
|
||||
await page.getByTestId("access-rule-0").getByText("Select country...").click();
|
||||
await page
|
||||
.getByTestId("select-dropdown-search")
|
||||
.fill("Germany");
|
||||
await page.getByText("Germany (DE)").click({ force: true });
|
||||
|
||||
// Rule 2: Block IP Address
|
||||
await page.getByTestId("add-access-rule").click();
|
||||
await page
|
||||
.getByTestId("access-rule-1")
|
||||
.getByTestId("access-rule-action")
|
||||
.click();
|
||||
await page.getByText("Block Only").click({ force: true });
|
||||
await page
|
||||
.getByTestId("access-rule-1")
|
||||
.getByTestId("access-rule-type")
|
||||
.click();
|
||||
await page.locator('[role="option"]').filter({ hasText: "IP Address" }).click({ force: true });
|
||||
const ipInput = page.getByTestId("access-rule-1").getByTestId("access-rule-value");
|
||||
await expect(ipInput).toBeVisible();
|
||||
await ipInput.fill("85.203.15.42");
|
||||
}
|
||||
|
||||
/** Remove all access control rules (expects exactly 2). */
|
||||
export async function removeAllAccessControlRules(page: Page) {
|
||||
await expect(page.getByTestId("remove-access-rule")).toHaveCount(2);
|
||||
await page.getByTestId("remove-access-rule").last().click({ force: true });
|
||||
await page.getByTestId("remove-access-rule").first().click({ force: true });
|
||||
}
|
||||
|
||||
/** Reset any stale filters/search so all services are visible in the table. */
|
||||
export async function resetServiceFilters(page: Page) {
|
||||
const resetBtn = page.getByTestId("reset-filters-and-search");
|
||||
if (await resetBtn.isVisible().catch(() => false)) {
|
||||
await resetBtn.click();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to a reverse-proxy page and wait for every /api/reverse-prox*
|
||||
* backend call triggered by the navigation to finish before proceeding,
|
||||
* so the table/picker is fully populated when the test interacts with it.
|
||||
*/
|
||||
export async function gotoReverseProxyPage(
|
||||
page: Page,
|
||||
path = "/reverse-proxy/services",
|
||||
) {
|
||||
await waitForApiCalls(page, () => navigateTo(page, path));
|
||||
}
|
||||
|
||||
/** Open the edit modal for a service row. */
|
||||
export async function openServiceEdit(page: Page, subdomain: string) {
|
||||
await gotoReverseProxyPage(page, "/reverse-proxy/services");
|
||||
await resetServiceFilters(page);
|
||||
await page
|
||||
.locator("tr")
|
||||
.filter({ hasText: subdomain })
|
||||
.getByTestId("service-actions")
|
||||
.click({ force: true });
|
||||
await page.getByTestId("edit-service").click({ force: true });
|
||||
// Wait for the edit modal to fully load
|
||||
await expect(page.getByTestId("proxy-save")).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
/** Delete a service via the action dropdown and confirm. */
|
||||
export async function deleteService(page: Page, subdomain: string) {
|
||||
await page
|
||||
.locator("tr")
|
||||
.filter({ hasText: subdomain })
|
||||
.getByTestId("service-actions")
|
||||
.click({ force: true });
|
||||
await page.getByTestId("delete-service").click({ force: true });
|
||||
await page
|
||||
.getByTestId("confirmation.confirm")
|
||||
.click({ force: true });
|
||||
|
||||
await expect(
|
||||
page.locator("tr").filter({ hasText: subdomain }),
|
||||
).not.toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
/** Save an edited service (handles the "No Protection" confirmation). */
|
||||
export async function saveServiceEdit(page: Page) {
|
||||
await page.getByTestId("proxy-save").click();
|
||||
await page
|
||||
.getByTestId("confirmation.confirm")
|
||||
.click({ force: true });
|
||||
}
|
||||
|
||||
/** Navigate to Networks, find the network by name, and delete it. */
|
||||
export async function deleteNetwork(page: Page, networkName: string) {
|
||||
await navigateTo(page, "/networks");
|
||||
const searchInput = page.getByTestId("table-search-input");
|
||||
await expect(searchInput).toBeVisible({ timeout: 30_000 });
|
||||
await searchInput.fill(networkName, { force: true });
|
||||
await expect(
|
||||
page.locator("tr").filter({ hasText: networkName }),
|
||||
).toBeVisible();
|
||||
|
||||
// Open the row's action menu (last button) and click Delete
|
||||
await page
|
||||
.locator("tr")
|
||||
.filter({ hasText: networkName })
|
||||
.locator("button")
|
||||
.last()
|
||||
.click({ force: true });
|
||||
await page.getByText("Delete").click({ force: true });
|
||||
await page
|
||||
.getByTestId("confirmation.confirm")
|
||||
.click({ force: true });
|
||||
|
||||
await expect(
|
||||
page.locator("tr").filter({ hasText: networkName }),
|
||||
).not.toBeVisible();
|
||||
}
|
||||
103
e2e/helpers/utils.ts
Normal file
103
e2e/helpers/utils.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { Page, Request } from "@playwright/test";
|
||||
|
||||
export function generateRandomName(prefix?: string): string {
|
||||
return (prefix || "") + Math.random().toString(36).substring(7);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an action (click, goto, ...) and wait until every API request whose
|
||||
* URL contains `pattern` has finished (response received or failed), plus a
|
||||
* short quiet window to catch request chains where one response triggers
|
||||
* the next fetch.
|
||||
*
|
||||
* Use this to make navigation deterministic: e.g. when opening the services
|
||||
* page, the table only renders fully after /api/reverse-proxies/* calls
|
||||
* return, so asserting on rows right after the click races the backend.
|
||||
*
|
||||
* Returns whatever the action returns.
|
||||
*/
|
||||
export async function waitForApiCalls<T>(
|
||||
page: Page,
|
||||
action: () => Promise<T>,
|
||||
{
|
||||
pattern = "/api/reverse-prox",
|
||||
quietMs = 500,
|
||||
timeoutMs = 15_000,
|
||||
}: { pattern?: string; quietMs?: number; timeoutMs?: number } = {},
|
||||
): Promise<T> {
|
||||
let inFlight = 0;
|
||||
let sawRequest = false;
|
||||
let lastActivity = Date.now();
|
||||
|
||||
const matches = (req: Request) => req.url().includes(pattern);
|
||||
const onRequest = (req: Request) => {
|
||||
if (!matches(req)) return;
|
||||
inFlight++;
|
||||
sawRequest = true;
|
||||
lastActivity = Date.now();
|
||||
};
|
||||
const onSettled = (req: Request) => {
|
||||
if (!matches(req)) return;
|
||||
inFlight = Math.max(0, inFlight - 1);
|
||||
lastActivity = Date.now();
|
||||
};
|
||||
|
||||
page.on("request", onRequest);
|
||||
page.on("requestfinished", onSettled);
|
||||
page.on("requestfailed", onSettled);
|
||||
|
||||
try {
|
||||
const result = await action();
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
// Wait until: at least one matching request was seen (unless none ever
|
||||
// fires), none are in flight, and the network has been quiet for quietMs.
|
||||
while (Date.now() < deadline) {
|
||||
const quietFor = Date.now() - lastActivity;
|
||||
if (inFlight === 0 && quietFor >= quietMs) {
|
||||
if (sawRequest || quietFor >= quietMs * 2) break;
|
||||
}
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
page.off("request", onRequest);
|
||||
page.off("requestfinished", onSettled);
|
||||
page.off("requestfailed", onSettled);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a single-choice (radio) table filter via the new TableFilters UI:
|
||||
* open the "Filters" popover, pick the filter by column id, then select the
|
||||
* option by its visible label (e.g. "Active", "Inactive", "All").
|
||||
*/
|
||||
export async function applyRadioTableFilter(
|
||||
page: Page,
|
||||
filterId: string,
|
||||
optionLabel: string,
|
||||
) {
|
||||
await page.getByTestId("table-filters-button").click();
|
||||
await page.getByTestId(`table-filter-${filterId}`).click();
|
||||
const optionId = `radio-option-${optionLabel
|
||||
.replace(/\s+/g, "-")
|
||||
.toLowerCase()}`;
|
||||
await page.getByTestId(optionId).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear stale Radix scroll-lock and overlay from body.
|
||||
* Some Radix modals leave `data-scroll-locked`, `pointer-events: none`,
|
||||
* or a stale overlay div blocking the entire page.
|
||||
*/
|
||||
export async function clearScrollLock(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
document.body.removeAttribute("data-scroll-locked");
|
||||
document.body.style.removeProperty("pointer-events");
|
||||
// Remove stale Radix dialog overlays that block pointer events
|
||||
document
|
||||
.querySelectorAll(
|
||||
'div[data-state="open"].fixed[class*="backdrop-blur"]',
|
||||
)
|
||||
.forEach((el) => el.remove());
|
||||
});
|
||||
}
|
||||
54
e2e/playwright.config.ts
Normal file
54
e2e/playwright.config.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
const envPath = path.resolve(__dirname, "playwright.env.json");
|
||||
const env = fs.existsSync(envPath)
|
||||
? JSON.parse(fs.readFileSync(envPath, "utf-8"))
|
||||
: {};
|
||||
|
||||
export default defineConfig({
|
||||
outputDir: "./test-results",
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 1,
|
||||
workers: process.env.CI ? 2 : 4,
|
||||
reporter: process.env.CI
|
||||
? [
|
||||
["github"],
|
||||
["html", { outputFolder: "./playwright-report", open: "never" }],
|
||||
["json", { outputFile: "test-results/results.json" }],
|
||||
]
|
||||
: [
|
||||
["list"],
|
||||
["html", { outputFolder: "./playwright-report", open: "on-failure" }],
|
||||
],
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
baseURL: env.BASE_URL || "http://localhost:1337",
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
screenshot: "only-on-failure",
|
||||
trace: "retain-on-failure",
|
||||
video: "retain-on-failure",
|
||||
actionTimeout: 10_000,
|
||||
navigationTimeout: 15_000,
|
||||
},
|
||||
testDir: "./tests",
|
||||
webServer: {
|
||||
command: "npx serve@latest out -p 1337 --no-request-logging",
|
||||
port: 1337,
|
||||
reuseExistingServer: true,
|
||||
cwd: path.resolve(__dirname, ".."),
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "login",
|
||||
testMatch: "login.spec.ts",
|
||||
},
|
||||
{
|
||||
name: "e2e",
|
||||
testIgnore: "login.spec.ts",
|
||||
dependencies: ["login"],
|
||||
},
|
||||
],
|
||||
});
|
||||
116
e2e/tests/access-control-groups.spec.ts
Normal file
116
e2e/tests/access-control-groups.spec.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { test, expect } from "../helpers/fixtures";
|
||||
import { navigateTo } from "../helpers/auth";
|
||||
import { generateRandomName } from "../helpers/utils";
|
||||
import { deleteGroupsByPrefix } from "../helpers/api";
|
||||
|
||||
let createdGroupName = "";
|
||||
|
||||
const ALL_GROUP_TABS = [
|
||||
"policies",
|
||||
"resources",
|
||||
"network-routes",
|
||||
"nameservers",
|
||||
"zones",
|
||||
];
|
||||
|
||||
const REGULAR_GROUP_TABS = [
|
||||
"users",
|
||||
"peers",
|
||||
...ALL_GROUP_TABS,
|
||||
"setup-keys",
|
||||
];
|
||||
|
||||
test.describe.serial("Groups @access-control", () => {
|
||||
// ── List page tests (no navigation between these) ──────────────────
|
||||
|
||||
test('Should show the "All" group in the list', async ({ dashboardAsOwner: page }) => {
|
||||
await navigateTo(page, "/groups");
|
||||
await expect(
|
||||
page.locator('[aria-label="View details of group All"]'),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('Should search for "All" group and still find it', async ({ dashboardAsOwner: page }) => {
|
||||
const input = page.getByTestId("table-search-input");
|
||||
await input.fill("All");
|
||||
await expect(
|
||||
page.locator('[aria-label="View details of group All"]'),
|
||||
).toBeVisible();
|
||||
await input.fill("");
|
||||
});
|
||||
|
||||
test("Should create a new group", async ({ dashboardAsOwner: page }) => {
|
||||
const name = generateRandomName("test-group-");
|
||||
createdGroupName = name;
|
||||
await page.getByTestId("open-create-group").click();
|
||||
await page.getByTestId("group-name-input").fill(name);
|
||||
await page.getByTestId("create-group").click();
|
||||
await expect(page.getByText(name).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("Should rename the group", async ({ dashboardAsOwner: page }) => {
|
||||
// Go back to list via breadcrumb (client-side nav, faster than navigateTo)
|
||||
await page.getByText("Groups").first().click();
|
||||
const input = page.getByTestId("table-search-input");
|
||||
await expect(input).toBeVisible();
|
||||
await input.fill(createdGroupName);
|
||||
await page
|
||||
.locator("tr")
|
||||
.filter({ hasText: createdGroupName })
|
||||
.getByTestId("group-actions")
|
||||
.click();
|
||||
await page.getByTestId("rename-group").click();
|
||||
|
||||
const newName = generateRandomName("renamed-group-");
|
||||
await page.getByTestId("group-name-input").fill(newName);
|
||||
await page.getByTestId("save-group-name").click();
|
||||
await expect(page.getByText(newName).first()).toBeVisible();
|
||||
createdGroupName = newName;
|
||||
});
|
||||
|
||||
// ── Detail page tests ──────────────────────────────────────────────
|
||||
|
||||
test('Should open "All" group page and show only All-group tabs', async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await navigateTo(page, "/groups");
|
||||
const input = page.getByTestId("table-search-input");
|
||||
await input.fill("");
|
||||
await page.locator('[aria-label="View details of group All"]').click({ force: true });
|
||||
|
||||
for (const tab of ALL_GROUP_TABS) {
|
||||
await expect(page.getByTestId(`group-tab-${tab}`)).toBeVisible();
|
||||
}
|
||||
for (const tab of ["users", "peers", "setup-keys"]) {
|
||||
await expect(page.getByTestId(`group-tab-${tab}`)).not.toBeVisible();
|
||||
}
|
||||
for (const tab of ALL_GROUP_TABS) {
|
||||
await page.getByTestId(`group-tab-${tab}`).click({ force: true });
|
||||
await expect(page.getByTestId(`group-tab-${tab}`)).toHaveAttribute("data-state", "active");
|
||||
}
|
||||
});
|
||||
|
||||
test("Should open the new group page and show all 8 tabs", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await navigateTo(page, "/groups");
|
||||
const input = page.getByTestId("table-search-input");
|
||||
await expect(input).toBeVisible();
|
||||
await input.fill(createdGroupName);
|
||||
await page.locator(`[aria-label="View details of group ${createdGroupName}"]`).click({ force: true });
|
||||
|
||||
for (const tab of REGULAR_GROUP_TABS) {
|
||||
await expect(page.getByTestId(`group-tab-${tab}`)).toBeVisible();
|
||||
}
|
||||
for (const tab of REGULAR_GROUP_TABS) {
|
||||
await page.getByTestId(`group-tab-${tab}`).click({ force: true });
|
||||
await expect(page.getByTestId(`group-tab-${tab}`)).toHaveAttribute("data-state", "active");
|
||||
}
|
||||
});
|
||||
|
||||
// ── Cleanup ────────────────────────────────────────────────────────
|
||||
|
||||
test("Should delete the created group", async ({ dashboardAsOwner: page }) => {
|
||||
await deleteGroupsByPrefix(page, createdGroupName);
|
||||
});
|
||||
});
|
||||
151
e2e/tests/access-control.spec.ts
Normal file
151
e2e/tests/access-control.spec.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { test, expect } from "../helpers/fixtures";
|
||||
import { generateRandomName } from "../helpers/utils";
|
||||
import { navigateTo } from "../helpers/auth";
|
||||
import { deleteGroupsByPrefix } from "../helpers/api";
|
||||
|
||||
let policies: string[] = [];
|
||||
let createdGroups: string[] = [];
|
||||
|
||||
test.describe.serial("Access Controls @access-control", () => {
|
||||
test("Should have default policy", async ({ dashboardAsOwner: page }) => {
|
||||
await navigateTo(page, "/access-control");
|
||||
await expect(page.getByText("Default", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("This is a default rule")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Should create new policy", async ({ dashboardAsOwner: page }) => {
|
||||
const srcGroup = generateRandomName("ac-src-");
|
||||
const dstGroup = generateRandomName("ac-dst-");
|
||||
createdGroups.push(srcGroup, dstGroup);
|
||||
|
||||
const name = generateRandomName("Policy ");
|
||||
await createPolicy(page, {
|
||||
name,
|
||||
source_groups: [srcGroup],
|
||||
destination_groups: [dstGroup],
|
||||
protocol: "TCP",
|
||||
ports: ["80", "443"],
|
||||
direction: "in",
|
||||
description: "This is a test policy",
|
||||
});
|
||||
policies.push(name);
|
||||
});
|
||||
|
||||
test("Should delete created policies", async ({ dashboardAsOwner: page }) => {
|
||||
for (const policy of policies) {
|
||||
await deletePolicy(page, policy);
|
||||
}
|
||||
policies = [];
|
||||
});
|
||||
|
||||
test("Should delete created groups", async ({ dashboardAsOwner: page }) => {
|
||||
for (const prefix of createdGroups) {
|
||||
await deleteGroupsByPrefix(page, prefix);
|
||||
}
|
||||
createdGroups = [];
|
||||
});
|
||||
});
|
||||
|
||||
async function createPolicy(
|
||||
page: import("@playwright/test").Page,
|
||||
opts: {
|
||||
protocol?: "ALL" | "TCP" | "UDP" | "ICMP";
|
||||
source_groups: string[];
|
||||
destination_groups: string[];
|
||||
direction?: "bi" | "in";
|
||||
ports?: string[];
|
||||
name: string;
|
||||
description?: string;
|
||||
},
|
||||
) {
|
||||
await page.getByTestId("open-add-policy").click();
|
||||
|
||||
if (opts.protocol !== "ALL") {
|
||||
await page.getByTestId("protocol-select-button").click();
|
||||
await page
|
||||
.getByTestId("protocol-selection")
|
||||
.getByText(opts.protocol!)
|
||||
.click();
|
||||
}
|
||||
|
||||
if (opts.direction === "in") {
|
||||
await page.getByTestId("policy-direction").click();
|
||||
}
|
||||
|
||||
// Add source groups
|
||||
if (opts.source_groups.length > 0) {
|
||||
await page.getByTestId("source-group-selector").click();
|
||||
for (const group of opts.source_groups) {
|
||||
const search = page.getByTestId("source-group-selector-search");
|
||||
await expect(search).toBeVisible();
|
||||
await search.fill(group);
|
||||
await search.press("Enter");
|
||||
}
|
||||
await page.getByTestId("source-group-selector-search").press("Escape");
|
||||
await expect(
|
||||
page.getByTestId("source-group-selector-search"),
|
||||
).not.toBeVisible();
|
||||
}
|
||||
|
||||
// Add destination groups
|
||||
if (opts.destination_groups.length > 0) {
|
||||
await page.getByTestId("destination-group-selector").click();
|
||||
for (const group of opts.destination_groups) {
|
||||
const search = page.getByTestId("destination-group-selector-search");
|
||||
await expect(search).toBeVisible();
|
||||
await search.fill(group);
|
||||
await search.press("Enter");
|
||||
}
|
||||
await page
|
||||
.getByTestId("destination-group-selector-search")
|
||||
.press("Escape");
|
||||
await expect(
|
||||
page.getByTestId("destination-group-selector-search"),
|
||||
).not.toBeVisible();
|
||||
}
|
||||
|
||||
// Add ports
|
||||
if (
|
||||
opts.ports &&
|
||||
(opts.protocol === "TCP" || opts.protocol === "UDP")
|
||||
) {
|
||||
await page.getByTestId("port-selector").click();
|
||||
for (const port of opts.ports) {
|
||||
const input = page.getByTestId("port-input");
|
||||
await expect(input).toBeVisible();
|
||||
await input.fill(port);
|
||||
await input.press("Enter");
|
||||
}
|
||||
await page.getByTestId("port-input").press("Escape");
|
||||
}
|
||||
|
||||
// Click Continue (policy → posture checks)
|
||||
await page.getByTestId("policy-continue").click();
|
||||
// Skip posture checks and continue (posture checks → general)
|
||||
await page.getByTestId("policy-continue").click();
|
||||
|
||||
// Enter name
|
||||
await page.getByTestId("policy-name").fill(opts.name);
|
||||
if (opts.description) {
|
||||
await page.getByTestId("policy-description").fill(opts.description);
|
||||
}
|
||||
|
||||
// Create policy
|
||||
await page.getByTestId("submit-policy").click();
|
||||
await expect(page.getByTestId(opts.name)).toBeVisible();
|
||||
}
|
||||
|
||||
async function deletePolicy(
|
||||
page: import("@playwright/test").Page,
|
||||
name: string,
|
||||
) {
|
||||
// Row actions are now behind a dropdown menu.
|
||||
await page
|
||||
.locator("tr")
|
||||
.filter({ hasText: name })
|
||||
.getByTestId("policy-actions")
|
||||
.click({ force: true });
|
||||
await page.getByTestId("delete-policy").click({ force: true });
|
||||
await page.getByTestId("confirmation.confirm").click();
|
||||
await expect(page.getByTestId(name)).not.toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
168
e2e/tests/dns-nameservers.spec.ts
Normal file
168
e2e/tests/dns-nameservers.spec.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { test, expect } from "../helpers/fixtures";
|
||||
import { navigateTo } from "../helpers/auth";
|
||||
import { generateRandomName } from "../helpers/utils";
|
||||
import { deleteGroupsByPrefix, deleteNameserverGroupsByPrefix } from "../helpers/api";
|
||||
|
||||
let nsName = "";
|
||||
let nsDomain = "";
|
||||
let nsGroup1 = "";
|
||||
let nsGroup2 = "";
|
||||
|
||||
test.describe.serial("DNS - Nameservers @dns", () => {
|
||||
test("Should show all 4 DNS presets and create a custom nameserver", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
// Clean up stale nameservers and groups from previous runs
|
||||
await deleteNameserverGroupsByPrefix(page, "test-ns-");
|
||||
await deleteNameserverGroupsByPrefix(page, "renamed-ns-");
|
||||
await deleteGroupsByPrefix(page, "ns-group-");
|
||||
await deleteGroupsByPrefix(page, "ns-domain-");
|
||||
|
||||
await navigateTo(page, "/dns/nameservers");
|
||||
|
||||
await page.getByTestId("open-add-nameserver").click();
|
||||
await expect(page.getByTestId("nameserver-preset-google")).toBeVisible();
|
||||
await expect(page.getByTestId("nameserver-preset-cloudflare")).toBeVisible();
|
||||
await expect(page.getByTestId("nameserver-preset-quad9")).toBeVisible();
|
||||
await expect(page.getByTestId("nameserver-preset-custom")).toBeVisible();
|
||||
|
||||
// Create via Custom DNS
|
||||
await page.getByTestId("nameserver-preset-custom").click();
|
||||
|
||||
await page.getByTestId("nameserver-ip-input").first().fill("10.0.0.1");
|
||||
await page.getByTestId("add-nameserver-row").click();
|
||||
await page.getByTestId("nameserver-ip-input").last().fill("10.0.0.2");
|
||||
await page.getByTestId("nameserver-port-input").last().fill("5353");
|
||||
|
||||
const groupName = generateRandomName("ns-group-");
|
||||
nsGroup1 = groupName;
|
||||
await page.getByTestId("nameserver-groups-selector").click();
|
||||
await page.getByTestId("nameserver-groups-selector-search").fill(groupName);
|
||||
await page.getByTestId("nameserver-groups-selector-search").press("Enter");
|
||||
await page.getByTestId("nameserver-groups-selector-search").press("Escape");
|
||||
|
||||
await page.getByTestId("nameserver-continue").click();
|
||||
|
||||
// Domains tab
|
||||
const d = generateRandomName("ns-domain-");
|
||||
nsDomain = `${d}.internal`;
|
||||
await page.getByTestId("add-match-domain").click();
|
||||
await page.getByTestId("domain-input").last().fill(nsDomain);
|
||||
await page.getByTestId("nameserver-mark-search-domains").click();
|
||||
|
||||
await page.getByTestId("nameserver-continue").click();
|
||||
|
||||
// General tab
|
||||
const name = generateRandomName("test-ns-");
|
||||
nsName = name;
|
||||
await page.getByTestId("nameserver-name-input").fill(name);
|
||||
await page.getByTestId("nameserver-description-input").fill("Test nameserver");
|
||||
|
||||
await page.getByTestId("submit-nameserver").click();
|
||||
});
|
||||
|
||||
test("Should verify the nameserver in the table", async ({ dashboardAsOwner: page }) => {
|
||||
const row = page.locator("tr").filter({ hasText: nsName });
|
||||
await expect(row).toBeVisible({ timeout: 10_000 });
|
||||
await expect(row.getByText(nsDomain)).toBeVisible();
|
||||
await expect(row.getByText("10.0.0.1")).toBeVisible();
|
||||
await expect(row.getByText("10.0.0.2")).toBeVisible();
|
||||
await expect(row.getByText(nsGroup1)).toBeVisible();
|
||||
// Active state moved into the row action menu: a freshly-created
|
||||
// nameserver is enabled, so the toggle item reads "Disable".
|
||||
await row.getByTestId("nameserver-actions").click({ force: true });
|
||||
await expect(page.getByTestId("nameserver-active-toggle")).toContainText(
|
||||
"Disable",
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
|
||||
test("Should edit the nameserver", async ({ dashboardAsOwner: page }) => {
|
||||
await page.locator("tr").filter({ hasText: nsName }).getByTestId("nameserver-name-cell").click({ force: true });
|
||||
|
||||
// Nameserver tab — change IPs and add group
|
||||
await page.getByTestId("nameserver-tab-nameserver").click({ force: true });
|
||||
await expect(page.getByTestId("nameserver-ip-input").first()).toBeVisible();
|
||||
await page.getByTestId("nameserver-ip-input").first().fill("192.168.1.1");
|
||||
await page.getByTestId("nameserver-ip-input").last().fill("192.168.1.2");
|
||||
|
||||
const groupName = generateRandomName("ns-group-");
|
||||
nsGroup2 = groupName;
|
||||
await page.getByTestId("nameserver-groups-selector").click();
|
||||
await page.getByTestId("nameserver-groups-selector-search").fill(groupName);
|
||||
await page.getByTestId("nameserver-groups-selector-search").press("Enter");
|
||||
await page.getByTestId("nameserver-groups-selector-search").press("Escape");
|
||||
|
||||
// Domains tab — remove domain
|
||||
await page.getByTestId("nameserver-tab-domains").click({ force: true });
|
||||
await page.getByTestId("domain-input-remove").click({ force: true });
|
||||
|
||||
// General tab — rename
|
||||
await page.getByTestId("nameserver-tab-general").click({ force: true });
|
||||
const newName = generateRandomName("renamed-ns-");
|
||||
await page.getByTestId("nameserver-name-input").fill(newName);
|
||||
await page.getByTestId("nameserver-description-input").fill("Updated");
|
||||
|
||||
await page.getByTestId("submit-nameserver").click();
|
||||
await expect(page.getByText("successfully").first()).toBeVisible({ timeout: 10_000 });
|
||||
// Verify the renamed nameserver appears in the table
|
||||
await expect(page.locator("tr").filter({ hasText: newName })).toBeVisible({ timeout: 10_000 });
|
||||
nsName = newName;
|
||||
});
|
||||
|
||||
test("Should verify edits and toggle active state", async ({ dashboardAsOwner: page }) => {
|
||||
await navigateTo(page, "/dns/nameservers");
|
||||
const row = page.locator("tr").filter({ hasText: nsName });
|
||||
await expect(row).toBeVisible({ timeout: 10_000 });
|
||||
await expect(row.getByText("192.168.1.1")).toBeVisible();
|
||||
await expect(row.getByText("192.168.1.2")).toBeVisible();
|
||||
// Distribution-groups cell now renders a count badge (2 groups after edit).
|
||||
await expect(row.getByText("2 Groups")).toBeVisible();
|
||||
|
||||
// Toggle active off and back on via the row action menu.
|
||||
// Two races to defend against on each toggle:
|
||||
// 1. Radix leaves `pointer-events: none` on body briefly during the
|
||||
// close transition — re-opening without `force: true` makes
|
||||
// Playwright auto-wait for the body to accept pointer events.
|
||||
// 2. The toast fires before SWR refetches `/dns/nameservers`, so the
|
||||
// row's `ns.enabled` is stale and the re-opened menu shows the
|
||||
// old label. Wait for the GET refetch before re-opening.
|
||||
const actions = row.getByTestId("nameserver-actions");
|
||||
const toggle = page.getByTestId("nameserver-active-toggle");
|
||||
const waitForRefetch = () =>
|
||||
page.waitForResponse(
|
||||
(r) =>
|
||||
r.url().includes("/api/dns/nameservers") &&
|
||||
r.request().method() === "GET" &&
|
||||
r.ok(),
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
|
||||
await actions.click({ force: true });
|
||||
let refetch = waitForRefetch();
|
||||
await toggle.click({ force: true });
|
||||
await expect(page.getByText("successfully disabled").first()).toBeVisible();
|
||||
await refetch;
|
||||
|
||||
await expect(toggle).toBeHidden();
|
||||
await actions.click();
|
||||
await expect(toggle).toContainText("Enable");
|
||||
refetch = waitForRefetch();
|
||||
await toggle.click({ force: true });
|
||||
await expect(page.getByText("successfully enabled").first()).toBeVisible();
|
||||
await refetch;
|
||||
await expect(toggle).toBeHidden();
|
||||
});
|
||||
|
||||
test("Should delete the nameserver and groups", async ({ dashboardAsOwner: page }) => {
|
||||
await page.locator("tr").filter({ hasText: nsName }).getByTestId("nameserver-actions").click({ force: true });
|
||||
await page.getByTestId("delete-nameserver").click({ force: true });
|
||||
await page.getByTestId("confirmation.confirm").click({ force: true });
|
||||
await expect(page.locator("tr").filter({ hasText: nsName })).not.toBeVisible();
|
||||
|
||||
for (const group of [nsGroup1, nsGroup2]) {
|
||||
if (!group) continue;
|
||||
await deleteGroupsByPrefix(page, group);
|
||||
}
|
||||
});
|
||||
});
|
||||
89
e2e/tests/dns-settings.spec.ts
Normal file
89
e2e/tests/dns-settings.spec.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { test, expect } from "../helpers/fixtures";
|
||||
import { navigateTo } from "../helpers/auth";
|
||||
import { generateRandomName } from "../helpers/utils";
|
||||
import { deleteGroupsByPrefix } from "../helpers/api";
|
||||
|
||||
let dnsGroups: string[] = [];
|
||||
|
||||
test.describe.serial("DNS - Settings @dns", () => {
|
||||
test("Should add groups to DNS disabled management", async ({ dashboardAsOwner: page }) => {
|
||||
// Clean up stale groups from previous failed runs
|
||||
await deleteGroupsByPrefix(page, "dns-group-");
|
||||
|
||||
await navigateTo(page, "/dns/settings");
|
||||
|
||||
const name1 = generateRandomName("dns-group-");
|
||||
const name2 = generateRandomName("dns-group-");
|
||||
dnsGroups = [name1, name2];
|
||||
|
||||
await expect(page.getByTestId("dns-groups-selector")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Remove any existing group badges before adding new ones
|
||||
const existingBadges = page.getByTestId("group-badge");
|
||||
const badgeCount = await existingBadges.count();
|
||||
for (let i = 0; i < badgeCount; i++) {
|
||||
await existingBadges.first().click();
|
||||
}
|
||||
if (badgeCount > 0) {
|
||||
await page.getByTestId("save-changes").click();
|
||||
await expect(page.getByText("successfully").first()).toBeVisible();
|
||||
}
|
||||
|
||||
for (const group of dnsGroups) {
|
||||
// Ensure dropdown is closed before reopening
|
||||
const search = page.getByTestId("dns-groups-selector-search");
|
||||
if (await search.isVisible().catch(() => false)) {
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(search).not.toBeVisible({ timeout: 3_000 });
|
||||
}
|
||||
await page.getByTestId("dns-groups-selector-open-close").click({ force: true });
|
||||
await expect(search).toBeVisible({ timeout: 5_000 });
|
||||
await search.fill(group);
|
||||
await search.press("Enter");
|
||||
// Wait for the group badge to appear before continuing
|
||||
await expect(page.getByText(group).first()).toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
// Close the dropdown if still open
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
for (const group of dnsGroups) {
|
||||
await expect(page.getByText(group).first()).toBeVisible();
|
||||
}
|
||||
|
||||
const saveResponse = page.waitForResponse(
|
||||
(resp) => resp.url().includes("/api/dns/settings") && resp.request().method() === "PUT",
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
await page.getByTestId("save-changes").click();
|
||||
await saveResponse;
|
||||
await expect(page.getByText("successfully").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("Should persist groups after reload and then remove them", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("dns-groups-selector")).toBeVisible({ timeout: 15_000 });
|
||||
for (const group of dnsGroups) {
|
||||
await expect(page.getByText(group).first()).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
// Remove groups
|
||||
for (const group of dnsGroups) {
|
||||
await page.getByTestId("group-badge").filter({ hasText: group }).click();
|
||||
}
|
||||
await page.getByTestId("save-changes").click();
|
||||
|
||||
// Verify removed after reload
|
||||
await page.reload();
|
||||
for (const group of dnsGroups) {
|
||||
await expect(page.getByTestId("group-badge").filter({ hasText: group })).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
test("Should delete the created groups", async ({ dashboardAsOwner: page }) => {
|
||||
for (const group of dnsGroups) {
|
||||
await deleteGroupsByPrefix(page, group);
|
||||
}
|
||||
});
|
||||
});
|
||||
197
e2e/tests/dns-zones.spec.ts
Normal file
197
e2e/tests/dns-zones.spec.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { test, expect } from "../helpers/fixtures";
|
||||
import { navigateTo } from "../helpers/auth";
|
||||
import { applyRadioTableFilter, generateRandomName } from "../helpers/utils";
|
||||
import { deleteGroupsByPrefix, deleteDnsZonesByPrefix } from "../helpers/api";
|
||||
|
||||
let zoneDomain = "";
|
||||
let zoneGroup = "";
|
||||
let zoneGroup2 = "";
|
||||
|
||||
test.describe.serial("DNS - Zones @dns", () => {
|
||||
test("Should add a new zone with a distribution group", async ({ dashboardAsOwner: page }) => {
|
||||
// Clean up leftover zones from previous runs
|
||||
await deleteDnsZonesByPrefix(page, "dns-zone-");
|
||||
await deleteGroupsByPrefix(page, "zone-group-");
|
||||
|
||||
await navigateTo(page, "/dns/zones");
|
||||
|
||||
const name = generateRandomName("dns-zone-");
|
||||
zoneDomain = `${name}.test`;
|
||||
|
||||
await page.getByTestId("add-dns-zone").click();
|
||||
await page.getByTestId("dns-zone-domain-input").fill(zoneDomain);
|
||||
|
||||
const groupName = generateRandomName("zone-group-");
|
||||
zoneGroup = groupName;
|
||||
await page.getByTestId("dns-zone-groups-selector").click();
|
||||
await page.getByTestId("dns-zone-groups-selector-search").fill(groupName);
|
||||
await page.getByTestId("dns-zone-groups-selector-search").press("Enter");
|
||||
await page.getByTestId("dns-zone-groups-selector-search").press("Escape");
|
||||
|
||||
await page.getByTestId("dns-zone-search-domains").click();
|
||||
await expect(page.getByTestId("dns-zone-enabled")).toHaveAttribute("data-state", "checked");
|
||||
|
||||
await page.getByTestId("submit-dns-zone").click();
|
||||
await expect(page.locator("tr").filter({ hasText: zoneDomain })).toBeVisible();
|
||||
});
|
||||
|
||||
test("Should add A, AAAA, and CNAME records", async ({ dashboardAsOwner: page }) => {
|
||||
const zoneRow = page.locator("tr").filter({ hasText: zoneDomain });
|
||||
|
||||
// Dismiss or use the "Add Record" prompt from zone creation
|
||||
const addRecordBtn = page.getByTestId("confirmation.confirm");
|
||||
if (await addRecordBtn.isVisible().catch(() => false)) {
|
||||
await addRecordBtn.click({ force: true });
|
||||
} else {
|
||||
await zoneRow.getByTestId("add-dns-record").click({ force: true });
|
||||
}
|
||||
await expect(page.getByTestId("dns-record-hostname-input")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("dns-record-hostname-input").fill("server1");
|
||||
await page.getByTestId("dns-record-content-input").fill("10.0.0.10");
|
||||
await page.getByTestId("dns-record-ttl-select").click();
|
||||
await page.locator('[role="option"]').filter({ hasText: "1 Min." }).click({ force: true });
|
||||
await page.getByTestId("submit-dns-record").click();
|
||||
await expect(zoneRow.getByTestId("dns-zone-records-count")).toContainText("1");
|
||||
|
||||
// AAAA record
|
||||
await zoneRow.getByTestId("add-dns-record").click({ force: true });
|
||||
await page.getByTestId("dns-record-type-select").click();
|
||||
await page.locator('[role="option"]').filter({ hasText: "AAAA" }).click({ force: true });
|
||||
await page.getByTestId("dns-record-hostname-input").fill("server2");
|
||||
await page.getByTestId("dns-record-content-input").fill("2001:db8::1");
|
||||
await page.getByTestId("submit-dns-record").click();
|
||||
await expect(zoneRow.getByTestId("dns-zone-records-count")).toContainText("2");
|
||||
|
||||
// CNAME record
|
||||
await zoneRow.getByTestId("add-dns-record").click({ force: true });
|
||||
await page.getByTestId("dns-record-type-select").click();
|
||||
await page.locator('[role="option"]').filter({ hasText: "CNAME" }).click({ force: true });
|
||||
await page.getByTestId("dns-record-hostname-input").fill("alias");
|
||||
await page.getByTestId("dns-record-content-input").fill("server1.example.com");
|
||||
await page.getByTestId("submit-dns-record").click();
|
||||
await expect(zoneRow.getByTestId("dns-zone-records-count")).toContainText("3");
|
||||
});
|
||||
|
||||
test("Should edit a record", async ({ dashboardAsOwner: page }) => {
|
||||
await page.reload();
|
||||
// Expand accordion to show records
|
||||
await page.locator("tr").filter({ hasText: zoneDomain }).first().click({ force: true });
|
||||
await expect(page.getByText("10.0.0.10")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Edit A record
|
||||
await page.getByTestId("edit-dns-record").first().click({ force: true });
|
||||
await page.getByTestId("dns-record-hostname-input").fill("web1");
|
||||
await page.getByTestId("dns-record-content-input").fill("10.0.0.99");
|
||||
await page.getByTestId("submit-dns-record").click();
|
||||
await expect(page.getByText(`web1.${zoneDomain}`).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("Should toggle active and search domain states", async ({ dashboardAsOwner: page }) => {
|
||||
await page.reload();
|
||||
const row = page.locator("tr").filter({ hasText: zoneDomain });
|
||||
|
||||
// Active state moved into the row action menu (Enable/Disable item).
|
||||
// Zone starts enabled → item reads "Disable"; toggle off then on,
|
||||
// reopening the menu each time to read the updated label.
|
||||
// Two races to defend against on each toggle:
|
||||
// 1. Radix leaves `pointer-events: none` on body briefly during the
|
||||
// close transition — re-opening without `force: true` makes
|
||||
// Playwright auto-wait for the body to accept pointer events.
|
||||
// 2. The toggle's PUT resolves before SWR refetches `/dns/zones`, so
|
||||
// the row's `zone.enabled` is stale and the re-opened menu shows
|
||||
// the old label. Wait for the GET refetch before re-opening.
|
||||
const actions = row.getByTestId("dns-zone-actions");
|
||||
const toggle = page.getByTestId("dns-zone-active-toggle");
|
||||
const waitForRefetch = () =>
|
||||
page.waitForResponse(
|
||||
(r) =>
|
||||
r.url().includes("/api/dns/zones") &&
|
||||
r.request().method() === "GET" &&
|
||||
r.ok(),
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
|
||||
await actions.click({ force: true });
|
||||
let refetch = waitForRefetch();
|
||||
await toggle.click({ force: true });
|
||||
await refetch;
|
||||
|
||||
await expect(toggle).toBeHidden();
|
||||
await actions.click();
|
||||
await expect(toggle).toContainText("Enable");
|
||||
refetch = waitForRefetch();
|
||||
await toggle.click({ force: true });
|
||||
await refetch;
|
||||
|
||||
await expect(toggle).toBeHidden();
|
||||
await actions.click();
|
||||
await expect(toggle).toContainText("Disable");
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// Toggle search domain off
|
||||
const searchToggle = row.getByTestId("dns-zone-search-domain-toggle");
|
||||
await searchToggle.click({ force: true });
|
||||
await expect(searchToggle).toHaveAttribute("data-state", "unchecked");
|
||||
});
|
||||
|
||||
test("Should update distribution groups", async ({ dashboardAsOwner: page }) => {
|
||||
const newGroup = generateRandomName("zone-group-");
|
||||
zoneGroup2 = newGroup;
|
||||
|
||||
await page.locator("tr").filter({ hasText: zoneDomain }).getByTestId("multiple-groups").click({ force: true });
|
||||
await expect(page.getByTestId("save-groups")).toBeVisible();
|
||||
|
||||
await page.getByTestId("group-selector-dropdown").click();
|
||||
await page.getByTestId("group-selector-dropdown-search").fill(newGroup);
|
||||
await page.getByTestId("group-selector-dropdown-search").press("Enter");
|
||||
await page.getByTestId("group-selector-dropdown-search").press("Escape");
|
||||
|
||||
await page.getByTestId("save-groups").click();
|
||||
await expect(page.getByTestId("save-groups")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("Should edit the zone and toggle settings back", async ({ dashboardAsOwner: page }) => {
|
||||
// Page is on /dns/zones from previous test
|
||||
await page.locator("tr").filter({ hasText: zoneDomain }).getByTestId("dns-zone-actions").click({ force: true });
|
||||
await page.getByTestId("edit-dns-zone").click({ force: true });
|
||||
|
||||
await page.getByTestId("dns-zone-search-domains").click();
|
||||
await expect(page.getByTestId("dns-zone-search-domains")).toHaveAttribute("data-state", "checked");
|
||||
|
||||
await page.getByTestId("submit-dns-zone").click();
|
||||
});
|
||||
|
||||
test("Should filter and search zones", async ({ dashboardAsOwner: page }) => {
|
||||
await page.reload();
|
||||
const zoneRow = page.locator("tr").filter({ hasText: zoneDomain }).first();
|
||||
|
||||
// Filter: Active should show, Inactive should hide
|
||||
await applyRadioTableFilter(page, "enabled", "Active");
|
||||
await expect(zoneRow).toBeVisible();
|
||||
await applyRadioTableFilter(page, "enabled", "Inactive");
|
||||
await expect(zoneRow).toBeHidden();
|
||||
await applyRadioTableFilter(page, "enabled", "All");
|
||||
|
||||
// Search by domain
|
||||
const searchInput = page.getByTestId("table-search-input");
|
||||
await searchInput.fill(zoneDomain);
|
||||
await expect(page.locator("tr").filter({ hasText: zoneDomain })).toBeVisible();
|
||||
|
||||
// Search by content
|
||||
await searchInput.fill("10.0.0.99");
|
||||
await expect(page.locator("tr").filter({ hasText: zoneDomain })).toBeVisible();
|
||||
|
||||
// Search by group
|
||||
await searchInput.fill(zoneGroup);
|
||||
await expect(page.locator("tr").filter({ hasText: zoneDomain })).toBeVisible();
|
||||
await searchInput.fill("");
|
||||
});
|
||||
|
||||
test("Should delete the zone and groups", async ({ dashboardAsOwner: page }) => {
|
||||
await deleteDnsZonesByPrefix(page, zoneDomain);
|
||||
for (const group of [zoneGroup, zoneGroup2]) {
|
||||
if (!group) continue;
|
||||
await deleteGroupsByPrefix(page, group);
|
||||
}
|
||||
});
|
||||
});
|
||||
216
e2e/tests/edition-gating.spec.ts
Normal file
216
e2e/tests/edition-gating.spec.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Temporary spec validating edition gating (cloud / licensed / oss).
|
||||
*
|
||||
* The test build hard-codes APP_ENV=test, so isNetBirdCloud() normally returns
|
||||
* true. This spec uses the test-only `netbird-test-edition` localStorage
|
||||
* override (see testEditionOverride in src/utils/netbird.ts) to drive each
|
||||
* edition against the OSS test management backend, which does not report the
|
||||
* premium permission modules (edr, idp, event_streaming). That absence is what
|
||||
* triggered the original `permission.event_streaming.read` crash and is now
|
||||
* covered by withDefaultModules in PermissionsProvider.
|
||||
*/
|
||||
import { test, expect, type Browser, type Page } from "@playwright/test";
|
||||
import { loginToApp, navigateTo } from "../helpers/auth";
|
||||
|
||||
type Edition = "cloud" | "licensed" | "oss";
|
||||
|
||||
// Premium permission modules the open-source management server does not report.
|
||||
const PREMIUM_MODULES = [
|
||||
"edr",
|
||||
"idp",
|
||||
"event_streaming",
|
||||
"assistant",
|
||||
"msp",
|
||||
"tenants",
|
||||
"billing",
|
||||
"proxy",
|
||||
"proxy_configuration",
|
||||
];
|
||||
|
||||
// stripPremiumModules rewrites /users/current to drop the premium permission
|
||||
// modules, reproducing an open-source management backend regardless of what the
|
||||
// test management returns. This is the exact condition that crashed before the
|
||||
// withDefaultModules default in PermissionsProvider.
|
||||
async function stripPremiumModules(page: Page) {
|
||||
await page.route("**/users/current", async (route) => {
|
||||
const response = await route.fetch();
|
||||
let body: any;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch (e) {
|
||||
return route.fulfill({ response });
|
||||
}
|
||||
if (body?.permissions?.modules) {
|
||||
PREMIUM_MODULES.forEach((m) => delete body.permissions.modules[m]);
|
||||
}
|
||||
return route.fulfill({ response, json: body });
|
||||
});
|
||||
}
|
||||
|
||||
async function openAs(
|
||||
browser: Browser,
|
||||
edition: Edition,
|
||||
opts: { stripModules?: boolean } = {},
|
||||
): Promise<{ page: Page; close: () => Promise<void> }> {
|
||||
const context = await browser.newContext({
|
||||
storageState: "e2e/fixtures/auth/owner.json",
|
||||
});
|
||||
await context.addInitScript((ed) => {
|
||||
try {
|
||||
window.localStorage.setItem("netbird-test-edition", ed as string);
|
||||
} catch (e) {}
|
||||
}, edition);
|
||||
const page = await context.newPage();
|
||||
if (opts.stripModules) await stripPremiumModules(page);
|
||||
await loginToApp(page, "owner");
|
||||
return { page, close: () => context.close() };
|
||||
}
|
||||
|
||||
function collectPageErrors(page: Page): string[] {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (err) => errors.push(err.message));
|
||||
return errors;
|
||||
}
|
||||
|
||||
const SELF_HOSTED_CTA = "self-hosted-upgrade-cta";
|
||||
const START_TRIAL = "Start 14-Day Free Trial";
|
||||
|
||||
test.describe.serial("Edition gating @edition", () => {
|
||||
test("integrations renders when premium permission modules are absent", async ({
|
||||
browser,
|
||||
}) => {
|
||||
// Reproduces the original crash: OSS management omits event_streaming/edr/
|
||||
// idp permission modules, and the integrations children read them directly.
|
||||
const { page, close } = await openAs(browser, "oss", {
|
||||
stripModules: true,
|
||||
});
|
||||
const errors = collectPageErrors(page);
|
||||
try {
|
||||
await navigateTo(page, "/integrations");
|
||||
|
||||
await expect(
|
||||
page.getByText("Identity Provider Sync").first(),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("MDM & EDR").first()).toBeVisible();
|
||||
|
||||
expect(
|
||||
errors,
|
||||
`unexpected runtime errors: ${errors.join(" | ")}`,
|
||||
).toHaveLength(0);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test("integrations renders without crashing on oss (teaser + upsell)", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openAs(browser, "oss");
|
||||
const errors = collectPageErrors(page);
|
||||
try {
|
||||
await navigateTo(page, "/integrations");
|
||||
|
||||
// Tabs render (the crash happened while rendering these children).
|
||||
await expect(
|
||||
page.getByText("Identity Provider Sync").first(),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("MDM & EDR").first()).toBeVisible();
|
||||
|
||||
// Self-hosted upsell CTA is present.
|
||||
await expect(page.getByTestId(SELF_HOSTED_CTA).first()).toBeVisible();
|
||||
|
||||
expect(
|
||||
errors,
|
||||
`unexpected runtime errors: ${errors.join(" | ")}`,
|
||||
).toHaveLength(0);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test("integrations renders unlocked on licensed (no upsell)", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openAs(browser, "licensed");
|
||||
const errors = collectPageErrors(page);
|
||||
try {
|
||||
await navigateTo(page, "/integrations");
|
||||
|
||||
await expect(
|
||||
page.getByText("Identity Provider Sync").first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Licensed self-hosted unlocks features: no upsell CTA.
|
||||
await expect(page.getByTestId(SELF_HOSTED_CTA)).toHaveCount(0);
|
||||
|
||||
expect(
|
||||
errors,
|
||||
`unexpected runtime errors: ${errors.join(" | ")}`,
|
||||
).toHaveLength(0);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test("traffic events is locked with cloud upgrade CTA on cloud free", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openAs(browser, "cloud");
|
||||
const errors = collectPageErrors(page);
|
||||
try {
|
||||
await navigateTo(page, "/events/traffic");
|
||||
|
||||
// Cloud free plan locks the feature with a trial/upgrade CTA, not the
|
||||
// self-hosted license CTA.
|
||||
await expect(page.getByText(START_TRIAL).first()).toBeVisible();
|
||||
await expect(page.getByTestId(SELF_HOSTED_CTA)).toHaveCount(0);
|
||||
|
||||
expect(
|
||||
errors,
|
||||
`unexpected runtime errors: ${errors.join(" | ")}`,
|
||||
).toHaveLength(0);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test("traffic events is locked with self-hosted CTA on oss", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openAs(browser, "oss");
|
||||
const errors = collectPageErrors(page);
|
||||
try {
|
||||
await navigateTo(page, "/events/traffic");
|
||||
|
||||
await expect(page.getByTestId(SELF_HOSTED_CTA).first()).toBeVisible();
|
||||
await expect(page.getByText(START_TRIAL)).toHaveCount(0);
|
||||
|
||||
expect(
|
||||
errors,
|
||||
`unexpected runtime errors: ${errors.join(" | ")}`,
|
||||
).toHaveLength(0);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
test("traffic events is unlocked on licensed (no upsell)", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const { page, close } = await openAs(browser, "licensed");
|
||||
const errors = collectPageErrors(page);
|
||||
try {
|
||||
await navigateTo(page, "/events/traffic");
|
||||
|
||||
await expect(page.getByTestId(SELF_HOSTED_CTA)).toHaveCount(0);
|
||||
await expect(page.getByText(START_TRIAL)).toHaveCount(0);
|
||||
|
||||
expect(
|
||||
errors,
|
||||
`unexpected runtime errors: ${errors.join(" | ")}`,
|
||||
).toHaveLength(0);
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
});
|
||||
109
e2e/tests/login.spec.ts
Normal file
109
e2e/tests/login.spec.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { test } from "@playwright/test";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { waitForProxyClustersOnline } from "../helpers/api";
|
||||
import { loginToApp } from "../helpers/auth";
|
||||
|
||||
type TestUser = "owner" | "user";
|
||||
|
||||
const AUTH_DIR = path.resolve(__dirname, "../fixtures/auth");
|
||||
|
||||
const credentials: Record<TestUser, { username: string; password: string }> = {
|
||||
owner: { username: "owner@localhost.test", password: "testMe123@" },
|
||||
user: { username: "user@localhost.test", password: "testMe123@" },
|
||||
};
|
||||
|
||||
async function loginAndSave(
|
||||
page: import("@playwright/test").Page,
|
||||
user: TestUser,
|
||||
) {
|
||||
const { username, password } = credentials[user];
|
||||
|
||||
await page.goto("/");
|
||||
|
||||
await page.locator("input[id=loginName]").waitFor({ state: "visible" });
|
||||
await page.locator("input[id=loginName]").fill(username);
|
||||
await page.locator("button[id=submit-button]").click();
|
||||
await page.locator("input[id=password]").waitFor({ state: "visible" });
|
||||
await page.locator("input[id=password]").fill(password);
|
||||
await page.locator("button[id=submit-button]").click();
|
||||
|
||||
// After submitting credentials, we land on either:
|
||||
// - 2FA skip prompt, or
|
||||
// - the app directly (redirect to localhost:1337)
|
||||
const skipButton = page.locator("button[name=skip]");
|
||||
const appNav = page.getByTestId("left-navigation-item").first();
|
||||
const modal = page.getByTestId("setup-netbird-modal");
|
||||
const approval = page.getByText("User Approval Pending");
|
||||
|
||||
const after_login = await Promise.race([
|
||||
skipButton.waitFor({ timeout: 15_000 }).then(() => "2fa" as const),
|
||||
appNav.waitFor({ timeout: 15_000 }).then(() => "app" as const),
|
||||
modal.waitFor({ timeout: 15_000 }).then(() => "modal" as const),
|
||||
approval.waitFor({ timeout: 15_000 }).then(() => "approval" as const),
|
||||
]);
|
||||
|
||||
if (after_login === "2fa") {
|
||||
await skipButton.click();
|
||||
await Promise.race([
|
||||
appNav.waitFor({ timeout: 15_000 }),
|
||||
modal.waitFor({ timeout: 15_000 }),
|
||||
approval.waitFor({ timeout: 15_000 }),
|
||||
]);
|
||||
}
|
||||
|
||||
// Dismiss setup modal if present
|
||||
if (await modal.isVisible().catch(() => false)) {
|
||||
await modal.getByTestId("modal-close").click();
|
||||
}
|
||||
|
||||
await page
|
||||
.context()
|
||||
.storageState({ path: path.join(AUTH_DIR, `${user}.json`) });
|
||||
}
|
||||
|
||||
test.describe("Global Setup", () => {
|
||||
for (const user of ["owner", "user"] as TestUser[]) {
|
||||
test(`authenticate ${user}`, async ({ page }) => {
|
||||
const authFile = path.join(AUTH_DIR, `${user}.json`);
|
||||
test.skip(fs.existsSync(authFile), `${user} auth file already exists`);
|
||||
await loginAndSave(page, user);
|
||||
});
|
||||
}
|
||||
|
||||
// Wait for the test reverse-proxy clusters to be registered and online
|
||||
// before the rest of the suite runs. They come up asynchronously after
|
||||
// test:setup, so without this the reverse-proxy specs flake when the
|
||||
// domain picker is still empty.
|
||||
//
|
||||
// This deliberately does NOT fail the run if the clusters never appear:
|
||||
// it only adds a bounded wait so slow registration is absorbed. A hard
|
||||
// gate would skip the entire suite on any cluster hiccup, which is worse
|
||||
// than letting the individual reverse-proxy specs report the problem.
|
||||
test("wait for reverse-proxy clusters to be online", async ({ browser }) => {
|
||||
test.setTimeout(15_000);
|
||||
const context = await browser.newContext({
|
||||
storageState: path.join(AUTH_DIR, "owner.json"),
|
||||
});
|
||||
const page = await context.newPage();
|
||||
try {
|
||||
// storageState only carries the Zitadel session cookies — the app
|
||||
// still needs the OIDC redirect flow to get an access token before
|
||||
// it makes any API call, so log in like every other consumer does.
|
||||
await loginToApp(page, "owner");
|
||||
await waitForProxyClustersOnline(page, [
|
||||
"example.com",
|
||||
"noports.example.com",
|
||||
]);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[setup] proxy clusters not confirmed online; reverse-proxy specs may be affected: ${
|
||||
(err as Error).message
|
||||
}`,
|
||||
);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
176
e2e/tests/network-routes.spec.ts
Normal file
176
e2e/tests/network-routes.spec.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { test, expect } from "../helpers/fixtures";
|
||||
import { navigateTo } from "../helpers/auth";
|
||||
import { generateRandomName } from "../helpers/utils";
|
||||
import { deleteGroupsByPrefix, deleteRoutesByNetworkIdPrefix } from "../helpers/api";
|
||||
|
||||
const networkRoutes: string[] = [];
|
||||
let networkRoutesCreatedGroups: string[] = [];
|
||||
|
||||
async function closePopover(
|
||||
page: import("@playwright/test").Page,
|
||||
selectorCy: string,
|
||||
) {
|
||||
await page.getByTestId(`${selectorCy}-search`).press("Escape");
|
||||
await expect(page.getByTestId(`${selectorCy}-search`)).not.toBeVisible();
|
||||
}
|
||||
|
||||
test.describe.serial("Network Routes @network", () => {
|
||||
test("Should create a network route with IP range", async ({ dashboardAsOwner: page }) => {
|
||||
// Clean up leftovers from previous runs
|
||||
await deleteRoutesByNetworkIdPrefix(page, "network-route-");
|
||||
await deleteGroupsByPrefix(page, "route-peer-");
|
||||
await deleteGroupsByPrefix(page, "route-dist-");
|
||||
await deleteGroupsByPrefix(page, "route-acl-");
|
||||
await navigateTo(page, "/network-routes");
|
||||
|
||||
const peerGroup = generateRandomName("route-peer-");
|
||||
const distGroup = generateRandomName("route-dist-");
|
||||
const aclGroup = generateRandomName("route-acl-");
|
||||
networkRoutesCreatedGroups.push(peerGroup, distGroup, aclGroup);
|
||||
|
||||
const name = generateRandomName("network-route-");
|
||||
await createNetworkRoute(page, {
|
||||
name,
|
||||
range: "192.168.1.0/24",
|
||||
peer_groups: [peerGroup],
|
||||
distribution_groups: [distGroup],
|
||||
access_control_groups: [aclGroup],
|
||||
description: "This is a test route",
|
||||
});
|
||||
networkRoutes.push(name);
|
||||
});
|
||||
|
||||
test("Should create a network route with domains", async ({ dashboardAsOwner: page }) => {
|
||||
const peerGroup = generateRandomName("route-peer-");
|
||||
const distGroup = generateRandomName("route-dist-");
|
||||
const aclGroup = generateRandomName("route-acl-");
|
||||
networkRoutesCreatedGroups.push(peerGroup, distGroup, aclGroup);
|
||||
|
||||
const name = generateRandomName("network-route-");
|
||||
await createNetworkRoute(page, {
|
||||
name,
|
||||
domains: ["netbird.io"],
|
||||
peer_groups: [peerGroup],
|
||||
distribution_groups: [distGroup],
|
||||
access_control_groups: [aclGroup],
|
||||
description: "This is a test route with domains",
|
||||
});
|
||||
networkRoutes.push(name);
|
||||
});
|
||||
|
||||
test("Should delete network routes", async ({ dashboardAsOwner: page }) => {
|
||||
for (const route of networkRoutes) {
|
||||
await deleteNetworkRoute(page, route);
|
||||
}
|
||||
});
|
||||
|
||||
test("Should delete created groups", async ({ dashboardAsOwner: page }) => {
|
||||
for (const prefix of networkRoutesCreatedGroups) {
|
||||
await deleteGroupsByPrefix(page, prefix);
|
||||
}
|
||||
networkRoutesCreatedGroups = [];
|
||||
});
|
||||
});
|
||||
|
||||
async function createNetworkRoute(
|
||||
page: import("@playwright/test").Page,
|
||||
opts: {
|
||||
range?: string;
|
||||
domains?: string[];
|
||||
peer_groups?: string[];
|
||||
distribution_groups?: string[];
|
||||
access_control_groups?: string[];
|
||||
name: string;
|
||||
description?: string;
|
||||
masquerade?: boolean;
|
||||
metric?: string;
|
||||
},
|
||||
) {
|
||||
await page.getByTestId("open-add-route").click();
|
||||
|
||||
if (opts.range) {
|
||||
await page.getByTestId("network-range").fill(opts.range);
|
||||
}
|
||||
|
||||
if (opts.domains && opts.domains.length > 0) {
|
||||
await page.getByTestId("route-type-domains").click();
|
||||
for (const domain of opts.domains) {
|
||||
await page.getByTestId("add-domain").click();
|
||||
await page.getByTestId("domain-input").last().fill(domain);
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.peer_groups && opts.peer_groups.length > 0) {
|
||||
await page.getByTestId("route-tab-peer-group").click();
|
||||
await page.getByTestId("routing-peer-groups-selector").click();
|
||||
for (const group of opts.peer_groups) {
|
||||
const search = page.getByTestId("routing-peer-groups-selector-search");
|
||||
await expect(search).toBeVisible({ timeout: 10_000 });
|
||||
await search.fill(group);
|
||||
await search.press("Enter");
|
||||
}
|
||||
await closePopover(page, "routing-peer-groups-selector");
|
||||
}
|
||||
|
||||
await page.getByTestId("route-continue").click();
|
||||
|
||||
if (opts.distribution_groups && opts.distribution_groups.length > 0) {
|
||||
await page.getByTestId("distribution-groups-selector").click();
|
||||
for (const group of opts.distribution_groups) {
|
||||
const search = page.getByTestId("distribution-groups-selector-search");
|
||||
await expect(search).toBeVisible();
|
||||
await search.fill(group);
|
||||
await search.press("Enter");
|
||||
}
|
||||
await closePopover(page, "distribution-groups-selector");
|
||||
}
|
||||
|
||||
if (opts.access_control_groups && opts.access_control_groups.length > 0) {
|
||||
await page.getByTestId("access-control-groups-selector").click();
|
||||
for (const group of opts.access_control_groups) {
|
||||
const search = page.getByTestId("access-control-groups-selector-search");
|
||||
await expect(search).toBeVisible();
|
||||
await search.fill(group);
|
||||
await search.press("Enter");
|
||||
}
|
||||
await closePopover(page, "access-control-groups-selector");
|
||||
}
|
||||
|
||||
await page.getByTestId("route-continue").click();
|
||||
|
||||
await page.getByTestId("network-identifier").fill(opts.name);
|
||||
if (opts.description) {
|
||||
await page.getByTestId("description").fill(opts.description);
|
||||
}
|
||||
|
||||
await page.getByTestId("route-continue").click();
|
||||
|
||||
if (opts.masquerade === false) {
|
||||
await page.getByText("Masquerade").click();
|
||||
}
|
||||
|
||||
if (opts.metric) {
|
||||
await page.getByTestId("metric").fill(opts.metric);
|
||||
}
|
||||
|
||||
await page.getByTestId("submit-route").click();
|
||||
|
||||
if (opts.access_control_groups && opts.access_control_groups.length > 0) {
|
||||
await page.getByTestId("confirmation.cancel").click();
|
||||
}
|
||||
|
||||
await expect(page.getByTestId(opts.name)).toBeVisible();
|
||||
}
|
||||
|
||||
async function deleteNetworkRoute(
|
||||
page: import("@playwright/test").Page,
|
||||
name: string,
|
||||
) {
|
||||
await page
|
||||
.locator("tr")
|
||||
.filter({ hasText: name })
|
||||
.getByRole("button", { name: "Delete" })
|
||||
.click();
|
||||
await page.getByTestId("confirmation.confirm").click();
|
||||
await expect(page.getByTestId(name)).not.toBeVisible();
|
||||
}
|
||||
164
e2e/tests/networks.spec.ts
Normal file
164
e2e/tests/networks.spec.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { test, expect } from "../helpers/fixtures";
|
||||
import { navigateTo } from "../helpers/auth";
|
||||
import { generateRandomName } from "../helpers/utils";
|
||||
import { deleteGroupsByPrefix, deleteNetworksByPrefix, deletePoliciesByGroupName, deletePoliciesBySubstring } from "../helpers/api";
|
||||
|
||||
let networkName = "";
|
||||
let resourceName = "";
|
||||
let policySourceGroup = "";
|
||||
let routingPeerGroup = "";
|
||||
|
||||
test.describe.serial("Networks @network", () => {
|
||||
test("Should create a network with a resource, policy, and routing peer", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await navigateTo(page, "/networks");
|
||||
|
||||
const name = generateRandomName("test-network-");
|
||||
networkName = name;
|
||||
await page.getByTestId("add-network").click();
|
||||
await page.getByTestId("network-name-input").fill(name);
|
||||
await page.getByTestId("network-description-input").fill("E2E test network");
|
||||
await page.getByTestId("submit-network").click();
|
||||
|
||||
// "Add Resource?" → confirm
|
||||
await page.getByTestId("confirmation.confirm").click({ force: true });
|
||||
|
||||
// Resource tab
|
||||
const resName = generateRandomName("test-resource-");
|
||||
resourceName = resName;
|
||||
await page.getByTestId("resource-name-input").fill(resName);
|
||||
await page.getByTestId("resource-address-input").fill("10.50.0.1");
|
||||
await page.getByTestId("resource-optional-settings").click();
|
||||
await page.getByTestId("resource-description-input").fill("E2E test resource");
|
||||
await page.getByTestId("resource-continue").click();
|
||||
|
||||
// Access control tab — add policy
|
||||
await page.getByTestId("add-policy").click();
|
||||
const srcGroup = generateRandomName("net-src-group-");
|
||||
policySourceGroup = srcGroup;
|
||||
await page.getByTestId("source-group-selector").click();
|
||||
await page.getByTestId("source-group-selector-search").fill(srcGroup);
|
||||
await page.getByTestId("source-group-selector-search").press("Enter");
|
||||
await page.getByTestId("source-group-selector-search").press("Escape");
|
||||
await page.getByTestId("policy-continue").click();
|
||||
await page.getByTestId("policy-continue").click();
|
||||
await page.getByTestId("submit-policy").click();
|
||||
|
||||
// Submit resource
|
||||
await page.getByTestId("submit-resource").click();
|
||||
|
||||
// "Add Routing Peer?" → confirm
|
||||
await page.getByTestId("confirmation.confirm").click({ force: true });
|
||||
|
||||
// Routing peer
|
||||
await page.getByTestId("routing-peer-tab-group").click({ force: true });
|
||||
const rpGroup = generateRandomName("net-rp-group-");
|
||||
routingPeerGroup = rpGroup;
|
||||
await page.getByTestId("group-selector-dropdown").click();
|
||||
await page.getByTestId("group-selector-dropdown-search").fill(rpGroup);
|
||||
await page.getByTestId("group-selector-dropdown-search").press("Enter");
|
||||
await page.getByTestId("group-selector-dropdown-search").press("Escape");
|
||||
await page.getByTestId("routing-peer-continue").click();
|
||||
await page.getByTestId("toggle-masquerade").click();
|
||||
await page.getByTestId("metric").fill("100");
|
||||
await page.getByTestId("submit-routing-peer").click();
|
||||
|
||||
// Verify network in table
|
||||
await expect(page.locator("tr").filter({ hasText: name })).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("Should add a CIDR range resource", async ({ dashboardAsOwner: page }) => {
|
||||
await addResourceToNetwork(page, "cidr-resource-", "192.168.100.0/24");
|
||||
});
|
||||
|
||||
test("Should add a domain resource", async ({ dashboardAsOwner: page }) => {
|
||||
await addResourceToNetwork(page, "domain-resource-", "resource.internal");
|
||||
});
|
||||
|
||||
test("Should rename the network from the table", async ({ dashboardAsOwner: page }) => {
|
||||
// Page is already on /networks from previous test
|
||||
const row = page.locator("tr").filter({ hasText: networkName });
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
await row.getByTestId("network-actions").click({ force: true });
|
||||
await page.getByTestId("rename-network").click({ force: true });
|
||||
|
||||
const newName = generateRandomName("test-network-");
|
||||
await page.getByTestId("network-name-input").fill(newName);
|
||||
await page.getByTestId("network-description-input").fill("Updated description");
|
||||
await page.getByTestId("submit-network").click();
|
||||
|
||||
await expect(page.locator("tr").filter({ hasText: newName })).toBeVisible({ timeout: 10_000 });
|
||||
networkName = newName;
|
||||
});
|
||||
|
||||
test("Should navigate to the network detail page and verify tabs", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await navigateTo(page, "/networks");
|
||||
const row = page.locator("tr").filter({ hasText: networkName });
|
||||
await expect(row).toBeVisible({ timeout: 10_000 });
|
||||
await row.locator("button").first().click();
|
||||
|
||||
// Wait for detail page to load (tab bar appears)
|
||||
await expect(page.locator('[role="tab"]').filter({ hasText: "Resource" })).toBeVisible();
|
||||
await expect(page.getByText(resourceName).first()).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Routing Peers tab
|
||||
await page.getByTestId("network-tab-routing-peers").click();
|
||||
await expect(page.getByText(routingPeerGroup).first()).toBeVisible();
|
||||
|
||||
// Services tab
|
||||
await page.getByTestId("network-tab-services").click();
|
||||
await expect(page.getByTestId("network-tab-services")).toHaveAttribute("data-state", "active");
|
||||
});
|
||||
|
||||
test("Should rename the network from the detail page", async ({ dashboardAsOwner: page }) => {
|
||||
// Already on the detail page from previous test
|
||||
await page.getByTestId("network-detail-actions").click();
|
||||
await page.getByTestId("rename-network").click({ force: true });
|
||||
|
||||
const newName = generateRandomName("test-network-");
|
||||
await page.getByTestId("network-name-input").fill(newName);
|
||||
await page.getByTestId("network-description-input").fill("Renamed from detail page");
|
||||
await page.getByTestId("submit-network").click();
|
||||
|
||||
await expect(page.getByText(newName).first()).toBeVisible();
|
||||
networkName = newName;
|
||||
});
|
||||
|
||||
test("Should delete the network and clean up", async ({ dashboardAsOwner: page }) => {
|
||||
await deleteNetworksByPrefix(page, "test-network-");
|
||||
await deletePoliciesByGroupName(page, policySourceGroup);
|
||||
await deletePoliciesBySubstring(page, "test-resource-");
|
||||
for (const group of [policySourceGroup, routingPeerGroup]) {
|
||||
await deleteGroupsByPrefix(page, group);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function addResourceToNetwork(
|
||||
page: import("@playwright/test").Page,
|
||||
prefix: string,
|
||||
address: string,
|
||||
) {
|
||||
// Page should already be on /networks from previous test
|
||||
const row = page.locator("tr").filter({ hasText: networkName });
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
const name = generateRandomName(prefix);
|
||||
// The per-row resource-add affordance is now an icon "Add" button.
|
||||
await row.getByTestId("add-resource").click();
|
||||
|
||||
await expect(page.getByTestId("resource-name-input")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("resource-name-input").fill(name);
|
||||
await page.getByTestId("resource-address-input").fill(address);
|
||||
await page.getByTestId("resource-continue").click();
|
||||
|
||||
await page.getByTestId("submit-resource").click();
|
||||
// "No policies configured" warning
|
||||
await page.getByTestId("confirmation.confirm").click();
|
||||
// "Add Routing Peer?" prompt — wait for it and dismiss
|
||||
await page.getByTestId("confirmation.cancel").click();
|
||||
}
|
||||
167
e2e/tests/reverse-proxy-crowdsec.spec.ts
Normal file
167
e2e/tests/reverse-proxy-crowdsec.spec.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { test, expect } from "../helpers/fixtures";
|
||||
import { navigateTo } from "../helpers/auth";
|
||||
import { generateRandomName } from "../helpers/utils";
|
||||
import { deleteNetworksByPrefix, deleteServicesByPrefix } from "../helpers/api";
|
||||
import {
|
||||
gotoReverseProxyPage,
|
||||
selectL4Resource,
|
||||
selectProxyDomain,
|
||||
openServiceEdit,
|
||||
deleteService,
|
||||
resetServiceFilters,
|
||||
CUSTOM_PORTS_DOMAIN,
|
||||
} from "../helpers/reverse-proxy-l4";
|
||||
|
||||
const DOMAINS_GLOB = "**/reverse-proxies/domains";
|
||||
|
||||
// Force the test clusters to advertise CrowdSec support so the selector renders,
|
||||
// independent of whether the test backend has CrowdSec configured. The save
|
||||
// payload assertion below verifies the real wiring regardless of the backend.
|
||||
async function forceCrowdSecSupport(page: import("@playwright/test").Page) {
|
||||
await page.route(DOMAINS_GLOB, async (route) => {
|
||||
if (route.request().method() !== "GET") return route.continue();
|
||||
const response = await route.fetch();
|
||||
let body: any;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch (e) {
|
||||
return route.fulfill({ response });
|
||||
}
|
||||
if (Array.isArray(body)) {
|
||||
body = body.map((d) => ({ ...d, supports_crowdsec: true }));
|
||||
}
|
||||
return route.fulfill({ response, json: body });
|
||||
});
|
||||
}
|
||||
|
||||
test.describe.serial("Reverse Proxy - CrowdSec @reverse-proxy", () => {
|
||||
let network = "";
|
||||
let resource = "";
|
||||
let subdomain = "";
|
||||
|
||||
test("Should configure CrowdSec on a service and send crowdsec_mode on save", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
await forceCrowdSecSupport(page);
|
||||
await deleteServicesByPrefix(page, "crowdsec-svc-");
|
||||
await deleteNetworksByPrefix(page, "rp-crowdsec-net-");
|
||||
|
||||
// Create a network with a resource (same inline flow as the L4 specs).
|
||||
await navigateTo(page, "/networks");
|
||||
network = generateRandomName("rp-crowdsec-net-");
|
||||
await page.getByTestId("add-network").click();
|
||||
await page.getByTestId("network-name-input").fill(network);
|
||||
await page.getByTestId("submit-network").click();
|
||||
await page.getByTestId("confirmation.confirm").click({ force: true });
|
||||
|
||||
resource = generateRandomName("rp-resource-");
|
||||
await page.getByTestId("resource-name-input").fill(resource);
|
||||
await page.getByTestId("resource-address-input").fill("10.99.99.40");
|
||||
await page.getByTestId("resource-continue").click();
|
||||
const resourcePromise = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/api/networks/") &&
|
||||
resp.url().includes("/resources") &&
|
||||
resp.request().method() === "POST",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await page.getByTestId("submit-resource").click();
|
||||
await page.getByTestId("confirmation.confirm").click({ force: true });
|
||||
await resourcePromise;
|
||||
const cancelBtn = page.getByTestId("confirmation.cancel");
|
||||
if (await cancelBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await cancelBtn.click({ force: true });
|
||||
}
|
||||
|
||||
await gotoReverseProxyPage(page, "/reverse-proxy/services");
|
||||
subdomain = generateRandomName("crowdsec-svc-");
|
||||
|
||||
await page.getByTestId("add-service").first().click();
|
||||
await expect(page.getByTestId("proxy-subdomain-input")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await page.getByTestId("proxy-subdomain-input").fill(subdomain);
|
||||
await selectProxyDomain(page, CUSTOM_PORTS_DOMAIN);
|
||||
await page
|
||||
.getByTestId("service-mode-select-button")
|
||||
.click({ force: true });
|
||||
await page.getByTestId("service-mode-option-tcp").click({ force: true });
|
||||
await expect(page.getByTestId("group-selector-dropdown")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await selectL4Resource(page, resource);
|
||||
await expect(page.getByTestId("listen-port-input")).toBeEnabled({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await page.getByTestId("listen-port-input").fill("3306");
|
||||
await page.getByTestId("destination-port-input").fill("3306");
|
||||
await page.getByTestId("proxy-continue").click();
|
||||
|
||||
// Access control step: the CrowdSec selector renders for supporting clusters.
|
||||
const crowdsecTrigger = page.getByTestId("crowdsec-mode-trigger");
|
||||
await expect(crowdsecTrigger).toBeVisible({ timeout: 10_000 });
|
||||
await crowdsecTrigger.click({ force: true });
|
||||
await page.getByTestId("crowdsec-mode-enforce").click({ force: true });
|
||||
await expect(crowdsecTrigger).toContainText("Enforce");
|
||||
|
||||
await page.getByTestId("proxy-continue").click();
|
||||
|
||||
const savePromise = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/reverse-proxies/services") &&
|
||||
resp.request().method() === "POST",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await page.getByTestId("submit-service").click();
|
||||
const saveResp = await savePromise;
|
||||
|
||||
// Core assertion: the configured mode is included in the save payload.
|
||||
const payload = saveResp.request().postDataJSON();
|
||||
expect(
|
||||
payload?.access_restrictions?.crowdsec_mode,
|
||||
"crowdsec_mode should be sent in the service payload",
|
||||
).toBe("enforce");
|
||||
|
||||
await resetServiceFilters(page);
|
||||
await expect(
|
||||
page.locator("tr").filter({ hasText: subdomain }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
test("Should show CrowdSec in the access control cell and persist on reopen", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
await forceCrowdSecSupport(page);
|
||||
await gotoReverseProxyPage(page, "/reverse-proxy/services");
|
||||
await resetServiceFilters(page);
|
||||
|
||||
// The access control cell counts CrowdSec as a rule and lists it on hover.
|
||||
const cell = page
|
||||
.locator("tr")
|
||||
.filter({ hasText: subdomain })
|
||||
.locator("[data-access-control-cell]");
|
||||
await expect(cell).toContainText("1", { timeout: 10_000 });
|
||||
|
||||
// Reopen the service: the selector reflects the persisted Enforce mode.
|
||||
await openServiceEdit(page, subdomain);
|
||||
await page.getByTestId("proxy-tab-access-control").click({ force: true });
|
||||
await expect(page.getByTestId("crowdsec-mode-trigger")).toContainText(
|
||||
"Enforce",
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
|
||||
test("Should clean up the CrowdSec service and network", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await forceCrowdSecSupport(page);
|
||||
await gotoReverseProxyPage(page, "/reverse-proxy/services");
|
||||
await resetServiceFilters(page);
|
||||
await deleteService(page, subdomain);
|
||||
await deleteNetworksByPrefix(page, network);
|
||||
await page.unroute(DOMAINS_GLOB);
|
||||
});
|
||||
});
|
||||
87
e2e/tests/reverse-proxy-custom-domains.spec.ts
Normal file
87
e2e/tests/reverse-proxy-custom-domains.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { test, expect } from "../helpers/fixtures";
|
||||
import { navigateTo } from "../helpers/auth";
|
||||
import { applyRadioTableFilter, generateRandomName } from "../helpers/utils";
|
||||
import { gotoReverseProxyPage } from "../helpers/reverse-proxy-l4";
|
||||
|
||||
let domain = "";
|
||||
const TARGET_CLUSTER = "example.com";
|
||||
|
||||
test.describe.serial("Reverse Proxy - Custom Domains @reverse-proxy", () => {
|
||||
test("Should validate domain input and add a custom domain", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await gotoReverseProxyPage(page, "/reverse-proxy/custom-domains");
|
||||
|
||||
await page.getByTestId("add-custom-domain").click();
|
||||
await expect(page.getByTestId("custom-domain-input")).toBeVisible();
|
||||
|
||||
// Invalid input should show error
|
||||
await page.getByTestId("custom-domain-input").fill("mycustomdomain");
|
||||
await page.getByTestId("custom-domain-input").blur();
|
||||
await expect(page.getByText("Please enter a valid TLD domain")).toBeVisible();
|
||||
|
||||
// Fill valid domain — error should disappear
|
||||
const prefix = generateRandomName("mycustomdomain-");
|
||||
domain = `${prefix}.com`;
|
||||
await page.getByTestId("custom-domain-input").fill(domain);
|
||||
await expect(page.getByText("Please enter a valid TLD domain")).toHaveCount(0);
|
||||
|
||||
// Pick the target proxy cluster explicitly — with multiple clusters the
|
||||
// dashboard does not auto-select.
|
||||
const clusterSection = page.getByTestId("custom-domain-cluster-selector");
|
||||
await clusterSection.locator("button").first().click({ force: true });
|
||||
await page
|
||||
.locator('[role="option"]')
|
||||
.filter({ has: page.getByText(TARGET_CLUSTER, { exact: true }) })
|
||||
.first()
|
||||
.click({ force: true });
|
||||
|
||||
const responsePromise = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/api/reverse-proxies/domains") &&
|
||||
resp.request().method() === "POST",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await page.getByTestId("submit-custom-domain").click();
|
||||
const response = await responsePromise;
|
||||
expect([200, 201]).toContain(response.status());
|
||||
|
||||
await expect(page.getByRole("heading", { name: "Verify Domain" })).toBeVisible();
|
||||
await expect(page.getByText(`*.${domain}`)).toBeVisible();
|
||||
|
||||
await page.getByTestId("verify-domain-later").click();
|
||||
|
||||
const row = page.locator("tr").filter({ hasText: domain });
|
||||
await expect(row).toBeVisible();
|
||||
await expect(row).toContainText("Pending Verification");
|
||||
await expect(row).toContainText(TARGET_CLUSTER);
|
||||
});
|
||||
|
||||
test("Should filter domains by Pending and Active", async ({ dashboardAsOwner: page }) => {
|
||||
await applyRadioTableFilter(page, "validated", "Pending");
|
||||
await expect(page.locator("tr").filter({ hasText: domain })).toBeVisible();
|
||||
|
||||
await applyRadioTableFilter(page, "validated", "Active");
|
||||
await expect(page.locator("tr").filter({ hasText: domain })).not.toBeVisible();
|
||||
|
||||
await applyRadioTableFilter(page, "validated", "All");
|
||||
await expect(page.locator("tr").filter({ hasText: domain })).toBeVisible();
|
||||
});
|
||||
|
||||
test("Should search for the domain", async ({ dashboardAsOwner: page }) => {
|
||||
const searchInput = page.getByTestId("table-search-input");
|
||||
await searchInput.fill(domain);
|
||||
await expect(page.locator("tr").filter({ hasText: domain })).toBeVisible();
|
||||
await searchInput.fill("");
|
||||
});
|
||||
|
||||
test("Should delete the custom domain", async ({ dashboardAsOwner: page }) => {
|
||||
await page
|
||||
.locator("tr")
|
||||
.filter({ hasText: domain })
|
||||
.getByTestId("delete-custom-domain")
|
||||
.click();
|
||||
await page.getByTestId("confirmation.confirm").click();
|
||||
await expect(page.locator("tr").filter({ hasText: domain })).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
275
e2e/tests/reverse-proxy-services-https.spec.ts
Normal file
275
e2e/tests/reverse-proxy-services-https.spec.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { test, expect } from "../helpers/fixtures";
|
||||
import { navigateTo } from "../helpers/auth";
|
||||
import { generateRandomName } from "../helpers/utils";
|
||||
import { deleteNetworksByPrefix, deleteServicesByPrefix } from "../helpers/api";
|
||||
import { gotoReverseProxyPage, selectProxyDomain, CUSTOM_PORTS_DOMAIN } from "../helpers/reverse-proxy-l4";
|
||||
|
||||
let createdNetwork = "";
|
||||
let createdResource = "";
|
||||
let createdSubdomain = "";
|
||||
|
||||
test.describe.serial("Reverse Proxy - Services (HTTPS) @reverse-proxy", () => {
|
||||
test("Should create a network with a resource", async ({ dashboardAsOwner: page }) => {
|
||||
// Clean up leftovers from previous runs (unique prefix per protocol)
|
||||
await deleteServicesByPrefix(page, "https-svc-");
|
||||
await deleteNetworksByPrefix(page, "rp-https-net-");
|
||||
await navigateTo(page, "/networks");
|
||||
const name = generateRandomName("rp-https-net-");
|
||||
createdNetwork = name;
|
||||
await page.getByTestId("add-network").click();
|
||||
await page.getByTestId("network-name-input").fill(name);
|
||||
await page.getByTestId("submit-network").click();
|
||||
await page.getByTestId("confirmation.confirm").click({ force: true });
|
||||
|
||||
// Add resource
|
||||
const resName = generateRandomName("rp-resource-");
|
||||
createdResource = resName;
|
||||
await page.getByTestId("resource-name-input").fill(resName);
|
||||
await page.getByTestId("resource-address-input").fill("10.99.99.10");
|
||||
await page.getByTestId("resource-continue").click();
|
||||
|
||||
const responsePromise = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/api/networks/") &&
|
||||
resp.url().includes("/resources") &&
|
||||
resp.request().method() === "POST",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await page.getByTestId("submit-resource").click();
|
||||
await page.getByTestId("confirmation.confirm").click({ force: true });
|
||||
await responsePromise;
|
||||
await page.getByTestId("confirmation.cancel").click({ force: true });
|
||||
});
|
||||
|
||||
test("Should create an HTTPS reverse proxy service with full configuration", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
await gotoReverseProxyPage(page, "/reverse-proxy/services");
|
||||
const subdomain = generateRandomName("https-svc-");
|
||||
createdSubdomain = subdomain;
|
||||
|
||||
await page.getByTestId("add-service").first().click();
|
||||
await expect(page.getByTestId("proxy-subdomain-input")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Step 1: Service
|
||||
await page.getByTestId("proxy-subdomain-input").fill(subdomain);
|
||||
await selectProxyDomain(page, CUSTOM_PORTS_DOMAIN);
|
||||
|
||||
// Add 2 targets: http with options, https with port
|
||||
await addTarget(page, {
|
||||
resourceName: createdResource,
|
||||
protocol: "http",
|
||||
timeout: "10s",
|
||||
customHeader: { name: "X-Custom-Header", value: "custom-value" },
|
||||
});
|
||||
await addTarget(page, {
|
||||
resourceName: createdResource,
|
||||
location: "/secure",
|
||||
protocol: "https",
|
||||
port: 4433,
|
||||
});
|
||||
|
||||
const targetsSection = page.getByText("HTTPS Targets").locator("..");
|
||||
await expect(targetsSection.locator("table tbody tr")).toHaveCount(2);
|
||||
|
||||
await page.getByTestId("proxy-continue").click();
|
||||
|
||||
// Step 2: Authentication
|
||||
await page.getByTestId("auth-sso-card").click();
|
||||
await page.getByTestId("submit-sso").click();
|
||||
|
||||
await page.getByTestId("auth-password-card").click();
|
||||
await page.getByTestId("password-input").fill("super-secret-pass");
|
||||
await page.getByTestId("submit-password").click();
|
||||
|
||||
await page.getByTestId("auth-pin-card").click();
|
||||
const pinInputs = page.locator('input[inputmode="numeric"][maxlength="1"]');
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await pinInputs.nth(i).fill(String(i + 1), { force: true });
|
||||
}
|
||||
await page.getByTestId("submit-pin").click();
|
||||
|
||||
await page.getByTestId("auth-header-card").click();
|
||||
await page.getByTestId("header-type-select").click();
|
||||
await page.locator("[cmdk-list]").getByText("Basic Auth").click({ force: true });
|
||||
await page.getByTestId("header-basic-username").fill("admin");
|
||||
await page.getByTestId("header-basic-password").fill("admin-pass");
|
||||
await page.getByTestId("submit-headers").click();
|
||||
|
||||
await page.getByTestId("proxy-continue").click();
|
||||
|
||||
// Step 3: Access Control
|
||||
await page.getByTestId("add-access-rule").click();
|
||||
await page.getByTestId("access-rule-0").getByText("Select country...").click();
|
||||
await page.getByTestId("select-dropdown-search").fill("Germany");
|
||||
await page.getByText("Germany (DE)").click({ force: true });
|
||||
|
||||
await page.getByTestId("add-access-rule").click();
|
||||
await page.getByTestId("access-rule-1").getByTestId("access-rule-action").click();
|
||||
await page.getByText("Block Only").click({ force: true });
|
||||
await page.getByTestId("access-rule-1").getByTestId("access-rule-type").click();
|
||||
await page.locator('[role="option"]').filter({ hasText: "IP Address" }).click({ force: true });
|
||||
const ipInput = page.getByTestId("access-rule-1").getByTestId("access-rule-value");
|
||||
await expect(ipInput).toBeVisible();
|
||||
await ipInput.fill("85.203.15.42");
|
||||
|
||||
await page.getByTestId("proxy-continue").click();
|
||||
|
||||
// Step 4: Advanced Settings
|
||||
await page.getByTestId("toggle-pass-host-header").click();
|
||||
await page.getByTestId("toggle-rewrite-redirects").click();
|
||||
await page.getByTestId("submit-service").click();
|
||||
|
||||
await expect(page.locator("tr").filter({ hasText: subdomain })).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
test("Should edit the service, remove auth and rules, then delete", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await resetServiceFilters(page);
|
||||
await page.locator("tr").filter({ hasText: createdSubdomain }).getByTestId("service-actions").click({ force: true });
|
||||
await page.getByTestId("edit-service").click({ force: true });
|
||||
|
||||
// Edit first target
|
||||
const targetsSection = page.getByText("HTTPS Targets").locator("..");
|
||||
await targetsSection.locator("table tbody tr").first().click({ force: true });
|
||||
await page.getByTestId("target-location-input").fill("/new-location");
|
||||
await page.getByTestId("submit-target").click();
|
||||
|
||||
// Remove second target
|
||||
await targetsSection.locator("table tbody tr").filter({ hasText: "/secure" }).getByTestId("target-row-actions").click();
|
||||
await page.getByTestId("remove-target").click();
|
||||
await expect(targetsSection.locator("table tbody tr")).toHaveCount(1);
|
||||
|
||||
// Remove all auth methods — click Edit on each card, then Remove in the modal
|
||||
await page.getByTestId("proxy-tab-auth").click({ force: true });
|
||||
await removeAuthMethod(page, "auth-sso-card", "remove-sso");
|
||||
await removeAuthMethod(page, "auth-password-card", "remove-password");
|
||||
await removeAuthMethod(page, "auth-pin-card", "remove-pin");
|
||||
await removeAuthMethod(page, "auth-header-card", "remove-headers");
|
||||
|
||||
// Remove access control rules
|
||||
await page.getByTestId("proxy-tab-access-control").click({ force: true });
|
||||
await page.getByTestId("remove-access-rule").last().click({ force: true });
|
||||
await page.getByTestId("remove-access-rule").first().click({ force: true });
|
||||
|
||||
// Toggle advanced settings back
|
||||
await page.getByTestId("proxy-tab-settings").click({ force: true });
|
||||
await page.getByTestId("toggle-pass-host-header").click({ force: true });
|
||||
await page.getByTestId("toggle-rewrite-redirects").click({ force: true });
|
||||
|
||||
// Save and wait for API response
|
||||
const saveResponse = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/api/reverse-proxies/services") &&
|
||||
resp.request().method() === "PUT",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await page.getByTestId("proxy-save").click();
|
||||
const confirmBtn = page.getByTestId("confirmation.confirm");
|
||||
if (await confirmBtn.isVisible({ timeout: 3_000 }).catch(() => false)) {
|
||||
await confirmBtn.click({ force: true });
|
||||
}
|
||||
await saveResponse;
|
||||
|
||||
// Verify no auth / no access rules: both cells now show a "0" count badge.
|
||||
await resetServiceFilters(page);
|
||||
const row = page.locator("tr").filter({ hasText: createdSubdomain });
|
||||
await expect(row.locator("[data-auth-cell]")).toContainText("0", {
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(
|
||||
row.locator("[data-access-control-cell]"),
|
||||
).toContainText("0", { timeout: 15_000 });
|
||||
|
||||
// Delete the service
|
||||
await row.getByTestId("service-actions").click({ force: true });
|
||||
await page.getByTestId("delete-service").click({ force: true });
|
||||
await page.getByTestId("confirmation.confirm").click({ force: true });
|
||||
await expect(row).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("Should delete the network", async ({ dashboardAsOwner: page }) => {
|
||||
await deleteNetworksByPrefix(page, createdNetwork);
|
||||
});
|
||||
});
|
||||
|
||||
async function resetServiceFilters(page: import("@playwright/test").Page) {
|
||||
const resetBtn = page.getByTestId("reset-filters-and-search");
|
||||
if (await resetBtn.isVisible().catch(() => false)) {
|
||||
await resetBtn.click();
|
||||
}
|
||||
}
|
||||
|
||||
type AddTargetOptions = {
|
||||
resourceName: string;
|
||||
location?: string;
|
||||
protocol?: "http" | "https";
|
||||
port?: number;
|
||||
timeout?: string;
|
||||
customHeader?: { name: string; value: string };
|
||||
};
|
||||
|
||||
async function addTarget(page: import("@playwright/test").Page, opts: AddTargetOptions) {
|
||||
await page.getByTestId("add-target").scrollIntoViewIfNeeded();
|
||||
await page.getByTestId("add-target").click();
|
||||
await expect(page.getByTestId("group-selector-dropdown")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await page.getByTestId("group-selector-dropdown").click();
|
||||
await page.locator('[role="tab"]').filter({ hasText: "Resources" }).click({ force: true });
|
||||
const search = page.getByTestId("group-selector-dropdown-search");
|
||||
await expect(search).toBeVisible({ timeout: 5_000 });
|
||||
await search.fill(opts.resourceName);
|
||||
await page.getByText(opts.resourceName).click({ force: true, timeout: 15_000 });
|
||||
await expect(page.getByTestId("target-port-input")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
if (opts.location) {
|
||||
await expect(page.getByTestId("target-location-input")).toBeEnabled({ timeout: 5_000 });
|
||||
await page.getByTestId("target-location-input").fill(opts.location);
|
||||
}
|
||||
|
||||
if (opts.protocol === "https") {
|
||||
await page.getByTestId("target-protocol-select").click();
|
||||
await page.locator("[cmdk-list]").getByText("https://").click({ force: true });
|
||||
}
|
||||
|
||||
if (opts.port !== undefined) {
|
||||
await page.getByTestId("target-port-input").fill(String(opts.port));
|
||||
} else {
|
||||
await page.getByTestId("target-port-input").fill("");
|
||||
}
|
||||
|
||||
if (opts.timeout || opts.customHeader) {
|
||||
await page.getByTestId("target-optional-settings").click();
|
||||
if (opts.timeout) {
|
||||
await page.getByTestId("target-timeout-input").fill(opts.timeout);
|
||||
}
|
||||
if (opts.customHeader) {
|
||||
await page.getByTestId("add-custom-header").click();
|
||||
await page.getByTestId("custom-header-name-0").fill(opts.customHeader.name);
|
||||
await page.getByTestId("custom-header-value-0").fill(opts.customHeader.value);
|
||||
}
|
||||
}
|
||||
|
||||
await page.getByTestId("submit-target").click();
|
||||
}
|
||||
|
||||
async function removeAuthMethod(
|
||||
page: import("@playwright/test").Page,
|
||||
cardTestId: string,
|
||||
removeTestId: string,
|
||||
) {
|
||||
const card = page.getByTestId(cardTestId);
|
||||
const removeBtn = page.getByTestId(removeTestId);
|
||||
|
||||
// Click the card to open the auth modal
|
||||
await card.click();
|
||||
await expect(removeBtn).toBeVisible();
|
||||
await removeBtn.click();
|
||||
|
||||
// Wait for the modal to fully close — the remove button must disappear
|
||||
// and the "Enabled" badge on the card should also disappear
|
||||
await expect(removeBtn).not.toBeVisible();
|
||||
await expect(card.getByText("Enabled")).not.toBeVisible();
|
||||
}
|
||||
115
e2e/tests/reverse-proxy-services-tcp.spec.ts
Normal file
115
e2e/tests/reverse-proxy-services-tcp.spec.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { test, expect } from "../helpers/fixtures";
|
||||
import { navigateTo } from "../helpers/auth";
|
||||
import { generateRandomName } from "../helpers/utils";
|
||||
import { deleteNetworksByPrefix, deleteServicesByPrefix } from "../helpers/api";
|
||||
import {
|
||||
gotoReverseProxyPage,
|
||||
selectL4Resource,
|
||||
addAccessControlRules,
|
||||
removeAllAccessControlRules,
|
||||
resetServiceFilters,
|
||||
openServiceEdit,
|
||||
deleteService,
|
||||
saveServiceEdit,
|
||||
selectProxyDomain,
|
||||
CUSTOM_PORTS_DOMAIN,
|
||||
} from "../helpers/reverse-proxy-l4";
|
||||
|
||||
let tcpNetwork = "";
|
||||
let tcpResource = "";
|
||||
let tcpSubdomain = "";
|
||||
|
||||
test.describe.serial("Reverse Proxy - Services (TCP) @reverse-proxy", () => {
|
||||
test("Should create a network with a resource", async ({ dashboardAsOwner: page }) => {
|
||||
await deleteServicesByPrefix(page, "tcp-svc-");
|
||||
await deleteNetworksByPrefix(page, "rp-tcp-net-");
|
||||
await navigateTo(page, "/networks");
|
||||
|
||||
const name = generateRandomName("rp-tcp-net-");
|
||||
tcpNetwork = name;
|
||||
await page.getByTestId("add-network").click();
|
||||
await page.getByTestId("network-name-input").fill(name);
|
||||
await page.getByTestId("submit-network").click();
|
||||
await page.getByTestId("confirmation.confirm").click({ force: true });
|
||||
|
||||
const resName = generateRandomName("rp-resource-");
|
||||
tcpResource = resName;
|
||||
await page.getByTestId("resource-name-input").fill(resName);
|
||||
await page.getByTestId("resource-address-input").fill("10.99.99.30");
|
||||
await page.getByTestId("resource-continue").click();
|
||||
|
||||
const responsePromise = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/api/networks/") &&
|
||||
resp.url().includes("/resources") &&
|
||||
resp.request().method() === "POST",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await page.getByTestId("submit-resource").click();
|
||||
await page.getByTestId("confirmation.confirm").click({ force: true });
|
||||
await responsePromise;
|
||||
const cancelBtn = page.getByTestId("confirmation.cancel");
|
||||
if (await cancelBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await cancelBtn.click({ force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Should create a TCP service", async ({ dashboardAsOwner: page }) => {
|
||||
await gotoReverseProxyPage(page, "/reverse-proxy/services");
|
||||
const subdomain = generateRandomName("tcp-svc-");
|
||||
tcpSubdomain = subdomain;
|
||||
|
||||
await page.getByTestId("add-service").first().click();
|
||||
await expect(page.getByTestId("proxy-subdomain-input")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("proxy-subdomain-input").fill(subdomain);
|
||||
await selectProxyDomain(page, CUSTOM_PORTS_DOMAIN);
|
||||
await page.getByTestId("service-mode-select-button").click({ force: true });
|
||||
await page.getByTestId("service-mode-option-tcp").click({ force: true });
|
||||
await expect(page.getByTestId("group-selector-dropdown")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await selectL4Resource(page, tcpResource);
|
||||
await expect(page.getByTestId("listen-port-input")).toBeEnabled({ timeout: 10_000 });
|
||||
await page.getByTestId("listen-port-input").fill("3306");
|
||||
await page.getByTestId("destination-port-input").fill("3306");
|
||||
await page.getByTestId("proxy-continue").click();
|
||||
|
||||
await addAccessControlRules(page);
|
||||
await page.getByTestId("proxy-continue").click();
|
||||
|
||||
await page.getByTestId("connection-timeout-input").fill("20s");
|
||||
await page.getByTestId("toggle-preserve-client-ip").click();
|
||||
await page.getByTestId("submit-service").click();
|
||||
|
||||
await resetServiceFilters(page);
|
||||
await expect(page.locator("tr").filter({ hasText: subdomain }).getByText("TCP", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
test("Should edit the TCP service and delete it", async ({ dashboardAsOwner: page }) => {
|
||||
await openServiceEdit(page, tcpSubdomain);
|
||||
|
||||
await page.getByTestId("listen-port-input").fill("5432");
|
||||
await page.getByTestId("destination-port-input").fill("5432");
|
||||
|
||||
await page.getByTestId("proxy-tab-access-control").click({ force: true });
|
||||
await removeAllAccessControlRules(page);
|
||||
|
||||
await page.getByTestId("proxy-tab-settings").click({ force: true });
|
||||
await page.getByTestId("toggle-preserve-client-ip").click({ force: true });
|
||||
await page.getByTestId("connection-timeout-input").fill("15s");
|
||||
|
||||
await saveServiceEdit(page);
|
||||
|
||||
await resetServiceFilters(page);
|
||||
const row = page.locator("tr").filter({ hasText: tcpSubdomain });
|
||||
await expect(row.locator("[data-access-control-cell]")).toContainText(
|
||||
"0",
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
|
||||
await deleteService(page, tcpSubdomain);
|
||||
});
|
||||
|
||||
test("Should delete the network", async ({ dashboardAsOwner: page }) => {
|
||||
await deleteNetworksByPrefix(page, tcpNetwork);
|
||||
});
|
||||
});
|
||||
117
e2e/tests/reverse-proxy-services-tls.spec.ts
Normal file
117
e2e/tests/reverse-proxy-services-tls.spec.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { test, expect } from "../helpers/fixtures";
|
||||
import { navigateTo } from "../helpers/auth";
|
||||
import { generateRandomName } from "../helpers/utils";
|
||||
import { deleteNetworksByPrefix, deleteServicesByPrefix } from "../helpers/api";
|
||||
import {
|
||||
gotoReverseProxyPage,
|
||||
selectL4Resource,
|
||||
addAccessControlRules,
|
||||
removeAllAccessControlRules,
|
||||
resetServiceFilters,
|
||||
openServiceEdit,
|
||||
deleteService,
|
||||
saveServiceEdit,
|
||||
selectProxyDomain,
|
||||
CUSTOM_PORTS_DOMAIN,
|
||||
} from "../helpers/reverse-proxy-l4";
|
||||
|
||||
let tlsNetwork = "";
|
||||
let tlsResource = "";
|
||||
let tlsSubdomain = "";
|
||||
|
||||
test.describe.serial("Reverse Proxy - Services (TLS Passthrough) @reverse-proxy", () => {
|
||||
test("Should create a network with a resource", async ({ dashboardAsOwner: page }) => {
|
||||
// Clean up leftover networks
|
||||
await deleteServicesByPrefix(page, "tls-svc-");
|
||||
await deleteNetworksByPrefix(page, "rp-tls-net-");
|
||||
await navigateTo(page, "/networks");
|
||||
|
||||
// Create network
|
||||
const name = generateRandomName("rp-tls-net-");
|
||||
tlsNetwork = name;
|
||||
await page.getByTestId("add-network").click();
|
||||
await page.getByTestId("network-name-input").fill(name);
|
||||
await page.getByTestId("submit-network").click();
|
||||
await page.getByTestId("confirmation.confirm").click({ force: true });
|
||||
|
||||
// Add resource directly from the confirmation flow
|
||||
const resName = generateRandomName("rp-resource-");
|
||||
tlsResource = resName;
|
||||
await page.getByTestId("resource-name-input").fill(resName);
|
||||
await page.getByTestId("resource-address-input").fill("10.99.99.20");
|
||||
await page.getByTestId("resource-continue").click();
|
||||
|
||||
const responsePromise = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/api/networks/") &&
|
||||
resp.url().includes("/resources") &&
|
||||
resp.request().method() === "POST",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await page.getByTestId("submit-resource").click();
|
||||
await page.getByTestId("confirmation.confirm").click({ force: true });
|
||||
await responsePromise;
|
||||
// "Add Routing Peer?" prompt may or may not appear
|
||||
const cancelBtn = page.getByTestId("confirmation.cancel");
|
||||
if (await cancelBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await cancelBtn.click({ force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Should create a TLS Passthrough service", async ({ dashboardAsOwner: page }) => {
|
||||
test.setTimeout(60_000);
|
||||
await gotoReverseProxyPage(page, "/reverse-proxy/services");
|
||||
const subdomain = generateRandomName("tls-svc-");
|
||||
tlsSubdomain = subdomain;
|
||||
|
||||
await page.getByTestId("add-service").first().click();
|
||||
await expect(page.getByTestId("proxy-subdomain-input")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("proxy-subdomain-input").fill(subdomain);
|
||||
await selectProxyDomain(page, CUSTOM_PORTS_DOMAIN);
|
||||
await page.getByTestId("service-mode-select-button").click({ force: true });
|
||||
await page.getByTestId("service-mode-option-tls").click({ force: true });
|
||||
await expect(page.getByTestId("group-selector-dropdown")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await selectL4Resource(page, tlsResource);
|
||||
await expect(page.getByTestId("listen-port-input")).toBeEnabled({ timeout: 10_000 });
|
||||
await page.getByTestId("listen-port-input").fill("8443");
|
||||
await page.getByTestId("destination-port-input").fill("443");
|
||||
await page.getByTestId("proxy-continue").click();
|
||||
|
||||
await addAccessControlRules(page);
|
||||
await page.getByTestId("proxy-continue").click();
|
||||
|
||||
await page.getByTestId("toggle-preserve-client-ip").click();
|
||||
await page.getByTestId("connection-timeout-input").fill("20s");
|
||||
await page.getByTestId("submit-service").click();
|
||||
|
||||
await resetServiceFilters(page);
|
||||
await expect(page.locator("tr").filter({ hasText: subdomain }).getByText("TLS Passthrough")).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
test("Should edit the TLS service and delete it", async ({ dashboardAsOwner: page }) => {
|
||||
await openServiceEdit(page, tlsSubdomain);
|
||||
|
||||
await page.getByTestId("listen-port-input").fill("9443");
|
||||
await page.getByTestId("destination-port-input").fill("8443");
|
||||
|
||||
await page.getByTestId("proxy-tab-access-control").click({ force: true });
|
||||
await removeAllAccessControlRules(page);
|
||||
|
||||
await page.getByTestId("proxy-tab-settings").click({ force: true });
|
||||
await page.getByTestId("toggle-preserve-client-ip").click({ force: true });
|
||||
await page.getByTestId("connection-timeout-input").fill("");
|
||||
|
||||
await saveServiceEdit(page);
|
||||
|
||||
await resetServiceFilters(page);
|
||||
const row = page.locator("tr").filter({ hasText: tlsSubdomain });
|
||||
await expect(row.locator("[data-access-control-cell]")).toContainText("0");
|
||||
|
||||
await deleteService(page, tlsSubdomain);
|
||||
});
|
||||
|
||||
test("Should delete the network", async ({ dashboardAsOwner: page }) => {
|
||||
await deleteNetworksByPrefix(page, tlsNetwork);
|
||||
});
|
||||
});
|
||||
119
e2e/tests/reverse-proxy-services-udp-no-custom-ports.spec.ts
Normal file
119
e2e/tests/reverse-proxy-services-udp-no-custom-ports.spec.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { test, expect } from "../helpers/fixtures";
|
||||
import { navigateTo } from "../helpers/auth";
|
||||
import { generateRandomName } from "../helpers/utils";
|
||||
import { deleteNetworksByPrefix, deleteServicesByPrefix } from "../helpers/api";
|
||||
import {
|
||||
gotoReverseProxyPage,
|
||||
selectL4Resource,
|
||||
addAccessControlRules,
|
||||
removeAllAccessControlRules,
|
||||
resetServiceFilters,
|
||||
openServiceEdit,
|
||||
deleteService,
|
||||
saveServiceEdit,
|
||||
selectProxyDomain,
|
||||
NO_CUSTOM_PORTS_DOMAIN,
|
||||
} from "../helpers/reverse-proxy-l4";
|
||||
|
||||
let udpNetwork = "";
|
||||
let udpResource = "";
|
||||
let udpSubdomain = "";
|
||||
|
||||
test.describe.serial("Reverse Proxy - Services (UDP, no custom ports) @reverse-proxy", () => {
|
||||
test("Should create a network with a resource", async ({ dashboardAsOwner: page }) => {
|
||||
await deleteServicesByPrefix(page, "udp-np-svc-");
|
||||
await deleteNetworksByPrefix(page, "rp-udp-np-net-");
|
||||
await navigateTo(page, "/networks");
|
||||
|
||||
const name = generateRandomName("rp-udp-np-net-");
|
||||
udpNetwork = name;
|
||||
await page.getByTestId("add-network").click();
|
||||
await page.getByTestId("network-name-input").fill(name);
|
||||
await page.getByTestId("submit-network").click();
|
||||
await page.getByTestId("confirmation.confirm").click({ force: true });
|
||||
|
||||
const resName = generateRandomName("rp-resource-");
|
||||
udpResource = resName;
|
||||
await page.getByTestId("resource-name-input").fill(resName);
|
||||
await page.getByTestId("resource-address-input").fill("10.99.99.41");
|
||||
await page.getByTestId("resource-continue").click();
|
||||
|
||||
const responsePromise = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/api/networks/") &&
|
||||
resp.url().includes("/resources") &&
|
||||
resp.request().method() === "POST",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await page.getByTestId("submit-resource").click();
|
||||
await page.getByTestId("confirmation.confirm").click({ force: true });
|
||||
await responsePromise;
|
||||
const cancelBtn = page.getByTestId("confirmation.cancel");
|
||||
if (await cancelBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await cancelBtn.click({ force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Should create a UDP service on the no-custom-ports cluster", async ({ dashboardAsOwner: page }) => {
|
||||
await gotoReverseProxyPage(page, "/reverse-proxy/services");
|
||||
const subdomain = generateRandomName("udp-np-svc-");
|
||||
udpSubdomain = subdomain;
|
||||
|
||||
await page.getByTestId("add-service").first().click();
|
||||
await expect(page.getByTestId("proxy-subdomain-input")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("proxy-subdomain-input").fill(subdomain);
|
||||
|
||||
await selectProxyDomain(page, NO_CUSTOM_PORTS_DOMAIN);
|
||||
|
||||
await page.getByTestId("service-mode-select-button").click({ force: true });
|
||||
await page.getByTestId("service-mode-option-udp").click({ force: true });
|
||||
await expect(page.getByTestId("group-selector-dropdown")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await selectL4Resource(page, udpResource);
|
||||
|
||||
// Listen port is auto-assigned when the cluster has custom ports disabled
|
||||
await expect(page.getByTestId("listen-port-input")).toBeDisabled({ timeout: 10_000 });
|
||||
await expect(page.getByTestId("listen-port-input")).toHaveAttribute("placeholder", "Auto");
|
||||
|
||||
await page.getByTestId("destination-port-input").fill("5060");
|
||||
await page.getByTestId("proxy-continue").click();
|
||||
|
||||
await addAccessControlRules(page);
|
||||
await page.getByTestId("proxy-continue").click();
|
||||
|
||||
await page.getByTestId("connection-timeout-input").fill("30s");
|
||||
await page.getByTestId("submit-service").click();
|
||||
|
||||
await resetServiceFilters(page);
|
||||
const row = page.locator("tr").filter({ hasText: subdomain });
|
||||
await expect(row.getByText("UDP", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row).toContainText(NO_CUSTOM_PORTS_DOMAIN);
|
||||
});
|
||||
|
||||
test("Should edit the UDP service and delete it", async ({ dashboardAsOwner: page }) => {
|
||||
await openServiceEdit(page, udpSubdomain);
|
||||
|
||||
// Listen port must remain auto-assigned on this cluster
|
||||
await expect(page.getByTestId("listen-port-input")).toBeDisabled();
|
||||
|
||||
await page.getByTestId("destination-port-input").fill("5061");
|
||||
|
||||
await page.getByTestId("proxy-tab-access-control").click({ force: true });
|
||||
await removeAllAccessControlRules(page);
|
||||
|
||||
await page.getByTestId("proxy-tab-settings").click({ force: true });
|
||||
await page.getByTestId("connection-timeout-input").fill("");
|
||||
|
||||
await saveServiceEdit(page);
|
||||
|
||||
await resetServiceFilters(page);
|
||||
const row = page.locator("tr").filter({ hasText: udpSubdomain });
|
||||
await expect(row.locator("[data-access-control-cell]")).toContainText("0");
|
||||
|
||||
await deleteService(page, udpSubdomain);
|
||||
});
|
||||
|
||||
test("Should delete the network", async ({ dashboardAsOwner: page }) => {
|
||||
await deleteNetworksByPrefix(page, udpNetwork);
|
||||
});
|
||||
});
|
||||
111
e2e/tests/reverse-proxy-services-udp.spec.ts
Normal file
111
e2e/tests/reverse-proxy-services-udp.spec.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { test, expect } from "../helpers/fixtures";
|
||||
import { navigateTo } from "../helpers/auth";
|
||||
import { generateRandomName } from "../helpers/utils";
|
||||
import { deleteNetworksByPrefix, deleteServicesByPrefix } from "../helpers/api";
|
||||
import {
|
||||
gotoReverseProxyPage,
|
||||
selectL4Resource,
|
||||
addAccessControlRules,
|
||||
removeAllAccessControlRules,
|
||||
resetServiceFilters,
|
||||
openServiceEdit,
|
||||
deleteService,
|
||||
saveServiceEdit,
|
||||
selectProxyDomain,
|
||||
CUSTOM_PORTS_DOMAIN,
|
||||
} from "../helpers/reverse-proxy-l4";
|
||||
|
||||
let udpNetwork = "";
|
||||
let udpResource = "";
|
||||
let udpSubdomain = "";
|
||||
|
||||
test.describe.serial("Reverse Proxy - Services (UDP) @reverse-proxy", () => {
|
||||
test("Should create a network with a resource", async ({ dashboardAsOwner: page }) => {
|
||||
await deleteServicesByPrefix(page, "udp-svc-");
|
||||
await deleteNetworksByPrefix(page, "rp-udp-net-");
|
||||
await navigateTo(page, "/networks");
|
||||
|
||||
const name = generateRandomName("rp-udp-net-");
|
||||
udpNetwork = name;
|
||||
await page.getByTestId("add-network").click();
|
||||
await page.getByTestId("network-name-input").fill(name);
|
||||
await page.getByTestId("submit-network").click();
|
||||
await page.getByTestId("confirmation.confirm").click({ force: true });
|
||||
|
||||
const resName = generateRandomName("rp-resource-");
|
||||
udpResource = resName;
|
||||
await page.getByTestId("resource-name-input").fill(resName);
|
||||
await page.getByTestId("resource-address-input").fill("10.99.99.40");
|
||||
await page.getByTestId("resource-continue").click();
|
||||
|
||||
const responsePromise = page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/api/networks/") &&
|
||||
resp.url().includes("/resources") &&
|
||||
resp.request().method() === "POST",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await page.getByTestId("submit-resource").click();
|
||||
await page.getByTestId("confirmation.confirm").click({ force: true });
|
||||
await responsePromise;
|
||||
const cancelBtn = page.getByTestId("confirmation.cancel");
|
||||
if (await cancelBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await cancelBtn.click({ force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Should create a UDP service", async ({ dashboardAsOwner: page }) => {
|
||||
await gotoReverseProxyPage(page, "/reverse-proxy/services");
|
||||
const subdomain = generateRandomName("udp-svc-");
|
||||
udpSubdomain = subdomain;
|
||||
|
||||
await page.getByTestId("add-service").first().click();
|
||||
await expect(page.getByTestId("proxy-subdomain-input")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("proxy-subdomain-input").fill(subdomain);
|
||||
await selectProxyDomain(page, CUSTOM_PORTS_DOMAIN);
|
||||
await page.getByTestId("service-mode-select-button").click({ force: true });
|
||||
await page.getByTestId("service-mode-option-udp").click({ force: true });
|
||||
// Wait for mode switch to take effect
|
||||
await expect(page.getByTestId("group-selector-dropdown")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await selectL4Resource(page, udpResource);
|
||||
await expect(page.getByTestId("listen-port-input")).toBeEnabled({ timeout: 10_000 });
|
||||
await page.getByTestId("listen-port-input").fill("5060");
|
||||
await page.getByTestId("destination-port-input").fill("5060");
|
||||
await page.getByTestId("proxy-continue").click();
|
||||
|
||||
await addAccessControlRules(page);
|
||||
await page.getByTestId("proxy-continue").click();
|
||||
|
||||
await page.getByTestId("connection-timeout-input").fill("30s");
|
||||
await page.getByTestId("submit-service").click();
|
||||
|
||||
await resetServiceFilters(page);
|
||||
await expect(page.locator("tr").filter({ hasText: subdomain }).getByText("UDP", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
|
||||
test("Should edit the UDP service and delete it", async ({ dashboardAsOwner: page }) => {
|
||||
await openServiceEdit(page, udpSubdomain);
|
||||
|
||||
await page.getByTestId("listen-port-input").fill("5061");
|
||||
await page.getByTestId("destination-port-input").fill("5061");
|
||||
|
||||
await page.getByTestId("proxy-tab-access-control").click({ force: true });
|
||||
await removeAllAccessControlRules(page);
|
||||
|
||||
await page.getByTestId("proxy-tab-settings").click({ force: true });
|
||||
await page.getByTestId("connection-timeout-input").fill("");
|
||||
|
||||
await saveServiceEdit(page);
|
||||
|
||||
await resetServiceFilters(page);
|
||||
const row = page.locator("tr").filter({ hasText: udpSubdomain });
|
||||
await expect(row.locator("[data-access-control-cell]")).toContainText("0");
|
||||
|
||||
await deleteService(page, udpSubdomain);
|
||||
});
|
||||
|
||||
test("Should delete the network", async ({ dashboardAsOwner: page }) => {
|
||||
await deleteNetworksByPrefix(page, udpNetwork);
|
||||
});
|
||||
});
|
||||
80
e2e/tests/settings-authentication.spec.ts
Normal file
80
e2e/tests/settings-authentication.spec.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { test, expect } from "../helpers/fixtures";
|
||||
import { navigateTo } from "../helpers/auth";
|
||||
|
||||
test.describe.serial("Settings - Authentication @settings", () => {
|
||||
test("Should toggle peer approval", async ({ dashboardAsOwner: page }) => {
|
||||
await navigateTo(page, "/settings");
|
||||
await toggleAndSave(page, "peer-approval");
|
||||
});
|
||||
|
||||
test("Should toggle peer login expiration off and back on", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await toggleAndSave(page, "peer-login-expiration");
|
||||
await toggleAndSave(page, "peer-login-expiration");
|
||||
});
|
||||
|
||||
test("Should change peer login expiration time", async ({ dashboardAsOwner: page }) => {
|
||||
await ensureToggleState(page, "peer-login-expiration", "checked");
|
||||
|
||||
// Use a value different from current to ensure the save button enables
|
||||
const currentValue = await page.getByTestId("peer-login-expiration-input").inputValue();
|
||||
const hoursValue = currentValue === "17" ? "22" : "17";
|
||||
|
||||
await page.getByTestId("peer-login-expiration-input").fill(hoursValue);
|
||||
await page.getByTestId("peer-login-expiration-select").click();
|
||||
await page
|
||||
.getByTestId("peer-login-expiration-select-content")
|
||||
.getByText("Hours")
|
||||
.click();
|
||||
await save(page);
|
||||
await expect(page.getByTestId("peer-login-expiration-input")).toHaveValue(hoursValue);
|
||||
|
||||
// Change to a different days value
|
||||
const currentDays = await page.getByTestId("peer-login-expiration-input").inputValue();
|
||||
const daysValue = currentDays === "180" ? "90" : "180";
|
||||
|
||||
await page.getByTestId("peer-login-expiration-input").fill(daysValue);
|
||||
await page.getByTestId("peer-login-expiration-select").click();
|
||||
await page
|
||||
.getByTestId("peer-login-expiration-select-content")
|
||||
.getByText("Days")
|
||||
.click();
|
||||
await save(page);
|
||||
await expect(page.getByTestId("peer-login-expiration-input")).toHaveValue(daysValue);
|
||||
await expect(page.getByTestId("peer-login-expiration-select-value")).toContainText("Days");
|
||||
});
|
||||
|
||||
test("Should toggle peer inactivity expiration", async ({ dashboardAsOwner: page }) => {
|
||||
await toggleAndSave(page, "peer-inactivity-expiration");
|
||||
});
|
||||
});
|
||||
|
||||
async function save(page: import("@playwright/test").Page) {
|
||||
await page.getByTestId("save-authentication-settings").click();
|
||||
await expect(page.getByText("successfully saved").first()).toBeVisible();
|
||||
}
|
||||
|
||||
async function toggleAndSave(
|
||||
page: import("@playwright/test").Page,
|
||||
name: string,
|
||||
) {
|
||||
const toggle = page.getByTestId(name);
|
||||
const initialState = await toggle.getAttribute("data-state");
|
||||
const expectedState = initialState === "checked" ? "unchecked" : "checked";
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("data-state", expectedState);
|
||||
await save(page);
|
||||
}
|
||||
|
||||
async function ensureToggleState(
|
||||
page: import("@playwright/test").Page,
|
||||
name: string,
|
||||
desiredState: "checked" | "unchecked",
|
||||
) {
|
||||
const toggle = page.getByTestId(name);
|
||||
const currentState = await toggle.getAttribute("data-state");
|
||||
if (currentState !== desiredState) {
|
||||
await toggle.click();
|
||||
}
|
||||
}
|
||||
124
e2e/tests/settings-clients.spec.ts
Normal file
124
e2e/tests/settings-clients.spec.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { test, expect } from "../helpers/fixtures";
|
||||
import { navigateTo } from "../helpers/auth";
|
||||
import { generateRandomName } from "../helpers/utils";
|
||||
import { deleteGroupsByPrefix } from "../helpers/api";
|
||||
|
||||
let peerExposeGroup = "";
|
||||
|
||||
test.describe.serial("Settings - Clients @settings", () => {
|
||||
test("Should set automatic updates to Latest Version with force updates", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await navigateTo(page, "/settings?tab=clients");
|
||||
|
||||
// Ensure we start from Disabled so the change to Latest Version is always detected
|
||||
const currentMethod = await page.getByTestId("auto-update-method").textContent();
|
||||
if (currentMethod?.includes("Latest")) {
|
||||
await selectAutoUpdateMethod(page, "Disabled");
|
||||
await save(page);
|
||||
}
|
||||
|
||||
await selectAutoUpdateMethod(page, "Latest Version");
|
||||
const forceToggle = page.getByTestId("force-auto-updates");
|
||||
if ((await forceToggle.getAttribute("data-state")) !== "checked") {
|
||||
await forceToggle.click();
|
||||
}
|
||||
await expect(forceToggle).toHaveAttribute("data-state", "checked");
|
||||
await save(page);
|
||||
});
|
||||
|
||||
test("Should switch to Custom Version and disable force updates", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await page.getByTestId("force-auto-updates").click();
|
||||
await expect(page.getByTestId("force-auto-updates")).toHaveAttribute("data-state", "unchecked");
|
||||
|
||||
await selectAutoUpdateMethod(page, "Custom Version");
|
||||
await page.getByTestId("auto-update-version-input").fill("0.5");
|
||||
await save(page);
|
||||
});
|
||||
|
||||
test("Should set automatic updates back to Disabled", async ({ dashboardAsOwner: page }) => {
|
||||
await selectAutoUpdateMethod(page, "Disabled");
|
||||
await save(page);
|
||||
await expect(page.getByTestId("auto-update-version-input")).toBeDisabled();
|
||||
});
|
||||
|
||||
test("Should enable peer expose with a group", async ({ dashboardAsOwner: page }) => {
|
||||
// Ensure peer expose starts disabled for a clean test
|
||||
const toggle = page.getByTestId("peer-expose");
|
||||
if ((await toggle.getAttribute("data-state")) === "checked") {
|
||||
// Remove any existing groups first
|
||||
const badges = page.getByTestId("group-badge");
|
||||
const count = await badges.count();
|
||||
for (let i = 0; i < count; i++) {
|
||||
await badges.first().click();
|
||||
}
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("data-state", "unchecked");
|
||||
await save(page);
|
||||
}
|
||||
|
||||
// Now enable and add group
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("data-state", "checked");
|
||||
|
||||
const name = generateRandomName("expose-group-");
|
||||
peerExposeGroup = name;
|
||||
await page.getByTestId("peer-expose-groups-selector").click();
|
||||
const search = page.getByTestId("peer-expose-groups-selector-search");
|
||||
await search.fill(name);
|
||||
await search.press("Enter");
|
||||
await search.press("Escape");
|
||||
await save(page);
|
||||
});
|
||||
|
||||
test("Should remove the group and disable peer expose", async ({ dashboardAsOwner: page }) => {
|
||||
const toggle = page.getByTestId("peer-expose");
|
||||
|
||||
// Remove the group badge if it exists
|
||||
const badge = page.getByTestId("group-badge").filter({ hasText: peerExposeGroup });
|
||||
if (await badge.first().isVisible().catch(() => false)) {
|
||||
await badge.first().click();
|
||||
await expect(badge).not.toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
// Disable peer expose if enabled
|
||||
if ((await toggle.getAttribute("data-state")) === "checked") {
|
||||
await toggle.click();
|
||||
}
|
||||
await expect(toggle).toHaveAttribute("data-state", "unchecked");
|
||||
await save(page);
|
||||
|
||||
// Verify peer expose persisted after save
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("peer-expose")).toHaveAttribute("data-state", "unchecked", { timeout: 10_000 });
|
||||
await expect(page.getByTestId("peer-expose")).toHaveAttribute("data-state", "unchecked");
|
||||
});
|
||||
|
||||
test("Should toggle lazy connections on and off", async ({ dashboardAsOwner: page }) => {
|
||||
const toggle = page.getByTestId("lazy-connections");
|
||||
await toggle.click();
|
||||
await expect(page.getByText("successfully").first()).toBeVisible();
|
||||
await toggle.click();
|
||||
await expect(page.getByText("successfully").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("Should delete the created group", async ({ dashboardAsOwner: page }) => {
|
||||
if (!peerExposeGroup) return;
|
||||
await deleteGroupsByPrefix(page, peerExposeGroup);
|
||||
});
|
||||
});
|
||||
|
||||
async function selectAutoUpdateMethod(
|
||||
page: import("@playwright/test").Page,
|
||||
label: string,
|
||||
) {
|
||||
await page.getByTestId("auto-update-method").click({ force: true });
|
||||
await page.locator("[cmdk-list]").getByText(label).click();
|
||||
}
|
||||
|
||||
async function save(page: import("@playwright/test").Page) {
|
||||
await page.getByTestId("save-clients-settings").click();
|
||||
await expect(page.getByText("successfully updated").first()).toBeVisible();
|
||||
}
|
||||
24
e2e/tests/settings-groups.spec.ts
Normal file
24
e2e/tests/settings-groups.spec.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { test, expect } from "../helpers/fixtures";
|
||||
import { navigateTo } from "../helpers/auth";
|
||||
|
||||
test.describe.serial("Settings - Groups @settings", () => {
|
||||
test("Should toggle user group propagation", async ({ dashboardAsOwner: page }) => {
|
||||
await navigateTo(page, "/settings?tab=groups");
|
||||
|
||||
const toggle = page.getByTestId("user-group-propagation");
|
||||
const initialState = await toggle.getAttribute("data-state");
|
||||
const expectedState = initialState === "checked" ? "unchecked" : "checked";
|
||||
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("data-state", expectedState);
|
||||
|
||||
await page.getByTestId("save-groups-settings").click();
|
||||
await expect(page.getByText("updated successfully").first()).toBeVisible();
|
||||
await expect(toggle).toHaveAttribute("data-state", expectedState);
|
||||
|
||||
// Toggle back to restore original state
|
||||
await page.getByTestId("user-group-propagation").click();
|
||||
await page.getByTestId("save-groups-settings").click();
|
||||
await expect(page.getByText("updated successfully").first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
240
e2e/tests/settings-networks.spec.ts
Normal file
240
e2e/tests/settings-networks.spec.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { test, expect } from "../helpers/fixtures";
|
||||
import { navigateTo } from "../helpers/auth";
|
||||
import { generateRandomName } from "../helpers/utils";
|
||||
import { deleteGroupsByPrefix } from "../helpers/api";
|
||||
|
||||
let trafficGroup = "";
|
||||
let ipv6Group = "";
|
||||
|
||||
test.describe.serial("Settings - Networks @settings", () => {
|
||||
test("Should update DNS domain and network range", async ({ dashboardAsOwner: page }) => {
|
||||
await navigateTo(page, "/settings?tab=networks");
|
||||
|
||||
const origDomain = await page.getByTestId("dns-domain-input").inputValue();
|
||||
const origRange = await page.getByTestId("network-range-input").inputValue();
|
||||
|
||||
// Use values guaranteed to differ from current
|
||||
const testDomain = origDomain === "test.internal" ? "test2.internal" : "test.internal";
|
||||
const testRange = origRange === "10.100.0.0/16" ? "10.200.0.0/16" : "10.100.0.0/16";
|
||||
|
||||
await page.getByTestId("dns-domain-input").fill(testDomain);
|
||||
await page.getByTestId("network-range-input").fill(testRange);
|
||||
await page.getByTestId("save-network-settings").click();
|
||||
await expect(page.getByText("successfully updated").first()).toBeVisible();
|
||||
|
||||
// Verify UI shows new values
|
||||
await expect(page.getByTestId("dns-domain-input")).toHaveValue(testDomain);
|
||||
await expect(page.getByTestId("network-range-input")).toHaveValue(testRange);
|
||||
|
||||
// Revert
|
||||
await page.getByTestId("dns-domain-input").fill(origDomain || "netbird.selfhosted");
|
||||
await page.getByTestId("network-range-input").fill(origRange || "100.64.0.0/10");
|
||||
await page.getByTestId("save-network-settings").click();
|
||||
await expect(page.getByText("successfully updated").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("Should toggle DNS wildcard routing", async ({ dashboardAsOwner: page }) => {
|
||||
await toggleAndRevert(page, "dns-wildcard-routing");
|
||||
});
|
||||
|
||||
test("Should toggle traffic events", async ({ dashboardAsOwner: page }) => {
|
||||
await toggleAndRevert(page, "traffic-events");
|
||||
});
|
||||
|
||||
test("Should toggle traffic reporting kernel", async ({ dashboardAsOwner: page }) => {
|
||||
await ensureToggleState(page, "traffic-events", "checked");
|
||||
|
||||
const toggle = page.getByTestId("traffic-reporting-kernel");
|
||||
await expect(toggle).toBeVisible();
|
||||
|
||||
// Dispatch click via JS to bypass pointer-events interception from parent layout
|
||||
await toggle.dispatchEvent("click");
|
||||
|
||||
// Confirmation dialog only appears when turning ON
|
||||
const confirmBtn = page.getByTestId("confirmation.confirm");
|
||||
if (await confirmBtn.isVisible({ timeout: 2_000 }).catch(() => false)) {
|
||||
await confirmBtn.click({ force: true });
|
||||
}
|
||||
await expect(page.getByText("successfully").first()).toBeVisible();
|
||||
|
||||
// Toggle back
|
||||
await page.getByTestId("traffic-reporting-kernel").dispatchEvent("click");
|
||||
if (await confirmBtn.isVisible({ timeout: 2_000 }).catch(() => false)) {
|
||||
await confirmBtn.click({ force: true });
|
||||
}
|
||||
await expect(page.getByText("successfully").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("Should add a group to traffic events and save", async ({ dashboardAsOwner: page }) => {
|
||||
// Clean up stale groups from previous runs
|
||||
await deleteGroupsByPrefix(page, "traffic-group-");
|
||||
await navigateTo(page, "/settings?tab=networks");
|
||||
|
||||
await ensureToggleState(page, "traffic-events", "checked");
|
||||
|
||||
// Scope to the traffic-events selector so we don't accidentally remove
|
||||
// badges from other group selectors on the same page (e.g. IPv6 groups).
|
||||
const trafficSelector = page.getByTestId("traffic-events-groups-selector");
|
||||
const existingBadges = trafficSelector.getByTestId("group-badge");
|
||||
const badgeCount = await existingBadges.count();
|
||||
for (let i = 0; i < badgeCount; i++) {
|
||||
await existingBadges.first().click({ force: true });
|
||||
}
|
||||
if (badgeCount > 0) {
|
||||
await page.getByTestId("save-traffic-groups").click({ force: true });
|
||||
await expect(page.getByText("successfully updated").first()).toBeVisible();
|
||||
}
|
||||
|
||||
const name = generateRandomName("traffic-group-");
|
||||
trafficGroup = name;
|
||||
|
||||
await page.getByTestId("traffic-events-groups-selector-open-close").click({ force: true });
|
||||
const search = page.getByTestId("traffic-events-groups-selector-search");
|
||||
await expect(search).toBeVisible({ timeout: 5_000 });
|
||||
await search.fill(name);
|
||||
await search.press("Enter");
|
||||
if (await search.isVisible().catch(() => false)) {
|
||||
await search.press("Escape");
|
||||
}
|
||||
|
||||
await page.getByTestId("save-traffic-groups").click({ force: true });
|
||||
await expect(page.getByText("successfully updated").first()).toBeVisible();
|
||||
|
||||
// Verify group is visible in UI within the traffic selector
|
||||
await expect(trafficSelector.getByText(name).first()).toBeVisible();
|
||||
|
||||
// Remove the group (force needed due to parent pointer-events interception)
|
||||
await trafficSelector.getByTestId("group-badge").filter({ hasText: name }).click({ force: true });
|
||||
await page.getByTestId("save-traffic-groups").click({ force: true });
|
||||
await expect(page.getByText("successfully updated").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("Should delete the created traffic group", async ({ dashboardAsOwner: page }) => {
|
||||
await deleteGroupsByPrefix(page, trafficGroup);
|
||||
});
|
||||
|
||||
test("Should update the IPv6 network range", async ({ dashboardAsOwner: page }) => {
|
||||
await navigateTo(page, "/settings?tab=networks");
|
||||
|
||||
const input = page.getByTestId("network-range-v6-input");
|
||||
await expect(input).toBeVisible();
|
||||
const origRange = await input.inputValue();
|
||||
|
||||
// Pick a value guaranteed to differ from the current one
|
||||
const testRange =
|
||||
origRange === "fd00:1234::/64" ? "fd00:5678::/64" : "fd00:1234::/64";
|
||||
|
||||
await input.fill(testRange);
|
||||
await page.getByTestId("save-network-settings").click();
|
||||
await expect(page.getByText("successfully updated").first()).toBeVisible();
|
||||
await expect(input).toHaveValue(testRange);
|
||||
|
||||
// Revert
|
||||
await input.fill(origRange);
|
||||
await page.getByTestId("save-network-settings").click();
|
||||
await expect(page.getByText("successfully updated").first()).toBeVisible();
|
||||
await expect(input).toHaveValue(origRange);
|
||||
});
|
||||
|
||||
test("Should reject an invalid IPv6 network range", async ({ dashboardAsOwner: page }) => {
|
||||
await navigateTo(page, "/settings?tab=networks");
|
||||
|
||||
const input = page.getByTestId("network-range-v6-input");
|
||||
const origRange = await input.inputValue();
|
||||
|
||||
// Prefix length outside the allowed /48..../112 window
|
||||
await input.fill("fd00:1234::/32");
|
||||
await expect(page.getByTestId("save-network-settings")).toBeDisabled();
|
||||
|
||||
// Non-IPv6 string
|
||||
await input.fill("not-an-ip");
|
||||
await expect(page.getByTestId("save-network-settings")).toBeDisabled();
|
||||
|
||||
// Restore so subsequent tests start from a clean state
|
||||
await input.fill(origRange);
|
||||
});
|
||||
|
||||
test("Should add and remove a group from IPv6 enabled groups", async ({ dashboardAsOwner: page }) => {
|
||||
await deleteGroupsByPrefix(page, "ipv6-group-");
|
||||
await navigateTo(page, "/settings?tab=networks");
|
||||
|
||||
const ipv6Selector = page.getByTestId("ipv6-enabled-groups-selector");
|
||||
await expect(ipv6Selector).toBeVisible();
|
||||
|
||||
// Start from a clean slate: remove any existing badges scoped to this selector
|
||||
const existingBadges = ipv6Selector.getByTestId("group-badge");
|
||||
const badgeCount = await existingBadges.count();
|
||||
for (let i = 0; i < badgeCount; i++) {
|
||||
await existingBadges.first().click({ force: true });
|
||||
}
|
||||
if (badgeCount > 0) {
|
||||
await page.getByTestId("save-network-settings").click();
|
||||
await expect(page.getByText("successfully updated").first()).toBeVisible();
|
||||
}
|
||||
|
||||
const name = generateRandomName("ipv6-group-");
|
||||
ipv6Group = name;
|
||||
|
||||
await page.getByTestId("ipv6-enabled-groups-selector-open-close").click({ force: true });
|
||||
const search = page.getByTestId("ipv6-enabled-groups-selector-search");
|
||||
await expect(search).toBeVisible({ timeout: 5_000 });
|
||||
await search.fill(name);
|
||||
await search.press("Enter");
|
||||
if (await search.isVisible().catch(() => false)) {
|
||||
await search.press("Escape");
|
||||
}
|
||||
|
||||
await page.getByTestId("save-network-settings").click();
|
||||
await expect(page.getByText("successfully updated").first()).toBeVisible();
|
||||
|
||||
// Verify the new group appears as a badge in the IPv6 selector
|
||||
await expect(
|
||||
ipv6Selector.getByTestId("group-badge").filter({ hasText: name }),
|
||||
).toBeVisible();
|
||||
|
||||
// Remove the group via the badge and save again
|
||||
await ipv6Selector
|
||||
.getByTestId("group-badge")
|
||||
.filter({ hasText: name })
|
||||
.click({ force: true });
|
||||
await page.getByTestId("save-network-settings").click();
|
||||
await expect(page.getByText("successfully updated").first()).toBeVisible();
|
||||
await expect(
|
||||
ipv6Selector.getByTestId("group-badge").filter({ hasText: name }),
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("Should delete the created IPv6 group", async ({ dashboardAsOwner: page }) => {
|
||||
await deleteGroupsByPrefix(page, ipv6Group);
|
||||
});
|
||||
});
|
||||
|
||||
async function toggleAndRevert(
|
||||
page: import("@playwright/test").Page,
|
||||
name: string,
|
||||
) {
|
||||
const toggle = page.getByTestId(name);
|
||||
const initialState = await toggle.getAttribute("data-state");
|
||||
const expectedState = initialState === "checked" ? "unchecked" : "checked";
|
||||
|
||||
await toggle.click();
|
||||
await expect(page.getByText("successfully").first()).toBeVisible();
|
||||
await expect(toggle).toHaveAttribute("data-state", expectedState);
|
||||
|
||||
// Toggle back
|
||||
await toggle.click();
|
||||
await expect(page.getByText("successfully").first()).toBeVisible();
|
||||
}
|
||||
|
||||
async function ensureToggleState(
|
||||
page: import("@playwright/test").Page,
|
||||
name: string,
|
||||
desiredState: "checked" | "unchecked",
|
||||
) {
|
||||
const toggle = page.getByTestId(name);
|
||||
const currentState = await toggle.getAttribute("data-state");
|
||||
if (currentState !== desiredState) {
|
||||
await toggle.click();
|
||||
await expect(page.getByText("successfully").first()).toBeVisible();
|
||||
}
|
||||
}
|
||||
75
e2e/tests/settings-notifications-email.spec.ts
Normal file
75
e2e/tests/settings-notifications-email.spec.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { test, expect } from "../helpers/fixtures";
|
||||
import { navigateTo } from "../helpers/auth";
|
||||
import { deleteNotificationChannelsByType } from "../helpers/api";
|
||||
|
||||
const TEST_EMAIL = "notify@example.test";
|
||||
|
||||
test.describe.serial("Settings - Notifications - Email @notifications", () => {
|
||||
test("Should add an email recipient", async ({ dashboardAsOwner: page }) => {
|
||||
await deleteNotificationChannelsByType(page, "email");
|
||||
await navigateTo(page, "/settings?tab=notifications");
|
||||
await expect(page.getByTestId("notification-channel-email")).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByTestId("notification-channel-email").click();
|
||||
await expect(page.getByTestId("notification-email-input")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.getByTestId("notification-email-input").fill(TEST_EMAIL);
|
||||
await page.getByTestId("notification-email-add").click();
|
||||
await expect(
|
||||
page.getByTestId("notification-email-recipient").filter({ hasText: TEST_EMAIL }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("Should toggle email channel enabled and verify on overview", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
const toggle = page.locator('[data-testid="notification-email-enabled"]');
|
||||
if ((await toggle.getAttribute("data-state")) !== "checked") {
|
||||
await toggle.click();
|
||||
}
|
||||
await expect(toggle).toHaveAttribute("data-state", "checked");
|
||||
|
||||
await backToOverview(page);
|
||||
await expect(page.getByTestId("notification-channel-email")).toContainText("Enabled");
|
||||
|
||||
await page.getByTestId("notification-channel-email").click();
|
||||
await page.locator('[data-testid="notification-email-enabled"]').click();
|
||||
await backToOverview(page);
|
||||
await expect(page.getByTestId("notification-channel-email")).toContainText("Disabled");
|
||||
});
|
||||
|
||||
test("Should toggle a notification event", async ({ dashboardAsOwner: page }) => {
|
||||
await page.getByTestId("notification-channel-email").click();
|
||||
const toggle = page.getByTestId("notification-event-peer.pending.approval");
|
||||
const initial = await toggle.getAttribute("data-state");
|
||||
const expected = initial === "checked" ? "unchecked" : "checked";
|
||||
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("data-state", expected);
|
||||
|
||||
// Toggle back to restore
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("data-state", initial!);
|
||||
});
|
||||
|
||||
test("Should remove the email recipient and leave channel disabled", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await page
|
||||
.getByTestId("notification-email-recipient")
|
||||
.filter({ hasText: TEST_EMAIL })
|
||||
.click({ force: true });
|
||||
await expect(
|
||||
page.getByTestId("notification-email-recipient").filter({ hasText: TEST_EMAIL }),
|
||||
).not.toBeVisible();
|
||||
|
||||
const toggle = page.locator('[data-testid="notification-email-enabled"]');
|
||||
if ((await toggle.getAttribute("data-state")) === "checked") {
|
||||
await toggle.click();
|
||||
}
|
||||
await expect(toggle).toHaveAttribute("data-state", "unchecked");
|
||||
});
|
||||
});
|
||||
|
||||
async function backToOverview(page: import("@playwright/test").Page) {
|
||||
await page.getByTestId("breadcrumb-item").filter({ hasText: "Notifications" }).click();
|
||||
}
|
||||
55
e2e/tests/settings-notifications-slack.spec.ts
Normal file
55
e2e/tests/settings-notifications-slack.spec.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { test, expect } from "../helpers/fixtures";
|
||||
import { navigateTo } from "../helpers/auth";
|
||||
import { deleteNotificationChannelsByType } from "../helpers/api";
|
||||
|
||||
test.describe.serial("Settings - Notifications - Slack @notifications", () => {
|
||||
test("Should connect Slack through the 2-step wizard", async ({ dashboardAsOwner: page }) => {
|
||||
await deleteNotificationChannelsByType(page, "slack");
|
||||
await navigateTo(page, "/settings?tab=notifications");
|
||||
await expect(page.getByTestId("notification-channel-slack")).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByTestId("notification-channel-slack").click();
|
||||
await expect(page.getByTestId("slack-channel-connect")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.getByTestId("slack-channel-connect").click();
|
||||
await expect(page.getByText("Create a Slack App")).toBeVisible();
|
||||
await page.getByTestId("slack-continue").click({ force: true });
|
||||
await expect(page.getByText("Configure Incoming Webhook")).toBeVisible();
|
||||
await page.getByTestId("slack-webhook-url-input").fill("https://hooks.slack.com/services/T000/B000/XXXX");
|
||||
await page.getByTestId("slack-connect").click();
|
||||
await expect(page.getByTestId("slack-actions")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Should show Enabled on overview", async ({ dashboardAsOwner: page }) => {
|
||||
await backToOverview(page);
|
||||
await expect(page.getByTestId("notification-channel-slack")).toContainText("Enabled");
|
||||
});
|
||||
|
||||
test("Should toggle a notification event", async ({ dashboardAsOwner: page }) => {
|
||||
await page.getByTestId("notification-channel-slack").click();
|
||||
const toggle = page.getByTestId("notification-event-peer.pending.approval");
|
||||
const initial = await toggle.getAttribute("data-state");
|
||||
const expected = initial === "checked" ? "unchecked" : "checked";
|
||||
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("data-state", expected);
|
||||
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("data-state", initial!);
|
||||
});
|
||||
|
||||
test("Should disconnect Slack and show Disabled on overview", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await page.getByTestId("slack-actions").click({ force: true });
|
||||
await page.getByTestId("slack-disconnect").click({ force: true });
|
||||
await page.getByTestId("confirmation.confirm").click({ force: true });
|
||||
await expect(page.getByTestId("slack-channel-connect")).toBeVisible();
|
||||
|
||||
await backToOverview(page);
|
||||
await expect(page.getByTestId("notification-channel-slack")).toContainText("Disabled");
|
||||
});
|
||||
});
|
||||
|
||||
async function backToOverview(page: import("@playwright/test").Page) {
|
||||
await page.getByTestId("breadcrumb-item").filter({ hasText: "Notifications" }).click();
|
||||
}
|
||||
130
e2e/tests/settings-notifications-webhook.spec.ts
Normal file
130
e2e/tests/settings-notifications-webhook.spec.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { test, expect } from "../helpers/fixtures";
|
||||
import { navigateTo } from "../helpers/auth";
|
||||
import { deleteNotificationChannelsByType } from "../helpers/api";
|
||||
|
||||
test.describe.serial("Settings - Notifications - Webhook @notifications", () => {
|
||||
test("Should connect a webhook with no authentication", async ({ dashboardAsOwner: page }) => {
|
||||
await deleteNotificationChannelsByType(page, "webhook");
|
||||
await navigateTo(page, "/settings?tab=notifications");
|
||||
await expect(page.getByTestId("notification-channel-webhook")).toBeVisible({ timeout: 15_000 });
|
||||
await page.getByTestId("notification-channel-webhook").click();
|
||||
await expect(page.getByTestId("webhook-connect")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.getByTestId("webhook-connect").click();
|
||||
await page.getByTestId("webhook-url-input").fill("https://webhook.example/test");
|
||||
await expect(page.getByTestId("webhook-auth-type")).toContainText("No Authentication");
|
||||
await page.getByTestId("webhook-continue").click();
|
||||
await page.getByTestId("webhook-save").click();
|
||||
await expect(page.getByTestId("webhook-actions")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Should toggle a notification event", async ({ dashboardAsOwner: page }) => {
|
||||
const toggle = page.getByTestId("notification-event-peer.pending.approval");
|
||||
const initial = await toggle.getAttribute("data-state");
|
||||
const expected = initial === "checked" ? "unchecked" : "checked";
|
||||
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("data-state", expected);
|
||||
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("data-state", initial!);
|
||||
});
|
||||
|
||||
test("Should edit webhook and cycle through auth types", async ({ dashboardAsOwner: page }) => {
|
||||
// Basic Auth
|
||||
await openWebhookEdit(page);
|
||||
await selectWebhookAuth(page, "Basic Auth");
|
||||
await page.getByTestId("webhook-basic-username").fill("admin");
|
||||
await page.getByTestId("webhook-basic-password").fill("password");
|
||||
await page.getByTestId("webhook-save").click();
|
||||
|
||||
// Bearer Token
|
||||
await openWebhookEdit(page);
|
||||
await selectWebhookAuth(page, "Bearer Token");
|
||||
await page.getByTestId("webhook-bearer-token").fill("my-bearer-token");
|
||||
await page.getByTestId("webhook-save").click();
|
||||
|
||||
// Custom Auth
|
||||
await openWebhookEdit(page);
|
||||
await selectWebhookAuth(page, "Custom Authentication");
|
||||
await page.getByTestId("webhook-custom-auth-name").fill("X-API-Key");
|
||||
await page.getByTestId("webhook-custom-auth-value").fill("secret-api-key");
|
||||
await page.getByTestId("webhook-save").click();
|
||||
});
|
||||
|
||||
test("Should manage custom headers", async ({ dashboardAsOwner: page }) => {
|
||||
await page.reload();
|
||||
// Ensure webhook exists (previous test may have failed)
|
||||
if (await page.getByTestId("webhook-connect").isVisible().catch(() => false)) {
|
||||
await page.getByTestId("webhook-connect").click();
|
||||
await page.getByTestId("webhook-url-input").fill("https://webhook.example/test");
|
||||
await page.getByTestId("webhook-continue").click();
|
||||
await page.getByTestId("webhook-save").click();
|
||||
await expect(page.getByTestId("webhook-actions")).toBeVisible();
|
||||
}
|
||||
await openWebhookEdit(page);
|
||||
await page.getByTestId("webhook-tab-headers").click({ force: true });
|
||||
|
||||
// Remove existing headers
|
||||
const removeButtons = page.getByTestId("webhook-header-remove");
|
||||
const count = await removeButtons.count();
|
||||
for (let i = 0; i < count; i++) {
|
||||
await page.getByTestId("webhook-header-remove").first().click({ force: true });
|
||||
}
|
||||
|
||||
// Add new header
|
||||
await page.getByTestId("webhook-add-header").click({ force: true });
|
||||
await page.getByTestId("webhook-header-name").last().fill("X-Custom-Header");
|
||||
await page.getByTestId("webhook-header-value").last().fill("my-custom-value");
|
||||
await page.getByTestId("webhook-save").click();
|
||||
|
||||
// Verify persistence
|
||||
await page.reload();
|
||||
await openWebhookEdit(page);
|
||||
await page.getByTestId("webhook-tab-headers").click({ force: true });
|
||||
// Verify the custom header exists (there may be auth headers with the same testid)
|
||||
const headerNames = page.getByTestId("webhook-header-name");
|
||||
const headerCount = await headerNames.count();
|
||||
let found = false;
|
||||
for (let i = 0; i < headerCount; i++) {
|
||||
if ((await headerNames.nth(i).inputValue()) === "X-Custom-Header") {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
expect(found).toBe(true);
|
||||
await page.getByRole("button", { name: "Cancel" }).click({ force: true });
|
||||
});
|
||||
|
||||
test("Should delete the webhook", async ({ dashboardAsOwner: page }) => {
|
||||
await page.reload();
|
||||
await page.getByTestId("webhook-actions").click({ force: true });
|
||||
await page.getByTestId("webhook-delete").click({ force: true });
|
||||
await page.getByTestId("confirmation.confirm").click({ force: true });
|
||||
await expect(page.getByTestId("webhook-connect")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
async function openWebhookEdit(page: import("@playwright/test").Page) {
|
||||
await expect(page.getByTestId("webhook-actions")).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("webhook-actions").click({ force: true });
|
||||
await expect(page.getByTestId("webhook-edit")).toBeVisible({ timeout: 5_000 });
|
||||
await page.getByTestId("webhook-edit").click({ force: true });
|
||||
}
|
||||
|
||||
async function selectWebhookAuth(page: import("@playwright/test").Page, label: string) {
|
||||
await page.getByTestId("webhook-auth-type").click();
|
||||
await page.locator("[cmdk-list]").getByText(label).click();
|
||||
}
|
||||
|
||||
async function ensureWebhookDisconnected(page: import("@playwright/test").Page) {
|
||||
await expect(
|
||||
page.getByTestId("webhook-connect").or(page.getByTestId("webhook-actions")),
|
||||
).toBeVisible();
|
||||
if (await page.getByTestId("webhook-actions").isVisible().catch(() => false)) {
|
||||
await page.getByTestId("webhook-actions").click({ force: true });
|
||||
await page.getByTestId("webhook-delete").click({ force: true });
|
||||
await page.getByTestId("confirmation.confirm").click({ force: true });
|
||||
await expect(page.getByTestId("webhook-connect")).toBeVisible();
|
||||
}
|
||||
}
|
||||
41
e2e/tests/settings-permissions.spec.ts
Normal file
41
e2e/tests/settings-permissions.spec.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { test, expect } from "../helpers/fixtures";
|
||||
import { navigateTo } from "../helpers/auth";
|
||||
|
||||
test.describe.serial("Settings - Permissions @settings", () => {
|
||||
test("Should toggle restrict dashboard for regular users", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await navigateTo(page, "/settings?tab=permissions");
|
||||
|
||||
const toggle = page.getByTestId("restrict-regular-users");
|
||||
await expect(toggle).toBeVisible({ timeout: 15_000 });
|
||||
const initialState = await toggle.getAttribute("data-state");
|
||||
const expectedState = initialState === "checked" ? "unchecked" : "checked";
|
||||
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("data-state", expectedState);
|
||||
|
||||
await page.getByTestId("save-permissions-settings").click();
|
||||
await expect(page.getByText("updated successfully").first()).toBeVisible();
|
||||
|
||||
// Verify persistence — wait for settings API to load after reload
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(resp) =>
|
||||
resp.url().includes("/api/accounts") &&
|
||||
resp.request().method() === "GET",
|
||||
),
|
||||
page.reload(),
|
||||
]);
|
||||
await expect(page.getByTestId("restrict-regular-users")).toHaveAttribute(
|
||||
"data-state",
|
||||
expectedState,
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
|
||||
// Toggle back to restore original state
|
||||
await page.getByTestId("restrict-regular-users").click();
|
||||
await page.getByTestId("save-permissions-settings").click();
|
||||
await expect(page.getByText("updated successfully").first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
161
e2e/tests/setup-keys.spec.ts
Normal file
161
e2e/tests/setup-keys.spec.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { test, expect } from "../helpers/fixtures";
|
||||
import { navigateTo } from "../helpers/auth";
|
||||
import { generateRandomName } from "../helpers/utils";
|
||||
import { deleteGroupsByPrefix, deleteSetupKeysByPrefix } from "../helpers/api";
|
||||
|
||||
let setupKeys: string[] = [];
|
||||
let setupKeysCreatedGroups: string[] = [];
|
||||
|
||||
test.describe.serial("Setup Keys @setup-keys", () => {
|
||||
test("Should create a simple setup key", async ({ dashboardAsOwner: page }) => {
|
||||
// Clean up leftovers from previous runs
|
||||
await deleteSetupKeysByPrefix(page, "setup-key");
|
||||
await deleteGroupsByPrefix(page, "sk-group-");
|
||||
await navigateTo(page, "/setup-keys");
|
||||
const name = generateRandomName("setup-key");
|
||||
await createSetupKey(page, { name });
|
||||
setupKeys.push(name);
|
||||
});
|
||||
|
||||
test("Should create a reusable setup key", async ({ dashboardAsOwner: page }) => {
|
||||
const name = generateRandomName("setup-key");
|
||||
await createSetupKey(page, { name, reusable: true });
|
||||
setupKeys.push(name);
|
||||
});
|
||||
|
||||
test("Should create a setup key with all options", async ({ dashboardAsOwner: page }) => {
|
||||
const group1 = generateRandomName("sk-group-");
|
||||
const group2 = generateRandomName("sk-group-");
|
||||
setupKeysCreatedGroups.push(group1, group2);
|
||||
|
||||
const name = generateRandomName("setup-key");
|
||||
await createSetupKey(page, {
|
||||
name,
|
||||
reusable: true,
|
||||
usageLimit: "100",
|
||||
expiration: "365",
|
||||
ephemeral: true,
|
||||
groups: [group1, group2],
|
||||
});
|
||||
setupKeys.push(name);
|
||||
});
|
||||
|
||||
test("Should revoke setup keys", async ({ dashboardAsOwner: page }) => {
|
||||
for (const name of setupKeys) {
|
||||
await revokeSetupKey(page, name);
|
||||
}
|
||||
});
|
||||
|
||||
test("Should delete setup keys", async ({ dashboardAsOwner: page }) => {
|
||||
for (const name of setupKeys) {
|
||||
await deleteSetupKey(page, name);
|
||||
}
|
||||
});
|
||||
|
||||
test("Should delete created groups", async ({ dashboardAsOwner: page }) => {
|
||||
for (const prefix of setupKeysCreatedGroups) {
|
||||
await deleteGroupsByPrefix(page, prefix);
|
||||
}
|
||||
setupKeysCreatedGroups = [];
|
||||
});
|
||||
});
|
||||
|
||||
async function createSetupKey(
|
||||
page: import("@playwright/test").Page,
|
||||
opts: {
|
||||
name: string;
|
||||
reusable?: boolean;
|
||||
usageLimit?: string;
|
||||
expiration?: string;
|
||||
ephemeral?: boolean;
|
||||
groups?: string[];
|
||||
},
|
||||
) {
|
||||
await page.getByTestId("open-create-setup-key").click();
|
||||
await page.getByTestId("setup-key-name").fill(opts.name);
|
||||
|
||||
if (opts.reusable) {
|
||||
await page.getByText("Make this key reusable").click();
|
||||
if (opts.usageLimit) {
|
||||
await page.getByTestId("setup-key-usage-limit").fill(opts.usageLimit);
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.expiration) {
|
||||
await page.getByTestId("setup-key-expire-in-days").fill(opts.expiration);
|
||||
}
|
||||
|
||||
if (opts.ephemeral) {
|
||||
await page.getByText("Ephemeral Peers").click();
|
||||
}
|
||||
|
||||
if (opts.groups && opts.groups.length > 0) {
|
||||
await page.getByTestId("group-selector-dropdown").click();
|
||||
for (const group of opts.groups) {
|
||||
const search = page.getByTestId("group-selector-dropdown-search");
|
||||
await expect(search).toBeVisible();
|
||||
await search.fill(group);
|
||||
await search.press("Enter");
|
||||
}
|
||||
await page.getByTestId("group-selector-dropdown-search").press("Escape");
|
||||
await expect(
|
||||
page.getByTestId("group-selector-dropdown-search"),
|
||||
).not.toBeVisible();
|
||||
}
|
||||
|
||||
const responsePromise = page.waitForResponse(
|
||||
(resp) => resp.url().includes("/api/setup-keys") && resp.request().method() === "GET",
|
||||
);
|
||||
await page.getByTestId("create-setup-key").click();
|
||||
|
||||
const copyInput = page.getByTestId("setup-key-copy-input");
|
||||
const keyValue = await copyInput.getAttribute("data-testid-setup-key-value");
|
||||
expect(keyValue!.length).toBeGreaterThan(10);
|
||||
await page.getByTestId("setup-key-close").click();
|
||||
|
||||
await expect(copyInput).not.toBeVisible();
|
||||
await responsePromise;
|
||||
await expect(page.getByText(opts.name)).toBeVisible();
|
||||
}
|
||||
|
||||
async function revokeSetupKey(
|
||||
page: import("@playwright/test").Page,
|
||||
name: string,
|
||||
) {
|
||||
// Row actions are now behind a dropdown menu.
|
||||
await page
|
||||
.locator("tr")
|
||||
.filter({ hasText: name })
|
||||
.getByTestId("setup-key-actions")
|
||||
.click({ force: true });
|
||||
await page
|
||||
.locator('[data-testid="revoke-setup-key"]:not([data-disabled])')
|
||||
.click({ force: true });
|
||||
const responsePromise = page.waitForResponse(
|
||||
(resp) => resp.url().includes("/api/setup-keys/") && resp.request().method() === "PUT",
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
await page.getByTestId("confirmation.confirm").click();
|
||||
await responsePromise;
|
||||
await expect(
|
||||
page
|
||||
.locator("tr")
|
||||
.filter({ hasText: name })
|
||||
.getByTestId("circle-icon-inactive"),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
async function deleteSetupKey(
|
||||
page: import("@playwright/test").Page,
|
||||
name: string,
|
||||
) {
|
||||
// Row actions are now behind a dropdown menu.
|
||||
await page
|
||||
.locator("tr")
|
||||
.filter({ hasText: name })
|
||||
.getByTestId("setup-key-actions")
|
||||
.click({ force: true });
|
||||
await page.getByTestId("delete-setup-key").click({ force: true });
|
||||
await page.getByTestId("confirmation.confirm").click();
|
||||
await expect(page.locator("tr").filter({ hasText: name })).not.toBeVisible();
|
||||
}
|
||||
115
e2e/tests/team-service-users.spec.ts
Normal file
115
e2e/tests/team-service-users.spec.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { test, expect } from "../helpers/fixtures";
|
||||
import { navigateTo } from "../helpers/auth";
|
||||
import { generateRandomName } from "../helpers/utils";
|
||||
|
||||
let regularUser = "";
|
||||
let adminServiceUser = "";
|
||||
|
||||
test.describe.serial("Team - Service Users @team", () => {
|
||||
test("Should create service users and verify roles", async ({ dashboardAsOwner: page }) => {
|
||||
await navigateTo(page, "/team/service-users");
|
||||
|
||||
regularUser = generateRandomName("svc-user-");
|
||||
adminServiceUser = generateRandomName("svc-admin-");
|
||||
|
||||
await createServiceUser(page, regularUser, "User");
|
||||
await createServiceUser(page, adminServiceUser, "Admin");
|
||||
|
||||
await checkServiceUserRow(page, regularUser, "User");
|
||||
await checkServiceUserRow(page, adminServiceUser, "Admin");
|
||||
});
|
||||
|
||||
test("Should update role and manage access tokens", async ({ dashboardAsOwner: page }) => {
|
||||
await page.locator("tr").getByText(regularUser).click();
|
||||
await changeRoleTo(page, "Admin");
|
||||
await page.getByTestId("save-changes").click();
|
||||
|
||||
// Create and delete access token
|
||||
const tokenName = generateRandomName("tkn_");
|
||||
await page.getByTestId("access-token-open-modal").click();
|
||||
await page.getByTestId("access-token-name").fill(tokenName);
|
||||
await page.getByTestId("access-token-expires-in").fill("30");
|
||||
await page.getByTestId("create-access-token").click();
|
||||
await expect(page.getByTestId("access-token-copy-close")).toBeVisible();
|
||||
await page.getByTestId("access-token-copy-close").click();
|
||||
|
||||
const tokenRow = page.locator("tr").filter({ hasText: tokenName });
|
||||
await tokenRow.getByTestId("access-token-delete").click();
|
||||
await page.getByTestId("confirmation.confirm").click();
|
||||
await expect(tokenRow).not.toBeVisible();
|
||||
|
||||
await page.getByText("Service Users").first().click();
|
||||
});
|
||||
|
||||
test("Should update admin user role and verify all changes persisted", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await page.locator("tr").getByText(adminServiceUser).click();
|
||||
await changeRoleTo(page, "User");
|
||||
const saveResponse = page.waitForResponse(
|
||||
(resp) => resp.url().includes("/api/users/") && resp.request().method() === "PUT",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await page.getByTestId("save-changes").click();
|
||||
await saveResponse;
|
||||
|
||||
await page.getByText("Service Users").first().click();
|
||||
await checkServiceUserRow(page, regularUser, "Admin");
|
||||
await checkServiceUserRow(page, adminServiceUser, "User");
|
||||
|
||||
// Single reload to verify all changes persisted
|
||||
await page.reload();
|
||||
await checkServiceUserRow(page, regularUser, "Admin");
|
||||
await checkServiceUserRow(page, adminServiceUser, "User");
|
||||
});
|
||||
|
||||
test("Should delete service users", async ({ dashboardAsOwner: page }) => {
|
||||
for (const name of [regularUser, adminServiceUser]) {
|
||||
const row = page.locator("tr").filter({ hasText: name });
|
||||
// Row actions are now behind a dropdown menu; open it, then delete.
|
||||
await row.getByTestId("user-actions").click({ force: true });
|
||||
await page.getByTestId("delete-user").click({ force: true });
|
||||
await page.getByTestId("confirmation.confirm").click();
|
||||
await expect(row).not.toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function createServiceUser(
|
||||
page: import("@playwright/test").Page,
|
||||
name: string,
|
||||
role: string,
|
||||
) {
|
||||
await page.getByTestId("open-service-user-modal").click();
|
||||
await expect(page.getByTestId("service-user-name")).toBeVisible({ timeout: 5_000 });
|
||||
await page.getByTestId("service-user-name").fill(name);
|
||||
await page.getByTestId("user-role-selector").click({ force: true });
|
||||
await page
|
||||
.getByTestId("user-role-selector-item")
|
||||
.getByText(role, { exact: true })
|
||||
.click({ force: true });
|
||||
await page.getByTestId("create-service-user").click();
|
||||
// Wait for modal to close
|
||||
await expect(page.getByTestId("service-user-name")).not.toBeVisible({ timeout: 5_000 });
|
||||
}
|
||||
|
||||
async function checkServiceUserRow(
|
||||
page: import("@playwright/test").Page,
|
||||
name: string,
|
||||
role: string,
|
||||
) {
|
||||
const row = page.locator("tr").filter({ hasText: name });
|
||||
await expect(row).toBeVisible({ timeout: 10_000 });
|
||||
await expect(row.getByText(role, { exact: true }).first()).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function changeRoleTo(
|
||||
page: import("@playwright/test").Page,
|
||||
role: string,
|
||||
) {
|
||||
await page.getByTestId("user-role-selector").click();
|
||||
await page
|
||||
.getByTestId("user-role-selector-item")
|
||||
.getByText(role, { exact: true })
|
||||
.click();
|
||||
}
|
||||
150
e2e/tests/team-users-approval-and-billing.spec.ts
Normal file
150
e2e/tests/team-users-approval-and-billing.spec.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { expect, test } from "../helpers/fixtures";
|
||||
import { loginToApp, navigateTo } from "../helpers/auth";
|
||||
import { deleteUserByEmail } from "../helpers/api";
|
||||
|
||||
test.setTimeout(60_000);
|
||||
|
||||
test.describe.serial("User Approval & Billing Admin @team", () => {
|
||||
// ── User Approval ────────────────────────────────────────────────────
|
||||
|
||||
test("Should show approval pending for the second user", async ({
|
||||
browser,
|
||||
dashboardAsOwner: ownerPage,
|
||||
}) => {
|
||||
// Clean up user from previous runs so approval flow starts fresh
|
||||
await deleteUserByEmail(ownerPage, "user@localhost.test");
|
||||
|
||||
const context = await browser.newContext({
|
||||
storageState: "e2e/fixtures/auth/user.json",
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await loginToApp(page, "user");
|
||||
await expect(page.getByText("User Approval Pending")).toBeVisible();
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test("Should approve the pending user", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await navigateTo(page, "/team/users");
|
||||
|
||||
const pendingRow = page.locator("tr").filter({ hasText: "Pending" });
|
||||
await expect(pendingRow).toBeVisible();
|
||||
await pendingRow.getByRole("button", { name: "Approve" }).click();
|
||||
await expect(pendingRow).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("Should delete the approved user", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
const userRow = page
|
||||
.locator("tr")
|
||||
.filter({ hasText: "user@localhost.test" });
|
||||
await expect(userRow).toBeVisible();
|
||||
// Row actions are now behind a dropdown menu.
|
||||
await userRow.getByTestId("user-actions").click({ force: true });
|
||||
await page.getByTestId("delete-user").click({ force: true });
|
||||
await page.getByTestId("confirmation.confirm").click();
|
||||
await expect(userRow).not.toBeVisible();
|
||||
});
|
||||
|
||||
// ── Billing Admin ────────────────────────────────────────────────────
|
||||
|
||||
test("Should login as second user to trigger registration", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const context = await browser.newContext({
|
||||
storageState: "e2e/fixtures/auth/user.json",
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await loginToApp(page, "user");
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test("Should approve user and assign Billing Admin role", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await navigateTo(page, "/team/users");
|
||||
|
||||
const pendingRow = page.locator("tr").filter({ hasText: "Pending" });
|
||||
if (await pendingRow.isVisible({ timeout: 5_000 }).catch(() => false)) {
|
||||
await pendingRow.getByRole("button", { name: "Approve" }).click();
|
||||
await expect(pendingRow).not.toBeVisible();
|
||||
}
|
||||
|
||||
const userRow = page
|
||||
.locator("tr")
|
||||
.filter({ hasText: "user@localhost.test" });
|
||||
await expect(userRow).toBeVisible();
|
||||
await userRow.getByTestId("user-name-cell").click();
|
||||
await expect(
|
||||
page.getByTestId("breadcrumb-item").filter({ hasText: /^user/i }),
|
||||
).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId("user-role-selector")).toBeEnabled({
|
||||
timeout: 15_000,
|
||||
});
|
||||
const currentRole = await page
|
||||
.getByTestId("user-role-selector")
|
||||
.textContent();
|
||||
if (!currentRole?.includes("Billing Admin")) {
|
||||
await page.getByTestId("user-role-selector").click();
|
||||
await page
|
||||
.getByTestId("user-role-selector-item")
|
||||
.filter({ hasText: "Billing Admin" })
|
||||
.click();
|
||||
await page.getByTestId("save-changes").click();
|
||||
}
|
||||
});
|
||||
|
||||
test("Should show Plans & Billing and Invoices for the Billing Admin", async ({
|
||||
browser,
|
||||
}) => {
|
||||
const context = await browser.newContext({
|
||||
storageState: "e2e/fixtures/auth/user.json",
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await loginToApp(page, "user");
|
||||
|
||||
await expect(page.getByTestId("user-dropdown")).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await page.getByTestId("user-dropdown").click({ force: true });
|
||||
await page.getByText("Plans & Billing").click();
|
||||
|
||||
await expect(
|
||||
page.getByTestId("settings-tab-plans-and-billing"),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByTestId("settings-tab-invoices")).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("settings-content-plans-and-billing"),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByTestId("settings-tab-invoices").click();
|
||||
await expect(page.getByTestId("settings-content-invoices")).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByTestId("settings-tab-authentication"),
|
||||
).not.toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("settings-tab-permissions"),
|
||||
).not.toBeVisible();
|
||||
await expect(page.getByTestId("settings-tab-clients")).not.toBeVisible();
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test("Should delete the second user", async ({ dashboardAsOwner: page }) => {
|
||||
await navigateTo(page, "/team/users");
|
||||
|
||||
const userRow = page
|
||||
.locator("tr")
|
||||
.filter({ hasText: "user@localhost.test" });
|
||||
await expect(userRow).toBeVisible();
|
||||
// Row actions are now behind a dropdown menu.
|
||||
await userRow.getByTestId("user-actions").click({ force: true });
|
||||
await page.getByTestId("delete-user").click({ force: true });
|
||||
await page.getByTestId("confirmation.confirm").click();
|
||||
await expect(userRow).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
105
e2e/tests/team-users.spec.ts
Normal file
105
e2e/tests/team-users.spec.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { test, expect } from "../helpers/fixtures";
|
||||
import { navigateTo } from "../helpers/auth";
|
||||
import { generateRandomName } from "../helpers/utils";
|
||||
import { deleteGroupsByPrefix } from "../helpers/api";
|
||||
|
||||
let createdGroupName = "";
|
||||
|
||||
test.describe.serial("Team - Users @team", () => {
|
||||
test('Should show the owner with "You" badge and "Owner" role', async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await navigateTo(page, "/team/users");
|
||||
|
||||
const ownerRow = page
|
||||
.getByTestId("user-name-cell")
|
||||
.filter({ hasText: "You" })
|
||||
.locator("xpath=ancestor::tr");
|
||||
await expect(ownerRow).toBeVisible();
|
||||
await expect(ownerRow.getByText("Owner", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("Should open the user detail page with Peers and Access Tokens tabs", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
await openOwnerDetailPage(page);
|
||||
|
||||
await expect(page.getByTestId("user-tab-peers")).toBeVisible();
|
||||
await expect(page.getByTestId("user-tab-access-tokens")).toBeVisible();
|
||||
|
||||
await page.getByTestId("user-tab-peers").click();
|
||||
await expect(page.getByText("View all peers registered by this user.")).toBeVisible();
|
||||
|
||||
await page.getByTestId("user-tab-access-tokens").click();
|
||||
await expect(page.getByText("Access tokens give access to NetBird API.")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Should add an auto-assigned group, save, and verify persistence", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
// Go back to users list via breadcrumb
|
||||
await page.getByTestId("breadcrumb-item").filter({ hasText: "Users" }).click();
|
||||
await openOwnerDetailPage(page);
|
||||
|
||||
const name = generateRandomName("user-group-");
|
||||
createdGroupName = name;
|
||||
|
||||
await page.getByTestId("user-group-selector").click();
|
||||
const search = page.getByTestId("user-group-selector-search");
|
||||
await expect(search).toBeVisible();
|
||||
await search.fill(name);
|
||||
await search.press("Enter");
|
||||
await expect(
|
||||
page.getByTestId("user-group-selector").getByText(name),
|
||||
).toBeVisible();
|
||||
await search.press("Escape");
|
||||
|
||||
const saveResponse = page.waitForResponse(
|
||||
(resp) => resp.url().includes("/api/users/") && resp.request().method() === "PUT",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await page.getByTestId("save-changes").click();
|
||||
await saveResponse;
|
||||
await expect(
|
||||
page.getByTestId("user-group-selector").getByText(name),
|
||||
).toBeVisible();
|
||||
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByTestId("user-group-selector").getByText(name),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("Should remove the auto-assigned group, save, and verify removal", async ({
|
||||
dashboardAsOwner: page,
|
||||
}) => {
|
||||
// Already on user detail page from previous test (after reload)
|
||||
await page
|
||||
.getByTestId("user-group-selector")
|
||||
.getByTestId("group-badge")
|
||||
.filter({ hasText: createdGroupName })
|
||||
.click();
|
||||
|
||||
await page.getByTestId("save-changes").click();
|
||||
await expect(
|
||||
page.getByTestId("user-group-selector").getByText(createdGroupName),
|
||||
).not.toBeVisible();
|
||||
|
||||
await page.reload();
|
||||
await expect(
|
||||
page.getByTestId("user-group-selector").getByText(createdGroupName),
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("Should delete the created group", async ({ dashboardAsOwner: page }) => {
|
||||
await deleteGroupsByPrefix(page, createdGroupName);
|
||||
});
|
||||
});
|
||||
|
||||
async function openOwnerDetailPage(page: import("@playwright/test").Page) {
|
||||
await page.getByTestId("user-name-cell").filter({ hasText: "You" }).click();
|
||||
await expect(
|
||||
page.getByTestId("breadcrumb-item").filter({ hasText: "Users" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("Auto-assigned groups")).toBeVisible();
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
const createNextIntlPlugin = require('next-intl/plugin');
|
||||
|
||||
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: "export",
|
||||
@@ -7,7 +11,9 @@ const nextConfig = {
|
||||
reactStrictMode: false,
|
||||
env: {
|
||||
APP_ENV: process.env.APP_ENV || "production",
|
||||
NEXT_PUBLIC_DASHBOARD_VERSION:
|
||||
process.env.NEXT_PUBLIC_DASHBOARD_VERSION || "development",
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
module.exports = withNextIntl(nextConfig);
|
||||
|
||||
5200
package-lock.json
generated
5200
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
93
package.json
93
package.json
@@ -2,45 +2,54 @@
|
||||
"name": "netbird-dashboard",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"scripts": {
|
||||
"copy": "copyfiles -f ./node_modules/@axa-fr/react-oidc/dist/OidcServiceWorker.js ./public",
|
||||
"copytrusted": "copyfiles -f ./public/local/OidcTrustedDomains.js ./public",
|
||||
"dev": "next dev -p 3000",
|
||||
"turbo": "next dev -p 3000 --turbo",
|
||||
"build": "next build",
|
||||
"postbuild": "node postbuild.js",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"cypress:open": "cypress open"
|
||||
"test:setup": "cd ./e2e/environment && sh create-test-env.sh",
|
||||
"test:clean": "cd ./e2e/environment && sh clean-test-env.sh",
|
||||
"test:dev": "cross-env APP_ENV=test next dev -p 1337",
|
||||
"test": "npx playwright test --config=e2e/playwright.config.ts",
|
||||
"test:ui": "npx playwright test --config=e2e/playwright.config.ts --ui",
|
||||
"test:ci": "cross-env APP_ENV=test next build && npx playwright test --config=e2e/playwright.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@axa-fr/react-oidc": "^7.22.18",
|
||||
"@axa-fr/react-oidc": "^7.26.3",
|
||||
"@dagrejs/dagre": "^1.1.5",
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@radix-ui/react-hover-card": "^1.1.4",
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-radio-group": "^1.1.3",
|
||||
"@radix-ui/react-scroll-area": "^1.1.0",
|
||||
"@radix-ui/react-select": "^2.0.0",
|
||||
"@radix-ui/react-slider": "^1.1.2",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tabs": "^1.0.4",
|
||||
"@radix-ui/react-toast": "^1.1.5",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@tabler/icons-react": "^2.39.0",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tabler/icons-react": "^3.36.1",
|
||||
"@tanstack/match-sorter-utils": "^8.8.4",
|
||||
"@tanstack/react-table": "^8.10.7",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/lodash": "^4.14.200",
|
||||
"@types/lodash": "4.17.24",
|
||||
"@types/node": "20.10.6",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
@@ -49,8 +58,10 @@
|
||||
"chart.js": "^4.4.8",
|
||||
"chroma-js": "^3.1.2",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"classnames": "^2.5.1",
|
||||
"clsx": "^2.0.0",
|
||||
"cmdk": "^0.2.0",
|
||||
"cmdk": "^1.1.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"crypto-js": "^4.2.0",
|
||||
"d3": "^7.9.0",
|
||||
"date-fns": "^2.30.0",
|
||||
@@ -58,40 +69,46 @@
|
||||
"elkjs": "^0.10.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-simple-import-sort": "^10.0.0",
|
||||
"flowbite": "^1.8.1",
|
||||
"flowbite-react": "^0.6.4",
|
||||
"framer-motion": "^10.16.4",
|
||||
"ip-address": "^10.1.0",
|
||||
"framer-motion": "^12.29.2",
|
||||
"ip-address": "^10.2.0",
|
||||
"ip-cidr": "^3.1.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.539.0",
|
||||
"next": "^14.2.35",
|
||||
"js-cookie": "^3.0.7",
|
||||
"lodash": "4.18.1",
|
||||
"lucide-react": "^0.566.0",
|
||||
"next": "16.1.7",
|
||||
"next-intl": "^4.13.0",
|
||||
"next-themes": "^0.2.1",
|
||||
"punycode": "^2.3.1",
|
||||
"react": "^18.3.1",
|
||||
"react-day-picker": "^8.9.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react": "^19.2.4",
|
||||
"react-chartjs-2": "^5.3.0",
|
||||
"react-confetti-explosion": "^3.0.3",
|
||||
"react-day-picker": "^9.13.0",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-ga4": "^2.1.0",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-hotjar": "^6.2.0",
|
||||
"react-hotjar": "^6.3.1",
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-jwt": "^1.2.0",
|
||||
"react-loading-skeleton": "^3.3.1",
|
||||
"react-responsive": "^9.0.2",
|
||||
"react-virtuoso": "^4.9.0",
|
||||
"sonner": "^2.0.7",
|
||||
"swr": "^2.2.4",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"timescape": "^0.7.1",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"overrides": {
|
||||
"minimatch": ">=10.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^9.5.1",
|
||||
"@types/chroma-js": "^3.1.1",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "^16.0.5",
|
||||
"eslint-config-next": "^16.1.6",
|
||||
"postcss": "^8",
|
||||
"prettier": "3.0.3",
|
||||
"tailwindcss": "^3.4.17"
|
||||
|
||||
96
postbuild.js
Normal file
96
postbuild.js
Normal file
@@ -0,0 +1,96 @@
|
||||
const { resolve, join } = require("path");
|
||||
const { createHash } = require("crypto");
|
||||
const {
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
statSync,
|
||||
} = require("fs");
|
||||
|
||||
process.env.NODE_ENV = "production";
|
||||
const PLACEHOLDER = "NB_INLINE_SCRIPT_PLACEHOLDER";
|
||||
console.log("Starting post-build script to extract inline scripts...");
|
||||
|
||||
// Function to find HTML files recursively
|
||||
function findHtmlFiles(dir) {
|
||||
const files = [];
|
||||
const entries = readdirSync(dir);
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry);
|
||||
const stat = statSync(fullPath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
files.push(...findHtmlFiles(fullPath));
|
||||
} else if (entry.endsWith(".html")) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
// For Next.js export output, the files are in the 'out' directory
|
||||
const baseDir = resolve("out");
|
||||
const htmlFiles = findHtmlFiles(baseDir);
|
||||
|
||||
console.log(`Found ${htmlFiles.length} .html files to process`);
|
||||
|
||||
// Ensure assets directory exists
|
||||
const assetsDir = `${baseDir}/assets`;
|
||||
mkdirSync(assetsDir, { recursive: true });
|
||||
|
||||
htmlFiles.forEach((file) => {
|
||||
// Read file contents
|
||||
const contents = readFileSync(file, "utf8");
|
||||
const scripts = [];
|
||||
|
||||
// Extract inline scripts
|
||||
const newFile = contents.replace(
|
||||
/<script(?![^>]*src)([^>]*)>(.+?)<\/script>/gs,
|
||||
(match, attributes, scriptContent) => {
|
||||
// Skip if script has src attribute (external script)
|
||||
if (attributes.includes("src=")) {
|
||||
return match;
|
||||
}
|
||||
|
||||
const addPlaceholderString = scripts.length === 0;
|
||||
const cleanedScript = scriptContent.trim();
|
||||
|
||||
if (cleanedScript) {
|
||||
scripts.push(
|
||||
`${cleanedScript}${cleanedScript.endsWith(";") ? "" : ";"}`,
|
||||
);
|
||||
}
|
||||
|
||||
return addPlaceholderString ? PLACEHOLDER : "";
|
||||
},
|
||||
);
|
||||
|
||||
// Early exit if no inline scripts found
|
||||
if (!scripts.length) {
|
||||
console.log(`No inline scripts found`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Combine scripts and create hash
|
||||
const chunk = scripts.join("\n");
|
||||
const hash = createHash("md5").update(chunk).digest("hex").slice(0, 8);
|
||||
const chunkFileName = `chunk.${hash}.js`;
|
||||
const chunkPath = `${assetsDir}/${chunkFileName}`;
|
||||
|
||||
// Write the chunk file
|
||||
writeFileSync(chunkPath, chunk, "utf8");
|
||||
|
||||
// Replace placeholder string with script tag
|
||||
const updatedFile = newFile.replace(
|
||||
PLACEHOLDER,
|
||||
`<script src="/assets/${chunkFileName}" crossorigin=""></script>`,
|
||||
);
|
||||
|
||||
// Write updated HTML file
|
||||
writeFileSync(file, updatedFile, "utf8");
|
||||
});
|
||||
|
||||
console.log("Post-build script completed successfully!");
|
||||
8
src/app/(dashboard)/(cloud)/customers/layout.tsx
Normal file
8
src/app/(dashboard)/(cloud)/customers/layout.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { globalMetaTitle } from "@utils/meta";
|
||||
import type { Metadata } from "next";
|
||||
import BlankLayout from "@/layouts/BlankLayout";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Customers - ${globalMetaTitle}`,
|
||||
};
|
||||
export default BlankLayout;
|
||||
69
src/app/(dashboard)/(cloud)/customers/page.tsx
Normal file
69
src/app/(dashboard)/(cloud)/customers/page.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import Breadcrumbs from "@components/Breadcrumbs";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import SkeletonTable from "@components/skeletons/SkeletonTable";
|
||||
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import useFetchApi from "@utils/api";
|
||||
import React, { Suspense } from "react";
|
||||
import MSPIcon from "@/assets/icons/MSPIcon";
|
||||
import { CustomersProvider } from "@/cloud/distributor/contexts/CustomersProvider";
|
||||
import DistributorCustomersTable from "@/cloud/distributor/table/DistributorCustomersTable";
|
||||
import { DistributorDocsLink } from "@/cloud/distributor/DistributorDocsLink";
|
||||
import { useDistributor } from "@/cloud/distributor/contexts/DistributorProvider";
|
||||
import { DistributorCustomer } from "@/cloud/distributor/interfaces/Distributor";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
|
||||
export default function CustomersPage() {
|
||||
const { isDistributorInfoLoading } = useDistributor();
|
||||
if (isDistributorInfoLoading) return <FullScreenLoading fullScreen={false} />;
|
||||
return <CustomersPageContent />;
|
||||
}
|
||||
|
||||
const CustomersPageContent = () => {
|
||||
const { permission } = usePermissions();
|
||||
const { data: customers, isLoading } = useFetchApi<DistributorCustomer[]>(
|
||||
"/integrations/msp/reseller/msps",
|
||||
);
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/customers"}
|
||||
label={"Customers"}
|
||||
icon={<MSPIcon size={15} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>Customers</h1>
|
||||
<Paragraph>
|
||||
Use this view to manage customer accounts and their plans.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<DistributorDocsLink />
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<RestrictedAccess
|
||||
page={"Customers"}
|
||||
hasAccess={permission.tenants.create}
|
||||
>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<CustomersProvider>
|
||||
<DistributorCustomersTable
|
||||
isLoading={isLoading}
|
||||
headingTarget={portalTarget}
|
||||
customers={customers}
|
||||
/>
|
||||
</CustomersProvider>
|
||||
</Suspense>
|
||||
</RestrictedAccess>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
8
src/app/(dashboard)/(cloud)/integrations/layout.tsx
Normal file
8
src/app/(dashboard)/(cloud)/integrations/layout.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { globalMetaTitle } from "@utils/meta";
|
||||
import type { Metadata } from "next";
|
||||
import BlankLayout from "@/layouts/BlankLayout";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Integrations - ${globalMetaTitle}`,
|
||||
};
|
||||
export default BlankLayout;
|
||||
73
src/app/(dashboard)/(cloud)/integrations/page.tsx
Normal file
73
src/app/(dashboard)/(cloud)/integrations/page.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import { VerticalTabs } from "@components/VerticalTabs";
|
||||
import {
|
||||
FileText,
|
||||
FingerprintIcon,
|
||||
KeyRoundIcon,
|
||||
ShieldCheckIcon,
|
||||
} from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import React, { useState } from "react";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
import { useAccount } from "@/modules/account/useAccount";
|
||||
import EDRTab from "@/modules/integrations/edr/EDRTab";
|
||||
import EventStreamingTab from "@/modules/integrations/event-streaming/EventStreamingTab";
|
||||
import IdentityProviderTab from "@/modules/integrations/idp-sync/IdentityProviderTab";
|
||||
import SSOTab from "@/modules/integrations/sso/SSOTab";
|
||||
import { isNetBirdCloud } from "@utils/netbird";
|
||||
|
||||
export default function Integrations() {
|
||||
const searchParams = useSearchParams();
|
||||
const currentTab = searchParams.get("tab");
|
||||
const [tab, setTab] = useState(currentTab || "identity-provider");
|
||||
const account = useAccount();
|
||||
const { permission } = usePermissions();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<VerticalTabs value={tab} onChange={setTab}>
|
||||
<VerticalTabs.List>
|
||||
<VerticalTabs.Trigger value="identity-provider">
|
||||
<FingerprintIcon size={14} />
|
||||
Identity Provider Sync
|
||||
</VerticalTabs.Trigger>
|
||||
|
||||
{isNetBirdCloud() && (
|
||||
<VerticalTabs.Trigger value="sso">
|
||||
<KeyRoundIcon size={14} />
|
||||
Single Sign-On
|
||||
</VerticalTabs.Trigger>
|
||||
)}
|
||||
|
||||
<VerticalTabs.Trigger value="event-streaming">
|
||||
<FileText size={14} />
|
||||
Event Streaming
|
||||
</VerticalTabs.Trigger>
|
||||
<VerticalTabs.Trigger value="edr">
|
||||
<ShieldCheckIcon size={15} />
|
||||
MDM & EDR
|
||||
</VerticalTabs.Trigger>
|
||||
</VerticalTabs.List>
|
||||
<RestrictedAccess
|
||||
page={"Integrations"}
|
||||
hasAccess={
|
||||
permission?.edr?.read ||
|
||||
permission?.idp?.read ||
|
||||
permission?.event_streaming?.read ||
|
||||
(!isNetBirdCloud() && (permission?.settings?.read ?? false))
|
||||
}
|
||||
>
|
||||
<div className={"border-l border-nb-gray-930 w-full"}>
|
||||
<IdentityProviderTab />
|
||||
<SSOTab />
|
||||
<EventStreamingTab />
|
||||
{account && <EDRTab account={account} />}
|
||||
</div>
|
||||
</RestrictedAccess>
|
||||
</VerticalTabs>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
150
src/app/(dashboard)/(cloud)/msp/page.tsx
Normal file
150
src/app/(dashboard)/(cloud)/msp/page.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import Button from "@components/Button";
|
||||
import { Callout } from "@components/Callout";
|
||||
import { Modal, ModalContent, ModalFooter } from "@components/modal/Modal";
|
||||
import { notify } from "@components/Notification";
|
||||
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||
import { GradientFadedBackground } from "@components/ui/GradientFadedBackground";
|
||||
import useRedirect from "@hooks/useRedirect";
|
||||
import { useApiCall } from "@utils/api";
|
||||
import { LockIcon } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import NetBirdIcon from "@/assets/icons/NetBirdIcon";
|
||||
import { useMSP } from "@/cloud/msp/contexts/MSPProvider";
|
||||
import { useLoggedInUser } from "@/contexts/UsersProvider";
|
||||
|
||||
export default function JoinMspPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const inviteCode = searchParams.get("invite");
|
||||
|
||||
const { mutate } = useSWRConfig();
|
||||
const router = useRouter();
|
||||
const { isMspInfoLoading, mspInfo, isActive, isAccountWithMSPParent } =
|
||||
useMSP();
|
||||
const [open, setOpen] = useState(true);
|
||||
const { isOwner } = useLoggedInUser();
|
||||
const [isAccepting, setIsAccepting] = useState(false);
|
||||
const [calledOnce, setCalledOnce] = useState(false);
|
||||
const isMSPAccount = !!mspInfo && isActive;
|
||||
|
||||
const mspRequest = useApiCall<string>("/integrations/msp", true, {
|
||||
ignoreGlobalParams: true,
|
||||
});
|
||||
|
||||
const declineButtonText = useMemo(() => {
|
||||
if (isMSPAccount && !calledOnce) return "Go to Tenants";
|
||||
if (isOwner) return "Decline";
|
||||
return "Go to Peers";
|
||||
}, [isMSPAccount, calledOnce, isOwner]);
|
||||
|
||||
if (isAccountWithMSPParent || !inviteCode) return <Redirect />;
|
||||
|
||||
const acceptInvitation = async () => {
|
||||
if (isAccepting) return;
|
||||
setCalledOnce(true);
|
||||
setIsAccepting(true);
|
||||
const promise = mspRequest
|
||||
.post({
|
||||
invite: inviteCode,
|
||||
})
|
||||
.then(() => {
|
||||
mutate("/integrations/msp");
|
||||
mutate("/integrations/msp/tenants");
|
||||
router.push("/tenants");
|
||||
})
|
||||
.finally(() => setIsAccepting(false));
|
||||
|
||||
notify({
|
||||
title: `NetBird Managed Service Provider`,
|
||||
description: `Successfully joined as an Managed Service Provider`,
|
||||
loadingMessage: `Processing your invitation...`,
|
||||
promise,
|
||||
});
|
||||
return promise;
|
||||
};
|
||||
|
||||
const redirectTo = () => {
|
||||
if (isMSPAccount) {
|
||||
router.push("/tenants");
|
||||
} else {
|
||||
router.push("/peers");
|
||||
}
|
||||
};
|
||||
|
||||
const isDisabled = !isOwner || isMspInfoLoading || isMSPAccount;
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={setOpen}>
|
||||
<ModalContent
|
||||
maxWidthClass={"max-w-sm relative"}
|
||||
showClose={false}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<GradientFadedBackground />
|
||||
<div className={"flex items-center justify-center flex-col gap-2 px-8"}>
|
||||
<div
|
||||
className={
|
||||
"bg-nb-gray-900 rounded-full h-11 w-11 flex items-center justify-center mb-2"
|
||||
}
|
||||
>
|
||||
<NetBirdIcon size={24} className={"shrink-0"} />
|
||||
</div>
|
||||
|
||||
<div className={"text-xl font-medium text-center max-w-xs mb-1"}>
|
||||
NetBird invites you to join as an Managed Service Provider (MSP)
|
||||
</div>
|
||||
<div className={"text-sm text-nb-gray-300 text-center"}>
|
||||
You will get access to the NetBird MSP portal where you can manage
|
||||
multiple customers and their networks from a single place.
|
||||
</div>
|
||||
{!isOwner && !isMSPAccount && (
|
||||
<Callout
|
||||
icon={
|
||||
<LockIcon size={14} className={"shrink-0 relative top-[3px]"} />
|
||||
}
|
||||
className={"text-xs mt-3"}
|
||||
>
|
||||
Only the owner of the account can accept this invitation. Please
|
||||
contact the owner of the account to accept the invitation.
|
||||
</Callout>
|
||||
)}
|
||||
{isMSPAccount && !calledOnce && (
|
||||
<Callout className={"text-xs mt-3 w-full"}>
|
||||
The invitation has already been accepted
|
||||
</Callout>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ModalFooter separator={false} className={"gap-x-2 mt-1"}>
|
||||
<Button
|
||||
className={"w-full"}
|
||||
variant={"secondary"}
|
||||
onClick={redirectTo}
|
||||
>
|
||||
{declineButtonText}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
autoFocus={true}
|
||||
className={"w-full"}
|
||||
variant={"primary"}
|
||||
disabled={isDisabled}
|
||||
onClick={acceptInvitation}
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const Redirect = () => {
|
||||
useRedirect("/peers");
|
||||
return <FullScreenLoading fullScreen={false} />;
|
||||
};
|
||||
8
src/app/(dashboard)/(cloud)/plans/cancel/layout.tsx
Normal file
8
src/app/(dashboard)/(cloud)/plans/cancel/layout.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { globalMetaTitle } from "@utils/meta";
|
||||
import type { Metadata } from "next";
|
||||
import BlankLayout from "@/layouts/BlankLayout";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Plans - ${globalMetaTitle}`,
|
||||
};
|
||||
export default BlankLayout;
|
||||
9
src/app/(dashboard)/(cloud)/plans/cancel/page.tsx
Normal file
9
src/app/(dashboard)/(cloud)/plans/cancel/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||
import { useRedirect } from "@hooks/useRedirect";
|
||||
|
||||
export default function PlanCancel() {
|
||||
useRedirect("/settings?tab=plans-and-billing");
|
||||
return <FullScreenLoading fullScreen={false} />;
|
||||
}
|
||||
8
src/app/(dashboard)/(cloud)/plans/layout.tsx
Normal file
8
src/app/(dashboard)/(cloud)/plans/layout.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { globalMetaTitle } from "@utils/meta";
|
||||
import type { Metadata } from "next";
|
||||
import BlankLayout from "@/layouts/BlankLayout";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Plans - ${globalMetaTitle}`,
|
||||
};
|
||||
export default BlankLayout;
|
||||
10
src/app/(dashboard)/(cloud)/plans/page.tsx
Normal file
10
src/app/(dashboard)/(cloud)/plans/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||
import { useRedirect } from "@hooks/useRedirect";
|
||||
import React from "react";
|
||||
|
||||
export default function PlanSuccess() {
|
||||
useRedirect("/settings?tab=plans-and-billing");
|
||||
return <FullScreenLoading fullScreen={false} />;
|
||||
}
|
||||
8
src/app/(dashboard)/(cloud)/plans/success/layout.tsx
Normal file
8
src/app/(dashboard)/(cloud)/plans/success/layout.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { globalMetaTitle } from "@utils/meta";
|
||||
import type { Metadata } from "next";
|
||||
import BlankLayout from "@/layouts/BlankLayout";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Plans - ${globalMetaTitle}`,
|
||||
};
|
||||
export default BlankLayout;
|
||||
10
src/app/(dashboard)/(cloud)/plans/success/page.tsx
Normal file
10
src/app/(dashboard)/(cloud)/plans/success/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||
import { useRedirect } from "@hooks/useRedirect";
|
||||
import React from "react";
|
||||
|
||||
export default function PlanSuccess() {
|
||||
useRedirect("/settings?tab=plans-and-billing&success=true");
|
||||
return <FullScreenLoading fullScreen={false} />;
|
||||
}
|
||||
8
src/app/(dashboard)/(cloud)/tenants/layout.tsx
Normal file
8
src/app/(dashboard)/(cloud)/tenants/layout.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { globalMetaTitle } from "@utils/meta";
|
||||
import type { Metadata } from "next";
|
||||
import BlankLayout from "@/layouts/BlankLayout";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Tenants - ${globalMetaTitle}`,
|
||||
};
|
||||
export default BlankLayout;
|
||||
85
src/app/(dashboard)/(cloud)/tenants/page.tsx
Normal file
85
src/app/(dashboard)/(cloud)/tenants/page.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import Breadcrumbs from "@components/Breadcrumbs";
|
||||
import Paragraph from "@components/Paragraph";
|
||||
import SkeletonTable from "@components/skeletons/SkeletonTable";
|
||||
import FullScreenLoading from "@components/ui/FullScreenLoading";
|
||||
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import useRedirect from "@hooks/useRedirect";
|
||||
import useFetchApi from "@utils/api";
|
||||
import React, { Suspense, useMemo } from "react";
|
||||
import MSPIcon from "@/assets/icons/MSPIcon";
|
||||
import { useMSP } from "@/cloud/msp/contexts/MSPProvider";
|
||||
import { TenantsProvider } from "@/cloud/msp/contexts/TenantsProvider";
|
||||
import { Tenant } from "@/cloud/msp/interfaces/Tenant";
|
||||
import { MSPTenantDocsLink } from "@/cloud/msp/MSPTenantDocsLink";
|
||||
import MSPTenantsTable from "@/cloud/msp/MSPTenantsTable";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useLoggedInUser } from "@/contexts/UsersProvider";
|
||||
import { User } from "@/interfaces/User";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
|
||||
export default function TenantsPage() {
|
||||
const { isActive, isMSPInMSPContext, isMspInfoLoading } = useMSP();
|
||||
const { isOwnerOrAdmin } = useLoggedInUser();
|
||||
|
||||
const show = useMemo(() => {
|
||||
if (!isActive) return false;
|
||||
return isMSPInMSPContext && isOwnerOrAdmin;
|
||||
}, [isActive, isMSPInMSPContext, isOwnerOrAdmin]);
|
||||
|
||||
if (isMspInfoLoading) return <FullScreenLoading fullScreen={false} />;
|
||||
if (!show) return <Redirect />;
|
||||
return <TenantsPageContent />;
|
||||
}
|
||||
|
||||
const Redirect = () => {
|
||||
useRedirect("/peers");
|
||||
return <FullScreenLoading fullScreen={false} />;
|
||||
};
|
||||
|
||||
const TenantsPageContent = () => {
|
||||
const { permission } = usePermissions();
|
||||
const { data: tenants, isLoading } = useFetchApi<Tenant[]>(
|
||||
"/integrations/msp/tenants",
|
||||
);
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
useFetchApi<User[]>("/users", true);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/tenants"}
|
||||
label={"Tenants"}
|
||||
icon={<MSPIcon size={15} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>Tenants</h1>
|
||||
<Paragraph>
|
||||
A list of all tenants and their subscription details. Use this view to
|
||||
manage accounts, plans and permissions.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<MSPTenantDocsLink />
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<RestrictedAccess page={"Tenants"} hasAccess={permission.tenants.read}>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<TenantsProvider>
|
||||
<MSPTenantsTable
|
||||
isLoading={isLoading}
|
||||
headingTarget={portalTarget}
|
||||
tenants={tenants}
|
||||
/>
|
||||
</TenantsProvider>
|
||||
</Suspense>
|
||||
</RestrictedAccess>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
@@ -6,5 +6,5 @@ import React from "react";
|
||||
|
||||
export default function Redirect() {
|
||||
useRedirect("/events/audit");
|
||||
return <FullScreenLoading height={"auto"} />;
|
||||
return <FullScreenLoading fullScreen={false} />;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
|
||||
import GroupsProvider from "@/contexts/GroupsProvider";
|
||||
@@ -17,60 +18,56 @@ import { Policy } from "@/interfaces/Policy";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
|
||||
const AccessControlTable = lazy(
|
||||
() => import("@/modules/access-control/table/AccessControlTable"),
|
||||
() => import("@/modules/access-control/table/AccessControlTable"),
|
||||
);
|
||||
export default function AccessControlPage() {
|
||||
const { permission } = usePermissions();
|
||||
const t = useTranslations("policies");
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const { data: policies, isLoading } = useFetchApi<Policy[]>("/policies");
|
||||
const { data: policies, isLoading } = useFetchApi<Policy[]>("/policies");
|
||||
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<GroupsProvider>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/access-control"}
|
||||
label={"Access Control"}
|
||||
icon={<AccessControlIcon size={14} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>Access Control Policies</h1>
|
||||
<Paragraph>
|
||||
Create rules to manage access in your network and define what peers
|
||||
can connect.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Learn more about
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/manage-network-access"}
|
||||
target={"_blank"}
|
||||
>
|
||||
Access Controls
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
</div>
|
||||
return (
|
||||
<PageContainer>
|
||||
<GroupsProvider>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/access-control"}
|
||||
label={t("title")}
|
||||
icon={<AccessControlIcon size={14} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>{t("title")}</h1>
|
||||
<Paragraph>
|
||||
{t("accessControlDescription")}{" "}
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/manage-network-access"}
|
||||
target={"_blank"}
|
||||
>
|
||||
{t("learnMore")}
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<RestrictedAccess
|
||||
page={"Access Control"}
|
||||
hasAccess={permission.policies.read}
|
||||
>
|
||||
<PoliciesProvider>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<AccessControlTable
|
||||
isLoading={isLoading}
|
||||
policies={policies}
|
||||
headingTarget={portalTarget}
|
||||
/>
|
||||
</Suspense>
|
||||
</PoliciesProvider>
|
||||
</RestrictedAccess>
|
||||
</GroupsProvider>
|
||||
</PageContainer>
|
||||
);
|
||||
<RestrictedAccess
|
||||
page={t("title")}
|
||||
hasAccess={permission.policies.read}
|
||||
>
|
||||
<PoliciesProvider>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<AccessControlTable
|
||||
isLoading={isLoading}
|
||||
policies={policies}
|
||||
headingTarget={portalTarget}
|
||||
/>
|
||||
</Suspense>
|
||||
</PoliciesProvider>
|
||||
</RestrictedAccess>
|
||||
</GroupsProvider>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,7 @@ import "@xyflow/react/dist/style.css";
|
||||
import Button from "@components/Button";
|
||||
import InlineLink from "@components/InlineLink";
|
||||
import { NoPeersGettingStarted } from "@components/NoPeersGettingStarted";
|
||||
import {
|
||||
SelectDropdown,
|
||||
SelectOption,
|
||||
} from "@components/select/SelectDropdown";
|
||||
import { SelectDropdown, SelectOption } from "@components/select/SelectDropdown";
|
||||
import SquareIcon from "@components/SquareIcon";
|
||||
import GetStartedTest from "@components/ui/GetStartedTest";
|
||||
import { SmallBadge } from "@components/ui/SmallBadge";
|
||||
@@ -22,16 +19,10 @@ import {
|
||||
ReactFlowProvider,
|
||||
useEdgesState,
|
||||
useNodesState,
|
||||
useReactFlow,
|
||||
useReactFlow
|
||||
} from "@xyflow/react";
|
||||
import { forEach, orderBy, sortBy } from "lodash";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ExternalLinkIcon,
|
||||
LayoutGridIcon,
|
||||
MessageSquareShareIcon,
|
||||
NetworkIcon,
|
||||
} from "lucide-react";
|
||||
import { ArrowLeftIcon, ExternalLinkIcon, LayoutGridIcon, MessageSquareShareIcon, NetworkIcon } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
|
||||
@@ -53,13 +44,13 @@ import { EDGE_TYPES } from "@/modules/control-center/utils/edges";
|
||||
import {
|
||||
getFirstGroup,
|
||||
getPolicyProtocolAndPortText,
|
||||
getResourcePolicyByGroups,
|
||||
getResourcePolicyByGroups
|
||||
} from "@/modules/control-center/utils/helpers";
|
||||
import {
|
||||
applyD3ForceLayout,
|
||||
applyD3HierarchicalLayout,
|
||||
DEFAULT_MAX_ZOOM,
|
||||
DEFAULT_MIN_ZOOM,
|
||||
DEFAULT_MIN_ZOOM
|
||||
} from "@/modules/control-center/utils/layouts";
|
||||
import { NODE_TYPES } from "@/modules/control-center/utils/nodes";
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import { usePortalElement } from "@hooks/usePortalElement";
|
||||
import useFetchApi from "@utils/api";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import DNSIcon from "@/assets/icons/DNSIcon";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
@@ -15,63 +16,61 @@ import { NameserverGroup } from "@/interfaces/Nameserver";
|
||||
import PageContainer from "@/layouts/PageContainer";
|
||||
|
||||
const NameserverGroupTable = lazy(
|
||||
() => import("@/modules/dns/nameservers/table/NameserverGroupTable"),
|
||||
() => import("@/modules/dns/nameservers/table/NameserverGroupTable"),
|
||||
);
|
||||
|
||||
export default function NameServers() {
|
||||
const { permission } = usePermissions();
|
||||
const t = useTranslations("dns");
|
||||
const tCommon = useTranslations("common");
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const { data: nameserverGroups, isLoading } =
|
||||
useFetchApi<NameserverGroup[]>("/dns/nameservers");
|
||||
const { data: nameserverGroups, isLoading } =
|
||||
useFetchApi<NameserverGroup[]>("/dns/nameservers");
|
||||
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
const { ref: headingRef, portalTarget } =
|
||||
usePortalElement<HTMLHeadingElement>();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/dns/nameservers"}
|
||||
label={"DNS"}
|
||||
icon={<DNSIcon size={13} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/dns/nameservers"}
|
||||
label={"Nameservers"}
|
||||
active
|
||||
icon={<DNSIcon size={13} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>Nameservers</h1>
|
||||
<Paragraph>
|
||||
Add nameservers for domain name resolution in your NetBird network.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Learn more about
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/manage-dns-in-your-network"}
|
||||
target={"_blank"}
|
||||
>
|
||||
DNS
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
</div>
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/dns/nameservers"}
|
||||
label={t("title")}
|
||||
icon={<DNSIcon size={13} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/dns/nameservers"}
|
||||
label={t("nameservers")}
|
||||
active
|
||||
icon={<DNSIcon size={13} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1 ref={headingRef}>{t("nameservers")}</h1>
|
||||
<Paragraph>
|
||||
{t("nameserversDescription")}{" "}
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/manage-dns-in-your-network"}
|
||||
target={"_blank"}
|
||||
>
|
||||
{tCommon("learnMore")}
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<RestrictedAccess
|
||||
page={"Nameservers"}
|
||||
hasAccess={permission.nameservers.read}
|
||||
>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<NameserverGroupTable
|
||||
nameserverGroups={nameserverGroups}
|
||||
isLoading={isLoading}
|
||||
headingTarget={portalTarget}
|
||||
/>
|
||||
</Suspense>
|
||||
</RestrictedAccess>
|
||||
</PageContainer>
|
||||
);
|
||||
<RestrictedAccess
|
||||
page={t("nameservers")}
|
||||
hasAccess={permission.nameservers.read}
|
||||
>
|
||||
<Suspense fallback={<SkeletonTable />}>
|
||||
<NameserverGroupTable
|
||||
nameserverGroups={nameserverGroups}
|
||||
isLoading={isLoading}
|
||||
headingTarget={portalTarget}
|
||||
/>
|
||||
</Suspense>
|
||||
</RestrictedAccess>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,5 +11,5 @@ export default function DNS() {
|
||||
router.push("/dns/nameservers");
|
||||
}, [router]);
|
||||
|
||||
return <FullScreenLoading height={"auto"} />;
|
||||
return <FullScreenLoading fullScreen={false} />;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||
import { IconSettings2 } from "@tabler/icons-react";
|
||||
import useFetchApi, { useApiCall } from "@utils/api";
|
||||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import React from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import { useSWRConfig } from "swr";
|
||||
@@ -26,126 +27,128 @@ import useGroupHelper from "@/modules/groups/useGroupHelper";
|
||||
import { useGroupIdsToGroups } from "@/modules/groups/useGroupIdsToGroups";
|
||||
|
||||
export default function NameServerSettings() {
|
||||
const { permission } = usePermissions();
|
||||
const t = useTranslations("dns");
|
||||
const tCommon = useTranslations("common");
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const { data: settings, isLoading } =
|
||||
useFetchApi<NameserverSettings>("/dns/settings");
|
||||
const { data: settings, isLoading } =
|
||||
useFetchApi<NameserverSettings>("/dns/settings");
|
||||
|
||||
const initialDNSGroups = useGroupIdsToGroups(
|
||||
settings?.disabled_management_groups,
|
||||
);
|
||||
const initialDNSGroups = useGroupIdsToGroups(
|
||||
settings?.disabled_management_groups,
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/dns"}
|
||||
label={"DNS"}
|
||||
icon={<DNSIcon size={13} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/dns/settings"}
|
||||
label={"DNS Settings"}
|
||||
active
|
||||
icon={<IconSettings2 size={15} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1>DNS Settings</h1>
|
||||
<Paragraph>{"Manage your account's DNS settings."}</Paragraph>
|
||||
<Paragraph>
|
||||
Learn more about
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/manage-dns-in-your-network"}
|
||||
target={"_blank"}
|
||||
>
|
||||
DNS
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
in our documentation.
|
||||
</Paragraph>
|
||||
<RestrictedAccess page={"DNS Settings"} hasAccess={permission.dns.read}>
|
||||
{!isLoading && initialDNSGroups !== undefined ? (
|
||||
<SettingDisabledManagementGroups initialGroups={initialDNSGroups} />
|
||||
) : (
|
||||
<div>
|
||||
<Skeleton
|
||||
width={"100%"}
|
||||
className={"mt-8 max-w-xl"}
|
||||
height={240}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</RestrictedAccess>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
return (
|
||||
<PageContainer>
|
||||
<div className={"p-default py-6"}>
|
||||
<Breadcrumbs>
|
||||
<Breadcrumbs.Item
|
||||
href={"/dns"}
|
||||
label={t("title")}
|
||||
icon={<DNSIcon size={13} />}
|
||||
/>
|
||||
<Breadcrumbs.Item
|
||||
href={"/dns/settings"}
|
||||
label={t("dnsSettings")}
|
||||
active
|
||||
icon={<IconSettings2 size={15} />}
|
||||
/>
|
||||
</Breadcrumbs>
|
||||
<h1>{t("dnsSettings")}</h1>
|
||||
<Paragraph>
|
||||
{t("dnsSettingsDescription")}{" "}
|
||||
<InlineLink
|
||||
href={"https://docs.netbird.io/how-to/manage-dns-in-your-network"}
|
||||
target={"_blank"}
|
||||
>
|
||||
{tCommon("learnMore")}
|
||||
<ExternalLinkIcon size={12} />
|
||||
</InlineLink>
|
||||
</Paragraph>
|
||||
<RestrictedAccess
|
||||
page={t("dnsSettings")}
|
||||
hasAccess={permission.dns.read}
|
||||
>
|
||||
{!isLoading && initialDNSGroups !== undefined ? (
|
||||
<SettingDisabledManagementGroups initialGroups={initialDNSGroups} />
|
||||
) : (
|
||||
<div>
|
||||
<Skeleton
|
||||
width={"100%"}
|
||||
className={"mt-8 max-w-xl"}
|
||||
height={240}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</RestrictedAccess>
|
||||
</div>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const SettingDisabledManagementGroups = ({
|
||||
initialGroups,
|
||||
initialGroups,
|
||||
}: {
|
||||
initialGroups: Group[];
|
||||
initialGroups: Group[];
|
||||
}) => {
|
||||
const settingRequest = useApiCall<NameserverSettings>("/dns/settings");
|
||||
const { mutate } = useSWRConfig();
|
||||
const { permission } = usePermissions();
|
||||
const t = useTranslations("dns");
|
||||
const settingRequest = useApiCall<NameserverSettings>("/dns/settings");
|
||||
const { mutate } = useSWRConfig();
|
||||
const { permission } = usePermissions();
|
||||
|
||||
const [selectedGroups, setSelectedGroups, { save: saveGroups }] =
|
||||
useGroupHelper({
|
||||
initial: initialGroups,
|
||||
});
|
||||
const [selectedGroups, setSelectedGroups, { save: saveGroups }] =
|
||||
useGroupHelper({
|
||||
initial: initialGroups,
|
||||
});
|
||||
|
||||
const { hasChanges, updateRef: updateChangesRef } = useHasChanges([
|
||||
selectedGroups,
|
||||
]);
|
||||
const { hasChanges, updateRef: updateChangesRef } = useHasChanges([
|
||||
selectedGroups,
|
||||
]);
|
||||
|
||||
const saveSettings = async () => {
|
||||
const savedGroups = await saveGroups();
|
||||
notify({
|
||||
title: "DNS Settings",
|
||||
description: "Settings saved successfully.",
|
||||
promise: settingRequest
|
||||
.put({
|
||||
disabled_management_groups: savedGroups.map((g) => g.id),
|
||||
})
|
||||
.then(() => {
|
||||
mutate("/dns/settings");
|
||||
updateChangesRef([selectedGroups]);
|
||||
}),
|
||||
loadingMessage: "Saving the settings...",
|
||||
});
|
||||
};
|
||||
const saveSettings = async () => {
|
||||
const savedGroups = await saveGroups();
|
||||
notify({
|
||||
title: t("dnsSettings"),
|
||||
description: t("settingsSaved"),
|
||||
promise: settingRequest
|
||||
.put({
|
||||
disabled_management_groups: savedGroups.map((g) => g.id),
|
||||
})
|
||||
.then(() => {
|
||||
mutate("/dns/settings");
|
||||
updateChangesRef([selectedGroups]);
|
||||
}),
|
||||
loadingMessage: t("settingsSaving"),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={"mt-8 max-w-xl"}>
|
||||
<div className={"px-8 py-8"}>
|
||||
<Label>Disable DNS management for these groups</Label>
|
||||
<HelpText>
|
||||
Peers in these groups will require manual domain name resolution
|
||||
</HelpText>
|
||||
<PeerGroupSelector
|
||||
dataCy={"dns-groups-selector"}
|
||||
onChange={setSelectedGroups}
|
||||
values={selectedGroups}
|
||||
disabled={!permission.dns.update}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
"flex justify-end bg-nb-gray-900/20 border-t border-nb-gray-900 px-8 py-5"
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
size={"sm"}
|
||||
onClick={saveSettings}
|
||||
disabled={!hasChanges || !permission.dns.update}
|
||||
data-cy={"save-changes"}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
return (
|
||||
<Card className={"mt-8 max-w-xl"}>
|
||||
<div className={"px-8 py-8"}>
|
||||
<Label>{t("disabledManagementGroup")}</Label>
|
||||
<HelpText>{t("disabledManagementGroupHelp")}</HelpText>
|
||||
<PeerGroupSelector
|
||||
data-testid={"dns-groups-selector"}
|
||||
onChange={setSelectedGroups}
|
||||
values={selectedGroups}
|
||||
disabled={!permission.dns.update}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
"flex justify-end bg-nb-gray-900/20 border-t border-nb-gray-900 px-8 py-5"
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
size={"sm"}
|
||||
onClick={saveSettings}
|
||||
disabled={!hasChanges || !permission.dns.update}
|
||||
data-testid={"save-changes"}
|
||||
>
|
||||
{t("saveChanges")}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user