mirror of
https://github.com/fosrl/pangolin.git
synced 2025-08-16 07:28:06 +02:00
Merge branch 'dev' into auth-providers-clients
This commit is contained in:
commit
a76dd9c9d1
38 changed files with 455 additions and 204 deletions
10
Dockerfile
10
Dockerfile
|
@ -2,8 +2,9 @@ FROM node:20-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
# COPY package.json package-lock.json ./
|
||||||
RUN npm ci
|
COPY package.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
@ -18,8 +19,9 @@ WORKDIR /app
|
||||||
# Curl used for the health checks
|
# Curl used for the health checks
|
||||||
RUN apk add --no-cache curl
|
RUN apk add --no-cache curl
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
# COPY package.json package-lock.json ./
|
||||||
RUN npm ci --only=production && npm cache clean --force
|
COPY package.json ./
|
||||||
|
RUN npm install --only=production && npm cache clean --force
|
||||||
|
|
||||||
COPY --from=builder /app/.next/standalone ./
|
COPY --from=builder /app/.next/standalone ./
|
||||||
COPY --from=builder /app/.next/static ./.next/static
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
|
77
README.md
77
README.md
|
@ -34,53 +34,58 @@ _Your own self-hosted zero trust tunnel._
|
||||||
|
|
||||||
Pangolin is a self-hosted tunneled reverse proxy server with identity and access control, designed to securely expose private resources on distributed networks. Acting as a central hub, it connects isolated networks — even those behind restrictive firewalls — through encrypted tunnels, enabling easy access to remote services without opening ports.
|
Pangolin is a self-hosted tunneled reverse proxy server with identity and access control, designed to securely expose private resources on distributed networks. Acting as a central hub, it connects isolated networks — even those behind restrictive firewalls — through encrypted tunnels, enabling easy access to remote services without opening ports.
|
||||||
|
|
||||||
<img src="public/screenshots/sites.png" alt="Preview"/>
|
<img src="public/screenshots/hero.png" alt="Preview"/>
|
||||||
|
|
||||||
_Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected to the central server._
|
_Resources page of Pangolin dashboard (dark mode) showing multiple resources available to connect._
|
||||||
|
|
||||||
## Key Features
|
## Key Features
|
||||||
|
|
||||||
### Reverse Proxy Through WireGuard Tunnel
|
### Reverse Proxy Through WireGuard Tunnel
|
||||||
|
|
||||||
- Expose private resources on your network **without opening ports** (firewall punching).
|
- Expose private resources on your network **without opening ports** (firewall punching).
|
||||||
- Secure and easy to configure site-to-site connectivity via a custom **user space WireGuard client**, [Newt](https://github.com/fosrl/newt).
|
- Secure and easy to configure site-to-site connectivity via a custom **user space WireGuard client**, [Newt](https://github.com/fosrl/newt).
|
||||||
- Built-in support for any WireGuard client.
|
- Built-in support for any WireGuard client.
|
||||||
- Automated **SSL certificates** (https) via [LetsEncrypt](https://letsencrypt.org/).
|
- Automated **SSL certificates** (https) via [LetsEncrypt](https://letsencrypt.org/).
|
||||||
- Support for HTTP/HTTPS and **raw TCP/UDP services**.
|
- Support for HTTP/HTTPS and **raw TCP/UDP services**.
|
||||||
- Load balancing.
|
- Load balancing.
|
||||||
|
|
||||||
### Identity & Access Management
|
### Identity & Access Management
|
||||||
|
|
||||||
- Centralized authentication system using platform SSO. **Users will only have to manage one login.**
|
- Centralized authentication system using platform SSO. **Users will only have to manage one login.**
|
||||||
- **Define access control rules for IPs, IP ranges, and URL paths per resource.**
|
- **Define access control rules for IPs, IP ranges, and URL paths per resource.**
|
||||||
- TOTP with backup codes for two-factor authentication.
|
- TOTP with backup codes for two-factor authentication.
|
||||||
- Create organizations, each with multiple sites, users, and roles.
|
- Create organizations, each with multiple sites, users, and roles.
|
||||||
- **Role-based access control** to manage resource access permissions.
|
- **Role-based access control** to manage resource access permissions.
|
||||||
- Additional authentication options include:
|
- Additional authentication options include:
|
||||||
- Email whitelisting with **one-time passcodes.**
|
- Email whitelisting with **one-time passcodes.**
|
||||||
- **Temporary, self-destructing share links.**
|
- **Temporary, self-destructing share links.**
|
||||||
- Resource specific pin codes.
|
- Resource specific pin codes.
|
||||||
- Resource specific passwords.
|
- Resource specific passwords.
|
||||||
|
- External identity provider (IdP) support with OAuth2/OIDC, such as Authentik, Keycloak, Okta, and others.
|
||||||
|
- Auto-provision users and roles from your IdP.
|
||||||
|
|
||||||
### Simple Dashboard UI
|
### Simple Dashboard UI
|
||||||
|
|
||||||
- Manage sites, users, and roles with a clean and intuitive UI.
|
- Manage sites, users, and roles with a clean and intuitive UI.
|
||||||
- Monitor site usage and connectivity.
|
- Monitor site usage and connectivity.
|
||||||
- Light and dark mode options.
|
- Light and dark mode options.
|
||||||
- Mobile friendly.
|
- Mobile friendly.
|
||||||
|
|
||||||
### Easy Deployment
|
### Easy Deployment
|
||||||
|
|
||||||
- Run on any cloud provider or on-premises.
|
- 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.
|
- Future-proof installation script for streamlined setup and feature additions.
|
||||||
- Use any 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.
|
||||||
|
- Use the API to create custom integrations and scripts.
|
||||||
|
- Fine-grained access control to the API via scoped API keys.
|
||||||
|
- Comprehensive Swagger documentation for the API.
|
||||||
|
|
||||||
### Modular Design
|
### Modular Design
|
||||||
|
|
||||||
- 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).
|
- 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.**
|
- **Automatically install and configure Crowdsec via Pangolin's installer script.**
|
||||||
- Attach as many sites to the central server as you wish.
|
- Attach as many sites to the central server as you wish.
|
||||||
|
|
||||||
<img src="public/screenshots/collage.png" alt="Collage"/>
|
<img src="public/screenshots/collage.png" alt="Collage"/>
|
||||||
|
|
||||||
|
@ -88,8 +93,8 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
|
||||||
|
|
||||||
1. **Deploy the Central Server**:
|
1. **Deploy the Central Server**:
|
||||||
|
|
||||||
- Deploy the Docker Compose stack onto a VPS hosted on a cloud platform like RackNerd, Amazon EC2, DigitalOcean Droplet, or similar. There are many cheap VPS hosting options available to suit your needs.
|
- Deploy the Docker Compose stack onto a VPS hosted on a cloud platform like RackNerd, Amazon EC2, DigitalOcean Droplet, or similar. There are many cheap VPS hosting options available to suit your needs.
|
||||||
|
|
||||||
> [!TIP]
|
> [!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!
|
> 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 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.
|
> 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.
|
||||||
|
@ -111,21 +116,21 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
|
||||||
**Use Case Example - Bypassing Port Restrictions in Home Lab**:
|
**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.
|
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.
|
||||||
|
|
||||||
|
**Use Case Example - Deploying Services For Your Business**:
|
||||||
|
You can use Pangolin as an easy way to expose your business applications to your users behind a safe authentication portal you can integrate into your IdP solution. Expose resources on prem and on the cloud.
|
||||||
|
|
||||||
**Use Case Example - IoT Networks**:
|
**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.
|
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._
|
_Resources page of Pangolin dashboard (dark mode) showing HTTPS and TCP resources with access control rules._
|
||||||
|
|
||||||
## Similar Projects and Inspirations
|
## Similar Projects and Inspirations
|
||||||
|
|
||||||
**Cloudflare Tunnels**:
|
**Cloudflare Tunnels**:
|
||||||
A similar approach to proxying private resources securely, but Pangolin is a self-hosted alternative, giving you full control over your infrastructure.
|
A similar approach to proxying private resources securely, but Pangolin is a self-hosted alternative, giving you full control over your infrastructure.
|
||||||
|
|
||||||
**Authentik and Authelia**:
|
**Authelia**:
|
||||||
These projects inspired Pangolin’s centralized authentication system for proxies, enabling robust user and role management.
|
This inspired Pangolin’s centralized authentication system for proxies, enabling robust user and role management.
|
||||||
|
|
||||||
## Project Development / Roadmap
|
## Project Development / Roadmap
|
||||||
|
|
||||||
|
|
|
@ -18,6 +18,7 @@ server:
|
||||||
internal_hostname: "pangolin"
|
internal_hostname: "pangolin"
|
||||||
session_cookie_name: "p_session_token"
|
session_cookie_name: "p_session_token"
|
||||||
resource_access_token_param: "p_token"
|
resource_access_token_param: "p_token"
|
||||||
|
secret: "your_secret_key_here"
|
||||||
resource_access_token_headers:
|
resource_access_token_headers:
|
||||||
id: "P-Access-Token-Id"
|
id: "P-Access-Token-Id"
|
||||||
token: "P-Access-Token"
|
token: "P-Access-Token"
|
||||||
|
|
|
@ -35,7 +35,7 @@ services:
|
||||||
- 80:80 # Port for traefik because of the network_mode
|
- 80:80 # Port for traefik because of the network_mode
|
||||||
{{end}}
|
{{end}}
|
||||||
traefik:
|
traefik:
|
||||||
image: traefik:v3.3.5
|
image: traefik:v3.3.6
|
||||||
container_name: traefik
|
container_name: traefik
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
{{if .InstallGerbil}}
|
{{if .InstallGerbil}}
|
||||||
|
|
|
@ -64,15 +64,15 @@ func main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
var config Config
|
var config Config
|
||||||
config.DoCrowdsecInstall = false
|
|
||||||
config.Secret = generateRandomSecretKey()
|
|
||||||
|
|
||||||
// check if there is already a config file
|
// check if there is already a config file
|
||||||
if _, err := os.Stat("config/config.yml"); err != nil {
|
if _, err := os.Stat("config/config.yml"); err != nil {
|
||||||
config = collectUserInput(reader)
|
config = collectUserInput(reader)
|
||||||
|
|
||||||
loadVersions(&config)
|
loadVersions(&config)
|
||||||
|
config.DoCrowdsecInstall = false
|
||||||
|
config.Secret = generateRandomSecretKey()
|
||||||
|
|
||||||
if err := createConfigFiles(config); err != nil {
|
if err := createConfigFiles(config); err != nil {
|
||||||
fmt.Printf("Error creating config files: %v\n", err)
|
fmt.Printf("Error creating config files: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
@ -202,7 +202,7 @@ func collectUserInput(reader *bufio.Reader) Config {
|
||||||
config.BaseDomain = readString(reader, "Enter your base domain (no subdomain e.g. example.com)", "")
|
config.BaseDomain = readString(reader, "Enter your base domain (no subdomain e.g. example.com)", "")
|
||||||
config.DashboardDomain = readString(reader, "Enter the domain for the Pangolin dashboard", "pangolin."+config.BaseDomain)
|
config.DashboardDomain = readString(reader, "Enter the domain for the Pangolin dashboard", "pangolin."+config.BaseDomain)
|
||||||
config.LetsEncryptEmail = readString(reader, "Enter email for Let's Encrypt certificates", "")
|
config.LetsEncryptEmail = readString(reader, "Enter email for Let's Encrypt certificates", "")
|
||||||
config.InstallGerbil = readBool(reader, "Do you want to use Gerbil to allow tunned connections", true)
|
config.InstallGerbil = readBool(reader, "Do you want to use Gerbil to allow tunneled connections", true)
|
||||||
|
|
||||||
// Admin user configuration
|
// Admin user configuration
|
||||||
fmt.Println("\n=== Admin User Configuration ===")
|
fmt.Println("\n=== Admin User Configuration ===")
|
||||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 574 KiB |
BIN
public/screenshots/hero.png
Normal file
BIN
public/screenshots/hero.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 434 KiB |
Binary file not shown.
Before Width: | Height: | Size: 706 KiB |
Binary file not shown.
Before Width: | Height: | Size: 729 KiB |
|
@ -15,7 +15,6 @@ import {
|
||||||
} from "@server/middlewares";
|
} from "@server/middlewares";
|
||||||
import { authenticated, unauthenticated } from "@server/routers/integration";
|
import { authenticated, unauthenticated } from "@server/routers/integration";
|
||||||
import { logIncomingMiddleware } from "./middlewares/logIncoming";
|
import { logIncomingMiddleware } from "./middlewares/logIncoming";
|
||||||
import { csrfProtectionMiddleware } from "./middlewares/csrfProtection";
|
|
||||||
import helmet from "helmet";
|
import helmet from "helmet";
|
||||||
import swaggerUi from "swagger-ui-express";
|
import swaggerUi from "swagger-ui-express";
|
||||||
import { OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi";
|
import { OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi";
|
||||||
|
@ -37,7 +36,6 @@ export function createIntegrationApiServer() {
|
||||||
|
|
||||||
if (!dev) {
|
if (!dev) {
|
||||||
apiServer.use(helmet());
|
apiServer.use(helmet());
|
||||||
apiServer.use(csrfProtectionMiddleware);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
apiServer.use(cookieParser());
|
apiServer.use(cookieParser());
|
||||||
|
|
|
@ -2,7 +2,7 @@ import path from "path";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
// This is a placeholder value replaced by the build process
|
// This is a placeholder value replaced by the build process
|
||||||
export const APP_VERSION = "1.2.0";
|
export const APP_VERSION = "1.3.0";
|
||||||
|
|
||||||
export const __FILENAME = fileURLToPath(import.meta.url);
|
export const __FILENAME = fileURLToPath(import.meta.url);
|
||||||
export const __DIRNAME = path.dirname(__FILENAME);
|
export const __DIRNAME = path.dirname(__FILENAME);
|
||||||
|
|
|
@ -13,12 +13,19 @@ import moment from "moment";
|
||||||
import { setHostMeta } from "@server/setup/setHostMeta";
|
import { setHostMeta } from "@server/setup/setHostMeta";
|
||||||
import { encrypt, decrypt } from "@server/lib/crypto";
|
import { encrypt, decrypt } from "@server/lib/crypto";
|
||||||
|
|
||||||
|
const keyTypes = ["HOST", "SITES"] as const;
|
||||||
|
type KeyType = (typeof keyTypes)[number];
|
||||||
|
|
||||||
|
const keyTiers = ["PROFESSIONAL", "ENTERPRISE"] as const;
|
||||||
|
type KeyTier = (typeof keyTiers)[number];
|
||||||
|
|
||||||
export type LicenseStatus = {
|
export type LicenseStatus = {
|
||||||
isHostLicensed: boolean; // Are there any license keys?
|
isHostLicensed: boolean; // Are there any license keys?
|
||||||
isLicenseValid: boolean; // Is the license key valid?
|
isLicenseValid: boolean; // Is the license key valid?
|
||||||
hostId: string; // Host ID
|
hostId: string; // Host ID
|
||||||
maxSites?: number;
|
maxSites?: number;
|
||||||
usedSites?: number;
|
usedSites?: number;
|
||||||
|
tier?: KeyTier;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LicenseKeyCache = {
|
export type LicenseKeyCache = {
|
||||||
|
@ -26,7 +33,8 @@ export type LicenseKeyCache = {
|
||||||
licenseKeyEncrypted: string;
|
licenseKeyEncrypted: string;
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
iat?: Date;
|
iat?: Date;
|
||||||
type?: "LICENSE" | "SITES";
|
type?: KeyType;
|
||||||
|
tier?: KeyTier;
|
||||||
numSites?: number;
|
numSites?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -54,7 +62,8 @@ type ValidateLicenseAPIResponse = {
|
||||||
|
|
||||||
type TokenPayload = {
|
type TokenPayload = {
|
||||||
valid: boolean;
|
valid: boolean;
|
||||||
type: "LICENSE" | "SITES";
|
type: KeyType;
|
||||||
|
tier: KeyTier;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
terminateAt: string; // ISO
|
terminateAt: string; // ISO
|
||||||
iat: number; // Issued at
|
iat: number; // Issued at
|
||||||
|
@ -182,11 +191,12 @@ LQIDAQAB
|
||||||
licenseKeyEncrypted: key.licenseKeyId,
|
licenseKeyEncrypted: key.licenseKeyId,
|
||||||
valid: payload.valid,
|
valid: payload.valid,
|
||||||
type: payload.type,
|
type: payload.type,
|
||||||
|
tier: payload.tier,
|
||||||
numSites: payload.quantity,
|
numSites: payload.quantity,
|
||||||
iat: new Date(payload.iat * 1000)
|
iat: new Date(payload.iat * 1000)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (payload.type === "LICENSE") {
|
if (payload.type === "HOST") {
|
||||||
foundHostKey = true;
|
foundHostKey = true;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
@ -273,6 +283,7 @@ LQIDAQAB
|
||||||
);
|
);
|
||||||
cached.valid = payload.valid;
|
cached.valid = payload.valid;
|
||||||
cached.type = payload.type;
|
cached.type = payload.type;
|
||||||
|
cached.tier = payload.tier;
|
||||||
cached.numSites = payload.quantity;
|
cached.numSites = payload.quantity;
|
||||||
cached.iat = new Date(payload.iat * 1000);
|
cached.iat = new Date(payload.iat * 1000);
|
||||||
|
|
||||||
|
@ -311,8 +322,9 @@ LQIDAQAB
|
||||||
|
|
||||||
logger.debug("Checking key", cached);
|
logger.debug("Checking key", cached);
|
||||||
|
|
||||||
if (cached.type === "LICENSE") {
|
if (cached.type === "HOST") {
|
||||||
status.isLicenseValid = cached.valid;
|
status.isLicenseValid = cached.valid;
|
||||||
|
status.tier = cached.tier;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!cached.valid) {
|
if (!cached.valid) {
|
||||||
|
|
|
@ -172,9 +172,20 @@ export async function listAccessTokens(
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { orgId, resourceId } = parsedParams.data;
|
const { resourceId } = parsedParams.data;
|
||||||
|
|
||||||
if (orgId && orgId !== req.userOrgId) {
|
const orgId =
|
||||||
|
parsedParams.data.orgId ||
|
||||||
|
req.userOrg?.orgId ||
|
||||||
|
req.apiKeyOrg?.orgId;
|
||||||
|
|
||||||
|
if (!orgId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.user && orgId && orgId !== req.userOrgId) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
|
@ -183,21 +194,29 @@ export async function listAccessTokens(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const accessibleResources = await db
|
let accessibleResources;
|
||||||
.select({
|
if (req.user) {
|
||||||
resourceId: sql<number>`COALESCE(${userResources.resourceId}, ${roleResources.resourceId})`
|
accessibleResources = await db
|
||||||
})
|
.select({
|
||||||
.from(userResources)
|
resourceId: sql<number>`COALESCE(${userResources.resourceId}, ${roleResources.resourceId})`
|
||||||
.fullJoin(
|
})
|
||||||
roleResources,
|
.from(userResources)
|
||||||
eq(userResources.resourceId, roleResources.resourceId)
|
.fullJoin(
|
||||||
)
|
roleResources,
|
||||||
.where(
|
eq(userResources.resourceId, roleResources.resourceId)
|
||||||
or(
|
|
||||||
eq(userResources.userId, req.user!.userId),
|
|
||||||
eq(roleResources.roleId, req.userOrgRoleId!)
|
|
||||||
)
|
)
|
||||||
);
|
.where(
|
||||||
|
or(
|
||||||
|
eq(userResources.userId, req.user!.userId),
|
||||||
|
eq(roleResources.roleId, req.userOrgRoleId!)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
accessibleResources = await db
|
||||||
|
.select({ resourceId: resources.resourceId })
|
||||||
|
.from(resources)
|
||||||
|
.where(eq(resources.orgId, orgId));
|
||||||
|
}
|
||||||
|
|
||||||
const accessibleResourceIds = accessibleResources.map(
|
const accessibleResourceIds = accessibleResources.map(
|
||||||
(resource) => resource.resourceId
|
(resource) => resource.resourceId
|
||||||
|
|
|
@ -23,7 +23,7 @@ import { oidcAutoProvision } from "./oidcAutoProvision";
|
||||||
import license from "@server/license/license";
|
import license from "@server/license/license";
|
||||||
|
|
||||||
const ensureTrailingSlash = (url: string): string => {
|
const ensureTrailingSlash = (url: string): string => {
|
||||||
return url.endsWith('/') ? url : `${url}/`;
|
return url.endsWith("/") ? url : `${url}/`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const paramsSchema = z
|
const paramsSchema = z
|
||||||
|
@ -228,6 +228,16 @@ export async function validateOidcCallback(
|
||||||
req,
|
req,
|
||||||
res
|
res
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return response<ValidateOidcUrlCallbackResponse>(res, {
|
||||||
|
data: {
|
||||||
|
redirectUrl: postAuthRedirectUrl
|
||||||
|
},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "OIDC callback validated successfully",
|
||||||
|
status: HttpCode.CREATED
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
if (!existingUser) {
|
if (!existingUser) {
|
||||||
return next(
|
return next(
|
||||||
|
|
|
@ -49,7 +49,7 @@ export async function createNewt(
|
||||||
|
|
||||||
const { newtId, secret } = parsedBody.data;
|
const { newtId, secret } = parsedBody.data;
|
||||||
|
|
||||||
if (!req.userOrgRoleId) {
|
if (req.user && !req.userOrgRoleId) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
|
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
|
||||||
);
|
);
|
||||||
|
|
|
@ -3,13 +3,16 @@ import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
|
apiKeyOrg,
|
||||||
|
apiKeys,
|
||||||
domains,
|
domains,
|
||||||
Org,
|
Org,
|
||||||
orgDomains,
|
orgDomains,
|
||||||
orgs,
|
orgs,
|
||||||
roleActions,
|
roleActions,
|
||||||
roles,
|
roles,
|
||||||
userOrgs
|
userOrgs,
|
||||||
|
users
|
||||||
} from "@server/db/schemas";
|
} from "@server/db/schemas";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
@ -57,7 +60,7 @@ export async function createOrg(
|
||||||
try {
|
try {
|
||||||
// should this be in a middleware?
|
// should this be in a middleware?
|
||||||
if (config.getRawConfig().flags?.disable_user_create_org) {
|
if (config.getRawConfig().flags?.disable_user_create_org) {
|
||||||
if (!req.user?.serverAdmin) {
|
if (req.user && !req.user?.serverAdmin) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
|
@ -171,12 +174,33 @@ export async function createOrg(
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
await trx.insert(userOrgs).values({
|
if (req.user) {
|
||||||
userId: req.user!.userId,
|
await trx.insert(userOrgs).values({
|
||||||
orgId: newOrg[0].orgId,
|
userId: req.user!.userId,
|
||||||
roleId: roleId,
|
orgId: newOrg[0].orgId,
|
||||||
isOwner: true
|
roleId: roleId,
|
||||||
});
|
isOwner: true
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// if org created by root api key, set the server admin as the owner
|
||||||
|
const [serverAdmin] = await trx
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.serverAdmin, true));
|
||||||
|
|
||||||
|
if (!serverAdmin) {
|
||||||
|
error = "Server admin not found";
|
||||||
|
trx.rollback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await trx.insert(userOrgs).values({
|
||||||
|
userId: serverAdmin.userId,
|
||||||
|
orgId: newOrg[0].orgId,
|
||||||
|
roleId: roleId,
|
||||||
|
isOwner: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const memberRole = await trx
|
const memberRole = await trx
|
||||||
.insert(roles)
|
.insert(roles)
|
||||||
|
@ -194,6 +218,18 @@ export async function createOrg(
|
||||||
orgId
|
orgId
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const rootApiKeys = await trx
|
||||||
|
.select()
|
||||||
|
.from(apiKeys)
|
||||||
|
.where(eq(apiKeys.isRoot, true));
|
||||||
|
|
||||||
|
for (const apiKey of rootApiKeys) {
|
||||||
|
await trx.insert(apiKeyOrg).values({
|
||||||
|
apiKeyId: apiKey.apiKeyId,
|
||||||
|
orgId: newOrg[0].orgId
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!org) {
|
if (!org) {
|
||||||
|
|
|
@ -29,16 +29,16 @@ const listOrgsSchema = z.object({
|
||||||
.pipe(z.number().int().nonnegative())
|
.pipe(z.number().int().nonnegative())
|
||||||
});
|
});
|
||||||
|
|
||||||
registry.registerPath({
|
// registry.registerPath({
|
||||||
method: "get",
|
// method: "get",
|
||||||
path: "/user/{userId}/orgs",
|
// path: "/user/{userId}/orgs",
|
||||||
description: "List all organizations for a user.",
|
// description: "List all organizations for a user.",
|
||||||
tags: [OpenAPITags.Org, OpenAPITags.User],
|
// tags: [OpenAPITags.Org, OpenAPITags.User],
|
||||||
request: {
|
// request: {
|
||||||
query: listOrgsSchema
|
// query: listOrgsSchema
|
||||||
},
|
// },
|
||||||
responses: {}
|
// responses: {}
|
||||||
});
|
// });
|
||||||
|
|
||||||
export type ListUserOrgsResponse = {
|
export type ListUserOrgsResponse = {
|
||||||
orgs: Org[];
|
orgs: Org[];
|
||||||
|
|
|
@ -39,6 +39,7 @@ const createHttpResourceSchema = z
|
||||||
isBaseDomain: z.boolean().optional(),
|
isBaseDomain: z.boolean().optional(),
|
||||||
siteId: z.number(),
|
siteId: z.number(),
|
||||||
http: z.boolean(),
|
http: z.boolean(),
|
||||||
|
protocol: z.string(),
|
||||||
domainId: z.string()
|
domainId: z.string()
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
|
@ -129,7 +130,7 @@ export async function createResource(
|
||||||
|
|
||||||
const { siteId, orgId } = parsedParams.data;
|
const { siteId, orgId } = parsedParams.data;
|
||||||
|
|
||||||
if (!req.userOrgRoleId) {
|
if (req.user && !req.userOrgRoleId) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
|
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
|
||||||
);
|
);
|
||||||
|
@ -202,7 +203,7 @@ async function createHttpResource(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { name, subdomain, isBaseDomain, http, domainId } =
|
const { name, subdomain, isBaseDomain, http, protocol, domainId } =
|
||||||
parsedBody.data;
|
parsedBody.data;
|
||||||
|
|
||||||
const [orgDomain] = await db
|
const [orgDomain] = await db
|
||||||
|
@ -261,7 +262,7 @@ async function createHttpResource(
|
||||||
name,
|
name,
|
||||||
subdomain,
|
subdomain,
|
||||||
http,
|
http,
|
||||||
protocol: "tcp",
|
protocol,
|
||||||
ssl: true,
|
ssl: true,
|
||||||
isBaseDomain
|
isBaseDomain
|
||||||
})
|
})
|
||||||
|
@ -284,7 +285,7 @@ async function createHttpResource(
|
||||||
resourceId: newResource[0].resourceId
|
resourceId: newResource[0].resourceId
|
||||||
});
|
});
|
||||||
|
|
||||||
if (req.userOrgRoleId != adminRole[0].roleId) {
|
if (req.user && req.userOrgRoleId != adminRole[0].roleId) {
|
||||||
// make sure the user can access the resource
|
// make sure the user can access the resource
|
||||||
await trx.insert(userResources).values({
|
await trx.insert(userResources).values({
|
||||||
userId: req.user?.userId!,
|
userId: req.user?.userId!,
|
||||||
|
|
|
@ -69,9 +69,7 @@ function queryResources(
|
||||||
http: resources.http,
|
http: resources.http,
|
||||||
protocol: resources.protocol,
|
protocol: resources.protocol,
|
||||||
proxyPort: resources.proxyPort,
|
proxyPort: resources.proxyPort,
|
||||||
enabled: resources.enabled,
|
enabled: resources.enabled
|
||||||
tlsServerName: resources.tlsServerName,
|
|
||||||
setHostHeader: resources.setHostHeader
|
|
||||||
})
|
})
|
||||||
.from(resources)
|
.from(resources)
|
||||||
.leftJoin(sites, eq(resources.siteId, sites.siteId))
|
.leftJoin(sites, eq(resources.siteId, sites.siteId))
|
||||||
|
@ -105,9 +103,7 @@ function queryResources(
|
||||||
http: resources.http,
|
http: resources.http,
|
||||||
protocol: resources.protocol,
|
protocol: resources.protocol,
|
||||||
proxyPort: resources.proxyPort,
|
proxyPort: resources.proxyPort,
|
||||||
enabled: resources.enabled,
|
enabled: resources.enabled
|
||||||
tlsServerName: resources.tlsServerName,
|
|
||||||
setHostHeader: resources.setHostHeader
|
|
||||||
})
|
})
|
||||||
.from(resources)
|
.from(resources)
|
||||||
.leftJoin(sites, eq(resources.siteId, sites.siteId))
|
.leftJoin(sites, eq(resources.siteId, sites.siteId))
|
||||||
|
@ -187,9 +183,17 @@ export async function listResources(
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { siteId, orgId } = parsedParams.data;
|
const { siteId } = parsedParams.data;
|
||||||
|
|
||||||
if (orgId && orgId !== req.userOrgId) {
|
const orgId = parsedParams.data.orgId || req.userOrg?.orgId || req.apiKeyOrg?.orgId;
|
||||||
|
|
||||||
|
if (!orgId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.user && orgId && orgId !== req.userOrgId) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
|
@ -198,7 +202,9 @@ export async function listResources(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const accessibleResources = await db
|
let accessibleResources;
|
||||||
|
if (req.user) {
|
||||||
|
accessibleResources = await db
|
||||||
.select({
|
.select({
|
||||||
resourceId: sql<number>`COALESCE(${userResources.resourceId}, ${roleResources.resourceId})`
|
resourceId: sql<number>`COALESCE(${userResources.resourceId}, ${roleResources.resourceId})`
|
||||||
})
|
})
|
||||||
|
@ -213,6 +219,11 @@ export async function listResources(
|
||||||
eq(roleResources.roleId, req.userOrgRoleId!)
|
eq(roleResources.roleId, req.userOrgRoleId!)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
accessibleResources = await db.select({
|
||||||
|
resourceId: resources.resourceId
|
||||||
|
}).from(resources).where(eq(resources.orgId, orgId));
|
||||||
|
}
|
||||||
|
|
||||||
const accessibleResourceIds = accessibleResources.map(
|
const accessibleResourceIds = accessibleResources.map(
|
||||||
(resource) => resource.resourceId
|
(resource) => resource.resourceId
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { roleResources, roles } from "@server/db/schemas";
|
import { apiKeys, roleResources, roles } from "@server/db/schemas";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
|
@ -74,6 +74,17 @@ export async function setResourceRoles(
|
||||||
|
|
||||||
const { resourceId } = parsedParams.data;
|
const { resourceId } = parsedParams.data;
|
||||||
|
|
||||||
|
const orgId = req.userOrg?.orgId || req.apiKeyOrg?.orgId;
|
||||||
|
|
||||||
|
if (!orgId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Organization not found"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// get this org's admin role
|
// get this org's admin role
|
||||||
const adminRole = await db
|
const adminRole = await db
|
||||||
.select()
|
.select()
|
||||||
|
@ -81,7 +92,7 @@ export async function setResourceRoles(
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(roles.name, "Admin"),
|
eq(roles.name, "Admin"),
|
||||||
eq(roles.orgId, req.userOrg!.orgId)
|
eq(roles.orgId, orgId)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
@ -136,3 +147,4 @@ export async function setResourceRoles(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -45,8 +45,8 @@ const updateHttpResourceBodySchema = z
|
||||||
domainId: z.string().optional(),
|
domainId: z.string().optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
stickySession: z.boolean().optional(),
|
stickySession: z.boolean().optional(),
|
||||||
tlsServerName: z.string().optional(),
|
tlsServerName: z.string().nullable().optional(),
|
||||||
setHostHeader: z.string().optional()
|
setHostHeader: z.string().nullable().optional()
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.refine((data) => Object.keys(data).length > 0, {
|
.refine((data) => Object.keys(data).length > 0, {
|
||||||
|
@ -81,7 +81,10 @@ const updateHttpResourceBodySchema = z
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
{ message: "Invalid TLS Server Name. Use domain name format, or save empty to remove the TLS Server Name." }
|
{
|
||||||
|
message:
|
||||||
|
"Invalid TLS Server Name. Use domain name format, or save empty to remove the TLS Server Name."
|
||||||
|
}
|
||||||
)
|
)
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
|
@ -90,7 +93,10 @@ const updateHttpResourceBodySchema = z
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
{ message: "Invalid custom Host Header value. Use domain name format, or save empty to unset custom Host Header." }
|
{
|
||||||
|
message:
|
||||||
|
"Invalid custom Host Header value. Use domain name format, or save empty to unset custom Host Header."
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export type UpdateResourceResponse = Resource;
|
export type UpdateResourceResponse = Resource;
|
||||||
|
@ -300,7 +306,22 @@ async function updateHttpResource(
|
||||||
|
|
||||||
const updatedResource = await db
|
const updatedResource = await db
|
||||||
.update(resources)
|
.update(resources)
|
||||||
.set(updatePayload)
|
.set({
|
||||||
|
name: updatePayload.name,
|
||||||
|
subdomain: updatePayload.subdomain,
|
||||||
|
ssl: updatePayload.ssl,
|
||||||
|
sso: updatePayload.sso,
|
||||||
|
blockAccess: updatePayload.blockAccess,
|
||||||
|
emailWhitelistEnabled: updatePayload.emailWhitelistEnabled,
|
||||||
|
isBaseDomain: updatePayload.isBaseDomain,
|
||||||
|
applyRules: updatePayload.applyRules,
|
||||||
|
domainId: updatePayload.domainId,
|
||||||
|
enabled: updatePayload.enabled,
|
||||||
|
stickySession: updatePayload.stickySession,
|
||||||
|
tlsServerName: updatePayload.tlsServerName || null,
|
||||||
|
setHostHeader: updatePayload.setHostHeader || null,
|
||||||
|
fullDomain: updatePayload.fullDomain
|
||||||
|
})
|
||||||
.where(eq(resources.resourceId, resource.resourceId))
|
.where(eq(resources.resourceId, resource.resourceId))
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|
|
@ -103,7 +103,7 @@ export async function createSite(
|
||||||
|
|
||||||
const { orgId } = parsedParams.data;
|
const { orgId } = parsedParams.data;
|
||||||
|
|
||||||
if (!req.userOrgRoleId) {
|
if (req.user && !req.userOrgRoleId) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
|
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
|
||||||
);
|
);
|
||||||
|
@ -235,7 +235,7 @@ export async function createSite(
|
||||||
siteId: newSite.siteId
|
siteId: newSite.siteId
|
||||||
});
|
});
|
||||||
|
|
||||||
if (req.userOrgRoleId != adminRole[0].roleId) {
|
if (req.user && req.userOrgRoleId != adminRole[0].roleId) {
|
||||||
// make sure the user can access the site
|
// make sure the user can access the site
|
||||||
trx.insert(userSites).values({
|
trx.insert(userSites).values({
|
||||||
userId: req.user?.userId!,
|
userId: req.user?.userId!,
|
||||||
|
|
|
@ -101,7 +101,7 @@ export async function listSites(
|
||||||
}
|
}
|
||||||
const { orgId } = parsedParams.data;
|
const { orgId } = parsedParams.data;
|
||||||
|
|
||||||
if (orgId && orgId !== req.userOrgId) {
|
if (req.user && orgId && orgId !== req.userOrgId) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
|
@ -110,18 +110,26 @@ export async function listSites(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const accessibleSites = await db
|
let accessibleSites;
|
||||||
.select({
|
if (req.user) {
|
||||||
siteId: sql<number>`COALESCE(${userSites.siteId}, ${roleSites.siteId})`
|
accessibleSites = await db
|
||||||
})
|
.select({
|
||||||
.from(userSites)
|
siteId: sql<number>`COALESCE(${userSites.siteId}, ${roleSites.siteId})`
|
||||||
.fullJoin(roleSites, eq(userSites.siteId, roleSites.siteId))
|
})
|
||||||
.where(
|
.from(userSites)
|
||||||
or(
|
.fullJoin(roleSites, eq(userSites.siteId, roleSites.siteId))
|
||||||
eq(userSites.userId, req.user!.userId),
|
.where(
|
||||||
eq(roleSites.roleId, req.userOrgRoleId!)
|
or(
|
||||||
)
|
eq(userSites.userId, req.user!.userId),
|
||||||
);
|
eq(roleSites.roleId, req.userOrgRoleId!)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
accessibleSites = await db
|
||||||
|
.select({ siteId: sites.siteId })
|
||||||
|
.from(sites)
|
||||||
|
.where(eq(sites.orgId, orgId));
|
||||||
|
}
|
||||||
|
|
||||||
const accessibleSiteIds = accessibleSites.map((site) => site.siteId);
|
const accessibleSiteIds = accessibleSites.map((site) => site.siteId);
|
||||||
const baseQuery = querySites(orgId, accessibleSiteIds);
|
const baseQuery = querySites(orgId, accessibleSiteIds);
|
||||||
|
|
|
@ -49,7 +49,7 @@ export async function addUserRole(
|
||||||
|
|
||||||
const { userId, roleId } = parsedParams.data;
|
const { userId, roleId } = parsedParams.data;
|
||||||
|
|
||||||
if (!req.userOrg) {
|
if (req.user && !req.userOrg) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
|
@ -58,7 +58,13 @@ export async function addUserRole(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const orgId = req.userOrg.orgId;
|
const orgId = req.userOrg?.orgId || req.apiKeyOrg?.orgId;
|
||||||
|
|
||||||
|
if (!orgId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const existingUser = await db
|
const existingUser = await db
|
||||||
.select()
|
.select()
|
||||||
|
|
|
@ -19,7 +19,15 @@ const paramsSchema = z
|
||||||
|
|
||||||
const bodySchema = z
|
const bodySchema = z
|
||||||
.object({
|
.object({
|
||||||
email: z.string().email().optional(),
|
email: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.refine((data) => {
|
||||||
|
if (data) {
|
||||||
|
return z.string().email().safeParse(data).success;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
username: z.string().nonempty(),
|
username: z.string().nonempty(),
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
type: z.enum(["internal", "oidc"]).optional(),
|
type: z.enum(["internal", "oidc"]).optional(),
|
||||||
|
|
|
@ -106,7 +106,7 @@ export async function getOrgUser(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.userId !== req.userOrg.userId) {
|
if (req.user && user.userId !== req.userOrg.userId) {
|
||||||
const hasPermission = await checkUserActionPermission(
|
const hasPermission = await checkUserActionPermission(
|
||||||
ActionsEnum.getOrgUser,
|
ActionsEnum.getOrgUser,
|
||||||
req
|
req
|
||||||
|
|
|
@ -8,8 +8,6 @@ import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
|
||||||
const version = "1.3.0";
|
const version = "1.3.0";
|
||||||
const location = path.join(APP_PATH, "db", "db.sqlite");
|
const location = path.join(APP_PATH, "db", "db.sqlite");
|
||||||
|
|
||||||
await migration();
|
|
||||||
|
|
||||||
export default async function migration() {
|
export default async function migration() {
|
||||||
console.log(`Running setup script ${version}...`);
|
console.log(`Running setup script ${version}...`);
|
||||||
|
|
||||||
|
|
|
@ -657,7 +657,7 @@ export default function ReverseProxyTargets(props: {
|
||||||
loading={httpsTlsLoading}
|
loading={httpsTlsLoading}
|
||||||
form="tls-settings-form"
|
form="tls-settings-form"
|
||||||
>
|
>
|
||||||
Save HTTPS & TLS Settings
|
Save Settings
|
||||||
</Button>
|
</Button>
|
||||||
</SettingsSectionFooter>
|
</SettingsSectionFooter>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
@ -896,7 +896,7 @@ export default function ReverseProxyTargets(props: {
|
||||||
<Input {...field} />
|
<Input {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
The Host header to set when
|
The host header to set when
|
||||||
proxying requests. Leave
|
proxying requests. Leave
|
||||||
empty to use the default.
|
empty to use the default.
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
|
|
|
@ -40,6 +40,21 @@ export function SitePriceCalculator({
|
||||||
setSiteCount((prev) => (prev > 1 ? prev - 1 : 1));
|
setSiteCount((prev) => (prev > 1 ? prev - 1 : 1));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function continueToPayment() {
|
||||||
|
if (mode === "license") {
|
||||||
|
// open in new tab
|
||||||
|
window.open(
|
||||||
|
`https://payment.fossorial.io/buy/dab98d3d-9976-49b1-9e55-1580059d833f?quantity=${siteCount}`,
|
||||||
|
"_blank"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
window.open(
|
||||||
|
`https://payment.fossorial.io/buy/2b881c36-ea5d-4c11-8652-9be6810a054f?quantity=${siteCount}`,
|
||||||
|
"_blank"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const totalCost =
|
const totalCost =
|
||||||
mode === "license"
|
mode === "license"
|
||||||
? licenseFlatRate + siteCount * pricePerSite
|
? licenseFlatRate + siteCount * pricePerSite
|
||||||
|
@ -141,7 +156,9 @@ export function SitePriceCalculator({
|
||||||
<CredenzaClose asChild>
|
<CredenzaClose asChild>
|
||||||
<Button variant="outline">Cancel</Button>
|
<Button variant="outline">Cancel</Button>
|
||||||
</CredenzaClose>
|
</CredenzaClose>
|
||||||
<Button>Continue to Payment</Button>
|
<Button onClick={continueToPayment}>
|
||||||
|
Continue to Payment
|
||||||
|
</Button>
|
||||||
</CredenzaFooter>
|
</CredenzaFooter>
|
||||||
</CredenzaContent>
|
</CredenzaContent>
|
||||||
</Credenza>
|
</Credenza>
|
||||||
|
|
|
@ -56,12 +56,16 @@ import { MinusCircle, PlusCircle } from "lucide-react";
|
||||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||||
import { SitePriceCalculator } from "./components/SitePriceCalculator";
|
import { SitePriceCalculator } from "./components/SitePriceCalculator";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { Checkbox } from "@app/components/ui/checkbox";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
licenseKey: z
|
licenseKey: z
|
||||||
.string()
|
.string()
|
||||||
.nonempty({ message: "License key is required" })
|
.nonempty({ message: "License key is required" })
|
||||||
.max(255)
|
.max(255),
|
||||||
|
agreeToTerms: z.boolean().refine((val) => val === true, {
|
||||||
|
message: "You must agree to the license terms"
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
function obfuscateLicenseKey(key: string): string {
|
function obfuscateLicenseKey(key: string): string {
|
||||||
|
@ -95,7 +99,8 @@ export default function LicensePage() {
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
licenseKey: ""
|
licenseKey: "",
|
||||||
|
agreeToTerms: false
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -116,7 +121,7 @@ export default function LicensePage() {
|
||||||
);
|
);
|
||||||
const keys = response.data.data;
|
const keys = response.data.data;
|
||||||
setRows(keys);
|
setRows(keys);
|
||||||
const hostKey = keys.find((key) => key.type === "LICENSE");
|
const hostKey = keys.find((key) => key.type === "HOST");
|
||||||
if (hostKey) {
|
if (hostKey) {
|
||||||
setHostLicense(hostKey.licenseKey);
|
setHostLicense(hostKey.licenseKey);
|
||||||
} else {
|
} else {
|
||||||
|
@ -265,6 +270,44 @@ export default function LicensePage() {
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="agreeToTerms"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
|
||||||
|
<FormControl>
|
||||||
|
<Checkbox
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={
|
||||||
|
field.onChange
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<div className="space-y-1 leading-none">
|
||||||
|
<FormLabel>
|
||||||
|
By checking this box, you
|
||||||
|
confirm that you have read
|
||||||
|
and agree to the license
|
||||||
|
terms corresponding to the
|
||||||
|
tier associated with your
|
||||||
|
license key.
|
||||||
|
<br />
|
||||||
|
<Link
|
||||||
|
href="https://fossorial.io/license.html"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
View Fossorial
|
||||||
|
Commercial License &
|
||||||
|
Subscription Terms
|
||||||
|
</Link>
|
||||||
|
</FormLabel>
|
||||||
|
<FormMessage />
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</CredenzaBody>
|
</CredenzaBody>
|
||||||
|
@ -305,8 +348,7 @@ export default function LicensePage() {
|
||||||
<p>
|
<p>
|
||||||
<b>
|
<b>
|
||||||
This will remove the license key and all
|
This will remove the license key and all
|
||||||
associated permissions. Any sites using this
|
associated permissions granted by it.
|
||||||
license key will no longer be accessible.
|
|
||||||
</b>
|
</b>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
|
@ -343,7 +385,13 @@ export default function LicensePage() {
|
||||||
<div className="space-y-2 text-green-500">
|
<div className="space-y-2 text-green-500">
|
||||||
<div className="text-2xl flex items-center gap-2">
|
<div className="text-2xl flex items-center gap-2">
|
||||||
<Check />
|
<Check />
|
||||||
Licensed
|
{licenseStatus?.tier ===
|
||||||
|
"PROFESSIONAL"
|
||||||
|
? "Professional License"
|
||||||
|
: licenseStatus?.tier ===
|
||||||
|
"ENTERPRISE"
|
||||||
|
? "Enterprise License"
|
||||||
|
: "Licensed"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
@ -173,7 +173,7 @@ export default function UsersTable({ users }: Props) {
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p>
|
<p>
|
||||||
Are you sure you want to permanently delete{" "}
|
Are you sure you want to permanently delete{" "}
|
||||||
<b>
|
<b className="break-all">
|
||||||
{selected?.email ||
|
{selected?.email ||
|
||||||
selected?.name ||
|
selected?.name ||
|
||||||
selected?.username}
|
selected?.username}
|
||||||
|
|
|
@ -5,21 +5,33 @@
|
||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
export default function LicenseViolation() {
|
export default function LicenseViolation() {
|
||||||
const { licenseStatus } = useLicenseStatusContext();
|
const { licenseStatus } = useLicenseStatusContext();
|
||||||
|
const [isDismissed, setIsDismissed] = useState(false);
|
||||||
|
|
||||||
if (!licenseStatus) return null;
|
if (!licenseStatus || isDismissed) return null;
|
||||||
|
|
||||||
// Show invalid license banner
|
// Show invalid license banner
|
||||||
if (licenseStatus.isHostLicensed && !licenseStatus.isLicenseValid) {
|
if (licenseStatus.isHostLicensed && !licenseStatus.isLicenseValid) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-0 left-0 right-0 w-full bg-red-500 text-white p-4 text-center z-50">
|
<div className="fixed bottom-0 left-0 right-0 w-full bg-red-500 text-white p-4 text-center z-50">
|
||||||
<p>
|
<div className="flex justify-between items-center">
|
||||||
Invalid or expired license keys detected. Follow license
|
<p>
|
||||||
terms to continue using all features.
|
Invalid or expired license keys detected. Follow license
|
||||||
</p>
|
terms to continue using all features.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant={"ghost"}
|
||||||
|
className="hover:bg-yellow-500"
|
||||||
|
onClick={() => setIsDismissed(true)}
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -32,12 +44,21 @@ export default function LicenseViolation() {
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-0 left-0 right-0 w-full bg-yellow-500 text-black p-4 text-center z-50">
|
<div className="fixed bottom-0 left-0 right-0 w-full bg-yellow-500 text-black p-4 text-center z-50">
|
||||||
<p>
|
<div className="flex justify-between items-center">
|
||||||
License Violation: This server is using{" "}
|
<p>
|
||||||
{licenseStatus.usedSites} sites which exceeds its licensed
|
License Violation: This server is using{" "}
|
||||||
limit of {licenseStatus.maxSites} sites. Follow license
|
{licenseStatus.usedSites} sites which exceeds its
|
||||||
terms to continue using all features.
|
licensed limit of {licenseStatus.maxSites} sites. Follow
|
||||||
</p>
|
license terms to continue using all features.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant={"ghost"}
|
||||||
|
className="hover:bg-yellow-500"
|
||||||
|
onClick={() => setIsDismissed(true)}
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -105,7 +105,7 @@ export default function InviteUserForm({
|
||||||
<CredenzaTitle>{title}</CredenzaTitle>
|
<CredenzaTitle>{title}</CredenzaTitle>
|
||||||
</CredenzaHeader>
|
</CredenzaHeader>
|
||||||
<CredenzaBody>
|
<CredenzaBody>
|
||||||
<div className="mb-4">{dialog}</div>
|
<div className="mb-4 break-all overflow-hidden">{dialog}</div>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={form.handleSubmit(onSubmit)}
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
|
|
@ -248,6 +248,12 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
|
||||||
pattern={
|
pattern={
|
||||||
REGEXP_ONLY_DIGITS_AND_CHARS
|
REGEXP_ONLY_DIGITS_AND_CHARS
|
||||||
}
|
}
|
||||||
|
onChange={(e) => {
|
||||||
|
field.onChange(e);
|
||||||
|
if (e.target.value.length === 6) {
|
||||||
|
mfaForm.handleSubmit(onSubmit)();
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<InputOTPGroup>
|
<InputOTPGroup>
|
||||||
<InputOTPSlot
|
<InputOTPSlot
|
||||||
|
|
|
@ -27,7 +27,6 @@ function getActionsCategories(root: boolean) {
|
||||||
"Get Organization User": "getOrgUser",
|
"Get Organization User": "getOrgUser",
|
||||||
"List Organization Domains": "listOrgDomains",
|
"List Organization Domains": "listOrgDomains",
|
||||||
"Check Org ID": "checkOrgId",
|
"Check Org ID": "checkOrgId",
|
||||||
"List Orgs": "listOrgs"
|
|
||||||
},
|
},
|
||||||
|
|
||||||
Site: {
|
Site: {
|
||||||
|
@ -91,14 +90,12 @@ function getActionsCategories(root: boolean) {
|
||||||
"List Resource Rules": "listResourceRules",
|
"List Resource Rules": "listResourceRules",
|
||||||
"Update Resource Rule": "updateResourceRule"
|
"Update Resource Rule": "updateResourceRule"
|
||||||
}
|
}
|
||||||
|
|
||||||
// "Newt": {
|
|
||||||
// "Create Newt": "createNewt"
|
|
||||||
// },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (root) {
|
if (root) {
|
||||||
actionsByCategory["Organization"] = {
|
actionsByCategory["Organization"] = {
|
||||||
|
"List Organizations": "listOrgs",
|
||||||
|
"Check ID": "checkOrgId",
|
||||||
"Create Organization": "createOrg",
|
"Create Organization": "createOrg",
|
||||||
"Delete Organization": "deleteOrg",
|
"Delete Organization": "deleteOrg",
|
||||||
"List API Keys": "listApiKeys",
|
"List API Keys": "listApiKeys",
|
||||||
|
|
|
@ -22,6 +22,7 @@ import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
import Disable2FaForm from "./Disable2FaForm";
|
import Disable2FaForm from "./Disable2FaForm";
|
||||||
import Enable2FaForm from "./Enable2FaForm";
|
import Enable2FaForm from "./Enable2FaForm";
|
||||||
import SupporterStatus from "./SupporterStatus";
|
import SupporterStatus from "./SupporterStatus";
|
||||||
|
import { UserType } from "@server/types/UserTypes";
|
||||||
|
|
||||||
export default function ProfileIcon() {
|
export default function ProfileIcon() {
|
||||||
const { setTheme, theme } = useTheme();
|
const { setTheme, theme } = useTheme();
|
||||||
|
@ -108,21 +109,25 @@ export default function ProfileIcon() {
|
||||||
)}
|
)}
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
{!user.twoFactorEnabled && (
|
{user?.type === UserType.Internal && (
|
||||||
<DropdownMenuItem
|
<>
|
||||||
onClick={() => setOpenEnable2fa(true)}
|
{!user.twoFactorEnabled && (
|
||||||
>
|
<DropdownMenuItem
|
||||||
<span>Enable Two-factor</span>
|
onClick={() => setOpenEnable2fa(true)}
|
||||||
</DropdownMenuItem>
|
>
|
||||||
|
<span>Enable Two-factor</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{user.twoFactorEnabled && (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => setOpenDisable2fa(true)}
|
||||||
|
>
|
||||||
|
<span>Disable Two-factor</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{user.twoFactorEnabled && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => setOpenDisable2fa(true)}
|
|
||||||
>
|
|
||||||
<span>Disable Two-factor</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuLabel>Theme</DropdownMenuLabel>
|
<DropdownMenuLabel>Theme</DropdownMenuLabel>
|
||||||
{(["light", "dark", "system"] as const).map(
|
{(["light", "dark", "system"] as const).map(
|
||||||
(themeOption) => (
|
(themeOption) => (
|
||||||
|
|
17
src/components/QRContainer.tsx
Normal file
17
src/components/QRContainer.tsx
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
export default function QRContainer({
|
||||||
|
children = <div/>,
|
||||||
|
outline = true
|
||||||
|
}) {
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative w-fit border-2 rounded-md`}
|
||||||
|
>
|
||||||
|
<div className="bg-white p-6 rounded-md">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -37,8 +37,31 @@ export function SidebarNav({
|
||||||
const niceId = params.niceId as string;
|
const niceId = params.niceId as string;
|
||||||
const resourceId = params.resourceId as string;
|
const resourceId = params.resourceId as string;
|
||||||
const userId = params.userId as string;
|
const userId = params.userId as string;
|
||||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
|
||||||
const clientId = params.clientId as string;
|
const clientId = params.clientId as string;
|
||||||
|
const [expandedItems, setExpandedItems] = useState<Set<string>>(() => {
|
||||||
|
const autoExpanded = new Set<string>();
|
||||||
|
|
||||||
|
function findAutoExpandedAndActivePath(
|
||||||
|
items: SidebarNavItem[],
|
||||||
|
parentHrefs: string[] = []
|
||||||
|
) {
|
||||||
|
items.forEach((item) => {
|
||||||
|
const hydratedHref = hydrateHref(item.href);
|
||||||
|
const currentPath = [...parentHrefs, hydratedHref];
|
||||||
|
|
||||||
|
if (item.autoExpand || pathname.startsWith(hydratedHref)) {
|
||||||
|
currentPath.forEach((href) => autoExpanded.add(href));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.children) {
|
||||||
|
findAutoExpandedAndActivePath(item.children, currentPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
findAutoExpandedAndActivePath(items);
|
||||||
|
return autoExpanded;
|
||||||
|
});
|
||||||
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
|
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
|
||||||
|
|
||||||
const { user } = useUserContext();
|
const { user } = useUserContext();
|
||||||
|
@ -52,37 +75,6 @@ export function SidebarNav({
|
||||||
.replace("{clientId}", clientId);
|
.replace("{clientId}", clientId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize expanded items based on autoExpand property and current path
|
|
||||||
useEffect(() => {
|
|
||||||
const autoExpanded = new Set<string>();
|
|
||||||
|
|
||||||
function findAutoExpandedAndActivePath(
|
|
||||||
items: SidebarNavItem[],
|
|
||||||
parentHrefs: string[] = []
|
|
||||||
) {
|
|
||||||
items.forEach((item) => {
|
|
||||||
const hydratedHref = hydrateHref(item.href);
|
|
||||||
|
|
||||||
// Add current item's href to the path
|
|
||||||
const currentPath = [...parentHrefs, hydratedHref];
|
|
||||||
|
|
||||||
// Auto expand if specified or if this item or any child is active
|
|
||||||
if (item.autoExpand || pathname.startsWith(hydratedHref)) {
|
|
||||||
// Expand all parent sections when a child is active
|
|
||||||
currentPath.forEach((href) => autoExpanded.add(href));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recursively check children
|
|
||||||
if (item.children) {
|
|
||||||
findAutoExpandedAndActivePath(item.children, currentPath);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
findAutoExpandedAndActivePath(items);
|
|
||||||
setExpandedItems(autoExpanded);
|
|
||||||
}, [items, pathname]);
|
|
||||||
|
|
||||||
function toggleItem(href: string) {
|
function toggleItem(href: string) {
|
||||||
setExpandedItems((prev) => {
|
setExpandedItems((prev) => {
|
||||||
const newSet = new Set(prev);
|
const newSet = new Set(prev);
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue