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", 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/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/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/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"; diff --git a/server/routers/external.ts b/server/routers/external.ts index b851eda8e..c01606ffb 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -504,6 +504,21 @@ authenticated.get( resource.getResourceWhitelist ); +authenticated.post( + `/resource/:resourceId/move-org`, + verifyResourceAccess, + verifyUserHasAction(ActionsEnum.updateResource), + resource.moveResourceToOrg +); + +authenticated.get( + `/resource/:resourceId/move-impact`, + verifyResourceAccess, + verifyUserHasAction(ActionsEnum.updateResource), + resource.getMoveImpact +); + + authenticated.post( `/resource/:resourceId/access-token`, verifyResourceAccess, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 69bdbb42b..0b568fcd2 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -414,6 +414,22 @@ authenticated.get( resource.getResourceWhitelist ); + +authenticated.post( + `/resource/:resourceId/move-org`, + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.updateResource), + resource.moveResourceToOrg +); + +authenticated.get( + `/resource/:resourceId/move-impact`, + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.updateResource), + resource.getMoveImpact +); + + authenticated.post( `/resource/:resourceId/access-token`, verifyApiKeyResourceAccess, diff --git a/server/routers/resource/getMoveImpact.ts b/server/routers/resource/getMoveImpact.ts new file mode 100644 index 000000000..802cad38a --- /dev/null +++ b/server/routers/resource/getMoveImpact.ts @@ -0,0 +1,330 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { + resources, + orgs, + userOrgs, + userResources, + roleResources, + roles, + users, + targets, + sites +} from "@server/db"; +import { eq, and, inArray } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { 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" } + } + } + } + } + }, + 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" }, + willBeDisconnected: { type: "boolean" } + } + } + } + } + }, + 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(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)); + + // 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, + willBeDisconnected: true // Site association will be removed, but target preserved + })); + + // 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 || '' + })) + }, + targetSites: { + count: targetsWithCrossOrgSites.length, + details: targetsWithCrossOrgSites + }, + 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, + targetSitesAffected: targetsWithCrossOrgSites.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 1a2e5c2d5..8f1961bab 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -22,3 +22,5 @@ export * from "./deleteResourceRule"; export * from "./listResourceRules"; export * from "./updateResourceRule"; export * from "./getUserResources"; +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 new file mode 100644 index 000000000..5b286f3be --- /dev/null +++ b/server/routers/resource/moveResourceToOrg.ts @@ -0,0 +1,532 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { + resources, + orgs, + userOrgs, + userResources, + roleResources, + targets, + sites +} 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"; +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: {} +}); + +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, + movingUserId: string, + tx: any +): Promise { + try { + 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 + const deletedUserResources = await tx + .delete(userResources) + .where(and( + eq(userResources.resourceId, resourceId), + ne(userResources.userId, movingUserId) + )) + .returning(); + + 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 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; + } +} + +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) { + 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())); + } + + 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) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError(HttpCode.NOT_FOUND, `Resource with ID ${resourceId} not found`) + ); + } + + // 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 === targetOrgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, `Resource is already in this organization`) + ); + } + + 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`) + ); + } + + logger.info(`Starting resource move: ${resourceId} from ${resource.orgId} to ${targetOrgId}`); + + // 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}`); + + const [updatedResource] = await tx + .update(resources) + .set({ orgId: targetOrgId }) + .where(eq(resources.resourceId, resourceId)) + .returning(); + + if (!updatedResource) { + throw new Error("Failed to update resource orgId"); + } + + logger.info(`Resource ${resourceId} orgId updated to ${targetOrgId} within transaction`); + + 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: targetOrgId, + movedByUserId: user.userId, + impactSummary: { + 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: 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, + message: "Resource successfully moved to new organization", + status: HttpCode.OK, + }); + + } 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, + "Failed to move resource. The resource remains in its original organization." + ) + ); + } +} \ No newline at end of file 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.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/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.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/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 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..027c6249f --- /dev/null +++ b/src/app/[orgId]/settings/resources/[niceId]/general/GeneralForm.tsx @@ -0,0 +1,949 @@ +"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, + AlertTriangle, + Users, + Shield, + Check, + ArrowRight, + Unplug, + RotateCw, + Globe +} from "lucide-react"; +import { build } from "@server/build"; +import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils"; +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"; +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; + 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"}://${toUnicode(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 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(); + } + 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: ( +
+

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 lose its associated site.` + }); + } + + 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 will lose its associated site ({moveImpact.impact.targetSites.count}): +

+
    + {moveImpact.impact.targetSites.details.map((target, idx) => ( +
  • + + + {target.siteName} ({target.ip}:{target.port}) + +
  • + ))} +
+
+ )} + + {moveImpact.impact.rolePermissions.count === 0 && + moveImpact.impact.targetSites.count === 0 && ( +

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

+ )} +
+ +
+
+
+ )} + + {/* 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 3f8425cee..dd90a84a8 100644 --- a/src/app/[orgId]/settings/resources/[niceId]/layout.tsx +++ b/src/app/[orgId]/settings/resources/[niceId]/layout.tsx @@ -16,6 +16,7 @@ import ResourceInfoBox from "../../../../../components/ResourceInfoBox"; import { GetSiteResponse } from "@server/routers/site"; import { getTranslations } from 'next-intl/server'; + interface ResourceLayoutProps { children: React.ReactNode; params: Promise<{ niceId: string; orgId: string }>; 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 diff --git a/src/components/ui/accordion.tsx b/src/components/ui/accordion.tsx new file mode 100644 index 000000000..1bbf3aafd --- /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 };