Skip to content

Commit 4760fc0

Browse files
committed
fix(desktop): route organization members to their organization and settings
1 parent f17f1b6 commit 4760fc0

9 files changed

Lines changed: 537 additions & 278 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
3+
vi.mock('electron', () => import('@/test/electron-mock'))
4+
5+
import { settingsRoute } from '@/main/app-routes'
6+
7+
describe('settingsRoute', () => {
8+
it('opens the settings of the organization the user was last in', () => {
9+
expect(settingsRoute('/o/org-1/search?q=x')).toBe('/o/org-1/settings/general')
10+
})
11+
12+
it('keeps the desktop settings of the workspace the user was last in', () => {
13+
expect(settingsRoute('/workspace/ws-1/home')).toBe('/workspace/ws-1/settings/desktop')
14+
})
15+
16+
it.each([
17+
['the workspace picker', '/workspace'],
18+
['the app entry', '/home'],
19+
['no saved route', undefined],
20+
['a route that only starts like an organization', '/oauth/o/org-1'],
21+
['an unsafe route', '//evil.example/o/org-1'],
22+
])('resolves settings through the web app from %s', (_label, lastRoute) => {
23+
expect(settingsRoute(lastRoute)).toBe('/workspace?redirect=settings')
24+
})
25+
})

‎apps/desktop/src/main/app-routes.ts‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ import { isSafeInternalPath } from '@/main/config'
1616
*/
1717
export const APP_ENTRY_ROUTE = '/home'
1818

19+
/**
20+
* Settings for a viewer whose last route names no workspace or organization. The web
21+
* app resolves it to their most recent workspace, or to their organization's settings.
22+
*/
23+
const SETTINGS_ENTRY_ROUTE = '/workspace?redirect=settings'
24+
1925
/** Workspace id from the last visited route, or null when it carries none. */
2026
function workspaceIdFromRoute(lastRoute: string | undefined): string | null {
2127
if (isSafeInternalPath(lastRoute)) {
@@ -27,6 +33,16 @@ function workspaceIdFromRoute(lastRoute: string | undefined): string | null {
2733
return null
2834
}
2935

36+
function organizationIdFromRoute(lastRoute: string | undefined): string | null {
37+
if (isSafeInternalPath(lastRoute)) {
38+
const match = /^\/o\/([^/?#]+)/.exec(lastRoute)
39+
if (match) {
40+
return match[1]
41+
}
42+
}
43+
return null
44+
}
45+
3046
/**
3147
* Route for "New Chat": the home (chat) surface of the workspace the user was
3248
* last in, falling back to the app entry when the last route carries no
@@ -38,10 +54,14 @@ export function newChatRoute(lastRoute: string | undefined): string {
3854
}
3955

4056
/**
41-
* Route for "Settings…": the Sim app's settings surface for the workspace the
42-
* user was last in, falling back to the app entry.
57+
* Route for "Settings…": the settings of the workspace or organization the user
58+
* was last in. Never the app entry, which lands on a page without settings.
4359
*/
4460
export function settingsRoute(lastRoute: string | undefined): string {
4561
const workspaceId = workspaceIdFromRoute(lastRoute)
46-
return workspaceId ? `/workspace/${workspaceId}/settings/desktop` : APP_ENTRY_ROUTE
62+
if (workspaceId) {
63+
return `/workspace/${workspaceId}/settings/desktop`
64+
}
65+
const organizationId = organizationIdFromRoute(lastRoute)
66+
return organizationId ? `/o/${organizationId}/settings/general` : SETTINGS_ENTRY_ROUTE
4767
}

‎apps/desktop/src/main/handoff.test.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { get as httpGet } from 'node:http'
2+
import type { BrowserWindow } from 'electron'
23
import { describe, expect, it, vi } from 'vitest'
34

45
vi.mock('electron', () => import('@/test/electron-mock'))
56

67
import {
8+
createAuthFlow,
79
createHandoffManager,
810
type HandoffCallback,
911
type HandoffCallbacks,
@@ -161,6 +163,31 @@ describe('createHandoffManager', () => {
161163
})
162164
})
163165

166+
describe('login handoff landing', () => {
167+
it('lands on the app entry after redeeming the login token', async () => {
168+
const win = {
169+
isDestroyed: () => false,
170+
loadURL: vi.fn(async () => {}),
171+
show: vi.fn(),
172+
focus: vi.fn(),
173+
webContents: {
174+
getURL: () => 'https://sim.ai/login',
175+
executeJavaScript: vi.fn(async () => 200),
176+
},
177+
}
178+
const flow = createAuthFlow({
179+
handoff: { consume: () => true } as unknown as ReturnType<typeof createHandoffManager>,
180+
origin: () => 'https://sim.ai',
181+
events: makeEvents(),
182+
ensureMainWindow: async () => win as unknown as BrowserWindow,
183+
})
184+
185+
await flow.handleCallback({ token: VALID_TOKEN, state: _VALID_STATE })
186+
187+
expect(win.loadURL).toHaveBeenCalledExactlyOnceWith('https://sim.ai/home')
188+
})
189+
})
190+
164191
describe('connect handoff account pinning', () => {
165192
it('keeps a source request correlated across the browser and native completion', async () => {
166193
const deps = makeDeps()

‎apps/desktop/src/main/handoff.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { getErrorMessage } from '@sim/utils/errors'
66
import { generateShortId } from '@sim/utils/id'
77
import type { BrowserWindow } from 'electron'
88
import { app } from 'electron'
9+
import { APP_ENTRY_ROUTE } from '@/main/app-routes'
910
import { showShellDialog } from '@/main/dialogs'
1011
import type { EventRecorder } from '@/main/observability'
1112

@@ -451,7 +452,7 @@ export interface AuthFlow {
451452

452453
/**
453454
* Orchestrates the login handoff: opening the system browser, consuming the
454-
* loopback callback, redeeming the token, and navigating to the workspace. A
455+
* loopback callback, redeeming the token, and navigating to the app entry. A
455456
* failed or expired callback never leaves a partial session — the window lands
456457
* back on /login.
457458
*/
@@ -521,7 +522,7 @@ export function createAuthFlow(deps: AuthFlowDeps): AuthFlow {
521522
}
522523
deps.events.record('handoff_redeem_ok')
523524
try {
524-
await win.loadURL(`${origin}/workspace`)
525+
await win.loadURL(`${origin}${APP_ENTRY_ROUTE}`)
525526
} catch {}
526527
win.show()
527528
win.focus()
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { authMockFns } from '@sim/testing/mocks/auth.mock'
5+
import { nextNavigationMock } from '@sim/testing/mocks/next-navigation.mock'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const { mockResolveWorkspacelessMemberPath } = vi.hoisted(() => ({
9+
mockResolveWorkspacelessMemberPath: vi.fn(),
10+
}))
11+
12+
vi.mock('next/navigation', () => nextNavigationMock)
13+
14+
vi.mock('@/lib/navigation/resolve-app-entry', () => ({
15+
resolveWorkspacelessMemberPath: mockResolveWorkspacelessMemberPath,
16+
}))
17+
18+
vi.mock('@/app/workspace/workspace-entry', () => ({ WorkspaceEntry: () => null }))
19+
20+
import WorkspacePage from '@/app/workspace/page'
21+
22+
const mockGetSession = authMockFns.mockGetSession
23+
const session = { user: { id: 'viewer' } }
24+
25+
function visit(search: { redirect?: string } = {}) {
26+
return WorkspacePage({ searchParams: Promise.resolve(search) })
27+
}
28+
29+
describe('WorkspacePage', () => {
30+
beforeEach(() => {
31+
mockGetSession.mockResolvedValue(session)
32+
mockResolveWorkspacelessMemberPath.mockResolvedValue(null)
33+
})
34+
35+
it('forwards a workspaceless member before the picker renders', async () => {
36+
mockResolveWorkspacelessMemberPath.mockResolvedValue('/o/org-1/home')
37+
await expect(visit()).rejects.toThrow('NEXT_REDIRECT:/o/org-1/home')
38+
expect(mockResolveWorkspacelessMemberPath).toHaveBeenCalledWith(session, 'home')
39+
})
40+
41+
it('carries a settings request through to the destination', async () => {
42+
mockResolveWorkspacelessMemberPath.mockResolvedValue('/o/org-1/settings/general')
43+
await expect(visit({ redirect: 'settings' })).rejects.toThrow(
44+
'NEXT_REDIRECT:/o/org-1/settings/general'
45+
)
46+
expect(mockResolveWorkspacelessMemberPath).toHaveBeenCalledWith(session, 'settings')
47+
})
48+
49+
it('leaves a stale session on the recovery surface', async () => {
50+
mockGetSession.mockResolvedValue(null)
51+
await expect(visit()).resolves.toBeTruthy()
52+
expect(mockResolveWorkspacelessMemberPath).not.toHaveBeenCalled()
53+
})
54+
55+
it.each([
56+
['session', () => mockGetSession.mockRejectedValue(new Error('down'))],
57+
['destination', () => mockResolveWorkspacelessMemberPath.mockRejectedValue(new Error('down'))],
58+
])('keeps the recovery surface when the %s lookup fails', async (_label, fail) => {
59+
fail()
60+
await expect(visit()).resolves.toBeTruthy()
61+
})
62+
})

0 commit comments

Comments
 (0)