mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-30 17:14:40 +08:00
fix: respect unavailable personal Kilo accounts
This commit is contained in:
@@ -3,4 +3,4 @@
|
||||
"@kilocode/kilo-gateway": patch
|
||||
---
|
||||
|
||||
Use the cloud-selected organization as the active Kilo account when provided.
|
||||
Use cloud account preferences to select the active Kilo organization and hide unavailable personal accounts.
|
||||
|
||||
@@ -26,6 +26,7 @@ export async function fetchProfile(token: string): Promise<KilocodeProfile> {
|
||||
name?: string
|
||||
organizations?: Organization[]
|
||||
selectedOrganizationId?: string | null
|
||||
hasPersonalAccount?: boolean | null
|
||||
}
|
||||
// Backend returns { user: { email, name, ... }, organizations }
|
||||
// Transform to flat KilocodeProfile structure
|
||||
@@ -34,6 +35,7 @@ export async function fetchProfile(token: string): Promise<KilocodeProfile> {
|
||||
name: data.user?.name ?? data.name,
|
||||
organizations: data.organizations,
|
||||
selectedOrganizationId: data.selectedOrganizationId ?? undefined,
|
||||
hasPersonalAccount: data.hasPersonalAccount ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -102,6 +102,7 @@ export function createKiloRoutes(deps: KiloRoutesDeps) {
|
||||
name: z.string().optional(),
|
||||
organizations: z.array(Organization).optional(),
|
||||
selectedOrganizationId: z.string().optional(),
|
||||
hasPersonalAccount: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const Balance = z.object({
|
||||
|
||||
@@ -56,6 +56,7 @@ export function formatProfileInfo(
|
||||
export function getOrganizationOptions(
|
||||
organizations: Organization[],
|
||||
currentOrgId?: string,
|
||||
hasPersonalAccount = true,
|
||||
): Array<{
|
||||
title: string
|
||||
value: string | null
|
||||
@@ -63,12 +64,16 @@ export function getOrganizationOptions(
|
||||
category: string
|
||||
}> {
|
||||
return [
|
||||
{
|
||||
title: "Personal Account",
|
||||
value: null,
|
||||
description: !currentOrgId ? "→ (current)" : undefined,
|
||||
category: "Accounts",
|
||||
},
|
||||
...(hasPersonalAccount
|
||||
? [
|
||||
{
|
||||
title: "Personal Account",
|
||||
value: null,
|
||||
description: !currentOrgId ? "→ (current)" : undefined,
|
||||
category: "Accounts",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...organizations.map((org) => ({
|
||||
title: org.name,
|
||||
value: org.id,
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface KilocodeProfile {
|
||||
name?: string
|
||||
organizations?: Organization[]
|
||||
selectedOrganizationId?: string
|
||||
hasPersonalAccount?: boolean
|
||||
}
|
||||
|
||||
export interface KilocodeBalance {
|
||||
|
||||
+1
@@ -143,6 +143,7 @@ internal fun profileDto(p: KiloProfile200Response): ProfileDto = ProfileDto(
|
||||
organizations = p.profile.organizations.orEmpty().map { org ->
|
||||
ProfileOrganizationDto(id = org.id, name = org.name, role = org.role)
|
||||
},
|
||||
hasPersonalAccount = p.profile.hasPersonalAccount ?: true,
|
||||
balance = p.balance?.let { ProfileBalanceDto(balance = it.balance) },
|
||||
kiloPass = p.kiloPass?.let {
|
||||
ProfileKiloPassDto(
|
||||
|
||||
+2
@@ -790,6 +790,7 @@ class KiloBackendAppServiceTest {
|
||||
"profile":{
|
||||
"email":"alice@test.com",
|
||||
"name":"Alice",
|
||||
"hasPersonalAccount":false,
|
||||
"organizations":[{"id":"org_1","name":"Acme","role":"ADMIN"}]
|
||||
},
|
||||
"balance":{"balance":42.5},
|
||||
@@ -804,6 +805,7 @@ class KiloBackendAppServiceTest {
|
||||
assertEquals("alice@test.com", dto.profile?.email)
|
||||
assertEquals("Alice", dto.profile?.name)
|
||||
assertEquals("ADMIN", dto.profile?.organizations?.firstOrNull()?.role)
|
||||
assertFalse(dto.profile?.hasPersonalAccount ?: true)
|
||||
assertEquals(42.5, dto.profile?.balance?.balance)
|
||||
assertEquals("org_1", dto.profile?.currentOrgId)
|
||||
}
|
||||
|
||||
+3
-2
@@ -124,7 +124,8 @@ internal class SessionAccountOverlay(
|
||||
var layout = false
|
||||
|
||||
val orgs = prof.organizations
|
||||
val next = listOf(AccountChoice(null, KiloBundle.message("profile.personalAccount"))) +
|
||||
val personal = prof.hasPersonalAccount
|
||||
val next = (if (personal) listOf(AccountChoice(null, KiloBundle.message("profile.personalAccount"))) else emptyList()) +
|
||||
orgs.map { org -> AccountChoice(org.id, org.name) }
|
||||
if (next != choices) {
|
||||
choices = next
|
||||
@@ -133,7 +134,7 @@ internal class SessionAccountOverlay(
|
||||
|
||||
if (currentOrgId != prof.currentOrgId) currentOrgId = prof.currentOrgId
|
||||
|
||||
val activeId = if (switching) target else prof.currentOrgId
|
||||
val activeId = if (switching) target else prof.currentOrgId ?: if (personal) null else orgs.firstOrNull()?.id
|
||||
val active = choices.firstOrNull { it.org == activeId } ?: choices.firstOrNull()
|
||||
val title = "${active?.title ?: " "} ▾"
|
||||
if (picker.text != title) {
|
||||
|
||||
+3
-4
@@ -331,12 +331,11 @@ internal class LoggedInProfileUi(
|
||||
@RequiresEdt
|
||||
private fun applyOrganizations(profile: ProfileDto) {
|
||||
val orgs = profile.organizations
|
||||
val keys: List<Pair<String?, String>> = listOf(null to KiloBundle.message("profile.personalAccount")) +
|
||||
val personal = profile.hasPersonalAccount
|
||||
val keys: List<Pair<String?, String>> = (if (personal) listOf(null to KiloBundle.message("profile.personalAccount")) else emptyList()) +
|
||||
orgs.map { it.id to it.name }
|
||||
|
||||
val target = profile.currentOrgId
|
||||
?.let { id -> orgs.indexOfFirst { it.id == id }.takeIf { it >= 0 }?.plus(1) }
|
||||
?: 0
|
||||
val target = keys.indexOfFirst { it.first == profile.currentOrgId }.takeIf { it >= 0 } ?: 0
|
||||
|
||||
currentOrgId = profile.currentOrgId
|
||||
|
||||
|
||||
+18
@@ -42,10 +42,12 @@ class SessionAccountOverlayTest : SessionControllerTestBase() {
|
||||
organizations: List<ProfileOrganizationDto> = emptyList(),
|
||||
balance: ProfileBalanceDto? = null,
|
||||
currentOrgId: String? = null,
|
||||
hasPersonalAccount: Boolean = true,
|
||||
) = ProfileDto(
|
||||
email = email,
|
||||
name = name,
|
||||
organizations = organizations,
|
||||
hasPersonalAccount = hasPersonalAccount,
|
||||
balance = balance,
|
||||
currentOrgId = currentOrgId,
|
||||
)
|
||||
@@ -106,6 +108,22 @@ class SessionAccountOverlayTest : SessionControllerTestBase() {
|
||||
}
|
||||
}
|
||||
|
||||
fun `test profile without personal account hides personal choice`() {
|
||||
val acme = org("org_1", "Acme", "MEMBER")
|
||||
val prof = profile(
|
||||
email = "user@example.com",
|
||||
organizations = listOf(acme),
|
||||
currentOrgId = "org_1",
|
||||
hasPersonalAccount = false,
|
||||
)
|
||||
show(snap(prof))
|
||||
edt {
|
||||
assertEquals("Acme", panel.accountTitle())
|
||||
assertEquals(1, panel.choiceCount())
|
||||
assertEquals(0, panel.selectedIndex())
|
||||
}
|
||||
}
|
||||
|
||||
// --- test 4: programmatic update does not call select callback ---
|
||||
|
||||
fun `test programmatic update does not call select callback`() {
|
||||
|
||||
@@ -155,6 +155,7 @@ data class ProfileDto(
|
||||
val email: String,
|
||||
val name: String? = null,
|
||||
val organizations: List<ProfileOrganizationDto> = emptyList(),
|
||||
val hasPersonalAccount: Boolean = true,
|
||||
val balance: ProfileBalanceDto? = null,
|
||||
val kiloPass: ProfileKiloPassDto? = null,
|
||||
val currentOrgId: String? = null,
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface KilocodeProfile {
|
||||
name?: string
|
||||
organizations?: KilocodeOrganization[]
|
||||
selectedOrganizationId?: string
|
||||
hasPersonalAccount?: boolean
|
||||
}
|
||||
|
||||
export interface KilocodeBalance {
|
||||
|
||||
@@ -65,20 +65,23 @@ const ProfileView: Component<ProfileViewProps> = (props) => {
|
||||
const orgOptions = createMemo<OrgOption[]>(() => {
|
||||
const orgs = props.profileData?.profile.organizations ?? []
|
||||
if (orgs.length === 0) return []
|
||||
const personal = props.profileData?.profile.hasPersonalAccount !== false
|
||||
return [
|
||||
{ value: PERSONAL, label: language.t("profile.personalAccount") },
|
||||
...(personal ? [{ value: PERSONAL, label: language.t("profile.personalAccount") }] : []),
|
||||
...orgs.map((org) => ({ value: org.id, label: org.name, description: org.role })),
|
||||
]
|
||||
})
|
||||
|
||||
const currentOrg = createMemo(() => {
|
||||
const id = props.profileData?.currentOrgId ?? PERSONAL
|
||||
const personal = props.profileData?.profile.hasPersonalAccount !== false
|
||||
const id = props.profileData?.currentOrgId ?? (personal ? PERSONAL : orgOptions()[0]?.value)
|
||||
return orgOptions().find((o) => o.value === id)
|
||||
})
|
||||
|
||||
const selectOrg = (option: OrgOption | undefined) => {
|
||||
if (!option) return
|
||||
const current = props.profileData?.currentOrgId ?? PERSONAL
|
||||
const personal = props.profileData?.profile.hasPersonalAccount !== false
|
||||
const current = props.profileData?.currentOrgId ?? (personal ? PERSONAL : orgOptions()[0]?.value)
|
||||
if (option.value === current) return
|
||||
setTarget(option.value)
|
||||
vscode.postMessage({
|
||||
@@ -258,7 +261,13 @@ const ProfileView: Component<ProfileViewProps> = (props) => {
|
||||
</div>
|
||||
|
||||
{/* Kilo Pass is part of personal credits, so only show it on the personal account */}
|
||||
<Show when={(data().currentOrgId ?? null) === null ? data().kiloPass : null}>
|
||||
<Show
|
||||
when={
|
||||
(data().currentOrgId ?? null) === null && data().profile.hasPersonalAccount !== false
|
||||
? data().kiloPass
|
||||
: null
|
||||
}
|
||||
>
|
||||
{(pass) => (
|
||||
<div
|
||||
style={{
|
||||
@@ -331,7 +340,13 @@ const ProfileView: Component<ProfileViewProps> = (props) => {
|
||||
</Show>
|
||||
|
||||
{/* No active Kilo Pass on the personal account — nudge to subscribe */}
|
||||
<Show when={(data().currentOrgId ?? null) === null && !data().kiloPass}>
|
||||
<Show
|
||||
when={
|
||||
(data().currentOrgId ?? null) === null &&
|
||||
data().profile.hasPersonalAccount !== false &&
|
||||
!data().kiloPass
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
"border-top": "1px solid var(--border-weak-base)",
|
||||
|
||||
@@ -24,8 +24,9 @@ export const AccountSwitcher: Component<{ class?: string }> = (props) => {
|
||||
|
||||
const profile = () => server.profileData()
|
||||
const orgs = () => profile()?.profile.organizations ?? []
|
||||
const personal = () => profile()?.profile.hasPersonalAccount !== false
|
||||
const visible = () => !!profile() && orgs().length > 0
|
||||
const current = () => profile()?.currentOrgId ?? PERSONAL
|
||||
const current = () => profile()?.currentOrgId ?? (personal() ? PERSONAL : (orgs()[0]?.id ?? PERSONAL))
|
||||
|
||||
const selected = createMemo(() => {
|
||||
const id = current()
|
||||
@@ -119,15 +120,17 @@ export const AccountSwitcher: Component<{ class?: string }> = (props) => {
|
||||
|
||||
<Show when={open() && !switching()}>
|
||||
<div class="account-switcher-dropdown" role="listbox" aria-label="Account">
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={current() === PERSONAL}
|
||||
class="account-switcher-item"
|
||||
onClick={() => pick(null)}
|
||||
>
|
||||
<span class="account-switcher-item-name">{language.t("profile.personalAccount")}</span>
|
||||
</button>
|
||||
<Show when={personal()}>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={current() === PERSONAL}
|
||||
class="account-switcher-item"
|
||||
onClick={() => pick(null)}
|
||||
>
|
||||
<span class="account-switcher-item-name">{language.t("profile.personalAccount")}</span>
|
||||
</button>
|
||||
</Show>
|
||||
<For each={orgs()}>
|
||||
{(org) => (
|
||||
<button
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface ProfileData {
|
||||
name?: string
|
||||
organizations?: Array<{ id: string; name: string; role: string }>
|
||||
selectedOrganizationId?: string
|
||||
hasPersonalAccount?: boolean
|
||||
}
|
||||
balance: KilocodeBalance | null
|
||||
kiloPass: KiloPassState | null
|
||||
|
||||
@@ -83,11 +83,12 @@ export function KiloAutoMethod(props: KiloAutoMethodProps) {
|
||||
await sync.bootstrap()
|
||||
|
||||
dialog.replace(() => (
|
||||
<DialogKiloOrganization
|
||||
organizations={profile.organizations!}
|
||||
userEmail={profile.email}
|
||||
providerID={props.providerID}
|
||||
useSDK={props.useSDK}
|
||||
<DialogKiloOrganization
|
||||
organizations={profile.organizations!}
|
||||
userEmail={profile.email}
|
||||
providerID={props.providerID}
|
||||
hasPersonalAccount={profile.hasPersonalAccount !== false}
|
||||
useSDK={props.useSDK}
|
||||
useTheme={props.useTheme}
|
||||
DialogModel={props.DialogModel}
|
||||
/>
|
||||
|
||||
@@ -21,6 +21,7 @@ interface DialogKiloOrganizationProps {
|
||||
organizations: Organization[]
|
||||
userEmail: string
|
||||
providerID: string
|
||||
hasPersonalAccount?: boolean
|
||||
useSDK: () => UseSDK
|
||||
useTheme: () => UseTheme
|
||||
DialogModel: DialogModel
|
||||
@@ -33,7 +34,7 @@ export function DialogKiloOrganization(props: DialogKiloOrganizationProps) {
|
||||
const toast = useToast()
|
||||
|
||||
// Get formatted options with current markers
|
||||
const options = getOrganizationOptions(props.organizations)
|
||||
const options = getOrganizationOptions(props.organizations, undefined, props.hasPersonalAccount !== false)
|
||||
|
||||
// Pre-select first organization (user requirement)
|
||||
const defaultSelection = getDefaultOrganizationSelection(props.organizations)
|
||||
|
||||
@@ -12,12 +12,17 @@ import { getOrganizationOptions } from "@kilocode/kilo-gateway/tui"
|
||||
interface DialogKiloTeamSelectProps {
|
||||
organizations: Organization[]
|
||||
currentOrgId?: string | null
|
||||
hasPersonalAccount?: boolean
|
||||
onSelect: (orgId: string | null) => Promise<void>
|
||||
}
|
||||
|
||||
export function DialogKiloTeamSelect(props: DialogKiloTeamSelectProps) {
|
||||
// Get formatted options with current markers
|
||||
const options = getOrganizationOptions(props.organizations, props.currentOrgId || undefined)
|
||||
const options = getOrganizationOptions(
|
||||
props.organizations,
|
||||
props.currentOrgId || undefined,
|
||||
props.hasPersonalAccount !== false,
|
||||
)
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
|
||||
@@ -218,6 +218,7 @@ export function registerKiloCommands(useSDK: () => UseSDK) {
|
||||
<DialogKiloTeamSelect
|
||||
organizations={profile.organizations!}
|
||||
currentOrgId={currentOrgId}
|
||||
hasPersonalAccount={profile.hasPersonalAccount !== false}
|
||||
onSelect={async (orgId) => {
|
||||
try {
|
||||
// Switch to team immediately using server endpoint
|
||||
|
||||
@@ -22,6 +22,7 @@ export const Profile = Schema.Struct({
|
||||
name: Schema.optional(Schema.String),
|
||||
organizations: Schema.optional(Schema.Array(Organization)),
|
||||
selectedOrganizationId: Schema.optional(Schema.String),
|
||||
hasPersonalAccount: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
|
||||
export const Balance = Schema.Struct({
|
||||
|
||||
@@ -190,6 +190,7 @@ describe("Kilo PublicApi OpenAPI contract", () => {
|
||||
expect(profile?.balance).toEqual({ anyOf: [expect.objectContaining({ type: "object" }), { type: "null" }] })
|
||||
expect(profile?.kiloPass).toEqual({ anyOf: [expect.objectContaining({ type: "object" }), { type: "null" }] })
|
||||
expect(profile?.profile?.properties?.selectedOrganizationId).toEqual({ type: "string" })
|
||||
expect(profile?.profile?.properties?.hasPersonalAccount).toEqual({ type: "boolean" })
|
||||
const pass = profile?.kiloPass?.anyOf?.find((item) => item.type === "object")?.properties
|
||||
expect(pass?.nextBillingAt).toEqual({ anyOf: [{ type: "string" }, { type: "null" }] })
|
||||
expect(profile?.currentOrgId).toEqual({ anyOf: [{ type: "string" }, { type: "null" }] })
|
||||
|
||||
@@ -10788,6 +10788,7 @@ export type KiloProfileResponses = {
|
||||
role: string
|
||||
}>
|
||||
selectedOrganizationId?: string
|
||||
hasPersonalAccount?: boolean
|
||||
}
|
||||
balance: {
|
||||
balance: number
|
||||
|
||||
@@ -13512,6 +13512,9 @@
|
||||
},
|
||||
"selectedOrganizationId": {
|
||||
"type": "string"
|
||||
},
|
||||
"hasPersonalAccount": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["email"],
|
||||
|
||||
Reference in New Issue
Block a user