From 0cb4498939549fa5c667d40e3a6295067c3e9195 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 2 Nov 2026 18:27:22 -0800 Subject: [PATCH 1/5] fix(organizations): let members leave their organization; remove sign out other devices --- .../settings/components/general/general.tsx | 32 ---------- .../organization-member-lists.tsx | 19 ------ .../team-management/team-management.tsx | 64 ++++++++++++++----- apps/sim/hooks/queries/user-profile.ts | 14 ---- apps/sim/lib/workspaces/organization/index.ts | 1 - apps/sim/lib/workspaces/organization/utils.ts | 12 ---- scripts/check-unused-exports.baseline.json | 2 - 7 files changed, 48 insertions(+), 96 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx index 22511883b7a..dd916ade3c1 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx @@ -6,7 +6,6 @@ import { Button, Chip, ChipCombobox, - ChipConfirmModal, ChipModal, ChipModalBody, ChipModalError, @@ -18,11 +17,9 @@ import { Label, Switch, Tooltip, - toast, } from '@sim/emcn' import { Camera, Check, CircleInfo, Pencil } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' import dynamic from 'next/dynamic' import Image from 'next/image' import { useRouter } from 'next/navigation' @@ -50,7 +47,6 @@ import { useBrandConfig } from '@/ee/whitelabeling' import { useGeneralSettings, useUpdateGeneralSetting } from '@/hooks/queries/general-settings' import { useResetPassword, - useSignOutOtherDevices, useUpdateUserProfile, useUserProfile, } from '@/hooks/queries/user-profile' @@ -122,8 +118,6 @@ export function General() { }) const [showResetPasswordModal, setShowResetPasswordModal] = useState(false) const resetPassword = useResetPassword() - const [showSignOutOtherDevicesModal, setShowSignOutOtherDevicesModal] = useState(false) - const signOutOtherDevices = useSignOutOtherDevices() const [showDeleteAccountModal, setShowDeleteAccountModal] = useState(false) @@ -227,14 +221,6 @@ export function General() { ) } - const confirmSignOutOtherDevices = () => { - signOutOtherDevices.mutate(undefined, { - onSuccess: () => toast.success('Signed out of other devices'), - onError: (error) => toast.error(getErrorMessage(error)), - onSettled: () => setShowSignOutOtherDevicesModal(false), - }) - } - const handleThemeChange = async (value: string) => { await updateSetting.mutateAsync({ key: 'theme', value: value as 'system' | 'light' | 'dark' }) } @@ -302,11 +288,6 @@ export function General() { ...(session?.user?.id && !isAuthDisabled ? [ { id: 'sign-out', text: 'Sign out', onSelect: () => signOutAndRedirect(router.push) }, - { - id: 'sign-out-other-devices', - text: 'Sign out other devices', - onSelect: () => setShowSignOutOtherDevicesModal(true), - }, { id: 'reset-password', text: 'Reset password', @@ -659,19 +640,6 @@ export function General() { /> - - onTransferOwnership() }] : []), - ...(canManage && isSelf && !isOwner - ? [ - { - label: 'Leave organization', - destructive: true, - onSelect: () => - onRemoveMember({ - id: member.memberId, - role: member.role, - user: { - id: member.userId, - name: member.name, - email: member.email, - image: member.image, - }, - }), - }, - ] - : []), ])} /> ) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx index e60537723d2..e214a1b3dd0 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx @@ -3,6 +3,7 @@ import { useState } from 'react' import { Plus } from '@sim/emcn' import { createLogger } from '@sim/logger' +import { isOrgAdminRole } from '@sim/platform-authz/predicates' import { getErrorMessage } from '@sim/utils/errors' import { useSettingsUnsavedGuard } from '@/components/settings/use-settings-unsaved-guard' import { useSession } from '@/lib/auth/client' @@ -10,7 +11,7 @@ import { getSubscriptionAccessState } from '@/lib/billing/client/utils' import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { getBaseUrl } from '@/lib/core/utils/urls' import { APP_ENTRY_PATH } from '@/lib/navigation/paths' -import { generateSlug, isAdminOrOwner, type Member } from '@/lib/workspaces/organization' +import { generateSlug, getUserRole, type Member } from '@/lib/workspaces/organization' import { InviteModal } from '@/app/workspace/[workspaceId]/components/invite-modal' import { SettingsEmptyState, @@ -83,7 +84,25 @@ export function TeamManagement({ const hasTeamPlan = subscriptionAccess.hasUsableTeamAccess const hasEnterprisePlan = subscriptionAccess.hasUsableEnterpriseAccess - const adminOrOwner = isAdminOrOwner(organization, session?.user?.email) + const { + data: roster, + isLoading: isLoadingRoster, + error: rosterError, + isFetchedAfterMount: isRosterFetchedAfterMount, + isFetching: isRosterFetching, + refetch: refetchRoster, + } = useOrganizationRoster(organizationId) + + // Prefer the roster row: Better Auth's full-organization read caps its member list and can omit + // the caller; the roster is withheld only when the directory is hidden, where the fallback is accurate. + const currentUserRole = + roster?.members.find((member) => member.userId === session?.user?.id)?.role ?? + getUserRole(organization, session?.user?.email) + const adminOrOwner = isOrgAdminRole(currentUserRole) + const canLeave = + Boolean(organization) && + (roster !== undefined || rosterError !== null) && + currentUserRole !== 'owner' const { data: organizationBillingData, @@ -94,15 +113,6 @@ export function TeamManagement({ refetch: refetchOrganizationBilling, } = useOrganizationPlanSeats(organizationId, { enabled: billingEnabled && adminOrOwner }) - const { - data: roster, - isLoading: isLoadingRoster, - error: rosterError, - isFetchedAfterMount: isRosterFetchedAfterMount, - isFetching: isRosterFetching, - refetch: refetchRoster, - } = useOrganizationRoster(organizationId) - const removeMemberMutation = useRemoveMember() const transferOwnershipMutation = useTransferOwnership() const openBillingPortal = useOpenBillingPortal() @@ -224,6 +234,17 @@ export function TeamManagement({ }) } + const handleLeave = () => { + if (!session?.user) return + setRemoveMemberDialog({ + open: true, + memberId: session.user.id, + memberName: 'yourself', + isSelfRemoval: true, + isExternalRemoval: false, + }) + } + const confirmRemoveMember = async () => { const { memberId, isSelfRemoval } = removeMemberDialog if (!session?.user || !memberId) return @@ -365,20 +386,31 @@ export function TeamManagement({ onChange: setMemberQuery, placeholder: 'Search members...', }} - actions={ - adminOrOwner + actions={[ + ...(canLeave ? [ { + id: 'leave-organization', + text: 'Leave organization', + variant: 'destructive' as const, + onSelect: handleLeave, + }, + ] + : []), + ...(adminOrOwner + ? [ + { + id: 'invite', text: 'Invite', icon: Plus, - variant: 'primary', + variant: 'primary' as const, onSelect: () => setInviteModalOpen(true), disabled: invitationsDisabled, tooltip: invitationsDisabled ? 'Invitations are disabled' : undefined, }, ] - : [] - } + : []), + ]} > {billingEnabled && adminOrOwner && diff --git a/apps/sim/hooks/queries/user-profile.ts b/apps/sim/hooks/queries/user-profile.ts index f7bd779f471..a482ebfbbbe 100644 --- a/apps/sim/hooks/queries/user-profile.ts +++ b/apps/sim/hooks/queries/user-profile.ts @@ -8,7 +8,6 @@ import { type UpdateUserProfileBody, updateUserProfileContract, } from '@/lib/api/contracts/user' -import { client } from '@/lib/auth/client' import { mapUserProfileResponse, USER_PROFILE_STALE_TIME, @@ -89,16 +88,3 @@ export function useResetPassword() { }, }) } - -/** - * Ends every session except this one. Better Auth's plugin endpoint is called through the auth - * client, as no Sim contract types it. - */ -export function useSignOutOtherDevices() { - return useMutation({ - mutationFn: async () => { - const { error } = await client.revokeOtherSessions() - if (error) throw new Error(error.message ?? 'Failed to sign out other devices') - }, - }) -} diff --git a/apps/sim/lib/workspaces/organization/index.ts b/apps/sim/lib/workspaces/organization/index.ts index 20f39ac9f43..78054ed61d6 100644 --- a/apps/sim/lib/workspaces/organization/index.ts +++ b/apps/sim/lib/workspaces/organization/index.ts @@ -4,5 +4,4 @@ export type { Member } from '@/lib/workspaces/organization/types' export { generateSlug, getUserRole, - isAdminOrOwner, } from '@/lib/workspaces/organization/utils' diff --git a/apps/sim/lib/workspaces/organization/utils.ts b/apps/sim/lib/workspaces/organization/utils.ts index f81470f3aec..ddc1a48c477 100644 --- a/apps/sim/lib/workspaces/organization/utils.ts +++ b/apps/sim/lib/workspaces/organization/utils.ts @@ -3,7 +3,6 @@ * These are pure functions that compute values from organization data */ -import { isOrgAdminRole } from '@sim/platform-authz/predicates' import type { Organization } from '@/lib/workspaces/organization/types' /** @@ -20,17 +19,6 @@ export function getUserRole( return currentMember?.role ?? 'member' } -/** - * Check if a user is an admin or owner in an organization - */ -export function isAdminOrOwner( - organization: Organization | null | undefined, - userEmail?: string -): boolean { - const role = getUserRole(organization, userEmail) - return isOrgAdminRole(role) -} - /** * Generate a URL-friendly slug from a name */ diff --git a/scripts/check-unused-exports.baseline.json b/scripts/check-unused-exports.baseline.json index e4448b682b2..262099b9b3f 100644 --- a/scripts/check-unused-exports.baseline.json +++ b/scripts/check-unused-exports.baseline.json @@ -5235,8 +5235,6 @@ "apps/sim/lib/workspaces/operations/receipts.ts#WorkspaceOperationIssue", "apps/sim/lib/workspaces/operations/receipts.ts#WorkspaceOperationKind", "apps/sim/lib/workspaces/operations/receipts.ts#WorkspaceOperationStatus", - "apps/sim/lib/workspaces/organization/index.ts#getUserRole", - "apps/sim/lib/workspaces/organization/utils.ts#getUserRole", "apps/sim/lib/workspaces/permissions/utils.ts#MemberRoleSource", "apps/sim/lib/workspaces/policy.ts#CONTACT_OWNER_TO_UPGRADE_REASON", "apps/sim/lib/workspaces/policy.ts#UPGRADE_TO_INVITE_REASON", From 4334e9268ea2f4919a5f47f89f955d98abbc00aa Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 2 Nov 2026 18:35:19 -0800 Subject: [PATCH 2/5] fix(organizations): hide Leave when the caller's role is unknown --- .../team-management/team-management.tsx | 13 +++++-------- apps/sim/lib/workspaces/organization/index.ts | 5 +---- apps/sim/lib/workspaces/organization/types.ts | 10 ---------- apps/sim/lib/workspaces/organization/utils.ts | 16 ---------------- 4 files changed, 6 insertions(+), 38 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx index e214a1b3dd0..341c84cf7cf 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx @@ -11,7 +11,7 @@ import { getSubscriptionAccessState } from '@/lib/billing/client/utils' import { useDeploymentShape } from '@/lib/core/config/deployment-shape' import { getBaseUrl } from '@/lib/core/utils/urls' import { APP_ENTRY_PATH } from '@/lib/navigation/paths' -import { generateSlug, getUserRole, type Member } from '@/lib/workspaces/organization' +import { generateSlug, type Member } from '@/lib/workspaces/organization' import { InviteModal } from '@/app/workspace/[workspaceId]/components/invite-modal' import { SettingsEmptyState, @@ -94,15 +94,12 @@ export function TeamManagement({ } = useOrganizationRoster(organizationId) // Prefer the roster row: Better Auth's full-organization read caps its member list and can omit - // the caller; the roster is withheld only when the directory is hidden, where the fallback is accurate. + // the caller. An unknown role hides Leave rather than offering an owner a request that always fails. const currentUserRole = roster?.members.find((member) => member.userId === session?.user?.id)?.role ?? - getUserRole(organization, session?.user?.email) - const adminOrOwner = isOrgAdminRole(currentUserRole) - const canLeave = - Boolean(organization) && - (roster !== undefined || rosterError !== null) && - currentUserRole !== 'owner' + organization?.members.find((member) => member.user.email === session?.user?.email)?.role + const adminOrOwner = isOrgAdminRole(currentUserRole ?? 'member') + const canLeave = currentUserRole !== undefined && currentUserRole !== 'owner' const { data: organizationBillingData, diff --git a/apps/sim/lib/workspaces/organization/index.ts b/apps/sim/lib/workspaces/organization/index.ts index 78054ed61d6..4ae911f56b7 100644 --- a/apps/sim/lib/workspaces/organization/index.ts +++ b/apps/sim/lib/workspaces/organization/index.ts @@ -1,7 +1,4 @@ // Export types export type { Member } from '@/lib/workspaces/organization/types' // Export utility functions -export { - generateSlug, - getUserRole, -} from '@/lib/workspaces/organization/utils' +export { generateSlug } from '@/lib/workspaces/organization/utils' diff --git a/apps/sim/lib/workspaces/organization/types.ts b/apps/sim/lib/workspaces/organization/types.ts index ce817a5d5c9..58940e5a382 100644 --- a/apps/sim/lib/workspaces/organization/types.ts +++ b/apps/sim/lib/workspaces/organization/types.ts @@ -10,13 +10,3 @@ export interface Member { role: string user?: User } - -export interface Organization { - id: string - name: string - slug: string - logo?: string | null - members?: Member[] - createdAt: string | Date - [key: string]: unknown -} diff --git a/apps/sim/lib/workspaces/organization/utils.ts b/apps/sim/lib/workspaces/organization/utils.ts index ddc1a48c477..7b64801e85b 100644 --- a/apps/sim/lib/workspaces/organization/utils.ts +++ b/apps/sim/lib/workspaces/organization/utils.ts @@ -3,22 +3,6 @@ * These are pure functions that compute values from organization data */ -import type { Organization } from '@/lib/workspaces/organization/types' - -/** - * Get the role of a user in an organization - */ -export function getUserRole( - organization: Organization | null | undefined, - userEmail?: string -): string { - if (!userEmail || !organization?.members) { - return 'member' - } - const currentMember = organization.members.find((m) => m.user?.email === userEmail) - return currentMember?.role ?? 'member' -} - /** * Generate a URL-friendly slug from a name */ From 36e007dd60922f77c2454047d743c77b23db7ed1 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 2 Nov 2026 18:48:27 -0800 Subject: [PATCH 3/5] fix(organizations): offer Leave only from an authoritative role --- .../team-management/team-management.tsx | 21 ++++++++++++------- apps/sim/hooks/queries/organization.ts | 3 ++- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx index 341c84cf7cf..29dd5839968 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx @@ -31,6 +31,7 @@ import { useMemberRemovalImpact, useOrganization, useOrganizationRoster, + useOrganizationSummary, useRemoveMember, useTransferOwnership, } from '@/hooks/queries/organization' @@ -93,13 +94,19 @@ export function TeamManagement({ refetch: refetchRoster, } = useOrganizationRoster(organizationId) - // Prefer the roster row: Better Auth's full-organization read caps its member list and can omit - // the caller. An unknown role hides Leave rather than offering an owner a request that always fails. - const currentUserRole = - roster?.members.find((member) => member.userId === session?.user?.id)?.role ?? - organization?.members.find((member) => member.user.email === session?.user?.email)?.role - const adminOrOwner = isOrgAdminRole(currentUserRole ?? 'member') - const canLeave = currentUserRole !== undefined && currentUserRole !== 'owner' + // Only a row naming the caller (the roster) or the server's own role read is authoritative: + // Better Auth's full-organization read caps its member list and can omit or lag the caller. + const rosterRole = roster?.members.find((member) => member.userId === session?.user?.id)?.role + const { data: organizationSummary } = useOrganizationSummary(organizationId, { + enabled: rosterError !== null, + }) + const confirmedRole = rosterRole ?? organizationSummary?.userRole + const adminOrOwner = isOrgAdminRole( + confirmedRole ?? + organization?.members.find((member) => member.user.email === session?.user?.email)?.role ?? + 'member' + ) + const canLeave = confirmedRole !== undefined && confirmedRole !== 'owner' const { data: organizationBillingData, diff --git a/apps/sim/hooks/queries/organization.ts b/apps/sim/hooks/queries/organization.ts index 6d58a60e748..fa6800b2f19 100644 --- a/apps/sim/hooks/queries/organization.ts +++ b/apps/sim/hooks/queries/organization.ts @@ -49,8 +49,9 @@ export const ORGANIZATION_REMOVAL_IMPACT_STALE_TIME = 0 export { organizationKeys } /** Revalidates organization membership and chat policy through the shared authorized read. */ -export function useOrganizationSummary(organizationId: string) { +export function useOrganizationSummary(organizationId: string, options?: { enabled?: boolean }) { return useQuery({ + enabled: options?.enabled ?? true, queryKey: organizationKeys.summary(organizationId), queryFn: ({ signal }) => requestJson(getOrganizationContract, { From 898c81593b0e0b116b249c17fa7d6a911cb24e78 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 2 Nov 2026 18:59:47 -0800 Subject: [PATCH 4/5] fix(organizations): use only a freshly fetched summary role for Leave --- .../components/team-management/team-management.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx index 29dd5839968..592e46edaf2 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx @@ -97,10 +97,14 @@ export function TeamManagement({ // Only a row naming the caller (the roster) or the server's own role read is authoritative: // Better Auth's full-organization read caps its member list and can omit or lag the caller. const rosterRole = roster?.members.find((member) => member.userId === session?.user?.id)?.role - const { data: organizationSummary } = useOrganizationSummary(organizationId, { + const organizationSummary = useOrganizationSummary(organizationId, { enabled: rosterError !== null, }) - const confirmedRole = rosterRole ?? organizationSummary?.userRole + // Fetched on this mount, so the identity-scoped role belongs to the current session. + const summaryRole = organizationSummary.isFetchedAfterMount + ? organizationSummary.data?.userRole + : undefined + const confirmedRole = rosterRole ?? summaryRole const adminOrOwner = isOrgAdminRole( confirmedRole ?? organization?.members.find((member) => member.user.email === session?.user?.email)?.role ?? From 3d3795113c1f93a5f3eead495423582fdaed618b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 2 Nov 2026 19:22:13 -0800 Subject: [PATCH 5/5] fix(organizations): refetch the summary role on mount --- .../settings/components/team-management/team-management.tsx | 1 + apps/sim/hooks/queries/organization.ts | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx index 592e46edaf2..df43655f00c 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/team-management/team-management.tsx @@ -99,6 +99,7 @@ export function TeamManagement({ const rosterRole = roster?.members.find((member) => member.userId === session?.user?.id)?.role const organizationSummary = useOrganizationSummary(organizationId, { enabled: rosterError !== null, + refetchOnMount: 'always', }) // Fetched on this mount, so the identity-scoped role belongs to the current session. const summaryRole = organizationSummary.isFetchedAfterMount diff --git a/apps/sim/hooks/queries/organization.ts b/apps/sim/hooks/queries/organization.ts index fa6800b2f19..f05215db781 100644 --- a/apps/sim/hooks/queries/organization.ts +++ b/apps/sim/hooks/queries/organization.ts @@ -49,9 +49,13 @@ export const ORGANIZATION_REMOVAL_IMPACT_STALE_TIME = 0 export { organizationKeys } /** Revalidates organization membership and chat policy through the shared authorized read. */ -export function useOrganizationSummary(organizationId: string, options?: { enabled?: boolean }) { +export function useOrganizationSummary( + organizationId: string, + options?: { enabled?: boolean; refetchOnMount?: 'always' } +) { return useQuery({ enabled: options?.enabled ?? true, + refetchOnMount: options?.refetchOnMount ?? true, queryKey: organizationKeys.summary(organizationId), queryFn: ({ signal }) => requestJson(getOrganizationContract, {