Compare commits

..

33 Commits

Author SHA1 Message Date
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
Milo Schwartz
eb73da8aa0 Merge pull request #275 from fosrl/dev
add migration script
2025-03-04 11:14:31 -05:00
miloschwartz
cc6800c791 add migration script 2025-03-04 11:13:34 -05:00
Milo Schwartz
47abdf873a Merge pull request #274 from fosrl/dev
1.0.0
2025-03-04 11:12:14 -05:00
miloschwartz
90366da61b allow hash in url path rule 2025-03-03 19:47:07 -05:00
miloschwartz
5529beaf6e allow anything for hostname closes #265 2025-03-03 17:11:41 -05:00
miloschwartz
93c8236535 update example config files 2025-03-03 17:09:48 -05:00
Owen
37fdc4a6a8 Warn if it did not replace the bouncer key 2025-03-03 15:43:26 -05:00
miloschwartz
a456a37b2f fix typo 2025-03-02 23:24:21 -05:00
miloschwartz
430afe3f93 Merge branch 'dev' of https://github.com/fosrl/pangolin into dev 2025-03-02 22:19:11 -05:00
miloschwartz
3b60e1f3ac update screenshots 2025-03-02 22:19:04 -05:00
Owen
b8543e5fa8 Remove prom exposed port and waf port
Resolves #247
2025-03-02 21:49:42 -05:00
Owen
d594314e52 Remove gerbil depenency and exposed port 9090 2025-03-02 21:47:32 -05:00
Owen
81c142e8ae Merge branch 'dev' of github.com:fosrl/pangolin into dev 2025-03-02 21:46:22 -05:00
miloschwartz
59eedce664 allow setting tks.rejectUnauthorized for Nodemailer in config closes #264 2025-03-02 20:03:20 -05:00
miloschwartz
adef93623d more visual enhancements and use expires instead of max age in cookies 2025-03-02 15:50:03 -05:00
miloschwartz
759434e9f8 more visual enhancements and update readme 2025-03-01 23:03:42 -05:00
miloschwartz
0e38f58a7f minor visual enhancements 2025-03-01 17:45:38 -05:00
92 changed files with 17179 additions and 1854 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

@@ -25,6 +25,10 @@ _Your own self-hosted zero trust tunnel._
<a href="https://docs.fossorial.io">
Full Documentation
</a>
<span> | </span>
<a href="mailto:numbat@fossorial.io">
Contact Us
</a>
</h5>
</div>
@@ -68,41 +72,17 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
### Easy Deployment
- Run on any cloud provider or on-premises.
- Docker Compose based setup for simplified deployment.
- **Docker Compose based setup** for simplified deployment.
- Future-proof installation script for streamlined setup and feature additions.
- Use your preferred WireGuard client to connect, or use Newt, our custom user space client for the best experience.
- Use any WireGuard client to connect, or use **Newt, our custom user space client** for the best experience.
### Modular Design
- Extend functionality with existing [Traefik](https://github.com/traefik/traefik) plugins, such as [Fail2Ban](https://plugins.traefik.io/plugins/628c9ebcffc0cd18356a979f/fail2-ban) or [CrowdSec](https://plugins.traefik.io/plugins/6335346ca4caa9ddeffda116/crowdsec-bouncer-traefik-plugin).
- Extend functionality with existing [Traefik](https://github.com/traefik/traefik) plugins, such as [CrowdSec](https://plugins.traefik.io/plugins/6335346ca4caa9ddeffda116/crowdsec-bouncer-traefik-plugin) and [Geoblock](github.com/PascalMinder/geoblock).
- **Automatically install and configure Crowdsec via Pangolin's installer script.**
- Attach as many sites to the central server as you wish.
## Screenshots
<div align="center">
<table>
<tr>
<td align="center"><img src="public/screenshots/sites.png" alt="Sites Example" width="200"/></td>
<td align="center"><img src="public/screenshots/users.png" alt="Users Example" width="200"/></td>
<td align="center"><img src="public/screenshots/share-link.png" alt="Share Link Example" width="200"/></td>
</tr>
<tr>
<td align="center"><b>Sites</b></td>
<td align="center"><b>Users</b></td>
<td align="center"><b>Share Link</b></td>
</tr>
<tr>
<td align="center"><img src="public/screenshots/auth.png" alt="Authentication Example" width="200"/></td>
<td align="center"><img src="public/screenshots/connectivity.png" alt="Connectivity Example" width="200"/></td>
<td align="center"></td>
</tr>
<tr>
<td align="center"><b>Authentication</b></td>
<td align="center"><b>Connectivity</b></td>
<td align="center"><b></b></td>
</tr>
</table>
</div>
<img src="public/screenshots/collage.png" alt="Collage"/>
## Deployment and Usage Example
@@ -112,7 +92,7 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
> [!TIP]
> Many of our users have had a great experience with [RackNerd](https://my.racknerd.com/aff.php?aff=13788). Depending on promotions, you can likely get a **VPS with 1 vCPU, 1GB RAM, and ~20GB SSD for just around $12/year**. That's a great deal!
> We are part of the [RackNerd](https://my.racknerd.com/aff.php?aff=13788) affiliate program, so if you sign up using [our link](https://my.racknerd.com/aff.php?aff=13788), we receive a small commission which helps us maintain the project and keep it free for everyone.
> We are part of the [RackNerd](https://my.racknerd.com/aff.php?aff=13788) affiliate program, so if you purchase through [our link](https://my.racknerd.com/aff.php?aff=13788), we receive a small commission which helps us maintain the project and keep it free for everyone.
2. **Domain Configuration**:
@@ -123,10 +103,10 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
- Install Newt or use another WireGuard client on private sites.
- Automatically establish a connection from these sites to the central server.
4. **Configure Users & Roles**
4. **Expose Resources**:
- Define organizations and invite users.
- Implement user- or role-based permissions to control resource access.
- Add resources to the central server and configure access control rules.
- Access these resources securely from anywhere.
**Use Case Example - Bypassing Port Restrictions in Home Lab**:
Imagine private sites where the ISP restricts port forwarding. By connecting these sites to Pangolin via WireGuard, you can securely expose HTTP and HTTPS resources on the private network without any networking complexity.
@@ -134,6 +114,11 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
**Use Case Example - IoT Networks**:
IoT networks are often fragmented and difficult to manage. By deploying Pangolin on a central server, you can connect all your IoT sites via Newt or another WireGuard client. This creates a simple, secure, and centralized way to access IoT resources without the need for intricate networking setups.
<img src="public/screenshots/resources.png" alt="Resources"/>
_Resources page of Pangolin dashboard (dark mode) showing HTTPS and TCP resources with access control rules._
## Similar Projects and Inspirations
**Cloudflare Tunnels**:

View File

@@ -1,3 +1,6 @@
# To see all available options, please visit the docs:
# https://docs.fossorial.io/Pangolin/Configuration/config
app:
dashboard_url: "http://localhost:3002"
log_level: "info"

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

@@ -1,3 +1,6 @@
# To see all available options, please visit the docs:
# https://docs.fossorial.io/Pangolin/Configuration/config
app:
dashboard_url: "https://{{.DashboardDomain}}"
log_level: "info"
@@ -26,7 +29,6 @@ traefik:
cert_resolver: "letsencrypt"
http_entrypoint: "web"
https_entrypoint: "websecure"
prefer_wildcard_cert: false
gerbil:
start_port: 51820

View File

@@ -11,8 +11,6 @@ services:
ENROLL_TAGS: docker
healthcheck:
test: ["CMD", "cscli", "capi", "status"]
depends_on:
- gerbil # Wait for gerbil to be healthy
labels:
- "traefik.enable=false" # Disable traefik for crowdsec
volumes:
@@ -25,11 +23,8 @@ services:
- ./config/crowdsec_logs:/var/log # crowdsec logs
- ./config/traefik/logs:/var/log/traefik # traefik logs
ports:
- 9090:9090 # port mapping for local firewall bouncers
- 6060:6060 # metrics endpoint for prometheus
expose:
- 9090 # http api for bouncers
- 6060 # metrics endpoint for prometheus
- 7422 # appsec waf endpoint
restart: unless-stopped
command: -t # Add test config flag to verify configuration

View File

@@ -82,6 +82,11 @@ func installCrowdsec(config Config) error {
return fmt.Errorf("failed to restart containers: %v", err)
}
if checkIfTextInFile("config/traefik/dynamic_config.yml", "PUT_YOUR_BOUNCER_KEY_HERE_OR_IT_WILL_NOT_WORK") {
fmt.Println("Failed to replace bouncer key! Please retrieve the key and replace it in the config/traefik/dynamic_config.yml file using the following command:")
fmt.Println(" docker exec crowdsec cscli bouncers add traefik-bouncer")
}
return nil
}
@@ -119,3 +124,14 @@ func GetCrowdSecAPIKey() (string, error) {
return apiKey, nil
}
func checkIfTextInFile(file, text string) bool {
// Read file
content, err := os.ReadFile(file)
if err != nil {
return false
}
// Check for text
return bytes.Contains(content, []byte(text))
}

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;

14861
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 577 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 447 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 706 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 484 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 438 KiB

After

Width:  |  Height:  |  Size: 729 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 415 KiB

View File

@@ -129,18 +129,19 @@ export async function invalidateAllSessions(userId: string): Promise<void> {
export function serializeSessionCookie(
token: string,
isSecure: boolean
isSecure: boolean,
expiresAt: Date
): string {
if (isSecure) {
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
} else {
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/;`;
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/;`;
}
}
export function createBlankSessionTokenCookie(isSecure: boolean): string {
if (isSecure) {
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
} else {
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/;`;
}

View File

@@ -167,12 +167,19 @@ export function serializeResourceSessionCookie(
cookieName: string,
domain: string,
token: string,
isHttp: boolean = false
isHttp: boolean = false,
expiresAt?: Date
): string {
if (!isHttp) {
return `${cookieName}_s=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Secure; Domain=${"." + domain}`;
if (expiresAt === undefined) {
return `${cookieName}_s=${token}; HttpOnly; SameSite=Lax; Path=/; Secure; Domain=${"." + domain}`;
}
return `${cookieName}_s=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Secure; Domain=${"." + domain}`;
} else {
return `${cookieName}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Domain=${"." + domain}`;
if (expiresAt === undefined) {
return `${cookieName}=${token}; HttpOnly; SameSite=Lax; Path=/; Domain=${"." + domain}`;
}
return `${cookieName}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Domain=${"." + domain}`;
}
}

View File

@@ -3,6 +3,7 @@ export * from "@server/emails/sendEmail";
import nodemailer from "nodemailer";
import config from "@server/lib/config";
import logger from "@server/logger";
import SMTPTransport from "nodemailer/lib/smtp-transport";
function createEmailClient() {
const emailConfig = config.getRawConfig().email;
@@ -13,7 +14,7 @@ function createEmailClient() {
return;
}
return nodemailer.createTransport({
const settings = {
host: emailConfig.smtp_host,
port: emailConfig.smtp_port,
secure: emailConfig.smtp_secure || false,
@@ -21,7 +22,15 @@ function createEmailClient() {
user: emailConfig.smtp_user,
pass: emailConfig.smtp_pass
}
});
} as SMTPTransport.Options;
if (emailConfig.smtp_tls_reject_unauthorized !== undefined) {
settings.tls = {
rejectUnauthorized: emailConfig.smtp_tls_reject_unauthorized
};
}
return nodemailer.createTransport(settings);
}
export const emailClient = createEmailClient();

View File

@@ -1,11 +1,9 @@
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
@@ -14,12 +12,6 @@ import { passwordSchema } from "@server/auth/passwordSchema";
import stoi from "./stoi";
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 +23,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"]),
@@ -42,9 +33,10 @@ const configSchema = z.object({
.record(
z.string(),
z.object({
base_domain: hostnameSchema.transform((url) =>
url.toLowerCase()
),
base_domain: z
.string()
.nonempty("base_domain must not be empty")
.transform((url) => url.toLowerCase()),
cert_resolver: z.string().optional(),
prefer_wildcard_cert: z.boolean().optional()
})
@@ -62,37 +54,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 hostnameSchema.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(),
@@ -125,15 +91,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(),
@@ -160,6 +121,7 @@ const configSchema = z.object({
smtp_user: z.string().optional(),
smtp_pass: z.string().optional(),
smtp_secure: z.boolean().optional(),
smtp_tls_reject_unauthorized: z.boolean().optional(),
no_reply: z.string().email().optional()
})
.optional(),
@@ -184,7 +146,8 @@ const configSchema = z.object({
disable_signup_without_invite: z.boolean().optional(),
disable_user_create_org: z.boolean().optional(),
allow_raw_resources: z.boolean().optional(),
allow_base_domain_resources: z.boolean().optional()
allow_base_domain_resources: z.boolean().optional(),
allow_local_sites: z.boolean().optional()
})
.optional()
});
@@ -194,10 +157,6 @@ export class Config {
constructor() {
this.loadConfig();
if (process.env.GENERATE_TRAEFIK_CONFIG === "true") {
this.createTraefikConfig();
}
}
public loadConfig() {
@@ -222,45 +181,15 @@ export class Config {
} else if (fs.existsSync(configFilePath2)) {
environment = loadConfig(configFilePath2);
}
if (!environment) {
const exampleConfigPath = path.join(
__DIRNAME,
"config.example.yml"
);
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 (process.env.APP_BASE_DOMAIN) {
console.log("You're using deprecated environment variables. Transition to the configuration file. https://docs.fossorial.io/");
}
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);
@@ -306,17 +235,6 @@ 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.rawConfig = parsedConfig.data;
}
@@ -333,72 +251,6 @@ export class Config {
public getDomain(domainId: string) {
return this.rawConfig.domains[domainId];
}
private createTraefikConfig() {
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 traefikPath = path.join(APP_PATH, "traefik");
if (!fs.existsSync(traefikPath)) {
return;
}
// load default configs
let traefikConfig = fs.readFileSync(
defaultTraefikConfigPath,
"utf8"
);
let dynamicConfig = fs.readFileSync(
defaultDynamicConfigPath,
"utf8"
);
traefikConfig = traefikConfig
.split("{{.LetsEncryptEmail}}")
.join(this.rawConfig.users.server_admin.email);
traefikConfig = traefikConfig
.split("{{.INTERNAL_PORT}}")
.join(this.rawConfig.server.internal_port.toString());
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());
// 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");
} catch (e) {
console.log(
"Failed to generate the Traefik configuration files. Please create them manually."
);
console.error(e);
}
}
}
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-beta.15";
export const APP_VERSION = "1.0.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];
@@ -56,7 +51,7 @@ export function isValidUrlGlobPattern(pattern: string): boolean {
// - unreserved (A-Z a-z 0-9 - . _ ~)
// - sub-delims (! $ & ' ( ) * + , ; =)
// - @ : for compatibility with some systems
if (!/^[A-Za-z0-9\-._~!$&'()*+,;=@:]$/.test(char)) {
if (!/^[A-Za-z0-9\-._~!$&'()*+,;#=@:]$/.test(char)) {
return false;
}
}

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"
)
);
@@ -137,9 +137,13 @@ export async function login(
}
const token = generateSessionToken();
await createSession(token, existingUser.userId);
const sess = await createSession(token, existingUser.userId);
const isSecure = req.protocol === "https";
const cookie = serializeSessionCookie(token, isSecure);
const cookie = serializeSessionCookie(
token,
isSecure,
new Date(sess.expiresAt)
);
res.appendHeader("Set-Cookie", cookie);

View File

@@ -170,9 +170,13 @@ export async function signup(
// });
const token = generateSessionToken();
await createSession(token, userId);
const sess = await createSession(token, userId);
const isSecure = req.protocol === "https";
const cookie = serializeSessionCookie(token, isSecure);
const cookie = serializeSessionCookie(
token,
isSecure,
new Date(sess.expiresAt)
);
res.appendHeader("Set-Cookie", cookie);
if (config.getRawConfig().flags?.require_email_verification) {

View File

@@ -102,6 +102,8 @@ export async function exchangeSession(
const token = generateSessionToken();
let expiresAt: number | null = null;
if (requestSession.userSessionId) {
const [res] = await db
.select()
@@ -118,6 +120,7 @@ export async function exchangeSession(
expiresAt: res.expiresAt,
sessionLength: SESSION_COOKIE_EXPIRES
});
expiresAt = res.expiresAt;
}
} else if (requestSession.accessTokenId) {
const [res] = await db
@@ -140,8 +143,12 @@ export async function exchangeSession(
expiresAt: res.expiresAt,
sessionLength: res.sessionLength
});
expiresAt = res.expiresAt;
}
} else {
const expires = new Date(
Date.now() + SESSION_COOKIE_EXPIRES
).getTime();
await createResourceSession({
token,
resourceId: resource.resourceId,
@@ -152,11 +159,10 @@ export async function exchangeSession(
whitelistId: requestSession.whitelistId,
accessTokenId: requestSession.accessTokenId,
doNotExtend: false,
expiresAt: new Date(
Date.now() + SESSION_COOKIE_EXPIRES
).getTime(),
expiresAt: expires,
sessionLength: RESOURCE_SESSION_COOKIE_EXPIRES
});
expiresAt = expires;
}
const cookieName = `${config.getRawConfig().server.session_cookie_name}`;
@@ -164,7 +170,8 @@ export async function exchangeSession(
cookieName,
resource.fullDomain!,
token,
!resource.ssl
!resource.ssl,
expiresAt ? new Date(expiresAt) : undefined
);
logger.debug(JSON.stringify("Exchange cookie: " + cookie));

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

@@ -384,7 +384,7 @@ async function createAccessTokenSession(
tokenItem: ResourceAccessToken
) {
const token = generateSessionToken();
await createResourceSession({
const sess = await createResourceSession({
resourceId: resource.resourceId,
token,
accessTokenId: tokenItem.accessTokenId,
@@ -397,7 +397,8 @@ async function createAccessTokenSession(
cookieName,
resource.fullDomain!,
token,
!resource.ssl
!resource.ssl,
new Date(sess.expiresAt)
);
res.appendHeader("Set-Cookie", cookie);
logger.debug("Access token is valid, creating new session");
@@ -533,7 +534,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
@@ -574,7 +575,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}`
@@ -606,6 +607,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(
@@ -624,4 +651,4 @@ function isPathAllowed(pattern: string, path: string): boolean {
const result = matchSegments(0, 0);
logger.debug(`Final result: ${result}`);
return result;
}
}

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

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

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

@@ -16,6 +16,7 @@ import m7 from "./scripts/1.0.0-beta10";
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";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA
@@ -31,16 +32,14 @@ const migrations = [
{ version: "1.0.0-beta.10", run: m7 },
{ 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-beta.15", run: m15 },
{ version: "1.0.0", run: m16 }
// Add new migrations here as they are created
] as const;
await run();
async function run() {
// backup the database
backupDb();
// run the migrations
await runMigrations();
}
@@ -125,6 +124,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,57 @@
import { APP_PATH } from "@server/lib/consts";
import fs from "fs";
import yaml from "js-yaml";
import path from "path";
import { z } from "zod";
import { fromZodError } from "zod-validation-error";
const version = "1.0.0";
export default async function migration() {
console.log(`Running setup script ${version}...`);
try {
const traefikPath = path.join(
APP_PATH,
"traefik",
"traefik_config.yml"
);
const schema = z.object({
experimental: z.object({
plugins: z.object({
badger: z.object({
moduleName: z.string(),
version: z.string()
})
})
})
});
const traefikFileContents = fs.readFileSync(traefikPath, "utf8");
const traefikConfig = yaml.load(traefikFileContents) as any;
const parsedConfig = schema.safeParse(traefikConfig);
if (!parsedConfig.success) {
throw new Error(fromZodError(parsedConfig.error).toString());
}
traefikConfig.experimental.plugins.badger.version = "v1.0.0";
const updatedTraefikYaml = yaml.dump(traefikConfig);
fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8");
console.log(
"Updated the version of Badger in your Traefik configuration to 1.0.0"
);
} catch (e) {
console.log(
"We were unable to update the version of Badger in your Traefik configuration. Please update it manually."
);
console.error(e);
}
console.log(`${version} migration complete`);
}

View File

@@ -7,7 +7,7 @@ import {
FormField,
FormItem,
FormLabel,
FormMessage,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { toast } from "@app/hooks/useToast";
@@ -24,11 +24,11 @@ import {
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle,
CredenzaTitle
} from "@app/components/Credenza";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { CreateRoleBody, CreateRoleResponse } from "@server/routers/role";
import { formatAxiosError } from "@app/lib/api";;
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
@@ -40,13 +40,13 @@ type CreateRoleFormProps = {
const formSchema = z.object({
name: z.string({ message: "Name is required" }).max(32),
description: z.string().max(255).optional(),
description: z.string().max(255).optional()
});
export default function CreateRoleForm({
open,
setOpen,
afterCreate,
afterCreate
}: CreateRoleFormProps) {
const { org } = useOrgContext();
@@ -58,8 +58,8 @@ export default function CreateRoleForm({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
description: "",
},
description: ""
}
});
async function onSubmit(values: z.infer<typeof formSchema>) {
@@ -70,7 +70,7 @@ export default function CreateRoleForm({
`/org/${org?.org.orgId}/role`,
{
name: values.name,
description: values.description,
description: values.description
} as CreateRoleBody
)
.catch((e) => {
@@ -80,7 +80,7 @@ export default function CreateRoleForm({
description: formatAxiosError(
e,
"An error occurred while creating the role."
),
)
});
});
@@ -88,7 +88,7 @@ export default function CreateRoleForm({
toast({
variant: "default",
title: "Role created",
description: "The role has been successfully created.",
description: "The role has been successfully created."
});
if (open) {
@@ -135,9 +135,7 @@ export default function CreateRoleForm({
<FormItem>
<FormLabel>Role Name</FormLabel>
<FormControl>
<Input
{...field}
/>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -150,9 +148,7 @@ export default function CreateRoleForm({
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input
{...field}
/>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -162,6 +158,9 @@ export default function CreateRoleForm({
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
<Button
type="submit"
form="create-role-form"
@@ -170,9 +169,6 @@ export default function CreateRoleForm({
>
Create Role
</Button>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>

View File

@@ -7,7 +7,7 @@ import {
FormField,
FormItem,
FormLabel,
FormMessage,
FormMessage
} from "@app/components/ui/form";
import { toast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -23,7 +23,7 @@ import {
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle,
CredenzaTitle
} from "@app/components/Credenza";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { ListRolesResponse } from "@server/routers/role";
@@ -32,10 +32,10 @@ import {
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
SelectValue
} from "@app/components/ui/select";
import { RoleRow } from "./RolesTable";
import { formatAxiosError } from "@app/lib/api";;
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
@@ -47,14 +47,14 @@ type CreateRoleFormProps = {
};
const formSchema = z.object({
newRoleId: z.string({ message: "New role is required" }),
newRoleId: z.string({ message: "New role is required" })
});
export default function DeleteRoleForm({
open,
roleToDelete,
setOpen,
afterDelete,
afterDelete
}: CreateRoleFormProps) {
const { org } = useOrgContext();
@@ -66,9 +66,9 @@ export default function DeleteRoleForm({
useEffect(() => {
async function fetchRoles() {
const res = await api
.get<AxiosResponse<ListRolesResponse>>(
`/org/${org?.org.orgId}/roles`
)
.get<
AxiosResponse<ListRolesResponse>
>(`/org/${org?.org.orgId}/roles`)
.catch((e) => {
console.error(e);
toast({
@@ -77,7 +77,7 @@ export default function DeleteRoleForm({
description: formatAxiosError(
e,
"An error occurred while fetching the roles"
),
)
});
});
@@ -96,8 +96,8 @@ export default function DeleteRoleForm({
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
newRoleId: "",
},
newRoleId: ""
}
});
async function onSubmit(values: z.infer<typeof formSchema>) {
@@ -106,8 +106,8 @@ export default function DeleteRoleForm({
const res = await api
.delete(`/role/${roleToDelete.roleId}`, {
data: {
roleId: values.newRoleId,
},
roleId: values.newRoleId
}
})
.catch((e) => {
toast({
@@ -116,7 +116,7 @@ export default function DeleteRoleForm({
description: formatAxiosError(
e,
"An error occurred while removing the role."
),
)
});
});
@@ -124,7 +124,7 @@ export default function DeleteRoleForm({
toast({
variant: "default",
title: "Role removed",
description: "The role has been successfully removed.",
description: "The role has been successfully removed."
});
if (open) {
@@ -214,6 +214,9 @@ export default function DeleteRoleForm({
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
<Button
type="submit"
form="remove-role-form"
@@ -222,9 +225,6 @@ export default function DeleteRoleForm({
>
Remove Role
</Button>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>

View File

@@ -37,7 +37,7 @@ import {
} from "@app/components/Credenza";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { ListRolesResponse } from "@server/routers/role";
import { formatAxiosError } from "@app/lib/api";;
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { Checkbox } from "@app/components/ui/checkbox";
@@ -194,9 +194,7 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
{...field}
/>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -340,6 +338,9 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
<Button
type="submit"
form="invite-user-form"
@@ -348,9 +349,6 @@ export default function InviteUserForm({ open, setOpen }: InviteUserFormProps) {
>
Create Invitation
</Button>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>

View File

@@ -185,7 +185,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
<Link
href={`/${org?.org.orgId}/settings/access/users/${userRow.id}`}
>
<Button variant={"outline"} className="ml-2">
<Button variant={"outlinePrimary"} className="ml-2">
Manage
<ArrowRight className="ml-2 w-4 h-4" />
</Button>

View File

@@ -64,7 +64,7 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
</Breadcrumb>
</div>
<div className="space-y-0.5 select-none mb-6">
<div className="space-y-0.5 mb-6">
<h2 className="text-2xl font-bold tracking-tight">
User {user?.email}
</h2>

View File

@@ -1,6 +1,13 @@
import { Metadata } from "next";
import { TopbarNav } from "@app/components/TopbarNav";
import { Cog, Combine, Link, Settings, Users, Waypoints } from "lucide-react";
import {
Cog,
Combine,
LinkIcon,
Settings,
Users,
Waypoints
} from "lucide-react";
import { Header } from "@app/components/Header";
import { verifySession } from "@app/lib/auth/verifySession";
import { redirect } from "next/navigation";
@@ -11,6 +18,14 @@ import { authCookieHeader } from "@app/lib/api/cookies";
import { cache } from "react";
import { GetOrgUserResponse } from "@server/routers/user";
import UserProvider from "@app/providers/UserProvider";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator
} from "@app/components/ui/breadcrumb";
import Link from "next/link";
export const dynamic = "force-dynamic";
@@ -38,7 +53,7 @@ const topNavItems = [
{
title: "Shareable Links",
href: "/{orgId}/settings/share-links",
icon: <Link className="h-4 w-4" />
icon: <LinkIcon className="h-4 w-4" />
},
{
title: "General",
@@ -95,19 +110,23 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
return (
<>
<div className="w-full border-b bg-card select-none sm:px-0 px-3 fixed top-0 z-10">
<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 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>
<TopbarNav items={topNavItems} orgId={params.orgId} />
</div>
</div>
<div className="container mx-auto sm:px-0 px-3 pt-[165px]">
{children}
<div className="container mx-auto sm:px-0 px-3 pt-[155px]">
<div className="container mx-auto sm:px-0 px-3">
{children}
</div>
</div>
</>
);

View File

@@ -66,6 +66,8 @@ import CopyTextBox from "@app/components/CopyTextBox";
import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group";
import { Label } from "@app/components/ui/label";
import { ListDomainsResponse } from "@server/routers/domain";
import LoaderPlaceholder from "@app/components/PlaceHolderLoader";
import { StrategySelect } from "@app/components/StrategySelect";
const createResourceFormSchema = z
.object({
@@ -140,6 +142,7 @@ export default function CreateResourceForm({
const [domainType, setDomainType] = useState<"subdomain" | "basedomain">(
"subdomain"
);
const [loadingPage, setLoadingPage] = useState(true);
const form = useForm<CreateResourceFormValues>({
resolver: zodResolver(createResourceFormSchema),
@@ -215,8 +218,17 @@ export default function CreateResourceForm({
}
};
fetchSites();
fetchDomains();
const load = async () => {
setLoadingPage(true);
await fetchSites();
await fetchDomains();
await new Promise((r) => setTimeout(r, 200));
setLoadingPage(false);
};
load();
}, [open]);
async function onSubmit(data: CreateResourceFormValues) {
@@ -231,7 +243,7 @@ export default function CreateResourceForm({
protocol: data.protocol,
proxyPort: data.http ? undefined : data.proxyPort,
siteId: data.siteId,
isBaseDomain: data.http ? undefined : data.isBaseDomain
isBaseDomain: data.http ? data.isBaseDomain : undefined
}
)
.catch((e) => {
@@ -253,6 +265,7 @@ export default function CreateResourceForm({
goToResource(id);
} else {
setShowSnippets(true);
router.refresh();
}
}
}
@@ -262,6 +275,21 @@ export default function CreateResourceForm({
router.push(`/${orgId}/settings/resources/${id || resourceId}`);
}
const launchOptions = [
{
id: "http",
title: "HTTPS Resource",
description:
"Proxy requests to your app over HTTPS using a subdomain or base domain."
},
{
id: "raw",
title: "Raw TCP/UDP Resource",
description:
"Proxy requests to your app over TCP/UDP using a port number."
}
];
return (
<>
<Credenza
@@ -282,236 +310,458 @@ export default function CreateResourceForm({
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
{!showSnippets && (
<Form {...form} key={formKey}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="create-resource-form"
>
{!env.flags.allowRawResources || (
<FormField
control={form.control}
name="http"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">
HTTP Resource
</FormLabel>
<FormDescription>
Toggle if this is an
HTTP resource or a
raw TCP/UDP
resource.
</FormDescription>
</div>
<FormControl>
<Switch
checked={
field.value
}
onCheckedChange={
field.onChange
}
/>
</FormControl>
</FormItem>
{loadingPage ? (
<LoaderPlaceholder height="300px" />
) : (
<div>
{!showSnippets && (
<Form {...form} key={formKey}>
<form
onSubmit={form.handleSubmit(
onSubmit
)}
/>
)}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
<FormDescription>
This is display name for the
resource.
</FormDescription>
</FormItem>
)}
/>
{form.watch("http") &&
env.flags.allowBaseDomainResources && (
<div>
<RadioGroup
className="flex space-x-4"
defaultValue={domainType}
onValueChange={(val) => {
setDomainType(
val as any
);
form.setValue(
"isBaseDomain",
val === "basedomain"
);
}}
>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="subdomain"
id="r1"
/>
<Label htmlFor="r1">
Subdomain
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="basedomain"
id="r2"
/>
<Label htmlFor="r2">
Base Domain
</Label>
</div>
</RadioGroup>
</div>
)}
{form.watch("http") && (
<>
{domainType === "subdomain" ? (
<div className="w-fill space-y-2">
{!env.flags
.allowBaseDomainResources && (
className="space-y-4"
id="create-resource-form"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
Subdomain
Name
</FormLabel>
)}
<div className="flex">
<div className="w-full mr-1">
<FormField
control={
form.control
}
name="subdomain"
render={({
field
}) => (
<FormControl>
<Input
{...field}
className="text-right"
placeholder="Enter subdomain"
/>
</FormControl>
)}
/>
</div>
<div className="max-w-1/2">
<FormField
control={
form.control
}
name="domainId"
render={({
field
}) => (
<FormItem>
<Select
onValueChange={
field.onChange
}
value={
field.value
}
defaultValue={
field.value
}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{baseDomains.map(
(
option
) => (
<SelectItem
key={
option.domainId
}
value={
option.domainId
}
>
.
{
option.baseDomain
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
</div>
) : (
<FormField
control={form.control}
name="domainId"
render={({ field }) => (
<FormItem>
<Select
onValueChange={
field.onChange
}
defaultValue={
field.value
}
{...field}
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="siteId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>
Site
</FormLabel>
<Popover>
<PopoverTrigger
asChild
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<Button
variant="outline"
role="combobox"
className={cn(
"justify-between",
!field.value &&
"text-muted-foreground"
)}
>
{field.value
? sites.find(
(
site
) =>
site.siteId ===
field.value
)
?.name
: "Select site"}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
<SelectContent>
{baseDomains.map(
(
option
) => (
<SelectItem
key={
option.domainId
}
value={
option.domainId
}
>
{
option.baseDomain
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
</>
)}
</PopoverTrigger>
<PopoverContent className="p-0">
<Command>
<CommandInput placeholder="Search site" />
<CommandList>
<CommandEmpty>
No
site
found.
</CommandEmpty>
<CommandGroup>
{sites.map(
(
site
) => (
<CommandItem
value={`${site.siteId}:${site.name}:${site.niceId}`}
key={
site.siteId
}
onSelect={() => {
form.setValue(
"siteId",
site.siteId
);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
site.siteId ===
field.value
? "opacity-100"
: "opacity-0"
)}
/>
{
site.name
}
</CommandItem>
)
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
<FormDescription>
This site will
provide connectivity
to the resource.
</FormDescription>
</FormItem>
)}
/>
{!env.flags.allowRawResources || (
<div className="space-y-2">
<FormLabel>
Resource Type
</FormLabel>
<StrategySelect
options={launchOptions}
defaultValue="http"
onChange={(value) =>
form.setValue(
"http",
value === "http"
)
}
/>
<FormDescription>
You cannot change the
type of resource after
creation.
</FormDescription>
</div>
)}
{form.watch("http") &&
env.flags
.allowBaseDomainResources && (
<FormField
control={form.control}
name="isBaseDomain"
render={({ field }) => (
<FormItem>
<FormLabel>
Domain Type
</FormLabel>
<Select
value={
domainType
}
onValueChange={(
val
) => {
setDomainType(
val ===
"basedomain"
? "basedomain"
: "subdomain"
);
form.setValue(
"isBaseDomain",
val ===
"basedomain"
);
}}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="subdomain">
Subdomain
</SelectItem>
<SelectItem value="basedomain">
Base
Domain
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
{form.watch("http") && (
<>
{domainType ===
"subdomain" ? (
<div className="w-fill space-y-2">
<FormLabel>
Subdomain
</FormLabel>
<div className="flex">
<div className="w-1/2">
<FormField
control={
form.control
}
name="subdomain"
render={({
field
}) => (
<FormItem>
<FormControl>
<Input
{...field}
className="border-r-0 rounded-r-none"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="w-1/2">
<FormField
control={
form.control
}
name="domainId"
render={({
field
}) => (
<FormItem>
<Select
onValueChange={
field.onChange
}
value={
field.value
}
defaultValue={
field.value
}
>
<FormControl>
<SelectTrigger className="rounded-l-none">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{baseDomains.map(
(
option
) => (
<SelectItem
key={
option.domainId
}
value={
option.domainId
}
>
.
{
option.baseDomain
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
</div>
) : (
<FormField
control={
form.control
}
name="domainId"
render={({
field
}) => (
<FormItem>
<FormLabel>
Base
Domain
</FormLabel>
<Select
onValueChange={
field.onChange
}
defaultValue={
field.value
}
{...field}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{baseDomains.map(
(
option
) => (
<SelectItem
key={
option.domainId
}
value={
option.domainId
}
>
{
option.baseDomain
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
</>
)}
{!form.watch("http") && (
<>
<FormField
control={form.control}
name="protocol"
render={({ field }) => (
<FormItem>
<FormLabel>
Protocol
</FormLabel>
<Select
value={
field.value
}
onValueChange={
field.onChange
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a protocol" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="tcp">
TCP
</SelectItem>
<SelectItem value="udp">
UDP
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="proxyPort"
render={({ field }) => (
<FormItem>
<FormLabel>
Port Number
</FormLabel>
<FormControl>
<Input
type="number"
value={
field.value ??
""
}
onChange={(
e
) =>
field.onChange(
e
.target
.value
? parseInt(
e
.target
.value
)
: null
)
}
/>
</FormControl>
<FormMessage />
<FormDescription>
The external
port number
to proxy
requests.
</FormDescription>
</FormItem>
)}
/>
</>
)}
</form>
</Form>
)}
{showSnippets && (
<div>
<div className="flex items-start space-x-4 mb-6 last:mb-0">
<div className="flex-grow">
<h3 className="text-lg font-semibold mb-3">
Traefik: Add Entrypoints
</h3>
<CopyTextBox
text={`entryPoints:
${form.getValues("protocol")}-${form.getValues("proxyPort")}:
address: ":${form.getValues("proxyPort")}/${form.getValues("protocol")}"`}
wrapText={false}
/>
</div>
</div>
<div className="flex items-start space-x-4 mb-6 last:mb-0">
<div className="flex-grow">
<h3 className="text-lg font-semibold mb-3">
Gerbil: Expose Ports in
Docker Compose
</h3>
<CopyTextBox
text={`ports:
- ${form.getValues("proxyPort")}:${form.getValues("proxyPort")}${form.getValues("protocol") === "tcp" ? "" : "/" + form.getValues("protocol")}`}
wrapText={false}
/>
</div>
</div>
{!form.watch("http") && (
<Link
className="text-sm text-primary flex items-center gap-1"
href="https://docs.fossorial.io/Getting%20Started/tcp-udp"
@@ -524,228 +774,15 @@ export default function CreateResourceForm({
</span>
<SquareArrowOutUpRight size={14} />
</Link>
)}
{!form.watch("http") && (
<>
<FormField
control={form.control}
name="protocol"
render={({ field }) => (
<FormItem>
<FormLabel>
Protocol
</FormLabel>
<Select
value={field.value}
onValueChange={
field.onChange
}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a protocol" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="tcp">
TCP
</SelectItem>
<SelectItem value="udp">
UDP
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
<FormDescription>
The protocol to use
for the resource.
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="proxyPort"
render={({ field }) => (
<FormItem>
<FormLabel>
Port Number
</FormLabel>
<FormControl>
<Input
type="number"
value={
field.value ??
""
}
onChange={(e) =>
field.onChange(
e.target
.value
? parseInt(
e
.target
.value
)
: null
)
}
/>
</FormControl>
<FormMessage />
<FormDescription>
The port number to
proxy requests to
(required for
non-HTTP resources).
</FormDescription>
</FormItem>
)}
/>
</>
)}
<FormField
control={form.control}
name="siteId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>Site</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"justify-between",
!field.value &&
"text-muted-foreground"
)}
>
{field.value
? sites.find(
(
site
) =>
site.siteId ===
field.value
)?.name
: "Select site"}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0">
<Command>
<CommandInput placeholder="Search site" />
<CommandList>
<CommandEmpty>
No site
found.
</CommandEmpty>
<CommandGroup>
{sites.map(
(
site
) => (
<CommandItem
value={`${site.siteId}:${site.name}:${site.niceId}`}
key={
site.siteId
}
onSelect={() => {
form.setValue(
"siteId",
site.siteId
);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
site.siteId ===
field.value
? "opacity-100"
: "opacity-0"
)}
/>
{
site.name
}
</CommandItem>
)
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
<FormDescription>
This site will provide
connectivity to the
resource.
</FormDescription>
</FormItem>
)}
/>
</form>
</Form>
)}
{showSnippets && (
<div>
<div className="flex items-start space-x-4 mb-6 last:mb-0">
<div className="flex-shrink-0 w-8 h-8 bg-muted text-primary-foreground rounded-full flex items-center justify-center font-bold">
1
</div>
<div className="flex-grow">
<h3 className="text-lg font-semibold mb-3">
Traefik: Add Entrypoints
</h3>
<CopyTextBox
text={`entryPoints:
${form.getValues("protocol")}-${form.getValues("proxyPort")}:
address: ":${form.getValues("proxyPort")}/${form.getValues("protocol")}"`}
wrapText={false}
/>
</div>
</div>
<div className="flex items-start space-x-4 mb-6 last:mb-0">
<div className="flex-shrink-0 w-8 h-8 bg-muted text-primary-foreground rounded-full flex items-center justify-center font-bold">
2
</div>
<div className="flex-grow">
<h3 className="text-lg font-semibold mb-3">
Gerbil: Expose Ports in Docker
Compose
</h3>
<CopyTextBox
text={`ports:
- ${form.getValues("proxyPort")}:${form.getValues("proxyPort")}${form.getValues("protocol") === "tcp" ? "" : "/" + form.getValues("protocol")}`}
wrapText={false}
/>
</div>
</div>
<Link
className="text-sm text-primary flex items-center gap-1"
href="https://docs.fossorial.io/Getting%20Started/tcp-udp"
target="_blank"
rel="noopener noreferrer"
>
<span>
Make sure to follow the full guide
</span>
<SquareArrowOutUpRight size={14} />
</Link>
)}
</div>
)}
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
{!showSnippets && (
<Button
type="submit"
@@ -765,10 +802,6 @@ export default function CreateResourceForm({
Go to Resource
</Button>
)}
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>

View File

@@ -233,7 +233,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
<Link
href={`/${resourceRow.orgId}/settings/resources/${resourceRow.id}`}
>
<Button variant={"outline"} className="ml-2">
<Button variant={"outlinePrimary"} className="ml-2">
Edit
<ArrowRight className="ml-2 w-4 h-4" />
</Button>

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

@@ -8,7 +8,7 @@ import {
FormField,
FormItem,
FormLabel,
FormMessage,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { toast } from "@app/hooks/useToast";
@@ -24,22 +24,22 @@ import {
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle,
CredenzaTitle
} from "@app/components/Credenza";
import { formatAxiosError } from "@app/lib/api";;
import { formatAxiosError } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { Resource } from "@server/db/schema";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
const setPasswordFormSchema = z.object({
password: z.string().min(4).max(100),
password: z.string().min(4).max(100)
});
type SetPasswordFormValues = z.infer<typeof setPasswordFormSchema>;
const defaultValues: Partial<SetPasswordFormValues> = {
password: "",
password: ""
};
type SetPasswordFormProps = {
@@ -53,7 +53,7 @@ export default function SetResourcePasswordForm({
open,
setOpen,
resourceId,
onSetPassword,
onSetPassword
}: SetPasswordFormProps) {
const api = createApiClient(useEnvContext());
@@ -61,7 +61,7 @@ export default function SetResourcePasswordForm({
const form = useForm<SetPasswordFormValues>({
resolver: zodResolver(setPasswordFormSchema),
defaultValues,
defaultValues
});
useEffect(() => {
@@ -76,7 +76,7 @@ export default function SetResourcePasswordForm({
setLoading(true);
api.post<AxiosResponse<Resource>>(`/resource/${resourceId}/password`, {
password: data.password,
password: data.password
})
.catch((e) => {
toast({
@@ -85,14 +85,14 @@ export default function SetResourcePasswordForm({
description: formatAxiosError(
e,
"An error occurred while setting the resource password"
),
)
});
})
.then(() => {
toast({
title: "Resource password set",
description:
"The resource password has been set successfully",
"The resource password has been set successfully"
});
if (onSetPassword) {
@@ -153,6 +153,9 @@ export default function SetResourcePasswordForm({
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
<Button
type="submit"
form="set-password-form"
@@ -161,9 +164,6 @@ export default function SetResourcePasswordForm({
>
Enable Password Protection
</Button>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>

View File

@@ -8,7 +8,7 @@ import {
FormField,
FormItem,
FormLabel,
FormMessage,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { toast } from "@app/hooks/useToast";
@@ -24,27 +24,27 @@ import {
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle,
CredenzaTitle
} from "@app/components/Credenza";
import { formatAxiosError } from "@app/lib/api";;
import { formatAxiosError } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { Resource } from "@server/db/schema";
import {
InputOTP,
InputOTPGroup,
InputOTPSlot,
InputOTPSlot
} from "@app/components/ui/input-otp";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
const setPincodeFormSchema = z.object({
pincode: z.string().length(6),
pincode: z.string().length(6)
});
type SetPincodeFormValues = z.infer<typeof setPincodeFormSchema>;
const defaultValues: Partial<SetPincodeFormValues> = {
pincode: "",
pincode: ""
};
type SetPincodeFormProps = {
@@ -58,7 +58,7 @@ export default function SetResourcePincodeForm({
open,
setOpen,
resourceId,
onSetPincode,
onSetPincode
}: SetPincodeFormProps) {
const [loading, setLoading] = useState(false);
@@ -66,7 +66,7 @@ export default function SetResourcePincodeForm({
const form = useForm<SetPincodeFormValues>({
resolver: zodResolver(setPincodeFormSchema),
defaultValues,
defaultValues
});
useEffect(() => {
@@ -81,7 +81,7 @@ export default function SetResourcePincodeForm({
setLoading(true);
api.post<AxiosResponse<Resource>>(`/resource/${resourceId}/pincode`, {
pincode: data.pincode,
pincode: data.pincode
})
.catch((e) => {
toast({
@@ -89,15 +89,15 @@ export default function SetResourcePincodeForm({
title: "Error setting resource PIN code",
description: formatAxiosError(
e,
"An error occurred while setting the resource PIN code",
),
"An error occurred while setting the resource PIN code"
)
});
})
.then(() => {
toast({
title: "Resource PIN code set",
description:
"The resource pincode has been set successfully",
"The resource pincode has been set successfully"
});
if (onSetPincode) {
@@ -181,6 +181,9 @@ export default function SetResourcePincodeForm({
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
<Button
type="submit"
form="set-pincode-form"
@@ -189,9 +192,6 @@ export default function SetResourcePincodeForm({
>
Enable PIN Code Protection
</Button>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>

View File

@@ -38,7 +38,8 @@ import {
SettingsSectionHeader,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionFooter
SettingsSectionFooter,
SettingsSectionForm
} from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput";
import { InfoPopup } from "@app/components/ui/info-popup";
@@ -407,7 +408,7 @@ export default function ResourceAuthenticationPage() {
<SwitchInput
id="sso-toggle"
label="Use Platform SSO"
description="Existing users will only have to login once for all resources that have this enabled."
description="Existing users will only have to log in once for all resources that have this enabled."
defaultChecked={resource.sso}
onCheckedChange={(val) => setSsoEnabled(val)}
/>
@@ -438,6 +439,7 @@ export default function ResourceAuthenticationPage() {
setActiveRolesTagIndex
}
placeholder="Select a role"
size="sm"
tags={
usersRolesForm.getValues()
.roles
@@ -466,14 +468,6 @@ export default function ResourceAuthenticationPage() {
true
}
sortTags={true}
styleClasses={{
tag: {
body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full"
},
input: "text-base md:text-sm border-none bg-transparent text-inherit placeholder:text-inherit shadow-none",
inlineTagsContainer:
"bg-transparent p-2"
}}
/>
</FormControl>
<FormMessage />
@@ -504,6 +498,7 @@ export default function ResourceAuthenticationPage() {
usersRolesForm.getValues()
.users
}
size="sm"
setTags={(
newUsers
) => {
@@ -528,14 +523,6 @@ export default function ResourceAuthenticationPage() {
true
}
sortTags={true}
styleClasses={{
tag: {
body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full"
},
input: "text-base md:text-sm border-none bg-transparent text-inherit placeholder:text-inherit shadow-none",
inlineTagsContainer:
"bg-transparent p-2"
}}
/>
</FormControl>
<FormMessage />
@@ -582,7 +569,7 @@ export default function ResourceAuthenticationPage() {
</span>
</div>
<Button
variant="outline"
variant="outlinePrimary"
onClick={
authInfo.password
? removeResourcePassword
@@ -608,7 +595,7 @@ export default function ResourceAuthenticationPage() {
</span>
</div>
<Button
variant="outline"
variant="outlinePrimary"
onClick={
authInfo.pincode
? removeResourcePincode
@@ -664,6 +651,7 @@ export default function ResourceAuthenticationPage() {
activeTagIndex={
activeEmailTagIndex
}
size={"sm"}
validateTag={(
tag
) => {
@@ -708,14 +696,6 @@ export default function ResourceAuthenticationPage() {
false
}
sortTags={true}
styleClasses={{
tag: {
body: "bg-muted hover:bg-accent text-foreground py-2 px-3 rounded-full"
},
input: "text-base md:text-sm border-none bg-transparent text-inherit placeholder:text-inherit shadow-none",
inlineTagsContainer:
"bg-transparent p-2"
}}
/>
</FormControl>
<FormDescription>

View File

@@ -39,6 +39,7 @@ import {
import {
Table,
TableBody,
TableCaption,
TableCell,
TableContainer,
TableHead,
@@ -103,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>
});
@@ -199,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) => {
@@ -241,10 +246,7 @@ export default function ReverseProxyTargets(props: {
>(`/resource/${params.resourceId}/target`, data);
target.targetId = res.data.data.targetId;
} else if (target.updated) {
await api.post(
`/target/${target.targetId}`,
data
);
await api.post(`/target/${target.targetId}`, data);
}
setTargets([
@@ -261,9 +263,7 @@ export default function ReverseProxyTargets(props: {
for (const targetId of targetsToRemove) {
await api.delete(`/target/${targetId}`);
setTargets(
targets.filter((t) => t.targetId !== targetId)
);
setTargets(targets.filter((t) => t.targetId !== targetId));
}
toast({
@@ -459,7 +459,7 @@ export default function ReverseProxyTargets(props: {
SSL Configuration
</SettingsSectionTitle>
<SettingsSectionDescription>
Setup SSL to secure your connections with Let's Encrypt certificates
Set up SSL to secure your connections with certificates
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -481,7 +481,7 @@ export default function ReverseProxyTargets(props: {
Target Configuration
</SettingsSectionTitle>
<SettingsSectionDescription>
Setup targets to route traffic to your services
Set up targets to route traffic to your services
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
@@ -490,7 +490,7 @@ export default function ReverseProxyTargets(props: {
onSubmit={addTargetForm.handleSubmit(addTarget)}
className="space-y-4"
>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 items-start">
{resource.http && (
<FormField
control={addTargetForm.control}
@@ -545,18 +545,6 @@ export default function ReverseProxyTargets(props: {
<Input id="ip" {...field} />
</FormControl>
<FormMessage />
{site?.type === "newt" ? (
<FormDescription>
This is the IP or hostname
of the target service on
your network.
</FormDescription>
) : site?.type === "wireguard" ? (
<FormDescription>
This is the IP of the
WireGuard peer.
</FormDescription>
) : null}
</FormItem>
)}
/>
@@ -575,83 +563,68 @@ export default function ReverseProxyTargets(props: {
/>
</FormControl>
<FormMessage />
{site?.type === "newt" ? (
<FormDescription>
This is the port of the
target service on your
network.
</FormDescription>
) : site?.type === "wireguard" ? (
<FormDescription>
This is the port exposed on
an address on the WireGuard
network.
</FormDescription>
) : null}
</FormItem>
)}
/>
<Button
type="submit"
variant="outlinePrimary"
className="mt-8"
>
Add Target
</Button>
</div>
<Button type="submit" variant="outline">
Add Target
</Button>
</form>
</Form>
<TableContainer>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column
.columnDef.header,
header.getContext()
)}
</TableHead>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<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}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{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"
>
No targets. Add a target using the
form.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
<p className="text-sm text-muted-foreground">
Adding more than one target above will enable load
balancing.
</p>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No targets. Add a target using the form.
</TableCell>
</TableRow>
)}
</TableBody>
<TableCaption>
Adding more than one target above will enable load
balancing.
</TableCaption>
</Table>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button

View File

@@ -129,6 +129,7 @@ export default function GeneralForm() {
ListDomainsResponse["domains"]
>([]);
const [loadingPage, setLoadingPage] = useState(true);
const [domainType, setDomainType] = useState<"subdomain" | "basedomain">(
resource.isBaseDomain ? "basedomain" : "subdomain"
);
@@ -184,8 +185,14 @@ export default function GeneralForm() {
}
};
fetchDomains();
fetchSites();
const load = async () => {
await fetchDomains();
await fetchSites();
setLoadingPage(false);
};
load();
}, []);
async function onSubmit(data: GeneralFormValues) {
@@ -258,396 +265,415 @@ 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);
}
return (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
General Settings
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure the general settings for this resource
</SettingsSectionDescription>
</SettingsSectionHeader>
!loadingPage && (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
General Settings
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure the general settings for this resource
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form} key={formKey}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="general-settings-form"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
<FormDescription>
This is the display name of the
resource.
</FormDescription>
</FormItem>
)}
/>
{resource.http && (
<>
{env.flags.allowBaseDomainResources && (
<div>
<RadioGroup
className="flex space-x-4"
defaultValue={domainType}
onValueChange={(val) => {
setDomainType(
val as any
);
form.setValue(
"isBaseDomain",
val === "basedomain"
);
}}
>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="subdomain"
id="r1"
/>
<Label htmlFor="r1">
Subdomain
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="basedomain"
id="r2"
/>
<Label htmlFor="r2">
Base Domain
</Label>
</div>
</RadioGroup>
</div>
)}
{domainType === "subdomain" ? (
<div className="w-fill space-y-2">
{!env.flags
.allowBaseDomainResources && (
<FormLabel>
Subdomain
</FormLabel>
)}
<div className="flex">
<div className="w-full mr-1">
<FormField
control={
form.control
}
name="subdomain"
render={({
field
}) => (
<FormItem>
<FormControl>
<Input
{...field}
className="text-right"
placeholder="Enter subdomain"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="max-w-1/2">
<FormField
control={
form.control
}
name="domainId"
render={({
field
}) => (
<FormItem>
<Select
onValueChange={
field.onChange
}
defaultValue={
field.value
}
value={
field.value
}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{baseDomains.map(
(
option
) => (
<SelectItem
key={
option.domainId
}
value={
option.domainId
}
>
.
{
option.baseDomain
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
</div>
) : (
<FormField
control={form.control}
name="domainId"
render={({ field }) => (
<FormItem>
<Select
onValueChange={
field.onChange
}
defaultValue={
field.value ||
baseDomains[0]
?.domainId
}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{baseDomains.map(
(
option
) => (
<SelectItem
key={
option.domainId
}
value={
option.domainId
}
>
{
option.baseDomain
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
</>
)}
{!resource.http && (
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...form} key={formKey}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid grid-cols-1 md:grid-cols-2 gap-4"
id="general-settings-form"
>
<FormField
control={form.control}
name="proxyPort"
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
Port Number
</FormLabel>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
type="number"
value={
field.value ?? ""
}
onChange={(e) =>
field.onChange(
e.target.value
? parseInt(
e
.target
.value
)
: null
)
}
/>
<Input {...field} />
</FormControl>
<FormMessage />
<FormDescription>
This is the port that will
be used to access the
resource.
</FormDescription>
</FormItem>
)}
/>
)}
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={saveLoading}
disabled={saveLoading}
form="general-settings-form"
>
Save Settings
</Button>
</SettingsSectionFooter>
</SettingsSection>
{resource.http && (
<>
{env.flags
.allowBaseDomainResources && (
<FormField
control={form.control}
name="isBaseDomain"
render={({ field }) => (
<FormItem>
<FormLabel>
Domain Type
</FormLabel>
<Select
value={
domainType
}
onValueChange={(
val
) => {
setDomainType(
val ===
"basedomain"
? "basedomain"
: "subdomain"
);
form.setValue(
"isBaseDomain",
val ===
"basedomain"
? true
: false
);
}}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="subdomain">
Subdomain
</SelectItem>
<SelectItem value="basedomain">
Base
Domain
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Transfer Resource
</SettingsSectionTitle>
<SettingsSectionDescription>
Transfer this resource to a different site
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...transferForm}>
<form
onSubmit={transferForm.handleSubmit(onTransfer)}
className="space-y-4"
id="transfer-form"
>
<FormField
control={transferForm.control}
name="siteId"
render={({ field }) => (
<FormItem>
<FormLabel>
Destination Site
</FormLabel>
<Popover
open={open}
onOpenChange={setOpen}
>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
!field.value &&
"text-muted-foreground"
)}
>
{field.value
? sites.find(
(site) =>
site.siteId ===
field.value
)?.name
: "Select site"}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput
placeholder="Search sites"
className="h-9"
/>
<CommandEmpty>
No sites found.
</CommandEmpty>
<CommandGroup>
{sites.map(
(site) => (
<CommandItem
value={`${site.name}:${site.siteId}`}
key={
site.siteId
}
onSelect={() => {
transferForm.setValue(
"siteId",
site.siteId
);
setOpen(
false
);
}}
>
{
site.name
}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
site.siteId ===
<div className="col-span-2">
{domainType === "subdomain" ? (
<div className="w-fill space-y-2">
<FormLabel>
Subdomain
</FormLabel>
<div className="flex">
<div className="w-1/2">
<FormField
control={
form.control
}
name="subdomain"
render={({
field
}) => (
<FormItem>
<FormControl>
<Input
{...field}
className="border-r-0 rounded-r-none"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="w-1/2">
<FormField
control={
form.control
}
name="domainId"
render={({
field
}) => (
<FormItem>
<Select
onValueChange={
field.onChange
}
defaultValue={
field.value
? "opacity-100"
: "opacity-0"
)}
/>
</CommandItem>
)
)}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
}
value={
field.value
}
>
<FormControl>
<SelectTrigger className="rounded-l-none">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{baseDomains.map(
(
option
) => (
<SelectItem
key={
option.domainId
}
value={
option.domainId
}
>
.
{
option.baseDomain
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
</div>
) : (
<FormField
control={form.control}
name="domainId"
render={({ field }) => (
<FormItem>
<FormLabel>
Base Domain
</FormLabel>
<Select
onValueChange={
field.onChange
}
defaultValue={
field.value ||
baseDomains[0]
?.domainId
}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{baseDomains.map(
(
option
) => (
<SelectItem
key={
option.domainId
}
value={
option.domainId
}
>
{
option.baseDomain
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
</div>
</>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={transferLoading}
disabled={transferLoading}
form="transfer-form"
>
Transfer Resource
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
{!resource.http && (
<FormField
control={form.control}
name="proxyPort"
render={({ field }) => (
<FormItem>
<FormLabel>
Port Number
</FormLabel>
<FormControl>
<Input
type="number"
value={
field.value ??
""
}
onChange={(e) =>
field.onChange(
e.target
.value
? parseInt(
e
.target
.value
)
: null
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={saveLoading}
disabled={saveLoading}
form="general-settings-form"
>
Save Settings
</Button>
</SettingsSectionFooter>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Transfer Resource
</SettingsSectionTitle>
<SettingsSectionDescription>
Transfer this resource to a different site
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...transferForm}>
<form
onSubmit={transferForm.handleSubmit(
onTransfer
)}
className="space-y-4"
id="transfer-form"
>
<FormField
control={transferForm.control}
name="siteId"
render={({ field }) => (
<FormItem>
<FormLabel>
Destination Site
</FormLabel>
<Popover
open={open}
onOpenChange={setOpen}
>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
!field.value &&
"text-muted-foreground"
)}
>
{field.value
? sites.find(
(
site
) =>
site.siteId ===
field.value
)?.name
: "Select site"}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder="Search sites" />
<CommandEmpty>
No sites found.
</CommandEmpty>
<CommandGroup>
{sites.map(
(site) => (
<CommandItem
value={`${site.name}:${site.siteId}`}
key={
site.siteId
}
onSelect={() => {
transferForm.setValue(
"siteId",
site.siteId
);
setOpen(
false
);
}}
>
{
site.name
}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
site.siteId ===
field.value
? "opacity-100"
: "opacity-0"
)}
/>
</CommandItem>
)
)}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={transferLoading}
disabled={transferLoading}
form="transfer-form"
>
Transfer Resource
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
)
);
}

View File

@@ -130,9 +130,7 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
<OrgProvider org={org}>
<ResourceProvider resource={resource} authInfo={authInfo}>
<SidebarSettings sidebarNavItems={sidebarNavItems}>
<div className="mb-8">
<ResourceInfoBox />
</div>
<ResourceInfoBox />
{children}
</SidebarSettings>
</ResourceProvider>

View File

@@ -33,6 +33,7 @@ import {
import {
Table,
TableBody,
TableCaption,
TableCell,
TableContainer,
TableHead,
@@ -94,7 +95,7 @@ enum RuleAction {
enum RuleMatch {
PATH = "Path",
IP = "IP",
CIDR = "IP Range",
CIDR = "IP Range"
}
export default function ResourceRules(props: {
@@ -623,7 +624,7 @@ export default function ResourceRules(props: {
onSubmit={addRuleForm.handleSubmit(addRule)}
className="space-y-4"
>
<div className="grid grid-cols-3 gap-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 items-end">
<FormField
control={addRuleForm.control}
name="action"
@@ -711,68 +712,63 @@ export default function ResourceRules(props: {
</FormItem>
)}
/>
<Button
type="submit"
variant="outlinePrimary"
disabled={!rulesEnabled}
>
Add Rule
</Button>
</div>
<Button
type="submit"
variant="outline"
disabled={!rulesEnabled}
>
Add Rule
</Button>
</form>
</Form>
<TableContainer>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column
.columnDef.header,
header.getContext()
)}
</TableHead>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<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}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{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"
>
No rules. Add a rule using the form.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
<p className="text-sm text-muted-foreground">
Rules are evaluated by priority in ascending order.
</p>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No rules. Add a rule using the form.
</TableCell>
</TableRow>
)}
</TableBody>
<TableCaption>
Rules are evaluated by priority in ascending order.
</TableCaption>
</Table>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button

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 = [
@@ -152,13 +157,16 @@ export default function CreateShareLinkForm({
if (res?.status === 200) {
setResources(
res.data.data.resources.filter((r) => {
return r.http;
}).map((r) => ({
resourceId: r.resourceId,
name: r.name,
resourceUrl: `${r.ssl ? "https://" : "http://"}${r.fullDomain}/`
}))
res.data.data.resources
.filter((r) => {
return r.http;
})
.map((r) => ({
resourceId: r.resourceId,
name: r.name,
resourceUrl: `${r.ssl ? "https://" : "http://"}${r.fullDomain}/`,
siteName: r.siteName
}))
);
}
}
@@ -229,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
@@ -274,7 +291,7 @@ export default function CreateShareLinkForm({
name="resourceId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel className="mb-2">
<FormLabel>
Resource
</FormLabel>
<Popover>
@@ -290,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>
@@ -318,9 +330,7 @@ export default function CreateShareLinkForm({
r
) => (
<CommandItem
value={
`${r.name}:${r.resourceId}`
}
value={`${r.name}:${r.resourceId}`}
key={
r.resourceId
}
@@ -348,9 +358,7 @@ export default function CreateShareLinkForm({
: "opacity-0"
)}
/>
{
r.name
}
{`${r.name} ${r.siteName ? `(${r.siteName})` : ""}`}
</CommandItem>
)
)}
@@ -369,13 +377,11 @@ export default function CreateShareLinkForm({
name="title"
render={({ field }) => (
<FormItem>
<Label>
<FormLabel>
Title (optional)
</Label>
</FormLabel>
<FormControl>
<Input
{...field}
/>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -383,66 +389,68 @@ export default function CreateShareLinkForm({
/>
<div className="space-y-4">
<Label>Expire In</Label>
<div className="grid grid-cols-2 gap-4 mt-2">
<FormField
control={form.control}
name="timeUnit"
render={({ field }) => (
<FormItem>
<Select
onValueChange={
field.onChange
}
defaultValue={field.value.toString()}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select duration" />
</SelectTrigger>
</FormControl>
<SelectContent>
{timeUnits.map(
(
option
) => (
<SelectItem
key={
option.unit
}
value={
option.unit
}
>
{
option.name
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2">
<FormLabel>Expire In</FormLabel>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="timeUnit"
render={({ field }) => (
<FormItem>
<Select
onValueChange={
field.onChange
}
defaultValue={field.value.toString()}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select duration" />
</SelectTrigger>
</FormControl>
<SelectContent>
{timeUnits.map(
(
option
) => (
<SelectItem
key={
option.unit
}
value={
option.unit
}
>
{
option.name
}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="timeValue"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
type="number"
min={1}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="timeValue"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
type="number"
min={1}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<div className="flex items-center space-x-2">
@@ -552,6 +560,9 @@ export default function CreateShareLinkForm({
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
<Button
type="button"
onClick={form.handleSubmit(onSubmit)}
@@ -560,9 +571,6 @@ export default function CreateShareLinkForm({
>
Create Link
</Button>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>

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>
@@ -273,6 +275,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)
}
>
Delete
</Button>
</div>
)
}
];

View File

@@ -41,6 +41,7 @@ import Link from "next/link";
import {
ArrowUpRight,
ChevronsUpDown,
Loader2,
SquareArrowOutUpRight
} from "lucide-react";
import {
@@ -48,6 +49,7 @@ import {
CollapsibleContent,
CollapsibleTrigger
} from "@app/components/ui/collapsible";
import LoaderPlaceholder from "@app/components/PlaceHolderLoader";
const createSiteFormSchema = z.object({
name: z
@@ -97,6 +99,8 @@ export default function CreateSiteForm({
const [siteDefaults, setSiteDefaults] =
useState<PickSiteDefaultsResponse | null>(null);
const [loadingPage, setLoadingPage] = useState(true);
const handleCheckboxChange = (checked: boolean) => {
// setChecked?.(checked);
setIsChecked(checked);
@@ -121,27 +125,36 @@ export default function CreateSiteForm({
useEffect(() => {
if (!open) return;
// reset all values
setLoading?.(false);
setIsLoading(false);
form.reset();
setChecked?.(false);
setKeypair(null);
setSiteDefaults(null);
const load = async () => {
setLoadingPage(true);
// reset all values
setLoading?.(false);
setIsLoading(false);
form.reset();
setChecked?.(false);
setKeypair(null);
setSiteDefaults(null);
const generatedKeypair = generateKeypair();
setKeypair(generatedKeypair);
const generatedKeypair = generateKeypair();
setKeypair(generatedKeypair);
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) {
setSiteDefaults(res.data.data);
}
});
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) {
setSiteDefaults(res.data.data);
}
});
await new Promise((resolve) => setTimeout(resolve, 200));
setLoadingPage(false);
};
load();
}, [open]);
async function onSubmit(data: CreateSiteFormValues) {
@@ -257,7 +270,9 @@ PersistentKeepalive = 5`
const newtConfigDockerRun = `docker run -it fosrl/newt --id ${siteDefaults?.newtId} --secret ${siteDefaults?.newtSecret} --endpoint ${env.app.dashboardUrl}`;
return (
return loadingPage ? (
<LoaderPlaceholder height="300px" />
) : (
<div className="space-y-4">
<Form {...form}>
<form
@@ -276,8 +291,7 @@ PersistentKeepalive = 5`
</FormControl>
<FormMessage />
<FormDescription>
This is the the display name for the
site.
This is the the display name for the site.
</FormDescription>
</FormItem>
)}
@@ -331,7 +345,6 @@ PersistentKeepalive = 5`
rel="noopener noreferrer"
>
<span>
{" "}
Learn how to install Newt on your system
</span>
<SquareArrowOutUpRight size={14} />
@@ -358,12 +371,16 @@ PersistentKeepalive = 5`
onOpenChange={setIsOpen}
className="space-y-2"
>
<div className="mx-auto">
<div className="mx-auto mb-2">
<CopyTextBox
text={newtConfig}
wrapText={false}
/>
</div>
<span className="text-sm text-muted-foreground">
You will only be able to see the
configuration once.
</span>
<div className="flex items-center justify-between space-x-4">
<CollapsibleTrigger asChild>
<Button
@@ -405,10 +422,6 @@ PersistentKeepalive = 5`
</CollapsibleContent>
</Collapsible>
</div>
<span className="text-sm text-muted-foreground">
You will only be able to see the
configuration once.
</span>
</>
) : null}
</div>

View File

@@ -58,6 +58,9 @@ export default function CreateSiteFormModal({
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
<Button
type="submit"
form="create-site-form"
@@ -69,9 +72,6 @@ export default function CreateSiteFormModal({
>
Create Site
</Button>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>

View File

@@ -268,7 +268,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
<Link
href={`/${siteRow.orgId}/settings/sites/${siteRow.nice}`}
>
<Button variant={"outline"} className="ml-2">
<Button variant={"outlinePrimary"} className="ml-2">
Edit
<ArrowRight className="ml-2 w-4 h-4" />
</Button>

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

@@ -68,9 +68,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
<SiteProvider site={site}>
<SidebarSettings sidebarNavItems={sidebarNavItems}>
<div className="mb-8">
<SiteInfoCard />
</div>
<SiteInfoCard />
{children}
</SidebarSettings>
</SiteProvider>

View File

@@ -41,7 +41,7 @@ export default async function Page(props: {
Looks like you've been invited!
</h2>
<p className="text-center">
To accept the invite, you must login or create an
To accept the invite, you must log in or create an
account.
</p>
</div>

View File

@@ -182,7 +182,7 @@ export default function ResetPasswordForm({
return;
}
setSuccessMessage("Password reset successfully! Back to login...");
setSuccessMessage("Password reset successfully! Back to log in...");
setTimeout(() => {
if (redirect) {

View File

@@ -57,7 +57,7 @@ export default async function Page(props: {
Looks like you've been invited!
</h2>
<p className="text-center">
To accept the invite, you must login or create an
To accept the invite, you must log in or create an
account.
</p>
</div>

View File

@@ -198,8 +198,7 @@ export default function VerifyEmailForm({
<FormMessage />
<FormDescription>
We sent a verification code to your
email address. Please enter the code
to verify your email address.
email address.
</FormDescription>
</FormItem>
)}

View File

@@ -21,8 +21,8 @@
--accent-foreground: 24 9.8% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 20 5.9% 85%;
--input: 20 5.9% 85%;
--border: 20 5.9% 80%;
--input: 20 5.9% 75%;
--ring: 24.6 95% 53.1%;
--radius: 0.75rem;
--chart-1: 12 76% 61%;
@@ -49,8 +49,8 @@
--accent-foreground: 60 9.1% 97.8%;
--destructive: 0 72.2% 50.6%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 12 6.5% 25.0%;
--input: 12 6.5% 25.0%;
--border: 12 6.5% 30.0%;
--input: 12 6.5% 35.0%;
--ring: 20.5 90.2% 48.2%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;

View File

@@ -37,11 +37,11 @@ export default async function RootLayout({
>
<EnvProvider env={pullEnv()}>
{/* Main content */}
<div className="flex-grow">{children}</div>
<div className="flex-grow pb-3 md:pb-0">{children}</div>
{/* Footer */}
<footer className="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 select-none">
<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>

View File

@@ -112,7 +112,7 @@ export default function StepperForm() {
<>
<Card>
<CardHeader>
<CardTitle>Setup New Organization</CardTitle>
<CardTitle>New Organization</CardTitle>
<CardDescription>
Create your organization, site, and resources
</CardDescription>

View File

@@ -7,7 +7,7 @@ import {
FormField,
FormItem,
FormLabel,
FormMessage,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import {
@@ -15,14 +15,14 @@ import {
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
SelectValue
} from "@app/components/ui/select";
import { useToast } from "@app/hooks/useToast";
import { zodResolver } from "@hookform/resolvers/zod";
import {
InviteUserBody,
InviteUserResponse,
ListUsersResponse,
ListUsersResponse
} from "@server/routers/user";
import { AxiosResponse } from "axios";
import React, { useState } from "react";
@@ -37,7 +37,7 @@ import {
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle,
CredenzaTitle
} from "@app/components/Credenza";
import { useOrgContext } from "@app/hooks/useOrgContext";
import { Description } from "@radix-ui/react-toast";
@@ -61,7 +61,7 @@ export default function InviteUserForm({
title,
onConfirm,
buttonText,
dialog,
dialog
}: InviteUserFormProps) {
const [loading, setLoading] = useState(false);
@@ -69,15 +69,15 @@ export default function InviteUserForm({
const formSchema = z.object({
string: z.string().refine((val) => val === string, {
message: "Invalid confirmation",
}),
message: "Invalid confirmation"
})
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
string: "",
},
string: ""
}
});
function reset() {
@@ -128,6 +128,9 @@ export default function InviteUserForm({
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
<Button
type="submit"
form="confirm-delete-form"
@@ -136,9 +139,6 @@ export default function InviteUserForm({
>
{buttonText}
</Button>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>

View File

@@ -78,7 +78,7 @@ const CredenzaClose = ({ className, children, ...props }: CredenzaProps) => {
const CredenzaClose = isDesktop ? DialogClose : DrawerClose;
return (
<CredenzaClose className={cn("mb-3 md:mb-0", className)} {...props}>
<CredenzaClose className={cn("mb-3 mt-3 md:mt-0 md:mb-0", className)} {...props}>
{children}
</CredenzaClose>
);
@@ -155,7 +155,7 @@ const CredenzaBody = ({ className, children, ...props }: CredenzaProps) => {
// );
return (
<div className={cn("px-0 mb-4", className)} {...props}>
<div className={cn("px-0 mb-4 space-y-4", className)} {...props}>
{children}
</div>
);
@@ -168,7 +168,7 @@ const CredenzaFooter = ({ className, children, ...props }: CredenzaProps) => {
const CredenzaFooter = isDesktop ? DialogFooter : SheetFooter;
return (
<CredenzaFooter className={className} {...props}>
<CredenzaFooter className={cn("mt-8 md:mt-0", className)} {...props}>
{children}
</CredenzaFooter>
);

View File

@@ -29,10 +29,8 @@ import {
CredenzaTitle
} from "@app/components/Credenza";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";;
import { formatAxiosError } from "@app/lib/api";
import { useUserContext } from "@app/hooks/useUserContext";
import { InputOTP, InputOTPGroup, InputOTPSlot } from "./ui/input-otp";
import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
import { CheckCircle2 } from "lucide-react";
const disableSchema = z.object({
@@ -152,36 +150,7 @@ export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) {
Authenticator Code
</FormLabel>
<FormControl>
<InputOTP
maxLength={6}
{...field}
pattern={
REGEXP_ONLY_DIGITS_AND_CHARS
}
>
<InputOTPGroup>
<InputOTPSlot
index={0}
/>
<InputOTPSlot
index={1}
/>
<InputOTPSlot
index={2}
/>
</InputOTPGroup>
<InputOTPGroup>
<InputOTPSlot
index={3}
/>
<InputOTPSlot
index={4}
/>
<InputOTPSlot
index={5}
/>
</InputOTPGroup>
</InputOTP>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -210,6 +179,9 @@ export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) {
)}
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
{step === "password" && (
<Button
type="submit"
@@ -220,9 +192,6 @@ export default function Disable2FaForm({ open, setOpen }: Disable2FaProps) {
Disable 2FA
</Button>
)}
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>

View File

@@ -36,7 +36,7 @@ import {
CredenzaTitle
} from "@app/components/Credenza";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";;
import { formatAxiosError } from "@app/lib/api";
import CopyTextBox from "@app/components/CopyTextBox";
import { QRCodeCanvas, QRCodeSVG } from "qrcode.react";
import { useUserContext } from "@app/hooks/useUserContext";
@@ -222,7 +222,10 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
<QRCodeCanvas value={secretUri} size={200} />
</div>
<div className="max-w-md mx-auto">
<CopyTextBox text={secretUri} wrapText={false} />
<CopyTextBox
text={secretUri}
wrapText={false}
/>
</div>
<Form {...confirmForm}>
@@ -279,6 +282,9 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
)}
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
{(step === 1 || step === 2) && (
<Button
type="button"
@@ -295,9 +301,6 @@ export default function Enable2FaForm({ open, setOpen }: Enable2FaProps) {
Submit
</Button>
)}
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>

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

@@ -0,0 +1,21 @@
"use client";
import React from "react";
import { Loader2 } from "lucide-react"; // Ensure you have lucide-react installed
interface LoaderProps {
height?: string;
}
const LoaderPlaceholder: React.FC<LoaderProps> = ({ height = "100px" }) => {
return (
<div
className="flex items-center justify-center w-full"
style={{ height }}
>
<Loader2 className="animate-spin" />
</div>
);
};
export default LoaderPlaceholder;

View File

@@ -1,5 +1,5 @@
export function SettingsContainer({ children }: { children: React.ReactNode }) {
return <div className="space-y-4">{children}</div>
return <div className="space-y-6">{children}</div>
}
export function SettingsSection({ children }: { children: React.ReactNode }) {
@@ -7,7 +7,7 @@ export function SettingsSection({ children }: { children: React.ReactNode }) {
}
export function SettingsSectionHeader({ children }: { children: React.ReactNode }) {
return <div className="space-y-0.5 pb-8">{children}</div>
return <div className="space-y-0.5 pb-6">{children}</div>
}
export function SettingsSectionForm({ children }: { children: React.ReactNode }) {
@@ -19,7 +19,7 @@ export function SettingsSectionTitle({ children }: { children: React.ReactNode }
}
export function SettingsSectionDescription({ children }: { children: React.ReactNode }) {
return <p className="text-muted-foreground">{children}</p>
return <p className="text-muted-foreground text-sm">{children}</p>
}
export function SettingsSectionBody({ children }: { children: React.ReactNode }) {

View File

@@ -11,7 +11,7 @@ export default function SettingsSectionTitle({
}: SettingsSectionTitleProps) {
return (
<div
className={`space-y-0.5 select-none ${!size || size === "2xl" ? "mb-8 md:mb-8" : ""}`}
className={`space-y-0.5 ${!size || size === "2xl" ? "mb-8 md:mb-8" : ""}`}
>
<h2
className={`text-${

View File

@@ -26,7 +26,7 @@ export function SidebarSettings({
<aside className="lg:w-1/5">
<SidebarNav items={sidebarNavItems} disabled={disabled} />
</aside>
<div className={`flex-1 ${limitWidth ? "lg:max-w-2xl" : ""}`}>
<div className={`flex-1 ${limitWidth ? "lg:max-w-2xl" : ""} space-y-6`}>
{children}
</div>
</div>

View File

@@ -0,0 +1,53 @@
"use client";
import { cn } from "@app/lib/cn";
import { RadioGroup, RadioGroupItem } from "./ui/radio-group";
interface StrategyOption {
id: string;
title: string;
description: string;
}
interface StrategySelectProps {
options: StrategyOption[];
defaultValue?: string;
onChange?: (value: string) => void;
}
export function StrategySelect({
options,
defaultValue,
onChange
}: StrategySelectProps) {
return (
<RadioGroup
defaultValue={defaultValue}
onValueChange={onChange}
className="grid gap-4"
>
{options.map((option) => (
<label
key={option.id}
htmlFor={option.id}
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"
)}
>
<RadioGroupItem
value={option.id}
id={option.id}
className="absolute left-4 top-5 h-4 w-4 border-primary text-primary"
/>
<div className="pl-7">
<div className="font-medium">{option.title}</div>
<div className="text-sm text-muted-foreground">
{option.description}
</div>
</div>
</label>
))}
</RadioGroup>
);
}

View File

@@ -490,7 +490,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
<div className="w-full">
<div
className={cn(
`flex flex-row flex-wrap items-center gap-2 p-2 w-full rounded-md border-2 border-input bg-background text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50`,
`flex flex-row flex-wrap items-center gap-1.5 p-1.5 w-full rounded-md border-2 border-input bg-background text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 bg-transparent`,
styleClasses?.inlineTagsContainer
)}
>
@@ -644,7 +644,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
) : (
<div
className={cn(
`flex flex-row flex-wrap items-center p-2 gap-2 h-fit w-full bg-background text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50`,
`flex flex-row flex-wrap items-center p-1.5 gap-1.5 h-fit w-full bg-transparent text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50`,
styleClasses?.inlineTagsContainer
)}
>

View File

@@ -22,7 +22,7 @@ export const tagVariants = cva(
"bg-destructive border-destructive text-destructive-foreground hover:bg-destructive/90 disabled:cursor-not-allowed disabled:opacity-50"
},
size: {
sm: "text-xs h-7",
sm: "text-xs h-6",
md: "text-sm h-8",
lg: "text-base h-9",
xl: "text-lg h-10"
@@ -67,7 +67,7 @@ export const tagVariants = cva(
variant: "default",
size: "md",
shape: "default",
borderStyle: "default",
borderStyle: "none",
interaction: "nonClickable",
animation: "fadeIn",
textStyle: "normal"
@@ -144,7 +144,7 @@ export const Tag: React.FC<TagProps> = ({
}}
disabled={disabled}
className={cn(
`py-1 px-3 h-full hover:bg-transparent`,
`p-1 h-full hover:bg-transparent`,
tagClasses?.closeButton
)}
>

View File

@@ -16,6 +16,8 @@ const buttonVariants = cva(
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border-2 border-input bg-card hover:bg-accent hover:text-accent-foreground",
outlinePrimary:
"border-2 border-primary bg-card hover:bg-primary/10 text-primary",
secondary:
"bg-secondary border border-input border-2 text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",

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

@@ -41,7 +41,7 @@ const InputOTPSlot = React.forwardRef<
<div
ref={ref}
className={cn(
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-base md:text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
"relative flex h-10 w-10 items-center justify-center border-y-2 border-r-2 border-input text-base md:text-sm transition-all first:rounded-l-md first:border-l-2 last:rounded-r-md",
isActive && "z-10 ring-2 ring-ring ring-offset-background",
className
)}

View File

@@ -20,8 +20,8 @@ const SelectTrigger = React.forwardRef<
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between border-2 border-input bg-card px-3 py-2 text-base md:text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
"rounded-md"
"rounded-md",
className
)}
{...props}
>

View File

@@ -11,7 +11,7 @@ const Switch = React.forwardRef<
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-4 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
@@ -19,7 +19,7 @@ const Switch = React.forwardRef<
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-3 w-3 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>

View File

@@ -77,7 +77,7 @@ const TableHead = React.forwardRef<
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
"h-10 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}

View File

@@ -25,7 +25,7 @@ const ToastViewport = React.forwardRef<
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-3 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-3 pr-8 transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=open]:fade-in data-[state=closed]:animate-out data-[state=closed]:fade-out data-[swipe=end]:animate-out",
{
variants: {
variant: {

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/*": ["*"],
"@/*": ["./*"]
},