openvibers/openvibe.network Commits

Reorganize account home and machine pairing (#810)

OP OpenVibers committed 2 days ago · 3bc5d9d7f3 · 14 files, +217 −86

* Reorganize account home and machine pairing

* Keep Machines section clear of fixed navigation

* Use supported OS glyphs in machine cards

* Merge remote-tracking branch 'origin/main' into cx-my-home-r2

* Merge remote-tracking branch 'origin/main' into cx-my-home-r2

# Conflicts:
#	server/my/routes.js

Changes

ddiff --git a/public/css/my.css b/public/css/my.css
iindex f66f0480..d257daba 100644
---- a/public/css/my.css
++++ b/public/css/my.css
@@@ -714,3 +714,35 @@ html[data-ov-motion=off] .me-act:hover{transform: none}
.mc-desk-live summary { display: inline-block; cursor: pointer; }
.mc-desk-live[open] { display: grid; gap: 8px; }
.mc-takeover { margin-top: 4px; }
++
++/* Account home organization and pairing visibility. */
++@media (max-width:700px) { body { padding-bottom:112px; } }
++@media (min-width:760px) { .td-grid { grid-template-columns:minmax(0,1fr); } }
++.td-machines { display:grid; gap:10px; min-width:0; }
++.td-machines p { color:var(--text-secondary); font-size:13px; }
++.td-machines .ovk-btn { justify-self:start; min-height:44px; }
++.setup-fold { margin:0 0 18px; padding:12px 16px; border:1px solid var(--border); border-radius:14px; background:var(--bg-secondary); }
++.setup-fold[hidden] { display:none !important; }
++.setup-fold summary { min-height:28px; font-weight:700; cursor:pointer; }
++.setup-items { display:grid; gap:4px; padding-top:6px; }
++.ny-actions { display:flex; flex-wrap:wrap; align-items:center; gap:8px; }
++.ny-details { flex:1 1 100%; font-size:12px; color:var(--text-muted); }
++.ny-details summary { width:max-content; min-height:36px; cursor:pointer; }
++.ny-details p { margin:2px 0 8px; }
++.ny-command { display:block; width:fit-content; max-width:100%; overflow-wrap:anywhere; padding:7px 10px; border-radius:8px; background:var(--bg-secondary); color:var(--text); font-size:12px; }
++.ny-btn.is-quiet { background:transparent; color:var(--text-secondary); border:1px solid var(--border); }
++.mc-steps { margin:0; padding-left:22px; display:grid; gap:5px; }
++.mc-pair-code-row { display:flex; flex-wrap:wrap; align-items:center; gap:10px; }
++.mc-pair-code-row .mc-code { margin:0; }
++.mc-qr { width:176px; height:176px; border-radius:10px; background:#fff; }
++body.my-needs-visible #ovdk-pill { display:none !important; }
++.mc-action-menu { margin-top: 12px; }
++.mc-action-menu summary { display:inline-flex; align-items:center; min-height:40px; padding:0 13px; border:1px solid var(--border); border-radius:10px; cursor:pointer; font-size:13px; font-weight:700; }
++.mc-action-menu .mc-acts { margin-top:8px; }
++.mc-role-chips { display:flex; flex-wrap:wrap; gap:6px; margin:8px 0; }
++.mc-role-chip { display:inline-flex; align-items:center; min-height:28px; padding:2px 10px; border:1px solid var(--border); border-radius:999px; background:var(--bg-secondary); color:var(--text-secondary); font-size:11px; font-weight:650; }
++.mc-role-chip.is-empty { opacity:.7; }
++.me-acts { margin-bottom: 20px; }
++.mc-count { margin:0 0 8px; color:var(--text-muted); font-size:12px; }
++#sec-machines { scroll-margin-top: 90px; }
++body.my-machines-open { padding-bottom: max(112px, calc(100vh - 90px)); }
ddiff --git a/public/js/my/boot.js b/public/js/my/boot.js
iindex f055ed7f..a260e3ec 100644
---- a/public/js/my/boot.js
++++ b/public/js/my/boot.js
@@@ -14,6 +14,11 @@ OpenVibeAccountSwitcher.init({ apiBase: API, onSwitch: (account) => {
window.location.reload();
} });
const _signedIn = !!(getAuthToken() || getAnonToken());
++const needsYouSection = document.getElementById('needs-you');
++if (needsYouSection && 'IntersectionObserver' in window) {
++ new IntersectionObserver((entries) => document.body.classList.toggle('my-needs-visible', !!entries[0]?.isIntersecting))
++ .observe(needsYouSection);
++}
loadUser();
if (_signedIn) {
loadToday();
ddiff --git a/public/js/my/core.js b/public/js/my/core.js
iindex a3ef20b6..e12a83c3 100644
---- a/public/js/my/core.js
++++ b/public/js/my/core.js
@@@ -155,6 +155,7 @@ async function anonFetch(path, opts = {}) {
// Section switching
function showSection(id, btn) {
++ document.body.classList.toggle('my-machines-open', id === 'machines');
document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
document.querySelectorAll('.section-tabs button').forEach(b => b.classList.remove('active'));
document.getElementById('sec-' + id).classList.add('active');
@@@ -173,7 +174,7 @@ function openInitialSection() {
const pathSection = { '/themes': 'themes', '/notifications': 'notifications', '/verify-email': 'notifications', '/linked': 'linked', '/security': 'security', '/machines': 'machines', '/profile': 'profile', '/history': 'history' }[window.location.pathname];
// #delete is the re-authentication return target: the delete step lives in the accounts section.
const hash = window.location.hash.replace(/^#/, '');
-- const routeSection = pathSection || (hash === 'delete' ? 'accounts' : hash) || 'profile';
++ const routeSection = pathSection || (hash === 'delete' ? 'accounts' : hash === 'pair' ? 'machines' : hash) || 'profile';
if (window.location.pathname === '/verify-email') setTimeout(handleVerifyEmailToken, 50);
if (isAnonSession() && ['notifications','linked','security','data','content','approvals','machines','apps'].includes(routeSection)) {
return showSection('anon');
@@@ -190,6 +191,8 @@ function openInitialSection() {
}
if (document.getElementById('sec-' + routeSection)) {
showSection(routeSection);
++ if (routeSection === 'machines') document.getElementById('sec-machines')?.scrollIntoView?.({ block: 'start' });
++ if (routeSection === 'machines' && hash === 'pair') setTimeout(() => mcPair(), 0);
}
}
ddiff --git a/public/js/my/machines.js b/public/js/my/machines.js
iindex 952b7ad9..5e2905f0 100644
---- a/public/js/my/machines.js
++++ b/public/js/my/machines.js
@@@ -41,9 +41,13 @@ function mcCard(m) {
const dot = mcEl('span', `mc-dot ${m.online ? 'is-on' : ''}`, [], { 'aria-hidden': 'true' });
const name = mcEl('h4', 'mc-name', [m.name || MC_KIND[m.kind] || 'A machine']);
const state = mcEl('span', 'mc-state', [m.online ? 'Online' : (m.last_seen_at ? `Offline · seen ${mcAgo(m.last_seen_at)}` : 'Offline')]);
-- const meta = mcEl('p', 'mc-meta', [[MC_KIND[m.kind], m.os, m.node_version ? `Node ${m.node_version}` : ''].filter(Boolean).join(' · ')]);
++ const os = String(m.os || '').toLowerCase();
++ const osIcon = os.includes('mac') || os.includes('darwin') ? 'fa-apple' : os.includes('linux') ? 'fa-linux' : 'fa-laptop';
++ const meta = mcEl('p', 'mc-meta', [mcIcon(osIcon), ' ', [MC_KIND[m.kind], m.os, m.node_version ? `Node ${m.node_version}` : ''].filter(Boolean).join(' · ')]);
const head = mcEl('div', 'mc-head', [dot, mcEl('div', 'mc-title', [name, state]), meta]);
card.append(head);
++ const roleChips = (m.roles || []).map((id) => (mcRoles || []).find((r) => r.id === id)).filter(Boolean);
++ card.append(mcEl('div', 'mc-role-chips', roleChips.length ? roleChips.map((r) => mcEl('span', 'mc-role-chip', [r.name])) : [mcEl('span', 'mc-role-chip is-empty', ['No roles assigned'])]));
(m.pools || []).forEach((pool) => card.append(mcPoolBlock(pool)));
if (Array.isArray(m.running) && m.running.length) {
@@@ -76,7 +80,10 @@ function mcCard(m) {
const revoke = mcEl('button', 'btn btn-small mc-revoke', [mcIcon('fa-link-slash'), ' Revoke'], { type: 'button' });
revoke.addEventListener('click', () => mcConfirmRevoke(acts, m));
acts.append(rename, revoke);
-- card.append(acts);
++ const menu = mcEl('details', 'mc-action-menu');
++ menu.append(mcEl('summary', '', ['Actions']));
++ menu.append(acts);
++ card.append(menu);
return card;
}
@@@ -516,12 +523,19 @@ async function mcPair() {
const out = await apiFetch('/api/v1/machines/pairings', { method: 'POST', body: '{}' });
const p = (out && out.pairing) || {};
const code = mcEl('p', 'mc-code', [String(p.code || '')]);
++ code.setAttribute('aria-label', 'Pairing code');
++ const copyCode = mcEl('button', 'btn btn-outline btn-small', [mcIcon('fa-copy'), ' Copy code'], { type: 'button' });
++ copyCode.addEventListener('click', () => navigator.clipboard.writeText(String(p.code || '')).then(() => mcSay('Code copied.')));
++ const qr = p.qr_data ? mcEl('img', 'mc-qr', [], { src: p.qr_data, alt: 'Scan this QR code to read the pairing code' }) : null;
const cmd = mcEl('pre', 'mc-cmd', [mcEl('code', '', [String(p.installer || '')])]);
const copy = mcEl('button', 'btn btn-outline btn-small', [mcIcon('fa-copy'), ' Copy the command'], { type: 'button' });
copy.addEventListener('click', () => navigator.clipboard.writeText(String(p.installer || '')).then(() => mcSay('Copied. Run it on the machine.')));
const until = p.expires_at ? mcEl('span', 'mc-until', [`The code works until ${new Date(p.expires_at).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}.`]) : null;
const wait = mcEl('p', 'mc-wait', [mcEl('span', 'mc-wait-dot', [], { 'aria-hidden': 'true' }), 'Waiting for your machine…'], { role: 'status' });
-- box.replaceChildren(mcEl('h4', '', ['Run this on the machine']), mcEl('p', 'muted', ['It installs OpenVibe Link and pairs it with your account. Or type the code into Link yourself:']), code, cmd, mcEl('div', 'mc-pair-acts', [copy, until]), wait);
++ const pairContent = [mcEl('h4', '', ['Pair your computer']), mcEl('ol', 'mc-steps', [mcEl('li', '', ['Open OpenVibe Link on the computer.']), mcEl('li', '', ['Enter this code or scan the QR code.']), mcEl('li', '', ['Give it a name when it connects.'])]), mcEl('div', 'mc-pair-code-row', [code, copyCode])];
++ if (qr) pairContent.push(qr);
++ pairContent.push(mcEl('p', 'muted', ['Or install Link with this command:']), cmd, mcEl('div', 'mc-pair-acts', [copy, until]), wait);
++ box.replaceChildren(...pairContent);
box.hidden = false;
clearInterval(mcWatch);
const t0 = Date.now();
@@@ -551,11 +565,13 @@ async function mcPair() {
async function loadMachines() {
const list = document.getElementById('mc-list');
if (!list) return;
++ const count = document.getElementById('mc-count');
try {
if (!mcRoles) mcRoles = ((await apiFetch('/api/v1/machines/roles').catch(() => null)) || {}).roles || [];
const out = await apiFetch('/api/v1/machines');
const machines = (out && out.machines) || [];
machines.sort((a, b) => (b.online === true) - (a.online === true));
++ if (count) count.textContent = `${machines.length} paired · ${machines.filter((m) => m.online).length} online now`;
if (!machines.length) {
list.replaceChildren(mcEl('div', 'mc-empty', [mcIcon('fa-laptop'), mcEl('p', '', [mcEl('b', '', ['No machines yet.']), ' Pair your computer and it can run your Codes jobs, host your desks and serve your own models, under your account.'])]));
return;
@@@ -566,6 +582,7 @@ async function loadMachines() {
for (const m of machines) m.pools = (pools.pools || []).filter((p) => p.machines.some((x) => x.id === m.id));
list.replaceChildren(...machines.map(mcCard), ...mcPoolPickers(pools.candidates || []));
} catch (e) {
++ if (count) count.textContent = '';
list.replaceChildren(mcEl('p', 'muted', [e.status === 401 ? 'Sign in to see your machines.' : 'Your machines could not be read just now.']));
}
}
ddiff --git a/public/js/my/receipts.js b/public/js/my/receipts.js
iindex 6aa1f5a9..a47b2784 100644
---- a/public/js/my/receipts.js
++++ b/public/js/my/receipts.js
@@@ -67,13 +67,16 @@ function renderReceipts(doc) {
const rows = (doc && Array.isArray(doc.receipts) ? doc.receipts : []).map(rcRow).filter(Boolean);
body.replaceChildren();
if (!enabled) {
-- body.append(rcEl('a', 'rc-quiet', ['Turn on Inbox AI on openvibe.email to see receipts here'], { href: RC_SETTING }));
-- box.hidden = false;
++ box.hidden = true;
++ const fold = document.getElementById('setup-fold');
++ if (fold) { fold.hidden = false; fold.querySelector('summary').textContent = 'Set up more (1)'; fold.querySelector('.setup-items').replaceChildren(rcEl('a', 'rc-quiet', ['Set up receipts with Inbox AI'], { href: RC_SETTING })); }
return;
}
++ const fold = document.getElementById('setup-fold');
++ if (fold) fold.hidden = true;
if (!rows.length) {
body.append(rcEl('p', 'rc-quiet', ['No receipts yet.']));
-- box.hidden = false;
++ box.hidden = true;
return;
}
body.append(...rows.slice(0, RC_SHOWN));
ddiff --git a/public/js/my/today.js b/public/js/my/today.js
iindex 4021beb6..468b1306 100644
---- a/public/js/my/today.js
++++ b/public/js/my/today.js
@@@ -325,7 +325,11 @@ function nyHref(item) {
// An approval is never one tap from here: the hub shows a line, and a step may delete, spend or send. Review opens the
// approval itself (what exactly runs, Approve or Decline). Try again stays one tap: it only repeats what already ran.
function nyButton(item, action) {
-- const label = action === 'approve' ? 'Review' : action === 'retry' ? 'Try again' : 'Open';
++ const label = action === 'approve' ? (item.kind === 'approval' ? 'Approve' : 'Review') : action === 'decline' ? 'Decline' : action === 'retry' ? 'Try again' : 'Open';
++ if (item.kind === 'approval' && item.service === 'network' && ['approve', 'decline'].includes(action)) {
++ const b = document.createElement('button'); b.type = 'button'; b.className = `ny-btn${action === 'decline' ? ' is-quiet' : ''}`; b.textContent = label;
++ b.addEventListener('click', () => nyDo(item, action, b)); return b;
++ }
if (action !== 'retry') {
const href = nyHref(item);
const a = document.createElement('a');
@@@ -353,7 +357,7 @@ async function nyDo(item, action, button) {
if (!item || !item.service || !item.id) return;
const label = button.textContent;
button.disabled = true;
-- button.textContent = action === 'retry' ? 'Trying…' : 'Approving…';
++ button.textContent = action === 'retry' ? 'Trying…' : action === 'decline' ? 'Declining…' : 'Approving…';
try {
await apiFetch(`/api/v1/me/actions/${encodeURIComponent(item.service)}/${encodeURIComponent(item.id)}/${action}`, { method: 'POST' });
const row = button.closest('.ny-item');
@@@ -363,7 +367,7 @@ async function nyDo(item, action, button) {
const sub = document.getElementById('needs-you-sub');
if (sub) sub.textContent = left ? `${left} more waiting` : '';
if (!left && box) box.hidden = true;
-- if (typeof toast === 'function') toast(action === 'retry' ? 'Trying it again' : 'Approved', 'success');
++ if (typeof toast === 'function') toast(action === 'retry' ? 'Trying it again' : action === 'decline' ? 'Declined' : 'Approved', 'success');
} catch (err) {
button.disabled = false;
button.textContent = label;
@@@ -371,15 +375,18 @@ async function nyDo(item, action, button) {
}
}
--function renderNeedsYou(doc) {
++function renderNeedsYou(doc, confirmationDoc) {
const box = document.getElementById('needs-you');
const list = document.getElementById('needs-you-list');
if (!box || !list) return;
const items = doc && doc.buckets && Array.isArray(doc.buckets.waiting) ? doc.buckets.waiting : [];
++ const confirmations = new Map(((confirmationDoc && confirmationDoc.confirmations) || []).map((c) => [c.id, c]));
if (!items.length) { box.hidden = true; list.replaceChildren(); return; }
const sub = document.getElementById('needs-you-sub');
if (sub) sub.textContent = `${items.length} thing${items.length === 1 ? '' : 's'} waiting`;
-- list.replaceChildren(...items.map((item) => {
++ list.replaceChildren(...items.map((source) => {
++ const confirmation = source.service === 'network' ? confirmations.get(source.id) : null;
++ const item = confirmation ? { ...source, details: confirmation.details, resources: confirmation.resources, capability: confirmation.capability } : source;
const row = document.createElement('article');
row.className = 'ny-item';
const icon = document.createElement('span');
@@@ -391,9 +398,17 @@ function renderNeedsYou(doc) {
const body = document.createElement('div');
body.className = 'ny-body';
const title = document.createElement('h3');
-- title.textContent = item.title || 'Something needs you';
++ const isApproval = item.kind === 'approval' && item.service === 'network';
++ const details = item.details && typeof item.details === 'object' ? item.details : {};
++ const host = details.machine_name || details.machine || details.target || item.resources?.find((r) => r && typeof r === 'object' && /machine|computer|host/i.test(String(r.type || '')) && r.name);
++ const hostName = typeof host === 'string' ? host : host && (host.name || host.hostname);
++ const commandText = typeof details.command === 'string' ? details.command.slice(0, 500) : '';
++ const commandRequest = Boolean(commandText || details.action === 'command');
++ title.textContent = isApproval ? `${commandRequest ? 'Run a command' : 'Approve a request'}${hostName ? ` on ${hostName}` : ''}` : (item.title || 'Something needs you');
body.append(title);
-- if (item.summary) {
++ if (isApproval && commandText) body.append(Object.assign(document.createElement('code'), { className: 'ny-command', textContent: commandText }));
++ if (isApproval && typeof details.reason === 'string') { const why = document.createElement('p'); why.className = 'ny-meta'; why.textContent = details.reason.slice(0, 300); body.append(why); }
++ if (!isApproval && item.summary) {
const summary = document.createElement('p');
summary.textContent = item.summary;
body.append(summary);
@@@ -407,12 +422,39 @@ function renderNeedsYou(doc) {
}
const actions = Array.isArray(item.actions) ? item.actions : [];
const primary = actions.includes('approve') ? 'approve' : actions.includes('retry') ? 'retry' : 'open';
-- row.append(icon, body, nyButton(item, primary));
++ const actionsWrap = document.createElement('div'); actionsWrap.className = 'ny-actions';
++ if (isApproval) {
++ const detailsToggle = document.createElement('details'); detailsToggle.className = 'ny-details';
++ const summary = document.createElement('summary'); summary.textContent = 'Details'; detailsToggle.append(summary);
++ const agentName = item.agent && !/^(agt|app|svc|usr)[_0-9A-HJKMNP-TV-Z]{20,}$/i.test(String(item.agent)) ? item.agent : '';
++ const words = [agentName ? `Asked by ${agentName}` : '', item.summary || ''].filter(Boolean).join(' · ');
++ if (words) { const p = document.createElement('p'); p.textContent = words; detailsToggle.append(p); }
++ actionsWrap.append(detailsToggle, nyButton(item, 'approve'), nyButton(item, 'decline'));
++ } else actionsWrap.append(nyButton(item, primary));
++ row.append(icon, body, actionsWrap);
return row;
}));
box.hidden = false;
}
++function openMachines(pair) {
++ showSection('machines');
++ document.getElementById('sec-machines')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
++ if (pair) mcPair();
++}
++
++function renderMachineSummary(doc) {
++ const box = document.getElementById('td-machines-summary');
++ const state = document.getElementById('td-machines-state');
++ const count = document.getElementById('td-machines-count');
++ if (!box || !state) return;
++ const machines = (doc && Array.isArray(doc.machines) ? doc.machines : []).filter((m) => !m.revoked_at);
++ const online = machines.filter((m) => m.online === true).length;
++ count.textContent = `${machines.length} paired`;
++ state.textContent = machines.length ? `${online} online now${online ? ` · ${machines.filter((m) => m.online).map((m) => m.name).filter(Boolean).slice(0, 3).join(', ')}` : ''}` : 'Pair a computer to run your work on OpenVibe.';
++ box.hidden = false;
++}
++
async function loadToday() {
if (!document.getElementById('today')) return;
renderGreeting();
@@@ -420,7 +462,7 @@ async function loadToday() {
const quiet = (p) => p.catch(() => null);
renderActions(null);
renderStatus();
-- const [acts, live, hist, coins, last, unread, sites, nodes, frame, tryNext] = await Promise.all([
++ const [acts, live, hist, coins, last, unread, sites, nodes, frame, tryNext, confirmationsDoc, machineList] = await Promise.all([
quiet(apiFetch('/api/v1/me/actions')),
quiet(apiFetch('/api/v1/me/live-follows')),
quiet(apiFetch('/api/history?limit=30')),
@@@ -431,9 +473,11 @@ async function loadToday() {
quiet(apiFetch('/api/v1/me/nodes')),
quiet(apiFetch('/api/frame?host=openvibe.network')),
quiet(apiFetch('/api/v1/me/try-next?limit=3')),
++ quiet(apiFetch('/api/v1/confirmations?state=pending&limit=100')),
++ quiet(apiFetch('/api/v1/machines')),
]);
globalThis.__tdUnread = unread;
-- renderNeedsYou(acts);
++ renderNeedsYou(acts, confirmationsDoc);
renderActions(unread);
renderNet(frame);
renderNow(live, unread);
@@@ -441,5 +485,11 @@ async function loadToday() {
renderMoney(coins, last);
renderSites(hist, sites, tryNext);
renderStuff(live, hist, nodes);
++ renderMachineSummary(machineList);
document.getElementById('today').classList.add('td-ready');
++ const machineSection = document.getElementById('sec-machines');
++ if (machineSection && machineSection.classList.contains('active') && typeof location !== 'undefined' && typeof window !== 'undefined' && location.hash === '#machines') {
++ const y = machineSection.getBoundingClientRect().top + window.scrollY - 90;
++ window.scrollTo(0, Math.max(0, y));
++ }
}
ddiff --git a/public/my.html b/public/my.html
iindex 50c86bed..40175355 100644
---- a/public/my.html
++++ b/public/my.html
@@@ -58,27 +58,6 @@
An approval says what kind of act it is in plain words (plan HO1: publishing for others to see, sending
something to someone, spending your money), which agent asked and in which thread; the agent's name is
shown, never its id. Review opens the approval itself — the hub never approves with one tap. -->
-- <section class="ny" id="needs-you" hidden aria-labelledby="needs-you-h">
-- <div class="ny-h">
-- <h2 id="needs-you-h"><span class="ny-dot" aria-hidden="true"></span>Needs you</h2>
-- <span class="ny-sub" id="needs-you-sub"></span>
-- </div>
-- <div class="ny-list" id="needs-you-list"></div>
-- </section>
--
-- <!-- Receipts (plan OpenVibe_Seven_Domains_Plan R2 item 13): the latest five receipt-category summaries Inbox AI
-- keeps on openvibe.email, each a link to its thread, with a counted fold for the rest and never a tab.
-- my/receipts.js fills it from GET /api/v1/me/receipts; with Inbox AI off (or no mailbox) the one quiet line
-- links to the switch, and no sample receipt is ever drawn. The card stays out until it has an answer. -->
-- <section class="rc" id="receipts" hidden aria-labelledby="receipts-h">
-- <div class="rc-h">
-- <h2 id="receipts-h"><span class="rc-ic" aria-hidden="true"><i class="fa-solid fa-file-lines"></i></span>Receipts</h2>
-- </div>
-- <div class="rc-body" id="receipts-body"></div>
-- </section>
--
-- <!-- You (11-07 redesign; the owner: "front-face the best stuff we have to offer … awesome design"): who you are in
-- your own colour, your balances, and one tap to what you do most, on every site. -->
<header class="me" id="page-header">
<div class="me-top">
<div class="me-who">
@@@ -100,36 +79,50 @@
<a class="me-tip" href="https://openvibe.tips"><i class="fa-solid fa-hand-holding-heart" aria-hidden="true"></i> Tip a creator</a>
</div>
</div>
-- <nav class="me-acts" id="me-acts" aria-label="Quick actions"></nav>
</header>
++ <section class="ny" id="needs-you" hidden aria-labelledby="needs-you-h">
++ <div class="ny-h">
++ <h2 id="needs-you-h"><span class="ny-dot" aria-hidden="true"></span>Needs you</h2>
++ <span class="ny-sub" id="needs-you-sub"></span>
++ </div>
++ <div class="ny-list" id="needs-you-list"></div>
++ </section>
++ <nav class="me-acts" id="me-acts" aria-label="Quick actions"></nav>
++
<!-- You, today (plan L43): what is happening for you now, what you were doing, your money, your sites and your
numbers. my/today.js fills each block from its own API; a block with nothing in it stays out. -->
<div class="td" id="today">
-- <section class="td-sec" id="td-now" hidden aria-labelledby="td-now-h">
-- <div class="td-h"><h2 id="td-now-h"><span class="td-pulse" aria-hidden="true"></span>Live from people you follow</h2><span class="td-sub" id="td-now-sub"></span></div>
-- <div class="td-rail" id="td-live"></div>
-- </section>
-- <div class="td-chips" id="td-chips" hidden></div>
<section class="td-sec" id="td-continue" hidden aria-labelledby="td-continue-h">
<div class="td-h"><h2 id="td-continue-h">Pick up where you left off</h2><a class="td-more" href="#history" onclick="showSection('history');document.getElementById('settings').scrollIntoView({behavior:'smooth'});return false">History</a></div>
<div class="td-rail" id="td-continue-list"></div>
</section>
-- <section class="td-sec" id="td-net" hidden aria-labelledby="td-net-h">
-- <div class="td-h"><h2 id="td-net-h">On OpenVibe now</h2><a class="td-more" href="/sites">Every site</a></div>
-- <div class="td-net" id="td-net-cards"></div>
-- </section>
<div class="td-grid">
<section class="td-box" id="td-sites" hidden aria-labelledby="td-sites-h">
<div class="td-h"><h2 id="td-sites-h">Your sites</h2><a class="td-more" href="/sites">All sites</a></div>
<div class="td-sites" id="td-sites-grid"></div>
<div class="td-tries" id="td-try" hidden></div>
</section>
++ <section class="td-box td-machines" id="td-machines-summary" hidden aria-labelledby="td-machines-h">
++ <div class="td-h"><h2 id="td-machines-h">Your machines</h2><span class="td-sub" id="td-machines-count"></span></div>
++ <p id="td-machines-state">Loading your machines…</p>
++ <button class="ovk-btn ovk-btn--primary" type="button" onclick="openMachines(true)">Pair a computer</button>
++ </section>
<section class="td-box td-nums" id="td-nums" hidden aria-labelledby="td-nums-h">
<div class="td-h"><h2 id="td-nums-h">Your numbers</h2><a class="td-more" href="https://openvibe.report/you">On openvibe.report</a></div>
<div class="td-stats" id="td-stuff-row"></div>
</section>
</div>
++ <section class="td-sec" id="td-now" hidden aria-labelledby="td-now-h">
++ <div class="td-h"><h2 id="td-now-h"><span class="td-pulse" aria-hidden="true"></span>Live from people you follow</h2><span class="td-sub" id="td-now-sub"></span></div>
++ <div class="td-rail" id="td-live"></div>
++ </section>
++ <div class="td-chips" id="td-chips" hidden></div>
++
++ <section class="td-sec" id="td-net" hidden aria-labelledby="td-net-h">
++ <div class="td-h"><h2 id="td-net-h">On OpenVibe now</h2><a class="td-more" href="/sites">Every site</a></div>
++ <div class="td-net" id="td-net-cards"></div>
++ </section>
<div class="recent-tools" id="my-recent-tools" hidden>
<h3><i class="fa-solid fa-screwdriver-wrench"></i> Your tools</h3>
<div class="recent-tools-list" id="my-recent-tools-list"></div>
@@@ -137,6 +130,15 @@
</div>
</div>
++ <details id="setup-fold" class="setup-fold" hidden><summary>Set up more (0)</summary><div class="setup-items"></div></details>
++
++ <section class="rc" id="receipts" hidden aria-labelledby="receipts-h">
++ <div class="rc-h">
++ <h2 id="receipts-h"><span class="rc-ic" aria-hidden="true"><i class="fa-solid fa-file-lines"></i></span>Receipts</h2>
++ </div>
++ <div class="rc-body" id="receipts-body"></div>
++ </section>
++
<div class="card" id="account-emails-card">
<h2><span class="icon"><i class="fa-solid fa-envelope"></i></span> Email addresses</h2>
<p>Keep several verified addresses on your account. Only you can see this list.</p>
@@@ -491,9 +493,10 @@
<div class="mc-top">
<div>
<h3><span class="icon"><i class="fa-solid fa-laptop"></i></span> Your machines</h3>
++ <p class="mc-count" id="mc-count" aria-live="polite">Loading machines…</p>
<p class="muted">Computers and servers paired with your account. They run your Codes jobs, host your desks and serve your own models. Only you can pair one or change what it does.</p>
</div>
-- <button class="btn btn-primary" id="mc-pair-btn" type="button" onclick="mcPair()"><i class="fa-solid fa-plus"></i> Pair a machine</button>
++ <button class="btn btn-primary" id="mc-pair-btn" type="button" onclick="mcPair()"><i class="fa-solid fa-plus"></i> Pair a computer</button>
</div>
<div class="mc-pair" id="mc-pair" hidden></div>
<p class="mc-status" id="mc-status" role="status"></p>
ddiff --git a/server/my/routes.js b/server/my/routes.js
iindex 150a62dd..5d7f34a1 100644
---- a/server/my/routes.js
++++ b/server/my/routes.js
@@@ -226,9 +226,9 @@ async function createMyRoutes({ db, config = require('../config'), sso, pagePath
router.use((req, res, next) => {
const host = hostOf(req);
if (host === WWW_HOST) return res.redirect(301, `https://${MY_HOST}${req.originalUrl}`);
-- // The machines hub moved to the Link product: /machines goes there from both the apex and the hub,
-- // carrying a handoff ticket when the browser is signed in here.
++ // The signed-in account hub serves its own Machines section; Network routes keep the Link pairing handoff.
if (req.path === '/machines' && ['GET', 'HEAD'].includes(req.method) && (host === MY_HOST || NETWORK_HOSTS.has(host))) {
++ if (host === MY_HOST) { req.ovMyHost = true; return next(); }
return machinesRedirect(req, res, host).catch(() => res.redirect(302, `https://openvibe.link/machines${queryOf(req)}`));
}
if (NETWORK_HOSTS.has(host)) {
ddiff --git a/server/registry/machines.js b/server/registry/machines.js
iindex e0c675ca..cef9e89e 100644
---- a/server/registry/machines.js
++++ b/server/registry/machines.js
@@@ -35,6 +35,7 @@
* .roles_changed and .revoked (the person's own changes).
*/
const express = require('express');
++const QRCode = require('qrcode');
const crypto = require('crypto');
const { capabilities, http, ids, serviceAuth, validate } = require('openvibe-contracts');
const eventRelay = require('../developer/event-relay');
@@@ -421,6 +422,7 @@ function routers({ bot, requireAuth, publicKey, issuer, db = (req) => req.app.lo
const p = (out && out.pairing) || {};
res.status(201).json({ pairing: {
code: String(p.code || ''), expires_at: p.expires_at, installer: String(p.installer || ''),
++ qr_data: await QRCode.toDataURL(String(p.code || ''), { width: 220, margin: 1 }),
...(p.pairing_id ? { pairing_id: p.pairing_id } : {}),
} });
}));
ddiff --git a/test/my-home.test.js b/test/my-home.test.js
iindex 45692811..2c463f49 100644
---- a/test/my-home.test.js
++++ b/test/my-home.test.js
@@@ -128,7 +128,8 @@ const MY = 'openvibe.my';
assert.strictEqual(r.status, 200, 'a Network guest gets the page quietly, with sso=none');
assert.ok(r.body.includes('id="needs-you"'), 'the hub leads with Needs you');
assert.ok(r.body.includes('id="needs-you-list"'), 'and has its list');
-- assert.ok(r.body.indexOf('id="needs-you"') < r.body.indexOf('id="page-header"'), 'Needs you is above the hero');
++ assert.ok(r.body.indexOf('id="page-header"') < r.body.indexOf('id="needs-you"'), 'the person comes first');
++ assert.ok(r.body.indexOf('id="needs-you"') < r.body.indexOf('id="me-acts"'), 'actions follow Needs you');
assert.ok(r.body.includes('id="page-header"') && r.body.includes('class="section-tabs"'), 'the hero and sections stay');
assert.ok(r.body.includes('<link rel="canonical" href="https://openvibe.my/">'), 'the canonical is openvibe.my');
assert.strictEqual(r.headers['cache-control'], 'no-store', 'the hub is not cached');
@@@ -136,6 +137,10 @@ const MY = 'openvibe.my';
r = await get('/', MY, ['ov_token=already']);
assert.strictEqual(r.status, 200, 'a session skips the silent redirect');
++ r = await get('/machines', MY, ['ov_token=already']);
++ assert.strictEqual(r.status, 200, 'signed-in /machines stays in the hub');
++ assert.ok(r.body.includes('id="sec-machines"'), 'serves Machines');
++
// ── The sign-in: PKCE S256, state and verifier in host-only cookies ──
r = await get('/auth/login?next=%2Fsecurity', MY);
assert.strictEqual(r.status, 302);
ddiff --git a/test/my-machines-handoff.test.js b/test/my-machines-handoff.test.js
iindex 97b00247..7bc2cb52 100644
---- a/test/my-machines-handoff.test.js
++++ b/test/my-machines-handoff.test.js
@@@ -1,6 +1,6 @@
'use strict';
--// The hub's old paths here mint a one-time handoff ticket for link-web when the browser is signed in here,
--// so openvibe.link signs them in with no round trip. Signed out, the plain hop lands on silent sign-in.
++// Network's apex sends signed-in /machines visitors to Link with a handoff ticket. openvibe.my keeps its
++// signed-in Machines section and silently signs in visitors who arrive there without a session.
// ov test test/my-machines-handoff.test.js (or: node test/my-machines-handoff.test.js)
const assert = require('assert');
const http = require('http');
@@@ -31,23 +31,21 @@ const server = http.createServer(app);
req.on('error', reject);
});
-- // Signed in here: the hop carries a ticket minted for link-web.
++ // The account hub's own /machines route stays here and serves its Machines section.
let r = await get('/machines', 'ov_token=good-token');
-- assert.strictEqual(r.status, 302, 'signed in 302s to the hub');
-- const to = new URL(r.headers.location);
-- assert.strictEqual(to.origin + to.pathname, 'https://openvibe.link/machines');
-- assert.ok(/^ovh_[A-Za-z0-9_-]{20,200}$/.test(to.searchParams.get('ov_handoff')), 'a ticket rides along');
++ assert.strictEqual(r.status, 200, 'signed in /machines stays on the account hub');
++ assert.ok(r.body.includes('id="sec-machines"'));
-- // Signed out (or a bad cookie): the plain hop, which lands on the hub's silent sign-in.
++ // Signed out: openvibe.my starts its silent sign-in with /machines as the return path.
r = await get('/machines');
assert.strictEqual(r.status, 302);
-- assert.strictEqual(r.headers.location, 'https://openvibe.link/machines', 'no ticket when signed out');
++ assert.strictEqual(r.headers.location, '/auth/login?silent=1&next=%2Fmachines');
r = await get('/machines', 'ov_token=nope');
-- assert.strictEqual(r.headers.location, 'https://openvibe.link/machines', 'no ticket on a bad session');
++ assert.strictEqual(r.status, 200, 'an existing session cookie reaches the account hub');
-- // The old Network path mints the same way.
++ // Network apex arrivals still get Link's signed-in handoff.
r = await get('/machines', 'ov_token=good-token', 'openvibe.network');
-- assert.ok(new URL(r.headers.location).searchParams.get('ov_handoff'), 'apex arrivals mint too');
++ assert.ok(new URL(r.headers.location).searchParams.get('ov_handoff'));
console.log('my-machines-handoff.test.js: all assertions passed');
server.close();
ddiff --git a/test/my-machines.test.js b/test/my-machines.test.js
iindex 74581d39..d2b10335 100644
---- a/test/my-machines.test.js
++++ b/test/my-machines.test.js
@@@ -61,6 +61,8 @@ const ago = (m) => new Date(Date.now() - m * 60000).toISOString();
assert.ok(HTML.includes('id="sec-machines"') && HTML.includes("showSection('machines',this)"), 'a Machines section and tab');
assert.ok(HTML.indexOf('<script src="/js/my/machines.js" defer>') > 0, 'its script');
assert.match(CORE, /'\/machines': 'machines'/, 'the /machines address opens it');
++assert.match(CORE, /hash === 'pair' \? 'machines'/, '#pair opens Machines');
++assert.match(CORE, /if \(routeSection === 'machines' && hash === 'pair'\) setTimeout\(\(\) => mcPair\(\), 0\)/, '#pair starts pairing');
assert.match(CORE, /if \(id === 'machines' && typeof loadMachines === 'function'\) loadMachines\(\);/, 'it loads when opened');
assert.ok(NOT_FOUND.ACCOUNT_HUB_PATHS.includes('/machines'), '/machines is the account hub, not a 404');
ddiff --git a/test/my-receipts.test.js b/test/my-receipts.test.js
iindex f2fcac82..1b614db9 100644
---- a/test/my-receipts.test.js
++++ b/test/my-receipts.test.js
@@@ -15,9 +15,13 @@ const HTML = fs.readFileSync(path.join(__dirname, '..', 'public', 'my.html'), 'u
const JS = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'my', 'receipts.js'), 'utf8');
const CSS = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'my.css'), 'utf8');
--// ── The page: the card sits under Needs you, above the hero, and starts out hidden ──
--assert.ok(HTML.indexOf('id="needs-you"') < HTML.indexOf('id="receipts"'), 'Receipts follows Needs you');
--assert.ok(HTML.indexOf('id="receipts"') < HTML.indexOf('id="page-header"'), 'and is above the hero');
++// ── The page: the person leads, then Needs you, actions, history, sites, machines, numbers and receipts ──
++assert.ok(HTML.indexOf('id="page-header"') < HTML.indexOf('id="needs-you"'));
++assert.ok(HTML.indexOf('id="needs-you"') < HTML.indexOf('id="me-acts"'));
++assert.ok(HTML.indexOf('id="td-continue"') < HTML.indexOf('id="td-sites"'));
++assert.ok(HTML.indexOf('id="td-sites"') < HTML.indexOf('id="td-machines-summary"'));
++assert.ok(HTML.indexOf('id="td-machines-summary"') < HTML.indexOf('id="td-nums"'));
++assert.ok(HTML.indexOf('id="td-nums"') < HTML.indexOf('id="receipts"'));
assert.match(HTML, /<section class="rc" id="receipts" hidden/);
assert.match(HTML, /<script src="\/js\/my\/receipts\.js" defer><\/script>/, 'the card has its own script');
assert.match(CSS, /\.rc-row \{/, 'the rows have a style');
@@@ -34,12 +38,16 @@ class Node {
get textContent() { return this._text + this.children.map((c) => c.textContent).join(''); }
set textContent(v) { this._text = String(v); this.children = []; }
get href() { return this.attrs.href; }
++ querySelector(sel) { return sel === 'summary' ? this.summary : sel === '.setup-items' ? this.items : null; }
all(pred, out = []) { for (const c of this.children) { if (pred(c)) out.push(c); c.all(pred, out); } return out; }
}
function page() {
const ids = {};
for (const [, id] of HTML.matchAll(/id="([^"]+)"/g)) ids[id] = new Node('div');
++ ids['setup-fold'].hidden = true;
++ ids['setup-fold'].summary = new Node('summary');
++ ids['setup-fold'].items = new Node('div');
const sandbox = {
document: { getElementById: (id) => ids[id] || null, createElement: (t) => new Node(t) },
console, Date, Number, String, Array, Map, Set, Math, JSON, Promise, encodeURIComponent,
@@@ -55,16 +63,14 @@ const receipt = (i) => ({
});
(async () => {
-- // ── Inbox AI off (or no mailbox): one quiet line, a link to the switch, and no sample receipt ──
++ // ── Inbox AI off: one counted setup fold, no empty Receipts card, and no sample receipt ──
let p = page();
p.sandbox.renderReceipts({ enabled: false, receipts: [] });
-- assert.strictEqual(p.ids.receipts.hidden, false, 'the card says why it is empty');
-- const quiet = p.ids['receipts-body'].children;
-- assert.strictEqual(quiet.length, 1, 'one line only');
-- assert.strictEqual(quiet[0].tagName, 'A');
-- assert.strictEqual(quiet[0].textContent, 'Turn on Inbox AI on openvibe.email to see receipts here');
-- assert.strictEqual(quiet[0].getAttribute('href'), 'https://openvibe.email/inbox#inbox-ai', 'it links to the switch');
-- assert.ok(!/Shop|Order|sample|example/i.test(p.ids['receipts-body'].textContent), 'no sample or fake receipt');
++ assert.strictEqual(p.ids.receipts.hidden, true, 'no empty Receipts card');
++ assert.strictEqual(p.ids['setup-fold'].hidden, false);
++ assert.strictEqual(p.ids['setup-fold'].summary.textContent, 'Set up more (1)');
++ assert.strictEqual(p.ids['setup-fold'].items.children[0].textContent, 'Set up receipts with Inbox AI');
++ assert.strictEqual(p.ids['setup-fold'].items.children[0].getAttribute('href'), 'https://openvibe.email/inbox#inbox-ai');
// ── On with receipts: the latest five, then a counted fold for the rest, each a thread on openvibe.email ──
p = page();
@@@ -98,11 +104,11 @@ const receipt = (i) => ({
p.sandbox.renderReceipts({ enabled: true, receipts: [{ ...receipt(0), message_id: null, thread_id: 'thr_ok' }] });
assert.strictEqual(p.ids['receipts-body'].all((c) => c.tagName === 'A')[0].href, 'https://openvibe.email/inbox/thr_ok');
-- // ── On with nothing triaged yet: a quiet line, not the turn-it-on line ──
++ // ── On with nothing triaged yet: neither receipts nor setup nudge is shown ──
p = page();
p.sandbox.renderReceipts({ enabled: true, receipts: [] });
-- assert.match(p.ids['receipts-body'].textContent, /^No receipts yet\.$/);
-- assert.ok(!/Turn on Inbox AI/.test(p.ids['receipts-body'].textContent));
++ assert.strictEqual(p.ids.receipts.hidden, true);
++ assert.strictEqual(p.ids['setup-fold'].hidden, true);
// ── The route: Mail is asked with the person's own token; off hides rows, on passes them, down stays out ──
const seen = [];
ddiff --git a/test/my-today.test.js b/test/my-today.test.js
iindex 8ce16b10..d0bc41a9 100644
---- a/test/my-today.test.js
++++ b/test/my-today.test.js
@@@ -175,22 +175,27 @@ assert.match(CSS, /@media \(min-width:1000px\)\{[\s\S]*\.settings\{display:grid;
p.ids['td-stuff-row'].children.find((c) => /history/.test(c.textContent)).click();
assert.deepStrictEqual(p.shown, ['history'], 'your history opens below');
++ p.sandbox.renderMachineSummary({ machines: [{ name: 'Studio PC', online: true }, { name: 'Laptop', online: false }, { id: 'revoked', revoked_at: 'x', online: true }] });
++ assert.strictEqual(p.ids['td-machines-count'].textContent, '2 paired');
++ assert.strictEqual(p.ids['td-machines-state'].textContent, '1 online now · Studio PC');
++
// ── Needs you (plan §4): the Action Center's waiting items, one button each, above the hero ──
p = page();
-- assert.ok(HTML.indexOf('id="needs-you"') < HTML.indexOf('id="page-header"'), 'Needs you is the first section');
++ assert.ok(HTML.indexOf('id="page-header"') < HTML.indexOf('id="needs-you"'), 'the person is first');
++ assert.ok(HTML.indexOf('id="needs-you"') < HTML.indexOf('id="me-acts"'), 'actions follow Needs you');
p.sandbox.renderNeedsYou({ buckets: { waiting: [
-- { service: 'network', id: 'cnf_1', kind: 'approval', title: 'Send one email to the venue', summary: 'Trip planner asks to use mail.send', agent: 'Trip planner', href: 'https://openvibe.network/my#approvals', actions: ['approve', 'decline', 'open'] },
++ { service: 'network', id: 'cnf_1', kind: 'approval', title: 'Run a command', summary: 'Trip planner asks to run a command', agent: 'Trip planner', href: 'https://openvibe.network/my#approvals', actions: ['approve', 'decline', 'open'] },
{ service: 'actor', id: 'tsk_1', kind: 'agent_task', title: 'Find three cheap flights', summary: 'Read 4 pages', href: 'https://openvibe.actor/tasks/tsk_1', actions: ['open'] },
-- ] } });
++ ] } }, { confirmations: [{ id: 'cnf_1', capability: 'computer.command', details: { command: 'rm -rf /tmp/actor-gate-proof', reason: 'Clean up a test file', arguments: { raw: true } }, resources: [{ type: 'machine', name: 'devprobe' }] }] });
assert.strictEqual(p.ids['needs-you'].hidden, false);
assert.strictEqual(p.ids['needs-you-sub'].textContent, '2 things waiting');
const rows = p.ids['needs-you-list'].children;
assert.strictEqual(rows.length, 2);
-- const review = rows[0].all((c) => c.tagName === 'A')[0];
-- assert.strictEqual(review.textContent, 'Review', 'an approval opens itself: never one tap from the hub');
-- assert.strictEqual(rows[0].all((c) => c.tagName === 'BUTTON').length, 0, 'no Approve button here');
-- assert.strictEqual(rows[0].all((c) => c.tagName === 'H3')[0].textContent, 'Send one email to the venue');
-- assert.strictEqual(rows[0].all((c) => c.tagName === 'P')[0].textContent, 'Trip planner asks to use mail.send');
++ assert.strictEqual(rows[0].all((c) => c.tagName === 'BUTTON').length, 2, 'inline approve and decline');
++ assert.strictEqual(rows[0].all((c) => c.tagName === 'H3')[0].textContent, 'Run a command on devprobe');
++ assert.match(rows[0].textContent, /rm -rf \/tmp\/actor-gate-proof/);
++ assert.doesNotMatch(rows[0].textContent, /"arguments"|\[object Object\]/, 'structured arguments stay hidden');
++ assert.match(rows[0].textContent, /Details/);
assert.strictEqual(rows[1].all((c) => c.tagName === 'A')[0].href, 'https://openvibe.actor/tasks/tsk_1', 'an open item links its page');
// An agent known only by its id is not shown by it; Try again stays one tap.
p.sandbox.renderNeedsYou({ buckets: { waiting: [