145 lines
5.6 KiB
JavaScript
145 lines
5.6 KiB
JavaScript
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);
|
|||
|
|
});
|