feat: add codex oauth login flow

This commit is contained in:
2026-05-01 01:59:21 +09:00
parent 0ced12cb81
commit e9496f2c4a
13 changed files with 1502 additions and 218 deletions

View File

@@ -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

View File

@@ -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`

View File

@@ -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])
}
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])
}

View File

@@ -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');
});
});

View File

@@ -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 `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CodexDash login</title>
<style>
:root { color-scheme: dark; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
background: #020617;
color: #e2e8f0;
font-family: Inter, system-ui, sans-serif;
}
main {
width: min(92vw, 420px);
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 24px;
padding: 24px;
background: rgba(15, 23, 42, 0.92);
box-shadow: 0 20px 60px rgba(15, 23, 42, 0.35);
}
h1 { margin: 0 0 8px; font-size: 1.25rem; }
p { margin: 0; line-height: 1.6; color: #cbd5e1; }
</style>
</head>
<body>
<main>
<h1>${escapeHtml(input.status === 'success' ? 'Connected to CodexDash' : 'CodexDash login failed')}</h1>
<p>${escapeHtml(input.message)}</p>
</main>
<script>
const payload = ${payload};
try {
if (window.opener && !window.opener.closed) {
window.opener.postMessage(payload, ${JSON.stringify(input.frontendOrigin)});
window.close();
}
} catch (error) {
console.error(error);
}
</script>
</body>
</html>`;
}
function parseJwtPayload(token: string): Record<string, unknown> | null {
const [, payload] = token.split('.');
if (!payload) {
return null;
}
try {
return JSON.parse(
Buffer.from(payload, 'base64url').toString('utf8'),
) as Record<string, unknown>;
} catch {
return null;
}
}
function readRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: 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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}

View File

@@ -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<string, string>,
@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,

View File

@@ -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<string, unknown>;
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<ConnectedAccount> {
const usage = await this.fetchUsage(dto.cookieHeader);
const account = await this.prisma.openAiAccount.create({
dto: StartCodexLoginDto,
): Promise<StartCodexLoginResponse> {
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<CodexLoginAttemptResponse> {
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<UsageApiResponse> {
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<string, string> {
const headers: Record<string, string> = {
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<string>('CODEXDASH_FRONTEND_ORIGIN') ??
'http://localhost:5173'
);
}
private getOauthRedirectUri() {
return (
this.configService.get<string>('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<string, unknown> | 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;
};

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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();

View File

@@ -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
<Card className="overflow-hidden border-sky-500/20 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.18),_transparent_28%),linear-gradient(180deg,rgba(15,23,42,0.94),rgba(2,6,23,0.92))]">
<CardContent className="flex h-full flex-col justify-between gap-8 p-6 sm:p-8">
<div className="space-y-5">
<Badge className="w-fit border-sky-400/30 bg-sky-400/10 text-sky-100">Mobile-first Codex monitor</Badge>
<Badge className="w-fit border-sky-400/30 bg-sky-400/10 text-sky-100">
Mobile-first Codex monitor
</Badge>
<div className="space-y-4">
<h1 className="max-w-xl text-4xl font-semibold tracking-tight text-white sm:text-5xl">
CodexDash keeps every Codex account in one gorgeous live dashboard.
</h1>
<p className="max-w-xl text-base leading-7 text-slate-300 sm:text-lg">
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.
</p>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-3">
{[
{ 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) => (
<div key={item.title} className="rounded-2xl border border-white/10 bg-white/6 p-4">
<div
key={item.title}
className="rounded-2xl border border-white/10 bg-white/6 p-4"
>
<item.icon className="mb-3 size-5 text-sky-300" />
<div className="font-medium text-white">{item.title}</div>
<div className="mt-1 text-sm leading-6 text-slate-400">{item.desc}</div>
<div className="mt-1 text-sm leading-6 text-slate-400">
{item.desc}
</div>
</div>
))}
</div>
@@ -94,34 +165,63 @@ function AuthShell({ onAuthenticated }: { onAuthenticated: (response: AuthRespon
<Card className="border-white/10 bg-slate-950/88">
<CardHeader>
<CardTitle>{mode === 'register' ? 'Create your account' : 'Welcome back'}</CardTitle>
<CardTitle>
{mode === 'register' ? 'Create your account' : 'Welcome back'}
</CardTitle>
<CardDescription>
{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.'}
</CardDescription>
</CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={form.handleSubmit((values) => mutation.mutate(values))}>
<form
className="space-y-4"
onSubmit={form.handleSubmit((values) => mutation.mutate(values))}
>
{mode === 'register' ? (
<div className="space-y-2">
<Label htmlFor="name">Display name</Label>
<Input id="name" placeholder="Codex operator" {...form.register('name' as const)} />
<p className="text-xs text-rose-300">{String(form.formState.errors.name?.message ?? '')}</p>
<Input
id="name"
placeholder="Codex operator"
{...form.register('name' as const)}
/>
<p className="text-xs text-rose-300">
{String(form.formState.errors.name?.message ?? '')}
</p>
</div>
) : null}
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" placeholder="you@example.com" {...form.register('email')} />
<p className="text-xs text-rose-300">{String(form.formState.errors.email?.message ?? '')}</p>
<Input
id="email"
type="email"
placeholder="you@example.com"
{...form.register('email')}
/>
<p className="text-xs text-rose-300">
{String(form.formState.errors.email?.message ?? '')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input id="password" type="password" placeholder="At least 8 characters" {...form.register('password')} />
<p className="text-xs text-rose-300">{String(form.formState.errors.password?.message ?? '')}</p>
<Input
id="password"
type="password"
placeholder="At least 8 characters"
{...form.register('password')}
/>
<p className="text-xs text-rose-300">
{String(form.formState.errors.password?.message ?? '')}
</p>
</div>
<Button className="w-full" disabled={mutation.isPending} type="submit">
{mutation.isPending ? 'Please wait…' : mode === 'register' ? 'Create account' : 'Sign in'}
{mutation.isPending
? 'Please wait…'
: mode === 'register'
? 'Create account'
: 'Sign in'}
</Button>
</form>
@@ -129,12 +229,20 @@ function AuthShell({ onAuthenticated }: { onAuthenticated: (response: AuthRespon
<div className="space-y-3 text-sm text-slate-400">
<p>
OpenAI account connection is implemented as a session-based Codex login: after signing into
chatgpt.com in your browser, paste the authenticated <code className="rounded bg-white/10 px-1.5 py-0.5 text-slate-200">Cookie</code>{' '}
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.
</p>
<Button type="button" variant="ghost" className="px-0 text-sky-300 hover:bg-transparent" onClick={() => setMode(mode === 'register' ? 'login' : 'register')}>
{mode === 'register' ? 'Already have an account? Sign in' : 'Need an account? Register'}
<Button
type="button"
variant="ghost"
className="px-0 text-sky-300 hover:bg-transparent"
onClick={() =>
setMode(mode === 'register' ? 'login' : 'register')
}
>
{mode === 'register'
? 'Already have an account? Sign in'
: 'Need an account? Register'}
</Button>
</div>
</CardContent>
@@ -147,24 +255,111 @@ function AuthShell({ onAuthenticated }: { onAuthenticated: (response: AuthRespon
function ConnectAccountDialog() {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [attemptId, setAttemptId] = useState<string | null>(null);
const [authorizeUrl, setAuthorizeUrl] = useState<string | null>(null);
const popupRef = useRef<Window | null>(null);
const handledAttemptStatusRef = useRef<string | null>(null);
const form = useForm<z.infer<typeof connectSchema>>({
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 (
<Dialog open={open} onOpenChange={setOpen}>
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next && isPendingAttempt && attemptId) {
cancelMutation.mutate(attemptId);
}
}}
>
<DialogTrigger asChild>
<Button className="w-full sm:w-auto">
<CirclePlus className="size-4" />
@@ -173,37 +368,122 @@ function ConnectAccountDialog() {
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Connect an OpenAI Codex session</DialogTitle>
<DialogTitle>Connect an OpenAI Codex account</DialogTitle>
<DialogDescription>
Paste the authenticated <code className="rounded bg-white/10 px-1 py-0.5 text-slate-200">Cookie</code>{' '}
header from a signed-in <code className="rounded bg-white/10 px-1 py-0.5 text-slate-200">chatgpt.com</code>{' '}
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.
</DialogDescription>
</DialogHeader>
<form className="mt-5 space-y-4" onSubmit={form.handleSubmit((values) => mutation.mutate(values))}>
<div className="space-y-2">
<Label htmlFor="label">Account label</Label>
<Input id="label" placeholder="Primary Team Pro" {...form.register('label')} />
<p className="text-xs text-rose-300">{String(form.formState.errors.label?.message ?? '')}</p>
{attemptId ? (
<div className="mt-4 space-y-4">
<div className="rounded-2xl border border-sky-500/20 bg-sky-500/8 p-4 text-sm text-slate-300">
<div className="flex items-center gap-2 font-medium text-white">
<LoaderCircle className="size-4 animate-spin" />
Waiting for OpenAI login
</div>
<div className="mt-2 leading-6">
{attempt?.status === 'completed'
? 'The login finished successfully. Closing this dialog…'
: 'Finish the sign-in flow in the popup window. CodexDash will detect the callback automatically.'}
</div>
<div className="mt-3 text-xs text-slate-400">
Expires: {formatDate(attempt?.expiresAt ?? null)}
</div>
</div>
{authorizeUrl ? (
<div className="flex flex-col gap-3 sm:flex-row">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => {
popupRef.current = window.open(
authorizeUrl,
'codexdash-openai-login',
'popup=yes,width=520,height=760',
);
}}
>
<ExternalLink className="size-4" />
Re-open login popup
</Button>
<Button
type="button"
variant="outline"
className="flex-1"
asChild
>
<a href={authorizeUrl} rel="noreferrer" target="_blank">
<LinkIcon className="size-4" />
Open login in new tab
</a>
</Button>
</div>
) : null}
{attempt?.lastError ? (
<div className="rounded-2xl border border-rose-500/20 bg-rose-500/8 p-4 text-sm text-rose-200">
{attempt.lastError}
</div>
) : null}
<div className="flex gap-3">
<Button
type="button"
variant="outline"
onClick={() => void attemptQuery.refetch()}
>
<RefreshCw className="size-4" />
Check status
</Button>
<Button
type="button"
variant="ghost"
disabled={!attemptId || cancelMutation.isPending}
onClick={() => attemptId && cancelMutation.mutate(attemptId)}
>
Cancel
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="emailHint">Email hint</Label>
<Input id="emailHint" placeholder="ops@example.com" {...form.register('emailHint')} />
</div>
<div className="space-y-2">
<Label htmlFor="cookieHeader">Cookie header</Label>
<textarea
id="cookieHeader"
className="min-h-36 w-full rounded-2xl border border-white/10 bg-white/5 p-4 text-sm text-white outline-none focus:border-sky-400/60"
placeholder="__Secure-next-auth.session-token=...; oai-did=..."
{...form.register('cookieHeader')}
/>
<p className="text-xs text-rose-300">{String(form.formState.errors.cookieHeader?.message ?? '')}</p>
</div>
<Button className="w-full" disabled={mutation.isPending} type="submit">
{mutation.isPending ? 'Connecting…' : 'Validate and connect'}
</Button>
</form>
) : (
<form
className="mt-5 space-y-4"
onSubmit={form.handleSubmit((values) =>
startMutation.mutate(values as StartCodexLoginInput)
)}
>
<div className="space-y-2">
<Label htmlFor="label">Account label</Label>
<Input
id="label"
placeholder="Primary Team Pro"
{...form.register('label')}
/>
<p className="text-xs text-rose-300">
{String(form.formState.errors.label?.message ?? '')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="emailHint">Email hint</Label>
<Input
id="emailHint"
placeholder="ops@example.com"
{...form.register('emailHint')}
/>
</div>
<div className="rounded-2xl border border-white/10 bg-white/4 p-4 text-sm leading-6 text-slate-400">
CodexDash reuses the Codex public-client login shape discovered in
codex-pool, but presents it as an integrated popup flow instead of asking you to paste cookies manually.
</div>
<Button className="w-full" disabled={startMutation.isPending} type="submit">
{startMutation.isPending ? 'Preparing login…' : 'Continue to OpenAI'}
</Button>
</form>
)}
</DialogContent>
</Dialog>
);
@@ -211,7 +491,10 @@ function ConnectAccountDialog() {
function Dashboard() {
const queryClient = useQueryClient();
const summaryQuery = useQuery({ queryKey: ['usage-summary'], queryFn: () => api.getUsageSummary(true) });
const summaryQuery = useQuery({
queryKey: ['usage-summary'],
queryFn: () => api.getUsageSummary(true),
});
const userQuery = useQuery({ queryKey: ['me'], queryFn: api.me });
const deleteMutation = useMutation({
mutationFn: api.deleteAccount,
@@ -227,7 +510,11 @@ function Dashboard() {
}, [summaryQuery.data?.aggregatedUsage]);
if (summaryQuery.isLoading || userQuery.isLoading) {
return <div className="flex min-h-screen items-center justify-center text-slate-300">Loading CodexDash</div>;
return (
<div className="flex min-h-screen items-center justify-center text-slate-300">
Loading CodexDash
</div>
);
}
if (summaryQuery.isError || userQuery.isError) {
@@ -237,7 +524,8 @@ function Dashboard() {
<CardHeader>
<CardTitle>Unable to load dashboard</CardTitle>
<CardDescription>
{(summaryQuery.error as Error | undefined)?.message ?? (userQuery.error as Error | undefined)?.message}
{(summaryQuery.error as Error | undefined)?.message ??
(userQuery.error as Error | undefined)?.message}
</CardDescription>
</CardHeader>
<CardContent>
@@ -252,17 +540,23 @@ function Dashboard() {
const user = userQuery.data!;
const firstMetric = metricCards[0]?.value ?? 0;
const secondMetric = metricCards[1]?.value ?? 0;
const progressValue = firstMetric + secondMetric > 0 ? (firstMetric / (firstMetric + secondMetric)) * 100 : 0;
const progressValue =
firstMetric + secondMetric > 0
? (firstMetric / (firstMetric + secondMetric)) * 100
: 0;
return (
<div className="mx-auto min-h-screen max-w-7xl px-4 py-5 sm:px-6 lg:px-8">
<div className="mb-6 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<Badge className="mb-3 w-fit border-emerald-400/20 bg-emerald-400/10 text-emerald-200">Signed in as {user.name}</Badge>
<h1 className="text-3xl font-semibold text-white sm:text-4xl">CodexDash overview</h1>
<Badge className="mb-3 w-fit border-emerald-400/20 bg-emerald-400/10 text-emerald-200">
Signed in as {user.name}
</Badge>
<h1 className="text-3xl font-semibold text-white sm:text-4xl">
CodexDash overview
</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-400 sm:text-base">
Combined usage is refreshed by calling <code className="rounded bg-white/10 px-1.5 py-0.5 text-slate-200">chatgpt.com/backend-api/api/codex/usage</code>{' '}
for each attached OpenAI account and merging numeric fields into one dashboard.
Combined usage is refreshed by calling Codex usage endpoints for each attached OpenAI account and merging numeric fields into one dashboard.
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row">
@@ -271,7 +565,13 @@ function Dashboard() {
Refresh now
</Button>
<ConnectAccountDialog />
<Button variant="ghost" onClick={() => { clearToken(); window.location.reload(); }}>
<Button
variant="ghost"
onClick={() => {
clearToken();
window.location.reload();
}}
>
<LogOut className="size-4" />
Sign out
</Button>
@@ -282,17 +582,27 @@ function Dashboard() {
<Card className="md:col-span-2">
<CardHeader>
<CardTitle>Unified capacity</CardTitle>
<CardDescription>Fast glance card for the first two numeric metrics extracted from the merged usage payload.</CardDescription>
<CardDescription>
Fast glance card for the first two numeric metrics extracted from the merged usage payload.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-end justify-between gap-4">
<div>
<div className="text-4xl font-semibold text-white">{firstMetric.toLocaleString()}</div>
<div className="mt-1 text-sm text-slate-400">{titleizeMetric(metricCards[0]?.label ?? 'Primary metric')}</div>
<div className="text-4xl font-semibold text-white">
{firstMetric.toLocaleString()}
</div>
<div className="mt-1 text-sm text-slate-400">
{titleizeMetric(metricCards[0]?.label ?? 'Primary metric')}
</div>
</div>
<div className="text-right">
<div className="text-2xl font-semibold text-slate-100">{secondMetric.toLocaleString()}</div>
<div className="mt-1 text-sm text-slate-500">{titleizeMetric(metricCards[1]?.label ?? 'Secondary metric')}</div>
<div className="text-2xl font-semibold text-slate-100">
{secondMetric.toLocaleString()}
</div>
<div className="mt-1 text-sm text-slate-500">
{titleizeMetric(metricCards[1]?.label ?? 'Secondary metric')}
</div>
</div>
</div>
<Progress value={progressValue} />
@@ -306,14 +616,28 @@ function Dashboard() {
</Card>
{[
{ title: 'Connected sessions', value: summary.totals.totalAccounts, tone: 'text-sky-300' },
{ title: 'Healthy sessions', value: summary.totals.activeAccounts, tone: 'text-emerald-300' },
{ title: 'Errored sessions', value: summary.totals.erroredAccounts, tone: 'text-rose-300' },
{
title: 'Connected sessions',
value: summary.totals.totalAccounts,
tone: 'text-sky-300',
},
{
title: 'Healthy sessions',
value: summary.totals.activeAccounts,
tone: 'text-emerald-300',
},
{
title: 'Errored sessions',
value: summary.totals.erroredAccounts,
tone: 'text-rose-300',
},
].map((item) => (
<Card key={item.title}>
<CardHeader>
<CardDescription>{item.title}</CardDescription>
<CardTitle className={item.tone}>{item.value.toLocaleString()}</CardTitle>
<CardTitle className={item.tone}>
{item.value.toLocaleString()}
</CardTitle>
</CardHeader>
</Card>
))}
@@ -323,19 +647,28 @@ function Dashboard() {
<Card>
<CardHeader>
<CardTitle>Usage metrics</CardTitle>
<CardDescription>CodexDash extracts numeric leaf nodes from the aggregated usage payload for quick overview cards.</CardDescription>
<CardDescription>
CodexDash extracts numeric leaf nodes from the aggregated usage payload for quick overview cards.
</CardDescription>
</CardHeader>
<CardContent>
{metricCards.length === 0 ? (
<div className="rounded-2xl border border-dashed border-white/10 bg-white/3 p-6 text-sm text-slate-400">
No usage data yet. Connect an OpenAI account with a valid ChatGPT session cookie header to start refreshing.
No usage data yet. Connect an OpenAI account and complete the sign-in flow to start refreshing.
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{metricCards.map((metric) => (
<div key={metric.label} className="rounded-2xl border border-white/10 bg-white/4 p-4">
<div className="text-sm text-slate-400">{titleizeMetric(metric.label)}</div>
<div className="mt-3 text-2xl font-semibold text-white">{metric.value.toLocaleString()}</div>
<div
key={metric.label}
className="rounded-2xl border border-white/10 bg-white/4 p-4"
>
<div className="text-sm text-slate-400">
{titleizeMetric(metric.label)}
</div>
<div className="mt-3 text-2xl font-semibold text-white">
{metric.value.toLocaleString()}
</div>
</div>
))}
</div>
@@ -366,7 +699,11 @@ function Dashboard() {
<Tabs defaultValue={summary.accounts[0]?.id}>
<TabsList className="mb-4 flex h-auto w-full flex-wrap justify-start gap-2 bg-transparent p-0">
{summary.accounts.map((account) => (
<TabsTrigger key={account.id} value={account.id} className="border border-white/10 bg-white/5 data-[state=active]:border-sky-400/40 data-[state=active]:bg-slate-900">
<TabsTrigger
key={account.id}
value={account.id}
className="border border-white/10 bg-white/5 data-[state=active]:border-sky-400/40 data-[state=active]:bg-slate-900"
>
{account.label}
</TabsTrigger>
))}
@@ -377,27 +714,61 @@ function Dashboard() {
<div className="space-y-4 rounded-3xl border border-white/10 bg-white/4 p-5">
<div className="flex items-start justify-between gap-4">
<div>
<div className="text-lg font-semibold text-white">{account.label}</div>
<div className="mt-1 text-sm text-slate-400">{account.emailHint || 'No email hint provided'}</div>
<div className="text-lg font-semibold text-white">
{account.label}
</div>
<div className="mt-1 text-sm text-slate-400">
{account.providerEmail ||
account.emailHint ||
'No email available yet'}
</div>
</div>
<Badge className={account.status === 'active' ? 'border-emerald-400/20 bg-emerald-400/10 text-emerald-200' : 'border-rose-400/20 bg-rose-400/10 text-rose-200'}>
<Badge
className={
account.status === 'active'
? 'border-emerald-400/20 bg-emerald-400/10 text-emerald-200'
: 'border-rose-400/20 bg-rose-400/10 text-rose-200'
}
>
{account.status}
</Badge>
</div>
<Separator />
<div className="space-y-2 text-sm text-slate-300">
<div className="flex items-center gap-2">
<Waypoints className="size-4 text-sky-300" />
Auth: {account.authType}
</div>
<div>Plan: {account.planType || 'Unknown'}</div>
<div>
Provider account:{' '}
<span className="text-slate-400">
{account.providerAccountId || 'Unknown'}
</span>
</div>
<div>Session expires: {formatDate(account.sessionExpiresAt)}</div>
<div>Last synced: {formatDate(account.lastSyncedAt)}</div>
<div>Connected: {formatDate(account.createdAt)}</div>
<div>
Error: <span className="text-slate-400">{account.lastError || 'None'}</span>
Error:{' '}
<span className="text-slate-400">
{account.lastError || 'None'}
</span>
</div>
</div>
<div className="flex gap-3">
<Button variant="outline" onClick={() => summaryQuery.refetch()}>
<Button
variant="outline"
onClick={() => summaryQuery.refetch()}
>
<RefreshCw className="size-4" />
Refresh
</Button>
<Button variant="destructive" disabled={deleteMutation.isPending} onClick={() => deleteMutation.mutate(account.id)}>
<Button
variant="destructive"
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate(account.id)}
>
<Trash2 className="size-4" />
Remove
</Button>
@@ -406,7 +777,11 @@ function Dashboard() {
<JsonViewer
title="Account payload"
description="Most recent raw usage JSON for this specific OpenAI Codex account."
value={account.usage ?? { message: account.lastError || 'No usage fetched yet' }}
value={
account.usage ?? {
message: account.lastError || 'No usage fetched yet',
}
}
/>
</div>
</TabsContent>
@@ -425,7 +800,11 @@ export default function App() {
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(14,165,233,0.12),_transparent_25%),linear-gradient(180deg,#020617_0%,#0f172a_48%,#020617_100%)] text-slate-100">
<Toaster richColors position="top-center" theme="dark" />
{authenticated ? <Dashboard /> : <AuthShell onAuthenticated={() => setAuthenticated(true)} />}
{authenticated ? (
<Dashboard />
) : (
<AuthShell onAuthenticated={() => setAuthenticated(true)} />
)}
</div>
);
}

View File

@@ -1,9 +1,11 @@
import type {
AuthResponse,
ConnectAccountInput,
CodexLoginAttemptResponse,
ConnectedAccount,
LoginInput,
RegisterInput,
StartCodexLoginInput,
StartCodexLoginResponse,
UsageSummary,
UserProfile,
} from '@codexdash/shared-types';
@@ -27,7 +29,9 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
}
if (!response.ok) {
const error = await response.json().catch(() => ({ message: response.statusText }));
const error = await response
.json()
.catch(() => ({ message: response.statusText }));
throw new Error(error.message ?? 'Request failed');
}
@@ -36,12 +40,32 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
export const api = {
register: (input: RegisterInput) =>
request<AuthResponse>('/auth/register', { method: 'POST', body: JSON.stringify(input) }),
request<AuthResponse>('/auth/register', {
method: 'POST',
body: JSON.stringify(input),
}),
login: (input: LoginInput) =>
request<AuthResponse>('/auth/login', { method: 'POST', body: JSON.stringify(input) }),
request<AuthResponse>('/auth/login', {
method: 'POST',
body: JSON.stringify(input),
}),
me: () => request<UserProfile>('/auth/me'),
getUsageSummary: (refresh = true) => request<UsageSummary>(`/codex/usage-summary?refresh=${refresh}`),
connectAccount: (input: ConnectAccountInput) =>
request<ConnectedAccount>('/codex/accounts', { method: 'POST', body: JSON.stringify(input) }),
deleteAccount: (accountId: string) => request<{ ok: boolean }>(`/codex/accounts/${accountId}`, { method: 'DELETE' }),
getUsageSummary: (refresh = true) =>
request<UsageSummary>(`/codex/usage-summary?refresh=${refresh}`),
startCodexLogin: (input: StartCodexLoginInput) =>
request<StartCodexLoginResponse>('/codex/accounts/login/start', {
method: 'POST',
body: JSON.stringify(input),
}),
getCodexLoginAttempt: (attemptId: string) =>
request<CodexLoginAttemptResponse>(`/codex/accounts/login/${attemptId}`),
cancelCodexLoginAttempt: (attemptId: string) =>
request<{ ok: boolean }>(`/codex/accounts/login/${attemptId}/cancel`, {
method: 'POST',
}),
deleteAccount: (accountId: string) =>
request<{ ok: boolean }>(`/codex/accounts/${accountId}`, {
method: 'DELETE',
}),
listAccounts: () => request<ConnectedAccount[]>('/codex/accounts'),
};

View File

@@ -12,11 +12,25 @@ export type UserProfile = {
export type CodexUsagePayload = Record<string, unknown>;
export type ConnectedAccountStatus = 'active' | 'error';
export type CodexAuthType = 'codex-oauth';
export type CodexLoginAttemptStatus =
| 'pending'
| 'completed'
| 'error'
| 'expired'
| 'cancelled';
export type ConnectedAccount = {
id: string;
label: string;
emailHint: string | null;
status: 'active' | 'error';
providerEmail: string | null;
providerAccountId: string | null;
planType: string | null;
authType: CodexAuthType;
status: ConnectedAccountStatus;
sessionExpiresAt: string | null;
lastSyncedAt: string | null;
lastError: string | null;
usage: CodexUsagePayload | null;
@@ -34,10 +48,26 @@ export type UsageSummary = {
refreshedAt: string;
};
export type ConnectAccountInput = {
export type StartCodexLoginInput = {
label: string;
emailHint?: string;
cookieHeader: string;
};
export type StartCodexLoginResponse = {
attemptId: string;
authorizeUrl: string;
expiresAt: string;
};
export type CodexLoginAttemptResponse = {
id: string;
label: string;
emailHint: string | null;
status: CodexLoginAttemptStatus;
expiresAt: string;
completedAt: string | null;
lastError: string | null;
connectedAccount: ConnectedAccount | null;
};
export type RegisterInput = {