Compare commits

..

12 Commits

Author SHA1 Message Date
Milo Schwartz
bada1fdb97 Merge pull request #3394 from fosrl/feat/remember-last-idp-on-smart-login-form
feat: Remember last used idp in login forms
2026-07-10 16:34:22 -04:00
miloschwartz
d9303f87c8 set last used cookie in smart login lookup 2026-07-10 16:34:02 -04:00
miloschwartz
14680df160 suse tiermatrix in server component susbcribed check 2026-07-10 15:23:09 -04:00
miloschwartz
d38f9ac2bb update readme 2026-07-09 21:32:35 -04:00
Fred KISSIE
34d5c9535d ♻️ delete last used IDP coookie when logging in with email/password on smart login form 2026-07-10 03:22:28 +02:00
Fred KISSIE
dc60ef712f Merge branch 'dev' into feat/remember-last-idp-on-smart-login-form 2026-07-10 02:38:38 +02:00
Fred KISSIE
2fe85ebda2 ♻️ set last IDP to null if login with email + password 2026-07-08 03:11:12 +02:00
Fred KISSIE
3d13e9105c 🏷️ fix types 2026-07-03 22:33:58 +02:00
Fred KISSIE
289be30e6b show last used login idp in smart login form 2026-07-03 22:33:51 +02:00
Fred KISSIE
a74c0c227c 🌐 text 2026-07-03 21:05:53 +02:00
Fred KISSIE
05bf77da29 ♻️ remove cache on verifySession calls as it's already wrapped in cache 2026-07-03 21:05:47 +02:00
Fred KISSIE
bb4deb1ae9 remember last used idp in dashboard login form 2026-07-03 21:05:16 +02:00
55 changed files with 423 additions and 550 deletions

View File

@@ -41,7 +41,7 @@
</strong>
</p>
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.
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.
## Installation
@@ -63,11 +63,26 @@ 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.
* 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%" />
### Browser-based reverse proxy access
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 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 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%" />
@@ -75,14 +90,35 @@ Expose web applications through identity and context-aware tunneled reverse prox
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%" />
### 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%" />
### 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 the Pangolin client for your platform:

View File

@@ -1517,6 +1517,7 @@
"otpAuthDescription": "Enter the code from your authenticator app or one of your single-use backup codes.",
"otpAuthSubmit": "Submit Code",
"idpContinue": "Or continue with",
"idpLastUsed": "Last Used",
"otpAuthBack": "Back to Password",
"navbar": "Navigation Menu",
"navbarDescription": "Main navigation menu for the application",

Binary file not shown.

After

Width:  |  Height:  |  Size: 556 KiB

View File

@@ -200,10 +200,7 @@ export const resources = pgTable(
authDaemonMode: varchar("authDaemonMode", { length: 32 })
.$type<"site" | "remote" | "native">()
.default("site"),
authDaemonPort: integer("authDaemonPort").default(22123),
status: varchar("status")
.$type<"pending" | "approved">()
.default("approved")
authDaemonPort: integer("authDaemonPort").default(22123)
},
(t) => [
index("idx_resources_fulldomain")
@@ -454,10 +451,7 @@ export const siteResources = pgTable(
onDelete: "set null"
}),
subdomain: varchar("subdomain"),
fullDomain: varchar("fullDomain"),
status: varchar("status")
.$type<"pending" | "approved">()
.default("approved")
fullDomain: varchar("fullDomain")
},
(t) => [index("idx_siteresources_orgid_niceid").on(t.orgId, t.niceId)]
);

View File

@@ -209,8 +209,7 @@ export const resources = sqliteTable("resources", {
authDaemonMode: text("authDaemonMode")
.$type<"site" | "remote" | "native">()
.default("site"),
authDaemonPort: integer("authDaemonPort").default(22123),
status: text("status").$type<"pending" | "approved">().default("approved")
authDaemonPort: integer("authDaemonPort").default(22123)
});
export const labels = sqliteTable("labels", {
@@ -448,8 +447,7 @@ export const siteResources = sqliteTable("siteResources", {
onDelete: "set null"
}),
subdomain: text("subdomain"),
fullDomain: text("fullDomain"),
status: text("status").$type<"pending" | "approved">().default("approved")
fullDomain: text("fullDomain")
});
export const networks = sqliteTable("networks", {

View File

@@ -34,6 +34,12 @@ import {
rebuildClientAssociationsFromSiteResource,
waitForSiteResourceRebuildIdle
} 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 = {
orgId: string;

View File

@@ -26,6 +26,9 @@ import { createCertificate } from "#dynamic/routers/certificates/createCertifica
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "../billing/tierMatrix";
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";
@@ -198,19 +201,17 @@ export async function updatePrivateResources(
}
}
let resourceStatusFromSite: "approved" | "pending" = "approved";
if (siteId && allSites.length === 0) {
// only add if there are not provided sites
// Use the provided siteId directly, but verify it belongs to the org
const [siteSingle] = await trx
.select({ siteId: sites.siteId, status: sites.status })
.select({ siteId: sites.siteId })
.from(sites)
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
.limit(1);
if (siteSingle) {
allSites.push(siteSingle);
}
resourceStatusFromSite = siteSingle.status ?? "approved";
}
if (allSites.length === 0) {
@@ -219,13 +220,6 @@ export async function updatePrivateResources(
);
}
const resourceEnabled =
resourceData.enabled == undefined || resourceData.enabled == null
? true
: resourceStatusFromSite === "pending"
? false
: resourceData.enabled;
if (existingResource) {
let domainInfo:
| { subdomain: string | null; domainId: string }
@@ -249,7 +243,8 @@ export async function updatePrivateResources(
scheme: resourceData.scheme,
destination: resourceData.destination,
destinationPort: resourceData["destination-port"],
enabled: resourceEnabled,
enabled: true, // hardcoded for now
// enabled: resourceData.enabled ?? true,
alias: resourceData.alias || null,
disableIcmp:
resourceData["disable-icmp"] ||
@@ -268,8 +263,7 @@ export async function updatePrivateResources(
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
authDaemonMode:
resourceData["auth-daemon"]?.mode || "native",
authDaemonPort: resourceData["auth-daemon"]?.port || 22123,
status: resourceStatusFromSite
authDaemonPort: resourceData["auth-daemon"]?.port || 22123
})
.where(
eq(
@@ -502,7 +496,8 @@ export async function updatePrivateResources(
scheme: resourceData.scheme,
destination: resourceData.destination,
destinationPort: resourceData["destination-port"],
enabled: resourceEnabled,
enabled: true, // hardcoded for now
// enabled: resourceData.enabled ?? true,
alias: resourceData.alias || null,
aliasAddress: aliasAddress,
disableIcmp:
@@ -522,8 +517,7 @@ export async function updatePrivateResources(
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
authDaemonMode:
resourceData["auth-daemon"]?.mode || "native",
authDaemonPort: resourceData["auth-daemon"]?.port || 22123,
status: resourceStatusFromSite
authDaemonPort: resourceData["auth-daemon"]?.port || 22123
})
.returning();

View File

@@ -25,7 +25,6 @@ import {
rolePolicies,
roleResources,
roles,
Site,
sites,
Target,
TargetHealthCheck,
@@ -75,40 +74,19 @@ export async function updatePublicResources(
)) {
const targetsToUpdate: Target[] = [];
const healthchecksToUpdate: TargetHealthCheck[] = [];
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
resourceId: number,
targetData: TargetData
) {
const targetSiteId = targetData.site;
let site: Partial<Site> | undefined;
let site;
if (targetSiteId) {
// Look up site by niceId
[site] = await trx
.select({
siteId: sites.siteId,
type: sites.type,
status: sites.status
})
.select({ siteId: sites.siteId, type: sites.type })
.from(sites)
.where(
and(
@@ -117,9 +95,15 @@ export async function updatePublicResources(
)
)
.limit(1);
} else if (siteId && providedSite) {
} else if (siteId) {
// Use the provided siteId directly, but verify it belongs to the org
site = providedSite;
[site] = await trx
.select({ siteId: sites.siteId, type: sites.type })
.from(sites)
.where(
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
)
.limit(1);
} else {
throw new Error(`Target site is required`);
}
@@ -155,7 +139,7 @@ export async function updatePublicResources(
.insert(targets)
.values({
resourceId: resourceId,
siteId: site.siteId!,
siteId: site.siteId,
ip: targetData.hostname,
mode: resourceData.mode as Target["mode"],
method: targetData.method,
@@ -188,7 +172,7 @@ export async function updatePublicResources(
.insert(targetHealthCheck)
.values({
name: `${targetData.hostname}:${targetData.port}`,
siteId: site.siteId!,
siteId: site.siteId,
targetId: newTarget.targetId,
orgId: orgId,
hcEnabled: healthcheckData?.enabled || false,
@@ -246,10 +230,7 @@ export async function updatePublicResources(
const resourceEnabled =
resourceData.enabled == undefined || resourceData.enabled == null
? true
: resourceStatusFromSite === "pending"
? false
: resourceData.enabled;
: resourceData.enabled;
const resourceSsl =
resourceData.ssl == undefined || resourceData.ssl == null
? true
@@ -425,8 +406,7 @@ export async function updatePublicResources(
? (resourceData["proxy-protocol-version"] ??
1)
: 1,
resourcePolicyId: sharedPolicy.resourcePolicyId,
status: resourceStatusFromSite
resourcePolicyId: sharedPolicy.resourcePolicyId
})
.where(
eq(
@@ -610,8 +590,7 @@ export async function updatePublicResources(
authDaemonPort:
resourceData["auth-daemon"]?.port || 22123,
resourcePolicyId: null,
defaultResourcePolicyId: inlinePolicyId,
status: resourceStatusFromSite
defaultResourcePolicyId: inlinePolicyId
})
.where(
eq(
@@ -1152,7 +1131,6 @@ export async function updatePublicResources(
.values({
orgId,
niceId: resourceNiceId,
status: resourceStatusFromSite,
name: resourceData.name || "Unnamed Resource",
mode: resourceData.mode,
proxyPort: ["http", "ssh", "rdp", "vnc"].includes(

View File

@@ -470,7 +470,7 @@ export const PrivateResourceSchema = z
// proxyPort: z.int().positive().optional(),
"destination-port": z.int().positive().optional(),
destination: z.string().min(1).optional(),
enabled: z.boolean().default(true),
// enabled: z.boolean().default(true),
"tcp-ports": portRangeStringSchema.optional().default("*"),
"udp-ports": portRangeStringSchema.optional().default("*"),
"disable-icmp": z.boolean().optional().default(false),

View File

@@ -496,7 +496,6 @@ export function generateRemoteSubnets(
): string[] {
const remoteSubnets = allSiteResources
.filter((sr) => {
if (!sr.enabled) return false;
if (!sr.destination) return false;
if (sr.mode === "cidr") {
@@ -531,7 +530,6 @@ export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] {
return allSiteResources
.filter(
(sr) =>
sr.enabled &&
sr.aliasAddress &&
((sr.alias && (sr.mode == "host" || sr.mode == "ssh")) ||
(sr.fullDomain && sr.mode == "http"))
@@ -664,13 +662,6 @@ export async function generateSubnetProxyTargetV2(
subnet: string | null;
}[]
): Promise<SubnetProxyTargetV2[] | undefined> {
if (!siteResource.enabled) {
logger.debug(
`Site resource ${siteResource.siteResourceId} is disabled, skipping target generation.`
);
return;
}
if (clients.length === 0) {
logger.debug(
`No clients have access to site resource ${siteResource.siteResourceId}, skipping target generation.`

View File

@@ -1561,19 +1561,9 @@ export async function handleMessagingForUpdatedSiteResource(
updatedSiteResource.udpPortRangeString ||
existingSiteResource.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(
`handleMessagingForUpdatedSiteResource: change flags destinationChanged=${Boolean(destinationChanged)} destinationPortChanged=${Boolean(destinationPortChanged)} aliasChanged=${Boolean(aliasChanged)} fullDomainChanged=${Boolean(fullDomainChanged)} sslChanged=${Boolean(sslChanged)} portRangesChanged=${Boolean(portRangesChanged)} enabledChanged=${Boolean(enabledChanged)}`
`handleMessagingForUpdatedSiteResource: change flags destinationChanged=${Boolean(destinationChanged)} destinationPortChanged=${Boolean(destinationPortChanged)} aliasChanged=${Boolean(aliasChanged)} fullDomainChanged=${Boolean(fullDomainChanged)} sslChanged=${Boolean(sslChanged)} portRangesChanged=${Boolean(portRangesChanged)}`
);
// if the existingSiteResource is undefined (new resource) we don't need to do anything here, the rebuild above handled it all
@@ -1584,16 +1574,14 @@ export async function handleMessagingForUpdatedSiteResource(
fullDomainChanged ||
sslChanged ||
portRangesChanged ||
destinationPortChanged ||
enabledChanged
destinationPortChanged
) {
const shouldUpdateTargets =
destinationChanged ||
sslChanged ||
portRangesChanged ||
fullDomainChanged ||
destinationPortChanged ||
enabledChanged;
destinationPortChanged;
logger.debug(
`handleMessagingForUpdatedSiteResource: entering unchanged-site update path shouldUpdateTargets=${shouldUpdateTargets}`
@@ -1669,22 +1657,20 @@ export async function handleMessagingForUpdatedSiteResource(
peerDataUpdateBatch.push({
clientId: client.clientId,
siteId,
remoteSubnets:
destinationChanged || enabledChanged
? {
oldRemoteSubnets:
!oldDestinationStillInUseBySite
? generateRemoteSubnets([
existingSiteResource
])
: [],
newRemoteSubnets: generateRemoteSubnets([
updatedSiteResource
])
}
: undefined,
remoteSubnets: destinationChanged
? {
oldRemoteSubnets: !oldDestinationStillInUseBySite
? generateRemoteSubnets([
existingSiteResource
])
: [],
newRemoteSubnets: generateRemoteSubnets([
updatedSiteResource
])
}
: undefined,
aliases:
aliasChanged || fullDomainChanged || enabledChanged // the full domain is sent down as an alias
aliasChanged || fullDomainChanged // the full domain is sent down as an alias
? {
oldAliases: generateAliasConfig([
existingSiteResource

View File

@@ -157,8 +157,7 @@ async function resolveAccessibleIdsUncached(
.where(
and(
eq(userResources.userId, userId),
eq(resources.orgId, orgId),
eq(resources.status, "approved")
eq(resources.orgId, orgId)
)
),
userRoleIds.length > 0
@@ -172,8 +171,7 @@ async function resolveAccessibleIdsUncached(
.where(
and(
inArray(roleResources.roleId, userRoleIds),
eq(resources.orgId, orgId),
eq(resources.status, "approved")
eq(resources.orgId, orgId)
)
)
: Promise.resolve([]),
@@ -185,11 +183,7 @@ async function resolveAccessibleIdsUncached(
eq(effectiveResourcePolicyId, userPolicies.resourcePolicyId)
)
.where(
and(
eq(userPolicies.userId, userId),
eq(resources.orgId, orgId),
eq(resources.status, "approved")
)
and(eq(userPolicies.userId, userId), eq(resources.orgId, orgId))
),
userRoleIds.length > 0
? db
@@ -205,48 +199,21 @@ async function resolveAccessibleIdsUncached(
.where(
and(
inArray(rolePolicies.roleId, userRoleIds),
eq(resources.orgId, orgId),
eq(resources.status, "approved")
eq(resources.orgId, orgId)
)
)
: Promise.resolve([]),
db
.select({ siteResourceId: userSiteResources.siteResourceId })
.from(userSiteResources)
.innerJoin(
siteResources,
eq(
userSiteResources.siteResourceId,
siteResources.siteResourceId
)
)
.where(
and(
eq(userSiteResources.userId, userId),
eq(siteResources.orgId, orgId),
eq(siteResources.status, "approved")
)
),
.where(eq(userSiteResources.userId, userId)),
userRoleIds.length > 0
? db
.select({
siteResourceId: roleSiteResources.siteResourceId
})
.from(roleSiteResources)
.innerJoin(
siteResources,
eq(
roleSiteResources.siteResourceId,
siteResources.siteResourceId
)
)
.where(
and(
inArray(roleSiteResources.roleId, userRoleIds),
eq(siteResources.orgId, orgId),
eq(siteResources.status, "approved")
)
)
.where(inArray(roleSiteResources.roleId, userRoleIds))
: Promise.resolve([])
]);
@@ -398,7 +365,6 @@ async function filterPublicResourceIdsByTextSearch(
inArray(resources.resourceId, resourceIds),
eq(resources.orgId, orgId),
eq(resources.enabled, true),
eq(resources.status, "approved"),
textMatch
)
);
@@ -436,7 +402,6 @@ async function filterSiteResourceIdsByTextSearch(
inArray(siteResources.siteResourceId, siteResourceIds),
eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true),
eq(siteResources.status, "approved"),
textMatch
)
);
@@ -538,8 +503,7 @@ async function listSiteGroups(
const publicConditions = [
inArray(resources.resourceId, accessible.resourceIds),
eq(resources.orgId, orgId),
eq(resources.enabled, true),
eq(resources.status, "approved")
eq(resources.enabled, true)
];
if (searchPublic) {
publicConditions.push(searchPublic);
@@ -594,8 +558,7 @@ async function listSiteGroups(
const siteConditions = [
inArray(siteResources.siteResourceId, accessible.siteResourceIds),
eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true),
eq(siteResources.status, "approved")
eq(siteResources.enabled, true)
];
if (searchSite) {
siteConditions.push(searchSite);
@@ -658,8 +621,7 @@ async function listSiteGroups(
const noSitePublicConditions = [
inArray(resources.resourceId, accessible.resourceIds),
eq(resources.orgId, orgId),
eq(resources.enabled, true),
eq(resources.status, "approved")
eq(resources.enabled, true)
];
if (searchPublic) {
noSitePublicConditions.push(searchPublic);
@@ -693,8 +655,7 @@ async function listSiteGroups(
const noSiteSiteConditions = [
inArray(siteResources.siteResourceId, accessible.siteResourceIds),
eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true),
eq(siteResources.status, "approved")
eq(siteResources.enabled, true)
];
if (searchSite) {
noSiteSiteConditions.push(searchSite);
@@ -785,8 +746,7 @@ async function listLabelGroups(
const publicConditions = [
inArray(resources.resourceId, accessible.resourceIds),
eq(resources.orgId, orgId),
eq(resources.enabled, true),
eq(resources.status, "approved")
eq(resources.enabled, true)
];
const searchPublic = buildSearchConditionForPublic(query.query);
if (searchPublic) {
@@ -850,8 +810,7 @@ async function listLabelGroups(
const siteConditions = [
inArray(siteResources.siteResourceId, accessible.siteResourceIds),
eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true),
eq(siteResources.status, "approved")
eq(siteResources.enabled, true)
];
const searchSite = buildSearchConditionForSiteResource(query.query);
if (searchSite) {
@@ -1038,7 +997,6 @@ async function mapPublicResources(
inArray(resources.resourceId, resourceIds),
eq(resources.orgId, orgId),
eq(resources.enabled, true),
eq(resources.status, "approved"),
siteIdFilter != null
? eq(sites.siteId, siteIdFilter)
: undefined
@@ -1130,7 +1088,6 @@ async function mapSiteResources(
inArray(siteResources.siteResourceId, siteResourceIds),
eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true),
eq(siteResources.status, "approved"),
siteIdFilter != null
? eq(sites.siteId, siteIdFilter)
: undefined
@@ -1425,8 +1382,7 @@ async function collectAccessibleSites(
const publicConditions = [
inArray(resources.resourceId, accessible.resourceIds),
eq(resources.orgId, orgId),
eq(resources.enabled, true),
eq(resources.status, "approved")
eq(resources.enabled, true)
];
if (siteNameSearch) {
publicConditions.push(siteNameSearch);
@@ -1466,8 +1422,7 @@ async function collectAccessibleSites(
const siteConditions = [
inArray(siteResources.siteResourceId, accessible.siteResourceIds),
eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true),
eq(siteResources.status, "approved")
eq(siteResources.enabled, true)
];
if (siteNameSearch) {
siteConditions.push(siteNameSearch);
@@ -1521,7 +1476,6 @@ async function collectAccessibleLabels(
inArray(resources.resourceId, accessible.resourceIds),
eq(resources.orgId, orgId),
eq(resources.enabled, true),
eq(resources.status, "approved"),
eq(labels.orgId, orgId)
];
if (labelNameSearch) {
@@ -1557,7 +1511,6 @@ async function collectAccessibleLabels(
inArray(siteResources.siteResourceId, accessible.siteResourceIds),
eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true),
eq(siteResources.status, "approved"),
eq(labels.orgId, orgId)
];
if (labelNameSearch) {

View File

@@ -148,12 +148,7 @@ export async function buildClientConfigurationForNewtClient(
.from(siteResources)
.innerJoin(networks, eq(siteResources.networkId, networks.networkId))
.innerJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId))
.where(
and(
eq(siteNetworks.siteId, siteId),
eq(siteResources.enabled, true)
)
)
.where(eq(siteNetworks.siteId, siteId))
.then((rows) => rows.map((r) => r.siteResources));
const targetsToSend: SubnetProxyTargetV2[] = [];

View File

@@ -16,7 +16,7 @@ import {
generateRemoteSubnets
} from "@server/lib/ip";
import logger from "@server/logger";
import { and, eq, inArray } from "drizzle-orm";
import { eq, inArray } from "drizzle-orm";
import { addPeer, deletePeer } from "../newt/peers";
import config from "@server/lib/config";
@@ -70,13 +70,7 @@ export async function buildSiteConfigurationForOlmClient(
.innerJoin(networks, eq(siteResources.networkId, networks.networkId))
.innerJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId))
.where(
and(
eq(
clientSiteResourcesAssociationsCache.clientId,
client.clientId
),
eq(siteResources.enabled, true)
)
eq(clientSiteResourcesAssociationsCache.clientId, client.clientId)
);
const siteResourcesBySiteId = new Map<number, SiteResource[]>();

View File

@@ -138,15 +138,6 @@ const listResourcesSchema = z.strictObject({
description:
"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
.preprocess((val) => {
if (val === undefined || val === null || val === "") {
@@ -460,7 +451,6 @@ export async function listResources(
sort_by,
order,
siteId,
status,
labels: labelFilter
} = parsedQuery.data;
@@ -670,10 +660,6 @@ export async function listResources(
}
}
if (typeof status !== "undefined") {
conditions.push(eq(resources.status, status));
}
if (siteId != null) {
const resourcesWithSite = db
.select({ resourceId: targets.resourceId })

View File

@@ -45,19 +45,18 @@ function userResourceAliasesCacheKey(
page: number,
pageSize: number,
includeLabels: boolean,
labelFilter: string[],
status?: "pending" | "approved"
labelFilter: string[]
) {
const labelsKey =
labelFilter.length > 0 ? labelFilter.slice().sort().join(",") : "all";
return `userResourceAliases:${orgId}:${userId}:${page}:${pageSize}:${includeLabels ? "labels" : "plain"}:${labelsKey}:${status ?? "all"}`;
return `userResourceAliases:${orgId}:${userId}:${page}:${pageSize}:${includeLabels ? "labels" : "plain"}:${labelsKey}`;
}
const listUserResourceAliasesParamsSchema = z.strictObject({
orgId: z.string()
});
const listUserResourceAliasesQuerySchema = z.object({
const listUserResourceAliasesQuerySchema = z.strictObject({
pageSize: z.coerce
.number<string>()
.int()
@@ -97,16 +96,7 @@ const listUserResourceAliasesQuerySchema = z.object({
type: "array",
description:
"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"
})
})
});
export type UserResourceAliasItem = {
@@ -140,8 +130,7 @@ export async function listUserResourceAliases(
page,
pageSize,
includeLabels,
labels: labelFilter,
status
labels: labelFilter
} = parsedQuery.data;
const parsedParams = listUserResourceAliasesParamsSchema.safeParse(
@@ -183,8 +172,7 @@ export async function listUserResourceAliases(
page,
pageSize,
includeLabels,
labelFilter ?? [],
status
labelFilter ?? []
);
const cachedData: ListUserResourceAliasesResponse | undefined =
await cache.get(cacheKey);
@@ -269,10 +257,6 @@ export async function listUserResourceAliases(
inArray(siteResources.siteResourceId, accessibleSiteResourceIds)
];
if (typeof status !== "undefined") {
whereConditions.push(eq(siteResources.status, status));
}
if (labelFilter && labelFilter.length > 0) {
whereConditions.push(
inArray(

View File

@@ -56,6 +56,7 @@ const createSiteResourceSchema = z
siteId: z.number().int().positive().optional(), // DEPRECATED: for backward compatibility, we will convert this to siteIds array if provided
destinationPort: z.int().positive().optional(),
destination: z.string().min(1).nullish(),
enabled: z.boolean().default(true),
alias: z
.string()
.regex(
@@ -274,6 +275,7 @@ export async function createSiteResource(
scheme,
destinationPort,
destination,
enabled,
ssl,
alias,
userIds,
@@ -537,6 +539,7 @@ export async function createSiteResource(
destination: destination, // the ssh can be null
scheme,
destinationPort,
enabled,
alias: alias ? alias.trim() : null,
aliasAddress,
tcpPortRangeString: tcpPortRangeStringAdjusted,

View File

@@ -86,15 +86,6 @@ const listAllSiteResourcesByOrgQuerySchema = z.strictObject({
description:
"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
.preprocess((val) => {
if (val === undefined || val === null || val === "") {
@@ -292,7 +283,6 @@ export async function listAllSiteResourcesByOrg(
sort_by,
order,
siteId,
status,
labels: labelFilter
} = parsedQuery.data;
@@ -325,10 +315,6 @@ export async function listAllSiteResourcesByOrg(
conditions.push(eq(siteResources.mode, mode));
}
if (typeof status !== "undefined") {
conditions.push(eq(siteResources.status, status));
}
if (labelFilter && labelFilter.length > 0) {
conditions.push(
inArray(

View File

@@ -47,15 +47,6 @@ const listSiteResourcesQuerySchema = z.strictObject({
enum: ["asc", "desc"],
default: "asc",
description: "Sort order"
}),
status: z
.enum(["pending", "approved"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["pending", "approved"],
description: "Filter by site resource status"
})
});
@@ -119,7 +110,7 @@ export async function listSiteResources(
}
const { siteId, orgId } = parsedParams.data;
const { limit, offset, sort_by, order, status } = parsedQuery.data;
const { limit, offset, sort_by, order } = parsedQuery.data;
// Verify the site exists and belongs to the org
const site = await db
@@ -133,15 +124,6 @@ export async function listSiteResources(
}
// 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
.select()
.from(siteNetworks)
@@ -150,7 +132,12 @@ export async function listSiteResources(
siteResources,
eq(siteResources.networkId, networks.networkId)
)
.where(and(...conditions))
.where(
and(
eq(siteNetworks.siteId, siteId),
eq(siteResources.orgId, orgId)
)
)
.orderBy(
sort_by
? order === "asc"

View File

@@ -152,11 +152,6 @@ const updateSiteResourceSchema = z
)
.refine(
(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
if (data.mode === "ssh" && data.authDaemonMode === "native") {
return true;
@@ -416,10 +411,8 @@ export async function updateSiteResource(
: [];
const existingSiteIds = existingSiteNetworks.map((sn) => sn.siteId);
// undefined means "leave unchanged" (partial update); only nulled out
// when the mode is explicitly being changed away from http
let fullDomain: string | null | undefined = undefined;
let finalSubdomain: string | null | undefined = undefined;
let fullDomain: string | null = null;
let finalSubdomain: string | null = null;
if (domainId) {
// Validate domain and construct full domain
const domainResult = await validateAndConstructDomain(
@@ -455,11 +448,6 @@ 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
@@ -528,28 +516,15 @@ export async function updateSiteResource(
destination: destination,
destinationPort: destinationPort,
enabled: enabled,
alias:
alias !== undefined
? alias
? alias.trim()
: null
: mode !== undefined &&
mode !== "host" &&
mode !== "ssh"
? null
: undefined,
alias: alias ? alias.trim() : null,
tcpPortRangeString: tcpPortRangeStringAdjusted,
udpPortRangeString:
mode == "http" || mode == "ssh"
? ""
: udpPortRangeString,
disableIcmp:
mode !== undefined
? disableIcmp ||
(mode == "http" || mode == "ssh"
? true
: false)
: disableIcmp,
disableIcmp ||
(mode == "http" || mode == "ssh" ? true : false),
domainId,
subdomain: finalSubdomain,
fullDomain,

View File

@@ -23,7 +23,7 @@ import {
import { ChevronsUpDown } from "lucide-react";
import { useTranslations } from "next-intl";
import type { Control, FieldPath, FieldValues } from "react-hook-form";
import { PrivateResourceMultiSiteRoutingHelp } from "@app/components/PrivateResourceMultiSiteRoutingHelp";
import { PrivateResourceMultiSiteRoutingHelp } from "./PrivateResourceMultiSiteRoutingHelp";
type PrivateResourceSitesFieldProps<T extends FieldValues> = {
control: Control<T>;

View File

@@ -9,10 +9,10 @@ import {
} from "@app/components/Settings";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { SshServerSettingsFields } from "@app/components/SshServerSettingsFields";
import { PrivateResourceAliasField } from "@app/components/PrivateResourceDestinationFields";
import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
import { PrivateResourceAliasField } from "./PrivateResourceDestinationFields";
import { PrivateResourceSitesField } from "./PrivateResourceSitesField";
import { getSshUseMultiSiteTargetForm } from "./privateResourceUtils";
import { inferSshPamMode } from "@app/lib/privateResourceForm";
import { getSshUseMultiSiteTargetForm } from "@app/lib/privateResourceUtils";
import {
FormControl,
FormField,

View File

@@ -29,7 +29,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useActionState, useEffect } from "react";
import { useForm } from "react-hook-form";
import { PrivateResourceAccessFields } from "@app/components/PrivateResourceAccessFields";
import { PrivateResourceAccessFields } from "../../PrivateResourceAccessFields";
export default function PrivateResourceAccessPage() {
const t = useTranslations();

View File

@@ -20,12 +20,12 @@ import { useTranslations } from "next-intl";
import { useActionState, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
import { PrivateResourceCidrDestinationField } from "@app/components/PrivateResourceDestinationFields";
import { PrivateResourcePortRanges } from "@app/components/PrivateResourcePortRanges";
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
import { asAnyControl, asAnySetValue } from "@app/lib/formControlUtils";
import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
import { PrivateResourceSitesField } from "../../PrivateResourceSitesField";
import { PrivateResourceCidrDestinationField } from "../../PrivateResourceDestinationFields";
import { PrivateResourcePortRanges } from "../../PrivateResourcePortRanges";
import { buildSelectedSitesForResource } from "../../privateResourceUtils";
import { asAnyControl, asAnySetValue } from "../../formControlUtils";
import { useSaveSiteResource } from "../../useSaveSiteResource";
export default function PrivateResourceCidrPage() {
const t = useTranslations();

View File

@@ -16,21 +16,19 @@ import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { SwitchInput } from "@app/components/SwitchInput";
import { createGeneralFormSchema } from "@app/lib/privateResourceForm";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import { useActionState, useMemo } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
import { useSaveSiteResource } from "../../useSaveSiteResource";
export default function PrivateResourceGeneralPage() {
const t = useTranslations();
@@ -43,8 +41,7 @@ export default function PrivateResourceGeneralPage() {
resolver: zodResolver(formSchema),
defaultValues: {
name: siteResource.name,
niceId: siteResource.niceId,
enabled: siteResource.enabled
niceId: siteResource.niceId
}
});
@@ -55,8 +52,7 @@ export default function PrivateResourceGeneralPage() {
const data = form.getValues();
await save({
name: data.name,
niceId: data.niceId,
enabled: data.enabled
niceId: data.niceId
});
}, null);
@@ -80,42 +76,6 @@ export default function PrivateResourceGeneralPage() {
id="private-resource-general-form"
>
<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">
<FormField
control={form.control}

View File

@@ -20,16 +20,16 @@ import { useTranslations } from "next-intl";
import { useActionState, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
import { PrivateResourceHostDestinationFields } from "@app/components/PrivateResourceDestinationFields";
import { PrivateResourcePortRanges } from "@app/components/PrivateResourcePortRanges";
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
import { PrivateResourceSitesField } from "../../PrivateResourceSitesField";
import { PrivateResourceHostDestinationFields } from "../../PrivateResourceDestinationFields";
import { PrivateResourcePortRanges } from "../../PrivateResourcePortRanges";
import { buildSelectedSitesForResource } from "../../privateResourceUtils";
import {
asAnyControl,
asAnySetValue,
asAnyWatch
} from "@app/lib/formControlUtils";
import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
} from "../../formControlUtils";
import { useSaveSiteResource } from "../../useSaveSiteResource";
export default function PrivateResourceHostPage() {
const t = useTranslations();

View File

@@ -22,15 +22,15 @@ import { useTranslations } from "next-intl";
import { useActionState, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
import { PrivateResourceHttpFields } from "@app/components/PrivateResourceHttpFields";
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
import { PrivateResourceSitesField } from "../../PrivateResourceSitesField";
import { PrivateResourceHttpFields } from "../../PrivateResourceHttpFields";
import { buildSelectedSitesForResource } from "../../privateResourceUtils";
import {
asAnyControl,
asAnySetValue,
asAnyWatch
} from "@app/lib/formControlUtils";
import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
} from "../../formControlUtils";
import { useSaveSiteResource } from "../../useSaveSiteResource";
export default function PrivateResourceHttpPage() {
const t = useTranslations();

View File

@@ -26,15 +26,15 @@ import { useTranslations } from "next-intl";
import { useActionState, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { PrivateResourceSshFields } from "@app/components/PrivateResourceSshFields";
import type { Selectedsite } from "@app/components/site-selector";
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
import { PrivateResourceSshFields } from "../../PrivateResourceSshFields";
import { buildSelectedSitesForResource } from "../../privateResourceUtils";
import {
asAnyControl,
asAnySetValue,
asAnyWatch
} from "@app/lib/formControlUtils";
import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
} from "../../formControlUtils";
import { useSaveSiteResource } from "../../useSaveSiteResource";
import type { Selectedsite } from "@app/components/site-selector";
export default function PrivateResourceSshPage() {
const t = useTranslations();

View File

@@ -50,20 +50,16 @@ import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState, useTransition } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
import { PrivateResourceHttpFields } from "@app/components/PrivateResourceHttpFields";
import { PrivateResourceSshFields } from "@app/components/PrivateResourceSshFields";
import { PrivateResourcePortRanges } from "@app/components/PrivateResourcePortRanges";
import { PrivateResourceSitesField } from "../PrivateResourceSitesField";
import { PrivateResourceHttpFields } from "../PrivateResourceHttpFields";
import { PrivateResourceSshFields } from "../PrivateResourceSshFields";
import { PrivateResourcePortRanges } from "../PrivateResourcePortRanges";
import {
PrivateResourceAliasField,
PrivateResourceCidrDestinationField,
PrivateResourceHostDestinationFields
} from "@app/components/PrivateResourceDestinationFields";
import {
asAnyControl,
asAnySetValue,
asAnyWatch
} from "@app/lib/formControlUtils";
} from "../PrivateResourceDestinationFields";
import { asAnyControl, asAnySetValue, asAnyWatch } from "../formControlUtils";
export default function CreatePrivateResourcePage() {
const params = useParams();

View File

@@ -27,7 +27,6 @@ export default async function ClientResourcesPage(
const params = await props.params;
const t = await getTranslations();
const searchParams = new URLSearchParams(await props.searchParams);
searchParams.set("status", "approved");
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
let pagination: ListAllSiteResourcesByOrgResponse["pagination"] = {

View File

@@ -38,7 +38,6 @@ export default async function ProxyResourcesPage(
const params = await props.params;
const t = await getTranslations();
const searchParams = new URLSearchParams(await props.searchParams);
searchParams.set("status", "approved");
let resources: ListResourcesResponse["resources"] = [];
let pagination: ListResourcesResponse["pagination"] = {

View File

@@ -16,8 +16,11 @@ import LoginCardHeader from "@app/components/LoginCardHeader";
import { priv } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { LoginFormIDP } from "@app/components/LoginForm";
import { ListIdpsResponse } from "@server/routers/idp";
import { ListIdpsResponse, type GetIdpResponse } from "@server/routers/idp";
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 = {
title: "Log In"
@@ -29,8 +32,9 @@ export default async function Page(props: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
const searchParams = await props.searchParams;
const getUser = cache(verifySession);
const user = await getUser({ skipCheckVerifyEmail: true });
const user = await verifySession({ skipCheckVerifyEmail: true });
const lastUsedIdpCookie = (await cookies()).get(LAST_USED_IDP_COOKIE_NAME);
const isInvite = searchParams?.redirect?.includes("/invite");
const forceLoginParam = searchParams?.forceLogin;
@@ -85,19 +89,47 @@ export default async function Page(props: {
(build === "enterprise" && env.app.identityProviderMode === "org");
let loginIdps: LoginFormIDP[] = [];
let lastUsedIdpForSmartLogin: (LoginFormIDP & { orgId?: string }) | null =
null;
if (!useSmartLogin) {
// Load IdPs for DashboardLoginForm (OSS or org-only IdP mode)
if (build === "oss" || env.app.identityProviderMode !== "org") {
const idpsRes = await cache(
async () =>
await priv.get<AxiosResponse<ListIdpsResponse>>("/idp")
)();
const idpsRes =
await priv.get<AxiosResponse<ListIdpsResponse>>("/idp");
loginIdps = idpsRes.data.data.idps.map((idp) => ({
idpId: idp.idpId,
name: idp.name,
variant: idp.type
})) 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();
@@ -160,6 +192,7 @@ export default async function Page(props: {
redirect={redirectUrl}
forceLogin={forceLogin}
defaultUser={defaultUser}
lastUsedIdp={lastUsedIdpForSmartLogin}
orgSignIn={
!isInvite &&
(build === "saas" ||

View File

@@ -13,6 +13,8 @@ import { redirect } from "next/navigation";
import OrgLoginPage from "@app/components/OrgLoginPage";
import { pullEnv } from "@app/lib/pullEnv";
import type { Metadata } from "next";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
export const metadata: Metadata = {
title: "Organization Login"
@@ -68,15 +70,22 @@ export default async function OrgAuthPage(props: {
variant: idp.variant
})) as LoginFormIDP[];
const hasLoginPageBranding = await isOrgSubscribed(
orgId,
tierMatrix.loginPageBranding
);
let branding: LoadLoginPageBrandingResponse | null = null;
try {
const res = await priv.get<
AxiosResponse<LoadLoginPageBrandingResponse>
>(`/login-page-branding?orgId=${orgId}`);
if (res.status === 200) {
branding = res.data.data;
}
} catch (error) {}
if (hasLoginPageBranding) {
try {
const res = await priv.get<
AxiosResponse<LoadLoginPageBrandingResponse>
>(`/login-page-branding?orgId=${orgId}`);
if (res.status === 200) {
branding = res.data.data;
}
} catch (error) {}
}
return (
<OrgLoginPage

View File

@@ -19,6 +19,7 @@ import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
import { OrgSelectionForm } from "@app/components/OrgSelectionForm";
import OrgLoginPage from "@app/components/OrgLoginPage";
import type { Metadata } from "next";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
export const metadata: Metadata = {
title: "Choose Organization"
@@ -83,7 +84,10 @@ export default async function OrgAuthPage(props: {
redirect(env.app.dashboardUrl);
}
const subscribed = await isOrgSubscribed(loginPage.orgId);
const subscribed = await isOrgSubscribed(
loginPage.orgId,
tierMatrix.loginPageDomain
);
if (build === "saas" && !subscribed) {
console.log(

View File

@@ -27,6 +27,7 @@ import { CheckOrgUserAccessResponse } from "@server/routers/org";
import OrgPolicyRequired from "@app/components/OrgPolicyRequired";
import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
import { normalizePostAuthPath } from "@server/lib/normalizePostAuthPath";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import type { Metadata } from "next";
export const metadata: Metadata = {
@@ -70,14 +71,25 @@ export default async function ResourceAuthPage(props: {
);
}
const subscribed = await isOrgSubscribed(authInfo.orgId);
const hasLoginPageDomain = await isOrgSubscribed(
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 host = allHeaders.get("host");
const expectedHost = env.app.dashboardUrl.split("//")[1];
if (host !== expectedHost) {
if (build === "saas" && !subscribed) {
if (build === "saas" && !hasLoginPageDomain) {
redirect(env.app.dashboardUrl);
}
@@ -106,7 +118,10 @@ export default async function ResourceAuthPage(props: {
const redirectPort = new URL(searchParams.redirect).port;
const serverResourceHostWithPort = `${serverResourceHost}:${redirectPort}`;
const wildcardMatchesRedirect = (wildcardDomain: string, host: string): boolean => {
const wildcardMatchesRedirect = (
wildcardDomain: string,
host: string
): boolean => {
if (!wildcardDomain.startsWith("*.")) return false;
const suffix = wildcardDomain.slice(1); // e.g. ".wildcard.owen.fosrl.io"
return host.endsWith(suffix) && host.length > suffix.length;
@@ -228,7 +243,7 @@ export default async function ResourceAuthPage(props: {
let loginIdps: LoginFormIDP[] = [];
if (build === "saas" || env.app.identityProviderMode === "org") {
if (subscribed) {
if (hasOrgOidc) {
const idpsRes = await cache(
async () =>
await priv.get<AxiosResponse<ListOrgIdpsResponse>>(
@@ -271,7 +286,7 @@ export default async function ResourceAuthPage(props: {
let branding: LoadLoginPageBrandingResponse | null = null;
try {
if (subscribed) {
if (hasLoginPageBranding) {
const res = await priv.get<
AxiosResponse<LoadLoginPageBrandingResponse>
>(`/login-page-branding?orgId=${authInfo.orgId}`);

View File

@@ -5,7 +5,6 @@ import UserProvider from "@app/providers/UserProvider";
import { ListUserOrgsResponse } from "@server/routers/org";
import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
import { cache } from "react";
import OrganizationLanding from "@app/components/OrganizationLanding";
import { pullEnv } from "@app/lib/pullEnv";
import { cleanRedirect } from "@app/lib/cleanRedirect";
@@ -13,7 +12,6 @@ import { Layout } from "@app/components/Layout";
import RedirectToOrg from "@app/components/RedirectToOrg";
import { InitialSetupCompleteResponse } from "@server/routers/auth";
import { cookies } from "next/headers";
import { build } from "@server/build";
export const dynamic = "force-dynamic";
@@ -29,8 +27,7 @@ export default async function Page(props: {
const env = pullEnv();
const getUser = cache(verifySession);
const user = await getUser({ skipCheckVerifyEmail: true });
const user = await verifySession({ skipCheckVerifyEmail: true });
let complete = false;
try {

View File

@@ -1,26 +1,25 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@app/components/ui/button";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { useTranslations } from "next-intl";
import { generateOidcUrlProxy } from "@app/actions/server";
import IdpTypeIcon from "@app/components/IdpTypeIcon";
import {
generateOidcUrlProxy,
type GenerateOidcUrlResponse
} from "@app/actions/server";
import { Alert, AlertDescription } from "@app/components/ui/alert";
import { Button } from "@app/components/ui/button";
import { cleanRedirect } from "@app/lib/cleanRedirect";
import { LAST_USED_IDP_COOKIE_NAME } from "@app/lib/consts";
import { setClientCookie } from "@app/lib/setClientCookie";
import { useTranslations } from "next-intl";
import {
redirect as redirectTo,
useParams,
useRouter,
useSearchParams
} from "next/navigation";
import { useRouter } from "next/navigation";
import { cleanRedirect } from "@app/lib/cleanRedirect";
import { useEffect, useState, useTransition } from "react";
export type LoginFormIDP = {
idpId: number;
name: string;
variant?: string;
lastUsed?: boolean;
};
type IdpLoginButtonsProps = {
@@ -35,7 +34,6 @@ export default function IdpLoginButtons({
orgId
}: IdpLoginButtonsProps) {
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const t = useTranslations();
const params = useSearchParams();
@@ -52,10 +50,22 @@ export default function IdpLoginButtons({
}
}, []);
const [loading, startTransition] = useTransition();
async function loginWithIdp(idpId: number) {
setLoading(true);
setError(null);
setClientCookie(
LAST_USED_IDP_COOKIE_NAME,
JSON.stringify({
orgId,
idpId
}),
{
sameSite: "Lax"
}
);
let redirectToUrl: string | undefined;
try {
console.log("generating", idpId, redirect || "/", orgId);
@@ -68,7 +78,6 @@ export default function IdpLoginButtons({
if (response.error) {
setError(response.message);
setLoading(false);
return;
}
@@ -84,7 +93,6 @@ export default function IdpLoginButtons({
"An unexpected error occurred. Please try again."
})
);
setLoading(false);
}
if (redirectToUrl) {
@@ -124,20 +132,38 @@ export default function IdpLoginButtons({
idp.variant || idp.name.toLowerCase();
return (
<Button
<div
className="w-full relative"
key={idp.idpId}
type="button"
variant="outline"
className="w-full inline-flex items-center space-x-2"
onClick={() => {
loginWithIdp(idp.idpId);
}}
disabled={loading}
loading={loading}
>
<IdpTypeIcon type={effectiveType} size={16} />
<span>{idp.name}</span>
</Button>
<Button
key={idp.idpId}
type="button"
variant="outline"
className="w-full inline-flex items-center space-x-2 after:absolute after:inset-0 after:z-10"
onClick={() => {
startTransition(() =>
loginWithIdp(idp.idpId)
);
}}
disabled={loading}
loading={loading}
>
<IdpTypeIcon
type={effectiveType}
size={16}
/>
<span>{idp.name}</span>
</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>
);
})}
</>

View File

@@ -30,10 +30,7 @@ import Link from "next/link";
import { GenerateOidcUrlResponse } from "@server/routers/idp";
import { Separator } from "./ui/separator";
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 { useEnvContext } from "@app/hooks/useEnvContext";
import IdpTypeIcon from "@app/components/IdpTypeIcon";
@@ -41,11 +38,13 @@ import IdpTypeIcon from "@app/components/IdpTypeIcon";
import { loadReoScript } from "reodotdev";
import { build } from "@server/build";
import MfaInputForm from "@app/components/MfaInputForm";
import { useLocalStorage } from "@app/hooks/useLocalStorage";
export type LoginFormIDP = {
idpId: number;
name: string;
variant?: string;
lastUsed?: boolean;
};
type LoginFormProps = {
@@ -105,7 +104,6 @@ export default function LoginForm({
}
}, []);
const formSchema = z.object({
email: z.string().email({ message: t("emailInvalid") }),
password: z.string().min(8, { message: t("passwordRequirementsChars") })
@@ -130,11 +128,16 @@ export default function LoginForm({
}
});
const [lastUsedIdpId, setLastUsedIdpId] = useLocalStorage<string | null>(
"login:last-used-idp",
null
);
async function onSubmit(values: any) {
const { email, password } = form.getValues();
const { code } = mfaForm.getValues();
setLastUsedIdpId(null);
setLoading(true);
setError(null);
@@ -179,8 +182,7 @@ export default function LoginForm({
if (data.useSecurityKey) {
setError(
t("securityKeyRequired", {
defaultValue:
"Please use your security key to sign in."
defaultValue: "Please use your security key to sign in."
})
);
return;
@@ -242,6 +244,8 @@ export default function LoginForm({
async function loginWithIdp(idpId: number) {
let redirectUrl: string | undefined;
setLastUsedIdpId(idpId.toString());
try {
const data = await generateOidcUrlProxy(
idpId,
@@ -356,7 +360,6 @@ export default function LoginForm({
)}
<div className="space-y-4">
{!mfaRequested && (
<>
<SecurityKeyAuthButton
@@ -385,25 +388,41 @@ export default function LoginForm({
idp.variant || idp.name.toLowerCase();
return (
<Button
<div
className="w-full relative"
key={idp.idpId}
type="button"
variant="outline"
className="w-full inline-flex items-center space-x-2"
onClick={() => {
loginWithIdp(idp.idpId);
}}
>
<IdpTypeIcon type={effectiveType} size={16} />
<span>{idp.name}</span>
</Button>
<Button
key={idp.idpId}
type="button"
variant="outline"
className="w-full inline-flex items-center space-x-2 after:absolute after:inset-0 after:z-10"
onClick={() => {
loginWithIdp(idp.idpId);
}}
>
<IdpTypeIcon
type={effectiveType}
size={16}
/>
<span>{idp.name}</span>
</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>
);

View File

@@ -22,6 +22,8 @@ import Link from "next/link";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { cleanRedirect } from "@app/lib/cleanRedirect";
import MfaInputForm from "@app/components/MfaInputForm";
import { LAST_USED_IDP_COOKIE_NAME } from "@app/lib/consts";
import { setClientCookie } from "@app/lib/setClientCookie";
type LoginPasswordFormProps = {
identifier: string;
@@ -82,6 +84,12 @@ export default function LoginPasswordForm({
const { password } = values;
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);
setError(null);

View File

@@ -23,7 +23,6 @@ import {
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { Switch } from "@app/components/ui/switch";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useNavigationContext } from "@app/hooks/useNavigationContext";
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
@@ -37,9 +36,7 @@ import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHr
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
import { build } from "@server/build";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { UpdateSiteResourceResponse } from "@server/routers/siteResource";
import type { PaginationState } from "@tanstack/react-table";
import { AxiosResponse } from "axios";
import {
ArrowDown01Icon,
ArrowRight,
@@ -52,17 +49,8 @@ import {
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
startTransition,
useMemo,
useOptimistic,
useRef,
useState,
useTransition,
type ComponentRef
} from "react";
import { startTransition, useMemo, useState, useTransition } from "react";
import { useDebouncedCallback } from "use-debounce";
import z from "zod";
import { ColumnFilterButton } from "./ColumnFilterButton";
import { LabelColumnFilterButton } from "./LabelColumnFilterButton";
import { LabelsTableCell } from "./LabelsTableCell";
@@ -126,11 +114,6 @@ function isSafeUrlForLink(href: string): boolean {
}
}
const booleanSearchFilterSchema = z
.enum(["true", "false"])
.optional()
.catch(undefined);
type ClientResourcesTableProps = {
internalResources: InternalResourceRow[];
orgId: string;
@@ -191,30 +174,6 @@ 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 (
resourceId: number,
siteId: number
@@ -470,36 +429,6 @@ 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",
accessorKey: "labels",
@@ -714,39 +643,3 @@ 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>
);
}

View File

@@ -27,6 +27,8 @@ import UserProfileCard from "@app/components/UserProfileCard";
import SecurityKeyAuthButton from "@app/components/SecurityKeyAuthButton";
import { Separator } from "@app/components/ui/separator";
import OrgSignInLink from "@app/components/OrgSignInLink";
import type { LoginFormIDP } from "./LoginForm";
import IdpLoginButtons from "./IdpLoginButtons";
const identifierSchema = z.object({
identifier: z.string().min(1, "Username or email is required")
@@ -53,6 +55,7 @@ type SmartLoginFormProps = {
forceLogin?: boolean;
defaultUser?: string;
orgSignIn?: OrgSignInConfig;
lastUsedIdp?: (LoginFormIDP & { orgId?: string }) | null;
};
type ViewState =
@@ -89,7 +92,8 @@ export default function SmartLoginForm({
redirect,
forceLogin,
defaultUser,
orgSignIn
orgSignIn,
lastUsedIdp
}: SmartLoginFormProps) {
const router = useRouter();
const { env } = useEnvContext();
@@ -294,6 +298,15 @@ export default function SmartLoginForm({
</span>
</div>
</div>
{lastUsedIdp && (
<IdpLoginButtons
idps={[lastUsedIdp]}
orgId={lastUsedIdp.orgId}
redirect={redirect}
/>
)}
<OrgSignInLink
href={orgSignIn.href}
linkText={orgSignIn.linkText}

View File

@@ -17,6 +17,8 @@ import {
} from "next/navigation";
import { cleanRedirect } from "@app/lib/cleanRedirect";
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 = {
identifier: string;
@@ -141,6 +143,17 @@ export default function SmartLoginOrgSelector({
setPendingIdpId(idpId);
setError(null);
setClientCookie(
LAST_USED_IDP_COOKIE_NAME,
JSON.stringify({
orgId,
idpId
}),
{
sameSite: "Lax"
}
);
let redirectToUrl: string | undefined;
try {
const safeRedirect = cleanRedirect(redirect || "/");

View File

@@ -4,9 +4,13 @@ import { getCachedSubscription } from "./getCachedSubscription";
import { priv } from ".";
import { AxiosResponse } from "axios";
import { GetLicenseStatusResponse } from "@server/routers/license/types";
import { Tier } from "@server/types/Tiers";
export const isOrgSubscribed = cache(async (orgId: string) => {
const DEFAULT_PAID_TIERS: Tier[] = ["tier1", "tier2", "tier3", "enterprise"];
export const isOrgSubscribed = cache(async (orgId: string, tiers?: Tier[]) => {
let subscribed = false;
const allowedTiers = tiers ?? DEFAULT_PAID_TIERS;
if (build === "enterprise") {
try {
@@ -20,7 +24,8 @@ export const isOrgSubscribed = cache(async (orgId: string) => {
try {
const subRes = await getCachedSubscription(orgId);
subscribed =
(subRes.data.data.tier == "tier1" || subRes.data.data.tier == "tier2" || subRes.data.data.tier == "tier3" || subRes.data.data.tier == "enterprise") &&
!!subRes.data.data.tier &&
allowedTiers.includes(subRes.data.data.tier as Tier) &&
subRes.data.data.active;
} catch {}
}

1
src/lib/consts.ts Normal file
View File

@@ -0,0 +1 @@
export const LAST_USED_IDP_COOKIE_NAME = "p__last_used_idp";

View File

@@ -1,6 +1,7 @@
import { priv } from "@app/lib/api";
import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
import { build } from "@server/build";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { LoadLoginPageBrandingResponse } from "@server/routers/loginPage/types";
import { AxiosResponse } from "axios";
@@ -11,7 +12,10 @@ export async function loadOrgLoginPageBranding(orgId: string): Promise<{
return { primaryColor: null };
}
const subscribed = await isOrgSubscribed(orgId);
const subscribed = await isOrgSubscribed(
orgId,
tierMatrix.loginPageBranding
);
if (!subscribed) {
return { primaryColor: null };
}

View File

@@ -165,6 +165,7 @@ export function buildCreateSiteResourcePayload(
siteIds: data.siteIds,
mode: data.mode,
destination: isNativeSsh ? undefined : (data.destination ?? undefined),
enabled: true,
...(data.mode === "http" && {
scheme: data.scheme,
ssl: data.ssl ?? false,
@@ -341,8 +342,7 @@ export function createGeneralFormSchema(t: TranslateFn) {
.string()
.min(1)
.max(255)
.regex(/^[a-zA-Z0-9-]+$/),
enabled: z.boolean()
.regex(/^[a-zA-Z0-9-]+$/)
});
}

View File

@@ -0,0 +1,32 @@
/**
* 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;
}