Files
app/backend/web/app.js
T

568 lines
26 KiB
JavaScript
Raw Normal View History

2026-10-04 02:57:24 +00:00
import {updateState} from './version.mjs';
const $ = id => document.getElementById(id);
const EXTENSION_ID = 'kiolgjoohhgdohcafgelhikpgkbmckbg';
let generation = 0, currentOwner, currentState, dragging = false, juiceBusy = false;
const masterItems = new Map();
const openVaults = new Set();
$('signOutButton').hidden = true;
$('signOutButton').textContent = 'Sign out';
$('userList').innerHTML = `
<div id="vaultSummary" class="vaultSummary" hidden>
<span id="extensionState" class="lockIcon" role="img"></span>
<div><strong id="vaultCount"></strong><div id="extensionHelp" class="hint"></div></div>
</div>
<div id="onboarding" class="empty">
<p id="onboardingHelp"></p>
<button id="createAccount" class="primary" hidden>Create account</button>
<button id="getExtension" class="primary" hidden>Get extension</button>
<button id="updateExtension" hidden>Update available</button>
</div>
<div id="vaultItems"></div>`;
function extensionMessage(type, owner, extra = {}) {
return new Promise(resolve => {
const timer = setTimeout(() => resolve(null), type === 'persimmons.juice.v1' ? 180000 : 2500);
try {
chrome.runtime.sendMessage(EXTENSION_ID, {type, owner, ...extra}, result => {
const failed = chrome.runtime.lastError;
clearTimeout(timer);
resolve(failed ? null : result);
});
} catch {
clearTimeout(timer);
resolve(null);
}
});
}
const extensionStatus = owner => extensionMessage('persimmons.connection.v1', owner);
const extensionInventory = owner => extensionMessage('persimmons.inventory.v1', owner);
async function latestVersion() {
try {
const response = await fetch('/assets/version.json', {cache: 'no-store'});
return response.ok ? (await response.json()).version : null;
} catch { return null; }
}
// One refresh at a time. Signed in, a pass (session, then the vault index and the vault list, then the extension)
// takes longer than the 1.5 s poll, and each new pass used to make the one in flight bail out before rendering, so
// the vault stayed empty forever. Now a refresh asked for during another runs once more after it (actions, focus),
// and the poll simply skips while one is running.
let refreshing = null, refreshAgain = false;
function refresh() {
if (refreshing) { refreshAgain = true; return refreshing; }
refreshing = (async () => {
try { do { refreshAgain = false; await refreshOnce(); } while (refreshAgain); }
finally { refreshing = null; }
})();
return refreshing;
}
async function refreshOnce() {
2026-10-04 02:57:24 +00:00
if (dragging || juiceBusy) return;
const current = ++generation;
const response = await fetch('/api/session', {credentials: 'same-origin', cache: 'no-store'});
if (current !== generation) return;
if (!response.ok && response.status !== 401) throw new Error();
const signedIn = response.ok;
const identity = signedIn ? await response.json() : null;
const owner = identity?.owner || identity?.login?.toLowerCase() || identity?.user?.login?.toLowerCase() || identity?.name?.toLowerCase();
2026-10-04 02:57:24 +00:00
const [state, latest, vault, named] = await Promise.all([
extensionStatus(owner),
latestVersion(),
signedIn
? fetch('/api/vault-index', {credentials: 'same-origin', cache: 'no-store'})
.then(async response => response.ok ? response.json() : null)
.catch(() => null)
: Promise.resolve(null),
signedIn ? fetch('/api/vaults', {credentials: 'same-origin', cache: 'no-store'})
.then(async response => response.ok ? response.json() : null).catch(() => null) : null
]);
if (current !== generation) return;
const decrypted = state?.unlocked ? await extensionInventory(owner) : null;
if (current !== generation || dragging || juiceBusy) return;
$('signInLink').hidden = signedIn;
$('signOutButton').hidden = !signedIn;
$('addCredential').hidden = !signedIn || !state?.connected || !state?.initialized;
$('userState').hidden = true;
$('userState').textContent = '';
const displayName = identity?.name || identity?.login || identity?.user?.login || owner || '';
$('userIdentity').textContent = signedIn ? 'Signed in as ' + displayName : '';
2026-10-04 02:57:24 +00:00
currentOwner = owner; currentState = state;
if (!await renderVaults(signedIn, named, current)) return;
if (current !== generation || dragging || juiceBusy) return;
const stale = !!state && updateState(state.version, latest) === 'Update available';
$('updateExtension').hidden = !signedIn || !stale;
$('createAccount').hidden = signedIn;
$('getExtension').hidden = !signedIn || !!state;
if (!signedIn) {
$('vaultSummary').hidden = true;
$('onboarding').hidden = false;
$('onboardingHelp').textContent = 'Sign in or create an account to use your encrypted vault.';
$('vaultItems').replaceChildren();
return;
}
const linked = state?.connected === true;
const ready = linked && state.initialized;
$('vaultSummary').hidden = !ready;
$('onboarding').hidden = ready;
$('extensionState').textContent = state?.unlocked ? '🔓' : '🔒';
$('extensionState').ariaLabel = state?.unlocked ? 'Vault unlocked' : 'Vault locked';
$('extensionState').title = $('extensionState').ariaLabel;
$('extensionHelp').textContent = state?.unlocked ? 'Drag a credential to a named vault to create Juice.' : 'Open 🍊 to unlock. Choose a timed auto-lock in Settings for Juice.';
$('onboardingHelp').textContent =
!state ? 'Install Persimmons in this browser.' :
!linked ? 'Open 🍊 and choose Link vault.' :
!state.initialized ? 'Open 🍊 and choose a vault passphrase.' :
'';
const inventory = $('vaultItems');
inventory.replaceChildren();
const serverItems = Array.isArray(vault?.items) ? vault.items : null;
const extensionItems = Array.isArray(decrypted?.items) ? decrypted.items : null;
const sourceItems = serverItems || extensionItems;
const vaultAvailable = Array.isArray(sourceItems);
const items = vaultAvailable ? sourceItems.filter(item => {
2026-10-04 02:57:24 +00:00
try { return /^[0-9a-f]{32}$/.test(item.id) && new URL(item.origin).protocol === 'https:'; }
catch { return false; }
}) : [];
$('vaultCount').textContent = !vaultAvailable ? 'Credential list unavailable' :
items.length === 1 ? '1 saved credential' : items.length + ' saved credentials';
masterItems.clear();
for (const item of items) masterItems.set(item.id, item);
if (!items.length) {
const empty = document.createElement('div');
empty.className = 'empty';
empty.textContent = vaultAvailable ?
'No saved credentials yet. Add one from the site where you use it.' :
'Credentials could not be loaded. The page will retry automatically.';
inventory.append(empty);
} else {
const usernames = new Map((decrypted?.items || []).map(item => [item.id, item.username]));
for (const item of items) {
const url = new URL(item.origin);
const username = item.username || usernames.get(item.id);
const card = document.createElement('article');
card.className = 'connection';
card.dataset.sourceId = item.id;
card.draggable = !!(state?.connected && state?.initialized && state?.unlocked);
card.ondragstart = event => {
dragging = true;
event.dataTransfer.effectAllowed = 'copy';
event.dataTransfer.setData('application/x-persimmons-source', item.id);
};
card.ondragend = () => { dragging = false; };
const top = document.createElement('div');
top.className = 'cardtop';
const description = document.createElement('div');
const name = document.createElement('div');
name.className = 'title';
name.textContent = '🔑 ' + (username || url.hostname);
const label = document.createElement('div');
label.className = 'account';
label.textContent = username ? 'Website credential' : 'Credential · ID ' + item.id.slice(-6).toUpperCase();
description.append(name, label);
const status = document.createElement('span');
status.className = 'badge';
status.textContent = state?.unlocked ? '🔓' : '🔒';
status.ariaLabel = state?.unlocked ? 'Vault unlocked' : 'Vault locked';
status.title = status.ariaLabel;
top.append(description, status);
const source = document.createElement('div');
source.className = 'credentialSource';
const sourceLabel = document.createElement('span');
sourceLabel.textContent = 'Source';
const sourceLink = document.createElement('a');
sourceLink.href = item.origin;
sourceLink.target = '_blank';
sourceLink.rel = 'noreferrer';
sourceLink.textContent = item.origin;
source.append(sourceLabel, sourceLink);
const mask = document.createElement('div');
mask.className = 'mask';
mask.textContent = '••••••••••••••••';
const capability = document.createElement('span');
capability.className = 'scope';
capability.textContent = 'Fill only on ' + url.hostname;
const metadata = document.createElement('div');
metadata.className = 'meta credentialId';
metadata.textContent = 'ID ' + item.id.slice(-6);
const actions = document.createElement('div');
actions.className = 'actions';
const open = document.createElement('a');
open.className = 'buttonLink';
open.href = item.origin;
open.target = '_blank';
open.rel = 'noreferrer';
open.textContent = 'Open site';
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'danger';
remove.textContent = 'Delete';
remove.onclick = async () => {
if (!confirm('Delete this saved credential? This cannot be undone.')) return;
remove.disabled = true;
try {
const deleted = await fetch('/api/vault-index/' + item.id, {
method: 'DELETE', credentials: 'same-origin', headers: {'X-Requested-With': 'Persimmons'}
});
if (!deleted.ok) throw new Error();
await refresh();
} catch {
remove.disabled = false;
$('userState').hidden = false;
$('userState').textContent = 'Delete unavailable. Retry shortly.';
}
};
actions.append(open, remove);
card.append(top, source, mask, capability, metadata, actions);
inventory.append(card);
}
}
}
async function renderVaults(signedIn, result, current) {
const contents = new Map();
if (signedIn && Array.isArray(result?.vaults)) await Promise.all(result.vaults.map(async vault => {
try { contents.set(vault.id, await pageJuice('list', vault)); } catch {}
}));
// Resolve all network reads before touching cards. Never replace a drag target mid-gesture.
if (current !== generation || dragging || juiceBusy) return false;
$('namedVaults').hidden = !signedIn;
const list = $('namedVaultList');
list.replaceChildren();
if (!signedIn) return true;
if (!Array.isArray(result?.vaults) || !result.vaults.length) {
const empty = document.createElement('p');
empty.className = 'empty';
empty.textContent = Array.isArray(result?.vaults) ? 'No named vaults yet. Create a private vault when you need one.' : 'Vaults could not be loaded. Retrying shortly.';
list.append(empty);
return true;
}
for (const vault of result.vaults) {
const card = document.createElement('article');
card.className = 'connection namedVaultCard';
card.dataset.vaultId = vault.id;
card.ondragover = event => {
if (!currentState?.connected || !currentState?.initialized || !currentState?.unlocked ||
!event.dataTransfer.types.includes('application/x-persimmons-source')) return;
event.preventDefault(); event.dataTransfer.dropEffect = 'copy'; card.classList.add('juiceHover');
};
card.ondragleave = event => { if (!card.contains(event.relatedTarget)) card.classList.remove('juiceHover'); };
card.ondrop = event => {
event.preventDefault(); dragging = false; card.classList.remove('juiceHover');
const item = masterItems.get(event.dataTransfer.getData('application/x-persimmons-source'));
if (item && currentState?.connected && currentState?.initialized && currentState?.unlocked) openJuice(item, vault);
};
const top = document.createElement('div');
top.className = 'cardtop';
const description = document.createElement('div');
const name = document.createElement('div');
name.className = 'title';
name.textContent = '📦 ' + vault.name;
const owner = document.createElement('div');
owner.className = 'account';
owner.textContent = vault.owner + '/' + vault.name;
description.append(name, owner);
const badge = document.createElement('span');
badge.className = 'badge active';
badge.textContent = 'private ✓';
top.append(description, badge);
const meta = document.createElement('div');
meta.className = 'namedVaultMeta';
const state = document.createElement('span');
state.className = 'namedVaultBadge';
const juiceCount = contents.get(vault.id)?.items.length;
state.textContent = juiceCount ? juiceCount + ' Juice' : vault.empty ? 'Empty vault' : 'Vault';
meta.append(state);
const actions = document.createElement('div');
actions.className = 'namedVaultActions';
const open = document.createElement('button');
open.type = 'button';
open.textContent = 'Open vault';
const body = document.createElement('div');
body.className = 'namedVaultBody';
body.hidden = !openVaults.has(vault.owner + '/' + vault.name);
const bodyTitle = document.createElement('strong');
bodyTitle.textContent = vault.name;
const bodyText = document.createElement('span');
bodyText.textContent = 'Drop an unlocked Master credential here to create Juice.';
body.append(bodyTitle, bodyText);
try {
const result = contents.get(vault.id);
for (const item of result.items) body.append(juiceCard(item, vault));
if (result.items.length) bodyText.remove();
} catch { bodyText.textContent = 'Juice metadata unavailable. Retry shortly.'; }
open.textContent = body.hidden ? 'Open vault' : 'Close vault';
open.onclick = () => {
const key = vault.owner + '/' + vault.name;
if (openVaults.has(key)) openVaults.delete(key);
else openVaults.add(key);
body.hidden = !openVaults.has(key);
open.textContent = body.hidden ? 'Open vault' : 'Close vault';
};
actions.append(open);
card.append(top, meta, actions, body);
list.append(card);
}
return true;
}
async function pageJuice(operation, vault, extra = {}) {
const response = await fetch('/api/juice/' + operation, {method: 'POST', credentials: 'same-origin', cache: 'no-store',
headers: {'Content-Type': 'application/json', 'X-Vault-Owner': currentOwner, 'X-Requested-With': 'Persimmons'},
body: JSON.stringify({target: {id: vault.id, name: vault.name}, ...extra})});
if (!response.ok) throw new Error();
return response.json();
}
function juiceCard(item, vault) {
const card = document.createElement('article');
card.className = 'juiceCard'; card.dataset.juiceId = item.id;
const title = document.createElement('strong');
title.textContent = '💧 Gitea · ' + item.source_username + '@' + new URL(item.source_origin).hostname;
const details = document.createElement('p');
details.textContent = item.scopes.join(', ') + ' · ' + item.status + ' · ' + item.created_at;
const boundary = document.createElement('p');
boundary.textContent = 'Provider API-class scopes; not restricted to this vault or a single repository.';
const status = document.createElement('p');
status.textContent = 'Target secret: ' + item.target_status + ' · Provider token: ' + item.provider_status;
if (item.status === 'partial-failure' || item.status === 'pending') status.textContent += ' · ANDON: cleanup may be required. Retry Revoke.';
const actions = document.createElement('div'); actions.className = 'actions';
for (const action of ['rotate', 'revoke']) {
const button = document.createElement('button'); button.textContent = action === 'rotate' ? 'Rotate' : 'Revoke';
button.disabled = action === 'rotate' && (item.status !== 'active' || !currentState?.unlocked);
if (action === 'revoke' && item.status === 'revoked') button.disabled = true;
button.onclick = async () => {
juiceBusy = true; button.disabled = true;
try {
if (action === 'revoke') await pageJuice('remove', vault, {id: item.id});
const result = await extensionMessage('persimmons.juice.v1', currentOwner,
{action, target: {id: vault.id, name: vault.name}, id: item.id});
if (!result && action === 'revoke') {
status.textContent = 'ANDON: Target secret removed; provider revocation unconfirmed. Connect and unlock the source to retry.';
} else if (!result?.ok) {
status.textContent = (action === 'revoke' ? 'ANDON: Target secret removed; provider cleanup unconfirmed. ' : '') +
(result?.message || 'ANDON: Operation outcome unknown. Refresh and inspect Juice before retrying.');
} else {
openVaults.add(vault.owner + '/' + vault.name);
juiceBusy = false; await refresh();
}
} catch { status.textContent = 'ANDON: Operation outcome unknown. Inspect Juice before retrying.'; }
finally { juiceBusy = false; button.disabled = false; }
};
actions.append(button);
}
card.append(title, details, boundary, status, actions);
return card;
}
function openJuice(item, vault) {
const dialog = ensureDialog(), body = $('siteModalBody');
body.replaceChildren(closeButton(dialog));
const title = document.createElement('h2');
title.style.cssText = 'font-size:20px;overflow-wrap:anywhere;line-height:1.3';
title.textContent = 'Juice ' + (item.username || 'credential') + '@' + new URL(item.origin).hostname + ' → ' + vault.name;
body.append(title);
if (item.origin !== 'https://teahub.xyz') {
const note = document.createElement('p'); note.textContent = 'Juice adapter not available yet.'; body.append(note);
} else {
const form = document.createElement('form');
form.innerHTML = `<label>Gitea PAT scopes<select name="scope"><option value="read:repository">Read repositories</option><option value="read:issue">Read issues</option><option value="write:issue">Read and write issues</option></select></label>
<p class="hint">Scopes apply across the source account’s accessible resources in that API category. They are not limited to this vault or one repository. No automatic expiry.</p>
<button type="submit" class="primary">Create Juice</button><p role="status"></p>`;
form.onsubmit = async event => {
event.preventDefault(); juiceBusy = true; form.querySelector('button').disabled = true;
const result = await extensionMessage('persimmons.juice.v1', currentOwner, {action: 'create', source_id: item.id,
target: {id: vault.id, name: vault.name}, policy: {scopes: [form.elements.scope.value]}});
juiceBusy = false;
if (result?.ok) {
openVaults.add(vault.owner + '/' + vault.name); dialog.close(); await refresh();
} else {
form.querySelector('[role=status]').textContent = result?.message || 'ANDON: Outcome unknown. Close and inspect Juice before retrying.';
form.querySelector('button').disabled = false;
}
};
body.append(form);
}
dialog.showModal();
}
function openNewVault() {
const dialog = ensureDialog();
const body = $('siteModalBody');
body.replaceChildren(closeButton(dialog));
const title = document.createElement('h2');
title.textContent = 'New vault';
const form = document.createElement('form');
form.innerHTML = `<label>Vault name<input id="vaultName" name="name" autocomplete="off" maxlength="100" required></label>
<p class="hint">Creates an empty private vault owned by you. Spaces become hyphens.</p>
<button type="submit" class="primary">Create vault</button><p id="vaultStatus" role="status"></p>`;
form.onsubmit = async event => {
event.preventDefault();
form.querySelector('button').disabled = true;
try {
const response = await fetch('/api/vaults', {method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json', 'X-Requested-With': 'Persimmons'},
body: JSON.stringify({name: form.querySelector('#vaultName').value})});
const result = await response.json();
if (!response.ok) {
form.querySelector('#vaultStatus').textContent = result.error || 'Vault creation unavailable. Retry shortly.';
form.querySelector('button').disabled = false;
return;
}
dialog.close();
await refresh();
} catch {
form.querySelector('#vaultStatus').textContent = 'Vault creation unavailable. Refresh the page before retrying.';
form.querySelector('button').disabled = false;
}
};
body.append(title, form);
dialog.showModal();
}
function ensureDialog() {
let dialog = $('siteModal');
if (dialog) return dialog;
dialog = document.createElement('dialog');
dialog.id = 'siteModal';
dialog.style.cssText = 'border:0;border-radius:16px;padding:0;width:min(460px,calc(100vw - 32px));box-shadow:0 24px 80px rgba(36,26,20,.24)';
dialog.innerHTML = '<div id="siteModalBody" style="padding:22px;background:#fffdfa"></div>';
document.body.append(dialog);
dialog.addEventListener('close', () => refresh().catch(failed));
return dialog;
}
function closeButton(dialog) {
const button = document.createElement('button');
button.type = 'button';
button.textContent = '×';
button.setAttribute('aria-label', 'Close');
button.style.cssText = 'float:right;border:0;background:none;font-size:22px';
button.onclick = () => dialog.close();
return button;
}
function openAuth(mode = 'login') {
const dialog = ensureDialog();
const body = $('siteModalBody');
body.replaceChildren(closeButton(dialog));
const title = document.createElement('h2');
title.textContent = mode === 'register' ? 'Create account' : 'Sign in';
const form = document.createElement('form');
form.autocomplete = 'on';
form.innerHTML = `
<label>Username<input id="modalName" name="username" autocomplete="username" autocapitalize="none" spellcheck="false" minlength="3" maxlength="64" required></label>
<label>Password<input id="modalPassword" name="password" type="password" autocomplete="current-password" minlength="12" maxlength="1024" required></label>
<button type="submit" class="primary">${mode === 'register' ? 'Create account' : 'Sign in'}</button>
<p id="modalStatus" role="status"></p>`;
form.onsubmit = async event => {
event.preventDefault();
const password = form.querySelector('#modalPassword');
const payload = {name: form.querySelector('#modalName').value, password: password.value};
password.value = '';
form.querySelector('button').disabled = true;
try {
const response = await fetch('/api/auth/' + mode, {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
});
payload.password = '';
if (!response.ok) throw new Error();
dialog.close();
await refresh();
} catch {
payload.password = '';
form.querySelector('#modalStatus').textContent = 'Sign-in unavailable. Check details and retry.';
form.querySelector('button').disabled = false;
}
};
body.append(title, form);
dialog.showModal();
}
function openInstall(update = false) {
const dialog = ensureDialog();
const body = $('siteModalBody');
body.replaceChildren(closeButton(dialog));
const title = document.createElement('h2');
title.textContent = update ? 'Update Persimmons' : 'Get Persimmons';
const text = document.createElement('p');
text.textContent = update ? 'Install the current build into your unpacked extension folder, then reload it in Chromium.' : 'Download the current build, unzip it, then use Chromium → Extensions → Developer mode → Load unpacked.';
const link = document.createElement('a');
link.href = '/assets/extension.zip';
link.className = 'buttonLink primary';
link.textContent = update ? 'Download current build' : 'Download extension';
body.append(title, text, link);
dialog.showModal();
}
function openAddCredential() {
const dialog = ensureDialog();
const body = $('siteModalBody');
body.replaceChildren(closeButton(dialog));
const title = document.createElement('h2');
title.textContent = 'Add a credential';
const text = document.createElement('p');
text.textContent = 'Open the HTTPS site where you use the credential, enter it without submitting, then open 🍊 and choose Save for this site.';
const note = document.createElement('p');
note.className = 'hint';
note.textContent = 'The page updates automatically after the extension saves it.';
body.append(title, text, note);
dialog.showModal();
}
const failed = () => {
$('userState').hidden = false;
$('userState').textContent = 'Service unavailable. Retry shortly.';
$('extensionState').hidden = false;
$('extensionState').textContent = 'Connection could not be checked.';
};
$('signOutButton').onclick = async () => {
try {
const response = await fetch('/api/logout', {method: 'POST'});
if (!response.ok) throw new Error();
await refresh();
} catch {
$('userState').hidden = false;
$('userState').textContent = 'Sign-out unavailable. Retry shortly.';
}
};
$('signInLink').onclick = event => { event.preventDefault(); openAuth('login'); };
$('createAccount').onclick = () => openAuth('register');
$('getExtension').onclick = () => openInstall(false);
$('updateExtension').onclick = () => openInstall(true);
$('addCredential').onclick = openAddCredential;
$('newVault').onclick = openNewVault;
window.addEventListener('pointerdown', event => {
if (event.target.closest?.('.connection[draggable="true"]')) dragging = true;
}, true);
window.addEventListener('pointerup', () => { dragging = false; }, true);
window.addEventListener('pageshow', () => refresh().catch(failed));
window.addEventListener('focus', () => refresh().catch(failed));
setInterval(() => { if (!refreshing && !document.hidden && !$('siteModal')?.open) refresh().catch(() => {}); }, 1500);
2026-10-04 02:57:24 +00:00
refresh().catch(failed);