feat: Add manual OAuth authorization mode for WebUI (#131)

* feat: add manual OAuth flow support in WebUI

* fix: reset add account modal state on close

* feat: display custom API key in startup banner

* fix: move translations to separate files and optimize import API

* fix: remove orphaned model-manager.js and cleanup callback server on manual auth

---------

Co-authored-by: Badri Narayanan S <59133612+badrisnarayanan@users.noreply.github.com>
This commit is contained in:
董飞祥
2026-01-23 21:23:29 +08:00
committed by GitHub
parent 0fa945b069
commit 9992c4ab27
15 changed files with 624 additions and 16 deletions

View File

@@ -13,6 +13,7 @@ document.addEventListener('alpine:init', () => {
Alpine.data('accountManager', window.Components.accountManager); Alpine.data('accountManager', window.Components.accountManager);
Alpine.data('claudeConfig', window.Components.claudeConfig); Alpine.data('claudeConfig', window.Components.claudeConfig);
Alpine.data('logsViewer', window.Components.logsViewer); Alpine.data('logsViewer', window.Components.logsViewer);
Alpine.data('addAccountModal', window.Components.addAccountModal);
// View Loader Directive // View Loader Directive
Alpine.directive('load-view', (el, { expression }, { evaluate }) => { Alpine.directive('load-view', (el, { expression }, { evaluate }) => {

View File

@@ -252,8 +252,8 @@
</div> </div>
<!-- Add Account Modal --> <!-- Add Account Modal -->
<dialog id="add_account_modal" class="modal backdrop-blur-sm"> <dialog id="add_account_modal" class="modal backdrop-blur-sm" x-data="addAccountModal">
<div class="modal-box max-w-md w-full bg-space-900 border border-space-border text-gray-300 shadow-[0_0_50px_rgba(0,0,0,0.5)] p-6"> <div class="modal-box max-w-lg w-full bg-space-900 border border-space-border text-gray-300 shadow-[0_0_50px_rgba(0,0,0,0.5)] p-6">
<h3 class="font-bold text-lg text-white mb-4" x-text="$store.global.t('addAccount')">Add New Account</h3> <h3 class="font-bold text-lg text-white mb-4" x-text="$store.global.t('addAccount')">Add New Account</h3>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
@@ -281,6 +281,47 @@
<div class="text-center mt-2"> <div class="text-center mt-2">
<p class="text-xs text-gray-500 mb-2" x-text="$store.global.t('or')">OR</p> <p class="text-xs text-gray-500 mb-2" x-text="$store.global.t('or')">OR</p>
<!-- Manual Mode (collapsible) -->
<details class="group mb-2" @toggle="initManualAuth($event)">
<summary class="text-xs text-gray-400 hover:text-neon-cyan cursor-pointer transition-colors inline-flex items-center gap-1">
<svg class="w-3 h-3 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
<span x-text="$store.global.t('manualMode')">Manual Mode</span>
</summary>
<div class="mt-3 p-3 bg-black/50 rounded border border-space-border/30 text-xs">
<template x-if="authUrl">
<div>
<div class="flex items-center gap-2 mb-2">
<input type="text" readonly :value="authUrl"
class="input input-xs input-bordered flex-1 bg-space-800 text-gray-300 font-mono">
<button class="btn btn-xs btn-ghost text-neon-cyan" @click="copyLink" :title="$store.global.t('linkCopied')">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
</button>
</div>
<div class="flex items-center gap-2">
<input type="text" x-model="callbackInput"
:placeholder="$store.global.t('pasteCallbackPlaceholder')"
class="input input-xs input-bordered flex-1 bg-space-800 text-gray-300 font-mono">
<button class="btn btn-xs btn-success"
@click="completeManualAuth"
:disabled="!callbackInput || submitting"
:class="{ 'loading': submitting }">
<span x-text="$store.global.t('completeAuth')">OK</span>
</button>
</div>
</div>
</template>
<template x-if="!authUrl">
<div class="text-gray-500">Loading...</div>
</template>
</div>
</details>
<!-- CLI Command -->
<details class="group"> <details class="group">
<summary class="text-xs text-gray-400 hover:text-neon-cyan cursor-pointer transition-colors inline-flex items-center gap-1"> <summary class="text-xs text-gray-400 hover:text-neon-cyan cursor-pointer transition-colors inline-flex items-center gap-1">
<svg class="w-3 h-3 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-3 h-3 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -300,12 +341,12 @@
<div class="modal-action mt-6"> <div class="modal-action mt-6">
<form method="dialog"> <form method="dialog">
<button type="submit" class="btn btn-ghost hover:bg-white/10" x-text="$store.global.t('close')">Close</button> <button type="submit" class="btn btn-ghost hover:bg-white/10" @click="resetState()" x-text="$store.global.t('close')">Close</button>
</form> </form>
</div> </div>
</div> </div>
<form method="dialog" class="modal-backdrop"> <form method="dialog" class="modal-backdrop">
<button type="button" x-text="$store.global.t('close')">close</button> <button type="button" @click="resetState()" x-text="$store.global.t('close')">close</button>
</form> </form>
</dialog> </dialog>
@@ -374,6 +415,7 @@
<script src="js/components/claude-config.js"></script> <script src="js/components/claude-config.js"></script>
<script src="js/components/logs-viewer.js"></script> <script src="js/components/logs-viewer.js"></script>
<script src="js/components/server-config.js"></script> <script src="js/components/server-config.js"></script>
<script src="js/components/add-account-modal.js"></script>
<!-- 4. App (registers Alpine components from window.Components) --> <!-- 4. App (registers Alpine components from window.Components) -->
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>

View File

@@ -241,5 +241,90 @@ window.Components.accountManager = () => ({
percent: Math.round(val * 100), percent: Math.round(val * 100),
model: bestModel model: bestModel
}; };
},
/**
* Export accounts to JSON file
*/
async exportAccounts() {
const store = Alpine.store('global');
try {
const { response, newPassword } = await window.utils.request(
'/api/accounts/export',
{},
store.webuiPassword
);
if (newPassword) store.webuiPassword = newPassword;
const data = await response.json();
// API returns plain array directly
if (Array.isArray(data)) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `antigravity-accounts-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
store.showToast(store.t('exportSuccess', { count: data.length }), 'success');
} else if (data.error) {
throw new Error(data.error);
}
} catch (e) {
store.showToast(store.t('exportFailed') + ': ' + e.message, 'error');
}
},
/**
* Import accounts from JSON file
* @param {Event} event - file input change event
*/
async importAccounts(event) {
const store = Alpine.store('global');
const file = event.target.files?.[0];
if (!file) return;
try {
const text = await file.text();
const importData = JSON.parse(text);
// Support both plain array and wrapped format
const accounts = Array.isArray(importData) ? importData : (importData.accounts || []);
if (!Array.isArray(accounts) || accounts.length === 0) {
throw new Error('Invalid file format: expected accounts array');
}
const { response, newPassword } = await window.utils.request(
'/api/accounts/import',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(accounts)
},
store.webuiPassword
);
if (newPassword) store.webuiPassword = newPassword;
const data = await response.json();
if (data.status === 'ok') {
const { added, updated, failed } = data.results;
let msg = store.t('importSuccess') + ` ${added.length} added, ${updated.length} updated`;
if (failed.length > 0) {
msg += `, ${failed.length} failed`;
}
store.showToast(msg, failed.length > 0 ? 'info' : 'success');
Alpine.store('data').fetchData();
} else {
throw new Error(data.error || 'Import failed');
}
} catch (e) {
store.showToast(store.t('importFailed') + ': ' + e.message, 'error');
} finally {
// Reset file input
event.target.value = '';
}
} }
}); });

View File

@@ -0,0 +1,88 @@
/**
* Add Account Modal Component
* Registers itself to window.Components for Alpine.js to consume
*/
window.Components = window.Components || {};
window.Components.addAccountModal = () => ({
manualMode: false,
authUrl: '',
authState: '',
callbackInput: '',
submitting: false,
/**
* Reset all state to initial values
*/
resetState() {
this.manualMode = false;
this.authUrl = '';
this.authState = '';
this.callbackInput = '';
this.submitting = false;
// Close any open details elements
const details = document.querySelectorAll('#add_account_modal details[open]');
details.forEach(d => d.removeAttribute('open'));
},
async copyLink() {
if (!this.authUrl) return;
await navigator.clipboard.writeText(this.authUrl);
Alpine.store('global').showToast(Alpine.store('global').t('linkCopied'), 'success');
},
async initManualAuth(event) {
if (event.target.open && !this.authUrl) {
try {
const password = Alpine.store('global').webuiPassword;
const {
response,
newPassword
} = await window.utils.request('/api/auth/url', {}, password);
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
const data = await response.json();
if (data.status === 'ok') {
this.authUrl = data.url;
this.authState = data.state;
}
} catch (e) {
Alpine.store('global').showToast(e.message, 'error');
}
}
},
async completeManualAuth() {
if (!this.callbackInput || !this.authState) return;
this.submitting = true;
try {
const store = Alpine.store('global');
const {
response,
newPassword
} = await window.utils.request('/api/auth/complete', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
callbackInput: this.callbackInput,
state: this.authState
})
}, store.webuiPassword);
if (newPassword) store.webuiPassword = newPassword;
const data = await response.json();
if (data.status === 'ok') {
store.showToast(store.t('accountAddedSuccess'), 'success');
Alpine.store('data').fetchData();
document.getElementById('add_account_modal').close();
this.resetState();
} else {
store.showToast(data.error || store.t('authFailed'), 'error');
}
} catch (e) {
Alpine.store('global').showToast(e.message, 'error');
} finally {
this.submitting = false;
}
}
});

View File

@@ -298,6 +298,24 @@ window.translations.en = {
quotaDistribution: "Quota Distribution", quotaDistribution: "Quota Distribution",
resetsIn: "Resets in {time}", resetsIn: "Resets in {time}",
noQuotaData: "No quota data available for this account yet.", noQuotaData: "No quota data available for this account yet.",
// Manual OAuth Mode
manualMode: "Manual Mode",
manualModeDesc: "(for environments where callback cannot reach)",
authLinkLabel: "Authorization Link:",
linkCopied: "Link copied to clipboard",
pasteCallbackLabel: "Paste callback URL or code:",
pasteCallbackPlaceholder: "http://localhost:51121/oauth-callback?code=... or 4/0xxx...",
completeAuth: "Complete Authorization",
authFailed: "Authorization failed",
// Import/Export
export: "Export",
import: "Import",
exportAccounts: "Export Accounts",
importAccounts: "Import Accounts",
exportSuccess: "Exported {count} accounts",
exportFailed: "Export failed",
importSuccess: "Import complete:",
importFailed: "Import failed",
// UI Elements // UI Elements
pageTitle: "Antigravity Console", pageTitle: "Antigravity Console",
live: "Live", live: "Live",

View File

@@ -337,6 +337,24 @@ window.translations.id = {
quotaDistribution: "Distribusi Kuota", quotaDistribution: "Distribusi Kuota",
resetsIn: "Reset dalam {time}", resetsIn: "Reset dalam {time}",
noQuotaData: "Data kuota belum tersedia untuk akun ini.", noQuotaData: "Data kuota belum tersedia untuk akun ini.",
// Manual OAuth Mode
manualMode: "Mode Manual",
manualModeDesc: "(untuk lingkungan di mana callback tidak bisa dicapai)",
authLinkLabel: "Link Otorisasi:",
linkCopied: "Link disalin ke clipboard",
pasteCallbackLabel: "Tempel callback URL atau kode:",
pasteCallbackPlaceholder: "http://localhost:51121/oauth-callback?code=... atau 4/0xxx...",
completeAuth: "Selesaikan Otorisasi",
authFailed: "Otorisasi gagal",
// Import/Export
export: "Ekspor",
import: "Impor",
exportAccounts: "Ekspor Akun",
importAccounts: "Impor Akun",
exportSuccess: "Berhasil mengekspor {count} akun",
exportFailed: "Gagal mengekspor",
importSuccess: "Impor selesai:",
importFailed: "Gagal mengimpor",
// Completed TODOs // Completed TODOs
pageTitle: "Antigravity Console", pageTitle: "Antigravity Console",

View File

@@ -271,6 +271,29 @@ window.translations.pt = {
gemini1mDesc: "Adiciona sufixo [1m] aos modelos Gemini para suporte a janela de contexto de 1M.", gemini1mDesc: "Adiciona sufixo [1m] aos modelos Gemini para suporte a janela de contexto de 1M.",
gemini1mWarning: "⚠ Contexto grande pode reduzir o desempenho do Gemini-3-Pro.", gemini1mWarning: "⚠ Contexto grande pode reduzir o desempenho do Gemini-3-Pro.",
clickToSet: "Clique para configurar...", clickToSet: "Clique para configurar...",
none: "Nenhum",
// Quota Distribution
quotaDistribution: "Distribuição de Quota",
resetsIn: "Reseta em {time}",
noQuotaData: "Dados de quota ainda não disponíveis para esta conta.",
// Manual OAuth Mode
manualMode: "Modo Manual",
manualModeDesc: "(para ambientes onde callback não consegue alcançar)",
authLinkLabel: "Link de Autorização:",
linkCopied: "Link copiado para a área de transferência",
pasteCallbackLabel: "Cole a URL de callback ou código:",
pasteCallbackPlaceholder: "http://localhost:51121/oauth-callback?code=... ou 4/0xxx...",
completeAuth: "Completar Autorização",
authFailed: "Falha na autorização",
// Import/Export
export: "Exportar",
import: "Importar",
exportAccounts: "Exportar Contas",
importAccounts: "Importar Contas",
exportSuccess: "Exportadas {count} contas",
exportFailed: "Falha ao exportar",
importSuccess: "Importação concluída:",
importFailed: "Falha ao importar",
// Account Selection Strategy translations // Account Selection Strategy translations
accountSelectionStrategy: "Estratégia de Seleção de Conta", accountSelectionStrategy: "Estratégia de Seleção de Conta",

View File

@@ -280,6 +280,24 @@ window.translations.tr = {
quotaDistribution: "Kota Dağılımı", quotaDistribution: "Kota Dağılımı",
resetsIn: "{time} içinde sıfırlanır", resetsIn: "{time} içinde sıfırlanır",
noQuotaData: "Bu hesap için henüz kota verisi yok.", noQuotaData: "Bu hesap için henüz kota verisi yok.",
// Manual OAuth Mode
manualMode: "Manuel Mod",
manualModeDesc: "(callback ulaşamadığında kullan)",
authLinkLabel: "Yetkilendirme Linki:",
linkCopied: "Link panoya kopyalandı",
pasteCallbackLabel: "Callback URL veya kodu yapıştır:",
pasteCallbackPlaceholder: "http://localhost:51121/oauth-callback?code=... veya 4/0xxx...",
completeAuth: "Yetkilendirmeyi Tamamla",
authFailed: "Yetkilendirme başarısız",
// Import/Export
export: "Dışa Aktar",
import: "İçe Aktar",
exportAccounts: "Hesapları Dışa Aktar",
importAccounts: "Hesapları İçe Aktar",
exportSuccess: "{count} hesap dışa aktarıldı",
exportFailed: "Dışa aktarma başarısız",
importSuccess: "İçe aktarma tamamlandı:",
importFailed: "İçe aktarma başarısız",
// TODO: Missing translations - Hardcoded strings from HTML // TODO: Missing translations - Hardcoded strings from HTML
// pageTitle: "Antigravity Console", // pageTitle: "Antigravity Console",

View File

@@ -298,6 +298,24 @@ window.translations.zh = {
quotaDistribution: "配额分布", quotaDistribution: "配额分布",
resetsIn: "{time} 后重置", resetsIn: "{time} 后重置",
noQuotaData: "暂无此账号的配额数据。", noQuotaData: "暂无此账号的配额数据。",
// Manual OAuth Mode
manualMode: "手动模式",
manualModeDesc: "(当回调无法到达时使用)",
authLinkLabel: "授权链接:",
linkCopied: "链接已复制到剪贴板",
pasteCallbackLabel: "粘贴回调 URL 或授权码:",
pasteCallbackPlaceholder: "http://localhost:51121/oauth-callback?code=... 或 4/0xxx...",
completeAuth: "完成授权",
authFailed: "授权失败",
// Import/Export
export: "导出",
import: "导入",
exportAccounts: "导出账号",
importAccounts: "导入账号",
exportSuccess: "已导出 {count} 个账号",
exportFailed: "导出失败",
importSuccess: "导入完成:",
importFailed: "导入失败",
// TODO: Missing translations - Hardcoded strings from HTML // TODO: Missing translations - Hardcoded strings from HTML
// pageTitle: "Antigravity Console", // pageTitle: "Antigravity Console",

View File

@@ -28,6 +28,28 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg> </svg>
</div> </div>
<!-- Export Button -->
<button class="btn btn-xs btn-outline border-space-border text-gray-400 hover:text-white transition-all gap-2 h-8"
@click="exportAccounts()"
x-show="$store.data.accounts.length > 0"
:title="$store.global.t('exportAccounts') || 'Export Accounts'">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
<span x-text="$store.global.t('export') || 'Export'">Export</span>
</button>
<!-- Import Button -->
<label class="btn btn-xs btn-outline border-space-border text-gray-400 hover:text-white transition-all gap-2 h-8 cursor-pointer"
:title="$store.global.t('importAccounts') || 'Import Accounts'">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<span x-text="$store.global.t('import') || 'Import'">Import</span>
<input type="file" accept=".json" class="hidden" @change="importAccounts($event)">
</label>
<button class="btn btn-xs btn-outline border-space-border text-gray-400 hover:text-white transition-all gap-2 h-8" <button class="btn btn-xs btn-outline border-space-border text-gray-400 hover:text-white transition-all gap-2 h-8"
@click="reloadAccounts()" @click="reloadAccounts()"
:disabled="reloading"> :disabled="reloading">

View File

@@ -139,15 +139,19 @@ export function extractCodeFromInput(input) {
/** /**
* Start a local server to receive the OAuth callback * Start a local server to receive the OAuth callback
* Returns a promise that resolves with the authorization code * Returns an object with a promise and an abort function
* *
* @param {string} expectedState - Expected state parameter for CSRF protection * @param {string} expectedState - Expected state parameter for CSRF protection
* @param {number} timeoutMs - Timeout in milliseconds (default 120000) * @param {number} timeoutMs - Timeout in milliseconds (default 120000)
* @returns {Promise<string>} Authorization code from OAuth callback * @returns {{promise: Promise<string>, abort: Function}} Object with promise and abort function
*/ */
export function startCallbackServer(expectedState, timeoutMs = 120000) { export function startCallbackServer(expectedState, timeoutMs = 120000) {
return new Promise((resolve, reject) => { let server = null;
const server = http.createServer((req, res) => { let timeoutId = null;
let isAborted = false;
const promise = new Promise((resolve, reject) => {
server = http.createServer((req, res) => {
const url = new URL(req.url, `http://localhost:${OAUTH_CONFIG.callbackPort}`); const url = new URL(req.url, `http://localhost:${OAUTH_CONFIG.callbackPort}`);
if (url.pathname !== '/oauth-callback') { if (url.pathname !== '/oauth-callback') {
@@ -241,11 +245,28 @@ export function startCallbackServer(expectedState, timeoutMs = 120000) {
}); });
// Timeout after specified duration // Timeout after specified duration
setTimeout(() => { timeoutId = setTimeout(() => {
if (!isAborted) {
server.close(); server.close();
reject(new Error('OAuth callback timeout - no response received')); reject(new Error('OAuth callback timeout - no response received'));
}
}, timeoutMs); }, timeoutMs);
}); });
// Abort function to clean up server when manual completion happens
const abort = () => {
if (isAborted) return;
isAborted = true;
if (timeoutId) {
clearTimeout(timeoutId);
}
if (server) {
server.close();
logger.info('[OAuth] Callback server aborted (manual completion)');
}
};
return { promise, abort };
} }
/** /**

View File

@@ -200,7 +200,9 @@ async function addAccount(existingAccounts) {
console.log('Waiting for authentication (timeout: 2 minutes)...\n'); console.log('Waiting for authentication (timeout: 2 minutes)...\n');
try { try {
const code = await startCallbackServer(state); // startCallbackServer now returns { promise, abort }
const { promise } = startCallbackServer(state);
const code = await promise;
console.log('Received authorization code. Exchanging for tokens...'); console.log('Received authorization code. Exchanging for tokens...');
const result = await completeOAuthFlow(code, verifier); const result = await completeOAuthFlow(code, verifier);

View File

@@ -6,6 +6,7 @@
import app, { accountManager } from './server.js'; import app, { accountManager } from './server.js';
import { DEFAULT_PORT } from './constants.js'; import { DEFAULT_PORT } from './constants.js';
import { logger } from './utils/logger.js'; import { logger } from './utils/logger.js';
import { config } from './config.js';
import { getStrategyLabel, STRATEGY_NAMES, DEFAULT_STRATEGY } from './account-manager/strategies/index.js'; import { getStrategyLabel, STRATEGY_NAMES, DEFAULT_STRATEGY } from './account-manager/strategies/index.js';
import path from 'path'; import path from 'path';
import os from 'os'; import os from 'os';
@@ -108,7 +109,7 @@ ${border} ${align4(`Storage: ${CONFIG_DIR}`)}${border}
║ ║ ║ ║
║ Usage with Claude Code: ║ ║ Usage with Claude Code: ║
${border} ${align4(`export ANTHROPIC_BASE_URL=http://localhost:${PORT}`)}${border} ${border} ${align4(`export ANTHROPIC_BASE_URL=http://localhost:${PORT}`)}${border}
║ export ANTHROPIC_API_KEY=dummy ║ ${border} ${align4(`export ANTHROPIC_API_KEY=${config.apiKey || 'dummy'}`)}${border}
║ claude ║ ║ claude ║
║ ║ ║ ║
║ Add Google accounts: ║ ║ Add Google accounts: ║

View File

@@ -261,6 +261,115 @@ export function mountWebUI(app, dirname, accountManager) {
} }
}); });
/**
* GET /api/accounts/export - Export accounts
*/
app.get('/api/accounts/export', async (req, res) => {
try {
const { accounts } = await loadAccounts(ACCOUNT_CONFIG_PATH);
// Export only essential fields for portability
const exportData = accounts
.filter(acc => acc.source !== 'database')
.map(acc => {
const essential = { email: acc.email };
// Use snake_case for compatibility
if (acc.refreshToken) {
essential.refresh_token = acc.refreshToken;
}
if (acc.apiKey) {
essential.api_key = acc.apiKey;
}
return essential;
});
// Return plain array for simpler format
res.json(exportData);
} catch (error) {
logger.error('[WebUI] Export accounts error:', error);
res.status(500).json({ status: 'error', error: error.message });
}
});
/**
* POST /api/accounts/import - Batch import accounts
*/
app.post('/api/accounts/import', async (req, res) => {
try {
// Support both wrapped format { accounts: [...] } and plain array [...]
let importAccounts = req.body;
if (req.body.accounts && Array.isArray(req.body.accounts)) {
importAccounts = req.body.accounts;
}
if (!Array.isArray(importAccounts) || importAccounts.length === 0) {
return res.status(400).json({
status: 'error',
error: 'accounts must be a non-empty array'
});
}
const results = { added: [], updated: [], failed: [] };
// Load existing accounts once before the loop
const { accounts: existingAccounts } = await loadAccounts(ACCOUNT_CONFIG_PATH);
const existingEmails = new Set(existingAccounts.map(a => a.email));
for (const acc of importAccounts) {
try {
// Validate required fields
if (!acc.email) {
results.failed.push({ email: acc.email || 'unknown', reason: 'Missing email' });
continue;
}
// Support both snake_case and camelCase
const refreshToken = acc.refresh_token || acc.refreshToken;
const apiKey = acc.api_key || acc.apiKey;
// Must have at least one credential
if (!refreshToken && !apiKey) {
results.failed.push({ email: acc.email, reason: 'Missing refresh_token or api_key' });
continue;
}
// Check if account already exists
const exists = existingEmails.has(acc.email);
// Add account
await addAccount({
email: acc.email,
source: apiKey ? 'manual' : 'oauth',
refreshToken: refreshToken,
apiKey: apiKey
});
if (exists) {
results.updated.push(acc.email);
} else {
results.added.push(acc.email);
}
} catch (err) {
results.failed.push({ email: acc.email, reason: err.message });
}
}
// Reload AccountManager
await accountManager.reload();
logger.info(`[WebUI] Import complete: ${results.added.length} added, ${results.updated.length} updated, ${results.failed.length} failed`);
res.json({
status: 'ok',
results,
message: `Imported ${results.added.length + results.updated.length} accounts`
});
} catch (error) {
logger.error('[WebUI] Import accounts error:', error);
res.status(500).json({ status: 'error', error: error.message });
}
});
// ========================================== // ==========================================
// Configuration API // Configuration API
// ========================================== // ==========================================
@@ -674,11 +783,12 @@ export function mountWebUI(app, dirname, accountManager) {
const { url, verifier, state } = getAuthorizationUrl(); const { url, verifier, state } = getAuthorizationUrl();
// Start callback server on port 51121 (same as CLI) // Start callback server on port 51121 (same as CLI)
const serverPromise = startCallbackServer(state, 120000); // 2 min timeout const { promise: serverPromise, abort: abortServer } = startCallbackServer(state, 120000); // 2 min timeout
// Store the flow data // Store the flow data
pendingOAuthFlows.set(state, { pendingOAuthFlows.set(state, {
serverPromise, serverPromise,
abortServer,
verifier, verifier,
state, state,
timestamp: Date.now() timestamp: Date.now()
@@ -711,17 +821,85 @@ export function mountWebUI(app, dirname, accountManager) {
} }
}) })
.catch((err) => { .catch((err) => {
// Only log if not aborted (manual completion causes this)
if (!err.message?.includes('aborted')) {
logger.error('[WebUI] OAuth callback server error:', err); logger.error('[WebUI] OAuth callback server error:', err);
}
pendingOAuthFlows.delete(state); pendingOAuthFlows.delete(state);
}); });
res.json({ status: 'ok', url }); res.json({ status: 'ok', url, state });
} catch (error) { } catch (error) {
logger.error('[WebUI] Error generating auth URL:', error); logger.error('[WebUI] Error generating auth URL:', error);
res.status(500).json({ status: 'error', error: error.message }); res.status(500).json({ status: 'error', error: error.message });
} }
}); });
/**
* POST /api/auth/complete - Complete OAuth with manually submitted callback URL/code
* Used when auto-callback cannot reach the local server
*/
app.post('/api/auth/complete', async (req, res) => {
try {
const { callbackInput, state } = req.body;
if (!callbackInput || !state) {
return res.status(400).json({
status: 'error',
error: 'Missing callbackInput or state'
});
}
// Find the pending flow
const flowData = pendingOAuthFlows.get(state);
if (!flowData) {
return res.status(400).json({
status: 'error',
error: 'OAuth flow not found. The account may have been already added via auto-callback. Please refresh the account list.'
});
}
const { verifier, abortServer } = flowData;
// Extract code from input (URL or raw code)
const { extractCodeFromInput, completeOAuthFlow } = await import('../auth/oauth.js');
const { code } = extractCodeFromInput(callbackInput);
// Complete the OAuth flow
const accountData = await completeOAuthFlow(code, verifier);
// Add or update the account
await addAccount({
email: accountData.email,
refreshToken: accountData.refreshToken,
projectId: accountData.projectId,
source: 'oauth'
});
// Reload AccountManager to pick up the new account
await accountManager.reload();
// Abort the callback server since manual completion succeeded
if (abortServer) {
abortServer();
}
// Clean up
pendingOAuthFlows.delete(state);
logger.success(`[WebUI] Account ${accountData.email} added via manual callback`);
res.json({
status: 'ok',
email: accountData.email,
message: `Account ${accountData.email} added successfully`
});
} catch (error) {
logger.error('[WebUI] Manual OAuth completion error:', error);
res.status(500).json({ status: 'error', error: error.message });
}
});
/** /**
* Note: /oauth/callback route removed * Note: /oauth/callback route removed
* OAuth callbacks are now handled by the temporary server on port 51121 * OAuth callbacks are now handled by the temporary server on port 51121

View File

@@ -128,6 +128,79 @@ const tests = [
return 'Add Node button present'; return 'Add Node button present';
} }
}, },
{
name: 'Accounts view has addAccountModal component',
async run() {
const res = await request('/views/accounts.html');
if (!res.data.includes('addAccountModal')) {
throw new Error('addAccountModal component not found');
}
return 'addAccountModal component present';
}
},
{
name: 'Accounts view has manual auth UI elements',
async run() {
const res = await request('/views/accounts.html');
const elements = ['initManualAuth', 'completeManualAuth', 'callbackInput'];
const missing = elements.filter(el => !res.data.includes(el));
if (missing.length > 0) {
throw new Error(`Missing manual auth elements: ${missing.join(', ')}`);
}
return 'All manual auth UI elements present';
}
},
{
name: 'Auth URL API endpoint works',
async run() {
const res = await request('/api/auth/url');
if (res.status !== 200) {
throw new Error(`Expected 200, got ${res.status}`);
}
const data = JSON.parse(res.data);
if (data.status !== 'ok') {
throw new Error(`API returned status: ${data.status}`);
}
if (!data.url || !data.state) {
throw new Error('Missing url or state in response');
}
return `Auth URL generated with state: ${data.state.substring(0, 8)}...`;
}
},
{
name: 'Auth complete API validates required fields',
async run() {
const res = await request('/api/auth/complete', {
method: 'POST',
body: {}
});
if (res.status !== 400) {
throw new Error(`Expected 400 for missing fields, got ${res.status}`);
}
const data = JSON.parse(res.data);
if (!data.error || !data.error.includes('Missing')) {
throw new Error('Expected error about missing fields');
}
return 'API validates required fields';
}
},
{
name: 'Auth complete API rejects invalid state',
async run() {
const res = await request('/api/auth/complete', {
method: 'POST',
body: { callbackInput: 'fake-code', state: 'invalid-state' }
});
if (res.status !== 400) {
throw new Error(`Expected 400 for invalid state, got ${res.status}`);
}
const data = JSON.parse(res.data);
if (!data.error || !data.error.includes('not found')) {
throw new Error('Expected error about flow not found');
}
return 'API rejects invalid state';
}
},
{ {
name: 'Account toggle API works', name: 'Account toggle API works',
async run() { async run() {