mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-13 09:18:12 +02:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ae0be3a4bc | |||
| a8f3f71021 | |||
| 933ca71c16 | |||
| 591cb9cdc1 | |||
| 34b18bdb53 | |||
| 7d475f5e91 | |||
| 39c35fa539 | |||
| e1bc0b7efd | |||
| 5ef068c8dc | |||
| 94c01a23a9 | |||
| cf9a17cc2e |
+9
-1
@@ -449,8 +449,14 @@
|
||||
"provisioningManage": "Provisioning",
|
||||
"provisioningDescription": "Manage provisioning keys and review pending sites awaiting approval.",
|
||||
"pendingSites": "Pending Sites",
|
||||
"siteApproveSuccess": "Site approved successfully",
|
||||
"siteApproveSuccess": "Site and associated resources approved successfully",
|
||||
"siteApproveError": "Error approving site",
|
||||
"siteReject": "Reject Site",
|
||||
"siteQuestionReject": "Are you sure you want to reject this site?",
|
||||
"siteMessageReject": "This will permanently delete the site and any associated resources that are still pending.",
|
||||
"siteConfirmReject": "Confirm Reject Site",
|
||||
"siteRejectSuccess": "Site rejected successfully",
|
||||
"siteRejectError": "Error rejecting site",
|
||||
"provisioningKeys": "Provisioning Keys",
|
||||
"searchProvisioningKeys": "Search provisioning keys...",
|
||||
"provisioningKeysAdd": "Generate Provisioning Key",
|
||||
@@ -1420,6 +1426,8 @@
|
||||
"setupTokenDescription": "Enter the setup token from the server console.",
|
||||
"setupTokenRequired": "Setup token is required",
|
||||
"actionUpdateSite": "Update Site",
|
||||
"actionApproveSite": "Approve Site",
|
||||
"actionRejectSite": "Reject Site",
|
||||
"actionResetSiteBandwidth": "Reset Organization Bandwidth",
|
||||
"actionListSiteRoles": "List Allowed Site Roles",
|
||||
"actionCreateResource": "Create Resource",
|
||||
|
||||
@@ -21,6 +21,7 @@ export enum ActionsEnum {
|
||||
getSite = "getSite",
|
||||
listSites = "listSites",
|
||||
updateSite = "updateSite",
|
||||
updateSiteApprovals = "updateSiteApprovals",
|
||||
restartSite = "restartSite",
|
||||
resetSiteBandwidth = "resetSiteBandwidth",
|
||||
reGenerateSecret = "reGenerateSecret",
|
||||
|
||||
@@ -200,7 +200,10 @@ export const resources = pgTable(
|
||||
authDaemonMode: varchar("authDaemonMode", { length: 32 })
|
||||
.$type<"site" | "remote" | "native">()
|
||||
.default("site"),
|
||||
authDaemonPort: integer("authDaemonPort").default(22123)
|
||||
authDaemonPort: integer("authDaemonPort").default(22123),
|
||||
status: varchar("status")
|
||||
.$type<"pending" | "approved">()
|
||||
.default("approved")
|
||||
},
|
||||
(t) => [
|
||||
index("idx_resources_fulldomain")
|
||||
@@ -451,7 +454,10 @@ export const siteResources = pgTable(
|
||||
onDelete: "set null"
|
||||
}),
|
||||
subdomain: varchar("subdomain"),
|
||||
fullDomain: varchar("fullDomain")
|
||||
fullDomain: varchar("fullDomain"),
|
||||
status: varchar("status")
|
||||
.$type<"pending" | "approved">()
|
||||
.default("approved")
|
||||
},
|
||||
(t) => [index("idx_siteresources_orgid_niceid").on(t.orgId, t.niceId)]
|
||||
);
|
||||
|
||||
@@ -209,7 +209,8 @@ export const resources = sqliteTable("resources", {
|
||||
authDaemonMode: text("authDaemonMode")
|
||||
.$type<"site" | "remote" | "native">()
|
||||
.default("site"),
|
||||
authDaemonPort: integer("authDaemonPort").default(22123)
|
||||
authDaemonPort: integer("authDaemonPort").default(22123),
|
||||
status: text("status").$type<"pending" | "approved">().default("approved")
|
||||
});
|
||||
|
||||
export const labels = sqliteTable("labels", {
|
||||
@@ -447,7 +448,8 @@ export const siteResources = sqliteTable("siteResources", {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
subdomain: text("subdomain"),
|
||||
fullDomain: text("fullDomain")
|
||||
fullDomain: text("fullDomain"),
|
||||
status: text("status").$type<"pending" | "approved">().default("approved")
|
||||
});
|
||||
|
||||
export const networks = sqliteTable("networks", {
|
||||
|
||||
@@ -198,17 +198,19 @@ export async function updatePrivateResources(
|
||||
}
|
||||
}
|
||||
|
||||
let resourceStatusFromSite: "approved" | "pending" = "approved";
|
||||
if (siteId && allSites.length === 0) {
|
||||
// only add if there are not provided sites
|
||||
// Use the provided siteId directly, but verify it belongs to the org
|
||||
const [siteSingle] = await trx
|
||||
.select({ siteId: sites.siteId })
|
||||
.select({ siteId: sites.siteId, status: sites.status })
|
||||
.from(sites)
|
||||
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
|
||||
.limit(1);
|
||||
if (siteSingle) {
|
||||
allSites.push(siteSingle);
|
||||
}
|
||||
resourceStatusFromSite = siteSingle.status ?? "approved";
|
||||
}
|
||||
|
||||
if (allSites.length === 0) {
|
||||
@@ -217,6 +219,13 @@ export async function updatePrivateResources(
|
||||
);
|
||||
}
|
||||
|
||||
const resourceEnabled =
|
||||
resourceData.enabled == undefined || resourceData.enabled == null
|
||||
? true
|
||||
: resourceStatusFromSite === "pending"
|
||||
? false
|
||||
: resourceData.enabled;
|
||||
|
||||
if (existingResource) {
|
||||
let domainInfo:
|
||||
| { subdomain: string | null; domainId: string }
|
||||
@@ -240,7 +249,7 @@ export async function updatePrivateResources(
|
||||
scheme: resourceData.scheme,
|
||||
destination: resourceData.destination,
|
||||
destinationPort: resourceData["destination-port"],
|
||||
enabled: resourceData.enabled ?? true,
|
||||
enabled: resourceEnabled,
|
||||
alias: resourceData.alias || null,
|
||||
disableIcmp:
|
||||
resourceData["disable-icmp"] ||
|
||||
@@ -259,7 +268,8 @@ export async function updatePrivateResources(
|
||||
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
|
||||
authDaemonMode:
|
||||
resourceData["auth-daemon"]?.mode || "native",
|
||||
authDaemonPort: resourceData["auth-daemon"]?.port || 22123
|
||||
authDaemonPort: resourceData["auth-daemon"]?.port || 22123,
|
||||
status: resourceStatusFromSite
|
||||
})
|
||||
.where(
|
||||
eq(
|
||||
@@ -492,7 +502,7 @@ export async function updatePrivateResources(
|
||||
scheme: resourceData.scheme,
|
||||
destination: resourceData.destination,
|
||||
destinationPort: resourceData["destination-port"],
|
||||
enabled: resourceData.enabled ?? true,
|
||||
enabled: resourceEnabled,
|
||||
alias: resourceData.alias || null,
|
||||
aliasAddress: aliasAddress,
|
||||
disableIcmp:
|
||||
@@ -512,7 +522,8 @@ export async function updatePrivateResources(
|
||||
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
|
||||
authDaemonMode:
|
||||
resourceData["auth-daemon"]?.mode || "native",
|
||||
authDaemonPort: resourceData["auth-daemon"]?.port || 22123
|
||||
authDaemonPort: resourceData["auth-daemon"]?.port || 22123,
|
||||
status: resourceStatusFromSite
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
rolePolicies,
|
||||
roleResources,
|
||||
roles,
|
||||
Site,
|
||||
sites,
|
||||
Target,
|
||||
TargetHealthCheck,
|
||||
@@ -74,19 +75,40 @@ export async function updatePublicResources(
|
||||
)) {
|
||||
const targetsToUpdate: Target[] = [];
|
||||
const healthchecksToUpdate: TargetHealthCheck[] = [];
|
||||
|
||||
let resource: Resource;
|
||||
let resourceStatusFromSite: "approved" | "pending" = "approved";
|
||||
let providedSite: Partial<Site> | undefined;
|
||||
if (siteId) {
|
||||
// Use the provided siteId directly, but verify it belongs to the org
|
||||
[providedSite] = await trx
|
||||
.select({
|
||||
siteId: sites.siteId,
|
||||
type: sites.type,
|
||||
status: sites.status
|
||||
})
|
||||
.from(sites)
|
||||
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
resourceStatusFromSite = providedSite?.status ?? "approved";
|
||||
}
|
||||
|
||||
async function createTarget( // reusable function to create a target
|
||||
resourceId: number,
|
||||
targetData: TargetData
|
||||
) {
|
||||
const targetSiteId = targetData.site;
|
||||
let site;
|
||||
let site: Partial<Site> | undefined;
|
||||
|
||||
if (targetSiteId) {
|
||||
// Look up site by niceId
|
||||
[site] = await trx
|
||||
.select({ siteId: sites.siteId, type: sites.type })
|
||||
.select({
|
||||
siteId: sites.siteId,
|
||||
type: sites.type,
|
||||
status: sites.status
|
||||
})
|
||||
.from(sites)
|
||||
.where(
|
||||
and(
|
||||
@@ -95,15 +117,9 @@ export async function updatePublicResources(
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
} else if (siteId) {
|
||||
} else if (siteId && providedSite) {
|
||||
// Use the provided siteId directly, but verify it belongs to the org
|
||||
[site] = await trx
|
||||
.select({ siteId: sites.siteId, type: sites.type })
|
||||
.from(sites)
|
||||
.where(
|
||||
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
|
||||
)
|
||||
.limit(1);
|
||||
site = providedSite;
|
||||
} else {
|
||||
throw new Error(`Target site is required`);
|
||||
}
|
||||
@@ -139,7 +155,7 @@ export async function updatePublicResources(
|
||||
.insert(targets)
|
||||
.values({
|
||||
resourceId: resourceId,
|
||||
siteId: site.siteId,
|
||||
siteId: site.siteId!,
|
||||
ip: targetData.hostname,
|
||||
mode: resourceData.mode as Target["mode"],
|
||||
method: targetData.method,
|
||||
@@ -172,7 +188,7 @@ export async function updatePublicResources(
|
||||
.insert(targetHealthCheck)
|
||||
.values({
|
||||
name: `${targetData.hostname}:${targetData.port}`,
|
||||
siteId: site.siteId,
|
||||
siteId: site.siteId!,
|
||||
targetId: newTarget.targetId,
|
||||
orgId: orgId,
|
||||
hcEnabled: healthcheckData?.enabled || false,
|
||||
@@ -230,7 +246,10 @@ export async function updatePublicResources(
|
||||
const resourceEnabled =
|
||||
resourceData.enabled == undefined || resourceData.enabled == null
|
||||
? true
|
||||
: resourceData.enabled;
|
||||
: resourceStatusFromSite === "pending"
|
||||
? false
|
||||
: resourceData.enabled;
|
||||
|
||||
const resourceSsl =
|
||||
resourceData.ssl == undefined || resourceData.ssl == null
|
||||
? true
|
||||
@@ -406,7 +425,8 @@ export async function updatePublicResources(
|
||||
? (resourceData["proxy-protocol-version"] ??
|
||||
1)
|
||||
: 1,
|
||||
resourcePolicyId: sharedPolicy.resourcePolicyId
|
||||
resourcePolicyId: sharedPolicy.resourcePolicyId,
|
||||
status: resourceStatusFromSite
|
||||
})
|
||||
.where(
|
||||
eq(
|
||||
@@ -590,7 +610,8 @@ export async function updatePublicResources(
|
||||
authDaemonPort:
|
||||
resourceData["auth-daemon"]?.port || 22123,
|
||||
resourcePolicyId: null,
|
||||
defaultResourcePolicyId: inlinePolicyId
|
||||
defaultResourcePolicyId: inlinePolicyId,
|
||||
status: resourceStatusFromSite
|
||||
})
|
||||
.where(
|
||||
eq(
|
||||
@@ -1131,6 +1152,7 @@ export async function updatePublicResources(
|
||||
.values({
|
||||
orgId,
|
||||
niceId: resourceNiceId,
|
||||
status: resourceStatusFromSite,
|
||||
name: resourceData.name || "Unnamed Resource",
|
||||
mode: resourceData.mode,
|
||||
proxyPort: ["http", "ssh", "rdp", "vnc"].includes(
|
||||
|
||||
@@ -14,8 +14,6 @@ import {
|
||||
} from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
import { removeTargets } from "@server/routers/newt/targets";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
|
||||
export type DeleteResourceResult = {
|
||||
deletedResource: Resource;
|
||||
@@ -117,10 +115,10 @@ export async function runResourceDeleteSideEffects(
|
||||
.limit(1);
|
||||
|
||||
if (!site) {
|
||||
throw createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Site with ID ${target.siteId} not found`
|
||||
logger.debug(
|
||||
`Site with ID ${target.siteId} not found during resource delete side effects; skipping target removal`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (site.pubKey && site.type === "newt") {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import {
|
||||
db,
|
||||
resources,
|
||||
siteNetworks,
|
||||
siteResources,
|
||||
targets,
|
||||
@@ -97,6 +98,64 @@ export function exceedsSiteAssociatedResourceDeleteLimit(
|
||||
return resourceCount > MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE;
|
||||
}
|
||||
|
||||
export async function getPendingResourceIdsForSite(
|
||||
siteId: number,
|
||||
trx: Transaction | typeof db = db
|
||||
): Promise<number[]> {
|
||||
const resourceIds = await getResourceIdsForSite(siteId, trx);
|
||||
if (resourceIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = await trx
|
||||
.select({ resourceId: resources.resourceId })
|
||||
.from(resources)
|
||||
.where(
|
||||
and(
|
||||
inArray(resources.resourceId, resourceIds),
|
||||
eq(resources.status, "pending")
|
||||
)
|
||||
);
|
||||
|
||||
return rows.map((row) => row.resourceId);
|
||||
}
|
||||
|
||||
export async function getPendingSiteResourceIdsForSite(
|
||||
siteId: number,
|
||||
orgId: string,
|
||||
trx: Transaction | typeof db = db
|
||||
): Promise<number[]> {
|
||||
const siteResourceIds = await getSiteResourceIdsForSite(siteId, orgId, trx);
|
||||
if (siteResourceIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = await trx
|
||||
.select({ siteResourceId: siteResources.siteResourceId })
|
||||
.from(siteResources)
|
||||
.where(
|
||||
and(
|
||||
inArray(siteResources.siteResourceId, siteResourceIds),
|
||||
eq(siteResources.status, "pending")
|
||||
)
|
||||
);
|
||||
|
||||
return rows.map((row) => row.siteResourceId);
|
||||
}
|
||||
|
||||
export async function getPendingAssociatedResourceCountForSite(
|
||||
siteId: number,
|
||||
orgId: string,
|
||||
trx: Transaction | typeof db = db
|
||||
): Promise<number> {
|
||||
const [resourceIds, siteResourceIds] = await Promise.all([
|
||||
getPendingResourceIdsForSite(siteId, trx),
|
||||
getPendingSiteResourceIdsForSite(siteId, orgId, trx)
|
||||
]);
|
||||
|
||||
return resourceIds.length + siteResourceIds.length;
|
||||
}
|
||||
|
||||
export async function deleteAssociatedResourcesForSite(
|
||||
siteId: number,
|
||||
orgId: string,
|
||||
@@ -105,12 +164,32 @@ export async function deleteAssociatedResourcesForSite(
|
||||
const resourceIds = await getResourceIdsForSite(siteId, trx);
|
||||
const siteResourceIds = await getSiteResourceIdsForSite(siteId, orgId, trx);
|
||||
|
||||
const [resources, siteResourcesDeleted] = await Promise.all([
|
||||
const [deletedResources, siteResourcesDeleted] = await Promise.all([
|
||||
performDeleteResources(resourceIds, trx),
|
||||
performDeleteSiteResources(siteResourceIds, trx)
|
||||
]);
|
||||
|
||||
return { resources, siteResources: siteResourcesDeleted };
|
||||
return { resources: deletedResources, siteResources: siteResourcesDeleted };
|
||||
}
|
||||
|
||||
export async function deletePendingAssociatedResourcesForSite(
|
||||
siteId: number,
|
||||
orgId: string,
|
||||
trx: Transaction | typeof db = db
|
||||
): Promise<DeleteSiteAssociatedResourcesSideEffects> {
|
||||
const resourceIds = await getPendingResourceIdsForSite(siteId, trx);
|
||||
const siteResourceIds = await getPendingSiteResourceIdsForSite(
|
||||
siteId,
|
||||
orgId,
|
||||
trx
|
||||
);
|
||||
|
||||
const [deletedResources, siteResourcesDeleted] = await Promise.all([
|
||||
performDeleteResources(resourceIds, trx),
|
||||
performDeleteSiteResources(siteResourceIds, trx)
|
||||
]);
|
||||
|
||||
return { resources: deletedResources, siteResources: siteResourcesDeleted };
|
||||
}
|
||||
|
||||
export async function runDeleteSiteAssociatedResourcesSideEffects(
|
||||
|
||||
@@ -248,6 +248,22 @@ authenticated.post(
|
||||
site.updateSite
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/site/:siteId/approve",
|
||||
verifySiteAccess,
|
||||
verifyUserHasAction(ActionsEnum.updateSiteApprovals),
|
||||
logActionAudit(ActionsEnum.updateSiteApprovals),
|
||||
site.approveSite
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/site/:siteId/reject",
|
||||
verifySiteAccess,
|
||||
verifyUserHasAction(ActionsEnum.updateSiteApprovals),
|
||||
logActionAudit(ActionsEnum.updateSiteApprovals),
|
||||
site.rejectSite
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/site/:siteId",
|
||||
verifySiteAccess,
|
||||
|
||||
@@ -138,6 +138,7 @@ authenticated.post(
|
||||
logActionAudit(ActionsEnum.updateSite),
|
||||
site.updateSite
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/reset-bandwidth",
|
||||
verifyApiKeyOrgAccess,
|
||||
|
||||
@@ -157,7 +157,8 @@ async function resolveAccessibleIdsUncached(
|
||||
.where(
|
||||
and(
|
||||
eq(userResources.userId, userId),
|
||||
eq(resources.orgId, orgId)
|
||||
eq(resources.orgId, orgId),
|
||||
eq(resources.status, "approved")
|
||||
)
|
||||
),
|
||||
userRoleIds.length > 0
|
||||
@@ -171,7 +172,8 @@ async function resolveAccessibleIdsUncached(
|
||||
.where(
|
||||
and(
|
||||
inArray(roleResources.roleId, userRoleIds),
|
||||
eq(resources.orgId, orgId)
|
||||
eq(resources.orgId, orgId),
|
||||
eq(resources.status, "approved")
|
||||
)
|
||||
)
|
||||
: Promise.resolve([]),
|
||||
@@ -183,7 +185,11 @@ async function resolveAccessibleIdsUncached(
|
||||
eq(effectiveResourcePolicyId, userPolicies.resourcePolicyId)
|
||||
)
|
||||
.where(
|
||||
and(eq(userPolicies.userId, userId), eq(resources.orgId, orgId))
|
||||
and(
|
||||
eq(userPolicies.userId, userId),
|
||||
eq(resources.orgId, orgId),
|
||||
eq(resources.status, "approved")
|
||||
)
|
||||
),
|
||||
userRoleIds.length > 0
|
||||
? db
|
||||
@@ -199,21 +205,48 @@ async function resolveAccessibleIdsUncached(
|
||||
.where(
|
||||
and(
|
||||
inArray(rolePolicies.roleId, userRoleIds),
|
||||
eq(resources.orgId, orgId)
|
||||
eq(resources.orgId, orgId),
|
||||
eq(resources.status, "approved")
|
||||
)
|
||||
)
|
||||
: Promise.resolve([]),
|
||||
db
|
||||
.select({ siteResourceId: userSiteResources.siteResourceId })
|
||||
.from(userSiteResources)
|
||||
.where(eq(userSiteResources.userId, userId)),
|
||||
.innerJoin(
|
||||
siteResources,
|
||||
eq(
|
||||
userSiteResources.siteResourceId,
|
||||
siteResources.siteResourceId
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(userSiteResources.userId, userId),
|
||||
eq(siteResources.orgId, orgId),
|
||||
eq(siteResources.status, "approved")
|
||||
)
|
||||
),
|
||||
userRoleIds.length > 0
|
||||
? db
|
||||
.select({
|
||||
siteResourceId: roleSiteResources.siteResourceId
|
||||
})
|
||||
.from(roleSiteResources)
|
||||
.where(inArray(roleSiteResources.roleId, userRoleIds))
|
||||
.innerJoin(
|
||||
siteResources,
|
||||
eq(
|
||||
roleSiteResources.siteResourceId,
|
||||
siteResources.siteResourceId
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
inArray(roleSiteResources.roleId, userRoleIds),
|
||||
eq(siteResources.orgId, orgId),
|
||||
eq(siteResources.status, "approved")
|
||||
)
|
||||
)
|
||||
: Promise.resolve([])
|
||||
]);
|
||||
|
||||
@@ -365,6 +398,7 @@ async function filterPublicResourceIdsByTextSearch(
|
||||
inArray(resources.resourceId, resourceIds),
|
||||
eq(resources.orgId, orgId),
|
||||
eq(resources.enabled, true),
|
||||
eq(resources.status, "approved"),
|
||||
textMatch
|
||||
)
|
||||
);
|
||||
@@ -402,6 +436,7 @@ async function filterSiteResourceIdsByTextSearch(
|
||||
inArray(siteResources.siteResourceId, siteResourceIds),
|
||||
eq(siteResources.orgId, orgId),
|
||||
eq(siteResources.enabled, true),
|
||||
eq(siteResources.status, "approved"),
|
||||
textMatch
|
||||
)
|
||||
);
|
||||
@@ -503,7 +538,8 @@ async function listSiteGroups(
|
||||
const publicConditions = [
|
||||
inArray(resources.resourceId, accessible.resourceIds),
|
||||
eq(resources.orgId, orgId),
|
||||
eq(resources.enabled, true)
|
||||
eq(resources.enabled, true),
|
||||
eq(resources.status, "approved")
|
||||
];
|
||||
if (searchPublic) {
|
||||
publicConditions.push(searchPublic);
|
||||
@@ -558,7 +594,8 @@ async function listSiteGroups(
|
||||
const siteConditions = [
|
||||
inArray(siteResources.siteResourceId, accessible.siteResourceIds),
|
||||
eq(siteResources.orgId, orgId),
|
||||
eq(siteResources.enabled, true)
|
||||
eq(siteResources.enabled, true),
|
||||
eq(siteResources.status, "approved")
|
||||
];
|
||||
if (searchSite) {
|
||||
siteConditions.push(searchSite);
|
||||
@@ -621,7 +658,8 @@ async function listSiteGroups(
|
||||
const noSitePublicConditions = [
|
||||
inArray(resources.resourceId, accessible.resourceIds),
|
||||
eq(resources.orgId, orgId),
|
||||
eq(resources.enabled, true)
|
||||
eq(resources.enabled, true),
|
||||
eq(resources.status, "approved")
|
||||
];
|
||||
if (searchPublic) {
|
||||
noSitePublicConditions.push(searchPublic);
|
||||
@@ -655,7 +693,8 @@ async function listSiteGroups(
|
||||
const noSiteSiteConditions = [
|
||||
inArray(siteResources.siteResourceId, accessible.siteResourceIds),
|
||||
eq(siteResources.orgId, orgId),
|
||||
eq(siteResources.enabled, true)
|
||||
eq(siteResources.enabled, true),
|
||||
eq(siteResources.status, "approved")
|
||||
];
|
||||
if (searchSite) {
|
||||
noSiteSiteConditions.push(searchSite);
|
||||
@@ -746,7 +785,8 @@ async function listLabelGroups(
|
||||
const publicConditions = [
|
||||
inArray(resources.resourceId, accessible.resourceIds),
|
||||
eq(resources.orgId, orgId),
|
||||
eq(resources.enabled, true)
|
||||
eq(resources.enabled, true),
|
||||
eq(resources.status, "approved")
|
||||
];
|
||||
const searchPublic = buildSearchConditionForPublic(query.query);
|
||||
if (searchPublic) {
|
||||
@@ -810,7 +850,8 @@ async function listLabelGroups(
|
||||
const siteConditions = [
|
||||
inArray(siteResources.siteResourceId, accessible.siteResourceIds),
|
||||
eq(siteResources.orgId, orgId),
|
||||
eq(siteResources.enabled, true)
|
||||
eq(siteResources.enabled, true),
|
||||
eq(siteResources.status, "approved")
|
||||
];
|
||||
const searchSite = buildSearchConditionForSiteResource(query.query);
|
||||
if (searchSite) {
|
||||
@@ -997,6 +1038,7 @@ async function mapPublicResources(
|
||||
inArray(resources.resourceId, resourceIds),
|
||||
eq(resources.orgId, orgId),
|
||||
eq(resources.enabled, true),
|
||||
eq(resources.status, "approved"),
|
||||
siteIdFilter != null
|
||||
? eq(sites.siteId, siteIdFilter)
|
||||
: undefined
|
||||
@@ -1088,6 +1130,7 @@ async function mapSiteResources(
|
||||
inArray(siteResources.siteResourceId, siteResourceIds),
|
||||
eq(siteResources.orgId, orgId),
|
||||
eq(siteResources.enabled, true),
|
||||
eq(siteResources.status, "approved"),
|
||||
siteIdFilter != null
|
||||
? eq(sites.siteId, siteIdFilter)
|
||||
: undefined
|
||||
@@ -1382,7 +1425,8 @@ async function collectAccessibleSites(
|
||||
const publicConditions = [
|
||||
inArray(resources.resourceId, accessible.resourceIds),
|
||||
eq(resources.orgId, orgId),
|
||||
eq(resources.enabled, true)
|
||||
eq(resources.enabled, true),
|
||||
eq(resources.status, "approved")
|
||||
];
|
||||
if (siteNameSearch) {
|
||||
publicConditions.push(siteNameSearch);
|
||||
@@ -1422,7 +1466,8 @@ async function collectAccessibleSites(
|
||||
const siteConditions = [
|
||||
inArray(siteResources.siteResourceId, accessible.siteResourceIds),
|
||||
eq(siteResources.orgId, orgId),
|
||||
eq(siteResources.enabled, true)
|
||||
eq(siteResources.enabled, true),
|
||||
eq(siteResources.status, "approved")
|
||||
];
|
||||
if (siteNameSearch) {
|
||||
siteConditions.push(siteNameSearch);
|
||||
@@ -1476,6 +1521,7 @@ async function collectAccessibleLabels(
|
||||
inArray(resources.resourceId, accessible.resourceIds),
|
||||
eq(resources.orgId, orgId),
|
||||
eq(resources.enabled, true),
|
||||
eq(resources.status, "approved"),
|
||||
eq(labels.orgId, orgId)
|
||||
];
|
||||
if (labelNameSearch) {
|
||||
@@ -1511,6 +1557,7 @@ async function collectAccessibleLabels(
|
||||
inArray(siteResources.siteResourceId, accessible.siteResourceIds),
|
||||
eq(siteResources.orgId, orgId),
|
||||
eq(siteResources.enabled, true),
|
||||
eq(siteResources.status, "approved"),
|
||||
eq(labels.orgId, orgId)
|
||||
];
|
||||
if (labelNameSearch) {
|
||||
|
||||
@@ -138,6 +138,15 @@ const listResourcesSchema = z.strictObject({
|
||||
description:
|
||||
"When set, only resources that have at least one target on this site are returned"
|
||||
}),
|
||||
status: z
|
||||
.enum(["pending", "approved"])
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.openapi({
|
||||
type: "string",
|
||||
enum: ["pending", "approved"],
|
||||
description: "Filter by resource status"
|
||||
}),
|
||||
labels: z
|
||||
.preprocess((val) => {
|
||||
if (val === undefined || val === null || val === "") {
|
||||
@@ -451,6 +460,7 @@ export async function listResources(
|
||||
sort_by,
|
||||
order,
|
||||
siteId,
|
||||
status,
|
||||
labels: labelFilter
|
||||
} = parsedQuery.data;
|
||||
|
||||
@@ -660,6 +670,10 @@ export async function listResources(
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof status !== "undefined") {
|
||||
conditions.push(eq(resources.status, status));
|
||||
}
|
||||
|
||||
if (siteId != null) {
|
||||
const resourcesWithSite = db
|
||||
.select({ resourceId: targets.resourceId })
|
||||
|
||||
@@ -45,18 +45,19 @@ function userResourceAliasesCacheKey(
|
||||
page: number,
|
||||
pageSize: number,
|
||||
includeLabels: boolean,
|
||||
labelFilter: string[]
|
||||
labelFilter: string[],
|
||||
status?: "pending" | "approved"
|
||||
) {
|
||||
const labelsKey =
|
||||
labelFilter.length > 0 ? labelFilter.slice().sort().join(",") : "all";
|
||||
return `userResourceAliases:${orgId}:${userId}:${page}:${pageSize}:${includeLabels ? "labels" : "plain"}:${labelsKey}`;
|
||||
return `userResourceAliases:${orgId}:${userId}:${page}:${pageSize}:${includeLabels ? "labels" : "plain"}:${labelsKey}:${status ?? "all"}`;
|
||||
}
|
||||
|
||||
const listUserResourceAliasesParamsSchema = z.strictObject({
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
const listUserResourceAliasesQuerySchema = z.strictObject({
|
||||
const listUserResourceAliasesQuerySchema = z.object({
|
||||
pageSize: z.coerce
|
||||
.number<string>()
|
||||
.int()
|
||||
@@ -96,7 +97,16 @@ const listUserResourceAliasesQuerySchema = z.strictObject({
|
||||
type: "array",
|
||||
description:
|
||||
"Filter by resource labels. A resource matches when it has any of the given labels (OR)."
|
||||
})
|
||||
}),
|
||||
status: z
|
||||
.enum(["pending", "approved"])
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.openapi({
|
||||
type: "string",
|
||||
enum: ["pending", "approved"],
|
||||
description: "Filter by site resource status"
|
||||
})
|
||||
});
|
||||
|
||||
export type UserResourceAliasItem = {
|
||||
@@ -130,7 +140,8 @@ export async function listUserResourceAliases(
|
||||
page,
|
||||
pageSize,
|
||||
includeLabels,
|
||||
labels: labelFilter
|
||||
labels: labelFilter,
|
||||
status
|
||||
} = parsedQuery.data;
|
||||
|
||||
const parsedParams = listUserResourceAliasesParamsSchema.safeParse(
|
||||
@@ -172,7 +183,8 @@ export async function listUserResourceAliases(
|
||||
page,
|
||||
pageSize,
|
||||
includeLabels,
|
||||
labelFilter ?? []
|
||||
labelFilter ?? [],
|
||||
status
|
||||
);
|
||||
const cachedData: ListUserResourceAliasesResponse | undefined =
|
||||
await cache.get(cacheKey);
|
||||
@@ -257,6 +269,10 @@ export async function listUserResourceAliases(
|
||||
inArray(siteResources.siteResourceId, accessibleSiteResourceIds)
|
||||
];
|
||||
|
||||
if (typeof status !== "undefined") {
|
||||
whereConditions.push(eq(siteResources.status, status));
|
||||
}
|
||||
|
||||
if (labelFilter && labelFilter.length > 0) {
|
||||
whereConditions.push(
|
||||
inArray(
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
db,
|
||||
resources,
|
||||
siteNetworks,
|
||||
siteResources,
|
||||
sites,
|
||||
type Site,
|
||||
type SiteResource
|
||||
} from "@server/db";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import {
|
||||
getResourceIdsForSite,
|
||||
getSiteResourceIdsForSite
|
||||
} from "@server/lib/deleteSiteAssociatedResources";
|
||||
import {
|
||||
handleMessagingForUpdatedSiteResource,
|
||||
rebuildClientAssociationsFromSiteResource,
|
||||
waitForSiteResourceRebuildIdle
|
||||
} from "@server/lib/rebuildClientAssociations";
|
||||
|
||||
const approveSiteParamsSchema = z.strictObject({
|
||||
siteId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/site/{siteId}/approve",
|
||||
description:
|
||||
"Approve a pending site and approve (and enable when needed) its associated resources.",
|
||||
tags: [OpenAPITags.Site],
|
||||
request: {
|
||||
params: approveSiteParamsSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
status: z.number()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
type SiteResourceEnableSideEffect = {
|
||||
existing: SiteResource;
|
||||
updated: SiteResource;
|
||||
siteIds: number[];
|
||||
};
|
||||
|
||||
export async function approveSite(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = approveSiteParamsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { siteId } = parsedParams.data;
|
||||
|
||||
const [existingSite] = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.siteId, siteId))
|
||||
.limit(1);
|
||||
|
||||
if (!existingSite) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Site with ID ${siteId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!existingSite.orgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
`Site with ID ${siteId} has no organization`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const orgId = existingSite.orgId;
|
||||
let updatedSite: Site | undefined;
|
||||
const siteResourceEnableSideEffects: SiteResourceEnableSideEffect[] =
|
||||
[];
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
[updatedSite] = await trx
|
||||
.update(sites)
|
||||
.set({ status: "approved" })
|
||||
.where(eq(sites.siteId, siteId))
|
||||
.returning();
|
||||
|
||||
const resourceIds = await getResourceIdsForSite(siteId, trx);
|
||||
const siteResourceIds = await getSiteResourceIdsForSite(
|
||||
siteId,
|
||||
orgId,
|
||||
trx
|
||||
);
|
||||
|
||||
if (resourceIds.length > 0) {
|
||||
const pendingDisabledResources = await trx
|
||||
.select({ resourceId: resources.resourceId })
|
||||
.from(resources)
|
||||
.where(
|
||||
and(
|
||||
inArray(resources.resourceId, resourceIds),
|
||||
eq(resources.status, "pending"),
|
||||
eq(resources.enabled, false)
|
||||
)
|
||||
);
|
||||
|
||||
await trx
|
||||
.update(resources)
|
||||
.set({ status: "approved" })
|
||||
.where(inArray(resources.resourceId, resourceIds));
|
||||
|
||||
if (pendingDisabledResources.length > 0) {
|
||||
await trx
|
||||
.update(resources)
|
||||
.set({ enabled: true })
|
||||
.where(
|
||||
inArray(
|
||||
resources.resourceId,
|
||||
pendingDisabledResources.map(
|
||||
(r) => r.resourceId
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (siteResourceIds.length > 0) {
|
||||
const existingSiteResources = await trx
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(
|
||||
inArray(siteResources.siteResourceId, siteResourceIds)
|
||||
);
|
||||
|
||||
const pendingDisabledSiteResources =
|
||||
existingSiteResources.filter(
|
||||
(sr) => sr.status === "pending" && !sr.enabled
|
||||
);
|
||||
|
||||
await trx
|
||||
.update(siteResources)
|
||||
.set({ status: "approved" })
|
||||
.where(
|
||||
inArray(siteResources.siteResourceId, siteResourceIds)
|
||||
);
|
||||
|
||||
if (pendingDisabledSiteResources.length > 0) {
|
||||
const enableIds = pendingDisabledSiteResources.map(
|
||||
(sr) => sr.siteResourceId
|
||||
);
|
||||
|
||||
const updatedEnabledSiteResources = await trx
|
||||
.update(siteResources)
|
||||
.set({ enabled: true })
|
||||
.where(inArray(siteResources.siteResourceId, enableIds))
|
||||
.returning();
|
||||
|
||||
for (const updated of updatedEnabledSiteResources) {
|
||||
const existing = pendingDisabledSiteResources.find(
|
||||
(sr) => sr.siteResourceId === updated.siteResourceId
|
||||
);
|
||||
if (!existing || !updated.networkId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const networkSites = await trx
|
||||
.select({ siteId: siteNetworks.siteId })
|
||||
.from(siteNetworks)
|
||||
.where(
|
||||
eq(siteNetworks.networkId, updated.networkId)
|
||||
);
|
||||
|
||||
siteResourceEnableSideEffects.push({
|
||||
existing,
|
||||
updated,
|
||||
siteIds: networkSites.map((s) => s.siteId)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (const sideEffect of siteResourceEnableSideEffects) {
|
||||
rebuildClientAssociationsFromSiteResource(sideEffect.updated)
|
||||
.then(() =>
|
||||
waitForSiteResourceRebuildIdle(
|
||||
sideEffect.updated.siteResourceId
|
||||
)
|
||||
)
|
||||
.then(() =>
|
||||
handleMessagingForUpdatedSiteResource(
|
||||
sideEffect.existing,
|
||||
sideEffect.updated,
|
||||
sideEffect.siteIds,
|
||||
sideEffect.siteIds
|
||||
)
|
||||
)
|
||||
.catch((e) => {
|
||||
logger.error(
|
||||
`Failed to rebuild and handle messaging for site resource ${sideEffect.updated.siteResourceId} after site approval:`,
|
||||
e
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return response(res, {
|
||||
data: updatedSite ?? null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Site approved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -159,15 +159,21 @@ export async function deleteSite(
|
||||
siteResources: []
|
||||
};
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
if (deleteResources) {
|
||||
if (deleteResources) {
|
||||
await db.transaction(async (trx) => {
|
||||
resourceSideEffects = await deleteAssociatedResourcesForSite(
|
||||
siteId,
|
||||
site.orgId,
|
||||
trx
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
await runDeleteSiteAssociatedResourcesSideEffects(
|
||||
resourceSideEffects
|
||||
);
|
||||
}
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
if (site.type == "wireguard") {
|
||||
if (site.pubKey) {
|
||||
await deletePeer(site.exitNodeId!, site.pubKey);
|
||||
@@ -180,12 +186,6 @@ export async function deleteSite(
|
||||
await usageService.add(site.orgId, LimitId.SITES, -1, trx);
|
||||
});
|
||||
|
||||
if (deleteResources) {
|
||||
await runDeleteSiteAssociatedResourcesSideEffects(
|
||||
resourceSideEffects
|
||||
);
|
||||
}
|
||||
|
||||
if (deletedNewt) {
|
||||
const payload = {
|
||||
type: `newt/wg/terminate`,
|
||||
|
||||
@@ -3,6 +3,8 @@ export * from "./getStatusHistory";
|
||||
export * from "./createSite";
|
||||
export * from "./deleteSite";
|
||||
export * from "./updateSite";
|
||||
export * from "./approveSite";
|
||||
export * from "./rejectSite";
|
||||
export * from "./listSites";
|
||||
export * from "./listSiteRoles";
|
||||
export * from "./pickSiteDefaults";
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { newts, sites } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { deletePeer } from "../gerbil/peers";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { sendToClient } from "#dynamic/routers/ws";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { cleanupSiteAssociations } from "@server/lib/rebuildClientAssociations";
|
||||
import { usageService } from "@server/lib/billing/usageService";
|
||||
import { LimitId } from "@server/lib/billing";
|
||||
import {
|
||||
deletePendingAssociatedResourcesForSite,
|
||||
exceedsSiteAssociatedResourceDeleteLimit,
|
||||
getPendingAssociatedResourceCountForSite,
|
||||
runDeleteSiteAssociatedResourcesSideEffects,
|
||||
MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE,
|
||||
type DeleteSiteAssociatedResourcesSideEffects
|
||||
} from "@server/lib/deleteSiteAssociatedResources";
|
||||
|
||||
const rejectSiteParamsSchema = z.strictObject({
|
||||
siteId: z.coerce.number().int().positive()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/site/{siteId}/reject",
|
||||
description:
|
||||
"Reject a pending site by deleting it and any associated resources that are still pending.",
|
||||
tags: [OpenAPITags.Site],
|
||||
request: {
|
||||
params: rejectSiteParamsSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
status: z.number()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function rejectSite(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = rejectSiteParamsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { siteId } = parsedParams.data;
|
||||
|
||||
const [site] = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.siteId, siteId))
|
||||
.limit(1);
|
||||
|
||||
if (!site) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Site with ID ${siteId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!site.orgId) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
`Site with ID ${siteId} has no organization`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const pendingAssociatedResourceCount =
|
||||
await getPendingAssociatedResourceCountForSite(siteId, site.orgId);
|
||||
|
||||
if (
|
||||
exceedsSiteAssociatedResourceDeleteLimit(
|
||||
pendingAssociatedResourceCount
|
||||
)
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
`Cannot reject site and associated pending resources when the site has more than ${MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE} pending resources`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [deletedNewt] = await db
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, siteId))
|
||||
.limit(1);
|
||||
|
||||
let resourceSideEffects: DeleteSiteAssociatedResourcesSideEffects = {
|
||||
resources: [],
|
||||
siteResources: []
|
||||
};
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
resourceSideEffects = await deletePendingAssociatedResourcesForSite(
|
||||
siteId,
|
||||
site.orgId,
|
||||
trx
|
||||
);
|
||||
});
|
||||
|
||||
await runDeleteSiteAssociatedResourcesSideEffects(resourceSideEffects);
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
if (site.type == "wireguard") {
|
||||
if (site.pubKey) {
|
||||
await deletePeer(site.exitNodeId!, site.pubKey);
|
||||
}
|
||||
} else if (site.type == "newt") {
|
||||
await cleanupSiteAssociations(site, trx);
|
||||
}
|
||||
|
||||
await trx.delete(sites).where(eq(sites.siteId, siteId));
|
||||
await usageService.add(site.orgId, LimitId.SITES, -1, trx);
|
||||
});
|
||||
|
||||
if (deletedNewt) {
|
||||
const payload = {
|
||||
type: `newt/wg/terminate`,
|
||||
data: {}
|
||||
};
|
||||
sendToClient(deletedNewt.newtId, payload).catch((error) => {
|
||||
logger.error(
|
||||
"Failed to send termination message to newt:",
|
||||
error
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return response(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Site rejected successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
if (createHttpError.isHttpError(error)) {
|
||||
return next(error);
|
||||
}
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,6 @@ const updateSiteBodySchema = z
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
niceId: z.string().min(1).max(255).optional(),
|
||||
dockerSocketEnabled: z.boolean().optional(),
|
||||
status: z.enum(["pending", "approved"]).optional(),
|
||||
autoUpdateEnabled: z.boolean().optional(),
|
||||
autoUpdateOverrideOrg: z.boolean().optional()
|
||||
})
|
||||
|
||||
@@ -86,6 +86,15 @@ const listAllSiteResourcesByOrgQuerySchema = z.strictObject({
|
||||
description:
|
||||
"When set, only site resources associated with this site (via network) are returned"
|
||||
}),
|
||||
status: z
|
||||
.enum(["pending", "approved"])
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.openapi({
|
||||
type: "string",
|
||||
enum: ["pending", "approved"],
|
||||
description: "Filter by site resource status"
|
||||
}),
|
||||
labels: z
|
||||
.preprocess((val) => {
|
||||
if (val === undefined || val === null || val === "") {
|
||||
@@ -283,6 +292,7 @@ export async function listAllSiteResourcesByOrg(
|
||||
sort_by,
|
||||
order,
|
||||
siteId,
|
||||
status,
|
||||
labels: labelFilter
|
||||
} = parsedQuery.data;
|
||||
|
||||
@@ -315,6 +325,10 @@ export async function listAllSiteResourcesByOrg(
|
||||
conditions.push(eq(siteResources.mode, mode));
|
||||
}
|
||||
|
||||
if (typeof status !== "undefined") {
|
||||
conditions.push(eq(siteResources.status, status));
|
||||
}
|
||||
|
||||
if (labelFilter && labelFilter.length > 0) {
|
||||
conditions.push(
|
||||
inArray(
|
||||
|
||||
@@ -47,6 +47,15 @@ const listSiteResourcesQuerySchema = z.strictObject({
|
||||
enum: ["asc", "desc"],
|
||||
default: "asc",
|
||||
description: "Sort order"
|
||||
}),
|
||||
status: z
|
||||
.enum(["pending", "approved"])
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.openapi({
|
||||
type: "string",
|
||||
enum: ["pending", "approved"],
|
||||
description: "Filter by site resource status"
|
||||
})
|
||||
});
|
||||
|
||||
@@ -110,7 +119,7 @@ export async function listSiteResources(
|
||||
}
|
||||
|
||||
const { siteId, orgId } = parsedParams.data;
|
||||
const { limit, offset, sort_by, order } = parsedQuery.data;
|
||||
const { limit, offset, sort_by, order, status } = parsedQuery.data;
|
||||
|
||||
// Verify the site exists and belongs to the org
|
||||
const site = await db
|
||||
@@ -124,6 +133,15 @@ export async function listSiteResources(
|
||||
}
|
||||
|
||||
// Get site resources by joining networks to siteResources via siteNetworks
|
||||
const conditions = [
|
||||
eq(siteNetworks.siteId, siteId),
|
||||
eq(siteResources.orgId, orgId)
|
||||
];
|
||||
|
||||
if (typeof status !== "undefined") {
|
||||
conditions.push(eq(siteResources.status, status));
|
||||
}
|
||||
|
||||
const siteResourcesList = await db
|
||||
.select()
|
||||
.from(siteNetworks)
|
||||
@@ -132,12 +150,7 @@ export async function listSiteResources(
|
||||
siteResources,
|
||||
eq(siteResources.networkId, networks.networkId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(siteNetworks.siteId, siteId),
|
||||
eq(siteResources.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.where(and(...conditions))
|
||||
.orderBy(
|
||||
sort_by
|
||||
? order === "asc"
|
||||
|
||||
@@ -29,7 +29,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useActionState, useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { PrivateResourceAccessFields } from "../../PrivateResourceAccessFields";
|
||||
import { PrivateResourceAccessFields } from "@app/components/PrivateResourceAccessFields";
|
||||
|
||||
export default function PrivateResourceAccessPage() {
|
||||
const t = useTranslations();
|
||||
|
||||
@@ -20,12 +20,12 @@ import { useTranslations } from "next-intl";
|
||||
import { useActionState, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { PrivateResourceSitesField } from "../../PrivateResourceSitesField";
|
||||
import { PrivateResourceCidrDestinationField } from "../../PrivateResourceDestinationFields";
|
||||
import { PrivateResourcePortRanges } from "../../PrivateResourcePortRanges";
|
||||
import { buildSelectedSitesForResource } from "../../privateResourceUtils";
|
||||
import { asAnyControl, asAnySetValue } from "../../formControlUtils";
|
||||
import { useSaveSiteResource } from "../../useSaveSiteResource";
|
||||
import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
|
||||
import { PrivateResourceCidrDestinationField } from "@app/components/PrivateResourceDestinationFields";
|
||||
import { PrivateResourcePortRanges } from "@app/components/PrivateResourcePortRanges";
|
||||
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
|
||||
import { asAnyControl, asAnySetValue } from "@app/lib/formControlUtils";
|
||||
import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
|
||||
|
||||
export default function PrivateResourceCidrPage() {
|
||||
const t = useTranslations();
|
||||
|
||||
@@ -30,7 +30,7 @@ import { useTranslations } from "next-intl";
|
||||
import { useActionState, useMemo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { useSaveSiteResource } from "../../useSaveSiteResource";
|
||||
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
|
||||
|
||||
export default function PrivateResourceGeneralPage() {
|
||||
const t = useTranslations();
|
||||
|
||||
@@ -20,16 +20,16 @@ import { useTranslations } from "next-intl";
|
||||
import { useActionState, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { PrivateResourceSitesField } from "../../PrivateResourceSitesField";
|
||||
import { PrivateResourceHostDestinationFields } from "../../PrivateResourceDestinationFields";
|
||||
import { PrivateResourcePortRanges } from "../../PrivateResourcePortRanges";
|
||||
import { buildSelectedSitesForResource } from "../../privateResourceUtils";
|
||||
import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
|
||||
import { PrivateResourceHostDestinationFields } from "@app/components/PrivateResourceDestinationFields";
|
||||
import { PrivateResourcePortRanges } from "@app/components/PrivateResourcePortRanges";
|
||||
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
|
||||
import {
|
||||
asAnyControl,
|
||||
asAnySetValue,
|
||||
asAnyWatch
|
||||
} from "../../formControlUtils";
|
||||
import { useSaveSiteResource } from "../../useSaveSiteResource";
|
||||
} from "@app/lib/formControlUtils";
|
||||
import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
|
||||
|
||||
export default function PrivateResourceHostPage() {
|
||||
const t = useTranslations();
|
||||
|
||||
@@ -22,15 +22,15 @@ import { useTranslations } from "next-intl";
|
||||
import { useActionState, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { PrivateResourceSitesField } from "../../PrivateResourceSitesField";
|
||||
import { PrivateResourceHttpFields } from "../../PrivateResourceHttpFields";
|
||||
import { buildSelectedSitesForResource } from "../../privateResourceUtils";
|
||||
import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
|
||||
import { PrivateResourceHttpFields } from "@app/components/PrivateResourceHttpFields";
|
||||
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
|
||||
import {
|
||||
asAnyControl,
|
||||
asAnySetValue,
|
||||
asAnyWatch
|
||||
} from "../../formControlUtils";
|
||||
import { useSaveSiteResource } from "../../useSaveSiteResource";
|
||||
} from "@app/lib/formControlUtils";
|
||||
import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
|
||||
|
||||
export default function PrivateResourceHttpPage() {
|
||||
const t = useTranslations();
|
||||
|
||||
@@ -26,15 +26,15 @@ import { useTranslations } from "next-intl";
|
||||
import { useActionState, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { PrivateResourceSshFields } from "../../PrivateResourceSshFields";
|
||||
import { buildSelectedSitesForResource } from "../../privateResourceUtils";
|
||||
import { PrivateResourceSshFields } from "@app/components/PrivateResourceSshFields";
|
||||
import type { Selectedsite } from "@app/components/site-selector";
|
||||
import { useSaveSiteResource } from "@app/hooks/useSaveSiteResource";
|
||||
import {
|
||||
asAnyControl,
|
||||
asAnySetValue,
|
||||
asAnyWatch
|
||||
} from "../../formControlUtils";
|
||||
import { useSaveSiteResource } from "../../useSaveSiteResource";
|
||||
import type { Selectedsite } from "@app/components/site-selector";
|
||||
} from "@app/lib/formControlUtils";
|
||||
import { buildSelectedSitesForResource } from "@app/lib/privateResourceUtils";
|
||||
|
||||
export default function PrivateResourceSshPage() {
|
||||
const t = useTranslations();
|
||||
|
||||
@@ -50,16 +50,20 @@ import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { PrivateResourceSitesField } from "../PrivateResourceSitesField";
|
||||
import { PrivateResourceHttpFields } from "../PrivateResourceHttpFields";
|
||||
import { PrivateResourceSshFields } from "../PrivateResourceSshFields";
|
||||
import { PrivateResourcePortRanges } from "../PrivateResourcePortRanges";
|
||||
import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
|
||||
import { PrivateResourceHttpFields } from "@app/components/PrivateResourceHttpFields";
|
||||
import { PrivateResourceSshFields } from "@app/components/PrivateResourceSshFields";
|
||||
import { PrivateResourcePortRanges } from "@app/components/PrivateResourcePortRanges";
|
||||
import {
|
||||
PrivateResourceAliasField,
|
||||
PrivateResourceCidrDestinationField,
|
||||
PrivateResourceHostDestinationFields
|
||||
} from "../PrivateResourceDestinationFields";
|
||||
import { asAnyControl, asAnySetValue, asAnyWatch } from "../formControlUtils";
|
||||
} from "@app/components/PrivateResourceDestinationFields";
|
||||
import {
|
||||
asAnyControl,
|
||||
asAnySetValue,
|
||||
asAnyWatch
|
||||
} from "@app/lib/formControlUtils";
|
||||
|
||||
export default function CreatePrivateResourcePage() {
|
||||
const params = useParams();
|
||||
|
||||
@@ -27,6 +27,7 @@ export default async function ClientResourcesPage(
|
||||
const params = await props.params;
|
||||
const t = await getTranslations();
|
||||
const searchParams = new URLSearchParams(await props.searchParams);
|
||||
searchParams.set("status", "approved");
|
||||
|
||||
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
|
||||
let pagination: ListAllSiteResourcesByOrgResponse["pagination"] = {
|
||||
|
||||
@@ -38,6 +38,7 @@ export default async function ProxyResourcesPage(
|
||||
const params = await props.params;
|
||||
const t = await getTranslations();
|
||||
const searchParams = new URLSearchParams(await props.searchParams);
|
||||
searchParams.set("status", "approved");
|
||||
|
||||
let resources: ListResourcesResponse["resources"] = [];
|
||||
let pagination: ListResourcesResponse["pagination"] = {
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import SiteResourcesOverview from "@app/components/SiteResourcesOverview";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
@@ -24,6 +34,7 @@ import {
|
||||
ArrowUp10Icon,
|
||||
ArrowUpRight,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronsUpDownIcon,
|
||||
MoreHorizontal,
|
||||
X
|
||||
@@ -65,8 +76,10 @@ export default function PendingSitesTable({
|
||||
const [isRefreshing, startTransition] = useTransition();
|
||||
const [approvingIds, setApprovingIds] = useState<Set<number>>(new Set());
|
||||
const [rejectingIds, setRejectingIds] = useState<Set<number>>(new Set());
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isRejectModalOpen, setIsRejectModalOpen] = useState(false);
|
||||
const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null);
|
||||
const [resourcesDialogSite, setResourcesDialogSite] =
|
||||
useState<SiteRow | null>(null);
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
const t = useTranslations();
|
||||
@@ -111,7 +124,7 @@ export default function PendingSitesTable({
|
||||
async function approveSite(siteId: number) {
|
||||
setApprovingIds((prev) => new Set(prev).add(siteId));
|
||||
try {
|
||||
await api.post(`/site/${siteId}`, { status: "approved" });
|
||||
await api.post(`/site/${siteId}/approve`);
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("siteApproveSuccess"),
|
||||
@@ -136,20 +149,20 @@ export default function PendingSitesTable({
|
||||
async function rejectSite(siteId: number) {
|
||||
setRejectingIds((prev) => new Set(prev).add(siteId));
|
||||
try {
|
||||
await api.delete(`/site/${siteId}`);
|
||||
await api.post(`/site/${siteId}/reject`);
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("siteDeleted"),
|
||||
description: t("siteRejectSuccess"),
|
||||
variant: "default"
|
||||
});
|
||||
setIsDeleteModalOpen(false);
|
||||
setIsRejectModalOpen(false);
|
||||
setSelectedSite(null);
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("siteErrorDelete"),
|
||||
description: formatAxiosError(e, t("siteErrorDelete"))
|
||||
title: t("siteRejectError"),
|
||||
description: formatAxiosError(e, t("siteRejectError"))
|
||||
});
|
||||
} finally {
|
||||
setRejectingIds((prev) => {
|
||||
@@ -342,6 +355,29 @@ export default function PendingSitesTable({
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "resources",
|
||||
accessorKey: "resourceCount",
|
||||
friendlyName: t("resources"),
|
||||
header: () => <span className="p-3">{t("resources")}</span>,
|
||||
cell: ({ row }) => {
|
||||
const siteRow = row.original;
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setResourcesDialogSite(siteRow)}
|
||||
className="flex h-8 items-center gap-2 px-0 font-normal"
|
||||
>
|
||||
<span className="text-sm tabular-nums">
|
||||
{siteRow.resourceCount} {t("resources")}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 shrink-0" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "exitNode",
|
||||
friendlyName: t("exitNode"),
|
||||
@@ -445,7 +481,7 @@ export default function PendingSitesTable({
|
||||
disabled={isApproving || isRejecting}
|
||||
onClick={() => {
|
||||
setSelectedSite(siteRow);
|
||||
setIsDeleteModalOpen(true);
|
||||
setIsRejectModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<X className="mr-2 w-4 h-4" />
|
||||
@@ -491,25 +527,63 @@ export default function PendingSitesTable({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Credenza
|
||||
open={Boolean(resourcesDialogSite)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setResourcesDialogSite(null);
|
||||
}}
|
||||
>
|
||||
<CredenzaContent className="md:max-w-7xl">
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>{t("siteResourcesTab")}</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("siteResourcesDialogDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
{resourcesDialogSite != null && (
|
||||
<SiteResourcesOverview
|
||||
orgIdOverride={orgId}
|
||||
siteId={resourcesDialogSite.id}
|
||||
initialPublicData={null}
|
||||
initialPrivateData={null}
|
||||
initialPublicForbidden={false}
|
||||
initialPrivateForbidden={false}
|
||||
showViewAllLinks={false}
|
||||
/>
|
||||
)}
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setResourcesDialogSite(null)}
|
||||
>
|
||||
{t("close")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
|
||||
{selectedSite && (
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteModalOpen}
|
||||
open={isRejectModalOpen}
|
||||
setOpen={(val) => {
|
||||
setIsDeleteModalOpen(val);
|
||||
setIsRejectModalOpen(val);
|
||||
if (!val) {
|
||||
setSelectedSite(null);
|
||||
}
|
||||
}}
|
||||
dialog={
|
||||
<div className="space-y-2">
|
||||
<p>{t("siteQuestionRemove")}</p>
|
||||
<p>{t("siteMessageRemove")}</p>
|
||||
<p>{t("siteQuestionReject")}</p>
|
||||
<p>{t("siteMessageReject")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("siteConfirmDelete")}
|
||||
buttonText={t("siteConfirmReject")}
|
||||
onConfirm={async () => rejectSite(selectedSite.id)}
|
||||
string={selectedSite.name}
|
||||
title={t("siteDelete")}
|
||||
title={t("siteReject")}
|
||||
/>
|
||||
)}
|
||||
<ControlledDataTable
|
||||
|
||||
@@ -55,6 +55,7 @@ function getActionsCategories(root: boolean) {
|
||||
[t("actionGetSite")]: "getSite",
|
||||
[t("actionListSites")]: "listSites",
|
||||
[t("actionUpdateSite")]: "updateSite",
|
||||
[t("actionUpdateSiteApprovals")]: "updateSiteApprovals",
|
||||
[t("actionListSiteRoles")]: "listSiteRoles"
|
||||
},
|
||||
|
||||
@@ -78,7 +79,8 @@ function getActionsCategories(root: boolean) {
|
||||
[t("actionGetSiteResource")]: "getSiteResource",
|
||||
[t("actionListSiteResources")]: "listSiteResources",
|
||||
[t("actionUpdateSiteResource")]: "updateSiteResource",
|
||||
[t("actionCreateResourceSessionToken")]: "createResourceSessionToken"
|
||||
[t("actionCreateResourceSessionToken")]:
|
||||
"createResourceSessionToken"
|
||||
},
|
||||
|
||||
Target: {
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ import {
|
||||
import { ChevronsUpDown } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { Control, FieldPath, FieldValues } from "react-hook-form";
|
||||
import { PrivateResourceMultiSiteRoutingHelp } from "./PrivateResourceMultiSiteRoutingHelp";
|
||||
import { PrivateResourceMultiSiteRoutingHelp } from "@app/components/PrivateResourceMultiSiteRoutingHelp";
|
||||
|
||||
type PrivateResourceSitesFieldProps<T extends FieldValues> = {
|
||||
control: Control<T>;
|
||||
+3
-3
@@ -9,10 +9,10 @@ import {
|
||||
} from "@app/components/Settings";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { SshServerSettingsFields } from "@app/components/SshServerSettingsFields";
|
||||
import { PrivateResourceAliasField } from "./PrivateResourceDestinationFields";
|
||||
import { PrivateResourceSitesField } from "./PrivateResourceSitesField";
|
||||
import { getSshUseMultiSiteTargetForm } from "./privateResourceUtils";
|
||||
import { PrivateResourceAliasField } from "@app/components/PrivateResourceDestinationFields";
|
||||
import { PrivateResourceSitesField } from "@app/components/PrivateResourceSitesField";
|
||||
import { inferSshPamMode } from "@app/lib/privateResourceForm";
|
||||
import { getSshUseMultiSiteTargetForm } from "@app/lib/privateResourceUtils";
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
@@ -174,8 +174,8 @@ type OverviewRow = {
|
||||
type OverviewColumnProps = {
|
||||
title: string;
|
||||
description: string;
|
||||
viewAllHref: string;
|
||||
viewAllLabel: string;
|
||||
viewAllHref?: string;
|
||||
viewAllLabel?: string;
|
||||
emptyLabel: string;
|
||||
isForbidden: boolean;
|
||||
isFetching: boolean;
|
||||
@@ -212,12 +212,14 @@ function OverviewColumn({
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={viewAllHref}
|
||||
className="shrink-0 text-muted-foreground text-sm hover:underline"
|
||||
>
|
||||
{viewAllLabel}
|
||||
</Link>
|
||||
{viewAllHref && viewAllLabel ? (
|
||||
<Link
|
||||
href={viewAllHref}
|
||||
className="shrink-0 text-muted-foreground text-sm hover:underline"
|
||||
>
|
||||
{viewAllLabel}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -319,6 +321,8 @@ type SiteResourcesOverviewProps = {
|
||||
initialPrivateForbidden: boolean;
|
||||
/** When not under `/[orgId]/...` routes, pass org id explicitly (e.g. credenza on sites list). */
|
||||
orgIdOverride?: string;
|
||||
/** When false, hides links to the org resources tables filtered by this site. */
|
||||
showViewAllLinks?: boolean;
|
||||
};
|
||||
|
||||
export default function SiteResourcesOverview({
|
||||
@@ -327,7 +331,8 @@ export default function SiteResourcesOverview({
|
||||
initialPrivateData,
|
||||
initialPublicForbidden,
|
||||
initialPrivateForbidden,
|
||||
orgIdOverride
|
||||
orgIdOverride,
|
||||
showViewAllLinks = true
|
||||
}: SiteResourcesOverviewProps) {
|
||||
const t = useTranslations();
|
||||
const params = useParams<{ orgId: string }>();
|
||||
@@ -467,8 +472,10 @@ export default function SiteResourcesOverview({
|
||||
key="public"
|
||||
title={t("siteResourcesSectionPublic")}
|
||||
description={t("siteResourcesSectionPublicDescription")}
|
||||
viewAllHref={publicViewAllHref}
|
||||
viewAllLabel={t("siteResourcesViewAllPublic")}
|
||||
viewAllHref={showViewAllLinks ? publicViewAllHref : undefined}
|
||||
viewAllLabel={
|
||||
showViewAllLinks ? t("siteResourcesViewAllPublic") : undefined
|
||||
}
|
||||
emptyLabel={t("siteResourcesEmptyPublic")}
|
||||
isForbidden={publicForbidden}
|
||||
isFetching={publicQuery.isFetching}
|
||||
@@ -484,8 +491,10 @@ export default function SiteResourcesOverview({
|
||||
key="private"
|
||||
title={t("siteResourcesSectionPrivate")}
|
||||
description={t("siteResourcesSectionPrivateDescription")}
|
||||
viewAllHref={privateViewAllHref}
|
||||
viewAllLabel={t("siteResourcesViewAllPrivate")}
|
||||
viewAllHref={showViewAllLinks ? privateViewAllHref : undefined}
|
||||
viewAllLabel={
|
||||
showViewAllLinks ? t("siteResourcesViewAllPrivate") : undefined
|
||||
}
|
||||
emptyLabel={t("siteResourcesEmptyPrivate")}
|
||||
isForbidden={privateForbidden}
|
||||
isFetching={privateQuery.isFetching}
|
||||
|
||||
Reference in New Issue
Block a user