feat(webui): Enhance dashboard, global styles, and settings module
## Dashboard Enhancements
- Add Request Volume trend chart with Chart.js line graph
- Support Family/Model display modes for aggregation levels
- Show Total/Today/1H usage statistics
- Hierarchical filter dropdown with Smart select (Top 5 by 24h usage)
- Persist chart preferences to localStorage
- Improve account health detection logic
- Core models (sonnet/opus/pro/flash) require >5% quota to be healthy
- Dynamic quota ring chart supporting any model family
- Unify table styles with standard-table class
## Global Style Refactoring
- Add CSS variable system for theming
- Space color scale (950/900/850/800/border)
- Neon accent colors (purple/green/cyan/yellow/red)
- Text hierarchy (main/dim/muted/bright)
- Chart palette (16 colors)
- Add unified component classes
- .view-container for consistent page layouts
- .section-header/.section-title/.section-desc
- .standard-table for table styling
- Update scrollbar, nav-item, progress-bar to use theme variables
## Settings Module Extensions
- Add model mapping column in Models tab
- Enhance model selectors with family color indicators
- Support horizontal scroll for tabs on narrow screens
- Add defaultCooldownMs and maxWaitBeforeErrorMs config options
## New Module
- Add src/modules/usage-stats.js for request tracking
- Track /v1/messages and /v1/chat/completions endpoints
- Hierarchical storage: { hour: { family: { model: count } } }
- Auto-save every minute, 30-day retention
- GET /api/stats/history endpoint for dashboard chart
## Backend Changes
- Add direct account manipulation helpers (bypass AccountManager)
- Add POST /api/config/password endpoint for WebUI password change
- Auto-reload AccountManager after account operations
- Use CSS variables in OAuth callback pages
## Other
- Update .gitignore for runtime data directory
- Add i18n keys for new UI elements (EN/zh_CN)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,9 @@ export function getAvailableAccounts(accounts, modelId = null) {
|
||||
return accounts.filter(acc => {
|
||||
if (acc.isInvalid) return false;
|
||||
|
||||
// WebUI: Skip disabled accounts
|
||||
if (acc.enabled === false) return false;
|
||||
|
||||
if (modelId && acc.modelRateLimits && acc.modelRateLimits[modelId]) {
|
||||
const limit = acc.modelRateLimits[modelId];
|
||||
if (limit.isRateLimited && limit.resetTime > Date.now()) {
|
||||
|
||||
@@ -19,6 +19,9 @@ import { clearExpiredLimits, getAvailableAccounts } from './rate-limits.js';
|
||||
function isAccountUsable(account, modelId) {
|
||||
if (!account || account.isInvalid) return false;
|
||||
|
||||
// WebUI: Skip disabled accounts
|
||||
if (account.enabled === false) return false;
|
||||
|
||||
if (modelId && account.modelRateLimits && account.modelRateLimits[modelId]) {
|
||||
const limit = account.modelRateLimits[modelId];
|
||||
if (limit.isRateLimited && limit.resetTime > Date.now()) {
|
||||
|
||||
@@ -27,6 +27,7 @@ export async function loadAccounts(configPath = ACCOUNT_CONFIG_PATH) {
|
||||
const accounts = (config.accounts || []).map(acc => ({
|
||||
...acc,
|
||||
lastUsed: acc.lastUsed || null,
|
||||
enabled: acc.enabled !== false, // Default to true if not specified
|
||||
// Reset invalid flag on startup - give accounts a fresh chance to refresh
|
||||
isInvalid: false,
|
||||
invalidReason: null,
|
||||
@@ -107,6 +108,7 @@ export async function saveAccounts(configPath, accounts, settings, activeIndex)
|
||||
accounts: accounts.map(acc => ({
|
||||
email: acc.email,
|
||||
source: acc.source,
|
||||
enabled: acc.enabled !== false, // Persist enabled state
|
||||
dbPath: acc.dbPath || null,
|
||||
refreshToken: acc.source === 'oauth' ? acc.refreshToken : undefined,
|
||||
apiKey: acc.source === 'manual' ? acc.apiKey : undefined,
|
||||
|
||||
@@ -12,6 +12,8 @@ const DEFAULT_CONFIG = {
|
||||
retryBaseMs: 1000,
|
||||
retryMaxMs: 30000,
|
||||
persistTokenCache: false,
|
||||
defaultCooldownMs: 60000, // 1 minute
|
||||
maxWaitBeforeErrorMs: 120000, // 2 minutes
|
||||
modelMapping: {}
|
||||
};
|
||||
|
||||
|
||||
175
src/modules/usage-stats.js
Normal file
175
src/modules/usage-stats.js
Normal file
@@ -0,0 +1,175 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// Persistence path
|
||||
const DATA_DIR = path.join(process.cwd(), 'data');
|
||||
const HISTORY_FILE = path.join(DATA_DIR, 'usage-history.json');
|
||||
|
||||
// In-memory storage
|
||||
// Structure: { "YYYY-MM-DDTHH:00:00.000Z": { "claude": { "model-name": count, "_subtotal": count }, "_total": count } }
|
||||
let history = {};
|
||||
let isDirty = false;
|
||||
|
||||
/**
|
||||
* Extract model family from model ID
|
||||
* @param {string} modelId - The model identifier (e.g., "claude-opus-4-5-thinking")
|
||||
* @returns {string} The family name (claude, gemini, or other)
|
||||
*/
|
||||
function getFamily(modelId) {
|
||||
const lower = (modelId || '').toLowerCase();
|
||||
if (lower.includes('claude')) return 'claude';
|
||||
if (lower.includes('gemini')) return 'gemini';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract short model name (without family prefix)
|
||||
* @param {string} modelId - The model identifier
|
||||
* @param {string} family - The model family
|
||||
* @returns {string} Short model name
|
||||
*/
|
||||
function getShortName(modelId, family) {
|
||||
if (family === 'other') return modelId;
|
||||
// Remove family prefix (e.g., "claude-opus-4-5" -> "opus-4-5")
|
||||
return modelId.replace(new RegExp(`^${family}-`, 'i'), '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure data directory exists and load history
|
||||
*/
|
||||
function load() {
|
||||
try {
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
if (fs.existsSync(HISTORY_FILE)) {
|
||||
const data = fs.readFileSync(HISTORY_FILE, 'utf8');
|
||||
history = JSON.parse(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[UsageStats] Failed to load history:', err);
|
||||
history = {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save history to disk
|
||||
*/
|
||||
function save() {
|
||||
if (!isDirty) return;
|
||||
try {
|
||||
fs.writeFileSync(HISTORY_FILE, JSON.stringify(history, null, 2));
|
||||
isDirty = false;
|
||||
} catch (err) {
|
||||
console.error('[UsageStats] Failed to save history:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune old data (keep last 30 days)
|
||||
*/
|
||||
function prune() {
|
||||
const now = new Date();
|
||||
const cutoff = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
let pruned = false;
|
||||
Object.keys(history).forEach(key => {
|
||||
if (new Date(key) < cutoff) {
|
||||
delete history[key];
|
||||
pruned = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (pruned) isDirty = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a request by model ID using hierarchical structure
|
||||
* @param {string} modelId - The specific model identifier
|
||||
*/
|
||||
function track(modelId) {
|
||||
const now = new Date();
|
||||
// Round down to nearest hour
|
||||
now.setMinutes(0, 0, 0);
|
||||
const key = now.toISOString();
|
||||
|
||||
if (!history[key]) {
|
||||
history[key] = { _total: 0 };
|
||||
}
|
||||
|
||||
const hourData = history[key];
|
||||
const family = getFamily(modelId);
|
||||
const shortName = getShortName(modelId, family);
|
||||
|
||||
// Initialize family object if needed
|
||||
if (!hourData[family]) {
|
||||
hourData[family] = { _subtotal: 0 };
|
||||
}
|
||||
|
||||
// Increment model-specific count
|
||||
hourData[family][shortName] = (hourData[family][shortName] || 0) + 1;
|
||||
|
||||
// Increment family subtotal
|
||||
hourData[family]._subtotal = (hourData[family]._subtotal || 0) + 1;
|
||||
|
||||
// Increment global total
|
||||
hourData._total = (hourData._total || 0) + 1;
|
||||
|
||||
isDirty = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup Express Middleware
|
||||
* @param {import('express').Application} app
|
||||
*/
|
||||
function setupMiddleware(app) {
|
||||
load();
|
||||
|
||||
// Auto-save every minute
|
||||
setInterval(() => {
|
||||
save();
|
||||
prune();
|
||||
}, 60 * 1000);
|
||||
|
||||
// Save on exit
|
||||
process.on('SIGINT', () => { save(); process.exit(); });
|
||||
process.on('SIGTERM', () => { save(); process.exit(); });
|
||||
|
||||
// Request interceptor
|
||||
// Track both Anthropic (/v1/messages) and OpenAI compatible (/v1/chat/completions) endpoints
|
||||
const TRACKED_PATHS = ['/v1/messages', '/v1/chat/completions'];
|
||||
|
||||
app.use((req, res, next) => {
|
||||
if (req.method === 'POST' && TRACKED_PATHS.includes(req.path)) {
|
||||
const model = req.body?.model;
|
||||
if (model) {
|
||||
track(model);
|
||||
}
|
||||
}
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup API Routes
|
||||
* @param {import('express').Application} app
|
||||
*/
|
||||
function setupRoutes(app) {
|
||||
app.get('/api/stats/history', (req, res) => {
|
||||
// Sort keys to ensure chronological order
|
||||
const sortedKeys = Object.keys(history).sort();
|
||||
const sortedData = {};
|
||||
sortedKeys.forEach(key => {
|
||||
sortedData[key] = history[key];
|
||||
});
|
||||
res.json(sortedData);
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
setupMiddleware,
|
||||
setupRoutes,
|
||||
track,
|
||||
getFamily,
|
||||
getShortName
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { sendMessage, sendMessageStream, listModels, getModelQuotas } from './cloudcode/index.js';
|
||||
import { mountWebUI } from './webui/index.js';
|
||||
import { config } from './config.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -18,6 +19,7 @@ import { REQUEST_BODY_LIMIT } from './constants.js';
|
||||
import { AccountManager } from './account-manager/index.js';
|
||||
import { formatDuration } from './utils/helpers.js';
|
||||
import { logger } from './utils/logger.js';
|
||||
import usageStats from './modules/usage-stats.js';
|
||||
|
||||
// Parse fallback flag directly from command line args to avoid circular dependency
|
||||
const args = process.argv.slice(2);
|
||||
@@ -63,6 +65,9 @@ async function ensureInitialized() {
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: REQUEST_BODY_LIMIT }));
|
||||
|
||||
// Setup usage statistics middleware
|
||||
usageStats.setupMiddleware(app);
|
||||
|
||||
// Mount WebUI (optional web interface for account management)
|
||||
mountWebUI(app, __dirname, accountManager);
|
||||
|
||||
@@ -132,11 +137,11 @@ app.get('/health', async (req, res) => {
|
||||
try {
|
||||
await ensureInitialized();
|
||||
const start = Date.now();
|
||||
|
||||
|
||||
// Get high-level status first
|
||||
const status = accountManager.getStatus();
|
||||
const allAccounts = accountManager.getAllAccounts();
|
||||
|
||||
|
||||
// Fetch quotas for each account in parallel to get detailed model info
|
||||
const accountDetails = await Promise.allSettled(
|
||||
allAccounts.map(async (account) => {
|
||||
@@ -423,6 +428,7 @@ app.get('/account-limits', async (req, res) => {
|
||||
timestamp: new Date().toLocaleString(),
|
||||
totalAccounts: allAccounts.length,
|
||||
models: sortedModels,
|
||||
modelConfig: config.modelMapping || {},
|
||||
accounts: accountLimits.map(acc => ({
|
||||
email: acc.email,
|
||||
status: acc.status,
|
||||
@@ -535,23 +541,19 @@ app.post('/v1/messages', async (req, res) => {
|
||||
await ensureInitialized();
|
||||
|
||||
|
||||
const {
|
||||
model,
|
||||
messages,
|
||||
max_tokens,
|
||||
stream,
|
||||
system,
|
||||
tools,
|
||||
tool_choice,
|
||||
thinking,
|
||||
top_p,
|
||||
top_k,
|
||||
temperature
|
||||
} = req.body;
|
||||
// Resolve model mapping if configured
|
||||
let requestedModel = model || 'claude-3-5-sonnet-20241022';
|
||||
const modelMapping = config.modelMapping || {};
|
||||
if (modelMapping[requestedModel] && modelMapping[requestedModel].mapping) {
|
||||
const targetModel = modelMapping[requestedModel].mapping;
|
||||
logger.info(`[Server] Mapping model ${requestedModel} -> ${targetModel}`);
|
||||
requestedModel = targetModel;
|
||||
}
|
||||
|
||||
const modelId = requestedModel;
|
||||
|
||||
// Optimistic Retry: If ALL accounts are rate-limited for this model, reset them to force a fresh check.
|
||||
// If we have some available accounts, we try them first.
|
||||
const modelId = model || 'claude-3-5-sonnet-20241022';
|
||||
if (accountManager.isAllRateLimited(modelId)) {
|
||||
logger.warn(`[Server] All accounts rate-limited for ${modelId}. Resetting state for optimistic retry.`);
|
||||
accountManager.resetAllRateLimits();
|
||||
@@ -570,7 +572,7 @@ app.post('/v1/messages', async (req, res) => {
|
||||
|
||||
// Build the request object
|
||||
const request = {
|
||||
model: model || 'claude-3-5-sonnet-20241022',
|
||||
model: modelId,
|
||||
messages,
|
||||
max_tokens: max_tokens || 4096,
|
||||
stream,
|
||||
@@ -676,6 +678,8 @@ app.post('/v1/messages', async (req, res) => {
|
||||
/**
|
||||
* Catch-all for unsupported endpoints
|
||||
*/
|
||||
usageStats.setupRoutes(app);
|
||||
|
||||
app.use('*', (req, res) => {
|
||||
if (logger.isDebugEnabled) {
|
||||
logger.debug(`[API] 404 Not Found: ${req.method} ${req.originalUrl}`);
|
||||
|
||||
@@ -15,14 +15,87 @@
|
||||
import path from 'path';
|
||||
import express from 'express';
|
||||
import { getPublicConfig, saveConfig, config } from '../config.js';
|
||||
import { DEFAULT_PORT } from '../constants.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 { loadAccounts, saveAccounts } from '../account-manager/storage.js';
|
||||
|
||||
// OAuth state storage (state -> { verifier, timestamp })
|
||||
const pendingOAuthStates = new Map();
|
||||
|
||||
/**
|
||||
* WebUI Helper Functions - Direct account manipulation
|
||||
* These functions work around AccountManager's limited API by directly
|
||||
* manipulating the accounts.json config file (non-invasive approach for PR)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Set account enabled/disabled state
|
||||
*/
|
||||
async function setAccountEnabled(email, enabled) {
|
||||
const { accounts, settings, activeIndex } = await loadAccounts(ACCOUNT_CONFIG_PATH);
|
||||
const account = accounts.find(a => a.email === email);
|
||||
if (!account) {
|
||||
throw new Error(`Account ${email} not found`);
|
||||
}
|
||||
account.enabled = enabled;
|
||||
await saveAccounts(ACCOUNT_CONFIG_PATH, accounts, settings, activeIndex);
|
||||
logger.info(`[WebUI] Account ${email} ${enabled ? 'enabled' : 'disabled'}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove account from config
|
||||
*/
|
||||
async function removeAccount(email) {
|
||||
const { accounts, settings, activeIndex } = await loadAccounts(ACCOUNT_CONFIG_PATH);
|
||||
const index = accounts.findIndex(a => a.email === email);
|
||||
if (index === -1) {
|
||||
throw new Error(`Account ${email} not found`);
|
||||
}
|
||||
accounts.splice(index, 1);
|
||||
// Adjust activeIndex if needed
|
||||
const newActiveIndex = activeIndex >= accounts.length ? Math.max(0, accounts.length - 1) : activeIndex;
|
||||
await saveAccounts(ACCOUNT_CONFIG_PATH, accounts, settings, newActiveIndex);
|
||||
logger.info(`[WebUI] Account ${email} removed`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add new account to config
|
||||
*/
|
||||
async function addAccount(accountData) {
|
||||
const { accounts, settings, activeIndex } = await loadAccounts(ACCOUNT_CONFIG_PATH);
|
||||
|
||||
// Check if account already exists
|
||||
const existingIndex = accounts.findIndex(a => a.email === accountData.email);
|
||||
if (existingIndex !== -1) {
|
||||
// Update existing account
|
||||
accounts[existingIndex] = {
|
||||
...accounts[existingIndex],
|
||||
...accountData,
|
||||
enabled: true,
|
||||
isInvalid: false,
|
||||
invalidReason: null,
|
||||
addedAt: accounts[existingIndex].addedAt || new Date().toISOString()
|
||||
};
|
||||
logger.info(`[WebUI] Account ${accountData.email} updated`);
|
||||
} else {
|
||||
// Add new account
|
||||
accounts.push({
|
||||
...accountData,
|
||||
enabled: true,
|
||||
isInvalid: false,
|
||||
invalidReason: null,
|
||||
modelRateLimits: {},
|
||||
lastUsed: null,
|
||||
addedAt: new Date().toISOString()
|
||||
});
|
||||
logger.info(`[WebUI] Account ${accountData.email} added`);
|
||||
}
|
||||
|
||||
await saveAccounts(ACCOUNT_CONFIG_PATH, accounts, settings, activeIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Auth Middleware - Optional password protection for WebUI
|
||||
* Password can be set via WEBUI_PASSWORD env var or config.json
|
||||
@@ -114,7 +187,11 @@ export function mountWebUI(app, dirname, accountManager) {
|
||||
return res.status(400).json({ status: 'error', error: 'enabled must be a boolean' });
|
||||
}
|
||||
|
||||
accountManager.setAccountEnabled(email, enabled);
|
||||
await setAccountEnabled(email, enabled);
|
||||
|
||||
// Reload AccountManager to pick up changes
|
||||
await accountManager.initialize();
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
message: `Account ${email} ${enabled ? 'enabled' : 'disabled'}`
|
||||
@@ -130,7 +207,11 @@ export function mountWebUI(app, dirname, accountManager) {
|
||||
app.delete('/api/accounts/:email', async (req, res) => {
|
||||
try {
|
||||
const { email } = req.params;
|
||||
accountManager.removeAccount(email);
|
||||
await removeAccount(email);
|
||||
|
||||
// Reload AccountManager to pick up changes
|
||||
await accountManager.initialize();
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
message: `Account ${email} removed`
|
||||
@@ -145,7 +226,9 @@ export function mountWebUI(app, dirname, accountManager) {
|
||||
*/
|
||||
app.post('/api/accounts/reload', async (req, res) => {
|
||||
try {
|
||||
await accountManager.reloadAccounts();
|
||||
// Reload AccountManager from disk
|
||||
await accountManager.initialize();
|
||||
|
||||
const status = accountManager.getStatus();
|
||||
res.json({
|
||||
status: 'ok',
|
||||
@@ -183,7 +266,7 @@ export function mountWebUI(app, dirname, accountManager) {
|
||||
*/
|
||||
app.post('/api/config', (req, res) => {
|
||||
try {
|
||||
const { debug, logLevel, maxRetries, retryBaseMs, retryMaxMs, persistTokenCache } = req.body;
|
||||
const { debug, logLevel, maxRetries, retryBaseMs, retryMaxMs, persistTokenCache, defaultCooldownMs, maxWaitBeforeErrorMs } = req.body;
|
||||
|
||||
// Only allow updating specific fields (security)
|
||||
const updates = {};
|
||||
@@ -203,6 +286,12 @@ export function mountWebUI(app, dirname, accountManager) {
|
||||
if (typeof persistTokenCache === 'boolean') {
|
||||
updates.persistTokenCache = persistTokenCache;
|
||||
}
|
||||
if (typeof defaultCooldownMs === 'number' && defaultCooldownMs >= 1000 && defaultCooldownMs <= 300000) {
|
||||
updates.defaultCooldownMs = defaultCooldownMs;
|
||||
}
|
||||
if (typeof maxWaitBeforeErrorMs === 'number' && maxWaitBeforeErrorMs >= 0 && maxWaitBeforeErrorMs <= 600000) {
|
||||
updates.maxWaitBeforeErrorMs = maxWaitBeforeErrorMs;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return res.status(400).json({
|
||||
@@ -232,6 +321,48 @@ export function mountWebUI(app, dirname, accountManager) {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/config/password - Change WebUI password
|
||||
*/
|
||||
app.post('/api/config/password', (req, res) => {
|
||||
try {
|
||||
const { oldPassword, newPassword } = req.body;
|
||||
|
||||
// Validate input
|
||||
if (!newPassword || typeof newPassword !== 'string') {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
error: 'New password is required'
|
||||
});
|
||||
}
|
||||
|
||||
// If current password exists, verify old password
|
||||
if (config.webuiPassword && config.webuiPassword !== oldPassword) {
|
||||
return res.status(403).json({
|
||||
status: 'error',
|
||||
error: 'Invalid current password'
|
||||
});
|
||||
}
|
||||
|
||||
// Save new password
|
||||
const success = saveConfig({ webuiPassword: newPassword });
|
||||
|
||||
if (success) {
|
||||
// Update in-memory config
|
||||
config.webuiPassword = newPassword;
|
||||
res.json({
|
||||
status: 'ok',
|
||||
message: 'Password changed successfully'
|
||||
});
|
||||
} else {
|
||||
throw new Error('Failed to save password to config file');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[WebUI] Error changing password:', error);
|
||||
res.status(500).json({ status: 'error', error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/settings - Get runtime settings
|
||||
*/
|
||||
@@ -427,24 +558,28 @@ export function mountWebUI(app, dirname, accountManager) {
|
||||
const accountData = await completeOAuthFlow(code, storedState.verifier);
|
||||
|
||||
// Add or update the account
|
||||
accountManager.addAccount({
|
||||
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: system-ui, -apple-system, sans-serif;
|
||||
background: #09090b;
|
||||
color: #e4e4e7;
|
||||
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;
|
||||
@@ -452,7 +587,7 @@ export function mountWebUI(app, dirname, accountManager) {
|
||||
margin: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
h1 { color: #22c55e; }
|
||||
h1 { color: var(--color-neon-green); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -479,14 +614,16 @@ export function mountWebUI(app, dirname, accountManager) {
|
||||
<html>
|
||||
<head>
|
||||
<title>Authentication Failed</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: #09090b;
|
||||
color: #ef4444;
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user