Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions src/features/authFiles/components/AuthJsonPasteModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import type { AuthJsonInputType } from '@/features/authFiles/sessionAuthConverter';
import styles from '@/pages/AuthFilesPage.module.scss';

type AuthJsonPasteModalProps = {
open: boolean;
saving: boolean;
onClose: () => void;
onSave: (type: AuthJsonInputType, fileName: string, jsonText: string) => Promise<void>;
};

const DEFAULT_FILE_NAME = 'codex-account.json';
const INVALID_BASE_FILE_NAME_PATTERN = /[\\/:*?"<>|]/;

const isValidBaseJsonFileName = (value: string) =>
value.toLowerCase().endsWith('.json') &&
!INVALID_BASE_FILE_NAME_PATTERN.test(value) &&
!Array.from(value).some((char) => char.charCodeAt(0) < 32);

export function AuthJsonPasteModal({ open, saving, onClose, onSave }: AuthJsonPasteModalProps) {
const { t } = useTranslation();
const [type, setType] = useState<AuthJsonInputType>('session');
const [fileName, setFileName] = useState(DEFAULT_FILE_NAME);
const [jsonText, setJsonText] = useState('');
const [error, setError] = useState('');

const resetForm = () => {
setType('session');
setFileName(DEFAULT_FILE_NAME);
setJsonText('');
setError('');
};

const handleClose = () => {
resetForm();
onClose();
};

const options = useMemo(
() => [
{ value: 'cpa', label: t('auth_files.paste_type_cpa') },
{ value: 'session', label: t('auth_files.paste_type_session') },
],
[t]
);

const handleSave = async () => {
const trimmedName = fileName.trim();
if (!trimmedName) {
setError(t('auth_files.paste_error_file_name'));
return;
}
if (!isValidBaseJsonFileName(trimmedName)) {
setError(t('auth_files.paste_error_file_name_invalid'));
return;
}
if (!jsonText.trim()) {
setError(t('auth_files.paste_error_json_required'));
return;
}

setError('');
try {
await onSave(type, trimmedName, jsonText);
resetForm();
} catch (err) {
setError(err instanceof Error ? err.message : t('notification.save_failed'));
}
};

return (
<Modal
open={open}
onClose={handleClose}
title={t('auth_files.paste_title')}
width={640}
closeDisabled={saving}
footer={
<>
<Button variant="secondary" onClick={handleClose} disabled={saving}>
{t('common.cancel')}
</Button>
<Button onClick={handleSave} loading={saving}>
{t('auth_files.paste_save_button')}
</Button>
</>
}
>
<div className={styles.authJsonPasteModal}>
{error && <div className={styles.prefixProxyError}>{error}</div>}
<div className={styles.formGroup}>
<label>{t('auth_files.paste_type_label')}</label>
<Select
value={type}
options={options}
onChange={(value) => setType(value as AuthJsonInputType)}
ariaLabel={t('auth_files.paste_type_label')}
/>
</div>
<Input
label={t('auth_files.paste_file_name_label')}
value={fileName}
onChange={(event) => setFileName(event.target.value)}
disabled={saving}
placeholder={DEFAULT_FILE_NAME}
/>
<div className={styles.formGroup}>
<label htmlFor="auth-json-paste-content">{t('auth_files.paste_json_label')}</label>
<textarea
id="auth-json-paste-content"
className={styles.authJsonPasteTextarea}
value={jsonText}
onChange={(event) => setJsonText(event.target.value)}
disabled={saving}
spellCheck={false}
placeholder={t(
type === 'session'
? 'auth_files.paste_session_placeholder'
: 'auth_files.paste_cpa_placeholder'
)}
/>
</div>
<p className={styles.authJsonPasteHint}>
{t(type === 'session' ? 'auth_files.paste_session_hint' : 'auth_files.paste_cpa_hint')}
</p>
</div>
</Modal>
);
}
83 changes: 83 additions & 0 deletions src/features/authFiles/sessionAuthConverter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest';
import {
convertAuthJsonInput,
getDefaultSessionAuthFileName,
} from '@/features/authFiles/sessionAuthConverter';

const encodeBase64UrlJson = (value: unknown) =>
btoa(JSON.stringify(value)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');

const buildJwt = (payload: Record<string, unknown>) =>
`${encodeBase64UrlJson({ alg: 'none', typ: 'JWT' })}.${encodeBase64UrlJson(payload)}.`;

describe('convertAuthJsonInput', () => {
it('keeps a CPA auth JSON object unchanged', () => {
const input = {
type: 'codex',
email: 'user@example.com',
access_token: 'existing-access-token',
};

const result = convertAuthJsonInput(JSON.stringify(input), 'cpa');

expect(result).toEqual(input);
});

it('converts a ChatGPT session object to CPA Codex auth JSON', () => {
const accessToken = buildJwt({
exp: 1_800_000_000,
email: 'token@example.com',
'https://api.openai.com/auth': {
chatgpt_account_id: 'acc-from-token',
chatgpt_plan_type: 'plus',
chatgpt_user_id: 'user-from-token',
},
});

const result = convertAuthJsonInput(
JSON.stringify({
user: { email: 'session@example.com', id: 'session-user' },
account: { id: 'session-account', planType: 'pro' },
accessToken,
sessionToken: 'session-token',
}),
'session',
new Date('2026-05-11T08:00:00.000Z')
);

expect(result).toMatchObject({
type: 'codex',
account_id: 'session-account',
chatgpt_account_id: 'session-account',
email: 'session@example.com',
name: 'session@example.com',
plan_type: 'pro',
chatgpt_plan_type: 'pro',
access_token: accessToken,
session_token: 'session-token',
last_refresh: '2026-05-11T08:00:00.000Z',
expired: '2027-01-15T08:00:00.000Z',
});
});

it('rejects a session object with a non-string access token', () => {
expect(() =>
convertAuthJsonInput(
JSON.stringify({
user: { email: 'session@example.com' },
accessToken: true,
}),
'session'
)
).toThrow('No ChatGPT session object with accessToken was found');
});

it('builds a safe default file name from converted account identity', () => {
const authJson = {
type: 'codex',
email: 'User.Name+tag@example.com',
};

expect(getDefaultSessionAuthFileName(authJson)).toBe('user-name-tag-example-com.codex.json');
});
});
Loading
Loading