Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
268 changes: 140 additions & 128 deletions frontend/snapshots.yml

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions frontend/src/lib/constants.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,7 @@ export const FEATURE_FLAGS = {
POSTHOG_AI_CONVERSATION_FEEDBACK_LLMA_SESSIONS: 'posthog-ai-conversation-feedback-llma-sessions', // owner: #team-posthog-ai
POSTHOG_AI_QUEUE_MESSAGES_SYSTEM: 'posthog-ai-queue-messages-system', // owner: #team-posthog-ai
POSTHOG_CODE_BILLING: 'posthog-code-billing', // owner: #team-desktop
POSTHOG_CODE_CODEX_OWN_SUBSCRIPTION_CLOUD: 'posthog-code-codex-own-subscription-cloud', // owner: #team-desktop, gates the Codex subscription settings and the composer billing row
POSTHOG_CODE_TASK_ANALYSIS: 'posthog-code-task-analysis', // owner: #team-desktop
POSTHOG_CONNECT: 'posthog-connect', // owner: @Gilbert09, gates the "connect another PostHog project" personal integration
POSTHOG_DESKTOP_CLOUD_COMPUTE_BILLING: 'posthog-desktop-cloud-compute-billing', // owner: #team-desktop
Expand Down
18 changes: 18 additions & 0 deletions frontend/src/scenes/settings/SettingsMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ import { MCPHintsSetting } from './user/MCPHintsSetting'
import { OptOutCapture } from './user/OptOutCapture'
import { PasskeySettings } from './user/PasskeySettings'
import { PersonalAPIKeys } from './user/PersonalAPIKeys'
import { PersonalCodexIntegration } from './user/PersonalCodexIntegration'
import { PersonalGitHubIntegrations, PersonalSlackIntegrations } from './user/PersonalIntegrations'
import { ProfilePictureSettings } from './user/ProfilePictureSettings'
import { RealtimeNotificationPreferences } from './user/RealtimeNotificationPreferences'
Expand Down Expand Up @@ -402,6 +403,23 @@ export const SETTINGS_MAP: SettingSection[] = [
},
],
},
{
level: 'environment',
id: 'environment-ai-subscriptions',
title: 'Subscriptions',
group: 'AI',
flag: 'POSTHOG_CODE_CODEX_OWN_SUBSCRIPTION_CLOUD',
settings: [
{
id: 'ai-subscription-codex',
title: 'Codex',
description:
'Connect your ChatGPT account so your Codex cloud tasks can run on your own ChatGPT plan. The connection is yours and applies in every project.',
component: <PersonalCodexIntegration />,
keywords: ['codex', 'chatgpt', 'openai', 'subscription', 'plan', 'billing', 'cloud', 'personal'],
},
],
},
{
level: 'environment',
id: 'posthog-mcp',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { MOCK_DEFAULT_TEAM } from 'lib/api.mock'

import type { Meta, StoryObj } from '@storybook/react'
import { within } from '@testing-library/dom'
import userEvent from '@testing-library/user-event'
import { useActions } from 'kea'
import { router } from 'kea-router'
import { useEffect, useState } from 'react'
Expand Down Expand Up @@ -255,3 +257,55 @@ export const SettingsEnvironmentBusinessKnowledgeLearningOnSupportOff: Story = {
}),
],
}

const codexSubscriptionMocks = (codex: Record<string, unknown>): Record<string, Record<string, unknown>> => ({
get: {
'/api/users/@me/integrations/codex/': codex,
},
})

export const SettingsEnvironmentAiSubscriptionsCodexConnected: Story = {
args: { sectionId: 'environment-ai-subscriptions' },
parameters: {
msw: {
mocks: codexSubscriptionMocks({
status: 'connected',
plan_type: 'pro',
email: 'jane@example.com',
connected_at: '2023-05-20T10:00:00Z',
}),
},
},
}

export const SettingsEnvironmentAiSubscriptionsCodexReauthRequired: Story = {
args: { sectionId: 'environment-ai-subscriptions' },
parameters: {
msw: {
mocks: codexSubscriptionMocks({
status: 'reauth_required',
plan_type: 'plus',
email: 'jane@example.com',
connected_at: '2023-05-20T10:00:00Z',
}),
},
},
}

export const SettingsEnvironmentAiSubscriptionsCodexConnectModal: Story = {
args: { sectionId: 'environment-ai-subscriptions' },
parameters: {
msw: {
mocks: codexSubscriptionMocks({
status: 'not_connected',
plan_type: null,
email: null,
connected_at: null,
}),
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
await userEvent.click(await canvas.findByText('Connect Codex'))
},
}
2 changes: 2 additions & 0 deletions frontend/src/scenes/settings/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export type SettingSectionId =
| 'environment-secret-api-keys'
| 'environment-surveys'
| 'environment-task-agents'
| 'environment-ai-subscriptions'
| 'environment-tracing'
| 'environment-web-analytics'
| 'environment-workflows'
Expand Down Expand Up @@ -289,6 +290,7 @@ export type SettingId =
| 'surveys-default-appearance'
| 'surveys-interface'
| 'task-agent-my-preference'
| 'ai-subscription-codex'
| 'task-agent-project-default'
| 'theme'
| 'tracing-distinct-id-attribute-keys'
Expand Down
137 changes: 137 additions & 0 deletions frontend/src/scenes/settings/user/CodexConnectModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { useActions, useValues } from 'kea'

import { IconCheck } from '@posthog/icons'
import { LemonBanner, LemonButton, LemonInput, LemonModal, LemonSegmentedButton, Link } from '@posthog/lemon-ui'

import { CodeSnippet, Language } from 'lib/components/CodeSnippet'
import { platformCommandControlKey } from 'lib/utils/dom'

import { CODEX_LOGIN_PLATFORM_OPTIONS, codexLoginCommand } from './codexLoginCommands'
import { personalCodexIntegrationLogic } from './personalCodexIntegrationLogic'

const PLATFORM_HINTS = {
macos: 'Run it in Terminal.',
linux: 'Run it in a terminal. It needs wl-clipboard, xclip, or xsel to copy to the clipboard.',
windows: 'Run it in PowerShell.',
}

export const SETTINGS_CODEX_CONNECT_OPENER = 'settings'

export interface CodexConnectModalProps {
/** Several surfaces can show the dialog at once. Each opens only for the opener that asked for it. */
opener: string
}

export function CodexConnectModal({ opener }: CodexConnectModalProps): JSX.Element {
const { connectModalOpener, authFileText, connectError, connecting, loginPlatform } =
useValues(personalCodexIntegrationLogic)
const { closeConnectModal, setLoginPlatform, commandCopied, pasteAuthFile, pasteFromClipboard, submitAuthFile } =
useActions(personalCodexIntegrationLogic)
const pasted = authFileText.length > 0
Comment thread
puemos marked this conversation as resolved.

return (
<LemonModal
isOpen={connectModalOpener === opener}
onClose={closeConnectModal}
closable={!connecting}
title="Connect Codex"
description="Sign in to ChatGPT with a device code in your terminal. Then paste the sign-in here."
width={560}
footer={
<>
<LemonButton
type="secondary"
onClick={closeConnectModal}
disabledReason={connecting ? 'Connecting…' : undefined}
>
Cancel
</LemonButton>
<LemonButton
type="primary"
onClick={submitAuthFile}
loading={connecting}
disabledReason={!pasted ? 'Paste the sign-in first' : undefined}
data-attr="codex-connect-submit"
>
Connect
</LemonButton>
</>
}
>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<h4 className="mb-0">1. Run this command</h4>
<LemonSegmentedButton
size="small"
value={loginPlatform}
onChange={setLoginPlatform}
options={CODEX_LOGIN_PLATFORM_OPTIONS}
data-attr="codex-login-platform"
/>
</div>
<CodeSnippet
language={loginPlatform === 'windows' ? Language.Text : Language.Bash}
compact
wrap
thing="command"
onCopy={commandCopied}
>
{codexLoginCommand(loginPlatform)}
</CodeSnippet>
<p className="mb-0 text-xs text-secondary">
<span>{PLATFORM_HINTS[loginPlatform]}</span> Open the link that it shows, sign in, and enter the
one-time code. The command copies your sign-in to the clipboard and deletes its temporary
folder, so nothing stays on your computer.
</p>
<p className="mb-0 text-xs text-secondary">
You need the{' '}
<Link to="https://developers.openai.com/codex/cli" target="_blank">
Codex CLI
</Link>
. Install it with <code>npm install -g @openai/codex</code>.
</p>
<p className="mb-0 text-xs text-secondary">
Comment thread
puemos marked this conversation as resolved.
Turn on device code login in your ChatGPT security settings first. In a ChatGPT workspace, an
admin turns it on in the workspace permissions.
</p>
</div>
<div className="flex flex-col gap-2">
<h4 className="mb-0">2. Paste the sign-in</h4>
<div className="flex flex-wrap items-center gap-2">
<LemonInput
className="min-w-48 flex-1"
value=""
onChange={() => undefined}
onPaste={(event) => {
event.preventDefault()
pasteAuthFile(event.clipboardData.getData('text'))
}}
placeholder={
pasted ? 'Sign-in received' : `Click here and press ${platformCommandControlKey('V')}`
}
suffix={pasted ? <IconCheck className="text-success" /> : undefined}
autoComplete="off"
spellCheck={false}
disabled={connecting}
data-attr="codex-auth-file-input"
/>
<LemonButton
type="secondary"
onClick={pasteFromClipboard}
disabledReason={connecting ? 'Connecting…' : undefined}
data-attr="codex-paste-from-clipboard"
>
Paste from clipboard
</LemonButton>
</div>
</div>
{connectError ? <LemonBanner type="error">{connectError}</LemonBanner> : null}
<LemonBanner type="info">
PostHog never shows your sign-in and tries to clear your clipboard after you paste. It stores the
sign-in encrypted and uses it only for your Codex cloud tasks. You can disconnect at any time.
</LemonBanner>
</div>
</LemonModal>
)
}
128 changes: 128 additions & 0 deletions frontend/src/scenes/settings/user/PersonalCodexIntegration.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { useActions, useValues } from 'kea'

import { IconPlus, IconTerminal, IconTrash } from '@posthog/icons'
import { LemonBanner, LemonButton, LemonDialog, LemonSkeleton, LemonTag } from '@posthog/lemon-ui'

import { TZLabel } from 'lib/components/TZLabel'

import { CodexIntegrationStatusEnumApi, type UserCodexIntegrationApi } from '~/generated/core/api.schemas'

import { CodexConnectModal, SETTINGS_CODEX_CONNECT_OPENER } from './CodexConnectModal'
import { personalCodexIntegrationLogic } from './personalCodexIntegrationLogic'

function CodexAccountRow({ integration }: { integration: UserCodexIntegrationApi }): JSX.Element {
const { disconnectCodex } = useActions(personalCodexIntegrationLogic)
const { codexIntegrationLoading } = useValues(personalCodexIntegrationLogic)

const handleDisconnect = (): void => {
LemonDialog.open({
title: 'Disconnect Codex?',
description: (
<p>
PostHog removes your ChatGPT sign-in. Codex cloud tasks that use your ChatGPT plan stop working
until you connect again.
</p>
),
primaryButton: {
children: 'Disconnect',
status: 'danger',
onClick: () => disconnectCodex(),
},
secondaryButton: { children: 'Cancel' },
})
}

return (
<div className="flex items-center gap-4 px-4 py-3">
<div className="shrink-0">
<div className="flex h-10 w-10 items-center justify-center rounded-md border bg-surface-secondary text-2xl">
<IconTerminal />
</div>
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold truncate">{integration.email || 'ChatGPT account'}</span>
{integration.plan_type ? (
<LemonTag type="muted" className="capitalize">
{`ChatGPT ${integration.plan_type}`}
</LemonTag>
) : null}
</div>
<div className="mt-0.5 text-xs text-secondary">
{integration.connected_at ? (
<>
<span>Connected </span>
<TZLabel time={integration.connected_at} className="align-baseline" />
</>
) : (
'Connected'
)}
</div>
{integration.status === CodexIntegrationStatusEnumApi.ReauthRequired ? (
<LemonBanner type="error" className="mt-2">
Your ChatGPT sign-in stopped working. Connect Codex again to keep using your ChatGPT plan.
</LemonBanner>
) : null}
</div>
<div className="flex shrink-0 items-center">
<LemonButton
size="small"
type="secondary"
status="danger"
icon={<IconTrash />}
onClick={handleDisconnect}
disabledReason={codexIntegrationLoading ? 'Loading…' : undefined}
tooltip="Disconnect Codex"
data-attr="codex-disconnect"
/>
</div>
</div>
)
}

export function PersonalCodexIntegration(): JSX.Element {
const { codexIntegration, codexIntegrationLoadFailed, connecting } = useValues(personalCodexIntegrationLogic)
const { openConnectModal } = useActions(personalCodexIntegrationLogic)

if (codexIntegration === null) {
return codexIntegrationLoadFailed ? (
<LemonBanner type="error">Could not load your Codex connection. Refresh the page to try again.</LemonBanner>
) : (
<LemonSkeleton className="h-16 w-full" />
)
}

const notConnected = codexIntegration.status === CodexIntegrationStatusEnumApi.NotConnected
const reauthRequired = codexIntegration.status === CodexIntegrationStatusEnumApi.ReauthRequired

return (
<>
<div className="divide-y rounded border bg-surface-primary">
{notConnected ? (
<div className="px-4 py-6 text-center text-sm text-secondary">
<IconTerminal className="text-3xl mb-2 opacity-40" />
<p className="mb-0">No Codex account connected</p>
</div>
) : (
<CodexAccountRow integration={codexIntegration} />
)}
{notConnected || reauthRequired ? (
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-3">
<LemonButton
type="secondary"
size="small"
icon={<IconPlus />}
onClick={() => openConnectModal(SETTINGS_CODEX_CONNECT_OPENER)}
disabledReason={connecting ? 'Connecting…' : undefined}
data-attr="codex-connect"
>
{reauthRequired ? 'Connect again' : 'Connect Codex'}
</LemonButton>
<span className="text-xs text-secondary text-balance">You need a paid ChatGPT plan.</span>
</div>
) : null}
</div>
<CodexConnectModal opener={SETTINGS_CODEX_CONNECT_OPENER} />
</>
)
}
Loading
Loading