-
Notifications
You must be signed in to change notification settings - Fork 3.8k
feat(admin): move a workspace between organizations #7243
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -59,13 +59,114 @@ const adminDashboardWorkspaceCandidateSchema = z.object({ | |
| ownerEmail: z.string(), | ||
| workspaceMode: z.string(), | ||
| organizationId: z.string().nullable(), | ||
| /** Name of the organization that currently owns the workspace, if any. */ | ||
| organizationName: z.string().nullable(), | ||
| billedAccountUserId: z.string(), | ||
| /** Archived workspaces are movable; the flag lets admin UIs label them. */ | ||
| archived: z.boolean(), | ||
| /** | ||
| * Non-null when the workspace cannot be moved. Ineligible rows are returned | ||
| * rather than filtered out so the admin learns the workspace exists and why | ||
| * it is stuck, instead of an empty result they cannot act on. | ||
| */ | ||
| ineligibleReason: z.string().nullable().optional(), | ||
| }) | ||
|
|
||
| /** Usage split so the UI can separate what leaves from what breaks behind. */ | ||
| const adminDashboardCustomBlockUsageSchema = z.object({ | ||
| live: z.number().int().min(0), | ||
| deployed: z.number().int().min(0), | ||
| }) | ||
|
|
||
| const adminDashboardWorkspaceSourceImpactSchema = z.object({ | ||
| unpublishedCustomBlocks: z | ||
|
mzxchandra marked this conversation as resolved.
|
||
| .array( | ||
| z.object({ | ||
| id: z.string(), | ||
| type: z.string(), | ||
| name: z.string(), | ||
| movingWorkspaceUsage: adminDashboardCustomBlockUsageSchema, | ||
| sourceOrgElsewhereUsage: adminDashboardCustomBlockUsageSchema, | ||
| }) | ||
| ) | ||
| .max(500), | ||
| /** Non-empty means the move is blocked until the fork is disconnected. */ | ||
| blockingForkEdges: z | ||
|
mzxchandra marked this conversation as resolved.
mzxchandra marked this conversation as resolved.
|
||
| .array( | ||
| z.object({ | ||
| workspaceId: z.string(), | ||
| name: z.string(), | ||
| organizationId: z.string().nullable(), | ||
| direction: z.enum(['parent', 'child']), | ||
| }) | ||
| ) | ||
| .max(500), | ||
| detachedPermissionGroups: z | ||
| .array(z.object({ permissionGroupId: z.string(), name: z.string() })) | ||
| .max(500), | ||
| strippedRetentionRules: z.object({ | ||
| piiRedactionRules: z.number().int().min(0), | ||
| retentionOverrides: z.number().int().min(0), | ||
| }), | ||
| retainedCollaboratorCaps: z | ||
| .array( | ||
| z.object({ | ||
| userId: z.string(), | ||
| email: z.string(), | ||
| sourceOrgLimitDollars: z.number().nullable(), | ||
| }) | ||
| ) | ||
| .max(1000), | ||
| brandingChanges: z.boolean(), | ||
| /** | ||
| * Rows omitted to keep the response inside the array bounds above. Non-null | ||
| * means the lists are incomplete and the notice says so. | ||
| */ | ||
| truncated: z | ||
| .object({ | ||
| customBlocks: z.number().int().min(0), | ||
| permissionGroups: z.number().int().min(0), | ||
| collaboratorCaps: z.number().int().min(0), | ||
| forkEdges: z.number().int().min(0), | ||
| credentials: z.number().int().min(0), | ||
| environmentVariableKeys: z.number().int().min(0), | ||
| }) | ||
| .nullable(), | ||
| }) | ||
|
|
||
| /** Secrets that travel with the workspace. Never carries secret material. */ | ||
| const adminDashboardWorkspaceCredentialsSchema = z.object({ | ||
| items: z | ||
| .array( | ||
| z.object({ | ||
| id: z.string(), | ||
| displayName: z.string(), | ||
| type: z.string(), | ||
| backedBySourceOrgMember: z.boolean(), | ||
| }) | ||
| ) | ||
| .max(1000), | ||
| credentialGroupCount: z.number().int().min(0), | ||
| /** Variable names only — values are never sent. */ | ||
| environmentVariableKeys: z.array(z.string()).max(1000), | ||
|
mzxchandra marked this conversation as resolved.
|
||
| byokKeyCount: z.number().int().min(0), | ||
| /** Rows omitted to stay within the bounds above. */ | ||
| truncatedCredentials: z.number().int().min(0), | ||
|
mzxchandra marked this conversation as resolved.
|
||
| truncatedEnvironmentVariableKeys: z.number().int().min(0), | ||
| }) | ||
|
|
||
| const adminDashboardWorkspacePreflightSchema = z.object({ | ||
| workspace: adminDashboardWorkspaceCandidateSchema, | ||
| /** `null` for a personal or grandfathered source. */ | ||
| sourceOrganization: z | ||
| .object({ | ||
| id: z.string(), | ||
| name: z.string(), | ||
| ownerId: z.string().nullable(), | ||
| ownerName: z.string().nullable(), | ||
| ownerEmail: z.string().nullable(), | ||
| }) | ||
| .nullable(), | ||
| destinationOrganization: z.object({ | ||
| id: z.string(), | ||
| name: z.string(), | ||
|
|
@@ -80,6 +181,8 @@ const adminDashboardWorkspacePreflightSchema = z.object({ | |
| email: z.string(), | ||
| permission: z.enum(['admin', 'write', 'read']), | ||
| organizationMember: z.boolean(), | ||
| /** Retains access after the move, as an external collaborator. */ | ||
| sourceOrganizationMember: z.boolean(), | ||
| }) | ||
| ), | ||
| invitations: z.array( | ||
|
|
@@ -91,6 +194,17 @@ const adminDashboardWorkspacePreflightSchema = z.object({ | |
| workspaceGrantCount: z.number().int().min(1), | ||
| }) | ||
| ), | ||
| sourceOrganizationImpact: adminDashboardWorkspaceSourceImpactSchema, | ||
| credentials: adminDashboardWorkspaceCredentialsSchema, | ||
| entitlements: z.object({ | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: After a completed move is reloaded through the move-operation endpoint, these required entitlement fields report both organizations as non-Enterprise unconditionally. Persist or reconstruct the actual entitlement values before exposing them in the operation response. Prompt for AI agents |
||
| sourceIsEnterprise: z.boolean(), | ||
| destinationIsEnterprise: z.boolean(), | ||
| capabilitiesLost: z.array(z.string()).max(50), | ||
| }), | ||
| /** Non-empty means the move will throw; the UI must not offer a confirm. */ | ||
| blockers: z.array(z.string()).max(20), | ||
| /** Advisory consequences worth reading, which never block. */ | ||
| notices: z.array(z.string()).max(20), | ||
| warning: z.string().nullable(), | ||
| }) | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -431,6 +431,21 @@ export async function isEnterpriseOrgAdminOrOwner(userId: string): Promise<boole | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * Whether an organization's entitlement actually comes from its subscription | ||
| * row, as opposed to being granted by deployment configuration. | ||
| * | ||
| * `resolveOrganizationEnterprisePlan` short-circuits to `true` in two modes — | ||
| * billing disabled, and self-hosted with access control enabled — where no | ||
| * `subscription` row need exist at all. Anything that wants to re-verify an | ||
| * entitlement against the subscription table must consult this first, or it | ||
| * will read a missing row as a lapse and refuse work that should proceed. | ||
| * Exported so those callers cannot drift from the short-circuits below. | ||
| */ | ||
| export function isSubscriptionBackedEntitlement(): boolean { | ||
| return isBillingEnabled && !(isAccessControlEnabled && !isHosted) | ||
|
mzxchandra marked this conversation as resolved.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: When billing is enabled outside self-hosted access-control mode, this helper makes the locked move check bypass the entitlement result from Prompt for AI agents |
||
| } | ||
|
|
||
| async function resolveOrganizationEnterprisePlan(organizationId: string): Promise<boolean> { | ||
| try { | ||
| if (!isBillingEnabled) { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.