feat(webui): add hot-reload account management with OAuth support
This commit is contained in:
@@ -109,22 +109,43 @@ document.addEventListener('alpine:init', () => {
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'ok') {
|
||||
// Show info toast that OAuth is in progress
|
||||
Alpine.store('global').showToast(Alpine.store('global').t('oauthInProgress'), 'info');
|
||||
|
||||
// Open OAuth window
|
||||
window.open(data.url, 'google_oauth', 'width=600,height=700,scrollbars=yes');
|
||||
|
||||
const messageHandler = (event) => {
|
||||
if (event.data?.type === 'oauth-success') {
|
||||
// Poll for account changes instead of relying on postMessage
|
||||
// (since OAuth callback is now on port 51121, not this server)
|
||||
const initialAccountCount = Alpine.store('data').accounts.length;
|
||||
let pollCount = 0;
|
||||
const maxPolls = 60; // 2 minutes (2 second intervals)
|
||||
|
||||
const pollInterval = setInterval(async () => {
|
||||
pollCount++;
|
||||
|
||||
// Refresh account list
|
||||
await Alpine.store('data').fetchData();
|
||||
|
||||
// Check if new account was added
|
||||
const currentAccountCount = Alpine.store('data').accounts.length;
|
||||
if (currentAccountCount > initialAccountCount) {
|
||||
clearInterval(pollInterval);
|
||||
const actionKey = reAuthEmail ? 'reauthenticated' : 'added';
|
||||
const action = Alpine.store('global').t(actionKey);
|
||||
const successfully = Alpine.store('global').t('successfully');
|
||||
const msg = `${Alpine.store('global').t('accounts')} ${event.data.email} ${action} ${successfully}`;
|
||||
|
||||
Alpine.store('global').showToast(msg, 'success');
|
||||
Alpine.store('data').fetchData();
|
||||
Alpine.store('global').showToast(
|
||||
`${Alpine.store('global').t('accounts')} ${action} ${successfully}`,
|
||||
'success'
|
||||
);
|
||||
document.getElementById('add_account_modal')?.close();
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', messageHandler);
|
||||
setTimeout(() => window.removeEventListener('message', messageHandler), 300000);
|
||||
|
||||
// Stop polling after max attempts
|
||||
if (pollCount >= maxPolls) {
|
||||
clearInterval(pollInterval);
|
||||
}
|
||||
}, 2000); // Poll every 2 seconds
|
||||
} else {
|
||||
Alpine.store('global').showToast(data.error || Alpine.store('global').t('failedToGetAuthUrl'), 'error');
|
||||
}
|
||||
|
||||
@@ -130,7 +130,10 @@
|
||||
|
||||
/* View Containers */
|
||||
.view-container {
|
||||
@apply max-w-7xl mx-auto p-6 space-y-6 animate-fade-in;
|
||||
@apply mx-auto p-6 space-y-6 animate-fade-in;
|
||||
/* Responsive max-width: use most of screen on small displays,
|
||||
but cap at 1600px on large displays for reading comfort */
|
||||
max-width: min(95%, 1600px);
|
||||
}
|
||||
|
||||
/* Section Headers */
|
||||
|
||||
@@ -28,6 +28,14 @@ window.Components.accountManager = () => ({
|
||||
async toggleAccount(email, enabled) {
|
||||
const store = Alpine.store('global');
|
||||
const password = store.webuiPassword;
|
||||
|
||||
// Optimistic update: immediately update UI
|
||||
const dataStore = Alpine.store('data');
|
||||
const account = dataStore.accounts.find(a => a.email === email);
|
||||
if (account) {
|
||||
account.enabled = enabled;
|
||||
}
|
||||
|
||||
try {
|
||||
const { response, newPassword } = await window.utils.request(`/api/accounts/${encodeURIComponent(email)}/toggle`, {
|
||||
method: 'POST',
|
||||
@@ -40,12 +48,23 @@ window.Components.accountManager = () => ({
|
||||
if (data.status === 'ok') {
|
||||
const status = enabled ? store.t('enabledStatus') : store.t('disabledStatus');
|
||||
store.showToast(store.t('accountToggled', { email, status }), 'success');
|
||||
Alpine.store('data').fetchData();
|
||||
// Refresh to confirm server state
|
||||
await dataStore.fetchData();
|
||||
} else {
|
||||
store.showToast(data.error || store.t('toggleFailed'), 'error');
|
||||
// Rollback optimistic update on error
|
||||
if (account) {
|
||||
account.enabled = !enabled;
|
||||
}
|
||||
await dataStore.fetchData();
|
||||
}
|
||||
} catch (e) {
|
||||
store.showToast(store.t('toggleFailed') + ': ' + e.message, 'error');
|
||||
// Rollback optimistic update on error
|
||||
if (account) {
|
||||
account.enabled = !enabled;
|
||||
}
|
||||
await dataStore.fetchData();
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -495,7 +495,10 @@ window.Components.dashboard = () => ({
|
||||
|
||||
const isCore = (id) => /sonnet|opus|pro|flash/i.test(id);
|
||||
|
||||
accounts.forEach(acc => {
|
||||
// Only count enabled accounts in statistics
|
||||
const enabledAccounts = accounts.filter(acc => acc.enabled !== false);
|
||||
|
||||
enabledAccounts.forEach(acc => {
|
||||
if (acc.status === 'ok') {
|
||||
const limits = Object.entries(acc.limits || {});
|
||||
let hasActiveCore = limits.some(([id, l]) => l && l.remainingFraction > 0.05 && isCore(id));
|
||||
@@ -512,7 +515,10 @@ window.Components.dashboard = () => ({
|
||||
limited++;
|
||||
}
|
||||
});
|
||||
this.stats.total = accounts.length;
|
||||
|
||||
// TOTAL shows only enabled accounts
|
||||
// Disabled accounts are excluded from all statistics
|
||||
this.stats.total = enabledAccounts.length;
|
||||
this.stats.active = active;
|
||||
this.stats.limited = limited;
|
||||
},
|
||||
|
||||
@@ -50,17 +50,20 @@ document.addEventListener('alpine:init', () => {
|
||||
enabled: "ENABLED",
|
||||
health: "HEALTH",
|
||||
identity: "IDENTITY (EMAIL)",
|
||||
source: "SOURCE",
|
||||
projectId: "PROJECT ID",
|
||||
sessionState: "SESSION STATE",
|
||||
operations: "OPERATIONS",
|
||||
delete: "Delete",
|
||||
confirmDelete: "Are you sure you want to remove this account?",
|
||||
cannotDeleteDatabase: "Cannot delete: This account is from Antigravity database (read-only)",
|
||||
connectGoogle: "Connect Google Account",
|
||||
reauthenticated: "re-authenticated",
|
||||
added: "added",
|
||||
successfully: "successfully",
|
||||
failedToGetAuthUrl: "Failed to get auth URL",
|
||||
failedToStartOAuth: "Failed to start OAuth flow",
|
||||
oauthInProgress: "OAuth in progress. Please complete authentication in the popup window...",
|
||||
family: "Family",
|
||||
model: "Model",
|
||||
activeSuffix: "Active",
|
||||
@@ -211,17 +214,20 @@ document.addEventListener('alpine:init', () => {
|
||||
enabled: "启用",
|
||||
health: "健康度",
|
||||
identity: "身份 (邮箱)",
|
||||
source: "来源",
|
||||
projectId: "项目 ID",
|
||||
sessionState: "会话状态",
|
||||
operations: "操作",
|
||||
delete: "删除",
|
||||
confirmDelete: "确定要移除此账号吗?",
|
||||
cannotDeleteDatabase: "无法删除:此账号来自 Antigravity 数据库(只读)",
|
||||
connectGoogle: "连接 Google 账号",
|
||||
reauthenticated: "已重新认证",
|
||||
added: "已添加",
|
||||
successfully: "成功",
|
||||
failedToGetAuthUrl: "获取认证链接失败",
|
||||
failedToStartOAuth: "启动 OAuth 流程失败",
|
||||
oauthInProgress: "OAuth 授权进行中,请在弹出窗口中完成认证...",
|
||||
family: "系列",
|
||||
model: "模型",
|
||||
activeSuffix: "活跃",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<tr class="border-b border-space-border/50">
|
||||
<th class="pl-6 py-3 text-left text-[10px] font-bold text-gray-500 uppercase tracking-wider w-20" x-text="$store.global.t('enabled')">Enabled</th>
|
||||
<th class="py-3 text-left text-[10px] font-bold text-gray-500 uppercase tracking-wider" x-text="$store.global.t('identity')">Identity (Email)</th>
|
||||
<th class="py-3 text-left text-[10px] font-bold text-gray-500 uppercase tracking-wider w-24" x-text="$store.global.t('source')">Source</th>
|
||||
<th class="py-3 text-left text-[10px] font-bold text-gray-500 uppercase tracking-wider w-32" x-text="$store.global.t('projectId')">Project ID</th>
|
||||
<th class="py-3 text-left text-[10px] font-bold text-gray-500 uppercase tracking-wider w-24" x-text="$store.global.t('health')">Health</th>
|
||||
<th class="py-3 pr-6 text-right text-[10px] font-bold text-gray-500 uppercase tracking-wider w-32" x-text="$store.global.t('operations')">Operations</th>
|
||||
@@ -50,6 +51,12 @@
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-4">
|
||||
<span class="px-2 py-1 text-[10px] font-mono font-bold uppercase rounded"
|
||||
:class="acc.source === 'oauth' ? 'bg-neon-purple/10 text-neon-purple border border-neon-purple/30' : 'bg-gray-500/10 text-gray-400 border border-gray-500/30'"
|
||||
x-text="acc.source || 'oauth'">
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-4 font-mono text-xs text-gray-500" x-text="acc.projectId || '-'"></td>
|
||||
<td class="py-4">
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -81,8 +88,11 @@
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="p-2 rounded hover:bg-red-500/10 text-gray-500 hover:text-red-400 transition-colors"
|
||||
@click="deleteAccount(acc.email)" :title="$store.global.t('delete')">
|
||||
class="p-2 rounded transition-colors"
|
||||
:class="acc.source === 'database' ? 'text-gray-700 cursor-not-allowed' : 'hover:bg-red-500/10 text-gray-500 hover:text-red-400'"
|
||||
:disabled="acc.source === 'database'"
|
||||
@click="acc.source !== 'database' && deleteAccount(acc.email)"
|
||||
:title="acc.source === 'database' ? $store.global.t('cannotDeleteDatabase') : $store.global.t('delete')">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
|
||||
Reference in New Issue
Block a user