import { session as loadSession, repositories as loadRepositoryOptions, listUser, listRepository, createConnection, rotateConnection, revokeConnection, deleteConnection } from './store.js'; const $=id=>document.getElementById(id); const userList=$('userList'),userState=$('userState'),repoList=$('repoList'),repoState=$('repoState'); const repoSelect=$('repoSelect'),composerRepo=$('composerRepo'),scrim=$('composerScrim'); const nextButton=$('nextButton'),backButton=$('backButton'),saveButton=$('saveButton'),composerState=$('composerState'); const providers={ Gitea:{type:'PAT',account:'Username',accountPlaceholder:'Your Gitea username',value:'Personal access token',scopes:['Repositories','Issues','Wiki']}, Cloudflare:{type:'API token',account:'Account or label',accountPlaceholder:'Optional account or label',value:'API token',scopes:['DNS','Domains','Account']}, Fly:{type:'Token',account:'Account or label',accountPlaceholder:'Optional account or label',value:'Access token',scopes:['Apps','Machines','Deploys']}, Other:{type:'',account:'Account or label',accountPlaceholder:'Optional account or label',value:'Password, key, or token',scopes:[]} }; const ALL_REPOS='__all__'; let repos=[]; let flow={step:1,provider:'',vault_kind:'',repository:''}; let authPopup=null; let authPoll=null; function node(tag,text='',cls=''){ const el=document.createElement(tag); if(cls)el.className=cls; el.textContent=text; return el; } function fmt(value){ if(!value)return '—'; const d=new Date(value); return Number.isNaN(d.getTime())?String(value):d.toLocaleString(); } function setState(el,message,kind=''){ el.textContent=message; el.className='state'+(kind?' '+kind:''); } function normalizeSecretName(value){ let name=String(value||'').trim().toUpperCase().replace(/[^A-Z0-9_]+/g,'_').replace(/^_+|_+$/g,''); if(/^\d/.test(name))name='P_'+name; return name.slice(0,100); } async function reloadFor(item){ if((item.vault_kind||'user')==='user')return loadUserVault(); if(repoSelect.value===ALL_REPOS||repoSelect.value===item.repository)return loadRepoVault(); } function renderList(target,items,emptyText){ target.replaceChildren(); if(!items.length){ target.append(node('div',emptyText,'empty')); return; } for(const item of items){ const card=node('article','', 'connection'); const top=node('div','', 'cardtop'),left=node('div',''); left.append(node('div','🍊 '+(item.secret_name||item.name||item.provider||'Connection'),'title')); left.append(node('div',[item.provider,item.account,item.credential_type].filter(Boolean).join(' · '),'account')); const status=node('div',item.available?'active ✓':'revoked','badge '+(item.available?'active':'revoked')); top.append(left,status);card.append(top); card.append( node('div',item.vault_kind==='repository'?'📦 '+item.repository:'👤 My vault','where'), node('div','••••••••••••••••','mask') ); const scopes=node('div',''); for(const scope of item.scopes||[])scopes.append(node('span',scope,'scope')); if(!(item.scopes||[]).length)scopes.append(node('span','No capabilities recorded','meta')); card.append(scopes,node('div','Updated '+fmt(item.updated_at)+' · Expires '+(item.expires||'—'),'meta')); if(item.notes)card.append(node('div',item.notes,'meta')); const actions=node('div','', 'actions'); const rotate=node('button',item.available?'Rotate':'Add new value'); const revoke=node('button','Revoke'); const remove=node('button','Delete','danger'); rotate.type=revoke.type=remove.type='button';revoke.disabled=!item.available; actions.append(rotate,revoke,remove);card.append(actions); const rotateBox=node('div','', 'rotatebox'); const value=document.createElement('input'); value.type='password';value.autocomplete='new-password';value.spellcheck=false;value.placeholder='New value'; const save=node('button','Save','primary');save.type='button'; rotateBox.append(value,save);card.append(rotateBox); rotate.onclick=()=>{rotateBox.classList.toggle('open');if(rotateBox.classList.contains('open'))value.focus()}; save.onclick=async()=>{ if(!value.value)return; save.disabled=true; try{await rotateConnection(item,value.value);value.value='';await reloadFor(item)} catch(error){alert(error.message)} finally{save.disabled=false} }; revoke.onclick=async()=>{ if(!confirm('Revoke this connection? Its metadata will remain.'))return; revoke.disabled=true; try{await revokeConnection(item);await reloadFor(item)} catch(error){alert(error.message)} finally{revoke.disabled=false} }; remove.onclick=async()=>{ if(!confirm('Delete this Persimmon and its stored value?'))return; remove.disabled=true; try{await deleteConnection(item);await reloadFor(item)} catch(error){alert(error.message)} finally{remove.disabled=false} }; target.append(card); } } function setSignedOut(){ $('userIdentity').textContent='Signed out'; $('signOutButton').hidden=true; $('addButton').hidden=true; } function setSignedIn(login){ $('userIdentity').textContent=login?'Signed in as '+login:'Signed in'; $('signOutButton').hidden=false; $('addButton').hidden=false; } async function loadIdentity(){ try{ const data=await loadSession(); setSignedIn(data.login||''); return true; }catch(error){ if(error.status===401){setSignedOut();return false} $('userIdentity').textContent='Identity unavailable'; return false; } } async function loadUserVault(){ setState(userState,'Loading…'); try{ const data=await listUser(); const items=data.connections||[]; renderList(userList,items,'No Persimmons yet. Use + Add to store your first useful connection.'); setState(userState,items.length?items.length+' in your vault':'','good'); }catch(error){ if(error.status===401){ const wrap=node('div','', 'empty emptyCta'); const signIn=node('button','Sign in to use your vault.','ctaOutline'); signIn.type='button'; signIn.onclick=beginSignIn; wrap.append(signIn); userList.replaceChildren(wrap); setState(userState,''); return; } const wrap=node('div','', 'empty emptyCta'); wrap.append(node('div','Unable to load your vault.','meta')); const retry=node('button','Retry','ctaOutline'); retry.type='button'; retry.onclick=refreshEverything; wrap.append(retry); userList.replaceChildren(wrap); setState(userState,'Unavailable','bad'); } } async function loadRepoVault(){ const repository=repoSelect.value; repoList.replaceChildren(); if(!repository){setState(repoState,'Choose a repository to see its Persimmons.');return} if(repository===ALL_REPOS){ if(!repos.length){ renderList(repoList,[],'No writable repository vaults are available.'); setState(repoState,'0 writable repositories','good'); return; } setState(repoState,'Loading all repository vaults…'); const items=[]; const failed=[]; const queue=[...repos]; const workers=Array.from({length:Math.min(6,queue.length)},async()=>{ while(queue.length){ const repo=queue.shift(); try{ const data=await listRepository(repo.full_name); items.push(...(data.connections||[])); }catch(error){ failed.push({repository:repo.full_name,error}); } } }); await Promise.all(workers); items.sort((a,b)=>String(b.updated_at||b.created_at||'').localeCompare(String(a.updated_at||a.created_at||''))); renderList(repoList,items,'No Persimmons in your repository vaults yet.'); if(failed.length){ setState(repoState,items.length+' Persimmon'+(items.length===1?'':'s')+' across '+repos.length+' repos · '+failed.length+' vault'+(failed.length===1?'':'s')+' unavailable','bad'); }else{ setState(repoState,items.length+' Persimmon'+(items.length===1?'':'s')+' across '+repos.length+' repository vault'+(repos.length===1?'':'s'),'good'); } return; } setState(repoState,'Loading…'); try{ const data=await listRepository(repository); const items=data.connections||[]; renderList(repoList,items,'No Persimmons in this repository yet.'); setState(repoState,items.length?items.length+' in '+repository:'Empty vault','good'); }catch(error){ repoList.replaceChildren(node('div','Unable to load this repository vault.','empty')); setState(repoState,'Unavailable','bad'); } } function fillRepoSelect(select,current='',includeAll=false){ select.replaceChildren(); if(includeAll)select.append(new Option('All repository vaults',ALL_REPOS)); else select.append(new Option('Choose a repository…','')); for(const repo of repos)select.append(new Option(repo.full_name,repo.full_name)); if(includeAll){ select.value=current&&repos.some(x=>x.full_name===current)?current:ALL_REPOS; }else if(current&&repos.some(x=>x.full_name===current)){ select.value=current; } } async function loadRepositories(){ setState(repoState,'Discovering writable repositories…'); try{ const data=await loadRepositoryOptions(); repos=data.repositories||[]; repoSelect.disabled=false; fillRepoSelect(repoSelect,repoSelect.value,true); fillRepoSelect(composerRepo,flow.repository,false); await loadRepoVault(); }catch(error){ repos=[];fillRepoSelect(repoSelect,'',true);fillRepoSelect(composerRepo,'',false); if(error.status===401){ repoList.replaceChildren(); repoSelect.disabled=true; setState(repoState,''); return; } repoSelect.disabled=false; setState(repoState,'Repository discovery unavailable.','bad'); } } function stopAuthPoll(){ if(authPoll){clearInterval(authPoll);authPoll=null} } function openAuthWindow(){ const width=520,height=680; const left=Math.max(0,(window.screenX||0)+((window.outerWidth||screen.width)-width)/2); const top=Math.max(0,(window.screenY||0)+((window.outerHeight||screen.height)-height)/2); authPopup=window.open( 'https://teahub.xyz/user/login?redirect_to=%2Ftea%2Fchat%2Fpersimmons%2Fauth-complete', 'persimmons-auth', 'popup=yes,width='+width+',height='+height+',left='+Math.round(left)+',top='+Math.round(top) ); if(!authPopup){ setState(userState,'Pop-up blocked. Allow pop-ups and try again.','bad'); return; } setState(userState,'Waiting for sign-in…'); authPopup.focus(); stopAuthPoll(); const started=Date.now(); authPoll=setInterval(async()=>{ try{ const data=await loadSession(); if(data&&data.login){ stopAuthPoll(); try{authPopup&&authPopup.close()}catch{} authPopup=null; setSignedIn(data.login); setState(userState,''); await Promise.all([loadRepositories(),loadUserVault()]); return; } }catch(error){ if(error.status!==401){ setState(userState,'Sign-in check unavailable. Try again.','bad'); } } if(Date.now()-started>120000){ stopAuthPoll(); authPopup=null; setState(userState,'Sign-in timed out. Try again.'); } },700); } async function beginSignIn(){ setState(userState,'Checking sign-in…'); try{ const data=await loadSession(); if(data&&data.login){ setSignedIn(data.login); setState(userState,''); await Promise.all([loadRepositories(),loadUserVault()]); return; } }catch(error){ if(error.status!==401){ setState(userState,'Sign-in check unavailable. Try again.','bad'); return; } } setState(userState,''); openAuthWindow(); } const sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms)); async function waitForSignedOut(){ for(let attempt=0;attempt<15;attempt++){ await sleep(200); try{ await loadSession(); }catch(error){ if(error.status===401)return true; } } return false; } async function signOut(){ const button=$('signOutButton'); button.disabled=true; setState(userState,'Signing out…'); try{ await fetch('https://teahub.xyz/user/logout',{ credentials:'include', mode:'no-cors', cache:'no-store' }); if(!await waitForSignedOut())throw new Error('Sign out did not complete. Try again.'); setSignedOut(); await Promise.all([loadRepositories(),loadUserVault()]); }catch(error){ setState(userState,'Sign out failed. Try again.','bad'); }finally{ button.disabled=false; } } function resetComposer(){ flow={step:1,provider:'',vault_kind:'',repository:''}; document.querySelectorAll('.choice').forEach(x=>x.classList.remove('selected')); $('connectionName').value='';$('accountInput').value='';$('valueInput').value=''; $('expiresInput').value='';$('notesInput').value='';$('typeInput').value='Password'; $('composerRepoField').hidden=true;composerRepo.value=''; setState(composerState,'');showStep(1); } function openComposer(){resetComposer();scrim.hidden=false} function closeComposer(){$('valueInput').value='';scrim.hidden=true} function showStep(step){ flow.step=step; $('step1').hidden=step!==1;$('step2').hidden=step!==2;$('step3').hidden=step!==3; backButton.hidden=step===1;nextButton.hidden=step===3;saveButton.hidden=step!==3; $('stepSummary').textContent={1:'What are you connecting?',2:'Where should it be available?',3:'Add only what this connection needs.'}[step]; updateNext();if(step===3)prepareDetails(); } function updateNext(){ if(flow.step===1)nextButton.disabled=!flow.provider; if(flow.step===2)nextButton.disabled=!flow.vault_kind||(flow.vault_kind==='repository'&&!composerRepo.value); } function selectChoice(selector,clicked){ document.querySelectorAll(selector).forEach(x=>x.classList.toggle('selected',x===clicked)); } document.querySelectorAll('.provider').forEach(button=>{ button.onclick=()=>{flow.provider=button.dataset.provider;selectChoice('.provider',button);updateNext()}; }); document.querySelectorAll('.vault').forEach(button=>{ button.onclick=()=>{ flow.vault_kind=button.dataset.vault;selectChoice('.vault',button); $('composerRepoField').hidden=flow.vault_kind!=='repository'; if(flow.vault_kind!=='repository'){flow.repository='';composerRepo.value=''} updateNext(); }; }); composerRepo.onchange=()=>{flow.repository=composerRepo.value;updateNext()}; function prepareDetails(){ const cfg=providers[flow.provider]; $('connectionName').placeholder=flow.provider==='Other'?'e.g. MY_PASSWORD':flow.provider.toUpperCase()+'_TOKEN'; $('accountLabel').textContent=cfg.account;$('accountInput').placeholder=cfg.accountPlaceholder; $('valueLabel').textContent=cfg.value;$('typeField').hidden=flow.provider!=='Other'; if(flow.provider!=='Other')$('typeInput').value=cfg.type; $('scopeChoices').replaceChildren();$('scopeField').hidden=!cfg.scopes.length; cfg.scopes.forEach((scope,index)=>{ const wrap=node('span','', 'check'),input=document.createElement('input'),label=document.createElement('label'); input.type='checkbox';input.id='scope_'+index;input.value=scope; label.htmlFor=input.id;label.textContent=scope;wrap.append(input,label);$('scopeChoices').append(wrap); }); } const selectedScopes=()=>[...$('scopeChoices').querySelectorAll('input:checked')].map(x=>x.value); nextButton.onclick=()=>{ if(flow.step===1&&flow.provider)return showStep(2); if(flow.step===2&&flow.vault_kind&&(flow.vault_kind!=='repository'||composerRepo.value)){ flow.repository=composerRepo.value;return showStep(3); } }; backButton.onclick=()=>showStep(Math.max(1,flow.step-1)); saveButton.onclick=async()=>{ const value=$('valueInput').value; const name=normalizeSecretName($('connectionName').value); if(!name){setState(composerState,'Name this Persimmon first.','bad');$('connectionName').focus();return} if(!value){setState(composerState,'Enter the new value.','bad');return} $('connectionName').value=name; const cfg=providers[flow.provider]; const metadata={ name, provider:flow.provider, account:$('accountInput').value.trim(), credential_type:flow.provider==='Other'?$('typeInput').value:cfg.type, scopes:selectedScopes(), vault_kind:flow.vault_kind, expires:$('expiresInput').value.trim(), notes:$('notesInput').value.trim() }; if(flow.vault_kind==='repository')metadata.repository=flow.repository; saveButton.disabled=true;setState(composerState,'Saving…'); try{ await createConnection(metadata,value); $('valueInput').value=''; const savedVault=flow.vault_kind,savedRepo=flow.repository; closeComposer();await loadUserVault(); if(savedVault==='repository'){repoSelect.value=savedRepo;await loadRepoVault()} }catch(error){setState(composerState,error.message,'bad')} finally{saveButton.disabled=false} }; async function refreshEverything(){ await Promise.all([loadIdentity(),loadRepositories(),loadUserVault()]); } $('connectionName').addEventListener('blur',()=>{$('connectionName').value=normalizeSecretName($('connectionName').value)}); $('addButton').onclick=openComposer;$('closeComposer').onclick=closeComposer; $('refreshAll').onclick=refreshEverything;$('signOutButton').onclick=signOut;repoSelect.onchange=loadRepoVault; scrim.addEventListener('click',event=>{if(event.target===scrim)closeComposer()}); document.addEventListener('keydown',event=>{ if(event.key==='Escape'&&!scrim.hidden)closeComposer(); }); refreshEverything();