Compare commits

...

25 commits

Author SHA1 Message Date
Milo Schwartz
21f1326045
Merge pull request #651 from fosrl/dev
Dev
2025-05-03 13:07:18 -04:00
miloschwartz
f62e32724c
Merge branch 'main' into dev 2025-05-03 12:43:50 -04:00
miloschwartz
5e052a446a
1.3.1 2025-05-03 12:25:02 -04:00
Milo Schwartz
5d2f3186cc
Update README.md 2025-05-02 12:25:49 -04:00
miloschwartz
e58d10fc53
fix login form ts error 2025-05-02 11:05:20 -04:00
Milo Schwartz
4392bb604c
Merge pull request #636 from fosrl/dev
1.3.0
2025-05-02 10:55:35 -04:00
Milo Schwartz
5a4a6655a5
Merge branch 'main' into dev 2025-05-02 10:55:21 -04:00
miloschwartz
a20befd89f
add secret to example config 2025-05-02 10:52:12 -04:00
miloschwartz
a9f0b9aa38
add user checks in routes 2025-05-02 10:44:50 -04:00
Owen
f8e0219b49
Add api 2025-05-01 17:48:49 -04:00
Milo Schwartz
c4ae34383d
Merge pull request #595 from imbavirus/feature/wireguard-qrcode
Added QR code to wireguard config for easy scanning on mobile phones
2025-04-28 18:21:01 -04:00
Owen Schwartz
c543376a0a
Merge pull request #612 from lxfrdl/feat/enhance_2fa_login
feat: enhance 2fa login usability
2025-04-28 18:18:57 -04:00
Alex Freidel
a5b782b72a feat: enhance 2fa login
As soon as all digits have been entered, the form will be sent automatically. Similar to GitHub's implementation.
2025-04-28 08:14:19 +02:00
Owen Schwartz
4084849fdc
Merge pull request #610 from michaelfuckner/main
Fix Typo
2025-04-27 10:24:06 -04:00
Michael Fuckner
35e5f39c71 Fix Typo 2025-04-27 11:33:16 +02:00
Justin van der Westhuizen
893244100e removed react-qr-code 2025-04-25 19:50:14 +02:00
Justin van der Westhuizen
2a43b3ce4a changed qrcode to react-qr-code 2025-04-25 19:46:19 +02:00
Owen Schwartz
b82754c7af
Merge pull request #598 from imbavirus/bug/fixed-typo-in-db
Bug: fixed typo in exitNodes table
2025-04-25 10:31:50 -04:00
Owen Schwartz
8793d3976d
Merge pull request #599 from TuncTaylan/traefik-update
use the new traefik version 3.3.6
2025-04-25 10:28:42 -04:00
Owen Schwartz
6e833d4cee
Merge pull request #582 from vickodin/fix_link_to_geoblock
Improve README: Fix link to geoblock
2025-04-25 10:28:20 -04:00
Taylan
b3d0b69c04 use the new traefik version 3.3.6 2025-04-25 12:01:12 +02:00
Justin van der Westhuizen
28ac5e1237 fixed spelling of public in db (the L was missing) 2025-04-25 08:05:21 +02:00
Justin van der Westhuizen
8990de5618 added missing package 2025-04-25 07:38:17 +02:00
Justin van der Westhuizen
6aeddde1cd Added QR code to wireguard config for easy scanning on mobile phones 2025-04-25 07:06:14 +02:00
vickodin
2a00c877ea Improve README: Fix link to geoblock 2025-04-24 09:07:16 +03:00
33 changed files with 369 additions and 191 deletions

View file

@ -42,46 +42,50 @@ _Resources page of Pangolin dashboard (dark mode) showing multiple resources ava
### 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.
- OIDC Support for IDPs like Authentik - 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"/>
@ -89,8 +93,8 @@ _Resources page of Pangolin dashboard (dark mode) showing multiple resources ava
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.
@ -112,23 +116,19 @@ _Resources page of Pangolin dashboard (dark mode) showing multiple resources ava
**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** **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. 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._
## 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 Pangolins centralized authentication system for proxies, enabling robust user and role management. This inspired Pangolins centralized authentication system for proxies, enabling robust user and role management.
## Project Development / Roadmap ## Project Development / Roadmap

View file

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

View file

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

View file

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

View file

@ -17,7 +17,7 @@ function detectIpVersion(ip: string): IPVersion {
*/ */
function ipToBigInt(ip: string): bigint { function ipToBigInt(ip: string): bigint {
const version = detectIpVersion(ip); const version = detectIpVersion(ip);
if (version === 4) { if (version === 4) {
return ip.split('.') return ip.split('.')
.reduce((acc, octet) => { .reduce((acc, octet) => {
@ -105,7 +105,7 @@ export function cidrToRange(cidr: string): IPRange {
const version = detectIpVersion(ip); const version = detectIpVersion(ip);
const prefixBits = parseInt(prefix); const prefixBits = parseInt(prefix);
const ipBigInt = ipToBigInt(ip); const ipBigInt = ipToBigInt(ip);
// Validate prefix length // Validate prefix length
const maxPrefix = version === 4 ? 32 : 128; const maxPrefix = version === 4 ? 32 : 128;
if (prefixBits < 0 || prefixBits > maxPrefix) { if (prefixBits < 0 || prefixBits > maxPrefix) {
@ -116,7 +116,7 @@ export function cidrToRange(cidr: string): IPRange {
const mask = BigInt.asUintN(version === 4 ? 64 : 128, (BigInt(1) << shiftBits) - BigInt(1)); const mask = BigInt.asUintN(version === 4 ? 64 : 128, (BigInt(1) << shiftBits) - BigInt(1));
const start = ipBigInt & ~mask; const start = ipBigInt & ~mask;
const end = start | mask; const end = start | mask;
return { start, end }; return { start, end };
} }
@ -136,17 +136,17 @@ export function findNextAvailableCidr(
if (!startCidr && existingCidrs.length === 0) { if (!startCidr && existingCidrs.length === 0) {
return null; return null;
} }
// If no existing CIDRs, use the IP version from startCidr // If no existing CIDRs, use the IP version from startCidr
const version = startCidr const version = startCidr
? detectIpVersion(startCidr.split('/')[0]) ? detectIpVersion(startCidr.split('/')[0])
: 4; // Default to IPv4 if no startCidr provided : 4; // Default to IPv4 if no startCidr provided
// Use appropriate default startCidr if none provided // Use appropriate default startCidr if none provided
startCidr = startCidr || (version === 4 ? "0.0.0.0/0" : "::/0"); startCidr = startCidr || (version === 4 ? "0.0.0.0/0" : "::/0");
// If there are existing CIDRs, ensure all are same version // If there are existing CIDRs, ensure all are same version
if (existingCidrs.length > 0 && if (existingCidrs.length > 0 &&
existingCidrs.some(cidr => detectIpVersion(cidr.split('/')[0]) !== version)) { existingCidrs.some(cidr => detectIpVersion(cidr.split('/')[0]) !== version)) {
throw new Error('All CIDRs must be of the same IP version'); throw new Error('All CIDRs must be of the same IP version');
} }
@ -196,12 +196,14 @@ export function findNextAvailableCidr(
export function isIpInCidr(ip: string, cidr: string): boolean { export function isIpInCidr(ip: string, cidr: string): boolean {
const ipVersion = detectIpVersion(ip); const ipVersion = detectIpVersion(ip);
const cidrVersion = detectIpVersion(cidr.split('/')[0]); const cidrVersion = detectIpVersion(cidr.split('/')[0]);
// If IP versions don't match, the IP cannot be in the CIDR range
if (ipVersion !== cidrVersion) { if (ipVersion !== cidrVersion) {
throw new Error('IP address and CIDR must be of the same version'); // throw new Erorr
return false;
} }
const ipBigInt = ipToBigInt(ip); const ipBigInt = ipToBigInt(ip);
const range = cidrToRange(cidr); const range = cidrToRange(cidr);
return ipBigInt >= range.start && ipBigInt <= range.end; return ipBigInt >= range.start && ipBigInt <= range.end;
} }

View file

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

View file

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

View file

@ -28,7 +28,7 @@ const bodySchema = z
.strict(); .strict();
const ensureTrailingSlash = (url: string): string => { const ensureTrailingSlash = (url: string): string => {
return url.endsWith('/') ? url : `${url}/`; return url;
}; };
export type GenerateOidcUrlResponse = { export type GenerateOidcUrlResponse = {

View file

@ -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;
}; };
const paramsSchema = z const paramsSchema = z
@ -243,7 +243,7 @@ export async function validateOidcCallback(
return next( return next(
createHttpError( createHttpError(
HttpCode.UNAUTHORIZED, HttpCode.UNAUTHORIZED,
"User not provisioned in the system" `User with username ${userIdentifier} is unprovisioned. This user must be added to an organization before logging in.`
) )
); );
} }

View file

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

View file

@ -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";
@ -55,7 +58,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,
@ -143,12 +146,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)
@ -166,6 +190,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) {

View file

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

View file

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

View file

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

View file

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

View file

@ -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 { roles, userSites, sites, roleSites, Site } from "@server/db/schemas"; import { roles, userSites, sites, roleSites, Site, orgs } 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";
@ -10,7 +10,6 @@ import { eq, and } from "drizzle-orm";
import { getUniqueSiteName } from "@server/db/names"; import { getUniqueSiteName } from "@server/db/names";
import { addPeer } from "../gerbil/peers"; import { addPeer } from "../gerbil/peers";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { hash } from "@node-rs/argon2";
import { newts } from "@server/db/schemas"; import { newts } from "@server/db/schemas";
import moment from "moment"; import moment from "moment";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
@ -78,8 +77,15 @@ export async function createSite(
); );
} }
const { name, type, exitNodeId, pubKey, subnet, newtId, secret } = const {
parsedBody.data; name,
type,
exitNodeId,
pubKey,
subnet,
newtId,
secret
} = parsedBody.data;
const parsedParams = createSiteParamsSchema.safeParse(req.params); const parsedParams = createSiteParamsSchema.safeParse(req.params);
if (!parsedParams.success) { if (!parsedParams.success) {
@ -93,12 +99,23 @@ 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")
); );
} }
const [org] = await db.select().from(orgs).where(eq(orgs.orgId, orgId));
if (!org) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Organization with ID ${orgId} not found`
)
);
}
const niceId = await getUniqueSiteName(orgId); const niceId = await getUniqueSiteName(orgId);
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
@ -159,7 +176,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!,

View file

@ -100,7 +100,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,
@ -109,18 +109,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);

View file

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

View file

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

View file

@ -363,12 +363,12 @@ export default function ReverseProxyTargets(props: {
setHttpsTlsLoading(true); setHttpsTlsLoading(true);
await api.post(`/resource/${params.resourceId}`, { await api.post(`/resource/${params.resourceId}`, {
ssl: data.ssl, ssl: data.ssl,
tlsServerName: data.tlsServerName || undefined tlsServerName: data.tlsServerName || null
}); });
updateResource({ updateResource({
...resource, ...resource,
ssl: data.ssl, ssl: data.ssl,
tlsServerName: data.tlsServerName || undefined tlsServerName: data.tlsServerName || null
}); });
toast({ toast({
title: "TLS settings updated", title: "TLS settings updated",
@ -393,11 +393,11 @@ export default function ReverseProxyTargets(props: {
try { try {
setProxySettingsLoading(true); setProxySettingsLoading(true);
await api.post(`/resource/${params.resourceId}`, { await api.post(`/resource/${params.resourceId}`, {
setHostHeader: data.setHostHeader || undefined setHostHeader: data.setHostHeader || null
}); });
updateResource({ updateResource({
...resource, ...resource,
setHostHeader: data.setHostHeader || undefined setHostHeader: data.setHostHeader || null
}); });
toast({ toast({
title: "Proxy settings updated", title: "Proxy settings updated",
@ -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>

View file

@ -173,13 +173,15 @@ export default function Page() {
if (httpData.isBaseDomain) { if (httpData.isBaseDomain) {
Object.assign(payload, { Object.assign(payload, {
domainId: httpData.domainId, domainId: httpData.domainId,
isBaseDomain: true isBaseDomain: true,
protocol: "tcp"
}); });
} else { } else {
Object.assign(payload, { Object.assign(payload, {
subdomain: httpData.subdomain, subdomain: httpData.subdomain,
domainId: httpData.domainId, domainId: httpData.domainId,
isBaseDomain: false isBaseDomain: false,
protocol: "tcp"
}); });
} }
} else { } else {

View file

@ -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
@ -122,8 +137,8 @@ export function SitePriceCalculator({
</div> </div>
<p className="text-muted-foreground text-sm mt-2 text-center"> <p className="text-muted-foreground text-sm mt-2 text-center">
For the most up-to-date pricing, please visit For the most up-to-date pricing and discounts,
our{" "} please visit the{" "}
<a <a
href="https://docs.fossorial.io/pricing" href="https://docs.fossorial.io/pricing"
target="_blank" target="_blank"
@ -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>

View file

@ -121,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 {
@ -285,17 +285,22 @@ export default function LicensePage() {
</FormControl> </FormControl>
<div className="space-y-1 leading-none"> <div className="space-y-1 leading-none">
<FormLabel> <FormLabel>
I have read and agree to the By checking this box, you
Fossorial Commercial License confirm that you have read
- Professional Edition and agree to the license
Subscription Terms.{" "} terms corresponding to the
tier associated with your
license key.
<br />
<Link <Link
href="https://docs.fossorial.io/license.html" href="https://fossorial.io/license.html"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-primary hover:underline" className="text-primary hover:underline"
> >
View License & Terms View Fossorial
Commercial License &
Subscription Terms
</Link> </Link>
</FormLabel> </FormLabel>
<FormMessage /> <FormMessage />
@ -380,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>
) : ( ) : (
@ -441,6 +452,12 @@ export default function LicensePage() {
in system in system
</div> </div>
</div> </div>
{!licenseStatus?.isHostLicensed && (
<p className="text-sm text-muted-foreground">
There is no limit on the number of sites
using an unlicensed host.
</p>
)}
{licenseStatus?.maxSites && ( {licenseStatus?.maxSites && (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">

View file

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

View file

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

View file

@ -16,33 +16,7 @@ export function Breadcrumbs() {
const breadcrumbs: BreadcrumbItem[] = segments.map((segment, index) => { const breadcrumbs: BreadcrumbItem[] = segments.map((segment, index) => {
const href = `/${segments.slice(0, index + 1).join("/")}`; const href = `/${segments.slice(0, index + 1).join("/")}`;
let label = segment; let label = decodeURIComponent(segment);
// // Format labels
// if (segment === "settings") {
// label = "Settings";
// } else if (segment === "sites") {
// label = "Sites";
// } else if (segment === "resources") {
// label = "Resources";
// } else if (segment === "access") {
// label = "Access Control";
// } else if (segment === "general") {
// label = "General";
// } else if (segment === "share-links") {
// label = "Shareable Links";
// } else if (segment === "users") {
// label = "Users";
// } else if (segment === "roles") {
// label = "Roles";
// } else if (segment === "invitations") {
// label = "Invitations";
// } else if (segment === "proxy") {
// label = "proxy";
// } else if (segment === "authentication") {
// label = "Authentication";
// }
return { label, href }; return { label, href };
}); });

View file

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

View file

@ -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.length === 6) {
mfaForm.handleSubmit(onSubmit)();
}
}}
> >
<InputOTPGroup> <InputOTPGroup>
<InputOTPSlot <InputOTPSlot

View file

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

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

View file

@ -189,10 +189,12 @@ export default function SupporterStatus() {
<CredenzaBody> <CredenzaBody>
<p> <p>
Purchase a supporter key to help us continue Purchase a supporter key to help us continue
developing Pangolin. Your contribution allows us developing Pangolin for the community. Your
commit more time to maintain and add new features to contribution allows us to commit more time to
the application for everyone. We will never use this maintain and add new features to the application for
to paywall features. everyone. We will never use this to paywall
features. This is separate from the Professional
Edition.
</p> </p>
<p> <p>

View file

@ -16,7 +16,7 @@ const ToastViewport = React.forwardRef<
<ToastPrimitives.Viewport <ToastPrimitives.Viewport
ref={ref} ref={ref}
className={cn( className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]", "fixed top-0 right-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 md:max-w-[420px]",
className className
)} )}
{...props} {...props}