diff --git a/src/dashboard/api.js b/src/dashboard/api.js
index 6fc65bc..668cf17 100644
--- a/src/dashboard/api.js
+++ b/src/dashboard/api.js
@@ -123,7 +123,9 @@ async function processWindsurfLogin({ email, password, loginProxy, autoAdd }) {
/**
* Handle all /dashboard/api/* requests.
*/
-export async function handleDashboardApi(method, subpath, body, req, res) {
+export async function handleDashboardApi(method, subpath, body, req, res, deps = {}) {
+ const ensureLsForAccountFn = deps.ensureLsForAccount || ensureLsForAccount;
+ const probeAccountFn = deps.probeAccount || probeAccount;
if (method === 'OPTIONS') return json(res, 204, '');
// Auth check (except for auth verification endpoint)
@@ -288,16 +290,19 @@ export async function handleDashboardApi(method, subpath, body, req, res) {
if (subpath === '/accounts' && method === 'POST') {
try {
+ // Validate required credentials before any proxy validation/network checks.
if (!body.api_key && !body.token) {
return json(res, 400, { error: 'Provide api_key or token' });
}
+ // 1. Validate proxy first (if provided) - fail early before touching account store
let parsedProxy = null;
if (body.proxy) {
parsedProxy = parseProxyUrl(body.proxy);
if (!parsedProxy) {
return json(res, 400, { error: 'ERR_PROXY_FORMAT_INVALID' });
}
+ // Validate proxy host (respect ALLOW_PRIVATE_PROXY_HOSTS config)
try {
if (config.allowPrivateProxyHosts) {
await validateHostFormat(parsedProxy.host);
@@ -309,10 +314,12 @@ export async function handleDashboardApi(method, subpath, body, req, res) {
}
}
+ // 2. Create account only after proxy validation passes
const account = body.api_key
? addAccountByKey(body.api_key, body.label)
: await addAccountByToken(body.token, body.label);
+ // 3. Bind proxy to the newly created account
if (parsedProxy) {
setAccountProxy(account.id, parsedProxy);
ensureLsForAccount(account.id).catch(e => log.warn(`LS ensure failed: ${e.message}`));
diff --git a/src/dashboard/index.html b/src/dashboard/index.html
index 6fecdd6..31599c1 100644
--- a/src/dashboard/index.html
+++ b/src/dashboard/index.html
@@ -2350,6 +2350,12 @@
控制台登录
I18n.apply();
},
+ translateError(errorCode, fallbackKey = 'error.unknown') {
+ const errKey = errorCode ? `error.${errorCode}` : '';
+ if (errKey && I18n.t(errKey) !== errKey) return I18n.t(errKey);
+ return errorCode || I18n.t(fallbackKey);
+ },
+
async toggleLang() {
const newLang = I18n.locale === 'zh' || I18n.locale === 'zh-CN' ? 'en' : 'zh';
await I18n.setLocale(newLang);
@@ -2854,10 +2860,10 @@ 控制台登录
input.value = '';
this.loadAccounts();
} else {
- this.toast(r.error || I18n.t('toast.addFailed'), 'error');
+ this.toast(this.translateError(r.error, 'toast.addFailed'), 'error');
}
} catch (err) {
- this.toast(err.message, 'error');
+ this.toast(this.translateError(err.message, 'error.unknown'), 'error');
} finally {
if (btn) btn.disabled = originalDisabled || false;
}
@@ -3433,7 +3439,7 @@ 控制台登录
this.toast(I18n.t('toast.credits.refreshed'));
this.loadAccounts();
} else {
- this.toast(r.error || I18n.t('toast.credits.refreshFailed'), 'error');
+ this.toast(this.translateError(r.error, 'toast.credits.refreshFailed'), 'error');
}
} catch (e) { this.toast(I18n.t('toast.credits.refreshFailed') + ': ' + e.message, 'error'); }
},
@@ -3448,7 +3454,7 @@ 控制台登录
this.toast(I18n.t('toast.credits.allRefreshed', { ok: okCount, fail: failCount }));
this.loadAccounts();
} else {
- this.toast(r.error || I18n.t('toast.credits.refreshFailed'), 'error');
+ this.toast(this.translateError(r.error, 'toast.credits.refreshFailed'), 'error');
}
} catch (e) { this.toast(I18n.t('toast.credits.refreshFailed') + ': ' + e.message, 'error'); }
},
@@ -3463,7 +3469,7 @@ 控制台登录
this.toast(I18n.t('toast.probeAllComplete', { summary }));
this.loadAccounts();
} else {
- this.toast(r.error || I18n.t('error.probeFailed'), 'error');
+ this.toast(this.translateError(r.error, 'error.probeFailed'), 'error');
}
} catch (e) { this.toast(I18n.t('error.probeFailed') + ': ' + e.message, 'error'); }
},
@@ -3477,7 +3483,7 @@ 控制台登录
this.toast(I18n.t('toast.probeComplete', { tier: tierLabel[r.tier] || r.tier }));
this.loadAccounts();
} else {
- this.toast(r.error || I18n.t('error.probeFailed'), 'error');
+ this.toast(this.translateError(r.error, 'error.probeFailed'), 'error');
}
} catch (e) { this.toast(I18n.t('error.probeFailed') + ': ' + e.message, 'error'); }
},
@@ -3603,7 +3609,7 @@ 控制台登录
root.remove();
self.loadAccounts();
} else {
- self.toast(r.error || I18n.t('error.saveFailed'), 'error');
+ self.toast(self.translateError(r.error, 'error.saveFailed'), 'error');
okBtn.disabled = false; cancelBtn.disabled = false; okBtn.textContent = I18n.t('action.save');
}
} catch (e) {
@@ -3638,7 +3644,7 @@ 控制台登录
const body = type === 'api_key' ? { api_key: key, label, proxy } : { token: key, label, proxy };
const r = await this.api('POST', '/accounts', body);
if (r.success) { this.toast(I18n.t('account.addSuccess')); document.getElementById('acc-key').value = ''; document.getElementById('acc-label').value = ''; document.getElementById('acc-proxy').value = ''; this.loadAccounts(); }
- else this.toast(r.error || I18n.t('toast.addFailed'), 'error');
+ else this.toast(this.translateError(r.error, 'toast.addFailed'), 'error');
},
async discoverLocalWindsurf() {
diff --git a/test/account-add-proxy.test.js b/test/account-add-proxy.test.js
new file mode 100644
index 0000000..319f552
--- /dev/null
+++ b/test/account-add-proxy.test.js
@@ -0,0 +1,107 @@
+import { afterEach, describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { config } from '../src/config.js';
+import { configureBindHost, getAccountList, removeAccount } from '../src/auth.js';
+import { handleDashboardApi } from '../src/dashboard/api.js';
+import { getProxyConfig, removeProxy } from '../src/dashboard/proxy-config.js';
+
+const originalAllowPrivateProxyHosts = config.allowPrivateProxyHosts;
+const createdAccountIds = [];
+const testDeps = {
+ ensureLsForAccount: async () => {},
+ probeAccount: async () => ({}),
+};
+
+function fakeRes() {
+ return {
+ statusCode: 0,
+ body: '',
+ writeHead(status) { this.statusCode = status; },
+ end(chunk) { this.body += chunk ? String(chunk) : ''; },
+ json() { return this.body ? JSON.parse(this.body) : null; },
+ };
+}
+
+function accountPayload(extra = {}) {
+ return {
+ api_key: `test-key-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
+ label: 'proxy-test',
+ ...extra,
+ };
+}
+
+afterEach(() => {
+ config.allowPrivateProxyHosts = originalAllowPrivateProxyHosts;
+ configureBindHost('0.0.0.0');
+ while (createdAccountIds.length) {
+ const id = createdAccountIds.pop();
+ removeProxy('account', id);
+ removeAccount(id);
+ }
+});
+
+describe('dashboard account add with proxy validation', () => {
+ it('rejects invalid proxy format before creating an account', async () => {
+ configureBindHost('127.0.0.1');
+ const before = getAccountList().length;
+
+ const res = fakeRes();
+ await handleDashboardApi('POST', '/accounts', accountPayload({ proxy: 'invalid-proxy-url' }), { headers: {} }, res, testDeps);
+
+ assert.equal(res.statusCode, 400);
+ assert.equal(res.json().error, 'ERR_PROXY_FORMAT_INVALID');
+ assert.equal(getAccountList().length, before);
+ });
+
+ it('rejects private proxy hosts by default before creating an account', async () => {
+ configureBindHost('127.0.0.1');
+ config.allowPrivateProxyHosts = false;
+ const before = getAccountList().length;
+
+ const res = fakeRes();
+ await handleDashboardApi('POST', '/accounts', accountPayload({ proxy: 'http://127.0.0.1:8080' }), { headers: {} }, res, testDeps);
+
+ assert.equal(res.statusCode, 400);
+ assert.match(String(res.json().error || ''), /ERR_PROXY_PRIVATE_IP|ERR_PROXY_PRIVATE_HOST/);
+ assert.equal(getAccountList().length, before);
+ });
+
+ it('creates account and binds proxy when proxy is valid', async () => {
+ configureBindHost('127.0.0.1');
+ config.allowPrivateProxyHosts = true;
+ const before = getAccountList().length;
+
+ const res = fakeRes();
+ await handleDashboardApi('POST', '/accounts', accountPayload({ proxy: 'http://127.0.0.1:8080' }), { headers: {} }, res, testDeps);
+
+ assert.equal(res.statusCode, 200);
+ assert.equal(res.json().success, true);
+ const accountId = res.json().account?.id;
+ assert.ok(accountId);
+ createdAccountIds.push(accountId);
+ assert.equal(getAccountList().length, before + 1);
+ assert.deepEqual(getProxyConfig().perAccount[accountId], {
+ type: 'http',
+ host: '127.0.0.1',
+ port: 8080,
+ username: '',
+ password: '',
+ });
+ });
+
+ it('creates account without proxy binding when proxy is omitted', async () => {
+ configureBindHost('127.0.0.1');
+ const before = getAccountList().length;
+
+ const res = fakeRes();
+ await handleDashboardApi('POST', '/accounts', accountPayload(), { headers: {} }, res, testDeps);
+
+ assert.equal(res.statusCode, 200);
+ assert.equal(res.json().success, true);
+ const accountId = res.json().account?.id;
+ assert.ok(accountId);
+ createdAccountIds.push(accountId);
+ assert.equal(getAccountList().length, before + 1);
+ assert.equal(getProxyConfig().perAccount[accountId], undefined);
+ });
+});