From bd1d16dd7f0c4901e0e976f56bd51b86e06999cd Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Mon, 25 May 2026 15:54:19 -0700 Subject: [PATCH 1/4] scope auth tokens by org with project map --- .../db/migrations/0007_futuristic_domino.sql | 11 + .../db/migrations/meta/0007_snapshot.json | 626 ++++++++++++++++++ .../src/main/db/migrations/meta/_journal.json | 7 + .../auth-preference-repository.mock.ts | 52 ++ .../auth-preference-repository.ts | 90 ++- apps/code/src/main/db/schema.ts | 20 + apps/code/src/main/services/auth/schemas.ts | 20 +- .../src/main/services/auth/service.test.ts | 142 +++- apps/code/src/main/services/auth/service.ts | 247 +++++-- .../findStaleFlagSuggestions.test.ts | 2 +- .../src/main/services/enrichment/service.ts | 4 +- apps/code/src/main/services/oauth/schemas.ts | 1 - apps/code/src/main/trpc/routers/auth.ts | 6 + apps/code/src/renderer/api/posthogClient.ts | 49 +- .../components/ScopeReauthPrompt.test.tsx | 11 +- .../features/auth/hooks/authClient.ts | 13 +- .../features/auth/hooks/authMutations.ts | 15 +- .../features/auth/hooks/authQueries.ts | 8 +- .../features/auth/hooks/useAuthSession.ts | 11 +- .../features/auth/stores/authStore.test.ts | 41 +- .../features/auth/stores/authStore.ts | 67 +- .../components/EnrichmentPopover.tsx | 2 +- .../inbox/components/DataSourceSetup.tsx | 8 +- .../inbox/components/detail/SignalCard.tsx | 4 +- .../list/GitHubConnectionBanner.tsx | 2 +- .../features/inbox/hooks/useEvaluations.ts | 2 +- .../inbox/hooks/useExternalDataSources.ts | 2 +- .../inbox/hooks/useSignalSourceConfigs.ts | 2 +- .../inbox/hooks/useSignalSourceManager.ts | 2 +- .../integrations/hooks/useSlackConnect.ts | 2 +- .../components/GitIntegrationStep.tsx | 2 +- .../components/ProjectSelectStep.tsx | 10 +- .../hooks/useProjectsWithIntegrations.ts | 8 +- .../features/projects/hooks/useProjects.tsx | 113 +--- .../sessions/hooks/useSessionConnection.ts | 6 +- .../service.recovery.integration.test.ts | 22 +- .../features/sessions/service/service.test.ts | 50 +- .../features/sessions/service/service.ts | 10 +- .../components/sections/GeneralSettings.tsx | 2 +- .../sections/GitHubIntegrationSection.tsx | 2 +- .../components/sections/GitHubSettings.tsx | 4 +- .../components/sections/SlackSettings.tsx | 2 +- .../setup/services/setupRunService.ts | 2 +- .../sidebar/components/ProjectSwitcher.tsx | 11 +- .../components/CloudGithubMissingNotice.tsx | 2 +- .../src/renderer/hooks/useProjectQuery.ts | 2 +- apps/code/src/renderer/utils/posthogLinks.ts | 2 +- apps/code/src/shared/constants/oauth.test.ts | 2 +- apps/code/src/shared/constants/oauth.ts | 2 +- 49 files changed, 1435 insertions(+), 288 deletions(-) create mode 100644 apps/code/src/main/db/migrations/0007_futuristic_domino.sql create mode 100644 apps/code/src/main/db/migrations/meta/0007_snapshot.json diff --git a/apps/code/src/main/db/migrations/0007_futuristic_domino.sql b/apps/code/src/main/db/migrations/0007_futuristic_domino.sql new file mode 100644 index 0000000000..034625c596 --- /dev/null +++ b/apps/code/src/main/db/migrations/0007_futuristic_domino.sql @@ -0,0 +1,11 @@ +CREATE TABLE `auth_org_project_preferences` ( + `account_key` text NOT NULL, + `cloud_region` text NOT NULL, + `org_id` text NOT NULL, + `last_selected_project_id` integer NOT NULL, + `created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL, + `updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL +); +--> statement-breakpoint +CREATE INDEX `auth_org_project_account_region_org_idx` ON `auth_org_project_preferences` (`account_key`,`cloud_region`,`org_id`);--> statement-breakpoint +ALTER TABLE `auth_preferences` ADD `last_selected_org_id` text; \ No newline at end of file diff --git a/apps/code/src/main/db/migrations/meta/0007_snapshot.json b/apps/code/src/main/db/migrations/meta/0007_snapshot.json new file mode 100644 index 0000000000..84f42e0b98 --- /dev/null +++ b/apps/code/src/main/db/migrations/meta/0007_snapshot.json @@ -0,0 +1,626 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "cbfce23d-5f7a-4245-8e3b-5eee05597c8b", + "prevId": "805d2ed3-331d-4ba6-8379-30f926268064", + "tables": { + "archives": { + "name": "archives", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "archives_workspaceId_unique": { + "name": "archives_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "archives_workspace_id_workspaces_id_fk": { + "name": "archives_workspace_id_workspaces_id_fk", + "tableFrom": "archives", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_org_project_preferences": { + "name": "auth_org_project_preferences", + "columns": { + "account_key": { + "name": "account_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_region": { + "name": "cloud_region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_selected_project_id": { + "name": "last_selected_project_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "auth_org_project_account_region_org_idx": { + "name": "auth_org_project_account_region_org_idx", + "columns": ["account_key", "cloud_region", "org_id"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_preferences": { + "name": "auth_preferences", + "columns": { + "account_key": { + "name": "account_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_region": { + "name": "cloud_region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_selected_project_id": { + "name": "last_selected_project_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_selected_org_id": { + "name": "last_selected_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "auth_preferences_account_region_idx": { + "name": "auth_preferences_account_region_idx", + "columns": ["account_key", "cloud_region"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "auth_sessions": { + "name": "auth_sessions", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "refresh_token_encrypted": { + "name": "refresh_token_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cloud_region": { + "name": "cloud_region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "selected_project_id": { + "name": "selected_project_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope_version": { + "name": "scope_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "default_additional_directories": { + "name": "default_additional_directories", + "columns": { + "path": { + "name": "path", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "repositories": { + "name": "repositories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_url": { + "name": "remote_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "repositories_path_unique": { + "name": "repositories_path_unique", + "columns": ["path"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "suspensions": { + "name": "suspensions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch_name": { + "name": "branch_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "suspensions_workspaceId_unique": { + "name": "suspensions_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "suspensions_workspace_id_workspaces_id_fk": { + "name": "suspensions_workspace_id_workspaces_id_fk", + "tableFrom": "suspensions", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "linked_branch": { + "name": "linked_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_viewed_at": { + "name": "last_viewed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "additional_directories": { + "name": "additional_directories", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "workspaces_taskId_unique": { + "name": "workspaces_taskId_unique", + "columns": ["task_id"], + "isUnique": true + }, + "workspaces_repository_id_idx": { + "name": "workspaces_repository_id_idx", + "columns": ["repository_id"], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_repository_id_repositories_id_fk": { + "name": "workspaces_repository_id_repositories_id_fk", + "tableFrom": "workspaces", + "tableTo": "repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "worktrees": { + "name": "worktrees", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "worktrees_workspaceId_unique": { + "name": "worktrees_workspaceId_unique", + "columns": ["workspace_id"], + "isUnique": true + } + }, + "foreignKeys": { + "worktrees_workspace_id_workspaces_id_fk": { + "name": "worktrees_workspace_id_workspaces_id_fk", + "tableFrom": "worktrees", + "tableTo": "workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/code/src/main/db/migrations/meta/_journal.json b/apps/code/src/main/db/migrations/meta/_journal.json index 98745d4e45..f5ea654734 100644 --- a/apps/code/src/main/db/migrations/meta/_journal.json +++ b/apps/code/src/main/db/migrations/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1777639303535, "tag": "0006_youthful_warstar", "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1779747695687, + "tag": "0007_futuristic_domino", + "breakpoints": true } ] } diff --git a/apps/code/src/main/db/repositories/auth-preference-repository.mock.ts b/apps/code/src/main/db/repositories/auth-preference-repository.mock.ts index ae99875b68..146df33149 100644 --- a/apps/code/src/main/db/repositories/auth-preference-repository.mock.ts +++ b/apps/code/src/main/db/repositories/auth-preference-repository.mock.ts @@ -1,18 +1,25 @@ import type { + AuthOrgProjectPreference, AuthPreference, IAuthPreferenceRepository, + PersistAuthOrgProjectPreferenceInput, PersistAuthPreferenceInput, } from "./auth-preference-repository"; export interface MockAuthPreferenceRepository extends IAuthPreferenceRepository { _preferences: AuthPreference[]; + _orgProjectPreferences: AuthOrgProjectPreference[]; } export function createMockAuthPreferenceRepository(): MockAuthPreferenceRepository { let preferences: AuthPreference[] = []; + let orgProjectPreferences: AuthOrgProjectPreference[] = []; const clone = (value: AuthPreference): AuthPreference => ({ ...value }); + const cloneOrgProject = ( + value: AuthOrgProjectPreference, + ): AuthOrgProjectPreference => ({ ...value }); return { get _preferences() { @@ -21,6 +28,12 @@ export function createMockAuthPreferenceRepository(): MockAuthPreferenceReposito set _preferences(value) { preferences = value.map(clone); }, + get _orgProjectPreferences() { + return orgProjectPreferences.map(cloneOrgProject); + }, + set _orgProjectPreferences(value) { + orgProjectPreferences = value.map(cloneOrgProject); + }, get: (accountKey, cloudRegion) => { const preference = preferences.find( (entry) => @@ -40,6 +53,7 @@ export function createMockAuthPreferenceRepository(): MockAuthPreferenceReposito accountKey: input.accountKey, cloudRegion: input.cloudRegion, lastSelectedProjectId: input.lastSelectedProjectId, + lastSelectedOrgId: input.lastSelectedOrgId, createdAt: existingIndex >= 0 ? preferences[existingIndex].createdAt : timestamp, updatedAt: timestamp, @@ -53,5 +67,43 @@ export function createMockAuthPreferenceRepository(): MockAuthPreferenceReposito return clone(row); }, + getOrgProject: (accountKey, cloudRegion, orgId) => { + const preference = orgProjectPreferences.find( + (entry) => + entry.accountKey === accountKey && + entry.cloudRegion === cloudRegion && + entry.orgId === orgId, + ); + return preference ? cloneOrgProject(preference) : null; + }, + saveOrgProject: (input: PersistAuthOrgProjectPreferenceInput) => { + const timestamp = new Date().toISOString(); + const existingIndex = orgProjectPreferences.findIndex( + (entry) => + entry.accountKey === input.accountKey && + entry.cloudRegion === input.cloudRegion && + entry.orgId === input.orgId, + ); + + const row: AuthOrgProjectPreference = { + accountKey: input.accountKey, + cloudRegion: input.cloudRegion, + orgId: input.orgId, + lastSelectedProjectId: input.lastSelectedProjectId, + createdAt: + existingIndex >= 0 + ? orgProjectPreferences[existingIndex].createdAt + : timestamp, + updatedAt: timestamp, + }; + + if (existingIndex >= 0) { + orgProjectPreferences[existingIndex] = row; + } else { + orgProjectPreferences.push(row); + } + + return cloneOrgProject(row); + }, }; } diff --git a/apps/code/src/main/db/repositories/auth-preference-repository.ts b/apps/code/src/main/db/repositories/auth-preference-repository.ts index 6962e03e91..3a989baaa5 100644 --- a/apps/code/src/main/db/repositories/auth-preference-repository.ts +++ b/apps/code/src/main/db/repositories/auth-preference-repository.ts @@ -1,16 +1,28 @@ import { and, eq } from "drizzle-orm"; import { inject, injectable } from "inversify"; import { MAIN_TOKENS } from "../../di/tokens"; -import { authPreferences } from "../schema"; +import { authOrgProjectPreferences, authPreferences } from "../schema"; import type { DatabaseService } from "../service"; export type AuthPreference = typeof authPreferences.$inferSelect; export type NewAuthPreference = typeof authPreferences.$inferInsert; +export type AuthOrgProjectPreference = + typeof authOrgProjectPreferences.$inferSelect; +export type NewAuthOrgProjectPreference = + typeof authOrgProjectPreferences.$inferInsert; export interface PersistAuthPreferenceInput { accountKey: string; cloudRegion: "us" | "eu" | "dev"; lastSelectedProjectId: number | null; + lastSelectedOrgId: string | null; +} + +export interface PersistAuthOrgProjectPreferenceInput { + accountKey: string; + cloudRegion: "us" | "eu" | "dev"; + orgId: string; + lastSelectedProjectId: number; } export interface IAuthPreferenceRepository { @@ -19,6 +31,14 @@ export interface IAuthPreferenceRepository { cloudRegion: "us" | "eu" | "dev", ): AuthPreference | null; save(input: PersistAuthPreferenceInput): AuthPreference; + getOrgProject( + accountKey: string, + cloudRegion: "us" | "eu" | "dev", + orgId: string, + ): AuthOrgProjectPreference | null; + saveOrgProject( + input: PersistAuthOrgProjectPreferenceInput, + ): AuthOrgProjectPreference; } const now = () => new Date().toISOString(); @@ -61,6 +81,7 @@ export class AuthPreferenceRepository implements IAuthPreferenceRepository { accountKey: input.accountKey, cloudRegion: input.cloudRegion, lastSelectedProjectId: input.lastSelectedProjectId, + lastSelectedOrgId: input.lastSelectedOrgId, createdAt: existing?.createdAt ?? timestamp, updatedAt: timestamp, }; @@ -86,4 +107,71 @@ export class AuthPreferenceRepository implements IAuthPreferenceRepository { } return saved; } + + getOrgProject( + accountKey: string, + cloudRegion: "us" | "eu" | "dev", + orgId: string, + ): AuthOrgProjectPreference | null { + return ( + this.db + .select() + .from(authOrgProjectPreferences) + .where( + and( + eq(authOrgProjectPreferences.accountKey, accountKey), + eq(authOrgProjectPreferences.cloudRegion, cloudRegion), + eq(authOrgProjectPreferences.orgId, orgId), + ), + ) + .limit(1) + .get() ?? null + ); + } + + saveOrgProject( + input: PersistAuthOrgProjectPreferenceInput, + ): AuthOrgProjectPreference { + const timestamp = now(); + const existing = this.getOrgProject( + input.accountKey, + input.cloudRegion, + input.orgId, + ); + + const row: NewAuthOrgProjectPreference = { + accountKey: input.accountKey, + cloudRegion: input.cloudRegion, + orgId: input.orgId, + lastSelectedProjectId: input.lastSelectedProjectId, + createdAt: existing?.createdAt ?? timestamp, + updatedAt: timestamp, + }; + + if (existing) { + this.db + .update(authOrgProjectPreferences) + .set(row) + .where( + and( + eq(authOrgProjectPreferences.accountKey, input.accountKey), + eq(authOrgProjectPreferences.cloudRegion, input.cloudRegion), + eq(authOrgProjectPreferences.orgId, input.orgId), + ), + ) + .run(); + } else { + this.db.insert(authOrgProjectPreferences).values(row).run(); + } + + const saved = this.getOrgProject( + input.accountKey, + input.cloudRegion, + input.orgId, + ); + if (!saved) { + throw new Error("Failed to persist auth org project preference"); + } + return saved; + } } diff --git a/apps/code/src/main/db/schema.ts b/apps/code/src/main/db/schema.ts index 8823ad2744..b45365ebec 100644 --- a/apps/code/src/main/db/schema.ts +++ b/apps/code/src/main/db/schema.ts @@ -104,6 +104,7 @@ export const authPreferences = sqliteTable( accountKey: text().notNull(), cloudRegion: text({ enum: ["us", "eu", "dev"] }).notNull(), lastSelectedProjectId: integer(), + lastSelectedOrgId: text(), createdAt: createdAt(), updatedAt: updatedAt(), }, @@ -114,3 +115,22 @@ export const authPreferences = sqliteTable( ), ], ); + +export const authOrgProjectPreferences = sqliteTable( + "auth_org_project_preferences", + { + accountKey: text().notNull(), + cloudRegion: text({ enum: ["us", "eu", "dev"] }).notNull(), + orgId: text().notNull(), + lastSelectedProjectId: integer().notNull(), + createdAt: createdAt(), + updatedAt: updatedAt(), + }, + (t) => [ + index("auth_org_project_account_region_org_idx").on( + t.accountKey, + t.cloudRegion, + t.orgId, + ), + ], +); diff --git a/apps/code/src/main/services/auth/schemas.ts b/apps/code/src/main/services/auth/schemas.ts index f165e6a22a..378a39eaf2 100644 --- a/apps/code/src/main/services/auth/schemas.ts +++ b/apps/code/src/main/services/auth/schemas.ts @@ -4,13 +4,22 @@ import { cloudRegion, type oAuthTokenResponse } from "../oauth/schemas"; export const authStatusSchema = z.enum(["anonymous", "authenticated"]); export type AuthStatus = z.infer; +export const orgProjectsSchema = z.object({ + orgName: z.string(), + projects: z.array(z.object({ id: z.number(), name: z.string() })), +}); +export type OrgProjects = z.infer; + +export const orgProjectsMapSchema = z.record(z.string(), orgProjectsSchema); +export type OrgProjectsMap = z.infer; + export const authStateSchema = z.object({ status: authStatusSchema, bootstrapComplete: z.boolean(), cloudRegion: cloudRegion.nullable(), - projectId: z.number().nullable(), - availableProjectIds: z.array(z.number()), - availableOrgIds: z.array(z.string()), + orgProjectsMap: orgProjectsMapSchema, + currentOrgId: z.string().nullable(), + currentProjectId: z.number().nullable(), hasCodeAccess: z.boolean().nullable(), needsScopeReauth: z.boolean(), }); @@ -34,6 +43,11 @@ export const selectProjectInput = z.object({ projectId: z.number(), }); +export const switchOrgInput = z.object({ + orgId: z.string().min(1), +}); +export type SwitchOrgInput = z.infer; + export const validAccessTokenOutput = z.object({ accessToken: z.string(), apiHost: z.string(), diff --git a/apps/code/src/main/services/auth/service.test.ts b/apps/code/src/main/services/auth/service.test.ts index 8733ebd258..0e010d1a40 100644 --- a/apps/code/src/main/services/auth/service.test.ts +++ b/apps/code/src/main/services/auth/service.test.ts @@ -34,7 +34,6 @@ function mockTokenResponse( overrides: { accessToken?: string; refreshToken?: string; - scopedTeams?: number[]; scopedOrgs?: string[]; } = {}, ) { @@ -46,7 +45,6 @@ function mockTokenResponse( expires_in: 3600, token_type: "Bearer", scope: "", - scoped_teams: overrides.scopedTeams ?? [42], scoped_organizations: overrides.scopedOrgs ?? ["org-1"], }, }; @@ -98,7 +96,22 @@ describe("AuthService", () => { return (call as unknown as [() => void])[0]; } - const stubAuthFetch = (accountKey = "user-1") => { + const stubAuthFetch = ( + options: { + accountKey?: string; + currentOrgId?: string; + orgs?: Record< + string, + { name: string; projects: { id: number; name: string }[] } + >; + } = {}, + ) => { + const accountKey = options.accountKey ?? "user-1"; + const currentOrgId = options.currentOrgId ?? "org-1"; + const orgs = options.orgs ?? { + "org-1": { name: "Org 1", projects: [{ id: 42, name: "Project 42" }] }, + }; + vi.stubGlobal( "fetch", vi.fn(async (input: string | Request) => { @@ -107,7 +120,33 @@ describe("AuthService", () => { if (url.includes("/api/users/@me/")) { return { ok: true, - json: vi.fn().mockResolvedValue({ uuid: accountKey }), + json: vi.fn().mockResolvedValue({ + uuid: accountKey, + organization: { id: currentOrgId }, + }), + } as unknown as Response; + } + + const orgProjectsMatch = url.match( + /\/api\/organizations\/([^/]+)\/projects\/$/, + ); + if (orgProjectsMatch) { + const orgId = orgProjectsMatch[1]; + const projects = orgs[orgId]?.projects ?? []; + return { + ok: true, + json: vi.fn().mockResolvedValue({ results: projects }), + } as unknown as Response; + } + + const orgMatch = url.match(/\/api\/organizations\/([^/]+)\/$/); + if (orgMatch) { + const orgId = orgMatch[1]; + return { + ok: true, + json: vi + .fn() + .mockResolvedValue({ name: orgs[orgId]?.name ?? "Unknown" }), } as unknown as Response; } @@ -147,9 +186,9 @@ describe("AuthService", () => { status: "anonymous", bootstrapComplete: true, cloudRegion: null, - projectId: null, - availableProjectIds: [], - availableOrgIds: [], + orgProjectsMap: {}, + currentOrgId: null, + currentProjectId: null, hasCodeAccess: null, needsScopeReauth: false, }); @@ -168,9 +207,9 @@ describe("AuthService", () => { status: "anonymous", bootstrapComplete: true, cloudRegion: "us", - projectId: 123, - availableProjectIds: [], - availableOrgIds: [], + orgProjectsMap: {}, + currentOrgId: null, + currentProjectId: 123, hasCodeAccess: null, needsScopeReauth: true, }); @@ -182,10 +221,19 @@ describe("AuthService", () => { mockTokenResponse({ accessToken: "new-access-token", refreshToken: "rotated-refresh-token", - scopedTeams: [42, 84], }), ); - stubAuthFetch(); + stubAuthFetch({ + orgs: { + "org-1": { + name: "Org 1", + projects: [ + { id: 42, name: "Project 42" }, + { id: 84, name: "Project 84" }, + ], + }, + }, + }); await service.initialize(); @@ -193,9 +241,17 @@ describe("AuthService", () => { status: "authenticated", bootstrapComplete: true, cloudRegion: "us", - projectId: 42, - availableProjectIds: [42, 84], - availableOrgIds: ["org-1"], + orgProjectsMap: { + "org-1": { + orgName: "Org 1", + projects: [ + { id: 42, name: "Project 42" }, + { id: 84, name: "Project 84" }, + ], + }, + }, + currentOrgId: "org-1", + currentProjectId: 42, hasCodeAccess: true, needsScopeReauth: false, }); @@ -234,29 +290,35 @@ describe("AuthService", () => { }); it("preserves the selected project across logout and re-login for the same account", async () => { + const orgs = { + "org-1": { + name: "Org 1", + projects: [ + { id: 42, name: "Project 42" }, + { id: 84, name: "Project 84" }, + ], + }, + }; vi.mocked(oauthService.startFlow) .mockResolvedValueOnce( mockTokenResponse({ accessToken: "initial-access-token", refreshToken: "initial-refresh-token", - scopedTeams: [42, 84], }), ) .mockResolvedValueOnce( mockTokenResponse({ accessToken: "second-access-token", refreshToken: "second-refresh-token", - scopedTeams: [42, 84], }), ); vi.mocked(oauthService.refreshToken).mockResolvedValue( mockTokenResponse({ accessToken: "refreshed-access-token", refreshToken: "refreshed-refresh-token", - scopedTeams: [42, 84], }), ); - stubAuthFetch(); + stubAuthFetch({ orgs }); await service.login("us"); await service.selectProject(84); @@ -265,7 +327,7 @@ describe("AuthService", () => { expect(service.getState()).toMatchObject({ status: "anonymous", cloudRegion: "us", - projectId: 84, + currentProjectId: 84, }); await service.login("us"); @@ -273,35 +335,49 @@ describe("AuthService", () => { expect(service.getState()).toMatchObject({ status: "authenticated", cloudRegion: "us", - projectId: 84, - availableProjectIds: [42, 84], + currentProjectId: 84, + orgProjectsMap: { + "org-1": { + orgName: "Org 1", + projects: [ + { id: 42, name: "Project 42" }, + { id: 84, name: "Project 84" }, + ], + }, + }, }); }); it("restores the selected project after app restart while logged out", async () => { + const orgs = { + "org-1": { + name: "Org 1", + projects: [ + { id: 42, name: "Project 42" }, + { id: 84, name: "Project 84" }, + ], + }, + }; vi.mocked(oauthService.startFlow) .mockResolvedValueOnce( mockTokenResponse({ accessToken: "initial-access-token", refreshToken: "initial-refresh-token", - scopedTeams: [42, 84], }), ) .mockResolvedValueOnce( mockTokenResponse({ accessToken: "second-access-token", refreshToken: "second-refresh-token", - scopedTeams: [42, 84], }), ); vi.mocked(oauthService.refreshToken).mockResolvedValue( mockTokenResponse({ accessToken: "refreshed-access-token", refreshToken: "refreshed-refresh-token", - scopedTeams: [42, 84], }), ); - stubAuthFetch(); + stubAuthFetch({ orgs }); await service.login("us"); await service.selectProject(84); @@ -320,8 +396,16 @@ describe("AuthService", () => { expect(service.getState()).toMatchObject({ status: "authenticated", cloudRegion: "us", - projectId: 84, - availableProjectIds: [42, 84], + currentProjectId: 84, + orgProjectsMap: { + "org-1": { + orgName: "Org 1", + projects: [ + { id: 42, name: "Project 42" }, + { id: 84, name: "Project 84" }, + ], + }, + }, }); }); @@ -464,7 +548,7 @@ describe("AuthService", () => { expect(service.getState()).toMatchObject({ status: "anonymous", cloudRegion: "us", - projectId: 42, + currentProjectId: 42, }); expect(oauthService.refreshToken).toHaveBeenCalledTimes(1); expect(repository.getCurrent()).toBeNull(); diff --git a/apps/code/src/main/services/auth/service.ts b/apps/code/src/main/services/auth/service.ts index e59051aa16..f90461e345 100644 --- a/apps/code/src/main/services/auth/service.ts +++ b/apps/code/src/main/services/auth/service.ts @@ -25,6 +25,8 @@ import { type AuthServiceEvents, type AuthState, type AuthTokenResponse, + type OrgProjects, + type OrgProjectsMap, type ValidAccessTokenOutput, } from "./schemas"; @@ -41,9 +43,25 @@ interface InMemorySession { accessTokenExpiresAt: number; refreshToken: string; cloudRegion: CloudRegion; - projectId: number | null; - availableProjectIds: number[]; - availableOrgIds: string[]; + orgProjectsMap: OrgProjectsMap; + currentOrgId: string | null; + currentProjectId: number | null; +} + +function flattenProjectIds(map: OrgProjectsMap): number[] { + return Object.values(map).flatMap((org) => org.projects.map((p) => p.id)); +} + +function findOrgForProject( + map: OrgProjectsMap, + projectId: number, +): string | null { + for (const [orgId, org] of Object.entries(map)) { + if (org.projects.some((p) => p.id === projectId)) { + return orgId; + } + } + return null; } interface StoredSessionInput { @@ -63,9 +81,9 @@ export class AuthService extends TypedEventEmitter { status: "anonymous", bootstrapComplete: false, cloudRegion: null, - projectId: null, - availableProjectIds: [], - availableOrgIds: [], + orgProjectsMap: {}, + currentOrgId: null, + currentProjectId: null, hasCodeAccess: null, needsScopeReauth: false, }; @@ -220,13 +238,13 @@ export class AuthService extends TypedEventEmitter { const session = this.requireSession(); - if (!session.availableProjectIds.includes(projectId)) { + if (!flattenProjectIds(session.orgProjectsMap).includes(projectId)) { throw new Error("Invalid project selection"); } this.session = { ...session, - projectId, + currentProjectId: projectId, }; this.persistProjectPreference(this.session); @@ -236,15 +254,68 @@ export class AuthService extends TypedEventEmitter { selectedProjectId: projectId, }); - this.updateState({ projectId }); + this.updateState({ currentProjectId: projectId }); + return this.getState(); + } + async switchOrg(orgId: string): Promise { + await this.initialize(); + + const session = this.requireSession(); + + if (!session.orgProjectsMap[orgId]) { + throw new Error("Invalid organization"); + } + + const apiHost = getCloudUrlFromRegion(session.cloudRegion); + const response = await this.authenticatedFetch( + fetch, + `${apiHost}/api/users/@me/`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ set_current_organization: orgId }), + }, + ); + + if (!response.ok) { + throw new Error(`Failed to switch organization: ${response.statusText}`); + } + + const preferredProjectId = session.accountKey + ? (this.authPreferenceRepository.getOrgProject( + session.accountKey, + session.cloudRegion, + orgId, + )?.lastSelectedProjectId ?? null) + : null; + const orgProjects = session.orgProjectsMap[orgId]?.projects ?? []; + const currentProjectId = + preferredProjectId && orgProjects.some((p) => p.id === preferredProjectId) + ? preferredProjectId + : (orgProjects[0]?.id ?? null); + + this.session = { + ...session, + currentOrgId: orgId, + currentProjectId, + }; + + this.persistProjectPreference(this.session); + this.persistSession({ + refreshToken: this.session.refreshToken, + cloudRegion: this.session.cloudRegion, + selectedProjectId: currentProjectId, + }); + + this.updateState({ currentOrgId: orgId, currentProjectId }); return this.getState(); } async logout(): Promise { - const { cloudRegion, projectId } = this.state; + const { cloudRegion, currentProjectId } = this.state; this.authSessionRepository.clearCurrent(); this.session = null; - this.setAnonymousState({ cloudRegion, projectId }); + this.setAnonymousState({ cloudRegion, currentProjectId }); return this.getState(); } private executeAuthenticatedFetch( @@ -274,7 +345,7 @@ export class AuthService extends TypedEventEmitter { this.setAnonymousState({ bootstrapComplete: true, cloudRegion: stored.cloudRegion, - projectId: stored.selectedProjectId, + currentProjectId: stored.selectedProjectId, needsScopeReauth: true, }); return; @@ -296,7 +367,7 @@ export class AuthService extends TypedEventEmitter { this.setAnonymousState({ bootstrapComplete: true, cloudRegion: storedSession.cloudRegion, - projectId: storedSession.selectedProjectId, + currentProjectId: storedSession.selectedProjectId, }); } } @@ -331,7 +402,7 @@ export class AuthService extends TypedEventEmitter { return { refreshToken: this.session.refreshToken, cloudRegion: this.session.cloudRegion, - selectedProjectId: this.session.projectId, + selectedProjectId: this.session.currentProjectId, }; } @@ -373,7 +444,7 @@ export class AuthService extends TypedEventEmitter { this.session = null; this.setAnonymousState({ cloudRegion: input.cloudRegion, - projectId: input.selectedProjectId, + currentProjectId: input.selectedProjectId, }); throw new Error(lastError); } @@ -402,22 +473,27 @@ export class AuthService extends TypedEventEmitter { tokenResponse: AuthTokenResponse, options: TokenResponseOptions, ): Promise { - const availableProjectIds = tokenResponse.scoped_teams ?? []; - const availableOrgIds = tokenResponse.scoped_organizations ?? []; - const accountKey = await this.fetchAccountKey( + const scopedOrgIds = tokenResponse.scoped_organizations ?? []; + const { accountKey, currentOrgId } = await this.fetchUserContext( + tokenResponse.access_token, + options.cloudRegion, + ); + const orgProjectsMap = await this.buildOrgProjectsMap( tokenResponse.access_token, options.cloudRegion, + scopedOrgIds, ); + const allProjectIds = flattenProjectIds(orgProjectsMap); const preferredProjectId = options.selectedProjectId ?? (accountKey ? (this.authPreferenceRepository.get(accountKey, options.cloudRegion) ?.lastSelectedProjectId ?? null) : null); - const projectId = - preferredProjectId && availableProjectIds.includes(preferredProjectId) + const currentProjectId = + preferredProjectId && allProjectIds.includes(preferredProjectId) ? preferredProjectId - : (availableProjectIds[0] ?? null); + : (allProjectIds[0] ?? null); const session: InMemorySession = { accountKey, @@ -425,13 +501,67 @@ export class AuthService extends TypedEventEmitter { accessTokenExpiresAt: Date.now() + tokenResponse.expires_in * 1000, refreshToken: tokenResponse.refresh_token, cloudRegion: options.cloudRegion, - projectId, - availableProjectIds, - availableOrgIds, + orgProjectsMap, + currentOrgId, + currentProjectId, }; return session; } + private async buildOrgProjectsMap( + accessToken: string, + cloudRegion: CloudRegion, + orgIds: string[], + ): Promise { + const apiHost = getCloudUrlFromRegion(cloudRegion); + const entries = await Promise.all( + orgIds.map(async (orgId): Promise<[string, OrgProjects]> => { + try { + const [orgRes, projectsRes] = await Promise.all([ + fetch(`${apiHost}/api/organizations/${orgId}/`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }), + fetch(`${apiHost}/api/organizations/${orgId}/projects/`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }), + ]); + + const orgData = orgRes.ok + ? ((await orgRes.json().catch(() => ({}))) as { name?: unknown }) + : null; + const projectsRaw = projectsRes.ok + ? ((await projectsRes.json().catch(() => null)) as unknown) + : null; + const projectsArray = Array.isArray(projectsRaw) + ? projectsRaw + : Array.isArray( + (projectsRaw as { results?: unknown } | null)?.results, + ) + ? ((projectsRaw as { results: unknown[] }).results as unknown[]) + : []; + + const projects = projectsArray + .map((p) => p as { id?: unknown; name?: unknown }) + .filter( + (p) => typeof p.id === "number" && typeof p.name === "string", + ) + .map((p) => ({ id: p.id as number, name: p.name as string })); + + const orgName = + typeof orgData?.name === "string" && orgData.name.length > 0 + ? orgData.name + : "(unknown)"; + + return [orgId, { orgName, projects }]; + } catch (error) { + log.warn("Failed to fetch org projects", { orgId, error }); + return [orgId, { orgName: "(unknown)", projects: [] }]; + } + }), + ); + + return Object.fromEntries(entries); + } private async authenticateWithFlow( runFlow: () => Promise<{ success: boolean; @@ -448,7 +578,7 @@ export class AuthService extends TypedEventEmitter { const session = await this.createSessionFromTokenResponse(result.data, { cloudRegion: region, - selectedProjectId: this.state.projectId, + selectedProjectId: this.state.currentProjectId, }); await this.syncAuthenticatedSession(session); } @@ -465,7 +595,7 @@ export class AuthService extends TypedEventEmitter { this.persistSession({ refreshToken: session.refreshToken, cloudRegion: session.cloudRegion, - selectedProjectId: session.projectId, + selectedProjectId: session.currentProjectId, }); this.session = session; @@ -473,9 +603,9 @@ export class AuthService extends TypedEventEmitter { status: "authenticated", bootstrapComplete: true, cloudRegion: session.cloudRegion, - projectId: session.projectId, - availableProjectIds: session.availableProjectIds, - availableOrgIds: session.availableOrgIds, + orgProjectsMap: session.orgProjectsMap, + currentOrgId: session.currentOrgId, + currentProjectId: session.currentProjectId, needsScopeReauth: false, }); await this.updateCodeAccessFromSession(); @@ -502,16 +632,29 @@ export class AuthService extends TypedEventEmitter { this.authPreferenceRepository.save({ accountKey: session.accountKey, cloudRegion: session.cloudRegion, - lastSelectedProjectId: session.projectId, + lastSelectedProjectId: session.currentProjectId, + lastSelectedOrgId: session.currentOrgId, }); + + const orgIdForProject = session.currentProjectId + ? findOrgForProject(session.orgProjectsMap, session.currentProjectId) + : null; + if (orgIdForProject && session.currentProjectId) { + this.authPreferenceRepository.saveOrgProject({ + accountKey: session.accountKey, + cloudRegion: session.cloudRegion, + orgId: orgIdForProject, + lastSelectedProjectId: session.currentProjectId, + }); + } } private isSessionExpiring(session: InMemorySession): boolean { return session.accessTokenExpiresAt - Date.now() <= TOKEN_EXPIRY_SKEW_MS; } - private async fetchAccountKey( + private async fetchUserContext( accessToken: string, cloudRegion: "us" | "eu" | "dev", - ): Promise { + ): Promise<{ accountKey: string | null; currentOrgId: string | null }> { try { const response = await fetch( `${getCloudUrlFromRegion(cloudRegion)}/api/users/@me/`, @@ -523,29 +666,36 @@ export class AuthService extends TypedEventEmitter { ); if (!response.ok) { - return null; + return { accountKey: null, currentOrgId: null }; } const data = (await response.json().catch(() => ({}))) as { uuid?: unknown; distinct_id?: unknown; email?: unknown; + organization?: { id?: unknown } | null; }; + let accountKey: string | null = null; if (typeof data.uuid === "string" && data.uuid.length > 0) { - return data.uuid; - } - if (typeof data.distinct_id === "string" && data.distinct_id.length > 0) { - return data.distinct_id; - } - if (typeof data.email === "string" && data.email.length > 0) { - return data.email; + accountKey = data.uuid; + } else if ( + typeof data.distinct_id === "string" && + data.distinct_id.length > 0 + ) { + accountKey = data.distinct_id; + } else if (typeof data.email === "string" && data.email.length > 0) { + accountKey = data.email; } - return null; + const orgId = data.organization?.id; + const currentOrgId = + typeof orgId === "string" && orgId.length > 0 ? orgId : null; + + return { accountKey, currentOrgId }; } catch (error) { - log.warn("Failed to resolve auth account key", { error }); - return null; + log.warn("Failed to resolve user context", { error }); + return { accountKey: null, currentOrgId: null }; } } private requireSession(): InMemorySession { @@ -557,16 +707,19 @@ export class AuthService extends TypedEventEmitter { private setAnonymousState( partial: Pick< Partial, - "bootstrapComplete" | "cloudRegion" | "projectId" | "needsScopeReauth" + | "bootstrapComplete" + | "cloudRegion" + | "currentProjectId" + | "needsScopeReauth" > = {}, ): void { this.updateState({ status: "anonymous", bootstrapComplete: partial.bootstrapComplete ?? true, cloudRegion: partial.cloudRegion ?? null, - projectId: partial.projectId ?? null, - availableProjectIds: [], - availableOrgIds: [], + orgProjectsMap: {}, + currentOrgId: null, + currentProjectId: partial.currentProjectId ?? null, hasCodeAccess: null, needsScopeReauth: partial.needsScopeReauth ?? false, }); diff --git a/apps/code/src/main/services/enrichment/findStaleFlagSuggestions.test.ts b/apps/code/src/main/services/enrichment/findStaleFlagSuggestions.test.ts index 4b394f783e..de63b38d57 100644 --- a/apps/code/src/main/services/enrichment/findStaleFlagSuggestions.test.ts +++ b/apps/code/src/main/services/enrichment/findStaleFlagSuggestions.test.ts @@ -17,7 +17,7 @@ function authedStub(): AuthService { return { getState: vi.fn(() => ({ status: "authenticated", - projectId: 42, + currentProjectId: 42, cloudRegion: "us", })), getValidAccessToken: vi.fn(async () => ({ diff --git a/apps/code/src/main/services/enrichment/service.ts b/apps/code/src/main/services/enrichment/service.ts index e859d2ecc2..29375d7db6 100644 --- a/apps/code/src/main/services/enrichment/service.ts +++ b/apps/code/src/main/services/enrichment/service.ts @@ -203,7 +203,7 @@ export class EnrichmentService { const state = this.authService.getState(); if ( state.status !== "authenticated" || - !state.projectId || + !state.currentProjectId || !state.cloudRegion ) { return null; @@ -213,7 +213,7 @@ export class EnrichmentService { return { apiKey: auth.accessToken, host: auth.apiHost, - projectId: state.projectId, + projectId: state.currentProjectId, }; } catch (err) { log.debug("Failed to resolve access token", { diff --git a/apps/code/src/main/services/oauth/schemas.ts b/apps/code/src/main/services/oauth/schemas.ts index aef4a0280a..e3333f45c8 100644 --- a/apps/code/src/main/services/oauth/schemas.ts +++ b/apps/code/src/main/services/oauth/schemas.ts @@ -24,7 +24,6 @@ export const oAuthTokenResponse = z.object({ token_type: z.string(), scope: z.string().optional().default(""), refresh_token: z.string(), - scoped_teams: z.array(z.number()).optional(), scoped_organizations: z.array(z.string()).optional(), }); export type OAuthTokenResponse = z.infer; diff --git a/apps/code/src/main/trpc/routers/auth.ts b/apps/code/src/main/trpc/routers/auth.ts index 161d071145..bb0704179a 100644 --- a/apps/code/src/main/trpc/routers/auth.ts +++ b/apps/code/src/main/trpc/routers/auth.ts @@ -7,6 +7,7 @@ import { loginOutput, redeemInviteCodeInput, selectProjectInput, + switchOrgInput, validAccessTokenOutput, } from "../../services/auth/schemas"; import type { AuthService } from "../../services/auth/service"; @@ -56,6 +57,11 @@ export const authRouter = router({ .output(authStateSchema) .mutation(async ({ input }) => getService().selectProject(input.projectId)), + switchOrg: publicProcedure + .input(switchOrgInput) + .output(authStateSchema) + .mutation(async ({ input }) => getService().switchOrg(input.orgId)), + redeemInviteCode: publicProcedure .input(redeemInviteCodeInput) .output(authStateSchema) diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index dc4721d056..0439ff98ce 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -581,8 +581,8 @@ export class PostHogAPIClient { } } - setTeamId(teamId: number): void { - this._teamId = teamId; + setTeamId(teamId: number | null | undefined): void { + this._teamId = teamId ?? null; } private async getTeamId(): Promise { @@ -693,6 +693,51 @@ export class PostHogAPIClient { }); } + async listOrgProjects( + orgId: string, + ): Promise<{ id: number; name: string }[]> { + const urlPath = `/api/organizations/${encodeURIComponent(orgId)}/projects/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + if (!response.ok) { + throw new Error( + `Failed to list organization projects: ${response.statusText}`, + ); + } + const raw = (await response.json()) as unknown; + const list = Array.isArray(raw) + ? raw + : Array.isArray((raw as { results?: unknown } | null)?.results) + ? ((raw as { results: unknown[] }).results as unknown[]) + : []; + return list + .map((p) => p as { id?: unknown; name?: unknown }) + .filter((p) => typeof p.id === "number" && typeof p.name === "string") + .map((p) => ({ id: p.id as number, name: p.name as string })); + } + + async approveAiDataProcessing(): Promise { + const urlPath = `/api/organizations/@current/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "patch", + url, + path: urlPath, + overrides: { + body: JSON.stringify({ is_ai_data_processing_approved: true }), + }, + }); + if (!response.ok) { + throw new Error( + `Failed to approve AI data processing: ${response.statusText}`, + ); + } + } + async getProject(projectId: number) { //@ts-expect-error this is not in the generated client const data = await this.api.get("/api/projects/{project_id}/", { diff --git a/apps/code/src/renderer/components/ScopeReauthPrompt.test.tsx b/apps/code/src/renderer/components/ScopeReauthPrompt.test.tsx index 0cb091af0e..de6675ce61 100644 --- a/apps/code/src/renderer/components/ScopeReauthPrompt.test.tsx +++ b/apps/code/src/renderer/components/ScopeReauthPrompt.test.tsx @@ -9,9 +9,12 @@ const authState = { status: "anonymous" as const, bootstrapComplete: true, cloudRegion: null as "us" | "eu" | "dev" | null, - projectId: null, - availableProjectIds: [], - availableOrgIds: [], + orgProjectsMap: {} as Record< + string, + { orgName: string; projects: { id: number; name: string }[] } + >, + currentOrgId: null as string | null, + currentProjectId: null as number | null, hasCodeAccess: null, needsScopeReauth: false, }; @@ -57,7 +60,7 @@ describe("ScopeReauthPrompt", () => { vi.clearAllMocks(); authState.status = "anonymous"; authState.cloudRegion = null; - authState.projectId = null; + authState.currentProjectId = null; authState.hasCodeAccess = null; authState.needsScopeReauth = false; }); diff --git a/apps/code/src/renderer/features/auth/hooks/authClient.ts b/apps/code/src/renderer/features/auth/hooks/authClient.ts index 42d23a1990..34805c4360 100644 --- a/apps/code/src/renderer/features/auth/hooks/authClient.ts +++ b/apps/code/src/renderer/features/auth/hooks/authClient.ts @@ -30,11 +30,11 @@ export function createAuthenticatedClient( getCloudUrlFromRegion(authState.cloudRegion), getValidAccessToken, refreshAccessToken, - authState.projectId ?? undefined, + authState.currentProjectId ?? undefined, ); - if (authState.projectId) { - client.setTeamId(authState.projectId); + if (authState.currentProjectId) { + client.setTeamId(authState.currentProjectId); } return client; @@ -49,7 +49,12 @@ export function useOptionalAuthenticatedClient(): PostHogAPIClient | null { return useMemo( () => createAuthenticatedClient(authState), - [authState.cloudRegion, authState.projectId, authState.status, authState], + [ + authState.cloudRegion, + authState.currentProjectId, + authState.status, + authState, + ], ); } diff --git a/apps/code/src/renderer/features/auth/hooks/authMutations.ts b/apps/code/src/renderer/features/auth/hooks/authMutations.ts index a371710d5d..81501a0751 100644 --- a/apps/code/src/renderer/features/auth/hooks/authMutations.ts +++ b/apps/code/src/renderer/features/auth/hooks/authMutations.ts @@ -26,7 +26,7 @@ function useAuthFlowMutation( await refreshAuthStateQuery(); useAuthUiStateStore.getState().clearStaleRegion(); track(ANALYTICS_EVENTS.USER_LOGGED_IN, { - project_id: state.projectId?.toString() ?? "", + project_id: state.currentProjectId?.toString() ?? "", region, }); }, @@ -59,6 +59,19 @@ export function useSelectProjectMutation() { }); } +export function useSwitchOrgMutation() { + return useMutation({ + mutationFn: async (orgId: string) => { + resetSessionService(); + return await trpcClient.auth.switchOrg.mutate({ orgId }); + }, + onSuccess: async () => { + clearAuthScopedQueries(); + await refreshAuthStateQuery(); + }, + }); +} + export function useRedeemInviteCodeMutation() { return useMutation({ mutationFn: async (code: string) => diff --git a/apps/code/src/renderer/features/auth/hooks/authQueries.ts b/apps/code/src/renderer/features/auth/hooks/authQueries.ts index c7a7198c71..ee8dada494 100644 --- a/apps/code/src/renderer/features/auth/hooks/authQueries.ts +++ b/apps/code/src/renderer/features/auth/hooks/authQueries.ts @@ -15,9 +15,9 @@ export const ANONYMOUS_AUTH_STATE: AuthState = { status: "anonymous", bootstrapComplete: false, cloudRegion: null, - projectId: null, - availableProjectIds: [], - availableOrgIds: [], + orgProjectsMap: {}, + currentOrgId: null, + currentProjectId: null, hasCodeAccess: null, needsScopeReauth: false, }; @@ -58,7 +58,7 @@ export function getAuthIdentity(authState: AuthState): string | null { return null; } - return `${authState.cloudRegion}:${authState.projectId ?? "none"}`; + return `${authState.cloudRegion}:${authState.currentProjectId ?? "none"}`; } export function useAuthState() { diff --git a/apps/code/src/renderer/features/auth/hooks/useAuthSession.ts b/apps/code/src/renderer/features/auth/hooks/useAuthSession.ts index f3b946ce93..25ec0f3583 100644 --- a/apps/code/src/renderer/features/auth/hooks/useAuthSession.ts +++ b/apps/code/src/renderer/features/auth/hooks/useAuthSession.ts @@ -68,7 +68,7 @@ function useAuthAnalyticsIdentity( identifyUser(distinctId, { email: currentUser.email, uuid: currentUser.uuid, - project_id: authState.projectId?.toString() ?? "", + project_id: authState.currentProjectId?.toString() ?? "", region: authState.cloudRegion ?? "", }); @@ -79,11 +79,16 @@ function useAuthAnalyticsIdentity( properties: { email: currentUser.email, uuid: currentUser.uuid, - project_id: authState.projectId?.toString() ?? "", + project_id: authState.currentProjectId?.toString() ?? "", region: authState.cloudRegion ?? "", }, }); - }, [authIdentity, authState.cloudRegion, authState.projectId, currentUser]); + }, [ + authIdentity, + authState.cloudRegion, + authState.currentProjectId, + currentUser, + ]); } function useSeatSync( diff --git a/apps/code/src/renderer/features/auth/stores/authStore.test.ts b/apps/code/src/renderer/features/auth/stores/authStore.test.ts index f5d0ec9518..50e1725ab3 100644 --- a/apps/code/src/renderer/features/auth/stores/authStore.test.ts +++ b/apps/code/src/renderer/features/auth/stores/authStore.test.ts @@ -85,16 +85,23 @@ vi.mock("@stores/navigationStore", () => ({ })); import { resetUser, setUserGroups } from "@utils/analytics"; -import { queryClient } from "@utils/queryClient"; import { resetAuthStoreModuleStateForTest, useAuthStore } from "./authStore"; const authenticatedState = { status: "authenticated" as const, bootstrapComplete: true, cloudRegion: "us" as const, - projectId: 1, - availableProjectIds: [1, 2], - availableOrgIds: ["org-1"], + orgProjectsMap: { + "org-1": { + orgName: "Org 1", + projects: [ + { id: 1, name: "Project 1" }, + { id: 2, name: "Project 2" }, + ], + }, + }, + currentOrgId: "org-1", + currentProjectId: 1, hasCodeAccess: true, needsScopeReauth: false, }; @@ -120,9 +127,9 @@ describe("authStore", () => { status: "anonymous", bootstrapComplete: true, cloudRegion: null, - projectId: null, - availableProjectIds: [], - availableOrgIds: [], + orgProjectsMap: {}, + currentOrgId: null, + currentProjectId: null, hasCodeAccess: null, needsScopeReauth: false, }); @@ -131,9 +138,9 @@ describe("authStore", () => { staleCloudRegion: null, isAuthenticated: false, client: null, - projectId: null, - availableProjectIds: [], - availableOrgIds: [], + orgProjectsMap: {}, + currentOrgId: null, + currentProjectId: null, needsProjectSelection: false, needsScopeReauth: false, hasCodeAccess: null, @@ -146,7 +153,7 @@ describe("authStore", () => { await useAuthStore.getState().checkCodeAccess(); expect(useAuthStore.getState().isAuthenticated).toBe(true); - expect(useAuthStore.getState().projectId).toBe(1); + expect(useAuthStore.getState().currentProjectId).toBe(1); }); it("logs in through the main auth service", async () => { @@ -177,9 +184,9 @@ describe("authStore", () => { status: "anonymous", bootstrapComplete: true, cloudRegion: null, - projectId: null, - availableProjectIds: [], - availableOrgIds: [], + orgProjectsMap: {}, + currentOrgId: null, + currentProjectId: null, hasCodeAccess: null, needsScopeReauth: false, }); @@ -188,10 +195,6 @@ describe("authStore", () => { await useAuthStore.getState().checkCodeAccess(); expect(resetUser).toHaveBeenCalledTimes(1); - expect(queryClient.removeQueries).toHaveBeenCalledWith({ - queryKey: ["currentUser"], - exact: true, - }); }); it("clears auth state immediately on logout before the auth service responds", async () => { @@ -211,7 +214,7 @@ describe("authStore", () => { expect(useAuthStore.getState().isAuthenticated).toBe(false); expect(useAuthStore.getState().client).toBeNull(); - expect(useAuthStore.getState().projectId).toBeNull(); + expect(useAuthStore.getState().currentProjectId).toBeNull(); expect(useAuthStore.getState().needsScopeReauth).toBe(false); resolveLogout(); diff --git a/apps/code/src/renderer/features/auth/stores/authStore.ts b/apps/code/src/renderer/features/auth/stores/authStore.ts index 8de660445c..8bc7b1814d 100644 --- a/apps/code/src/renderer/features/auth/stores/authStore.ts +++ b/apps/code/src/renderer/features/auth/stores/authStore.ts @@ -1,3 +1,4 @@ +import { authKeys, getAuthIdentity } from "@features/auth/hooks/authQueries"; import { useSeatStore } from "@features/billing/stores/seatStore"; import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; import { PostHogAPIClient } from "@renderer/api/posthogClient"; @@ -16,6 +17,11 @@ import { logger } from "@utils/logger"; import { queryClient } from "@utils/queryClient"; import { create } from "zustand"; +type OrgProjects = { + orgName: string; + projects: { id: number; name: string }[]; +}; + const log = logger.scope("auth-store"); let sessionResetCallback: (() => void) | null = null; @@ -39,9 +45,9 @@ interface AuthStoreState { staleCloudRegion: CloudRegion | null; isAuthenticated: boolean; client: PostHogAPIClient | null; - projectId: number | null; - availableProjectIds: number[]; - availableOrgIds: string[]; + orgProjectsMap: Record; + currentOrgId: string | null; + currentProjectId: number | null; needsProjectSelection: boolean; needsScopeReauth: boolean; hasCodeAccess: boolean | null; @@ -51,9 +57,14 @@ interface AuthStoreState { loginWithOAuth: (region: CloudRegion) => Promise; signupWithOAuth: (region: CloudRegion) => Promise; selectProject: (projectId: number) => Promise; + switchOrg: (orgId: string) => Promise; logout: () => Promise; } +function flattenProjectIds(map: Record): number[] { + return Object.values(map).flatMap((org) => org.projects.map((p) => p.id)); +} + async function getValidAccessToken(): Promise { const { accessToken } = await trpcClient.auth.getValidAccessToken.query(); return accessToken; @@ -88,10 +99,7 @@ function clearAuthenticatedRendererState(options?: { if (options?.clearAllQueries) { queryClient.clear(); - return; } - - queryClient.removeQueries({ queryKey: ["currentUser"], exact: true }); } async function syncAuthState(): Promise { @@ -101,14 +109,16 @@ async function syncAuthState(): Promise { useAuthStore.setState((state) => { const regionChanged = authState.cloudRegion !== state.cloudRegion; - const projectChanged = authState.projectId !== state.projectId; + const projectChanged = + authState.currentProjectId !== state.currentProjectId; const client = isAuthenticated && authState.cloudRegion ? regionChanged || projectChanged || !state.client - ? createClient(authState.cloudRegion, authState.projectId) + ? createClient(authState.cloudRegion, authState.currentProjectId) : state.client : null; + const projectIds = flattenProjectIds(authState.orgProjectsMap); return { ...state, isAuthenticated, @@ -117,13 +127,13 @@ async function syncAuthState(): Promise { ? null : (authState.cloudRegion ?? state.staleCloudRegion), client, - projectId: authState.projectId, - availableProjectIds: authState.availableProjectIds, - availableOrgIds: authState.availableOrgIds, + orgProjectsMap: authState.orgProjectsMap, + currentOrgId: authState.currentOrgId, + currentProjectId: authState.currentProjectId, needsProjectSelection: isAuthenticated && - authState.availableProjectIds.length > 1 && - authState.projectId === null, + projectIds.length > 1 && + authState.currentProjectId === null, needsScopeReauth: authState.needsScopeReauth, hasCodeAccess: authState.hasCodeAccess, }; @@ -144,7 +154,7 @@ async function syncAuthState(): Promise { const authSyncKey = JSON.stringify({ status: authState.status, cloudRegion: authState.cloudRegion, - projectId: authState.projectId, + currentProjectId: authState.currentProjectId, }); if (authSyncKey === lastCompletedAuthSyncKey) { @@ -160,13 +170,14 @@ async function syncAuthState(): Promise { inFlightAuthSync = (async () => { try { const user = await client.getCurrentUser(); - queryClient.setQueryData(["currentUser"], user); + const authIdentity = getAuthIdentity(authState); + queryClient.setQueryData(authKeys.currentUser(authIdentity), user); const distinctId = user.distinct_id || user.email; identifyUser(distinctId, { email: user.email, uuid: user.uuid, - project_id: authState.projectId?.toString() ?? "", + project_id: authState.currentProjectId?.toString() ?? "", region: authState.cloudRegion ?? "", }); @@ -177,7 +188,7 @@ async function syncAuthState(): Promise { properties: { email: user.email, uuid: user.uuid, - project_id: authState.projectId?.toString() ?? "", + project_id: authState.currentProjectId?.toString() ?? "", region: authState.cloudRegion ?? "", }, }); @@ -202,9 +213,9 @@ export const useAuthStore = create((set) => ({ isAuthenticated: false, client: null, - projectId: null, - availableProjectIds: [], - availableOrgIds: [], + orgProjectsMap: {}, + currentOrgId: null, + currentProjectId: null, needsProjectSelection: false, needsScopeReauth: false, hasCodeAccess: null, @@ -222,7 +233,7 @@ export const useAuthStore = create((set) => ({ const result = await trpcClient.auth.login.mutate({ region }); await syncAuthState(); track(ANALYTICS_EVENTS.USER_LOGGED_IN, { - project_id: result.state.projectId?.toString() ?? "", + project_id: result.state.currentProjectId?.toString() ?? "", region, }); }, @@ -231,7 +242,7 @@ export const useAuthStore = create((set) => ({ const result = await trpcClient.auth.signup.mutate({ region }); await syncAuthState(); track(ANALYTICS_EVENTS.USER_LOGGED_IN, { - project_id: result.state.projectId?.toString() ?? "", + project_id: result.state.currentProjectId?.toString() ?? "", region, }); }, @@ -243,6 +254,12 @@ export const useAuthStore = create((set) => ({ useNavigationStore.getState().navigateToTaskInput(); }, + switchOrg: async (orgId: string) => { + sessionResetCallback?.(); + await trpcClient.auth.switchOrg.mutate({ orgId }); + await syncAuthState(); + }, + logout: async () => { track(ANALYTICS_EVENTS.USER_LOGGED_OUT); sessionResetCallback?.(); @@ -255,9 +272,9 @@ export const useAuthStore = create((set) => ({ staleCloudRegion: state.cloudRegion ?? null, isAuthenticated: false, client: null, - projectId: null, - availableProjectIds: [], - availableOrgIds: [], + orgProjectsMap: {}, + currentOrgId: null, + currentProjectId: null, needsProjectSelection: false, needsScopeReauth: false, hasCodeAccess: null, diff --git a/apps/code/src/renderer/features/code-editor/components/EnrichmentPopover.tsx b/apps/code/src/renderer/features/code-editor/components/EnrichmentPopover.tsx index b837f4e49c..09bb2a756b 100644 --- a/apps/code/src/renderer/features/code-editor/components/EnrichmentPopover.tsx +++ b/apps/code/src/renderer/features/code-editor/components/EnrichmentPopover.tsx @@ -248,7 +248,7 @@ export function EnrichmentPopover() { const entry = useEnrichmentPopoverStore((s) => s.entry); const anchorRect = useEnrichmentPopoverStore((s) => s.anchorRect); const close = useEnrichmentPopoverStore((s) => s.close); - const projectId = useAuthStateValue((s) => s.projectId); + const projectId = useAuthStateValue((s) => s.currentProjectId); const cloudRegion = useAuthStateValue((s) => s.cloudRegion); const ref = useRef(null); diff --git a/apps/code/src/renderer/features/inbox/components/DataSourceSetup.tsx b/apps/code/src/renderer/features/inbox/components/DataSourceSetup.tsx index 406781f68b..95b3537021 100644 --- a/apps/code/src/renderer/features/inbox/components/DataSourceSetup.tsx +++ b/apps/code/src/renderer/features/inbox/components/DataSourceSetup.tsx @@ -63,7 +63,7 @@ interface SetupFormProps { } function GitHubSetup({ onComplete, onCancel }: SetupFormProps) { - const projectId = useAuthStateValue((state) => state.projectId); + const projectId = useAuthStateValue((state) => state.currentProjectId); const client = useAuthenticatedClient(); const { repositories, @@ -249,7 +249,7 @@ const POLL_TIMEOUT_MS = 300_000; // 5 minutes function LinearSetup({ onComplete }: SetupFormProps) { const cloudRegion = useAuthStateValue((state) => state.cloudRegion); - const projectId = useAuthStateValue((state) => state.projectId); + const projectId = useAuthStateValue((state) => state.currentProjectId); const client = useAuthenticatedClient(); const [loading, setLoading] = useState(false); const [oauthConnected, setOauthConnected] = useState(false); @@ -376,7 +376,7 @@ function LinearSetup({ onComplete }: SetupFormProps) { } function ZendeskSetup({ onComplete, onCancel }: SetupFormProps) { - const projectId = useAuthStateValue((state) => state.projectId); + const projectId = useAuthStateValue((state) => state.currentProjectId); const client = useAuthenticatedClient(); const [subdomain, setSubdomain] = useState(""); const [apiKey, setApiKey] = useState(""); @@ -453,7 +453,7 @@ function ZendeskSetup({ onComplete, onCancel }: SetupFormProps) { } function PgAnalyzeSetup({ onComplete, onCancel }: SetupFormProps) { - const projectId = useAuthStateValue((state) => state.projectId); + const projectId = useAuthStateValue((state) => state.currentProjectId); const client = useAuthenticatedClient(); const [apiKey, setApiKey] = useState(""); const [organizationSlug, setOrganizationSlug] = useState(""); diff --git a/apps/code/src/renderer/features/inbox/components/detail/SignalCard.tsx b/apps/code/src/renderer/features/inbox/components/detail/SignalCard.tsx index 47fca1fc6b..8c7cf612ec 100644 --- a/apps/code/src/renderer/features/inbox/components/detail/SignalCard.tsx +++ b/apps/code/src/renderer/features/inbox/components/detail/SignalCard.tsx @@ -616,7 +616,7 @@ function SessionRecordingVideo({ exportedAssetId?: number; sessionId: string; }) { - const projectId = useAuthStateValue((state) => state.projectId); + const projectId = useAuthStateValue((state) => state.currentProjectId); const videoRef = useRef(null); const hasFiredPlayRef = useRef(false); const interaction = useSignalInteraction(); @@ -680,7 +680,7 @@ function ErrorTrackingSignalCard({ codePaths?: string[]; dataQueried?: string; }) { - const projectId = useAuthStateValue((s) => s.projectId); + const projectId = useAuthStateValue((s) => s.currentProjectId); const cloudRegion = useAuthStateValue((s) => s.cloudRegion); const issueUrl = signal.source_id ? errorTrackingIssueUrl(signal.source_id, { projectId, cloudRegion }) diff --git a/apps/code/src/renderer/features/inbox/components/list/GitHubConnectionBanner.tsx b/apps/code/src/renderer/features/inbox/components/list/GitHubConnectionBanner.tsx index 4db9a02da7..010f8467a3 100644 --- a/apps/code/src/renderer/features/inbox/components/list/GitHubConnectionBanner.tsx +++ b/apps/code/src/renderer/features/inbox/components/list/GitHubConnectionBanner.tsx @@ -26,7 +26,7 @@ export function GitHubConnectionBanner() { useUserRepositoryIntegration(); const { hasGithubIntegration: hasTeamGithubIntegration } = useRepositoryIntegration(); - const projectId = useAuthStateValue((s) => s.projectId); + const projectId = useAuthStateValue((s) => s.currentProjectId); const cloudRegion = useAuthStateValue((s) => s.cloudRegion); const { diff --git a/apps/code/src/renderer/features/inbox/hooks/useEvaluations.ts b/apps/code/src/renderer/features/inbox/hooks/useEvaluations.ts index dcd207e935..8b2ca28f11 100644 --- a/apps/code/src/renderer/features/inbox/hooks/useEvaluations.ts +++ b/apps/code/src/renderer/features/inbox/hooks/useEvaluations.ts @@ -5,7 +5,7 @@ import type { Evaluation } from "@renderer/api/posthogClient"; const POLL_INTERVAL_MS = 5_000; export function useEvaluations() { - const projectId = useAuthStore((s) => s.projectId); + const projectId = useAuthStore((s) => s.currentProjectId); return useAuthenticatedQuery( ["evaluations", projectId], (client) => diff --git a/apps/code/src/renderer/features/inbox/hooks/useExternalDataSources.ts b/apps/code/src/renderer/features/inbox/hooks/useExternalDataSources.ts index 55fe227f2d..467c9795ec 100644 --- a/apps/code/src/renderer/features/inbox/hooks/useExternalDataSources.ts +++ b/apps/code/src/renderer/features/inbox/hooks/useExternalDataSources.ts @@ -3,7 +3,7 @@ import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; import type { ExternalDataSource } from "@renderer/api/posthogClient"; export function useExternalDataSources() { - const projectId = useAuthStateValue((state) => state.projectId); + const projectId = useAuthStateValue((state) => state.currentProjectId); return useAuthenticatedQuery( ["external-data-sources", projectId], (client) => diff --git a/apps/code/src/renderer/features/inbox/hooks/useSignalSourceConfigs.ts b/apps/code/src/renderer/features/inbox/hooks/useSignalSourceConfigs.ts index 6cc50445bc..ed848103c4 100644 --- a/apps/code/src/renderer/features/inbox/hooks/useSignalSourceConfigs.ts +++ b/apps/code/src/renderer/features/inbox/hooks/useSignalSourceConfigs.ts @@ -3,7 +3,7 @@ import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; import type { SignalSourceConfig } from "@renderer/api/posthogClient"; export function useSignalSourceConfigs() { - const projectId = useAuthStateValue((state) => state.projectId); + const projectId = useAuthStateValue((state) => state.currentProjectId); return useAuthenticatedQuery( ["signals", "source-configs", projectId], (client) => diff --git a/apps/code/src/renderer/features/inbox/hooks/useSignalSourceManager.ts b/apps/code/src/renderer/features/inbox/hooks/useSignalSourceManager.ts index 7d9f0a642c..15f5094756 100644 --- a/apps/code/src/renderer/features/inbox/hooks/useSignalSourceManager.ts +++ b/apps/code/src/renderer/features/inbox/hooks/useSignalSourceManager.ts @@ -103,7 +103,7 @@ function computeValues( } export function useSignalSourceManager() { - const projectId = useAuthStateValue((state) => state.projectId); + const projectId = useAuthStateValue((state) => state.currentProjectId); const cloudRegion = useAuthStateValue((state) => state.cloudRegion); const client = useAuthenticatedClient(); const queryClient = useQueryClient(); diff --git a/apps/code/src/renderer/features/integrations/hooks/useSlackConnect.ts b/apps/code/src/renderer/features/integrations/hooks/useSlackConnect.ts index 1f0f920d7c..5c83f13486 100644 --- a/apps/code/src/renderer/features/integrations/hooks/useSlackConnect.ts +++ b/apps/code/src/renderer/features/integrations/hooks/useSlackConnect.ts @@ -39,7 +39,7 @@ function invalidateIntegrationQueries(queryClient: QueryClient): void { export function useSlackConnect(): Result { const queryClient = useQueryClient(); const cloudRegion = useAuthStateValue((s) => s.cloudRegion); - const projectId = useAuthStateValue((s) => s.projectId); + const projectId = useAuthStateValue((s) => s.currentProjectId); const [state, setState] = useState("idle"); const [error, setError] = useState(null); diff --git a/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx b/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx index 3fd3b060c1..40875c8c33 100644 --- a/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx +++ b/apps/code/src/renderer/features/onboarding/components/GitIntegrationStep.tsx @@ -82,7 +82,7 @@ export function GitIntegrationStep({ isDetectingRepo, onDirectoryChange, }: GitIntegrationStepProps) { - const currentProjectId = useAuthStateValue((state) => state.projectId); + const currentProjectId = useAuthStateValue((state) => state.currentProjectId); const selectProjectMutation = useSelectProjectMutation(); const queryClient = useQueryClient(); diff --git a/apps/code/src/renderer/features/onboarding/components/ProjectSelectStep.tsx b/apps/code/src/renderer/features/onboarding/components/ProjectSelectStep.tsx index 64b3f8e81a..afdf646620 100644 --- a/apps/code/src/renderer/features/onboarding/components/ProjectSelectStep.tsx +++ b/apps/code/src/renderer/features/onboarding/components/ProjectSelectStep.tsx @@ -62,8 +62,8 @@ export function ProjectSelectStep({ onNext, onBack }: ProjectSelectStepProps) { const isAuthenticated = useAuthStateValue((state) => state.status) === "authenticated"; const selectProjectMutation = useSelectProjectMutation(); - const currentProjectId = useAuthStateValue((state) => state.projectId); - const { projects, currentProject, currentUser, isLoading } = useProjects(); + const currentProjectId = useAuthStateValue((state) => state.currentProjectId); + const { projects, currentProject } = useProjects(); const [projectOpen, setProjectOpen] = useState(false); const [orgOpen, setOrgOpen] = useState(false); const [isSwitchingOrg, setIsSwitchingOrg] = useState(false); @@ -72,7 +72,11 @@ export function ProjectSelectStep({ onNext, onBack }: ProjectSelectStepProps) { const client = useOptionalAuthenticatedClient(); const queryClient = useQueryClient(); - const { data: fullUser } = useCurrentUser({ client }); + const { data: fullUser, isLoading: isUserLoading } = useCurrentUser({ + client, + }); + const currentUser = fullUser; + const isLoading = isUserLoading; const billingEnabled = useFeatureFlag(BILLING_FLAG); const organizations = useMemo(() => { diff --git a/apps/code/src/renderer/features/onboarding/hooks/useProjectsWithIntegrations.ts b/apps/code/src/renderer/features/onboarding/hooks/useProjectsWithIntegrations.ts index c6da7b49ec..f57f2e60ba 100644 --- a/apps/code/src/renderer/features/onboarding/hooks/useProjectsWithIntegrations.ts +++ b/apps/code/src/renderer/features/onboarding/hooks/useProjectsWithIntegrations.ts @@ -1,5 +1,8 @@ import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; -import { AUTH_SCOPED_QUERY_META } from "@features/auth/hooks/authQueries"; +import { + AUTH_SCOPED_QUERY_META, + useAuthStateFetched, +} from "@features/auth/hooks/authQueries"; import type { Integration } from "@features/integrations/stores/integrationStore"; import { useProjects } from "@features/projects/hooks/useProjects"; import { useQueries } from "@tanstack/react-query"; @@ -14,7 +17,8 @@ export interface ProjectWithIntegrations { } export function useProjectsWithIntegrations() { - const { projects, isLoading: projectsLoading } = useProjects(); + const { projects } = useProjects(); + const projectsLoading = !useAuthStateFetched(); const client = useOptionalAuthenticatedClient(); // Fetch integrations for each project in parallel diff --git a/apps/code/src/renderer/features/projects/hooks/useProjects.tsx b/apps/code/src/renderer/features/projects/hooks/useProjects.tsx index 0ac73003f8..3820fba315 100644 --- a/apps/code/src/renderer/features/projects/hooks/useProjects.tsx +++ b/apps/code/src/renderer/features/projects/hooks/useProjects.tsx @@ -1,9 +1,5 @@ -import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; import { useSelectProjectMutation } from "@features/auth/hooks/authMutations"; -import { - useAuthStateValue, - useCurrentUser, -} from "@features/auth/hooks/authQueries"; +import { useAuthStateValue } from "@features/auth/hooks/authQueries"; import { logger } from "@utils/logger"; import { useEffect, useMemo } from "react"; @@ -21,96 +17,57 @@ export interface GroupedProjects { projects: ProjectInfo[]; } -export function groupProjectsByOrg(projects: ProjectInfo[]): GroupedProjects[] { - const orgMap = new Map(); +type OrgProjectsMap = Record< + string, + { orgName: string; projects: { id: number; name: string }[] } +>; - for (const project of projects) { - const orgId = project.organization.id; - if (!orgMap.has(orgId)) { - orgMap.set(orgId, { - orgId, - orgName: project.organization.name, - projects: [], - }); - } - orgMap.get(orgId)?.projects.push(project); - } - - return Array.from(orgMap.values()); +export function groupProjectsByOrg(map: OrgProjectsMap): GroupedProjects[] { + return Object.entries(map).map(([orgId, org]) => ({ + orgId, + orgName: org.orgName, + projects: org.projects.map((p) => ({ + id: p.id, + name: p.name, + organization: { id: orgId, name: org.orgName }, + })), + })); } export function useProjects() { - const availableProjectIds = useAuthStateValue( - (state) => state.availableProjectIds, - ); - const currentProjectId = useAuthStateValue((state) => state.projectId); - const client = useOptionalAuthenticatedClient(); - const { - data: currentUser, - isLoading: isQueryLoading, - error, - } = useCurrentUser({ client }); - const isInitialLoading = isQueryLoading && !currentUser; - - const projects = useMemo(() => { - if (!currentUser?.organization) return []; + const orgProjectsMap = useAuthStateValue((state) => state.orgProjectsMap); + const currentProjectId = useAuthStateValue((state) => state.currentProjectId); - const rawTeams = Array.isArray(currentUser.organization.teams) - ? currentUser.organization.teams - : []; - const teams = rawTeams - .filter( - (t): t is { id: number | string; name?: string } => - t != null && - typeof t === "object" && - (typeof t.id === "number" || typeof t.id === "string"), - ) - .map((t) => ({ ...t, id: Number(t.id) })) - .filter((t) => !Number.isNaN(t.id)); - const orgName = currentUser.organization.name ?? "Unknown Organization"; - const orgId = currentUser.organization.id ?? ""; - - const teamMap = new Map(teams.map((t) => [t.id, t])); - - return availableProjectIds - .map((id) => { - const team = teamMap.get(id); - if (!team) return null; - return { - id, - name: team.name ?? `Project ${id}`, - organization: { id: orgId, name: orgName }, - }; - }) - .filter((p): p is ProjectInfo => p !== null); - }, [currentUser, availableProjectIds]); + const projects = useMemo(() => { + return Object.entries(orgProjectsMap).flatMap(([orgId, org]) => + org.projects.map((p) => ({ + id: p.id, + name: p.name, + organization: { id: orgId, name: org.orgName }, + })), + ); + }, [orgProjectsMap]); const { mutate: selectProject, isPending: isSelectingProject } = useSelectProjectMutation(); const currentProject = projects.find((p) => p.id === currentProjectId); - const groupedProjects = groupProjectsByOrg(projects); - - const userTeamId = - currentUser?.team && typeof currentUser.team === "object" - ? (currentUser.team as { id: number }).id - : null; + const groupedProjects = useMemo( + () => groupProjectsByOrg(orgProjectsMap), + [orgProjectsMap], + ); useEffect(() => { if (isSelectingProject) return; if (projects.length > 0 && !currentProject) { - const preferredProject = - (userTeamId && projects.find((p) => p.id === userTeamId)) || - projects[0]; + const preferred = projects[0]; log.info("Auto-selecting project", { - projectId: preferredProject.id, - source: - preferredProject.id === userTeamId ? "user-team" : "first-available", + projectId: preferred.id, reason: currentProjectId == null ? "no project selected" : "current project not found in list", }); - selectProject(preferredProject.id); + selectProject(preferred.id); } }, [ currentProject, @@ -118,7 +75,6 @@ export function useProjects() { projects, selectProject, isSelectingProject, - userTeamId, ]); return { @@ -126,8 +82,5 @@ export function useProjects() { groupedProjects, currentProject, currentProjectId, - currentUser: currentUser ?? null, - isLoading: isInitialLoading, - error, }; } diff --git a/apps/code/src/renderer/features/sessions/hooks/useSessionConnection.ts b/apps/code/src/renderer/features/sessions/hooks/useSessionConnection.ts index 7de8a5f2ad..fe528b95f6 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useSessionConnection.ts +++ b/apps/code/src/renderer/features/sessions/hooks/useSessionConnection.ts @@ -69,7 +69,7 @@ export function useSessionConnection({ if (!isCloud || !task.latest_run?.id) return; if (cloudAuthState.status !== "authenticated") return; if (!cloudAuthState.bootstrapComplete) return; - if (!cloudAuthState.projectId || !cloudAuthState.cloudRegion) return; + if (!cloudAuthState.currentProjectId || !cloudAuthState.cloudRegion) return; const runId = task.latest_run.id; const initialMode = @@ -83,7 +83,7 @@ export function useSessionConnection({ task.id, runId, getCloudUrlFromRegion(cloudAuthState.cloudRegion), - cloudAuthState.projectId, + cloudAuthState.currentProjectId, () => { queryClient.invalidateQueries({ queryKey: ["tasks"] }); }, @@ -97,7 +97,7 @@ export function useSessionConnection({ }, [ cloudAuthState.bootstrapComplete, cloudAuthState.cloudRegion, - cloudAuthState.projectId, + cloudAuthState.currentProjectId, cloudAuthState.status, isCloud, queryClient, diff --git a/apps/code/src/renderer/features/sessions/service/service.recovery.integration.test.ts b/apps/code/src/renderer/features/sessions/service/service.recovery.integration.test.ts index 617ad07f7a..1b9a3b065d 100644 --- a/apps/code/src/renderer/features/sessions/service/service.recovery.integration.test.ts +++ b/apps/code/src/renderer/features/sessions/service/service.recovery.integration.test.ts @@ -95,9 +95,14 @@ const mockAuth = vi.hoisted(() => ({ status: "authenticated", bootstrapComplete: true, cloudRegion: "us", - projectId: 123, - availableProjectIds: [123], - availableOrgIds: [], + orgProjectsMap: { + "org-1": { + orgName: "Org 1", + projects: [{ id: 123, name: "Project 123" }], + }, + }, + currentOrgId: "org-1", + currentProjectId: 123, hasCodeAccess: true, needsScopeReauth: false, })), @@ -314,9 +319,14 @@ describe("SessionService cloud queue recovery (real store, e2e)", () => { status: "authenticated", bootstrapComplete: true, cloudRegion: "us", - projectId: 123, - availableProjectIds: [123], - availableOrgIds: [], + orgProjectsMap: { + "org-1": { + orgName: "Org 1", + projects: [{ id: 123, name: "Project 123" }], + }, + }, + currentOrgId: "org-1", + currentProjectId: 123, hasCodeAccess: true, needsScopeReauth: false, }); diff --git a/apps/code/src/renderer/features/sessions/service/service.test.ts b/apps/code/src/renderer/features/sessions/service/service.test.ts index 1b7f411b4b..1836a6bd4a 100644 --- a/apps/code/src/renderer/features/sessions/service/service.test.ts +++ b/apps/code/src/renderer/features/sessions/service/service.test.ts @@ -146,9 +146,14 @@ const mockAuth = vi.hoisted(() => ({ status: "authenticated", bootstrapComplete: true, cloudRegion: "us", - projectId: 123, - availableProjectIds: [123], - availableOrgIds: [], + orgProjectsMap: { + "org-1": { + orgName: "Org 1", + projects: [{ id: 123, name: "Project 123" }], + }, + }, + currentOrgId: "org-1", + currentProjectId: 123, hasCodeAccess: true, needsScopeReauth: false, })), @@ -360,9 +365,14 @@ describe("SessionService", () => { status: "authenticated", bootstrapComplete: true, cloudRegion: "us", - projectId: 123, - availableProjectIds: [123], - availableOrgIds: [], + orgProjectsMap: { + "org-1": { + orgName: "Org 1", + projects: [{ id: 123, name: "Project 123" }], + }, + }, + currentOrgId: "org-1", + currentProjectId: 123, hasCodeAccess: true, needsScopeReauth: false, }); @@ -489,9 +499,14 @@ describe("SessionService", () => { status: "authenticated", bootstrapComplete: true, cloudRegion: "us", - projectId: 123, - availableProjectIds: [123], - availableOrgIds: [], + orgProjectsMap: { + "org-1": { + orgName: "Org 1", + projects: [{ id: 123, name: "Project 123" }], + }, + }, + currentOrgId: "org-1", + currentProjectId: 123, hasCodeAccess: true, needsScopeReauth: false, }); @@ -549,9 +564,9 @@ describe("SessionService", () => { status: "anonymous", bootstrapComplete: true, cloudRegion: null, - projectId: null, - availableProjectIds: [], - availableOrgIds: [], + orgProjectsMap: {}, + currentOrgId: null, + currentProjectId: null, hasCodeAccess: null, needsScopeReauth: false, }); @@ -3304,9 +3319,14 @@ describe("SessionService", () => { status: "authenticated", bootstrapComplete: true, cloudRegion: "us", - projectId: 123, - availableProjectIds: [123], - availableOrgIds: [], + orgProjectsMap: { + "org-1": { + orgName: "Org 1", + projects: [{ id: 123, name: "Project 123" }], + }, + }, + currentOrgId: "org-1", + currentProjectId: 123, hasCodeAccess: true, needsScopeReauth: false, }); diff --git a/apps/code/src/renderer/features/sessions/service/service.ts b/apps/code/src/renderer/features/sessions/service/service.ts index f7617e9fed..aeaa6cb373 100644 --- a/apps/code/src/renderer/features/sessions/service/service.ts +++ b/apps/code/src/renderer/features/sessions/service/service.ts @@ -2250,10 +2250,10 @@ export class SessionService { teamId: number; } | null> { const authState = await fetchAuthState(); - if (!authState.cloudRegion || !authState.projectId) return null; + if (!authState.cloudRegion || !authState.currentProjectId) return null; return { apiHost: getCloudUrlFromRegion(authState.cloudRegion), - teamId: authState.projectId, + teamId: authState.currentProjectId, }; } @@ -3237,13 +3237,13 @@ export class SessionService { toast.error(`Authentication required for handoff: ${message}`); return null; } - if (!auth.projectId || !auth.cloudRegion) { + if (!auth.currentProjectId || !auth.cloudRegion) { toast.error("Missing project configuration for handoff"); return null; } return { apiHost: getCloudUrlFromRegion(auth.cloudRegion), - projectId: auth.projectId, + projectId: auth.currentProjectId, }; } @@ -3645,7 +3645,7 @@ export class SessionService { const apiHost = authState.cloudRegion ? getCloudUrlFromRegion(authState.cloudRegion) : null; - const projectId = authState.projectId; + const projectId = authState.currentProjectId; const client = createAuthenticatedClient(authState); if (!apiHost || !projectId || !client) return null; diff --git a/apps/code/src/renderer/features/settings/components/sections/GeneralSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/GeneralSettings.tsx index c64480e830..41eca9a4c3 100644 --- a/apps/code/src/renderer/features/settings/components/sections/GeneralSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/GeneralSettings.tsx @@ -530,7 +530,7 @@ export function GeneralSettings() { } function HedgehogDescription() { - const projectId = useAuthStateValue((state) => state.projectId); + const projectId = useAuthStateValue((state) => state.currentProjectId); const cloudRegion = useAuthStateValue((state) => state.cloudRegion); const customizeUrl = projectId diff --git a/apps/code/src/renderer/features/settings/components/sections/GitHubIntegrationSection.tsx b/apps/code/src/renderer/features/settings/components/sections/GitHubIntegrationSection.tsx index 4786b80373..4af598a96a 100644 --- a/apps/code/src/renderer/features/settings/components/sections/GitHubIntegrationSection.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/GitHubIntegrationSection.tsx @@ -45,7 +45,7 @@ export function GitHubIntegrationSection({ : null, [repositories], ); - const projectId = useAuthStateValue((state) => state.projectId); + const projectId = useAuthStateValue((state) => state.currentProjectId); const { error: connectError, isConnecting: connecting, diff --git a/apps/code/src/renderer/features/settings/components/sections/GitHubSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/GitHubSettings.tsx index 0bf77e2605..28f3f43cb9 100644 --- a/apps/code/src/renderer/features/settings/components/sections/GitHubSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/GitHubSettings.tsx @@ -52,7 +52,7 @@ function githubInstallationSettingsUrl(integration: UserGitHubIntegration) { } export function GitHubSettings() { - const projectId = useAuthStateValue((s) => s.projectId); + const projectId = useAuthStateValue((s) => s.currentProjectId); const cloudRegion = useAuthStateValue((s) => s.cloudRegion); const { data: integrations = [], isLoading } = useUserGithubIntegrations(); const { reposByInstallationId, failedInstallationIds, isLoadingRepos } = @@ -149,7 +149,7 @@ function GitHubIntegrationRow({ isLoadingRepos, }: GitHubIntegrationRowProps) { const apiClient = useOptionalAuthenticatedClient(); - const projectId = useAuthStateValue((s) => s.projectId); + const projectId = useAuthStateValue((s) => s.currentProjectId); const queryClient = useQueryClient(); const [confirmOpen, setConfirmOpen] = useState(false); const [expanded, setExpanded] = useState(false); diff --git a/apps/code/src/renderer/features/settings/components/sections/SlackSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/SlackSettings.tsx index 658595ccf9..1024da3138 100644 --- a/apps/code/src/renderer/features/settings/components/sections/SlackSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/SlackSettings.tsx @@ -11,7 +11,7 @@ import { openUrlInBrowser } from "@utils/browser"; import { getPostHogUrl } from "@utils/urls"; export function SlackSettings() { - const projectId = useAuthStateValue((s) => s.projectId); + const projectId = useAuthStateValue((s) => s.currentProjectId); const cloudRegion = useAuthStateValue((s) => s.cloudRegion); const { isLoading } = useIntegrations(); const { slackIntegrations, hasSlackIntegration } = useIntegrationSelectors(); diff --git a/apps/code/src/renderer/features/setup/services/setupRunService.ts b/apps/code/src/renderer/features/setup/services/setupRunService.ts index 71f63f5766..7ef26c14a8 100644 --- a/apps/code/src/renderer/features/setup/services/setupRunService.ts +++ b/apps/code/src/renderer/features/setup/services/setupRunService.ts @@ -329,7 +329,7 @@ export class SetupRunService { const apiHost = authState.cloudRegion ? getCloudUrlFromRegion(authState.cloudRegion) : null; - const projectId = authState.projectId; + const projectId = authState.currentProjectId; if (!apiHost || !projectId) { log.error("Missing auth for discovery", { apiHost, projectId }); diff --git a/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx b/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx index 7aaa897d27..08d182b228 100644 --- a/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx +++ b/apps/code/src/renderer/features/sidebar/components/ProjectSwitcher.tsx @@ -1,8 +1,12 @@ +import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; import { useLogoutMutation, useSelectProjectMutation, } from "@features/auth/hooks/authMutations"; -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; +import { + useAuthStateValue, + useCurrentUser, +} from "@features/auth/hooks/authQueries"; import { CommandKeyHints } from "@features/command/components/CommandKeyHints"; import { useProjects } from "@features/projects/hooks/useProjects"; import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; @@ -61,10 +65,11 @@ export function ProjectSwitcher() { const [dialogOpen, setDialogOpen] = useState(false); const cloudRegion = useAuthStateValue((state) => state.cloudRegion); + const client = useOptionalAuthenticatedClient(); + const { data: currentUser } = useCurrentUser({ client }); const selectProjectMutation = useSelectProjectMutation(); const logoutMutation = useLogoutMutation(); - const { groupedProjects, currentProject, currentProjectId, currentUser } = - useProjects(); + const { groupedProjects, currentProject, currentProjectId } = useProjects(); const handleProjectSelect = (projectId: number) => { if (projectId !== currentProjectId) { diff --git a/apps/code/src/renderer/features/task-detail/components/CloudGithubMissingNotice.tsx b/apps/code/src/renderer/features/task-detail/components/CloudGithubMissingNotice.tsx index 2e8b37bdad..b97fbcd54b 100644 --- a/apps/code/src/renderer/features/task-detail/components/CloudGithubMissingNotice.tsx +++ b/apps/code/src/renderer/features/task-detail/components/CloudGithubMissingNotice.tsx @@ -8,7 +8,7 @@ import { ArrowSquareOutIcon, InfoIcon } from "@phosphor-icons/react"; import { Button, Callout, Flex, Spinner, Text } from "@radix-ui/themes"; export function CloudGithubMissingNotice() { - const projectId = useAuthStateValue((s) => s.projectId); + const projectId = useAuthStateValue((s) => s.currentProjectId); const cloudRegion = useAuthStateValue((s) => s.cloudRegion); const { hasGithubIntegration: hasTeamGithubIntegration } = useRepositoryIntegration(); diff --git a/apps/code/src/renderer/hooks/useProjectQuery.ts b/apps/code/src/renderer/hooks/useProjectQuery.ts index a0137df388..aa845ca9d2 100644 --- a/apps/code/src/renderer/hooks/useProjectQuery.ts +++ b/apps/code/src/renderer/hooks/useProjectQuery.ts @@ -2,7 +2,7 @@ import { useAuthStateValue } from "@features/auth/hooks/authQueries"; import { useAuthenticatedQuery } from "./useAuthenticatedQuery"; export function useProjectQuery() { - const projectId = useAuthStateValue((state) => state.projectId); + const projectId = useAuthStateValue((state) => state.currentProjectId); return useAuthenticatedQuery( ["project", projectId], diff --git a/apps/code/src/renderer/utils/posthogLinks.ts b/apps/code/src/renderer/utils/posthogLinks.ts index 5512b0ea4c..0d274a9ae6 100644 --- a/apps/code/src/renderer/utils/posthogLinks.ts +++ b/apps/code/src/renderer/utils/posthogLinks.ts @@ -9,7 +9,7 @@ export interface LinkOverrides { function resolveProjectId(override?: number | null): number | null { if (override != null) return override; - return getCachedAuthState().projectId ?? null; + return getCachedAuthState().currentProjectId ?? null; } function withProjectId( diff --git a/apps/code/src/shared/constants/oauth.test.ts b/apps/code/src/shared/constants/oauth.test.ts index 4aac1ce9f2..0c94e061d4 100644 --- a/apps/code/src/shared/constants/oauth.test.ts +++ b/apps/code/src/shared/constants/oauth.test.ts @@ -8,7 +8,7 @@ describe("OAUTH_SCOPES guard", () => { scopes: OAUTH_SCOPES, }).toMatchInlineSnapshot(` { - "scopeVersion": 4, + "scopeVersion": 5, "scopes": [ "*", ], diff --git a/apps/code/src/shared/constants/oauth.ts b/apps/code/src/shared/constants/oauth.ts index f59ce0cca2..076734aee2 100644 --- a/apps/code/src/shared/constants/oauth.ts +++ b/apps/code/src/shared/constants/oauth.ts @@ -7,7 +7,7 @@ export const POSTHOG_DEV_CLIENT_ID = "DC5uRLVbGI02YQ82grxgnK6Qn12SXWpCqdPb60oZ"; // Bump OAUTH_SCOPE_VERSION below whenever OAUTH_SCOPES changes to force re-authentication export const OAUTH_SCOPES = ["*"]; -export const OAUTH_SCOPE_VERSION = 4; +export const OAUTH_SCOPE_VERSION = 5; // Token refresh settings export const TOKEN_REFRESH_BUFFER_MS = 30 * 60 * 1000; // 30 minutes before expiry From 2e6ef99a2f2ab4c07019868bff627b58734597cc Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Mon, 25 May 2026 16:23:37 -0700 Subject: [PATCH 2/4] fix selectProject orgId drift and route onboarding through trpc --- apps/code/src/main/services/auth/service.ts | 7 +++++- .../features/auth/stores/authStore.test.ts | 2 ++ .../features/auth/stores/authStore.ts | 2 ++ .../components/ProjectSelectStep.tsx | 22 ++++++++----------- 4 files changed, 19 insertions(+), 14 deletions(-) diff --git a/apps/code/src/main/services/auth/service.ts b/apps/code/src/main/services/auth/service.ts index f90461e345..dcad934bdc 100644 --- a/apps/code/src/main/services/auth/service.ts +++ b/apps/code/src/main/services/auth/service.ts @@ -242,9 +242,14 @@ export class AuthService extends TypedEventEmitter { throw new Error("Invalid project selection"); } + const newOrgId = + findOrgForProject(session.orgProjectsMap, projectId) ?? + session.currentOrgId; + this.session = { ...session, currentProjectId: projectId, + currentOrgId: newOrgId, }; this.persistProjectPreference(this.session); @@ -254,7 +259,7 @@ export class AuthService extends TypedEventEmitter { selectedProjectId: projectId, }); - this.updateState({ currentProjectId: projectId }); + this.updateState({ currentProjectId: projectId, currentOrgId: newOrgId }); return this.getState(); } async switchOrg(orgId: string): Promise { diff --git a/apps/code/src/renderer/features/auth/stores/authStore.test.ts b/apps/code/src/renderer/features/auth/stores/authStore.test.ts index 50e1725ab3..b014b6d0fe 100644 --- a/apps/code/src/renderer/features/auth/stores/authStore.test.ts +++ b/apps/code/src/renderer/features/auth/stores/authStore.test.ts @@ -6,6 +6,7 @@ const mockRefreshAccessToken = vi.hoisted(() => ({ mutate: vi.fn() })); const mockLogin = vi.hoisted(() => ({ mutate: vi.fn() })); const mockSignup = vi.hoisted(() => ({ mutate: vi.fn() })); const mockSelectProject = vi.hoisted(() => ({ mutate: vi.fn() })); +const mockSwitchOrg = vi.hoisted(() => ({ mutate: vi.fn() })); const mockRedeemInviteCode = vi.hoisted(() => ({ mutate: vi.fn() })); const mockLogout = vi.hoisted(() => ({ mutate: vi.fn() })); const mockGetCurrentUser = vi.fn(); @@ -19,6 +20,7 @@ vi.mock("@renderer/trpc/client", () => ({ login: mockLogin, signup: mockSignup, selectProject: mockSelectProject, + switchOrg: mockSwitchOrg, redeemInviteCode: mockRedeemInviteCode, logout: mockLogout, }, diff --git a/apps/code/src/renderer/features/auth/stores/authStore.ts b/apps/code/src/renderer/features/auth/stores/authStore.ts index 8bc7b1814d..39bcde1982 100644 --- a/apps/code/src/renderer/features/auth/stores/authStore.ts +++ b/apps/code/src/renderer/features/auth/stores/authStore.ts @@ -154,6 +154,7 @@ async function syncAuthState(): Promise { const authSyncKey = JSON.stringify({ status: authState.status, cloudRegion: authState.cloudRegion, + currentOrgId: authState.currentOrgId, currentProjectId: authState.currentProjectId, }); @@ -258,6 +259,7 @@ export const useAuthStore = create((set) => ({ sessionResetCallback?.(); await trpcClient.auth.switchOrg.mutate({ orgId }); await syncAuthState(); + useNavigationStore.getState().navigateToTaskInput(); }, logout: async () => { diff --git a/apps/code/src/renderer/features/onboarding/components/ProjectSelectStep.tsx b/apps/code/src/renderer/features/onboarding/components/ProjectSelectStep.tsx index afdf646620..a2075dd89c 100644 --- a/apps/code/src/renderer/features/onboarding/components/ProjectSelectStep.tsx +++ b/apps/code/src/renderer/features/onboarding/components/ProjectSelectStep.tsx @@ -1,8 +1,10 @@ import { SignInCard } from "@features/auth/components/SignInCard"; import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; -import { useSelectProjectMutation } from "@features/auth/hooks/authMutations"; import { - authKeys, + useSelectProjectMutation, + useSwitchOrgMutation, +} from "@features/auth/hooks/authMutations"; +import { useAuthStateFetched, useAuthStateValue, useCurrentUser, @@ -36,7 +38,7 @@ import { } from "@renderer/styles/fieldTrigger"; import { BILLING_FLAG } from "@shared/constants"; import { ANALYTICS_EVENTS } from "@shared/types/analytics"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation } from "@tanstack/react-query"; import { track } from "@utils/analytics"; import { logger } from "@utils/logger"; import { AnimatePresence, motion } from "framer-motion"; @@ -71,13 +73,11 @@ export function ProjectSelectStep({ onNext, onBack }: ProjectSelectStepProps) { const projectAnchorRef = useRef(null); const client = useOptionalAuthenticatedClient(); - const queryClient = useQueryClient(); - const { data: fullUser, isLoading: isUserLoading } = useCurrentUser({ + const { data: fullUser, isLoading } = useCurrentUser({ client, }); - const currentUser = fullUser; - const isLoading = isUserLoading; const billingEnabled = useFeatureFlag(BILLING_FLAG); + const switchOrgTrpcMutation = useSwitchOrgMutation(); const organizations = useMemo(() => { if (!fullUser?.organizations) return []; @@ -109,11 +109,7 @@ export function ProjectSelectStep({ onNext, onBack }: ProjectSelectStepProps) { const switchOrgMutation = useMutation({ mutationFn: async (orgId: string) => { - if (!client) return; - await client.switchOrganization(orgId); - await queryClient.invalidateQueries({ - queryKey: authKeys.currentUsers(), - }); + await switchOrgTrpcMutation.mutateAsync(orgId); if (billingEnabled) { void useSeatStore.getState().fetchSeat({ autoProvision: true }); } @@ -374,7 +370,7 @@ export function ProjectSelectStep({ onNext, onBack }: ProjectSelectStepProps) { className="text-(--green-9)" /> - Signed in as {currentUser?.email} + Signed in as {fullUser?.email} )} From 1382e6f79c91bf322376695fb2006623e027aa53 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Mon, 25 May 2026 18:47:30 -0700 Subject: [PATCH 3/4] address greptile review on auth org map --- apps/code/src/main/services/auth/service.ts | 153 ++++++++++++------ .../features/projects/hooks/useProjects.tsx | 13 +- 2 files changed, 117 insertions(+), 49 deletions(-) diff --git a/apps/code/src/main/services/auth/service.ts b/apps/code/src/main/services/auth/service.ts index dcad934bdc..1ea72c631d 100644 --- a/apps/code/src/main/services/auth/service.ts +++ b/apps/code/src/main/services/auth/service.ts @@ -55,7 +55,14 @@ function flattenProjectIds(map: OrgProjectsMap): number[] { function findOrgForProject( map: OrgProjectsMap, projectId: number, + preferredOrgId: string | null, ): string | null { + if ( + preferredOrgId && + map[preferredOrgId]?.projects.some((p) => p.id === projectId) + ) { + return preferredOrgId; + } for (const [orgId, org] of Object.entries(map)) { if (org.projects.some((p) => p.id === projectId)) { return orgId; @@ -243,8 +250,15 @@ export class AuthService extends TypedEventEmitter { } const newOrgId = - findOrgForProject(session.orgProjectsMap, projectId) ?? - session.currentOrgId; + findOrgForProject( + session.orgProjectsMap, + projectId, + session.currentOrgId, + ) ?? session.currentOrgId; + + if (newOrgId && newOrgId !== session.currentOrgId) { + await this.patchCurrentOrganization(newOrgId); + } this.session = { ...session, @@ -271,20 +285,22 @@ export class AuthService extends TypedEventEmitter { throw new Error("Invalid organization"); } - const apiHost = getCloudUrlFromRegion(session.cloudRegion); - const response = await this.authenticatedFetch( - fetch, - `${apiHost}/api/users/@me/`, - { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ set_current_organization: orgId }), - }, - ); + await this.patchCurrentOrganization(orgId); - if (!response.ok) { - throw new Error(`Failed to switch organization: ${response.statusText}`); - } + const refreshedProjects = await this.fetchOrgProjects( + session.accessToken, + session.cloudRegion, + orgId, + ); + const orgProjectsMap: OrgProjectsMap = refreshedProjects + ? { + ...session.orgProjectsMap, + [orgId]: { + orgName: session.orgProjectsMap[orgId]?.orgName ?? "(unknown)", + projects: refreshedProjects, + }, + } + : session.orgProjectsMap; const preferredProjectId = session.accountKey ? (this.authPreferenceRepository.getOrgProject( @@ -293,7 +309,7 @@ export class AuthService extends TypedEventEmitter { orgId, )?.lastSelectedProjectId ?? null) : null; - const orgProjects = session.orgProjectsMap[orgId]?.projects ?? []; + const orgProjects = orgProjectsMap[orgId]?.projects ?? []; const currentProjectId = preferredProjectId && orgProjects.some((p) => p.id === preferredProjectId) ? preferredProjectId @@ -301,6 +317,7 @@ export class AuthService extends TypedEventEmitter { this.session = { ...session, + orgProjectsMap, currentOrgId: orgId, currentProjectId, }; @@ -312,9 +329,29 @@ export class AuthService extends TypedEventEmitter { selectedProjectId: currentProjectId, }); - this.updateState({ currentOrgId: orgId, currentProjectId }); + this.updateState({ + orgProjectsMap, + currentOrgId: orgId, + currentProjectId, + }); return this.getState(); } + private async patchCurrentOrganization(orgId: string): Promise { + const { apiHost } = await this.getValidAccessToken(); + const response = await this.authenticatedFetch( + fetch, + `${apiHost}/api/users/@me/`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ set_current_organization: orgId }), + }, + ); + + if (!response.ok) { + throw new Error(`Failed to switch organization: ${response.statusText}`); + } + } async logout(): Promise { const { cloudRegion, currentProjectId } = this.state; @@ -489,16 +526,26 @@ export class AuthService extends TypedEventEmitter { scopedOrgIds, ); const allProjectIds = flattenProjectIds(orgProjectsMap); + const lastPrefs = accountKey + ? this.authPreferenceRepository.get(accountKey, options.cloudRegion) + : null; const preferredProjectId = - options.selectedProjectId ?? - (accountKey - ? (this.authPreferenceRepository.get(accountKey, options.cloudRegion) - ?.lastSelectedProjectId ?? null) - : null); + options.selectedProjectId ?? lastPrefs?.lastSelectedProjectId ?? null; + const projectsInCurrentOrg = currentOrgId + ? (orgProjectsMap[currentOrgId]?.projects ?? []) + : []; + const projectsInLastOrg = + lastPrefs?.lastSelectedOrgId && + orgProjectsMap[lastPrefs.lastSelectedOrgId] + ? orgProjectsMap[lastPrefs.lastSelectedOrgId].projects + : []; const currentProjectId = preferredProjectId && allProjectIds.includes(preferredProjectId) ? preferredProjectId - : (allProjectIds[0] ?? null); + : (projectsInCurrentOrg[0]?.id ?? + projectsInLastOrg[0]?.id ?? + allProjectIds[0] ?? + null); const session: InMemorySession = { accountKey, @@ -522,42 +569,23 @@ export class AuthService extends TypedEventEmitter { const entries = await Promise.all( orgIds.map(async (orgId): Promise<[string, OrgProjects]> => { try { - const [orgRes, projectsRes] = await Promise.all([ + const [orgRes, projects] = await Promise.all([ fetch(`${apiHost}/api/organizations/${orgId}/`, { headers: { Authorization: `Bearer ${accessToken}` }, }), - fetch(`${apiHost}/api/organizations/${orgId}/projects/`, { - headers: { Authorization: `Bearer ${accessToken}` }, - }), + this.fetchOrgProjects(accessToken, cloudRegion, orgId), ]); const orgData = orgRes.ok ? ((await orgRes.json().catch(() => ({}))) as { name?: unknown }) : null; - const projectsRaw = projectsRes.ok - ? ((await projectsRes.json().catch(() => null)) as unknown) - : null; - const projectsArray = Array.isArray(projectsRaw) - ? projectsRaw - : Array.isArray( - (projectsRaw as { results?: unknown } | null)?.results, - ) - ? ((projectsRaw as { results: unknown[] }).results as unknown[]) - : []; - - const projects = projectsArray - .map((p) => p as { id?: unknown; name?: unknown }) - .filter( - (p) => typeof p.id === "number" && typeof p.name === "string", - ) - .map((p) => ({ id: p.id as number, name: p.name as string })); const orgName = typeof orgData?.name === "string" && orgData.name.length > 0 ? orgData.name : "(unknown)"; - return [orgId, { orgName, projects }]; + return [orgId, { orgName, projects: projects ?? [] }]; } catch (error) { log.warn("Failed to fetch org projects", { orgId, error }); return [orgId, { orgName: "(unknown)", projects: [] }]; @@ -567,6 +595,35 @@ export class AuthService extends TypedEventEmitter { return Object.fromEntries(entries); } + private async fetchOrgProjects( + accessToken: string, + cloudRegion: CloudRegion, + orgId: string, + ): Promise<{ id: number; name: string }[] | null> { + const apiHost = getCloudUrlFromRegion(cloudRegion); + try { + const res = await fetch( + `${apiHost}/api/organizations/${orgId}/projects/`, + { + headers: { Authorization: `Bearer ${accessToken}` }, + }, + ); + if (!res.ok) return null; + const raw = (await res.json().catch(() => null)) as unknown; + const array = Array.isArray(raw) + ? raw + : Array.isArray((raw as { results?: unknown } | null)?.results) + ? ((raw as { results: unknown[] }).results as unknown[]) + : []; + return array + .map((p) => p as { id?: unknown; name?: unknown }) + .filter((p) => typeof p.id === "number" && typeof p.name === "string") + .map((p) => ({ id: p.id as number, name: p.name as string })); + } catch (error) { + log.warn("Failed to refresh org projects", { orgId, error }); + return null; + } + } private async authenticateWithFlow( runFlow: () => Promise<{ success: boolean; @@ -642,7 +699,11 @@ export class AuthService extends TypedEventEmitter { }); const orgIdForProject = session.currentProjectId - ? findOrgForProject(session.orgProjectsMap, session.currentProjectId) + ? findOrgForProject( + session.orgProjectsMap, + session.currentProjectId, + session.currentOrgId, + ) : null; if (orgIdForProject && session.currentProjectId) { this.authPreferenceRepository.saveOrgProject({ diff --git a/apps/code/src/renderer/features/projects/hooks/useProjects.tsx b/apps/code/src/renderer/features/projects/hooks/useProjects.tsx index 3820fba315..75f7cb0bcd 100644 --- a/apps/code/src/renderer/features/projects/hooks/useProjects.tsx +++ b/apps/code/src/renderer/features/projects/hooks/useProjects.tsx @@ -36,6 +36,7 @@ export function groupProjectsByOrg(map: OrgProjectsMap): GroupedProjects[] { export function useProjects() { const orgProjectsMap = useAuthStateValue((state) => state.orgProjectsMap); + const currentOrgId = useAuthStateValue((state) => state.currentOrgId); const currentProjectId = useAuthStateValue((state) => state.currentProjectId); const projects = useMemo(() => { @@ -59,19 +60,25 @@ export function useProjects() { useEffect(() => { if (isSelectingProject) return; if (projects.length > 0 && !currentProject) { - const preferred = projects[0]; + const currentOrgProjects = currentOrgId + ? (orgProjectsMap[currentOrgId]?.projects ?? []) + : []; + const preferredId = currentOrgProjects[0]?.id ?? projects[0]?.id; + if (preferredId == null) return; log.info("Auto-selecting project", { - projectId: preferred.id, + projectId: preferredId, reason: currentProjectId == null ? "no project selected" : "current project not found in list", }); - selectProject(preferred.id); + selectProject(preferredId); } }, [ currentProject, currentProjectId, + currentOrgId, + orgProjectsMap, projects, selectProject, isSelectingProject, From 31834ee87d5fcfe931323a9bca8ae2a690fdc91a Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Mon, 25 May 2026 20:07:33 -0700 Subject: [PATCH 4/4] read project names from organization teams not projects endpoint --- .../src/main/services/auth/service.test.ts | 19 +---- apps/code/src/main/services/auth/service.ts | 76 +++++++++---------- 2 files changed, 40 insertions(+), 55 deletions(-) diff --git a/apps/code/src/main/services/auth/service.test.ts b/apps/code/src/main/services/auth/service.test.ts index 0e010d1a40..31729f9258 100644 --- a/apps/code/src/main/services/auth/service.test.ts +++ b/apps/code/src/main/services/auth/service.test.ts @@ -127,26 +127,15 @@ describe("AuthService", () => { } as unknown as Response; } - const orgProjectsMatch = url.match( - /\/api\/organizations\/([^/]+)\/projects\/$/, - ); - if (orgProjectsMatch) { - const orgId = orgProjectsMatch[1]; - const projects = orgs[orgId]?.projects ?? []; - return { - ok: true, - json: vi.fn().mockResolvedValue({ results: projects }), - } as unknown as Response; - } - const orgMatch = url.match(/\/api\/organizations\/([^/]+)\/$/); if (orgMatch) { const orgId = orgMatch[1]; return { ok: true, - json: vi - .fn() - .mockResolvedValue({ name: orgs[orgId]?.name ?? "Unknown" }), + json: vi.fn().mockResolvedValue({ + name: orgs[orgId]?.name ?? "Unknown", + teams: orgs[orgId]?.projects ?? [], + }), } as unknown as Response; } diff --git a/apps/code/src/main/services/auth/service.ts b/apps/code/src/main/services/auth/service.ts index 1ea72c631d..281a2e51cd 100644 --- a/apps/code/src/main/services/auth/service.ts +++ b/apps/code/src/main/services/auth/service.ts @@ -565,31 +565,14 @@ export class AuthService extends TypedEventEmitter { cloudRegion: CloudRegion, orgIds: string[], ): Promise { - const apiHost = getCloudUrlFromRegion(cloudRegion); const entries = await Promise.all( orgIds.map(async (orgId): Promise<[string, OrgProjects]> => { - try { - const [orgRes, projects] = await Promise.all([ - fetch(`${apiHost}/api/organizations/${orgId}/`, { - headers: { Authorization: `Bearer ${accessToken}` }, - }), - this.fetchOrgProjects(accessToken, cloudRegion, orgId), - ]); - - const orgData = orgRes.ok - ? ((await orgRes.json().catch(() => ({}))) as { name?: unknown }) - : null; - - const orgName = - typeof orgData?.name === "string" && orgData.name.length > 0 - ? orgData.name - : "(unknown)"; - - return [orgId, { orgName, projects: projects ?? [] }]; - } catch (error) { - log.warn("Failed to fetch org projects", { orgId, error }); - return [orgId, { orgName: "(unknown)", projects: [] }]; - } + const result = await this.fetchOrgWithProjects( + accessToken, + cloudRegion, + orgId, + ); + return [orgId, result ?? { orgName: "(unknown)", projects: [] }]; }), ); @@ -600,27 +583,40 @@ export class AuthService extends TypedEventEmitter { cloudRegion: CloudRegion, orgId: string, ): Promise<{ id: number; name: string }[] | null> { + const result = await this.fetchOrgWithProjects( + accessToken, + cloudRegion, + orgId, + ); + return result?.projects ?? null; + } + private async fetchOrgWithProjects( + accessToken: string, + cloudRegion: CloudRegion, + orgId: string, + ): Promise { const apiHost = getCloudUrlFromRegion(cloudRegion); try { - const res = await fetch( - `${apiHost}/api/organizations/${orgId}/projects/`, - { - headers: { Authorization: `Bearer ${accessToken}` }, - }, - ); + const res = await fetch(`${apiHost}/api/organizations/${orgId}/`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); if (!res.ok) return null; - const raw = (await res.json().catch(() => null)) as unknown; - const array = Array.isArray(raw) - ? raw - : Array.isArray((raw as { results?: unknown } | null)?.results) - ? ((raw as { results: unknown[] }).results as unknown[]) - : []; - return array - .map((p) => p as { id?: unknown; name?: unknown }) - .filter((p) => typeof p.id === "number" && typeof p.name === "string") - .map((p) => ({ id: p.id as number, name: p.name as string })); + const raw = (await res.json().catch(() => null)) as { + name?: unknown; + teams?: unknown; + } | null; + const orgName = + typeof raw?.name === "string" && raw.name.length > 0 + ? raw.name + : "(unknown)"; + const teams = Array.isArray(raw?.teams) ? raw.teams : []; + const projects = teams + .map((t) => t as { id?: unknown; name?: unknown }) + .filter((t) => typeof t.id === "number" && typeof t.name === "string") + .map((t) => ({ id: t.id as number, name: t.name as string })); + return { orgName, projects }; } catch (error) { - log.warn("Failed to refresh org projects", { orgId, error }); + log.warn("Failed to fetch org with projects", { orgId, error }); return null; } }