openvibers/openvibe.network Commits
Add an Approvals section to the account hub for agent confirmations (#48)
Changes
nnew file mode 100644@@@ -0,0 +1,130 @@++/* ═══════════════════════════════════════════════════════════════++ OpenVibe.Network — account hub (/my): Approvals (plan T2 WS-Z2).++ The owner's confirmation inbox: an agent acting for this person asks before++ a sensitive capability runs (server/developer/confirmations.js). Lists the++ pending requests from GET /api/v1/confirmations; Approve and Deny post to++ /:id/approve and /:id/deny. Built from DOM nodes: summaries, names and++ resources come from agents, so nothing here is parsed as HTML.++ Classic script, global scope; relies on my/core.js (apiFetch, toast).++ ═══════════════════════════════════════════════════════════════ */++let approvalsState = { loading: false, items: [], agents: {} };++++function approvalsEl(tag, attrs, ...kids) {++ const n = document.createElement(tag);++ for (const [k, v] of Object.entries(attrs || {})) {++ if (v == null) continue;++ if (k === 'class') n.className = v; else if (k === 'text') n.textContent = v; else if (k.startsWith('on')) n.addEventListener(k.slice(2), v); else n.setAttribute(k, v);++ }++ for (const kid of kids) if (kid != null) n.append(kid);++ return n;++}++++// "in 4 min", "in 1 h 5 min", "expired": the request lapses by itself (expires_at), so the owner sees how long is left.++function approvalsLeft(iso) {++ const ms = Date.parse(iso) - Date.now();++ if (!Number.isFinite(ms) || ms <= 0) return 'expired';++ const m = Math.ceil(ms / 60000);++ return m < 60 ? `${m} min left` : `${Math.floor(m / 60)} h ${m % 60} min left`;++}++++// The agent's host is a SubjectRef ({ type: 'app', id: 'app_…' }) from agentContext; a string is taken as is.++function approvalsHost(agent) {++ const h = agent && agent.host;++ if (!h) return '';++ return typeof h === 'string' ? h : String(h.id || h.type || '');++}++++function setApprovalsBadge(n) {++ const btn = [...document.querySelectorAll('.section-tabs button')].find((b) => b.getAttribute('onclick')?.includes("'approvals'"));++ if (!btn) return;++ let badge = btn.querySelector('.tab-badge');++ if (!n) { badge?.remove(); return; }++ if (!badge) { badge = approvalsEl('span', { class: 'tab-badge', 'aria-label': 'waiting for you' }); btn.append(' ', badge); }++ badge.textContent = String(n);++}++++function renderApprovals(error) {++ const list = document.getElementById('approvals-list');++ if (!list) return;++ list.replaceChildren();++ if (error) {++ list.append(approvalsEl('div', { class: 'approvals-empty', role: 'alert' },++ approvalsEl('p', { text: `Could not load your approvals: ${error}` }),++ approvalsEl('button', { class: 'btn', type: 'button', text: 'Try again', onclick: () => loadApprovals() })));++ return;++ }++ const items = approvalsState.items.filter((c) => c.state === 'pending');++ setApprovalsBadge(items.length);++ if (!items.length) {++ list.append(approvalsEl('p', { class: 'approvals-empty', text: 'Nothing is waiting for you. When an agent asks to do something sensitive on your behalf, it shows up here.' }));++ return;++ }++ for (const c of items) list.append(approvalRow(c));++}++++function approvalRow(c) {++ const agent = approvalsState.agents[c.requested_by && c.requested_by.id] || null;++ const who = agent ? agent.name : (c.requested_by && c.requested_by.id) || 'An agent';++ const row = approvalsEl('div', { class: 'approval', 'data-id': c.id });++ const meta = approvalsEl('div', { class: 'approval-meta' },++ approvalsEl('span', { class: 'approval-who', text: who }),++ approvalsHost(agent) ? approvalsEl('span', { class: 'approval-host', text: ` on ${approvalsHost(agent)}` }) : null,++ approvalsEl('span', { class: 'approval-sep', text: ' · ' }),++ approvalsEl('code', { class: 'approval-cap', text: c.capability }),++ approvalsEl('span', { class: 'approval-sep', text: ' · ' }),++ approvalsEl('span', { class: 'approval-left', text: approvalsLeft(c.expires_at) }));++ const resources = Array.isArray(c.resources) && c.resources.length++ ? approvalsEl('ul', { class: 'approval-resources', 'aria-label': 'What it touches' },++ ...c.resources.slice(0, 8).map((r) => approvalsEl('li', { text: typeof r === 'string' ? r : [r.type, r.id || r.name].filter(Boolean).join(' ') })))++ : null;++ const approve = approvalsEl('button', { class: 'btn btn-primary', type: 'button', onclick: () => decideApproval(c.id, 'approve', row) },++ approvalsEl('i', { class: 'fa-solid fa-check', 'aria-hidden': 'true' }), ' Approve once');++ const deny = approvalsEl('button', { class: 'btn', type: 'button', onclick: () => decideApproval(c.id, 'deny', row) },++ approvalsEl('i', { class: 'fa-solid fa-xmark', 'aria-hidden': 'true' }), ' Deny');++ row.append(approvalsEl('p', { class: 'approval-summary', text: c.summary || c.capability }), meta, resources,++ approvalsEl('div', { class: 'btn-row' }, approve, deny));++ return row;++}++++async function decideApproval(id, action, row) {++ for (const b of row.querySelectorAll('button')) b.disabled = true;++ try {++ await apiFetch(`/api/v1/confirmations/${encodeURIComponent(id)}/${action}`, { method: 'POST', body: JSON.stringify(action === 'approve' ? { standing_rule: 'once' } : {}) });++ approvalsState.items = approvalsState.items.filter((c) => c.id !== id);++ renderApprovals();++ toast(action === 'approve' ? 'Approved: the agent can go ahead once' : 'Denied: the agent was told no', 'success');++ } catch (e) {++ for (const b of row.querySelectorAll('button')) b.disabled = false;++ // 409: it was decided elsewhere or lapsed meanwhile; reload so the list says what is true.++ if (e.status === 409) { toast('That request is no longer pending', 'info'); return loadApprovals(); }++ toast(`Could not ${action}: ${e.message}`, 'error');++ }++}++++async function loadApprovals() {++ if (approvalsState.loading) return;++ // An anonymous session has no confirmations to read (the API needs a full account): say so, call nothing.++ if (typeof isAnonSession === 'function' && isAnonSession()) {++ document.getElementById('approvals-list')?.replaceChildren(approvalsEl('p', { class: 'approvals-empty', text: 'Approvals are only available once you sign in with a full OpenVibe account.' }));++ return;++ }++ approvalsState.loading = true;++ const list = document.getElementById('approvals-list');++ if (list && !approvalsState.items.length) list.replaceChildren(approvalsEl('p', { class: 'approvals-empty', text: 'Loading…' }));++ try {++ const r = await apiFetch('/api/v1/confirmations?state=pending&limit=100');++ approvalsState.items = r.confirmations || [];++ approvalsState.agents = r.agents || {};++ renderApprovals();++ } catch (e) {++ renderApprovals(e.message);++ } finally {++ approvalsState.loading = false;++ }++}++++// The tab shows how many wait even before it is opened (a quiet read; a failure leaves the badge off).++async function refreshApprovalsBadge() {++ if (typeof isAnonSession === 'function' && isAnonSession()) return;++ try { const r = await apiFetch('/api/v1/confirmations?state=pending&limit=100'); setApprovalsBadge((r.confirmations || []).filter((c) => c.state === 'pending').length); } catch { /* signed out or offline: no badge */ }++}@@@ -9,3 +9,4 @@ OpenVibeAccountSwitcher.init({ apiBase: API });loadUser();buildDashboardShowcase();openInitialSection();++refreshApprovalsBadge();@@@ -158,13 +158,14 @@ function showSection(id, btn) {history.replaceState(null, '', id === 'profile' ? window.location.pathname : `${window.location.pathname}#${id}`);if (id === 'history' && typeof loadHistory === 'function') loadHistory();if (id === 'data') loadDataSection();++ if (id === 'approvals' && typeof loadApprovals === 'function') loadApprovals();}function openInitialSection() {const pathSection = { '/themes': 'themes', '/notifications': 'notifications', '/verify-email': 'notifications', '/linked': 'linked', '/security': 'security', '/profile': 'profile', '/history': 'history' }[window.location.pathname];const routeSection = pathSection || window.location.hash.replace(/^#/, '') || 'profile';if (window.location.pathname === '/verify-email') setTimeout(handleVerifyEmailToken, 50);-- if (isAnonSession() && ['notifications','linked','security','data'].includes(routeSection)) {++ if (isAnonSession() && ['notifications','linked','security','data','approvals'].includes(routeSection)) {return showSection('anon');}if (document.getElementById('sec-' + routeSection)) {@@@ -199,7 +200,8 @@ function applyAnonModeUI(user) {const notifTab = [...document.querySelectorAll('.section-tabs button')].find((button) => button.getAttribute('onclick')?.includes("'notifications'"));const linkedTab = [...document.querySelectorAll('.section-tabs button')].find((button) => button.getAttribute('onclick')?.includes("'linked'"));const securityTab = [...document.querySelectorAll('.section-tabs button')].find((button) => button.getAttribute('onclick')?.includes("'security'"));-- [notifTab, linkedTab, securityTab].forEach((button) => {++ const approvalsTab = [...document.querySelectorAll('.section-tabs button')].find((button) => button.getAttribute('onclick')?.includes("'approvals'"));++ [notifTab, linkedTab, securityTab, approvalsTab].forEach((button) => {if (!button) return;button.style.opacity = '.45';button.title = 'Unavailable in anonymous mode';@@@ -208,6 +210,7 @@ function applyAnonModeUI(user) {setSectionDisabled('notifications', 'Notification inbox, email preferences, and alert delivery are only available for full accounts.');setSectionDisabled('linked', 'Linked services are only available once you sign in with a full OpenVibe account.');setSectionDisabled('security', 'Anonymous identities do not have passwords or device sessions to manage.');++ setSectionDisabled('approvals', 'Approvals for your agents are only available for full accounts.');const themeGrid = document.getElementById('theme-grid');if (themeGrid) {@@@ -68,6 +68,18 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa.section-tabs button.active{background:var(--bg-card);color:var(--accent-light)}.section-tabs button:hover:not(.active){color:var(--text)}++/* Approvals (my/approvals.js): one row per waiting request, and the count on its tab */++.section-tabs .tab-badge{display:inline-block;min-width:18px;padding:0 6px;border-radius:9px;background:var(--accent);color:#fff;font-size:11px;line-height:18px;text-align:center}++.approval{border:1px solid var(--border);border-radius:10px;padding:14px 16px;margin-bottom:10px;background:var(--bg-secondary)}++.approval-summary{font-weight:600;color:var(--text);margin-bottom:6px;overflow-wrap:anywhere}++.approval-meta{font-size:12px;color:var(--text-secondary);margin-bottom:10px;overflow-wrap:anywhere}++.approval-who{color:var(--text);font-weight:600}++.approval-cap{font-size:11px;padding:1px 6px;border-radius:5px;background:var(--bg-input);border:1px solid var(--border)}++.approval-resources{list-style:none;display:flex;flex-wrap:wrap;gap:6px;margin:0 0 10px;padding:0}++.approval-resources li{font-size:11px;padding:2px 8px;border-radius:6px;background:var(--bg-input);color:var(--text-secondary);overflow-wrap:anywhere}++.approval .btn-row{display:flex;gap:8px;flex-wrap:wrap}++.approvals-empty{color:var(--text-secondary);font-size:13px}++/* Section */.section{display:none;animation:sec-in .2s ease}.section.active{display:block}@@@ -304,6 +316,7 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat<button onclick="showSection('notifications',this)"><i class="fa-solid fa-bell"></i> Notifications</button><button onclick="showSection('history',this)"><i class="fa-solid fa-clock-rotate-left"></i> History</button><button onclick="showSection('data',this)"><i class="fa-solid fa-database"></i> AI & Data</button>++ <button onclick="showSection('approvals',this)"><i class="fa-solid fa-user-check"></i> Approvals</button><button onclick="showSection('linked',this)"><i class="fa-solid fa-link"></i> Linked Services</button><button onclick="showSection('security',this)"><i class="fa-solid fa-lock"></i> Security</button><button onclick="showSection('anon',this)"><i class="fa-solid fa-user-secret"></i> Anon Info</button>@@@ -501,6 +514,16 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat</div><!-- Linked Services Section -->++ <div class="section" id="sec-approvals">++ <div class="card">++ <h3><span class="icon"><i class="fa-solid fa-user-check"></i></span> Approvals</h3>++ <p style="margin-bottom:12px;color:var(--text-secondary);font-size:13px">Agents you run ask here before they use a sensitive permission on your behalf. Approve lets it go ahead once; Deny tells it no. A request you leave alone expires by itself.</p>++ <div id="approvals-list" aria-live="polite">++ <!-- Populated by JS (my/approvals.js) -->++ </div>++ </div>++ </div>++<div class="section" id="sec-linked"><div class="card"><h3><span class="icon"><i class="fa-solid fa-link"></i></span> Linked Services</h3>@@@ -586,6 +609,7 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat<script src="/js/my/themes.js" defer></script><script src="/js/my/profile.js" defer></script><script src="/js/my/security.js" defer></script>++<script src="/js/my/approvals.js" defer></script><script src="/js/my/boot.js" defer></script><script src="https://openvibe.live/live-notify.js" async></script></body>@@@ -184,7 +184,7 @@ async function notifyOwner(db, { owner, name }, summary) {const u = await db.prepare('SELECT id FROM users WHERE subject_id = ?').get(owner);if (!u) return;const message = `${String(name || 'An agent').slice(0, 80)}: ${summary}`.slice(0, 200);-- await notifier.create({ user_id: u.id, type: 'CONFIRMATION_REQUESTED', title: 'Approval needed', message, url: 'https://openvibe.network/my', service: 'network' });++ await notifier.create({ user_id: u.id, type: 'CONFIRMATION_REQUESTED', title: 'Approval needed', message, url: 'https://openvibe.network/my#approvals', service: 'network' });} catch (err) {console.warn('[Confirmations] owner notify failed:', err.message);}@@@ -428,7 +428,7 @@ for (const [id, name] of PEOPLE) T[name] = jwt.sign({ sub: id, id }, keys.privatassert.strictEqual(n1.state, 'pending');assert.strictEqual(calls.length, 1);const { message, ...rest } = calls[0];-- assert.deepStrictEqual(rest, { user_id: 10, type: 'CONFIRMATION_REQUESTED', title: 'Approval needed', url: 'https://openvibe.network/my', service: 'network' });++ assert.deepStrictEqual(rest, { user_id: 10, type: 'CONFIRMATION_REQUESTED', title: 'Approval needed', url: 'https://openvibe.network/my#approvals', service: 'network' });assert.strictEqual(message, `Bot: ${SUMMARY}`);assert.ok(!message.includes('private-detail-text'));await approve(n1, 'owner', { standing_rule: 'always' });@@@ -12,7 +12,7 @@ for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) asconst files = fs.readdirSync(path.join(pub, 'js', 'my')).filter((f) => f.endsWith('.js')).sort();// The module map, in load order (public/js/my/…).--const modules = ['core.js', 'tools.js', 'themes.js', 'profile.js', 'security.js', 'boot.js'];++const modules = ['core.js', 'tools.js', 'themes.js', 'profile.js', 'security.js', 'approvals.js', 'boot.js'];assert.deepStrictEqual(files, [...modules].sort(), 'each module file exists and there is no other');const tags = [...html.matchAll(/<script\b([^>]*)\bsrc="\/js\/my\/([^"]+)"([^>]*)><\/script>/g)];const loaded = tags.map((m) => m[2]);@@@ -32,4 +32,22 @@ assert.ok(owner.size > 70, 'the modules declare the hub functions');// Every section calls these, so they load first.for (const name of ['_bootstrapFromCookie', 'getAuthToken', 'apiFetch', 'showSection']) assert.strictEqual(owner.get(name), 'core.js', `${name} lives in core.js`);++// The Approvals section (T2 WS-Z2): its tab, its section and its list, and the module builds rows from DOM nodes, never++// innerHTML (summaries, names and resources come from agents).++assert.ok(html.includes('id="sec-approvals"') && html.includes('id="approvals-list"') && html.includes("showSection('approvals',this)"), 'the Approvals tab and section are in my.html');++const approvals = fs.readFileSync(path.join(pub, 'js', 'my', 'approvals.js'), 'utf8');++assert.ok(!/innerHTML|insertAdjacentHTML/.test(approvals), 'approvals.js never parses agent text as HTML');++for (const name of ['loadApprovals', 'decideApproval', 'refreshApprovalsBadge']) assert.strictEqual(owner.get(name), 'approvals.js', `${name} lives in approvals.js`);++assert.ok(fs.readFileSync(path.join(pub, 'js', 'my', 'boot.js'), 'utf8').includes('refreshApprovalsBadge()'), 'boot.js shows the waiting count on the tab');++// The host is a SubjectRef object: the row names its id, never "[object Object]"; an anonymous session calls no API.++{++ const vm = require('vm');++ const sandbox = { document: { createElement: () => ({}) }, console };++ vm.createContext(sandbox); vm.runInContext(approvals, sandbox);++ assert.strictEqual(sandbox.approvalsHost({ host: { type: 'app', id: 'app_01' } }), 'app_01');++ assert.strictEqual(sandbox.approvalsHost({ host: 'node-1' }), 'node-1');++ assert.strictEqual(sandbox.approvalsHost({}), '');++ assert.ok(/isAnonSession\(\)/.test(approvals.slice(approvals.indexOf('async function loadApprovals'))), 'loadApprovals checks for an anonymous session first');++}++console.log('my-modules: all checks passed');
new file mode 100644
new file mode 100644
@@ -0,0 +1,130 @@
@@ -0,0 +1,130 @@
+/* ═══════════════════════════════════════════════════════════════
+ OpenVibe.Network — account hub (/my): Approvals (plan T2 WS-Z2).
+ The owner's confirmation inbox: an agent acting for this person asks before
+ a sensitive capability runs (server/developer/confirmations.js). Lists the
+ pending requests from GET /api/v1/confirmations; Approve and Deny post to
+ /:id/approve and /:id/deny. Built from DOM nodes: summaries, names and
+ resources come from agents, so nothing here is parsed as HTML.
+ Classic script, global scope; relies on my/core.js (apiFetch, toast).
+ ═══════════════════════════════════════════════════════════════ */
+let approvalsState = { loading: false, items: [], agents: {} };
+
+function approvalsEl(tag, attrs, ...kids) {
+ const n = document.createElement(tag);
+ for (const [k, v] of Object.entries(attrs || {})) {
+ if (v == null) continue;
+ if (k === 'class') n.className = v; else if (k === 'text') n.textContent = v; else if (k.startsWith('on')) n.addEventListener(k.slice(2), v); else n.setAttribute(k, v);
+ }
+ for (const kid of kids) if (kid != null) n.append(kid);
+ return n;
+}
+
+// "in 4 min", "in 1 h 5 min", "expired": the request lapses by itself (expires_at), so the owner sees how long is left.
+function approvalsLeft(iso) {
+ const ms = Date.parse(iso) - Date.now();
+ if (!Number.isFinite(ms) || ms <= 0) return 'expired';
+ const m = Math.ceil(ms / 60000);
+ return m < 60 ? `${m} min left` : `${Math.floor(m / 60)} h ${m % 60} min left`;
+}
+
+// The agent's host is a SubjectRef ({ type: 'app', id: 'app_…' }) from agentContext; a string is taken as is.
+function approvalsHost(agent) {
+ const h = agent && agent.host;
+ if (!h) return '';
+ return typeof h === 'string' ? h : String(h.id || h.type || '');
+}
+
+function setApprovalsBadge(n) {
+ const btn = [...document.querySelectorAll('.section-tabs button')].find((b) => b.getAttribute('onclick')?.includes("'approvals'"));
+ if (!btn) return;
+ let badge = btn.querySelector('.tab-badge');
+ if (!n) { badge?.remove(); return; }
+ if (!badge) { badge = approvalsEl('span', { class: 'tab-badge', 'aria-label': 'waiting for you' }); btn.append(' ', badge); }
+ badge.textContent = String(n);
+}
+
+function renderApprovals(error) {
+ const list = document.getElementById('approvals-list');
+ if (!list) return;
+ list.replaceChildren();
+ if (error) {
+ list.append(approvalsEl('div', { class: 'approvals-empty', role: 'alert' },
+ approvalsEl('p', { text: `Could not load your approvals: ${error}` }),
+ approvalsEl('button', { class: 'btn', type: 'button', text: 'Try again', onclick: () => loadApprovals() })));
+ return;
+ }
+ const items = approvalsState.items.filter((c) => c.state === 'pending');
+ setApprovalsBadge(items.length);
+ if (!items.length) {
+ list.append(approvalsEl('p', { class: 'approvals-empty', text: 'Nothing is waiting for you. When an agent asks to do something sensitive on your behalf, it shows up here.' }));
+ return;
+ }
+ for (const c of items) list.append(approvalRow(c));
+}
+
+function approvalRow(c) {
+ const agent = approvalsState.agents[c.requested_by && c.requested_by.id] || null;
+ const who = agent ? agent.name : (c.requested_by && c.requested_by.id) || 'An agent';
+ const row = approvalsEl('div', { class: 'approval', 'data-id': c.id });
+ const meta = approvalsEl('div', { class: 'approval-meta' },
+ approvalsEl('span', { class: 'approval-who', text: who }),
+ approvalsHost(agent) ? approvalsEl('span', { class: 'approval-host', text: ` on ${approvalsHost(agent)}` }) : null,
+ approvalsEl('span', { class: 'approval-sep', text: ' · ' }),
+ approvalsEl('code', { class: 'approval-cap', text: c.capability }),
+ approvalsEl('span', { class: 'approval-sep', text: ' · ' }),
+ approvalsEl('span', { class: 'approval-left', text: approvalsLeft(c.expires_at) }));
+ const resources = Array.isArray(c.resources) && c.resources.length
+ ? approvalsEl('ul', { class: 'approval-resources', 'aria-label': 'What it touches' },
+ ...c.resources.slice(0, 8).map((r) => approvalsEl('li', { text: typeof r === 'string' ? r : [r.type, r.id || r.name].filter(Boolean).join(' ') })))
+ : null;
+ const approve = approvalsEl('button', { class: 'btn btn-primary', type: 'button', onclick: () => decideApproval(c.id, 'approve', row) },
+ approvalsEl('i', { class: 'fa-solid fa-check', 'aria-hidden': 'true' }), ' Approve once');
+ const deny = approvalsEl('button', { class: 'btn', type: 'button', onclick: () => decideApproval(c.id, 'deny', row) },
+ approvalsEl('i', { class: 'fa-solid fa-xmark', 'aria-hidden': 'true' }), ' Deny');
+ row.append(approvalsEl('p', { class: 'approval-summary', text: c.summary || c.capability }), meta, resources,
+ approvalsEl('div', { class: 'btn-row' }, approve, deny));
+ return row;
+}
+
+async function decideApproval(id, action, row) {
+ for (const b of row.querySelectorAll('button')) b.disabled = true;
+ try {
+ await apiFetch(`/api/v1/confirmations/${encodeURIComponent(id)}/${action}`, { method: 'POST', body: JSON.stringify(action === 'approve' ? { standing_rule: 'once' } : {}) });
+ approvalsState.items = approvalsState.items.filter((c) => c.id !== id);
+ renderApprovals();
+ toast(action === 'approve' ? 'Approved: the agent can go ahead once' : 'Denied: the agent was told no', 'success');
+ } catch (e) {
+ for (const b of row.querySelectorAll('button')) b.disabled = false;
+ // 409: it was decided elsewhere or lapsed meanwhile; reload so the list says what is true.
+ if (e.status === 409) { toast('That request is no longer pending', 'info'); return loadApprovals(); }
+ toast(`Could not ${action}: ${e.message}`, 'error');
+ }
+}
+
+async function loadApprovals() {
+ if (approvalsState.loading) return;
+ // An anonymous session has no confirmations to read (the API needs a full account): say so, call nothing.
+ if (typeof isAnonSession === 'function' && isAnonSession()) {
+ document.getElementById('approvals-list')?.replaceChildren(approvalsEl('p', { class: 'approvals-empty', text: 'Approvals are only available once you sign in with a full OpenVibe account.' }));
+ return;
+ }
+ approvalsState.loading = true;
+ const list = document.getElementById('approvals-list');
+ if (list && !approvalsState.items.length) list.replaceChildren(approvalsEl('p', { class: 'approvals-empty', text: 'Loading…' }));
+ try {
+ const r = await apiFetch('/api/v1/confirmations?state=pending&limit=100');
+ approvalsState.items = r.confirmations || [];
+ approvalsState.agents = r.agents || {};
+ renderApprovals();
+ } catch (e) {
+ renderApprovals(e.message);
+ } finally {
+ approvalsState.loading = false;
+ }
+}
+
+// The tab shows how many wait even before it is opened (a quiet read; a failure leaves the badge off).
+async function refreshApprovalsBadge() {
+ if (typeof isAnonSession === 'function' && isAnonSession()) return;
+ try { const r = await apiFetch('/api/v1/confirmations?state=pending&limit=100'); setApprovalsBadge((r.confirmations || []).filter((c) => c.state === 'pending').length); } catch { /* signed out or offline: no badge */ }
+}
@@ -9,3 +9,4 @@ OpenVibeAccountSwitcher.init({ apiBase: API });
@@ -9,3 +9,4 @@ OpenVibeAccountSwitcher.init({ apiBase: API });
loadUser();
loadUser();
buildDashboardShowcase();
buildDashboardShowcase();
openInitialSection();
openInitialSection();
+refreshApprovalsBadge();
@@ -158,13 +158,14 @@ function showSection(id, btn) {
@@ -158,13 +158,14 @@ function showSection(id, btn) {
history.replaceState(null, '', id === 'profile' ? window.location.pathname : `${window.location.pathname}#${id}`);
history.replaceState(null, '', id === 'profile' ? window.location.pathname : `${window.location.pathname}#${id}`);
if (id === 'history' && typeof loadHistory === 'function') loadHistory();
if (id === 'history' && typeof loadHistory === 'function') loadHistory();
if (id === 'data') loadDataSection();
if (id === 'data') loadDataSection();
+ if (id === 'approvals' && typeof loadApprovals === 'function') loadApprovals();
}
}
function openInitialSection() {
function openInitialSection() {
const pathSection = { '/themes': 'themes', '/notifications': 'notifications', '/verify-email': 'notifications', '/linked': 'linked', '/security': 'security', '/profile': 'profile', '/history': 'history' }[window.location.pathname];
const pathSection = { '/themes': 'themes', '/notifications': 'notifications', '/verify-email': 'notifications', '/linked': 'linked', '/security': 'security', '/profile': 'profile', '/history': 'history' }[window.location.pathname];
const routeSection = pathSection || window.location.hash.replace(/^#/, '') || 'profile';
const routeSection = pathSection || window.location.hash.replace(/^#/, '') || 'profile';
if (window.location.pathname === '/verify-email') setTimeout(handleVerifyEmailToken, 50);
if (window.location.pathname === '/verify-email') setTimeout(handleVerifyEmailToken, 50);
- if (isAnonSession() && ['notifications','linked','security','data'].includes(routeSection)) {
+ if (isAnonSession() && ['notifications','linked','security','data','approvals'].includes(routeSection)) {
return showSection('anon');
return showSection('anon');
}
}
if (document.getElementById('sec-' + routeSection)) {
if (document.getElementById('sec-' + routeSection)) {
@@ -199,7 +200,8 @@ function applyAnonModeUI(user) {
@@ -199,7 +200,8 @@ function applyAnonModeUI(user) {
const notifTab = [...document.querySelectorAll('.section-tabs button')].find((button) => button.getAttribute('onclick')?.includes("'notifications'"));
const notifTab = [...document.querySelectorAll('.section-tabs button')].find((button) => button.getAttribute('onclick')?.includes("'notifications'"));
const linkedTab = [...document.querySelectorAll('.section-tabs button')].find((button) => button.getAttribute('onclick')?.includes("'linked'"));
const linkedTab = [...document.querySelectorAll('.section-tabs button')].find((button) => button.getAttribute('onclick')?.includes("'linked'"));
const securityTab = [...document.querySelectorAll('.section-tabs button')].find((button) => button.getAttribute('onclick')?.includes("'security'"));
const securityTab = [...document.querySelectorAll('.section-tabs button')].find((button) => button.getAttribute('onclick')?.includes("'security'"));
- [notifTab, linkedTab, securityTab].forEach((button) => {
+ const approvalsTab = [...document.querySelectorAll('.section-tabs button')].find((button) => button.getAttribute('onclick')?.includes("'approvals'"));
+ [notifTab, linkedTab, securityTab, approvalsTab].forEach((button) => {
if (!button) return;
if (!button) return;
button.style.opacity = '.45';
button.style.opacity = '.45';
button.title = 'Unavailable in anonymous mode';
button.title = 'Unavailable in anonymous mode';
@@ -208,6 +210,7 @@ function applyAnonModeUI(user) {
@@ -208,6 +210,7 @@ function applyAnonModeUI(user) {
setSectionDisabled('notifications', 'Notification inbox, email preferences, and alert delivery are only available for full accounts.');
setSectionDisabled('notifications', 'Notification inbox, email preferences, and alert delivery are only available for full accounts.');
setSectionDisabled('linked', 'Linked services are only available once you sign in with a full OpenVibe account.');
setSectionDisabled('linked', 'Linked services are only available once you sign in with a full OpenVibe account.');
setSectionDisabled('security', 'Anonymous identities do not have passwords or device sessions to manage.');
setSectionDisabled('security', 'Anonymous identities do not have passwords or device sessions to manage.');
+ setSectionDisabled('approvals', 'Approvals for your agents are only available for full accounts.');
const themeGrid = document.getElementById('theme-grid');
const themeGrid = document.getElementById('theme-grid');
if (themeGrid) {
if (themeGrid) {
@@ -68,6 +68,18 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa
@@ -68,6 +68,18 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa
.section-tabs button.active{background:var(--bg-card);color:var(--accent-light)}
.section-tabs button.active{background:var(--bg-card);color:var(--accent-light)}
.section-tabs button:hover:not(.active){color:var(--text)}
.section-tabs button:hover:not(.active){color:var(--text)}
+/* Approvals (my/approvals.js): one row per waiting request, and the count on its tab */
+.section-tabs .tab-badge{display:inline-block;min-width:18px;padding:0 6px;border-radius:9px;background:var(--accent);color:#fff;font-size:11px;line-height:18px;text-align:center}
+.approval{border:1px solid var(--border);border-radius:10px;padding:14px 16px;margin-bottom:10px;background:var(--bg-secondary)}
+.approval-summary{font-weight:600;color:var(--text);margin-bottom:6px;overflow-wrap:anywhere}
+.approval-meta{font-size:12px;color:var(--text-secondary);margin-bottom:10px;overflow-wrap:anywhere}
+.approval-who{color:var(--text);font-weight:600}
+.approval-cap{font-size:11px;padding:1px 6px;border-radius:5px;background:var(--bg-input);border:1px solid var(--border)}
+.approval-resources{list-style:none;display:flex;flex-wrap:wrap;gap:6px;margin:0 0 10px;padding:0}
+.approval-resources li{font-size:11px;padding:2px 8px;border-radius:6px;background:var(--bg-input);color:var(--text-secondary);overflow-wrap:anywhere}
+.approval .btn-row{display:flex;gap:8px;flex-wrap:wrap}
+.approvals-empty{color:var(--text-secondary);font-size:13px}
+
/* Section */
/* Section */
.section{display:none;animation:sec-in .2s ease}
.section{display:none;animation:sec-in .2s ease}
.section.active{display:block}
.section.active{display:block}
@@ -304,6 +316,7 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
@@ -304,6 +316,7 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
<button onclick="showSection('notifications',this)"><i class="fa-solid fa-bell"></i> Notifications</button>
<button onclick="showSection('notifications',this)"><i class="fa-solid fa-bell"></i> Notifications</button>
<button onclick="showSection('history',this)"><i class="fa-solid fa-clock-rotate-left"></i> History</button>
<button onclick="showSection('history',this)"><i class="fa-solid fa-clock-rotate-left"></i> History</button>
<button onclick="showSection('data',this)"><i class="fa-solid fa-database"></i> AI & Data</button>
<button onclick="showSection('data',this)"><i class="fa-solid fa-database"></i> AI & Data</button>
+ <button onclick="showSection('approvals',this)"><i class="fa-solid fa-user-check"></i> Approvals</button>
<button onclick="showSection('linked',this)"><i class="fa-solid fa-link"></i> Linked Services</button>
<button onclick="showSection('linked',this)"><i class="fa-solid fa-link"></i> Linked Services</button>
<button onclick="showSection('security',this)"><i class="fa-solid fa-lock"></i> Security</button>
<button onclick="showSection('security',this)"><i class="fa-solid fa-lock"></i> Security</button>
<button onclick="showSection('anon',this)"><i class="fa-solid fa-user-secret"></i> Anon Info</button>
<button onclick="showSection('anon',this)"><i class="fa-solid fa-user-secret"></i> Anon Info</button>
@@ -501,6 +514,16 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
@@ -501,6 +514,16 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
</div>
</div>
<!-- Linked Services Section -->
<!-- Linked Services Section -->
+ <div class="section" id="sec-approvals">
+ <div class="card">
+ <h3><span class="icon"><i class="fa-solid fa-user-check"></i></span> Approvals</h3>
+ <p style="margin-bottom:12px;color:var(--text-secondary);font-size:13px">Agents you run ask here before they use a sensitive permission on your behalf. Approve lets it go ahead once; Deny tells it no. A request you leave alone expires by itself.</p>
+ <div id="approvals-list" aria-live="polite">
+ <!-- Populated by JS (my/approvals.js) -->
+ </div>
+ </div>
+ </div>
+
<div class="section" id="sec-linked">
<div class="section" id="sec-linked">
<div class="card">
<div class="card">
<h3><span class="icon"><i class="fa-solid fa-link"></i></span> Linked Services</h3>
<h3><span class="icon"><i class="fa-solid fa-link"></i></span> Linked Services</h3>
@@ -586,6 +609,7 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
@@ -586,6 +609,7 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
<script src="/js/my/themes.js" defer></script>
<script src="/js/my/themes.js" defer></script>
<script src="/js/my/profile.js" defer></script>
<script src="/js/my/profile.js" defer></script>
<script src="/js/my/security.js" defer></script>
<script src="/js/my/security.js" defer></script>
+<script src="/js/my/approvals.js" defer></script>
<script src="/js/my/boot.js" defer></script>
<script src="/js/my/boot.js" defer></script>
<script src="https://openvibe.live/live-notify.js" async></script>
<script src="https://openvibe.live/live-notify.js" async></script>
</body>
</body>
@@ -184,7 +184,7 @@ async function notifyOwner(db, { owner, name }, summary) {
@@ -184,7 +184,7 @@ async function notifyOwner(db, { owner, name }, summary) {
const u = await db.prepare('SELECT id FROM users WHERE subject_id = ?').get(owner);
const u = await db.prepare('SELECT id FROM users WHERE subject_id = ?').get(owner);
if (!u) return;
if (!u) return;
const message = `${String(name || 'An agent').slice(0, 80)}: ${summary}`.slice(0, 200);
const message = `${String(name || 'An agent').slice(0, 80)}: ${summary}`.slice(0, 200);
- await notifier.create({ user_id: u.id, type: 'CONFIRMATION_REQUESTED', title: 'Approval needed', message, url: 'https://openvibe.network/my', service: 'network' });
+ await notifier.create({ user_id: u.id, type: 'CONFIRMATION_REQUESTED', title: 'Approval needed', message, url: 'https://openvibe.network/my#approvals', service: 'network' });
} catch (err) {
} catch (err) {
console.warn('[Confirmations] owner notify failed:', err.message);
console.warn('[Confirmations] owner notify failed:', err.message);
}
}
@@ -428,7 +428,7 @@ for (const [id, name] of PEOPLE) T[name] = jwt.sign({ sub: id, id }, keys.privat
@@ -428,7 +428,7 @@ for (const [id, name] of PEOPLE) T[name] = jwt.sign({ sub: id, id }, keys.privat
assert.strictEqual(n1.state, 'pending');
assert.strictEqual(n1.state, 'pending');
assert.strictEqual(calls.length, 1);
assert.strictEqual(calls.length, 1);
const { message, ...rest } = calls[0];
const { message, ...rest } = calls[0];
- assert.deepStrictEqual(rest, { user_id: 10, type: 'CONFIRMATION_REQUESTED', title: 'Approval needed', url: 'https://openvibe.network/my', service: 'network' });
+ assert.deepStrictEqual(rest, { user_id: 10, type: 'CONFIRMATION_REQUESTED', title: 'Approval needed', url: 'https://openvibe.network/my#approvals', service: 'network' });
assert.strictEqual(message, `Bot: ${SUMMARY}`);
assert.strictEqual(message, `Bot: ${SUMMARY}`);
assert.ok(!message.includes('private-detail-text'));
assert.ok(!message.includes('private-detail-text'));
await approve(n1, 'owner', { standing_rule: 'always' });
await approve(n1, 'owner', { standing_rule: 'always' });
@@ -12,7 +12,7 @@ for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) as
@@ -12,7 +12,7 @@ for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) as
const files = fs.readdirSync(path.join(pub, 'js', 'my')).filter((f) => f.endsWith('.js')).sort();
const files = fs.readdirSync(path.join(pub, 'js', 'my')).filter((f) => f.endsWith('.js')).sort();
// The module map, in load order (public/js/my/…).
// The module map, in load order (public/js/my/…).
-const modules = ['core.js', 'tools.js', 'themes.js', 'profile.js', 'security.js', 'boot.js'];
+const modules = ['core.js', 'tools.js', 'themes.js', 'profile.js', 'security.js', 'approvals.js', 'boot.js'];
assert.deepStrictEqual(files, [...modules].sort(), 'each module file exists and there is no other');
assert.deepStrictEqual(files, [...modules].sort(), 'each module file exists and there is no other');
const tags = [...html.matchAll(/<script\b([^>]*)\bsrc="\/js\/my\/([^"]+)"([^>]*)><\/script>/g)];
const tags = [...html.matchAll(/<script\b([^>]*)\bsrc="\/js\/my\/([^"]+)"([^>]*)><\/script>/g)];
const loaded = tags.map((m) => m[2]);
const loaded = tags.map((m) => m[2]);
@@ -32,4 +32,22 @@ assert.ok(owner.size > 70, 'the modules declare the hub functions');
@@ -32,4 +32,22 @@ assert.ok(owner.size > 70, 'the modules declare the hub functions');
// Every section calls these, so they load first.
// Every section calls these, so they load first.
for (const name of ['_bootstrapFromCookie', 'getAuthToken', 'apiFetch', 'showSection']) assert.strictEqual(owner.get(name), 'core.js', `${name} lives in core.js`);
for (const name of ['_bootstrapFromCookie', 'getAuthToken', 'apiFetch', 'showSection']) assert.strictEqual(owner.get(name), 'core.js', `${name} lives in core.js`);
+// The Approvals section (T2 WS-Z2): its tab, its section and its list, and the module builds rows from DOM nodes, never
+// innerHTML (summaries, names and resources come from agents).
+assert.ok(html.includes('id="sec-approvals"') && html.includes('id="approvals-list"') && html.includes("showSection('approvals',this)"), 'the Approvals tab and section are in my.html');
+const approvals = fs.readFileSync(path.join(pub, 'js', 'my', 'approvals.js'), 'utf8');
+assert.ok(!/innerHTML|insertAdjacentHTML/.test(approvals), 'approvals.js never parses agent text as HTML');
+for (const name of ['loadApprovals', 'decideApproval', 'refreshApprovalsBadge']) assert.strictEqual(owner.get(name), 'approvals.js', `${name} lives in approvals.js`);
+assert.ok(fs.readFileSync(path.join(pub, 'js', 'my', 'boot.js'), 'utf8').includes('refreshApprovalsBadge()'), 'boot.js shows the waiting count on the tab');
+// The host is a SubjectRef object: the row names its id, never "[object Object]"; an anonymous session calls no API.
+{
+ const vm = require('vm');
+ const sandbox = { document: { createElement: () => ({}) }, console };
+ vm.createContext(sandbox); vm.runInContext(approvals, sandbox);
+ assert.strictEqual(sandbox.approvalsHost({ host: { type: 'app', id: 'app_01' } }), 'app_01');
+ assert.strictEqual(sandbox.approvalsHost({ host: 'node-1' }), 'node-1');
+ assert.strictEqual(sandbox.approvalsHost({}), '');
+ assert.ok(/isAnonSession\(\)/.test(approvals.slice(approvals.indexOf('async function loadApprovals'))), 'loadApprovals checks for an anonymous session first');
+}
+
console.log('my-modules: all checks passed');
console.log('my-modules: all checks passed');