Skip to content

Commit f991ca0

Browse files
committed
address comments
1 parent 94a7216 commit f991ca0

16 files changed

Lines changed: 347 additions & 97 deletions

File tree

apps/docs/content/docs/en/platform/permissions.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ External workspace members still receive a workspace permission level — Read,
8585

8686
Accepting an invite that makes you an **internal member** does more than grant access — it brings your work under the organization:
8787

88-
- **Your workspaces move with you.** Every personal workspace you own, archived ones included, becomes an organization workspace. The accept screen lists exactly which workspaces will move before you accept.
88+
- **Your workspaces move with you.** Every personal workspace you own, archived ones included, becomes an organization workspace. The accept screen names the workspaces that will move before you accept.
8989
- **You keep Admin on them.** You remain their administrator; organization Owners and Admins also gain Admin access through the normal [role inheritance](#how-roles-inherit), and usage in those workspaces is billed to the organization from then on.
9090
- **Your collaborators are not pulled in.** Anyone you had shared those workspaces with keeps their access as an **external member** — they never join the organization or consume a seat as a side effect of your joining.
9191
- **The workspaces stay if you leave.** If you later leave or are removed from the organization, workspaces you brought in remain with the organization.
@@ -234,8 +234,8 @@ import { FAQ } from '@/components/ui/faq'
234234

235235
<FAQ items={[
236236
{ question: "What is the difference between organization roles and workspace permissions?", answer: "Organization roles (Owner, Admin, or Member) control who can manage the organization itself, including inviting people, creating shared workspaces, and handling billing. Workspace permissions (Read, Write, Admin) control what a user can do within a specific workspace, such as viewing, editing, or managing workflows. Internal members need both an organization role and a workspace permission to work within a shared workspace. External workspace members do not have an organization role in your org; they only have workspace-level access." },
237-
{ question: "What happens to my personal workspaces when I join an organization?", answer: "They move into the organization when you accept the invite — archived workspaces included. The accept screen lists exactly which workspaces will move before you accept. You stay their Admin, organization Owners and Admins gain Admin access through role inheritance, and usage in them is billed to the organization from then on. People you had shared them with keep access as external members. If you later leave the organization, those workspaces stay with it. Accepting an external invite never moves anything — everything you own stays yours." },
238-
{ question: "What happens when a member is removed from the organization?", answer: "Their workspace access is revoked and workflows they created are reassigned so deployed automations keep running. Credentials backed by their personal identity — OAuth connections and personal environment keys they own — stop working until another member reconnects them; the removal dialog lists exactly which credentials are affected before you confirm. Workspaces they brought into the organization stay with the organization." },
237+
{ question: "What happens to my personal workspaces when I join an organization?", answer: "They move into the organization when you accept the invite — archived workspaces included. The accept screen names the workspaces that will move before you accept. You stay their Admin, organization Owners and Admins gain Admin access through role inheritance, and usage in them is billed to the organization from then on. People you had shared them with keep access as external members. If you later leave the organization, those workspaces stay with it. Accepting an external invite never moves anything — everything you own stays yours." },
238+
{ question: "What happens when a member is removed from the organization?", answer: "Their workspace access is revoked and workflows they created are reassigned so deployed automations keep running. Credentials backed by their personal identity — OAuth connections and personal environment keys they own — stop working until another member reconnects them; the removal dialog names the affected credentials before you confirm. Workspaces they brought into the organization stay with the organization." },
239239
{ question: "What happens to my shared workspaces if I cancel or downgrade my Team plan?", answer: "Existing shared workspaces remain accessible to current members, but new invitations are disabled until you upgrade back to a Team or Enterprise plan. No workspaces or members are deleted — the organization is simply dormant until billing is re-enabled." },
240240
{ question: "Can I restrict which integrations or model providers a team member can use?", answer: "Yes, on Enterprise-entitled organizations. Any organization owner or admin can create permission groups with fine-grained controls, including restricting allowed integrations and allowed model providers to specific lists. You can also disable access to MCP tools, custom tools, skills, and various platform features like the knowledge base, API keys, or Copilot on a per-group basis. Permission groups are scoped to the organization and can govern either all workspaces or a specific subset — a user can belong to multiple groups but is governed by exactly one group in any given workspace." },
241241
{ question: "What happens when a personal environment variable has the same name as a workspace variable?", answer: "The workspace variable wins. When a workflow runs, the resolver checks workspace variables first and falls back to a personal variable only when no workspace variable shares that name. This keeps shared, team-managed values authoritative in production workflows." },

apps/docs/content/docs/en/platform/workspaces.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ Members join with one of three **permission levels**: **Read**, **Write**, or **
6666

6767
Most workspaces are one of two kinds.
6868

69-
A **personal workspace** lives under your own account. Use it for experimentation and individual work. Your plan sets how many you can create. When you join an organization as an internal member, personal workspaces you own move into it — the accept screen shows exactly which ones before you accept.
69+
A **personal workspace** lives under your own account. Use it for experimentation and individual work. Your plan sets how many you can create. When you join an organization as an internal member, personal workspaces you own move into it — the accept screen names them before you accept.
7070

7171
An **organization workspace** (also called a shared workspace) lives under an organization, available on Team and Enterprise plans. You invite members, each with a permission level. Internal members join the organization, count toward its seat total, and bring their own workspaces with them. External members stay outside the organization — no seat, and everything they own stays theirs — that's how partners and clients get access. For agencies and enterprises, this is the workspace you hand to the customer: the app they use, own, and maintain.
7272

apps/sim/app/api/organizations/route.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,6 @@ describe('POST /api/organizations', () => {
9696
expect(mockAttachOwnedWorkspacesToOrganization).toHaveBeenCalledWith({
9797
ownerUserId: 'user-1',
9898
organizationId: 'legacy-org-id',
99-
externalMemberPolicy: 'keep-external',
10099
includeArchived: true,
101100
})
102101
expect(mockCreateOrganizationForTeamPlan).not.toHaveBeenCalled()

apps/sim/app/api/organizations/route.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -187,15 +187,16 @@ export const POST = withRouteHandler(async (request: Request) => {
187187

188188
if (activeOrgSubscription.referenceId === organizationId) {
189189
/**
190-
* keep-external, matching the Pro→Team conversion: different-org
191-
* collaborators (including ones on archived workspaces the sweep now
192-
* covers) stay external workspace members instead of aborting the
193-
* whole org recovery with a conflict.
190+
* Keeps the default `reject` policy: manual organization creation
191+
* surfaces a different-org collaborator as an explicit conflict (409
192+
* with actionable copy) rather than silently demoting them to an
193+
* external member. Safe alongside `includeArchived` because the attach
194+
* only enumerates collaborators of ACTIVE workspaces, so sweeping
195+
* archived rows cannot manufacture a conflict.
194196
*/
195197
await attachOwnedWorkspacesToOrganization({
196198
ownerUserId: user.id,
197199
organizationId,
198-
externalMemberPolicy: 'keep-external',
199200
includeArchived: true,
200201
})
201202
} else {

apps/sim/app/api/workspaces/route.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
151151
organizationId: creationPolicy.organizationId,
152152
workspaceMode: creationPolicy.workspaceMode,
153153
billedAccountUserId: creationPolicy.billedAccountUserId,
154+
observedOrganizationId: creationPolicy.observedOrganizationId,
154155
})
155156

156157
captureServerEvent(
@@ -210,6 +211,7 @@ async function createDefaultWorkspace(
210211
organizationId: string | null
211212
workspaceMode: WorkspaceMode
212213
billedAccountUserId: string
214+
observedOrganizationId: string | null
213215
}
214216
) {
215217
const firstName = userName?.split(' ')[0] || null
@@ -220,11 +222,14 @@ async function createDefaultWorkspace(
220222
organizationId: creationPolicy.organizationId,
221223
workspaceMode: creationPolicy.workspaceMode,
222224
billedAccountUserId: creationPolicy.billedAccountUserId,
225+
observedOrganizationId: creationPolicy.observedOrganizationId,
223226
})
224227
}
225228

226229
interface CreateWorkspaceParams {
227230
userId: string
231+
/** Membership the creation policy observed; see WorkspaceCreationPolicy. */
232+
observedOrganizationId: string | null
228233
name: string
229234
skipDefaultWorkflow?: boolean
230235
explicitColor?: string
@@ -235,6 +240,7 @@ interface CreateWorkspaceParams {
235240

236241
async function createWorkspace({
237242
userId,
243+
observedOrganizationId,
238244
name,
239245
skipDefaultWorkflow = false,
240246
explicitColor,
@@ -259,12 +265,19 @@ async function createWorkspace({
259265
*/
260266
if (!organizationId) {
261267
await acquireUserBillingIdentityLock(tx, userId)
262-
const [existingMembership] = await tx
263-
.select({ id: member.id })
268+
const [currentMembership] = await tx
269+
.select({ organizationId: member.organizationId })
264270
.from(member)
265271
.where(eq(member.userId, userId))
266272
.limit(1)
267-
if (existingMembership) {
273+
/**
274+
* Only a CHANGE since the policy read means the decision is stale. An
275+
* unchanged membership is legitimately personal — the policy returns
276+
* a personal decision for members whose organization has no usable
277+
* Team/Enterprise plan (a dormant org), and those users must still be
278+
* able to create workspaces.
279+
*/
280+
if ((currentMembership?.organizationId ?? null) !== observedOrganizationId) {
268281
throw new PersonalWorkspaceCreationRacedError()
269282
}
270283
}

apps/sim/app/invite/[id]/invite.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ export default function Invite() {
240240
}
241241
}, [searchParams, inviteId, inviteTokenStorageKey])
242242

243-
const invitationQuery = useInvitationDetails(inviteId, token, {
243+
const invitationQuery = useInvitationDetails(inviteId, token, session?.user?.id ?? null, {
244244
enabled: Boolean(session?.user),
245245
})
246246
const invitation = invitationQuery.data?.invitation ?? null
@@ -492,7 +492,13 @@ export default function Invite() {
492492
}
493493

494494
const isOrg = invitation?.kind === 'organization'
495-
const organizationLabel = invitation?.organizationName || 'the organization'
495+
/**
496+
* Prefer the preview's organization (the one acceptance will really join)
497+
* over the invitation's stamped name — a granted workspace may have moved
498+
* organizations since the invite was sent.
499+
*/
500+
const organizationLabel =
501+
joinPreview?.organizationName || invitation?.organizationName || 'the organization'
496502
/**
497503
* When the server could not compute the preview, fall back to a generic
498504
* migration notice for membership invites — a missing preview must never

apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/remove-member-dialog/remove-member-dialog.tsx

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,12 +86,21 @@ export function RemoveMemberDialog({
8686
confirm={{
8787
label: isSelfRemoval ? 'Leave Organization' : 'Remove',
8888
onClick: () => onConfirmRemove(),
89-
pending: isSubmitting || credentialImpactPending,
90-
pendingLabel:
91-
credentialImpactPending && !isSubmitting ? 'Checking credentials…' : undefined,
89+
pending: isSubmitting,
90+
/**
91+
* `disabled`, not `pending`: the impact check is a precondition, and
92+
* `pending` means "the confirm is in flight" — the primitive also
93+
* disables the dismiss button while pending, which must stay available
94+
* while we are merely loading the disclosure.
95+
*/
96+
disabled: credentialImpactPending,
9297
}}
9398
>
94-
{credentialWarning ? (
99+
{credentialImpactPending ? (
100+
<p className='mt-1 px-2 text-[var(--text-muted)] text-caption'>
101+
Checking which connected credentials this affects…
102+
</p>
103+
) : credentialWarning ? (
95104
<p className='mt-1 px-2 text-[var(--text-muted)] text-caption'>{credentialWarning}</p>
96105
) : null}
97106
{errorMessage ? (

apps/sim/app/workspace/page.tsx

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ import { useWorkspacesWithMetadata } from '@/hooks/queries/workspace'
1616

1717
const logger = createLogger('WorkspacePage')
1818

19+
/** Bounds the one-shot reload after a creation-vs-membership 409. */
20+
const WORKSPACE_RACE_RETRY_KEY = 'workspaceRaceRetry'
21+
1922
/**
2023
* A 401 while the session claims we're authenticated means the auth cookies
2124
* are stale or inconsistent (e.g. after an impersonation session expired or
@@ -130,7 +133,7 @@ export default function WorkspacePage() {
130133
return
131134
}
132135
hasRedirectedRef.current = true
133-
handleNoWorkspaces(router)
136+
handleNoWorkspaces(router, () => setRecoveryFailed(true))
134137
return
135138
}
136139

@@ -171,7 +174,7 @@ export default function WorkspacePage() {
171174
description={
172175
blockedPolicy.workspaceMode === 'organization'
173176
? "Your account is linked to an organization, but you don't have access to any of its workspaces. Ask an organization admin for workspace access, then check again — or sign out and back in if you recently left the organization."
174-
: 'All of your workspaces are archived and your plan has reached its workspace limit. Unarchive a workspace or upgrade your plan to continue.'
177+
: 'Your plan has reached its workspace limit and none of your workspaces are active. Upgrade your plan to create another workspace, or contact support to restore an archived one.'
175178
}
176179
primaryLabel='Check again'
177180
onPrimary={() => window.location.reload()}
@@ -232,26 +235,39 @@ async function handleWorkflowRedirect(
232235
router.replace(`/workspace/${fallbackWorkspaceId}/home`)
233236
}
234237

235-
async function handleNoWorkspaces(router: ReturnType<typeof useRouter>): Promise<void> {
238+
async function handleNoWorkspaces(
239+
router: ReturnType<typeof useRouter>,
240+
onUnrecoverable: () => void
241+
): Promise<void> {
236242
logger.warn('No workspaces found, creating default workspace')
237243
try {
238244
const data = await requestJson(createWorkspaceContract, {
239245
body: { name: 'My Workspace' },
240246
})
241247
if (data.workspace?.id) {
242248
logger.info(`Created default workspace: ${data.workspace.id}`)
249+
sessionStorage.removeItem(WORKSPACE_RACE_RETRY_KEY)
243250
router.replace(`/workspace/${data.workspace.id}/home`)
244251
return
245252
}
246253
logger.error('Failed to create default workspace')
247254
} catch (error) {
248255
/**
249-
* 409 means the user joined an organization while the default workspace
250-
* was being created — they are still authenticated and likely have org
251-
* workspaces now, so re-resolve instead of falling into the login path.
256+
* 409 means the caller's organization membership changed while the
257+
* default workspace was being created — they are still authenticated and
258+
* their workspaces likely exist now, so re-resolve ONCE. A second 409
259+
* means something other than a race, so surface the error card instead of
260+
* reloading forever.
252261
*/
253262
if (isApiClientError(error) && error.status === 409) {
254-
logger.info('Default workspace creation raced an organization join; re-resolving')
263+
if (sessionStorage.getItem(WORKSPACE_RACE_RETRY_KEY)) {
264+
logger.error('Default workspace creation kept conflicting after a retry')
265+
sessionStorage.removeItem(WORKSPACE_RACE_RETRY_KEY)
266+
onUnrecoverable()
267+
return
268+
}
269+
sessionStorage.setItem(WORKSPACE_RACE_RETRY_KEY, '1')
270+
logger.info('Default workspace creation raced an organization change; re-resolving')
255271
window.location.reload()
256272
return
257273
}

apps/sim/hooks/queries/invitations.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,15 @@ export const invitationKeys = {
2121
lists: () => [...invitationKeys.all, 'list'] as const,
2222
list: (workspaceId: string) => [...invitationKeys.lists(), workspaceId] as const,
2323
details: () => [...invitationKeys.all, 'detail'] as const,
24-
detail: (invitationId: string, token: string | null) =>
25-
[...invitationKeys.details(), invitationId, token ?? ''] as const,
24+
/**
25+
* Scoped by viewer: the response is viewer-dependent (the join preview is
26+
* invitee-only, and authorization differs per account), so a cached entry
27+
* must never be reused across a sign-out/sign-in on the same invite link —
28+
* doing so would let a stale "nothing moves" preview become the disclosure
29+
* basis for a different user.
30+
*/
31+
detail: (invitationId: string, token: string | null, viewerId: string | null) =>
32+
[...invitationKeys.details(), invitationId, token ?? '', viewerId ?? ''] as const,
2633
}
2734

2835
export const WORKSPACE_INVITATION_LIST_STALE_TIME = 30 * 1000
@@ -48,10 +55,11 @@ async function fetchInvitationDetails(
4855
export function useInvitationDetails(
4956
invitationId: string | undefined,
5057
token: string | null,
58+
viewerId: string | null,
5159
options?: { enabled?: boolean }
5260
) {
5361
return useQuery({
54-
queryKey: invitationKeys.detail(invitationId ?? '', token),
62+
queryKey: invitationKeys.detail(invitationId ?? '', token, viewerId),
5563
queryFn: ({ signal }) => fetchInvitationDetails(invitationId as string, token, signal),
5664
enabled: Boolean(invitationId) && (options?.enabled ?? true),
5765
staleTime: INVITATION_DETAILS_STALE_TIME,

apps/sim/lib/api/contracts/invitations.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@ import { z } from 'zod'
22
import { defineRouteContract } from '@/lib/api/contracts/types'
33
import { workspacePermissionSchema } from '@/lib/api/contracts/workspaces'
44

5+
/**
6+
* Shared cap for the disclosure token: the preview's id list and the accept
7+
* body's echo of it must agree, or a large owned-workspace set would produce a
8+
* preview the accept endpoint rejects as over-long.
9+
*/
10+
export const DISCLOSED_WORKSPACE_ID_LIMIT = 500
11+
512
export const invitationParamsSchema = z.object({
613
id: z.string({ error: 'Invitation ID is required' }).min(1, 'Invitation ID is required'),
714
})
@@ -80,7 +87,7 @@ export const invitationActionBodySchema = z.object({
8087
* the set it would actually sweep differs — consent is only valid for the
8188
* set the user saw.
8289
*/
83-
disclosedWorkspaceIds: z.array(z.string()).max(200).optional(),
90+
disclosedWorkspaceIds: z.array(z.string()).max(DISCLOSED_WORKSPACE_ID_LIMIT).optional(),
8491
})
8592

8693
export const invitationDetailsSchema = z.object({
@@ -107,13 +114,17 @@ export const invitationDetailsSchema = z.object({
107114

108115
export const invitationJoinPreviewSchema = z.object({
109116
willJoinOrganization: z.boolean(),
110-
workspacesToMove: z.array(z.string()),
117+
/** Name of the organization acceptance will actually join. */
118+
organizationName: z.string().nullable(),
119+
workspacesToMove: z.array(z.string()).max(DISCLOSED_WORKSPACE_ID_LIMIT),
111120
/**
112121
* Stable ids behind `workspacesToMove`, echoed back on accept as the
113122
* disclosure token: acceptance rejects when the set it would sweep no
114-
* longer matches what this preview disclosed.
123+
* longer matches what this preview disclosed. Capped at the same limit as
124+
* the accept body's echo so a large owned set can never render an invite
125+
* permanently un-acceptable.
115126
*/
116-
workspaceIdsToMove: z.array(z.string()),
127+
workspaceIdsToMove: z.array(z.string()).max(DISCLOSED_WORKSPACE_ID_LIMIT),
117128
})
118129

119130
export const acceptInvitationResponseSchema = z.object({

0 commit comments

Comments
 (0)