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();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.status === 'ok') {
|
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');
|
window.open(data.url, 'google_oauth', 'width=600,height=700,scrollbars=yes');
|
||||||
|
|
||||||
const messageHandler = (event) => {
|
// Poll for account changes instead of relying on postMessage
|
||||||
if (event.data?.type === 'oauth-success') {
|
// (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 actionKey = reAuthEmail ? 'reauthenticated' : 'added';
|
||||||
const action = Alpine.store('global').t(actionKey);
|
const action = Alpine.store('global').t(actionKey);
|
||||||
const successfully = Alpine.store('global').t('successfully');
|
const successfully = Alpine.store('global').t('successfully');
|
||||||
const msg = `${Alpine.store('global').t('accounts')} ${event.data.email} ${action} ${successfully}`;
|
Alpine.store('global').showToast(
|
||||||
|
`${Alpine.store('global').t('accounts')} ${action} ${successfully}`,
|
||||||
Alpine.store('global').showToast(msg, 'success');
|
'success'
|
||||||
Alpine.store('data').fetchData();
|
);
|
||||||
document.getElementById('add_account_modal')?.close();
|
document.getElementById('add_account_modal')?.close();
|
||||||
}
|
}
|
||||||
};
|
|
||||||
window.addEventListener('message', messageHandler);
|
// Stop polling after max attempts
|
||||||
setTimeout(() => window.removeEventListener('message', messageHandler), 300000);
|
if (pollCount >= maxPolls) {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
}
|
||||||
|
}, 2000); // Poll every 2 seconds
|
||||||
} else {
|
} else {
|
||||||
Alpine.store('global').showToast(data.error || Alpine.store('global').t('failedToGetAuthUrl'), 'error');
|
Alpine.store('global').showToast(data.error || Alpine.store('global').t('failedToGetAuthUrl'), 'error');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -130,7 +130,10 @@
|
|||||||
|
|
||||||
/* View Containers */
|
/* View Containers */
|
||||||
.view-container {
|
.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 */
|
/* Section Headers */
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ window.Components.accountManager = () => ({
|
|||||||
async toggleAccount(email, enabled) {
|
async toggleAccount(email, enabled) {
|
||||||
const store = Alpine.store('global');
|
const store = Alpine.store('global');
|
||||||
const password = store.webuiPassword;
|
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 {
|
try {
|
||||||
const { response, newPassword } = await window.utils.request(`/api/accounts/${encodeURIComponent(email)}/toggle`, {
|
const { response, newPassword } = await window.utils.request(`/api/accounts/${encodeURIComponent(email)}/toggle`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -40,12 +48,23 @@ window.Components.accountManager = () => ({
|
|||||||
if (data.status === 'ok') {
|
if (data.status === 'ok') {
|
||||||
const status = enabled ? store.t('enabledStatus') : store.t('disabledStatus');
|
const status = enabled ? store.t('enabledStatus') : store.t('disabledStatus');
|
||||||
store.showToast(store.t('accountToggled', { email, status }), 'success');
|
store.showToast(store.t('accountToggled', { email, status }), 'success');
|
||||||
Alpine.store('data').fetchData();
|
// Refresh to confirm server state
|
||||||
|
await dataStore.fetchData();
|
||||||
} else {
|
} else {
|
||||||
store.showToast(data.error || store.t('toggleFailed'), 'error');
|
store.showToast(data.error || store.t('toggleFailed'), 'error');
|
||||||
|
// Rollback optimistic update on error
|
||||||
|
if (account) {
|
||||||
|
account.enabled = !enabled;
|
||||||
|
}
|
||||||
|
await dataStore.fetchData();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
store.showToast(store.t('toggleFailed') + ': ' + e.message, 'error');
|
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);
|
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') {
|
if (acc.status === 'ok') {
|
||||||
const limits = Object.entries(acc.limits || {});
|
const limits = Object.entries(acc.limits || {});
|
||||||
let hasActiveCore = limits.some(([id, l]) => l && l.remainingFraction > 0.05 && isCore(id));
|
let hasActiveCore = limits.some(([id, l]) => l && l.remainingFraction > 0.05 && isCore(id));
|
||||||
@@ -512,7 +515,10 @@ window.Components.dashboard = () => ({
|
|||||||
limited++;
|
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.active = active;
|
||||||
this.stats.limited = limited;
|
this.stats.limited = limited;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -50,17 +50,20 @@ document.addEventListener('alpine:init', () => {
|
|||||||
enabled: "ENABLED",
|
enabled: "ENABLED",
|
||||||
health: "HEALTH",
|
health: "HEALTH",
|
||||||
identity: "IDENTITY (EMAIL)",
|
identity: "IDENTITY (EMAIL)",
|
||||||
|
source: "SOURCE",
|
||||||
projectId: "PROJECT ID",
|
projectId: "PROJECT ID",
|
||||||
sessionState: "SESSION STATE",
|
sessionState: "SESSION STATE",
|
||||||
operations: "OPERATIONS",
|
operations: "OPERATIONS",
|
||||||
delete: "Delete",
|
delete: "Delete",
|
||||||
confirmDelete: "Are you sure you want to remove this account?",
|
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",
|
connectGoogle: "Connect Google Account",
|
||||||
reauthenticated: "re-authenticated",
|
reauthenticated: "re-authenticated",
|
||||||
added: "added",
|
added: "added",
|
||||||
successfully: "successfully",
|
successfully: "successfully",
|
||||||
failedToGetAuthUrl: "Failed to get auth URL",
|
failedToGetAuthUrl: "Failed to get auth URL",
|
||||||
failedToStartOAuth: "Failed to start OAuth flow",
|
failedToStartOAuth: "Failed to start OAuth flow",
|
||||||
|
oauthInProgress: "OAuth in progress. Please complete authentication in the popup window...",
|
||||||
family: "Family",
|
family: "Family",
|
||||||
model: "Model",
|
model: "Model",
|
||||||
activeSuffix: "Active",
|
activeSuffix: "Active",
|
||||||
@@ -211,17 +214,20 @@ document.addEventListener('alpine:init', () => {
|
|||||||
enabled: "启用",
|
enabled: "启用",
|
||||||
health: "健康度",
|
health: "健康度",
|
||||||
identity: "身份 (邮箱)",
|
identity: "身份 (邮箱)",
|
||||||
|
source: "来源",
|
||||||
projectId: "项目 ID",
|
projectId: "项目 ID",
|
||||||
sessionState: "会话状态",
|
sessionState: "会话状态",
|
||||||
operations: "操作",
|
operations: "操作",
|
||||||
delete: "删除",
|
delete: "删除",
|
||||||
confirmDelete: "确定要移除此账号吗?",
|
confirmDelete: "确定要移除此账号吗?",
|
||||||
|
cannotDeleteDatabase: "无法删除:此账号来自 Antigravity 数据库(只读)",
|
||||||
connectGoogle: "连接 Google 账号",
|
connectGoogle: "连接 Google 账号",
|
||||||
reauthenticated: "已重新认证",
|
reauthenticated: "已重新认证",
|
||||||
added: "已添加",
|
added: "已添加",
|
||||||
successfully: "成功",
|
successfully: "成功",
|
||||||
failedToGetAuthUrl: "获取认证链接失败",
|
failedToGetAuthUrl: "获取认证链接失败",
|
||||||
failedToStartOAuth: "启动 OAuth 流程失败",
|
failedToStartOAuth: "启动 OAuth 流程失败",
|
||||||
|
oauthInProgress: "OAuth 授权进行中,请在弹出窗口中完成认证...",
|
||||||
family: "系列",
|
family: "系列",
|
||||||
model: "模型",
|
model: "模型",
|
||||||
activeSuffix: "活跃",
|
activeSuffix: "活跃",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
<tr class="border-b border-space-border/50">
|
<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="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" 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-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 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>
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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 font-mono text-xs text-gray-500" x-text="acc.projectId || '-'"></td>
|
||||||
<td class="py-4">
|
<td class="py-4">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -81,8 +88,11 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="p-2 rounded hover:bg-red-500/10 text-gray-500 hover:text-red-400 transition-colors"
|
class="p-2 rounded transition-colors"
|
||||||
@click="deleteAccount(acc.email)" :title="$store.global.t('delete')">
|
: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">
|
<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"
|
<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" />
|
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" />
|
||||||
|
|||||||
@@ -71,6 +71,16 @@ export class AccountManager {
|
|||||||
this.#initialized = true;
|
this.#initialized = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reload accounts from disk (force re-initialization)
|
||||||
|
* Useful when accounts.json is modified externally (e.g., by WebUI)
|
||||||
|
*/
|
||||||
|
async reload() {
|
||||||
|
this.#initialized = false;
|
||||||
|
await this.initialize();
|
||||||
|
logger.info('[AccountManager] Accounts reloaded from disk');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the number of accounts
|
* Get the number of accounts
|
||||||
* @returns {number} Number of configured accounts
|
* @returns {number} Number of configured accounts
|
||||||
@@ -278,6 +288,8 @@ export class AccountManager {
|
|||||||
accounts: this.#accounts.map(a => ({
|
accounts: this.#accounts.map(a => ({
|
||||||
email: a.email,
|
email: a.email,
|
||||||
source: a.source,
|
source: a.source,
|
||||||
|
enabled: a.enabled !== false, // Default to true if undefined
|
||||||
|
projectId: a.projectId || null,
|
||||||
modelRateLimits: a.modelRateLimits || {},
|
modelRateLimits: a.modelRateLimits || {},
|
||||||
isInvalid: a.isInvalid || false,
|
isInvalid: a.isInvalid || false,
|
||||||
invalidReason: a.invalidReason || null,
|
invalidReason: a.invalidReason || null,
|
||||||
|
|||||||
@@ -32,15 +32,16 @@ function generatePKCE() {
|
|||||||
* Generate authorization URL for Google OAuth
|
* Generate authorization URL for Google OAuth
|
||||||
* Returns the URL and the PKCE verifier (needed for token exchange)
|
* Returns the URL and the PKCE verifier (needed for token exchange)
|
||||||
*
|
*
|
||||||
|
* @param {string} [customRedirectUri] - Optional custom redirect URI (e.g. for WebUI)
|
||||||
* @returns {{url: string, verifier: string, state: string}} Auth URL and PKCE data
|
* @returns {{url: string, verifier: string, state: string}} Auth URL and PKCE data
|
||||||
*/
|
*/
|
||||||
export function getAuthorizationUrl() {
|
export function getAuthorizationUrl(customRedirectUri = null) {
|
||||||
const { verifier, challenge } = generatePKCE();
|
const { verifier, challenge } = generatePKCE();
|
||||||
const state = crypto.randomBytes(16).toString('hex');
|
const state = crypto.randomBytes(16).toString('hex');
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
client_id: OAUTH_CONFIG.clientId,
|
client_id: OAUTH_CONFIG.clientId,
|
||||||
redirect_uri: OAUTH_REDIRECT_URI,
|
redirect_uri: customRedirectUri || OAUTH_REDIRECT_URI,
|
||||||
response_type: 'code',
|
response_type: 'code',
|
||||||
scope: OAUTH_CONFIG.scopes.join(' '),
|
scope: OAUTH_CONFIG.scopes.join(' '),
|
||||||
access_type: 'offline',
|
access_type: 'offline',
|
||||||
|
|||||||
@@ -423,32 +423,51 @@ app.get('/account-limits', async (req, res) => {
|
|||||||
return res.send(lines.join('\n'));
|
return res.send(lines.join('\n'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get account metadata from AccountManager
|
||||||
|
const accountStatus = accountManager.getStatus();
|
||||||
|
const accountMetadataMap = new Map(
|
||||||
|
accountStatus.accounts.map(a => [a.email, a])
|
||||||
|
);
|
||||||
|
|
||||||
// Default: JSON format
|
// Default: JSON format
|
||||||
res.json({
|
res.json({
|
||||||
timestamp: new Date().toLocaleString(),
|
timestamp: new Date().toLocaleString(),
|
||||||
totalAccounts: allAccounts.length,
|
totalAccounts: allAccounts.length,
|
||||||
models: sortedModels,
|
models: sortedModels,
|
||||||
modelConfig: config.modelMapping || {},
|
modelConfig: config.modelMapping || {},
|
||||||
accounts: accountLimits.map(acc => ({
|
accounts: accountLimits.map(acc => {
|
||||||
email: acc.email,
|
// Merge quota data with account metadata
|
||||||
status: acc.status,
|
const metadata = accountMetadataMap.get(acc.email) || {};
|
||||||
error: acc.error || null,
|
return {
|
||||||
limits: Object.fromEntries(
|
email: acc.email,
|
||||||
sortedModels.map(modelId => {
|
status: acc.status,
|
||||||
const quota = acc.models?.[modelId];
|
error: acc.error || null,
|
||||||
if (!quota) {
|
// Include metadata from AccountManager (WebUI needs these)
|
||||||
return [modelId, null];
|
source: metadata.source || 'unknown',
|
||||||
}
|
enabled: metadata.enabled !== false,
|
||||||
return [modelId, {
|
projectId: metadata.projectId || null,
|
||||||
remaining: quota.remainingFraction !== null
|
isInvalid: metadata.isInvalid || false,
|
||||||
? `${Math.round(quota.remainingFraction * 100)}%`
|
invalidReason: metadata.invalidReason || null,
|
||||||
: 'N/A',
|
lastUsed: metadata.lastUsed || null,
|
||||||
remainingFraction: quota.remainingFraction,
|
modelRateLimits: metadata.modelRateLimits || {},
|
||||||
resetTime: quota.resetTime || null
|
// Quota limits
|
||||||
}];
|
limits: Object.fromEntries(
|
||||||
})
|
sortedModels.map(modelId => {
|
||||||
)
|
const quota = acc.models?.[modelId];
|
||||||
}))
|
if (!quota) {
|
||||||
|
return [modelId, null];
|
||||||
|
}
|
||||||
|
return [modelId, {
|
||||||
|
remaining: quota.remainingFraction !== null
|
||||||
|
? `${Math.round(quota.remainingFraction * 100)}%`
|
||||||
|
: 'N/A',
|
||||||
|
remainingFraction: quota.remainingFraction,
|
||||||
|
resetTime: quota.resetTime || null
|
||||||
|
}];
|
||||||
|
})
|
||||||
|
)
|
||||||
|
};
|
||||||
|
})
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
|
|||||||
@@ -18,11 +18,12 @@ import { getPublicConfig, saveConfig, config } from '../config.js';
|
|||||||
import { DEFAULT_PORT, ACCOUNT_CONFIG_PATH } from '../constants.js';
|
import { DEFAULT_PORT, ACCOUNT_CONFIG_PATH } from '../constants.js';
|
||||||
import { readClaudeConfig, updateClaudeConfig, getClaudeConfigPath } from '../utils/claude-config.js';
|
import { readClaudeConfig, updateClaudeConfig, getClaudeConfigPath } from '../utils/claude-config.js';
|
||||||
import { logger } from '../utils/logger.js';
|
import { logger } from '../utils/logger.js';
|
||||||
import { getAuthorizationUrl, completeOAuthFlow } from '../auth/oauth.js';
|
import { getAuthorizationUrl, completeOAuthFlow, startCallbackServer } from '../auth/oauth.js';
|
||||||
import { loadAccounts, saveAccounts } from '../account-manager/storage.js';
|
import { loadAccounts, saveAccounts } from '../account-manager/storage.js';
|
||||||
|
|
||||||
// OAuth state storage (state -> { verifier, timestamp })
|
// OAuth state storage (state -> { server, verifier, state, timestamp })
|
||||||
const pendingOAuthStates = new Map();
|
// Maps state ID to active OAuth flow data
|
||||||
|
const pendingOAuthFlows = new Map();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WebUI Helper Functions - Direct account manipulation
|
* WebUI Helper Functions - Direct account manipulation
|
||||||
@@ -190,7 +191,7 @@ export function mountWebUI(app, dirname, accountManager) {
|
|||||||
await setAccountEnabled(email, enabled);
|
await setAccountEnabled(email, enabled);
|
||||||
|
|
||||||
// Reload AccountManager to pick up changes
|
// Reload AccountManager to pick up changes
|
||||||
await accountManager.initialize();
|
await accountManager.reload();
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
@@ -210,7 +211,7 @@ export function mountWebUI(app, dirname, accountManager) {
|
|||||||
await removeAccount(email);
|
await removeAccount(email);
|
||||||
|
|
||||||
// Reload AccountManager to pick up changes
|
// Reload AccountManager to pick up changes
|
||||||
await accountManager.initialize();
|
await accountManager.reload();
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
@@ -227,7 +228,7 @@ export function mountWebUI(app, dirname, accountManager) {
|
|||||||
app.post('/api/accounts/reload', async (req, res) => {
|
app.post('/api/accounts/reload', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// Reload AccountManager from disk
|
// Reload AccountManager from disk
|
||||||
await accountManager.initialize();
|
await accountManager.reload();
|
||||||
|
|
||||||
const status = accountManager.getStatus();
|
const status = accountManager.getStatus();
|
||||||
res.json({
|
res.json({
|
||||||
@@ -508,23 +509,63 @@ export function mountWebUI(app, dirname, accountManager) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/auth/url - Get OAuth URL to start the flow
|
* GET /api/auth/url - Get OAuth URL to start the flow
|
||||||
|
* Uses CLI's OAuth flow (localhost:51121) instead of WebUI's port
|
||||||
|
* to match Google OAuth Console's authorized redirect URIs
|
||||||
*/
|
*/
|
||||||
app.get('/api/auth/url', (req, res) => {
|
app.get('/api/auth/url', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { email } = req.query;
|
// Clean up old flows (> 10 mins)
|
||||||
const { url, verifier, state } = getAuthorizationUrl(email);
|
|
||||||
|
|
||||||
// Store the verifier temporarily
|
|
||||||
pendingOAuthStates.set(state, { verifier, timestamp: Date.now() });
|
|
||||||
|
|
||||||
// Clean up old states (> 10 mins)
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const [key, val] of pendingOAuthStates.entries()) {
|
for (const [key, val] of pendingOAuthFlows.entries()) {
|
||||||
if (now - val.timestamp > 10 * 60 * 1000) {
|
if (now - val.timestamp > 10 * 60 * 1000) {
|
||||||
pendingOAuthStates.delete(key);
|
pendingOAuthFlows.delete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate OAuth URL using default redirect URI (localhost:51121)
|
||||||
|
const { url, verifier, state } = getAuthorizationUrl();
|
||||||
|
|
||||||
|
// Start callback server on port 51121 (same as CLI)
|
||||||
|
const serverPromise = startCallbackServer(state, 120000); // 2 min timeout
|
||||||
|
|
||||||
|
// Store the flow data
|
||||||
|
pendingOAuthFlows.set(state, {
|
||||||
|
serverPromise,
|
||||||
|
verifier,
|
||||||
|
state,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start async handler for the OAuth callback
|
||||||
|
serverPromise
|
||||||
|
.then(async (code) => {
|
||||||
|
try {
|
||||||
|
logger.info('[WebUI] Received OAuth callback, completing 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();
|
||||||
|
|
||||||
|
logger.success(`[WebUI] Account ${accountData.email} added successfully`);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('[WebUI] OAuth flow completion error:', err);
|
||||||
|
} finally {
|
||||||
|
pendingOAuthFlows.delete(state);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
logger.error('[WebUI] OAuth callback server error:', err);
|
||||||
|
pendingOAuthFlows.delete(state);
|
||||||
|
});
|
||||||
|
|
||||||
res.json({ status: 'ok', url });
|
res.json({ status: 'ok', url });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[WebUI] Error generating auth URL:', error);
|
logger.error('[WebUI] Error generating auth URL:', error);
|
||||||
@@ -533,107 +574,10 @@ export function mountWebUI(app, dirname, accountManager) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /oauth/callback - OAuth callback handler
|
* Note: /oauth/callback route removed
|
||||||
|
* OAuth callbacks are now handled by the temporary server on port 51121
|
||||||
|
* (same as CLI) to match Google OAuth Console's authorized redirect URIs
|
||||||
*/
|
*/
|
||||||
app.get('/oauth/callback', async (req, res) => {
|
|
||||||
const { code, state, error } = req.query;
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return res.status(400).send(`Authentication failed: ${error}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!code || !state) {
|
|
||||||
return res.status(400).send('Missing code or state parameter');
|
|
||||||
}
|
|
||||||
|
|
||||||
const storedState = pendingOAuthStates.get(state);
|
|
||||||
if (!storedState) {
|
|
||||||
return res.status(400).send('Invalid or expired state parameter. Please try again.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove used state
|
|
||||||
pendingOAuthStates.delete(state);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const accountData = await completeOAuthFlow(code, storedState.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.initialize();
|
|
||||||
|
|
||||||
// Return a simple HTML page that closes itself or redirects
|
|
||||||
res.send(`
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>Authentication Successful</title>
|
|
||||||
<link rel="stylesheet" href="/css/style.css">
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
||||||
background-color: var(--color-space-950);
|
|
||||||
color: var(--color-text-main);
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
height: 100vh;
|
|
||||||
margin: 0;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
h1 { color: var(--color-neon-green); }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>Authentication Successful</h1>
|
|
||||||
<p>Account ${accountData.email} has been added.</p>
|
|
||||||
<p>You can close this window now.</p>
|
|
||||||
<script>
|
|
||||||
// Notify opener if opened via window.open
|
|
||||||
if (window.opener) {
|
|
||||||
window.opener.postMessage({ type: 'oauth-success', email: '${accountData.email}' }, '*');
|
|
||||||
setTimeout(() => window.close(), 2000);
|
|
||||||
} else {
|
|
||||||
// If redirected in same tab, redirect back to home after delay
|
|
||||||
setTimeout(() => window.location.href = '/', 3000);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('[WebUI] OAuth callback error:', err);
|
|
||||||
res.status(500).send(`
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>Authentication Failed</title>
|
|
||||||
<link rel="stylesheet" href="/css/style.css">
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
||||||
background-color: var(--color-space-950);
|
|
||||||
color: var(--color-text-main);
|
|
||||||
text-align: center;
|
|
||||||
padding: 50px;
|
|
||||||
}
|
|
||||||
h1 { color: var(--color-neon-red); }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>Authentication Failed</h1>
|
|
||||||
<p>${err.message}</p>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info('[WebUI] Mounted at /');
|
logger.info('[WebUI] Mounted at /');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user