feat(ui): agency token designation in Settings (#73)

Admin-facing. Extends the existing API-token UI: an 'agency' scope option reveals a
playlist picker (the workspace's playlists); creating the token binds the checked ones as
its allowlist (target_playlist_ids). The token list shows each agency token's designated
playlists (tokens GET now returns targets for agency-scoped tokens). i18n keys added across
all five locales (parity test).

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 6d152a5ccf
commit d59adfd10c
7 changed files with 70 additions and 2 deletions

View file

@ -357,6 +357,12 @@ export default {
'apitoken.scope_read': 'Nur Lesen',
'apitoken.scope_write': 'Lesen & Schreiben',
'apitoken.scope_full': 'Voll (inkl. Gerätebefehle)',
'apitoken.scope_agency': 'Agentur (nur in gewählte Playlists hochladen)',
'apitoken.agency_playlists_label': 'Playlists, in die dieser Agentur-Token posten darf',
'apitoken.agency_playlists_hint': 'Der Token kann nur in diese Playlists hochladen und zeitlich begrenzte Elemente hinzufügen. Hinzufügungen landen als Entwurf zur Veröffentlichung durch dich.',
'apitoken.agency_needs_playlists': 'Wähle mindestens eine Playlist für einen Agentur-Token.',
'apitoken.agency_no_playlists': 'Erstelle zuerst eine Playlist ein Agentur-Token muss auf eine zielen.',
'apitoken.targets_label': 'Zugewiesen:',
'apitoken.create': 'Token erstellen',
'apitoken.none': 'Noch keine Tokens.',
'apitoken.col_token': 'Token',

View file

@ -393,6 +393,12 @@ export default {
'apitoken.scope_read': 'Read only',
'apitoken.scope_write': 'Read & write',
'apitoken.scope_full': 'Full (incl. device commands)',
'apitoken.scope_agency': 'Agency (upload to chosen playlists only)',
'apitoken.agency_playlists_label': 'Playlists this agency token may post to',
'apitoken.agency_playlists_hint': 'The token can upload and add date-bounded items to these playlists only. Additions land as drafts for you to publish.',
'apitoken.agency_needs_playlists': 'Select at least one playlist for an agency token.',
'apitoken.agency_no_playlists': 'Create a playlist first — an agency token must target one.',
'apitoken.targets_label': 'Designated:',
'apitoken.create': 'Create token',
'apitoken.none': 'No tokens yet.',
'apitoken.col_token': 'Token',

View file

@ -356,6 +356,12 @@ export default {
'apitoken.scope_read': 'Solo lectura',
'apitoken.scope_write': 'Lectura y escritura',
'apitoken.scope_full': 'Completo (incl. comandos de dispositivo)',
'apitoken.scope_agency': 'Agencia (subir solo a listas elegidas)',
'apitoken.agency_playlists_label': 'Listas a las que este token de agencia puede publicar',
'apitoken.agency_playlists_hint': 'El token solo puede subir y añadir elementos con fechas a estas listas. Las adiciones quedan como borrador para que las publiques.',
'apitoken.agency_needs_playlists': 'Selecciona al menos una lista para un token de agencia.',
'apitoken.agency_no_playlists': 'Crea una lista primero: un token de agencia debe apuntar a una.',
'apitoken.targets_label': 'Designadas:',
'apitoken.create': 'Crear token',
'apitoken.none': 'Aún no hay tokens.',
'apitoken.col_token': 'Token',

View file

@ -357,6 +357,12 @@ export default {
'apitoken.scope_read': 'Lecture seule',
'apitoken.scope_write': 'Lecture et écriture',
'apitoken.scope_full': 'Complet (cmd. appareils incluses)',
'apitoken.scope_agency': 'Agence (téléverser uniquement vers les listes choisies)',
'apitoken.agency_playlists_label': 'Listes vers lesquelles ce jeton d\'agence peut publier',
'apitoken.agency_playlists_hint': 'Le jeton peut uniquement téléverser et ajouter des éléments datés à ces listes. Les ajouts restent en brouillon pour que vous les publiiez.',
'apitoken.agency_needs_playlists': 'Sélectionnez au moins une liste pour un jeton d\'agence.',
'apitoken.agency_no_playlists': 'Créez d\'abord une liste : un jeton d\'agence doit en cibler une.',
'apitoken.targets_label': 'Assignées :',
'apitoken.create': 'Créer un jeton',
'apitoken.none': 'Aucun jeton pour le moment.',
'apitoken.col_token': 'Jeton',

View file

@ -357,6 +357,12 @@ export default {
'apitoken.scope_read': 'Somente leitura',
'apitoken.scope_write': 'Leitura e escrita',
'apitoken.scope_full': 'Completo (incl. comandos de dispositivo)',
'apitoken.scope_agency': 'Agência (enviar apenas para listas escolhidas)',
'apitoken.agency_playlists_label': 'Listas às quais este token de agência pode publicar',
'apitoken.agency_playlists_hint': 'O token só pode enviar e adicionar itens com datas a estas listas. As adições ficam como rascunho para você publicar.',
'apitoken.agency_needs_playlists': 'Selecione pelo menos uma lista para um token de agência.',
'apitoken.agency_no_playlists': 'Crie uma lista primeiro: um token de agência deve apontar para uma.',
'apitoken.targets_label': 'Designadas:',
'apitoken.create': 'Criar token',
'apitoken.none': 'Ainda não há tokens.',
'apitoken.col_token': 'Token',

View file

@ -74,10 +74,16 @@ export async function render(container) {
<option value="read">${esc(t('apitoken.scope_read'))}</option>
<option value="write">${esc(t('apitoken.scope_write'))}</option>
<option value="full">${esc(t('apitoken.scope_full'))}</option>
<option value="agency">${esc(t('apitoken.scope_agency'))}</option>
</select>
</div>
<button class="btn btn-primary btn-sm" id="createTokenBtn">${t('apitoken.create')}</button>
</div>
<div id="agencyPlaylistPicker" style="display:none;margin-bottom:16px;padding:12px;border:1px solid var(--border);border-radius:var(--radius);background:var(--bg-secondary)">
<label style="display:block;font-weight:500;margin-bottom:4px">${t('apitoken.agency_playlists_label')}</label>
<p style="color:var(--text-muted);font-size:12px;margin-bottom:8px">${t('apitoken.agency_playlists_hint')}</p>
<div id="agencyPlaylistList" style="display:flex;flex-direction:column;gap:6px;max-height:200px;overflow:auto"></div>
</div>
<div id="tokenSecretBox" style="display:none"></div>
<div id="tokenList"><p style="color:var(--text-muted);font-size:13px">${t('settings.loading_users')}</p></div>
</div>
@ -329,6 +335,7 @@ export async function render(container) {
read: t('apitoken.scope_read'),
write: t('apitoken.scope_write'),
full: t('apitoken.scope_full'),
agency: t('apitoken.scope_agency'),
}[s] || s);
async function loadTokens() {
@ -357,7 +364,10 @@ export async function render(container) {
<tr style="border-bottom:1px solid var(--border)${tok.revoked_at ? ';opacity:0.55' : ''}">
<td style="padding:10px 12px;font-family:monospace">${esc(tok.prefix)}&hellip;</td>
<td style="padding:10px 12px">${esc(tok.name || '')}</td>
<td style="padding:10px 12px">${esc(scopeLabel(tok.scope))}</td>
<td style="padding:10px 12px">${esc(scopeLabel(tok.scope))}${
tok.scope === 'agency' && Array.isArray(tok.targets)
? `<div style="font-size:11px;color:var(--text-muted);margin-top:2px">${t('apitoken.targets_label')} ${tok.targets.length ? tok.targets.map(p => esc(p.name)).join(', ') : '—'}</div>`
: ''}</td>
<td style="padding:10px 12px">${esc(fmtTokenDate(tok.created_at))}</td>
<td style="padding:10px 12px">${tok.last_used_at ? esc(fmtTokenDate(tok.last_used_at)) : t('apitoken.never')}</td>
<td style="padding:10px 12px;white-space:nowrap;text-align:right">
@ -388,13 +398,36 @@ export async function render(container) {
loadTokens();
// #73: agency scope reveals a playlist picker (the token's allowlist). Loaded lazily once.
const tokScopeSel = document.getElementById('tokScope');
let agencyPlaylistsLoaded = false;
tokScopeSel?.addEventListener('change', async () => {
const picker = document.getElementById('agencyPlaylistPicker');
const isAgency = tokScopeSel.value === 'agency';
picker.style.display = isAgency ? 'block' : 'none';
if (isAgency && !agencyPlaylistsLoaded) {
agencyPlaylistsLoaded = true;
const list = document.getElementById('agencyPlaylistList');
const pls = await api.getPlaylists().catch(() => []);
list.innerHTML = pls.length
? pls.map(p => `<label style="display:flex;gap:8px;align-items:center;font-size:13px"><input type="checkbox" class="agency-pl" value="${esc(String(p.id))}"> ${esc(p.name)}</label>`).join('')
: `<p style="color:var(--text-muted);font-size:12px">${t('apitoken.agency_no_playlists')}</p>`;
}
});
document.getElementById('createTokenBtn')?.addEventListener('click', async () => {
const name = document.getElementById('tokName').value.trim();
const scope = document.getElementById('tokScope').value;
const payload = { name, scope };
if (scope === 'agency') {
const ids = [...document.querySelectorAll('#agencyPlaylistList .agency-pl:checked')].map(c => c.value);
if (!ids.length) return showToast(t('apitoken.agency_needs_playlists'), 'error');
payload.target_playlist_ids = ids;
}
const btn = document.getElementById('createTokenBtn');
btn.disabled = true;
try {
const r = await api.createToken({ name, scope });
const r = await api.createToken(payload);
const box = document.getElementById('tokenSecretBox');
box.style.display = 'block';
box.innerHTML = `

View file

@ -19,6 +19,11 @@ router.get('/', (req, res) => {
SELECT id, prefix, name, scope, workspace_id, created_at, last_used_at, revoked_at
FROM api_tokens WHERE user_id = ? AND workspace_id = ? ORDER BY created_at DESC
`).all(req.user.id, req.workspaceId);
// #73: attach designated playlists for agency tokens so the admin sees the binding persist.
const targetsStmt = db.prepare('SELECT p.id, p.name FROM api_token_targets t JOIN playlists p ON p.id = t.playlist_id WHERE t.token_id = ? ORDER BY p.name');
for (const r of rows) {
if (r.scope === 'agency') r.targets = targetsStmt.all(r.id);
}
res.json(rows);
});