Compare commits

...
6 changed files with 205 additions and 46 deletions
@@ -1,6 +1,7 @@
import type { Controller } from "../index"
import type { EmptyRequest } from "@shared/proto/common"
import { UserOrganization, UserOrganizationsResponse } from "@shared/proto/account"
import { getGlobalState } from "../../storage/state"
/**
* Handles fetching all user credits data (balance, usage, payments)
@@ -17,11 +18,14 @@ export async function getUserOrganizations(controller: Controller, request: Empt
// Fetch user organizations from the account service
const organizations = await controller.accountService.fetchUserOrganizationsRPC()
// Get the current active organization ID from global state
const currentActiveOrganizationId = await getGlobalState(controller.context, "currentActiveOrganizationId")
return UserOrganizationsResponse.create({
organizations:
organizations?.map((org) =>
UserOrganization.create({
active: org.active,
active: org.organizationId === currentActiveOrganizationId,
memberId: org.memberId,
name: org.name,
organizationId: org.organizationId,
@@ -1,6 +1,10 @@
import type { Controller } from "../index"
import { Empty } from "@shared/proto/common"
import { UserOrganizationUpdateRequest } from "@shared/proto/account"
import { updateGlobalState } from "../../storage/state"
// Backend request deduplication tracking
const ongoingOrganizationSwitches = new Map<string, Promise<Empty>>()
/**
* Handles setting the user's active organization
@@ -9,16 +13,39 @@ import { UserOrganizationUpdateRequest } from "@shared/proto/account"
* @returns Empty response
*/
export async function setUserOrganization(controller: Controller, request: UserOrganizationUpdateRequest): Promise<Empty> {
try {
if (!controller.accountService) {
throw new Error("Account service not available")
}
// Create a unique key for this request (user + organization)
const userId = controller.context.globalState.get("clineUserId") || "unknown"
const requestKey = `${userId}:${request.organizationId || "personal"}`
// Switch to the specified organization using the account service
await controller.accountService.switchAccount(request.organizationId)
return Empty.create({})
} catch (error) {
throw error
// Check if there's already an ongoing request for this user+organization combination
const existingRequest = ongoingOrganizationSwitches.get(requestKey)
if (existingRequest) {
return existingRequest
}
// Create the promise for this request
const requestPromise = (async (): Promise<Empty> => {
try {
if (!controller.accountService) {
throw new Error("Account service not available")
}
await controller.accountService.switchAccount(request.organizationId)
// Store the current active organization ID in global state
await updateGlobalState(controller.context, "currentActiveOrganizationId", request.organizationId)
return Empty.create({})
} catch (error) {
throw error
} finally {
// Clean up the ongoing request tracking
ongoingOrganizationSwitches.delete(requestKey)
}
})()
// Store the promise to prevent duplicate requests
ongoingOrganizationSwitches.set(requestKey, requestPromise)
return requestPromise
}
+1
View File
@@ -135,5 +135,6 @@ export type GlobalStateKey =
| "actModeGroqModelInfo"
| "actModeHuggingFaceModelId"
| "actModeHuggingFaceModelInfo"
| "currentActiveOrganizationId"
export type LocalStateKey = "localClineRulesToggles" | "localCursorRulesToggles" | "localWindsurfRulesToggles" | "workflowToggles"
+2
View File
@@ -190,6 +190,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
sapAiCoreTokenUrl,
sapAiResourceGroup,
claudeCodePath,
currentActiveOrganizationId,
] = await Promise.all([
getGlobalState(context, "isNewUser") as Promise<boolean | undefined>,
getGlobalState(context, "welcomeViewCompleted") as Promise<boolean | undefined>,
@@ -269,6 +270,7 @@ export async function getAllExtensionState(context: vscode.ExtensionContext) {
getGlobalState(context, "sapAiCoreTokenUrl") as Promise<string | undefined>,
getGlobalState(context, "sapAiResourceGroup") as Promise<string | undefined>,
getGlobalState(context, "claudeCodePath") as Promise<string | undefined>,
getGlobalState(context, "currentActiveOrganizationId") as Promise<string | undefined>,
])
const localClineRulesToggles = (await getWorkspaceState(context, "localClineRulesToggles")) as ClineRulesToggles
+106 -18
View File
@@ -15,6 +15,9 @@ export class ClineAccountService {
private _authService: AuthService
private readonly _baseUrl = clineEnvConfig.apiBaseUrl
// Service-level request deduplication tracking
private ongoingSwitchRequests = new Map<string, Promise<void>>()
constructor() {
this._authService = AuthService.getInstance()
}
@@ -220,24 +223,109 @@ export class ClineAccountService {
* @throws {Error} If the account switch fails, an error will be thrown.
*/
async switchAccount(organizationId?: string): Promise<void> {
// Call API to switch account
try {
// make XHR request to switch account
const response = await this.authenticatedRequest<string>(`/api/v1/users/active-account`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
data: {
organizationId: organizationId || null, // Pass organization if provided
},
})
} catch (error) {
console.error("Error switching account:", error)
throw error
} finally {
// After user switches account, we will force a refresh of the id token by calling this function that restores the refresh token and retrieves new auth info
await this._authService.restoreRefreshTokenAndRetrieveAuthInfo()
const requestKey = organizationId || "personal"
// Check if there's already an ongoing switch request for this organization
const existingRequest = this.ongoingSwitchRequests.get(requestKey)
if (existingRequest) {
return existingRequest
}
// Create the promise for this request
const requestPromise = (async (): Promise<void> => {
// Token validation before API call
let tokenBeforeSwitch: string | null = null
let tokenValidBeforeSwitch = false
try {
tokenBeforeSwitch = await this._authService.getAuthToken()
tokenValidBeforeSwitch = tokenBeforeSwitch !== null
if (tokenBeforeSwitch) {
// Try to decode token expiry if it's a JWT
try {
const tokenParts = tokenBeforeSwitch.split(".")
if (tokenParts.length === 3) {
const payload = JSON.parse(atob(tokenParts[1]))
}
} catch (e) {
console.log(`[TOKEN_REFRESH] Could not decode token expiry (not a JWT or malformed)`)
}
}
} catch (error) {
console.log(`[TOKEN_REFRESH] Error checking token before switch:`, error)
}
// Call API to switch account
try {
const requestStartTime = performance.now()
// make XHR request to switch account
const response = await this.authenticatedRequest<string>(`/api/v1/users/active-account`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
data: {
organizationId: organizationId || null, // Pass organization if provided
},
})
} catch (error) {
throw error
} finally {
// Token validation after API call but before refresh
let tokenAfterSwitch: string | null = null
let tokenValidAfterSwitch = false
try {
const tokenCheckStartTime = performance.now()
// Get the current token without triggering a refresh
const authInfo = (this._authService as any)._clineAuthInfo
tokenAfterSwitch = authInfo?.idToken || null
tokenValidAfterSwitch = tokenAfterSwitch !== null
} catch (error) {
console.log(`[TOKEN_REFRESH] Error checking token after API call:`, error)
}
// Determine if refresh is actually needed
let needsRefreshByExpiry = false
if (tokenAfterSwitch) {
try {
const tokenParts = tokenAfterSwitch.split(".")
if (tokenParts.length === 3) {
const payload = JSON.parse(atob(tokenParts[1]))
const expiry = payload.exp ? new Date(payload.exp * 1000) : null
const now = new Date()
const fiveMinutesInMs = 5 * 60 * 1000
needsRefreshByExpiry = expiry ? expiry.getTime() < now.getTime() + fiveMinutesInMs : false
}
} catch (e) {
needsRefreshByExpiry = true // If we can't decode, assume refresh needed
}
}
const shouldRefresh = !tokenValidAfterSwitch || needsRefreshByExpiry
if (shouldRefresh) {
// After user switches account, we will force a refresh of the id token by calling this function that restores the refresh token and retrieves new auth info
await this._authService.restoreRefreshTokenAndRetrieveAuthInfo()
// Check token after refresh
try {
await this._authService.getAuthToken()
} catch (error) {
console.log(`[TOKEN_REFRESH] Error checking token after refresh:`, error)
}
}
// Clean up the ongoing request tracking
this.ongoingSwitchRequests.delete(requestKey)
}
})()
// Store the promise to prevent duplicate requests
this.ongoingSwitchRequests.set(requestKey, requestPromise)
return requestPromise
}
}
@@ -11,7 +11,7 @@ import {
VSCodeOption,
VSCodeTag,
} from "@vscode/webview-ui-toolkit/react"
import { memo, useCallback, useEffect, useMemo, useState } from "react"
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
import ClineLogoWhite from "../../assets/ClineLogoWhite"
import CreditsHistoryTable from "./CreditsHistoryTable"
import debounce from "debounce"
@@ -62,6 +62,7 @@ export const ClineAccountView = () => {
// Source of truth: Dedicated state for dropdown value that persists through failures
// and represents that user's current selection.
const [dropdownValue, setDropdownValue] = useState<string>("personal")
const [hasInitializedDropdown, setHasInitializedDropdown] = useState(false)
const [isLoading, setIsLoading] = useState(true)
@@ -88,18 +89,34 @@ export const ClineAccountView = () => {
return userOrganizations.find((org) => org.organizationId === dropdownValue)
}, [userOrganizations, dropdownValue])
// Request deduplication tracking
const isOrganizationSwitchingRef = useRef<string | null>(null)
const getUserOrganizations = useCallback(async () => {
try {
if (clineUser?.uid) {
const response = await AccountServiceClient.getUserOrganizations(EmptyRequest.create())
if (response?.organizations && !deepEqual(userOrganizations, response.organizations)) {
setUserOrganizations(response.organizations)
// Initialize dropdown with the active organization if not already initialized
if (!hasInitializedDropdown) {
const activeOrg = response.organizations.find((org) => org.active)
const newDropdownValue = activeOrg ? activeOrg.organizationId : "personal"
setDropdownValue(newDropdownValue)
setHasInitializedDropdown(true)
// Return the determined organization ID for use in data fetching
return newDropdownValue
}
}
}
} catch (error) {
console.error("Failed to fetch user organizations:", error)
}
}, [userOrganizations, clineUser?.uid, dropdownValue])
return null
}, [userOrganizations, clineUser?.uid, dropdownValue, hasInitializedDropdown])
const fetchCreditBalance = useCallback(
async (orgId?: string) => {
@@ -131,6 +148,7 @@ export const ClineAccountView = () => {
}
// Check if response is UserCreditsData type
const paymentStartTime = performance.now()
if (typeof response !== "object" || !("paymentTransactions" in response)) {
return
}
@@ -140,7 +158,7 @@ export const ClineAccountView = () => {
setPaymentsData(newPaymentsData)
}
} catch (error) {
console.error("Failed to fetch credit balance:", error)
const errorTime = performance.now()
} finally {
setLastFetchTime(Date.now())
setIsLoading(false)
@@ -165,30 +183,38 @@ export const ClineAccountView = () => {
async (event: any) => {
const newValue = (event.target as VSCodeDropdownChangeEvent["target"]).value || "personal"
const organizationId = newValue === "personal" ? undefined : newValue
const requestKey = organizationId || "personal"
if (newValue === dropdownValue) {
return // No change, do nothing
}
// Request deduplication check
if (isOrganizationSwitchingRef.current === requestKey) {
return
}
// Mark this organization switch as in progress
isOrganizationSwitchingRef.current = requestKey
console.log(`[ORG_SWITCH_DEDUP] Request allowed - setting lock for "${requestKey}"`)
try {
console.info("Changing selection to:", newValue)
await AccountServiceClient.setUserOrganization({ organizationId })
// Send the change to the server
AccountServiceClient.setUserOrganization({ organizationId })
// Update dropdownValue immediately - this persists through failures
setDropdownValue(newValue)
setIsLoading(true)
setBalance(null)
setUsageData([])
setPaymentsData([])
await fetchCreditBalance(organizationId)
await fetchCreditBalance(newValue)
} catch (error) {
console.error("Failed to update organization:", error)
// Don't reset selectedOrgId on error - keep the user's selection
// The next refresh will use the correct selectedOrgId
} finally {
// Clear the lock
isOrganizationSwitchingRef.current = null
setIsLoading(false)
}
},
@@ -199,10 +225,19 @@ export const ClineAccountView = () => {
useEffect(() => {
const loadData = async () => {
if (clineUser?.uid) {
// Start with personal account as we do not have the user's organizations yet
AccountServiceClient.setUserOrganization({ organizationId: undefined })
await getUserOrganizations()
await fetchCreditBalance()
// First, get organizations and determine the active one
const activeOrgId = await getUserOrganizations()
if (activeOrgId !== null) {
// If we got an active organization ID, set it in the backend and fetch data for it
const organizationId = activeOrgId === "personal" ? undefined : activeOrgId
await AccountServiceClient.setUserOrganization({ organizationId })
await fetchCreditBalance(activeOrgId)
} else {
// Fallback: start with personal account if no active org determined
await AccountServiceClient.setUserOrganization({ organizationId: undefined })
await fetchCreditBalance("personal")
}
}
}
loadData()
@@ -212,9 +247,11 @@ export const ClineAccountView = () => {
useEffect(() => {
const refreshData = async () => {
try {
if (clineUser?.uid) {
if (clineUser?.uid && hasInitializedDropdown) {
// Only refresh organizations if already initialized to avoid disrupting the UI
await getUserOrganizations()
await fetchCreditBalance()
// Use current dropdown value for data refresh
await fetchCreditBalance(dropdownValue)
}
} catch (error) {
console.error("Error during periodic refresh:", error)
@@ -223,7 +260,7 @@ export const ClineAccountView = () => {
const intervalId = setInterval(refreshData, 60000)
return () => clearInterval(intervalId)
}, [clineUser?.uid])
}, [clineUser?.uid, hasInitializedDropdown, dropdownValue])
return (
<div className="h-full flex flex-col">