feat(ui): standalone agency upload portal (#73)

Agency-facing. A self-contained page at /agency (NOT the dashboard SPA - the agency has no
JWT, only the token). Entry: paste access key -> sessionStorage (cleared on tab close, not
localStorage) -> sent as Bearer. Flow: list designated playlists -> upload (shared ingest =
first-class content) -> date-bounded item on a chosen playlist (lands as draft for admin
re-publish). Graceful failure: any 401/403 resets to the entry screen with "key invalid,
paste it again" - never a wall of 403s. Blast radius of a leaked key stays bounded by the
narrow scope.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-06-14 13:08:07 -05:00
parent d59adfd10c
commit efd4d7826c
3 changed files with 204 additions and 0 deletions

75
frontend/agency.html Normal file
View file

@ -0,0 +1,75 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>Agency Upload Portal</title>
<style>
:root { --bg:#0f1115; --card:#1a1d24; --border:#2a2f3a; --text:#e8eaed; --muted:#9aa0aa; --accent:#4f8cff; --danger:#ff5c5c; --ok:#3ddc84; --radius:10px; }
* { box-sizing:border-box; }
body { margin:0; font-family:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif; background:var(--bg); color:var(--text); }
.wrap { max-width:640px; margin:0 auto; padding:32px 20px; }
.card { background:var(--card); border:1px solid var(--border); border-radius:12px; padding:24px; margin-bottom:20px; }
h1 { font-size:22px; margin:0 0 4px; }
h2 { font-size:16px; margin:0 0 12px; }
.sub { color:var(--muted); font-size:13px; margin:0 0 16px; }
label { display:block; font-size:13px; margin:12px 0 4px; color:var(--muted); }
input, select { width:100%; padding:10px 12px; background:#0d0f13; border:1px solid var(--border); border-radius:8px; color:var(--text); font-size:14px; }
button { padding:10px 16px; background:var(--accent); color:#fff; border:0; border-radius:8px; font-size:14px; cursor:pointer; }
button:disabled { opacity:.45; cursor:default; }
button.secondary { background:transparent; border:1px solid var(--border); color:var(--muted); }
.row { display:flex; gap:12px; }
.row > * { flex:1; }
.msg { padding:10px 12px; border-radius:8px; font-size:13px; margin:12px 0; display:none; }
.msg.err { background:rgba(255,92,92,.12); color:var(--danger); border:1px solid rgba(255,92,92,.3); }
.msg.ok { background:rgba(61,220,132,.12); color:var(--ok); border:1px solid rgba(61,220,132,.3); }
.hidden { display:none; }
.topbar { display:flex; justify-content:space-between; align-items:center; margin-bottom:20px; }
.pill { font-size:12px; color:var(--muted); }
</style>
</head>
<body>
<div class="wrap">
<!-- ENTRY -->
<div id="entry" class="card">
<h1>Agency Upload Portal</h1>
<p class="sub">Paste the access key your contact gave you. It stays in this browser tab only and is cleared when you close it.</p>
<div id="entryMsg" class="msg err"></div>
<label for="keyInput">Access key</label>
<input id="keyInput" type="password" placeholder="st_…" autocomplete="off" spellcheck="false">
<div style="margin-top:16px"><button id="enterBtn">Continue</button></div>
</div>
<!-- PORTAL -->
<div id="portal" class="hidden">
<div class="topbar">
<h1>Agency Upload Portal</h1>
<button class="secondary" id="signOutBtn">Sign out</button>
</div>
<div id="portalMsg" class="msg"></div>
<div class="card">
<h2>1. Upload content</h2>
<p class="sub">An image or video — added to the workspace library, full quality.</p>
<input id="fileInput" type="file" accept="image/*,video/*">
<div style="margin-top:12px"><button id="uploadBtn" disabled>Upload</button></div>
<div id="uploadInfo" class="pill" style="margin-top:8px"></div>
</div>
<div class="card">
<h2>2. Schedule it on a playlist</h2>
<p class="sub">Pick one of your designated playlists and the dates it should run. It's added as a <strong>draft</strong> for your contact to publish.</p>
<label for="plSelect">Playlist</label>
<select id="plSelect"></select>
<div class="row">
<div><label for="startDate">Start date (optional)</label><input id="startDate" type="date"></div>
<div><label for="endDate">End date (optional)</label><input id="endDate" type="date"></div>
</div>
<div style="margin-top:16px"><button id="scheduleBtn" disabled>Add to playlist</button></div>
</div>
</div>
</div>
<script src="/js/agency-portal.js"></script>
</body>
</html>

View file

@ -0,0 +1,124 @@
'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('', '');
}
// ---- 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 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('');
})();

View file

@ -201,6 +201,11 @@ app.get('/openapi.yaml', (req, res) => {
app.get('/docs', (req, res) => { app.get('/docs', (req, res) => {
res.sendFile(path.join(config.frontendDir, 'api-docs.html')); res.sendFile(path.join(config.frontendDir, 'api-docs.html'));
}); });
// #73: the standalone agency portal (token-auth, NOT the JWT dashboard SPA). Served as its
// own page so the agency never touches the dashboard login.
app.get('/agency', (req, res) => {
res.sendFile(path.join(config.frontendDir, 'agency.html'));
});
// Serve frontend static files // Serve frontend static files
// JS/CSS/HTML: no-cache (always revalidate, uses ETag/304) // JS/CSS/HTML: no-cache (always revalidate, uses ETag/304)