Compare commits
136 Commits
v2.22.2
...
localize-z
| Author | SHA1 | Date | |
|---|---|---|---|
| 9bb8d2f09a | |||
| 083b5dbdbe | |||
| fdcf641f29 | |||
| b13b7de232 | |||
| 92dc5b26c6 | |||
| 1514a48f2b | |||
| 43a9649e15 | |||
| d9b9bb59ac | |||
|
|
7e4e2f0169 | ||
|
|
2ce9f9020d | ||
| 2bd2e03fd6 | |||
| 5eb928f6bb | |||
| 3bb1a61c3f | |||
| 46d20e5877 | |||
| a6f46b1a47 | |||
|
|
3cb8244d77 | ||
|
|
1e0b124a03 | ||
|
|
9d1cd3e189 | ||
|
|
3b870a0e74 | ||
|
|
9bbbeead1a | ||
|
|
fdc7af186e | ||
|
|
76529fc089 | ||
|
|
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 | ||
|
|
92676b6c38 | ||
|
|
3affa8908f | ||
|
|
52fd984912 | ||
|
|
83e3159ee4 | ||
|
|
bf81aeb02d | ||
|
|
b058e66e32 | ||
|
|
8d6b617cbd | ||
|
|
47db655e9f | ||
|
|
0661cbf9f4 | ||
|
|
240a96fa8b |
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
|
||||||
69
.github/workflows/build_and_push.yml
vendored
69
.github/workflows/build_and_push.yml
vendored
@@ -2,25 +2,31 @@ name: build and push
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- "feature/**"
|
|
||||||
- main
|
- main
|
||||||
tags:
|
tags:
|
||||||
- "**"
|
- "**"
|
||||||
pull_request:
|
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:
|
env:
|
||||||
IMAGE_NAME: netbirdio/dashboard
|
DOCKERHUB_IMAGE: netbirdio/dashboard
|
||||||
|
GHCR_IMAGE: ghcr.io/netbirdio/dashboard-cloud
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build_n_push:
|
build_n_push:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: setup-node
|
- name: setup-node
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: '18'
|
node-version: '20'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
@@ -55,29 +61,58 @@ jobs:
|
|||||||
fileName: "ironrdp_web_bg.wasm"
|
fileName: "ironrdp_web_bg.wasm"
|
||||||
out-file-path: 'public/ironrdp-pkg'
|
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
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
env:
|
||||||
|
NEXT_PUBLIC_DASHBOARD_VERSION: ${{ steps.version.outputs.version }}
|
||||||
-
|
-
|
||||||
name: Set up QEMU
|
name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-qemu-action@v3
|
||||||
-
|
-
|
||||||
name: Set up Docker Buildx
|
name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@v3
|
||||||
-
|
-
|
||||||
name: Docker meta
|
name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v4
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: ${{ env.IMAGE_NAME }}
|
images: |
|
||||||
-
|
${{ env.DOCKERHUB_IMAGE }}
|
||||||
name: Login to DockerHub
|
${{ env.GHCR_IMAGE }}
|
||||||
uses: docker/login-action@v2
|
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:
|
with:
|
||||||
username: ${{ secrets.NB_DOCKER_USER }}
|
username: ${{ secrets.NB_DOCKER_USER }}
|
||||||
password: ${{ secrets.NB_DOCKER_TOKEN }}
|
password: ${{ secrets.NB_DOCKER_TOKEN }}
|
||||||
-
|
|
||||||
name: Docker build and push
|
- name: Log in to the GitHub Container registry
|
||||||
uses: docker/build-push-action@v3
|
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:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: docker/Dockerfile
|
file: docker/Dockerfile
|
||||||
@@ -85,3 +120,7 @@ jobs:
|
|||||||
platforms: linux/amd64,linux/arm64,linux/arm
|
platforms: linux/amd64,linux/arm64,linux/arm
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
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
|
next-env.d.ts
|
||||||
|
|
||||||
# config
|
# 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/.local-config.zitadel.json
|
||||||
.configs/.staging-config.json
|
.configs/.staging-config.json
|
||||||
.configs/.temp-config.json
|
.configs/.temp-config.json
|
||||||
|
|||||||
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)
|
Mikhail Bragin (https://github.com/braginini)
|
||||||
Maycon Santos (https://github.com/mlsmaycon)
|
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
|
## Contributor License Agreement
|
||||||
|
|
||||||
This Contributor License Agreement (referred to as the "Agreement") is entered into by the individual
|
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
|
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
|
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
|
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 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:
|
The dashboard makes it possible to:
|
||||||
|
|
||||||
- track the status of your peers
|
- track the status of your peers
|
||||||
- remove peers
|
- remove peers
|
||||||
- manage Setup Keys (to authenticate new peers)
|
- manage Setup Keys (to authenticate new peers)
|
||||||
@@ -17,10 +18,10 @@ The dashboard makes it possible to:
|
|||||||
- define access controls
|
- define access controls
|
||||||
|
|
||||||
## Some Screenshots
|
## Some Screenshots
|
||||||
|
|
||||||
<img src="./src/assets/screenshots/peers.png" alt="peers"/>
|
<img src="./src/assets/screenshots/peers.png" alt="peers"/>
|
||||||
<img src="./src/assets/screenshots/add-peer.png" alt="add-peer"/>
|
<img src="./src/assets/screenshots/add-peer.png" alt="add-peer"/>
|
||||||
|
|
||||||
|
|
||||||
## Technologies Used
|
## Technologies Used
|
||||||
|
|
||||||
- NextJS
|
- NextJS
|
||||||
@@ -33,8 +34,9 @@ The dashboard makes it possible to:
|
|||||||
- Let's Encrypt
|
- Let's Encrypt
|
||||||
|
|
||||||
## How to run
|
## 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
|
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.
|
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/)
|
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`
|
`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):
|
5. Run docker container without SSL (Let's Encrypt):
|
||||||
|
|
||||||
```shell
|
```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_DOMAIN=<SET YOUR AUTH DOMAIN> \
|
||||||
-e AUTH0_CLIENT_ID=<SET YOUR CLIENT ID> \
|
-e AUTH0_CLIENT_ID=<SET YOUR CLIENT ID> \
|
||||||
-e AUTH0_AUDIENCE=<SET YOUR AUDIENCE> \
|
-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
|
netbirdio/dashboard:main
|
||||||
```
|
```
|
||||||
|
|
||||||
6. Run docker container with SSL (Let's Encrypt):
|
6. Run docker container with SSL (Let's Encrypt):
|
||||||
|
|
||||||
```shell
|
```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_DOMAIN=<SET YOUR AUTH DOMAIN> \
|
||||||
-e AUTH0_CLIENT_ID=<SET YOUR CLEITN ID> \
|
-e AUTH0_CLIENT_ID=<SET YOUR CLEITN ID> \
|
||||||
-e AUTH0_AUDIENCE=<SET YOUR AUDIENCE> \
|
-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
|
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/..`
|
You can start editing by modifying the code inside `src/..`
|
||||||
The page auto-updates as you edit the file.
|
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`.
|
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.
|
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`
|
1. Stop the dashboard container `docker compose down dashboard`
|
||||||
2. Replace the docker image name in your `docker-compose.yml` with `netbirdio/dashboard:main`
|
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!")
|
||||||
12
config.json
12
config.json
@@ -14,5 +14,13 @@
|
|||||||
"hotjarTrackID": "$NETBIRD_HOTJAR_TRACK_ID",
|
"hotjarTrackID": "$NETBIRD_HOTJAR_TRACK_ID",
|
||||||
"googleAnalyticsID": "$NETBIRD_GOOGLE_ANALYTICS_ID",
|
"googleAnalyticsID": "$NETBIRD_GOOGLE_ANALYTICS_ID",
|
||||||
"googleTagManagerID": "$NETBIRD_GOOGLE_TAG_MANAGER_ID",
|
"googleTagManagerID": "$NETBIRD_GOOGLE_TAG_MANAGER_ID",
|
||||||
"wasmPath": "$NETBIRD_WASM_PATH"
|
"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
|
FROM node:22-alpine
|
||||||
|
|
||||||
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"]
|
|
||||||
|
|
||||||
WORKDIR /usr/share/nginx/html
|
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 build files
|
||||||
COPY docker/supervisord.conf /etc/supervisord.conf
|
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 / {
|
location / {
|
||||||
try_files $uri $uri.html $uri/ =404;
|
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;
|
expires off;
|
||||||
}
|
}
|
||||||
|
|
||||||
error_page 404 /404.html;
|
error_page 404 /404.html;
|
||||||
location = /404.html {
|
location = /404.html {
|
||||||
internal;
|
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;
|
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_GOOGLE_TAG_MANAGER_ID=${NETBIRD_GOOGLE_TAG_MANAGER_ID}
|
||||||
export NETBIRD_TOKEN_SOURCE=${NETBIRD_TOKEN_SOURCE:-accessToken}
|
export NETBIRD_TOKEN_SOURCE=${NETBIRD_TOKEN_SOURCE:-accessToken}
|
||||||
export NETBIRD_DRAG_QUERY_PARAMS=${NETBIRD_DRAG_QUERY_PARAMS:-false}
|
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_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}"
|
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
|
# 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"
|
OIDC_TRUSTED_DOMAINS="/usr/share/nginx/html/OidcTrustedDomains.js"
|
||||||
envsubst "$ENV_STR" < "$OIDC_TRUSTED_DOMAINS".tmpl > "$OIDC_TRUSTED_DOMAINS"
|
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/`
|
||||||
1064
docs/i18n-reports/en-string-baseline.json
Normal file
1064
docs/i18n-reports/en-string-baseline.json
Normal file
File diff suppressed because it is too large
Load Diff
1
docs/i18n-reports/phase1-common-components.json
Normal file
1
docs/i18n-reports/phase1-common-components.json
Normal file
File diff suppressed because one or more lines are too long
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} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
output: "export",
|
output: "export",
|
||||||
@@ -7,7 +11,9 @@ const nextConfig = {
|
|||||||
reactStrictMode: false,
|
reactStrictMode: false,
|
||||||
env: {
|
env: {
|
||||||
APP_ENV: process.env.APP_ENV || "production",
|
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);
|
||||||
|
|||||||
9846
package-lock.json
generated
9846
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
96
package.json
96
package.json
@@ -2,45 +2,54 @@
|
|||||||
"name": "netbird-dashboard",
|
"name": "netbird-dashboard",
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.9.0"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"copy": "copyfiles -f ./node_modules/@axa-fr/react-oidc/dist/OidcServiceWorker.js ./public",
|
"copy": "copyfiles -f ./node_modules/@axa-fr/react-oidc/dist/OidcServiceWorker.js ./public",
|
||||||
"copytrusted": "copyfiles -f ./public/local/OidcTrustedDomains.js ./public",
|
"copytrusted": "copyfiles -f ./public/local/OidcTrustedDomains.js ./public",
|
||||||
"dev": "next dev -p 3000",
|
"dev": "next dev -p 3000",
|
||||||
"turbo": "next dev -p 3000 --turbo",
|
"turbo": "next dev -p 3000 --turbo",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
|
"postbuild": "node postbuild.js",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"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": {
|
"dependencies": {
|
||||||
"@axa-fr/react-oidc": "^7.22.18",
|
"@axa-fr/react-oidc": "^7.26.3",
|
||||||
"@dagrejs/dagre": "^1.1.5",
|
"@dagrejs/dagre": "^1.1.5",
|
||||||
"@radix-ui/react-accordion": "^1.1.2",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-checkbox": "^1.0.4",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-collapsible": "^1.0.3",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-dialog": "^1.0.5",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-hover-card": "^1.1.4",
|
"@radix-ui/react-hover-card": "^1.1.15",
|
||||||
"@radix-ui/react-label": "^2.0.2",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
"@radix-ui/react-popover": "^1.0.7",
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
"@radix-ui/react-radio-group": "^1.1.3",
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
"@radix-ui/react-scroll-area": "^1.1.0",
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
"@radix-ui/react-select": "^2.0.0",
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
"@radix-ui/react-slider": "^1.1.2",
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
"@radix-ui/react-slot": "^1.0.2",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.0.3",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
"@radix-ui/react-tabs": "^1.0.4",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-toast": "^1.1.5",
|
"@radix-ui/react-toast": "^1.2.15",
|
||||||
"@radix-ui/react-tooltip": "^1.0.7",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@tabler/icons-react": "^2.39.0",
|
"@tabler/icons-react": "^3.36.1",
|
||||||
"@tanstack/match-sorter-utils": "^8.8.4",
|
"@tanstack/match-sorter-utils": "^8.8.4",
|
||||||
"@tanstack/react-table": "^8.10.7",
|
"@tanstack/react-table": "^8.10.7",
|
||||||
"@types/crypto-js": "^4.2.2",
|
"@types/crypto-js": "^4.2.2",
|
||||||
"@types/d3": "^7.4.3",
|
"@types/d3": "^7.4.3",
|
||||||
"@types/lodash": "^4.14.200",
|
"@types/lodash": "4.17.24",
|
||||||
"@types/node": "20.10.6",
|
"@types/node": "20.10.6",
|
||||||
"@types/react": "^18",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^19",
|
||||||
"@types/react-window": "^1.8.8",
|
"@types/react-window": "^1.8.8",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
@@ -49,50 +58,59 @@
|
|||||||
"chart.js": "^4.4.8",
|
"chart.js": "^4.4.8",
|
||||||
"chroma-js": "^3.1.2",
|
"chroma-js": "^3.1.2",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
|
"classnames": "^2.5.1",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"cmdk": "^0.2.0",
|
"cmdk": "^1.1.1",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
"date-fns": "^2.30.0",
|
"date-fns": "^2.30.0",
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
"elkjs": "^0.10.0",
|
"elkjs": "^0.10.0",
|
||||||
"eslint": "^8",
|
|
||||||
"eslint-config-prettier": "^9.0.0",
|
"eslint-config-prettier": "^9.0.0",
|
||||||
"eslint-plugin-simple-import-sort": "^10.0.0",
|
"eslint-plugin-simple-import-sort": "^10.0.0",
|
||||||
"flowbite": "^1.8.1",
|
"framer-motion": "^12.29.2",
|
||||||
"flowbite-react": "^0.6.4",
|
"ip-address": "^10.2.0",
|
||||||
"framer-motion": "^10.16.4",
|
|
||||||
"ip-cidr": "^3.1.0",
|
"ip-cidr": "^3.1.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.7",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "4.18.1",
|
||||||
"lucide-react": "^0.539.0",
|
"lucide-react": "^0.566.0",
|
||||||
"next": "^14.2.28",
|
"next": "16.1.7",
|
||||||
|
"next-intl": "^4.13.0",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"punycode": "^2.3.1",
|
"punycode": "^2.3.1",
|
||||||
"react": "^18.3.1",
|
"react": "^19.2.4",
|
||||||
"react-day-picker": "^8.9.1",
|
"react-chartjs-2": "^5.3.0",
|
||||||
"react-dom": "^18.3.1",
|
"react-confetti-explosion": "^3.0.3",
|
||||||
|
"react-day-picker": "^9.13.0",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
"react-ga4": "^2.1.0",
|
"react-ga4": "^2.1.0",
|
||||||
"react-hot-toast": "^2.4.1",
|
"react-hotjar": "^6.3.1",
|
||||||
"react-hotjar": "^6.2.0",
|
|
||||||
"react-hotkeys-hook": "^4.4.1",
|
"react-hotkeys-hook": "^4.4.1",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
"react-jwt": "^1.2.0",
|
"react-jwt": "^1.2.0",
|
||||||
"react-loading-skeleton": "^3.3.1",
|
"react-loading-skeleton": "^3.3.1",
|
||||||
"react-responsive": "^9.0.2",
|
"react-responsive": "^9.0.2",
|
||||||
"react-virtuoso": "^4.9.0",
|
"react-virtuoso": "^4.9.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"swr": "^2.2.4",
|
"swr": "^2.2.4",
|
||||||
"tailwind-merge": "^1.14.0",
|
"tailwind-merge": "^1.14.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"timescape": "^0.7.1",
|
"timescape": "^0.7.1",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
},
|
},
|
||||||
|
"overrides": {
|
||||||
|
"minimatch": ">=10.2.1"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@faker-js/faker": "^9.5.1",
|
"@faker-js/faker": "^9.5.1",
|
||||||
"@types/chroma-js": "^3.1.1",
|
"@types/chroma-js": "^3.1.1",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
"eslint-config-next": "^14.2.28",
|
"@playwright/test": "^1.52.0",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-config-next": "^16.1.6",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"prettier": "3.0.3",
|
"prettier": "3.0.3",
|
||||||
"tailwindcss": "^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() {
|
export default function Redirect() {
|
||||||
useRedirect("/events/audit");
|
useRedirect("/events/audit");
|
||||||
return <FullScreenLoading height={"auto"} />;
|
return <FullScreenLoading fullScreen={false} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
import { globalMetaTitle } from "@utils/meta";
|
import { globalMetaTitle } from "@utils/meta";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import BlankLayout from "@/layouts/BlankLayout";
|
import BlankLayout from "@/layouts/BlankLayout";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
title: `Access Control - ${globalMetaTitle}`,
|
const t = await getTranslations();
|
||||||
};
|
return {
|
||||||
|
title: `${t("navigation.accessControl")} - ${globalMetaTitle}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default BlankLayout;
|
export default BlankLayout;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
|||||||
import { usePortalElement } from "@hooks/usePortalElement";
|
import { usePortalElement } from "@hooks/usePortalElement";
|
||||||
import useFetchApi from "@utils/api";
|
import useFetchApi from "@utils/api";
|
||||||
import { ExternalLinkIcon } from "lucide-react";
|
import { ExternalLinkIcon } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import React, { lazy, Suspense } from "react";
|
import React, { lazy, Suspense } from "react";
|
||||||
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
|
import AccessControlIcon from "@/assets/icons/AccessControlIcon";
|
||||||
import GroupsProvider from "@/contexts/GroupsProvider";
|
import GroupsProvider from "@/contexts/GroupsProvider";
|
||||||
@@ -17,60 +18,56 @@ import { Policy } from "@/interfaces/Policy";
|
|||||||
import PageContainer from "@/layouts/PageContainer";
|
import PageContainer from "@/layouts/PageContainer";
|
||||||
|
|
||||||
const AccessControlTable = lazy(
|
const AccessControlTable = lazy(
|
||||||
() => import("@/modules/access-control/table/AccessControlTable"),
|
() => import("@/modules/access-control/table/AccessControlTable"),
|
||||||
);
|
);
|
||||||
export default function AccessControlPage() {
|
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 } =
|
const { ref: headingRef, portalTarget } =
|
||||||
usePortalElement<HTMLHeadingElement>();
|
usePortalElement<HTMLHeadingElement>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<GroupsProvider>
|
<GroupsProvider>
|
||||||
<div className={"p-default py-6"}>
|
<div className={"p-default py-6"}>
|
||||||
<Breadcrumbs>
|
<Breadcrumbs>
|
||||||
<Breadcrumbs.Item
|
<Breadcrumbs.Item
|
||||||
href={"/access-control"}
|
href={"/access-control"}
|
||||||
label={"Access Control"}
|
label={t("title")}
|
||||||
icon={<AccessControlIcon size={14} />}
|
icon={<AccessControlIcon size={14} />}
|
||||||
/>
|
/>
|
||||||
</Breadcrumbs>
|
</Breadcrumbs>
|
||||||
<h1 ref={headingRef}>Access Control Policies</h1>
|
<h1 ref={headingRef}>{t("title")}</h1>
|
||||||
<Paragraph>
|
<Paragraph>
|
||||||
Create rules to manage access in your network and define what peers
|
{t("accessControlDescription")}{" "}
|
||||||
can connect.
|
<InlineLink
|
||||||
</Paragraph>
|
href={"https://docs.netbird.io/how-to/manage-network-access"}
|
||||||
<Paragraph>
|
target={"_blank"}
|
||||||
Learn more about
|
>
|
||||||
<InlineLink
|
{t("learnMore")}
|
||||||
href={"https://docs.netbird.io/how-to/manage-network-access"}
|
<ExternalLinkIcon size={12} />
|
||||||
target={"_blank"}
|
</InlineLink>
|
||||||
>
|
</Paragraph>
|
||||||
Access Controls
|
</div>
|
||||||
<ExternalLinkIcon size={12} />
|
|
||||||
</InlineLink>
|
|
||||||
in our documentation.
|
|
||||||
</Paragraph>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<RestrictedAccess
|
<RestrictedAccess
|
||||||
page={"Access Control"}
|
page={t("title")}
|
||||||
hasAccess={permission.policies.read}
|
hasAccess={permission.policies.read}
|
||||||
>
|
>
|
||||||
<PoliciesProvider>
|
<PoliciesProvider>
|
||||||
<Suspense fallback={<SkeletonTable />}>
|
<Suspense fallback={<SkeletonTable />}>
|
||||||
<AccessControlTable
|
<AccessControlTable
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
policies={policies}
|
policies={policies}
|
||||||
headingTarget={portalTarget}
|
headingTarget={portalTarget}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</PoliciesProvider>
|
</PoliciesProvider>
|
||||||
</RestrictedAccess>
|
</RestrictedAccess>
|
||||||
</GroupsProvider>
|
</GroupsProvider>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
import { globalMetaTitle } from "@utils/meta";
|
import { globalMetaTitle } from "@utils/meta";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import BlankLayout from "@/layouts/BlankLayout";
|
import BlankLayout from "@/layouts/BlankLayout";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
title: `Control Center - ${globalMetaTitle}`,
|
const t = await getTranslations();
|
||||||
};
|
return {
|
||||||
|
title: `${t("navigation.controlCenter")} - ${globalMetaTitle}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default BlankLayout;
|
export default BlankLayout;
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
import "@xyflow/react/dist/style.css";
|
import "@xyflow/react/dist/style.css";
|
||||||
import Button from "@components/Button";
|
import Button from "@components/Button";
|
||||||
import {
|
import InlineLink from "@components/InlineLink";
|
||||||
SelectDropdown,
|
import { NoPeersGettingStarted } from "@components/NoPeersGettingStarted";
|
||||||
SelectOption,
|
import { SelectDropdown, SelectOption } from "@components/select/SelectDropdown";
|
||||||
} from "@components/select/SelectDropdown";
|
import SquareIcon from "@components/SquareIcon";
|
||||||
|
import GetStartedTest from "@components/ui/GetStartedTest";
|
||||||
|
import { SmallBadge } from "@components/ui/SmallBadge";
|
||||||
import useFetchApi from "@utils/api";
|
import useFetchApi from "@utils/api";
|
||||||
import {
|
import {
|
||||||
Background,
|
Background,
|
||||||
@@ -15,50 +17,42 @@ import {
|
|||||||
NodeTypes,
|
NodeTypes,
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
ReactFlowProvider,
|
ReactFlowProvider,
|
||||||
useReactFlow,
|
useEdgesState,
|
||||||
|
useNodesState,
|
||||||
|
useReactFlow
|
||||||
} from "@xyflow/react";
|
} from "@xyflow/react";
|
||||||
import { forEach, orderBy, sortBy } from "lodash";
|
import { forEach, orderBy, sortBy } from "lodash";
|
||||||
import {
|
import { ArrowLeftIcon, ExternalLinkIcon, LayoutGridIcon, MessageSquareShareIcon, NetworkIcon } from "lucide-react";
|
||||||
ArrowLeftIcon,
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
ExternalLinkIcon,
|
|
||||||
LayoutGridIcon,
|
|
||||||
MessageSquareShareIcon,
|
|
||||||
NetworkIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
|
||||||
|
import PeersProvider from "@/contexts/PeersProvider";
|
||||||
|
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||||
|
import PoliciesProvider from "@/contexts/PoliciesProvider";
|
||||||
|
import { useLoggedInUser } from "@/contexts/UsersProvider";
|
||||||
|
import { Group } from "@/interfaces/Group";
|
||||||
|
import { Network, NetworkResource } from "@/interfaces/Network";
|
||||||
|
import { Peer } from "@/interfaces/Peer";
|
||||||
|
import { Policy } from "@/interfaces/Policy";
|
||||||
|
import { User } from "@/interfaces/User";
|
||||||
|
import PageContainer from "@/layouts/PageContainer";
|
||||||
|
import { AccessControlUpdateModal } from "@/modules/access-control/AccessControlModal";
|
||||||
import { FlowSelector, FlowView } from "@/modules/control-center/FlowSelector";
|
import { FlowSelector, FlowView } from "@/modules/control-center/FlowSelector";
|
||||||
import { NetworkRoutingPeerCount } from "@/modules/control-center/NetworkRoutingPeerCount";
|
import { NetworkRoutingPeerCount } from "@/modules/control-center/NetworkRoutingPeerCount";
|
||||||
|
import { ControlCenterCurrentUserBadge } from "@/modules/control-center/user/ControlCenterCurrentUserBadge";
|
||||||
import { EDGE_TYPES } from "@/modules/control-center/utils/edges";
|
import { EDGE_TYPES } from "@/modules/control-center/utils/edges";
|
||||||
import {
|
import {
|
||||||
getFirstGroup,
|
getFirstGroup,
|
||||||
getPolicyProtocolAndPortText,
|
getPolicyProtocolAndPortText,
|
||||||
getResourcePolicyByGroups,
|
getResourcePolicyByGroups
|
||||||
} from "@/modules/control-center/utils/helpers";
|
} from "@/modules/control-center/utils/helpers";
|
||||||
import {
|
import {
|
||||||
applyD3ForceLayout,
|
applyD3ForceLayout,
|
||||||
applyD3HierarchicalLayout,
|
applyD3HierarchicalLayout,
|
||||||
DEFAULT_MAX_ZOOM,
|
DEFAULT_MAX_ZOOM,
|
||||||
DEFAULT_MIN_ZOOM,
|
DEFAULT_MIN_ZOOM
|
||||||
} from "@/modules/control-center/utils/layouts";
|
} from "@/modules/control-center/utils/layouts";
|
||||||
import { NODE_TYPES } from "@/modules/control-center/utils/nodes";
|
import { NODE_TYPES } from "@/modules/control-center/utils/nodes";
|
||||||
import PeersProvider from "@/contexts/PeersProvider";
|
|
||||||
import PoliciesProvider from "@/contexts/PoliciesProvider";
|
|
||||||
import { Group } from "@/interfaces/Group";
|
|
||||||
import { Network, NetworkResource } from "@/interfaces/Network";
|
|
||||||
import { OperatingSystem } from "@/interfaces/OperatingSystem";
|
|
||||||
import { Peer } from "@/interfaces/Peer";
|
|
||||||
import { Policy } from "@/interfaces/Policy";
|
|
||||||
import PageContainer from "@/layouts/PageContainer";
|
|
||||||
import { useLoggedInUser } from "@/contexts/UsersProvider";
|
|
||||||
import { AccessControlUpdateModal } from "@/modules/access-control/AccessControlModal";
|
|
||||||
import { NoPeersGettingStarted } from "@components/NoPeersGettingStarted";
|
|
||||||
import GetStartedTest from "@components/ui/GetStartedTest";
|
|
||||||
import SquareIcon from "@components/SquareIcon";
|
|
||||||
import NetworkRoutesIcon from "@/assets/icons/NetworkRoutesIcon";
|
|
||||||
import InlineLink from "@components/InlineLink";
|
|
||||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
|
||||||
import { SmallBadge } from "@components/ui/SmallBadge";
|
|
||||||
|
|
||||||
export default function ControlCenter() {
|
export default function ControlCenter() {
|
||||||
return (
|
return (
|
||||||
@@ -71,8 +65,8 @@ export default function ControlCenter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ControlCenterView() {
|
function ControlCenterView() {
|
||||||
const [nodes, setNodes] = useState<Node[]>([]);
|
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
|
||||||
const [edges, setEdges] = useState<Edge[]>([]);
|
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||||
const reactFlow = useReactFlow();
|
const reactFlow = useReactFlow();
|
||||||
const [layoutInitialized, setLayoutInitialized] = useState(false);
|
const [layoutInitialized, setLayoutInitialized] = useState(false);
|
||||||
const [forceLayoutChange, setForceLayoutChange] = useState(false);
|
const [forceLayoutChange, setForceLayoutChange] = useState(false);
|
||||||
@@ -82,6 +76,7 @@ function ControlCenterView() {
|
|||||||
const queryTab = queryParams.get("tab");
|
const queryTab = queryParams.get("tab");
|
||||||
const initialTab = useMemo(() => {
|
const initialTab = useMemo(() => {
|
||||||
if (queryTab === "peers") return FlowView.PEERS;
|
if (queryTab === "peers") return FlowView.PEERS;
|
||||||
|
if (queryTab === "users") return FlowView.USERS;
|
||||||
if (queryTab === "groups") return FlowView.GROUPS;
|
if (queryTab === "groups") return FlowView.GROUPS;
|
||||||
if (queryTab === "networks") return FlowView.NETWORKS;
|
if (queryTab === "networks") return FlowView.NETWORKS;
|
||||||
return FlowView.PEERS;
|
return FlowView.PEERS;
|
||||||
@@ -99,17 +94,24 @@ function ControlCenterView() {
|
|||||||
>("/networks/resources");
|
>("/networks/resources");
|
||||||
const { data: groups, isLoading: isGroupsLoading } =
|
const { data: groups, isLoading: isGroupsLoading } =
|
||||||
useFetchApi<Group[]>("/groups");
|
useFetchApi<Group[]>("/groups");
|
||||||
|
const { data: users, isLoading: isUsersLoading } = useFetchApi<User[]>(
|
||||||
|
"/users?service_user=false",
|
||||||
|
);
|
||||||
|
|
||||||
const isLoading =
|
const isLoading =
|
||||||
isPoliciesLoading ||
|
isPoliciesLoading ||
|
||||||
isPeersLoading ||
|
isPeersLoading ||
|
||||||
isNetworksLoading ||
|
isNetworksLoading ||
|
||||||
isResourcesLoading ||
|
isResourcesLoading ||
|
||||||
isGroupsLoading;
|
isGroupsLoading ||
|
||||||
|
isUsersLoading;
|
||||||
|
|
||||||
const [selectedNetwork, setSelectedNetwork] = useState("");
|
const [selectedNetwork, setSelectedNetwork] = useState("");
|
||||||
const [selectedGroup, setSelectedGroup] = useState("");
|
const [selectedGroup, setSelectedGroup] = useState("");
|
||||||
const [selectedPeer, setSelectedPeer] = useState("");
|
const [selectedPeer, setSelectedPeer] = useState("");
|
||||||
|
const [selectedUser, setSelectedUser] = useState("");
|
||||||
|
const [previousSelectedUser, setPreviousSelectedUser] = useState("");
|
||||||
|
|
||||||
const [selectedPolicy, setSelectedPolicy] = useState("");
|
const [selectedPolicy, setSelectedPolicy] = useState("");
|
||||||
const [selectedDestinationGroup, setSelectedDestinationGroup] = useState("");
|
const [selectedDestinationGroup, setSelectedDestinationGroup] = useState("");
|
||||||
|
|
||||||
@@ -138,14 +140,149 @@ function ControlCenterView() {
|
|||||||
|
|
||||||
const onDestinationGroupSelect = useCallback(
|
const onDestinationGroupSelect = useCallback(
|
||||||
(groupId: string) => {
|
(groupId: string) => {
|
||||||
setLayoutInitialized(false);
|
const isTogglingSameGroup = selectedDestinationGroup === groupId;
|
||||||
if (selectedDestinationGroup == groupId) {
|
const newSelectedGroup = isTogglingSameGroup ? "" : groupId;
|
||||||
setSelectedDestinationGroup("");
|
|
||||||
} else {
|
setSelectedDestinationGroup(newSelectedGroup);
|
||||||
setSelectedDestinationGroup(groupId);
|
|
||||||
|
if (
|
||||||
|
currentView !== FlowView.PEERS &&
|
||||||
|
currentView !== FlowView.GROUPS &&
|
||||||
|
currentView !== FlowView.USERS
|
||||||
|
) {
|
||||||
|
setLayoutInitialized(false);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getPeersAndResources = (groupId: string) => {
|
||||||
|
const resources =
|
||||||
|
networkResources?.filter((n) => {
|
||||||
|
const resourceGroupIds =
|
||||||
|
n.groups?.map((g) => (g as Group)?.id) || [];
|
||||||
|
return resourceGroupIds.includes(groupId);
|
||||||
|
}) || [];
|
||||||
|
|
||||||
|
const groupPeers =
|
||||||
|
peers?.filter((p) => {
|
||||||
|
const peerGroupIds = p.groups?.map((g) => g.id) || [];
|
||||||
|
return peerGroupIds.includes(groupId);
|
||||||
|
}) || [];
|
||||||
|
|
||||||
|
return { resources, peers: groupPeers };
|
||||||
|
};
|
||||||
|
|
||||||
|
const addExpandedNodes = (groupId: string, baseNodes: Node[]) => {
|
||||||
|
const { resources, peers } = getPeersAndResources(groupId);
|
||||||
|
const destinationGroupNode = baseNodes.find(
|
||||||
|
(node) => node.id === `group-${groupId}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!destinationGroupNode) return [];
|
||||||
|
|
||||||
|
const baseX = destinationGroupNode.position.x + 300;
|
||||||
|
const groupCenterY = destinationGroupNode.position.y;
|
||||||
|
const nodeSpacing = 80;
|
||||||
|
const totalNodes = peers.length + resources.length;
|
||||||
|
const totalHeight = (totalNodes - 1) * nodeSpacing;
|
||||||
|
const startY = groupCenterY - totalHeight / 2;
|
||||||
|
|
||||||
|
const newNodes: Node[] = [];
|
||||||
|
let currentY = startY;
|
||||||
|
|
||||||
|
// Add peer nodes
|
||||||
|
peers.forEach((peer) => {
|
||||||
|
newNodes.push({
|
||||||
|
id: `peer-${peer.id}`,
|
||||||
|
type:
|
||||||
|
currentView === FlowView.PEERS ? "expandedGroupPeer" : "peerNode",
|
||||||
|
data: { peer },
|
||||||
|
position: { x: baseX, y: currentY },
|
||||||
|
});
|
||||||
|
currentY += nodeSpacing;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add resource nodes
|
||||||
|
resources.forEach((resource) => {
|
||||||
|
newNodes.push({
|
||||||
|
id: `resource-${resource.id}`,
|
||||||
|
type: "resourceNode",
|
||||||
|
data: { resource },
|
||||||
|
position: { x: baseX, y: currentY },
|
||||||
|
});
|
||||||
|
currentY += nodeSpacing;
|
||||||
|
});
|
||||||
|
|
||||||
|
return newNodes;
|
||||||
|
};
|
||||||
|
|
||||||
|
const addExpandedEdges = (groupId: string) => {
|
||||||
|
const { resources, peers } = getPeersAndResources(groupId);
|
||||||
|
const newEdges: Edge[] = [];
|
||||||
|
|
||||||
|
// Add peer edges
|
||||||
|
peers.forEach((peer) => {
|
||||||
|
newEdges.push({
|
||||||
|
id: `group-peer-${groupId}-${peer.id}`,
|
||||||
|
source: `group-${groupId}`,
|
||||||
|
target: `peer-${peer.id}`,
|
||||||
|
type: "simple",
|
||||||
|
data: { enabled: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add resource edges
|
||||||
|
resources.forEach((resource) => {
|
||||||
|
newEdges.push({
|
||||||
|
id: `group-resource-${groupId}-${resource.id}`,
|
||||||
|
source: `group-${groupId}`,
|
||||||
|
target: `resource-${resource.id}`,
|
||||||
|
type: "simple",
|
||||||
|
data: { enabled: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return newEdges;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update nodes
|
||||||
|
setNodes((prevNodes) => {
|
||||||
|
// Remove previous nodes
|
||||||
|
const baseNodes = prevNodes.filter(
|
||||||
|
(node) =>
|
||||||
|
!node.id.startsWith(`peer-`) && !node.id.startsWith(`resource-`),
|
||||||
|
);
|
||||||
|
// If toggling a new group, add its nodes
|
||||||
|
if (!isTogglingSameGroup) {
|
||||||
|
const expandedNodes = addExpandedNodes(groupId, baseNodes);
|
||||||
|
return [...baseNodes, ...expandedNodes];
|
||||||
|
}
|
||||||
|
return baseNodes;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update edges
|
||||||
|
setEdges((prevEdges) => {
|
||||||
|
// Remove all previously expanded peer/resource edges
|
||||||
|
const baseEdges = prevEdges.filter(
|
||||||
|
(edge) =>
|
||||||
|
!edge.id.includes(`group-peer-`) &&
|
||||||
|
!edge.id.includes(`group-resource-`),
|
||||||
|
);
|
||||||
|
// If expanding a new group, add its edges
|
||||||
|
if (!isTogglingSameGroup) {
|
||||||
|
const expandedEdges = addExpandedEdges(groupId);
|
||||||
|
return [...baseEdges, ...expandedEdges];
|
||||||
|
}
|
||||||
|
return baseEdges;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[selectedDestinationGroup],
|
[
|
||||||
|
selectedDestinationGroup,
|
||||||
|
currentView,
|
||||||
|
setNodes,
|
||||||
|
setEdges,
|
||||||
|
networkResources,
|
||||||
|
peers,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const applySingleGroupView = (groupId: string) => {
|
const applySingleGroupView = (groupId: string) => {
|
||||||
@@ -211,7 +348,6 @@ function ControlCenterView() {
|
|||||||
type: "destinationGroupNode",
|
type: "destinationGroupNode",
|
||||||
data: {
|
data: {
|
||||||
group: destination,
|
group: destination,
|
||||||
enabled,
|
|
||||||
},
|
},
|
||||||
position: { x: 0, y: 0 },
|
position: { x: 0, y: 0 },
|
||||||
});
|
});
|
||||||
@@ -235,7 +371,7 @@ function ControlCenterView() {
|
|||||||
allNodes.push({
|
allNodes.push({
|
||||||
id: peerNodeId,
|
id: peerNodeId,
|
||||||
type: "peerNode",
|
type: "peerNode",
|
||||||
data: { peer, enabled },
|
data: { peer },
|
||||||
position: { x: 0, y: 0 },
|
position: { x: 0, y: 0 },
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -281,7 +417,7 @@ function ControlCenterView() {
|
|||||||
allNodes.push({
|
allNodes.push({
|
||||||
id: resourceNodeId,
|
id: resourceNodeId,
|
||||||
type: "resourceNode",
|
type: "resourceNode",
|
||||||
data: { resource, enabled },
|
data: { resource },
|
||||||
position: { x: 0, y: 0 },
|
position: { x: 0, y: 0 },
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -356,6 +492,9 @@ function ControlCenterView() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add destination resource nodes
|
||||||
|
addDestinationResourceNodes(policy, allNodes, allEdges);
|
||||||
});
|
});
|
||||||
|
|
||||||
return applyD3HierarchicalLayout(allNodes, allEdges, 400, 120, "group", {
|
return applyD3HierarchicalLayout(allNodes, allEdges, 400, 120, "group", {
|
||||||
@@ -645,7 +784,6 @@ function ControlCenterView() {
|
|||||||
if (!groups || isGroupsLoading) return;
|
if (!groups || isGroupsLoading) return;
|
||||||
if (!networks || isNetworksLoading) return;
|
if (!networks || isNetworksLoading) return;
|
||||||
if (!networkResources || isResourcesLoading) return;
|
if (!networkResources || isResourcesLoading) return;
|
||||||
if (layoutInitialized) return;
|
|
||||||
|
|
||||||
const allNodes: Node[] = [];
|
const allNodes: Node[] = [];
|
||||||
const allEdges: Edge[] = [];
|
const allEdges: Edge[] = [];
|
||||||
@@ -704,7 +842,6 @@ function ControlCenterView() {
|
|||||||
type: "destinationGroupNode",
|
type: "destinationGroupNode",
|
||||||
data: {
|
data: {
|
||||||
group: destination,
|
group: destination,
|
||||||
enabled,
|
|
||||||
},
|
},
|
||||||
position: { x: 0, y: 0 },
|
position: { x: 0, y: 0 },
|
||||||
});
|
});
|
||||||
@@ -848,6 +985,9 @@ function ControlCenterView() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add destination resource nodes
|
||||||
|
addDestinationResourceNodes(policy, allNodes, allEdges);
|
||||||
});
|
});
|
||||||
|
|
||||||
return applyD3HierarchicalLayout(allNodes, allEdges, 400, 120, "peer", {
|
return applyD3HierarchicalLayout(allNodes, allEdges, 400, 120, "peer", {
|
||||||
@@ -857,13 +997,290 @@ function ControlCenterView() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addDestinationResourceNodes = (
|
||||||
|
policy: Policy,
|
||||||
|
nodes: Node[],
|
||||||
|
edges: Edge[],
|
||||||
|
) => {
|
||||||
|
const destinationPolicyResource = policy?.rules?.[0].destinationResource;
|
||||||
|
const enabled = policy.enabled;
|
||||||
|
|
||||||
|
if (destinationPolicyResource) {
|
||||||
|
const type = destinationPolicyResource.type;
|
||||||
|
const peer = peers?.find((p) => p.id === destinationPolicyResource.id);
|
||||||
|
const resource = networkResources?.find(
|
||||||
|
(r) => r.id === destinationPolicyResource.id,
|
||||||
|
);
|
||||||
|
const nodeId = `destination-resource-${destinationPolicyResource.id}`;
|
||||||
|
const nodeExists = nodes.some((n) => n.id === nodeId);
|
||||||
|
if (!nodeExists) {
|
||||||
|
if (type === "peer" && peer) {
|
||||||
|
nodes.push({
|
||||||
|
id: nodeId,
|
||||||
|
type: "destinationResourceNode",
|
||||||
|
data: {
|
||||||
|
peer: peer,
|
||||||
|
enabled,
|
||||||
|
className: "pl-3",
|
||||||
|
},
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
});
|
||||||
|
} else if (resource) {
|
||||||
|
nodes.push({
|
||||||
|
id: nodeId,
|
||||||
|
type: "destinationResourceNode",
|
||||||
|
data: {
|
||||||
|
resource: resource,
|
||||||
|
enabled,
|
||||||
|
className: "pl-3",
|
||||||
|
},
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nodes.forEach((n) => {
|
||||||
|
if (n.id === nodeId) {
|
||||||
|
n.data = {
|
||||||
|
...n.data,
|
||||||
|
enabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const edgeExists = edges.some(
|
||||||
|
(e) => e.id === `policy-dest-resource-${policy.id}-${nodeId}`,
|
||||||
|
);
|
||||||
|
if (!edgeExists) {
|
||||||
|
edges.push({
|
||||||
|
id: `policy-dest-resource-${policy.id}-${nodeId}`,
|
||||||
|
source: `policy-${policy.id}`,
|
||||||
|
target: nodeId,
|
||||||
|
type: "in",
|
||||||
|
data: { enabled, type: "bezier" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyUserView = (userId: string) => {
|
||||||
|
if (!policies || isLoading) return;
|
||||||
|
if (!groups || isGroupsLoading) return;
|
||||||
|
if (!networks || isNetworksLoading) return;
|
||||||
|
if (!networkResources || isResourcesLoading) return;
|
||||||
|
|
||||||
|
const allNodes: Node[] = [];
|
||||||
|
const allEdges: Edge[] = [];
|
||||||
|
|
||||||
|
// Get all peers for this user
|
||||||
|
const userPeers = peers?.filter((p) => p.user_id === userId) || [];
|
||||||
|
if (userPeers.length === 0) {
|
||||||
|
return applyD3HierarchicalLayout([], [], 400, 120, "user", {
|
||||||
|
policy: { width: 500, spacing: 60 },
|
||||||
|
destinationGroup: { width: 1000, spacing: 100 },
|
||||||
|
peersAndResources: { width: 1400, spacing: 80 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add peer nodes
|
||||||
|
userPeers.forEach((peer, index) => {
|
||||||
|
allNodes.push({
|
||||||
|
id: `source-peer-${peer.id}`,
|
||||||
|
type: "sourcePeerNode",
|
||||||
|
data: {
|
||||||
|
peer,
|
||||||
|
enabled: true,
|
||||||
|
onClick: () => {
|
||||||
|
setPreviousSelectedUser(userId);
|
||||||
|
forceSinglePeerView(peer.id || "", userId);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
allEdges.push({
|
||||||
|
id: `user-peer-${userId}-${peer.id}`,
|
||||||
|
source: `select-user-node`,
|
||||||
|
target: `source-peer-${peer.id}`,
|
||||||
|
type: "simple",
|
||||||
|
data: { enabled: true },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const allUserGroups = [
|
||||||
|
...new Set(userPeers.flatMap((p) => p.groups?.map((g) => g.id) || [])),
|
||||||
|
];
|
||||||
|
const userPolicies = sortBy(
|
||||||
|
policies?.filter((p) => {
|
||||||
|
const rule = p.rules?.[0];
|
||||||
|
if (!rule) return false;
|
||||||
|
const sources = rule.sources as Group[];
|
||||||
|
return sources?.some((d) => allUserGroups.includes(d.id));
|
||||||
|
}),
|
||||||
|
"enabled",
|
||||||
|
"desc",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add policies and their connections
|
||||||
|
userPolicies?.forEach((policy, policyIndex) => {
|
||||||
|
const enabled = policy.enabled;
|
||||||
|
const policyNodeId = `policy-${policy.id}`;
|
||||||
|
|
||||||
|
allNodes.push({
|
||||||
|
id: policyNodeId,
|
||||||
|
type: "policyNode",
|
||||||
|
data: { policy },
|
||||||
|
position: { x: 600, y: policyIndex * 120 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add peer to policy edges
|
||||||
|
const rule = policy.rules?.[0];
|
||||||
|
const sourcesIds = (rule?.sources as Group[])?.map((g) => g.id) || [];
|
||||||
|
|
||||||
|
userPeers.forEach((peer) => {
|
||||||
|
const peerGroupIds = peer.groups?.map((g) => g.id) || [];
|
||||||
|
const hasSharedGroup = sourcesIds.some((sourceId) =>
|
||||||
|
peerGroupIds.includes(sourceId),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasSharedGroup) {
|
||||||
|
allEdges.push({
|
||||||
|
id: `peer-policy-${peer.id}-${policy.id}`,
|
||||||
|
source: `source-peer-${peer.id}`,
|
||||||
|
target: policyNodeId,
|
||||||
|
type: "in",
|
||||||
|
data: { enabled, type: "bezier" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add destination groups
|
||||||
|
const destinations = (rule?.destinations as Group[]) || [];
|
||||||
|
destinations.forEach((destination, destIndex) => {
|
||||||
|
const destinationNodeId = `group-${destination.id}`;
|
||||||
|
const destinationNodeExists = allNodes.some(
|
||||||
|
(n) => n.id === destinationNodeId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!destinationNodeExists) {
|
||||||
|
allNodes.push({
|
||||||
|
id: destinationNodeId,
|
||||||
|
type: "destinationGroupNode",
|
||||||
|
data: {
|
||||||
|
group: destination,
|
||||||
|
},
|
||||||
|
position: { x: 900, y: policyIndex * 120 + destIndex * 60 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const destinationEdgeExists = allEdges.some(
|
||||||
|
(e) => e.id === `policy-group-${policy.id}-${destination.id}`,
|
||||||
|
);
|
||||||
|
if (!destinationEdgeExists) {
|
||||||
|
allEdges.push({
|
||||||
|
id: `policy-group-${policy.id}-${destination.id}`,
|
||||||
|
source: policyNodeId,
|
||||||
|
target: destinationNodeId,
|
||||||
|
type: "in",
|
||||||
|
data: { enabled, type: "bezier" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add expanded destination group content if selected
|
||||||
|
if (selectedDestinationGroup === destination.id) {
|
||||||
|
const resources = networkResources.filter((n) => {
|
||||||
|
const resourceGroupIds =
|
||||||
|
n.groups?.map((g) => (g as Group)?.id) || [];
|
||||||
|
return resourceGroupIds.includes(destination.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const destinationPeers = peers?.filter((p) => {
|
||||||
|
const peerGroupIds = p.groups?.map((g) => g.id) || [];
|
||||||
|
return peerGroupIds.includes(destination.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add peer nodes
|
||||||
|
destinationPeers?.forEach((peer, peerIndex) => {
|
||||||
|
const peerNodeId = `dest-peer-${peer.id}`;
|
||||||
|
const peerNodeExists = allNodes.some((n) => n.id === peerNodeId);
|
||||||
|
if (!peerNodeExists) {
|
||||||
|
allNodes.push({
|
||||||
|
id: peerNodeId,
|
||||||
|
type: "peerNode",
|
||||||
|
data: { peer },
|
||||||
|
position: { x: 1200, y: policyIndex * 120 + peerIndex * 80 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const peerEdgeExists = allEdges.some(
|
||||||
|
(e) => e.id === `group-peer-${destination.id}-${peer.id}`,
|
||||||
|
);
|
||||||
|
if (!peerEdgeExists) {
|
||||||
|
allEdges.push({
|
||||||
|
id: `group-peer-${destination.id}-${peer.id}`,
|
||||||
|
source: destinationNodeId,
|
||||||
|
target: peerNodeId,
|
||||||
|
type: "simple",
|
||||||
|
data: { enabled },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add resource nodes
|
||||||
|
resources.forEach((resource, resourceIndex) => {
|
||||||
|
const resourceNodeId = `resource-${resource.id}`;
|
||||||
|
const resourceNodeExists = allNodes.some(
|
||||||
|
(n) => n.id === resourceNodeId,
|
||||||
|
);
|
||||||
|
if (!resourceNodeExists) {
|
||||||
|
allNodes.push({
|
||||||
|
id: resourceNodeId,
|
||||||
|
type: "resourceNode",
|
||||||
|
data: { resource },
|
||||||
|
position: {
|
||||||
|
x: 1200,
|
||||||
|
y:
|
||||||
|
policyIndex * 120 +
|
||||||
|
(destinationPeers?.length || 0) * 80 +
|
||||||
|
resourceIndex * 80,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const resourceEdgeExists = allEdges.some(
|
||||||
|
(e) => e.id === `group-resource-${destination.id}-${resource.id}`,
|
||||||
|
);
|
||||||
|
if (!resourceEdgeExists) {
|
||||||
|
allEdges.push({
|
||||||
|
id: `group-resource-${destination.id}-${resource.id}`,
|
||||||
|
source: destinationNodeId,
|
||||||
|
target: resourceNodeId,
|
||||||
|
type: "simple",
|
||||||
|
data: { enabled },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add destination resource nodes
|
||||||
|
addDestinationResourceNodes(policy, allNodes, allEdges);
|
||||||
|
});
|
||||||
|
|
||||||
|
return applyD3HierarchicalLayout(allNodes, allEdges, 400, 120, "user", {
|
||||||
|
policy: { width: 500, spacing: 60 },
|
||||||
|
destinationGroup: { width: 1000, spacing: 100 },
|
||||||
|
peersAndResources: { width: 1400, spacing: 80 },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const fitView = (newNodes?: Node[]) => {
|
const fitView = (newNodes?: Node[]) => {
|
||||||
window.requestAnimationFrame(() =>
|
window.requestAnimationFrame(() =>
|
||||||
reactFlow.fitView({
|
reactFlow.fitView({
|
||||||
nodes: newNodes ?? nodes,
|
nodes: newNodes ?? nodes,
|
||||||
padding: 0.1,
|
padding: 0.1,
|
||||||
duration: 750,
|
duration: 750,
|
||||||
maxZoom: DEFAULT_MAX_ZOOM,
|
maxZoom: 0.8,
|
||||||
minZoom: DEFAULT_MIN_ZOOM,
|
minZoom: DEFAULT_MIN_ZOOM,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -903,6 +1320,76 @@ function ControlCenterView() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePeerChange = (newPeerId: string) => {
|
||||||
|
setNodes((prev) => {
|
||||||
|
const shouldRecalculate = selectedPeer !== newPeerId;
|
||||||
|
shouldRecalculate && setSelectedPeer(newPeerId);
|
||||||
|
|
||||||
|
let selectPeerNode;
|
||||||
|
const previousNodes = prev.map((node) => {
|
||||||
|
if (node.id === `select-peer-node`) {
|
||||||
|
selectPeerNode = shouldRecalculate
|
||||||
|
? {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...node.data,
|
||||||
|
currentPeer: newPeerId,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: node;
|
||||||
|
return selectPeerNode;
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
const result = applyPeerView(newPeerId);
|
||||||
|
if (result && selectPeerNode) {
|
||||||
|
let nodesWithCurrentPeer = result.updatedNodes;
|
||||||
|
nodesWithCurrentPeer.push(selectPeerNode);
|
||||||
|
setEdges(result.updatedEdges);
|
||||||
|
setLayoutInitialized(true);
|
||||||
|
shouldRecalculate && fitView(nodesWithCurrentPeer);
|
||||||
|
return nodesWithCurrentPeer;
|
||||||
|
} else {
|
||||||
|
return previousNodes;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUserChange = (newUserId: string) => {
|
||||||
|
setNodes((prev) => {
|
||||||
|
const shouldRecalculate = selectedUser !== newUserId;
|
||||||
|
shouldRecalculate && setSelectedUser(newUserId);
|
||||||
|
|
||||||
|
let selectUserNode;
|
||||||
|
const previousNodes = prev.map((node) => {
|
||||||
|
if (node.id === `select-user-node`) {
|
||||||
|
selectUserNode = shouldRecalculate
|
||||||
|
? {
|
||||||
|
...node,
|
||||||
|
data: {
|
||||||
|
...node.data,
|
||||||
|
currentUser: newUserId,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: node;
|
||||||
|
return selectUserNode;
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
const result = applyUserView(newUserId);
|
||||||
|
if (result && selectUserNode) {
|
||||||
|
let nodesWithCurrentUser = result.updatedNodes;
|
||||||
|
nodesWithCurrentUser.push(selectUserNode);
|
||||||
|
setEdges(result.updatedEdges);
|
||||||
|
setLayoutInitialized(true);
|
||||||
|
shouldRecalculate && fitView(nodesWithCurrentUser);
|
||||||
|
return nodesWithCurrentUser;
|
||||||
|
} else {
|
||||||
|
return previousNodes;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const forceSingleGroupView = (groupId: string) => {
|
const forceSingleGroupView = (groupId: string) => {
|
||||||
setSelectedGroup(groupId);
|
setSelectedGroup(groupId);
|
||||||
setSelectedNetwork("");
|
setSelectedNetwork("");
|
||||||
@@ -928,13 +1415,70 @@ function ControlCenterView() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const forceSingleUserView = (userId: string) => {
|
||||||
|
setSelectedPeer("");
|
||||||
|
setSelectedUser("");
|
||||||
|
setPreviousSelectedUser("");
|
||||||
|
setCurrentView(FlowView.USERS);
|
||||||
|
|
||||||
|
const selectUserNode = {
|
||||||
|
id: `select-user-node`,
|
||||||
|
type: "selectUserNode",
|
||||||
|
position: { x: -550, y: 0 },
|
||||||
|
data: {
|
||||||
|
currentUser: userId,
|
||||||
|
onUserChange: handleUserChange,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
setNodes([selectUserNode]);
|
||||||
|
|
||||||
|
const result = applyUserView(userId);
|
||||||
|
if (result) {
|
||||||
|
let nodesWithUser = result.updatedNodes;
|
||||||
|
nodesWithUser.push(selectUserNode);
|
||||||
|
setEdges(result.updatedEdges);
|
||||||
|
setNodes(nodesWithUser);
|
||||||
|
setLayoutInitialized(true);
|
||||||
|
fitView(nodesWithUser);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const forceSinglePeerView = (peerId: string, userId?: string) => {
|
||||||
|
setSelectedPeer(peerId);
|
||||||
|
setSelectedNetwork("");
|
||||||
|
setSelectedUser("");
|
||||||
|
setCurrentView(FlowView.PEERS);
|
||||||
|
const selectPeerNode = {
|
||||||
|
id: `select-peer-node`,
|
||||||
|
type: "selectPeerNode",
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
currentPeer: peerId,
|
||||||
|
onPeerChange: handlePeerChange,
|
||||||
|
userId: userId,
|
||||||
|
placeholder: "Search peers of user...",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
setNodes([selectPeerNode]);
|
||||||
|
const result = applyPeerView(peerId);
|
||||||
|
if (result) {
|
||||||
|
let nodesWithCurrentPeer = result.updatedNodes;
|
||||||
|
nodesWithCurrentPeer.push(selectPeerNode);
|
||||||
|
setEdges(result.updatedEdges);
|
||||||
|
setNodes(nodesWithCurrentPeer);
|
||||||
|
setLayoutInitialized(true);
|
||||||
|
fitView(nodesWithCurrentPeer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isLoading) return;
|
if (isLoading) return;
|
||||||
if (layoutInitialized) return;
|
if (layoutInitialized) return;
|
||||||
|
|
||||||
switch (currentView) {
|
switch (currentView) {
|
||||||
case FlowView.PEERS:
|
case FlowView.PEERS:
|
||||||
if (peers?.length === 0) {
|
if (!peers || peers.length === 0) {
|
||||||
setEdges([]);
|
setEdges([]);
|
||||||
setNodes([]);
|
setNodes([]);
|
||||||
setLayoutInitialized(true);
|
setLayoutInitialized(true);
|
||||||
@@ -942,41 +1486,6 @@ function ControlCenterView() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePeerChange = (newPeerId: string) => {
|
|
||||||
setNodes((prev) => {
|
|
||||||
const shouldRecalculate = selectedPeer !== newPeerId;
|
|
||||||
shouldRecalculate && setSelectedPeer(newPeerId);
|
|
||||||
|
|
||||||
let selectPeerNode;
|
|
||||||
const previousNodes = prev.map((node) => {
|
|
||||||
if (node.id === `select-peer-node`) {
|
|
||||||
selectPeerNode = shouldRecalculate
|
|
||||||
? {
|
|
||||||
...node,
|
|
||||||
data: {
|
|
||||||
...node.data,
|
|
||||||
currentPeer: newPeerId,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: node;
|
|
||||||
return selectPeerNode;
|
|
||||||
}
|
|
||||||
return node;
|
|
||||||
});
|
|
||||||
const result = applyPeerView(newPeerId);
|
|
||||||
if (result && selectPeerNode) {
|
|
||||||
let nodesWithCurrentPeer = result.updatedNodes;
|
|
||||||
nodesWithCurrentPeer.push(selectPeerNode);
|
|
||||||
setEdges(result.updatedEdges);
|
|
||||||
setLayoutInitialized(true);
|
|
||||||
shouldRecalculate && fitView(nodesWithCurrentPeer);
|
|
||||||
return nodesWithCurrentPeer;
|
|
||||||
} else {
|
|
||||||
return previousNodes;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (selectedPeer === "") {
|
if (selectedPeer === "") {
|
||||||
const userPeer = peers?.find((p) => p.user_id === loggedInUser?.id);
|
const userPeer = peers?.find((p) => p.user_id === loggedInUser?.id);
|
||||||
const firstPeer = userPeer ?? peers?.[0];
|
const firstPeer = userPeer ?? peers?.[0];
|
||||||
@@ -998,6 +1507,50 @@ function ControlCenterView() {
|
|||||||
handlePeerChange(selectedPeer);
|
handlePeerChange(selectedPeer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
case FlowView.USERS:
|
||||||
|
if (!users || users.length === 0) {
|
||||||
|
setEdges([]);
|
||||||
|
setNodes([]);
|
||||||
|
setLayoutInitialized(true);
|
||||||
|
fitView([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedUser === "") {
|
||||||
|
let initialUser = users?.find((u) => u.id === loggedInUser?.id);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!initialUser ||
|
||||||
|
!peers?.some((p) => p.user_id === initialUser?.id)
|
||||||
|
) {
|
||||||
|
initialUser = users?.find(
|
||||||
|
(u) => peers?.some((p) => p.user_id === u.id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!initialUser) {
|
||||||
|
initialUser = users?.[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialUserId = initialUser?.id ?? "";
|
||||||
|
setNodes([
|
||||||
|
{
|
||||||
|
id: `select-user-node`,
|
||||||
|
type: "selectUserNode",
|
||||||
|
position: { x: -550, y: 0 },
|
||||||
|
data: {
|
||||||
|
currentUser: initialUserId,
|
||||||
|
onUserChange: handleUserChange,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
if (initialUserId !== "") handleUserChange(initialUserId);
|
||||||
|
} else {
|
||||||
|
resetView();
|
||||||
|
handleUserChange(selectedUser);
|
||||||
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
case FlowView.GROUPS:
|
case FlowView.GROUPS:
|
||||||
if (selectedGroup === "") {
|
if (selectedGroup === "") {
|
||||||
@@ -1023,7 +1576,7 @@ function ControlCenterView() {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case FlowView.NETWORKS:
|
case FlowView.NETWORKS:
|
||||||
if (networks?.length === 0) {
|
if (!networks || networks.length === 0) {
|
||||||
setEdges([]);
|
setEdges([]);
|
||||||
setNodes([]);
|
setNodes([]);
|
||||||
setLayoutInitialized(true);
|
setLayoutInitialized(true);
|
||||||
@@ -1051,6 +1604,7 @@ function ControlCenterView() {
|
|||||||
selectedNetwork,
|
selectedNetwork,
|
||||||
selectedPeer,
|
selectedPeer,
|
||||||
selectedGroup,
|
selectedGroup,
|
||||||
|
selectedUser,
|
||||||
isLoading,
|
isLoading,
|
||||||
layoutInitialized,
|
layoutInitialized,
|
||||||
]);
|
]);
|
||||||
@@ -1077,6 +1631,7 @@ function ControlCenterView() {
|
|||||||
setSelectedPeer("");
|
setSelectedPeer("");
|
||||||
setSelectedGroup("");
|
setSelectedGroup("");
|
||||||
setSelectedNetwork("");
|
setSelectedNetwork("");
|
||||||
|
setSelectedUser("");
|
||||||
setCurrentView(view);
|
setCurrentView(view);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1108,7 +1663,11 @@ function ControlCenterView() {
|
|||||||
if (networkId && currentView === FlowView.NETWORKS) {
|
if (networkId && currentView === FlowView.NETWORKS) {
|
||||||
onNetworkSelect(networkId);
|
onNetworkSelect(networkId);
|
||||||
}
|
}
|
||||||
if (currentView === FlowView.PEERS || currentView === FlowView.GROUPS) {
|
if (
|
||||||
|
currentView === FlowView.PEERS ||
|
||||||
|
currentView === FlowView.GROUPS ||
|
||||||
|
currentView === FlowView.USERS
|
||||||
|
) {
|
||||||
groupId && onGroupSelect(groupId);
|
groupId && onGroupSelect(groupId);
|
||||||
destinationGroupId && onDestinationGroupSelect(destinationGroupId);
|
destinationGroupId && onDestinationGroupSelect(destinationGroupId);
|
||||||
}
|
}
|
||||||
@@ -1206,10 +1765,6 @@ function ControlCenterView() {
|
|||||||
<div className={"absolute left-0 top-0 z-10"}>
|
<div className={"absolute left-0 top-0 z-10"}>
|
||||||
<div className={"flex justify-between px-6 py-4 text-sm w-full"}>
|
<div className={"flex justify-between px-6 py-4 text-sm w-full"}>
|
||||||
<div className={"flex gap-4"}>
|
<div className={"flex gap-4"}>
|
||||||
{selectedNetwork === "" && (
|
|
||||||
<FlowSelector value={currentView} onChange={onViewChange} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedNetwork !== "" && (
|
{selectedNetwork !== "" && (
|
||||||
<Button
|
<Button
|
||||||
variant={"secondary"}
|
variant={"secondary"}
|
||||||
@@ -1221,6 +1776,28 @@ function ControlCenterView() {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{previousSelectedUser !== "" && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant={"secondary"}
|
||||||
|
size={"xs"}
|
||||||
|
className={"!bg-nb-gray-930"}
|
||||||
|
onClick={() => {
|
||||||
|
forceSingleUserView(previousSelectedUser);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon size={14} />
|
||||||
|
</Button>
|
||||||
|
<ControlCenterCurrentUserBadge
|
||||||
|
userId={previousSelectedUser}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedNetwork === "" && previousSelectedUser === "" && (
|
||||||
|
<FlowSelector value={currentView} onChange={onViewChange} />
|
||||||
|
)}
|
||||||
|
|
||||||
{currentView === "networks" && (
|
{currentView === "networks" && (
|
||||||
<div className={"w-64"}>
|
<div className={"w-64"}>
|
||||||
<SelectDropdown
|
<SelectDropdown
|
||||||
@@ -1270,6 +1847,8 @@ function ControlCenterView() {
|
|||||||
<ReactFlow
|
<ReactFlow
|
||||||
edges={edges}
|
edges={edges}
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
|
onNodesChange={onNodesChange}
|
||||||
|
onEdgesChange={onEdgesChange}
|
||||||
proOptions={{
|
proOptions={{
|
||||||
hideAttribution: true,
|
hideAttribution: true,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import SkeletonTable from "@components/skeletons/SkeletonTable";
|
|||||||
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
import { RestrictedAccess } from "@components/ui/RestrictedAccess";
|
||||||
import { usePortalElement } from "@hooks/usePortalElement";
|
import { usePortalElement } from "@hooks/usePortalElement";
|
||||||
import useFetchApi from "@utils/api";
|
import useFetchApi from "@utils/api";
|
||||||
import { ExternalLinkIcon, ServerIcon } from "lucide-react";
|
import { ExternalLinkIcon } from "lucide-react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
import React, { lazy, Suspense } from "react";
|
import React, { lazy, Suspense } from "react";
|
||||||
import DNSIcon from "@/assets/icons/DNSIcon";
|
import DNSIcon from "@/assets/icons/DNSIcon";
|
||||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||||
@@ -15,63 +16,61 @@ import { NameserverGroup } from "@/interfaces/Nameserver";
|
|||||||
import PageContainer from "@/layouts/PageContainer";
|
import PageContainer from "@/layouts/PageContainer";
|
||||||
|
|
||||||
const NameserverGroupTable = lazy(
|
const NameserverGroupTable = lazy(
|
||||||
() => import("@/modules/dns-nameservers/table/NameserverGroupTable"),
|
() => import("@/modules/dns/nameservers/table/NameserverGroupTable"),
|
||||||
);
|
);
|
||||||
|
|
||||||
export default function NameServers() {
|
export default function NameServers() {
|
||||||
const { permission } = usePermissions();
|
const t = useTranslations("dns");
|
||||||
|
const tCommon = useTranslations("common");
|
||||||
|
const { permission } = usePermissions();
|
||||||
|
|
||||||
const { data: nameserverGroups, isLoading } =
|
const { data: nameserverGroups, isLoading } =
|
||||||
useFetchApi<NameserverGroup[]>("/dns/nameservers");
|
useFetchApi<NameserverGroup[]>("/dns/nameservers");
|
||||||
|
|
||||||
const { ref: headingRef, portalTarget } =
|
const { ref: headingRef, portalTarget } =
|
||||||
usePortalElement<HTMLHeadingElement>();
|
usePortalElement<HTMLHeadingElement>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<div className={"p-default py-6"}>
|
<div className={"p-default py-6"}>
|
||||||
<Breadcrumbs>
|
<Breadcrumbs>
|
||||||
<Breadcrumbs.Item
|
<Breadcrumbs.Item
|
||||||
href={"/dns/nameservers"}
|
href={"/dns/nameservers"}
|
||||||
label={"DNS"}
|
label={t("title")}
|
||||||
icon={<DNSIcon size={13} />}
|
icon={<DNSIcon size={13} />}
|
||||||
/>
|
/>
|
||||||
<Breadcrumbs.Item
|
<Breadcrumbs.Item
|
||||||
href={"/dns/nameservers"}
|
href={"/dns/nameservers"}
|
||||||
label={"Nameservers"}
|
label={t("nameservers")}
|
||||||
active
|
active
|
||||||
icon={<ServerIcon size={13} />}
|
icon={<DNSIcon size={13} />}
|
||||||
/>
|
/>
|
||||||
</Breadcrumbs>
|
</Breadcrumbs>
|
||||||
<h1 ref={headingRef}>Nameservers</h1>
|
<h1 ref={headingRef}>{t("nameservers")}</h1>
|
||||||
<Paragraph>
|
<Paragraph>
|
||||||
Add nameservers for domain name resolution in your NetBird network.
|
{t("nameserversDescription")}{" "}
|
||||||
</Paragraph>
|
<InlineLink
|
||||||
<Paragraph>
|
href={"https://docs.netbird.io/how-to/manage-dns-in-your-network"}
|
||||||
Learn more about
|
target={"_blank"}
|
||||||
<InlineLink
|
>
|
||||||
href={"https://docs.netbird.io/how-to/manage-dns-in-your-network"}
|
{tCommon("learnMore")}
|
||||||
target={"_blank"}
|
<ExternalLinkIcon size={12} />
|
||||||
>
|
</InlineLink>
|
||||||
DNS
|
</Paragraph>
|
||||||
<ExternalLinkIcon size={12} />
|
</div>
|
||||||
</InlineLink>
|
|
||||||
in our documentation.
|
|
||||||
</Paragraph>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<RestrictedAccess
|
<RestrictedAccess
|
||||||
page={"Nameservers"}
|
page={t("nameservers")}
|
||||||
hasAccess={permission.nameservers.read}
|
hasAccess={permission.nameservers.read}
|
||||||
>
|
>
|
||||||
<Suspense fallback={<SkeletonTable />}>
|
<Suspense fallback={<SkeletonTable />}>
|
||||||
<NameserverGroupTable
|
<NameserverGroupTable
|
||||||
nameserverGroups={nameserverGroups}
|
nameserverGroups={nameserverGroups}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
headingTarget={portalTarget}
|
headingTarget={portalTarget}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</RestrictedAccess>
|
</RestrictedAccess>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user