screentinker/frontend/js/agency-portal.js
screentinker 2f3dd80881
feat(agency): per-token upload folder — auto-created, subtree-confined (#158) (#171)
Agency-portal uploads previously all landed at the workspace library root, unsorted.
Instead of the issue's whole-workspace folder dropdown (which would leak every folder
name to an external party), bind ONE folder per agency token — admin-controlled and
agency-invisible — and scope the portal picker strictly to that folder's own subtree
(Hybrid-C). Fully backwards-compatible: no bound folder -> root, exactly as before.

Model / multi-workspace: an agency token is bound to ONE workspace at issuance, so the
token key IS that workspace's private link and the bound folder lives in that workspace.
An admin with N workspaces mints one token per workspace (each with its own auto-folder).
No workspace-switcher in the portal — the token is the tenant boundary.

Backend:
- api_tokens.upload_folder_id (additive; ON DELETE SET NULL -> deleting the folder falls
  back to root).
- lib/agency-targets.folderSubtree(): recursive-CTE helper = the SINGLE confinement source
  shared by GET /api/agency/folders AND the POST /api/agency/content target check, so the
  set the agency can SEE and the set it may WRITE to can never drift. Workspace-guarded at
  the anchor row; descendants inherit the workspace (folders.js forbids cross-ws parents).
- routes/agency.js: GET /folders (bound subtree only); POST /content defaults to the bound
  folder and 403s any folder_id outside the subtree.
- routes/tokens.js: create auto-creates "Agency — <name>" (or binds a picked folder,
  validated same-workspace, respecting the 100-folder cap) inside the token tx; new
  PUT /:id/upload-folder to rebind; listing surfaces the bound folder name.
- middleware/apiToken.js + lib/content-ingest.js: upload_folder_id onto req.apiToken; ingest
  writes folder_id.

Frontend:
- Agency portal: folder <select> shown only when a real subfolder choice exists (identifies
  the "Main folder" root client-side without learning the token's folder id).
- Settings: folder pick at token creation, bound-folder display, rebind modal.
- i18n: 7 new apitoken.* keys across all 5 locales.

Tests (429/429):
- test/agency-folder.test.js: 5 folderSubtree confinement bites (subtree in, siblings out,
  workspace guard, null -> root).
- test/agency.test.js (+1 e2e): auto-create, default-to-bound, in-subtree pick lands there,
  sibling -> 403, admin-pick, unknown-pick -> 400, rebind-to-root.

Closes #158.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 21:23:25 -05:00

182 lines
8.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
// #73 agency portal. Token-auth ONLY (never the dashboard JWT). The access key lives in
// sessionStorage (cleared on tab close — chosen over localStorage so it doesn't linger on a
// shared agency machine) and is sent as a Bearer header. Any 401/403 resets to the entry
// screen with a clear "key invalid" message — never a wall of 403s. The token is narrow
// (agency scope), so even if leaked its blast radius is upload + drafts to designated
// playlists, which the admin must publish.
(function () {
const KEY = 'agency_key';
const $ = (id) => document.getElementById(id);
let uploadedContentId = null;
const getKey = () => sessionStorage.getItem(KEY) || '';
const setKey = (k) => sessionStorage.setItem(KEY, k);
const clearKey = () => sessionStorage.removeItem(KEY);
function showEntry(msg) {
$('portal').classList.add('hidden');
$('entry').classList.remove('hidden');
const m = $('entryMsg');
if (msg) { m.textContent = msg; m.style.display = 'block'; } else { m.style.display = 'none'; }
}
function showPortal() {
$('entry').classList.add('hidden');
$('portal').classList.remove('hidden');
}
function portalMsg(text, kind) {
const m = $('portalMsg');
m.textContent = text || '';
m.className = 'msg ' + (kind || 'ok');
m.style.display = text ? 'block' : 'none';
}
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
// Fetch /api/agency/* with the bearer key. On 401/403 -> graceful reset to entry.
async function agencyFetch(path, opts = {}) {
const headers = Object.assign({}, opts.headers, { Authorization: 'Bearer ' + getKey() });
const res = await fetch('/api/agency' + path, Object.assign({}, opts, { headers }));
if (res.status === 401 || res.status === 403) {
clearKey();
showEntry('That access key is invalid, revoked, or expired. Paste it again.');
throw new Error('auth');
}
return res;
}
async function loadPortal() {
let playlists;
try {
playlists = await (await agencyFetch('/playlists')).json();
} catch (e) { return; } // agencyFetch already reset to entry on an auth failure
const sel = $('plSelect');
sel.innerHTML = playlists.length
? playlists.map(p => `<option value="${escapeHtml(p.id)}">${escapeHtml(p.name)}</option>`).join('')
: '<option value="">No playlists designated — ask your contact</option>';
showPortal();
portalMsg('', '');
// #73: the placement card reacts to the playlist selector - "where does THIS playlist go?"
sel.onchange = () => loadLayoutForPlaylist(sel.value);
loadLayoutForPlaylist(sel.value); // initial selection
loadFolders();
}
// #158 (Hybrid-C): the folders this token may drop into = its bound folder + descendants.
// Only offer the picker when there's a real choice (a subfolder exists); with just the bound
// folder, uploads default to it server-side and the picker stays hidden. The bound "root" is
// the one node whose parent isn't in the returned set, so we can label it "Main folder"
// (default, value="") and list the descendants under it — without the portal ever learning
// the token's folder id.
async function loadFolders() {
const row = $('folderRow'), sel = $('folderSelect');
let folders;
try { folders = await (await agencyFetch('/folders')).json(); } catch (e) { return; }
const ids = new Set(folders.map(f => f.id));
const descendants = folders.filter(f => f.parent_id && ids.has(f.parent_id)); // exclude the root
if (!descendants.length) { row.style.display = 'none'; return; }
sel.innerHTML = '<option value="">Main folder</option>'
+ descendants.map(f => `<option value="${escapeHtml(f.id)}">${escapeHtml(f.name)}</option>`).join('');
row.style.display = 'block';
}
// Visual placement guide for the SELECTED playlist: draw its layout to scale, highlight the
// GRANTED zone(s) with the px size to design for, show sibling zones as context (geometry
// only - no content, no device/screen data; the endpoint is device-free).
async function loadLayoutForPlaylist(playlistId) {
const card = $('placementCard'), view = $('layoutView');
if (!playlistId) { card.style.display = 'none'; return; }
let layouts;
try { layouts = await (await agencyFetch('/playlists/' + encodeURIComponent(playlistId) + '/layout')).json(); } catch (e) { return; }
card.style.display = 'block';
if (!layouts.length) {
view.innerHTML = '<p class="pill">This playlist plays full-screen — design for the full display.</p>';
return;
}
view.innerHTML = layouts.map(l => {
const mine = new Set(l.feeds_zone_ids);
const aspect = (l.height / l.width) * 100; // padding-bottom % = aspect ratio
const zones = l.zones.map(z => {
const isMine = mine.has(z.id);
const wpx = Math.round(l.width * z.width_percent / 100);
const hpx = Math.round(l.height * z.height_percent / 100);
return `<div style="position:absolute;left:${z.x_percent}%;top:${z.y_percent}%;width:${z.width_percent}%;height:${z.height_percent}%;`
+ `border:2px solid ${isMine ? 'var(--accent)' : 'var(--border)'};box-sizing:border-box;`
+ `background:${isMine ? 'rgba(79,140,255,.20)' : 'transparent'};display:flex;align-items:center;justify-content:center;`
+ `text-align:center;overflow:hidden;font-size:11px;color:${isMine ? '#fff' : 'var(--muted)'}">`
+ `<span>${escapeHtml(z.name)}${isMine ? `<br><strong>YOUR ZONE</strong><br>${wpx}×${hpx}px` : ''}</span></div>`;
}).join('');
return `<div style="margin-bottom:16px">`
+ `<div class="pill" style="margin-bottom:6px">${escapeHtml(l.name)} · ${l.width}×${l.height}</div>`
+ `<div style="position:relative;width:100%;padding-bottom:${aspect}%;background:#0d0f13;border:1px solid var(--border);border-radius:6px">`
+ `<div style="position:absolute;inset:0">${zones}</div></div></div>`;
}).join('');
}
// ---- entry ----
$('enterBtn').addEventListener('click', () => {
const k = $('keyInput').value.trim();
if (!k) return;
setKey(k);
$('keyInput').value = '';
loadPortal();
});
$('keyInput').addEventListener('keydown', (e) => { if (e.key === 'Enter') $('enterBtn').click(); });
$('signOutBtn').addEventListener('click', () => { clearKey(); uploadedContentId = null; showEntry(''); });
// ---- upload ----
$('fileInput').addEventListener('change', () => { $('uploadBtn').disabled = !$('fileInput').files.length; });
$('uploadBtn').addEventListener('click', async () => {
const file = $('fileInput').files[0];
if (!file) return;
$('uploadBtn').disabled = true;
portalMsg('Uploading…', 'ok');
try {
const fd = new FormData();
fd.append('file', file);
const folderId = $('folderSelect') && $('folderSelect').value;
if (folderId) fd.append('folder_id', folderId); // #158: a subfolder of the bound folder; empty = the bound default
const res = await agencyFetch('/content', { method: 'POST', body: fd });
if (!res.ok) { portalMsg('Upload failed. Try again.', 'err'); return; }
const content = await res.json();
uploadedContentId = content.id;
$('uploadInfo').textContent = 'Uploaded: ' + (content.filename || content.id);
$('scheduleBtn').disabled = false;
portalMsg('Uploaded. Now schedule it below.', 'ok');
} catch (e) { /* auth already handled */ }
finally { if (getKey()) $('uploadBtn').disabled = false; }
});
// ---- schedule ----
$('scheduleBtn').addEventListener('click', async () => {
if (!uploadedContentId) return portalMsg('Upload a file first.', 'err');
const playlistId = $('plSelect').value;
if (!playlistId) return portalMsg('No playlist available to schedule on.', 'err');
const body = { content_id: uploadedContentId };
if ($('startDate').value) body.start_date = $('startDate').value;
if ($('endDate').value) body.end_date = $('endDate').value;
$('scheduleBtn').disabled = true;
try {
const res = await agencyFetch('/playlists/' + encodeURIComponent(playlistId) + '/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const e = await res.json().catch(() => ({}));
portalMsg(e.error || 'Could not add to the playlist.', 'err');
$('scheduleBtn').disabled = false;
return;
}
portalMsg('Added as a draft — your contact will publish it. You can upload another.', 'ok');
uploadedContentId = null;
$('uploadInfo').textContent = '';
$('fileInput').value = '';
$('uploadBtn').disabled = true;
} catch (e) { /* auth already handled */ }
});
// ---- boot: a stored key is validated by the first /playlists call ----
if (getKey()) loadPortal(); else showEntry('');
})();