Skip to content

Commit 0682629

Browse files
mrubenscteroomotebrunobergherjr
authored
Cloud account switcher (#8223)
* Cloud account switcher * Bare metal evals fixes (#8224) Co-authored-by: Roo Code <[email protected]> * Rounded icons in chat account switcher * Visual tweaks to CloudView * Remove hardcoded timeout * Safer check * PR feedback * Fix test * Check for org mismatches in handleCredentialsChange * Cloud: use the existing auth event flow to handle org switching * Cleanup: broadcast() might be confusingly named. --------- Co-authored-by: Chris Estreich <[email protected]> Co-authored-by: Roo Code <[email protected]> Co-authored-by: Bruno Bergher <[email protected]> Co-authored-by: John Richmond <[email protected]>
1 parent 382ab63 commit 0682629

33 files changed

+528
-44
lines changed

packages/cloud/src/CloudService.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
AuthService,
99
SettingsService,
1010
CloudUserInfo,
11+
CloudOrganizationMembership,
1112
OrganizationAllowList,
1213
OrganizationSettings,
1314
ShareVisibility,
@@ -242,6 +243,21 @@ export class CloudService extends EventEmitter<CloudServiceEvents> implements Di
242243
return this.authService!.handleCallback(code, state, organizationId)
243244
}
244245

246+
public async switchOrganization(organizationId: string | null): Promise<void> {
247+
this.ensureInitialized()
248+
249+
// Perform the organization switch
250+
// StaticTokenAuthService will throw an error if organization switching is not supported
251+
await this.authService!.switchOrganization(organizationId)
252+
}
253+
254+
public async getOrganizationMemberships(): Promise<CloudOrganizationMembership[]> {
255+
this.ensureInitialized()
256+
257+
// StaticTokenAuthService will throw an error if organization memberships are not supported
258+
return await this.authService!.getOrganizationMemberships()
259+
}
260+
245261
// SettingsService
246262

247263
public getAllowList(): OrganizationAllowList {

packages/cloud/src/StaticTokenAuthService.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,14 @@ export class StaticTokenAuthService extends EventEmitter<AuthServiceEvents> impl
6363
throw new Error("Authentication methods are disabled in StaticTokenAuthService")
6464
}
6565

66+
public async switchOrganization(_organizationId: string | null): Promise<void> {
67+
throw new Error("Authentication methods are disabled in StaticTokenAuthService")
68+
}
69+
70+
public async getOrganizationMemberships(): Promise<import("@roo-code/types").CloudOrganizationMembership[]> {
71+
throw new Error("Authentication methods are disabled in StaticTokenAuthService")
72+
}
73+
6674
public getState(): AuthState {
6775
return this.state
6876
}

packages/cloud/src/WebAuthService.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,8 @@ export class WebAuthService extends EventEmitter<AuthServiceEvents> implements A
141141
if (
142142
this.credentials === null ||
143143
this.credentials.clientToken !== credentials.clientToken ||
144-
this.credentials.sessionId !== credentials.sessionId
144+
this.credentials.sessionId !== credentials.sessionId ||
145+
this.credentials.organizationId !== credentials.organizationId
145146
) {
146147
this.transitionToAttemptingSession(credentials)
147148
}
@@ -174,6 +175,7 @@ export class WebAuthService extends EventEmitter<AuthServiceEvents> implements A
174175

175176
this.changeState("attempting-session")
176177

178+
this.timer.stop()
177179
this.timer.start()
178180
}
179181

@@ -469,6 +471,42 @@ export class WebAuthService extends EventEmitter<AuthServiceEvents> implements A
469471
return this.credentials?.organizationId || null
470472
}
471473

474+
/**
475+
* Switch to a different organization context
476+
* @param organizationId The organization ID to switch to, or null for personal account
477+
*/
478+
public async switchOrganization(organizationId: string | null): Promise<void> {
479+
if (!this.credentials) {
480+
throw new Error("Cannot switch organization: not authenticated")
481+
}
482+
483+
// Update the stored credentials with the new organization ID
484+
const updatedCredentials: AuthCredentials = {
485+
...this.credentials,
486+
organizationId: organizationId,
487+
}
488+
489+
// Store the updated credentials, handleCredentialsChange will handle the update
490+
await this.storeCredentials(updatedCredentials)
491+
}
492+
493+
/**
494+
* Get all organization memberships for the current user
495+
* @returns Array of organization memberships
496+
*/
497+
public async getOrganizationMemberships(): Promise<CloudOrganizationMembership[]> {
498+
if (!this.credentials) {
499+
return []
500+
}
501+
502+
try {
503+
return await this.clerkGetOrganizationMemberships()
504+
} catch (error) {
505+
this.log(`[auth] Failed to get organization memberships: ${error}`)
506+
return []
507+
}
508+
}
509+
472510
private async clerkSignIn(ticket: string): Promise<AuthCredentials> {
473511
const formData = new URLSearchParams()
474512
formData.append("strategy", "ticket")
@@ -653,9 +691,14 @@ export class WebAuthService extends EventEmitter<AuthServiceEvents> implements A
653691
}
654692

655693
private async clerkGetOrganizationMemberships(): Promise<CloudOrganizationMembership[]> {
694+
if (!this.credentials) {
695+
this.log("[auth] Cannot get organization memberships: missing credentials")
696+
return []
697+
}
698+
656699
const response = await fetch(`${getClerkBaseUrl()}/v1/me/organization_memberships`, {
657700
headers: {
658-
Authorization: `Bearer ${this.credentials!.clientToken}`,
701+
Authorization: `Bearer ${this.credentials.clientToken}`,
659702
"User-Agent": this.userAgent(),
660703
},
661704
signal: AbortSignal.timeout(10000),

packages/types/src/cloud.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,7 @@ export interface AuthService extends EventEmitter<AuthServiceEvents> {
242242
login(landingPageSlug?: string): Promise<void>
243243
logout(): Promise<void>
244244
handleCallback(code: string | null, state: string | null, organizationId?: string | null): Promise<void>
245+
switchOrganization(organizationId: string | null): Promise<void>
245246

246247
// State methods
247248
getState(): AuthState
@@ -253,6 +254,9 @@ export interface AuthService extends EventEmitter<AuthServiceEvents> {
253254
getSessionToken(): string | undefined
254255
getUserInfo(): CloudUserInfo | null
255256
getStoredOrganizationId(): string | null
257+
258+
// Organization management
259+
getOrganizationMemberships(): Promise<CloudOrganizationMembership[]>
256260
}
257261

258262
/**

src/core/webview/ClineProvider.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
type TerminalActionPromptType,
3131
type HistoryItem,
3232
type CloudUserInfo,
33+
type CloudOrganizationMembership,
3334
type CreateTaskOptions,
3435
type TokenUsage,
3536
RooCodeEventName,
@@ -1815,6 +1816,16 @@ export class ClineProvider
18151816
featureRoomoteControlEnabled,
18161817
} = await this.getState()
18171818

1819+
let cloudOrganizations: CloudOrganizationMembership[] = []
1820+
1821+
try {
1822+
cloudOrganizations = await CloudService.instance.getOrganizationMemberships()
1823+
} catch (error) {
1824+
console.error(
1825+
`[getStateToPostToWebview] failed to get cloud organizations: ${error instanceof Error ? error.message : String(error)}`,
1826+
)
1827+
}
1828+
18181829
const telemetryKey = process.env.POSTHOG_API_KEY
18191830
const machineId = vscode.env.machineId
18201831
const mergedAllowedCommands = this.mergeAllowedCommands(allowedCommands)
@@ -1916,6 +1927,7 @@ export class ClineProvider
19161927
historyPreviewCollapsed: historyPreviewCollapsed ?? false,
19171928
cloudUserInfo,
19181929
cloudIsAuthenticated: cloudIsAuthenticated ?? false,
1930+
cloudOrganizations,
19191931
sharingEnabled: sharingEnabled ?? false,
19201932
organizationAllowList,
19211933
organizationSettingsVersion,

src/core/webview/webviewMessageHandler.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2379,6 +2379,38 @@ export const webviewMessageHandler = async (
23792379

23802380
break
23812381
}
2382+
case "switchOrganization": {
2383+
try {
2384+
const organizationId = message.organizationId ?? null
2385+
2386+
// Switch to the new organization context
2387+
await CloudService.instance.switchOrganization(organizationId)
2388+
2389+
// Refresh the state to update UI
2390+
await provider.postStateToWebview()
2391+
2392+
// Send success response back to webview
2393+
await provider.postMessageToWebview({
2394+
type: "organizationSwitchResult",
2395+
success: true,
2396+
organizationId: organizationId,
2397+
})
2398+
} catch (error) {
2399+
provider.log(`Organization switch failed: ${error}`)
2400+
const errorMessage = error instanceof Error ? error.message : String(error)
2401+
2402+
// Send error response back to webview
2403+
await provider.postMessageToWebview({
2404+
type: "organizationSwitchResult",
2405+
success: false,
2406+
error: errorMessage,
2407+
organizationId: message.organizationId ?? null,
2408+
})
2409+
2410+
vscode.window.showErrorMessage(`Failed to switch organization: ${errorMessage}`)
2411+
}
2412+
break
2413+
}
23822414

23832415
case "saveCodeIndexSettingsAtomic": {
23842416
if (!message.codeIndexSettings) {

src/shared/ExtensionMessage.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
MarketplaceItem,
1111
TodoItem,
1212
CloudUserInfo,
13+
CloudOrganizationMembership,
1314
OrganizationAllowList,
1415
ShareVisibility,
1516
QueuedMessage,
@@ -124,6 +125,7 @@ export interface ExtensionMessage {
124125
| "commands"
125126
| "insertTextIntoTextarea"
126127
| "dismissedUpsells"
128+
| "organizationSwitchResult"
127129
text?: string
128130
payload?: any // Add a generic payload for now, can refine later
129131
action?:
@@ -202,6 +204,7 @@ export interface ExtensionMessage {
202204
commands?: Command[]
203205
queuedMessages?: QueuedMessage[]
204206
list?: string[] // For dismissedUpsells
207+
organizationId?: string | null // For organizationSwitchResult
205208
}
206209

207210
export type ExtensionState = Pick<
@@ -327,6 +330,7 @@ export type ExtensionState = Pick<
327330
cloudUserInfo: CloudUserInfo | null
328331
cloudIsAuthenticated: boolean
329332
cloudApiUrl?: string
333+
cloudOrganizations?: CloudOrganizationMembership[]
330334
sharingEnabled: boolean
331335
organizationAllowList: OrganizationAllowList
332336
organizationSettingsVersion?: number

src/shared/WebviewMessage.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,7 @@ export interface WebviewMessage {
184184
| "cloudLandingPageSignIn"
185185
| "rooCloudSignOut"
186186
| "rooCloudManualUrl"
187+
| "switchOrganization"
187188
| "condenseTaskContextRequest"
188189
| "requestIndexingStatus"
189190
| "startIndexing"
@@ -274,6 +275,7 @@ export interface WebviewMessage {
274275
checkOnly?: boolean // For deleteCustomMode check
275276
upsellId?: string // For dismissUpsell
276277
list?: string[] // For dismissedUpsells response
278+
organizationId?: string | null // For organization switching
277279
codeIndexSettings?: {
278280
// Global state settings
279281
codebaseIndexEnabled: boolean

webview-ui/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ const App = () => {
7676
cloudUserInfo,
7777
cloudIsAuthenticated,
7878
cloudApiUrl,
79+
cloudOrganizations,
7980
renderContext,
8081
mdmCompliant,
8182
} = useExtensionState()
@@ -267,6 +268,7 @@ const App = () => {
267268
userInfo={cloudUserInfo}
268269
isAuthenticated={cloudIsAuthenticated}
269270
cloudApiUrl={cloudApiUrl}
271+
organizations={cloudOrganizations}
270272
onDone={() => switchTab("chat")}
271273
/>
272274
)}

webview-ui/src/components/chat/ChatTextArea.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { MAX_IMAGES_PER_MESSAGE } from "./ChatView"
3131
import ContextMenu from "./ContextMenu"
3232
import { IndexingStatusBadge } from "./IndexingStatusBadge"
3333
import { usePromptHistory } from "./hooks/usePromptHistory"
34+
import { CloudAccountSwitcher } from "../cloud/CloudAccountSwitcher"
3435

3536
interface ChatTextAreaProps {
3637
inputValue: string
@@ -87,6 +88,8 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
8788
taskHistory,
8889
clineMessages,
8990
commands,
91+
cloudUserInfo,
92+
cloudOrganizations,
9093
} = useExtensionState()
9194

9295
// Find the ID and display text for the currently selected API configuration.
@@ -1232,7 +1235,13 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
12321235
/>
12331236
<AutoApproveDropdown triggerClassName="min-w-[28px] text-ellipsis overflow-hidden flex-shrink" />
12341237
</div>
1235-
<div className="flex flex-shrink-0 items-center gap-0.5 pr-2">
1238+
<div
1239+
className={cn(
1240+
"flex flex-shrink-0 items-center gap-0.5",
1241+
!isEditMode && cloudOrganizations && cloudOrganizations.length > 0 && cloudUserInfo
1242+
? ""
1243+
: "pr-2",
1244+
)}>
12361245
{isTtsPlaying && (
12371246
<StandardTooltip content={t("chat:stopTts")}>
12381247
<button
@@ -1254,6 +1263,9 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
12541263
</StandardTooltip>
12551264
)}
12561265
{!isEditMode ? <IndexingStatusBadge /> : null}
1266+
{!isEditMode && cloudOrganizations && cloudOrganizations.length > 0 && cloudUserInfo && (
1267+
<CloudAccountSwitcher />
1268+
)}
12571269
</div>
12581270
</div>
12591271
</div>

0 commit comments

Comments
 (0)