feat(webui): add hot-reload account management with OAuth support
This commit is contained in:
@@ -71,6 +71,16 @@ export class AccountManager {
|
||||
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
|
||||
* @returns {number} Number of configured accounts
|
||||
@@ -278,6 +288,8 @@ export class AccountManager {
|
||||
accounts: this.#accounts.map(a => ({
|
||||
email: a.email,
|
||||
source: a.source,
|
||||
enabled: a.enabled !== false, // Default to true if undefined
|
||||
projectId: a.projectId || null,
|
||||
modelRateLimits: a.modelRateLimits || {},
|
||||
isInvalid: a.isInvalid || false,
|
||||
invalidReason: a.invalidReason || null,
|
||||
|
||||
@@ -32,15 +32,16 @@ function generatePKCE() {
|
||||
* Generate authorization URL for Google OAuth
|
||||
* 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
|
||||
*/
|
||||
export function getAuthorizationUrl() {
|
||||
export function getAuthorizationUrl(customRedirectUri = null) {
|
||||
const { verifier, challenge } = generatePKCE();
|
||||
const state = crypto.randomBytes(16).toString('hex');
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: OAUTH_CONFIG.clientId,
|
||||
redirect_uri: OAUTH_REDIRECT_URI,
|
||||
redirect_uri: customRedirectUri || OAUTH_REDIRECT_URI,
|
||||
response_type: 'code',
|
||||
scope: OAUTH_CONFIG.scopes.join(' '),
|
||||
access_type: 'offline',
|
||||
|
||||
@@ -423,32 +423,51 @@ app.get('/account-limits', async (req, res) => {
|
||||
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
|
||||
res.json({
|
||||
timestamp: new Date().toLocaleString(),
|
||||
totalAccounts: allAccounts.length,
|
||||
models: sortedModels,
|
||||
modelConfig: config.modelMapping || {},
|
||||
accounts: accountLimits.map(acc => ({
|
||||
email: acc.email,
|
||||
status: acc.status,
|
||||
error: acc.error || null,
|
||||
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
|
||||
}];
|
||||
})
|
||||
)
|
||||
}))
|
||||
accounts: accountLimits.map(acc => {
|
||||
// Merge quota data with account metadata
|
||||
const metadata = accountMetadataMap.get(acc.email) || {};
|
||||
return {
|
||||
email: acc.email,
|
||||
status: acc.status,
|
||||
error: acc.error || null,
|
||||
// Include metadata from AccountManager (WebUI needs these)
|
||||
source: metadata.source || 'unknown',
|
||||
enabled: metadata.enabled !== false,
|
||||
projectId: metadata.projectId || null,
|
||||
isInvalid: metadata.isInvalid || false,
|
||||
invalidReason: metadata.invalidReason || null,
|
||||
lastUsed: metadata.lastUsed || null,
|
||||
modelRateLimits: metadata.modelRateLimits || {},
|
||||
// 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) {
|
||||
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 { readClaudeConfig, updateClaudeConfig, getClaudeConfigPath } from '../utils/claude-config.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';
|
||||
|
||||
// OAuth state storage (state -> { verifier, timestamp })
|
||||
const pendingOAuthStates = new Map();
|
||||
// OAuth state storage (state -> { server, verifier, state, timestamp })
|
||||
// Maps state ID to active OAuth flow data
|
||||
const pendingOAuthFlows = new Map();
|
||||
|
||||
/**
|
||||
* WebUI Helper Functions - Direct account manipulation
|
||||
@@ -190,7 +191,7 @@ export function mountWebUI(app, dirname, accountManager) {
|
||||
await setAccountEnabled(email, enabled);
|
||||
|
||||
// Reload AccountManager to pick up changes
|
||||
await accountManager.initialize();
|
||||
await accountManager.reload();
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
@@ -210,7 +211,7 @@ export function mountWebUI(app, dirname, accountManager) {
|
||||
await removeAccount(email);
|
||||
|
||||
// Reload AccountManager to pick up changes
|
||||
await accountManager.initialize();
|
||||
await accountManager.reload();
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
@@ -227,7 +228,7 @@ export function mountWebUI(app, dirname, accountManager) {
|
||||
app.post('/api/accounts/reload', async (req, res) => {
|
||||
try {
|
||||
// Reload AccountManager from disk
|
||||
await accountManager.initialize();
|
||||
await accountManager.reload();
|
||||
|
||||
const status = accountManager.getStatus();
|
||||
res.json({
|
||||
@@ -508,23 +509,63 @@ export function mountWebUI(app, dirname, accountManager) {
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const { email } = req.query;
|
||||
const { url, verifier, state } = getAuthorizationUrl(email);
|
||||
|
||||
// Store the verifier temporarily
|
||||
pendingOAuthStates.set(state, { verifier, timestamp: Date.now() });
|
||||
|
||||
// Clean up old states (> 10 mins)
|
||||
// Clean up old flows (> 10 mins)
|
||||
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) {
|
||||
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 });
|
||||
} catch (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 /');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user