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">
|
||||
|
||||
Reference in New Issue
Block a user