Skip to content

Commit 22c5d70

Browse files
committed
Keep pending admin key separate from current auth
1 parent c1c7f08 commit 22c5d70

2 files changed

Lines changed: 13 additions & 8 deletions

File tree

src/dashboard.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -46,17 +46,18 @@ export function dashboardHtml(initialConfig: unknown = null): string {
4646
<script>
4747
const policies=[['daily_burn_priority','잔액/남은일수 우선','만료 전 써야 할 크레딧을 먼저 태움'],['balance_priority','잔액 많은 순','현재 잔액이 큰 key 우선'],['round_robin','순환 분산','요청마다 key를 순서대로 선택'],['drain_first','앞 key 소진','1번 key부터 다 쓰고 다음 key로 이동']];
4848
const initialConfig=${scriptJson(initialConfig)};
49-
let cfg=initialConfig, dirty=false;
49+
let cfg=initialConfig, dirty=false, pendingBridgeKey=authKey(localStorage.getItem('pendingBridgeApiKey'));
5050
const $=id=>document.getElementById(id); const esc=v=>String(v??'').replace(/[&<>"']/g,ch=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch])); const toast=t=>{ $('toast').textContent=t; $('toast').classList.add('show'); setTimeout(()=>$('toast').classList.remove('show'),2500); };
5151
const expiredIds=new Set();
5252
function isRedactedSecret(value){const v=String(value||'').trim(); return !v||v==='[REDACTED]'||v==='sk-[REDACTED]'||v.includes('…')||v.includes('...');}
5353
function fullBridgeKey(){const raw=($('bridgeApiKey')?.value||'').trim(); if(!raw||isRedactedSecret(raw))return ''; return raw.startsWith('sk-')?raw:'sk-'+raw;}
5454
function displayBridgeKey(key){return isRedactedSecret(key)?'':(key?.startsWith('sk-')?key.slice(3):(key||''))}
5555
function authKey(value){return isRedactedSecret(value)?'':String(value||'').trim();}
56-
function auth(){const key=authKey(cfg?.bridgeApiKey)||authKey(localStorage.getItem('bridgeApiKey'))||fullBridgeKey()||''; return key?{'authorization':'Bearer '+key,'content-type':'application/json'}:{'content-type':'application/json'}}
56+
function currentBridgeAuthKey(){return authKey(cfg?.bridgeApiKey)||authKey(localStorage.getItem('bridgeApiKey'))||'';}
57+
function auth(){const key=currentBridgeAuthKey()||(!pendingBridgeKey?fullBridgeKey():'')||''; return key?{'authorization':'Bearer '+key,'content-type':'application/json'}:{'content-type':'application/json'}}
5758
function duplicateCredentialMessage(e){const text=String(e?.message||e||''); return text.includes('duplicate_commandcode_api_key')||text.includes('Duplicate CommandCode API key')?'이미 등록된 키입니다. 다른 CommandCode API key를 입력해주세요.':null;}
5859
function preventDuplicateVisibleKey(input){const value=input.value.trim(); if(!value)return false; const dup=Array.from(document.querySelectorAll('[data-ckey]')).some(el=>el!==input&&el.value.trim()===value); if(!dup)return false; const i=+input.dataset.ckey; input.value=''; if(cfg?.credentials?.[i])cfg.credentials[i].apiKey=''; toast('이미 등록된 키입니다. 입력하지 않았습니다.'); return true;}
59-
function syncBridgeKey(){const el=$('bridgeApiKey'); if(!el)return; const configured=authKey(cfg?.bridgeApiKey); const stored=authKey(localStorage.getItem('bridgeApiKey')); const key=configured||stored; if(configured&&stored!==configured)localStorage.setItem('bridgeApiKey',configured); if(document.activeElement!==el) el.value=displayBridgeKey(key); const wrap=$('bridgeKeyWrap'); if(wrap) wrap.style.display=$('bindHost')?.value==='0.0.0.0'?'grid':'none';}
60+
function syncBridgeKey(){const el=$('bridgeApiKey'); if(!el)return; const configured=authKey(cfg?.bridgeApiKey); const stored=authKey(localStorage.getItem('bridgeApiKey')); const key=pendingBridgeKey||configured||stored; if(configured&&stored!==configured)localStorage.setItem('bridgeApiKey',configured); if(document.activeElement!==el) el.value=displayBridgeKey(key); const wrap=$('bridgeKeyWrap'); if(wrap) wrap.style.display=$('bindHost')?.value==='0.0.0.0'?'grid':'none';}
6061
function setDirty(v=true){dirty=v; $('dirtyText').textContent=v?'pending changes · restart required':'no pending changes'; updateRestart();}
6162
function updateRestart(){const online=$('online').textContent==='online'; $('restart').disabled=online&&!dirty; $('restart').classList.toggle('active',!$('restart').disabled)}
6263
async function fetchJson(path,opt={}){const init={...opt,cache:'no-store',headers:{...auth(),...(opt.headers||{})}}; try{const r=await fetch(path,init); if(!r.ok) throw new Error(await r.text()); return r.json();}catch(e){if(location.hostname&&!location.port){const r=await fetch('http://'+location.hostname+':9992'+path,init); if(!r.ok) throw new Error(await r.text()); return r.json();}throw e;}}
@@ -70,10 +71,11 @@ $('creds').innerHTML=cfg.credentials.map((c,i)=>{c.originalId=c.originalId||c.id
7071
document.querySelectorAll('[data-cid]').forEach(e=>e.oninput=()=>{cfg.credentials[+e.dataset.cid].id=e.value;setDirty();}); document.querySelectorAll('[data-cenabled]').forEach(e=>e.onchange=()=>{cfg.credentials[+e.dataset.cenabled].enabled=e.checked;setDirty();}); document.querySelectorAll('[data-ckey]').forEach(e=>e.oninput=()=>{if(preventDuplicateVisibleKey(e))return; cfg.credentials[+e.dataset.ckey].apiKey=e.value;setDirty();}); document.querySelectorAll('[data-del]').forEach(e=>e.onclick=()=>{cfg.credentials.splice(+e.dataset.del,1);render();setDirty();});
7172
$('models').innerHTML=cfg.models.map((m,i)=>'<div class="model"><div class="model-head"><div><b>'+esc(m.label||m.id)+'</b><div class="small">'+esc(m.provider)+' · '+esc(m.id)+'</div></div><label class="switch"><input data-mid="'+i+'" type="checkbox" '+(m.enabled?'checked':'')+'><span class="slider"></span></label></div><div class="small">'+esc(m.notes||'')+'</div></div>').join(''); document.querySelectorAll('[data-mid]').forEach(e=>e.onchange=()=>{cfg.models[+e.dataset.mid].enabled=e.checked;setDirty();});}
7273
function randomBridgeKey(){const bytes=new Uint8Array(3); if(globalThis.crypto?.getRandomValues) crypto.getRandomValues(bytes); else for(let i=0;i<bytes.length;i++)bytes[i]=Math.floor(Math.random()*256); return 'sk-cmdbridge-'+Array.from(bytes,b=>b.toString(16).padStart(2,'0')).join('');}
73-
function generateBridgeKey(){const key=randomBridgeKey(); const el=$('bridgeApiKey'); if(el)el.value=displayBridgeKey(key); setDirty(); toast('Random Admin API key generated. Save JSON, then restart to apply.');}
74-
$('bindHost').onchange=()=>{cfg.server.host=$('bindHost').value;syncBridgeKey();setDirty();}; $('bindPort').oninput=()=>{cfg.server.port=Number($('bindPort').value)||9992;setDirty();}; function saveBridgeKey(nextKey){const key=nextKey||fullBridgeKey(); if(key)localStorage.setItem('bridgeApiKey',key); else localStorage.removeItem('bridgeApiKey'); syncBridgeKey(); toast(key?'Admin API key saved in this browser.':'Admin API key cleared.');} async function copyBridgeKey(){const key=fullBridgeKey()||authKey(localStorage.getItem('bridgeApiKey'))||''; if(!key){toast('Admin API key is empty.');return;} try{await navigator.clipboard.writeText(key);toast('Admin API key copied.');}catch{toast('Copy failed. Select and copy manually.');}} $('generateBridgeKey').onclick=generateBridgeKey; $('saveBridgeKey').onclick=()=>saveBridgeKey(); $('copyBridgeKey').onclick=copyBridgeKey; $('bridgeApiKey').onkeydown=e=>{if(e.key==='Enter')saveBridgeKey();}; $('maxPer').oninput=()=>{cfg.routing.maxInFlightPerCredential=Number($('maxPer').value)||4; setDirty();}; $('refreshCreds').onclick=async()=>{try{const m=await api('/admin/commandcode/credentials?refresh=true'); const byId=new Map((m.credentials||[]).map(x=>[x.id,x])); cfg.credentials.forEach(c=>c.metrics=byId.get(c.id)); render(); toast('Credentials refreshed.');}catch(e){toast('Credential refresh failed.');}}; $('addCred').onclick=()=>{cfg.credentials.push({id:'key'+(cfg.credentials.length+1),apiKey:'',weight:1,enabled:true});render();setDirty();};
75-
$('save').onclick=async()=>{try{if(!cfg)throw new Error('config not loaded'); const pendingBridgeKey=fullBridgeKey(); const payload={...(pendingBridgeKey?{bridgeApiKey:pendingBridgeKey}:{}),server:cfg.server,routing:cfg.routing,models:cfg.models,credentials:cfg.credentials.map(c=>({id:c.id,originalId:c.originalId,apiKey:c.apiKey||undefined,weight:c.weight||1,enabled:expiredIds.has(c.id)?undefined:c.enabled!==false,maxInFlight:c.maxInFlight,allowedModels:c.allowedModels}))}; cfg=await api('/admin/config',{method:'PUT',body:JSON.stringify(payload)}); render(); setDirty(true); toast('JSON saved. Restart required.');}catch(e){toast(duplicateCredentialMessage(e)||('Save failed: '+(e?.message||e)));}};
76-
$('restart').onclick=async()=>{const pendingBridgeKey=fullBridgeKey(); try{await api('/admin/restart',{method:'POST',body:'{}'}); if(pendingBridgeKey)localStorage.setItem('bridgeApiKey',pendingBridgeKey); toast('Restart requested'); setTimeout(load,1800);}catch(e){toast('Restart failed: '+(e?.message||e));}};
74+
function rememberPendingBridgeKey(key){pendingBridgeKey=authKey(key); if(pendingBridgeKey)localStorage.setItem('pendingBridgeApiKey',pendingBridgeKey); else localStorage.removeItem('pendingBridgeApiKey');}
75+
function generateBridgeKey(){const key=randomBridgeKey(); rememberPendingBridgeKey(key); const el=$('bridgeApiKey'); if(el)el.value=displayBridgeKey(key); setDirty(); toast('Random Admin API key generated. Save JSON, then restart to apply.');}
76+
$('bindHost').onchange=()=>{cfg.server.host=$('bindHost').value;syncBridgeKey();setDirty();}; $('bindPort').oninput=()=>{cfg.server.port=Number($('bindPort').value)||9992;setDirty();}; function saveBridgeKey(nextKey){const key=nextKey||fullBridgeKey(); const current=currentBridgeAuthKey(); if(key&&key!==current){rememberPendingBridgeKey(key); syncBridgeKey(); setDirty(); toast('Pending Admin API key saved. Save JSON, then restart to apply.'); return;} if(key)localStorage.setItem('bridgeApiKey',key); else localStorage.removeItem('bridgeApiKey'); syncBridgeKey(); toast(key?'Current Admin API key saved in this browser.':'Admin API key cleared.');} async function copyBridgeKey(){const key=fullBridgeKey()||authKey(localStorage.getItem('bridgeApiKey'))||''; if(!key){toast('Admin API key is empty.');return;} try{await navigator.clipboard.writeText(key);toast('Admin API key copied.');}catch{toast('Copy failed. Select and copy manually.');}} $('generateBridgeKey').onclick=generateBridgeKey; $('saveBridgeKey').onclick=()=>saveBridgeKey(); $('copyBridgeKey').onclick=copyBridgeKey; $('bridgeApiKey').onkeydown=e=>{if(e.key==='Enter')saveBridgeKey();}; $('maxPer').oninput=()=>{cfg.routing.maxInFlightPerCredential=Number($('maxPer').value)||4; setDirty();}; $('refreshCreds').onclick=async()=>{try{const m=await api('/admin/commandcode/credentials?refresh=true'); const byId=new Map((m.credentials||[]).map(x=>[x.id,x])); cfg.credentials.forEach(c=>c.metrics=byId.get(c.id)); render(); toast('Credentials refreshed.');}catch(e){toast('Credential refresh failed.');}}; $('addCred').onclick=()=>{cfg.credentials.push({id:'key'+(cfg.credentials.length+1),apiKey:'',weight:1,enabled:true});render();setDirty();};
77+
$('save').onclick=async()=>{try{if(!cfg)throw new Error('config not loaded'); const pendingKey=pendingBridgeKey||((fullBridgeKey()&&fullBridgeKey()!==currentBridgeAuthKey())?fullBridgeKey():''); const payload={...(pendingKey?{bridgeApiKey:pendingKey}:{}),server:cfg.server,routing:cfg.routing,models:cfg.models,credentials:cfg.credentials.map(c=>({id:c.id,originalId:c.originalId,apiKey:c.apiKey||undefined,weight:c.weight||1,enabled:expiredIds.has(c.id)?undefined:c.enabled!==false,maxInFlight:c.maxInFlight,allowedModels:c.allowedModels}))}; cfg=await api('/admin/config',{method:'PUT',body:JSON.stringify(payload)}); render(); setDirty(true); toast('JSON saved. Restart required.');}catch(e){toast(duplicateCredentialMessage(e)||('Save failed: '+(e?.message||e)));}};
78+
$('restart').onclick=async()=>{const pendingKey=pendingBridgeKey; try{await api('/admin/restart',{method:'POST',body:'{}'}); if(pendingKey){localStorage.setItem('bridgeApiKey',pendingKey); rememberPendingBridgeKey('');} toast('Restart requested'); setTimeout(load,1800);}catch(e){toast('Restart failed: '+(e?.message||e));}};
7779
load();
7880
</script>
7981
</body></html>`;

tests/dashboard-ui.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,9 @@ describe("dashboard UI", () => {
150150
expect(html).toContain("function randomBridgeKey");
151151
expect(html).toContain("cmdbridge-");
152152
expect(html).toContain("generateBridgeKey");
153-
expect(html).toContain("bridgeApiKey:pendingBridgeKey");
153+
expect(html).toContain("pendingBridgeApiKey");
154+
expect(html).toContain("currentBridgeAuthKey()||(!pendingBridgeKey?fullBridgeKey():'')");
155+
expect(html).toContain("bridgeApiKey:pendingKey");
156+
expect(html).toContain("Pending Admin API key saved");
154157
});
155158
});

0 commit comments

Comments
 (0)