You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by zh...@apache.org on 2026/08/31 05:07:04 UTC
(maka) 01/03: feat(desktop): expose OAuth connection accounts
This is an automated email from the ASF dual-hosted git repository.
me2seeks pushed a commit to branch feat/4312-desktop-oauth-multi-account
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 93731bf14a4375a3e86ac9dba43c7385fd56677f
Author: me2seeks <me...@gmail.com>
AuthorDate: Mon Aug 31 12:34:28 2026 +0800
feat(desktop): expose OAuth connection accounts
---
apps/desktop/renderer-architecture.json | 8 +-
.../__tests__/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 +++--
.../src/renderer/locales/settings-provider-copy.ts | 14 ++-
.../renderer/settings/provider-catalog-page.tsx | 23 +++-
.../settings/provider-connection-detail.tsx | 20 +++-
.../renderer/settings/provider-oauth-section.tsx | 113 ++++++++----------
.../src/renderer/settings/providers-panel.tsx | 126 ++++++++++++++++-----
.../settings/runtime-host-settings-bridge.ts | 75 +++++++++---
.../src/renderer/settings/use-connection-detail.ts | 24 +++-
.../src/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<ReturnType<OAuthClient['loadConnectionCatalog']>>,
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<SubscriptionActionResult, { readonly ok: true }>;
+
+export type DesktopOAuthAuthorizationResult =
+ | { readonly ok: true; readonly connection: DesktopOAuthConnectionIdentity }
+ | Exclude<SubscriptionActionResult, { readonly ok: true }>;
+
export type DesktopNewTaskHostRef = DesktopRuntimeHostRef;
export interface DesktopNewTaskTarget extends DesktopRuntimeHostRef {
@@ -1438,45 +1458,51 @@ export interface MakaBridge {
};
openAiCodex: {
isExperimentalEnabled(host?: DesktopRuntimeHostRef): Promise<boolean>;
- getAuthUrl(host?: DesktopRuntimeHostRef, connectionId?: string): Promise<AuthorizationUrlPayload | SubscriptionActionResult>;
+ getAuthUrl(host: DesktopRuntimeHostRef | undefined, target: DesktopOAuthLoginTarget): Promise<DesktopOAuthAuthorizationStartResult>;
openAuthUrl(authRequestId: string, host?: DesktopRuntimeHostRef): Promise<SubscriptionActionResult>;
- completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise<SubscriptionActionResult>;
+ completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise<DesktopOAuthAuthorizationResult>;
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<SubscriptionActionResult>;
- logout(host?: DesktopRuntimeHostRef, connectionId?: string): Promise<SubscriptionActionResult>;
+ 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<SubscriptionActionResult, { readonly ok: true }>
+ >;
+ refreshTokens(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise<SubscriptionActionResult>;
+ logout(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise<SubscriptionActionResult>;
};
xaiOAuth: {
- getAuthUrl(host?: DesktopRuntimeHostRef, connectionId?: string): Promise<AuthorizationUrlPayload | SubscriptionActionResult>;
+ getAuthUrl(host: DesktopRuntimeHostRef | undefined, target: DesktopOAuthLoginTarget): Promise<DesktopOAuthAuthorizationStartResult>;
openAuthUrl(authRequestId: string, host?: DesktopRuntimeHostRef): Promise<SubscriptionActionResult>;
- completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise<SubscriptionActionResult>;
+ completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise<DesktopOAuthAuthorizationResult>;
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<SubscriptionActionResult>;
- logout(host?: DesktopRuntimeHostRef, connectionId?: string): Promise<SubscriptionActionResult>;
+ getAccountState(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise<
+ | {
+ provider: 'xai-oauth';
+ runtimeState:
+ | 'not_logged_in'
+ | 'authorizing'
+ | 'authenticated'
+ | 'refreshing'
+ | 'refresh_failed'
+ | 'storage_failed';
+ errorMessage?: string;
+ }
+ | Exclude<SubscriptionActionResult, { readonly ok: true }>
+ >;
+ refreshTokens(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise<SubscriptionActionResult>;
+ logout(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise<SubscriptionActionResult>;
};
githubCopilotSubscription: {
connectExistingLogin(host?: DesktopRuntimeHostRef): Promise<SubscriptionActionResult>;
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<boolean> {
return invokeSelectedRuntimeHost(host, 'openai-codex:is-experimental-enabled');
},
- getAuthUrl(host?: DesktopRuntimeHostRef, connectionId?: string): Promise<AuthorizationUrlPayload | SubscriptionActionResult> {
- 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<SubscriptionActionResult> {
return invokeSelectedRuntimeHost(host, 'openai-codex:open-auth-url', authRequestId);
},
- completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise<SubscriptionActionResult> {
+ completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise<DesktopOAuthAuthorizationResult> {
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<SubscriptionActionResult> {
+ refreshTokens(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise<SubscriptionActionResult> {
return invokeSelectedRuntimeHost(host, 'openai-codex:refresh-tokens', connectionId);
},
- logout(host?: DesktopRuntimeHostRef, connectionId?: string): Promise<SubscriptionActionResult> {
+ logout(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise<SubscriptionActionResult> {
return invokeSelectedRuntimeHost(host, 'openai-codex:logout', connectionId);
},
},
xaiOAuth: {
- getAuthUrl(host?: DesktopRuntimeHostRef, connectionId?: string): Promise<AuthorizationUrlPayload | SubscriptionActionResult> {
- 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<SubscriptionActionResult> {
return invokeSelectedRuntimeHost(host, 'xai-oauth:open-auth-url', authRequestId);
},
- completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise<SubscriptionActionResult> {
+ completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise<DesktopOAuthAuthorizationResult> {
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<SubscriptionActionResult> {
+ refreshTokens(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise<SubscriptionActionResult> {
return invokeSelectedRuntimeHost(host, 'xai-oauth:refresh-tokens', connectionId);
},
- logout(host?: DesktopRuntimeHostRef, connectionId?: string): Promise<SubscriptionActionResult> {
+ logout(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise<SubscriptionActionResult> {
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<void>;
- onAccountChanged(): Promise<void>;
+ onAccountCreated(connection?: CreatedOAuthConnectionIdentity): Promise<void>;
+ labelledBy?: string;
}) {
if (props.target.method === 'account') {
return (
- <div tabIndex={-1} className="settingsRouteLevel" data-maka-contract="provider-setup">
- <OAuthLoginPanel cardId={props.target.cardId} onLoginSuccess={props.onAccountChanged} />
+ <div tabIndex={-1} role="region" aria-labelledby={props.labelledBy} className="settingsRouteLevel" data-maka-contract="provider-setup">
+ <OAuthLoginPanel cardId={props.target.cardId} onLoginSuccess={props.onAccountCreated} />
</div>
);
}
return (
- <div tabIndex={-1} className="settingsRouteLevel" data-maka-contract="provider-setup">
+ <div tabIndex={-1} role="region" aria-labelledby={props.labelledBy} className="settingsRouteLevel" data-maka-contract="provider-setup">
<AddProviderForm
key={props.target.providerType}
bridge={props.bridge}
diff --git a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx
index d54149911e..c1130622a5 100644
--- a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx
+++ b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx
@@ -1104,9 +1104,12 @@ function OAuthReloginNoticeForCurrentGeneration(props: {
const providerCopy = getProviderSettingsCopy(useUiLocale());
const copy = providerCopy.detail;
const flow = useOAuthLoginFlow({
- bridge: props.service.bridge,
+ mode: 'existing',
+ authorizationBridge: props.service.authorizationBridge,
+ accountBridge: props.service.accountBridge,
display: props.service.display,
- onLoginSuccess: props.onRelogin,
+ onLoginSuccess: () => props.onRelogin(),
+ onAccountChanged: props.onRelogin,
});
const { hasSecret } = props;
const loggedIn = hasSecret === true;
@@ -1139,6 +1142,7 @@ function OAuthReloginNoticeForCurrentGeneration(props: {
</>
) : detail}
endContent={!loading ? (
+ <HStack gap={2}>
<Button
variant="primary"
size="sm"
@@ -1146,6 +1150,18 @@ function OAuthReloginNoticeForCurrentGeneration(props: {
onClick={() => void flow.startLogin()}
label={flow.pendingAction === 'login' ? copy.loggingIn : loggedIn ? copy.relogin : copy.login}
/>
+ {loggedIn && flow.logout && (
+ <Button
+ variant="ghost"
+ size="sm"
+ isDisabled={flow.actionBusy}
+ onClick={() => void flow.logout?.()}
+ label={flow.pendingAction === 'logout'
+ ? providerCopy.oauthSection.loggingOut
+ : providerCopy.oauthSection.logout}
+ />
+ )}
+ </HStack>
) : undefined} />
);
}
diff --git a/apps/desktop/src/renderer/settings/provider-oauth-section.tsx b/apps/desktop/src/renderer/settings/provider-oauth-section.tsx
index 10d86d72f6..b10ab880d8 100644
--- a/apps/desktop/src/renderer/settings/provider-oauth-section.tsx
+++ b/apps/desktop/src/renderer/settings/provider-oauth-section.tsx
@@ -19,7 +19,7 @@
import { useEffect, useRef, useState } from 'react';
import { Banner, HStack, Text, VStack } from '@astryxdesign/core';
-import { type ProviderType } from '@maka/core/llm-connections';
+import { type LlmConnection, type ProviderType } from '@maka/core/llm-connections';
import type { DesktopRuntimeHostRef } from '../../preload/bridge-contract.js';
import {
Badge,
@@ -31,8 +31,7 @@ import { getProviderSettingsCopy, type ProviderSettingsCopy } from '../locales/s
import {
useOAuthLoginFlow,
subscriptionActionErrorMessage,
- subscriptionResultMessage,
- type OAuthLoginFlowBridge,
+ type OAuthConnectionIdentity,
type SubscriptionSnapshot,
} from './use-oauth-login-flow';
import {
@@ -40,7 +39,9 @@ import {
useRuntimeHostSettingsGenerationKey,
useRuntimeHostSettingsTarget,
} from './runtime-host-settings-target.js';
-import { runtimeHostOAuthLoginBridge } from './runtime-host-settings-bridge.js';
+import {
+ runtimeHostOAuthAuthorizationBridge,
+} from './runtime-host-settings-bridge.js';
export type OAuthCardId = 'codex' | 'github-copilot' | 'xai';
@@ -64,8 +65,8 @@ function emptyOAuthCardStates(): Record<OAuthCardId, SubscriptionSnapshot | null
}
/**
- * Account sign-in rows for the provider catalog, plus the refresh that keeps
- * their badges live.
+ * Account enrollment rows for the provider catalog. Codex and xAI are pure
+ * add intents; only the singleton Copilot import retains aggregate state.
*
* This used to be a self-contained `ModelOAuthSection` that rendered both the
* rows and a Dialog per service. The rows and the login body are now two
@@ -76,7 +77,10 @@ function emptyOAuthCardStates(): Record<OAuthCardId, SubscriptionSnapshot | null
* login remounts it, which re-reads every account state; that is the refresh,
* and it is why nothing here has to be pushed across a level boundary.
*/
-export function useOAuthCards(props: { query?: string }) {
+export function useOAuthCards(props: {
+ query?: string;
+ connections: readonly LlmConnection[];
+}) {
const host = useRuntimeHostSettingsTarget();
const generationKey = useRuntimeHostSettingsGenerationKey();
const locale = useUiLocale();
@@ -104,7 +108,7 @@ export function useOAuthCards(props: { query?: string }) {
const ticket = refreshTicketRef.current + 1;
refreshTicketRef.current = ticket;
const results = await Promise.all(
- cards.map(async (card) => {
+ cards.filter((card) => card.id === 'github-copilot').map(async (card) => {
try {
const snapshot = await getSubscriptionSnapshot(card.id, host);
return { id: card.id, snapshot } as const;
@@ -155,17 +159,25 @@ export function useOAuthCards(props: { query?: string }) {
.filter(matchesQuery)
.map((card) => {
const snapshot = cardStates[card.id];
+ const connectionCount = props.connections.filter(
+ (connection) => connection.providerType === card.providerType,
+ ).length;
const runtimeState = snapshot?.runtimeState ?? 'unknown';
- const isLoggedIn =
+ const isLoggedIn = card.id === 'github-copilot' && (
runtimeState === 'authenticated' ||
runtimeState === 'refreshing' ||
runtimeState === 'quota_unavailable' ||
- runtimeState === 'provider_rejected';
+ runtimeState === 'provider_rejected'
+ );
return {
id: card.id,
providerType: card.providerType,
name: card.name,
- description: isLoggedIn && snapshot?.email ? snapshot.email : card.description,
+ description: card.id !== 'github-copilot' && connectionCount > 0
+ ? copy.configuredConnections(connectionCount)
+ : isLoggedIn && snapshot?.email
+ ? snapshot.email
+ : card.description,
...(isLoggedIn ? { status: copy.signedIn } : {}),
isLoggedIn,
};
@@ -179,7 +191,10 @@ export function useOAuthCards(props: { query?: string }) {
* this as its setup level; the header and the back affordance belong to that
* level, the same ones the catalog and the connection detail use.
*/
-export function OAuthLoginPanel(props: { cardId: OAuthCardId; onLoginSuccess(): void | Promise<void> }) {
+export function OAuthLoginPanel(props: {
+ cardId: OAuthCardId;
+ onLoginSuccess(connection?: OAuthConnectionIdentity): void | Promise<void>;
+}) {
return (
<RuntimeHostSettingsGenerationBoundary>
<OAuthLoginPanelForCurrentGeneration {...props} />
@@ -189,7 +204,7 @@ export function OAuthLoginPanel(props: { cardId: OAuthCardId; onLoginSuccess():
function OAuthLoginPanelForCurrentGeneration(props: {
cardId: OAuthCardId;
- onLoginSuccess(): void | Promise<void>;
+ onLoginSuccess(connection?: OAuthConnectionIdentity): void | Promise<void>;
}) {
if (props.cardId === 'github-copilot') {
return <GitHubCopilotLoginPanel onLoginSuccess={props.onLoginSuccess} />;
@@ -219,11 +234,10 @@ function modelOAuthCards(copy: ProviderSettingsCopy['oauthSection']): ReadonlyAr
function SubscriptionLoginPanel(props: {
service: 'codex' | 'xai';
- onLoginSuccess(): void | Promise<void>;
+ onLoginSuccess(connection: OAuthConnectionIdentity): void | Promise<void>;
}) {
const host = useRuntimeHostSettingsTarget();
- const locale = useUiLocale();
- const copy = getProviderSettingsCopy(locale).oauthSection;
+ const copy = getProviderSettingsCopy(useUiLocale()).oauthSection;
const isXai = props.service === 'xai';
const display: SubscriptionDisplay = isXai
? { name: 'xAI Grok', shortName: 'SuperGrok / X Premium', detail: copy.xaiDetail }
@@ -234,9 +248,11 @@ function SubscriptionLoginPanel(props: {
// localized toast copy) lives in useOAuthLoginFlow so the connection detail
// page can drive the exact same flow behind its relogin button.
const flow = useOAuthLoginFlow({
- bridge: runtimeHostOAuthLoginBridge(
+ mode: 'create',
+ authorizationBridge: runtimeHostOAuthAuthorizationBridge(
isXai ? window.maka.xaiOAuth : window.maka.openAiCodex,
host,
+ { kind: 'create' },
),
display: { name: display.name, shortName: display.shortName },
onLoginSuccess: props.onLoginSuccess,
@@ -244,7 +260,7 @@ function SubscriptionLoginPanel(props: {
return (
<VStack gap={3} data-status={flow.runtimeState}>
- <Text type="body">{presentSnapshotDetail(flow.state, display, locale)}</Text>
+ <Text type="body">{display.detail}</Text>
{!isXai && flow.stateHint && (
<Text type="supporting" color="secondary">
{copy.deviceCode} {flow.stateHint}
@@ -254,21 +270,12 @@ function SubscriptionLoginPanel(props: {
<Banner status="error" role="alert" title={flow.errorMessage} />
)}
<HStack gap={2} hAlign="end">
- {!flow.isLoggedIn ? (
- <Button
- variant="primary"
- onClick={() => void flow.startLogin()}
- isDisabled={flow.actionBusy}
- label={flow.pendingAction === 'login' ? copy.openingBrowser : copy.login(display.shortName)}
- />
- ) : (
- <Button
- variant="ghost"
- onClick={() => void flow.logout()}
- isDisabled={flow.actionBusy}
- label={flow.pendingAction === 'logout' ? copy.loggingOut : copy.logout}
- />
- )}
+ <Button
+ variant="primary"
+ onClick={() => void flow.startLogin()}
+ isDisabled={flow.actionBusy}
+ label={flow.pendingAction === 'login' ? copy.openingBrowser : copy.loginAndAdd}
+ />
</HStack>
</VStack>
);
@@ -283,10 +290,11 @@ function GitHubCopilotLoginPanel(props: { onLoginSuccess(): void | Promise<void>
// no browser handoff, no logout confirm) instead of owning a separate
// pending-action state machine here (#1042).
const flow = useOAuthLoginFlow({
- bridge: {
+ mode: 'direct',
+ accountBridge: {
getAccountState: () => window.maka.githubCopilotSubscription.getAccountState(host),
logout: () => window.maka.githubCopilotSubscription.logout(host),
- } as OAuthLoginFlowBridge,
+ },
display: { name: 'GitHub Copilot', shortName: 'GitHub Copilot' },
onLoginSuccess: props.onLoginSuccess,
direct: {
@@ -310,7 +318,7 @@ function GitHubCopilotLoginPanel(props: { onLoginSuccess(): void | Promise<void>
{loggedIn && (
<>
<Button variant="secondary" onClick={() => void refreshTokens?.()} isDisabled={flow.actionBusy} label={flow.pendingAction === 'refresh' ? copy.verifying : copy.reverify} />
- <Button variant="ghost" onClick={() => void flow.logout()} isDisabled={flow.actionBusy} label={flow.pendingAction === 'logout' ? copy.removing : copy.removeLocal} />
+ <Button variant="ghost" onClick={() => void flow.logout?.()} isDisabled={flow.actionBusy} label={flow.pendingAction === 'logout' ? copy.removing : copy.removeLocal} />
</>
)}
</HStack>
@@ -325,10 +333,7 @@ async function getSubscriptionSnapshot(
if (serviceId === 'github-copilot') {
return window.maka.githubCopilotSubscription.getAccountState(host);
}
- if (serviceId === 'xai') {
- return window.maka.xaiOAuth.getAccountState(host);
- }
- return (await window.maka.openAiCodex.getAccountState(host)) as SubscriptionSnapshot;
+ throw new Error(`Aggregate OAuth state is not available for ${serviceId}`);
}
interface SubscriptionDisplay {
@@ -336,31 +341,3 @@ interface SubscriptionDisplay {
shortName: string;
detail: string;
}
-
-function presentSnapshotDetail(state: SubscriptionSnapshot | null, display: SubscriptionDisplay, locale: 'zh' | 'en'): string {
- const copy = getProviderSettingsCopy(locale).oauthSection;
- if (!state) return copy.loadingAccount;
- switch (state.runtimeState) {
- case 'not_logged_in':
- return copy.signedOut(display.name);
- case 'authorizing':
- return copy.authorizing;
- case 'authenticated': {
- const parts = [copy.signedIn];
- if (state.email) parts.push(state.email);
- if (state.plan) parts.push(state.plan);
- return parts.join(' · ');
- }
- case 'refreshing':
- return copy.refreshing;
- case 'refresh_failed':
- return subscriptionResultMessage(state.errorMessage, copy.refreshTokenFailed, locale);
- case 'storage_failed':
- return subscriptionResultMessage(state.errorMessage, copy.storageFailed(display.name), locale);
- case 'quota_unavailable':
- case 'provider_rejected':
- return subscriptionResultMessage(state.errorMessage, copy.providerUnavailable(display.name), locale);
- }
- const _exhaustive: never = state.runtimeState;
- return _exhaustive;
-}
diff --git a/apps/desktop/src/renderer/settings/providers-panel.tsx b/apps/desktop/src/renderer/settings/providers-panel.tsx
index f38af09d46..5a809ed6bc 100644
--- a/apps/desktop/src/renderer/settings/providers-panel.tsx
+++ b/apps/desktop/src/renderer/settings/providers-panel.tsx
@@ -80,7 +80,7 @@ type PanelRoute =
| { kind: 'list' }
| { kind: 'catalog' }
| { kind: 'setup'; target: SetupTarget; origin: 'list' | 'catalog' }
- | { kind: 'detail'; slug: string };
+ | { kind: 'detail'; connectionId: string };
function backTarget(route: PanelRoute): PanelRoute {
if (route.kind === 'setup' && route.origin === 'catalog') return { kind: 'catalog' };
@@ -119,29 +119,34 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
const addButtonRef = useRef<HTMLButtonElement>(null);
// Which row the user left the list from, so returning puts focus back where
// they were rather than on the page's primary action.
- const listReturnFocusRef = useRef<string | null>(null);
+ const returnFocusRef = useRef<
+ | { level: 'list'; connectionId: string }
+ | { level: 'catalog'; providerType: ProviderType }
+ | null
+ >(null);
const detailTitleId = useId();
+ const setupTitleId = useId();
const locale = useUiLocale();
const providerCopy = getProviderSettingsCopy(locale);
const copy = providerCopy.panel;
- async function reload(): Promise<boolean> {
+ async function reload(): Promise<Awaited<ReturnType<ConnectionsBridge['getSnapshot']>> | null> {
const ticket = ++providersReloadTicketRef.current;
try {
const snapshot = await bridge.getSnapshot();
- if (!providersPanelMountedRef.current || providersReloadTicketRef.current !== ticket) return false;
+ if (!providersPanelMountedRef.current || providersReloadTicketRef.current !== ticket) return null;
setConnections(snapshot.connections);
setDefaultSlug(snapshot.defaultConnection);
setLoadError(null);
setLoading(false);
- return true;
+ return snapshot;
} catch (error) {
- if (!providersPanelMountedRef.current || providersReloadTicketRef.current !== ticket) return false;
+ if (!providersPanelMountedRef.current || providersReloadTicketRef.current !== ticket) return null;
const message = providerPanelActionErrorMessage(error, locale);
setLoadError(message);
setLoading(false);
reportHostError(copy.loadFailed, message);
- return false;
+ return null;
}
}
@@ -167,9 +172,10 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
const initialConnectionDetailOpenedRef = useRef(false);
useEffect(() => {
if (loading || !initialConnectionSlug || initialConnectionDetailOpenedRef.current) return;
- if (!connections.some((candidate) => candidate.slug === initialConnectionSlug)) return;
+ const connection = connections.find((candidate) => candidate.slug === initialConnectionSlug);
+ if (!connection?.connectionId) return;
initialConnectionDetailOpenedRef.current = true;
- setRoute({ kind: 'detail', slug: initialConnectionSlug });
+ setRoute({ kind: 'detail', connectionId: connection.connectionId });
}, [loading, initialConnectionSlug, connections]);
const initialCreateOpenedRef = useRef(false);
@@ -198,18 +204,22 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
setRoute({ kind: 'list' });
}
- function openDetail(slug: string) {
- listReturnFocusRef.current = slug;
- setRoute({ kind: 'detail', slug });
+ function openDetail(connection: LlmConnection) {
+ if (!connection.connectionId) {
+ reportHostError(copy.loadFailed, copy.connectionRemoved);
+ return;
+ }
+ returnFocusRef.current = { level: 'list', connectionId: connection.connectionId };
+ setRoute({ kind: 'detail', connectionId: connection.connectionId });
}
function openCatalog() {
- listReturnFocusRef.current = null;
+ returnFocusRef.current = null;
setRoute({ kind: 'catalog' });
}
const selected = route.kind === 'detail'
- ? connections.find((connection) => connection.slug === route.slug) ?? null
+ ? connections.find((connection) => connection.connectionId === route.connectionId) ?? null
: null;
// A detail route whose connection vanished (deleted in another window) is an
@@ -223,7 +233,13 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
isReady: !loading,
resolveTarget: (current) => {
const find = (selector: string) => document.querySelector<HTMLElement>(selector);
- if (current === 'catalog') return find('[data-maka-contract="provider-catalog"] input');
+ if (current === 'catalog') {
+ const target = returnFocusRef.current;
+ returnFocusRef.current = null;
+ return (target?.level === 'catalog'
+ ? find(`[data-provider="${target.providerType}"] button`)
+ : null) ?? find('[data-maka-contract="provider-catalog"] input');
+ }
if (current === 'setup') {
return find('[data-maka-contract="provider-setup"] input')
?? find('[data-maka-contract="provider-setup"]');
@@ -235,9 +251,11 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
// survive the levels in between. The row the user came from may be gone —
// that is exactly what a deletion does — so the primary action is the
// fallback, not the default.
- const returnToSlug = listReturnFocusRef.current;
- listReturnFocusRef.current = null;
- return (returnToSlug ? find(`[data-connection-slug="${returnToSlug}"] button`) : null)
+ const target = returnFocusRef.current;
+ returnFocusRef.current = null;
+ return (target?.level === 'list'
+ ? find(`[data-connection-id="${target.connectionId}"] button`)
+ : null)
?? addButtonRef.current;
},
});
@@ -268,7 +286,7 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
backLabel={copy.backToList}
logo={<ProviderLogo type={selected.providerType} compact />}
titleId={detailTitleId}
- title={selected.name}
+ title={connectionDisplayName(selected, connections)}
/* The control sits in the slot that shows the state. Setting the
default connection was reachable only from the command palette,
while this page displayed a 默认 Badge and offered no way to
@@ -322,7 +340,7 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
subtitle={connectionSubtitle(selected, locale)}
/>
<ConnectionDetail
- key={selected.slug}
+ key={selected.connectionId ?? selected.slug}
bridge={bridge}
connection={selected}
isDefault={selected.slug === defaultSlug}
@@ -344,8 +362,12 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
/>
<ProviderCatalogPage
filter={catalogFilter}
+ connections={connections}
onFilterChange={setCatalogFilter}
- onPick={(target) => setRoute({ kind: 'setup', target, origin: 'catalog' })}
+ onPick={(target) => {
+ returnFocusRef.current = { level: 'catalog', providerType: target.providerType };
+ setRoute({ kind: 'setup', target, origin: 'catalog' });
+ }}
/>
</VStack>
) : level === 'setup' && route.kind === 'setup' ? (
@@ -354,7 +376,10 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
onBack={goBack}
backLabel={route.origin === 'catalog' ? copy.backToCatalog : copy.backToList}
logo={<ProviderLogo type={route.target.providerType} compact />}
- title={copy.connectTitle(route.target.name)}
+ titleId={setupTitleId}
+ title={route.target.method === 'account' && route.target.cardId !== 'github-copilot'
+ ? providerCopy.oauthSection.addAccountTitle(route.target.name)
+ : copy.connectTitle(route.target.name)}
subtitle={route.target.method === 'account'
? oauthPanelSubtitle(route.target.cardId, providerCopy.oauthSection)
: copy.createSubtitle}
@@ -363,16 +388,35 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
bridge={bridge}
target={route.target}
existingSlugs={connections.map((connection) => connection.slug)}
+ labelledBy={setupTitleId}
onCancel={goBack}
- onAccountChanged={async () => { await reload(); }}
+ onAccountCreated={async (identity) => {
+ const snapshot = await reload();
+ if (!snapshot || !providersPanelMountedRef.current) return;
+ if (!identity) return;
+ const created = snapshot.connections.find(
+ (connection) => connection.connectionId === identity.connectionId,
+ );
+ if (!created || created.slug !== identity.slug || created.providerType !== identity.providerType) {
+ reportHostError(copy.loadFailed, copy.connectionRemoved);
+ return;
+ }
+ returnFocusRef.current = null;
+ setRoute({ kind: 'detail', connectionId: created.connectionId });
+ }}
onCreated={async (slug, modelDiscoveryError) => {
- await reload();
- if (!providersPanelMountedRef.current) return;
+ const snapshot = await reload();
+ if (!snapshot || !providersPanelMountedRef.current) return;
// The new connection's detail, not the list: creating it is the
// start of setting it up, and every next move — pick the default
// model, enable models, fix the endpoint the discovery error just
// complained about — is on that page.
- setRoute({ kind: 'detail', slug });
+ const created = snapshot.connections.find((connection) => connection.slug === slug);
+ if (!created?.connectionId) {
+ reportHostError(copy.loadFailed, copy.connectionRemoved);
+ return;
+ }
+ setRoute({ kind: 'detail', connectionId: created.connectionId });
if (modelDiscoveryError) {
const providerName = providerDisplay(route.target.providerType, locale).name;
reportHostError(
@@ -388,6 +432,9 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
</VStack>
) : (
<>
+ {route.kind === 'detail' && !selected && (
+ <Banner status="warning" role="status" title={copy.connectionRemoved} />
+ )}
<HStack gap={2} vAlign="center" hAlign="between">
<HStack gap={2} vAlign="center">
<Heading level={3}>{copy.connections}</Heading>
@@ -424,15 +471,16 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
const isDefault = connection.slug === defaultSlug;
return (
<ListItem
- key={connection.slug}
+ key={connection.connectionId ?? connection.slug}
className="connectionRow"
+ data-connection-id={connection.connectionId}
data-connection-slug={connection.slug}
data-disabled={connection.enabled ? undefined : 'true'}
startContent={<ProviderLogo type={connection.providerType} compact />}
label={(
<HStack gap={2} vAlign="center">
{/* a11y-allow: this label names the ROW, not the span. Astryx's Item puts consumer props on its outer wrapper and renders a separate invisible <button> for the click target, so an aria-label on the Item never reaches that button — measured. The button is named from its content, and this span is how the status reaches that name. Removing it drops the runtime error from the row's accessible name (settings.spec:226).*/}
- <span aria-label={chipAriaLabel(connection, isDefault)}>{connection.name}</span>
+ <span aria-label={chipAriaLabel(connection, isDefault)}>{connectionDisplayName(connection, connections)}</span>
{isDefault && <Badge variant="neutral" label={copy.default} />}
</HStack>
)}
@@ -448,7 +496,7 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
<ChevronRight size={ICON_SIZE.chrome} aria-hidden="true" />
</HStack>
)}
- onClick={() => openDetail(connection.slug)}
+ onClick={() => openDetail(connection)}
/>
);
})}
@@ -462,7 +510,12 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
function chipAriaLabel(connection: LlmConnection, isDefault: boolean): string {
const provider = providerDisplay(connection.providerType, locale).name;
const status = connectionChipStatus(connection, locale);
- return copy.chipAria(connection.name, provider, isDefault, status?.label);
+ return copy.chipAria(
+ connectionDisplayName(connection, connections),
+ provider,
+ isDefault,
+ status?.label,
+ );
}
}
@@ -473,3 +526,16 @@ function connectionSubtitle(connection: LlmConnection, locale: 'zh' | 'en'): str
if (connection.defaultModel) parts.push(connection.defaultModel);
return parts.join(' · ');
}
+
+function connectionDisplayName(
+ connection: LlmConnection,
+ connections: readonly LlmConnection[],
+): string {
+ const ambiguous = connections.some(
+ (candidate) =>
+ candidate.connectionId !== connection.connectionId &&
+ candidate.providerType === connection.providerType &&
+ candidate.name === connection.name,
+ );
+ return ambiguous ? `${connection.name} · ${connection.slug}` : connection.name;
+}
diff --git a/apps/desktop/src/renderer/settings/runtime-host-settings-bridge.ts b/apps/desktop/src/renderer/settings/runtime-host-settings-bridge.ts
index a4a4aa1b20..f9ed6ef707 100644
--- a/apps/desktop/src/renderer/settings/runtime-host-settings-bridge.ts
+++ b/apps/desktop/src/renderer/settings/runtime-host-settings-bridge.ts
@@ -17,9 +17,16 @@
* under the License.
*/
-import type { DesktopRuntimeHostRef } from '../../preload/bridge-contract.js';
+import type {
+ DesktopOAuthLoginTarget,
+ DesktopRuntimeHostRef,
+ MakaBridge,
+} from '../../preload/bridge-contract.js';
import type { ConnectionsBridge } from './provider-panel-shared.js';
-import type { OAuthLoginFlowBridge } from './use-oauth-login-flow.js';
+import type {
+ OAuthAccountFlowBridge,
+ OAuthAuthorizationFlowBridge,
+} from './use-oauth-login-flow.js';
export type RuntimeHostSettingsConnectionsBridge = ConnectionsBridge & {
setDefaultModel(input: { slug: string; model: string } | null): Promise<void>;
@@ -46,20 +53,62 @@ export function runtimeHostConnectionsBridge(
};
}
-export function runtimeHostOAuthLoginBridge(
- bridge: typeof window.maka.openAiCodex | typeof window.maka.xaiOAuth,
+type RuntimeHostOAuthBridge = MakaBridge['openAiCodex'] | MakaBridge['xaiOAuth'];
+
+export function runtimeHostOAuthAuthorizationBridge(
+ bridge: RuntimeHostOAuthBridge,
host: DesktopRuntimeHostRef,
- connectionId?: string,
-): OAuthLoginFlowBridge {
+ target: DesktopOAuthLoginTarget,
+): OAuthAuthorizationFlowBridge {
return {
- getAuthUrl: () =>
- bridge.getAuthUrl(host, connectionId) as ReturnType<OAuthLoginFlowBridge['getAuthUrl']>,
+ getAuthUrl: () => bridge.getAuthUrl(host, target),
openAuthUrl: (authRequestId) => bridge.openAuthUrl(authRequestId, host),
- completeAuthorization: (authRequestId) =>
- bridge.completeAuthorization(authRequestId, host),
- cancelAuthorization: (authRequestId) =>
- bridge.cancelAuthorization(authRequestId, host),
- getAccountState: () => bridge.getAccountState(host, connectionId),
+ completeAuthorization: (authRequestId) => bridge.completeAuthorization(authRequestId, host),
+ cancelAuthorization: (authRequestId) => bridge.cancelAuthorization(authRequestId, host),
+ };
+}
+
+export function runtimeHostOAuthAccountBridge(
+ bridge: RuntimeHostOAuthBridge,
+ host: DesktopRuntimeHostRef,
+ connectionId: string,
+): OAuthAccountFlowBridge {
+ return {
+ getAccountState: async () => requireOAuthAccountState(
+ await bridge.getAccountState(host, connectionId),
+ ),
logout: () => bridge.logout(host, connectionId),
};
}
+
+function requireOAuthAccountState(value: unknown): unknown {
+ if (
+ value &&
+ typeof value === 'object' &&
+ 'ok' in value &&
+ value.ok === false &&
+ 'message' in value &&
+ typeof value.message === 'string'
+ ) {
+ throw new Error(value.message);
+ }
+ return value;
+}
+
+export function runtimeHostOAuthExistingLoginBridges(
+ bridge: RuntimeHostOAuthBridge,
+ host: DesktopRuntimeHostRef,
+ connectionId: string,
+): {
+ authorizationBridge: OAuthAuthorizationFlowBridge;
+ accountBridge: OAuthAccountFlowBridge;
+} {
+ return {
+ authorizationBridge: runtimeHostOAuthAuthorizationBridge(
+ bridge,
+ host,
+ { kind: 'existing', connectionId },
+ ),
+ accountBridge: runtimeHostOAuthAccountBridge(bridge, host, connectionId),
+ };
+}
diff --git a/apps/desktop/src/renderer/settings/use-connection-detail.ts b/apps/desktop/src/renderer/settings/use-connection-detail.ts
index e9e9859a04..443e6337fe 100644
--- a/apps/desktop/src/renderer/settings/use-connection-detail.ts
+++ b/apps/desktop/src/renderer/settings/use-connection-detail.ts
@@ -50,7 +50,10 @@ import { connectionChipStatus } from './provider-connection-status';
import { relayProfileDraftReseedPlan, relayProfileDraftSeed } from './relay-profile-draft';
import { applyBulkThinkingLevel, relayProfileWithThinkingLevels } from './relay-thinking-bulk';
import { useKeyedActionGuard } from './use-action-guard';
-import type { OAuthLoginFlowBridge } from './use-oauth-login-flow';
+import type {
+ OAuthAccountFlowBridge,
+ OAuthAuthorizationFlowBridge,
+} from './use-oauth-login-flow';
import {
connectionLastTestMessageDisplay,
connectionTestFailureMessage,
@@ -62,7 +65,9 @@ import {
useRuntimeHostSettingsErrorReporter,
useRuntimeHostSettingsTarget,
} from './runtime-host-settings-target.js';
-import { runtimeHostOAuthLoginBridge } from './runtime-host-settings-bridge.js';
+import {
+ runtimeHostOAuthExistingLoginBridges,
+} from './runtime-host-settings-bridge.js';
// Maps an OAuth model-connection provider type to the browser-assisted login
// service that can re-run its authorization from inside the connection dialog. Only
@@ -70,7 +75,8 @@ import { runtimeHostOAuthLoginBridge } from './runtime-host-settings-bridge.js';
// here; plain API-key providers return null so the notice falls back to
// prose instead of rendering a dead button.
export interface OAuthLoginService {
- bridge: OAuthLoginFlowBridge;
+ authorizationBridge: OAuthAuthorizationFlowBridge;
+ accountBridge: OAuthAccountFlowBridge;
display: { name: string; shortName: string };
// Codex's device-authorization page requires the user to type the code the
// flow surfaces as `stateHint` — the verification URL does not embed it, so
@@ -87,13 +93,21 @@ export function oauthLoginServiceFor(
switch (providerType) {
case 'openai-codex':
return {
- bridge: runtimeHostOAuthLoginBridge(window.maka.openAiCodex, host, connectionId),
+ ...runtimeHostOAuthExistingLoginBridges(
+ window.maka.openAiCodex,
+ host,
+ connectionId,
+ ),
display: { name: 'OpenAI Codex', shortName: 'Codex' },
showsDeviceCode: true,
};
case 'xai-oauth':
return {
- bridge: runtimeHostOAuthLoginBridge(window.maka.xaiOAuth, host, connectionId),
+ ...runtimeHostOAuthExistingLoginBridges(
+ window.maka.xaiOAuth,
+ host,
+ connectionId,
+ ),
display: { name: 'xAI Grok', shortName: 'SuperGrok / X Premium' },
showsDeviceCode: false,
};
diff --git a/apps/desktop/src/renderer/settings/use-oauth-login-flow.ts b/apps/desktop/src/renderer/settings/use-oauth-login-flow.ts
index 57006a0c82..cc4b8b9564 100644
--- a/apps/desktop/src/renderer/settings/use-oauth-login-flow.ts
+++ b/apps/desktop/src/renderer/settings/use-oauth-login-flow.ts
@@ -61,17 +61,32 @@ export interface SubscriptionSnapshot {
errorMessage?: string;
}
-export interface OAuthLoginFlowBridge {
+export interface OAuthConnectionIdentity {
+ connectionId: string;
+ slug: string;
+ providerType: 'openai-codex' | 'xai-oauth';
+}
+
+export interface OAuthAuthorizationFlowBridge {
getAuthUrl(): Promise<
- { authRequestId: string; stateHint: string } | { ok: boolean; reason?: string; message: string }
+ { authRequestId: string; stateHint: string; connection: OAuthConnectionIdentity }
+ | { ok: boolean; reason?: string; message: string }
>;
openAuthUrl(authRequestId: string): Promise<{ ok: true } | { ok: false; reason: string; message: string }>;
- completeAuthorization(authRequestId: string): Promise<{ ok: true } | { ok: false; reason: string; message: string }>;
+ completeAuthorization(authRequestId: string): Promise<
+ { ok: true; connection: OAuthConnectionIdentity }
+ | { ok: false; reason: string; message: string }
+ >;
cancelAuthorization(authRequestId?: string): Promise<{ ok: true }>;
+}
+
+export interface OAuthAccountFlowBridge {
getAccountState(): Promise<unknown>;
logout(): Promise<{ ok: true } | { ok: false; reason: string; message: string }>;
}
+export type OAuthLoginFlowBridge = OAuthAuthorizationFlowBridge & OAuthAccountFlowBridge;
+
export interface OAuthLoginFlowDisplay {
name: string;
shortName: string;
@@ -103,30 +118,43 @@ export interface OAuthLoginFlowController {
errorMessage: string | null;
actionBusy: boolean;
startLogin(): Promise<void>;
- logout(): Promise<void>;
+ logout: (() => Promise<void>) | undefined;
refresh(): Promise<boolean>;
// Direct account flows only (GitHub Copilot 重新验证); undefined for the
// browser-assisted services so they cannot render a dead action.
refreshTokens: (() => Promise<void>) | undefined;
}
-export function useOAuthLoginFlow(params: {
- bridge: OAuthLoginFlowBridge;
- display: OAuthLoginFlowDisplay;
- // Fired after a successful completeAuthorization (browser handoff done).
- // The detail sheet uses it to re-probe hasSecret + reload connection status;
- // catalog modals use it to refresh both their account card and the shared
- // model connection list without waiting for the modal to close.
- onLoginSuccess?: () => void | Promise<void>;
- // When present, startLogin runs this one-shot import instead of the
- // getAuthUrl -> openAuthUrl -> completeAuthorization handoff, and the
- // controller exposes the extra `refreshTokens` action.
- direct?: OAuthDirectAccountFlow;
-}): OAuthLoginFlowController {
- const { bridge, display } = params;
+type OAuthLoginFlowParams =
+ | {
+ mode: 'create';
+ authorizationBridge: OAuthAuthorizationFlowBridge;
+ display: OAuthLoginFlowDisplay;
+ onLoginSuccess(connection: OAuthConnectionIdentity): void | Promise<void>;
+ }
+ | {
+ mode: 'existing';
+ authorizationBridge: OAuthAuthorizationFlowBridge;
+ accountBridge: OAuthAccountFlowBridge;
+ display: OAuthLoginFlowDisplay;
+ onLoginSuccess?: (connection: OAuthConnectionIdentity) => void | Promise<void>;
+ onAccountChanged?: () => void | Promise<void>;
+ }
+ | {
+ mode: 'direct';
+ accountBridge: OAuthAccountFlowBridge;
+ display: OAuthLoginFlowDisplay;
+ onLoginSuccess?: () => void | Promise<void>;
+ direct: OAuthDirectAccountFlow;
+ };
+
+export function useOAuthLoginFlow(params: OAuthLoginFlowParams): OAuthLoginFlowController {
+ const { display } = params;
const locale = useUiLocale();
const copy = getProviderSettingsCopy(locale).oauthFlow;
- const direct = params.direct;
+ const direct = params.mode === 'direct' ? params.direct : undefined;
+ const authorizationBridge = params.mode === 'direct' ? undefined : params.authorizationBridge;
+ const accountBridge = params.mode === 'create' ? undefined : params.accountBridge;
const toast = useToast();
const reportHostError = useRuntimeHostSettingsErrorReporter();
const [state, setState] = useState<SubscriptionSnapshot | null>(null);
@@ -139,8 +167,9 @@ export function useOAuthLoginFlow(params: {
const oauthLoginFlowMountedRef = useMountedRef();
async function refresh(): Promise<boolean> {
+ if (!accountBridge) return true;
try {
- const next = (await bridge.getAccountState()) as SubscriptionSnapshot;
+ const next = (await accountBridge.getAccountState()) as SubscriptionSnapshot;
if (!oauthLoginFlowMountedRef.current) return false;
setState(next);
setErrorMessage(null);
@@ -155,10 +184,15 @@ export function useOAuthLoginFlow(params: {
}
useEffect(() => {
- void refresh();
+ if (accountBridge) void refresh();
return () => {
pendingGuard.finish();
- teardownPendingAuthorization(authRequestIdRef, (id) => void bridge.cancelAuthorization(id));
+ if (authorizationBridge) {
+ teardownPendingAuthorization(
+ authRequestIdRef,
+ (id) => void authorizationBridge.cancelAuthorization(id),
+ );
+ }
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -191,7 +225,9 @@ export function useOAuthLoginFlow(params: {
}
await refresh();
if (!oauthLoginFlowMountedRef.current) return;
- if (result.ok && params.onLoginSuccess) await params.onLoginSuccess();
+ if (result.ok && params.mode === 'direct' && params.onLoginSuccess) {
+ await params.onLoginSuccess();
+ }
} catch (error) {
if (!oauthLoginFlowMountedRef.current) return;
reportHostError(
@@ -204,7 +240,8 @@ export function useOAuthLoginFlow(params: {
return;
}
try {
- const payload = await bridge.getAuthUrl();
+ if (!authorizationBridge || params.mode === 'direct') return;
+ const payload = await authorizationBridge.getAuthUrl();
if ('ok' in payload) {
if (!oauthLoginFlowMountedRef.current) return;
const failureMessage = payload.ok ? copy.retry : subscriptionResultMessage(payload.message, copy.startFailedRetry, locale);
@@ -215,18 +252,18 @@ export function useOAuthLoginFlow(params: {
authRequestIdRef.current = payload.authRequestId;
if (!oauthLoginFlowMountedRef.current) {
authRequestIdRef.current = null;
- void bridge.cancelAuthorization(payload.authRequestId);
+ void authorizationBridge.cancelAuthorization(payload.authRequestId);
return;
}
setAuthRequestId(payload.authRequestId);
setStateHint(payload.stateHint);
- const opened = await bridge.openAuthUrl(payload.authRequestId);
+ const opened = await authorizationBridge.openAuthUrl(payload.authRequestId);
if (!oauthLoginFlowMountedRef.current) return;
if (!opened.ok) {
const message = subscriptionResultMessage(opened.message, copy.openFailedRetry, locale);
reportHostError(copy.openFailed, message);
setErrorMessage(message);
- void bridge.cancelAuthorization(payload.authRequestId);
+ void authorizationBridge.cancelAuthorization(payload.authRequestId);
authRequestIdRef.current = null;
setAuthRequestId(null);
setStateHint(null);
@@ -235,7 +272,7 @@ export function useOAuthLoginFlow(params: {
const refreshed = await refresh();
if (!oauthLoginFlowMountedRef.current || !refreshed) return;
// Wait for the backend to finish polling the provider.
- const result = await bridge.completeAuthorization(payload.authRequestId);
+ const result = await authorizationBridge.completeAuthorization(payload.authRequestId);
if (!oauthLoginFlowMountedRef.current) return;
authRequestIdRef.current = null;
setAuthRequestId(null);
@@ -244,7 +281,7 @@ export function useOAuthLoginFlow(params: {
toast.success(copy.loginSuccess, copy.bound(display.name));
await refresh();
if (!oauthLoginFlowMountedRef.current) return;
- if (params.onLoginSuccess) await params.onLoginSuccess();
+ if (params.onLoginSuccess) await params.onLoginSuccess(result.connection);
} else {
const message = subscriptionResultMessage(result.message, copy.incompleteRetry, locale);
reportHostError(copy.incomplete, message);
@@ -254,7 +291,9 @@ export function useOAuthLoginFlow(params: {
if (!oauthLoginFlowMountedRef.current) return;
const pendingAuthRequestId = authRequestIdRef.current;
authRequestIdRef.current = null;
- if (pendingAuthRequestId) void bridge.cancelAuthorization(pendingAuthRequestId);
+ if (pendingAuthRequestId && authorizationBridge) {
+ void authorizationBridge.cancelAuthorization(pendingAuthRequestId);
+ }
setAuthRequestId(null);
setStateHint(null);
const message = subscriptionActionErrorMessage(error, locale);
@@ -266,6 +305,7 @@ export function useOAuthLoginFlow(params: {
}
async function logout() {
+ if (!accountBridge) return;
if (!beginPendingAction('logout')) return;
try {
// Direct-import flows keep their original no-confirm, silent-success
@@ -281,13 +321,16 @@ export function useOAuthLoginFlow(params: {
});
if (!ok) return;
}
- const result = await bridge.logout();
+ const result = await accountBridge.logout();
if (!oauthLoginFlowMountedRef.current) return;
if (result.ok) {
if (!direct) {
toast.success(copy.loggedOut, copy.credentialsCleared);
}
await refresh();
+ if (params.mode === 'existing' && params.onAccountChanged) {
+ await params.onAccountChanged();
+ }
} else if (direct) {
reportHostError(
copy.accountActionFailed(display.name),
@@ -355,7 +398,7 @@ export function useOAuthLoginFlow(params: {
errorMessage,
actionBusy,
startLogin,
- logout,
+ logout: accountBridge ? logout : undefined,
refresh,
refreshTokens: direct ? refreshTokens : undefined,
};
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org