feat: bootstrap codexdash app

This commit is contained in:
2026-05-01 01:33:57 +09:00
commit 0ced12cb81
55 changed files with 10481 additions and 0 deletions

4
apps/api/.prettierrc Normal file
View File

@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

98
apps/api/README.md Normal file
View File

@@ -0,0 +1,98 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ pnpm install
```
## Compile and run the project
```bash
# development
$ pnpm run start
# watch mode
$ pnpm run start:dev
# production mode
$ pnpm run start:prod
```
## Run tests
```bash
# unit tests
$ pnpm run test
# e2e tests
$ pnpm run test:e2e
# test coverage
$ pnpm run test:cov
```
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ pnpm install -g @nestjs/mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).

View File

@@ -0,0 +1,35 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
},
);

8
apps/api/nest-cli.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

69
apps/api/package.json Normal file
View File

@@ -0,0 +1,69 @@
{
"name": "@codexdash/api",
"version": "0.1.0",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,test}/**/*.ts\" --fix",
"test": "jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"prisma:generate": "prisma generate",
"prisma:push": "prisma db push"
},
"dependencies": {
"@codexdash/shared-types": "workspace:*",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.1",
"@nestjs/platform-express": "^11.0.1",
"@prisma/client": "6.16.2",
"argon2": "^0.44.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.2",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^24.0.0",
"@types/supertest": "^7.0.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^17.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"prisma": "6.16.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": ["**/*.(t|j)s"],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

View File

@@ -0,0 +1,35 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
passwordHash String
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts OpenAiAccount[]
}
model OpenAiAccount {
id String @id @default(cuid())
userId String
label String
emailHint String?
encryptedCookie String
lastUsageJson Json?
lastSyncedAt DateTime?
lastError String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}

View File

@@ -0,0 +1,13 @@
import { Controller, Get } from '@nestjs/common';
@Controller()
export class AppController {
@Get('health')
health() {
return {
ok: true,
service: 'codexdash-api',
timestamp: new Date().toISOString(),
};
}
}

View File

@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AppController } from './app.controller';
import { AuthModule } from './auth/auth.module';
import { CodexModule } from './codex/codex.module';
import { PrismaModule } from './prisma/prisma.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
PrismaModule,
AuthModule,
CodexModule,
],
controllers: [AppController],
providers: [],
})
export class AppModule {}

View File

@@ -0,0 +1,30 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import {
CurrentUser,
type AuthenticatedUser,
} from '../common/current-user.decorator';
import { JwtAuthGuard } from '../common/jwt-auth.guard';
import { AuthService } from './auth.service';
import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('register')
register(@Body() dto: RegisterDto) {
return this.authService.register(dto);
}
@Post('login')
login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
@UseGuards(JwtAuthGuard)
@Get('me')
me(@CurrentUser() user: AuthenticatedUser) {
return this.authService.me(user.sub);
}
}

View File

@@ -0,0 +1,23 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from '../common/jwt-auth.guard';
@Module({
imports: [
ConfigModule,
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET') ?? 'change-me',
signOptions: { expiresIn: '30d' },
}),
}),
],
controllers: [AuthController],
providers: [AuthService, JwtAuthGuard],
exports: [JwtModule, JwtAuthGuard],
})
export class AuthModule {}

View File

@@ -0,0 +1,84 @@
import {
ConflictException,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as argon2 from 'argon2';
import { AuthResponse, UserProfile } from '@codexdash/shared-types';
import { PrismaService } from '../prisma/prisma.service';
import { LoginDto } from './dto/login.dto';
import { RegisterDto } from './dto/register.dto';
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwtService: JwtService,
) {}
async register(dto: RegisterDto): Promise<AuthResponse> {
const existing = await this.prisma.user.findUnique({
where: { email: dto.email.toLowerCase() },
});
if (existing) {
throw new ConflictException('Email already registered');
}
const user = await this.prisma.user.create({
data: {
email: dto.email.toLowerCase(),
name: dto.name.trim(),
passwordHash: await argon2.hash(dto.password),
},
});
return this.toAuthResponse(user);
}
async login(dto: LoginDto): Promise<AuthResponse> {
const user = await this.prisma.user.findUnique({
where: { email: dto.email.toLowerCase() },
});
if (!user || !(await argon2.verify(user.passwordHash, dto.password))) {
throw new UnauthorizedException('Invalid email or password');
}
return this.toAuthResponse(user);
}
async me(userId: string): Promise<UserProfile> {
const user = await this.prisma.user.findUniqueOrThrow({
where: { id: userId },
});
return {
id: user.id,
email: user.email,
name: user.name,
createdAt: user.createdAt.toISOString(),
};
}
private async toAuthResponse(user: {
id: string;
email: string;
name: string;
createdAt: Date;
}): Promise<AuthResponse> {
return {
token: await this.jwtService.signAsync({
sub: user.id,
email: user.email,
}),
user: {
id: user.id,
email: user.email,
name: user.name,
createdAt: user.createdAt.toISOString(),
},
};
}
}

View File

@@ -0,0 +1,11 @@
import { IsEmail, IsString, MinLength } from 'class-validator';
import { LoginInput } from '@codexdash/shared-types';
export class LoginDto implements LoginInput {
@IsEmail()
email!: string;
@IsString()
@MinLength(8)
password!: string;
}

View File

@@ -0,0 +1,15 @@
import { IsEmail, IsString, MinLength } from 'class-validator';
import { RegisterInput } from '@codexdash/shared-types';
export class RegisterDto implements RegisterInput {
@IsEmail()
email!: string;
@IsString()
@MinLength(8)
password!: string;
@IsString()
@MinLength(2)
name!: string;
}

View File

@@ -0,0 +1,52 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import {
CurrentUser,
type AuthenticatedUser,
} from '../common/current-user.decorator';
import { JwtAuthGuard } from '../common/jwt-auth.guard';
import { CodexService } from './codex.service';
import { ConnectAccountDto } from './dto/connect-account.dto';
@UseGuards(JwtAuthGuard)
@Controller('codex')
export class CodexController {
constructor(private readonly codexService: CodexService) {}
@Get('accounts')
listAccounts(@CurrentUser() user: AuthenticatedUser) {
return this.codexService.listAccounts(user.sub);
}
@Post('accounts')
connectAccount(
@CurrentUser() user: AuthenticatedUser,
@Body() dto: ConnectAccountDto,
) {
return this.codexService.connectAccount(user.sub, dto);
}
@Delete('accounts/:accountId')
deleteAccount(
@CurrentUser() user: AuthenticatedUser,
@Param('accountId') accountId: string,
) {
return this.codexService.deleteAccount(user.sub, accountId);
}
@Get('usage-summary')
getUsageSummary(
@CurrentUser() user: AuthenticatedUser,
@Query('refresh') refresh?: string,
) {
return this.codexService.getUsageSummary(user.sub, refresh !== 'false');
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { CodexController } from './codex.controller';
import { CodexService } from './codex.service';
@Module({
imports: [AuthModule],
controllers: [CodexController],
providers: [CodexService],
})
export class CodexModule {}

View File

@@ -0,0 +1,172 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Prisma } from '@prisma/client';
import { ConnectedAccount, UsageSummary } from '@codexdash/shared-types';
import { decryptString, encryptString } from '../common/crypto';
import { PrismaService } from '../prisma/prisma.service';
import { ConnectAccountDto } from './dto/connect-account.dto';
import { aggregateUsagePayloads } from './usage-aggregator';
type UsageApiResponse = Record<string, unknown>;
@Injectable()
export class CodexService {
constructor(
private readonly prisma: PrismaService,
private readonly configService: ConfigService,
) {}
async connectAccount(
userId: string,
dto: ConnectAccountDto,
): Promise<ConnectedAccount> {
const usage = await this.fetchUsage(dto.cookieHeader);
const account = await this.prisma.openAiAccount.create({
data: {
userId,
label: dto.label.trim(),
emailHint: dto.emailHint?.trim() || null,
encryptedCookie: encryptString(
dto.cookieHeader,
this.getEncryptionSecret(),
),
lastUsageJson: usage as Prisma.InputJsonValue,
lastSyncedAt: new Date(),
},
});
return this.toAccountView(account);
}
async listAccounts(userId: string) {
const accounts = await this.prisma.openAiAccount.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
});
return accounts.map((account) => this.toAccountView(account));
}
async deleteAccount(userId: string, accountId: string) {
const account = await this.prisma.openAiAccount.findFirst({
where: { id: accountId, userId },
});
if (!account) {
throw new NotFoundException('Connected account not found');
}
await this.prisma.openAiAccount.delete({ where: { id: accountId } });
return { ok: true };
}
async getUsageSummary(userId: string, refresh = true): Promise<UsageSummary> {
const accounts = await this.prisma.openAiAccount.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
});
const resolvedAccounts = await Promise.all(
accounts.map(async (account) => {
if (!refresh) {
return account;
}
try {
const usage = await this.fetchUsage(
decryptString(account.encryptedCookie, this.getEncryptionSecret()),
);
return this.prisma.openAiAccount.update({
where: { id: account.id },
data: {
lastUsageJson: usage as Prisma.InputJsonValue,
lastSyncedAt: new Date(),
lastError: null,
},
});
} catch (error) {
return this.prisma.openAiAccount.update({
where: { id: account.id },
data: {
lastError:
error instanceof Error ? error.message : 'Unknown error',
},
});
}
}),
);
const accountViews = resolvedAccounts.map((account) =>
this.toAccountView(account),
);
return {
aggregatedUsage: aggregateUsagePayloads(
accountViews.map((account) => account.usage),
),
accounts: accountViews,
totals: {
totalAccounts: accountViews.length,
activeAccounts: accountViews.filter(
(account) => account.status === 'active',
).length,
erroredAccounts: accountViews.filter(
(account) => account.status === 'error',
).length,
},
refreshedAt: new Date().toISOString(),
};
}
private async fetchUsage(cookieHeader: string): Promise<UsageApiResponse> {
const response = await fetch(
'https://chatgpt.com/backend-api/api/codex/usage',
{
headers: {
accept: 'application/json',
cookie: cookieHeader,
'user-agent': 'CodexDash/0.1 (+https://example.invalid)',
},
},
);
if (!response.ok) {
throw new BadRequestException(
`Codex usage request failed with status ${response.status}`,
);
}
return (await response.json()) as UsageApiResponse;
}
private getEncryptionSecret() {
return (
this.configService.get<string>('ENCRYPTION_SECRET') ??
'change-me-32-characters-minimum'
);
}
private toAccountView(account: {
id: string;
label: string;
emailHint: string | null;
lastUsageJson: unknown;
lastSyncedAt: Date | null;
lastError: string | null;
createdAt: Date;
}): ConnectedAccount {
return {
id: account.id,
label: account.label,
emailHint: account.emailHint,
status: account.lastError ? 'error' : 'active',
lastSyncedAt: account.lastSyncedAt?.toISOString() ?? null,
lastError: account.lastError,
usage: (account.lastUsageJson as Record<string, unknown> | null) ?? null,
createdAt: account.createdAt.toISOString(),
};
}
}

View File

@@ -0,0 +1,16 @@
import { IsOptional, IsString, MinLength } from 'class-validator';
import { ConnectAccountInput } from '@codexdash/shared-types';
export class ConnectAccountDto implements ConnectAccountInput {
@IsString()
@MinLength(2)
label!: string;
@IsOptional()
@IsString()
emailHint?: string;
@IsString()
@MinLength(20)
cookieHeader!: string;
}

View File

@@ -0,0 +1,24 @@
import { aggregateUsagePayloads } from './usage-aggregator';
describe('aggregateUsagePayloads', () => {
it('sums nested numeric values while preserving useful metadata', () => {
const result = aggregateUsagePayloads([
{
limit: { used: 10, remaining: 90, unit: 'requests' },
plan: 'pro',
buckets: [{ used: 2 }, { used: 3 }],
},
{
limit: { used: 25, remaining: 75, unit: 'requests' },
plan: 'pro',
buckets: [{ used: 4 }, { used: 1 }],
},
]);
expect(result).toEqual({
limit: { used: 35, remaining: 165, unit: 'requests' },
plan: 'pro',
buckets: [{ used: 6 }, { used: 4 }],
});
});
});

View File

@@ -0,0 +1,60 @@
type JsonValue =
| null
| boolean
| number
| string
| JsonValue[]
| { [key: string]: JsonValue };
const isObject = (value: JsonValue): value is { [key: string]: JsonValue } =>
typeof value === 'object' && value !== null && !Array.isArray(value);
function mergeValues(values: JsonValue[]): JsonValue {
const filtered = values.filter((value) => value !== null);
if (filtered.length === 0) {
return null;
}
if (filtered.every((value) => typeof value === 'number')) {
return filtered.reduce((sum, value) => sum + value, 0);
}
if (filtered.every((value) => Array.isArray(value))) {
const arrays = filtered as JsonValue[][];
const maxLength = Math.max(...arrays.map((array) => array.length));
return Array.from({ length: maxLength }, (_, index) =>
mergeValues(arrays.map((array) => array[index] ?? null)),
);
}
if (filtered.every((value) => isObject(value))) {
const keys = [...new Set(filtered.flatMap((value) => Object.keys(value)))];
return Object.fromEntries(
keys.map((key) => [
key,
mergeValues(
filtered.map(
(value) => (value as Record<string, JsonValue>)[key] ?? null,
),
),
]),
);
}
const unique = [
...new Set(filtered.map((value) => JSON.stringify(value))),
].map((value) => JSON.parse(value) as JsonValue);
return unique.length === 1 ? unique[0] : unique[0];
}
export function aggregateUsagePayloads(
payloads: Array<Record<string, unknown> | null | undefined>,
) {
const normalized = payloads.filter(Boolean) as Array<Record<string, unknown>>;
if (normalized.length === 0) {
return null;
}
return mergeValues(normalized as JsonValue[]) as Record<string, unknown>;
}

View File

@@ -0,0 +1,37 @@
import {
createCipheriv,
createDecipheriv,
createHash,
randomBytes,
} from 'node:crypto';
const ALGORITHM = 'aes-256-gcm';
function getKey(secret: string) {
return createHash('sha256').update(secret).digest();
}
export function encryptString(value: string, secret: string) {
const iv = randomBytes(12);
const cipher = createCipheriv(ALGORITHM, getKey(secret), iv);
const encrypted = Buffer.concat([
cipher.update(value, 'utf8'),
cipher.final(),
]);
const tag = cipher.getAuthTag();
return Buffer.concat([iv, tag, encrypted]).toString('base64url');
}
export function decryptString(value: string, secret: string) {
const buffer = Buffer.from(value, 'base64url');
const iv = buffer.subarray(0, 12);
const tag = buffer.subarray(12, 28);
const encrypted = buffer.subarray(28);
const decipher = createDecipheriv(ALGORITHM, getKey(secret), iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString(
'utf8',
);
}

View File

@@ -0,0 +1,16 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import type { Request } from 'express';
export type AuthenticatedUser = {
sub: string;
email: string;
};
type RequestWithUser = Request & { user?: AuthenticatedUser };
export const CurrentUser = createParamDecorator(
(_: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest<RequestWithUser>();
return request.user as AuthenticatedUser;
},
);

View File

@@ -0,0 +1,42 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import type { Request } from 'express';
import type { AuthenticatedUser } from './current-user.decorator';
type RequestWithUser = Request & { user?: AuthenticatedUser };
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<RequestWithUser>();
const authorization = request.headers.authorization;
if (!authorization?.startsWith('Bearer ')) {
throw new UnauthorizedException('Missing bearer token');
}
try {
const token = authorization.slice('Bearer '.length);
request.user = await this.jwtService.verifyAsync<AuthenticatedUser>(
token,
{
secret: this.configService.get<string>('JWT_SECRET') ?? 'change-me',
},
);
return true;
} catch {
throw new UnauthorizedException('Invalid token');
}
}
}

18
apps/api/src/main.ts Normal file
View File

@@ -0,0 +1,18 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors({ origin: true, credentials: true });
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
}),
);
await app.listen(process.env.PORT ?? 3001);
}
void bootstrap();

View File

@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

View File

@@ -0,0 +1,16 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
@Injectable()
export class PrismaService
extends PrismaClient
implements OnModuleInit, OnModuleDestroy
{
async onModuleInit() {
await this.$connect();
}
async onModuleDestroy() {
await this.$disconnect();
}
}

View File

@@ -0,0 +1,29 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from './../src/app.module';
describe('AppController (e2e)', () => {
let app: INestApplication<App>;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
afterEach(async () => {
await app.close();
});
});

View File

@@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

25
apps/api/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"resolvePackageJsonExports": true,
"esModuleInterop": true,
"isolatedModules": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"noFallthroughCasesInSwitch": true
}
}

24
apps/web/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
apps/web/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

22
apps/web/eslint.config.js Normal file
View File

@@ -0,0 +1,22 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
])

17
apps/web/index.html Normal file
View File

@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="CodexDash helps you monitor multiple OpenAI Codex accounts and combine their usage into a single mobile-first dashboard."
/>
<title>CodexDash</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

49
apps/web/package.json Normal file
View File

@@ -0,0 +1,49 @@
{
"name": "@codexdash/web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@codexdash/shared-types": "workspace:*",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@tanstack/react-query": "^5.90.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.554.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.66.0",
"react-router-dom": "^7.9.6",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"zod": "^4.1.12"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.1.14",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.2.1",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
"tailwindcss": "^4.1.14",
"tw-animate-css": "^1.3.8",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.2",
"vite": "^8.0.10"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

431
apps/web/src/App.tsx Normal file
View File

@@ -0,0 +1,431 @@
import { useMemo, useState } from 'react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { AuthResponse, LoginInput, RegisterInput } from '@codexdash/shared-types';
import { Activity, CirclePlus, Gauge, LogOut, RefreshCw, ShieldCheck, Trash2 } from 'lucide-react';
import { toast, Toaster } from 'sonner';
import { api } from '@/lib/api';
import { clearToken, getToken, setToken } from '@/lib/storage';
import { flattenNumericMetrics, formatDate, titleizeMetric } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Progress } from '@/components/ui/progress';
import { Separator } from '@/components/ui/separator';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { JsonViewer } from '@/components/json-viewer';
const registerSchema = z.object({
name: z.string().min(2),
email: z.email(),
password: z.string().min(8),
});
const loginSchema = z.object({
email: z.email(),
password: z.string().min(8),
});
const connectSchema = z.object({
label: z.string().min(2),
emailHint: z.string().optional(),
cookieHeader: z.string().min(20),
});
function AuthShell({ onAuthenticated }: { onAuthenticated: (response: AuthResponse) => void }) {
const [mode, setMode] = useState<'login' | 'register'>('register');
const schema = mode === 'register' ? registerSchema : loginSchema;
const form = useForm<{ name?: string; email: string; password: string }>({
resolver: zodResolver(schema),
defaultValues: { email: '', password: '', ...(mode === 'register' ? { name: '' } : {}) },
});
const mutation = useMutation({
mutationFn: async (values: { name?: string; email: string; password: string }) => {
return mode === 'register'
? api.register(values as RegisterInput)
: api.login({ email: values.email, password: values.password } as LoginInput);
},
onSuccess: (response) => {
setToken(response.token);
onAuthenticated(response);
toast.success(mode === 'register' ? 'Welcome to CodexDash.' : 'Signed in successfully.');
},
onError: (error: Error) => toast.error(error.message),
});
return (
<div className="mx-auto flex min-h-screen w-full max-w-6xl items-center justify-center px-4 py-8 sm:px-6">
<div className="grid w-full gap-6 lg:grid-cols-[1.15fr_0.85fr]">
<Card className="overflow-hidden border-sky-500/20 bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.18),_transparent_28%),linear-gradient(180deg,rgba(15,23,42,0.94),rgba(2,6,23,0.92))]">
<CardContent className="flex h-full flex-col justify-between gap-8 p-6 sm:p-8">
<div className="space-y-5">
<Badge className="w-fit border-sky-400/30 bg-sky-400/10 text-sky-100">Mobile-first Codex monitor</Badge>
<div className="space-y-4">
<h1 className="max-w-xl text-4xl font-semibold tracking-tight text-white sm:text-5xl">
CodexDash keeps every Codex account in one gorgeous live dashboard.
</h1>
<p className="max-w-xl text-base leading-7 text-slate-300 sm:text-lg">
Sign into CodexDash, attach multiple OpenAI Codex sessions, and view combined limits,
remaining usage, raw API payloads, and per-account drilldowns from a single responsive UI.
</p>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-3">
{[
{ icon: Gauge, title: 'Unified usage', desc: 'Merge multiple OpenAI accounts into one overview.' },
{ icon: ShieldCheck, title: 'Stored safely', desc: 'Session cookie headers are encrypted at rest.' },
{ icon: Activity, title: 'Live detail', desc: 'See refreshed usage plus raw usage payloads.' },
].map((item) => (
<div key={item.title} className="rounded-2xl border border-white/10 bg-white/6 p-4">
<item.icon className="mb-3 size-5 text-sky-300" />
<div className="font-medium text-white">{item.title}</div>
<div className="mt-1 text-sm leading-6 text-slate-400">{item.desc}</div>
</div>
))}
</div>
</CardContent>
</Card>
<Card className="border-white/10 bg-slate-950/88">
<CardHeader>
<CardTitle>{mode === 'register' ? 'Create your account' : 'Welcome back'}</CardTitle>
<CardDescription>
{mode === 'register'
? 'Start with your CodexDash account, then connect OpenAI Codex sessions inside the dashboard.'
: 'Log in to continue monitoring your combined Codex usage.'}
</CardDescription>
</CardHeader>
<CardContent>
<form className="space-y-4" onSubmit={form.handleSubmit((values) => mutation.mutate(values))}>
{mode === 'register' ? (
<div className="space-y-2">
<Label htmlFor="name">Display name</Label>
<Input id="name" placeholder="Codex operator" {...form.register('name' as const)} />
<p className="text-xs text-rose-300">{String(form.formState.errors.name?.message ?? '')}</p>
</div>
) : null}
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" placeholder="you@example.com" {...form.register('email')} />
<p className="text-xs text-rose-300">{String(form.formState.errors.email?.message ?? '')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input id="password" type="password" placeholder="At least 8 characters" {...form.register('password')} />
<p className="text-xs text-rose-300">{String(form.formState.errors.password?.message ?? '')}</p>
</div>
<Button className="w-full" disabled={mutation.isPending} type="submit">
{mutation.isPending ? 'Please wait…' : mode === 'register' ? 'Create account' : 'Sign in'}
</Button>
</form>
<Separator className="my-6" />
<div className="space-y-3 text-sm text-slate-400">
<p>
OpenAI account connection is implemented as a session-based Codex login: after signing into
chatgpt.com in your browser, paste the authenticated <code className="rounded bg-white/10 px-1.5 py-0.5 text-slate-200">Cookie</code>{' '}
header into the connect flow.
</p>
<Button type="button" variant="ghost" className="px-0 text-sky-300 hover:bg-transparent" onClick={() => setMode(mode === 'register' ? 'login' : 'register')}>
{mode === 'register' ? 'Already have an account? Sign in' : 'Need an account? Register'}
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
function ConnectAccountDialog() {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const form = useForm<z.infer<typeof connectSchema>>({
resolver: zodResolver(connectSchema),
defaultValues: { label: '', emailHint: '', cookieHeader: '' },
});
const mutation = useMutation({
mutationFn: api.connectAccount,
onSuccess: () => {
toast.success('OpenAI Codex session connected.');
setOpen(false);
form.reset();
void queryClient.invalidateQueries({ queryKey: ['usage-summary'] });
},
onError: (error: Error) => toast.error(error.message),
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="w-full sm:w-auto">
<CirclePlus className="size-4" />
Connect OpenAI account
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Connect an OpenAI Codex session</DialogTitle>
<DialogDescription>
Paste the authenticated <code className="rounded bg-white/10 px-1 py-0.5 text-slate-200">Cookie</code>{' '}
header from a signed-in <code className="rounded bg-white/10 px-1 py-0.5 text-slate-200">chatgpt.com</code>{' '}
session. CodexDash will use it to call the official usage endpoint.
</DialogDescription>
</DialogHeader>
<form className="mt-5 space-y-4" onSubmit={form.handleSubmit((values) => mutation.mutate(values))}>
<div className="space-y-2">
<Label htmlFor="label">Account label</Label>
<Input id="label" placeholder="Primary Team Pro" {...form.register('label')} />
<p className="text-xs text-rose-300">{String(form.formState.errors.label?.message ?? '')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="emailHint">Email hint</Label>
<Input id="emailHint" placeholder="ops@example.com" {...form.register('emailHint')} />
</div>
<div className="space-y-2">
<Label htmlFor="cookieHeader">Cookie header</Label>
<textarea
id="cookieHeader"
className="min-h-36 w-full rounded-2xl border border-white/10 bg-white/5 p-4 text-sm text-white outline-none focus:border-sky-400/60"
placeholder="__Secure-next-auth.session-token=...; oai-did=..."
{...form.register('cookieHeader')}
/>
<p className="text-xs text-rose-300">{String(form.formState.errors.cookieHeader?.message ?? '')}</p>
</div>
<Button className="w-full" disabled={mutation.isPending} type="submit">
{mutation.isPending ? 'Connecting…' : 'Validate and connect'}
</Button>
</form>
</DialogContent>
</Dialog>
);
}
function Dashboard() {
const queryClient = useQueryClient();
const summaryQuery = useQuery({ queryKey: ['usage-summary'], queryFn: () => api.getUsageSummary(true) });
const userQuery = useQuery({ queryKey: ['me'], queryFn: api.me });
const deleteMutation = useMutation({
mutationFn: api.deleteAccount,
onSuccess: () => {
toast.success('OpenAI account removed.');
void queryClient.invalidateQueries({ queryKey: ['usage-summary'] });
},
onError: (error: Error) => toast.error(error.message),
});
const metricCards = useMemo(() => {
return flattenNumericMetrics(summaryQuery.data?.aggregatedUsage).slice(0, 6);
}, [summaryQuery.data?.aggregatedUsage]);
if (summaryQuery.isLoading || userQuery.isLoading) {
return <div className="flex min-h-screen items-center justify-center text-slate-300">Loading CodexDash</div>;
}
if (summaryQuery.isError || userQuery.isError) {
return (
<div className="flex min-h-screen items-center justify-center px-4">
<Card className="max-w-md">
<CardHeader>
<CardTitle>Unable to load dashboard</CardTitle>
<CardDescription>
{(summaryQuery.error as Error | undefined)?.message ?? (userQuery.error as Error | undefined)?.message}
</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={() => window.location.reload()}>Retry</Button>
</CardContent>
</Card>
</div>
);
}
const summary = summaryQuery.data!;
const user = userQuery.data!;
const firstMetric = metricCards[0]?.value ?? 0;
const secondMetric = metricCards[1]?.value ?? 0;
const progressValue = firstMetric + secondMetric > 0 ? (firstMetric / (firstMetric + secondMetric)) * 100 : 0;
return (
<div className="mx-auto min-h-screen max-w-7xl px-4 py-5 sm:px-6 lg:px-8">
<div className="mb-6 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<Badge className="mb-3 w-fit border-emerald-400/20 bg-emerald-400/10 text-emerald-200">Signed in as {user.name}</Badge>
<h1 className="text-3xl font-semibold text-white sm:text-4xl">CodexDash overview</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-400 sm:text-base">
Combined usage is refreshed by calling <code className="rounded bg-white/10 px-1.5 py-0.5 text-slate-200">chatgpt.com/backend-api/api/codex/usage</code>{' '}
for each attached OpenAI account and merging numeric fields into one dashboard.
</p>
</div>
<div className="flex flex-col gap-3 sm:flex-row">
<Button variant="secondary" onClick={() => summaryQuery.refetch()}>
<RefreshCw className="size-4" />
Refresh now
</Button>
<ConnectAccountDialog />
<Button variant="ghost" onClick={() => { clearToken(); window.location.reload(); }}>
<LogOut className="size-4" />
Sign out
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-3 xl:grid-cols-4">
<Card className="md:col-span-2">
<CardHeader>
<CardTitle>Unified capacity</CardTitle>
<CardDescription>Fast glance card for the first two numeric metrics extracted from the merged usage payload.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-end justify-between gap-4">
<div>
<div className="text-4xl font-semibold text-white">{firstMetric.toLocaleString()}</div>
<div className="mt-1 text-sm text-slate-400">{titleizeMetric(metricCards[0]?.label ?? 'Primary metric')}</div>
</div>
<div className="text-right">
<div className="text-2xl font-semibold text-slate-100">{secondMetric.toLocaleString()}</div>
<div className="mt-1 text-sm text-slate-500">{titleizeMetric(metricCards[1]?.label ?? 'Secondary metric')}</div>
</div>
</div>
<Progress value={progressValue} />
<div className="flex flex-wrap gap-3 text-sm text-slate-400">
<span>Accounts: {summary.totals.totalAccounts}</span>
<span>Healthy: {summary.totals.activeAccounts}</span>
<span>Errors: {summary.totals.erroredAccounts}</span>
<span>Updated: {formatDate(summary.refreshedAt)}</span>
</div>
</CardContent>
</Card>
{[
{ title: 'Connected sessions', value: summary.totals.totalAccounts, tone: 'text-sky-300' },
{ title: 'Healthy sessions', value: summary.totals.activeAccounts, tone: 'text-emerald-300' },
{ title: 'Errored sessions', value: summary.totals.erroredAccounts, tone: 'text-rose-300' },
].map((item) => (
<Card key={item.title}>
<CardHeader>
<CardDescription>{item.title}</CardDescription>
<CardTitle className={item.tone}>{item.value.toLocaleString()}</CardTitle>
</CardHeader>
</Card>
))}
</div>
<div className="mt-6 grid gap-6 xl:grid-cols-[1.15fr_0.85fr]">
<Card>
<CardHeader>
<CardTitle>Usage metrics</CardTitle>
<CardDescription>CodexDash extracts numeric leaf nodes from the aggregated usage payload for quick overview cards.</CardDescription>
</CardHeader>
<CardContent>
{metricCards.length === 0 ? (
<div className="rounded-2xl border border-dashed border-white/10 bg-white/3 p-6 text-sm text-slate-400">
No usage data yet. Connect an OpenAI account with a valid ChatGPT session cookie header to start refreshing.
</div>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{metricCards.map((metric) => (
<div key={metric.label} className="rounded-2xl border border-white/10 bg-white/4 p-4">
<div className="text-sm text-slate-400">{titleizeMetric(metric.label)}</div>
<div className="mt-3 text-2xl font-semibold text-white">{metric.value.toLocaleString()}</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
<JsonViewer
title="Merged payload"
description="Raw aggregated JSON merged from every attached OpenAI Codex account."
value={summary.aggregatedUsage ?? { message: 'No data yet' }}
/>
</div>
<Card className="mt-6">
<CardHeader>
<CardTitle>Connected OpenAI accounts</CardTitle>
<CardDescription>
By default, these accounts are merged into one Codex usage view. Switch tabs to inspect individual account payloads and timestamps.
</CardDescription>
</CardHeader>
<CardContent>
{summary.accounts.length === 0 ? (
<div className="rounded-2xl border border-dashed border-white/10 bg-white/3 p-6 text-sm text-slate-400">
No OpenAI accounts connected yet.
</div>
) : (
<Tabs defaultValue={summary.accounts[0]?.id}>
<TabsList className="mb-4 flex h-auto w-full flex-wrap justify-start gap-2 bg-transparent p-0">
{summary.accounts.map((account) => (
<TabsTrigger key={account.id} value={account.id} className="border border-white/10 bg-white/5 data-[state=active]:border-sky-400/40 data-[state=active]:bg-slate-900">
{account.label}
</TabsTrigger>
))}
</TabsList>
{summary.accounts.map((account) => (
<TabsContent key={account.id} value={account.id}>
<div className="grid gap-4 lg:grid-cols-[0.9fr_1.1fr]">
<div className="space-y-4 rounded-3xl border border-white/10 bg-white/4 p-5">
<div className="flex items-start justify-between gap-4">
<div>
<div className="text-lg font-semibold text-white">{account.label}</div>
<div className="mt-1 text-sm text-slate-400">{account.emailHint || 'No email hint provided'}</div>
</div>
<Badge className={account.status === 'active' ? 'border-emerald-400/20 bg-emerald-400/10 text-emerald-200' : 'border-rose-400/20 bg-rose-400/10 text-rose-200'}>
{account.status}
</Badge>
</div>
<Separator />
<div className="space-y-2 text-sm text-slate-300">
<div>Last synced: {formatDate(account.lastSyncedAt)}</div>
<div>Connected: {formatDate(account.createdAt)}</div>
<div>
Error: <span className="text-slate-400">{account.lastError || 'None'}</span>
</div>
</div>
<div className="flex gap-3">
<Button variant="outline" onClick={() => summaryQuery.refetch()}>
<RefreshCw className="size-4" />
Refresh
</Button>
<Button variant="destructive" disabled={deleteMutation.isPending} onClick={() => deleteMutation.mutate(account.id)}>
<Trash2 className="size-4" />
Remove
</Button>
</div>
</div>
<JsonViewer
title="Account payload"
description="Most recent raw usage JSON for this specific OpenAI Codex account."
value={account.usage ?? { message: account.lastError || 'No usage fetched yet' }}
/>
</div>
</TabsContent>
))}
</Tabs>
)}
</CardContent>
</Card>
</div>
);
}
export default function App() {
const [authenticated, setAuthenticated] = useState(Boolean(getToken()));
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(14,165,233,0.12),_transparent_25%),linear-gradient(180deg,#020617_0%,#0f172a_48%,#020617_100%)] text-slate-100">
<Toaster richColors position="top-center" theme="dark" />
{authenticated ? <Dashboard /> : <AuthShell onAuthenticated={() => setAuthenticated(true)} />}
</div>
);
}

View File

@@ -0,0 +1,17 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
export function JsonViewer({ title, description, value }: { title: string; description: string; value: unknown }) {
return (
<Card className="border-white/8 bg-slate-900/80">
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>
<pre className="max-h-80 overflow-auto rounded-2xl bg-black/30 p-4 text-xs leading-6 text-slate-300">
{JSON.stringify(value, null, 2)}
</pre>
</CardContent>
</Card>
);
}

37
apps/web/src/index.css Normal file
View File

@@ -0,0 +1,37 @@
@import "tailwindcss";
@import "tw-animate-css";
:root {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #f8fafc;
background: #020617;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
background: #020617;
}
button,
input,
textarea {
font: inherit;
}
#root {
min-height: 100vh;
}

47
apps/web/src/lib/api.ts Normal file
View File

@@ -0,0 +1,47 @@
import type {
AuthResponse,
ConnectAccountInput,
ConnectedAccount,
LoginInput,
RegisterInput,
UsageSummary,
UserProfile,
} from '@codexdash/shared-types';
import { clearToken, getToken } from './storage';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001';
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const token = getToken();
const response = await fetch(`${API_BASE_URL}${path}`, {
...init,
headers: {
'content-type': 'application/json',
...(token ? { authorization: `Bearer ${token}` } : {}),
...(init?.headers ?? {}),
},
});
if (response.status === 401) {
clearToken();
}
if (!response.ok) {
const error = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(error.message ?? 'Request failed');
}
return response.json() as Promise<T>;
}
export const api = {
register: (input: RegisterInput) =>
request<AuthResponse>('/auth/register', { method: 'POST', body: JSON.stringify(input) }),
login: (input: LoginInput) =>
request<AuthResponse>('/auth/login', { method: 'POST', body: JSON.stringify(input) }),
me: () => request<UserProfile>('/auth/me'),
getUsageSummary: (refresh = true) => request<UsageSummary>(`/codex/usage-summary?refresh=${refresh}`),
connectAccount: (input: ConnectAccountInput) =>
request<ConnectedAccount>('/codex/accounts', { method: 'POST', body: JSON.stringify(input) }),
deleteAccount: (accountId: string) => request<{ ok: boolean }>(`/codex/accounts/${accountId}`, { method: 'DELETE' }),
};

View File

@@ -0,0 +1,13 @@
const TOKEN_KEY = 'codexdash.token';
export function getToken() {
return window.localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string) {
window.localStorage.setItem(TOKEN_KEY, token);
}
export function clearToken() {
window.localStorage.removeItem(TOKEN_KEY);
}

44
apps/web/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,44 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDate(value: string | null) {
if (!value) return 'Never';
return new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
}
export function flattenNumericMetrics(
value: unknown,
path: string[] = [],
): Array<{ label: string; value: number }> {
if (typeof value === 'number' && Number.isFinite(value)) {
return [{ label: path.join(' / ') || 'value', value }];
}
if (Array.isArray(value)) {
return value.flatMap((item, index) => flattenNumericMetrics(item, [...path, String(index + 1)]));
}
if (value && typeof value === 'object') {
return Object.entries(value).flatMap(([key, nestedValue]) =>
flattenNumericMetrics(nestedValue, [...path, key]),
);
}
return [];
}
export function titleizeMetric(label: string) {
return label
.split('/')
.map((part) => part.trim())
.map((part) => part.replace(/[_-]+/g, ' '))
.map((part) => part.replace(/\b\w/g, (char) => char.toUpperCase()))
.join(' · ');
}

15
apps/web/src/main.tsx Normal file
View File

@@ -0,0 +1,15 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import './index.css';
import App from './App';
const queryClient = new QueryClient();
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>,
);

View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023", "DOM"],
"ignoreDeprecations": "6.0",
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true
},
"include": ["src"]
}

7
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

13
apps/web/vite.config.ts Normal file
View File

@@ -0,0 +1,13 @@
import { fileURLToPath, URL } from 'node:url';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
});