Compare commits

...

37 Commits
1.0.0 ... 1.1.0

Author SHA1 Message Date
miloschwartz
aabdcea3c0 add docs link 2025-03-22 12:37:35 -04:00
miloschwartz
a178faa377 add support links 2025-03-22 12:35:33 -04:00
miloschwartz
edf0ce226f Merge branch 'main' into dev 2025-03-22 12:25:00 -04:00
miloschwartz
7118ae374d fix try catch in supporter keys 2025-03-22 12:24:20 -04:00
miloschwartz
f2a14e6a36 append timestamp to cookie name to prevent redirect loops 2025-03-21 21:38:36 -04:00
miloschwartz
f37be774a6 disable limited tier if already used 2025-03-21 18:36:11 -04:00
miloschwartz
0dcfeb3587 add server admin panel to delete users 2025-03-21 18:04:27 -04:00
Owen
dbfc8b51aa Update default email 2025-03-21 17:18:51 -04:00
miloschwartz
d72a8af04b log failed check key 2025-03-21 17:14:27 -04:00
Owen
7131dea7a0 package-lock update 2025-03-21 17:12:59 -04:00
Owen
deb30ed4ae Add admin user api interfaces 2025-03-21 17:05:04 -04:00
Owen
3b09ef3345 False by default for crowdsec 2nd question 2025-03-21 09:57:28 -04:00
miloschwartz
06e90c9555 don't add wildcard asterisk for base domain resource closes #356 2025-03-20 22:52:29 -04:00
miloschwartz
cdc415079c add supporer key program 2025-03-20 22:16:02 -04:00
miloschwartz
1c2ba4076a add crowdsec warning to installer 2025-03-19 21:17:19 -04:00
miloschwartz
af68aa692c fix header padding on large screens 2025-03-17 11:53:30 -04:00
miloschwartz
edba818615 add new create site workflow 2025-03-16 15:20:19 -04:00
miloschwartz
cdf904a2bc fix broken docs link closes #335 2025-03-15 17:37:27 -04:00
miloschwartz
fedab6c9a8 Merge branch 'main' into dev 2025-03-11 21:03:25 -04:00
Milo Schwartz
33e8ed4c93 Update README.md 2025-03-11 21:02:42 -04:00
miloschwartz
2b54dfe035 fix rules info columns 2025-03-10 12:47:42 -04:00
Milo Schwartz
e601816791 Merge pull request #319 from fosrl/dev
1.0.1
2025-03-10 11:09:38 -04:00
miloschwartz
7a46cf3da7 add border-2 to checkbox 2025-03-10 11:06:02 -04:00
miloschwartz
ad32e5e651 fix base domain overwritten on update closes #282 2025-03-09 22:05:13 -04:00
miloschwartz
8ec55eb70d append site name to resource in list add add site to info card closes #297 2025-03-08 22:11:27 -05:00
Owen
767fec19cd Remove old example config 2025-03-08 20:03:26 -05:00
miloschwartz
d215a12f5a disable auto backup on migration with env var 2025-03-08 18:23:36 -05:00
Owen
d22dcfb464 Optimize container size 2025-03-08 18:11:47 -05:00
miloschwartz
c93b36c757 remove environment variable support and config file autogeneration 2025-03-08 18:06:14 -05:00
Owen Schwartz
9253dd19ba Merge pull request #307 from fosrl/remove_json_group_array
Remove json_group_array from queries
2025-03-08 17:30:46 -05:00
miloschwartz
b9d83a2507 add es.md 2025-03-08 16:06:52 -05:00
Owen
581f96daa8 Remove json_group_array from queries
Also improve handling tcp/udp resources in newt function so it does not
loop twice
2025-03-08 11:58:48 -05:00
Owen
33ff2fbf3b Allow matching parts of words in path
Resolves #228
2025-03-08 11:43:47 -05:00
Owen
535b4e1fb1 Add clean up our few tests and add tests for #228 2025-03-08 11:43:22 -05:00
Owen
5871bea706 Reset port when entering targets
Resolves #270
2025-03-08 10:52:38 -05:00
Owen
07eb422491 Add back metrics port for scripting 2025-03-04 23:52:37 -05:00
Owen
654ed46a46 Return 401 instead of 400 on bad login
Resolves #276
2025-03-04 20:32:48 -05:00
81 changed files with 20560 additions and 726 deletions

View File

@@ -22,7 +22,6 @@ next-env.d.ts
*.log
.machinelogs*.json
*-audit.json
package-lock.json
install/
bruno/
LICENSE

1
.gitignore vendored
View File

@@ -23,7 +23,6 @@ next-env.d.ts
.machinelogs*.json
*-audit.json
migrations
package-lock.json
tsconfig.tsbuildinfo
config/config.yml
dist

View File

@@ -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

View File

@@ -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

View File

@@ -12,7 +12,7 @@ post {
body:json {
{
"email": "owen@fossorial.io",
"email": "admin@fosrl.io",
"password": "Password123!"
}
}

View File

@@ -0,0 +1,11 @@
meta {
name: adminListUsers
type: http
seq: 2
}
get {
url: http://localhost:3000/api/v1/users
body: none
auth: none
}

View 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
}

View File

@@ -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

View File

@@ -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

View File

@@ -41,7 +41,7 @@ gerbil:
rate_limits:
global:
window_minutes: 1
max_requests: 100
max_requests: 500
{{if .EnableEmail}}
email:
smtp_host: "{{.EmailSMTPHost}}"

View File

@@ -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

View File

@@ -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
View 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 organizations 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 | |

View File

@@ -2,7 +2,8 @@
const nextConfig = {
eslint: {
ignoreDuringBuilds: true,
}
},
output: "standalone"
};
export default nextConfig;

17119
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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}`;
}
}

View File

@@ -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>;

View File

@@ -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();

View File

@@ -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);

View File

@@ -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() {

View 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&ampersand'), 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);
}

View File

@@ -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];

View File

@@ -14,3 +14,4 @@ export * from "./verifyAdmin";
export * from "./verifySetResourceUsers";
export * from "./verifyUserInRole";
export * from "./verifyAccessTokenAccess";
export * from "./verifyUserIsServerAdmin";

View 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"
)
);
}
}

View File

@@ -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(

View File

@@ -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"
)
);

View 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);
}

View File

@@ -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(

View File

@@ -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",

View File

@@ -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);

View File

@@ -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: {

View File

@@ -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",

View File

@@ -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}`;

View 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")
);
}
}

View File

@@ -0,0 +1,3 @@
export * from "./validateSupporterKey";
export * from "./isSupporterKeyVisible";
export * from "./hideSupporterKey";

View 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")
);
}
}

View 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")
);
}
}

View File

@@ -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) {

View 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")
);
}
}

View 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")
);
}
}

View File

@@ -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";

View File

@@ -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) {

View File

@@ -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);
}
}

View File

@@ -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

View 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`);
}

View File

@@ -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>
</>
);

View File

@@ -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"
>

View File

@@ -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>

View File

@@ -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) => {

View File

@@ -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>

View File

@@ -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

View File

@@ -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>
)
)}

View File

@@ -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 (

View File

@@ -331,7 +331,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
columns={columns}
data={rows}
addSite={() => {
setIsCreateModalOpen(true);
router.push(`/${orgId}/settings/sites/create`);
}}
/>
</>

View File

@@ -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>

View 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
View 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
View 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 <></>;
}

View 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>
);
}

View 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} />
</>
);
}

View 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} />
</>
);
}

View File

@@ -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 />

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 ${

View File

@@ -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"

View File

@@ -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">

View File

@@ -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>
);
}

View File

@@ -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 }) {

View File

@@ -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>
);
}

View File

@@ -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">

View 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}
</>
);
}

View File

@@ -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"
}
}
);

View File

@@ -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}

View File

@@ -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<

View 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;

View 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;
}

View 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
View 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}`
);
}
}
}

View File

@@ -15,6 +15,7 @@
"baseUrl": "src",
"paths": {
"@server/*": ["../server/*"],
"@test/*": ["../test/*"],
"@app/*": ["*"],
"@/*": ["./*"]
},