From 93731bf14a4375a3e86ac9dba43c7385fd56677f Mon Sep 17 00:00:00 2001 From: me2seeks Date: Mon, 31 Aug 2026 12:34:28 +0800 Subject: [PATCH 1/5] feat(desktop): expose OAuth connection accounts --- apps/desktop/renderer-architecture.json | 8 +- .../runtime-host-oauth-ipc-main.test.ts | 78 ++++++++--- .../src/main/runtime-host-oauth-ipc-main.ts | 102 +++++++------- apps/desktop/src/preload/bridge-contract.d.ts | 92 ++++++++----- apps/desktop/src/preload/preload.ts | 26 ++-- .../locales/settings-provider-copy.ts | 14 +- .../settings/provider-catalog-page.tsx | 23 +++- .../settings/provider-connection-detail.tsx | 20 ++- .../settings/provider-oauth-section.tsx | 113 +++++++--------- .../src/renderer/settings/providers-panel.tsx | 126 +++++++++++++----- .../settings/runtime-host-settings-bridge.ts | 75 +++++++++-- .../settings/use-connection-detail.ts | 24 +++- .../renderer/settings/use-oauth-login-flow.ts | 107 ++++++++++----- 13 files changed, 531 insertions(+), 277 deletions(-) diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 2f8f24e25e..4b1534321a 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -3676,9 +3676,7 @@ "window.maka.githubCopilotSubscription.logout": 1, "window.maka.githubCopilotSubscription.refreshTokens": 1, "window.maka.openAiCodex": 1, - "window.maka.openAiCodex.getAccountState": 1, - "window.maka.xaiOAuth": 1, - "window.maka.xaiOAuth.getAccountState": 1 + "window.maka.xaiOAuth": 1 }, "environmentCapabilities": {}, "hookCalls": { @@ -3985,9 +3983,7 @@ "window.maka.connections.test": 1, "window.maka.connections.update": 1 }, - "environmentCapabilities": { - "window": 2 - }, + "environmentCapabilities": {}, "hookCalls": {}, "lifecycleMethods": {}, "unresolvedDependencies": 0, diff --git a/apps/desktop/src/main/__tests__/runtime-host-oauth-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-oauth-ipc-main.test.ts index 64f824e4be..a5389b4073 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-oauth-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-oauth-ipc-main.test.ts @@ -167,9 +167,18 @@ test('adapts every Host OAuth provider through one Desktop flow', async () => { const authorization = await invoke( handlers, 'openai-codex:get-auth-url', - catalog.connections[0]?.connectionId, + { kind: 'existing', connectionId: catalog.connections[0]?.connectionId }, ); - assert.deepEqual(authorization, { authRequestId: attemptId, stateHint: 'STATE-HINT' }); + const expectedConnection = { + connectionId: catalog.connections[0]?.connectionId, + slug: 'codex-subscription', + providerType: provider, + }; + assert.deepEqual(authorization, { + authRequestId: attemptId, + stateHint: 'STATE-HINT', + connection: expectedConnection, + }); assert.deepEqual(opened, ['https://codex.example/authorize']); assert.deepEqual( await invoke( @@ -178,7 +187,7 @@ test('adapts every Host OAuth provider through one Desktop flow', async () => { attemptId, 'authorization-code#state', ), - { ok: true }, + { ok: true, connection: expectedConnection }, ); assert.equal(changed, 1); assert.deepEqual(catalog.defaultTarget, { @@ -187,7 +196,11 @@ test('adapts every Host OAuth provider through one Desktop flow', async () => { }); // No quota: reporting it required the retired provider's own client identity, // so the account state carries the runtime state alone. - assert.deepEqual(await invoke(handlers, 'openai-codex:get-account-state'), { + assert.deepEqual(await invoke( + handlers, + 'openai-codex:get-account-state', + catalog.connections[0]?.connectionId, + ), { provider, runtimeState: 'authenticated', }); @@ -231,7 +244,10 @@ test('provider-scoped OAuth IPC rejects a Connection ID owned by another provide }); assert.deepEqual( - await invoke(handlers, 'openai-codex:get-auth-url', xaiConnection.connectionId), + await invoke(handlers, 'openai-codex:get-auth-url', { + kind: 'existing', + connectionId: xaiConnection.connectionId, + }), { ok: false, reason: 'unknown', @@ -240,14 +256,18 @@ test('provider-scoped OAuth IPC rejects a Connection ID owned by another provide ); assert.deepEqual( await invoke(handlers, 'openai-codex:get-account-state', xaiConnection.connectionId), - { provider: 'openai-codex', runtimeState: 'not_logged_in' }, + { + ok: false, + reason: 'unknown', + message: 'OAuth account does not match this provider', + }, ); assert.deepEqual( await invoke(handlers, 'openai-codex:refresh-tokens', xaiConnection.connectionId), { ok: false, reason: 'refresh_failed', - message: 'OAuth account is not connected', + message: 'OAuth account does not match this provider', }, ); assert.deepEqual(await invoke(handlers, 'openai-codex:logout', xaiConnection.connectionId), { @@ -395,9 +415,9 @@ test('a second OAuth start cannot replace or cancel a pending active attempt', a isProviderEnabled: () => true, }); - const firstAuthorization = invoke(handlers, 'openai-codex:get-auth-url'); + const firstAuthorization = invoke(handlers, 'openai-codex:get-auth-url', { kind: 'create' }); await firstPresentationPoll; - assert.deepEqual(await invoke(handlers, 'openai-codex:get-auth-url'), { + assert.deepEqual(await invoke(handlers, 'openai-codex:get-auth-url', { kind: 'create' }), { ok: false, reason: 'unknown', message: 'Another OAuth login is already in progress', @@ -413,6 +433,11 @@ test('a second OAuth start cannot replace or cancel a pending active attempt', a assert.deepEqual(await firstAuthorization, { authRequestId: firstAttemptId, stateHint: 'FIRST', + connection: { + connectionId, + slug: 'codex-subscription', + providerType: provider, + }, }); assert.deepEqual( await invoke(handlers, 'openai-codex:logout', foreignConnection.connectionId), @@ -425,7 +450,7 @@ test('a second OAuth start cannot replace or cancel a pending active attempt', a assert.deepEqual(await invoke(handlers, 'openai-codex:logout'), { ok: false, reason: 'unknown', - message: 'Select a specific OAuth account to log out', + message: 'Invalid OAuth Connection identity', }); assert.equal(cancels, 0); assert.deepEqual( @@ -435,7 +460,14 @@ test('a second OAuth start cannot replace or cancel a pending active attempt', a assert.equal(cancels, 0); assert.deepEqual( await invoke(handlers, 'openai-codex:complete-authorization', firstAttemptId), - { ok: true }, + { + ok: true, + connection: { + connectionId, + slug: 'codex-subscription', + providerType: provider, + }, + }, ); assert.equal(cancels, 0); assertNoUnexpectedClientCalls(); @@ -475,7 +507,7 @@ test('completion rejects a terminal projection that changes Connection identity' isProviderEnabled: () => true, }); - await invoke(handlers, 'openai-codex:get-auth-url'); + await invoke(handlers, 'openai-codex:get-auth-url', { kind: 'create' }); assert.deepEqual(await invoke(handlers, 'openai-codex:complete-authorization', attemptId), { ok: false, reason: 'unknown', @@ -575,18 +607,34 @@ test('keeps a committed OAuth login successful when model discovery fails', asyn isProviderEnabled: () => true, }); - assert.deepEqual(await invoke(handlers, 'openai-codex:get-auth-url'), { + assert.deepEqual(await invoke(handlers, 'openai-codex:get-auth-url', { kind: 'create' }), { authRequestId: attemptId, stateHint: 'DEVICE-CODE', + connection: { + connectionId: created.connectionId, + slug: created.slug, + providerType: provider, + }, }); assert.deepEqual( await invoke(handlers, 'openai-codex:complete-authorization', attemptId, undefined), - { ok: true }, + { + ok: true, + connection: { + connectionId: created.connectionId, + slug: created.slug, + providerType: provider, + }, + }, ); assert.equal(changed, 1); assert.deepEqual(fetchedConnectionIds, [created.connectionId]); assert.deepEqual(catalog.defaultTarget, { connectionId: created.connectionId, modelId }); - assert.deepEqual(await invoke(handlers, 'openai-codex:get-account-state'), { + assert.deepEqual(await invoke( + handlers, + 'openai-codex:get-account-state', + created.connectionId, + ), { provider, runtimeState: 'authenticated', }); diff --git a/apps/desktop/src/main/runtime-host-oauth-ipc-main.ts b/apps/desktop/src/main/runtime-host-oauth-ipc-main.ts index e151449c56..2ed0c5b3c7 100644 --- a/apps/desktop/src/main/runtime-host-oauth-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-oauth-ipc-main.ts @@ -94,9 +94,9 @@ export function registerRuntimeHostOAuthIpc(deps: RuntimeHostOAuthIpcDeps): void if (provider !== 'xai-oauth') { deps.ipcMain.handle(channel('is-experimental-enabled'), () => providerEnabled(provider)); } - deps.ipcMain.handle(channel('get-auth-url'), async (_event, rawConnectionId: unknown) => { + deps.ipcMain.handle(channel('get-auth-url'), async (_event, rawTarget: unknown) => { if (!providerEnabled(provider)) return providerDisabled(); - const selection = decodeOAuthConnectionSelection(rawConnectionId); + const selection = decodeOAuthLoginSelection(rawTarget); if (selection.kind === 'invalid') return invalidConnectionIdentity(); const connectionId = selection.kind === 'exact' ? selection.connectionId : undefined; if (connectionId) { @@ -129,7 +129,11 @@ export function registerRuntimeHostOAuthIpc(deps: RuntimeHostOAuthIpcDeps): void provider, connection: started.connection, }); - return { authRequestId: attemptId, stateHint: presented.stateHint }; + return { + authRequestId: attemptId, + stateHint: presented.stateHint, + connection: started.connection, + }; } catch (error) { expectation?.cancel(error); if (startedOnHost) { @@ -176,7 +180,7 @@ export function registerRuntimeHostOAuthIpc(deps: RuntimeHostOAuthIpcDeps): void terminal.connection.connectionId, ).catch(() => undefined); deps.emitConnectionListChanged(); - return { ok: true as const }; + return { ok: true as const, connection: terminal.connection }; } catch { activeAttempts.delete(attemptId); return actionFailure('Unable to complete OAuth authorization'); @@ -192,45 +196,44 @@ export function registerRuntimeHostOAuthIpc(deps: RuntimeHostOAuthIpcDeps): void return { ok: true as const }; }); handleReconnectableRead(deps.ipcMain, channel('get-account-state'), async (_event, rawConnectionId: unknown) => { - const selection = decodeOAuthConnectionSelection(rawConnectionId); - if (selection.kind === 'invalid') return invalidConnectionIdentity(); - const connectionId = selection.kind === 'exact' ? selection.connectionId : undefined; + const connectionId = decodeExactOAuthConnectionId(rawConnectionId); + if (!connectionId) return invalidConnectionIdentity(); const candidates = oauthAccountCandidates( await deps.client.loadConnectionCatalog(), provider, connectionId, ); + if (candidates.length === 0) { + return actionFailure('OAuth account does not match this provider'); + } const authorizing = [...activeAttempts.values()].some( (attempt) => attempt.provider === provider && - (connectionId === undefined || attempt.connection.connectionId === connectionId), + attempt.connection.connectionId === connectionId, ); - if (candidates.length === 0) { - return accountState(provider, authorizing ? 'authorizing' : 'not_logged_in'); - } if ((await configuredOAuthAccountConnections(deps.client, candidates)).length > 0) { return accountState(provider, 'authenticated'); } return accountState(provider, authorizing ? 'authorizing' : 'not_logged_in'); }); deps.ipcMain.handle(channel('refresh-tokens'), async (_event, rawConnectionId: unknown) => { - const selection = decodeOAuthConnectionSelection(rawConnectionId); - if (selection.kind === 'invalid') return invalidConnectionIdentity('refresh_failed'); - const connectionId = selection.kind === 'exact' ? selection.connectionId : undefined; + const connectionId = decodeExactOAuthConnectionId(rawConnectionId); + if (!connectionId) return invalidConnectionIdentity('refresh_failed'); + const candidates = oauthAccountCandidates( + await deps.client.loadConnectionCatalog(), + provider, + connectionId, + ); + if (candidates.length === 0) { + return actionFailure('OAuth account does not match this provider', 'refresh_failed'); + } const connections = await configuredOAuthAccountConnections( deps.client, - oauthAccountCandidates( - await deps.client.loadConnectionCatalog(), - provider, - connectionId, - ), + candidates, ); if (connections.length === 0) { return actionFailure('OAuth account is not connected', 'refresh_failed'); } - if (connectionId === undefined && connections.length > 1) { - return actionFailure('Select a specific OAuth account to refresh', 'refresh_failed'); - } const connection = connections[0]!; const refreshed = await deps.client.fetchConnectionModels(connection.connectionId); return refreshed.kind === 'committed' @@ -238,34 +241,25 @@ export function registerRuntimeHostOAuthIpc(deps: RuntimeHostOAuthIpcDeps): void : actionFailure('Unable to refresh OAuth account', 'refresh_failed'); }); deps.ipcMain.handle(channel('logout'), async (_event, rawConnectionId: unknown) => { - const selection = decodeOAuthConnectionSelection(rawConnectionId); - if (selection.kind === 'invalid') return invalidConnectionIdentity(); - const connectionId = selection.kind === 'exact' ? selection.connectionId : undefined; + const connectionId = decodeExactOAuthConnectionId(rawConnectionId); + if (!connectionId) return invalidConnectionIdentity(); try { const candidates = oauthAccountCandidates( await deps.client.loadConnectionCatalog(), provider, connectionId, ); - if (connectionId !== undefined && candidates.length === 0) { + if (candidates.length === 0) { return actionFailure('OAuth account does not match this provider'); } - const connections = - connectionId !== undefined - ? candidates - : await configuredOAuthAccountConnections(deps.client, candidates); - if (connectionId === undefined && connections.length > 1) { - return actionFailure('Select a specific OAuth account to log out'); - } - const connection = connections[0]; + const connection = candidates[0]!; await cancelProviderAttempts( deps, activeAttempts, provider, - connection?.connectionId, + connection.connectionId, ); - if (connection) - await disableRuntimeHostAccountConnectionById(deps.client, connection.connectionId); + await disableRuntimeHostAccountConnectionById(deps.client, connection.connectionId); } catch { return actionFailure('Unable to remove OAuth account'); } @@ -370,30 +364,40 @@ function accountState( function oauthAccountCandidates( catalog: Awaited>, provider: OAuthLoginProvider, - connectionId: string | undefined, + connectionId: string, ): ConnectionCatalogEntry[] { - if (connectionId !== undefined) { - const connection = findRuntimeHostAccountConnectionById(catalog, connectionId); - return connection?.providerType === provider ? [connection] : []; - } - return catalog.connections.filter((connection) => connection.providerType === provider); + const connection = findRuntimeHostAccountConnectionById(catalog, connectionId); + return connection?.providerType === provider ? [connection] : []; } -type OAuthConnectionSelection = - | { readonly kind: 'aggregate' } +type OAuthLoginSelection = + | { readonly kind: 'create' } | { readonly kind: 'exact'; readonly connectionId: string } | { readonly kind: 'invalid' }; -function decodeOAuthConnectionSelection(value: unknown): OAuthConnectionSelection { - if (value === undefined) return { kind: 'aggregate' }; - if (typeof value !== 'string') return { kind: 'invalid' }; +function decodeOAuthLoginSelection(value: unknown): OAuthLoginSelection { + if (!value || typeof value !== 'object') return { kind: 'invalid' }; + const candidate = value as { readonly kind?: unknown; readonly connectionId?: unknown }; + if (candidate.kind === 'create') return { kind: 'create' }; + if (candidate.kind !== 'existing' || typeof candidate.connectionId !== 'string') { + return { kind: 'invalid' }; + } try { - return { kind: 'exact', connectionId: decodeRuntimePolicyEntityId(value) }; + return { kind: 'exact', connectionId: decodeRuntimePolicyEntityId(candidate.connectionId) }; } catch { return { kind: 'invalid' }; } } +function decodeExactOAuthConnectionId(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + try { + return decodeRuntimePolicyEntityId(value); + } catch { + return undefined; + } +} + function sameOAuthConnectionIdentity( left: OAuthConnectionIdentity, right: OAuthConnectionIdentity, diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 10fc8cf377..75adb306a6 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -350,6 +350,26 @@ export interface DesktopRuntimeHostRef { readonly hostId: string; } +/** Desktop-local OAuth intent. Runtime Host remains the identity allocator. */ +export type DesktopOAuthLoginTarget = + | { readonly kind: 'create' } + | { readonly kind: 'existing'; readonly connectionId: string }; + +/** Secret-free canonical identity returned by the Runtime Host OAuth flow. */ +export interface DesktopOAuthConnectionIdentity { + readonly connectionId: string; + readonly slug: string; + readonly providerType: 'openai-codex' | 'xai-oauth'; +} + +export type DesktopOAuthAuthorizationStartResult = + | (AuthorizationUrlPayload & { readonly connection: DesktopOAuthConnectionIdentity }) + | Exclude; + +export type DesktopOAuthAuthorizationResult = + | { readonly ok: true; readonly connection: DesktopOAuthConnectionIdentity } + | Exclude; + export type DesktopNewTaskHostRef = DesktopRuntimeHostRef; export interface DesktopNewTaskTarget extends DesktopRuntimeHostRef { @@ -1438,45 +1458,51 @@ export interface MakaBridge { }; openAiCodex: { isExperimentalEnabled(host?: DesktopRuntimeHostRef): Promise; - getAuthUrl(host?: DesktopRuntimeHostRef, connectionId?: string): Promise; + getAuthUrl(host: DesktopRuntimeHostRef | undefined, target: DesktopOAuthLoginTarget): Promise; openAuthUrl(authRequestId: string, host?: DesktopRuntimeHostRef): Promise; - completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise; + completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise; cancelAuthorization(authRequestId?: string, host?: DesktopRuntimeHostRef): Promise<{ ok: true }>; - getAccountState(host?: DesktopRuntimeHostRef, connectionId?: string): Promise<{ - provider: 'openai-codex'; - runtimeState: - | 'not_logged_in' - | 'authorizing' - | 'authenticated' - | 'refreshing' - | 'refresh_failed'; - accountId?: string; - email?: string; - plan?: string; - picture?: string; - errorMessage?: string; - }>; - refreshTokens(host?: DesktopRuntimeHostRef, connectionId?: string): Promise; - logout(host?: DesktopRuntimeHostRef, connectionId?: string): Promise; + getAccountState(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise< + | { + provider: 'openai-codex'; + runtimeState: + | 'not_logged_in' + | 'authorizing' + | 'authenticated' + | 'refreshing' + | 'refresh_failed'; + accountId?: string; + email?: string; + plan?: string; + picture?: string; + errorMessage?: string; + } + | Exclude + >; + refreshTokens(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise; + logout(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise; }; xaiOAuth: { - getAuthUrl(host?: DesktopRuntimeHostRef, connectionId?: string): Promise; + getAuthUrl(host: DesktopRuntimeHostRef | undefined, target: DesktopOAuthLoginTarget): Promise; openAuthUrl(authRequestId: string, host?: DesktopRuntimeHostRef): Promise; - completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise; + completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise; cancelAuthorization(authRequestId?: string, host?: DesktopRuntimeHostRef): Promise<{ ok: true }>; - getAccountState(host?: DesktopRuntimeHostRef, connectionId?: string): Promise<{ - provider: 'xai-oauth'; - runtimeState: - | 'not_logged_in' - | 'authorizing' - | 'authenticated' - | 'refreshing' - | 'refresh_failed' - | 'storage_failed'; - errorMessage?: string; - }>; - refreshTokens(host?: DesktopRuntimeHostRef, connectionId?: string): Promise; - logout(host?: DesktopRuntimeHostRef, connectionId?: string): Promise; + getAccountState(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise< + | { + provider: 'xai-oauth'; + runtimeState: + | 'not_logged_in' + | 'authorizing' + | 'authenticated' + | 'refreshing' + | 'refresh_failed' + | 'storage_failed'; + errorMessage?: string; + } + | Exclude + >; + refreshTokens(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise; + logout(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise; }; githubCopilotSubscription: { connectExistingLogin(host?: DesktopRuntimeHostRef): Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 157a758e6e..097b89000f 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -62,6 +62,8 @@ import type { DesktopNewTaskHostRef, DesktopNewTaskTarget, DesktopRuntimeHostRef, + DesktopOAuthLoginTarget, + DesktopOAuthAuthorizationResult, DesktopProjectSnapshot, DesktopAppInfo, DesktopSessionTracePage, @@ -2763,19 +2765,19 @@ const makaBridge = { isExperimentalEnabled(host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'openai-codex:is-experimental-enabled'); }, - getAuthUrl(host?: DesktopRuntimeHostRef, connectionId?: string): Promise { - return invokeSelectedRuntimeHost(host, 'openai-codex:get-auth-url', connectionId); + getAuthUrl(host: DesktopRuntimeHostRef | undefined, target: DesktopOAuthLoginTarget) { + return invokeSelectedRuntimeHost(host, 'openai-codex:get-auth-url', target); }, openAuthUrl(authRequestId: string, host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'openai-codex:open-auth-url', authRequestId); }, - completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise { + completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'openai-codex:complete-authorization', authRequestId); }, cancelAuthorization(authRequestId?: string, host?: DesktopRuntimeHostRef): Promise<{ ok: true }> { return invokeSelectedRuntimeHost(host, 'openai-codex:cancel-authorization', authRequestId); }, - getAccountState(host?: DesktopRuntimeHostRef, connectionId?: string): Promise<{ + getAccountState(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise<{ provider: 'openai-codex'; runtimeState: 'not_logged_in' | 'authorizing' | 'authenticated' | 'refreshing' | 'refresh_failed'; accountId?: string; @@ -2786,27 +2788,27 @@ const makaBridge = { }> { return invokeSelectedRuntimeHost(host, 'openai-codex:get-account-state', connectionId); }, - refreshTokens(host?: DesktopRuntimeHostRef, connectionId?: string): Promise { + refreshTokens(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise { return invokeSelectedRuntimeHost(host, 'openai-codex:refresh-tokens', connectionId); }, - logout(host?: DesktopRuntimeHostRef, connectionId?: string): Promise { + logout(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise { return invokeSelectedRuntimeHost(host, 'openai-codex:logout', connectionId); }, }, xaiOAuth: { - getAuthUrl(host?: DesktopRuntimeHostRef, connectionId?: string): Promise { - return invokeSelectedRuntimeHost(host, 'xai-oauth:get-auth-url', connectionId); + getAuthUrl(host: DesktopRuntimeHostRef | undefined, target: DesktopOAuthLoginTarget) { + return invokeSelectedRuntimeHost(host, 'xai-oauth:get-auth-url', target); }, openAuthUrl(authRequestId: string, host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'xai-oauth:open-auth-url', authRequestId); }, - completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise { + completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'xai-oauth:complete-authorization', authRequestId); }, cancelAuthorization(authRequestId?: string, host?: DesktopRuntimeHostRef): Promise<{ ok: true }> { return invokeSelectedRuntimeHost(host, 'xai-oauth:cancel-authorization', authRequestId); }, - getAccountState(host?: DesktopRuntimeHostRef, connectionId?: string): Promise<{ + getAccountState(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise<{ provider: 'xai-oauth'; runtimeState: | 'not_logged_in' @@ -2819,10 +2821,10 @@ const makaBridge = { }> { return invokeSelectedRuntimeHost(host, 'xai-oauth:get-account-state', connectionId); }, - refreshTokens(host?: DesktopRuntimeHostRef, connectionId?: string): Promise { + refreshTokens(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise { return invokeSelectedRuntimeHost(host, 'xai-oauth:refresh-tokens', connectionId); }, - logout(host?: DesktopRuntimeHostRef, connectionId?: string): Promise { + logout(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise { return invokeSelectedRuntimeHost(host, 'xai-oauth:logout', connectionId); }, }, diff --git a/apps/desktop/src/renderer/locales/settings-provider-copy.ts b/apps/desktop/src/renderer/locales/settings-provider-copy.ts index 6a2633bd39..b73b966a87 100644 --- a/apps/desktop/src/renderer/locales/settings-provider-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-provider-copy.ts @@ -176,7 +176,7 @@ const zhCopy = { panel: { tabs: { all: '全部', recommended: '推荐', accounts: '账号', plans: '模型计划', api: 'API', aggregators: '聚合服务', local: '本地' }, loadFailed: '载入模型连接失败', loadingAria: '正在加载模型供应商', connections: '模型连接', - retry: '点击重试。', empty: '还没有模型连接', + retry: '点击重试。', empty: '还没有模型连接', connectionRemoved: '原账号已被删除或移除,已返回模型连接列表。', emptyHelp: '从下方选择一种连接方式开始。', default: '默认', setDefault: '设为默认', setDefaultTitle: '让新任务默认使用这个连接', setDefaultPending: '设置中…', setDefaultFailed: '设为默认失败', addHelp: '选择账号登录、模型计划、API、聚合服务或本地运行时。', categoriesAria: '模型供应商分类', searchPlaceholder: '搜索服务商', searchAria: '搜索模型服务商', noMatch: '没有匹配的服务商', clearSearch: '清除搜索', createSubtitle: '完成必要配置后,连接会出现在模型页上方。', connection: '模型连接', @@ -214,7 +214,8 @@ const zhCopy = { logoutTitle: (name: string) => `退出 ${name} 登录?`, }, oauthSection: { - signedIn: '已登录', codexDescription: 'ChatGPT Plus / Pro 订阅账号登录。', xaiDescription: 'SuperGrok / X Premium 账号登录。', + signedIn: '已登录', codexDescription: '使用 ChatGPT Plus / Pro 账号添加连接。', xaiDescription: '使用 SuperGrok / X Premium 账号添加连接。', + configuredConnections: (count: number) => `已有 ${count} 个连接 · 添加另一个账号`, copilotDescription: '导入兼容 GitHub 凭据连接 Copilot 订阅。', serviceUnavailable: '登录服务暂时不可用,请检查网络后重试。', aria: 'OAuth 登录', staleState: 'OAuth 登录状态暂时没刷新成功,已保留上一次状态。', @@ -225,7 +226,7 @@ const zhCopy = { reimport: '重新导入', importCredential: '导入兼容凭据', verifying: '验证中…', reverify: '重新验证', removing: '移除中…', removeLocal: '移除本地登录', loadingAccount: '正在加载账号状态…', authorizing: '请在弹出的浏览器窗口完成登录。', refreshing: '正在刷新访问令牌…', refreshTokenFailed: '令牌刷新失败,请重新登录。', cardAria: (name: string, status: string | undefined, description: string) => `打开 OAuth 登录:${name}${status ? `,状态:${status}` : ''},${description.replace(/[。.!!??]+$/u, '')}`, - connectTitle: (name: string) => `连接 ${name}`, login: (name: string) => `登录 ${name}`, signedOut: (name: string) => `${name} 尚未登录。`, + connectTitle: (name: string) => `连接 ${name}`, addAccountTitle: (name: string) => `添加 ${name} 账号`, login: (name: string) => `登录 ${name}`, loginAndAdd: '登录并添加', signedOut: (name: string) => `${name} 尚未登录。`, storageFailed: (name: string) => `${name} 本地凭据读取失败,请重新登录。`, providerUnavailable: (name: string) => `${name} 已登录,但当前 provider 状态不可用。`, }, } as const; @@ -329,7 +330,7 @@ const enCopy: ProviderSettingsCopy = { panel: { tabs: { all: 'All', recommended: 'Recommended', accounts: 'Accounts', plans: 'Model plans', api: 'API', aggregators: 'Aggregators', local: 'Local' }, loadFailed: 'Failed to load model connections', loadingAria: 'Loading model providers', connections: 'Connections', - retry: 'Select to retry.', empty: 'No model connections yet', + retry: 'Select to retry.', empty: 'No model connections yet', connectionRemoved: 'The original account was deleted or removed. Returned to the connection list.', emptyHelp: 'Choose a connection method below to begin.', default: 'Default', setDefault: 'Set as default', setDefaultTitle: 'New chats will use this connection', setDefaultPending: 'Setting…', setDefaultFailed: 'Could not set as default', addHelp: 'Choose account sign-in, a model plan, API, aggregator, or local runtime.', categoriesAria: 'Model provider categories', searchPlaceholder: 'Search providers', searchAria: 'Search model providers', noMatch: 'No matching providers', clearSearch: 'Clear search', createSubtitle: 'After required setup, the connection appears above on the Models page.', connection: 'Model connection', @@ -367,7 +368,8 @@ const enCopy: ProviderSettingsCopy = { logoutTitle: (name: string) => `Sign out of ${name}?`, }, oauthSection: { - signedIn: 'Signed in', codexDescription: 'Sign in with a ChatGPT Plus / Pro subscription.', xaiDescription: 'Sign in with SuperGrok or X Premium.', + signedIn: 'Signed in', codexDescription: 'Use a ChatGPT Plus / Pro account to add a connection.', xaiDescription: 'Use a SuperGrok or X Premium account to add a connection.', + configuredConnections: (count: number) => `${count} ${count === 1 ? 'connection' : 'connections'} configured · Add another account`, copilotDescription: 'Import compatible GitHub credentials to connect a Copilot subscription.', serviceUnavailable: 'The sign-in service is temporarily unavailable. Check the network and try again.', aria: 'OAuth sign-in', staleState: 'OAuth sign-in status could not be refreshed. The last known state is preserved. ', @@ -378,7 +380,7 @@ const enCopy: ProviderSettingsCopy = { reimport: 'Reimport', importCredential: 'Import compatible credentials', verifying: 'Verifying…', reverify: 'Verify again', removing: 'Removing…', removeLocal: 'Remove local sign-in', loadingAccount: 'Loading account status…', authorizing: 'Complete sign-in in the browser window.', refreshing: 'Refreshing access token…', refreshTokenFailed: 'Token refresh failed. Sign in again.', cardAria: (name: string, status: string | undefined, description: string) => `Open OAuth sign-in: ${name}${status ? `; status: ${status}` : ''}; ${description.replace(/[。.!!??]+$/u, '')}`, - connectTitle: (name: string) => `Connect ${name}`, login: (name: string) => `Sign in to ${name}`, signedOut: (name: string) => `${name} is signed out.`, + connectTitle: (name: string) => `Connect ${name}`, addAccountTitle: (name: string) => `Add ${name} account`, login: (name: string) => `Sign in to ${name}`, loginAndAdd: 'Sign in and add', signedOut: (name: string) => `${name} is signed out.`, storageFailed: (name: string) => `Could not read local credentials for ${name}. Sign in again.`, providerUnavailable: (name: string) => `${name} is signed in, but the provider status is currently unavailable.`, }, }; diff --git a/apps/desktop/src/renderer/settings/provider-catalog-page.tsx b/apps/desktop/src/renderer/settings/provider-catalog-page.tsx index 364407fcfa..228ce44a72 100644 --- a/apps/desktop/src/renderer/settings/provider-catalog-page.tsx +++ b/apps/desktop/src/renderer/settings/provider-catalog-page.tsx @@ -34,7 +34,7 @@ import { type ProviderCatalogGroup, type ProviderType, } from '@maka/core/provider-registry'; -import { PROVIDER_DEFAULTS } from '@maka/core/llm-connections'; +import { PROVIDER_DEFAULTS, type LlmConnection } from '@maka/core/llm-connections'; import { Button, TextInput, useUiLocale } from '@maka/ui'; import { AddProviderForm } from './provider-add-form'; import { ProviderLogo, providerDisplay } from './provider-display'; @@ -66,6 +66,12 @@ export interface CatalogFilter { export const CATALOG_INITIAL_FILTER: CatalogFilter = { query: '', category: 'all' }; +interface CreatedOAuthConnectionIdentity { + connectionId: string; + slug: string; + providerType: 'openai-codex' | 'xai-oauth'; +} + /** * One provider being set up. `credentials` is a form, `account` is a browser * login — two bodies of one level, not two levels: they are reached the same @@ -87,6 +93,7 @@ export type SetupTarget = */ export function ProviderCatalogPage(props: { filter: CatalogFilter; + connections: readonly LlmConnection[]; onFilterChange(filter: CatalogFilter): void; onPick(target: SetupTarget): void; }) { @@ -96,7 +103,10 @@ export function ProviderCatalogPage(props: { const catalogCopy = providerCopy.catalog; const { query, category } = props.filter; const showsOAuth = category === 'all' || category === 'recommended' || category === 'accounts'; - const oauth = useOAuthCards({ query: showsOAuth ? query : undefined }); + const oauth = useOAuthCards({ + query: showsOAuth ? query : undefined, + connections: props.connections, + }); // Category is a Selector, not a second TabList: the page header already owns // one level of navigation, and six tabs beside a search field made the @@ -215,17 +225,18 @@ export function ProviderSetupPage(props: { existingSlugs: string[]; onCancel(): void; onCreated(slug: string, modelDiscoveryError?: unknown): Promise; - onAccountChanged(): Promise; + onAccountCreated(connection?: CreatedOAuthConnectionIdentity): Promise; + labelledBy?: string; }) { if (props.target.method === 'account') { return ( -
- +
+
); } return ( -
+
props.onRelogin(), + onAccountChanged: props.onRelogin, }); const { hasSecret } = props; const loggedIn = hasSecret === true; @@ -1139,6 +1142,7 @@ function OAuthReloginNoticeForCurrentGeneration(props: { ) : detail} endContent={!loading ? ( +