459 lines
17 KiB
JavaScript
459 lines
17 KiB
JavaScript
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();
|