Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/docs/content/docs/api-reference/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions apps/docs/content/docs/cli/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>
```

Expand Down Expand Up @@ -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 <name>`, or log
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
58 changes: 58 additions & 0 deletions apps/sim/app/account/settings/[section]/page.test.tsx
Original file line number Diff line number Diff line change
@@ -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')
})
})
3 changes: 3 additions & 0 deletions apps/sim/app/account/settings/[section]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, (workspaceId: string) => 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`,
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -189,7 +184,6 @@ export function SettingsPage({ section }: SettingsPageProps) {
/>
)}
{effectiveSection === 'apikeys' && <ApiKeys scope='combined' />}
{effectiveSection === 'authorized-apps' && <AuthorizedApps />}
{billingEnabled && effectiveSection === 'billing' && (
<Billing
scope={organizationId ? 'organization' : 'account'}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

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

interface AuthorizedAppsProps {
onBack: () => 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()
Expand All @@ -46,6 +51,16 @@ export function AuthorizedApps() {
return (
<>
<SettingsPanel
back={{
text: 'General',
icon: ArrowLeft,
onSelect: () => {
setSearchTerm('')
onBack()
},
}}
title='Authorized apps'
description='Review and revoke apps that can act on your account.'
search={{
value: searchTerm,
onChange: setSearchTerm,
Expand Down
Loading
Loading