Merge branch 'dev' into auth-providers-clients

This commit is contained in:
Owen 2025-05-03 11:45:11 -04:00
commit a76dd9c9d1
No known key found for this signature in database
GPG key ID: 8271FDFFD9E0CCBD
38 changed files with 455 additions and 204 deletions

View file

@ -2,8 +2,9 @@ FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# COPY package.json package-lock.json ./
COPY package.json ./
RUN npm install
COPY . .
@ -18,8 +19,9 @@ WORKDIR /app
# Curl used for the health checks
RUN apk add --no-cache curl
COPY package.json package-lock.json ./
RUN npm ci --only=production && npm cache clean --force
# COPY package.json package-lock.json ./
COPY package.json ./
RUN npm install --only=production && npm cache clean --force
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static

View file

@ -34,9 +34,9 @@ _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.
<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
@ -61,6 +61,8 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
- **Temporary, self-destructing share links.**
- Resource specific pin codes.
- 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
@ -75,6 +77,9 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
- **Docker Compose based setup** for simplified deployment.
- 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 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
@ -111,12 +116,12 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
**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.
**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**:
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
@ -124,8 +129,8 @@ _Resources page of Pangolin dashboard (dark mode) showing HTTPS and TCP resource
**Cloudflare Tunnels**:
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**:
These projects inspired Pangolins centralized authentication system for proxies, enabling robust user and role management.
**Authelia**:
This inspired Pangolins centralized authentication system for proxies, enabling robust user and role management.
## Project Development / Roadmap

View file

@ -18,6 +18,7 @@ server:
internal_hostname: "pangolin"
session_cookie_name: "p_session_token"
resource_access_token_param: "p_token"
secret: "your_secret_key_here"
resource_access_token_headers:
id: "P-Access-Token-Id"
token: "P-Access-Token"

View file

@ -35,7 +35,7 @@ services:
- 80:80 # Port for traefik because of the network_mode
{{end}}
traefik:
image: traefik:v3.3.5
image: traefik:v3.3.6
container_name: traefik
restart: unless-stopped
{{if .InstallGerbil}}

View file

@ -64,14 +64,14 @@ func main() {
}
var config Config
config.DoCrowdsecInstall = false
config.Secret = generateRandomSecretKey()
// check if there is already a config file
if _, err := os.Stat("config/config.yml"); err != nil {
config = collectUserInput(reader)
loadVersions(&config)
config.DoCrowdsecInstall = false
config.Secret = generateRandomSecretKey()
if err := createConfigFiles(config); err != nil {
fmt.Printf("Error creating config files: %v\n", err)
@ -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.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.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
fmt.Println("\n=== Admin User Configuration ===")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 574 KiB

Before After
Before After

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

View file

@ -15,7 +15,6 @@ import {
} from "@server/middlewares";
import { authenticated, unauthenticated } from "@server/routers/integration";
import { logIncomingMiddleware } from "./middlewares/logIncoming";
import { csrfProtectionMiddleware } from "./middlewares/csrfProtection";
import helmet from "helmet";
import swaggerUi from "swagger-ui-express";
import { OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi";
@ -37,7 +36,6 @@ export function createIntegrationApiServer() {
if (!dev) {
apiServer.use(helmet());
apiServer.use(csrfProtectionMiddleware);
}
apiServer.use(cookieParser());

View file

@ -2,7 +2,7 @@ import path from "path";
import { fileURLToPath } from "url";
// This is a placeholder value replaced by the build process
export const APP_VERSION = "1.2.0";
export const APP_VERSION = "1.3.0";
export const __FILENAME = fileURLToPath(import.meta.url);
export const __DIRNAME = path.dirname(__FILENAME);

View file

@ -13,12 +13,19 @@ import moment from "moment";
import { setHostMeta } from "@server/setup/setHostMeta";
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 = {
isHostLicensed: boolean; // Are there any license keys?
isLicenseValid: boolean; // Is the license key valid?
hostId: string; // Host ID
maxSites?: number;
usedSites?: number;
tier?: KeyTier;
};
export type LicenseKeyCache = {
@ -26,7 +33,8 @@ export type LicenseKeyCache = {
licenseKeyEncrypted: string;
valid: boolean;
iat?: Date;
type?: "LICENSE" | "SITES";
type?: KeyType;
tier?: KeyTier;
numSites?: number;
};
@ -54,7 +62,8 @@ type ValidateLicenseAPIResponse = {
type TokenPayload = {
valid: boolean;
type: "LICENSE" | "SITES";
type: KeyType;
tier: KeyTier;
quantity: number;
terminateAt: string; // ISO
iat: number; // Issued at
@ -182,11 +191,12 @@ LQIDAQAB
licenseKeyEncrypted: key.licenseKeyId,
valid: payload.valid,
type: payload.type,
tier: payload.tier,
numSites: payload.quantity,
iat: new Date(payload.iat * 1000)
});
if (payload.type === "LICENSE") {
if (payload.type === "HOST") {
foundHostKey = true;
}
} catch (e) {
@ -273,6 +283,7 @@ LQIDAQAB
);
cached.valid = payload.valid;
cached.type = payload.type;
cached.tier = payload.tier;
cached.numSites = payload.quantity;
cached.iat = new Date(payload.iat * 1000);
@ -311,8 +322,9 @@ LQIDAQAB
logger.debug("Checking key", cached);
if (cached.type === "LICENSE") {
if (cached.type === "HOST") {
status.isLicenseValid = cached.valid;
status.tier = cached.tier;
}
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(
createHttpError(
HttpCode.FORBIDDEN,
@ -183,7 +194,9 @@ export async function listAccessTokens(
);
}
const accessibleResources = await db
let accessibleResources;
if (req.user) {
accessibleResources = await db
.select({
resourceId: sql<number>`COALESCE(${userResources.resourceId}, ${roleResources.resourceId})`
})
@ -198,6 +211,12 @@ export async function listAccessTokens(
eq(roleResources.roleId, req.userOrgRoleId!)
)
);
} else {
accessibleResources = await db
.select({ resourceId: resources.resourceId })
.from(resources)
.where(eq(resources.orgId, orgId));
}
const accessibleResourceIds = accessibleResources.map(
(resource) => resource.resourceId

View file

@ -23,7 +23,7 @@ import { oidcAutoProvision } from "./oidcAutoProvision";
import license from "@server/license/license";
const ensureTrailingSlash = (url: string): string => {
return url.endsWith('/') ? url : `${url}/`;
return url.endsWith("/") ? url : `${url}/`;
};
const paramsSchema = z
@ -228,6 +228,16 @@ export async function validateOidcCallback(
req,
res
});
return response<ValidateOidcUrlCallbackResponse>(res, {
data: {
redirectUrl: postAuthRedirectUrl
},
success: true,
error: false,
message: "OIDC callback validated successfully",
status: HttpCode.CREATED
});
} else {
if (!existingUser) {
return next(

View file

@ -49,7 +49,7 @@ export async function createNewt(
const { newtId, secret } = parsedBody.data;
if (!req.userOrgRoleId) {
if (req.user && !req.userOrgRoleId) {
return next(
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 { eq } from "drizzle-orm";
import {
apiKeyOrg,
apiKeys,
domains,
Org,
orgDomains,
orgs,
roleActions,
roles,
userOrgs
userOrgs,
users
} from "@server/db/schemas";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@ -57,7 +60,7 @@ export async function createOrg(
try {
// should this be in a middleware?
if (config.getRawConfig().flags?.disable_user_create_org) {
if (!req.user?.serverAdmin) {
if (req.user && !req.user?.serverAdmin) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
@ -171,12 +174,33 @@ export async function createOrg(
}))
);
if (req.user) {
await trx.insert(userOrgs).values({
userId: req.user!.userId,
orgId: newOrg[0].orgId,
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
.insert(roles)
@ -194,6 +218,18 @@ export async function createOrg(
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) {

View file

@ -29,16 +29,16 @@ const listOrgsSchema = z.object({
.pipe(z.number().int().nonnegative())
});
registry.registerPath({
method: "get",
path: "/user/{userId}/orgs",
description: "List all organizations for a user.",
tags: [OpenAPITags.Org, OpenAPITags.User],
request: {
query: listOrgsSchema
},
responses: {}
});
// registry.registerPath({
// method: "get",
// path: "/user/{userId}/orgs",
// description: "List all organizations for a user.",
// tags: [OpenAPITags.Org, OpenAPITags.User],
// request: {
// query: listOrgsSchema
// },
// responses: {}
// });
export type ListUserOrgsResponse = {
orgs: Org[];

View file

@ -39,6 +39,7 @@ const createHttpResourceSchema = z
isBaseDomain: z.boolean().optional(),
siteId: z.number(),
http: z.boolean(),
protocol: z.string(),
domainId: z.string()
})
.strict()
@ -129,7 +130,7 @@ export async function createResource(
const { siteId, orgId } = parsedParams.data;
if (!req.userOrgRoleId) {
if (req.user && !req.userOrgRoleId) {
return next(
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;
const [orgDomain] = await db
@ -261,7 +262,7 @@ async function createHttpResource(
name,
subdomain,
http,
protocol: "tcp",
protocol,
ssl: true,
isBaseDomain
})
@ -284,7 +285,7 @@ async function createHttpResource(
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
await trx.insert(userResources).values({
userId: req.user?.userId!,

View file

@ -69,9 +69,7 @@ function queryResources(
http: resources.http,
protocol: resources.protocol,
proxyPort: resources.proxyPort,
enabled: resources.enabled,
tlsServerName: resources.tlsServerName,
setHostHeader: resources.setHostHeader
enabled: resources.enabled
})
.from(resources)
.leftJoin(sites, eq(resources.siteId, sites.siteId))
@ -105,9 +103,7 @@ function queryResources(
http: resources.http,
protocol: resources.protocol,
proxyPort: resources.proxyPort,
enabled: resources.enabled,
tlsServerName: resources.tlsServerName,
setHostHeader: resources.setHostHeader
enabled: resources.enabled
})
.from(resources)
.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(
createHttpError(
HttpCode.FORBIDDEN,
@ -198,7 +202,9 @@ export async function listResources(
);
}
const accessibleResources = await db
let accessibleResources;
if (req.user) {
accessibleResources = await db
.select({
resourceId: sql<number>`COALESCE(${userResources.resourceId}, ${roleResources.resourceId})`
})
@ -213,6 +219,11 @@ export async function listResources(
eq(roleResources.roleId, req.userOrgRoleId!)
)
);
} else {
accessibleResources = await db.select({
resourceId: resources.resourceId
}).from(resources).where(eq(resources.orgId, orgId));
}
const accessibleResourceIds = accessibleResources.map(
(resource) => resource.resourceId

View file

@ -1,7 +1,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
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 HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@ -74,6 +74,17 @@ export async function setResourceRoles(
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
const adminRole = await db
.select()
@ -81,7 +92,7 @@ export async function setResourceRoles(
.where(
and(
eq(roles.name, "Admin"),
eq(roles.orgId, req.userOrg!.orgId)
eq(roles.orgId, orgId)
)
)
.limit(1);
@ -136,3 +147,4 @@ export async function setResourceRoles(
);
}
}

View file

@ -45,8 +45,8 @@ const updateHttpResourceBodySchema = z
domainId: z.string().optional(),
enabled: z.boolean().optional(),
stickySession: z.boolean().optional(),
tlsServerName: z.string().optional(),
setHostHeader: z.string().optional()
tlsServerName: z.string().nullable().optional(),
setHostHeader: z.string().nullable().optional()
})
.strict()
.refine((data) => Object.keys(data).length > 0, {
@ -81,7 +81,10 @@ const updateHttpResourceBodySchema = z
}
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(
(data) => {
@ -90,7 +93,10 @@ const updateHttpResourceBodySchema = z
}
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;
@ -300,7 +306,22 @@ async function updateHttpResource(
const updatedResource = await db
.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))
.returning();

View file

@ -103,7 +103,7 @@ export async function createSite(
const { orgId } = parsedParams.data;
if (!req.userOrgRoleId) {
if (req.user && !req.userOrgRoleId) {
return next(
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
);
@ -235,7 +235,7 @@ export async function createSite(
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
trx.insert(userSites).values({
userId: req.user?.userId!,

View file

@ -101,7 +101,7 @@ export async function listSites(
}
const { orgId } = parsedParams.data;
if (orgId && orgId !== req.userOrgId) {
if (req.user && orgId && orgId !== req.userOrgId) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
@ -110,7 +110,9 @@ export async function listSites(
);
}
const accessibleSites = await db
let accessibleSites;
if (req.user) {
accessibleSites = await db
.select({
siteId: sql<number>`COALESCE(${userSites.siteId}, ${roleSites.siteId})`
})
@ -122,6 +124,12 @@ export async function listSites(
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 baseQuery = querySites(orgId, accessibleSiteIds);

View file

@ -49,7 +49,7 @@ export async function addUserRole(
const { userId, roleId } = parsedParams.data;
if (!req.userOrg) {
if (req.user && !req.userOrg) {
return next(
createHttpError(
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
.select()

View file

@ -19,7 +19,15 @@ const paramsSchema = z
const bodySchema = z
.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(),
name: z.string().optional(),
type: z.enum(["internal", "oidc"]).optional(),

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(
ActionsEnum.getOrgUser,
req

View file

@ -8,8 +8,6 @@ import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
const version = "1.3.0";
const location = path.join(APP_PATH, "db", "db.sqlite");
await migration();
export default async function migration() {
console.log(`Running setup script ${version}...`);

View file

@ -657,7 +657,7 @@ export default function ReverseProxyTargets(props: {
loading={httpsTlsLoading}
form="tls-settings-form"
>
Save HTTPS & TLS Settings
Save Settings
</Button>
</SettingsSectionFooter>
</SettingsSection>
@ -896,7 +896,7 @@ export default function ReverseProxyTargets(props: {
<Input {...field} />
</FormControl>
<FormDescription>
The Host header to set when
The host header to set when
proxying requests. Leave
empty to use the default.
</FormDescription>

View file

@ -40,6 +40,21 @@ export function SitePriceCalculator({
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 =
mode === "license"
? licenseFlatRate + siteCount * pricePerSite
@ -141,7 +156,9 @@ export function SitePriceCalculator({
<CredenzaClose asChild>
<Button variant="outline">Cancel</Button>
</CredenzaClose>
<Button>Continue to Payment</Button>
<Button onClick={continueToPayment}>
Continue to Payment
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>

View file

@ -56,12 +56,16 @@ import { MinusCircle, PlusCircle } from "lucide-react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { SitePriceCalculator } from "./components/SitePriceCalculator";
import Link from "next/link";
import { Checkbox } from "@app/components/ui/checkbox";
const formSchema = z.object({
licenseKey: z
.string()
.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 {
@ -95,7 +99,8 @@ export default function LicensePage() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
licenseKey: ""
licenseKey: "",
agreeToTerms: false
}
});
@ -116,7 +121,7 @@ export default function LicensePage() {
);
const keys = response.data.data;
setRows(keys);
const hostKey = keys.find((key) => key.type === "LICENSE");
const hostKey = keys.find((key) => key.type === "HOST");
if (hostKey) {
setHostLicense(hostKey.licenseKey);
} else {
@ -265,6 +270,44 @@ export default function LicensePage() {
</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>
</CredenzaBody>
@ -305,8 +348,7 @@ export default function LicensePage() {
<p>
<b>
This will remove the license key and all
associated permissions. Any sites using this
license key will no longer be accessible.
associated permissions granted by it.
</b>
</p>
<p>
@ -343,7 +385,13 @@ export default function LicensePage() {
<div className="space-y-2 text-green-500">
<div className="text-2xl flex items-center gap-2">
<Check />
Licensed
{licenseStatus?.tier ===
"PROFESSIONAL"
? "Professional License"
: licenseStatus?.tier ===
"ENTERPRISE"
? "Enterprise License"
: "Licensed"}
</div>
</div>
) : (

View file

@ -173,7 +173,7 @@ export default function UsersTable({ users }: Props) {
<div className="space-y-4">
<p>
Are you sure you want to permanently delete{" "}
<b>
<b className="break-all">
{selected?.email ||
selected?.name ||
selected?.username}

View file

@ -5,21 +5,33 @@
"use client";
import { Button } from "@app/components/ui/button";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useState } from "react";
export default function LicenseViolation() {
const { licenseStatus } = useLicenseStatusContext();
const [isDismissed, setIsDismissed] = useState(false);
if (!licenseStatus) return null;
if (!licenseStatus || isDismissed) return null;
// Show invalid license banner
if (licenseStatus.isHostLicensed && !licenseStatus.isLicenseValid) {
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="flex justify-between items-center">
<p>
Invalid or expired license keys detected. Follow license
terms to continue using all features.
</p>
<Button
variant={"ghost"}
className="hover:bg-yellow-500"
onClick={() => setIsDismissed(true)}
>
Dismiss
</Button>
</div>
</div>
);
}
@ -32,12 +44,21 @@ export default function LicenseViolation() {
) {
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="flex justify-between items-center">
<p>
License Violation: This server is using{" "}
{licenseStatus.usedSites} sites which exceeds its licensed
limit of {licenseStatus.maxSites} sites. Follow license
terms to continue using all features.
{licenseStatus.usedSites} sites which exceeds its
licensed limit of {licenseStatus.maxSites} sites. Follow
license terms to continue using all features.
</p>
<Button
variant={"ghost"}
className="hover:bg-yellow-500"
onClick={() => setIsDismissed(true)}
>
Dismiss
</Button>
</div>
</div>
);
}

View file

@ -105,7 +105,7 @@ export default function InviteUserForm({
<CredenzaTitle>{title}</CredenzaTitle>
</CredenzaHeader>
<CredenzaBody>
<div className="mb-4">{dialog}</div>
<div className="mb-4 break-all overflow-hidden">{dialog}</div>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}

View file

@ -248,6 +248,12 @@ export default function LoginForm({ redirect, onLogin, idps }: LoginFormProps) {
pattern={
REGEXP_ONLY_DIGITS_AND_CHARS
}
onChange={(e) => {
field.onChange(e);
if (e.target.value.length === 6) {
mfaForm.handleSubmit(onSubmit)();
}
}}
>
<InputOTPGroup>
<InputOTPSlot

View file

@ -27,7 +27,6 @@ function getActionsCategories(root: boolean) {
"Get Organization User": "getOrgUser",
"List Organization Domains": "listOrgDomains",
"Check Org ID": "checkOrgId",
"List Orgs": "listOrgs"
},
Site: {
@ -91,14 +90,12 @@ function getActionsCategories(root: boolean) {
"List Resource Rules": "listResourceRules",
"Update Resource Rule": "updateResourceRule"
}
// "Newt": {
// "Create Newt": "createNewt"
// },
};
if (root) {
actionsByCategory["Organization"] = {
"List Organizations": "listOrgs",
"Check ID": "checkOrgId",
"Create Organization": "createOrg",
"Delete Organization": "deleteOrg",
"List API Keys": "listApiKeys",

View file

@ -22,6 +22,7 @@ import { useUserContext } from "@app/hooks/useUserContext";
import Disable2FaForm from "./Disable2FaForm";
import Enable2FaForm from "./Enable2FaForm";
import SupporterStatus from "./SupporterStatus";
import { UserType } from "@server/types/UserTypes";
export default function ProfileIcon() {
const { setTheme, theme } = useTheme();
@ -108,6 +109,8 @@ export default function ProfileIcon() {
)}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{user?.type === UserType.Internal && (
<>
{!user.twoFactorEnabled && (
<DropdownMenuItem
onClick={() => setOpenEnable2fa(true)}
@ -123,6 +126,8 @@ export default function ProfileIcon() {
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
</>
)}
<DropdownMenuLabel>Theme</DropdownMenuLabel>
{(["light", "dark", "system"] as const).map(
(themeOption) => (

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

@ -37,8 +37,31 @@ export function SidebarNav({
const niceId = params.niceId as string;
const resourceId = params.resourceId as string;
const userId = params.userId as string;
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
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 { user } = useUserContext();
@ -52,37 +75,6 @@ export function SidebarNav({
.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) {
setExpandedItems((prev) => {
const newSet = new Set(prev);