mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-17 03:32:32 -06:00
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:
parent
d59adfd10c
commit
efd4d7826c
75
frontend/agency.html
Normal file
75
frontend/agency.html
Normal 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>
|
||||||
124
frontend/js/agency-portal.js
Normal file
124
frontend/js/agency-portal.js
Normal 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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('');
|
||||||
|
})();
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue