feat(config): add configurable max accounts limit (#156)

Adds `maxAccounts` configuration parameter to control the maximum number of Google accounts.

**Changes:**
- New config field `maxAccounts` (default: 10, range: 1-100)
- Settings page: slider control for adjusting limit
- Accounts page: counter badge (e.g., "8/10") with visual feedback
- Add button disabled when limit reached
- Server-side validation on account creation

**Breaking Changes:** None
This commit is contained in:
jgor20
2026-01-20 20:34:57 +00:00
committed by GitHub
parent 11f135ef32
commit e51e3ff56a
10 changed files with 76 additions and 4 deletions

View File

@@ -362,6 +362,7 @@ While most users can use the default settings, you can tune the proxy behavior v
- **Retry Logic**: Configure `maxRetries`, `retryBaseMs`, and `retryMaxMs`. - **Retry Logic**: Configure `maxRetries`, `retryBaseMs`, and `retryMaxMs`.
- **Load Balancing**: Adjust `defaultCooldownMs` and `maxWaitBeforeErrorMs`. - **Load Balancing**: Adjust `defaultCooldownMs` and `maxWaitBeforeErrorMs`.
- **Persistence**: Enable `persistTokenCache` to save OAuth sessions across restarts. - **Persistence**: Enable `persistTokenCache` to save OAuth sessions across restarts.
- **Max Accounts**: Set `maxAccounts` (1-100) to limit the number of Google accounts. Default: 10.
Refer to `config.example.json` for a complete list of fields and documentation. Refer to `config.example.json` for a complete list of fields and documentation.

View File

@@ -32,7 +32,9 @@
"persistTokenCache": false, "persistTokenCache": false,
"requestTimeoutMs": 300000, "requestTimeoutMs": 300000,
"maxAccounts": 10, "maxAccounts": 10,
"_maxAccounts_comment": "Maximum number of Google accounts allowed (1-100). Default: 10.",
"_profiles": { "_profiles": {
"development": { "development": {

2
public/css/style.css generated

File diff suppressed because one or more lines are too long

View File

@@ -250,6 +250,12 @@ window.Components.serverConfig = () => ({
(v) => window.Validators.validateTimeout(v, MAX_WAIT_MIN, MAX_WAIT_MAX)); (v) => window.Validators.validateTimeout(v, MAX_WAIT_MIN, MAX_WAIT_MAX));
}, },
toggleMaxAccounts(value) {
const { MAX_ACCOUNTS_MIN, MAX_ACCOUNTS_MAX } = window.AppConstants.VALIDATION;
this.saveConfigField('maxAccounts', value, 'Max Accounts',
(v) => window.Validators.validateRange(v, MAX_ACCOUNTS_MIN, MAX_ACCOUNTS_MAX, 'Max Accounts'));
},
toggleRateLimitDedupWindowMs(value) { toggleRateLimitDedupWindowMs(value) {
const { RATE_LIMIT_DEDUP_MIN, RATE_LIMIT_DEDUP_MAX } = window.AppConstants.VALIDATION; const { RATE_LIMIT_DEDUP_MIN, RATE_LIMIT_DEDUP_MAX } = window.AppConstants.VALIDATION;
this.saveConfigField('rateLimitDedupWindowMs', value, 'Rate Limit Dedup Window', this.saveConfigField('rateLimitDedupWindowMs', value, 'Rate Limit Dedup Window',

View File

@@ -69,6 +69,10 @@ window.AppConstants.VALIDATION = {
MAX_WAIT_MIN: 60000, MAX_WAIT_MIN: 60000,
MAX_WAIT_MAX: 1800000, MAX_WAIT_MAX: 1800000,
// Max accounts range (1 - 100)
MAX_ACCOUNTS_MIN: 1,
MAX_ACCOUNTS_MAX: 100,
// Rate limit dedup window (1 - 30 seconds) // Rate limit dedup window (1 - 30 seconds)
RATE_LIMIT_DEDUP_MIN: 1000, RATE_LIMIT_DEDUP_MIN: 1000,
RATE_LIMIT_DEDUP_MAX: 30000, RATE_LIMIT_DEDUP_MAX: 30000,

View File

@@ -13,6 +13,7 @@ document.addEventListener('alpine:init', () => {
modelConfig: {}, // Model metadata (hidden, pinned, alias) modelConfig: {}, // Model metadata (hidden, pinned, alias)
quotaRows: [], // Filtered view quotaRows: [], // Filtered view
usageHistory: {}, // Usage statistics history (from /account-limits?includeHistory=true) usageHistory: {}, // Usage statistics history (from /account-limits?includeHistory=true)
maxAccounts: 10, // Maximum number of accounts allowed (from config)
loading: false, loading: false,
initialLoad: true, // Track first load for skeleton screen initialLoad: true, // Track first load for skeleton screen
connectionStatus: 'connecting', connectionStatus: 'connecting',
@@ -135,6 +136,24 @@ document.addEventListener('alpine:init', () => {
} }
}, },
async fetchVersion(password) {
try {
const { response } = await window.utils.request('/api/config', {}, password);
if (response.ok) {
const data = await response.json();
if (data.version) {
Alpine.store('global').version = data.version;
}
// Store maxAccounts from config
if (data.config && typeof data.config.maxAccounts === 'number') {
this.maxAccounts = data.config.maxAccounts;
}
}
} catch (error) {
console.warn('Failed to fetch version:', error);
}
},
async performHealthCheck() { async performHealthCheck() {
try { try {
// Get password from global store // Get password from global store

View File

@@ -39,8 +39,17 @@
</svg> </svg>
<span x-text="$store.global.t('reload')">Reload</span> <span x-text="$store.global.t('reload')">Reload</span>
</button> </button>
<!-- Account Count Indicator -->
<div class="flex items-center h-6 px-2 rounded bg-space-800/80 border border-space-border/50"
:class="{ 'border-yellow-500/50': $store.data.accounts.length >= $store.data.maxAccounts }">
<span class="text-[11px] font-mono"
:class="$store.data.accounts.length >= $store.data.maxAccounts ? 'text-yellow-400' : 'text-gray-400'"
x-text="$store.data.accounts.length + '/' + $store.data.maxAccounts"></span>
</div>
<button class="btn bg-neon-purple hover:bg-purple-600 border-none text-white btn-xs gap-2 shadow-lg shadow-neon-purple/20 h-8" <button class="btn bg-neon-purple hover:bg-purple-600 border-none text-white btn-xs gap-2 shadow-lg shadow-neon-purple/20 h-8"
onclick="document.getElementById('add_account_modal').showModal()"> :class="{ 'opacity-50 cursor-not-allowed': $store.data.accounts.length >= $store.data.maxAccounts }"
:disabled="$store.data.accounts.length >= $store.data.maxAccounts"
@click="$store.data.accounts.length < $store.data.maxAccounts && document.getElementById('add_account_modal').showModal()">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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="M12 4v16m8-8H4" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg> </svg>

View File

@@ -934,6 +934,28 @@
</label> </label>
</div> </div>
</div> </div>
<!-- Max Accounts -->
<div class="form-control view-card border-space-border/50 hover:border-neon-cyan/50">
<label class="label pt-0">
<span class="label-text text-gray-400 text-xs">Max Accounts</span>
<span class="label-text-alt font-mono text-neon-cyan text-xs font-semibold"
x-text="serverConfig.maxAccounts || 10"></span>
</label>
<div class="flex gap-3 items-center">
<input type="range" min="1" max="100" class="custom-range custom-range-cyan flex-1"
:value="serverConfig.maxAccounts || 10"
:style="`background-size: ${((serverConfig.maxAccounts || 10) - 1) / 99 * 100}% 100%`"
@input="toggleMaxAccounts($event.target.value)"
aria-label="Max accounts slider">
<input type="number" min="1" max="100"
class="input input-xs input-bordered w-16 bg-space-800 border-space-border text-white font-mono text-center"
:value="serverConfig.maxAccounts || 10"
@change="toggleMaxAccounts($event.target.value)"
aria-label="Max accounts value">
</div>
<span class="text-[11px] text-gray-500 mt-1">Maximum number of Google accounts allowed</span>
</div>
</div> </div>
<!-- 🔀 Account Selection Strategy --> <!-- 🔀 Account Selection Strategy -->

View File

@@ -15,6 +15,7 @@ const DEFAULT_CONFIG = {
persistTokenCache: false, persistTokenCache: false,
defaultCooldownMs: 10000, // 10 seconds defaultCooldownMs: 10000, // 10 seconds
maxWaitBeforeErrorMs: 120000, // 2 minutes maxWaitBeforeErrorMs: 120000, // 2 minutes
maxAccounts: 10, // Maximum number of accounts allowed
modelMapping: {}, modelMapping: {},
// Account selection strategy configuration // Account selection strategy configuration
accountSelection: { accountSelection: {

View File

@@ -17,7 +17,7 @@ import { readFileSync } from 'fs';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import express from 'express'; import express from 'express';
import { getPublicConfig, saveConfig, config } from '../config.js'; import { getPublicConfig, saveConfig, config } from '../config.js';
import { DEFAULT_PORT, ACCOUNT_CONFIG_PATH } from '../constants.js'; import { DEFAULT_PORT, ACCOUNT_CONFIG_PATH, MAX_ACCOUNTS } from '../constants.js';
import { readClaudeConfig, updateClaudeConfig, replaceClaudeConfig, getClaudeConfigPath, readPresets, savePreset, deletePreset } from '../utils/claude-config.js'; import { readClaudeConfig, updateClaudeConfig, replaceClaudeConfig, getClaudeConfigPath, readPresets, savePreset, deletePreset } from '../utils/claude-config.js';
import { logger } from '../utils/logger.js'; import { logger } from '../utils/logger.js';
import { getAuthorizationUrl, completeOAuthFlow, startCallbackServer } from '../auth/oauth.js'; import { getAuthorizationUrl, completeOAuthFlow, startCallbackServer } from '../auth/oauth.js';
@@ -77,6 +77,7 @@ async function removeAccount(email) {
/** /**
* Add new account to config * Add new account to config
* @throws {Error} If MAX_ACCOUNTS limit is reached (for new accounts only)
*/ */
async function addAccount(accountData) { async function addAccount(accountData) {
const { accounts, settings, activeIndex } = await loadAccounts(ACCOUNT_CONFIG_PATH); const { accounts, settings, activeIndex } = await loadAccounts(ACCOUNT_CONFIG_PATH);
@@ -95,6 +96,10 @@ async function addAccount(accountData) {
}; };
logger.info(`[WebUI] Account ${accountData.email} updated`); logger.info(`[WebUI] Account ${accountData.email} updated`);
} else { } else {
// Check MAX_ACCOUNTS limit before adding new account
if (accounts.length >= MAX_ACCOUNTS) {
throw new Error(`Maximum of ${MAX_ACCOUNTS} accounts reached. Update maxAccounts in config to increase the limit.`);
}
// Add new account // Add new account
accounts.push({ accounts.push({
...accountData, ...accountData,
@@ -282,7 +287,7 @@ export function mountWebUI(app, dirname, accountManager) {
*/ */
app.post('/api/config', (req, res) => { app.post('/api/config', (req, res) => {
try { try {
const { debug, logLevel, maxRetries, retryBaseMs, retryMaxMs, persistTokenCache, defaultCooldownMs, maxWaitBeforeErrorMs, accountSelection } = req.body; const { debug, logLevel, maxRetries, retryBaseMs, retryMaxMs, persistTokenCache, defaultCooldownMs, maxWaitBeforeErrorMs, maxAccounts, accountSelection } = req.body;
// Only allow updating specific fields (security) // Only allow updating specific fields (security)
const updates = {}; const updates = {};
@@ -308,6 +313,9 @@ export function mountWebUI(app, dirname, accountManager) {
if (typeof maxWaitBeforeErrorMs === 'number' && maxWaitBeforeErrorMs >= 0 && maxWaitBeforeErrorMs <= 600000) { if (typeof maxWaitBeforeErrorMs === 'number' && maxWaitBeforeErrorMs >= 0 && maxWaitBeforeErrorMs <= 600000) {
updates.maxWaitBeforeErrorMs = maxWaitBeforeErrorMs; updates.maxWaitBeforeErrorMs = maxWaitBeforeErrorMs;
} }
if (typeof maxAccounts === 'number' && maxAccounts >= 1 && maxAccounts <= 100) {
updates.maxAccounts = maxAccounts;
}
// Account selection strategy validation // Account selection strategy validation
if (accountSelection && typeof accountSelection === 'object') { if (accountSelection && typeof accountSelection === 'object') {
const validStrategies = ['sticky', 'round-robin', 'hybrid']; const validStrategies = ['sticky', 'round-robin', 'hybrid'];