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('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 }) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
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",
|
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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
server.close();
|
if (!isAborted) {
|
||||||
reject(new Error('OAuth callback timeout - no response received'));
|
server.close();
|
||||||
|
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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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: ║
|
||||||
|
|||||||
@@ -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) => {
|
||||||
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);
|
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
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user