mirror of
https://github.com/netbirdio/netbird.git
synced 2026-04-16 15:26:40 +00:00
Add proxy auth ui
This commit is contained in:
@@ -36,6 +36,25 @@ Certificate generation can either be via ACME (by default, using Let's Encrypt,
|
||||
When not using ACME, the proxy server attempts to load a certificate and key from the files `tls.crt` and `tls.key` in a specified certificate directory.
|
||||
When using ACME, the proxy server will store generated certificates in the specified certificate directory.
|
||||
|
||||
|
||||
## Auth UI
|
||||
|
||||
The authentication UI is a Vite + React application located in the `web/` directory. It is embedded into the Go binary at build time.
|
||||
|
||||
To build the UI:
|
||||
```bash
|
||||
cd web
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
For UI development with hot reload (served at http://localhost:3031):
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The built assets in `web/dist/` are embedded via `//go:embed` and served by the `web.ServeHTTP` handler.
|
||||
|
||||
## Configuration
|
||||
|
||||
NetBird Proxy deployment configuration is via flags or environment variables, with flags taking precedence over the environment.
|
||||
|
||||
@@ -3,9 +3,7 @@ package auth
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
_ "embed"
|
||||
"encoding/base64"
|
||||
"html/template"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
@@ -13,12 +11,10 @@ import (
|
||||
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/netbirdio/netbird/proxy/web"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
//go:embed auth.gohtml
|
||||
var authTemplate string
|
||||
|
||||
type Method string
|
||||
|
||||
var (
|
||||
@@ -91,7 +87,6 @@ func NewMiddleware() *Middleware {
|
||||
// In the event that no authentication schemes are defined for the domain,
|
||||
// then the request will also be simply passed through.
|
||||
func (mw *Middleware) Protect(next http.Handler) http.Handler {
|
||||
tmpl := template.Must(template.New("auth").Parse(authTemplate))
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
host, _, err := net.SplitHostPort(r.Host)
|
||||
if err != nil {
|
||||
@@ -141,13 +136,7 @@ func (mw *Middleware) Protect(next http.Handler) http.Handler {
|
||||
// if none of them intercept the request, then this handler will
|
||||
// be called and present the user with the authentication page.
|
||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := tmpl.Execute(w, struct {
|
||||
Methods map[string]string
|
||||
}{
|
||||
Methods: methods,
|
||||
}); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
}
|
||||
web.ServeHTTP(w, r, map[string]any{"methods": methods})
|
||||
}))
|
||||
|
||||
// No authentication succeeded. Apply the scheme handlers.
|
||||
|
||||
23
proxy/web/.gitignore
vendored
Normal file
23
proxy/web/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
BIN
proxy/web/dist/assets/Inter-Italic-VariableFont_opsz_wght-B-9PvMw6.ttf
vendored
Normal file
BIN
proxy/web/dist/assets/Inter-Italic-VariableFont_opsz_wght-B-9PvMw6.ttf
vendored
Normal file
Binary file not shown.
BIN
proxy/web/dist/assets/Inter-VariableFont_opsz_wght-c8O0ljhh.ttf
vendored
Normal file
BIN
proxy/web/dist/assets/Inter-VariableFont_opsz_wght-c8O0ljhh.ttf
vendored
Normal file
Binary file not shown.
BIN
proxy/web/dist/assets/favicon-Cv-2QvSV.ico
vendored
Normal file
BIN
proxy/web/dist/assets/favicon-Cv-2QvSV.ico
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
9
proxy/web/dist/assets/index-BQ7jeUNq.js
vendored
Normal file
9
proxy/web/dist/assets/index-BQ7jeUNq.js
vendored
Normal file
File diff suppressed because one or more lines are too long
19
proxy/web/dist/assets/netbird-full-4AdtrUIK.svg
vendored
Normal file
19
proxy/web/dist/assets/netbird-full-4AdtrUIK.svg
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
<svg width="133" height="23" viewBox="0 0 133 23" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_0_3)">
|
||||
<path d="M46.9438 7.5013C48.1229 8.64688 48.7082 10.3025 48.7082 12.4683V21.6663H46.1411V12.8362C46.1411 11.2809 45.7481 10.0851 44.9704 9.26566C44.1928 8.43783 43.1308 8.0281 41.7846 8.0281C40.4383 8.0281 39.3345 8.45455 38.5234 9.30747C37.7123 10.1604 37.3109 11.4063 37.3109 13.0369V21.6663H34.7188V6.06305H37.3109V8.28732C37.821 7.49294 38.5234 6.87416 39.4014 6.43934C40.2878 6.00452 41.2578 5.78711 42.3197 5.78711C44.2179 5.78711 45.7565 6.36408 46.9355 7.50966L46.9438 7.5013Z" fill="#F2F2F2"/>
|
||||
<path d="M67.1048 14.8344H54.6288C54.7208 16.373 55.2476 17.5771 56.2092 18.4384C57.1708 19.2997 58.3331 19.7345 59.6961 19.7345C60.8166 19.7345 61.7531 19.4753 62.4973 18.9485C63.2499 18.4301 63.7767 17.7277 64.0777 16.858H66.8706C66.4525 18.3548 65.6163 19.5756 64.3621 20.5205C63.1078 21.4571 61.5525 21.9337 59.6878 21.9337C58.2077 21.9337 56.8865 21.5992 55.7159 20.9386C54.5452 20.278 53.6337 19.3331 52.9648 18.1039C52.2958 16.8831 51.9697 15.4616 51.9697 13.8477C51.9697 12.2339 52.2958 10.8207 52.9397 9.60825C53.5836 8.39578 54.495 7.45924 55.6573 6.80702C56.828 6.15479 58.1659 5.82031 59.6878 5.82031C61.2096 5.82031 62.4806 6.14643 63.6178 6.79029C64.7551 7.43416 65.6331 8.32052 66.2518 9.44938C66.8706 10.5782 67.18 11.8576 67.18 13.2791C67.18 13.7725 67.1549 14.2909 67.0964 14.8428L67.1048 14.8344ZM63.8603 10.1769C63.4255 9.4661 62.8318 8.92258 62.0793 8.55465C61.3267 8.18673 60.4989 8.00277 59.5874 8.00277C58.2746 8.00277 57.1625 8.42086 56.2427 9.25705C55.3228 10.0932 54.796 11.2472 54.6623 12.7356H64.5126C64.5126 11.7489 64.2952 10.896 63.8603 10.1852V10.1769Z" fill="#F2F2F2"/>
|
||||
<path d="M73.7695 8.20355V17.4016C73.7695 18.1626 73.9284 18.6977 74.2545 19.0071C74.5806 19.3165 75.1409 19.4754 75.9352 19.4754H77.8418V21.6662H75.5088C74.0622 21.6662 72.9835 21.3317 72.2644 20.6711C71.5452 20.0105 71.1857 18.9151 71.1857 17.3933V8.19519H69.1621V6.0629H71.1857V2.13281H73.7779V6.0629H77.8501V8.19519H73.7779L73.7695 8.20355Z" fill="#F2F2F2"/>
|
||||
<path d="M85.9022 6.68902C86.9307 6.10369 88.093 5.80266 89.4058 5.80266C90.8106 5.80266 92.0732 6.13714 93.1937 6.79773C94.3142 7.46668 95.2006 8.39485 95.8444 9.59896C96.4883 10.8031 96.8144 12.2079 96.8144 13.7966C96.8144 15.3854 96.4883 16.7818 95.8444 18.011C95.2006 19.2486 94.3142 20.2018 93.1854 20.8875C92.0565 21.5732 90.7939 21.916 89.4141 21.916C88.0344 21.916 86.8805 21.6234 85.8687 21.0297C84.8569 20.4443 84.0876 19.6918 83.5775 18.7803V21.6568H80.9854V0.601562H83.5775V8.97182C84.1127 8.04365 84.8904 7.28272 85.9105 6.69738L85.9022 6.68902ZM93.4529 10.7362C92.9763 9.86654 92.3408 9.19759 91.5297 8.74605C90.7186 8.29451 89.8322 8.06037 88.8706 8.06037C87.909 8.06037 87.0394 8.29451 86.2366 8.75441C85.4255 9.22268 84.7817 9.89163 84.2967 10.778C83.8117 11.6643 83.5692 12.6845 83.5692 13.8384C83.5692 14.9924 83.8117 16.046 84.2967 16.9323C84.7817 17.8187 85.4255 18.4877 86.2366 18.9559C87.0394 19.4242 87.9174 19.65 88.8706 19.65C89.8239 19.65 90.727 19.4158 91.5297 18.9559C92.3324 18.4877 92.9763 17.8187 93.4529 16.9323C93.9296 16.046 94.1637 15.0091 94.1637 13.8134C94.1637 12.6176 93.9296 11.6142 93.4529 10.7362Z" fill="#F2F2F2"/>
|
||||
<path d="M100.318 3.01864C99.9749 2.67581 99.8076 2.25771 99.8076 1.76436C99.8076 1.27101 99.9749 0.852913 100.318 0.510076C100.661 0.167238 101.079 0 101.572 0C102.065 0 102.45 0.167238 102.784 0.510076C103.119 0.852913 103.286 1.27101 103.286 1.76436C103.286 2.25771 103.119 2.67581 102.784 3.01864C102.45 3.36148 102.049 3.52872 101.572 3.52872C101.095 3.52872 100.661 3.36148 100.318 3.01864ZM102.826 6.06237V21.6657H100.234V6.06237H102.826Z" fill="#F2F2F2"/>
|
||||
<path d="M111.773 6.52155C112.617 6.0282 113.646 5.77734 114.867 5.77734V8.45315H114.181C111.28 8.45315 109.825 10.0252 109.825 13.1776V21.6649H107.232V6.06165H109.825V8.5953C110.276 7.70058 110.928 7.00654 111.773 6.51319V6.52155Z" fill="#F2F2F2"/>
|
||||
<path d="M117.861 9.60732C118.505 8.40321 119.391 7.46668 120.52 6.80609C121.649 6.1455 122.92 5.81102 124.325 5.81102C125.537 5.81102 126.666 6.09533 127.711 6.64721C128.757 7.20746 129.551 7.94331 130.103 8.85475V0.601562H132.72V21.6735H130.103V18.7385C129.593 19.6667 128.832 20.436 127.828 21.0297C126.825 21.6317 125.646 21.9244 124.3 21.9244C122.953 21.9244 121.657 21.5816 120.528 20.8959C119.4 20.2102 118.513 19.257 117.869 18.0194C117.226 16.7818 116.899 15.377 116.899 13.805C116.899 12.233 117.226 10.8114 117.869 9.60732H117.861ZM129.392 10.7613C128.915 9.89163 128.28 9.22268 127.469 8.75441C126.658 8.28614 125.771 8.06037 124.81 8.06037C123.848 8.06037 122.962 8.28614 122.159 8.74605C121.356 9.20595 120.729 9.86654 120.253 10.7362C119.776 11.6058 119.542 12.6343 119.542 13.8134C119.542 14.9924 119.776 16.046 120.253 16.9323C120.729 17.8187 121.365 18.4877 122.159 18.9559C122.953 19.4242 123.84 19.65 124.81 19.65C125.78 19.65 126.666 19.4158 127.469 18.9559C128.272 18.4877 128.915 17.8187 129.392 16.9323C129.869 16.046 130.103 15.0175 130.103 13.8384C130.103 12.6594 129.869 11.6393 129.392 10.7613Z" fill="#F2F2F2"/>
|
||||
<path d="M21.4651 0.568359C17.8193 0.902835 16.0047 3.00167 15.3191 4.06363L4.66602 22.5183H17.5182L30.1949 0.568359H21.4651Z" fill="#F68330"/>
|
||||
<path d="M17.5265 22.5187L0 3.9302C0 3.9302 19.8177 -1.39633 21.7493 15.2188L17.5265 22.5187Z" fill="#F68330"/>
|
||||
<path d="M14.9255 4.75055L9.54883 14.0657L17.5177 22.5196L21.7405 15.2029C21.0715 9.49174 18.287 6.37276 14.9255 4.74219" fill="#F35E32"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_0_3">
|
||||
<rect width="132.72" height="22.5186" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.5 KiB |
1
proxy/web/dist/assets/style-B08XFatU.css
vendored
Normal file
1
proxy/web/dist/assets/style-B08XFatU.css
vendored
Normal file
File diff suppressed because one or more lines are too long
19
proxy/web/dist/index.html
vendored
Normal file
19
proxy/web/dist/index.html
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/x-icon" href="/assets/favicon-Cv-2QvSV.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Authentication Required</title>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<script type="module" crossorigin src="/assets/index-BQ7jeUNq.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/style-B08XFatU.css">
|
||||
</head>
|
||||
<body>
|
||||
<!-- Go template variables injected here -->
|
||||
<script>
|
||||
window.__DATA__ = {{ .Data }};
|
||||
</script>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
2
proxy/web/dist/robots.txt
vendored
Normal file
2
proxy/web/dist/robots.txt
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
23
proxy/web/eslint.config.js
Normal file
23
proxy/web/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
18
proxy/web/index.html
Normal file
18
proxy/web/index.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/x-icon" href="/src/assets/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Authentication Required</title>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- Go template variables injected here -->
|
||||
<script>
|
||||
window.__DATA__ = {{ .Data }};
|
||||
</script>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3939
proxy/web/package-lock.json
generated
Normal file
3939
proxy/web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
proxy/web/package.json
Normal file
36
proxy/web/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"tailwind-merge": "^2.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
206
proxy/web/src/App.tsx
Normal file
206
proxy/web/src/App.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { useState, useRef } from "react";
|
||||
import {Loader2, Lock, Binary, LogIn} from "lucide-react";
|
||||
import { getData, type Data } from "@/data";
|
||||
import Button from "@/components/Button";
|
||||
import { Input } from "@/components/Input";
|
||||
import PinCodeInput, { type PinCodeInputRef } from "@/components/PinCodeInput";
|
||||
import { SegmentedTabs } from "@/components/SegmentedTabs";
|
||||
import { PoweredByNetBird } from "@/components/PoweredByNetBird";
|
||||
import { Card } from "@/components/Card";
|
||||
import { Title } from "@/components/Title";
|
||||
import { Description } from "@/components/Description";
|
||||
import { Separator } from "@/components/Separator";
|
||||
import { ErrorMessage } from "@/components/ErrorMessage";
|
||||
import { Label } from "@/components/Label";
|
||||
|
||||
const data = getData();
|
||||
|
||||
// For testing, show all methods if none are configured
|
||||
const methods: NonNullable<Data["methods"]> =
|
||||
data.methods && Object.keys(data.methods).length > 0
|
||||
? data.methods
|
||||
: { password:"password", pin: "pin", oidc: "/auth/oidc" };
|
||||
|
||||
function App() {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState<string | null>(null);
|
||||
const [pin, setPin] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const passwordRef = useRef<HTMLInputElement>(null);
|
||||
const pinRef = useRef<PinCodeInputRef>(null);
|
||||
const [activeTab, setActiveTab] = useState<"password" | "pin">(
|
||||
methods.password ? "password" : "pin"
|
||||
);
|
||||
|
||||
const handleAuthError = (method: "password" | "pin", message: string) => {
|
||||
setError(message);
|
||||
setSubmitting(null);
|
||||
if (method === "password") {
|
||||
setPassword("");
|
||||
setTimeout(() => passwordRef.current?.focus(), 200);
|
||||
} else {
|
||||
setPin("");
|
||||
setTimeout(() => pinRef.current?.focus(), 200);
|
||||
}
|
||||
};
|
||||
|
||||
const submitCredentials = (method: "password" | "pin", value: string) => {
|
||||
setError(null);
|
||||
setSubmitting(method);
|
||||
|
||||
const formData = new FormData();
|
||||
if (method === "password") {
|
||||
formData.append(methods.password!, value);
|
||||
} else {
|
||||
formData.append(methods.pin!, value);
|
||||
}
|
||||
|
||||
fetch(window.location.href, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
redirect: "follow",
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.ok || res.redirected) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
handleAuthError(method, "Authentication failed. Please try again.");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
handleAuthError(method, "An error occurred. Please try again.");
|
||||
});
|
||||
};
|
||||
|
||||
const handlePinChange = (value: string) => {
|
||||
setPin(value);
|
||||
if (value.length === 6) {
|
||||
submitCredentials("pin", value);
|
||||
}
|
||||
};
|
||||
|
||||
const isPinComplete = pin.length === 6;
|
||||
const isPasswordEntered = password.length > 0;
|
||||
const isButtonDisabled = submitting !== null ||
|
||||
(activeTab === "password" && !isPasswordEntered) ||
|
||||
(activeTab === "pin" && !isPinComplete);
|
||||
|
||||
const hasCredentialAuth = methods.password || methods.pin;
|
||||
const hasBothCredentials = methods.password && methods.pin;
|
||||
|
||||
return (
|
||||
<main className="mt-20">
|
||||
<Card className="max-w-105 mx-auto">
|
||||
<Title>Authentication Required</Title>
|
||||
<Description>
|
||||
The service you are trying to access is protected. Please authenticate to continue.
|
||||
</Description>
|
||||
|
||||
<div className="flex flex-col gap-4 mt-7 z-10 relative">
|
||||
{error && <ErrorMessage error={error} />}
|
||||
|
||||
{/* SSO Button */}
|
||||
{methods.oidc && (
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={() => (window.location.href = methods.oidc!)}
|
||||
>
|
||||
<LogIn size={16} />
|
||||
Sign in with SSO
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Separator */}
|
||||
{methods.oidc && hasCredentialAuth && <Separator />}
|
||||
|
||||
{/* Credential Authentication */}
|
||||
{hasCredentialAuth && (
|
||||
<form onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submitCredentials(activeTab, activeTab === "password" ? password : pin);
|
||||
}}>
|
||||
{hasBothCredentials && (
|
||||
<SegmentedTabs
|
||||
value={activeTab}
|
||||
onChange={(v) => {
|
||||
setActiveTab(v as "password" | "pin");
|
||||
setTimeout(() => {
|
||||
if (v === "password") {
|
||||
passwordRef.current?.focus();
|
||||
} else {
|
||||
pinRef.current?.focus();
|
||||
}
|
||||
}, 0);
|
||||
}}
|
||||
>
|
||||
<SegmentedTabs.List className="rounded-lg border mb-4">
|
||||
<SegmentedTabs.Trigger value="password">
|
||||
<Lock size={14} />
|
||||
Password
|
||||
</SegmentedTabs.Trigger>
|
||||
<SegmentedTabs.Trigger value="pin">
|
||||
<Binary size={14} />
|
||||
PIN
|
||||
</SegmentedTabs.Trigger>
|
||||
</SegmentedTabs.List>
|
||||
</SegmentedTabs>
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
{methods.password && (activeTab === "password" || !methods.pin) && (
|
||||
<>
|
||||
{!hasBothCredentials && <Label>Password</Label>}
|
||||
<Input
|
||||
ref={passwordRef}
|
||||
type="password"
|
||||
id="password"
|
||||
placeholder="Enter password"
|
||||
disabled={submitting !== null}
|
||||
showPasswordToggle
|
||||
autoFocus
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{methods.pin && (activeTab === "pin" || !methods.password) && (
|
||||
<>
|
||||
{!hasBothCredentials && <Label>Enter PIN Code</Label>}
|
||||
<PinCodeInput
|
||||
ref={pinRef}
|
||||
value={pin}
|
||||
onChange={handlePinChange}
|
||||
disabled={submitting !== null}
|
||||
autoFocus={!methods.password}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isButtonDisabled}
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
>
|
||||
{submitting !== null ? (
|
||||
<>
|
||||
<Loader2 className="animate-spin" size={16} />
|
||||
Verifying...
|
||||
</>
|
||||
) : (
|
||||
activeTab === "password" ? "Sign in" : "Submit"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<PoweredByNetBird />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
BIN
proxy/web/src/assets/favicon.ico
Normal file
BIN
proxy/web/src/assets/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
BIN
proxy/web/src/assets/fonts/Inter-VariableFont_opsz,wght.ttf
Normal file
BIN
proxy/web/src/assets/fonts/Inter-VariableFont_opsz,wght.ttf
Normal file
Binary file not shown.
19
proxy/web/src/assets/netbird-full.svg
Normal file
19
proxy/web/src/assets/netbird-full.svg
Normal file
@@ -0,0 +1,19 @@
|
||||
<svg width="133" height="23" viewBox="0 0 133 23" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_0_3)">
|
||||
<path d="M46.9438 7.5013C48.1229 8.64688 48.7082 10.3025 48.7082 12.4683V21.6663H46.1411V12.8362C46.1411 11.2809 45.7481 10.0851 44.9704 9.26566C44.1928 8.43783 43.1308 8.0281 41.7846 8.0281C40.4383 8.0281 39.3345 8.45455 38.5234 9.30747C37.7123 10.1604 37.3109 11.4063 37.3109 13.0369V21.6663H34.7188V6.06305H37.3109V8.28732C37.821 7.49294 38.5234 6.87416 39.4014 6.43934C40.2878 6.00452 41.2578 5.78711 42.3197 5.78711C44.2179 5.78711 45.7565 6.36408 46.9355 7.50966L46.9438 7.5013Z" fill="#F2F2F2"/>
|
||||
<path d="M67.1048 14.8344H54.6288C54.7208 16.373 55.2476 17.5771 56.2092 18.4384C57.1708 19.2997 58.3331 19.7345 59.6961 19.7345C60.8166 19.7345 61.7531 19.4753 62.4973 18.9485C63.2499 18.4301 63.7767 17.7277 64.0777 16.858H66.8706C66.4525 18.3548 65.6163 19.5756 64.3621 20.5205C63.1078 21.4571 61.5525 21.9337 59.6878 21.9337C58.2077 21.9337 56.8865 21.5992 55.7159 20.9386C54.5452 20.278 53.6337 19.3331 52.9648 18.1039C52.2958 16.8831 51.9697 15.4616 51.9697 13.8477C51.9697 12.2339 52.2958 10.8207 52.9397 9.60825C53.5836 8.39578 54.495 7.45924 55.6573 6.80702C56.828 6.15479 58.1659 5.82031 59.6878 5.82031C61.2096 5.82031 62.4806 6.14643 63.6178 6.79029C64.7551 7.43416 65.6331 8.32052 66.2518 9.44938C66.8706 10.5782 67.18 11.8576 67.18 13.2791C67.18 13.7725 67.1549 14.2909 67.0964 14.8428L67.1048 14.8344ZM63.8603 10.1769C63.4255 9.4661 62.8318 8.92258 62.0793 8.55465C61.3267 8.18673 60.4989 8.00277 59.5874 8.00277C58.2746 8.00277 57.1625 8.42086 56.2427 9.25705C55.3228 10.0932 54.796 11.2472 54.6623 12.7356H64.5126C64.5126 11.7489 64.2952 10.896 63.8603 10.1852V10.1769Z" fill="#F2F2F2"/>
|
||||
<path d="M73.7695 8.20355V17.4016C73.7695 18.1626 73.9284 18.6977 74.2545 19.0071C74.5806 19.3165 75.1409 19.4754 75.9352 19.4754H77.8418V21.6662H75.5088C74.0622 21.6662 72.9835 21.3317 72.2644 20.6711C71.5452 20.0105 71.1857 18.9151 71.1857 17.3933V8.19519H69.1621V6.0629H71.1857V2.13281H73.7779V6.0629H77.8501V8.19519H73.7779L73.7695 8.20355Z" fill="#F2F2F2"/>
|
||||
<path d="M85.9022 6.68902C86.9307 6.10369 88.093 5.80266 89.4058 5.80266C90.8106 5.80266 92.0732 6.13714 93.1937 6.79773C94.3142 7.46668 95.2006 8.39485 95.8444 9.59896C96.4883 10.8031 96.8144 12.2079 96.8144 13.7966C96.8144 15.3854 96.4883 16.7818 95.8444 18.011C95.2006 19.2486 94.3142 20.2018 93.1854 20.8875C92.0565 21.5732 90.7939 21.916 89.4141 21.916C88.0344 21.916 86.8805 21.6234 85.8687 21.0297C84.8569 20.4443 84.0876 19.6918 83.5775 18.7803V21.6568H80.9854V0.601562H83.5775V8.97182C84.1127 8.04365 84.8904 7.28272 85.9105 6.69738L85.9022 6.68902ZM93.4529 10.7362C92.9763 9.86654 92.3408 9.19759 91.5297 8.74605C90.7186 8.29451 89.8322 8.06037 88.8706 8.06037C87.909 8.06037 87.0394 8.29451 86.2366 8.75441C85.4255 9.22268 84.7817 9.89163 84.2967 10.778C83.8117 11.6643 83.5692 12.6845 83.5692 13.8384C83.5692 14.9924 83.8117 16.046 84.2967 16.9323C84.7817 17.8187 85.4255 18.4877 86.2366 18.9559C87.0394 19.4242 87.9174 19.65 88.8706 19.65C89.8239 19.65 90.727 19.4158 91.5297 18.9559C92.3324 18.4877 92.9763 17.8187 93.4529 16.9323C93.9296 16.046 94.1637 15.0091 94.1637 13.8134C94.1637 12.6176 93.9296 11.6142 93.4529 10.7362Z" fill="#F2F2F2"/>
|
||||
<path d="M100.318 3.01864C99.9749 2.67581 99.8076 2.25771 99.8076 1.76436C99.8076 1.27101 99.9749 0.852913 100.318 0.510076C100.661 0.167238 101.079 0 101.572 0C102.065 0 102.45 0.167238 102.784 0.510076C103.119 0.852913 103.286 1.27101 103.286 1.76436C103.286 2.25771 103.119 2.67581 102.784 3.01864C102.45 3.36148 102.049 3.52872 101.572 3.52872C101.095 3.52872 100.661 3.36148 100.318 3.01864ZM102.826 6.06237V21.6657H100.234V6.06237H102.826Z" fill="#F2F2F2"/>
|
||||
<path d="M111.773 6.52155C112.617 6.0282 113.646 5.77734 114.867 5.77734V8.45315H114.181C111.28 8.45315 109.825 10.0252 109.825 13.1776V21.6649H107.232V6.06165H109.825V8.5953C110.276 7.70058 110.928 7.00654 111.773 6.51319V6.52155Z" fill="#F2F2F2"/>
|
||||
<path d="M117.861 9.60732C118.505 8.40321 119.391 7.46668 120.52 6.80609C121.649 6.1455 122.92 5.81102 124.325 5.81102C125.537 5.81102 126.666 6.09533 127.711 6.64721C128.757 7.20746 129.551 7.94331 130.103 8.85475V0.601562H132.72V21.6735H130.103V18.7385C129.593 19.6667 128.832 20.436 127.828 21.0297C126.825 21.6317 125.646 21.9244 124.3 21.9244C122.953 21.9244 121.657 21.5816 120.528 20.8959C119.4 20.2102 118.513 19.257 117.869 18.0194C117.226 16.7818 116.899 15.377 116.899 13.805C116.899 12.233 117.226 10.8114 117.869 9.60732H117.861ZM129.392 10.7613C128.915 9.89163 128.28 9.22268 127.469 8.75441C126.658 8.28614 125.771 8.06037 124.81 8.06037C123.848 8.06037 122.962 8.28614 122.159 8.74605C121.356 9.20595 120.729 9.86654 120.253 10.7362C119.776 11.6058 119.542 12.6343 119.542 13.8134C119.542 14.9924 119.776 16.046 120.253 16.9323C120.729 17.8187 121.365 18.4877 122.159 18.9559C122.953 19.4242 123.84 19.65 124.81 19.65C125.78 19.65 126.666 19.4158 127.469 18.9559C128.272 18.4877 128.915 17.8187 129.392 16.9323C129.869 16.046 130.103 15.0175 130.103 13.8384C130.103 12.6594 129.869 11.6393 129.392 10.7613Z" fill="#F2F2F2"/>
|
||||
<path d="M21.4651 0.568359C17.8193 0.902835 16.0047 3.00167 15.3191 4.06363L4.66602 22.5183H17.5182L30.1949 0.568359H21.4651Z" fill="#F68330"/>
|
||||
<path d="M17.5265 22.5187L0 3.9302C0 3.9302 19.8177 -1.39633 21.7493 15.2188L17.5265 22.5187Z" fill="#F68330"/>
|
||||
<path d="M14.9255 4.75055L9.54883 14.0657L17.5177 22.5196L21.7405 15.2029C21.0715 9.49174 18.287 6.37276 14.9255 4.74219" fill="#F35E32"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_0_3">
|
||||
<rect width="132.72" height="22.5186" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.5 KiB |
5
proxy/web/src/assets/netbird.svg
Normal file
5
proxy/web/src/assets/netbird.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="31" height="23" viewBox="0 0 31 23" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21.4631 0.523438C17.8173 0.857913 16.0028 2.95675 15.3171 4.01871L4.66406 22.4734H17.5163L30.1929 0.523438H21.4631Z" fill="#F68330"/>
|
||||
<path d="M17.5265 22.4737L0 3.88525C0 3.88525 19.8177 -1.44128 21.7493 15.1738L17.5265 22.4737Z" fill="#F68330"/>
|
||||
<path d="M14.9236 4.70563L9.54688 14.0208L17.5158 22.4747L21.7385 15.158C21.0696 9.44682 18.2851 6.32784 14.9236 4.69727" fill="#F05252"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 500 B |
156
proxy/web/src/components/Button.tsx
Normal file
156
proxy/web/src/components/Button.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { cn } from "@/utils/helpers";
|
||||
import { forwardRef } from "react";
|
||||
|
||||
type Variant =
|
||||
| "default"
|
||||
| "primary"
|
||||
| "secondary"
|
||||
| "secondaryLighter"
|
||||
| "input"
|
||||
| "dropdown"
|
||||
| "dotted"
|
||||
| "tertiary"
|
||||
| "white"
|
||||
| "outline"
|
||||
| "danger-outline"
|
||||
| "danger-text"
|
||||
| "default-outline"
|
||||
| "danger";
|
||||
|
||||
type Size = "xs" | "xs2" | "sm" | "md" | "lg";
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
rounded?: boolean;
|
||||
border?: 0 | 1 | 2;
|
||||
disabled?: boolean;
|
||||
stopPropagation?: boolean;
|
||||
}
|
||||
|
||||
const baseStyles = [
|
||||
"relative cursor-pointer",
|
||||
"text-sm focus:z-10 focus:ring-2 font-medium focus:outline-none whitespace-nowrap shadow-sm",
|
||||
"inline-flex gap-2 items-center justify-center transition-colors focus:ring-offset-1",
|
||||
"disabled:opacity-40 disabled:cursor-not-allowed disabled:text-nb-gray-300 ring-offset-neutral-950/50",
|
||||
];
|
||||
|
||||
const variantStyles: Record<Variant, string[]> = {
|
||||
default: [
|
||||
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900",
|
||||
"dark:focus:ring-zinc-800/50 dark:bg-nb-gray dark:text-gray-400 dark:border-gray-700/30 dark:hover:text-white dark:hover:bg-zinc-800/50",
|
||||
],
|
||||
primary: [
|
||||
"dark:focus:ring-netbird-600/50 dark:ring-offset-neutral-950/50 enabled:dark:bg-netbird disabled:dark:bg-nb-gray-910 dark:text-gray-100 enabled:dark:hover:text-white enabled:dark:hover:bg-netbird-500/80",
|
||||
"enabled:bg-netbird enabled:text-white enabled:focus:ring-netbird-400/50 enabled:hover:bg-netbird-500",
|
||||
],
|
||||
secondary: [
|
||||
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
|
||||
"dark:bg-nb-gray-920 dark:text-gray-400 dark:border-gray-700/40 dark:hover:text-white dark:hover:bg-nb-gray-910",
|
||||
],
|
||||
secondaryLighter: [
|
||||
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
|
||||
"dark:bg-nb-gray-900/70 dark:text-gray-400 dark:border-gray-700/70 dark:hover:text-white dark:hover:bg-nb-gray-800/60",
|
||||
],
|
||||
input: [
|
||||
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-neutral-200 text-gray-900",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
|
||||
"dark:bg-nb-gray-900 dark:text-gray-400 dark:border-nb-gray-700 dark:hover:bg-nb-gray-900/80",
|
||||
],
|
||||
dropdown: [
|
||||
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-neutral-200 text-gray-900",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
|
||||
"dark:bg-nb-gray-900/40 dark:text-gray-400 dark:border-nb-gray-900 dark:hover:bg-nb-gray-900/50",
|
||||
],
|
||||
dotted: [
|
||||
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900 border-dashed",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
|
||||
"dark:bg-nb-gray-900/30 dark:text-gray-400 dark:border-gray-500/40 dark:hover:text-white dark:hover:bg-zinc-800/50",
|
||||
],
|
||||
tertiary: [
|
||||
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900",
|
||||
"dark:focus:ring-zinc-800/50 dark:bg-white dark:text-gray-800 dark:border-gray-700/40 dark:hover:bg-neutral-200 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300",
|
||||
],
|
||||
white: [
|
||||
"focus:ring-white/50 bg-white text-gray-800 border-white outline-none hover:bg-neutral-200 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300",
|
||||
"disabled:dark:bg-nb-gray-900 disabled:dark:text-nb-gray-300 disabled:dark:border-nb-gray-900",
|
||||
],
|
||||
outline: [
|
||||
"bg-white hover:text-black focus:ring-zinc-200/50 hover:bg-gray-100 border-gray-200 text-gray-900",
|
||||
"dark:focus:ring-zinc-800/50 dark:bg-transparent dark:text-netbird dark:border-netbird dark:hover:bg-nb-gray-900/30",
|
||||
],
|
||||
"danger-outline": [
|
||||
"enabled:dark:focus:ring-red-800/20 enabled:dark:focus:bg-red-950/40 enabled:hover:dark:bg-red-950/50 enabled:dark:hover:border-red-800/50 dark:bg-transparent dark:text-red-500",
|
||||
],
|
||||
"danger-text": [
|
||||
"dark:bg-transparent dark:text-red-500 dark:hover:text-red-600 dark:border-transparent !px-0 !shadow-none !py-0 focus:ring-red-500/30 dark:ring-offset-neutral-950/50",
|
||||
],
|
||||
"default-outline": [
|
||||
"dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20",
|
||||
"dark:bg-transparent dark:text-nb-gray-400 dark:border-transparent dark:hover:text-white dark:hover:bg-nb-gray-900/30 dark:hover:border-nb-gray-800/50",
|
||||
"data-[state=open]:dark:text-white data-[state=open]:dark:bg-nb-gray-900/30 data-[state=open]:dark:border-nb-gray-800/50",
|
||||
],
|
||||
danger: [
|
||||
"dark:focus:ring-red-700/20 dark:focus:bg-red-700 hover:dark:bg-red-700 dark:hover:border-red-800/50 dark:bg-red-600 dark:text-red-100",
|
||||
],
|
||||
};
|
||||
|
||||
const sizeStyles: Record<Size, string> = {
|
||||
xs: "text-xs py-2 px-4",
|
||||
xs2: "text-[0.78rem] py-2 px-4",
|
||||
sm: "text-sm py-2.5 px-4",
|
||||
md: "text-sm py-2.5 px-4",
|
||||
lg: "text-base py-2.5 px-4",
|
||||
};
|
||||
|
||||
const borderStyles: Record<0 | 1 | 2, string> = {
|
||||
0: "border",
|
||||
1: "border border-transparent",
|
||||
2: "border border-t-0 border-b-0",
|
||||
};
|
||||
|
||||
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{
|
||||
variant = "default",
|
||||
rounded = true,
|
||||
border = 1,
|
||||
size = "md",
|
||||
stopPropagation = true,
|
||||
className,
|
||||
onClick,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
baseStyles,
|
||||
variantStyles[variant],
|
||||
sizeStyles[size],
|
||||
borderStyles[border ? 1 : 0],
|
||||
rounded && "rounded-md",
|
||||
className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (stopPropagation) e.stopPropagation();
|
||||
onClick?.(e);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Button.displayName = "Button";
|
||||
|
||||
export default Button;
|
||||
23
proxy/web/src/components/Card.tsx
Normal file
23
proxy/web/src/components/Card.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { cn } from "@/utils/helpers";
|
||||
import { GradientFadedBackground } from "@/components/GradientFadedBackground";
|
||||
|
||||
export const Card = ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"px-6 sm:px-10 py-10 pt-8",
|
||||
"bg-nb-gray-940 border border-nb-gray-910 rounded-lg relative",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<GradientFadedBackground />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
14
proxy/web/src/components/Description.tsx
Normal file
14
proxy/web/src/components/Description.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { cn } from "@/utils/helpers";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function Description({ children, className }: Props) {
|
||||
return (
|
||||
<div className={cn("text-sm text-nb-gray-300 font-light mt-2 block text-center z-10 relative", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
proxy/web/src/components/ErrorMessage.tsx
Normal file
7
proxy/web/src/components/ErrorMessage.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export const ErrorMessage = ({ error }: { error?: string }) => {
|
||||
return (
|
||||
<div className="text-red-400 bg-red-800/20 border border-red-800/50 rounded-lg px-4 py-3 whitespace-break-spaces text-sm">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
22
proxy/web/src/components/GradientFadedBackground.tsx
Normal file
22
proxy/web/src/components/GradientFadedBackground.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { cn } from "@/utils/helpers";
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const GradientFadedBackground = ({ className }: Props) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-full w-full absolute left-0 top-0 rounded-md overflow-hidden z-0 pointer-events-none",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"bg-linear-to-b from-nb-gray-900/10 via-transparent to-transparent w-full h-full rounded-md"
|
||||
}
|
||||
></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
19
proxy/web/src/components/HelpText.tsx
Normal file
19
proxy/web/src/components/HelpText.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { cn } from "@/utils/helpers";
|
||||
|
||||
interface HelpTextProps {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function HelpText({ children, className }: HelpTextProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[.8rem] text-nb-gray-300 block font-light tracking-wide",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
137
proxy/web/src/components/Input.tsx
Normal file
137
proxy/web/src/components/Input.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { cn } from "@/utils/helpers";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
customPrefix?: React.ReactNode;
|
||||
customSuffix?: React.ReactNode;
|
||||
maxWidthClass?: string;
|
||||
icon?: React.ReactNode;
|
||||
error?: string;
|
||||
prefixClassName?: string;
|
||||
showPasswordToggle?: boolean;
|
||||
variant?: "default" | "darker";
|
||||
}
|
||||
|
||||
const variantStyles = {
|
||||
default: [
|
||||
"bg-nb-gray-900 placeholder:text-neutral-400/70 border-nb-gray-700",
|
||||
"ring-offset-neutral-950/50 focus-visible:ring-neutral-500/20",
|
||||
],
|
||||
darker: [
|
||||
"bg-nb-gray-920 placeholder:text-neutral-400/70 border-nb-gray-800",
|
||||
"ring-offset-neutral-950/50 focus-visible:ring-neutral-500/20",
|
||||
],
|
||||
error: [
|
||||
"bg-nb-gray-900 placeholder:text-neutral-400/70 border-red-500 text-red-500",
|
||||
"ring-offset-red-500/10 focus-visible:ring-red-500/10",
|
||||
],
|
||||
};
|
||||
|
||||
const prefixSuffixStyles = {
|
||||
default: "bg-nb-gray-900 border-nb-gray-700 text-nb-gray-300",
|
||||
error: "bg-nb-gray-900 border-red-500 text-nb-gray-300 text-red-500",
|
||||
};
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
type,
|
||||
customSuffix,
|
||||
customPrefix,
|
||||
icon,
|
||||
maxWidthClass = "",
|
||||
error,
|
||||
variant = "default",
|
||||
prefixClassName,
|
||||
showPasswordToggle = false,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const isPasswordType = type === "password";
|
||||
const inputType = isPasswordType && showPassword ? "text" : type;
|
||||
|
||||
const passwordToggle =
|
||||
isPasswordType && showPasswordToggle ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="hover:text-white transition-all"
|
||||
aria-label="Toggle password visibility"
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
const suffix = passwordToggle || customSuffix;
|
||||
const activeVariant = error ? "error" : variant;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn("flex relative h-[42px]", maxWidthClass)}>
|
||||
{customPrefix && (
|
||||
<div
|
||||
className={cn(
|
||||
prefixSuffixStyles[error ? "error" : "default"],
|
||||
"flex h-[42px] w-auto rounded-l-md px-3 py-2 text-sm",
|
||||
"border items-center whitespace-nowrap",
|
||||
props.disabled && "opacity-40",
|
||||
prefixClassName
|
||||
)}
|
||||
>
|
||||
{customPrefix}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-0 top-0 h-full flex items-center text-xs text-nb-gray-300 pl-3 leading-[0]",
|
||||
props.disabled && "opacity-40"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
|
||||
<input
|
||||
type={inputType}
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={cn(
|
||||
variantStyles[activeVariant],
|
||||
"flex h-[42px] w-full rounded-md px-3 py-2 text-sm",
|
||||
"file:bg-transparent file:text-sm file:font-medium file:border-0",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
|
||||
"disabled:cursor-not-allowed disabled:opacity-40",
|
||||
"border",
|
||||
customPrefix && "!border-l-0 !rounded-l-none",
|
||||
suffix && "!pr-16",
|
||||
icon && "!pl-10",
|
||||
className
|
||||
)}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-0 top-0 h-full flex items-center text-xs text-nb-gray-300 pr-4 leading-[0] select-none",
|
||||
props.disabled && "opacity-30"
|
||||
)}
|
||||
>
|
||||
{suffix}
|
||||
</div>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-xs text-red-500 mt-2">{error}</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
18
proxy/web/src/components/Label.tsx
Normal file
18
proxy/web/src/components/Label.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { cn } from "@/utils/helpers";
|
||||
|
||||
type LabelProps = React.LabelHTMLAttributes<HTMLLabelElement>;
|
||||
|
||||
export function Label({ className, ...props }: LabelProps) {
|
||||
return (
|
||||
<label
|
||||
className={cn(
|
||||
"text-sm font-medium tracking-wider leading-none",
|
||||
"peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
"mb-2.5 inline-block text-nb-gray-200",
|
||||
"flex items-center gap-2 select-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
46
proxy/web/src/components/NetBirdLogo.tsx
Normal file
46
proxy/web/src/components/NetBirdLogo.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { cn } from "@/utils/helpers";
|
||||
import netbirdFull from "@/assets/netbird-full.svg";
|
||||
import netbirdMark from "@/assets/netbird.svg";
|
||||
|
||||
type Props = {
|
||||
size?: "small" | "default" | "large";
|
||||
mobile?: boolean;
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
small: {
|
||||
desktop: 14,
|
||||
mobile: 20,
|
||||
},
|
||||
default: {
|
||||
desktop: 22,
|
||||
mobile: 30,
|
||||
},
|
||||
large: {
|
||||
desktop: 24,
|
||||
mobile: 40,
|
||||
},
|
||||
};
|
||||
|
||||
export const NetBirdLogo = ({ size = "default", mobile = true }: Props) => {
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
src={netbirdFull}
|
||||
height={sizes[size].desktop}
|
||||
style={{ height: sizes[size].desktop }}
|
||||
alt="NetBird Logo"
|
||||
className={cn(mobile && "hidden md:block", "group-hover:opacity-80 transition-all")}
|
||||
/>
|
||||
{mobile && (
|
||||
<img
|
||||
src={netbirdMark}
|
||||
width={sizes[size].mobile}
|
||||
style={{ width: sizes[size].mobile }}
|
||||
alt="NetBird Logo"
|
||||
className={cn(mobile && "md:hidden ml-4")}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
107
proxy/web/src/components/PinCodeInput.tsx
Normal file
107
proxy/web/src/components/PinCodeInput.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { cn } from "@/utils/helpers";
|
||||
import React, {
|
||||
useRef,
|
||||
type KeyboardEvent,
|
||||
type ClipboardEvent,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from "react";
|
||||
|
||||
export interface PinCodeInputRef {
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
length?: number;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
const PinCodeInput = forwardRef<PinCodeInputRef, Props>(function PinCodeInput(
|
||||
{ value, onChange, length = 6, disabled = false, className, autoFocus = false },
|
||||
ref,
|
||||
) {
|
||||
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
focus: () => {
|
||||
inputRefs.current[0]?.focus();
|
||||
},
|
||||
}));
|
||||
|
||||
const digits = value.split("").concat(Array(length).fill("")).slice(0, length);
|
||||
|
||||
const handleChange = (index: number, digit: string) => {
|
||||
if (!/^\d*$/.test(digit)) return;
|
||||
|
||||
const newDigits = [...digits];
|
||||
newDigits[index] = digit.slice(-1);
|
||||
const newValue = newDigits.join("").replace(/\s/g, "");
|
||||
onChange(newValue);
|
||||
|
||||
if (digit && index < length - 1) {
|
||||
inputRefs.current[index + 1]?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (index: number, e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Backspace" && !digits[index] && index > 0) {
|
||||
inputRefs.current[index - 1]?.focus();
|
||||
}
|
||||
if (e.key === "ArrowLeft" && index > 0) {
|
||||
inputRefs.current[index - 1]?.focus();
|
||||
}
|
||||
if (e.key === "ArrowRight" && index < length - 1) {
|
||||
inputRefs.current[index + 1]?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = (e: ClipboardEvent<HTMLInputElement>) => {
|
||||
e.preventDefault();
|
||||
const pastedData = e.clipboardData.getData("text").replace(/\D/g, "").slice(0, length);
|
||||
onChange(pastedData);
|
||||
|
||||
const nextIndex = Math.min(pastedData.length, length - 1);
|
||||
inputRefs.current[nextIndex]?.focus();
|
||||
};
|
||||
|
||||
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
e.target.select();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex gap-2 w-full min-w-0", className)}>
|
||||
{digits.map((digit, index) => (
|
||||
<input
|
||||
key={index}
|
||||
ref={(el) => {
|
||||
inputRefs.current[index] = el;
|
||||
}}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
maxLength={1}
|
||||
value={digit}
|
||||
onChange={(e) => handleChange(index, e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(index, e)}
|
||||
onPaste={handlePaste}
|
||||
onFocus={handleFocus}
|
||||
disabled={disabled}
|
||||
autoFocus={autoFocus && index === 0}
|
||||
className={cn(
|
||||
"flex-1 min-w-0 h-[42px] text-center text-sm rounded-md",
|
||||
"dark:bg-nb-gray-900 border dark:border-nb-gray-700",
|
||||
"dark:placeholder:text-neutral-400/70",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
|
||||
"ring-offset-neutral-200/20 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20",
|
||||
"disabled:cursor-not-allowed disabled:opacity-40"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default PinCodeInput;
|
||||
12
proxy/web/src/components/PoweredByNetBird.tsx
Normal file
12
proxy/web/src/components/PoweredByNetBird.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { NetBirdLogo } from "./NetBirdLogo";
|
||||
|
||||
export function PoweredByNetBird() {
|
||||
return (
|
||||
<div className="flex items-center justify-center mt-8 gap-2 group cursor-pointer">
|
||||
<span className="text-sm text-nb-gray-400 font-light text-center group-hover:opacity-80 transition-all">
|
||||
Powered by
|
||||
</span>
|
||||
<NetBirdLogo size="small" mobile={false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
proxy/web/src/components/SegmentedTabs.tsx
Normal file
137
proxy/web/src/components/SegmentedTabs.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { cn } from "@/utils/helpers";
|
||||
import { useState } from "react";
|
||||
import { TabContext, useTabContext } from "./TabContext";
|
||||
|
||||
type TabsProps = {
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
onChange?: (value: string) => void;
|
||||
children:
|
||||
| React.ReactNode
|
||||
| ((context: { value: string; onChange: (value: string) => void }) => React.ReactNode);
|
||||
};
|
||||
|
||||
function SegmentedTabs({ value, defaultValue, onChange, children }: TabsProps) {
|
||||
const [internalValue, setInternalValue] = useState(defaultValue || "");
|
||||
const currentValue = value !== undefined ? value : internalValue;
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
if (value === undefined) {
|
||||
setInternalValue(newValue);
|
||||
}
|
||||
onChange?.(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<TabContext.Provider value={{ value: currentValue, onChange: handleChange }}>
|
||||
<div>
|
||||
{typeof children === "function"
|
||||
? children({ value: currentValue, onChange: handleChange })
|
||||
: children}
|
||||
</div>
|
||||
</TabContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function List({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role="tablist"
|
||||
className={cn(
|
||||
"bg-nb-gray-930/70 p-1.5 flex justify-center gap-1 border-nb-gray-900",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Trigger({
|
||||
children,
|
||||
value,
|
||||
disabled = false,
|
||||
className,
|
||||
selected,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
selected?: boolean;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
const context = useTabContext();
|
||||
const isSelected = selected !== undefined ? selected : value === context.value;
|
||||
|
||||
const handleClick = () => {
|
||||
context.onChange(value);
|
||||
onClick?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
role="tab"
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-selected={isSelected}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm rounded-md w-full transition-all cursor-pointer",
|
||||
disabled && "opacity-30 cursor-not-allowed",
|
||||
isSelected
|
||||
? "bg-nb-gray-900 text-white"
|
||||
: disabled
|
||||
? ""
|
||||
: "text-nb-gray-400 hover:bg-nb-gray-900/50",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center w-full justify-center gap-2">
|
||||
{children}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Content({
|
||||
children,
|
||||
value,
|
||||
className,
|
||||
visible,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
value: string;
|
||||
className?: string;
|
||||
visible?: boolean;
|
||||
}) {
|
||||
const context = useTabContext();
|
||||
const isVisible = visible !== undefined ? visible : value === context.value;
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="tabpanel"
|
||||
className={cn(
|
||||
"bg-nb-gray-930/70 px-4 pt-4 pb-5 rounded-b-md border border-t-0 border-nb-gray-900",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
SegmentedTabs.List = List;
|
||||
SegmentedTabs.Trigger = Trigger;
|
||||
SegmentedTabs.Content = Content;
|
||||
|
||||
export { SegmentedTabs };
|
||||
10
proxy/web/src/components/Separator.tsx
Normal file
10
proxy/web/src/components/Separator.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
export const Separator = () => {
|
||||
return (
|
||||
<div className="flex items-center justify-center relative my-4">
|
||||
<span className="bg-nb-gray-940 relative z-10 px-4 text-xs text-nb-gray-400 font-medium">
|
||||
OR
|
||||
</span>
|
||||
<span className="h-px bg-nb-gray-900 w-full absolute z-0" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
13
proxy/web/src/components/TabContext.tsx
Normal file
13
proxy/web/src/components/TabContext.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
type TabContextValue = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export const TabContext = createContext<TabContextValue>({
|
||||
value: "",
|
||||
onChange: () => {},
|
||||
});
|
||||
|
||||
export const useTabContext = () => useContext(TabContext);
|
||||
14
proxy/web/src/components/Title.tsx
Normal file
14
proxy/web/src/components/Title.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { cn } from "@/utils/helpers";
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function Title({ children, className }: Props) {
|
||||
return (
|
||||
<h1 className={cn("text-xl! text-center z-10 relative", className)}>
|
||||
{children}
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
17
proxy/web/src/data.ts
Normal file
17
proxy/web/src/data.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// Auth method types matching Go
|
||||
export type AuthMethod = 'pin' | 'password' | 'oidc' | "link"
|
||||
|
||||
// Data injected by Go templates
|
||||
export interface Data {
|
||||
methods?: Partial<Record<AuthMethod, string>>
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__DATA__?: Data
|
||||
}
|
||||
}
|
||||
|
||||
export function getData(): Data {
|
||||
return window.__DATA__ ?? {}
|
||||
}
|
||||
214
proxy/web/src/index.css
Normal file
214
proxy/web/src/index.css
Normal file
@@ -0,0 +1,214 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("./assets/fonts/Inter-VariableFont_opsz,wght.ttf") format("truetype");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: italic;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url("./assets/fonts/Inter-Italic-VariableFont_opsz,wght.ttf") format("truetype");
|
||||
}
|
||||
|
||||
@theme {
|
||||
/* Gray */
|
||||
--color-gray-50: #F9FAFB;
|
||||
--color-gray-100: #F3F4F6;
|
||||
--color-gray-200: #E5E7EB;
|
||||
--color-gray-300: #D1D5DB;
|
||||
--color-gray-400: #9CA3AF;
|
||||
--color-gray-500: #6B7280;
|
||||
--color-gray-600: #4B5563;
|
||||
--color-gray-700: #374151;
|
||||
--color-gray-800: #1F2937;
|
||||
--color-gray-900: #111827;
|
||||
|
||||
/* Red */
|
||||
--color-red-50: #FDF2F2;
|
||||
--color-red-100: #FDE8E8;
|
||||
--color-red-200: #FBD5D5;
|
||||
--color-red-300: #F8B4B4;
|
||||
--color-red-400: #F98080;
|
||||
--color-red-500: #F05252;
|
||||
--color-red-600: #E02424;
|
||||
--color-red-700: #C81E1E;
|
||||
--color-red-800: #9B1C1C;
|
||||
--color-red-900: #771D1D;
|
||||
|
||||
/* Yellow */
|
||||
--color-yellow-50: #FDFDEA;
|
||||
--color-yellow-100: #FDF6B2;
|
||||
--color-yellow-200: #FCE96A;
|
||||
--color-yellow-300: #FACA15;
|
||||
--color-yellow-400: #E3A008;
|
||||
--color-yellow-500: #C27803;
|
||||
--color-yellow-600: #9F580A;
|
||||
--color-yellow-700: #8E4B10;
|
||||
--color-yellow-800: #723B13;
|
||||
--color-yellow-900: #633112;
|
||||
|
||||
/* Green */
|
||||
--color-green-50: #F3FAF7;
|
||||
--color-green-100: #DEF7EC;
|
||||
--color-green-200: #BCF0DA;
|
||||
--color-green-300: #84E1BC;
|
||||
--color-green-400: #31C48D;
|
||||
--color-green-500: #0E9F6E;
|
||||
--color-green-600: #057A55;
|
||||
--color-green-700: #046C4E;
|
||||
--color-green-800: #03543F;
|
||||
--color-green-900: #014737;
|
||||
|
||||
/* Blue */
|
||||
--color-blue-50: #EBF5FF;
|
||||
--color-blue-100: #E1EFFE;
|
||||
--color-blue-200: #C3DDFD;
|
||||
--color-blue-300: #A4CAFE;
|
||||
--color-blue-400: #76A9FA;
|
||||
--color-blue-500: #3F83F8;
|
||||
--color-blue-600: #1C64F2;
|
||||
--color-blue-700: #1A56DB;
|
||||
--color-blue-800: #1E429F;
|
||||
--color-blue-900: #233876;
|
||||
|
||||
/* Indigo */
|
||||
--color-indigo-50: #F0F5FF;
|
||||
--color-indigo-100: #E5EDFF;
|
||||
--color-indigo-200: #CDDBFE;
|
||||
--color-indigo-300: #B4C6FC;
|
||||
--color-indigo-400: #8DA2FB;
|
||||
--color-indigo-500: #6875F5;
|
||||
--color-indigo-600: #5850EC;
|
||||
--color-indigo-700: #5145CD;
|
||||
--color-indigo-800: #42389D;
|
||||
--color-indigo-900: #362F78;
|
||||
|
||||
/* Purple */
|
||||
--color-purple-50: #F6F5FF;
|
||||
--color-purple-100: #EDEBFE;
|
||||
--color-purple-200: #DCD7FE;
|
||||
--color-purple-300: #CABFFD;
|
||||
--color-purple-400: #AC94FA;
|
||||
--color-purple-500: #9061F9;
|
||||
--color-purple-600: #7E3AF2;
|
||||
--color-purple-700: #6C2BD9;
|
||||
--color-purple-800: #5521B5;
|
||||
--color-purple-900: #4A1D96;
|
||||
|
||||
/* Pink */
|
||||
--color-pink-50: #FDF2F8;
|
||||
--color-pink-100: #FCE8F3;
|
||||
--color-pink-200: #FAD1E8;
|
||||
--color-pink-300: #F8B4D9;
|
||||
--color-pink-400: #F17EB8;
|
||||
--color-pink-500: #E74694;
|
||||
--color-pink-600: #D61F69;
|
||||
--color-pink-700: #BF125D;
|
||||
--color-pink-800: #99154B;
|
||||
--color-pink-900: #751A3D;
|
||||
|
||||
/* NetBird Gray */
|
||||
--color-nb-gray: #181A1D;
|
||||
--color-nb-gray-50: #f4f6f7;
|
||||
--color-nb-gray-100: #e4e7e9;
|
||||
--color-nb-gray-200: #cbd2d6;
|
||||
--color-nb-gray-250: #b7c0c6;
|
||||
--color-nb-gray-300: #aab4bd;
|
||||
--color-nb-gray-350: #8f9ca8;
|
||||
--color-nb-gray-400: #7c8994;
|
||||
--color-nb-gray-500: #616e79;
|
||||
--color-nb-gray-600: #535d67;
|
||||
--color-nb-gray-700: #474e57;
|
||||
--color-nb-gray-800: #3f444b;
|
||||
--color-nb-gray-850: #363b40;
|
||||
--color-nb-gray-900: #32363D;
|
||||
--color-nb-gray-910: #2b2f33;
|
||||
--color-nb-gray-920: #25282d;
|
||||
--color-nb-gray-925: #1e2123;
|
||||
--color-nb-gray-930: #25282c;
|
||||
--color-nb-gray-935: #1f2124;
|
||||
--color-nb-gray-940: #1c1e21;
|
||||
--color-nb-gray-950: #181a1d;
|
||||
--color-nb-gray-960: #15171a;
|
||||
|
||||
/* NetBird Orange */
|
||||
--color-netbird: #f68330;
|
||||
--color-netbird-50: #fff6ed;
|
||||
--color-netbird-100: #feecd6;
|
||||
--color-netbird-150: #ffdfb8;
|
||||
--color-netbird-200: #ffd4a6;
|
||||
--color-netbird-300: #fab677;
|
||||
--color-netbird-400: #f68330;
|
||||
--color-netbird-500: #f46d1b;
|
||||
--color-netbird-600: #e55311;
|
||||
--color-netbird-700: #be3e10;
|
||||
--color-netbird-800: #973215;
|
||||
--color-netbird-900: #7a2b14;
|
||||
--color-netbird-950: #421308;
|
||||
|
||||
/* NetBird Blue */
|
||||
--color-nb-blue: #31e4f5;
|
||||
--color-nb-blue-50: #ebffff;
|
||||
--color-nb-blue-100: #cefdff;
|
||||
--color-nb-blue-200: #a2f9ff;
|
||||
--color-nb-blue-300: #63f2fd;
|
||||
--color-nb-blue-400: #31e4f5;
|
||||
--color-nb-blue-500: #00c4da;
|
||||
--color-nb-blue-600: #039cb7;
|
||||
--color-nb-blue-700: #0a7c94;
|
||||
--color-nb-blue-800: #126478;
|
||||
--color-nb-blue-900: #145365;
|
||||
--color-nb-blue-950: #063746;
|
||||
}
|
||||
|
||||
:root {
|
||||
--nb-bg: #18191d;
|
||||
--nb-card-bg: #1b1f22;
|
||||
--nb-border: rgba(50, 54, 61, 0.5);
|
||||
--nb-text: #e4e7e9;
|
||||
--nb-text-muted: rgba(167, 177, 185, 0.8);
|
||||
--nb-primary: #f68330;
|
||||
--nb-primary-hover: #e5722a;
|
||||
--nb-input-bg: rgba(63, 68, 75, 0.5);
|
||||
--nb-input-border: rgba(63, 68, 75, 0.8);
|
||||
--nb-error-bg: rgba(153, 27, 27, 0.2);
|
||||
--nb-error-border: rgba(153, 27, 27, 0.5);
|
||||
--nb-error-text: #f87171;
|
||||
}
|
||||
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html{
|
||||
@apply bg-nb-gray;
|
||||
}
|
||||
|
||||
html.dark,
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Inter", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
|
||||
}
|
||||
|
||||
h1 {
|
||||
@apply text-2xl font-medium text-gray-700 dark:text-nb-gray-100 my-1;
|
||||
}
|
||||
h2 {
|
||||
@apply text-xl font-medium text-gray-700 dark:text-nb-gray-100 my-1;
|
||||
}
|
||||
p {
|
||||
@apply font-light tracking-wide text-gray-700 dark:text-zinc-50 text-sm;
|
||||
}
|
||||
|
||||
[placeholder] {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
10
proxy/web/src/main.tsx
Normal file
10
proxy/web/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
6
proxy/web/src/utils/helpers.ts
Normal file
6
proxy/web/src/utils/helpers.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
6
proxy/web/src/vite-env.d.ts
vendored
Normal file
6
proxy/web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.svg" {
|
||||
const content: string;
|
||||
export default content;
|
||||
}
|
||||
22
proxy/web/tsconfig.json
Normal file
22
proxy/web/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
24
proxy/web/vite.config.ts
Normal file
24
proxy/web/vite.config.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3031,
|
||||
},
|
||||
preview: {
|
||||
port: 3031,
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
assetsDir: 'assets',
|
||||
cssCodeSplit: false,
|
||||
},
|
||||
})
|
||||
105
proxy/web/web.go
Normal file
105
proxy/web/web.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed dist/*
|
||||
var files embed.FS
|
||||
|
||||
var (
|
||||
webFS fs.FS
|
||||
tmpl *template.Template
|
||||
initErr error
|
||||
)
|
||||
|
||||
func init() {
|
||||
webFS, initErr = fs.Sub(files, "dist")
|
||||
if initErr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var indexHTML []byte
|
||||
indexHTML, initErr = fs.ReadFile(webFS, "index.html")
|
||||
if initErr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
tmpl, initErr = template.New("index").Parse(string(indexHTML))
|
||||
}
|
||||
|
||||
// ServeHTTP serves the web UI. For static assets it serves them directly,
|
||||
// for other paths it renders the page with the provided data.
|
||||
func ServeHTTP(w http.ResponseWriter, r *http.Request, data any) {
|
||||
if initErr != nil {
|
||||
http.Error(w, initErr.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
path := r.URL.Path
|
||||
|
||||
// Serve robots.txt
|
||||
if path == "/robots.txt" {
|
||||
content, err := fs.ReadFile(webFS, "robots.txt")
|
||||
if err != nil {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Write(content)
|
||||
return
|
||||
}
|
||||
|
||||
// Serve static assets directly
|
||||
if strings.HasPrefix(path, "/assets/") {
|
||||
filePath := strings.TrimPrefix(path, "/")
|
||||
content, err := fs.ReadFile(webFS, filePath)
|
||||
if err != nil {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
switch filepath.Ext(filePath) {
|
||||
case ".js":
|
||||
w.Header().Set("Content-Type", "application/javascript")
|
||||
case ".css":
|
||||
w.Header().Set("Content-Type", "text/css")
|
||||
case ".svg":
|
||||
w.Header().Set("Content-Type", "image/svg+xml")
|
||||
case ".ttf":
|
||||
w.Header().Set("Content-Type", "font/ttf")
|
||||
case ".woff":
|
||||
w.Header().Set("Content-Type", "font/woff")
|
||||
case ".woff2":
|
||||
w.Header().Set("Content-Type", "font/woff2")
|
||||
case ".ico":
|
||||
w.Header().Set("Content-Type", "image/x-icon")
|
||||
}
|
||||
|
||||
w.Write(content)
|
||||
return
|
||||
}
|
||||
|
||||
// Render the page with data
|
||||
dataJSON, _ := json.Marshal(data)
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, struct {
|
||||
Data template.JS
|
||||
}{
|
||||
Data: template.JS(dataJSON),
|
||||
}); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write(buf.Bytes())
|
||||
}
|
||||
Reference in New Issue
Block a user