From a8fcd67dde94b9100ca6e7e5bc4ce38801f34496 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 7 Sep 2026 18:09:38 -0700 Subject: [PATCH] fix(settings): move authorized apps into General --- .../docs/api-reference/authentication.mdx | 2 +- apps/docs/content/docs/cli/authentication.mdx | 4 +- .../platform/self-hosting/authentication.mdx | 2 +- .../account/settings/[section]/page.test.tsx | 58 ++++++ .../app/account/settings/[section]/page.tsx | 3 + .../settings/[section]/layout.test.tsx | 42 +++++ .../settings/[section]/layout.tsx | 5 +- .../settings/[section]/settings.tsx | 6 - .../authorized-apps/authorized-apps.tsx | 17 +- .../components/general/general.test.tsx | 174 ++++++++++++++++++ .../settings/components/general/general.tsx | 25 ++- .../components/general/search-params.ts | 7 +- .../[workspaceId]/settings/navigation.test.ts | 2 - .../settings/account-settings-renderer.tsx | 6 - .../components/settings/navigation.test.ts | 2 - apps/sim/components/settings/navigation.ts | 28 +-- packages/sim-cli/README.md | 2 +- packages/sim-cli/src/auth/oauth-flow.ts | 2 +- packages/sim-cli/src/auth/refresh.ts | 2 +- packages/sim-cli/src/commands/auth.ts | 8 +- 20 files changed, 332 insertions(+), 65 deletions(-) create mode 100644 apps/sim/app/account/settings/[section]/page.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/settings/components/general/general.test.tsx diff --git a/apps/docs/content/docs/api-reference/authentication.mdx b/apps/docs/content/docs/api-reference/authentication.mdx index e4c9bc8ee94..fbef2a325e7 100644 --- a/apps/docs/content/docs/api-reference/authentication.mdx +++ b/apps/docs/content/docs/api-reference/authentication.mdx @@ -108,7 +108,7 @@ curl https://www.sim.ai/api/v2/workspaces \ Scopes limit what an application may do; your current workspace membership and role still apply. Each endpoint documents its required scope. Some GET endpoints that perform external discovery require `api:write`, so HTTP method alone does not determine the permission. -Manage grants in **Settings** → **Authorized apps**. Revoking an application signs out all of its logins. `sim logout` revokes the current CLI login and removes it from your machine. The Python and TypeScript SDKs currently use API keys; they do not manage OAuth sign-in or refresh tokens. +Manage grants in **Settings** → **General** → **Authorized apps**. Revoking an application signs out all of its logins. `sim logout` revokes the current CLI login and removes it from your machine. The Python and TypeScript SDKs currently use API keys; they do not manage OAuth sign-in or refresh tokens. ## Security diff --git a/apps/docs/content/docs/cli/authentication.mdx b/apps/docs/content/docs/cli/authentication.mdx index b306dd8ee30..977f71d5f23 100644 --- a/apps/docs/content/docs/cli/authentication.mdx +++ b/apps/docs/content/docs/cli/authentication.mdx @@ -27,7 +27,7 @@ https://www.sim.ai/api/auth/oauth2/authorize?client_id=sim-cli&… Waiting for you to approve in the browser… ✓ Logged in. Login stored in /Users/you/.sim/credentials - Renews itself; revoke it any time in Settings → Authorized apps, or with: sim logout + Renews itself; revoke it any time in Settings → General → Authorized apps, or with: sim logout No default workspace. Set one with: sim configure --set-workspace ``` @@ -151,7 +151,7 @@ For an OAuth login, `sim logout` revokes that login's complete token family before removing it from disk, including access tokens issued before earlier rotations. Other machines that ran their own `sim login` remain signed in. To cut off every independent login for the client, revoke the grant under -**Settings → Authorized apps**. +**Settings → General → Authorized apps**. A workspace profile that shares authentication cannot remove the shared login. Remove only that local profile with `sim logout --all --profile `, or log diff --git a/apps/docs/content/docs/platform/self-hosting/authentication.mdx b/apps/docs/content/docs/platform/self-hosting/authentication.mdx index 0ef498e2bcb..4ed18f53fa7 100644 --- a/apps/docs/content/docs/platform/self-hosting/authentication.mdx +++ b/apps/docs/content/docs/platform/self-hosting/authentication.mdx @@ -122,7 +122,7 @@ requires a real Better Auth user session. Access tokens are opaque and last an hour; refresh tokens rotate on every use. Each login has a fixed thirty-day lifetime that refreshing does not extend. Token validation checks current grants, so revoking a grant under -**Settings → Authorized apps** stops the app on its very next request. These +**Settings → General → Authorized apps** stops the app on its very next request. These settings remain available for reviewing and revoking existing grants while the provider is off, and scheduled OAuth token cleanup continues. diff --git a/apps/sim/app/account/settings/[section]/page.test.tsx b/apps/sim/app/account/settings/[section]/page.test.tsx new file mode 100644 index 00000000000..838297805e3 --- /dev/null +++ b/apps/sim/app/account/settings/[section]/page.test.tsx @@ -0,0 +1,58 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession, mockPrefetch } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockPrefetch: vi.fn(), +})) + +vi.mock('next/navigation', () => ({ + notFound: () => { + throw new Error('NEXT_NOT_FOUND') + }, + redirect: (href: string) => { + throw new Error(`NEXT_REDIRECT:${href}`) + }, +})) +vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) +vi.mock('@/lib/core/config/env-flags', () => ({ isBillingEnabled: true })) +vi.mock('@/lib/permissions/super-user', () => ({ isPlatformAdmin: vi.fn() })) +vi.mock('@/app/_shell/providers/get-query-client', () => ({ getQueryClient: vi.fn() })) +vi.mock('@/components/settings/prefetch-standalone-general', () => ({ + prefetchStandaloneGeneral: mockPrefetch, +})) +vi.mock('@/components/settings/account-settings-renderer', () => ({ + AccountSettingsRenderer: () => null, +})) + +import AccountSettingsSectionPage from '@/app/account/settings/[section]/page' + +const pageProps = (section: string) => ({ params: Promise.resolve({ section }) }) + +describe('account settings legacy links', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'viewer-a' } }) + }) + + it('redirects Authorized apps bookmarks to the General subview', async () => { + await expect(AccountSettingsSectionPage(pageProps('authorized-apps'))).rejects.toThrow( + 'NEXT_REDIRECT:/account/settings/general?view=authorized-apps' + ) + expect(mockPrefetch).not.toHaveBeenCalled() + }) + + it('authenticates before following the legacy bookmark', async () => { + mockGetSession.mockResolvedValue(null) + + await expect(AccountSettingsSectionPage(pageProps('authorized-apps'))).rejects.toThrow( + 'NEXT_REDIRECT:/login' + ) + }) + + it('still rejects unknown sections', async () => { + await expect(AccountSettingsSectionPage(pageProps('unknown'))).rejects.toThrow('NEXT_NOT_FOUND') + }) +}) diff --git a/apps/sim/app/account/settings/[section]/page.tsx b/apps/sim/app/account/settings/[section]/page.tsx index 424a05612cd..a4a551f1a89 100644 --- a/apps/sim/app/account/settings/[section]/page.tsx +++ b/apps/sim/app/account/settings/[section]/page.tsx @@ -41,6 +41,9 @@ export default async function AccountSettingsSectionPage({ if (!session?.user) redirect('/login') const { section } = await params + if (section === 'authorized-apps') { + redirect(`${getAccountSettingsHref('general')}?view=authorized-apps`) + } const parsed = parseSettingsPathSection({ path: section, items: ACCOUNT_SETTINGS_ITEMS, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.test.tsx new file mode 100644 index 00000000000..1f18435d8b6 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.test.tsx @@ -0,0 +1,42 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('next/navigation', () => ({ + notFound: () => { + throw new Error('NEXT_NOT_FOUND') + }, + redirect: (href: string) => { + throw new Error(`NEXT_REDIRECT:${href}`) + }, +})) +vi.mock( + '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header', + () => ({ + SettingsHeaderProvider: () => null, + SettingsHeaderShell: () => null, + }) +) + +import SettingsSectionLayout from '@/app/workspace/[workspaceId]/settings/[section]/layout' + +const layoutProps = (section: string) => ({ + children: null, + params: Promise.resolve({ workspaceId: 'workspace-a', section }), +}) + +describe('workspace settings legacy links', () => { + it.each(['privacy', 'authorized-apps'])( + 'redirects %s before rendering the shell', + async (view) => { + await expect(SettingsSectionLayout(layoutProps(view))).rejects.toThrow( + `NEXT_REDIRECT:/workspace/workspace-a/settings/general?view=${view}` + ) + } + ) + + it('still rejects unknown sections', async () => { + await expect(SettingsSectionLayout(layoutProps('unknown'))).rejects.toThrow('NEXT_NOT_FOUND') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.tsx index 86e4ff29e5a..a2965fd9ab0 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.tsx @@ -6,14 +6,15 @@ import { import { resolveSettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation' /** - * Sections that were promoted out of settings into their own workspace routes. Kept as - * segment-level rewrites so old links and bookmarks still land somewhere sensible. + * Legacy settings sections kept as redirects so old links and bookmarks still work. */ const TOP_LEVEL_REDIRECTS: Readonly string>> = { integrations: (workspaceId) => `/workspace/${workspaceId}/integrations`, skills: (workspaceId) => `/workspace/${workspaceId}/skills`, /** Cookie preferences moved into General. */ privacy: (workspaceId) => `/workspace/${workspaceId}/settings/general?view=privacy`, + 'authorized-apps': (workspaceId) => + `/workspace/${workspaceId}/settings/general?view=authorized-apps`, } /** diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx index 1a392f78ab8..7407f7af1fe 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx @@ -25,11 +25,6 @@ const ApiKeys = dynamic(() => const BYOK = dynamic(() => import('@/app/workspace/[workspaceId]/settings/components/byok/byok').then((m) => m.BYOK) ) -const AuthorizedApps = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/authorized-apps/authorized-apps').then( - (m) => m.AuthorizedApps - ) -) const Forks = dynamic(() => import('@/ee/workspace-forking/components/forks').then((m) => m.Forks)) const Secrets = dynamic(() => import('@/app/workspace/[workspaceId]/settings/components/secrets/secrets').then((m) => m.Secrets) @@ -189,7 +184,6 @@ export function SettingsPage({ section }: SettingsPageProps) { /> )} {effectiveSection === 'apikeys' && } - {effectiveSection === 'authorized-apps' && } {billingEnabled && effectiveSection === 'billing' && ( void +} + /** * The apps this account has authorized through Sim's OAuth provider. Revoking * one withdraws its consent and kills every token it holds, so the next * request it makes fails and the next sign-in asks again. */ -export function AuthorizedApps() { +export function AuthorizedApps({ onBack }: AuthorizedAppsProps) { const [searchTerm, setSearchTerm] = useSettingsSearch() const apps = useAuthorizedApps(searchTerm.trim()) const revoke = useRevokeAuthorizedApp() @@ -46,6 +51,16 @@ export function AuthorizedApps() { return ( <> { + setSearchTerm('') + onBack() + }, + }} + title='Authorized apps' + description='Review and revoke apps that can act on your account.' search={{ value: searchTerm, onChange: setSearchTerm, diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.test.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.test.tsx new file mode 100644 index 00000000000..ea0b7b35eb0 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.test.tsx @@ -0,0 +1,174 @@ +/** + * @vitest-environment jsdom + */ +import { act, type ReactNode, Suspense } from 'react' +import { NuqsTestingAdapter } from 'nuqs/adapters/testing' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ANONYMOUS_USER_ID } from '@/lib/auth/constants' + +const { mockUseSession, mockUseAuthorizedApps, mockUrlUpdate } = vi.hoisted(() => ({ + mockUseSession: vi.fn(), + mockUseAuthorizedApps: vi.fn(), + mockUrlUpdate: vi.fn(), +})) + +vi.mock('next/dynamic', () => ({ + default: () => AuthorizedApps, +})) +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn() }), + usePathname: () => '/account/settings/general', +})) +vi.mock('@/lib/auth/auth-client', () => ({ useSession: mockUseSession, signOut: vi.fn() })) +vi.mock('@/lib/core/config/deployment-shape', () => ({ + useDeploymentShape: () => ({ hosted: false }), +})) +vi.mock('@/ee/whitelabeling', () => ({ useBrandConfig: () => ({ logoUrl: '/logo.png' }) })) +vi.mock('@/stores', () => ({ clearUserData: vi.fn() })) +vi.mock('@/hooks/queries/general-settings', () => ({ + useGeneralSettings: () => ({ data: {}, isLoading: false }), + useUpdateGeneralSetting: () => ({ mutateAsync: vi.fn() }), +})) +vi.mock('@/hooks/queries/user-profile', () => ({ + useUserProfile: () => ({ + data: { name: 'Test user', email: 'user@example.com' }, + isLoading: false, + }), + useUpdateUserProfile: () => ({ mutateAsync: vi.fn() }), + useResetPassword: () => ({ mutateAsync: vi.fn() }), +})) +vi.mock('@/hooks/queries/oauth-provider', () => ({ + useAuthorizedApps: mockUseAuthorizedApps, + useRevokeAuthorizedApp: () => ({ mutate: vi.fn() }), +})) +vi.mock('@/app/workspace/[workspaceId]/settings/hooks/use-profile-picture-upload', () => ({ + useProfilePictureUpload: () => ({ + fileInputRef: { current: null }, + handleThumbnailClick: vi.fn(), + handleFileChange: vi.fn(), + }), +})) +vi.mock( + '@/app/workspace/[workspaceId]/settings/components/general/components/delete-account-modal', + () => ({ + DeleteAccountModal: () => null, + }) +) +vi.mock( + '@/app/workspace/[workspaceId]/settings/components/general/components/privacy-view', + () => ({ + PrivacyView: () => null, + }) +) +vi.mock('@/app/workspace/[workspaceId]/settings/components/settings-panel', () => ({ + SettingsPanel: ({ + children, + title, + back, + search, + }: { + children: ReactNode + title?: string + back?: { onSelect: () => void; text: string } + search?: { value: string; onChange: (value: string) => void; placeholder: string } + }) => ( + <> + {back && ( + + )} + {title &&

{title}

} + {search && ( + search.onChange(event.target.value)} + /> + )} + {children} + + ), +})) + +import { AuthorizedApps } from '@/app/workspace/[workspaceId]/settings/components/authorized-apps/authorized-apps' +import { General } from '@/app/workspace/[workspaceId]/settings/components/general/general' + +let root: Root +let container: HTMLDivElement + +async function renderGeneral(searchParams = '') { + await act(async () => { + root.render( + + + + + + ) + }) +} + +describe('General authorized apps subview', () => { + beforeEach(() => { + vi.clearAllMocks() + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + mockUseSession.mockReturnValue({ data: { user: { id: 'viewer-a' } } }) + mockUseAuthorizedApps.mockReturnValue({ data: { pages: [{ apps: [] }] }, isPending: false }) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + }) + + it('opens the full management view from Account without loading grants beforehand', async () => { + await renderGeneral('?keep=value') + expect(mockUseAuthorizedApps).not.toHaveBeenCalled() + + const label = [...container.querySelectorAll('label')].find( + (node) => node.textContent === 'Authorized apps' + )! + await act(async () => label.parentElement!.querySelector('button')!.click()) + + expect(container.querySelector('h1')?.textContent).toBe('Authorized apps') + expect(container.querySelector('input[aria-label="Search authorized apps..."]')).not.toBeNull() + expect(container.textContent).toContain('No apps have access to your account') + await vi.waitFor(() => expect(mockUrlUpdate).toHaveBeenCalled()) + const update = mockUrlUpdate.mock.calls.at(-1)![0] + expect(update.searchParams.get('view')).toBe('authorized-apps') + expect(update.searchParams.get('keep')).toBe('value') + expect(update.options.history).toBe('push') + }) + + it('opens a direct link and clears its search on Back without adding history', async () => { + await renderGeneral('?view=authorized-apps&search=old&keep=value') + expect(container.querySelector('h1')?.textContent).toBe('Authorized apps') + expect(mockUseAuthorizedApps).toHaveBeenCalledWith('old') + + const back = [...container.querySelectorAll('button')].find( + (node) => node.textContent === 'General' + )! + await act(async () => back.click()) + + expect(container.querySelector('h1')).toBeNull() + await vi.waitFor(() => expect(mockUrlUpdate).toHaveBeenCalled()) + const update = mockUrlUpdate.mock.calls.at(-1)![0] + expect(update.searchParams.has('view')).toBe(false) + expect(update.searchParams.has('search')).toBe(false) + expect(update.searchParams.get('keep')).toBe('value') + expect(update.options.history).toBe('replace') + }) + + it('keeps account grants unavailable when authentication is disabled', async () => { + mockUseSession.mockReturnValue({ data: { user: { id: ANONYMOUS_USER_ID } } }) + await renderGeneral('?view=authorized-apps') + + expect(container.textContent).not.toContain('Authorized apps') + expect(mockUseAuthorizedApps).not.toHaveBeenCalled() + }) +}) 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 03d0d5de409..9ffff5c4db5 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/general.tsx @@ -19,6 +19,7 @@ import { } from '@sim/emcn' import { Camera, Check, CircleInfo, Pencil } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' +import dynamic from 'next/dynamic' import Image from 'next/image' import { useRouter } from 'next/navigation' import { useQueryState } from 'nuqs' @@ -50,6 +51,12 @@ import { } from '@/hooks/queries/user-profile' import { clearUserData } from '@/stores' +const AuthorizedApps = dynamic(() => + import('@/app/workspace/[workspaceId]/settings/components/authorized-apps/authorized-apps').then( + (module) => module.AuthorizedApps + ) +) + const logger = createLogger('General') /** Human-friendly timezone options for the picker, common zones first. */ @@ -273,7 +280,11 @@ export function General() { const imageUrl = profilePictureUrl || profile?.image || brandConfig.logoUrl if (view === 'privacy') { - return setView(null)} /> + return setView(null, { history: 'replace' })} /> + } + + if (view === 'authorized-apps' && !isAuthDisabled) { + return setView(null, { history: 'replace' })} /> } const actions: SettingsAction[] = [ @@ -596,9 +607,15 @@ export function General() { {!isAuthDisabled && ( -
- - setShowDeleteAccountModal(true)}>Delete +
+
+ + setView('authorized-apps')}>Manage +
+
+ + setShowDeleteAccountModal(true)}>Delete +
)} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/general/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/components/general/search-params.ts index 93a3fa7316c..ada30101961 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/general/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/general/search-params.ts @@ -1,13 +1,12 @@ import { parseAsStringLiteral } from 'nuqs/server' /** - * The sub-view open inside General. Only `privacy` exists today; the literal - * parser means an unknown value from an old link falls back to General rather - * than rendering an empty detail pane. + * General's sub-view. Unknown values fall back to General rather than rendering + * an empty detail pane. */ export const generalViewParam = { key: 'view', - parser: parseAsStringLiteral(['privacy'] as const), + parser: parseAsStringLiteral(['privacy', 'authorized-apps'] as const), } as const /** Opening the sub-view is a destination — Back should return to General. */ diff --git a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts index 06e4363c029..f304c1e6574 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/settings/navigation.test.ts @@ -37,7 +37,6 @@ describe('unified settings navigation', () => { { id: 'custom-tools', label: 'Custom tools', section: 'workspace' }, { id: 'mcp', label: 'MCP tools', section: 'workspace' }, { id: 'apikeys', label: 'Sim API keys', section: 'workspace' }, - { id: 'authorized-apps', label: 'Authorized apps', section: 'account' }, { id: 'workflow-mcp-servers', label: 'MCP servers', section: 'workspace' }, { id: 'byok', label: 'BYOK', section: 'workspace' }, { id: 'sandboxes', label: 'Sandboxes', section: 'workspace' }, @@ -68,7 +67,6 @@ describe('unified settings navigation', () => { 'desktop', 'browser', 'terminal', - 'authorized-apps', ]) expect(idsForSection('workspace')).toEqual([ 'teammates', diff --git a/apps/sim/components/settings/account-settings-renderer.tsx b/apps/sim/components/settings/account-settings-renderer.tsx index 56af48a60cc..7cefeba5d47 100644 --- a/apps/sim/components/settings/account-settings-renderer.tsx +++ b/apps/sim/components/settings/account-settings-renderer.tsx @@ -17,11 +17,6 @@ const ApiKeys = dynamic(() => (module) => module.ApiKeys ) ) -const AuthorizedApps = dynamic(() => - import('@/app/workspace/[workspaceId]/settings/components/authorized-apps/authorized-apps').then( - (module) => module.AuthorizedApps - ) -) const Admin = dynamic(() => import('@/app/workspace/[workspaceId]/settings/components/admin/admin').then( (module) => module.Admin @@ -47,7 +42,6 @@ export function AccountSettingsRenderer({ section }: AccountSettingsRendererProp if (section === 'general') return if (section === 'billing') return if (section === 'api-keys') return - if (section === 'authorized-apps') return if (section === 'admin') return return } diff --git a/apps/sim/components/settings/navigation.test.ts b/apps/sim/components/settings/navigation.test.ts index ab4884fc3ef..270209f733d 100644 --- a/apps/sim/components/settings/navigation.test.ts +++ b/apps/sim/components/settings/navigation.test.ts @@ -110,7 +110,6 @@ describe('settings navigation boundaries', () => { 'custom-tools', 'mcp', 'apikeys', - 'authorized-apps', 'workflow-mcp-servers', 'byok', 'sandboxes', @@ -130,7 +129,6 @@ describe('settings navigation boundaries', () => { 'general', 'billing', 'api-keys', - 'authorized-apps', 'admin', 'mothership', ]) diff --git a/apps/sim/components/settings/navigation.ts b/apps/sim/components/settings/navigation.ts index 13a724bc98e..b1f09e3054c 100644 --- a/apps/sim/components/settings/navigation.ts +++ b/apps/sim/components/settings/navigation.ts @@ -3,7 +3,6 @@ import { ChartColumn, ClipboardList, Clock, - Connections, Credit, Database, Globe, @@ -34,13 +33,7 @@ import type { DeploymentFeatures, DeploymentShape } from '@/lib/api/contracts/wo export type SettingsPlane = 'account' | 'selfhost' | 'workspace' -export type AccountSettingsSection = - | 'general' - | 'billing' - | 'api-keys' - | 'authorized-apps' - | 'admin' - | 'mothership' +export type AccountSettingsSection = 'general' | 'billing' | 'api-keys' | 'admin' | 'mothership' /** * Settings a self-hoster needs from the managed service: their profile, what @@ -102,7 +95,6 @@ export type UnifiedSettingsSection = | 'custom-blocks' | 'audit-logs' | 'apikeys' - | 'authorized-apps' | 'byok' | 'billing' | 'teammates' @@ -591,24 +583,6 @@ export const SETTINGS_SECTION_REGISTRY: readonly SettingsSectionRegistryEntry[] }, }, }, - { - label: 'Authorized apps', - icon: Connections, - unified: { - id: 'authorized-apps', - description: 'Review and revoke apps that can act on your account.', - group: 'account', - order: 4, - }, - planes: { - account: { - id: 'authorized-apps', - description: 'Review and revoke apps that can act on your account.', - group: 'developer', - order: 3, - }, - }, - }, { label: 'MCP servers', icon: Server, diff --git a/packages/sim-cli/README.md b/packages/sim-cli/README.md index 0344b40b023..d9b230edbcd 100644 --- a/packages/sim-cli/README.md +++ b/packages/sim-cli/README.md @@ -36,7 +36,7 @@ sim login The CLI opens Sim in your browser, asks you to approve the requested access, and receives the one-time authorization code on a loopback callback. It stores a short-lived OAuth login that renews automatically and can be revoked under -**Settings → Authorized apps**. Choose a default workspace afterward with +**Settings → General → Authorized apps**. Choose a default workspace afterward with `sim configure --set-workspace `. Use `sim login --no-browser` to print the OAuth URL without opening it. The diff --git a/packages/sim-cli/src/auth/oauth-flow.ts b/packages/sim-cli/src/auth/oauth-flow.ts index 24f79f7d244..6e28ecb29e0 100644 --- a/packages/sim-cli/src/auth/oauth-flow.ts +++ b/packages/sim-cli/src/auth/oauth-flow.ts @@ -17,7 +17,7 @@ import { USER_AGENT } from '../version' * the life of one login. * * The result is a short-lived access token and a rotating refresh token, both - * revocable from Settings → Authorized apps, instead of the permanent API key + * revocable from Settings → General → Authorized apps, instead of the permanent API key * the pairing-code handoff in `device-flow.ts` mints. That handoff remains the * path for a terminal whose browser cannot reach it (SSH, containers). */ diff --git a/packages/sim-cli/src/auth/refresh.ts b/packages/sim-cli/src/auth/refresh.ts index f0e2d30864b..115c1ca5a9c 100644 --- a/packages/sim-cli/src/auth/refresh.ts +++ b/packages/sim-cli/src/auth/refresh.ts @@ -26,7 +26,7 @@ const REFRESH_TIMEOUT_MS = 10 * 1000 * and containing a copied token remains the authorization server's job. * * `invalid_grant` means the server no longer honours the refresh token — it - * was revoked from Settings → Authorized apps, expired, or was already rotated + * was revoked from Settings → General → Authorized apps, expired, or was already rotated * by a process this one could not see — and the remedy is logout followed by a * new login. */ diff --git a/packages/sim-cli/src/commands/auth.ts b/packages/sim-cli/src/commands/auth.ts index 103dd7c0fc8..7436f4da1bc 100644 --- a/packages/sim-cli/src/commands/auth.ts +++ b/packages/sim-cli/src/commands/auth.ts @@ -501,7 +501,7 @@ async function loginWithOAuth( } catch (revocationError) { console.log( chalk.yellow( - `Could not revoke the uncommitted login (${safeOneLine(getErrorMessage(revocationError))}). Revoke Sim CLI in Settings → Authorized apps.` + `Could not revoke the uncommitted login (${safeOneLine(getErrorMessage(revocationError))}). Revoke Sim CLI in Settings → General → Authorized apps.` ) ) } @@ -518,7 +518,7 @@ async function loginWithOAuth( console.log( chalk.dim( grantsWriteAccess(tokens.scope) - ? ' Renews itself; revoke it any time in Settings → Authorized apps, or with: sim logout' + ? ' Renews itself; revoke it any time in Settings → General → Authorized apps, or with: sim logout' : ' Read-only login — commands that change anything will be refused.' ) ) @@ -708,7 +708,7 @@ async function loginWithHandoff( /** * Revokes the complete server-side token family before forgetting it locally. - * Settings → Authorized apps is broader: it revokes every independent login + * Settings → General → Authorized apps is broader: it revokes every independent login * for the client. An unreachable server must not stop someone clearing their * machine, but it is said out loud so nobody assumes revocation succeeded. */ @@ -739,7 +739,7 @@ async function revokeStoredOAuth(credential: StoredOAuthCredential): Promise