From fa2a82a63d7f9005a6d9cde8b9360bc9ea1cd44b Mon Sep 17 00:00:00 2001 From: Pallavi Date: Wed, 6 Aug 2025 14:19:42 +0530 Subject: [PATCH 01/16] Basic Implementation for resource move to another Org --- server/routers/external.ts | 15 ++ server/routers/integration.ts | 15 ++ server/routers/resource/index.ts | 1 + server/routers/resource/moveResourceToOrg.ts | 159 +++++++++++++++++++ src/app/api/orgs/route.ts | 49 ++++++ src/components/ResourceInfoBox.tsx | 4 +- 6 files changed, 240 insertions(+), 3 deletions(-) create mode 100644 server/routers/resource/moveResourceToOrg.ts create mode 100644 src/app/api/orgs/route.ts diff --git a/server/routers/external.ts b/server/routers/external.ts index b851eda8e..26dfa72ea 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -504,6 +504,21 @@ authenticated.get( resource.getResourceWhitelist ); +authenticated.post( + `/resource/:resourceId/transfer`, + verifyResourceAccess, + verifyUserHasAction(ActionsEnum.updateResource), + resource.transferResource +); + +authenticated.post( + `/resource/:resourceId/move-org`, + verifyResourceAccess, + verifyUserHasAction(ActionsEnum.updateResource), + resource.moveResourceToOrg +); + + authenticated.post( `/resource/:resourceId/access-token`, verifyResourceAccess, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 69bdbb42b..ab02a66a6 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -414,6 +414,21 @@ authenticated.get( resource.getResourceWhitelist ); +authenticated.post( + `/resource/:resourceId/transfer`, + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.updateResource), + resource.transferResource +); + +authenticated.post( + `/resource/:resourceId/move-org`, + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.updateResource), + resource.moveResourceToOrg +); + + authenticated.post( `/resource/:resourceId/access-token`, verifyApiKeyResourceAccess, diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index 1a2e5c2d5..6cb8aa688 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -22,3 +22,4 @@ export * from "./deleteResourceRule"; export * from "./listResourceRules"; export * from "./updateResourceRule"; export * from "./getUserResources"; +export * from "./moveResourceToOrg"; \ No newline at end of file diff --git a/server/routers/resource/moveResourceToOrg.ts b/server/routers/resource/moveResourceToOrg.ts new file mode 100644 index 000000000..b809503c6 --- /dev/null +++ b/server/routers/resource/moveResourceToOrg.ts @@ -0,0 +1,159 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { resources, orgs, userOrgs, userResources } from "@server/db"; +import { eq, and } 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 { registry, OpenAPITags } from "@server/openApi"; + +const moveResourceParamsSchema = z.object({ + resourceId: z.string().transform(Number).pipe(z.number().int().positive()), +}); + +const moveResourceBodySchema = z.object({ + orgId: z.string().min(1) +}); + + +registry.registerPath({ + method: "post", + path: "/resource/{resourceId}/move-org", + description: "Move a resource to a different org", + tags: [OpenAPITags.Resource], + request: { + params: moveResourceParamsSchema, + body: { + content: { + "application/json": { + schema: moveResourceBodySchema + } + } + } + }, + responses: {} +}); + +export async function moveResourceToOrg( + req: Request, + res: Response, + next: NextFunction +) { + try { + const parsedParams = moveResourceParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next(createHttpError(HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString())); + } + + const parsedBody = moveResourceBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next(createHttpError(HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString())); + } + + const { resourceId } = parsedParams.data; + const { orgId } = parsedBody.data; + const user = req.user; + + if (!user) { + return next(createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")); + } + + // Step 1: Fetch resource + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError(HttpCode.NOT_FOUND, `Resource with ID ${resourceId} not found`) + ); + } + + // Step 2: Set req.userOrgId to source org so permissions are checked correctly + req.userOrgId = resource.orgId; + + // Step 3: Prevent move to same org + if (resource.orgId === orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, `Resource is already in this organization`) + ); + } + + // Step 4: Check if target org exists + const [targetOrg] = await db + .select() + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); + + if (!targetOrg) { + return next( + createHttpError(HttpCode.NOT_FOUND, `Target organization with ID ${orgId} not found`) + ); + } + + // Step 5: Verify user has access to target organization + const [userOrgAccess] = await db + .select() + .from(userOrgs) + .where(and( + eq(userOrgs.userId, user.userId), + eq(userOrgs.orgId, orgId) + )) + .limit(1); + + if (!userOrgAccess) { + return next( + createHttpError(HttpCode.FORBIDDEN, `You don't have access to the target organization`) + ); + } + + // Step 6: Move the resource + const [updatedResource] = await db + .update(resources) + .set({ + orgId + }) + .where(eq(resources.resourceId, resourceId)) + .returning(); + + await db.insert(userResources).values({ + userId: req.user!.userId, + resourceId + }); + + + if (!updatedResource) { + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "Failed to update resource") + ); + } + + // Log the successful move + logger.info(`Resource ${resourceId} moved from org ${resource.orgId} to org ${orgId} by user ${user.userId}`); + + // Step 7: Respond + return response(res, { + data: { + resourceId: updatedResource.resourceId, + oldOrgId: resource.orgId, + newOrgId: orgId, + name: updatedResource.name + }, + success: true, + error: false, + message: "Resource successfully moved to new organization", + status: HttpCode.OK, + }); + } catch (err) { + logger.error("Error moving resource to org:", err); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred while moving the resource") + ); + } +} \ No newline at end of file diff --git a/src/app/api/orgs/route.ts b/src/app/api/orgs/route.ts new file mode 100644 index 000000000..801ea9880 --- /dev/null +++ b/src/app/api/orgs/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from "next/server"; +import { authCookieHeader } from "@/lib/api/cookies"; +import { verifySession } from "@/lib/auth/verifySession"; +import { internal } from "@/lib/api"; +import { ListUserOrgsResponse } from "@server/routers/org"; + +export type Org = { + orgId: string; + name: string; +}; + +export async function GET(req: NextRequest) { + try { + const user = await verifySession(); + if (!user) { + return new Response("Unauthorized", { status: 401 }); + } + + let orgs: ListUserOrgsResponse["orgs"] = []; + + try { + const res = await internal.get(`/user/${user.userId}/orgs`, await authCookieHeader()); + if (res && res.data && res.data.data && res.data.data.orgs) { + orgs = res.data.data.orgs; + } + } catch (apiError) { + console.error("Failed to fetch user orgs from internal API:", apiError); + // Return empty array if API call fails instead of throwing + orgs = []; + } + + // Transform the response to match the expected format + const transformedOrgs = orgs.map(org => ({ + orgId: org.orgId, + name: org.name + })); + + return NextResponse.json(transformedOrgs, { + headers: { + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0' + } + }); + } catch (err) { + console.error("API /api/orgs failed:", err); + return new Response("Internal Server Error", { status: 500 }); + } +} \ No newline at end of file diff --git a/src/components/ResourceInfoBox.tsx b/src/components/ResourceInfoBox.tsx index 8da95ec07..cabfeb28e 100644 --- a/src/components/ResourceInfoBox.tsx +++ b/src/components/ResourceInfoBox.tsx @@ -21,11 +21,9 @@ export default function ResourceInfoBox({ }: ResourceInfoBoxType) { const t = useTranslations(); - const fullUrl = `${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}`; - return ( @@ -132,4 +130,4 @@ export default function ResourceInfoBox({ }: ResourceInfoBoxType) { ); -} +} \ No newline at end of file From d490402e0c11a156973b94a30f52e63b7a707dfd Mon Sep 17 00:00:00 2001 From: Pallavi Date: Thu, 7 Aug 2025 00:07:34 +0530 Subject: [PATCH 02/16] add server component with server-side function call --- .../[niceId]/ResourceInfoWrapper.tsx | 32 ++++++++++++ .../settings/resources/[niceId]/layout.tsx | 3 +- src/app/api/orgs/route.ts | 49 ------------------- 3 files changed, 34 insertions(+), 50 deletions(-) create mode 100644 src/app/[orgId]/settings/resources/[niceId]/ResourceInfoWrapper.tsx delete mode 100644 src/app/api/orgs/route.ts diff --git a/src/app/[orgId]/settings/resources/[niceId]/ResourceInfoWrapper.tsx b/src/app/[orgId]/settings/resources/[niceId]/ResourceInfoWrapper.tsx new file mode 100644 index 000000000..499313d00 --- /dev/null +++ b/src/app/[orgId]/settings/resources/[niceId]/ResourceInfoWrapper.tsx @@ -0,0 +1,32 @@ + +import { verifySession } from "@/lib/auth/verifySession"; +import { authCookieHeader } from "@/lib/api/cookies"; +import { internal } from "@/lib/api"; +import { ListUserOrgsResponse } from "@server/routers/org"; +import ResourceInfoBox from "./ResourceInfoBox"; + + +interface ResourceInfoWrapperType { + resource: any; +} + +export default async function ResourceInfoWrapper({ resource }: ResourceInfoWrapperType) { + const user = await verifySession(); + if (!user) return
Unauthorized
; + + let orgs: ListUserOrgsResponse["orgs"] = []; + + try { + const res = await internal.get(`/user/${user.userId}/orgs`, await authCookieHeader()); + if (res?.data?.data?.orgs) { + const fetchedOrgs: ListUserOrgsResponse["orgs"] = res.data.data.orgs; + orgs = fetchedOrgs.filter( + (org: { orgId: string; name: string }) => org.orgId !== resource.orgId + ); + } + } catch (err) { + console.error("Failed to fetch user orgs:", err); + } + + return ; +} diff --git a/src/app/[orgId]/settings/resources/[niceId]/layout.tsx b/src/app/[orgId]/settings/resources/[niceId]/layout.tsx index 3f8425cee..5fd19967c 100644 --- a/src/app/[orgId]/settings/resources/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/[niceId]/layout.tsx @@ -15,6 +15,7 @@ import { cache } from "react"; import ResourceInfoBox from "../../../../../components/ResourceInfoBox"; import { GetSiteResponse } from "@server/routers/site"; import { getTranslations } from 'next-intl/server'; +import ResourceInfoWrapper from "./ResourceInfoWrapper"; interface ResourceLayoutProps { children: React.ReactNode; @@ -109,7 +110,7 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { authInfo={authInfo} >
- + {children} diff --git a/src/app/api/orgs/route.ts b/src/app/api/orgs/route.ts deleted file mode 100644 index 801ea9880..000000000 --- a/src/app/api/orgs/route.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { authCookieHeader } from "@/lib/api/cookies"; -import { verifySession } from "@/lib/auth/verifySession"; -import { internal } from "@/lib/api"; -import { ListUserOrgsResponse } from "@server/routers/org"; - -export type Org = { - orgId: string; - name: string; -}; - -export async function GET(req: NextRequest) { - try { - const user = await verifySession(); - if (!user) { - return new Response("Unauthorized", { status: 401 }); - } - - let orgs: ListUserOrgsResponse["orgs"] = []; - - try { - const res = await internal.get(`/user/${user.userId}/orgs`, await authCookieHeader()); - if (res && res.data && res.data.data && res.data.data.orgs) { - orgs = res.data.data.orgs; - } - } catch (apiError) { - console.error("Failed to fetch user orgs from internal API:", apiError); - // Return empty array if API call fails instead of throwing - orgs = []; - } - - // Transform the response to match the expected format - const transformedOrgs = orgs.map(org => ({ - orgId: org.orgId, - name: org.name - })); - - return NextResponse.json(transformedOrgs, { - headers: { - 'Cache-Control': 'no-cache, no-store, must-revalidate', - 'Pragma': 'no-cache', - 'Expires': '0' - } - }); - } catch (err) { - console.error("API /api/orgs failed:", err); - return new Response("Internal Server Error", { status: 500 }); - } -} \ No newline at end of file From e27e6b36c26c989716deaea2aaf56337290501da Mon Sep 17 00:00:00 2001 From: Pallavi Date: Thu, 7 Aug 2025 21:45:49 +0530 Subject: [PATCH 03/16] Permission & Access Management with Better UI Warnings --- server/routers/external.ts | 7 + server/routers/integration.ts | 7 + server/routers/resource/getMoveImpact.ts | 279 +++++++++++++++++++ server/routers/resource/index.ts | 3 +- server/routers/resource/moveResourceToOrg.ts | 130 +++++++-- 5 files changed, 396 insertions(+), 30 deletions(-) create mode 100644 server/routers/resource/getMoveImpact.ts diff --git a/server/routers/external.ts b/server/routers/external.ts index 26dfa72ea..4a9e46364 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -518,6 +518,13 @@ authenticated.post( resource.moveResourceToOrg ); +authenticated.get( + `/resource/:resourceId/move-impact`, + verifyResourceAccess, + verifyUserHasAction(ActionsEnum.updateResource), + resource.getMoveImpact +); + authenticated.post( `/resource/:resourceId/access-token`, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index ab02a66a6..ee61d1af1 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -428,6 +428,13 @@ authenticated.post( resource.moveResourceToOrg ); +authenticated.get( + `/resource/:resourceId/move-impact`, + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.updateResource), + resource.getMoveImpact +); + authenticated.post( `/resource/:resourceId/access-token`, diff --git a/server/routers/resource/getMoveImpact.ts b/server/routers/resource/getMoveImpact.ts new file mode 100644 index 000000000..72b1a61bf --- /dev/null +++ b/server/routers/resource/getMoveImpact.ts @@ -0,0 +1,279 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { + resources, + orgs, + userOrgs, + userResources, + roleResources, + roles, + users +} from "@server/db"; +import { eq, and } 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 { registry, OpenAPITags } from "@server/openApi"; + +const getMoveImpactParamsSchema = z.object({ + resourceId: z.string().transform(Number).pipe(z.number().int().positive()), +}); + +const getMoveImpactQuerySchema = z.object({ + targetOrgId: z.string().min(1) +}); + +registry.registerPath({ + method: "get", + path: "/resource/{resourceId}/move-impact", + description: "Get the impact analysis of moving a resource to a different org", + tags: [OpenAPITags.Resource], + request: { + params: getMoveImpactParamsSchema, + query: getMoveImpactQuerySchema + }, + responses: { + 200: { + description: "Move impact analysis", + content: { + "application/json": { + schema: { + type: "object", + properties: { + data: { + type: "object", + properties: { + resourceId: { type: "number" }, + resourceName: { type: "string" }, + currentOrgId: { type: "string" }, + currentOrgName: { type: "string" }, + targetOrgId: { type: "string" }, + targetOrgName: { type: "string" }, + impact: { + type: "object", + properties: { + rolePermissions: { + type: "object", + properties: { + count: { type: "number" }, + details: { + type: "array", + items: { + type: "object", + properties: { + roleId: { type: "number" }, + roleName: { type: "string" } + } + } + } + } + }, + userPermissions: { + type: "object", + properties: { + count: { type: "number" }, + details: { + type: "array", + items: { + type: "object", + properties: { + userId: { type: "string" }, + username: { type: "string" }, + email: { type: "string" } + } + } + } + } + }, + totalImpactedPermissions: { type: "number" }, + authenticationPreserved: { type: "boolean" }, + movingUserRetainsAccess: { type: "boolean" } + } + } + } + } + } + } + } + } + } + } +}); + +export async function getMoveImpact( + req: Request, + res: Response, + next: NextFunction +) { + try { + const parsedParams = getMoveImpactParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next(createHttpError(HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString())); + } + + const parsedQuery = getMoveImpactQuerySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next(createHttpError(HttpCode.BAD_REQUEST, fromError(parsedQuery.error).toString())); + } + + const { resourceId } = parsedParams.data; + const { targetOrgId } = parsedQuery.data; + const user = req.user; + + if (!user) { + return next(createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")); + } + + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError(HttpCode.NOT_FOUND, `Resource with ID ${resourceId} not found`) + ); + } + + // set req.userOrgId to source org for permission check + req.userOrgId = resource.orgId; + + if (resource.orgId === targetOrgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, `Resource is already in this organization`) + ); + } + + const [currentOrg] = await db + .select() + .from(orgs) + .where(eq(orgs.orgId, resource.orgId)) + .limit(1); + + const [targetOrg] = await db + .select() + .from(orgs) + .where(eq(orgs.orgId, targetOrgId)) + .limit(1); + + if (!targetOrg) { + return next( + createHttpError(HttpCode.NOT_FOUND, `Target organization with ID ${targetOrgId} not found`) + ); + } + + const [userOrgAccess] = await db + .select() + .from(userOrgs) + .where(and( + eq(userOrgs.userId, user.userId), + eq(userOrgs.orgId, targetOrgId) + )) + .limit(1); + + if (!userOrgAccess) { + return next( + createHttpError(HttpCode.FORBIDDEN, `You don't have access to the target organization`) + ); + } + + // get role-based permissions that will be affected + const rolePermissionsQuery = await db + .select({ + roleId: roleResources.roleId, + roleName: roles.name, + roleDescription: roles.description + }) + .from(roleResources) + .innerJoin(roles, eq(roleResources.roleId, roles.roleId)) + .where(eq(roleResources.resourceId, resourceId)); + + // get user permissions that will be affected (excluding moving user) + const userPermissionsQuery = await db + .select({ + userId: userResources.userId, + username: users.username, + email: users.email, + name: users.name + }) + .from(userResources) + .innerJoin(users, eq(userResources.userId, users.userId)) + .where(and( + eq(userResources.resourceId, resourceId), + // exclude the moving user from the impact as they'll retain access + // Note: We'll show this in the UI but they won't "lose" access + )); + + // Separate moving user from others who will lose access + const movingUserPermission = userPermissionsQuery.find(up => up.userId === user.userId); + const otherUserPermissions = userPermissionsQuery.filter(up => up.userId !== user.userId); + + const totalImpactedPermissions = rolePermissionsQuery.length + otherUserPermissions.length; + + const impactData = { + resourceId: resource.resourceId, + resourceName: resource.name, + currentOrgId: resource.orgId, + currentOrgName: currentOrg?.name || 'Unknown', + targetOrgId, + targetOrgName: targetOrg.name, + impact: { + rolePermissions: { + count: rolePermissionsQuery.length, + details: rolePermissionsQuery.map(rp => ({ + roleId: rp.roleId, + roleName: rp.roleName, + roleDescription: rp.roleDescription + })) + }, + userPermissions: { + count: otherUserPermissions.length, + details: otherUserPermissions.map(up => ({ + userId: up.userId, + username: up.username, + email: up.email || '', + name: up.name || '' + })) + }, + movingUser: movingUserPermission ? { + userId: movingUserPermission.userId, + username: movingUserPermission.username, + email: movingUserPermission.email || '', + name: movingUserPermission.name || '', + retainsAccess: true + } : null, + totalImpactedPermissions, + authenticationPreserved: true, // Passwords, pins, etc. are preserved + movingUserRetainsAccess: true + } + }; + + logger.info(`Move impact calculated for resource ${resourceId}`, { + resourceId, + currentOrgId: resource.orgId, + targetOrgId, + userId: user.userId, + rolePermissionsAffected: rolePermissionsQuery.length, + userPermissionsAffected: otherUserPermissions.length, + totalImpact: totalImpactedPermissions + }); + + return response(res, { + data: impactData, + success: true, + error: false, + message: "Move impact analysis completed successfully", + status: HttpCode.OK, + }); + + } catch (err) { + logger.error("Error calculating move impact:", err); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred while calculating move impact") + ); + } +} \ No newline at end of file diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index 6cb8aa688..8f1961bab 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -22,4 +22,5 @@ export * from "./deleteResourceRule"; export * from "./listResourceRules"; export * from "./updateResourceRule"; export * from "./getUserResources"; -export * from "./moveResourceToOrg"; \ No newline at end of file +export * from "./moveResourceToOrg"; +export * from "./getMoveImpact"; \ No newline at end of file diff --git a/server/routers/resource/moveResourceToOrg.ts b/server/routers/resource/moveResourceToOrg.ts index b809503c6..8f78cad90 100644 --- a/server/routers/resource/moveResourceToOrg.ts +++ b/server/routers/resource/moveResourceToOrg.ts @@ -1,8 +1,14 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { resources, orgs, userOrgs, userResources } from "@server/db"; -import { eq, and } from "drizzle-orm"; +import { + resources, + orgs, + userOrgs, + userResources, + roleResources +} from "@server/db"; +import { eq, and, ne } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -18,7 +24,6 @@ const moveResourceBodySchema = z.object({ orgId: z.string().min(1) }); - registry.registerPath({ method: "post", path: "/resource/{resourceId}/move-org", @@ -37,6 +42,52 @@ registry.registerPath({ responses: {} }); + +async function cleanupResourcePermissions( + resourceId: number, + oldOrgId: string, + newOrgId: string, + movingUserId: string, + tx: any +) { + try { + // remove all role-based permissions (roles belong to the old org) + const deletedRoleResources = await tx + .delete(roleResources) + .where(eq(roleResources.resourceId, resourceId)) + .returning(); + + // remove all user permissions except the moving user + const deletedUserResources = await tx + .delete(userResources) + .where(and( + eq(userResources.resourceId, resourceId), + ne(userResources.userId, movingUserId) + )) + .returning(); + + // Note: we preserve authentication settings (passwords, pins, whitelist, tokens) + // as these are resource-specific and remain valid across orgs + + logger.info(`Permission cleanup for resource ${resourceId}:`, { + resourceId, + oldOrgId, + newOrgId, + movingUserId, + deletedRolePermissions: deletedRoleResources.length, + deletedUserPermissions: deletedUserResources.length + }); + + return { + deletedRolePermissions: deletedRoleResources.length, + deletedUserPermissions: deletedUserResources.length + }; + } catch (error) { + logger.error("Error during permission cleanup:", error); + throw error; + } +} + export async function moveResourceToOrg( req: Request, res: Response, @@ -61,7 +112,6 @@ export async function moveResourceToOrg( return next(createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")); } - // Step 1: Fetch resource const [resource] = await db .select() .from(resources) @@ -74,17 +124,15 @@ export async function moveResourceToOrg( ); } - // Step 2: Set req.userOrgId to source org so permissions are checked correctly + // set req.userOrgId to source org so permissions are checked correctly req.userOrgId = resource.orgId; - // Step 3: Prevent move to same org if (resource.orgId === orgId) { return next( createHttpError(HttpCode.BAD_REQUEST, `Resource is already in this organization`) ); } - // Step 4: Check if target org exists const [targetOrg] = await db .select() .from(orgs) @@ -97,7 +145,6 @@ export async function moveResourceToOrg( ); } - // Step 5: Verify user has access to target organization const [userOrgAccess] = await db .select() .from(userOrgs) @@ -113,43 +160,68 @@ export async function moveResourceToOrg( ); } - // Step 6: Move the resource - const [updatedResource] = await db - .update(resources) - .set({ - orgId - }) - .where(eq(resources.resourceId, resourceId)) - .returning(); - await db.insert(userResources).values({ - userId: req.user!.userId, - resourceId - }); + // perform the move within a transaction + const moveResult = await db.transaction(async (tx) => { + // Move the resource to new org + const [updatedResource] = await tx + .update(resources) + .set({ orgId }) + .where(eq(resources.resourceId, resourceId)) + .returning(); + if (!updatedResource) { + throw new Error("Failed to update resource"); + } - if (!updatedResource) { - return next( - createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "Failed to update resource") + // Clean up permissions that become invalid + const cleanupResult = await cleanupResourcePermissions( + resourceId, + resource.orgId, + orgId, + user.userId, + tx ); - } - // Log the successful move - logger.info(`Resource ${resourceId} moved from org ${resource.orgId} to org ${orgId} by user ${user.userId}`); + // Grant access to moving user in new org (ensure they have access) + await tx.insert(userResources) + .values({ + userId: user.userId, + resourceId + }) + .onConflictDoNothing(); // In case they already have access somehow + + return { + updatedResource, + cleanupResult + }; + }); + + logger.info(`Resource ${resourceId} successfully moved`, { + resourceId, + resourceName: resource.name, + oldOrgId: resource.orgId, + newOrgId: orgId, + movedByUserId: user.userId, + impactSummary: { + ...moveResult.cleanupResult + } + }); - // Step 7: Respond return response(res, { data: { - resourceId: updatedResource.resourceId, + resourceId: moveResult.updatedResource.resourceId, + resourceName: moveResult.updatedResource.name, oldOrgId: resource.orgId, newOrgId: orgId, - name: updatedResource.name + targetOrgName: targetOrg.name, }, success: true, error: false, message: "Resource successfully moved to new organization", status: HttpCode.OK, }); + } catch (err) { logger.error("Error moving resource to org:", err); return next( From c8d21b0cf08a5c717e46a4af7acd5d9e4a22d167 Mon Sep 17 00:00:00 2001 From: Pallavi Date: Tue, 26 Aug 2025 22:35:57 +0530 Subject: [PATCH 04/16] transferResource error fix --- server/routers/external.ts | 7 ------- server/routers/integration.ts | 6 ------ 2 files changed, 13 deletions(-) diff --git a/server/routers/external.ts b/server/routers/external.ts index 4a9e46364..c01606ffb 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -504,13 +504,6 @@ authenticated.get( resource.getResourceWhitelist ); -authenticated.post( - `/resource/:resourceId/transfer`, - verifyResourceAccess, - verifyUserHasAction(ActionsEnum.updateResource), - resource.transferResource -); - authenticated.post( `/resource/:resourceId/move-org`, verifyResourceAccess, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index ee61d1af1..0b568fcd2 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -414,12 +414,6 @@ authenticated.get( resource.getResourceWhitelist ); -authenticated.post( - `/resource/:resourceId/transfer`, - verifyApiKeyResourceAccess, - verifyApiKeyHasAction(ActionsEnum.updateResource), - resource.transferResource -); authenticated.post( `/resource/:resourceId/move-org`, From a9a279826d935ec839b1827bd9bed0b3db4af247 Mon Sep 17 00:00:00 2001 From: Pallavi Date: Wed, 27 Aug 2025 00:30:50 +0530 Subject: [PATCH 05/16] Improve org resource move --- package.json | 1 + server/routers/resource/getMoveImpact.ts | 63 ++- server/routers/resource/moveResourceToOrg.ts | 442 ++++++++++++++++--- src/components/ui/accordion.tsx | 66 +++ 4 files changed, 495 insertions(+), 77 deletions(-) create mode 100644 src/components/ui/accordion.tsx diff --git a/package.json b/package.json index d2578fc78..2fa047b14 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@node-rs/argon2": "^2.0.2", "@oslojs/crypto": "1.0.1", "@oslojs/encoding": "1.1.0", + "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", diff --git a/server/routers/resource/getMoveImpact.ts b/server/routers/resource/getMoveImpact.ts index 72b1a61bf..baef8f02f 100644 --- a/server/routers/resource/getMoveImpact.ts +++ b/server/routers/resource/getMoveImpact.ts @@ -8,9 +8,11 @@ import { userResources, roleResources, roles, - users + users, + targets, + sites } from "@server/db"; -import { eq, and } from "drizzle-orm"; +import { eq, and, inArray } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -88,6 +90,26 @@ registry.registerPath({ } } }, + targetSites: { + type: "object", + properties: { + count: { type: "number" }, + details: { + type: "array", + items: { + type: "object", + properties: { + siteId: { type: "number" }, + siteName: { type: "string" }, + targetId: { type: "number" }, + ip: { type: "string" }, + port: { type: "number" }, + willBeRemoved: { type: "boolean" } + } + } + } + } + }, totalImpactedPermissions: { type: "number" }, authenticationPreserved: { type: "boolean" }, movingUserRetainsAccess: { type: "boolean" } @@ -202,11 +224,33 @@ export async function getMoveImpact( }) .from(userResources) .innerJoin(users, eq(userResources.userId, users.userId)) - .where(and( - eq(userResources.resourceId, resourceId), - // exclude the moving user from the impact as they'll retain access - // Note: We'll show this in the UI but they won't "lose" access - )); + .where(eq(userResources.resourceId, resourceId)); + + // Get targets and their associated sites + const resourceTargets = await db + .select({ + targetId: targets.targetId, + siteId: targets.siteId, + ip: targets.ip, + port: targets.port, + siteName: sites.name, + siteOrgId: sites.orgId + }) + .from(targets) + .leftJoin(sites, eq(targets.siteId, sites.siteId)) + .where(eq(targets.resourceId, resourceId)); + + // Analyze which targets will be affected + const affectedTargetSites = resourceTargets + .filter(target => target.siteId && target.siteOrgId !== targetOrgId) + .map(target => ({ + siteId: target.siteId!, + siteName: target.siteName || 'Unknown', + targetId: target.targetId, + ip: target.ip, + port: target.port, + willBeRemoved: true // Sites from different orgs will lose connection + })); // Separate moving user from others who will lose access const movingUserPermission = userPermissionsQuery.find(up => up.userId === user.userId); @@ -239,6 +283,10 @@ export async function getMoveImpact( name: up.name || '' })) }, + targetSites: { + count: affectedTargetSites.length, + details: affectedTargetSites + }, movingUser: movingUserPermission ? { userId: movingUserPermission.userId, username: movingUserPermission.username, @@ -259,6 +307,7 @@ export async function getMoveImpact( userId: user.userId, rolePermissionsAffected: rolePermissionsQuery.length, userPermissionsAffected: otherUserPermissions.length, + targetSitesAffected: affectedTargetSites.length, totalImpact: totalImpactedPermissions }); diff --git a/server/routers/resource/moveResourceToOrg.ts b/server/routers/resource/moveResourceToOrg.ts index 8f78cad90..5e63516f8 100644 --- a/server/routers/resource/moveResourceToOrg.ts +++ b/server/routers/resource/moveResourceToOrg.ts @@ -1,12 +1,14 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { - resources, - orgs, - userOrgs, - userResources, - roleResources +import { + resources, + orgs, + userOrgs, + userResources, + roleResources, + targets, + sites } from "@server/db"; import { eq, and, ne } from "drizzle-orm"; import response from "@server/lib/response"; @@ -42,22 +44,54 @@ registry.registerPath({ responses: {} }); +// Type definitions +type ResourceTarget = { + targetId: number; + siteId: number | null; + siteOrgId: string | null; + ip: string | null; + port: number | null; +}; + +type DisconnectedTarget = { + targetId: number; + previousSiteId: number | null; + ip: string | null; + port: number | null; +}; + +type PermissionCleanupResult = { + deletedRolePermissions: number; + deletedUserPermissions: number; +}; + +type TargetCleanupResult = { + targetsDisconnected: number; + disconnectedTargets: DisconnectedTarget[]; +}; + +type MoveResult = { + updatedResource: any; + permissionCleanup: PermissionCleanupResult; + targetCleanup: TargetCleanupResult; +}; async function cleanupResourcePermissions( - resourceId: number, - oldOrgId: string, - newOrgId: string, + resourceId: number, + oldOrgId: string, + newOrgId: string, movingUserId: string, tx: any -) { +): Promise { try { - // remove all role-based permissions (roles belong to the old org) + logger.info(`Starting permission cleanup for resource ${resourceId}`); + const deletedRoleResources = await tx .delete(roleResources) .where(eq(roleResources.resourceId, resourceId)) .returning(); - // remove all user permissions except the moving user + // Remove all user permissions except the moving user const deletedUserResources = await tx .delete(userResources) .where(and( @@ -66,24 +100,177 @@ async function cleanupResourcePermissions( )) .returning(); - // Note: we preserve authentication settings (passwords, pins, whitelist, tokens) - // as these are resource-specific and remain valid across orgs - - logger.info(`Permission cleanup for resource ${resourceId}:`, { - resourceId, - oldOrgId, - newOrgId, - movingUserId, - deletedRolePermissions: deletedRoleResources.length, - deletedUserPermissions: deletedUserResources.length - }); - - return { + const result = { deletedRolePermissions: deletedRoleResources.length, deletedUserPermissions: deletedUserResources.length }; + + logger.info(`Permission cleanup completed for resource ${resourceId}:`, result); + return result; } catch (error) { - logger.error("Error during permission cleanup:", error); + logger.error(`Error during permission cleanup for resource ${resourceId}:`, error); + throw error; + } +} + +async function cleanupTargetSitesSafely( + resourceId: number, + targetOrgId: string, + tx: any +): Promise { + try { + logger.info(`Starting target cleanup for resource ${resourceId}`); + + // Get all targets for this resource with their site information + const resourceTargets: ResourceTarget[] = await tx + .select({ + targetId: targets.targetId, + siteId: targets.siteId, + siteOrgId: sites.orgId, + ip: targets.ip, + port: targets.port + }) + .from(targets) + .leftJoin(sites, eq(targets.siteId, sites.siteId)) + .where(eq(targets.resourceId, resourceId)); + + // Find targets that reference sites from different orgs + const problematicTargets = resourceTargets.filter( + (target: ResourceTarget) => target.siteId && target.siteOrgId && target.siteOrgId !== targetOrgId + ); + + logger.info(`Found ${problematicTargets.length} targets with cross-org site references for resource ${resourceId}`); + + if (problematicTargets.length === 0) { + return { + targetsDisconnected: 0, + disconnectedTargets: [] + }; + } + + // Strategy: Try to set siteId to null first, if that fails due to NOT NULL constraint, delete the targets + const testTarget = problematicTargets[0]; + + try { + // Test if we can set siteId to null on the first target + await tx + .update(targets) + .set({ siteId: null }) + .where(eq(targets.targetId, testTarget.targetId)); + + // If successful, continue with the rest + const disconnectedTargets: DisconnectedTarget[] = [{ + targetId: testTarget.targetId, + previousSiteId: testTarget.siteId, + ip: testTarget.ip, + port: testTarget.port + }]; + + // Process remaining targets + for (let i = 1; i < problematicTargets.length; i++) { + const target = problematicTargets[i]; + await tx + .update(targets) + .set({ siteId: null }) + .where(eq(targets.targetId, target.targetId)); + + disconnectedTargets.push({ + targetId: target.targetId, + previousSiteId: target.siteId, + ip: target.ip, + port: target.port + }); + } + + logger.info(`Successfully disconnected ${disconnectedTargets.length} targets from sites`); + + return { + targetsDisconnected: disconnectedTargets.length, + disconnectedTargets + }; + + } catch (nullConstraintError) { + // Rollback the test change and delete targets instead + logger.warn(`Cannot set siteId to null due to schema constraints. Will delete problematic targets instead.`); + + // The test update failed, so we need to delete all problematic targets + const deletedTargets: DisconnectedTarget[] = []; + + for (const target of problematicTargets) { + const [deletedTarget] = await tx + .delete(targets) + .where(eq(targets.targetId, target.targetId)) + .returning(); + + if (deletedTarget) { + deletedTargets.push({ + targetId: target.targetId, + previousSiteId: target.siteId, + ip: target.ip, + port: target.port + }); + } + } + + logger.info(`Deleted ${deletedTargets.length} targets due to cross-org site references`); + + return { + targetsDisconnected: deletedTargets.length, + disconnectedTargets: deletedTargets + }; + } + + } catch (error) { + logger.error(`Error during target cleanup for resource ${resourceId}:`, error); + throw error; // This will cause transaction rollback + } +} + +async function verifyResourceState( + resourceId: number, + expectedOrgId: string, + context: string +): Promise { + try { + const [verificationResource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (verificationResource) { + if (verificationResource.orgId === expectedOrgId) { + logger.info(`✓ ${context}: Resource ${resourceId} is in expected org ${expectedOrgId}`); + return true; + } else { + logger.error(`✗ ${context}: Resource ${resourceId} is in org ${verificationResource.orgId}, expected ${expectedOrgId}`); + return false; + } + } else { + logger.error(`✗ ${context}: Resource ${resourceId} not found`); + return false; + } + } catch (error) { + logger.error(`Failed to verify resource state for ${resourceId}:`, error); + return false; + } +} + +async function emergencyRecovery( + resourceId: number, + originalOrgId: string +): Promise { + try { + logger.warn(`Starting emergency recovery for resource ${resourceId}`); + + await db + .update(resources) + .set({ orgId: originalOrgId }) + .where(eq(resources.resourceId, resourceId)); + + logger.info(`Emergency recovery completed: Resource ${resourceId} restored to org ${originalOrgId}`); + } catch (error) { + logger.error(`Emergency recovery failed for resource ${resourceId}:`, error); throw error; } } @@ -92,7 +279,11 @@ export async function moveResourceToOrg( req: Request, res: Response, next: NextFunction -) { +): Promise { + let originalResource: any = null; + let resourceId: number = 0; + let targetOrgId: string = ''; + try { const parsedParams = moveResourceParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -104,14 +295,15 @@ export async function moveResourceToOrg( return next(createHttpError(HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString())); } - const { resourceId } = parsedParams.data; - const { orgId } = parsedBody.data; + resourceId = parsedParams.data.resourceId; + targetOrgId = parsedBody.data.orgId; const user = req.user; if (!user) { return next(createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")); } + // Get and store original resource state before any modifications const [resource] = await db .select() .from(resources) @@ -124,10 +316,13 @@ export async function moveResourceToOrg( ); } - // set req.userOrgId to source org so permissions are checked correctly + // Store original state for potential recovery + originalResource = { ...resource }; + + // Set req.userOrgId to source org so permissions are checked correctly req.userOrgId = resource.orgId; - if (resource.orgId === orgId) { + if (resource.orgId === targetOrgId) { return next( createHttpError(HttpCode.BAD_REQUEST, `Resource is already in this organization`) ); @@ -136,12 +331,12 @@ export async function moveResourceToOrg( const [targetOrg] = await db .select() .from(orgs) - .where(eq(orgs.orgId, orgId)) + .where(eq(orgs.orgId, targetOrgId)) .limit(1); if (!targetOrg) { return next( - createHttpError(HttpCode.NOT_FOUND, `Target organization with ID ${orgId} not found`) + createHttpError(HttpCode.NOT_FOUND, `Target organization with ID ${targetOrgId} not found`) ); } @@ -150,7 +345,7 @@ export async function moveResourceToOrg( .from(userOrgs) .where(and( eq(userOrgs.userId, user.userId), - eq(userOrgs.orgId, orgId) + eq(userOrgs.orgId, targetOrgId) )) .limit(1); @@ -160,61 +355,101 @@ export async function moveResourceToOrg( ); } + logger.info(`Starting resource move: ${resourceId} from ${resource.orgId} to ${targetOrgId}`); - // perform the move within a transaction - const moveResult = await db.transaction(async (tx) => { - // Move the resource to new org - const [updatedResource] = await tx - .update(resources) - .set({ orgId }) - .where(eq(resources.resourceId, resourceId)) - .returning(); + // Perform the move within a transaction with explicit rollback handling + const moveResult: MoveResult = await db.transaction(async (tx) => { + try { + logger.info(`Transaction started for resource ${resourceId}`); - if (!updatedResource) { - throw new Error("Failed to update resource"); - } + const [updatedResource] = await tx + .update(resources) + .set({ orgId: targetOrgId }) + .where(eq(resources.resourceId, resourceId)) + .returning(); - // Clean up permissions that become invalid - const cleanupResult = await cleanupResourcePermissions( - resourceId, - resource.orgId, - orgId, - user.userId, - tx - ); + if (!updatedResource) { + throw new Error("Failed to update resource orgId"); + } - // Grant access to moving user in new org (ensure they have access) - await tx.insert(userResources) - .values({ - userId: user.userId, - resourceId - }) - .onConflictDoNothing(); // In case they already have access somehow + logger.info(`Resource ${resourceId} orgId updated to ${targetOrgId} within transaction`); - return { - updatedResource, - cleanupResult - }; + const permissionCleanup = await cleanupResourcePermissions( + resourceId, + resource.orgId, + targetOrgId, + user.userId, + tx + ); + + const targetCleanup = await cleanupTargetSitesSafely( + resourceId, + targetOrgId, + tx + ); + + await tx.insert(userResources) + .values({ + userId: user.userId, + resourceId + }) + .onConflictDoNothing(); + + logger.info(`User access granted for resource ${resourceId}`); + + return { + updatedResource, + permissionCleanup, + targetCleanup + }; + + } catch (txError: any) { + logger.error(`Transaction error for resource ${resourceId}:`, { + error: txError.message, + stack: txError.stack, + resourceId, + originalOrgId: resource.orgId, + targetOrgId + }); + + // Re-throw to trigger transaction rollback + throw txError; + } }); logger.info(`Resource ${resourceId} successfully moved`, { resourceId, resourceName: resource.name, oldOrgId: resource.orgId, - newOrgId: orgId, + newOrgId: targetOrgId, movedByUserId: user.userId, impactSummary: { - ...moveResult.cleanupResult + permissionsRemoved: moveResult.permissionCleanup.deletedRolePermissions + moveResult.permissionCleanup.deletedUserPermissions, + targetsDisconnected: moveResult.targetCleanup.targetsDisconnected } }); + const moveVerified = await verifyResourceState(resourceId, targetOrgId, "Post-move verification"); + + if (!moveVerified) { + logger.error(`Move verification failed for resource ${resourceId} - this should not happen`); + throw new Error("Move verification failed"); + } + return response(res, { data: { resourceId: moveResult.updatedResource.resourceId, resourceName: moveResult.updatedResource.name, oldOrgId: resource.orgId, - newOrgId: orgId, + newOrgId: targetOrgId, targetOrgName: targetOrg.name, + moveImpact: { + rolePermissionsRemoved: moveResult.permissionCleanup.deletedRolePermissions, + userPermissionsRemoved: moveResult.permissionCleanup.deletedUserPermissions, + targetsDisconnected: moveResult.targetCleanup.targetsDisconnected, + disconnectedTargets: moveResult.targetCleanup.disconnectedTargets, + authenticationPreserved: true + } }, success: true, error: false, @@ -222,10 +457,77 @@ export async function moveResourceToOrg( status: HttpCode.OK, }); - } catch (err) { - logger.error("Error moving resource to org:", err); + } catch (err: any) { + // Transaction failed -- verify rollback worked and attempt recovery if needed + logger.error(`Move operation failed for resource ${resourceId}:`, { + error: err.message, + stack: err.stack, + originalOrgId: originalResource?.orgId, + targetOrgId + }); + + + if (resourceId && originalResource) { + const rollbackVerified = await verifyResourceState( + resourceId, + originalResource.orgId, + "Rollback verification" + ); + + if (!rollbackVerified) { + try { + await emergencyRecovery(resourceId, originalResource.orgId); + + const recoveryVerified = await verifyResourceState( + resourceId, + originalResource.orgId, + "Emergency recovery verification" + ); + + if (recoveryVerified) { + logger.info(`✓ Emergency recovery successful for resource ${resourceId}`); + } else { + logger.error(`✗ Emergency recovery failed for resource ${resourceId} - manual intervention required`); + } + } catch (recoveryError) { + logger.error(`Emergency recovery failed for resource ${resourceId}:`, recoveryError); + } + } + } + + // Return appropriate error message based on error type + if (err.message && err.message.includes('NOT NULL constraint failed: targets.siteId')) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Cannot move resource: some targets have required site connections that cannot be transferred between organizations. The resource remains in its original organization." + ) + ); + } + + if (err.message && err.message.includes('FOREIGN KEY constraint failed')) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Cannot move resource due to dependencies that cannot be transferred. The resource remains in its original organization." + ) + ); + } + + if (err.message && err.message.includes('UNIQUE constraint failed')) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Cannot move resource due to conflicting data in the target organization. The resource remains in its original organization." + ) + ); + } + return next( - createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred while moving the resource") + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to move resource. The resource remains in its original organization." + ) ); } } \ No newline at end of file diff --git a/src/components/ui/accordion.tsx b/src/components/ui/accordion.tsx new file mode 100644 index 000000000..c9c9b1121 --- /dev/null +++ b/src/components/ui/accordion.tsx @@ -0,0 +1,66 @@ +"use client" + +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDownIcon } from "lucide-react" + +import { cn } from "@app/lib/cn"; + +function Accordion({ + ...props +}: React.ComponentProps) { + return +} + +function AccordionItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AccordionTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + + ) +} + +function AccordionContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + +
{children}
+
+ ) +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } From e55c5dcf5fb48bf8b02da6dcb8c79a2d10f31eaa Mon Sep 17 00:00:00 2001 From: Pallavi Date: Wed, 27 Aug 2025 00:31:27 +0530 Subject: [PATCH 06/16] Improve org resource move backend --- server/routers/resource/moveResourceToOrg.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/server/routers/resource/moveResourceToOrg.ts b/server/routers/resource/moveResourceToOrg.ts index 5e63516f8..5b286f3be 100644 --- a/server/routers/resource/moveResourceToOrg.ts +++ b/server/routers/resource/moveResourceToOrg.ts @@ -44,7 +44,6 @@ registry.registerPath({ responses: {} }); -// Type definitions type ResourceTarget = { targetId: number; siteId: number | null; @@ -91,7 +90,7 @@ async function cleanupResourcePermissions( .where(eq(roleResources.resourceId, resourceId)) .returning(); - // Remove all user permissions except the moving user + // remove all user permissions except the moving user const deletedUserResources = await tx .delete(userResources) .where(and( From 07b31bfd355a480306c191c6b327d01db2caa8c0 Mon Sep 17 00:00:00 2001 From: Pallavi Date: Wed, 27 Aug 2025 00:37:50 +0530 Subject: [PATCH 07/16] package-lock file for ci --- package-lock.json | 43 ++++++++++++++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/package-lock.json b/package-lock.json index 59a6dcce4..bb621403e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@node-rs/argon2": "^2.0.2", "@oslojs/crypto": "1.0.1", "@oslojs/encoding": "1.1.0", + "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", @@ -3000,6 +3001,37 @@ "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "license": "MIT" }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", + "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", @@ -9687,17 +9719,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/jiti": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz", - "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", - "license": "MIT", - "optional": true, - "peer": true, - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, "node_modules/jmespath": { "version": "0.16.0", "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.16.0.tgz", From ad4037d282b16f18f6d943ebce07e3bbe8f07d82 Mon Sep 17 00:00:00 2001 From: Pallavi Date: Wed, 27 Aug 2025 00:45:55 +0530 Subject: [PATCH 08/16] fix ESLint issue --- src/components/ui/accordion.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/components/ui/accordion.tsx b/src/components/ui/accordion.tsx index c9c9b1121..1bbf3aafd 100644 --- a/src/components/ui/accordion.tsx +++ b/src/components/ui/accordion.tsx @@ -1,15 +1,15 @@ -"use client" +"use client"; -import * as React from "react" -import * as AccordionPrimitive from "@radix-ui/react-accordion" -import { ChevronDownIcon } from "lucide-react" +import * as React from "react"; +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import { ChevronDownIcon } from "lucide-react"; import { cn } from "@app/lib/cn"; function Accordion({ ...props }: React.ComponentProps) { - return + return ; } function AccordionItem({ @@ -22,7 +22,7 @@ function AccordionItem({ className={cn("border-b last:border-b-0", className)} {...props} /> - ) + ); } function AccordionTrigger({ @@ -44,7 +44,7 @@ function AccordionTrigger({ - ) + ); } function AccordionContent({ @@ -60,7 +60,7 @@ function AccordionContent({ >
{children}
- ) + ); } -export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; From 22448db7bce6ee93774126860c81ed109cd7bc0c Mon Sep 17 00:00:00 2001 From: Pallavi Date: Mon, 1 Sep 2025 02:46:34 +0530 Subject: [PATCH 09/16] Move-Resource button moved from ResourceInfoBox to general settings --- .../[niceId]/ResourceInfoWrapper.tsx | 32 - .../[niceId]/general/GeneralForm.tsx | 947 ++++++++++++++++++ .../resources/[niceId]/general/page.tsx | 497 +-------- .../settings/resources/[niceId]/layout.tsx | 4 +- 4 files changed, 968 insertions(+), 512 deletions(-) delete mode 100644 src/app/[orgId]/settings/resources/[niceId]/ResourceInfoWrapper.tsx create mode 100644 src/app/[orgId]/settings/resources/[niceId]/general/GeneralForm.tsx diff --git a/src/app/[orgId]/settings/resources/[niceId]/ResourceInfoWrapper.tsx b/src/app/[orgId]/settings/resources/[niceId]/ResourceInfoWrapper.tsx deleted file mode 100644 index 499313d00..000000000 --- a/src/app/[orgId]/settings/resources/[niceId]/ResourceInfoWrapper.tsx +++ /dev/null @@ -1,32 +0,0 @@ - -import { verifySession } from "@/lib/auth/verifySession"; -import { authCookieHeader } from "@/lib/api/cookies"; -import { internal } from "@/lib/api"; -import { ListUserOrgsResponse } from "@server/routers/org"; -import ResourceInfoBox from "./ResourceInfoBox"; - - -interface ResourceInfoWrapperType { - resource: any; -} - -export default async function ResourceInfoWrapper({ resource }: ResourceInfoWrapperType) { - const user = await verifySession(); - if (!user) return
Unauthorized
; - - let orgs: ListUserOrgsResponse["orgs"] = []; - - try { - const res = await internal.get(`/user/${user.userId}/orgs`, await authCookieHeader()); - if (res?.data?.data?.orgs) { - const fetchedOrgs: ListUserOrgsResponse["orgs"] = res.data.data.orgs; - orgs = fetchedOrgs.filter( - (org: { orgId: string; name: string }) => org.orgId !== resource.orgId - ); - } - } catch (err) { - console.error("Failed to fetch user orgs:", err); - } - - return ; -} diff --git a/src/app/[orgId]/settings/resources/[niceId]/general/GeneralForm.tsx b/src/app/[orgId]/settings/resources/[niceId]/general/GeneralForm.tsx new file mode 100644 index 000000000..f2d70f010 --- /dev/null +++ b/src/app/[orgId]/settings/resources/[niceId]/general/GeneralForm.tsx @@ -0,0 +1,947 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { formatAxiosError } from "@app/lib/api"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { useResourceContext } from "@app/hooks/useResourceContext"; +import { ListSitesResponse } from "@server/routers/site"; +import { useEffect, useState } from "react"; +import { AxiosResponse } from "axios"; +import { useParams, useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { toast } from "@app/hooks/useToast"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionHeader, + SettingsSectionTitle, + SettingsSectionDescription, + SettingsSectionBody, + SettingsSectionForm, + SettingsSectionFooter +} from "@app/components/Settings"; +import { useOrgContext } from "@app/hooks/useOrgContext"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { Label } from "@app/components/ui/label"; +import { ListDomainsResponse } from "@server/routers/domain"; +import { UpdateResourceResponse } from "@server/routers/resource"; +import { SwitchInput } from "@app/components/SwitchInput"; +import { useTranslations } from "next-intl"; +import { Checkbox } from "@app/components/ui/checkbox"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import DomainPicker from "@app/components/DomainPicker"; +import { + InfoIcon, + ShieldCheck, + ShieldOff, + AlertTriangle, + Users, + Shield, + Check, + ArrowRight, + Unplug, + RotateCw, + Globe +} from "lucide-react"; +import { build } from "@server/build"; +import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils"; +import { InfoSection, InfoSectionContent } from "@app/components/InfoSection"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@app/components/ui/dialog"; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@app/components/ui/accordion"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@app/components/ui/select"; + +type ResponseOrg = { + orgId: string; + name: string; +}; + +interface GeneralFormProps { + fetchedOrgs: ResponseOrg[]; +} + +type MoveImpact = { + resourceId: number; + resourceName: string; + currentOrgId: string; + currentOrgName: string; + targetOrgId: string; + targetOrgName: string; + impact: { + rolePermissions: { + count: number; + details: { + roleId: number; + roleName: string; + roleDescription?: string; + }[]; + }; + userPermissions: { + count: number; + details: { + userId: string; + username: string; + email: string; + name: string; + }[]; + }; + targetSites: { + count: number; + details: { + siteId: number; + siteName: string; + targetId: number; + ip: string; + port: number; + willBeRemoved: boolean; + }[]; + }; + movingUser: { + userId: string; + username: string; + email: string; + name: string; + retainsAccess: boolean; + } | null; + totalImpactedPermissions: number; + authenticationPreserved: boolean; + movingUserRetainsAccess: boolean; + }; +}; + +type MoveWarning = { + type: 'warning' | 'info' | 'danger'; + icon: React.ReactNode; + message: string; +}; + +export default function GeneralForm({ fetchedOrgs }: GeneralFormProps) { + const [formKey, setFormKey] = useState(0); + const params = useParams(); + const { resource, updateResource } = useResourceContext(); + const { org } = useOrgContext(); + const router = useRouter(); + const t = useTranslations(); + const [editDomainOpen, setEditDomainOpen] = useState(false); + + const { env } = useEnvContext(); + + const orgId = params.orgId; + + const api = createApiClient({ env }); + + const [sites, setSites] = useState([]); + const [saveLoading, setSaveLoading] = useState(false); + const [transferLoading, setTransferLoading] = useState(false); + const [open, setOpen] = useState(false); + const [baseDomains, setBaseDomains] = useState< + ListDomainsResponse["domains"] + >([]); + + const [loadingPage, setLoadingPage] = useState(true); + const [resourceFullDomain, setResourceFullDomain] = useState( + `${resource.ssl ? "https" : "http"}://${resource.fullDomain}` + ); + const [selectedDomain, setSelectedDomain] = useState<{ + domainId: string; + subdomain?: string; + fullDomain: string; + baseDomain: string; + } | null>(null); + + // Move resource states + const [selectedOrg, setSelectedOrg] = useState(undefined); + const [isLoading, setIsLoading] = useState(false); + const [showConfirmDialog, setShowConfirmDialog] = useState(false); + const [moveImpact, setMoveImpact] = useState(null); + + const GeneralFormSchema = z + .object({ + enabled: z.boolean(), + subdomain: z.string().optional(), + name: z.string().min(1).max(255), + domainId: z.string().optional(), + proxyPort: z.number().int().min(1).max(65535).optional(), + // enableProxy: z.boolean().optional() + }) + .refine( + (data) => { + // For non-HTTP resources, proxyPort should be defined + if (!resource.http) { + return data.proxyPort !== undefined; + } + // For HTTP resources, proxyPort should be undefined + return data.proxyPort === undefined; + }, + { + message: !resource.http + ? "Port number is required for non-HTTP resources" + : "Port number should not be set for HTTP resources", + path: ["proxyPort"] + } + ); + + type GeneralFormValues = z.infer; + + const form = useForm({ + resolver: zodResolver(GeneralFormSchema), + defaultValues: { + enabled: resource.enabled, + name: resource.name, + subdomain: resource.subdomain ? resource.subdomain : undefined, + domainId: resource.domainId || undefined, + proxyPort: resource.proxyPort || undefined, + // enableProxy: resource.enableProxy || false + }, + mode: "onChange" + }); + + useEffect(() => { + const fetchSites = async () => { + const res = await api.get>( + `/org/${orgId}/sites/` + ); + setSites(res.data.data.sites); + }; + + const fetchDomains = async () => { + const res = await api + .get< + AxiosResponse + >(`/org/${orgId}/domains/`) + .catch((e) => { + toast({ + variant: "destructive", + title: t("domainErrorFetch"), + description: formatAxiosError( + e, + t("domainErrorFetchDescription") + ) + }); + }); + + if (res?.status === 200) { + const domains = res.data.data.domains; + setBaseDomains(domains); + setFormKey((key) => key + 1); + } + }; + + const load = async () => { + await fetchDomains(); + await fetchSites(); + + setLoadingPage(false); + }; + + load(); + }, []); + + async function onSubmit(data: GeneralFormValues) { + setSaveLoading(true); + + const res = await api + .post>( + `resource/${resource?.resourceId}`, + { + enabled: data.enabled, + name: data.name, + subdomain: data.subdomain, + domainId: data.domainId, + proxyPort: data.proxyPort, + // ...(!resource.http && { + // enableProxy: data.enableProxy + // }) + } + ) + .catch((e) => { + toast({ + variant: "destructive", + title: t("resourceErrorUpdate"), + description: formatAxiosError( + e, + t("resourceErrorUpdateDescription") + ) + }); + }); + + if (res && res.status === 200) { + toast({ + title: t("resourceUpdated"), + description: t("resourceUpdatedDescription") + }); + + const resourceData = res.data.data; + + updateResource({ + enabled: data.enabled, + name: data.name, + subdomain: data.subdomain, + fullDomain: resourceData.fullDomain, + proxyPort: data.proxyPort, + // ...(!resource.http && { + // enableProxy: data.enableProxy + // }) + }); + + router.refresh(); + } + setSaveLoading(false); + } + + let orgs = fetchedOrgs.filter( + (org: { orgId: string; name: string }) => org.orgId !== resource.orgId + ); + + const getMoveImpact = async (targetOrgId: string): Promise => { + try { + const res = await api.get( + `/resource/${resource.resourceId}/move-impact?targetOrgId=${targetOrgId}`, + { + headers: { + 'Content-Type': 'application/json', + } + } + ); + + if (res.status === 200 && res.data?.data) { + return res.data.data; + } + + throw new Error('Invalid response format'); + } catch (error) { + console.error('Error fetching move impact:', error); + + // Fallback to basic impact data if API call fails + const selectedOrgName = orgs.find(org => org.orgId === targetOrgId)?.name || ''; + + return { + resourceId: resource.resourceId, + resourceName: resource.name, + currentOrgId: resource.orgId, + currentOrgName: 'Current Organization', + targetOrgId, + targetOrgName: selectedOrgName, + impact: { + rolePermissions: { count: 0, details: [] }, + userPermissions: { count: 0, details: [] }, + targetSites: { count: 0, details: [] }, + movingUser: null, + totalImpactedPermissions: 0, + authenticationPreserved: true, + movingUserRetainsAccess: true + } + }; + } + }; + + useEffect(() => { + if (selectedOrg) { + setMoveImpact(null); + getMoveImpact(selectedOrg).then(impact => { + setMoveImpact(impact); + }); + } else { + setMoveImpact(null); + } + }, [selectedOrg]); + + const handleMoveClick = () => { + if (!selectedOrg || !moveImpact) return; + setShowConfirmDialog(true); + }; + + const handleConfirmMove = async () => { + if (!selectedOrg) return; + + try { + setIsLoading(true); + setShowConfirmDialog(false); + + const res = await api.post( + `/resource/${resource.resourceId}/move-org`, + { orgId: selectedOrg }, + { + headers: { + 'Content-Type': 'application/json', + } + } + ); + + if (res.status !== 200) { + throw new Error("Failed to move resource"); + } + + const moveData = res.data?.data; + if (moveData?.moveImpact) { + toast({ + title: "Resource moved successfully!", + description: ( +
+

Resource moved successfully!

+

Moved to: {moveData.targetOrgName}

+

Redirecting to the new organization...

+
+ ), + }); + + } + window.location.href = `/${selectedOrg}/settings/resources`; + + } catch (err) { + console.error("Failed to move resource", err); + toast({ + variant: "destructive", + title: "Error moving resource", + description: "Please check if you have permission to move resources to the selected organization." + }); + } finally { + setIsLoading(false); + } + }; + + const selectedOrgName = orgs.find(org => org.orgId === selectedOrg)?.name || ''; + + const generateMoveWarnings = (): MoveWarning[] => { + const warnings: MoveWarning[] = []; + + if (!moveImpact) return warnings; + + const { impact } = moveImpact; + + if (impact.rolePermissions.count > 0) { + warnings.push({ + type: 'warning', + icon: , + message: `${impact.rolePermissions.count} role-based permission${impact.rolePermissions.count > 1 ? 's' : ''} will be removed` + }); + } + + if (impact.userPermissions.count > 0) { + warnings.push({ + type: 'warning', + icon: , + message: `${impact.userPermissions.count} user${impact.userPermissions.count > 1 ? 's' : ''} will lose access` + }); + } + + if (impact.targetSites.count > 0) { + warnings.push({ + type: 'warning', + icon: , + message: `${impact.targetSites.count} target connection${impact.targetSites.count > 1 ? 's' : ''} will be disconnected` + }); + } + + if (impact.totalImpactedPermissions === 0 && impact.targetSites.count === 0) { + warnings.push({ + type: 'info', + icon: , + message: 'No existing permissions or connections will be affected' + }); + } + + if (impact.movingUser) { + warnings.push({ + type: 'info', + icon: , + message: 'You will retain access to this resource' + }); + } + + return warnings; + }; + + const generatePreservedItems = () => [ + 'Authentication settings (passwords, pins, whitelists)', + 'Resource configuration and settings', + 'SSL certificates and domain settings', + 'Your personal access to the resource' + ]; + + const warnings = generateMoveWarnings(); + const preservedItems = generatePreservedItems(); + + return ( + !loadingPage && ( + <> + + + + + {t("resourceGeneral")} + + + {t("resourceGeneralDescription")} + + + + + +
+ + ( + +
+ + + form.setValue( + "enabled", + val + ) + } + /> + +
+ +
+ )} + /> + + ( + + + {t("name")} + + + + + + + )} + /> + + {!resource.http && ( + <> + ( + + + {t( + "resourcePortNumber" + )} + + + + field.onChange( + e + .target + .value + ? parseInt( + e + .target + .value + ) + : undefined + ) + } + /> + + + + {t( + "resourcePortNumberDescription" + )} + + + )} + /> + + {/* {build == "oss" && ( + ( + + + + +
+ + {t( + "resourceEnableProxy" + )} + + + {t( + "resourceEnableProxyDescription" + )} + +
+
+ )} + /> + )} */} + + )} + + {resource.http && ( +
+ +
+ + + {resourceFullDomain} + + +
+
+ )} + + +
+
+ + +
+ + + +
+ {/* Move Resource Section */} +
+ + + + + + + + + + + + Move Resource to {selectedOrgName}? + + + This will move "{resource.name}" + from {moveImpact?.currentOrgName || 'current organization'} + to {moveImpact?.targetOrgName || selectedOrgName}. + Please review the impact below. + + + +
+ {warnings.length > 0 && ( + w.type === "danger") ? "destructive" : "default" + } + > +
+ +
+ Impact Summary + +
    + {warnings.map((warning, idx) => ( +
  • + + {warning.icon} + + {warning.message} +
  • + ))} +
+
+
+
+
+ )} + + {moveImpact && ( +
+ + + + + Detailed Impact + + + {moveImpact.impact.rolePermissions.count > 0 && ( +
+

+ Roles that will lose access ( + {moveImpact.impact.rolePermissions.count}): +

+
    + {moveImpact.impact.rolePermissions.details.map( + (role, idx) => ( +
  • + + {role.roleName} +
  • + ) + )} +
+
+ )} + + {moveImpact.impact.targetSites.count > 0 && ( +
+

+ Target connections that will be disconnected ( + {moveImpact.impact.targetSites.count}): +

+
    + {moveImpact.impact.targetSites.details.map((target, idx) => ( +
  • + + + {target.siteName} ({target.ip}:{target.port}) + +
  • + ))} +
+
+ )} +
+
+
+
+ )} + + {/* Preserved Items */} +
+

+ + What will be preserved +

+
    + {preservedItems.map((item, idx) => ( +
  • + + {item} +
  • + ))} +
+
+
+ + {/* Sticky Footer */} + + + + +
+
+
+
+
+
+ +
+
+
+
+ + setEditDomainOpen(setOpen)} + > + + + Edit Domain + + Select a domain for your resource + + + + { + const selected = { + domainId: res.domainId, + subdomain: res.subdomain, + fullDomain: res.fullDomain, + baseDomain: res.baseDomain + }; + setSelectedDomain(selected); + }} + /> + + + + + + + + + + + ) + ); +} diff --git a/src/app/[orgId]/settings/resources/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[niceId]/general/page.tsx index 0f201a1a9..adff897f4 100644 --- a/src/app/[orgId]/settings/resources/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/[niceId]/general/page.tsx @@ -1,490 +1,31 @@ -"use client"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; -import { formatAxiosError } from "@app/lib/api"; -import { Button } from "@/components/ui/button"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage -} from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; -import { useResourceContext } from "@app/hooks/useResourceContext"; -import { ListSitesResponse } from "@server/routers/site"; -import { useEffect, useState } from "react"; -import { AxiosResponse } from "axios"; -import { useParams, useRouter } from "next/navigation"; -import { useForm } from "react-hook-form"; -import { toast } from "@app/hooks/useToast"; -import { - SettingsContainer, - SettingsSection, - SettingsSectionHeader, - SettingsSectionTitle, - SettingsSectionDescription, - SettingsSectionBody, - SettingsSectionForm, - SettingsSectionFooter -} from "@app/components/Settings"; -import { useOrgContext } from "@app/hooks/useOrgContext"; -import { createApiClient } from "@app/lib/api"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { Label } from "@app/components/ui/label"; -import { ListDomainsResponse } from "@server/routers/domain"; -import { UpdateResourceResponse } from "@server/routers/resource"; -import { SwitchInput } from "@app/components/SwitchInput"; -import { useTranslations } from "next-intl"; -import { Checkbox } from "@app/components/ui/checkbox"; -import { - Credenza, - CredenzaBody, - CredenzaClose, - CredenzaContent, - CredenzaDescription, - CredenzaFooter, - CredenzaHeader, - CredenzaTitle -} from "@app/components/Credenza"; -import DomainPicker from "@app/components/DomainPicker"; -import { Globe } from "lucide-react"; -import { build } from "@server/build"; -import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils"; -import { DomainRow } from "../../../../../../components/DomainsTable"; -import { toASCII, toUnicode } from "punycode"; +import { verifySession } from "@/lib/auth/verifySession"; +import { authCookieHeader } from "@/lib/api/cookies"; +import { internal } from "@/lib/api"; +import { ListUserOrgsResponse } from "@server/routers/org"; +import GeneralForm from "./GeneralForm"; -export default function GeneralForm() { - const [formKey, setFormKey] = useState(0); - const params = useParams(); - const { resource, updateResource } = useResourceContext(); - const { org } = useOrgContext(); - const router = useRouter(); - const t = useTranslations(); - const [editDomainOpen, setEditDomainOpen] = useState(false); - const { env } = useEnvContext(); +export default async function ResourceInfoWrapper() { + const user = await verifySession(); - const orgId = params.orgId; + if (!user) return
Unauthorized
; - const api = createApiClient({ env }); + let fetchedOrgs: ListUserOrgsResponse["orgs"] = []; - const [sites, setSites] = useState([]); - const [saveLoading, setSaveLoading] = useState(false); - const [transferLoading, setTransferLoading] = useState(false); - const [open, setOpen] = useState(false); - const [baseDomains, setBaseDomains] = useState< - ListDomainsResponse["domains"] - >([]); - - const [loadingPage, setLoadingPage] = useState(true); - const [resourceFullDomain, setResourceFullDomain] = useState( - `${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}` - ); - const [selectedDomain, setSelectedDomain] = useState<{ - domainId: string; - subdomain?: string; - fullDomain: string; - baseDomain: string; - } | null>(null); - - const GeneralFormSchema = z - .object({ - enabled: z.boolean(), - subdomain: z.string().optional(), - name: z.string().min(1).max(255), - domainId: z.string().optional(), - proxyPort: z.number().int().min(1).max(65535).optional(), - // enableProxy: z.boolean().optional() - }) - .refine( - (data) => { - // For non-HTTP resources, proxyPort should be defined - if (!resource.http) { - return data.proxyPort !== undefined; - } - // For HTTP resources, proxyPort should be undefined - return data.proxyPort === undefined; - }, - { - message: !resource.http - ? "Port number is required for non-HTTP resources" - : "Port number should not be set for HTTP resources", - path: ["proxyPort"] - } - ); - - type GeneralFormValues = z.infer; - - const form = useForm({ - resolver: zodResolver(GeneralFormSchema), - defaultValues: { - enabled: resource.enabled, - name: resource.name, - subdomain: resource.subdomain ? resource.subdomain : undefined, - domainId: resource.domainId || undefined, - proxyPort: resource.proxyPort || undefined, - // enableProxy: resource.enableProxy || false - }, - mode: "onChange" - }); - - useEffect(() => { - const fetchSites = async () => { - const res = await api.get>( - `/org/${orgId}/sites/` - ); - setSites(res.data.data.sites); - }; - - const fetchDomains = async () => { - const res = await api - .get< - AxiosResponse - >(`/org/${orgId}/domains/`) - .catch((e) => { - toast({ - variant: "destructive", - title: t("domainErrorFetch"), - description: formatAxiosError( - e, - t("domainErrorFetchDescription") - ) - }); - }); - - if (res?.status === 200) { - const rawDomains = res.data.data.domains as DomainRow[]; - const domains = rawDomains.map((domain) => ({ - ...domain, - baseDomain: toUnicode(domain.baseDomain), - })); - setBaseDomains(domains); - setFormKey((key) => key + 1); - } - }; - - const load = async () => { - await fetchDomains(); - await fetchSites(); - - setLoadingPage(false); - }; - - load(); - }, []); - - async function onSubmit(data: GeneralFormValues) { - setSaveLoading(true); - - const res = await api - .post>( - `resource/${resource?.resourceId}`, - { - enabled: data.enabled, - name: data.name, - subdomain: data.subdomain ? toASCII(data.subdomain) : undefined, - domainId: data.domainId, - proxyPort: data.proxyPort, - // ...(!resource.http && { - // enableProxy: data.enableProxy - // }) - } - ) - .catch((e) => { - toast({ - variant: "destructive", - title: t("resourceErrorUpdate"), - description: formatAxiosError( - e, - t("resourceErrorUpdateDescription") - ) - }); - }); - - if (res && res.status === 200) { - toast({ - title: t("resourceUpdated"), - description: t("resourceUpdatedDescription") - }); - - const resource = res.data.data; - - updateResource({ - enabled: data.enabled, - name: data.name, - subdomain: data.subdomain, - fullDomain: resource.fullDomain, - proxyPort: data.proxyPort, - // ...(!resource.http && { - // enableProxy: data.enableProxy - // }) - }); - - router.refresh(); + try { + const res = await internal.get(`/user/${user.userId}/orgs`, await authCookieHeader()); + if (res?.data?.data?.orgs) { + fetchedOrgs = res.data.data.orgs; } - setSaveLoading(false); + } catch (err) { + console.error("Failed to fetch user orgs:", err); } - return ( - !loadingPage && ( - <> - - - - - {t("resourceGeneral")} - - - {t("resourceGeneralDescription")} - - - - - -
- - ( - -
- - - form.setValue( - "enabled", - val - ) - } - /> - -
- -
- )} - /> - - ( - - - {t("name")} - - - - - - - )} - /> - - {!resource.http && ( - <> - ( - - - {t( - "resourcePortNumber" - )} - - - - field.onChange( - e - .target - .value - ? parseInt( - e - .target - .value - ) - : undefined - ) - } - /> - - - - {t( - "resourcePortNumberDescription" - )} - - - )} - /> - {/* {build == "oss" && ( - ( - - - - -
- - {t( - "resourceEnableProxy" - )} - - - {t( - "resourceEnableProxyDescription" - )} - -
-
- )} - /> - )} */} - - )} - - {resource.http && ( -
- -
- - - {resourceFullDomain} - - -
-
- )} - - -
-
- - - - -
-
- - setEditDomainOpen(setOpen)} - > - - - Edit Domain - - Select a domain for your resource - - - - { - const selected = { - domainId: res.domainId, - subdomain: res.subdomain, - fullDomain: res.fullDomain, - baseDomain: res.baseDomain - }; - setSelectedDomain(selected); - }} - /> - - - - - - - - - - - ) + return ( + <> + + ); } diff --git a/src/app/[orgId]/settings/resources/[niceId]/layout.tsx b/src/app/[orgId]/settings/resources/[niceId]/layout.tsx index 5fd19967c..180b3ab9e 100644 --- a/src/app/[orgId]/settings/resources/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/[niceId]/layout.tsx @@ -15,7 +15,7 @@ import { cache } from "react"; import ResourceInfoBox from "../../../../../components/ResourceInfoBox"; import { GetSiteResponse } from "@server/routers/site"; import { getTranslations } from 'next-intl/server'; -import ResourceInfoWrapper from "./ResourceInfoWrapper"; +import ResourceInfoBox from "./ResourceInfoBox"; interface ResourceLayoutProps { children: React.ReactNode; @@ -110,7 +110,7 @@ export default async function ResourceLayout(props: ResourceLayoutProps) { authInfo={authInfo} >
- + {children} From e68e2d452e67f7c1b08496d1ba27d26791d77261 Mon Sep 17 00:00:00 2001 From: Pallavi Date: Mon, 1 Sep 2025 04:16:54 +0530 Subject: [PATCH 10/16] update schema to make siteId nullable --- server/db/sqlite/schema.ts | 3 +- server/routers/resource/getMoveImpact.ts | 18 ++++++----- server/setup/scriptsSqlite/1.10.0.ts | 16 +++++----- .../[niceId]/general/GeneralForm.tsx | 31 ++++++++++--------- 4 files changed, 36 insertions(+), 32 deletions(-) diff --git a/server/db/sqlite/schema.ts b/server/db/sqlite/schema.ts index 4db6fd7a0..15dbed1d5 100644 --- a/server/db/sqlite/schema.ts +++ b/server/db/sqlite/schema.ts @@ -120,8 +120,7 @@ export const targets = sqliteTable("targets", { siteId: integer("siteId") .references(() => sites.siteId, { onDelete: "cascade" - }) - .notNull(), + }), ip: text("ip").notNull(), method: text("method"), port: integer("port").notNull(), diff --git a/server/routers/resource/getMoveImpact.ts b/server/routers/resource/getMoveImpact.ts index baef8f02f..802cad38a 100644 --- a/server/routers/resource/getMoveImpact.ts +++ b/server/routers/resource/getMoveImpact.ts @@ -104,7 +104,7 @@ registry.registerPath({ targetId: { type: "number" }, ip: { type: "string" }, port: { type: "number" }, - willBeRemoved: { type: "boolean" } + willBeDisconnected: { type: "boolean" } } } } @@ -240,16 +240,18 @@ export async function getMoveImpact( .leftJoin(sites, eq(targets.siteId, sites.siteId)) .where(eq(targets.resourceId, resourceId)); - // Analyze which targets will be affected - const affectedTargetSites = resourceTargets - .filter(target => target.siteId && target.siteOrgId !== targetOrgId) + // Since siteId is now nullable, targets with cross-org sites will be disconnected + // (siteId set to null) rather than removed entirely. This preserves the target + // but removes the site association. + const targetsWithCrossOrgSites = resourceTargets + .filter(target => target.siteId && target.siteOrgId && target.siteOrgId !== targetOrgId) .map(target => ({ siteId: target.siteId!, siteName: target.siteName || 'Unknown', targetId: target.targetId, ip: target.ip, port: target.port, - willBeRemoved: true // Sites from different orgs will lose connection + willBeDisconnected: true // Site association will be removed, but target preserved })); // Separate moving user from others who will lose access @@ -284,8 +286,8 @@ export async function getMoveImpact( })) }, targetSites: { - count: affectedTargetSites.length, - details: affectedTargetSites + count: targetsWithCrossOrgSites.length, + details: targetsWithCrossOrgSites }, movingUser: movingUserPermission ? { userId: movingUserPermission.userId, @@ -307,7 +309,7 @@ export async function getMoveImpact( userId: user.userId, rolePermissionsAffected: rolePermissionsQuery.length, userPermissionsAffected: otherUserPermissions.length, - targetSitesAffected: affectedTargetSites.length, + targetSitesAffected: targetsWithCrossOrgSites.length, totalImpact: totalImpactedPermissions }); diff --git a/server/setup/scriptsSqlite/1.10.0.ts b/server/setup/scriptsSqlite/1.10.0.ts index f5f6c3a31..e4c5378a9 100644 --- a/server/setup/scriptsSqlite/1.10.0.ts +++ b/server/setup/scriptsSqlite/1.10.0.ts @@ -12,14 +12,14 @@ export default async function migration() { const db = new Database(location); const resourceSiteMap = new Map(); - const firstSiteId: number = 1; + const firstSiteId: number = 1; try { - const resources = db - .prepare( - "SELECT resourceId FROM resources WHERE siteId IS NOT NULL" - ) - .all() as Array<{ resourceId: number; }>; + const resources = db + .prepare( + "SELECT resourceId FROM resources WHERE siteId IS NOT NULL" + ) + .all() as Array<{ resourceId: number; }>; db.transaction(() => { db.exec(` @@ -70,7 +70,7 @@ export const names = JSON.parse(readFileSync(file, "utf-8")); export function generateName(): string { const name = ( names.descriptors[ - Math.floor(Math.random() * names.descriptors.length) + Math.floor(Math.random() * names.descriptors.length) ] + "-" + names.animals[Math.floor(Math.random() * names.animals.length)] @@ -80,4 +80,4 @@ export function generateName(): string { // clean out any non-alphanumeric characters except for dashes return name.replace(/[^a-z0-9-]/g, ""); -} +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/resources/[niceId]/general/GeneralForm.tsx b/src/app/[orgId]/settings/resources/[niceId]/general/GeneralForm.tsx index f2d70f010..f3c1944c2 100644 --- a/src/app/[orgId]/settings/resources/[niceId]/general/GeneralForm.tsx +++ b/src/app/[orgId]/settings/resources/[niceId]/general/GeneralForm.tsx @@ -399,7 +399,6 @@ export default function GeneralForm({ fetchedOrgs }: GeneralFormProps) { title: "Resource moved successfully!", description: (
-

Resource moved successfully!

Moved to: {moveData.targetOrgName}

Redirecting to the new organization...

@@ -669,7 +668,7 @@ export default function GeneralForm({ fetchedOrgs }: GeneralFormProps) { -
+
@@ -779,18 +778,15 @@ export default function GeneralForm({ fetchedOrgs }: GeneralFormProps) { {moveImpact.impact.rolePermissions.count > 0 && (

- Roles that will lose access ( - {moveImpact.impact.rolePermissions.count}): + Roles that will lose access ({moveImpact.impact.rolePermissions.count}):

    - {moveImpact.impact.rolePermissions.details.map( - (role, idx) => ( -
  • - - {role.roleName} -
  • - ) - )} + {moveImpact.impact.rolePermissions.details.map((role, idx) => ( +
  • + + {role.roleName} +
  • + ))}
)} @@ -798,8 +794,7 @@ export default function GeneralForm({ fetchedOrgs }: GeneralFormProps) { {moveImpact.impact.targetSites.count > 0 && (

- Target connections that will be disconnected ( - {moveImpact.impact.targetSites.count}): + Target connections that will be disconnected ({moveImpact.impact.targetSites.count}):

    {moveImpact.impact.targetSites.details.map((target, idx) => ( @@ -813,7 +808,15 @@ export default function GeneralForm({ fetchedOrgs }: GeneralFormProps) {
)} + + {moveImpact.impact.rolePermissions.count === 0 && + moveImpact.impact.targetSites.count === 0 && ( +

+ No roles or connections will be impacted by this move. +

+ )} +
From bca1601e4ed5e97a7ee2573b21aba934a4021df4 Mon Sep 17 00:00:00 2001 From: Pallavi Date: Tue, 2 Sep 2025 01:07:42 +0530 Subject: [PATCH 11/16] sync GeneralForm with updated page --- .../[niceId]/general/GeneralForm.tsx | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/app/[orgId]/settings/resources/[niceId]/general/GeneralForm.tsx b/src/app/[orgId]/settings/resources/[niceId]/general/GeneralForm.tsx index f3c1944c2..5a70f4d00 100644 --- a/src/app/[orgId]/settings/resources/[niceId]/general/GeneralForm.tsx +++ b/src/app/[orgId]/settings/resources/[niceId]/general/GeneralForm.tsx @@ -53,8 +53,6 @@ import { import DomainPicker from "@app/components/DomainPicker"; import { InfoIcon, - ShieldCheck, - ShieldOff, AlertTriangle, Users, Shield, @@ -66,11 +64,13 @@ import { } from "lucide-react"; import { build } from "@server/build"; import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils"; +import { DomainRow } from "../../../domains/DomainsTable"; import { InfoSection, InfoSectionContent } from "@app/components/InfoSection"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@app/components/ui/dialog"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@app/components/ui/accordion"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@app/components/ui/select"; +import { toASCII, toUnicode } from "punycode"; type ResponseOrg = { orgId: string; @@ -161,7 +161,7 @@ export default function GeneralForm({ fetchedOrgs }: GeneralFormProps) { const [loadingPage, setLoadingPage] = useState(true); const [resourceFullDomain, setResourceFullDomain] = useState( - `${resource.ssl ? "https" : "http"}://${resource.fullDomain}` + `${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}` ); const [selectedDomain, setSelectedDomain] = useState<{ domainId: string; @@ -242,7 +242,11 @@ export default function GeneralForm({ fetchedOrgs }: GeneralFormProps) { }); if (res?.status === 200) { - const domains = res.data.data.domains; + const rawDomains = res.data.data.domains as DomainRow[]; + const domains = rawDomains.map((domain) => ({ + ...domain, + baseDomain: toUnicode(domain.baseDomain), + })); setBaseDomains(domains); setFormKey((key) => key + 1); } @@ -267,7 +271,7 @@ export default function GeneralForm({ fetchedOrgs }: GeneralFormProps) { { enabled: data.enabled, name: data.name, - subdomain: data.subdomain, + subdomain: data.subdomain ? toASCII(data.subdomain) : undefined, domainId: data.domainId, proxyPort: data.proxyPort, // ...(!resource.http && { @@ -292,13 +296,13 @@ export default function GeneralForm({ fetchedOrgs }: GeneralFormProps) { description: t("resourceUpdatedDescription") }); - const resourceData = res.data.data; + const resource = res.data.data; updateResource({ enabled: data.enabled, name: data.name, subdomain: data.subdomain, - fullDomain: resourceData.fullDomain, + fullDomain: resource.fullDomain, proxyPort: data.proxyPort, // ...(!resource.http && { // enableProxy: data.enableProxy @@ -812,7 +816,7 @@ export default function GeneralForm({ fetchedOrgs }: GeneralFormProps) { {moveImpact.impact.rolePermissions.count === 0 && moveImpact.impact.targetSites.count === 0 && (

- No roles or connections will be impacted by this move. + No roles or connections will be impacted by this move.

)} @@ -933,7 +937,6 @@ export default function GeneralForm({ fetchedOrgs }: GeneralFormProps) { setEditDomainOpen(false); toast({ - title: "Domain sanitized", description: `Final domain: ${sanitizedFullDomain}`, }); } From d227e9037128ab9a7aca96cab1c27a43687bbbb6 Mon Sep 17 00:00:00 2001 From: Pallavi Date: Tue, 2 Sep 2025 01:59:12 +0530 Subject: [PATCH 12/16] add pg migration --- server/db/pg/schema.ts | 3 +-- server/setup/scriptsPg/1.10.0.ts | 2 +- .../resources/[niceId]/general/GeneralForm.tsx | 10 +++++----- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/server/db/pg/schema.ts b/server/db/pg/schema.ts index 1e634cc99..fd37d0c63 100644 --- a/server/db/pg/schema.ts +++ b/server/db/pg/schema.ts @@ -108,8 +108,7 @@ export const targets = pgTable("targets", { siteId: integer("siteId") .references(() => sites.siteId, { onDelete: "cascade" - }) - .notNull(), + }), ip: varchar("ip").notNull(), method: varchar("method"), port: integer("port").notNull(), diff --git a/server/setup/scriptsPg/1.10.0.ts b/server/setup/scriptsPg/1.10.0.ts index f33906cff..c28752953 100644 --- a/server/setup/scriptsPg/1.10.0.ts +++ b/server/setup/scriptsPg/1.10.0.ts @@ -78,4 +78,4 @@ export function generateName(): string { // clean out any non-alphanumeric characters except for dashes return name.replace(/[^a-z0-9-]/g, ""); -} +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/resources/[niceId]/general/GeneralForm.tsx b/src/app/[orgId]/settings/resources/[niceId]/general/GeneralForm.tsx index 5a70f4d00..a7d693490 100644 --- a/src/app/[orgId]/settings/resources/[niceId]/general/GeneralForm.tsx +++ b/src/app/[orgId]/settings/resources/[niceId]/general/GeneralForm.tsx @@ -453,7 +453,7 @@ export default function GeneralForm({ fetchedOrgs }: GeneralFormProps) { warnings.push({ type: 'warning', icon: , - message: `${impact.targetSites.count} target connection${impact.targetSites.count > 1 ? 's' : ''} will be disconnected` + message: `${impact.targetSites.count} target connection${impact.targetSites.count > 1 ? 's' : ''} will be Moved to ${moveImpact?.targetOrgName || selectedOrgName}` }); } @@ -685,7 +685,7 @@ export default function GeneralForm({ fetchedOrgs }: GeneralFormProps) { placeholder={ orgs.length === 0 ? "No other organizations" - : "Select target organization" + : "Select organization" } /> @@ -727,8 +727,8 @@ export default function GeneralForm({ fetchedOrgs }: GeneralFormProps) { Move Resource to {selectedOrgName}? - This will move "{resource.name}" - from {moveImpact?.currentOrgName || 'current organization'} + This will move "{resource.name}" + from {moveImpact?.currentOrgName || 'current organization'} to {moveImpact?.targetOrgName || selectedOrgName}. Please review the impact below. @@ -798,7 +798,7 @@ export default function GeneralForm({ fetchedOrgs }: GeneralFormProps) { {moveImpact.impact.targetSites.count > 0 && (

- Target connections that will be disconnected ({moveImpact.impact.targetSites.count}): + Target connections will be Moved ({moveImpact.impact.targetSites.count}):

    {moveImpact.impact.targetSites.details.map((target, idx) => ( From e27c264bb846a5378f00615ba0a7ed4f4bfc5c03 Mon Sep 17 00:00:00 2001 From: Pallavi Date: Tue, 2 Sep 2025 02:11:14 +0530 Subject: [PATCH 13/16] fix lint issue --- server/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/server/index.ts b/server/index.ts index 746de7b91..0d900b8a9 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,6 +1,5 @@ #! /usr/bin/env node import "./extendZod.ts"; - import { runSetupFunctions } from "./setup"; import { createApiServer } from "./apiServer"; import { createNextServer } from "./nextServer"; From 41f12249a467829f9e877078005291125c13f0a5 Mon Sep 17 00:00:00 2001 From: Pallavi Date: Tue, 2 Sep 2025 03:22:38 +0530 Subject: [PATCH 14/16] improved impact message --- .../settings/resources/[niceId]/general/GeneralForm.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/[orgId]/settings/resources/[niceId]/general/GeneralForm.tsx b/src/app/[orgId]/settings/resources/[niceId]/general/GeneralForm.tsx index a7d693490..25bcaf4ab 100644 --- a/src/app/[orgId]/settings/resources/[niceId]/general/GeneralForm.tsx +++ b/src/app/[orgId]/settings/resources/[niceId]/general/GeneralForm.tsx @@ -453,7 +453,7 @@ export default function GeneralForm({ fetchedOrgs }: GeneralFormProps) { warnings.push({ type: 'warning', icon: , - message: `${impact.targetSites.count} target connection${impact.targetSites.count > 1 ? 's' : ''} will be Moved to ${moveImpact?.targetOrgName || selectedOrgName}` + message: `${impact.targetSites.count} target connection${impact.targetSites.count > 1 ? 's' : ''} will lose its associated site.` }); } @@ -798,7 +798,7 @@ export default function GeneralForm({ fetchedOrgs }: GeneralFormProps) { {moveImpact.impact.targetSites.count > 0 && (

    - Target connections will be Moved ({moveImpact.impact.targetSites.count}): + Target connections will lose its associated site ({moveImpact.impact.targetSites.count}):

      {moveImpact.impact.targetSites.details.map((target, idx) => ( From 3708531fddfcb1c41fb9881c095e1c8bb68042b9 Mon Sep 17 00:00:00 2001 From: Pallavi Date: Fri, 12 Sep 2025 22:59:39 +0530 Subject: [PATCH 15/16] fix duplicate import --- .../settings/resources/[niceId]/general/GeneralForm.tsx | 8 ++------ src/app/[orgId]/settings/resources/[niceId]/layout.tsx | 2 +- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/app/[orgId]/settings/resources/[niceId]/general/GeneralForm.tsx b/src/app/[orgId]/settings/resources/[niceId]/general/GeneralForm.tsx index 25bcaf4ab..027c6249f 100644 --- a/src/app/[orgId]/settings/resources/[niceId]/general/GeneralForm.tsx +++ b/src/app/[orgId]/settings/resources/[niceId]/general/GeneralForm.tsx @@ -64,7 +64,7 @@ import { } from "lucide-react"; import { build } from "@server/build"; import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils"; -import { DomainRow } from "../../../domains/DomainsTable"; +import { DomainRow } from "@app/components/DomainsTable"; import { InfoSection, InfoSectionContent } from "@app/components/InfoSection"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@app/components/ui/dialog"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@app/components/ui/accordion"; @@ -930,15 +930,11 @@ export default function GeneralForm({ fetchedOrgs }: GeneralFormProps) { ? `${sanitizedSubdomain}.${selectedDomain.baseDomain}` : selectedDomain.baseDomain; - setResourceFullDomain(sanitizedFullDomain); + setResourceFullDomain(`${resource.ssl ? "https" : "http"}://${sanitizedFullDomain}`); form.setValue("domainId", selectedDomain.domainId); form.setValue("subdomain", sanitizedSubdomain); setEditDomainOpen(false); - - toast({ - description: `Final domain: ${sanitizedFullDomain}`, - }); } }} > diff --git a/src/app/[orgId]/settings/resources/[niceId]/layout.tsx b/src/app/[orgId]/settings/resources/[niceId]/layout.tsx index 180b3ab9e..dd90a84a8 100644 --- a/src/app/[orgId]/settings/resources/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/[niceId]/layout.tsx @@ -15,7 +15,7 @@ import { cache } from "react"; import ResourceInfoBox from "../../../../../components/ResourceInfoBox"; import { GetSiteResponse } from "@server/routers/site"; import { getTranslations } from 'next-intl/server'; -import ResourceInfoBox from "./ResourceInfoBox"; + interface ResourceLayoutProps { children: React.ReactNode; From d61b975180448ac19cf7fd49c3efdc1014b292c3 Mon Sep 17 00:00:00 2001 From: Pallavi Date: Fri, 12 Sep 2025 23:04:50 +0530 Subject: [PATCH 16/16] change migration file name --- server/setup/migrationsPg.ts | 2 ++ server/setup/migrationsSqlite.ts | 2 ++ server/setup/scriptsPg/1.11.0.ts | 25 +++++++++++++ server/setup/scriptsSqlite/1.11.0.ts | 54 ++++++++++++++++++++++++++++ 4 files changed, 83 insertions(+) create mode 100644 server/setup/scriptsPg/1.11.0.ts create mode 100644 server/setup/scriptsSqlite/1.11.0.ts diff --git a/server/setup/migrationsPg.ts b/server/setup/migrationsPg.ts index c5950e1db..035f1f331 100644 --- a/server/setup/migrationsPg.ts +++ b/server/setup/migrationsPg.ts @@ -10,6 +10,7 @@ import m2 from "./scriptsPg/1.7.0"; import m3 from "./scriptsPg/1.8.0"; import m4 from "./scriptsPg/1.9.0"; import m5 from "./scriptsPg/1.10.0"; +import m6 from "./scriptsPg/1.11.0"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -21,6 +22,7 @@ const migrations = [ { version: "1.8.0", run: m3 }, { version: "1.9.0", run: m4 }, { version: "1.10.0", run: m5 }, + { version: "1.11.0", run: m6 } // Add new migrations here as they are created ] as { version: string; diff --git a/server/setup/migrationsSqlite.ts b/server/setup/migrationsSqlite.ts index 79a7d0ab4..4f81a9762 100644 --- a/server/setup/migrationsSqlite.ts +++ b/server/setup/migrationsSqlite.ts @@ -27,6 +27,7 @@ import m22 from "./scriptsSqlite/1.7.0"; import m23 from "./scriptsSqlite/1.8.0"; import m24 from "./scriptsSqlite/1.9.0"; import m25 from "./scriptsSqlite/1.10.0"; +import m26 from "./scriptsSqlite/1.11.0"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -53,6 +54,7 @@ const migrations = [ { version: "1.8.0", run: m23 }, { version: "1.9.0", run: m24 }, { version: "1.10.0", run: m25 }, + { version: "1.11.0", run: m26 } // Add new migrations here as they are created ] as const; diff --git a/server/setup/scriptsPg/1.11.0.ts b/server/setup/scriptsPg/1.11.0.ts new file mode 100644 index 000000000..db8de7a02 --- /dev/null +++ b/server/setup/scriptsPg/1.11.0.ts @@ -0,0 +1,25 @@ +import { db } from "@server/db/pg/driver"; +import { sql } from "drizzle-orm"; + +const version = "1.11.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + try { + await db.execute(sql` + BEGIN; + -- Make siteId nullable in targets table + ALTER TABLE "targets" ALTER COLUMN "siteId" DROP NOT NULL; + COMMIT; + `); + + console.log(`Migrated targets table to make siteId nullable`); + } catch (e) { + console.log("Unable to migrate targets table"); + console.log(e); + throw e; + } + + console.log(`${version} migration complete`); +} diff --git a/server/setup/scriptsSqlite/1.11.0.ts b/server/setup/scriptsSqlite/1.11.0.ts new file mode 100644 index 000000000..48874c0d4 --- /dev/null +++ b/server/setup/scriptsSqlite/1.11.0.ts @@ -0,0 +1,54 @@ +import { APP_PATH } from "@server/lib/consts"; +import Database from "better-sqlite3"; +import path from "path"; + +const version = "1.11.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + const location = path.join(APP_PATH, "db", "db.sqlite"); + const db = new Database(location); + + try { + db.pragma("foreign_keys = OFF"); + db.transaction(() => { + // 1. Rename old table + db.exec(` + ALTER TABLE targets RENAME TO targets_old; + `); + + // 2. Recreate table with siteId nullable + db.exec(` + CREATE TABLE targets ( + targetId INTEGER PRIMARY KEY AUTOINCREMENT, + resourceId INTEGER NOT NULL REFERENCES resources(resourceId) ON DELETE CASCADE, + siteId INTEGER REFERENCES sites(siteId) ON DELETE CASCADE, -- now nullable + ip TEXT NOT NULL, + method TEXT, + port INTEGER NOT NULL, + internalPort INTEGER, + enabled INTEGER NOT NULL DEFAULT 1 + ); + `); + + // 3. Copy data over + db.exec(` + INSERT INTO targets (targetId, resourceId, siteId, ip, method, port, internalPort, enabled) + SELECT targetId, resourceId, siteId, ip, method, port, internalPort, enabled + FROM targets_old; + `); + + // 4. Drop old table + db.exec(`DROP TABLE targets_old;`); + })(); + + db.pragma("foreign_keys = ON"); + console.log(`Migrated targets table to make siteId nullable`); + } catch (e) { + console.log("Unable to migrate targets table"); + console.log(e); + } + + console.log(`${version} migration complete`); +} \ No newline at end of file