feat: add codex oauth login flow
This commit is contained in:
@@ -1,4 +1,6 @@
|
|||||||
JWT_SECRET=change-me
|
JWT_SECRET=***
|
||||||
ENCRYPTION_SECRET=change-me-32-characters-minimum
|
ENCRYPTION_SECRET=change-this-to-at-least-32-characters
|
||||||
DATABASE_URL=file:./dev.db
|
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
|
VITE_API_BASE_URL=http://localhost:3001
|
||||||
|
|||||||
44
README.md
44
README.md
@@ -12,29 +12,32 @@ CodexDash is a mobile-first dashboard for monitoring multiple OpenAI Codex accou
|
|||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
- Create a CodexDash account and sign in
|
- Create a CodexDash account and sign in
|
||||||
- Connect multiple OpenAI Codex sessions under one CodexDash account
|
- Connect multiple OpenAI Codex accounts under one CodexDash account
|
||||||
- Refresh `https://chatgpt.com/backend-api/api/codex/usage` for each connected OpenAI account
|
- Start an integrated OpenAI login popup instead of pasting cookies manually
|
||||||
- Merge numeric usage fields into one aggregate dashboard
|
- Refresh Codex usage data and merge numeric usage fields into one aggregate dashboard
|
||||||
- Inspect each connected account individually with raw API payload details
|
- 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
|
### Important local-dev note
|
||||||
2. Copy the authenticated `Cookie` header
|
|
||||||
3. Paste it into the **Connect OpenAI account** dialog in CodexDash
|
|
||||||
|
|
||||||
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
|
## Local development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm --filter @codexdash/api exec prisma generate
|
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 ../..
|
cd ../..
|
||||||
pnpm --filter @codexdash/api start:dev
|
pnpm --filter @codexdash/api start:dev
|
||||||
pnpm --filter @codexdash/web dev --host 0.0.0.0
|
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
|
## Environment variables
|
||||||
|
|
||||||
### `apps/api/.env`
|
### Root `.env`
|
||||||
|
|
||||||
```env
|
```env
|
||||||
JWT_SECRET=dev-jwt-secret-for-codexdash
|
JWT_SECRET=***
|
||||||
ENCRYPTION_SECRET=dev-encryption-secret-for-codexdash-32chars
|
ENCRYPTION_SECRET=***
|
||||||
DATABASE_URL=file:./dev.db
|
DATABASE_URL=file:./dev.db
|
||||||
```
|
CODEXDASH_FRONTEND_ORIGIN=http://localhost:5173
|
||||||
|
CODEX_OAUTH_REDIRECT_URI=http://localhost:1455/auth/callback
|
||||||
### `apps/web/.env`
|
|
||||||
|
|
||||||
```env
|
|
||||||
VITE_API_BASE_URL=http://localhost:3001
|
VITE_API_BASE_URL=http://localhost:3001
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -71,6 +71,10 @@ curl http://localhost:3001/health
|
|||||||
- `POST /auth/login`
|
- `POST /auth/login`
|
||||||
- `GET /auth/me`
|
- `GET /auth/me`
|
||||||
- `GET /codex/accounts`
|
- `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`
|
- `DELETE /codex/accounts/:accountId`
|
||||||
- `GET /codex/usage-summary`
|
- `GET /codex/usage-summary`
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ model User {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
accounts OpenAiAccount[]
|
accounts OpenAiAccount[]
|
||||||
|
loginAttempts OpenAiLoginAttempt[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model OpenAiAccount {
|
model OpenAiAccount {
|
||||||
@@ -22,14 +23,42 @@ model OpenAiAccount {
|
|||||||
userId String
|
userId String
|
||||||
label String
|
label String
|
||||||
emailHint String?
|
emailHint String?
|
||||||
encryptedCookie String
|
providerEmail String?
|
||||||
|
providerAccountId String?
|
||||||
|
planType String?
|
||||||
|
authType String @default("codex-oauth")
|
||||||
|
encryptedSessionJson String
|
||||||
|
sessionExpiresAt DateTime?
|
||||||
|
lastValidatedAt DateTime?
|
||||||
lastUsageJson Json?
|
lastUsageJson Json?
|
||||||
lastSyncedAt DateTime?
|
lastSyncedAt DateTime?
|
||||||
lastError String?
|
lastError String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
loginAttempts OpenAiLoginAttempt[]
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([userId])
|
@@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])
|
||||||
|
}
|
||||||
|
|||||||
83
apps/api/src/codex/codex-oauth.spec.ts
Normal file
83
apps/api/src/codex/codex-oauth.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
178
apps/api/src/codex/codex-oauth.ts
Normal file
178
apps/api/src/codex/codex-oauth.ts
Normal 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('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
@@ -6,34 +6,71 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
|
Res,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import type { Response } from 'express';
|
||||||
import {
|
import {
|
||||||
CurrentUser,
|
CurrentUser,
|
||||||
type AuthenticatedUser,
|
type AuthenticatedUser,
|
||||||
} from '../common/current-user.decorator';
|
} from '../common/current-user.decorator';
|
||||||
import { JwtAuthGuard } from '../common/jwt-auth.guard';
|
import { JwtAuthGuard } from '../common/jwt-auth.guard';
|
||||||
import { CodexService } from './codex.service';
|
import { CodexService } from './codex.service';
|
||||||
import { ConnectAccountDto } from './dto/connect-account.dto';
|
import { StartCodexLoginDto } from './dto/start-codex-login.dto';
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@Controller('codex')
|
@Controller('codex')
|
||||||
export class CodexController {
|
export class CodexController {
|
||||||
constructor(private readonly codexService: CodexService) {}
|
constructor(private readonly codexService: CodexService) {}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
@Get('accounts')
|
@Get('accounts')
|
||||||
listAccounts(@CurrentUser() user: AuthenticatedUser) {
|
listAccounts(@CurrentUser() user: AuthenticatedUser) {
|
||||||
return this.codexService.listAccounts(user.sub);
|
return this.codexService.listAccounts(user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('accounts')
|
@UseGuards(JwtAuthGuard)
|
||||||
connectAccount(
|
@Post('accounts/login/start')
|
||||||
|
startAccountLogin(
|
||||||
@CurrentUser() user: AuthenticatedUser,
|
@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')
|
@Delete('accounts/:accountId')
|
||||||
deleteAccount(
|
deleteAccount(
|
||||||
@CurrentUser() user: AuthenticatedUser,
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
@@ -42,6 +79,7 @@ export class CodexController {
|
|||||||
return this.codexService.deleteAccount(user.sub, accountId);
|
return this.codexService.deleteAccount(user.sub, accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
@Get('usage-summary')
|
@Get('usage-summary')
|
||||||
getUsageSummary(
|
getUsageSummary(
|
||||||
@CurrentUser() user: AuthenticatedUser,
|
@CurrentUser() user: AuthenticatedUser,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
Injectable,
|
Injectable,
|
||||||
@@ -5,14 +6,41 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { Prisma } from '@prisma/client';
|
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 { decryptString, encryptString } from '../common/crypto';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
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';
|
import { aggregateUsagePayloads } from './usage-aggregator';
|
||||||
|
|
||||||
type UsageApiResponse = Record<string, unknown>;
|
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()
|
@Injectable()
|
||||||
export class CodexService {
|
export class CodexService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -20,26 +48,86 @@ export class CodexService {
|
|||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async connectAccount(
|
async startAccountLogin(
|
||||||
userId: string,
|
userId: string,
|
||||||
dto: ConnectAccountDto,
|
dto: StartCodexLoginDto,
|
||||||
): Promise<ConnectedAccount> {
|
): Promise<StartCodexLoginResponse> {
|
||||||
const usage = await this.fetchUsage(dto.cookieHeader);
|
const pkce = createPkcePair(randomBytes(32));
|
||||||
const account = await this.prisma.openAiAccount.create({
|
const state = randomBytes(32).toString('base64url');
|
||||||
|
const expiresAt = new Date(Date.now() + 10 * 60 * 1000);
|
||||||
|
const attempt = await this.prisma.openAiLoginAttempt.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
label: dto.label.trim(),
|
label: dto.label.trim(),
|
||||||
emailHint: dto.emailHint?.trim() || null,
|
emailHint: dto.emailHint?.trim() || null,
|
||||||
encryptedCookie: encryptString(
|
state,
|
||||||
dto.cookieHeader,
|
encryptedCodeVerifier: encryptString(
|
||||||
|
pkce.verifier,
|
||||||
this.getEncryptionSecret(),
|
this.getEncryptionSecret(),
|
||||||
),
|
),
|
||||||
lastUsageJson: usage as Prisma.InputJsonValue,
|
expiresAt,
|
||||||
lastSyncedAt: new Date(),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
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) {
|
async listAccounts(userId: string) {
|
||||||
@@ -59,6 +147,10 @@ export class CodexService {
|
|||||||
throw new NotFoundException('Connected account not found');
|
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 } });
|
await this.prisma.openAiAccount.delete({ where: { id: accountId } });
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
@@ -76,17 +168,8 @@ export class CodexService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const usage = await this.fetchUsage(
|
const updated = await this.refreshAccountUsage(account);
|
||||||
decryptString(account.encryptedCookie, this.getEncryptionSecret()),
|
return updated;
|
||||||
);
|
|
||||||
return this.prisma.openAiAccount.update({
|
|
||||||
where: { id: account.id },
|
|
||||||
data: {
|
|
||||||
lastUsageJson: usage as Prisma.InputJsonValue,
|
|
||||||
lastSyncedAt: new Date(),
|
|
||||||
lastError: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return this.prisma.openAiAccount.update({
|
return this.prisma.openAiAccount.update({
|
||||||
where: { id: account.id },
|
where: { id: account.id },
|
||||||
@@ -121,25 +204,371 @@ export class CodexService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fetchUsage(cookieHeader: string): Promise<UsageApiResponse> {
|
async handleOauthCallbackRequest(rawUrl: string) {
|
||||||
const response = await fetch(
|
const callbackUrl = new URL(rawUrl, this.getOauthRedirectUri());
|
||||||
'https://chatgpt.com/backend-api/api/codex/usage',
|
const code = callbackUrl.searchParams.get('code');
|
||||||
{
|
const state = callbackUrl.searchParams.get('state');
|
||||||
headers: {
|
const oauthError = callbackUrl.searchParams.get('error');
|
||||||
accept: 'application/json',
|
const oauthErrorDescription =
|
||||||
cookie: cookieHeader,
|
callbackUrl.searchParams.get('error_description');
|
||||||
'user-agent': 'CodexDash/0.1 (+https://example.invalid)',
|
|
||||||
},
|
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}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Codex usage request failed with status ${response.status}`,
|
`Codex usage request failed (${errors.join(', ')})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (await response.json()) as UsageApiResponse;
|
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(
|
||||||
|
`OpenAI token exchange failed (${response.status}): ${error || 'No details returned.'}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
private getEncryptionSecret() {
|
||||||
@@ -149,20 +578,48 @@ export class CodexService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private toAccountView(account: {
|
private getFrontendOrigin() {
|
||||||
id: string;
|
return (
|
||||||
label: string;
|
this.configService.get<string>('CODEXDASH_FRONTEND_ORIGIN') ??
|
||||||
emailHint: string | null;
|
'http://localhost:5173'
|
||||||
lastUsageJson: unknown;
|
);
|
||||||
lastSyncedAt: Date | null;
|
}
|
||||||
lastError: string | null;
|
|
||||||
createdAt: Date;
|
private getOauthRedirectUri() {
|
||||||
}): ConnectedAccount {
|
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 {
|
return {
|
||||||
id: account.id,
|
id: account.id,
|
||||||
label: account.label,
|
label: account.label,
|
||||||
emailHint: account.emailHint,
|
emailHint: account.emailHint,
|
||||||
|
providerEmail: account.providerEmail,
|
||||||
|
providerAccountId: account.providerAccountId,
|
||||||
|
planType: account.planType,
|
||||||
|
authType: 'codex-oauth',
|
||||||
status: account.lastError ? 'error' : 'active',
|
status: account.lastError ? 'error' : 'active',
|
||||||
|
sessionExpiresAt: account.sessionExpiresAt?.toISOString() ?? null,
|
||||||
lastSyncedAt: account.lastSyncedAt?.toISOString() ?? null,
|
lastSyncedAt: account.lastSyncedAt?.toISOString() ?? null,
|
||||||
lastError: account.lastError,
|
lastError: account.lastError,
|
||||||
usage: (account.lastUsageJson as Record<string, unknown> | null) ?? null,
|
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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
12
apps/api/src/codex/dto/start-codex-login.dto.ts
Normal file
12
apps/api/src/codex/dto/start-codex-login.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { createServer } from 'node:http';
|
||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
import { CodexService } from './codex/codex.service';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
@@ -14,5 +16,39 @@ async function bootstrap() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await app.listen(process.env.PORT ?? 3001);
|
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();
|
void bootstrap();
|
||||||
|
|||||||
@@ -1,20 +1,54 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import {
|
||||||
import type { AuthResponse, LoginInput, RegisterInput } from '@codexdash/shared-types';
|
useMutation,
|
||||||
import { Activity, CirclePlus, Gauge, LogOut, RefreshCw, ShieldCheck, Trash2 } from 'lucide-react';
|
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 { toast, Toaster } from 'sonner';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { clearToken, getToken, setToken } from '@/lib/storage';
|
import { clearToken, getToken, setToken } from '@/lib/storage';
|
||||||
import { flattenNumericMetrics, formatDate, titleizeMetric } from '@/lib/utils';
|
import { flattenNumericMetrics, formatDate, titleizeMetric } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Badge } from '@/components/ui/badge';
|
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 { Progress } from '@/components/ui/progress';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
@@ -34,27 +68,45 @@ const loginSchema = z.object({
|
|||||||
const connectSchema = z.object({
|
const connectSchema = z.object({
|
||||||
label: z.string().min(2),
|
label: z.string().min(2),
|
||||||
emailHint: z.string().optional(),
|
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 [mode, setMode] = useState<'login' | 'register'>('register');
|
||||||
const schema = mode === 'register' ? registerSchema : loginSchema;
|
const schema = mode === 'register' ? registerSchema : loginSchema;
|
||||||
const form = useForm<{ name?: string; email: string; password: string }>({
|
const form = useForm<{ name?: string; email: string; password: string }>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: { email: '', password: '', ...(mode === 'register' ? { name: '' } : {}) },
|
defaultValues: {
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
...(mode === 'register' ? { name: '' } : {}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: async (values: { name?: string; email: string; password: string }) => {
|
mutationFn: async (values: {
|
||||||
|
name?: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}) => {
|
||||||
return mode === 'register'
|
return mode === 'register'
|
||||||
? api.register(values as RegisterInput)
|
? 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) => {
|
onSuccess: (response) => {
|
||||||
setToken(response.token);
|
setToken(response.token);
|
||||||
onAuthenticated(response);
|
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),
|
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))]">
|
<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">
|
<CardContent className="flex h-full flex-col justify-between gap-8 p-6 sm:p-8">
|
||||||
<div className="space-y-5">
|
<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">
|
<div className="space-y-4">
|
||||||
<h1 className="max-w-xl text-4xl font-semibold tracking-tight text-white sm:text-5xl">
|
<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.
|
CodexDash keeps every Codex account in one gorgeous live dashboard.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="max-w-xl text-base leading-7 text-slate-300 sm:text-lg">
|
<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.
|
remaining usage, raw API payloads, and per-account drilldowns from a single responsive UI.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
<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: Gauge,
|
||||||
{ icon: Activity, title: 'Live detail', desc: 'See refreshed usage plus raw usage payloads.' },
|
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) => (
|
].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" />
|
<item.icon className="mb-3 size-5 text-sky-300" />
|
||||||
<div className="font-medium text-white">{item.title}</div>
|
<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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -94,34 +165,63 @@ function AuthShell({ onAuthenticated }: { onAuthenticated: (response: AuthRespon
|
|||||||
|
|
||||||
<Card className="border-white/10 bg-slate-950/88">
|
<Card className="border-white/10 bg-slate-950/88">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{mode === 'register' ? 'Create your account' : 'Welcome back'}</CardTitle>
|
<CardTitle>
|
||||||
|
{mode === 'register' ? 'Create your account' : 'Welcome back'}
|
||||||
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{mode === 'register'
|
{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.'}
|
: 'Log in to continue monitoring your combined Codex usage.'}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<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' ? (
|
{mode === 'register' ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name">Display name</Label>
|
<Label htmlFor="name">Display name</Label>
|
||||||
<Input id="name" placeholder="Codex operator" {...form.register('name' as const)} />
|
<Input
|
||||||
<p className="text-xs text-rose-300">{String(form.formState.errors.name?.message ?? '')}</p>
|
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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email">Email</Label>
|
<Label htmlFor="email">Email</Label>
|
||||||
<Input id="email" type="email" placeholder="you@example.com" {...form.register('email')} />
|
<Input
|
||||||
<p className="text-xs text-rose-300">{String(form.formState.errors.email?.message ?? '')}</p>
|
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>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password">Password</Label>
|
<Label htmlFor="password">Password</Label>
|
||||||
<Input id="password" type="password" placeholder="At least 8 characters" {...form.register('password')} />
|
<Input
|
||||||
<p className="text-xs text-rose-300">{String(form.formState.errors.password?.message ?? '')}</p>
|
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>
|
</div>
|
||||||
<Button className="w-full" disabled={mutation.isPending} type="submit">
|
<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>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -129,12 +229,20 @@ function AuthShell({ onAuthenticated }: { onAuthenticated: (response: AuthRespon
|
|||||||
|
|
||||||
<div className="space-y-3 text-sm text-slate-400">
|
<div className="space-y-3 text-sm text-slate-400">
|
||||||
<p>
|
<p>
|
||||||
OpenAI account connection is implemented as a session-based Codex login: after signing into
|
OpenAI account connection now uses a real sign-in flow based on the Codex client OAuth pattern.
|
||||||
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>{' '}
|
After you click connect, CodexDash opens OpenAI login in a popup and receives the callback locally.
|
||||||
header into the connect flow.
|
|
||||||
</p>
|
</p>
|
||||||
<Button type="button" variant="ghost" className="px-0 text-sky-300 hover:bg-transparent" onClick={() => setMode(mode === 'register' ? 'login' : 'register')}>
|
<Button
|
||||||
{mode === 'register' ? 'Already have an account? Sign in' : 'Need an account? Register'}
|
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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -147,24 +255,111 @@ function AuthShell({ onAuthenticated }: { onAuthenticated: (response: AuthRespon
|
|||||||
function ConnectAccountDialog() {
|
function ConnectAccountDialog() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [open, setOpen] = useState(false);
|
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>>({
|
const form = useForm<z.infer<typeof connectSchema>>({
|
||||||
resolver: zodResolver(connectSchema),
|
resolver: zodResolver(connectSchema),
|
||||||
defaultValues: { label: '', emailHint: '', cookieHeader: '' },
|
defaultValues: { label: '', emailHint: '' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const mutation = useMutation({
|
const startMutation = useMutation({
|
||||||
mutationFn: api.connectAccount,
|
mutationFn: api.startCodexLogin,
|
||||||
onSuccess: () => {
|
onSuccess: (response) => {
|
||||||
toast.success('OpenAI Codex session connected.');
|
setAttemptId(response.attemptId);
|
||||||
setOpen(false);
|
setAuthorizeUrl(response.authorizeUrl);
|
||||||
form.reset();
|
popupRef.current = window.open(
|
||||||
void queryClient.invalidateQueries({ queryKey: ['usage-summary'] });
|
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),
|
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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
setOpen(next);
|
||||||
|
if (!next && isPendingAttempt && attemptId) {
|
||||||
|
cancelMutation.mutate(attemptId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button className="w-full sm:w-auto">
|
<Button className="w-full sm:w-auto">
|
||||||
<CirclePlus className="size-4" />
|
<CirclePlus className="size-4" />
|
||||||
@@ -173,37 +368,122 @@ function ConnectAccountDialog() {
|
|||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Connect an OpenAI Codex session</DialogTitle>
|
<DialogTitle>Connect an OpenAI Codex account</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Paste the authenticated <code className="rounded bg-white/10 px-1 py-0.5 text-slate-200">Cookie</code>{' '}
|
Start a real OpenAI sign-in flow. CodexDash opens the official login,
|
||||||
header from a signed-in <code className="rounded bg-white/10 px-1 py-0.5 text-slate-200">chatgpt.com</code>{' '}
|
completes the Codex-style OAuth callback locally, then stores the
|
||||||
session. CodexDash will use it to call the official usage endpoint.
|
encrypted session for future usage refreshes.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form className="mt-5 space-y-4" onSubmit={form.handleSubmit((values) => mutation.mutate(values))}>
|
|
||||||
|
{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>
|
||||||
|
) : (
|
||||||
|
<form
|
||||||
|
className="mt-5 space-y-4"
|
||||||
|
onSubmit={form.handleSubmit((values) =>
|
||||||
|
startMutation.mutate(values as StartCodexLoginInput)
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="label">Account label</Label>
|
<Label htmlFor="label">Account label</Label>
|
||||||
<Input id="label" placeholder="Primary Team Pro" {...form.register('label')} />
|
<Input
|
||||||
<p className="text-xs text-rose-300">{String(form.formState.errors.label?.message ?? '')}</p>
|
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>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="emailHint">Email hint</Label>
|
<Label htmlFor="emailHint">Email hint</Label>
|
||||||
<Input id="emailHint" placeholder="ops@example.com" {...form.register('emailHint')} />
|
<Input
|
||||||
</div>
|
id="emailHint"
|
||||||
<div className="space-y-2">
|
placeholder="ops@example.com"
|
||||||
<Label htmlFor="cookieHeader">Cookie header</Label>
|
{...form.register('emailHint')}
|
||||||
<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>
|
</div>
|
||||||
<Button className="w-full" disabled={mutation.isPending} type="submit">
|
<div className="rounded-2xl border border-white/10 bg-white/4 p-4 text-sm leading-6 text-slate-400">
|
||||||
{mutation.isPending ? 'Connecting…' : 'Validate and connect'}
|
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>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
@@ -211,7 +491,10 @@ function ConnectAccountDialog() {
|
|||||||
|
|
||||||
function Dashboard() {
|
function Dashboard() {
|
||||||
const queryClient = useQueryClient();
|
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 userQuery = useQuery({ queryKey: ['me'], queryFn: api.me });
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: api.deleteAccount,
|
mutationFn: api.deleteAccount,
|
||||||
@@ -227,7 +510,11 @@ function Dashboard() {
|
|||||||
}, [summaryQuery.data?.aggregatedUsage]);
|
}, [summaryQuery.data?.aggregatedUsage]);
|
||||||
|
|
||||||
if (summaryQuery.isLoading || userQuery.isLoading) {
|
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) {
|
if (summaryQuery.isError || userQuery.isError) {
|
||||||
@@ -237,7 +524,8 @@ function Dashboard() {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Unable to load dashboard</CardTitle>
|
<CardTitle>Unable to load dashboard</CardTitle>
|
||||||
<CardDescription>
|
<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>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -252,17 +540,23 @@ function Dashboard() {
|
|||||||
const user = userQuery.data!;
|
const user = userQuery.data!;
|
||||||
const firstMetric = metricCards[0]?.value ?? 0;
|
const firstMetric = metricCards[0]?.value ?? 0;
|
||||||
const secondMetric = metricCards[1]?.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 (
|
return (
|
||||||
<div className="mx-auto min-h-screen max-w-7xl px-4 py-5 sm:px-6 lg:px-8">
|
<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 className="mb-6 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<Badge className="mb-3 w-fit border-emerald-400/20 bg-emerald-400/10 text-emerald-200">Signed in as {user.name}</Badge>
|
<Badge className="mb-3 w-fit border-emerald-400/20 bg-emerald-400/10 text-emerald-200">
|
||||||
<h1 className="text-3xl font-semibold text-white sm:text-4xl">CodexDash overview</h1>
|
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">
|
<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>{' '}
|
Combined usage is refreshed by calling Codex usage endpoints for each attached OpenAI account and merging numeric fields into one dashboard.
|
||||||
for each attached OpenAI account and merging numeric fields into one dashboard.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-3 sm:flex-row">
|
<div className="flex flex-col gap-3 sm:flex-row">
|
||||||
@@ -271,7 +565,13 @@ function Dashboard() {
|
|||||||
Refresh now
|
Refresh now
|
||||||
</Button>
|
</Button>
|
||||||
<ConnectAccountDialog />
|
<ConnectAccountDialog />
|
||||||
<Button variant="ghost" onClick={() => { clearToken(); window.location.reload(); }}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => {
|
||||||
|
clearToken();
|
||||||
|
window.location.reload();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<LogOut className="size-4" />
|
<LogOut className="size-4" />
|
||||||
Sign out
|
Sign out
|
||||||
</Button>
|
</Button>
|
||||||
@@ -282,17 +582,27 @@ function Dashboard() {
|
|||||||
<Card className="md:col-span-2">
|
<Card className="md:col-span-2">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Unified capacity</CardTitle>
|
<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>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="flex items-end justify-between gap-4">
|
<div className="flex items-end justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-4xl font-semibold text-white">{firstMetric.toLocaleString()}</div>
|
<div className="text-4xl font-semibold text-white">
|
||||||
<div className="mt-1 text-sm text-slate-400">{titleizeMetric(metricCards[0]?.label ?? 'Primary metric')}</div>
|
{firstMetric.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-sm text-slate-400">
|
||||||
|
{titleizeMetric(metricCards[0]?.label ?? 'Primary metric')}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-2xl font-semibold text-slate-100">{secondMetric.toLocaleString()}</div>
|
<div className="text-2xl font-semibold text-slate-100">
|
||||||
<div className="mt-1 text-sm text-slate-500">{titleizeMetric(metricCards[1]?.label ?? 'Secondary metric')}</div>
|
{secondMetric.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-sm text-slate-500">
|
||||||
|
{titleizeMetric(metricCards[1]?.label ?? 'Secondary metric')}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Progress value={progressValue} />
|
<Progress value={progressValue} />
|
||||||
@@ -306,14 +616,28 @@ function Dashboard() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{[
|
{[
|
||||||
{ title: 'Connected sessions', value: summary.totals.totalAccounts, tone: 'text-sky-300' },
|
{
|
||||||
{ title: 'Healthy sessions', value: summary.totals.activeAccounts, tone: 'text-emerald-300' },
|
title: 'Connected sessions',
|
||||||
{ title: 'Errored sessions', value: summary.totals.erroredAccounts, tone: 'text-rose-300' },
|
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) => (
|
].map((item) => (
|
||||||
<Card key={item.title}>
|
<Card key={item.title}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardDescription>{item.title}</CardDescription>
|
<CardDescription>{item.title}</CardDescription>
|
||||||
<CardTitle className={item.tone}>{item.value.toLocaleString()}</CardTitle>
|
<CardTitle className={item.tone}>
|
||||||
|
{item.value.toLocaleString()}
|
||||||
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
@@ -323,19 +647,28 @@ function Dashboard() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Usage metrics</CardTitle>
|
<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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{metricCards.length === 0 ? (
|
{metricCards.length === 0 ? (
|
||||||
<div className="rounded-2xl border border-dashed border-white/10 bg-white/3 p-6 text-sm text-slate-400">
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
{metricCards.map((metric) => (
|
{metricCards.map((metric) => (
|
||||||
<div key={metric.label} className="rounded-2xl border border-white/10 bg-white/4 p-4">
|
<div
|
||||||
<div className="text-sm text-slate-400">{titleizeMetric(metric.label)}</div>
|
key={metric.label}
|
||||||
<div className="mt-3 text-2xl font-semibold text-white">{metric.value.toLocaleString()}</div>
|
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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -366,7 +699,11 @@ function Dashboard() {
|
|||||||
<Tabs defaultValue={summary.accounts[0]?.id}>
|
<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">
|
<TabsList className="mb-4 flex h-auto w-full flex-wrap justify-start gap-2 bg-transparent p-0">
|
||||||
{summary.accounts.map((account) => (
|
{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}
|
{account.label}
|
||||||
</TabsTrigger>
|
</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="space-y-4 rounded-3xl border border-white/10 bg-white/4 p-5">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-lg font-semibold text-white">{account.label}</div>
|
<div className="text-lg font-semibold text-white">
|
||||||
<div className="mt-1 text-sm text-slate-400">{account.emailHint || 'No email hint provided'}</div>
|
{account.label}
|
||||||
</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'}>
|
<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'
|
||||||
|
}
|
||||||
|
>
|
||||||
{account.status}
|
{account.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="space-y-2 text-sm text-slate-300">
|
<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>Last synced: {formatDate(account.lastSyncedAt)}</div>
|
||||||
<div>Connected: {formatDate(account.createdAt)}</div>
|
<div>Connected: {formatDate(account.createdAt)}</div>
|
||||||
<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>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<Button variant="outline" onClick={() => summaryQuery.refetch()}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => summaryQuery.refetch()}
|
||||||
|
>
|
||||||
<RefreshCw className="size-4" />
|
<RefreshCw className="size-4" />
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</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" />
|
<Trash2 className="size-4" />
|
||||||
Remove
|
Remove
|
||||||
</Button>
|
</Button>
|
||||||
@@ -406,7 +777,11 @@ function Dashboard() {
|
|||||||
<JsonViewer
|
<JsonViewer
|
||||||
title="Account payload"
|
title="Account payload"
|
||||||
description="Most recent raw usage JSON for this specific OpenAI Codex account."
|
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>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -425,7 +800,11 @@ export default function App() {
|
|||||||
return (
|
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">
|
<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" />
|
<Toaster richColors position="top-center" theme="dark" />
|
||||||
{authenticated ? <Dashboard /> : <AuthShell onAuthenticated={() => setAuthenticated(true)} />}
|
{authenticated ? (
|
||||||
|
<Dashboard />
|
||||||
|
) : (
|
||||||
|
<AuthShell onAuthenticated={() => setAuthenticated(true)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import type {
|
import type {
|
||||||
AuthResponse,
|
AuthResponse,
|
||||||
ConnectAccountInput,
|
CodexLoginAttemptResponse,
|
||||||
ConnectedAccount,
|
ConnectedAccount,
|
||||||
LoginInput,
|
LoginInput,
|
||||||
RegisterInput,
|
RegisterInput,
|
||||||
|
StartCodexLoginInput,
|
||||||
|
StartCodexLoginResponse,
|
||||||
UsageSummary,
|
UsageSummary,
|
||||||
UserProfile,
|
UserProfile,
|
||||||
} from '@codexdash/shared-types';
|
} from '@codexdash/shared-types';
|
||||||
@@ -27,7 +29,9 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
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');
|
throw new Error(error.message ?? 'Request failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,12 +40,32 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
register: (input: RegisterInput) =>
|
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) =>
|
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'),
|
me: () => request<UserProfile>('/auth/me'),
|
||||||
getUsageSummary: (refresh = true) => request<UsageSummary>(`/codex/usage-summary?refresh=${refresh}`),
|
getUsageSummary: (refresh = true) =>
|
||||||
connectAccount: (input: ConnectAccountInput) =>
|
request<UsageSummary>(`/codex/usage-summary?refresh=${refresh}`),
|
||||||
request<ConnectedAccount>('/codex/accounts', { method: 'POST', body: JSON.stringify(input) }),
|
startCodexLogin: (input: StartCodexLoginInput) =>
|
||||||
deleteAccount: (accountId: string) => request<{ ok: boolean }>(`/codex/accounts/${accountId}`, { method: 'DELETE' }),
|
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'),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,11 +12,25 @@ export type UserProfile = {
|
|||||||
|
|
||||||
export type CodexUsagePayload = Record<string, unknown>;
|
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 = {
|
export type ConnectedAccount = {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
emailHint: string | null;
|
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;
|
lastSyncedAt: string | null;
|
||||||
lastError: string | null;
|
lastError: string | null;
|
||||||
usage: CodexUsagePayload | null;
|
usage: CodexUsagePayload | null;
|
||||||
@@ -34,10 +48,26 @@ export type UsageSummary = {
|
|||||||
refreshedAt: string;
|
refreshedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ConnectAccountInput = {
|
export type StartCodexLoginInput = {
|
||||||
label: string;
|
label: string;
|
||||||
emailHint?: 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 = {
|
export type RegisterInput = {
|
||||||
|
|||||||
Reference in New Issue
Block a user