diff --git a/packages/worker/client/app.tsx b/packages/worker/client/app.tsx index a3f536c2e6..3bb1004603 100644 --- a/packages/worker/client/app.tsx +++ b/packages/worker/client/app.tsx @@ -90,6 +90,7 @@ export function App(handle: Handle) { return () => { const isWideLayout = + currentPathname.startsWith('/account/integrations') || currentPathname.startsWith('/account/package-invocation-tokens') || currentPathname.startsWith('/account/remote-connectors') || currentPathname.startsWith('/account/secrets') @@ -183,6 +184,9 @@ export function App(handle: Handle) { Secrets + + Integrations + + scopeSeparator?: string | null + extraAuthorizeParams?: Record +} + +type AccountIntegrationListItem = { + name: string + valueName: string + tokenUrl: string + apiBaseUrl?: string | null + flow: IntegrationFlow + clientIdValueName: string + clientSecretSecretName?: string | null + accessTokenSecretName: string + refreshTokenSecretName?: string | null + requiredHosts?: Array + authorization?: IntegrationAuthorization | null + createdAt: string + updatedAt: string +} + +type AccountIntegrationsPayload = { + ok: true + email: string + username: string + integrations: Array +} + +const accountIntegrationsApiPath = '/account/integrations.json' + +function formatTimestamp(value: string) { + return new Date(value).toLocaleString() +} + +function formatList(values: Array | null | undefined) { + if (!values || values.length === 0) return 'None' + return values.join(', ') +} + +function formatOptional(value: string | null | undefined) { + return value?.trim() ? value : 'None' +} + +function buildConnectOauthHref(integration: AccountIntegrationListItem) { + const authorization = integration.authorization + if (!authorization?.authorizeUrl) return null + + const params = new URLSearchParams({ provider: integration.name }) + return `/connect/oauth?${params.toString()}` +} + +function renderIntegrationDetail(label: string, value: string) { + return ( +
+ {label} + + {value} + +
+ ) +} + +export function AccountIntegrationsRoute(handle: Handle) { + let status: AccountStatus = 'loading' + let email = '' + let integrations: Array = [] + let message: string | null = null + let lastLoadedHref = '' + + async function loadIntegrations(signal: AbortSignal) { + try { + const href = + typeof window === 'undefined' + ? '/account/integrations' + : window.location.href + lastLoadedHref = href + const response = await fetch(accountIntegrationsApiPath, { + headers: { Accept: 'application/json' }, + credentials: 'include', + signal, + }) + if (signal.aborted) return + if (response.status === 401) { + window.location.assign('/login') + return + } + const payload = await readJson(response) + if (!response.ok || !payload?.ok) { + throw new Error('Unable to load integrations.') + } + email = payload.email + integrations = payload.integrations + status = 'ready' + message = null + handle.update() + } catch (error) { + if (signal.aborted) return + status = 'error' + message = + error instanceof Error ? error.message : 'Unable to load integrations.' + handle.update() + } + } + + return () => { + const currentHref = + typeof window === 'undefined' + ? '/account/integrations' + : window.location.href + const isRefreshingForLocationChange = + status !== 'loading' && currentHref !== lastLoadedHref + if (status === 'loading' || isRefreshingForLocationChange) { + handle.queueTask(loadIntegrations) + } + + return ( +
+
+

+ {email ? `${email} integrations` : 'Integrations'} +

+

+ Review saved OAuth integrations and reconnect providers when tokens + need to be refreshed. +

+
+ + {status === 'loading' ? ( +

+ Loading integrations... +

+ ) : null} + {message ? ( +

+ {message} +

+ ) : null} + + {status === 'ready' && integrations.length === 0 ? ( +
+

No integrations yet

+

+ OAuth integrations appear here after Kody saves provider + configuration through an OAuth setup flow. +

+
+ ) : null} + + {status === 'ready' && integrations.length > 0 ? ( +
+ {integrations.map((integration) => { + const connectHref = buildConnectOauthHref(integration) + return ( +
+
+
+

{integration.name}

+

+ Stored as {integration.valueName} +

+
+ + {integration.flow} + +
+ +
+ {renderIntegrationDetail('Token URL', integration.tokenUrl)} + {renderIntegrationDetail( + 'API base URL', + formatOptional(integration.apiBaseUrl), + )} + {renderIntegrationDetail( + 'Authorize URL', + formatOptional(integration.authorization?.authorizeUrl), + )} + {renderIntegrationDetail( + 'Scopes', + formatList(integration.authorization?.scopes), + )} +
+ +
+

Stored names

+
+ {renderIntegrationDetail( + 'Client ID value', + integration.clientIdValueName, + )} + {renderIntegrationDetail( + 'Client secret', + formatOptional(integration.clientSecretSecretName), + )} + {renderIntegrationDetail( + 'Access token secret', + integration.accessTokenSecretName, + )} + {renderIntegrationDetail( + 'Refresh token secret', + formatOptional(integration.refreshTokenSecretName), + )} +
+
+ +
+ {renderIntegrationDetail( + 'Required hosts', + formatList(integration.requiredHosts), + )} + {renderIntegrationDetail( + 'Updated', + formatTimestamp(integration.updatedAt), + )} +
+ + {connectHref ? ( +
+ + Reconnect OAuth + +
+ ) : ( +

+ This integration does not include authorization details + yet. +

+ )} +
+ ) + })} +
+ ) : null} + +

+ + Back to account + +

+
+ ) + } +} diff --git a/packages/worker/client/routes/account.tsx b/packages/worker/client/routes/account.tsx index 2ac13592f2..f100c23c07 100644 --- a/packages/worker/client/routes/account.tsx +++ b/packages/worker/client/routes/account.tsx @@ -161,8 +161,8 @@ export function AccountRoute(handle: Handle) { {username ? `${username} account` : 'Account'}

- Manage your profile, approval requests, stored secrets, and package - invocation tokens. + Manage your profile, integrations, approval requests, stored + secrets, and package invocation tokens.

@@ -235,6 +235,18 @@ export function AccountRoute(handle: Handle) { +
+

Integrations

+

+ Review saved OAuth provider configurations and reconnect + integrations when tokens need to be refreshed. +

+
+ + Manage integrations + +
+

Package invocation tokens

diff --git a/packages/worker/client/routes/index.tsx b/packages/worker/client/routes/index.tsx index 75d4f2cb90..eb7b365d4f 100644 --- a/packages/worker/client/routes/index.tsx +++ b/packages/worker/client/routes/index.tsx @@ -1,4 +1,5 @@ import { AccountRoute } from './account.tsx' +import { AccountIntegrationsRoute } from './account-integrations.tsx' import { AccountPackageInvocationTokensRoute } from './account-package-invocation-tokens.tsx' import { AccountRemoteConnectorsRoute } from './account-remote-connectors.tsx' import { AccountSecretsRoute } from './account-secrets.tsx' @@ -12,6 +13,7 @@ import { ResetPasswordRoute } from './reset-password.tsx' export const clientRoutes = { '/': , '/account': , + '/account/integrations': , '/account/package-invocation-tokens': , '/account/package-invocation-tokens/new': ( diff --git a/packages/worker/src/app/handlers/account-integrations.node.test.ts b/packages/worker/src/app/handlers/account-integrations.node.test.ts new file mode 100644 index 0000000000..839096714c --- /dev/null +++ b/packages/worker/src/app/handlers/account-integrations.node.test.ts @@ -0,0 +1,147 @@ +import { expect, test, vi } from 'vitest' + +const mockModule = vi.hoisted(() => ({ + readAuthenticatedAppUser: vi.fn(async () => ({ + sessionUserId: '42', + userId: 42, + username: 'test-user', + email: 'user@example.com', + displayName: 'user', + artifactOwnerIds: [], + mcpUser: { + userId: 'stable-user-1', + email: 'user@example.com', + username: 'test-user', + displayName: 'user', + }, + })), + readAuthSessionResult: async () => ({ session: null, setCookie: null }), + listValues: vi.fn(async () => [ + { + name: '_integration:github', + scope: 'user', + value: JSON.stringify({ + tokenUrl: 'https://github.com/login/oauth/access_token', + apiBaseUrl: 'https://api.github.com', + flow: 'confidential', + clientIdValueName: 'github-client-id', + clientSecretSecretName: 'githubClientSecret', + accessTokenSecretName: 'githubAccessToken', + refreshTokenSecretName: null, + requiredHosts: ['api.github.com'], + authorization: { + authorizeUrl: 'https://github.com/login/oauth/authorize', + scopes: ['repo', 'read:user'], + }, + }), + description: '', + appId: null, + createdAt: '1970-01-01T00:00:00.000Z', + updatedAt: '1970-01-01T00:00:00.001Z', + ttlMs: null, + }, + { + name: '_integration:broken', + scope: 'user', + value: '{', + description: '', + appId: null, + createdAt: '1970-01-01T00:00:00.000Z', + updatedAt: '1970-01-01T00:00:00.001Z', + ttlMs: null, + }, + { + name: 'plain-value', + scope: 'user', + value: 'ignored', + description: '', + appId: null, + createdAt: '1970-01-01T00:00:00.000Z', + updatedAt: '1970-01-01T00:00:00.001Z', + ttlMs: null, + }, + ]), +})) + +const createdAt = '1970-01-01T00:00:00.000Z' +const updatedAt = '1970-01-01T00:00:00.001Z' + +vi.mock('#app/authenticated-user.ts', () => ({ + readAuthenticatedAppUser: (...args: Array) => + mockModule.readAuthenticatedAppUser(...args), +})) + +vi.mock('#app/auth-session.ts', () => ({ + readAuthSessionResult: (...args: Array) => + mockModule.readAuthSessionResult(...args), +})) + +vi.mock('#app/auth-redirect.ts', () => ({ + redirectToLogin: () => new Response(null, { status: 302 }), +})) + +vi.mock('#app/layout.ts', () => ({ + Layout: () => null, +})) + +vi.mock('#app/render.ts', () => ({ + render: () => new Response('ok'), +})) + +vi.mock('#mcp/values/service.ts', () => ({ + listValues: (...args: Array) => mockModule.listValues(...args), +})) + +const { createAccountIntegrationsApiHandler } = + await import('./account-integrations.ts') + +function createEnv() { + return { + APP_DB: {} as D1Database, + SECRET_STORE_KEY: 'x'.repeat(32), + } as Env +} + +test('integrations API lists valid user-scoped OAuth integrations', async () => { + const handler = createAccountIntegrationsApiHandler(createEnv()) + const response = await handler.handler({ + request: new Request('https://example.com/account/integrations.json'), + params: {}, + } as never) + + expect(response.status).toBe(200) + expect(response.headers.get('Cache-Control')).toBe('no-store') + expect(mockModule.listValues).toHaveBeenCalledWith({ + env: expect.any(Object), + userId: 'stable-user-1', + scope: 'user', + storageContext: { sessionId: null, appId: null }, + }) + await expect(response.json()).resolves.toEqual({ + ok: true, + email: 'user@example.com', + username: 'test-user', + integrations: [ + { + name: 'github', + valueName: '_integration:github', + tokenUrl: 'https://github.com/login/oauth/access_token', + apiBaseUrl: 'https://api.github.com', + flow: 'confidential', + clientIdValueName: 'github-client-id', + clientSecretSecretName: 'githubClientSecret', + accessTokenSecretName: 'githubAccessToken', + refreshTokenSecretName: null, + requiredHosts: ['api.github.com'], + authorization: { + authorizeUrl: 'https://github.com/login/oauth/authorize', + scopes: ['repo', 'read:user'], + scopeSeparator: null, + extraAuthorizeParams: {}, + }, + createdAt, + updatedAt, + }, + ], + }) +}) diff --git a/packages/worker/src/app/handlers/account-integrations.ts b/packages/worker/src/app/handlers/account-integrations.ts new file mode 100644 index 0000000000..c232bdd5e0 --- /dev/null +++ b/packages/worker/src/app/handlers/account-integrations.ts @@ -0,0 +1,114 @@ +import { type Action } from 'remix/router' +import { redirectToLogin } from '#app/auth-redirect.ts' +import { readAuthSessionResult } from '#app/auth-session.ts' +import { readAuthenticatedAppUser } from '#app/authenticated-user.ts' +import { Layout } from '#app/layout.ts' +import { render } from '#app/render.ts' +import { type routes } from '#app/routes.ts' +import { + type IntegrationConfig, + parseIntegrationConfig, + parseIntegrationJson, + parseIntegrationValueName, +} from '#mcp/capabilities/values/integration-shared.ts' +import { listValues } from '#mcp/values/service.ts' + +type AuthenticatedUser = NonNullable< + Awaited> +> + +type AccountIntegrationListItem = IntegrationConfig & { + valueName: string + createdAt: string + updatedAt: string +} + +type AccountIntegrationsPayload = { + ok: true + email: string + username: string + integrations: Array +} + +export function createAccountIntegrationsHandler(_env: Env) { + return { + middleware: [], + async handler({ request }) { + const { session, setCookie } = await readAuthSessionResult(request) + if (!session) { + return redirectToLogin(request) + } + + const response = render(Layout({ title: 'Integrations' })) + if (setCookie) { + response.headers.set('Set-Cookie', setCookie) + } + return response + }, + } satisfies Action +} + +export function createAccountIntegrationsApiHandler(env: Env) { + return { + middleware: [], + async handler({ request }) { + const user = await readAuthenticatedAppUser(request, env) + if (!user) { + return jsonResponse({ ok: false, error: 'Unauthorized.' }, 401) + } + + if (request.method !== 'GET') { + return jsonResponse({ ok: false, error: 'Method not allowed.' }, 405) + } + + return jsonResponse(await buildPayload({ env, user })) + }, + } satisfies Action +} + +async function buildPayload(input: { + env: Env + user: AuthenticatedUser +}): Promise { + const values = await listValues({ + env: input.env, + userId: input.user.mcpUser.userId, + scope: 'user', + storageContext: { sessionId: null, appId: null }, + }) + const integrations = values + .map((value) => { + const integrationName = parseIntegrationValueName(value.name) + if (!integrationName) return null + const config = parseIntegrationConfig( + parseIntegrationJson(value.value), + integrationName, + ) + if (!config) return null + return { + ...config, + valueName: value.name, + createdAt: value.createdAt, + updatedAt: value.updatedAt, + } + }) + .filter((entry): entry is AccountIntegrationListItem => Boolean(entry)) + .sort((left, right) => left.name.localeCompare(right.name)) + + return { + ok: true, + email: input.user.email, + username: input.user.username, + integrations, + } +} + +function jsonResponse(body: Record, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }, + }) +} diff --git a/packages/worker/src/app/router.ts b/packages/worker/src/app/router.ts index ff4c0e59bb..291969fd62 100644 --- a/packages/worker/src/app/router.ts +++ b/packages/worker/src/app/router.ts @@ -1,6 +1,10 @@ import { createRouter } from 'remix/router' import { account } from '#app/handlers/account.ts' import { createAccountDeleteHandler } from '#app/handlers/account-delete.ts' +import { + createAccountIntegrationsApiHandler, + createAccountIntegrationsHandler, +} from '#app/handlers/account-integrations.ts' import { createAccountPackageInvocationTokensApiHandler, createAccountPackageInvocationTokensHandler, @@ -47,6 +51,12 @@ export function createAppRouter(appEnv: AppEnv) { signup, account, accountDelete: createAccountDeleteHandler(appEnv as unknown as Env), + accountIntegrations: createAccountIntegrationsHandler( + appEnv as unknown as Env, + ), + accountIntegrationsApi: createAccountIntegrationsApiHandler( + appEnv as unknown as Env, + ), accountPackageInvocationTokens: createAccountPackageInvocationTokensHandler(appEnv as unknown as Env), accountPackageInvocationTokenNew: diff --git a/packages/worker/src/app/routes.ts b/packages/worker/src/app/routes.ts index 0d7eb9d13c..c812a70b4a 100644 --- a/packages/worker/src/app/routes.ts +++ b/packages/worker/src/app/routes.ts @@ -3,6 +3,8 @@ import { post, route } from 'remix/routes' export const routes = route({ home: '/', connectOauth: '/connect/oauth', + accountIntegrations: '/account/integrations', + accountIntegrationsApi: '/account/integrations.json', accountPackageInvocationTokens: '/account/package-invocation-tokens', accountPackageInvocationTokenNew: '/account/package-invocation-tokens/new', accountPackageInvocationTokensApi: '/account/package-invocation-tokens.json',