Finish Persims consolidation

This commit is contained in:
matcha committed 2026-10-04 02:57:28 +00:00
1 parent f6fb6ac838
commit fcb8a67cd4
1 file changed
+458
+458
View File
@@ -0,0 +1,458 @@
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();