Finish Persims consolidation
This commit is contained in:
1 parent
f6fb6ac838
commit
fcb8a67cd4
1 file changed
+458
+458
@@ -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();
|
||||
Reference in new issue
Block a user