mirror of
https://github.com/fosrl/pangolin.git
synced 2026-01-29 06:10:47 +00:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aabdcea3c0 | ||
|
|
a178faa377 | ||
|
|
edf0ce226f | ||
|
|
7118ae374d | ||
|
|
f2a14e6a36 | ||
|
|
f37be774a6 | ||
|
|
0dcfeb3587 | ||
|
|
dbfc8b51aa | ||
|
|
d72a8af04b | ||
|
|
7131dea7a0 | ||
|
|
deb30ed4ae | ||
|
|
3b09ef3345 | ||
|
|
06e90c9555 | ||
|
|
cdc415079c | ||
|
|
1c2ba4076a | ||
|
|
af68aa692c | ||
|
|
edba818615 | ||
|
|
cdf904a2bc | ||
|
|
fedab6c9a8 | ||
|
|
33e8ed4c93 | ||
|
|
2b54dfe035 | ||
|
|
e601816791 | ||
|
|
7a46cf3da7 | ||
|
|
ad32e5e651 | ||
|
|
8ec55eb70d | ||
|
|
767fec19cd | ||
|
|
d215a12f5a | ||
|
|
d22dcfb464 | ||
|
|
c93b36c757 | ||
|
|
9253dd19ba | ||
|
|
b9d83a2507 | ||
|
|
581f96daa8 | ||
|
|
33ff2fbf3b | ||
|
|
535b4e1fb1 | ||
|
|
5871bea706 | ||
|
|
07eb422491 | ||
|
|
654ed46a46 |
@@ -22,7 +22,6 @@ next-env.d.ts
|
||||
*.log
|
||||
.machinelogs*.json
|
||||
*-audit.json
|
||||
package-lock.json
|
||||
install/
|
||||
bruno/
|
||||
LICENSE
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -23,7 +23,6 @@ next-env.d.ts
|
||||
.machinelogs*.json
|
||||
*-audit.json
|
||||
migrations
|
||||
package-lock.json
|
||||
tsconfig.tsbuildinfo
|
||||
config/config.yml
|
||||
dist
|
||||
|
||||
19
Dockerfile
19
Dockerfile
@@ -2,9 +2,8 @@ FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
|
||||
RUN npm install
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
@@ -14,21 +13,19 @@ RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
# Curl used for the health checks
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
RUN npm install --omit=dev
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --only=production && npm cache clean --force
|
||||
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/init ./dist/init
|
||||
|
||||
COPY config/config.example.yml ./dist/config.example.yml
|
||||
COPY config/traefik/traefik_config.example.yml ./dist/traefik_config.example.yml
|
||||
COPY config/traefik/dynamic_config.example.yml ./dist/dynamic_config.example.yml
|
||||
COPY server/db/names.json ./dist/names.json
|
||||
|
||||
COPY public ./public
|
||||
|
||||
10
README.md
10
README.md
@@ -18,12 +18,12 @@ _Your own self-hosted zero trust tunnel._
|
||||
|
||||
<div align="center">
|
||||
<h5>
|
||||
<a href="https://docs.fossorial.io/Getting%20Started/quick-install">
|
||||
Install Guide
|
||||
<a href="https://fossorial.io">
|
||||
Website
|
||||
</a>
|
||||
<span> | </span>
|
||||
<a href="https://docs.fossorial.io">
|
||||
Full Documentation
|
||||
<a href="https://docs.fossorial.io/Getting%20Started/quick-install">
|
||||
Install Guide
|
||||
</a>
|
||||
<span> | </span>
|
||||
<a href="mailto:numbat@fossorial.io">
|
||||
@@ -136,7 +136,7 @@ View the [project board](https://github.com/orgs/fosrl/projects/1) for more deta
|
||||
|
||||
## Licensing
|
||||
|
||||
Pangolin is dual licensed under the AGPLv3 and the Fossorial Commercial license. For inquiries about commercial licensing, please contact us at [numbat@fossorial.io](mailto:numbat@fossorial.io).
|
||||
Pangolin is dual licensed under the AGPL-3 and the Fossorial Commercial license. To see our commercial offerings, please see our [website](https://fossorial.io) for details. For inquiries about commercial licensing, please contact us at [numbat@fossorial.io](mailto:numbat@fossorial.io).
|
||||
|
||||
## Contributions
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ post {
|
||||
|
||||
body:json {
|
||||
{
|
||||
"email": "owen@fossorial.io",
|
||||
"email": "admin@fosrl.io",
|
||||
"password": "Password123!"
|
||||
}
|
||||
}
|
||||
|
||||
11
bruno/Users/adminListUsers.bru
Normal file
11
bruno/Users/adminListUsers.bru
Normal file
@@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: adminListUsers
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
get {
|
||||
url: http://localhost:3000/api/v1/users
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
11
bruno/Users/adminRemoveUser.bru
Normal file
11
bruno/Users/adminRemoveUser.bru
Normal file
@@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: adminRemoveUser
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
delete {
|
||||
url: http://localhost:3000/api/v1/user/ky5r7ivqs8wc7u4
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
http:
|
||||
middlewares:
|
||||
redirect-to-https:
|
||||
redirectScheme:
|
||||
scheme: https
|
||||
|
||||
routers:
|
||||
# HTTP to HTTPS redirect router
|
||||
main-app-router-redirect:
|
||||
rule: "Host(`{{.DashboardDomain}}`)"
|
||||
service: next-service
|
||||
entryPoints:
|
||||
- web
|
||||
middlewares:
|
||||
- redirect-to-https
|
||||
|
||||
# Next.js router (handles everything except API and WebSocket paths)
|
||||
next-router:
|
||||
rule: "Host(`{{.DashboardDomain}}`) && !PathPrefix(`/api/v1`)"
|
||||
service: next-service
|
||||
entryPoints:
|
||||
- websecure
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
|
||||
# API router (handles /api/v1 paths)
|
||||
api-router:
|
||||
rule: "Host(`{{.DashboardDomain}}`) && PathPrefix(`/api/v1`)"
|
||||
service: api-service
|
||||
entryPoints:
|
||||
- websecure
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
|
||||
# WebSocket router
|
||||
ws-router:
|
||||
rule: "Host(`{{.DashboardDomain}}`)"
|
||||
service: api-service
|
||||
entryPoints:
|
||||
- websecure
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
|
||||
services:
|
||||
next-service:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://pangolin:{{.NEXT_PORT}}" # Next.js server
|
||||
|
||||
api-service:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://pangolin:{{.EXTERNAL_PORT}}" # API/WebSocket server
|
||||
@@ -1,44 +0,0 @@
|
||||
api:
|
||||
insecure: true
|
||||
dashboard: true
|
||||
|
||||
providers:
|
||||
http:
|
||||
endpoint: "http://pangolin:{{.INTERNAL_PORT}}/api/v1/traefik-config"
|
||||
pollInterval: "5s"
|
||||
file:
|
||||
filename: "/etc/traefik/dynamic_config.yml"
|
||||
|
||||
experimental:
|
||||
plugins:
|
||||
badger:
|
||||
moduleName: "github.com/fosrl/badger"
|
||||
version: "v1.0.0-beta.3"
|
||||
|
||||
log:
|
||||
level: "INFO"
|
||||
format: "common"
|
||||
|
||||
certificatesResolvers:
|
||||
letsencrypt:
|
||||
acme:
|
||||
httpChallenge:
|
||||
entryPoint: web
|
||||
email: "{{.LetsEncryptEmail}}"
|
||||
storage: "/letsencrypt/acme.json"
|
||||
caServer: "https://acme-v02.api.letsencrypt.org/directory"
|
||||
|
||||
entryPoints:
|
||||
web:
|
||||
address: ":80"
|
||||
websecure:
|
||||
address: ":443"
|
||||
transport:
|
||||
respondingTimeouts:
|
||||
readTimeout: "30m"
|
||||
http:
|
||||
tls:
|
||||
certResolver: "letsencrypt"
|
||||
|
||||
serversTransport:
|
||||
insecureSkipVerify: true
|
||||
@@ -41,7 +41,7 @@ gerbil:
|
||||
rate_limits:
|
||||
global:
|
||||
window_minutes: 1
|
||||
max_requests: 100
|
||||
max_requests: 500
|
||||
{{if .EnableEmail}}
|
||||
email:
|
||||
smtp_host: "{{.EmailSMTPHost}}"
|
||||
|
||||
@@ -22,5 +22,9 @@ services:
|
||||
- ./config/crowdsec_logs/syslog:/var/log/syslog:ro # syslog
|
||||
- ./config/crowdsec_logs:/var/log # crowdsec logs
|
||||
- ./config/traefik/logs:/var/log/traefik # traefik logs
|
||||
ports:
|
||||
- 6060:6060 # metrics endpoint for prometheus
|
||||
expose:
|
||||
- 6060 # metrics endpoint for prometheus
|
||||
restart: unless-stopped
|
||||
command: -t # Add test config flag to verify configuration
|
||||
141
install/main.go
141
install/main.go
@@ -2,19 +2,19 @@ package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"time"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"bytes"
|
||||
"text/template"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/term"
|
||||
@@ -48,8 +48,8 @@ type Config struct {
|
||||
EmailSMTPPass string
|
||||
EmailNoReply string
|
||||
InstallGerbil bool
|
||||
TraefikBouncerKey string
|
||||
DoCrowdsecInstall bool
|
||||
TraefikBouncerKey string
|
||||
DoCrowdsecInstall bool
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -84,7 +84,7 @@ func main() {
|
||||
}
|
||||
|
||||
fmt.Println("\n=== Starting installation ===")
|
||||
|
||||
|
||||
if isDockerInstalled() {
|
||||
if readBool(reader, "Would you like to install and start the containers?", true) {
|
||||
pullAndStartContainers()
|
||||
@@ -95,33 +95,35 @@ func main() {
|
||||
}
|
||||
|
||||
if !checkIsCrowdsecInstalledInCompose() {
|
||||
fmt.Println("\n=== Crowdsec Install ===")
|
||||
fmt.Println("\n=== CrowdSec Install ===")
|
||||
// check if crowdsec is installed
|
||||
if readBool(reader, "Would you like to install Crowdsec?", true) {
|
||||
if readBool(reader, "Would you like to install CrowdSec?", false) {
|
||||
fmt.Println("This installer constitutes a minimal viable CrowdSec deployment. CrowdSec will add extra complexity to your Pangolin installation and may not work to the best of its abilities out of the box. Users are expected to implement configuration adjustments on their own to achieve the best security posture. Consult the CrowdSec documentation for detailed configuration instructions.")
|
||||
if readBool(reader, "Are you willing to manage CrowdSec?", false) {
|
||||
if config.DashboardDomain == "" {
|
||||
traefikConfig, err := ReadTraefikConfig("config/traefik/traefik_config.yml", "config/traefik/dynamic_config.yml")
|
||||
if err != nil {
|
||||
fmt.Printf("Error reading config: %v\n", err)
|
||||
return
|
||||
}
|
||||
config.DashboardDomain = traefikConfig.DashboardDomain
|
||||
config.LetsEncryptEmail = traefikConfig.LetsEncryptEmail
|
||||
config.BadgerVersion = traefikConfig.BadgerVersion
|
||||
|
||||
if config.DashboardDomain == "" {
|
||||
traefikConfig, err := ReadTraefikConfig("config/traefik/traefik_config.yml", "config/traefik/dynamic_config.yml")
|
||||
if err != nil {
|
||||
fmt.Printf("Error reading config: %v\n", err)
|
||||
return
|
||||
}
|
||||
config.DashboardDomain = traefikConfig.DashboardDomain
|
||||
config.LetsEncryptEmail = traefikConfig.LetsEncryptEmail
|
||||
config.BadgerVersion = traefikConfig.BadgerVersion
|
||||
|
||||
// print the values and check if they are right
|
||||
fmt.Println("Detected values:")
|
||||
fmt.Printf("Dashboard Domain: %s\n", config.DashboardDomain)
|
||||
fmt.Printf("Let's Encrypt Email: %s\n", config.LetsEncryptEmail)
|
||||
fmt.Printf("Badger Version: %s\n", config.BadgerVersion)
|
||||
// print the values and check if they are right
|
||||
fmt.Println("Detected values:")
|
||||
fmt.Printf("Dashboard Domain: %s\n", config.DashboardDomain)
|
||||
fmt.Printf("Let's Encrypt Email: %s\n", config.LetsEncryptEmail)
|
||||
fmt.Printf("Badger Version: %s\n", config.BadgerVersion)
|
||||
|
||||
if !readBool(reader, "Are these values correct?", true) {
|
||||
config = collectUserInput(reader)
|
||||
if !readBool(reader, "Are these values correct?", true) {
|
||||
config = collectUserInput(reader)
|
||||
}
|
||||
}
|
||||
|
||||
config.DoCrowdsecInstall = true
|
||||
installCrowdsec(config)
|
||||
}
|
||||
|
||||
config.DoCrowdsecInstall = true
|
||||
installCrowdsec(config)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,23 +145,23 @@ func readString(reader *bufio.Reader, prompt string, defaultValue string) string
|
||||
}
|
||||
|
||||
func readPassword(prompt string, reader *bufio.Reader) string {
|
||||
if term.IsTerminal(int(syscall.Stdin)) {
|
||||
fmt.Print(prompt + ": ")
|
||||
// Read password without echo if we're in a terminal
|
||||
password, err := term.ReadPassword(int(syscall.Stdin))
|
||||
fmt.Println() // Add a newline since ReadPassword doesn't add one
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
input := strings.TrimSpace(string(password))
|
||||
if input == "" {
|
||||
return readPassword(prompt, reader)
|
||||
}
|
||||
return input
|
||||
} else {
|
||||
if term.IsTerminal(int(syscall.Stdin)) {
|
||||
fmt.Print(prompt + ": ")
|
||||
// Read password without echo if we're in a terminal
|
||||
password, err := term.ReadPassword(int(syscall.Stdin))
|
||||
fmt.Println() // Add a newline since ReadPassword doesn't add one
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
input := strings.TrimSpace(string(password))
|
||||
if input == "" {
|
||||
return readPassword(prompt, reader)
|
||||
}
|
||||
return input
|
||||
} else {
|
||||
// Fallback to reading from stdin if not in a terminal
|
||||
return readString(reader, prompt, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readBool(reader *bufio.Reader, prompt string, defaultValue bool) bool {
|
||||
@@ -319,15 +321,15 @@ func createConfigFiles(config Config) error {
|
||||
if !config.DoCrowdsecInstall && strings.Contains(path, "crowdsec") {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
if config.DoCrowdsecInstall && !strings.Contains(path, "crowdsec") {
|
||||
return nil
|
||||
}
|
||||
|
||||
// skip .DS_Store
|
||||
if strings.Contains(path, ".DS_Store") {
|
||||
return nil
|
||||
}
|
||||
// skip .DS_Store
|
||||
if strings.Contains(path, ".DS_Store") {
|
||||
return nil
|
||||
}
|
||||
|
||||
if d.IsDir() {
|
||||
// Create directory
|
||||
@@ -376,7 +378,6 @@ func createConfigFiles(config Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
func installDocker() error {
|
||||
// Detect Linux distribution
|
||||
cmd := exec.Command("cat", "/etc/os-release")
|
||||
@@ -654,29 +655,29 @@ func moveFile(src, dst string) error {
|
||||
}
|
||||
|
||||
func waitForContainer(containerName string) error {
|
||||
maxAttempts := 30
|
||||
retryInterval := time.Second * 2
|
||||
maxAttempts := 30
|
||||
retryInterval := time.Second * 2
|
||||
|
||||
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||
// Check if container is running
|
||||
cmd := exec.Command("docker", "container", "inspect", "-f", "{{.State.Running}}", containerName)
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
// If the container doesn't exist or there's another error, wait and retry
|
||||
time.Sleep(retryInterval)
|
||||
continue
|
||||
}
|
||||
for attempt := 0; attempt < maxAttempts; attempt++ {
|
||||
// Check if container is running
|
||||
cmd := exec.Command("docker", "container", "inspect", "-f", "{{.State.Running}}", containerName)
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
|
||||
isRunning := strings.TrimSpace(out.String()) == "true"
|
||||
if isRunning {
|
||||
return nil
|
||||
}
|
||||
if err := cmd.Run(); err != nil {
|
||||
// If the container doesn't exist or there's another error, wait and retry
|
||||
time.Sleep(retryInterval)
|
||||
continue
|
||||
}
|
||||
|
||||
// Container exists but isn't running yet, wait and retry
|
||||
time.Sleep(retryInterval)
|
||||
}
|
||||
isRunning := strings.TrimSpace(out.String()) == "true"
|
||||
if isRunning {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("container %s did not start within %v seconds", containerName, maxAttempts*int(retryInterval.Seconds()))
|
||||
}
|
||||
// Container exists but isn't running yet, wait and retry
|
||||
time.Sleep(retryInterval)
|
||||
}
|
||||
|
||||
return fmt.Errorf("container %s did not start within %v seconds", containerName, maxAttempts*int(retryInterval.Seconds()))
|
||||
}
|
||||
|
||||
291
internationalization/es.md
Normal file
291
internationalization/es.md
Normal file
@@ -0,0 +1,291 @@
|
||||
## Authentication Site
|
||||
|
||||
|
||||
| EN | ES | Notes |
|
||||
| -------------------------------------------------------- | ------------------------------------------------------------ | ---------- |
|
||||
| Powered by [Pangolin](https://github.com/fosrl/pangolin) | Desarrollado por [Pangolin](https://github.com/fosrl/pangolin) | |
|
||||
| Authentication Required | Se requiere autenticación | |
|
||||
| Choose your preferred method to access {resource} | Elije tu método requerido para acceder a {resource} | |
|
||||
| PIN | PIN | |
|
||||
| User | Usuario | |
|
||||
| 6-digit PIN Code | Código PIN de 6 dígitos | pin login |
|
||||
| Login in with PIN | Registrate con PIN | pin login |
|
||||
| Email | Email | user login |
|
||||
| Enter your email | Introduce tu email | user login |
|
||||
| Password | Contraseña | user login |
|
||||
| Enter your password | Introduce tu contraseña | user login |
|
||||
| Forgot your password? | ¿Olvidaste tu contraseña? | user login |
|
||||
| Log in | Iniciar sesión | user login |
|
||||
|
||||
|
||||
## Login site
|
||||
|
||||
| EN | ES | Notes |
|
||||
| --------------------- | ---------------------------------- | ----------- |
|
||||
| Welcome to Pangolin | Binvenido a Pangolin | |
|
||||
| Log in to get started | Registrate para comenzar | |
|
||||
| Email | Email | |
|
||||
| Enter your email | Introduce tu email | placeholder |
|
||||
| Password | Contraseña | |
|
||||
| Enter your password | Introduce tu contraseña | placeholder |
|
||||
| Forgot your password? | ¿Olvidaste tu contraseña? | |
|
||||
| Log in | Iniciar sesión | |
|
||||
|
||||
# Ogranization site after successful login
|
||||
|
||||
| EN | ES | Notes |
|
||||
| ----------------------------------------- | -------------------------------------------- | ----- |
|
||||
| Welcome to Pangolin | Binvenido a Pangolin | |
|
||||
| You're a member of {number} organization. | Eres miembro de la organización {number}. | |
|
||||
|
||||
## Shared Header, Navbar and Footer
|
||||
##### Header
|
||||
|
||||
| EN | ES | Notes |
|
||||
| ------------------- | ------------------- | ----- |
|
||||
| Documentation | Documentación | |
|
||||
| Support | Soporte | |
|
||||
| Organization {name} | Organización {name} | |
|
||||
##### Organization selector
|
||||
|
||||
| EN | ES | Notes |
|
||||
| ---------------- | ----------------- | ----- |
|
||||
| Search… | Buscar… | |
|
||||
| Create | Crear | |
|
||||
| New Organization | Nueva Organización| |
|
||||
| Organizations | Organizaciones | |
|
||||
|
||||
##### Navbar
|
||||
|
||||
| EN | ES | Notes |
|
||||
| --------------- | -----------------------| ----- |
|
||||
| Sites | Sitios | |
|
||||
| Resources | Recursos | |
|
||||
| User & Roles | Usuarios y roles | |
|
||||
| Shareable Links | Enlaces para compartir | |
|
||||
| General | General | |
|
||||
|
||||
##### Footer
|
||||
| EN | ES | |
|
||||
| ------------------------- | --------------------------- | -------|
|
||||
| Page {number} of {number} | Página {number} de {number} | footer |
|
||||
| Rows per page | Filas por página | footer |
|
||||
| Pangolin | Pangolin | footer |
|
||||
| Built by Fossorial | Construido por Fossorial | footer |
|
||||
| Open Source | Código abierto | footer |
|
||||
| Documentation | Documentación | footer |
|
||||
| {version} | {version} | footer |
|
||||
|
||||
## Main “Sites”
|
||||
##### “Hero” section
|
||||
|
||||
| EN | ES | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----- |
|
||||
| Newt (Recommended) | Newt (Recomendado) | |
|
||||
| For the best user experience, use Newt. It uses WireGuard under the hood and allows you to address your private resources by their LAN address on your private network from within the Pangolin dashboard. | Para obtener la mejor experiencia de usuario, utiliza Newt. Utiliza WireGuard internamente y te permite abordar tus recursos privados mediante tu dirección LAN en tu red privada desde el panel de Pangolin. | |
|
||||
| Runs in Docker | Se ejecuta en Docker | |
|
||||
| Runs in shell on macOS, Linux, and Windows | Se ejecuta en shell en macOS, Linux y Windows | |
|
||||
| Install Newt | Instalar Newt | |
|
||||
| Basic WireGuard<br> | WireGuard básico<br> | |
|
||||
| Compatible with all WireGuard clients<br> | Compatible con todos los clientes WireGuard<br> | |
|
||||
| Manual configuration required | Se requiere configuración manual | |
|
||||
|
||||
##### Content
|
||||
|
||||
| EN | ES | Notes |
|
||||
| --------------------------------------------------------- | ------------------------------------------------------------ | -------------------------------- |
|
||||
| Manage Sites | Administrar sitios | |
|
||||
| Allow connectivity to your network through secure tunnels | Permitir la conectividad a tu red a través de túneles seguros| |
|
||||
| Search sites | Buscar sitios | placeholder |
|
||||
| Add Site | Agregar sitio | |
|
||||
| Name | Nombre | table header |
|
||||
| Online | Conectado | table header |
|
||||
| Site | Sitio | table header |
|
||||
| Data In | Datos en | table header |
|
||||
| Data Out | Datos de salida | table header |
|
||||
| Connection Type | Tipo de conexión | table header |
|
||||
| Online | Conectado | site state |
|
||||
| Offline | Desconectado | site state |
|
||||
| Edit → | Editar → | |
|
||||
| View settings | Ver configuración | Popup after clicking “…” on site |
|
||||
| Delete | Borrar | Popup after clicking “…” on site |
|
||||
|
||||
##### Add Site Popup
|
||||
|
||||
| EN | ES | Notes |
|
||||
| ------------------------------------------------------ | ----------------------------------------------------------- | ----------- |
|
||||
| Create Site | Crear sitio | |
|
||||
| Create a new site to start connection for this site | Crear un nuevo sitio para iniciar la conexión para este sitio | |
|
||||
| Name | Nombre | |
|
||||
| Site name | Nombre del sitio | placeholder |
|
||||
| This is the name that will be displayed for this site. | Este es el nombre que se mostrará para este sitio. | desc |
|
||||
| Method | Método | |
|
||||
| Local | Local | |
|
||||
| Newt | Newt | |
|
||||
| WireGuard | WireGuard | |
|
||||
| This is how you will expose connections. | Así es como expondrás las conexiones. | |
|
||||
| You will only be able to see the configuration once. | Solo podrás ver la configuración una vez. | |
|
||||
| Learn how to install Newt on your system | Aprende a instalar Newt en tu sistema | |
|
||||
| I have copied the config | He copiado la configuración | |
|
||||
| Create Site | Crear sitio | |
|
||||
| Close | Cerrar | |
|
||||
|
||||
## Main “Resources”
|
||||
|
||||
##### “Hero” section
|
||||
|
||||
| EN | ES | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----- |
|
||||
| Resources | Recursos | |
|
||||
| Ressourcen sind Proxy-Server für Anwendungen, die in Ihrem privaten Netzwerk laufen. Erstellen Sie eine Ressource für jede HTTP- oder HTTPS-Anwendung in Ihrem privaten Netzwerk. Jede Ressource muss mit einer Website verbunden sein, um eine private und sichere Verbindung über den verschlüsselten WireGuard-Tunnel zu ermöglichen. |Los recursos son servidores proxy para aplicaciones que se ejecutan en su red privada. Cree un recurso para cada aplicación HTTP o HTTPS en su red privada. Cada recurso debe estar conectado a un sitio web para proporcionar una conexión privada y segura a través del túnel cifrado WireGuard. | |
|
||||
| Secure connectivity with WireGuard encryption | Conectividad segura con encriptación WireGuard | |
|
||||
| Configure multiple authentication methods | Configura múltiples métodos de autenticación | |
|
||||
| User and role-based access control | Control de acceso basado en usuarios y roles | |
|
||||
|
||||
##### Content
|
||||
|
||||
| EN | ES | Notes |
|
||||
| -------------------------------------------------- | ---------------------------------------------------------- | -------------------- |
|
||||
| Manage Resources | Administrar recursos | |
|
||||
| Create secure proxies to your private applications | Crea servidores proxy seguros para tus aplicaciones privadas | |
|
||||
| Search resources | Buscar recursos | placeholder |
|
||||
| Name | Nombre | |
|
||||
| Site | Sitio | |
|
||||
| Full URL | URL completa | |
|
||||
| Authentication | Autenticación | |
|
||||
| Not Protected | No protegido | authentication state |
|
||||
| Protected | Protegido | authentication state |
|
||||
| Edit → | Editar → | |
|
||||
| Add Resource | Agregar recurso | |
|
||||
|
||||
##### Add Resource Popup
|
||||
|
||||
| EN | ES | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------- |
|
||||
| Create Resource | Crear recurso | |
|
||||
| Create a new resource to proxy request to your app | Crea un nuevo recurso para enviar solicitudes a tu aplicación | |
|
||||
| Name | Nombre | |
|
||||
| My Resource | Mi recurso | name placeholder |
|
||||
| This is the name that will be displayed for this resource. | Este es el nombre que se mostrará para este recurso. | |
|
||||
| Subdomain | Subdominio | |
|
||||
| Enter subdomain | Ingresar subdominio | |
|
||||
| This is the fully qualified domain name that will be used to access the resource. | Este es el nombre de dominio completo que se utilizará para acceder al recurso. | |
|
||||
| Site | Sitio | |
|
||||
| Search site… | Buscar sitio… | Site selector popup |
|
||||
| This is the site that will be used in the dashboard. | Este es el sitio que se utilizará en el panel de control. | |
|
||||
| Create Resource | Crear recurso | |
|
||||
| Close | Cerrar | |
|
||||
|
||||
## Main “User & Roles”
|
||||
##### Content
|
||||
|
||||
| EN | ES | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----------------------------- |
|
||||
| Manage User & Roles | Administrar usuarios y roles | |
|
||||
| Invite users and add them to roles to manage access to your organization | Invita a usuarios y agrégalos a roles para administrar el acceso a tu organización | |
|
||||
| Users | Usuarios | sidebar item |
|
||||
| Roles | Roles | sidebar item |
|
||||
| **User tab** | **Pestaña de usuario** | |
|
||||
| Search users | Buscar usuarios | placeholder |
|
||||
| Invite User | Invitar usuario | addbutton |
|
||||
| Email | Email | table header |
|
||||
| Status | Estado | table header |
|
||||
| Role | Role | table header |
|
||||
| Confirmed | Confirmado | account status |
|
||||
| Not confirmed (?) | No confirmado (?) | unknown for me account status |
|
||||
| Owner | Dueño | role |
|
||||
| Admin | Administrador | role |
|
||||
| Member | Miembro | role |
|
||||
| **Roles Tab** | **Pestaña Roles** | |
|
||||
| Search roles | Buscar roles | placeholder |
|
||||
| Add Role | Agregar rol | addbutton |
|
||||
| Name | Nombre | table header |
|
||||
| Description | Descripción | table header |
|
||||
| Admin | Administrador | role |
|
||||
| Member | Miembro | role |
|
||||
| Admin role with the most permissions | Rol de administrador con más permisos | admin role desc |
|
||||
| Members can only view resources | Los miembros sólo pueden ver los recursos | member role desc |
|
||||
|
||||
##### Invite User popup
|
||||
|
||||
| EN | ES | Notes |
|
||||
| ----------------- | ------------------------------------------------------- | ----------- |
|
||||
| Invite User | Invitar usuario | |
|
||||
| Email | Email | |
|
||||
| Enter an email | Introduzca un email | placeholder |
|
||||
| Role | Rol | |
|
||||
| Select role | Seleccionar rol | placeholder |
|
||||
| Gültig für | Válido para | |
|
||||
| 1 day | 1 día | |
|
||||
| 2 days | 2 días | |
|
||||
| 3 days | 3 días | |
|
||||
| 4 days | 4 días | |
|
||||
| 5 days | 5 días | |
|
||||
| 6 days | 6 días | |
|
||||
| 7 days | 7 días | |
|
||||
| Create Invitation | Crear invitación | |
|
||||
| Close | Cerrar | |
|
||||
|
||||
|
||||
## Main “Shareable Links”
|
||||
##### “Hero” section
|
||||
|
||||
| EN | ES | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----- |
|
||||
| Shareable Links | Enlaces para compartir | |
|
||||
| Create shareable links to your resources. Links provide temporary or unlimited access to your resource. You can configure the expiration duration of the link when you create one. | Crear enlaces que se puedan compartir a tus recursos. Los enlaces proporcionan acceso temporal o ilimitado a tu recurso. Puedes configurar la duración de caducidad del enlace cuando lo creas. | |
|
||||
| Easy to create and share | Fácil de crear y compartir | |
|
||||
| Configurable expiration duration | Duración de expiración configurable | |
|
||||
| Secure and revocable | Seguro y revocable | |
|
||||
##### Content
|
||||
|
||||
| EN | ES | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----------------- |
|
||||
| Manage Shareable Links | Administrar enlaces compartibles | |
|
||||
| Create shareable links to grant temporary or permanent access to your resources | Crear enlaces compartibles para otorgar acceso temporal o permanente a tus recursos | |
|
||||
| Search links | Buscar enlaces | placeholder |
|
||||
| Create Share Link | Crear enlace para compartir | addbutton |
|
||||
| Resource | Recurso | table header |
|
||||
| Title | Título | table header |
|
||||
| Created | Creado | table header |
|
||||
| Expires | Caduca | table header |
|
||||
| No links. Create one to get started. | No hay enlaces. Crea uno para comenzar. | table placeholder |
|
||||
|
||||
##### Create Shareable Link popup
|
||||
|
||||
| EN | ES | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----------------------- |
|
||||
| Create Shareable Link | Crear un enlace para compartir | |
|
||||
| Anyone with this link can access the resource | Cualquier persona con este enlace puede acceder al recurso. | |
|
||||
| Resource | Recurso | |
|
||||
| Select resource | Seleccionar recurso | |
|
||||
| Search resources… | Buscar recursos… | resource selector popup |
|
||||
| Title (optional) | Título (opcional) | |
|
||||
| Enter title | Introducir título | placeholder |
|
||||
| Expire in | Caduca en | |
|
||||
| Minutes | Minutos | |
|
||||
| Hours | Horas | |
|
||||
| Days | Días | |
|
||||
| Months | Meses | |
|
||||
| Years | Años | |
|
||||
| Never expire | Nunca caduca | |
|
||||
| Expiration time is how long the link will be usable and provide access to the resource. After this time, the link will no longer work, and users who used this link will lose access to the resource. | El tiempo de expiración es el tiempo durante el cual el enlace se podrá utilizar y brindará acceso al recurso. Después de este tiempo, el enlace dejará de funcionar y los usuarios que lo hayan utilizado perderán el acceso al recurso. | |
|
||||
| Create Link | Crear enlace | |
|
||||
| Close | Cerrar | |
|
||||
|
||||
|
||||
## Main “General”
|
||||
|
||||
| EN | ES | Notes |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------ | ------------ |
|
||||
| General | General | |
|
||||
| Configure your organization’s general settings | Configura los ajustes generales de tu organización | |
|
||||
| General | General | sidebar item |
|
||||
| Organization Settings | Configuración de la organización | |
|
||||
| Manage your organization details and configuration | Administra los detalles y la configuración de tu organización| |
|
||||
| Name | Nombre | |
|
||||
| This is the display name of the org | Este es el nombre para mostrar de la organización. | |
|
||||
| Save Settings | Guardar configuración | |
|
||||
| Danger Zone | Zona de peligro | |
|
||||
| Once you delete this org, there is no going back. Please be certain. | Una vez que elimines esta organización, no habrá vuelta atrás. Asegúrate de hacerlo. | |
|
||||
| Delete Organization Data | Eliminar datos de la organización | |
|
||||
@@ -2,7 +2,8 @@
|
||||
const nextConfig = {
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
}
|
||||
},
|
||||
output: "standalone"
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
17119
package-lock.json
generated
Normal file
17119
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -57,6 +57,7 @@
|
||||
"glob": "11.0.0",
|
||||
"helmet": "8.0.0",
|
||||
"http-errors": "2.0.0",
|
||||
"i": "^0.3.7",
|
||||
"input-otp": "1.4.1",
|
||||
"js-yaml": "4.1.0",
|
||||
"lucide-react": "0.469.0",
|
||||
@@ -66,12 +67,14 @@
|
||||
"node-cache": "5.1.2",
|
||||
"node-fetch": "3.3.2",
|
||||
"nodemailer": "6.9.16",
|
||||
"npm": "^11.2.0",
|
||||
"oslo": "1.2.1",
|
||||
"qrcode.react": "4.2.0",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react-easy-sort": "^1.6.0",
|
||||
"react-hook-form": "7.54.2",
|
||||
"react-icons": "^5.5.0",
|
||||
"rebuild": "0.1.2",
|
||||
"semver": "7.6.3",
|
||||
"tailwind-merge": "2.6.0",
|
||||
|
||||
@@ -170,16 +170,17 @@ export function serializeResourceSessionCookie(
|
||||
isHttp: boolean = false,
|
||||
expiresAt?: Date
|
||||
): string {
|
||||
const now = new Date().getTime();
|
||||
if (!isHttp) {
|
||||
if (expiresAt === undefined) {
|
||||
return `${cookieName}_s=${token}; HttpOnly; SameSite=Lax; Path=/; Secure; Domain=${"." + domain}`;
|
||||
return `${cookieName}_s.${now}=${token}; HttpOnly; SameSite=Lax; Path=/; Secure; Domain=${"." + domain}`;
|
||||
}
|
||||
return `${cookieName}_s=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Secure; Domain=${"." + domain}`;
|
||||
return `${cookieName}_s.${now}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Secure; Domain=${"." + domain}`;
|
||||
} else {
|
||||
if (expiresAt === undefined) {
|
||||
return `${cookieName}=${token}; HttpOnly; SameSite=Lax; Path=/; Domain=${"." + domain}`;
|
||||
return `${cookieName}.${now}=${token}; HttpOnly; SameSite=Lax; Path=/; Domain=${"." + domain}`;
|
||||
}
|
||||
return `${cookieName}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Domain=${"." + domain}`;
|
||||
return `${cookieName}.${now}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Domain=${"." + domain}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -405,6 +405,15 @@ export const resourceRules = sqliteTable("resourceRules", {
|
||||
value: text("value").notNull()
|
||||
});
|
||||
|
||||
export const supporterKey = sqliteTable("supporterKey", {
|
||||
keyId: integer("keyId").primaryKey({ autoIncrement: true }),
|
||||
key: text("key").notNull(),
|
||||
githubUsername: text("githubUsername").notNull(),
|
||||
phrase: text("phrase"),
|
||||
tier: text("tier"),
|
||||
valid: integer("valid", { mode: "boolean" }).notNull().default(false)
|
||||
});
|
||||
|
||||
export type Org = InferSelectModel<typeof orgs>;
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
export type Site = InferSelectModel<typeof sites>;
|
||||
@@ -439,3 +448,4 @@ export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;
|
||||
export type VersionMigration = InferSelectModel<typeof versionMigrations>;
|
||||
export type ResourceRule = InferSelectModel<typeof resourceRules>;
|
||||
export type Domain = InferSelectModel<typeof domains>;
|
||||
export type SupporterKey = InferSelectModel<typeof supporterKey>;
|
||||
|
||||
@@ -1,25 +1,21 @@
|
||||
import fs from "fs";
|
||||
import yaml from "js-yaml";
|
||||
import path from "path";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import {
|
||||
__DIRNAME,
|
||||
APP_PATH,
|
||||
APP_VERSION,
|
||||
configFilePath1,
|
||||
configFilePath2
|
||||
} from "@server/lib/consts";
|
||||
import { passwordSchema } from "@server/auth/passwordSchema";
|
||||
import stoi from "./stoi";
|
||||
import db from "@server/db";
|
||||
import { SupporterKey, supporterKey } from "@server/db/schema";
|
||||
import { suppressDeprecationWarnings } from "moment";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
const portSchema = z.number().positive().gt(0).lte(65535);
|
||||
// const hostnameSchema = z
|
||||
// .string()
|
||||
// .regex(
|
||||
// /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)+([A-Za-z]|[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9])$/
|
||||
// )
|
||||
// .or(z.literal("localhost"));
|
||||
|
||||
const getEnvOrYaml = (envVar: string) => (valFromYaml: any) => {
|
||||
return process.env[envVar] ?? valFromYaml;
|
||||
@@ -31,7 +27,6 @@ const configSchema = z.object({
|
||||
.string()
|
||||
.url()
|
||||
.optional()
|
||||
.transform(getEnvOrYaml("APP_DASHBOARDURL"))
|
||||
.pipe(z.string().url())
|
||||
.transform((url) => url.toLowerCase()),
|
||||
log_level: z.enum(["debug", "info", "warn", "error"]),
|
||||
@@ -63,37 +58,11 @@ const configSchema = z.object({
|
||||
{
|
||||
message: "At least one domain must be defined"
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(domains) => {
|
||||
const envBaseDomain = process.env.APP_BASE_DOMAIN;
|
||||
|
||||
if (envBaseDomain) {
|
||||
return z.string().nonempty().safeParse(envBaseDomain).success;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "APP_BASE_DOMAIN must be a valid hostname"
|
||||
}
|
||||
),
|
||||
server: z.object({
|
||||
external_port: portSchema
|
||||
.optional()
|
||||
.transform(getEnvOrYaml("SERVER_EXTERNALPORT"))
|
||||
.transform(stoi)
|
||||
.pipe(portSchema),
|
||||
internal_port: portSchema
|
||||
.optional()
|
||||
.transform(getEnvOrYaml("SERVER_INTERNALPORT"))
|
||||
.transform(stoi)
|
||||
.pipe(portSchema),
|
||||
next_port: portSchema
|
||||
.optional()
|
||||
.transform(getEnvOrYaml("SERVER_NEXTPORT"))
|
||||
.transform(stoi)
|
||||
.pipe(portSchema),
|
||||
external_port: portSchema.optional().transform(stoi).pipe(portSchema),
|
||||
internal_port: portSchema.optional().transform(stoi).pipe(portSchema),
|
||||
next_port: portSchema.optional().transform(stoi).pipe(portSchema),
|
||||
internal_hostname: z.string().transform((url) => url.toLowerCase()),
|
||||
session_cookie_name: z.string(),
|
||||
resource_access_token_param: z.string(),
|
||||
@@ -126,15 +95,10 @@ const configSchema = z.object({
|
||||
additional_middlewares: z.array(z.string()).optional()
|
||||
}),
|
||||
gerbil: z.object({
|
||||
start_port: portSchema
|
||||
.optional()
|
||||
.transform(getEnvOrYaml("GERBIL_STARTPORT"))
|
||||
.transform(stoi)
|
||||
.pipe(portSchema),
|
||||
start_port: portSchema.optional().transform(stoi).pipe(portSchema),
|
||||
base_endpoint: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform(getEnvOrYaml("GERBIL_BASEENDPOINT"))
|
||||
.pipe(z.string())
|
||||
.transform((url) => url.toLowerCase()),
|
||||
use_subdomain: z.boolean(),
|
||||
@@ -195,12 +159,12 @@ const configSchema = z.object({
|
||||
export class Config {
|
||||
private rawConfig!: z.infer<typeof configSchema>;
|
||||
|
||||
supporterData: SupporterKey | null = null;
|
||||
|
||||
supporterHiddenUntil: number | null = null;
|
||||
|
||||
constructor() {
|
||||
this.loadConfig();
|
||||
|
||||
if (process.env.GENERATE_TRAEFIK_CONFIG === "true") {
|
||||
this.createTraefikConfig();
|
||||
}
|
||||
}
|
||||
|
||||
public loadConfig() {
|
||||
@@ -225,45 +189,17 @@ export class Config {
|
||||
} else if (fs.existsSync(configFilePath2)) {
|
||||
environment = loadConfig(configFilePath2);
|
||||
}
|
||||
if (!environment) {
|
||||
const exampleConfigPath = path.join(
|
||||
__DIRNAME,
|
||||
"config.example.yml"
|
||||
|
||||
if (process.env.APP_BASE_DOMAIN) {
|
||||
console.log(
|
||||
"You're using deprecated environment variables. Transition to the configuration file. https://docs.fossorial.io/"
|
||||
);
|
||||
if (fs.existsSync(exampleConfigPath)) {
|
||||
try {
|
||||
const exampleConfigContent = fs.readFileSync(
|
||||
exampleConfigPath,
|
||||
"utf8"
|
||||
);
|
||||
fs.writeFileSync(
|
||||
configFilePath1,
|
||||
exampleConfigContent,
|
||||
"utf8"
|
||||
);
|
||||
environment = loadConfig(configFilePath1);
|
||||
} catch (error) {
|
||||
console.log(
|
||||
"See the docs for information about what to include in the configuration file: https://docs.fossorial.io/Pangolin/Configuration/config"
|
||||
);
|
||||
if (error instanceof Error) {
|
||||
throw new Error(
|
||||
`Error creating configuration file from example: ${
|
||||
error.message
|
||||
}`
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
"No configuration file found and no example configuration available"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!environment) {
|
||||
throw new Error("No configuration file found");
|
||||
throw new Error(
|
||||
"No configuration file found. Please create one. https://docs.fossorial.io/"
|
||||
);
|
||||
}
|
||||
|
||||
const parsedConfig = configSchema.safeParse(environment);
|
||||
@@ -309,16 +245,7 @@ export class Config {
|
||||
: "false";
|
||||
process.env.DASHBOARD_URL = parsedConfig.data.app.dashboard_url;
|
||||
|
||||
if (process.env.APP_BASE_DOMAIN) {
|
||||
console.log(
|
||||
`DEPRECATED! APP_BASE_DOMAIN is deprecated and will be removed in a future release. Use the domains section in the configuration file instead. See https://docs.fossorial.io/Pangolin/Configuration/config for more information.`
|
||||
);
|
||||
|
||||
parsedConfig.data.domains.domain1 = {
|
||||
base_domain: process.env.APP_BASE_DOMAIN,
|
||||
cert_resolver: "letsencrypt"
|
||||
};
|
||||
}
|
||||
this.checkSupporterKey();
|
||||
|
||||
this.rawConfig = parsedConfig.data;
|
||||
}
|
||||
@@ -337,71 +264,89 @@ export class Config {
|
||||
return this.rawConfig.domains[domainId];
|
||||
}
|
||||
|
||||
private createTraefikConfig() {
|
||||
public hideSupporterKey(days: number = 7) {
|
||||
const now = new Date().getTime();
|
||||
|
||||
if (this.supporterHiddenUntil && now < this.supporterHiddenUntil) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.supporterHiddenUntil = now + 1000 * 60 * 60 * 24 * days;
|
||||
}
|
||||
|
||||
public isSupporterKeyHidden() {
|
||||
const now = new Date().getTime();
|
||||
|
||||
if (this.supporterHiddenUntil && now < this.supporterHiddenUntil) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async checkSupporterKey() {
|
||||
const [key] = await db.select().from(supporterKey).limit(1);
|
||||
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { key: licenseKey, githubUsername } = key;
|
||||
|
||||
try {
|
||||
// check if traefik_config.yml and dynamic_config.yml exists in APP_PATH/traefik
|
||||
const defaultTraefikConfigPath = path.join(
|
||||
__DIRNAME,
|
||||
"traefik_config.example.yml"
|
||||
);
|
||||
const defaultDynamicConfigPath = path.join(
|
||||
__DIRNAME,
|
||||
"dynamic_config.example.yml"
|
||||
const response = await fetch(
|
||||
"https://api.dev.fossorial.io/api/v1/license/validate",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
licenseKey,
|
||||
githubUsername
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
const traefikPath = path.join(APP_PATH, "traefik");
|
||||
if (!fs.existsSync(traefikPath)) {
|
||||
if (!response.ok) {
|
||||
this.supporterData = key;
|
||||
return;
|
||||
}
|
||||
|
||||
// load default configs
|
||||
let traefikConfig = fs.readFileSync(
|
||||
defaultTraefikConfigPath,
|
||||
"utf8"
|
||||
);
|
||||
let dynamicConfig = fs.readFileSync(
|
||||
defaultDynamicConfigPath,
|
||||
"utf8"
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
traefikConfig = traefikConfig
|
||||
.split("{{.LetsEncryptEmail}}")
|
||||
.join(this.rawConfig.users.server_admin.email);
|
||||
traefikConfig = traefikConfig
|
||||
.split("{{.INTERNAL_PORT}}")
|
||||
.join(this.rawConfig.server.internal_port.toString());
|
||||
if (!data.data.valid) {
|
||||
this.supporterData = {
|
||||
...key,
|
||||
valid: false
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
dynamicConfig = dynamicConfig
|
||||
.split("{{.DashboardDomain}}")
|
||||
.join(new URL(this.rawConfig.app.dashboard_url).hostname);
|
||||
dynamicConfig = dynamicConfig
|
||||
.split("{{.NEXT_PORT}}")
|
||||
.join(this.rawConfig.server.next_port.toString());
|
||||
dynamicConfig = dynamicConfig
|
||||
.split("{{.EXTERNAL_PORT}}")
|
||||
.join(this.rawConfig.server.external_port.toString());
|
||||
this.supporterData = {
|
||||
...key,
|
||||
tier: data.data.tier,
|
||||
valid: true
|
||||
};
|
||||
|
||||
// write thiese to the traefik directory
|
||||
const traefikConfigPath = path.join(
|
||||
traefikPath,
|
||||
"traefik_config.yml"
|
||||
);
|
||||
const dynamicConfigPath = path.join(
|
||||
traefikPath,
|
||||
"dynamic_config.yml"
|
||||
);
|
||||
|
||||
fs.writeFileSync(traefikConfigPath, traefikConfig, "utf8");
|
||||
fs.writeFileSync(dynamicConfigPath, dynamicConfig, "utf8");
|
||||
|
||||
console.log("Traefik configuration files created");
|
||||
// update the supporter key in the database
|
||||
await db
|
||||
.update(supporterKey)
|
||||
.set({
|
||||
tier: data.data.tier || null,
|
||||
phrase: data.data.cutePhrase || null,
|
||||
valid: true
|
||||
})
|
||||
.where(eq(supporterKey.keyId, key.keyId));
|
||||
} catch (e) {
|
||||
console.log(
|
||||
"Failed to generate the Traefik configuration files. Please create them manually."
|
||||
);
|
||||
console.error(e);
|
||||
this.supporterData = key;
|
||||
console.error("Failed to validate supporter key", e);
|
||||
}
|
||||
}
|
||||
|
||||
public getSupporterData() {
|
||||
return this.supporterData;
|
||||
}
|
||||
}
|
||||
|
||||
export const config = new Config();
|
||||
|
||||
@@ -2,7 +2,7 @@ import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
// This is a placeholder value replaced by the build process
|
||||
export const APP_VERSION = "1.0.0";
|
||||
export const APP_VERSION = "1.1.0";
|
||||
|
||||
export const __FILENAME = fileURLToPath(import.meta.url);
|
||||
export const __DIRNAME = path.dirname(__FILENAME);
|
||||
|
||||
@@ -1,61 +1,5 @@
|
||||
import { cidrToRange, findNextAvailableCidr } from "./ip";
|
||||
|
||||
/**
|
||||
* Compares two objects for deep equality
|
||||
* @param actual The actual value to test
|
||||
* @param expected The expected value to compare against
|
||||
* @param message The message to display if assertion fails
|
||||
* @throws Error if objects are not equal
|
||||
*/
|
||||
export function assertEqualsObj<T>(actual: T, expected: T, message: string): void {
|
||||
const actualStr = JSON.stringify(actual);
|
||||
const expectedStr = JSON.stringify(expected);
|
||||
if (actualStr !== expectedStr) {
|
||||
throw new Error(`${message}\nExpected: ${expectedStr}\nActual: ${actualStr}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares two primitive values for equality
|
||||
* @param actual The actual value to test
|
||||
* @param expected The expected value to compare against
|
||||
* @param message The message to display if assertion fails
|
||||
* @throws Error if values are not equal
|
||||
*/
|
||||
export function assertEquals<T>(actual: T, expected: T, message: string): void {
|
||||
if (actual !== expected) {
|
||||
throw new Error(`${message}\nExpected: ${expected}\nActual: ${actual}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests if a function throws an expected error
|
||||
* @param fn The function to test
|
||||
* @param expectedError The expected error message or part of it
|
||||
* @param message The message to display if assertion fails
|
||||
* @throws Error if function doesn't throw or throws unexpected error
|
||||
*/
|
||||
export function assertThrows(
|
||||
fn: () => void,
|
||||
expectedError: string,
|
||||
message: string
|
||||
): void {
|
||||
try {
|
||||
fn();
|
||||
throw new Error(`${message}: Expected to throw "${expectedError}"`);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error)) {
|
||||
throw new Error(`${message}\nUnexpected error type: ${typeof error}`);
|
||||
}
|
||||
|
||||
if (!error.message.includes(expectedError)) {
|
||||
throw new Error(
|
||||
`${message}\nExpected error: ${expectedError}\nActual error: ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
import { assertEquals } from "@test/assert";
|
||||
|
||||
// Test cases
|
||||
function testFindNextAvailableCidr() {
|
||||
|
||||
71
server/lib/validators.test.ts
Normal file
71
server/lib/validators.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { isValidUrlGlobPattern } from "./validators";
|
||||
import { assertEquals } from "@test/assert";
|
||||
|
||||
function runTests() {
|
||||
console.log('Running URL pattern validation tests...');
|
||||
|
||||
// Test valid patterns
|
||||
assertEquals(isValidUrlGlobPattern('simple'), true, 'Simple path segment should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('simple/path'), true, 'Simple path with slash should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('/leading/slash'), true, 'Path with leading slash should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('path/'), true, 'Path with trailing slash should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('path/*'), true, 'Path with wildcard segment should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('*'), true, 'Single wildcard should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('*/subpath'), true, 'Wildcard with subpath should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('path/*/more'), true, 'Path with wildcard in the middle should be valid');
|
||||
|
||||
// Test with special characters
|
||||
assertEquals(isValidUrlGlobPattern('path-with-dash'), true, 'Path with dash should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('path_with_underscore'), true, 'Path with underscore should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('path.with.dots'), true, 'Path with dots should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('path~with~tilde'), true, 'Path with tilde should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('path!with!exclamation'), true, 'Path with exclamation should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('path$with$dollar'), true, 'Path with dollar should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('path&with&ersand'), true, 'Path with ampersand should be valid');
|
||||
assertEquals(isValidUrlGlobPattern("path'with'quote"), true, "Path with quote should be valid");
|
||||
assertEquals(isValidUrlGlobPattern('path(with)parentheses'), true, 'Path with parentheses should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('path+with+plus'), true, 'Path with plus should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('path,with,comma'), true, 'Path with comma should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('path;with;semicolon'), true, 'Path with semicolon should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('path=with=equals'), true, 'Path with equals should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('path:with:colon'), true, 'Path with colon should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('path@with@at'), true, 'Path with at should be valid');
|
||||
|
||||
// Test with percent encoding
|
||||
assertEquals(isValidUrlGlobPattern('path%20with%20spaces'), true, 'Path with percent-encoded spaces should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('path%2Fwith%2Fencoded%2Fslashes'), true, 'Path with percent-encoded slashes should be valid');
|
||||
|
||||
// Test with wildcards in segments (the fixed functionality)
|
||||
assertEquals(isValidUrlGlobPattern('padbootstrap*'), true, 'Path with wildcard at the end of segment should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('pad*bootstrap'), true, 'Path with wildcard in the middle of segment should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('*bootstrap'), true, 'Path with wildcard at the start of segment should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('multiple*wildcards*in*segment'), true, 'Path with multiple wildcards in segment should be valid');
|
||||
assertEquals(isValidUrlGlobPattern('wild*/cards/in*/different/seg*ments'), true, 'Path with wildcards in different segments should be valid');
|
||||
|
||||
// Test invalid patterns
|
||||
assertEquals(isValidUrlGlobPattern(''), false, 'Empty string should be invalid');
|
||||
assertEquals(isValidUrlGlobPattern('//double/slash'), false, 'Path with double slash should be invalid');
|
||||
assertEquals(isValidUrlGlobPattern('path//end'), false, 'Path with double slash in the middle should be invalid');
|
||||
assertEquals(isValidUrlGlobPattern('invalid<char>'), false, 'Path with invalid characters should be invalid');
|
||||
assertEquals(isValidUrlGlobPattern('invalid|char'), false, 'Path with invalid pipe character should be invalid');
|
||||
assertEquals(isValidUrlGlobPattern('invalid"char'), false, 'Path with invalid quote character should be invalid');
|
||||
assertEquals(isValidUrlGlobPattern('invalid`char'), false, 'Path with invalid backtick character should be invalid');
|
||||
assertEquals(isValidUrlGlobPattern('invalid^char'), false, 'Path with invalid caret character should be invalid');
|
||||
assertEquals(isValidUrlGlobPattern('invalid\\char'), false, 'Path with invalid backslash character should be invalid');
|
||||
assertEquals(isValidUrlGlobPattern('invalid[char]'), false, 'Path with invalid square brackets should be invalid');
|
||||
assertEquals(isValidUrlGlobPattern('invalid{char}'), false, 'Path with invalid curly braces should be invalid');
|
||||
|
||||
// Test invalid percent encoding
|
||||
assertEquals(isValidUrlGlobPattern('invalid%2'), false, 'Path with incomplete percent encoding should be invalid');
|
||||
assertEquals(isValidUrlGlobPattern('invalid%GZ'), false, 'Path with invalid hex in percent encoding should be invalid');
|
||||
assertEquals(isValidUrlGlobPattern('invalid%'), false, 'Path with isolated percent sign should be invalid');
|
||||
|
||||
console.log('All tests passed!');
|
||||
}
|
||||
|
||||
// Run all tests
|
||||
try {
|
||||
runTests();
|
||||
} catch (error) {
|
||||
console.error('Test failed:', error);
|
||||
}
|
||||
@@ -29,11 +29,6 @@ export function isValidUrlGlobPattern(pattern: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If segment contains *, it must be exactly *
|
||||
if (segment.includes("*") && segment !== "*") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check each character in the segment
|
||||
for (let j = 0; j < segment.length; j++) {
|
||||
const char = segment[j];
|
||||
|
||||
@@ -14,3 +14,4 @@ export * from "./verifyAdmin";
|
||||
export * from "./verifySetResourceUsers";
|
||||
export * from "./verifyUserInRole";
|
||||
export * from "./verifyAccessTokenAccess";
|
||||
export * from "./verifyUserIsServerAdmin";
|
||||
37
server/middlewares/verifyUserIsServerAdmin.ts
Normal file
37
server/middlewares/verifyUserIsServerAdmin.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
|
||||
export async function verifyUserIsServerAdmin(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
const userId = req.user!.userId;
|
||||
|
||||
if (!userId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
if (!req.user?.serverAdmin) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"User is not a server admin"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return next();
|
||||
} catch (e) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Error verifying organization access"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
resources,
|
||||
userResources,
|
||||
roleResources,
|
||||
resourceAccessToken
|
||||
resourceAccessToken,
|
||||
sites
|
||||
} from "@server/db/schema";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
@@ -59,7 +60,8 @@ function queryAccessTokens(
|
||||
title: resourceAccessToken.title,
|
||||
description: resourceAccessToken.description,
|
||||
createdAt: resourceAccessToken.createdAt,
|
||||
resourceName: resources.name
|
||||
resourceName: resources.name,
|
||||
siteName: sites.name
|
||||
};
|
||||
|
||||
if (orgId) {
|
||||
@@ -70,6 +72,10 @@ function queryAccessTokens(
|
||||
resources,
|
||||
eq(resourceAccessToken.resourceId, resources.resourceId)
|
||||
)
|
||||
.leftJoin(
|
||||
sites,
|
||||
eq(resources.resourceId, sites.siteId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
inArray(
|
||||
@@ -91,6 +97,10 @@ function queryAccessTokens(
|
||||
resources,
|
||||
eq(resourceAccessToken.resourceId, resources.resourceId)
|
||||
)
|
||||
.leftJoin(
|
||||
sites,
|
||||
eq(resources.resourceId, sites.siteId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
inArray(
|
||||
|
||||
@@ -78,7 +78,7 @@ export async function login(
|
||||
}
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
HttpCode.UNAUTHORIZED,
|
||||
"Username or password is incorrect"
|
||||
)
|
||||
);
|
||||
@@ -98,7 +98,7 @@ export async function login(
|
||||
}
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
HttpCode.UNAUTHORIZED,
|
||||
"Username or password is incorrect"
|
||||
)
|
||||
);
|
||||
@@ -129,7 +129,7 @@ export async function login(
|
||||
}
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
HttpCode.UNAUTHORIZED,
|
||||
"The two-factor code you entered is incorrect"
|
||||
)
|
||||
);
|
||||
|
||||
67
server/routers/badger/verifySession.test.ts
Normal file
67
server/routers/badger/verifySession.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { isPathAllowed } from './verifySession';
|
||||
import { assertEquals } from '@test/assert';
|
||||
|
||||
function runTests() {
|
||||
console.log('Running path matching tests...');
|
||||
|
||||
// Test exact matching
|
||||
assertEquals(isPathAllowed('foo', 'foo'), true, 'Exact match should be allowed');
|
||||
assertEquals(isPathAllowed('foo', 'bar'), false, 'Different segments should not match');
|
||||
assertEquals(isPathAllowed('foo/bar', 'foo/bar'), true, 'Exact multi-segment match should be allowed');
|
||||
assertEquals(isPathAllowed('foo/bar', 'foo/baz'), false, 'Partial multi-segment match should not be allowed');
|
||||
|
||||
// Test with leading and trailing slashes
|
||||
assertEquals(isPathAllowed('/foo', 'foo'), true, 'Pattern with leading slash should match');
|
||||
assertEquals(isPathAllowed('foo/', 'foo'), true, 'Pattern with trailing slash should match');
|
||||
assertEquals(isPathAllowed('/foo/', 'foo'), true, 'Pattern with both leading and trailing slashes should match');
|
||||
assertEquals(isPathAllowed('foo', '/foo/'), true, 'Path with leading and trailing slashes should match');
|
||||
|
||||
// Test simple wildcard matching
|
||||
assertEquals(isPathAllowed('*', 'foo'), true, 'Single wildcard should match any single segment');
|
||||
assertEquals(isPathAllowed('*', 'foo/bar'), true, 'Single wildcard should match multiple segments');
|
||||
assertEquals(isPathAllowed('*/bar', 'foo/bar'), true, 'Wildcard prefix should match');
|
||||
assertEquals(isPathAllowed('foo/*', 'foo/bar'), true, 'Wildcard suffix should match');
|
||||
assertEquals(isPathAllowed('foo/*/baz', 'foo/bar/baz'), true, 'Wildcard in middle should match');
|
||||
|
||||
// Test multiple wildcards
|
||||
assertEquals(isPathAllowed('*/*', 'foo/bar'), true, 'Multiple wildcards should match corresponding segments');
|
||||
assertEquals(isPathAllowed('*/*/*', 'foo/bar/baz'), true, 'Three wildcards should match three segments');
|
||||
assertEquals(isPathAllowed('foo/*/*', 'foo/bar/baz'), true, 'Specific prefix with wildcards should match');
|
||||
assertEquals(isPathAllowed('*/*/baz', 'foo/bar/baz'), true, 'Wildcards with specific suffix should match');
|
||||
|
||||
// Test wildcard consumption behavior
|
||||
assertEquals(isPathAllowed('*', ''), true, 'Wildcard should optionally consume segments');
|
||||
assertEquals(isPathAllowed('foo/*', 'foo'), true, 'Trailing wildcard should be optional');
|
||||
assertEquals(isPathAllowed('*/*', 'foo'), true, 'Multiple wildcards can match fewer segments');
|
||||
assertEquals(isPathAllowed('*/*/*', 'foo/bar'), true, 'Extra wildcards can be skipped');
|
||||
|
||||
// Test complex nested paths
|
||||
assertEquals(isPathAllowed('api/*/users', 'api/v1/users'), true, 'API versioning pattern should match');
|
||||
assertEquals(isPathAllowed('api/*/users/*', 'api/v1/users/123'), true, 'API resource pattern should match');
|
||||
assertEquals(isPathAllowed('api/*/users/*/profile', 'api/v1/users/123/profile'), true, 'Nested API pattern should match');
|
||||
|
||||
// Test for the requested padbootstrap* pattern
|
||||
assertEquals(isPathAllowed('padbootstrap*', 'padbootstrap'), true, 'padbootstrap* should match padbootstrap');
|
||||
assertEquals(isPathAllowed('padbootstrap*', 'padbootstrapv1'), true, 'padbootstrap* should match padbootstrapv1');
|
||||
assertEquals(isPathAllowed('padbootstrap*', 'padbootstrap/files'), false, 'padbootstrap* should not match padbootstrap/files');
|
||||
assertEquals(isPathAllowed('padbootstrap*/*', 'padbootstrap/files'), true, 'padbootstrap*/* should match padbootstrap/files');
|
||||
assertEquals(isPathAllowed('padbootstrap*/files', 'padbootstrapv1/files'), true, 'padbootstrap*/files should not match padbootstrapv1/files (wildcard is segment-based, not partial)');
|
||||
|
||||
// Test wildcard edge cases
|
||||
assertEquals(isPathAllowed('*/*/*/*/*/*', 'a/b'), true, 'Many wildcards can match few segments');
|
||||
assertEquals(isPathAllowed('a/*/b/*/c', 'a/anything/b/something/c'), true, 'Multiple wildcards in pattern should match corresponding segments');
|
||||
|
||||
// Test patterns with partial segment matches
|
||||
assertEquals(isPathAllowed('padbootstrap*', 'padbootstrap-123'), true, 'Wildcards in isPathAllowed should be segment-based, not character-based');
|
||||
assertEquals(isPathAllowed('test*', 'testuser'), true, 'Asterisk as part of segment name is treated as a literal, not a wildcard');
|
||||
assertEquals(isPathAllowed('my*app', 'myapp'), true, 'Asterisk in middle of segment name is treated as a literal, not a wildcard');
|
||||
|
||||
console.log('All tests passed!');
|
||||
}
|
||||
|
||||
// Run all tests
|
||||
try {
|
||||
runTests();
|
||||
} catch (error) {
|
||||
console.error('Test failed:', error);
|
||||
}
|
||||
@@ -229,12 +229,10 @@ export async function verifyResourceSession(
|
||||
return notAllowed(res);
|
||||
}
|
||||
|
||||
const resourceSessionToken =
|
||||
sessions[
|
||||
`${config.getRawConfig().server.session_cookie_name}${
|
||||
resource.ssl ? "_s" : ""
|
||||
}`
|
||||
];
|
||||
const resourceSessionToken = extractResourceSessionToken(
|
||||
sessions,
|
||||
resource.ssl
|
||||
);
|
||||
|
||||
if (resourceSessionToken) {
|
||||
const sessionCacheKey = `session:${resourceSessionToken}`;
|
||||
@@ -354,6 +352,50 @@ export async function verifyResourceSession(
|
||||
}
|
||||
}
|
||||
|
||||
function extractResourceSessionToken(
|
||||
sessions: Record<string, string>,
|
||||
ssl: boolean
|
||||
) {
|
||||
const prefix = `${config.getRawConfig().server.session_cookie_name}${
|
||||
ssl ? "_s" : ""
|
||||
}`;
|
||||
|
||||
const all: { cookieName: string; token: string; priority: number }[] =
|
||||
[];
|
||||
|
||||
for (const [key, value] of Object.entries(sessions)) {
|
||||
const parts = key.split(".");
|
||||
const timestamp = parts[parts.length - 1];
|
||||
|
||||
// check if string is only numbers
|
||||
if (!/^\d+$/.test(timestamp)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// cookie name is the key without the timestamp
|
||||
const cookieName = key.slice(0, -timestamp.length - 1);
|
||||
|
||||
if (cookieName === prefix) {
|
||||
all.push({
|
||||
cookieName,
|
||||
token: value,
|
||||
priority: parseInt(timestamp)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// sort by priority in desc order
|
||||
all.sort((a, b) => b.priority - a.priority);
|
||||
|
||||
const latest = all[0];
|
||||
|
||||
if (!latest) {
|
||||
return;
|
||||
}
|
||||
|
||||
return latest.token;
|
||||
}
|
||||
|
||||
function notAllowed(res: Response, redirectUrl?: string) {
|
||||
const data = {
|
||||
data: { valid: false, redirectUrl },
|
||||
@@ -534,7 +576,7 @@ async function checkRules(
|
||||
return;
|
||||
}
|
||||
|
||||
function isPathAllowed(pattern: string, path: string): boolean {
|
||||
export function isPathAllowed(pattern: string, path: string): boolean {
|
||||
logger.debug(`\nMatching path "${path}" against pattern "${pattern}"`);
|
||||
|
||||
// Normalize and split paths into segments
|
||||
@@ -575,7 +617,7 @@ function isPathAllowed(pattern: string, path: string): boolean {
|
||||
return result;
|
||||
}
|
||||
|
||||
// For wildcards, try consuming different numbers of path segments
|
||||
// For full segment wildcards, try consuming different numbers of path segments
|
||||
if (currentPatternPart === "*") {
|
||||
logger.debug(
|
||||
`${indent}Found wildcard at pattern index ${patternIndex}`
|
||||
@@ -607,6 +649,32 @@ function isPathAllowed(pattern: string, path: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for in-segment wildcard (e.g., "prefix*" or "prefix*suffix")
|
||||
if (currentPatternPart.includes("*")) {
|
||||
logger.debug(
|
||||
`${indent}Found in-segment wildcard in "${currentPatternPart}"`
|
||||
);
|
||||
|
||||
// Convert the pattern segment to a regex pattern
|
||||
const regexPattern = currentPatternPart
|
||||
.replace(/\*/g, ".*") // Replace * with .* for regex wildcard
|
||||
.replace(/\?/g, "."); // Replace ? with . for single character wildcard if needed
|
||||
|
||||
const regex = new RegExp(`^${regexPattern}$`);
|
||||
|
||||
if (regex.test(currentPathPart)) {
|
||||
logger.debug(
|
||||
`${indent}Segment with wildcard matches: "${currentPatternPart}" matches "${currentPathPart}"`
|
||||
);
|
||||
return matchSegments(patternIndex + 1, pathIndex + 1);
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`${indent}Segment with wildcard mismatch: "${currentPatternPart}" doesn't match "${currentPathPart}"`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// For regular segments, they must match exactly
|
||||
if (currentPatternPart !== currentPathPart) {
|
||||
logger.debug(
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as target from "./target";
|
||||
import * as user from "./user";
|
||||
import * as auth from "./auth";
|
||||
import * as role from "./role";
|
||||
import * as supporterKey from "./supporterKey";
|
||||
import * as accessToken from "./accessToken";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import {
|
||||
@@ -22,7 +23,8 @@ import {
|
||||
verifyRoleAccess,
|
||||
verifySetResourceUsers,
|
||||
verifyUserAccess,
|
||||
getUserOrgs
|
||||
getUserOrgs,
|
||||
verifyUserIsServerAdmin
|
||||
} from "@server/middlewares";
|
||||
import { verifyUserHasAction } from "../middlewares/verifyUserHasAction";
|
||||
import { ActionsEnum } from "@server/auth/actions";
|
||||
@@ -239,7 +241,6 @@ authenticated.delete(
|
||||
target.deleteTarget
|
||||
);
|
||||
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/role",
|
||||
verifyOrgAccess,
|
||||
@@ -382,6 +383,9 @@ authenticated.get(
|
||||
|
||||
authenticated.get(`/org/:orgId/overview`, verifyOrgAccess, org.getOrgOverview);
|
||||
|
||||
authenticated.post(`/supporter-key/validate`, supporterKey.validateSupporterKey);
|
||||
authenticated.post(`/supporter-key/hide`, supporterKey.hideSupporterKey);
|
||||
|
||||
unauthenticated.get("/resource/:resourceId/auth", resource.getResourceAuthInfo);
|
||||
|
||||
// authenticated.get(
|
||||
@@ -415,6 +419,13 @@ unauthenticated.get("/resource/:resourceId/auth", resource.getResourceAuthInfo);
|
||||
|
||||
unauthenticated.get("/user", verifySessionMiddleware, user.getUser);
|
||||
|
||||
authenticated.get("/users", verifyUserIsServerAdmin, user.adminListUsers);
|
||||
authenticated.delete(
|
||||
"/user/:userId",
|
||||
verifyUserIsServerAdmin,
|
||||
user.adminRemoveUser
|
||||
);
|
||||
|
||||
authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser);
|
||||
authenticated.get(
|
||||
"/org/:orgId/users",
|
||||
|
||||
@@ -4,8 +4,12 @@ import * as traefik from "@server/routers/traefik";
|
||||
import * as resource from "./resource";
|
||||
import * as badger from "./badger";
|
||||
import * as auth from "@server/routers/auth";
|
||||
import * as supporterKey from "@server/routers/supporterKey";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { verifyResourceAccess, verifySessionUserMiddleware } from "@server/middlewares";
|
||||
import {
|
||||
verifyResourceAccess,
|
||||
verifySessionUserMiddleware
|
||||
} from "@server/middlewares";
|
||||
|
||||
// Root routes
|
||||
const internalRouter = Router();
|
||||
@@ -28,6 +32,11 @@ internalRouter.post(
|
||||
resource.getExchangeToken
|
||||
);
|
||||
|
||||
internalRouter.get(
|
||||
`/supporter-key/visible`,
|
||||
supporterKey.isSupporterKeyVisible
|
||||
);
|
||||
|
||||
// Gerbil routes
|
||||
const gerbilRouter = Router();
|
||||
internalRouter.use("/gerbil", gerbilRouter);
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
Target,
|
||||
targets
|
||||
} from "@server/db/schema";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import { eq, and, sql, inArray } from "drizzle-orm";
|
||||
import { addPeer, deletePeer } from "../gerbil/peers";
|
||||
import logger from "@server/logger";
|
||||
|
||||
@@ -75,68 +75,84 @@ export const handleRegisterMessage: MessageHandler = async (context) => {
|
||||
allowedIps: [site.subnet]
|
||||
});
|
||||
|
||||
const allResources = await db
|
||||
.select({
|
||||
// Resource fields
|
||||
resourceId: resources.resourceId,
|
||||
subdomain: resources.subdomain,
|
||||
fullDomain: resources.fullDomain,
|
||||
ssl: resources.ssl,
|
||||
blockAccess: resources.blockAccess,
|
||||
sso: resources.sso,
|
||||
emailWhitelistEnabled: resources.emailWhitelistEnabled,
|
||||
http: resources.http,
|
||||
proxyPort: resources.proxyPort,
|
||||
protocol: resources.protocol,
|
||||
// Targets as a subquery
|
||||
targets: sql<string>`json_group_array(json_object(
|
||||
'targetId', ${targets.targetId},
|
||||
'ip', ${targets.ip},
|
||||
'method', ${targets.method},
|
||||
'port', ${targets.port},
|
||||
'internalPort', ${targets.internalPort},
|
||||
'enabled', ${targets.enabled}
|
||||
))`.as("targets")
|
||||
})
|
||||
.from(resources)
|
||||
.leftJoin(
|
||||
targets,
|
||||
and(
|
||||
eq(targets.resourceId, resources.resourceId),
|
||||
eq(targets.enabled, true)
|
||||
// Improved version
|
||||
const allResources = await db.transaction(async (tx) => {
|
||||
// First get all resources for the site
|
||||
const resourcesList = await tx
|
||||
.select({
|
||||
resourceId: resources.resourceId,
|
||||
subdomain: resources.subdomain,
|
||||
fullDomain: resources.fullDomain,
|
||||
ssl: resources.ssl,
|
||||
blockAccess: resources.blockAccess,
|
||||
sso: resources.sso,
|
||||
emailWhitelistEnabled: resources.emailWhitelistEnabled,
|
||||
http: resources.http,
|
||||
proxyPort: resources.proxyPort,
|
||||
protocol: resources.protocol
|
||||
})
|
||||
.from(resources)
|
||||
.where(eq(resources.siteId, siteId));
|
||||
|
||||
// Get all enabled targets for these resources in a single query
|
||||
const resourceIds = resourcesList.map((r) => r.resourceId);
|
||||
const allTargets =
|
||||
resourceIds.length > 0
|
||||
? await tx
|
||||
.select({
|
||||
resourceId: targets.resourceId,
|
||||
targetId: targets.targetId,
|
||||
ip: targets.ip,
|
||||
method: targets.method,
|
||||
port: targets.port,
|
||||
internalPort: targets.internalPort,
|
||||
enabled: targets.enabled
|
||||
})
|
||||
.from(targets)
|
||||
.where(
|
||||
and(
|
||||
inArray(targets.resourceId, resourceIds),
|
||||
eq(targets.enabled, true)
|
||||
)
|
||||
)
|
||||
: [];
|
||||
|
||||
// Combine the data in JS instead of using SQL for the JSON
|
||||
return resourcesList.map((resource) => ({
|
||||
...resource,
|
||||
targets: allTargets.filter(
|
||||
(target) => target.resourceId === resource.resourceId
|
||||
)
|
||||
)
|
||||
.where(eq(resources.siteId, siteId))
|
||||
.groupBy(resources.resourceId);
|
||||
}));
|
||||
});
|
||||
|
||||
let tcpTargets: string[] = [];
|
||||
let udpTargets: string[] = [];
|
||||
const { tcpTargets, udpTargets } = allResources.reduce(
|
||||
(acc, resource) => {
|
||||
// Skip resources with no targets
|
||||
if (!resource.targets?.length) return acc;
|
||||
|
||||
for (const resource of allResources) {
|
||||
const targets = JSON.parse(resource.targets);
|
||||
if (!targets || targets.length === 0) {
|
||||
continue;
|
||||
}
|
||||
if (resource.protocol === "tcp") {
|
||||
tcpTargets = tcpTargets.concat(
|
||||
targets.map(
|
||||
// Format valid targets into strings
|
||||
const formattedTargets = resource.targets
|
||||
.filter(
|
||||
(target: Target) =>
|
||||
`${
|
||||
target.internalPort ? target.internalPort + ":" : ""
|
||||
}${target.ip}:${target.port}`
|
||||
target?.internalPort && target?.ip && target?.port
|
||||
)
|
||||
);
|
||||
} else {
|
||||
udpTargets = tcpTargets.concat(
|
||||
targets.map(
|
||||
.map(
|
||||
(target: Target) =>
|
||||
`${
|
||||
target.internalPort ? target.internalPort + ":" : ""
|
||||
}${target.ip}:${target.port}`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
`${target.internalPort}:${target.ip}:${target.port}`
|
||||
);
|
||||
|
||||
// Add to the appropriate protocol array
|
||||
if (resource.protocol === "tcp") {
|
||||
acc.tcpTargets.push(...formattedTargets);
|
||||
} else {
|
||||
acc.udpTargets.push(...formattedTargets);
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{ tcpTargets: [] as string[], udpTargets: [] as string[] }
|
||||
);
|
||||
|
||||
return {
|
||||
message: {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { Resource, resources } from "@server/db/schema";
|
||||
import { Resource, resources, sites } from "@server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
@@ -18,7 +18,9 @@ const getResourceSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type GetResourceResponse = Resource;
|
||||
export type GetResourceResponse = Resource & {
|
||||
siteName: string;
|
||||
};
|
||||
|
||||
export async function getResource(
|
||||
req: Request,
|
||||
@@ -38,13 +40,17 @@ export async function getResource(
|
||||
|
||||
const { resourceId } = parsedParams.data;
|
||||
|
||||
const resource = await db
|
||||
const [resp] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.resourceId, resourceId))
|
||||
.leftJoin(sites, eq(sites.siteId, resources.siteId))
|
||||
.limit(1);
|
||||
|
||||
if (resource.length === 0) {
|
||||
const resource = resp.resources;
|
||||
const site = resp.sites;
|
||||
|
||||
if (!resource) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
@@ -54,7 +60,10 @@ export async function getResource(
|
||||
}
|
||||
|
||||
return response(res, {
|
||||
data: resource[0],
|
||||
data: {
|
||||
...resource,
|
||||
siteName: site?.name
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Resource retrieved successfully",
|
||||
|
||||
@@ -215,8 +215,13 @@ async function updateHttpResource(
|
||||
.from(domains)
|
||||
.where(eq(domains.domainId, domainId));
|
||||
|
||||
const isBaseDomain =
|
||||
updateData.isBaseDomain !== undefined
|
||||
? updateData.isBaseDomain
|
||||
: resource.isBaseDomain;
|
||||
|
||||
let fullDomain: string | null = null;
|
||||
if (updateData.isBaseDomain) {
|
||||
if (isBaseDomain) {
|
||||
fullDomain = domain.baseDomain;
|
||||
} else if (subdomain && domain) {
|
||||
fullDomain = `${subdomain}.${domain.baseDomain}`;
|
||||
|
||||
35
server/routers/supporterKey/hideSupporterKey.ts
Normal file
35
server/routers/supporterKey/hideSupporterKey.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { response as sendResponse } from "@server/lib";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
export type HideSupporterKeyResponse = {
|
||||
hidden: boolean;
|
||||
};
|
||||
|
||||
export async function hideSupporterKey(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
config.hideSupporterKey();
|
||||
|
||||
return sendResponse<HideSupporterKeyResponse>(res, {
|
||||
data: {
|
||||
hidden: true
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Hidden",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
3
server/routers/supporterKey/index.ts
Normal file
3
server/routers/supporterKey/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./validateSupporterKey";
|
||||
export * from "./isSupporterKeyVisible";
|
||||
export * from "./hideSupporterKey";
|
||||
56
server/routers/supporterKey/isSupporterKeyVisible.ts
Normal file
56
server/routers/supporterKey/isSupporterKeyVisible.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { response as sendResponse } from "@server/lib";
|
||||
import config from "@server/lib/config";
|
||||
import db from "@server/db";
|
||||
import { count } from "drizzle-orm";
|
||||
import { users } from "@server/db/schema";
|
||||
|
||||
export type IsSupporterKeyVisibleResponse = {
|
||||
visible: boolean;
|
||||
tier?: string;
|
||||
};
|
||||
|
||||
const USER_LIMIT = 5;
|
||||
|
||||
export async function isSupporterKeyVisible(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const hidden = config.isSupporterKeyHidden();
|
||||
const key = config.getSupporterData();
|
||||
|
||||
let visible = !hidden && key?.valid !== true;
|
||||
|
||||
if (key?.tier === "Limited Supporter") {
|
||||
const [numUsers] = await db.select({ count: count() }).from(users);
|
||||
|
||||
if (numUsers.count > USER_LIMIT) {
|
||||
logger.debug(
|
||||
`User count ${numUsers.count} exceeds limit ${USER_LIMIT}`
|
||||
);
|
||||
visible = true;
|
||||
}
|
||||
}
|
||||
|
||||
return sendResponse<IsSupporterKeyVisibleResponse>(res, {
|
||||
data: {
|
||||
visible,
|
||||
tier: key?.tier || undefined
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Status",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
115
server/routers/supporterKey/validateSupporterKey.ts
Normal file
115
server/routers/supporterKey/validateSupporterKey.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { response as sendResponse } from "@server/lib";
|
||||
import { suppressDeprecationWarnings } from "moment";
|
||||
import { supporterKey } from "@server/db/schema";
|
||||
import db from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
const validateSupporterKeySchema = z
|
||||
.object({
|
||||
githubUsername: z.string().nonempty(),
|
||||
key: z.string().nonempty()
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type ValidateSupporterKeyResponse = {
|
||||
valid: boolean;
|
||||
githubUsername?: string;
|
||||
tier?: string;
|
||||
phrase?: string;
|
||||
};
|
||||
|
||||
export async function validateSupporterKey(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedBody = validateSupporterKeySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { githubUsername, key } = parsedBody.data;
|
||||
|
||||
const response = await fetch(
|
||||
"https://api.dev.fossorial.io/api/v1/license/validate",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
licenseKey: key,
|
||||
githubUsername: githubUsername
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error(response);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"An error occurred"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data || !data.data.valid) {
|
||||
return sendResponse<ValidateSupporterKeyResponse>(res, {
|
||||
data: {
|
||||
valid: false
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Invalid supporter key",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
}
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx.delete(supporterKey);
|
||||
await trx.insert(supporterKey).values({
|
||||
githubUsername: githubUsername,
|
||||
key: key,
|
||||
tier: data.data.tier || null,
|
||||
phrase: data.data.cutePhrase || null,
|
||||
valid: true
|
||||
});
|
||||
});
|
||||
|
||||
await config.checkSupporterKey();
|
||||
|
||||
return sendResponse<ValidateSupporterKeyResponse>(res, {
|
||||
data: {
|
||||
valid: true,
|
||||
githubUsername: data.data.githubUsername,
|
||||
tier: data.data.tier,
|
||||
phrase: data.data.cutePhrase
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Valid supporter key",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response } from "express";
|
||||
import db from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import config from "@server/lib/config";
|
||||
@@ -12,52 +12,79 @@ export async function traefikConfigProvider(
|
||||
res: Response
|
||||
): Promise<any> {
|
||||
try {
|
||||
const allResources = await db
|
||||
.select({
|
||||
// Resource fields
|
||||
resourceId: resources.resourceId,
|
||||
subdomain: resources.subdomain,
|
||||
fullDomain: resources.fullDomain,
|
||||
ssl: resources.ssl,
|
||||
blockAccess: resources.blockAccess,
|
||||
sso: resources.sso,
|
||||
emailWhitelistEnabled: resources.emailWhitelistEnabled,
|
||||
http: resources.http,
|
||||
proxyPort: resources.proxyPort,
|
||||
protocol: resources.protocol,
|
||||
isBaseDomain: resources.isBaseDomain,
|
||||
domainId: resources.domainId,
|
||||
// Site fields
|
||||
site: {
|
||||
siteId: sites.siteId,
|
||||
type: sites.type,
|
||||
subnet: sites.subnet
|
||||
},
|
||||
// Org fields
|
||||
org: {
|
||||
orgId: orgs.orgId
|
||||
},
|
||||
// Targets as a subquery
|
||||
targets: sql<string>`json_group_array(json_object(
|
||||
'targetId', ${targets.targetId},
|
||||
'ip', ${targets.ip},
|
||||
'method', ${targets.method},
|
||||
'port', ${targets.port},
|
||||
'internalPort', ${targets.internalPort},
|
||||
'enabled', ${targets.enabled}
|
||||
))`.as("targets")
|
||||
})
|
||||
.from(resources)
|
||||
.innerJoin(sites, eq(sites.siteId, resources.siteId))
|
||||
.innerJoin(orgs, eq(resources.orgId, orgs.orgId))
|
||||
.leftJoin(
|
||||
targets,
|
||||
and(
|
||||
eq(targets.resourceId, resources.resourceId),
|
||||
eq(targets.enabled, true)
|
||||
)
|
||||
)
|
||||
.groupBy(resources.resourceId);
|
||||
// Get all resources with related data
|
||||
const allResources = await db.transaction(async (tx) => {
|
||||
// First query to get resources with site and org info
|
||||
const resourcesWithRelations = await tx
|
||||
.select({
|
||||
// Resource fields
|
||||
resourceId: resources.resourceId,
|
||||
subdomain: resources.subdomain,
|
||||
fullDomain: resources.fullDomain,
|
||||
ssl: resources.ssl,
|
||||
blockAccess: resources.blockAccess,
|
||||
sso: resources.sso,
|
||||
emailWhitelistEnabled: resources.emailWhitelistEnabled,
|
||||
http: resources.http,
|
||||
proxyPort: resources.proxyPort,
|
||||
protocol: resources.protocol,
|
||||
isBaseDomain: resources.isBaseDomain,
|
||||
domainId: resources.domainId,
|
||||
// Site fields
|
||||
site: {
|
||||
siteId: sites.siteId,
|
||||
type: sites.type,
|
||||
subnet: sites.subnet
|
||||
},
|
||||
// Org fields
|
||||
org: {
|
||||
orgId: orgs.orgId
|
||||
}
|
||||
})
|
||||
.from(resources)
|
||||
.innerJoin(sites, eq(sites.siteId, resources.siteId))
|
||||
.innerJoin(orgs, eq(resources.orgId, orgs.orgId));
|
||||
|
||||
// Get all resource IDs from the first query
|
||||
const resourceIds = resourcesWithRelations.map((r) => r.resourceId);
|
||||
|
||||
// Second query to get all enabled targets for these resources
|
||||
const allTargets =
|
||||
resourceIds.length > 0
|
||||
? await tx
|
||||
.select({
|
||||
resourceId: targets.resourceId,
|
||||
targetId: targets.targetId,
|
||||
ip: targets.ip,
|
||||
method: targets.method,
|
||||
port: targets.port,
|
||||
internalPort: targets.internalPort,
|
||||
enabled: targets.enabled
|
||||
})
|
||||
.from(targets)
|
||||
.where(
|
||||
and(
|
||||
inArray(targets.resourceId, resourceIds),
|
||||
eq(targets.enabled, true)
|
||||
)
|
||||
)
|
||||
: [];
|
||||
|
||||
// Create a map for fast target lookup by resourceId
|
||||
const targetsMap = allTargets.reduce((map, target) => {
|
||||
if (!map.has(target.resourceId)) {
|
||||
map.set(target.resourceId, []);
|
||||
}
|
||||
map.get(target.resourceId).push(target);
|
||||
return map;
|
||||
}, new Map());
|
||||
|
||||
// Combine the data
|
||||
return resourcesWithRelations.map((resource) => ({
|
||||
...resource,
|
||||
targets: targetsMap.get(resource.resourceId) || []
|
||||
}));
|
||||
});
|
||||
|
||||
if (!allResources.length) {
|
||||
return res.status(HttpCode.OK).json({});
|
||||
@@ -101,7 +128,7 @@ export async function traefikConfigProvider(
|
||||
};
|
||||
|
||||
for (const resource of allResources) {
|
||||
const targets = JSON.parse(resource.targets);
|
||||
const targets = resource.targets as Target[];
|
||||
const site = resource.site;
|
||||
const org = resource.org;
|
||||
|
||||
@@ -143,6 +170,10 @@ export async function traefikConfigProvider(
|
||||
wildCard = `*.${domainParts.slice(1).join(".")}`;
|
||||
}
|
||||
|
||||
if (resource.isBaseDomain) {
|
||||
wildCard = resource.fullDomain;
|
||||
}
|
||||
|
||||
const configDomain = config.getDomain(resource.domainId);
|
||||
|
||||
if (!configDomain) {
|
||||
|
||||
90
server/routers/user/adminListUsers.ts
Normal file
90
server/routers/user/adminListUsers.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import { sql, eq } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { users } from "@server/db/schema";
|
||||
|
||||
const listUsersSchema = z
|
||||
.object({
|
||||
limit: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("1000")
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().nonnegative()),
|
||||
offset: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("0")
|
||||
.transform(Number)
|
||||
.pipe(z.number().int().nonnegative())
|
||||
})
|
||||
.strict();
|
||||
|
||||
async function queryUsers(limit: number, offset: number) {
|
||||
return await db
|
||||
.select({
|
||||
id: users.userId,
|
||||
email: users.email,
|
||||
dateCreated: users.dateCreated,
|
||||
serverAdmin: users.serverAdmin
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.serverAdmin, false))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
}
|
||||
|
||||
export type AdminListUsersResponse = {
|
||||
users: NonNullable<Awaited<ReturnType<typeof queryUsers>>>;
|
||||
pagination: { total: number; limit: number; offset: number };
|
||||
};
|
||||
|
||||
export async function adminListUsers(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedQuery = listUsersSchema.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
parsedQuery.error.errors.map((e) => e.message).join(", ")
|
||||
)
|
||||
);
|
||||
}
|
||||
const { limit, offset } = parsedQuery.data;
|
||||
|
||||
const allUsers = await queryUsers(limit, offset);
|
||||
|
||||
const [{ count }] = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(users);
|
||||
|
||||
return response<AdminListUsersResponse>(res, {
|
||||
data: {
|
||||
users: allUsers,
|
||||
pagination: {
|
||||
total: count,
|
||||
limit,
|
||||
offset
|
||||
}
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Users retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
70
server/routers/user/adminRemoveUser.ts
Normal file
70
server/routers/user/adminRemoveUser.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { users } from "@server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
const removeUserSchema = z
|
||||
.object({
|
||||
userId: z.string()
|
||||
})
|
||||
.strict();
|
||||
|
||||
export async function adminRemoveUser(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = removeUserSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { userId } = parsedParams.data;
|
||||
|
||||
// get the user first
|
||||
const user = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.userId, userId));
|
||||
|
||||
if (!user || user.length === 0) {
|
||||
return next(createHttpError(HttpCode.NOT_FOUND, "User not found"));
|
||||
}
|
||||
|
||||
if (user[0].serverAdmin) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Cannot remove server admin"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await db.delete(users).where(eq(users.userId, userId));
|
||||
|
||||
return response(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "User removed successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,4 +4,6 @@ export * from "./listUsers";
|
||||
export * from "./addUserRole";
|
||||
export * from "./inviteUser";
|
||||
export * from "./acceptInvite";
|
||||
export * from "./getOrgUser";
|
||||
export * from "./getOrgUser";
|
||||
export * from "./adminListUsers";
|
||||
export * from "./adminRemoveUser";
|
||||
|
||||
@@ -71,7 +71,7 @@ export async function removeUserOrg(
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "User remove from org successfully",
|
||||
message: "User removed from org successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -18,7 +18,7 @@ export async function clearStaleData() {
|
||||
.delete(sessions)
|
||||
.where(lt(sessions.expiresAt, new Date().getTime()));
|
||||
} catch (e) {
|
||||
logger.error("Error clearing expired sessions:", e);
|
||||
logger.warn("Error clearing expired sessions:", e);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -26,7 +26,7 @@ export async function clearStaleData() {
|
||||
.delete(newtSessions)
|
||||
.where(lt(newtSessions.expiresAt, new Date().getTime()));
|
||||
} catch (e) {
|
||||
logger.error("Error clearing expired newtSessions:", e);
|
||||
logger.warn("Error clearing expired newtSessions:", e);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -34,7 +34,7 @@ export async function clearStaleData() {
|
||||
.delete(emailVerificationCodes)
|
||||
.where(lt(emailVerificationCodes.expiresAt, new Date().getTime()));
|
||||
} catch (e) {
|
||||
logger.error("Error clearing expired emailVerificationCodes:", e);
|
||||
logger.warn("Error clearing expired emailVerificationCodes:", e);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -42,7 +42,7 @@ export async function clearStaleData() {
|
||||
.delete(passwordResetTokens)
|
||||
.where(lt(passwordResetTokens.expiresAt, new Date().getTime()));
|
||||
} catch (e) {
|
||||
logger.error("Error clearing expired passwordResetTokens:", e);
|
||||
logger.warn("Error clearing expired passwordResetTokens:", e);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -50,7 +50,7 @@ export async function clearStaleData() {
|
||||
.delete(userInvites)
|
||||
.where(lt(userInvites.expiresAt, new Date().getTime()));
|
||||
} catch (e) {
|
||||
logger.error("Error clearing expired userInvites:", e);
|
||||
logger.warn("Error clearing expired userInvites:", e);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -58,7 +58,7 @@ export async function clearStaleData() {
|
||||
.delete(resourceAccessToken)
|
||||
.where(lt(resourceAccessToken.expiresAt, new Date().getTime()));
|
||||
} catch (e) {
|
||||
logger.error("Error clearing expired resourceAccessToken:", e);
|
||||
logger.warn("Error clearing expired resourceAccessToken:", e);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -66,7 +66,7 @@ export async function clearStaleData() {
|
||||
.delete(resourceSessions)
|
||||
.where(lt(resourceSessions.expiresAt, new Date().getTime()));
|
||||
} catch (e) {
|
||||
logger.error("Error clearing expired resourceSessions:", e);
|
||||
logger.warn("Error clearing expired resourceSessions:", e);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -74,6 +74,6 @@ export async function clearStaleData() {
|
||||
.delete(resourceOtp)
|
||||
.where(lt(resourceOtp.expiresAt, new Date().getTime()));
|
||||
} catch (e) {
|
||||
logger.error("Error clearing expired resourceOtp:", e);
|
||||
logger.warn("Error clearing expired resourceOtp:", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import m8 from "./scripts/1.0.0-beta12";
|
||||
import m13 from "./scripts/1.0.0-beta13";
|
||||
import m15 from "./scripts/1.0.0-beta15";
|
||||
import m16 from "./scripts/1.0.0";
|
||||
import m17 from "./scripts/1.1.0";
|
||||
|
||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||
@@ -33,16 +34,14 @@ const migrations = [
|
||||
{ version: "1.0.0-beta.12", run: m8 },
|
||||
{ version: "1.0.0-beta.13", run: m13 },
|
||||
{ version: "1.0.0-beta.15", run: m15 },
|
||||
{ version: "1.0.0", run: m16 }
|
||||
{ version: "1.0.0", run: m16 },
|
||||
{ version: "1.1.0", run: m17 }
|
||||
// Add new migrations here as they are created
|
||||
] as const;
|
||||
|
||||
await run();
|
||||
|
||||
async function run() {
|
||||
// backup the database
|
||||
backupDb();
|
||||
|
||||
// run the migrations
|
||||
await runMigrations();
|
||||
}
|
||||
@@ -127,6 +126,11 @@ async function executeScripts() {
|
||||
console.log(`Running migration ${migration.version}`);
|
||||
|
||||
try {
|
||||
if (!process.env.DISABLE_BACKUP_ON_MIGRATION) {
|
||||
// Backup the database before running the migration
|
||||
backupDb();
|
||||
}
|
||||
|
||||
await migration.run();
|
||||
|
||||
// Update version in database
|
||||
|
||||
28
server/setup/scripts/1.1.0.ts
Normal file
28
server/setup/scripts/1.1.0.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import db from "@server/db";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
const version = "1.1.0";
|
||||
|
||||
export default async function migration() {
|
||||
console.log(`Running setup script ${version}...`);
|
||||
|
||||
try {
|
||||
db.transaction((trx) => {
|
||||
trx.run(sql`CREATE TABLE 'supporterKey' (
|
||||
'keyId' integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
'key' text NOT NULL,
|
||||
'githubUsername' text NOT NULL,
|
||||
'phrase' text,
|
||||
'tier' text,
|
||||
'valid' integer DEFAULT false NOT NULL
|
||||
);`);
|
||||
});
|
||||
|
||||
console.log(`Migrated database schema`);
|
||||
} catch (e) {
|
||||
console.log("Unable to migrate database schema");
|
||||
throw e;
|
||||
}
|
||||
|
||||
console.log(`${version} migration complete`);
|
||||
}
|
||||
@@ -110,23 +110,19 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full bg-card sm:px-0 px-3 fixed top-0 z-10">
|
||||
<div className="border-b">
|
||||
<div className="container mx-auto flex flex-col content-between">
|
||||
<div className="my-4">
|
||||
<UserProvider user={user}>
|
||||
<Header orgId={params.orgId} orgs={orgs} />
|
||||
</UserProvider>
|
||||
</div>
|
||||
<TopbarNav items={topNavItems} orgId={params.orgId} />
|
||||
<div className="w-full bg-card sm:px-0 fixed top-0 z-10 border-b">
|
||||
<div className="container mx-auto flex flex-col content-between">
|
||||
<div className="my-4 px-3 md:px-0">
|
||||
<UserProvider user={user}>
|
||||
<Header orgId={params.orgId} orgs={orgs} />
|
||||
</UserProvider>
|
||||
</div>
|
||||
<TopbarNav items={topNavItems} orgId={params.orgId} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto sm:px-0 px-3 pt-[155px]">
|
||||
<div className="container mx-auto sm:px-0 px-3">
|
||||
{children}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -764,7 +764,7 @@ export default function CreateResourceForm({
|
||||
|
||||
<Link
|
||||
className="text-sm text-primary flex items-center gap-1"
|
||||
href="https://docs.fossorial.io/Getting%20Started/tcp-udp"
|
||||
href="https://docs.fossorial.io/Pangolin/tcp-udp"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { InfoIcon, ShieldCheck, ShieldOff } from "lucide-react";
|
||||
import { ArrowRight, InfoIcon, ShieldCheck, ShieldOff } from "lucide-react";
|
||||
import { useResourceContext } from "@app/hooks/useResourceContext";
|
||||
import { Separator } from "@app/components/ui/separator";
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
InfoSections,
|
||||
InfoSectionTitle
|
||||
} from "@app/components/InfoSection";
|
||||
import Link from "next/link";
|
||||
|
||||
type ResourceInfoBoxType = {};
|
||||
|
||||
@@ -26,7 +27,7 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
Resource Information
|
||||
</AlertTitle>
|
||||
<AlertDescription className="mt-4">
|
||||
<InfoSections>
|
||||
<InfoSections cols={3}>
|
||||
{resource.http ? (
|
||||
<>
|
||||
<InfoSection>
|
||||
@@ -40,22 +41,16 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
authInfo.whitelist ? (
|
||||
<div className="flex items-start space-x-2 text-green-500">
|
||||
<ShieldCheck className="w-4 h-4 mt-0.5" />
|
||||
<span>
|
||||
This resource is protected with
|
||||
at least one authentication method.
|
||||
</span>
|
||||
<span>Protected</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-2 text-yellow-500">
|
||||
<ShieldOff className="w-4 h-4" />
|
||||
<span>
|
||||
Anyone can access this resource.
|
||||
</span>
|
||||
<span>Not Protected</span>
|
||||
</div>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<Separator orientation="vertical" />
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>URL</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
@@ -65,6 +60,12 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
/>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>Site</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
{resource.siteName}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -76,7 +77,6 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
</span>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<Separator orientation="vertical" />
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>Port</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
|
||||
@@ -104,8 +104,8 @@ export default function ReverseProxyTargets(props: {
|
||||
resolver: zodResolver(addTargetSchema),
|
||||
defaultValues: {
|
||||
ip: "",
|
||||
method: resource.http ? "http" : null
|
||||
// protocol: "TCP",
|
||||
method: resource.http ? "http" : null,
|
||||
port: "" as any as number
|
||||
} as z.infer<typeof addTargetSchema>
|
||||
});
|
||||
|
||||
@@ -200,7 +200,11 @@ export default function ReverseProxyTargets(props: {
|
||||
};
|
||||
|
||||
setTargets([...targets, newTarget]);
|
||||
addTargetForm.reset();
|
||||
addTargetForm.reset({
|
||||
ip: "",
|
||||
method: resource.http ? "http" : null,
|
||||
port: "" as any as number
|
||||
});
|
||||
}
|
||||
|
||||
const removeTarget = (targetId: number) => {
|
||||
|
||||
@@ -265,6 +265,12 @@ export default function GeneralForm() {
|
||||
description: "The resource has been transferred successfully"
|
||||
});
|
||||
router.refresh();
|
||||
|
||||
updateResource({
|
||||
siteName:
|
||||
sites.find((site) => site.siteId === data.siteId)?.name ||
|
||||
""
|
||||
});
|
||||
}
|
||||
setTransferLoading(false);
|
||||
}
|
||||
@@ -606,9 +612,7 @@ export default function GeneralForm() {
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search sites"
|
||||
/>
|
||||
<CommandInput placeholder="Search sites" />
|
||||
<CommandEmpty>
|
||||
No sites found.
|
||||
</CommandEmpty>
|
||||
|
||||
@@ -552,7 +552,7 @@ export default function ResourceRules(props: {
|
||||
path.
|
||||
</p>
|
||||
</div>
|
||||
<InfoSections>
|
||||
<InfoSections cols={2}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>Actions</InfoSectionTitle>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
@@ -568,7 +568,6 @@ export default function ResourceRules(props: {
|
||||
</li>
|
||||
</ul>
|
||||
</InfoSection>
|
||||
<Separator orientation="vertical" />
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
Matching Criteria
|
||||
|
||||
@@ -107,7 +107,12 @@ export default function CreateShareLinkForm({
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const [resources, setResources] = useState<
|
||||
{ resourceId: number; name: string; resourceUrl: string }[]
|
||||
{
|
||||
resourceId: number;
|
||||
name: string;
|
||||
resourceUrl: string;
|
||||
siteName: string | null;
|
||||
}[]
|
||||
>([]);
|
||||
|
||||
const timeUnits = [
|
||||
@@ -159,7 +164,8 @@ export default function CreateShareLinkForm({
|
||||
.map((r) => ({
|
||||
resourceId: r.resourceId,
|
||||
name: r.name,
|
||||
resourceUrl: `${r.ssl ? "https://" : "http://"}${r.fullDomain}/`
|
||||
resourceUrl: `${r.ssl ? "https://" : "http://"}${r.fullDomain}/`,
|
||||
siteName: r.siteName
|
||||
}))
|
||||
);
|
||||
}
|
||||
@@ -231,19 +237,28 @@ export default function CreateShareLinkForm({
|
||||
token.accessToken
|
||||
);
|
||||
setDirectLink(directLink);
|
||||
|
||||
const resource = resources.find((r) => r.resourceId === values.resourceId);
|
||||
|
||||
onCreated?.({
|
||||
accessTokenId: token.accessTokenId,
|
||||
resourceId: token.resourceId,
|
||||
resourceName: values.resourceName,
|
||||
title: token.title,
|
||||
createdAt: token.createdAt,
|
||||
expiresAt: token.expiresAt
|
||||
expiresAt: token.expiresAt,
|
||||
siteName: resource?.siteName || null,
|
||||
});
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
function getSelectedResourceName(id: number) {
|
||||
const resource = resources.find((r) => r.resourceId === id);
|
||||
return `${resource?.name} ${resource?.siteName ? `(${resource.siteName})` : ""}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Credenza
|
||||
@@ -292,14 +307,9 @@ export default function CreateShareLinkForm({
|
||||
)}
|
||||
>
|
||||
{field.value
|
||||
? resources.find(
|
||||
(
|
||||
r
|
||||
) =>
|
||||
r.resourceId ===
|
||||
field.value
|
||||
? getSelectedResourceName(
|
||||
field.value
|
||||
)
|
||||
?.name
|
||||
: "Select resource"}
|
||||
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -348,9 +358,7 @@ export default function CreateShareLinkForm({
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{
|
||||
r.name
|
||||
}
|
||||
{`${r.name} ${r.siteName ? `(${r.siteName})` : ""}`}
|
||||
</CommandItem>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -41,6 +41,7 @@ export type ShareLinkRow = {
|
||||
title: string | null;
|
||||
createdAt: number;
|
||||
expiresAt: number | null;
|
||||
siteName: string | null;
|
||||
};
|
||||
|
||||
type ShareLinksTableProps = {
|
||||
@@ -145,7 +146,8 @@ export default function ShareLinksTable({
|
||||
return (
|
||||
<Link href={`/${orgId}/settings/resources/${r.resourceId}`}>
|
||||
<Button variant="outline">
|
||||
{r.resourceName}
|
||||
{r.resourceName}{" "}
|
||||
{r.siteName ? `(${r.siteName})` : ""}
|
||||
<ArrowUpRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -274,20 +276,21 @@ export default function ShareLinksTable({
|
||||
return "Never";
|
||||
}
|
||||
},
|
||||
{
|
||||
{
|
||||
id: "delete",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<Button
|
||||
variant="outlinePrimary"
|
||||
onClick={() => deleteSharelink(row.original.accessTokenId)}
|
||||
onClick={() =>
|
||||
deleteSharelink(row.original.accessTokenId)
|
||||
}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -331,7 +331,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||
columns={columns}
|
||||
data={rows}
|
||||
addSite={() => {
|
||||
setIsCreateModalOpen(true);
|
||||
router.push(`/${orgId}/settings/sites/create`);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import { useSiteContext } from "@app/hooks/useSiteContext";
|
||||
import { Separator } from "@app/components/ui/separator";
|
||||
import {
|
||||
InfoSection,
|
||||
InfoSectionContent,
|
||||
@@ -33,7 +32,7 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">Site Information</AlertTitle>
|
||||
<AlertDescription className="mt-4">
|
||||
<InfoSections>
|
||||
<InfoSections cols={2}>
|
||||
{(site.type == "newt" || site.type == "wireguard") && (
|
||||
<>
|
||||
<InfoSection>
|
||||
@@ -52,8 +51,6 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
|
||||
<Separator orientation="vertical" />
|
||||
</>
|
||||
)}
|
||||
<InfoSection>
|
||||
|
||||
861
src/app/[orgId]/settings/sites/create/page.tsx
Normal file
861
src/app/[orgId]/settings/sites/create/page.tsx
Normal file
@@ -0,0 +1,861 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
SettingsContainer,
|
||||
SettingsSection,
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle
|
||||
} from "@app/components/Settings";
|
||||
import { StrategySelect } from "@app/components/StrategySelect";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||
import { z } from "zod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { Terminal, InfoIcon } from "lucide-react";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import CopyTextBox from "@app/components/CopyTextBox";
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import {
|
||||
InfoSection,
|
||||
InfoSectionContent,
|
||||
InfoSections,
|
||||
InfoSectionTitle
|
||||
} from "@app/components/InfoSection";
|
||||
import { FaWindows, FaApple, FaFreebsd, FaDocker } from "react-icons/fa";
|
||||
import { Checkbox } from "@app/components/ui/checkbox";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { generateKeypair } from "../[niceId]/wireguardConfig";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import {
|
||||
CreateSiteBody,
|
||||
CreateSiteResponse,
|
||||
PickSiteDefaultsResponse
|
||||
} from "@server/routers/site";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator
|
||||
} from "@app/components/ui/breadcrumb";
|
||||
import Link from "next/link";
|
||||
|
||||
const createSiteFormSchema = z
|
||||
.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(2, {
|
||||
message: "Name must be at least 2 characters."
|
||||
})
|
||||
.max(30, {
|
||||
message: "Name must not be longer than 30 characters."
|
||||
}),
|
||||
method: z.string(),
|
||||
copied: z.boolean()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.method !== "local") {
|
||||
return data.copied;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Please confirm that you have copied the config.",
|
||||
path: ["copied"]
|
||||
}
|
||||
);
|
||||
|
||||
type CreateSiteFormValues = z.infer<typeof createSiteFormSchema>;
|
||||
|
||||
type Commands = {
|
||||
mac: Record<string, string[]>;
|
||||
linux: Record<string, string[]>;
|
||||
windows: Record<string, string[]>;
|
||||
docker: Record<string, string[]>;
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const { orgId } = useParams();
|
||||
const router = useRouter();
|
||||
|
||||
const [tunnelTypes, setTunnelTypes] = useState<any>([
|
||||
{
|
||||
id: "newt",
|
||||
title: "Newt Tunnel (Recommended)",
|
||||
description:
|
||||
"Easiest way to create an entrypoint into your network. No extra setup.",
|
||||
disabled: true
|
||||
},
|
||||
{
|
||||
id: "wireguard",
|
||||
title: "Basic WireGuard",
|
||||
description:
|
||||
"Use any WireGuard client to establish a tunnel. Manual NAT setup required.",
|
||||
disabled: true
|
||||
},
|
||||
{
|
||||
id: "local",
|
||||
title: "Local",
|
||||
description: "Local resources only. No tunneling."
|
||||
}
|
||||
]);
|
||||
|
||||
const [loadingPage, setLoadingPage] = useState(true);
|
||||
|
||||
const [platform, setPlatform] = useState("linux");
|
||||
const [architecture, setArchitecture] = useState("amd64");
|
||||
const [commands, setCommands] = useState<Commands | null>(null);
|
||||
|
||||
const [newtId, setNewtId] = useState("");
|
||||
const [newtSecret, setNewtSecret] = useState("");
|
||||
const [newtEndpoint, setNewtEndpoint] = useState("");
|
||||
|
||||
const [publicKey, setPublicKey] = useState("");
|
||||
const [privateKey, setPrivateKey] = useState("");
|
||||
const [wgConfig, setWgConfig] = useState("");
|
||||
|
||||
const [createLoading, setCreateLoading] = useState(false);
|
||||
|
||||
const [siteDefaults, setSiteDefaults] =
|
||||
useState<PickSiteDefaultsResponse | null>(null);
|
||||
|
||||
const hydrateWireGuardConfig = (
|
||||
privateKey: string,
|
||||
publicKey: string,
|
||||
subnet: string,
|
||||
address: string,
|
||||
endpoint: string,
|
||||
listenPort: string
|
||||
) => {
|
||||
const wgConfig = `[Interface]
|
||||
Address = ${subnet}
|
||||
ListenPort = 51820
|
||||
PrivateKey = ${privateKey}
|
||||
|
||||
[Peer]
|
||||
PublicKey = ${publicKey}
|
||||
AllowedIPs = ${address.split("/")[0]}/32
|
||||
Endpoint = ${endpoint}:${listenPort}
|
||||
PersistentKeepalive = 5`;
|
||||
setWgConfig(wgConfig);
|
||||
};
|
||||
|
||||
const hydrateCommands = (
|
||||
id: string,
|
||||
secret: string,
|
||||
endpoint: string,
|
||||
version: string
|
||||
) => {
|
||||
const commands = {
|
||||
mac: {
|
||||
"Apple Silicon (arm64)": [
|
||||
`curl -L -o newt "https://github.com/fosrl/newt/releases/download/${version}/newt_darwin_arm64" && chmod +x ./newt`,
|
||||
`./newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
],
|
||||
"Intel x64 (amd64)": [
|
||||
`curl -L -o newt "https://github.com/fosrl/newt/releases/download/${version}/newt_darwin_amd64" && chmod +x ./newt`,
|
||||
`./newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
]
|
||||
},
|
||||
linux: {
|
||||
amd64: [
|
||||
`wget -O newt "https://github.com/fosrl/newt/releases/download/${version}/newt_linux_amd64" && chmod +x ./newt`,
|
||||
`./newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
],
|
||||
arm64: [
|
||||
`wget -O newt "https://github.com/fosrl/newt/releases/download/${version}/newt_linux_arm64" && chmod +x ./newt`,
|
||||
`./newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
],
|
||||
arm32: [
|
||||
`wget -O newt "https://github.com/fosrl/newt/releases/download/${version}/newt_linux_arm32" && chmod +x ./newt`,
|
||||
`./newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
],
|
||||
arm32v6: [
|
||||
`wget -O newt "https://github.com/fosrl/newt/releases/download/${version}/newt_linux_arm32v6" && chmod +x ./newt`,
|
||||
`./newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
],
|
||||
riscv64: [
|
||||
`wget -O newt "https://github.com/fosrl/newt/releases/download/${version}/newt_linux_riscv64" && chmod +x ./newt`,
|
||||
`./newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
]
|
||||
},
|
||||
freebsd: {
|
||||
amd64: [
|
||||
`fetch -o newt "https://github.com/fosrl/newt/releases/download/${version}/newt_freebsd_amd64" && chmod +x ./newt`,
|
||||
`./newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
],
|
||||
arm64: [
|
||||
`fetch -o newt "https://github.com/fosrl/newt/releases/download/${version}/newt_freebsd_arm64" && chmod +x ./newt`,
|
||||
`./newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
]
|
||||
},
|
||||
windows: {
|
||||
x64: [
|
||||
`curl -o newt.exe -L "https://github.com/fosrl/newt/releases/download/${version}/newt_windows_amd64.exe"`,
|
||||
`newt.exe --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
]
|
||||
},
|
||||
docker: {
|
||||
"Docker Compose": [
|
||||
`services:
|
||||
newt:
|
||||
image: fosrl/newt
|
||||
container_name: newt
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- PANGOLIN_ENDPOINT=${endpoint}
|
||||
- NEWT_ID=${id}
|
||||
- NEWT_SECRET=${secret}`
|
||||
],
|
||||
"Docker Run": [
|
||||
`docker run -it fosrl/newt --id ${id} --secret ${secret} --endpoint ${endpoint}`
|
||||
]
|
||||
}
|
||||
};
|
||||
setCommands(commands);
|
||||
};
|
||||
|
||||
const getArchitectures = () => {
|
||||
switch (platform) {
|
||||
case "linux":
|
||||
return ["amd64", "arm64", "arm32", "arm32v6", "riscv64"];
|
||||
case "mac":
|
||||
return ["Apple Silicon (arm64)", "Intel x64 (amd64)"];
|
||||
case "windows":
|
||||
return ["x64"];
|
||||
case "docker":
|
||||
return ["Docker Compose", "Docker Run"];
|
||||
case "freebsd":
|
||||
return ["amd64", "arm64"];
|
||||
default:
|
||||
return ["x64"];
|
||||
}
|
||||
};
|
||||
|
||||
const getPlatformName = (platformName: string) => {
|
||||
switch (platformName) {
|
||||
case "windows":
|
||||
return "Windows";
|
||||
case "mac":
|
||||
return "macOS";
|
||||
case "docker":
|
||||
return "Docker";
|
||||
case "freebsd":
|
||||
return "FreeBSD";
|
||||
default:
|
||||
return "Linux";
|
||||
}
|
||||
};
|
||||
|
||||
const getCommand = () => {
|
||||
const placeholder = ["Unknown command"];
|
||||
if (!commands) {
|
||||
return placeholder;
|
||||
}
|
||||
let platformCommands = commands[platform as keyof Commands];
|
||||
|
||||
if (!platformCommands) {
|
||||
// get first key
|
||||
const firstPlatform = Object.keys(commands)[0];
|
||||
platformCommands = commands[firstPlatform as keyof Commands];
|
||||
|
||||
setPlatform(firstPlatform);
|
||||
}
|
||||
|
||||
let architectureCommands = platformCommands[architecture];
|
||||
if (!architectureCommands) {
|
||||
// get first key
|
||||
const firstArchitecture = Object.keys(platformCommands)[0];
|
||||
architectureCommands = platformCommands[firstArchitecture];
|
||||
|
||||
setArchitecture(firstArchitecture);
|
||||
}
|
||||
|
||||
return architectureCommands || placeholder;
|
||||
};
|
||||
|
||||
const getPlatformIcon = (platformName: string) => {
|
||||
switch (platformName) {
|
||||
case "windows":
|
||||
return <FaWindows className="h-4 w-4 mr-2" />;
|
||||
case "mac":
|
||||
return <FaApple className="h-4 w-4 mr-2" />;
|
||||
case "docker":
|
||||
return <FaDocker className="h-4 w-4 mr-2" />;
|
||||
case "freebsd":
|
||||
return <FaFreebsd className="h-4 w-4 mr-2" />;
|
||||
default:
|
||||
return <Terminal className="h-4 w-4 mr-2" />;
|
||||
}
|
||||
};
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(createSiteFormSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
copied: false,
|
||||
method: "newt"
|
||||
}
|
||||
});
|
||||
|
||||
async function onSubmit(data: CreateSiteFormValues) {
|
||||
setCreateLoading(true);
|
||||
|
||||
let payload: CreateSiteBody = {
|
||||
name: data.name,
|
||||
type: data.method
|
||||
};
|
||||
|
||||
if (data.method == "wireguard") {
|
||||
if (!siteDefaults || !wgConfig) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error creating site",
|
||||
description: "Key pair or site defaults not found"
|
||||
});
|
||||
setCreateLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
payload = {
|
||||
...payload,
|
||||
subnet: siteDefaults.subnet,
|
||||
exitNodeId: siteDefaults.exitNodeId,
|
||||
pubKey: publicKey
|
||||
};
|
||||
}
|
||||
if (data.method === "newt") {
|
||||
if (!siteDefaults) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error creating site",
|
||||
description: "Site defaults not found"
|
||||
});
|
||||
setCreateLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
payload = {
|
||||
...payload,
|
||||
subnet: siteDefaults.subnet,
|
||||
exitNodeId: siteDefaults.exitNodeId,
|
||||
secret: siteDefaults.newtSecret,
|
||||
newtId: siteDefaults.newtId
|
||||
};
|
||||
}
|
||||
|
||||
const res = await api
|
||||
.put<
|
||||
AxiosResponse<CreateSiteResponse>
|
||||
>(`/org/${orgId}/site/`, payload)
|
||||
.catch((e) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error creating site",
|
||||
description: formatAxiosError(e)
|
||||
});
|
||||
});
|
||||
|
||||
if (res && res.status === 201) {
|
||||
const data = res.data.data;
|
||||
|
||||
router.push(`/${orgId}/settings/sites/${data.niceId}`);
|
||||
}
|
||||
|
||||
setCreateLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setLoadingPage(true);
|
||||
|
||||
let newtVersion = "latest";
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://api.github.com/repos/fosrl/newt/releases/latest`
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch release info: ${response.statusText}`
|
||||
);
|
||||
}
|
||||
const data = await response.json();
|
||||
const latestVersion = data.tag_name;
|
||||
newtVersion = latestVersion;
|
||||
} catch (error) {
|
||||
console.error("Error fetching latest release:", error);
|
||||
}
|
||||
|
||||
const generatedKeypair = generateKeypair();
|
||||
|
||||
const privateKey = generatedKeypair.privateKey;
|
||||
const publicKey = generatedKeypair.publicKey;
|
||||
|
||||
setPrivateKey(privateKey);
|
||||
setPublicKey(publicKey);
|
||||
|
||||
await api
|
||||
.get(`/org/${orgId}/pick-site-defaults`)
|
||||
.catch((e) => {
|
||||
// update the default value of the form to be local method
|
||||
form.setValue("method", "local");
|
||||
})
|
||||
.then((res) => {
|
||||
if (res && res.status === 200) {
|
||||
const data = res.data.data;
|
||||
|
||||
setSiteDefaults(data);
|
||||
|
||||
const newtId = data.newtId;
|
||||
const newtSecret = data.newtSecret;
|
||||
const newtEndpoint = data.endpoint;
|
||||
|
||||
setNewtId(newtId);
|
||||
setNewtSecret(newtSecret);
|
||||
setNewtEndpoint(newtEndpoint);
|
||||
|
||||
hydrateCommands(
|
||||
newtId,
|
||||
newtSecret,
|
||||
env.app.dashboardUrl,
|
||||
newtVersion
|
||||
);
|
||||
|
||||
hydrateWireGuardConfig(
|
||||
privateKey,
|
||||
data.publicKey,
|
||||
data.subnet,
|
||||
data.address,
|
||||
data.endpoint,
|
||||
data.listenPort
|
||||
);
|
||||
|
||||
setTunnelTypes((prev: any) => {
|
||||
return prev.map((item: any) => {
|
||||
return {
|
||||
...item,
|
||||
disabled: false
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
setLoadingPage(false);
|
||||
};
|
||||
|
||||
load();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex-row">
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<Link href="../">Sites</Link>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>Create Site</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<HeaderTitle
|
||||
title="Create Site"
|
||||
description="Follow the steps below to create and connect a new site"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
router.push(`/${orgId}/settings/sites`);
|
||||
}}
|
||||
>
|
||||
See All Sites
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!loadingPage && (
|
||||
<div>
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Site Information
|
||||
</SettingsSectionTitle>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-4"
|
||||
id="create-site-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Name
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
This is the the
|
||||
display name for the
|
||||
site.
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Tunnel Type
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Determine how you want to connect to your
|
||||
site
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<StrategySelect
|
||||
options={tunnelTypes}
|
||||
defaultValue={
|
||||
form.getValues("method") as string
|
||||
}
|
||||
onChange={(value) =>
|
||||
form.setValue("method", value)
|
||||
}
|
||||
cols={3}
|
||||
/>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
{form.watch("method") === "newt" && (
|
||||
<>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Newt Credentials
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
This is how Newt will authenticate
|
||||
with the server
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<InfoSections cols={3}>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
Newt Endpoint
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard
|
||||
text={
|
||||
env.app.dashboardUrl
|
||||
}
|
||||
/>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
Newt ID
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard
|
||||
text={newtId}
|
||||
/>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
Newt Secret Key
|
||||
</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard
|
||||
text={newtSecret}
|
||||
/>
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
</InfoSections>
|
||||
|
||||
<Alert variant="default" className="">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
Save Your Credentials
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
You will only be able to see
|
||||
this once. Make sure to copy it
|
||||
to a secure place.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-4"
|
||||
id="create-site-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="copied"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="terms"
|
||||
defaultChecked={
|
||||
form.getValues(
|
||||
"copied"
|
||||
) as boolean
|
||||
}
|
||||
onCheckedChange={(
|
||||
e
|
||||
) => {
|
||||
form.setValue(
|
||||
"copied",
|
||||
e as boolean
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor="terms"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
I have
|
||||
copied the
|
||||
config
|
||||
</label>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
Install Newt
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Get Newt running on your system
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<div>
|
||||
<p className="font-bold mb-3">
|
||||
Operating System
|
||||
</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
{[
|
||||
"linux",
|
||||
"docker",
|
||||
"mac",
|
||||
"windows",
|
||||
"freebsd"
|
||||
].map((os) => (
|
||||
<Button
|
||||
key={os}
|
||||
variant={
|
||||
platform === os
|
||||
? "squareOutlinePrimary"
|
||||
: "squareOutline"
|
||||
}
|
||||
className={`flex-1 min-w-[120px] ${platform === os ? "bg-primary/10" : ""}`}
|
||||
onClick={() => {
|
||||
setPlatform(os);
|
||||
}}
|
||||
>
|
||||
{getPlatformIcon(os)}
|
||||
{getPlatformName(os)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="font-bold mb-3">
|
||||
{platform === "docker"
|
||||
? "Method"
|
||||
: "Architecture"}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
{getArchitectures().map(
|
||||
(arch) => (
|
||||
<Button
|
||||
key={arch}
|
||||
variant={
|
||||
architecture ===
|
||||
arch
|
||||
? "squareOutlinePrimary"
|
||||
: "squareOutline"
|
||||
}
|
||||
className={`flex-1 min-w-[120px] ${architecture === arch ? "bg-primary/10" : ""}`}
|
||||
onClick={() =>
|
||||
setArchitecture(
|
||||
arch
|
||||
)
|
||||
}
|
||||
>
|
||||
{arch}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div className="pt-4">
|
||||
<p className="font-bold mb-3">
|
||||
Commands
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<CopyTextBox
|
||||
text={getCommand().join(
|
||||
"\n"
|
||||
)}
|
||||
outline={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
</>
|
||||
)}
|
||||
|
||||
{form.watch("method") === "wireguard" && (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
WireGuard Configuration
|
||||
</SettingsSectionTitle>
|
||||
<SettingsSectionDescription>
|
||||
Use the following configuration to
|
||||
connect to your network
|
||||
</SettingsSectionDescription>
|
||||
</SettingsSectionHeader>
|
||||
<SettingsSectionBody>
|
||||
<CopyTextBox text={wgConfig} />
|
||||
|
||||
<Alert variant="default">
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle className="font-semibold">
|
||||
Save Your Credentials
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
You will only be able to see this
|
||||
once. Make sure to copy it to a
|
||||
secure place.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
className="space-y-4"
|
||||
id="create-site-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="copied"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="terms"
|
||||
defaultChecked={
|
||||
form.getValues(
|
||||
"copied"
|
||||
) as boolean
|
||||
}
|
||||
onCheckedChange={(
|
||||
e
|
||||
) => {
|
||||
form.setValue(
|
||||
"copied",
|
||||
e as boolean
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor="terms"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
I have copied
|
||||
the config
|
||||
</label>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionBody>
|
||||
</SettingsSection>
|
||||
)}
|
||||
</SettingsContainer>
|
||||
|
||||
<div className="flex justify-end space-x-2 mt-8">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
router.push(`/${orgId}/settings/sites`);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
>
|
||||
Create Site
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
71
src/app/admin/layout.tsx
Normal file
71
src/app/admin/layout.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Metadata } from "next";
|
||||
import { TopbarNav } from "@app/components/TopbarNav";
|
||||
import { Users } from "lucide-react";
|
||||
import { Header } from "@app/components/Header";
|
||||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
import UserProvider from "@app/providers/UserProvider";
|
||||
import { ListOrgsResponse } from "@server/routers/org";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Server Admin - Pangolin`,
|
||||
description: ""
|
||||
};
|
||||
|
||||
const topNavItems = [
|
||||
{
|
||||
title: "All Users",
|
||||
href: "/admin/users",
|
||||
icon: <Users className="h-4 w-4" />
|
||||
}
|
||||
];
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default async function SettingsLayout(props: LayoutProps) {
|
||||
const getUser = cache(verifySession);
|
||||
const user = await getUser();
|
||||
|
||||
if (!user || !user.serverAdmin) {
|
||||
redirect(`/`);
|
||||
}
|
||||
|
||||
const cookie = await authCookieHeader();
|
||||
let orgs: ListOrgsResponse["orgs"] = [];
|
||||
try {
|
||||
const getOrgs = cache(() =>
|
||||
internal.get<AxiosResponse<ListOrgsResponse>>(`/orgs`, cookie)
|
||||
);
|
||||
const res = await getOrgs();
|
||||
if (res && res.data.data.orgs) {
|
||||
orgs = res.data.data.orgs;
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full bg-card sm:px-0 fixed top-0 z-10 border-b">
|
||||
<div className="container mx-auto flex flex-col content-between">
|
||||
<div className="my-4 px-3 md:px-0">
|
||||
<UserProvider user={user}>
|
||||
<Header orgId={""} orgs={orgs} />
|
||||
</UserProvider>
|
||||
</div>
|
||||
<TopbarNav items={topNavItems} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container mx-auto sm:px-0 px-3 pt-[155px]">
|
||||
{props.children}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
11
src/app/admin/page.tsx
Normal file
11
src/app/admin/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import { cache } from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
type AdminPageProps = {};
|
||||
|
||||
export default async function OrgPage(props: AdminPageProps) {
|
||||
redirect(`/admin/users`);
|
||||
|
||||
return <></>;
|
||||
}
|
||||
141
src/app/admin/users/AdminUsersDataTable.tsx
Normal file
141
src/app/admin/users/AdminUsersDataTable.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
getPaginationRowModel,
|
||||
SortingState,
|
||||
getSortedRowModel,
|
||||
ColumnFiltersState,
|
||||
getFilteredRowModel
|
||||
} from "@tanstack/react-table";
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from "@/components/ui/table";
|
||||
import { useState } from "react";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { DataTablePagination } from "@app/components/DataTablePagination";
|
||||
import { Search } from "lucide-react";
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
}
|
||||
|
||||
export function UsersDataTable<TData, TValue>({
|
||||
columns,
|
||||
data
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageSize: 20,
|
||||
pageIndex: 0
|
||||
}
|
||||
},
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between pb-4">
|
||||
<div className="flex items-center max-w-sm mr-2 w-full relative">
|
||||
<Input
|
||||
placeholder="Search server users"
|
||||
value={
|
||||
(table
|
||||
.getColumn("email")
|
||||
?.getFilterValue() as string) ?? ""
|
||||
}
|
||||
onChange={(event) =>
|
||||
table
|
||||
.getColumn("name")
|
||||
?.setFilterValue(event.target.value)
|
||||
}
|
||||
className="w-full pl-8"
|
||||
/>
|
||||
<Search className="h-4 w-4 absolute left-2 top-1/2 transform -translate-y-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef
|
||||
.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={
|
||||
row.getIsSelected() && "selected"
|
||||
}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
This server has no users.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<div className="mt-4">
|
||||
<DataTablePagination table={table} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
151
src/app/admin/users/AdminUsersTable.tsx
Normal file
151
src/app/admin/users/AdminUsersTable.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { UsersDataTable } from "./AdminUsersDataTable";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { ArrowRight, ArrowUpDown } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
|
||||
export type GlobalUserRow = {
|
||||
id: string;
|
||||
email: string;
|
||||
dateCreated: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
users: GlobalUserRow[];
|
||||
};
|
||||
|
||||
export default function UsersTable({ users }: Props) {
|
||||
const router = useRouter();
|
||||
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selected, setSelected] = useState<GlobalUserRow | null>(null);
|
||||
const [rows, setRows] = useState<GlobalUserRow[]>(users);
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const deleteUser = (id: string) => {
|
||||
api.delete(`/user/${id}`)
|
||||
.catch((e) => {
|
||||
console.error("Error deleting user", e);
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error deleting user",
|
||||
description: formatAxiosError(e, "Error deleting user")
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
router.refresh();
|
||||
setIsDeleteModalOpen(false);
|
||||
|
||||
const newRows = rows.filter((row) => row.id !== id);
|
||||
|
||||
setRows(newRows);
|
||||
});
|
||||
};
|
||||
|
||||
const columns: ColumnDef<GlobalUserRow>[] = [
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
ID
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "email",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
Email
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
variant={"outlinePrimary"}
|
||||
className="ml-2"
|
||||
onClick={() => {
|
||||
setSelected(r);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{selected && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
setOpen={(val) => {
|
||||
setIsDeleteModalOpen(val);
|
||||
setSelected(null);
|
||||
}}
|
||||
dialog={
|
||||
<div className="space-y-4">
|
||||
<p>
|
||||
Are you sure you want to permanently delete{" "}
|
||||
<b>{selected?.email || selected?.id}</b> from
|
||||
the server?
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<b>
|
||||
The user will be removed from all
|
||||
organizations and be completely removed from
|
||||
the server.
|
||||
</b>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
To confirm, please type the email of the user
|
||||
below.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
buttonText="Confirm Delete User"
|
||||
onConfirm={async () => deleteUser(selected!.id)}
|
||||
string={selected.email}
|
||||
title="Delete User from Server"
|
||||
/>
|
||||
)}
|
||||
|
||||
<UsersDataTable columns={columns} data={rows} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
44
src/app/admin/users/page.tsx
Normal file
44
src/app/admin/users/page.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { AxiosResponse } from "axios";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { AdminListUsersResponse } from "@server/routers/user/adminListUsers";
|
||||
import UsersTable, { GlobalUserRow } from "./AdminUsersTable";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function UsersPage(props: PageProps) {
|
||||
let rows: AdminListUsersResponse["users"] = [];
|
||||
try {
|
||||
const res = await internal.get<AxiosResponse<AdminListUsersResponse>>(
|
||||
`/users`,
|
||||
await authCookieHeader()
|
||||
);
|
||||
rows = res.data.data.users;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
const userRows: GlobalUserRow[] = rows.map((row) => {
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
dateCreated: row.dateCreated,
|
||||
serverAdmin: row.serverAdmin
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
title="Manage All Users"
|
||||
description="View and manage all users in the system"
|
||||
/>
|
||||
<UsersTable users={userRows} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -23,14 +23,7 @@ import {
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@/components/ui/form";
|
||||
import {
|
||||
LockIcon,
|
||||
Binary,
|
||||
Key,
|
||||
User,
|
||||
Send,
|
||||
AtSign
|
||||
} from "lucide-react";
|
||||
import { LockIcon, Binary, Key, User, Send, AtSign } from "lucide-react";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
@@ -50,6 +43,7 @@ import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import Link from "next/link";
|
||||
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
|
||||
|
||||
const pinSchema = z.object({
|
||||
pin: z
|
||||
@@ -115,6 +109,8 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
|
||||
const api = createApiClient({ env });
|
||||
|
||||
const { supporterStatus } = useSupporterStatusContext();
|
||||
|
||||
function getDefaultSelectedMethod() {
|
||||
if (props.methods.sso) {
|
||||
return "sso";
|
||||
@@ -194,7 +190,10 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
|
||||
const session = res.data.data.session;
|
||||
if (session) {
|
||||
window.location.href = appendRequestToken(props.redirect, session);
|
||||
window.location.href = appendRequestToken(
|
||||
props.redirect,
|
||||
session
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
@@ -216,7 +215,10 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
setPincodeError(null);
|
||||
const session = res.data.data.session;
|
||||
if (session) {
|
||||
window.location.href = appendRequestToken(props.redirect, session);
|
||||
window.location.href = appendRequestToken(
|
||||
props.redirect,
|
||||
session
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
@@ -241,7 +243,10 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
setPasswordError(null);
|
||||
const session = res.data.data.session;
|
||||
if (session) {
|
||||
window.location.href = appendRequestToken(props.redirect, session);
|
||||
window.location.href = appendRequestToken(
|
||||
props.redirect,
|
||||
session
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
@@ -621,6 +626,15 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{supporterStatus?.visible && (
|
||||
<div className="text-center mt-2">
|
||||
<span className="text-sm text-muted-foreground opacity-50">
|
||||
Server is running without a supporter key.
|
||||
<br />
|
||||
Consider supporting the project!
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ResourceAccessDenied />
|
||||
|
||||
@@ -6,14 +6,20 @@ import { ThemeProvider } from "@app/providers/ThemeProvider";
|
||||
import EnvProvider from "@app/providers/EnvProvider";
|
||||
import { Separator } from "@app/components/ui/separator";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
import { BookOpenText } from "lucide-react";
|
||||
import { BookOpenText, ExternalLink } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import SupportStatusProvider from "@app/providers/SupporterStatusProvider";
|
||||
import { createApiClient, internal, priv } from "@app/lib/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { IsSupporterKeyVisibleResponse } from "@server/routers/supporterKey";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Dashboard - Pangolin`,
|
||||
description: ""
|
||||
};
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
// const font = Figtree({ subsets: ["latin"] });
|
||||
const font = Inter({ subsets: ["latin"] });
|
||||
|
||||
@@ -24,6 +30,16 @@ export default async function RootLayout({
|
||||
}>) {
|
||||
const env = pullEnv();
|
||||
|
||||
let supporterData = {
|
||||
visible: true
|
||||
} as any;
|
||||
|
||||
const res = await priv.get<
|
||||
AxiosResponse<IsSupporterKeyVisibleResponse>
|
||||
>("supporter-key/visible");
|
||||
supporterData.visible = res.data.data.visible;
|
||||
supporterData.tier = res.data.data.tier;
|
||||
|
||||
const version = env.app.version;
|
||||
|
||||
return (
|
||||
@@ -36,58 +52,69 @@ export default async function RootLayout({
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<EnvProvider env={pullEnv()}>
|
||||
{/* Main content */}
|
||||
<div className="flex-grow pb-3 md:pb-0">{children}</div>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="hidden md:block w-full mt-12 py-3 mb-6 px-4">
|
||||
<div className="container mx-auto flex flex-wrap justify-center items-center h-3 space-x-4 text-sm text-neutral-400 dark:text-neutral-600">
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<span>Pangolin</span>
|
||||
</div>
|
||||
<Separator orientation="vertical" />
|
||||
<div className="whitespace-nowrap">
|
||||
Built by Fossorial
|
||||
</div>
|
||||
<Separator orientation="vertical" />
|
||||
<a
|
||||
href="https://github.com/fosrl/pangolin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="GitHub"
|
||||
className="flex items-center space-x-3 whitespace-nowrap"
|
||||
>
|
||||
<span>Open Source</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="w-3 h-3"
|
||||
>
|
||||
<path d="M12 0C5.37 0 0 5.373 0 12c0 5.303 3.438 9.8 8.207 11.385.6.11.82-.26.82-.577v-2.17c-3.338.726-4.042-1.61-4.042-1.61-.546-1.385-1.333-1.755-1.333-1.755-1.09-.744.082-.73.082-.73 1.205.085 1.84 1.24 1.84 1.24 1.07 1.835 2.807 1.305 3.492.997.107-.775.42-1.305.763-1.605-2.665-.305-5.467-1.335-5.467-5.93 0-1.31.468-2.382 1.236-3.22-.123-.303-.535-1.523.117-3.176 0 0 1.008-.322 3.3 1.23a11.52 11.52 0 013.006-.403c1.02.005 2.045.137 3.006.403 2.29-1.552 3.295-1.23 3.295-1.23.654 1.653.242 2.873.12 3.176.77.838 1.235 1.91 1.235 3.22 0 4.605-2.805 5.623-5.475 5.92.43.37.814 1.1.814 2.22v3.293c0 .32.217.693.825.576C20.565 21.795 24 17.298 24 12 24 5.373 18.627 0 12 0z" />
|
||||
</svg>
|
||||
</a>
|
||||
<Separator orientation="vertical" />
|
||||
<a
|
||||
href="https://docs.fossorial.io/Pangolin/overview"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Documentation"
|
||||
className="flex items-center space-x-3 whitespace-nowrap"
|
||||
>
|
||||
<span>Documentation</span>
|
||||
<BookOpenText className="w-3 h-3" />
|
||||
</a>
|
||||
{version && (
|
||||
<>
|
||||
<Separator orientation="vertical" />
|
||||
<div className="whitespace-nowrap">
|
||||
v{version}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<SupportStatusProvider supporterStatus={supporterData}>
|
||||
{/* Main content */}
|
||||
<div className="flex-grow pb-3 md:pb-0">
|
||||
{children}
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="hidden md:block w-full mt-12 py-3 mb-6 px-4">
|
||||
<div className="container mx-auto flex flex-wrap justify-center items-center h-3 space-x-4 text-sm text-neutral-400 dark:text-neutral-600">
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<span>Pangolin</span>
|
||||
</div>
|
||||
<Separator orientation="vertical" />
|
||||
<a
|
||||
href="https://fossorial.io/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Built by Fossorial"
|
||||
className="flex items-center space-x-3 whitespace-nowrap"
|
||||
>
|
||||
<span>Fossorial</span>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
<Separator orientation="vertical" />
|
||||
<a
|
||||
href="https://github.com/fosrl/pangolin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="GitHub"
|
||||
className="flex items-center space-x-3 whitespace-nowrap"
|
||||
>
|
||||
<span>Open Source</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="w-3 h-3"
|
||||
>
|
||||
<path d="M12 0C5.37 0 0 5.373 0 12c0 5.303 3.438 9.8 8.207 11.385.6.11.82-.26.82-.577v-2.17c-3.338.726-4.042-1.61-4.042-1.61-.546-1.385-1.333-1.755-1.333-1.755-1.09-.744.082-.73.082-.73 1.205.085 1.84 1.24 1.84 1.24 1.07 1.835 2.807 1.305 3.492.997.107-.775.42-1.305.763-1.605-2.665-.305-5.467-1.335-5.467-5.93 0-1.31.468-2.382 1.236-3.22-.123-.303-.535-1.523.117-3.176 0 0 1.008-.322 3.3 1.23a11.52 11.52 0 013.006-.403c1.02.005 2.045.137 3.006.403 2.29-1.552 3.295-1.23 3.295-1.23.654 1.653.242 2.873.12 3.176.77.838 1.235 1.91 1.235 3.22 0 4.605-2.805 5.623-5.475 5.92.43.37.814 1.1.814 2.22v3.293c0 .32.217.693.825.576C20.565 21.795 24 17.298 24 12 24 5.373 18.627 0 12 0z" />
|
||||
</svg>
|
||||
</a>
|
||||
<Separator orientation="vertical" />
|
||||
<a
|
||||
href="https://docs.fossorial.io/Pangolin/overview"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Documentation"
|
||||
className="flex items-center space-x-3 whitespace-nowrap"
|
||||
>
|
||||
<span>Documentation</span>
|
||||
<BookOpenText className="w-3 h-3" />
|
||||
</a>
|
||||
{version && (
|
||||
<>
|
||||
<Separator orientation="vertical" />
|
||||
<div className="whitespace-nowrap">
|
||||
v{version}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
</SupportStatusProvider>
|
||||
</EnvProvider>
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -96,7 +96,8 @@ export default function StepperForm() {
|
||||
});
|
||||
|
||||
if (res && res.status === 201) {
|
||||
setCurrentStep("site");
|
||||
// setCurrentStep("site");
|
||||
router.push(`/${values.orgId}/settings/sites/create`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -290,42 +291,6 @@ export default function StepperForm() {
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{currentStep === "site" && (
|
||||
<div>
|
||||
<CreateSiteForm
|
||||
setLoading={(val) => setLoading(val)}
|
||||
setChecked={(val) => setIsChecked(val)}
|
||||
orgId={orgForm.getValues().orgId}
|
||||
onCreate={() => {
|
||||
router.push(
|
||||
`/${orgForm.getValues().orgId}/settings/resources`
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-between mt-6">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
router.push(
|
||||
`/${orgForm.getValues().orgId}/settings/sites`
|
||||
);
|
||||
}}
|
||||
>
|
||||
Skip for now
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="create-site-form"
|
||||
loading={loading}
|
||||
disabled={loading || !isChecked}
|
||||
>
|
||||
Create Site
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -4,7 +4,11 @@ import { useState, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Copy, Check } from "lucide-react";
|
||||
|
||||
export default function CopyTextBox({ text = "", wrapText = false }) {
|
||||
export default function CopyTextBox({
|
||||
text = "",
|
||||
wrapText = false,
|
||||
outline = true
|
||||
}) {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const textRef = useRef<HTMLPreElement>(null);
|
||||
|
||||
@@ -23,7 +27,9 @@ export default function CopyTextBox({ text = "", wrapText = false }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative w-full border rounded-md bg-card">
|
||||
<div
|
||||
className={`relative w-full border rounded-md ${!outline ? "bg-muted" : "bg-card"}`}
|
||||
>
|
||||
<pre
|
||||
ref={textRef}
|
||||
className={`p-4 pr-16 text-sm w-full ${
|
||||
|
||||
@@ -20,18 +20,32 @@ const CopyToClipboard = ({ text, isLink }: CopyToClipboardProps) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center space-x-2 max-w-full">
|
||||
{isLink ? (
|
||||
<Link
|
||||
href={text}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline mr-2"
|
||||
className="truncate hover:underline"
|
||||
style={{ maxWidth: "100%" }} // Ensures truncation works within parent
|
||||
title={text} // Shows full text on hover
|
||||
>
|
||||
{text}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="mr-2">{text}</span>
|
||||
<span
|
||||
className="truncate"
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
display: "block",
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis"
|
||||
}}
|
||||
title={text} // Full text tooltip
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useUserContext } from "@app/hooks/useUserContext";
|
||||
import ProfileIcon from "./ProfileIcon";
|
||||
import SupporterStatus from "./SupporterStatus";
|
||||
|
||||
type HeaderProps = {
|
||||
orgId?: string;
|
||||
@@ -42,7 +43,13 @@ export function Header({ orgId, orgs }: HeaderProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<ProfileIcon />
|
||||
<div className="flex items-center gap-2">
|
||||
<ProfileIcon />
|
||||
|
||||
<div className="hidden md:block">
|
||||
<SupporterStatus />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<div className="hidden md:block">
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
"use client";
|
||||
|
||||
export function InfoSections({ children }: { children: React.ReactNode }) {
|
||||
export function InfoSections({
|
||||
children,
|
||||
cols
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
cols?: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:gap-4 gap-2 md:grid-cols-[1fr_auto_1fr] md:items-start">
|
||||
<div
|
||||
className={`grid md:grid-cols-${cols || 1} md:gap-4 gap-2 md:items-start grid-cols-1`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -23,9 +31,3 @@ export function InfoSectionContent({
|
||||
}) {
|
||||
return <div className="break-words">{children}</div>;
|
||||
}
|
||||
|
||||
export function Divider() {
|
||||
return (
|
||||
<div className="hidden md:block border-l border-gray-300 h-auto mx-4"></div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,11 +3,11 @@ export function SettingsContainer({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
export function SettingsSection({ children }: { children: React.ReactNode }) {
|
||||
return <div className="border rounded-md bg-card p-4">{children}</div>
|
||||
return <div className="border rounded-lg bg-card p-5">{children}</div>
|
||||
}
|
||||
|
||||
export function SettingsSectionHeader({ children }: { children: React.ReactNode }) {
|
||||
return <div className="space-y-0.5 pb-6">{children}</div>
|
||||
return <div className="text-lg space-y-0.5 pb-6">{children}</div>
|
||||
}
|
||||
|
||||
export function SettingsSectionForm({ children }: { children: React.ReactNode }) {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
type SettingsSectionTitleProps = {
|
||||
title: string | React.ReactNode;
|
||||
description: string | React.ReactNode;
|
||||
description?: string | React.ReactNode;
|
||||
size?: "2xl" | "1xl";
|
||||
};
|
||||
|
||||
export default function SettingsSectionTitle({
|
||||
title,
|
||||
description,
|
||||
size,
|
||||
size
|
||||
}: SettingsSectionTitleProps) {
|
||||
return (
|
||||
<div
|
||||
@@ -20,7 +20,9 @@ export default function SettingsSectionTitle({
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<p className="text-muted-foreground">{description}</p>
|
||||
{description && (
|
||||
<p className="text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,42 +2,59 @@
|
||||
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { RadioGroup, RadioGroupItem } from "./ui/radio-group";
|
||||
import { useState } from "react";
|
||||
|
||||
interface StrategyOption {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
disabled?: boolean; // New optional property
|
||||
}
|
||||
|
||||
interface StrategySelectProps {
|
||||
options: StrategyOption[];
|
||||
defaultValue?: string;
|
||||
onChange?: (value: string) => void;
|
||||
cols?: number;
|
||||
}
|
||||
|
||||
export function StrategySelect({
|
||||
options,
|
||||
defaultValue,
|
||||
onChange
|
||||
onChange,
|
||||
cols
|
||||
}: StrategySelectProps) {
|
||||
const [selected, setSelected] = useState(defaultValue);
|
||||
|
||||
return (
|
||||
<RadioGroup
|
||||
defaultValue={defaultValue}
|
||||
onValueChange={onChange}
|
||||
className="grid gap-4"
|
||||
onValueChange={(value) => {
|
||||
setSelected(value);
|
||||
onChange?.(value);
|
||||
}}
|
||||
className={`grid md:grid-cols-${cols ? cols : 1} gap-4`}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<label
|
||||
key={option.id}
|
||||
htmlFor={option.id}
|
||||
data-state={
|
||||
selected === option.id ? "checked" : "unchecked"
|
||||
}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer rounded-lg border-2 p-4",
|
||||
"data-[state=checked]:border-primary data-[state=checked]:bg-primary/10 data-[state=checked]:text-primary"
|
||||
"relative flex rounded-lg border-2 p-4 transition-colors cursor-pointer",
|
||||
option.disabled
|
||||
? "border-input text-muted-foreground cursor-not-allowed opacity-50"
|
||||
: selected === option.id
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-input hover:bg-accent"
|
||||
)}
|
||||
>
|
||||
<RadioGroupItem
|
||||
value={option.id}
|
||||
id={option.id}
|
||||
disabled={option.disabled}
|
||||
className="absolute left-4 top-5 h-4 w-4 border-primary text-primary"
|
||||
/>
|
||||
<div className="pl-7">
|
||||
|
||||
381
src/components/SupporterStatus.tsx
Normal file
381
src/components/SupporterStatus.tsx
Normal file
@@ -0,0 +1,381 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { Separator } from "@app/components/ui/separator";
|
||||
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger
|
||||
} from "@app/components/ui/popover";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "./Credenza";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "./ui/form";
|
||||
import { Input } from "./ui/input";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { ValidateSupporterKeyResponse } from "@server/routers/supporterKey";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "./ui/card";
|
||||
import { Check, ExternalLink } from "lucide-react";
|
||||
|
||||
const formSchema = z.object({
|
||||
githubUsername: z
|
||||
.string()
|
||||
.nonempty({ message: "GitHub username is required" }),
|
||||
key: z.string().nonempty({ message: "Supporter key is required" })
|
||||
});
|
||||
|
||||
export default function SupporterStatus() {
|
||||
const { supporterStatus, updateSupporterStatus } =
|
||||
useSupporterStatusContext();
|
||||
const [supportOpen, setSupportOpen] = useState(false);
|
||||
const [keyOpen, setKeyOpen] = useState(false);
|
||||
const [purchaseOptionsOpen, setPurchaseOptionsOpen] = useState(false);
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
githubUsername: "",
|
||||
key: ""
|
||||
}
|
||||
});
|
||||
|
||||
async function hide() {
|
||||
await api.post("/supporter-key/hide");
|
||||
|
||||
updateSupporterStatus({
|
||||
visible: false
|
||||
});
|
||||
}
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
try {
|
||||
const res = await api.post<
|
||||
AxiosResponse<ValidateSupporterKeyResponse>
|
||||
>("/supporter-key/validate", {
|
||||
githubUsername: values.githubUsername,
|
||||
key: values.key
|
||||
});
|
||||
|
||||
const data = res.data.data;
|
||||
|
||||
if (!data || !data.valid) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Invalid Key",
|
||||
description: "Your supporter key is invalid."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
variant: "default",
|
||||
title: "Valid Key",
|
||||
description:
|
||||
"Your supporter key has been validated. Thank you for your support!"
|
||||
});
|
||||
|
||||
setPurchaseOptionsOpen(false);
|
||||
setKeyOpen(false);
|
||||
|
||||
updateSupporterStatus({
|
||||
visible: false
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: formatAxiosError(
|
||||
error,
|
||||
"Failed to validate supporter key."
|
||||
)
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Credenza
|
||||
open={purchaseOptionsOpen}
|
||||
onOpenChange={(val) => {
|
||||
setPurchaseOptionsOpen(val);
|
||||
}}
|
||||
>
|
||||
<CredenzaContent className="max-w-3xl">
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
Support Development and Adopt a Pangolin!
|
||||
</CredenzaTitle>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<p>
|
||||
Purchase a supporter key to help us continue
|
||||
developing Pangolin. Your contribution allows us
|
||||
commit more time to maintain and add new features to
|
||||
the application for everyone. We will never use this
|
||||
to paywall features.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
You will also get to adopt and meet your very own
|
||||
pet Pangolin!
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Payments are processed via GitHub. Afterward, you
|
||||
can retrieve your key on{" "}
|
||||
<Link
|
||||
href="https://supporters.dev.fossorial.io/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
our website
|
||||
</Link>{" "}
|
||||
and redeem it here.{" "}
|
||||
<Link
|
||||
href="https://docs.fossorial.io/supporter-program"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
Learn more.
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<p>Please select the option that best suits you.</p>
|
||||
|
||||
<div className="grid md:grid-cols-2 grid-cols-1 gap-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Full Supporter</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-4xl mb-6">$95</p>
|
||||
<ul className="space-y-3">
|
||||
<li className="flex items-center gap-2">
|
||||
<Check className="h-6 w-6 text-green-500" />
|
||||
<span className="text-muted-foreground">
|
||||
For the whole server
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Check className="h-6 w-6 text-green-500" />
|
||||
<span className="text-muted-foreground">
|
||||
Lifetime purchase
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Check className="h-6 w-6 text-green-500" />
|
||||
<span className="text-muted-foreground">
|
||||
Supporter status
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Link
|
||||
href="https://github.com/sponsors/fosrl/sponsorships?tier_id=474929"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-full"
|
||||
>
|
||||
<Button className="w-full">Buy</Button>
|
||||
</Link>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className={`${supporterStatus?.tier === "Limited Supporter" ? "opacity-50" : ""}`}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>Limited Supporter</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-4xl mb-6">$25</p>
|
||||
<ul className="space-y-3">
|
||||
<li className="flex items-center gap-2">
|
||||
<Check className="h-6 w-6 text-green-500" />
|
||||
<span className="text-muted-foreground">
|
||||
For 5 or less users
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Check className="h-6 w-6 text-green-500" />
|
||||
<span className="text-muted-foreground">
|
||||
Lifetime purchase
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<Check className="h-6 w-6 text-green-500" />
|
||||
<span className="text-muted-foreground">
|
||||
Supporter status
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
{supporterStatus?.tier !==
|
||||
"Limited Supporter" ? (
|
||||
<Link
|
||||
href="https://github.com/sponsors/fosrl/sponsorships?tier_id=463100"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-full"
|
||||
>
|
||||
<Button className="w-full">
|
||||
Buy
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={
|
||||
supporterStatus?.tier ===
|
||||
"Limited Supporter"
|
||||
}
|
||||
>
|
||||
Buy
|
||||
</Button>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="w-full pt-6 space-y-2">
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outlinePrimary"
|
||||
onClick={() => {
|
||||
setKeyOpen(true);
|
||||
}}
|
||||
>
|
||||
Redeem Supporter Key
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full"
|
||||
onClick={() => hide()}
|
||||
>
|
||||
Hide for 7 days
|
||||
</Button>
|
||||
</div>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</CredenzaClose>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
|
||||
<Credenza
|
||||
open={keyOpen}
|
||||
onOpenChange={(val) => {
|
||||
setKeyOpen(val);
|
||||
}}
|
||||
>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>Enter Supporter Key</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
Meet your very own pet Pangolin!
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="githubUsername"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
GitHub Username
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Supporter Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</CredenzaClose>
|
||||
<Button type="submit" form="form">
|
||||
Submit
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
|
||||
{supporterStatus?.visible ? (
|
||||
<Button
|
||||
variant="outlinePrimary"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
onClick={() => {
|
||||
setPurchaseOptionsOpen(true);
|
||||
}}
|
||||
>
|
||||
Buy Supporter Key
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -21,20 +21,26 @@ const buttonVariants = cva(
|
||||
secondary:
|
||||
"bg-secondary border border-input border-2 text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
squareOutlinePrimary:
|
||||
"border-2 border-primary bg-card hover:bg-primary/10 text-primary rounded-md",
|
||||
squareOutline:
|
||||
"border-2 border-input bg-card hover:bg-accent hover:text-accent-foreground rounded-md",
|
||||
squareDefault:
|
||||
"bg-primary text-primary-foreground hover:bg-primary/90 rounded-md",
|
||||
text: "",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
link: "text-primary underline-offset-4 hover:underline"
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
icon: "h-9 w-9"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
size: "default"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ const Checkbox = React.forwardRef<
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
"peer h-4 w-4 shrink-0 rounded-sm border-2 border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as React from "react"
|
||||
import { cn } from "@app/lib/cn"
|
||||
|
||||
export function TableContainer({ children }: { children: React.ReactNode }) {
|
||||
return <div className="border rounded-md bg-card">{children}</div>
|
||||
return <div className="border rounded-lg bg-card">{children}</div>
|
||||
}
|
||||
|
||||
const Table = React.forwardRef<
|
||||
|
||||
17
src/contexts/supporterStatusContext.ts
Normal file
17
src/contexts/supporterStatusContext.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
export type SupporterStatus = {
|
||||
visible: boolean;
|
||||
tier?: string;
|
||||
};
|
||||
|
||||
type SupporterStatusContextType = {
|
||||
supporterStatus: SupporterStatus | null;
|
||||
updateSupporterStatus: (updatedSite: Partial<SupporterStatus>) => void;
|
||||
};
|
||||
|
||||
const SupporterStatusContext = createContext<
|
||||
SupporterStatusContextType | undefined
|
||||
>(undefined);
|
||||
|
||||
export default SupporterStatusContext;
|
||||
12
src/hooks/useSupporterStatusContext.ts
Normal file
12
src/hooks/useSupporterStatusContext.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import SupporterStatusContext from "@app/contexts/supporterStatusContext";
|
||||
import { useContext } from "react";
|
||||
|
||||
export function useSupporterStatusContext() {
|
||||
const context = useContext(SupporterStatusContext);
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
"useSupporterStatusContext must be used within an SupporterStatusProvider"
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
46
src/providers/SupporterStatusProvider.tsx
Normal file
46
src/providers/SupporterStatusProvider.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import SupportStatusContext, {
|
||||
SupporterStatus
|
||||
} from "@app/contexts/supporterStatusContext";
|
||||
import { useState } from "react";
|
||||
|
||||
interface ProviderProps {
|
||||
children: React.ReactNode;
|
||||
supporterStatus: SupporterStatus | null;
|
||||
}
|
||||
|
||||
export function SupporterStatusProvider({
|
||||
children,
|
||||
supporterStatus
|
||||
}: ProviderProps) {
|
||||
const [supporterStatusState, setSupporterStatusState] =
|
||||
useState<SupporterStatus | null>(supporterStatus);
|
||||
|
||||
const updateSupporterStatus = (
|
||||
updatedSupporterStatus: Partial<SupporterStatus>
|
||||
) => {
|
||||
setSupporterStatusState((prev) => {
|
||||
if (!prev) {
|
||||
return updatedSupporterStatus as SupporterStatus;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
...updatedSupporterStatus
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SupportStatusContext.Provider
|
||||
value={{
|
||||
supporterStatus: supporterStatusState,
|
||||
updateSupporterStatus
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SupportStatusContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default SupporterStatusProvider;
|
||||
55
test/assert.ts
Normal file
55
test/assert.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Compares two objects for deep equality
|
||||
* @param actual The actual value to test
|
||||
* @param expected The expected value to compare against
|
||||
* @param message The message to display if assertion fails
|
||||
* @throws Error if objects are not equal
|
||||
*/
|
||||
export function assertEqualsObj<T>(actual: T, expected: T, message: string): void {
|
||||
const actualStr = JSON.stringify(actual);
|
||||
const expectedStr = JSON.stringify(expected);
|
||||
if (actualStr !== expectedStr) {
|
||||
throw new Error(`${message}\nExpected: ${expectedStr}\nActual: ${actualStr}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares two primitive values for equality
|
||||
* @param actual The actual value to test
|
||||
* @param expected The expected value to compare against
|
||||
* @param message The message to display if assertion fails
|
||||
* @throws Error if values are not equal
|
||||
*/
|
||||
export function assertEquals<T>(actual: T, expected: T, message: string): void {
|
||||
if (actual !== expected) {
|
||||
throw new Error(`${message}\nExpected: ${expected}\nActual: ${actual}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests if a function throws an expected error
|
||||
* @param fn The function to test
|
||||
* @param expectedError The expected error message or part of it
|
||||
* @param message The message to display if assertion fails
|
||||
* @throws Error if function doesn't throw or throws unexpected error
|
||||
*/
|
||||
export function assertThrows(
|
||||
fn: () => void,
|
||||
expectedError: string,
|
||||
message: string
|
||||
): void {
|
||||
try {
|
||||
fn();
|
||||
throw new Error(`${message}: Expected to throw "${expectedError}"`);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error)) {
|
||||
throw new Error(`${message}\nUnexpected error type: ${typeof error}`);
|
||||
}
|
||||
|
||||
if (!error.message.includes(expectedError)) {
|
||||
throw new Error(
|
||||
`${message}\nExpected error: ${expectedError}\nActual error: ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@
|
||||
"baseUrl": "src",
|
||||
"paths": {
|
||||
"@server/*": ["../server/*"],
|
||||
"@test/*": ["../test/*"],
|
||||
"@app/*": ["*"],
|
||||
"@/*": ["./*"]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user