Skip to content

Commit 1b72781

Browse files
authored
fix(organizations): let members leave their organization; remove sign out other devices (#9595)
* fix(organizations): let members leave their organization; remove sign out other devices * fix(organizations): hide Leave when the caller's role is unknown * fix(organizations): offer Leave only from an authoritative role * fix(organizations): use only a freshly fetched summary role for Leave * fix(organizations): refetch the summary role on mount
1 parent eacc3a2 commit 1b72781

9 files changed

Lines changed: 64 additions & 127 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx‎

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import {
66
Button,
77
Chip,
88
ChipCombobox,
9-
ChipConfirmModal,
109
ChipModal,
1110
ChipModalBody,
1211
ChipModalError,
@@ -18,11 +17,9 @@ import {
1817
Label,
1918
Switch,
2019
Tooltip,
21-
toast,
2220
} from '@sim/emcn'
2321
import { Camera, Check, CircleInfo, Pencil } from '@sim/emcn/icons'
2422
import { createLogger } from '@sim/logger'
25-
import { getErrorMessage } from '@sim/utils/errors'
2623
import dynamic from 'next/dynamic'
2724
import Image from 'next/image'
2825
import { useRouter } from 'next/navigation'
@@ -50,7 +47,6 @@ import { useBrandConfig } from '@/ee/whitelabeling'
5047
import { useGeneralSettings, useUpdateGeneralSetting } from '@/hooks/queries/general-settings'
5148
import {
5249
useResetPassword,
53-
useSignOutOtherDevices,
5450
useUpdateUserProfile,
5551
useUserProfile,
5652
} from '@/hooks/queries/user-profile'
@@ -130,8 +126,6 @@ export function General() {
130126
})
131127
const [showResetPasswordModal, setShowResetPasswordModal] = useState(false)
132128
const resetPassword = useResetPassword()
133-
const [showSignOutOtherDevicesModal, setShowSignOutOtherDevicesModal] = useState(false)
134-
const signOutOtherDevices = useSignOutOtherDevices()
135129

136130
const [showDeleteAccountModal, setShowDeleteAccountModal] = useState(false)
137131

@@ -235,14 +229,6 @@ export function General() {
235229
)
236230
}
237231

238-
const confirmSignOutOtherDevices = () => {
239-
signOutOtherDevices.mutate(undefined, {
240-
onSuccess: () => toast.success('Signed out of other devices'),
241-
onError: (error) => toast.error(getErrorMessage(error)),
242-
onSettled: () => setShowSignOutOtherDevicesModal(false),
243-
})
244-
}
245-
246232
const handleThemeChange = async (value: string) => {
247233
await updateSetting.mutateAsync({ key: 'theme', value: value as 'system' | 'light' | 'dark' })
248234
}
@@ -314,11 +300,6 @@ export function General() {
314300
...(session?.user?.id && !isAuthDisabled
315301
? [
316302
{ id: 'sign-out', text: 'Sign out', onSelect: () => signOutAndRedirect(router.push) },
317-
{
318-
id: 'sign-out-other-devices',
319-
text: 'Sign out other devices',
320-
onSelect: () => setShowSignOutOtherDevicesModal(true),
321-
},
322303
{
323304
id: 'reset-password',
324305
text: 'Reset password',
@@ -675,19 +656,6 @@ export function General() {
675656
/>
676657
</ChipModal>
677658

678-
<ChipConfirmModal
679-
open={showSignOutOtherDevicesModal}
680-
onOpenChange={setShowSignOutOtherDevicesModal}
681-
title='Sign out other devices'
682-
text='This device and your authorized apps stay signed in.'
683-
confirm={{
684-
label: 'Sign out',
685-
onClick: confirmSignOutOtherDevices,
686-
pending: signOutOtherDevices.isPending,
687-
pendingLabel: 'Signing out...',
688-
}}
689-
/>
690-
691659
<DeleteAccountModal
692660
open={showDeleteAccountModal}
693661
onOpenChange={setShowDeleteAccountModal}

‎apps/sim/app/workspace/[workspaceId]/settings/components/team-management/components/organization-member-lists/organization-member-lists.tsx‎

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -194,25 +194,6 @@ export function OrganizationMemberLists({
194194
...(isSelf && isOwner && onTransferOwnership
195195
? [{ label: 'Transfer ownership', onSelect: () => onTransferOwnership() }]
196196
: []),
197-
...(canManage && isSelf && !isOwner
198-
? [
199-
{
200-
label: 'Leave organization',
201-
destructive: true,
202-
onSelect: () =>
203-
onRemoveMember({
204-
id: member.memberId,
205-
role: member.role,
206-
user: {
207-
id: member.userId,
208-
name: member.name,
209-
email: member.email,
210-
image: member.image,
211-
},
212-
}),
213-
},
214-
]
215-
: []),
216197
])}
217198
/>
218199
)

‎apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx‎

Lines changed: 57 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@
33
import { useState } from 'react'
44
import { Plus } from '@sim/emcn'
55
import { createLogger } from '@sim/logger'
6+
import { isOrgAdminRole } from '@sim/platform-authz/predicates'
67
import { getErrorMessage } from '@sim/utils/errors'
78
import { useSettingsUnsavedGuard } from '@/components/settings/use-settings-unsaved-guard'
89
import { useSession } from '@/lib/auth/client'
910
import { getSubscriptionAccessState } from '@/lib/billing/client/utils'
1011
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
1112
import { getBaseUrl } from '@/lib/core/utils/urls'
1213
import { APP_ENTRY_PATH } from '@/lib/navigation/paths'
13-
import { generateSlug, isAdminOrOwner, type Member } from '@/lib/workspaces/organization'
14+
import { generateSlug, type Member } from '@/lib/workspaces/organization'
1415
import { InviteModal } from '@/app/workspace/[workspaceId]/components/invite-modal'
1516
import {
1617
SettingsEmptyState,
@@ -30,6 +31,7 @@ import {
3031
useMemberRemovalImpact,
3132
useOrganization,
3233
useOrganizationRoster,
34+
useOrganizationSummary,
3335
useRemoveMember,
3436
useTransferOwnership,
3537
} from '@/hooks/queries/organization'
@@ -83,7 +85,33 @@ export function TeamManagement({
8385
const hasTeamPlan = subscriptionAccess.hasUsableTeamAccess
8486
const hasEnterprisePlan = subscriptionAccess.hasUsableEnterpriseAccess
8587

86-
const adminOrOwner = isAdminOrOwner(organization, session?.user?.email)
88+
const {
89+
data: roster,
90+
isLoading: isLoadingRoster,
91+
error: rosterError,
92+
isFetchedAfterMount: isRosterFetchedAfterMount,
93+
isFetching: isRosterFetching,
94+
refetch: refetchRoster,
95+
} = useOrganizationRoster(organizationId)
96+
97+
// Only a row naming the caller (the roster) or the server's own role read is authoritative:
98+
// Better Auth's full-organization read caps its member list and can omit or lag the caller.
99+
const rosterRole = roster?.members.find((member) => member.userId === session?.user?.id)?.role
100+
const organizationSummary = useOrganizationSummary(organizationId, {
101+
enabled: rosterError !== null,
102+
refetchOnMount: 'always',
103+
})
104+
// Fetched on this mount, so the identity-scoped role belongs to the current session.
105+
const summaryRole = organizationSummary.isFetchedAfterMount
106+
? organizationSummary.data?.userRole
107+
: undefined
108+
const confirmedRole = rosterRole ?? summaryRole
109+
const adminOrOwner = isOrgAdminRole(
110+
confirmedRole ??
111+
organization?.members.find((member) => member.user.email === session?.user?.email)?.role ??
112+
'member'
113+
)
114+
const canLeave = confirmedRole !== undefined && confirmedRole !== 'owner'
87115

88116
const {
89117
data: organizationBillingData,
@@ -94,15 +122,6 @@ export function TeamManagement({
94122
refetch: refetchOrganizationBilling,
95123
} = useOrganizationPlanSeats(organizationId, { enabled: billingEnabled && adminOrOwner })
96124

97-
const {
98-
data: roster,
99-
isLoading: isLoadingRoster,
100-
error: rosterError,
101-
isFetchedAfterMount: isRosterFetchedAfterMount,
102-
isFetching: isRosterFetching,
103-
refetch: refetchRoster,
104-
} = useOrganizationRoster(organizationId)
105-
106125
const removeMemberMutation = useRemoveMember()
107126
const transferOwnershipMutation = useTransferOwnership()
108127
const openBillingPortal = useOpenBillingPortal()
@@ -224,6 +243,17 @@ export function TeamManagement({
224243
})
225244
}
226245

246+
const handleLeave = () => {
247+
if (!session?.user) return
248+
setRemoveMemberDialog({
249+
open: true,
250+
memberId: session.user.id,
251+
memberName: 'yourself',
252+
isSelfRemoval: true,
253+
isExternalRemoval: false,
254+
})
255+
}
256+
227257
const confirmRemoveMember = async () => {
228258
const { memberId, isSelfRemoval } = removeMemberDialog
229259
if (!session?.user || !memberId) return
@@ -365,20 +395,31 @@ export function TeamManagement({
365395
onChange: setMemberQuery,
366396
placeholder: 'Search members...',
367397
}}
368-
actions={
369-
adminOrOwner
398+
actions={[
399+
...(canLeave
370400
? [
371401
{
402+
id: 'leave-organization',
403+
text: 'Leave organization',
404+
variant: 'destructive' as const,
405+
onSelect: handleLeave,
406+
},
407+
]
408+
: []),
409+
...(adminOrOwner
410+
? [
411+
{
412+
id: 'invite',
372413
text: 'Invite',
373414
icon: Plus,
374-
variant: 'primary',
415+
variant: 'primary' as const,
375416
onSelect: () => setInviteModalOpen(true),
376417
disabled: invitationsDisabled,
377418
tooltip: invitationsDisabled ? 'Invitations are disabled' : undefined,
378419
},
379420
]
380-
: []
381-
}
421+
: []),
422+
]}
382423
>
383424
{billingEnabled &&
384425
adminOrOwner &&

‎apps/sim/hooks/queries/organization.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,13 @@ export const ORGANIZATION_REMOVAL_IMPACT_STALE_TIME = 0
5050
export { organizationKeys }
5151

5252
/** Revalidates organization membership and chat policy through the shared authorized read. */
53-
export function useOrganizationSummary(organizationId: string) {
53+
export function useOrganizationSummary(
54+
organizationId: string,
55+
options?: { enabled?: boolean; refetchOnMount?: 'always' }
56+
) {
5457
return useQuery({
58+
enabled: options?.enabled ?? true,
59+
refetchOnMount: options?.refetchOnMount ?? true,
5560
queryKey: organizationKeys.summary(organizationId),
5661
queryFn: ({ signal }) =>
5762
requestJson(getOrganizationContract, {

‎apps/sim/hooks/queries/user-profile.ts‎

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import {
88
type UpdateUserProfileBody,
99
updateUserProfileContract,
1010
} from '@/lib/api/contracts/user'
11-
import { client } from '@/lib/auth/client'
1211
import {
1312
mapUserProfileResponse,
1413
USER_PROFILE_STALE_TIME,
@@ -89,16 +88,3 @@ export function useResetPassword() {
8988
},
9089
})
9190
}
92-
93-
/**
94-
* Ends every session except this one. Better Auth's plugin endpoint is called through the auth
95-
* client, as no Sim contract types it.
96-
*/
97-
export function useSignOutOtherDevices() {
98-
return useMutation({
99-
mutationFn: async () => {
100-
const { error } = await client.revokeOtherSessions()
101-
if (error) throw new Error(error.message ?? 'Failed to sign out other devices')
102-
},
103-
})
104-
}
Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,4 @@
11
// Export types
22
export type { Member } from '@/lib/workspaces/organization/types'
33
// Export utility functions
4-
export {
5-
generateSlug,
6-
getUserRole,
7-
isAdminOrOwner,
8-
} from '@/lib/workspaces/organization/utils'
4+
export { generateSlug } from '@/lib/workspaces/organization/utils'

‎apps/sim/lib/workspaces/organization/types.ts‎

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,3 @@ export interface Member {
1010
role: string
1111
user?: User
1212
}
13-
14-
export interface Organization {
15-
id: string
16-
name: string
17-
slug: string
18-
logo?: string | null
19-
members?: Member[]
20-
createdAt: string | Date
21-
[key: string]: unknown
22-
}

‎apps/sim/lib/workspaces/organization/utils.ts‎

Lines changed: 0 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,34 +3,6 @@
33
* These are pure functions that compute values from organization data
44
*/
55

6-
import { isOrgAdminRole } from '@sim/platform-authz/predicates'
7-
import type { Organization } from '@/lib/workspaces/organization/types'
8-
9-
/**
10-
* Get the role of a user in an organization
11-
*/
12-
export function getUserRole(
13-
organization: Organization | null | undefined,
14-
userEmail?: string
15-
): string {
16-
if (!userEmail || !organization?.members) {
17-
return 'member'
18-
}
19-
const currentMember = organization.members.find((m) => m.user?.email === userEmail)
20-
return currentMember?.role ?? 'member'
21-
}
22-
23-
/**
24-
* Check if a user is an admin or owner in an organization
25-
*/
26-
export function isAdminOrOwner(
27-
organization: Organization | null | undefined,
28-
userEmail?: string
29-
): boolean {
30-
const role = getUserRole(organization, userEmail)
31-
return isOrgAdminRole(role)
32-
}
33-
346
/**
357
* Generate a URL-friendly slug from a name
368
*/

‎scripts/check-unused-exports.baseline.json‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5231,8 +5231,6 @@
52315231
"apps/sim/lib/workspaces/operations/receipts.ts#WorkspaceOperationIssue",
52325232
"apps/sim/lib/workspaces/operations/receipts.ts#WorkspaceOperationKind",
52335233
"apps/sim/lib/workspaces/operations/receipts.ts#WorkspaceOperationStatus",
5234-
"apps/sim/lib/workspaces/organization/index.ts#getUserRole",
5235-
"apps/sim/lib/workspaces/organization/utils.ts#getUserRole",
52365234
"apps/sim/lib/workspaces/permissions/utils.ts#MemberRoleSource",
52375235
"apps/sim/lib/workspaces/policy.ts#CONTACT_OWNER_TO_UPGRADE_REASON",
52385236
"apps/sim/lib/workspaces/policy.ts#UPGRADE_TO_INVITE_REASON",

0 commit comments

Comments
 (0)