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 dd916ade3c1..22511883b7a 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx
@@ -6,6 +6,7 @@ import {
Button,
Chip,
ChipCombobox,
+ ChipConfirmModal,
ChipModal,
ChipModalBody,
ChipModalError,
@@ -17,9 +18,11 @@ 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'
@@ -47,6 +50,7 @@ import { useBrandConfig } from '@/ee/whitelabeling'
import { useGeneralSettings, useUpdateGeneralSetting } from '@/hooks/queries/general-settings'
import {
useResetPassword,
+ useSignOutOtherDevices,
useUpdateUserProfile,
useUserProfile,
} from '@/hooks/queries/user-profile'
@@ -118,6 +122,8 @@ export function General() {
})
const [showResetPasswordModal, setShowResetPasswordModal] = useState(false)
const resetPassword = useResetPassword()
+ const [showSignOutOtherDevicesModal, setShowSignOutOtherDevicesModal] = useState(false)
+ const signOutOtherDevices = useSignOutOtherDevices()
const [showDeleteAccountModal, setShowDeleteAccountModal] = useState(false)
@@ -221,6 +227,14 @@ 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' })
}
@@ -288,6 +302,11 @@ 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',
@@ -640,6 +659,19 @@ export function General() {
/>
+
+
{
+ const { error } = await client.revokeOtherSessions()
+ if (error) throw new Error(error.message ?? 'Failed to sign out other devices')
+ },
+ })
+}
diff --git a/apps/sim/scripts/test-scim-e2e.ts b/apps/sim/scripts/test-scim-e2e.ts
index d81e5955608..bdbb789d422 100644
--- a/apps/sim/scripts/test-scim-e2e.ts
+++ b/apps/sim/scripts/test-scim-e2e.ts
@@ -543,6 +543,73 @@ async function run() {
}
)
+ await check(
+ 'An address change ends sessions and OAuth grants issued under the old one',
+ async () => {
+ const carolEmail = `carol@${primary.domain}`
+ const created = await scim(token, '/Users', {
+ method: 'POST',
+ expected: 201,
+ body: {
+ schemas: [USER_SCHEMA],
+ userName: carolEmail,
+ active: true,
+ name: { givenName: 'Carol', familyName: 'Example' },
+ emails: [{ value: carolEmail, type: 'work', primary: true }],
+ },
+ })
+ const carolId = string(created.body.id)
+ const userId = string(
+ (await sql`select user_id from scim_user where id = ${carolId}`)[0]?.user_id
+ )
+ const sessionId = generateId()
+ const clientId = generateId()
+ await sql`insert into session (id, token, user_id, expires_at, created_at, updated_at)
+ values (${sessionId}, ${generateShortId()}, ${userId}, now() + interval '1 day', now(), now())`
+ await sql`insert into oauth_client (id, client_id, name, public, token_endpoint_auth_method, grant_types, redirect_uris)
+ values (${clientId}, ${clientId}, 'SCIM E2E OAuth client', true, 'none',
+ ${['authorization_code', 'refresh_token']}, ${['http://127.0.0.1/callback']})`
+ try {
+ await sql`insert into oauth_consent (id, client_id, user_id, scopes, created_at, updated_at)
+ values (${generateId()}, ${clientId}, ${userId}, ${['api:read']}, now(), now())`
+ await sql`insert into oauth_token_family (id, client_id, user_id, session_id, created_at, expires_at)
+ values (${generateId()}, ${clientId}, ${userId}, ${sessionId}, now(), now() + interval '1 day')`
+ await sql`insert into oauth_access_token (id, token, client_id, user_id, scopes, created_at, expires_at)
+ values (${generateId()}, ${generateShortId()}, ${clientId}, ${userId}, ${['api:read']},
+ now(), now() + interval '1 hour')`
+
+ await patch(token, `/Users/${carolId}`, [
+ { op: 'replace', path: 'name.givenName', value: 'Caroline' },
+ ])
+ assert.equal(
+ (await sql`select id from oauth_access_token where user_id = ${userId}`).length,
+ 1
+ )
+
+ await patch(token, `/Users/${carolId}`, [
+ {
+ op: 'replace',
+ path: 'emails[type eq "work"].value',
+ value: `carol-renamed@${primary.domain}`,
+ },
+ ])
+
+ assert.equal((await sql`select id from session where user_id = ${userId}`).length, 0)
+ assert.equal(
+ (await sql`select id from oauth_token_family where user_id = ${userId}`).length,
+ 0
+ )
+ assert.equal(
+ (await sql`select id from oauth_access_token where user_id = ${userId}`).length,
+ 0
+ )
+ } finally {
+ await sql`delete from oauth_client where client_id = ${clientId}`
+ await sql`delete from "user" where id = ${userId}`
+ }
+ }
+ )
+
await check(
'Group CRUD supports Entra member PATCH, duplicates, foreign IDs, and membership-only PUT versions',
async () => {