Skip to content

Commit f7bdf76

Browse files
h0tak88rclaude
andcommitted
feat(settings): multi-account manager in Platforms & Keys tab
Surface the existing multi-account backend (bbp_accounts + accounts.For + /api/accounts CRUD) directly in Settings, where users look for credentials. New 'Multiple Accounts' panel at the top of the Platforms & Keys tab lists every stored credential per platform (HackerOne/Bugcrowd/Intigriti/YesWeHack) with enable/disable + delete and an inline add form using each platform's auth fields. Every enabled account is queried when fetching programs/scope and the results are merged — one dashboard pulls from all accounts. The single-field section below still sets the env credential (used as an extra 'env' account). UI-only; reuses the endpoints already powering the Targets-page manager. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d469a32 commit f7bdf76

2 files changed

Lines changed: 194 additions & 0 deletions

File tree

internal/api/ui/pages/settings.js

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,21 @@
143143
</div>
144144
</div>
145145
146+
<div class="settings-section" data-tab="platforms">
147+
<div class="settings-section-header"> Multiple Accounts</div>
148+
<div class="settings-section-description">
149+
Add more than one credential per platform to pull programs, domains and scope from
150+
<strong>all</strong> of your accounts at once. Every enabled account is queried and the
151+
results are merged (deduplicated). The single credential in the section below is used
152+
automatically as an extra <code>env</code> account.
153+
</div>
154+
<div class="settings-section-body">
155+
<div id="settings-accounts-manager" style="padding:8px 24px 16px;">
156+
<div style="color:var(--text-muted);font-size:13px;">Loading accounts…</div>
157+
</div>
158+
</div>
159+
</div>
160+
146161
<div class="settings-section" data-tab="platforms">
147162
<div class="settings-section-header"> Bug Bounty Platform API Keys</div>
148163
<div class="settings-section-description">
@@ -268,6 +283,104 @@
268283

269284
// Restore last-active tab (default: Platforms & Keys — the most-used surface).
270285
settingsTab(window.state._settingsTab || 'platforms');
286+
// Populate the multi-account manager (async — fills the placeholder in-place).
287+
loadSettingsAccounts();
288+
}
289+
290+
// ── Multi-account manager (Platforms & Keys tab) ──────────────────────────
291+
// Lets the user store several credentials per platform. Every enabled account
292+
// is queried when fetching programs/scope (see accounts.For on the server) and
293+
// the results are merged — so one dashboard pulls from all your accounts.
294+
const ACCT_PLATFORMS = [
295+
{ id: 'h1', name: 'HackerOne', fields: ['username', 'token'] },
296+
{ id: 'bc', name: 'Bugcrowd', fields: ['token'] },
297+
{ id: 'it', name: 'Intigriti', fields: ['token'] },
298+
{ id: 'ywh', name: 'YesWeHack', fields: ['token', 'email', 'password'] },
299+
];
300+
301+
async function loadSettingsAccounts() {
302+
const host = document.getElementById('settings-accounts-manager');
303+
if (!host) return;
304+
let accts = [];
305+
try {
306+
const data = await window.apiFetch('/api/accounts'); // "" platform = all
307+
accts = data.accounts || [];
308+
} catch (e) {
309+
host.innerHTML = `<div style="color:var(--accent-amber);font-size:13px;">Failed to load accounts: ${escValue(e.message || String(e))}</div>`;
310+
return;
311+
}
312+
renderSettingsAccounts(host, accts);
313+
}
314+
315+
function renderSettingsAccounts(host, accts) {
316+
const byPlatform = {};
317+
for (const a of accts) (byPlatform[a.platform] = byPlatform[a.platform] || []).push(a);
318+
319+
host.innerHTML = ACCT_PLATFORMS.map((p) => {
320+
const list = byPlatform[p.id] || [];
321+
const rows = list.length
322+
? list.map((a) => `
323+
<div class="acct-row">
324+
<div class="acct-meta">
325+
<div class="acct-label">${escValue(a.label)}${a.enabled ? '' : ' <span class="acct-disabled">(disabled)</span>'}</div>
326+
<div class="acct-sub">${a.username ? escValue(a.username) + ' · ' : ''}${a.token_set ? 'token ' + escValue(a.token_mask || '••••') : 'no token'}</div>
327+
</div>
328+
<button class="acct-toggle ${a.enabled ? 'on' : 'off'}" onclick="window.SettingsPage.toggleAccount(${a.id}, ${a.enabled ? 'false' : 'true'})">${a.enabled ? 'On' : 'Off'}</button>
329+
<button class="acct-del" title="Delete" onclick="window.SettingsPage.deleteAccount(${a.id})">✕</button>
330+
</div>`).join('')
331+
: `<div class="acct-empty">No extra accounts yet — add one below.</div>`;
332+
const addFields = p.fields.map((f) => {
333+
const isSecret = f === 'password' || f === 'token';
334+
const ph = f.charAt(0).toUpperCase() + f.slice(1);
335+
return `<input id="acct-${p.id}-${f}" type="${isSecret ? 'password' : 'text'}" placeholder="${ph}" class="form-control premium-input acct-input">`;
336+
}).join('');
337+
return `
338+
<div class="acct-platform">
339+
<div class="acct-platform-head">${escValue(p.name)}${list.length ? ` <span class="acct-count">${list.length}</span>` : ''}</div>
340+
<div class="acct-list">${rows}</div>
341+
<div class="acct-add">
342+
<input id="acct-${p.id}-label" type="text" placeholder="Label (e.g. main, alt)" class="form-control premium-input acct-input">
343+
${addFields}
344+
<button class="btn btn-primary acct-add-btn" onclick="window.SettingsPage.addAccount('${p.id}')">+ Add</button>
345+
</div>
346+
</div>`;
347+
}).join('');
348+
}
349+
350+
async function addAccount(platformId) {
351+
const p = ACCT_PLATFORMS.find((x) => x.id === platformId);
352+
if (!p) return;
353+
const label = (document.getElementById(`acct-${platformId}-label`)?.value || '').trim();
354+
if (!label) { window.showToast('warning', 'Label required', 'Give the account a label (e.g. main, alt).'); return; }
355+
const body = { platform: platformId, label, enabled: true };
356+
for (const f of p.fields) body[f] = (document.getElementById(`acct-${platformId}-${f}`)?.value || '').trim();
357+
try {
358+
await window.apiPost('/api/accounts', body);
359+
window.showToast('success', 'Account added', `${label} saved — programs will refresh in the background.`);
360+
await loadSettingsAccounts();
361+
} catch (e) {
362+
window.showToast('error', 'Add failed', e.message);
363+
}
364+
}
365+
366+
async function toggleAccount(id, enabled) {
367+
try {
368+
await window.apiPost(`/api/accounts/${id}/toggle`, { enabled });
369+
await loadSettingsAccounts();
370+
} catch (e) {
371+
window.showToast('error', 'Toggle failed', e.message);
372+
}
373+
}
374+
375+
async function deleteAccount(id) {
376+
if (!window.confirm('Delete this account?')) return;
377+
try {
378+
await window.apiDelete(`/api/accounts/${id}`);
379+
window.showToast('success', 'Deleted', 'Account removed.');
380+
await loadSettingsAccounts();
381+
} catch (e) {
382+
window.showToast('error', 'Delete failed', e.message);
383+
}
271384
}
272385

273386
// Show only the sections belonging to the chosen tab; highlight the active pill.
@@ -523,6 +636,10 @@
523636
loadConfig,
524637
renderSettings,
525638
settingsTab,
639+
loadSettingsAccounts,
640+
addAccount,
641+
toggleAccount,
642+
deleteAccount,
526643
saveOpenRouterKey,
527644
saveOpenCodeKey,
528645
saveOpenCodeModel,

internal/api/ui/styles.css

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3934,3 +3934,80 @@ select.input {
39343934
.settings-control { width: 100%; }
39353935
.settings-item { flex-direction: column; gap: 12px; }
39363936
}
3937+
3938+
/* ── Multi-account manager (Settings → Platforms & Keys) ─────────────── */
3939+
.acct-platform {
3940+
padding: 14px 0;
3941+
border-bottom: 1px solid var(--border);
3942+
}
3943+
.acct-platform:last-child { border-bottom: none; }
3944+
3945+
.acct-platform-head {
3946+
font-size: 13px;
3947+
font-weight: 700;
3948+
color: var(--text-primary);
3949+
margin-bottom: 10px;
3950+
display: flex;
3951+
align-items: center;
3952+
gap: 8px;
3953+
}
3954+
.acct-count {
3955+
font-size: 11px;
3956+
font-weight: 700;
3957+
color: var(--accent-cyan);
3958+
background: var(--accent-cyan-dim, rgba(34, 211, 238, 0.15));
3959+
border-radius: 999px;
3960+
padding: 1px 8px;
3961+
}
3962+
3963+
.acct-row {
3964+
display: flex;
3965+
align-items: center;
3966+
gap: 10px;
3967+
padding: 8px 11px;
3968+
border: 1px solid var(--border);
3969+
border-radius: 9px;
3970+
margin-bottom: 8px;
3971+
background: rgba(0, 0, 0, 0.22);
3972+
}
3973+
.acct-meta { flex: 1; min-width: 0; }
3974+
.acct-label { font-size: 13px; font-weight: 600; color: var(--text-primary); }
3975+
.acct-disabled { color: var(--accent-amber); font-size: 11px; font-weight: 500; }
3976+
.acct-sub {
3977+
font-size: 11px;
3978+
color: var(--text-muted);
3979+
font-family: 'JetBrains Mono', monospace;
3980+
overflow: hidden;
3981+
text-overflow: ellipsis;
3982+
white-space: nowrap;
3983+
}
3984+
.acct-toggle {
3985+
padding: 5px 12px;
3986+
border-radius: 7px;
3987+
border: 1px solid var(--border);
3988+
background: transparent;
3989+
font-size: 11px;
3990+
font-weight: 600;
3991+
cursor: pointer;
3992+
}
3993+
.acct-toggle.on { color: var(--accent-emerald); border-color: rgba(16, 185, 129, 0.4); }
3994+
.acct-toggle.off { color: var(--accent-amber); border-color: rgba(245, 158, 11, 0.4); }
3995+
.acct-del {
3996+
padding: 5px 10px;
3997+
border-radius: 7px;
3998+
border: 1px solid rgba(239, 68, 68, 0.4);
3999+
background: transparent;
4000+
color: #ef4444;
4001+
font-size: 11px;
4002+
cursor: pointer;
4003+
}
4004+
.acct-empty { font-size: 12px; color: var(--text-muted); padding: 2px 0 8px; }
4005+
4006+
.acct-add {
4007+
display: flex;
4008+
flex-wrap: wrap;
4009+
gap: 8px;
4010+
margin-top: 4px;
4011+
}
4012+
.acct-input { flex: 1; min-width: 130px; }
4013+
.acct-add-btn { flex-shrink: 0; }

0 commit comments

Comments
 (0)