feat: add Codex auth login and export flow

Add Codex Auth support in account management so selected accounts can
complete a Codex-compatible OAuth login flow and export usable auth.json
files.

This commit includes:
- account-management UI entrypoints for Codex Auth login and auth.json download
- backend SSE routes for single-account and batch Codex Auth login execution
- persistence of freshly returned Codex-compatible tokens back into the account database
- Codex auth export support for direct auth.json download and batch zip packaging
- tests covering the Codex Auth login flow and export behavior

The OTP verification failure was caused by manually sending a second OTP after
password verification. The flow now reuses the existing proven login path:
login re-entry, password verification, automatic OTP reception, consent page
handling, workspace selection, and OAuth callback exchange.

Successful logins now also persist workspace_id together with the refreshed
Codex-compatible tokens, making later re-export of auth.json possible without
requiring the browser-downloaded file to still exist locally.

Change-Id: I59df518ef4dc05f8bc52c734dd1b738fcb0b7a4e
This commit is contained in:
Solo
2026-03-25 16:31:03 +08:00
parent 1cbb95f91c
commit f4d0327f67
11 changed files with 1266 additions and 13 deletions

View File

@@ -111,6 +111,24 @@ function initEventListeners() {
// 批量删除
elements.batchDeleteBtn.addEventListener('click', handleBatchDelete);
// Codex Auth 登录
const codexAuthBtn = document.getElementById('codex-auth-login-btn');
if (codexAuthBtn) {
codexAuthBtn.addEventListener('click', handleCodexAuthLogin);
}
const closeCodexAuthModal = document.getElementById('close-codex-auth-modal');
if (closeCodexAuthModal) {
closeCodexAuthModal.addEventListener('click', () => {
document.getElementById('codex-auth-modal').classList.remove('active');
});
}
const closeCodexAuthModalBtn = document.getElementById('close-codex-auth-modal-btn');
if (closeCodexAuthModalBtn) {
closeCodexAuthModalBtn.addEventListener('click', () => {
document.getElementById('codex-auth-modal').classList.remove('active');
});
}
// 全选(当前页)
elements.selectAll.addEventListener('change', (e) => {
const checkboxes = elements.table.querySelectorAll('input[type="checkbox"][data-id]');
@@ -481,7 +499,10 @@ function updateBatchButtons() {
elements.batchCheckSubBtn.disabled = count === 0;
elements.exportBtn.disabled = count === 0;
elements.batchDeleteBtn.textContent = count > 0 ? `🗑️ 删除 (${count})` : '🗑️ 批量删除';
const codexAuthBtn = document.getElementById('codex-auth-login-btn');
if (codexAuthBtn) codexAuthBtn.disabled = count === 0;
elements.batchDeleteBtn.textContent = count > 0 ? `删除 (${count})` : '删除';
elements.batchRefreshBtn.textContent = count > 0 ? `🔄 刷新 (${count})` : '🔄 刷新Token';
elements.batchValidateBtn.textContent = count > 0 ? `✅ 验证 (${count})` : '✅ 验证Token';
elements.batchUploadBtn.textContent = count > 0 ? `☁️ 上传 (${count})` : '☁️ 上传';
@@ -724,7 +745,14 @@ async function exportAccounts(format) {
});
if (!response.ok) {
throw new Error(`导出失败: HTTP ${response.status}`);
let errorMessage = `HTTP ${response.status}`;
try {
const errorData = await response.json();
errorMessage = errorData.detail || errorData.message || errorMessage;
} catch (parseError) {
// ignore non-JSON error bodies and fall back to status text
}
throw new Error(errorMessage);
}
// 获取文件内容
@@ -732,7 +760,7 @@ async function exportAccounts(format) {
// 从 Content-Disposition 获取文件名
const disposition = response.headers.get('Content-Disposition');
let filename = `accounts_${Date.now()}.${(format === 'cpa' || format === 'sub2api') ? 'json' : format}`;
let filename = `accounts_${Date.now()}.${(format === 'cpa' || format === 'sub2api' || format === 'codex_auth') ? 'json' : format}`;
if (disposition) {
const match = disposition.match(/filename=(.+)/);
if (match) {
@@ -1266,3 +1294,188 @@ function showInboxCodeResult(code, email) {
`;
elements.detailModal.classList.add('active');
}
// ============== Codex Auth 登录 ==============
let codexAuthResults = [];
async function handleCodexAuthLogin() {
const count = getEffectiveCount();
if (count === 0) {
toast.warning('请先选择要登录的账号');
return;
}
const confirmed = await confirm(`将对选中的 ${count} 个账号执行 Codex Auth 登录(需要接收邮箱验证码),确定继续吗?`);
if (!confirmed) return;
const modal = document.getElementById('codex-auth-modal');
const logsEl = document.getElementById('codex-auth-logs');
const statusEl = document.getElementById('codex-auth-status');
const downloadBtn = document.getElementById('codex-auth-download-btn');
logsEl.textContent = '';
statusEl.textContent = '正在启动 Codex Auth 登录...';
downloadBtn.style.display = 'none';
codexAuthResults = [];
modal.classList.add('active');
if (count === 1 && !selectAllPages) {
// 单账号登录
const accountId = [...selectedAccounts][0];
await codexAuthLoginSingle(accountId, logsEl, statusEl, downloadBtn);
} else {
// 批量登录
await codexAuthLoginBatch(logsEl, statusEl, downloadBtn);
}
}
async function codexAuthLoginSingle(accountId, logsEl, statusEl, downloadBtn) {
try {
const response = await fetch('/api/accounts/codex-auth-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ account_id: accountId }),
});
if (!response.ok) {
const err = await response.json();
statusEl.textContent = '登录失败: ' + (err.detail || response.statusText);
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
try {
const data = JSON.parse(line.slice(6));
if (data.type === 'log') {
logsEl.textContent += data.message + '\n';
logsEl.scrollTop = logsEl.scrollHeight;
} else if (data.type === 'result') {
if (data.success && data.auth_json) {
statusEl.textContent = 'Codex Auth 登录成功!';
codexAuthResults = [{ email: data.email, auth_json: data.auth_json }];
downloadBtn.style.display = 'inline-block';
downloadBtn.onclick = () => downloadCodexAuthResults();
loadAccounts();
} else {
statusEl.textContent = '登录失败: ' + (data.error_message || '未知错误');
}
}
} catch (e) { /* ignore parse errors */ }
}
}
} catch (error) {
statusEl.textContent = '登录失败: ' + error.message;
}
}
async function codexAuthLoginBatch(logsEl, statusEl, downloadBtn) {
try {
const payload = buildBatchPayload();
const response = await fetch('/api/accounts/codex-auth-login/batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
const err = await response.json();
statusEl.textContent = '批量登录失败: ' + (err.detail || response.statusText);
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let successCount = 0;
let failCount = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
try {
const data = JSON.parse(line.slice(6));
if (data.type === 'log') {
logsEl.textContent += data.message + '\n';
logsEl.scrollTop = logsEl.scrollHeight;
} else if (data.type === 'progress') {
statusEl.textContent = `正在处理 ${data.current}/${data.total}: ${data.email}`;
} else if (data.type === 'account_result') {
if (data.success) {
successCount++;
logsEl.textContent += `[${data.email}] 登录成功\n`;
} else {
failCount++;
logsEl.textContent += `[${data.email}] 登录失败: ${data.error || '未知错误'}\n`;
}
logsEl.scrollTop = logsEl.scrollHeight;
} else if (data.type === 'batch_done') {
codexAuthResults = data.results || [];
statusEl.textContent = `批量登录完成: 成功 ${successCount}, 失败 ${failCount}`;
if (codexAuthResults.length > 0) {
downloadBtn.style.display = 'inline-block';
downloadBtn.onclick = () => downloadCodexAuthResults();
}
loadAccounts();
}
} catch (e) { /* ignore parse errors */ }
}
}
} catch (error) {
statusEl.textContent = '批量登录失败: ' + error.message;
}
}
function downloadCodexAuthResults() {
if (codexAuthResults.length === 0) return;
if (codexAuthResults.length === 1) {
// 单个直接下载 auth.json
const item = codexAuthResults[0];
const blob = new Blob([JSON.stringify(item.auth_json, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'auth.json';
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(url);
a.remove();
} else {
// 多个:逐个下载(浏览器端无法打 ZIP逐个下载
codexAuthResults.forEach((item, i) => {
setTimeout(() => {
const blob = new Blob([JSON.stringify(item.auth_json, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${item.email}_auth.json`;
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(url);
a.remove();
}, i * 300);
});
}
toast.success(`已下载 ${codexAuthResults.length} 个 auth.json`);
}