Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
621556c
feat(chatgpt): record the plan usage limit and show the partner notices
iscekic Sep 24, 2026
846ec81
feat(chatgpt): add the organization shared-services connection
iscekic Sep 24, 2026
c03fbe6
feat(chatgpt): connect an account for shared services from the organi…
iscekic Sep 24, 2026
2b23c12
chore(kilo-mcp): refresh catalog.json from CI
github-actions[bot] Sep 24, 2026
a34bac2
test(chatgpt): drop the unused react import from the usage link test
iscekic Sep 24, 2026
e8cc7ce
style(chatgpt): format the touched files with oxfmt
iscekic Sep 24, 2026
7490dbc
fix(chatgpt): return the shared-services scope from the linking session
iscekic Sep 24, 2026
31ae7f0
fix(chatgpt): keep the usage link view out of the server-only module
iscekic Sep 24, 2026
7b2c2c7
fix(chatgpt): attribute a plan limit to the connection that served it
iscekic Sep 24, 2026
ff8ce2a
chore(db): regenerate the chatgpt connection migration
iscekic Sep 24, 2026
a36b66a
test(chatgpt): cover the router status usage limit
iscekic Sep 24, 2026
cff7d8a
fix(chatgpt): show the expired ChatGPT connect on the BYOK card
iscekic Sep 24, 2026
557d219
fix(auth): widen the account-linking window to fifteen minutes
iscekic Sep 24, 2026
70dfc58
chore(db): renumber the ChatGPT shared-services migration to 0261
iscekic Sep 24, 2026
bf6d727
fix(chatgpt): keep the organization connection when its connector leaves
iscekic Sep 24, 2026
b350de5
fix(auth): recheck the organization role when the ChatGPT link returns
iscekic Sep 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions apps/web/src/app/(app)/usage/page.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import { Suspense } from 'react';
import { UsageAnalyticsDashboard } from '@/components/usage-analytics/UsageAnalyticsDashboard';
import { ChatGptUsageLink } from '@/components/chatgpt/ChatGptUsageLink';
import { getUserFromAuthOrRedirect } from '@/lib/user/server';

export default async function UsagePage() {
await getUserFromAuthOrRedirect();
const user = await getUserFromAuthOrRedirect();

return (
<Suspense>
<UsageAnalyticsDashboard context="personal" title="Usage" />
<UsageAnalyticsDashboard
context="personal"
title="Usage"
notice={<ChatGptUsageLink kiloUserId={user.id} />}
/>
</Suspense>
);
}
1 change: 1 addition & 0 deletions apps/web/src/app/api/openrouter/[...path]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,7 @@ async function openRouterPost(request: NextRequest): Promise<NextResponseType<un
request: requestBodyParsed,
user,
organizationId,
botId,
taskId,
getRoutingProviderConfig: accessCheckResolver.getRoutingProviderConfig,
});
Expand Down
62 changes: 62 additions & 0 deletions apps/web/src/app/auth/openai/callback/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,68 @@ describe('GET /auth/openai/callback', () => {
expect(location.searchParams.get('openai_error')).toBe('OAuthAccountNotLinked');
});

test('redirects the app sign-in hop that carries a failure code to BYOK', async () => {
mockGetUserFromSession.mockResolvedValue({ id: 'user-1' });
// An expired linking session makes the callback's own `signIn` callback
// refuse the link and redirect to the app's sign-in route; without this the
// person lands on the sign-in page with no card message.
mockNextAuthHttpHandler.mockResolvedValue(
redirectResponse('https://app.kilo.ai/users/sign_in?error=TURNSTILE_REQUIRED')
);
const request = new NextRequest(`${CALLBACK_URL}?code=the-code&state=the-state`);

const response = await GET(request, {});

const location = new URL(response.headers.get('location') ?? '');
expect(location.pathname).toBe('/byok');
expect(location.searchParams.get('openai_error')).toBe('TURNSTILE_REQUIRED');
});

test('redirects the app sign-in hop to the organization BYOK page when the linking session carries one', async () => {
mockGetUserFromSession.mockResolvedValue({ id: 'user-1' });
mockGetAccountLinkingSession.mockResolvedValue({ organizationId: ORG_ID });
mockNextAuthHttpHandler.mockResolvedValue(
redirectResponse('https://app.kilo.ai/users/sign_in?error=TURNSTILE_REQUIRED')
);
const request = new NextRequest(`${CALLBACK_URL}?code=the-code&state=the-state`);

const response = await GET(request, {});

const location = new URL(response.headers.get('location') ?? '');
expect(location.pathname).toBe(`/organizations/${ORG_ID}/byok`);
expect(location.searchParams.get('openai_error')).toBe('TURNSTILE_REQUIRED');
});

test('leaves a code-free app sign-in redirect unchanged', async () => {
mockGetUserFromSession.mockResolvedValue({ id: 'user-1' });
// The app sign-in route is shared with the plain sign-in flow, so a redirect
// there without a failure code is not a connect failure.
const nextAuthResponse = redirectResponse('https://app.kilo.ai/users/sign_in');
mockNextAuthHttpHandler.mockResolvedValue(nextAuthResponse);
const request = new NextRequest(`${CALLBACK_URL}?code=the-code&state=the-state`);

const response = await GET(request, {});

expect(response).toBe(nextAuthResponse);
expect(mockGetUserFromSession).not.toHaveBeenCalled();
});

test('leaves the app sign-in hop untouched without a session', async () => {
mockGetUserFromSession.mockResolvedValue(null);
const nextAuthResponse = redirectResponse(
'https://app.kilo.ai/users/sign_in?error=TURNSTILE_REQUIRED'
);
mockNextAuthHttpHandler.mockResolvedValue(nextAuthResponse);
const request = new NextRequest(`${CALLBACK_URL}?code=the-code&state=the-state`);

const response = await GET(request, {});

expect(response).toBe(nextAuthResponse);
expect(response.headers.get('location')).toBe(
'https://app.kilo.ai/users/sign_in?error=TURNSTILE_REQUIRED'
);
});

test('leaves an error hop without a failure code unchanged', async () => {
mockGetUserFromSession.mockResolvedValue({ id: 'user-1' });
const nextAuthResponse = redirectResponse('https://app.kilo.ai/api/auth/error');
Expand Down
38 changes: 26 additions & 12 deletions apps/web/src/app/auth/openai/callback/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { getUserFromSession, nextAuthHttpHandler } from '@/lib/user/server';
import { getAccountLinkingSession } from '@/lib/account-linking-session';
import { SSO_SIGNIN_PATH } from '@/lib/auth/constants';
import { NextRequest, NextResponse } from 'next/server';

/**
Expand Down Expand Up @@ -32,11 +33,14 @@ const NEXT_AUTH_CALLBACK_CONTEXT = {
};

/**
* The two locations NextAuth's callback action uses to surface an internal
* failure: the `/api/auth/error` hop, which always carries the failure code in
* its `error` query parameter, and the bare `/api/auth/signin` hop it returns
* when the token exchange produced no usable profile or account
* (`next-auth/core/routes/callback.js`).
* The locations a failed callback redirect can land on. NextAuth's callback
* action surfaces an internal failure at `/api/auth/error`, which always
* carries the failure code in its `error` query parameter, and at the bare
* `/api/auth/signin` hop it returns when the token exchange produced no usable
* profile or account (`next-auth/core/routes/callback.js`). The app's own
* sign-in route (`SSO_SIGNIN_PATH`) is the third: the callback's `signIn`
* callback returns it with a failure code when it refuses an authorization
* (`authFailureRedirectUrl` in `@/lib/auth/redirect-urls`).
*/
const NEXT_AUTH_ERROR_PATH = '/api/auth/error';
const NEXT_AUTH_SIGNIN_PATH = '/api/auth/signin';
Expand Down Expand Up @@ -64,16 +68,18 @@ async function redirectToByok(request: NextRequest, errorCode: string): Promise<
/**
* NextAuth reports a callback-stage failure (a rejected or expired code, a
* wrong client secret, a network error during the token exchange) with a 3xx to
* `/api/auth/error`, and a callback whose exchange yields no usable profile
* with a 3xx to `/api/auth/signin` carrying no error code. A person who started
* the connect from the BYOK card would land on the sign-in page with no way
* back and no card message, so this turns either redirect into the card's
* readable failure. The browser follows the error hop itself, so the first
* `/api/auth/error`, a callback whose exchange yields no usable profile with a
* 3xx to `/api/auth/signin` carrying no error code, and a refusal from the
* callback's `signIn` callback with a 3xx to the app's own sign-in route
* carrying the refusal code. A person who started the connect from the BYOK card
* would land on the sign-in page with no way back and no card message, so this
* turns any of those redirects into the card's readable failure. The browser
* follows the error hop itself, so the first
* response is the only chance to catch it here.
*
* Returns null - leaving NextAuth's response untouched - unless this is a real
* authorization-code callback (a sign-in start or a providers fetch must not be
* touched), the failure location is one of NextAuth's two hops, a failure code
* touched), the failure location is one of the known failure hops, a failure
* is available (the hop's own, or the default for the bare sign-in hop), and
* the request still has a session. Without a session this is a failed sign-up,
* which must keep landing on the sign-in page with NextAuth's existing readable
Expand All @@ -97,10 +103,18 @@ async function redirectCallbackFailureToByok(
const failureUrl = new URL(location, request.url);
const isErrorHop = failureUrl.pathname === NEXT_AUTH_ERROR_PATH;
const isSignInHop = failureUrl.pathname === NEXT_AUTH_SIGNIN_PATH;
if (!isErrorHop && !isSignInHop) {
// When the linking session expired, the callback's `signIn` callback refuses
// the link and redirects to the app's sign-in route with a failure code
// (`TURNSTILE_REQUIRED` among others), so that hop is the connect's failure
// and must reach the card like the two NextAuth hops above.
const isAppSignInHop = failureUrl.pathname === SSO_SIGNIN_PATH;
if (!isErrorHop && !isSignInHop && !isAppSignInHop) {
return null;
}
const carriedCode = failureUrl.searchParams.get('error');
// Only the bare NextAuth sign-in hop may fail without a code. The app's sign-in
// route is shared with the plain sign-in flow, so a code-free redirect there
// stays on the sign-in page.
const errorCode = carriedCode || (isSignInHop ? DEFAULT_CALLBACK_FAILURE_CODE : null);
if (!errorCode) {
return null;
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/components/auth/OpenAILogo.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export function OpenAILogo() {
import * as React from 'react';
export function OpenAILogo({ className }: { className?: string } = {}) {
return (
<svg
width="20"
Expand All @@ -7,6 +8,7 @@ export function OpenAILogo() {
viewBox="0 0 24 24"
aria-hidden="true"
fill="currentColor"
className={className}
>
<title>OpenAI logo</title>
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" />
Expand Down
128 changes: 128 additions & 0 deletions apps/web/src/components/chatgpt/ChatGptSharedServicesCard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// The client hooks and the dialog primitives are stubbed before the component
// module is loaded: @swc/jest emits the static imports first, so every render
// goes through the fresh-module helper below.
jest.mock('next/navigation', () => ({
useRouter: () => ({ replace: jest.fn() }),
useSearchParams: () => new URLSearchParams(),
}));

jest.mock('@/components/ui/dialog', () => ({
Dialog: ({ children }: { children: ReactNode }) => children,
DialogContent: ({ children }: { children: ReactNode }) => children,
DialogHeader: ({ children }: { children: ReactNode }) => children,
DialogTitle: ({ children }: { children: ReactNode }) => children,
DialogDescription: ({ children }: { children: ReactNode }) => children,
DialogFooter: ({ children }: { children: ReactNode }) => children,
}));

import { describe, expect, it, jest } from '@jest/globals';
import type { ReactNode } from 'react';
import type { OpenAiChatGptStatus } from '@/lib/ai-gateway/openai-chatgpt/status';
import { CHATGPT_USAGE_SETTINGS_URL } from '@/lib/ai-gateway/openai-chatgpt/usage-limit';
import {
canManageSharedServices,
type ChatGptSharedServicesCardViewProps,
} from './ChatGptSharedServicesCard';

const CARD_TITLE = 'OpenAI (ChatGPT) for shared services';
const CONNECT_LABEL = 'Connect an OpenAI account';
const USAGE_LINK_SUMMARY = 'View and manage your ChatGPT usage';
const CONNECTED_AT = '2026-09-16T00:00:00.000Z';

async function render(props: ChatGptSharedServicesCardViewProps): Promise<string> {
jest.resetModules();
const [card, react, server] = await Promise.all([
import('./ChatGptSharedServicesCard'),
import('react'),
import('react-dom/server'),
]);
return server.renderToStaticMarkup(
react.createElement(card.ChatGptSharedServicesCardView, props)
);
}

function connectedStatus(overrides: Partial<OpenAiChatGptStatus> = {}): OpenAiChatGptStatus {
return {
state: 'connected',
email: 'shared@example.com',
subject: 'subject-1',
connectedAt: CONNECTED_AT,
...overrides,
} as OpenAiChatGptStatus;
}

describe('ChatGptSharedServicesCardView', () => {
it('offers the connect action while no account is connected', async () => {
const html = await render({ status: { state: 'disconnected' } });

expect(html).toContain(CARD_TITLE);
expect(html).toContain(CONNECT_LABEL);
expect(html).toContain('Kilo shared services use it');
expect(html).not.toContain(USAGE_LINK_SUMMARY);
});

it('names the connected account, its scope, the usage link and a disconnect action', async () => {
const html = await render({ status: connectedStatus() });

expect(html).toContain('Connected as shared@example.com');
expect(html).toContain('It is not your own connection.');
expect(html).toContain(USAGE_LINK_SUMMARY);
expect(html).toContain(CHATGPT_USAGE_SETTINGS_URL);
expect(html).toContain('Disconnect');
expect(html).not.toContain(CONNECT_LABEL);
});

it('offers reconnect and disconnect for a failed connection', async () => {
const html = await render({
status: {
state: 'error',
errorMessage: 'Your ChatGPT connection has expired. Reconnect to continue.',
},
});

expect(html).toContain('Your ChatGPT connection has expired. Reconnect to continue.');
expect(html).toContain('Reconnect');
expect(html).toContain('Disconnect');
});

it('shows the usage-limit message for a current plan limit', async () => {
const html = await render({
status: connectedStatus({
usageLimit: { reachedAt: '2026-09-16T13:00:00.000Z', resetsAt: null },
}),
});

expect(html).toContain('ChatGPT usage limit reached');
expect(html).toContain('Review your usage settings in ChatGPT.');
expect(html).toContain('Buy Kilo credits instead');
});

it('hides the usage-limit message once it is dismissed', async () => {
const html = await render({
status: connectedStatus({
usageLimit: { reachedAt: '2026-09-16T13:00:00.000Z', resetsAt: null },
}),
isUsageLimitDismissed: true,
});

expect(html).not.toContain('ChatGPT usage limit reached');
});

it('reports a load failure with a retry', async () => {
const html = await render({ status: undefined, hasLoadError: true });

// The apostrophe is HTML-escaped in the markup, so the stable part is asserted.
expect(html).toContain('load the shared services connection. Try again.');
expect(html).toContain('Try again');
});
});

describe('canManageSharedServices', () => {
it('is true only for an owner or an admin', () => {
expect(canManageSharedServices('owner')).toBe(true);
expect(canManageSharedServices('admin')).toBe(true);
expect(canManageSharedServices('member')).toBe(false);
expect(canManageSharedServices('billing_manager')).toBe(false);
expect(canManageSharedServices(undefined)).toBe(false);
});
});
Loading