Merge branch 'dev' into refactor/save-button-positions

This commit is contained in:
Fred KISSIE
2025-12-18 04:39:17 +01:00
24 changed files with 817 additions and 2725 deletions

View File

@@ -33,7 +33,7 @@
"password": "Password", "password": "Password",
"confirmPassword": "Confirm Password", "confirmPassword": "Confirm Password",
"createAccount": "Create Account", "createAccount": "Create Account",
"viewSettings": "View settings", "viewSettings": "View Settings",
"delete": "Delete", "delete": "Delete",
"name": "Name", "name": "Name",
"online": "Online", "online": "Online",
@@ -1621,9 +1621,8 @@
"createInternalResourceDialogResourceProperties": "Resource Properties", "createInternalResourceDialogResourceProperties": "Resource Properties",
"createInternalResourceDialogName": "Name", "createInternalResourceDialogName": "Name",
"createInternalResourceDialogSite": "Site", "createInternalResourceDialogSite": "Site",
"createInternalResourceDialogSelectSite": "Select site...", "selectSite": "Select site...",
"createInternalResourceDialogSearchSites": "Search sites...", "noSitesFound": "No sites found.",
"createInternalResourceDialogNoSitesFound": "No sites found.",
"createInternalResourceDialogProtocol": "Protocol", "createInternalResourceDialogProtocol": "Protocol",
"createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogTcp": "TCP",
"createInternalResourceDialogUdp": "UDP", "createInternalResourceDialogUdp": "UDP",
@@ -2317,5 +2316,6 @@
"organizationLoginPageDescription": "Customize the login page for this organization", "organizationLoginPageDescription": "Customize the login page for this organization",
"resourceLoginPageTitle": "Resource Login Page", "resourceLoginPageTitle": "Resource Login Page",
"resourceLoginPageDescription": "Customize the login page for individual resources", "resourceLoginPageDescription": "Customize the login page for individual resources",
"enterConfirmation": "Enter confirmation" "enterConfirmation": "Enter confirmation",
"blueprintViewDetails": "Details"
} }

2272
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { db, newts, blueprints, Blueprint } from "@server/db"; import { db, newts, blueprints, Blueprint, Site, siteResources, roleSiteResources, userSiteResources, clientSiteResources } from "@server/db";
import { Config, ConfigSchema } from "./types"; import { Config, ConfigSchema } from "./types";
import { ProxyResourcesResults, updateProxyResources } from "./proxyResources"; import { ProxyResourcesResults, updateProxyResources } from "./proxyResources";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
@@ -15,6 +15,7 @@ import { BlueprintSource } from "@server/routers/blueprints/types";
import { stringify as stringifyYaml } from "yaml"; import { stringify as stringifyYaml } from "yaml";
import { faker } from "@faker-js/faker"; import { faker } from "@faker-js/faker";
import { handleMessagingForUpdatedSiteResource } from "@server/routers/siteResource"; import { handleMessagingForUpdatedSiteResource } from "@server/routers/siteResource";
import { rebuildClientAssociationsFromSiteResource } from "../rebuildClientAssociations";
type ApplyBlueprintArgs = { type ApplyBlueprintArgs = {
orgId: string; orgId: string;
@@ -108,38 +109,136 @@ export async function applyBlueprint({
// We need to update the targets on the newts from the successfully updated information // We need to update the targets on the newts from the successfully updated information
for (const result of clientResourcesResults) { for (const result of clientResourcesResults) {
const [site] = await trx if (
.select() result.oldSiteResource &&
.from(sites) result.oldSiteResource.siteId !=
.innerJoin(newts, eq(sites.siteId, newts.siteId)) result.newSiteResource.siteId
.where( ) {
and( // query existing associations
eq(sites.siteId, result.newSiteResource.siteId), const existingRoleIds = await trx
eq(sites.orgId, orgId), .select()
eq(sites.type, "newt"), .from(roleSiteResources)
isNotNull(sites.pubKey) .where(
eq(
roleSiteResources.siteResourceId,
result.oldSiteResource.siteResourceId
)
) )
) .then((rows) => rows.map((row) => row.roleId));
.limit(1);
if (!site) { const existingUserIds= await trx
logger.debug( .select()
`No newt site found for client resource ${result.newSiteResource.siteResourceId}, skipping target update` .from(userSiteResources)
.where(
eq(
userSiteResources.siteResourceId,
result.oldSiteResource.siteResourceId
)
).then((rows) => rows.map((row) => row.userId));
const existingClientIds = await trx
.select()
.from(clientSiteResources)
.where(
eq(
clientSiteResources.siteResourceId,
result.oldSiteResource.siteResourceId
)
).then((rows) => rows.map((row) => row.clientId));
// delete the existing site resource
await trx
.delete(siteResources)
.where(
and(eq(siteResources.siteResourceId, result.oldSiteResource.siteResourceId))
);
await rebuildClientAssociationsFromSiteResource(
result.oldSiteResource,
trx
);
const [insertedSiteResource] = await trx
.insert(siteResources)
.values({
...result.newSiteResource,
})
.returning();
// wait some time to allow for messages to be handled
await new Promise((resolve) => setTimeout(resolve, 750));
//////////////////// update the associations ////////////////////
if (existingRoleIds.length > 0) {
await trx.insert(roleSiteResources).values(
existingRoleIds.map((roleId) => ({
roleId,
siteResourceId: insertedSiteResource!.siteResourceId
}))
);
}
if (existingUserIds.length > 0) {
await trx.insert(userSiteResources).values(
existingUserIds.map((userId) => ({
userId,
siteResourceId: insertedSiteResource!.siteResourceId
}))
);
}
if (existingClientIds.length > 0) {
await trx.insert(clientSiteResources).values(
existingClientIds.map((clientId) => ({
clientId,
siteResourceId: insertedSiteResource!.siteResourceId
}))
);
}
await rebuildClientAssociationsFromSiteResource(
insertedSiteResource,
trx
);
} else {
const [newSite] = await trx
.select()
.from(sites)
.innerJoin(newts, eq(sites.siteId, newts.siteId))
.where(
and(
eq(sites.siteId, result.newSiteResource.siteId),
eq(sites.orgId, orgId),
eq(sites.type, "newt"),
isNotNull(sites.pubKey)
)
)
.limit(1);
if (!newSite) {
logger.debug(
`No newt site found for client resource ${result.newSiteResource.siteResourceId}, skipping target update`
);
continue;
}
logger.debug(
`Updating client resource ${result.newSiteResource.siteResourceId} on site ${newSite.sites.siteId}`
);
await handleMessagingForUpdatedSiteResource(
result.oldSiteResource,
result.newSiteResource,
{
siteId: newSite.sites.siteId,
orgId: newSite.sites.orgId
},
trx
); );
continue;
} }
logger.debug(
`Updating client resource ${result.newSiteResource.siteResourceId} on site ${site.sites.siteId}`
);
await handleMessagingForUpdatedSiteResource(
result.oldSiteResource,
result.newSiteResource,
{ siteId: site.sites.siteId, orgId: site.sites.orgId },
trx
);
// await addClientTargets( // await addClientTargets(
// site.newt.newtId, // site.newt.newtId,
// result.resource.destination, // result.resource.destination,

View File

@@ -14,6 +14,7 @@ import { sites } from "@server/db";
import { eq, and, ne, inArray } from "drizzle-orm"; import { eq, and, ne, inArray } from "drizzle-orm";
import { Config } from "./types"; import { Config } from "./types";
import logger from "@server/logger"; import logger from "@server/logger";
import { getNextAvailableAliasAddress } from "../ip";
export type ClientResourcesResults = { export type ClientResourcesResults = {
newSiteResource: SiteResource; newSiteResource: SiteResource;
@@ -75,17 +76,12 @@ export async function updateClientResources(
} }
if (existingResource) { if (existingResource) {
if (existingResource.siteId !== site.siteId) {
throw new Error(
`You can not change the site of an existing client resource (${resourceNiceId}). Please delete and recreate it instead.`
);
}
// Update existing resource // Update existing resource
const [updatedResource] = await trx const [updatedResource] = await trx
.update(siteResources) .update(siteResources)
.set({ .set({
name: resourceData.name || resourceNiceId, name: resourceData.name || resourceNiceId,
siteId: site.siteId,
mode: resourceData.mode, mode: resourceData.mode,
destination: resourceData.destination, destination: resourceData.destination,
enabled: true, // hardcoded for now enabled: true, // hardcoded for now
@@ -208,6 +204,12 @@ export async function updateClientResources(
oldSiteResource: existingResource oldSiteResource: existingResource
}); });
} else { } else {
let aliasAddress: string | null = null;
if (resourceData.mode == "host") {
// we can only have an alias on a host
aliasAddress = await getNextAvailableAliasAddress(orgId);
}
// Create new resource // Create new resource
const [newResource] = await trx const [newResource] = await trx
.insert(siteResources) .insert(siteResources)
@@ -221,6 +223,7 @@ export async function updateClientResources(
enabled: true, // hardcoded for now enabled: true, // hardcoded for now
// enabled: resourceData.enabled ?? true, // enabled: resourceData.enabled ?? true,
alias: resourceData.alias || null, alias: resourceData.alias || null,
aliasAddress: aliasAddress,
disableIcmp: resourceData["disable-icmp"], disableIcmp: resourceData["disable-icmp"],
tcpPortRangeString: resourceData["tcp-ports"], tcpPortRangeString: resourceData["tcp-ports"],
udpPortRangeString: resourceData["udp-ports"] udpPortRangeString: resourceData["udp-ports"]

View File

@@ -239,9 +239,8 @@ authenticated.get(
// Site Resource endpoints // Site Resource endpoints
authenticated.put( authenticated.put(
"/org/:orgId/site/:siteId/resource", "/org/:orgId/site-resource",
verifyOrgAccess, verifyOrgAccess,
verifySiteAccess,
verifyUserHasAction(ActionsEnum.createSiteResource), verifyUserHasAction(ActionsEnum.createSiteResource),
logActionAudit(ActionsEnum.createSiteResource), logActionAudit(ActionsEnum.createSiteResource),
siteResource.createSiteResource siteResource.createSiteResource
@@ -263,18 +262,14 @@ authenticated.get(
); );
authenticated.get( authenticated.get(
"/org/:orgId/site/:siteId/resource/:siteResourceId", "/site-resource/:siteResourceId",
verifyOrgAccess,
verifySiteAccess,
verifySiteResourceAccess, verifySiteResourceAccess,
verifyUserHasAction(ActionsEnum.getSiteResource), verifyUserHasAction(ActionsEnum.getSiteResource),
siteResource.getSiteResource siteResource.getSiteResource
); );
authenticated.post( authenticated.post(
"/org/:orgId/site/:siteId/resource/:siteResourceId", "/site-resource/:siteResourceId",
verifyOrgAccess,
verifySiteAccess,
verifySiteResourceAccess, verifySiteResourceAccess,
verifyUserHasAction(ActionsEnum.updateSiteResource), verifyUserHasAction(ActionsEnum.updateSiteResource),
logActionAudit(ActionsEnum.updateSiteResource), logActionAudit(ActionsEnum.updateSiteResource),
@@ -282,9 +277,7 @@ authenticated.post(
); );
authenticated.delete( authenticated.delete(
"/org/:orgId/site/:siteId/resource/:siteResourceId", "/site-resource/:siteResourceId",
verifyOrgAccess,
verifySiteAccess,
verifySiteResourceAccess, verifySiteResourceAccess,
verifyUserHasAction(ActionsEnum.deleteSiteResource), verifyUserHasAction(ActionsEnum.deleteSiteResource),
logActionAudit(ActionsEnum.deleteSiteResource), logActionAudit(ActionsEnum.deleteSiteResource),

View File

@@ -146,9 +146,8 @@ authenticated.get(
); );
// Site Resource endpoints // Site Resource endpoints
authenticated.put( authenticated.put(
"/org/:orgId/site/:siteId/resource", "/org/:orgId/private-resource",
verifyApiKeyOrgAccess, verifyApiKeyOrgAccess,
verifyApiKeySiteAccess,
verifyApiKeyHasAction(ActionsEnum.createSiteResource), verifyApiKeyHasAction(ActionsEnum.createSiteResource),
logActionAudit(ActionsEnum.createSiteResource), logActionAudit(ActionsEnum.createSiteResource),
siteResource.createSiteResource siteResource.createSiteResource
@@ -170,18 +169,14 @@ authenticated.get(
); );
authenticated.get( authenticated.get(
"/org/:orgId/site/:siteId/resource/:siteResourceId", "/site-resource/:siteResourceId",
verifyApiKeyOrgAccess,
verifyApiKeySiteAccess,
verifyApiKeySiteResourceAccess, verifyApiKeySiteResourceAccess,
verifyApiKeyHasAction(ActionsEnum.getSiteResource), verifyApiKeyHasAction(ActionsEnum.getSiteResource),
siteResource.getSiteResource siteResource.getSiteResource
); );
authenticated.post( authenticated.post(
"/org/:orgId/site/:siteId/resource/:siteResourceId", "/site-resource/:siteResourceId",
verifyApiKeyOrgAccess,
verifyApiKeySiteAccess,
verifyApiKeySiteResourceAccess, verifyApiKeySiteResourceAccess,
verifyApiKeyHasAction(ActionsEnum.updateSiteResource), verifyApiKeyHasAction(ActionsEnum.updateSiteResource),
logActionAudit(ActionsEnum.updateSiteResource), logActionAudit(ActionsEnum.updateSiteResource),
@@ -189,9 +184,7 @@ authenticated.post(
); );
authenticated.delete( authenticated.delete(
"/org/:orgId/site/:siteId/resource/:siteResourceId", "/site-resource/:siteResourceId",
verifyApiKeyOrgAccess,
verifyApiKeySiteAccess,
verifyApiKeySiteResourceAccess, verifyApiKeySiteResourceAccess,
verifyApiKeyHasAction(ActionsEnum.deleteSiteResource), verifyApiKeyHasAction(ActionsEnum.deleteSiteResource),
logActionAudit(ActionsEnum.deleteSiteResource), logActionAudit(ActionsEnum.deleteSiteResource),

View File

@@ -89,7 +89,7 @@ export async function deleteSite(
// Send termination message outside of transaction to prevent blocking // Send termination message outside of transaction to prevent blocking
if (deletedNewtId) { if (deletedNewtId) {
const payload = { const payload = {
type: `newt/terminate`, type: `newt/wg/terminate`,
data: {} data: {}
}; };
// Don't await this to prevent blocking the response // Don't await this to prevent blocking the response

View File

@@ -23,7 +23,6 @@ import { z } from "zod";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
const createSiteResourceParamsSchema = z.strictObject({ const createSiteResourceParamsSchema = z.strictObject({
siteId: z.string().transform(Number).pipe(z.int().positive()),
orgId: z.string() orgId: z.string()
}); });
@@ -31,6 +30,7 @@ const createSiteResourceSchema = z
.strictObject({ .strictObject({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
mode: z.enum(["host", "cidr", "port"]), mode: z.enum(["host", "cidr", "port"]),
siteId: z.int(),
// protocol: z.enum(["tcp", "udp"]).optional(), // protocol: z.enum(["tcp", "udp"]).optional(),
// proxyPort: z.int().positive().optional(), // proxyPort: z.int().positive().optional(),
// destinationPort: z.int().positive().optional(), // destinationPort: z.int().positive().optional(),
@@ -101,7 +101,7 @@ export type CreateSiteResourceResponse = SiteResource;
registry.registerPath({ registry.registerPath({
method: "put", method: "put",
path: "/org/{orgId}/site/{siteId}/resource", path: "/org/{orgId}/site-resource",
description: "Create a new site resource.", description: "Create a new site resource.",
tags: [OpenAPITags.Client, OpenAPITags.Org], tags: [OpenAPITags.Client, OpenAPITags.Org],
request: { request: {
@@ -145,9 +145,10 @@ export async function createSiteResource(
); );
} }
const { siteId, orgId } = parsedParams.data; const { orgId } = parsedParams.data;
const { const {
name, name,
siteId,
mode, mode,
// protocol, // protocol,
// proxyPort, // proxyPort,

View File

@@ -12,9 +12,7 @@ import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
const deleteSiteResourceParamsSchema = z.strictObject({ const deleteSiteResourceParamsSchema = z.strictObject({
siteResourceId: z.string().transform(Number).pipe(z.int().positive()), siteResourceId: z.string().transform(Number).pipe(z.int().positive())
siteId: z.string().transform(Number).pipe(z.int().positive()),
orgId: z.string()
}); });
export type DeleteSiteResourceResponse = { export type DeleteSiteResourceResponse = {
@@ -23,7 +21,7 @@ export type DeleteSiteResourceResponse = {
registry.registerPath({ registry.registerPath({
method: "delete", method: "delete",
path: "/org/{orgId}/site/{siteId}/resource/{siteResourceId}", path: "/site-resource/{siteResourceId}",
description: "Delete a site resource.", description: "Delete a site resource.",
tags: [OpenAPITags.Client, OpenAPITags.Org], tags: [OpenAPITags.Client, OpenAPITags.Org],
request: { request: {
@@ -50,29 +48,13 @@ export async function deleteSiteResource(
); );
} }
const { siteResourceId, siteId, orgId } = parsedParams.data; const { siteResourceId } = parsedParams.data;
const [site] = await db
.select()
.from(sites)
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
.limit(1);
if (!site) {
return next(createHttpError(HttpCode.NOT_FOUND, "Site not found"));
}
// Check if site resource exists // Check if site resource exists
const [existingSiteResource] = await db const [existingSiteResource] = await db
.select() .select()
.from(siteResources) .from(siteResources)
.where( .where(and(eq(siteResources.siteResourceId, siteResourceId)))
and(
eq(siteResources.siteResourceId, siteResourceId),
eq(siteResources.siteId, siteId),
eq(siteResources.orgId, orgId)
)
)
.limit(1); .limit(1);
if (!existingSiteResource) { if (!existingSiteResource) {
@@ -85,19 +67,13 @@ export async function deleteSiteResource(
// Delete the site resource // Delete the site resource
const [removedSiteResource] = await trx const [removedSiteResource] = await trx
.delete(siteResources) .delete(siteResources)
.where( .where(and(eq(siteResources.siteResourceId, siteResourceId)))
and(
eq(siteResources.siteResourceId, siteResourceId),
eq(siteResources.siteId, siteId),
eq(siteResources.orgId, orgId)
)
)
.returning(); .returning();
const [newt] = await trx const [newt] = await trx
.select() .select()
.from(newts) .from(newts)
.where(eq(newts.siteId, site.siteId)) .where(eq(newts.siteId, removedSiteResource.siteId))
.limit(1); .limit(1);
if (!newt) { if (!newt) {
@@ -113,7 +89,7 @@ export async function deleteSiteResource(
}); });
logger.info( logger.info(
`Deleted site resource ${siteResourceId} for site ${siteId}` `Deleted site resource ${siteResourceId}`
); );
return response(res, { return response(res, {

View File

@@ -63,7 +63,7 @@ export type GetSiteResourceResponse = NonNullable<
registry.registerPath({ registry.registerPath({
method: "get", method: "get",
path: "/org/{orgId}/site/{siteId}/resource/{siteResourceId}", path: "/site-resource/{siteResourceId}",
description: "Get a specific site resource by siteResourceId.", description: "Get a specific site resource by siteResourceId.",
tags: [OpenAPITags.Client, OpenAPITags.Org], tags: [OpenAPITags.Client, OpenAPITags.Org],
request: { request: {

View File

@@ -32,14 +32,13 @@ import {
} from "@server/lib/rebuildClientAssociations"; } from "@server/lib/rebuildClientAssociations";
const updateSiteResourceParamsSchema = z.strictObject({ const updateSiteResourceParamsSchema = z.strictObject({
siteResourceId: z.string().transform(Number).pipe(z.int().positive()), siteResourceId: z.string().transform(Number).pipe(z.int().positive())
siteId: z.string().transform(Number).pipe(z.int().positive()),
orgId: z.string()
}); });
const updateSiteResourceSchema = z const updateSiteResourceSchema = z
.strictObject({ .strictObject({
name: z.string().min(1).max(255).optional(), name: z.string().min(1).max(255).optional(),
siteId: z.int(),
// mode: z.enum(["host", "cidr", "port"]).optional(), // mode: z.enum(["host", "cidr", "port"]).optional(),
mode: z.enum(["host", "cidr"]).optional(), mode: z.enum(["host", "cidr"]).optional(),
// protocol: z.enum(["tcp", "udp"]).nullish(), // protocol: z.enum(["tcp", "udp"]).nullish(),
@@ -78,7 +77,10 @@ const updateSiteResourceSchema = z
const domainRegex = const domainRegex =
/^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/; /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
const isValidDomain = domainRegex.test(data.destination); const isValidDomain = domainRegex.test(data.destination);
const isValidAlias = data.alias !== undefined && data.alias !== null && data.alias.trim() !== ""; const isValidAlias =
data.alias !== undefined &&
data.alias !== null &&
data.alias.trim() !== "";
return isValidDomain && isValidAlias; // require the alias to be set in the case of domain return isValidDomain && isValidAlias; // require the alias to be set in the case of domain
} }
@@ -111,7 +113,7 @@ export type UpdateSiteResourceResponse = SiteResource;
registry.registerPath({ registry.registerPath({
method: "post", method: "post",
path: "/org/{orgId}/site/{siteId}/resource/{siteResourceId}", path: "/site-resource/{siteResourceId}",
description: "Update a site resource.", description: "Update a site resource.",
tags: [OpenAPITags.Client, OpenAPITags.Org], tags: [OpenAPITags.Client, OpenAPITags.Org],
request: { request: {
@@ -155,9 +157,10 @@ export async function updateSiteResource(
); );
} }
const { siteResourceId, siteId, orgId } = parsedParams.data; const { siteResourceId } = parsedParams.data;
const { const {
name, name,
siteId, // because it can change
mode, mode,
destination, destination,
alias, alias,
@@ -173,7 +176,7 @@ export async function updateSiteResource(
const [site] = await db const [site] = await db
.select() .select()
.from(sites) .from(sites)
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))) .where(eq(sites.siteId, siteId))
.limit(1); .limit(1);
if (!site) { if (!site) {
@@ -184,13 +187,7 @@ export async function updateSiteResource(
const [existingSiteResource] = await db const [existingSiteResource] = await db
.select() .select()
.from(siteResources) .from(siteResources)
.where( .where(and(eq(siteResources.siteResourceId, siteResourceId)))
and(
eq(siteResources.siteResourceId, siteResourceId),
eq(siteResources.siteId, siteId),
eq(siteResources.orgId, orgId)
)
)
.limit(1); .limit(1);
if (!existingSiteResource) { if (!existingSiteResource) {
@@ -199,6 +196,27 @@ export async function updateSiteResource(
); );
} }
let existingSite = site;
let siteChanged = false;
if (existingSiteResource.siteId !== siteId) {
siteChanged = true;
// get the existing site
[existingSite] = await db
.select()
.from(sites)
.where(eq(sites.siteId, existingSiteResource.siteId))
.limit(1);
if (!existingSite) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Existing site not found"
)
);
}
}
// make sure the alias is unique within the org if provided // make sure the alias is unique within the org if provided
if (alias) { if (alias) {
const [conflict] = await db const [conflict] = await db
@@ -206,7 +224,7 @@ export async function updateSiteResource(
.from(siteResources) .from(siteResources)
.where( .where(
and( and(
eq(siteResources.orgId, orgId), eq(siteResources.orgId, existingSiteResource.orgId),
eq(siteResources.alias, alias.trim()), eq(siteResources.alias, alias.trim()),
ne(siteResources.siteResourceId, siteResourceId) // exclude self ne(siteResources.siteResourceId, siteResourceId) // exclude self
) )
@@ -225,100 +243,220 @@ export async function updateSiteResource(
let updatedSiteResource: SiteResource | undefined; let updatedSiteResource: SiteResource | undefined;
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
// Update the site resource // if the site is changed we need to delete and recreate the resource to avoid complications with the rebuild function otherwise we can just update in place
[updatedSiteResource] = await trx if (siteChanged) {
.update(siteResources) // delete the existing site resource
.set({
name: name,
mode: mode,
destination: destination,
enabled: enabled,
alias: alias && alias.trim() ? alias : null,
tcpPortRangeString: tcpPortRangeString,
udpPortRangeString: udpPortRangeString,
disableIcmp: disableIcmp
})
.where(
and(
eq(siteResources.siteResourceId, siteResourceId),
eq(siteResources.siteId, siteId),
eq(siteResources.orgId, orgId)
)
)
.returning();
//////////////////// update the associations ////////////////////
await trx
.delete(clientSiteResources)
.where(eq(clientSiteResources.siteResourceId, siteResourceId));
if (clientIds.length > 0) {
await trx.insert(clientSiteResources).values(
clientIds.map((clientId) => ({
clientId,
siteResourceId
}))
);
}
await trx
.delete(userSiteResources)
.where(eq(userSiteResources.siteResourceId, siteResourceId));
if (userIds.length > 0) {
await trx await trx
.insert(userSiteResources) .delete(siteResources)
.values( .where(
userIds.map((userId) => ({ userId, siteResourceId })) and(eq(siteResources.siteResourceId, siteResourceId))
); );
}
// Get all admin role IDs for this org to exclude from deletion await rebuildClientAssociationsFromSiteResource(
const adminRoles = await trx existingSiteResource,
.select() trx
.from(roles)
.where(
and(
eq(roles.isAdmin, true),
eq(roles.orgId, updatedSiteResource.orgId)
)
); );
const adminRoleIds = adminRoles.map((role) => role.roleId);
if (adminRoleIds.length > 0) { // create the new site resource from the removed one - the ID should stay the same
await trx.delete(roleSiteResources).where( const [insertedSiteResource] = await trx
and( .insert(siteResources)
eq(roleSiteResources.siteResourceId, siteResourceId), .values({
ne(roleSiteResources.roleId, adminRoleIds[0]) // delete all but the admin role ...existingSiteResource,
})
.returning();
// wait some time to allow for messages to be handled
await new Promise((resolve) => setTimeout(resolve, 750));
[updatedSiteResource] = await trx
.update(siteResources)
.set({
name: name,
siteId: siteId,
mode: mode,
destination: destination,
enabled: enabled,
alias: alias && alias.trim() ? alias : null,
tcpPortRangeString: tcpPortRangeString,
udpPortRangeString: udpPortRangeString,
disableIcmp: disableIcmp
})
.where(
and(
eq(
siteResources.siteResourceId,
insertedSiteResource.siteResourceId
)
)
) )
.returning();
if (!updatedSiteResource) {
throw new Error(
"Failed to create updated site resource after site change"
);
}
//////////////////// update the associations ////////////////////
const [adminRole] = await trx
.select()
.from(roles)
.where(
and(
eq(roles.isAdmin, true),
eq(roles.orgId, updatedSiteResource.orgId)
)
)
.limit(1);
if (!adminRole) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Admin role not found`
)
);
}
await trx.insert(roleSiteResources).values({
roleId: adminRole.roleId,
siteResourceId: updatedSiteResource.siteResourceId
});
if (roleIds.length > 0) {
await trx.insert(roleSiteResources).values(
roleIds.map((roleId) => ({
roleId,
siteResourceId: updatedSiteResource!.siteResourceId
}))
);
}
if (userIds.length > 0) {
await trx.insert(userSiteResources).values(
userIds.map((userId) => ({
userId,
siteResourceId: updatedSiteResource!.siteResourceId
}))
);
}
if (clientIds.length > 0) {
await trx.insert(clientSiteResources).values(
clientIds.map((clientId) => ({
clientId,
siteResourceId: updatedSiteResource!.siteResourceId
}))
);
}
await rebuildClientAssociationsFromSiteResource(
updatedSiteResource,
trx
); );
} else { } else {
await trx // Update the site resource
.delete(roleSiteResources) [updatedSiteResource] = await trx
.update(siteResources)
.set({
name: name,
siteId: siteId,
mode: mode,
destination: destination,
enabled: enabled,
alias: alias && alias.trim() ? alias : null,
tcpPortRangeString: tcpPortRangeString,
udpPortRangeString: udpPortRangeString,
disableIcmp: disableIcmp
})
.where( .where(
eq(roleSiteResources.siteResourceId, siteResourceId) and(eq(siteResources.siteResourceId, siteResourceId))
); )
} .returning();
//////////////////// update the associations ////////////////////
if (roleIds.length > 0) {
await trx await trx
.insert(roleSiteResources) .delete(clientSiteResources)
.values( .where(
roleIds.map((roleId) => ({ roleId, siteResourceId })) eq(clientSiteResources.siteResourceId, siteResourceId)
); );
if (clientIds.length > 0) {
await trx.insert(clientSiteResources).values(
clientIds.map((clientId) => ({
clientId,
siteResourceId
}))
);
}
await trx
.delete(userSiteResources)
.where(
eq(userSiteResources.siteResourceId, siteResourceId)
);
if (userIds.length > 0) {
await trx.insert(userSiteResources).values(
userIds.map((userId) => ({
userId,
siteResourceId
}))
);
}
// Get all admin role IDs for this org to exclude from deletion
const adminRoles = await trx
.select()
.from(roles)
.where(
and(
eq(roles.isAdmin, true),
eq(roles.orgId, updatedSiteResource.orgId)
)
);
const adminRoleIds = adminRoles.map((role) => role.roleId);
if (adminRoleIds.length > 0) {
await trx.delete(roleSiteResources).where(
and(
eq(
roleSiteResources.siteResourceId,
siteResourceId
),
ne(roleSiteResources.roleId, adminRoleIds[0]) // delete all but the admin role
)
);
} else {
await trx
.delete(roleSiteResources)
.where(
eq(roleSiteResources.siteResourceId, siteResourceId)
);
}
if (roleIds.length > 0) {
await trx.insert(roleSiteResources).values(
roleIds.map((roleId) => ({
roleId,
siteResourceId
}))
);
}
logger.info(
`Updated site resource ${siteResourceId} for site ${siteId}`
);
await handleMessagingForUpdatedSiteResource(
existingSiteResource,
updatedSiteResource,
{ siteId: site.siteId, orgId: site.orgId },
trx
);
} }
logger.info(
`Updated site resource ${siteResourceId} for site ${siteId}`
);
await handleMessagingForUpdatedSiteResource(
existingSiteResource,
updatedSiteResource!,
{ siteId: site.siteId, orgId: site.orgId },
trx
);
}); });
return response(res, { return response(res, {
@@ -345,6 +483,10 @@ export async function handleMessagingForUpdatedSiteResource(
site: { siteId: number; orgId: string }, site: { siteId: number; orgId: string },
trx: Transaction trx: Transaction
) { ) {
logger.debug("handleMessagingForUpdatedSiteResource: existingSiteResource is: ", existingSiteResource);
logger.debug("handleMessagingForUpdatedSiteResource: updatedSiteResource is: ", updatedSiteResource);
const { mergedAllClients } = const { mergedAllClients } =
await rebuildClientAssociationsFromSiteResource( await rebuildClientAssociationsFromSiteResource(
existingSiteResource || updatedSiteResource, // we want to rebuild based on the existing resource then we will apply the change to the destination below existingSiteResource || updatedSiteResource, // we want to rebuild based on the existing resource then we will apply the change to the destination below

View File

@@ -4,124 +4,161 @@
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
}
:root { :root {
--background: oklch(0.99 0 0); --radius: 0.75rem;
--foreground: oklch(0.145 0 0); --background: oklch(0.985 0 0);
--card: oklch(1 0 0); --foreground: oklch(0.141 0.005 285.823);
--card-foreground: oklch(0.145 0 0); --card: oklch(1 0 0);
--popover: oklch(1 0 0); --card-foreground: oklch(0.141 0.005 285.823);
--popover-foreground: oklch(0.145 0 0); --popover: oklch(1 0 0);
--primary: oklch(0.6734 0.195 41.36); --popover-foreground: oklch(0.141 0.005 285.823);
--primary-foreground: oklch(0.98 0.016 73.684); --primary: oklch(0.6717 0.1946 41.93);
--secondary: oklch(0.967 0.001 286.375); --primary-foreground: oklch(0.98 0.016 73.684);
--secondary-foreground: oklch(0.21 0.006 285.885); --secondary: oklch(0.967 0.001 286.375);
--muted: oklch(0.97 0 0); --secondary-foreground: oklch(0.21 0.006 285.885);
--muted-foreground: oklch(0.556 0 0); --muted: oklch(0.967 0.001 286.375);
--accent: oklch(0.97 0 0); --muted-foreground: oklch(0.552 0.016 285.938);
--accent-foreground: oklch(0.205 0 0); --accent: oklch(0.967 0.001 286.375);
--destructive: oklch(0.58 0.22 27); --accent-foreground: oklch(0.21 0.006 285.885);
--border: oklch(0.922 0 0); --destructive: oklch(0.577 0.245 27.325);
--input: oklch(0.922 0 0); --border: oklch(0.91 0.004 286.32);
--ring: oklch(0.6734 0.195 41.36); --input: oklch(0.92 0.004 286.32);
--chart-1: oklch(0.837 0.128 66.29); --ring: oklch(0.705 0.213 47.604);
--chart-2: oklch(0.705 0.213 47.604); --chart-1: oklch(0.646 0.222 41.116);
--chart-3: oklch(0.646 0.222 41.116); --chart-2: oklch(0.6 0.118 184.704);
--chart-4: oklch(0.553 0.195 38.402); --chart-3: oklch(0.398 0.07 227.392);
--chart-5: oklch(0.47 0.157 37.304); --chart-4: oklch(0.828 0.189 84.429);
--radius: 0.75rem; --chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0); --sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0); --sidebar-foreground: oklch(0.141 0.005 285.823);
--sidebar-primary: oklch(0.646 0.222 41.116); --sidebar-primary: oklch(0.705 0.213 47.604);
--sidebar-primary-foreground: oklch(0.98 0.016 73.684); --sidebar-primary-foreground: oklch(0.98 0.016 73.684);
--sidebar-accent: oklch(0.97 0 0); --sidebar-accent: oklch(0.967 0.001 286.375);
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.922 0 0); --sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.708 0 0); --sidebar-ring: oklch(0.705 0.213 47.604);
} }
.dark { .dark {
--background: oklch(0.160 0 0); --background: oklch(0.17 0.006 285.885);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0); --card: oklch(0.21 0.006 285.885);
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0); --popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.6734 0.195 41.36); --primary: oklch(0.6717 0.1946 41.93);
--primary-foreground: oklch(0.98 0.016 73.684); --primary-foreground: oklch(0.98 0.016 73.684);
--secondary: oklch(0.274 0.006 286.033); --secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0); --muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.708 0 0); --muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.371 0 0); --accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.5382 0.1949 22.216);
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 13%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 15%);
--ring: oklch(0.6734 0.195 41.36); --ring: oklch(0.646 0.222 41.116);
--chart-1: oklch(0.837 0.128 66.29); --chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.705 0.213 47.604); --chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.646 0.222 41.116); --chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.553 0.195 38.402); --chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.47 0.157 37.304); --chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0); --sidebar: oklch(0.21 0.006 285.885);
--sidebar-foreground: oklch(0.985 0 0); --sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.705 0.213 47.604); --sidebar-primary: oklch(0.646 0.222 41.116);
--sidebar-primary-foreground: oklch(0.98 0.016 73.684); --sidebar-primary-foreground: oklch(0.98 0.016 73.684);
--sidebar-accent: oklch(0.269 0 0); --sidebar-accent: oklch(0.274 0.006 286.033);
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0); --sidebar-ring: oklch(0.646 0.222 41.116);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--shadow-2xs: 0 1px 1px rgba(0, 0, 0, 0.03);
--inset-shadow-2xs: inset 0 1px 1px rgba(0, 0, 1, 0.03);
} }
@layer base { @layer base {
* { *,
@apply border-border outline-ring/50; ::after,
} ::before,
body { ::backdrop,
@apply bg-background text-foreground; ::file-selector-button {
} border-color: var(--color-gray-200, currentcolor);
}
} }
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
@layer base {
:root {
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
}
.dark {
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
}
}
p {
word-break: keep-all;
white-space: normal;
}
#nprogress .bar {
background: var(--color-primary) !important;
}

View File

@@ -96,11 +96,6 @@ export const orgNavSections = (): SidebarNavSection[] => [
title: "sidebarDomains", title: "sidebarDomains",
href: "/{orgId}/settings/domains", href: "/{orgId}/settings/domains",
icon: <Globe className="size-4 flex-none" /> icon: <Globe className="size-4 flex-none" />
},
{
title: "sidebarBluePrints",
href: "/{orgId}/settings/blueprints",
icon: <ReceiptText className="size-4 flex-none" />
} }
] ]
}, },
@@ -200,6 +195,17 @@ export const orgNavSections = (): SidebarNavSection[] => [
href: "/{orgId}/settings/api-keys", href: "/{orgId}/settings/api-keys",
icon: <KeyRound className="size-4 flex-none" /> icon: <KeyRound className="size-4 flex-none" />
}, },
{
title: "sidebarBluePrints",
href: "/{orgId}/settings/blueprints",
icon: <ReceiptText className="size-4 flex-none" />
},
{
title: "sidebarSettings",
href: "/{orgId}/settings/general",
icon: <Settings className="size-4 flex-none" />
},
...(build == "saas" ...(build == "saas"
? [ ? [
{ {
@@ -217,12 +223,7 @@ export const orgNavSections = (): SidebarNavSection[] => [
icon: <TicketCheck className="size-4 flex-none" /> icon: <TicketCheck className="size-4 flex-none" />
} }
] ]
: []), : [])
{
title: "sidebarSettings",
href: "/{orgId}/settings/general",
icon: <Settings className="size-4 flex-none" />
}
] ]
} }
]; ];

View File

@@ -6,7 +6,7 @@ import { UsersDataTable } from "@app/components/AdminUsersDataTable";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react"; import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState, useEffect } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api"; import { formatAxiosError } from "@app/lib/api";
@@ -72,6 +72,11 @@ export default function UsersTable({ users }: Props) {
useState<AdminGeneratePasswordResetCodeResponse | null>(null); useState<AdminGeneratePasswordResetCodeResponse | null>(null);
const [isGeneratingCode, setIsGeneratingCode] = useState(false); const [isGeneratingCode, setIsGeneratingCode] = useState(false);
// Update local state when props change (e.g., after refresh)
useEffect(() => {
setRows(users);
}, [users]);
const refreshData = async () => { const refreshData = async () => {
console.log("Data refreshed"); console.log("Data refreshed");
setIsRefreshing(true); setIsRefreshing(true);

View File

@@ -52,8 +52,14 @@ export default function BlueprintDetailsForm({
<Form {...form}> <Form {...form}>
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<Alert> <Alert>
<AlertDescription> <AlertDescription className="space-y-2">
<InfoSections cols={3}> <InfoSections cols={4}>
<InfoSection>
<InfoSectionTitle>{t("name")}</InfoSectionTitle>
<InfoSectionContent>
{blueprint.name}
</InfoSectionContent>
</InfoSection>
<InfoSection> <InfoSection>
<InfoSectionTitle> <InfoSectionTitle>
{t("status")} {t("status")}
@@ -121,6 +127,8 @@ export default function BlueprintDetailsForm({
</time> </time>
</InfoSectionContent> </InfoSectionContent>
</InfoSection> </InfoSection>
</InfoSections>
<InfoSections cols={1}>
{blueprint.message && ( {blueprint.message && (
<InfoSection> <InfoSection>
<InfoSectionTitle> <InfoSectionTitle>
@@ -138,60 +146,39 @@ export default function BlueprintDetailsForm({
</Alert> </Alert>
<SettingsContainer> <SettingsContainer>
<SettingsSection> <SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("blueprintInfo")}
</SettingsSectionTitle>
</SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<SettingsSectionForm className="max-w-2xl"> <FormField
<FormField control={form.control}
control={form.control} name="contents"
name="name" render={({ field }) => (
render={({ field }) => ( <FormItem>
<FormItem> <FormLabel>
<FormLabel>{t("name")}</FormLabel> {t("parsedContents")}
<FormControl> </FormLabel>
<Input {...field} /> <FormControl>
</FormControl> <div
<FormMessage /> className={cn(
</FormItem> "resize-y h-64 min-h-128 overflow-y-auto overflow-x-clip max-w-full rounded-md"
)} )}
/> >
<Editor
<FormField className="w-full h-full max-w-full"
control={form.control} language="yaml"
name="contents" theme="vs-dark"
render={({ field }) => ( options={{
<FormItem> minimap: {
<FormLabel> enabled: false
{t("parsedContents")} },
</FormLabel> readOnly: true
<FormControl> }}
<div {...field}
className={cn( />
"resize-y h-64 min-h-64 overflow-y-auto overflow-x-clip max-w-full rounded-md" </div>
)} </FormControl>
> <FormMessage />
<Editor </FormItem>
className="w-full h-full max-w-full" )}
language="yaml" />
theme="vs-dark"
options={{
minimap: {
enabled: false
},
readOnly: true
}}
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsSectionForm>
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>
</SettingsContainer> </SettingsContainer>

View File

@@ -32,35 +32,6 @@ export default function BlueprintsTable({ blueprints, orgId }: Props) {
const router = useRouter(); const router = useRouter();
const columns: ExtendedColumnDef<BlueprintRow>[] = [ const columns: ExtendedColumnDef<BlueprintRow>[] = [
{
accessorKey: "createdAt",
friendlyName: t("appliedAt"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("appliedAt")}
<ArrowUpDown className="ml-2 size-4" />
</Button>
);
},
cell: ({ row }) => {
return (
<time
className="text-muted-foreground"
dateTime={row.original.createdAt.toString()}
>
{new Date(
row.original.createdAt * 1000
).toLocaleString()}
</time>
);
}
},
{ {
accessorKey: "name", accessorKey: "name",
enableHiding: false, enableHiding: false,
@@ -79,7 +50,32 @@ export default function BlueprintsTable({ blueprints, orgId }: Props) {
); );
} }
}, },
{
accessorKey: "createdAt",
friendlyName: t("appliedAt"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("appliedAt")}
<ArrowUpDown className="ml-2 size-4" />
</Button>
);
},
cell: ({ row }) => {
return (
<time dateTime={row.original.createdAt.toString()}>
{new Date(
row.original.createdAt * 1000
).toLocaleString()}
</time>
);
}
},
{ {
accessorKey: "source", accessorKey: "source",
friendlyName: t("source"), friendlyName: t("source"),
@@ -104,7 +100,7 @@ export default function BlueprintsTable({ blueprints, orgId }: Props) {
<Badge variant="secondary"> <Badge variant="secondary">
<span className="inline-flex items-center gap-1 "> <span className="inline-flex items-center gap-1 ">
API API
<Webhook className="size-4 flex-none" /> <Webhook className="w-3 h-3 flex-none" />
</span> </span>
</Badge> </Badge>
); );
@@ -114,7 +110,7 @@ export default function BlueprintsTable({ blueprints, orgId }: Props) {
<Badge variant="secondary"> <Badge variant="secondary">
<span className="inline-flex items-center gap-1 "> <span className="inline-flex items-center gap-1 ">
Newt CLI Newt CLI
<Terminal className="size-4 flex-none" /> <Terminal className="w-3 h-3 flex-none" />
</span> </span>
</Badge> </Badge>
); );
@@ -174,7 +170,7 @@ export default function BlueprintsTable({ blueprints, orgId }: Props) {
href={`/${orgId}/settings/blueprints/${row.original.blueprintId}`} href={`/${orgId}/settings/blueprints/${row.original.blueprintId}`}
> >
<Button variant="outline" className="items-center"> <Button variant="outline" className="items-center">
View Details {t("blueprintViewDetails")}
<ArrowRight className="ml-2 w-4 h-4" /> <ArrowRight className="ml-2 w-4 h-4" />
</Button> </Button>
</Link> </Link>

View File

@@ -100,7 +100,7 @@ export default function ClientResourcesTable({
) => { ) => {
try { try {
await api await api
.delete(`/org/${orgId}/site/${siteId}/resource/${resourceId}`) .delete(`/site-resource/${resourceId}`)
.then(() => { .then(() => {
startTransition(() => { startTransition(() => {
router.refresh(); router.refresh();
@@ -327,6 +327,7 @@ export default function ClientResourcesTable({
setOpen={setIsEditDialogOpen} setOpen={setIsEditDialogOpen}
resource={editingResource} resource={editingResource}
orgId={orgId} orgId={orgId}
sites={sites}
onSuccess={() => { onSuccess={() => {
router.refresh(); router.refresh();
setEditingResource(null); setEditingResource(null);

View File

@@ -127,7 +127,7 @@ export default function CreateBlueprintForm({
</SettingsSectionTitle> </SettingsSectionTitle>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
<SettingsSectionForm className="max-w-2xl"> <SettingsSectionForm>
<FormField <FormField
control={form.control} control={form.control}
name="name" name="name"
@@ -141,44 +141,40 @@ export default function CreateBlueprintForm({
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="contents"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("contents")}
</FormLabel>
<FormDescription>
{t(
"blueprintContentsDescription"
)}
</FormDescription>
<FormControl>
<div
className={cn(
"resize-y h-64 min-h-64 overflow-y-auto overflow-x-clip max-w-full rounded-md"
)}
>
<Editor
className="w-full h-full max-w-full"
language="yaml"
theme="vs-dark"
options={{
minimap: {
enabled: false
}
}}
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsSectionForm> </SettingsSectionForm>
<FormField
control={form.control}
name="contents"
render={({ field }) => (
<FormItem>
<FormLabel>{t("contents")}</FormLabel>
<FormDescription>
{t("blueprintContentsDescription")}
</FormDescription>
<FormControl>
<div
className={cn(
"resize-y h-64 min-h-128 overflow-y-auto overflow-x-clip max-w-full rounded-md"
)}
>
<Editor
className="w-full h-full max-w-full"
language="yaml"
theme="vs-dark"
options={{
minimap: {
enabled: false
}
}}
{...field}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsSectionBody> </SettingsSectionBody>
</SettingsSection> </SettingsSection>

View File

@@ -395,9 +395,10 @@ export default function CreateInternalResourceDialog({
} }
const response = await api.put<AxiosResponse<any>>( const response = await api.put<AxiosResponse<any>>(
`/org/${orgId}/site/${data.siteId}/resource`, `/org/${orgId}/site-resource`,
{ {
name: data.name, name: data.name,
siteId: data.siteId,
mode: data.mode, mode: data.mode,
// protocol: data.protocol, // protocol: data.protocol,
// proxyPort: data.mode === "port" ? data.proxyPort : undefined, // proxyPort: data.mode === "port" ? data.proxyPort : undefined,
@@ -548,7 +549,7 @@ export default function CreateInternalResourceDialog({
<FormItem className="flex flex-col"> <FormItem className="flex flex-col">
<FormLabel> <FormLabel>
{t( {t(
"createInternalResourceDialogSite" "site"
)} )}
</FormLabel> </FormLabel>
<Popover> <Popover>
@@ -572,7 +573,7 @@ export default function CreateInternalResourceDialog({
field.value field.value
)?.name )?.name
: t( : t(
"createInternalResourceDialogSelectSite" "selectSite"
)} )}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
@@ -582,13 +583,13 @@ export default function CreateInternalResourceDialog({
<Command> <Command>
<CommandInput <CommandInput
placeholder={t( placeholder={t(
"createInternalResourceDialogSearchSites" "searchSites"
)} )}
/> />
<CommandList> <CommandList>
<CommandEmpty> <CommandEmpty>
{t( {t(
"createInternalResourceDialogNoSitesFound" "noSitesFound"
)} )}
</CommandEmpty> </CommandEmpty>
<CommandGroup> <CommandGroup>

View File

@@ -41,6 +41,22 @@ import { Tag, TagInput } from "@app/components/tags/tag-input";
import { UserType } from "@server/types/UserTypes"; import { UserType } from "@server/types/UserTypes";
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
import { orgQueries, resourceQueries } from "@app/lib/queries"; import { orgQueries, resourceQueries } from "@app/lib/queries";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@app/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { cn } from "@app/lib/cn";
import { ListSitesResponse } from "@server/routers/site";
import { Check, ChevronsUpDown } from "lucide-react";
// import { InfoPopup } from "@app/components/ui/info-popup"; // import { InfoPopup } from "@app/components/ui/info-popup";
// Helper to validate port range string format // Helper to validate port range string format
@@ -118,6 +134,8 @@ const getPortStringFromMode = (mode: PortMode, customValue: string): string | un
return customValue; return customValue;
}; };
type Site = ListSitesResponse["sites"][0];
type InternalResourceData = { type InternalResourceData = {
id: number; id: number;
name: string; name: string;
@@ -141,6 +159,7 @@ type EditInternalResourceDialogProps = {
setOpen: (val: boolean) => void; setOpen: (val: boolean) => void;
resource: InternalResourceData; resource: InternalResourceData;
orgId: string; orgId: string;
sites: Site[];
onSuccess?: () => void; onSuccess?: () => void;
}; };
@@ -149,6 +168,7 @@ export default function EditInternalResourceDialog({
setOpen, setOpen,
resource, resource,
orgId, orgId,
sites,
onSuccess onSuccess
}: EditInternalResourceDialogProps) { }: EditInternalResourceDialogProps) {
const t = useTranslations(); const t = useTranslations();
@@ -161,6 +181,7 @@ export default function EditInternalResourceDialog({
.string() .string()
.min(1, t("editInternalResourceDialogNameRequired")) .min(1, t("editInternalResourceDialogNameRequired"))
.max(255, t("editInternalResourceDialogNameMaxLength")), .max(255, t("editInternalResourceDialogNameMaxLength")),
siteId: z.number().int().positive(),
mode: z.enum(["host", "cidr", "port"]), mode: z.enum(["host", "cidr", "port"]),
// protocol: z.enum(["tcp", "udp"]).nullish(), // protocol: z.enum(["tcp", "udp"]).nullish(),
// proxyPort: z.int().positive().min(1, t("editInternalResourceDialogProxyPortMin")).max(65535, t("editInternalResourceDialogProxyPortMax")).nullish(), // proxyPort: z.int().positive().min(1, t("editInternalResourceDialogProxyPortMin")).max(65535, t("editInternalResourceDialogProxyPortMax")).nullish(),
@@ -349,10 +370,15 @@ export default function EditInternalResourceDialog({
: "" : ""
); );
const availableSites = sites.filter(
(site) => site.type === "newt" && site.subnet
);
const form = useForm<FormData>({ const form = useForm<FormData>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
name: resource.name, name: resource.name,
siteId: resource.siteId,
mode: resource.mode || "host", mode: resource.mode || "host",
// protocol: (resource.protocol as "tcp" | "udp" | null | undefined) ?? undefined, // protocol: (resource.protocol as "tcp" | "udp" | null | undefined) ?? undefined,
// proxyPort: resource.proxyPort ?? undefined, // proxyPort: resource.proxyPort ?? undefined,
@@ -421,9 +447,10 @@ export default function EditInternalResourceDialog({
// Update the site resource // Update the site resource
await api.post( await api.post(
`/org/${orgId}/site/${resource.siteId}/resource/${resource.id}`, `/site-resource/${resource.id}`,
{ {
name: data.name, name: data.name,
siteId: data.siteId,
mode: data.mode, mode: data.mode,
// protocol: data.mode === "port" ? data.protocol : null, // protocol: data.mode === "port" ? data.protocol : null,
// proxyPort: data.mode === "port" ? data.proxyPort : null, // proxyPort: data.mode === "port" ? data.proxyPort : null,
@@ -504,6 +531,7 @@ export default function EditInternalResourceDialog({
if (resourceChanged) { if (resourceChanged) {
form.reset({ form.reset({
name: resource.name, name: resource.name,
siteId: resource.siteId,
mode: resource.mode || "host", mode: resource.mode || "host",
destination: resource.destination || "", destination: resource.destination || "",
alias: resource.alias ?? null, alias: resource.alias ?? null,
@@ -559,6 +587,7 @@ export default function EditInternalResourceDialog({
// reset only on close // reset only on close
form.reset({ form.reset({
name: resource.name, name: resource.name,
siteId: resource.siteId,
mode: resource.mode || "host", mode: resource.mode || "host",
// protocol: (resource.protocol as "tcp" | "udp" | null | undefined) ?? undefined, // protocol: (resource.protocol as "tcp" | "udp" | null | undefined) ?? undefined,
// proxyPort: resource.proxyPort ?? undefined, // proxyPort: resource.proxyPort ?? undefined,
@@ -636,6 +665,99 @@ export default function EditInternalResourceDialog({
)} )}
/> />
<FormField
control={form.control}
name="siteId"
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>
{t(
"site"
)}
</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
!field.value &&
"text-muted-foreground"
)}
>
{field.value
? availableSites.find(
(
site
) =>
site.siteId ===
field.value
)?.name
: t(
"selectSite"
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput
placeholder={t(
"searchSites"
)}
/>
<CommandList>
<CommandEmpty>
{t(
"noSitesFound"
)}
</CommandEmpty>
<CommandGroup>
{availableSites.map(
(
site
) => (
<CommandItem
key={
site.siteId
}
value={
site.name
}
onSelect={() => {
field.onChange(
site.siteId
);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
field.value ===
site.siteId
? "opacity-100"
: "opacity-0"
)}
/>
{
site.name
}
</CommandItem>
)
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="mode" name="mode"

View File

@@ -123,7 +123,7 @@ export function LayoutSidebar({
<Link <Link
href="/admin" href="/admin"
className={cn( className={cn(
"flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-secondary/50 dark:hover:bg-secondary/20 rounded-md", "flex items-center transition-colors text-muted-foreground hover:text-foreground text-sm w-full hover:bg-secondary/80 dark:hover:bg-secondary/50 rounded-md",
isSidebarCollapsed isSidebarCollapsed
? "px-2 py-2 justify-center" ? "px-2 py-2 justify-center"
: "px-3 py-1.5" : "px-3 py-1.5"

View File

@@ -199,7 +199,7 @@ function ProductUpdatesListPopup({
{t("productUpdateWhatsNew")} {t("productUpdateWhatsNew")}
</p> </p>
<div className="p-1 cursor-pointer"> <div className="p-1 cursor-pointer">
<ChevronRightIcon className="size-4 flex-none" /> <ChevronRightIcon className="size-4 flex-none opacity-50" />
</div> </div>
</div> </div>
</div> </div>

View File

@@ -112,7 +112,7 @@ function CollapsibleNavItem({
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<button <button
className={cn( className={cn(
"flex items-center w-full rounded transition-colors hover:bg-secondary/50 dark:hover:bg-secondary/20 rounded-md", "flex items-center w-full rounded transition-colors hover:bg-secondary/80 dark:hover:bg-secondary/50 rounded-md",
level === 0 ? "p-3 py-1.5" : "py-1.5", level === 0 ? "p-3 py-1.5" : "py-1.5",
isChildActive isChildActive
? "text-primary font-medium" ? "text-primary font-medium"
@@ -252,7 +252,7 @@ export function SidebarNav({
<Link <Link
href={isDisabled ? "#" : hydratedHref} href={isDisabled ? "#" : hydratedHref}
className={cn( className={cn(
"flex items-center rounded transition-colors hover:bg-secondary/50 dark:hover:bg-secondary/20 rounded-md", "flex items-center rounded transition-colors hover:bg-secondary/80 dark:hover:bg-secondary/50 rounded-md",
isCollapsed ? "px-2 py-2 justify-center" : "px-3 py-1.5", isCollapsed ? "px-2 py-2 justify-center" : "px-3 py-1.5",
isActive isActive
? "text-primary font-medium" ? "text-primary font-medium"
@@ -338,7 +338,7 @@ export function SidebarNav({
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
className={cn( className={cn(
"flex items-center rounded transition-colors hover:bg-secondary/50 dark:hover:bg-secondary/20 rounded-md px-2 py-2 justify-center w-full", "flex items-center rounded transition-colors hover:bg-secondary/80 dark:hover:bg-secondary/50 rounded-md px-2 py-2 justify-center w-full",
isChildActive isChildActive
? "text-primary font-medium" ? "text-primary font-medium"
: "text-muted-foreground hover:text-foreground", : "text-muted-foreground hover:text-foreground",

View File

@@ -11,7 +11,7 @@ import {
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { ArrowRight, ArrowUpDown, Crown, MoreHorizontal } from "lucide-react"; import { ArrowRight, ArrowUpDown, Crown, MoreHorizontal } from "lucide-react";
import { UsersDataTable } from "@app/components/UsersDataTable"; import { UsersDataTable } from "@app/components/UsersDataTable";
import { useState } from "react"; import { useState, useEffect } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { useOrgContext } from "@app/hooks/useOrgContext"; import { useOrgContext } from "@app/hooks/useOrgContext";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
@@ -54,6 +54,11 @@ export default function UsersTable({ users: u }: UsersTableProps) {
const t = useTranslations(); const t = useTranslations();
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
// Update local state when props change (e.g., after refresh)
useEffect(() => {
setUsers(u);
}, [u]);
const refreshData = async () => { const refreshData = async () => {
console.log("Data refreshed"); console.log("Data refreshed");
setIsRefreshing(true); setIsRefreshing(true);