diff --git a/frontend/js/i18n/de.js b/frontend/js/i18n/de.js index d4f680a..704482b 100644 --- a/frontend/js/i18n/de.js +++ b/frontend/js/i18n/de.js @@ -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', diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 7efdaa0..f07750f 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -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', diff --git a/frontend/js/i18n/es.js b/frontend/js/i18n/es.js index f722311..ce06355 100644 --- a/frontend/js/i18n/es.js +++ b/frontend/js/i18n/es.js @@ -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', diff --git a/frontend/js/i18n/fr.js b/frontend/js/i18n/fr.js index e7e1c48..d361b67 100644 --- a/frontend/js/i18n/fr.js +++ b/frontend/js/i18n/fr.js @@ -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', diff --git a/frontend/js/i18n/pt.js b/frontend/js/i18n/pt.js index da40f03..641f53d 100644 --- a/frontend/js/i18n/pt.js +++ b/frontend/js/i18n/pt.js @@ -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', diff --git a/frontend/js/views/settings.js b/frontend/js/views/settings.js index 6e0fe02..caa5e87 100644 --- a/frontend/js/views/settings.js +++ b/frontend/js/views/settings.js @@ -74,10 +74,16 @@ export async function render(container) { + +

${t('settings.loading_users')}

@@ -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) { ${esc(tok.prefix)}… ${esc(tok.name || '')} - ${esc(scopeLabel(tok.scope))} + ${esc(scopeLabel(tok.scope))}${ + tok.scope === 'agency' && Array.isArray(tok.targets) + ? `
${t('apitoken.targets_label')} ${tok.targets.length ? tok.targets.map(p => esc(p.name)).join(', ') : '—'}
` + : ''} ${esc(fmtTokenDate(tok.created_at))} ${tok.last_used_at ? esc(fmtTokenDate(tok.last_used_at)) : t('apitoken.never')} @@ -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 => ``).join('') + : `

${t('apitoken.agency_no_playlists')}

`; + } + }); + 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 = ` diff --git a/server/routes/tokens.js b/server/routes/tokens.js index 20436c3..673ea36 100644 --- a/server/routes/tokens.js +++ b/server/routes/tokens.js @@ -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); });