Compare commits
308 Commits
v1.15.7
...
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 | ||
|
|
43bc069a49 | ||
|
|
936de0f4f3 | ||
|
|
d81b75a946 | ||
|
|
a632eeeef0 | ||
|
|
e2219aeea0 | ||
|
|
63f4c69eb4 | ||
|
|
b1af256296 | ||
|
|
4027894a2e | ||
|
|
af90792595 | ||
|
|
9a401733b3 | ||
|
|
07b6895380 | ||
|
|
9e2e38764e | ||
|
|
d9fb379abf | ||
|
|
831673d0d6 | ||
|
|
bc4aac10aa | ||
|
|
38e14a6c64 | ||
|
|
b79c6615b4 | ||
|
|
5d4e491611 | ||
|
|
9b1f920863 | ||
|
|
7c7f0a0f10 | ||
|
|
76541c701c | ||
|
|
d2046fee21 | ||
|
|
8e2cbe1d2a | ||
|
|
8a08583225 | ||
|
|
1defac4e34 | ||
|
|
fa68f98cd0 | ||
|
|
3f6e4c4e4f | ||
|
|
0e2661caea | ||
|
|
d7c5f7e183 | ||
|
|
ebbe865ce0 | ||
|
|
6c0ab88488 | ||
|
|
a50576d851 | ||
|
|
676250266c | ||
|
|
042c65a652 | ||
|
|
96f2d39e54 | ||
|
|
61e11d3740 | ||
|
|
c8e3b50f1b | ||
|
|
25be69e7bb | ||
|
|
43e5d5cf53 | ||
|
|
18819d6fdf | ||
|
|
158804c1ac | ||
|
|
14d2d68819 | ||
|
|
40902b3629 | ||
|
|
fa9bcea4ab | ||
|
|
3ba7acdecf | ||
|
|
c7775ade8c | ||
|
|
cd3e75b640 | ||
|
|
f8281c8057 | ||
|
|
c1fcadaefe | ||
|
|
a0c4520f4b | ||
|
|
76ef50a886 | ||
|
|
58cec8fcd1 | ||
|
|
d34ae9beb2 | ||
|
|
650496f670 | ||
|
|
121778c4a6 | ||
|
|
d4102c5d04 | ||
|
|
e78c35bdbe | ||
|
|
6ebee98695 | ||
|
|
f4b28d5f40 | ||
|
|
b4b6d9295b | ||
|
|
4898742ee9 | ||
|
|
79164e9dd5 | ||
|
|
5caeab118b | ||
|
|
3f943bb7d4 | ||
|
|
96b939e6cc | ||
|
|
5e13548b81 | ||
|
|
2272a1d2a4 | ||
|
|
fc3da50346 | ||
|
|
6d4716cdad | ||
|
|
859916b1df | ||
|
|
80ce7d21b0 | ||
|
|
06fdbd8ec4 | ||
|
|
973cceff79 | ||
|
|
f4a2d6fae8 | ||
|
|
cb922b46b7 | ||
|
|
4c56ae704c | ||
|
|
fe6d8c9bd5 | ||
|
|
121976d101 | ||
|
|
f7071e00b6 | ||
|
|
6b73ccf102 | ||
|
|
87dcd00264 | ||
|
|
99f1bcc375 | ||
|
|
bf34c55110 | ||
|
|
1dfc6e2d75 | ||
|
|
b7860a8786 | ||
|
|
c9172e3a5f | ||
|
|
78d75134f9 | ||
|
|
071feb02f9 | ||
|
|
8e7bcc0c22 | ||
|
|
02a0b71e46 | ||
|
|
a8b66d935f | ||
|
|
f74f9cf812 | ||
|
|
7578595f05 | ||
|
|
a5fc05ca3a | ||
|
|
8ffdb442f1 | ||
|
|
a04e3afccb | ||
|
|
bca327e4cf | ||
|
|
6c74506316 | ||
|
|
663d7ea58c | ||
|
|
b701783dca | ||
|
|
fc9a9dfa3e | ||
|
|
093efc08b3 | ||
|
|
dfa41a48e3 | ||
|
|
2cf366a5f8 | ||
|
|
f91788faef | ||
|
|
ec7bb76f1e | ||
|
|
15bab2cef4 | ||
|
|
4fa3482c74 | ||
|
|
f5059f485c | ||
|
|
3c60de4169 | ||
|
|
2267cecf46 | ||
|
|
2b222e082a | ||
|
|
4612f6c49a | ||
|
|
a3a0e6315f | ||
|
|
fa7bc205ba | ||
|
|
87ff65f1a7 | ||
|
|
748596f710 | ||
|
|
b06cb0ec3d | ||
|
|
0c924f7ded | ||
|
|
f29c915fc2 | ||
|
|
5f8579bfda | ||
|
|
a71389aa29 | ||
|
|
c3ba026452 | ||
|
|
193f8a7bdf | ||
|
|
f9814e1169 | ||
|
|
2f800bf912 | ||
|
|
0199ea81f3 | ||
|
|
a20894092b | ||
|
|
f4d6ce5770 | ||
|
|
dd67ab6dcb | ||
|
|
2613948cdf | ||
|
|
dc95d8bfd1 | ||
|
|
f49c28f550 | ||
|
|
2fc7b73ea0 | ||
|
|
a9354d3c87 | ||
|
|
e0ae7d068a | ||
|
|
ddd812e9a0 | ||
|
|
2d55d0736f | ||
|
|
8febc26f1f | ||
|
|
3f854b01a0 | ||
|
|
303d51eff8 | ||
|
|
21e69e642a | ||
|
|
835bb37ab9 | ||
|
|
a944dc8ab0 | ||
|
|
b2c51533fb | ||
|
|
fd24536926 | ||
|
|
8e8484cd45 | ||
|
|
6c87f53195 | ||
|
|
9bbbff7dc0 | ||
|
|
04a20fa31f | ||
|
|
3797db93f0 | ||
|
|
2e81765e85 | ||
|
|
cb9f76c0fc | ||
|
|
54accb665c | ||
|
|
cfea3bd489 | ||
|
|
a44a1c5424 | ||
|
|
c2c044421f | ||
|
|
e8d57c3445 | ||
|
|
0b892c0056 | ||
|
|
14d9b80029 | ||
|
|
dedbe55308 | ||
|
|
796a06cf27 | ||
|
|
2443c6332d | ||
|
|
3b5193ae4e | ||
|
|
cf42dd52fc | ||
|
|
bc6842e5b5 | ||
|
|
a8ed755dda | ||
|
|
a87c06ef52 | ||
|
|
c0130d265c | ||
|
|
63ced3088a | ||
|
|
42b7a15466 | ||
|
|
c88bfa6476 |
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
```
|
||||
13
.eslintrc.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals","prettier"],
|
||||
"plugins": ["simple-import-sort"],
|
||||
"rules": {
|
||||
"simple-import-sort/imports": [
|
||||
"warn",
|
||||
{
|
||||
"groups": [["^\\u0000", "^@?\\w", "^[^.]", "^\\."]]
|
||||
}
|
||||
],
|
||||
"simple-import-sort/exports": "warn"
|
||||
}
|
||||
}
|
||||
44
.github/ISSUE_TEMPLATE/bug-issue-report.md
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
name: Bug/Issue report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ['needs-triage']
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the problem**
|
||||
|
||||
A clear and concise description of what the problem is.
|
||||
|
||||
**To Reproduce**
|
||||
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Are you using NetBird Cloud?**
|
||||
|
||||
Please specify whether you use NetBird Cloud or self-host NetBird's control plane.
|
||||
|
||||
**NetBird version**
|
||||
|
||||
`netbird version`
|
||||
|
||||
**NetBird status -d output:**
|
||||
|
||||
If applicable, add the `netbird status -d' command output.
|
||||
|
||||
**Screenshots**
|
||||
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Additional context**
|
||||
|
||||
Add any other context about the problem here.
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ['feature-request','needs-triage']
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
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
|
||||
118
.github/workflows/build_and_push.yml
vendored
@@ -7,50 +7,120 @@ on:
|
||||
- "**"
|
||||
pull_request:
|
||||
|
||||
# Cancel in-progress runs on the same ref (PR or branch) when a new commit
|
||||
# arrives, so we don't waste CI building superseded commits.
|
||||
concurrency:
|
||||
group: build-and-push-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
DOCKERHUB_IMAGE: netbirdio/dashboard
|
||||
GHCR_IMAGE: ghcr.io/netbirdio/dashboard-cloud
|
||||
|
||||
jobs:
|
||||
build_n_push:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: setup-node
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: '16'
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependecies
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Build
|
||||
# skiping fail on warning for now
|
||||
run: CI=false npm run build
|
||||
-
|
||||
- run: echo '{}' > .local-config.json
|
||||
|
||||
- name: Download IronRDP release TS files
|
||||
uses: robinraju/release-downloader@v1.7
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
repository: netbirdio/IronRDP
|
||||
latest: true
|
||||
fileName: "*.ts"
|
||||
out-file-path: 'public/ironrdp-pkg'
|
||||
|
||||
- name: Download IronRDP release JS files
|
||||
uses: robinraju/release-downloader@v1.7
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
repository: netbirdio/IronRDP
|
||||
latest: true
|
||||
fileName: "*.js"
|
||||
out-file-path: 'public/ironrdp-pkg'
|
||||
|
||||
- name: Download IronRDP release WASM file
|
||||
uses: robinraju/release-downloader@v1.7
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
repository: netbirdio/IronRDP
|
||||
latest: true
|
||||
fileName: "ironrdp_web_bg.wasm"
|
||||
out-file-path: 'public/ironrdp-pkg'
|
||||
|
||||
- name: Get version from tag
|
||||
id: version
|
||||
run: |
|
||||
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
|
||||
echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "version=development" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
env:
|
||||
NEXT_PUBLIC_DASHBOARD_VERSION: ${{ steps.version.outputs.version }}
|
||||
-
|
||||
name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
-
|
||||
uses: docker/setup-qemu-action@v3
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
uses: docker/setup-buildx-action@v3
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: wiretrustee/dashboard
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v2
|
||||
images: |
|
||||
${{ env.DOCKERHUB_IMAGE }}
|
||||
${{ env.GHCR_IMAGE }}
|
||||
flavor: |
|
||||
latest=false
|
||||
tags: |
|
||||
type=schedule
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/') }}
|
||||
type=ref,event=pr
|
||||
type=sha
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
-
|
||||
name: Docker build and push
|
||||
uses: docker/build-push-action@v3
|
||||
username: ${{ secrets.NB_DOCKER_USER }}
|
||||
password: ${{ secrets.NB_DOCKER_TOKEN }}
|
||||
|
||||
- name: Log in to the GitHub Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64,linux/arm
|
||||
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
|
||||
|
||||
16
.github/workflows/codespell.yml
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
name: Codespell
|
||||
on: [pull_request]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: codespell
|
||||
uses: codespell-project/actions-codespell@v2
|
||||
with:
|
||||
only_warn: 1
|
||||
skip: package-lock.json,*.svg
|
||||
ignore_words_list: mappin, allTime
|
||||
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
@@ -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
|
||||
44
.gitignore
vendored
@@ -2,30 +2,54 @@
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/node_modules.bkp
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
/out
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
src/auth_config.json
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# config
|
||||
.local*config*.json
|
||||
.test-config.json
|
||||
e2e/playwright.env.json
|
||||
e2e/fixtures/auth/*.json
|
||||
e2e/test-results/
|
||||
e2e/playwright-report/
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
.configs/.local-config.zitadel.json
|
||||
.configs/.staging-config.json
|
||||
.configs/.temp-config.json
|
||||
.configs
|
||||
|
||||
/public/ironrdp-pkg/
|
||||
/public/netbird.wasm
|
||||
.idea
|
||||
.eslintcache
|
||||
src/.local-config*.json
|
||||
/public/OidcServiceWorker.js
|
||||
/public/OidcTrustedDomains.js
|
||||
src/.local-config*
|
||||
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 自动生成。随着代码库的发展而更新。*
|
||||
1
AUTHORS
@@ -1,2 +1,3 @@
|
||||
Mikhail Bragin (https://github.com/braginini)
|
||||
Maycon Santos (https://github.com/mlsmaycon)
|
||||
NetBird GmbH
|
||||
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 -->
|
||||
64
CONTRIBUTOR_LICENSE_AGREEMENT.md
Normal file
@@ -0,0 +1,64 @@
|
||||
## Contributor License Agreement
|
||||
|
||||
This Contributor License Agreement (referred to as the "Agreement") is entered into by the individual
|
||||
submitting this Agreement and NetBird GmbH, Brunnenstraße 196, 10119 Berlin, Germany,
|
||||
referred to as "NetBird" (collectively, the "Parties"). The Agreement outlines the terms and conditions
|
||||
under which NetBird may utilize software contributions provided by the Contributor for inclusion in
|
||||
its software development projects. By submitting this Agreement, the Contributor confirms their acceptance
|
||||
of the terms and conditions outlined below. The Contributor further represents that they are authorized to
|
||||
complete this process as described herein.
|
||||
|
||||
|
||||
## 1 Preamble
|
||||
In order to clarify the IP Rights situation with regard to Contributions from any person or entity, NetBird
|
||||
must have a contributor license agreement on file to be signed by each Contributor, containing the license
|
||||
terms below. This license serves as protection for both the Contributor as well as NetBird and its software users;
|
||||
it does not change Contributor’s rights to use his/her own Contributions for any other purpose.
|
||||
|
||||
## 2 Definitions
|
||||
2.1 “IP Rights” shall mean all industrial and intellectual property rights, whether registered or not registered, whether created by Contributor or acquired by Contributor from third parties, and similar rights, including (but not limited to) semiconductor property rights, design rights, copyrights (including in the form of database rights and rights to software), all neighbouring rights (Leistungsschutzrechte), trademarks, service marks, titles, internet domain names, trade names and other labelling rights, rights deriving from corresponding applications and registrations of such rights as well as any licenses (Nutzungsrechte) under and entitlements to any such intellectual and industrial property rights.
|
||||
|
||||
2.2 "Contribution" shall mean any original work of authorship, including any modifications or additions to an existing work, that is or previously has been intentionally Submitted by Contributor to NetBird for inclusion in, or documentation of any Work.
|
||||
|
||||
2.3 "Contributor" shall mean the copyright owner or legal entity authorized by the copyright owner that is concluding this Agreement with NetBird. For legal entities, the entity making a Contribution and all other entities that control, are controlled by, or are under common control with that entity are considered to be a single Contributor. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
2.4 "Submitted" shall mean any form of electronic, verbal, or written communication sent to NetBird or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, NetBird for the purpose of discussing and improving the Work, but excluding communication that is marked or otherwise designated in writing by Contributor as "Not a Contribution".
|
||||
|
||||
2.5 "Work" means any of the products owned or managed by NetBird, in particular, but not exclusively, software.
|
||||
|
||||
## 3 Licenses
|
||||
3.1 Subject to the terms and conditions of this agreement, Contributor hereby grants to NetBird and to recipients of software distributed by NetBird a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable license to reproduce by any means and in any form, in whole or in part, permanently or temporarily, the Contributions (including loading, displaying, executing, transmitting or storing works for the purpose of executing and processing data or transferring them to video, audio and other data carriers), including the right to distribute, display and present such Contributions and make them available to the public (e.g. via the internet) and to transmit and display such Contributions by any means. The license also includes the right to modify, translate, adapt, edit and otherwise alter the Contributions and to use these results in the same manner as the original Contributions and derivative works. Except for licenses in patents acc. to Sec. 3, such license refers to any IP Rights in the Contributions and derivative works. The Contributor acknowledges that NetBird is not required to credit them by name for their Contribution and agrees to waive any moral rights associated with their Contribution in relation to NetBird or its sublicensees.
|
||||
|
||||
3.2 Subject to the terms and conditions of this agreement, Contributor hereby grants to NetBird and to recipients of software distributed by NetBird a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license in the Contributions to make, have made, use, sell, offer to sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by the Contributor which are necessarily infringed by Contributor‘s Contribution(s) alone or by combination of Contributor’s Contribution(s) with the Work to which such Contribution(s) was Submitted.
|
||||
|
||||
3.3 NetBird hereby accepts such licenses.
|
||||
|
||||
## 4 Contributor’s Representations
|
||||
4.1 Contributor represents that Contributor is legally entitled to grant the above license. If Contributor’s employer has IP Rights to Contributor’s Contributions, Contributor represent that he/she has received permission to make Contributions on behalf of such employer, that such employer has waived such IP Rights to the Contributions of Contributor to NetBird, or that such employer has executed a separate contributor license agreement with NetBird.
|
||||
|
||||
4.2 Contributor represents that any Contribution is his/her original creation.
|
||||
|
||||
4.3 Contributor represents to his/her best knowledge that any Contribution does not violate any third party IP Rights.
|
||||
|
||||
4.4 Contributor represents that any Contribution submission includes complete details of any third-party license or other restriction (including, but not limited to, related patents and trademarks) of which Contributor is personally aware and which are associated with any part of the Contribution.
|
||||
|
||||
4.5 The Contributor represents that their Contribution does not include any work distributed under a copyleft license.
|
||||
|
||||
## 5 Information obligation
|
||||
Contributor agrees to notify NetBird of any facts or circumstances of which Contributor become aware that would make these representations inaccurate in any respect.
|
||||
|
||||
## 6 Submission of Third-Party works
|
||||
Should Contributor wish to submit work that is not Contributor’s original creation, Contributor may submit it to NetBird separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including, but not limited to, related patents, trademarks, and license agreements) of which Contributor are personally aware, and conspicuously marking the work as "Submitted on behalf of a third-party: [named here]".
|
||||
|
||||
## 7 No Consideration
|
||||
Unless compensation is mandatory under statutory law, no compensation for any license under this agreement shall be payable.
|
||||
|
||||
## 8 Final Provisions
|
||||
8.1 Laws. This Agreement is governed by the laws of the Federal Republic of Germany.
|
||||
|
||||
8.2 Venue. Place of jurisdiction shall, to the extent legally permissible, be Berlin, Germany.
|
||||
|
||||
8.3 Severability. If any provision in this agreement is unlawful, invalid or ineffective, it shall not affect the enforceability or effectiveness of the remainder of this agreement. The parties agree to replace any unlawful, invalid or ineffective provision with a provision that comes as close as possible to the commercial intent and purpose of the original provision. This section also applies accordingly to any gaps in the contract.
|
||||
|
||||
8.4 Variations. Any variations, amendments or supplements to this Agreement must be in writing. This also applies to any variation of this Section 8.4.
|
||||
|
||||
662
LICENSE
@@ -1,13 +1,661 @@
|
||||
BSD 3-Clause License
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (c) 2021 Wiretrustee AUTHORS
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
Preamble
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
57
README.md
@@ -1,4 +1,4 @@
|
||||
# NetBird dashboard
|
||||
# NetBird Dashboard
|
||||
|
||||
This project is the UI for NetBird's Management service.
|
||||
|
||||
@@ -10,6 +10,7 @@ See [NetBird repo](https://github.com/netbirdio/netbird)
|
||||
|
||||
The purpose of this project is simple - make it easy to manage VPN built with [NetBird](https://github.com/netbirdio/netbird).
|
||||
The dashboard makes it possible to:
|
||||
|
||||
- track the status of your peers
|
||||
- remove peers
|
||||
- manage Setup Keys (to authenticate new peers)
|
||||
@@ -17,49 +18,52 @@ The dashboard makes it possible to:
|
||||
- define access controls
|
||||
|
||||
## Some Screenshots
|
||||
<img src="./media/auth.png" alt="auth"/>
|
||||
<img src="./media/peers.png" alt="peers"/>
|
||||
<img src="./media/add-peer.png" alt="add-peer"/>
|
||||
|
||||
<img src="./src/assets/screenshots/peers.png" alt="peers"/>
|
||||
<img src="./src/assets/screenshots/add-peer.png" alt="add-peer"/>
|
||||
|
||||
## Technologies Used
|
||||
|
||||
- NextJS
|
||||
- ReactJS
|
||||
- AntD UI framework
|
||||
- Tailwind CSS
|
||||
- [React Flow](https://reactflow.dev/) for the Control Center
|
||||
- Auth0
|
||||
- Nginx
|
||||
- Docker
|
||||
- Let's Encrypt
|
||||
|
||||
## How to run
|
||||
|
||||
Disclaimer. We believe that proper user management system is not a trivial task and requires quite some effort to make it right. Therefore we decided to
|
||||
use Auth0 service that covers all our needs (user management, social login, JTW for the management API).
|
||||
use Auth0 service that covers all our needs (user management, social login, JWT for the management API).
|
||||
Auth0 so far is the only 3rd party dependency that can't be really self-hosted.
|
||||
|
||||
1. Install [Docker](https://docs.docker.com/get-docker/)
|
||||
2. Register [Auth0](https://auth0.com/) account
|
||||
3. Running Wiretrustee UI Dashboard requires the following Auth0 environmental variables to be set (see docker command below):
|
||||
3. Running NetBird UI Dashboard requires the following Auth0 environmental variables to be set (see docker command below):
|
||||
|
||||
`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. Wiretrustee UI Dashboard uses Wiretrustee Management Service HTTP API, so setting `NETBIRD_MGMT_API_ENDPOINT` is required. Most likely it will be `http://localhost:33071` if you are hosting Management API on the same server.
|
||||
4. NetBird UI Dashboard uses NetBird's Management Service HTTP API, so setting `NETBIRD_MGMT_API_ENDPOINT` is required. Most likely it will be `http://localhost:33071` if you are hosting Management API on the same server.
|
||||
5. Run docker container without SSL (Let's Encrypt):
|
||||
|
||||
```shell
|
||||
docker run -d --name wiretrustee-dashboard \
|
||||
docker run -d --name netbird-dashboard \
|
||||
--rm -p 80:80 -p 443:443 \
|
||||
-e AUTH0_DOMAIN=<SET YOUR AUTH DOMAIN> \
|
||||
-e AUTH0_CLIENT_ID=<SET YOUR CLIENT ID> \
|
||||
-e AUTH0_AUDIENCE=<SET YOUR AUDIENCE> \
|
||||
-e NETBIRD_MGMT_API_ENDPOINT=<SET YOUR MANAGEMETN API URL> \
|
||||
wiretrustee/dashboard:main
|
||||
-e NETBIRD_MGMT_API_ENDPOINT=<SET YOUR MANAGEMENT API URL> \
|
||||
netbirdio/dashboard:main
|
||||
```
|
||||
|
||||
6. Run docker container with SSL (Let's Encrypt):
|
||||
|
||||
```shell
|
||||
docker run -d --name wiretrustee-dashboard \
|
||||
docker run -d --name netbird-dashboard \
|
||||
--rm -p 80:80 -p 443:443 \
|
||||
-e NGINX_SSL_PORT=443 \
|
||||
-e LETSENCRYPT_DOMAIN=<YOUR PUBLIC DOMAIN> \
|
||||
@@ -67,12 +71,27 @@ Auth0 so far is the only 3rd party dependency that can't be really self-hosted.
|
||||
-e AUTH0_DOMAIN=<SET YOUR AUTH DOMAIN> \
|
||||
-e AUTH0_CLIENT_ID=<SET YOUR CLEITN ID> \
|
||||
-e AUTH0_AUDIENCE=<SET YOUR AUDIENCE> \
|
||||
-e NETBIRD_MGMT_API_ENDPOINT=<SET YOUR MANAGEMETN API URL> \
|
||||
wiretrustee/dashboard:main
|
||||
-e NETBIRD_MGMT_API_ENDPOINT=<SET YOUR MANAGEMENT API URL> \
|
||||
netbirdio/dashboard:main
|
||||
```
|
||||
|
||||
## How to run local development
|
||||
1. Install node 16
|
||||
2. create and update the `src/.local-config.json` file. This file should contain values to be replaced from `src/config.json`
|
||||
3. run `npm install`
|
||||
4. run `npm run start dev`
|
||||
|
||||
1. Install [Node](https://nodejs.org/)
|
||||
2. Create and update the `.local-config.json` file. This file should contain values to be replaced from `config.json`
|
||||
3. Run `npm install` to install dependencies
|
||||
4. Run `npm run dev` to start the development server
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing by modifying the code inside `src/..`
|
||||
The page auto-updates as you edit the file.
|
||||
|
||||
## How to migrate from old dashboard (v1)
|
||||
|
||||
The new dashboard comes with a new docker image `netbirdio/dashboard:main`.
|
||||
To migrate from the old dashboard (v1) `wiretrustee/dashboard:main` to the new one, please follow the steps below.
|
||||
|
||||
1. Stop the dashboard container `docker compose down dashboard`
|
||||
2. Replace the docker image name in your `docker-compose.yml` with `netbirdio/dashboard:main`
|
||||
3. Recreate the dashboard container `docker compose up -d --force-recreate dashboard`
|
||||
|
||||
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
@@ -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!")
|
||||
16
components.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": false
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/utils/helpers"
|
||||
}
|
||||
}
|
||||
26
config.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"auth0Auth": "$USE_AUTH0",
|
||||
"authAuthority": "$AUTH_AUTHORITY",
|
||||
"authClientId": "$AUTH_CLIENT_ID",
|
||||
"authClientSecret": "$AUTH_CLIENT_SECRET",
|
||||
"authScopesSupported": "$AUTH_SUPPORTED_SCOPES",
|
||||
"authAudience": "$AUTH_AUDIENCE",
|
||||
"apiOrigin": "$NETBIRD_MGMT_API_ENDPOINT",
|
||||
"grpcApiOrigin": "$NETBIRD_MGMT_GRPC_API_ENDPOINT",
|
||||
"redirectURI": "$AUTH_REDIRECT_URI",
|
||||
"silentRedirectURI": "$AUTH_SILENT_REDIRECT_URI",
|
||||
"tokenSource": "$NETBIRD_TOKEN_SOURCE",
|
||||
"dragQueryParams": "$NETBIRD_DRAG_QUERY_PARAMS",
|
||||
"hotjarTrackID": "$NETBIRD_HOTJAR_TRACK_ID",
|
||||
"googleAnalyticsID": "$NETBIRD_GOOGLE_ANALYTICS_ID",
|
||||
"googleTagManagerID": "$NETBIRD_GOOGLE_TAG_MANAGER_ID",
|
||||
"authServiceUrl": "$NETBIRD_AUTH_SERVICE_URL",
|
||||
"wasmPath": "$NETBIRD_WASM_PATH",
|
||||
"licensed": "$NETBIRD_LICENSED",
|
||||
"cloud": "$NETBIRD_CLOUD",
|
||||
"hubspotPortalId": "$NETBIRD_HUBSPOT_PORTAL_ID",
|
||||
"hubspotSignupFormId": "$NETBIRD_HUBSPOT_SIGNUP_FORM_ID",
|
||||
"hubspotOnboardingFormId": "$NETBIRD_HUBSPOT_ONBOARDING_FORM_ID",
|
||||
"hubspotSurveyFormId": "$NETBIRD_HUBSPOT_SURVEY_FORM_ID",
|
||||
"analyticsExcludedEmails": "$NETBIRD_ANALYTICS_EXCLUDED_EMAILS"
|
||||
}
|
||||
@@ -1,24 +1,13 @@
|
||||
FROM alpine:3.14
|
||||
|
||||
RUN apk add --no-cache bash curl less ca-certificates git tzdata zip gettext \
|
||||
nginx curl supervisor certbot-nginx && \
|
||||
rm -rf /var/cache/apk/* && mkdir -p /run/nginx
|
||||
|
||||
STOPSIGNAL SIGINT
|
||||
EXPOSE 80
|
||||
EXPOSE 443
|
||||
ENTRYPOINT ["/usr/bin/supervisord","-c","/etc/supervisord.conf"]
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /usr/share/nginx/html
|
||||
# copy configuration files
|
||||
COPY docker/default.conf /etc/nginx/http.d/default.conf
|
||||
COPY docker/nginx.conf /etc/nginx/nginx.conf
|
||||
COPY docker/init_cert.sh /usr/local/init_cert.sh
|
||||
COPY docker/init_react_envs.sh /usr/local/init_react_envs.sh
|
||||
RUN chmod +x /usr/local/init_cert.sh && rm /etc/crontabs/root
|
||||
RUN chmod +x /usr/local/init_react_envs.sh
|
||||
|
||||
# configure supervisor
|
||||
COPY docker/supervisord.conf /etc/supervisord.conf
|
||||
# copy build files
|
||||
COPY build/ /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"]
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
# Wiretrustee Dashboard
|
||||
Wiretrustee Dashboard is a the Wiretrustee Managemenet server UI. It allow users to signin, view setup keys and manage peers. This image is **not ready** for production use.
|
||||
# NetBird Dashboard
|
||||
NetBird Dashboard is NetBirds Management server UI. It allows users to signin, view setup keys and manage peers. This image is **not ready** for production use.
|
||||
## Tags
|
||||
```latest``` ```vX.X.X``` not available yet.
|
||||
|
||||
```main``` builded on every PR being merged to the repository
|
||||
```main``` built on every PR being merged to the repository
|
||||
## How to use this image
|
||||
HTTP run:
|
||||
```shell
|
||||
docker run -d --rm -p 80:80 wiretrustee/dashboard:main
|
||||
```
|
||||
Using SSL certificate from Let's Encript®:
|
||||
Using SSL certificate from Let's Encrypt®:
|
||||
```shell
|
||||
docker run -d --rm -p 80:80 -p 443:443 \
|
||||
-e LETSENCRYPT_DOMAIN=app.mydomain.com \
|
||||
-e LETSENCRYPT_EMAIL=hello@mydomain.com \
|
||||
wiretrustee/dashboard:main
|
||||
netbirdio/dashboard:main
|
||||
```
|
||||
> For SSL generation, you need to run this image in a server with proper public IP and a domain name pointing to it.
|
||||
## Environment variables
|
||||
|
||||
@@ -1,16 +1,37 @@
|
||||
# simple server configuration to replace nginx's default
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
location = /netbird.wasm {
|
||||
root /usr/share/nginx/html;
|
||||
default_type application/wasm;
|
||||
}
|
||||
location = /ironrdp-pkg/ironrdp_web_bg.wasm {
|
||||
root /usr/share/nginx/html;
|
||||
default_type application/wasm;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
# You may need this to prevent return 404 recursion.
|
||||
location = /404.html {
|
||||
internal;
|
||||
}
|
||||
try_files $uri $uri.html $uri/ =404;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header Content-Security-Policy "default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob: ws: wss: https:;" always;
|
||||
add_header Last-Modified "";
|
||||
expires off;
|
||||
}
|
||||
|
||||
error_page 404 /404.html;
|
||||
location = /404.html {
|
||||
internal;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" 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;
|
||||
}
|
||||
}
|
||||
@@ -57,16 +57,104 @@ export AUTH_SUPPORTED_SCOPES=${AUTH_SUPPORTED_SCOPES:-openid profile email api o
|
||||
export NETBIRD_MGMT_API_ENDPOINT=$(echo $NETBIRD_MGMT_API_ENDPOINT | sed -E 's/(:80|:443)$//')
|
||||
export NETBIRD_MGMT_GRPC_API_ENDPOINT=${NETBIRD_MGMT_GRPC_API_ENDPOINT}
|
||||
export NETBIRD_HOTJAR_TRACK_ID=${NETBIRD_HOTJAR_TRACK_ID}
|
||||
export NETBIRD_GOOGLE_ANALYTICS_ID=${NETBIRD_GOOGLE_ANALYTICS_ID}
|
||||
export NETBIRD_GOOGLE_TAG_MANAGER_ID=${NETBIRD_GOOGLE_TAG_MANAGER_ID}
|
||||
export NETBIRD_TOKEN_SOURCE=${NETBIRD_TOKEN_SOURCE:-accessToken}
|
||||
export NETBIRD_DRAG_QUERY_PARAMS=${NETBIRD_DRAG_QUERY_PARAMS:-false}
|
||||
export NETBIRD_AUTH_SERVICE_URL=${NETBIRD_AUTH_SERVICE_URL}
|
||||
export NETBIRD_WASM_PATH=${NETBIRD_WASM_PATH}
|
||||
export NETBIRD_CSP=${NETBIRD_CSP}
|
||||
export NETBIRD_LICENSED=${NETBIRD_LICENSED:-false}
|
||||
export NETBIRD_CLOUD=${NETBIRD_CLOUD:-false}
|
||||
export NETBIRD_HUBSPOT_PORTAL_ID=${NETBIRD_HUBSPOT_PORTAL_ID}
|
||||
export NETBIRD_HUBSPOT_SIGNUP_FORM_ID=${NETBIRD_HUBSPOT_SIGNUP_FORM_ID}
|
||||
export NETBIRD_HUBSPOT_ONBOARDING_FORM_ID=${NETBIRD_HUBSPOT_ONBOARDING_FORM_ID}
|
||||
export NETBIRD_HUBSPOT_SURVEY_FORM_ID=${NETBIRD_HUBSPOT_SURVEY_FORM_ID}
|
||||
export NETBIRD_ANALYTICS_EXCLUDED_EMAILS=${NETBIRD_ANALYTICS_EXCLUDED_EMAILS}
|
||||
|
||||
echo "NetBird latest version: ${NETBIRD_LATEST_VERSION}"
|
||||
|
||||
# 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 \$\$AUTH_REDIRECT_URI \$\$AUTH_SILENT_REDIRECT_URI \$\$NETBIRD_TOKEN_SOURCE"
|
||||
# Build CSP
|
||||
FIRST_PARTY_CSP="pkgs.netbird.io"
|
||||
FIRST_PARTY_CSP_CONNECT_SRC="wss://*.netbird.io"
|
||||
THIRD_PARTY_CSP="*.licdn.com *.linkedin.com *.vector.co *.sibforms.com *.hotjar.com *.hotjar.io *.redditstatic.com pixel-config.reddit.com *.clarity.ms c.bing.com *.microsoft.com googleads.g.doubleclick.net pagead2.googlesyndication.com www.google.com www.googleadservices.com *.google-analytics.com *.googletagmanager.com analytics.google.com *.hubapi.com *.hs-banner.com *.hubspot.com *.hubspot.net js.hs-analytics.com *.hsforms.net *.hscollectedforms.net *.hs-analytics.net *.hsforms.com track.hubspot.com *.hsadspixel.net static.hsappstatic.net"
|
||||
THIRD_PARTY_CSP_CONNECT_SRC="https://api.github.com/repos/netbirdio/netbird/releases/latest https://raw.githubusercontent.com/netbirdio/dashboard/ wss://ws.hotjar.com"
|
||||
THIRD_PARTY_CSP_SCRIPT_SRC="'sha256-7knV6EIjKUvCpYWE2rCYx8dYV2WCNb2bpTuitFXzBcA=' *.hs-scripts.com"
|
||||
|
||||
CSP_DOMAINS=""
|
||||
CSP_DOMAINS_CONNECT_SRC=""
|
||||
|
||||
if [[ -n "${NETBIRD_CSP}" ]]; then
|
||||
CSP_DOMAINS="$CSP_DOMAINS $NETBIRD_CSP"
|
||||
fi
|
||||
|
||||
# Add AUTH_AUTHORITY to CSP
|
||||
if [[ -n "${AUTH_AUTHORITY}" ]]; then
|
||||
CSP_DOMAINS="$CSP_DOMAINS $AUTH_AUTHORITY"
|
||||
fi
|
||||
|
||||
# Add AUTH_AUDIENCE to CSP
|
||||
if [[ -n "${AUTH_AUDIENCE}" && ("${AUTH_AUDIENCE}" == *"http://"* || "${AUTH_AUDIENCE}" == *"https://"*) ]]; then
|
||||
CSP_DOMAINS="$CSP_DOMAINS $AUTH_AUDIENCE"
|
||||
fi
|
||||
|
||||
# Add NETBIRD_AUTH_SERVICE_URL to CSP
|
||||
if [[ -n "${NETBIRD_AUTH_SERVICE_URL}" ]]; then
|
||||
CSP_DOMAINS="$CSP_DOMAINS $NETBIRD_AUTH_SERVICE_URL"
|
||||
fi
|
||||
|
||||
# Add NETBIRD_MGMT_API_ENDPOINT to CSP
|
||||
if [[ -n "${NETBIRD_MGMT_API_ENDPOINT}" ]]; then
|
||||
MGMT_DOMAIN=$(echo "$NETBIRD_MGMT_API_ENDPOINT" | sed -E 's|https?://||' | cut -d'/' -f1 | cut -d':' -f1)
|
||||
if [[ -n "$MGMT_DOMAIN" ]]; then
|
||||
if [[ "$NETBIRD_MGMT_API_ENDPOINT" == https://* ]]; then
|
||||
CSP_DOMAINS="$CSP_DOMAINS $NETBIRD_MGMT_API_ENDPOINT"
|
||||
CSP_DOMAINS_CONNECT_SRC="$CSP_DOMAINS_CONNECT_SRC wss://$MGMT_DOMAIN"
|
||||
elif [[ "$NETBIRD_MGMT_API_ENDPOINT" == http://* ]]; then
|
||||
CSP_DOMAINS="$CSP_DOMAINS $NETBIRD_MGMT_API_ENDPOINT"
|
||||
CSP_DOMAINS_CONNECT_SRC="$CSP_DOMAINS_CONNECT_SRC ws://$MGMT_DOMAIN"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Add LETSENCRYPT_DOMAIN to CSP
|
||||
if [[ -n "${LETSENCRYPT_DOMAIN}" ]]; then
|
||||
if [[ "$LETSENCRYPT_DOMAIN" == *"localhost"* ]]; then
|
||||
CSP_DOMAINS="$CSP_DOMAINS http://$LETSENCRYPT_DOMAIN"
|
||||
CSP_DOMAINS_CONNECT_SRC="$CSP_DOMAINS_CONNECT_SRC ws://$LETSENCRYPT_DOMAIN"
|
||||
else
|
||||
CSP_DOMAINS="$CSP_DOMAINS https://$LETSENCRYPT_DOMAIN"
|
||||
CSP_DOMAINS_CONNECT_SRC="$CSP_DOMAINS_CONNECT_SRC wss://$LETSENCRYPT_DOMAIN"
|
||||
fi
|
||||
fi
|
||||
|
||||
CSP_CONNECT_SRC="$CSP_DOMAINS $CSP_DOMAINS_CONNECT_SRC $FIRST_PARTY_CSP $FIRST_PARTY_CSP_CONNECT_SRC $THIRD_PARTY_CSP $THIRD_PARTY_CSP_CONNECT_SRC"
|
||||
CSP_FRAME_SRC="$CSP_DOMAINS $FIRST_PARTY_CSP $THIRD_PARTY_CSP"
|
||||
CSP_SCRIPT_SRC="$CSP_DOMAINS $FIRST_PARTY_CSP $THIRD_PARTY_CSP $THIRD_PARTY_CSP_SCRIPT_SRC"
|
||||
|
||||
# Remove duplicates
|
||||
CSP_CONNECT_SRC=$(echo $CSP_CONNECT_SRC | tr ' ' '\n' | sort -u | tr '\n' ' ' | sed 's/ $//')
|
||||
CSP_FRAME_SRC=$(echo $CSP_FRAME_SRC | tr ' ' '\n' | sort -u | tr '\n' ' ' | sed 's/ $//')
|
||||
CSP_SCRIPT_SRC=$(echo $CSP_SCRIPT_SRC | tr ' ' '\n' | sort -u | tr '\n' ' ' | sed 's/ $//')
|
||||
|
||||
# Update CSP in nginx config
|
||||
CSP_POLICY="default-src 'none'; connect-src 'self' $CSP_CONNECT_SRC; frame-src 'self' $CSP_FRAME_SRC; script-src 'self' 'wasm-unsafe-eval' $CSP_SCRIPT_SRC; font-src 'self'; img-src * data:; manifest-src 'self'; style-src 'self' 'unsafe-inline'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests;"
|
||||
CSP_HEADER="add_header Content-Security-Policy \"$CSP_POLICY\" always;"
|
||||
|
||||
echo "CSP header: $CSP_HEADER"
|
||||
|
||||
# Replace CSP header in nginx config
|
||||
sed -i "s|add_header Content-Security-Policy \"[^\"]*\" always;|$CSP_HEADER|g" /etc/nginx/http.d/default.conf || {
|
||||
echo "Failed to replace CSP header"
|
||||
}
|
||||
|
||||
# replace ENVs in the config
|
||||
ENV_STR="\$\$USE_AUTH0 \$\$AUTH_AUDIENCE \$\$AUTH_AUTHORITY \$\$AUTH_CLIENT_ID \$\$AUTH_CLIENT_SECRET \$\$AUTH_SUPPORTED_SCOPES \$\$NETBIRD_MGMT_API_ENDPOINT \$\$NETBIRD_MGMT_GRPC_API_ENDPOINT \$\$NETBIRD_HOTJAR_TRACK_ID \$\$NETBIRD_GOOGLE_ANALYTICS_ID \$\$NETBIRD_GOOGLE_TAG_MANAGER_ID \$\$AUTH_REDIRECT_URI \$\$AUTH_SILENT_REDIRECT_URI \$\$NETBIRD_TOKEN_SOURCE \$\$NETBIRD_DRAG_QUERY_PARAMS \$\$NETBIRD_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"
|
||||
|
||||
MAIN_JS=$(find /usr/share/nginx/html/static/js/main.*js)
|
||||
OIDC_TRUSTED_DOMAINS="/usr/share/nginx/html/OidcTrustedDomains.js"
|
||||
cp "$MAIN_JS" "$MAIN_JS".copy
|
||||
envsubst "$ENV_STR" < "$MAIN_JS".copy > "$MAIN_JS"
|
||||
envsubst "$ENV_STR" < "$OIDC_TRUSTED_DOMAINS".tmpl > "$OIDC_TRUSTED_DOMAINS"
|
||||
rm "$MAIN_JS".copy
|
||||
for f in $(grep -R -l AUTH_SUPPORTED_SCOPES /usr/share/nginx/html); do
|
||||
cp "$f" "$f".copy
|
||||
envsubst "$ENV_STR" < "$f".copy > "$f"
|
||||
rm "$f".copy
|
||||
done
|
||||
@@ -101,6 +101,7 @@ http {
|
||||
application/rss+xml
|
||||
application/vnd.geo+json
|
||||
application/vnd.ms-fontobject
|
||||
application/wasm
|
||||
application/x-font-ttf
|
||||
application/x-web-app-manifest+json
|
||||
application/xhtml+xml
|
||||
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
1
docs/i18n-reports/phase1-common-components.json
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
@@ -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
@@ -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
@@ -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
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
|
Before Width: | Height: | Size: 44 KiB |
BIN
media/auth.png
|
Before Width: | Height: | Size: 37 KiB |
BIN
media/peers.png
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 158 KiB |
19
next.config.js
Normal file
@@ -0,0 +1,19 @@
|
||||
const createNextIntlPlugin = require('next-intl/plugin');
|
||||
|
||||
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: "export",
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
reactStrictMode: false,
|
||||
env: {
|
||||
APP_ENV: process.env.APP_ENV || "production",
|
||||
NEXT_PUBLIC_DASHBOARD_VERSION:
|
||||
process.env.NEXT_PUBLIC_DASHBOARD_VERSION || "development",
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = withNextIntl(nextConfig);
|
||||
34333
package-lock.json
generated
176
package.json
@@ -1,84 +1,116 @@
|
||||
{
|
||||
"name": "wiretrustee-dashboard",
|
||||
"version": "0.1.0",
|
||||
"name": "netbird-dashboard",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^4.8.0",
|
||||
"@axa-fr/react-oidc": "^5.14.0",
|
||||
"@headlessui/react": "^1.5.0",
|
||||
"@heroicons/react": "^1.0.4",
|
||||
"@testing-library/jest-dom": "^5.11.4",
|
||||
"@testing-library/react": "^11.1.0",
|
||||
"@testing-library/user-event": "^12.1.10",
|
||||
"@types/jest": "^27.5.1",
|
||||
"@types/lodash": "^4.14.182",
|
||||
"@types/node": "^17.0.35",
|
||||
"@types/react": "^18.0.9",
|
||||
"@types/react-dom": "^18.0.5",
|
||||
"@types/react-redux": "^7.1.24",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/styled-components": "^5.1.25",
|
||||
"antd": "^5.3.1",
|
||||
"autoprefixer": "^10.4.4",
|
||||
"axios": "^0.27.2",
|
||||
"cidr-regex": "^3.1.1",
|
||||
"copyfiles": "^2.4.1",
|
||||
"heroicons": "^1.0.6",
|
||||
"highlight.js": "^11.2.0",
|
||||
"history": "^5.0.1",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.29.4",
|
||||
"postcss": "^8.4.12",
|
||||
"prop-types": "^15.7.2",
|
||||
"punycode": "^2.1.1",
|
||||
"rc-overflow": "^1.2.8",
|
||||
"react": "^18.2.0",
|
||||
"react-copy-to-clipboard": "^5.1.0",
|
||||
"react-dom": "^18.1.0",
|
||||
"react-hotjar": "^5.1.0",
|
||||
"react-redux": "^8.0.2",
|
||||
"react-router-dom": "^5.3.3",
|
||||
"react-scripts": "^5.0.1",
|
||||
"react-select": "^5.7.3",
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"react-table": "^7.7.0",
|
||||
"redux": "^4.2.0",
|
||||
"redux-devtools-extension": "^2.13.9",
|
||||
"redux-saga": "^1.1.3",
|
||||
"styled-components": "^5.3.5",
|
||||
"tailwindcss": "^3.0.23",
|
||||
"ts-md5": "^1.3.1",
|
||||
"typesafe-actions": "^5.1.0",
|
||||
"typescript": "^4.6.4",
|
||||
"web-vitals": "^2.1.4"
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
},
|
||||
"scripts": {
|
||||
"copy": "copyfiles -f ./node_modules/@axa-fr/react-oidc/dist/OidcServiceWorker.js ./public",
|
||||
"copytrusted": "copyfiles -f ./public/local/OidcTrustedDomains.js ./public",
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
"dev": "next dev -p 3000",
|
||||
"turbo": "next dev -p 3000 --turbo",
|
||||
"build": "next build",
|
||||
"postbuild": "node postbuild.js",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"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"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
"dependencies": {
|
||||
"@axa-fr/react-oidc": "^7.26.3",
|
||||
"@dagrejs/dagre": "^1.1.5",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tabler/icons-react": "^3.36.1",
|
||||
"@tanstack/match-sorter-utils": "^8.8.4",
|
||||
"@tanstack/react-table": "^8.10.7",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@types/lodash": "4.17.24",
|
||||
"@types/node": "20.10.6",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"@xyflow/react": "^12.8.4",
|
||||
"autoprefixer": "^10",
|
||||
"chart.js": "^4.4.8",
|
||||
"chroma-js": "^3.1.2",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"classnames": "^2.5.1",
|
||||
"clsx": "^2.0.0",
|
||||
"cmdk": "^1.1.1",
|
||||
"cross-env": "^7.0.3",
|
||||
"crypto-js": "^4.2.0",
|
||||
"d3": "^7.9.0",
|
||||
"date-fns": "^2.30.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"elkjs": "^0.10.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-simple-import-sort": "^10.0.0",
|
||||
"framer-motion": "^12.29.2",
|
||||
"ip-address": "^10.2.0",
|
||||
"ip-cidr": "^3.1.0",
|
||||
"js-cookie": "^3.0.7",
|
||||
"lodash": "4.18.1",
|
||||
"lucide-react": "^0.566.0",
|
||||
"next": "16.1.7",
|
||||
"next-intl": "^4.13.0",
|
||||
"next-themes": "^0.2.1",
|
||||
"punycode": "^2.3.1",
|
||||
"react": "^19.2.4",
|
||||
"react-chartjs-2": "^5.3.0",
|
||||
"react-confetti-explosion": "^3.0.3",
|
||||
"react-day-picker": "^9.13.0",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-ga4": "^2.1.0",
|
||||
"react-hotjar": "^6.3.1",
|
||||
"react-hotkeys-hook": "^4.4.1",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-jwt": "^1.2.0",
|
||||
"react-loading-skeleton": "^3.3.1",
|
||||
"react-responsive": "^9.0.2",
|
||||
"react-virtuoso": "^4.9.0",
|
||||
"sonner": "^2.0.7",
|
||||
"swr": "^2.2.4",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"timescape": "^0.7.1",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
"overrides": {
|
||||
"minimatch": ">=10.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react-syntax-highlighter": "^15.5.3"
|
||||
"@faker-js/faker": "^9.5.1",
|
||||
"@types/chroma-js": "^3.1.1",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "^16.1.6",
|
||||
"postcss": "^8",
|
||||
"prettier": "3.0.3",
|
||||
"tailwindcss": "^3.4.17"
|
||||
}
|
||||
}
|
||||
|
||||
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!");
|
||||
148
public/assets/flags/1x1/ad.svg
Normal file
@@ -0,0 +1,148 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-ad" viewBox="0 0 512 512">
|
||||
<path fill="#d0103a" d="M0 0h512v512H0z"/>
|
||||
<path fill="#fedf00" d="M0 0h348.2v512H0z"/>
|
||||
<path fill="#0018a8" d="M0 0h163.8v512H0z"/>
|
||||
<path fill="#c7b37f" d="M240.3 173.3c6.2 0 8.7 5.3 14.9 5.3 3.8 0 6-1.2 9.3-3.1 2.4-1.3 3.8-2 6.5-2s4.4.8 5.8 3.1a9 9 0 0 1 1 5.4 32 32 0 0 1-2.1 6.7c-.5 1.2-1 2-1 3.3 0 3.3 4.4 4.4 7.4 4.5.7 0 6.3 0 9.7-3.4-1.9 0-4-1.5-4-3.4 0-2 1.5-3.5 3.5-4.1.4-.1 1 .2 1.4 0 .5-.2.2-.8.7-1.1 1-.8 1.6-1.3 2.9-1.3a3 3 0 0 1 2 .6c.3.2.5.6.9.6 1 0 1.4-.6 2.3-.6.7 0 1.2 0 1.8.4.6.3.6 1.2 1.2 1.2.3 0 1.9-.6 2.8-.6 1.7 0 2.7.6 3.8 2 .3.3.5 1 .8 1a5 5 0 0 1 3.9 2.4c.2.3.5 1.1.9 1.3.4.1.7.1 1.3.5a4.8 4.8 0 0 1 2.3 3.9c0 .5-.3 1.2-.4 1.7-1.5 5.2-5.1 7-8.7 11.4-1.6 2-2.8 3.5-2.8 6 0 .6.8 1.7 1 2.2-.1-1.2.4-2.6 1.6-2.7 1.7 0 3 1.2 3.2 2.8 0 .4 0 1.1-.2 1.5.9-.6 2-1 3.2-1.1a9.9 9.9 0 0 1 1.5 0 13 13 0 0 1 7.4 3 16.9 16.9 0 0 1 5.9 13.4c-.7 4.3-.3 11.9-11 15 2 .8 3.3 2.3 3.3 4.1 0 2-1.5 3.8-3.5 3.8a3.5 3.5 0 0 1-2.8-1.1c-2.2 2.2-2.7 4.5-2.7 7.7 0 1.9.4 3 1.2 4.7a9 9 0 0 0 3 4.2c.8-1.2 1.6-2 3-2 1.5 0 2.7.4 3.3 1.7.1.3 0 .7.2 1 .2.5.7.6 1 1.1.3.8 0 1.4.3 2.2.2.5.7.6 1 1 .3.9.4 1.4.4 2.3 0 2.4-2.2 4-4.6 4-.8 0-1.2-.2-1.9-.1 1.4 1.3 2.4 2 3.5 3.6a14.1 14.1 0 0 1 2.3 8.2c0 3.6-.6 5.8-2.2 9a16 16 0 0 1-5.6 6.8 28 28 0 0 1-12.8 5c-3.4.7-5.3 1-8.8 1.2l-11.3.6c-5.7.4-9.7 1.2-13.8 5.3 2 1.4 3.3 2.8 3.3 5.2 0 2.4-1.5 4.2-3.9 5-.5.1-1 0-1.4.2-.6.3-.6 1-1.2 1.4a5 5 0 0 1-3 .8c-2.2 0-3.6-.5-5.2-2-1.7 1.4-2.3 2.7-4.3 3.9-.7.3-1 .8-1.7.8-1.2 0-1.8-.7-2.7-1.4a18.4 18.4 0 0 1-3.6-3.3c-1.8 1.1-2.9 2-5 2a5.2 5.2 0 0 1-3.1-.9c-.6-.3-.7-.9-1.3-1.2-.6-.4-1-.2-1.7-.5-2.4-1-4-2.8-4-5.5 0-2.3 1.5-3.8 3.6-4.7-4-4-8-4.7-13.6-5-4.4-.4-7-.4-11.3-.7-3.4-.2-5.4-.6-8.8-1.1-2.6-.4-4.1-.6-6.5-1.7-8.2-3.8-13.4-9-14.5-18v-2c0-4.7 1.8-7.5 5-10.8-.8-.2-1.3 0-2.2-.2-2-.8-3.5-2.2-3.5-4.4 0-.8 0-1.4.4-2 .3-.6.8-.7 1-1.2.1-.8 0-1.3.3-2 .2-.5.6-.5.8-1 .7-1.5 1.6-2.7 3.3-2.7 1.4 0 2.3.8 3 2 1.4-.7 1.8-1.7 2.6-3 1.3-2.2 1.8-3.7 1.8-6.2a11 11 0 0 0-.7-4.4c-.4-1.2-.5-2-1.4-3a3.5 3.5 0 0 1-2.8 1.2c-2.3 0-4-2-4-4.3 0-1.7.8-3 2.4-3.7-1.3-1-2.4-1.2-3.7-2-2.1-1.4-2.9-2.7-4.2-4.8-1-1.4-1.2-2.3-1.6-3.8a15 15 0 0 1-.9-5v-1.3c.6-3.9 1.3-6.4 3.8-9.5a11 11 0 0 1 4.6-3.9 11.6 11.6 0 0 1 6.5-1.3c1 .2 1.5.2 2.3.7.3.2.9.7.9.3l-.2-1c0-1.7 1.2-3.2 2.8-3.2 1.2 0 1.7 1 2.3 2 .4-.6.6-1 .6-1.7 0-2.8-1.5-4.2-3.2-6.3-3.7-4.7-8.4-6.9-8.4-12.8 0-1.8.9-3 2.4-4 .4-.2 1 0 1.5-.2.3-.3.3-.7.5-1.1a4 4 0 0 1 1.3-1.3c.8-.8 1.6-.5 2.5-1.2.5-.3.6-.7 1-1.2 1-1.2 2-1.8 3.6-1.8.8 0 1.3 0 2 .3.3 0 .8.5.9.4.1-.2.6-.7 1.1-1 .7-.3 1-.4 1.8-.4.9 0 1.4.5 2.3.5.4 0 .4-.3.7-.5.8-.5 1.2-.8 2.2-.8 1 0 1.4.3 2.2.8.7.4.8 1 1.6 1.5l1.2.3c2 .6 3.6 2 3.6 4.2 0 1.2-.2 2-1.1 2.8-.7.6-1.4.5-2.3.8a13 13 0 0 0 9 2.8c3.5 0 7.6-1.3 7.6-4.7 0-1.6-.9-2.4-1.5-3.8a15 15 0 0 1-1.7-6.9c0-2.2.2-3.5 1.5-5.3 1.3-1.9 3-2.3 5.2-2.3"/>
|
||||
<g fill="none" stroke="#703d29">
|
||||
<path stroke-linejoin="round" stroke-width=".5" d="M217.9 191.2c.2.9.9 1.6 2 2a3 3 0 0 0 3-1.1c.8-1 .7-2.3.5-3.3a3.8 3.8 0 0 0-1.4-1.8z"/>
|
||||
<path stroke-linecap="round" stroke-width=".5" d="M320.8 252.9c-1-2.3-3.4-1.3-3.6 0-.3 3 2.3 3.8 4.1 3.3.9-.2 1.6-.8 2-1.5.5-.8.6-2 .3-3a4 4 0 0 0-.7-1.3 4 4 0 0 0-1-1c-.7-.4-1.5-.5-2.7-.5-4.4 0-8.3 5.3-9.6 10.8a23.6 23.6 0 0 0-.2 9.6 18 18 0 0 0 4.7 9 20 20 0 0 0 7.9 4.7c1.1.3 2.2.3 3.1 0 2.7-.5 3.9-3 2.6-5.5-1.1-2-4.3-3.2-5.8-.6a2.6 2.6 0 0 0-.4 1.3c0 .7.3 1.5.8 1.8 1.2.8 3 .6 3-1.5"/>
|
||||
<path stroke-width=".7" d="M307 283.2a9 9 0 0 1 5.3-3c2.4-.2 4.5.5 6.6 1.6a14.9 14.9 0 0 1 8.6 13.6c0 3-.8 6-1.5 7.6-.7 1.3-2.5 7.1-12.3 11.2a67.4 67.4 0 0 1-20.5 3c-8.4.4-16 .7-20.5 6.2"/>
|
||||
<g stroke-width=".6">
|
||||
<path d="M309.1 292.6c-.2-.9 0-1.7.7-2.7 1-1.3 2.9-1.7 4.7-.7.6.3 1.3.8 2 1.7l1 1.2.8 2c2 5.6-1.2 11.7-5.2 14.1-3.2 2-7 2.8-11.5 3.3l-5.3.3h-7.6a56.3 56.3 0 0 0-5.8 0l-6 .6-4.4.8-1.5.4-1 .3a31.9 31.9 0 0 0-7.7 3.3c-.7.4-1.5.9-2 1.4l-1.1 1c-1.5 1.4-3.1 3-3.5 5.3v1.3c0 1.4 1.1 3.4 4.3 4m4.4-136.1c.6 1.2 1 2 .6 3.1-.4 1.4-1.4 2.3-2.8 2.3-3.2 0-5-3.8-3.6-6.2 2.5-4.3 7.4-1.9 12 .2-.3-1-.7-1.4-.6-2.8 0-3.3 2.6-4.8 3.6-8 .6-1.8.8-3.4-.6-4.7-1.1-1.2-2.5-1.1-4-.5-3.1 1.2-6.8 4.6-13.3 4.7-6.5 0-10.3-3.5-13.4-4.7-1.5-.6-2.9-.7-4 .5-1.4 1.3-1.2 3-.6 4.8 1 3 3.5 4.6 3.6 8 0 1.3-.3 1.6-.6 2.7 4.6-2 9.7-4.7 12-.2 1.3 2.5-.4 6.2-3.6 6.2-1.4 0-2.4-1-2.8-2.3-.4-1.1 0-2.2.6-3.1"/>
|
||||
<path stroke-linecap="round" d="M251.7 191.9c1.2 1 2 2.1 1.9 4-.1 2-.7 2.5-2.2 3.6m1.9-3c-.1 1.2-.6 2-1.8 2.5"/>
|
||||
</g>
|
||||
<path fill="#c7b37f" stroke="none" d="m221.4 186.6.5.4.6.7.4.8.2.6v1.5l-.2.7-.4.5-.4.5-.7.3-.9.2-.7.2-.8-.4-.8-.5-.4-.7-.3-.8v-.3z"/>
|
||||
<path stroke-linecap="round" stroke-width=".5" d="M220.2 189.7c-.3-1.3-1.8-1.6-2.4-.8-1 1.2-.3 3.2 1.6 3.8a3 3 0 0 0 3-1.1c.8-1 .8-2.3.5-3.2-.2-.7-.7-1.2-1.4-1.7-2.2-1.7-5.7-1.3-6.8 1.5-1.5 3.6 1.7 6.3 4.7 8.3 3.8 2.5 8 3 11.3 3 7.3-.1 12.9-3.6 16.5-5.6.8-.5 1.7-.4 2.1.2.5.6.5 1.5-.2 2.2"/>
|
||||
<path stroke-width=".5" d="m198.4 289-1.6.5-1.7 1.3-.7 1-.9 1.6-.4 1.2-.3 1.5-.2 1m15.2-8v1.4l-.3 1-.7 1.7-1.1 1.5-1.2 1-1 .4-1.2.3"/>
|
||||
<path stroke-width=".6" d="M255.8 327.3c-.3 1.3-1.5 2.8-4.3 3.4h-.5"/>
|
||||
<path stroke-width=".7" d="M323.4 285a14.6 14.6 0 0 1 4.5 10.8c-.1 2.8-.8 6-1.6 7.5-.7 1.3-2.5 7.2-12.3 11.2a67.7 67.7 0 0 1-20.5 3.1c-8.2.4-15.8.7-20.3 6"/>
|
||||
<path stroke-width=".5" d="M310 290.3c.6-.9 2.8-1.9 4.6-1a5 5 0 0 1 2 1.7"/>
|
||||
<path stroke-width=".7" d="m321.3 283 1.1.4a5.6 5.6 0 0 0 3.2 0c2.2-.6 3.7-2.7 2.5-5.5a4.5 4.5 0 0 0-1.4-1.7"/>
|
||||
<path stroke-linecap="round" stroke-width=".5" d="M192.2 223.8c-1.5 1-2.6 1.2-3.8 2.5a22.5 22.5 0 0 0-2.1 5.5m36.9-41.4c0 1.4-1 2.3-2.4 2.6"/>
|
||||
<path stroke-width=".5" d="M317.7 217.6c3.8 0 14.8 2.9 14.9 15.8 0 12.8-8 14.9-11.1 15.7"/>
|
||||
<path stroke-width=".5" d="M318.7 217.6c6.5-.3 13.2 4.5 13.5 16.5.3 9.4-6.4 13.6-9.6 14.5m-7.6 14.1.2-1.2.4-2 .6-1.7.7-1.3.8-1m6.3-2.7-.1 1.2-.4.9-.5.8-.7.5-1 .3h-1.5m-11.4-42.3.3-1.3.6-1.3.7-1.2 1.4-1.7 1-1.2 1.7-1.7 1.5-1.5 1-1.1 1.2-1.5 1-1.7.7-1.3.4-1.8.1-2.1-.2-.7M310 296.7l1.3-.3 1-.5.5-.5.4-.7.2-1v-.6M187 283.3l.9.1h1.2l1.3-.5m4-29.3-.2 1.2-.2.4-.4.5-.5.4-.6.3-.8.1h-.5m8-12.5-.3 1.8-.4.7-.7 1-1 .7-.9.5-1.8.4m12.2-31.8-.3 1-.5.8-.6.9-.8.7-1 .5-.8.2h-.6m.3-5v.8"/>
|
||||
<g stroke-width=".5">
|
||||
<path stroke-linecap="round" d="M203.4 243.3a5.5 5.5 0 0 1-1.6 1M322.2 280l.4.2c1 .7 3.3-.2 2.7-2"/>
|
||||
<path d="M318.2 255.7c.8 1 2.4 1.3 3.6 1 .9-.2 1.5-.8 2-1.6.4-.8.6-1.9.3-3a4 4 0 0 0-.7-1.3 5.4 5.4 0 0 0-1.1-1.1l-.3-.2m5.2 27.2a3.1 3.1 0 0 0 0-.6c0-.9-.4-1.7-1-2.3-.2-.2-.4-.5-.7-.6m.4.3c0-1.5-1.3-2.5-2.8-2.8m-3.3 2.3c-.4-.3-.8-.5-1-.9a12.6 12.6 0 0 1-3.5-8.5c0-3.3 1.3-6.7 2.8-8M273 323.3l1.5-1.3 1-.8 1.8-1.1 1.8-.9 1.2-.3 2.5-.5 2.9-.5M262 333.4a14.1 14.1 0 0 1-6.1 5 14.1 14.1 0 0 1-6.1-5"/>
|
||||
<path stroke-linecap="round" d="M251.5 330.1a8 8 0 0 1-1.7 3.3"/>
|
||||
<path d="m251.8 328.4-.4 1.8m-1.8 3.3-.8.8-1.4.7-1.5.5m-4.5-142.2c.2-.6.4-1.1.3-2.1 0-3.4-2.5-4.9-3.5-8-.6-1.8-.9-3.4.5-4.8 1.2-1.1 2.5-1 4-.5 3.2 1.2 6.9 4.7 13.4 4.8-6.5-.1-10.2-3.6-13.3-4.8-1.6-.6-3-.8-4.2.4-1.4 1.3-1 3-.4 5 1 3 3.3 4.5 3.4 7.9 0 1-.2 1.5-.4 2m14.9-10.7c6.4-.4 11.9-4.7 13.7-5 1.6-.3 2.4-.2 3.6.9-1.2-1.1-2.5-1-4-.5-3 1.2-6.8 4.7-13.3 4.8m63.7 90.3a12.4 12.4 0 0 1-5-9.9c0-3.3 1.3-6.7 2.9-8m-56 78a14.1 14.1 0 0 1-6 5 14 14 0 0 1-6.2-5"/>
|
||||
<path stroke-linecap="round" d="m245.3 195 1.9-1c.8-.6 1.9-.5 2.3 0 .5.7.6 1.7-.1 2.3"/>
|
||||
<path d="M235.8 199.4c4.4-.9 8-2.9 10.6-4.4m25.9 131.4.6.7.2.7c.2 1.2-.6 2-1.5 2.1a2.7 2.7 0 0 1-2.8-1.6m-33.3-129.1c4.4-1 8-2.9 10.7-4.4m78 85.5c-.7.3-1.2.3-2.2-.2l-1.5-.8c-2-1.1-4.5-3-6.8-7.2a15 15 0 0 1-1.3-3.6c-.2-.9-.4-1.8-.4-2.7a20.5 20.5 0 0 1 .5-5 16.2 16.2 0 0 1 3.2-7.2c1-1.3 1.7-2 3.5-2m-115-31.5a5.7 5.7 0 0 1 2.1 4.6c0 2.5-2 6.5-7.2 8-2 .5-3.8 0-5-.7"/>
|
||||
<path d="M205 228.5c1 .6 1.3 1.4 1.3 2.6 0 .8-.5 2-1.5 3a9.9 9.9 0 0 1-7 3.2 8.2 8.2 0 0 1-4.8-1.4 7.3 7.3 0 0 1-3-4.3"/>
|
||||
<path d="M205 233.8c1 1 1.3 2.2 1.3 3.7 0 2.2-.9 3.9-3 5.7a5 5 0 0 1-1.5 1m103.6-17.6v2.9m-.3-3.6v4m.3-12.6v5.2m-.3-6.3v7m-1.5 65.7c-1 2-1.8 3-3.3 4.5a15.7 15.7 0 0 1-4.7 3.3 19.7 19.7 0 0 1-5.2 1.7c-2.1.5-3.4.6-5.5.7-2 0-3.1 0-5.1-.2-2.1 0-3.3-.4-5.4-.6-1.7-.1-2.7-.3-4.5-.3a22.8 22.8 0 0 0-8.7 1.5c-2.2.9-4.6 2.4-5.1 3-.5-.6-3-2.1-5.1-3a22.8 22.8 0 0 0-8.8-1.5c-1.7 0-2.7.2-4.4.3-2.1.2-3.3.5-5.4.6a37.3 37.3 0 0 1-10.6-.5c-2.1-.5-3.3-.8-5.3-1.8a15.7 15.7 0 0 1-5-3.7m33.6 42.7 1.5-.2m24.2-1.9 1.4-.1 1.4-.6 1-.5 1.3-1.6.3-.6.2-1.3v-.6M314 218.8c.6-2.1-.2-4.3-2.2-4.3m-105.6 37.3a6.5 6.5 0 0 1-2.9 3.7m3-37.4a5.2 5.2 0 0 1-3 3.2c-1.4.7-3.2 0-4-.6"/>
|
||||
<path stroke-linecap="round" d="M195 225.9c1.3.6 2.5-.3 2.3-1.9a2.3 2.3 0 0 0-2-1.8"/>
|
||||
<path d="M200.1 293.3c.3.3.4.6.7.6.5.1 1 .3 1.5-.4.7-.9.3-2.2-.4-3.1a4 4 0 0 0-4.7-.7c-.5.3-1.3.7-2 1.6l-.9 1.3-.9 2c-1.6 4.6.3 9.5 3.4 12.5"/>
|
||||
<path stroke-linecap="round" d="m272.2 326.3.5.6.2.7c.2 1.2-.6 2-1.6 2-1.3.2-2.2-.6-2.7-1.6"/>
|
||||
<path d="M311.6 187.8a6 6 0 0 1 5 5.6c0 3.6-1.2 4.9-3.1 7.4-2 2.7-8.5 7.7-8.5 13.4 0 3.4 1 5.6 3.4 6.7 1.6.7 3.5 0 4.3-.8 2-1.9 1.3-5.2-1-5.6-2.5-.4-3 3.7-.5 3.4m14.3 55.3a3 3 0 0 0-2.9-2.5 3 3 0 0 0-3 3c0 .8.4 1.5.9 2"/>
|
||||
<path d="M307.1 220.1a5.7 5.7 0 0 0-2.1 4.6c0 2.5 2 6.5 7.2 8 1.9.5 3.8.4 5-.3m-124.9-8.2a7.5 7.5 0 0 0-3.8 2.7 13.5 13.5 0 0 0-1.9 4.9c-.1.7-.3 3 .1 5.3a12.7 12.7 0 0 0 1.9 4.5l.8 1 .9.7m51.2 73.6c3.9 1.8 6.7 3 9.2 6.9a8.2 8.2 0 0 1-1.7 10 6.6 6.6 0 0 1-5.4 1.6c-1.5-.2-3-1.3-3.2-2m-37.2-90a6.6 6.6 0 0 1 3.1 6c0 3-1.5 4.8-3.2 5.9"/>
|
||||
<path stroke-linecap="round" d="M201.2 253.1c3.3 4.1 5 6.5 5.1 11.3.1 4.6-1.4 7.7-4 11"/>
|
||||
<path d="M263.8 199.5a3.3 3.3 0 0 0 1.3-1.8c.4-1.2.4-2.2-.3-3.1.8 1 .9 1.9.7 3.1-.2.8-.7 1.2-1.3 1.8m41.2 69v12.8a19.6 19.6 0 0 1-.3 3.4m0-17.5V283l-.4 2.1m.4-34.3v11.6m.3-10.7v9.4m0-21.5v7.1m-.3-7.9v8.8m.3-15.2v2.8m-.3-3.4v4m-1.4 52.2-.3.5a15 15 0 0 1-3.4 4.6 15.7 15.7 0 0 1-4.6 3.2 19.7 19.7 0 0 1-5.3 1.8c-2 .5-3.3.6-5.5.7-2 0-3 0-5-.2-2.2-.1-3.3-.4-5.4-.6-1.8-.1-2.8-.3-4.5-.3a22.9 22.9 0 0 0-8.8 1.5c-2.1.9-4.5 2.4-5 3a17 17 0 0 0-5.1-3 22.9 22.9 0 0 0-8.8-1.5c-1.7 0-2.7.2-4.5.3-2 .2-3.2.5-5.4.6a37.3 37.3 0 0 1-10.5-.5 19.8 19.8 0 0 1-10-5 17.6 17.6 0 0 1-1.9-2.3m-1.6-2.5a8 8 0 0 1-1.8 6.2c-.7.7-2.2 2-4 2-3 .1-4-2-4.1-2.5"/>
|
||||
<path d="M204.5 287a8.2 8.2 0 0 1 1.5 2.1c.7 1.4.5 3.8-.1 5a3.7 3.7 0 0 1-.3.3m-16.1 14.4c1.8 2 4.5 4 8.7 5.7a67.4 67.4 0 0 0 20.5 3.1c8 .3 15.5.7 20 5.7m13.9-3.3a12 12 0 0 1 3.2 4.5m-5.9 9.2a7 7 0 0 1-.5.5 6.6 6.6 0 0 1-5.3 1.6 5 5 0 0 1-3.5-2m-4.3-2.4.3.3a6 6 0 0 0 4 2m21.6 0a14.1 14.1 0 0 1-6.1 4.9 14.1 14.1 0 0 1-6.1-5l-.2-.3m12.4.3.6.6a6.6 6.6 0 0 0 5.3 1.6 4.4 4.4 0 0 0 3.3-2l.4-.6"/>
|
||||
<path d="m271.2 333.3-.6 1-.9.7-1.3.6H267"/>
|
||||
<path d="M274.4 324.2a6.1 6.1 0 0 1 1.9 2.3c.2.6.4 1.3.4 2a4.7 4.7 0 0 1-1.1 3.2 6 6 0 0 1-4.4 2 4.4 4.4 0 0 1-.3 0m.1-.2a5.5 5.5 0 0 1-4.1-1.7m51-54.3a19 19 0 0 1-4-5.2 15 15 0 0 1-1.3-3.6c-.2-.9-.4-1.7-.4-2.6 0-1.6.1-3.2.5-5a16.7 16.7 0 0 1 3.3-7.3c.5-.6 1-1.4 1.6-1.8m-1-60.6c2 .2 3.8 2.3 3.8 4.5 0 3.1-1 4.4-3.5 7.4-2.1 2.7-8.5 7.3-8.3 11.7 0 .8.5 1.6 1 2.2M307 220c.4.5 1 .8 1.6 1.1a4 4 0 0 0 3.4-.2m-16.9-34.6a4.8 4.8 0 0 1 1.8 2.1c1.4 3.6-1.8 6.3-4.8 8.3a17 17 0 0 1-6.6 2.6"/>
|
||||
<path d="M291.7 193.2c-.7 0-1.6-.2-2.5-1.2a2.7 2.7 0 0 1-.6-.7m-11.9 3.9a3.7 3.7 0 0 1-1-.8c-.7-.8-1.2-1.9-.7-3.5.5-1.5 3-5.8 3-8.7.3-4.5-1.5-7.2-4.2-8.2"/>
|
||||
<path stroke-linecap="round" d="m277.9 181.2-.1 1.7-.5 1.7-.9 2.3-.7 1.6-.7 1.5-.3 1-.2.8.1.8m30.5 101c0 .3.4.6.4.6a6.2 6.2 0 0 0 4.4 2.5c3 0 3.7-2.1 3.8-2.6.4-2.3-.4-3-1.6-3.6 0 0-.7-.3-1.5-.2"/>
|
||||
<path d="M189.6 283.5a5.5 5.5 0 0 1-3 0c-2.3-.7-4-2.9-3.1-5.5m10.7-25.5c.2.2.3.6.3.8.3 3-2.2 3.8-4 3.4a4.5 4.5 0 0 1-2.5-1.9 3.8 3.8 0 0 1-.5-1.8m17.7-19c.4.5.8 1 1 1.5m-1-6.8c.5.3.8.6 1 1"/>
|
||||
<path stroke-linecap="round" d="M206.3 232.4a6.8 6.8 0 0 1-1.3 2 9.9 9.9 0 0 1-7 3.1 8.2 8.2 0 0 1-4.8-1.4 7.6 7.6 0 0 1-3.3-4.4"/>
|
||||
<path d="M204.3 220.2a6.2 6.2 0 0 1 2 2.7"/>
|
||||
<path stroke-linecap="round" d="M206.3 226.6a9.4 9.4 0 0 1-7 6.3 7 7 0 0 1-5.2-.9"/>
|
||||
<path d="M192 226c.2 2.1 1.7 3.7 4.3 3.8 3.8 0 6-5.4 2.7-9.3"/>
|
||||
<path stroke-linecap="round" d="M183.6 244.4c.5.7 1.2 1.3 1.8 1.9a13.4 13.4 0 0 0 4.8 2.6m4.2.4c3.4-.4 5.3-2.9 4.9-5.8-.3-2.3-2.4-4-3.8-4"/>
|
||||
<path d="M199.9 214.5c1.4 0 2.3 1.3 2.2 2.4"/>
|
||||
<path stroke-linecap="round" d="M199.5 194.5a9.2 9.2 0 0 0 4 4.6M319 224a3.7 3.7 0 0 1-3.3 5.7 4.2 4.2 0 0 1-3.5-2"/>
|
||||
<path d="M305.4 199.3v12.6"/>
|
||||
<path stroke-linecap="round" d="M195 225.9c1.2.8 2.6-.6 2-2.1-.3-1-1.8-2.1-3.8-.8-2.1 1.5-1.5 6.3 2.7 6.3 3.7.1 6-5.4 2.7-9.2-3.2-3.7-9-2.9-13 .2a17.1 17.1 0 0 0-5.6 9.3 17 17 0 0 0 0 7.4 16.7 16.7 0 0 0 2.4 6l1 1.3 1.6 1.6a12 12 0 0 0 8.3 3c3.8-.1 6-2.8 5.5-5.9-.4-3-3.4-4.5-5.4-3-1.3.9-1.8 3.8.6 4.5 1.3.4 2.5-1.3 1.6-2.3m103.6-57.5c2.2-1.2 3.8-1 5 .7a7.9 7.9 0 0 1 1.3 5.8c-.4 2.2-1 3-2.8 4.6"/>
|
||||
<path stroke-linecap="round" d="M304.4 185.6c2.5-1.6 5.2-1 6.6 1.3a7.3 7.3 0 0 1 1.3 4.9 9 9 0 0 1-4.6 7.3"/>
|
||||
<path d="M316 191.3c2 .2 3.7 2 3.7 4.2 0 3-.8 4.4-3.3 7.4-2.1 2.6-8.4 7.2-8.3 11.7 0 1.6 1.5 3.2 2.7 3.3"/>
|
||||
<path stroke-linecap="round" d="M316.3 225.9c-1.2.8-2.6-.5-2-2 .4-1 1.8-2.2 3.7-.9 2.2 1.5 1.6 6.3-2.6 6.3-3.7.1-6.3-5.2-2.7-9.2 3.3-3.7 9.4-3 13.2 0 1.6 1.4 5 5 5.6 9.6.9 5.6.7 12.6-5 16.8a13.8 13.8 0 0 1-8.5 2.4c-3.8-.1-6-2.8-5.5-5.9.4-3 3.3-4.3 5.4-3 2.2 1.1 1.8 4.3-.6 4.5-1.4.2-2.5-1.3-1.6-2.3"/>
|
||||
<path d="M314.3 224c.6-2.9 3-3.1 5-3.1 5.2 0 8.9 6.3 9 12.4 0 7.6-3.3 12.1-9 12.3-1.3.1-3.8-.6-3.9-2.3"/>
|
||||
<path stroke-linecap="square" d="M317.5 222.7c5.6 1.2 7.6 6.2 7.6 11 0 3.9-.4 9.2-8 11"/>
|
||||
<path d="M326.7 276.3a3.1 3.1 0 1 0-5 1.8"/>
|
||||
<path stroke-linecap="round" d="M315.6 271.5a13.3 13.3 0 0 0 5 4.8m-1 8.4c-2.7-1.7-7.7-4-12.2-1.8a6.3 6.3 0 0 0-3.4 3.5 8 8 0 0 0 1.5 7.7 6 6 0 0 0 4 2.1c3 0 3.7-2 3.8-2.5.3-2.2-1-3.1-1.6-3.3-.6-.2-2.2-.2-2.6 1-.1.4-.1 1.1.2 1.6"/>
|
||||
<path stroke-linecap="round" d="M272.4 326.7c.8 1.8-.1 2.6-1.3 2.7-1.7.2-2.6-1.1-2.7-2.3-.2-2 1.5-3.9 3.5-3.8a4.4 4.4 0 0 1 4 2.8c.2.6.3 1.2.3 1.9a4.7 4.7 0 0 1-1.1 3.3 6 6 0 0 1-4.3 2c-3.4.1-6-3-6-6.3 0-6.1 9.1-9.5 12.8-10.4a67 67 0 0 1 14.3-1.8c2.9-.2 5-.1 8.1-.4 2.8-.3 4.3-.5 7.2-1.1a22 22 0 0 0 10-5.2 13.7 13.7 0 0 0 3.7-17.7 11.5 11.5 0 0 0-8.2-5.3c-3-.5-5.6.8-7.2 3.8a6.2 6.2 0 0 0 .1 5c.5.9 2 2.3 3.8 2.3 3 0 3.8-2 3.9-2.5.3-2.2-1-3.1-1.6-3.3-.6-.2-2.2-.2-2.6 1-.1.4-.1 1.1.2 1.6"/>
|
||||
<path stroke-linecap="round" d="M269.8 317c-4 1.7-6.8 3-9.2 6.7a7.9 7.9 0 0 0-1 4c0 2.1 1 4.5 2.7 6a6.6 6.6 0 0 0 5.4 1.7c1.5-.2 3-1.3 3.2-2"/>
|
||||
<path d="M308 243.3c-1.7.6-3 3.4-3 6 0 3 1.4 5 3.2 6"/>
|
||||
<path stroke-linecap="round" d="M310 253.1c-3.2 4.1-5 6.5-5 11.3-.1 4.6 1.3 7.7 4 11"/>
|
||||
<path d="m292.7 185.6.3-.4c1.3-2 3.7-2.5 5.5-1.2 2 1.6 2.6 4.3 2 7.2a7 7 0 0 1-3.2 4.4"/>
|
||||
<path stroke-linecap="round" d="M212 184.7c-2-1-3.7-.8-5 .7a7.5 7.5 0 0 0-1.2 5.8c.4 2.1 1 3 2.8 4.6"/>
|
||||
<path d="M206.9 185.6c-2.5-1.6-5.2-1-6.6 1.3a7.3 7.3 0 0 0-1.3 4.9 9 9 0 0 0 4.6 7.3"/>
|
||||
<path d="M199.7 187.8a5.5 5.5 0 0 0-4.8 5.3c0 3.6.9 5 2.9 7.7s8.5 7.7 8.5 13.4c0 3.4-1 5.6-3.4 6.7-1.6.7-3.5 0-4.3-.8-2-1.9-1.2-5.2.9-5.6 2.6-.4 3.1 3.7.6 3.4"/>
|
||||
<path d="M195.2 191.3c-2 .2-4 2-4 4 0 3.1 1.2 4.5 3.7 7.6 2 2.6 8 7.2 7.9 11.6 0 1.6-1.2 3.7-2.3 3.4"/>
|
||||
<path stroke-linecap="round" d="M190.5 252.9c1-2.3 3.4-1.3 3.5 0 .4 3-2.2 3.8-4 3.3-1-.2-1.6-.8-2-1.5a3.9 3.9 0 0 1 .4-4.3 4 4 0 0 1 1-1c.7-.4 1.5-.5 2.7-.5 4.4 0 8.3 5.3 9.6 10.8a23.6 23.6 0 0 1 .2 9.6 18 18 0 0 1-4.7 9 20.1 20.1 0 0 1-7.9 4.7 5.6 5.6 0 0 1-3.2 0c-2.2-.6-3.7-2.8-2.5-5.5 1-2.1 4.3-3.2 5.8-.6.1.3.3.7.3 1.3 0 .7-.3 1.5-.8 1.8-1.1.8-3 .6-2.9-1.5"/>
|
||||
<path d="M187 280.3c.8.3 1.3.3 2.3-.2l1.5-.8c2-1.1 4.5-3 6.7-7.2a15.1 15.1 0 0 0 1.4-3.6c.2-.9.4-1.8.4-2.7a20.5 20.5 0 0 0-.5-5 16.2 16.2 0 0 0-3.2-7.2c-1-1.3-1.7-2-3.5-2m-7.5 24.7a3.1 3.1 0 1 1 5 1.8"/>
|
||||
<path d="M185.8 273.2a3 3 0 0 1 2.9-2.5 3 3 0 0 1 3 3 3 3 0 0 1-1 2"/>
|
||||
<path d="M191.5 273a12.4 12.4 0 0 0 5-9.9c0-3.3-1.3-6.7-2.9-8"/>
|
||||
<path stroke-linecap="round" d="M195.7 271.5a13.2 13.2 0 0 1-5 4.8"/>
|
||||
<path d="M203.7 283c-.8-1.8-2.2-2.6-4.6-2.9a11 11 0 0 0-6.6 1.6 14.8 14.8 0 0 0-8 9 13.7 13.7 0 0 0-.6 4.6c0 2.9.8 6 1.6 7.5.6 1.4 2.4 7.2 12.2 11.2a67.7 67.7 0 0 0 20.6 3.2c8.3.3 16 .6 20.4 6.1"/>
|
||||
<path stroke-linecap="round" d="M191.7 284.7c2.7-1.7 7.6-4 12.1-1.8a7 7 0 0 1 3.5 3.5 8 8 0 0 1-1.5 7.7c-.7.7-2.1 2-4 2.1-3 0-3.7-2-3.8-2.5-.3-2.2 1-3.1 1.6-3.3.5-.2 2.2-.2 2.6 1 .1.4.1 1.1-.2 1.6"/>
|
||||
<path d="M202.2 292.6a2.7 2.7 0 0 0-.7-2.7 4.1 4.1 0 0 0-4.7-.7 5 5 0 0 0-2 1.7l-1 1.2-.8 2c-2 5.6 1.2 11.6 5.2 14.1a24 24 0 0 0 11.5 3.3l5.3.3h13.4l6 .6 4.4.8 1.5.4 1 .3a31.9 31.9 0 0 1 7.7 3.3c.7.4 1.5.8 2 1.4l1.1 1c1.5 1.4 3.1 3 3.5 5.3v1.3c0 1.4-1.1 3.4-4.3 4"/>
|
||||
<path d="M239 326.7c-1 1.8 0 2.6 1.2 2.7 1.7.2 2.6-1.1 2.7-2.3.2-2-1.5-3.9-3.5-3.8a4.4 4.4 0 0 0-4 2.8 5.5 5.5 0 0 0-.3 1.9 4.7 4.7 0 0 0 1 3.3 6 6 0 0 0 4.4 2c3.4.1 6-3 6-6.3 0-6.1-9.1-9.5-12.8-10.4a67 67 0 0 0-14.3-1.8c-2.9-.2-5-.1-8.1-.4-2.8-.3-4.3-.5-7.2-1.1a22 22 0 0 1-10-5.2 13.7 13.7 0 0 1-3.7-17.7 11.5 11.5 0 0 1 8.2-5.3c3-.5 5.6.8 7.1 3.8.8 1.4.6 3.8 0 5a4.8 4.8 0 0 1-3.9 2.3c-3 0-3.7-2-3.8-2.5-.3-2.2 1-3.1 1.6-3.3.5-.2 2.2-.2 2.6 1 .1.4.1 1.1-.2 1.6"/>
|
||||
<path stroke-linecap="round" d="M218.6 185.6a97 97 0 0 0-.3-.4c-1.3-2-3.7-2.5-5.5-1.2-2 1.6-2.6 4.3-2 7.2a7 7 0 0 0 3.2 4.4"/>
|
||||
<path d="M293.4 191.7c-3.2 3.5-6.5 4.6-11.3 4.8-1.5 0-4.4-.5-6-1.7-1-.8-2.3-2-1.5-4.4.5-1.5 3-5.7 3-8.7.2-4.5-1.5-7-4.2-7.9-5-1.8-10.4 3.2-13.6 4.3a11 11 0 0 1-4.1.6c-1.6 0-2.5 0-4.2-.6-3.2-1.1-8.6-6-13.6-4.3-2.7 1-4.4 3.4-4.2 8 0 2.9 2.5 7.1 3 8.6.8 2.3-.4 3.6-1.5 4.4a11.6 11.6 0 0 1-6 1.7c-4.9-.2-8-1.3-11.3-4.8"/>
|
||||
<path stroke-linecap="round" d="M237.9 315.5c.6.3.1-.1 4.2 1.7 3.8 1.7 6.6 3.2 9 7a8.5 8.5 0 0 1 .7 5.9"/>
|
||||
<path d="M238.1 332.8a6.4 6.4 0 0 0 2.6.7c3.4.1 6-3 6-6.3 0-2.2-1.2-4-2.9-5.6"/>
|
||||
<path stroke-linecap="round" d="M238.9 326.7c-.9 1.9.3 2.8 1.5 3 1.7.2 2.6-1.2 2.8-2.4a3.6 3.6 0 0 0-1.7-3.3"/>
|
||||
<path d="M312 187.8c2.6 0 4.9 2.9 4.9 5.8 0 3.4-1.8 5.5-3.1 7-1 1.3-2.2 2.4-3.6 3.8"/>
|
||||
<path stroke-linecap="round" d="M309 185.1a5 5 0 0 1 2.3 2 7.3 7.3 0 0 1 1.2 4.9c-.1 3.4-2.5 5.7-4.7 7.1m-3.8-14 .5.6a7 7 0 0 1 1.2 5.7 6.5 6.5 0 0 1-3 4.4m-4-11.6c2 1.6 2.7 4.4 2 7.2-.5 2-1.8 3.3-3.3 4.2m8.9 32.9c.2.7.6 1 1.2 1.5a10.8 10.8 0 0 0 4.9 2.9 6.2 6.2 0 0 0 5-.7M187 275.4c1 0 2 .6 2.7 1.8a2.6 2.6 0 0 1 .3 1.2c0 .7-.3 1.4-.8 1.8-1.2.7-3.2.4-3.1-1.7"/>
|
||||
<path d="M193.2 249c4 .8 7.7 5.5 9 10.7a23.6 23.6 0 0 1 .2 9.6 18 18 0 0 1-4.7 9c-.5.6-1 1-1.7 1.5l-.9.6m-6.3-9.7c1.6 0 3 1.5 3 3.2a3 3 0 0 1-.8 2"/>
|
||||
<path d="M187.7 272.6c1.7 0 3.3 1.6 3.3 3.3a3.1 3.1 0 0 1-1.2 2.5"/>
|
||||
<path stroke-linecap="round" d="M203.2 255.6c1.5 2 2.6 3.9 3 6.2m0 6.8a13.8 13.8 0 0 1-1.2 3.2 14.2 14.2 0 0 1-2.8 3.7"/>
|
||||
<path d="M203.4 243.5a7.5 7.5 0 0 1 2.8 3.8"/>
|
||||
<path stroke-linecap="round" d="M206.3 239.6a8.7 8.7 0 0 1-2.7 3.7m-7.3-13.8 1.7-.4 1-.8.7-1 .5-1.4.3-1.2"/>
|
||||
<path d="m192.8 223.4-2 .7a7 7 0 0 0-2.8 2.4 13.5 13.5 0 0 0-1.8 4.8c-.2.7-.4 3 0 5.3a12.6 12.6 0 0 0 2 4.6l.8 1c1 1 2 1.7 3.5 1.4"/>
|
||||
<path stroke-linecap="round" d="M202.4 215.8c-.2 1-.8 2.3-2.4 2.2"/>
|
||||
<path d="M196.5 222.8c-1.5-1.5-4.8-1.9-8 .2-.5.2-.9.6-1.3 1a7 7 0 0 0-1.1 1.2l-1.2 2a10 10 0 0 0-.7 2c-.6 2.3-.6 4.5-.6 5l.3 2.2a15 15 0 0 0 1.8 5 8.2 8.2 0 0 0 6.2 4.3c1.4.1 3.9-.6 4-2.4"/>
|
||||
<path stroke-linecap="round" d="M291 189.7c.2-1.4 1.8-1.6 2.4-.8 1 1.2.4 3.2-1.5 3.8a3 3 0 0 1-3-1.1c-.9-1-.8-2.2-.5-3.2.2-.7.7-1.2 1.4-1.7 2.1-1.7 5.7-1.3 6.8 1.5 1.5 3.6-1.7 6.3-4.7 8.3-3.8 2.5-8 3-11.3 3-7.3-.1-12.9-3.6-16.5-5.6-.8-.5-1.7-.4-2.1.2-.5.6-.5 1.5.2 2.1"/>
|
||||
<path stroke-linecap="round" d="M292.5 188.4c.8 0 1 .4 1.2.7 1 1.2.3 3.2-1.6 3.8m14.3 41.2c-2.8 3-.3 8.3 1.8 9.5.7.5 1 .2 1.6.6"/>
|
||||
<path d="M306.5 228.3c-1 .7-1.2 1.4-1.3 2.6a4.2 4.2 0 0 0 1.2 3.2 11.2 11.2 0 0 0 7.3 3 8.2 8.2 0 0 0 4.9-1.4 7.3 7.3 0 0 0 3-4.3M305 281v2c-.4 2.2-.7 3.5-1.7 5.5a15 15 0 0 1-3.4 4.5 15.7 15.7 0 0 1-4.7 3.3 19.7 19.7 0 0 1-5.2 1.8 33 33 0 0 1-5.5.6h-5l-5.5-.7c-1.7-.2-2.7-.3-4.4-.3a22.8 22.8 0 0 0-8.8 1.5 17 17 0 0 0-5 3c-.6-.6-3-2.2-5.2-3a17.6 17.6 0 0 0-4.1-1.2c-1.8-.3-2.8-.3-4.6-.3-1.8 0-2.7.1-4.5.3-2 .2-3.3.5-5.4.6-2 .1-3 .2-5 .1-2.2 0-3.4-.2-5.6-.6a19.7 19.7 0 0 1-5.2-1.8c-2-1-3-1.7-4.7-3.3a15 15 0 0 1-3.3-4.5 15.1 15.1 0 0 1-1.7-5.5v-83.4H305z"/>
|
||||
</g>
|
||||
<g fill="#c7b37f" stroke="#c7b37f">
|
||||
<path stroke-width=".3" d="M198.3 292.5a2 2 0 1 1 4 0 2 2 0 0 1-4 0zm-12.2-14.1c0-1 .6-1.8 1.4-1.8.8 0 1.4.8 1.4 1.8s-.6 1.8-1.4 1.8c-.8 0-1.4-.8-1.4-1.8z"/>
|
||||
<path stroke="none" d="M193 242.9c0-.8.7-1.5 1.4-1.5.8 0 1.4.7 1.4 1.5s-.6 1.4-1.4 1.4c-.7 0-1.3-.6-1.3-1.4zm24.6-52.5c-.1-.9.4-1.6 1-1.6.7-.1 1.4.5 1.5 1.3 0 .8-.4 1.5-1.1 1.6-.7 0-1.4-.5-1.4-1.3"/>
|
||||
</g>
|
||||
<g stroke="#c7b37f" stroke-linecap="round" stroke-width=".5">
|
||||
<path d="M191.4 251.2a1.8 1.8 0 0 0-.6.4l-.5.7-.2 1m3.8 21.3.7-.8.6-.8.4-.7.5-1m-1 11-1.2.6-.9.5a14 14 0 0 0-1 .7l-1 .8m12-30.3-.6-.7-.7-.7-.8-.5"/>
|
||||
<path stroke-linecap="butt" d="m203.3 244-1 .4a4 4 0 0 1-1.1.2"/>
|
||||
<path d="M190 230.8c0 .4.1.7.3 1.1l.7 1.4a6.8 6.8 0 0 0 2.2 2.1l1.2.7m-.9-4.7 1 .5a6 6 0 0 0 2.4.5l1.5-.1m5.7-32.5-1.6-1a9.6 9.6 0 0 1-2.4-2.3l-.7-1m6-3.6.5 1.3 1.2 1.7c.7.8 1.3 1 2.2 1.6m1.1-4.7.5 1.2.7 1 1 1c.6.5 1 .6 1.6 1"/>
|
||||
</g>
|
||||
<path fill="#703d29" stroke-width=".1" d="M266.6 185.3c0-1.4-1.3-1.5-1.9-1.5-1.4 0-1.8 1-3.7 2a9.5 9.5 0 0 1-5.3 1.4 9 9 0 0 1-5.4-1.5c-1.9-1-2.2-1.9-3.6-1.9-.8 0-1.9.7-1.8 2v.7s.2 0 .2.2c0-.7.1-1 .4-1.4a1.8 1.8 0 0 1 1.3-.7c1.5 0 2 1 3.9 2a9.5 9.5 0 0 0 5.3 1.5c2 0 3.1-.3 5.4-1.5 1.9-1 2.4-2 3.9-2 .5 0 .8.3 1 .8v.7h.2c0-.1.2-.2.1-.8z"/>
|
||||
</g>
|
||||
<g fill="#703d29">
|
||||
<path d="M211.5 299.2c.4-.4.8-.3.8-.5l-.2-.2-.7-.2-.7-.3s-.3-.2-.4 0c0 .3.9.3.5 1.1 0 .2-.1.5-.6 1l-2.1 2.3-.2.2V299l.1-1.4c.2-.4.6 0 .7-.3 0-.2 0-.2-.2-.3-.2 0-.4 0-1-.3l-.7-.3c-.1 0-.4-.2-.5 0l.1.2c.3.2.4.3.4.7v6c0 .4.1.6.2.6l.3-.2 4.2-4.6z"/>
|
||||
<path d="M214 300.1c.3-.8.8-.3.9-.6l-.3-.2-1-.3-1-.4h-.3c0 .4 1 .4.7 1.3l-1.4 4.4c-.3.8-.8.4-.9.7v.1l1 .3 1.2.4h.3c.1-.3-1-.2-.6-1.3zm3 1c.1-.6.4-.6.7-.5.8.3 1 1.1.8 2-.2.5-.4 1-1.6.7-.3-.1-.6-.2-.5-.4zm-2.3 3.9c-.4 1.1-1 .6-1 1l.2.1 1.3.4.7.2h.3c0-.4-.9-.2-.6-1.2l.5-1.6c0-.3 0-.4.5-.3.4.2.5.3.6.7l.3 1.7c0 .6.2 1.3.8 1.4.3.2 1 .1 1-.2v-.1h-.3l-.3-.3-.5-3 .6-.2c.3-.2.6-.4.8-1 .1-.4.3-1.7-1.5-2.3l-1.6-.4-1-.3h-.2c-.1.4.9.3.6 1.3l-1.2 4zm6.7 2c-.2 1-1 .4-1.2.7 0 .2.1.3.3.3l1.2.2 1.1.4.5-.1c0-.3-1.1-.3-.8-1.4l1-4.2c0-.5.2-.5.5-.4l.7.2c1 .2.5 1.1.8 1.2.3 0 .2-.3.3-.5v-1.1l-2.6-.6-2.5-.6c-.2 0-.2 0-.2.2l-.5 1.2v.3c.5.1.5-1.2 1.4-1l.7.2c.4.1.5.2.4.6l-1 4.3zm10.2-2.7c.3-.5.7-.4.7-.6l-.3-.2h-.7l-.7-.2c-.1 0-.4-.1-.4 0 0 .4.9.2.7 1 0 .2-.1.6-.5 1.1l-1.7 2.7-.1.2v-.3l-.6-3.2a4.3 4.3 0 0 1-.1-1.3c0-.4.5-.2.6-.5l-.3-.2-1-.1-.8-.2c-.1 0-.4-.1-.4 0l.1.2c.4.2.5.3.5.7l1.1 5.9c.1.4.2.5.3.5l.2-.2zm.5 5.4.1.4 1.4.6c1.1.2 2-.5 2.3-1.7.2-1.2-.3-1.7-1.2-2.3-1-.8-1.5-1-1.3-1.6 0-.6.5-1 1-.8 1.5.2 1.4 2 1.6 2 .1 0 .2 0 .2-.3l.1-1.3v-.3h-.5c-.3 0-.5-.4-1.2-.5-1-.2-1.8.5-2 1.6-.2 1 .2 1.4 1 1.9 1.2.9 1.7 1 1.6 1.9-.2.7-.8 1.1-1.4 1-1-.2-1.3-1.1-1.5-2l-.1-.3c-.2 0-.2.3-.2.4v1.3zm12.6-3.5c.3-.6.6-.5.7-.7 0-.2-.2-.2-.3-.2h-.8l-.7-.1-.4.1c0 .4 1 0 .8 1 0 .1 0 .5-.3 1l-1.4 2.9-.2.2v-.2l-1-3.2a4.3 4.3 0 0 1-.2-1.3c0-.4.6-.3.6-.5s0-.2-.3-.2h-1l-.8-.1c-.1 0-.4-.1-.4 0l.1.2c.4.2.5.3.6.6l1.7 5.8c.1.4.2.5.3.5l.2-.3z"/>
|
||||
<path d="M246 310.8c0 1-.8.8-.8 1.2h1l1 .1.4-.1c0-.5-1.1.2-1.1-1.7v-3.4s.2 0 .3.2l4 5h.3v-.2l.1-5.3c0-1 .8-.8.8-1.1l-.2-.1h-2v.1c0 .3 1 .2 1 1v3.2l-.1.4-.3-.3-3.4-4.2c-.1-.2 0-.3-.3-.3h-1.4l-.1.2c0 .4 1-.2.9 1.7v3.6zm8.4-4.3c0-1 .6-.6.6-.9l-.3-.1h-2.3c0 .4.9.1.9 1v4.6c0 1-.6.7-.6 1v.1h2.3l.3-.1c0-.3-1 .1-1-1zm3.6 4.4c0 1.2-1 .7-1 1 0 .3.2.3.3.3h2.4c.3 0 .5 0 .5-.2 0-.3-1.1 0-1.1-1.2v-4.3c0-.5 0-.5.3-.5h.8c1 0 .7 1 1 1 .3 0 .2-.4.2-.5l-.1-.9s0-.2-.2-.2H256c-.2 0-.2.2-.2.3l-.1 1.2.1.4c.4 0 .1-1.3 1.1-1.3h.7c.4 0 .5 0 .5.5v4.4zm5-1.8h-.3v-.4l.6-1.8h.1l1 1.7v.3l-.2.1zm1.5.4c.2 0 .3 0 .6.8l.2.6c0 .6-.6.6-.6.8 0 .2.2.1.3.1h1l1-.1c.3 0 .4 0 .4-.2 0-.3-.5.1-.8-.6l-2.8-5.6-.2-.3-.2.4-1.9 5.9c-.2.5-.6.5-.6.7 0 .2.2.1.3.1h1.5c.2-.1.5 0 .5-.3 0-.2-1 0-1-.7l.1-.7c.2-.7.3-.7.5-.7zm6.6-4c0-.6 0-.6 1-.7 1.6-.3 1.1 1 1.5.9.2 0 .1-.4.1-.5l-.1-1h-.2l-2 .2-2.2.3c-.2 0-.2 0-.2.2 0 .3 1 0 1 .8l.6 4.4c.2 1.2-.5.7-.5 1.2h.2l1.1-.1 1-.1c.2 0 .4 0 .4-.2 0-.3-1 0-1.1-1l-.2-1.4c0-.5-.1-.6.3-.7h.6c.9-.2.8.9 1 .8.3 0 .2-.3.1-.5l-.2-1.6c0-.3-.2-.3-.2-.3-.2 0-.1 1-.8 1l-.6.1c-.4 0-.4 0-.4-.4zm3.2 2.2c.3 2 1.7 3 3.4 2.7 2.7-.5 2.8-3 2.5-4.2-.3-2-1.8-3-3.5-2.7-2 .4-2.8 2.2-2.4 4.2m.9-.7c-.3-1.4 0-2.7 1.4-3 1-.3 2.3.6 2.7 2.7.3 1.6 0 3-1.4 3.2-1.5.3-2.4-1.5-2.7-2.9m6.7-3.3c-.2-.6.1-.7.4-.7.8-.2 1.5.3 1.7 1.3.1.5.2 1-1 1.3-.3.1-.6.2-.7 0l-.4-2zm0 4.5c.3 1.2-.5 1-.4 1.3 0 .2.2.2.3.1l1.3-.3.7-.1c.2 0 .2-.2.2-.2 0-.4-.8.2-1-.8l-.4-1.6c-.1-.3-.2-.4.3-.5.4 0 .6 0 .9.3l1 1.3c.4.5.8 1 1.5.9.3-.1.8-.5.7-.7 0-.1 0-.2-.1-.1h-.6l-2-2.3.5-.5c.1-.3.3-.7.2-1.3-.1-.4-.6-1.7-2.5-1.2l-1.6.4-1 .2-.2.2c.1.4 1-.2 1.2.8zm6.9-1.5c.3 1-.8.9-.7 1.2 0 .2.2.2.3.2l1.2-.4 1.2-.3c.2 0 .4 0 .3-.2 0-.3-1 .2-1.3-.9l-1.1-4.2c-.1-.4 0-.5.3-.6l.7-.2c1-.3 1 .8 1.3.8.2 0 0-.4 0-.5l-.4-.9s0-.2-.2-.2l-2.5.7-2.5.7c-.2 0-.1.1-.1.2l.2 1.3c0 .1 0 .3.2.3.3-.1-.2-1.3.7-1.5l.7-.2c.3 0 .4 0 .6.4l1 4.3zm4.4-5.9c-.3-.9.4-.7.3-1h-.3c-.4 0-.7.2-1 .3l-1 .2s-.3 0-.2.2c0 .3 1-.2 1.2.6l1.2 4.4c.2 1-.4.8-.3 1.2l1-.2 1.3-.3c.2-.1.2-.2.2-.3 0-.3-.9.4-1.2-.7zm1.8 2.1c.6 1.9 2.1 2.7 3.8 2.2 2.6-.9 2.3-3.3 1.9-4.5-.6-2-2.3-2.7-3.8-2.2-2 .7-2.6 2.6-1.9 4.5m.8-.8c-.4-1.3-.4-2.7 1-3.2 1-.4 2.3.3 3 2.4.5 1.5.5 2.8-1 3.3-1.4.5-2.5-1.2-3-2.5m6.1-4.3c-.2-.6 0-.7.4-.8.8-.3 1.5.2 1.8 1 .2.6.4 1-.8 1.6-.3 0-.6.2-.7 0zm.7 4.5c.4 1-.4 1-.2 1.3 0 .2.2.1.3 0 .4 0 .8-.3 1.2-.4l.7-.3c.2 0 .2-.1.2-.2-.1-.3-.8.3-1.2-.6l-.6-1.5c0-.4-.2-.4.3-.6.4-.1.5-.1.9.2l1.2 1.2c.5.4 1 .8 1.6.6.3-.1.7-.5.6-.8 0 0 0-.1-.1 0h-.6l-2.2-2 .3-.5c.1-.3.2-.7 0-1.3-.2-.4-.8-1.6-2.6-.9l-1.6.7-1 .3v.2c.1.3.8-.4 1.2.6z"/>
|
||||
</g>
|
||||
<g fill="#fedf00" transform="translate(0 76.8)scale(.512)">
|
||||
<path fill="#d52b1e" d="M412.7 249.3h82.1v82h-82.1z"/>
|
||||
<path id="ad-a" fill="#fff" d="M451.2 313.8s0 3-.8 5.3c-1 2.7-1 2.7-1.9 4a13.2 13.2 0 0 1-3.8 4c-2 1.2-4 1.8-6 1.6-5.4-.4-8-6.4-9.2-11.2-1.3-5.1-5-8-7.5-6-1.4 1-1.4 2.8-.3 4.6a9 9 0 0 0 4.1 2.8l-2.9 3.7s-6.3-.8-7.5-7.4c-.5-2.5.7-7.1 4.9-8.5 5.3-1.8 8.6 2 10.3 5.2 2.2 4.4 3.2 12.4 9.4 11.2 3.4-.7 5-5.6 5-7.9l2.4-2.6 3.7 1.2z"/>
|
||||
<use xlink:href="#ad-a" width="100%" height="100%" transform="matrix(-1 0 0 1 907.5 0)"/>
|
||||
<path d="m461.1 279 10.8-11.7s1.6-1.3 1.6-3.4l-2.2.4-.5-1.2-.1-1.1 3-.7V260l.3-1.3-3.2.2.3-1.4.5-1 1.9-.4h1.9c1.8-3.4 9.2-6.4 14.4-1 3.8 4 3 11.2-2 13.2a6.3 6.3 0 0 1-6.8-1.1l2-4c2.7 1.7 5-.3 4.8-2.4-.2-2.7-2-4.3-4.3-4.5-2.3-.2-4 1-5 3-.6 1.3-.3 2.2-.5 3.6-.2 1.5 0 2.3-.5 3.8a8.8 8.8 0 0 1-2.4 3.6l-11 12-43 46.4-3.2-3z"/>
|
||||
<path fill="#fff" d="M429.5 283s2.7 13.4 11.9 33.5c4.7-1.7 7.4-2.8 12.4-2.8 4.9 0 7.6 1 12.3 2.8A171 171 0 0 0 478 283l-24.2-31z"/>
|
||||
<path d="m456.1 262.4 16.8 21.7s-2.2 10.5-9 26.3c-2.7-.6-5-1.1-7.8-1.3zm-4.7 0-16.8 21.7s2.2 10.5 9 26.3c2.7-.6 5-1.1 7.8-1.3z"/>
|
||||
</g>
|
||||
<g fill="#d52b1e">
|
||||
<path fill="#fedf00" d="M257.8 204.4H300v42h-42z"/>
|
||||
<path d="M263.7 204.4h6.3v42h-6.3zm12 0h6.3v42h-6.2zm12 0h6.3v42h-6.2z"/>
|
||||
</g>
|
||||
<g fill="#d52b1e" stroke="#d52b1e" stroke-width=".5">
|
||||
<path fill="#fedf00" stroke="none" d="M211.4 282.8c.2.8.4 2 1.1 3.4.8 1.2.5 1.2 2.2 3a13.8 13.8 0 0 0 6.7 3.6c3.4 1 5.7 1 8.5.9 2.2-.1 3.9-.4 5.3-.6 2-.2 3.4-.4 5.7-.5a32.4 32.4 0 0 1 3.1 0c1.2 0 2.4.3 3.7.5 2.8.6 5.6 1.7 5.6 1.7v-43.9h-42v30z"/>
|
||||
<path stroke-width=".3" d="m216.3 290.5 2 1.2 2.7 1v-41.8h-4.7zm23.4 2v-41.6H235v42.2l4.7-.5zm9.3-41.6h-4.6v41.7a31 31 0 0 1 4.6.8zm-18.6 0v42.7h-4.7v-42.7z"/>
|
||||
</g>
|
||||
<g transform="translate(0 76.8)scale(.512)">
|
||||
<path fill="#fedf00" d="M585.5 402.4a20.8 20.8 0 0 1-2.2 6.6c-1.5 2.3-1 2.3-4.3 6a26.3 26.3 0 0 1-13 7 51.8 51.8 0 0 1-16.6 1.6c-4.3-.2-7.5-.7-10.3-1-3.8-.6-6.7-.9-11-1a62.9 62.9 0 0 0-6.2 0 83.3 83.3 0 0 0-18.3 4.2V340h82.2v58.5z"/>
|
||||
<g id="ad-b">
|
||||
<path fill="#d52b1e" d="m524.6 347-.6.2-.8.8c-.4.4-.7.5-1.2.8l-.6.5c-.3.3 0 .6-.3 1-.1.4-.3.6-.6 1-.4.4-.7.5-1 1l-1.2 1-.3.1h-.6c-.4.2-.5.6-.8.8l.3.6.8 1.4c.2.3.2.7.5.8.5.2.9.2 1.3.1.8.2 1.3.2 2 .5l1.5.8c.5.3.8.4 1.3.5h1.8v.3l2 1a1.7 1.7 0 0 0-.1.4c-.1.3-.2.7-.1.8.6 1.9 1.2 3 1.5 3.2.6.2.8.9 1.1 1.5l-.3.3c-.6.6-1.2 1-1.7 1.8-.7 1.2-1.2 1.2-.3 2.8l1.5 2.4c.4.7.6 1.2.8 2 .2.7.3 1.2.3 2l1 .3.7-.6.6-1.2v-1c-.2-.1-.3-.4-.2-.7 0-.4.5-.3.7-.6.3-.5-.4-.8-.7-1.1-.6-.7-1.4-.9-1.6-1.9 0-.2 0-.4.4-.7l2-1.8c.2.1.6.2 1 .1l1.3.4c.6.2.9 0 1.2 0h.4l.1.6c.1 1-.1 3 .2 3.5l.3.6.2.6v2l-.2 1.7c0 .4-.2.7-.5 1-.2.4-.6.4-1 .7v1l1.1.5 1.3.3.7-.3.1-.6.5-.5c.4-.2.8 0 .9-.1.2-.3 0-.4 0-.8 0-.6-.2-1-.3-1.6a11.8 11.8 0 0 1-.1-2.8c0-.6 0-1 .2-1.5.1-1 .4-1.4.6-2.2.3-1 .3-1.6.4-2.5a24.4 24.4 0 0 0 10.1-.6c.8.7 1.7 1.2 2.7 1.6v1c0 .3 0 .4.2.7l.3.3c.3 0 .5 0 .7-.2.2-.2.2-.4.2-.7v-.7h1.8v1.1c.1.3.3.4.5.4a.7.7 0 0 0 .6 0c.3-.2.2-.6.3-1v-.7l1-.4a5.1 5.1 0 0 1 0 .9l-.3.9c-.2.6-.5.8-.8 1.4-.4.6-.5 1-1 1.5l-.6.7-.6.9-.9 1c-.7.6-1.2.2-2 .9l-.3 1 1.4.6 1.3.2.4-.2c0-.3 0-.6.3-.8.2-.3.4-.3.7-.4.4 0 .8 0 1-.2.4-.3.4-1 .7-1.5a12.7 12.7 0 0 1 3-3.9l1.7-1.4c.2-.4.5-.5.5-1l-.2-.6-.2-1c1.5.7 1 .7 1.2 1.4.3.6 0 1 .1 1.7.1.8.5 1.1.5 1.9.1.9-.1 1.4-.3 2.3-.1.8-.1 1.3-.5 2a3.8 3.8 0 0 1-1.1 1.5l-.6.5-.1 1 1.1.4 1.6.4.4-.3c.2-.7 0-1.7.4-1.7.4-.1.7 0 .8-.3v-.7l.7-4.5.4-1.9.4-1.7c.7-2-.2-2.3-1-3.6-.5-.7-.7-1-.7-1.5V362a42.7 42.7 0 0 1 0-2.8l.4-.2c1.2-.7 1.7-.9 2.4-2.5a3.4 3.4 0 0 0 .3-1.5v-1l-.4-1a3.2 3.2 0 0 0-.6-.8c-.7-1-1.7-1.1-2.7-1.5-1.5-.5-2.5-.4-4-.5-1.8-.2-2.7-.2-4.4 0-2 0-3.1.4-5.1.7l-4.9.4c-2.3 0-4.4-.5-5.8-.4-2.4.2-2.5.8-6.2 1.1a67 67 0 0 1-3.8.2l-2.2-.7c.9-.3 1.1-.5 1.5-1 .3-.4.2-.7.6-1.1l.7-1a2.2 2.2 0 0 0-.9-.4h-1a3 3 0 0 0-1.2.3l-.8.6-2.2-1.2a8.8 8.8 0 0 0-3-.9zm2 11.8"/>
|
||||
<g fill="none" stroke="#fedf00" stroke-linecap="round">
|
||||
<path d="m568.8 359.5-.8.3c-.9.4-1.6.4-2.6.5-2.6.2-4.3-1.1-7-.9-1.4.1-2 1.2-3.5 1.6a9.3 9.3 0 0 1-1.7.2l.5-1s-1.2.3-2 .3a7.5 7.5 0 0 1-1.6-.2l1-1-1.3-.2a4 4 0 0 1-1-.7 20.5 20.5 0 0 0 1.7-.3c1.5-.4 2-1.2 3.9-1.4 1.1 0 3 0 7.6.8 3 .5 4.4.2 5.5-.3.8-.3 1-1 1.1-1.8.1-.8-.4-1.4-.8-1.8-.1 0-.5-.3-1.1-.4"/>
|
||||
<path fill="#fcd900" stroke-linecap="butt" stroke-width=".5" d="M524.8 350.6c-.5 0-.9 0-1.3.3-.5.3-.6.7-1 1.1.5.1.8.4 1.2.3.4 0 .5-.2.8-.5.3-.4.4-.7.4-1.2z"/>
|
||||
<path d="M536 363.8a13.6 13.6 0 0 0 1 2.3c.2.8 0 1.2.2 2v1.6m6.8-7-.3 1.3-1 3.5v.7m-11-4c.9.2.6 3.3 1.9 4"/>
|
||||
<path stroke-linecap="butt" d="m560.1 369.8.4-.3a8.2 8.2 0 0 0 2.7-1.8"/>
|
||||
<path d="M552.4 368c3.5-.9 5.9-2.6 7.6-2.9m-4-1.5h.8c1.5-.3 1.7.6 2.7 1.2 1.9 1 2.1 2.3 4.3 3.4l.4.1.8.4"/>
|
||||
<path fill="#fcd900" stroke-linecap="butt" stroke-width=".5" d="M517.7 354.5h.7l.8-.2c.3 0 .5 0 .7.2.2 0 .2.1.3.3 0 .2.2.3.1.5 0 .2-.3.4-.6.4-.2 0-.4 0-.5-.3a.5.5 0 0 1 0-.4 1 1 0 0 1-.9 0 1 1 0 0 1-.6-.5z"/>
|
||||
</g>
|
||||
<path fill="#0065bd" d="m525.1 364.2-2-.9c.4-.2.7-.2 1-.5.3-.4.3-.8.5-1.3s.2-1 .7-1.4c.3-.2.8-.2 1.1-.1.4 0 .8.4.9.7 0 .6-.2 1-.3 1.5 0 .6-.3.9-.2 1.4 0 .4.2.6.4 1l-2-.4zm-1 1a.6.6 0 1 1 .7.5.6.6 0 0 1-.7-.6zm-1.7-16.6h-.2c-.4-.4-.4-.8-.6-1.2a4 4 0 0 1-.3-1.2v-2c0-.3 0-.6-.2-.9 0-.2-.4-.3-.3-.4 0-.1.3 0 .4 0 .4 0 .6.1 1 .4.3.3.5.6.6 1l.4 1.5.3.8.5.6-.7.8zm3.6 10.6 2.2 1a9.2 9.2 0 0 0 3.5-3.8c.9-1.8 1-2.7 1.4-4.4l-1.8-.5h-.4c-.5 1.8-.7 2.7-1.6 4.2-.8 1.3-1.7 2.3-2.6 3zm5 18.2.8-1.3 1.4-1.1h.4a8.7 8.7 0 0 1-.5 2.8l-.4 1-.5.5c-.5-.8-1.3-1.3-1.3-2zm33 1.8 1.4.6 1.5.9v.5l-1.5.2a8.4 8.4 0 0 1-1.3 0h-1l-.6-.4c.5-.7.8-1.6 1.4-1.8zm-9.8-2 1.4.5 1.5 1c0 .1.1.3 0 .4a9 9 0 0 1-2.7.3l-1-.1-.7-.3c.6-.7.9-1.7 1.5-1.8m-17.4 2.1 1.5.5 1.5 1v.5a9 9 0 0 1-2.8.2h-1l-.6-.4c.5-.7.8-1.6 1.4-1.8m-9-29.8c-.6-.3-1-1-.6-1.6.1-.2.4-.2.6-.4.2-.3.1-.5 0-.8l-.1-1-.2-1c0-.6 0-1 .4-1.6.2-.3.7-.6.8-.6.2.1 0 .5 0 .8 0 .5.1.7.3 1.2l.7 1.3c.2.6.4.8.4 1.4 0 .5 0 .7-.2 1.2a2 2 0 0 1-.6.8 2 2 0 0 1-.8.4 1.1 1.1 0 0 1-.6 0z"/>
|
||||
</g>
|
||||
<use xlink:href="#ad-b" width="100%" height="100%" y="36.6"/>
|
||||
</g>
|
||||
<path fill="none" stroke="#703d29" stroke-width=".4" d="M211.3 204.4h42v42h-42zm46.5 0H300v42h-42zm-46.4 78.4c.2.8.4 2 1.1 3.4.8 1.2.5 1.2 2.2 3a13.8 13.8 0 0 0 6.7 3.6c3.4 1 5.7 1 8.5.9 2.2-.1 3.9-.4 5.3-.6 2-.2 3.4-.4 5.7-.5a32.4 32.4 0 0 1 3.1 0c1.2 0 2.4.3 3.7.5 2.8.6 5.6 1.7 5.6 1.7v-43.9h-42v30zm88.4 0c-.1.8-.4 2-1.1 3.4-.8 1.2-.5 1.2-2.2 3a13.8 13.8 0 0 1-6.7 3.6 26.1 26.1 0 0 1-8.5.9c-2.2-.1-3.9-.4-5.3-.6a55.6 55.6 0 0 0-5.6-.5 32.4 32.4 0 0 0-3.2 0c-1.2 0-2.4.3-3.7.5-2.8.6-5.7 1.7-5.7 1.7v-43.9H300v30z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 32 KiB |
6
public/assets/flags/1x1/ae.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-ae" viewBox="0 0 512 512">
|
||||
<path fill="#00732f" d="M0 0h512v170.7H0z"/>
|
||||
<path fill="#fff" d="M0 170.7h512v170.6H0z"/>
|
||||
<path fill="#000001" d="M0 341.3h512V512H0z"/>
|
||||
<path fill="red" d="M0 0h180v512H0z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 274 B |
81
public/assets/flags/1x1/af.svg
Normal file
@@ -0,0 +1,81 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-af" viewBox="0 0 512 512">
|
||||
<g fill-rule="evenodd" stroke-width="1pt">
|
||||
<path fill="#000001" d="M0 0h512v512H0z"/>
|
||||
<path fill="#090" d="M341.3 0H512v512H341.3z"/>
|
||||
<path fill="#bf0000" d="M170.7 0h170.6v512H170.7z"/>
|
||||
</g>
|
||||
<g fill="#fff" fill-rule="evenodd" stroke="#bd6b00" stroke-width=".5" transform="translate(2.2 86.8)scale(.84611)">
|
||||
<path d="M319.5 225.8h8.3c0 3.2 2 6.6 4.5 8.5h-16c2.5-2.2 3.2-5 3.2-8.5z"/>
|
||||
<path stroke="none" d="m266.7 178.5 4.6 5 57 .2 4.6-5-14.6-.3-7-5h-23l-6.6 5.1z"/>
|
||||
<path d="M290 172.7h19.7c2.6-1.4 3.5-5.9 3.5-8.4 0-7.4-5.3-11-10.5-11.2-.8 0-1.7-.6-1.9-1.3-.5-1.6-.4-2.7-1-2.6-.4 0-.3 1-.7 2.4-.3.8-1.1 1.5-2 1.6-6.4.3-10.6 5-10.5 11.1.1 4 .6 6.4 3.4 8.4z"/>
|
||||
<path stroke="none" d="M257.7 242.8H342l-7.5-6.1h-69.4z"/>
|
||||
<path d="m296.4 219.7 1.5 4.6h3.5l-2.8-4.6zm-2 4.6 1 4.6h4l-1.5-4.6zm7 0 2.8 4.6h5.9l-4.6-4.6zm-34.5 10.4c3.1-2.9 5.1-5.3 5.1-8.8h7.6c0 2 .7 3.1 1.8 3h7.7v-4.5h-5.6v-24.7c-.2-8.8 10.6-13.8 15-13.8h-26.3v-.8h55.3v.8H301c7.9 0 15.5 7.5 15.6 13.8v7h-1l-.1-6.9c0-6.9-8.7-13.3-15.7-13.1-6 .1-15.4 5.9-15.3 13v2.2l14.3.1-.1 2.5 2.2 1.4 4.5 1.4v3.8l3.2.9v3.7l3.8 1.7v3.8l2.5 1.5-.1 3.9 3.3 2.3h-7.8l4.9 5.5h-7.3l-3.6-5.5h-4.7l2.1 5.4h-5l-1.3-5.4h-6.2v5.8H267zm22.2-15v4.6h5.3l-1-4.6H289z"/>
|
||||
<path fill="none" d="M289.4 211.7h3.3v7.6h-3.3z"/>
|
||||
<path fill="none" d="M284.7 219.8h3.2v-5.6c0-2.4 2.2-4.9 3.2-5 1.2 0 2.9 2.3 3 4.8v5.8h3.4v-14.4h-12.8zm25.6 3.3h4v3.2h-4zm-2.4-5.3h4v3.1h-4zm-3.9-5.4h4v3.1h-4zm-3.3-4.5h4v3.1h-4z"/>
|
||||
<path fill="none" d="m298 219.8 4.2.2 7.3 6.4v-3.8l-2.5-1.8v-3l-3.6-2v-3.3l-3.5-1.2V207l-1.7-1.5z"/>
|
||||
<path d="M315.4 210.3h1v7.1h-1z"/>
|
||||
<g id="af-a">
|
||||
<path d="M257.3 186.5c-1.2-2-2.7 2.8-7.8 6.3-2.3 1.6-4 5.9-4 8.7 0 2 .2 3.9 0 5.8-.1 1.1-1.4 3.8-.5 4.5 2.2 1.6 5.1 5.4 6.4 6.7 1.2 1 2.2-5.3 3-8 1-3 .6-6.7 3.2-9.4 1.8-2 6.4-3.8 6-4.6z"/>
|
||||
<path fill="#bf0000" d="M257 201.9a10 10 0 0 0-1.6-2.6 6.1 6.1 0 0 0-2.4-1.8 5.3 5.3 0 0 1-2.4-1.5 3.6 3.6 0 0 1-.8-1.5 5.9 5.9 0 0 1 0-2l-.3.3c-2.3 1.6-4 5.9-4 8.7a28.5 28.5 0 0 0 0 2.3c.2.5.3 1 .6 1.3l1.1.8 2.7.7a7.1 7.1 0 0 1 2.6 2 10.5 10.5 0 0 1 1.8 2.6l.2-.8c.8-2.7.7-5.9 2.6-8.5z"/>
|
||||
<path fill="none" d="M249.8 192.4c-.5 3.3 1.4 4.5 3.2 5.1 1.8.7 3.3 2.6 4 4.4m-11.7 1.5c.8 3 2.8 2.6 4.6 3.2 1.8.7 3.7 3 4.5 4.8"/>
|
||||
<path d="m255.6 184.5 1-.6 17.7 29.9-1 .6z"/>
|
||||
<path d="M257.5 183.3a2 2 0 1 1-4 0 2 2 0 1 1 4 0zm15.2-24h7.2v1.6h-7.2zm0 3.1h7.2v13.8h-7.2zm-.4-5h8c.2-2.7-2.5-5.6-4-5.6-1.6.1-4.1 3-4 5.6z"/>
|
||||
<path fill="#bd6b00" stroke="none" d="M292.6 155.8c-1.5.6-2.7 2.3-3.4 4.3-.7 2-1 4.3-.6 6.1 0 .7.3 1.1.5 1.5.2.3.4.5.6.5.3 0 .6 0 .7-.3l.2-.8c-.1-2-.1-3.8.3-5.4a7.7 7.7 0 0 1 3-4.4c.3-.2.4-.5.5-.7a1 1 0 0 0-.3-.7c-.4-.3-1-.4-1.5-.1m.2.4c.4-.2.8 0 1 .1l.1.2c0 .1 0 .2-.3.4a8.2 8.2 0 0 0-3.1 4.6 16.7 16.7 0 0 0-.3 5.6 1 1 0 0 1-.2.6s0 .1-.2 0c0 0-.2 0-.4-.3a3.9 3.9 0 0 1-.4-1.2c-.3-1.8 0-4 .7-6 .7-1.8 1.8-3.4 3-4z"/>
|
||||
<path fill="#bd6b00" stroke="none" d="M295.2 157.7c-1.5.7-2.5 2.3-3 4.2a13.6 13.6 0 0 0-.3 5.9c.2 1.3 1 2 1.6 2 .3.1.6 0 .8-.3.2-.3.3-.6.2-1-.4-1.6-.5-3.4-.3-5.1.3-1.7 1-3.2 2.2-4.1.3-.3.5-.5.5-.8a.8.8 0 0 0-.2-.6c-.4-.3-1-.4-1.5-.2m.2.5c.4-.2.8-.1 1 0l.1.3-.3.4a6.5 6.5 0 0 0-2.4 4.4c-.3 1.8-.1 3.7.2 5.2.1.4 0 .6 0 .8l-.5.1c-.3 0-1-.5-1.2-1.7-.3-1.7-.2-3.9.3-5.7.5-1.8 1.5-3.3 2.8-3.8"/>
|
||||
<path d="M272.3 187.4h8v11h-8zm.5 17.4h7.7v2.4h-7.7zm-.2 4.1h8v8.7h-8zm-.6 10.5h8.7v4.9H272zm1.1-16.6h7l1.4-2.4h-9.6zm9.4-8.6.1-6h4.8a17.4 17.4 0 0 0-4.9 6z"/>
|
||||
<path fill="none" d="M273.6 196.7c0 1.3 1.5.8 1.5.1v-5.6c0-1 2.4-.8 2.4-.1v6c0 1 1.7.9 1.6 0v-7c0-2.2-5.5-2.1-5.5-.1zm0 13.3h5.7v7h-5.7z"/>
|
||||
<path d="M277.2 213h2v1h-2zm-3.5 0h2v1h-2zm2-3h1.5v3h-1.5zm0 4h1.5v3.1h-1.5zM244 139c.4 5.5-1.4 8.6-4.3 8.1-.8-3 1-5.1 4.3-8.1zm-6.5 12.3c-2.6-1.3-.7-11.5.3-15.8.7 5.5 2 13.3-.3 15.8z"/>
|
||||
<path d="M238.4 151.8c4.4 1.5 8-3.2 9.1-8.7-3.6 5-9.5 5-9 8.7zm-3.3 5.1c-3.4-.9-1.4-11.7-.7-16 .7 4.5 3.1 14.5.7 16zm1.2-.3c.2-3.7 3.9-2.7 6.5-4.7-.5 2-2 5.2-6.5 4.7zm-4.2 5c-3.4-1-1.4-12.6-1.6-17.4 1 4.2 4.2 16.3 1.6 17.4zm1.6-.5c2.8.9 6.5-1 6.8-4.3-2.5 1.7-6.3.4-6.8 4.3z"/>
|
||||
<path d="M229.5 166.7c-3.2.3-1.8-9.6-1.8-18.8 1.2 8.6 4.5 16.5 1.8 18.8z"/>
|
||||
<path d="M230.7 166.3c2.2 1 6.1-.7 7.2-4.4-4 1.7-6.6 0-7.2 4.4zm25.6-22.2c-.6 4.9-2.6 7.7-5.5 7.2-.8-3 1.6-5 5.5-7.2zm-7.8 12.4c4.9.7 6.6-3 10-7.9-4.7 3.4-10.2 4-10 8z"/>
|
||||
<path d="M247 156c-2.6-3.2 0-7.3 2-10.7-.4 5.1 1.3 8-2 10.7zm-1 5.3c-.4-3.2 5-3.9 7.4-5.6-.9 1.8-2 6.7-7.5 5.6z"/>
|
||||
<path d="M244.8 161.3c-3.7-.4-2.2-6.7.5-10.1-1.1 4.8 2 8.1-.5 10.1z"/>
|
||||
<path d="M242 166.6c-4.2-2-1.5-7.2 0-10.3-.6 4.1 2.8 7.2 0 10.2z"/>
|
||||
<path d="M242.8 166c2.2 3 6.5-.8 7.4-5.2-3.7 3.1-6.5 2.6-7.4 5.3zm-9.6 20.3c-.4-4.3 2.8-12 .5-16.2-.3-.6.7-2.1 1.4-1.2 1 1.5 2 5.7 2.5 4.1.4-1.7.5-4.6 2-5.2 1-.3 2.3-.6 1.9 1-.4 1.4-1.2 3.4-.3 3.5.5 0 2-2 3.3-3 1-.8 2.6.6 1 1.8-4.8 4-9.5 5.9-12.3 15.2zm-8.7 64.5c-.6 0-1.3-.3-.6.6 5.7 7 7.3 9 15.6 8 8.3-1.1 10.3-3.4 16.2-6.7a14.6 14.6 0 0 1 11.2-1c1.6.5 2.6.5 1.4-.7-1.2-1.1-2.5-2.7-4-3.8a17.5 17.5 0 0 0-12.7-2.7c-6 1-11.1 4.9-17.2 6.4a25 25 0 0 1-9.9 0zm47.8 12.5c1 .2 1.7 2.2 2.3.9.8-2.3.2-4-.8-3.9-1.2.3-3.1 3-1.5 3z"/>
|
||||
<path stroke="none" d="M220.6 183c-1.2-1.4-.9-1.8 1-1.9 1.4 0 4.2 1 5.3.1 1-.7.5-3.7 1-5 .2-.9.7-2 2-.2 3.6 5.8 8 12.8 10 19.6 1 3.8 0 9.8-3.4 13.8 0-3.4-1.2-5.7-2.7-8.6-2-3.7-9.1-14-13.2-17.9z"/>
|
||||
<path d="M235.5 213.4c4 0 4.7-5.3 4.7-6.8-2 .4-5.4 3.7-4.7 6.8zm34.5 51.9c2.8.6 2.7-6.2-.2-9.1 1.3 4.4-2 8.4.1 9zm-1.2-.1c.2 3.2-8-.4-10-3 4.8 2.1 9.8.4 10 3zm-3.5-4.6c.3 3.1-7 .3-9.3-2.1 4.9 1.6 9-.5 9.3 2zm1.3.4c2.9.7 2.4-6.4-.4-8.8 1.4 4.7-1.8 8.1.4 8.8zm-3-4.3c2.9.7 1.2-5.4-.9-7.8.4 4.4-1 7.5 1 7.8zm-1.5 0c.3 3.2-5.4.8-7.6-2.3 4.8 1.5 7.3-.3 7.6 2.3zm-1.5-2.5c1.8-1.3-.1-4.8-3.7-4.6.4 2.1 1.6 5.9 3.7 4.6zm14 14.7c.1 3.2-8 1.6-10.6-1.8 5.2 1 10.3-.8 10.5 1.8zm-32.4-5.8c.3 3.2-8.6-.4-10.8-3.4 4.7 1.6 10.5.8 10.8 3.4zm5.4 1.3c1.9-1.3-1.9-4.7-5-5.5.4 2.1 3 6.8 5 5.6zm.6 2.3c.2 2.9-9.5 1.3-12-1.4 8.3 1.5 11.7-1.1 12 1.4z"/>
|
||||
<path d="M252.8 268.6c1 2.7-8.3 2-11.6.5 5.3 0 10.8-2.4 11.6-.5z"/>
|
||||
<path d="M257.1 270.6c1 2.4-7.6 2.4-11.8 1 5.6 0 10.8-3.4 11.8-1zm6.3 1.3c1.6 2.9-7.6 3.1-10.5 1.7 5.2-.7 9.2-4 10.5-1.7zm-10.7-4.9c-2.9 1.8-2.7-3.6-5-7.3 3.6 3.3 7 5.6 5 7.3z"/>
|
||||
<path d="M257.9 269c-2.4 2.1-4.4-5.3-6.6-9.5 3.6 4 8.8 7.7 6.6 9.4zm6.8 2c-2 2.4-8-7-10.2-12 3.3 3.9 11.8 10 10.2 12zm-5.8 7.2c-1 3.6-16.2-3.4-18-7.1 8.8 4.6 18.2 3.6 18 7zm-48.7-73.8c-.4-.5-1.4 0-1.2 1.1.3 1.5 2.5 9.2 6.3 11.8 2.7 2 17 5.1 23.4 6.5 3.6.7 6.5 2.5 8.9 5.3a94.4 94.4 0 0 0-3-9.8c-1.2-3-4.4-6.2-7.8-6.3-6.1-.3-14.1-.8-20-3.3a16 16 0 0 1-6.7-5.3z"/>
|
||||
<path d="M245.5 234.9c2 1.4 4.1-3.7 1.7-8.6-.1 4.7-3.8 6.3-1.7 8.6z"/>
|
||||
<path d="M247.4 239.6c2.7.8 3.5-4 1.8-7.8.3 4.1-4.3 6.6-1.8 7.8z"/>
|
||||
<path d="M249.5 243.4c2.6 1.3 3.5-3.6 1.7-7.1.2 4.5-3.7 5.9-1.7 7z"/>
|
||||
<path d="M248.4 243.7c-1 3-7-2.7-8-5.8 3.7 3.7 8.7 3.2 8 5.7z"/>
|
||||
<path d="M245.7 239c-1.2 3-8.7-5-10.4-8.7 3.7 3.7 11.2 6.5 10.4 8.6z"/>
|
||||
<path d="M244.2 234.3c-1.2 3.5-9.3-5.8-11.7-9.1 4 3.6 12.6 6.6 11.7 9.1zm-.3-3.4c3-.6-.1-3-3.7-6.9-.1 4.1.5 7 3.7 6.9z"/>
|
||||
<path d="M239 228.5c1.3-1.3-1.1-1.9-4.1-5.3-.5 2.3 2.8 6.5 4.2 5.3zm14 15.2c1.6 1 2.6-2.3.7-5.2-.5 3.2-2.1 4-.7 5.2zm-34.2-20.3c-3.3 2-8.6-6-10-9.3 2.9 3.8 10.6 7.2 10 9.3z"/>
|
||||
<path d="M221.7 228c-1.9 2-7.7-3.5-9.7-6.3 3 2.7 10.5 3 9.7 6.3z"/>
|
||||
<path d="M224.8 232.2c-.6 2.8-9-3.5-11-6.5 3.6 3.5 11.6 3.2 11 6.5z"/>
|
||||
<path d="M223.5 235.3c-1.3 2.5-8.2-3.8-9.9-7 4.3 3.6 11 4.5 10 7zM220 223c2.1-2.3 1.2-3.4-.4-7-.8 3.7-2.1 5.2.4 7zm2.9 4.3c4 .2 0-4.6-1-8.7.4 4.6-1 8.3 1 8.7z"/>
|
||||
<path d="M225.4 231.1c2.7-.6 2-4.5-.2-9.2.5 5.1-2.3 8 .2 9.2zm-1 7.7c-1 3-8.8-4-10-6.8 4 3.4 10.7 4.5 10 6.8z"/>
|
||||
<path d="M229.1 243.6c-1.1 3-9.3-3.2-11.8-6.6 4.9 4 12.4 3.6 11.8 6.6z"/>
|
||||
<path d="M233.9 248.5c-1.3 4.3-9.9-2.6-12.4-6 5.4 4.2 13 3 12.4 6zm-8-11c2.3 1.1 3.2-5.4 1.9-10.1 0 5-4.7 8.8-2 10z"/>
|
||||
<path d="M229.8 242.7c2.8.8 2-6.3-.5-11-.3 4.7-2.3 9 .5 11zm5 4.9c3 .1 1-6.1-1.6-9.6.4 4.5-1 9 1.6 9.6zm-5.5 2.6c-1 1.6-3.2-1.3-7-3.5 3.4 1 7.4 2 7 3.5zm-1.8-52.7c3-2.2.7-6.2 0-10-1 3.6-3.4 8.4 0 10zm0 5.3c-4.5-.5-3.8-6.1-4-9.7 1.4 4.9 5 5.7 4 9.8zm.6-.7c3.7-.2 3.5-4.4 3.7-8.6-1.9 3.9-4 4.5-3.7 8.6z"/>
|
||||
<path d="M228 207.3c-3 .3-4.4-2.6-5-7 2.7 4.1 5.1 2.8 5 7zm1-.3c3.7.5 3-3.8 3-7-1.2 3-4.2 4-3 7z"/>
|
||||
<path d="M223.2 205.2c.3 2.8 2.1 7.6 5 6.5 1.1-3.4-2.6-4.1-5-6.5z"/>
|
||||
<path d="M229 212c-1.2-2.4 3-3.7 3.8-6.9.5 4.6.1 7.6-3.8 7zm-11.9-29.2c2.3-2.4.3-6.4-.4-10.2-1 3.6-2.5 8.4.4 10.2zm0 4.6c-4 .5-5-7.7-5.5-11.3 1.4 4.9 6 7 5.5 11.4zm.8 0c2.8-1.5 2.2-4.7 3-7-1.8 2.9-3.6 3.3-3 7z"/>
|
||||
<path d="M217 192.8c-4.1.3-6.6-8.8-6.8-12.4 1.3 4.9 7.4 7.5 6.9 12.4zm.9-.2c4-.9 3.5-3.5 2.9-7.6-1.3 4.2-3.5 3.3-2.9 7.6z"/>
|
||||
<path d="M217 198c-4.6.8-4.3-6.6-8-11.9 3.2 4 9 9 8 11.9zm1-.3c3.6.2 4-5.1 3.8-7.3-.9 2.2-5 4.2-3.7 7.4z"/>
|
||||
<path d="M209.8 192.3c1.7 5.7 4.2 11.4 7.2 11 1.5-3.3-2.9-3.7-7.2-11z"/>
|
||||
<path d="M218.1 202.4c-1.2-2.5 3-3.7 3.8-6.9.5 4.6.1 7.6-3.8 6.9zm-7.1-3.6c2.5 5.1 3.6 11 7 10.1 1.3-4-3.8-4.8-7-10.1z"/>
|
||||
<path d="M218.7 208c-1.5-2.8 2.7-3.7 3.8-7.4.5 4.8 0 8.3-3.8 7.3zm7.2-34.5c2.4.6 5-2.1 4.1-6.2-2.8.6-4 3.2-4.1 6.2zm-7.9-2.1c.2 1.2 1.7 1.3 1.2-.4a5.3 5.3 0 0 1 0-3.4 7.5 7.5 0 0 0 0-4.6c-.4-1-1.8-.4-1.2.4.6.9.7 2.8.2 3.7-.6 1.3-.4 3-.2 4.3zm22.9 16c-1 1.3-2.9.4-1.4-1.5 1.2-1.5 3-2.8 3-4.4.2-2 1.3-5 2.4-6.1 1.1-1.1 2.4.4 1.2 1.2-1.3.8-2.2 4.4-2.1 5.8-.1 2-2 3.5-3.1 5zm-3-2.3c-1 1.4-2.4.5-1.6-1.7.7-1.5.8-3.5 1.6-4.6 1.2-1.7 3-3.1 4.1-4.2 1.2-1 2 0 1 1a27 27 0 0 0-3.3 4c-1.4 2.2-.8 4-1.8 5.5zm-15.7-7.2c-.1 2 1.5 2.4 1.4-.4 0-3-2.2-5.8-1-10.3.8-2.2.8-6.3.4-8.4-.4-2.2-2-.8-1.3.9.6 2-.1 5.6-.6 7.5-1.5 5.4 1.2 8 1 10.7zm4.3-11c-.2 1.9-1.8 2-1.3-.5.4-2 .4-3.6 0-5.3-.6-2.1-.4-5.7 0-7.2.5-1.6 2-.7 1.4.5a9.9 9.9 0 0 0-.3 5.9c.6 2 .5 4.8.2 6.7zM210.9 204c.8.9 2 .3 1-1-1-1-.7-1.2-1.3-2.4-.6-1.4-.5-2.1-1.2-3-.7-1-1.6 0-1 .7.8 1 .6 1.6 1 2.5 1 1.5.7 2.3 1.5 3.2zm20.4 24.6a8.6 8.6 0 0 1 4.4 6.7 16 16 0 0 0 2 7.1c-2-.5-3-3.7-3.3-6.8-.3-3.2-2-4.5-3-7zm5.1 5.9c1.7 3.1 4 4.3 4.2 6.6.2 2.7.4 2.8 1.1 5.4-2-.5-2.5-.7-3-4.7-.3-2.8-2.6-4.7-2.3-7.3z"/>
|
||||
<path stroke="none" d="M289 263.3c1 1.8 2 4.5 4 4 0-1.3-2.1-2.3-4-4m3 .6c3.7 1.6 7 1.2 7.5 3.6-3.6.4-5-1-7.6-3.6zm-16.1-12.7a14 14 0 0 1 5 7.7 29 29 0 0 0 3.6 7.8 13 13 0 0 1-5.3-7.4c-.7-3-1.6-5.3-3.3-8zm3.1 0c2.8 2.2 5.4 4.8 6.2 7.9.8 2.9 1.3 5.1 3.2 8-3-1.9-4.1-4.7-5-7.8-.7-3-2.5-5.2-4.4-8zm9.2 7.3a1.1 1.1 0 0 1 .7-1.2 33.4 33.4 0 0 1 2.6-.8c1-.3 1.6.4 1.6.9v2c0 .7-.2.8-.7.9-.7.1-1.7.2-2.4.7-.6.4-1.2.1-1.5-.5zm10.6 0c0-.6-.2-1.1-.6-1.2a5.4 5.4 0 0 0-2.4-.4c-1 0-1.1.2-1.1.6v2.1c0 .8 0 .8.4 1 .7 0 1.8 0 2.5.6.5.3 1 0 1.1-.6z"/>
|
||||
</g>
|
||||
<use xlink:href="#af-a" width="100%" height="100%" x="-600" transform="scale(-1 1)"/>
|
||||
<g stroke="none">
|
||||
<path d="M328.5 286.6c0 1.2.2 2.2 1 3.1a19 19 0 0 0-13.8 1.1c-1.8.8-4-1-1.9-2.7 3-2.3 9.7-1 14.7-1.5m-57.5 0a7 7 0 0 1-.4 3c4.4-1.7 9.1-.2 13.6 1.6 3 1.3 3.3-1 2.8-1.7a6.5 6.5 0 0 0-5-2.9zm3.8-21.7c-1.3-.5-2.7 0-4 1.4-4.3 4.2-9.4 8.3-13.5 11.6-1.5 1.3-3 3.7 3.4 6 .3.2 5 2 8 2 1.3 0 1.3 1.8 1 2.3-.5 1-.1 1.4-1.1 2.3-1.1 1 0 2.1 1 1.3 3.6-3.2 9.6-1.1 15.3.7 1.4.4 3.8.3 3.8-1.6 0-2 1.5-3.4 2.4-3.5 2.4.4 14 .5 17.5.1 2-.3 2.2 2.9 3.3 4 .8.9 3.7 1.1 5.8.2 4-1.8 10-1.8 12.5 0 1 .7 1.9 0 1.3-.7-.8-1-.7-1.6-1.1-2.4-1-2-.2-2.4.8-2.5 11-1.5 14.6-5.2 11.2-8.3-4.4-3.8-9.2-7.7-13.4-12.2-1.2-1.2-2-1.7-4.3-.7a66.5 66.5 0 0 1-25.3 5.9 76 76 0 0 1-24.6-5.8z"/>
|
||||
<path fill="#bd6b00" d="m326.6 265.5-1.6.4c-9 3.2-17.2 5.4-25.7 5.4-8.3 0-17-2.4-24.9-5.6a2.3 2.3 0 0 0-1.5 0c-.5.1-1 .4-1.3.7a115.5 115.5 0 0 1-11.8 10.3c-.7.5-.6 1.8.5 2.2 8.3 3 16.4 8.5 39.6 8.3 23.5-.2 31.8-5.6 39.2-8.1.5-.2 1-.5 1.3-1a1 1 0 0 0 .1-.8 2 2 0 0 0-.6-.8c-4.3-3.5-8.8-6.3-11.8-10.4-.3-.5-.9-.6-1.5-.5zm0 .5c.5 0 1 0 1.1.3 3 4.3 7.7 7 11.9 10.5l.4.7a.5.5 0 0 1 0 .4c-.1.3-.6.6-1 .7-7.6 2.6-15.7 8-39 8.2-23.2.2-31.2-5.3-39.5-8.3-.8-.4-.7-1.2-.4-1.4 4.2-3.2 8.2-6.8 11.8-10.4a2.5 2.5 0 0 1 1.1-.6h1.2a68 68 0 0 0 25 5.6c8.7 0 17-2.2 26-5.3a6.7 6.7 0 0 1 1.5-.4z"/>
|
||||
<path d="M269.7 114.6c0-1.4 2-1.5 1.8.4-.3 2.3 4.5 8.3 4.9 12 .3 2.5-1.5 4.6-3.2 6a6.6 6.6 0 0 1-6.8.5c-.9-.8-1.7-3.3-1-4.3.2-.3 1.3 3.7 3.7 3.7 3.3 0 6-2.5 6-4.7.2-3.8-5.3-9.8-5.4-13.6m9.5 9.4c.6-.4 1.4 1.3.8 1.7-.5.3-1.5-1.3-.8-1.8zm1.5-3.5c-.3.2-.8 0-.7-.2a12 12 0 0 1 3.6-3.3c.4-.2 1 .4.8.7a11 11 0 0 1-3.7 2.8m12.6-10c.3-.6 2.1-1.3 2.6-1.7.4-.5.6.4.4.7-.3.7-1.9 1.7-2.6 1.8-.3 0-.6-.4-.4-.7zm4.3.3a8.3 8.3 0 0 1 2.5-3.4c.5-.3 1.3 0 1.1.4a9 9 0 0 1-2.9 3.3c-.3.3-.8 0-.7-.3m-3.7 2.7c-.3.2-.1.7.1.8.6.2 1.5.2 2 0 .6-.4.3-2.9-.5-1.6-.6.8-1 .6-1.6.8m-7.3 5.6c-1.3-1 .4-2.4 1.7-1.4 2.7 2-4 9.8-7.6 13.4-.7.7-1.3-1-.4-1.9a33.7 33.7 0 0 0 6.7-7.6c.4-.5.7-1.6-.4-2.5m15.3-6.6c.1-1-1.6 0-1.6-1.3 0-.7 1.9-1.2 2.7-.4 1.3 1.4.3 3.7-2 3.9-1.8 0-5 2.7-4.5 3.2.5.7 5.4 1.1 8.3.7 1.8-.3 1.4 1.3-.4 1.5-1.8.2-3.2 0-4.8.6-2 .5-2.8 3-3.9 4-.2.2-.8-.8-.6-1.2.8-1.2 2-3 3.4-3.6.8-.3-2.4-.4-3.4-.7-.8-.2-.6-1.3-.3-1.9.4-.8 3.4-3.9 4.7-3.8 1.1 0 2.3-.3 2.4-1m5 .2c.6-.5 1-1.3 1.5-1.8.3-.3.9 0 .8.8-.1.7-1 1.2-1.5 1.7-.5.3-1-.4-.7-.7zm6.5-2.3c.9 0 1 1.6.2 1.8-.6.2-1-1.7-.2-1.8m-2.1 5c0 1.5.7 1.4 2 1.3 1.3 0 2.4 0 2.4-1.2 0-1.3-.7-2.5-1-1.6-.1.8-.3 2.2-.8 1.6-.4-.5-.2-.6-1 .2-.5.5-.5-.2-.8-.6-.2-.3-.8.2-.8.4zm-9.2 7.2c-.3 1.9 0 4.5.9 4.5 1.2 0 3.6-4 4.8-6.2.7-1.2 1.8-1.4 1.3-.1-.7 1.9-.6 6 0 7.2.4.6 3-.6 3.4-1.5.8-1.7.1-4.8.4-6.7.1-1.2 1.3-1.5 1.2-.3a75.6 75.6 0 0 0-.1 7.5c0 1 2.9 2.4 3.3-.6.2-1.8 1.2-3.7 0-5.7-.8-1.3 1.1-1.2 2.1.6.7 1.2-.6 3.2-.5 4.7 0 2.4-1.8 3.8-3.1 3.8-1.2 0-2-1.5-3-1.5s-2.2 1.7-3 1.6c-3.6-.2-1.7-5.3-2.8-5.4-1.2 0-2.5 5-4 4.9-1.4-.2-3-4.2-2.3-5.8.5-1.6 1.5-2 1.4-1m16.9-8c-1.7-1 0-3.7.9-2.8 1.6 2 3.2 6.5 4.4 6.9.7.2.6-3.4 1.1-5 .4-1.3 1.8-.9 1.6.7-.1.5-2 6.4-1.8 6.6a47.1 47.1 0 0 1 3.3 7.8c.3 1.2-1.1.4-1.3.2-.9-1.4-2.4-6.5-2.4-6.2l-1.7 7.7c-.2 1-1.7.8-1.3-1 .3-1.4 2.3-8.3 2.2-8.6a17.2 17.2 0 0 0-5-6.3"/>
|
||||
<path d="M322 131.2c-.4 0-1.2 1 1.2 1.5 3.1.6 6.6-.5 7.6-3.6 1.3-3.7 2-7.2 2.7-8.5.8-1.5 1.8-1.4 1-3.6-.5-1.7-1.5-1.2-1.7-.3-.5 2.3-2.6 10-3.3 11.3-1.2 2.6-3.7 3.6-7.5 3.2"/>
|
||||
<path d="M328.4 119c-.4-.7-1.2 0-1 .7a1.2 1.2 0 0 0 1.2 1c.7 0 2.2.1 2.2-1 0-.8-.7-1.5-1.1-.6-.5.8-1 .7-1.3 0zm.7-3c-.2.2 0 1.1.3 1a7 7 0 0 0 3.3-.8c.2-.2.1-.7-.2-.7-1 0-2.6 0-3.4.5m8.8 2.3c.8-1.2 2.8-1.3 2 .4a614.3 614.3 0 0 1-6.3 12.3c-.8 1.4-1.4.7-.8-.4.7-1.4 4.9-12 5.1-12.3"/>
|
||||
<path d="M330.2 133c-.2-.8-1.5-2-1.3.2.2 3.8 5.5 2.6 7 1.3s.3 4.3 2.2 4.9c1 .3 3-1.1 4-2.4 2.7-3.5 4.5-8.6 7-12 1-1.4-.5-2.4-1-1.3-2.4 3.8-5.2 11.6-8.3 13.6-2.5 1.6-1.7-2-1.8-3.2-.1-.8-1.1-2-2.4-.9a5.5 5.5 0 0 1-3.7 1.2c-.7 0-1.4 0-1.7-1.4"/>
|
||||
<path d="M339.6 126c0-.3-1.1-.4-1 .7 0 .8 1 1 1.1 1 1.5-1.2-.3-.6-.1-1.8zm-2.3 4.4c-.3 0-.6 1 .2 1.1l3.9-.2c.4 0 .6-.9-.4-.8-1.2 0-2.7-.3-3.7 0zm-62-16.6c.5 0 1.6 1.4 1.5 1.9 0 .2-1.2 0-1.5-.3-.3-.3-.2-1.6 0-1.6m-5.3 10.4c-1 .6.2 1.7 1 1.2 2.8-1.9 7-3.8 8-7.5.3-1.2 1.4-3.1 2.5-3.5 1-.5 2.6 1.9 3.6 0 .6-1 2.7.7 3.2-.4.6-1.3.3-2 .3-3.4 0-.8-.7-1-1.2.3-.2.6 0 1.2-.1 1.6-.2.2-.6.4-1 .2-.2-.2 0-.7-.6-1-.2 0-.6-.1-.8.2-.7 1.3-1 2.5-2.1 1-.9-1-1.4-3.1-2-.3-.2 1-1.7 2.4-2.6 2.4-1.1 0-.8-3-3.2-2.5-1.3.3-1.2 2.7-1 3.5.3 1.3 4 .4 3.7 1.2-.6 2.7-4.4 5.4-7.7 7m-22.7 13.2c-.1.5.5 1.7 1.1 1.8.6 0 1-1.3.8-1.8-.2-.3-1.8-.3-1.9 0m3.3 4.9c-.4-.4-1.6.7-.6 1.5.5.5 2.5 1.1 3 .2.8-1.2-.7-5.5 0-6 .5-.5 2.8 2.8 4 3 2.7.4 2-4.6 5-4.2 1.9.2 2.1-2.2 1.8-3.8-.2-1.5-2.6-3.6-3.7-4.6-1.4-1.2-2.1 1-1.2 1.6 1.2 1 3.3 2.9 3.6 4.1.1.6-1.4 1.8-2 1.5-1.4-.8-2.6-4-3.8-4.7-.4-.2-1.4.3-1 1.3.6 1.1 3 2.7 3.1 3.9.1 1-1 3.2-1.8 3.2-.9 0-3-2.7-3.7-4-.4-.5-1.5-.5-1.7.4a22 22 0 0 0 .5 5.5c.2 1.6-.9 1.7-1.5 1.1m-4-8.6c-.4.4.8 1.2 1 1 .4-.4 2.1-2.3 1.8-3-.3-.6-2.6-2-3-1.3-.7 1.1 2.2 1.7 1.7 2a7 7 0 0 0-1.5 1.3m4.1-8.4s.8 2.5 1.4 1.4c.4-.7-1.4-1.4-1.4-1.4m1.2 4c-.2 0-1 .7-.5 1 .8.4 2.9.8 2.4-.7-.3-.9 3.2 0 2.3-2.4a3.7 3.7 0 0 0-1.7-1.7c-.4 0-1.5.5-.8.9.5.2 2 1.1 1.5 1.7-.7.6-1.1-.3-1.9-.1-.4 0-.1 1.2-.4 1.5 0 .2-.7-.4-.9-.3zm5.5-9.5a3.5 3.5 0 0 0-1.2 2c0 .2.3.6.5.5a3.2 3.2 0 0 0 1.2-1.9c0-.3-.2-.8-.5-.6m2.8-.3c-.8-1 1-2.6 1.7-.5.5 1.3 5.5 7.9 6.5 10.1.8 1.5 0 2.1-.9 1-2.5-3.2-4.6-7.2-7.3-10.6m5.2.1c.9-1 2.7-3 2.2-4-.4-1-1.5-1-1.7-.7-1 1.3.8 1 .5 1.4-.5 1-1 1.6-1.3 2.6-.1.3.1.9.3.7m77.8 3.2c-.7-.5.6-3 1.5-2 2.3 2.7 3.4 11.6 4.1 18.3 0 0-1 .9-1 .7 0-3.5-1.5-14.4-4.6-17m-53.1-8.6c-.8-1.8 1.1-2.4 1.4-1.2 1.3 5.8 4.5 10.2 7 14.1.7 1.2 0 2-1.7.8-1.2-.8-2.5-3.9-3-4-1.2-.2-3.8 5-9.1 3.5-1.4-.4-1.3-4.5-1.4-6.3 0-.9 1-1 1 0 0 1.7 0 5.2 2.1 5.4 1.8 0 5.6-2.4 6.4-4.4.8-2-1.9-5.9-2.7-8z"/>
|
||||
<path d="M344.6 138.4c.4-1.2 6.1-10.8 6.9-12.9.4-1 2 1.8.4 3.3-1.4 1.2-5.5 8-6.3 10.4-.4 1-1.4.5-1-.8"/>
|
||||
<path d="M354.3 129.3c1-4 3.6.6 1.3 2.8-3.4 3.4-4.5 9.9-10 10.9-1.4.3-4-.7-4.8-1.3-.3-.2.2-1.6 1.1-.9 1.3 1 4.1 1.3 5.6.1a25.4 25.4 0 0 0 6.8-11.6m-57 12.7c-.3.3-1 .3-1.1.7-.3 1.4 0 2.2-.3 3.6s-1.3 1.4-1.2.3c0-1.4 1.3-3.5.4-3.6-.6-.1-1-.9-.4-1.3 1.1-.7 1.7-.6 2.4-.4.3.1.4.5.2.7"/>
|
||||
<path d="M296.5 140c-1.4 1.4-2.8 1.9-4.1 3.5-.6.6-.5 1.5-.9 2.4-.3.9-1.4 1-1.7.9-.5-.4-.4-2-1-1.2-.6.9-.9 2-1.7 2-.7 0-2-1.5-1.3-1.5 2.3-.3 2.2-2 3-2.2 1-.1 1 1.5 1.7 1.2.4-.2.7-2.1 1.2-2.6 1.5-1.6 2.7-2.4 4.3-3.6.7-.6 1.3.5.5 1.2zm5.3 5c-1.2.2-1 1.7-.6 1.8.5.3 1.4.4 1.7-1.3.2-.7.3 3.5 1.8 1.9 1-1 3.1.2 4-1 .7-.9 1-1.5.4-2.7-.2-.3-1-.2-1 .7 0 .8-.5 1.7-1.3 1.6-.4-.1.2-1.9-.2-2.4a.5.5 0 0 0-.7 0c-.3.4.3 2.2-.6 2.4-1.2.2-.6-1.2-1-1.4-1.7-.8-1.8.2-2.5.3zm9-3c.9-.2.6-.2 2-1.3.5-.4.6.8.5 1.3 0 .7-1 .2-1.3.9-.4.9-.2 3-.4 3.8 0 .4-.8.4-.8 0-.2-1 .1-2 0-3.3 0-.4-.5-1.1 0-1.3zm-5-2.5c-.2.9-.2 1.6-.2 2.3 0 .5 1 .2 1 .1 0-.8.2-2 0-2.3-.2-.1-.7-.3-.8-.1"/>
|
||||
<path d="m299.5 130.2-1.4 5.6-2-3.8v3.9l-4.4-5.2 1.5 5.6-4-3.4 2.2 3.8-7-4.5 4.4 5.2-5.6-2.8 4 3.4-9-3.4 8.7 4.3a29 29 0 0 1 12.6-2.6c4.9 0 9.3 1 12.5 2.6l8.8-4.3-9 3.4 4-3.4-5.5 2.8 4.3-5.2-7 4.5 2.2-3.8-4 3.3 1.5-5.5-4.3 5.2V132l-2 3.8z"/>
|
||||
</g>
|
||||
</g>
|
||||
<path fill="#fff" d="m249 299.7-.1 2.2h-.4v-1.5a7.4 7.4 0 0 0-.4-1.3 5.8 5.8 0 0 0-.5-1 11.3 11.3 0 0 0-.8-1.1l.7-1.8a5.3 5.3 0 0 1 1.1 2 7.5 7.5 0 0 1 .5 2.5m5.5-3.4c0 .6-.1 1-.3 1.2-.2.3-.6.5-1 .6l.2 1.1a5.3 5.3 0 0 1 0 1.7v1h-.4v-1a4.4 4.4 0 0 0-.2-.8 28.8 28.8 0 0 0-.3-.8 8.4 8.4 0 0 0-.6-1.2l-.8-1.3.5-1.6.8.9.7.2c.7 0 1-.3 1-1h.3a8 8 0 0 0 0 .5v.5m5.1 3.9-.4 1.7-.6-.6a3.5 3.5 0 0 1-.3-1 9.9 9.9 0 0 1 0-1.4 3 3 0 0 1-.9.1c-.4 0-.7 0-1-.3a1 1 0 0 1-.4-.8c0-.7.2-1.3.6-1.8.3-.6.7-.9 1.2-.9.3 0 .6.1.7.3l.3.8v1.6c0 .7 0 1.2.2 1.4 0 .3.3.5.6.9m-1.5-2.9c0-.4-.3-.6-.7-.6a.8.8 0 0 0-.4.1c-.2.1-.2.2-.2.3 0 .2.2.3.8.3a2.2 2.2 0 0 0 .5 0m6.9 2.3-.2 2.1c-.4-.3-.8-.8-1.1-1.5a20 20 0 0 1-1.1-3.3 41.3 41.3 0 0 1-.8 3l-.6 1.3a2 2 0 0 1-.6.6v-2l.8-1.2a6 6 0 0 0 .6-1.4 16 16 0 0 0 .3-2h.4l.7 2a6.7 6.7 0 0 0 1.6 2.4"/>
|
||||
<path fill="#bf0000" d="M280.5 319.2c.3.3.5.6.6 1l.2 1.2h-.6a6.2 6.2 0 0 0-.7-1.1 15.2 15.2 0 0 0-1-1l-1.3-1.2a27.3 27.3 0 0 0-1.6-1.3l-.5-.4-.2-.6a9 9 0 0 1-.1-1.3l2.1 1.7a35.3 35.3 0 0 1 2 1.8zm-7.6-4.6-.1 1.6-2.5-.1.2-1.6h2.4m6.7 7.1-6 1.9-1.2-1.6 5.2-1.5a6.3 6.3 0 0 0-.5-.7l-.7-.5a1.1 1.1 0 0 1-.4.8 2 2 0 0 1-.8.5 2.7 2.7 0 0 1-1.4 0c-.5 0-.8-.3-1-.6a3.1 3.1 0 0 1-.5-1.7c0-.8.2-1.3.6-1.5.6-.2 1.4 0 2.5.5a6.5 6.5 0 0 1 2.4 2zm-4.7-3.2a3.1 3.1 0 0 0-.6-.2.9.9 0 0 0-.5 0 .5.5 0 0 0-.4.3.4.4 0 0 0 0 .4l.4.2h.5a.9.9 0 0 0 .3-.3zm-6.4-1.2-.4 1.6-2.5-.3.4-1.5zm6 6-1.4.4a4.2 4.2 0 0 1-1.4 0 2.8 2.8 0 0 1-1.2-.3c-.2.4-.6.7-1.1 1a5.9 5.9 0 0 1-1.3.4l-1 .3-.8-1.6 1-.2 1-.3.6-.4a4.7 4.7 0 0 0-.7-.4 1 1 0 0 0-.6-.1.3.3 0 0 0-.2 0 .5.5 0 0 0 0 .3h-.5c-.4-.7-.5-1.2-.3-1.6.3-.4.8-.7 1.6-.9.8-.2 1.5-.2 2.1 0 .6 0 1 .3 1.2.6.1.2.2.4.1.6 0 .2 0 .5-.3 1a1.6 1.6 0 0 0 1 0l1.3-.3zm-6.4 1.5-1.3.2c-.7 0-1.3 0-1.8-.4a4.3 4.3 0 0 1-1.3-2l-.6-1.7a2 2 0 0 0-.6-1l-.8-.3.5-1.7 1.1.9.8 1.3.4 1.2a5 5 0 0 0 1 1.7c.2.3.4.4.7.3l1.3-.2zm-5.5-6-.9 1.5-2.3-.6.8-1.5zm1.4 6.7-6 .5-.3-1.6 5-.5a1.9 1.9 0 0 0-.6-.7 6 6 0 0 0-.8-.5l.5-1.5c.5.3 1 .6 1.2 1 .2.4.5 1 .6 1.7zm-4.8.8a13 13 0 0 1-1.8-.2 8.3 8.3 0 0 1-1.3-.4 4.5 4.5 0 0 1-1 .3h-3c-.5 0-.8 0-1-.2l-.6-.8a3.3 3.3 0 0 1-1.3.7 4 4 0 0 1-1.3.2h-1.4l.2-1.8 1.3.1c.7 0 1.3 0 1.7-.3.6-.3 1-.8 1-1.4h.6a22.9 22.9 0 0 0-.1 1c0 .3 0 .5.3.6l.7.2h2.9c.4-.2.6-.5.7-1l.1-.3a2.6 2.6 0 0 1 .4-.2l.4-.1v.6l-.3.8a6.4 6.4 0 0 0 1.7.4c0-.1 0-.3-.2-.5 0-.3-.2-.4-.2-.5a.4.4 0 0 1 .1-.2l.3-.2.8-.7.3.7c0 .2.1.5 0 .8l-.1 2.4m-9-7-1.5 1-1.1-.6-1.1.8-1.5-.9 1.4-1 1.2.7 1.1-.9zm-2.4 6.4-5.8-1 .7-1.6 4.8.8a1.3 1.3 0 0 0 0-.8 4 4 0 0 0-.5-.6l1.3-1.3c.3.4.5.8.5 1.2 0 .4 0 1-.4 1.7zm-4.9-.8-1.2-.3c-.7-.1-1.1-.4-1.2-.9-.1-.5.1-1.2.7-2.2l1-1.7.2-.9-.3-.6 1.8-1.2.2 1.1c0 .4-.2.9-.6 1.4l-.6 1.2a4 4 0 0 0-.7 1.7c0 .3.1.5.4.5l1.2.3zm-3-6.3-2 .9-1.4-1.4 2-.8zm-.9 5.3a4 4 0 0 1-1.2 1.1c-.4.3-.9.4-1.4.5a7 7 0 0 1-1.9 0 11.8 11.8 0 0 1-2.2-.6 6 6 0 0 1-2.7-1.6c-.5-.6-.5-1.2 0-1.8a5.6 5.6 0 0 1 1.5-1.3 18.8 18.8 0 0 1 3-1.2l.4.4c-1 .4-1.8.7-2.2 1a3.3 3.3 0 0 0-1 .7c-.3.4-.3.8.1 1.3a8.4 8.4 0 0 0 5 1.8c1 0 1.6-.3 1.9-.6l.4-.7.1-1.4 2-1.2-.1 1.2c-.1.4-.4.8-.8 1.3z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 20 KiB |
14
public/assets/flags/1x1/ag.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-ag" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<clipPath id="ag-a">
|
||||
<path fill="#25ff01" d="M109 47.6h464.8v464.9H109z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g fill-rule="evenodd" clip-path="url(#ag-a)" transform="translate(-120 -52.4)scale(1.1014)">
|
||||
<path fill="#fff" d="M0 47.6h693V512H0z"/>
|
||||
<path fill="#000001" d="M109 47.6h464.8v186.1H109z"/>
|
||||
<path fill="#0072c6" d="M128.3 232.1h435.8v103.5H128.3z"/>
|
||||
<path fill="#ce1126" d="M692.5 49.2v463.3H347zm-691.3 0v463.3h345.7z"/>
|
||||
<path fill="#fcd116" d="m508.8 232.2-69.3-17.6 59-44.4-72.5 10.3 37.3-63-64.1 37.2 11.3-73.5-43.4 58-17.6-67.3-19.6 69.3-43.4-59 12.4 75.6-64.1-39.3 37.2 63-70.3-11.3 57.9 43.4-72.4 18.6z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 751 B |
29
public/assets/flags/1x1/ai.svg
Normal file
@@ -0,0 +1,29 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-ai" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<path id="ai-b" fill="#f90" d="M271 87c1.5 3.6 6.5 7.6 7.8 9.6-1.7 2-2 1.8-1.8 5.4 3-3.1 3-3.5 5-3 4.2 4.2.8 13.3-2.8 15.3-3.4 2.1-2.8 0-8 2.6 2.3 2 5.1-.3 7.4.3 1.2 1.5-.6 4.1.4 6.7 2-.2 1.8-4.3 2.2-5.8 1.5-5.4 10.4-9.1 10.8-14.1 1.9-.9 3.7-.3 6 1-1.1-4.6-4.9-4.6-5.9-6-2.4-3.7-4.5-7.8-9.6-9-3.8-.7-3.5.3-6-1.4-1.6-1.2-6.3-3.4-5.5-1.6"/>
|
||||
</defs>
|
||||
<clipPath id="ai-a">
|
||||
<path d="M0 0v128h298.7v128H256zm256 0H128v298.7H0V256z"/>
|
||||
</clipPath>
|
||||
<path fill="#012169" d="M0 0h512v512H0z"/>
|
||||
<path stroke="#fff" stroke-width="50" d="m0 0 256 256m0-256L0 256"/>
|
||||
<path stroke="#c8102e" stroke-width="30" d="m0 0 256 256m0-256L0 256" clip-path="url(#ai-a)"/>
|
||||
<path stroke="#fff" stroke-width="75" d="M128 0v298.7M0 128h298.7"/>
|
||||
<path stroke="#c8102e" stroke-width="50" d="M128 0v298.7M0 128h298.7"/>
|
||||
<path fill="#012169" d="M0 256h256V0h85.3v341.3H0z"/>
|
||||
<path fill="#fff" d="M323.6 224.1c0 90.4 9.8 121.5 29.4 142.5a179.4 179.4 0 0 0 35 30 179.7 179.7 0 0 0 35-30c19.5-21 29.3-52.1 29.3-142.5-14.2 6.5-22.3 9.7-34 9.5a78.4 78.4 0 0 1-30.3-9.5 78.4 78.4 0 0 1-30.3 9.5c-11.7.2-19.8-3-34-9.5z"/>
|
||||
<g transform="matrix(1.96 0 0 2.002 -141.1 95.2)">
|
||||
<use xlink:href="#ai-b"/>
|
||||
<circle cx="281.3" cy="91.1" r=".8" fill="#fff" fill-rule="evenodd"/>
|
||||
</g>
|
||||
<g transform="matrix(-.916 -1.77 1.733 -.935 463.1 861.4)">
|
||||
<use xlink:href="#ai-b"/>
|
||||
<circle cx="281.3" cy="91.1" r=".8" fill="#fff" fill-rule="evenodd"/>
|
||||
</g>
|
||||
<g transform="matrix(-1.01 1.716 -1.68 -1.031 825 -71)">
|
||||
<use xlink:href="#ai-b"/>
|
||||
<circle cx="281.3" cy="91.1" r=".8" fill="#fff" fill-rule="evenodd"/>
|
||||
</g>
|
||||
<path fill="#9cf" d="M339.8 347.4a78 78 0 0 0 13.2 19.2 179.4 179.4 0 0 0 35 30 180 180 0 0 0 35-30 78 78 0 0 0 13.2-19.2z"/>
|
||||
<path fill="#fdc301" d="M321 220.5c0 94.2 10.1 126.6 30.5 148.5a187 187 0 0 0 36.5 31 186.3 186.3 0 0 0 36.4-31.1C444.8 347 455 314.7 455 220.5c-14.8 6.8-23.3 10.1-35.5 10-11-.3-22.6-5.7-31.5-10-9 4.3-20.6 9.7-31.5 10-12.3.1-20.7-3.2-35.6-10zm4 5c13.9 6.5 21.9 9.6 33.4 9.4a76.4 76.4 0 0 0 29.6-9.4c8.4 4 19.3 9.2 29.6 9.4 11.5.2 19.4-3 33.4-9.4 0 89-9.6 119.6-28.8 140.2a176 176 0 0 1-34.2 29.4 175.6 175.6 0 0 1-34.3-29.4c-19.2-20.6-28.7-51.3-28.7-140.2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
5
public/assets/flags/1x1/al.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-al" viewBox="0 0 512 512">
|
||||
<path fill="red" d="M0 0h512v512H0z"/>
|
||||
<path id="al-a" fill="#000001" d="M204.9 99.5c-5 0-13.2 1.6-13 5.4-14-2.3-15.4 3.4-14.6 8.5 1.4-2 3-3.1 4.2-3.3 1.9-.3 3.8.3 5.8 1.5a23 23 0 0 1 5 4.4c-4.8 1.1-8.6.4-12.4-.3a17.6 17.6 0 0 1-6.1-2.5c-1.6-1.1-2.1-2.1-4.6-4.7-2.9-3-6-2.1-5 2.5 2.2 4.3 6 6.3 10.7 7 2.2.4 5.6 1.2 9.4 1.2 3.8 0 8.1-.5 10.5 0-1.4.8-3 2.4-6.2 3-3.2.6-8-2-11-2.6.4 2.5 3.5 4.8 9.7 6 10.2 2.2 18.7 4 24.3 7 5.6 3 9.1 6.8 11.6 9.8 5 6 5.3 10.5 5.6 11.5 1 9.5-2.2 14.8-8.4 16.4-3 .8-8.5-.7-10.5-3-2-2.4-4-6.4-3.4-12.7.5-2.5 3.4-9 1-10.3a291.6 291.6 0 0 0-34.4-16c-2.7-1.1-5 2.5-5.8 4A53.5 53.5 0 0 1 129 107c-4.6-8.1-12.1 0-10.9 7.7 2.1 8.6 8.6 14.8 16.5 19.2 8 4.5 18.1 8.8 28.3 8.6 5.5 1 5.5 8.2-1.1 9.5-13 0-23.2-.2-32.9-9.6-7.4-6.7-11.5 1.3-9.4 5.8 3.6 14 23.6 18 43.8 13.4 7.8-1.3 3.1 7 .9 7.2-8.4 6-23.5 12-36.8-.1-6.1-4.7-10.2-.7-8 6 6 17.5 28.5 13.8 44 5.2 4-2.2 7.6 3 2.7 6.9-19.2 13.4-28.9 13.6-37.6 8.4-10.8-4.3-11.8 7.8-5.3 11.8 7.2 4.4 25.4 1 38.9-7.4 5.7-4.2 6 2.4 2.3 5-15.9 13.8-22.2 17.5-38.8 15.2-8.2-.6-8 9.5-1.6 13.5 8.8 5.4 26.1-3.6 39.5-14.7 5.6-3 6.6 2 3.8 7.8a57.4 57.4 0 0 1-23.3 19.2 29.1 29.1 0 0 1-19.5.7c-6.2-2.2-7 4.2-3.6 10 2 3.5 10.6 4.7 19.7 1.4 9.2-3.2 19-10.8 25.7-19.8 6-5.1 5.2 1.8 2.5 6.7-13.5 21.3-25.9 29.2-42.1 27.9-7.3-1.2-8.9 4.4-4.3 9.6 8 6.7 18.2 6.4 27-.2a751 751 0 0 0 30.8-32.6c5.5-4.4 7.3 0 5.7 9-1.5 5.1-5.2 10.5-15.3 14.5-7 4-1.8 9.4 3.4 9.5 2.9 0 8.7-3.3 13-8.3 5.9-6.5 6.2-11 9.5-21.1 3-5 8.4-2.7 8.4 2.5-2.6 10.2-4.8 12-10 16.2-5.1 4.7 3.4 6.3 6.3 4.4 8.3-5.6 11.3-12.8 14.1-19.4 2-4.8 7.8-2.5 5.1 5.3-6.4 18.5-17 25.8-35.5 29.6-1.9.3-3 1.4-2.4 3.6l7.5 7.5c-11.5 3.3-20.8 5.2-32.2 8.5L142 300.6c-1.5-3.4-2.2-8.7-10.4-5-5.7-2.6-8.2-1.6-11.4 1 4.5.1 6.5 1.3 8.3 3.4 2.3 6 7.6 6.6 13 5 3.5 2.9 5.4 5.2 9 8.2l-17.8-.6c-6.3-6.7-11.3-6.3-15.8-1-3.5.5-5 .5-7.3 4.7 3.7-1.5 6-2 7.7-.3 6.6 3.9 11 3 14.3 0l18.7 1.1c-2.3 2-5.6 3.1-8 5.2-9.7-2.8-14.7 1-16.4 8.8a18.2 18.2 0 0 0-1.4 10c1-3.2 2.5-5.9 5.3-7.6 8.6 2.2 11.8-1.3 12.3-6.5 4.2-3.4 10.5-4.1 14.6-7.6 4.9 1.6 7.2 2.6 12.1 4.1 1.7 5.3 5.7 7.4 12 6 7.7.3 6.3 3.4 7 5.9 2-3.6 2-7-2.8-10.3-1.7-4.6-5.5-6.7-10.4-4-4.7-1.3-5.9-3.2-10.5-4.6 11.7-3.7 20-4.5 31.8-8.3 3 2.8 5.2 4.8 8.2 7.2 1.6 1 3 1.2 4 0 7.3-10.6 10.6-20 17.4-27 2.6-2.9 6-6.8 9.6-7.8 1.8-.4 4-.2 5.5 1.4 1.4 1.6 2.6 4.4 2 8.7-.6 6.2-2 8.2-3.8 11.8-1.7 3.7-3.9 6-6 8.8-4.4 5.7-10.1 9-13.5 11.2-6.8 4.4-9.7 2.5-15 2.2-6.7.8-8.5 4.1-3 8.7a21 21 0 0 0 13.7 2.3c3.3-.6 7-4.8 9.8-7 3-3.6 8.1.6 4.7 4.7-6.3 7.5-12.6 12.4-20.3 12.3-8.2 1-6.7 5.7-1.3 7.9 9.8 4 18.6-3.5 23-8.5 3.5-3.7 6-3.9 5.3 2-3.4 10.5-8.1 14.6-15.7 15.1-6.2-.5-6.3 4.2-1.7 7.5 10.3 7 17.7-5 21.2-12.4 2.5-6.6 6.3-3.5 6.7 2 0 7.3-3.2 13.2-12 20.7 6.7 10.7 14.5 21.7 21.3 32.5l20.5-228.2-20.5-36c-2.1-2-9.3-10.5-11.2-11.7-.7-.7-1.1-1.2-.1-1.6 1-.4 3.2-.8 4.8-1-4.4-4.4-8-5.8-16.3-8.2 2-.8 4-.3 9.9-.6a32.3 32.3 0 0 0-14.4-11c4.5-3 5.3-3.3 9.8-7-7.7-.6-14.3-2-20.8-4a41 41 0 0 0-12.8-3.7m.7 9c4 0 6.6 1.4 6.6 3 0 1.7-2.5 3.1-6.6 3.1-4 0-6.6-1.5-6.6-3.2 0-1.7 2.6-3 6.6-3z"/>
|
||||
<use xlink:href="#al-a" width="100%" height="100%" transform="matrix(-1 0 0 1 512 0)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.2 KiB |
5
public/assets/flags/1x1/am.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-am" viewBox="0 0 512 512">
|
||||
<path fill="#d90012" d="M0 0h512v170.7H0z"/>
|
||||
<path fill="#0033a0" d="M0 170.7h512v170.6H0z"/>
|
||||
<path fill="#f2a800" d="M0 341.3h512V512H0z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 236 B |
13
public/assets/flags/1x1/ao.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-ao" viewBox="0 0 512 512">
|
||||
<g fill-rule="evenodd" stroke-width="1pt">
|
||||
<path fill="red" d="M0 0h512v259.8H0z"/>
|
||||
<path fill="#000001" d="M0 252.2h512V512H0z"/>
|
||||
</g>
|
||||
<path fill="#ffec00" fill-rule="evenodd" d="M228.7 148.2c165.2 43.3 59 255.6-71.3 167.2l-8.8 13.6c76.7 54.6 152.6 10.6 174-46.4 22.2-58.8-7.6-141.5-92.6-150z"/>
|
||||
<path fill="#ffec00" fill-rule="evenodd" d="m170 330.8 21.7 10.1-10.2 21.8-21.7-10.2zm149-99.5h24v24h-24zm-11.7-38.9 22.3-8.6 8.7 22.3-22.3 8.7zm-26-29.1 17.1-16.9 16.9 17-17 16.9zm-26.2-39.8 22.4 8.4-8.5 22.4-22.4-8.4zM316 270l22.3 8.9-9 22.2-22.2-8.9zm-69.9 70 22-9.3 9.5 22-22 9.4zm-39.5 2.8h24v24h-24zm41.3-116-20.3-15-20.3 14.6 8-23-20.3-15h24.5l8.5-22.6 7.8 22.7 24.7-.3-19.6 15.3z"/>
|
||||
<path fill="#fe0" fill-rule="evenodd" d="M336 346.4c-1.2.4-6.2 12.4-9.7 18.2l3.7 1c13.6 4.8 20.4 9.2 26.2 17.5a7.9 7.9 0 0 0 10.2.7s2.8-1 6.4-5c3-4.5 2.2-8-1.4-11.1-11-8-22.9-14-35.4-21.3"/>
|
||||
<path fill="#000001" fill-rule="evenodd" d="M365.3 372.8a4.3 4.3 0 1 1-8.7 0 4.3 4.3 0 0 1 8.6 0zm-21.4-13.6a4.3 4.3 0 1 1-8.7 0 4.3 4.3 0 0 1 8.7 0m10.9 7a4.3 4.3 0 1 1-8.7 0 4.3 4.3 0 0 1 8.7 0"/>
|
||||
<path fill="#fe0" fill-rule="evenodd" d="M324.5 363.7c-42.6-24.3-87.3-50.5-130-74.8-18.7-11.7-19.6-33.4-7-49.9 1.2-2.3 2.8-1.8 3.4-.5 1.5 8 6 16.3 11.4 21.5A5288 5288 0 0 1 334 345.6c-3.4 5.8-6 12.3-9.5 18z"/>
|
||||
<path fill="#ffec00" fill-rule="evenodd" d="m297.2 305.5 17.8 16-16 17.8-17.8-16z"/>
|
||||
<path fill="none" stroke="#000" stroke-width="3" d="m331.5 348.8-125-75.5m109.6 58.1L274 304.1m18.2 42.7L249.3 322"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
5
public/assets/flags/1x1/aq.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-aq" viewBox="0 0 512 512">
|
||||
<path fill="#3a7dce" d="M0 0h512v512H0z"/>
|
||||
<path fill="#fff" d="M107.7 240.9c-3.5-7.9-3.5-7.9-3.5-15.7-1.8 0-2.1.4-3.1 0-1-.3-1.4 7.3-4.7 5.8-.5-.7 2.4-6.2-.8-8.4-1-.8.3-5.3-.2-7.2 0 0-4 2.3-7-5.9-1.4-2.1-3.4 2-3.4 2s.9 2.5-.7 3c-2.3-1.8-3.9-.8-6.7-3.3-2.9-2.5.6-5.4-4.8-7.6 3.5-9.8 3.5-7.8 12.2-11.8-5.2-3.9-5.2-3.9-8.7-9.8-5.3-2-7-3.9-12.2-7.8-7-9.8-10.5-29.4-10.5-43.2 4.4-4.6 10.5 15.7 19.2 21.6l12.2 5.9c7 4 8.7 7.8 14 11.8l15.6 5.9c7 5.8 10.5 13.7 15.7 15.6 5.7 0 6.8-3.6 8.6-3.9 10.2-.5 15.5-2 17.5-5.5 2-2.8 7 1.6 21-4.3l-1.8-7.8s3.8-3.5 8.8-2c-.2-3.6-.5-13.1 4.4-17.5-3-3.5-1-6-1-6s2.8-3 3.2-4.6c-1.5-8.7 1.2-8.8 1.9-11.3.6-2.6-2.4-1.7-1.6-5.2.9-3.5 6-4.4 6.6-7.3.7-2.8-1.5-4.3-1.3-5 1-2.7.1-9.2 0-11.7 9.3-2.9 12.4-11.4 15.7-7.9 1.7-11.8 3.5-15.7 14-15.7 1.4-3.6-3.9-6.7-1.8-7.8 3.5-.5 6.1-.3 10.2 5.7 1.3 1.9 1.5-2.8 2.8-3.3 1.4-.5 4.5-.5 5-2.8.4-2.4 1.1-5.5 2.9-9.4 1.5-3.2 2.6 1.2 4 7.4 7.3.3 23.9 2.2 30.9 4.3 5.2 1.6 8.7-1.5 13.7-2.1 3.7 4.2 7.2 1 9.1 10 2.8 4.7 7.3.3 8.3 1.8 5.9 18 26 5.8 27.4 6.1 2.6 0 5.7 8.1 7.7 8 3.3-.7 2.4-3.2 5.2-2.2-.7 6.8 5.7 14.7 5.7 19.7 0 0 1.5.9 3-.6 1.4-1.5 2.7-5.4 4-5.3 3 .5 4.3 1 7.8 1.6 9.4 3.7 14.3 4.5 18 6.3 1.6 3.6 3.3 5.4 6.8 4.7 2.8 2.2.7 5 2.4 5.2 3.5-2 4.7-4.1 8.1-2.2 3.5 2 7 6 8.8 9.8 0 2-1.8 9.8 0 21.6.8 4 1.3 7 5 13.8-1 6.9 4.7 18.5 4.7 21.5 0 3.9-2.8 6-4.5 9.8 7 6 0 15.7-3.5 21.6 26.2 5.9 14 17.7 34.9 11.8-5.3 13.7-3.4 12.6 1.8 26.3-10.4 7.9-.2 10.3-7.2 20-.4.7 4.2 8.6 10.6 8.6-1.7 15.7-7 9.8-5.2 33.3-13.8-.3-8.2 17.6-17.5 15.7.6 11.3 5.3 12.2 3.5 23.6-7 2-7 2-10.4 7.8l-5.3-2c-1.7 9.9-5.2 11.8 0 21.6 0 0-6.7.3-8.7 0-.1 3.4 3 4.3 3.5 7.9-.3 1.4-10 7.6-17.4 7.8-2 4.9 5.2 10 4.8 12.5-8.2 1.7-11.8 13-11.8 13s4.2 2 3.5 4c-2.3-1.9-3.5-2-7-2-1.7.5-6-.1-10 7.6-4.5 1.7-6.6 1-10 6.1-1.5-4.8-3.7 0-6.3 2-2.7 1.8-6.2 6.4-6.7 6.2.1-1.3 1.6-6.2 1.6-6.2l-8.7 2h-1c-.8.1-.6-5.7-2.2-5.5-1.7.3-6.4 7.3-8 7.6-1.6.2-2.1-2.3-3.5-2-1.4.1-4.1 7.4-5 7.6-1 .2-5-4.4-8.3-3.8-17.2 6.8-19.9-13.4-22.6-2-3.6-2.1-3-.9-6.6.2-2.3.7-2.5-3.5-4.6-3.4-4.2.1-4 4.5-6.2 3.2-1.8-9.2-13-7.5-14.1-11.5-.9-4 4.8-4 6.7-6.8 1.4-4-1.5-5.5 4.3-9.4 7.4-5.7 3.1-7.8 4.4-12.1 2.4-6.2 2.4-7.7.4-13.2 0 0-5.8-17.6-7-17.6-3.4-1.1-3.4 6.5-8.5 8.6-10.5 3.9-29-10-32.2-10-3 .1-16.5 3.7-16-4-2 7.5-9.6 1.8-10 1.8-7 0-4.3 6-9 5.8-2.1-.8-23.6-2.2-23.6-2.2v4l-14-8-12.2-3.9c-10.4-3.9-5.2-13.7-22.6-7.8v-11.8h-8.7c3.4-23.5 0-11.7-1.8-33.3l-7 2c-7-10.7 9.7-8.6-5.2-15.8 0 0 .3-11.7-3.5-7.8-.7.5 1.8 5.9 1.8 5.9-14-2-17.5-5.9-17.5-21.6 0 0 11.5 1.9 10.5 0-1.6-3-3.8-22-3.4-23.3-.2-2.6 10.7-9.1 8.6-15.3 1.3-.6 5.3-.6 5.3-.6"/>
|
||||
<path fill="none" stroke="#fff" stroke-linejoin="round" stroke-width="2.5" d="M595.5 297.6c-.6 1.3-.5 2.6.1 3.6 1.1-1.7.2-2.4 0-3.6zm-476-149.4s-3-.4-2.4 2.3c1-2 2.3-2.2 2.4-2.3zm-.3-6.4c-1.7 0-3.8-.2-3 2.5 1-2.1 3-2.4 3-2.5zm12.7 36.3s2.6-.2 2 2.5c-1-2-2-2.4-2-2.5z" transform="matrix(.86021 0 0 .96774 -50 10)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
32
public/assets/flags/1x1/ar.svg
Normal file
@@ -0,0 +1,32 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="flag-icons-ar" viewBox="0 0 512 512">
|
||||
<path fill="#74acdf" d="M0 0h512v512H0z"/>
|
||||
<path fill="#fff" d="M0 170.7h512v170.7H0z"/>
|
||||
<g id="ar-c" transform="translate(-153.6)scale(1.024)">
|
||||
<path id="ar-a" fill="#f6b40e" stroke="#85340a" stroke-width="1.1" d="m396.8 251.3 28.5 62s.5 1.2 1.3.9c.8-.4.3-1.6.3-1.6l-23.7-64m-.7 24.2c-.4 9.4 5.4 14.6 4.7 23-.8 8.5 3.8 13.2 5 16.5 1 3.3-1.2 5.2-.3 5.7 1 .5 3-2.1 2.4-6.8-.7-4.6-4.2-6-3.4-16.3.8-10.3-4.2-12.7-3-22"/>
|
||||
<use xlink:href="#ar-a" width="100%" height="100%" transform="rotate(22.5 400 250)"/>
|
||||
<use xlink:href="#ar-a" width="100%" height="100%" transform="rotate(45 400 250)"/>
|
||||
<use xlink:href="#ar-a" width="100%" height="100%" transform="rotate(67.5 400 250)"/>
|
||||
<path id="ar-b" fill="#85340a" d="M404.3 274.4c.5 9 5.6 13 4.6 21.3 2.2-6.5-3.1-11.6-2.8-21.2m-7.7-23.8 19.5 42.6-16.3-43.9"/>
|
||||
<use xlink:href="#ar-b" width="100%" height="100%" transform="rotate(22.5 400 250)"/>
|
||||
<use xlink:href="#ar-b" width="100%" height="100%" transform="rotate(45 400 250)"/>
|
||||
<use xlink:href="#ar-b" width="100%" height="100%" transform="rotate(67.5 400 250)"/>
|
||||
</g>
|
||||
<use xlink:href="#ar-c" width="100%" height="100%" transform="rotate(90 256 256)"/>
|
||||
<use xlink:href="#ar-c" width="100%" height="100%" transform="rotate(180 256 256)"/>
|
||||
<use xlink:href="#ar-c" width="100%" height="100%" transform="rotate(-90 256 256)"/>
|
||||
<circle cx="256" cy="256" r="28.4" fill="#f6b40e" stroke="#85340a" stroke-width="1.5"/>
|
||||
<path id="ar-h" fill="#843511" stroke-width="1" d="M265.7 250c-2 0-3.8.8-4.9 2.5 2.2 2 7 2.2 10.3-.2a7.5 7.5 0 0 0-5.4-2.4zm0 .4c1.9 0 3.6.8 3.9 1.7-2.2 2.4-5.7 2.2-7.9.4 1-1.5 2.5-2.1 4-2.1"/>
|
||||
<use xlink:href="#ar-d" width="100%" height="100%" transform="matrix(-1 0 0 1 512.3 0)"/>
|
||||
<use xlink:href="#ar-e" width="100%" height="100%" transform="matrix(-1 0 0 1 512.3 0)"/>
|
||||
<use xlink:href="#ar-f" width="100%" height="100%" transform="translate(19.3)"/>
|
||||
<use xlink:href="#ar-g" width="100%" height="100%" transform="matrix(-1 0 0 1 512.3 0)"/>
|
||||
<path fill="#85340a" d="M251.6 260a2 2 0 1 0 2 3c.8.6 1.8.6 2.4.6h.3c.5 0 1.6 0 2.3-.6.4.5 1 .8 1.6.8a2 2 0 0 0 .4-3.9c.5.2.9.7.9 1.3a1.3 1.3 0 0 1-2.7 0 3 3 0 0 1-2.7 1.8 3.3 3.3 0 0 1-2.7-1.8c0 .7-.6 1.3-1.3 1.3a1.3 1.3 0 0 1-.4-2.6zm2.2 5.8c-2.2 0-3 2-5 3.3 1-.5 2-1.3 3.5-2.2 1.5-.9 2.8.2 3.7.2.9 0 2.2-1.1 3.7-.2 1.5.9 2.4 1.7 3.5 2.2-2-1.4-2.8-3.3-5-3.3a6 6 0 0 0-2.2.6c-1-.4-1.8-.6-2.2-.6"/>
|
||||
<path fill="#85340a" d="M253 268.3c-.8 0-2 .3-3.6.8 4-1 4.8.4 6.6.4 1.7 0 2.6-1.3 6.6-.4-4.4-1.4-5.3-.5-6.6-.5-.9 0-1.5-.3-3-.3"/>
|
||||
<path fill="#85340a" d="M249.6 269h-.8c4.6.5 2.4 3.1 7.2 3.1 4.8 0 2.6-2.6 7.2-3-4.8-.5-3.3 2.4-7.2 2.4-3.7 0-2.6-2.5-6.4-2.5"/>
|
||||
<path fill="#85340a" d="M260 276.1a4 4 0 0 0-8 0 4 4 0 0 1 8 0"/>
|
||||
<path id="ar-e" fill="#85340a" stroke-width="1" d="M238.3 249.9c5-4.4 11.4-5 14.9-1.8a8.6 8.6 0 0 1 1.6 3.7c.5 2.5-.3 5.2-2.3 8 .3 0 .7.1 1 .4 1.7-3.4 2.3-6.8 1.7-10l-.7-2.5c-4.8-4-11.4-4.4-16.2 2.2"/>
|
||||
<path id="ar-d" fill="#85340a" stroke-width="1" d="M246.2 248.6c2.8 0 3.5.6 4.8 1.7 1.3 1.1 2 .9 2.2 1.1.2.2 0 .9-.4.7-.5-.3-1.4-.7-2.7-1.8-1.3-1-2.6-1-4-1-3.8 0-6 3.2-6.5 3-.4-.2 2.2-3.7 6.6-3.7"/>
|
||||
<use xlink:href="#ar-h" width="100%" height="100%" transform="translate(-19.6)"/>
|
||||
<circle id="ar-f" cx="246.3" cy="252.1" r="2" fill="#85340a" stroke-width="1"/>
|
||||
<path id="ar-g" fill="#85340a" stroke-width="1" d="M241 253.4c3.7 2.8 7.4 2.6 9.6 1.3 2.2-1.3 2.2-1.8 1.7-1.8-.4 0-.9.5-2.6 1.4-1.8.8-4.4.8-8.8-1z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
109
public/assets/flags/1x1/arab.svg
Normal file
@@ -0,0 +1,109 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" version="1.0" id="flag-icons-arab" viewBox="0 0 512 512">
|
||||
<path fill="#006233" d="M0 0v512h512V0Z" class="arab-fil0 arab-str0"/>
|
||||
<g fill="#fff" fill-rule="evenodd" stroke="#fff">
|
||||
<path stroke-width=".4" d="M1071.9 2779.7c-25.9 38.9-7.2 64.2 19.5 66 17.6 1.3 54.2-24.9 54.1-55.7l-10-5.6c5.6 15.8-.2 20.8-12.1 31.6-23.5 21.3-71.5 22.8-51.5-36.3z" transform="matrix(.38779 0 0 .35285 -224 -715.6)"/>
|
||||
<path d="M1277.2 2881.7c145.8 4.1 192.2-137 102.2-257.8l-8.9 13.3c5.8 56.3 14.2 111.8 15 169.5-17.6 20.7-43.2 13-48.3-10 .3-31.2-9.9-57.6-22.8-82.8l-7.2 13.3c8.4 20.7 17.5 44 19.4 69.5-41.6 49.9-87.6 60-70.5-5.6-32.9 57.5 16.9 98 73.3 9.5 12.1 60.4 58.9 22.9 61.7 9.9 5.1-39.6 2.5-103.4-7.8-153.8 40.6 70.3 42 121 20.4 154.9-24 37.7-76.2 55.3-126.5 70.1z" transform="matrix(.38779 0 0 .35285 -224 -715.6)"/>
|
||||
<path d="M1359.9 2722.2c-31.2 2.3-47.2-4.1-30.3-27.2 16.7-22.6 32.3-4.6 36.5 25.6 3.9 28.3-54.8 64.4-75.1 64.4-30.7 0-44.9-39.5-16.6-75-36.4 103.6 78.6 43.5 85.5 12.2zm-21.6-24c-3.8-.2-6.6 6.5-4.7 7.8 5.5 3.8 14.2 1.5 15.1-.4 1.9-4.2-5.1-7.2-10.4-7.4z" transform="matrix(.38779 0 0 .35285 -224 -715.6)"/>
|
||||
<path d="M1190.5 2771.1c-30 59-.1 83.4 38.4 76.6 22.4-4.1 50.8-20 67.2-41.7.3-47.8-.4-95.2-4.6-141.5 15-17.9-1.3-17.8-7-37-2.6 11.2-8.9 23.3-2.8 32 4.3 46.7 6.7 94 6.6 142.2-30.2 24.3-52.9 33.3-69.1 33.1-33.5-.3-40.7-28.5-28.7-63.7z" transform="matrix(.38779 0 0 .35285 -224 -715.6)"/>
|
||||
<path d="M1251.8 2786.7c-.5-44.5-1.2-95-5.2-126.1 15.6-17.3-.8-17.7-5.9-37.1-3 11-9.6 23-3.8 31.9 2.6 47.6 5.1 95.2 5.6 142.8 3.6-2.3 7.7-3.2 9.3-11.5z" transform="matrix(.38779 0 0 .35285 -224 -715.6)"/>
|
||||
<path stroke-width=".4" d="M1135.4 2784.6c-3.8-4.8-6.5-10.2-9.6-14.9-.5-6.7 4-12.9 4.6-16.3 5.1 7.9 8.1 13.9 12.2 17.8m5.4 3.1c7.5 3 16.7 3 25.2 3.2 32.8.6 67.3-4.8 63.6 39.6a66.2 66.2 0 0 1-65.2 61.9c-41.7-.4-77.3-46.4-13-131.1 6.2-1 14.3.7 21 1.3 11.5.9 23.3-.2 36.8-11-1.6-27.9-1.6-54.3-5-79.5-5.8-8.9.8-20.8 3.8-31.9 5.1 19.4 21.4 19.8 5.9 37.2 3.7 28 4.1 56.5 4.1 73.5-7.8 11.9-13.9 24.5-36.7 29.3-23.3-3.4-33.8-36-58.1-25.2 6.7-29.4 68.4-36.1 74.6-12.9-4.1 24.2-61.7 14.5-77 92.7-4.7 24.1 20.7 46.3 46.8 44.5 25.5-1.7 52.7-19.4 55.4-49.2 2.1-24.9-33-22-47.7-21.7-21.4.5-34.9-2.8-43-7.5m21.9-53.9c3.8-3.6 17.1-6.1 21.9-.3-3.6 2.4-7.1 5-10 8.1-5-2.6-8.3-5.2-11.9-7.8z" transform="matrix(.38779 0 0 .35285 -224 -715.6)"/>
|
||||
<path d="M1194 2650.9a49 49 0 0 1 5.3 21c-2.2 10.4-11.1 20.1-20.3 20.4-5.7.2-12.1-1.4-16.6-10.3-.5-1.1-2.9-3.7-5.2-2.5-10.1 16.6-17.6 23.6-26.7 23.5-18.2-.3-12.8-16.5-29.6-21.5-7-.2-18.5 6.9-24.4 20.8-22.4 63.5-42.8-.2-34.1-29.8 1.3 28.3 8.1 45.1 15.1 44.6 5.1-.5 9.6-12.3 16.1-24.7 5-9.5 17-26.6 29.7-26.6 11.6.3 4.3 21.6 27.5 21.3 11.2-.2 21.5-8.8 31.9-26 2.3-.4 2.9 3.7 3.4 5.1 1.6 5.9 11.8 22.1 25.6 7.3-.7-3.2-.4-8.5-3.9-9.6z" transform="matrix(.38779 0 0 .35285 -224 -715.6)"/>
|
||||
<path stroke-width=".4" d="M1266.9 2598.3c-12.3 6.1-21.3.5-26.4-4.9 8.9-1.8 15.8-5 17.8-12-4-9-13.5-12.9-26.9-13-17.9.5-27.1 7.7-28.2 17.6 8.3.3 15.8-2 19 6-14.7 7.2-32 9.8-50.8 9.7-30.8 1.6-35.3-12.3-43.4-24.5-.6-.8-3.3-2.1-4.7-1.9-9.5 0-16.5 33.2-27.2 33.1-10.7-1.4-8.3-21.4-11.4-32.8-2.6 17.9 3.3 84.5 36.4 12.2 1-2.4 2.4-1.7 3.3.3 8.9 20.2 27 27.2 46.5 28.2 16.3.9 37.1-6.2 59.4-18.8 5.9 6.5 10.6 13.9 23 15.3 14.5.7 30-9.8 33.5-22.8 1.8-6.7 2.1-19.9-5-20.1-9.9-.3-17.1 23.7-14.8 45.3.2-.3 1.3-5.4 1.3-5.4m-43.8-28.8c6.5-3 12.8-4.4 17.8 2.2a27.4 27.4 0 0 0-8.4 4c-2.8-2.2-6.6-3.3-9.4-6.2zm47.8 14.9c1.6-7.1 2.5-12.8 8.3-16.5 1.2 7.5 1.4 11.7-8.3 16.5zm39 11c-1.9-6.1-3.8-11.4-4.4-18-1.4-13.4 10.1-21 20.5-19.9 10.7 1.1 17.8 5.1 28 8.6 8 2.7 18.8 4.8 29.1 7.7 5.8 2.6 0 9.4-1.5 10.3-25.8 10.1-44.1 26.1-60.5 26.8-9.8.5-18.5-5.9-26.4-19-.5-25.4-1.4-55.2-3.9-73.9 3.8-3.8 4.6-6.6 6.4-9.7 2 24.7 2.8 50.7 3.3 76.9 2.1 4.5 4.7 8.3 9.4 10.2zm16.5 2c-13.8 3.9-12.1-7.8-13.4-15-1.5-8.4-.5-17.9 10.2-15.5 13.9 3.7 26.6 8.6 38.9 13.8z" transform="matrix(.38779 0 0 .35285 -224 -715.6)"/>
|
||||
<path stroke-width=".4" d="m1314.3 2621.3 1.9 9.3h1.5l-.6-8.7" transform="matrix(.38779 0 0 .35285 -224 -715.6)"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="m1094.2 2718.5 7-7.2 8.1 6.9-7.5 6.7zm17.8-2.4 7.1-7.2 8.1 6.9-7.5 6.7zm-49.5-74.6 7.1-7.2 8.1 6.9-7.5 6.7zm3.2 21.2 7.1-7.2 8 6.9-7.5 6.7zm128.5 35.5 6.5-5.3 6 6.5-6.8 4.8zm-85.8-135.7 4.6-4.7 5.3 4.5-4.9 4.4zm11.7-1.5 4.6-4.8 5.3 4.6-4.9 4.3zm245.6 53.7-4.4 3.7-4.2-4.3 4.6-3.4z" transform="matrix(.38779 0 0 .35285 -224 -715.6)"/>
|
||||
<path stroke-width=".4" d="m1158.7 2747.4-.5 7.9 12.6 1.2 10.1-7.6z" transform="matrix(.38779 0 0 .35285 -224 -715.6)"/>
|
||||
<path d="m1265.2 2599.8 3.7-.8-.4 10.3-2.3.9z" transform="matrix(.38779 0 0 .35285 -224 -715.6)"/>
|
||||
</g>
|
||||
<path fill="#fff" d="M256 348c55 0 99.8-40.7 99.8-90.8a87.3 87.3 0 0 0-34.7-68.8 74.9 74.9 0 0 1 20.5 51.3c0 43.5-38.3 78.8-85.6 78.8s-85.6-35.3-85.6-78.8a74.8 74.8 0 0 1 20.6-51.3 87.3 87.3 0 0 0-34.8 68.8c0 50.1 44.8 90.9 99.8 90.9z" class="arab-fil2"/>
|
||||
<g fill="#fff" stroke="#000" stroke-width="8">
|
||||
<path d="M-54 1623c-88 44-198 32-291-28-4-2-6 1-2 12 10 29 18 52-12 95-13 19 2 22 24 20 112-11 222-36 275-57zm-2 52c-35 14-95 31-162 43-27 4-26 21 22 27 49 5 112-30 150-61z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
|
||||
<path d="M0 1579c12 0 34-5 56-8 41-7 11 56-56 56v21c68 0 139-74 124-107-21-48-79-7-124-7s-103-41-124 7c-15 33 56 107 124 107v-21c-67 0-97-63-56-56 22 3 44 8 56 8z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
|
||||
<path d="M54 1623c88 44 198 32 291-28 4-2 6 1 2 12-10 29-18 52 12 95 13 19-2 22-24 20-112-11-222-36-275-57zm2 52c35 14 94 31 162 43 27 4 26 21-22 27-49 5-112-30-150-61z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
|
||||
<path d="M3 1665c2 17 5 54 28 38 31-21 38-37 38-67 0-19-23-47-69-47s-69 28-69 47c0 30 7 46 38 67 23 16 25-21 28-38 1-6 6-4 6 0z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
|
||||
</g>
|
||||
<g fill="#fff" stroke="#000" stroke-width="8">
|
||||
<path d="M-29 384c-13-74-122-79-139-91-20-13-17 0-10 20 20 52 88 73 119 79 25 4 33 6 30-8z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
|
||||
<path d="M4 386c11-76-97-112-110-129-15-18-17-7-10 14 13 45 60 98 88 112 23 12 30 17 32 3z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
|
||||
<path d="M93 430c10-91-78-105-101-134-15-18-16-8-11 13 10 46 54 100 81 117 21 13 30 18 31 4z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
|
||||
<path d="M66 410c-91-59-155-26-181-29-25-3-33 13 10 37 53 29 127 25 156 14 30-12 21-18 15-22zm137 40c-28-98-93-82-112-94s-21-9-17 13c8 39 75 82 108 95 12 4 27 10 21-14z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
|
||||
<path d="M190 467c-78-63-139-16-163-23-18-5-10 7-3 12 50 35 112 54 160 32 19-8 20-10 6-21zm169 64c1-62-127-88-154-126-16-23-30-11-22 26 12 48 100 101 148 111 29 6 28-4 28-11z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
|
||||
<path d="M355 542c-81-73-149-49-174-56-25-6-35 9 4 39 48 36 122 43 153 36s23-14 17-19zm145 107c-23-106-96-128-114-148-17-20-35-14-20 34 18 57 77 107 108 119 30 13 28 3 26-5z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
|
||||
<path d="M499 663c-59-95-136-92-160-105-23-14-39-2-8 39 36 50 110 78 144 80s28-7 24-14z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
|
||||
<path d="M575 776c34-108-44-148-52-166-9-18-18-18-23 1-22 77 49 152 60 167 11 14 13 7 15-2z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
|
||||
<path d="M559 806c-27-121-98-114-114-131-17-17-19-5-16 17 8 59 79 99 111 119 10 6 22 13 19-5zm68 142c49-114-9-191-27-208-18-16-29-23-23 0 8 35-20 125 23 191 14 22 16 43 27 17z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
|
||||
<path d="M601 971c11-70-29-134-72-159-25-15-26-11-26 10 2 65 63 119 81 149 17 28 16 7 17 0z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
|
||||
<path d="M590 1153c-36-132 39-208 62-223 22-16 36-22 26 3-15 37 1 140-56 205-18 22-25 45-32 15z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
|
||||
<path d="M598 1124c30-115-35-180-55-193-19-13-31-18-22 3 12 32-1 122 49 178 16 19 22 38 28 12z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
|
||||
<path d="M561 1070c-54 58-55 143-31 193 15 29 17 27 31 6 38-61 15-149 17-188 1-37-11-17-17-11z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
|
||||
<path d="M650 1162c0 80-49 145-101 165-30 11-30 8-26-16 14-90 83-123 108-152 24-28 19-5 19 3z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
|
||||
<path d="M464 1400c88-80 41-136 45-188 2-28-9-21-19-11-56 55-59 153-47 191 5 17 13 15 21 8z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
|
||||
<path d="M582 1348c-29 88-106 142-171 145-38 2-37-1-24-27 49-94 136-105 175-129 36-22 23 2 20 11z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
|
||||
<path d="M343 1513c114-57 91-152 112-176 15-17-3-15-12-9-67 39-121 101-122 167 0 25 2 28 22 18z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
|
||||
<path d="M187 1619c144 23 211-86 253-96 22-5 6-14-5-15-96-11-218 34-255 84-15 20-15 24 7 27z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
|
||||
<path d="M333 1448c-29 95-137 173-218 179-38 3-38-1-24-26 65-118 178-138 218-168 34-26 27 6 24 15zM29 384c13-74 122-79 139-91 20-13 17 0 10 20-20 52-88 73-119 79-25 4-33 6-30-8z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
|
||||
<path d="M-4 386c-11-76 97-112 110-129 15-18 17-7 10 14-13 45-60 98-88 112-23 12-30 17-32 3z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
|
||||
<path d="M-93 430c-10-91 78-105 101-134 15-18 16-8 11 13-10 46-54 100-81 117-21 13-30 18-31 4z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
|
||||
<path d="M-66 410c91-59 155-26 181-29 25-3 33 13-10 37-53 29-127 25-156 14-30-12-21-18-15-22zm-137 40c28-98 93-82 112-94s21-9 17 13c-8 39-75 82-108 95-12 4-27 10-21-14z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
|
||||
<path d="M-190 467c78-63 139-16 163-23 18-5 10 7 3 12-50 35-112 54-160 32-19-8-20-10-6-21zm-169 64c-1-62 127-88 154-126 16-23 30-11 22 26-12 48-100 101-148 111-29 6-28-4-28-11z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
|
||||
<path d="M-355 542c81-73 149-49 174-56 25-6 35 9-4 39-48 36-122 43-153 36s-23-14-17-19zm-145 107c23-106 96-128 114-148 17-20 35-14 20 34-18 57-77 107-108 119-30 13-28 3-26-5z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
|
||||
<path d="M-499 663c59-95 136-92 160-105 23-14 39-2 8 39-36 50-110 78-144 80s-28-7-24-14z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
|
||||
<path d="M-575 776c-34-108 44-148 52-166 9-18 18-18 23 1 22 77-49 152-60 167-11 14-13 7-15-2z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
|
||||
<path d="M-559 806c27-121 98-114 114-131 17-17 19-5 16 17-8 59-79 99-111 119-10 6-22 13-19-5zm-68 142c-49-114 9-191 27-208 18-16 29-23 23 0-8 35 20 125-23 191-14 22-16 43-27 17z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
|
||||
<path d="M-601 971c-11-70 29-134 72-159 25-15 26-11 26 10-2 65-63 119-81 149-17 28-16 7-17 0z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
|
||||
<path d="M-590 1153c36-132-39-208-62-223-22-16-36-22-26 3 15 37-1 140 56 205 18 22 24 45 32 15z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
|
||||
<path d="M-598 1124c-30-115 35-180 55-193 19-13 31-18 22 3-12 32 1 122-49 178-16 19-22 38-28 12z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
|
||||
<path d="M-561 1070c54 58 55 143 31 193-15 29-17 27-31 6-38-61-15-149-17-188-1-37 11-17 17-11z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
|
||||
<path d="M-650 1162c0 80 49 145 101 165 30 11 30 8 26-16-14-90-83-123-108-152-24-28-19-5-19 3z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
|
||||
<path d="M-464 1400c-88-80-41-136-45-188-2-28 9-21 19-11 56 55 59 153 47 191-5 17-13 15-21 8z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
|
||||
<path d="M-582 1348c29 88 106 142 171 145 38 2 37-1 24-27-49-94-136-105-175-129-36-22-23 2-20 11z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
|
||||
<path d="M-343 1513c-114-57-91-152-112-176-15-17 3-15 12-9 67 39 121 101 122 167 0 25-2 28-22 18z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
|
||||
<path d="M-187 1619c-144 23-211-86-253-96-22-5-6-14 5-15 96-11 218 34 255 84 15 20 15 24-7 27z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
|
||||
<path d="M-333 1448c29 95 137 173 218 179 38 3 38-1 24-26-65-118-178-138-218-168-34-26-27 6-24 15z" class="arab-fil2 arab-str2" transform="matrix(.25022 0 0 .22768 256 29)"/>
|
||||
</g>
|
||||
<path fill="#006233" d="M298.3 137.4c-4.8-3.1-22.3-1.3-25.5-3.4 6.2 4.8 20.2 1.4 25.5 3.4m42.3 8.2c-3.8-6.1-26-10.2-29.3-15.7 5.8 10.5 23 9.1 29.3 15.7m-3.3 7c-8.2-7.2-27.5-4.2-33.2-8.6 13.5 11.2 21 2.5 33.2 8.7zM289 120.4c5.3 2.5 11.8 5 15 10.9-3.7-4.6-10.5-6.4-16-10.2.3 0 .8-.5 1-.7m82.1 46.9c-3.3-6.9-14.8-14.4-15.8-16.9 3.3 8.9 12.8 11 15.8 16.9m3 12c-10-14.3-25.8-12.7-32-18.2 4.7 5.3 22.8 8.7 32 18.2m23.3 22.1c.7-15.2-11.8-21-12.3-29.6-.2 10.3 12.8 24.2 12.3 29.6m-6.3 8.2c-2.5-13.2-19.5-14.1-22.5-21.8 0 7.2 20 14.8 22.5 21.8m14-7.5c9 10 2.8 25.3 6.5 36.4-4.5-8.2-2.2-28.6-6.5-36.4m-14.7 42.8c13.5 13.2 8 28 13.5 34.4-6.8-9-5.8-26.2-13.5-34.4m28 1.8c-11.5 11.6-4.5 29.1-10.5 37.4 6.7-7.1 5.7-28.8 10.5-37.4m-14.5 0c-1.5-13.4-15.3-20.5-16.5-27.8-1.5 7.3 13.2 18.7 16.5 27.8m-7 32.1c2.2 9.4-6 29.4-3.5 35.5-5.5-10.7 4.7-31 3.5-35.5m17.7 21.4c-5.5 16.6-16.5 15.5-20 25.5 2.5-9.5 17-18.2 20-25.5m-35.7 7.8c-7.3 11.1-1.3 23.9-7.3 31.8 8.5-8 4-22.7 7.3-31.8m17.5 30.5c-8.8 14.8-26.8 13.4-34 24.1 7.2-13.4 29.5-15.7 34-24.1m-31.8-1.9c-15.5 9.8-10.8 20-22.5 31 14.7-11 13.5-23 22.5-31m-7.3 39.7c-15-.5-36.5 17.3-49.5 15.9 13 2.5 37-13.4 49.5-16zm-24.2-16c-1 13.9-40 22.8-44.3 32.1 4.7-12.3 39-21.4 44.3-32zm-88.4-256c-5-4-11-7.1-12.7-11 1.2 5 6.2 8.7 11.2 12 .5-.2 1-.9 1.5-1m-8.5 4c-7.7-3.4-16.7-3.2-20.7-8 2.5 4.8 11 6.7 18.2 9.2.8-.5 1.8-1 2.6-1.2zm-22.5 29.2c4.8-3.2 22.3-1.4 25.5-3.4-6.2 4.7-20.2 1.3-25.5 3.4m-42.3 8.2c3.8-6.2 26-10.3 29.3-15.7-5.8 10.4-23 9-29.3 15.7m3.3 7c8.2-7.3 27.5-4.3 33.3-8.6-13.5 11.1-21 2.5-33.3 8.6m33.3-21.4c4.7-9 18.2-10.2 21.7-15.7-5.2 8.2-16.7 9.6-21.7 15.7m38.5-8c13.8-5.9 27.3-.9 33.8-3.6-8 3.9-27 2-33.8 3.7zm-105.6 44c3.3-6.8 14.8-14.4 15.8-16.9-3.3 9-12.8 11-15.8 16.9m-3 12c10-14.3 25.8-12.7 32-18.2-4.7 5.3-22.7 8.7-32 18.3zm-23.3 22.1c-.7-15.2 11.8-21 12.3-29.6.2 10.3-12.8 24.2-12.3 29.6m6.3 8.2c2.5-13.2 19.5-14 22.5-21.8 0 7.3-20 14.8-22.5 21.8m-14-7.5c-9 10-2.8 25.3-6.5 36.4 4.5-8.1 2.2-28.6 6.5-36.4m14.7 42.8c-13.5 13.2-8 28-13.5 34.4 6.8-8.9 5.8-26.2 13.5-34.4m-28 1.9c11.5 11.6 4.5 29 10.5 37.3-6.7-7-5.7-28.7-10.5-37.3m14.5 0c1.5-13.5 15.3-20.5 16.5-27.8 1.5 7.3-13.2 18.7-16.5 27.8m7 32c-2.2 9.4 6 29.4 3.5 35.6 5.5-10.7-4.7-31-3.5-35.5zm-17.7 21.4c5.5 16.7 16.5 15.5 20 25.5-2.5-9.5-17-18.2-20-25.4zm35.8 7.8c7.2 11.2 1.2 23.9 7.2 31.9-8.5-8-4-22.8-7.2-31.9m-17.6 30.5c8.8 14.8 26.8 13.4 34 24.1-7.2-13.4-29.5-15.7-34-24.1m31.8-1.8c15.5 9.8 10.8 20 22.5 31-14.7-11-13.5-23-22.5-31m7.3 39.6c15-.5 36.5 17.3 49.5 16-13 2.4-37-13.5-49.5-16m24.3-16c1 14 40 22.8 44.2 32.2-4.7-12.3-39-21.4-44.3-32.1zm56.7-236.5c3-12.3 18-14.6 20-21.9-.7 7.8-18.5 16.4-20 22zM280 93.3c-2.2 9.3-18.5 14.6-20.7 21.6.7-9.5 17.5-15 20.7-21.6m-12.7 22c7.7-11.3 23.7-8.3 29.3-15-4 7.8-23.8 8-29.3 15" class="arab-fil0"/>
|
||||
<path fill="none" stroke="#f7c608" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.9" d="M373.2 256c0 59.2-52.7 107.1-117.7 107.1s-117.6-47.9-117.6-107c0-59.2 52.6-107 117.6-107s117.7 47.8 117.7 107z"/>
|
||||
<path fill="#f7c608" d="m232.4 363.2-.4 1.3c-.3.9-1.2 1.3-2.2 1.3-2.5-.7-5.6-1.3-8.5-2l2.7-8.3a90.2 90.2 0 0 0 8.5 1.8c1 .2 1.5 1 1.2 2l-.4 1m-20.2-5.1.4-1.3c.3-1 1.2-1.4 2.2-1.1 2.3.8 5.1 1.8 8.3 2.7l-2.7 8.3c-3-.8-5.8-1.6-8.3-2.5-1-.5-1.2-2.2-.9-3"/>
|
||||
<path fill="#006233" d="m230.8 362.5-.3.9c-.2.6-1 1-1.8.9l-7-1.8 1.9-5.9a87 87 0 0 0 7 1.6c.9.2 1.3.8 1 1.5l-.2.7m-16.8-4.3.3-1c.2-.6 1-.9 1.8-.6l6.9 2.1-2 6a105.5 105.5 0 0 1-7-2c-.6-.4-.9-1.7-.7-2.3"/>
|
||||
<path fill="#f7c608" d="m200.2 352.8-.8 1.2c-.5.7-1.5 1-2.5.6-2.1-1.2-5-2.6-7.5-4.1l5.1-7.3a85.7 85.7 0 0 0 7.6 4c.9.5 1.1 1.4.6 2.2l-.7 1m-17.8-10.2.8-1.2c.5-.7 1.6-1 2.4-.5 2 1.5 4.4 3.1 7.1 4.7l-5.1 7.3a97 97 0 0 1-7.2-4.5c-.8-.7-.4-2.4 0-3.1"/>
|
||||
<path fill="#006233" d="m198.9 351.7-.6.8c-.4.6-1.2.7-2 .4l-6.2-3.5 3.7-5.2c2.2 1.4 4.6 2.5 6.3 3.4.7.4 1 1 .5 1.6l-.5.7m-14.8-8.5.6-.8c.4-.5 1.2-.6 2-.2a95.4 95.4 0 0 0 5.9 3.8l-3.7 5.2a68.3 68.3 0 0 1-6-3.7c-.6-.5-.5-1.8-.1-2.3"/>
|
||||
<path fill="#f7c608" d="m172.6 334.6-1.2.9c-.7.6-1.8.5-2.6 0l-6-5.9 7.3-5.6c2 2.2 4.4 4.3 6 5.7.7.7.6 1.6-.1 2.2l-1 .8m-14-14.3 1.2-1a1.8 1.8 0 0 1 2.5.2 78 78 0 0 0 5.3 6.4l-7.1 5.6a98.8 98.8 0 0 1-5.6-6.1c-.4-.9.4-2.5 1.1-3"/>
|
||||
<path fill="#006233" d="m171.6 333.2-.8.6c-.5.5-1.3.3-2-.1l-5-5 5.2-4a78.2 78.2 0 0 0 5 4.8c.6.6.6 1.3 0 1.8l-.6.5m-11.6-12 .8-.5c.6-.5 1.4-.4 2 .2a76.5 76.5 0 0 0 4.4 5.2l-5.1 4a67.8 67.8 0 0 1-4.6-5c-.5-.7 0-1.9.6-2.3"/>
|
||||
<path fill="#f7c608" d="m151.7 310-1.4.6c-.9.4-2 0-2.5-.7-1-2-2.7-4.7-4-7.1l8.7-3.6c1.4 2.7 3 5.3 4.1 7 .4.9 0 1.8-.8 2.1l-1.2.5m-9-17.3 1.4-.6c1-.3 2 0 2.3.8a75.3 75.3 0 0 0 3.2 7.5l-8.6 3.6c-1.3-2.5-2.5-5-3.4-7.4-.2-1 1-2.2 2-2.6"/>
|
||||
<path fill="#006233" d="m151.2 308.4-1 .4c-.6.3-1.4 0-1.9-.6l-3.2-6 6.2-2.6a72.3 72.3 0 0 0 3.4 6c.3.6 0 1.3-.6 1.6l-.8.4m-7.4-14.4 1-.4c.6-.3 1.4 0 1.7.7.7 1.8 1.6 4 2.7 6.2l-6.2 2.5a78 78 0 0 1-2.9-6c-.1-.8.7-1.8 1.4-2"/>
|
||||
<path fill="#f7c608" d="m139.2 281.1-1.5.2a2.1 2.1 0 0 1-2.2-1.3l-1.5-7.9 9.4-1.2a68 68 0 0 0 1.7 7.8c.2 1-.4 1.7-1.4 1.8l-1.3.2m-3.2-18.9 1.5-.2c1-.1 1.8.4 2 1.4 0 2.2.2 5 .7 8l-9.4 1.1c-.4-2.7-.9-5.3-1-7.9.1-1 1.7-1.8 2.7-2"/>
|
||||
<path fill="#006233" d="m139.2 279.5-1 .1c-.7.1-1.3-.4-1.6-1.1-.3-1.9-.9-4.3-1.2-6.6l6.7-.9a68.7 68.7 0 0 0 1.4 6.6c0 .7-.3 1.3-1 1.4l-1 .2m-2.6-15.8 1-.1c.8 0 1.4.4 1.5 1.2.1 1.8.3 4.1.7 6.5l-6.8 1c-.3-2.3-.7-4.6-.8-6.6 0-.8 1.2-1.5 2-1.6"/>
|
||||
<path fill="#f7c608" d="m136.2 250.2-1.5-.2c-1-.1-1.6-1-1.7-1.9l1-7.9 9.4 1.3a69 69 0 0 0-.7 7.9c-.2.9-1 1.5-2 1.3l-1.3-.1m2.8-19 1.5.2c1 .2 1.6 1 1.4 1.8a73.3 73.3 0 0 0-1.7 7.9l-9.4-1.3a85 85 0 0 1 1.5-7.8c.4-1 2.2-1.4 3.2-1.2"/>
|
||||
<path fill="#006233" d="m136.8 248.6-1.1-.2c-.7 0-1.2-.7-1.2-1.5l.9-6.5 6.7.8a69.3 69.3 0 0 0-.7 6.7c-.1.7-.7 1.2-1.4 1.1l-1-.1m2.4-15.8 1 .2c.8 0 1.2.7 1 1.4-.4 1.9-1 4.1-1.3 6.5l-6.7-.8c.3-2.3.7-4.5 1.2-6.6.3-.7 1.6-1 2.3-1"/>
|
||||
<path fill="#f7c608" d="m142.9 219.7-1.3-.6c-1-.3-1.3-1.2-1.1-2.2 1-2 2.1-4.8 3.4-7.3l8.6 3.6a75 75 0 0 0-3.2 7.4c-.4.9-1.4 1.2-2.3.8l-1.1-.4m8.5-17.5 1.3.5c1 .4 1.3 1.3.9 2.1a77.3 77.3 0 0 0-4.1 7l-8.6-3.5a78 78 0 0 1 3.8-7.1c.7-.8 2.6-.8 3.5-.4"/>
|
||||
<path fill="#006233" d="m144 218.4-1-.5c-.7-.2-1-1-.7-1.7l2.8-6 6.2 2.5a70.7 70.7 0 0 0-2.7 6.1c-.3.7-1 1-1.7.8l-.9-.4m7.1-14.5 1 .4c.7.3.9 1 .6 1.7a76.1 76.1 0 0 0-3.4 5.9l-6.2-2.6a82.2 82.2 0 0 1 3.2-6c.5-.6 2-.6 2.6-.4"/>
|
||||
<path fill="#f7c608" d="m158.8 192.2-1.2-.9c-.7-.6-.8-1.6-.3-2.4 1.6-1.7 3.5-4.1 5.5-6.2l7.2 5.7a76.9 76.9 0 0 0-5.4 6.3 1.8 1.8 0 0 1-2.4.2l-1-.8m13.6-14.6 1.1 1c.8.5.9 1.5.2 2.2a83.6 83.6 0 0 0-6.1 5.7l-7.2-5.7 6-5.8c.8-.6 2.6 0 3.4.5"/>
|
||||
<path fill="#006233" d="m160.2 191.1-.8-.6c-.6-.4-.6-1.2-.2-1.9l4.7-5 5.1 4a76.1 76.1 0 0 0-4.5 5.2c-.5.6-1.3.7-1.9.3l-.7-.6m11.3-12.1.9.6c.5.5.5 1.2 0 1.8a85 85 0 0 0-5 4.8l-5.2-4a58 58 0 0 1 4.9-5c.6-.5 2-.1 2.5.3"/>
|
||||
<path fill="#f7c608" d="m182.5 169.9-.8-1.2c-.6-.8-.3-1.7.4-2.4l7.2-4.5 5.1 7.3a83.2 83.2 0 0 0-7 4.7 1.8 1.8 0 0 1-2.5-.5l-.7-1m17.6-10.5.8 1.2c.6.7.3 1.7-.5 2.2a83.6 83.6 0 0 0-7.7 3.9l-5.1-7.3c2.5-1.5 5-3 7.5-4.1 1-.3 2.6.7 3 1.4"/>
|
||||
<path fill="#006233" d="m184.2 169.2-.6-.8c-.4-.6-.2-1.3.4-1.8 1.8-1 4-2.5 6-3.8l3.7 5.2-5.9 3.9c-.7.4-1.5.3-1.9-.2l-.5-.7m14.6-8.8.6.9c.4.5.2 1.2-.5 1.6a88.8 88.8 0 0 0-6.4 3.3l-3.6-5.2a96.5 96.5 0 0 1 6.2-3.4c.8-.3 2 .4 2.4 1"/>
|
||||
<path fill="#f7c608" d="m212.2 154.5-.4-1.3c-.3-.9.2-1.7 1-2.2 2.5-.6 5.5-1.7 8.4-2.5l2.7 8.3a88 88 0 0 0-8.3 2.7c-1 .3-1.9-.2-2.1-1l-.4-1.1m20-5.7.5 1.3c.3 1-.2 1.8-1.2 2a94.8 94.8 0 0 0-8.5 1.8l-2.7-8.3c2.9-.7 5.8-1.5 8.5-2 1 0 2.2 1.3 2.5 2.2"/>
|
||||
<path fill="#006233" d="m214 154.3-.3-1c-.2-.6.2-1.2 1-1.5 2-.6 4.5-1.4 7-2l1.9 5.9a86.6 86.6 0 0 0-7 2.2c-.8.1-1.5-.1-1.7-.8l-.2-.7m16.7-4.7.3 1c.2.6-.3 1.2-1 1.4a98 98 0 0 0-7.2 1.6l-1.9-6a107 107 0 0 1 7-1.7c1 0 1.9.9 2 1.5"/>
|
||||
<path fill="#f7c608" d="M245.4 147.4v-1.3c0-1 .8-1.6 1.8-1.9l8.7-.2v8.7a89.5 89.5 0 0 0-8.7.4c-1 0-1.8-.7-1.8-1.6v-1.1m21-.3v1.4c0 1-.7 1.6-1.7 1.6a96 96 0 0 0-8.8-.4V144c3 0 6 0 8.8.2 1 .2 1.7 1.8 1.7 2.7"/>
|
||||
<path fill="#006233" d="M247.2 147.7v-1c0-.6.6-1.1 1.5-1.3l7.3-.1v6.1a92 92 0 0 0-7.3.4c-.9 0-1.5-.5-1.5-1.2v-.8m17.5-.2v1c0 .7-.6 1.2-1.5 1.2a87.9 87.9 0 0 0-7.2-.4v-6.1l7.2.1c.9.2 1.5 1.3 1.5 2"/>
|
||||
<path fill="#f7c608" d="m277.3 149 .4-1.4c.2-.8 1.2-1.3 2.2-1.3 2.4.6 5.6 1.2 8.5 2l-2.6 8.3a90.2 90.2 0 0 0-8.6-1.7c-1-.3-1.5-1.1-1.2-2l.4-1.1m20.3 5-.5 1.3c-.2.9-1.2 1.4-2.1 1.1a93.4 93.4 0 0 0-8.3-2.6l2.6-8.3a82 82 0 0 1 8.3 2.4c1 .4 1.2 2.2 1 3"/>
|
||||
<path fill="#006233" d="m279 149.7.2-1c.2-.6 1-1 1.8-.9 2 .6 4.6 1 7 1.7l-1.8 6a89 89 0 0 0-7.1-1.6c-.8-.2-1.3-.8-1-1.4l.2-.8m16.8 4.2-.3 1c-.2.6-1 .9-1.7.7a92.3 92.3 0 0 0-7-2.2l2-6 6.9 2c.8.4 1 1.7.8 2.3"/>
|
||||
<path fill="#f7c608" d="m309.5 159 .8-1c.6-.9 1.6-1 2.6-.8l7.5 4-5 7.4a86 86 0 0 0-7.7-3.8 1.6 1.6 0 0 1-.6-2.3l.7-1m18 10-.9 1.2a1.8 1.8 0 0 1-2.4.5 87.8 87.8 0 0 0-7.1-4.6l5-7.3c2.6 1.4 5.1 2.9 7.3 4.4.7.7.4 2.4-.1 3.2"/>
|
||||
<path fill="#006233" d="m310.9 160.2.6-.8c.4-.6 1.2-.7 2-.4 1.7 1 4.1 2.1 6.2 3.4l-3.6 5.2a84.9 84.9 0 0 0-6.4-3.3c-.7-.4-1-1-.5-1.6l.5-.7m14.8 8.3-.5.8c-.4.6-1.2.7-2 .3a86.9 86.9 0 0 0-6-3.8l3.7-5.2c2.1 1.2 4.3 2.4 6 3.6.7.6.6 1.8.2 2.4"/>
|
||||
<path fill="#f7c608" d="m337.4 177 1.1-.8c.8-.7 1.8-.6 2.7 0 1.6 1.7 4 3.7 6 5.8l-7.2 5.7a79.8 79.8 0 0 0-6.2-5.7c-.6-.7-.5-1.6.2-2.2l1-.8m14 14.2-1.1 1a1.8 1.8 0 0 1-2.5-.2c-1.4-1.9-3.3-4-5.4-6.3l7.2-5.7c2 2 3.9 4 5.5 6.1.5.8-.3 2.4-1 3"/>
|
||||
<path fill="#006233" d="m338.3 178.5.8-.7c.6-.4 1.4-.3 2 .1 1.4 1.5 3.4 3.2 5 5l-5 4a78.5 78.5 0 0 0-5.2-4.8c-.5-.5-.5-1.3 0-1.7l.7-.6m11.7 11.9-.8.6c-.6.4-1.4.3-2-.2a80.2 80.2 0 0 0-4.5-5.2l5.1-4c1.7 1.6 3.3 3.3 4.7 5 .4.6-.1 1.8-.6 2.3"/>
|
||||
<path fill="#f7c608" d="m358.5 201.5 1.4-.6c.9-.4 1.9 0 2.5.7 1 2 2.7 4.6 4 7l-8.7 3.7c-1.3-2.7-3-5.2-4.1-7-.4-.8 0-1.7.8-2.1l1.2-.5m9 17.2-1.3.6c-.9.4-1.9 0-2.3-.8a75.5 75.5 0 0 0-3.3-7.4l8.7-3.6a86.5 86.5 0 0 1 3.4 7.3c.2 1-1 2.2-2 2.6"/>
|
||||
<path fill="#006233" d="m359 203 1-.4c.6-.2 1.4 0 1.9.7.9 1.7 2.2 3.9 3.2 6l-6.1 2.5a79.4 79.4 0 0 0-3.4-5.8c-.4-.7-.2-1.4.5-1.7l.8-.4m7.6 14.4-1 .4c-.7.3-1.4 0-1.8-.7-.7-1.8-1.6-4-2.7-6.2l6.1-2.6a86.3 86.3 0 0 1 3 6c.1.9-.7 1.8-1.4 2.1"/>
|
||||
<path fill="#f7c608" d="m371.2 230.3 1.5-.2c1-.2 1.9.4 2.3 1.3l1.5 7.8-9.3 1.3a69.8 69.8 0 0 0-1.9-7.8c-.1-.9.5-1.7 1.5-1.8l1.2-.2m3.4 18.9-1.4.2c-1 .1-1.9-.4-2-1.3l-.8-8 9.3-1.3c.5 2.7 1 5.4 1.1 7.9 0 1-1.7 1.9-2.7 2"/>
|
||||
<path fill="#006233" d="m371.2 232 1-.2c.8-.1 1.4.4 1.7 1.1l1.3 6.5-6.7 1a68.8 68.8 0 0 0-1.5-6.6c-.1-.7.3-1.3 1-1.4l1-.2m2.7 15.7-1 .2c-.7.1-1.4-.4-1.5-1.2a71.7 71.7 0 0 0-.7-6.5l6.7-1c.4 2.3.8 4.5 1 6.6-.1.8-1.3 1.5-2 1.6"/>
|
||||
<path fill="#f7c608" d="m374.5 261.2 1.5.2c1 0 1.7.9 1.8 1.8-.4 2.3-.6 5.2-1 8l-9.4-1.2c.5-3 .7-6 .7-8 .1-.9 1-1.5 2-1.3l1.2.1m-2.5 19-1.5-.2c-1-.1-1.7-.9-1.5-1.8.5-2.2 1.2-4.9 1.6-7.8l9.5 1.1c-.4 2.7-.8 5.4-1.5 7.9-.3.9-2.2 1.3-3.2 1.2"/>
|
||||
<path fill="#006233" d="m374 262.8 1 .1c.8 0 1.2.7 1.3 1.5l-.8 6.6-6.8-.8c.4-2.5.6-5 .7-6.6 0-.8.7-1.3 1.4-1.2h.9m-2.2 15.9-1-.1c-.8-.1-1.2-.8-1-1.5.4-1.9.9-4 1.3-6.6l6.7.9a82 82 0 0 1-1.2 6.5c-.2.8-1.6 1.2-2.3 1"/>
|
||||
<path fill="#f7c608" d="m368.2 291.7 1.3.6c1 .3 1.3 1.2 1.1 2.2-1 2-2 4.8-3.3 7.3l-8.7-3.5a76 76 0 0 0 3.1-7.5c.4-.8 1.4-1.1 2.3-.8l1.2.5m-8.4 17.6-1.3-.6c-1-.4-1.3-1.3-1-2.1 1.3-2 2.8-4.4 4.1-7l8.7 3.4c-1.2 2.5-2.5 5-3.8 7.2-.6.8-2.5.7-3.5.3"/>
|
||||
<path fill="#006233" d="m367.1 293 1 .5c.7.3.9 1 .7 1.7l-2.8 6.1-6.2-2.5a70.7 70.7 0 0 0 2.6-6.2c.4-.6 1.1-1 1.8-.7l.8.3m-7 14.6-1-.4c-.6-.2-.8-1-.5-1.7a76 76 0 0 0 3.3-5.9l6.2 2.5a86 86 0 0 1-3.1 6c-.5.7-1.9.7-2.5.5"/>
|
||||
<path fill="#f7c608" d="m352.5 319.4 1.2.8c.8.6.8 1.6.4 2.4l-5.5 6.2-7.2-5.6c2-2.2 4-4.6 5.3-6.3.6-.7 1.6-.8 2.4-.2l1 .7m-13.5 14.7-1.1-.8c-.8-.6-.9-1.6-.2-2.3a77 77 0 0 0 6-5.8l7.3 5.6a94.1 94.1 0 0 1-5.9 6c-.8.5-2.6 0-3.4-.6"/>
|
||||
<path fill="#006233" d="m351.1 320.4.9.6c.5.5.5 1.2.1 1.9l-4.6 5.1-5.2-4c1.8-1.8 3.4-3.8 4.5-5.2.5-.6 1.3-.7 1.9-.3l.7.5m-11.2 12.3-.8-.7c-.6-.4-.6-1.1 0-1.7a82 82 0 0 0 5-4.9l5.1 4a77 77 0 0 1-4.8 5c-.7.5-2 .2-2.6-.3"/>
|
||||
<path fill="#f7c608" d="m329 341.9.8 1.1c.6.8.4 1.7-.3 2.4l-7.2 4.6-5.2-7.3a83 83 0 0 0 7-4.7 1.8 1.8 0 0 1 2.5.4l.6 1m-17.4 10.7-.8-1.2c-.6-.7-.4-1.7.5-2.2a89.9 89.9 0 0 0 7.6-4l5.2 7.3c-2.5 1.4-5 3-7.5 4.1-1 .4-2.5-.6-3-1.4"/>
|
||||
<path fill="#006233" d="m327.3 342.5.6.9c.4.5.2 1.2-.4 1.8l-6 3.7-3.7-5.1a84.6 84.6 0 0 0 5.9-4c.7-.4 1.5-.3 1.9.3l.5.7m-14.6 8.8-.5-.8c-.4-.6-.2-1.3.5-1.7a88.6 88.6 0 0 0 6.3-3.3l3.7 5.1c-2 1.3-4.2 2.5-6.2 3.5-.8.3-2-.3-2.4-.9"/>
|
||||
<path fill="#f7c608" d="m299.5 357.4.4 1.4c.3.8-.2 1.7-1 2.1l-8.4 2.6-2.7-8.3a80.4 80.4 0 0 0 8.2-2.7c1-.3 1.9.1 2.2 1l.3 1.1m-20 5.8-.4-1.3c-.3-.9.2-1.8 1.1-2 2.5-.4 5.5-1 8.6-1.9l2.7 8.3c-2.9.8-5.7 1.6-8.4 2-1 .1-2.3-1.2-2.6-2"/>
|
||||
<path fill="#006233" d="m297.7 357.7.3.9c.2.6-.2 1.2-1 1.6-2 .6-4.5 1.4-7 2l-1.9-5.8a87.7 87.7 0 0 0 6.9-2.3c.8-.2 1.5.1 1.8.7l.2.8m-16.7 4.8-.3-1c-.2-.6.3-1.2 1-1.4a87.9 87.9 0 0 0 7.1-1.6l2 5.9a106 106 0 0 1-7 1.7c-.9.1-1.8-.8-2-1.4"/>
|
||||
<path fill="#f7c608" d="M266.3 364.8v1.4c0 1-.7 1.6-1.7 1.8-2.5 0-5.7.3-8.8.3v-8.6a87.7 87.7 0 0 0 8.7-.5c1 0 1.8.6 1.8 1.5v1.2m-21 .4v-1.4c0-.9.7-1.6 1.7-1.6 2.5.2 5.5.4 8.7.4l.1 8.6a100.6 100.6 0 0 1-8.7-.1c-1-.2-1.8-1.8-1.8-2.7"/>
|
||||
<path fill="#006233" d="M264.5 364.6v1c0 .6-.6 1-1.4 1.3l-7.3.2v-6.2c2.6 0 5.3-.2 7.2-.4.9 0 1.5.5 1.5 1.1v.8m-17.5.4v-1c0-.7.6-1.2 1.4-1.2 2.1.2 4.6.3 7.3.3l.1 6.2-7.3-.1c-.8-.2-1.5-1.3-1.5-2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 26 KiB |
73
public/assets/flags/1x1/as.svg
Normal file
@@ -0,0 +1,73 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-as" viewBox="0 0 512 512">
|
||||
<path fill="#006" d="M0 0h512v512H0Z"/>
|
||||
<path fill="#bd1021" d="M0 256 512 0v512Z"/>
|
||||
<path fill="#fff" d="m41.4 256 470-228.6v457.2"/>
|
||||
<path d="M334.9 288.5c5.4.3 5.2 5.7 5.2 5.7l19.3.5c2.5-6.8 5-6 9.7-2.6a35 35 0 0 0 9.3 4.5c2-9.6 15.6-7.7 15.6-7.7 5.8-13.9 6.3-13.7 2.8-15.5a11.4 11.4 0 0 1-4.9-4.7 28 28 0 0 1-5.4-13.3c-.4-3.5-4.5 1.7-5.2.7-.7-1.2-6.8-.5-6.8-.5 1.5 1.6-3.6.6-3.6.6.5.5 0 2 0 2-.5-.7-4.4-1.4-4.4-1.4a6.5 6.5 0 0 1-1.2 1.7c-2.1-.8-6.3-.7-6.3-.7a21.4 21.4 0 0 0-11.7 3c-1.7 1-8 4-13 9-5.1 5-8 4.3-8 4.3-1.5 5.6-13.6 12.3-13.6 12.3-2 1.7-8.1 2.5-11.2 0-3.1-2.6 0-7.4 0-7.4 1.2-2 2.3-2 2.4-9.5.1-5 5.3-9.2 10.7-15 6.6-7.3 15.9-19.3 15.9-19.3 0 3.7 2 4.3 2 4.3 1.8-3.7 4.4-6.7 4.4-6.7.2.3.6.5.6.5 2-2.8 3.3-3.9 3.3-3.9-.7-.3-6.5 0-11.8 4.7-5.4 4.7-9 3.1-9 3.1-3.7-1.2-4-4-4-4-2.8-11.8 7.9-20 7.9-20-14.3-3.5-4-21.7 13.8-29.5 17.7-7.7 17.5-11.2 17.5-11.2a13.9 13.9 0 0 1 2 3.3c0-.1 1.4-2 11.7-6.6a76 76 0 0 0 15-8.5c1.4 2.6 1.2 4.4 1.2 4.4 28-9.8 55.5-32.4 55.5-32.4.8 1.9.4 4.8.4 4.8 4.6-4.3 21-14 21-14 .3 6.2-4.8 8.6-4.8 8.6a8.2 8.2 0 0 1 .8 2.5 384 384 0 0 0 15.4-10.2c4.6 4 .5 10.4.5 10.4 1.6-.2 2.7-1.6 2.7-1.6 1.3 6.8-6.3 12.8-6.3 12.8a10 10 0 0 0 3.6-1.4c-1.5 7.4-15.5 15.6-15.5 15.6 2 1.8.1 4.2-1.6 5.4-1.7 1-4.6 3.4-3.7 4.4.9 1 7.1-3.5 7.1-3.5 1 3.1-6.9 9.3-6.9 9.3 5.6.7 21-6.3 21-6.3-1.3 6-7 10.6-14.3 13.3-7.1 2.7-6.7 3.1-6.7 3.1 1.2 1 11-1.9 11-1.9-2.9 6.6-13.2 11.2-13.2 11.2 2.8 2.5 6.7-.4 10.6-3a62 62 0 0 1 15-6.9c5.7-2 9.8-.5 9.8-.5 5-1.5 9 .7 9 .7 9.2.7 10.2 4 10.2 4 1 .3 1.8.7 4.2 2.5 2.4 1.7 2.2 7.1 2.1 9.8 0 2.6-.8 2.6-1.3 3.3-.4.8-.5 1.6-.5 2.6s-2.4 7.4-16.7 7.4h-21.8c-1.2 0-2.6.7-2.6.7-6 3-2.8-2-10 3.8-7.2 6-10.9 5-10.9 5a96.2 96.2 0 0 1-13 11.2c-4.3 2.8-3.6 2.5.2 4.1s9.4 0 9.4 0c-3.6 2.5-1 3.6-1 3.6 4.6-3 7.6-1.9 7.6-1.9 1.5 4.2-4 10.8-4 10.8 2.1.2 6.1 0 6.1 0-1 2.8-4.9 5.9-7.8 6.8-3 1-2.5 1.4-1.6 3 .8 1.9.2 3.7.2 3.7-5.2-3.5-5.3-.4-5.3-.4-.6 4.1-.5 10.2-.5 10.2-3.6-1.8-3.8.6-3.8.6a23 23 0 0 1-5.4 8.1c-.1-2.3-2.3-3-2.3-3-2.3 4.5-6.6 7.2-6.6 7.2-.5 3.8.6 9.1.6 9.1-2.8-.5-3.8-.5-4.2.1-.4.7.6 1 .6 1l35.6.9c.5 0 2.6.3 2.6 4 .1 4-3 4.2-3 4.2l-39-1s.2 1.1-1.8 2.2-1.3-1.2-2 3.6c-.5 4.7-8.2-.4-8.2-.4-1.3 1.9-4.3 4.3-4.3 4.3-1.8-5.3-3.6-7-6.4-2.4-2.7 4.6 5.1 3.8 5.1 3.8l48.2-7c2.5-.2 5.1 0 6.3 3.4 1 3.5-5.6 4-5.6 4l-46.9 5.2c-1 2.7-4.9 2.6-4.9 2.6.4 2.6-2.4 4.3-3.8 5.2-1.5 1-6 .6-6 .6-5.4 3.7-8 .8-8 .8-3.6 1.5-5.8.8-8.7-.4-3-1.3-2.7-4.8-2.7-4.8l-29.6 3a7.2 7.2 0 0 0-2.3 1.5c1 1.3-2.2 4.4-2.2 4.4 1 .6 2.6 2.3 2.9 6 .2 4-4.8 4.6-2.3 7.4 2.4 2.7 7.1.4 12.3-2 5-2.5 10-2.2 12.2-2.2 2.2 0 8.3 1.6 12.1 3 3.8 1.2 5.1.4 5.4-1.5.2-2 2-2.5 2-2.5-.5 1.9.5 2.8.5 2.8 1.5 0 4-1.4 4-1.4-.2 1.5-2.1 2.4-2.1 2.4-3.7 2.4 1.4 1.5 1.4 1.5a50.3 50.3 0 0 1 16.5-1.6 130.7 130.7 0 0 1 15.2 5.6c.4-1.3.2-4.4.2-4.4a9 9 0 0 1 4.4 2.8c1.3-1.3.5-3.6.5-3.6 10.2 5.8-2.2 8.4-5.5 9.6-3.3 1-3.3 2.4-3.3 2.4 2.8-.8 4.7-1.2 7-1.4 2.2-.1 1.4 0 6.9-1 5.5-1.2 8.3 1.2 8.3 1.2-4.6.2-6 1.6-6 1.6 3 1.9.2 3.6.2 3.6-4.1-5.2-7.7.2-7.7.2a16.3 16.3 0 0 1 6.8 1.4c.9.6 3 1.7 5.7 3 3.8 1.6 3 .6 6 1.6 3 1.2 1.8 4 1.8 4a7.3 7.3 0 0 0-4-3.2c-.2 3.1-3.3 3.7-3.3 3.7 3.9-4.2-4.4-6.1-8.3-6-3.8 0-6.7 2.5-6.7 2.5 7.8 7.4 13.2 5 13.2 5-1 2.6-7.4 1.5-7.4 1.5 3 2.3 2.6 3.9 2.6 3.9-1.6-1.6-4.1-.8-9.8-4.4-5.5-3.6-10.5-2.4-10.5-2.4 5.5 5.7-2 9.2-2 9.2-2.8 1.7 1.2 3.7 1.2 3.7-3.5.7-4-2.8-4-2.8-1.8-.4-4.4 1.8-4.4 1.8.2-3.5 4.9-1.7 5-5.4.1-3.7-4.3-6.6-17.5-4.8-13.2 1.9-17-2.4-17-2.4-1.2 0-1.3 1.2-1.3 1.2 2 2.1 3 3 2.7 4.4-.4 1.5.7 2 .7 2-2.5-.2-2.6-3-2.6-3 0 1.2-.5 1.2-1.3 2.5s0 2.8 0 2.8c-1.1-.9-3-2-1.2-4.5 1.3-2-2.8-4.5-2.8-4.5-1.7-1.7-6-.1-6-.1-9 1.7-14.2-4-14.2-4-1 0-3-.5-3-.5-9.5 4.2-17.9-5-17.9-5-7.1 1.4-10.4-2-12.5-5.5a12.3 12.3 0 0 0-5.6-5.4c-2.8-1.6-5.5-6.6-2.8-9.2 2.2-2.3 1.6-2.8 1.6-2.8-3.8-6.3 6.5-8.2 6.8-9.8.2-2.1 2.4-3.5 4.7-3.7 2.4-.1 2.4 0 4-1.4 1.4-1.7 4.3.3 4.3.3.7-.4 5.8-4.4 10.3-2.4 4.6 2 8.4.7 8.4.7 3.2-.7 29.9-4.3 29.9-4.3 1.6-2.7 3-5.8 10.3-7.4s12.8-6.4 12.8-6.4c-1.3-1.4-3.5-1.3-4.6-1.4-1.1-.1-3.4-2.2-3.4-2.2-1.4.7-2 .4-11.7 6.3-8.7 5.2-9-5.2-9-5.2h-18.7c-.3 4-3.3 5.6-3.3 5.6l-7 .3c-3.7-2-3.7-8.8-3.7-8.8a65.2 65.2 0 0 0-32.2 7.7c-23.4-11.9-41.7-14.7-41.7-14.7 28.4-3 43.5-11 43.5-11a67.5 67.5 0 0 0 30.4 9.7c.5-5.7 4.4-7.1 4.4-7.1z"/>
|
||||
<path fill="#ffc221" d="M301 335.7c-5.9 3.4-4.8 5.3-4.3 6.4.5 1 .6 2.1-1 3.8-1.7 1.6-1.5 2.2-1.5 2.2.3 5.7 4.3 7 6 8.4 1.5 1.1 3.9 4.9 3.9 4.9a9.3 9.3 0 0 0 8.7 4.4c2.4 0 2.2-.2 1-1.3l-3.6-3a18.8 18.8 0 0 1 6.2 4.5c6 6.6 11.6 5.7 14 5.4 2.5-.3 2.1-1.8 2.1-1.8-.1-.2-2.4-.4-2.4-.4-9.2-.8-11.9-6.8-11.9-6.8a25.7 25.7 0 0 0 16.7 6.4c2.6-.1 2.4.7 1.9.8-.6.1-1.3 0-2.6-.1-1.2 0-1.2.2-1 .8.4.4 1.5.5 3 .4 1.4 0 .2.2 4 3 3.8 3 13 .6 13 .6-6-1.5-6.8-4.3-6.8-4.3-8.2 1-11.5-3.9-11.5-3.9a34.7 34.7 0 0 0-5.9-3.7c-4.6-2-5.3-6.2-5.3-6.2 1.3 2 3.8 4 7 5 3.3.8 4.1 1.2 4.1 1.2a4 4 0 0 1-2.4-.2c-3.2-1-1.4.3-1.4.3 3.6 2.9 4.5 2.6 4.5 2.6 9.3 1 4.7-2.7 4.7-2.7 6.5 1.6 7.7-.9 7.7-.9 1.4 3 6.4 1.8 6.4 1.8-6.7 3.3-1.7 2.3-1.7 2.3 6.8-1.2 8.2.5 8.2.5 1.7 1.7 3.6 1.5 3.6 1.5s1.2 0 3.7.5c2.6.5 6.6 2.7 10.3 2.3 3.6-.5 4.3.6 4.3.6-.8-.2-2.4-.5-5.2.8-2.9 1.3-7.9 1.7-15.2 0s-7.8-1.4-7.8-1.4a10 10 0 0 1 3.6 4.3c.3 1.2 1.6 1.3 1.6 1.3.5-1.6 2.6-2.3 2.6-2.3 1.5 1.3 5.4 3 5.4 3 .4-.8 0-1.4 0-1.4 2.7 2.7 6 1.9 6 1.9.8-.6.6-2.2.6-2.2 1.1 0 1.3.7 2 1.3 1 .4 3.4.1 3.4.1-1-.4-1.7-1.8-1.7-1.8 3.8-2.5 11.7-1.5 11.7-1.5 5.7 1.1 5 4.9 5 4.9a11 11 0 0 1 2.8 2.3c.5-1.4 0-2.7 0-2.7 2.7 1.2 3.2 4.3 3.2 4.3 3-3.5-2.9-7.4-2.9-7.4 2.9-.4 6-.1 8 .1a14.7 14.7 0 0 1 7 3.3 19 19 0 0 0 6.2 2.7c0-.8-2.2-2.1-2.7-2.4-.5-.2-.7-1-.7-1 2 .4 3.3.2 3.3.2a47.2 47.2 0 0 1-8.7-6.2c2.6.3 4.1-1.3 4.1-1.3-5.5 0-5.7-1.3-5.7-1.3.7.1 3.2.8 6.5.1s7.8 0 7.8 0c-2.4-3.8-11.5-3.1-14.5-3-3 .2-4-.2-4-.2.4-.2 1-.7 3.2-.8 2.3 0 4.6.3 7.2-1.6 2.5-1.7 6.1-1.1 6.1-1.1-.8-1.8-5-2.4-8.6 0-3.6 2.2-7 1.6-7 1.6 5.8-.8 7.4-2.9 7.4-2.9-1.6-.5-2.6.1-6 .8-3.5.7-4.4-.5-4.4-.5 3.7-2.2 6.4-3.1 6.4-3.1-3.2-.8-6.2-2.2-6.2-2.2-3.3 3.2-6 5-12.4 1.7-6.4-3.5-9.8-3-9.8-3a15 15 0 0 1 15.7.6c4.2 2.5 5.5.5 5.5.5-1.4-.8-1.2-1.6-1.2-1.6 10.2 5.2 14.7 2 17 .5 2.2-1.6-1.1-3.6-1.1-3.6-.2 3.2-4.4 4.9-7.7 3.7-3.3-1.1-6.3-2.5-11-4.6-4.8-2-10.6-.9-16.2.3-5.6 1.1-6.2.6-6.8.2-.5-.5-.7-1.8-3.6-.7-2.8 1.2-9.4-1.9-13.5-2.9a25 25 0 0 0-16.5 2.7c-5.8 3.3-8.7 2.5-10.4 1.7-1.7-.8-2.9-3-1-5s2-2.4 1.9-5.2c-.2-2.8-3-4.5-3-4.5 2.6-2.6 3.2-3.3 2.3-4.4-.8-1.1.5-1.1 2-1.8 1.5-.6.8-.7.5-1.6-.4-.8-1.2-.6-1.2-.6-3.4.1-5.3-.9-5.3-.9-5.5-2.5-10.8 2.5-10.8 2.5-3.1-2.5-3.9-.8-4.5-.3-.4.6-1.6 1-3 1.2-1.4.1-3.5.6-4.1 2 0 0-.7 1 0 2 0 0 .9 1.4-.6 3-1.6 1.6-2.2 1.9-1.6 3.4.4 1.5.3 2.7-.4 3.6 0 0-.7-.8-.5-1.8.2-1 .2-1.7 0-2.2 0 0-1.6 1.5-1.8 2.6 0 0-.7-1.7 1.6-4 2.2-2.2 3.3-3.3 2.6-4.2-.5-.7-2.1.4-2.5.7z"/>
|
||||
<path d="M307.5 360.6s-2.8-2.2-2.5-5.2c.3-3 .3-3.2 0-4 0 0-.6.3-.5 1.5l-.2 2.4s-1.5-2.4-2.2-2.9c0 0 .6-2.6-.2-3.7s-1.6-1.2-2.5-.8c-1.4.4-2.3 1.6 2 5.2 0 0 1.6 1.3 2.7 4 1.1 2.9 3 3.3 3.3 3.5zm14-8.3s-.3-1.6 1.3-4.6a6.5 6.5 0 0 0 .2-6c-.3-.8-.5-.5 1-1.8 2-1.6-.7-3.6 2.5-6.4 0 0 2-1.7 2.4-2.4 0 0-3.2 1.7-5.4 2.6-2.2.9-10.3 4.9-8.5 7.6 1.8 2.7 1.6 2.8 1.3 4 0 0-5-2.6-3.1-6.8 0 0 .7-1.6 2.7-3.6 2-1.7.9.4 4.6-1.8 0 0 3-1.8 4.6-4.1 0 0-2 1.2-2.7 1.4 0 0-4.4.8-6.2 2.6-1.7 2-5.5 5.1-4.3 8.7 0 0-4.3-.5-5.3-5 0 0-8.1 9.9 9 14.6 0 0 3 .9 5.9 1"/>
|
||||
<path fill="#ffc221" d="M396.2 318.9c6.4-.9 43.2-6.5 46.6-6.9 3.6-.3 5-.8 6.2 2.2 1.4 3.2-5 3.3-5 3.3l-44 5c-2 .2-2.5-.6-2.5-.6l-1.7-2.2s-.5-.6.4-.8"/>
|
||||
<path fill="#5a3719" d="M306.4 339s-4.8 9.9 13.9 12.3c0 0 0-1 .8-2.7.8-1.5 2.4-4.7.8-6.7-1.5-2 1.3-1 1.6-3.7.5-2.6-.2-2.3 1.1-4 0 0-5.8 2.1-8 4.7-2.3 2.6 3 4.5 0 7.4 0 0-2.8-1-4.3-3.9 0 0-3.6.1-6-3.5z"/>
|
||||
<path d="M324.7 351.2s4.6 4 10 3.8c5.5-.2 7.9-1.6 9.3-3.8 0 0 1 1.6 1 2.7 0 0 4.8-3.9 13-.4s5.6 2.4 7.4 2.7c0 0-3.5-.6-11.4 3-8.2 3.7-29.6 2.4-29.4-8z"/>
|
||||
<path fill="#5a3719" d="M317 333.5s2 .4 4-2c0 0-2.7.6-4 2m-15.7 18.6s-3.8-3-1.4-3.6c0 0 1.8-.4 1.4 3.6"/>
|
||||
<path d="M385.1 371s2.2-3.7 8-3.7c6 0 6.6 2.8 14 3.3 0 0-8.9 2.5-15.1.3a8.9 8.9 0 0 0-6.9.1"/>
|
||||
<path fill="#5a3719" d="M326.6 353.9s9 5.3 16.8-.5c0 0 .6.6 1.2 2 0 0 6-5.7 16.6.5 0 0-1.3-.2-6.3 2-6.5 2.8-22.9 4.7-28.3-4.1z"/>
|
||||
<path d="M360.8 359.3s8.5 1 15.6.5c4.4-.2 9.2-1 6.9.4-2.5 1.5-1.3 1.7 8.9.8 10-1-.1 1.9 6.8 2.8 0 0-16.9 8.6-38.2-4.5"/>
|
||||
<path fill="#5a3719" d="M383 353.9s5-1.8 9.7.3c4.5 2.2 3.8 2.4 6.9 2.7 0 0-2.1 3-7.2.6a31 31 0 0 0-9.4-3.6m5.6 15.2s4.9-2.4 10.3.1c.6.4 2 1 3.5 1.3 0 0-4.1 1.3-8.4 0a30 30 0 0 0-5.4-1.4m-24.2-8.7s11 1 16.8-.2c0 0-6.8 3.3 10.4 1.8 0 0 3.7-.4 3.3.2-.5.6-.8 1 1.1 1.6 0 0-12.7 5.8-31.6-3.4"/>
|
||||
<path d="M310 351.2s.1.8 2.5 1.9c2.5 1 3.8 3 4.3 4a6 6 0 0 0 3.7 3.1s-8.6 1.8-12.3-2.7c0 0-3-3.1 1.7-6.3"/>
|
||||
<path fill="#5a3719" d="M377.2 370.9s-3.3-.3-8-1.8c-4.6-1.7-5.8-.3-8.4-2.2-2.6-2-7.9-.8-8.8-.6-1 0-3.8 0-.4-2.3 0 0-2.7 0-3.8-1.5 0 0-1.2 1.3-6 .8 0 0 2.2 3.3-6.3 2.3a11 11 0 0 0 11.8 3.3s-.6 2.6 3.3 3.6c4 1 4.7 1.7 6.8 2.5 0 0 .2-1.6-5-5.4 0 0 2.8-.1 6.8.9s13.1 3.2 18 .4m2.1 3.9s.9 2 3.5 1.5c2.5-.5 6.7-1.2 10.8.9 0 0 .8-3.5-7.5-3.7 0 0-5.1.2-6.8 1.3m-69.6-22.4s-3.2 2.6-.3 5.3c2.6 2.5 6.7 2.3 8.7 2.2 0 0-1.3-.6-2.2-2.2-1.1-1.5-1.1-2.6-3.3-3.6-2.2-1-2.5-1.2-2.9-1.7m-3.2-13.5s-4.9 10 13.9 12.4c0 0 0-1 .8-2.7.6-1.5 2.3-4.7.8-6.7-1.6-2 1.2-1 1.6-3.7.4-2.6-.3-2.3 1-4 0 0-5.8 2.1-8 4.7-2.3 2.6 3 4.5 0 7.4 0 0-2.8-1-4.3-3.8 0 0-3.6 0-6-3.6z"/>
|
||||
<path d="M355.4 362s4-.7 13.9 3c10 3.7 14.1 2.8 15.6 2.8 0 0-5.6 2.9-14.3-1-7.7-3.4-8.2-2-15.2-4.8"/>
|
||||
<path fill="#ffc221" d="M417.8 359.8s2.2-.1 3.6.4c0 0 .8-.7 3-1 0 0-1.4-1.2-6.6.6m-6.8-5.6s2.2 0 3-1.2c0 0-1.3-1.4-3-2.2 0 0 .4 1.7 0 3.4m-76.5-25.4s-.5-1.2 2-1.5l33.3-4.8s1.6 0 1.9 1c.2 1.3-.2 2.1-7.7 3-7.5.8-27.3 3.3-27.3 3.3s-2 .4-2.2-1"/>
|
||||
<path fill="#ffc221" d="M364.7 327.4s0 4.4 4.5 5c4.3.7 5.8-.2 7-2.5.3-.6 1.6-5.2-.3-5.5a7 7 0 0 0-3.1.3c-1.5.7-2.9 1.4-2.5 2.1 1.2 1.7 1.4 2 1 2.2-1.1.3-1.9-.7-2.1-1.4-.2-.8.6-1.3-2.3-.8-1.2.1-2.2.1-2.2.6"/>
|
||||
<path fill="#ffc221" d="M383.3 324c2.2.3 2 5.1-.6 7.3-3 2.4-5.7 1.5-5.7 1.5-1.6-.6-1.3-.4-.2-2.1 1-1.7 1.6-3.9 1-5.4-.2-.5.3-1 1-1 0 0 2.2-.5 4.5-.3"/>
|
||||
<path fill="#ffc221" d="M385.3 324.5s1.6 2-.4 5.8c0 0-1 1.1 1.1 1 2-.2 6.6-2.3 6-5.1 0 0 0-.7-1.3-.7-1.2.1-.2-.6.3-.8.5 0 2-.7-2-3.3 0 0-.6-.5-1.4-.3-.6.3-2.7 1-2.7 2.4 0 .5.4 1 .4 1"/>
|
||||
<path fill="#ffc221" d="M389.6 321s3.3 2.3 3.2 3c0 .6-.3 1.6.6 1.4.8 0 4.2-.8 3.3-3-1-2.3-2-3-3.5-3.7-1.6-.5-2 .2-3.4 1.3 0 0-1 .6-.2 1m-17.1 3.2s.5-1.6-2.4-2.3c0 0 1.2-1 3.7-.5 2.3.5 2.1 2.2 2.1 2.3 0 0-1.9 0-3.4.5m6.2-.4s3.2-.6 4.7-.4c0 0-1.6-3.6-6-2.5 0 0 1.6 2 1.3 2.9m5.6-.8s0-1.2 2.8-2.3c0 0-1.3-1.3-3.3-1.1-2.1 0-2.6.8-2.6.8s2.5.9 3.1 2.5zm1-4s1.9.6 3 1.5c0 0 1.6-1.8 3-2.2 0 0-2.7-1.4-6 .8z"/>
|
||||
<path fill="#5a3719" d="M294 310.3s7.7-6.5 12-5.7c4.3.8 2.2.3 6.8-.5 4.8-.7 9.6-1.2 11.6-1 0 0-5.8-4-15.9-4 0 0-7.1 2.5-12 5.8 0 0-9.6-5.3-19.4-2.2 0 0 10.7 4 17 7.6z"/>
|
||||
<path fill="#ffc221" d="m375.6 321.6 1.3-.2s2.1 2.6.6 2.6c-1.3 0-.9-.3-1-1a2.5 2.5 0 0 0-1-1.4zm-9.7.2s-.8 1 .7.8c1.8-.3 1.5 0 3.3-1.3 0 0 1.3-1.3 3.5-.5 0 0 1.8.6 3.3-.1 1.6-.8 1.9-.7 2.7-.7.8.1.8.3 1.8-.5 1.2-.8 3-.2 4.2-1.2 1.2-1 2.7-.1 0-2 0 0-.6-.6-.5-1 0 0 1 .3 1.9 1 .8.7 2 .5 2.3.3 0 0 .2-2.4 2.5-4.6 2.4-2.2 2.4-2.3 1-2.3s-3.7-.6-4.5 0-7.7 5.1-11.8 5.9c-4 .8-7.7 2-10.4 6.2m-108-25s12.5 3.2 15.2 4.5c0 0 .7-2-5-3.6 0 0 13.8-.4 28.2 6.2 0 0 7-6 29.6-4.2 0 0 0-2 .2-3.5 0 0-15.8-.5-30.3-9.3 0 0-13.1 6.4-37.9 10zm69 6c-.7-12.7 4-13.9 4-13.9s2.4.1 4.8.5c0 0-3.8 4.6-2.8 13.7 0 0 .4 1.4-2.8 1.4S327 303 327 303z"/>
|
||||
<path fill="#5a3719" d="M330.3 311.2s-2.4-2.4-2.6-5.2c0 0 0-.7 2.3-.7 2.4.1 2.7-.1 3.3 1.3.5 1.3 2 4.1 2.4 4.5z"/>
|
||||
<path fill="#ffc221" d="M335.4 304.9a30.2 30.2 0 0 1-.3-4.9c.2-7 1.3-6.4 1.8-5.5h2.5s-1.8-8-4-3.3a20.3 20.3 0 0 0-1.6 11.2c.2 2.1.4 3.4.7 4.2z"/>
|
||||
<path fill="#5a3719" d="M362.8 304.9s4.6.8-2.5 2.4c0 0 .3 8.7 8.7 2.6 0 0 5.1-3.2 8.5-4.5 0 0 1.8-.5 1.6-1.9 0 0 .2-1.6-1.6-1.2 0 0-1.5 0-2.5-.3 0 0-1-1.2-1.6-.8-.7.5-2.3.2-1 1.8 1.2 1.4 1.6 1 2.2.6.6-.4 3.3-1.4.8.8s-4.4-1.3-5.2-2zm-23.5 1h-2s-1.2 1.6-2-1l-.7 1.7s2.4 9.3 4.7-.7"/>
|
||||
<path fill="#ffc221" d="M336.4 295.1s-1.1 6.2.3 9.8l22.5.5s-.3-4.3 0-10.3H356s-.5 5 0 8h-.6s-.4-4.2 0-8h-2.6s-.4 4.6-.1 8h-.5s-.4-4 0-8h-2.5s-.5 4.1 0 8h-.7s-.5-4.1 0-8h-2.9s-.6 3.9 0 8h-.5s-.7-3.9 0-8h-2.9s-.6 4.4 0 8h-.6s-.4-4.2.1-8h-2.7s-.7 3.7 0 8h-.7s-.4-3.2.2-8zm23.7 11.1s-.5-11 1.5-13.8c2.1-2.8 2.6-2.3 6.2 0s8.2 4.8 9 5c.7.3 1.8.6 1.8 2.6s.3 2.6-2.8 0c-.4-.3-1.9-1.6-3-2-2.7-.8.7.6 1.7 2.1.8 1.2 1.6 1-.6 1.6a233.6 233.6 0 0 0-13.8 4.5"/>
|
||||
<path d="M368 298.3s-1.7-2 .5-2.4c2-.5 2.2 3.2 2.6 5.4.3 2.3-2.6-2.2-3-2.9zm-3 9.7s-2.4.9-.8 1.6c1.6.8 6-2.9 4.5-2.6-1.6.2-3.7 1-3.7 1m3.3-3.4s2.1-.2 1.6.7c-.3 1-1.1.4-1.4.1-.4-.1-1.8-.7-.2-.8"/>
|
||||
<path fill="#ffc221" d="M379.7 301.7s.5 4.2 4.1 5.3c0 0 2.2.5 1.7-1.3l-.6-2.2c-.3-.7-1.8-1.1-2-1.2-.1 0-.3-.5.7-.1 1 .3 1.1.4 1-.4 0-.7-.6-.4-1.5-.8-.3-.2 0-.5.4-.4.4 0 1.3.4 1.3-1 0 0 .2-.9-.9-.9-1.2 0-1.1-.6-.8-.7.4 0 1.6.8 2-.7.4-1.4-1.7-.5-1.4-1.3.3-.8 1.8.4 1.8-.5.2-.8 1.4-1.2-.7-1.5-.9-.1 0-.6 1-.4 1.2.1 1.9-1.2 2.5-1.7.8-.5 4.5-2.7-.6-2-5 .7-6.5 3.2-6.7 3.7a13.9 13.9 0 0 0-1.3 8.1"/>
|
||||
<path fill="#ffc221" d="M391.4 305c.7-.1 1.3 0 1.5.4.8 1.7-1 1.2-2.1 2.4s-1.2 1-2.6.6c-1.5-.5-2-2.7-2-2.7-.2-.8.4-.8 1.1-.7 0 0 2.5.3 4.1 0m-5.4-.9s0 .4 1 .5c.7 0 3.2.3 4.8-.2 0 0 .4 0 .2-1 0 0 0-.7-1.3-.4-1.4.2-3.3 0-4-.1-.6-.2-1 0-.7 1.1zm-.2-3s0 1.2 1.2 1.4h3.7c.6 0 1.5-.3 1.6-1 0-.9.3-1.4-1.4-1s-3.6.1-3.8 0c-.2 0-1.3-.3-1.3.5zm.6-2.8s-.4.7-.3 1.2c0 .6 1 .9 2.7.9 1.7 0 3.3-.3 3.4-.9.1-.6.5-1.3-.8-1a12 12 0 0 1-3.8 0c-.7-.3-1.2-.4-1.2-.2"/>
|
||||
<path fill="#5a3719" d="M450.2 305.1s0 1.1.9 2.4l-48.2-1.4s.6-.4.8-2.3z"/>
|
||||
<path fill="#ffc221" d="M386.8 296.4s-.4 1.2.4 1.4c.8.3 2.3.5 4.3.2 0 0 1 0 1.3-1 .4-1 .3-.5-2.4-1 0 0-.9-.3 1.6-.3 0 0 1.5 0 1.6-.2.3-.2 2.2-1.8-.3-1.6-2.4 0-1.2-.5 0-.5 1.3 0 1.6.3 2 0s0-.2-.6-.9-.2-.5.3 0c.5.3.8.4 1.3 0 .6-.6-.4-1.3 0-1.2.4 0 .7.9 2.2.2 1.7-.9 3.8-.5 4.4 0 .5.4 2.2 1 3.2 0s-1.2-2-.3-2c1-.2 1.6.2 2-.6.4-.8-1.5-1.4.3-2 1.7-.4.2-5.3-.3-5.7 0 0-2 1.1-4.1 4.5-2.2 3.5-3.5 5.6-6.4 4.5-4-1.6-6.3.7-7 1-1 .7 2.2 1 .3 1-1.8 0-1.8.2-2 .5-.1.2 0 .5.4.6.3 0 1 .6-.1.6-1.2 0-2-.2-1.6 1 0 0 0 .2.6.3.7 0 1 .8-.3.8-.8 0-.8.3-.8.4m4.4 12s-.8.6.3.7c1.2 0 1.8.3 2.2-.3s2-.4 1-1.3c-1.2-.9-1.9-.4-3.5 1z"/>
|
||||
<path fill="#ffc221" d="M396.2 293.9s4-3.8 7.3-1.2l3.8 3s.4.3-.5 1.1c-1 .9 0 .9 1 .3 1-.5 1 0 1.5.6.5.5 1.2.8-.4.8H404s-2.2.2-1-.8c1-.9.8-2 .3-2-.6 0 0 .6-.4 1s-1.1.8-2 .8-1.4.6-.2 1c1.3.5-.1.8-.8.8s-3.7.2-.6.6c3.2.4-.3.3 2 1.6 2.7 1.5.8 4.6-.2 4.9 0 0-1 .6.3.4 1.4-.2 2.1-.3 1.1.4-.9.8-2.8 3.1-5.4 1.3 0 0-1.3-.5.9-.7 2.2 0-1.8-.6-2.5-1-.6-.4-3-3-1.5-2.7 1.6.3 1-.6 0-.9-.9-.3-1-1.7 0-1.5 1 .4 2.2 1 3.1.9.9 0 .6-.4-1.2-.9-1.8-.6-2.5-.7-2.1-2.2.4-1.5 2.4.6 2-.5-.5-1.2-2.3-.6-1.4-2s1.2-.9 1.6-.7c.4.1 1 0 0-.8-.9-.6 0-1.4.2-1.6"/>
|
||||
<path d="M399 295s0-.5.8-.4c.7 0 .5-.3.7-.5.2 0 2 .6.3 1.2-.6.2-1.6 0-1.7-.3z"/>
|
||||
<path fill="#ffc221" d="M403 299.2s-1.4.7-.2 2.1c1 1.3 1 1.7 1 2.5-.1.8 46.4 1.3 46.4 1.3s-.1-3 1.8-4.7z"/>
|
||||
<path fill="#5a3719" d="M450.9 304.3s.2-2.6 1.6-3.4c.8-.5 1.8-.2 2.2 1.8.7 2.9-1.8 5.4-2.9 4.3-1-1-.8-2.7-.8-2.7z"/>
|
||||
<path fill="#7b3c20" d="M397.6 315.1s3.1-2.7 3.6-3.8c0 0 8.4 6 7.9.5 0 0 0-1.5.2-2.9 0 0 3 .4 3.5-2l-7.8-.3s-.8-.2-2 1.1-3.7 2.7-6 1.5c0 0-1-.8-2-.1-1 .7-1 .8-.2 1.6s2.5 3.1 2.8 4.4m18-16.4-4.6-.1s-1.6-2.4-4.9-5c0 0-1-.4.8-1.9 2-1.5 2.5-3 2.5-3.8 0-.6 0-1.8.7-1 .6.9 5.3 5.2 6 4 .7-1.3.9-1.9.9-2.3.2-.4.3-1.6 1-.3.7 1.2 1.1.9 1.2 4 0 0 0 3.3.6 4.4 0 0-6-1.9-4.1 2zm-19.8-9.8s3.5 2 5.2-.6c1.6-2.6 2.8-3 1.5-5.7-1.3-2.4 0-3.6 1-4.7 1-1 1.9-.8 2-5 .1-4 3-5.3 4.2-6.6 1.3-1.2 4.4-3.1-.4-4-4.7-.7-14.3-3.2-16.7-6.9-2.5-3.7-3.6-1.5-3.6-1.4 0 .2-.8 2.9 1.6 7.8s4.5 8 7 9.6c2.3 1.7 4.4 2.5 3.2 5.8s-3.2 9.1-5 11.8z"/>
|
||||
<path fill="#5a3719" d="M408.7 278.5s.5 8.5 6.7 11.5c0 0 1.4-3.3.8-6.6 0 0 2 .2 2.6 1.1 0 0 0-2.4-2.8-3.3-3-.9-1.5-6.4-.5-7 1-.6.7-1.8 0-2.8-.7-1-.8-2.4 1.6-1.8 2.4.6 2-.6.5-1.8-1.4-1.2-1.3-2.7.7-2.7s5.5-2 3.4-2.6c-2-.6-2.7-1.3 0-2 2.9-1 4.3-1.9 2.2-2.1-2.2-.3-3.6-1-1.5-1.3 2-.4-.3-2.5-2.7-2.6-2.5-.1-7.5.8-3.5-2.4s-5.7-.8-1.7-3c4-2-1.4-1.2-2.2-1.2-.7 0-.7 0-.4-1 .3-1.1-.5-1.7-1.8-1-1.1.7-1.1.7-1-.8 0-1.5-1.4-.4-2.3 0-1 .4-3.1 2-4.1 1.2-.8-1-1.3-1.9-4-.3-2.9 1.7-2.2.3-2.1-.5 0-.8 1-3.6-2.7-.5s-.8-3.3-3.7-1.2c-3 2.2-3.3 2.6-3.8 1.7-.4-1-1-1.8-4.3.3-3.3 2-.8-1.3-.4-2 .5-.8 1.9-5.5-1-1.8 0 0-1.4 2.6-4.5-2 0 0-3.2 4.6-4.1 2.5-.9-2-1.7-2.2-2.8-.9-1 1.3-.3 0-.7-1.2-.5-1-.8-3.1-6.3.8-5.4 4.1 2 1.2-2.2 3s-14.3 7.5-5.1 6.2c9.3-1.3-4.5 3.6-1.3 4.5 3.2.9 2.2 3.7 14.3.4 12-3.3 10-.5 16.2-3.2 6.2-2.6-1.4.9 6.8.8 8.3-.3 1.4 0 3 1.6 1.7 1.7 8.6 5.7 15 6.5 6.6.6 8.2-1.8 6.4 1-2 2.8-2.6 3.9-3.7 5-1 .8-4.2 3.1-4.3 7 0 3.9-5 4.5-3.2 8.9z"/>
|
||||
<path fill="#5a3719" d="M419.4 288s-1.5-1.1-1.5-3c0 0 1 .2 1.5.8 0 0 3.7-4.2-.9-5.7-4.4-1.6-2.2-5.6-.6-5.6 1.5 0 1.8-.4.5-2.2-1.3-1.6-1.2-1.8 1.4-2.2 2.4-.4 2.2-1 1-1.6a8.2 8.2 0 0 1-2-1.6s7.2-3.1 4.9-4.6c-2.4-1.4 0-1 2-2.5 2.2-1.5 2.5-1.8 2.7-2.4 0 0-2 .2-3.6 0 0 0 1.8-1 0-2.5s-2.4-2.7-5.4-2c-2.8.6-1.8-.3-.8-1.5 1.2-1.2.7-1.9-1.4-2.2 0 0 .3-1.2 1.8-2.7 0 0-3.9.3-5.2-.4 0 0 1.6-1 1.6-2.4 0 0-2 .8-4.8.5 0 0 1.7-1.4 1.7-2.6 0 0-4.8 1-7 2.7 0 0-.4 0-.7-.6-.4-.4-.7-1-5.9.6 0 0 .6-2.3 1.9-3.3 1.2-.8 1-2.6-7 2.4 0 0-1.1-.6-2-3 0 0-1.8 2.4-3.1 3.2 0 0-1.2.5-1-1 .2-1.7-.8-.6-1.6 0-.8.3-1.4 1.5-1-1.7s-1.2-3.9-1.2-3.9-2.4 3.6-3.9 4c0 0-2.6-2.6-3.6-4.3-.9-1.6-.9-2.3-1.8.7-1 3-2 3.2-2 3.2s-1.7-1.4-1.9-2.2c0 0-.2.9-.8 1.2 0 0-1.4-1.7-1.3-4 0 0-8.7 4.8-9.8 7.7 0 0-8.2-.5-11.5.1 0 0 .8-2.6 3-4 0 0-2.2-.2-2.3-2.4 0 0 1.7.2 2.8 0 1-.3-1.5-3.4 1.2-3.5 2.7 0 4.4 1.3 3.3-2.3-1.3-3.6-.8-3.6-.8-3.6s4.8 2.8 5.5 2c.8-.6-.6-2 3.6-1.4 4.2.8 3-1.6 4.7-1.8 1.6 0 2.4 1 1.4-6.5s5.1 3.7 1-7.7c0 0-1-3.5-3.6-5 0 0-.6 2.5-3.3.4-2.9-2.3-8.4-3-6-4.8 2.3-1.8 3.4-4 2.7-5.5 0 0-2.8 2.8-7.5.8-3.8-1.7-4.6 1.3-8.4.5 0 0 0-1 3.2-3.6 3.3-2.6-1.8.8-3.8 1.3-2 .5-2.6 0 1.6-3.3 4.3-3.3 12.8-9 11.6-13.9 0 0 2 2.6 7.3.7 5.2-1.8 9.2-2.5 10.7-5.3 1.6-2.6 5.7-5.3 6.8-6 1.1-.6 2.5-1 .9 1.6-1.7 2.6-4.3 7-11.5 10-7.3 3-10 5.1-11.4 6.8-1.3 1.5-8 5-3.6 4.5 4.3-.7 11.7 0 8.2-1-3.4-1-7.4.6-4.2-2.3s3.8-3.8 8.4-5.8c4.7-2 9.8-6.4 9.3-1.6-.5 4.6-9.2 9.7-11.3 11.2-2.1 1.6-1.3 1.4-1.3 2 0 .6-.3 1.9-1.2 2.4-.9.6-.6 1.3-.4 2.6.3 1.4-.2 1.9.4 2.1.7.2 1.3.3 1.5 1.2.3 1 .7 1.1 2 1 1.2-.1 2 0 2 .8.2.7 1.4 1.7 1.5-.5.1-2.3 1-2.7-1.3-1.7-2.3 1-2.8.7-2.7-.4 0-1-.3-.8-1.2-.9-1 0-1.3-1.4.4-2.3 1.7-.9 1.7 0 3.8-1.8 2.1-1.8 2.1-2.2 2.5-3 .3-.9-3 2.4-4.7 3.1-1.6.8-1.1-.5-.8-2.3.2-1.8 4.2-4.3 6-4.3 1.9 0 6 1 4.3 3.6-1.8 2.5-6.9 5.5-4.8 5.7 2.2.2 2.6-.6 3.9.4 1.2 1.2 0 3.5-.5 4.7a9 9 0 0 1-2.4 3s-2.3-4.2-2.2-.9c0 3.3-.5 4.4 0 4.5.5.2 3 1.9 3.8 1.9s-4.4 2.4-2.2 2.6c2.2.2 5.8-1 7-3.3 0 0-4.6-1-6.3-2.7 0 0 5.1-1.2 3.7-6.2 0 0 5.2 1.4 3 3.8-2.3 2.3-3.7 2-1.7 2.6 2 .6 2.8 1.2 2.8 1.2s1.4.7.6 1.7c-.8 1.2-.8 2.8 0 2.7.5 0 2.7-1 1-2-2-1.2 2-1 .3-2-1.6-1-2.1-1.1-2.5-1.6-.5-.4 21-13 10.1-8.3 0 0 2.3-5 5.5-5 3 0 3.3 2.5 1.6 4.5-1.9 1.9-3 4.9-7.2 5.5 0 0 6 3-1.1 7.8 0 0-1.6.7-1 1.2.6.6 4.8-1.8 5.4-3.2a5.9 5.9 0 0 1 3.1-3.2c1.6-.8 9.6-6.2 12-10.3 2.4-4 3-4.1 7.6-7.9s3.9-3 4.5-3.9c.6-.9.8-2.4 3-3.6 2-1.2 10.4-5.8 13-7.7 2.6-1.9 8-5.4 10.2-8.3 2.3-2.9 8.5-6.6 10-6 1.6.8 0 3-3.7 5.7s-12.6 10-14.1 11.2a47.7 47.7 0 0 1-12 7c-2.8.4-2.5 1.4-4.2 3.3s-5.7 5.7-7 6.7c-1.3 1.2-4.5 3.3-4.7 4.9-.1 1.5.6 1.7-2 4a50.5 50.5 0 0 1-12.7 8.7s4.8 1.6 2 4.9c-2.9 3.2-2.7 2.7-2.9 3 0 0 7.2-1.2 2.2 4.5 0 0-1.1 1.7 1.2 0 2.4-2 1.4-4.4 1.1-4.7 0 0 3.9-2.5 8.4-2.5 4.4 0 4.2-.4.2-1.5 0 0 2.9-3.5 5.2-1.7 2.5 1.6 1.7 2.7-.8 4-2.6 1.4-6.3 1.8-9 3.4 0 0 5.2 1 8-1.1 2.8-2.2 3-1.1 3.3-.7.4.4.7 1-.5 2.9-1.3 1.8-1.4 1.8-1.3 2.2 0 .5-.2 1.7-2.7 2-2.4.5-3.7 1.6-2.8 2.8.8 1.2.8 4.1-1.3 3.9-2.1-.3-1.6-2-2.4-2.7-.8-.6-2-1.6-5.8.3-3.8 2-4-.4-3.9-1.7 0 0-2.4 2.2-4.5.3-2-2-.2-2.8 1-3.8s6-3.1 3.1-2.7c-3 .3-7.2.5-8.1-1.6-1.1-2.3 2-2 2.6-1.8.5.1 2.4 1.8 2.6-.3 0-2.3 3.2-2.5 2.1-3-1-.3-2.6 1-3 1.6 0 0-2.2-3.1-5.8-2.2-3.6 1.1 1.1.7 2 .9 1 .1.4 2-2.8 4.9-3.2 2.8-1.8 2 .6 2 2.5 0 8.3 0 5 2.7-3.5 2.9-4.9 4.2-6.6 3.8-1.8-.5.1-1.7 1-2.3.8-.4 1.2-1.3-.5-.6-1.7.6-2.2.7-3.6-1.6-1.3-2.5-.8-1.7-.2-3.3.6-1.6 1.9-3.2.3-2.6-1.6.6-1.4.6-1.3-1.2.1-1.8-1.8-2.2-1.8-2.2s.9 1.8.1 3c-.6 1.1-.7 1.5.4 1.8 1.2.3 2.2 1.3.7 2.3-1.6 1-1.3.9-.4 1.5 1 .7 2.4 1.4.9 2.9-1.5 1.4-.3 1 .5 1s2.4.6 2.4 2c0 1.5 0 1.8 2.4.5 2.4-1.4 7.2-1.2 7.2.6-.1 2-.7 2.5 2 .8 2.5-1.6 3.6 1.5 5.4 0 1.7-1.5 2.9-3 5-.4 2 2.6 1.3 3.3-1.2 5.2-2.4 1.8 1.3.4 3.1-.6s7.2-1.6 10.3-.2c3 1.3 3.9 1 6 0 2.4-.8 3.5-1 6.9 1.2a13 13 0 0 0 7.8 2.6s-3.7 1.5-8 1.7c-4.2.4-6.4 1-7.2 1.7 0 0 2.5 1.7 3 3.5 0 0 2.8-.4 4.1.1 0 0-.6 2 1 3.1 1.8 1.1 3 1.5 1.7 3s2 .8.1 2.8c-1.8 2.2-2.3 3.1-2.4 4.9 0 1.6.4 1.8-1.2 2-1.6.2.3 2-.5 4.3-.7 2-5.3 1.8-5.1 7.7 0 0 1.3-2.8 4-5.4 2.7-2.4 2.8-2.7 2.7-4.2 0-1.5-.1-1.2 1.4-2.3 1.3-1.2-.7-2.2.8-3.9 1.4-1.6.1-1.3 1.9-2.9 1.6-1.6-1.6-1.8.2-3.5 1.6-1.6-4.3-3.7-2.5-4.7 1.7-1.1 4.6-2.7-5.3-2.5 0 0 2.4-4 10.7-3.1 0 0-2 1.6-2.3 3.1l1.6.7s-.4 1.1-2 2.4c0 0 4.5 2.5 5.2 4 0 0-2.7.8-3.4 2 0 0 1.2 1.4 1.6 3 0 0-3-.3-3.4 2-.4 2.2-1.3.7-1.3 2s0 1.9-1 2.1c-1 .2-.2 1.3 0 2 .1.8.5 2.5.4 3 0 0-1.6 0-2.3.2 0 0 .5 3.3-1.3 3.7-1.8.5 1 1.2-1 1.5-1.9.3-1.6.5-4 4.7 0 0 2-1.1 4-2.5 2.2-1.4 0-1 3.4-4.3 3.4-3.5 2.8-3.7 2.5-5.4-.3-1.7-.3-3.1.9-4.8 1.3-1.6 1.6-3.5 6.1-3.2 0 0-1.3-3-3-3.7 0 0 2.2-1.5 4.4-1.6 0 0-2-2.5-6-4.7 0 0 3.3-2.9 4.1-4.2 0 0-1.5.3-2.8 0 0 0 .6-1.3 3.6-3.3 0 0 1.7 1.5 1.5 3.1 0 0 5.2-2.9 8-2.5 0 0 1.5 3.6-5.6 10.5 0 0 4.4.4 6.3.2 0 0-1 3.3-6.4 5.3-5.3 2 1.1 4.4-4.3 4-5.4-.4-3.8 1.4-3.6 4.1.1 2.8.3 5.7.2 6.4 0 0-4.3-1.4-4.2 2.8 0 4.2-2.3 5.1-2.7 5.5 0 0-1.3-1-3.2-1.8 0 0-2.7 5.1-7 8.1z"/>
|
||||
<path fill="#7b3c20" d="M413.2 235.2s1.4-.2 4 1.4c2.4 1.6 4.9-1.7 2.2-2.5-2.9-.8 0-1.9 2.4.2 2.6 2 3.6 1 4.5.3 1-.7 2-1.2.3-2.3-1.7-1.2 1.2-.6 2.5.3 1.3.8.8 1.6.6 1.8-.2.3-.3 3 2.2.5 2.6-2.6 3.9-5.1 3.8-6.4 0 0 1.4.9 1.6 2.5.2 1.6 2.2-.8 2.8-1.7.7-.8 1.7-3.1 1.6-4.8 0 0 1.7 2.7 4.3 0 2.5-2.6 1.5-1 4.5-1.8a18.8 18.8 0 0 0 9-5.5c2.1-2.6 2.3-.8 5-1.5s8.1-4.4 8.6-6.5c.5-2 .4-3.3-.3-2.5-.8.6-.5 0-1.6-.6-1.3-.8-3 .9-3 .9s1.7 1.3.4 1.9c-1.3.5-2.4 2.4-5 1.6-2.4-.7-5 2.4-5 2.4s2.2 1.7-.8 2.8c-2.8 1.2-2.4 1.5-4.1.3 0 0-3.1 3.9-5 4.7 0 0-.7 0-1.2-.8 0 0-2 2.3-3 2.7 0 0-1.4-1.2-2.5-1.6 0 0-2.5 3-4.5 4 0 0-.6-1.2-1.9-2 0 0-.6 4-4.9 6.3 0 0 .3-1-1.9-2.5 0 0-5.3 4.6-7.3 5.1-2.1.4-.3-1 0-1.6.3-.6 1.6-2.5-1-3.3s-2 .6-2.6.8c-.6.3-.6-.5-2.4-.2-1.6.2-1.4 1-2.1 1.3-.8.2-3.8-.5-3.6 1.4.2 2 1.6 3.4-1 4.5-2.7 1.2 1 1 4.4.4"/>
|
||||
<path fill="#5a3719" d="M423.9 229.7s.6-2.6-1.6-3.8c0 0 14-2.2 3.4-7.6 0 0 12.7-2.5 9.7-6.5s-6-3.3-6.3-3.1c-.4 0 2.7-2.4 3.5-2 .8.3 11 4.1 8.3.8-2.5-3.3-2.3-3.1-2.7-4.1 0 0 3.3 0 8.4 4.9 0 0 1-1 1-3.1 0 0 3.5 1 4.7 2.1 0 0 .6-1.2.3-2 0 0 3.2 1.7 4.3 3.5 0 0 1.4-1.2 1.6-2.7 0 0 3.1 1.3 4 2.3 0 0 1-1.4.6-3.3 0 0 5.1 1.5 5.9-1.6 0 0 5.1 1 1.8 3-4.4 2.7-.5-.6-5 2.5-3.4 2.5-5.3 5.3-7 4.7-1.2-.5-2.7 3-4.3 1.4-1.6-1.8-1.6-1-2.9.8a26.1 26.1 0 0 1-3 3.7s-.8-.5-1.6-1.3c0 0-1 1.7-2.2 3 0 0-1-1.4-2.7-2.2 0 0-2.6 3-4.1 4 0 0-1.6-1.5-3.1-2 0 0-.3 4-3.4 6 0 0-.6-1.2-2.7-2 0 0-1.5 2.4-4.9 4.5z"/>
|
||||
<path fill="#5a3719" d="M416.5 223.5s-1.7 1.3-.6 2.7c1.1 1.4 1.2-.3 2.6-.3 1.4-.2 18.6-3.2 3-7.8 0 0 .6-.7 3.3-.8 2.7-.4 12.5-3 8-6.5-4.6-3.4-8.5 1.2-4.6-3 3-3.3.6-4.9.6-4.9l-11 7.2c-2 1-5 3.3-1.5 4.3 3.4 1 5.7-3.7 6-2.6.3 1-6.9 5-5.8 6.9 1 1.8.8 3.4 2.5 3 1.8-.3 6.6.9 2.7.8-3.9-.2-5.2 1-5.2 1"/>
|
||||
<path d="M422.7 214.8s-1.6 1.2.5.6 6.3-1.5 5.6-2.5c-.9-1.2-3.6.1-6 2z"/>
|
||||
<path fill="#7b3c20" d="M450.4 196.9s8-.3 11.3 2c3.3 2.2 4.9 3.7 5.9 4 0 0-.2 3-5.3.8 0 0 .3 1.5-.3 3 0 0-1.9-1.4-4-1.8 0 0-.5 1.1-1.1 1.8 0 0-2.3-2.3-5-3l-.8 1.6s-2.8-1.7-5-1.7c0 0 .5 1.7 0 2.5 0 0-5.7-4.7-10.9-4.1 0 0 2.5 3.7 4.1 5.5 0 0-10.5-.8-8.7-6.5 1.7-5.6-.1-4.2 6.7-4.1z"/>
|
||||
<path fill="#5a3719" d="M401.3 212.5s-1.2 1 0 1.8 5.3-2.2 5.9-2.6c.6-.5 2-.4 0 1.2s-4 3.2-5.5 4.9c0 0 7-2 11.5-5.8 4.5-4-.2-1.4 7.5-5.1 7.7-3.8 11.7-9.7 7.6-9-4.1.6-7.9 5.3-11.1 7.1-3.2 1.8-5 2-4.5 1s2.8-.7 7.3-4.2c4.4-3.6 3.4-3.2 3.4-4.5 0-1.3-1.6-4.4 5-7.9 6.7-3.5 27.4-15.5 29.2-19.8 0 0-6 .7-14 6.6a74.3 74.3 0 0 1-14.4 9.4c-2.2.8-2 .2-3.3 2a183.2 183.2 0 0 1-10.8 10.4c-1.4 1-1.9 1.8-2 4 0 1.1-9 8-11.7 10.5z"/>
|
||||
<path fill="#5a3719" d="M428.7 196.7s-1.7.6-3.1 0c-1.5-.8-1-4 2.6-5.9a53.3 53.3 0 0 1 13.4-5.1s-.5 4-10.8 7.6c0 0 .7 2-2.1 3.4"/>
|
||||
<path fill="#aa5323" d="M432.3 194s.3 1 0 1.9c0 0 19 1.8 29-9.8 0 0-13.6 1.3-19 4.5 0 0 3.5-4.2 13.6-7.7s14.3-8 15.2-10.3c0 0-13 4.5-19 4.5 0 0-1.2 0-2.4.6-1.2.7-9.3 6.6-11.5 7.6 0 0 4.6-.4 6.2-1.8 0 0-3.2 8.5-12.1 10.5"/>
|
||||
<path fill="#5a3719" d="M395.7 204.9s-2.5 1.8-1.5 2.6c1 .9 2.8 1 6.5-2 3.8-3.3 12.9-11 7.1-11.3 0 0-7.3-.5-7 4 .2 4.5-4.8 6.4-5.1 6.7m-17-2.7s4.9 2.9 3 5.2c0 0 14.9-12.6 10.6-15.4-4-2.7-7.3 2.5-6.3 3s3.1-.4 2.4.5a82.5 82.5 0 0 1-9.7 6.7m-3.9-4s3.3 1 3.5 2.4c0 1.3 9.8-6.9 7.2-10.4-1.2-1.5-6.5-2.2-6.8 1-.3 3 4.9-.4 3 1.8-2.2 3-6.1 4.7-6.9 5.2m34.8-6.9s-2 1.5-.1 2.4c1.9.9 3-.5 4-1.3.9-.9 5.5-4.3 6.5-6.4 1.2-2.2 2.7-2.9 4.4-4 1.7-1 13.5-7 20.8-13.5 7.3-6.5 4.2-4.8 11.7-9s12.5-8 14.1-12.6c0 0-3.5 1.2-6.5 3.2-3.1 2-10 6.3-11.5 7-1.4.5-3.2.6-4.3 1.7-1 1.1-1 2.4-4.6 5.4-3.7 3-22.4 16.2-24.7 18.1a357 357 0 0 0-9.8 9"/>
|
||||
<path fill="#aa5323" d="M394.6 195.7s2-1.1 5.9-.8c3.8.2 19-14.6 23.4-17.5a364.4 364.4 0 0 0 20-14.7c2-1.9 2.3-3.8 3.9-4.9 1.6-1 3.1-.9 6.8-3 3.7-2.1 21.6-12.8 20.6-19 0 0-26.7 15.9-32.8 21.1a400 400 0 0 1-26.3 18.9c-3 2-5.4 5.2-10.5 9.4-5.1 4-10.2 7.6-11 10.5"/>
|
||||
<path fill="#aa5323" d="M389 190s4.9-.5 5.6 2c0 0 10.5-7.3 13-10.3 2.4-3-.9-1.3 5.1-5.2a634 634 0 0 0 28.9-20.4c2.8-2.5 8.3-5.8 12.6-8.8 4.4-2.9 21.3-11.2 19.4-18l-15.2 10.1c-2.8 1.9-4 .8-6.8 3-3 2.3-9.2 6.7-10.3 8.2a172 172 0 0 1-15.3 11.8c-4.7 3-15 9.1-20.1 13.5a643 643 0 0 1-17 14z"/>
|
||||
<path fill="#aa5323" d="M373.7 188.6s2.4 0 3.3 1c0 0 4.5-4.1 9.4 0 0 0 18-12.3 19.8-15.3 1.8-3 4.8-3.1 11.7-8.2 7-5.1 11.4-7.3 16-10.8 4.7-3.7 8.7-7.8 12-10 3.3-2.1 11.8-7.7 10.4-12.4 0 0-6.9 3.8-11.3 8.7-4.5 5-4 .8-8.6 4.8A88.2 88.2 0 0 1 419 159c-5.8 2.9-2.3 2.6-6.6 5.3-4.1 2.7-3.8 2.3-5.4 2.7a10.6 10.6 0 0 0-5.4 3.2 53.6 53.6 0 0 1-10.3 6.9 113.6 113.6 0 0 0-17.7 11.6z"/>
|
||||
<path fill="#aa5323" d="M379 179.3s-.9-2 .8-3.5c1.7-1.4 5-5.2 5.4-7.5.5-2.5.1-2 5-4.1a200.7 200.7 0 0 0 40.8-23c2-1.6 7-5 8.9-6.6 0 0 .9 2.6-1.3 4.5a237 237 0 0 1-23 15.8c-2.3 1.3-8 4.2-10.1 6-2 1.6-1.7 2.1-10.9 6.6-9.1 4.4-9.5 5-9.3 5.3.3.4 4.4-1.4 6.4-2.6 2-1.1 9.4-4.6 11.7-6.4 2.2-1.8 6-4.5 7.6-5.4 1.6-.8 15-9 19-11.9 3.8-2.8 4.9-3.7 5.7-3.2.8.4 2.2.4.5 2s-7.2 6.6-9.3 8c-2.1 1.3-8.7 5.2-10.5 6.1-1.8 1-2.5 2.7-3.6 3.5-1 .8-4 2.8-7.6 3.6s-4.2 3.6-6.6 5.2c-2.4 1.5-19.3 10.6-19.8 11 0 0 1-1 .3-3.4z"/>
|
||||
<path fill="#aa5323" d="M437.5 141.3s-.9.8-.4 1.3c.6.7 3 2.3 6-.6a113.6 113.6 0 0 1 13.4-10.8c2.4-1.5 3.7-2.9 3.6-5 0 0-12.1 6.5-22.6 15.1m16.7-1s1.8-3.2 6.4-6.1c4.6-3 11.5-7.2 12.3-8.2 0 0 1.6 1.8-1.9 4.1a332.5 332.5 0 0 0-11.4 7.5 15 15 0 0 1-5.4 2.6z"/>
|
||||
<path fill="#7b3c20" d="M361.4 174s-5 2.7-3.2 4.4c1.8 1.6 4.5 1.1 5.7.6 1-.4 3.2-1 3.5-1 .3-.1 4.7-1.4 5.9-3.5 1-2 4-4.4 6.4-6 2.4-1.7 3.3-3.5 2.9-4.7 0 0-20 9.4-21.2 10.2m-30.2 23.4s3.6-2.1 8.5-.8c0 0-.2-1.1-1-1.8 0 0 6-1.5 7.3-4.2 1.3-2.7 1.6-2 2.7-2.8 1.2-.8 9.2-7.3 8.3-8.7-1-1.4-1.1-3.2-1.8-3.9 0 0-1.6 2.3-9.5 6-7.8 3.9-16.5 6.7-22.9 15.2-6.3 8.6-5.6 13.5 2.1 15.7 0 0 5.4-3.4 18.7-2.3 13.3 1.3 17.8 6.2 18.7 7.1.8 1 3.5 4.2.9 9.8 0 0 2.7 1.1 2.8-1.4.3-2.6.4-2 1-1.7.7.5 1.4.5 1-1.4-.2-2-1-6.3-2.4-7.8-1.1-1.5.3-.8 1-.6.8.4 3.6 2.7 2-1.6-1.7-4-2.2-2-2.3-1.9 0 .3-.3 1.3-4-1.4a28.6 28.6 0 0 0-9.4-4.8c-2.4-.6-.7-.6.8-1.1 1.5-.6 3.3-.8 4-2.5 0 0-1.4.4-4-.6a14 14 0 0 0-12.3 2.2s1.3-5-2.6-4.7c-4 .3-6.6.2-10.8 3.5 0 0-.2-5 3.7-7.6 4-2.6 3.4-1 5.6-1.7 2.2-.6 2.4-2.8 1.4-3.6 0 0 5.2 1 13.8-6.2 0 0-4.7 6-10.3 7.3 0 0-.9 3.3-6 4-5 .6-4.9 3.6-4.9 4.3z"/>
|
||||
<path fill="#5a3719" d="M316.6 227s2.4-15.1 16.6-16.2c12.2-1 16.2.6 18.6 1.4s8.5 2.5 6.1 4.5c-2.3 1.9-3.6 1.6-3.6 1.6s2.6-3 .2-3.6c-2.5-.4-2.6 1-3 2.2-.4 1.4-.4 2.8-1.7 3.8 0 0-1.2-1.4-3-.2s-.2 1.3.5 1.1c.7-.2 1.6-.6 1.4.6-.2 1-1.1 3-4 4.5-3 1.4-2.9 1.4-6.3 2-3.5.5-6.7 1.9-11.1 5.6-4.5 3.9-9.3 2.6-10.3-1.6-.8-3.7-.4-5.7-.4-5.7"/>
|
||||
<path d="M332.7 226.4s1.3-3-1-4.3c0 0-7.4 1.3-9.7-.9 0 0 8-.5 13-2.5 5-1.8 3.6-3.2 1.9-3.6-1.8-.3-5 .5-5.3 2 0 0-1-1.5.3-2.7 1.3-1.1 3.2-1.3 5-.8 1.8.5 3.3 1.3 9.1-1.7 0 0 3.4.8 3.5 3 0 2.3-.2 3.2-.6 3.5-.2.4-.6 1-1.3 1-.7 0-1.7-.2-2.5 1.3-.8 1.6-1.4 3-2.9 4 0 0 1.7-4.9-2.6-6 0 0-3.5 2-6.2 2.2 0 0 3.5 3.2-.8 5.5z"/>
|
||||
<path fill="#5a3719" d="M340.4 217.4s-1.6-1.7.5-2c2.2 0 5 1.5 4.4 2.9-.5 1.3-3 1.2-4.9-.9"/>
|
||||
<path fill="#fff" d="M461.4 193.7s-4 1.1-.3 3.6c3.6 2.4 5.4 4.5 8 5.1 2.7.7 5.4 1.6 5.4 4.3 0 2.6-.6 3.7-2 5.5-1.5 1.9.8 2.6 2.7 1.6l4.8-2.3c1.2-.8 3.3-.7 1.5.3-2 1-4 1.6-1.5 1.6 2.4.1 17.3.4 20.4-.6 3.1-1 7.2-1.3 7.5-5.3 0 0 .2-1.8 1.3-2.6 1.2-.8 2-2.5.3-1.4-1.7 1.3-3 1.8-3.3 1.5-.3-.4-.5-.6.8-1.2 1.2-.6 1.9 0 3-1.6 1.2-1.8 1.1-1.5.5-2.2-.6-.6-2-1-1.3-1.8.7-.9 1.3-3.3-1.4-2-2.7 1.5-8.1 5.2-10.7 5.8-2.4.6-4.3 1.3-7.6 2a30 30 0 0 0-9 3.4c-3.6 2-3.3-1.2-2.7-1.6 0 0 1.4 2.5 5-.7 3.6-3.1 2.4-.2 11.3-3.1 8.8-2.9 6.7-3.4 10.1-5.1 3.6-1.8 6.8-2 4.4-4.4-2.5-2.4-2.7-2.6-5.8 0a36.8 36.8 0 0 1-17 7s20-8.6 18-9.8a22.1 22.1 0 0 0-5.7-2.7c-1.4-.4-1.8-.7-5 .8-3.2 1.4-3.7 1.7-4.6 1.8-1 0-3.7.6-7.5 2.6s-5.8 2.8-8.5 4.3c0 0 1.8-3.6 9.8-6 7.8-2.3 11.8-4.4 11-4.8s-2.9-.9-4.3-.5c-1.5.2-1-.2-6 1.8-4.7 1.8-2.7 1.4-6.5 2.3-4 .8-5.5 1.6-7.4 2.4 0 0 .8-1 3.4-2 1.4-.4-1.5-1 2.3-1.1h1a34.3 34.3 0 0 0 9.2-3.4c-.8-.2-5.4-.7-10.2 1.6-4.7 2.1-2.6 1.4-4.2 1.6-1.6.4-5.2 2.6-6.4 3.6-1.2 1-2.8 1.7-2.8 1.7"/>
|
||||
<path fill="#5a3719" d="M344.1 215.3s1.9.6 2.4 1.9c.5 1.3 1.6-.6 1.6-1.2 0-.5-1.2-3.2-3.2-2-2 1.2-1 1.2-.8 1.3"/>
|
||||
<path fill="#7b3c20" d="M339 241.1s3.9-1.8 7.3-1.6c0 0-1.4-4.7 1-4 2.3.9 1.6.6 2.1.6 0 0 .2-3.1-.5-4.4 0 0 2.5.6 4.9.6 0 0-2.3-4.4.2-7.5a7.4 7.4 0 0 0 4.5 3.7v-2.6s1.8-.2 3.3.5c1.4.8 2.6-8-1.7-10 0 0-1 1.7-5 2.5s-4 1.6-5.6 4.7-3 3-6.5 5.2a17.8 17.8 0 0 0-5.5 7s1.7 2 1.5 5.3"/>
|
||||
<path fill="#999" d="M472.5 189.7c1.4-.2-1.5-1 2.3-1.1h1a34.3 34.3 0 0 0 9.2-3.4c-.8-.2-5.4-.7-10.2 1.5s-2.6 1.5-4.2 1.8a22 22 0 0 0-6.4 3.5c-1.2 1-2.8 1.7-2.8 1.7s-4 1.1-.3 3.6c3.6 2.4 5.4 4.5 8 5.1 2.7.7 5.4 1.6 5.4 4.3a7.4 7.4 0 0 1-2 5.5c-1.5 1.9.8 2.6 2.7 1.6l4.8-2.3c1.2-.8 3.3-.7 1.4.3-2 1-3.9 1.6-1.4 1.6 2.4.1 17.3.4 20.4-.6 3.1-1 7.2-1.3 7.5-5.3 0 0 .2-1.8 1.3-2.6 1.2-.8 2-2.5.3-1.4-1.7 1.3-3 1.8-3.3 1.5-.3-.4-.5-.6.8-1.2 1.2-.6 1.9 0 3-1.6 1.2-1.8 1.1-1.5.5-2.2-.4-.3-.9-.5-1.1-.9 0 0-1-.8-2-.1a29.4 29.4 0 0 1-7.4 2.8c-1.8.2-3.8 1-7 2.6s-8.8 5-9.7 1.8l-2.8 1.2c-3.7 1.9-3.4-.8-2.7-1.6 0 0-2 2.2-1.9.3 0-2 1.3-1.7 3.5-2.3 2.1-.7 5.5-2 4.1-3.2-1.4-1.1-2.9 1.2-4.4 1.9-1.6.7-4.7 1.3-5.2-1-.5-2.2-.5-3.8-4.6-4-4.2-.2-4.1-2.9-3-4 1.3-1.1 2.2-3 6.2-3.8"/>
|
||||
<path d="M485.9 210s6.5-2.9 12.5-4.3 1.3.2.3.5-10.4 3.4-12.6 4.5c-2.2 1-1.8.1-.2-.5zm1.4 1.5s7.4-2.5 8.8-1.5c1.4 1 .2.6-1.4.8-1.6.2-6 1-7.2 1-1.2 0-.2-.3-.2-.3m11.9-2.6s1.4-.3 1.5.3c.1.5-.6.6-1.4.5-.8-.2-1.4-.6-.1-.8"/>
|
||||
<path fill="#fff" d="M305 273s-.3-6.5 3-9.8 19-19.7 21.4-24.3c0 0 2 1.4 2.1 4 0 0 2.7-4.5 4.8-6.3 0 0 1.9 2 1.6 5.8 0 0 3.8-2 9.7-2 0 0-2.3 2.4-2.3 4 0 0 8.1-1 12.5-.2 0 0-11.3 6.2-8.2 6.8 3.3.6 6.5 0 6.5 0s-3.6 3.6-9.3 4.3c0 0 7.3 0 8.8 1.6 0 0-7.1 1-12.8 5.4 0 0-.6-.3-.6-1.9 0 0-.2 1.5-1.8 2.9-1.7 1.3-5.5 4.2-7 5.7-1.4 1.4-4 4.3-7 4.2 0 0 .6-2.3-1.5-3a6 6 0 0 0-6.3 1.6s-7.6.2-10.1.5c0 0 1.7-2.7 3.3-2.7 1.6 0 8 1 8.6-3.4.5-4.3-4.1-3.2-2.4-5.7 1.8-2.6 1.4-2.5 1.5-2.8 0 0-1.5.8-2.3 3.1a11.4 11.4 0 0 1-4.5 6.4 16 16 0 0 0-5.2 5.3s-1.4.2-2.6.5z"/>
|
||||
<path fill="#fff" d="M312.3 269s.2-.7 2.4-1.2c2.3-.4 2.5-1.4 2.1-2-.3-.3-1.6-.3.5-2.9 0 0 .8.3 1.3.9.6.6 3 5.8-6.3 5.3z"/>
|
||||
<path fill="#999" d="M307 264.5c0 4.3 5.7 2.7 5.7 2.7a22.9 22.9 0 0 0-4.1 3.7c.4-2-3.2-2.6-3.2-2.6a13 13 0 0 1 1.6-3.8m20.6-23 1.8-2.6s2 1.4 2.1 4c0 0 2.7-4.5 4.8-6.3 0 0 1.9 2 1.6 5.8 0 0 3.8-2 9.7-2 0 0-2.3 2.4-2.3 4 0 0 8.1-1 12.5-.2 0 0-11.3 6.2-8.2 6.8 3.3.6 6.5 0 6.5 0s-3.6 3.6-9.3 4.3c0 0 7.3 0 8.8 1.6 0 0-2.2.3-5 1.3 0 0-2-2-8.1-1.6 0 0 4.7-2.7 8.5-3.6 0 0-1.6-2.1-4.3-.2 0 0-5.1-3.5-.8-6.5 0 0-3-.6-5 .8 0 0 0-2.5 2.2-3.5 0 0-5.7-1-7 3.2 0 0-1.2-1.7-.6-3.6 0 0-3.5 2-5.1 4.2 0 0-.6-4.3-2.8-5.9m-11 27.3c-1 .3-2.5.4-4.3.3 0 0 .1-.6 1.4-1 0 0 .3.7 2.8.7"/>
|
||||
<path d="M327 252.6s2.6 2 3.5 3.2c0 0 2.4-1.6 3.2-3 0 0 2 1.2 2.5 3 0 0 1.4-.8 1.6-2 0 0 3.3.6 4.4 1.6 0 0 .5-3 0-5.1 0 0 2.3.2 3.7.8 0 0-1.3-2.1 5.3-4.8 0 0-5 1.1-7 3.2 0 0-2 .2-3-.4v4.7s-1.2-.6-3.7-1.2c0 0-.6 1.1-1 1.4 0 0-1.7-1.4-2.3-2.9 0 0-2.5 2.2-3.3 3.1 0 0-2.4-1.6-4-1.6z"/>
|
||||
<path fill="#ffc221" d="M312 285.2s1 .5 3.4-1.5 9.3-6.3 9.9-9.8c.7-3.5-2.2-3.7-4.4-2.7-2.3 1-1.3 3-1.2 3.6 0 .6.2 3-3.5 6.4l-4.3 4z"/>
|
||||
<path fill="#ffc221" d="M311.2 286.2s-5.5-2.3-.6-4.7 7.1-3.1 7.7-5.2c.6-2 .3-1.7-1.6-.9-1.8.9-8.8 4.1-9.8 1.1 0 0 2.8 1.1 6.4-.6 3.6-1.8 6.6-2.3 4.3-3-2.3-.7-10.6.2-11.9.6-1.4.4-1 .3-1.3 1.6-.2 1.4-1.7 4.3-2.3 5-.5.9-2 4.4.6 6a9.2 9.2 0 0 0 8.5.1"/>
|
||||
<path d="M309 274.3s-1.2.2-1 .7c.3.4.6.4 1 .4.5 0 1.2-.3 1.3-.5 0-.4-.8-.8-1.2-.6z"/>
|
||||
<path fill="#fff" d="M310.8 285s-2.5-1.2.4-2.8c3-1.6 6-3.2 6.5-3.8 0 0-1.4 2-6.8 6.6z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 32 KiB |
6
public/assets/flags/1x1/at.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-at" viewBox="0 0 512 512">
|
||||
<g fill-rule="evenodd">
|
||||
<path fill="#fff" d="M512 512H0V0h512z"/>
|
||||
<path fill="#c8102e" d="M512 512H0V341.3h512zm0-341.2H0V.1h512z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 239 B |