diff --git a/.env.example b/.env.example
index 8e47349..3f577a0 100644
--- a/.env.example
+++ b/.env.example
@@ -1,4 +1,6 @@
-JWT_SECRET=change-me
-ENCRYPTION_SECRET=change-me-32-characters-minimum
+JWT_SECRET=***
+ENCRYPTION_SECRET=change-this-to-at-least-32-characters
DATABASE_URL=file:./dev.db
+CODEXDASH_FRONTEND_ORIGIN=http://localhost:5173
+CODEX_OAUTH_REDIRECT_URI=http://localhost:1455/auth/callback
VITE_API_BASE_URL=http://localhost:3001
diff --git a/README.md b/README.md
index 1b685b6..554b7fa 100644
--- a/README.md
+++ b/README.md
@@ -12,29 +12,32 @@ CodexDash is a mobile-first dashboard for monitoring multiple OpenAI Codex accou
## What it does
- Create a CodexDash account and sign in
-- Connect multiple OpenAI Codex sessions under one CodexDash account
-- Refresh `https://chatgpt.com/backend-api/api/codex/usage` for each connected OpenAI account
-- Merge numeric usage fields into one aggregate dashboard
+- Connect multiple OpenAI Codex accounts under one CodexDash account
+- Start an integrated OpenAI login popup instead of pasting cookies manually
+- Refresh Codex usage data and merge numeric usage fields into one aggregate dashboard
- Inspect each connected account individually with raw API payload details
-## Important note about "OpenAI Codex login"
+## OpenAI/Codex login flow
-OpenAI does not expose a simple third-party OAuth flow for this usage endpoint.
+CodexDash now reuses the public-client OAuth/PKCE shape found in [`darvell/codex-pool`](https://github.com/darvell/codex-pool), but wraps it in an app-native flow:
-This MVP implements OpenAI account connection as a **session-based login flow**:
+1. The user clicks **Connect OpenAI account**.
+2. CodexDash API creates a short-lived PKCE login attempt.
+3. The web app opens the OpenAI authorization page in a popup.
+4. After successful login, OpenAI redirects back to the local callback bridge at `http://localhost:1455/auth/callback`.
+5. The callback bridge exchanges the authorization code for tokens, encrypts the session JSON in SQLite, and posts the result back to the main app window.
+6. CodexDash refreshes usage using the saved OAuth session and shows both the aggregate view and per-account details.
-1. Sign in to `chatgpt.com` in your browser
-2. Copy the authenticated `Cookie` header
-3. Paste it into the **Connect OpenAI account** dialog in CodexDash
+### Important local-dev note
-The backend encrypts the cookie header before storing it in SQLite.
+This flow depends on the local callback bridge being reachable on `localhost:1455`. In local development, make sure that port is free before starting the API.
## Local development
```bash
pnpm install
pnpm --filter @codexdash/api exec prisma generate
-cd apps/api && DATABASE_URL=file:./dev.db pnpm exec prisma db push
+cd apps/api && DATABASE_URL=file:./dev.db pnpm exec prisma db push --accept-data-loss
cd ../..
pnpm --filter @codexdash/api start:dev
pnpm --filter @codexdash/web dev --host 0.0.0.0
@@ -42,17 +45,14 @@ pnpm --filter @codexdash/web dev --host 0.0.0.0
## Environment variables
-### `apps/api/.env`
+### Root `.env`
```env
-JWT_SECRET=dev-jwt-secret-for-codexdash
-ENCRYPTION_SECRET=dev-encryption-secret-for-codexdash-32chars
+JWT_SECRET=***
+ENCRYPTION_SECRET=***
DATABASE_URL=file:./dev.db
-```
-
-### `apps/web/.env`
-
-```env
+CODEXDASH_FRONTEND_ORIGIN=http://localhost:5173
+CODEX_OAUTH_REDIRECT_URI=http://localhost:1455/auth/callback
VITE_API_BASE_URL=http://localhost:3001
```
@@ -71,6 +71,10 @@ curl http://localhost:3001/health
- `POST /auth/login`
- `GET /auth/me`
- `GET /codex/accounts`
-- `POST /codex/accounts`
+- `POST /codex/accounts/login/start`
+- `GET /codex/accounts/login/attempts/:attemptId`
+- `DELETE /codex/accounts/login/attempts/:attemptId`
+- `GET /codex/accounts/login/callback`
+- `GET /codex/accounts`
- `DELETE /codex/accounts/:accountId`
- `GET /codex/usage-summary`
diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma
index 4b2587e..d2a1492 100644
--- a/apps/api/prisma/schema.prisma
+++ b/apps/api/prisma/schema.prisma
@@ -8,28 +8,57 @@ datasource db {
}
model User {
- id String @id @default(cuid())
- email String @unique
- passwordHash String
- name String
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
- accounts OpenAiAccount[]
+ id String @id @default(cuid())
+ email String @unique
+ passwordHash String
+ name String
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ accounts OpenAiAccount[]
+ loginAttempts OpenAiLoginAttempt[]
}
model OpenAiAccount {
- id String @id @default(cuid())
- userId String
- label String
- emailHint String?
- encryptedCookie String
- lastUsageJson Json?
- lastSyncedAt DateTime?
- lastError String?
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
+ id String @id @default(cuid())
+ userId String
+ label String
+ emailHint String?
+ providerEmail String?
+ providerAccountId String?
+ planType String?
+ authType String @default("codex-oauth")
+ encryptedSessionJson String
+ sessionExpiresAt DateTime?
+ lastValidatedAt DateTime?
+ lastUsageJson Json?
+ lastSyncedAt DateTime?
+ lastError String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ loginAttempts OpenAiLoginAttempt[]
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
-}
\ No newline at end of file
+}
+
+model OpenAiLoginAttempt {
+ id String @id @default(cuid())
+ userId String
+ accountId String?
+ label String
+ emailHint String?
+ status String @default("pending")
+ state String @unique
+ encryptedCodeVerifier String
+ expiresAt DateTime
+ completedAt DateTime?
+ lastError String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ account OpenAiAccount? @relation(fields: [accountId], references: [id], onDelete: SetNull)
+
+ @@index([userId, status])
+}
diff --git a/apps/api/src/codex/codex-oauth.spec.ts b/apps/api/src/codex/codex-oauth.spec.ts
new file mode 100644
index 0000000..2b01edc
--- /dev/null
+++ b/apps/api/src/codex/codex-oauth.spec.ts
@@ -0,0 +1,83 @@
+import {
+ buildCodexAuthorizeUrl,
+ createPkcePair,
+ extractCodexIdentity,
+ renderCodexOauthCallbackHtml,
+} from './codex-oauth';
+
+describe('codex-oauth helpers', () => {
+ it('builds the OpenAI authorize URL with Codex-specific PKCE parameters', () => {
+ const url = new URL(
+ buildCodexAuthorizeUrl({
+ state: 'state-123',
+ codeChallenge: 'challenge-abc',
+ redirectUri: 'http://localhost:1455/auth/callback',
+ }),
+ );
+
+ expect(url.origin + url.pathname).toBe(
+ 'https://auth.openai.com/oauth/authorize',
+ );
+ expect(url.searchParams.get('response_type')).toBe('code');
+ expect(url.searchParams.get('client_id')).toBe(
+ 'app_EMoamEEZ73f0CkXaXp7hrann',
+ );
+ expect(url.searchParams.get('redirect_uri')).toBe(
+ 'http://localhost:1455/auth/callback',
+ );
+ expect(url.searchParams.get('scope')).toBe(
+ 'openid profile email offline_access',
+ );
+ expect(url.searchParams.get('code_challenge')).toBe('challenge-abc');
+ expect(url.searchParams.get('code_challenge_method')).toBe('S256');
+ expect(url.searchParams.get('state')).toBe('state-123');
+ expect(url.searchParams.get('originator')).toBe('codex_cli_rs');
+ expect(url.searchParams.get('codex_cli_simplified_flow')).toBe('true');
+ expect(url.searchParams.get('id_token_add_organizations')).toBe('true');
+ });
+
+ it('creates a valid PKCE verifier/challenge pair', () => {
+ const pair = createPkcePair(Buffer.alloc(32, 7));
+
+ expect(pair.verifier).toBe('BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc');
+ expect(pair.challenge).toBe('3Ev4DHdHPRMPoN6GukAY_pi7IUAF5qWJHRK6kURvnoE');
+ });
+
+ it('extracts account identity fields from the id token payload', () => {
+ const header = Buffer.from(JSON.stringify({ alg: 'none' })).toString(
+ 'base64url',
+ );
+ const payload = Buffer.from(
+ JSON.stringify({
+ email: 'operator@example.com',
+ exp: 1_800_000_000,
+ 'https://api.openai.com/auth': {
+ chatgpt_account_id: 'acct_123',
+ chatgpt_plan_type: 'pro',
+ },
+ }),
+ ).toString('base64url');
+ const token = `${header}.${payload}.signature`;
+
+ expect(extractCodexIdentity(token)).toEqual({
+ email: 'operator@example.com',
+ accountId: 'acct_123',
+ planType: 'pro',
+ expiresAt: new Date(1_800_000_000 * 1000),
+ });
+ });
+
+ it('renders callback html that reports completion back to the frontend window', () => {
+ const html = renderCodexOauthCallbackHtml({
+ attemptId: 'attempt_123',
+ status: 'success',
+ frontendOrigin: 'http://localhost:5173',
+ message: 'Connected successfully',
+ });
+
+ expect(html).toContain('codexdash:oauth-complete');
+ expect(html).toContain('attempt_123');
+ expect(html).toContain('Connected successfully');
+ expect(html).toContain('http://localhost:5173');
+ });
+});
diff --git a/apps/api/src/codex/codex-oauth.ts b/apps/api/src/codex/codex-oauth.ts
new file mode 100644
index 0000000..d720e4a
--- /dev/null
+++ b/apps/api/src/codex/codex-oauth.ts
@@ -0,0 +1,178 @@
+import { createHash } from 'node:crypto';
+
+export const CODEX_OAUTH_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
+export const CODEX_OAUTH_AUTHORIZE_URL =
+ 'https://auth.openai.com/oauth/authorize';
+export const CODEX_OAUTH_TOKEN_URL = 'https://auth.openai.com/oauth/token';
+export const CODEX_OAUTH_DEFAULT_REDIRECT_URI =
+ 'http://localhost:1455/auth/callback';
+
+export type CodexIdentity = {
+ email: string | null;
+ accountId: string | null;
+ planType: string | null;
+ expiresAt: Date | null;
+};
+
+export function createPkcePair(bytes: Uint8Array) {
+ const verifier = Buffer.from(bytes).toString('base64url');
+ const challenge = createHash('sha256').update(verifier).digest('base64url');
+
+ return { verifier, challenge };
+}
+
+export function buildCodexAuthorizeUrl(input: {
+ state: string;
+ codeChallenge: string;
+ redirectUri?: string;
+}) {
+ const url = new URL(CODEX_OAUTH_AUTHORIZE_URL);
+ url.searchParams.set('response_type', 'code');
+ url.searchParams.set('client_id', CODEX_OAUTH_CLIENT_ID);
+ url.searchParams.set(
+ 'redirect_uri',
+ input.redirectUri ?? CODEX_OAUTH_DEFAULT_REDIRECT_URI,
+ );
+ url.searchParams.set('scope', 'openid profile email offline_access');
+ url.searchParams.set('code_challenge', input.codeChallenge);
+ url.searchParams.set('code_challenge_method', 'S256');
+ url.searchParams.set('id_token_add_organizations', 'true');
+ url.searchParams.set('codex_cli_simplified_flow', 'true');
+ url.searchParams.set('state', input.state);
+ url.searchParams.set('originator', 'codex_cli_rs');
+
+ return url.toString();
+}
+
+export function extractCodexIdentity(idToken: string): CodexIdentity {
+ const payload = parseJwtPayload(idToken);
+ if (!payload) {
+ return {
+ email: null,
+ accountId: null,
+ planType: null,
+ expiresAt: null,
+ };
+ }
+
+ const authClaim = readRecord(payload['https://api.openai.com/auth']);
+ const profileClaim = readRecord(payload['https://api.openai.com/profile']);
+ const email =
+ readString(profileClaim?.email) ?? readString(payload.email) ?? null;
+ const accountId =
+ readString(authClaim?.chatgpt_account_id) ??
+ readString(payload.chatgpt_account_id) ??
+ null;
+ const planType =
+ readString(authClaim?.chatgpt_plan_type) ??
+ readString(payload.chatgpt_plan_type) ??
+ null;
+ const exp = readNumber(payload.exp);
+
+ return {
+ email,
+ accountId,
+ planType,
+ expiresAt: exp ? new Date(exp * 1000) : null,
+ };
+}
+
+export function renderCodexOauthCallbackHtml(input: {
+ attemptId: string;
+ status: 'success' | 'error';
+ frontendOrigin: string;
+ message: string;
+}) {
+ const payload = JSON.stringify({
+ type: 'codexdash:oauth-complete',
+ attemptId: input.attemptId,
+ status: input.status,
+ message: input.message,
+ });
+
+ return `
+
+
+
+
+ CodexDash login
+
+
+
+
+ ${escapeHtml(input.status === 'success' ? 'Connected to CodexDash' : 'CodexDash login failed')}
+ ${escapeHtml(input.message)}
+
+
+
+`;
+}
+
+function parseJwtPayload(token: string): Record | null {
+ const [, payload] = token.split('.');
+ if (!payload) {
+ return null;
+ }
+
+ try {
+ return JSON.parse(
+ Buffer.from(payload, 'base64url').toString('utf8'),
+ ) as Record;
+ } catch {
+ return null;
+ }
+}
+
+function readRecord(value: unknown): Record | null {
+ return value && typeof value === 'object' && !Array.isArray(value)
+ ? (value as Record)
+ : null;
+}
+
+function readString(value: unknown): string | null {
+ return typeof value === 'string' && value.trim().length > 0 ? value : null;
+}
+
+function readNumber(value: unknown): number | null {
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
+}
+
+function escapeHtml(value: string) {
+ return value
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''');
+}
diff --git a/apps/api/src/codex/codex.controller.ts b/apps/api/src/codex/codex.controller.ts
index b0e7025..7a86623 100644
--- a/apps/api/src/codex/codex.controller.ts
+++ b/apps/api/src/codex/codex.controller.ts
@@ -6,34 +6,71 @@ import {
Param,
Post,
Query,
+ Res,
UseGuards,
} from '@nestjs/common';
+import type { Response } from 'express';
import {
CurrentUser,
type AuthenticatedUser,
} from '../common/current-user.decorator';
import { JwtAuthGuard } from '../common/jwt-auth.guard';
import { CodexService } from './codex.service';
-import { ConnectAccountDto } from './dto/connect-account.dto';
+import { StartCodexLoginDto } from './dto/start-codex-login.dto';
-@UseGuards(JwtAuthGuard)
@Controller('codex')
export class CodexController {
constructor(private readonly codexService: CodexService) {}
+ @UseGuards(JwtAuthGuard)
@Get('accounts')
listAccounts(@CurrentUser() user: AuthenticatedUser) {
return this.codexService.listAccounts(user.sub);
}
- @Post('accounts')
- connectAccount(
+ @UseGuards(JwtAuthGuard)
+ @Post('accounts/login/start')
+ startAccountLogin(
@CurrentUser() user: AuthenticatedUser,
- @Body() dto: ConnectAccountDto,
+ @Body() dto: StartCodexLoginDto,
) {
- return this.codexService.connectAccount(user.sub, dto);
+ return this.codexService.startAccountLogin(user.sub, dto);
}
+ @UseGuards(JwtAuthGuard)
+ @Get('accounts/login/:attemptId')
+ getLoginAttempt(
+ @CurrentUser() user: AuthenticatedUser,
+ @Param('attemptId') attemptId: string,
+ ) {
+ return this.codexService.getLoginAttempt(user.sub, attemptId);
+ }
+
+ @UseGuards(JwtAuthGuard)
+ @Post('accounts/login/:attemptId/cancel')
+ cancelLoginAttempt(
+ @CurrentUser() user: AuthenticatedUser,
+ @Param('attemptId') attemptId: string,
+ ) {
+ return this.codexService.cancelLoginAttempt(user.sub, attemptId);
+ }
+
+ @Get('accounts/login/callback')
+ async oauthCallback(
+ @Query() query: Record,
+ @Res() res: Response,
+ ) {
+ const search = new URLSearchParams(query);
+ const result = await this.codexService.handleOauthCallbackRequest(
+ `${search.size > 0 ? `?${search.toString()}` : ''}`,
+ );
+ return res
+ .status(result.statusCode)
+ .contentType('text/html')
+ .send(result.html);
+ }
+
+ @UseGuards(JwtAuthGuard)
@Delete('accounts/:accountId')
deleteAccount(
@CurrentUser() user: AuthenticatedUser,
@@ -42,6 +79,7 @@ export class CodexController {
return this.codexService.deleteAccount(user.sub, accountId);
}
+ @UseGuards(JwtAuthGuard)
@Get('usage-summary')
getUsageSummary(
@CurrentUser() user: AuthenticatedUser,
diff --git a/apps/api/src/codex/codex.service.ts b/apps/api/src/codex/codex.service.ts
index ed2fca2..67fcff4 100644
--- a/apps/api/src/codex/codex.service.ts
+++ b/apps/api/src/codex/codex.service.ts
@@ -1,3 +1,4 @@
+import { randomBytes } from 'node:crypto';
import {
BadRequestException,
Injectable,
@@ -5,14 +6,41 @@ import {
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Prisma } from '@prisma/client';
-import { ConnectedAccount, UsageSummary } from '@codexdash/shared-types';
+import type {
+ CodexLoginAttemptResponse,
+ ConnectedAccount,
+ StartCodexLoginResponse,
+ UsageSummary,
+} from '@codexdash/shared-types';
import { decryptString, encryptString } from '../common/crypto';
import { PrismaService } from '../prisma/prisma.service';
-import { ConnectAccountDto } from './dto/connect-account.dto';
+import { StartCodexLoginDto } from './dto/start-codex-login.dto';
+import {
+ buildCodexAuthorizeUrl,
+ CODEX_OAUTH_CLIENT_ID,
+ CODEX_OAUTH_DEFAULT_REDIRECT_URI,
+ CODEX_OAUTH_TOKEN_URL,
+ createPkcePair,
+ extractCodexIdentity,
+ renderCodexOauthCallbackHtml,
+} from './codex-oauth';
import { aggregateUsagePayloads } from './usage-aggregator';
type UsageApiResponse = Record;
+type StoredCodexSession = {
+ accessToken: string;
+ refreshToken: string;
+ idToken: string;
+ accountId: string | null;
+};
+
+type CodexTokenResponse = {
+ access_token: string;
+ refresh_token?: string;
+ id_token?: string;
+};
+
@Injectable()
export class CodexService {
constructor(
@@ -20,26 +48,86 @@ export class CodexService {
private readonly configService: ConfigService,
) {}
- async connectAccount(
+ async startAccountLogin(
userId: string,
- dto: ConnectAccountDto,
- ): Promise {
- const usage = await this.fetchUsage(dto.cookieHeader);
- const account = await this.prisma.openAiAccount.create({
+ dto: StartCodexLoginDto,
+ ): Promise {
+ const pkce = createPkcePair(randomBytes(32));
+ const state = randomBytes(32).toString('base64url');
+ const expiresAt = new Date(Date.now() + 10 * 60 * 1000);
+ const attempt = await this.prisma.openAiLoginAttempt.create({
data: {
userId,
label: dto.label.trim(),
emailHint: dto.emailHint?.trim() || null,
- encryptedCookie: encryptString(
- dto.cookieHeader,
+ state,
+ encryptedCodeVerifier: encryptString(
+ pkce.verifier,
this.getEncryptionSecret(),
),
- lastUsageJson: usage as Prisma.InputJsonValue,
- lastSyncedAt: new Date(),
+ expiresAt,
},
});
- return this.toAccountView(account);
+ return {
+ attemptId: attempt.id,
+ authorizeUrl: buildCodexAuthorizeUrl({
+ state,
+ codeChallenge: pkce.challenge,
+ redirectUri: this.getOauthRedirectUri(),
+ }),
+ expiresAt: expiresAt.toISOString(),
+ };
+ }
+
+ async getLoginAttempt(
+ userId: string,
+ attemptId: string,
+ ): Promise {
+ const attempt = await this.prisma.openAiLoginAttempt.findFirst({
+ where: { id: attemptId, userId },
+ include: { account: true },
+ });
+ if (!attempt) {
+ throw new NotFoundException('Login attempt not found');
+ }
+
+ if (
+ attempt.status === 'pending' &&
+ attempt.expiresAt.getTime() < Date.now()
+ ) {
+ const expired = await this.prisma.openAiLoginAttempt.update({
+ where: { id: attempt.id },
+ data: { status: 'expired', lastError: 'Login window expired.' },
+ include: { account: true },
+ });
+ return this.toLoginAttemptView(expired);
+ }
+
+ return this.toLoginAttemptView(attempt);
+ }
+
+ async cancelLoginAttempt(userId: string, attemptId: string) {
+ const attempt = await this.prisma.openAiLoginAttempt.findFirst({
+ where: { id: attemptId, userId },
+ });
+ if (!attempt) {
+ throw new NotFoundException('Login attempt not found');
+ }
+
+ if (attempt.status !== 'pending') {
+ return { ok: true };
+ }
+
+ await this.prisma.openAiLoginAttempt.update({
+ where: { id: attempt.id },
+ data: {
+ status: 'cancelled',
+ lastError: 'Login cancelled by user.',
+ },
+ });
+
+ return { ok: true };
}
async listAccounts(userId: string) {
@@ -59,6 +147,10 @@ export class CodexService {
throw new NotFoundException('Connected account not found');
}
+ await this.prisma.openAiLoginAttempt.updateMany({
+ where: { accountId },
+ data: { accountId: null },
+ });
await this.prisma.openAiAccount.delete({ where: { id: accountId } });
return { ok: true };
}
@@ -76,17 +168,8 @@ export class CodexService {
}
try {
- const usage = await this.fetchUsage(
- decryptString(account.encryptedCookie, this.getEncryptionSecret()),
- );
- return this.prisma.openAiAccount.update({
- where: { id: account.id },
- data: {
- lastUsageJson: usage as Prisma.InputJsonValue,
- lastSyncedAt: new Date(),
- lastError: null,
- },
- });
+ const updated = await this.refreshAccountUsage(account);
+ return updated;
} catch (error) {
return this.prisma.openAiAccount.update({
where: { id: account.id },
@@ -121,25 +204,371 @@ export class CodexService {
};
}
- private async fetchUsage(cookieHeader: string): Promise {
- const response = await fetch(
- 'https://chatgpt.com/backend-api/api/codex/usage',
- {
- headers: {
- accept: 'application/json',
- cookie: cookieHeader,
- 'user-agent': 'CodexDash/0.1 (+https://example.invalid)',
+ async handleOauthCallbackRequest(rawUrl: string) {
+ const callbackUrl = new URL(rawUrl, this.getOauthRedirectUri());
+ const code = callbackUrl.searchParams.get('code');
+ const state = callbackUrl.searchParams.get('state');
+ const oauthError = callbackUrl.searchParams.get('error');
+ const oauthErrorDescription =
+ callbackUrl.searchParams.get('error_description');
+
+ if (!state) {
+ return this.renderCallbackPage({
+ attemptId: 'unknown',
+ status: 'error',
+ message: 'Missing OAuth state.',
+ });
+ }
+
+ const attempt = await this.prisma.openAiLoginAttempt.findUnique({
+ where: { state },
+ });
+ if (!attempt) {
+ return this.renderCallbackPage({
+ attemptId: 'unknown',
+ status: 'error',
+ message: 'This CodexDash login attempt no longer exists.',
+ });
+ }
+
+ if (attempt.status !== 'pending') {
+ return this.renderCallbackPage({
+ attemptId: attempt.id,
+ status: attempt.status === 'completed' ? 'success' : 'error',
+ message:
+ attempt.status === 'completed'
+ ? 'This OpenAI account is already connected.'
+ : attempt.lastError || 'This login attempt is no longer active.',
+ });
+ }
+
+ if (attempt.expiresAt.getTime() < Date.now()) {
+ await this.prisma.openAiLoginAttempt.update({
+ where: { id: attempt.id },
+ data: { status: 'expired', lastError: 'Login window expired.' },
+ });
+ return this.renderCallbackPage({
+ attemptId: attempt.id,
+ status: 'error',
+ message: 'This login window has expired. Please start again.',
+ });
+ }
+
+ if (oauthError) {
+ const message = oauthErrorDescription
+ ? `${oauthError}: ${oauthErrorDescription}`
+ : oauthError;
+ await this.prisma.openAiLoginAttempt.update({
+ where: { id: attempt.id },
+ data: { status: 'error', lastError: message },
+ });
+ return this.renderCallbackPage({
+ attemptId: attempt.id,
+ status: 'error',
+ message,
+ });
+ }
+
+ if (!code) {
+ await this.prisma.openAiLoginAttempt.update({
+ where: { id: attempt.id },
+ data: {
+ status: 'error',
+ lastError: 'Missing authorization code from OpenAI.',
},
+ });
+ return this.renderCallbackPage({
+ attemptId: attempt.id,
+ status: 'error',
+ message: 'Missing authorization code from OpenAI.',
+ });
+ }
+
+ try {
+ const verifier = decryptString(
+ attempt.encryptedCodeVerifier,
+ this.getEncryptionSecret(),
+ );
+ const tokenResponse = await this.exchangeAuthorizationCode(
+ code,
+ verifier,
+ );
+ const identity = extractCodexIdentity(tokenResponse.id_token ?? '');
+ let session: StoredCodexSession = {
+ accessToken: tokenResponse.access_token,
+ refreshToken: tokenResponse.refresh_token ?? '',
+ idToken: tokenResponse.id_token ?? '',
+ accountId: identity.accountId,
+ };
+ const usage = await this.fetchUsageWithSession(session);
+ session = usage.session;
+
+ const sessionIdentity = extractCodexIdentity(session.idToken);
+ const existing = sessionIdentity.accountId
+ ? await this.prisma.openAiAccount.findFirst({
+ where: {
+ userId: attempt.userId,
+ providerAccountId: sessionIdentity.accountId,
+ },
+ })
+ : null;
+
+ const account = existing
+ ? await this.prisma.openAiAccount.update({
+ where: { id: existing.id },
+ data: {
+ label: attempt.label,
+ emailHint: attempt.emailHint,
+ providerEmail: sessionIdentity.email,
+ providerAccountId: sessionIdentity.accountId,
+ planType: sessionIdentity.planType,
+ encryptedSessionJson: encryptString(
+ JSON.stringify(session),
+ this.getEncryptionSecret(),
+ ),
+ sessionExpiresAt: sessionIdentity.expiresAt,
+ lastValidatedAt: new Date(),
+ lastUsageJson: usage.payload as Prisma.InputJsonValue,
+ lastSyncedAt: new Date(),
+ lastError: null,
+ },
+ })
+ : await this.prisma.openAiAccount.create({
+ data: {
+ userId: attempt.userId,
+ label: attempt.label,
+ emailHint: attempt.emailHint,
+ providerEmail: sessionIdentity.email,
+ providerAccountId: sessionIdentity.accountId,
+ planType: sessionIdentity.planType,
+ authType: 'codex-oauth',
+ encryptedSessionJson: encryptString(
+ JSON.stringify(session),
+ this.getEncryptionSecret(),
+ ),
+ sessionExpiresAt: sessionIdentity.expiresAt,
+ lastValidatedAt: new Date(),
+ lastUsageJson: usage.payload as Prisma.InputJsonValue,
+ lastSyncedAt: new Date(),
+ },
+ });
+
+ await this.prisma.openAiLoginAttempt.update({
+ where: { id: attempt.id },
+ data: {
+ status: 'completed',
+ completedAt: new Date(),
+ lastError: null,
+ accountId: account.id,
+ },
+ });
+
+ return this.renderCallbackPage({
+ attemptId: attempt.id,
+ status: 'success',
+ message: `Connected ${sessionIdentity.email ?? attempt.label} to CodexDash.`,
+ });
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : 'OpenAI login failed.';
+ await this.prisma.openAiLoginAttempt.update({
+ where: { id: attempt.id },
+ data: { status: 'error', lastError: message },
+ });
+ return this.renderCallbackPage({
+ attemptId: attempt.id,
+ status: 'error',
+ message,
+ });
+ }
+ }
+
+ private async refreshAccountUsage(account: OpenAiAccountRecord) {
+ let session = this.readStoredSession(account.encryptedSessionJson);
+
+ if (
+ account.sessionExpiresAt &&
+ account.sessionExpiresAt.getTime() - Date.now() < 2 * 60 * 1000 &&
+ session.refreshToken
+ ) {
+ session = await this.refreshStoredSession(session);
+ }
+
+ const usage = await this.fetchUsageWithSession(session);
+ session = usage.session;
+ const identity = extractCodexIdentity(session.idToken);
+
+ return this.prisma.openAiAccount.update({
+ where: { id: account.id },
+ data: {
+ providerEmail: identity.email,
+ providerAccountId: identity.accountId,
+ planType: identity.planType,
+ encryptedSessionJson: encryptString(
+ JSON.stringify(session),
+ this.getEncryptionSecret(),
+ ),
+ sessionExpiresAt: identity.expiresAt,
+ lastValidatedAt: new Date(),
+ lastUsageJson: usage.payload as Prisma.InputJsonValue,
+ lastSyncedAt: new Date(),
+ lastError: null,
},
+ });
+ }
+
+ private async fetchUsageWithSession(
+ session: StoredCodexSession,
+ allowRefresh = true,
+ ): Promise<{ payload: UsageApiResponse; session: StoredCodexSession }> {
+ const urls = [
+ 'https://chatgpt.com/backend-api/api/codex/usage',
+ 'https://chatgpt.com/backend-api/wham/usage',
+ ];
+ const errors: string[] = [];
+
+ for (const url of urls) {
+ const response = await fetch(url, {
+ headers: this.buildUsageHeaders(session),
+ });
+
+ if (response.ok) {
+ return {
+ payload: (await response.json()) as UsageApiResponse,
+ session,
+ };
+ }
+
+ if (
+ (response.status === 401 || response.status === 403) &&
+ allowRefresh &&
+ session.refreshToken
+ ) {
+ const refreshed = await this.refreshStoredSession(session);
+ return this.fetchUsageWithSession(refreshed, false);
+ }
+
+ errors.push(`${url} -> ${response.status}`);
+ }
+
+ throw new BadRequestException(
+ `Codex usage request failed (${errors.join(', ')})`,
);
+ }
+
+ private async exchangeAuthorizationCode(code: string, verifier: string) {
+ const body = new URLSearchParams({
+ grant_type: 'authorization_code',
+ client_id: CODEX_OAUTH_CLIENT_ID,
+ code,
+ redirect_uri: this.getOauthRedirectUri(),
+ code_verifier: verifier,
+ });
+
+ const response = await fetch(CODEX_OAUTH_TOKEN_URL, {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/x-www-form-urlencoded',
+ accept: 'application/json',
+ },
+ body: body.toString(),
+ });
if (!response.ok) {
+ const error = await response.text();
throw new BadRequestException(
- `Codex usage request failed with status ${response.status}`,
+ `OpenAI token exchange failed (${response.status}): ${error || 'No details returned.'}`,
);
}
- return (await response.json()) as UsageApiResponse;
+ const payload = (await response.json()) as CodexTokenResponse;
+ if (!payload.access_token) {
+ throw new BadRequestException(
+ 'OpenAI token exchange returned an empty access token.',
+ );
+ }
+
+ return payload;
+ }
+
+ private async refreshStoredSession(session: StoredCodexSession) {
+ const response = await fetch(CODEX_OAUTH_TOKEN_URL, {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/json',
+ accept: 'application/json',
+ },
+ body: JSON.stringify({
+ client_id: CODEX_OAUTH_CLIENT_ID,
+ grant_type: 'refresh_token',
+ refresh_token: session.refreshToken,
+ scope: 'openid profile email',
+ }),
+ });
+
+ if (!response.ok) {
+ const error = await response.text();
+ throw new BadRequestException(
+ `OpenAI token refresh failed (${response.status}): ${error || 'No details returned.'}`,
+ );
+ }
+
+ const payload = (await response.json()) as CodexTokenResponse;
+ if (!payload.access_token) {
+ throw new BadRequestException(
+ 'OpenAI token refresh returned an empty access token.',
+ );
+ }
+
+ const nextSession: StoredCodexSession = {
+ accessToken: payload.access_token,
+ refreshToken: payload.refresh_token ?? session.refreshToken,
+ idToken: payload.id_token ?? session.idToken,
+ accountId:
+ extractCodexIdentity(payload.id_token ?? session.idToken).accountId ??
+ session.accountId,
+ };
+
+ return nextSession;
+ }
+
+ private buildUsageHeaders(
+ session: StoredCodexSession,
+ ): Record {
+ const headers: Record = {
+ accept: 'application/json',
+ authorization: `Bearer ${session.accessToken}`,
+ 'user-agent': 'CodexDash/0.2 (+https://github.com/darvell/codex-pool)',
+ originator: 'codex_cli_rs',
+ };
+
+ if (session.accountId) {
+ headers['ChatGPT-Account-ID'] = session.accountId;
+ }
+
+ return headers;
+ }
+
+ private readStoredSession(encryptedSessionJson: string): StoredCodexSession {
+ return JSON.parse(
+ decryptString(encryptedSessionJson, this.getEncryptionSecret()),
+ ) as StoredCodexSession;
+ }
+
+ private renderCallbackPage(input: {
+ attemptId: string;
+ status: 'success' | 'error';
+ message: string;
+ }) {
+ return {
+ statusCode: input.status === 'success' ? 200 : 400,
+ html: renderCodexOauthCallbackHtml({
+ attemptId: input.attemptId,
+ status: input.status,
+ message: input.message,
+ frontendOrigin: this.getFrontendOrigin(),
+ }),
+ };
}
private getEncryptionSecret() {
@@ -149,20 +578,48 @@ export class CodexService {
);
}
- private toAccountView(account: {
- id: string;
- label: string;
- emailHint: string | null;
- lastUsageJson: unknown;
- lastSyncedAt: Date | null;
- lastError: string | null;
- createdAt: Date;
- }): ConnectedAccount {
+ private getFrontendOrigin() {
+ return (
+ this.configService.get('CODEXDASH_FRONTEND_ORIGIN') ??
+ 'http://localhost:5173'
+ );
+ }
+
+ private getOauthRedirectUri() {
+ return (
+ this.configService.get('CODEX_OAUTH_REDIRECT_URI') ??
+ CODEX_OAUTH_DEFAULT_REDIRECT_URI
+ );
+ }
+
+ private toLoginAttemptView(
+ attempt: LoginAttemptRecord,
+ ): CodexLoginAttemptResponse {
+ return {
+ id: attempt.id,
+ label: attempt.label,
+ emailHint: attempt.emailHint,
+ status: attempt.status as CodexLoginAttemptResponse['status'],
+ expiresAt: attempt.expiresAt.toISOString(),
+ completedAt: attempt.completedAt?.toISOString() ?? null,
+ lastError: attempt.lastError,
+ connectedAccount: attempt.account
+ ? this.toAccountView(attempt.account)
+ : null,
+ };
+ }
+
+ private toAccountView(account: AccountRecord): ConnectedAccount {
return {
id: account.id,
label: account.label,
emailHint: account.emailHint,
+ providerEmail: account.providerEmail,
+ providerAccountId: account.providerAccountId,
+ planType: account.planType,
+ authType: 'codex-oauth',
status: account.lastError ? 'error' : 'active',
+ sessionExpiresAt: account.sessionExpiresAt?.toISOString() ?? null,
lastSyncedAt: account.lastSyncedAt?.toISOString() ?? null,
lastError: account.lastError,
usage: (account.lastUsageJson as Record | null) ?? null,
@@ -170,3 +627,31 @@ export class CodexService {
};
}
}
+
+type AccountRecord = {
+ id: string;
+ label: string;
+ emailHint: string | null;
+ providerEmail: string | null;
+ providerAccountId: string | null;
+ planType: string | null;
+ encryptedSessionJson: string;
+ sessionExpiresAt: Date | null;
+ lastUsageJson: unknown;
+ lastSyncedAt: Date | null;
+ lastError: string | null;
+ createdAt: Date;
+};
+
+type OpenAiAccountRecord = AccountRecord;
+
+type LoginAttemptRecord = {
+ id: string;
+ label: string;
+ emailHint: string | null;
+ status: string;
+ expiresAt: Date;
+ completedAt: Date | null;
+ lastError: string | null;
+ account: AccountRecord | null;
+};
diff --git a/apps/api/src/codex/dto/connect-account.dto.ts b/apps/api/src/codex/dto/connect-account.dto.ts
deleted file mode 100644
index 5bee186..0000000
--- a/apps/api/src/codex/dto/connect-account.dto.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { IsOptional, IsString, MinLength } from 'class-validator';
-import { ConnectAccountInput } from '@codexdash/shared-types';
-
-export class ConnectAccountDto implements ConnectAccountInput {
- @IsString()
- @MinLength(2)
- label!: string;
-
- @IsOptional()
- @IsString()
- emailHint?: string;
-
- @IsString()
- @MinLength(20)
- cookieHeader!: string;
-}
diff --git a/apps/api/src/codex/dto/start-codex-login.dto.ts b/apps/api/src/codex/dto/start-codex-login.dto.ts
new file mode 100644
index 0000000..a7ed665
--- /dev/null
+++ b/apps/api/src/codex/dto/start-codex-login.dto.ts
@@ -0,0 +1,12 @@
+import { IsOptional, IsString, MinLength } from 'class-validator';
+import { StartCodexLoginInput } from '@codexdash/shared-types';
+
+export class StartCodexLoginDto implements StartCodexLoginInput {
+ @IsString()
+ @MinLength(2)
+ label!: string;
+
+ @IsOptional()
+ @IsString()
+ emailHint?: string;
+}
diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts
index 63f1cae..5ada6e1 100644
--- a/apps/api/src/main.ts
+++ b/apps/api/src/main.ts
@@ -1,6 +1,8 @@
-import { NestFactory } from '@nestjs/core';
+import { createServer } from 'node:http';
import { ValidationPipe } from '@nestjs/common';
+import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
+import { CodexService } from './codex/codex.service';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
@@ -14,5 +16,39 @@ async function bootstrap() {
);
await app.listen(process.env.PORT ?? 3001);
+
+ const codexService = app.get(CodexService);
+ const callbackUrl = new URL(
+ process.env.CODEX_OAUTH_REDIRECT_URI ??
+ 'http://localhost:1455/auth/callback',
+ );
+
+ const callbackServer = createServer((req, res) => {
+ void (async () => {
+ if (!req.url) {
+ res.writeHead(400, { 'content-type': 'text/plain; charset=utf-8' });
+ res.end('Bad request');
+ return;
+ }
+
+ const requestedUrl = new URL(req.url, callbackUrl);
+ if (
+ req.method !== 'GET' ||
+ requestedUrl.pathname !== callbackUrl.pathname
+ ) {
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
+ res.end('Not found');
+ return;
+ }
+
+ const result = await codexService.handleOauthCallbackRequest(req.url);
+ res.writeHead(result.statusCode, {
+ 'content-type': 'text/html; charset=utf-8',
+ });
+ res.end(result.html);
+ })();
+ });
+
+ callbackServer.listen(Number(callbackUrl.port || 80), callbackUrl.hostname);
}
void bootstrap();
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 2fed597..b0f2fe4 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -1,20 +1,54 @@
-import { useMemo, useState } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
-import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
-import type { AuthResponse, LoginInput, RegisterInput } from '@codexdash/shared-types';
-import { Activity, CirclePlus, Gauge, LogOut, RefreshCw, ShieldCheck, Trash2 } from 'lucide-react';
+import {
+ useMutation,
+ useQuery,
+ useQueryClient,
+} from '@tanstack/react-query';
+import type {
+ AuthResponse,
+ LoginInput,
+ RegisterInput,
+ StartCodexLoginInput,
+} from '@codexdash/shared-types';
+import {
+ Activity,
+ CirclePlus,
+ ExternalLink,
+ Gauge,
+ Link as LinkIcon,
+ LoaderCircle,
+ LogOut,
+ RefreshCw,
+ ShieldCheck,
+ Trash2,
+ Waypoints,
+} from 'lucide-react';
import { toast, Toaster } from 'sonner';
import { api } from '@/lib/api';
import { clearToken, getToken, setToken } from '@/lib/storage';
import { flattenNumericMetrics, formatDate, titleizeMetric } from '@/lib/utils';
import { Button } from '@/components/ui/button';
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
-import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@/components/ui/dialog';
import { Progress } from '@/components/ui/progress';
import { Separator } from '@/components/ui/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
@@ -34,27 +68,45 @@ const loginSchema = z.object({
const connectSchema = z.object({
label: z.string().min(2),
emailHint: z.string().optional(),
- cookieHeader: z.string().min(20),
});
-function AuthShell({ onAuthenticated }: { onAuthenticated: (response: AuthResponse) => void }) {
+function AuthShell({
+ onAuthenticated,
+}: {
+ onAuthenticated: (response: AuthResponse) => void;
+}) {
const [mode, setMode] = useState<'login' | 'register'>('register');
const schema = mode === 'register' ? registerSchema : loginSchema;
const form = useForm<{ name?: string; email: string; password: string }>({
resolver: zodResolver(schema),
- defaultValues: { email: '', password: '', ...(mode === 'register' ? { name: '' } : {}) },
+ defaultValues: {
+ email: '',
+ password: '',
+ ...(mode === 'register' ? { name: '' } : {}),
+ },
});
const mutation = useMutation({
- mutationFn: async (values: { name?: string; email: string; password: string }) => {
+ mutationFn: async (values: {
+ name?: string;
+ email: string;
+ password: string;
+ }) => {
return mode === 'register'
? api.register(values as RegisterInput)
- : api.login({ email: values.email, password: values.password } as LoginInput);
+ : api.login({
+ email: values.email,
+ password: values.password,
+ } as LoginInput);
},
onSuccess: (response) => {
setToken(response.token);
onAuthenticated(response);
- toast.success(mode === 'register' ? 'Welcome to CodexDash.' : 'Signed in successfully.');
+ toast.success(
+ mode === 'register'
+ ? 'Welcome to CodexDash.'
+ : 'Signed in successfully.',
+ );
},
onError: (error: Error) => toast.error(error.message),
});
@@ -65,27 +117,46 @@ function AuthShell({ onAuthenticated }: { onAuthenticated: (response: AuthRespon
-
Mobile-first Codex monitor
+
+ Mobile-first Codex monitor
+
CodexDash keeps every Codex account in one gorgeous live dashboard.
- Sign into CodexDash, attach multiple OpenAI Codex sessions, and view combined limits,
+ Sign into CodexDash, connect multiple OpenAI Codex accounts through a real login flow, and view combined limits,
remaining usage, raw API payloads, and per-account drilldowns from a single responsive UI.
{[
- { icon: Gauge, title: 'Unified usage', desc: 'Merge multiple OpenAI accounts into one overview.' },
- { icon: ShieldCheck, title: 'Stored safely', desc: 'Session cookie headers are encrypted at rest.' },
- { icon: Activity, title: 'Live detail', desc: 'See refreshed usage plus raw usage payloads.' },
+ {
+ icon: Gauge,
+ title: 'Unified usage',
+ desc: 'Merge multiple OpenAI accounts into one overview.',
+ },
+ {
+ icon: ShieldCheck,
+ title: 'Stored safely',
+ desc: 'OAuth session data is encrypted at rest.',
+ },
+ {
+ icon: Activity,
+ title: 'Live detail',
+ desc: 'See refreshed usage plus raw usage payloads.',
+ },
].map((item) => (
-
+
{item.title}
-
{item.desc}
+
+ {item.desc}
+
))}
@@ -94,34 +165,63 @@ function AuthShell({ onAuthenticated }: { onAuthenticated: (response: AuthRespon
- {mode === 'register' ? 'Create your account' : 'Welcome back'}
+
+ {mode === 'register' ? 'Create your account' : 'Welcome back'}
+
{mode === 'register'
- ? 'Start with your CodexDash account, then connect OpenAI Codex sessions inside the dashboard.'
+ ? 'Start with your CodexDash account, then connect OpenAI Codex logins inside the dashboard.'
: 'Log in to continue monitoring your combined Codex usage.'}
-
@@ -129,12 +229,20 @@ function AuthShell({ onAuthenticated }: { onAuthenticated: (response: AuthRespon
- OpenAI account connection is implemented as a session-based Codex login: after signing into
- chatgpt.com in your browser, paste the authenticated Cookie{' '}
- header into the connect flow.
+ OpenAI account connection now uses a real sign-in flow based on the Codex client OAuth pattern.
+ After you click connect, CodexDash opens OpenAI login in a popup and receives the callback locally.
-
setMode(mode === 'register' ? 'login' : 'register')}>
- {mode === 'register' ? 'Already have an account? Sign in' : 'Need an account? Register'}
+
+ setMode(mode === 'register' ? 'login' : 'register')
+ }
+ >
+ {mode === 'register'
+ ? 'Already have an account? Sign in'
+ : 'Need an account? Register'}
@@ -147,24 +255,111 @@ function AuthShell({ onAuthenticated }: { onAuthenticated: (response: AuthRespon
function ConnectAccountDialog() {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
+ const [attemptId, setAttemptId] = useState(null);
+ const [authorizeUrl, setAuthorizeUrl] = useState(null);
+ const popupRef = useRef(null);
+ const handledAttemptStatusRef = useRef(null);
const form = useForm>({
resolver: zodResolver(connectSchema),
- defaultValues: { label: '', emailHint: '', cookieHeader: '' },
+ defaultValues: { label: '', emailHint: '' },
});
- const mutation = useMutation({
- mutationFn: api.connectAccount,
- onSuccess: () => {
- toast.success('OpenAI Codex session connected.');
- setOpen(false);
- form.reset();
- void queryClient.invalidateQueries({ queryKey: ['usage-summary'] });
+ const startMutation = useMutation({
+ mutationFn: api.startCodexLogin,
+ onSuccess: (response) => {
+ setAttemptId(response.attemptId);
+ setAuthorizeUrl(response.authorizeUrl);
+ popupRef.current = window.open(
+ response.authorizeUrl,
+ 'codexdash-openai-login',
+ 'popup=yes,width=520,height=760',
+ );
+ if (!popupRef.current) {
+ toast.error('Popup was blocked. Use the fallback link inside the dialog.');
+ } else {
+ toast.success('Continue the OpenAI sign-in flow in the popup.');
+ }
},
onError: (error: Error) => toast.error(error.message),
});
+ const attemptQuery = useQuery({
+ enabled: Boolean(attemptId),
+ queryKey: ['codex-login-attempt', attemptId],
+ queryFn: () => api.getCodexLoginAttempt(attemptId as string),
+ refetchInterval: (query) =>
+ query.state.data?.status === 'pending' ? 2_000 : false,
+ });
+
+ const cancelMutation = useMutation({
+ mutationFn: api.cancelCodexLoginAttempt,
+ onSuccess: () => {
+ toast.success('Login attempt cancelled.');
+ setAttemptId(null);
+ setAuthorizeUrl(null);
+ popupRef.current?.close();
+ },
+ onError: (error: Error) => toast.error(error.message),
+ });
+
+ useEffect(() => {
+ const attempt = attemptQuery.data;
+ if (!attempt) {
+ return;
+ }
+
+ const statusKey = `${attempt.id}:${attempt.status}:${attempt.completedAt ?? ''}:${attempt.lastError ?? ''}`;
+ if (handledAttemptStatusRef.current === statusKey) {
+ return;
+ }
+
+ if (attempt.status === 'completed') {
+ handledAttemptStatusRef.current = statusKey;
+ window.setTimeout(() => {
+ toast.success('OpenAI Codex account connected.');
+ setOpen(false);
+ setAttemptId(null);
+ setAuthorizeUrl(null);
+ form.reset();
+ popupRef.current?.close();
+ void queryClient.invalidateQueries({ queryKey: ['usage-summary'] });
+ }, 0);
+ return;
+ }
+
+ if (attempt.status === 'error' || attempt.status === 'expired') {
+ handledAttemptStatusRef.current = statusKey;
+ toast.error(attempt.lastError || 'OpenAI login failed.');
+ }
+ }, [attemptQuery.data, form, queryClient]);
+
+ useEffect(() => {
+ function onMessage(event: MessageEvent) {
+ if (event.data?.type !== 'codexdash:oauth-complete') {
+ return;
+ }
+ if (event.data?.attemptId && event.data.attemptId === attemptId) {
+ void attemptQuery.refetch();
+ }
+ }
+
+ window.addEventListener('message', onMessage);
+ return () => window.removeEventListener('message', onMessage);
+ }, [attemptId, attemptQuery]);
+
+ const attempt = attemptQuery.data;
+ const isPendingAttempt = attempt?.status === 'pending';
+
return (
-
+ {
+ setOpen(next);
+ if (!next && isPendingAttempt && attemptId) {
+ cancelMutation.mutate(attemptId);
+ }
+ }}
+ >
@@ -173,37 +368,122 @@ function ConnectAccountDialog() {
- Connect an OpenAI Codex session
+ Connect an OpenAI Codex account
- Paste the authenticated Cookie{' '}
- header from a signed-in chatgpt.com{' '}
- session. CodexDash will use it to call the official usage endpoint.
+ Start a real OpenAI sign-in flow. CodexDash opens the official login,
+ completes the Codex-style OAuth callback locally, then stores the
+ encrypted session for future usage refreshes.
-