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:
@@ -13,6 +13,7 @@ document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('accountManager', window.Components.accountManager);
|
||||
Alpine.data('claudeConfig', window.Components.claudeConfig);
|
||||
Alpine.data('logsViewer', window.Components.logsViewer);
|
||||
Alpine.data('addAccountModal', window.Components.addAccountModal);
|
||||
|
||||
// View Loader Directive
|
||||
Alpine.directive('load-view', (el, { expression }, { evaluate }) => {
|
||||
|
||||
@@ -252,8 +252,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Add Account Modal -->
|
||||
<dialog id="add_account_modal" class="modal backdrop-blur-sm">
|
||||
<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">
|
||||
<dialog id="add_account_modal" class="modal backdrop-blur-sm" x-data="addAccountModal">
|
||||
<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>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
@@ -281,6 +281,47 @@
|
||||
|
||||
<div class="text-center mt-2">
|
||||
<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">
|
||||
<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">
|
||||
@@ -300,12 +341,12 @@
|
||||
|
||||
<div class="modal-action mt-6">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</dialog>
|
||||
|
||||
@@ -374,6 +415,7 @@
|
||||
<script src="js/components/claude-config.js"></script>
|
||||
<script src="js/components/logs-viewer.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) -->
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -241,5 +241,90 @@ window.Components.accountManager = () => ({
|
||||
percent: Math.round(val * 100),
|
||||
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 = '';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
88
public/js/components/add-account-modal.js
Normal file
88
public/js/components/add-account-modal.js
Normal 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -298,6 +298,24 @@ window.translations.en = {
|
||||
quotaDistribution: "Quota Distribution",
|
||||
resetsIn: "Resets in {time}",
|
||||
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
|
||||
pageTitle: "Antigravity Console",
|
||||
live: "Live",
|
||||
|
||||
@@ -337,6 +337,24 @@ window.translations.id = {
|
||||
quotaDistribution: "Distribusi Kuota",
|
||||
resetsIn: "Reset dalam {time}",
|
||||
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
|
||||
pageTitle: "Antigravity Console",
|
||||
|
||||
@@ -271,6 +271,29 @@ window.translations.pt = {
|
||||
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.",
|
||||
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
|
||||
accountSelectionStrategy: "Estratégia de Seleção de Conta",
|
||||
|
||||
@@ -280,6 +280,24 @@ window.translations.tr = {
|
||||
quotaDistribution: "Kota Dağılımı",
|
||||
resetsIn: "{time} içinde sıfırlanır",
|
||||
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
|
||||
// pageTitle: "Antigravity Console",
|
||||
|
||||
@@ -298,6 +298,24 @@ window.translations.zh = {
|
||||
quotaDistribution: "配额分布",
|
||||
resetsIn: "{time} 后重置",
|
||||
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
|
||||
// pageTitle: "Antigravity Console",
|
||||
|
||||
@@ -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" />
|
||||
</svg>
|
||||
</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"
|
||||
@click="reloadAccounts()"
|
||||
:disabled="reloading">
|
||||
|
||||
@@ -139,15 +139,19 @@ export function extractCodeFromInput(input) {
|
||||
|
||||
/**
|
||||
* 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 {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) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = http.createServer((req, res) => {
|
||||
let server = null;
|
||||
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}`);
|
||||
|
||||
if (url.pathname !== '/oauth-callback') {
|
||||
@@ -241,11 +245,28 @@ export function startCallbackServer(expectedState, timeoutMs = 120000) {
|
||||
});
|
||||
|
||||
// Timeout after specified duration
|
||||
setTimeout(() => {
|
||||
server.close();
|
||||
reject(new Error('OAuth callback timeout - no response received'));
|
||||
timeoutId = setTimeout(() => {
|
||||
if (!isAborted) {
|
||||
server.close();
|
||||
reject(new Error('OAuth callback timeout - no response received'));
|
||||
}
|
||||
}, 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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -200,7 +200,9 @@ async function addAccount(existingAccounts) {
|
||||
console.log('Waiting for authentication (timeout: 2 minutes)...\n');
|
||||
|
||||
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...');
|
||||
const result = await completeOAuthFlow(code, verifier);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import app, { accountManager } from './server.js';
|
||||
import { DEFAULT_PORT } from './constants.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 path from 'path';
|
||||
import os from 'os';
|
||||
@@ -108,7 +109,7 @@ ${border} ${align4(`Storage: ${CONFIG_DIR}`)}${border}
|
||||
║ ║
|
||||
║ Usage with Claude Code: ║
|
||||
${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 ║
|
||||
║ ║
|
||||
║ Add Google accounts: ║
|
||||
|
||||
@@ -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
|
||||
// ==========================================
|
||||
@@ -674,11 +783,12 @@ export function mountWebUI(app, dirname, accountManager) {
|
||||
const { url, verifier, state } = getAuthorizationUrl();
|
||||
|
||||
// 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
|
||||
pendingOAuthFlows.set(state, {
|
||||
serverPromise,
|
||||
abortServer,
|
||||
verifier,
|
||||
state,
|
||||
timestamp: Date.now()
|
||||
@@ -711,17 +821,85 @@ export function mountWebUI(app, dirname, accountManager) {
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error('[WebUI] OAuth callback server error:', err);
|
||||
// Only log if not aborted (manual completion causes this)
|
||||
if (!err.message?.includes('aborted')) {
|
||||
logger.error('[WebUI] OAuth callback server error:', err);
|
||||
}
|
||||
pendingOAuthFlows.delete(state);
|
||||
});
|
||||
|
||||
res.json({ status: 'ok', url });
|
||||
res.json({ status: 'ok', url, state });
|
||||
} catch (error) {
|
||||
logger.error('[WebUI] Error generating auth URL:', error);
|
||||
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
|
||||
* OAuth callbacks are now handled by the temporary server on port 51121
|
||||
|
||||
@@ -128,6 +128,79 @@ const tests = [
|
||||
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',
|
||||
async run() {
|
||||
|
||||
Reference in New Issue
Block a user