mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-14 09:41:44 +02:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ae0be3a4bc | |||
| a8f3f71021 | |||
| 933ca71c16 | |||
| 591cb9cdc1 | |||
| 34b18bdb53 | |||
| 7d475f5e91 | |||
| 39c35fa539 | |||
| e1bc0b7efd | |||
| 5ef068c8dc | |||
| 94c01a23a9 | |||
| 609fb357bb | |||
| cf9a17cc2e | |||
| 538b57941c | |||
| f4bee6406a | |||
| 0b2693a317 | |||
| 3be2d928f6 | |||
| 8d018fe47d |
@@ -41,7 +41,7 @@
|
|||||||
</strong>
|
</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
Pangolin is an open-source, identity-based remote access platform built on WireGuard® that enables secure connectivity to infrastructure anywhere. It combines reverse-proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to private resources with NAT traversal, all with granular access control.
|
Pangolin is an open-source, identity-based remote access platform built on WireGuard® that enables secure, seamless connectivity to private and public resources. Pangolin combines reverse proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to any private resources with NAT traversal, all with granular access controls.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -63,26 +63,11 @@ Pangolin is an open-source, identity-based remote access platform built on WireG
|
|||||||
|
|
||||||
Pangolin's site connectors provide gateways into networks so you can access any networked resources. Sites use outbound tunnels and intelligent NAT traversal to make networks behind restrictive firewalls available for authorized access without public IPs or open ports. Easily deploy a site as a binary or container on any platform.
|
Pangolin's site connectors provide gateways into networks so you can access any networked resources. Sites use outbound tunnels and intelligent NAT traversal to make networks behind restrictive firewalls available for authorized access without public IPs or open ports. Easily deploy a site as a binary or container on any platform.
|
||||||
|
|
||||||
* Lightweight user-space connector runs anywhere
|
|
||||||
* Punches through any firewall
|
|
||||||
* Doesn't require open ports or a public IP
|
|
||||||
* Strict network segmentation
|
|
||||||
* WireGuard-based
|
|
||||||
* Get alerts when a device or network resource goes down
|
|
||||||
|
|
||||||
<img src="public/screenshots/sites.png" alt="Sites" width="100%" />
|
<img src="public/screenshots/sites.png" alt="Sites" width="100%" />
|
||||||
|
|
||||||
### Browser-based reverse proxy access
|
### Browser-based reverse proxy access
|
||||||
|
|
||||||
Expose HTTPS web applications and connect to VNC, RDP, and SSH entirely in the browser through identity and context-aware tunneled reverse proxies. Users access resources with authentication and granular access control without installing a client. Pangolin handles routing, load balancing, health checking, and automatic SSL certificates without exposing your network directly to the internet.
|
Expose web applications through identity and context-aware tunneled reverse proxies. Users access applications through any web browser with authentication and granular access control without installing a client. Pangolin handles routing, load balancing, health checking, and automatic SSL certificates without exposing your network directly to the internet.
|
||||||
|
|
||||||
* Expose a web panel anywhere
|
|
||||||
* Access via any web browser
|
|
||||||
* Single sign-on across all resources
|
|
||||||
* HTTPS resources
|
|
||||||
* Remote desktop in the browser with VNC and RDP
|
|
||||||
* In-browser SSH terminal with privileged access management (PAM)
|
|
||||||
* PIN codes, passcodes, email OTP, geoblocking, allow-lists, and more
|
|
||||||
|
|
||||||
<img src="public/clip.gif" alt="Reverse proxy access" width="100%" />
|
<img src="public/clip.gif" alt="Reverse proxy access" width="100%" />
|
||||||
|
|
||||||
@@ -90,35 +75,14 @@ Expose HTTPS web applications and connect to VNC, RDP, and SSH entirely in the b
|
|||||||
|
|
||||||
Access private resources like SSH servers, databases, RDP, and entire network ranges through Pangolin clients. Intelligent NAT traversal enables connections even through restrictive firewalls, while DNS aliases provide friendly names and fast connections to resources across all your sites. Add redundancy by routing traffic through multiple connectors in your network.
|
Access private resources like SSH servers, databases, RDP, and entire network ranges through Pangolin clients. Intelligent NAT traversal enables connections even through restrictive firewalls, while DNS aliases provide friendly names and fast connections to resources across all your sites. Add redundancy by routing traffic through multiple connectors in your network.
|
||||||
|
|
||||||
* Peer-to-peer with intelligent NAT traversal
|
|
||||||
* Hosts/IPs and port ranges
|
|
||||||
* Network ranges/CIDRs
|
|
||||||
* Friendly DNS aliases for network addresses
|
|
||||||
* Privileged access management (PAM) with SSH resources
|
|
||||||
* Private HTTPS resources only accessible on the private network
|
|
||||||
|
|
||||||
<img src="public/screenshots/private-resources.png" alt="Private resources" width="100%" />
|
<img src="public/screenshots/private-resources.png" alt="Private resources" width="100%" />
|
||||||
|
|
||||||
### Give users and roles access to resources
|
### Give users and roles access to resources
|
||||||
|
|
||||||
Use Pangolin's built-in users or bring your own identity provider and set up role-based access control (RBAC). Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications, services, and routes you explicitly define.
|
Use Pangolin's built in users or bring your own identity provider and set up role based access control (RBAC). Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications, services, and routes you explicitly define.
|
||||||
|
|
||||||
* Bring your existing identity provider (IdP) or use Pangolin identities
|
|
||||||
* Sync users and roles from your IdP
|
|
||||||
* User- and role-based access control
|
|
||||||
* Full network audit and access logs
|
|
||||||
|
|
||||||
<img src="public/screenshots/users.png" alt="Users from identity provider with roles" width="100%" />
|
<img src="public/screenshots/users.png" alt="Users from identity provider with roles" width="100%" />
|
||||||
|
|
||||||
### Find and launch resources from a personalized home page
|
|
||||||
|
|
||||||
Give users a landing page to quickly find and open the resources they can access. Resources are grouped by site or label, searchable, and filterable, with grid or list views. Saved views capture filters, grouping, and layout as personal or organization-wide defaults.
|
|
||||||
|
|
||||||
* Single place for admins and non-admins to see accessible resources
|
|
||||||
* Create reusable views for common access patterns
|
|
||||||
|
|
||||||
<img src="public/screenshots/resource-launcher.png" alt="Resource Launcher" width="100%" />
|
|
||||||
|
|
||||||
## Download Clients
|
## Download Clients
|
||||||
|
|
||||||
Download the Pangolin client for your platform:
|
Download the Pangolin client for your platform:
|
||||||
|
|||||||
+9
-2
@@ -449,8 +449,14 @@
|
|||||||
"provisioningManage": "Provisioning",
|
"provisioningManage": "Provisioning",
|
||||||
"provisioningDescription": "Manage provisioning keys and review pending sites awaiting approval.",
|
"provisioningDescription": "Manage provisioning keys and review pending sites awaiting approval.",
|
||||||
"pendingSites": "Pending Sites",
|
"pendingSites": "Pending Sites",
|
||||||
"siteApproveSuccess": "Site approved successfully",
|
"siteApproveSuccess": "Site and associated resources approved successfully",
|
||||||
"siteApproveError": "Error approving site",
|
"siteApproveError": "Error approving site",
|
||||||
|
"siteReject": "Reject Site",
|
||||||
|
"siteQuestionReject": "Are you sure you want to reject this site?",
|
||||||
|
"siteMessageReject": "This will permanently delete the site and any associated resources that are still pending.",
|
||||||
|
"siteConfirmReject": "Confirm Reject Site",
|
||||||
|
"siteRejectSuccess": "Site rejected successfully",
|
||||||
|
"siteRejectError": "Error rejecting site",
|
||||||
"provisioningKeys": "Provisioning Keys",
|
"provisioningKeys": "Provisioning Keys",
|
||||||
"searchProvisioningKeys": "Search provisioning keys...",
|
"searchProvisioningKeys": "Search provisioning keys...",
|
||||||
"provisioningKeysAdd": "Generate Provisioning Key",
|
"provisioningKeysAdd": "Generate Provisioning Key",
|
||||||
@@ -1420,6 +1426,8 @@
|
|||||||
"setupTokenDescription": "Enter the setup token from the server console.",
|
"setupTokenDescription": "Enter the setup token from the server console.",
|
||||||
"setupTokenRequired": "Setup token is required",
|
"setupTokenRequired": "Setup token is required",
|
||||||
"actionUpdateSite": "Update Site",
|
"actionUpdateSite": "Update Site",
|
||||||
|
"actionApproveSite": "Approve Site",
|
||||||
|
"actionRejectSite": "Reject Site",
|
||||||
"actionResetSiteBandwidth": "Reset Organization Bandwidth",
|
"actionResetSiteBandwidth": "Reset Organization Bandwidth",
|
||||||
"actionListSiteRoles": "List Allowed Site Roles",
|
"actionListSiteRoles": "List Allowed Site Roles",
|
||||||
"actionCreateResource": "Create Resource",
|
"actionCreateResource": "Create Resource",
|
||||||
@@ -1517,7 +1525,6 @@
|
|||||||
"otpAuthDescription": "Enter the code from your authenticator app or one of your single-use backup codes.",
|
"otpAuthDescription": "Enter the code from your authenticator app or one of your single-use backup codes.",
|
||||||
"otpAuthSubmit": "Submit Code",
|
"otpAuthSubmit": "Submit Code",
|
||||||
"idpContinue": "Or continue with",
|
"idpContinue": "Or continue with",
|
||||||
"idpLastUsed": "Last Used",
|
|
||||||
"otpAuthBack": "Back to Password",
|
"otpAuthBack": "Back to Password",
|
||||||
"navbar": "Navigation Menu",
|
"navbar": "Navigation Menu",
|
||||||
"navbarDescription": "Main navigation menu for the application",
|
"navbarDescription": "Main navigation menu for the application",
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 556 KiB |
@@ -21,6 +21,7 @@ export enum ActionsEnum {
|
|||||||
getSite = "getSite",
|
getSite = "getSite",
|
||||||
listSites = "listSites",
|
listSites = "listSites",
|
||||||
updateSite = "updateSite",
|
updateSite = "updateSite",
|
||||||
|
updateSiteApprovals = "updateSiteApprovals",
|
||||||
restartSite = "restartSite",
|
restartSite = "restartSite",
|
||||||
resetSiteBandwidth = "resetSiteBandwidth",
|
resetSiteBandwidth = "resetSiteBandwidth",
|
||||||
reGenerateSecret = "reGenerateSecret",
|
reGenerateSecret = "reGenerateSecret",
|
||||||
|
|||||||
@@ -200,7 +200,10 @@ export const resources = pgTable(
|
|||||||
authDaemonMode: varchar("authDaemonMode", { length: 32 })
|
authDaemonMode: varchar("authDaemonMode", { length: 32 })
|
||||||
.$type<"site" | "remote" | "native">()
|
.$type<"site" | "remote" | "native">()
|
||||||
.default("site"),
|
.default("site"),
|
||||||
authDaemonPort: integer("authDaemonPort").default(22123)
|
authDaemonPort: integer("authDaemonPort").default(22123),
|
||||||
|
status: varchar("status")
|
||||||
|
.$type<"pending" | "approved">()
|
||||||
|
.default("approved")
|
||||||
},
|
},
|
||||||
(t) => [
|
(t) => [
|
||||||
index("idx_resources_fulldomain")
|
index("idx_resources_fulldomain")
|
||||||
@@ -451,7 +454,10 @@ export const siteResources = pgTable(
|
|||||||
onDelete: "set null"
|
onDelete: "set null"
|
||||||
}),
|
}),
|
||||||
subdomain: varchar("subdomain"),
|
subdomain: varchar("subdomain"),
|
||||||
fullDomain: varchar("fullDomain")
|
fullDomain: varchar("fullDomain"),
|
||||||
|
status: varchar("status")
|
||||||
|
.$type<"pending" | "approved">()
|
||||||
|
.default("approved")
|
||||||
},
|
},
|
||||||
(t) => [index("idx_siteresources_orgid_niceid").on(t.orgId, t.niceId)]
|
(t) => [index("idx_siteresources_orgid_niceid").on(t.orgId, t.niceId)]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -209,7 +209,8 @@ export const resources = sqliteTable("resources", {
|
|||||||
authDaemonMode: text("authDaemonMode")
|
authDaemonMode: text("authDaemonMode")
|
||||||
.$type<"site" | "remote" | "native">()
|
.$type<"site" | "remote" | "native">()
|
||||||
.default("site"),
|
.default("site"),
|
||||||
authDaemonPort: integer("authDaemonPort").default(22123)
|
authDaemonPort: integer("authDaemonPort").default(22123),
|
||||||
|
status: text("status").$type<"pending" | "approved">().default("approved")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const labels = sqliteTable("labels", {
|
export const labels = sqliteTable("labels", {
|
||||||
@@ -447,7 +448,8 @@ export const siteResources = sqliteTable("siteResources", {
|
|||||||
onDelete: "set null"
|
onDelete: "set null"
|
||||||
}),
|
}),
|
||||||
subdomain: text("subdomain"),
|
subdomain: text("subdomain"),
|
||||||
fullDomain: text("fullDomain")
|
fullDomain: text("fullDomain"),
|
||||||
|
status: text("status").$type<"pending" | "approved">().default("approved")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const networks = sqliteTable("networks", {
|
export const networks = sqliteTable("networks", {
|
||||||
|
|||||||
@@ -34,12 +34,6 @@ import {
|
|||||||
rebuildClientAssociationsFromSiteResource,
|
rebuildClientAssociationsFromSiteResource,
|
||||||
waitForSiteResourceRebuildIdle
|
waitForSiteResourceRebuildIdle
|
||||||
} from "../rebuildClientAssociations";
|
} from "../rebuildClientAssociations";
|
||||||
import { build } from "@server/build";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import next from "next";
|
|
||||||
import { LimitId } from "../billing";
|
|
||||||
import { usageService } from "../billing/usageService";
|
|
||||||
|
|
||||||
type ApplyBlueprintArgs = {
|
type ApplyBlueprintArgs = {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
|
|||||||
@@ -26,9 +26,6 @@ import { createCertificate } from "#dynamic/routers/certificates/createCertifica
|
|||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||||
import { tierMatrix } from "../billing/tierMatrix";
|
import { tierMatrix } from "../billing/tierMatrix";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import next from "next";
|
|
||||||
import { LimitId } from "../billing";
|
import { LimitId } from "../billing";
|
||||||
import { usageService } from "../billing/usageService";
|
import { usageService } from "../billing/usageService";
|
||||||
|
|
||||||
@@ -201,17 +198,19 @@ export async function updatePrivateResources(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let resourceStatusFromSite: "approved" | "pending" = "approved";
|
||||||
if (siteId && allSites.length === 0) {
|
if (siteId && allSites.length === 0) {
|
||||||
// only add if there are not provided sites
|
// only add if there are not provided sites
|
||||||
// Use the provided siteId directly, but verify it belongs to the org
|
// Use the provided siteId directly, but verify it belongs to the org
|
||||||
const [siteSingle] = await trx
|
const [siteSingle] = await trx
|
||||||
.select({ siteId: sites.siteId })
|
.select({ siteId: sites.siteId, status: sites.status })
|
||||||
.from(sites)
|
.from(sites)
|
||||||
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
|
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (siteSingle) {
|
if (siteSingle) {
|
||||||
allSites.push(siteSingle);
|
allSites.push(siteSingle);
|
||||||
}
|
}
|
||||||
|
resourceStatusFromSite = siteSingle.status ?? "approved";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allSites.length === 0) {
|
if (allSites.length === 0) {
|
||||||
@@ -220,6 +219,13 @@ export async function updatePrivateResources(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resourceEnabled =
|
||||||
|
resourceData.enabled == undefined || resourceData.enabled == null
|
||||||
|
? true
|
||||||
|
: resourceStatusFromSite === "pending"
|
||||||
|
? false
|
||||||
|
: resourceData.enabled;
|
||||||
|
|
||||||
if (existingResource) {
|
if (existingResource) {
|
||||||
let domainInfo:
|
let domainInfo:
|
||||||
| { subdomain: string | null; domainId: string }
|
| { subdomain: string | null; domainId: string }
|
||||||
@@ -243,8 +249,7 @@ export async function updatePrivateResources(
|
|||||||
scheme: resourceData.scheme,
|
scheme: resourceData.scheme,
|
||||||
destination: resourceData.destination,
|
destination: resourceData.destination,
|
||||||
destinationPort: resourceData["destination-port"],
|
destinationPort: resourceData["destination-port"],
|
||||||
enabled: true, // hardcoded for now
|
enabled: resourceEnabled,
|
||||||
// enabled: resourceData.enabled ?? true,
|
|
||||||
alias: resourceData.alias || null,
|
alias: resourceData.alias || null,
|
||||||
disableIcmp:
|
disableIcmp:
|
||||||
resourceData["disable-icmp"] ||
|
resourceData["disable-icmp"] ||
|
||||||
@@ -263,7 +268,8 @@ export async function updatePrivateResources(
|
|||||||
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
|
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
|
||||||
authDaemonMode:
|
authDaemonMode:
|
||||||
resourceData["auth-daemon"]?.mode || "native",
|
resourceData["auth-daemon"]?.mode || "native",
|
||||||
authDaemonPort: resourceData["auth-daemon"]?.port || 22123
|
authDaemonPort: resourceData["auth-daemon"]?.port || 22123,
|
||||||
|
status: resourceStatusFromSite
|
||||||
})
|
})
|
||||||
.where(
|
.where(
|
||||||
eq(
|
eq(
|
||||||
@@ -496,8 +502,7 @@ export async function updatePrivateResources(
|
|||||||
scheme: resourceData.scheme,
|
scheme: resourceData.scheme,
|
||||||
destination: resourceData.destination,
|
destination: resourceData.destination,
|
||||||
destinationPort: resourceData["destination-port"],
|
destinationPort: resourceData["destination-port"],
|
||||||
enabled: true, // hardcoded for now
|
enabled: resourceEnabled,
|
||||||
// enabled: resourceData.enabled ?? true,
|
|
||||||
alias: resourceData.alias || null,
|
alias: resourceData.alias || null,
|
||||||
aliasAddress: aliasAddress,
|
aliasAddress: aliasAddress,
|
||||||
disableIcmp:
|
disableIcmp:
|
||||||
@@ -517,7 +522,8 @@ export async function updatePrivateResources(
|
|||||||
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
|
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
|
||||||
authDaemonMode:
|
authDaemonMode:
|
||||||
resourceData["auth-daemon"]?.mode || "native",
|
resourceData["auth-daemon"]?.mode || "native",
|
||||||
authDaemonPort: resourceData["auth-daemon"]?.port || 22123
|
authDaemonPort: resourceData["auth-daemon"]?.port || 22123,
|
||||||
|
status: resourceStatusFromSite
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
rolePolicies,
|
rolePolicies,
|
||||||
roleResources,
|
roleResources,
|
||||||
roles,
|
roles,
|
||||||
|
Site,
|
||||||
sites,
|
sites,
|
||||||
Target,
|
Target,
|
||||||
TargetHealthCheck,
|
TargetHealthCheck,
|
||||||
@@ -74,19 +75,40 @@ export async function updatePublicResources(
|
|||||||
)) {
|
)) {
|
||||||
const targetsToUpdate: Target[] = [];
|
const targetsToUpdate: Target[] = [];
|
||||||
const healthchecksToUpdate: TargetHealthCheck[] = [];
|
const healthchecksToUpdate: TargetHealthCheck[] = [];
|
||||||
|
|
||||||
let resource: Resource;
|
let resource: Resource;
|
||||||
|
let resourceStatusFromSite: "approved" | "pending" = "approved";
|
||||||
|
let providedSite: Partial<Site> | undefined;
|
||||||
|
if (siteId) {
|
||||||
|
// Use the provided siteId directly, but verify it belongs to the org
|
||||||
|
[providedSite] = await trx
|
||||||
|
.select({
|
||||||
|
siteId: sites.siteId,
|
||||||
|
type: sites.type,
|
||||||
|
status: sites.status
|
||||||
|
})
|
||||||
|
.from(sites)
|
||||||
|
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
resourceStatusFromSite = providedSite?.status ?? "approved";
|
||||||
|
}
|
||||||
|
|
||||||
async function createTarget( // reusable function to create a target
|
async function createTarget( // reusable function to create a target
|
||||||
resourceId: number,
|
resourceId: number,
|
||||||
targetData: TargetData
|
targetData: TargetData
|
||||||
) {
|
) {
|
||||||
const targetSiteId = targetData.site;
|
const targetSiteId = targetData.site;
|
||||||
let site;
|
let site: Partial<Site> | undefined;
|
||||||
|
|
||||||
if (targetSiteId) {
|
if (targetSiteId) {
|
||||||
// Look up site by niceId
|
// Look up site by niceId
|
||||||
[site] = await trx
|
[site] = await trx
|
||||||
.select({ siteId: sites.siteId, type: sites.type })
|
.select({
|
||||||
|
siteId: sites.siteId,
|
||||||
|
type: sites.type,
|
||||||
|
status: sites.status
|
||||||
|
})
|
||||||
.from(sites)
|
.from(sites)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
@@ -95,15 +117,9 @@ export async function updatePublicResources(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
} else if (siteId) {
|
} else if (siteId && providedSite) {
|
||||||
// Use the provided siteId directly, but verify it belongs to the org
|
// Use the provided siteId directly, but verify it belongs to the org
|
||||||
[site] = await trx
|
site = providedSite;
|
||||||
.select({ siteId: sites.siteId, type: sites.type })
|
|
||||||
.from(sites)
|
|
||||||
.where(
|
|
||||||
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Target site is required`);
|
throw new Error(`Target site is required`);
|
||||||
}
|
}
|
||||||
@@ -139,7 +155,7 @@ export async function updatePublicResources(
|
|||||||
.insert(targets)
|
.insert(targets)
|
||||||
.values({
|
.values({
|
||||||
resourceId: resourceId,
|
resourceId: resourceId,
|
||||||
siteId: site.siteId,
|
siteId: site.siteId!,
|
||||||
ip: targetData.hostname,
|
ip: targetData.hostname,
|
||||||
mode: resourceData.mode as Target["mode"],
|
mode: resourceData.mode as Target["mode"],
|
||||||
method: targetData.method,
|
method: targetData.method,
|
||||||
@@ -172,7 +188,7 @@ export async function updatePublicResources(
|
|||||||
.insert(targetHealthCheck)
|
.insert(targetHealthCheck)
|
||||||
.values({
|
.values({
|
||||||
name: `${targetData.hostname}:${targetData.port}`,
|
name: `${targetData.hostname}:${targetData.port}`,
|
||||||
siteId: site.siteId,
|
siteId: site.siteId!,
|
||||||
targetId: newTarget.targetId,
|
targetId: newTarget.targetId,
|
||||||
orgId: orgId,
|
orgId: orgId,
|
||||||
hcEnabled: healthcheckData?.enabled || false,
|
hcEnabled: healthcheckData?.enabled || false,
|
||||||
@@ -230,7 +246,10 @@ export async function updatePublicResources(
|
|||||||
const resourceEnabled =
|
const resourceEnabled =
|
||||||
resourceData.enabled == undefined || resourceData.enabled == null
|
resourceData.enabled == undefined || resourceData.enabled == null
|
||||||
? true
|
? true
|
||||||
|
: resourceStatusFromSite === "pending"
|
||||||
|
? false
|
||||||
: resourceData.enabled;
|
: resourceData.enabled;
|
||||||
|
|
||||||
const resourceSsl =
|
const resourceSsl =
|
||||||
resourceData.ssl == undefined || resourceData.ssl == null
|
resourceData.ssl == undefined || resourceData.ssl == null
|
||||||
? true
|
? true
|
||||||
@@ -406,7 +425,8 @@ export async function updatePublicResources(
|
|||||||
? (resourceData["proxy-protocol-version"] ??
|
? (resourceData["proxy-protocol-version"] ??
|
||||||
1)
|
1)
|
||||||
: 1,
|
: 1,
|
||||||
resourcePolicyId: sharedPolicy.resourcePolicyId
|
resourcePolicyId: sharedPolicy.resourcePolicyId,
|
||||||
|
status: resourceStatusFromSite
|
||||||
})
|
})
|
||||||
.where(
|
.where(
|
||||||
eq(
|
eq(
|
||||||
@@ -590,7 +610,8 @@ export async function updatePublicResources(
|
|||||||
authDaemonPort:
|
authDaemonPort:
|
||||||
resourceData["auth-daemon"]?.port || 22123,
|
resourceData["auth-daemon"]?.port || 22123,
|
||||||
resourcePolicyId: null,
|
resourcePolicyId: null,
|
||||||
defaultResourcePolicyId: inlinePolicyId
|
defaultResourcePolicyId: inlinePolicyId,
|
||||||
|
status: resourceStatusFromSite
|
||||||
})
|
})
|
||||||
.where(
|
.where(
|
||||||
eq(
|
eq(
|
||||||
@@ -1131,6 +1152,7 @@ export async function updatePublicResources(
|
|||||||
.values({
|
.values({
|
||||||
orgId,
|
orgId,
|
||||||
niceId: resourceNiceId,
|
niceId: resourceNiceId,
|
||||||
|
status: resourceStatusFromSite,
|
||||||
name: resourceData.name || "Unnamed Resource",
|
name: resourceData.name || "Unnamed Resource",
|
||||||
mode: resourceData.mode,
|
mode: resourceData.mode,
|
||||||
proxyPort: ["http", "ssh", "rdp", "vnc"].includes(
|
proxyPort: ["http", "ssh", "rdp", "vnc"].includes(
|
||||||
|
|||||||
@@ -470,7 +470,7 @@ export const PrivateResourceSchema = z
|
|||||||
// proxyPort: z.int().positive().optional(),
|
// proxyPort: z.int().positive().optional(),
|
||||||
"destination-port": z.int().positive().optional(),
|
"destination-port": z.int().positive().optional(),
|
||||||
destination: z.string().min(1).optional(),
|
destination: z.string().min(1).optional(),
|
||||||
// enabled: z.boolean().default(true),
|
enabled: z.boolean().default(true),
|
||||||
"tcp-ports": portRangeStringSchema.optional().default("*"),
|
"tcp-ports": portRangeStringSchema.optional().default("*"),
|
||||||
"udp-ports": portRangeStringSchema.optional().default("*"),
|
"udp-ports": portRangeStringSchema.optional().default("*"),
|
||||||
"disable-icmp": z.boolean().optional().default(false),
|
"disable-icmp": z.boolean().optional().default(false),
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ import {
|
|||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { removeTargets } from "@server/routers/newt/targets";
|
import { removeTargets } from "@server/routers/newt/targets";
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
|
|
||||||
export type DeleteResourceResult = {
|
export type DeleteResourceResult = {
|
||||||
deletedResource: Resource;
|
deletedResource: Resource;
|
||||||
@@ -117,10 +115,10 @@ export async function runResourceDeleteSideEffects(
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!site) {
|
if (!site) {
|
||||||
throw createHttpError(
|
logger.debug(
|
||||||
HttpCode.NOT_FOUND,
|
`Site with ID ${target.siteId} not found during resource delete side effects; skipping target removal`
|
||||||
`Site with ID ${target.siteId} not found`
|
|
||||||
);
|
);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (site.pubKey && site.type === "newt") {
|
if (site.pubKey && site.type === "newt") {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { and, eq, sql } from "drizzle-orm";
|
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
db,
|
db,
|
||||||
|
resources,
|
||||||
siteNetworks,
|
siteNetworks,
|
||||||
siteResources,
|
siteResources,
|
||||||
targets,
|
targets,
|
||||||
@@ -97,6 +98,64 @@ export function exceedsSiteAssociatedResourceDeleteLimit(
|
|||||||
return resourceCount > MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE;
|
return resourceCount > MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getPendingResourceIdsForSite(
|
||||||
|
siteId: number,
|
||||||
|
trx: Transaction | typeof db = db
|
||||||
|
): Promise<number[]> {
|
||||||
|
const resourceIds = await getResourceIdsForSite(siteId, trx);
|
||||||
|
if (resourceIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await trx
|
||||||
|
.select({ resourceId: resources.resourceId })
|
||||||
|
.from(resources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(resources.resourceId, resourceIds),
|
||||||
|
eq(resources.status, "pending")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map((row) => row.resourceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPendingSiteResourceIdsForSite(
|
||||||
|
siteId: number,
|
||||||
|
orgId: string,
|
||||||
|
trx: Transaction | typeof db = db
|
||||||
|
): Promise<number[]> {
|
||||||
|
const siteResourceIds = await getSiteResourceIdsForSite(siteId, orgId, trx);
|
||||||
|
if (siteResourceIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await trx
|
||||||
|
.select({ siteResourceId: siteResources.siteResourceId })
|
||||||
|
.from(siteResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(siteResources.siteResourceId, siteResourceIds),
|
||||||
|
eq(siteResources.status, "pending")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map((row) => row.siteResourceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPendingAssociatedResourceCountForSite(
|
||||||
|
siteId: number,
|
||||||
|
orgId: string,
|
||||||
|
trx: Transaction | typeof db = db
|
||||||
|
): Promise<number> {
|
||||||
|
const [resourceIds, siteResourceIds] = await Promise.all([
|
||||||
|
getPendingResourceIdsForSite(siteId, trx),
|
||||||
|
getPendingSiteResourceIdsForSite(siteId, orgId, trx)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return resourceIds.length + siteResourceIds.length;
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteAssociatedResourcesForSite(
|
export async function deleteAssociatedResourcesForSite(
|
||||||
siteId: number,
|
siteId: number,
|
||||||
orgId: string,
|
orgId: string,
|
||||||
@@ -105,12 +164,32 @@ export async function deleteAssociatedResourcesForSite(
|
|||||||
const resourceIds = await getResourceIdsForSite(siteId, trx);
|
const resourceIds = await getResourceIdsForSite(siteId, trx);
|
||||||
const siteResourceIds = await getSiteResourceIdsForSite(siteId, orgId, trx);
|
const siteResourceIds = await getSiteResourceIdsForSite(siteId, orgId, trx);
|
||||||
|
|
||||||
const [resources, siteResourcesDeleted] = await Promise.all([
|
const [deletedResources, siteResourcesDeleted] = await Promise.all([
|
||||||
performDeleteResources(resourceIds, trx),
|
performDeleteResources(resourceIds, trx),
|
||||||
performDeleteSiteResources(siteResourceIds, trx)
|
performDeleteSiteResources(siteResourceIds, trx)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return { resources, siteResources: siteResourcesDeleted };
|
return { resources: deletedResources, siteResources: siteResourcesDeleted };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePendingAssociatedResourcesForSite(
|
||||||
|
siteId: number,
|
||||||
|
orgId: string,
|
||||||
|
trx: Transaction | typeof db = db
|
||||||
|
): Promise<DeleteSiteAssociatedResourcesSideEffects> {
|
||||||
|
const resourceIds = await getPendingResourceIdsForSite(siteId, trx);
|
||||||
|
const siteResourceIds = await getPendingSiteResourceIdsForSite(
|
||||||
|
siteId,
|
||||||
|
orgId,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
|
||||||
|
const [deletedResources, siteResourcesDeleted] = await Promise.all([
|
||||||
|
performDeleteResources(resourceIds, trx),
|
||||||
|
performDeleteSiteResources(siteResourceIds, trx)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { resources: deletedResources, siteResources: siteResourcesDeleted };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function runDeleteSiteAssociatedResourcesSideEffects(
|
export async function runDeleteSiteAssociatedResourcesSideEffects(
|
||||||
|
|||||||
@@ -496,6 +496,7 @@ export function generateRemoteSubnets(
|
|||||||
): string[] {
|
): string[] {
|
||||||
const remoteSubnets = allSiteResources
|
const remoteSubnets = allSiteResources
|
||||||
.filter((sr) => {
|
.filter((sr) => {
|
||||||
|
if (!sr.enabled) return false;
|
||||||
if (!sr.destination) return false;
|
if (!sr.destination) return false;
|
||||||
|
|
||||||
if (sr.mode === "cidr") {
|
if (sr.mode === "cidr") {
|
||||||
@@ -530,6 +531,7 @@ export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] {
|
|||||||
return allSiteResources
|
return allSiteResources
|
||||||
.filter(
|
.filter(
|
||||||
(sr) =>
|
(sr) =>
|
||||||
|
sr.enabled &&
|
||||||
sr.aliasAddress &&
|
sr.aliasAddress &&
|
||||||
((sr.alias && (sr.mode == "host" || sr.mode == "ssh")) ||
|
((sr.alias && (sr.mode == "host" || sr.mode == "ssh")) ||
|
||||||
(sr.fullDomain && sr.mode == "http"))
|
(sr.fullDomain && sr.mode == "http"))
|
||||||
@@ -662,6 +664,13 @@ export async function generateSubnetProxyTargetV2(
|
|||||||
subnet: string | null;
|
subnet: string | null;
|
||||||
}[]
|
}[]
|
||||||
): Promise<SubnetProxyTargetV2[] | undefined> {
|
): Promise<SubnetProxyTargetV2[] | undefined> {
|
||||||
|
if (!siteResource.enabled) {
|
||||||
|
logger.debug(
|
||||||
|
`Site resource ${siteResource.siteResourceId} is disabled, skipping target generation.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (clients.length === 0) {
|
if (clients.length === 0) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`No clients have access to site resource ${siteResource.siteResourceId}, skipping target generation.`
|
`No clients have access to site resource ${siteResource.siteResourceId}, skipping target generation.`
|
||||||
|
|||||||
@@ -1561,9 +1561,19 @@ export async function handleMessagingForUpdatedSiteResource(
|
|||||||
updatedSiteResource.udpPortRangeString ||
|
updatedSiteResource.udpPortRangeString ||
|
||||||
existingSiteResource.disableIcmp !==
|
existingSiteResource.disableIcmp !==
|
||||||
updatedSiteResource.disableIcmp);
|
updatedSiteResource.disableIcmp);
|
||||||
|
// Toggling enabled on/off doesn't change any of the fields above, but it
|
||||||
|
// does change whether targets/peer data should exist at all, so it needs
|
||||||
|
// to drive the same old->new diff machinery: going enabled->disabled
|
||||||
|
// diffs "real data" against "nothing" (a remove), and disabled->enabled
|
||||||
|
// diffs "nothing" against "real data" (an add). generateSubnetProxyTargetV2/
|
||||||
|
// generateRemoteSubnets/generateAliasConfig already return nothing for a
|
||||||
|
// disabled resource, so no other changes are needed here.
|
||||||
|
const enabledChanged =
|
||||||
|
existingSiteResource &&
|
||||||
|
existingSiteResource.enabled !== updatedSiteResource.enabled;
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`handleMessagingForUpdatedSiteResource: change flags destinationChanged=${Boolean(destinationChanged)} destinationPortChanged=${Boolean(destinationPortChanged)} aliasChanged=${Boolean(aliasChanged)} fullDomainChanged=${Boolean(fullDomainChanged)} sslChanged=${Boolean(sslChanged)} portRangesChanged=${Boolean(portRangesChanged)}`
|
`handleMessagingForUpdatedSiteResource: change flags destinationChanged=${Boolean(destinationChanged)} destinationPortChanged=${Boolean(destinationPortChanged)} aliasChanged=${Boolean(aliasChanged)} fullDomainChanged=${Boolean(fullDomainChanged)} sslChanged=${Boolean(sslChanged)} portRangesChanged=${Boolean(portRangesChanged)} enabledChanged=${Boolean(enabledChanged)}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// if the existingSiteResource is undefined (new resource) we don't need to do anything here, the rebuild above handled it all
|
// if the existingSiteResource is undefined (new resource) we don't need to do anything here, the rebuild above handled it all
|
||||||
@@ -1574,14 +1584,16 @@ export async function handleMessagingForUpdatedSiteResource(
|
|||||||
fullDomainChanged ||
|
fullDomainChanged ||
|
||||||
sslChanged ||
|
sslChanged ||
|
||||||
portRangesChanged ||
|
portRangesChanged ||
|
||||||
destinationPortChanged
|
destinationPortChanged ||
|
||||||
|
enabledChanged
|
||||||
) {
|
) {
|
||||||
const shouldUpdateTargets =
|
const shouldUpdateTargets =
|
||||||
destinationChanged ||
|
destinationChanged ||
|
||||||
sslChanged ||
|
sslChanged ||
|
||||||
portRangesChanged ||
|
portRangesChanged ||
|
||||||
fullDomainChanged ||
|
fullDomainChanged ||
|
||||||
destinationPortChanged;
|
destinationPortChanged ||
|
||||||
|
enabledChanged;
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`handleMessagingForUpdatedSiteResource: entering unchanged-site update path shouldUpdateTargets=${shouldUpdateTargets}`
|
`handleMessagingForUpdatedSiteResource: entering unchanged-site update path shouldUpdateTargets=${shouldUpdateTargets}`
|
||||||
@@ -1657,9 +1669,11 @@ export async function handleMessagingForUpdatedSiteResource(
|
|||||||
peerDataUpdateBatch.push({
|
peerDataUpdateBatch.push({
|
||||||
clientId: client.clientId,
|
clientId: client.clientId,
|
||||||
siteId,
|
siteId,
|
||||||
remoteSubnets: destinationChanged
|
remoteSubnets:
|
||||||
|
destinationChanged || enabledChanged
|
||||||
? {
|
? {
|
||||||
oldRemoteSubnets: !oldDestinationStillInUseBySite
|
oldRemoteSubnets:
|
||||||
|
!oldDestinationStillInUseBySite
|
||||||
? generateRemoteSubnets([
|
? generateRemoteSubnets([
|
||||||
existingSiteResource
|
existingSiteResource
|
||||||
])
|
])
|
||||||
@@ -1670,7 +1684,7 @@ export async function handleMessagingForUpdatedSiteResource(
|
|||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
aliases:
|
aliases:
|
||||||
aliasChanged || fullDomainChanged // the full domain is sent down as an alias
|
aliasChanged || fullDomainChanged || enabledChanged // the full domain is sent down as an alias
|
||||||
? {
|
? {
|
||||||
oldAliases: generateAliasConfig([
|
oldAliases: generateAliasConfig([
|
||||||
existingSiteResource
|
existingSiteResource
|
||||||
|
|||||||
@@ -248,6 +248,22 @@ authenticated.post(
|
|||||||
site.updateSite
|
site.updateSite
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/site/:siteId/approve",
|
||||||
|
verifySiteAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.updateSiteApprovals),
|
||||||
|
logActionAudit(ActionsEnum.updateSiteApprovals),
|
||||||
|
site.approveSite
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/site/:siteId/reject",
|
||||||
|
verifySiteAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.updateSiteApprovals),
|
||||||
|
logActionAudit(ActionsEnum.updateSiteApprovals),
|
||||||
|
site.rejectSite
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.delete(
|
authenticated.delete(
|
||||||
"/site/:siteId",
|
"/site/:siteId",
|
||||||
verifySiteAccess,
|
verifySiteAccess,
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ authenticated.post(
|
|||||||
logActionAudit(ActionsEnum.updateSite),
|
logActionAudit(ActionsEnum.updateSite),
|
||||||
site.updateSite
|
site.updateSite
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.post(
|
authenticated.post(
|
||||||
"/org/:orgId/reset-bandwidth",
|
"/org/:orgId/reset-bandwidth",
|
||||||
verifyApiKeyOrgAccess,
|
verifyApiKeyOrgAccess,
|
||||||
|
|||||||
@@ -157,7 +157,8 @@ async function resolveAccessibleIdsUncached(
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(userResources.userId, userId),
|
eq(userResources.userId, userId),
|
||||||
eq(resources.orgId, orgId)
|
eq(resources.orgId, orgId),
|
||||||
|
eq(resources.status, "approved")
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
userRoleIds.length > 0
|
userRoleIds.length > 0
|
||||||
@@ -171,7 +172,8 @@ async function resolveAccessibleIdsUncached(
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
inArray(roleResources.roleId, userRoleIds),
|
inArray(roleResources.roleId, userRoleIds),
|
||||||
eq(resources.orgId, orgId)
|
eq(resources.orgId, orgId),
|
||||||
|
eq(resources.status, "approved")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
: Promise.resolve([]),
|
: Promise.resolve([]),
|
||||||
@@ -183,7 +185,11 @@ async function resolveAccessibleIdsUncached(
|
|||||||
eq(effectiveResourcePolicyId, userPolicies.resourcePolicyId)
|
eq(effectiveResourcePolicyId, userPolicies.resourcePolicyId)
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
and(eq(userPolicies.userId, userId), eq(resources.orgId, orgId))
|
and(
|
||||||
|
eq(userPolicies.userId, userId),
|
||||||
|
eq(resources.orgId, orgId),
|
||||||
|
eq(resources.status, "approved")
|
||||||
|
)
|
||||||
),
|
),
|
||||||
userRoleIds.length > 0
|
userRoleIds.length > 0
|
||||||
? db
|
? db
|
||||||
@@ -199,21 +205,48 @@ async function resolveAccessibleIdsUncached(
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
inArray(rolePolicies.roleId, userRoleIds),
|
inArray(rolePolicies.roleId, userRoleIds),
|
||||||
eq(resources.orgId, orgId)
|
eq(resources.orgId, orgId),
|
||||||
|
eq(resources.status, "approved")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
: Promise.resolve([]),
|
: Promise.resolve([]),
|
||||||
db
|
db
|
||||||
.select({ siteResourceId: userSiteResources.siteResourceId })
|
.select({ siteResourceId: userSiteResources.siteResourceId })
|
||||||
.from(userSiteResources)
|
.from(userSiteResources)
|
||||||
.where(eq(userSiteResources.userId, userId)),
|
.innerJoin(
|
||||||
|
siteResources,
|
||||||
|
eq(
|
||||||
|
userSiteResources.siteResourceId,
|
||||||
|
siteResources.siteResourceId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userSiteResources.userId, userId),
|
||||||
|
eq(siteResources.orgId, orgId),
|
||||||
|
eq(siteResources.status, "approved")
|
||||||
|
)
|
||||||
|
),
|
||||||
userRoleIds.length > 0
|
userRoleIds.length > 0
|
||||||
? db
|
? db
|
||||||
.select({
|
.select({
|
||||||
siteResourceId: roleSiteResources.siteResourceId
|
siteResourceId: roleSiteResources.siteResourceId
|
||||||
})
|
})
|
||||||
.from(roleSiteResources)
|
.from(roleSiteResources)
|
||||||
.where(inArray(roleSiteResources.roleId, userRoleIds))
|
.innerJoin(
|
||||||
|
siteResources,
|
||||||
|
eq(
|
||||||
|
roleSiteResources.siteResourceId,
|
||||||
|
siteResources.siteResourceId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(roleSiteResources.roleId, userRoleIds),
|
||||||
|
eq(siteResources.orgId, orgId),
|
||||||
|
eq(siteResources.status, "approved")
|
||||||
|
)
|
||||||
|
)
|
||||||
: Promise.resolve([])
|
: Promise.resolve([])
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -365,6 +398,7 @@ async function filterPublicResourceIdsByTextSearch(
|
|||||||
inArray(resources.resourceId, resourceIds),
|
inArray(resources.resourceId, resourceIds),
|
||||||
eq(resources.orgId, orgId),
|
eq(resources.orgId, orgId),
|
||||||
eq(resources.enabled, true),
|
eq(resources.enabled, true),
|
||||||
|
eq(resources.status, "approved"),
|
||||||
textMatch
|
textMatch
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -402,6 +436,7 @@ async function filterSiteResourceIdsByTextSearch(
|
|||||||
inArray(siteResources.siteResourceId, siteResourceIds),
|
inArray(siteResources.siteResourceId, siteResourceIds),
|
||||||
eq(siteResources.orgId, orgId),
|
eq(siteResources.orgId, orgId),
|
||||||
eq(siteResources.enabled, true),
|
eq(siteResources.enabled, true),
|
||||||
|
eq(siteResources.status, "approved"),
|
||||||
textMatch
|
textMatch
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -503,7 +538,8 @@ async function listSiteGroups(
|
|||||||
const publicConditions = [
|
const publicConditions = [
|
||||||
inArray(resources.resourceId, accessible.resourceIds),
|
inArray(resources.resourceId, accessible.resourceIds),
|
||||||
eq(resources.orgId, orgId),
|
eq(resources.orgId, orgId),
|
||||||
eq(resources.enabled, true)
|
eq(resources.enabled, true),
|
||||||
|
eq(resources.status, "approved")
|
||||||
];
|
];
|
||||||
if (searchPublic) {
|
if (searchPublic) {
|
||||||
publicConditions.push(searchPublic);
|
publicConditions.push(searchPublic);
|
||||||
@@ -558,7 +594,8 @@ async function listSiteGroups(
|
|||||||
const siteConditions = [
|
const siteConditions = [
|
||||||
inArray(siteResources.siteResourceId, accessible.siteResourceIds),
|
inArray(siteResources.siteResourceId, accessible.siteResourceIds),
|
||||||
eq(siteResources.orgId, orgId),
|
eq(siteResources.orgId, orgId),
|
||||||
eq(siteResources.enabled, true)
|
eq(siteResources.enabled, true),
|
||||||
|
eq(siteResources.status, "approved")
|
||||||
];
|
];
|
||||||
if (searchSite) {
|
if (searchSite) {
|
||||||
siteConditions.push(searchSite);
|
siteConditions.push(searchSite);
|
||||||
@@ -621,7 +658,8 @@ async function listSiteGroups(
|
|||||||
const noSitePublicConditions = [
|
const noSitePublicConditions = [
|
||||||
inArray(resources.resourceId, accessible.resourceIds),
|
inArray(resources.resourceId, accessible.resourceIds),
|
||||||
eq(resources.orgId, orgId),
|
eq(resources.orgId, orgId),
|
||||||
eq(resources.enabled, true)
|
eq(resources.enabled, true),
|
||||||
|
eq(resources.status, "approved")
|
||||||
];
|
];
|
||||||
if (searchPublic) {
|
if (searchPublic) {
|
||||||
noSitePublicConditions.push(searchPublic);
|
noSitePublicConditions.push(searchPublic);
|
||||||
@@ -655,7 +693,8 @@ async function listSiteGroups(
|
|||||||
const noSiteSiteConditions = [
|
const noSiteSiteConditions = [
|
||||||
inArray(siteResources.siteResourceId, accessible.siteResourceIds),
|
inArray(siteResources.siteResourceId, accessible.siteResourceIds),
|
||||||
eq(siteResources.orgId, orgId),
|
eq(siteResources.orgId, orgId),
|
||||||
eq(siteResources.enabled, true)
|
eq(siteResources.enabled, true),
|
||||||
|
eq(siteResources.status, "approved")
|
||||||
];
|
];
|
||||||
if (searchSite) {
|
if (searchSite) {
|
||||||
noSiteSiteConditions.push(searchSite);
|
noSiteSiteConditions.push(searchSite);
|
||||||
@@ -746,7 +785,8 @@ async function listLabelGroups(
|
|||||||
const publicConditions = [
|
const publicConditions = [
|
||||||
inArray(resources.resourceId, accessible.resourceIds),
|
inArray(resources.resourceId, accessible.resourceIds),
|
||||||
eq(resources.orgId, orgId),
|
eq(resources.orgId, orgId),
|
||||||
eq(resources.enabled, true)
|
eq(resources.enabled, true),
|
||||||
|
eq(resources.status, "approved")
|
||||||
];
|
];
|
||||||
const searchPublic = buildSearchConditionForPublic(query.query);
|
const searchPublic = buildSearchConditionForPublic(query.query);
|
||||||
if (searchPublic) {
|
if (searchPublic) {
|
||||||
@@ -810,7 +850,8 @@ async function listLabelGroups(
|
|||||||
const siteConditions = [
|
const siteConditions = [
|
||||||
inArray(siteResources.siteResourceId, accessible.siteResourceIds),
|
inArray(siteResources.siteResourceId, accessible.siteResourceIds),
|
||||||
eq(siteResources.orgId, orgId),
|
eq(siteResources.orgId, orgId),
|
||||||
eq(siteResources.enabled, true)
|
eq(siteResources.enabled, true),
|
||||||
|
eq(siteResources.status, "approved")
|
||||||
];
|
];
|
||||||
const searchSite = buildSearchConditionForSiteResource(query.query);
|
const searchSite = buildSearchConditionForSiteResource(query.query);
|
||||||
if (searchSite) {
|
if (searchSite) {
|
||||||
@@ -997,6 +1038,7 @@ async function mapPublicResources(
|
|||||||
inArray(resources.resourceId, resourceIds),
|
inArray(resources.resourceId, resourceIds),
|
||||||
eq(resources.orgId, orgId),
|
eq(resources.orgId, orgId),
|
||||||
eq(resources.enabled, true),
|
eq(resources.enabled, true),
|
||||||
|
eq(resources.status, "approved"),
|
||||||
siteIdFilter != null
|
siteIdFilter != null
|
||||||
? eq(sites.siteId, siteIdFilter)
|
? eq(sites.siteId, siteIdFilter)
|
||||||
: undefined
|
: undefined
|
||||||
@@ -1088,6 +1130,7 @@ async function mapSiteResources(
|
|||||||
inArray(siteResources.siteResourceId, siteResourceIds),
|
inArray(siteResources.siteResourceId, siteResourceIds),
|
||||||
eq(siteResources.orgId, orgId),
|
eq(siteResources.orgId, orgId),
|
||||||
eq(siteResources.enabled, true),
|
eq(siteResources.enabled, true),
|
||||||
|
eq(siteResources.status, "approved"),
|
||||||
siteIdFilter != null
|
siteIdFilter != null
|
||||||
? eq(sites.siteId, siteIdFilter)
|
? eq(sites.siteId, siteIdFilter)
|
||||||
: undefined
|
: undefined
|
||||||
@@ -1382,7 +1425,8 @@ async function collectAccessibleSites(
|
|||||||
const publicConditions = [
|
const publicConditions = [
|
||||||
inArray(resources.resourceId, accessible.resourceIds),
|
inArray(resources.resourceId, accessible.resourceIds),
|
||||||
eq(resources.orgId, orgId),
|
eq(resources.orgId, orgId),
|
||||||
eq(resources.enabled, true)
|
eq(resources.enabled, true),
|
||||||
|
eq(resources.status, "approved")
|
||||||
];
|
];
|
||||||
if (siteNameSearch) {
|
if (siteNameSearch) {
|
||||||
publicConditions.push(siteNameSearch);
|
publicConditions.push(siteNameSearch);
|
||||||
@@ -1422,7 +1466,8 @@ async function collectAccessibleSites(
|
|||||||
const siteConditions = [
|
const siteConditions = [
|
||||||
inArray(siteResources.siteResourceId, accessible.siteResourceIds),
|
inArray(siteResources.siteResourceId, accessible.siteResourceIds),
|
||||||
eq(siteResources.orgId, orgId),
|
eq(siteResources.orgId, orgId),
|
||||||
eq(siteResources.enabled, true)
|
eq(siteResources.enabled, true),
|
||||||
|
eq(siteResources.status, "approved")
|
||||||
];
|
];
|
||||||
if (siteNameSearch) {
|
if (siteNameSearch) {
|
||||||
siteConditions.push(siteNameSearch);
|
siteConditions.push(siteNameSearch);
|
||||||
@@ -1476,6 +1521,7 @@ async function collectAccessibleLabels(
|
|||||||
inArray(resources.resourceId, accessible.resourceIds),
|
inArray(resources.resourceId, accessible.resourceIds),
|
||||||
eq(resources.orgId, orgId),
|
eq(resources.orgId, orgId),
|
||||||
eq(resources.enabled, true),
|
eq(resources.enabled, true),
|
||||||
|
eq(resources.status, "approved"),
|
||||||
eq(labels.orgId, orgId)
|
eq(labels.orgId, orgId)
|
||||||
];
|
];
|
||||||
if (labelNameSearch) {
|
if (labelNameSearch) {
|
||||||
@@ -1511,6 +1557,7 @@ async function collectAccessibleLabels(
|
|||||||
inArray(siteResources.siteResourceId, accessible.siteResourceIds),
|
inArray(siteResources.siteResourceId, accessible.siteResourceIds),
|
||||||
eq(siteResources.orgId, orgId),
|
eq(siteResources.orgId, orgId),
|
||||||
eq(siteResources.enabled, true),
|
eq(siteResources.enabled, true),
|
||||||
|
eq(siteResources.status, "approved"),
|
||||||
eq(labels.orgId, orgId)
|
eq(labels.orgId, orgId)
|
||||||
];
|
];
|
||||||
if (labelNameSearch) {
|
if (labelNameSearch) {
|
||||||
|
|||||||
@@ -148,7 +148,12 @@ export async function buildClientConfigurationForNewtClient(
|
|||||||
.from(siteResources)
|
.from(siteResources)
|
||||||
.innerJoin(networks, eq(siteResources.networkId, networks.networkId))
|
.innerJoin(networks, eq(siteResources.networkId, networks.networkId))
|
||||||
.innerJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId))
|
.innerJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId))
|
||||||
.where(eq(siteNetworks.siteId, siteId))
|
.where(
|
||||||
|
and(
|
||||||
|
eq(siteNetworks.siteId, siteId),
|
||||||
|
eq(siteResources.enabled, true)
|
||||||
|
)
|
||||||
|
)
|
||||||
.then((rows) => rows.map((r) => r.siteResources));
|
.then((rows) => rows.map((r) => r.siteResources));
|
||||||
|
|
||||||
const targetsToSend: SubnetProxyTargetV2[] = [];
|
const targetsToSend: SubnetProxyTargetV2[] = [];
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
generateRemoteSubnets
|
generateRemoteSubnets
|
||||||
} from "@server/lib/ip";
|
} from "@server/lib/ip";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { eq, inArray } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
import { addPeer, deletePeer } from "../newt/peers";
|
import { addPeer, deletePeer } from "../newt/peers";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
@@ -70,7 +70,13 @@ export async function buildSiteConfigurationForOlmClient(
|
|||||||
.innerJoin(networks, eq(siteResources.networkId, networks.networkId))
|
.innerJoin(networks, eq(siteResources.networkId, networks.networkId))
|
||||||
.innerJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId))
|
.innerJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId))
|
||||||
.where(
|
.where(
|
||||||
eq(clientSiteResourcesAssociationsCache.clientId, client.clientId)
|
and(
|
||||||
|
eq(
|
||||||
|
clientSiteResourcesAssociationsCache.clientId,
|
||||||
|
client.clientId
|
||||||
|
),
|
||||||
|
eq(siteResources.enabled, true)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const siteResourcesBySiteId = new Map<number, SiteResource[]>();
|
const siteResourcesBySiteId = new Map<number, SiteResource[]>();
|
||||||
|
|||||||
@@ -138,6 +138,15 @@ const listResourcesSchema = z.strictObject({
|
|||||||
description:
|
description:
|
||||||
"When set, only resources that have at least one target on this site are returned"
|
"When set, only resources that have at least one target on this site are returned"
|
||||||
}),
|
}),
|
||||||
|
status: z
|
||||||
|
.enum(["pending", "approved"])
|
||||||
|
.optional()
|
||||||
|
.catch(undefined)
|
||||||
|
.openapi({
|
||||||
|
type: "string",
|
||||||
|
enum: ["pending", "approved"],
|
||||||
|
description: "Filter by resource status"
|
||||||
|
}),
|
||||||
labels: z
|
labels: z
|
||||||
.preprocess((val) => {
|
.preprocess((val) => {
|
||||||
if (val === undefined || val === null || val === "") {
|
if (val === undefined || val === null || val === "") {
|
||||||
@@ -451,6 +460,7 @@ export async function listResources(
|
|||||||
sort_by,
|
sort_by,
|
||||||
order,
|
order,
|
||||||
siteId,
|
siteId,
|
||||||
|
status,
|
||||||
labels: labelFilter
|
labels: labelFilter
|
||||||
} = parsedQuery.data;
|
} = parsedQuery.data;
|
||||||
|
|
||||||
@@ -660,6 +670,10 @@ export async function listResources(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof status !== "undefined") {
|
||||||
|
conditions.push(eq(resources.status, status));
|
||||||
|
}
|
||||||
|
|
||||||
if (siteId != null) {
|
if (siteId != null) {
|
||||||
const resourcesWithSite = db
|
const resourcesWithSite = db
|
||||||
.select({ resourceId: targets.resourceId })
|
.select({ resourceId: targets.resourceId })
|
||||||
|
|||||||
@@ -45,18 +45,19 @@ function userResourceAliasesCacheKey(
|
|||||||
page: number,
|
page: number,
|
||||||
pageSize: number,
|
pageSize: number,
|
||||||
includeLabels: boolean,
|
includeLabels: boolean,
|
||||||
labelFilter: string[]
|
labelFilter: string[],
|
||||||
|
status?: "pending" | "approved"
|
||||||
) {
|
) {
|
||||||
const labelsKey =
|
const labelsKey =
|
||||||
labelFilter.length > 0 ? labelFilter.slice().sort().join(",") : "all";
|
labelFilter.length > 0 ? labelFilter.slice().sort().join(",") : "all";
|
||||||
return `userResourceAliases:${orgId}:${userId}:${page}:${pageSize}:${includeLabels ? "labels" : "plain"}:${labelsKey}`;
|
return `userResourceAliases:${orgId}:${userId}:${page}:${pageSize}:${includeLabels ? "labels" : "plain"}:${labelsKey}:${status ?? "all"}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const listUserResourceAliasesParamsSchema = z.strictObject({
|
const listUserResourceAliasesParamsSchema = z.strictObject({
|
||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
});
|
});
|
||||||
|
|
||||||
const listUserResourceAliasesQuerySchema = z.strictObject({
|
const listUserResourceAliasesQuerySchema = z.object({
|
||||||
pageSize: z.coerce
|
pageSize: z.coerce
|
||||||
.number<string>()
|
.number<string>()
|
||||||
.int()
|
.int()
|
||||||
@@ -96,6 +97,15 @@ const listUserResourceAliasesQuerySchema = z.strictObject({
|
|||||||
type: "array",
|
type: "array",
|
||||||
description:
|
description:
|
||||||
"Filter by resource labels. A resource matches when it has any of the given labels (OR)."
|
"Filter by resource labels. A resource matches when it has any of the given labels (OR)."
|
||||||
|
}),
|
||||||
|
status: z
|
||||||
|
.enum(["pending", "approved"])
|
||||||
|
.optional()
|
||||||
|
.catch(undefined)
|
||||||
|
.openapi({
|
||||||
|
type: "string",
|
||||||
|
enum: ["pending", "approved"],
|
||||||
|
description: "Filter by site resource status"
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -130,7 +140,8 @@ export async function listUserResourceAliases(
|
|||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
includeLabels,
|
includeLabels,
|
||||||
labels: labelFilter
|
labels: labelFilter,
|
||||||
|
status
|
||||||
} = parsedQuery.data;
|
} = parsedQuery.data;
|
||||||
|
|
||||||
const parsedParams = listUserResourceAliasesParamsSchema.safeParse(
|
const parsedParams = listUserResourceAliasesParamsSchema.safeParse(
|
||||||
@@ -172,7 +183,8 @@ export async function listUserResourceAliases(
|
|||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
includeLabels,
|
includeLabels,
|
||||||
labelFilter ?? []
|
labelFilter ?? [],
|
||||||
|
status
|
||||||
);
|
);
|
||||||
const cachedData: ListUserResourceAliasesResponse | undefined =
|
const cachedData: ListUserResourceAliasesResponse | undefined =
|
||||||
await cache.get(cacheKey);
|
await cache.get(cacheKey);
|
||||||
@@ -257,6 +269,10 @@ export async function listUserResourceAliases(
|
|||||||
inArray(siteResources.siteResourceId, accessibleSiteResourceIds)
|
inArray(siteResources.siteResourceId, accessibleSiteResourceIds)
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (typeof status !== "undefined") {
|
||||||
|
whereConditions.push(eq(siteResources.status, status));
|
||||||
|
}
|
||||||
|
|
||||||
if (labelFilter && labelFilter.length > 0) {
|
if (labelFilter && labelFilter.length > 0) {
|
||||||
whereConditions.push(
|
whereConditions.push(
|
||||||
inArray(
|
inArray(
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
db,
|
||||||
|
resources,
|
||||||
|
siteNetworks,
|
||||||
|
siteResources,
|
||||||
|
sites,
|
||||||
|
type Site,
|
||||||
|
type SiteResource
|
||||||
|
} from "@server/db";
|
||||||
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import {
|
||||||
|
getResourceIdsForSite,
|
||||||
|
getSiteResourceIdsForSite
|
||||||
|
} from "@server/lib/deleteSiteAssociatedResources";
|
||||||
|
import {
|
||||||
|
handleMessagingForUpdatedSiteResource,
|
||||||
|
rebuildClientAssociationsFromSiteResource,
|
||||||
|
waitForSiteResourceRebuildIdle
|
||||||
|
} from "@server/lib/rebuildClientAssociations";
|
||||||
|
|
||||||
|
const approveSiteParamsSchema = z.strictObject({
|
||||||
|
siteId: z.coerce.number().int().positive()
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "post",
|
||||||
|
path: "/site/{siteId}/approve",
|
||||||
|
description:
|
||||||
|
"Approve a pending site and approve (and enable when needed) its associated resources.",
|
||||||
|
tags: [OpenAPITags.Site],
|
||||||
|
request: {
|
||||||
|
params: approveSiteParamsSchema
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Successful response",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: z.object({
|
||||||
|
data: z.record(z.string(), z.any()).nullable(),
|
||||||
|
success: z.boolean(),
|
||||||
|
error: z.boolean(),
|
||||||
|
message: z.string(),
|
||||||
|
status: z.number()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
type SiteResourceEnableSideEffect = {
|
||||||
|
existing: SiteResource;
|
||||||
|
updated: SiteResource;
|
||||||
|
siteIds: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function approveSite(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = approveSiteParamsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { siteId } = parsedParams.data;
|
||||||
|
|
||||||
|
const [existingSite] = await db
|
||||||
|
.select()
|
||||||
|
.from(sites)
|
||||||
|
.where(eq(sites.siteId, siteId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existingSite) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Site with ID ${siteId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existingSite.orgId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
`Site with ID ${siteId} has no organization`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgId = existingSite.orgId;
|
||||||
|
let updatedSite: Site | undefined;
|
||||||
|
const siteResourceEnableSideEffects: SiteResourceEnableSideEffect[] =
|
||||||
|
[];
|
||||||
|
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
[updatedSite] = await trx
|
||||||
|
.update(sites)
|
||||||
|
.set({ status: "approved" })
|
||||||
|
.where(eq(sites.siteId, siteId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const resourceIds = await getResourceIdsForSite(siteId, trx);
|
||||||
|
const siteResourceIds = await getSiteResourceIdsForSite(
|
||||||
|
siteId,
|
||||||
|
orgId,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
|
||||||
|
if (resourceIds.length > 0) {
|
||||||
|
const pendingDisabledResources = await trx
|
||||||
|
.select({ resourceId: resources.resourceId })
|
||||||
|
.from(resources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(resources.resourceId, resourceIds),
|
||||||
|
eq(resources.status, "pending"),
|
||||||
|
eq(resources.enabled, false)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await trx
|
||||||
|
.update(resources)
|
||||||
|
.set({ status: "approved" })
|
||||||
|
.where(inArray(resources.resourceId, resourceIds));
|
||||||
|
|
||||||
|
if (pendingDisabledResources.length > 0) {
|
||||||
|
await trx
|
||||||
|
.update(resources)
|
||||||
|
.set({ enabled: true })
|
||||||
|
.where(
|
||||||
|
inArray(
|
||||||
|
resources.resourceId,
|
||||||
|
pendingDisabledResources.map(
|
||||||
|
(r) => r.resourceId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (siteResourceIds.length > 0) {
|
||||||
|
const existingSiteResources = await trx
|
||||||
|
.select()
|
||||||
|
.from(siteResources)
|
||||||
|
.where(
|
||||||
|
inArray(siteResources.siteResourceId, siteResourceIds)
|
||||||
|
);
|
||||||
|
|
||||||
|
const pendingDisabledSiteResources =
|
||||||
|
existingSiteResources.filter(
|
||||||
|
(sr) => sr.status === "pending" && !sr.enabled
|
||||||
|
);
|
||||||
|
|
||||||
|
await trx
|
||||||
|
.update(siteResources)
|
||||||
|
.set({ status: "approved" })
|
||||||
|
.where(
|
||||||
|
inArray(siteResources.siteResourceId, siteResourceIds)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pendingDisabledSiteResources.length > 0) {
|
||||||
|
const enableIds = pendingDisabledSiteResources.map(
|
||||||
|
(sr) => sr.siteResourceId
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedEnabledSiteResources = await trx
|
||||||
|
.update(siteResources)
|
||||||
|
.set({ enabled: true })
|
||||||
|
.where(inArray(siteResources.siteResourceId, enableIds))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
for (const updated of updatedEnabledSiteResources) {
|
||||||
|
const existing = pendingDisabledSiteResources.find(
|
||||||
|
(sr) => sr.siteResourceId === updated.siteResourceId
|
||||||
|
);
|
||||||
|
if (!existing || !updated.networkId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const networkSites = await trx
|
||||||
|
.select({ siteId: siteNetworks.siteId })
|
||||||
|
.from(siteNetworks)
|
||||||
|
.where(
|
||||||
|
eq(siteNetworks.networkId, updated.networkId)
|
||||||
|
);
|
||||||
|
|
||||||
|
siteResourceEnableSideEffects.push({
|
||||||
|
existing,
|
||||||
|
updated,
|
||||||
|
siteIds: networkSites.map((s) => s.siteId)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const sideEffect of siteResourceEnableSideEffects) {
|
||||||
|
rebuildClientAssociationsFromSiteResource(sideEffect.updated)
|
||||||
|
.then(() =>
|
||||||
|
waitForSiteResourceRebuildIdle(
|
||||||
|
sideEffect.updated.siteResourceId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.then(() =>
|
||||||
|
handleMessagingForUpdatedSiteResource(
|
||||||
|
sideEffect.existing,
|
||||||
|
sideEffect.updated,
|
||||||
|
sideEffect.siteIds,
|
||||||
|
sideEffect.siteIds
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.catch((e) => {
|
||||||
|
logger.error(
|
||||||
|
`Failed to rebuild and handle messaging for site resource ${sideEffect.updated.siteResourceId} after site approval:`,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: updatedSite ?? null,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Site approved successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -159,15 +159,21 @@ export async function deleteSite(
|
|||||||
siteResources: []
|
siteResources: []
|
||||||
};
|
};
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
|
||||||
if (deleteResources) {
|
if (deleteResources) {
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
resourceSideEffects = await deleteAssociatedResourcesForSite(
|
resourceSideEffects = await deleteAssociatedResourcesForSite(
|
||||||
siteId,
|
siteId,
|
||||||
site.orgId,
|
site.orgId,
|
||||||
trx
|
trx
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await runDeleteSiteAssociatedResourcesSideEffects(
|
||||||
|
resourceSideEffects
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
if (site.type == "wireguard") {
|
if (site.type == "wireguard") {
|
||||||
if (site.pubKey) {
|
if (site.pubKey) {
|
||||||
await deletePeer(site.exitNodeId!, site.pubKey);
|
await deletePeer(site.exitNodeId!, site.pubKey);
|
||||||
@@ -180,12 +186,6 @@ export async function deleteSite(
|
|||||||
await usageService.add(site.orgId, LimitId.SITES, -1, trx);
|
await usageService.add(site.orgId, LimitId.SITES, -1, trx);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (deleteResources) {
|
|
||||||
await runDeleteSiteAssociatedResourcesSideEffects(
|
|
||||||
resourceSideEffects
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deletedNewt) {
|
if (deletedNewt) {
|
||||||
const payload = {
|
const payload = {
|
||||||
type: `newt/wg/terminate`,
|
type: `newt/wg/terminate`,
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ export * from "./getStatusHistory";
|
|||||||
export * from "./createSite";
|
export * from "./createSite";
|
||||||
export * from "./deleteSite";
|
export * from "./deleteSite";
|
||||||
export * from "./updateSite";
|
export * from "./updateSite";
|
||||||
|
export * from "./approveSite";
|
||||||
|
export * from "./rejectSite";
|
||||||
export * from "./listSites";
|
export * from "./listSites";
|
||||||
export * from "./listSiteRoles";
|
export * from "./listSiteRoles";
|
||||||
export * from "./pickSiteDefaults";
|
export * from "./pickSiteDefaults";
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { newts, sites } from "@server/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { deletePeer } from "../gerbil/peers";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { sendToClient } from "#dynamic/routers/ws";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { cleanupSiteAssociations } from "@server/lib/rebuildClientAssociations";
|
||||||
|
import { usageService } from "@server/lib/billing/usageService";
|
||||||
|
import { LimitId } from "@server/lib/billing";
|
||||||
|
import {
|
||||||
|
deletePendingAssociatedResourcesForSite,
|
||||||
|
exceedsSiteAssociatedResourceDeleteLimit,
|
||||||
|
getPendingAssociatedResourceCountForSite,
|
||||||
|
runDeleteSiteAssociatedResourcesSideEffects,
|
||||||
|
MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE,
|
||||||
|
type DeleteSiteAssociatedResourcesSideEffects
|
||||||
|
} from "@server/lib/deleteSiteAssociatedResources";
|
||||||
|
|
||||||
|
const rejectSiteParamsSchema = z.strictObject({
|
||||||
|
siteId: z.coerce.number().int().positive()
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "post",
|
||||||
|
path: "/site/{siteId}/reject",
|
||||||
|
description:
|
||||||
|
"Reject a pending site by deleting it and any associated resources that are still pending.",
|
||||||
|
tags: [OpenAPITags.Site],
|
||||||
|
request: {
|
||||||
|
params: rejectSiteParamsSchema
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Successful response",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: z.object({
|
||||||
|
data: z.record(z.string(), z.any()).nullable(),
|
||||||
|
success: z.boolean(),
|
||||||
|
error: z.boolean(),
|
||||||
|
message: z.string(),
|
||||||
|
status: z.number()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function rejectSite(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = rejectSiteParamsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { siteId } = parsedParams.data;
|
||||||
|
|
||||||
|
const [site] = await db
|
||||||
|
.select()
|
||||||
|
.from(sites)
|
||||||
|
.where(eq(sites.siteId, siteId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!site) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Site with ID ${siteId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!site.orgId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
`Site with ID ${siteId} has no organization`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingAssociatedResourceCount =
|
||||||
|
await getPendingAssociatedResourceCountForSite(siteId, site.orgId);
|
||||||
|
|
||||||
|
if (
|
||||||
|
exceedsSiteAssociatedResourceDeleteLimit(
|
||||||
|
pendingAssociatedResourceCount
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
`Cannot reject site and associated pending resources when the site has more than ${MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE} pending resources`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [deletedNewt] = await db
|
||||||
|
.select()
|
||||||
|
.from(newts)
|
||||||
|
.where(eq(newts.siteId, siteId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
let resourceSideEffects: DeleteSiteAssociatedResourcesSideEffects = {
|
||||||
|
resources: [],
|
||||||
|
siteResources: []
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
resourceSideEffects = await deletePendingAssociatedResourcesForSite(
|
||||||
|
siteId,
|
||||||
|
site.orgId,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await runDeleteSiteAssociatedResourcesSideEffects(resourceSideEffects);
|
||||||
|
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
if (site.type == "wireguard") {
|
||||||
|
if (site.pubKey) {
|
||||||
|
await deletePeer(site.exitNodeId!, site.pubKey);
|
||||||
|
}
|
||||||
|
} else if (site.type == "newt") {
|
||||||
|
await cleanupSiteAssociations(site, trx);
|
||||||
|
}
|
||||||
|
|
||||||
|
await trx.delete(sites).where(eq(sites.siteId, siteId));
|
||||||
|
await usageService.add(site.orgId, LimitId.SITES, -1, trx);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (deletedNewt) {
|
||||||
|
const payload = {
|
||||||
|
type: `newt/wg/terminate`,
|
||||||
|
data: {}
|
||||||
|
};
|
||||||
|
sendToClient(deletedNewt.newtId, payload).catch((error) => {
|
||||||
|
logger.error(
|
||||||
|
"Failed to send termination message to newt:",
|
||||||
|
error
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: null,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Site rejected successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
if (createHttpError.isHttpError(error)) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,6 @@ const updateSiteBodySchema = z
|
|||||||
name: z.string().min(1).max(255).optional(),
|
name: z.string().min(1).max(255).optional(),
|
||||||
niceId: z.string().min(1).max(255).optional(),
|
niceId: z.string().min(1).max(255).optional(),
|
||||||
dockerSocketEnabled: z.boolean().optional(),
|
dockerSocketEnabled: z.boolean().optional(),
|
||||||
status: z.enum(["pending", "approved"]).optional(),
|
|
||||||
autoUpdateEnabled: z.boolean().optional(),
|
autoUpdateEnabled: z.boolean().optional(),
|
||||||
autoUpdateOverrideOrg: z.boolean().optional()
|
autoUpdateOverrideOrg: z.boolean().optional()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ const createSiteResourceSchema = z
|
|||||||
siteId: z.number().int().positive().optional(), // DEPRECATED: for backward compatibility, we will convert this to siteIds array if provided
|
siteId: z.number().int().positive().optional(), // DEPRECATED: for backward compatibility, we will convert this to siteIds array if provided
|
||||||
destinationPort: z.int().positive().optional(),
|
destinationPort: z.int().positive().optional(),
|
||||||
destination: z.string().min(1).nullish(),
|
destination: z.string().min(1).nullish(),
|
||||||
enabled: z.boolean().default(true),
|
|
||||||
alias: z
|
alias: z
|
||||||
.string()
|
.string()
|
||||||
.regex(
|
.regex(
|
||||||
@@ -275,7 +274,6 @@ export async function createSiteResource(
|
|||||||
scheme,
|
scheme,
|
||||||
destinationPort,
|
destinationPort,
|
||||||
destination,
|
destination,
|
||||||
enabled,
|
|
||||||
ssl,
|
ssl,
|
||||||
alias,
|
alias,
|
||||||
userIds,
|
userIds,
|
||||||
@@ -539,7 +537,6 @@ export async function createSiteResource(
|
|||||||
destination: destination, // the ssh can be null
|
destination: destination, // the ssh can be null
|
||||||
scheme,
|
scheme,
|
||||||
destinationPort,
|
destinationPort,
|
||||||
enabled,
|
|
||||||
alias: alias ? alias.trim() : null,
|
alias: alias ? alias.trim() : null,
|
||||||
aliasAddress,
|
aliasAddress,
|
||||||
tcpPortRangeString: tcpPortRangeStringAdjusted,
|
tcpPortRangeString: tcpPortRangeStringAdjusted,
|
||||||
|
|||||||
@@ -86,6 +86,15 @@ const listAllSiteResourcesByOrgQuerySchema = z.strictObject({
|
|||||||
description:
|
description:
|
||||||
"When set, only site resources associated with this site (via network) are returned"
|
"When set, only site resources associated with this site (via network) are returned"
|
||||||
}),
|
}),
|
||||||
|
status: z
|
||||||
|
.enum(["pending", "approved"])
|
||||||
|
.optional()
|
||||||
|
.catch(undefined)
|
||||||
|
.openapi({
|
||||||
|
type: "string",
|
||||||
|
enum: ["pending", "approved"],
|
||||||
|
description: "Filter by site resource status"
|
||||||
|
}),
|
||||||
labels: z
|
labels: z
|
||||||
.preprocess((val) => {
|
.preprocess((val) => {
|
||||||
if (val === undefined || val === null || val === "") {
|
if (val === undefined || val === null || val === "") {
|
||||||
@@ -283,6 +292,7 @@ export async function listAllSiteResourcesByOrg(
|
|||||||
sort_by,
|
sort_by,
|
||||||
order,
|
order,
|
||||||
siteId,
|
siteId,
|
||||||
|
status,
|
||||||
labels: labelFilter
|
labels: labelFilter
|
||||||
} = parsedQuery.data;
|
} = parsedQuery.data;
|
||||||
|
|
||||||
@@ -315,6 +325,10 @@ export async function listAllSiteResourcesByOrg(
|
|||||||
conditions.push(eq(siteResources.mode, mode));
|
conditions.push(eq(siteResources.mode, mode));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof status !== "undefined") {
|
||||||
|
conditions.push(eq(siteResources.status, status));
|
||||||
|
}
|
||||||
|
|
||||||
if (labelFilter && labelFilter.length > 0) {
|
if (labelFilter && labelFilter.length > 0) {
|
||||||
conditions.push(
|
conditions.push(
|
||||||
inArray(
|
inArray(
|
||||||
|
|||||||
@@ -47,6 +47,15 @@ const listSiteResourcesQuerySchema = z.strictObject({
|
|||||||
enum: ["asc", "desc"],
|
enum: ["asc", "desc"],
|
||||||
default: "asc",
|
default: "asc",
|
||||||
description: "Sort order"
|
description: "Sort order"
|
||||||
|
}),
|
||||||
|
status: z
|
||||||
|
.enum(["pending", "approved"])
|
||||||
|
.optional()
|
||||||
|
.catch(undefined)
|
||||||
|
.openapi({
|
||||||
|
type: "string",
|
||||||
|
enum: ["pending", "approved"],
|
||||||
|
description: "Filter by site resource status"
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -110,7 +119,7 @@ export async function listSiteResources(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { siteId, orgId } = parsedParams.data;
|
const { siteId, orgId } = parsedParams.data;
|
||||||
const { limit, offset, sort_by, order } = parsedQuery.data;
|
const { limit, offset, sort_by, order, status } = parsedQuery.data;
|
||||||
|
|
||||||
// Verify the site exists and belongs to the org
|
// Verify the site exists and belongs to the org
|
||||||
const site = await db
|
const site = await db
|
||||||
@@ -124,6 +133,15 @@ export async function listSiteResources(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get site resources by joining networks to siteResources via siteNetworks
|
// Get site resources by joining networks to siteResources via siteNetworks
|
||||||
|
const conditions = [
|
||||||
|
eq(siteNetworks.siteId, siteId),
|
||||||
|
eq(siteResources.orgId, orgId)
|
||||||
|
];
|
||||||
|
|
||||||
|
if (typeof status !== "undefined") {
|
||||||
|
conditions.push(eq(siteResources.status, status));
|
||||||
|
}
|
||||||
|
|
||||||
const siteResourcesList = await db
|
const siteResourcesList = await db
|
||||||
.select()
|
.select()
|
||||||
.from(siteNetworks)
|
.from(siteNetworks)
|
||||||
@@ -132,12 +150,7 @@ export async function listSiteResources(
|
|||||||
siteResources,
|
siteResources,
|
||||||
eq(siteResources.networkId, networks.networkId)
|
eq(siteResources.networkId, networks.networkId)
|
||||||
)
|
)
|
||||||
.where(
|
.where(and(...conditions))
|
||||||
and(
|
|
||||||
eq(siteNetworks.siteId, siteId),
|
|
||||||
eq(siteResources.orgId, orgId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.orderBy(
|
.orderBy(
|
||||||
sort_by
|
sort_by
|
||||||
? order === "asc"
|
? order === "asc"
|
||||||
|
|||||||
@@ -152,6 +152,11 @@ const updateSiteResourceSchema = z
|
|||||||
)
|
)
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
|
// this is a partial update; only enforce destination when the
|
||||||
|
// caller is actually changing mode or destination
|
||||||
|
if (data.mode === undefined && data.destination === undefined) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
// destination is only optional for ssh mode with native authDaemonMode
|
// destination is only optional for ssh mode with native authDaemonMode
|
||||||
if (data.mode === "ssh" && data.authDaemonMode === "native") {
|
if (data.mode === "ssh" && data.authDaemonMode === "native") {
|
||||||
return true;
|
return true;
|
||||||
@@ -411,8 +416,10 @@ export async function updateSiteResource(
|
|||||||
: [];
|
: [];
|
||||||
const existingSiteIds = existingSiteNetworks.map((sn) => sn.siteId);
|
const existingSiteIds = existingSiteNetworks.map((sn) => sn.siteId);
|
||||||
|
|
||||||
let fullDomain: string | null = null;
|
// undefined means "leave unchanged" (partial update); only nulled out
|
||||||
let finalSubdomain: string | null = null;
|
// when the mode is explicitly being changed away from http
|
||||||
|
let fullDomain: string | null | undefined = undefined;
|
||||||
|
let finalSubdomain: string | null | undefined = undefined;
|
||||||
if (domainId) {
|
if (domainId) {
|
||||||
// Validate domain and construct full domain
|
// Validate domain and construct full domain
|
||||||
const domainResult = await validateAndConstructDomain(
|
const domainResult = await validateAndConstructDomain(
|
||||||
@@ -448,6 +455,11 @@ export async function updateSiteResource(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else if (mode !== undefined && mode !== "http") {
|
||||||
|
// mode is explicitly changing away from http, so the resource
|
||||||
|
// can no longer have a domain associated with it
|
||||||
|
fullDomain = null;
|
||||||
|
finalSubdomain = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// make sure the alias is unique within the org if provided
|
// make sure the alias is unique within the org if provided
|
||||||
@@ -516,15 +528,28 @@ export async function updateSiteResource(
|
|||||||
destination: destination,
|
destination: destination,
|
||||||
destinationPort: destinationPort,
|
destinationPort: destinationPort,
|
||||||
enabled: enabled,
|
enabled: enabled,
|
||||||
alias: alias ? alias.trim() : null,
|
alias:
|
||||||
|
alias !== undefined
|
||||||
|
? alias
|
||||||
|
? alias.trim()
|
||||||
|
: null
|
||||||
|
: mode !== undefined &&
|
||||||
|
mode !== "host" &&
|
||||||
|
mode !== "ssh"
|
||||||
|
? null
|
||||||
|
: undefined,
|
||||||
tcpPortRangeString: tcpPortRangeStringAdjusted,
|
tcpPortRangeString: tcpPortRangeStringAdjusted,
|
||||||
udpPortRangeString:
|
udpPortRangeString:
|
||||||
mode == "http" || mode == "ssh"
|
mode == "http" || mode == "ssh"
|
||||||
? ""
|
? ""
|
||||||
: udpPortRangeString,
|
: udpPortRangeString,
|
||||||
disableIcmp:
|
disableIcmp:
|
||||||
disableIcmp ||
|
mode !== undefined
|
||||||
(mode == "http" || mode == "ssh" ? true : false),
|
? disableIcmp ||
|
||||||
|
(mode == "http" || mode == "ssh"
|
||||||
|
? true
|
||||||
|
: false)
|
||||||
|
: disableIcmp,
|
||||||
domainId,
|
domainId,
|
||||||
subdomain: finalSubdomain,
|
subdomain: finalSubdomain,
|
||||||
fullDomain,
|
fullDomain,
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useActionState, useEffect } from "react";
|
import { useActionState, useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { PrivateResourceAccessFields } from "../../PrivateResourceAccessFields";
|
import { PrivateResourceAccessFields } from "@app/components/PrivateResourceAccessFields";
|
||||||
|
|
||||||
export default function PrivateResourceAccessPage() {
|
export default function PrivateResourceAccessPage() {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|||||||
@@ -20,12 +20,12 @@ import { useTranslations } from "next-intl";
|
|||||||
import { useActionState, useMemo, useState } from "react";
|
import { useActionState, useMemo, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { PrivateResourceSitesField } from "../../PrivateResourceSitesField";
|
import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
|
||||||
import { PrivateResourceCidrDestinationField } from "../../PrivateResourceDestinationFields";
|
import { PrivateResourceCidrDestinationField } from "@app/components/PrivateResourceDestinationFields";
|
||||||
import { PrivateResourcePortRanges } from "../../PrivateResourcePortRanges";
|
import { PrivateResourcePortRanges } from "@app/components/PrivateResourcePortRanges";
|
||||||
import { buildSelectedSitesForResource } from "../../privateResourceUtils";
|
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
|
||||||
import { asAnyControl, asAnySetValue } from "../../formControlUtils";
|
import { asAnyControl, asAnySetValue } from "@app/lib/formControlUtils";
|
||||||
import { useSaveSiteResource } from "../../useSaveSiteResource";
|
import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
|
||||||
|
|
||||||
export default function PrivateResourceCidrPage() {
|
export default function PrivateResourceCidrPage() {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|||||||
@@ -16,19 +16,21 @@ import { Button } from "@app/components/ui/button";
|
|||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
FormField,
|
FormField,
|
||||||
FormItem,
|
FormItem,
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage
|
FormMessage
|
||||||
} from "@app/components/ui/form";
|
} from "@app/components/ui/form";
|
||||||
import { Input } from "@app/components/ui/input";
|
import { Input } from "@app/components/ui/input";
|
||||||
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
import { createGeneralFormSchema } from "@app/lib/privateResourceForm";
|
import { createGeneralFormSchema } from "@app/lib/privateResourceForm";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useActionState, useMemo } from "react";
|
import { useActionState, useMemo } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { useSaveSiteResource } from "../../useSaveSiteResource";
|
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
|
||||||
|
|
||||||
export default function PrivateResourceGeneralPage() {
|
export default function PrivateResourceGeneralPage() {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
@@ -41,7 +43,8 @@ export default function PrivateResourceGeneralPage() {
|
|||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: siteResource.name,
|
name: siteResource.name,
|
||||||
niceId: siteResource.niceId
|
niceId: siteResource.niceId,
|
||||||
|
enabled: siteResource.enabled
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -52,7 +55,8 @@ export default function PrivateResourceGeneralPage() {
|
|||||||
const data = form.getValues();
|
const data = form.getValues();
|
||||||
await save({
|
await save({
|
||||||
name: data.name,
|
name: data.name,
|
||||||
niceId: data.niceId
|
niceId: data.niceId,
|
||||||
|
enabled: data.enabled
|
||||||
});
|
});
|
||||||
}, null);
|
}, null);
|
||||||
|
|
||||||
@@ -76,6 +80,42 @@ export default function PrivateResourceGeneralPage() {
|
|||||||
id="private-resource-general-form"
|
id="private-resource-general-form"
|
||||||
>
|
>
|
||||||
<SettingsFormGrid>
|
<SettingsFormGrid>
|
||||||
|
<SettingsFormCell span="full">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="enabled"
|
||||||
|
render={() => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<SwitchInput
|
||||||
|
id="enable-resource"
|
||||||
|
defaultChecked={
|
||||||
|
siteResource.enabled
|
||||||
|
}
|
||||||
|
label={t(
|
||||||
|
"resourceEnable"
|
||||||
|
)}
|
||||||
|
onCheckedChange={(
|
||||||
|
val
|
||||||
|
) =>
|
||||||
|
form.setValue(
|
||||||
|
"enabled",
|
||||||
|
val
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t(
|
||||||
|
"disabledResourceDescription"
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
|
||||||
<SettingsFormCell span="half">
|
<SettingsFormCell span="half">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
|
|||||||
@@ -20,16 +20,16 @@ import { useTranslations } from "next-intl";
|
|||||||
import { useActionState, useMemo, useState } from "react";
|
import { useActionState, useMemo, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { PrivateResourceSitesField } from "../../PrivateResourceSitesField";
|
import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
|
||||||
import { PrivateResourceHostDestinationFields } from "../../PrivateResourceDestinationFields";
|
import { PrivateResourceHostDestinationFields } from "@app/components/PrivateResourceDestinationFields";
|
||||||
import { PrivateResourcePortRanges } from "../../PrivateResourcePortRanges";
|
import { PrivateResourcePortRanges } from "@app/components/PrivateResourcePortRanges";
|
||||||
import { buildSelectedSitesForResource } from "../../privateResourceUtils";
|
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
|
||||||
import {
|
import {
|
||||||
asAnyControl,
|
asAnyControl,
|
||||||
asAnySetValue,
|
asAnySetValue,
|
||||||
asAnyWatch
|
asAnyWatch
|
||||||
} from "../../formControlUtils";
|
} from "@app/lib/formControlUtils";
|
||||||
import { useSaveSiteResource } from "../../useSaveSiteResource";
|
import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
|
||||||
|
|
||||||
export default function PrivateResourceHostPage() {
|
export default function PrivateResourceHostPage() {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|||||||
@@ -22,15 +22,15 @@ import { useTranslations } from "next-intl";
|
|||||||
import { useActionState, useMemo, useState } from "react";
|
import { useActionState, useMemo, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { PrivateResourceSitesField } from "../../PrivateResourceSitesField";
|
import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
|
||||||
import { PrivateResourceHttpFields } from "../../PrivateResourceHttpFields";
|
import { PrivateResourceHttpFields } from "@app/components/PrivateResourceHttpFields";
|
||||||
import { buildSelectedSitesForResource } from "../../privateResourceUtils";
|
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
|
||||||
import {
|
import {
|
||||||
asAnyControl,
|
asAnyControl,
|
||||||
asAnySetValue,
|
asAnySetValue,
|
||||||
asAnyWatch
|
asAnyWatch
|
||||||
} from "../../formControlUtils";
|
} from "@app/lib/formControlUtils";
|
||||||
import { useSaveSiteResource } from "../../useSaveSiteResource";
|
import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
|
||||||
|
|
||||||
export default function PrivateResourceHttpPage() {
|
export default function PrivateResourceHttpPage() {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|||||||
@@ -26,15 +26,15 @@ import { useTranslations } from "next-intl";
|
|||||||
import { useActionState, useMemo, useState } from "react";
|
import { useActionState, useMemo, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { PrivateResourceSshFields } from "../../PrivateResourceSshFields";
|
import { PrivateResourceSshFields } from "@app/components/PrivateResourceSshFields";
|
||||||
import { buildSelectedSitesForResource } from "../../privateResourceUtils";
|
import type { Selectedsite } from "@app/components/site-selector";
|
||||||
|
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
|
||||||
import {
|
import {
|
||||||
asAnyControl,
|
asAnyControl,
|
||||||
asAnySetValue,
|
asAnySetValue,
|
||||||
asAnyWatch
|
asAnyWatch
|
||||||
} from "../../formControlUtils";
|
} from "@app/lib/formControlUtils";
|
||||||
import { useSaveSiteResource } from "../../useSaveSiteResource";
|
import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
|
||||||
import type { Selectedsite } from "@app/components/site-selector";
|
|
||||||
|
|
||||||
export default function PrivateResourceSshPage() {
|
export default function PrivateResourceSshPage() {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|||||||
@@ -50,16 +50,20 @@ import { useParams, useRouter, useSearchParams } from "next/navigation";
|
|||||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { PrivateResourceSitesField } from "../PrivateResourceSitesField";
|
import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
|
||||||
import { PrivateResourceHttpFields } from "../PrivateResourceHttpFields";
|
import { PrivateResourceHttpFields } from "@app/components/PrivateResourceHttpFields";
|
||||||
import { PrivateResourceSshFields } from "../PrivateResourceSshFields";
|
import { PrivateResourceSshFields } from "@app/components/PrivateResourceSshFields";
|
||||||
import { PrivateResourcePortRanges } from "../PrivateResourcePortRanges";
|
import { PrivateResourcePortRanges } from "@app/components/PrivateResourcePortRanges";
|
||||||
import {
|
import {
|
||||||
PrivateResourceAliasField,
|
PrivateResourceAliasField,
|
||||||
PrivateResourceCidrDestinationField,
|
PrivateResourceCidrDestinationField,
|
||||||
PrivateResourceHostDestinationFields
|
PrivateResourceHostDestinationFields
|
||||||
} from "../PrivateResourceDestinationFields";
|
} from "@app/components/PrivateResourceDestinationFields";
|
||||||
import { asAnyControl, asAnySetValue, asAnyWatch } from "../formControlUtils";
|
import {
|
||||||
|
asAnyControl,
|
||||||
|
asAnySetValue,
|
||||||
|
asAnyWatch
|
||||||
|
} from "@app/lib/formControlUtils";
|
||||||
|
|
||||||
export default function CreatePrivateResourcePage() {
|
export default function CreatePrivateResourcePage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export default async function ClientResourcesPage(
|
|||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const t = await getTranslations();
|
const t = await getTranslations();
|
||||||
const searchParams = new URLSearchParams(await props.searchParams);
|
const searchParams = new URLSearchParams(await props.searchParams);
|
||||||
|
searchParams.set("status", "approved");
|
||||||
|
|
||||||
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
|
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
|
||||||
let pagination: ListAllSiteResourcesByOrgResponse["pagination"] = {
|
let pagination: ListAllSiteResourcesByOrgResponse["pagination"] = {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export default async function ProxyResourcesPage(
|
|||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const t = await getTranslations();
|
const t = await getTranslations();
|
||||||
const searchParams = new URLSearchParams(await props.searchParams);
|
const searchParams = new URLSearchParams(await props.searchParams);
|
||||||
|
searchParams.set("status", "approved");
|
||||||
|
|
||||||
let resources: ListResourcesResponse["resources"] = [];
|
let resources: ListResourcesResponse["resources"] = [];
|
||||||
let pagination: ListResourcesResponse["pagination"] = {
|
let pagination: ListResourcesResponse["pagination"] = {
|
||||||
|
|||||||
@@ -16,11 +16,8 @@ import LoginCardHeader from "@app/components/LoginCardHeader";
|
|||||||
import { priv } from "@app/lib/api";
|
import { priv } from "@app/lib/api";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { LoginFormIDP } from "@app/components/LoginForm";
|
import { LoginFormIDP } from "@app/components/LoginForm";
|
||||||
import { ListIdpsResponse, type GetIdpResponse } from "@server/routers/idp";
|
import { ListIdpsResponse } from "@server/routers/idp";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { cookies } from "next/headers";
|
|
||||||
import { LAST_USED_IDP_COOKIE_NAME } from "@app/lib/consts";
|
|
||||||
import z from "zod";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Log In"
|
title: "Log In"
|
||||||
@@ -32,9 +29,8 @@ export default async function Page(props: {
|
|||||||
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||||
}) {
|
}) {
|
||||||
const searchParams = await props.searchParams;
|
const searchParams = await props.searchParams;
|
||||||
const user = await verifySession({ skipCheckVerifyEmail: true });
|
const getUser = cache(verifySession);
|
||||||
|
const user = await getUser({ skipCheckVerifyEmail: true });
|
||||||
const lastUsedIdpCookie = (await cookies()).get(LAST_USED_IDP_COOKIE_NAME);
|
|
||||||
|
|
||||||
const isInvite = searchParams?.redirect?.includes("/invite");
|
const isInvite = searchParams?.redirect?.includes("/invite");
|
||||||
const forceLoginParam = searchParams?.forceLogin;
|
const forceLoginParam = searchParams?.forceLogin;
|
||||||
@@ -89,47 +85,19 @@ export default async function Page(props: {
|
|||||||
(build === "enterprise" && env.app.identityProviderMode === "org");
|
(build === "enterprise" && env.app.identityProviderMode === "org");
|
||||||
|
|
||||||
let loginIdps: LoginFormIDP[] = [];
|
let loginIdps: LoginFormIDP[] = [];
|
||||||
let lastUsedIdpForSmartLogin: (LoginFormIDP & { orgId?: string }) | null =
|
|
||||||
null;
|
|
||||||
if (!useSmartLogin) {
|
if (!useSmartLogin) {
|
||||||
// Load IdPs for DashboardLoginForm (OSS or org-only IdP mode)
|
// Load IdPs for DashboardLoginForm (OSS or org-only IdP mode)
|
||||||
if (build === "oss" || env.app.identityProviderMode !== "org") {
|
if (build === "oss" || env.app.identityProviderMode !== "org") {
|
||||||
const idpsRes =
|
const idpsRes = await cache(
|
||||||
await priv.get<AxiosResponse<ListIdpsResponse>>("/idp");
|
async () =>
|
||||||
|
await priv.get<AxiosResponse<ListIdpsResponse>>("/idp")
|
||||||
|
)();
|
||||||
loginIdps = idpsRes.data.data.idps.map((idp) => ({
|
loginIdps = idpsRes.data.data.idps.map((idp) => ({
|
||||||
idpId: idp.idpId,
|
idpId: idp.idpId,
|
||||||
name: idp.name,
|
name: idp.name,
|
||||||
variant: idp.type
|
variant: idp.type
|
||||||
})) as LoginFormIDP[];
|
})) as LoginFormIDP[];
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
if (lastUsedIdpCookie) {
|
|
||||||
const lastUsedIdpSchema = z.object({
|
|
||||||
orgId: z.string().optional(),
|
|
||||||
idpId: z.number()
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
const persistedData = lastUsedIdpSchema.parse(
|
|
||||||
JSON.parse(lastUsedIdpCookie.value)
|
|
||||||
);
|
|
||||||
|
|
||||||
const idpRes = await priv.get<AxiosResponse<GetIdpResponse>>(
|
|
||||||
`/idp/${persistedData.idpId}`
|
|
||||||
);
|
|
||||||
|
|
||||||
const idp = idpRes.data.data.idp;
|
|
||||||
|
|
||||||
lastUsedIdpForSmartLogin = {
|
|
||||||
idpId: idp.idpId,
|
|
||||||
name: idp.name,
|
|
||||||
variant: idp.type,
|
|
||||||
orgId: persistedData.orgId,
|
|
||||||
lastUsed: true
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
// the idp might not exist or the data is malformatted, skip this
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const t = await getTranslations();
|
const t = await getTranslations();
|
||||||
@@ -192,7 +160,6 @@ export default async function Page(props: {
|
|||||||
redirect={redirectUrl}
|
redirect={redirectUrl}
|
||||||
forceLogin={forceLogin}
|
forceLogin={forceLogin}
|
||||||
defaultUser={defaultUser}
|
defaultUser={defaultUser}
|
||||||
lastUsedIdp={lastUsedIdpForSmartLogin}
|
|
||||||
orgSignIn={
|
orgSignIn={
|
||||||
!isInvite &&
|
!isInvite &&
|
||||||
(build === "saas" ||
|
(build === "saas" ||
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ import { redirect } from "next/navigation";
|
|||||||
import OrgLoginPage from "@app/components/OrgLoginPage";
|
import OrgLoginPage from "@app/components/OrgLoginPage";
|
||||||
import { pullEnv } from "@app/lib/pullEnv";
|
import { pullEnv } from "@app/lib/pullEnv";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
|
||||||
import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Organization Login"
|
title: "Organization Login"
|
||||||
@@ -70,13 +68,7 @@ export default async function OrgAuthPage(props: {
|
|||||||
variant: idp.variant
|
variant: idp.variant
|
||||||
})) as LoginFormIDP[];
|
})) as LoginFormIDP[];
|
||||||
|
|
||||||
const hasLoginPageBranding = await isOrgSubscribed(
|
|
||||||
orgId,
|
|
||||||
tierMatrix.loginPageBranding
|
|
||||||
);
|
|
||||||
|
|
||||||
let branding: LoadLoginPageBrandingResponse | null = null;
|
let branding: LoadLoginPageBrandingResponse | null = null;
|
||||||
if (hasLoginPageBranding) {
|
|
||||||
try {
|
try {
|
||||||
const res = await priv.get<
|
const res = await priv.get<
|
||||||
AxiosResponse<LoadLoginPageBrandingResponse>
|
AxiosResponse<LoadLoginPageBrandingResponse>
|
||||||
@@ -85,7 +77,6 @@ export default async function OrgAuthPage(props: {
|
|||||||
branding = res.data.data;
|
branding = res.data.data;
|
||||||
}
|
}
|
||||||
} catch (error) {}
|
} catch (error) {}
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OrgLoginPage
|
<OrgLoginPage
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
|
|||||||
import { OrgSelectionForm } from "@app/components/OrgSelectionForm";
|
import { OrgSelectionForm } from "@app/components/OrgSelectionForm";
|
||||||
import OrgLoginPage from "@app/components/OrgLoginPage";
|
import OrgLoginPage from "@app/components/OrgLoginPage";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Choose Organization"
|
title: "Choose Organization"
|
||||||
@@ -84,10 +83,7 @@ export default async function OrgAuthPage(props: {
|
|||||||
redirect(env.app.dashboardUrl);
|
redirect(env.app.dashboardUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscribed = await isOrgSubscribed(
|
const subscribed = await isOrgSubscribed(loginPage.orgId);
|
||||||
loginPage.orgId,
|
|
||||||
tierMatrix.loginPageDomain
|
|
||||||
);
|
|
||||||
|
|
||||||
if (build === "saas" && !subscribed) {
|
if (build === "saas" && !subscribed) {
|
||||||
console.log(
|
console.log(
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import { CheckOrgUserAccessResponse } from "@server/routers/org";
|
|||||||
import OrgPolicyRequired from "@app/components/OrgPolicyRequired";
|
import OrgPolicyRequired from "@app/components/OrgPolicyRequired";
|
||||||
import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
|
import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
|
||||||
import { normalizePostAuthPath } from "@server/lib/normalizePostAuthPath";
|
import { normalizePostAuthPath } from "@server/lib/normalizePostAuthPath";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -71,25 +70,14 @@ export default async function ResourceAuthPage(props: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasLoginPageDomain = await isOrgSubscribed(
|
const subscribed = await isOrgSubscribed(authInfo.orgId);
|
||||||
authInfo.orgId,
|
|
||||||
tierMatrix.loginPageDomain
|
|
||||||
);
|
|
||||||
const hasOrgOidc = await isOrgSubscribed(
|
|
||||||
authInfo.orgId,
|
|
||||||
tierMatrix.orgOidc
|
|
||||||
);
|
|
||||||
const hasLoginPageBranding = await isOrgSubscribed(
|
|
||||||
authInfo.orgId,
|
|
||||||
tierMatrix.loginPageBranding
|
|
||||||
);
|
|
||||||
|
|
||||||
const allHeaders = await headers();
|
const allHeaders = await headers();
|
||||||
const host = allHeaders.get("host");
|
const host = allHeaders.get("host");
|
||||||
|
|
||||||
const expectedHost = env.app.dashboardUrl.split("//")[1];
|
const expectedHost = env.app.dashboardUrl.split("//")[1];
|
||||||
if (host !== expectedHost) {
|
if (host !== expectedHost) {
|
||||||
if (build === "saas" && !hasLoginPageDomain) {
|
if (build === "saas" && !subscribed) {
|
||||||
redirect(env.app.dashboardUrl);
|
redirect(env.app.dashboardUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,10 +106,7 @@ export default async function ResourceAuthPage(props: {
|
|||||||
const redirectPort = new URL(searchParams.redirect).port;
|
const redirectPort = new URL(searchParams.redirect).port;
|
||||||
const serverResourceHostWithPort = `${serverResourceHost}:${redirectPort}`;
|
const serverResourceHostWithPort = `${serverResourceHost}:${redirectPort}`;
|
||||||
|
|
||||||
const wildcardMatchesRedirect = (
|
const wildcardMatchesRedirect = (wildcardDomain: string, host: string): boolean => {
|
||||||
wildcardDomain: string,
|
|
||||||
host: string
|
|
||||||
): boolean => {
|
|
||||||
if (!wildcardDomain.startsWith("*.")) return false;
|
if (!wildcardDomain.startsWith("*.")) return false;
|
||||||
const suffix = wildcardDomain.slice(1); // e.g. ".wildcard.owen.fosrl.io"
|
const suffix = wildcardDomain.slice(1); // e.g. ".wildcard.owen.fosrl.io"
|
||||||
return host.endsWith(suffix) && host.length > suffix.length;
|
return host.endsWith(suffix) && host.length > suffix.length;
|
||||||
@@ -243,7 +228,7 @@ export default async function ResourceAuthPage(props: {
|
|||||||
|
|
||||||
let loginIdps: LoginFormIDP[] = [];
|
let loginIdps: LoginFormIDP[] = [];
|
||||||
if (build === "saas" || env.app.identityProviderMode === "org") {
|
if (build === "saas" || env.app.identityProviderMode === "org") {
|
||||||
if (hasOrgOidc) {
|
if (subscribed) {
|
||||||
const idpsRes = await cache(
|
const idpsRes = await cache(
|
||||||
async () =>
|
async () =>
|
||||||
await priv.get<AxiosResponse<ListOrgIdpsResponse>>(
|
await priv.get<AxiosResponse<ListOrgIdpsResponse>>(
|
||||||
@@ -286,7 +271,7 @@ export default async function ResourceAuthPage(props: {
|
|||||||
|
|
||||||
let branding: LoadLoginPageBrandingResponse | null = null;
|
let branding: LoadLoginPageBrandingResponse | null = null;
|
||||||
try {
|
try {
|
||||||
if (hasLoginPageBranding) {
|
if (subscribed) {
|
||||||
const res = await priv.get<
|
const res = await priv.get<
|
||||||
AxiosResponse<LoadLoginPageBrandingResponse>
|
AxiosResponse<LoadLoginPageBrandingResponse>
|
||||||
>(`/login-page-branding?orgId=${authInfo.orgId}`);
|
>(`/login-page-branding?orgId=${authInfo.orgId}`);
|
||||||
|
|||||||
+4
-1
@@ -5,6 +5,7 @@ import UserProvider from "@app/providers/UserProvider";
|
|||||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { cache } from "react";
|
||||||
import OrganizationLanding from "@app/components/OrganizationLanding";
|
import OrganizationLanding from "@app/components/OrganizationLanding";
|
||||||
import { pullEnv } from "@app/lib/pullEnv";
|
import { pullEnv } from "@app/lib/pullEnv";
|
||||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||||
@@ -12,6 +13,7 @@ import { Layout } from "@app/components/Layout";
|
|||||||
import RedirectToOrg from "@app/components/RedirectToOrg";
|
import RedirectToOrg from "@app/components/RedirectToOrg";
|
||||||
import { InitialSetupCompleteResponse } from "@server/routers/auth";
|
import { InitialSetupCompleteResponse } from "@server/routers/auth";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -27,7 +29,8 @@ export default async function Page(props: {
|
|||||||
|
|
||||||
const env = pullEnv();
|
const env = pullEnv();
|
||||||
|
|
||||||
const user = await verifySession({ skipCheckVerifyEmail: true });
|
const getUser = cache(verifySession);
|
||||||
|
const user = await getUser({ skipCheckVerifyEmail: true });
|
||||||
|
|
||||||
let complete = false;
|
let complete = false;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,25 +1,26 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { generateOidcUrlProxy } from "@app/actions/server";
|
import { useEffect, useState } from "react";
|
||||||
import IdpTypeIcon from "@app/components/IdpTypeIcon";
|
|
||||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||||
import { LAST_USED_IDP_COOKIE_NAME } from "@app/lib/consts";
|
|
||||||
import { setClientCookie } from "@app/lib/setClientCookie";
|
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import IdpTypeIcon from "@app/components/IdpTypeIcon";
|
||||||
|
import {
|
||||||
|
generateOidcUrlProxy,
|
||||||
|
type GenerateOidcUrlResponse
|
||||||
|
} from "@app/actions/server";
|
||||||
import {
|
import {
|
||||||
redirect as redirectTo,
|
redirect as redirectTo,
|
||||||
useRouter,
|
useParams,
|
||||||
useSearchParams
|
useSearchParams
|
||||||
} from "next/navigation";
|
} from "next/navigation";
|
||||||
import { useEffect, useState, useTransition } from "react";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||||
|
|
||||||
export type LoginFormIDP = {
|
export type LoginFormIDP = {
|
||||||
idpId: number;
|
idpId: number;
|
||||||
name: string;
|
name: string;
|
||||||
variant?: string;
|
variant?: string;
|
||||||
lastUsed?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type IdpLoginButtonsProps = {
|
type IdpLoginButtonsProps = {
|
||||||
@@ -34,6 +35,7 @@ export default function IdpLoginButtons({
|
|||||||
orgId
|
orgId
|
||||||
}: IdpLoginButtonsProps) {
|
}: IdpLoginButtonsProps) {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
const params = useSearchParams();
|
const params = useSearchParams();
|
||||||
@@ -50,22 +52,10 @@ export default function IdpLoginButtons({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [loading, startTransition] = useTransition();
|
|
||||||
|
|
||||||
async function loginWithIdp(idpId: number) {
|
async function loginWithIdp(idpId: number) {
|
||||||
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
setClientCookie(
|
|
||||||
LAST_USED_IDP_COOKIE_NAME,
|
|
||||||
JSON.stringify({
|
|
||||||
orgId,
|
|
||||||
idpId
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
sameSite: "Lax"
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
let redirectToUrl: string | undefined;
|
let redirectToUrl: string | undefined;
|
||||||
try {
|
try {
|
||||||
console.log("generating", idpId, redirect || "/", orgId);
|
console.log("generating", idpId, redirect || "/", orgId);
|
||||||
@@ -78,6 +68,7 @@ export default function IdpLoginButtons({
|
|||||||
|
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
setError(response.message);
|
setError(response.message);
|
||||||
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +84,7 @@ export default function IdpLoginButtons({
|
|||||||
"An unexpected error occurred. Please try again."
|
"An unexpected error occurred. Please try again."
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (redirectToUrl) {
|
if (redirectToUrl) {
|
||||||
@@ -132,38 +124,20 @@ export default function IdpLoginButtons({
|
|||||||
idp.variant || idp.name.toLowerCase();
|
idp.variant || idp.name.toLowerCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
|
||||||
className="w-full relative"
|
|
||||||
key={idp.idpId}
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
key={idp.idpId}
|
key={idp.idpId}
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full inline-flex items-center space-x-2 after:absolute after:inset-0 after:z-10"
|
className="w-full inline-flex items-center space-x-2"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
startTransition(() =>
|
loginWithIdp(idp.idpId);
|
||||||
loginWithIdp(idp.idpId)
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
>
|
>
|
||||||
<IdpTypeIcon
|
<IdpTypeIcon type={effectiveType} size={16} />
|
||||||
type={effectiveType}
|
|
||||||
size={16}
|
|
||||||
/>
|
|
||||||
<span>{idp.name}</span>
|
<span>{idp.name}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{idp.lastUsed && (
|
|
||||||
<div className="absolute inset-0">
|
|
||||||
<span className="absolute top-0 right-0 text-xs bg-primary text-primary-foreground rounded-bl-sm rounded-tr-sm px-2 py-0.5">
|
|
||||||
{t("idpLastUsed")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ import Link from "next/link";
|
|||||||
import { GenerateOidcUrlResponse } from "@server/routers/idp";
|
import { GenerateOidcUrlResponse } from "@server/routers/idp";
|
||||||
import { Separator } from "./ui/separator";
|
import { Separator } from "./ui/separator";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { generateOidcUrlProxy, loginProxy } from "@app/actions/server";
|
import {
|
||||||
|
generateOidcUrlProxy,
|
||||||
|
loginProxy
|
||||||
|
} from "@app/actions/server";
|
||||||
import { redirect as redirectTo } from "next/navigation";
|
import { redirect as redirectTo } from "next/navigation";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import IdpTypeIcon from "@app/components/IdpTypeIcon";
|
import IdpTypeIcon from "@app/components/IdpTypeIcon";
|
||||||
@@ -38,13 +41,11 @@ import IdpTypeIcon from "@app/components/IdpTypeIcon";
|
|||||||
import { loadReoScript } from "reodotdev";
|
import { loadReoScript } from "reodotdev";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import MfaInputForm from "@app/components/MfaInputForm";
|
import MfaInputForm from "@app/components/MfaInputForm";
|
||||||
import { useLocalStorage } from "@app/hooks/useLocalStorage";
|
|
||||||
|
|
||||||
export type LoginFormIDP = {
|
export type LoginFormIDP = {
|
||||||
idpId: number;
|
idpId: number;
|
||||||
name: string;
|
name: string;
|
||||||
variant?: string;
|
variant?: string;
|
||||||
lastUsed?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type LoginFormProps = {
|
type LoginFormProps = {
|
||||||
@@ -104,6 +105,7 @@ export default function LoginForm({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
email: z.string().email({ message: t("emailInvalid") }),
|
email: z.string().email({ message: t("emailInvalid") }),
|
||||||
password: z.string().min(8, { message: t("passwordRequirementsChars") })
|
password: z.string().min(8, { message: t("passwordRequirementsChars") })
|
||||||
@@ -128,16 +130,11 @@ export default function LoginForm({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const [lastUsedIdpId, setLastUsedIdpId] = useLocalStorage<string | null>(
|
|
||||||
"login:last-used-idp",
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
async function onSubmit(values: any) {
|
async function onSubmit(values: any) {
|
||||||
const { email, password } = form.getValues();
|
const { email, password } = form.getValues();
|
||||||
const { code } = mfaForm.getValues();
|
const { code } = mfaForm.getValues();
|
||||||
|
|
||||||
setLastUsedIdpId(null);
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
@@ -182,7 +179,8 @@ export default function LoginForm({
|
|||||||
if (data.useSecurityKey) {
|
if (data.useSecurityKey) {
|
||||||
setError(
|
setError(
|
||||||
t("securityKeyRequired", {
|
t("securityKeyRequired", {
|
||||||
defaultValue: "Please use your security key to sign in."
|
defaultValue:
|
||||||
|
"Please use your security key to sign in."
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -244,8 +242,6 @@ export default function LoginForm({
|
|||||||
|
|
||||||
async function loginWithIdp(idpId: number) {
|
async function loginWithIdp(idpId: number) {
|
||||||
let redirectUrl: string | undefined;
|
let redirectUrl: string | undefined;
|
||||||
|
|
||||||
setLastUsedIdpId(idpId.toString());
|
|
||||||
try {
|
try {
|
||||||
const data = await generateOidcUrlProxy(
|
const data = await generateOidcUrlProxy(
|
||||||
idpId,
|
idpId,
|
||||||
@@ -360,6 +356,7 @@ export default function LoginForm({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
||||||
{!mfaRequested && (
|
{!mfaRequested && (
|
||||||
<>
|
<>
|
||||||
<SecurityKeyAuthButton
|
<SecurityKeyAuthButton
|
||||||
@@ -388,41 +385,25 @@ export default function LoginForm({
|
|||||||
idp.variant || idp.name.toLowerCase();
|
idp.variant || idp.name.toLowerCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
|
||||||
className="w-full relative"
|
|
||||||
key={idp.idpId}
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
key={idp.idpId}
|
key={idp.idpId}
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full inline-flex items-center space-x-2 after:absolute after:inset-0 after:z-10"
|
className="w-full inline-flex items-center space-x-2"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
loginWithIdp(idp.idpId);
|
loginWithIdp(idp.idpId);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<IdpTypeIcon
|
<IdpTypeIcon type={effectiveType} size={16} />
|
||||||
type={effectiveType}
|
|
||||||
size={16}
|
|
||||||
/>
|
|
||||||
<span>{idp.name}</span>
|
<span>{idp.name}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{lastUsedIdpId ===
|
|
||||||
idp.idpId.toString() && (
|
|
||||||
<div className="absolute inset-0">
|
|
||||||
<span className="absolute top-0 right-0 text-xs bg-primary text-primary-foreground rounded-bl-sm rounded-tr-sm px-2 py-0.5">
|
|
||||||
{t("idpLastUsed")}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,8 +22,6 @@ import Link from "next/link";
|
|||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||||
import MfaInputForm from "@app/components/MfaInputForm";
|
import MfaInputForm from "@app/components/MfaInputForm";
|
||||||
import { LAST_USED_IDP_COOKIE_NAME } from "@app/lib/consts";
|
|
||||||
import { setClientCookie } from "@app/lib/setClientCookie";
|
|
||||||
|
|
||||||
type LoginPasswordFormProps = {
|
type LoginPasswordFormProps = {
|
||||||
identifier: string;
|
identifier: string;
|
||||||
@@ -84,12 +82,6 @@ export default function LoginPasswordForm({
|
|||||||
const { password } = values;
|
const { password } = values;
|
||||||
const { code } = mfaForm.getValues();
|
const { code } = mfaForm.getValues();
|
||||||
|
|
||||||
// delete last used auth cookie by setting it in the past
|
|
||||||
setClientCookie(LAST_USED_IDP_COOKIE_NAME, JSON.stringify(null), {
|
|
||||||
sameSite: "Lax",
|
|
||||||
days: -1
|
|
||||||
});
|
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||||
|
import {
|
||||||
|
Credenza,
|
||||||
|
CredenzaBody,
|
||||||
|
CredenzaContent,
|
||||||
|
CredenzaDescription,
|
||||||
|
CredenzaFooter,
|
||||||
|
CredenzaHeader,
|
||||||
|
CredenzaTitle
|
||||||
|
} from "@app/components/Credenza";
|
||||||
|
import SiteResourcesOverview from "@app/components/SiteResourcesOverview";
|
||||||
import { Badge } from "@app/components/ui/badge";
|
import { Badge } from "@app/components/ui/badge";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +34,7 @@ import {
|
|||||||
ArrowUp10Icon,
|
ArrowUp10Icon,
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
Check,
|
Check,
|
||||||
|
ChevronDown,
|
||||||
ChevronsUpDownIcon,
|
ChevronsUpDownIcon,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
X
|
X
|
||||||
@@ -65,8 +76,10 @@ export default function PendingSitesTable({
|
|||||||
const [isRefreshing, startTransition] = useTransition();
|
const [isRefreshing, startTransition] = useTransition();
|
||||||
const [approvingIds, setApprovingIds] = useState<Set<number>>(new Set());
|
const [approvingIds, setApprovingIds] = useState<Set<number>>(new Set());
|
||||||
const [rejectingIds, setRejectingIds] = useState<Set<number>>(new Set());
|
const [rejectingIds, setRejectingIds] = useState<Set<number>>(new Set());
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
const [isRejectModalOpen, setIsRejectModalOpen] = useState(false);
|
||||||
const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null);
|
const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null);
|
||||||
|
const [resourcesDialogSite, setResourcesDialogSite] =
|
||||||
|
useState<SiteRow | null>(null);
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
@@ -111,7 +124,7 @@ export default function PendingSitesTable({
|
|||||||
async function approveSite(siteId: number) {
|
async function approveSite(siteId: number) {
|
||||||
setApprovingIds((prev) => new Set(prev).add(siteId));
|
setApprovingIds((prev) => new Set(prev).add(siteId));
|
||||||
try {
|
try {
|
||||||
await api.post(`/site/${siteId}`, { status: "approved" });
|
await api.post(`/site/${siteId}/approve`);
|
||||||
toast({
|
toast({
|
||||||
title: t("success"),
|
title: t("success"),
|
||||||
description: t("siteApproveSuccess"),
|
description: t("siteApproveSuccess"),
|
||||||
@@ -136,20 +149,20 @@ export default function PendingSitesTable({
|
|||||||
async function rejectSite(siteId: number) {
|
async function rejectSite(siteId: number) {
|
||||||
setRejectingIds((prev) => new Set(prev).add(siteId));
|
setRejectingIds((prev) => new Set(prev).add(siteId));
|
||||||
try {
|
try {
|
||||||
await api.delete(`/site/${siteId}`);
|
await api.post(`/site/${siteId}/reject`);
|
||||||
toast({
|
toast({
|
||||||
title: t("success"),
|
title: t("success"),
|
||||||
description: t("siteDeleted"),
|
description: t("siteRejectSuccess"),
|
||||||
variant: "default"
|
variant: "default"
|
||||||
});
|
});
|
||||||
setIsDeleteModalOpen(false);
|
setIsRejectModalOpen(false);
|
||||||
setSelectedSite(null);
|
setSelectedSite(null);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: t("siteErrorDelete"),
|
title: t("siteRejectError"),
|
||||||
description: formatAxiosError(e, t("siteErrorDelete"))
|
description: formatAxiosError(e, t("siteRejectError"))
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setRejectingIds((prev) => {
|
setRejectingIds((prev) => {
|
||||||
@@ -342,6 +355,29 @@ export default function PendingSitesTable({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "resources",
|
||||||
|
accessorKey: "resourceCount",
|
||||||
|
friendlyName: t("resources"),
|
||||||
|
header: () => <span className="p-3">{t("resources")}</span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const siteRow = row.original;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setResourcesDialogSite(siteRow)}
|
||||||
|
className="flex h-8 items-center gap-2 px-0 font-normal"
|
||||||
|
>
|
||||||
|
<span className="text-sm tabular-nums">
|
||||||
|
{siteRow.resourceCount} {t("resources")}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "exitNode",
|
accessorKey: "exitNode",
|
||||||
friendlyName: t("exitNode"),
|
friendlyName: t("exitNode"),
|
||||||
@@ -445,7 +481,7 @@ export default function PendingSitesTable({
|
|||||||
disabled={isApproving || isRejecting}
|
disabled={isApproving || isRejecting}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedSite(siteRow);
|
setSelectedSite(siteRow);
|
||||||
setIsDeleteModalOpen(true);
|
setIsRejectModalOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<X className="mr-2 w-4 h-4" />
|
<X className="mr-2 w-4 h-4" />
|
||||||
@@ -491,25 +527,63 @@ export default function PendingSitesTable({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<Credenza
|
||||||
|
open={Boolean(resourcesDialogSite)}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) setResourcesDialogSite(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CredenzaContent className="md:max-w-7xl">
|
||||||
|
<CredenzaHeader>
|
||||||
|
<CredenzaTitle>{t("siteResourcesTab")}</CredenzaTitle>
|
||||||
|
<CredenzaDescription>
|
||||||
|
{t("siteResourcesDialogDescription")}
|
||||||
|
</CredenzaDescription>
|
||||||
|
</CredenzaHeader>
|
||||||
|
<CredenzaBody>
|
||||||
|
{resourcesDialogSite != null && (
|
||||||
|
<SiteResourcesOverview
|
||||||
|
orgIdOverride={orgId}
|
||||||
|
siteId={resourcesDialogSite.id}
|
||||||
|
initialPublicData={null}
|
||||||
|
initialPrivateData={null}
|
||||||
|
initialPublicForbidden={false}
|
||||||
|
initialPrivateForbidden={false}
|
||||||
|
showViewAllLinks={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CredenzaBody>
|
||||||
|
<CredenzaFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setResourcesDialogSite(null)}
|
||||||
|
>
|
||||||
|
{t("close")}
|
||||||
|
</Button>
|
||||||
|
</CredenzaFooter>
|
||||||
|
</CredenzaContent>
|
||||||
|
</Credenza>
|
||||||
|
|
||||||
{selectedSite && (
|
{selectedSite && (
|
||||||
<ConfirmDeleteDialog
|
<ConfirmDeleteDialog
|
||||||
open={isDeleteModalOpen}
|
open={isRejectModalOpen}
|
||||||
setOpen={(val) => {
|
setOpen={(val) => {
|
||||||
setIsDeleteModalOpen(val);
|
setIsRejectModalOpen(val);
|
||||||
if (!val) {
|
if (!val) {
|
||||||
setSelectedSite(null);
|
setSelectedSite(null);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
dialog={
|
dialog={
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p>{t("siteQuestionRemove")}</p>
|
<p>{t("siteQuestionReject")}</p>
|
||||||
<p>{t("siteMessageRemove")}</p>
|
<p>{t("siteMessageReject")}</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
buttonText={t("siteConfirmDelete")}
|
buttonText={t("siteConfirmReject")}
|
||||||
onConfirm={async () => rejectSite(selectedSite.id)}
|
onConfirm={async () => rejectSite(selectedSite.id)}
|
||||||
string={selectedSite.name}
|
string={selectedSite.name}
|
||||||
title={t("siteDelete")}
|
title={t("siteReject")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ControlledDataTable
|
<ControlledDataTable
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ function getActionsCategories(root: boolean) {
|
|||||||
[t("actionGetSite")]: "getSite",
|
[t("actionGetSite")]: "getSite",
|
||||||
[t("actionListSites")]: "listSites",
|
[t("actionListSites")]: "listSites",
|
||||||
[t("actionUpdateSite")]: "updateSite",
|
[t("actionUpdateSite")]: "updateSite",
|
||||||
|
[t("actionUpdateSiteApprovals")]: "updateSiteApprovals",
|
||||||
[t("actionListSiteRoles")]: "listSiteRoles"
|
[t("actionListSiteRoles")]: "listSiteRoles"
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -78,7 +79,8 @@ function getActionsCategories(root: boolean) {
|
|||||||
[t("actionGetSiteResource")]: "getSiteResource",
|
[t("actionGetSiteResource")]: "getSiteResource",
|
||||||
[t("actionListSiteResources")]: "listSiteResources",
|
[t("actionListSiteResources")]: "listSiteResources",
|
||||||
[t("actionUpdateSiteResource")]: "updateSiteResource",
|
[t("actionUpdateSiteResource")]: "updateSiteResource",
|
||||||
[t("actionCreateResourceSessionToken")]: "createResourceSessionToken"
|
[t("actionCreateResourceSessionToken")]:
|
||||||
|
"createResourceSessionToken"
|
||||||
},
|
},
|
||||||
|
|
||||||
Target: {
|
Target: {
|
||||||
|
|||||||
+1
-1
@@ -23,7 +23,7 @@ import {
|
|||||||
import { ChevronsUpDown } from "lucide-react";
|
import { ChevronsUpDown } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import type { Control, FieldPath, FieldValues } from "react-hook-form";
|
import type { Control, FieldPath, FieldValues } from "react-hook-form";
|
||||||
import { PrivateResourceMultiSiteRoutingHelp } from "./PrivateResourceMultiSiteRoutingHelp";
|
import { PrivateResourceMultiSiteRoutingHelp } from "@app/components/PrivateResourceMultiSiteRoutingHelp";
|
||||||
|
|
||||||
type PrivateResourceSitesFieldProps<T extends FieldValues> = {
|
type PrivateResourceSitesFieldProps<T extends FieldValues> = {
|
||||||
control: Control<T>;
|
control: Control<T>;
|
||||||
+3
-3
@@ -9,10 +9,10 @@ import {
|
|||||||
} from "@app/components/Settings";
|
} from "@app/components/Settings";
|
||||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||||
import { SshServerSettingsFields } from "@app/components/SshServerSettingsFields";
|
import { SshServerSettingsFields } from "@app/components/SshServerSettingsFields";
|
||||||
import { PrivateResourceAliasField } from "./PrivateResourceDestinationFields";
|
import { PrivateResourceAliasField } from "@app/components/PrivateResourceDestinationFields";
|
||||||
import { PrivateResourceSitesField } from "./PrivateResourceSitesField";
|
import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
|
||||||
import { getSshUseMultiSiteTargetForm } from "./privateResourceUtils";
|
|
||||||
import { inferSshPamMode } from "@app/lib/privateResourceForm";
|
import { inferSshPamMode } from "@app/lib/privateResourceForm";
|
||||||
|
import { getSshUseMultiSiteTargetForm } from "@app/lib/privateResourceUtils";
|
||||||
import {
|
import {
|
||||||
FormControl,
|
FormControl,
|
||||||
FormField,
|
FormField,
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger
|
PopoverTrigger
|
||||||
} from "@app/components/ui/popover";
|
} from "@app/components/ui/popover";
|
||||||
|
import { Switch } from "@app/components/ui/switch";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||||
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
|
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
|
||||||
@@ -36,7 +37,9 @@ import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHr
|
|||||||
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
|
import { UpdateSiteResourceResponse } from "@server/routers/siteResource";
|
||||||
import type { PaginationState } from "@tanstack/react-table";
|
import type { PaginationState } from "@tanstack/react-table";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
import {
|
import {
|
||||||
ArrowDown01Icon,
|
ArrowDown01Icon,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
@@ -49,8 +52,17 @@ import {
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { startTransition, useMemo, useState, useTransition } from "react";
|
import {
|
||||||
|
startTransition,
|
||||||
|
useMemo,
|
||||||
|
useOptimistic,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
useTransition,
|
||||||
|
type ComponentRef
|
||||||
|
} from "react";
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
|
import z from "zod";
|
||||||
import { ColumnFilterButton } from "./ColumnFilterButton";
|
import { ColumnFilterButton } from "./ColumnFilterButton";
|
||||||
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
|
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
|
||||||
import { LabelsTableCell } from "./LabelsTableCell";
|
import { LabelsTableCell } from "./LabelsTableCell";
|
||||||
@@ -114,6 +126,11 @@ function isSafeUrlForLink(href: string): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const booleanSearchFilterSchema = z
|
||||||
|
.enum(["true", "false"])
|
||||||
|
.optional()
|
||||||
|
.catch(undefined);
|
||||||
|
|
||||||
type ClientResourcesTableProps = {
|
type ClientResourcesTableProps = {
|
||||||
internalResources: InternalResourceRow[];
|
internalResources: InternalResourceRow[];
|
||||||
orgId: string;
|
orgId: string;
|
||||||
@@ -174,6 +191,30 @@ export default function PrivateResourcesTable({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function toggleInternalResourceEnabled(
|
||||||
|
val: boolean,
|
||||||
|
resourceId: number
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await api.post<AxiosResponse<UpdateSiteResourceResponse>>(
|
||||||
|
`site-resource/${resourceId}`,
|
||||||
|
{
|
||||||
|
enabled: val
|
||||||
|
}
|
||||||
|
);
|
||||||
|
router.refresh();
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: t("resourcesErrorUpdate"),
|
||||||
|
description: formatAxiosError(
|
||||||
|
e,
|
||||||
|
t("resourcesErrorUpdateDescription")
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const deleteInternalResource = async (
|
const deleteInternalResource = async (
|
||||||
resourceId: number,
|
resourceId: number,
|
||||||
siteId: number
|
siteId: number
|
||||||
@@ -429,6 +470,36 @@ export default function PrivateResourcesTable({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "enabled",
|
||||||
|
friendlyName: t("enabled"),
|
||||||
|
header: () => (
|
||||||
|
<ColumnFilterButton
|
||||||
|
options={[
|
||||||
|
{ value: "true", label: t("enabled") },
|
||||||
|
{ value: "false", label: t("disabled") }
|
||||||
|
]}
|
||||||
|
selectedValue={booleanSearchFilterSchema.parse(
|
||||||
|
searchParams.get("enabled")
|
||||||
|
)}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleFilterChange("enabled", value)
|
||||||
|
}
|
||||||
|
searchPlaceholder={t("searchPlaceholder")}
|
||||||
|
emptyMessage={t("emptySearchOptions")}
|
||||||
|
label={t("enabled")}
|
||||||
|
className="p-3"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<InternalResourceEnabledForm
|
||||||
|
resource={row.original}
|
||||||
|
onToggleInternalResourceEnabled={
|
||||||
|
toggleInternalResourceEnabled
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "labels",
|
id: "labels",
|
||||||
accessorKey: "labels",
|
accessorKey: "labels",
|
||||||
@@ -643,3 +714,39 @@ function ClientResourceLabelCell({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type InternalResourceEnabledFormProps = {
|
||||||
|
resource: InternalResourceRow;
|
||||||
|
onToggleInternalResourceEnabled: (
|
||||||
|
val: boolean,
|
||||||
|
resourceId: number
|
||||||
|
) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function InternalResourceEnabledForm({
|
||||||
|
resource,
|
||||||
|
onToggleInternalResourceEnabled
|
||||||
|
}: InternalResourceEnabledFormProps) {
|
||||||
|
const [optimisticEnabled, setOptimisticEnabled] = useOptimistic(
|
||||||
|
resource.enabled
|
||||||
|
);
|
||||||
|
|
||||||
|
const formRef = useRef<ComponentRef<"form">>(null);
|
||||||
|
|
||||||
|
async function submitAction(formData: FormData) {
|
||||||
|
const newEnabled = !(formData.get("enabled") === "on");
|
||||||
|
setOptimisticEnabled(newEnabled);
|
||||||
|
await onToggleInternalResourceEnabled(newEnabled, resource.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={submitAction} ref={formRef}>
|
||||||
|
<Switch
|
||||||
|
checked={optimisticEnabled}
|
||||||
|
disabled={optimisticEnabled !== resource.enabled}
|
||||||
|
name="enabled"
|
||||||
|
onCheckedChange={() => formRef.current?.requestSubmit()}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -174,8 +174,8 @@ type OverviewRow = {
|
|||||||
type OverviewColumnProps = {
|
type OverviewColumnProps = {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
viewAllHref: string;
|
viewAllHref?: string;
|
||||||
viewAllLabel: string;
|
viewAllLabel?: string;
|
||||||
emptyLabel: string;
|
emptyLabel: string;
|
||||||
isForbidden: boolean;
|
isForbidden: boolean;
|
||||||
isFetching: boolean;
|
isFetching: boolean;
|
||||||
@@ -212,12 +212,14 @@ function OverviewColumn({
|
|||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{viewAllHref && viewAllLabel ? (
|
||||||
<Link
|
<Link
|
||||||
href={viewAllHref}
|
href={viewAllHref}
|
||||||
className="shrink-0 text-muted-foreground text-sm hover:underline"
|
className="shrink-0 text-muted-foreground text-sm hover:underline"
|
||||||
>
|
>
|
||||||
{viewAllLabel}
|
{viewAllLabel}
|
||||||
</Link>
|
</Link>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -319,6 +321,8 @@ type SiteResourcesOverviewProps = {
|
|||||||
initialPrivateForbidden: boolean;
|
initialPrivateForbidden: boolean;
|
||||||
/** When not under `/[orgId]/...` routes, pass org id explicitly (e.g. credenza on sites list). */
|
/** When not under `/[orgId]/...` routes, pass org id explicitly (e.g. credenza on sites list). */
|
||||||
orgIdOverride?: string;
|
orgIdOverride?: string;
|
||||||
|
/** When false, hides links to the org resources tables filtered by this site. */
|
||||||
|
showViewAllLinks?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SiteResourcesOverview({
|
export default function SiteResourcesOverview({
|
||||||
@@ -327,7 +331,8 @@ export default function SiteResourcesOverview({
|
|||||||
initialPrivateData,
|
initialPrivateData,
|
||||||
initialPublicForbidden,
|
initialPublicForbidden,
|
||||||
initialPrivateForbidden,
|
initialPrivateForbidden,
|
||||||
orgIdOverride
|
orgIdOverride,
|
||||||
|
showViewAllLinks = true
|
||||||
}: SiteResourcesOverviewProps) {
|
}: SiteResourcesOverviewProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const params = useParams<{ orgId: string }>();
|
const params = useParams<{ orgId: string }>();
|
||||||
@@ -467,8 +472,10 @@ export default function SiteResourcesOverview({
|
|||||||
key="public"
|
key="public"
|
||||||
title={t("siteResourcesSectionPublic")}
|
title={t("siteResourcesSectionPublic")}
|
||||||
description={t("siteResourcesSectionPublicDescription")}
|
description={t("siteResourcesSectionPublicDescription")}
|
||||||
viewAllHref={publicViewAllHref}
|
viewAllHref={showViewAllLinks ? publicViewAllHref : undefined}
|
||||||
viewAllLabel={t("siteResourcesViewAllPublic")}
|
viewAllLabel={
|
||||||
|
showViewAllLinks ? t("siteResourcesViewAllPublic") : undefined
|
||||||
|
}
|
||||||
emptyLabel={t("siteResourcesEmptyPublic")}
|
emptyLabel={t("siteResourcesEmptyPublic")}
|
||||||
isForbidden={publicForbidden}
|
isForbidden={publicForbidden}
|
||||||
isFetching={publicQuery.isFetching}
|
isFetching={publicQuery.isFetching}
|
||||||
@@ -484,8 +491,10 @@ export default function SiteResourcesOverview({
|
|||||||
key="private"
|
key="private"
|
||||||
title={t("siteResourcesSectionPrivate")}
|
title={t("siteResourcesSectionPrivate")}
|
||||||
description={t("siteResourcesSectionPrivateDescription")}
|
description={t("siteResourcesSectionPrivateDescription")}
|
||||||
viewAllHref={privateViewAllHref}
|
viewAllHref={showViewAllLinks ? privateViewAllHref : undefined}
|
||||||
viewAllLabel={t("siteResourcesViewAllPrivate")}
|
viewAllLabel={
|
||||||
|
showViewAllLinks ? t("siteResourcesViewAllPrivate") : undefined
|
||||||
|
}
|
||||||
emptyLabel={t("siteResourcesEmptyPrivate")}
|
emptyLabel={t("siteResourcesEmptyPrivate")}
|
||||||
isForbidden={privateForbidden}
|
isForbidden={privateForbidden}
|
||||||
isFetching={privateQuery.isFetching}
|
isFetching={privateQuery.isFetching}
|
||||||
|
|||||||
@@ -27,8 +27,6 @@ import UserProfileCard from "@app/components/UserProfileCard";
|
|||||||
import SecurityKeyAuthButton from "@app/components/SecurityKeyAuthButton";
|
import SecurityKeyAuthButton from "@app/components/SecurityKeyAuthButton";
|
||||||
import { Separator } from "@app/components/ui/separator";
|
import { Separator } from "@app/components/ui/separator";
|
||||||
import OrgSignInLink from "@app/components/OrgSignInLink";
|
import OrgSignInLink from "@app/components/OrgSignInLink";
|
||||||
import type { LoginFormIDP } from "./LoginForm";
|
|
||||||
import IdpLoginButtons from "./IdpLoginButtons";
|
|
||||||
|
|
||||||
const identifierSchema = z.object({
|
const identifierSchema = z.object({
|
||||||
identifier: z.string().min(1, "Username or email is required")
|
identifier: z.string().min(1, "Username or email is required")
|
||||||
@@ -55,7 +53,6 @@ type SmartLoginFormProps = {
|
|||||||
forceLogin?: boolean;
|
forceLogin?: boolean;
|
||||||
defaultUser?: string;
|
defaultUser?: string;
|
||||||
orgSignIn?: OrgSignInConfig;
|
orgSignIn?: OrgSignInConfig;
|
||||||
lastUsedIdp?: (LoginFormIDP & { orgId?: string }) | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type ViewState =
|
type ViewState =
|
||||||
@@ -92,8 +89,7 @@ export default function SmartLoginForm({
|
|||||||
redirect,
|
redirect,
|
||||||
forceLogin,
|
forceLogin,
|
||||||
defaultUser,
|
defaultUser,
|
||||||
orgSignIn,
|
orgSignIn
|
||||||
lastUsedIdp
|
|
||||||
}: SmartLoginFormProps) {
|
}: SmartLoginFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
@@ -298,15 +294,6 @@ export default function SmartLoginForm({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{lastUsedIdp && (
|
|
||||||
<IdpLoginButtons
|
|
||||||
idps={[lastUsedIdp]}
|
|
||||||
orgId={lastUsedIdp.orgId}
|
|
||||||
redirect={redirect}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<OrgSignInLink
|
<OrgSignInLink
|
||||||
href={orgSignIn.href}
|
href={orgSignIn.href}
|
||||||
linkText={orgSignIn.linkText}
|
linkText={orgSignIn.linkText}
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ import {
|
|||||||
} from "next/navigation";
|
} from "next/navigation";
|
||||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||||
import { Separator } from "@app/components/ui/separator";
|
import { Separator } from "@app/components/ui/separator";
|
||||||
import { setClientCookie } from "@app/lib/setClientCookie";
|
|
||||||
import { LAST_USED_IDP_COOKIE_NAME } from "@app/lib/consts";
|
|
||||||
|
|
||||||
type SmartLoginOrgSelectorProps = {
|
type SmartLoginOrgSelectorProps = {
|
||||||
identifier: string;
|
identifier: string;
|
||||||
@@ -143,17 +141,6 @@ export default function SmartLoginOrgSelector({
|
|||||||
setPendingIdpId(idpId);
|
setPendingIdpId(idpId);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
setClientCookie(
|
|
||||||
LAST_USED_IDP_COOKIE_NAME,
|
|
||||||
JSON.stringify({
|
|
||||||
orgId,
|
|
||||||
idpId
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
sameSite: "Lax"
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
let redirectToUrl: string | undefined;
|
let redirectToUrl: string | undefined;
|
||||||
try {
|
try {
|
||||||
const safeRedirect = cleanRedirect(redirect || "/");
|
const safeRedirect = cleanRedirect(redirect || "/");
|
||||||
|
|||||||
@@ -4,13 +4,9 @@ import { getCachedSubscription } from "./getCachedSubscription";
|
|||||||
import { priv } from ".";
|
import { priv } from ".";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { GetLicenseStatusResponse } from "@server/routers/license/types";
|
import { GetLicenseStatusResponse } from "@server/routers/license/types";
|
||||||
import { Tier } from "@server/types/Tiers";
|
|
||||||
|
|
||||||
const DEFAULT_PAID_TIERS: Tier[] = ["tier1", "tier2", "tier3", "enterprise"];
|
export const isOrgSubscribed = cache(async (orgId: string) => {
|
||||||
|
|
||||||
export const isOrgSubscribed = cache(async (orgId: string, tiers?: Tier[]) => {
|
|
||||||
let subscribed = false;
|
let subscribed = false;
|
||||||
const allowedTiers = tiers ?? DEFAULT_PAID_TIERS;
|
|
||||||
|
|
||||||
if (build === "enterprise") {
|
if (build === "enterprise") {
|
||||||
try {
|
try {
|
||||||
@@ -24,8 +20,7 @@ export const isOrgSubscribed = cache(async (orgId: string, tiers?: Tier[]) => {
|
|||||||
try {
|
try {
|
||||||
const subRes = await getCachedSubscription(orgId);
|
const subRes = await getCachedSubscription(orgId);
|
||||||
subscribed =
|
subscribed =
|
||||||
!!subRes.data.data.tier &&
|
(subRes.data.data.tier == "tier1" || subRes.data.data.tier == "tier2" || subRes.data.data.tier == "tier3" || subRes.data.data.tier == "enterprise") &&
|
||||||
allowedTiers.includes(subRes.data.data.tier as Tier) &&
|
|
||||||
subRes.data.data.active;
|
subRes.data.data.active;
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export const LAST_USED_IDP_COOKIE_NAME = "p__last_used_idp";
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { priv } from "@app/lib/api";
|
import { priv } from "@app/lib/api";
|
||||||
import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
|
import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
|
||||||
import { LoadLoginPageBrandingResponse } from "@server/routers/loginPage/types";
|
import { LoadLoginPageBrandingResponse } from "@server/routers/loginPage/types";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
|
|
||||||
@@ -12,10 +11,7 @@ export async function loadOrgLoginPageBranding(orgId: string): Promise<{
|
|||||||
return { primaryColor: null };
|
return { primaryColor: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscribed = await isOrgSubscribed(
|
const subscribed = await isOrgSubscribed(orgId);
|
||||||
orgId,
|
|
||||||
tierMatrix.loginPageBranding
|
|
||||||
);
|
|
||||||
if (!subscribed) {
|
if (!subscribed) {
|
||||||
return { primaryColor: null };
|
return { primaryColor: null };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,7 +165,6 @@ export function buildCreateSiteResourcePayload(
|
|||||||
siteIds: data.siteIds,
|
siteIds: data.siteIds,
|
||||||
mode: data.mode,
|
mode: data.mode,
|
||||||
destination: isNativeSsh ? undefined : (data.destination ?? undefined),
|
destination: isNativeSsh ? undefined : (data.destination ?? undefined),
|
||||||
enabled: true,
|
|
||||||
...(data.mode === "http" && {
|
...(data.mode === "http" && {
|
||||||
scheme: data.scheme,
|
scheme: data.scheme,
|
||||||
ssl: data.ssl ?? false,
|
ssl: data.ssl ?? false,
|
||||||
@@ -342,7 +341,8 @@ export function createGeneralFormSchema(t: TranslateFn) {
|
|||||||
.string()
|
.string()
|
||||||
.min(1)
|
.min(1)
|
||||||
.max(255)
|
.max(255)
|
||||||
.regex(/^[a-zA-Z0-9-]+$/)
|
.regex(/^[a-zA-Z0-9-]+$/),
|
||||||
|
enabled: z.boolean()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
/**
|
|
||||||
* Set a cookie on the client side in javascript code, not on the server
|
|
||||||
* @param name
|
|
||||||
* @param value
|
|
||||||
* @param days
|
|
||||||
* @param options
|
|
||||||
*/
|
|
||||||
export function setClientCookie(
|
|
||||||
name: string,
|
|
||||||
value: string,
|
|
||||||
options: {
|
|
||||||
days?: number;
|
|
||||||
path?: string;
|
|
||||||
secure?: boolean;
|
|
||||||
sameSite?: "Strict" | "Lax" | "None";
|
|
||||||
} = {}
|
|
||||||
): void {
|
|
||||||
let cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
|
|
||||||
|
|
||||||
if (options.days) {
|
|
||||||
const date = new Date();
|
|
||||||
date.setTime(date.getTime() + options.days * 864e5);
|
|
||||||
cookie += `; expires=${date.toUTCString()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
cookie += `; path=${options.path ?? "/"}`;
|
|
||||||
|
|
||||||
if (options.secure) cookie += "; Secure";
|
|
||||||
if (options.sameSite) cookie += `; SameSite=${options.sameSite}`;
|
|
||||||
|
|
||||||
document.cookie = cookie;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user