diff --git a/extension/popup.mjs b/extension/popup.mjs new file mode 100644 index 0000000..5061744 --- /dev/null +++ b/extension/popup.mjs @@ -0,0 +1,145 @@ +import {pageTarget, metadata, publicSource, request} from './core.mjs'; + +const $ = id => document.getElementById(id); +let target; +let connected = false; +let busy = false; +const allowed = pattern => chrome.permissions.contains({origins: [pattern]}); +const scopes = () => [...document.querySelectorAll('input[name=scope]:checked')].map(input => input.value); +const settings = () => metadata({origin: target.origin, name: $('name').value, + scopes: scopes(), minutes: Number($('minutes').value)}); + +function controls() { + $('connect').disabled = busy; + $('save').disabled = busy || !target || !connected; + $('squeeze').disabled = busy || !connected || !$('sources').value; + for (const button of $('juices').querySelectorAll('button')) button.disabled = busy; +} + +async function run(work) { + if (busy) return; + busy = true; controls(); $('status').textContent = ''; + try { await work(); } + catch { $('status').textContent = 'Operation failed safely. Refresh before retrying.'; } + finally { busy = false; controls(); } +} + +async function api(path, body) { + try { return await request(path, body); } + catch (error) { $('status').textContent = error.message; return null; } +} + +async function listJuice() { + $('juices').replaceChildren(); + const data = await api('juice/list'); + if (!data || !Array.isArray(data.juice)) { + $('juice-status').textContent = 'Juice unavailable. Saved sources remain in My vault.'; + return; + } + $('juice-status').textContent = 'Juice · My vault'; + for (const item of data.juice.slice(0, 100)) { + if (item.vault_kind !== 'user' || typeof item.id !== 'string' || !/^[A-Za-z0-9_-]{1,100}$/.test(item.id)) continue; + const id = item.id; + const row = document.createElement('li'); + if (!['active', 'revoked'].includes(item.status)) continue; + const status = item.status; + row.append(document.createTextNode(`Juice ${id} · ${status} `)); + if (status === 'revoked') { $('juices').append(row); continue; } + const button = document.createElement('button'); + button.textContent = 'Revoke'; + button.onclick = () => run(async () => { + const result = await api('juice/revoke', {id, vault_kind: 'user'}); + if (result) { + await listJuice(); + $('status').textContent = 'Juice revoke request accepted. Source credentials remain in My vault.'; + } + }); + row.append(button); $('juices').append(row); + } +} + +async function refresh() { + connected = false; + $('sources').replaceChildren(new Option('Choose a saved source', '')); + $('juices').replaceChildren(); + if (!await allowed('https://teahub.xyz/*')) { + $('vault-status').textContent = 'Connect Teahub to list My vault.'; + return; + } + const data = await api('list'); + if (!data || !Array.isArray(data.connections)) { + $('vault-status').textContent = 'My vault unavailable. Sign in to Teahub and refresh.'; + return; + } + connected = true; + for (const item of data.connections.slice(0, 200)) { + if (item.vault_kind !== 'user') continue; + try { + const source = publicSource(item); + $('sources').add(new Option(source.name, source.id)); + } catch { /* Ignore malformed public metadata. */ } + } + $('vault-status').textContent = 'Connected · My vault'; + await listJuice(); +} + +$('connect').onclick = () => { + // Request directly in the gesture, before any async work. + const grant = chrome.permissions.request({origins: ['https://teahub.xyz/*']}); + run(async () => { + if (await grant) await refresh(); + else $('status').textContent = 'Teahub permission was not granted.'; + }); +}; +$('sources').onchange = controls; +$('save-form').onsubmit = event => { + event.preventDefault(); + run(async () => { + let credential = ''; + try { + const meta = settings(); + if (!connected) return; + credential = $('token').value; + $('token').value = ''; + if (!credential || new TextEncoder().encode(credential).length > 16384) { + $('status').textContent = 'Provide a credential of at most 16 KiB.'; return; + } + const result = await api('create', {...meta, credential}); + credential = ''; + if (!result) return; + const source = publicSource(result); + await refresh(); + $('sources').value = source.id; + $('status').textContent = 'Saved to My vault. Only public source metadata is retained.'; + } finally { credential = ''; $('token').value = ''; } + }); +}; +$('squeeze').onclick = () => run(async () => { + const source_id = $('sources').value; + const minutes = Number($('minutes').value); + const subject = $('subject').value.trim(); + if (!source_id || !subject || subject.length > 200 || !scopes().length || !Number.isInteger(minutes) || minutes < 1 || minutes > 60) { + $('status').textContent = 'Choose a source, enter a public subject, select at least one scope and use 1–60 minutes.'; + return; + } + const result = await api('juice/create', {source_id, subject, vault_kind: 'user', scopes: scopes(), + expires_at: new Date(Date.now() + minutes * 60000).toISOString()}); + if (result) { + await listJuice(); + $('status').textContent = 'Juice policy saved. Downstream scope and expiry enforcement is not yet available.'; + } +}); +window.addEventListener('pagehide', () => { $('token').value = ''; }); + +await run(async () => { + try { + const [tab] = await chrome.tabs.query({active: true, currentWindow: true}); + target = pageTarget(tab.url); + $('origin').textContent = target.origin; + } catch { $('origin').textContent = 'Use an HTTPS website tab.'; } + // Juice requests begin only when its secondary disclosure is opened. +}); + +$('juice-panel').addEventListener('toggle', () => { + if ($('juice-panel').open && !$('juice-panel').hidden) run(refresh); +});