Skip to content

Commit 3637aad

Browse files
dmarticusclaude
andauthored
fix(auth): populate project picker for tokens without scoped_organizations (#3279)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6b6580e commit 3637aad

2 files changed

Lines changed: 70 additions & 21 deletions

File tree

packages/core/src/auth/auth.test.ts

Lines changed: 59 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -886,7 +886,10 @@ describe("AuthService", () => {
886886
});
887887

888888
describe("project-less recovery", () => {
889-
function stubOrgFetch(state: { succeeds: boolean; orgCalls: number }) {
889+
function stubOrgFetch(
890+
state: { succeeds: boolean; orgCalls: number },
891+
options: { orgless?: boolean } = {},
892+
) {
890893
vi.stubGlobal(
891894
"fetch",
892895
vi.fn(async (input: string | Request) => {
@@ -897,7 +900,7 @@ describe("AuthService", () => {
897900
ok: true,
898901
json: vi.fn().mockResolvedValue({
899902
uuid: "user-1",
900-
organization: { id: "org-1" },
903+
organization: options.orgless ? null : { id: "org-1" },
901904
}),
902905
} as unknown as Response;
903906
}
@@ -1156,27 +1159,63 @@ describe("AuthService", () => {
11561159
expect(sessionPort.getCurrent()?.selectedProjectId).toBe(84);
11571160
});
11581161

1159-
it("does not attempt recovery when the token grants no scoped organizations", async () => {
1160-
const fetchState = { succeeds: true, orgCalls: 0 };
1161-
stubOrgFetch(fetchState);
1162-
oauthFlow.startFlow.mockResolvedValue(
1163-
mockTokenResponse({ scopedOrgs: [] }),
1164-
);
1165-
1166-
await service.login("us");
1162+
// Team-scoped tokens (required_access_level=project) arrive with empty
1163+
// scoped_organizations on some backends. When @me carries a current org,
1164+
// we fetch it so the picker isn't stranded on "No projects". When @me has
1165+
// no org either (truly orgless user), there's nothing to fetch and the
1166+
// recovery loop must not spin — that was the concern from #2655.
1167+
it.each([
1168+
{
1169+
when: "the user has no organization at all",
1170+
orgless: true,
1171+
expectedOrgCalls: 0,
1172+
expectedState: {
1173+
status: "authenticated",
1174+
currentProjectId: null,
1175+
orgProjectsMap: {},
1176+
},
1177+
},
1178+
{
1179+
when: "the @me current org is used as a fallback",
1180+
orgless: false,
1181+
expectedOrgCalls: 1,
1182+
expectedState: {
1183+
status: "authenticated",
1184+
currentOrgId: "org-1",
1185+
currentProjectId: 42,
1186+
orgProjectsMap: {
1187+
"org-1": {
1188+
orgName: "Org 1",
1189+
projects: [
1190+
{ id: 42, name: "Project 42" },
1191+
{ id: 84, name: "Project 84" },
1192+
],
1193+
},
1194+
},
1195+
},
1196+
},
1197+
])(
1198+
"with empty scoped_organizations: $when",
1199+
async ({ orgless, expectedOrgCalls, expectedState }) => {
1200+
const fetchState = { succeeds: true, orgCalls: 0 };
1201+
stubOrgFetch(fetchState, { orgless });
1202+
oauthFlow.startFlow.mockResolvedValue(
1203+
mockTokenResponse({ scopedOrgs: [] }),
1204+
);
11671205

1168-
expect(service.getState()).toMatchObject({
1169-
status: "authenticated",
1170-
currentProjectId: null,
1171-
orgProjectsMap: {},
1172-
});
1206+
await service.login("us");
11731207

1174-
emitStatus(true);
1175-
await new Promise((r) => setTimeout(r, 10));
1208+
expect(fetchState.orgCalls).toBe(expectedOrgCalls);
1209+
expect(service.getState()).toMatchObject(expectedState);
11761210

1177-
expect(fetchState.orgCalls).toBe(0);
1178-
expect(service.getState().currentProjectId).toBeNull();
1179-
});
1211+
// Emitting connectivity should not trigger extra work: the map is
1212+
// complete (or empty by design) and the recovery loop must not spin.
1213+
emitStatus(true);
1214+
await new Promise((r) => setTimeout(r, 10));
1215+
expect(fetchState.orgCalls).toBe(expectedOrgCalls);
1216+
expect(service.getState()).toMatchObject(expectedState);
1217+
},
1218+
);
11801219
});
11811220

11821221
describe("switchOrg", () => {

packages/core/src/auth/auth.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -595,11 +595,21 @@ export class AuthService extends TypedEventEmitter<AuthServiceEvents> {
595595
tokenResponse.access_token,
596596
options.cloudRegion,
597597
);
598+
// Team-scoped tokens (required_access_level=project) can arrive with an
599+
// empty scoped_organizations list — the server only populates scoped_teams.
600+
// Fall back to the current org from /api/users/@me/ so the picker isn't
601+
// empty; without this the user is stranded on "No projects".
602+
const orgIdsToFetch =
603+
scopedOrgIds.length > 0
604+
? scopedOrgIds
605+
: currentOrgId
606+
? [currentOrgId]
607+
: [];
598608
const { map: orgProjectsMap, incomplete: orgProjectsIncomplete } =
599609
await this.buildOrgProjectsMap(
600610
tokenResponse.access_token,
601611
options.cloudRegion,
602-
scopedOrgIds,
612+
orgIdsToFetch,
603613
this.session?.orgProjectsMap ?? {},
604614
);
605615
const lastPrefs = accountKey

0 commit comments

Comments
 (0)