From fce52687d3b6523b90cd06322c634d41c970ed73 Mon Sep 17 00:00:00 2001 From: matcha Date: Sun, 4 Oct 2026 02:57:24 +0000 Subject: [PATCH] Finish Persims consolidation --- backend/web/app.js | 550 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 550 insertions(+) create mode 100644 backend/web/app.js diff --git a/backend/web/app.js b/backend/web/app.js new file mode 100644 index 0000000..99545d6 --- /dev/null +++ b/backend/web/app.js @@ -0,0 +1,550 @@ +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 = ` + +
+

+ + + +
+
`; + +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; } +} + +async function refresh() { + 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; + 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 = ''; + $('userIdentity').textContent = signedIn ? 'Signed in as ' + identity.name : ''; + 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 vaultAvailable = Array.isArray(vault?.items); + const items = vaultAvailable ? vault.items.filter(item => { + 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 = ` +

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.

+

`; + 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 = ` +

Creates an empty private vault owned by you. Spaces become hyphens.

+

`; + 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 = '
'; + 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 = ` + + + +

`; + 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 (!document.hidden && !$('siteModal')?.open) refresh().catch(() => {}); }, 1500); +refresh().catch(failed);