mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-22 13:34:13 +02:00
Compare commits
45 Commits
dd2c9f2a02
..
1.19.1
| Author | SHA1 | Date | |
|---|---|---|---|
| d9952b0762 | |||
| 935593885a | |||
| 3fcfd3304f | |||
| 6e271028f3 | |||
| 820f66e58f | |||
| b0fdc10e06 | |||
| b82b41ed26 | |||
| 3e977ba00d | |||
| a724b07846 | |||
| 5f0bc71bcd | |||
| aea7827c1a | |||
| d865c4c55b | |||
| 5baf0c3c09 | |||
| cfe33eb974 | |||
| 71273e1b1c | |||
| 02f6e2a8c3 | |||
| 3cc244a1d3 | |||
| 1d9c4dd9e2 | |||
| b9dd0c8e43 | |||
| cd052976eb | |||
| cc498f0e33 | |||
| 1a942937e6 | |||
| d81d1a6b7f | |||
| f64d04e827 | |||
| 540aee3fe2 | |||
| 10542d7282 | |||
| b1d52ad1a3 | |||
| ce2fbef805 | |||
| e312b31e02 | |||
| bc156c715d | |||
| 9a4c1f23c6 | |||
| 6921447fab | |||
| d47449b082 | |||
| 665806dfe8 | |||
| e248571268 | |||
| fcf03854ff | |||
| dd1fba4e45 | |||
| a1ab8d8f35 | |||
| c789e967db | |||
| d870b9ff49 | |||
| 9c09019ddb | |||
| 9d88683fc5 | |||
| 7fa1180d10 | |||
| 8b50f1fb65 | |||
| 527d4cc777 |
+2
-1
@@ -34,4 +34,5 @@ build.ts
|
||||
tsconfig.json
|
||||
Dockerfile*
|
||||
drizzle.config.ts
|
||||
allowedDevOrigins.json
|
||||
allowedDevOrigins.json
|
||||
scratch/
|
||||
|
||||
@@ -4,19 +4,26 @@ import { eq } from "drizzle-orm";
|
||||
|
||||
type SetServerAdminArgs = {
|
||||
email: string;
|
||||
remove: boolean;
|
||||
};
|
||||
|
||||
export const setServerAdmin: CommandModule<{}, SetServerAdminArgs> = {
|
||||
command: "set-server-admin",
|
||||
describe: "Mark any user as a server admin by email address",
|
||||
describe: "Add or remove server admin by email address",
|
||||
builder: (yargs) => {
|
||||
return yargs.option("email", {
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
describe: "User email address"
|
||||
});
|
||||
return yargs
|
||||
.option("email", {
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
describe: "User email address"
|
||||
})
|
||||
.option("remove", {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
describe: "Remove server admin status from the user"
|
||||
});
|
||||
},
|
||||
handler: async (argv: { email: string }) => {
|
||||
handler: async (argv: SetServerAdminArgs) => {
|
||||
try {
|
||||
const email = argv.email.trim().toLowerCase();
|
||||
|
||||
@@ -31,6 +38,33 @@ export const setServerAdmin: CommandModule<{}, SetServerAdminArgs> = {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (argv.remove) {
|
||||
if (!user.serverAdmin) {
|
||||
console.log(`User '${email}' is not a server admin`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const serverAdmins = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.serverAdmin, true));
|
||||
|
||||
if (serverAdmins.length <= 1) {
|
||||
console.error(
|
||||
"Cannot remove server admin: at least one server admin must exist"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({ serverAdmin: false })
|
||||
.where(eq(users.userId, user.userId));
|
||||
|
||||
console.log(`Server admin status removed from user '${email}'`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (user.serverAdmin) {
|
||||
console.log(`User '${email}' is already a server admin`);
|
||||
process.exit(0);
|
||||
|
||||
+5
-4
@@ -214,6 +214,7 @@
|
||||
"resourceErrorDelte": "Error deleting resource",
|
||||
"resourcePoliciesBannerTitle": "Re-use Authentication and Access Rules",
|
||||
"resourcePoliciesBannerDescription": "Shared resource policies let you define authentication methods and access rules once, then attach them to multiple public resources. When you update a policy, every linked resource inherits the change automatically.",
|
||||
"resourcePoliciesBannerButtonText": "Learn More",
|
||||
"resourcePoliciesTitle": "Manage Public Resource Policies",
|
||||
"resourcePoliciesAttachedResourcesColumnTitle": "Resources",
|
||||
"resourcePoliciesAttachedResources": "{count} resource(s)",
|
||||
@@ -983,8 +984,8 @@
|
||||
"sharedPolicy": "Shared Policy",
|
||||
"sharedPolicyNoneDescription": "This resource has its own policy.",
|
||||
"resourceSharedPolicyOwnDescription": "This resource has its own authentication and access rules controls.",
|
||||
"resourceSharedPolicyInheritedDescription": "This resource inherits authentication and access rules controls from <policyLink>{policyName}</policyLink>.",
|
||||
"resourceSharedPolicyAuthenticationNotice": "This resource is using a shared policy. Some authentication settings can be edited on this resource. To change the underlying policy, you must edit to <policyLink>{policyName}</policyLink>.",
|
||||
"resourceSharedPolicyInheritedDescription": "This resource inherits from <policyLink>{policyName}</policyLink>.",
|
||||
"resourceSharedPolicyAuthenticationNotice": "This resource is using a shared policy. Some authentication settings can be edited on this resource to add to the policy. To change the underlying policy, you must edit to <policyLink>{policyName}</policyLink>.",
|
||||
"resourceSharedPolicyRulesNotice": "This resource is using a shared policy. Some access rules can be edited on this resource. To change the underlying policy, you must edit <policyLink>{policyName}</policyLink>.",
|
||||
"resourceUsersRoles": "Access Controls",
|
||||
"resourceUsersRolesDescription": "Configure which users and roles can visit this resource",
|
||||
@@ -3542,14 +3543,14 @@
|
||||
"sshConnecting": "Connecting…",
|
||||
"sshInitializing": "Initializing…",
|
||||
"sshSignInTitle": "Sign in to SSH",
|
||||
"sshSignInDescription": "Enter your SSH credentials",
|
||||
"sshSignInDescription": "Enter your SSH credentials to connect",
|
||||
"sshPasswordTab": "Password",
|
||||
"sshPrivateKeyTab": "Private Key",
|
||||
"sshPrivateKeyField": "Private Key",
|
||||
"sshPrivateKeyDisclaimer": "Your private key is not stored or visible to Pangolin. Alternatively, you can use short-lived certificates for seamless authentication using your existing Pangolin identity.",
|
||||
"sshLearnMore": "Learn more",
|
||||
"sshPrivateKeyFile": "Private Key File",
|
||||
"sshAuthenticate": "Authenticate",
|
||||
"sshAuthenticate": "Connect",
|
||||
"sshTerminate": "Terminate",
|
||||
"sshPoweredBy": "Powered by",
|
||||
"sshErrorNoTarget": "No target specified",
|
||||
|
||||
@@ -147,12 +147,10 @@ export const resources = pgTable("resources", {
|
||||
}),
|
||||
ssl: boolean("ssl").notNull().default(false),
|
||||
blockAccess: boolean("blockAccess").notNull().default(false),
|
||||
sso: boolean("sso").notNull().default(true),
|
||||
proxyPort: integer("proxyPort"),
|
||||
emailWhitelistEnabled: boolean("emailWhitelistEnabled")
|
||||
.notNull()
|
||||
.default(false),
|
||||
applyRules: boolean("applyRules").notNull().default(false),
|
||||
sso: boolean("sso"),
|
||||
emailWhitelistEnabled: boolean("emailWhitelistEnabled"),
|
||||
applyRules: boolean("applyRules"),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
stickySession: boolean("stickySession").notNull().default(false),
|
||||
tlsServerName: varchar("tlsServerName"),
|
||||
|
||||
@@ -45,9 +45,9 @@ export type ResourceWithAuth = {
|
||||
password: ResourcePassword | ResourcePolicyPassword | null;
|
||||
headerAuth: ResourceHeaderAuth | ResourcePolicyHeaderAuth | null;
|
||||
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
|
||||
applyRules: boolean;
|
||||
sso: boolean;
|
||||
emailWhitelistEnabled: boolean;
|
||||
applyRules: boolean | null;
|
||||
sso: boolean | null;
|
||||
emailWhitelistEnabled: boolean | null;
|
||||
org: Org;
|
||||
};
|
||||
|
||||
|
||||
@@ -165,14 +165,12 @@ export const resources = sqliteTable("resources", {
|
||||
blockAccess: integer("blockAccess", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
sso: integer("sso", { mode: "boolean" }).notNull().default(true),
|
||||
proxyPort: integer("proxyPort"),
|
||||
emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
applyRules: integer("applyRules", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
sso: integer("sso", { mode: "boolean" }),
|
||||
emailWhitelistEnabled: integer("emailWhitelistEnabled", {
|
||||
mode: "boolean"
|
||||
}),
|
||||
applyRules: integer("applyRules", { mode: "boolean" }),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
stickySession: integer("stickySession", { mode: "boolean" })
|
||||
.notNull()
|
||||
|
||||
@@ -31,7 +31,7 @@ export enum TierFeature {
|
||||
}
|
||||
|
||||
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||
[TierFeature.Labels]: ["tier2", "tier3", "enterprise"],
|
||||
[TierFeature.Labels]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.OrgOidc]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.LoginPageDomain]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.DeviceApprovals]: ["tier1", "tier3", "enterprise"],
|
||||
@@ -71,16 +71,6 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.NewtAutoUpdate]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.ResourcePolicies]: ["tier3", "enterprise"],
|
||||
[TierFeature.AdvancedPublicResources]: [
|
||||
"tier1",
|
||||
"tier2",
|
||||
"tier3",
|
||||
"enterprise"
|
||||
],
|
||||
[TierFeature.AdvancedPrivateResources]: [
|
||||
"tier1",
|
||||
"tier2",
|
||||
"tier3",
|
||||
"enterprise"
|
||||
]
|
||||
[TierFeature.AdvancedPublicResources]: ["tier3", "enterprise"],
|
||||
[TierFeature.AdvancedPrivateResources]: ["tier3", "enterprise"]
|
||||
};
|
||||
|
||||
@@ -415,7 +415,11 @@ export async function updatePrivateResources(
|
||||
} else {
|
||||
let aliasAddress: string | null = null;
|
||||
let releaseAliasLock: (() => Promise<void>) | null = null;
|
||||
if (resourceData.mode === "host" || resourceData.mode === "http") {
|
||||
if (
|
||||
resourceData.mode === "host" ||
|
||||
resourceData.mode === "http" ||
|
||||
resourceData.mode === "ssh"
|
||||
) {
|
||||
const { value, release } = await getNextAvailableAliasAddress(
|
||||
orgId,
|
||||
trx
|
||||
|
||||
+14
-5
@@ -504,7 +504,7 @@ export function generateRemoteSubnets(
|
||||
const parseResult = cidrSchema.safeParse(sr.destination);
|
||||
return parseResult.success;
|
||||
}
|
||||
if (sr.mode === "host") {
|
||||
if (sr.mode === "host" || sr.mode === "ssh") {
|
||||
// check if its a valid IP using zod
|
||||
const ipSchema = z.union([z.ipv4(), z.ipv6()]);
|
||||
const parseResult = ipSchema.safeParse(sr.destination);
|
||||
@@ -514,7 +514,7 @@ export function generateRemoteSubnets(
|
||||
})
|
||||
.map((sr) => {
|
||||
if (sr.mode === "cidr") return sr.destination;
|
||||
if (sr.mode === "host") {
|
||||
if (sr.mode === "host" || sr.mode === "ssh") {
|
||||
return `${sr.destination}/32`;
|
||||
}
|
||||
return ""; // This should never be reached due to filtering, but satisfies TypeScript
|
||||
@@ -531,7 +531,7 @@ export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] {
|
||||
.filter(
|
||||
(sr) =>
|
||||
sr.aliasAddress &&
|
||||
((sr.alias && sr.mode == "host") ||
|
||||
((sr.alias && (sr.mode == "host" || sr.mode == "ssh")) ||
|
||||
(sr.fullDomain && sr.mode == "http"))
|
||||
)
|
||||
.map((sr) => ({
|
||||
@@ -577,6 +577,10 @@ export function generateSubnetProxyTargets(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!siteResource.destination) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const clientPrefix = `${clientSite.subnet.split("/")[0]}/32`;
|
||||
const portRange = [
|
||||
...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"),
|
||||
@@ -584,7 +588,7 @@ export function generateSubnetProxyTargets(
|
||||
];
|
||||
const disableIcmp = siteResource.disableIcmp ?? false;
|
||||
|
||||
if (siteResource.mode == "host") {
|
||||
if (siteResource.mode == "host" || siteResource.mode == "ssh") {
|
||||
let destination = siteResource.destination;
|
||||
// check if this is a valid ip
|
||||
const ipSchema = z.union([z.ipv4(), z.ipv6()]);
|
||||
@@ -665,6 +669,11 @@ export async function generateSubnetProxyTargetV2(
|
||||
return;
|
||||
}
|
||||
|
||||
if (!siteResource.destination) {
|
||||
// ssh can have no destination
|
||||
return;
|
||||
}
|
||||
|
||||
const targets: SubnetProxyTargetV2[] = [];
|
||||
|
||||
const portRange = [
|
||||
@@ -673,7 +682,7 @@ export async function generateSubnetProxyTargetV2(
|
||||
];
|
||||
const disableIcmp = siteResource.disableIcmp ?? false;
|
||||
|
||||
if (siteResource.mode == "host") {
|
||||
if (siteResource.mode == "host" || siteResource.mode == "ssh") {
|
||||
let destination = siteResource.destination;
|
||||
// check if this is a valid ip
|
||||
const ipSchema = z.union([z.ipv4(), z.ipv6()]);
|
||||
|
||||
@@ -181,6 +181,7 @@ class TelemetryClient {
|
||||
let numPrivResourceHosts = 0;
|
||||
let numPrivResourceCidr = 0;
|
||||
let numPrivResourceHttp = 0;
|
||||
let numPrivResourceSsh = 0;
|
||||
for (const res of allPrivateResources) {
|
||||
if (res.mode === "host") {
|
||||
numPrivResourceHosts += 1;
|
||||
@@ -188,6 +189,8 @@ class TelemetryClient {
|
||||
numPrivResourceCidr += 1;
|
||||
} else if (res.mode === "http") {
|
||||
numPrivResourceHttp += 1;
|
||||
} else if (res.mode === "ssh") {
|
||||
numPrivResourceSsh += 1;
|
||||
}
|
||||
|
||||
if (res.alias) {
|
||||
@@ -207,6 +210,7 @@ class TelemetryClient {
|
||||
numPrivateResourceHosts: numPrivResourceHosts,
|
||||
numPrivateResourceCidr: numPrivResourceCidr,
|
||||
numPrivateResourceHttp: numPrivResourceHttp,
|
||||
numPrivateResourceSsh: numPrivResourceSsh,
|
||||
numAlertRules: numAlertRules.count,
|
||||
numUserDevices: userDevicesCount.count,
|
||||
numMachineClients: machineClients.count,
|
||||
|
||||
@@ -219,9 +219,9 @@ export type ResourceWithAuth = {
|
||||
password: ResourcePassword | ResourcePolicyPassword | null;
|
||||
headerAuth: ResourceHeaderAuth | ResourcePolicyHeaderAuth | null;
|
||||
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
|
||||
applyRules: boolean;
|
||||
sso: boolean;
|
||||
emailWhitelistEnabled: boolean;
|
||||
applyRules: boolean | null;
|
||||
sso: boolean | null;
|
||||
emailWhitelistEnabled: boolean | null;
|
||||
org: Org;
|
||||
};
|
||||
|
||||
|
||||
@@ -145,9 +145,9 @@ export async function verifyResourceSession(
|
||||
| ResourcePolicyHeaderAuth
|
||||
| null;
|
||||
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
|
||||
applyRules: boolean;
|
||||
sso: boolean;
|
||||
emailWhitelistEnabled: boolean;
|
||||
applyRules: boolean | null;
|
||||
sso: boolean | null;
|
||||
emailWhitelistEnabled: boolean | null;
|
||||
org: Org;
|
||||
}
|
||||
| undefined = localCache.get(resourceCacheKey);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, Org } from "@server/db";
|
||||
import { db, Org, primaryDb } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
@@ -635,9 +635,7 @@ export async function validateOidcCallback(
|
||||
}
|
||||
});
|
||||
|
||||
db.transaction(async (trx) => {
|
||||
await calculateUserClientsForOrgs(userId!, trx);
|
||||
}).catch((err) => {
|
||||
calculateUserClientsForOrgs(userId!, primaryDb).catch((err) => {
|
||||
logger.error(
|
||||
"Error calculating user clients after syncing orgs and roles for OIDC user",
|
||||
{ error: err }
|
||||
|
||||
@@ -56,13 +56,18 @@ async function getLatestReleaseInfo(): Promise<ReleaseInfo | null> {
|
||||
return staleReleaseInfo;
|
||||
}
|
||||
|
||||
// Drop drafts, pre-releases, and anything with "rc" in the tag name.
|
||||
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
|
||||
// Drop drafts, pre-releases, anything with "rc" in the tag name,
|
||||
// and releases published less than 1 day ago.
|
||||
releases = releases.filter(
|
||||
(r: any) =>
|
||||
!r.draft &&
|
||||
!r.prerelease &&
|
||||
!r.tag_name.includes("rc") &&
|
||||
!r.tag_name.includes("v")
|
||||
!r.tag_name.includes("v") &&
|
||||
r.published_at &&
|
||||
new Date(r.published_at) <= oneDayAgo
|
||||
);
|
||||
|
||||
// Sort descending by semver to find the true latest stable release.
|
||||
|
||||
@@ -28,7 +28,8 @@ const addRoleToResourceParamsSchema = z
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/resource/{resourceId}/roles/add",
|
||||
description: "Add a single role to a resource.",
|
||||
description:
|
||||
"Add a single role to a resource. When the resource has an inline policy defined (no shared resource policy assigned), the role is added to the inline policy instead of directly to the resource.",
|
||||
tags: [OpenAPITags.PublicResource, OpenAPITags.Role],
|
||||
request: {
|
||||
params: addRoleToResourceParamsSchema,
|
||||
|
||||
@@ -28,7 +28,8 @@ const addUserToResourceParamsSchema = z
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/resource/{resourceId}/users/add",
|
||||
description: "Add a single user to a resource.",
|
||||
description:
|
||||
"Add a single user to a resource. When the resource has an inline policy defined (no shared resource policy assigned), the user is added to the inline policy instead of directly to the resource.",
|
||||
tags: [OpenAPITags.PublicResource, OpenAPITags.User],
|
||||
request: {
|
||||
params: addUserToResourceParamsSchema,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { db, resources } from "@server/db";
|
||||
import { db, resourcePolicies, resources } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import stoi from "@server/lib/stoi";
|
||||
import logger from "@server/logger";
|
||||
@@ -41,6 +41,15 @@ async function query(resourceId?: number, niceId?: string, orgId?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function queryInlinePolicy(resourcePolicyId: number) {
|
||||
const [res] = await db
|
||||
.select()
|
||||
.from(resourcePolicies)
|
||||
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId))
|
||||
.limit(1);
|
||||
return res;
|
||||
}
|
||||
|
||||
export type GetResourceResponse = Omit<
|
||||
NonNullable<Awaited<ReturnType<typeof query>>>,
|
||||
"headers"
|
||||
@@ -132,12 +141,31 @@ export async function getResource(
|
||||
);
|
||||
}
|
||||
|
||||
const isInlinePolicy =
|
||||
resource.resourcePolicyId === null &&
|
||||
resource.defaultResourcePolicyId !== null;
|
||||
|
||||
let returnData = resource;
|
||||
if (isInlinePolicy) {
|
||||
// get the policy
|
||||
const policy = await queryInlinePolicy(
|
||||
resource.defaultResourcePolicyId!
|
||||
);
|
||||
returnData = {
|
||||
...returnData,
|
||||
sso: policy?.sso || null,
|
||||
emailWhitelistEnabled: policy?.emailWhitelistEnabled || null,
|
||||
applyRules: policy?.applyRules || null,
|
||||
skipToIdpId: policy?.idpId || null
|
||||
};
|
||||
}
|
||||
|
||||
return response<GetResourceResponse>(res, {
|
||||
data: {
|
||||
...resource,
|
||||
headers: resource.headers
|
||||
? JSON.parse(resource.headers)
|
||||
: resource.headers
|
||||
...returnData,
|
||||
headers: returnData.headers
|
||||
? JSON.parse(returnData.headers)
|
||||
: returnData.headers
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
|
||||
@@ -203,9 +203,9 @@ export async function getUserResources(
|
||||
fullDomain: string | null;
|
||||
ssl: boolean;
|
||||
enabled: boolean;
|
||||
sso: boolean;
|
||||
sso: boolean | null;
|
||||
mode: string;
|
||||
emailWhitelistEnabled: boolean;
|
||||
emailWhitelistEnabled: boolean | null;
|
||||
policyEmailWhitelistEnabled: boolean | null;
|
||||
}> = [];
|
||||
if (uniqueResourceIds.length > 0) {
|
||||
|
||||
@@ -123,6 +123,16 @@ const listResourcesSchema = z.object({
|
||||
description:
|
||||
"Filter resources based on health status of their targets. `healthy` means all targets are healthy. `degraded` means at least one target is unhealthy, but not all are unhealthy. `offline` means all targets are unhealthy. `unknown` means all targets have unknown health status."
|
||||
}),
|
||||
protocol: z
|
||||
.enum(["http", "https", "tcp", "udp", "ssh", "rdp", "vnc"])
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.openapi({
|
||||
type: "string",
|
||||
enum: ["http", "https", "tcp", "udp", "ssh", "rdp", "vnc"],
|
||||
description:
|
||||
"Filter resources by protocol. `http` and `https` match HTTP resources without and with SSL respectively."
|
||||
}),
|
||||
siteId: z.coerce.number<string>().int().positive().optional().openapi({
|
||||
type: "integer",
|
||||
description:
|
||||
@@ -437,6 +447,7 @@ export async function listResources(
|
||||
enabled,
|
||||
query,
|
||||
healthStatus,
|
||||
protocol,
|
||||
sort_by,
|
||||
order,
|
||||
siteId,
|
||||
@@ -632,6 +643,28 @@ export async function listResources(
|
||||
if (typeof healthStatus !== "undefined") {
|
||||
conditions.push(eq(resources.health, healthStatus));
|
||||
}
|
||||
|
||||
if (typeof protocol !== "undefined") {
|
||||
switch (protocol) {
|
||||
case "http":
|
||||
conditions.push(
|
||||
and(
|
||||
eq(resources.mode, "http"),
|
||||
eq(resources.ssl, false)
|
||||
)
|
||||
);
|
||||
break;
|
||||
case "https":
|
||||
conditions.push(
|
||||
and(eq(resources.mode, "http"), eq(resources.ssl, true))
|
||||
);
|
||||
break;
|
||||
default:
|
||||
conditions.push(eq(resources.mode, protocol));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (siteId != null) {
|
||||
const resourcesWithSite = db
|
||||
.select({ resourceId: targets.resourceId })
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
userOrgRoles,
|
||||
userOrgs
|
||||
} from "@server/db";
|
||||
import { and, eq, inArray, asc, isNotNull, ne } from "drizzle-orm";
|
||||
import { and, eq, inArray, asc, isNotNull, ne, or } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import response from "@server/lib/response";
|
||||
@@ -224,7 +224,7 @@ export async function listUserResourceAliases(
|
||||
const whereClause = and(
|
||||
eq(siteResources.orgId, orgId),
|
||||
eq(siteResources.enabled, true),
|
||||
eq(siteResources.mode, "host"),
|
||||
or(eq(siteResources.mode, "host"), eq(siteResources.mode, "ssh")),
|
||||
isNotNull(siteResources.alias),
|
||||
ne(siteResources.alias, ""),
|
||||
inArray(siteResources.siteResourceId, accessibleSiteResourceIds)
|
||||
|
||||
@@ -22,7 +22,7 @@ registry.registerPath({
|
||||
method: "post",
|
||||
path: "/resource/{resourceId}/roles",
|
||||
description:
|
||||
"Set roles for a resource. This will replace all existing roles.",
|
||||
"Set roles for a resource. This will replace all existing roles. When the resource has an inline policy defined (no shared resource policy assigned), roles are set on the inline policy instead of directly on the resource.",
|
||||
tags: [OpenAPITags.PublicResource, OpenAPITags.Role],
|
||||
request: {
|
||||
params: setResourceRolesParamsSchema,
|
||||
|
||||
@@ -22,7 +22,7 @@ registry.registerPath({
|
||||
method: "post",
|
||||
path: "/resource/{resourceId}/users",
|
||||
description:
|
||||
"Set users for a resource. This will replace all existing users.",
|
||||
"Set users for a resource. This will replace all existing users. When the resource has an inline policy defined (no shared resource policy assigned), users are set on the inline policy instead of directly on the resource.",
|
||||
tags: [OpenAPITags.PublicResource, OpenAPITags.User],
|
||||
request: {
|
||||
params: setUserResourcesParamsSchema,
|
||||
|
||||
@@ -9,7 +9,11 @@ import {
|
||||
resourcePassword,
|
||||
resourcePincode,
|
||||
resourceRules,
|
||||
resourceWhitelist
|
||||
resourceWhitelist,
|
||||
roleResources,
|
||||
roles,
|
||||
Transaction,
|
||||
userResources
|
||||
} from "@server/db";
|
||||
import {
|
||||
domains,
|
||||
@@ -62,16 +66,38 @@ const updateHttpResourceBodySchema = z
|
||||
.optional(),
|
||||
subdomain: z.string().nullable().optional(),
|
||||
ssl: z.boolean().optional(),
|
||||
sso: z.boolean().optional(),
|
||||
sso: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"When no shared resource policy is assigned (resourcePolicyId is null), updates the resource's inline policy. When a shared policy is assigned, this value overrides the shared policy for this resource."
|
||||
),
|
||||
blockAccess: z.boolean().optional(),
|
||||
emailWhitelistEnabled: z.boolean().optional(),
|
||||
applyRules: z.boolean().optional(),
|
||||
emailWhitelistEnabled: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"When no shared resource policy is assigned (resourcePolicyId is null), updates the resource's inline policy. When a shared policy is assigned, this value overrides the shared policy for this resource."
|
||||
),
|
||||
applyRules: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"When no shared resource policy is assigned (resourcePolicyId is null), updates the resource's inline policy. When a shared policy is assigned, this value overrides the shared policy for this resource."
|
||||
),
|
||||
domainId: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
stickySession: z.boolean().optional(),
|
||||
tlsServerName: z.string().nullable().optional(),
|
||||
setHostHeader: z.string().nullable().optional(),
|
||||
skipToIdpId: z.int().positive().nullable().optional(),
|
||||
skipToIdpId: z
|
||||
.int()
|
||||
.positive()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"When no shared resource policy is assigned (resourcePolicyId is null), updates the resource's inline policy. When a shared policy is assigned, this value overrides the shared policy for this resource."
|
||||
),
|
||||
headers: z
|
||||
.array(z.strictObject({ name: z.string(), value: z.string() }))
|
||||
.nullable()
|
||||
@@ -87,7 +113,13 @@ const updateHttpResourceBodySchema = z
|
||||
pamMode: z.enum(["passthrough", "push"]).optional(),
|
||||
authDaemonMode: z.enum(["site", "remote", "native"]).optional(),
|
||||
authDaemonPort: z.int().min(1).max(65535).nullable().optional(),
|
||||
resourcePolicyId: z.number().nullable().optional()
|
||||
resourcePolicyId: z
|
||||
.number()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"ID of the resource policy to apply to this resource. Set to null to remove the resource policy and fall back to the inline policy settings."
|
||||
)
|
||||
})
|
||||
.refine((data) => Object.keys(data).length > 0, {
|
||||
error: "At least one field must be provided for update"
|
||||
@@ -207,7 +239,8 @@ const updateRawResourceBodySchema = z
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/resource/{resourceId}",
|
||||
description: "Update a resource.",
|
||||
description:
|
||||
"Update a resource. Policy fields (sso, mfa, pincode, password, whitelist) update the inline policy when no shared resource policy is assigned; when a shared policy is assigned those fields override the shared policy for this resource only.",
|
||||
tags: [OpenAPITags.PublicResource],
|
||||
request: {
|
||||
params: updateResourceParamsSchema,
|
||||
@@ -310,6 +343,61 @@ export async function updateResource(
|
||||
}
|
||||
}
|
||||
|
||||
async function clearResourceSpecificSettings(
|
||||
resourceId: number,
|
||||
orgId: string,
|
||||
trx: Transaction | typeof db
|
||||
) {
|
||||
const adminRole = await db
|
||||
.select()
|
||||
.from(roles)
|
||||
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (adminRole.length === 0) {
|
||||
throw new Error(`Admin role not found for org ${orgId}`);
|
||||
}
|
||||
// remove the resource specific pincode, password, header auth, rules, nad whitelist entries so that the resource will fall back to the policy settings
|
||||
await Promise.all([
|
||||
trx
|
||||
.delete(resourcePassword)
|
||||
.where(eq(resourcePassword.resourceId, resourceId)),
|
||||
trx
|
||||
.delete(resourcePincode)
|
||||
.where(eq(resourcePincode.resourceId, resourceId)),
|
||||
trx
|
||||
.delete(resourceHeaderAuth)
|
||||
.where(eq(resourceHeaderAuth.resourceId, resourceId)),
|
||||
trx
|
||||
.delete(resourceHeaderAuthExtendedCompatibility)
|
||||
.where(
|
||||
eq(
|
||||
resourceHeaderAuthExtendedCompatibility.resourceId,
|
||||
resourceId
|
||||
)
|
||||
),
|
||||
trx
|
||||
.delete(resourceWhitelist)
|
||||
.where(eq(resourceWhitelist.resourceId, resourceId)),
|
||||
trx
|
||||
.delete(resourceRules)
|
||||
.where(eq(resourceRules.resourceId, resourceId)),
|
||||
// delete the roles and the users as well
|
||||
trx
|
||||
.delete(userResources)
|
||||
.where(eq(userResources.resourceId, resourceId)),
|
||||
// except the admin role
|
||||
trx
|
||||
.delete(roleResources)
|
||||
.where(
|
||||
and(
|
||||
eq(roleResources.resourceId, resourceId),
|
||||
ne(roleResources.roleId, adminRole[0].roleId)
|
||||
)
|
||||
)
|
||||
]);
|
||||
}
|
||||
|
||||
async function updateHttpResource(
|
||||
route: {
|
||||
req: Request;
|
||||
@@ -372,6 +460,15 @@ async function updateHttpResource(
|
||||
}
|
||||
}
|
||||
|
||||
// catch when the resource policy changes or gets cleared
|
||||
if (resource.resourcePolicyId != updateData.resourcePolicyId) {
|
||||
await clearResourceSpecificSettings(
|
||||
resource.resourceId,
|
||||
resource.orgId,
|
||||
db
|
||||
);
|
||||
}
|
||||
|
||||
if (updateData.niceId) {
|
||||
const [existingResource] = await db
|
||||
.select()
|
||||
@@ -560,9 +657,17 @@ async function updateHttpResource(
|
||||
emailWhitelistEnabled,
|
||||
applyRules,
|
||||
skipToIdpId,
|
||||
...resourceOnlyData
|
||||
...resourceOnlyDataRest
|
||||
} = updateData;
|
||||
|
||||
const resourceOnlyData = {
|
||||
...resourceOnlyDataRest,
|
||||
sso: null, // reset these because they are controlled by the inline policy
|
||||
emailWhitelistEnabled: null,
|
||||
applyRules: null,
|
||||
skipToIdpId: null
|
||||
};
|
||||
|
||||
const policyUpdate: Record<string, unknown> = {};
|
||||
if (sso !== undefined) policyUpdate.sso = sso;
|
||||
if (emailWhitelistEnabled !== undefined)
|
||||
@@ -659,81 +764,6 @@ async function updateRawResource(
|
||||
.limit(1);
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
if (updateData.resourcePolicyId != null) {
|
||||
const [existingPolicy] = await trx
|
||||
.select()
|
||||
.from(resourcePolicies)
|
||||
.where(
|
||||
eq(
|
||||
resourcePolicies.resourcePolicyId,
|
||||
updateData.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!existingPolicy) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource policy with ID ${updateData.resourcePolicyId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// we are in an inline policy and we need to clear out the old tables
|
||||
await Promise.all([
|
||||
trx
|
||||
.delete(resourcePassword)
|
||||
.where(
|
||||
eq(
|
||||
resourcePassword.resourceId,
|
||||
existingResource.resourceId
|
||||
)
|
||||
),
|
||||
trx
|
||||
.delete(resourcePincode)
|
||||
.where(
|
||||
eq(
|
||||
resourcePincode.resourceId,
|
||||
existingResource.resourceId
|
||||
)
|
||||
),
|
||||
trx
|
||||
.delete(resourceHeaderAuth)
|
||||
.where(
|
||||
eq(
|
||||
resourceHeaderAuth.resourceId,
|
||||
existingResource.resourceId
|
||||
)
|
||||
),
|
||||
trx
|
||||
.delete(resourceHeaderAuthExtendedCompatibility)
|
||||
.where(
|
||||
eq(
|
||||
resourceHeaderAuthExtendedCompatibility.resourceId,
|
||||
existingResource.resourceId
|
||||
)
|
||||
),
|
||||
trx
|
||||
.delete(resourceWhitelist)
|
||||
.where(
|
||||
eq(
|
||||
resourceWhitelist.resourceId,
|
||||
existingResource.resourceId
|
||||
)
|
||||
),
|
||||
|
||||
trx
|
||||
.delete(resourceRules)
|
||||
.where(
|
||||
eq(
|
||||
resourceRules.resourceId,
|
||||
existingResource.resourceId
|
||||
)
|
||||
)
|
||||
]);
|
||||
}
|
||||
|
||||
if (updateData.niceId) {
|
||||
const [existingResourceConflict] = await trx
|
||||
.select()
|
||||
@@ -758,9 +788,24 @@ async function updateRawResource(
|
||||
}
|
||||
}
|
||||
|
||||
await clearResourceSpecificSettings(
|
||||
resource.resourceId,
|
||||
resource.orgId,
|
||||
trx
|
||||
); // none of these are supported on raw resources
|
||||
|
||||
// we should make sure sso, emailWhitelistEnabled, and applyRules are null because this is a raw resource
|
||||
const realUpdateData = {
|
||||
...updateData,
|
||||
sso: null,
|
||||
emailWhitelistEnabled: null,
|
||||
applyRules: null,
|
||||
skipToIdpId: null
|
||||
};
|
||||
|
||||
[updatedResource] = await trx
|
||||
.update(resources)
|
||||
.set(updateData)
|
||||
.set(realUpdateData)
|
||||
.where(eq(resources.resourceId, resource.resourceId))
|
||||
.returning();
|
||||
});
|
||||
|
||||
@@ -327,27 +327,6 @@ export async function listSites(
|
||||
);
|
||||
}
|
||||
|
||||
let accessibleSites;
|
||||
if (req.user) {
|
||||
accessibleSites = await db
|
||||
.select({
|
||||
siteId: sql<number>`COALESCE(${userSites.siteId}, ${roleSites.siteId})`
|
||||
})
|
||||
.from(userSites)
|
||||
.fullJoin(roleSites, eq(userSites.siteId, roleSites.siteId))
|
||||
.where(
|
||||
or(
|
||||
eq(userSites.userId, req.user!.userId),
|
||||
inArray(roleSites.roleId, req.userOrgRoleIds!)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
accessibleSites = await db
|
||||
.select({ siteId: sites.siteId })
|
||||
.from(sites)
|
||||
.where(eq(sites.orgId, orgId));
|
||||
}
|
||||
|
||||
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
|
||||
orgId,
|
||||
tierMatrix.labels
|
||||
@@ -364,14 +343,38 @@ export async function listSites(
|
||||
labels: labelFilter
|
||||
} = parsedQuery.data;
|
||||
|
||||
const accessibleSiteIds = accessibleSites.map((site) => site.siteId);
|
||||
const conditions = [eq(sites.orgId, orgId)];
|
||||
|
||||
const conditions = [
|
||||
and(
|
||||
inArray(sites.siteId, accessibleSiteIds),
|
||||
eq(sites.orgId, orgId)
|
||||
)
|
||||
];
|
||||
if (req.user) {
|
||||
const userAccessConditions = [
|
||||
inArray(
|
||||
sites.siteId,
|
||||
db
|
||||
.select({ siteId: userSites.siteId })
|
||||
.from(userSites)
|
||||
.where(eq(userSites.userId, req.user.userId))
|
||||
)
|
||||
];
|
||||
|
||||
const roleIds = req.userOrgRoleIds ?? [];
|
||||
if (roleIds.length > 0) {
|
||||
userAccessConditions.push(
|
||||
inArray(
|
||||
sites.siteId,
|
||||
db
|
||||
.select({ siteId: roleSites.siteId })
|
||||
.from(roleSites)
|
||||
.where(inArray(roleSites.roleId, roleIds))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
conditions.push(
|
||||
userAccessConditions.length === 1
|
||||
? userAccessConditions[0]
|
||||
: or(...userAccessConditions)!
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof online !== "undefined") {
|
||||
conditions.push(eq(sites.online, online));
|
||||
@@ -418,17 +421,15 @@ export async function listSites(
|
||||
)
|
||||
);
|
||||
}
|
||||
conditions.push(or(...queryList));
|
||||
conditions.push(or(...queryList)!);
|
||||
}
|
||||
|
||||
const baseQuery = querySitesBase().where(and(...conditions));
|
||||
|
||||
// we need to add `as` so that drizzle filters the result as a subquery
|
||||
const countQuery = db.$count(
|
||||
querySitesBase()
|
||||
.where(and(...conditions))
|
||||
.as("filtered_sites")
|
||||
);
|
||||
const countQuery = db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(sites)
|
||||
.where(and(...conditions));
|
||||
|
||||
const siteListQuery = baseQuery
|
||||
.limit(pageSize)
|
||||
@@ -441,11 +442,13 @@ export async function listSites(
|
||||
: asc(sites.name)
|
||||
);
|
||||
|
||||
const [totalCount, rows] = await Promise.all([
|
||||
const [countRows, rows] = await Promise.all([
|
||||
countQuery,
|
||||
siteListQuery
|
||||
]);
|
||||
|
||||
const totalCount = Number(countRows[0]?.count ?? 0);
|
||||
|
||||
// Get latest version asynchronously without blocking the response
|
||||
const latestNewtVersionPromise = getLatestNewtVersion();
|
||||
|
||||
|
||||
@@ -445,7 +445,7 @@ export async function createSiteResource(
|
||||
|
||||
let aliasAddress: string | null = null;
|
||||
let releaseAliasLock: (() => Promise<void>) | null = null;
|
||||
if (mode === "host" || mode === "http") {
|
||||
if (mode === "host" || mode === "http" || mode === "ssh") {
|
||||
const { value, release } =
|
||||
await getNextAvailableAliasAddress(orgId);
|
||||
aliasAddress = value;
|
||||
|
||||
@@ -44,6 +44,7 @@ import m38 from "./scriptsSqlite/1.18.0";
|
||||
import m39 from "./scriptsSqlite/1.18.3";
|
||||
import m40 from "./scriptsSqlite/1.18.4";
|
||||
import m41 from "./scriptsSqlite/1.19.0";
|
||||
import m42 from "./scriptsSqlite/1.19.1";
|
||||
|
||||
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
|
||||
// EXCEPT FOR THE DATABASE AND THE SCHEMA
|
||||
@@ -85,7 +86,8 @@ const migrations = [
|
||||
{ version: "1.18.0", run: m38 },
|
||||
{ version: "1.18.3", run: m39 },
|
||||
{ version: "1.18.4", run: m40 },
|
||||
{ version: "1.19.0", run: m41 }
|
||||
{ version: "1.19.0", run: m41 },
|
||||
{ version: "1.19.1", run: m42 }
|
||||
// Add new migrations here as they are created
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -304,6 +304,25 @@ export default async function migration() {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "resourceSessions" ADD CONSTRAINT "resourceSessions_policyWhitelistId_resourcePolicyWhitelist_id_fk" FOREIGN KEY ("policyWhitelistId") REFERENCES "public"."resourcePolicyWhitelist"("id") ON DELETE cascade ON UPDATE no action;
|
||||
`);
|
||||
// remove not null/default from sso, applyRules, and emailWhitelistEnabled in preparation for resource policies
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "resources" ALTER COLUMN "sso" DROP NOT NULL;`
|
||||
);
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "resources" ALTER COLUMN "sso" DROP DEFAULT;`
|
||||
);
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "resources" ALTER COLUMN "applyRules" DROP NOT NULL;`
|
||||
);
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "resources" ALTER COLUMN "applyRules" DROP DEFAULT;`
|
||||
);
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "resources" ALTER COLUMN "emailWhitelistEnabled" DROP NOT NULL;`
|
||||
);
|
||||
await db.execute(
|
||||
sql`ALTER TABLE "resources" ALTER COLUMN "emailWhitelistEnabled" DROP DEFAULT;`
|
||||
);
|
||||
|
||||
await db.execute(sql`COMMIT`);
|
||||
console.log("Migrated database");
|
||||
@@ -600,6 +619,14 @@ export default async function migration() {
|
||||
`);
|
||||
}
|
||||
|
||||
// clear the sso, applyRules, and emailWhitelistEnabled columns on all resources since that information is now in the resource policies
|
||||
await db.execute(sql`
|
||||
UPDATE "resources"
|
||||
SET "sso" = null,
|
||||
"applyRules" = null,
|
||||
"emailWhitelistEnabled" = null
|
||||
`);
|
||||
|
||||
await db.execute(sql`COMMIT`);
|
||||
console.log(
|
||||
`Migrated inline resource policies for ${existingResources.length} resource(s)`
|
||||
|
||||
@@ -228,7 +228,7 @@ export default async function migration() {
|
||||
).run();
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE 'siteResources' SET 'destination2' = 'destination';
|
||||
UPDATE 'siteResources' SET "destination2" = "destination";
|
||||
`
|
||||
).run();
|
||||
db.prepare(
|
||||
@@ -349,9 +349,9 @@ export default async function migration() {
|
||||
db.prepare(
|
||||
`
|
||||
UPDATE 'targets'
|
||||
SET 'mode' = (
|
||||
SELECT 'mode' FROM 'resources'
|
||||
WHERE 'resources'.'resourceId' = 'targets'.'resourceId'
|
||||
SET "mode" = (
|
||||
SELECT "mode" FROM 'resources'
|
||||
WHERE "resources"."resourceId" = "targets"."resourceId"
|
||||
);
|
||||
`
|
||||
).run();
|
||||
@@ -688,6 +688,29 @@ export default async function migration() {
|
||||
);
|
||||
}
|
||||
|
||||
// add one more transaction
|
||||
db.transaction(() => {
|
||||
// remove not null/default from sso, applyRules, and emailWhitelistEnabled in preparation for resource policies
|
||||
db.prepare(`ALTER TABLE 'resources' DROP COLUMN 'sso';`).run();
|
||||
db.prepare(
|
||||
`ALTER TABLE 'resources' ADD COLUMN 'sso' integer;`
|
||||
).run();
|
||||
|
||||
db.prepare(
|
||||
`ALTER TABLE 'resources' DROP COLUMN 'applyRules';`
|
||||
).run();
|
||||
db.prepare(
|
||||
`ALTER TABLE 'resources' ADD COLUMN 'applyRules' integer;`
|
||||
).run();
|
||||
|
||||
db.prepare(
|
||||
`ALTER TABLE 'resources' DROP COLUMN 'emailWhitelistEnabled';`
|
||||
).run();
|
||||
db.prepare(
|
||||
`ALTER TABLE 'resources' ADD COLUMN 'emailWhitelistEnabled' integer;`
|
||||
).run();
|
||||
})();
|
||||
|
||||
console.log("Migrated database");
|
||||
} catch (e) {
|
||||
console.log("Failed to migrate db:", e);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { APP_PATH, __DIRNAME } from "@server/lib/consts";
|
||||
import Database from "better-sqlite3";
|
||||
import path from "path";
|
||||
|
||||
const version = "1.19.1";
|
||||
|
||||
export default async function migration() {
|
||||
console.log(`Running setup script ${version}...`);
|
||||
|
||||
const location = path.join(APP_PATH, "db", "db.sqlite");
|
||||
const db = new Database(location);
|
||||
|
||||
try {
|
||||
db.transaction(() => {
|
||||
// remove not null/default from sso, applyRules, and emailWhitelistEnabled in preparation for resource policies
|
||||
db.prepare(
|
||||
`ALTER TABLE 'resources' ADD COLUMN 'sso2' integer;`
|
||||
).run();
|
||||
db.prepare(`UPDATE 'resources' SET "sso2" = "sso";`).run();
|
||||
db.prepare(`ALTER TABLE 'resources' DROP COLUMN 'sso';`).run();
|
||||
db.prepare(
|
||||
`ALTER TABLE 'resources' RENAME COLUMN 'sso2' TO 'sso';`
|
||||
).run();
|
||||
|
||||
db.prepare(
|
||||
`ALTER TABLE 'resources' ADD COLUMN 'applyRules2' integer;`
|
||||
).run();
|
||||
db.prepare(
|
||||
`UPDATE 'resources' SET "applyRules2" = "applyRules";`
|
||||
).run();
|
||||
db.prepare(
|
||||
`ALTER TABLE 'resources' DROP COLUMN 'applyRules';`
|
||||
).run();
|
||||
db.prepare(
|
||||
`ALTER TABLE 'resources' RENAME COLUMN 'applyRules2' TO 'applyRules';`
|
||||
).run();
|
||||
|
||||
db.prepare(
|
||||
`ALTER TABLE 'resources' ADD COLUMN 'emailWhitelistEnabled2' integer;`
|
||||
).run();
|
||||
db.prepare(
|
||||
`UPDATE 'resources' SET "emailWhitelistEnabled2" = "emailWhitelistEnabled";`
|
||||
).run();
|
||||
db.prepare(
|
||||
`ALTER TABLE 'resources' DROP COLUMN 'emailWhitelistEnabled';`
|
||||
).run();
|
||||
db.prepare(
|
||||
`ALTER TABLE 'resources' RENAME COLUMN 'emailWhitelistEnabled2' TO 'emailWhitelistEnabled';`
|
||||
).run();
|
||||
})();
|
||||
|
||||
console.log("Migrated database");
|
||||
} catch (e) {
|
||||
console.log("Failed to migrate db:", e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
console.log(`${version} migration complete`);
|
||||
}
|
||||
@@ -3,7 +3,9 @@ import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { ListOrgLabelsResponse } from "@server/routers/labels/types";
|
||||
import { AxiosResponse } from "axios";
|
||||
import OrgLabelsTable from "@app/components/OrgLabelsTable";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
@@ -49,6 +51,8 @@ export default async function LabelsPage({ params, searchParams }: Props) {
|
||||
description={t("orgLabelsDescription")}
|
||||
/>
|
||||
|
||||
<PaidFeaturesAlert tiers={tierMatrix.labels} />
|
||||
|
||||
<OrgLabelsTable
|
||||
labels={labels}
|
||||
orgId={orgId}
|
||||
|
||||
@@ -43,6 +43,7 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { env } from "process";
|
||||
|
||||
// Schema for general organization settings
|
||||
const GeneralFormSchema = z.object({
|
||||
@@ -165,6 +166,7 @@ function DeleteForm({ org }: SectionFormProps) {
|
||||
|
||||
function GeneralSectionForm({ org }: SectionFormProps) {
|
||||
const { updateOrg } = useOrgContext();
|
||||
const { env } = useEnvContext();
|
||||
const form = useForm({
|
||||
resolver: zodResolver(
|
||||
GeneralFormSchema.pick({
|
||||
@@ -265,36 +267,42 @@ function GeneralSectionForm({ org }: SectionFormProps) {
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.newtAutoUpdate}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="settingsEnableGlobalNewtAutoUpdate"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="settings-enable-global-newt-auto-update"
|
||||
label={t("newtAutoUpdate")}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={!hasAutoUpdateFeature}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("newtAutoUpdateDescription")}{" "}
|
||||
<a
|
||||
href="https://docs.pangolin.net/manage/sites/configure-site#auto-update"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{t("learnMore")}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</a>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{!env.flags.disableEnterpriseFeatures && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="settingsEnableGlobalNewtAutoUpdate"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="settings-enable-global-newt-auto-update"
|
||||
label={t("newtAutoUpdate")}
|
||||
checked={field.value}
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
disabled={
|
||||
!hasAutoUpdateFeature
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("newtAutoUpdateDescription")}{" "}
|
||||
<a
|
||||
href="https://docs.pangolin.net/manage/sites/auto-update"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{t("learnMore")}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</a>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
|
||||
@@ -19,14 +19,14 @@ import {
|
||||
SettingsSectionBody,
|
||||
SettingsSectionDescription,
|
||||
SettingsSectionFooter,
|
||||
SettingsFormCell,
|
||||
SettingsFormGrid,
|
||||
SettingsSectionForm,
|
||||
SettingsSectionHeader,
|
||||
SettingsSectionTitle,
|
||||
SettingsSubsectionDescription,
|
||||
SettingsSubsectionHeader,
|
||||
SettingsSubsectionTitle
|
||||
SettingsSubsectionTitle,
|
||||
SettingsFormCell
|
||||
} from "@app/components/Settings";
|
||||
import { SwitchInput } from "@app/components/SwitchInput";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
@@ -70,7 +70,7 @@ export default function GeneralForm() {
|
||||
|
||||
const api = createApiClient({ env });
|
||||
|
||||
const showResourcePolicy =
|
||||
const hasResourcePolicies =
|
||||
build !== "oss" &&
|
||||
isPaidUser(tierMatrix[TierFeature.ResourcePolicies]);
|
||||
|
||||
@@ -86,7 +86,7 @@ export default function GeneralForm() {
|
||||
...orgQueries.resourcePolicy({
|
||||
resourcePolicyId: selectedSharedPolicyId!
|
||||
}),
|
||||
enabled: showResourcePolicy && selectedSharedPolicyId !== null
|
||||
enabled: hasResourcePolicies && selectedSharedPolicyId !== null
|
||||
});
|
||||
|
||||
const [resourceFullDomain, setResourceFullDomain] = useState(
|
||||
@@ -153,11 +153,10 @@ export default function GeneralForm() {
|
||||
|
||||
let resourcePolicyId: number | null | undefined;
|
||||
|
||||
if (
|
||||
showResourcePolicy &&
|
||||
!["tcp", "udp"].includes(resource.mode)
|
||||
) {
|
||||
resourcePolicyId = selectedSharedPolicyId;
|
||||
if (!["tcp", "udp"].includes(resource.mode)) {
|
||||
if (hasResourcePolicies || selectedSharedPolicyId === null) {
|
||||
resourcePolicyId = selectedSharedPolicyId;
|
||||
}
|
||||
}
|
||||
|
||||
const res = await api
|
||||
@@ -297,28 +296,6 @@ export default function GeneralForm() {
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="full">
|
||||
<SettingsSubsectionHeader>
|
||||
<SettingsSubsectionTitle>
|
||||
{t(
|
||||
"resourceGeneralDetailsSubsection"
|
||||
)}
|
||||
</SettingsSubsectionTitle>
|
||||
<SettingsSubsectionDescription>
|
||||
{t(
|
||||
[
|
||||
"tcp",
|
||||
"udp",
|
||||
].includes(
|
||||
resource.mode
|
||||
)
|
||||
? "resourceGeneralDetailsSubsectionPortDescription"
|
||||
: "resourceGeneralDetailsSubsectionDescription"
|
||||
)}
|
||||
</SettingsSubsectionDescription>
|
||||
</SettingsSubsectionHeader>
|
||||
</SettingsFormCell>
|
||||
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={form.control}
|
||||
@@ -476,10 +453,9 @@ export default function GeneralForm() {
|
||||
</div>
|
||||
</SettingsFormCell>
|
||||
)}
|
||||
{showResourcePolicy &&
|
||||
!["tcp", "udp"].includes(
|
||||
{ !["tcp", "udp"].includes(
|
||||
resource.mode
|
||||
) && (
|
||||
) && !env.flags.disableEnterpriseFeatures && (
|
||||
<>
|
||||
<SettingsFormCell span="full">
|
||||
<SettingsSubsectionHeader>
|
||||
|
||||
@@ -169,20 +169,27 @@ export default function ResourceMaintenancePage() {
|
||||
{
|
||||
id: "automatic",
|
||||
title: `${t("automatic")} (${t("recommended")})`,
|
||||
description: t("automaticModeDescription"),
|
||||
disabled: isMaintenanceDisabled
|
||||
description: t("automaticModeDescription")
|
||||
},
|
||||
{
|
||||
id: "forced",
|
||||
title: t("forced"),
|
||||
description: t("forcedModeDescription"),
|
||||
disabled: isMaintenanceDisabled
|
||||
description: t("forcedModeDescription")
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<>
|
||||
<PaidFeaturesAlert tiers={tierMatrix.maintencePage} />
|
||||
<div
|
||||
className={
|
||||
isMaintenanceDisabled
|
||||
? "pointer-events-none opacity-50"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<SettingsContainer>
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
<SettingsSectionTitle>
|
||||
{t("maintenanceMode")}
|
||||
@@ -193,7 +200,6 @@ export default function ResourceMaintenancePage() {
|
||||
</SettingsSectionHeader>
|
||||
|
||||
<SettingsSectionBody>
|
||||
<PaidFeaturesAlert tiers={tierMatrix.maintencePage} />
|
||||
<SettingsSectionForm variant="half">
|
||||
<Form {...maintenanceForm}>
|
||||
<form
|
||||
@@ -205,46 +211,33 @@ export default function ResourceMaintenancePage() {
|
||||
<FormField
|
||||
control={maintenanceForm.control}
|
||||
name="maintenanceModeEnabled"
|
||||
render={({ field }) => {
|
||||
const isDisabled = !isPaidUser(
|
||||
tierMatrix.maintencePage
|
||||
);
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="enable-maintenance"
|
||||
checked={
|
||||
field.value
|
||||
}
|
||||
label={t(
|
||||
"enableMaintenanceMode"
|
||||
)}
|
||||
description={t(
|
||||
"enableMaintenanceModeDescription"
|
||||
)}
|
||||
disabled={
|
||||
isDisabled
|
||||
}
|
||||
onCheckedChange={(
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<SwitchInput
|
||||
id="enable-maintenance"
|
||||
checked={
|
||||
field.value
|
||||
}
|
||||
label={t(
|
||||
"enableMaintenanceMode"
|
||||
)}
|
||||
description={t(
|
||||
"enableMaintenanceModeDescription"
|
||||
)}
|
||||
onCheckedChange={(
|
||||
val
|
||||
) => {
|
||||
maintenanceForm.setValue(
|
||||
"maintenanceModeEnabled",
|
||||
val
|
||||
) => {
|
||||
if (
|
||||
!isDisabled
|
||||
) {
|
||||
maintenanceForm.setValue(
|
||||
"maintenanceModeEnabled",
|
||||
val
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
@@ -329,11 +322,6 @@ export default function ResourceMaintenancePage() {
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
disabled={
|
||||
!isPaidUser(
|
||||
tierMatrix.maintencePage
|
||||
)
|
||||
}
|
||||
placeholder="We'll be back soon!"
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -365,11 +353,6 @@ export default function ResourceMaintenancePage() {
|
||||
<Textarea
|
||||
{...field}
|
||||
rows={4}
|
||||
disabled={
|
||||
!isPaidUser(
|
||||
tierMatrix.maintencePage
|
||||
)
|
||||
}
|
||||
placeholder={t(
|
||||
"maintenancePageMessagePlaceholder"
|
||||
)}
|
||||
@@ -402,11 +385,6 @@ export default function ResourceMaintenancePage() {
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
disabled={
|
||||
!isPaidUser(
|
||||
tierMatrix.maintencePage
|
||||
)
|
||||
}
|
||||
placeholder={t(
|
||||
"maintenanceTime"
|
||||
)}
|
||||
@@ -430,20 +408,19 @@ export default function ResourceMaintenancePage() {
|
||||
</SettingsSectionForm>
|
||||
</SettingsSectionBody>
|
||||
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={maintenanceSaveLoading}
|
||||
disabled={
|
||||
maintenanceSaveLoading ||
|
||||
!isPaidUser(tierMatrix.maintencePage)
|
||||
}
|
||||
form="maintenance-settings-form"
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
<SettingsSectionFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={maintenanceSaveLoading}
|
||||
disabled={maintenanceSaveLoading}
|
||||
form="maintenance-settings-form"
|
||||
>
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
</SettingsContainer>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -229,7 +229,7 @@ function RdpServerForm({
|
||||
sitesField="selectedSites"
|
||||
destinationField="destination"
|
||||
destinationPortField="destinationPort"
|
||||
learnMoreHref="https://docs.pangolin.net/manage/resources/public/rdp"
|
||||
learnMoreHref="https://docs.pangolin.net/manage/resources/public/rdp#site-and-host-configuration"
|
||||
defaultPort={3389}
|
||||
/>
|
||||
</SettingsSectionForm>
|
||||
|
||||
@@ -467,7 +467,7 @@ function SshServerForm({
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("sshDaemonDisclaimer")}{" "}
|
||||
<a
|
||||
href="https://docs.pangolin.net/manage/resources/public/ssh"
|
||||
href="https://docs.pangolin.net/manage/ssh"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
@@ -589,7 +589,7 @@ function SshServerForm({
|
||||
sitesField="selectedSites"
|
||||
destinationField="destination"
|
||||
destinationPortField="destinationPort"
|
||||
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
|
||||
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh#site-and-host-configuration"
|
||||
defaultPort={22}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
@@ -602,7 +602,7 @@ function SshServerForm({
|
||||
siteField="selectedSite"
|
||||
destinationField="destination"
|
||||
destinationPortField="destinationPort"
|
||||
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
|
||||
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh#site-and-host-configuration"
|
||||
defaultPort={22}
|
||||
/>
|
||||
</SettingsFormCell>
|
||||
|
||||
@@ -80,7 +80,6 @@ import { toASCII } from "punycode";
|
||||
import {
|
||||
useMemo,
|
||||
useState,
|
||||
useTransition,
|
||||
useEffect
|
||||
} from "react";
|
||||
import { useForm, type Resolver } from "react-hook-form";
|
||||
@@ -229,7 +228,7 @@ export default function Page() {
|
||||
>([]);
|
||||
const [loadingExitNodes, setLoadingExitNodes] = useState(build === "saas");
|
||||
|
||||
const [createLoading, startTransition] = useTransition();
|
||||
const [createLoading, setCreateLoading] = useState(false);
|
||||
const [showSnippets, setShowSnippets] = useState(false);
|
||||
const [niceId, setNiceId] = useState<string>("");
|
||||
|
||||
@@ -461,6 +460,7 @@ export default function Page() {
|
||||
};
|
||||
|
||||
async function onSubmit() {
|
||||
setCreateLoading(true);
|
||||
const baseData = baseForm.getValues();
|
||||
|
||||
try {
|
||||
@@ -707,6 +707,8 @@ export default function Page() {
|
||||
t("resourceErrorCreateMessageDescription")
|
||||
)
|
||||
});
|
||||
} finally {
|
||||
setCreateLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -762,7 +764,7 @@ export default function Page() {
|
||||
ssh: "SSH",
|
||||
rdp: "RDP",
|
||||
vnc: "VNC",
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const typeOptions: OptionSelectOption<NewResourceType>[] =
|
||||
@@ -1097,7 +1099,7 @@ export default function Page() {
|
||||
"sshDaemonDisclaimer"
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://docs.pangolin.net/manage/resources/public/ssh"
|
||||
href="https://docs.pangolin.net/manage/ssh"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
@@ -1235,7 +1237,7 @@ export default function Page() {
|
||||
sitesField="selectedSites"
|
||||
destinationField="destination"
|
||||
destinationPortField="destinationPort"
|
||||
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
|
||||
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh#site-and-host-configuration"
|
||||
defaultPort={22}
|
||||
/>
|
||||
</Form>
|
||||
@@ -1256,7 +1258,7 @@ export default function Page() {
|
||||
siteField="selectedSite"
|
||||
destinationField="destination"
|
||||
destinationPortField="destinationPort"
|
||||
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh"
|
||||
learnMoreHref="https://docs.pangolin.net/manage/resources/public/ssh#site-and-host-configuration"
|
||||
defaultPort={22}
|
||||
/>
|
||||
</Form>
|
||||
@@ -1306,7 +1308,7 @@ export default function Page() {
|
||||
sitesField="selectedSites"
|
||||
destinationField="destination"
|
||||
destinationPortField="destinationPort"
|
||||
learnMoreHref="https://docs.pangolin.net/manage/resources/public/rdp"
|
||||
learnMoreHref="https://docs.pangolin.net/manage/resources/public/rdp#site-and-host-configuration"
|
||||
defaultPort={3389}
|
||||
/>
|
||||
</Form>
|
||||
@@ -1353,7 +1355,7 @@ export default function Page() {
|
||||
sitesField="selectedSites"
|
||||
destinationField="destination"
|
||||
destinationPortField="destinationPort"
|
||||
learnMoreHref="https://docs.pangolin.net/manage/resources/public/vnc"
|
||||
learnMoreHref="https://docs.pangolin.net/manage/resources/public/vnc#site-and-host-configuration"
|
||||
defaultPort={5900}
|
||||
/>
|
||||
</Form>
|
||||
@@ -1427,7 +1429,7 @@ export default function Page() {
|
||||
}
|
||||
}}
|
||||
loading={createLoading}
|
||||
disabled={!areAllTargetsValid() || browserGatewayDisabled}
|
||||
disabled={!areAllTargetsValid() || browserGatewayDisabled || createLoading}
|
||||
>
|
||||
{t("resourceCreate")}
|
||||
</Button>
|
||||
|
||||
@@ -253,85 +253,87 @@ export default function GeneralPage() {
|
||||
<PaidFeaturesAlert
|
||||
tiers={tierMatrix.newtAutoUpdate}
|
||||
/>
|
||||
{site && site.type === "newt" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="autoUpdateEnabled"
|
||||
render={({ field }) => {
|
||||
const isOverriding = form.watch(
|
||||
"autoUpdateOverrideOrg"
|
||||
);
|
||||
return (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="">
|
||||
<SwitchInput
|
||||
id="auto-update-enabled"
|
||||
label={t(
|
||||
"siteAutoUpdateLabel"
|
||||
)}
|
||||
checked={
|
||||
field.value
|
||||
}
|
||||
onCheckedChange={(
|
||||
checked
|
||||
) => {
|
||||
field.onChange(
|
||||
{site &&
|
||||
site.type === "newt" &&
|
||||
!env.flags.disableEnterpriseFeatures && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="autoUpdateEnabled"
|
||||
render={({ field }) => {
|
||||
const isOverriding = form.watch(
|
||||
"autoUpdateOverrideOrg"
|
||||
);
|
||||
return (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="">
|
||||
<SwitchInput
|
||||
id="auto-update-enabled"
|
||||
label={t(
|
||||
"siteAutoUpdateLabel"
|
||||
)}
|
||||
checked={
|
||||
field.value
|
||||
}
|
||||
onCheckedChange={(
|
||||
checked
|
||||
);
|
||||
form.setValue(
|
||||
"autoUpdateOverrideOrg",
|
||||
true
|
||||
);
|
||||
}}
|
||||
disabled={
|
||||
!hasAutoUpdateFeature
|
||||
}
|
||||
/>
|
||||
{isOverriding && (
|
||||
<ButtonUI
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="text-sm text-muted-foreground px-0"
|
||||
onClick={() => {
|
||||
) => {
|
||||
field.onChange(
|
||||
checked
|
||||
);
|
||||
form.setValue(
|
||||
"autoUpdateOverrideOrg",
|
||||
false
|
||||
);
|
||||
form.setValue(
|
||||
"autoUpdateEnabled",
|
||||
orgAutoUpdate
|
||||
true
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
"siteAutoUpdateResetToOrg"
|
||||
)}
|
||||
</ButtonUI>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"siteAutoUpdateDescription"
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://docs.pangolin.net/manage/sites/configure-site#auto-update"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{t("learnMore")}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</a>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
disabled={
|
||||
!hasAutoUpdateFeature
|
||||
}
|
||||
/>
|
||||
{isOverriding && (
|
||||
<ButtonUI
|
||||
type="button"
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="text-sm text-muted-foreground px-0"
|
||||
onClick={() => {
|
||||
form.setValue(
|
||||
"autoUpdateOverrideOrg",
|
||||
false
|
||||
);
|
||||
form.setValue(
|
||||
"autoUpdateEnabled",
|
||||
orgAutoUpdate
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
"siteAutoUpdateResetToOrg"
|
||||
)}
|
||||
</ButtonUI>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"siteAutoUpdateDescription"
|
||||
)}{" "}
|
||||
<a
|
||||
href="https://docs.pangolin.net/manage/sites/auto-update"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
{t("learnMore")}
|
||||
<ExternalLink className="size-3.5 shrink-0" />
|
||||
</a>
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsSectionForm>
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
} from "@app/components/ui/form";
|
||||
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||
import { z } from "zod";
|
||||
import { createElement, useEffect, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
@@ -37,15 +37,6 @@ import {
|
||||
InfoSections,
|
||||
InfoSectionTitle
|
||||
} from "@app/components/InfoSection";
|
||||
import {
|
||||
FaApple,
|
||||
FaCubes,
|
||||
FaDocker,
|
||||
FaFreebsd,
|
||||
FaWindows
|
||||
} from "react-icons/fa";
|
||||
import { SiNixos, SiKubernetes } from "react-icons/si";
|
||||
import { Checkbox, CheckboxWithLabel } from "@app/components/ui/checkbox";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { generateKeypair } from "../[niceId]/wireguardConfig";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
@@ -526,7 +517,7 @@ export default function Page() {
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("name")}w
|
||||
{t("name")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
@@ -570,7 +561,7 @@ export default function Page() {
|
||||
</Button>
|
||||
</SettingsFormCell>
|
||||
{showAdvancedSettings && (
|
||||
<SettingsFormCell span="quarter">
|
||||
<SettingsFormCell span="half">
|
||||
<FormField
|
||||
control={
|
||||
form.control
|
||||
|
||||
@@ -156,10 +156,11 @@ export const orgNavSections = (
|
||||
]
|
||||
: []),
|
||||
// PaidFeaturesAlert
|
||||
...((build === "oss" && !env?.flags.disableEnterpriseFeatures) ||
|
||||
build === "saas" ||
|
||||
env?.app.identityProviderMode === "org" ||
|
||||
(env?.app.identityProviderMode === undefined && build !== "oss")
|
||||
...(!env?.flags.disableEnterpriseFeatures &&
|
||||
(build === "saas" ||
|
||||
env?.app.identityProviderMode === "org" ||
|
||||
(env?.app.identityProviderMode === undefined &&
|
||||
build !== "oss"))
|
||||
? [
|
||||
{
|
||||
title: "sidebarIdentityProviders",
|
||||
@@ -259,7 +260,7 @@ export const orgNavSections = (
|
||||
href: "/{orgId}/settings/api-keys",
|
||||
icon: <KeyRound className="size-4 flex-none" />
|
||||
},
|
||||
...(build !== "oss"
|
||||
...(!env?.flags.disableEnterpriseFeatures
|
||||
? [
|
||||
{
|
||||
title: "labels",
|
||||
|
||||
@@ -373,11 +373,7 @@ export default function RdpClient({
|
||||
<PoweredByPangolin />
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{resourceName
|
||||
? `${t("rdpSignInTitle")} - ${resourceName}`
|
||||
: t("rdpSignInTitle")}
|
||||
</CardTitle>
|
||||
<CardTitle>{t("rdpSignInTitle")}</CardTitle>
|
||||
<CardDescription>
|
||||
{resourceName
|
||||
? `${t("rdpSignInDescription")} (${resourceName})`
|
||||
|
||||
@@ -510,11 +510,7 @@ export default function SshClient({
|
||||
<PoweredByPangolin />
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{resourceName
|
||||
? `${t("sshSignInTitle")} - ${resourceName}`
|
||||
: t("sshSignInTitle")}
|
||||
</CardTitle>
|
||||
<CardTitle>{t("sshSignInTitle")}</CardTitle>
|
||||
<CardDescription>
|
||||
{resourceName
|
||||
? `${t("sshSignInDescription")} (${resourceName})`
|
||||
@@ -600,7 +596,7 @@ export default function SshClient({
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("sshPrivateKeyDisclaimer")}{" "}
|
||||
<Link
|
||||
href="https://docs.pangolin.net/"
|
||||
href="https://docs.pangolin.net/manage/ssh#authentication-method"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
|
||||
+16
-20
@@ -124,21 +124,21 @@ export default function VncClient({
|
||||
authToken: target.authToken
|
||||
});
|
||||
|
||||
try {
|
||||
const checkParams = new URLSearchParams(params);
|
||||
checkParams.set("checkOnly", "1");
|
||||
const response = await fetch(`${base}?${checkParams.toString()}`);
|
||||
if (!response.ok) {
|
||||
const detail = (await response.text()).trim();
|
||||
setConnectError(detail || t("sshErrorConnectionClosed"));
|
||||
setConnecting(false);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
setConnectError(t("sshErrorWebSocket"));
|
||||
setConnecting(false);
|
||||
return;
|
||||
}
|
||||
// try {
|
||||
// const checkParams = new URLSearchParams(params);
|
||||
// checkParams.set("checkOnly", "1");
|
||||
// const response = await fetch(`${base}?${checkParams.toString()}`);
|
||||
// if (!response.ok) {
|
||||
// const detail = (await response.text()).trim();
|
||||
// setConnectError(detail || t("sshErrorConnectionClosed"));
|
||||
// setConnecting(false);
|
||||
// return;
|
||||
// }
|
||||
// } catch {
|
||||
// setConnectError(t("sshErrorWebSocket"));
|
||||
// setConnecting(false);
|
||||
// return;
|
||||
// }
|
||||
|
||||
let RFB: new (
|
||||
target: HTMLElement,
|
||||
@@ -252,11 +252,7 @@ export default function VncClient({
|
||||
<PoweredByPangolin />
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{resourceName
|
||||
? `${t("vncTitle")} - ${resourceName}`
|
||||
: t("vncTitle")}
|
||||
</CardTitle>
|
||||
<CardTitle>{t("vncTitle")}</CardTitle>
|
||||
<CardDescription>
|
||||
{resourceName
|
||||
? `${t("vncSignInDescription")} (${resourceName})`
|
||||
|
||||
@@ -144,6 +144,7 @@ export function BrowserGatewayTargetForm<T extends FieldValues>(
|
||||
[]
|
||||
}
|
||||
onSelectionChange={field.onChange}
|
||||
filterTypes={["newt"]}
|
||||
/>
|
||||
) : (
|
||||
<SitesSelector
|
||||
@@ -155,6 +156,7 @@ export function BrowserGatewayTargetForm<T extends FieldValues>(
|
||||
field.onChange(site);
|
||||
setSiteOpen(false);
|
||||
}}
|
||||
filterTypes={["newt"]}
|
||||
/>
|
||||
)}
|
||||
</PopoverContent>
|
||||
@@ -220,7 +222,7 @@ export function BrowserGatewayTargetForm<T extends FieldValues>(
|
||||
<a
|
||||
href={
|
||||
props.learnMoreHref ??
|
||||
"https://docs.pangolin.net/manage/resources/public/ssh"
|
||||
"https://docs.pangolin.net/manage/resources/private/multi-site-routing"
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
CredenzaTitle
|
||||
} from "./Credenza";
|
||||
import { OrgLabelForm } from "./OrgLabelForm";
|
||||
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
export type CreateOrgLabelDialogProps = {
|
||||
@@ -35,6 +38,8 @@ export function CreateOrgLabelDialog({
|
||||
}: CreateOrgLabelDialogProps) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const canManageLabels = isPaidUser(tierMatrix.labels);
|
||||
const [isSubmitting, startTransition] = useTransition();
|
||||
|
||||
async function createOrgLabel(data: { name: string; color: string }) {
|
||||
@@ -79,8 +84,11 @@ export function CreateOrgLabelDialog({
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<PaidFeaturesAlert tiers={tierMatrix.labels} />
|
||||
<OrgLabelForm
|
||||
disabled={!canManageLabels}
|
||||
onSubmit={(data) => {
|
||||
if (!canManageLabels) return;
|
||||
startTransition(async () => createOrgLabel(data));
|
||||
}}
|
||||
/>
|
||||
@@ -98,7 +106,7 @@ export function CreateOrgLabelDialog({
|
||||
<Button
|
||||
type="submit"
|
||||
form="org-label-form"
|
||||
disabled={isSubmitting}
|
||||
disabled={isSubmitting || !canManageLabels}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{t("labelCreate")}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
CredenzaTitle
|
||||
} from "./Credenza";
|
||||
import { OrgLabelForm } from "./OrgLabelForm";
|
||||
import { PaidFeaturesAlert } from "./PaidFeaturesAlert";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
export type EditOrgLabelDialogProps = {
|
||||
@@ -41,6 +44,8 @@ export function EditOrgLabelDialog({
|
||||
}: EditOrgLabelDialogProps) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const canManageLabels = isPaidUser(tierMatrix.labels);
|
||||
const [isSubmitting, startTransition] = useTransition();
|
||||
|
||||
async function editOrgLabel(data: { name: string; color: string }) {
|
||||
@@ -85,9 +90,12 @@ export function EditOrgLabelDialog({
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<PaidFeaturesAlert tiers={tierMatrix.labels} />
|
||||
<OrgLabelForm
|
||||
disabled={!canManageLabels}
|
||||
defaultValue={label}
|
||||
onSubmit={(data) => {
|
||||
if (!canManageLabels) return;
|
||||
startTransition(async () => editOrgLabel(data));
|
||||
}}
|
||||
/>
|
||||
@@ -105,7 +113,7 @@ export function EditOrgLabelDialog({
|
||||
<Button
|
||||
type="submit"
|
||||
form="org-label-form"
|
||||
disabled={isSubmitting}
|
||||
disabled={isSubmitting || !canManageLabels}
|
||||
loading={isSubmitting}
|
||||
>
|
||||
{t("labelEdit")}
|
||||
|
||||
@@ -35,9 +35,14 @@ export type LabelFormData = z.infer<typeof labelFormSchema>;
|
||||
export type OrgLabelFormProps = {
|
||||
onSubmit: (data: LabelFormData) => void;
|
||||
defaultValue?: LabelFormData;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function OrgLabelForm({ onSubmit, defaultValue }: OrgLabelFormProps) {
|
||||
export function OrgLabelForm({
|
||||
onSubmit,
|
||||
defaultValue,
|
||||
disabled = false
|
||||
}: OrgLabelFormProps) {
|
||||
const t = useTranslations();
|
||||
|
||||
const colorValues = Object.values(LABEL_COLORS);
|
||||
@@ -70,9 +75,7 @@ export function OrgLabelForm({ onSubmit, defaultValue }: OrgLabelFormProps) {
|
||||
<FormItem>
|
||||
<FormLabel>{t("labelNameField")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
/>
|
||||
<Input {...field} disabled={disabled} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -88,6 +91,7 @@ export function OrgLabelForm({ onSubmit, defaultValue }: OrgLabelFormProps) {
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
@@ -110,7 +114,9 @@ export function OrgLabelForm({ onSubmit, defaultValue }: OrgLabelFormProps) {
|
||||
}}
|
||||
/>
|
||||
<span data-name>
|
||||
{color.charAt(0).toUpperCase() +
|
||||
{color
|
||||
.charAt(0)
|
||||
.toUpperCase() +
|
||||
color.slice(1)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
|
||||
@@ -12,14 +12,7 @@ import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { type PaginationState } from "@tanstack/react-table";
|
||||
import {
|
||||
ArrowDown01Icon,
|
||||
ArrowUp10Icon,
|
||||
ChevronsUpDownIcon,
|
||||
MoreHorizontal,
|
||||
PencilIcon,
|
||||
PencilLineIcon
|
||||
} from "lucide-react";
|
||||
import { ArrowRight, MoreHorizontal } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useActionState, useMemo, useState, useTransition } from "react";
|
||||
@@ -109,7 +102,7 @@ export default function OrgLabelsTable({
|
||||
cell: ({ row }) => (
|
||||
<div className="flex items-center gap-1.5 group">
|
||||
<div
|
||||
className="size-2.5 rounded-full bg-(--color) flex-none"
|
||||
className="size-2 rounded-full bg-(--color) flex-none"
|
||||
style={{
|
||||
// @ts-expect-error css color
|
||||
"--color": row.original.color
|
||||
@@ -125,34 +118,40 @@ export default function OrgLabelsTable({
|
||||
enableHiding: false,
|
||||
header: () => <span className="p-3"></span>,
|
||||
cell: ({ row }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">{t("openMenu")}</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedLabel(row.original);
|
||||
setIsEditModalOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("edit")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedLabel(row.original);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
{t("delete")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">
|
||||
{t("openMenu")}
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedLabel(row.original);
|
||||
setIsDeleteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
{t("delete")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSelectedLabel(row.original);
|
||||
setIsEditModalOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("edit")}
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
],
|
||||
|
||||
@@ -2079,7 +2079,7 @@ export function PrivateResourceForm({
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("sshDaemonDisclaimer")}{" "}
|
||||
<a
|
||||
href="https://docs.pangolin.net/manage/resources/private/ssh"
|
||||
href="https://docs.pangolin.net/manage/ssh"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
|
||||
@@ -278,7 +278,50 @@ export default function PublicResourcesTable({
|
||||
accessorKey: "protocol",
|
||||
friendlyName: t("protocol"),
|
||||
enableHiding: true,
|
||||
header: () => <span className="p-3">{t("protocol")}</span>,
|
||||
header: () => (
|
||||
<ColumnFilterButton
|
||||
options={[
|
||||
{
|
||||
value: "http",
|
||||
label: t("editInternalResourceDialogModeHttp")
|
||||
},
|
||||
{
|
||||
value: "https",
|
||||
label: t("editInternalResourceDialogModeHttps")
|
||||
},
|
||||
{
|
||||
value: "tcp",
|
||||
label: t("editInternalResourceDialogTcp")
|
||||
},
|
||||
{
|
||||
value: "udp",
|
||||
label: t("editInternalResourceDialogUdp")
|
||||
},
|
||||
{
|
||||
value: "ssh",
|
||||
label: t("editInternalResourceDialogModeSsh")
|
||||
},
|
||||
{
|
||||
value: "rdp",
|
||||
label: t("rdpTitle")
|
||||
},
|
||||
{
|
||||
value: "vnc",
|
||||
label: t("vncTitle")
|
||||
}
|
||||
]}
|
||||
selectedValue={
|
||||
searchParams.get("protocol") ?? undefined
|
||||
}
|
||||
onValueChange={(value) =>
|
||||
handleFilterChange("protocol", value)
|
||||
}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
emptyMessage={t("emptySearchOptions")}
|
||||
label={t("protocol")}
|
||||
className="p-3"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
return (
|
||||
@@ -724,7 +767,7 @@ function TargetStatusCell({
|
||||
|
||||
if (!targets || targets.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2">
|
||||
<div className="flex items-center gap-2 px-0">
|
||||
<StatusIcon status="unknown" />
|
||||
<span className="text-sm">{t("resourcesTableNoTargets")}</span>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { Shield } from "lucide-react";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Shield, ArrowRight } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Link from "next/link";
|
||||
import DismissableBanner from "./DismissableBanner";
|
||||
|
||||
export const ResourcePoliciesBanner = () => {
|
||||
@@ -14,7 +16,22 @@ export const ResourcePoliciesBanner = () => {
|
||||
title={t("resourcePoliciesBannerTitle")}
|
||||
titleIcon={<Shield className="w-5 h-5 text-primary" />}
|
||||
description={t("resourcePoliciesBannerDescription")}
|
||||
/>
|
||||
>
|
||||
<Link
|
||||
href="https://docs.pangolin.net/manage/resources/public/resource-policies"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
|
||||
>
|
||||
{t("resourcePoliciesBannerButtonText")}
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</DismissableBanner>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -42,16 +42,16 @@ export function MultiSelectTagInput<T extends TagValue>({
|
||||
buttonVariants({
|
||||
variant: "outline"
|
||||
}),
|
||||
"justify-between w-full inline-flex",
|
||||
"text-muted-foreground pl-1.5 cursor-text h-9 py-0",
|
||||
"justify-between w-full flex items-center",
|
||||
"text-muted-foreground pl-1.5 cursor-text h-auto min-h-9 py-1.5",
|
||||
"whitespace-normal",
|
||||
"hover:bg-transparent hover:text-muted-foreground",
|
||||
props.disabled && "pointer-events-none opacity-50"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 min-w-0 flex-1",
|
||||
"overflow-x-auto flex-nowrap h-full"
|
||||
"flex items-center gap-1 min-w-0 flex-1 flex-wrap"
|
||||
)}
|
||||
>
|
||||
{props.value.map((option) => {
|
||||
@@ -60,15 +60,13 @@ export function MultiSelectTagInput<T extends TagValue>({
|
||||
<span
|
||||
key={option.id}
|
||||
className={cn(
|
||||
"bg-muted-foreground/10 font-normal text-foreground rounded-sm flex-none",
|
||||
"py-0.5 pl-1.5 pr-0.5 text-xs inline-flex items-center gap-0.5",
|
||||
"bg-muted-foreground/10 font-normal text-foreground rounded-sm shrink-0",
|
||||
"py-0.5 pl-1.5 pr-0.5 text-xs inline-flex items-center gap-0.5 whitespace-nowrap",
|
||||
isLocked && "opacity-60"
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className="max-w-40 text-ellipsis overflow-hidden">
|
||||
{option.text}
|
||||
</span>
|
||||
<span>{option.text}</span>
|
||||
{isLocked ? (
|
||||
<span className="p-0.5 flex-none">
|
||||
<LockIcon className="size-3" />
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "react-icons/fa";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { SiKubernetes, SiNixos } from "react-icons/si";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
|
||||
export type CommandItem = string | { title: string; command: string };
|
||||
|
||||
@@ -50,9 +51,12 @@ export function NewtSiteInstallCommands({
|
||||
version = "latest"
|
||||
}: NewtSiteInstallCommandsProps) {
|
||||
const t = useTranslations();
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const [acceptClients, setAcceptClients] = useState(true);
|
||||
const [allowPangolinSsh, setAllowPangolinSsh] = useState(true);
|
||||
const [allowPangolinSsh, setAllowPangolinSsh] = useState(
|
||||
!env.flags.disableEnterpriseFeatures
|
||||
);
|
||||
const [platform, setPlatform] = useState<Platform>("linux");
|
||||
const [architecture, setArchitecture] = useState(
|
||||
() => getArchitectures(platform)[0]
|
||||
@@ -71,7 +75,11 @@ export function NewtSiteInstallCommands({
|
||||
: "";
|
||||
|
||||
const disableSshFlag =
|
||||
supportsSshOption && !allowPangolinSsh ? " --disable-ssh" : "";
|
||||
supportsSshOption &&
|
||||
!allowPangolinSsh &&
|
||||
!env.flags.disableEnterpriseFeatures
|
||||
? " --disable-ssh"
|
||||
: "";
|
||||
const runAsRootPrefix =
|
||||
supportsSshOption && allowPangolinSsh ? "sudo " : "";
|
||||
|
||||
@@ -306,27 +314,29 @@ WantedBy=default.target`
|
||||
>
|
||||
{t("siteAcceptClientConnectionsDescription")}
|
||||
</p>
|
||||
{supportsSshOption && (
|
||||
<>
|
||||
<div className="flex items-center space-x-2 mb-2 mt-2">
|
||||
<CheckboxWithLabel
|
||||
id="allowPangolinSsh"
|
||||
checked={allowPangolinSsh}
|
||||
onCheckedChange={(checked) => {
|
||||
const value = checked as boolean;
|
||||
setAllowPangolinSsh(value);
|
||||
}}
|
||||
label="Allow Pangolin SSH"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
id="allowPangolinSsh-desc"
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
{t("sitePangolinSshDescription")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{supportsSshOption &&
|
||||
!env.flags.disableEnterpriseFeatures && (
|
||||
<>
|
||||
<div className="flex items-center space-x-2 mb-2 mt-2">
|
||||
<CheckboxWithLabel
|
||||
id="allowPangolinSsh"
|
||||
checked={allowPangolinSsh}
|
||||
onCheckedChange={(checked) => {
|
||||
const value =
|
||||
checked as boolean;
|
||||
setAllowPangolinSsh(value);
|
||||
}}
|
||||
label="Allow Pangolin SSH"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
id="allowPangolinSsh-desc"
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
{t("sitePangolinSshDescription")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -354,7 +364,7 @@ WantedBy=default.target`
|
||||
{t.rich("siteInstallAdvantechDocsDescription", {
|
||||
docsLink: (chunks) => (
|
||||
<a
|
||||
href="https://docs.pangolin.net/manage/sites/install-advantech"
|
||||
href="https://docs.pangolin.net/manage/sites/install-site"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
|
||||
@@ -8,13 +8,14 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { build } from "@server/build";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { EditPolicyNameSectionForm } from "./EditPolicyNameSectionForm";
|
||||
import { PolicyAuthStackSection } from "./PolicyAuthStackSection";
|
||||
import { PolicyAccessRulesSection } from "./PolicyAccessRulesSection";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
|
||||
export type EditPolicyFormSection = "general" | "authentication" | "rules";
|
||||
|
||||
@@ -71,13 +72,19 @@ export function EditPolicyForm({
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const policyTiers = tierMatrix[TierFeature.ResourcePolicies];
|
||||
const isInlinePolicy = hidePolicyNameForm && resourceId === undefined;
|
||||
const showPaidAlert = !isInlinePolicy;
|
||||
const isDisabled = showPaidAlert && !isPaidUser(policyTiers);
|
||||
const effectiveReadonly = readonly || isDisabled;
|
||||
|
||||
const authSection = (
|
||||
<PolicyAuthStackSection
|
||||
mode="edit"
|
||||
orgId={org.org.orgId}
|
||||
allIdps={allIdps}
|
||||
emailEnabled={env.email.emailEnabled}
|
||||
readonly={readonly}
|
||||
readonly={effectiveReadonly}
|
||||
resourceId={resourceId}
|
||||
/>
|
||||
);
|
||||
@@ -87,32 +94,82 @@ export function EditPolicyForm({
|
||||
mode="edit"
|
||||
isMaxmindAvailable={isMaxmindAvailable}
|
||||
isMaxmindAsnAvailable={isMaxmindASNAvailable}
|
||||
readonly={readonly}
|
||||
readonly={effectiveReadonly}
|
||||
resourceId={resourceId}
|
||||
/>
|
||||
);
|
||||
|
||||
if (section === "general") {
|
||||
return <EditPolicyNameSectionForm readonly={readonly} />;
|
||||
return (
|
||||
<>
|
||||
{showPaidAlert && <PaidFeaturesAlert tiers={policyTiers} />}
|
||||
<div
|
||||
className={
|
||||
isDisabled
|
||||
? "pointer-events-none opacity-50"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<EditPolicyNameSectionForm readonly={effectiveReadonly} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (section === "authentication") {
|
||||
return authSection;
|
||||
return (
|
||||
<>
|
||||
{showPaidAlert && <PaidFeaturesAlert tiers={policyTiers} />}
|
||||
<div
|
||||
className={
|
||||
isDisabled
|
||||
? "pointer-events-none opacity-50"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{authSection}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (section === "rules") {
|
||||
return rulesSection;
|
||||
return (
|
||||
<>
|
||||
{showPaidAlert && <PaidFeaturesAlert tiers={policyTiers} />}
|
||||
<div
|
||||
className={
|
||||
isDisabled
|
||||
? "pointer-events-none opacity-50"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{rulesSection}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContainer>
|
||||
{!hidePolicyNameForm && !isOverlay && (
|
||||
<EditPolicyNameSectionForm readonly={readonly} />
|
||||
)}
|
||||
<>
|
||||
{showPaidAlert && <PaidFeaturesAlert tiers={policyTiers} />}
|
||||
<div
|
||||
className={
|
||||
isDisabled ? "pointer-events-none opacity-50" : undefined
|
||||
}
|
||||
>
|
||||
<SettingsContainer>
|
||||
{!hidePolicyNameForm && !isOverlay && (
|
||||
<EditPolicyNameSectionForm
|
||||
readonly={effectiveReadonly}
|
||||
/>
|
||||
)}
|
||||
|
||||
{authSection}
|
||||
{authSection}
|
||||
|
||||
{rulesSection}
|
||||
</SettingsContainer>
|
||||
{rulesSection}
|
||||
</SettingsContainer>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user