mirror of
https://github.com/cline/cline.git
synced 2026-09-09 15:02:23 +08:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f39f28400 | ||
|
|
61c55c31cb | ||
|
|
73bbb6e169 | ||
|
|
809c6ff728 |
@@ -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
|
||||
}
|
||||
|
||||
@@ -135,5 +135,6 @@ export type GlobalStateKey =
|
||||
| "actModeGroqModelInfo"
|
||||
| "actModeHuggingFaceModelId"
|
||||
| "actModeHuggingFaceModelInfo"
|
||||
| "currentActiveOrganizationId"
|
||||
|
||||
export type LocalStateKey = "localClineRulesToggles" | "localCursorRulesToggles" | "localWindsurfRulesToggles" | "workflowToggles"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user