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

@@ -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 = '';
}
}
});

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",
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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",