Skip to content

Commit 83544b1

Browse files
authored
fix(settings): move authorized apps into General (#7577)
1 parent d7124cd commit 83544b1

20 files changed

Lines changed: 332 additions & 65 deletions

File tree

apps/docs/content/docs/api-reference/authentication.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ curl https://www.sim.ai/api/v2/workspaces \
108108

109109
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.
110110

111-
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.
111+
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.
112112

113113
## Security
114114

apps/docs/content/docs/cli/authentication.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ https://www.sim.ai/api/auth/oauth2/authorize?client_id=sim-cli&…
2727
Waiting for you to approve in the browser…
2828
2929
✓ Logged in. Login stored in /Users/you/.sim/credentials
30-
Renews itself; revoke it any time in Settings → Authorized apps, or with: sim logout
30+
Renews itself; revoke it any time in Settings → General → Authorized apps, or with: sim logout
3131
No default workspace. Set one with: sim configure --set-workspace <id>
3232
```
3333

@@ -151,7 +151,7 @@ For an OAuth login, `sim logout` revokes that login's complete token family
151151
before removing it from disk, including access tokens issued before earlier
152152
rotations. Other machines that ran their own `sim login` remain signed in. To
153153
cut off every independent login for the client, revoke the grant under
154-
**Settings → Authorized apps**.
154+
**Settings → General → Authorized apps**.
155155

156156
A workspace profile that shares authentication cannot remove the shared login.
157157
Remove only that local profile with `sim logout --all --profile <name>`, or log

apps/docs/content/docs/platform/self-hosting/authentication.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ requires a real Better Auth user session.
122122
Access tokens are opaque and last an hour; refresh tokens rotate on every use.
123123
Each login has a fixed thirty-day lifetime that refreshing does not extend.
124124
Token validation checks current grants, so revoking a grant under
125-
**Settings → Authorized apps** stops the app on its very next request. These
125+
**Settings → General → Authorized apps** stops the app on its very next request. These
126126
settings remain available for reviewing and revoking existing grants while the
127127
provider is off, and scheduled OAuth token cleanup continues.
128128

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const { mockGetSession, mockPrefetch } = vi.hoisted(() => ({
7+
mockGetSession: vi.fn(),
8+
mockPrefetch: vi.fn(),
9+
}))
10+
11+
vi.mock('next/navigation', () => ({
12+
notFound: () => {
13+
throw new Error('NEXT_NOT_FOUND')
14+
},
15+
redirect: (href: string) => {
16+
throw new Error(`NEXT_REDIRECT:${href}`)
17+
},
18+
}))
19+
vi.mock('@/lib/auth', () => ({ getSession: mockGetSession }))
20+
vi.mock('@/lib/core/config/env-flags', () => ({ isBillingEnabled: true }))
21+
vi.mock('@/lib/permissions/super-user', () => ({ isPlatformAdmin: vi.fn() }))
22+
vi.mock('@/app/_shell/providers/get-query-client', () => ({ getQueryClient: vi.fn() }))
23+
vi.mock('@/components/settings/prefetch-standalone-general', () => ({
24+
prefetchStandaloneGeneral: mockPrefetch,
25+
}))
26+
vi.mock('@/components/settings/account-settings-renderer', () => ({
27+
AccountSettingsRenderer: () => null,
28+
}))
29+
30+
import AccountSettingsSectionPage from '@/app/account/settings/[section]/page'
31+
32+
const pageProps = (section: string) => ({ params: Promise.resolve({ section }) })
33+
34+
describe('account settings legacy links', () => {
35+
beforeEach(() => {
36+
vi.clearAllMocks()
37+
mockGetSession.mockResolvedValue({ user: { id: 'viewer-a' } })
38+
})
39+
40+
it('redirects Authorized apps bookmarks to the General subview', async () => {
41+
await expect(AccountSettingsSectionPage(pageProps('authorized-apps'))).rejects.toThrow(
42+
'NEXT_REDIRECT:/account/settings/general?view=authorized-apps'
43+
)
44+
expect(mockPrefetch).not.toHaveBeenCalled()
45+
})
46+
47+
it('authenticates before following the legacy bookmark', async () => {
48+
mockGetSession.mockResolvedValue(null)
49+
50+
await expect(AccountSettingsSectionPage(pageProps('authorized-apps'))).rejects.toThrow(
51+
'NEXT_REDIRECT:/login'
52+
)
53+
})
54+
55+
it('still rejects unknown sections', async () => {
56+
await expect(AccountSettingsSectionPage(pageProps('unknown'))).rejects.toThrow('NEXT_NOT_FOUND')
57+
})
58+
})

apps/sim/app/account/settings/[section]/page.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ export default async function AccountSettingsSectionPage({
4141
if (!session?.user) redirect('/login')
4242

4343
const { section } = await params
44+
if (section === 'authorized-apps') {
45+
redirect(`${getAccountSettingsHref('general')}?view=authorized-apps`)
46+
}
4447
const parsed = parseSettingsPathSection({
4548
path: section,
4649
items: ACCOUNT_SETTINGS_ITEMS,
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it, vi } from 'vitest'
5+
6+
vi.mock('next/navigation', () => ({
7+
notFound: () => {
8+
throw new Error('NEXT_NOT_FOUND')
9+
},
10+
redirect: (href: string) => {
11+
throw new Error(`NEXT_REDIRECT:${href}`)
12+
},
13+
}))
14+
vi.mock(
15+
'@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header',
16+
() => ({
17+
SettingsHeaderProvider: () => null,
18+
SettingsHeaderShell: () => null,
19+
})
20+
)
21+
22+
import SettingsSectionLayout from '@/app/workspace/[workspaceId]/settings/[section]/layout'
23+
24+
const layoutProps = (section: string) => ({
25+
children: null,
26+
params: Promise.resolve({ workspaceId: 'workspace-a', section }),
27+
})
28+
29+
describe('workspace settings legacy links', () => {
30+
it.each(['privacy', 'authorized-apps'])(
31+
'redirects %s before rendering the shell',
32+
async (view) => {
33+
await expect(SettingsSectionLayout(layoutProps(view))).rejects.toThrow(
34+
`NEXT_REDIRECT:/workspace/workspace-a/settings/general?view=${view}`
35+
)
36+
}
37+
)
38+
39+
it('still rejects unknown sections', async () => {
40+
await expect(SettingsSectionLayout(layoutProps('unknown'))).rejects.toThrow('NEXT_NOT_FOUND')
41+
})
42+
})

apps/sim/app/workspace/[workspaceId]/settings/[section]/layout.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@ import {
66
import { resolveSettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'
77

88
/**
9-
* Sections that were promoted out of settings into their own workspace routes. Kept as
10-
* segment-level rewrites so old links and bookmarks still land somewhere sensible.
9+
* Legacy settings sections kept as redirects so old links and bookmarks still work.
1110
*/
1211
const TOP_LEVEL_REDIRECTS: Readonly<Record<string, (workspaceId: string) => string>> = {
1312
integrations: (workspaceId) => `/workspace/${workspaceId}/integrations`,
1413
skills: (workspaceId) => `/workspace/${workspaceId}/skills`,
1514
/** Cookie preferences moved into General. */
1615
privacy: (workspaceId) => `/workspace/${workspaceId}/settings/general?view=privacy`,
16+
'authorized-apps': (workspaceId) =>
17+
`/workspace/${workspaceId}/settings/general?view=authorized-apps`,
1718
}
1819

1920
/**

apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,6 @@ const ApiKeys = dynamic(() =>
2525
const BYOK = dynamic(() =>
2626
import('@/app/workspace/[workspaceId]/settings/components/byok/byok').then((m) => m.BYOK)
2727
)
28-
const AuthorizedApps = dynamic(() =>
29-
import('@/app/workspace/[workspaceId]/settings/components/authorized-apps/authorized-apps').then(
30-
(m) => m.AuthorizedApps
31-
)
32-
)
3328
const Forks = dynamic(() => import('@/ee/workspace-forking/components/forks').then((m) => m.Forks))
3429
const Secrets = dynamic(() =>
3530
import('@/app/workspace/[workspaceId]/settings/components/secrets/secrets').then((m) => m.Secrets)
@@ -189,7 +184,6 @@ export function SettingsPage({ section }: SettingsPageProps) {
189184
/>
190185
)}
191186
{effectiveSection === 'apikeys' && <ApiKeys scope='combined' />}
192-
{effectiveSection === 'authorized-apps' && <AuthorizedApps />}
193187
{billingEnabled && effectiveSection === 'billing' && (
194188
<Billing
195189
scope={organizationId ? 'organization' : 'account'}

apps/sim/app/workspace/[workspaceId]/settings/components/authorized-apps/authorized-apps.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { useState } from 'react'
44
import { Chip, ChipConfirmModal, toast } from '@sim/emcn'
5+
import { ArrowLeft } from '@sim/emcn/icons'
56
import { getErrorMessage } from '@sim/utils/errors'
67
import { formatDate } from '@sim/utils/formatting'
78
import { summarizeOAuthAccess } from '@/lib/auth/oauth-provider'
@@ -18,12 +19,16 @@ import {
1819
import { useSettingsSearch } from '@/app/workspace/[workspaceId]/settings/components/use-settings-search'
1920
import { useAuthorizedApps, useRevokeAuthorizedApp } from '@/hooks/queries/oauth-provider'
2021

22+
interface AuthorizedAppsProps {
23+
onBack: () => void
24+
}
25+
2126
/**
2227
* The apps this account has authorized through Sim's OAuth provider. Revoking
2328
* one withdraws its consent and kills every token it holds, so the next
2429
* request it makes fails and the next sign-in asks again.
2530
*/
26-
export function AuthorizedApps() {
31+
export function AuthorizedApps({ onBack }: AuthorizedAppsProps) {
2732
const [searchTerm, setSearchTerm] = useSettingsSearch()
2833
const apps = useAuthorizedApps(searchTerm.trim())
2934
const revoke = useRevokeAuthorizedApp()
@@ -46,6 +51,16 @@ export function AuthorizedApps() {
4651
return (
4752
<>
4853
<SettingsPanel
54+
back={{
55+
text: 'General',
56+
icon: ArrowLeft,
57+
onSelect: () => {
58+
setSearchTerm('')
59+
onBack()
60+
},
61+
}}
62+
title='Authorized apps'
63+
description='Review and revoke apps that can act on your account.'
4964
search={{
5065
value: searchTerm,
5166
onChange: setSearchTerm,

0 commit comments

Comments
 (0)