mirror of
https://github.com/fosrl/pangolin.git
synced 2026-04-12 06:07:18 +00:00
Compare commits
19 Commits
dependabot
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e118e5b047 | ||
|
|
7e4e8ea266 | ||
|
|
2f386f8e47 | ||
|
|
f4ea572f6b | ||
|
|
825df7da63 | ||
|
|
cd34f0a7b0 | ||
|
|
b1b22c439a | ||
|
|
eac747849b | ||
|
|
1aedf9da0a | ||
|
|
840684aeba | ||
|
|
f57012eb90 | ||
|
|
34387d9859 | ||
|
|
80f5914fdd | ||
|
|
eaa70da4dd | ||
|
|
466f137590 | ||
|
|
028df8bf27 | ||
|
|
28ef5238c9 | ||
|
|
7d3d5b2b22 | ||
|
|
81eba50c9a |
1
.github/CODEOWNERS
vendored
Normal file
1
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* @oschwartz10612 @miloschwartz
|
||||
@@ -2113,9 +2113,11 @@
|
||||
"addDomainToEnableCustomAuthPages": "Users will be able to access the organization's login page and complete resource authentication using this domain.",
|
||||
"selectDomainForOrgAuthPage": "Select a domain for the organization's authentication page",
|
||||
"domainPickerProvidedDomain": "Provided Domain",
|
||||
"domainPickerFreeProvidedDomain": "Free Provided Domain",
|
||||
"domainPickerFreeProvidedDomain": "Provided Domain",
|
||||
"domainPickerFreeDomainsPaidFeature": "Provided domains are a paid feature. Subscribe to get a domain included with your plan — no need to bring your own.",
|
||||
"domainPickerVerified": "Verified",
|
||||
"domainPickerUnverified": "Unverified",
|
||||
"domainPickerManual": "Manual",
|
||||
"domainPickerInvalidSubdomainStructure": "This subdomain contains invalid characters or structure. It will be sanitized automatically when you save.",
|
||||
"domainPickerError": "Error",
|
||||
"domainPickerErrorLoadDomains": "Failed to load organization domains",
|
||||
|
||||
@@ -19,7 +19,8 @@ export enum TierFeature {
|
||||
SshPam = "sshPam",
|
||||
FullRbac = "fullRbac",
|
||||
SiteProvisioningKeys = "siteProvisioningKeys", // handle downgrade by revoking keys if needed
|
||||
SIEM = "siem" // handle downgrade by disabling SIEM integrations
|
||||
SIEM = "siem", // handle downgrade by disabling SIEM integrations
|
||||
DomainNamespaces = "domainNamespaces" // handle downgrade by removing custom domain namespaces
|
||||
}
|
||||
|
||||
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||
@@ -56,5 +57,6 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||
[TierFeature.SshPam]: ["tier1", "tier3", "enterprise"],
|
||||
[TierFeature.FullRbac]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.SiteProvisioningKeys]: ["tier3", "enterprise"],
|
||||
[TierFeature.SIEM]: ["enterprise"]
|
||||
[TierFeature.SIEM]: ["enterprise"],
|
||||
[TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"]
|
||||
};
|
||||
|
||||
@@ -22,11 +22,15 @@ import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { db, domainNamespaces, resources } from "@server/db";
|
||||
import { inArray } from "drizzle-orm";
|
||||
import { CheckDomainAvailabilityResponse } from "@server/routers/domain/types";
|
||||
import { build } from "@server/build";
|
||||
import { isSubscribed } from "#private/lib/isSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
const paramsSchema = z.strictObject({});
|
||||
|
||||
const querySchema = z.strictObject({
|
||||
subdomain: z.string()
|
||||
subdomain: z.string(),
|
||||
// orgId: build === "saas" ? z.string() : z.string().optional() // Required for saas, optional otherwise
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
@@ -58,6 +62,23 @@ export async function checkDomainNamespaceAvailability(
|
||||
}
|
||||
const { subdomain } = parsedQuery.data;
|
||||
|
||||
// if (
|
||||
// build == "saas" &&
|
||||
// !isSubscribed(orgId!, tierMatrix.domainNamespaces)
|
||||
// ) {
|
||||
// // return not available
|
||||
// return response<CheckDomainAvailabilityResponse>(res, {
|
||||
// data: {
|
||||
// available: false,
|
||||
// options: []
|
||||
// },
|
||||
// success: true,
|
||||
// error: false,
|
||||
// message: "Your current subscription does not support custom domain namespaces. Please upgrade to access this feature.",
|
||||
// status: HttpCode.OK
|
||||
// });
|
||||
// }
|
||||
|
||||
const namespaces = await db.select().from(domainNamespaces);
|
||||
let possibleDomains = namespaces.map((ns) => {
|
||||
const desired = `${subdomain}.${ns.domainNamespaceId}`;
|
||||
|
||||
@@ -22,6 +22,9 @@ import { eq, sql } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { isSubscribed } from "#private/lib/isSubscribed";
|
||||
import { build } from "@server/build";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
const paramsSchema = z.strictObject({});
|
||||
|
||||
@@ -37,7 +40,8 @@ const querySchema = z.strictObject({
|
||||
.optional()
|
||||
.default("0")
|
||||
.transform(Number)
|
||||
.pipe(z.int().nonnegative())
|
||||
.pipe(z.int().nonnegative()),
|
||||
// orgId: build === "saas" ? z.string() : z.string().optional() // Required for saas, optional otherwise
|
||||
});
|
||||
|
||||
async function query(limit: number, offset: number) {
|
||||
@@ -99,6 +103,26 @@ export async function listDomainNamespaces(
|
||||
);
|
||||
}
|
||||
|
||||
// if (
|
||||
// build == "saas" &&
|
||||
// !isSubscribed(orgId!, tierMatrix.domainNamespaces)
|
||||
// ) {
|
||||
// return response<ListDomainNamespacesResponse>(res, {
|
||||
// data: {
|
||||
// domainNamespaces: [],
|
||||
// pagination: {
|
||||
// total: 0,
|
||||
// limit,
|
||||
// offset
|
||||
// }
|
||||
// },
|
||||
// success: true,
|
||||
// error: false,
|
||||
// message: "No namespaces found. Your current subscription does not support custom domain namespaces. Please upgrade to access this feature.",
|
||||
// status: HttpCode.OK
|
||||
// });
|
||||
// }
|
||||
|
||||
const domainNamespacesList = await query(limit, offset);
|
||||
|
||||
const [{ count }] = await db
|
||||
|
||||
@@ -440,6 +440,12 @@ authenticated.get(
|
||||
resource.getUserResources
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/user-resource-aliases",
|
||||
verifyOrgAccess,
|
||||
resource.listUserResourceAliases
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/domains",
|
||||
verifyOrgAccess,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, loginPage } from "@server/db";
|
||||
import { db, domainNamespaces, loginPage } from "@server/db";
|
||||
import {
|
||||
domains,
|
||||
orgDomains,
|
||||
@@ -24,6 +24,8 @@ import { build } from "@server/build";
|
||||
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
||||
import { getUniqueResourceName } from "@server/db/names";
|
||||
import { validateAndConstructDomain } from "@server/lib/domainUtils";
|
||||
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
|
||||
const createResourceParamsSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
@@ -112,7 +114,10 @@ export async function createResource(
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
|
||||
if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) {
|
||||
if (
|
||||
req.user &&
|
||||
(!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)
|
||||
) {
|
||||
return next(
|
||||
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
|
||||
);
|
||||
@@ -193,6 +198,29 @@ async function createHttpResource(
|
||||
const subdomain = parsedBody.data.subdomain;
|
||||
const stickySession = parsedBody.data.stickySession;
|
||||
|
||||
if (build == "saas" && !isSubscribed(orgId!, tierMatrix.domainNamespaces)) {
|
||||
// grandfather in existing users
|
||||
const lastAllowedDate = new Date("2026-04-12");
|
||||
const userCreatedDate = new Date(req.user?.dateCreated || new Date());
|
||||
if (userCreatedDate > lastAllowedDate) {
|
||||
// check if this domain id is a namespace domain and if so, reject
|
||||
const domain = await db
|
||||
.select()
|
||||
.from(domainNamespaces)
|
||||
.where(eq(domainNamespaces.domainId, domainId))
|
||||
.limit(1);
|
||||
|
||||
if (domain.length > 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Your current subscription does not support custom domain namespaces. Please upgrade to access this feature."
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate domain and construct full domain
|
||||
const domainResult = await validateAndConstructDomain(
|
||||
domainId,
|
||||
|
||||
@@ -142,6 +142,7 @@ export async function getUserResources(
|
||||
let siteResourcesData: Array<{
|
||||
siteResourceId: number;
|
||||
name: string;
|
||||
niceId: string;
|
||||
destination: string;
|
||||
mode: string;
|
||||
protocol: string | null;
|
||||
@@ -154,6 +155,7 @@ export async function getUserResources(
|
||||
.select({
|
||||
siteResourceId: siteResources.siteResourceId,
|
||||
name: siteResources.name,
|
||||
niceId: siteResources.niceId,
|
||||
destination: siteResources.destination,
|
||||
mode: siteResources.mode,
|
||||
protocol: siteResources.protocol,
|
||||
@@ -249,7 +251,7 @@ export async function getUserResources(
|
||||
});
|
||||
|
||||
return response(res, {
|
||||
data: {
|
||||
data: {
|
||||
resources: resourcesWithAuth,
|
||||
siteResources: siteResourcesFormatted
|
||||
},
|
||||
|
||||
@@ -22,6 +22,7 @@ export * from "./deleteResourceRule";
|
||||
export * from "./listResourceRules";
|
||||
export * from "./updateResourceRule";
|
||||
export * from "./getUserResources";
|
||||
export * from "./listUserResourceAliases";
|
||||
export * from "./setResourceHeaderAuth";
|
||||
export * from "./addEmailToResourceWhitelist";
|
||||
export * from "./removeEmailFromResourceWhitelist";
|
||||
|
||||
262
server/routers/resource/listUserResourceAliases.ts
Normal file
262
server/routers/resource/listUserResourceAliases.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import {
|
||||
db,
|
||||
siteResources,
|
||||
userSiteResources,
|
||||
roleSiteResources,
|
||||
userOrgRoles,
|
||||
userOrgs
|
||||
} from "@server/db";
|
||||
import { and, eq, inArray, asc, isNotNull, ne } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { z } from "zod";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
import type { PaginatedResponse } from "@server/types/Pagination";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { localCache } from "#dynamic/lib/cache";
|
||||
|
||||
const USER_RESOURCE_ALIASES_CACHE_TTL_SEC = 60;
|
||||
|
||||
function userResourceAliasesCacheKey(
|
||||
orgId: string,
|
||||
userId: string,
|
||||
page: number,
|
||||
pageSize: number
|
||||
) {
|
||||
return `userResourceAliases:${orgId}:${userId}:${page}:${pageSize}`;
|
||||
}
|
||||
|
||||
const listUserResourceAliasesParamsSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
const listUserResourceAliasesQuerySchema = z.object({
|
||||
pageSize: z.coerce
|
||||
.number<string>()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.catch(20)
|
||||
.default(20)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 20,
|
||||
description: "Number of items per page"
|
||||
}),
|
||||
page: z.coerce
|
||||
.number<string>()
|
||||
.int()
|
||||
.min(0)
|
||||
.optional()
|
||||
.catch(1)
|
||||
.default(1)
|
||||
.openapi({
|
||||
type: "integer",
|
||||
default: 1,
|
||||
description: "Page number to retrieve"
|
||||
})
|
||||
});
|
||||
|
||||
export type ListUserResourceAliasesResponse = PaginatedResponse<{
|
||||
aliases: string[];
|
||||
}>;
|
||||
|
||||
// registry.registerPath({
|
||||
// method: "get",
|
||||
// path: "/org/{orgId}/user-resource-aliases",
|
||||
// description:
|
||||
// "List private (host-mode) site resource aliases the authenticated user can access in the organization, paginated.",
|
||||
// tags: [OpenAPITags.PrivateResource],
|
||||
// request: {
|
||||
// params: z.object({
|
||||
// orgId: z.string()
|
||||
// }),
|
||||
// query: listUserResourceAliasesQuerySchema
|
||||
// },
|
||||
// responses: {}
|
||||
// });
|
||||
|
||||
export async function listUserResourceAliases(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedQuery = listUserResourceAliasesQuerySchema.safeParse(
|
||||
req.query
|
||||
);
|
||||
if (!parsedQuery.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromZodError(parsedQuery.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
const { page, pageSize } = parsedQuery.data;
|
||||
|
||||
const parsedParams = listUserResourceAliasesParamsSchema.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromZodError(parsedParams.error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId } = parsedParams.data;
|
||||
const userId = req.user?.userId;
|
||||
|
||||
if (!userId) {
|
||||
return next(
|
||||
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
|
||||
);
|
||||
}
|
||||
|
||||
const [userOrg] = await db
|
||||
.select()
|
||||
.from(userOrgs)
|
||||
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (!userOrg) {
|
||||
return next(
|
||||
createHttpError(HttpCode.FORBIDDEN, "User not in organization")
|
||||
);
|
||||
}
|
||||
|
||||
const cacheKey = userResourceAliasesCacheKey(
|
||||
orgId,
|
||||
userId,
|
||||
page,
|
||||
pageSize
|
||||
);
|
||||
const cachedData: ListUserResourceAliasesResponse | undefined =
|
||||
localCache.get(cacheKey);
|
||||
|
||||
if (cachedData) {
|
||||
return response<ListUserResourceAliasesResponse>(res, {
|
||||
data: cachedData,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "User resource aliases retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
}
|
||||
|
||||
const userRoleIds = await db
|
||||
.select({ roleId: userOrgRoles.roleId })
|
||||
.from(userOrgRoles)
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgRoles.userId, userId),
|
||||
eq(userOrgRoles.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.then((rows) => rows.map((r) => r.roleId));
|
||||
|
||||
const directSiteResourcesQuery = db
|
||||
.select({ siteResourceId: userSiteResources.siteResourceId })
|
||||
.from(userSiteResources)
|
||||
.where(eq(userSiteResources.userId, userId));
|
||||
|
||||
const roleSiteResourcesQuery =
|
||||
userRoleIds.length > 0
|
||||
? db
|
||||
.select({
|
||||
siteResourceId: roleSiteResources.siteResourceId
|
||||
})
|
||||
.from(roleSiteResources)
|
||||
.where(inArray(roleSiteResources.roleId, userRoleIds))
|
||||
: Promise.resolve([]);
|
||||
|
||||
const [directSiteResourceResults, roleSiteResourceResults] =
|
||||
await Promise.all([
|
||||
directSiteResourcesQuery,
|
||||
roleSiteResourcesQuery
|
||||
]);
|
||||
|
||||
const accessibleSiteResourceIds = [
|
||||
...directSiteResourceResults.map((r) => r.siteResourceId),
|
||||
...roleSiteResourceResults.map((r) => r.siteResourceId)
|
||||
];
|
||||
|
||||
if (accessibleSiteResourceIds.length === 0) {
|
||||
const data: ListUserResourceAliasesResponse = {
|
||||
aliases: [],
|
||||
pagination: {
|
||||
total: 0,
|
||||
pageSize,
|
||||
page
|
||||
}
|
||||
};
|
||||
localCache.set(cacheKey, data, USER_RESOURCE_ALIASES_CACHE_TTL_SEC);
|
||||
return response<ListUserResourceAliasesResponse>(res, {
|
||||
data,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "User resource aliases retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
}
|
||||
|
||||
const whereClause = and(
|
||||
eq(siteResources.orgId, orgId),
|
||||
eq(siteResources.enabled, true),
|
||||
eq(siteResources.mode, "host"),
|
||||
isNotNull(siteResources.alias),
|
||||
ne(siteResources.alias, ""),
|
||||
inArray(siteResources.siteResourceId, accessibleSiteResourceIds)
|
||||
);
|
||||
|
||||
const baseSelect = () =>
|
||||
db
|
||||
.select({ alias: siteResources.alias })
|
||||
.from(siteResources)
|
||||
.where(whereClause);
|
||||
|
||||
const countQuery = db.$count(baseSelect().as("filtered_aliases"));
|
||||
|
||||
const [rows, totalCount] = await Promise.all([
|
||||
baseSelect()
|
||||
.orderBy(asc(siteResources.alias))
|
||||
.limit(pageSize)
|
||||
.offset(pageSize * (page - 1)),
|
||||
countQuery
|
||||
]);
|
||||
|
||||
const aliases = rows.map((r) => r.alias as string);
|
||||
|
||||
const data: ListUserResourceAliasesResponse = {
|
||||
aliases,
|
||||
pagination: {
|
||||
total: totalCount,
|
||||
pageSize,
|
||||
page
|
||||
}
|
||||
};
|
||||
localCache.set(cacheKey, data, USER_RESOURCE_ALIASES_CACHE_TTL_SEC);
|
||||
|
||||
return response<ListUserResourceAliasesResponse>(res, {
|
||||
data,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "User resource aliases retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Internal server error"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, loginPage } from "@server/db";
|
||||
import { db, domainNamespaces, loginPage } from "@server/db";
|
||||
import {
|
||||
domains,
|
||||
Org,
|
||||
@@ -25,6 +25,7 @@ import { validateAndConstructDomain } from "@server/lib/domainUtils";
|
||||
import { build } from "@server/build";
|
||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { isSubscribed } from "#dynamic/lib/isSubscribed";
|
||||
|
||||
const updateResourceParamsSchema = z.strictObject({
|
||||
resourceId: z.string().transform(Number).pipe(z.int().positive())
|
||||
@@ -120,7 +121,9 @@ const updateHttpResourceBodySchema = z
|
||||
if (data.headers) {
|
||||
// HTTP header values must be visible ASCII or horizontal whitespace, no control chars (RFC 7230)
|
||||
const validHeaderValue = /^[\t\x20-\x7E]*$/;
|
||||
return data.headers.every((h) => validHeaderValue.test(h.value));
|
||||
return data.headers.every((h) =>
|
||||
validHeaderValue.test(h.value)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
@@ -318,6 +321,34 @@ async function updateHttpResource(
|
||||
if (updateData.domainId) {
|
||||
const domainId = updateData.domainId;
|
||||
|
||||
if (
|
||||
build == "saas" &&
|
||||
!isSubscribed(resource.orgId, tierMatrix.domainNamespaces)
|
||||
) {
|
||||
// grandfather in existing users
|
||||
const lastAllowedDate = new Date("2026-04-12");
|
||||
const userCreatedDate = new Date(
|
||||
req.user?.dateCreated || new Date()
|
||||
);
|
||||
if (userCreatedDate > lastAllowedDate) {
|
||||
// check if this domain id is a namespace domain and if so, reject
|
||||
const domain = await db
|
||||
.select()
|
||||
.from(domainNamespaces)
|
||||
.where(eq(domainNamespaces.domainId, domainId))
|
||||
.limit(1);
|
||||
|
||||
if (domain.length > 0) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Your current subscription does not support custom domain namespaces. Please upgrade to access this feature."
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate domain and construct full domain
|
||||
const domainResult = await validateAndConstructDomain(
|
||||
domainId,
|
||||
@@ -366,7 +397,7 @@ async function updateHttpResource(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (build != "oss") {
|
||||
const existingLoginPages = await db
|
||||
.select()
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { orgs, roles, userInviteRoles, userInvites, userOrgs, users } from "@server/db";
|
||||
import {
|
||||
orgs,
|
||||
roles,
|
||||
userInviteRoles,
|
||||
userInvites,
|
||||
userOrgs,
|
||||
users
|
||||
} from "@server/db";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
@@ -37,8 +44,7 @@ const inviteUserBodySchema = z
|
||||
regenerate: z.boolean().optional()
|
||||
})
|
||||
.refine(
|
||||
(d) =>
|
||||
(d.roleIds != null && d.roleIds.length > 0) || d.roleId != null,
|
||||
(d) => (d.roleIds != null && d.roleIds.length > 0) || d.roleId != null,
|
||||
{ message: "roleIds or roleId is required", path: ["roleIds"] }
|
||||
)
|
||||
.transform((data) => ({
|
||||
@@ -265,7 +271,7 @@ export async function inviteUser(
|
||||
)
|
||||
);
|
||||
|
||||
const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${encodeURIComponent(email)}`;
|
||||
const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${email}`;
|
||||
|
||||
if (doEmail) {
|
||||
await sendEmail(
|
||||
@@ -314,12 +320,12 @@ export async function inviteUser(
|
||||
expiresAt,
|
||||
tokenHash
|
||||
});
|
||||
await trx.insert(userInviteRoles).values(
|
||||
uniqueRoleIds.map((roleId) => ({ inviteId, roleId }))
|
||||
);
|
||||
await trx
|
||||
.insert(userInviteRoles)
|
||||
.values(uniqueRoleIds.map((roleId) => ({ inviteId, roleId })));
|
||||
});
|
||||
|
||||
const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${encodeURIComponent(email)}`;
|
||||
const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${email}`;
|
||||
|
||||
if (doEmail) {
|
||||
await sendEmail(
|
||||
|
||||
@@ -235,7 +235,9 @@ export default async function migration() {
|
||||
for (const row of existingUserInviteRoles) {
|
||||
await db.execute(sql`
|
||||
INSERT INTO "userInviteRoles" ("inviteId", "roleId")
|
||||
VALUES (${row.inviteId}, ${row.roleId})
|
||||
SELECT ${row.inviteId}, ${row.roleId}
|
||||
WHERE EXISTS (SELECT 1 FROM "userInvites" WHERE "inviteId" = ${row.inviteId})
|
||||
AND EXISTS (SELECT 1 FROM "roles" WHERE "roleId" = ${row.roleId})
|
||||
ON CONFLICT DO NOTHING
|
||||
`);
|
||||
}
|
||||
@@ -258,7 +260,10 @@ export default async function migration() {
|
||||
for (const row of existingUserOrgRoles) {
|
||||
await db.execute(sql`
|
||||
INSERT INTO "userOrgRoles" ("userId", "orgId", "roleId")
|
||||
VALUES (${row.userId}, ${row.orgId}, ${row.roleId})
|
||||
SELECT ${row.userId}, ${row.orgId}, ${row.roleId}
|
||||
WHERE EXISTS (SELECT 1 FROM "user" WHERE "id" = ${row.userId})
|
||||
AND EXISTS (SELECT 1 FROM "orgs" WHERE "orgId" = ${row.orgId})
|
||||
AND EXISTS (SELECT 1 FROM "roles" WHERE "roleId" = ${row.roleId})
|
||||
ON CONFLICT DO NOTHING
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ export default async function migration() {
|
||||
).run();
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO '__new_userOrgs'("userId", "orgId", "isOwner", "autoProvisioned", "pamUsername") SELECT "userId", "orgId", "isOwner", "autoProvisioned", "pamUsername" FROM 'userOrgs';`
|
||||
`INSERT INTO '__new_userOrgs'("userId", "orgId", "isOwner", "autoProvisioned", "pamUsername") SELECT "userId", "orgId", "isOwner", "autoProvisioned", "pamUsername" FROM 'userOrgs' WHERE EXISTS (SELECT 1 FROM 'user' WHERE id = userOrgs.userId) AND EXISTS (SELECT 1 FROM 'orgs' WHERE orgId = userOrgs.orgId);`
|
||||
).run();
|
||||
db.prepare(`DROP TABLE 'userOrgs';`).run();
|
||||
db.prepare(
|
||||
@@ -246,12 +246,15 @@ export default async function migration() {
|
||||
// Re-insert the preserved invite role assignments into the new userInviteRoles table
|
||||
if (existingUserInviteRoles.length > 0) {
|
||||
const insertUserInviteRole = db.prepare(
|
||||
`INSERT OR IGNORE INTO 'userInviteRoles' ("inviteId", "roleId") VALUES (?, ?)`
|
||||
`INSERT OR IGNORE INTO 'userInviteRoles' ("inviteId", "roleId")
|
||||
SELECT ?, ?
|
||||
WHERE EXISTS (SELECT 1 FROM 'userInvites' WHERE inviteId = ?)
|
||||
AND EXISTS (SELECT 1 FROM 'roles' WHERE roleId = ?)`
|
||||
);
|
||||
|
||||
const insertAll = db.transaction(() => {
|
||||
for (const row of existingUserInviteRoles) {
|
||||
insertUserInviteRole.run(row.inviteId, row.roleId);
|
||||
insertUserInviteRole.run(row.inviteId, row.roleId, row.inviteId, row.roleId);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -265,12 +268,16 @@ export default async function migration() {
|
||||
// Re-insert the preserved role assignments into the new userOrgRoles table
|
||||
if (existingUserOrgRoles.length > 0) {
|
||||
const insertUserOrgRole = db.prepare(
|
||||
`INSERT OR IGNORE INTO 'userOrgRoles' ("userId", "orgId", "roleId") VALUES (?, ?, ?)`
|
||||
`INSERT OR IGNORE INTO 'userOrgRoles' ("userId", "orgId", "roleId")
|
||||
SELECT ?, ?, ?
|
||||
WHERE EXISTS (SELECT 1 FROM 'user' WHERE id = ?)
|
||||
AND EXISTS (SELECT 1 FROM 'orgs' WHERE orgId = ?)
|
||||
AND EXISTS (SELECT 1 FROM 'roles' WHERE roleId = ?)`
|
||||
);
|
||||
|
||||
const insertAll = db.transaction(() => {
|
||||
for (const row of existingUserOrgRoles) {
|
||||
insertUserOrgRole.run(row.userId, row.orgId, row.roleId);
|
||||
insertUserOrgRole.run(row.userId, row.orgId, row.roleId, row.userId, row.orgId, row.roleId);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { GetDNSRecordsResponse } from "@server/routers/domain";
|
||||
import DNSRecordsTable from "@app/components/DNSRecordTable";
|
||||
import DomainCertForm from "@app/components/DomainCertForm";
|
||||
import { build } from "@server/build";
|
||||
|
||||
interface DomainSettingsPageProps {
|
||||
params: Promise<{ domainId: string; orgId: string }>;
|
||||
@@ -65,12 +66,14 @@ export default async function DomainSettingsPage({
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<DomainInfoCard
|
||||
failed={domain.failed}
|
||||
verified={domain.verified}
|
||||
type={domain.type}
|
||||
errorMessage={domain.errorMessage}
|
||||
/>
|
||||
{build != "oss" && env.flags.usePangolinDns ? (
|
||||
<DomainInfoCard
|
||||
failed={domain.failed}
|
||||
verified={domain.verified}
|
||||
type={domain.type}
|
||||
errorMessage={domain.errorMessage}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<DNSRecordsTable records={dnsRecords} type={domain.type} />
|
||||
|
||||
|
||||
@@ -678,6 +678,7 @@ function ProxyResourceTargetsForm({
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getRowId: (row) => String(row.targetId),
|
||||
state: {
|
||||
pagination: {
|
||||
pageIndex: 0,
|
||||
|
||||
@@ -999,6 +999,7 @@ export default function Page() {
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getRowId: (row) => String(row.targetId),
|
||||
state: {
|
||||
pagination: {
|
||||
pageIndex: 0,
|
||||
|
||||
@@ -154,7 +154,7 @@ export default function CreateDomainForm({
|
||||
|
||||
const punycodePreview = useMemo(() => {
|
||||
if (!baseDomain) return "";
|
||||
const punycode = toPunycode(baseDomain);
|
||||
const punycode = toPunycode(baseDomain.toLowerCase());
|
||||
return punycode !== baseDomain.toLowerCase() ? punycode : "";
|
||||
}, [baseDomain]);
|
||||
|
||||
@@ -239,21 +239,24 @@ export default function CreateDomainForm({
|
||||
className="space-y-4"
|
||||
id="create-domain-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<StrategySelect
|
||||
options={domainOptions}
|
||||
defaultValue={field.value}
|
||||
onChange={field.onChange}
|
||||
cols={1}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{build != "oss" && env.flags.usePangolinDns ? (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<StrategySelect
|
||||
options={domainOptions}
|
||||
defaultValue={field.value}
|
||||
onChange={field.onChange}
|
||||
cols={1}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="baseDomain"
|
||||
|
||||
@@ -319,6 +319,7 @@ export default function DeviceLoginForm({
|
||||
<div className="flex justify-center">
|
||||
<InputOTP
|
||||
maxLength={9}
|
||||
pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
|
||||
{...field}
|
||||
value={field.value
|
||||
.replace(/-/g, "")
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -40,9 +41,12 @@ import {
|
||||
Check,
|
||||
CheckCircle2,
|
||||
ChevronsUpDown,
|
||||
KeyRound,
|
||||
Zap
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { usePaidStatus } from "@/hooks/usePaidStatus";
|
||||
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { toUnicode } from "punycode";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
@@ -95,6 +99,7 @@ export default function DomainPicker({
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const t = useTranslations();
|
||||
const { hasSaasSubscription } = usePaidStatus();
|
||||
|
||||
const { data = [], isLoading: loadingDomains } = useQuery(
|
||||
orgQueries.domains({ orgId })
|
||||
@@ -509,9 +514,11 @@ export default function DomainPicker({
|
||||
<span className="truncate">
|
||||
{selectedBaseDomain.domain}
|
||||
</span>
|
||||
{selectedBaseDomain.verified && (
|
||||
<CheckCircle2 className="h-3 w-3 text-green-500 shrink-0" />
|
||||
)}
|
||||
{selectedBaseDomain.verified &&
|
||||
selectedBaseDomain.domainType !==
|
||||
"wildcard" && (
|
||||
<CheckCircle2 className="h-3 w-3 text-green-500 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
t("domainPickerSelectBaseDomain")
|
||||
@@ -574,14 +581,23 @@ export default function DomainPicker({
|
||||
}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{orgDomain.type.toUpperCase()}{" "}
|
||||
•{" "}
|
||||
{orgDomain.verified
|
||||
{orgDomain.type ===
|
||||
"wildcard"
|
||||
? t(
|
||||
"domainPickerVerified"
|
||||
"domainPickerManual"
|
||||
)
|
||||
: t(
|
||||
"domainPickerUnverified"
|
||||
: (
|
||||
<>
|
||||
{orgDomain.type.toUpperCase()}{" "}
|
||||
•{" "}
|
||||
{orgDomain.verified
|
||||
? t(
|
||||
"domainPickerVerified"
|
||||
)
|
||||
: t(
|
||||
"domainPickerUnverified"
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -680,6 +696,23 @@ export default function DomainPicker({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{build === "saas" &&
|
||||
!hasSaasSubscription(
|
||||
tierMatrix[TierFeature.DomainNamespaces]
|
||||
) &&
|
||||
!hideFreeDomain && (
|
||||
<Card className="mt-3 border-black-500/30 bg-linear-to-br from-black-500/10 via-background to-background overflow-hidden">
|
||||
<CardContent className="py-3 px-4">
|
||||
<div className="flex items-center gap-2.5 text-sm text-muted-foreground">
|
||||
<KeyRound className="size-4 shrink-0 text-black-500" />
|
||||
<span>
|
||||
{t("domainPickerFreeDomainsPaidFeature")}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/*showProvidedDomainSearch && build === "saas" && (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
|
||||
@@ -39,7 +39,11 @@ export default function InviteStatusCard({
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [type, setType] = useState<
|
||||
"rejected" | "wrong_user" | "user_does_not_exist" | "not_logged_in" | "user_limit_exceeded"
|
||||
| "rejected"
|
||||
| "wrong_user"
|
||||
| "user_does_not_exist"
|
||||
| "not_logged_in"
|
||||
| "user_limit_exceeded"
|
||||
>("rejected");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -90,12 +94,12 @@ export default function InviteStatusCard({
|
||||
|
||||
if (!user && type === "user_does_not_exist") {
|
||||
const redirectUrl = email
|
||||
? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${encodeURIComponent(email)}`
|
||||
? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${email}`
|
||||
: `/auth/signup?redirect=/invite?token=${tokenParam}`;
|
||||
router.push(redirectUrl);
|
||||
} else if (!user && type === "not_logged_in") {
|
||||
const redirectUrl = email
|
||||
? `/auth/login?redirect=/invite?token=${tokenParam}&email=${encodeURIComponent(email)}`
|
||||
? `/auth/login?redirect=/invite?token=${tokenParam}&email=${email}`
|
||||
: `/auth/login?redirect=/invite?token=${tokenParam}`;
|
||||
router.push(redirectUrl);
|
||||
} else {
|
||||
@@ -109,7 +113,7 @@ export default function InviteStatusCard({
|
||||
async function goToLogin() {
|
||||
await api.post("/auth/logout", {});
|
||||
const redirectUrl = email
|
||||
? `/auth/login?redirect=/invite?token=${tokenParam}&email=${encodeURIComponent(email)}`
|
||||
? `/auth/login?redirect=/invite?token=${tokenParam}&email=${email}`
|
||||
: `/auth/login?redirect=/invite?token=${tokenParam}`;
|
||||
router.push(redirectUrl);
|
||||
}
|
||||
@@ -117,7 +121,7 @@ export default function InviteStatusCard({
|
||||
async function goToSignup() {
|
||||
await api.post("/auth/logout", {});
|
||||
const redirectUrl = email
|
||||
? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${encodeURIComponent(email)}`
|
||||
? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${email}`
|
||||
: `/auth/signup?redirect=/invite?token=${tokenParam}`;
|
||||
router.push(redirectUrl);
|
||||
}
|
||||
@@ -157,7 +161,9 @@ export default function InviteStatusCard({
|
||||
Cannot Accept Invite
|
||||
</p>
|
||||
<p className="text-center text-sm">
|
||||
This organization has reached its user limit. Please contact the organization administrator to upgrade their plan before accepting this invite.
|
||||
This organization has reached its user limit. Please
|
||||
contact the organization administrator to upgrade their
|
||||
plan before accepting this invite.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -333,7 +333,8 @@ export default function PendingSitesTable({
|
||||
"jupiter",
|
||||
"saturn",
|
||||
"uranus",
|
||||
"neptune"
|
||||
"neptune",
|
||||
"pluto"
|
||||
].includes(originalRow.exitNodeName.toLowerCase());
|
||||
|
||||
if (isCloudNode) {
|
||||
|
||||
@@ -342,7 +342,8 @@ export default function SitesTable({
|
||||
"jupiter",
|
||||
"saturn",
|
||||
"uranus",
|
||||
"neptune"
|
||||
"neptune",
|
||||
"pluto"
|
||||
].includes(originalRow.exitNodeName.toLowerCase());
|
||||
|
||||
if (isCloudNode) {
|
||||
|
||||
@@ -164,7 +164,7 @@ const countryClass = cn(
|
||||
|
||||
const highlightedCountryClass = cn(
|
||||
sharedCountryClass,
|
||||
"stroke-2",
|
||||
"stroke-[3]",
|
||||
"fill-[#f4f4f5]",
|
||||
"stroke-[#f36117]",
|
||||
"dark:fill-[#3f3f46]"
|
||||
@@ -194,11 +194,20 @@ function drawInteractiveCountries(
|
||||
const path = setupProjetionPath();
|
||||
const data = parseWorldTopoJsonToGeoJsonFeatures();
|
||||
const svg = d3.select(element);
|
||||
const countriesLayer = svg.append("g");
|
||||
const hoverLayer = svg.append("g").style("pointer-events", "none");
|
||||
const hoverPath = hoverLayer
|
||||
.append("path")
|
||||
.datum(null)
|
||||
.attr("class", highlightedCountryClass)
|
||||
.style("display", "none");
|
||||
|
||||
svg.selectAll("path")
|
||||
countriesLayer
|
||||
.selectAll("path")
|
||||
.data(data)
|
||||
.enter()
|
||||
.append("path")
|
||||
.attr("data-country-path", "true")
|
||||
.attr("class", countryClass)
|
||||
.attr("d", path as never)
|
||||
|
||||
@@ -209,9 +218,10 @@ function drawInteractiveCountries(
|
||||
y,
|
||||
hoveredCountryAlpha3Code: country.properties.a3
|
||||
});
|
||||
// brings country to front
|
||||
this.parentNode?.appendChild(this);
|
||||
d3.select(this).attr("class", highlightedCountryClass);
|
||||
hoverPath
|
||||
.datum(country)
|
||||
.attr("d", path(country) as string)
|
||||
.style("display", null);
|
||||
})
|
||||
|
||||
.on("mousemove", function (event) {
|
||||
@@ -221,7 +231,7 @@ function drawInteractiveCountries(
|
||||
|
||||
.on("mouseout", function () {
|
||||
setTooltip({ x: 0, y: 0, hoveredCountryAlpha3Code: null });
|
||||
d3.select(this).attr("class", countryClass);
|
||||
hoverPath.style("display", "none");
|
||||
});
|
||||
|
||||
return svg;
|
||||
@@ -257,7 +267,7 @@ function colorInCountriesWithValues(
|
||||
const svg = d3.select(element);
|
||||
|
||||
return svg
|
||||
.selectAll("path")
|
||||
.selectAll('path[data-country-path="true"]')
|
||||
.style("fill", (countryPath) => {
|
||||
const country = getCountryByCountryPath(countryPath);
|
||||
if (!country?.count) {
|
||||
|
||||
Reference in New Issue
Block a user