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..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 @@ -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, type Member } from '@/lib/workspaces/organization' import { InviteModal } from '@/app/workspace/[workspaceId]/components/invite-modal' import { SettingsEmptyState, @@ -30,6 +31,7 @@ import { useMemberRemovalImpact, useOrganization, useOrganizationRoster, + useOrganizationSummary, useRemoveMember, useTransferOwnership, } from '@/hooks/queries/organization' @@ -83,7 +85,33 @@ 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) + + // 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 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 + ? organizationSummary.data?.userRole + : undefined + const confirmedRole = rosterRole ?? summaryRole + 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, @@ -94,15 +122,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 +243,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 +395,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/organization.ts b/apps/sim/hooks/queries/organization.ts index 6d58a60e748..f05215db781 100644 --- a/apps/sim/hooks/queries/organization.ts +++ b/apps/sim/hooks/queries/organization.ts @@ -49,8 +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) { +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, { 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..4ae911f56b7 100644 --- a/apps/sim/lib/workspaces/organization/index.ts +++ b/apps/sim/lib/workspaces/organization/index.ts @@ -1,8 +1,4 @@ // Export types export type { Member } from '@/lib/workspaces/organization/types' // Export utility functions -export { - generateSlug, - getUserRole, - isAdminOrOwner, -} 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 f81470f3aec..7b64801e85b 100644 --- a/apps/sim/lib/workspaces/organization/utils.ts +++ b/apps/sim/lib/workspaces/organization/utils.ts @@ -3,34 +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' - -/** - * 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' -} - -/** - * 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",