Compare commits

..

5 Commits

Author SHA1 Message Date
Milo Schwartz
280cbb6e22 Merge pull request #2553 from LaurenceJJones/explore/static-org-dropdown
enhance(sidebar): make mobile org selector sticky
2026-03-01 11:14:16 -08:00
miloschwartz
c20babcb53 fix org selector spacing on mobile 2026-03-01 11:13:49 -08:00
Owen Schwartz
768eebe2cd Merge pull request #2432 from ChanningHe/feat-integration-api-domain-crud
feat(integration): add domain CRUD endpoints to integration API
2026-03-01 11:12:05 -08:00
Laurence
81c1a1da9c enhance(sidebar): make mobile org selector sticky
Make org selector sticky on mobile sidebar

  Move OrgSelector outside the scrollable container so it stays fixed
  at the top while menu items scroll, matching the desktop sidebar
  behavior introduced in 9b2c0d0b.
2026-02-26 15:45:41 +00:00
ChanningHe
52f26396ac feat(integration): add domain CRUD endpoints to integration API 2026-02-26 08:44:55 +09:00
12 changed files with 901 additions and 792 deletions

View File

@@ -1102,6 +1102,12 @@
"actionGetUser": "Get User",
"actionGetOrgUser": "Get Organization User",
"actionListOrgDomains": "List Organization Domains",
"actionGetDomain": "Get Domain",
"actionCreateOrgDomain": "Create Domain",
"actionUpdateOrgDomain": "Update Domain",
"actionDeleteOrgDomain": "Delete Domain",
"actionGetDNSRecords": "Get DNS Records",
"actionRestartOrgDomain": "Restart Domain",
"actionCreateSite": "Create Site",
"actionDeleteSite": "Delete Site",
"actionGetSite": "Get Site",

1370
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -33,7 +33,7 @@
},
"dependencies": {
"@asteasolutions/zod-to-openapi": "8.4.1",
"@aws-sdk/client-s3": "3.1000.0",
"@aws-sdk/client-s3": "3.989.0",
"@faker-js/faker": "10.3.0",
"@headlessui/react": "2.2.9",
"@hookform/resolvers": "5.2.2",
@@ -85,11 +85,11 @@
"helmet": "8.1.0",
"http-errors": "2.0.1",
"input-otp": "1.4.2",
"ioredis": "5.10.0",
"ioredis": "5.9.3",
"jmespath": "0.16.0",
"js-yaml": "4.1.1",
"jsonwebtoken": "9.0.3",
"lucide-react": "0.575.0",
"lucide-react": "0.563.0",
"maxmind": "5.0.5",
"moment": "2.30.1",
"next": "15.5.12",
@@ -103,7 +103,7 @@
"posthog-node": "5.26.0",
"qrcode.react": "4.2.0",
"react": "19.2.4",
"react-day-picker": "9.14.0",
"react-day-picker": "9.13.2",
"react-dom": "19.2.4",
"react-easy-sort": "1.8.0",
"react-hook-form": "7.71.2",
@@ -113,7 +113,7 @@
"resend": "6.9.2",
"semver": "7.7.4",
"sshpk": "^1.18.0",
"stripe": "20.4.0",
"stripe": "20.3.1",
"swagger-ui-express": "5.0.1",
"tailwind-merge": "3.5.0",
"topojson-client": "3.1.0",

View File

@@ -477,10 +477,7 @@ export async function getTraefikConfig(
// TODO: HOW TO HANDLE ^^^^^^ BETTER
const anySitesOnline = targets.some(
(target) =>
target.site.online ||
target.site.type === "local" ||
target.site.type === "wireguard"
(target) => target.site.online
);
return (
@@ -493,7 +490,7 @@ export async function getTraefikConfig(
if (target.health == "unhealthy") {
return false;
}
// If any sites are online, exclude offline sites
if (anySitesOnline && !target.site.online) {
return false;
@@ -608,10 +605,7 @@ export async function getTraefikConfig(
servers: (() => {
// Check if any sites are online
const anySitesOnline = targets.some(
(target) =>
target.site.online ||
target.site.type === "local" ||
target.site.type === "wireguard"
(target) => target.site.online
);
return targets
@@ -619,7 +613,7 @@ export async function getTraefikConfig(
if (!target.enabled) {
return false;
}
// If any sites are online, exclude offline sites
if (anySitesOnline && !target.site.online) {
return false;

View File

@@ -14,3 +14,4 @@ export * from "./verifyApiKeyApiKeyAccess";
export * from "./verifyApiKeyClientAccess";
export * from "./verifyApiKeySiteResourceAccess";
export * from "./verifyApiKeyIdpAccess";
export * from "./verifyApiKeyDomainAccess";

View File

@@ -0,0 +1,90 @@
import { Request, Response, NextFunction } from "express";
import { db, domains, orgDomains, apiKeyOrg } from "@server/db";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
export async function verifyApiKeyDomainAccess(
req: Request,
res: Response,
next: NextFunction
) {
try {
const apiKey = req.apiKey;
const domainId =
req.params.domainId || req.body.domainId || req.query.domainId;
const orgId = req.params.orgId;
if (!apiKey) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
);
}
if (!domainId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid domain ID")
);
}
if (apiKey.isRoot) {
// Root keys can access any domain in any org
return next();
}
// Verify domain exists and belongs to the organization
const [domain] = await db
.select()
.from(domains)
.innerJoin(orgDomains, eq(orgDomains.domainId, domains.domainId))
.where(
and(
eq(orgDomains.domainId, domainId),
eq(orgDomains.orgId, orgId)
)
)
.limit(1);
if (!domain) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Domain with ID ${domainId} not found in organization ${orgId}`
)
);
}
// Verify the API key has access to this organization
if (!req.apiKeyOrg) {
const apiKeyOrgRes = await db
.select()
.from(apiKeyOrg)
.where(
and(
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
eq(apiKeyOrg.orgId, orgId)
)
)
.limit(1);
req.apiKeyOrg = apiKeyOrgRes[0];
}
if (!req.apiKeyOrg) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Key does not have access to this organization"
)
);
}
return next();
} catch (error) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error verifying domain access"
)
);
}
}

View File

@@ -665,10 +665,7 @@ export async function getTraefikConfig(
// TODO: HOW TO HANDLE ^^^^^^ BETTER
const anySitesOnline = targets.some(
(target) =>
target.site.online ||
target.site.type === "local" ||
target.site.type === "wireguard"
(target) => target.site.online
);
return (
@@ -796,10 +793,7 @@ export async function getTraefikConfig(
servers: (() => {
// Check if any sites are online
const anySitesOnline = targets.some(
(target) =>
target.site.online ||
target.site.type === "local" ||
target.site.type === "wireguard"
(target) => target.site.online
);
return targets

View File

@@ -27,7 +27,8 @@ import {
verifyApiKeyClientAccess,
verifyApiKeySiteResourceAccess,
verifyApiKeySetResourceClients,
verifyLimits
verifyLimits,
verifyApiKeyDomainAccess
} from "@server/middlewares";
import HttpCode from "@server/types/HttpCode";
import { Router } from "express";
@@ -347,6 +348,56 @@ authenticated.get(
domain.listDomains
);
authenticated.get(
"/org/:orgId/domain/:domainId",
verifyApiKeyOrgAccess,
verifyApiKeyDomainAccess,
verifyApiKeyHasAction(ActionsEnum.getDomain),
domain.getDomain
);
authenticated.put(
"/org/:orgId/domain",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.createOrgDomain),
logActionAudit(ActionsEnum.createOrgDomain),
domain.createOrgDomain
);
authenticated.patch(
"/org/:orgId/domain/:domainId",
verifyApiKeyOrgAccess,
verifyApiKeyDomainAccess,
verifyApiKeyHasAction(ActionsEnum.updateOrgDomain),
domain.updateOrgDomain
);
authenticated.delete(
"/org/:orgId/domain/:domainId",
verifyApiKeyOrgAccess,
verifyApiKeyDomainAccess,
verifyApiKeyHasAction(ActionsEnum.deleteOrgDomain),
logActionAudit(ActionsEnum.deleteOrgDomain),
domain.deleteAccountDomain
);
authenticated.get(
"/org/:orgId/domain/:domainId/dns-records",
verifyApiKeyOrgAccess,
verifyApiKeyDomainAccess,
verifyApiKeyHasAction(ActionsEnum.getDNSRecords),
domain.getDNSRecords
);
authenticated.post(
"/org/:orgId/domain/:domainId/restart",
verifyApiKeyOrgAccess,
verifyApiKeyDomainAccess,
verifyApiKeyHasAction(ActionsEnum.restartOrgDomain),
logActionAudit(ActionsEnum.restartOrgDomain),
domain.restartOrgDomain
);
authenticated.get(
"/org/:orgId/invitations",
verifyApiKeyOrgAccess,

View File

@@ -171,7 +171,8 @@ const DockerContainersTable: FC<{
...Object.values(container.networks)
.map((n) => n.ipAddress)
.filter(Boolean),
...getExposedPorts(container).map((p) => p.toString())
...getExposedPorts(container).map((p) => p.toString()),
...Object.entries(container.labels).flat()
];
return searchableFields.some((field) =>

View File

@@ -69,15 +69,16 @@ export function LayoutMobileMenu({
<SheetDescription className="sr-only">
{t("navbarDescription")}
</SheetDescription>
<div className="flex-1 overflow-y-auto relative">
<div className="px-1">
<div className="w-full border-b border-border">
<div className="px-1 shrink-0">
<OrgSelector
orgId={orgId}
orgs={orgs}
/>
</div>
<div className="w-full border-b border-border" />
<div className="px-3 pt-3">
</div>
<div className="flex-1 overflow-y-auto relative">
<div className="px-3">
{!isAdminPage &&
user.serverAdmin && (
<div className="mb-1">

View File

@@ -31,7 +31,6 @@ function getActionsCategories(root: boolean) {
[t("actionListInvitations")]: "listInvitations",
[t("actionRemoveUser")]: "removeUser",
[t("actionListUsers")]: "listUsers",
[t("actionListOrgDomains")]: "listOrgDomains",
[t("updateOrgUser")]: "updateOrgUser",
[t("createOrgUser")]: "createOrgUser",
[t("actionApplyBlueprint")]: "applyBlueprint",
@@ -39,6 +38,16 @@ function getActionsCategories(root: boolean) {
[t("actionGetBlueprint")]: "getBlueprint"
},
Domain: {
[t("actionListOrgDomains")]: "listOrgDomains",
[t("actionGetDomain")]: "getDomain",
[t("actionCreateOrgDomain")]: "createOrgDomain",
[t("actionUpdateOrgDomain")]: "updateOrgDomain",
[t("actionDeleteOrgDomain")]: "deleteOrgDomain",
[t("actionGetDNSRecords")]: "getDNSRecords",
[t("actionRestartOrgDomain")]: "restartOrgDomain"
},
Site: {
[t("actionCreateSite")]: "createSite",
[t("actionDeleteSite")]: "deleteSite",

View File

@@ -101,7 +101,6 @@ export function NewtSiteInstallCommands({
`helm install newt fossorial/newt \\
--create-namespace \\
--set newtInstances[0].name="main-tunnel" \\
--set newtInstances[0].enabled=true \\
--set-string newtInstances[0].auth.keys.endpointKey="${endpoint}" \\
--set-string newtInstances[0].auth.keys.idKey="${id}" \\
--set-string newtInstances[0].auth.keys.secretKey="${secret}"`
@@ -186,72 +185,59 @@ WantedBy=default.target`
className="mt-4"
/>
<div className="pt-4">
<p className="font-bold mb-3">{t("siteConfiguration")}</p>
<div className="flex items-center space-x-2 mb-2">
<CheckboxWithLabel
id="acceptClients"
aria-describedby="acceptClients-desc"
checked={acceptClients}
onCheckedChange={(checked) => {
const value = checked as boolean;
setAcceptClients(value);
}}
label={t("siteAcceptClientConnections")}
/>
</div>
<p
id="acceptClients-desc"
className="text-sm text-muted-foreground"
>
{t("siteAcceptClientConnectionsDescription")}
</p>
</div>
<div className="pt-4">
<p className="font-bold mb-3">{t("commands")}</p>
{platform === "kubernetes" && (
<p className="text-sm text-muted-foreground mb-3">
For more and up to date Kubernetes installation
information, see{" "}
<a
href="https://docs.pangolin.net/manage/sites/install-kubernetes"
target="_blank"
rel="noreferrer"
className="underline"
>
docs.pangolin.net/manage/sites/install-kubernetes
</a>
.
<div className="pt-4">
<p className="font-bold mb-3">
{t("siteConfiguration")}
</p>
<div className="flex items-center space-x-2 mb-2">
<CheckboxWithLabel
id="acceptClients"
aria-describedby="acceptClients-desc"
checked={acceptClients}
onCheckedChange={(checked) => {
const value = checked as boolean;
setAcceptClients(value);
}}
label={t("siteAcceptClientConnections")}
/>
</div>
<p
id="acceptClients-desc"
className="text-sm text-muted-foreground"
>
{t("siteAcceptClientConnectionsDescription")}
</p>
)}
<div className="mt-2 space-y-3">
{commands.map((item, index) => {
const commandText =
typeof item === "string" ? item : item.command;
const title =
typeof item === "string"
? undefined
: item.title;
const key = `${title ?? ""}::${commandText}`;
return (
<div key={key}>
{title && (
<p className="text-sm font-medium mb-1.5">
{title}
</p>
)}
<CopyTextBox
text={commandText}
outline={true}
/>
</div>
);
})}
</div>
</div>
<div className="pt-4">
<p className="font-bold mb-3">{t("commands")}</p>
<div className="mt-2 space-y-3">
{commands.map((item, index) => {
const commandText =
typeof item === "string"
? item
: item.command;
const title =
typeof item === "string"
? undefined
: item.title;
return (
<div key={index}>
{title && (
<p className="text-sm font-medium mb-1.5">
{title}
</p>
)}
<CopyTextBox
text={commandText}
outline={true}
/>
</div>
);
})}
</div>
</div>
</SettingsSectionBody>
</SettingsSection>
);